From 6f5c8c4b56012522cf2de627b66a15bbdd0ccf78 Mon Sep 17 00:00:00 2001 From: Jakob Heher Date: Mon, 8 Jul 2024 14:12:39 +0200 Subject: [PATCH 01/73] Signing --- .gitignore | 3 +- CHANGELOG.md | 1 + demoapp/DEVELOPMENT.md | 6 + demoapp/README.MD | 39 ++ demoapp/build.gradle.kts | 20 + demoapp/composeApp/build.gradle.kts | 111 ++++ .../src/androidMain/AndroidManifest.xml | 22 + .../at/asitplus/cryptotest/App.android.kt | 105 ++++ .../cryptotest/theme/Theme.android.kt | 24 + .../kotlin/at/asitplus/cryptotest/App.kt | 551 ++++++++++++++++++ .../at/asitplus/cryptotest/theme/Color.kt | 71 +++ .../at/asitplus/cryptotest/theme/Theme.kt | 129 ++++ .../kotlin/at/asitplus/cryptotest/App.ios.kt | 72 +++ .../at/asitplus/cryptotest/theme/Theme.ios.kt | 17 + demoapp/composeApp/src/iosMain/kotlin/main.kt | 9 + .../kotlin/at/asitplus/cryptotest/App.jvm.kt | 37 ++ .../kotlin/at/asitplus/cryptotest/Main.kt | 11 + .../at/asitplus/cryptotest/theme/Theme.jvm.kt | 8 + demoapp/gradle.properties | 16 + demoapp/gradle/libs.versions.toml | 34 ++ demoapp/gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 59536 bytes .../gradle/wrapper/gradle-wrapper.properties | 6 + demoapp/gradlew | 186 ++++++ demoapp/gradlew.bat | 90 +++ demoapp/img.png | Bin 0 -> 86871 bytes .../iosApp/iosApp.xcodeproj/project.pbxproj | 392 +++++++++++++ .../contents.xcworkspacedata | 7 + .../xcshareddata/IDEWorkspaceChecks.plist | 8 + .../UserInterfaceState.xcuserstate | Bin 0 -> 39121 bytes .../xcschemes/xcschememanagement.plist | 14 + .../AccentColor.colorset/Contents.json | 11 + .../AppIcon.appiconset/Contents.json | 13 + .../iosApp/Assets.xcassets/Contents.json | 6 + .../Preview Assets.xcassets/Contents.json | 6 + demoapp/iosApp/iosApp/iosApp.entitlements | 5 + demoapp/iosApp/iosApp/iosApp.swift | 32 + demoapp/settings.gradle.kts | 34 ++ .../signum/indispensable/CryptoPublicKey.kt | 6 +- .../asitplus/signum/indispensable/Digest.kt | 13 + .../asitplus/signum/indispensable/ECCurve.kt | 6 +- .../indispensable/SignatureAlgorithm.kt | 13 - .../indispensable/X509SignatureAlgorithm.kt | 42 +- .../indispensable/pki/AlternativeNames.kt | 4 +- .../pki/Pkcs10CertificationRequest.kt | 2 +- .../pki/RelativeDistinguishedName.kt | 8 +- .../iosMain/kotlin/CommonCryptoExtensions.kt | 46 ++ .../signum/indispensable/JcaExtensions.kt | 14 +- .../indispensable/DistinguishedNameTest.kt | 6 +- .../Pkcs10CertificationRequestJvmTest.kt | 12 +- .../indispensable/X509CertificateJvmTest.kt | 10 +- supreme/build.gradle.kts | 2 - .../os/AndroidKeyStoreProviderTests.kt | 36 ++ .../provider/os/AndroidKeyStoreProvider.kt | 412 +++++++++++++ .../signum/supreme/sign/VerifierImpl.kt | 12 +- .../at/asitplus/signum/supreme/Throwables.kt | 6 +- .../signum/supreme/dsl/ConfigurationDSL.kt | 39 +- .../asitplus/signum/supreme/dsl/DataTypes.kt | 19 + .../asitplus/signum/supreme/os/Attestation.kt | 60 ++ .../signum/supreme/os/SigningProvider.kt | 141 +++++ .../at/asitplus/signum/supreme/sign/Signer.kt | 130 +++++ .../asitplus/signum/supreme/sign/Verifier.kt | 50 +- .../at/asitplus/signum/supreme/TestUtils.kt | 2 - .../dsl/DSLInheritanceDemonstration.kt | 5 +- .../supreme/dsl/DSLVarianceDemonstration.kt | 5 +- .../asitplus/signum/supreme/InteropUtils.kt | 129 +++- .../signum/supreme/os/IosKeychainProvider.kt | 515 ++++++++++++++++ .../signum/supreme/sign/VerifierImpl.kt | 16 +- supreme/src/iosTest/kotlin/Test.kt | 9 + .../provider/os/iosKeychainProviderTests.kt | 11 + .../signum/supreme/sign/VerifierImpl.kt | 12 +- supreme/src/swift/Krypto.swift | 6 +- 71 files changed, 3738 insertions(+), 157 deletions(-) create mode 100644 demoapp/DEVELOPMENT.md create mode 100644 demoapp/README.MD create mode 100644 demoapp/build.gradle.kts create mode 100644 demoapp/composeApp/build.gradle.kts create mode 100644 demoapp/composeApp/src/androidMain/AndroidManifest.xml create mode 100644 demoapp/composeApp/src/androidMain/kotlin/at/asitplus/cryptotest/App.android.kt create mode 100644 demoapp/composeApp/src/androidMain/kotlin/at/asitplus/cryptotest/theme/Theme.android.kt create mode 100644 demoapp/composeApp/src/commonMain/kotlin/at/asitplus/cryptotest/App.kt create mode 100644 demoapp/composeApp/src/commonMain/kotlin/at/asitplus/cryptotest/theme/Color.kt create mode 100644 demoapp/composeApp/src/commonMain/kotlin/at/asitplus/cryptotest/theme/Theme.kt create mode 100644 demoapp/composeApp/src/iosMain/kotlin/at/asitplus/cryptotest/App.ios.kt create mode 100644 demoapp/composeApp/src/iosMain/kotlin/at/asitplus/cryptotest/theme/Theme.ios.kt create mode 100644 demoapp/composeApp/src/iosMain/kotlin/main.kt create mode 100644 demoapp/composeApp/src/jvmMain/kotlin/at/asitplus/cryptotest/App.jvm.kt create mode 100644 demoapp/composeApp/src/jvmMain/kotlin/at/asitplus/cryptotest/Main.kt create mode 100644 demoapp/composeApp/src/jvmMain/kotlin/at/asitplus/cryptotest/theme/Theme.jvm.kt create mode 100644 demoapp/gradle.properties create mode 100644 demoapp/gradle/libs.versions.toml create mode 100644 demoapp/gradle/wrapper/gradle-wrapper.jar create mode 100644 demoapp/gradle/wrapper/gradle-wrapper.properties create mode 100755 demoapp/gradlew create mode 100644 demoapp/gradlew.bat create mode 100644 demoapp/img.png create mode 100644 demoapp/iosApp/iosApp.xcodeproj/project.pbxproj create mode 100644 demoapp/iosApp/iosApp.xcodeproj/project.xcworkspace/contents.xcworkspacedata create mode 100644 demoapp/iosApp/iosApp.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist create mode 100644 demoapp/iosApp/iosApp.xcodeproj/project.xcworkspace/xcuserdata/bpruenster.xcuserdatad/UserInterfaceState.xcuserstate create mode 100644 demoapp/iosApp/iosApp.xcodeproj/xcuserdata/bpruenster.xcuserdatad/xcschemes/xcschememanagement.plist create mode 100644 demoapp/iosApp/iosApp/Assets.xcassets/AccentColor.colorset/Contents.json create mode 100644 demoapp/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 demoapp/iosApp/iosApp/Assets.xcassets/Contents.json create mode 100644 demoapp/iosApp/iosApp/Preview Content/Preview Assets.xcassets/Contents.json create mode 100644 demoapp/iosApp/iosApp/iosApp.entitlements create mode 100644 demoapp/iosApp/iosApp/iosApp.swift create mode 100644 demoapp/settings.gradle.kts create mode 100644 indispensable/src/iosMain/kotlin/CommonCryptoExtensions.kt create mode 100644 supreme/src/androidInstrumentedTest/kotlin/at/asitplus/signum/supreme/os/AndroidKeyStoreProviderTests.kt create mode 100644 supreme/src/androidMain/kotlin/at/asitplus/crypto/provider/os/AndroidKeyStoreProvider.kt create mode 100644 supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/dsl/DataTypes.kt create mode 100644 supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/os/Attestation.kt create mode 100644 supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/os/SigningProvider.kt create mode 100644 supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/sign/Signer.kt create mode 100644 supreme/src/iosMain/kotlin/at/asitplus/signum/supreme/os/IosKeychainProvider.kt create mode 100644 supreme/src/iosTest/kotlin/Test.kt create mode 100644 supreme/src/iosTest/kotlin/at/asitplus/crypto/provider/os/iosKeychainProviderTests.kt diff --git a/.gitignore b/.gitignore index a1f44262..9ec54fce 100644 --- a/.gitignore +++ b/.gitignore @@ -1,11 +1,12 @@ */generated .gradle +demoapp/build build/ !gradle/wrapper/gradle-wrapper.jar !**/src/main/**/build/ !**/src/test/**/build/ .kotlin -provider/src/androidInstrumentedTest/kotlin/generated +*/src/androidInstrumentedTest/kotlin/generated ### IntelliJ IDEA ### .idea diff --git a/CHANGELOG.md b/CHANGELOG.md index c6035854..8c030bbe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ## 3.0 ### NEXT +* Implement supreme signing capabilities ### 3.6.1 * Externalise `UVarInt` to multibase diff --git a/demoapp/DEVELOPMENT.md b/demoapp/DEVELOPMENT.md new file mode 100644 index 00000000..a2a1753e --- /dev/null +++ b/demoapp/DEVELOPMENT.md @@ -0,0 +1,6 @@ +**REQUIRES a MacOS Host to build all modules** + +* recursively clone this repo +* set `sdk.dir=/absulute/path/to/Android/sdk` inside `kmp-crypto/local.properties` +* `cd kmp-crypto && ./gradlew publishAllPublicationsToLocalRepository` +* import the this project into Android studio \ No newline at end of file diff --git a/demoapp/README.MD b/demoapp/README.MD new file mode 100644 index 00000000..d0c3a164 --- /dev/null +++ b/demoapp/README.MD @@ -0,0 +1,39 @@ +# KMP Crypto Demo Multiplatform (iOS, Android JVM) App + + +![img.png](img.png) + +This app showcases the KMP Crypto provider on the desktop, on Android and on iOS. +It is possible to generate key pairs, sign data, and verify the signature. + +On iOS, only P-256 keys can be attested. +The JVM does not support attestation. + +`*** TODO CLEANUP BELOW THIS LINE ***` + +Android and iOS support mandatory authentication for key usage. While somewhat similar functionality can be achieved on both platforms, iOS comes with some peculiarities. +Most prominently: It is possible to specify a max validity duration of an authentication context. This property, however, has jwf semantics, since a once authenticated LAContext will remain in this state for eternety. +Hence, reauthentication needs to be implemented manually. + +The BiometricAuthAdapter on Android requires some more polishing, but it should clearly convey the underlying idea even in its current state. + +## Before running! + - check your system with [KDoctor](https://github.com/Kotlin/kdoctor) + - install JDK 17 on your machine + - add `local.properties` file to the project root and set a path to Android SDK there + +### Android +To run the application on android device/emulator: + - open project in Android Studio and run imported android run configuration + +To build the application bundle: + - run `./gradlew :composeApp:assembleDebug` + - find `.apk` file in `composeApp/build/outputs/apk/debug/composeApp-debug.apk` + +### iOS +To run the application on iPhone device/simulator: + - Open `iosApp/iosApp.xcproject` in Xcode and run standard configuration + - Or use [Kotlin Multiplatform Mobile plugin](https://plugins.jetbrains.com/plugin/14936-kotlin-multiplatform-mobile) for Android Studio + + + diff --git a/demoapp/build.gradle.kts b/demoapp/build.gradle.kts new file mode 100644 index 00000000..1f24cf56 --- /dev/null +++ b/demoapp/build.gradle.kts @@ -0,0 +1,20 @@ +plugins { + alias(libs.plugins.multiplatform).apply(false) + alias(libs.plugins.compose).apply(false) + alias(libs.plugins.android.application).apply(false) + alias(libs.plugins.buildConfig).apply(false) + // id("at.asitplus.gradle.conventions") version "1.9.23+20240319+1" +} + +allprojects { + repositories { + maven(rootProject.projectDir.absolutePath+"/kmp-crypto/repo") + maven(uri("https://raw.githubusercontent.com/a-sit-plus/kotlinx.serialization/mvn/repo")) + mavenCentral() + google() + maven { + url = uri("https://oss.sonatype.org/content/repositories/snapshots") + name = "bigNum" + } + } +} \ No newline at end of file diff --git a/demoapp/composeApp/build.gradle.kts b/demoapp/composeApp/build.gradle.kts new file mode 100644 index 00000000..b8f9c4c1 --- /dev/null +++ b/demoapp/composeApp/build.gradle.kts @@ -0,0 +1,111 @@ +import com.android.build.api.dsl.Packaging + +plugins { + alias(libs.plugins.multiplatform) + alias(libs.plugins.compose) + alias(libs.plugins.android.application) + alias(libs.plugins.buildConfig) +} + +kotlin { + jvm() + jvmToolchain(17) + androidTarget { + compilations.all { + kotlinOptions { + jvmTarget = "17" + } + } + } + + listOf( + iosX64(), + iosArm64(), + iosSimulatorArm64() + ).forEach { + it.binaries.framework { + baseName = "ComposeApp" + isStatic = true + } + } + + sourceSets { + all { + languageSettings { + optIn("org.jetbrains.compose.resources.ExperimentalResourceApi") + } + } + commonMain.dependencies { + implementation("at.asitplus.signum:supreme:0.0.2-SNAPSHOT") { + isChanging = true + } + implementation(compose.runtime) + implementation(compose.material3) + implementation(compose.materialIconsExtended) + @OptIn(org.jetbrains.compose.ExperimentalComposeLibrary::class) + implementation(compose.components.resources) + implementation(libs.voyager.navigator) + implementation(libs.composeImageLoader) + implementation(libs.napier) + implementation(libs.kotlinx.coroutines.core) + } + + commonTest.dependencies { + implementation(kotlin("test")) + } + + androidMain.dependencies { + implementation(libs.androidx.appcompat) + implementation(libs.androidx.activityCompose) + implementation(libs.compose.uitooling) + implementation(libs.kotlinx.coroutines.android) + implementation(libs.androidx.biometric) + } + + + jvmMain.dependencies { + implementation(compose.desktop.currentOs) + } + + } +} + +android { + namespace = "at.asitplus.cryptotest" + compileSdk = 34 + + defaultConfig { + minSdk = 33 + targetSdk = 34 + + applicationId = "at.asitplus.cryptotest.androidApp" + versionCode = 1 + versionName = "1.0.0" + } + sourceSets["main"].apply { + manifest.srcFile("src/androidMain/AndroidManifest.xml") + res.srcDirs("src/androidMain/resources") + resources.srcDirs("src/commonMain/resources") + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + buildFeatures { + compose = true + } + composeOptions { + kotlinCompilerExtensionVersion = "1.5.4" + } + buildToolsVersion = "30.0.3" + + packaging { + resources.excludes.add("META-INF/versions/9/OSGI-INF/MANIFEST.MF") + } +} + +buildConfig { + // BuildConfig configuration here. + // https://github.com/gmazzo/gradle-buildconfig-plugin#usage-in-kts +} + diff --git a/demoapp/composeApp/src/androidMain/AndroidManifest.xml b/demoapp/composeApp/src/androidMain/AndroidManifest.xml new file mode 100644 index 00000000..9a3c04ef --- /dev/null +++ b/demoapp/composeApp/src/androidMain/AndroidManifest.xml @@ -0,0 +1,22 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/demoapp/composeApp/src/androidMain/kotlin/at/asitplus/cryptotest/App.android.kt b/demoapp/composeApp/src/androidMain/kotlin/at/asitplus/cryptotest/App.android.kt new file mode 100644 index 00000000..9fd94b6c --- /dev/null +++ b/demoapp/composeApp/src/androidMain/kotlin/at/asitplus/cryptotest/App.android.kt @@ -0,0 +1,105 @@ +package at.asitplus.cryptotest + +import android.app.Application +import android.os.Bundle +import androidx.activity.compose.setContent +import androidx.compose.ui.platform.LocalContext +import androidx.fragment.app.FragmentActivity +import at.asitplus.signum.supreme.os.AndroidKeyStoreProvider +import at.asitplus.signum.supreme.os.SigningProvider + + +class AndroidApp : Application() { + companion object { + lateinit var INSTANCE: AndroidApp + } + + override fun onCreate() { + super.onCreate() + INSTANCE = this + } +} + +private lateinit var fragmentActivity: FragmentActivity + +class AppActivity : FragmentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContent { + App() + fragmentActivity = LocalContext.current as FragmentActivity + } + } +} + +internal actual fun getSystemKeyStore(): SigningProvider = + AndroidKeyStoreProvider(fragmentActivity) + +/*internal actual suspend fun generateKey( + alg: CryptoAlgorithm, + attestation: ByteArray?, + withBiometricAuth: Duration? +): KmmResult { + val opsForUse = AndroidSpecificCryptoOps( + keyGenCustomization = { + withBiometricAuth?.also { + setUserAuthenticationRequired(true) + setUserAuthenticationParameters( + it.inWholeSeconds.toInt(), + KeyProperties.AUTH_BIOMETRIC_STRONG + ) + } ?: setUserAuthenticationRequired(false) + }) + return if (attestation == null) CryptoProvider.createSigningKey( + ALIAS, + alg, + opsForUse, + ).map { it to listOf() } + else { + CryptoProvider.createTbaP256Key( + ALIAS, + attestation, + opsForUse + ) + } +} + +fun setupBiometric(): AndroidSpecificCryptoOps.BiometricAuth { + val promptInfo = BiometricPrompt.PromptInfo.Builder() + .setTitle("Biometric Auth") + .setSubtitle("Authenticate private key usage") + .setNegativeButtonText("Abort") + .setAllowedAuthenticators(BIOMETRIC_STRONG) + .build() + + val biometricPrompt = BiometricPromptAdapter( + fragmentActivity!!, + executor!! + ) + return AndroidSpecificCryptoOps.BiometricAuth(promptInfo, biometricPrompt) +} + +internal actual suspend fun sign( + data: ByteArray, + alg: CryptoAlgorithm, + signingKey: CryptoPrivateKey +): KmmResult { + if (biometricPrompt == null) + biometricPrompt = setupBiometric() + (signingKey.platformSpecifics as AndroidSpecificCryptoOps).attachAuthenticationHandler { biometricPrompt!! } + return CryptoProvider.sign( + data, + signingKey, + alg + ) +} + +internal actual suspend fun loadPubKey() = CryptoProvider.getPublicKey(ALIAS) +internal actual suspend fun loadPrivateKey() = + CryptoProvider.getKeyPair(ALIAS, AndroidSpecificCryptoOps()) + +internal actual suspend fun storeCertChain(): KmmResult = + CryptoProvider.storeCertificateChain(ALIAS + "CRT_CHAIN", SAMPLE_CERT_CHAIN) + +internal actual suspend fun getCertChain(): KmmResult> = + CryptoProvider.getCertificateChain(ALIAS + "CRT_CHAIN")*/ \ No newline at end of file diff --git a/demoapp/composeApp/src/androidMain/kotlin/at/asitplus/cryptotest/theme/Theme.android.kt b/demoapp/composeApp/src/androidMain/kotlin/at/asitplus/cryptotest/theme/Theme.android.kt new file mode 100644 index 00000000..1fc8ec90 --- /dev/null +++ b/demoapp/composeApp/src/androidMain/kotlin/at/asitplus/cryptotest/theme/Theme.android.kt @@ -0,0 +1,24 @@ +package at.asitplus.cryptotest.theme + +import android.app.Activity +import android.graphics.Color +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.platform.LocalView +import androidx.core.view.WindowCompat + +@Composable +internal actual fun SystemAppearance(isDark: Boolean) { + val view = LocalView.current + val systemBarColor = Color.TRANSPARENT + LaunchedEffect(isDark) { + val window = (view.context as Activity).window + WindowCompat.setDecorFitsSystemWindows(window, false) + window.statusBarColor = systemBarColor + window.navigationBarColor = systemBarColor + WindowCompat.getInsetsController(window, window.decorView).apply { + isAppearanceLightStatusBars = isDark + isAppearanceLightNavigationBars = isDark + } + } +} \ No newline at end of file diff --git a/demoapp/composeApp/src/commonMain/kotlin/at/asitplus/cryptotest/App.kt b/demoapp/composeApp/src/commonMain/kotlin/at/asitplus/cryptotest/App.kt new file mode 100644 index 00000000..c63f0414 --- /dev/null +++ b/demoapp/composeApp/src/commonMain/kotlin/at/asitplus/cryptotest/App.kt @@ -0,0 +1,551 @@ +package at.asitplus.cryptotest + +import androidx.compose.foundation.ScrollState +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.DarkMode +import androidx.compose.material.icons.filled.LightMode +import androidx.compose.material3.Button +import androidx.compose.material3.Checkbox +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import at.asitplus.KmmResult +import at.asitplus.signum.indispensable.CryptoSignature +import at.asitplus.signum.indispensable.ECCurve +import at.asitplus.signum.indispensable.RSAPadding +import at.asitplus.signum.indispensable.SignatureAlgorithm +import at.asitplus.signum.indispensable.SpecializedSignatureAlgorithm +import at.asitplus.signum.indispensable.X509SignatureAlgorithm +import at.asitplus.signum.indispensable.nativeDigest +import at.asitplus.signum.indispensable.pki.X509Certificate +import at.asitplus.signum.supreme.dsl.PREFERRED +import at.asitplus.signum.supreme.os.SignerConfiguration +import at.asitplus.signum.supreme.os.SigningProvider +import at.asitplus.signum.supreme.sign.Signer +import at.asitplus.signum.supreme.sign.makeVerifier +import at.asitplus.signum.supreme.sign.sign +import at.asitplus.signum.supreme.sign.verify +import at.asitplus.cryptotest.theme.AppTheme +import at.asitplus.cryptotest.theme.LocalThemeIsDark +import io.github.aakira.napier.DebugAntilog +import io.github.aakira.napier.Napier +import io.ktor.util.decodeBase64Bytes +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.launch +import kotlinx.coroutines.newSingleThreadContext +import kotlin.random.Random +import kotlin.reflect.KProperty +import kotlin.time.Duration.Companion.seconds + +val SAMPLE_CERT_CHAIN = listOf( + "MIIDljCCAxygAwIBAgISBAkE/SHlMi5J8uQGoGCZBnhSMAoGCCqGSM49BAMDMDIx\n" + + "CzAJBgNVBAYTAlVTMRYwFAYDVQQKEw1MZXQncyBFbmNyeXB0MQswCQYDVQQDEwJF\n" + + "MTAeFw0yNDAzMTMyMDQ2MjZaFw0yNDA2MTEyMDQ2MjVaMBwxGjAYBgNVBAMTEXN0\n" + + "YWNrb3ZlcmZsb3cuY29tMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAENMSrkEQf\n" + + "2x8dEAh73snPfgxMIK+VYUyIIYA+NuRhhyZuL2ZV9N4ZUibe/eEad3Y8HND3Kuz/\n" + + "2vxFzJvR8nlKSqOCAiYwggIiMA4GA1UdDwEB/wQEAwIHgDAdBgNVHSUEFjAUBggr\n" + + "BgEFBQcDAQYIKwYBBQUHAwIwDAYDVR0TAQH/BAIwADAdBgNVHQ4EFgQUeQJ7DtZq\n" + + "02WUcs0cMmOa/eJEuxcwHwYDVR0jBBgwFoAUWvPtK/w2wjd5uVIw6lRvz1XLLqww\n" + + "VQYIKwYBBQUHAQEESTBHMCEGCCsGAQUFBzABhhVodHRwOi8vZTEuby5sZW5jci5v\n" + + "cmcwIgYIKwYBBQUHMAKGFmh0dHA6Ly9lMS5pLmxlbmNyLm9yZy8wMQYDVR0RBCow\n" + + "KIITKi5zdGFja292ZXJmbG93LmNvbYIRc3RhY2tvdmVyZmxvdy5jb20wEwYDVR0g\n" + + "BAwwCjAIBgZngQwBAgEwggECBgorBgEEAdZ5AgQCBIHzBIHwAO4AdQA7U3d1Pi25\n" + + "gE6LMFsG/kA7Z9hPw/THvQANLXJv4frUFwAAAY45x+icAAAEAwBGMEQCICqwZ2ic\n" + + "dHGogPX6/nRhsJ2AMWROA2MkZ+zZ/8dvzaCoAiBDqexmj0syXLpaCAhZ7Jjps+QN\n" + + "UHsHX8F/VE2eQ4fmdAB1AEiw42vapkc0D+VqAvqdMOscUgHLVt0sgdm7v6s52IRz\n" + + "AAABjjnH6KcAAAQDAEYwRAIgRB4bHal+3msYGbblbfHhWcVm+95f7fkEWQabASE2\n" + + "qycCIFJ/P1mixU1zSN6L/hZSvP8RTgUxy/xvbfrcF8giDNA/MAoGCCqGSM49BAMD\n" + + "A2gAMGUCMDe8nbCNF3evyvyGNxKOaScHhZ9ScGi5zeEo4ogiY6f25FV3wzfE2enB\n" + + "3QUOvZLJbgIxAIc//kc6UgMSKC+FNL3LM3c4avx9jaKZwUvlcOvxrSExYvnmxqrA\n" + + "jC2PPx8F/hF+ww==", + "MIICxjCCAk2gAwIBAgIRALO93/inhFu86QOgQTWzSkUwCgYIKoZIzj0EAwMwTzEL\n" + + "MAkGA1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2VhcmNo\n" + + "IEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDIwHhcNMjAwOTA0MDAwMDAwWhcN\n" + + "MjUwOTE1MTYwMDAwWjAyMQswCQYDVQQGEwJVUzEWMBQGA1UEChMNTGV0J3MgRW5j\n" + + "cnlwdDELMAkGA1UEAxMCRTEwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAAQkXC2iKv0c\n" + + "S6Zdl3MnMayyoGli72XoprDwrEuf/xwLcA/TmC9N/A8AmzfwdAVXMpcuBe8qQyWj\n" + + "+240JxP2T35p0wKZXuskR5LBJJvmsSGPwSSB/GjMH2m6WPUZIvd0xhajggEIMIIB\n" + + "BDAOBgNVHQ8BAf8EBAMCAYYwHQYDVR0lBBYwFAYIKwYBBQUHAwIGCCsGAQUFBwMB\n" + + "MBIGA1UdEwEB/wQIMAYBAf8CAQAwHQYDVR0OBBYEFFrz7Sv8NsI3eblSMOpUb89V\n" + + "yy6sMB8GA1UdIwQYMBaAFHxClq7eS0g7+pL4nozPbYupcjeVMDIGCCsGAQUFBwEB\n" + + "BCYwJDAiBggrBgEFBQcwAoYWaHR0cDovL3gyLmkubGVuY3Iub3JnLzAnBgNVHR8E\n" + + "IDAeMBygGqAYhhZodHRwOi8veDIuYy5sZW5jci5vcmcvMCIGA1UdIAQbMBkwCAYG\n" + + "Z4EMAQIBMA0GCysGAQQBgt8TAQEBMAoGCCqGSM49BAMDA2cAMGQCMHt01VITjWH+\n" + + "Dbo/AwCd89eYhNlXLr3pD5xcSAQh8suzYHKOl9YST8pE9kLJ03uGqQIwWrGxtO3q\n" + + "YJkgsTgDyj2gJrjubi1K9sZmHzOa25JK1fUpE8ZwYii6I4zPPS/Lgul/", + "MIICGzCCAaGgAwIBAgIQQdKd0XLq7qeAwSxs6S+HUjAKBggqhkjOPQQDAzBPMQsw\n" + + "CQYDVQQGEwJVUzEpMCcGA1UEChMgSW50ZXJuZXQgU2VjdXJpdHkgUmVzZWFyY2gg\n" + + "R3JvdXAxFTATBgNVBAMTDElTUkcgUm9vdCBYMjAeFw0yMDA5MDQwMDAwMDBaFw00\n" + + "MDA5MTcxNjAwMDBaME8xCzAJBgNVBAYTAlVTMSkwJwYDVQQKEyBJbnRlcm5ldCBT\n" + + "ZWN1cml0eSBSZXNlYXJjaCBHcm91cDEVMBMGA1UEAxMMSVNSRyBSb290IFgyMHYw\n" + + "EAYHKoZIzj0CAQYFK4EEACIDYgAEzZvVn4CDCuwJSvMWSj5cz3es3mcFDR0HttwW\n" + + "+1qLFNvicWDEukWVEYmO6gbf9yoWHKS5xcUy4APgHoIYOIvXRdgKam7mAHf7AlF9\n" + + "ItgKbppbd9/w+kHsOdx1ymgHDB/qo0IwQDAOBgNVHQ8BAf8EBAMCAQYwDwYDVR0T\n" + + "AQH/BAUwAwEB/zAdBgNVHQ4EFgQUfEKWrt5LSDv6kviejM9ti6lyN5UwCgYIKoZI\n" + + "zj0EAwMDaAAwZQIwe3lORlCEwkSHRhtFcP9Ymd70/aTSVaYgLXTWNLxBo1BfASdW\n" + + "tL4ndQavEi51mI38AjEAi/V3bNTIZargCyzuFJ0nN6T5U6VR5CmD1/iQMVtCnwr1\n" + + "/q4AaOeMSQ+2b1tbFfLn" +).map { X509Certificate.decodeFromDer(it.replace("\n", "").decodeBase64Bytes()) } + + +const val ALIAS = "Bartschlüssel" +val SIGNER_CONFIG: (SignerConfiguration.()->Unit) = { + unlockPrompt { + message = "We're signing a thing!" + cancelText = "No! Stop!" + } + rsa { + padding = RSAPadding.PKCS1 + } +} + +val context = newSingleThreadContext("crypto").also { Napier.base(DebugAntilog()) } + +private class getter(private val fn: ()->T) { + operator fun getValue(nothing: Nothing?, property: KProperty<*>): T = fn() +} + +@OptIn(ExperimentalStdlibApi::class, ExperimentalCoroutinesApi::class) +@Composable +internal fun App() { + + AppTheme { + var attestation by remember { mutableStateOf(false) } + var biometricAuth by remember { mutableStateOf(" Disabled") } + val algos = listOf( + X509SignatureAlgorithm.ES256, + X509SignatureAlgorithm.ES384, + X509SignatureAlgorithm.ES512, + X509SignatureAlgorithm.RS1, + X509SignatureAlgorithm.RS256, + X509SignatureAlgorithm.RS384, + X509SignatureAlgorithm.RS512) + var keyAlgorithm by remember { mutableStateOf(X509SignatureAlgorithm.ES256) } + var inputData by remember { mutableStateOf("Foo") } + var currentSigner by remember { mutableStateOf?>(null) } + val currentKey by getter { currentSigner?.mapCatching(Signer::publicKey) } + val currentKeyStr by getter { + currentKey?.fold(onSuccess = { + it.toString() + }, + onFailure = { + Napier.e("Key failed", it) + "${it::class.simpleName ?: ""}: ${it.message}" + }) ?: "" + } + val currentAttestation by getter { (currentSigner?.getOrNull() as? Signer.Attestable<*>)?.attestation } + val currentAttestationStr by getter { currentAttestation?.jsonEncoded ?: "" } + val signingPossible by getter { currentKey?.isSuccess == true } + var signatureData by remember { mutableStateOf?>(null) } + val signatureDataStr by getter { + signatureData?.fold(onSuccess = Any::toString) { + Napier.e("Signature failed", it) + "${it::class.simpleName ?: ""}: ${it.message}" + } ?: "" + } + val verifyPossible by getter { signatureData?.isSuccess == true } + var verifyState by remember { mutableStateOf?>(null) } + val verifySucceededStr by getter { + verifyState?.fold(onSuccess = { + "Verify OK!" + }, onFailure = { + "${it::class.simpleName ?: ""}: ${it.message}" + }) ?: " " + } + var canGenerate by remember { mutableStateOf(true) } + + var genTextOverride by remember { mutableStateOf(null) } + val genText by getter { genTextOverride ?: "Generate"} + + Column(modifier = Modifier.fillMaxSize().verticalScroll(ScrollState(0), enabled = true).windowInsetsPadding(WindowInsets.safeDrawing)) { + + Row( + horizontalArrangement = Arrangement.Center + ) { + Text( + text = "KMP Crypto Demo", + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding( + top = 16.dp, + start = 16.dp, + end = 16.dp, + bottom = 0.dp + ) + ) + + Spacer(modifier = Modifier.weight(1.0f)) + + var isDark by LocalThemeIsDark.current + IconButton( + onClick = { isDark = !isDark } + ) { + Icon( + modifier = Modifier.padding(8.dp).size(20.dp), + imageVector = if (isDark) Icons.Default.LightMode else Icons.Default.DarkMode, + contentDescription = null + ) + } + } + + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Row { + Text( + "Attestation", + modifier = Modifier.padding(top = 11.dp) + ) + Checkbox(checked = attestation, + modifier = Modifier.wrapContentSize(Alignment.TopStart).padding(0.dp), + onCheckedChange = { + attestation = it + }) + } + Row { + Text( + "Biometric Auth", + modifier = Modifier.padding( + start = 0.dp, + top = 12.dp, + end = 4.dp, + bottom = 0.dp + ) + + + ) + + var expanded by remember { mutableStateOf(false) } + Box( + modifier = Modifier.wrapContentSize(Alignment.TopStart).padding(top = 12.dp) + .background(MaterialTheme.colorScheme.primary) + ) { + + Text( + biometricAuth, + modifier = Modifier.align(Alignment.BottomStart).width(78.dp) + .clickable(onClick = { + expanded = true + + }), + color = MaterialTheme.colorScheme.onPrimary + + ) + DropdownMenu( + expanded = expanded, + onDismissRequest = { + expanded = false + }, + modifier = Modifier.fillMaxWidth() + ) { + listOf( + " Disabled", + " 0s", + " 10s", + " 20s", + " 60s" + ).forEachIndexed { _, s -> + DropdownMenuItem(text = { Text(text = s) }, + onClick = { + expanded = false + biometricAuth = s + }) + } + } + } + } + } + + Row( + modifier = Modifier.fillMaxWidth(), + ) { + Text("Key Type", modifier = Modifier.padding(horizontal = 16.dp)) + var expanded by remember { mutableStateOf(false) } + val displayedKeySize by getter { (if (expanded) " ▲ " else " ▼ ") + keyAlgorithm } + Box( + modifier = Modifier.fillMaxWidth().wrapContentSize(Alignment.TopStart) + .padding(horizontal = 16.dp).background(MaterialTheme.colorScheme.primary) + ) { + + Text( + displayedKeySize, + modifier = Modifier.fillMaxWidth().align(Alignment.TopStart) + .clickable(onClick = { + expanded = true + }), + color = MaterialTheme.colorScheme.onPrimary + + ) + DropdownMenu( + expanded = expanded, + onDismissRequest = { + expanded = false + }, + modifier = Modifier.fillMaxWidth() + ) { + algos.forEachIndexed { index, s -> + DropdownMenuItem(text = { Text(text = s.toString()) }, + onClick = { + keyAlgorithm = algos[index] + expanded = false + }) + } + } + } + } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Button( + enabled = canGenerate, + onClick = { + CoroutineScope(context).launch { + canGenerate = false + genTextOverride = "Creating…" + currentSigner = getSystemKeyStore().createSigningKey(ALIAS) { + signer(SIGNER_CONFIG) + + when (val alg = keyAlgorithm.algorithm) { + is SignatureAlgorithm.ECDSA -> { + this@createSigningKey.ec { + curve = alg.requiredCurve ?: + ECCurve.entries.find { it.nativeDigest == alg.digest }!! + digests = setOf(alg.digest) + } + } + is SignatureAlgorithm.RSA -> { + this@createSigningKey.rsa { + digests = setOf(alg.digest) + bits = 1024 + } + } + else -> TODO("unreachable") + } + + val timeout = runCatching { + biometricAuth.substringBefore("s").trim().toInt() + }.getOrNull() + + if (attestation || timeout != null) { + hardware { + backing = PREFERRED + if (attestation) { + attestation { + challenge = Random.nextBytes(16) + } + } + + if (timeout != null) { + protection { + this.timeout = timeout.seconds + factors { + biometry = true + deviceLock = true + } + } + } + } + } + } + verifyState = null + + Napier.w { "created signing key! $currentSigner" } + Napier.w { "Signing possible: ${currentKey?.isSuccess}" } + canGenerate = true + genTextOverride = null + } + }, + modifier = Modifier.padding(start = 16.dp) + ) { + Text(genText) + } + + Button( + enabled = canGenerate, + onClick = { + CoroutineScope(context).launch { + canGenerate = false + genTextOverride = "Loading…" + getSystemKeyStore().getSignerForKey(ALIAS, SIGNER_CONFIG).let { + Napier.w { "Priv retrieved from native: $it" } + currentSigner = it + verifyState = null + } + + //just to check + //loadPubKey().let { Napier.w { "PubKey retrieved from native: $it" } } + canGenerate = true + genTextOverride = null + } + }, + modifier = Modifier.padding(start = 16.dp, end = 16.dp) + ) { + Text("Load") + } + + Button( + enabled = canGenerate, + onClick = { + CoroutineScope(context).launch { + canGenerate = false + genTextOverride = "Deleting…" + try { + getSystemKeyStore().deleteSigningKey(ALIAS) + } catch (e: Throwable) { + Napier.e("Failed to delete key", e) + } + currentSigner = null + signatureData = null + verifyState = null + canGenerate = true + genTextOverride = null + } + }, + modifier = Modifier.padding(end = 16.dp) + ) { + Text("Delete") + } + + } + OutlinedTextField(value = currentKeyStr, + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), + minLines = 1, + maxLines = 5, + textStyle = TextStyle.Default.copy(fontSize = 10.sp), + readOnly = true, onValueChange = {}, label = { Text("Current Key") }) + + + OutlinedTextField(value = inputData, + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), + enabled = true, + minLines = 1, + maxLines = 2, + textStyle = TextStyle.Default.copy(fontSize = 10.sp), + onValueChange = { inputData = it; verifyState = null }, + label = { Text("Data to be signed") }) + + Button( + onClick = { + + Napier.w { "input: $inputData" } + Napier.w { "signingKey: $currentKey" } + CoroutineScope(context).launch { + val data = inputData.encodeToByteArray() + currentSigner!! + .transform { it.sign(data) } + .also { signatureData = it; verifyState = null } + } + + }, + + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), + enabled = signingPossible + ) { + Text("Sign") + } + + if (signatureData != null) { + OutlinedTextField(value = signatureDataStr, + modifier = Modifier.fillMaxWidth().padding(16.dp), + minLines = 1, + textStyle = TextStyle.Default.copy(fontSize = 10.sp), + readOnly = true, onValueChange = {}, label = { Text("Detached Signature") }) + } + + if (verifyPossible) { + Button( + onClick = { + CoroutineScope(context).launch { + val signer = currentSigner!!.getOrThrow() + val data = inputData.encodeToByteArray() + val sig = signatureData!!.getOrThrow() + signer.makeVerifier() + .transform { it.verify(data, sig) } + .also { verifyState = it } + } + }, + + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), + enabled = verifyPossible + ) { + Text("Verify") + } + } + + if (verifyState != null) { + OutlinedTextField(value = verifySucceededStr, + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), + minLines = 1, + textStyle = TextStyle.Default.copy(fontSize = 10.sp), + readOnly = true, + onValueChange = {}, + label = { Text("Verification Result") }) + } + + if (currentAttestation != null) { + OutlinedTextField(value = currentAttestationStr, + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), + minLines = 1, + textStyle = TextStyle.Default.copy(fontSize = 10.sp), + readOnly = true, + onValueChange = {}, + label = { Text("Key Attestation") }) + } + } + } +} + +internal expect fun getSystemKeyStore(): SigningProvider + +/*internal expect suspend fun generateKey( + alg: CryptoAlgorithm, + attestation: ByteArray?, + withBiometricAuth: Duration?, + + ): KmmResult + +internal expect suspend fun sign( + data: ByteArray, + alg: CryptoAlgorithm, + signingKey: CryptoPrivateKey +): KmmResult + +internal expect suspend fun loadPubKey(): KmmResult + +internal expect suspend fun loadPrivateKey(): KmmResult + +internal expect suspend fun storeCertChain(): KmmResult +internal expect suspend fun getCertChain(): KmmResult>*/ \ No newline at end of file diff --git a/demoapp/composeApp/src/commonMain/kotlin/at/asitplus/cryptotest/theme/Color.kt b/demoapp/composeApp/src/commonMain/kotlin/at/asitplus/cryptotest/theme/Color.kt new file mode 100644 index 00000000..ec42862e --- /dev/null +++ b/demoapp/composeApp/src/commonMain/kotlin/at/asitplus/cryptotest/theme/Color.kt @@ -0,0 +1,71 @@ +package at.asitplus.cryptotest.theme + +import androidx.compose.ui.graphics.Color + +//generated by https://m3.material.io/theme-builder#/custom +//Color palette was taken here: https://colorhunt.co/palettes/popular + +internal val md_theme_light_primary = Color(0xFF00687A) +internal val md_theme_light_onPrimary = Color(0xFFFFFFFF) +internal val md_theme_light_primaryContainer = Color(0xFFABEDFF) +internal val md_theme_light_onPrimaryContainer = Color(0xFF001F26) +internal val md_theme_light_secondary = Color(0xFF00696E) +internal val md_theme_light_onSecondary = Color(0xFFFFFFFF) +internal val md_theme_light_secondaryContainer = Color(0xFF6FF6FE) +internal val md_theme_light_onSecondaryContainer = Color(0xFF002022) +internal val md_theme_light_tertiary = Color(0xFF904D00) +internal val md_theme_light_onTertiary = Color(0xFFFFFFFF) +internal val md_theme_light_tertiaryContainer = Color(0xFFFFDCC2) +internal val md_theme_light_onTertiaryContainer = Color(0xFF2E1500) +internal val md_theme_light_error = Color(0xFFBA1A1A) +internal val md_theme_light_errorContainer = Color(0xFFFFDAD6) +internal val md_theme_light_onError = Color(0xFFFFFFFF) +internal val md_theme_light_onErrorContainer = Color(0xFF410002) +internal val md_theme_light_background = Color(0xFFFFFBFF) +internal val md_theme_light_onBackground = Color(0xFF221B00) +internal val md_theme_light_surface = Color(0xFFFFFBFF) +internal val md_theme_light_onSurface = Color(0xFF221B00) +internal val md_theme_light_surfaceVariant = Color(0xFFDBE4E7) +internal val md_theme_light_onSurfaceVariant = Color(0xFF3F484B) +internal val md_theme_light_outline = Color(0xFF70797B) +internal val md_theme_light_inverseOnSurface = Color(0xFFFFF0C0) +internal val md_theme_light_inverseSurface = Color(0xFF3A3000) +internal val md_theme_light_inversePrimary = Color(0xFF55D6F4) +internal val md_theme_light_shadow = Color(0xFF000000) +internal val md_theme_light_surfaceTint = Color(0xFF00687A) +internal val md_theme_light_outlineVariant = Color(0xFFBFC8CB) +internal val md_theme_light_scrim = Color(0xFF000000) + +internal val md_theme_dark_primary = Color(0xFF55D6F4) +internal val md_theme_dark_onPrimary = Color(0xFF003640) +internal val md_theme_dark_primaryContainer = Color(0xFF004E5C) +internal val md_theme_dark_onPrimaryContainer = Color(0xFFABEDFF) +internal val md_theme_dark_secondary = Color(0xFF4CD9E2) +internal val md_theme_dark_onSecondary = Color(0xFF00373A) +internal val md_theme_dark_secondaryContainer = Color(0xFF004F53) +internal val md_theme_dark_onSecondaryContainer = Color(0xFF6FF6FE) +internal val md_theme_dark_tertiary = Color(0xFFFFB77C) +internal val md_theme_dark_onTertiary = Color(0xFF4D2700) +internal val md_theme_dark_tertiaryContainer = Color(0xFF6D3900) +internal val md_theme_dark_onTertiaryContainer = Color(0xFFFFDCC2) +internal val md_theme_dark_error = Color(0xFFFFB4AB) +internal val md_theme_dark_errorContainer = Color(0xFF93000A) +internal val md_theme_dark_onError = Color(0xFF690005) +internal val md_theme_dark_onErrorContainer = Color(0xFFFFDAD6) +internal val md_theme_dark_background = Color(0xFF221B00) +internal val md_theme_dark_onBackground = Color(0xFFFFE264) +internal val md_theme_dark_surface = Color(0xFF221B00) +internal val md_theme_dark_onSurface = Color(0xFFFFE264) +internal val md_theme_dark_surfaceVariant = Color(0xFF3F484B) +internal val md_theme_dark_onSurfaceVariant = Color(0xFFBFC8CB) +internal val md_theme_dark_outline = Color(0xFF899295) +internal val md_theme_dark_inverseOnSurface = Color(0xFF221B00) +internal val md_theme_dark_inverseSurface = Color(0xFFFFE264) +internal val md_theme_dark_inversePrimary = Color(0xFF00687A) +internal val md_theme_dark_shadow = Color(0xFF000000) +internal val md_theme_dark_surfaceTint = Color(0xFF55D6F4) +internal val md_theme_dark_outlineVariant = Color(0xFF3F484B) +internal val md_theme_dark_scrim = Color(0xFF000000) + + +internal val seed = Color(0xFF2C3639) diff --git a/demoapp/composeApp/src/commonMain/kotlin/at/asitplus/cryptotest/theme/Theme.kt b/demoapp/composeApp/src/commonMain/kotlin/at/asitplus/cryptotest/theme/Theme.kt new file mode 100644 index 00000000..b1a69977 --- /dev/null +++ b/demoapp/composeApp/src/commonMain/kotlin/at/asitplus/cryptotest/theme/Theme.kt @@ -0,0 +1,129 @@ +package at.asitplus.cryptotest.theme + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.lightColorScheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Shapes +import androidx.compose.material3.Surface +import androidx.compose.material3.Typography +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +private val LightColorScheme = lightColorScheme( + primary = md_theme_light_primary, + onPrimary = md_theme_light_onPrimary, + primaryContainer = md_theme_light_primaryContainer, + onPrimaryContainer = md_theme_light_onPrimaryContainer, + secondary = md_theme_light_secondary, + onSecondary = md_theme_light_onSecondary, + secondaryContainer = md_theme_light_secondaryContainer, + onSecondaryContainer = md_theme_light_onSecondaryContainer, + tertiary = md_theme_light_tertiary, + onTertiary = md_theme_light_onTertiary, + tertiaryContainer = md_theme_light_tertiaryContainer, + onTertiaryContainer = md_theme_light_onTertiaryContainer, + error = md_theme_light_error, + errorContainer = md_theme_light_errorContainer, + onError = md_theme_light_onError, + onErrorContainer = md_theme_light_onErrorContainer, + background = md_theme_light_background, + onBackground = md_theme_light_onBackground, + surface = md_theme_light_surface, + onSurface = md_theme_light_onSurface, + surfaceVariant = md_theme_light_surfaceVariant, + onSurfaceVariant = md_theme_light_onSurfaceVariant, + outline = md_theme_light_outline, + inverseOnSurface = md_theme_light_inverseOnSurface, + inverseSurface = md_theme_light_inverseSurface, + inversePrimary = md_theme_light_inversePrimary, + surfaceTint = md_theme_light_surfaceTint, + outlineVariant = md_theme_light_outlineVariant, + scrim = md_theme_light_scrim, +) + +private val DarkColorScheme = darkColorScheme( + primary = md_theme_dark_primary, + onPrimary = md_theme_dark_onPrimary, + primaryContainer = md_theme_dark_primaryContainer, + onPrimaryContainer = md_theme_dark_onPrimaryContainer, + secondary = md_theme_dark_secondary, + onSecondary = md_theme_dark_onSecondary, + secondaryContainer = md_theme_dark_secondaryContainer, + onSecondaryContainer = md_theme_dark_onSecondaryContainer, + tertiary = md_theme_dark_tertiary, + onTertiary = md_theme_dark_onTertiary, + tertiaryContainer = md_theme_dark_tertiaryContainer, + onTertiaryContainer = md_theme_dark_onTertiaryContainer, + error = md_theme_dark_error, + errorContainer = md_theme_dark_errorContainer, + onError = md_theme_dark_onError, + onErrorContainer = md_theme_dark_onErrorContainer, + background = md_theme_dark_background, + onBackground = md_theme_dark_onBackground, + surface = md_theme_dark_surface, + onSurface = md_theme_dark_onSurface, + surfaceVariant = md_theme_dark_surfaceVariant, + onSurfaceVariant = md_theme_dark_onSurfaceVariant, + outline = md_theme_dark_outline, + inverseOnSurface = md_theme_dark_inverseOnSurface, + inverseSurface = md_theme_dark_inverseSurface, + inversePrimary = md_theme_dark_inversePrimary, + surfaceTint = md_theme_dark_surfaceTint, + outlineVariant = md_theme_dark_outlineVariant, + scrim = md_theme_dark_scrim, +) + +private val AppShapes = Shapes( + extraSmall = RoundedCornerShape(2.dp), + small = RoundedCornerShape(4.dp), + medium = RoundedCornerShape(8.dp), + large = RoundedCornerShape(16.dp), + extraLarge = RoundedCornerShape(32.dp) +) + +private val AppTypography = Typography( + bodyMedium = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Medium, + fontSize = 16.sp + ) +) + +internal val LocalThemeIsDark = compositionLocalOf { mutableStateOf(true) } + +@Composable +internal fun AppTheme( + content: @Composable() () -> Unit +) { + val systemIsDark = isSystemInDarkTheme() + val isDarkState = remember { mutableStateOf(systemIsDark) } + CompositionLocalProvider( + LocalThemeIsDark provides isDarkState + ) { + val isDark by isDarkState + SystemAppearance(!isDark) + MaterialTheme( + colorScheme = if (isDark) DarkColorScheme else LightColorScheme, + typography = AppTypography, + shapes = AppShapes, + content = { + Surface(content = content) + } + ) + } +} + +@Composable +internal expect fun SystemAppearance(isDark: Boolean) diff --git a/demoapp/composeApp/src/iosMain/kotlin/at/asitplus/cryptotest/App.ios.kt b/demoapp/composeApp/src/iosMain/kotlin/at/asitplus/cryptotest/App.ios.kt new file mode 100644 index 00000000..4eb75898 --- /dev/null +++ b/demoapp/composeApp/src/iosMain/kotlin/at/asitplus/cryptotest/App.ios.kt @@ -0,0 +1,72 @@ +package at.asitplus.cryptotest + +import at.asitplus.signum.supreme.os.IosKeychainProvider +import at.asitplus.signum.supreme.os.SigningProvider + +internal actual fun getSystemKeyStore(): SigningProvider = IosKeychainProvider + +/*@OptIn(ExperimentalForeignApi::class) +internal actual suspend fun generateKey( + alg: CryptoAlgorithm, + attestation: ByteArray?, + withBiometricAuth: Duration? +): KmmResult { + + val specificCryptoOps = withBiometricAuth?.let { + IosSpecificCryptoOps.withSecAccessControlFlagsAndReuse( + kSecAccessControlTouchIDCurrentSet, withBiometricAuth + ) + } ?: IosSpecificCryptoOps.plain() + + + + val hasKey = CryptoProvider.hasKey(ALIAS, specificCryptoOps) + Napier.w { "Key with alias $ALIAS exists: $hasKey" } + + if (hasKey.getOrThrow()) { + Napier.w { "trying to clear key" } + println(CryptoProvider.deleteEntry(ALIAS, specificCryptoOps)) + } + + Napier.w { "creating signing key" } + + + return (if (attestation == null) { + CryptoProvider.createSigningKey( + ALIAS, + alg, + specificCryptoOps + ).map { it to listOf() } + } else CryptoProvider.createTbaP256Key( + ALIAS, + attestation, + specificCryptoOps + )) +} + +@OptIn(ExperimentalForeignApi::class) +internal actual suspend fun sign( + data: ByteArray, + alg: CryptoAlgorithm, + signingKey: CryptoPrivateKey +): KmmResult { + if (signingKey !is IosPrivateKey) throw IllegalArgumentException("Not an iOS Private Key!") + return CryptoProvider.sign(data, signingKey, alg) +} + +internal actual suspend fun loadPubKey() = CryptoProvider.getPublicKey(ALIAS) + +@OptIn(ExperimentalForeignApi::class) +internal actual suspend fun loadPrivateKey(): KmmResult = + CryptoProvider.getKeyPair(ALIAS, IosSpecificCryptoOps()) + +internal actual suspend fun storeCertChain(): KmmResult = + CryptoProvider.storeCertificateChain( + ALIAS + "CRT_CHAIN", + SAMPLE_CERT_CHAIN + ) + +internal actual suspend fun getCertChain(): KmmResult> = + CryptoProvider.getCertificateChain( + ALIAS + "CRT_CHAIN" + )*/ \ No newline at end of file diff --git a/demoapp/composeApp/src/iosMain/kotlin/at/asitplus/cryptotest/theme/Theme.ios.kt b/demoapp/composeApp/src/iosMain/kotlin/at/asitplus/cryptotest/theme/Theme.ios.kt new file mode 100644 index 00000000..db40f0bc --- /dev/null +++ b/demoapp/composeApp/src/iosMain/kotlin/at/asitplus/cryptotest/theme/Theme.ios.kt @@ -0,0 +1,17 @@ +package at.asitplus.cryptotest.theme + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import platform.UIKit.UIApplication +import platform.UIKit.UIStatusBarStyleDarkContent +import platform.UIKit.UIStatusBarStyleLightContent +import platform.UIKit.setStatusBarStyle + +@Composable +internal actual fun SystemAppearance(isDark: Boolean) { + LaunchedEffect(isDark) { + UIApplication.sharedApplication.setStatusBarStyle( + if (isDark) UIStatusBarStyleDarkContent else UIStatusBarStyleLightContent + ) + } +} \ No newline at end of file diff --git a/demoapp/composeApp/src/iosMain/kotlin/main.kt b/demoapp/composeApp/src/iosMain/kotlin/main.kt new file mode 100644 index 00000000..f8edd2ef --- /dev/null +++ b/demoapp/composeApp/src/iosMain/kotlin/main.kt @@ -0,0 +1,9 @@ +import androidx.compose.ui.window.ComposeUIViewController +import at.asitplus.cryptotest.App +import io.github.aakira.napier.DebugAntilog +import io.github.aakira.napier.Napier +import platform.UIKit.UIViewController + +fun MainViewController(): UIViewController = ComposeUIViewController { + App() +} diff --git a/demoapp/composeApp/src/jvmMain/kotlin/at/asitplus/cryptotest/App.jvm.kt b/demoapp/composeApp/src/jvmMain/kotlin/at/asitplus/cryptotest/App.jvm.kt new file mode 100644 index 00000000..89f6d4b8 --- /dev/null +++ b/demoapp/composeApp/src/jvmMain/kotlin/at/asitplus/cryptotest/App.jvm.kt @@ -0,0 +1,37 @@ +package at.asitplus.cryptotest + +import at.asitplus.signum.supreme.os.SigningProvider + +internal actual fun getSystemKeyStore(): SigningProvider = TODO() + +/*val PROVIDER = BouncyCastleProvider() +val JVM_OPTS = + JvmSpecifics( + PROVIDER, + KeyStore.getInstance("PKCS12", PROVIDER).apply { load(null, null) }, + privateKeyPassword = null + ) + +internal actual suspend fun generateKey( + alg: CryptoAlgorithm, + attestation: ByteArray?, + withBiometricAuth: Duration?, + + ): KmmResult = CryptoProvider.createSigningKey(ALIAS, alg, JVM_OPTS).map { it to listOf() } + +internal actual suspend fun sign( + data: ByteArray, + alg: CryptoAlgorithm, + signingKey: CryptoPrivateKey +): KmmResult = CryptoProvider.sign(data, signingKey, alg) + +internal actual suspend fun loadPubKey() = CryptoProvider.getPublicKey(ALIAS, JVM_OPTS) +internal actual suspend fun loadPrivateKey() = CryptoProvider.getKeyPair(ALIAS, JVM_OPTS) + +internal actual suspend fun storeCertChain(): KmmResult = + CryptoProvider.storeCertificateChain(ALIAS + "CRT_CHAIN", SAMPLE_CERT_CHAIN, JVM_OPTS) + +internal actual suspend fun getCertChain(): KmmResult> = + CryptoProvider.getCertificateChain( + ALIAS + "CRT_CHAIN", JVM_OPTS + )*/ \ No newline at end of file diff --git a/demoapp/composeApp/src/jvmMain/kotlin/at/asitplus/cryptotest/Main.kt b/demoapp/composeApp/src/jvmMain/kotlin/at/asitplus/cryptotest/Main.kt new file mode 100644 index 00000000..13e4ea3a --- /dev/null +++ b/demoapp/composeApp/src/jvmMain/kotlin/at/asitplus/cryptotest/Main.kt @@ -0,0 +1,11 @@ +package at.asitplus.cryptotest + +import androidx.compose.runtime.Composable +import androidx.compose.ui.window.Window +import androidx.compose.ui.window.application + +fun main() = application { + Window(onCloseRequest = ::exitApplication, title = "KMP-Crypto Demo") { + App() + } +} \ No newline at end of file diff --git a/demoapp/composeApp/src/jvmMain/kotlin/at/asitplus/cryptotest/theme/Theme.jvm.kt b/demoapp/composeApp/src/jvmMain/kotlin/at/asitplus/cryptotest/theme/Theme.jvm.kt new file mode 100644 index 00000000..52f19b44 --- /dev/null +++ b/demoapp/composeApp/src/jvmMain/kotlin/at/asitplus/cryptotest/theme/Theme.jvm.kt @@ -0,0 +1,8 @@ +package at.asitplus.cryptotest.theme + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect + +@Composable +internal actual fun SystemAppearance(isDark: Boolean) { +} \ No newline at end of file diff --git a/demoapp/gradle.properties b/demoapp/gradle.properties new file mode 100644 index 00000000..eadd020f --- /dev/null +++ b/demoapp/gradle.properties @@ -0,0 +1,16 @@ +#Gradle +org.gradle.jvmargs=-Xmx4096M -Dfile.encoding=UTF-8 -Dkotlin.daemon.jvm.options\="-Xmx4096M" +org.gradle.caching=true +org.gradle.configuration-cache=false + +#Kotlin +kotlin.code.style=official +kotlin.js.compiler=ir + +#Android +android.useAndroidX=true +android.nonTransitiveRClass=true + +#Compose +org.jetbrains.compose.experimental.uikit.enabled=true +org.jetbrains.compose.experimental.jscanvas.enabled=true diff --git a/demoapp/gradle/libs.versions.toml b/demoapp/gradle/libs.versions.toml new file mode 100644 index 00000000..45f2c876 --- /dev/null +++ b/demoapp/gradle/libs.versions.toml @@ -0,0 +1,34 @@ +[versions] + +biometric = "1.2.0-alpha05" +kotlin = "1.9.23" +compose = "1.6.1" +agp = "8.2.0" +androidx-appcompat = "1.6.1" +androidx-activityCompose = "1.8.1" +compose-uitooling = "1.5.4" +voyager = "1.0.0" +composeImageLoader = "1.7.1" +napier = "2.7.1" +buildConfig = "4.1.1" +kotlinx-coroutines = "1.8.0" + +[libraries] + +androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "androidx-appcompat" } +androidx-activityCompose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activityCompose" } +androidx-biometric = { module = "androidx.biometric:biometric", version.ref = "biometric" } +compose-uitooling = { module = "androidx.compose.ui:ui-tooling", version.ref = "compose-uitooling" } +voyager-navigator = { module = "cafe.adriel.voyager:voyager-navigator", version.ref = "voyager" } +composeImageLoader = { module = "io.github.qdsfdhvh:image-loader", version.ref = "composeImageLoader" } +napier = { module = "io.github.aakira:napier", version.ref = "napier" } +kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" } +kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "kotlinx-coroutines" } +kotlinx-coroutines-swing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-swing", version.ref = "kotlinx-coroutines" } + +[plugins] + +multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } +compose = { id = "org.jetbrains.compose", version.ref = "compose" } +android-application = { id = "com.android.application", version.ref = "agp" } +buildConfig = { id = "com.github.gmazzo.buildconfig", version.ref = "buildConfig" } diff --git a/demoapp/gradle/wrapper/gradle-wrapper.jar b/demoapp/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..7454180f2ae8848c63b8b4dea2cb829da983f2fa GIT binary patch literal 59536 zcma&NbC71ylI~qywr$(CZQJHswz}-9F59+k+g;UV+cs{`J?GrGXYR~=-ydruB3JCa zB64N^cILAcWk5iofq)<(fq;O7{th4@;QxID0)qN`mJ?GIqLY#rX8-|G{5M0pdVW5^ zzXk$-2kQTAC?_N@B`&6-N-rmVFE=$QD?>*=4<|!MJu@}isLc4AW#{m2if&A5T5g&~ ziuMQeS*U5sL6J698wOd)K@oK@1{peP5&Esut<#VH^u)gp`9H4)`uE!2$>RTctN+^u z=ASkePDZA-X8)rp%D;p*~P?*a_=*Kwc<^>QSH|^<0>o37lt^+Mj1;4YvJ(JR-Y+?%Nu}JAYj5 z_Qc5%Ao#F?q32i?ZaN2OSNhWL;2oDEw_({7ZbgUjna!Fqn3NzLM@-EWFPZVmc>(fZ z0&bF-Ch#p9C{YJT9Rcr3+Y_uR^At1^BxZ#eo>$PLJF3=;t_$2|t+_6gg5(j{TmjYU zK12c&lE?Eh+2u2&6Gf*IdKS&6?rYbSEKBN!rv{YCm|Rt=UlPcW9j`0o6{66#y5t9C zruFA2iKd=H%jHf%ypOkxLnO8#H}#Zt{8p!oi6)7#NqoF({t6|J^?1e*oxqng9Q2Cc zg%5Vu!em)}Yuj?kaP!D?b?(C*w!1;>R=j90+RTkyEXz+9CufZ$C^umX^+4|JYaO<5 zmIM3#dv`DGM;@F6;(t!WngZSYzHx?9&$xEF70D1BvfVj<%+b#)vz)2iLCrTeYzUcL z(OBnNoG6Le%M+@2oo)&jdOg=iCszzv59e zDRCeaX8l1hC=8LbBt|k5?CXgep=3r9BXx1uR8!p%Z|0+4Xro=xi0G!e{c4U~1j6!) zH6adq0}#l{%*1U(Cb%4AJ}VLWKBPi0MoKFaQH6x?^hQ!6em@993xdtS%_dmevzeNl z(o?YlOI=jl(`L9^ z0O+H9k$_@`6L13eTT8ci-V0ljDMD|0ifUw|Q-Hep$xYj0hTO@0%IS^TD4b4n6EKDG z??uM;MEx`s98KYN(K0>c!C3HZdZ{+_53DO%9k5W%pr6yJusQAv_;IA}925Y%;+!tY z%2k!YQmLLOr{rF~!s<3-WEUs)`ix_mSU|cNRBIWxOox_Yb7Z=~Q45ZNe*u|m^|)d* zog=i>`=bTe!|;8F+#H>EjIMcgWcG2ORD`w0WD;YZAy5#s{65~qfI6o$+Ty&-hyMyJ z3Ra~t>R!p=5ZpxA;QkDAoPi4sYOP6>LT+}{xp}tk+<0k^CKCFdNYG(Es>p0gqD)jP zWOeX5G;9(m@?GOG7g;e74i_|SmE?`B2i;sLYwRWKLy0RLW!Hx`=!LH3&k=FuCsM=9M4|GqzA)anEHfxkB z?2iK-u(DC_T1};KaUT@3nP~LEcENT^UgPvp!QC@Dw&PVAhaEYrPey{nkcn(ro|r7XUz z%#(=$7D8uP_uU-oPHhd>>^adbCSQetgSG`e$U|7mr!`|bU0aHl_cmL)na-5x1#OsVE#m*+k84Y^+UMeSAa zbrVZHU=mFwXEaGHtXQq`2ZtjfS!B2H{5A<3(nb-6ARVV8kEmOkx6D2x7~-6hl;*-*}2Xz;J#a8Wn;_B5=m zl3dY;%krf?i-Ok^Pal-}4F`{F@TYPTwTEhxpZK5WCpfD^UmM_iYPe}wpE!Djai6_{ z*pGO=WB47#Xjb7!n2Ma)s^yeR*1rTxp`Mt4sfA+`HwZf%!7ZqGosPkw69`Ix5Ku6G z@Pa;pjzV&dn{M=QDx89t?p?d9gna*}jBly*#1!6}5K<*xDPJ{wv4& zM$17DFd~L*Te3A%yD;Dp9UGWTjRxAvMu!j^Tbc}2v~q^59d4bz zvu#!IJCy(BcWTc`;v$9tH;J%oiSJ_i7s;2`JXZF+qd4C)vY!hyCtl)sJIC{ebI*0> z@x>;EzyBv>AI-~{D6l6{ST=em*U( z(r$nuXY-#CCi^8Z2#v#UXOt`dbYN1z5jzNF2 z411?w)whZrfA20;nl&C1Gi+gk<`JSm+{|*2o<< zqM#@z_D`Cn|0H^9$|Tah)0M_X4c37|KQ*PmoT@%xHc3L1ZY6(p(sNXHa&49Frzto& zR`c~ClHpE~4Z=uKa5S(-?M8EJ$zt0&fJk~p$M#fGN1-y$7!37hld`Uw>Urri(DxLa;=#rK0g4J)pXMC zxzraOVw1+kNWpi#P=6(qxf`zSdUC?D$i`8ZI@F>k6k zz21?d+dw7b&i*>Kv5L(LH-?J%@WnqT7j#qZ9B>|Zl+=> z^U-pV@1y_ptHo4hl^cPRWewbLQ#g6XYQ@EkiP z;(=SU!yhjHp%1&MsU`FV1Z_#K1&(|5n(7IHbx&gG28HNT)*~-BQi372@|->2Aw5It z0CBpUcMA*QvsPy)#lr!lIdCi@1k4V2m!NH)%Px(vu-r(Q)HYc!p zJ^$|)j^E#q#QOgcb^pd74^JUi7fUmMiNP_o*lvx*q%_odv49Dsv$NV;6J z9GOXKomA{2Pb{w}&+yHtH?IkJJu~}Z?{Uk++2mB8zyvh*xhHKE``99>y#TdD z&(MH^^JHf;g(Tbb^&8P*;_i*2&fS$7${3WJtV7K&&(MBV2~)2KB3%cWg#1!VE~k#C z!;A;?p$s{ihyojEZz+$I1)L}&G~ml=udD9qh>Tu(ylv)?YcJT3ihapi!zgPtWb*CP zlLLJSRCj-^w?@;RU9aL2zDZY1`I3d<&OMuW=c3$o0#STpv_p3b9Wtbql>w^bBi~u4 z3D8KyF?YE?=HcKk!xcp@Cigvzy=lnFgc^9c%(^F22BWYNAYRSho@~*~S)4%AhEttv zvq>7X!!EWKG?mOd9&n>vvH1p4VzE?HCuxT-u+F&mnsfDI^}*-d00-KAauEaXqg3k@ zy#)MGX!X;&3&0s}F3q40ZmVM$(H3CLfpdL?hB6nVqMxX)q=1b}o_PG%r~hZ4gUfSp zOH4qlEOW4OMUc)_m)fMR_rl^pCfXc{$fQbI*E&mV77}kRF z&{<06AJyJ!e863o-V>FA1a9Eemx6>^F$~9ppt()ZbPGfg_NdRXBWoZnDy2;#ODgf! zgl?iOcF7Meo|{AF>KDwTgYrJLb$L2%%BEtO>T$C?|9bAB&}s;gI?lY#^tttY&hfr# zKhC+&b-rpg_?~uVK%S@mQleU#_xCsvIPK*<`E0fHE1&!J7!xD#IB|SSPW6-PyuqGn3^M^Rz%WT{e?OI^svARX&SAdU77V(C~ zM$H{Kg59op{<|8ry9ecfP%=kFm(-!W&?U0@<%z*+!*<e0XesMxRFu9QnGqun6R_%T+B%&9Dtk?*d$Q zb~>84jEAPi@&F@3wAa^Lzc(AJz5gsfZ7J53;@D<;Klpl?sK&u@gie`~vTsbOE~Cd4 z%kr56mI|#b(Jk&;p6plVwmNB0H@0SmgdmjIn5Ne@)}7Vty(yb2t3ev@22AE^s!KaN zyQ>j+F3w=wnx7w@FVCRe+`vUH)3gW%_72fxzqX!S&!dchdkRiHbXW1FMrIIBwjsai8`CB2r4mAbwp%rrO>3B$Zw;9=%fXI9B{d(UzVap7u z6piC-FQ)>}VOEuPpuqznpY`hN4dGa_1Xz9rVg(;H$5Te^F0dDv*gz9JS<|>>U0J^# z6)(4ICh+N_Q`Ft0hF|3fSHs*?a=XC;e`sJaU9&d>X4l?1W=|fr!5ShD|nv$GK;j46@BV6+{oRbWfqOBRb!ir88XD*SbC(LF}I1h#6@dvK%Toe%@ zhDyG$93H8Eu&gCYddP58iF3oQH*zLbNI;rN@E{T9%A8!=v#JLxKyUe}e}BJpB{~uN zqgxRgo0*-@-iaHPV8bTOH(rS(huwK1Xg0u+e!`(Irzu@Bld&s5&bWgVc@m7;JgELd zimVs`>vQ}B_1(2#rv#N9O`fJpVfPc7V2nv34PC);Dzbb;p!6pqHzvy?2pD&1NE)?A zt(t-ucqy@wn9`^MN5apa7K|L=9>ISC>xoc#>{@e}m#YAAa1*8-RUMKwbm|;5p>T`Z zNf*ph@tnF{gmDa3uwwN(g=`Rh)4!&)^oOy@VJaK4lMT&5#YbXkl`q?<*XtsqD z9PRK6bqb)fJw0g-^a@nu`^?71k|m3RPRjt;pIkCo1{*pdqbVs-Yl>4E>3fZx3Sv44grW=*qdSoiZ9?X0wWyO4`yDHh2E!9I!ZFi zVL8|VtW38}BOJHW(Ax#KL_KQzarbuE{(%TA)AY)@tY4%A%P%SqIU~8~-Lp3qY;U-} z`h_Gel7;K1h}7$_5ZZT0&%$Lxxr-<89V&&TCsu}LL#!xpQ1O31jaa{U34~^le*Y%L za?7$>Jk^k^pS^_M&cDs}NgXlR>16AHkSK-4TRaJSh#h&p!-!vQY%f+bmn6x`4fwTp z$727L^y`~!exvmE^W&#@uY!NxJi`g!i#(++!)?iJ(1)2Wk;RN zFK&O4eTkP$Xn~4bB|q8y(btx$R#D`O@epi4ofcETrx!IM(kWNEe42Qh(8*KqfP(c0 zouBl6>Fc_zM+V;F3znbo{x#%!?mH3`_ANJ?y7ppxS@glg#S9^MXu|FM&ynpz3o&Qh z2ujAHLF3($pH}0jXQsa#?t--TnF1P73b?4`KeJ9^qK-USHE)4!IYgMn-7z|=ALF5SNGkrtPG@Y~niUQV2?g$vzJN3nZ{7;HZHzWAeQ;5P|@Tl3YHpyznGG4-f4=XflwSJY+58-+wf?~Fg@1p1wkzuu-RF3j2JX37SQUc? zQ4v%`V8z9ZVZVqS8h|@@RpD?n0W<=hk=3Cf8R?d^9YK&e9ZybFY%jdnA)PeHvtBe- zhMLD+SSteHBq*q)d6x{)s1UrsO!byyLS$58WK;sqip$Mk{l)Y(_6hEIBsIjCr5t>( z7CdKUrJTrW%qZ#1z^n*Lb8#VdfzPw~OIL76aC+Rhr<~;4Tl!sw?Rj6hXj4XWa#6Tp z@)kJ~qOV)^Rh*-?aG>ic2*NlC2M7&LUzc9RT6WM%Cpe78`iAowe!>(T0jo&ivn8-7 zs{Qa@cGy$rE-3AY0V(l8wjI^uB8Lchj@?L}fYal^>T9z;8juH@?rG&g-t+R2dVDBe zq!K%{e-rT5jX19`(bP23LUN4+_zh2KD~EAYzhpEO3MUG8@}uBHH@4J zd`>_(K4q&>*k82(dDuC)X6JuPrBBubOg7qZ{?x!r@{%0);*`h*^F|%o?&1wX?Wr4b z1~&cy#PUuES{C#xJ84!z<1tp9sfrR(i%Tu^jnXy;4`Xk;AQCdFC@?V%|; zySdC7qS|uQRcH}EFZH%mMB~7gi}a0utE}ZE_}8PQH8f;H%PN41Cb9R%w5Oi5el^fd z$n{3SqLCnrF##x?4sa^r!O$7NX!}&}V;0ZGQ&K&i%6$3C_dR%I7%gdQ;KT6YZiQrW zk%q<74oVBV>@}CvJ4Wj!d^?#Zwq(b$E1ze4$99DuNg?6t9H}k_|D7KWD7i0-g*EO7 z;5{hSIYE4DMOK3H%|f5Edx+S0VI0Yw!tsaRS2&Il2)ea^8R5TG72BrJue|f_{2UHa z@w;^c|K3da#$TB0P3;MPlF7RuQeXT$ zS<<|C0OF(k)>fr&wOB=gP8!Qm>F41u;3esv7_0l%QHt(~+n; zf!G6%hp;Gfa9L9=AceiZs~tK+Tf*Wof=4!u{nIO90jH@iS0l+#%8=~%ASzFv7zqSB^?!@N7)kp0t&tCGLmzXSRMRyxCmCYUD2!B`? zhs$4%KO~m=VFk3Buv9osha{v+mAEq=ik3RdK@;WWTV_g&-$U4IM{1IhGX{pAu%Z&H zFfwCpUsX%RKg);B@7OUzZ{Hn{q6Vv!3#8fAg!P$IEx<0vAx;GU%}0{VIsmFBPq_mb zpe^BChDK>sc-WLKl<6 zwbW|e&d&dv9Wu0goueyu>(JyPx1mz0v4E?cJjFuKF71Q1)AL8jHO$!fYT3(;U3Re* zPPOe%*O+@JYt1bW`!W_1!mN&=w3G9ru1XsmwfS~BJ))PhD(+_J_^N6j)sx5VwbWK| zwRyC?W<`pOCY)b#AS?rluxuuGf-AJ=D!M36l{ua?@SJ5>e!IBr3CXIxWw5xUZ@Xrw z_R@%?{>d%Ld4p}nEsiA@v*nc6Ah!MUs?GA7e5Q5lPpp0@`%5xY$C;{%rz24$;vR#* zBP=a{)K#CwIY%p} zXVdxTQ^HS@O&~eIftU+Qt^~(DGxrdi3k}DdT^I7Iy5SMOp$QuD8s;+93YQ!OY{eB24%xY7ml@|M7I(Nb@K_-?F;2?et|CKkuZK_>+>Lvg!>JE~wN`BI|_h6$qi!P)+K-1Hh(1;a`os z55)4Q{oJiA(lQM#;w#Ta%T0jDNXIPM_bgESMCDEg6rM33anEr}=|Fn6)|jBP6Y}u{ zv9@%7*#RI9;fv;Yii5CI+KrRdr0DKh=L>)eO4q$1zmcSmglsV`*N(x=&Wx`*v!!hn6X-l0 zP_m;X??O(skcj+oS$cIdKhfT%ABAzz3w^la-Ucw?yBPEC+=Pe_vU8nd-HV5YX6X8r zZih&j^eLU=%*;VzhUyoLF;#8QsEfmByk+Y~caBqSvQaaWf2a{JKB9B>V&r?l^rXaC z8)6AdR@Qy_BxQrE2Fk?ewD!SwLuMj@&d_n5RZFf7=>O>hzVE*seW3U?_p|R^CfoY`?|#x9)-*yjv#lo&zP=uI`M?J zbzC<^3x7GfXA4{FZ72{PE*-mNHyy59Q;kYG@BB~NhTd6pm2Oj=_ zizmD?MKVRkT^KmXuhsk?eRQllPo2Ubk=uCKiZ&u3Xjj~<(!M94c)Tez@9M1Gfs5JV z->@II)CDJOXTtPrQudNjE}Eltbjq>6KiwAwqvAKd^|g!exgLG3;wP+#mZYr`cy3#39e653d=jrR-ulW|h#ddHu(m9mFoW~2yE zz5?dB%6vF}+`-&-W8vy^OCxm3_{02royjvmwjlp+eQDzFVEUiyO#gLv%QdDSI#3W* z?3!lL8clTaNo-DVJw@ynq?q!%6hTQi35&^>P85G$TqNt78%9_sSJt2RThO|JzM$iL zg|wjxdMC2|Icc5rX*qPL(coL!u>-xxz-rFiC!6hD1IR%|HSRsV3>Kq~&vJ=s3M5y8SG%YBQ|{^l#LGlg!D?E>2yR*eV%9m$_J6VGQ~AIh&P$_aFbh zULr0Z$QE!QpkP=aAeR4ny<#3Fwyw@rZf4?Ewq`;mCVv}xaz+3ni+}a=k~P+yaWt^L z@w67!DqVf7D%7XtXX5xBW;Co|HvQ8WR1k?r2cZD%U;2$bsM%u8{JUJ5Z0k= zZJARv^vFkmWx15CB=rb=D4${+#DVqy5$C%bf`!T0+epLJLnh1jwCdb*zuCL}eEFvE z{rO1%gxg>1!W(I!owu*mJZ0@6FM(?C+d*CeceZRW_4id*D9p5nzMY&{mWqrJomjIZ z97ZNnZ3_%Hx8dn;H>p8m7F#^2;T%yZ3H;a&N7tm=Lvs&lgJLW{V1@h&6Vy~!+Ffbb zv(n3+v)_D$}dqd!2>Y2B)#<+o}LH#%ogGi2-?xRIH)1!SD)u-L65B&bsJTC=LiaF+YOCif2dUX6uAA|#+vNR z>U+KQekVGon)Yi<93(d!(yw1h3&X0N(PxN2{%vn}cnV?rYw z$N^}_o!XUB!mckL`yO1rnUaI4wrOeQ(+&k?2mi47hzxSD`N#-byqd1IhEoh!PGq>t z_MRy{5B0eKY>;Ao3z$RUU7U+i?iX^&r739F)itdrTpAi-NN0=?^m%?{A9Ly2pVv>Lqs6moTP?T2-AHqFD-o_ znVr|7OAS#AEH}h8SRPQ@NGG47dO}l=t07__+iK8nHw^(AHx&Wb<%jPc$$jl6_p(b$ z)!pi(0fQodCHfM)KMEMUR&UID>}m^(!{C^U7sBDOA)$VThRCI0_+2=( zV8mMq0R(#z;C|7$m>$>`tX+T|xGt(+Y48@ZYu#z;0pCgYgmMVbFb!$?%yhZqP_nhn zy4<#3P1oQ#2b51NU1mGnHP$cf0j-YOgAA}A$QoL6JVLcmExs(kU{4z;PBHJD%_=0F z>+sQV`mzijSIT7xn%PiDKHOujX;n|M&qr1T@rOxTdxtZ!&u&3HHFLYD5$RLQ=heur zb>+AFokUVQeJy-#LP*^)spt{mb@Mqe=A~-4p0b+Bt|pZ+@CY+%x}9f}izU5;4&QFE zO1bhg&A4uC1)Zb67kuowWY4xbo&J=%yoXlFB)&$d*-}kjBu|w!^zbD1YPc0-#XTJr z)pm2RDy%J3jlqSMq|o%xGS$bPwn4AqitC6&e?pqWcjWPt{3I{>CBy;hg0Umh#c;hU3RhCUX=8aR>rmd` z7Orw(5tcM{|-^J?ZAA9KP|)X6n9$-kvr#j5YDecTM6n z&07(nD^qb8hpF0B^z^pQ*%5ePYkv&FabrlI61ntiVp!!C8y^}|<2xgAd#FY=8b*y( zuQOuvy2`Ii^`VBNJB&R!0{hABYX55ooCAJSSevl4RPqEGb)iy_0H}v@vFwFzD%>#I>)3PsouQ+_Kkbqy*kKdHdfkN7NBcq%V{x^fSxgXpg7$bF& zj!6AQbDY(1u#1_A#1UO9AxiZaCVN2F0wGXdY*g@x$ByvUA?ePdide0dmr#}udE%K| z3*k}Vv2Ew2u1FXBaVA6aerI36R&rzEZeDDCl5!t0J=ug6kuNZzH>3i_VN`%BsaVB3 zQYw|Xub_SGf{)F{$ZX5`Jc!X!;eybjP+o$I{Z^Hsj@D=E{MnnL+TbC@HEU2DjG{3-LDGIbq()U87x4eS;JXnSh;lRlJ z>EL3D>wHt-+wTjQF$fGyDO$>d+(fq@bPpLBS~xA~R=3JPbS{tzN(u~m#Po!?H;IYv zE;?8%^vle|%#oux(Lj!YzBKv+Fd}*Ur-dCBoX*t{KeNM*n~ZPYJ4NNKkI^MFbz9!v z4(Bvm*Kc!-$%VFEewYJKz-CQN{`2}KX4*CeJEs+Q(!kI%hN1!1P6iOq?ovz}X0IOi z)YfWpwW@pK08^69#wSyCZkX9?uZD?C^@rw^Y?gLS_xmFKkooyx$*^5#cPqntNTtSG zlP>XLMj2!VF^0k#ole7`-c~*~+_T5ls?x4)ah(j8vo_ zwb%S8qoaZqY0-$ZI+ViIA_1~~rAH7K_+yFS{0rT@eQtTAdz#8E5VpwnW!zJ_^{Utv zlW5Iar3V5t&H4D6A=>?mq;G92;1cg9a2sf;gY9pJDVKn$DYdQlvfXq}zz8#LyPGq@ z+`YUMD;^-6w&r-82JL7mA8&M~Pj@aK!m{0+^v<|t%APYf7`}jGEhdYLqsHW-Le9TL z_hZZ1gbrz7$f9^fAzVIP30^KIz!!#+DRLL+qMszvI_BpOSmjtl$hh;&UeM{ER@INV zcI}VbiVTPoN|iSna@=7XkP&-4#06C};8ajbxJ4Gcq8(vWv4*&X8bM^T$mBk75Q92j z1v&%a;OSKc8EIrodmIiw$lOES2hzGDcjjB`kEDfJe{r}yE6`eZL zEB`9u>Cl0IsQ+t}`-cx}{6jqcANucqIB>Qmga_&<+80E2Q|VHHQ$YlAt{6`Qu`HA3 z03s0-sSlwbvgi&_R8s={6<~M^pGvBNjKOa>tWenzS8s zR>L7R5aZ=mSU{f?ib4Grx$AeFvtO5N|D>9#)ChH#Fny2maHWHOf2G=#<9Myot#+4u zWVa6d^Vseq_0=#AYS(-m$Lp;*8nC_6jXIjEM`omUmtH@QDs3|G)i4j*#_?#UYVZvJ z?YjT-?!4Q{BNun;dKBWLEw2C-VeAz`%?A>p;)PL}TAZn5j~HK>v1W&anteARlE+~+ zj>c(F;?qO3pXBb|#OZdQnm<4xWmn~;DR5SDMxt0UK_F^&eD|KZ=O;tO3vy4@4h^;2 zUL~-z`-P1aOe?|ZC1BgVsL)2^J-&vIFI%q@40w0{jjEfeVl)i9(~bt2z#2Vm)p`V_ z1;6$Ae7=YXk#=Qkd24Y23t&GvRxaOoad~NbJ+6pxqzJ>FY#Td7@`N5xp!n(c!=RE& z&<<@^a$_Ys8jqz4|5Nk#FY$~|FPC0`*a5HH!|Gssa9=~66&xG9)|=pOOJ2KE5|YrR zw!w6K2aC=J$t?L-;}5hn6mHd%hC;p8P|Dgh6D>hGnXPgi;6r+eA=?f72y9(Cf_ho{ zH6#)uD&R=73^$$NE;5piWX2bzR67fQ)`b=85o0eOLGI4c-Tb@-KNi2pz=Ke@SDcPn za$AxXib84`!Sf;Z3B@TSo`Dz7GM5Kf(@PR>Ghzi=BBxK8wRp>YQoXm+iL>H*Jo9M3 z6w&E?BC8AFTFT&Tv8zf+m9<&S&%dIaZ)Aoqkak_$r-2{$d~0g2oLETx9Y`eOAf14QXEQw3tJne;fdzl@wV#TFXSLXM2428F-Q}t+n2g%vPRMUzYPvzQ9f# zu(liiJem9P*?0%V@RwA7F53r~|I!Ty)<*AsMX3J{_4&}{6pT%Tpw>)^|DJ)>gpS~1rNEh z0$D?uO8mG?H;2BwM5a*26^7YO$XjUm40XmBsb63MoR;bJh63J;OngS5sSI+o2HA;W zdZV#8pDpC9Oez&L8loZO)MClRz!_!WD&QRtQxnazhT%Vj6Wl4G11nUk8*vSeVab@N#oJ}`KyJv+8Mo@T1-pqZ1t|?cnaVOd;1(h9 z!$DrN=jcGsVYE-0-n?oCJ^4x)F}E;UaD-LZUIzcD?W^ficqJWM%QLy6QikrM1aKZC zi{?;oKwq^Vsr|&`i{jIphA8S6G4)$KGvpULjH%9u(Dq247;R#l&I0{IhcC|oBF*Al zvLo7Xte=C{aIt*otJD}BUq)|_pdR>{zBMT< z(^1RpZv*l*m*OV^8>9&asGBo8h*_4q*)-eCv*|Pq=XNGrZE)^(SF7^{QE_~4VDB(o zVcPA_!G+2CAtLbl+`=Q~9iW`4ZRLku!uB?;tWqVjB0lEOf}2RD7dJ=BExy=<9wkb- z9&7{XFA%n#JsHYN8t5d~=T~5DcW4$B%3M+nNvC2`0!#@sckqlzo5;hhGi(D9=*A4` z5ynobawSPRtWn&CDLEs3Xf`(8^zDP=NdF~F^s&={l7(aw&EG}KWpMjtmz7j_VLO;@ zM2NVLDxZ@GIv7*gzl1 zjq78tv*8#WSY`}Su0&C;2F$Ze(q>F(@Wm^Gw!)(j;dk9Ad{STaxn)IV9FZhm*n+U} zi;4y*3v%A`_c7a__DJ8D1b@dl0Std3F||4Wtvi)fCcBRh!X9$1x!_VzUh>*S5s!oq z;qd{J_r79EL2wIeiGAqFstWtkfIJpjVh%zFo*=55B9Zq~y0=^iqHWfQl@O!Ak;(o*m!pZqe9 z%U2oDOhR)BvW8&F70L;2TpkzIutIvNQaTjjs5V#8mV4!NQ}zN=i`i@WI1z0eN-iCS z;vL-Wxc^Vc_qK<5RPh(}*8dLT{~GzE{w2o$2kMFaEl&q zP{V=>&3kW7tWaK-Exy{~`v4J0U#OZBk{a9{&)&QG18L@6=bsZ1zC_d{{pKZ-Ey>I> z;8H0t4bwyQqgu4hmO`3|4K{R*5>qnQ&gOfdy?z`XD%e5+pTDzUt3`k^u~SaL&XMe= z9*h#kT(*Q9jO#w2Hd|Mr-%DV8i_1{J1MU~XJ3!WUplhXDYBpJH><0OU`**nIvPIof z|N8@I=wA)sf45SAvx||f?Z5uB$kz1qL3Ky_{%RPdP5iN-D2!p5scq}buuC00C@jom zhfGKm3|f?Z0iQ|K$Z~!`8{nmAS1r+fp6r#YDOS8V*;K&Gs7Lc&f^$RC66O|)28oh`NHy&vq zJh+hAw8+ybTB0@VhWN^0iiTnLsCWbS_y`^gs!LX!Lw{yE``!UVzrV24tP8o;I6-65 z1MUiHw^{bB15tmrVT*7-#sj6cs~z`wk52YQJ*TG{SE;KTm#Hf#a~|<(|ImHH17nNM z`Ub{+J3dMD!)mzC8b(2tZtokKW5pAwHa?NFiso~# z1*iaNh4lQ4TS)|@G)H4dZV@l*Vd;Rw;-;odDhW2&lJ%m@jz+Panv7LQm~2Js6rOW3 z0_&2cW^b^MYW3)@o;neZ<{B4c#m48dAl$GCc=$>ErDe|?y@z`$uq3xd(%aAsX)D%l z>y*SQ%My`yDP*zof|3@_w#cjaW_YW4BdA;#Glg1RQcJGY*CJ9`H{@|D+*e~*457kd z73p<%fB^PV!Ybw@)Dr%(ZJbX}xmCStCYv#K3O32ej{$9IzM^I{6FJ8!(=azt7RWf4 z7ib0UOPqN40X!wOnFOoddd8`!_IN~9O)#HRTyjfc#&MCZ zZAMzOVB=;qwt8gV?{Y2?b=iSZG~RF~uyx18K)IDFLl})G1v@$(s{O4@RJ%OTJyF+Cpcx4jmy|F3euCnMK!P2WTDu5j z{{gD$=M*pH!GGzL%P)V2*ROm>!$Y=z|D`!_yY6e7SU$~a5q8?hZGgaYqaiLnkK%?0 zs#oI%;zOxF@g*@(V4p!$7dS1rOr6GVs6uYCTt2h)eB4?(&w8{#o)s#%gN@BBosRUe z)@P@8_Zm89pr~)b>e{tbPC~&_MR--iB{=)y;INU5#)@Gix-YpgP<-c2Ms{9zuCX|3 z!p(?VaXww&(w&uBHzoT%!A2=3HAP>SDxcljrego7rY|%hxy3XlODWffO_%g|l+7Y_ zqV(xbu)s4lV=l7M;f>vJl{`6qBm>#ZeMA}kXb97Z)?R97EkoI?x6Lp0yu1Z>PS?2{ z0QQ(8D)|lc9CO3B~e(pQM&5(1y&y=e>C^X$`)_&XuaI!IgDTVqt31wX#n+@!a_A0ZQkA zCJ2@M_4Gb5MfCrm5UPggeyh)8 zO9?`B0J#rkoCx(R0I!ko_2?iO@|oRf1;3r+i)w-2&j?=;NVIdPFsB)`|IC0zk6r9c zRrkfxWsiJ(#8QndNJj@{@WP2Ackr|r1VxV{7S&rSU(^)-M8gV>@UzOLXu9K<{6e{T zXJ6b92r$!|lwjhmgqkdswY&}c)KW4A)-ac%sU;2^fvq7gfUW4Bw$b!i@duy1CAxSn z(pyh$^Z=&O-q<{bZUP+$U}=*#M9uVc>CQVgDs4swy5&8RAHZ~$)hrTF4W zPsSa~qYv_0mJnF89RnnJTH`3}w4?~epFl=D(35$ zWa07ON$`OMBOHgCmfO(9RFc<)?$x)N}Jd2A(<*Ll7+4jrRt9w zwGxExUXd9VB#I|DwfxvJ;HZ8Q{37^wDhaZ%O!oO(HpcqfLH%#a#!~;Jl7F5>EX_=8 z{()l2NqPz>La3qJR;_v+wlK>GsHl;uRA8%j`A|yH@k5r%55S9{*Cp%uw6t`qc1!*T za2OeqtQj7sAp#Q~=5Fs&aCR9v>5V+s&RdNvo&H~6FJOjvaj--2sYYBvMq;55%z8^o z|BJDA4vzfow#DO#ZQHh;Oq_{r+qP{R9ox2TOgwQiv7Ow!zjN+A@BN;0tA2lUb#+zO z(^b89eV)D7UVE+h{mcNc6&GtpOqDn_?VAQ)Vob$hlFwW%xh>D#wml{t&Ofmm_d_+; zKDxzdr}`n2Rw`DtyIjrG)eD0vut$}dJAZ0AohZ+ZQdWXn_Z@dI_y=7t3q8x#pDI-K z2VVc&EGq445Rq-j0=U=Zx`oBaBjsefY;%)Co>J3v4l8V(T8H?49_@;K6q#r~Wwppc z4XW0(4k}cP=5ex>-Xt3oATZ~bBWKv)aw|I|Lx=9C1s~&b77idz({&q3T(Y(KbWO?+ zmcZ6?WeUsGk6>km*~234YC+2e6Zxdl~<_g2J|IE`GH%n<%PRv-50; zH{tnVts*S5*_RxFT9eM0z-pksIb^drUq4>QSww=u;UFCv2AhOuXE*V4z?MM`|ABOC4P;OfhS(M{1|c%QZ=!%rQTDFx`+}?Kdx$&FU?Y<$x;j7z=(;Lyz+?EE>ov!8vvMtSzG!nMie zsBa9t8as#2nH}n8xzN%W%U$#MHNXmDUVr@GX{?(=yI=4vks|V)!-W5jHsU|h_&+kY zS_8^kd3jlYqOoiI`ZqBVY!(UfnAGny!FowZWY_@YR0z!nG7m{{)4OS$q&YDyw6vC$ zm4!$h>*|!2LbMbxS+VM6&DIrL*X4DeMO!@#EzMVfr)e4Tagn~AQHIU8?e61TuhcKD zr!F4(kEebk(Wdk-?4oXM(rJwanS>Jc%<>R(siF+>+5*CqJLecP_we33iTFTXr6W^G z7M?LPC-qFHK;E!fxCP)`8rkxZyFk{EV;G-|kwf4b$c1k0atD?85+|4V%YATWMG|?K zLyLrws36p%Qz6{}>7b>)$pe>mR+=IWuGrX{3ZPZXF3plvuv5Huax86}KX*lbPVr}L z{C#lDjdDeHr~?l|)Vp_}T|%$qF&q#U;ClHEPVuS+Jg~NjC1RP=17=aQKGOcJ6B3mp z8?4*-fAD~}sX*=E6!}^u8)+m2j<&FSW%pYr_d|p_{28DZ#Cz0@NF=gC-o$MY?8Ca8 zr5Y8DSR^*urS~rhpX^05r30Ik#2>*dIOGxRm0#0YX@YQ%Mg5b6dXlS!4{7O_kdaW8PFSdj1=ryI-=5$fiieGK{LZ+SX(1b=MNL!q#lN zv98?fqqTUH8r8C7v(cx#BQ5P9W>- zmW93;eH6T`vuJ~rqtIBg%A6>q>gnWb3X!r0wh_q;211+Om&?nvYzL1hhtjB zK_7G3!n7PL>d!kj){HQE zE8(%J%dWLh1_k%gVXTZt zEdT09XSKAx27Ncaq|(vzL3gm83q>6CAw<$fTnMU05*xAe&rDfCiu`u^1)CD<>sx0i z*hr^N_TeN89G(nunZoLBf^81#pmM}>JgD@Nn1l*lN#a=B=9pN%tmvYFjFIoKe_(GF z-26x{(KXdfsQL7Uv6UtDuYwV`;8V3w>oT_I<`Ccz3QqK9tYT5ZQzbop{=I=!pMOCb zCU68`n?^DT%^&m>A%+-~#lvF!7`L7a{z<3JqIlk1$<||_J}vW1U9Y&eX<}l8##6i( zZcTT@2`9(Mecptm@{3A_Y(X`w9K0EwtPq~O!16bq{7c0f7#(3wn-^)h zxV&M~iiF!{-6A@>o;$RzQ5A50kxXYj!tcgme=Qjrbje~;5X2xryU;vH|6bE(8z^<7 zQ>BG7_c*JG8~K7Oe68i#0~C$v?-t@~@r3t2inUnLT(c=URpA9kA8uq9PKU(Ps(LVH zqgcqW>Gm?6oV#AldDPKVRcEyQIdTT`Qa1j~vS{<;SwyTdr&3*t?J)y=M7q*CzucZ&B0M=joT zBbj@*SY;o2^_h*>R0e({!QHF0=)0hOj^B^d*m>SnRrwq>MolNSgl^~r8GR#mDWGYEIJA8B<|{{j?-7p zVnV$zancW3&JVDtVpIlI|5djKq0(w$KxEFzEiiL=h5Jw~4Le23@s(mYyXWL9SX6Ot zmb)sZaly_P%BeX_9 zw&{yBef8tFm+%=--m*J|o~+Xg3N+$IH)t)=fqD+|fEk4AAZ&!wcN5=mi~Vvo^i`}> z#_3ahR}Ju)(Px7kev#JGcSwPXJ2id9%Qd2A#Uc@t8~egZ8;iC{e! z%=CGJOD1}j!HW_sgbi_8suYnn4#Ou}%9u)dXd3huFIb!ytlX>Denx@pCS-Nj$`VO&j@(z!kKSP0hE4;YIP#w9ta=3DO$7f*x zc9M4&NK%IrVmZAe=r@skWD`AEWH=g+r|*13Ss$+{c_R!b?>?UaGXlw*8qDmY#xlR= z<0XFbs2t?8i^G~m?b|!Hal^ZjRjt<@a? z%({Gn14b4-a|#uY^=@iiKH+k?~~wTj5K1A&hU z2^9-HTC)7zpoWK|$JXaBL6C z#qSNYtY>65T@Zs&-0cHeu|RX(Pxz6vTITdzJdYippF zC-EB+n4}#lM7`2Ry~SO>FxhKboIAF#Z{1wqxaCb{#yEFhLuX;Rx(Lz%T`Xo1+a2M}7D+@wol2)OJs$TwtRNJ={( zD@#zTUEE}#Fz#&(EoD|SV#bayvr&E0vzmb%H?o~46|FAcx?r4$N z&67W3mdip-T1RIxwSm_&(%U|+WvtGBj*}t69XVd&ebn>KOuL(7Y8cV?THd-(+9>G7*Nt%T zcH;`p={`SOjaf7hNd(=37Lz3-51;58JffzIPgGs_7xIOsB5p2t&@v1mKS$2D$*GQ6 zM(IR*j4{nri7NMK9xlDy-hJW6sW|ZiDRaFiayj%;(%51DN!ZCCCXz+0Vm#};70nOx zJ#yA0P3p^1DED;jGdPbQWo0WATN=&2(QybbVdhd=Vq*liDk`c7iZ?*AKEYC#SY&2g z&Q(Ci)MJ{mEat$ZdSwTjf6h~roanYh2?9j$CF@4hjj_f35kTKuGHvIs9}Re@iKMxS-OI*`0S z6s)fOtz}O$T?PLFVSeOjSO26$@u`e<>k(OSP!&YstH3ANh>)mzmKGNOwOawq-MPXe zy4xbeUAl6tamnx))-`Gi2uV5>9n(73yS)Ukma4*7fI8PaEwa)dWHs6QA6>$}7?(L8 ztN8M}?{Tf!Zu22J5?2@95&rQ|F7=FK-hihT-vDp!5JCcWrVogEnp;CHenAZ)+E+K5 z$Cffk5sNwD_?4+ymgcHR(5xgt20Z8M`2*;MzOM#>yhk{r3x=EyM226wb&!+j`W<%* zSc&|`8!>dn9D@!pYow~(DsY_naSx7(Z4i>cu#hA5=;IuI88}7f%)bRkuY2B;+9Uep zpXcvFWkJ!mQai63BgNXG26$5kyhZ2&*3Q_tk)Ii4M>@p~_~q_cE!|^A;_MHB;7s#9 zKzMzK{lIxotjc};k67^Xsl-gS!^*m*m6kn|sbdun`O?dUkJ{0cmI0-_2y=lTAfn*Y zKg*A-2sJq)CCJgY0LF-VQvl&6HIXZyxo2#!O&6fOhbHXC?%1cMc6y^*dOS{f$=137Ds1m01qs`>iUQ49JijsaQ( zksqV9@&?il$|4Ua%4!O15>Zy&%gBY&wgqB>XA3!EldQ%1CRSM(pp#k~-pkcCg4LAT zXE=puHbgsw)!xtc@P4r~Z}nTF=D2~j(6D%gTBw$(`Fc=OOQ0kiW$_RDd=hcO0t97h zb86S5r=>(@VGy1&#S$Kg_H@7G^;8Ue)X5Y+IWUi`o;mpvoV)`fcVk4FpcT|;EG!;? zHG^zrVVZOm>1KFaHlaogcWj(v!S)O(Aa|Vo?S|P z5|6b{qkH(USa*Z7-y_Uvty_Z1|B{rTS^qmEMLEYUSk03_Fg&!O3BMo{b^*`3SHvl0 zhnLTe^_vVIdcSHe)SQE}r~2dq)VZJ!aSKR?RS<(9lzkYo&dQ?mubnWmgMM37Nudwo z3Vz@R{=m2gENUE3V4NbIzAA$H1z0pagz94-PTJyX{b$yndsdKptmlKQKaaHj@3=ED zc7L?p@%ui|RegVYutK$64q4pe9+5sv34QUpo)u{1ci?)_7gXQd{PL>b0l(LI#rJmN zGuO+%GO`xneFOOr4EU(Wg}_%bhzUf;d@TU+V*2#}!2OLwg~%D;1FAu=Un>OgjPb3S z7l(riiCwgghC=Lm5hWGf5NdGp#01xQ59`HJcLXbUR3&n%P(+W2q$h2Qd z*6+-QXJ*&Kvk9ht0f0*rO_|FMBALen{j7T1l%=Q>gf#kma zQlg#I9+HB+z*5BMxdesMND`_W;q5|FaEURFk|~&{@qY32N$G$2B=&Po{=!)x5b!#n zxLzblkq{yj05#O7(GRuT39(06FJlalyv<#K4m}+vs>9@q-&31@1(QBv82{}Zkns~K ze{eHC_RDX0#^A*JQTwF`a=IkE6Ze@j#-8Q`tTT?k9`^ZhA~3eCZJ-Jr{~7Cx;H4A3 zcZ+Zj{mzFZbVvQ6U~n>$U2ZotGsERZ@}VKrgGh0xM;Jzt29%TX6_&CWzg+YYMozrM z`nutuS)_0dCM8UVaKRj804J4i%z2BA_8A4OJRQ$N(P9Mfn-gF;4#q788C@9XR0O3< zsoS4wIoyt046d+LnSCJOy@B@Uz*#GGd#+Ln1ek5Dv>(ZtD@tgZlPnZZJGBLr^JK+!$$?A_fA3LOrkoDRH&l7 zcMcD$Hsjko3`-{bn)jPL6E9Ds{WskMrivsUu5apD z?grQO@W7i5+%X&E&p|RBaEZ(sGLR@~(y^BI@lDMot^Ll?!`90KT!JXUhYS`ZgX3jnu@Ja^seA*M5R@f`=`ynQV4rc$uT1mvE?@tz)TN<=&H1%Z?5yjxcpO+6y_R z6EPuPKM5uxKpmZfT(WKjRRNHs@ib)F5WAP7QCADvmCSD#hPz$V10wiD&{NXyEwx5S z6NE`3z!IS^$s7m}PCwQutVQ#~w+V z=+~->DI*bR2j0^@dMr9`p>q^Ny~NrAVxrJtX2DUveic5vM%#N*XO|?YAWwNI$Q)_) zvE|L(L1jP@F%gOGtnlXtIv2&1i8q<)Xfz8O3G^Ea~e*HJsQgBxWL(yuLY+jqUK zRE~`-zklrGog(X}$9@ZVUw!8*=l`6mzYLtsg`AvBYz(cxmAhr^j0~(rzXdiOEeu_p zE$sf2(w(BPAvO5DlaN&uQ$4@p-b?fRs}d7&2UQ4Fh?1Hzu*YVjcndqJLw0#q@fR4u zJCJ}>_7-|QbvOfylj+e^_L`5Ep9gqd>XI3-O?Wp z-gt*P29f$Tx(mtS`0d05nHH=gm~Po_^OxxUwV294BDKT>PHVlC5bndncxGR!n(OOm znsNt@Q&N{TLrmsoKFw0&_M9$&+C24`sIXGWgQaz=kY;S{?w`z^Q0JXXBKFLj0w0U6P*+jPKyZHX9F#b0D1$&(- zrm8PJd?+SrVf^JlfTM^qGDK&-p2Kdfg?f>^%>1n8bu&byH(huaocL>l@f%c*QkX2i znl}VZ4R1en4S&Bcqw?$=Zi7ohqB$Jw9x`aM#>pHc0x z0$!q7iFu zZ`tryM70qBI6JWWTF9EjgG@>6SRzsd}3h+4D8d~@CR07P$LJ}MFsYi-*O%XVvD@yT|rJ+Mk zDllJ7$n0V&A!0flbOf)HE6P_afPWZmbhpliqJuw=-h+r;WGk|ntkWN(8tKlYpq5Ow z(@%s>IN8nHRaYb*^d;M(D$zGCv5C|uqmsDjwy4g=Lz>*OhO3z=)VD}C<65;`89Ye} zSCxrv#ILzIpEx1KdLPlM&%Cctf@FqTKvNPXC&`*H9=l=D3r!GLM?UV zOxa(8ZsB`&+76S-_xuj?G#wXBfDY@Z_tMpXJS7^mp z@YX&u0jYw2A+Z+bD#6sgVK5ZgdPSJV3>{K^4~%HV?rn~4D)*2H!67Y>0aOmzup`{D zzDp3c9yEbGCY$U<8biJ_gB*`jluz1ShUd!QUIQJ$*1;MXCMApJ^m*Fiv88RZ zFopLViw}{$Tyhh_{MLGIE2~sZ)t0VvoW%=8qKZ>h=adTe3QM$&$PO2lfqH@brt!9j ziePM8$!CgE9iz6B<6_wyTQj?qYa;eC^{x_0wuwV~W+^fZmFco-o%wsKSnjXFEx02V zF5C2t)T6Gw$Kf^_c;Ei3G~uC8SM-xyycmXyC2hAVi-IfXqhu$$-C=*|X?R0~hu z8`J6TdgflslhrmDZq1f?GXF7*ALeMmOEpRDg(s*H`4>_NAr`2uqF;k;JQ+8>A|_6ZNsNLECC%NNEb1Y1dP zbIEmNpK)#XagtL4R6BC{C5T(+=yA-(Z|Ap}U-AfZM#gwVpus3(gPn}Q$CExObJ5AC z)ff9Yk?wZ}dZ-^)?cbb9Fw#EjqQ8jxF4G3=L?Ra zg_)0QDMV1y^A^>HRI$x?Op@t;oj&H@1xt4SZ9(kifQ zb59B*`M99Td7@aZ3UWvj1rD0sE)d=BsBuW*KwkCds7ay(7*01_+L}b~7)VHI>F_!{ zyxg-&nCO?v#KOUec0{OOKy+sjWA;8rTE|Lv6I9H?CI?H(mUm8VXGwU$49LGpz&{nQp2}dinE1@lZ1iox6{ghN&v^GZv9J${7WaXj)<0S4g_uiJ&JCZ zr8-hsu`U%N;+9N^@&Q0^kVPB3)wY(rr}p7{p0qFHb3NUUHJb672+wRZs`gd1UjKPX z4o6zljKKA+Kkj?H>Ew63o%QjyBk&1!P22;MkD>sM0=z_s-G{mTixJCT9@_|*(p^bz zJ8?ZZ&;pzV+7#6Mn`_U-)k8Pjg?a;|Oe^us^PoPY$Va~yi8|?+&=y$f+lABT<*pZr zP}D{~Pq1Qyni+@|aP;ixO~mbEW9#c0OU#YbDZIaw=_&$K%Ep2f%hO^&P67hApZe`x zv8b`Mz@?M_7-)b!lkQKk)JXXUuT|B8kJlvqRmRpxtQDgvrHMXC1B$M@Y%Me!BSx3P z#2Eawl$HleZhhTS6Txm>lN_+I`>eV$&v9fOg)%zVn3O5mI*lAl>QcHuW6!Kixmq`X zBCZ*Ck6OYtDiK!N47>jxI&O2a9x7M|i^IagRr-fmrmikEQGgw%J7bO|)*$2FW95O4 zeBs>KR)izRG1gRVL;F*sr8A}aRHO0gc$$j&ds8CIO1=Gwq1%_~E)CWNn9pCtBE}+`Jelk4{>S)M)`Ll=!~gnn1yq^EX(+y*ik@3Ou0qU`IgYi3*doM+5&dU!cho$pZ zn%lhKeZkS72P?Cf68<#kll_6OAO26bIbueZx**j6o;I0cS^XiL`y+>{cD}gd%lux} z)3N>MaE24WBZ}s0ApfdM;5J_Ny}rfUyxfkC``Awo2#sgLnGPewK};dORuT?@I6(5~ z?kE)Qh$L&fwJXzK){iYx!l5$Tt|^D~MkGZPA}(o6f7w~O2G6Vvzdo*a;iXzk$B66$ zwF#;wM7A+(;uFG4+UAY(2`*3XXx|V$K8AYu#ECJYSl@S=uZW$ksfC$~qrrbQj4??z-)uz0QL}>k^?fPnJTPw% zGz)~?B4}u0CzOf@l^um}HZzbaIwPmb<)< zi_3@E9lc)Qe2_`*Z^HH;1CXOceL=CHpHS{HySy3T%<^NrWQ}G0i4e1xm_K3(+~oi$ zoHl9wzb?Z4j#90DtURtjtgvi7uw8DzHYmtPb;?%8vb9n@bszT=1qr)V_>R%s!92_` zfnHQPANx z<#hIjIMm#*(v*!OXtF+w8kLu`o?VZ5k7{`vw{Yc^qYclpUGIM_PBN1+c{#Vxv&E*@ zxg=W2W~JuV{IuRYw3>LSI1)a!thID@R=bU+cU@DbR^_SXY`MC7HOsCN z!dO4OKV7(E_Z8T#8MA1H`99?Z!r0)qKW_#|29X3#Jb+5+>qUidbeP1NJ@)(qi2S-X zao|f0_tl(O+$R|Qwd$H{_ig|~I1fbp_$NkI!0E;Y z6JrnU{1Ra6^on{9gUUB0mwzP3S%B#h0fjo>JvV~#+X0P~JV=IG=yHG$O+p5O3NUgG zEQ}z6BTp^Fie)Sg<){Z&I8NwPR(=mO4joTLHkJ>|Tnk23E(Bo`FSbPc05lF2-+)X? z6vV3*m~IBHTy*^E!<0nA(tCOJW2G4DsH7)BxLV8kICn5lu6@U*R`w)o9;Ro$i8=Q^V%uH8n3q=+Yf;SFRZu z!+F&PKcH#8cG?aSK_Tl@K9P#8o+jry@gdexz&d(Q=47<7nw@e@FFfIRNL9^)1i@;A z28+$Z#rjv-wj#heI|<&J_DiJ*s}xd-f!{J8jfqOHE`TiHHZVIA8CjkNQ_u;Ery^^t zl1I75&u^`1_q)crO+JT4rx|z2ToSC>)Or@-D zy3S>jW*sNIZR-EBsfyaJ+Jq4BQE4?SePtD2+jY8*%FsSLZ9MY>+wk?}}}AFAw)vr{ml)8LUG-y9>^t!{~|sgpxYc0Gnkg`&~R z-pilJZjr@y5$>B=VMdZ73svct%##v%wdX~9fz6i3Q-zOKJ9wso+h?VME7}SjL=!NUG{J?M&i!>ma`eoEa@IX`5G>B1(7;%}M*%-# zfhJ(W{y;>MRz!Ic8=S}VaBKqh;~7KdnGEHxcL$kA-6E~=!hrN*zw9N+_=odt<$_H_8dbo;0=42wcAETPCVGUr~v(`Uai zb{=D!Qc!dOEU6v)2eHSZq%5iqK?B(JlCq%T6av$Cb4Rko6onlG&?CqaX7Y_C_cOC3 zYZ;_oI(}=>_07}Oep&Ws7x7-R)cc8zfe!SYxJYP``pi$FDS)4Fvw5HH=FiU6xfVqIM!hJ;Rx8c0cB7~aPtNH(Nmm5Vh{ibAoU#J6 zImRCr?(iyu_4W_6AWo3*vxTPUw@vPwy@E0`(>1Qi=%>5eSIrp^`` zK*Y?fK_6F1W>-7UsB)RPC4>>Ps9)f+^MqM}8AUm@tZ->j%&h1M8s*s!LX5&WxQcAh z8mciQej@RPm?660%>{_D+7er>%zX_{s|$Z+;G7_sfNfBgY(zLB4Ey}J9F>zX#K0f6 z?dVNIeEh?EIShmP6>M+d|0wMM85Sa4diw1hrg|ITJ}JDg@o8y>(rF9mXk5M z2@D|NA)-7>wD&wF;S_$KS=eE84`BGw3g0?6wGxu8ys4rwI?9U=*^VF22t3%mbGeOh z`!O-OpF7#Vceu~F`${bW0nYVU9ecmk31V{tF%iv&5hWofC>I~cqAt@u6|R+|HLMMX zVxuSlMFOK_EQ86#E8&KwxIr8S9tj_goWtLv4f@!&h8;Ov41{J~496vp9vX=(LK#j! zAwi*21RAV-LD>9Cw3bV_9X(X3)Kr0-UaB*7Y>t82EQ%!)(&(XuAYtTsYy-dz+w=$ir)VJpe!_$ z6SGpX^i(af3{o=VlFPC);|J8#(=_8#vdxDe|Cok+ANhYwbE*FO`Su2m1~w+&9<_9~ z-|tTU_ACGN`~CNW5WYYBn^B#SwZ(t4%3aPp z;o)|L6Rk569KGxFLUPx@!6OOa+5OjQLK5w&nAmwxkC5rZ|m&HT8G%GVZxB_@ME z>>{rnXUqyiJrT(8GMj_ap#yN_!9-lO5e8mR3cJiK3NE{_UM&=*vIU`YkiL$1%kf+1 z4=jk@7EEj`u(jy$HnzE33ZVW_J4bj}K;vT?T91YlO(|Y0FU4r+VdbmQ97%(J5 zkK*Bed8+C}FcZ@HIgdCMioV%A<*4pw_n}l*{Cr4}a(lq|injK#O?$tyvyE`S%(1`H z_wwRvk#13ElkZvij2MFGOj`fhy?nC^8`Zyo%yVcUAfEr8x&J#A{|moUBAV_^f$hpaUuyQeY3da^ zS9iRgf87YBwfe}>BO+T&Fl%rfpZh#+AM?Dq-k$Bq`vG6G_b4z%Kbd&v>qFjow*mBl z-OylnqOpLg}or7_VNwRg2za3VBK6FUfFX{|TD z`Wt0Vm2H$vdlRWYQJqDmM?JUbVqL*ZQY|5&sY*?!&%P8qhA~5+Af<{MaGo(dl&C5t zE%t!J0 zh6jqANt4ABdPxSTrVV}fLsRQal*)l&_*rFq(Ez}ClEH6LHv{J#v?+H-BZ2)Wy{K@9 z+ovXHq~DiDvm>O~r$LJo!cOuwL+Oa--6;UFE2q@g3N8Qkw5E>ytz^(&($!O47+i~$ zKM+tkAd-RbmP{s_rh+ugTD;lriL~`Xwkad#;_aM?nQ7L_muEFI}U_4$phjvYgleK~`Fo`;GiC07&Hq1F<%p;9Q;tv5b?*QnR%8DYJH3P>Svmv47Y>*LPZJy8_{9H`g6kQpyZU{oJ`m%&p~D=K#KpfoJ@ zn-3cqmHsdtN!f?~w+(t+I`*7GQA#EQC^lUA9(i6=i1PqSAc|ha91I%X&nXzjYaM{8$s&wEx@aVkQ6M{E2 zfzId#&r(XwUNtPcq4Ngze^+XaJA1EK-%&C9j>^9(secqe{}z>hR5CFNveMsVA)m#S zk)_%SidkY-XmMWlVnQ(mNJ>)ooszQ#vaK;!rPmGKXV7am^_F!Lz>;~{VrIO$;!#30XRhE1QqO_~#+Ux;B_D{Nk=grn z8Y0oR^4RqtcYM)7a%@B(XdbZCOqnX#fD{BQTeLvRHd(irHKq=4*jq34`6@VAQR8WG z^%)@5CXnD_T#f%@-l${>y$tfb>2LPmc{~5A82|16mH)R?&r#KKLs7xpN-D`=&Cm^R zvMA6#Ahr<3X>Q7|-qfTY)}32HkAz$_mibYV!I)u>bmjK`qwBe(>za^0Kt*HnFbSdO z1>+ryKCNxmm^)*$XfiDOF2|{-v3KKB?&!(S_Y=Ht@|ir^hLd978xuI&N{k>?(*f8H z=ClxVJK_%_z1TH0eUwm2J+2To7FK4o+n_na)&#VLn1m;!+CX+~WC+qg1?PA~KdOlC zW)C@pw75_xoe=w7i|r9KGIvQ$+3K?L{7TGHwrQM{dCp=Z*D}3kX7E-@sZnup!BImw z*T#a=+WcTwL78exTgBn|iNE3#EsOorO z*kt)gDzHiPt07fmisA2LWN?AymkdqTgr?=loT7z@d`wnlr6oN}@o|&JX!yPzC*Y8d zu6kWlTzE1)ckyBn+0Y^HMN+GA$wUO_LN6W>mxCo!0?oiQvT`z$jbSEu&{UHRU0E8# z%B^wOc@S!yhMT49Y)ww(Xta^8pmPCe@eI5C*ed96)AX9<>))nKx0(sci8gwob_1}4 z0DIL&vsJ1_s%<@y%U*-eX z5rN&(zef-5G~?@r79oZGW1d!WaTqQn0F6RIOa9tJ=0(kdd{d1{<*tHT#cCvl*i>YY zH+L7jq8xZNcTUBqj(S)ztTU!TM!RQ}In*n&Gn<>(60G7}4%WQL!o>hbJqNDSGwl#H z`4k+twp0cj%PsS+NKaxslAEu9!#U3xT1|_KB6`h=PI0SW`P9GTa7caD1}vKEglV8# zjKZR`pluCW19c2fM&ZG)c3T3Um;ir3y(tSCJ7Agl6|b524dy5El{^EQBG?E61H0XY z`bqg!;zhGhyMFl&(o=JWEJ8n~z)xI}A@C0d2hQGvw7nGv)?POU@(kS1m=%`|+^ika zXl8zjS?xqW$WlO?Ewa;vF~XbybHBor$f<%I&*t$F5fynwZlTGj|IjZtVfGa7l&tK} zW>I<69w(cZLu)QIVG|M2xzW@S+70NinQzk&Y0+3WT*cC)rx~04O-^<{JohU_&HL5XdUKW!uFy|i$FB|EMu0eUyW;gsf`XfIc!Z0V zeK&*hPL}f_cX=@iv>K%S5kL;cl_$v?n(Q9f_cChk8Lq$glT|=e+T*8O4H2n<=NGmn z+2*h+v;kBvF>}&0RDS>)B{1!_*XuE8A$Y=G8w^qGMtfudDBsD5>T5SB;Qo}fSkkiV ze^K^M(UthkwrD!&*tTsu>Dacdj_q`~V%r_twr$(Ct&_dKeeXE?fA&4&yASJWJ*}~- zel=@W)tusynfC_YqH4ll>4Eg`Xjs5F7Tj>tTLz<0N3)X<1px_d2yUY>X~y>>93*$) z5PuNMQLf9Bu?AAGO~a_|J2akO1M*@VYN^VxvP0F$2>;Zb9;d5Yfd8P%oFCCoZE$ z4#N$^J8rxYjUE_6{T%Y>MmWfHgScpuGv59#4u6fpTF%~KB^Ae`t1TD_^Ud#DhL+Dm zbY^VAM#MrAmFj{3-BpVSWph2b_Y6gCnCAombVa|1S@DU)2r9W<> zT5L8BB^er3zxKt1v(y&OYk!^aoQisqU zH(g@_o)D~BufUXcPt!Ydom)e|aW{XiMnes2z&rE?og>7|G+tp7&^;q?Qz5S5^yd$i z8lWr4g5nctBHtigX%0%XzIAB8U|T6&JsC4&^hZBw^*aIcuNO47de?|pGXJ4t}BB`L^d8tD`H`i zqrP8?#J@8T#;{^B!KO6J=@OWKhAerih(phML`(Rg7N1XWf1TN>=Z3Do{l_!d~DND&)O)D>ta20}@Lt77qSnVsA7>)uZAaT9bsB>u&aUQl+7GiY2|dAEg@%Al3i316y;&IhQL^8fw_nwS>f60M_-m+!5)S_6EPM7Y)(Nq^8gL7(3 zOiot`6Wy6%vw~a_H?1hLVzIT^i1;HedHgW9-P#)}Y6vF%C=P70X0Tk^z9Te@kPILI z_(gk!k+0%CG)%!WnBjjw*kAKs_lf#=5HXC00s-}oM-Q1aXYLj)(1d!_a7 z*Gg4Fe6F$*ujVjI|79Z5+Pr`us%zW@ln++2l+0hsngv<{mJ%?OfSo_3HJXOCys{Ug z00*YR-(fv<=&%Q!j%b-_ppA$JsTm^_L4x`$k{VpfLI(FMCap%LFAyq;#ns5bR7V+x zO!o;c5y~DyBPqdVQX)8G^G&jWkBy2|oWTw>)?5u}SAsI$RjT#)lTV&Rf8;>u*qXnb z8F%Xb=7#$m)83z%`E;49)t3fHInhtc#kx4wSLLms!*~Z$V?bTyUGiS&m>1P(952(H zuHdv=;o*{;5#X-uAyon`hP}d#U{uDlV?W?_5UjJvf%11hKwe&(&9_~{W)*y1nR5f_ z!N(R74nNK`y8>B!0Bt_Vr!;nc3W>~RiKtGSBkNlsR#-t^&;$W#)f9tTlZz>n*+Fjz z3zXZ;jf(sTM(oDzJt4FJS*8c&;PLTW(IQDFs_5QPy+7yhi1syPCarvqrHFcf&yTy)^O<1EBx;Ir`5W{TIM>{8w&PB>ro4;YD<5LF^TjTb0!zAP|QijA+1Vg>{Afv^% zmrkc4o6rvBI;Q8rj4*=AZacy*n8B{&G3VJc)so4$XUoie0)vr;qzPZVbb<#Fc=j+8CGBWe$n|3K& z_@%?{l|TzKSlUEO{U{{%Fz_pVDxs7i9H#bnbCw7@4DR=}r_qV!Zo~CvD4ZI*+j3kO zW6_=|S`)(*gM0Z;;}nj`73OigF4p6_NPZQ-Od~e$c_);;4-7sR>+2u$6m$Gf%T{aq zle>e3(*Rt(TPD}03n5)!Ca8Pu!V}m6v0o1;5<1h$*|7z|^(3$Y&;KHKTT}hV056wuF0Xo@mK-52~r=6^SI1NC%c~CC?n>yX6wPTgiWYVz!Sx^atLby9YNn1Rk{g?|pJaxD4|9cUf|V1_I*w zzxK)hRh9%zOl=*$?XUjly5z8?jPMy%vEN)f%T*|WO|bp5NWv@B(K3D6LMl!-6dQg0 zXNE&O>Oyf%K@`ngCvbGPR>HRg5!1IV$_}m@3dWB7x3t&KFyOJn9pxRXCAzFr&%37wXG;z^xaO$ekR=LJG ztIHpY8F5xBP{mtQidqNRoz= z@){+N3(VO5bD+VrmS^YjG@+JO{EOIW)9=F4v_$Ed8rZtHvjpiEp{r^c4F6Ic#ChlC zJX^DtSK+v(YdCW)^EFcs=XP7S>Y!4=xgmv>{S$~@h=xW-G4FF9?I@zYN$e5oF9g$# zb!eVU#J+NjLyX;yb)%SY)xJdvGhsnE*JEkuOVo^k5PyS=o#vq!KD46UTW_%R=Y&0G zFj6bV{`Y6)YoKgqnir2&+sl+i6foAn-**Zd1{_;Zb7Ki=u394C5J{l^H@XN`_6XTKY%X1AgQM6KycJ+= zYO=&t#5oSKB^pYhNdzPgH~aEGW2=ec1O#s-KG z71}LOg@4UEFtp3GY1PBemXpNs6UK-ax*)#$J^pC_me;Z$Je(OqLoh|ZrW*mAMBFn< zHttjwC&fkVfMnQeen8`Rvy^$pNRFVaiEN4Pih*Y3@jo!T0nsClN)pdrr9AYLcZxZ| zJ5Wlj+4q~($hbtuY zVQ7hl>4-+@6g1i`1a)rvtp-;b0>^`Dloy(#{z~ytgv=j4q^Kl}wD>K_Y!l~ zp(_&7sh`vfO(1*MO!B%<6E_bx1)&s+Ae`O)a|X=J9y~XDa@UB`m)`tSG4AUhoM=5& znWoHlA-(z@3n0=l{E)R-p8sB9XkV zZ#D8wietfHL?J5X0%&fGg@MH~(rNS2`GHS4xTo7L$>TPme+Is~!|79=^}QbPF>m%J zFMkGzSndiPO|E~hrhCeo@&Ea{M(ieIgRWMf)E}qeTxT8Q#g-!Lu*x$v8W^M^>?-g= zwMJ$dThI|~M06rG$Sv@C@tWR>_YgaG&!BAbkGggVQa#KdtDB)lMLNVLN|51C@F^y8 zCRvMB^{GO@j=cHfmy}_pCGbP%xb{pNN>? z?7tBz$1^zVaP|uaatYaIN+#xEN4jBzwZ|YI_)p(4CUAz1ZEbDk>J~Y|63SZaak~#0 zoYKruYsWHoOlC1(MhTnsdUOwQfz5p6-D0}4;DO$B;7#M{3lSE^jnTT;ns`>!G%i*F?@pR1JO{QTuD0U+~SlZxcc8~>IB{)@8p`P&+nDxNj`*gh|u?yrv$phpQcW)Us)bi`kT%qLj(fi{dWRZ%Es2!=3mI~UxiW0$-v3vUl?#g{p6eF zMEUAqo5-L0Ar(s{VlR9g=j7+lt!gP!UN2ICMokAZ5(Agd>})#gkA2w|5+<%-CuEP# zqgcM}u@3(QIC^Gx<2dbLj?cFSws_f3e%f4jeR?4M^M3cx1f+Qr6ydQ>n)kz1s##2w zk}UyQc+Z5G-d-1}{WzjkLXgS-2P7auWSJ%pSnD|Uivj5u!xk0 z_^-N9r9o;(rFDt~q1PvE#iJZ_f>J3gcP$)SOqhE~pD2|$=GvpL^d!r z6u=sp-CrMoF7;)}Zd7XO4XihC4ji?>V&(t^?@3Q&t9Mx=qex6C9d%{FE6dvU6%d94 zIE;hJ1J)cCqjv?F``7I*6bc#X)JW2b4f$L^>j{*$R`%5VHFi*+Q$2;nyieduE}qdS{L8y8F08yLs?w}{>8>$3236T-VMh@B zq-nujsb_1aUv_7g#)*rf9h%sFj*^mIcImRV*k~Vmw;%;YH(&ylYpy!&UjUVqqtfG` zox3esju?`unJJA_zKXRJP)rA3nXc$m^{S&-p|v|-0x9LHJm;XIww7C#R$?00l&Yyj z=e}gKUOpsImwW?N)+E(awoF@HyP^EhL+GlNB#k?R<2>95hz!h9sF@U20DHSB3~WMa zk90+858r@-+vWwkawJ)8ougd(i#1m3GLN{iSTylYz$brAsP%=&m$mQQrH$g%3-^VR zE%B`Vi&m8f3T~&myTEK28BDWCVzfWir1I?03;pX))|kY5ClO^+bae z*7E?g=3g7EiisYOrE+lA)2?Ln6q2*HLNpZEWMB|O-JI_oaHZB%CvYB(%=tU= zE*OY%QY58fW#RG5=gm0NR#iMB=EuNF@)%oZJ}nmm=tsJ?eGjia{e{yuU0l3{d^D@)kVDt=1PE)&tf_hHC%0MB znL|CRCPC}SeuVTdf>-QV70`0(EHizc21s^sU>y%hW0t!0&y<7}Wi-wGy>m%(-jsDj zP?mF|>p_K>liZ6ZP(w5(|9Ga%>tLgb$|doDDfkdW>Z z`)>V2XC?NJT26mL^@ zf+IKr27TfM!UbZ@?zRddC7#6ss1sw%CXJ4FWC+t3lHZupzM77m^=9 z&(a?-LxIq}*nvv)y?27lZ{j zifdl9hyJudyP2LpU$-kXctshbJDKS{WfulP5Dk~xU4Le4c#h^(YjJit4#R8_khheS z|8(>2ibaHES4+J|DBM7I#QF5u-*EdN{n=Kt@4Zt?@Tv{JZA{`4 zU#kYOv{#A&gGPwT+$Ud}AXlK3K7hYzo$(fBSFjrP{QQ zeaKg--L&jh$9N}`pu{Bs>?eDFPaWY4|9|foN%}i;3%;@4{dc+iw>m}{3rELqH21G! z`8@;w-zsJ1H(N3%|1B@#ioLOjib)j`EiJqPQVSbPSPVHCj6t5J&(NcWzBrzCiDt{4 zdlPAUKldz%6x5II1H_+jv)(xVL+a;P+-1hv_pM>gMRr%04@k;DTokASSKKhU1Qms| zrWh3a!b(J3n0>-tipg{a?UaKsP7?+|@A+1WPDiQIW1Sf@qDU~M_P65_s}7(gjTn0X zucyEm)o;f8UyshMy&>^SC3I|C6jR*R_GFwGranWZe*I>K+0k}pBuET&M~ z;Odo*ZcT?ZpduHyrf8E%IBFtv;JQ!N_m>!sV6ly$_1D{(&nO~w)G~Y`7sD3#hQk%^ zp}ucDF_$!6DAz*PM8yE(&~;%|=+h(Rn-=1Wykas_-@d&z#=S}rDf`4w(rVlcF&lF! z=1)M3YVz7orwk^BXhslJ8jR);sh^knJW(Qmm(QdSgIAIdlN4Te5KJisifjr?eB{FjAX1a0AB>d?qY4Wx>BZ8&}5K0fA+d{l8 z?^s&l8#j7pR&ijD?0b%;lL9l$P_mi2^*_OL+b}4kuLR$GAf85sOo02?Y#90}CCDiS zZ%rbCw>=H~CBO=C_JVV=xgDe%b4FaEFtuS7Q1##y686r%F6I)s-~2(}PWK|Z8M+Gu zl$y~5@#0Ka%$M<&Cv%L`a8X^@tY&T7<0|(6dNT=EsRe0%kp1Qyq!^43VAKYnr*A5~ zsI%lK1ewqO;0TpLrT9v}!@vJK{QoVa_+N4FYT#h?Y8rS1S&-G+m$FNMP?(8N`MZP zels(*?kK{{^g9DOzkuZXJ2;SrOQsp9T$hwRB1(phw1c7`!Q!by?Q#YsSM#I12RhU{$Q+{xj83axHcftEc$mNJ8_T7A-BQc*k(sZ+~NsO~xAA zxnbb%dam_fZlHvW7fKXrB~F&jS<4FD2FqY?VG?ix*r~MDXCE^WQ|W|WM;gsIA4lQP zJ2hAK@CF*3*VqPr2eeg6GzWFlICi8S>nO>5HvWzyZTE)hlkdC_>pBej*>o0EOHR|) z$?};&I4+_?wvL*g#PJ9)!bc#9BJu1(*RdNEn>#Oxta(VWeM40ola<0aOe2kSS~{^P zDJBd}0L-P#O-CzX*%+$#v;(x%<*SPgAje=F{Zh-@ucd2DA(yC|N_|ocs*|-!H%wEw z@Q!>siv2W;C^^j^59OAX03&}&D*W4EjCvfi(ygcL#~t8XGa#|NPO+*M@Y-)ctFA@I z-p7npT1#5zOLo>7q?aZpCZ=iecn3QYklP;gF0bq@>oyBq94f6C=;Csw3PkZ|5q=(c zfs`aw?II0e(h=|7o&T+hq&m$; zBrE09Twxd9BJ2P+QPN}*OdZ-JZV7%av@OM7v!!NL8R;%WFq*?{9T3{ct@2EKgc8h) zMxoM$SaF#p<`65BwIDfmXG6+OiK0e)`I=!A3E`+K@61f}0e z!2a*FOaDrOe>U`q%K!QN`&=&0C~)CaL3R4VY(NDt{Xz(Xpqru5=r#uQN1L$Je1*dkdqQ*=lofQaN%lO!<5z9ZlHgxt|`THd>2 zsWfU$9=p;yLyJyM^t zS2w9w?Bpto`@H^xJpZDKR1@~^30Il6oFGfk5%g6w*C+VM)+%R@gfIwNprOV5{F^M2 zO?n3DEzpT+EoSV-%OdvZvNF+pDd-ZVZ&d8 zKeIyrrfPN=EcFRCPEDCVflX#3-)Ik_HCkL(ejmY8vzcf-MTA{oHk!R2*36`O68$7J zf}zJC+bbQk--9Xm!u#lgLvx8TXx2J258E5^*IZ(FXMpq$2LUUvhWQPs((z1+2{Op% z?J}9k5^N=z;7ja~zi8a_-exIqWUBJwohe#4QJ`|FF*$C{lM18z^#hX6!5B8KAkLUX ziP=oti-gpV(BsLD{0(3*dw}4JxK23Y7M{BeFPucw!sHpY&l%Ws4pSm`+~V7;bZ%Dx zeI)MK=4vC&5#;2MT7fS?^ch9?2;%<8Jlu-IB&N~gg8t;6S-#C@!NU{`p7M8@2iGc& zg|JPg%@gCoCQ&s6JvDU&`X2S<57f(k8nJ1wvBu{8r?;q3_kpZZ${?|( z+^)UvR33sjSd)aT!UPkA;ylO6{aE3MQa{g%Mcf$1KONcjO@&g5zPHWtzM1rYC{_K> zgQNcs<{&X{OA=cEWw5JGqpr0O>x*Tfak2PE9?FuWtz^DDNI}rwAaT0(bdo-<+SJ6A z&}S%boGMWIS0L}=S>|-#kRX;e^sUsotry(MjE|3_9duvfc|nwF#NHuM-w7ZU!5ei8 z6Mkf>2)WunY2eU@C-Uj-A zG(z0Tz2YoBk>zCz_9-)4a>T46$(~kF+Y{#sA9MWH%5z#zNoz)sdXq7ZR_+`RZ%0(q zC7&GyS_|BGHNFl8Xa%@>iWh%Gr?=J5<(!OEjauj5jyrA-QXBjn0OAhJJ9+v=!LK`` z@g(`^*84Q4jcDL`OA&ZV60djgwG`|bcD*i50O}Q{9_noRg|~?dj%VtKOnyRs$Uzqg z191aWoR^rDX#@iSq0n z?9Sg$WSRPqSeI<}&n1T3!6%Wj@5iw5`*`Btni~G=&;J+4`7g#OQTa>u`{4ZZ(c@s$ zK0y;ySOGD-UTjREKbru{QaS>HjN<2)R%Nn-TZiQ(Twe4p@-saNa3~p{?^V9Nixz@a zykPv~<@lu6-Ng9i$Lrk(xi2Tri3q=RW`BJYOPC;S0Yly%77c727Yj-d1vF!Fuk{Xh z)lMbA69y7*5ufET>P*gXQrxsW+ zz)*MbHZv*eJPEXYE<6g6_M7N%#%mR{#awV3i^PafNv(zyI)&bH?F}2s8_rR(6%!V4SOWlup`TKAb@ee>!9JKPM=&8g#BeYRH9FpFybxBXQI2|g}FGJfJ+ zY-*2hB?o{TVL;Wt_ek;AP5PBqfDR4@Z->_182W z{P@Mc27j6jE*9xG{R$>6_;i=y{qf(c`5w9fa*`rEzX6t!KJ(p1H|>J1pC-2zqWENF zmm=Z5B4u{cY2XYl(PfrInB*~WGWik3@1oRhiMOS|D;acnf-Bs(QCm#wR;@Vf!hOPJ zgjhDCfDj$HcyVLJ=AaTbQ{@vIv14LWWF$=i-BDoC11}V;2V8A`S>_x)vIq44-VB-v z*w-d}$G+Ql?En8j!~ZkCpQ$|cA0|+rrY>tiCeWxkRGPoarxlGU2?7%k#F693RHT24 z-?JsiXlT2PTqZqNb&sSc>$d;O4V@|b6VKSWQb~bUaWn1Cf0+K%`Q&Wc<>mQ>*iEGB zbZ;aYOotBZ{vH3y<0A*L0QVM|#rf*LIsGx(O*-7)r@yyBIzJnBFSKBUSl1e|8lxU* zzFL+YDVVkIuzFWeJ8AbgN&w(4-7zbiaMn{5!JQXu)SELk*CNL+Fro|2v|YO)1l15t zs(0^&EB6DPMyaqvY>=KL>)tEpsn;N5Q#yJj<9}ImL((SqErWN3Q=;tBO~ExTCs9hB z2E$7eN#5wX4<3m^5pdjm#5o>s#eS_Q^P)tm$@SawTqF*1dj_i#)3};JslbLKHXl_N z)Fxzf>FN)EK&Rz&*|6&%Hs-^f{V|+_vL1S;-1K-l$5xiC@}%uDuwHYhmsV?YcOUlk zOYkG5v2+`+UWqpn0aaaqrD3lYdh0*!L`3FAsNKu=Q!vJu?Yc8n|CoYyDo_`r0mPoo z8>XCo$W4>l(==h?2~PoRR*kEe)&IH{1sM41mO#-36`02m#nTX{r*r`Q5rZ2-sE|nA zhnn5T#s#v`52T5|?GNS`%HgS2;R(*|^egNPDzzH_z^W)-Q98~$#YAe)cEZ%vge965AS_am#DK#pjPRr-!^za8>`kksCAUj(Xr*1NW5~e zpypt_eJpD&4_bl_y?G%>^L}=>xAaV>KR6;^aBytqpiHe%!j;&MzI_>Sx7O%F%D*8s zSN}cS^<{iiK)=Ji`FpO#^zY!_|D)qeRNAtgmH)m;qC|mq^j(|hL`7uBz+ULUj37gj zksdbnU+LSVo35riSX_4z{UX=%n&}7s0{WuZYoSfwAP`8aKN9P@%e=~1`~1ASL-z%# zw>DO&ixr}c9%4InGc*_y42bdEk)ZdG7-mTu0bD@_vGAr*NcFoMW;@r?@LUhRI zCUJgHb`O?M3!w)|CPu~ej%fddw20lod?Ufp8Dmt0PbnA0J%KE^2~AIcnKP()025V> zG>noSM3$5Btmc$GZoyP^v1@Poz0FD(6YSTH@aD0}BXva?LphAiSz9f&Y(aDAzBnUh z?d2m``~{z;{}kZJ>a^wYI?ry(V9hIoh;|EFc0*-#*`$T0DRQ1;WsqInG;YPS+I4{g zJGpKk%%Sdc5xBa$Q^_I~(F97eqDO7AN3EN0u)PNBAb+n+ zWBTxQx^;O9o0`=g+Zrt_{lP!sgWZHW?8bLYS$;1a@&7w9rD9|Ge;Gb?sEjFoF9-6v z#!2)t{DMHZ2@0W*fCx;62d#;jouz`R5Y(t{BT=$N4yr^^o$ON8d{PQ=!O zX17^CrdM~7D-;ZrC!||<+FEOxI_WI3CA<35va%4v>gc zEX-@h8esj=a4szW7x{0g$hwoWRQG$yK{@3mqd-jYiVofJE!Wok1* znV7Gm&Ssq#hFuvj1sRyHg(6PFA5U*Q8Rx>-blOs=lb`qa{zFy&n4xY;sd$fE+<3EI z##W$P9M{B3c3Si9gw^jlPU-JqD~Cye;wr=XkV7BSv#6}DrsXWFJ3eUNrc%7{=^sP> zrp)BWKA9<}^R9g!0q7yWlh;gr_TEOD|#BmGq<@IV;ueg+D2}cjpp+dPf&Q(36sFU&K8}hA85U61faW&{ zlB`9HUl-WWCG|<1XANN3JVAkRYvr5U4q6;!G*MTdSUt*Mi=z_y3B1A9j-@aK{lNvx zK%p23>M&=KTCgR!Ee8c?DAO2_R?B zkaqr6^BSP!8dHXxj%N1l+V$_%vzHjqvu7p@%Nl6;>y*S}M!B=pz=aqUV#`;h%M0rU zHfcog>kv3UZAEB*g7Er@t6CF8kHDmKTjO@rejA^ULqn!`LwrEwOVmHx^;g|5PHm#B zZ+jjWgjJ!043F+&#_;D*mz%Q60=L9Ove|$gU&~As5^uz@2-BfQ!bW)Khn}G+Wyjw- z19qI#oB(RSNydn0t~;tAmK!P-d{b-@@E5|cdgOS#!>%#Rj6ynkMvaW@37E>@hJP^8 z2zk8VXx|>#R^JCcWdBCy{0nPmYFOxN55#^-rlqobe0#L6)bi?E?SPymF*a5oDDeSd zO0gx?#KMoOd&G(2O@*W)HgX6y_aa6iMCl^~`{@UR`nMQE`>n_{_aY5nA}vqU8mt8H z`oa=g0SyiLd~BxAj2~l$zRSDHxvDs;I4>+M$W`HbJ|g&P+$!U7-PHX4RAcR0szJ*( ze-417=bO2q{492SWrqDK+L3#ChUHtz*@MP)e^%@>_&#Yk^1|tv@j4%3T)diEX zATx4K*hcO`sY$jk#jN5WD<=C3nvuVsRh||qDHnc~;Kf59zr0;c7VkVSUPD%NnnJC_ zl3F^#f_rDu8l}l8qcAz0FFa)EAt32IUy_JLIhU_J^l~FRH&6-ivSpG2PRqzDdMWft>Zc(c)#tb%wgmWN%>IOPm zZi-noqS!^Ftb81pRcQi`X#UhWK70hy4tGW1mz|+vI8c*h@ zfFGJtW3r>qV>1Z0r|L>7I3un^gcep$AAWfZHRvB|E*kktY$qQP_$YG60C@X~tTQjB3%@`uz!qxtxF+LE!+=nrS^07hn` zEgAp!h|r03h7B!$#OZW#ACD+M;-5J!W+{h|6I;5cNnE(Y863%1(oH}_FTW})8zYb$7czP zg~Szk1+_NTm6SJ0MS_|oSz%e(S~P-&SFp;!k?uFayytV$8HPwuyELSXOs^27XvK-D zOx-Dl!P|28DK6iX>p#Yb%3`A&CG0X2S43FjN%IB}q(!hC$fG}yl1y9W&W&I@KTg6@ zK^kpH8=yFuP+vI^+59|3%Zqnb5lTDAykf z9S#X`3N(X^SpdMyWQGOQRjhiwlj!0W-yD<3aEj^&X%=?`6lCy~?`&WSWt z?U~EKFcCG_RJ(Qp7j=$I%H8t)Z@6VjA#>1f@EYiS8MRHZphp zMA_5`znM=pzUpBPO)pXGYpQ6gkine{6u_o!P@Q+NKJ}k!_X7u|qfpAyIJb$_#3@wJ z<1SE2Edkfk9C!0t%}8Yio09^F`YGzpaJHGk*-ffsn85@)%4@`;Fv^8q(-Wk7r=Q8p zT&hD`5(f?M{gfzGbbwh8(}G#|#fDuk7v1W)5H9wkorE0ZZjL0Q1=NRGY>zwgfm81DdoaVwNH;or{{eSyybt)m<=zXoA^RALYG-2t zouH|L*BLvmm9cdMmn+KGopyR@4*=&0&4g|FLoreZOhRmh=)R0bg~ zT2(8V_q7~42-zvb)+y959OAv!V$u(O3)%Es0M@CRFmG{5sovIq4%8Ahjk#*5w{+)+ zMWQoJI_r$HxL5km1#6(e@{lK3Udc~n0@g`g$s?VrnQJ$!oPnb?IHh-1qA`Rz$)Ai< z6w$-MJW-gKNvOhL+XMbE7&mFt`x1KY>k4(!KbbpZ`>`K@1J<(#vVbjx@Z@(6Q}MF# zMnbr-f55(cTa^q4+#)=s+ThMaV~E`B8V=|W_fZWDwiso8tNMTNse)RNBGi=gVwgg% zbOg8>mbRN%7^Um-7oj4=6`$|(K7!+t^90a{$18Z>}<#!bm%ZEFQ{X(yBZMc>lCz0f1I2w9Sq zuGh<9<=AO&g6BZte6hn>Qmvv;Rt)*cJfTr2=~EnGD8P$v3R|&1RCl&7)b+`=QGapi zPbLg_pxm`+HZurtFZ;wZ=`Vk*do~$wB zxoW&=j0OTbQ=Q%S8XJ%~qoa3Ea|au5o}_(P;=!y-AjFrERh%8la!z6Fn@lR?^E~H12D?8#ht=1F;7@o4$Q8GDj;sSC%Jfn01xgL&%F2 zwG1|5ikb^qHv&9hT8w83+yv&BQXOQyMVJSBL(Ky~p)gU3#%|blG?IR9rP^zUbs7rOA0X52Ao=GRt@C&zlyjNLv-} z9?*x{y(`509qhCV*B47f2hLrGl^<@SuRGR!KwHei?!CM10Tq*YDIoBNyRuO*>3FU? zHjipIE#B~y3FSfOsMfj~F9PNr*H?0oHyYB^G(YyNh{SxcE(Y-`x5jFMKb~HO*m+R% zrq|ic4fzJ#USpTm;X7K+E%xsT_3VHKe?*uc4-FsILUH;kL>_okY(w`VU*8+l>o>Jm ziU#?2^`>arnsl#)*R&nf_%>A+qwl%o{l(u)M?DK1^mf260_oteV3#E_>6Y4!_hhVD zM8AI6MM2V*^_M^sQ0dmHu11fy^kOqXqzpr?K$`}BKWG`=Es(9&S@K@)ZjA{lj3ea7_MBP zk(|hBFRjHVMN!sNUkrB;(cTP)T97M$0Dtc&UXSec<+q?y>5=)}S~{Z@ua;1xt@=T5 zI7{`Z=z_X*no8s>mY;>BvEXK%b`a6(DTS6t&b!vf_z#HM{Uoy_5fiB(zpkF{})ruka$iX*~pq1ZxD?q68dIo zIZSVls9kFGsTwvr4{T_LidcWtt$u{kJlW7moRaH6+A5hW&;;2O#$oKyEN8kx`LmG)Wfq4ykh+q{I3|RfVpkR&QH_x;t41Uw z`P+tft^E2B$domKT@|nNW`EHwyj>&}K;eDpe z1bNOh=fvIfk`&B61+S8ND<(KC%>y&?>opCnY*r5M+!UrWKxv0_QvTlJc>X#AaI^xo zaRXL}t5Ej_Z$y*|w*$6D+A?Lw-CO-$itm^{2Ct82-<0IW)0KMNvJHgBrdsIR0v~=H z?n6^}l{D``Me90`^o|q!olsF?UX3YSq^6Vu>Ijm>>PaZI8G@<^NGw{Cx&%|PwYrfw zR!gX_%AR=L3BFsf8LxI|K^J}deh0ZdV?$3r--FEX`#INxsOG6_=!v)DI>0q|BxT)z z-G6kzA01M?rba+G_mwNMQD1mbVbNTWmBi*{s_v_Ft9m2Avg!^78(QFu&n6mbRJ2bA zv!b;%yo{g*9l2)>tsZJOOp}U~8VUH`}$ z8p_}t*XIOehezolNa-a2x0BS})Y9}&*TPgua{Ewn-=wVrmJUeU39EKx+%w%=ixQWK zDLpwaNJs65#6o7Ln7~~X+p_o2BR1g~VCfxLzxA{HlWAI6^H;`juI=&r1jQrUv_q0Z z1Ja-tjdktrrP>GOC*#p?*xfQU5MqjMsBe!9lh(u8)w$e@Z|>aUHI5o;MGw*|Myiz3 z-f0;pHg~Q#%*Kx8MxH%AluVXjG2C$)WL-K63@Q`#y9_k_+}eR(x4~dp7oV-ek0H>I zgy8p#i4GN{>#v=pFYUQT(g&b$OeTy-X_#FDgNF8XyfGY6R!>inYn8IR2RDa&O!(6< znXs{W!bkP|s_YI*Yx%4stI`=ZO45IK6rBs`g7sP40ic}GZ58s?Mc$&i`kq_tfci>N zIHrC0H+Qpam1bNa=(`SRKjixBTtm&e`j9porEci!zdlg1RI0Jw#b(_Tb@RQK1Zxr_ z%7SUeH6=TrXt3J@js`4iDD0=IoHhK~I7^W8^Rcp~Yaf>2wVe|Hh1bUpX9ATD#moByY57-f2Ef1TP^lBi&p5_s7WGG9|0T}dlfxOx zXvScJO1Cnq`c`~{Dp;{;l<-KkCDE+pmexJkd}zCgE{eF=)K``-qC~IT6GcRog_)!X z?fK^F8UDz$(zFUrwuR$qro5>qqn>+Z%<5>;_*3pZ8QM|yv9CAtrAx;($>4l^_$_-L z*&?(77!-=zvnCVW&kUcZMb6;2!83si518Y%R*A3JZ8Is|kUCMu`!vxDgaWjs7^0j( ziTaS4HhQ)ldR=r)_7vYFUr%THE}cPF{0H45FJ5MQW^+W>P+eEX2kLp3zzFe*-pFVA zdDZRybv?H|>`9f$AKVjFWJ=wegO7hOOIYCtd?Vj{EYLT*^gl35|HQ`R=ti+ADm{jyQE7K@kdjuqJhWVSks>b^ zxha88-h3s;%3_5b1TqFCPTxVjvuB5U>v=HyZ$?JSk+&I%)M7KE*wOg<)1-Iy)8-K! z^XpIt|0ibmk9RtMmlUd7#Ap3Q!q9N4atQy)TmrhrFhfx1DAN`^vq@Q_SRl|V z#lU<~n67$mT)NvHh`%als+G-)x1`Y%4Bp*6Un5Ri9h=_Db zA-AdP!f>f0m@~>7X#uBM?diI@)Egjuz@jXKvm zJo+==juc9_<;CqeRaU9_Mz@;3e=E4=6TK+c`|uu#pIqhSyNm`G(X)&)B`8q0RBv#> z`gGlw(Q=1Xmf55VHj%C#^1lpc>LY8kfA@|rlC1EA<1#`iuyNO z(=;irt{_&K=i4)^x%;U(Xv<)+o=dczC5H3W~+e|f~{*ucxj@{Yi-cw^MqYr3fN zF5D+~!wd$#al?UfMnz(@K#wn`_5na@rRr8XqN@&M&FGEC@`+OEv}sI1hw>Up0qAWf zL#e4~&oM;TVfjRE+10B_gFlLEP9?Q-dARr3xi6nQqnw>k-S;~b z;!0s2VS4}W8b&pGuK=7im+t(`nz@FnT#VD|!)eQNp-W6)@>aA+j~K*H{$G`y2|QHY z|Hmy+CR@#jWY4~)lr1qBJB_RfHJFfP<}pK5(#ZZGSqcpyS&}01LnTWk5fzmXMGHkJ zTP6L^B+uj;lmB_W<~4=${+v0>z31M!-_O@o-O9GyW)j_mjx}!0@br_LE-7SIuPP84 z;5=O(U*g_um0tyG|61N@d9lEuOeiRd+#NY^{nd5;-CVlw&Ap7J?qwM^?E29wvS}2d zbzar4Fz&RSR(-|s!Z6+za&Z zY#D<5q_JUktIzvL0)yq_kLWG6DO{ri=?c!y!f(Dk%G{8)k`Gym%j#!OgXVDD3;$&v@qy#ISJfp=Vm>pls@9-mapVQChAHHd-x+OGx)(*Yr zC1qDUTZ6mM(b_hi!TuFF2k#8uI2;kD70AQ&di$L*4P*Y-@p`jdm%_c3f)XhYD^6M8&#Y$ZpzQMcR|6nsH>b=*R_Von!$BTRj7yGCXokoAQ z&ANvx0-Epw`QIEPgI(^cS2f(Y85yV@ygI{ewyv5Frng)e}KCZF7JbR(&W618_dcEh(#+^zZFY;o<815<5sOHQdeax9_!PyM&;{P zkBa5xymca0#)c#tke@3KNEM8a_mT&1gm;p&&JlMGH(cL(b)BckgMQ^9&vRwj!~3@l zY?L5}=Jzr080OGKb|y`ee(+`flQg|!lo6>=H)X4`$Gz~hLmu2a%kYW_Uu8x09Pa0J zKZ`E$BKJ=2GPj_3l*TEcZ*uYRr<*J^#5pILTT;k_cgto1ZL-%slyc16J~OH-(RgDA z%;EjEnoUkZ&acS{Q8`{i6T5^nywgqQI5bDIymoa7CSZG|WWVk>GM9)zy*bNih|QIm z%0+(Nnc*a_xo;$=!HQYaapLms>J1ToyjtFByY`C2H1wT#178#4+|{H0BBqtCdd$L% z_3Hc60j@{t9~MjM@LBalR&6@>B;9?r<7J~F+WXyYu*y3?px*=8MAK@EA+jRX8{CG?GI-< z54?Dc9CAh>QTAvyOEm0^+x;r2BWX|{3$Y7)L5l*qVE*y0`7J>l2wCmW zL1?|a`pJ-l{fb_N;R(Z9UMiSj6pQjOvQ^%DvhIJF!+Th7jO2~1f1N+(-TyCFYQZYw z4)>7caf^Ki_KJ^Zx2JUb z&$3zJy!*+rCV4%jqwyuNY3j1ZEiltS0xTzd+=itTb;IPYpaf?8Y+RSdVdpacB(bVQ zC(JupLfFp8y43%PMj2}T|VS@%LVp>hv4Y!RPMF?pp8U_$xCJ)S zQx!69>bphNTIb9yn*_yfj{N%bY)t{L1cs8<8|!f$;UQ*}IN=2<6lA;x^(`8t?;+ST zh)z4qeYYgZkIy{$4x28O-pugO&gauRh3;lti9)9Pvw+^)0!h~%m&8Q!AKX%urEMnl z?yEz?g#ODn$UM`+Q#$Q!6|zsq_`dLO5YK-6bJM6ya>}H+vnW^h?o$z;V&wvuM$dR& zeEq;uUUh$XR`TWeC$$c&Jjau2it3#%J-y}Qm>nW*s?En?R&6w@sDXMEr#8~$=b(gk zwDC3)NtAP;M2BW_lL^5ShpK$D%@|BnD{=!Tq)o(5@z3i7Z){} zGr}Exom_qDO{kAVkZ*MbLNHE666Kina#D{&>Jy%~w7yX$oj;cYCd^p9zy z8*+wgSEcj$4{WxKmCF(5o7U4jqwEvO&dm1H#7z}%VXAbW&W24v-tS6N3}qrm1OnE)fUkoE8yMMn9S$?IswS88tQWm4#Oid#ckgr6 zRtHm!mfNl-`d>O*1~d7%;~n+{Rph6BBy^95zqI{K((E!iFQ+h*C3EsbxNo_aRm5gj zKYug($r*Q#W9`p%Bf{bi6;IY0v`pB^^qu)gbg9QHQ7 zWBj(a1YSu)~2RK8Pi#C>{DMlrqFb9e_RehEHyI{n?e3vL_}L>kYJC z_ly$$)zFi*SFyNrnOt(B*7E$??s67EO%DgoZL2XNk8iVx~X_)o++4oaK1M|ou73vA0K^503j@uuVmLcHH4ya-kOIDfM%5%(E z+Xpt~#7y2!KB&)PoyCA+$~DXqxPxxALy!g-O?<9+9KTk4Pgq4AIdUkl`1<1#j^cJg zgU3`0hkHj_jxV>`Y~%LAZl^3o0}`Sm@iw7kwff{M%VwtN)|~!p{AsfA6vB5UolF~d zHWS%*uBDt<9y!9v2Xe|au&1j&iR1HXCdyCjxSgG*L{wmTD4(NQ=mFjpa~xooc6kju z`~+d{j7$h-;HAB04H!Zscu^hZffL#9!p$)9>sRI|Yovm)g@F>ZnosF2EgkU3ln0bR zTA}|+E(tt)!SG)-bEJi_0m{l+(cAz^pi}`9=~n?y&;2eG;d9{M6nj>BHGn(KA2n|O zt}$=FPq!j`p&kQ8>cirSzkU0c08%8{^Qyqi-w2LoO8)^E7;;I1;HQ6B$u0nNaX2CY zSmfi)F`m94zL8>#zu;8|{aBui@RzRKBlP1&mfFxEC@%cjl?NBs`cr^nm){>;$g?rhKr$AO&6qV_Wbn^}5tfFBry^e1`%du2~o zs$~dN;S_#%iwwA_QvmMjh%Qo?0?rR~6liyN5Xmej8(*V9ym*T`xAhHih-v$7U}8=dfXi2i*aAB!xM(Xekg*ix@r|ymDw*{*s0?dlVys2e)z62u1 z+k3esbJE=-P5S$&KdFp+2H7_2e=}OKDrf( z9-207?6$@f4m4B+9E*e((Y89!q?zH|mz_vM>kp*HGXldO0Hg#!EtFhRuOm$u8e~a9 z5(roy7m$Kh+zjW6@zw{&20u?1f2uP&boD}$#Zy)4o&T;vyBoqFiF2t;*g=|1=)PxB z8eM3Mp=l_obbc?I^xyLz?4Y1YDWPa+nm;O<$Cn;@ane616`J9OO2r=rZr{I_Kizyc zP#^^WCdIEp*()rRT+*YZK>V@^Zs=ht32x>Kwe zab)@ZEffz;VM4{XA6e421^h~`ji5r%)B{wZu#hD}f3$y@L0JV9f3g{-RK!A?vBUA}${YF(vO4)@`6f1 z-A|}e#LN{)(eXloDnX4Vs7eH|<@{r#LodP@Nz--$Dg_Par%DCpu2>2jUnqy~|J?eZ zBG4FVsz_A+ibdwv>mLp>P!(t}E>$JGaK$R~;fb{O3($y1ssQQo|5M;^JqC?7qe|hg zu0ZOqeFcp?qVn&Qu7FQJ4hcFi&|nR!*j)MF#b}QO^lN%5)4p*D^H+B){n8%VPUzi! zDihoGcP71a6!ab`l^hK&*dYrVYzJ0)#}xVrp!e;lI!+x+bfCN0KXwUAPU9@#l7@0& QuEJmfE|#`Dqx|px0L@K;Y5)KL literal 0 HcmV?d00001 diff --git a/demoapp/gradle/wrapper/gradle-wrapper.properties b/demoapp/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..39561b34 --- /dev/null +++ b/demoapp/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ + +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/demoapp/gradlew b/demoapp/gradlew new file mode 100755 index 00000000..43bed5da --- /dev/null +++ b/demoapp/gradlew @@ -0,0 +1,186 @@ + +#!/usr/bin/env sh + +# +# Copyright 2015 the original author or authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Attempt to set APP_HOME +# Resolve links: ${'$'}0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)${'$'}'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn () { + echo "${'$'}*" +} + +die () { + echo + echo "${'$'}*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MSYS* | MINGW* ) + msys=true + ;; + NONSTOP* ) + nonstop=true + ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin or MSYS, switch paths to Windows format before running java +if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=`expr $i + 1` + done + case $i in + 0) set -- ;; + 1) set -- "$args0" ;; + 2) set -- "$args0" "$args1" ;; + 3) set -- "$args0" "$args1" "$args2" ;; + 4) set -- "$args0" "$args1" "$args2" "$args3" ;; + 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Escape application args +save () { + for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done + echo " " +} +APP_ARGS=`save "$@"` + +# Collect all arguments for the java command, following the shell quoting and substitution rules +eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" + +exec "$JAVACMD" "$@" diff --git a/demoapp/gradlew.bat b/demoapp/gradlew.bat new file mode 100644 index 00000000..e34f4911 --- /dev/null +++ b/demoapp/gradlew.bat @@ -0,0 +1,90 @@ + +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/demoapp/img.png b/demoapp/img.png new file mode 100644 index 0000000000000000000000000000000000000000..8cfb4927274145a70a505cdf7c80d7091aabdb1e GIT binary patch literal 86871 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

    1I|MayP8{Wij#;9hqg88ST{74Y$;-I9L2JLx8U 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 + + + + diff --git a/demoapp/iosApp/iosApp.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/demoapp/iosApp/iosApp.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 00000000..18d98100 --- /dev/null +++ b/demoapp/iosApp/iosApp.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/demoapp/iosApp/iosApp.xcodeproj/project.xcworkspace/xcuserdata/bpruenster.xcuserdatad/UserInterfaceState.xcuserstate b/demoapp/iosApp/iosApp.xcodeproj/project.xcworkspace/xcuserdata/bpruenster.xcuserdatad/UserInterfaceState.xcuserstate new file mode 100644 index 0000000000000000000000000000000000000000..f84f87cc8ea00488c9209fc6a4ba2b1bbabe635f GIT binary patch literal 39121 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 literal 0 HcmV?d00001 diff --git a/demoapp/iosApp/iosApp.xcodeproj/xcuserdata/bpruenster.xcuserdatad/xcschemes/xcschememanagement.plist b/demoapp/iosApp/iosApp.xcodeproj/xcuserdata/bpruenster.xcuserdatad/xcschemes/xcschememanagement.plist new file mode 100644 index 00000000..56b5955f --- /dev/null +++ b/demoapp/iosApp/iosApp.xcodeproj/xcuserdata/bpruenster.xcuserdatad/xcschemes/xcschememanagement.plist @@ -0,0 +1,14 @@ + + + + + SchemeUserState + + iosApp.xcscheme_^#shared#^_ + + orderHint + 0 + + + + diff --git a/demoapp/iosApp/iosApp/Assets.xcassets/AccentColor.colorset/Contents.json b/demoapp/iosApp/iosApp/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 00000000..eb878970 --- /dev/null +++ b/demoapp/iosApp/iosApp/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/demoapp/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/Contents.json b/demoapp/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 00000000..13613e3e --- /dev/null +++ b/demoapp/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,13 @@ +{ + "images" : [ + { + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/demoapp/iosApp/iosApp/Assets.xcassets/Contents.json b/demoapp/iosApp/iosApp/Assets.xcassets/Contents.json new file mode 100644 index 00000000..73c00596 --- /dev/null +++ b/demoapp/iosApp/iosApp/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/demoapp/iosApp/iosApp/Preview Content/Preview Assets.xcassets/Contents.json b/demoapp/iosApp/iosApp/Preview Content/Preview Assets.xcassets/Contents.json new file mode 100644 index 00000000..73c00596 --- /dev/null +++ b/demoapp/iosApp/iosApp/Preview Content/Preview Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/demoapp/iosApp/iosApp/iosApp.entitlements b/demoapp/iosApp/iosApp/iosApp.entitlements new file mode 100644 index 00000000..0c67376e --- /dev/null +++ b/demoapp/iosApp/iosApp/iosApp.entitlements @@ -0,0 +1,5 @@ + + + + + diff --git a/demoapp/iosApp/iosApp/iosApp.swift b/demoapp/iosApp/iosApp/iosApp.swift new file mode 100644 index 00000000..22639055 --- /dev/null +++ b/demoapp/iosApp/iosApp/iosApp.swift @@ -0,0 +1,32 @@ +import UIKit +import SwiftUI +import ComposeApp + + +@main +struct iosApp: App { + var body: some Scene { + WindowGroup { + ContentView() + } + } +} + +struct ContentView: View { + var body: some View { + ComposeView().ignoresSafeArea(.all) + } +} + +struct ComposeView: UIViewControllerRepresentable { + func makeUIViewController(context: Context) -> UIViewController { + MainKt.MainViewController() + } + + func updateUIViewController(_ uiViewController: UIViewController, context: Context) { + + } + + + +} diff --git a/demoapp/settings.gradle.kts b/demoapp/settings.gradle.kts new file mode 100644 index 00000000..2c36c0bc --- /dev/null +++ b/demoapp/settings.gradle.kts @@ -0,0 +1,34 @@ +rootProject.name = "CryptoTest-App" +include(":composeApp") + +pluginManagement { + repositories { + google() + gradlePluginPortal() + mavenCentral() + maven { + url = + uri("https://raw.githubusercontent.com/a-sit-plus/gradle-conventions-plugin/mvn/repo") + name = "aspConventions" + } + } +} + +includeBuild("..") { + dependencySubstitution { + substitute(module("at.asitplus.signum:indispensable")).using(project(":indispensable")) + substitute(module("at.asitplus.signum:indispensable-josef")).using(project(":indispensable-josef")) + substitute(module("at.asitplus.signum:indispensable-cosef")).using(project(":indispensable-cosef")) + substitute(module("at.asitplus.signum:supreme")).using(project(":supreme")) + } +} + +dependencyResolutionManagement { + repositories { + google() + mavenCentral() + mavenLocal() + maven(uri("https://raw.githubusercontent.com/a-sit-plus/kotlinx.serialization/mvn/repo")) + maven("https://maven.pkg.jetbrains.space/kotlin/p/dokka/dev") + } +} diff --git a/indispensable/src/commonMain/kotlin/at/asitplus/signum/indispensable/CryptoPublicKey.kt b/indispensable/src/commonMain/kotlin/at/asitplus/signum/indispensable/CryptoPublicKey.kt index 92ebbbe9..8f5723bf 100644 --- a/indispensable/src/commonMain/kotlin/at/asitplus/signum/indispensable/CryptoPublicKey.kt +++ b/indispensable/src/commonMain/kotlin/at/asitplus/signum/indispensable/CryptoPublicKey.kt @@ -224,7 +224,7 @@ sealed class CryptoPublicKey : Asn1Encodable, Identifiable { ?: throw IllegalArgumentException("Unsupported key size $nTruncSize") } - override val oid = at.asitplus.signum.indispensable.asn1.KnownOIDs.rsaEncryption + override val oid = KnownOIDs.rsaEncryption } } @@ -285,7 +285,7 @@ sealed class CryptoPublicKey : Asn1Encodable, Identifiable { return Rsa(Size.of(n), n, e) } - override val oid = at.asitplus.signum.indispensable.asn1.KnownOIDs.rsaEncryption + override val oid = KnownOIDs.rsaEncryption } } @@ -409,7 +409,7 @@ sealed class CryptoPublicKey : Asn1Encodable, Identifiable { } } - override val oid = at.asitplus.signum.indispensable.asn1.KnownOIDs.ecPublicKey + override val oid = KnownOIDs.ecPublicKey } } } diff --git a/indispensable/src/commonMain/kotlin/at/asitplus/signum/indispensable/Digest.kt b/indispensable/src/commonMain/kotlin/at/asitplus/signum/indispensable/Digest.kt index b66ebe44..ff4db010 100644 --- a/indispensable/src/commonMain/kotlin/at/asitplus/signum/indispensable/Digest.kt +++ b/indispensable/src/commonMain/kotlin/at/asitplus/signum/indispensable/Digest.kt @@ -5,3 +5,16 @@ import at.asitplus.signum.indispensable.asn1.KnownOIDs import at.asitplus.signum.indispensable.asn1.ObjectIdentifier import at.asitplus.signum.indispensable.misc.BitLength import at.asitplus.signum.indispensable.misc.bit + +enum class Digest(val outputLength: BitLength, override val oid: ObjectIdentifier) : Identifiable { + SHA1(160.bit, KnownOIDs.sha1), + SHA256(256.bit, KnownOIDs.sha_256), + SHA384(384.bit, KnownOIDs.sha_384), + SHA512(512.bit, KnownOIDs.sha_512); +} + +val ECCurve.nativeDigest get() = when (this) { + ECCurve.SECP_256_R_1 -> Digest.SHA256 + ECCurve.SECP_384_R_1 -> Digest.SHA384 + ECCurve.SECP_521_R_1 -> Digest.SHA512 +} diff --git a/indispensable/src/commonMain/kotlin/at/asitplus/signum/indispensable/ECCurve.kt b/indispensable/src/commonMain/kotlin/at/asitplus/signum/indispensable/ECCurve.kt index 49cc2b30..3405933d 100644 --- a/indispensable/src/commonMain/kotlin/at/asitplus/signum/indispensable/ECCurve.kt +++ b/indispensable/src/commonMain/kotlin/at/asitplus/signum/indispensable/ECCurve.kt @@ -30,9 +30,9 @@ enum class ECCurve( override val oid: ObjectIdentifier, ) : Identifiable { - SECP_256_R_1("P-256", at.asitplus.signum.indispensable.asn1.KnownOIDs.prime256v1), - SECP_384_R_1("P-384", at.asitplus.signum.indispensable.asn1.KnownOIDs.secp384r1), - SECP_521_R_1("P-521", at.asitplus.signum.indispensable.asn1.KnownOIDs.secp521r1); + SECP_256_R_1("P-256", KnownOIDs.prime256v1), + SECP_384_R_1("P-384", KnownOIDs.secp384r1), + SECP_521_R_1("P-521", KnownOIDs.secp521r1); val IDENTITY: ECPoint by lazy { ECPoint.General.unsafeFromXYZ(this, coordinateCreator.ZERO, coordinateCreator.ONE, coordinateCreator.ZERO) diff --git a/indispensable/src/commonMain/kotlin/at/asitplus/signum/indispensable/SignatureAlgorithm.kt b/indispensable/src/commonMain/kotlin/at/asitplus/signum/indispensable/SignatureAlgorithm.kt index f896330e..1659a602 100644 --- a/indispensable/src/commonMain/kotlin/at/asitplus/signum/indispensable/SignatureAlgorithm.kt +++ b/indispensable/src/commonMain/kotlin/at/asitplus/signum/indispensable/SignatureAlgorithm.kt @@ -1,18 +1,5 @@ package at.asitplus.signum.indispensable -import at.asitplus.signum.indispensable.asn1.Identifiable -import at.asitplus.signum.indispensable.asn1.KnownOIDs -import at.asitplus.signum.indispensable.asn1.ObjectIdentifier -import at.asitplus.signum.indispensable.misc.BitLength -import at.asitplus.signum.indispensable.misc.bit - -enum class Digest(val outputLength: BitLength, override val oid: ObjectIdentifier) : Identifiable { - SHA1(160.bit, at.asitplus.signum.indispensable.asn1.KnownOIDs.sha1), - SHA256(256.bit, at.asitplus.signum.indispensable.asn1.KnownOIDs.sha_256), - SHA384(384.bit, at.asitplus.signum.indispensable.asn1.KnownOIDs.sha_384), - SHA512(512.bit, at.asitplus.signum.indispensable.asn1.KnownOIDs.sha_512); -} - enum class RSAPadding { PKCS1, PSS; diff --git a/indispensable/src/commonMain/kotlin/at/asitplus/signum/indispensable/X509SignatureAlgorithm.kt b/indispensable/src/commonMain/kotlin/at/asitplus/signum/indispensable/X509SignatureAlgorithm.kt index 8e7d90bf..2778efae 100644 --- a/indispensable/src/commonMain/kotlin/at/asitplus/signum/indispensable/X509SignatureAlgorithm.kt +++ b/indispensable/src/commonMain/kotlin/at/asitplus/signum/indispensable/X509SignatureAlgorithm.kt @@ -19,33 +19,33 @@ enum class X509SignatureAlgorithm( ) : Asn1Encodable, Identifiable, SpecializedSignatureAlgorithm { // ECDSA with SHA-size - ES256(at.asitplus.signum.indispensable.asn1.KnownOIDs.ecdsaWithSHA256, true), - ES384(at.asitplus.signum.indispensable.asn1.KnownOIDs.ecdsaWithSHA384, true), - ES512(at.asitplus.signum.indispensable.asn1.KnownOIDs.ecdsaWithSHA512, true), + ES256(KnownOIDs.ecdsaWithSHA256, true), + ES384(KnownOIDs.ecdsaWithSHA384, true), + ES512(KnownOIDs.ecdsaWithSHA512, true), // HMAC-size with SHA-size - HS256(at.asitplus.signum.indispensable.asn1.KnownOIDs.hmacWithSHA256), - HS384(at.asitplus.signum.indispensable.asn1.KnownOIDs.hmacWithSHA384), - HS512(at.asitplus.signum.indispensable.asn1.KnownOIDs.hmacWithSHA512), + HS256(KnownOIDs.hmacWithSHA256), + HS384(KnownOIDs.hmacWithSHA384), + HS512(KnownOIDs.hmacWithSHA512), // RSASSA-PSS with SHA-size - PS256(at.asitplus.signum.indispensable.asn1.KnownOIDs.rsaPSS), - PS384(at.asitplus.signum.indispensable.asn1.KnownOIDs.rsaPSS), - PS512(at.asitplus.signum.indispensable.asn1.KnownOIDs.rsaPSS), + PS256(KnownOIDs.rsaPSS), + PS384(KnownOIDs.rsaPSS), + PS512(KnownOIDs.rsaPSS), // RSASSA-PKCS1-v1_5 with SHA-size - RS256(at.asitplus.signum.indispensable.asn1.KnownOIDs.sha256WithRSAEncryption), - RS384(at.asitplus.signum.indispensable.asn1.KnownOIDs.sha384WithRSAEncryption), - RS512(at.asitplus.signum.indispensable.asn1.KnownOIDs.sha512WithRSAEncryption), + RS256(KnownOIDs.sha256WithRSAEncryption), + RS384(KnownOIDs.sha384WithRSAEncryption), + RS512(KnownOIDs.sha512WithRSAEncryption), // RSASSA-PKCS1-v1_5 using SHA-1 - RS1(at.asitplus.signum.indispensable.asn1.KnownOIDs.sha1WithRSAEncryption); + RS1(KnownOIDs.sha1WithRSAEncryption); private fun encodePSSParams(bits: Int): Asn1Sequence { val shaOid = when (bits) { - 256 -> at.asitplus.signum.indispensable.asn1.KnownOIDs.sha_256 - 384 -> at.asitplus.signum.indispensable.asn1.KnownOIDs.sha_384 - 512 -> at.asitplus.signum.indispensable.asn1.KnownOIDs.sha_512 + 256 -> KnownOIDs.sha_256 + 384 -> KnownOIDs.sha_384 + 512 -> KnownOIDs.sha_512 else -> TODO() } return Asn1.Sequence { @@ -59,7 +59,7 @@ enum class X509SignatureAlgorithm( } +Tagged(1.toUByte()) { +Asn1.Sequence { - +at.asitplus.signum.indispensable.asn1.KnownOIDs.pkcs1_MGF + +KnownOIDs.pkcs1_MGF +Asn1.Sequence { +shaOid +Null() @@ -140,7 +140,7 @@ enum class X509SignatureAlgorithm( val second = (seq.nextChild() as Asn1Tagged).verifyTag(1.toUByte()).single() as Asn1Sequence val mgf = (second.nextChild() as Asn1Primitive).readOid() - if (mgf != at.asitplus.signum.indispensable.asn1.KnownOIDs.pkcs1_MGF) throw IllegalArgumentException("Illegal OID: $mgf") + if (mgf != KnownOIDs.pkcs1_MGF) throw IllegalArgumentException("Illegal OID: $mgf") val inner = second.nextChild() as Asn1Sequence val innerHash = (inner.nextChild() as Asn1Primitive).readOid() if (innerHash != sigAlg) throw IllegalArgumentException("HashFunction mismatch! Expected: $sigAlg, is: $innerHash") @@ -154,9 +154,9 @@ enum class X509SignatureAlgorithm( return sigAlg.let { when (it) { - at.asitplus.signum.indispensable.asn1.KnownOIDs.sha_256 -> PS256.also { if (saltLen != 256 / 8) throw IllegalArgumentException("Non-recommended salt length used: $saltLen") } - at.asitplus.signum.indispensable.asn1.KnownOIDs.sha_384 -> PS384.also { if (saltLen != 384 / 8) throw IllegalArgumentException("Non-recommended salt length used: $saltLen") } - at.asitplus.signum.indispensable.asn1.KnownOIDs.sha_512 -> PS512.also { if (saltLen != 512 / 8) throw IllegalArgumentException("Non-recommended salt length used: $saltLen") } + KnownOIDs.sha_256 -> PS256.also { if (saltLen != 256 / 8) throw IllegalArgumentException("Non-recommended salt length used: $saltLen") } + KnownOIDs.sha_384 -> PS384.also { if (saltLen != 384 / 8) throw IllegalArgumentException("Non-recommended salt length used: $saltLen") } + KnownOIDs.sha_512 -> PS512.also { if (saltLen != 512 / 8) throw IllegalArgumentException("Non-recommended salt length used: $saltLen") } else -> throw IllegalArgumentException("Unsupported OID: $it") } diff --git a/indispensable/src/commonMain/kotlin/at/asitplus/signum/indispensable/pki/AlternativeNames.kt b/indispensable/src/commonMain/kotlin/at/asitplus/signum/indispensable/pki/AlternativeNames.kt index 9b17d7bf..c75848b7 100644 --- a/indispensable/src/commonMain/kotlin/at/asitplus/signum/indispensable/pki/AlternativeNames.kt +++ b/indispensable/src/commonMain/kotlin/at/asitplus/signum/indispensable/pki/AlternativeNames.kt @@ -109,12 +109,12 @@ private constructor(private val extensions: List) { companion object { @Throws(Asn1Exception::class) fun List.findSubjectAltNames() = runRethrowing { - find(at.asitplus.signum.indispensable.asn1.KnownOIDs.subjectAltName_2_5_29_17)?.let { AlternativeNames(it) } + find(KnownOIDs.subjectAltName_2_5_29_17)?.let { AlternativeNames(it) } } @Throws(Asn1Exception::class) fun List.findIssuerAltNames() = runRethrowing { - find(at.asitplus.signum.indispensable.asn1.KnownOIDs.issuerAltName_2_5_29_18)?.let { AlternativeNames(it) } + find(KnownOIDs.issuerAltName_2_5_29_18)?.let { AlternativeNames(it) } } /**not for public use, since it forces [Asn1EncapsulatingOctetString]*/ diff --git a/indispensable/src/commonMain/kotlin/at/asitplus/signum/indispensable/pki/Pkcs10CertificationRequest.kt b/indispensable/src/commonMain/kotlin/at/asitplus/signum/indispensable/pki/Pkcs10CertificationRequest.kt index 0175baa7..38b927f8 100644 --- a/indispensable/src/commonMain/kotlin/at/asitplus/signum/indispensable/pki/Pkcs10CertificationRequest.kt +++ b/indispensable/src/commonMain/kotlin/at/asitplus/signum/indispensable/pki/Pkcs10CertificationRequest.kt @@ -39,7 +39,7 @@ data class TbsCertificationRequest( ) : this(version, subjectName, publicKey, mutableListOf().also { attrs -> if (extensions.isEmpty()) throw IllegalArgumentException("No extensions provided!") attributes?.let { attrs.addAll(it) } - attrs.add(Pkcs10CertificationRequestAttribute(at.asitplus.signum.indispensable.asn1.KnownOIDs.extensionRequest, Asn1.Sequence { + attrs.add(Pkcs10CertificationRequestAttribute(KnownOIDs.extensionRequest, Asn1.Sequence { extensions.forEach { +it } })) }) diff --git a/indispensable/src/commonMain/kotlin/at/asitplus/signum/indispensable/pki/RelativeDistinguishedName.kt b/indispensable/src/commonMain/kotlin/at/asitplus/signum/indispensable/pki/RelativeDistinguishedName.kt index ac849fcc..48faaed3 100644 --- a/indispensable/src/commonMain/kotlin/at/asitplus/signum/indispensable/pki/RelativeDistinguishedName.kt +++ b/indispensable/src/commonMain/kotlin/at/asitplus/signum/indispensable/pki/RelativeDistinguishedName.kt @@ -44,7 +44,7 @@ sealed class AttributeTypeAndValue : Asn1Encodable, Identifiable { constructor(str: Asn1String) : this(Asn1Primitive(str.tag, str.value.encodeToByteArray())) companion object { - val OID = at.asitplus.signum.indispensable.asn1.KnownOIDs.commonName + val OID = KnownOIDs.commonName } } @@ -56,7 +56,7 @@ sealed class AttributeTypeAndValue : Asn1Encodable, Identifiable { constructor(str: Asn1String) : this(Asn1Primitive(str.tag, str.value.encodeToByteArray())) companion object { - val OID = at.asitplus.signum.indispensable.asn1.KnownOIDs.countryName + val OID = KnownOIDs.countryName } } @@ -68,7 +68,7 @@ sealed class AttributeTypeAndValue : Asn1Encodable, Identifiable { constructor(str: Asn1String) : this(Asn1Primitive(str.tag, str.value.encodeToByteArray())) companion object { - val OID = at.asitplus.signum.indispensable.asn1.KnownOIDs.organizationName + val OID = KnownOIDs.organizationName } } @@ -80,7 +80,7 @@ sealed class AttributeTypeAndValue : Asn1Encodable, Identifiable { constructor(str: Asn1String) : this(Asn1Primitive(str.tag, str.value.encodeToByteArray())) companion object { - val OID = at.asitplus.signum.indispensable.asn1.KnownOIDs.organizationalUnitName + val OID = KnownOIDs.organizationalUnitName } } diff --git a/indispensable/src/iosMain/kotlin/CommonCryptoExtensions.kt b/indispensable/src/iosMain/kotlin/CommonCryptoExtensions.kt new file mode 100644 index 00000000..03a76775 --- /dev/null +++ b/indispensable/src/iosMain/kotlin/CommonCryptoExtensions.kt @@ -0,0 +1,46 @@ +@file:OptIn(ExperimentalForeignApi::class) +package at.asitplus.signum.indispensable + +import kotlinx.cinterop.ExperimentalForeignApi +import platform.Security.SecKeyAlgorithm +import platform.Security.kSecKeyAlgorithmECDSASignatureMessageX962SHA1 +import platform.Security.kSecKeyAlgorithmECDSASignatureMessageX962SHA256 +import platform.Security.kSecKeyAlgorithmECDSASignatureMessageX962SHA384 +import platform.Security.kSecKeyAlgorithmECDSASignatureMessageX962SHA512 +import platform.Security.kSecKeyAlgorithmRSASignatureMessagePKCS1v15SHA1 +import platform.Security.kSecKeyAlgorithmRSASignatureMessagePKCS1v15SHA256 +import platform.Security.kSecKeyAlgorithmRSASignatureMessagePKCS1v15SHA384 +import platform.Security.kSecKeyAlgorithmRSASignatureMessagePKCS1v15SHA512 +import platform.Security.kSecKeyAlgorithmRSASignatureMessagePSSSHA1 +import platform.Security.kSecKeyAlgorithmRSASignatureMessagePSSSHA256 +import platform.Security.kSecKeyAlgorithmRSASignatureMessagePSSSHA384 +import platform.Security.kSecKeyAlgorithmRSASignatureMessagePSSSHA512 + +val SignatureAlgorithm.secKeyAlgorithm : SecKeyAlgorithm get() = when (this) { + is SignatureAlgorithm.ECDSA -> { + when (digest) { + Digest.SHA1 -> kSecKeyAlgorithmECDSASignatureMessageX962SHA1 + Digest.SHA256 -> kSecKeyAlgorithmECDSASignatureMessageX962SHA256 + Digest.SHA384 -> kSecKeyAlgorithmECDSASignatureMessageX962SHA384 + Digest.SHA512 -> kSecKeyAlgorithmECDSASignatureMessageX962SHA512 + else -> throw IllegalArgumentException("Raw signing is not supported on iOS") + } + } + is SignatureAlgorithm.RSA -> { + when (padding) { + RSAPadding.PSS -> when (digest) { + Digest.SHA1 -> kSecKeyAlgorithmRSASignatureMessagePSSSHA1 + Digest.SHA256 -> kSecKeyAlgorithmRSASignatureMessagePSSSHA256 + Digest.SHA384 -> kSecKeyAlgorithmRSASignatureMessagePSSSHA384 + Digest.SHA512 -> kSecKeyAlgorithmRSASignatureMessagePSSSHA512 + } + RSAPadding.PKCS1 -> when (digest) { + Digest.SHA1 -> kSecKeyAlgorithmRSASignatureMessagePKCS1v15SHA1 + Digest.SHA256 -> kSecKeyAlgorithmRSASignatureMessagePKCS1v15SHA256 + Digest.SHA384 -> kSecKeyAlgorithmRSASignatureMessagePKCS1v15SHA384 + Digest.SHA512 -> kSecKeyAlgorithmRSASignatureMessagePKCS1v15SHA512 + } + } + } + is SignatureAlgorithm.HMAC -> TODO("HMAC is unsupported") +}!! diff --git a/indispensable/src/jvmMain/kotlin/at/asitplus/signum/indispensable/JcaExtensions.kt b/indispensable/src/jvmMain/kotlin/at/asitplus/signum/indispensable/JcaExtensions.kt index 35e33af4..2c51ac03 100644 --- a/indispensable/src/jvmMain/kotlin/at/asitplus/signum/indispensable/JcaExtensions.kt +++ b/indispensable/src/jvmMain/kotlin/at/asitplus/signum/indispensable/JcaExtensions.kt @@ -43,7 +43,7 @@ private fun sigGetInstance(alg: String, provider: String?) = else -> Signature.getInstance(alg, provider) } /** Get a pre-configured JCA instance for this algorithm */ -fun SignatureAlgorithm.getJCASignatureInstance(provider: String? = null) = catching { +fun SignatureAlgorithm.getJCASignatureInstance(provider: String? = null, isAndroid: Boolean = false) = catching { when (this) { is SignatureAlgorithm.ECDSA -> sigGetInstance("${this.digest.jcaAlgorithmComponent}withECDSA", provider) @@ -52,10 +52,14 @@ fun SignatureAlgorithm.getJCASignatureInstance(provider: String? = null) = catch is SignatureAlgorithm.RSA -> when (this.padding) { RSAPadding.PKCS1 -> sigGetInstance("${this.digest.jcaAlgorithmComponent}withRSA", provider) - RSAPadding.PSS -> - sigGetInstance("RSASSA-PSS", provider).also { - it.setParameter(this.digest.jcaPSSParams) - } + RSAPadding.PSS -> when (isAndroid) { + true -> + sigGetInstance("${this.digest.jcaAlgorithmComponent}withRSA/PSS", provider) + false -> + sigGetInstance("RSASSA-PSS", provider).also { + it.setParameter(this.digest.jcaPSSParams) + } + } } } } diff --git a/indispensable/src/jvmTest/kotlin/at/asitplus/signum/indispensable/DistinguishedNameTest.kt b/indispensable/src/jvmTest/kotlin/at/asitplus/signum/indispensable/DistinguishedNameTest.kt index 1edcbd92..df7d9b2a 100644 --- a/indispensable/src/jvmTest/kotlin/at/asitplus/signum/indispensable/DistinguishedNameTest.kt +++ b/indispensable/src/jvmTest/kotlin/at/asitplus/signum/indispensable/DistinguishedNameTest.kt @@ -10,9 +10,9 @@ import io.kotest.matchers.shouldNotBe class DistinguishedNameTest : FreeSpec({ "DistinguishedName test equals and hashCode" - { val oids = listOf( - at.asitplus.signum.indispensable.asn1.KnownOIDs.countryName, at.asitplus.signum.indispensable.asn1.KnownOIDs.country, at.asitplus.signum.indispensable.asn1.KnownOIDs.houseIdentifier, - at.asitplus.signum.indispensable.asn1.KnownOIDs.organizationName, at.asitplus.signum.indispensable.asn1.KnownOIDs.organization, at.asitplus.signum.indispensable.asn1.KnownOIDs.organizationalUnit, - at.asitplus.signum.indispensable.asn1.KnownOIDs.organizationalPerson, at.asitplus.signum.indispensable.asn1.KnownOIDs.brainpoolP512r1 + KnownOIDs.countryName, KnownOIDs.country, KnownOIDs.houseIdentifier, + KnownOIDs.organizationName, KnownOIDs.organization, KnownOIDs.organizationalUnit, + KnownOIDs.organizationalPerson, KnownOIDs.brainpoolP512r1 ) withData(oids) { first -> withData(oids) { second -> diff --git a/indispensable/src/jvmTest/kotlin/at/asitplus/signum/indispensable/Pkcs10CertificationRequestJvmTest.kt b/indispensable/src/jvmTest/kotlin/at/asitplus/signum/indispensable/Pkcs10CertificationRequestJvmTest.kt index 24e30923..66f75be7 100644 --- a/indispensable/src/jvmTest/kotlin/at/asitplus/signum/indispensable/Pkcs10CertificationRequestJvmTest.kt +++ b/indispensable/src/jvmTest/kotlin/at/asitplus/signum/indispensable/Pkcs10CertificationRequestJvmTest.kt @@ -122,9 +122,9 @@ class Pkcs10CertificationRequestJvmTest : FreeSpec({ ), publicKey = cryptoPublicKey, attributes = listOf( - Pkcs10CertificationRequestAttribute(at.asitplus.signum.indispensable.asn1.KnownOIDs.keyUsage, Asn1Element.parse(keyUsage.encoded)), + Pkcs10CertificationRequestAttribute(KnownOIDs.keyUsage, Asn1Element.parse(keyUsage.encoded)), Pkcs10CertificationRequestAttribute( - at.asitplus.signum.indispensable.asn1.KnownOIDs.extKeyUsage, + KnownOIDs.extKeyUsage, Asn1Element.parse(extendedKeyUsage.encoded) ) ) @@ -178,12 +178,12 @@ class Pkcs10CertificationRequestJvmTest : FreeSpec({ publicKey = cryptoPublicKey, extensions = listOf( X509CertificateExtension( - at.asitplus.signum.indispensable.asn1.KnownOIDs.keyUsage, + KnownOIDs.keyUsage, value = Asn1EncapsulatingOctetString(listOf(Asn1Element.parse(keyUsage.encoded))), critical = true ), X509CertificateExtension( - at.asitplus.signum.indispensable.asn1.KnownOIDs.extKeyUsage, + KnownOIDs.extKeyUsage, value = Asn1EncapsulatingOctetString(listOf(Asn1Element.parse(extendedKeyUsage.encoded))), critical = true ) @@ -363,9 +363,9 @@ class Pkcs10CertificationRequestJvmTest : FreeSpec({ Pkcs10CertificationRequestAttribute(ObjectIdentifier("1.2.1840.13549.1.9.16.1337.27"), 1337.encodeToTlv()) val attr13 = Pkcs10CertificationRequestAttribute(ObjectIdentifier("1.2.1840.13549.1.9.16.1337.26"), 1338.encodeToTlv()) - val attr2 = Pkcs10CertificationRequestAttribute(at.asitplus.signum.indispensable.asn1.KnownOIDs.keyUsage, Asn1Element.parse(keyUsage.encoded)) + val attr2 = Pkcs10CertificationRequestAttribute(KnownOIDs.keyUsage, Asn1Element.parse(keyUsage.encoded)) val attr3 = - Pkcs10CertificationRequestAttribute(at.asitplus.signum.indispensable.asn1.KnownOIDs.extKeyUsage, Asn1Element.parse(extendedKeyUsage.encoded)) + Pkcs10CertificationRequestAttribute(KnownOIDs.extKeyUsage, Asn1Element.parse(extendedKeyUsage.encoded)) attr1 shouldBe attr1 attr1 shouldBe attr11 diff --git a/indispensable/src/jvmTest/kotlin/at/asitplus/signum/indispensable/X509CertificateJvmTest.kt b/indispensable/src/jvmTest/kotlin/at/asitplus/signum/indispensable/X509CertificateJvmTest.kt index 2712a877..b3eb8f5d 100644 --- a/indispensable/src/jvmTest/kotlin/at/asitplus/signum/indispensable/X509CertificateJvmTest.kt +++ b/indispensable/src/jvmTest/kotlin/at/asitplus/signum/indispensable/X509CertificateJvmTest.kt @@ -386,27 +386,27 @@ class X509CertificateJvmTest : FreeSpec({ val extendedKeyUsage = ExtendedKeyUsage(KeyPurposeId.anyExtendedKeyUsage) val ext1 = X509CertificateExtension( - at.asitplus.signum.indispensable.asn1.KnownOIDs.keyUsage, + KnownOIDs.keyUsage, value = Asn1EncapsulatingOctetString(listOf(Asn1Element.parse(keyUsage.encoded))), critical = true ) val ext2 = X509CertificateExtension( - at.asitplus.signum.indispensable.asn1.KnownOIDs.keyUsage, + KnownOIDs.keyUsage, value = Asn1EncapsulatingOctetString(listOf(Asn1Element.parse(keyUsage.encoded))), critical = true ) val ext3 = X509CertificateExtension( - at.asitplus.signum.indispensable.asn1.KnownOIDs.extKeyUsage, + KnownOIDs.extKeyUsage, value = Asn1EncapsulatingOctetString(listOf(Asn1Element.parse(extendedKeyUsage.encoded))), critical = true ) val ext4 = X509CertificateExtension( - at.asitplus.signum.indispensable.asn1.KnownOIDs.keyUsage, + KnownOIDs.keyUsage, value = Asn1EncapsulatingOctetString(listOf(Asn1Element.parse(extendedKeyUsage.encoded))), critical = true ) val ext5 = X509CertificateExtension( - at.asitplus.signum.indispensable.asn1.KnownOIDs.keyUsage, + KnownOIDs.keyUsage, value = Asn1EncapsulatingOctetString(listOf(Asn1Element.parse(keyUsage.encoded))), critical = false ) diff --git a/supreme/build.gradle.kts b/supreme/build.gradle.kts index 4e47a0f3..c2893995 100644 --- a/supreme/build.gradle.kts +++ b/supreme/build.gradle.kts @@ -60,11 +60,9 @@ kotlin { sourceSets.jvmTest.dependencies { implementation("io.kotest.extensions:kotest-assertions-compiler:1.0.0") } - /* sourceSets.androidMain.dependencies { implementation("androidx.biometric:biometric:1.2.0-alpha05") } - */ } diff --git a/supreme/src/androidInstrumentedTest/kotlin/at/asitplus/signum/supreme/os/AndroidKeyStoreProviderTests.kt b/supreme/src/androidInstrumentedTest/kotlin/at/asitplus/signum/supreme/os/AndroidKeyStoreProviderTests.kt new file mode 100644 index 00000000..b85f52b8 --- /dev/null +++ b/supreme/src/androidInstrumentedTest/kotlin/at/asitplus/signum/supreme/os/AndroidKeyStoreProviderTests.kt @@ -0,0 +1,36 @@ +package at.asitplus.signum.supreme.os + +import at.asitplus.signum.indispensable.CryptoPublicKey +import at.asitplus.signum.indispensable.SignatureAlgorithm +import at.asitplus.signum.supreme.sign.sign +import at.asitplus.signum.supreme.sign.verifierFor +import at.asitplus.signum.supreme.sign.verify +import br.com.colman.kotest.FreeSpec +import io.kotest.matchers.types.shouldBeInstanceOf +import io.kotest.property.azstring +import kotlin.random.Random + +class AndroidKeyStoreProviderTests: FreeSpec({ + "Create attested keypair" { + val alias = Random.azstring(32) + val attestChallenge = Random.nextBytes(32) + val hardwareSigner = AndroidKeyStoreProvider().createSigningKey(alias) { + hardware { + attestation { + challenge = attestChallenge + } + } + }.getOrThrow() + val publicKey = hardwareSigner.publicKey + publicKey.shouldBeInstanceOf() + + val plaintext = Random.nextBytes(64) + val signature = hardwareSigner.sign(plaintext).getOrThrow() + + SignatureAlgorithm.ECDSAwithSHA256.verifierFor(publicKey).getOrThrow() + .verify(plaintext, signature).getOrThrow() + + // val certificateChain = hardwareSigner.certificateChain + // TODO verify attestation + } +}) diff --git a/supreme/src/androidMain/kotlin/at/asitplus/crypto/provider/os/AndroidKeyStoreProvider.kt b/supreme/src/androidMain/kotlin/at/asitplus/crypto/provider/os/AndroidKeyStoreProvider.kt new file mode 100644 index 00000000..14cfdc8e --- /dev/null +++ b/supreme/src/androidMain/kotlin/at/asitplus/crypto/provider/os/AndroidKeyStoreProvider.kt @@ -0,0 +1,412 @@ +package at.asitplus.signum.supreme.os + +import android.annotation.SuppressLint +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyInfo +import android.security.keystore.KeyProperties +import android.security.keystore.UserNotAuthenticatedException +import androidx.biometric.BiometricPrompt +import androidx.biometric.BiometricPrompt.AuthenticationResult +import androidx.biometric.BiometricPrompt.CryptoObject +import androidx.core.content.ContextCompat +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentActivity +import at.asitplus.KmmResult +import at.asitplus.catching +import at.asitplus.signum.indispensable.CryptoPublicKey +import at.asitplus.signum.indispensable.CryptoSignature +import at.asitplus.signum.indispensable.Digest +import at.asitplus.signum.indispensable.RSAPadding +import at.asitplus.signum.indispensable.SignatureAlgorithm +import at.asitplus.signum.indispensable.getJCASignatureInstance +import at.asitplus.signum.indispensable.jcaName +import at.asitplus.signum.indispensable.parseFromJca +import at.asitplus.signum.indispensable.pki.CertificateChain +import at.asitplus.signum.indispensable.pki.X509Certificate +import at.asitplus.signum.indispensable.pki.leaf +import at.asitplus.signum.supreme.UnlockFailed +import at.asitplus.signum.supreme.UnsupportedCryptoException +import at.asitplus.signum.supreme.dsl.DISCOURAGED +import at.asitplus.signum.supreme.dsl.DSL +import at.asitplus.signum.supreme.dsl.DSLConfigureFn +import at.asitplus.signum.supreme.dsl.FeaturePreference +import at.asitplus.signum.supreme.dsl.PREFERRED +import at.asitplus.signum.supreme.dsl.REQUIRED +import at.asitplus.signum.supreme.os.* +import at.asitplus.signum.supreme.sign.SignatureInput +import com.ionspin.kotlin.bignum.integer.base63.toJavaBigInteger +import at.asitplus.signum.supreme.sign.Signer as SignerI +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.asCoroutineDispatcher +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.launch +import java.security.KeyFactory +import java.security.KeyPairGenerator +import java.security.KeyStore +import java.security.PrivateKey +import java.security.Signature +import java.security.spec.ECGenParameterSpec +import java.security.spec.RSAKeyGenParameterSpec +import java.time.Instant +import java.util.Date +import javax.security.auth.x500.X500Principal +import java.security.Signature as JCASignatureObject + +internal sealed interface FragmentContext { + @JvmInline value class OfActivity(val activity: FragmentActivity): FragmentContext + @JvmInline value class OfFragment(val fragment: Fragment): FragmentContext +} + + +class AndroidKeymasterConfiguration internal constructor(): PlatformSigningKeyConfiguration.SecureHardwareConfiguration() { + /** Whether a StrongBox TPM is required. */ + var strongBox: FeaturePreference = PREFERRED +} +class AndroidSigningKeyConfiguration internal constructor(): PlatformSigningKeyConfiguration() { + override val hardware = childOrNull(::AndroidKeymasterConfiguration) +} + +class AndroidSignerConfiguration: SignerConfiguration() { + class AuthnPrompt: SignerConfiguration.AuthnPrompt() { + var subtitle: String? = null + var description: String? = null + var confirmationRequired: Boolean? = null + var allowedAuthenticators: Int? = null + /** if the provided fingerprint could not be matched, but the user will be allowed to retry */ + var invalidBiometryCallback: (()->Unit)? = null + } + override val unlockPrompt = childOrDefault(::AuthnPrompt) +} + +/** + * Resolve [what] differently based on whether the [v]alue was [spec]ified. + * + * * [spec] = `true`: Check if [valid] contains [nameMap] applied to [v], return [v] if yes, throw otherwise + * * [spec] = `false`: Check if [valid] contains exactly one element, if yes, return the [E] from [possible] for which [nameMap] returns that element, throw otherwise + */ +inline fun resolveOption(what: String, valid: Array, possible: Sequence, spec: Boolean, v: E, crossinline nameMap: (E)->String): E = + when (spec) { + true -> { + val vStr = nameMap(v) + if (!valid.any { it.equals(vStr, ignoreCase=true) }) + throw IllegalArgumentException("Key does not suppport $what $v; supported: $valid") + v + } + false -> { + if (valid.size != 1) + throw IllegalArgumentException("Key supports multiple ${what}s ($valid). You need to specify $what in signer configuration.") + val only = valid.first() + possible.find { + nameMap(it).equals(only, ignoreCase=true) + } ?: throw UnsupportedCryptoException("Unsupported $what $only") + } + } + +sealed class AndroidKeyStoreProviderImpl private constructor() : + SigningProviderI +{ + + class WithoutContext internal constructor() : + AndroidKeyStoreProviderImpl() + { + override val context get() = null + } + + class WithContext internal constructor(override val context: FragmentContext) : + AndroidKeyStoreProviderImpl() + + companion object { + /** + * Instantiate the keystore provider without associating an activity or fragment. + * Biometric authentication will be impossible. + */ + operator fun invoke() = + WithoutContext() + + /** + * Instantiate the keystore provider associated with this particular activity. + */ + operator fun invoke(activity: FragmentActivity) = + WithContext(FragmentContext.OfActivity(activity)) + + /** + * Instantiate the keystore provider associated with this particular fragment. + */ + operator fun invoke(fragment: Fragment) = + WithContext(FragmentContext.OfFragment(fragment)) + } + + private val ks: KeyStore = + KeyStore.getInstance("AndroidKeyStore").apply { load(null, null) } + + @SuppressLint("WrongConstant") + final override suspend fun createSigningKey( + alias: String, + configure: DSLConfigureFn + ) = catching { + val config = DSL.resolve(::AndroidSigningKeyConfiguration, configure) + val spec = KeyGenParameterSpec.Builder( + alias, + KeyProperties.PURPOSE_SIGN + ).apply { + when(val algSpec = config._algSpecific.v) { + is SigningKeyConfiguration.RSAConfiguration -> { + setAlgorithmParameterSpec( + RSAKeyGenParameterSpec(algSpec.bits, algSpec.publicExponent.toJavaBigInteger())) + setDigests(*algSpec.digests.map(Digest::jcaName).toTypedArray()) + setSignaturePaddings(*algSpec.paddings.map { + when (it) { + RSAPadding.PKCS1 -> KeyProperties.SIGNATURE_PADDING_RSA_PKCS1 + RSAPadding.PSS -> KeyProperties.SIGNATURE_PADDING_RSA_PSS + } + }.toTypedArray()) + } + is SigningKeyConfiguration.ECConfiguration -> { + setAlgorithmParameterSpec(ECGenParameterSpec(algSpec.curve.jcaName)) + setDigests(*algSpec.digests.map { it?.jcaName ?: KeyProperties.DIGEST_NONE }.toTypedArray()) + } + } + setCertificateNotBefore(Date.from(Instant.now())) + setCertificateSubject(X500Principal("CN=$alias")) // TODO + config.hardware.v?.let { hw -> + setIsStrongBoxBacked(when (hw.strongBox) { + REQUIRED -> true + PREFERRED -> false // TODO + DISCOURAGED -> false + }) + hw.attestation.v?.let { + setAttestationChallenge(it.challenge) + } + hw.protection.v?.let { + setUserAuthenticationRequired(true) + setUserAuthenticationParameters(it.timeout.inWholeSeconds.toInt(), + (if (it.factors.v.biometry) KeyProperties.AUTH_BIOMETRIC_STRONG else 0) or + (if (it.factors.v.deviceLock) KeyProperties.AUTH_DEVICE_CREDENTIAL else 0)) + } + } + }.build() + KeyPairGenerator.getInstance(when(config._algSpecific.v) { + is SigningKeyConfiguration.RSAConfiguration -> KeyProperties.KEY_ALGORITHM_RSA + is SigningKeyConfiguration.ECConfiguration -> KeyProperties.KEY_ALGORITHM_EC + }, "AndroidKeyStore").apply { + initialize(spec) + }.generateKeyPair() + return@catching getSignerForKey(alias, config.signer.v).getOrThrow() + } + + internal abstract val context: FragmentContext? + + final override suspend fun getSignerForKey( + alias: String, + configure: DSLConfigureFn + ): KmmResult = catching { + val jcaPrivateKey = ks.getKey(alias, null) as PrivateKey + val config = DSL.resolve(::AndroidSignerConfiguration, configure) + val certificateChain = + ks.getCertificateChain(alias).map { X509Certificate.decodeFromDer(it.encoded) } + val keyInfo = KeyFactory.getInstance(jcaPrivateKey.algorithm) + .getKeySpec(jcaPrivateKey, KeyInfo::class.java) + val algorithm = when (val publicKey = certificateChain.leaf.publicKey) { + is CryptoPublicKey.EC -> { + val ecConfig = config.ec.v + val digest = resolveOption("digest", keyInfo.digests, Digest.entries.asSequence() + sequenceOf(null), ecConfig.digestSpecified, ecConfig.digest) { it?.jcaName ?: KeyProperties.DIGEST_NONE } + SignatureAlgorithm.ECDSA(digest, publicKey.curve) + } + is CryptoPublicKey.Rsa -> { + val rsaConfig = config.rsa.v + val digest = resolveOption("digest", keyInfo.digests, Digest.entries.asSequence(), rsaConfig.digestSpecified, rsaConfig.digest, Digest::jcaName) + val padding = resolveOption("padding", keyInfo.signaturePaddings, RSAPadding.entries.asSequence(), rsaConfig.paddingSpecified, rsaConfig.padding) { + when (it) { + RSAPadding.PKCS1 -> KeyProperties.SIGNATURE_PADDING_RSA_PKCS1 + RSAPadding.PSS -> KeyProperties.SIGNATURE_PADDING_RSA_PSS + } + } + SignatureAlgorithm.RSA(digest, padding) + } + } + + val result: AndroidKeystoreSigner = if (keyInfo.isUserAuthenticationRequired) { + val ctx = context + ?: throw IllegalStateException("Key requires biometric authentication, but no fragment/activity context is available.") + when (certificateChain.leaf.publicKey) { + is CryptoPublicKey.EC -> + LockedAndroidKeystoreSigner.ECDSA( + ctx, jcaPrivateKey, keyInfo, config, certificateChain, + algorithm as SignatureAlgorithm.ECDSA) + is CryptoPublicKey.Rsa -> + LockedAndroidKeystoreSigner.RSA( + ctx, jcaPrivateKey, keyInfo, config, certificateChain, + algorithm as SignatureAlgorithm.RSA) + } + } else { + val jcaSig = algorithm.getJCASignatureInstance(isAndroid=true) + .getOrThrow().also { it.initSign(jcaPrivateKey) } + when (val publicKey = certificateChain.leaf.publicKey) { + is CryptoPublicKey.EC -> + UnlockedAndroidKeystoreSigner.ECDSA( + jcaSig, keyInfo, AndroidKeystoreAttestation(certificateChain), publicKey, + algorithm as SignatureAlgorithm.ECDSA) + is CryptoPublicKey.Rsa -> + UnlockedAndroidKeystoreSigner.RSA( + jcaSig, keyInfo, AndroidKeystoreAttestation(certificateChain), publicKey, + algorithm as SignatureAlgorithm.RSA) + } + } + @Suppress("UNCHECKED_CAST") + return@catching result as SignerT + } + + final override suspend fun deleteSigningKey(alias: String) { + ks.deleteEntry(alias) + } +} + +typealias AndroidKeyStoreProvider = AndroidKeyStoreProviderImpl<*> + +interface AndroidKeystoreSigner : SignerI.Attestable { + val keyInfo: KeyInfo + override val attestation: AndroidKeystoreAttestation +} + +sealed class UnlockedAndroidKeystoreSigner private constructor( + private val jcaSig: JCASignatureObject, + override val keyInfo: KeyInfo, + override val attestation: AndroidKeystoreAttestation +): SignerI.UnlockedHandle, AndroidKeystoreSigner { + + class ECDSA internal constructor(jcaSig: JCASignatureObject, + keyInfo: KeyInfo, + certificateChain: AndroidKeystoreAttestation, + override val publicKey: CryptoPublicKey.EC, + override val signatureAlgorithm: SignatureAlgorithm.ECDSA + ) : UnlockedAndroidKeystoreSigner(jcaSig, keyInfo, certificateChain), SignerI.ECDSA + + class RSA internal constructor(jcaSig: JCASignatureObject, + keyInfo: KeyInfo, + certificateChain: AndroidKeystoreAttestation, + override val publicKey: CryptoPublicKey.Rsa, + override val signatureAlgorithm: SignatureAlgorithm.RSA + ) : UnlockedAndroidKeystoreSigner(jcaSig, keyInfo, certificateChain), SignerI.RSA + + final override fun sign(data: SignatureInput) = catching { + require(data.format == null) + data.data.forEach(jcaSig::update) + val jcaSignature = jcaSig.sign() + when (this) { + is ECDSA -> CryptoSignature.EC.parseFromJca(jcaSignature) + is RSA -> CryptoSignature.RSAorHMAC.parseFromJca(jcaSignature) + } + } + + final override fun close() {} + +} + +sealed class LockedAndroidKeystoreSigner private constructor( + private val context: FragmentContext, + private val jcaPrivateKey: PrivateKey, + override val keyInfo: KeyInfo, + private val config: AndroidSignerConfiguration, + certificateChain: CertificateChain +) : SignerI.TemporarilyUnlockable(), AndroidKeystoreSigner { + + override val attestation = AndroidKeystoreAttestation(certificateChain) + private sealed interface AuthResult { + @JvmInline value class Success(val result: AuthenticationResult): AuthResult + data class Error(val code: Int, val message: String): AuthResult + } + + private suspend fun attemptBiometry(config: AndroidSignerConfiguration.AuthnPrompt, forSpecificKey: CryptoObject?) { + val channel = Channel(capacity = Channel.RENDEZVOUS) + val executor = when (context) { + is FragmentContext.OfActivity -> ContextCompat.getMainExecutor(context.activity) + is FragmentContext.OfFragment -> ContextCompat.getMainExecutor(context.fragment.context) + } + executor.asCoroutineDispatcher().let(::CoroutineScope).launch { + val promptInfo = BiometricPrompt.PromptInfo.Builder().apply { + setTitle(config.message) + setNegativeButtonText(config.cancelText) + config.subtitle?.let(this::setSubtitle) + config.description?.let(this::setDescription) + config.allowedAuthenticators?.let(this::setAllowedAuthenticators) + config.confirmationRequired?.let(this::setConfirmationRequired) + }.build() + val siphon = object: BiometricPrompt.AuthenticationCallback() { + private fun send(v: AuthResult) { + executor.asCoroutineDispatcher().let(::CoroutineScope).launch { channel.send(v) } + } + override fun onAuthenticationSucceeded(result: AuthenticationResult) { + send(AuthResult.Success(result)) + } + override fun onAuthenticationError(errorCode: Int, errString: CharSequence) { + send(AuthResult.Error(errorCode, errString.toString())) + } + override fun onAuthenticationFailed() { + config.invalidBiometryCallback?.invoke() + } + } + val prompt = when (context) { + is FragmentContext.OfActivity -> BiometricPrompt(context.activity, executor, siphon) + is FragmentContext.OfFragment -> BiometricPrompt(context.fragment, executor, siphon) + } + when (forSpecificKey) { + null -> prompt.authenticate(promptInfo) + else -> prompt.authenticate(promptInfo, forSpecificKey) + } + } + when (val result = channel.receive()) { + is AuthResult.Success -> return + is AuthResult.Error -> throw UnlockFailed("${result.message} (code ${result.code})") + } + } + + protected abstract fun toUnlocked(jcaSig: JCASignatureObject): UnlockedAndroidKeystoreSigner + + final override suspend fun unlock(): KmmResult = + signatureAlgorithm.getJCASignatureInstance(isAndroid=true).onSuccess { + if (needsAuthenticationForEveryUse) { + it.initSign(jcaPrivateKey) + attemptBiometry(config.unlockPrompt.v, CryptoObject(it)) + } else { + try { + it.initSign(jcaPrivateKey) + } catch (_: UserNotAuthenticatedException) { + attemptBiometry(config.unlockPrompt.v, null) + it.initSign(jcaPrivateKey) + } + } + }.mapCatching(this::toUnlocked) + + class ECDSA internal constructor(context: FragmentContext, + jcaPrivateKey: PrivateKey, + keyInfo: KeyInfo, + config: AndroidSignerConfiguration, + certificateChain: CertificateChain, + override val signatureAlgorithm: SignatureAlgorithm.ECDSA) + : LockedAndroidKeystoreSigner(context, jcaPrivateKey, keyInfo, config, certificateChain), SignerI.ECDSA { + override val publicKey = certificateChain.leaf.publicKey as CryptoPublicKey.EC + override fun toUnlocked(jcaSig: Signature) = + UnlockedAndroidKeystoreSigner.ECDSA(jcaSig, keyInfo, attestation, publicKey, signatureAlgorithm) + } + + class RSA internal constructor(context: FragmentContext, + jcaPrivateKey: PrivateKey, + keyInfo: KeyInfo, + config: AndroidSignerConfiguration, + certificateChain: CertificateChain, + override val signatureAlgorithm: SignatureAlgorithm.RSA) + : LockedAndroidKeystoreSigner(context, jcaPrivateKey, keyInfo, config, certificateChain), SignerI.RSA { + override val publicKey = certificateChain.leaf.publicKey as CryptoPublicKey.Rsa + override fun toUnlocked(jcaSig: Signature) = + UnlockedAndroidKeystoreSigner.RSA(jcaSig, keyInfo, attestation, publicKey, signatureAlgorithm) + } +} + +val AndroidKeystoreSigner.needsAuthentication inline get() = + keyInfo.isUserAuthenticationRequired +val AndroidKeystoreSigner.needsAuthenticationForEveryUse inline get() = + keyInfo.isUserAuthenticationRequired && + (keyInfo.userAuthenticationValidityDurationSeconds <= 0) +val AndroidKeystoreSigner.needsAuthenticationWithTimeout inline get() = + keyInfo.isUserAuthenticationRequired && + (keyInfo.userAuthenticationValidityDurationSeconds > 0) diff --git a/supreme/src/androidMain/kotlin/at/asitplus/signum/supreme/sign/VerifierImpl.kt b/supreme/src/androidMain/kotlin/at/asitplus/signum/supreme/sign/VerifierImpl.kt index dce7f596..31fd2a02 100644 --- a/supreme/src/androidMain/kotlin/at/asitplus/signum/supreme/sign/VerifierImpl.kt +++ b/supreme/src/androidMain/kotlin/at/asitplus/signum/supreme/sign/VerifierImpl.kt @@ -33,8 +33,7 @@ private fun getSigInstance(alg: String, p: String?) = @Throws(UnsupportedCryptoException::class) internal actual fun checkAlgorithmKeyCombinationSupportedByECDSAPlatformVerifier (signatureAlgorithm: SignatureAlgorithm.ECDSA, publicKey: CryptoPublicKey.EC, - config: PlatformVerifierConfiguration -) + config: PlatformVerifierConfiguration) { wrapping(asA=::UnsupportedCryptoException) { getSigInstance("${signatureAlgorithm.digest.jcaAlgorithmComponent}withECDSA", config.provider) @@ -46,8 +45,7 @@ internal actual fun checkAlgorithmKeyCombinationSupportedByECDSAPlatformVerifier internal actual fun verifyECDSAImpl (signatureAlgorithm: SignatureAlgorithm.ECDSA, publicKey: CryptoPublicKey.EC, data: SignatureInput, signature: CryptoSignature.EC, - config: PlatformVerifierConfiguration -) + config: PlatformVerifierConfiguration) { val (input, alg) = when { (data.format == null) -> /* input data is not hashed, let JCA do hashing */ @@ -73,8 +71,7 @@ private fun getRSAInstance(alg: SignatureAlgorithm.RSA, config: PlatformVerifier @Throws(UnsupportedCryptoException::class) internal actual fun checkAlgorithmKeyCombinationSupportedByRSAPlatformVerifier (signatureAlgorithm: SignatureAlgorithm.RSA, publicKey: CryptoPublicKey.Rsa, - config: PlatformVerifierConfiguration -) + config: PlatformVerifierConfiguration) { wrapping(asA=::UnsupportedCryptoException) { getRSAInstance(signatureAlgorithm, config) @@ -86,8 +83,7 @@ internal actual fun checkAlgorithmKeyCombinationSupportedByRSAPlatformVerifier internal actual fun verifyRSAImpl (signatureAlgorithm: SignatureAlgorithm.RSA, publicKey: CryptoPublicKey.Rsa, data: SignatureInput, signature: CryptoSignature.RSAorHMAC, - config: PlatformVerifierConfiguration -) + config: PlatformVerifierConfiguration) { getRSAInstance(signatureAlgorithm, config).run { initVerify(publicKey.getJcaPublicKey().getOrThrow()) diff --git a/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/Throwables.kt b/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/Throwables.kt index 72a5921e..fcc53633 100644 --- a/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/Throwables.kt +++ b/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/Throwables.kt @@ -1,6 +1,8 @@ package at.asitplus.signum.supreme sealed class CryptoException(message: String? = null, cause: Throwable? = null) : Throwable(message, cause) -class CryptoOperationFailed(message: String) : CryptoException(message) +open class CryptoOperationFailed(message: String) : CryptoException(message) -class UnsupportedCryptoException(message: String? = null, cause: Throwable? = null) : CryptoException(message, cause) +open class UnsupportedCryptoException(message: String? = null, cause: Throwable? = null) : CryptoException(message, cause) + +class UnlockFailed(message: String? = null, cause: Throwable? = null) : Throwable(message, cause) diff --git a/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/dsl/ConfigurationDSL.kt b/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/dsl/ConfigurationDSL.kt index b5d1d4d1..59e46f5a 100644 --- a/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/dsl/ConfigurationDSL.kt +++ b/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/dsl/ConfigurationDSL.kt @@ -7,8 +7,8 @@ package at.asitplus.signum.supreme.dsl */ object DSL { /** Resolve a DSL lambda to a concrete configuration */ - fun resolve(factory: ()->T, config: DSLConfigureFn): T = - (if (config == null) factory() else factory().apply(config)).also(Data::validate) + fun resolve(factory: ()->T, config: DSLConfigureFn): T = + (if (config == null) factory() else factory().apply(config)).also(DSL.Data::validate) sealed interface Holder { val v: T @@ -19,8 +19,8 @@ object DSL { } /** Constructed by: [DSL.Data.child]. */ - class DirectHolder internal constructor(default: T, private val factory: ()->(T & Any)) - : Invokable { + class DirectHolder internal constructor(default: T, private val factory: ()->(T & Any)) + : Invokable { private var _v: T = default override val v: T get() = _v @@ -28,7 +28,7 @@ object DSL { } /** Constructed by: [DSL.Data.subclassOf]. */ - class Generalized internal constructor(default: T): Holder { + class Generalized internal constructor(default: T): Holder { private var _v: T = default override val v: T get() = _v @@ -41,14 +41,14 @@ object DSL { * This constructs a new specialized child, configures it using the specified block, * and stores it in the underlying generalized storage. */ - internal constructor(private val factory: ()->S) : Invokable { + internal constructor(private val factory: ()->S) : Invokable { override val v: T get() = this@Generalized.v override operator fun invoke(configure: S.()->Unit) { _v = resolve(factory, configure) } } } /** Constructed by: [DSL.Data.integratedReceiver]. */ - class Integrated internal constructor(): Invokable<(T.() -> Unit)?, T> { + class Integrated internal constructor(): Invokable<(T.()->Unit)?, T> { private var _v: (T.()->Unit)? = null override val v: (T.()->Unit)? get() = _v override operator fun invoke(configure: T.()->Unit) { _v = configure } @@ -61,24 +61,25 @@ object DSL { @Marker open class Data { /** - * Embeds a child; use as `val sub = child(::TypeOfSub)`. - * Defaults to a default-constructed child. + * Embeds an optional child. Use as `val sub = childOrNull(::TypeOfSub)`. + * Defaults to `null`. * - * User code will invoke as `child { }`. + * User code will invoke as `sub { }` * This constructs a new child and configures it using the specified block. */ - protected fun child(factory: ()->T): Invokable = - DirectHolder(factory(), factory) + protected fun childOrNull(factory: ()->T): Invokable = + DirectHolder(null, factory) /** - * Embeds an optional child. Use as `val sub = childOrNull(::TypeOfSub)`. - * Defaults to `null`. + * Embeds an optional child. Use as `val sub = childOrDefault(::TypeOfSub) { ... } + * Defaults to a child configured using the specified default block. * - * User code will invoke as `child { }` + * User code will invoke as `sub { }` * This constructs a new child and configures it using the specified block. + * Note that the specified default block is **not** applied if user code configures the child. */ - protected fun childOrNull(factory: ()->T): Invokable = - DirectHolder(null, factory) + protected fun childOrDefault(factory: ()->T, default: (T.()->Unit)? = null): Invokable = + DirectHolder(factory().also{ default?.invoke(it) }, factory) /** * Specifies a generalized holder of type T. @@ -90,7 +91,7 @@ object DSL { * Specialized invokable accessors can be spun off via `.option(::SpecializedClass)`. * @see DSL.Generalized.option */ - protected fun subclassOf(): Generalized = + protected fun subclassOf(): Generalized = Generalized(null) /** * Specifies a generalized holder of type T. @@ -102,7 +103,7 @@ object DSL { * Specialized invokable accessors can be spun off via `.option(::SpecializedClass)`. * @see DSL.Generalized.option */ - protected fun subclassOf(default: T): Generalized = + protected fun subclassOf(default: T): Generalized = Generalized(default) /** diff --git a/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/dsl/DataTypes.kt b/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/dsl/DataTypes.kt new file mode 100644 index 00000000..f06070e1 --- /dev/null +++ b/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/dsl/DataTypes.kt @@ -0,0 +1,19 @@ +package at.asitplus.signum.supreme.dsl + +/** Tri-state setting for enabling a given feature. + * @see REQUIRED + * @see PREFERRED + * @see DISCOURAGED + */ +sealed interface FeaturePreference +/** Marks this feature as non-negotiable and absolutely required. + If the feature is not available on the current platform, the operation may fail. */ +object REQUIRED : FeaturePreference +/** Marks this feature as preferred. + If the feature is available on the current platform and with the specified configuration, it will be used. + If not, it will silently not be used. The effective state might be determined from the output. */ +object PREFERRED : FeaturePreference +/** Marks this feature as discouraged. + If it is possible to complete the operation without using the feature, this will be done. + The feature will only be used if its use is required to allow the operation to succeed. */ +object DISCOURAGED : FeaturePreference diff --git a/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/os/Attestation.kt b/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/os/Attestation.kt new file mode 100644 index 00000000..46418b5e --- /dev/null +++ b/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/os/Attestation.kt @@ -0,0 +1,60 @@ +package at.asitplus.signum.supreme.os + +import at.asitplus.signum.indispensable.io.ByteArrayBase64UrlSerializer +import at.asitplus.signum.indispensable.pki.CertificateChain +import kotlinx.serialization.Serializable +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.add +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import kotlinx.serialization.json.putJsonArray +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi + +sealed interface Attestation { + val jsonEncoded: String +} + +data class AndroidKeystoreAttestation (val certificateChain: CertificateChain) : Attestation { + @OptIn(ExperimentalEncodingApi::class) + override val jsonEncoded: String by lazy { + Json.encodeToString(buildJsonObject { + put("fmt", "android-key") + putJsonArray("x5c") { + certificateChain.forEach { add(Base64.UrlSafe.encode(it.encodeToDer())) } + } + }) + } +} + +@Serializable +data class iosHomebrewAttestation( + // TODO document this + @Serializable(ByteArrayBase64UrlSerializer::class) + val attestation: ByteArray, + @Serializable(ByteArrayBase64UrlSerializer::class) + val assertion: ByteArray): Attestation { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is iosHomebrewAttestation) return false + + if (!attestation.contentEquals(other.attestation)) return false + return assertion.contentEquals(other.assertion) + } + + override fun hashCode(): Int { + var result = attestation.contentHashCode() + result = 31 * result + assertion.contentHashCode() + return result + } + + @OptIn(ExperimentalEncodingApi::class) + override val jsonEncoded: String by lazy { + Json.encodeToString(buildJsonObject { + put("fmt", "ios-appattest-assertion") + put("attestation", Base64.UrlSafe.encode(attestation)) + put("assertion", Base64.UrlSafe.encode(assertion)) + }) + } +} diff --git a/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/os/SigningProvider.kt b/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/os/SigningProvider.kt new file mode 100644 index 00000000..4742de99 --- /dev/null +++ b/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/os/SigningProvider.kt @@ -0,0 +1,141 @@ +package at.asitplus.signum.supreme.os + +import at.asitplus.KmmResult +import at.asitplus.signum.indispensable.Digest +import at.asitplus.signum.indispensable.ECCurve +import at.asitplus.signum.indispensable.RSAPadding +import at.asitplus.signum.indispensable.nativeDigest +import at.asitplus.signum.supreme.dsl.DISCOURAGED +import at.asitplus.signum.supreme.dsl.DSL +import at.asitplus.signum.supreme.dsl.DSLConfigureFn +import at.asitplus.signum.supreme.dsl.FeaturePreference +import at.asitplus.signum.supreme.dsl.REQUIRED +import at.asitplus.signum.supreme.sign.Signer +import com.ionspin.kotlin.bignum.integer.BigInteger +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds + +open class SigningKeyConfiguration internal constructor(): DSL.Data() { + sealed class AlgorithmSpecific: DSL.Data() + internal val _algSpecific = subclassOf(default = ECConfiguration()) + open class ECConfiguration internal constructor() : AlgorithmSpecific() { + var curve: ECCurve = ECCurve.SECP_256_R_1 + + private var _digests: Set? = null + /** Specify the digests supported by the key. If not specified, supports the curve's native digest only. */ + var digests: Set + get() = _digests ?: setOf(curve.nativeDigest) + set(v) { _digests = v } + } + open val ec = _algSpecific.option(::ECConfiguration) + + open class RSAConfiguration internal constructor(): AlgorithmSpecific() { + companion object { val F0 = BigInteger(3); val F4 = BigInteger(65537)} + var digests: Set = setOf(Digest.SHA1, Digest.SHA256, Digest.SHA384, Digest.SHA512) + var paddings: Set = setOf(RSAPadding.PSS) + var bits: Int = 4096 + var publicExponent: BigInteger = F4 + } + open val rsa = _algSpecific.option(::RSAConfiguration) +} + +open class PlatformSigningKeyConfiguration internal constructor(): SigningKeyConfiguration() { + open class AttestationConfiguration internal constructor(): DSL.Data() { + /** The server-provided attestation challenge */ + lateinit var challenge: ByteArray + override fun validate() { + require(this::challenge.isInitialized) { "Server-provided attestation challenge must be set" } + } + } + + open class ProtectionFactorConfiguration internal constructor(): DSL.Data() { + /** Whether a biometric factor (fingerprint, facial recognition, ...) can authorize this key */ + var biometry = true + /** Whether a device unlock code, PIN, etc. can authorize this key */ + var deviceLock = true + + override fun validate() { + require(biometry || deviceLock) { "At least one authentication factor must be permissible" } + } + } + + open class ProtectionConfiguration internal constructor(): DSL.Data() { + /** The timeout before this key will need to be unlocked again. */ + var timeout: Duration = 0.seconds + /** Which authentication factors can authorize this key; + * if multiple factors are specified, any one of them can authorize the key */ + val factors = childOrDefault(::ProtectionFactorConfiguration) + } + + open class SecureHardwareConfiguration: DSL.Data() { + /** Whether to use hardware-backed storage, such as Android Keymaster or Apple's Secure Enclave. + * @see FeaturePreference */ + var backing: FeaturePreference = REQUIRED + open val attestation = childOrNull(::AttestationConfiguration) + open val protection = childOrNull(::ProtectionConfiguration) + override fun validate() { + super.validate() + require((backing != DISCOURAGED) || (attestation.v == null)) + { "To obtain hardware attestation, enable secure hardware support (do not set backing = DISCOURAGED, use backing = PREFERRED or backing = REQUIRED instead)."} + } + } + + open val hardware = childOrNull(::SecureHardwareConfiguration) + + open val signer = integratedReceiver() + + // TODO: figure out a reasonable common interface for biometry requirements +} + +open class ECSignerConfiguration internal constructor(): DSL.Data() { + internal var digestSpecified = false + /** + * Explicitly specify the digest to sign over. + * Omit to default to the only supported digest. + * + * If the key supports multiple digests, you need to explicitly specify the digest to use. + */ + var digest: Digest? = null; set(v) { digestSpecified = true; field = v } +} +open class RSASignerConfiguration internal constructor(): DSL.Data() { + internal var digestSpecified = false + /** + * Explicitly specify the digest to sign over. + * Omit to default to the only supported digest. + * + * If the key supports multiple digests, you need to explicitly specify the digest to use. + */ + var digest: Digest = Digest.SHA256; set(v) { digestSpecified = true; field = v } + + internal var paddingSpecified = false + /** + * Explicitly specify the padding to use. + * Omit to default to the only supported padding. + * + * If the key supports multiple padding modes, you need to explicitly specify the digest to use. + */ + var padding: RSAPadding = RSAPadding.PKCS1; set(v) { paddingSpecified = true; field = v } + + +} +open class SignerConfiguration internal constructor(): DSL.Data() { + open class AuthnPrompt: DSL.Data() { + /** The prompt message to show to the user when asking for unlock */ + var message: String = "Please authorize cryptographic signature" + /** The message to show on the cancellation button */ + var cancelText: String = "Abort" + } + open val unlockPrompt = childOrDefault(::AuthnPrompt) + + open val ec = childOrDefault(::ECSignerConfiguration) + open val rsa = childOrDefault(::RSASignerConfiguration) +} + +interface SigningProviderI> { + suspend fun createSigningKey(alias: String, configure: DSLConfigureFn = null) : KmmResult + suspend fun getSignerForKey(alias: String, configure: DSLConfigureFn = null) : KmmResult + suspend fun deleteSigningKey(alias: String) +} +typealias SigningProvider = SigningProviderI<*,*,*> diff --git a/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/sign/Signer.kt b/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/sign/Signer.kt new file mode 100644 index 00000000..7936891f --- /dev/null +++ b/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/sign/Signer.kt @@ -0,0 +1,130 @@ +package at.asitplus.signum.supreme.sign + +import at.asitplus.KmmResult +import at.asitplus.catching +import at.asitplus.signum.indispensable.CryptoPublicKey +import at.asitplus.signum.indispensable.CryptoSignature +import at.asitplus.signum.indispensable.SignatureAlgorithm +import at.asitplus.signum.supreme.UnlockFailed +import at.asitplus.signum.supreme.os.Attestation + +interface Signer { + val signatureAlgorithm: SignatureAlgorithm + val publicKey: CryptoPublicKey + + /** Any [Signer] instantiation must be [ECDSA] or [RSA] */ + sealed interface AlgTrait : Signer + + /** ECDSA signer */ + interface ECDSA : Signer.AlgTrait { + override val signatureAlgorithm: SignatureAlgorithm.ECDSA + override val publicKey: CryptoPublicKey.EC + } + + /** RSA signer */ + interface RSA : Signer.AlgTrait { + override val signatureAlgorithm: SignatureAlgorithm.RSA + override val publicKey: CryptoPublicKey.Rsa + } + + /** Some [Signer]s might have an attestation of some sort */ + interface Attestable: Signer { + val attestation: AttestationT? + } + + /** Any [Signer] is either [Unlocked] or [Unlockable] */ + sealed interface UnlockTrait: Signer + + /** + * This signer either does not require unlock, or is already unlocked. + * Signing operations immediately complete. + */ + interface Unlocked: Signer.UnlockTrait { + /** + * Signs the input. + * This operation never suspends. + */ + fun sign(data: SignatureInput): KmmResult + } + + /** + * This signer might require unlock. + * Signing operations might suspend while the user is prompted for confirmation. + * + * Some signers of this type are [TemporarilyUnlockable]. + */ + interface Unlockable: Signer.UnlockTrait { + /** + * Unlocks this signer, and signs the message once unlocked. + * This operation might suspend and request unlock from the user. + */ + suspend fun sign(data: SignatureInput): KmmResult + } + + /** + * A handle to a [TemporarilyUnlockable] signer that is temporarily unlocked. + * The handle is only guaranteed to be valid within the scope of the block. + */ + @OptIn(ExperimentalStdlibApi::class) + interface UnlockedHandle: AutoCloseable, Signer.Unlocked + + /** + * An [Unlockable] signer that can be temporarily unlocked. + * Once unlocked, multiple signing operations can be performed with a single unlock. + */ + abstract class TemporarilyUnlockable : Signer.Unlockable { + protected abstract suspend fun unlock(): KmmResult + + /** + * Unlocks the signer, then executes the block with the [UnlockedHandle] as its receiver. + * + * The handle's validity is only guaranteed in the block scope. + */ + @OptIn(ExperimentalStdlibApi::class) + suspend fun withUnlock(fn: Handle.()->T): KmmResult = + unlock().mapCatching { it.use(fn) } + + final override suspend fun sign(data: SignatureInput): KmmResult = + withUnlock { sign(data).getOrThrow() } + } +} + +/** + * Get a verifier for signatures generated by this [Signer]. + * @see SignatureAlgorithm.verifierFor + */ +fun Signer.makeVerifier(configure: ConfigurePlatformVerifier = null) = signatureAlgorithm.verifierFor(publicKey, configure) + +/** + * Gets a platform verifier for signatures generated by this [Signer]. + * @see SignatureAlgorithm.platformVerifierFor + */ +fun Signer.makePlatformVerifier(configure: ConfigurePlatformVerifier = null) = signatureAlgorithm.platformVerifierFor(publicKey, configure) + +val Signer.ECDSA.curve get() = publicKey.curve + +/** Sign without caring what type of signer this is. Might suspend. */ +suspend fun Signer.sign(data: SignatureInput): KmmResult { + this as Signer.UnlockTrait + return when (this) { + is Signer.Unlocked -> sign(data) + is Signer.Unlockable -> sign(data) + } +} + +/** + * Try to batch sign with this signer. + * Might fail for unlockable signers that cannot be temporarily unlocked. + */ +suspend fun Signer.withUnlock(fn: Signer.Unlocked.()->T) = catching { + this as Signer.UnlockTrait + when (this) { + is Signer.Unlocked -> this.fn() + is Signer.TemporarilyUnlockable<*> -> this.withUnlock(fn).getOrThrow() + is Signer.Unlockable -> throw UnlockFailed("This signer needs authentication for every use") + } +} + +suspend inline fun Signer.sign(data: ByteArray) = sign(SignatureInput(data)) +inline fun T.sign(data: ByteArray) = sign(SignatureInput(data)) +suspend inline fun T.sign(data: ByteArray) = sign(SignatureInput(data)) diff --git a/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/sign/Verifier.kt b/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/sign/Verifier.kt index 3b0fbd79..728aefa9 100644 --- a/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/sign/Verifier.kt +++ b/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/sign/Verifier.kt @@ -2,6 +2,7 @@ package at.asitplus.signum.supreme.sign import at.asitplus.KmmResult import at.asitplus.catching +import at.asitplus.recoverCatching import at.asitplus.signum.indispensable.CryptoPublicKey import at.asitplus.signum.indispensable.CryptoSignature import at.asitplus.signum.indispensable.SignatureAlgorithm @@ -54,18 +55,16 @@ sealed interface KotlinVerifier: Verifier @Throws(UnsupportedCryptoException::class) internal expect fun checkAlgorithmKeyCombinationSupportedByECDSAPlatformVerifier (signatureAlgorithm: SignatureAlgorithm.ECDSA, publicKey: CryptoPublicKey.EC, - config: PlatformVerifierConfiguration -) + config: PlatformVerifierConfiguration) internal expect fun verifyECDSAImpl (signatureAlgorithm: SignatureAlgorithm.ECDSA, publicKey: CryptoPublicKey.EC, data: SignatureInput, signature: CryptoSignature.EC, - config: PlatformVerifierConfiguration -) + config: PlatformVerifierConfiguration) + class PlatformECDSAVerifier internal constructor (signatureAlgorithm: SignatureAlgorithm.ECDSA, publicKey: CryptoPublicKey.EC, - configure: ConfigurePlatformVerifier - ) + configure: ConfigurePlatformVerifier) : Verifier.EC(signatureAlgorithm, publicKey), PlatformVerifier { private val config = DSL.resolve(::PlatformVerifierConfiguration, configure) @@ -82,19 +81,17 @@ class PlatformECDSAVerifier @Throws(UnsupportedCryptoException::class) internal expect fun checkAlgorithmKeyCombinationSupportedByRSAPlatformVerifier (signatureAlgorithm: SignatureAlgorithm.RSA, publicKey: CryptoPublicKey.Rsa, - config: PlatformVerifierConfiguration -) + config: PlatformVerifierConfiguration) /** data is guaranteed to be in RAW_BYTES format. failure should throw. */ internal expect fun verifyRSAImpl (signatureAlgorithm: SignatureAlgorithm.RSA, publicKey: CryptoPublicKey.Rsa, data: SignatureInput, signature: CryptoSignature.RSAorHMAC, - config: PlatformVerifierConfiguration -) + config: PlatformVerifierConfiguration) + class PlatformRSAVerifier internal constructor (signatureAlgorithm: SignatureAlgorithm.RSA, publicKey: CryptoPublicKey.Rsa, - configure: ConfigurePlatformVerifier - ) + configure: ConfigurePlatformVerifier) : Verifier.RSA(signatureAlgorithm, publicKey), PlatformVerifier { private val config = DSL.resolve(::PlatformVerifierConfiguration, configure) @@ -169,25 +166,22 @@ fun SignatureAlgorithm.platformVerifierFor private fun SignatureAlgorithm.verifierForImpl (publicKey: CryptoPublicKey, configure: ConfigurePlatformVerifier, - allowKotlin: Boolean): KmmResult = + allowKotlin: Boolean): KmmResult = when (this) { is SignatureAlgorithm.ECDSA -> { - require(publicKey is CryptoPublicKey.EC) - { "Non-EC public key passed to ECDSA algorithm"} - verifierForImpl(publicKey, configure, allowKotlin) + if(publicKey !is CryptoPublicKey.EC) + KmmResult.failure(IllegalArgumentException("Non-EC public key passed to ECDSA algorithm")) + else + verifierForImpl(publicKey, configure, allowKotlin) } is SignatureAlgorithm.RSA -> { - require(publicKey is CryptoPublicKey.Rsa) - { "Non-RSA public key passed to RSA algorithm"} - verifierForImpl(publicKey, configure, allowKotlin) + if (publicKey !is CryptoPublicKey.Rsa) + KmmResult.failure(IllegalArgumentException("Non-RSA public key passed to RSA algorithm")) + else + verifierForImpl(publicKey, configure, allowKotlin) } - is SignatureAlgorithm.HMAC -> throw UnsupportedCryptoException("HMAC is unsupported") - } - -private fun KmmResult.recoverCatching(fn: (Throwable)->R): KmmResult = - when (val x = exceptionOrNull()) { - null -> this - else -> catching { fn(x) } + is SignatureAlgorithm.HMAC -> + KmmResult.failure(IllegalArgumentException("HMAC is unsupported")) } /** @@ -219,7 +213,7 @@ fun SignatureAlgorithm.ECDSA.platformVerifierFor private fun SignatureAlgorithm.ECDSA.verifierForImpl (publicKey: CryptoPublicKey.EC, configure: ConfigurePlatformVerifier, - allowKotlin: Boolean): KmmResult = + allowKotlin: Boolean): KmmResult = catching { PlatformECDSAVerifier(this, publicKey, configure) } .recoverCatching { if (allowKotlin && (it is UnsupportedCryptoException)) @@ -256,7 +250,7 @@ fun SignatureAlgorithm.RSA.platformVerifierFor private fun SignatureAlgorithm.RSA.verifierForImpl (publicKey: CryptoPublicKey.Rsa, configure: ConfigurePlatformVerifier, - allowKotlin: Boolean): KmmResult = + allowKotlin: Boolean): KmmResult = catching { PlatformRSAVerifier(this, publicKey, configure) } /** @see [SignatureAlgorithm.verifierFor] */ diff --git a/supreme/src/commonTest/kotlin/at/asitplus/signum/supreme/TestUtils.kt b/supreme/src/commonTest/kotlin/at/asitplus/signum/supreme/TestUtils.kt index 66074a47..8aba0429 100644 --- a/supreme/src/commonTest/kotlin/at/asitplus/signum/supreme/TestUtils.kt +++ b/supreme/src/commonTest/kotlin/at/asitplus/signum/supreme/TestUtils.kt @@ -3,8 +3,6 @@ package at.asitplus.signum.supreme import at.asitplus.KmmResult import io.kotest.matchers.Matcher import io.kotest.matchers.MatcherResult -import kotlinx.coroutines.Runnable -import kotlin.reflect.KClass internal object succeed: Matcher> { override fun test(value: KmmResult<*>) = diff --git a/supreme/src/commonTest/kotlin/at/asitplus/signum/supreme/dsl/DSLInheritanceDemonstration.kt b/supreme/src/commonTest/kotlin/at/asitplus/signum/supreme/dsl/DSLInheritanceDemonstration.kt index af9e800c..6883ca25 100644 --- a/supreme/src/commonTest/kotlin/at/asitplus/signum/supreme/dsl/DSLInheritanceDemonstration.kt +++ b/supreme/src/commonTest/kotlin/at/asitplus/signum/supreme/dsl/DSLInheritanceDemonstration.kt @@ -1,6 +1,5 @@ package at.asitplus.signum.supreme.dsl -import at.asitplus.signum.supreme.dsl.DSL import io.kotest.assertions.fail import io.kotest.core.spec.style.FreeSpec import io.kotest.matchers.shouldBe @@ -13,7 +12,7 @@ private open class GenericOptions internal constructor(): DSL.Data() { var genericSubValue: Int = 42 } /* expose GenericSubOptions as a nested DSL child */ - open val subValue = child(GenericOptions::GenericSubOptions) + open val subValue = childOrDefault(::GenericSubOptions) } /* This is a more specific version of GenericOptions */ @@ -27,7 +26,7 @@ private class SpecificOptions internal constructor(): GenericOptions() { var anotherSpecificSubValue: String? = null } /* this shadows the subValue member on the superclass with a more specific version */ - override val subValue = child(SpecificOptions::SpecificSubOptions) + override val subValue = childOrDefault(::SpecificSubOptions) } open class DSLInheritanceDemonstration : FreeSpec({ diff --git a/supreme/src/commonTest/kotlin/at/asitplus/signum/supreme/dsl/DSLVarianceDemonstration.kt b/supreme/src/commonTest/kotlin/at/asitplus/signum/supreme/dsl/DSLVarianceDemonstration.kt index c310cb42..22141a15 100644 --- a/supreme/src/commonTest/kotlin/at/asitplus/signum/supreme/dsl/DSLVarianceDemonstration.kt +++ b/supreme/src/commonTest/kotlin/at/asitplus/signum/supreme/dsl/DSLVarianceDemonstration.kt @@ -1,6 +1,5 @@ package at.asitplus.signum.supreme.dsl -import at.asitplus.signum.supreme.dsl.DSL import io.kotest.assertions.throwables.shouldThrow import io.kotest.core.spec.style.FreeSpec import io.kotest.matchers.shouldBe @@ -21,8 +20,8 @@ private class Settings: DSL.Data() { /* this is null by default; a default could be explicitly specified, making this non-nullable */ internal val _flavor = subclassOf() /* and then we define user-visible accessors for the different flavors */ - val banana = _flavor.option(Settings::BananaFlavor) - val strawberry = _flavor.option(Settings::StrawberryFlavor) + val banana = _flavor.option(::BananaFlavor) + val strawberry = _flavor.option(::StrawberryFlavor) override fun validate() { require(_flavor.v != null) diff --git a/supreme/src/iosMain/kotlin/at/asitplus/signum/supreme/InteropUtils.kt b/supreme/src/iosMain/kotlin/at/asitplus/signum/supreme/InteropUtils.kt index ec157da6..2d5db41f 100644 --- a/supreme/src/iosMain/kotlin/at/asitplus/signum/supreme/InteropUtils.kt +++ b/supreme/src/iosMain/kotlin/at/asitplus/signum/supreme/InteropUtils.kt @@ -3,24 +3,84 @@ package at.asitplus.signum.supreme import kotlinx.cinterop.* +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import platform.CoreFoundation.CFDictionaryCreateMutable +import platform.CoreFoundation.CFDictionaryGetValue +import platform.CoreFoundation.CFDictionaryRef +import platform.CoreFoundation.CFDictionarySetValue +import platform.CoreFoundation.CFErrorRefVar +import platform.CoreFoundation.CFMutableDictionaryRef +import platform.CoreFoundation.CFTypeRef +import platform.CoreFoundation.kCFBooleanFalse +import platform.CoreFoundation.kCFBooleanTrue +import platform.CoreFoundation.kCFTypeDictionaryKeyCallBacks +import platform.CoreFoundation.kCFTypeDictionaryValueCallBacks import platform.Foundation.CFBridgingRelease +import platform.Foundation.CFBridgingRetain import platform.Foundation.NSData import platform.Foundation.NSError import platform.Foundation.create +import platform.Security.SecCopyErrorMessageString +import platform.darwin.OSStatus import platform.posix.memcpy -@OptIn(ExperimentalForeignApi::class) internal fun NSData.toByteArray(): ByteArray = ByteArray(length.toInt()).apply { usePinned { memcpy(it.addressOf(0), bytes, length) } } -@OptIn(ExperimentalForeignApi::class, BetaInteropApi::class) +@OptIn(BetaInteropApi::class) internal fun ByteArray.toNSData(): NSData = memScoped { NSData.create(bytes = allocArrayOf(this@toNSData), length = this@toNSData.size.toULong()) } +private fun NSError.toNiceString(): String { + val sb = StringBuilder("[Code $code] $localizedDescription\n") + localizedFailureReason?.let { sb.append("Because: $it") } + localizedRecoverySuggestion?.let { sb.append("Try: $it") } + localizedRecoveryOptions?.let { sb.append("Try also:\n - ${it.joinToString("\n - ")}\n") } + return sb.toString() +} + +class CFCryptoOperationFailed(thing: String, osStatus: OSStatus) : CryptoOperationFailed(buildMessage(thing, osStatus)) { + companion object { + private fun buildMessage(thing: String, osStatus: OSStatus): String { + val errorMessage = SecCopyErrorMessageString(osStatus, null).takeFromCF() + return "Failed to $thing: [code $osStatus] ${errorMessage ?: "unspecified security error"}" + } + } +} + +class CoreFoundationException(message: String): Throwable(message) +internal class corecall private constructor(val error: CPointer) { + /** Helper for calling Core Foundation functions, and bridging exceptions across. + * + * Usage: + * ``` + * corecall { SomeCoreFoundationFunction(arg1, arg2, ..., error) } + * ``` + * `error` is provided by the implicit receiver object, and will be mapped to a + * `CoreFoundationException` if an error occurs. + */ + companion object { + @OptIn(BetaInteropApi::class) + operator fun invoke(call: corecall.()->T?) : T { + memScoped { + val errorH = alloc() + val result = corecall(errorH.ptr).call() + val error = errorH.value + when { + (result != null) && (error == null) -> return result + (result == null) && (error != null) -> + throw CoreFoundationException(error.takeFromCF().toNiceString()) + else -> throw IllegalStateException("Invalid state returned by Core Foundation call") + } + } + } + } +} class SwiftException(message: String): Throwable(message) internal class swiftcall private constructor(val error: CPointer>) { /** Helper for calling swift-objc-mapped functions, and bridging exceptions across. @@ -41,10 +101,73 @@ internal class swiftcall private constructor(val error: CPointer return result - (result == null) && (error != null) -> throw SwiftException(error.localizedDescription) + (result == null) && (error != null) -> throw SwiftException(error.toNiceString()) else -> throw IllegalStateException("Invalid state returned by Swift") } } } } } + +internal class swiftasync private constructor(val callback: (T?, NSError?)->Unit) { + /** Helper for calling swift-objc-mapped async functions, and bridging exceptions across. + * + * Usage: + * ``` + * swiftasync { SwiftObj.func(arg1, arg2, .., argN, callback) } + * ``` + * `error` is provided by the implicit receiver object, and will be mapped to a + * `SwiftException` if the swift call throws. + */ + companion object { + suspend operator fun invoke(call: swiftasync.()->Unit): T { + var result: T? = null + var error: NSError? = null + val mut = Mutex(true) + swiftasync { res, err -> result = res; error = err; mut.unlock() }.call() + mut.withLock { + val res = result + val err = error + when { + (res != null) && (err == null) -> return res + (res == null) && (err != null) -> throw SwiftException(err.toNiceString()) + else -> throw IllegalStateException("Invalid state returned by Swift") + } + } + } + } +} + +internal inline fun Any?.giveToCF() = when(this) { + null -> this + is Boolean -> if (this) kCFBooleanTrue else kCFBooleanFalse + is CValuesRef<*> -> this + else -> CFBridgingRetain(this) +} as T +internal inline fun CFTypeRef?.takeFromCF() = CFBridgingRelease(this) as T +internal fun MemScope.cfDictionaryOf(vararg pairs: Pair<*,*>): CFDictionaryRef { + val dict = CFDictionaryCreateMutable(null, pairs.size.toLong(), + kCFTypeDictionaryKeyCallBacks.ptr, kCFTypeDictionaryValueCallBacks.ptr)!! + defer { CFBridgingRelease(dict) } // free it after the memscope finishes + pairs.forEach { (k,v) -> dict[k] = v } + return dict +} + +internal class CFDictionaryInitScope private constructor() { + private val pairs = mutableListOf>() + + fun map(pair: Pair<*,*>) { pairs.add(pair) } + infix fun Any?.mapsTo(other: Any?) { map(this to other) } + + internal companion object { + fun resolve(scope: MemScope, fn: CFDictionaryInitScope.()->Unit) = + scope.cfDictionaryOf(*CFDictionaryInitScope().apply(fn).pairs.toTypedArray()) + } +} +internal fun MemScope.createCFDictionary(pairs: CFDictionaryInitScope.()->Unit) = + CFDictionaryInitScope.resolve(this, pairs) +internal inline operator fun CFDictionaryRef.get(key: Any?): T = + CFDictionaryGetValue(this, key.giveToCF()).takeFromCF() + +internal inline operator fun CFMutableDictionaryRef.set(key: Any?, value: Any?) = + CFDictionarySetValue(this, key.giveToCF(), value.giveToCF()) diff --git a/supreme/src/iosMain/kotlin/at/asitplus/signum/supreme/os/IosKeychainProvider.kt b/supreme/src/iosMain/kotlin/at/asitplus/signum/supreme/os/IosKeychainProvider.kt new file mode 100644 index 00000000..66b0f1f3 --- /dev/null +++ b/supreme/src/iosMain/kotlin/at/asitplus/signum/supreme/os/IosKeychainProvider.kt @@ -0,0 +1,515 @@ +@file:OptIn(ExperimentalForeignApi::class) +package at.asitplus.signum.supreme.os + +import at.asitplus.KmmResult +import at.asitplus.catching +import at.asitplus.signum.indispensable.CryptoPublicKey +import at.asitplus.signum.indispensable.CryptoSignature +import at.asitplus.signum.indispensable.Digest +import at.asitplus.signum.indispensable.ECCurve +import at.asitplus.signum.indispensable.RSAPadding +import at.asitplus.signum.indispensable.SignatureAlgorithm +import at.asitplus.signum.indispensable.nativeDigest +import at.asitplus.signum.supreme.CFCryptoOperationFailed +import at.asitplus.signum.supreme.CryptoOperationFailed +import at.asitplus.signum.supreme.UnsupportedCryptoException +import at.asitplus.signum.supreme.createCFDictionary +import at.asitplus.signum.supreme.cfDictionaryOf +import at.asitplus.signum.supreme.corecall +import at.asitplus.signum.supreme.dsl.DISCOURAGED +import at.asitplus.signum.supreme.dsl.DSL +import at.asitplus.signum.supreme.dsl.DSLConfigureFn +import at.asitplus.signum.supreme.dsl.PREFERRED +import at.asitplus.signum.supreme.dsl.REQUIRED +import at.asitplus.signum.supreme.get +import at.asitplus.signum.supreme.giveToCF +import at.asitplus.signum.supreme.hash.digest +import at.asitplus.signum.supreme.sign.SignatureInput +import at.asitplus.signum.supreme.sign.Signer +import at.asitplus.signum.supreme.swiftasync +import at.asitplus.signum.supreme.takeFromCF +import at.asitplus.signum.supreme.toByteArray +import at.asitplus.signum.supreme.toNSData +import at.asitplus.signum.supreme.os.* +import io.github.aakira.napier.Napier +import kotlinx.cinterop.Arena +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.MemScope +import kotlinx.cinterop.alloc +import kotlinx.cinterop.memScoped +import kotlinx.cinterop.ptr +import kotlinx.cinterop.reinterpret +import kotlinx.cinterop.value +import kotlinx.coroutines.newFixedThreadPoolContext +import kotlinx.coroutines.withContext +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import platform.CoreFoundation.CFDictionaryRefVar +import platform.DeviceCheck.DCAppAttestService +import platform.Foundation.CFBridgingRelease +import platform.Foundation.NSBundle +import platform.Foundation.NSData +import platform.LocalAuthentication.LAContext +import platform.Security.SecAccessControlCreateWithFlags +import platform.Security.SecItemCopyMatching +import platform.Security.SecItemDelete +import platform.Security.SecItemUpdate +import platform.Security.SecKeyCopyExternalRepresentation +import platform.Security.SecKeyCreateSignature +import platform.Security.SecKeyGeneratePair +import platform.Security.SecKeyIsAlgorithmSupported +import platform.Security.SecKeyRef +import platform.Security.SecKeyRefVar +import platform.Security.errSecItemNotFound +import platform.Security.errSecSuccess +import platform.Security.kSecAccessControlBiometryAny +import platform.Security.kSecAccessControlDevicePasscode +import platform.Security.kSecAccessControlPrivateKeyUsage +import platform.Security.kSecAccessControlUserPresence +import platform.Security.kSecAttrAccessControl +import platform.Security.kSecAttrAccessible +import platform.Security.kSecAttrAccessibleAfterFirstUnlock +import platform.Security.kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly +import platform.Security.kSecAttrAccessibleAlways +import platform.Security.kSecAttrAccessibleAlwaysThisDeviceOnly +import platform.Security.kSecAttrAccessibleWhenUnlocked +import platform.Security.kSecAttrAccessibleWhenUnlockedThisDeviceOnly +import platform.Security.kSecAttrApplicationLabel +import platform.Security.kSecAttrApplicationTag +import platform.Security.kSecAttrIsPermanent +import platform.Security.kSecAttrKeyClass +import platform.Security.kSecAttrKeyClassPrivate +import platform.Security.kSecAttrKeyClassPublic +import platform.Security.kSecAttrKeySizeInBits +import platform.Security.kSecAttrKeyType +import platform.Security.kSecAttrKeyTypeEC +import platform.Security.kSecAttrKeyTypeRSA +import platform.Security.kSecAttrLabel +import platform.Security.kSecAttrTokenID +import platform.Security.kSecAttrTokenIDSecureEnclave +import platform.Security.kSecClass +import platform.Security.kSecClassKey +import platform.Security.kSecKeyOperationTypeSign +import platform.Security.kSecMatchLimit +import platform.Security.kSecMatchLimitOne +import platform.Security.kSecPrivateKeyAttrs +import platform.Security.kSecPublicKeyAttrs +import platform.Security.kSecReturnAttributes +import platform.Security.kSecReturnRef +import platform.Security.kSecUseAuthenticationContext +import platform.Security.kSecUseAuthenticationUI +import platform.Security.kSecUseAuthenticationUIAllow +import at.asitplus.signum.indispensable.secKeyAlgorithm + + +val keychainThreads = newFixedThreadPoolContext(nThreads = 4, name = "iOS Keychain Operations") + +private fun isSecureEnclaveSupportedCurve(c: SigningKeyConfiguration.AlgorithmSpecific): Boolean { + if (c !is SigningKeyConfiguration.ECConfiguration) return false + return when (c.curve) { + ECCurve.SECP_256_R_1 -> true + else -> false + } +} + +private object KeychainTags { + private val tags by lazy { + val bundleId = NSBundle.mainBundle.bundleIdentifier + ?: throw UnsupportedCryptoException("Keychain access is unsupported outside of a Bundle") + Pair("kmp-crypto-privatekey-$bundleId", "kmp-crypto.publickey-$bundleId") + } + val PRIVATE_KEYS get() = tags.first + val PUBLIC_KEYS get() = tags.second +} + +class iosSecureEnclaveConfiguration internal constructor() : PlatformSigningKeyConfiguration.SecureHardwareConfiguration() { + /** Set to true to allow this key to be backed up. */ + var allowBackup = false + enum class Availability { ALWAYS, AFTER_FIRST_UNLOCK, WHILE_UNLOCKED } + /** Specify when this key should be available */ + var availability = Availability.ALWAYS +} +class iosSigningKeyConfiguration internal constructor(): PlatformSigningKeyConfiguration() { + override val hardware = childOrDefault(::iosSecureEnclaveConfiguration) { + backing = DISCOURAGED + } +} + +class iosSignerConfiguration internal constructor(): SignerConfiguration() { +} + +sealed class unlockedIosSigner(private val ownedArena: Arena, private val privateKeyRef: SecKeyRef) : Signer.UnlockedHandle { + abstract val parent: iosSigner<*> + val alias get() = parent.alias + + var usable = true + final override fun close() { + if (!usable) return + usable = false + ownedArena.clear() + } + + internal fun checkSupport() { + if (!SecKeyIsAlgorithmSupported(privateKeyRef, kSecKeyOperationTypeSign, signatureAlgorithm.secKeyAlgorithm)) { + close() + throw UnsupportedCryptoException("Requested operation is not supported by this key") + } + } + + protected abstract fun bytesToSignature(sigBytes: ByteArray): CryptoSignature + override fun sign(data: SignatureInput): KmmResult = catching { + if (!usable) throw IllegalStateException("Scoping violation; using key after it has been freed") + require(data.format == null) { "Pre-hashed data is unsupported on iOS" } + val algorithm = signatureAlgorithm.secKeyAlgorithm + val plaintext = data.data.fold(byteArrayOf(), ByteArray::plus).toNSData() + val signatureBytes = corecall { + SecKeyCreateSignature(privateKeyRef, algorithm, plaintext.giveToCF(), error) + }.let { it.takeFromCF().toByteArray() } + return@catching bytesToSignature(signatureBytes) + } + + class ECDSA(ownedArena: Arena, + privateKeyRef: SecKeyRef, + override val parent: iosSigner.ECDSA) + : unlockedIosSigner(ownedArena, privateKeyRef), Signer.ECDSA + { + override val signatureAlgorithm get() = parent.signatureAlgorithm + override val publicKey get() = parent.publicKey + override fun bytesToSignature(sigBytes: ByteArray) = + CryptoSignature.EC.decodeFromDer(sigBytes).withCurve(publicKey.curve) + } + + class RSA(ownedArena: Arena, + privateKeyRef: SecKeyRef, + override val parent: iosSigner.RSA) + : unlockedIosSigner(ownedArena, privateKeyRef), Signer.RSA + { + override val signatureAlgorithm get() = parent.signatureAlgorithm + override val publicKey get() = parent.publicKey + override fun bytesToSignature(sigBytes: ByteArray) = + CryptoSignature.RSAorHMAC(sigBytes) + } + +} + +sealed class iosSigner( + val alias: String, + override val attestation: iosHomebrewAttestation?, + private val config: iosSignerConfiguration +) : Signer.TemporarilyUnlockable(), Signer.Attestable { + + override suspend fun unlock(): KmmResult = withContext(keychainThreads) { catching { + val arena = Arena() + val privateKey = arena.alloc() + try { + memScoped { + val query = cfDictionaryOf( + kSecClass to kSecClassKey, + kSecAttrKeyClass to kSecAttrKeyClassPrivate, + kSecAttrApplicationLabel to alias, + kSecAttrApplicationTag to KeychainTags.PRIVATE_KEYS, + kSecAttrKeyType to when (this@iosSigner) { + is ECDSA -> kSecAttrKeyTypeEC + is RSA -> kSecAttrKeyTypeRSA + }, + kSecMatchLimit to kSecMatchLimitOne, + kSecReturnRef to true, + + kSecUseAuthenticationContext to LAContext().apply { + setLocalizedReason(config.unlockPrompt.v.message) + setLocalizedCancelTitle(config.unlockPrompt.v.cancelText) + }, + kSecUseAuthenticationUI to kSecUseAuthenticationUIAllow + ) + val status = SecItemCopyMatching(query, privateKey.ptr.reinterpret()) + if ((status == errSecSuccess) && (privateKey.value != null)) { + return@memScoped /* continue below try/catch */ + } else { + throw CFCryptoOperationFailed(thing = "retrieve private key", osStatus = status) + } + } + } catch (e: Throwable) { + arena.clear() + throw e + } + /* if the block did not throw, the handle takes ownership of the arena */ + toUnlocked(arena, privateKey.value!!).also(unlockedIosSigner::checkSupport) + }} + + protected abstract fun toUnlocked(arena: Arena, key: SecKeyRef): H + class ECDSA(alias: String, attestation: iosHomebrewAttestation?, config: iosSignerConfiguration, + override val publicKey: CryptoPublicKey.EC) + : iosSigner(alias, attestation, config), Signer.ECDSA + { + override val signatureAlgorithm = when (val digest = if (config.ec.v.digestSpecified) config.ec.v.digest else publicKey.curve.nativeDigest){ + Digest.SHA256, Digest.SHA384, Digest.SHA512 -> SignatureAlgorithm.ECDSA(digest, publicKey.curve) + else -> throw UnsupportedCryptoException("ECDSA with $digest is not supported on iOS") + } + + override fun toUnlocked(arena: Arena, key: SecKeyRef) = + unlockedIosSigner.ECDSA(arena, key, this) + } + + class RSA(alias: String, attestation: iosHomebrewAttestation?, config: iosSignerConfiguration, + override val publicKey: CryptoPublicKey.Rsa) + : iosSigner(alias, attestation, config), Signer.RSA + { + override val signatureAlgorithm = SignatureAlgorithm.RSA( + digest = if (config.rsa.v.digestSpecified) config.rsa.v.digest else Digest.SHA512, + padding = if (config.rsa.v.paddingSpecified) config.rsa.v.padding else RSAPadding.PSS) + + override fun toUnlocked(arena: Arena, key: SecKeyRef) = + unlockedIosSigner.RSA(arena, key, this) + } +} + +@OptIn(ExperimentalForeignApi::class) +object IosKeychainProvider: SigningProviderI, iosSignerConfiguration, iosSigningKeyConfiguration> { + private fun MemScope.getPublicKey(alias: String): SecKeyRef? { + val it = alloc() + val query = cfDictionaryOf( + kSecClass to kSecClassKey, + kSecAttrKeyClass to kSecAttrKeyClassPublic, + kSecAttrApplicationLabel to alias, + kSecAttrApplicationTag to KeychainTags.PUBLIC_KEYS, + kSecReturnRef to true, + ) + val status = SecItemCopyMatching(query, it.ptr.reinterpret()) + return when (status) { + errSecSuccess -> it.value + errSecItemNotFound -> null + else -> { + throw CFCryptoOperationFailed(thing = "retrieve public key", osStatus = status) + } + } + } + private fun storeKeyAttestation(alias: String, attestation: iosHomebrewAttestation) = memScoped { + val status = SecItemUpdate( + cfDictionaryOf( + kSecClass to kSecClassKey, + kSecAttrKeyClass to kSecAttrKeyClassPublic, + kSecAttrApplicationLabel to alias, + kSecAttrApplicationTag to KeychainTags.PUBLIC_KEYS), + cfDictionaryOf( + kSecAttrLabel to Json.encodeToString(attestation) + )) + if (status != errSecSuccess) { + throw CFCryptoOperationFailed(thing = "store key attestation", osStatus = status) + } + } + private fun getKeyAttestation(alias: String): iosHomebrewAttestation? = memScoped { + val it = alloc() + val query = cfDictionaryOf( + kSecClass to kSecClassKey, + kSecAttrKeyClass to kSecAttrKeyClassPublic, + kSecAttrApplicationLabel to alias, + kSecAttrApplicationTag to KeychainTags.PUBLIC_KEYS, + kSecReturnAttributes to true + ) + val status = SecItemCopyMatching(query, it.ptr.reinterpret()) + return when (status) { + errSecSuccess -> it.value!!.let { attrs -> + attrs.get(kSecAttrLabel)?.let(Json::decodeFromString) + } + errSecItemNotFound -> null + else -> { + throw CFCryptoOperationFailed(thing = "retrieve attestation info", osStatus = status) + } + } + } + + override suspend fun createSigningKey( + alias: String, + configure: DSLConfigureFn + ): KmmResult> = catching { + memScoped { + if (getPublicKey(alias) != null) + throw NoSuchElementException("Key with alias $alias already exists") + } + deleteSigningKey(alias) /* make sure there are no leftover private keys */ + + val config = DSL.resolve(::iosSigningKeyConfiguration, configure) + + val availability = config.hardware.v.let { c-> when (c.availability) { + iosSecureEnclaveConfiguration.Availability.ALWAYS -> if (c.allowBackup) kSecAttrAccessibleAlways else kSecAttrAccessibleAlwaysThisDeviceOnly + iosSecureEnclaveConfiguration.Availability.AFTER_FIRST_UNLOCK -> if (c.allowBackup) kSecAttrAccessibleAfterFirstUnlock else kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly + iosSecureEnclaveConfiguration.Availability.WHILE_UNLOCKED -> if (c.allowBackup) kSecAttrAccessibleWhenUnlocked else kSecAttrAccessibleWhenUnlockedThisDeviceOnly + } } + + val useSecureEnclave = when (config.hardware.v.backing) { + is REQUIRED -> true + is PREFERRED -> isSecureEnclaveSupportedCurve(config._algSpecific.v) + is DISCOURAGED -> false + } + + val publicKeyBytes: ByteArray = memScoped { + val attr = createCFDictionary { + when (val alg = config._algSpecific.v) { + is SigningKeyConfiguration.ECConfiguration -> { + kSecAttrKeyType mapsTo kSecAttrKeyTypeEC + kSecAttrKeySizeInBits mapsTo alg.curve.coordinateLength.bits.toInt() + } + is SigningKeyConfiguration.RSAConfiguration -> { + kSecAttrKeyType mapsTo kSecAttrKeyTypeRSA + kSecAttrKeySizeInBits mapsTo alg.bits + } + } + if (useSecureEnclave) { + kSecAttrTokenID mapsTo kSecAttrTokenIDSecureEnclave + } + kSecPrivateKeyAttrs mapsTo createCFDictionary { + kSecAttrApplicationLabel mapsTo alias + kSecAttrIsPermanent mapsTo true + kSecAttrApplicationTag mapsTo KeychainTags.PRIVATE_KEYS + when (val factors = config.hardware.v.protection.v?.factors?.v) { + null -> { + kSecAttrAccessible mapsTo availability + } + else -> { + kSecAttrAccessControl mapsTo corecall { + SecAccessControlCreateWithFlags( + null, availability, + when { + (factors.biometry && factors.deviceLock) -> kSecAccessControlUserPresence + factors.biometry -> kSecAccessControlBiometryAny + else -> kSecAccessControlDevicePasscode + }.let { + if (useSecureEnclave) it or kSecAccessControlPrivateKeyUsage else it + }, error) + }.also { defer { CFBridgingRelease(it) } } + } + } + } + kSecPublicKeyAttrs mapsTo cfDictionaryOf( + kSecAttrApplicationLabel to alias, + kSecAttrIsPermanent to true, + kSecAttrApplicationTag to KeychainTags.PUBLIC_KEYS + ) + } + + val pubkey = alloc() + val privkey = alloc() + + Napier.v { "Ready to generate iOS keypair for alias $alias (secure enclave? $useSecureEnclave)" } + + val status = SecKeyGeneratePair(attr, pubkey.ptr, privkey.ptr) + + Napier.v { "Successfully generated iOS keypair for alias $alias (secure enclave? $useSecureEnclave)" } + + if ((status == errSecSuccess) && (pubkey.value != null) && (privkey.value != null)) { + return@memScoped corecall { + SecKeyCopyExternalRepresentation(pubkey.value, error) + }.let { it.takeFromCF() }.toByteArray() + } else { + val x = CFCryptoOperationFailed(thing = "generate key", osStatus = status) + if ((status == -50) && + useSecureEnclave && + !isSecureEnclaveSupportedCurve(config._algSpecific.v)) { + throw UnsupportedCryptoException("iOS Secure Enclave does not support this configuration.", x) + } + throw x + } + } + + val publicKey = when (val alg = config._algSpecific.v) { + is SigningKeyConfiguration.ECConfiguration -> + CryptoPublicKey.EC.fromAnsiX963Bytes(alg.curve, publicKeyBytes) + is SigningKeyConfiguration.RSAConfiguration -> + CryptoPublicKey.Rsa.fromPKCS1encoded(publicKeyBytes) + } + + val attestation = if (useSecureEnclave) { + config.hardware.v.attestation.v?.let { attestationConfig -> + val service = DCAppAttestService.sharedService + if (!service.isSupported()) { + if (config.hardware.v.backing == REQUIRED) { + throw UnsupportedCryptoException("App Attestation is unavailable") + } + Napier.v { "attestation is unsupported by the device" } + return@let null + } + Napier.v { "going to create attestation for key $alias" } + val keyId = swiftasync { + service.generateKeyWithCompletionHandler(callback) + } + Napier.v { "created attestation key (keyId = $keyId)" } + val assertionKeyAttestation = swiftasync { + service.attestKey(keyId, Digest.SHA256.digest(attestationConfig.challenge).toNSData(), callback) + }.toByteArray() + Napier.v { "attested key ($assertionKeyAttestation)" } + val keyAssertion = swiftasync { + service.generateAssertion(keyId, Digest.SHA256.digest(publicKeyBytes).toNSData(), callback) + }.toByteArray() + Napier.v { "asserted underlying public key for $alias ($keyAssertion)" } + + val attestation = iosHomebrewAttestation(attestation = assertionKeyAttestation, assertion = keyAssertion) + storeKeyAttestation(alias, attestation) + return@let attestation + } + } else null + + Napier.v { "key $alias has attestation? ${attestation != null}" } + + val signerConfiguration = DSL.resolve(::iosSignerConfiguration, config.signer.v) + return@catching when (publicKey) { + is CryptoPublicKey.EC -> + iosSigner.ECDSA(alias, attestation, signerConfiguration, publicKey) + is CryptoPublicKey.Rsa -> + iosSigner.RSA(alias, attestation, signerConfiguration, publicKey) + } + }.also { + val e = it.exceptionOrNull() + if (e != null && e !is NoSuchElementException) + runCatching { deleteSigningKey(alias) } + } + + override suspend fun getSignerForKey( + alias: String, + configure: DSLConfigureFn + ): KmmResult> = catching { + val config = DSL.resolve(::iosSignerConfiguration, configure) + return@catching withContext(keychainThreads) { + val publicKeyBytes: ByteArray = memScoped { + val publicKey = getPublicKey(alias) + ?: throw NoSuchElementException("No key for alias $alias exists") + return@memScoped corecall { + SecKeyCopyExternalRepresentation(publicKey, error) + }.let { it.takeFromCF() }.toByteArray() + } + val attestation = getKeyAttestation(alias) + return@withContext when (val publicKey = + CryptoPublicKey.fromIosEncoded(publicKeyBytes)) { + is CryptoPublicKey.EC -> iosSigner.ECDSA(alias, attestation, config, publicKey) + is CryptoPublicKey.Rsa -> iosSigner.RSA(alias, attestation, config, publicKey) + } + } + } + + override suspend fun deleteSigningKey(alias: String) = withContext(keychainThreads) { + memScoped { + mapOf( + "public key" to cfDictionaryOf( + kSecClass to kSecClassKey, + kSecAttrKeyClass to kSecAttrKeyClassPublic, + kSecAttrApplicationLabel to alias, + kSecAttrApplicationTag to KeychainTags.PUBLIC_KEYS + ), "private key" to cfDictionaryOf( + kSecClass to kSecClassKey, + kSecAttrKeyClass to kSecAttrKeyClassPrivate, + kSecAttrApplicationLabel to alias, + kSecAttrApplicationTag to KeychainTags.PRIVATE_KEYS + ) + ).map { (kind, options) -> + val status = SecItemDelete(options) + if ((status != errSecSuccess) && (status != errSecItemNotFound)) + CFCryptoOperationFailed(thing = "delete $kind", osStatus = status) + else + null + }.mapNotNull { it?.message }.let { + if (it.isNotEmpty()) + throw CryptoOperationFailed(it.joinToString(",")) + } + } + } + +} diff --git a/supreme/src/iosMain/kotlin/at/asitplus/signum/supreme/sign/VerifierImpl.kt b/supreme/src/iosMain/kotlin/at/asitplus/signum/supreme/sign/VerifierImpl.kt index fabed4aa..fc9e9fe1 100644 --- a/supreme/src/iosMain/kotlin/at/asitplus/signum/supreme/sign/VerifierImpl.kt +++ b/supreme/src/iosMain/kotlin/at/asitplus/signum/supreme/sign/VerifierImpl.kt @@ -24,8 +24,7 @@ actual class PlatformVerifierConfiguration internal actual constructor() : DSL.D @Throws(UnsupportedCryptoException::class) internal actual fun checkAlgorithmKeyCombinationSupportedByECDSAPlatformVerifier (signatureAlgorithm: SignatureAlgorithm.ECDSA, publicKey: CryptoPublicKey.EC, - config: PlatformVerifierConfiguration -) + config: PlatformVerifierConfiguration) { when (publicKey.curve) { ECCurve.SECP_256_R_1, ECCurve.SECP_384_R_1, ECCurve.SECP_521_R_1 -> {} @@ -40,18 +39,16 @@ internal actual fun checkAlgorithmKeyCombinationSupportedByECDSAPlatformVerifier @Throws(UnsupportedCryptoException::class) internal actual fun checkAlgorithmKeyCombinationSupportedByRSAPlatformVerifier (signatureAlgorithm: SignatureAlgorithm.RSA, publicKey: CryptoPublicKey.Rsa, - config: PlatformVerifierConfiguration -) + config: PlatformVerifierConfiguration) { } @OptIn(ExperimentalForeignApi::class) internal actual fun verifyECDSAImpl - (signatureAlgorithm: SignatureAlgorithm.ECDSA, publicKey: CryptoPublicKey.EC, - data: SignatureInput, signature: CryptoSignature.EC, - config: PlatformVerifierConfiguration -) { + (signatureAlgorithm: SignatureAlgorithm.ECDSA, publicKey: CryptoPublicKey.EC, + data: SignatureInput, signature: CryptoSignature.EC, + config: PlatformVerifierConfiguration) { val digest = signatureAlgorithm.digest val curve = publicKey.curve @@ -85,8 +82,7 @@ internal actual fun verifyECDSAImpl internal actual fun verifyRSAImpl (signatureAlgorithm: SignatureAlgorithm.RSA, publicKey: CryptoPublicKey.Rsa, data: SignatureInput, signature: CryptoSignature.RSAorHMAC, - config: PlatformVerifierConfiguration -) { + config: PlatformVerifierConfiguration) { val padding = signatureAlgorithm.padding val digest = signatureAlgorithm.digest diff --git a/supreme/src/iosTest/kotlin/Test.kt b/supreme/src/iosTest/kotlin/Test.kt new file mode 100644 index 00000000..5ac63132 --- /dev/null +++ b/supreme/src/iosTest/kotlin/Test.kt @@ -0,0 +1,9 @@ +import io.kotest.core.spec.style.FreeSpec +import io.kotest.matchers.shouldNotBe + +class ProviderTest : FreeSpec({ + + "This dummy test" { + "is just making sure" shouldNotBe "that iOS tests are indeed running" + } +}) \ No newline at end of file diff --git a/supreme/src/iosTest/kotlin/at/asitplus/crypto/provider/os/iosKeychainProviderTests.kt b/supreme/src/iosTest/kotlin/at/asitplus/crypto/provider/os/iosKeychainProviderTests.kt new file mode 100644 index 00000000..da6e8db5 --- /dev/null +++ b/supreme/src/iosTest/kotlin/at/asitplus/crypto/provider/os/iosKeychainProviderTests.kt @@ -0,0 +1,11 @@ +package at.asitplus.crypto.provider.os + +import at.asitplus.crypto.datatypes.CryptoPublicKey +import io.kotest.core.spec.style.FreeSpec +import io.kotest.matchers.types.shouldBeInstanceOf + +class iosKeychainProviderTests : FreeSpec({ + "it works" { + IosKeychainProvider.createSigningKey("Bartschloss").getOrThrow().shouldBeInstanceOf() + } +}) diff --git a/supreme/src/jvmMain/kotlin/at/asitplus/signum/supreme/sign/VerifierImpl.kt b/supreme/src/jvmMain/kotlin/at/asitplus/signum/supreme/sign/VerifierImpl.kt index f03bceef..bd126671 100644 --- a/supreme/src/jvmMain/kotlin/at/asitplus/signum/supreme/sign/VerifierImpl.kt +++ b/supreme/src/jvmMain/kotlin/at/asitplus/signum/supreme/sign/VerifierImpl.kt @@ -34,8 +34,7 @@ private fun getSigInstance(alg: String, p: String?) = @Throws(UnsupportedCryptoException::class) internal actual fun checkAlgorithmKeyCombinationSupportedByECDSAPlatformVerifier (signatureAlgorithm: SignatureAlgorithm.ECDSA, publicKey: CryptoPublicKey.EC, - config: PlatformVerifierConfiguration -) + config: PlatformVerifierConfiguration) { wrapping(asA=::UnsupportedCryptoException) { getSigInstance("${signatureAlgorithm.digest.jcaAlgorithmComponent}withECDSA", config.provider) @@ -47,8 +46,7 @@ internal actual fun checkAlgorithmKeyCombinationSupportedByECDSAPlatformVerifier internal actual fun verifyECDSAImpl (signatureAlgorithm: SignatureAlgorithm.ECDSA, publicKey: CryptoPublicKey.EC, data: SignatureInput, signature: CryptoSignature.EC, - config: PlatformVerifierConfiguration -) + config: PlatformVerifierConfiguration) { val (input, alg) = when { (data.format == null) -> /* input data is not hashed, let JCA do hashing */ @@ -77,8 +75,7 @@ private fun getRSAInstance(alg: SignatureAlgorithm.RSA, config: PlatformVerifier @Throws(UnsupportedCryptoException::class) internal actual fun checkAlgorithmKeyCombinationSupportedByRSAPlatformVerifier (signatureAlgorithm: SignatureAlgorithm.RSA, publicKey: CryptoPublicKey.Rsa, - config: PlatformVerifierConfiguration -) { + config: PlatformVerifierConfiguration) { wrapping(asA=::UnsupportedCryptoException) { getRSAInstance(signatureAlgorithm, config) .initVerify(publicKey.getJcaPublicKey().getOrThrow()) @@ -89,8 +86,7 @@ internal actual fun checkAlgorithmKeyCombinationSupportedByRSAPlatformVerifier internal actual fun verifyRSAImpl (signatureAlgorithm: SignatureAlgorithm.RSA, publicKey: CryptoPublicKey.Rsa, data: SignatureInput, signature: CryptoSignature.RSAorHMAC, - config: PlatformVerifierConfiguration -) + config: PlatformVerifierConfiguration) { getRSAInstance(signatureAlgorithm, config).run { initVerify(publicKey.getJcaPublicKey().getOrThrow()) diff --git a/supreme/src/swift/Krypto.swift b/supreme/src/swift/Krypto.swift index 5ebb3298..42e4ffdc 100644 --- a/supreme/src/swift/Krypto.swift +++ b/supreme/src/swift/Krypto.swift @@ -8,6 +8,8 @@ import Foundation @objc public class Krypto: NSObject { + // =========== VERIFICATION + fileprivate static func verifyECDSA_P256(_ pubkeyDER: Data, _ sigDER: Data, _ data: any Digest) throws -> Bool { let pubKey = try P256.Signing.PublicKey(derRepresentation: pubkeyDER) let sig = try P256.Signing.ECDSASignature(derRepresentation: sigDER) @@ -65,7 +67,7 @@ import Foundation } } - @objc public class func verifyRSA(_ padding: String, _ digest: String, _ pubkeyPKCS1: Data, + @objc public static func verifyRSA(_ padding: String, _ digest: String, _ pubkeyPKCS1: Data, _ sigDER: Data, _ data: Data) throws -> String { let algorithm = try getRSAAlgorithm(padding, digest) @@ -91,4 +93,4 @@ struct RuntimeError: LocalizedError { var errorDescription: String? { description } -} +} \ No newline at end of file From 40b96b57a04fdda627cf8218ad2a53b12b712491 Mon Sep 17 00:00:00 2001 From: Jakob Heher Date: Fri, 23 Aug 2024 15:44:39 +0200 Subject: [PATCH 02/73] android fixes + signer cleanup --- .../kotlin/at/asitplus/cryptotest/App.kt | 6 +- .../provider/os/AndroidKeyStoreProvider.kt | 23 +++--- .../at/asitplus/signum/supreme/sign/Signer.kt | 82 +++++++------------ 3 files changed, 46 insertions(+), 65 deletions(-) diff --git a/demoapp/composeApp/src/commonMain/kotlin/at/asitplus/cryptotest/App.kt b/demoapp/composeApp/src/commonMain/kotlin/at/asitplus/cryptotest/App.kt index c63f0414..bf94a74f 100644 --- a/demoapp/composeApp/src/commonMain/kotlin/at/asitplus/cryptotest/App.kt +++ b/demoapp/composeApp/src/commonMain/kotlin/at/asitplus/cryptotest/App.kt @@ -126,9 +126,7 @@ val SIGNER_CONFIG: (SignerConfiguration.()->Unit) = { message = "We're signing a thing!" cancelText = "No! Stop!" } - rsa { - padding = RSAPadding.PKCS1 - } + rsa {} } val context = newSingleThreadContext("crypto").also { Napier.base(DebugAntilog()) } @@ -352,7 +350,7 @@ internal fun App() { bits = 1024 } } - else -> TODO("unreachable") + else -> error("unreachable") } val timeout = runCatching { diff --git a/supreme/src/androidMain/kotlin/at/asitplus/crypto/provider/os/AndroidKeyStoreProvider.kt b/supreme/src/androidMain/kotlin/at/asitplus/crypto/provider/os/AndroidKeyStoreProvider.kt index 14cfdc8e..ff50dd37 100644 --- a/supreme/src/androidMain/kotlin/at/asitplus/crypto/provider/os/AndroidKeyStoreProvider.kt +++ b/supreme/src/androidMain/kotlin/at/asitplus/crypto/provider/os/AndroidKeyStoreProvider.kt @@ -89,12 +89,12 @@ inline fun resolveOption(what: String, valid: Array, possibl true -> { val vStr = nameMap(v) if (!valid.any { it.equals(vStr, ignoreCase=true) }) - throw IllegalArgumentException("Key does not suppport $what $v; supported: $valid") + throw IllegalArgumentException("Key does not support $what $v; supported: ${valid.joinToString(", ")}") v } false -> { if (valid.size != 1) - throw IllegalArgumentException("Key supports multiple ${what}s ($valid). You need to specify $what in signer configuration.") + throw IllegalArgumentException("Key supports multiple ${what}s (${valid.joinToString(", ")}). You need to specify $what in signer configuration.") val only = valid.first() possible.find { nameMap(it).equals(only, ignoreCase=true) @@ -102,6 +102,9 @@ inline fun resolveOption(what: String, valid: Array, possibl } } +private fun attestationFor(chain: CertificateChain) = + if (chain.size > 1) AndroidKeystoreAttestation(chain) else null + sealed class AndroidKeyStoreProviderImpl private constructor() : SigningProviderI { @@ -244,11 +247,11 @@ sealed class AndroidKeyStoreProviderImpl private when (val publicKey = certificateChain.leaf.publicKey) { is CryptoPublicKey.EC -> UnlockedAndroidKeystoreSigner.ECDSA( - jcaSig, keyInfo, AndroidKeystoreAttestation(certificateChain), publicKey, + jcaSig, keyInfo, attestationFor(certificateChain), publicKey, algorithm as SignatureAlgorithm.ECDSA) is CryptoPublicKey.Rsa -> UnlockedAndroidKeystoreSigner.RSA( - jcaSig, keyInfo, AndroidKeystoreAttestation(certificateChain), publicKey, + jcaSig, keyInfo, attestationFor(certificateChain), publicKey, algorithm as SignatureAlgorithm.RSA) } } @@ -265,30 +268,30 @@ typealias AndroidKeyStoreProvider = AndroidKeyStoreProviderImpl<*> interface AndroidKeystoreSigner : SignerI.Attestable { val keyInfo: KeyInfo - override val attestation: AndroidKeystoreAttestation + override val attestation: AndroidKeystoreAttestation? } sealed class UnlockedAndroidKeystoreSigner private constructor( private val jcaSig: JCASignatureObject, override val keyInfo: KeyInfo, - override val attestation: AndroidKeystoreAttestation + override val attestation: AndroidKeystoreAttestation? ): SignerI.UnlockedHandle, AndroidKeystoreSigner { class ECDSA internal constructor(jcaSig: JCASignatureObject, keyInfo: KeyInfo, - certificateChain: AndroidKeystoreAttestation, + certificateChain: AndroidKeystoreAttestation?, override val publicKey: CryptoPublicKey.EC, override val signatureAlgorithm: SignatureAlgorithm.ECDSA ) : UnlockedAndroidKeystoreSigner(jcaSig, keyInfo, certificateChain), SignerI.ECDSA class RSA internal constructor(jcaSig: JCASignatureObject, keyInfo: KeyInfo, - certificateChain: AndroidKeystoreAttestation, + certificateChain: AndroidKeystoreAttestation?, override val publicKey: CryptoPublicKey.Rsa, override val signatureAlgorithm: SignatureAlgorithm.RSA ) : UnlockedAndroidKeystoreSigner(jcaSig, keyInfo, certificateChain), SignerI.RSA - final override fun sign(data: SignatureInput) = catching { + final override suspend fun sign(data: SignatureInput) = catching { require(data.format == null) data.data.forEach(jcaSig::update) val jcaSignature = jcaSig.sign() @@ -310,7 +313,7 @@ sealed class LockedAndroidKeystoreSigner private constructor( certificateChain: CertificateChain ) : SignerI.TemporarilyUnlockable(), AndroidKeystoreSigner { - override val attestation = AndroidKeystoreAttestation(certificateChain) + override val attestation = attestationFor(certificateChain) private sealed interface AuthResult { @JvmInline value class Success(val result: AuthenticationResult): AuthResult data class Error(val code: Int, val message: String): AuthResult diff --git a/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/sign/Signer.kt b/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/sign/Signer.kt index 7936891f..d14c0600 100644 --- a/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/sign/Signer.kt +++ b/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/sign/Signer.kt @@ -1,6 +1,7 @@ package at.asitplus.signum.supreme.sign import at.asitplus.KmmResult +import at.asitplus.KmmResult.Companion.wrap import at.asitplus.catching import at.asitplus.signum.indispensable.CryptoPublicKey import at.asitplus.signum.indispensable.CryptoSignature @@ -11,6 +12,8 @@ import at.asitplus.signum.supreme.os.Attestation interface Signer { val signatureAlgorithm: SignatureAlgorithm val publicKey: CryptoPublicKey + /** Whether the signer may ask for user interaction when [sign] is called */ + val mayRequireUserUnlock: Boolean /** Any [Signer] instantiation must be [ECDSA] or [RSA] */ sealed interface AlgTrait : Signer @@ -32,47 +35,23 @@ interface Signer { val attestation: AttestationT? } - /** Any [Signer] is either [Unlocked] or [Unlockable] */ - sealed interface UnlockTrait: Signer - - /** - * This signer either does not require unlock, or is already unlocked. - * Signing operations immediately complete. - */ - interface Unlocked: Signer.UnlockTrait { - /** - * Signs the input. - * This operation never suspends. - */ - fun sign(data: SignatureInput): KmmResult - } - - /** - * This signer might require unlock. - * Signing operations might suspend while the user is prompted for confirmation. - * - * Some signers of this type are [TemporarilyUnlockable]. - */ - interface Unlockable: Signer.UnlockTrait { - /** - * Unlocks this signer, and signs the message once unlocked. - * This operation might suspend and request unlock from the user. - */ - suspend fun sign(data: SignatureInput): KmmResult - } + suspend fun sign(data: SignatureInput): KmmResult /** * A handle to a [TemporarilyUnlockable] signer that is temporarily unlocked. * The handle is only guaranteed to be valid within the scope of the block. */ @OptIn(ExperimentalStdlibApi::class) - interface UnlockedHandle: AutoCloseable, Signer.Unlocked + interface UnlockedHandle: AutoCloseable, Signer { + override val mayRequireUserUnlock: Boolean get() = false + } /** - * An [Unlockable] signer that can be temporarily unlocked. + * A signer that can be temporarily unlocked. * Once unlocked, multiple signing operations can be performed with a single unlock. */ - abstract class TemporarilyUnlockable : Signer.Unlockable { + abstract class TemporarilyUnlockable : Signer { + final override val mayRequireUserUnlock: Boolean get() = true protected abstract suspend fun unlock(): KmmResult /** @@ -81,8 +60,19 @@ interface Signer { * The handle's validity is only guaranteed in the block scope. */ @OptIn(ExperimentalStdlibApi::class) - suspend fun withUnlock(fn: Handle.()->T): KmmResult = - unlock().mapCatching { it.use(fn) } + suspend fun withUnlock(fn: suspend Handle.()->T): KmmResult = + /** this is .use() but for suspend functions */ + unlock().transform { h -> + val v = runCatching { fn(h) } + try { + h.close() + } catch (y: Throwable) { + (v.exceptionOrNull() + ?: return@transform KmmResult.failure(y)) + .addSuppressed(y) + } + v.wrap() + } final override suspend fun sign(data: SignatureInput): KmmResult = withUnlock { sign(data).getOrThrow() } @@ -103,28 +93,18 @@ fun Signer.makePlatformVerifier(configure: ConfigurePlatformVerifier = null) = s val Signer.ECDSA.curve get() = publicKey.curve -/** Sign without caring what type of signer this is. Might suspend. */ -suspend fun Signer.sign(data: SignatureInput): KmmResult { - this as Signer.UnlockTrait - return when (this) { - is Signer.Unlocked -> sign(data) - is Signer.Unlockable -> sign(data) - } -} - /** * Try to batch sign with this signer. * Might fail for unlockable signers that cannot be temporarily unlocked. */ -suspend fun Signer.withUnlock(fn: Signer.Unlocked.()->T) = catching { - this as Signer.UnlockTrait - when (this) { - is Signer.Unlocked -> this.fn() - is Signer.TemporarilyUnlockable<*> -> this.withUnlock(fn).getOrThrow() - is Signer.Unlockable -> throw UnlockFailed("This signer needs authentication for every use") +suspend fun Signer.withUnlock(fn: suspend Signer.()->T) = + when (this.mayRequireUserUnlock) { + true -> + if (this is Signer.TemporarilyUnlockable<*>) + this.withUnlock(fn) + else + KmmResult.failure(UnlockFailed("This signer needs authentication for every use")) + false -> catching { fn(this) } } -} suspend inline fun Signer.sign(data: ByteArray) = sign(SignatureInput(data)) -inline fun T.sign(data: ByteArray) = sign(SignatureInput(data)) -suspend inline fun T.sign(data: ByteArray) = sign(SignatureInput(data)) From fce2734ec96fa93544ada757da2bc9a0b8e0c935 Mon Sep 17 00:00:00 2001 From: Jakob Heher Date: Fri, 23 Aug 2024 17:33:32 +0200 Subject: [PATCH 03/73] attestation --- .../kotlin/at/asitplus/cryptotest/App.kt | 6 +- .../signum/indispensable/io/Encoding.kt | 101 +++++++++++------- .../signum/indispensable/EncodingTest.kt | 12 +++ .../provider/os/AndroidKeyStoreProvider.kt | 3 +- .../asitplus/signum/supreme/os/Attestation.kt | 67 +++++++----- 5 files changed, 124 insertions(+), 65 deletions(-) create mode 100644 indispensable/src/jvmTest/kotlin/at/asitplus/signum/indispensable/EncodingTest.kt diff --git a/demoapp/composeApp/src/commonMain/kotlin/at/asitplus/cryptotest/App.kt b/demoapp/composeApp/src/commonMain/kotlin/at/asitplus/cryptotest/App.kt index bf94a74f..b994537c 100644 --- a/demoapp/composeApp/src/commonMain/kotlin/at/asitplus/cryptotest/App.kt +++ b/demoapp/composeApp/src/commonMain/kotlin/at/asitplus/cryptotest/App.kt @@ -58,6 +58,7 @@ import at.asitplus.signum.supreme.sign.sign import at.asitplus.signum.supreme.sign.verify import at.asitplus.cryptotest.theme.AppTheme import at.asitplus.cryptotest.theme.LocalThemeIsDark +import at.asitplus.signum.supreme.os.jsonEncoded import io.github.aakira.napier.DebugAntilog import io.github.aakira.napier.Napier import io.ktor.util.decodeBase64Bytes @@ -126,7 +127,9 @@ val SIGNER_CONFIG: (SignerConfiguration.()->Unit) = { message = "We're signing a thing!" cancelText = "No! Stop!" } - rsa {} + rsa { + padding = RSAPadding.PKCS1 + } } val context = newSingleThreadContext("crypto").also { Napier.base(DebugAntilog()) } @@ -347,6 +350,7 @@ internal fun App() { is SignatureAlgorithm.RSA -> { this@createSigningKey.rsa { digests = setOf(alg.digest) + paddings = RSAPadding.entries.toSet() bits = 1024 } } diff --git a/indispensable/src/commonMain/kotlin/at/asitplus/signum/indispensable/io/Encoding.kt b/indispensable/src/commonMain/kotlin/at/asitplus/signum/indispensable/io/Encoding.kt index bd3d867b..b2c755b8 100644 --- a/indispensable/src/commonMain/kotlin/at/asitplus/signum/indispensable/io/Encoding.kt +++ b/indispensable/src/commonMain/kotlin/at/asitplus/signum/indispensable/io/Encoding.kt @@ -1,21 +1,22 @@ package at.asitplus.signum.indispensable.io -import at.asitplus.catching +import at.asitplus.signum.indispensable.pki.X509Certificate import io.matthewnelson.encoding.base64.Base64 import io.matthewnelson.encoding.base64.Base64ConfigBuilder import io.matthewnelson.encoding.core.Decoder.Companion.decodeToByteArray import io.matthewnelson.encoding.core.Encoder.Companion.encodeToString import kotlinx.serialization.KSerializer import kotlinx.serialization.SerializationException +import kotlinx.serialization.builtins.ListSerializer +import kotlinx.serialization.builtins.serializer import kotlinx.serialization.descriptors.PrimitiveKind import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.descriptors.listSerialDescriptor import kotlinx.serialization.encoding.Decoder import kotlinx.serialization.encoding.Encoder -/** - * Strict Base64 URL encode - */ +/** Strict Base64 URL encode */ val Base64UrlStrict = Base64(config = Base64ConfigBuilder().apply { lineBreakInterval = 0 encodeToUrlSafe = true @@ -24,9 +25,7 @@ val Base64UrlStrict = Base64(config = Base64ConfigBuilder().apply { }.build()) -/** - * Strict Base64 encoder - */ +/** Strict Base64 encoder */ val Base64Strict = Base64(config = Base64ConfigBuilder().apply { lineBreakInterval = 0 encodeToUrlSafe = false @@ -34,48 +33,72 @@ val Base64Strict = Base64(config = Base64ConfigBuilder().apply { padEncoded = true }.build()) +sealed class TemplateSerializer(serialName: String = "") : KSerializer { + protected val realSerialName = + serialName.ifEmpty { this::class.simpleName + ?: throw IllegalArgumentException("Anonymous classes must specify a serialName explicitly") } +} -/** - * De-/serializes Base64 strings to/from [ByteArray] - */ -object ByteArrayBase64Serializer : KSerializer { +sealed class TransformingSerializerTemplate + (private val parent: KSerializer, private val encodeAs: (ValueT)->EncodedT, + private val decodeAs: (EncodedT)->ValueT, serialName: String = "") + : TemplateSerializer(serialName) { override val descriptor: SerialDescriptor = - PrimitiveSerialDescriptor("ByteArrayBase64Serializer", PrimitiveKind.STRING) - - override fun serialize(encoder: Encoder, value: ByteArray) { - encoder.encodeString(value.encodeToString(Base64Strict)) + when (val kind = parent.descriptor.kind) { + is PrimitiveKind -> PrimitiveSerialDescriptor(realSerialName, kind) + else -> SerialDescriptor(realSerialName, parent.descriptor) + } + + override fun serialize(encoder: Encoder, value: ValueT) { + val v = try { encodeAs(value) } + catch (x: Throwable) { throw SerializationException("Encoding failed", x) } + encoder.encodeSerializableValue(parent, v) } - /** - * @throws SerializationException on error - */ - override fun deserialize(decoder: Decoder): ByteArray { - return catching { decoder.decodeString().decodeToByteArray(Base64Strict) } - .getOrElse { throw SerializationException("Base64 decoding failed", it) } + override fun deserialize(decoder: Decoder): ValueT { + val v = decoder.decodeSerializableValue(parent) + try { return decodeAs(v) } + catch (x: Throwable) { throw SerializationException("Decoding failed", x) } } - } - -/** - * De-/serializes Base64Url strings to/from [ByteArray] - */ -object ByteArrayBase64UrlSerializer : KSerializer { +/** De-/serializes Base64 strings to/from [ByteArray] */ +object ByteArrayBase64Serializer: TransformingSerializerTemplate( + parent = String.serializer(), + encodeAs = { it.encodeToString(Base64Strict) }, + decodeAs = { it.decodeToByteArray(Base64Strict) } +) + +/** De-/serializes Base64Url strings to/from [ByteArray] */ +object ByteArrayBase64UrlSerializer: TransformingSerializerTemplate( + parent = String.serializer(), + encodeAs = { it.encodeToString(Base64UrlStrict) }, + decodeAs = { it.decodeToByteArray(Base64UrlStrict) } +) + +/** De-/serializes X509Certificate as Base64Url-encoded String */ +object X509CertificateBase64UrlSerializer: TransformingSerializerTemplate( + parent = ByteArrayBase64UrlSerializer, + encodeAs = X509Certificate::encodeToDer, + decodeAs = X509Certificate::decodeFromDer +) + +sealed class ListSerializerTemplate( + using: KSerializer, serialName: String = "") + : TemplateSerializer>(serialName) { override val descriptor: SerialDescriptor = - PrimitiveSerialDescriptor("ByteArrayBase64UrlSerializer", PrimitiveKind.STRING) + SerialDescriptor(realSerialName, listSerialDescriptor(using.descriptor)) - override fun serialize(encoder: Encoder, value: ByteArray) { - encoder.encodeString(value.encodeToString(Base64UrlStrict)) - } + private val realSerializer = ListSerializer(using) + override fun serialize(encoder: Encoder, value: List) = + encoder.encodeSerializableValue(realSerializer, value) + + override fun deserialize(decoder: Decoder): List = + decoder.decodeSerializableValue(realSerializer) - //cannot be annotated with @Throws here because interfaces do not have annotations - /** - * @throws SerializationException on error - */ - override fun deserialize(decoder: Decoder): ByteArray = - catching { decoder.decodeString().decodeToByteArray(Base64UrlStrict) } - .getOrElse { throw SerializationException("Base64 decoding failed", it) } +} -} \ No newline at end of file +object CertificateChainBase64UrlSerializer: ListSerializerTemplate( + using = X509CertificateBase64UrlSerializer) diff --git a/indispensable/src/jvmTest/kotlin/at/asitplus/signum/indispensable/EncodingTest.kt b/indispensable/src/jvmTest/kotlin/at/asitplus/signum/indispensable/EncodingTest.kt new file mode 100644 index 00000000..75f8d69a --- /dev/null +++ b/indispensable/src/jvmTest/kotlin/at/asitplus/signum/indispensable/EncodingTest.kt @@ -0,0 +1,12 @@ +package at.asitplus.signum.indispensable + +import at.asitplus.signum.indispensable.io.ByteArrayBase64Serializer +import at.asitplus.signum.indispensable.io.ByteArrayBase64UrlSerializer +import io.kotest.core.spec.style.FreeSpec +import io.kotest.matchers.shouldBe + +class EncodingTest: FreeSpec({ + "Correct serialName is determined by encoders" { + ByteArrayBase64UrlSerializer.descriptor.serialName shouldBe "ByteArrayBase64UrlSerializer" + } +}) diff --git a/supreme/src/androidMain/kotlin/at/asitplus/crypto/provider/os/AndroidKeyStoreProvider.kt b/supreme/src/androidMain/kotlin/at/asitplus/crypto/provider/os/AndroidKeyStoreProvider.kt index ff50dd37..e6d8fb85 100644 --- a/supreme/src/androidMain/kotlin/at/asitplus/crypto/provider/os/AndroidKeyStoreProvider.kt +++ b/supreme/src/androidMain/kotlin/at/asitplus/crypto/provider/os/AndroidKeyStoreProvider.kt @@ -203,7 +203,8 @@ sealed class AndroidKeyStoreProviderImpl private alias: String, configure: DSLConfigureFn ): KmmResult = catching { - val jcaPrivateKey = ks.getKey(alias, null) as PrivateKey + val jcaPrivateKey = ks.getKey(alias, null) as? PrivateKey + ?: throw NoSuchElementException("No key for alias $alias exists") val config = DSL.resolve(::AndroidSignerConfiguration, configure) val certificateChain = ks.getCertificateChain(alias).map { X509Certificate.decodeFromDer(it.encoded) } diff --git a/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/os/Attestation.kt b/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/os/Attestation.kt index 46418b5e..2b2dac4d 100644 --- a/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/os/Attestation.kt +++ b/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/os/Attestation.kt @@ -1,10 +1,14 @@ package at.asitplus.signum.supreme.os import at.asitplus.signum.indispensable.io.ByteArrayBase64UrlSerializer +import at.asitplus.signum.indispensable.io.CertificateChainBase64UrlSerializer import at.asitplus.signum.indispensable.pki.CertificateChain +import kotlinx.serialization.SerialInfo +import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonClassDiscriminator import kotlinx.serialization.json.add import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.put @@ -12,32 +16,31 @@ import kotlinx.serialization.json.putJsonArray import kotlin.io.encoding.Base64 import kotlin.io.encoding.ExperimentalEncodingApi +@Serializable +@JsonClassDiscriminator("typ") sealed interface Attestation { - val jsonEncoded: String -} - -data class AndroidKeystoreAttestation (val certificateChain: CertificateChain) : Attestation { - @OptIn(ExperimentalEncodingApi::class) - override val jsonEncoded: String by lazy { - Json.encodeToString(buildJsonObject { - put("fmt", "android-key") - putJsonArray("x5c") { - certificateChain.forEach { add(Base64.UrlSafe.encode(it.encodeToDer())) } - } - }) + companion object { + fun fromJSON(v: String) = Json.decodeFromString(v) } } @Serializable -data class iosHomebrewAttestation( - // TODO document this - @Serializable(ByteArrayBase64UrlSerializer::class) +@SerialName("android-key") +data class AndroidKeystoreAttestation ( + @Serializable(with=CertificateChainBase64UrlSerializer::class) + @SerialName("x5c") + val certificateChain: CertificateChain) : Attestation + +@Serializable +@SerialName("ios-appattest-assertion") +data class iosLegacyHomebrewAttestation( + @Serializable(with=ByteArrayBase64UrlSerializer::class) val attestation: ByteArray, - @Serializable(ByteArrayBase64UrlSerializer::class) + @Serializable(with=ByteArrayBase64UrlSerializer::class) val assertion: ByteArray): Attestation { override fun equals(other: Any?): Boolean { if (this === other) return true - if (other !is iosHomebrewAttestation) return false + if (other !is iosLegacyHomebrewAttestation) return false if (!attestation.contentEquals(other.attestation)) return false return assertion.contentEquals(other.assertion) @@ -48,13 +51,29 @@ data class iosHomebrewAttestation( result = 31 * result + assertion.contentHashCode() return result } +} + +@Serializable +@SerialName("ios-appattest") +data class iosHomebrewAttestation( + @Serializable(with=ByteArrayBase64UrlSerializer::class) + val attestation: ByteArray, + @Serializable(with=ByteArrayBase64UrlSerializer::class) + val clientDataJSON: ByteArray): Attestation { - @OptIn(ExperimentalEncodingApi::class) - override val jsonEncoded: String by lazy { - Json.encodeToString(buildJsonObject { - put("fmt", "ios-appattest-assertion") - put("attestation", Base64.UrlSafe.encode(attestation)) - put("assertion", Base64.UrlSafe.encode(assertion)) - }) + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is iosHomebrewAttestation) return false + + if (!attestation.contentEquals(other.attestation)) return false + return clientDataJSON.contentEquals(other.clientDataJSON) + } + + override fun hashCode(): Int { + var result = attestation.contentHashCode() + result = 31 * result + clientDataJSON.contentHashCode() + return result } } + +val Attestation.jsonEncoded: String get() = Json.encodeToString(this) From 25a5b68fdfe637f88f5800ad444d61ee8ed9dc1e Mon Sep 17 00:00:00 2001 From: Jakob Heher Date: Fri, 23 Aug 2024 17:46:16 +0200 Subject: [PATCH 04/73] look at all of these lines of code that we aren't writing --- .../josef/io/JwsCertificateSerializer.kt | 29 +++++-------------- .../signum/indispensable/io/Encoding.kt | 2 +- 2 files changed, 8 insertions(+), 23 deletions(-) diff --git a/indispensable-josef/src/commonMain/kotlin/at/asitplus/signum/indispensable/josef/io/JwsCertificateSerializer.kt b/indispensable-josef/src/commonMain/kotlin/at/asitplus/signum/indispensable/josef/io/JwsCertificateSerializer.kt index bcd3ecd8..2f0b0cd2 100644 --- a/indispensable-josef/src/commonMain/kotlin/at/asitplus/signum/indispensable/josef/io/JwsCertificateSerializer.kt +++ b/indispensable-josef/src/commonMain/kotlin/at/asitplus/signum/indispensable/josef/io/JwsCertificateSerializer.kt @@ -1,26 +1,11 @@ package at.asitplus.signum.indispensable.josef.io -import at.asitplus.signum.indispensable.io.Base64Strict +import at.asitplus.signum.indispensable.io.ByteArrayBase64Serializer +import at.asitplus.signum.indispensable.io.TransformingSerializerTemplate import at.asitplus.signum.indispensable.pki.X509Certificate -import io.matthewnelson.encoding.core.Decoder.Companion.decodeToByteArray -import io.matthewnelson.encoding.core.Encoder.Companion.encodeToString -import kotlinx.serialization.KSerializer -import kotlinx.serialization.descriptors.PrimitiveKind -import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor -import kotlinx.serialization.descriptors.SerialDescriptor -import kotlinx.serialization.encoding.Decoder -import kotlinx.serialization.encoding.Encoder -object JwsCertificateSerializer : KSerializer { - override val descriptor: SerialDescriptor = - PrimitiveSerialDescriptor(serialName = "X509Certificate (JWS)", PrimitiveKind.STRING) - - override fun deserialize(decoder: Decoder): X509Certificate { - return X509Certificate.decodeFromDer(decoder.decodeString().decodeToByteArray(Base64Strict)) - } - - - override fun serialize(encoder: Encoder, value: X509Certificate) { - encoder.encodeString(value.encodeToDer().encodeToString(Base64Strict)) - } -} +object JwsCertificateSerializer : TransformingSerializerTemplate( + parent = ByteArrayBase64Serializer, + encodeAs = X509Certificate::encodeToDer, + decodeAs = X509Certificate::decodeFromDer +) diff --git a/indispensable/src/commonMain/kotlin/at/asitplus/signum/indispensable/io/Encoding.kt b/indispensable/src/commonMain/kotlin/at/asitplus/signum/indispensable/io/Encoding.kt index b2c755b8..ebe18872 100644 --- a/indispensable/src/commonMain/kotlin/at/asitplus/signum/indispensable/io/Encoding.kt +++ b/indispensable/src/commonMain/kotlin/at/asitplus/signum/indispensable/io/Encoding.kt @@ -39,7 +39,7 @@ sealed class TemplateSerializer(serialName: String = "") : KSerializer { ?: throw IllegalArgumentException("Anonymous classes must specify a serialName explicitly") } } -sealed class TransformingSerializerTemplate +open class TransformingSerializerTemplate (private val parent: KSerializer, private val encodeAs: (ValueT)->EncodedT, private val decodeAs: (EncodedT)->ValueT, serialName: String = "") : TemplateSerializer(serialName) { From be6801946a3b354adbabc4ceda47efe8451170bc Mon Sep 17 00:00:00 2001 From: Jakob Heher Date: Fri, 23 Aug 2024 18:11:05 +0200 Subject: [PATCH 05/73] cleanup xcode artefacts --- .gitignore | 15 +++++++++++++++ .../UserInterfaceState.xcuserstate | Bin 39121 -> 0 bytes .../xcschemes/xcschememanagement.plist | 14 -------------- 3 files changed, 15 insertions(+), 14 deletions(-) delete mode 100644 demoapp/iosApp/iosApp.xcodeproj/project.xcworkspace/xcuserdata/bpruenster.xcuserdatad/UserInterfaceState.xcuserstate delete mode 100644 demoapp/iosApp/iosApp.xcodeproj/xcuserdata/bpruenster.xcuserdatad/xcschemes/xcschememanagement.plist diff --git a/.gitignore b/.gitignore index 9ec54fce..b049cf5a 100644 --- a/.gitignore +++ b/.gitignore @@ -41,6 +41,21 @@ bin/ ### Mac OS ### .DS_Store +.Trashes +*.swp +*~.nib +DerivedData/ +*.pbxuser +*.mode1v3 +*.mode2v3 +*.perspectivev3 +!default.pbxuser +!default.mode1v3 +!default.mode2v3 +!default.perspectivev3 +*.xccheckout +xcuserdata/ +*.moved-aside ### Gradle ### local.properties diff --git a/demoapp/iosApp/iosApp.xcodeproj/project.xcworkspace/xcuserdata/bpruenster.xcuserdatad/UserInterfaceState.xcuserstate b/demoapp/iosApp/iosApp.xcodeproj/project.xcworkspace/xcuserdata/bpruenster.xcuserdatad/UserInterfaceState.xcuserstate deleted file mode 100644 index f84f87cc8ea00488c9209fc6a4ba2b1bbabe635f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 39121 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 diff --git a/demoapp/iosApp/iosApp.xcodeproj/xcuserdata/bpruenster.xcuserdatad/xcschemes/xcschememanagement.plist b/demoapp/iosApp/iosApp.xcodeproj/xcuserdata/bpruenster.xcuserdatad/xcschemes/xcschememanagement.plist deleted file mode 100644 index 56b5955f..00000000 --- a/demoapp/iosApp/iosApp.xcodeproj/xcuserdata/bpruenster.xcuserdatad/xcschemes/xcschememanagement.plist +++ /dev/null @@ -1,14 +0,0 @@ - - - - - SchemeUserState - - iosApp.xcscheme_^#shared#^_ - - orderHint - 0 - - - - From 0c66aa47bca0a226790cee13a91a5ed22b2f535f Mon Sep 17 00:00:00 2001 From: Jakob Heher Date: Fri, 23 Aug 2024 18:41:19 +0200 Subject: [PATCH 06/73] ios attestation adjustments --- .../signum/indispensable/io/Encoding.kt | 7 +++ .../asitplus/signum/supreme/os/Attestation.kt | 50 ++++++++++++++--- .../signum/supreme/os/IosKeychainProvider.kt | 56 ++++++++++--------- 3 files changed, 79 insertions(+), 34 deletions(-) diff --git a/indispensable/src/commonMain/kotlin/at/asitplus/signum/indispensable/io/Encoding.kt b/indispensable/src/commonMain/kotlin/at/asitplus/signum/indispensable/io/Encoding.kt index ebe18872..97673eaf 100644 --- a/indispensable/src/commonMain/kotlin/at/asitplus/signum/indispensable/io/Encoding.kt +++ b/indispensable/src/commonMain/kotlin/at/asitplus/signum/indispensable/io/Encoding.kt @@ -1,5 +1,6 @@ package at.asitplus.signum.indispensable.io +import at.asitplus.signum.indispensable.CryptoPublicKey import at.asitplus.signum.indispensable.pki.X509Certificate import io.matthewnelson.encoding.base64.Base64 import io.matthewnelson.encoding.base64.Base64ConfigBuilder @@ -84,6 +85,12 @@ object X509CertificateBase64UrlSerializer: TransformingSerializerTemplate( + parent = ByteArrayBase64UrlSerializer, + encodeAs = CryptoPublicKey::iosEncoded, + decodeAs = CryptoPublicKey::fromIosEncoded) + sealed class ListSerializerTemplate( using: KSerializer, serialName: String = "") : TemplateSerializer>(serialName) { diff --git a/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/os/Attestation.kt b/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/os/Attestation.kt index 2b2dac4d..53c7c09f 100644 --- a/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/os/Attestation.kt +++ b/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/os/Attestation.kt @@ -1,20 +1,15 @@ package at.asitplus.signum.supreme.os +import at.asitplus.signum.indispensable.CryptoPublicKey import at.asitplus.signum.indispensable.io.ByteArrayBase64UrlSerializer import at.asitplus.signum.indispensable.io.CertificateChainBase64UrlSerializer +import at.asitplus.signum.indispensable.io.IosPublicKeySerializer import at.asitplus.signum.indispensable.pki.CertificateChain -import kotlinx.serialization.SerialInfo import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonClassDiscriminator -import kotlinx.serialization.json.add -import kotlinx.serialization.json.buildJsonObject -import kotlinx.serialization.json.put -import kotlinx.serialization.json.putJsonArray -import kotlin.io.encoding.Base64 -import kotlin.io.encoding.ExperimentalEncodingApi @Serializable @JsonClassDiscriminator("typ") @@ -53,6 +48,8 @@ data class iosLegacyHomebrewAttestation( } } +val StrictJson = Json { ignoreUnknownKeys = true; isLenient = false } + @Serializable @SerialName("ios-appattest") data class iosHomebrewAttestation( @@ -61,6 +58,45 @@ data class iosHomebrewAttestation( @Serializable(with=ByteArrayBase64UrlSerializer::class) val clientDataJSON: ByteArray): Attestation { + companion object { const val THE_PURPOSE = "ios app-attest: secure enclave protected key" } + + @Serializable + data class ClientData private constructor( + private val purpose: String, + @Serializable(with=IosPublicKeySerializer::class) + val publicKey: CryptoPublicKey, + @Serializable(with=ByteArrayBase64UrlSerializer::class) + val challenge: ByteArray + ) { + constructor(publicKey: CryptoPublicKey, challenge: ByteArray) : + this(THE_PURPOSE, publicKey, challenge) + + internal fun assertValidity() { if (purpose != THE_PURPOSE) throw IllegalStateException("Invalid purpose") } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other == null || this::class != other::class) return false + + other as ClientData + + if (purpose != other.purpose) return false + if (publicKey != other.publicKey) return false + return challenge.contentEquals(other.challenge) + } + + override fun hashCode(): Int { + var result = purpose.hashCode() + result = 31 * result + publicKey.hashCode() + result = 31 * result + challenge.contentHashCode() + return result + } + } + + val parsedClientData: ClientData by lazy { + StrictJson.decodeFromString(clientDataJSON.decodeToString()) + .also(ClientData::assertValidity) + } + override fun equals(other: Any?): Boolean { if (this === other) return true if (other !is iosHomebrewAttestation) return false diff --git a/supreme/src/iosMain/kotlin/at/asitplus/signum/supreme/os/IosKeychainProvider.kt b/supreme/src/iosMain/kotlin/at/asitplus/signum/supreme/os/IosKeychainProvider.kt index 66b0f1f3..b7d308f5 100644 --- a/supreme/src/iosMain/kotlin/at/asitplus/signum/supreme/os/IosKeychainProvider.kt +++ b/supreme/src/iosMain/kotlin/at/asitplus/signum/supreme/os/IosKeychainProvider.kt @@ -157,7 +157,8 @@ sealed class unlockedIosSigner(private val ownedArena: Arena, private val privat } protected abstract fun bytesToSignature(sigBytes: ByteArray): CryptoSignature - override fun sign(data: SignatureInput): KmmResult = catching { + override suspend fun sign(data: SignatureInput): KmmResult = + withContext(keychainThreads) { catching { if (!usable) throw IllegalStateException("Scoping violation; using key after it has been freed") require(data.format == null) { "Pre-hashed data is unsupported on iOS" } val algorithm = signatureAlgorithm.secKeyAlgorithm @@ -166,7 +167,7 @@ sealed class unlockedIosSigner(private val ownedArena: Arena, private val privat SecKeyCreateSignature(privateKeyRef, algorithm, plaintext.giveToCF(), error) }.let { it.takeFromCF().toByteArray() } return@catching bytesToSignature(signatureBytes) - } + }} class ECDSA(ownedArena: Arena, privateKeyRef: SecKeyRef, @@ -321,7 +322,7 @@ object IosKeychainProvider: SigningProviderI, iosSignerConfigurati override suspend fun createSigningKey( alias: String, configure: DSLConfigureFn - ): KmmResult> = catching { + ): KmmResult> = withContext(keychainThreads) { catching { memScoped { if (getPublicKey(alias) != null) throw NoSuchElementException("Key with alias $alias already exists") @@ -433,16 +434,17 @@ object IosKeychainProvider: SigningProviderI, iosSignerConfigurati service.generateKeyWithCompletionHandler(callback) } Napier.v { "created attestation key (keyId = $keyId)" } + + val clientData = iosHomebrewAttestation.ClientData( + publicKey = publicKey, challenge = attestationConfig.challenge) + val clientDataJSON = Json.encodeToString(clientData).encodeToByteArray() + val assertionKeyAttestation = swiftasync { - service.attestKey(keyId, Digest.SHA256.digest(attestationConfig.challenge).toNSData(), callback) + service.attestKey(keyId, Digest.SHA256.digest(clientDataJSON).toNSData(), callback) }.toByteArray() Napier.v { "attested key ($assertionKeyAttestation)" } - val keyAssertion = swiftasync { - service.generateAssertion(keyId, Digest.SHA256.digest(publicKeyBytes).toNSData(), callback) - }.toByteArray() - Napier.v { "asserted underlying public key for $alias ($keyAssertion)" } - val attestation = iosHomebrewAttestation(attestation = assertionKeyAttestation, assertion = keyAssertion) + val attestation = iosHomebrewAttestation(attestation = assertionKeyAttestation, clientDataJSON = clientDataJSON) storeKeyAttestation(alias, attestation) return@let attestation } @@ -459,31 +461,31 @@ object IosKeychainProvider: SigningProviderI, iosSignerConfigurati } }.also { val e = it.exceptionOrNull() - if (e != null && e !is NoSuchElementException) + if (e != null && e !is NoSuchElementException) { + // get rid of any "partial" keys runCatching { deleteSigningKey(alias) } - } + } + }} override suspend fun getSignerForKey( alias: String, configure: DSLConfigureFn - ): KmmResult> = catching { + ): KmmResult> = withContext(keychainThreads) { catching { val config = DSL.resolve(::iosSignerConfiguration, configure) - return@catching withContext(keychainThreads) { - val publicKeyBytes: ByteArray = memScoped { - val publicKey = getPublicKey(alias) - ?: throw NoSuchElementException("No key for alias $alias exists") - return@memScoped corecall { - SecKeyCopyExternalRepresentation(publicKey, error) - }.let { it.takeFromCF() }.toByteArray() - } - val attestation = getKeyAttestation(alias) - return@withContext when (val publicKey = - CryptoPublicKey.fromIosEncoded(publicKeyBytes)) { - is CryptoPublicKey.EC -> iosSigner.ECDSA(alias, attestation, config, publicKey) - is CryptoPublicKey.Rsa -> iosSigner.RSA(alias, attestation, config, publicKey) - } + val publicKeyBytes: ByteArray = memScoped { + val publicKey = getPublicKey(alias) + ?: throw NoSuchElementException("No key for alias $alias exists") + return@memScoped corecall { + SecKeyCopyExternalRepresentation(publicKey, error) + }.let { it.takeFromCF() }.toByteArray() } - } + val attestation = getKeyAttestation(alias) + return@catching when (val publicKey = + CryptoPublicKey.fromIosEncoded(publicKeyBytes)) { + is CryptoPublicKey.EC -> iosSigner.ECDSA(alias, attestation, config, publicKey) + is CryptoPublicKey.Rsa -> iosSigner.RSA(alias, attestation, config, publicKey) + } + }} override suspend fun deleteSigningKey(alias: String) = withContext(keychainThreads) { memScoped { From 264e2b3b5702a8929dbfa94ef052058e8b74b9c9 Mon Sep 17 00:00:00 2001 From: Jakob Heher Date: Mon, 26 Aug 2024 13:48:10 +0200 Subject: [PATCH 07/73] ephemeral keys --- .../kotlin/at/asitplus/cryptotest/App.kt | 4 +- .../supreme}/os/AndroidKeyStoreProvider.kt | 31 +---- .../signum/supreme/sign/EphemeralKeysImpl.kt | 107 ++++++++++++++++++ .../signum/supreme/os/SigningProvider.kt | 42 ++----- .../signum/supreme/sign/EphemeralKeys.kt | 28 +++++ .../signum/supreme/sign/InternalCommonUtil.kt | 29 +++++ .../at/asitplus/signum/supreme/sign/Signer.kt | 36 ++++++ .../sign/EphemeralSignerCommonTests.kt | 25 ++++ .../signum/supreme/os/IosKeychainProvider.kt | 3 +- .../signum/supreme/sign/EphemeralKeysImpl.kt | 106 +++++++++++++++++ 10 files changed, 348 insertions(+), 63 deletions(-) rename supreme/src/androidMain/kotlin/at/asitplus/{crypto/provider => signum/supreme}/os/AndroidKeyStoreProvider.kt (93%) create mode 100644 supreme/src/androidMain/kotlin/at/asitplus/signum/supreme/sign/EphemeralKeysImpl.kt create mode 100644 supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/sign/EphemeralKeys.kt create mode 100644 supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/sign/InternalCommonUtil.kt create mode 100644 supreme/src/commonTest/kotlin/at/asitplus/signum/supreme/sign/EphemeralSignerCommonTests.kt create mode 100644 supreme/src/jvmMain/kotlin/at/asitplus/signum/supreme/sign/EphemeralKeysImpl.kt diff --git a/demoapp/composeApp/src/commonMain/kotlin/at/asitplus/cryptotest/App.kt b/demoapp/composeApp/src/commonMain/kotlin/at/asitplus/cryptotest/App.kt index b994537c..ce7d1a3d 100644 --- a/demoapp/composeApp/src/commonMain/kotlin/at/asitplus/cryptotest/App.kt +++ b/demoapp/composeApp/src/commonMain/kotlin/at/asitplus/cryptotest/App.kt @@ -50,7 +50,6 @@ import at.asitplus.signum.indispensable.X509SignatureAlgorithm import at.asitplus.signum.indispensable.nativeDigest import at.asitplus.signum.indispensable.pki.X509Certificate import at.asitplus.signum.supreme.dsl.PREFERRED -import at.asitplus.signum.supreme.os.SignerConfiguration import at.asitplus.signum.supreme.os.SigningProvider import at.asitplus.signum.supreme.sign.Signer import at.asitplus.signum.supreme.sign.makeVerifier @@ -58,6 +57,7 @@ import at.asitplus.signum.supreme.sign.sign import at.asitplus.signum.supreme.sign.verify import at.asitplus.cryptotest.theme.AppTheme import at.asitplus.cryptotest.theme.LocalThemeIsDark +import at.asitplus.signum.supreme.os.PlatformSignerConfiguration import at.asitplus.signum.supreme.os.jsonEncoded import io.github.aakira.napier.DebugAntilog import io.github.aakira.napier.Napier @@ -122,7 +122,7 @@ val SAMPLE_CERT_CHAIN = listOf( const val ALIAS = "Bartschlüssel" -val SIGNER_CONFIG: (SignerConfiguration.()->Unit) = { +val SIGNER_CONFIG: (PlatformSignerConfiguration.()->Unit) = { unlockPrompt { message = "We're signing a thing!" cancelText = "No! Stop!" diff --git a/supreme/src/androidMain/kotlin/at/asitplus/crypto/provider/os/AndroidKeyStoreProvider.kt b/supreme/src/androidMain/kotlin/at/asitplus/signum/supreme/os/AndroidKeyStoreProvider.kt similarity index 93% rename from supreme/src/androidMain/kotlin/at/asitplus/crypto/provider/os/AndroidKeyStoreProvider.kt rename to supreme/src/androidMain/kotlin/at/asitplus/signum/supreme/os/AndroidKeyStoreProvider.kt index e6d8fb85..2c366d6b 100644 --- a/supreme/src/androidMain/kotlin/at/asitplus/crypto/provider/os/AndroidKeyStoreProvider.kt +++ b/supreme/src/androidMain/kotlin/at/asitplus/signum/supreme/os/AndroidKeyStoreProvider.kt @@ -32,8 +32,9 @@ import at.asitplus.signum.supreme.dsl.DSLConfigureFn import at.asitplus.signum.supreme.dsl.FeaturePreference import at.asitplus.signum.supreme.dsl.PREFERRED import at.asitplus.signum.supreme.dsl.REQUIRED -import at.asitplus.signum.supreme.os.* import at.asitplus.signum.supreme.sign.SignatureInput +import at.asitplus.signum.supreme.sign.SigningKeyConfiguration +import at.asitplus.signum.supreme.sign.resolveOption import com.ionspin.kotlin.bignum.integer.base63.toJavaBigInteger import at.asitplus.signum.supreme.sign.Signer as SignerI import kotlinx.coroutines.CoroutineScope @@ -66,8 +67,8 @@ class AndroidSigningKeyConfiguration internal constructor(): PlatformSigningKeyC override val hardware = childOrNull(::AndroidKeymasterConfiguration) } -class AndroidSignerConfiguration: SignerConfiguration() { - class AuthnPrompt: SignerConfiguration.AuthnPrompt() { +class AndroidSignerConfiguration: PlatformSignerConfiguration() { + class AuthnPrompt: PlatformSignerConfiguration.AuthnPrompt() { var subtitle: String? = null var description: String? = null var confirmationRequired: Boolean? = null @@ -78,30 +79,6 @@ class AndroidSignerConfiguration: SignerConfiguration() { override val unlockPrompt = childOrDefault(::AuthnPrompt) } -/** - * Resolve [what] differently based on whether the [v]alue was [spec]ified. - * - * * [spec] = `true`: Check if [valid] contains [nameMap] applied to [v], return [v] if yes, throw otherwise - * * [spec] = `false`: Check if [valid] contains exactly one element, if yes, return the [E] from [possible] for which [nameMap] returns that element, throw otherwise - */ -inline fun resolveOption(what: String, valid: Array, possible: Sequence, spec: Boolean, v: E, crossinline nameMap: (E)->String): E = - when (spec) { - true -> { - val vStr = nameMap(v) - if (!valid.any { it.equals(vStr, ignoreCase=true) }) - throw IllegalArgumentException("Key does not support $what $v; supported: ${valid.joinToString(", ")}") - v - } - false -> { - if (valid.size != 1) - throw IllegalArgumentException("Key supports multiple ${what}s (${valid.joinToString(", ")}). You need to specify $what in signer configuration.") - val only = valid.first() - possible.find { - nameMap(it).equals(only, ignoreCase=true) - } ?: throw UnsupportedCryptoException("Unsupported $what $only") - } - } - private fun attestationFor(chain: CertificateChain) = if (chain.size > 1) AndroidKeystoreAttestation(chain) else null diff --git a/supreme/src/androidMain/kotlin/at/asitplus/signum/supreme/sign/EphemeralKeysImpl.kt b/supreme/src/androidMain/kotlin/at/asitplus/signum/supreme/sign/EphemeralKeysImpl.kt new file mode 100644 index 00000000..60fb7ed5 --- /dev/null +++ b/supreme/src/androidMain/kotlin/at/asitplus/signum/supreme/sign/EphemeralKeysImpl.kt @@ -0,0 +1,107 @@ +package at.asitplus.signum.supreme.sign + +import android.security.keystore.KeyProperties +import at.asitplus.catching +import at.asitplus.signum.indispensable.CryptoPublicKey +import at.asitplus.signum.indispensable.CryptoSignature +import at.asitplus.signum.indispensable.Digest +import at.asitplus.signum.indispensable.RSAPadding +import at.asitplus.signum.indispensable.SignatureAlgorithm +import at.asitplus.signum.indispensable.fromJcaPublicKey +import at.asitplus.signum.indispensable.getJCASignatureInstance +import at.asitplus.signum.indispensable.jcaName +import at.asitplus.signum.indispensable.parseFromJca +import at.asitplus.signum.supreme.dsl.DSL +import at.asitplus.signum.supreme.dsl.DSLConfigureFn +import at.asitplus.signum.supreme.os.SignerConfiguration +import at.asitplus.signum.supreme.sign.resolveOption +import com.ionspin.kotlin.bignum.integer.base63.toJavaBigInteger +import java.security.KeyPairGenerator +import java.security.PrivateKey +import java.security.interfaces.ECPublicKey +import java.security.interfaces.RSAPublicKey +import java.security.spec.ECGenParameterSpec +import java.security.spec.RSAKeyGenParameterSpec + +actual class EphemeralSigningKeyConfiguration internal actual constructor(): SigningKeyConfiguration() +actual class EphemeralSignerConfiguration internal actual constructor(): SignerConfiguration() + +sealed class AndroidEphemeralSigner (private val privateKey: PrivateKey) : Signer { + override val mayRequireUserUnlock = false + override suspend fun sign(data: SignatureInput) = catching { + val alg = if (data.format != null) { + (signatureAlgorithm as? SignatureAlgorithm.ECDSA).let { + require (it != null && it.digest == data.format) + { "Pre-hashed data (format ${data.format}) unsupported for algorithm $signatureAlgorithm" } + } + SignatureAlgorithm.ECDSA(digest = null, requiredCurve = null) + } else signatureAlgorithm + alg.getJCASignatureInstance(provider = null, isAndroid = true).getOrThrow().run { + initSign(privateKey) + data.data.forEach { update(it) } + sign().let { + CryptoSignature.parseFromJca(it, alg) + } + } + } + class EC (privateKey: PrivateKey, override val publicKey: CryptoPublicKey.EC, override val signatureAlgorithm: SignatureAlgorithm.ECDSA) + : AndroidEphemeralSigner(privateKey), Signer.ECDSA + + class RSA (privateKey: PrivateKey, override val publicKey: CryptoPublicKey.Rsa, override val signatureAlgorithm: SignatureAlgorithm.RSA) + : AndroidEphemeralSigner(privateKey), Signer.RSA +} + +sealed class AndroidKeyHolder (val privateKey: PrivateKey) { + + class EC internal constructor (privateKey: PrivateKey, publicKey: ECPublicKey, + val digests: Set) + : AndroidKeyHolder(privateKey), EphemeralKey.EC { + override val publicKey = CryptoPublicKey.fromJcaPublicKey(publicKey).getOrThrow() as CryptoPublicKey.EC + override fun signer(configure: DSLConfigureFn): Signer.ECDSA { + val config = DSL.resolve(::EphemeralSignerConfiguration, configure).ec.v + val digest = resolveOption("digest", digests, Digest.entries.asSequence() + sequenceOf(null), config.digestSpecified, config.digest) { it?.jcaName ?: KeyProperties.DIGEST_NONE } + return AndroidEphemeralSigner.EC(privateKey, publicKey, SignatureAlgorithm.ECDSA(digest, publicKey.curve)) + } + } + class RSA internal constructor (privateKey: PrivateKey, publicKey: RSAPublicKey, + val digests: Set, val paddings: Set) + : AndroidKeyHolder(privateKey), EphemeralKey.RSA { + override val publicKey = CryptoPublicKey.fromJcaPublicKey(publicKey).getOrThrow() as CryptoPublicKey.Rsa + override fun signer(configure: DSLConfigureFn): Signer.RSA { + val config = DSL.resolve(::EphemeralSignerConfiguration, configure).rsa.v + val digest = resolveOption("digest", digests, Digest.entries.asSequence(), config.digestSpecified, config.digest, Digest::jcaName) + val padding = resolveOption("padding", paddings, RSAPadding.entries.asSequence(), config.paddingSpecified, config.padding) { + when (it) { + RSAPadding.PKCS1 -> KeyProperties.SIGNATURE_PADDING_RSA_PKCS1 + RSAPadding.PSS -> KeyProperties.SIGNATURE_PADDING_RSA_PSS + } + } + + return AndroidEphemeralSigner.RSA(privateKey, publicKey, SignatureAlgorithm.RSA(digest, padding)) + } + } +} + +internal actual fun makeEphemeralKey(configuration: EphemeralSigningKeyConfiguration) : EphemeralKey = + when (val alg = configuration._algSpecific.v) { + is SigningKeyConfiguration.ECConfiguration -> { + KeyPairGenerator.getInstance(KeyProperties.KEY_ALGORITHM_EC).run { + initialize(ECGenParameterSpec(alg.curve.jcaName)) + generateKeyPair() + }.let { pair -> + AndroidKeyHolder.EC( + pair.private, pair.public as ECPublicKey, + digests = alg.digests) + } + } + is SigningKeyConfiguration.RSAConfiguration -> { + KeyPairGenerator.getInstance(KeyProperties.KEY_ALGORITHM_RSA).run { + initialize(RSAKeyGenParameterSpec(alg.bits, alg.publicExponent.toJavaBigInteger())) + generateKeyPair() + }.let { pair -> + AndroidKeyHolder.RSA( + pair.private, pair.public as RSAPublicKey, + digests = alg.digests, paddings = alg.paddings) + } + } + } diff --git a/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/os/SigningProvider.kt b/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/os/SigningProvider.kt index 4742de99..6cf7c960 100644 --- a/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/os/SigningProvider.kt +++ b/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/os/SigningProvider.kt @@ -2,44 +2,18 @@ package at.asitplus.signum.supreme.os import at.asitplus.KmmResult import at.asitplus.signum.indispensable.Digest -import at.asitplus.signum.indispensable.ECCurve import at.asitplus.signum.indispensable.RSAPadding -import at.asitplus.signum.indispensable.nativeDigest import at.asitplus.signum.supreme.dsl.DISCOURAGED import at.asitplus.signum.supreme.dsl.DSL import at.asitplus.signum.supreme.dsl.DSLConfigureFn import at.asitplus.signum.supreme.dsl.FeaturePreference import at.asitplus.signum.supreme.dsl.REQUIRED import at.asitplus.signum.supreme.sign.Signer -import com.ionspin.kotlin.bignum.integer.BigInteger +import at.asitplus.signum.supreme.sign.SigningKeyConfiguration import kotlin.time.Duration import kotlin.time.Duration.Companion.seconds -open class SigningKeyConfiguration internal constructor(): DSL.Data() { - sealed class AlgorithmSpecific: DSL.Data() - internal val _algSpecific = subclassOf(default = ECConfiguration()) - open class ECConfiguration internal constructor() : AlgorithmSpecific() { - var curve: ECCurve = ECCurve.SECP_256_R_1 - - private var _digests: Set? = null - /** Specify the digests supported by the key. If not specified, supports the curve's native digest only. */ - var digests: Set - get() = _digests ?: setOf(curve.nativeDigest) - set(v) { _digests = v } - } - open val ec = _algSpecific.option(::ECConfiguration) - - open class RSAConfiguration internal constructor(): AlgorithmSpecific() { - companion object { val F0 = BigInteger(3); val F4 = BigInteger(65537)} - var digests: Set = setOf(Digest.SHA1, Digest.SHA256, Digest.SHA384, Digest.SHA512) - var paddings: Set = setOf(RSAPadding.PSS) - var bits: Int = 4096 - var publicExponent: BigInteger = F4 - } - open val rsa = _algSpecific.option(::RSAConfiguration) -} - -open class PlatformSigningKeyConfiguration internal constructor(): SigningKeyConfiguration() { +open class PlatformSigningKeyConfiguration internal constructor(): SigningKeyConfiguration() { open class AttestationConfiguration internal constructor(): DSL.Data() { /** The server-provided attestation challenge */ lateinit var challenge: ByteArray @@ -82,7 +56,7 @@ open class PlatformSigningKeyConfiguration() + open val signer = integratedReceiver() // TODO: figure out a reasonable common interface for biometry requirements } @@ -119,6 +93,11 @@ open class RSASignerConfiguration internal constructor(): DSL.Data() { } open class SignerConfiguration internal constructor(): DSL.Data() { + open val ec = childOrDefault(::ECSignerConfiguration) + open val rsa = childOrDefault(::RSASignerConfiguration) +} + +open class PlatformSignerConfiguration internal constructor(): SignerConfiguration() { open class AuthnPrompt: DSL.Data() { /** The prompt message to show to the user when asking for unlock */ var message: String = "Please authorize cryptographic signature" @@ -126,13 +105,10 @@ open class SignerConfiguration internal constructor(): DSL.Data() { var cancelText: String = "Abort" } open val unlockPrompt = childOrDefault(::AuthnPrompt) - - open val ec = childOrDefault(::ECSignerConfiguration) - open val rsa = childOrDefault(::RSASignerConfiguration) } interface SigningProviderI> { suspend fun createSigningKey(alias: String, configure: DSLConfigureFn = null) : KmmResult suspend fun getSignerForKey(alias: String, configure: DSLConfigureFn = null) : KmmResult diff --git a/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/sign/EphemeralKeys.kt b/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/sign/EphemeralKeys.kt new file mode 100644 index 00000000..7efc345a --- /dev/null +++ b/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/sign/EphemeralKeys.kt @@ -0,0 +1,28 @@ +package at.asitplus.signum.supreme.sign + +import at.asitplus.signum.indispensable.CryptoPublicKey +import at.asitplus.signum.supreme.dsl.DSL +import at.asitplus.signum.supreme.dsl.DSLConfigureFn +import at.asitplus.signum.supreme.os.SignerConfiguration + +internal expect fun makeEphemeralKey(configuration: EphemeralSigningKeyConfiguration) : EphemeralKey + +expect class EphemeralSigningKeyConfiguration internal constructor(): SigningKeyConfiguration +expect class EphemeralSignerConfiguration internal constructor(): SignerConfiguration + +sealed interface EphemeralKey { + val publicKey: CryptoPublicKey + fun signer(configure: DSLConfigureFn = null): Signer + interface EC: EphemeralKey { + override val publicKey: CryptoPublicKey.EC + override fun signer(configure: DSLConfigureFn): Signer.ECDSA + } + interface RSA: EphemeralKey { + override val publicKey: CryptoPublicKey.Rsa + override fun signer(configure: DSLConfigureFn): Signer.RSA + } + companion object { + operator fun invoke(configure: DSLConfigureFn = null) = + makeEphemeralKey(DSL.resolve(::EphemeralSigningKeyConfiguration, configure)) + } +} diff --git a/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/sign/InternalCommonUtil.kt b/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/sign/InternalCommonUtil.kt new file mode 100644 index 00000000..3aab42e4 --- /dev/null +++ b/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/sign/InternalCommonUtil.kt @@ -0,0 +1,29 @@ +package at.asitplus.signum.supreme.sign + +import at.asitplus.signum.supreme.UnsupportedCryptoException + +/** + * Resolve [what] differently based on whether the [v]alue was [spec]ified. + * + * * [spec] = `true`: Check if [valid] contains [nameMap] applied to [v], return [v] if yes, throw otherwise + * * [spec] = `false`: Check if [valid] contains exactly one element, if yes, return the [E] from [possible] for which [nameMap] returns that element, throw otherwise + */ +internal inline fun resolveOption(what: String, valid: Array, possible: Sequence, spec: Boolean, v: E, crossinline nameMap: (E)->String): E = + when (spec) { + true -> { + val vStr = nameMap(v) + if (!valid.any { it.equals(vStr, ignoreCase=true) }) + throw IllegalArgumentException("Key does not support $what $v; supported: ${valid.joinToString(", ")}") + v + } + false -> { + if (valid.size != 1) + throw IllegalArgumentException("Key supports multiple ${what}s (${valid.joinToString(", ")}). You need to specify $what in signer configuration.") + val only = valid.first() + possible.find { + nameMap(it).equals(only, ignoreCase=true) + } ?: throw UnsupportedCryptoException("Unsupported $what $only") + } + } +internal inline fun resolveOption(what: String, valid: Set, possible: Sequence, spec: Boolean, v: E, crossinline nameMap: (E)->String): E = + resolveOption(what, valid.map(nameMap).toTypedArray(), possible, spec, v, nameMap) diff --git a/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/sign/Signer.kt b/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/sign/Signer.kt index d14c0600..2f9654e0 100644 --- a/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/sign/Signer.kt +++ b/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/sign/Signer.kt @@ -5,9 +5,40 @@ import at.asitplus.KmmResult.Companion.wrap import at.asitplus.catching import at.asitplus.signum.indispensable.CryptoPublicKey import at.asitplus.signum.indispensable.CryptoSignature +import at.asitplus.signum.indispensable.Digest +import at.asitplus.signum.indispensable.ECCurve +import at.asitplus.signum.indispensable.RSAPadding import at.asitplus.signum.indispensable.SignatureAlgorithm +import at.asitplus.signum.indispensable.nativeDigest import at.asitplus.signum.supreme.UnlockFailed +import at.asitplus.signum.supreme.dsl.DSL +import at.asitplus.signum.supreme.dsl.DSLConfigureFn import at.asitplus.signum.supreme.os.Attestation +import com.ionspin.kotlin.bignum.integer.BigInteger + +open class SigningKeyConfiguration internal constructor(): DSL.Data() { + sealed class AlgorithmSpecific: DSL.Data() + internal val _algSpecific = subclassOf(default = ECConfiguration()) + open class ECConfiguration internal constructor() : AlgorithmSpecific() { + var curve: ECCurve = ECCurve.SECP_256_R_1 + + private var _digests: Set? = null + /** Specify the digests supported by the key. If not specified, supports the curve's native digest only. */ + open var digests: Set + get() = _digests ?: setOf(curve.nativeDigest) + set(v) { _digests = v } + } + open val ec = _algSpecific.option(::ECConfiguration) + + open class RSAConfiguration internal constructor(): AlgorithmSpecific() { + companion object { val F0 = BigInteger(3); val F4 = BigInteger(65537) } + open var digests: Set = setOf(Digest.SHA256) + open var paddings: Set = setOf(RSAPadding.PSS) + var bits: Int = 4096 + var publicExponent: BigInteger = F4 + } + open val rsa = _algSpecific.option(::RSAConfiguration) +} interface Signer { val signatureAlgorithm: SignatureAlgorithm @@ -77,6 +108,11 @@ interface Signer { final override suspend fun sign(data: SignatureInput): KmmResult = withUnlock { sign(data).getOrThrow() } } + + companion object { + operator fun invoke(configure: DSLConfigureFn = null) = + EphemeralKey(configure).signer() + } } /** diff --git a/supreme/src/commonTest/kotlin/at/asitplus/signum/supreme/sign/EphemeralSignerCommonTests.kt b/supreme/src/commonTest/kotlin/at/asitplus/signum/supreme/sign/EphemeralSignerCommonTests.kt new file mode 100644 index 00000000..9e4cbf7a --- /dev/null +++ b/supreme/src/commonTest/kotlin/at/asitplus/signum/supreme/sign/EphemeralSignerCommonTests.kt @@ -0,0 +1,25 @@ +package at.asitplus.signum.supreme.sign + +import at.asitplus.signum.supreme.succeed +import io.kotest.core.spec.style.FreeSpec +import io.kotest.matchers.should +import kotlin.random.Random + +class EphemeralSignerCommonTests : FreeSpec({ + "RSA".config(invocations = 5) { + val signer = Signer { rsa {} } + val data = Random.Default.nextBytes(64) + val signature = signer.sign(data).getOrThrow() + + val verifier = signer.makeVerifier().getOrThrow() + verifier.verify(data, signature) should succeed + } + "ECDSA".config(invocations = 5) { + val signer = Signer { ec {} } + val data = Random.Default.nextBytes(64) + val signature = signer.sign(data).getOrThrow() + + val verifier = signer.makeVerifier().getOrThrow() + verifier.verify(data, signature) should succeed + } +}) diff --git a/supreme/src/iosMain/kotlin/at/asitplus/signum/supreme/os/IosKeychainProvider.kt b/supreme/src/iosMain/kotlin/at/asitplus/signum/supreme/os/IosKeychainProvider.kt index b7d308f5..85c0e3b3 100644 --- a/supreme/src/iosMain/kotlin/at/asitplus/signum/supreme/os/IosKeychainProvider.kt +++ b/supreme/src/iosMain/kotlin/at/asitplus/signum/supreme/os/IosKeychainProvider.kt @@ -100,6 +100,7 @@ import platform.Security.kSecUseAuthenticationContext import platform.Security.kSecUseAuthenticationUI import platform.Security.kSecUseAuthenticationUIAllow import at.asitplus.signum.indispensable.secKeyAlgorithm +import at.asitplus.signum.supreme.sign.SigningKeyConfiguration val keychainThreads = newFixedThreadPoolContext(nThreads = 4, name = "iOS Keychain Operations") @@ -135,7 +136,7 @@ class iosSigningKeyConfiguration internal constructor(): PlatformSigningKeyConfi } } -class iosSignerConfiguration internal constructor(): SignerConfiguration() { +class iosSignerConfiguration internal constructor(): PlatformSignerConfiguration() { } sealed class unlockedIosSigner(private val ownedArena: Arena, private val privateKeyRef: SecKeyRef) : Signer.UnlockedHandle { diff --git a/supreme/src/jvmMain/kotlin/at/asitplus/signum/supreme/sign/EphemeralKeysImpl.kt b/supreme/src/jvmMain/kotlin/at/asitplus/signum/supreme/sign/EphemeralKeysImpl.kt new file mode 100644 index 00000000..bb1d1643 --- /dev/null +++ b/supreme/src/jvmMain/kotlin/at/asitplus/signum/supreme/sign/EphemeralKeysImpl.kt @@ -0,0 +1,106 @@ +package at.asitplus.signum.supreme.sign + +import at.asitplus.catching +import at.asitplus.signum.indispensable.CryptoPublicKey +import at.asitplus.signum.indispensable.CryptoSignature +import at.asitplus.signum.indispensable.Digest +import at.asitplus.signum.indispensable.RSAPadding +import at.asitplus.signum.indispensable.SignatureAlgorithm +import at.asitplus.signum.indispensable.fromJcaPublicKey +import at.asitplus.signum.indispensable.getJCASignatureInstance +import at.asitplus.signum.indispensable.jcaAlgorithmComponent +import at.asitplus.signum.indispensable.jcaName +import at.asitplus.signum.indispensable.parseFromJca +import at.asitplus.signum.supreme.dsl.DSL +import at.asitplus.signum.supreme.dsl.DSLConfigureFn +import at.asitplus.signum.supreme.os.SignerConfiguration +import com.ionspin.kotlin.bignum.integer.base63.toJavaBigInteger +import java.security.KeyPairGenerator +import java.security.PrivateKey +import java.security.interfaces.ECPublicKey +import java.security.interfaces.RSAPublicKey +import java.security.spec.ECGenParameterSpec +import java.security.spec.RSAKeyGenParameterSpec + +actual class EphemeralSigningKeyConfiguration internal actual constructor(): SigningKeyConfiguration() +actual class EphemeralSignerConfiguration internal actual constructor(): SignerConfiguration() + +sealed class AndroidEphemeralSigner (private val privateKey: PrivateKey) : Signer { + override val mayRequireUserUnlock = false + override suspend fun sign(data: SignatureInput) = catching { + val alg = if (data.format != null) { + (signatureAlgorithm as? SignatureAlgorithm.ECDSA).let { + require (it != null && it.digest == data.format) + { "Pre-hashed data (format ${data.format}) unsupported for algorithm $signatureAlgorithm" } + } + SignatureAlgorithm.ECDSA(digest = null, requiredCurve = null) + } else signatureAlgorithm + alg.getJCASignatureInstance(provider = null, isAndroid = false).getOrThrow().run { + initSign(privateKey) + data.data.forEach { update(it) } + sign().let { + CryptoSignature.parseFromJca(it, alg) + } + } + } + class EC (privateKey: PrivateKey, override val publicKey: CryptoPublicKey.EC, override val signatureAlgorithm: SignatureAlgorithm.ECDSA) + : AndroidEphemeralSigner(privateKey), Signer.ECDSA + + class RSA (privateKey: PrivateKey, override val publicKey: CryptoPublicKey.Rsa, override val signatureAlgorithm: SignatureAlgorithm.RSA) + : AndroidEphemeralSigner(privateKey), Signer.RSA +} + +sealed class AndroidKeyHolder (val privateKey: PrivateKey) { + + class EC internal constructor (privateKey: PrivateKey, publicKey: ECPublicKey, + val digests: Set) + : AndroidKeyHolder(privateKey), EphemeralKey.EC { + override val publicKey = CryptoPublicKey.fromJcaPublicKey(publicKey).getOrThrow() as CryptoPublicKey.EC + override fun signer(configure: DSLConfigureFn): Signer.ECDSA { + val config = DSL.resolve(::EphemeralSignerConfiguration, configure).ec.v + val digest = resolveOption("digest", digests, Digest.entries.asSequence() + sequenceOf(null), config.digestSpecified, config.digest) { it.jcaAlgorithmComponent } + return AndroidEphemeralSigner.EC(privateKey, publicKey, SignatureAlgorithm.ECDSA(digest, publicKey.curve)) + } + } + class RSA internal constructor (privateKey: PrivateKey, publicKey: RSAPublicKey, + val digests: Set, val paddings: Set) + : AndroidKeyHolder(privateKey), EphemeralKey.RSA { + override val publicKey = CryptoPublicKey.fromJcaPublicKey(publicKey).getOrThrow() as CryptoPublicKey.Rsa + override fun signer(configure: DSLConfigureFn): Signer.RSA { + val config = DSL.resolve(::EphemeralSignerConfiguration, configure).rsa.v + val digest = resolveOption("digest", digests, Digest.entries.asSequence(), config.digestSpecified, config.digest, Digest::jcaName) + val padding = resolveOption("padding", paddings, RSAPadding.entries.asSequence(), config.paddingSpecified, config.padding) { + when (it) { + RSAPadding.PKCS1 -> "PKCS1" + RSAPadding.PSS -> "PSS" + } + } + + return AndroidEphemeralSigner.RSA(privateKey, publicKey, SignatureAlgorithm.RSA(digest, padding)) + } + } +} + +internal actual fun makeEphemeralKey(configuration: EphemeralSigningKeyConfiguration) : EphemeralKey = + when (val alg = configuration._algSpecific.v) { + is SigningKeyConfiguration.ECConfiguration -> { + KeyPairGenerator.getInstance("EC").run { + initialize(ECGenParameterSpec(alg.curve.jcaName)) + generateKeyPair() + }.let { pair -> + AndroidKeyHolder.EC( + pair.private, pair.public as ECPublicKey, + digests = alg.digests) + } + } + is SigningKeyConfiguration.RSAConfiguration -> { + KeyPairGenerator.getInstance("RSA").run { + initialize(RSAKeyGenParameterSpec(alg.bits, alg.publicExponent.toJavaBigInteger())) + generateKeyPair() + }.let { pair -> + AndroidKeyHolder.RSA( + pair.private, pair.public as RSAPublicKey, + digests = alg.digests, paddings = alg.paddings) + } + } + } From 15fb42b2b363c77ee1cfc29adeb29b4ce8526b3e Mon Sep 17 00:00:00 2001 From: Jakob Heher Date: Mon, 26 Aug 2024 17:49:26 +0200 Subject: [PATCH 08/73] ios ephemeral keys --- .../signum/supreme/sign/EphemeralKeysImpl.kt | 46 +------ .../signum/supreme/sign/EphemeralKeys.kt | 32 +++++ .../signum/supreme/sign/EphemeralKeysImpl.kt | 119 ++++++++++++++++++ .../provider/os/iosKeychainProviderTests.kt | 11 -- .../signum/supreme/sign/EphemeralKeysImpl.kt | 46 +------ 5 files changed, 159 insertions(+), 95 deletions(-) create mode 100644 supreme/src/iosMain/kotlin/at/asitplus/signum/supreme/sign/EphemeralKeysImpl.kt delete mode 100644 supreme/src/iosTest/kotlin/at/asitplus/crypto/provider/os/iosKeychainProviderTests.kt diff --git a/supreme/src/androidMain/kotlin/at/asitplus/signum/supreme/sign/EphemeralKeysImpl.kt b/supreme/src/androidMain/kotlin/at/asitplus/signum/supreme/sign/EphemeralKeysImpl.kt index 60fb7ed5..1d081c73 100644 --- a/supreme/src/androidMain/kotlin/at/asitplus/signum/supreme/sign/EphemeralKeysImpl.kt +++ b/supreme/src/androidMain/kotlin/at/asitplus/signum/supreme/sign/EphemeralKeysImpl.kt @@ -4,22 +4,15 @@ import android.security.keystore.KeyProperties import at.asitplus.catching import at.asitplus.signum.indispensable.CryptoPublicKey import at.asitplus.signum.indispensable.CryptoSignature -import at.asitplus.signum.indispensable.Digest -import at.asitplus.signum.indispensable.RSAPadding import at.asitplus.signum.indispensable.SignatureAlgorithm import at.asitplus.signum.indispensable.fromJcaPublicKey import at.asitplus.signum.indispensable.getJCASignatureInstance import at.asitplus.signum.indispensable.jcaName import at.asitplus.signum.indispensable.parseFromJca -import at.asitplus.signum.supreme.dsl.DSL -import at.asitplus.signum.supreme.dsl.DSLConfigureFn import at.asitplus.signum.supreme.os.SignerConfiguration -import at.asitplus.signum.supreme.sign.resolveOption import com.ionspin.kotlin.bignum.integer.base63.toJavaBigInteger import java.security.KeyPairGenerator import java.security.PrivateKey -import java.security.interfaces.ECPublicKey -import java.security.interfaces.RSAPublicKey import java.security.spec.ECGenParameterSpec import java.security.spec.RSAKeyGenParameterSpec @@ -51,37 +44,6 @@ sealed class AndroidEphemeralSigner (private val privateKey: PrivateKey) : Signe : AndroidEphemeralSigner(privateKey), Signer.RSA } -sealed class AndroidKeyHolder (val privateKey: PrivateKey) { - - class EC internal constructor (privateKey: PrivateKey, publicKey: ECPublicKey, - val digests: Set) - : AndroidKeyHolder(privateKey), EphemeralKey.EC { - override val publicKey = CryptoPublicKey.fromJcaPublicKey(publicKey).getOrThrow() as CryptoPublicKey.EC - override fun signer(configure: DSLConfigureFn): Signer.ECDSA { - val config = DSL.resolve(::EphemeralSignerConfiguration, configure).ec.v - val digest = resolveOption("digest", digests, Digest.entries.asSequence() + sequenceOf(null), config.digestSpecified, config.digest) { it?.jcaName ?: KeyProperties.DIGEST_NONE } - return AndroidEphemeralSigner.EC(privateKey, publicKey, SignatureAlgorithm.ECDSA(digest, publicKey.curve)) - } - } - class RSA internal constructor (privateKey: PrivateKey, publicKey: RSAPublicKey, - val digests: Set, val paddings: Set) - : AndroidKeyHolder(privateKey), EphemeralKey.RSA { - override val publicKey = CryptoPublicKey.fromJcaPublicKey(publicKey).getOrThrow() as CryptoPublicKey.Rsa - override fun signer(configure: DSLConfigureFn): Signer.RSA { - val config = DSL.resolve(::EphemeralSignerConfiguration, configure).rsa.v - val digest = resolveOption("digest", digests, Digest.entries.asSequence(), config.digestSpecified, config.digest, Digest::jcaName) - val padding = resolveOption("padding", paddings, RSAPadding.entries.asSequence(), config.paddingSpecified, config.padding) { - when (it) { - RSAPadding.PKCS1 -> KeyProperties.SIGNATURE_PADDING_RSA_PKCS1 - RSAPadding.PSS -> KeyProperties.SIGNATURE_PADDING_RSA_PSS - } - } - - return AndroidEphemeralSigner.RSA(privateKey, publicKey, SignatureAlgorithm.RSA(digest, padding)) - } - } -} - internal actual fun makeEphemeralKey(configuration: EphemeralSigningKeyConfiguration) : EphemeralKey = when (val alg = configuration._algSpecific.v) { is SigningKeyConfiguration.ECConfiguration -> { @@ -89,8 +51,8 @@ internal actual fun makeEphemeralKey(configuration: EphemeralSigningKeyConfigura initialize(ECGenParameterSpec(alg.curve.jcaName)) generateKeyPair() }.let { pair -> - AndroidKeyHolder.EC( - pair.private, pair.public as ECPublicKey, + EphemeralKeyBase.EC(AndroidEphemeralSigner::EC, + pair.private, CryptoPublicKey.fromJcaPublicKey(pair.public).getOrThrow() as CryptoPublicKey.EC, digests = alg.digests) } } @@ -99,8 +61,8 @@ internal actual fun makeEphemeralKey(configuration: EphemeralSigningKeyConfigura initialize(RSAKeyGenParameterSpec(alg.bits, alg.publicExponent.toJavaBigInteger())) generateKeyPair() }.let { pair -> - AndroidKeyHolder.RSA( - pair.private, pair.public as RSAPublicKey, + EphemeralKeyBase.RSA(AndroidEphemeralSigner::RSA, + pair.private, CryptoPublicKey.fromJcaPublicKey(pair.public) as CryptoPublicKey.Rsa, digests = alg.digests, paddings = alg.paddings) } } diff --git a/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/sign/EphemeralKeys.kt b/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/sign/EphemeralKeys.kt index 7efc345a..f855f8e0 100644 --- a/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/sign/EphemeralKeys.kt +++ b/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/sign/EphemeralKeys.kt @@ -1,6 +1,9 @@ package at.asitplus.signum.supreme.sign import at.asitplus.signum.indispensable.CryptoPublicKey +import at.asitplus.signum.indispensable.Digest +import at.asitplus.signum.indispensable.RSAPadding +import at.asitplus.signum.indispensable.SignatureAlgorithm import at.asitplus.signum.supreme.dsl.DSL import at.asitplus.signum.supreme.dsl.DSLConfigureFn import at.asitplus.signum.supreme.os.SignerConfiguration @@ -26,3 +29,32 @@ sealed interface EphemeralKey { makeEphemeralKey(DSL.resolve(::EphemeralSigningKeyConfiguration, configure)) } } + +internal sealed class EphemeralKeyBase + (protected val privateKey: PrivateKeyT): EphemeralKey { + + class EC( + private val signerFactory: (PrivateKeyT, CryptoPublicKey.EC, SignatureAlgorithm.ECDSA)->SignerT, + privateKey: PrivateKeyT, override val publicKey: CryptoPublicKey.EC, + val digests: Set) : EphemeralKeyBase(privateKey), EphemeralKey.EC { + + override fun signer(configure: DSLConfigureFn): SignerT { + val config = DSL.resolve(::EphemeralSignerConfiguration, configure).ec.v + val digest = resolveOption("digest", digests, Digest.entries.asSequence() + sequenceOf(null), config.digestSpecified, config.digest) { it?.name ?: "" } + return signerFactory(privateKey, publicKey, SignatureAlgorithm.ECDSA(digest, publicKey.curve)) + } + } + + class RSA( + private val signerFactory: (PrivateKeyT, CryptoPublicKey.Rsa, SignatureAlgorithm.RSA)->SignerT, + privateKey: PrivateKeyT, override val publicKey: CryptoPublicKey.Rsa, + val digests: Set, val paddings: Set) : EphemeralKeyBase(privateKey), EphemeralKey.RSA { + + override fun signer(configure: DSLConfigureFn): SignerT { + val config = DSL.resolve(::EphemeralSignerConfiguration, configure).rsa.v + val digest = resolveOption("digest", digests, Digest.entries.asSequence(), config.digestSpecified, config.digest, Digest::name) + val padding = resolveOption("padding", paddings, RSAPadding.entries.asSequence(), config.paddingSpecified, config.padding, RSAPadding::name) + return signerFactory(privateKey, publicKey, SignatureAlgorithm.RSA(digest, padding)) + } + } +} diff --git a/supreme/src/iosMain/kotlin/at/asitplus/signum/supreme/sign/EphemeralKeysImpl.kt b/supreme/src/iosMain/kotlin/at/asitplus/signum/supreme/sign/EphemeralKeysImpl.kt new file mode 100644 index 00000000..b423a890 --- /dev/null +++ b/supreme/src/iosMain/kotlin/at/asitplus/signum/supreme/sign/EphemeralKeysImpl.kt @@ -0,0 +1,119 @@ +@file:OptIn(ExperimentalForeignApi::class) +package at.asitplus.signum.supreme.sign + +import at.asitplus.catching +import at.asitplus.signum.indispensable.CryptoPublicKey +import at.asitplus.signum.indispensable.CryptoSignature +import at.asitplus.signum.indispensable.Digest +import at.asitplus.signum.indispensable.SignatureAlgorithm +import at.asitplus.signum.indispensable.secKeyAlgorithm +import at.asitplus.signum.supreme.CFCryptoOperationFailed +import at.asitplus.signum.supreme.cfDictionaryOf +import at.asitplus.signum.supreme.corecall +import at.asitplus.signum.supreme.createCFDictionary +import at.asitplus.signum.supreme.giveToCF +import at.asitplus.signum.supreme.os.SignerConfiguration +import at.asitplus.signum.supreme.takeFromCF +import at.asitplus.signum.supreme.toByteArray +import at.asitplus.signum.supreme.toNSData +import kotlinx.cinterop.Arena +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.alloc +import kotlinx.cinterop.memScoped +import kotlinx.cinterop.ptr +import kotlinx.cinterop.value +import platform.Foundation.NSData +import platform.Security.SecKeyCopyExternalRepresentation +import platform.Security.SecKeyCreateSignature +import platform.Security.SecKeyGeneratePair +import platform.Security.SecKeyRefVar +import platform.Security.errSecSuccess +import platform.Security.kSecAttrIsPermanent +import platform.Security.kSecAttrKeySizeInBits +import platform.Security.kSecAttrKeyType +import platform.Security.kSecAttrKeyTypeEC +import platform.Security.kSecAttrKeyTypeRSA +import platform.Security.kSecKeyAlgorithmECDSASignatureDigestX962SHA1 +import platform.Security.kSecKeyAlgorithmECDSASignatureDigestX962SHA256 +import platform.Security.kSecKeyAlgorithmECDSASignatureDigestX962SHA384 +import platform.Security.kSecKeyAlgorithmECDSASignatureDigestX962SHA512 +import platform.Security.kSecPrivateKeyAttrs +import platform.Security.kSecPublicKeyAttrs +import kotlin.experimental.ExperimentalNativeApi +import kotlin.native.ref.createCleaner + +actual class EphemeralSigningKeyConfiguration internal actual constructor(): SigningKeyConfiguration() +actual class EphemeralSignerConfiguration internal actual constructor(): SignerConfiguration() + +sealed class EphemeralSigner(private val privateKey: EphemeralKeyRef): Signer { + final override val mayRequireUserUnlock: Boolean get() = false + final override suspend fun sign(data: SignatureInput) = catching { + if (data.format != null) { + (signatureAlgorithm as? SignatureAlgorithm.ECDSA).let { + require (it != null && it.digest == data.format) + { "Pre-hashed data (format ${data.format}) is unsupported by algorithm $signatureAlgorithm"} + } + } + val algorithm = when (data.format) { + Digest.SHA1 -> kSecKeyAlgorithmECDSASignatureDigestX962SHA1 + Digest.SHA256 -> kSecKeyAlgorithmECDSASignatureDigestX962SHA256 + Digest.SHA384 -> kSecKeyAlgorithmECDSASignatureDigestX962SHA384 + Digest.SHA512 -> kSecKeyAlgorithmECDSASignatureDigestX962SHA512 + null -> signatureAlgorithm.secKeyAlgorithm + } + val input = data.data.fold(byteArrayOf(), ByteArray::plus).toNSData() + val signatureBytes = corecall { + SecKeyCreateSignature(privateKey.key.value, algorithm, input.giveToCF(), error) + }.let { it.takeFromCF().toByteArray() } + return@catching when (val pubkey = publicKey) { + is CryptoPublicKey.EC -> CryptoSignature.EC.decodeFromDer(signatureBytes).withCurve(pubkey.curve) + is CryptoPublicKey.Rsa -> CryptoSignature.RSAorHMAC(signatureBytes) + } + } + class EC(privateKey: EphemeralKeyRef, override val publicKey: CryptoPublicKey.EC, + override val signatureAlgorithm: SignatureAlgorithm.ECDSA): EphemeralSigner(privateKey), Signer.ECDSA + + class RSA(privateKey: EphemeralKeyRef, override val publicKey: CryptoPublicKey.Rsa, + override val signatureAlgorithm: SignatureAlgorithm.RSA): EphemeralSigner(privateKey), Signer.RSA +} + +class EphemeralKeyRef { + private val arena = Arena() + @OptIn(ExperimentalNativeApi::class) + private val cleaner = createCleaner(arena, Arena::clear) + val key = arena.alloc() +} + +internal actual fun makeEphemeralKey(configuration: EphemeralSigningKeyConfiguration) : EphemeralKey { + val key = EphemeralKeyRef() + memScoped { + val attr = createCFDictionary { + when (val alg = configuration._algSpecific.v) { + is SigningKeyConfiguration.ECConfiguration -> { + kSecAttrKeyType mapsTo kSecAttrKeyTypeEC + kSecAttrKeySizeInBits mapsTo alg.curve.coordinateLength.bits.toInt() + } + is SigningKeyConfiguration.RSAConfiguration -> { + kSecAttrKeyType mapsTo kSecAttrKeyTypeRSA + kSecAttrKeySizeInBits mapsTo alg.bits + } + } + kSecPrivateKeyAttrs mapsTo cfDictionaryOf(kSecAttrIsPermanent to false) + kSecPublicKeyAttrs mapsTo cfDictionaryOf(kSecAttrIsPermanent to false) + } + val pubkey = alloc() + val status = SecKeyGeneratePair(attr, pubkey.ptr, key.key.ptr) + if (status != errSecSuccess) { + throw CFCryptoOperationFailed(thing = "generate ephemeral key", osStatus = status) + } + val pubkeyBytes = corecall { + SecKeyCopyExternalRepresentation(pubkey.value, error) + }.let { it.takeFromCF() }.toByteArray() + return when (val alg = configuration._algSpecific.v) { + is SigningKeyConfiguration.ECConfiguration -> + EphemeralKeyBase.EC(EphemeralSigner::EC, key, CryptoPublicKey.EC.fromAnsiX963Bytes(alg.curve, pubkeyBytes), alg.digests) + is SigningKeyConfiguration.RSAConfiguration -> + EphemeralKeyBase.RSA(EphemeralSigner::RSA, key, CryptoPublicKey.Rsa.fromPKCS1encoded(pubkeyBytes), alg.digests, alg.paddings) + } + } +} diff --git a/supreme/src/iosTest/kotlin/at/asitplus/crypto/provider/os/iosKeychainProviderTests.kt b/supreme/src/iosTest/kotlin/at/asitplus/crypto/provider/os/iosKeychainProviderTests.kt deleted file mode 100644 index da6e8db5..00000000 --- a/supreme/src/iosTest/kotlin/at/asitplus/crypto/provider/os/iosKeychainProviderTests.kt +++ /dev/null @@ -1,11 +0,0 @@ -package at.asitplus.crypto.provider.os - -import at.asitplus.crypto.datatypes.CryptoPublicKey -import io.kotest.core.spec.style.FreeSpec -import io.kotest.matchers.types.shouldBeInstanceOf - -class iosKeychainProviderTests : FreeSpec({ - "it works" { - IosKeychainProvider.createSigningKey("Bartschloss").getOrThrow().shouldBeInstanceOf() - } -}) diff --git a/supreme/src/jvmMain/kotlin/at/asitplus/signum/supreme/sign/EphemeralKeysImpl.kt b/supreme/src/jvmMain/kotlin/at/asitplus/signum/supreme/sign/EphemeralKeysImpl.kt index bb1d1643..909d1a6e 100644 --- a/supreme/src/jvmMain/kotlin/at/asitplus/signum/supreme/sign/EphemeralKeysImpl.kt +++ b/supreme/src/jvmMain/kotlin/at/asitplus/signum/supreme/sign/EphemeralKeysImpl.kt @@ -3,22 +3,15 @@ package at.asitplus.signum.supreme.sign import at.asitplus.catching import at.asitplus.signum.indispensable.CryptoPublicKey import at.asitplus.signum.indispensable.CryptoSignature -import at.asitplus.signum.indispensable.Digest -import at.asitplus.signum.indispensable.RSAPadding import at.asitplus.signum.indispensable.SignatureAlgorithm import at.asitplus.signum.indispensable.fromJcaPublicKey import at.asitplus.signum.indispensable.getJCASignatureInstance -import at.asitplus.signum.indispensable.jcaAlgorithmComponent import at.asitplus.signum.indispensable.jcaName import at.asitplus.signum.indispensable.parseFromJca -import at.asitplus.signum.supreme.dsl.DSL -import at.asitplus.signum.supreme.dsl.DSLConfigureFn import at.asitplus.signum.supreme.os.SignerConfiguration import com.ionspin.kotlin.bignum.integer.base63.toJavaBigInteger import java.security.KeyPairGenerator import java.security.PrivateKey -import java.security.interfaces.ECPublicKey -import java.security.interfaces.RSAPublicKey import java.security.spec.ECGenParameterSpec import java.security.spec.RSAKeyGenParameterSpec @@ -50,37 +43,6 @@ sealed class AndroidEphemeralSigner (private val privateKey: PrivateKey) : Signe : AndroidEphemeralSigner(privateKey), Signer.RSA } -sealed class AndroidKeyHolder (val privateKey: PrivateKey) { - - class EC internal constructor (privateKey: PrivateKey, publicKey: ECPublicKey, - val digests: Set) - : AndroidKeyHolder(privateKey), EphemeralKey.EC { - override val publicKey = CryptoPublicKey.fromJcaPublicKey(publicKey).getOrThrow() as CryptoPublicKey.EC - override fun signer(configure: DSLConfigureFn): Signer.ECDSA { - val config = DSL.resolve(::EphemeralSignerConfiguration, configure).ec.v - val digest = resolveOption("digest", digests, Digest.entries.asSequence() + sequenceOf(null), config.digestSpecified, config.digest) { it.jcaAlgorithmComponent } - return AndroidEphemeralSigner.EC(privateKey, publicKey, SignatureAlgorithm.ECDSA(digest, publicKey.curve)) - } - } - class RSA internal constructor (privateKey: PrivateKey, publicKey: RSAPublicKey, - val digests: Set, val paddings: Set) - : AndroidKeyHolder(privateKey), EphemeralKey.RSA { - override val publicKey = CryptoPublicKey.fromJcaPublicKey(publicKey).getOrThrow() as CryptoPublicKey.Rsa - override fun signer(configure: DSLConfigureFn): Signer.RSA { - val config = DSL.resolve(::EphemeralSignerConfiguration, configure).rsa.v - val digest = resolveOption("digest", digests, Digest.entries.asSequence(), config.digestSpecified, config.digest, Digest::jcaName) - val padding = resolveOption("padding", paddings, RSAPadding.entries.asSequence(), config.paddingSpecified, config.padding) { - when (it) { - RSAPadding.PKCS1 -> "PKCS1" - RSAPadding.PSS -> "PSS" - } - } - - return AndroidEphemeralSigner.RSA(privateKey, publicKey, SignatureAlgorithm.RSA(digest, padding)) - } - } -} - internal actual fun makeEphemeralKey(configuration: EphemeralSigningKeyConfiguration) : EphemeralKey = when (val alg = configuration._algSpecific.v) { is SigningKeyConfiguration.ECConfiguration -> { @@ -88,8 +50,8 @@ internal actual fun makeEphemeralKey(configuration: EphemeralSigningKeyConfigura initialize(ECGenParameterSpec(alg.curve.jcaName)) generateKeyPair() }.let { pair -> - AndroidKeyHolder.EC( - pair.private, pair.public as ECPublicKey, + EphemeralKeyBase.EC(AndroidEphemeralSigner::EC, + pair.private, CryptoPublicKey.fromJcaPublicKey(pair.public).getOrThrow() as CryptoPublicKey.EC, digests = alg.digests) } } @@ -98,8 +60,8 @@ internal actual fun makeEphemeralKey(configuration: EphemeralSigningKeyConfigura initialize(RSAKeyGenParameterSpec(alg.bits, alg.publicExponent.toJavaBigInteger())) generateKeyPair() }.let { pair -> - AndroidKeyHolder.RSA( - pair.private, pair.public as RSAPublicKey, + EphemeralKeyBase.RSA(AndroidEphemeralSigner::RSA, + pair.private, CryptoPublicKey.fromJcaPublicKey(pair.public).getOrThrow() as CryptoPublicKey.Rsa, digests = alg.digests, paddings = alg.paddings) } } From 47ec231ffc4cc6bff9a8ece40433e1343e49e070 Mon Sep 17 00:00:00 2001 From: Jakob Heher Date: Mon, 26 Aug 2024 18:34:20 +0200 Subject: [PATCH 09/73] change defaults for ephemeral keys to be super permissive --- .../supreme/os/AndroidKeyStoreProvider.kt | 25 ++++++++- .../signum/supreme/sign/EphemeralKeysImpl.kt | 3 -- .../signum/supreme/sign/EphemeralKeys.kt | 54 +++++++++++++++++-- .../signum/supreme/sign/InternalCommonUtil.kt | 29 ---------- .../signum/supreme/sign/EphemeralKeysImpl.kt | 3 -- .../signum/supreme/sign/EphemeralKeysImpl.kt | 3 -- 6 files changed, 73 insertions(+), 44 deletions(-) delete mode 100644 supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/sign/InternalCommonUtil.kt diff --git a/supreme/src/androidMain/kotlin/at/asitplus/signum/supreme/os/AndroidKeyStoreProvider.kt b/supreme/src/androidMain/kotlin/at/asitplus/signum/supreme/os/AndroidKeyStoreProvider.kt index 2c366d6b..762a1b86 100644 --- a/supreme/src/androidMain/kotlin/at/asitplus/signum/supreme/os/AndroidKeyStoreProvider.kt +++ b/supreme/src/androidMain/kotlin/at/asitplus/signum/supreme/os/AndroidKeyStoreProvider.kt @@ -34,7 +34,6 @@ import at.asitplus.signum.supreme.dsl.PREFERRED import at.asitplus.signum.supreme.dsl.REQUIRED import at.asitplus.signum.supreme.sign.SignatureInput import at.asitplus.signum.supreme.sign.SigningKeyConfiguration -import at.asitplus.signum.supreme.sign.resolveOption import com.ionspin.kotlin.bignum.integer.base63.toJavaBigInteger import at.asitplus.signum.supreme.sign.Signer as SignerI import kotlinx.coroutines.CoroutineScope @@ -79,6 +78,30 @@ class AndroidSignerConfiguration: PlatformSignerConfiguration() { override val unlockPrompt = childOrDefault(::AuthnPrompt) } +/** + * Resolve [what] differently based on whether the [v]alue was [spec]ified. + * + * * [spec] = `true`: Check if [valid] contains [nameMap] applied to [v], return [v] if yes, throw otherwise + * * [spec] = `false`: Check if [valid] contains exactly one element, if yes, return the [E] from [possible] for which [nameMap] returns that element, throw otherwise + */ +internal inline fun resolveOption(what: String, valid: Array, possible: Sequence, spec: Boolean, v: E, crossinline nameMap: (E)->String): E = + when (spec) { + true -> { + val vStr = nameMap(v) + if (!valid.any { it.equals(vStr, ignoreCase=true) }) + throw IllegalArgumentException("Key does not support $what $v; supported: ${valid.joinToString(", ")}") + v + } + false -> { + if (valid.size != 1) + throw IllegalArgumentException("Key supports multiple ${what}s (${valid.joinToString(", ")}). You need to specify $what in signer configuration.") + val only = valid.first() + possible.find { + nameMap(it).equals(only, ignoreCase=true) + } ?: throw UnsupportedCryptoException("Unsupported $what $only") + } + } + private fun attestationFor(chain: CertificateChain) = if (chain.size > 1) AndroidKeystoreAttestation(chain) else null diff --git a/supreme/src/androidMain/kotlin/at/asitplus/signum/supreme/sign/EphemeralKeysImpl.kt b/supreme/src/androidMain/kotlin/at/asitplus/signum/supreme/sign/EphemeralKeysImpl.kt index 1d081c73..325e7d9b 100644 --- a/supreme/src/androidMain/kotlin/at/asitplus/signum/supreme/sign/EphemeralKeysImpl.kt +++ b/supreme/src/androidMain/kotlin/at/asitplus/signum/supreme/sign/EphemeralKeysImpl.kt @@ -16,9 +16,6 @@ import java.security.PrivateKey import java.security.spec.ECGenParameterSpec import java.security.spec.RSAKeyGenParameterSpec -actual class EphemeralSigningKeyConfiguration internal actual constructor(): SigningKeyConfiguration() -actual class EphemeralSignerConfiguration internal actual constructor(): SignerConfiguration() - sealed class AndroidEphemeralSigner (private val privateKey: PrivateKey) : Signer { override val mayRequireUserUnlock = false override suspend fun sign(data: SignatureInput) = catching { diff --git a/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/sign/EphemeralKeys.kt b/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/sign/EphemeralKeys.kt index f855f8e0..03ad2550 100644 --- a/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/sign/EphemeralKeys.kt +++ b/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/sign/EphemeralKeys.kt @@ -10,8 +10,17 @@ import at.asitplus.signum.supreme.os.SignerConfiguration internal expect fun makeEphemeralKey(configuration: EphemeralSigningKeyConfiguration) : EphemeralKey -expect class EphemeralSigningKeyConfiguration internal constructor(): SigningKeyConfiguration -expect class EphemeralSignerConfiguration internal constructor(): SignerConfiguration +class EphemeralSigningKeyConfiguration internal constructor(): SigningKeyConfiguration() { + class ECConfiguration internal constructor(): SigningKeyConfiguration.ECConfiguration() { + init { digests = (Digest.entries.asSequence() + sequenceOf(null)).toSet() } + } + override val ec = _algSpecific.option(::ECConfiguration) + class RSAConfiguration internal constructor(): SigningKeyConfiguration.RSAConfiguration() { + init { digests = Digest.entries.toSet(); paddings = RSAPadding.entries.toSet() } + } + override val rsa = _algSpecific.option(::RSAConfiguration) +} +typealias EphemeralSignerConfiguration = SignerConfiguration sealed interface EphemeralKey { val publicKey: CryptoPublicKey @@ -40,7 +49,19 @@ internal sealed class EphemeralKeyBase override fun signer(configure: DSLConfigureFn): SignerT { val config = DSL.resolve(::EphemeralSignerConfiguration, configure).ec.v - val digest = resolveOption("digest", digests, Digest.entries.asSequence() + sequenceOf(null), config.digestSpecified, config.digest) { it?.name ?: "" } + val digest = when (config.digestSpecified) { + true -> { + require (digests.contains(config.digest)) + { "Digest ${config.digest} unsupported (supported: ${digests.joinToString(",")}" } + config.digest + } + false -> when { + digests.contains(Digest.SHA256) -> Digest.SHA256 + digests.contains(Digest.SHA384) -> Digest.SHA384 + digests.contains(Digest.SHA512) -> Digest.SHA512 + else -> digests.first() + } + } return signerFactory(privateKey, publicKey, SignatureAlgorithm.ECDSA(digest, publicKey.curve)) } } @@ -52,8 +73,31 @@ internal sealed class EphemeralKeyBase override fun signer(configure: DSLConfigureFn): SignerT { val config = DSL.resolve(::EphemeralSignerConfiguration, configure).rsa.v - val digest = resolveOption("digest", digests, Digest.entries.asSequence(), config.digestSpecified, config.digest, Digest::name) - val padding = resolveOption("padding", paddings, RSAPadding.entries.asSequence(), config.paddingSpecified, config.padding, RSAPadding::name) + val digest = when (config.digestSpecified) { + true -> { + require (digests.contains(config.digest)) + { "Digest ${config.digest} unsupported (supported: ${digests.joinToString(", ")}" } + config.digest + } + false -> when { + digests.contains(Digest.SHA256) -> Digest.SHA256 + digests.contains(Digest.SHA384) -> Digest.SHA384 + digests.contains(Digest.SHA512) -> Digest.SHA512 + else -> digests.first() + } + } + val padding = when (config.paddingSpecified) { + true -> { + require (paddings.contains(config.padding)) + { "Padding ${config.padding} unsupported (supported: ${paddings.joinToString(", ")}" } + config.padding + } + false -> when { + paddings.contains(RSAPadding.PSS) -> RSAPadding.PSS + paddings.contains(RSAPadding.PKCS1) -> RSAPadding.PKCS1 + else -> paddings.first() + } + } return signerFactory(privateKey, publicKey, SignatureAlgorithm.RSA(digest, padding)) } } diff --git a/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/sign/InternalCommonUtil.kt b/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/sign/InternalCommonUtil.kt deleted file mode 100644 index 3aab42e4..00000000 --- a/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/sign/InternalCommonUtil.kt +++ /dev/null @@ -1,29 +0,0 @@ -package at.asitplus.signum.supreme.sign - -import at.asitplus.signum.supreme.UnsupportedCryptoException - -/** - * Resolve [what] differently based on whether the [v]alue was [spec]ified. - * - * * [spec] = `true`: Check if [valid] contains [nameMap] applied to [v], return [v] if yes, throw otherwise - * * [spec] = `false`: Check if [valid] contains exactly one element, if yes, return the [E] from [possible] for which [nameMap] returns that element, throw otherwise - */ -internal inline fun resolveOption(what: String, valid: Array, possible: Sequence, spec: Boolean, v: E, crossinline nameMap: (E)->String): E = - when (spec) { - true -> { - val vStr = nameMap(v) - if (!valid.any { it.equals(vStr, ignoreCase=true) }) - throw IllegalArgumentException("Key does not support $what $v; supported: ${valid.joinToString(", ")}") - v - } - false -> { - if (valid.size != 1) - throw IllegalArgumentException("Key supports multiple ${what}s (${valid.joinToString(", ")}). You need to specify $what in signer configuration.") - val only = valid.first() - possible.find { - nameMap(it).equals(only, ignoreCase=true) - } ?: throw UnsupportedCryptoException("Unsupported $what $only") - } - } -internal inline fun resolveOption(what: String, valid: Set, possible: Sequence, spec: Boolean, v: E, crossinline nameMap: (E)->String): E = - resolveOption(what, valid.map(nameMap).toTypedArray(), possible, spec, v, nameMap) diff --git a/supreme/src/iosMain/kotlin/at/asitplus/signum/supreme/sign/EphemeralKeysImpl.kt b/supreme/src/iosMain/kotlin/at/asitplus/signum/supreme/sign/EphemeralKeysImpl.kt index b423a890..202dcc58 100644 --- a/supreme/src/iosMain/kotlin/at/asitplus/signum/supreme/sign/EphemeralKeysImpl.kt +++ b/supreme/src/iosMain/kotlin/at/asitplus/signum/supreme/sign/EphemeralKeysImpl.kt @@ -42,9 +42,6 @@ import platform.Security.kSecPublicKeyAttrs import kotlin.experimental.ExperimentalNativeApi import kotlin.native.ref.createCleaner -actual class EphemeralSigningKeyConfiguration internal actual constructor(): SigningKeyConfiguration() -actual class EphemeralSignerConfiguration internal actual constructor(): SignerConfiguration() - sealed class EphemeralSigner(private val privateKey: EphemeralKeyRef): Signer { final override val mayRequireUserUnlock: Boolean get() = false final override suspend fun sign(data: SignatureInput) = catching { diff --git a/supreme/src/jvmMain/kotlin/at/asitplus/signum/supreme/sign/EphemeralKeysImpl.kt b/supreme/src/jvmMain/kotlin/at/asitplus/signum/supreme/sign/EphemeralKeysImpl.kt index 909d1a6e..88670102 100644 --- a/supreme/src/jvmMain/kotlin/at/asitplus/signum/supreme/sign/EphemeralKeysImpl.kt +++ b/supreme/src/jvmMain/kotlin/at/asitplus/signum/supreme/sign/EphemeralKeysImpl.kt @@ -15,9 +15,6 @@ import java.security.PrivateKey import java.security.spec.ECGenParameterSpec import java.security.spec.RSAKeyGenParameterSpec -actual class EphemeralSigningKeyConfiguration internal actual constructor(): SigningKeyConfiguration() -actual class EphemeralSignerConfiguration internal actual constructor(): SignerConfiguration() - sealed class AndroidEphemeralSigner (private val privateKey: PrivateKey) : Signer { override val mayRequireUserUnlock = false override suspend fun sign(data: SignatureInput) = catching { From 0dd5845745cae9aa24a17cf9f7da6649466ed6e9 Mon Sep 17 00:00:00 2001 From: Jakob Heher Date: Tue, 27 Aug 2024 11:38:47 +0200 Subject: [PATCH 10/73] ios digest cleanup --- .../iosMain/kotlin/CommonCryptoExtensions.kt | 41 ++++++++++++++ settings.gradle.kts | 6 -- supreme/build.gradle.kts | 4 -- .../signum/supreme/hash/DigestImpl.kt | 10 ++++ .../signum/supreme/hash/DigestExtensions.kt | 15 +---- .../sign/EphemeralSignerCommonTests.kt | 53 ++++++++++++++---- .../signum/supreme/hash/DigestImpl.kt | 55 +++++++++++++++++++ .../signum/supreme/sign/EphemeralKeysImpl.kt | 29 +++------- .../signum/supreme/hash/DigestImpl.kt | 10 ++++ 9 files changed, 168 insertions(+), 55 deletions(-) create mode 100644 supreme/src/androidMain/kotlin/at/asitplus/signum/supreme/hash/DigestImpl.kt create mode 100644 supreme/src/iosMain/kotlin/at/asitplus/signum/supreme/hash/DigestImpl.kt create mode 100644 supreme/src/jvmMain/kotlin/at/asitplus/signum/supreme/hash/DigestImpl.kt diff --git a/indispensable/src/iosMain/kotlin/CommonCryptoExtensions.kt b/indispensable/src/iosMain/kotlin/CommonCryptoExtensions.kt index 03a76775..c69c6bcf 100644 --- a/indispensable/src/iosMain/kotlin/CommonCryptoExtensions.kt +++ b/indispensable/src/iosMain/kotlin/CommonCryptoExtensions.kt @@ -3,10 +3,22 @@ package at.asitplus.signum.indispensable import kotlinx.cinterop.ExperimentalForeignApi import platform.Security.SecKeyAlgorithm +import platform.Security.kSecKeyAlgorithmECDSASignatureDigestX962SHA1 +import platform.Security.kSecKeyAlgorithmECDSASignatureDigestX962SHA256 +import platform.Security.kSecKeyAlgorithmECDSASignatureDigestX962SHA384 +import platform.Security.kSecKeyAlgorithmECDSASignatureDigestX962SHA512 import platform.Security.kSecKeyAlgorithmECDSASignatureMessageX962SHA1 import platform.Security.kSecKeyAlgorithmECDSASignatureMessageX962SHA256 import platform.Security.kSecKeyAlgorithmECDSASignatureMessageX962SHA384 import platform.Security.kSecKeyAlgorithmECDSASignatureMessageX962SHA512 +import platform.Security.kSecKeyAlgorithmRSASignatureDigestPKCS1v15SHA1 +import platform.Security.kSecKeyAlgorithmRSASignatureDigestPKCS1v15SHA256 +import platform.Security.kSecKeyAlgorithmRSASignatureDigestPKCS1v15SHA384 +import platform.Security.kSecKeyAlgorithmRSASignatureDigestPKCS1v15SHA512 +import platform.Security.kSecKeyAlgorithmRSASignatureDigestPSSSHA1 +import platform.Security.kSecKeyAlgorithmRSASignatureDigestPSSSHA256 +import platform.Security.kSecKeyAlgorithmRSASignatureDigestPSSSHA384 +import platform.Security.kSecKeyAlgorithmRSASignatureDigestPSSSHA512 import platform.Security.kSecKeyAlgorithmRSASignatureMessagePKCS1v15SHA1 import platform.Security.kSecKeyAlgorithmRSASignatureMessagePKCS1v15SHA256 import platform.Security.kSecKeyAlgorithmRSASignatureMessagePKCS1v15SHA384 @@ -44,3 +56,32 @@ val SignatureAlgorithm.secKeyAlgorithm : SecKeyAlgorithm get() = when (this) { } is SignatureAlgorithm.HMAC -> TODO("HMAC is unsupported") }!! + +val SignatureAlgorithm.secKeyAlgorithmPreHashed: SecKeyAlgorithm get() = when (this) { + is SignatureAlgorithm.ECDSA -> { + when (digest) { + Digest.SHA1 -> kSecKeyAlgorithmECDSASignatureDigestX962SHA1 + Digest.SHA256 -> kSecKeyAlgorithmECDSASignatureDigestX962SHA256 + Digest.SHA384 -> kSecKeyAlgorithmECDSASignatureDigestX962SHA384 + Digest.SHA512 -> kSecKeyAlgorithmECDSASignatureDigestX962SHA512 + else -> throw IllegalArgumentException("Raw signing is not supported on iOS") + } + } + is SignatureAlgorithm.RSA -> { + when (padding) { + RSAPadding.PSS -> when (digest) { + Digest.SHA1 -> kSecKeyAlgorithmRSASignatureDigestPSSSHA1 + Digest.SHA256 -> kSecKeyAlgorithmRSASignatureDigestPSSSHA256 + Digest.SHA384 -> kSecKeyAlgorithmRSASignatureDigestPSSSHA384 + Digest.SHA512 -> kSecKeyAlgorithmRSASignatureDigestPSSSHA512 + } + RSAPadding.PKCS1 -> when (digest) { + Digest.SHA1 -> kSecKeyAlgorithmRSASignatureDigestPKCS1v15SHA1 + Digest.SHA256 -> kSecKeyAlgorithmRSASignatureDigestPKCS1v15SHA256 + Digest.SHA384 -> kSecKeyAlgorithmRSASignatureDigestPKCS1v15SHA384 + Digest.SHA512 -> kSecKeyAlgorithmRSASignatureDigestPKCS1v15SHA512 + } + } + } + is SignatureAlgorithm.HMAC -> TODO("HMAC is unsupported") +}!! diff --git a/settings.gradle.kts b/settings.gradle.kts index 76aec1ac..d4c914d6 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -16,12 +16,6 @@ dependencyResolutionManagement { google() mavenCentral() } - versionCatalogs { - create("kotlincrypto") { - // https://github.com/KotlinCrypto/version-catalog/blob/master/gradle/kotlincrypto.versions.toml - from("org.kotlincrypto:version-catalog:0.5.2") - } - } } include(":indispensable") diff --git a/supreme/build.gradle.kts b/supreme/build.gradle.kts index c2893995..87bcc678 100644 --- a/supreme/build.gradle.kts +++ b/supreme/build.gradle.kts @@ -52,10 +52,6 @@ kotlin { implementation(coroutines()) implementation(napier()) api(project(":indispensable")) - api(kotlincrypto.core.digest) - implementation(kotlincrypto.hash.sha1) - implementation(kotlincrypto.hash.sha2) - implementation(kotlincrypto.secureRandom) } sourceSets.jvmTest.dependencies { implementation("io.kotest.extensions:kotest-assertions-compiler:1.0.0") diff --git a/supreme/src/androidMain/kotlin/at/asitplus/signum/supreme/hash/DigestImpl.kt b/supreme/src/androidMain/kotlin/at/asitplus/signum/supreme/hash/DigestImpl.kt new file mode 100644 index 00000000..f2c85fee --- /dev/null +++ b/supreme/src/androidMain/kotlin/at/asitplus/signum/supreme/hash/DigestImpl.kt @@ -0,0 +1,10 @@ +package at.asitplus.signum.supreme.hash + +import at.asitplus.signum.indispensable.Digest +import at.asitplus.signum.indispensable.jcaName +import java.security.MessageDigest + +internal actual fun doDigest(digest: Digest, data: Sequence): ByteArray = + MessageDigest.getInstance(digest.jcaName).apply { + data.forEach { update(it) } + }.digest() diff --git a/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/hash/DigestExtensions.kt b/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/hash/DigestExtensions.kt index 1ffccc2c..20fbcdc9 100644 --- a/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/hash/DigestExtensions.kt +++ b/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/hash/DigestExtensions.kt @@ -1,16 +1,7 @@ package at.asitplus.signum.supreme.hash import at.asitplus.signum.indispensable.Digest -import org.kotlincrypto.hash.sha1.SHA1 -import org.kotlincrypto.hash.sha2.SHA256 -import org.kotlincrypto.hash.sha2.SHA384 -import org.kotlincrypto.hash.sha2.SHA512 -operator fun Digest.invoke(): org.kotlincrypto.core.digest.Digest = when(this) { - Digest.SHA1 -> SHA1() - Digest.SHA256 -> SHA256() - Digest.SHA384 -> SHA384() - Digest.SHA512 -> SHA512() -} -inline fun Digest.digest(data: Sequence) = this().also { data.forEach(it::update) }.digest() -inline fun Digest.digest(bytes: ByteArray) = this().digest(bytes) +internal expect fun doDigest(digest: Digest, data: Sequence): ByteArray +fun Digest.digest(data: Sequence) = doDigest(this, data) +inline fun Digest.digest(bytes: ByteArray) = this.digest(sequenceOf(bytes)) diff --git a/supreme/src/commonTest/kotlin/at/asitplus/signum/supreme/sign/EphemeralSignerCommonTests.kt b/supreme/src/commonTest/kotlin/at/asitplus/signum/supreme/sign/EphemeralSignerCommonTests.kt index 9e4cbf7a..3bd55bbc 100644 --- a/supreme/src/commonTest/kotlin/at/asitplus/signum/supreme/sign/EphemeralSignerCommonTests.kt +++ b/supreme/src/commonTest/kotlin/at/asitplus/signum/supreme/sign/EphemeralSignerCommonTests.kt @@ -1,25 +1,54 @@ package at.asitplus.signum.supreme.sign +import at.asitplus.signum.indispensable.Digest +import at.asitplus.signum.indispensable.ECCurve +import at.asitplus.signum.indispensable.RSAPadding import at.asitplus.signum.supreme.succeed +import com.ionspin.kotlin.bignum.integer.Quadruple import io.kotest.core.spec.style.FreeSpec +import io.kotest.datatest.withData import io.kotest.matchers.should import kotlin.random.Random class EphemeralSignerCommonTests : FreeSpec({ - "RSA".config(invocations = 5) { - val signer = Signer { rsa {} } - val data = Random.Default.nextBytes(64) - val signature = signer.sign(data).getOrThrow() + "RSA" - { + withData(nameFn = { (pad,dig,bits,pre) -> "$dig/$pad/${bits}bit${if (pre) "/pre" else ""}" }, sequence { + RSAPadding.entries.forEach { padding -> + Digest.entries.forEach { digest -> + when { + digest == Digest.SHA512 && padding == RSAPadding.PSS -> listOf(2048, 3072, 4096) + digest == Digest.SHA384 || digest == Digest.SHA512 || padding == RSAPadding.PSS -> listOf(1024, 2048, 3072, 4096) + else -> listOf(512, 1024, 2048, 3072, 4096) + }.forEach { keySize -> + yield(Quadruple(padding, digest, keySize, false)) + yield(Quadruple(padding, digest, keySize, true)) + } + } + } + }) { (padding, digest, keySize, preHashed) -> + val signer = Signer { rsa { digests = setOf(digest); paddings = setOf(padding); bits = keySize } } + val data = Random.Default.nextBytes(64) + val signature = signer.sign(SignatureInput(data).let { if (preHashed) it.convertTo(digest).getOrThrow() else it }).getOrThrow() - val verifier = signer.makeVerifier().getOrThrow() - verifier.verify(data, signature) should succeed + val verifier = signer.makeVerifier().getOrThrow() + verifier.verify(data, signature) should succeed + } } - "ECDSA".config(invocations = 5) { - val signer = Signer { ec {} } - val data = Random.Default.nextBytes(64) - val signature = signer.sign(data).getOrThrow() + "ECDSA" - { + withData(nameFn = { (crv,dig,pre) -> "$crv/$dig${if (pre) "/pre" else ""}" }, sequence { + ECCurve.entries.forEach { curve -> + Digest.entries.forEach { digest -> + yield(Triple(curve, digest, false)) + yield(Triple(curve, digest, true)) + } + } + }) { (crv, digest, preHashed) -> + val signer = Signer { ec { curve = crv; digests = setOf(digest) } } + val data = Random.Default.nextBytes(64) + val signature = signer.sign(SignatureInput(data).let { if (preHashed) it.convertTo(digest).getOrThrow() else it }).getOrThrow() - val verifier = signer.makeVerifier().getOrThrow() - verifier.verify(data, signature) should succeed + val verifier = signer.makeVerifier().getOrThrow() + verifier.verify(data, signature) should succeed + } } }) diff --git a/supreme/src/iosMain/kotlin/at/asitplus/signum/supreme/hash/DigestImpl.kt b/supreme/src/iosMain/kotlin/at/asitplus/signum/supreme/hash/DigestImpl.kt new file mode 100644 index 00000000..a139cf1a --- /dev/null +++ b/supreme/src/iosMain/kotlin/at/asitplus/signum/supreme/hash/DigestImpl.kt @@ -0,0 +1,55 @@ +@file:OptIn(ExperimentalForeignApi::class) +package at.asitplus.signum.supreme.hash + +import at.asitplus.signum.indispensable.Digest +import kotlinx.cinterop.CValuesRef +import kotlinx.cinterop.CVariable +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.UByteVar +import kotlinx.cinterop.addressOf +import kotlinx.cinterop.alloc +import kotlinx.cinterop.memScoped +import kotlinx.cinterop.objcPtr +import kotlinx.cinterop.ptr +import kotlinx.cinterop.usePinned +import platform.CoreCrypto.CC_LONG +import platform.CoreCrypto.CC_SHA1_Final +import platform.CoreCrypto.CC_SHA1_Init +import platform.CoreCrypto.CC_SHA1_Update +import platform.CoreCrypto.CC_SHA256_Final +import platform.CoreCrypto.CC_SHA256_Init +import platform.CoreCrypto.CC_SHA256_Update +import platform.CoreCrypto.CC_SHA384_Final +import platform.CoreCrypto.CC_SHA384_Init +import platform.CoreCrypto.CC_SHA384_Update +import platform.CoreCrypto.CC_SHA512_Final +import platform.CoreCrypto.CC_SHA512_Init +import platform.CoreCrypto.CC_SHA512_Update + +private inline fun digestTemplate( + data: Sequence, + outputLength: Int, + init: (CValuesRef)->Int, + update: (CValuesRef, CValuesRef<*>?, CC_LONG)->Int, + finalize: (CValuesRef, CValuesRef)->Int +): ByteArray { + memScoped { + val ctx = alloc() + init(ctx.ptr) + data.forEach { a -> + if (a.isNotEmpty()) + a.usePinned { update(ctx.ptr, it.addressOf(0), a.size.toUInt()) } + else + a.usePinned { update(ctx.ptr, null, a.size.toUInt()) } + } + val output = UByteArray(outputLength) + output.usePinned { finalize(it.addressOf(0), ctx.ptr) } + return output.toByteArray() + } +} +internal actual fun doDigest(digest: Digest, data: Sequence): ByteArray = when(digest) { + Digest.SHA1 -> digestTemplate(data, digest.outputLength.bytes.toInt(), ::CC_SHA1_Init, ::CC_SHA1_Update, ::CC_SHA1_Final) + Digest.SHA256 -> digestTemplate(data, digest.outputLength.bytes.toInt(), ::CC_SHA256_Init, ::CC_SHA256_Update, ::CC_SHA256_Final) + Digest.SHA384 -> digestTemplate(data, digest.outputLength.bytes.toInt(), ::CC_SHA384_Init, ::CC_SHA384_Update, ::CC_SHA384_Final) + Digest.SHA512 -> digestTemplate(data, digest.outputLength.bytes.toInt(), ::CC_SHA512_Init, ::CC_SHA512_Update, ::CC_SHA512_Final) +} diff --git a/supreme/src/iosMain/kotlin/at/asitplus/signum/supreme/sign/EphemeralKeysImpl.kt b/supreme/src/iosMain/kotlin/at/asitplus/signum/supreme/sign/EphemeralKeysImpl.kt index 202dcc58..2fa6df1f 100644 --- a/supreme/src/iosMain/kotlin/at/asitplus/signum/supreme/sign/EphemeralKeysImpl.kt +++ b/supreme/src/iosMain/kotlin/at/asitplus/signum/supreme/sign/EphemeralKeysImpl.kt @@ -4,15 +4,13 @@ package at.asitplus.signum.supreme.sign import at.asitplus.catching import at.asitplus.signum.indispensable.CryptoPublicKey import at.asitplus.signum.indispensable.CryptoSignature -import at.asitplus.signum.indispensable.Digest import at.asitplus.signum.indispensable.SignatureAlgorithm -import at.asitplus.signum.indispensable.secKeyAlgorithm +import at.asitplus.signum.indispensable.secKeyAlgorithmPreHashed import at.asitplus.signum.supreme.CFCryptoOperationFailed import at.asitplus.signum.supreme.cfDictionaryOf import at.asitplus.signum.supreme.corecall import at.asitplus.signum.supreme.createCFDictionary import at.asitplus.signum.supreme.giveToCF -import at.asitplus.signum.supreme.os.SignerConfiguration import at.asitplus.signum.supreme.takeFromCF import at.asitplus.signum.supreme.toByteArray import at.asitplus.signum.supreme.toNSData @@ -33,10 +31,6 @@ import platform.Security.kSecAttrKeySizeInBits import platform.Security.kSecAttrKeyType import platform.Security.kSecAttrKeyTypeEC import platform.Security.kSecAttrKeyTypeRSA -import platform.Security.kSecKeyAlgorithmECDSASignatureDigestX962SHA1 -import platform.Security.kSecKeyAlgorithmECDSASignatureDigestX962SHA256 -import platform.Security.kSecKeyAlgorithmECDSASignatureDigestX962SHA384 -import platform.Security.kSecKeyAlgorithmECDSASignatureDigestX962SHA512 import platform.Security.kSecPrivateKeyAttrs import platform.Security.kSecPublicKeyAttrs import kotlin.experimental.ExperimentalNativeApi @@ -45,20 +39,13 @@ import kotlin.native.ref.createCleaner sealed class EphemeralSigner(private val privateKey: EphemeralKeyRef): Signer { final override val mayRequireUserUnlock: Boolean get() = false final override suspend fun sign(data: SignatureInput) = catching { - if (data.format != null) { - (signatureAlgorithm as? SignatureAlgorithm.ECDSA).let { - require (it != null && it.digest == data.format) - { "Pre-hashed data (format ${data.format}) is unsupported by algorithm $signatureAlgorithm"} - } - } - val algorithm = when (data.format) { - Digest.SHA1 -> kSecKeyAlgorithmECDSASignatureDigestX962SHA1 - Digest.SHA256 -> kSecKeyAlgorithmECDSASignatureDigestX962SHA256 - Digest.SHA384 -> kSecKeyAlgorithmECDSASignatureDigestX962SHA384 - Digest.SHA512 -> kSecKeyAlgorithmECDSASignatureDigestX962SHA512 - null -> signatureAlgorithm.secKeyAlgorithm - } - val input = data.data.fold(byteArrayOf(), ByteArray::plus).toNSData() + val inputData = data.convertTo(when (val alg = signatureAlgorithm) { + is SignatureAlgorithm.RSA -> alg.digest + is SignatureAlgorithm.ECDSA -> alg.digest + else -> TODO("hmac unsupported") + }).getOrThrow() + val algorithm = signatureAlgorithm.secKeyAlgorithmPreHashed + val input = inputData.data.single().toNSData() val signatureBytes = corecall { SecKeyCreateSignature(privateKey.key.value, algorithm, input.giveToCF(), error) }.let { it.takeFromCF().toByteArray() } diff --git a/supreme/src/jvmMain/kotlin/at/asitplus/signum/supreme/hash/DigestImpl.kt b/supreme/src/jvmMain/kotlin/at/asitplus/signum/supreme/hash/DigestImpl.kt new file mode 100644 index 00000000..f2c85fee --- /dev/null +++ b/supreme/src/jvmMain/kotlin/at/asitplus/signum/supreme/hash/DigestImpl.kt @@ -0,0 +1,10 @@ +package at.asitplus.signum.supreme.hash + +import at.asitplus.signum.indispensable.Digest +import at.asitplus.signum.indispensable.jcaName +import java.security.MessageDigest + +internal actual fun doDigest(digest: Digest, data: Sequence): ByteArray = + MessageDigest.getInstance(digest.jcaName).apply { + data.forEach { update(it) } + }.digest() From e17a2d68be6bea210bd3f3e19fe0961b4f4d4144 Mon Sep 17 00:00:00 2001 From: Jakob Heher Date: Tue, 27 Aug 2024 13:52:23 +0200 Subject: [PATCH 11/73] JKS provider --- .../signum/supreme/sign/EphemeralKeysImpl.kt | 9 +- .../signum/supreme/dsl/ConfigurationDSL.kt | 19 ++- .../asitplus/signum/supreme/os/Attestation.kt | 7 + .../signum/supreme/sign/EphemeralKeys.kt | 45 +++--- .../signum/supreme/sign/EphemeralKeysImpl.kt | 13 +- .../asitplus/signum/supreme/os/JKSProvider.kt | 141 ++++++++++++++++++ .../signum/supreme/sign/EphemeralKeysImpl.kt | 37 +++-- 7 files changed, 235 insertions(+), 36 deletions(-) create mode 100644 supreme/src/jvmMain/kotlin/at/asitplus/signum/supreme/os/JKSProvider.kt diff --git a/supreme/src/androidMain/kotlin/at/asitplus/signum/supreme/sign/EphemeralKeysImpl.kt b/supreme/src/androidMain/kotlin/at/asitplus/signum/supreme/sign/EphemeralKeysImpl.kt index 325e7d9b..c71d4d7c 100644 --- a/supreme/src/androidMain/kotlin/at/asitplus/signum/supreme/sign/EphemeralKeysImpl.kt +++ b/supreme/src/androidMain/kotlin/at/asitplus/signum/supreme/sign/EphemeralKeysImpl.kt @@ -16,6 +16,9 @@ import java.security.PrivateKey import java.security.spec.ECGenParameterSpec import java.security.spec.RSAKeyGenParameterSpec +actual class EphemeralSigningKeyConfiguration internal actual constructor(): EphemeralSigningKeyConfigurationBase() +actual class EphemeralSignerConfiguration internal actual constructor(): EphemeralSignerConfigurationBase() + sealed class AndroidEphemeralSigner (private val privateKey: PrivateKey) : Signer { override val mayRequireUserUnlock = false override suspend fun sign(data: SignatureInput) = catching { @@ -34,10 +37,12 @@ sealed class AndroidEphemeralSigner (private val privateKey: PrivateKey) : Signe } } } - class EC (privateKey: PrivateKey, override val publicKey: CryptoPublicKey.EC, override val signatureAlgorithm: SignatureAlgorithm.ECDSA) + class EC (config: EphemeralSignerConfiguration, privateKey: PrivateKey, + override val publicKey: CryptoPublicKey.EC, override val signatureAlgorithm: SignatureAlgorithm.ECDSA) : AndroidEphemeralSigner(privateKey), Signer.ECDSA - class RSA (privateKey: PrivateKey, override val publicKey: CryptoPublicKey.Rsa, override val signatureAlgorithm: SignatureAlgorithm.RSA) + class RSA (config: EphemeralSignerConfiguration, privateKey: PrivateKey, + override val publicKey: CryptoPublicKey.Rsa, override val signatureAlgorithm: SignatureAlgorithm.RSA) : AndroidEphemeralSigner(privateKey), Signer.RSA } diff --git a/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/dsl/ConfigurationDSL.kt b/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/dsl/ConfigurationDSL.kt index 59e46f5a..2301e01a 100644 --- a/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/dsl/ConfigurationDSL.kt +++ b/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/dsl/ConfigurationDSL.kt @@ -1,5 +1,7 @@ package at.asitplus.signum.supreme.dsl +import kotlin.reflect.KProperty + /** * The meta functionality that enables us to easily create DSLs. * @see at.asitplus.signum.supreme.dsl.DSLInheritanceDemonstration @@ -18,7 +20,7 @@ object DSL { operator fun invoke(configure: Target.()->Unit) } - /** Constructed by: [DSL.Data.child]. */ + /** Constructed by: [DSL.Data.childOrDefault] and [DSL.Data.childOrNull]. */ class DirectHolder internal constructor(default: T, private val factory: ()->(T & Any)) : Invokable { private var _v: T = default @@ -54,6 +56,15 @@ object DSL { override operator fun invoke(configure: T.()->Unit) { _v = configure } } + /** Constructed by: [DSL.Data.unsupported]. */ + class Unsupported internal constructor(val error: String): Invokable { + override val v: Unit get() = Unit + override fun invoke(configure: T.() -> Unit) { throw UnsupportedOperationException(error); } + + operator fun getValue(thisRef: Any?, property: KProperty<*>): Nothing { throw UnsupportedOperationException(error) } + operator fun setValue(thisRef: Any?, property: KProperty<*>, value: Any) { throw UnsupportedOperationException(error) } + } + @DslMarker annotation class Marker @@ -116,6 +127,12 @@ object DSL { protected fun integratedReceiver(): Integrated = Integrated() + /** + * Marks a DSL substructure as unsupported. + */ + protected fun unsupported(why: String): Unsupported = + Unsupported(why) + /** * Invoked by `DSL.resolve()` after the configuration block runs. * Can be used for sanity checks. diff --git a/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/os/Attestation.kt b/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/os/Attestation.kt index 53c7c09f..7633664b 100644 --- a/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/os/Attestation.kt +++ b/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/os/Attestation.kt @@ -4,7 +4,9 @@ import at.asitplus.signum.indispensable.CryptoPublicKey import at.asitplus.signum.indispensable.io.ByteArrayBase64UrlSerializer import at.asitplus.signum.indispensable.io.CertificateChainBase64UrlSerializer import at.asitplus.signum.indispensable.io.IosPublicKeySerializer +import at.asitplus.signum.indispensable.io.X509CertificateBase64UrlSerializer import at.asitplus.signum.indispensable.pki.CertificateChain +import at.asitplus.signum.indispensable.pki.X509Certificate import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.encodeToString @@ -19,6 +21,11 @@ sealed interface Attestation { } } +data class SelfAttestation ( + @Serializable(with=X509CertificateBase64UrlSerializer::class) + @SerialName("x5c") + val certificate: X509Certificate) : Attestation + @Serializable @SerialName("android-key") data class AndroidKeystoreAttestation ( diff --git a/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/sign/EphemeralKeys.kt b/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/sign/EphemeralKeys.kt index 03ad2550..240be5b3 100644 --- a/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/sign/EphemeralKeys.kt +++ b/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/sign/EphemeralKeys.kt @@ -10,7 +10,7 @@ import at.asitplus.signum.supreme.os.SignerConfiguration internal expect fun makeEphemeralKey(configuration: EphemeralSigningKeyConfiguration) : EphemeralKey -class EphemeralSigningKeyConfiguration internal constructor(): SigningKeyConfiguration() { +open class EphemeralSigningKeyConfigurationBase internal constructor(): SigningKeyConfiguration() { class ECConfiguration internal constructor(): SigningKeyConfiguration.ECConfiguration() { init { digests = (Digest.entries.asSequence() + sequenceOf(null)).toSet() } } @@ -20,7 +20,10 @@ class EphemeralSigningKeyConfiguration internal constructor(): SigningKeyConfigu } override val rsa = _algSpecific.option(::RSAConfiguration) } -typealias EphemeralSignerConfiguration = SignerConfiguration +expect class EphemeralSigningKeyConfiguration internal constructor(): EphemeralSigningKeyConfigurationBase + +typealias EphemeralSignerConfigurationBase = SignerConfiguration +expect class EphemeralSignerConfiguration internal constructor(): SignerConfiguration sealed interface EphemeralKey { val publicKey: CryptoPublicKey @@ -43,17 +46,18 @@ internal sealed class EphemeralKeyBase (protected val privateKey: PrivateKeyT): EphemeralKey { class EC( - private val signerFactory: (PrivateKeyT, CryptoPublicKey.EC, SignatureAlgorithm.ECDSA)->SignerT, + private val signerFactory: (EphemeralSignerConfiguration, PrivateKeyT, CryptoPublicKey.EC, SignatureAlgorithm.ECDSA)->SignerT, privateKey: PrivateKeyT, override val publicKey: CryptoPublicKey.EC, val digests: Set) : EphemeralKeyBase(privateKey), EphemeralKey.EC { override fun signer(configure: DSLConfigureFn): SignerT { - val config = DSL.resolve(::EphemeralSignerConfiguration, configure).ec.v - val digest = when (config.digestSpecified) { + val config = DSL.resolve(::EphemeralSignerConfiguration, configure) + val alg = config.ec.v + val digest = when (alg.digestSpecified) { true -> { - require (digests.contains(config.digest)) - { "Digest ${config.digest} unsupported (supported: ${digests.joinToString(",")}" } - config.digest + require (digests.contains(alg.digest)) + { "Digest ${alg.digest} unsupported (supported: ${digests.joinToString(",")}" } + alg.digest } false -> when { digests.contains(Digest.SHA256) -> Digest.SHA256 @@ -62,22 +66,23 @@ internal sealed class EphemeralKeyBase else -> digests.first() } } - return signerFactory(privateKey, publicKey, SignatureAlgorithm.ECDSA(digest, publicKey.curve)) + return signerFactory(config, privateKey, publicKey, SignatureAlgorithm.ECDSA(digest, publicKey.curve)) } } class RSA( - private val signerFactory: (PrivateKeyT, CryptoPublicKey.Rsa, SignatureAlgorithm.RSA)->SignerT, + private val signerFactory: (EphemeralSignerConfiguration, PrivateKeyT, CryptoPublicKey.Rsa, SignatureAlgorithm.RSA)->SignerT, privateKey: PrivateKeyT, override val publicKey: CryptoPublicKey.Rsa, val digests: Set, val paddings: Set) : EphemeralKeyBase(privateKey), EphemeralKey.RSA { override fun signer(configure: DSLConfigureFn): SignerT { - val config = DSL.resolve(::EphemeralSignerConfiguration, configure).rsa.v - val digest = when (config.digestSpecified) { + val config = DSL.resolve(::EphemeralSignerConfiguration, configure) + val alg = config.rsa.v + val digest = when (alg.digestSpecified) { true -> { - require (digests.contains(config.digest)) - { "Digest ${config.digest} unsupported (supported: ${digests.joinToString(", ")}" } - config.digest + require (digests.contains(alg.digest)) + { "Digest ${alg.digest} unsupported (supported: ${digests.joinToString(", ")}" } + alg.digest } false -> when { digests.contains(Digest.SHA256) -> Digest.SHA256 @@ -86,11 +91,11 @@ internal sealed class EphemeralKeyBase else -> digests.first() } } - val padding = when (config.paddingSpecified) { + val padding = when (alg.paddingSpecified) { true -> { - require (paddings.contains(config.padding)) - { "Padding ${config.padding} unsupported (supported: ${paddings.joinToString(", ")}" } - config.padding + require (paddings.contains(alg.padding)) + { "Padding ${alg.padding} unsupported (supported: ${paddings.joinToString(", ")}" } + alg.padding } false -> when { paddings.contains(RSAPadding.PSS) -> RSAPadding.PSS @@ -98,7 +103,7 @@ internal sealed class EphemeralKeyBase else -> paddings.first() } } - return signerFactory(privateKey, publicKey, SignatureAlgorithm.RSA(digest, padding)) + return signerFactory(config, privateKey, publicKey, SignatureAlgorithm.RSA(digest, padding)) } } } diff --git a/supreme/src/iosMain/kotlin/at/asitplus/signum/supreme/sign/EphemeralKeysImpl.kt b/supreme/src/iosMain/kotlin/at/asitplus/signum/supreme/sign/EphemeralKeysImpl.kt index 2fa6df1f..6eb55bae 100644 --- a/supreme/src/iosMain/kotlin/at/asitplus/signum/supreme/sign/EphemeralKeysImpl.kt +++ b/supreme/src/iosMain/kotlin/at/asitplus/signum/supreme/sign/EphemeralKeysImpl.kt @@ -36,6 +36,9 @@ import platform.Security.kSecPublicKeyAttrs import kotlin.experimental.ExperimentalNativeApi import kotlin.native.ref.createCleaner +actual class EphemeralSigningKeyConfiguration internal actual constructor(): EphemeralSigningKeyConfigurationBase() +actual class EphemeralSignerConfiguration internal actual constructor(): EphemeralSignerConfigurationBase() + sealed class EphemeralSigner(private val privateKey: EphemeralKeyRef): Signer { final override val mayRequireUserUnlock: Boolean get() = false final override suspend fun sign(data: SignatureInput) = catching { @@ -54,11 +57,13 @@ sealed class EphemeralSigner(private val privateKey: EphemeralKeyRef): Signer { is CryptoPublicKey.Rsa -> CryptoSignature.RSAorHMAC(signatureBytes) } } - class EC(privateKey: EphemeralKeyRef, override val publicKey: CryptoPublicKey.EC, - override val signatureAlgorithm: SignatureAlgorithm.ECDSA): EphemeralSigner(privateKey), Signer.ECDSA + class EC(config: EphemeralSignerConfiguration, privateKey: EphemeralKeyRef, + override val publicKey: CryptoPublicKey.EC, override val signatureAlgorithm: SignatureAlgorithm.ECDSA) + : EphemeralSigner(privateKey), Signer.ECDSA - class RSA(privateKey: EphemeralKeyRef, override val publicKey: CryptoPublicKey.Rsa, - override val signatureAlgorithm: SignatureAlgorithm.RSA): EphemeralSigner(privateKey), Signer.RSA + class RSA(config: EphemeralSignerConfiguration, privateKey: EphemeralKeyRef, + override val publicKey: CryptoPublicKey.Rsa, override val signatureAlgorithm: SignatureAlgorithm.RSA) + : EphemeralSigner(privateKey), Signer.RSA } class EphemeralKeyRef { diff --git a/supreme/src/jvmMain/kotlin/at/asitplus/signum/supreme/os/JKSProvider.kt b/supreme/src/jvmMain/kotlin/at/asitplus/signum/supreme/os/JKSProvider.kt new file mode 100644 index 00000000..0db7ec58 --- /dev/null +++ b/supreme/src/jvmMain/kotlin/at/asitplus/signum/supreme/os/JKSProvider.kt @@ -0,0 +1,141 @@ +package at.asitplus.signum.supreme.os + +import at.asitplus.KmmResult +import at.asitplus.catching +import at.asitplus.signum.indispensable.CryptoPublicKey +import at.asitplus.signum.indispensable.CryptoSignature +import at.asitplus.signum.indispensable.Digest +import at.asitplus.signum.indispensable.RSAPadding +import at.asitplus.signum.indispensable.SignatureAlgorithm +import at.asitplus.signum.indispensable.X509SignatureAlgorithm +import at.asitplus.signum.indispensable.asn1.Asn1String +import at.asitplus.signum.indispensable.asn1.Asn1Time +import at.asitplus.signum.indispensable.fromJcaPublicKey +import at.asitplus.signum.indispensable.getJCASignatureInstance +import at.asitplus.signum.indispensable.jcaName +import at.asitplus.signum.indispensable.parseFromJca +import at.asitplus.signum.indispensable.pki.AttributeTypeAndValue +import at.asitplus.signum.indispensable.pki.RelativeDistinguishedName +import at.asitplus.signum.indispensable.pki.TbsCertificate +import at.asitplus.signum.indispensable.pki.X509Certificate +import at.asitplus.signum.indispensable.pki.leaf +import at.asitplus.signum.indispensable.toJcaCertificate +import at.asitplus.signum.supreme.dsl.DSL +import at.asitplus.signum.supreme.dsl.DSLConfigureFn +import at.asitplus.signum.supreme.sign.EphemeralSigner +import at.asitplus.signum.supreme.sign.JvmEphemeralSignerCompatibleConfiguration +import at.asitplus.signum.supreme.sign.Signer +import at.asitplus.signum.supreme.sign.SigningKeyConfiguration +import at.asitplus.signum.supreme.sign.getKPGInstance +import com.ionspin.kotlin.bignum.integer.base63.toJavaBigInteger +import kotlinx.datetime.Clock +import java.security.KeyPairGenerator +import java.security.KeyStore +import java.security.PrivateKey +import java.security.SecureRandom +import java.security.interfaces.ECPrivateKey +import java.security.interfaces.RSAPrivateKey +import java.security.spec.ECGenParameterSpec +import java.security.spec.RSAKeyGenParameterSpec +import kotlin.time.Duration + +class JKSSigningKeyConfiguration: PlatformSigningKeyConfiguration() { + var provider: String? = null + var privateKeyPassword: CharArray? = null + var certificateValidityPeriod: Duration = Duration.INFINITE +} + +class JKSSignerConfiguration: PlatformSignerConfiguration(), JvmEphemeralSignerCompatibleConfiguration { + override var provider: String? = null + var privateKeyPassword: CharArray? = null +} + +sealed interface JKSSigner: Signer, Signer.Attestable { + class EC internal constructor (config: JvmEphemeralSignerCompatibleConfiguration, privateKey: PrivateKey, + publicKey: CryptoPublicKey.EC, signatureAlgorithm: SignatureAlgorithm.ECDSA, + certificate: X509Certificate) + : EphemeralSigner.EC(config, privateKey, publicKey, signatureAlgorithm), JKSSigner { + override val attestation = SelfAttestation(certificate) + } + + class RSA internal constructor (config: JvmEphemeralSignerCompatibleConfiguration, privateKey: PrivateKey, + publicKey: CryptoPublicKey.Rsa, signatureAlgorithm: SignatureAlgorithm.RSA, + certificate: X509Certificate) + : EphemeralSigner.RSA(config, privateKey, publicKey, signatureAlgorithm), JKSSigner { + override val attestation = SelfAttestation(certificate) + } +} + +class JKSProvider(private val ks: KeyStore): SigningProviderI { + override suspend fun createSigningKey( + alias: String, + configure: DSLConfigureFn + ): KmmResult = catching { + if (ks.containsAlias(alias)) + throw NoSuchElementException("Key with alias $alias already exists") + val config = DSL.resolve(::JKSSigningKeyConfiguration, configure) + + val (jcaAlg,jcaSpec,certAlg) = when (val algSpec = config._algSpecific.v) { + is SigningKeyConfiguration.RSAConfiguration -> + Triple("RSA", RSAKeyGenParameterSpec(algSpec.bits, algSpec.publicExponent.toJavaBigInteger()), X509SignatureAlgorithm.RS256) + is SigningKeyConfiguration.ECConfiguration -> + Triple("EC", ECGenParameterSpec(algSpec.curve.jcaName), X509SignatureAlgorithm.ES256) + } + val keyPair = getKPGInstance(jcaAlg, config.provider).run { + initialize(jcaSpec) + generateKeyPair() + } + val cn = listOf(RelativeDistinguishedName(AttributeTypeAndValue.CommonName(Asn1String.UTF8(alias)))) + val publicKey = CryptoPublicKey.fromJcaPublicKey(keyPair.public).getOrThrow() + val tbsCert = TbsCertificate( + serialNumber = ByteArray(32).also { SecureRandom().nextBytes(it) }, + signatureAlgorithm = certAlg, + issuerName = cn, + subjectName = cn, + validFrom = Asn1Time(Clock.System.now()), + validUntil = Asn1Time(Clock.System.now() + config.certificateValidityPeriod), + publicKey = publicKey + ) + val cert = certAlg.getJCASignatureInstance(config.provider).getOrThrow().run { + initSign(keyPair.private) + update(tbsCert.encodeToDer()) + sign() + }.let { X509Certificate(tbsCert, certAlg, CryptoSignature.parseFromJca(it, certAlg)) } + ks.setKeyEntry(alias, keyPair.private, config.privateKeyPassword, + arrayOf(cert.toJcaCertificate().getOrThrow())) + + return@catching getSigner(DSL.resolve(::JKSSignerConfiguration, config.signer.v), keyPair.private, cert) + } + + private suspend fun getSigner( + config: JKSSignerConfiguration, + privateKey: PrivateKey, + certificate: X509Certificate + ): JKSSigner = when (val publicKey = certificate.publicKey) { + is CryptoPublicKey.EC -> JKSSigner.EC(config, privateKey as ECPrivateKey, publicKey, + SignatureAlgorithm.ECDSA( + digest = if (config.ec.v.digestSpecified) config.ec.v.digest else Digest.SHA256, + requiredCurve = publicKey.curve), + certificate) + is CryptoPublicKey.Rsa -> JKSSigner.RSA(config, privateKey as RSAPrivateKey, publicKey, + SignatureAlgorithm.RSA( + digest = if (config.rsa.v.digestSpecified) config.rsa.v.digest else Digest.SHA256, + padding = if (config.rsa.v.paddingSpecified) config.rsa.v.padding else RSAPadding.PSS), + certificate) + } + + override suspend fun getSignerForKey( + alias: String, + configure: DSLConfigureFn + ): KmmResult = catching { + val config = DSL.resolve(::JKSSignerConfiguration, configure) + val privateKey = ks.getKey(alias, config.privateKeyPassword) as PrivateKey + val certificateChain = ks.getCertificateChain(alias).map { X509Certificate.decodeFromDer(it.encoded) } + return@catching getSigner(config, privateKey, certificateChain.leaf) + } + + override suspend fun deleteSigningKey(alias: String) { + if (ks.containsAlias(alias)) + ks.deleteEntry(alias) + } +} \ No newline at end of file diff --git a/supreme/src/jvmMain/kotlin/at/asitplus/signum/supreme/sign/EphemeralKeysImpl.kt b/supreme/src/jvmMain/kotlin/at/asitplus/signum/supreme/sign/EphemeralKeysImpl.kt index 88670102..872b8a88 100644 --- a/supreme/src/jvmMain/kotlin/at/asitplus/signum/supreme/sign/EphemeralKeysImpl.kt +++ b/supreme/src/jvmMain/kotlin/at/asitplus/signum/supreme/sign/EphemeralKeysImpl.kt @@ -12,10 +12,21 @@ import at.asitplus.signum.supreme.os.SignerConfiguration import com.ionspin.kotlin.bignum.integer.base63.toJavaBigInteger import java.security.KeyPairGenerator import java.security.PrivateKey +import java.security.Provider import java.security.spec.ECGenParameterSpec import java.security.spec.RSAKeyGenParameterSpec -sealed class AndroidEphemeralSigner (private val privateKey: PrivateKey) : Signer { +actual class EphemeralSigningKeyConfiguration internal actual constructor(): EphemeralSigningKeyConfigurationBase() { + var provider: String? = null +} +interface JvmEphemeralSignerCompatibleConfiguration { + var provider: String? +} +actual class EphemeralSignerConfiguration internal actual constructor(): EphemeralSignerConfigurationBase(), JvmEphemeralSignerCompatibleConfiguration { + override var provider: String? = null +} + +sealed class EphemeralSigner (private val privateKey: PrivateKey, private val provider: String?) : Signer { override val mayRequireUserUnlock = false override suspend fun sign(data: SignatureInput) = catching { val alg = if (data.format != null) { @@ -33,31 +44,39 @@ sealed class AndroidEphemeralSigner (private val privateKey: PrivateKey) : Signe } } } - class EC (privateKey: PrivateKey, override val publicKey: CryptoPublicKey.EC, override val signatureAlgorithm: SignatureAlgorithm.ECDSA) - : AndroidEphemeralSigner(privateKey), Signer.ECDSA + open class EC internal constructor (config: JvmEphemeralSignerCompatibleConfiguration, privateKey: PrivateKey, + override val publicKey: CryptoPublicKey.EC, override val signatureAlgorithm: SignatureAlgorithm.ECDSA) + : EphemeralSigner(privateKey, config.provider), Signer.ECDSA - class RSA (privateKey: PrivateKey, override val publicKey: CryptoPublicKey.Rsa, override val signatureAlgorithm: SignatureAlgorithm.RSA) - : AndroidEphemeralSigner(privateKey), Signer.RSA + open class RSA internal constructor (config: JvmEphemeralSignerCompatibleConfiguration, privateKey: PrivateKey, + override val publicKey: CryptoPublicKey.Rsa, override val signatureAlgorithm: SignatureAlgorithm.RSA) + : EphemeralSigner(privateKey, config.provider), Signer.RSA } +internal fun getKPGInstance(alg: String, provider: String? = null) = + when (provider) { + null -> KeyPairGenerator.getInstance(alg) + else -> KeyPairGenerator.getInstance(alg, provider) + } + internal actual fun makeEphemeralKey(configuration: EphemeralSigningKeyConfiguration) : EphemeralKey = when (val alg = configuration._algSpecific.v) { is SigningKeyConfiguration.ECConfiguration -> { - KeyPairGenerator.getInstance("EC").run { + getKPGInstance("EC", configuration.provider).run { initialize(ECGenParameterSpec(alg.curve.jcaName)) generateKeyPair() }.let { pair -> - EphemeralKeyBase.EC(AndroidEphemeralSigner::EC, + EphemeralKeyBase.EC(EphemeralSigner::EC, pair.private, CryptoPublicKey.fromJcaPublicKey(pair.public).getOrThrow() as CryptoPublicKey.EC, digests = alg.digests) } } is SigningKeyConfiguration.RSAConfiguration -> { - KeyPairGenerator.getInstance("RSA").run { + getKPGInstance("RSA", configuration.provider).run { initialize(RSAKeyGenParameterSpec(alg.bits, alg.publicExponent.toJavaBigInteger())) generateKeyPair() }.let { pair -> - EphemeralKeyBase.RSA(AndroidEphemeralSigner::RSA, + EphemeralKeyBase.RSA(EphemeralSigner::RSA, pair.private, CryptoPublicKey.fromJcaPublicKey(pair.public).getOrThrow() as CryptoPublicKey.Rsa, digests = alg.digests, paddings = alg.paddings) } From a0641bc860137d61ca7fffe69f1f9f88496aed68 Mon Sep 17 00:00:00 2001 From: Jakob Heher Date: Tue, 27 Aug 2024 16:41:08 +0200 Subject: [PATCH 12/73] JKS provider --- .../iosMain/kotlin/CommonCryptoExtensions.kt | 5 ++++ .../signum/indispensable/JcaExtensions.kt | 26 +++++++++++++++++-- .../signum/supreme/sign/EphemeralKeysImpl.kt | 19 +++++++------- .../sign/EphemeralSignerCommonTests.kt | 11 ++++++-- .../asitplus/signum/supreme/os/JKSProvider.kt | 16 +++++++++--- .../signum/supreme/sign/EphemeralKeysImpl.kt | 26 +++++++++++-------- .../signum/supreme/os/JKSProviderTest.kt | 26 +++++++++++++++++++ 7 files changed, 100 insertions(+), 29 deletions(-) create mode 100644 supreme/src/jvmTest/kotlin/at/asitplus/signum/supreme/os/JKSProviderTest.kt diff --git a/indispensable/src/iosMain/kotlin/CommonCryptoExtensions.kt b/indispensable/src/iosMain/kotlin/CommonCryptoExtensions.kt index c69c6bcf..123c3eb2 100644 --- a/indispensable/src/iosMain/kotlin/CommonCryptoExtensions.kt +++ b/indispensable/src/iosMain/kotlin/CommonCryptoExtensions.kt @@ -56,6 +56,8 @@ val SignatureAlgorithm.secKeyAlgorithm : SecKeyAlgorithm get() = when (this) { } is SignatureAlgorithm.HMAC -> TODO("HMAC is unsupported") }!! +val SpecializedSignatureAlgorithm.secKeyAlgorithm get() = + this.algorithm.secKeyAlgorithm val SignatureAlgorithm.secKeyAlgorithmPreHashed: SecKeyAlgorithm get() = when (this) { is SignatureAlgorithm.ECDSA -> { @@ -85,3 +87,6 @@ val SignatureAlgorithm.secKeyAlgorithmPreHashed: SecKeyAlgorithm get() = when (t } is SignatureAlgorithm.HMAC -> TODO("HMAC is unsupported") }!! + +val SpecializedSignatureAlgorithm.secKeyAlgorithmPreHashed get() = + this.algorithm.secKeyAlgorithmPreHashed diff --git a/indispensable/src/jvmMain/kotlin/at/asitplus/signum/indispensable/JcaExtensions.kt b/indispensable/src/jvmMain/kotlin/at/asitplus/signum/indispensable/JcaExtensions.kt index 2c51ac03..c2056bb5 100644 --- a/indispensable/src/jvmMain/kotlin/at/asitplus/signum/indispensable/JcaExtensions.kt +++ b/indispensable/src/jvmMain/kotlin/at/asitplus/signum/indispensable/JcaExtensions.kt @@ -64,8 +64,30 @@ fun SignatureAlgorithm.getJCASignatureInstance(provider: String? = null, isAndro } } /** Get a pre-configured JCA instance for this algorithm */ -fun SpecializedSignatureAlgorithm.getJCASignatureInstance(provider: String? = null) = - this.algorithm.getJCASignatureInstance(provider) +fun SpecializedSignatureAlgorithm.getJCASignatureInstance(provider: String? = null, isAndroid: Boolean = false) = + this.algorithm.getJCASignatureInstance(provider, isAndroid) + +/** Get a pre-configured JCA instance for pre-hashed data for this algorithm */ +fun SignatureAlgorithm.getJCASignatureInstancePreHashed(provider: String? = null, isAndroid: Boolean = false) = catching { + when (this) { + is SignatureAlgorithm.ECDSA -> sigGetInstance("NONEwithECDSA", provider) + is SignatureAlgorithm.RSA -> when (this.padding) { + RSAPadding.PKCS1 -> when (isAndroid) { + true -> sigGetInstance("NONEwithRSA", provider) + false -> throw UnsupportedOperationException("Pre-hashed RSA input is unsupported on JVM") + } + RSAPadding.PSS -> when (isAndroid) { + true -> sigGetInstance("NONEwithRSA/PSS", provider) + false -> throw UnsupportedOperationException("Pre-hashed RSA input is unsupported on JVM") + } + } + else -> TODO("$this is unsupported with pre-hashed data") + } +} + +/** Get a pre-configured JCA instance for pre-hashed data for this algorithm */ +fun SpecializedSignatureAlgorithm.getJCASignatureInstancePreHashed(provider: String? = null, isAndroid: Boolean = false) = + this.algorithm.getJCASignatureInstancePreHashed(provider, isAndroid) val Digest.jcaName get() = when (this) { diff --git a/supreme/src/androidMain/kotlin/at/asitplus/signum/supreme/sign/EphemeralKeysImpl.kt b/supreme/src/androidMain/kotlin/at/asitplus/signum/supreme/sign/EphemeralKeysImpl.kt index c71d4d7c..c4240ef8 100644 --- a/supreme/src/androidMain/kotlin/at/asitplus/signum/supreme/sign/EphemeralKeysImpl.kt +++ b/supreme/src/androidMain/kotlin/at/asitplus/signum/supreme/sign/EphemeralKeysImpl.kt @@ -7,6 +7,7 @@ import at.asitplus.signum.indispensable.CryptoSignature import at.asitplus.signum.indispensable.SignatureAlgorithm import at.asitplus.signum.indispensable.fromJcaPublicKey import at.asitplus.signum.indispensable.getJCASignatureInstance +import at.asitplus.signum.indispensable.getJCASignatureInstancePreHashed import at.asitplus.signum.indispensable.jcaName import at.asitplus.signum.indispensable.parseFromJca import at.asitplus.signum.supreme.os.SignerConfiguration @@ -22,18 +23,16 @@ actual class EphemeralSignerConfiguration internal actual constructor(): Ephemer sealed class AndroidEphemeralSigner (private val privateKey: PrivateKey) : Signer { override val mayRequireUserUnlock = false override suspend fun sign(data: SignatureInput) = catching { - val alg = if (data.format != null) { - (signatureAlgorithm as? SignatureAlgorithm.ECDSA).let { - require (it != null && it.digest == data.format) - { "Pre-hashed data (format ${data.format}) unsupported for algorithm $signatureAlgorithm" } - } - SignatureAlgorithm.ECDSA(digest = null, requiredCurve = null) - } else signatureAlgorithm - alg.getJCASignatureInstance(provider = null, isAndroid = true).getOrThrow().run { + val inputData = data.convertTo(when (val alg = signatureAlgorithm) { + is SignatureAlgorithm.RSA -> alg.digest + is SignatureAlgorithm.ECDSA -> alg.digest + else -> TODO("hmac unsupported") + }).getOrThrow() + signatureAlgorithm.getJCASignatureInstancePreHashed(provider = null, isAndroid = true).getOrThrow().run { initSign(privateKey) - data.data.forEach { update(it) } + inputData.data.forEach { update(it) } sign().let { - CryptoSignature.parseFromJca(it, alg) + CryptoSignature.parseFromJca(it, signatureAlgorithm) } } } diff --git a/supreme/src/commonTest/kotlin/at/asitplus/signum/supreme/sign/EphemeralSignerCommonTests.kt b/supreme/src/commonTest/kotlin/at/asitplus/signum/supreme/sign/EphemeralSignerCommonTests.kt index 3bd55bbc..0b40bce6 100644 --- a/supreme/src/commonTest/kotlin/at/asitplus/signum/supreme/sign/EphemeralSignerCommonTests.kt +++ b/supreme/src/commonTest/kotlin/at/asitplus/signum/supreme/sign/EphemeralSignerCommonTests.kt @@ -1,5 +1,6 @@ package at.asitplus.signum.supreme.sign +import at.asitplus.catching import at.asitplus.signum.indispensable.Digest import at.asitplus.signum.indispensable.ECCurve import at.asitplus.signum.indispensable.RSAPadding @@ -8,6 +9,7 @@ import com.ionspin.kotlin.bignum.integer.Quadruple import io.kotest.core.spec.style.FreeSpec import io.kotest.datatest.withData import io.kotest.matchers.should +import kotlinx.coroutines.cancel import kotlin.random.Random class EphemeralSignerCommonTests : FreeSpec({ @@ -26,9 +28,14 @@ class EphemeralSignerCommonTests : FreeSpec({ } } }) { (padding, digest, keySize, preHashed) -> - val signer = Signer { rsa { digests = setOf(digest); paddings = setOf(padding); bits = keySize } } val data = Random.Default.nextBytes(64) - val signature = signer.sign(SignatureInput(data).let { if (preHashed) it.convertTo(digest).getOrThrow() else it }).getOrThrow() + val signer: Signer + val signature = try { + signer = Signer { rsa { digests = setOf(digest); paddings = setOf(padding); bits = keySize } } + signer.sign(SignatureInput(data).let { if (preHashed) it.convertTo(digest).getOrThrow() else it }).getOrThrow() + } catch (x: UnsupportedOperationException) { + return@withData + } val verifier = signer.makeVerifier().getOrThrow() verifier.verify(data, signature) should succeed diff --git a/supreme/src/jvmMain/kotlin/at/asitplus/signum/supreme/os/JKSProvider.kt b/supreme/src/jvmMain/kotlin/at/asitplus/signum/supreme/os/JKSProvider.kt index 0db7ec58..9c7f044f 100644 --- a/supreme/src/jvmMain/kotlin/at/asitplus/signum/supreme/os/JKSProvider.kt +++ b/supreme/src/jvmMain/kotlin/at/asitplus/signum/supreme/os/JKSProvider.kt @@ -38,11 +38,12 @@ import java.security.interfaces.RSAPrivateKey import java.security.spec.ECGenParameterSpec import java.security.spec.RSAKeyGenParameterSpec import kotlin.time.Duration +import kotlin.time.Duration.Companion.days class JKSSigningKeyConfiguration: PlatformSigningKeyConfiguration() { var provider: String? = null var privateKeyPassword: CharArray? = null - var certificateValidityPeriod: Duration = Duration.INFINITE + var certificateValidityPeriod: Duration = 100.days } class JKSSignerConfiguration: PlatformSignerConfiguration(), JvmEphemeralSignerCompatibleConfiguration { @@ -96,7 +97,7 @@ class JKSProvider(private val ks: KeyStore): SigningProviderI KeyStore.getInstance(KeyStore.getDefaultType()) + else -> KeyStore.getInstance(KeyStore.getDefaultType(), provider) + }.apply { load(null) }) + } +} diff --git a/supreme/src/jvmMain/kotlin/at/asitplus/signum/supreme/sign/EphemeralKeysImpl.kt b/supreme/src/jvmMain/kotlin/at/asitplus/signum/supreme/sign/EphemeralKeysImpl.kt index 872b8a88..3d375b60 100644 --- a/supreme/src/jvmMain/kotlin/at/asitplus/signum/supreme/sign/EphemeralKeysImpl.kt +++ b/supreme/src/jvmMain/kotlin/at/asitplus/signum/supreme/sign/EphemeralKeysImpl.kt @@ -6,13 +6,12 @@ import at.asitplus.signum.indispensable.CryptoSignature import at.asitplus.signum.indispensable.SignatureAlgorithm import at.asitplus.signum.indispensable.fromJcaPublicKey import at.asitplus.signum.indispensable.getJCASignatureInstance +import at.asitplus.signum.indispensable.getJCASignatureInstancePreHashed import at.asitplus.signum.indispensable.jcaName import at.asitplus.signum.indispensable.parseFromJca -import at.asitplus.signum.supreme.os.SignerConfiguration import com.ionspin.kotlin.bignum.integer.base63.toJavaBigInteger import java.security.KeyPairGenerator import java.security.PrivateKey -import java.security.Provider import java.security.spec.ECGenParameterSpec import java.security.spec.RSAKeyGenParameterSpec @@ -29,18 +28,23 @@ actual class EphemeralSignerConfiguration internal actual constructor(): Ephemer sealed class EphemeralSigner (private val privateKey: PrivateKey, private val provider: String?) : Signer { override val mayRequireUserUnlock = false override suspend fun sign(data: SignatureInput) = catching { - val alg = if (data.format != null) { - (signatureAlgorithm as? SignatureAlgorithm.ECDSA).let { - require (it != null && it.digest == data.format) - { "Pre-hashed data (format ${data.format}) unsupported for algorithm $signatureAlgorithm" } - } - SignatureAlgorithm.ECDSA(digest = null, requiredCurve = null) - } else signatureAlgorithm - alg.getJCASignatureInstance(provider = null, isAndroid = false).getOrThrow().run { + val preHashed = (data.format != null) + if (preHashed) { + require (data.format == when (val alg = signatureAlgorithm) { + is SignatureAlgorithm.ECDSA -> alg.digest + is SignatureAlgorithm.RSA -> alg.digest + else -> TODO("HMAC is unsupported") + }) { "Pre-hashed data (format ${data.format}) unsupported for algorithm $signatureAlgorithm" } + } + (if (preHashed) + signatureAlgorithm.getJCASignatureInstancePreHashed(provider = provider, isAndroid = false).getOrThrow() + else + signatureAlgorithm.getJCASignatureInstance(provider = provider, isAndroid = false).getOrThrow()) + .run { initSign(privateKey) data.data.forEach { update(it) } sign().let { - CryptoSignature.parseFromJca(it, alg) + CryptoSignature.parseFromJca(it, signatureAlgorithm) } } } diff --git a/supreme/src/jvmTest/kotlin/at/asitplus/signum/supreme/os/JKSProviderTest.kt b/supreme/src/jvmTest/kotlin/at/asitplus/signum/supreme/os/JKSProviderTest.kt new file mode 100644 index 00000000..6f7cd8d6 --- /dev/null +++ b/supreme/src/jvmTest/kotlin/at/asitplus/signum/supreme/os/JKSProviderTest.kt @@ -0,0 +1,26 @@ +package at.asitplus.signum.supreme.os + +import at.asitplus.signum.supreme.sign.makeVerifier +import at.asitplus.signum.supreme.sign.sign +import at.asitplus.signum.supreme.sign.verify +import at.asitplus.signum.supreme.succeed +import io.kotest.core.spec.style.FreeSpec +import io.kotest.matchers.should +import io.kotest.matchers.shouldBe +import io.kotest.matchers.shouldNot +import kotlin.random.Random + +class JKSProviderTest : FreeSpec({ + "create - get - delete" { + val ks = JKSProvider.Ephemeral() + val alias = "Elfenbeinschloss" + ks.getSignerForKey(alias) shouldNot succeed + val signer = ks.createSigningKey(alias).getOrThrow() + val otherSigner = ks.getSignerForKey(alias).getOrThrow() + otherSigner.attestation shouldBe signer.attestation + + val data = Random.Default.nextBytes(64) + val signature = signer.sign(data).getOrThrow() + otherSigner.makeVerifier().getOrThrow().verify(data, signature) should succeed + } +}) From cf5a58dfe8204cf3921df61422e9e4a8ebaf8bdb Mon Sep 17 00:00:00 2001 From: Jakob Heher Date: Tue, 27 Aug 2024 18:31:13 +0200 Subject: [PATCH 13/73] expose ephemeral key internal object --- .../kotlin/at/asitplus/signum/supreme/Throwables.kt | 4 ++++ .../kotlin/at/asitplus/signum/supreme/sign/EphemeralKeys.kt | 3 ++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/Throwables.kt b/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/Throwables.kt index fcc53633..5eda3918 100644 --- a/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/Throwables.kt +++ b/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/Throwables.kt @@ -1,5 +1,9 @@ package at.asitplus.signum.supreme +@RequiresOptIn +/** This is an internal property. It is exposed if you know what you are doing. You very likely don't actually need it. */ +annotation class FootGunsAbound + sealed class CryptoException(message: String? = null, cause: Throwable? = null) : Throwable(message, cause) open class CryptoOperationFailed(message: String) : CryptoException(message) diff --git a/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/sign/EphemeralKeys.kt b/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/sign/EphemeralKeys.kt index 240be5b3..cbdb237c 100644 --- a/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/sign/EphemeralKeys.kt +++ b/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/sign/EphemeralKeys.kt @@ -4,6 +4,7 @@ import at.asitplus.signum.indispensable.CryptoPublicKey import at.asitplus.signum.indispensable.Digest import at.asitplus.signum.indispensable.RSAPadding import at.asitplus.signum.indispensable.SignatureAlgorithm +import at.asitplus.signum.supreme.FootGunsAbound import at.asitplus.signum.supreme.dsl.DSL import at.asitplus.signum.supreme.dsl.DSLConfigureFn import at.asitplus.signum.supreme.os.SignerConfiguration @@ -43,7 +44,7 @@ sealed interface EphemeralKey { } internal sealed class EphemeralKeyBase - (protected val privateKey: PrivateKeyT): EphemeralKey { + (@property:FootGunsAbound val privateKey: PrivateKeyT): EphemeralKey { class EC( private val signerFactory: (EphemeralSignerConfiguration, PrivateKeyT, CryptoPublicKey.EC, SignatureAlgorithm.ECDSA)->SignerT, From 881009598698f4eed1affb1697fb9e720c751999 Mon Sep 17 00:00:00 2001 From: Jakob Heher Date: Tue, 27 Aug 2024 18:42:05 +0200 Subject: [PATCH 14/73] annotate our own usages --- .../kotlin/at/asitplus/signum/supreme/Throwables.kt | 4 ++-- .../kotlin/at/asitplus/signum/supreme/sign/EphemeralKeys.kt | 6 ++++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/Throwables.kt b/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/Throwables.kt index 5eda3918..06a0ae18 100644 --- a/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/Throwables.kt +++ b/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/Throwables.kt @@ -1,8 +1,8 @@ package at.asitplus.signum.supreme -@RequiresOptIn +@RequiresOptIn(message = "Access to potentially hazardous platform-specific internals requires explicit opt-in. Specify @OptIn(HazardousMaterials::class)") /** This is an internal property. It is exposed if you know what you are doing. You very likely don't actually need it. */ -annotation class FootGunsAbound +annotation class HazardousMaterials sealed class CryptoException(message: String? = null, cause: Throwable? = null) : Throwable(message, cause) open class CryptoOperationFailed(message: String) : CryptoException(message) diff --git a/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/sign/EphemeralKeys.kt b/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/sign/EphemeralKeys.kt index cbdb237c..657a8965 100644 --- a/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/sign/EphemeralKeys.kt +++ b/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/sign/EphemeralKeys.kt @@ -4,7 +4,7 @@ import at.asitplus.signum.indispensable.CryptoPublicKey import at.asitplus.signum.indispensable.Digest import at.asitplus.signum.indispensable.RSAPadding import at.asitplus.signum.indispensable.SignatureAlgorithm -import at.asitplus.signum.supreme.FootGunsAbound +import at.asitplus.signum.supreme.HazardousMaterials import at.asitplus.signum.supreme.dsl.DSL import at.asitplus.signum.supreme.dsl.DSLConfigureFn import at.asitplus.signum.supreme.os.SignerConfiguration @@ -44,7 +44,7 @@ sealed interface EphemeralKey { } internal sealed class EphemeralKeyBase - (@property:FootGunsAbound val privateKey: PrivateKeyT): EphemeralKey { + (@property:HazardousMaterials val privateKey: PrivateKeyT): EphemeralKey { class EC( private val signerFactory: (EphemeralSignerConfiguration, PrivateKeyT, CryptoPublicKey.EC, SignatureAlgorithm.ECDSA)->SignerT, @@ -67,6 +67,7 @@ internal sealed class EphemeralKeyBase else -> digests.first() } } + @OptIn(HazardousMaterials::class) return signerFactory(config, privateKey, publicKey, SignatureAlgorithm.ECDSA(digest, publicKey.curve)) } } @@ -104,6 +105,7 @@ internal sealed class EphemeralKeyBase else -> paddings.first() } } + @OptIn(HazardousMaterials::class) return signerFactory(config, privateKey, publicKey, SignatureAlgorithm.RSA(digest, padding)) } } From 706959d89df70186770549543de345a8b94cd858 Mon Sep 17 00:00:00 2001 From: Jakob Heher Date: Tue, 27 Aug 2024 19:06:24 +0200 Subject: [PATCH 15/73] ephemerals arrrgh --- .../signum/supreme/sign/EphemeralKeysImpl.kt | 3 ++- .../signum/supreme/sign/EphemeralKeys.kt | 18 +++++++++++------- .../signum/supreme/sign/EphemeralKeysImpl.kt | 7 ++++--- .../signum/supreme/sign/EphemeralKeysImpl.kt | 3 ++- 4 files changed, 19 insertions(+), 12 deletions(-) diff --git a/supreme/src/androidMain/kotlin/at/asitplus/signum/supreme/sign/EphemeralKeysImpl.kt b/supreme/src/androidMain/kotlin/at/asitplus/signum/supreme/sign/EphemeralKeysImpl.kt index c4240ef8..cfe9c0c9 100644 --- a/supreme/src/androidMain/kotlin/at/asitplus/signum/supreme/sign/EphemeralKeysImpl.kt +++ b/supreme/src/androidMain/kotlin/at/asitplus/signum/supreme/sign/EphemeralKeysImpl.kt @@ -45,7 +45,8 @@ sealed class AndroidEphemeralSigner (private val privateKey: PrivateKey) : Signe : AndroidEphemeralSigner(privateKey), Signer.RSA } -internal actual fun makeEphemeralKey(configuration: EphemeralSigningKeyConfiguration) : EphemeralKey = +actual typealias PlatformEphemeralKey = PrivateKey +internal actual fun makeEphemeralKey(configuration: EphemeralSigningKeyConfiguration) : EphemeralKey = when (val alg = configuration._algSpecific.v) { is SigningKeyConfiguration.ECConfiguration -> { KeyPairGenerator.getInstance(KeyProperties.KEY_ALGORITHM_EC).run { diff --git a/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/sign/EphemeralKeys.kt b/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/sign/EphemeralKeys.kt index 657a8965..87d1868a 100644 --- a/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/sign/EphemeralKeys.kt +++ b/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/sign/EphemeralKeys.kt @@ -1,3 +1,5 @@ +@file:OptIn(HazardousMaterials::class) + package at.asitplus.signum.supreme.sign import at.asitplus.signum.indispensable.CryptoPublicKey @@ -9,7 +11,8 @@ import at.asitplus.signum.supreme.dsl.DSL import at.asitplus.signum.supreme.dsl.DSLConfigureFn import at.asitplus.signum.supreme.os.SignerConfiguration -internal expect fun makeEphemeralKey(configuration: EphemeralSigningKeyConfiguration) : EphemeralKey +expect interface PlatformEphemeralKey +internal expect fun makeEphemeralKey(configuration: EphemeralSigningKeyConfiguration) : EphemeralKey open class EphemeralSigningKeyConfigurationBase internal constructor(): SigningKeyConfiguration() { class ECConfiguration internal constructor(): SigningKeyConfiguration.ECConfiguration() { @@ -26,14 +29,15 @@ expect class EphemeralSigningKeyConfiguration internal constructor(): EphemeralS typealias EphemeralSignerConfigurationBase = SignerConfiguration expect class EphemeralSignerConfiguration internal constructor(): SignerConfiguration -sealed interface EphemeralKey { +sealed interface EphemeralKey { + @HazardousMaterials val privateKey: PrivateKeyT val publicKey: CryptoPublicKey fun signer(configure: DSLConfigureFn = null): Signer - interface EC: EphemeralKey { + interface EC: EphemeralKey { override val publicKey: CryptoPublicKey.EC override fun signer(configure: DSLConfigureFn): Signer.ECDSA } - interface RSA: EphemeralKey { + interface RSA: EphemeralKey { override val publicKey: CryptoPublicKey.Rsa override fun signer(configure: DSLConfigureFn): Signer.RSA } @@ -44,12 +48,12 @@ sealed interface EphemeralKey { } internal sealed class EphemeralKeyBase - (@property:HazardousMaterials val privateKey: PrivateKeyT): EphemeralKey { + (final override val privateKey: PrivateKeyT): EphemeralKey { class EC( private val signerFactory: (EphemeralSignerConfiguration, PrivateKeyT, CryptoPublicKey.EC, SignatureAlgorithm.ECDSA)->SignerT, privateKey: PrivateKeyT, override val publicKey: CryptoPublicKey.EC, - val digests: Set) : EphemeralKeyBase(privateKey), EphemeralKey.EC { + val digests: Set) : EphemeralKeyBase(privateKey), EphemeralKey.EC { override fun signer(configure: DSLConfigureFn): SignerT { val config = DSL.resolve(::EphemeralSignerConfiguration, configure) @@ -75,7 +79,7 @@ internal sealed class EphemeralKeyBase class RSA( private val signerFactory: (EphemeralSignerConfiguration, PrivateKeyT, CryptoPublicKey.Rsa, SignatureAlgorithm.RSA)->SignerT, privateKey: PrivateKeyT, override val publicKey: CryptoPublicKey.Rsa, - val digests: Set, val paddings: Set) : EphemeralKeyBase(privateKey), EphemeralKey.RSA { + val digests: Set, val paddings: Set) : EphemeralKeyBase(privateKey), EphemeralKey.RSA { override fun signer(configure: DSLConfigureFn): SignerT { val config = DSL.resolve(::EphemeralSignerConfiguration, configure) diff --git a/supreme/src/iosMain/kotlin/at/asitplus/signum/supreme/sign/EphemeralKeysImpl.kt b/supreme/src/iosMain/kotlin/at/asitplus/signum/supreme/sign/EphemeralKeysImpl.kt index 6eb55bae..839013f3 100644 --- a/supreme/src/iosMain/kotlin/at/asitplus/signum/supreme/sign/EphemeralKeysImpl.kt +++ b/supreme/src/iosMain/kotlin/at/asitplus/signum/supreme/sign/EphemeralKeysImpl.kt @@ -66,14 +66,15 @@ sealed class EphemeralSigner(private val privateKey: EphemeralKeyRef): Signer { : EphemeralSigner(privateKey), Signer.RSA } -class EphemeralKeyRef { +class EphemeralKeyRef: PlatformEphemeralKey { private val arena = Arena() @OptIn(ExperimentalNativeApi::class) private val cleaner = createCleaner(arena, Arena::clear) - val key = arena.alloc() + override val key = arena.alloc() } -internal actual fun makeEphemeralKey(configuration: EphemeralSigningKeyConfiguration) : EphemeralKey { +actual interface PlatformEphemeralKey { val key: SecKeyRefVar } +internal actual fun makeEphemeralKey(configuration: EphemeralSigningKeyConfiguration) : EphemeralKey { val key = EphemeralKeyRef() memScoped { val attr = createCFDictionary { diff --git a/supreme/src/jvmMain/kotlin/at/asitplus/signum/supreme/sign/EphemeralKeysImpl.kt b/supreme/src/jvmMain/kotlin/at/asitplus/signum/supreme/sign/EphemeralKeysImpl.kt index 3d375b60..452167b1 100644 --- a/supreme/src/jvmMain/kotlin/at/asitplus/signum/supreme/sign/EphemeralKeysImpl.kt +++ b/supreme/src/jvmMain/kotlin/at/asitplus/signum/supreme/sign/EphemeralKeysImpl.kt @@ -63,7 +63,8 @@ internal fun getKPGInstance(alg: String, provider: String? = null) = else -> KeyPairGenerator.getInstance(alg, provider) } -internal actual fun makeEphemeralKey(configuration: EphemeralSigningKeyConfiguration) : EphemeralKey = +actual typealias PlatformEphemeralKey = PrivateKey +internal actual fun makeEphemeralKey(configuration: EphemeralSigningKeyConfiguration) : EphemeralKey = when (val alg = configuration._algSpecific.v) { is SigningKeyConfiguration.ECConfiguration -> { getKPGInstance("EC", configuration.provider).run { From fd13988b46dd5770b0c9898ed5820c601f50ae6d Mon Sep 17 00:00:00 2001 From: Jakob Heher Date: Tue, 27 Aug 2024 19:11:35 +0200 Subject: [PATCH 16/73] Revert "ephemerals arrrgh" This reverts commit 706959d89df70186770549543de345a8b94cd858. --- .../signum/supreme/sign/EphemeralKeysImpl.kt | 3 +-- .../signum/supreme/sign/EphemeralKeys.kt | 18 +++++++----------- .../signum/supreme/sign/EphemeralKeysImpl.kt | 7 +++---- .../signum/supreme/sign/EphemeralKeysImpl.kt | 3 +-- 4 files changed, 12 insertions(+), 19 deletions(-) diff --git a/supreme/src/androidMain/kotlin/at/asitplus/signum/supreme/sign/EphemeralKeysImpl.kt b/supreme/src/androidMain/kotlin/at/asitplus/signum/supreme/sign/EphemeralKeysImpl.kt index cfe9c0c9..c4240ef8 100644 --- a/supreme/src/androidMain/kotlin/at/asitplus/signum/supreme/sign/EphemeralKeysImpl.kt +++ b/supreme/src/androidMain/kotlin/at/asitplus/signum/supreme/sign/EphemeralKeysImpl.kt @@ -45,8 +45,7 @@ sealed class AndroidEphemeralSigner (private val privateKey: PrivateKey) : Signe : AndroidEphemeralSigner(privateKey), Signer.RSA } -actual typealias PlatformEphemeralKey = PrivateKey -internal actual fun makeEphemeralKey(configuration: EphemeralSigningKeyConfiguration) : EphemeralKey = +internal actual fun makeEphemeralKey(configuration: EphemeralSigningKeyConfiguration) : EphemeralKey = when (val alg = configuration._algSpecific.v) { is SigningKeyConfiguration.ECConfiguration -> { KeyPairGenerator.getInstance(KeyProperties.KEY_ALGORITHM_EC).run { diff --git a/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/sign/EphemeralKeys.kt b/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/sign/EphemeralKeys.kt index 87d1868a..657a8965 100644 --- a/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/sign/EphemeralKeys.kt +++ b/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/sign/EphemeralKeys.kt @@ -1,5 +1,3 @@ -@file:OptIn(HazardousMaterials::class) - package at.asitplus.signum.supreme.sign import at.asitplus.signum.indispensable.CryptoPublicKey @@ -11,8 +9,7 @@ import at.asitplus.signum.supreme.dsl.DSL import at.asitplus.signum.supreme.dsl.DSLConfigureFn import at.asitplus.signum.supreme.os.SignerConfiguration -expect interface PlatformEphemeralKey -internal expect fun makeEphemeralKey(configuration: EphemeralSigningKeyConfiguration) : EphemeralKey +internal expect fun makeEphemeralKey(configuration: EphemeralSigningKeyConfiguration) : EphemeralKey open class EphemeralSigningKeyConfigurationBase internal constructor(): SigningKeyConfiguration() { class ECConfiguration internal constructor(): SigningKeyConfiguration.ECConfiguration() { @@ -29,15 +26,14 @@ expect class EphemeralSigningKeyConfiguration internal constructor(): EphemeralS typealias EphemeralSignerConfigurationBase = SignerConfiguration expect class EphemeralSignerConfiguration internal constructor(): SignerConfiguration -sealed interface EphemeralKey { - @HazardousMaterials val privateKey: PrivateKeyT +sealed interface EphemeralKey { val publicKey: CryptoPublicKey fun signer(configure: DSLConfigureFn = null): Signer - interface EC: EphemeralKey { + interface EC: EphemeralKey { override val publicKey: CryptoPublicKey.EC override fun signer(configure: DSLConfigureFn): Signer.ECDSA } - interface RSA: EphemeralKey { + interface RSA: EphemeralKey { override val publicKey: CryptoPublicKey.Rsa override fun signer(configure: DSLConfigureFn): Signer.RSA } @@ -48,12 +44,12 @@ sealed interface EphemeralKey { } internal sealed class EphemeralKeyBase - (final override val privateKey: PrivateKeyT): EphemeralKey { + (@property:HazardousMaterials val privateKey: PrivateKeyT): EphemeralKey { class EC( private val signerFactory: (EphemeralSignerConfiguration, PrivateKeyT, CryptoPublicKey.EC, SignatureAlgorithm.ECDSA)->SignerT, privateKey: PrivateKeyT, override val publicKey: CryptoPublicKey.EC, - val digests: Set) : EphemeralKeyBase(privateKey), EphemeralKey.EC { + val digests: Set) : EphemeralKeyBase(privateKey), EphemeralKey.EC { override fun signer(configure: DSLConfigureFn): SignerT { val config = DSL.resolve(::EphemeralSignerConfiguration, configure) @@ -79,7 +75,7 @@ internal sealed class EphemeralKeyBase class RSA( private val signerFactory: (EphemeralSignerConfiguration, PrivateKeyT, CryptoPublicKey.Rsa, SignatureAlgorithm.RSA)->SignerT, privateKey: PrivateKeyT, override val publicKey: CryptoPublicKey.Rsa, - val digests: Set, val paddings: Set) : EphemeralKeyBase(privateKey), EphemeralKey.RSA { + val digests: Set, val paddings: Set) : EphemeralKeyBase(privateKey), EphemeralKey.RSA { override fun signer(configure: DSLConfigureFn): SignerT { val config = DSL.resolve(::EphemeralSignerConfiguration, configure) diff --git a/supreme/src/iosMain/kotlin/at/asitplus/signum/supreme/sign/EphemeralKeysImpl.kt b/supreme/src/iosMain/kotlin/at/asitplus/signum/supreme/sign/EphemeralKeysImpl.kt index 839013f3..6eb55bae 100644 --- a/supreme/src/iosMain/kotlin/at/asitplus/signum/supreme/sign/EphemeralKeysImpl.kt +++ b/supreme/src/iosMain/kotlin/at/asitplus/signum/supreme/sign/EphemeralKeysImpl.kt @@ -66,15 +66,14 @@ sealed class EphemeralSigner(private val privateKey: EphemeralKeyRef): Signer { : EphemeralSigner(privateKey), Signer.RSA } -class EphemeralKeyRef: PlatformEphemeralKey { +class EphemeralKeyRef { private val arena = Arena() @OptIn(ExperimentalNativeApi::class) private val cleaner = createCleaner(arena, Arena::clear) - override val key = arena.alloc() + val key = arena.alloc() } -actual interface PlatformEphemeralKey { val key: SecKeyRefVar } -internal actual fun makeEphemeralKey(configuration: EphemeralSigningKeyConfiguration) : EphemeralKey { +internal actual fun makeEphemeralKey(configuration: EphemeralSigningKeyConfiguration) : EphemeralKey { val key = EphemeralKeyRef() memScoped { val attr = createCFDictionary { diff --git a/supreme/src/jvmMain/kotlin/at/asitplus/signum/supreme/sign/EphemeralKeysImpl.kt b/supreme/src/jvmMain/kotlin/at/asitplus/signum/supreme/sign/EphemeralKeysImpl.kt index 452167b1..3d375b60 100644 --- a/supreme/src/jvmMain/kotlin/at/asitplus/signum/supreme/sign/EphemeralKeysImpl.kt +++ b/supreme/src/jvmMain/kotlin/at/asitplus/signum/supreme/sign/EphemeralKeysImpl.kt @@ -63,8 +63,7 @@ internal fun getKPGInstance(alg: String, provider: String? = null) = else -> KeyPairGenerator.getInstance(alg, provider) } -actual typealias PlatformEphemeralKey = PrivateKey -internal actual fun makeEphemeralKey(configuration: EphemeralSigningKeyConfiguration) : EphemeralKey = +internal actual fun makeEphemeralKey(configuration: EphemeralSigningKeyConfiguration) : EphemeralKey = when (val alg = configuration._algSpecific.v) { is SigningKeyConfiguration.ECConfiguration -> { getKPGInstance("EC", configuration.provider).run { From d88b3199065ef7d6f0ec8ae9169de76b0ae6d591 Mon Sep 17 00:00:00 2001 From: Jakob Heher Date: Tue, 27 Aug 2024 19:18:49 +0200 Subject: [PATCH 17/73] ephemeral internals but nicer --- .../at/asitplus/signum/supreme/sign/EphemeralKeysImpl.kt | 6 ++++-- .../at/asitplus/signum/supreme/sign/EphemeralKeys.kt | 8 +++++--- .../at/asitplus/signum/supreme/sign/EphemeralKeysImpl.kt | 7 +++++-- .../at/asitplus/signum/supreme/sign/EphemeralKeysImpl.kt | 4 ++++ 4 files changed, 18 insertions(+), 7 deletions(-) diff --git a/supreme/src/androidMain/kotlin/at/asitplus/signum/supreme/sign/EphemeralKeysImpl.kt b/supreme/src/androidMain/kotlin/at/asitplus/signum/supreme/sign/EphemeralKeysImpl.kt index c4240ef8..dbf68038 100644 --- a/supreme/src/androidMain/kotlin/at/asitplus/signum/supreme/sign/EphemeralKeysImpl.kt +++ b/supreme/src/androidMain/kotlin/at/asitplus/signum/supreme/sign/EphemeralKeysImpl.kt @@ -6,11 +6,9 @@ import at.asitplus.signum.indispensable.CryptoPublicKey import at.asitplus.signum.indispensable.CryptoSignature import at.asitplus.signum.indispensable.SignatureAlgorithm import at.asitplus.signum.indispensable.fromJcaPublicKey -import at.asitplus.signum.indispensable.getJCASignatureInstance import at.asitplus.signum.indispensable.getJCASignatureInstancePreHashed import at.asitplus.signum.indispensable.jcaName import at.asitplus.signum.indispensable.parseFromJca -import at.asitplus.signum.supreme.os.SignerConfiguration import com.ionspin.kotlin.bignum.integer.base63.toJavaBigInteger import java.security.KeyPairGenerator import java.security.PrivateKey @@ -45,6 +43,10 @@ sealed class AndroidEphemeralSigner (private val privateKey: PrivateKey) : Signe : AndroidEphemeralSigner(privateKey), Signer.RSA } +actual interface EphemeralKeyPlatformSpecifics { val jcaPrivateKey: PrivateKey } +actual val EphemeralKey.platformSpecifics: EphemeralKeyPlatformSpecifics get() = + object : EphemeralKeyPlatformSpecifics { override val jcaPrivateKey get() = (this@platformSpecifics as EphemeralKeyBase<*>).privateKey as PrivateKey } + internal actual fun makeEphemeralKey(configuration: EphemeralSigningKeyConfiguration) : EphemeralKey = when (val alg = configuration._algSpecific.v) { is SigningKeyConfiguration.ECConfiguration -> { diff --git a/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/sign/EphemeralKeys.kt b/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/sign/EphemeralKeys.kt index 657a8965..e2c61eed 100644 --- a/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/sign/EphemeralKeys.kt +++ b/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/sign/EphemeralKeys.kt @@ -43,8 +43,12 @@ sealed interface EphemeralKey { } } +expect interface EphemeralKeyPlatformSpecifics +@HazardousMaterials +expect val EphemeralKey.platformSpecifics: EphemeralKeyPlatformSpecifics + internal sealed class EphemeralKeyBase - (@property:HazardousMaterials val privateKey: PrivateKeyT): EphemeralKey { + (val privateKey: PrivateKeyT): EphemeralKey { class EC( private val signerFactory: (EphemeralSignerConfiguration, PrivateKeyT, CryptoPublicKey.EC, SignatureAlgorithm.ECDSA)->SignerT, @@ -67,7 +71,6 @@ internal sealed class EphemeralKeyBase else -> digests.first() } } - @OptIn(HazardousMaterials::class) return signerFactory(config, privateKey, publicKey, SignatureAlgorithm.ECDSA(digest, publicKey.curve)) } } @@ -105,7 +108,6 @@ internal sealed class EphemeralKeyBase else -> paddings.first() } } - @OptIn(HazardousMaterials::class) return signerFactory(config, privateKey, publicKey, SignatureAlgorithm.RSA(digest, padding)) } } diff --git a/supreme/src/iosMain/kotlin/at/asitplus/signum/supreme/sign/EphemeralKeysImpl.kt b/supreme/src/iosMain/kotlin/at/asitplus/signum/supreme/sign/EphemeralKeysImpl.kt index 6eb55bae..0b1dc2ac 100644 --- a/supreme/src/iosMain/kotlin/at/asitplus/signum/supreme/sign/EphemeralKeysImpl.kt +++ b/supreme/src/iosMain/kotlin/at/asitplus/signum/supreme/sign/EphemeralKeysImpl.kt @@ -66,13 +66,16 @@ sealed class EphemeralSigner(private val privateKey: EphemeralKeyRef): Signer { : EphemeralSigner(privateKey), Signer.RSA } -class EphemeralKeyRef { +class EphemeralKeyRef: EphemeralKeyPlatformSpecifics { private val arena = Arena() @OptIn(ExperimentalNativeApi::class) private val cleaner = createCleaner(arena, Arena::clear) - val key = arena.alloc() + override val key = arena.alloc() } +actual interface EphemeralKeyPlatformSpecifics { val key: SecKeyRefVar } +actual val EphemeralKey.platformSpecifics: EphemeralKeyPlatformSpecifics get() = (this as EphemeralKeyBase<*>).privateKey as EphemeralKeyRef + internal actual fun makeEphemeralKey(configuration: EphemeralSigningKeyConfiguration) : EphemeralKey { val key = EphemeralKeyRef() memScoped { diff --git a/supreme/src/jvmMain/kotlin/at/asitplus/signum/supreme/sign/EphemeralKeysImpl.kt b/supreme/src/jvmMain/kotlin/at/asitplus/signum/supreme/sign/EphemeralKeysImpl.kt index 3d375b60..05d8d3a5 100644 --- a/supreme/src/jvmMain/kotlin/at/asitplus/signum/supreme/sign/EphemeralKeysImpl.kt +++ b/supreme/src/jvmMain/kotlin/at/asitplus/signum/supreme/sign/EphemeralKeysImpl.kt @@ -63,6 +63,10 @@ internal fun getKPGInstance(alg: String, provider: String? = null) = else -> KeyPairGenerator.getInstance(alg, provider) } +actual interface EphemeralKeyPlatformSpecifics { val jcaPrivateKey: PrivateKey } +actual val EphemeralKey.platformSpecifics: EphemeralKeyPlatformSpecifics get() = + object : EphemeralKeyPlatformSpecifics { override val jcaPrivateKey get() = (this@platformSpecifics as EphemeralKeyBase<*>).privateKey as PrivateKey } + internal actual fun makeEphemeralKey(configuration: EphemeralSigningKeyConfiguration) : EphemeralKey = when (val alg = configuration._algSpecific.v) { is SigningKeyConfiguration.ECConfiguration -> { From 64bb2b90a82f66f0fef41039caad6ff61603530a Mon Sep 17 00:00:00 2001 From: Jakob Heher Date: Wed, 28 Aug 2024 13:08:26 +0200 Subject: [PATCH 18/73] hazmat cleanup --- .../supreme/hazmat/InternalsAccessors.kt | 18 +++++++++++++++ .../supreme/os/AndroidKeyStoreProvider.kt | 2 +- .../signum/supreme/sign/EphemeralKeysImpl.kt | 6 +---- .../at/asitplus/signum/supreme/Throwables.kt | 2 +- .../signum/supreme/sign/EphemeralKeys.kt | 16 +++++--------- .../sign/EphemeralSignerCommonTests.kt | 22 ++++++++++++++----- .../supreme/hazmat/InternalsAccessors.kt | 18 +++++++++++++++ .../signum/supreme/os/IosKeychainProvider.kt | 2 +- .../signum/supreme/sign/EphemeralKeysImpl.kt | 9 +++----- .../supreme/hazmat/InternalsAccessors.kt | 13 +++++++++++ .../signum/supreme/sign/EphemeralKeysImpl.kt | 6 +---- 11 files changed, 79 insertions(+), 35 deletions(-) create mode 100644 supreme/src/androidMain/kotlin/at/asitplus/signum/supreme/hazmat/InternalsAccessors.kt create mode 100644 supreme/src/iosMain/kotlin/at/asitplus/signum/supreme/hazmat/InternalsAccessors.kt create mode 100644 supreme/src/jvmMain/kotlin/at/asitplus/signum/supreme/hazmat/InternalsAccessors.kt diff --git a/supreme/src/androidMain/kotlin/at/asitplus/signum/supreme/hazmat/InternalsAccessors.kt b/supreme/src/androidMain/kotlin/at/asitplus/signum/supreme/hazmat/InternalsAccessors.kt new file mode 100644 index 00000000..da16eced --- /dev/null +++ b/supreme/src/androidMain/kotlin/at/asitplus/signum/supreme/hazmat/InternalsAccessors.kt @@ -0,0 +1,18 @@ +package at.asitplus.signum.supreme.hazmat + +import at.asitplus.signum.supreme.HazardousMaterials +import at.asitplus.signum.supreme.os.LockedAndroidKeystoreSigner +import at.asitplus.signum.supreme.sign.AndroidEphemeralSigner +import at.asitplus.signum.supreme.sign.EphemeralKey +import at.asitplus.signum.supreme.sign.EphemeralKeyBase +import at.asitplus.signum.supreme.sign.Signer +import java.security.PrivateKey + +@HazardousMaterials +val EphemeralKey.jcaPrivateKey get() = (this as? EphemeralKeyBase<*>)?.privateKey as? PrivateKey +@HazardousMaterials +val Signer.jcaPrivateKey get() = when (this) { + is AndroidEphemeralSigner -> this.privateKey + is LockedAndroidKeystoreSigner -> this.jcaPrivateKey + else -> null +} diff --git a/supreme/src/androidMain/kotlin/at/asitplus/signum/supreme/os/AndroidKeyStoreProvider.kt b/supreme/src/androidMain/kotlin/at/asitplus/signum/supreme/os/AndroidKeyStoreProvider.kt index 762a1b86..85224a5f 100644 --- a/supreme/src/androidMain/kotlin/at/asitplus/signum/supreme/os/AndroidKeyStoreProvider.kt +++ b/supreme/src/androidMain/kotlin/at/asitplus/signum/supreme/os/AndroidKeyStoreProvider.kt @@ -308,7 +308,7 @@ sealed class UnlockedAndroidKeystoreSigner private constructor( sealed class LockedAndroidKeystoreSigner private constructor( private val context: FragmentContext, - private val jcaPrivateKey: PrivateKey, + internal val jcaPrivateKey: PrivateKey, override val keyInfo: KeyInfo, private val config: AndroidSignerConfiguration, certificateChain: CertificateChain diff --git a/supreme/src/androidMain/kotlin/at/asitplus/signum/supreme/sign/EphemeralKeysImpl.kt b/supreme/src/androidMain/kotlin/at/asitplus/signum/supreme/sign/EphemeralKeysImpl.kt index dbf68038..2e0f0cb4 100644 --- a/supreme/src/androidMain/kotlin/at/asitplus/signum/supreme/sign/EphemeralKeysImpl.kt +++ b/supreme/src/androidMain/kotlin/at/asitplus/signum/supreme/sign/EphemeralKeysImpl.kt @@ -18,7 +18,7 @@ import java.security.spec.RSAKeyGenParameterSpec actual class EphemeralSigningKeyConfiguration internal actual constructor(): EphemeralSigningKeyConfigurationBase() actual class EphemeralSignerConfiguration internal actual constructor(): EphemeralSignerConfigurationBase() -sealed class AndroidEphemeralSigner (private val privateKey: PrivateKey) : Signer { +sealed class AndroidEphemeralSigner (internal val privateKey: PrivateKey) : Signer { override val mayRequireUserUnlock = false override suspend fun sign(data: SignatureInput) = catching { val inputData = data.convertTo(when (val alg = signatureAlgorithm) { @@ -43,10 +43,6 @@ sealed class AndroidEphemeralSigner (private val privateKey: PrivateKey) : Signe : AndroidEphemeralSigner(privateKey), Signer.RSA } -actual interface EphemeralKeyPlatformSpecifics { val jcaPrivateKey: PrivateKey } -actual val EphemeralKey.platformSpecifics: EphemeralKeyPlatformSpecifics get() = - object : EphemeralKeyPlatformSpecifics { override val jcaPrivateKey get() = (this@platformSpecifics as EphemeralKeyBase<*>).privateKey as PrivateKey } - internal actual fun makeEphemeralKey(configuration: EphemeralSigningKeyConfiguration) : EphemeralKey = when (val alg = configuration._algSpecific.v) { is SigningKeyConfiguration.ECConfiguration -> { diff --git a/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/Throwables.kt b/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/Throwables.kt index 06a0ae18..3b70e06b 100644 --- a/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/Throwables.kt +++ b/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/Throwables.kt @@ -1,6 +1,6 @@ package at.asitplus.signum.supreme -@RequiresOptIn(message = "Access to potentially hazardous platform-specific internals requires explicit opt-in. Specify @OptIn(HazardousMaterials::class)") +@RequiresOptIn(message = "Access to potentially hazardous platform-specific internals requires explicit opt-in. Specify @OptIn(HazardousMaterials::class). These accessors are unstable and may change without warning.") /** This is an internal property. It is exposed if you know what you are doing. You very likely don't actually need it. */ annotation class HazardousMaterials diff --git a/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/sign/EphemeralKeys.kt b/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/sign/EphemeralKeys.kt index e2c61eed..cf50dae6 100644 --- a/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/sign/EphemeralKeys.kt +++ b/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/sign/EphemeralKeys.kt @@ -4,6 +4,7 @@ import at.asitplus.signum.indispensable.CryptoPublicKey import at.asitplus.signum.indispensable.Digest import at.asitplus.signum.indispensable.RSAPadding import at.asitplus.signum.indispensable.SignatureAlgorithm +import at.asitplus.signum.indispensable.nativeDigest import at.asitplus.signum.supreme.HazardousMaterials import at.asitplus.signum.supreme.dsl.DSL import at.asitplus.signum.supreme.dsl.DSLConfigureFn @@ -43,12 +44,8 @@ sealed interface EphemeralKey { } } -expect interface EphemeralKeyPlatformSpecifics -@HazardousMaterials -expect val EphemeralKey.platformSpecifics: EphemeralKeyPlatformSpecifics - internal sealed class EphemeralKeyBase - (val privateKey: PrivateKeyT): EphemeralKey { + (internal val privateKey: PrivateKeyT): EphemeralKey { class EC( private val signerFactory: (EphemeralSignerConfiguration, PrivateKeyT, CryptoPublicKey.EC, SignatureAlgorithm.ECDSA)->SignerT, @@ -64,12 +61,9 @@ internal sealed class EphemeralKeyBase { "Digest ${alg.digest} unsupported (supported: ${digests.joinToString(",")}" } alg.digest } - false -> when { - digests.contains(Digest.SHA256) -> Digest.SHA256 - digests.contains(Digest.SHA384) -> Digest.SHA384 - digests.contains(Digest.SHA512) -> Digest.SHA512 - else -> digests.first() - } + false -> + sequenceOf(publicKey.curve.nativeDigest, Digest.SHA256, Digest.SHA384, Digest.SHA512) + .firstOrNull(digests::contains) ?: digests.first() } return signerFactory(config, privateKey, publicKey, SignatureAlgorithm.ECDSA(digest, publicKey.curve)) } diff --git a/supreme/src/commonTest/kotlin/at/asitplus/signum/supreme/sign/EphemeralSignerCommonTests.kt b/supreme/src/commonTest/kotlin/at/asitplus/signum/supreme/sign/EphemeralSignerCommonTests.kt index 0b40bce6..50abfc50 100644 --- a/supreme/src/commonTest/kotlin/at/asitplus/signum/supreme/sign/EphemeralSignerCommonTests.kt +++ b/supreme/src/commonTest/kotlin/at/asitplus/signum/supreme/sign/EphemeralSignerCommonTests.kt @@ -4,11 +4,15 @@ import at.asitplus.catching import at.asitplus.signum.indispensable.Digest import at.asitplus.signum.indispensable.ECCurve import at.asitplus.signum.indispensable.RSAPadding +import at.asitplus.signum.indispensable.SignatureAlgorithm import at.asitplus.signum.supreme.succeed import com.ionspin.kotlin.bignum.integer.Quadruple import io.kotest.core.spec.style.FreeSpec import io.kotest.datatest.withData +import io.kotest.matchers.collections.shouldBeIn import io.kotest.matchers.should +import io.kotest.matchers.shouldBe +import io.kotest.matchers.types.shouldBeInstanceOf import kotlinx.coroutines.cancel import kotlin.random.Random @@ -36,6 +40,10 @@ class EphemeralSignerCommonTests : FreeSpec({ } catch (x: UnsupportedOperationException) { return@withData } + signer.signatureAlgorithm.shouldBeInstanceOf().let { + it.digest shouldBe digest + it.padding shouldBe padding + } val verifier = signer.makeVerifier().getOrThrow() verifier.verify(data, signature) should succeed @@ -50,12 +58,16 @@ class EphemeralSignerCommonTests : FreeSpec({ } } }) { (crv, digest, preHashed) -> - val signer = Signer { ec { curve = crv; digests = setOf(digest) } } - val data = Random.Default.nextBytes(64) - val signature = signer.sign(SignatureInput(data).let { if (preHashed) it.convertTo(digest).getOrThrow() else it }).getOrThrow() + val signer = Signer { ec { curve = crv; digests = setOf(digest) } } + signer.signatureAlgorithm.shouldBeInstanceOf().let { + it.digest shouldBe digest + it.requiredCurve shouldBeIn setOf(null, crv) + } + val data = Random.Default.nextBytes(64) + val signature = signer.sign(SignatureInput(data).let { if (preHashed) it.convertTo(digest).getOrThrow() else it }).getOrThrow() - val verifier = signer.makeVerifier().getOrThrow() - verifier.verify(data, signature) should succeed + val verifier = signer.makeVerifier().getOrThrow() + verifier.verify(data, signature) should succeed } } }) diff --git a/supreme/src/iosMain/kotlin/at/asitplus/signum/supreme/hazmat/InternalsAccessors.kt b/supreme/src/iosMain/kotlin/at/asitplus/signum/supreme/hazmat/InternalsAccessors.kt new file mode 100644 index 00000000..602c949d --- /dev/null +++ b/supreme/src/iosMain/kotlin/at/asitplus/signum/supreme/hazmat/InternalsAccessors.kt @@ -0,0 +1,18 @@ +package at.asitplus.signum.supreme.hazmat + +import at.asitplus.signum.supreme.HazardousMaterials +import at.asitplus.signum.supreme.os.unlockedIosSigner +import at.asitplus.signum.supreme.sign.EphemeralKey +import at.asitplus.signum.supreme.sign.EphemeralKeyBase +import at.asitplus.signum.supreme.sign.EphemeralKeyRef +import at.asitplus.signum.supreme.sign.EphemeralSigner +import at.asitplus.signum.supreme.sign.Signer + +@HazardousMaterials +val EphemeralKey.secKeyRef get() = ((this as? EphemeralKeyBase<*>)?.privateKey as? EphemeralKeyRef)?.key?.value +@HazardousMaterials +val Signer.secKeyRef get() = when (this) { + is EphemeralSigner -> this.privateKey.key.value + is unlockedIosSigner -> this.privateKeyRef + else -> null +} diff --git a/supreme/src/iosMain/kotlin/at/asitplus/signum/supreme/os/IosKeychainProvider.kt b/supreme/src/iosMain/kotlin/at/asitplus/signum/supreme/os/IosKeychainProvider.kt index 85c0e3b3..b1be05c8 100644 --- a/supreme/src/iosMain/kotlin/at/asitplus/signum/supreme/os/IosKeychainProvider.kt +++ b/supreme/src/iosMain/kotlin/at/asitplus/signum/supreme/os/IosKeychainProvider.kt @@ -139,7 +139,7 @@ class iosSigningKeyConfiguration internal constructor(): PlatformSigningKeyConfi class iosSignerConfiguration internal constructor(): PlatformSignerConfiguration() { } -sealed class unlockedIosSigner(private val ownedArena: Arena, private val privateKeyRef: SecKeyRef) : Signer.UnlockedHandle { +sealed class unlockedIosSigner(private val ownedArena: Arena, internal val privateKeyRef: SecKeyRef) : Signer.UnlockedHandle { abstract val parent: iosSigner<*> val alias get() = parent.alias diff --git a/supreme/src/iosMain/kotlin/at/asitplus/signum/supreme/sign/EphemeralKeysImpl.kt b/supreme/src/iosMain/kotlin/at/asitplus/signum/supreme/sign/EphemeralKeysImpl.kt index 0b1dc2ac..8e36c42f 100644 --- a/supreme/src/iosMain/kotlin/at/asitplus/signum/supreme/sign/EphemeralKeysImpl.kt +++ b/supreme/src/iosMain/kotlin/at/asitplus/signum/supreme/sign/EphemeralKeysImpl.kt @@ -39,7 +39,7 @@ import kotlin.native.ref.createCleaner actual class EphemeralSigningKeyConfiguration internal actual constructor(): EphemeralSigningKeyConfigurationBase() actual class EphemeralSignerConfiguration internal actual constructor(): EphemeralSignerConfigurationBase() -sealed class EphemeralSigner(private val privateKey: EphemeralKeyRef): Signer { +sealed class EphemeralSigner(internal val privateKey: EphemeralKeyRef): Signer { final override val mayRequireUserUnlock: Boolean get() = false final override suspend fun sign(data: SignatureInput) = catching { val inputData = data.convertTo(when (val alg = signatureAlgorithm) { @@ -66,16 +66,13 @@ sealed class EphemeralSigner(private val privateKey: EphemeralKeyRef): Signer { : EphemeralSigner(privateKey), Signer.RSA } -class EphemeralKeyRef: EphemeralKeyPlatformSpecifics { +class EphemeralKeyRef { private val arena = Arena() @OptIn(ExperimentalNativeApi::class) private val cleaner = createCleaner(arena, Arena::clear) - override val key = arena.alloc() + val key = arena.alloc() } -actual interface EphemeralKeyPlatformSpecifics { val key: SecKeyRefVar } -actual val EphemeralKey.platformSpecifics: EphemeralKeyPlatformSpecifics get() = (this as EphemeralKeyBase<*>).privateKey as EphemeralKeyRef - internal actual fun makeEphemeralKey(configuration: EphemeralSigningKeyConfiguration) : EphemeralKey { val key = EphemeralKeyRef() memScoped { diff --git a/supreme/src/jvmMain/kotlin/at/asitplus/signum/supreme/hazmat/InternalsAccessors.kt b/supreme/src/jvmMain/kotlin/at/asitplus/signum/supreme/hazmat/InternalsAccessors.kt new file mode 100644 index 00000000..4784383d --- /dev/null +++ b/supreme/src/jvmMain/kotlin/at/asitplus/signum/supreme/hazmat/InternalsAccessors.kt @@ -0,0 +1,13 @@ +package at.asitplus.signum.supreme.hazmat + +import at.asitplus.signum.supreme.HazardousMaterials +import at.asitplus.signum.supreme.sign.EphemeralKey +import at.asitplus.signum.supreme.sign.EphemeralKeyBase +import at.asitplus.signum.supreme.sign.EphemeralSigner +import at.asitplus.signum.supreme.sign.Signer +import java.security.PrivateKey + +@HazardousMaterials +val EphemeralKey.jcaPrivateKey get() = (this as? EphemeralKeyBase<*>)?.privateKey as? PrivateKey +@HazardousMaterials +val Signer.jcaPrivateKey get() = (this as? EphemeralSigner)?.privateKey diff --git a/supreme/src/jvmMain/kotlin/at/asitplus/signum/supreme/sign/EphemeralKeysImpl.kt b/supreme/src/jvmMain/kotlin/at/asitplus/signum/supreme/sign/EphemeralKeysImpl.kt index 05d8d3a5..5ed6070a 100644 --- a/supreme/src/jvmMain/kotlin/at/asitplus/signum/supreme/sign/EphemeralKeysImpl.kt +++ b/supreme/src/jvmMain/kotlin/at/asitplus/signum/supreme/sign/EphemeralKeysImpl.kt @@ -25,7 +25,7 @@ actual class EphemeralSignerConfiguration internal actual constructor(): Ephemer override var provider: String? = null } -sealed class EphemeralSigner (private val privateKey: PrivateKey, private val provider: String?) : Signer { +sealed class EphemeralSigner (internal val privateKey: PrivateKey, private val provider: String?) : Signer { override val mayRequireUserUnlock = false override suspend fun sign(data: SignatureInput) = catching { val preHashed = (data.format != null) @@ -63,10 +63,6 @@ internal fun getKPGInstance(alg: String, provider: String? = null) = else -> KeyPairGenerator.getInstance(alg, provider) } -actual interface EphemeralKeyPlatformSpecifics { val jcaPrivateKey: PrivateKey } -actual val EphemeralKey.platformSpecifics: EphemeralKeyPlatformSpecifics get() = - object : EphemeralKeyPlatformSpecifics { override val jcaPrivateKey get() = (this@platformSpecifics as EphemeralKeyBase<*>).privateKey as PrivateKey } - internal actual fun makeEphemeralKey(configuration: EphemeralSigningKeyConfiguration) : EphemeralKey = when (val alg = configuration._algSpecific.v) { is SigningKeyConfiguration.ECConfiguration -> { From 1c5ed2bd385300247750bf0f09a644069cf57677 Mon Sep 17 00:00:00 2001 From: Jakob Heher Date: Wed, 28 Aug 2024 13:29:43 +0200 Subject: [PATCH 19/73] experimentals opt-in --- .../at/asitplus/signum/supreme/hazmat/InternalsAccessors.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/supreme/src/iosMain/kotlin/at/asitplus/signum/supreme/hazmat/InternalsAccessors.kt b/supreme/src/iosMain/kotlin/at/asitplus/signum/supreme/hazmat/InternalsAccessors.kt index 602c949d..dede873c 100644 --- a/supreme/src/iosMain/kotlin/at/asitplus/signum/supreme/hazmat/InternalsAccessors.kt +++ b/supreme/src/iosMain/kotlin/at/asitplus/signum/supreme/hazmat/InternalsAccessors.kt @@ -1,3 +1,4 @@ +@file:OptIn(ExperimentalForeignApi::class) package at.asitplus.signum.supreme.hazmat import at.asitplus.signum.supreme.HazardousMaterials @@ -7,6 +8,7 @@ import at.asitplus.signum.supreme.sign.EphemeralKeyBase import at.asitplus.signum.supreme.sign.EphemeralKeyRef import at.asitplus.signum.supreme.sign.EphemeralSigner import at.asitplus.signum.supreme.sign.Signer +import kotlinx.cinterop.ExperimentalForeignApi @HazardousMaterials val EphemeralKey.secKeyRef get() = ((this as? EphemeralKeyBase<*>)?.privateKey as? EphemeralKeyRef)?.key?.value From ce91f4f80b0f5f1896c8ef45eb1fd771444fd45e Mon Sep 17 00:00:00 2001 From: Jakob Heher Date: Wed, 28 Aug 2024 13:39:38 +0200 Subject: [PATCH 20/73] missing import --- .../at/asitplus/signum/supreme/hazmat/InternalsAccessors.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/supreme/src/iosMain/kotlin/at/asitplus/signum/supreme/hazmat/InternalsAccessors.kt b/supreme/src/iosMain/kotlin/at/asitplus/signum/supreme/hazmat/InternalsAccessors.kt index dede873c..417dc171 100644 --- a/supreme/src/iosMain/kotlin/at/asitplus/signum/supreme/hazmat/InternalsAccessors.kt +++ b/supreme/src/iosMain/kotlin/at/asitplus/signum/supreme/hazmat/InternalsAccessors.kt @@ -9,6 +9,7 @@ import at.asitplus.signum.supreme.sign.EphemeralKeyRef import at.asitplus.signum.supreme.sign.EphemeralSigner import at.asitplus.signum.supreme.sign.Signer import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.value @HazardousMaterials val EphemeralKey.secKeyRef get() = ((this as? EphemeralKeyBase<*>)?.privateKey as? EphemeralKeyRef)?.key?.value From 56f30a3823795a1dabec255f9bf66ad616941fcc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bernd=20Pr=C3=BCnster?= Date: Wed, 28 Aug 2024 14:23:58 +0200 Subject: [PATCH 21/73] bump sumpreme version --- supreme/build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/supreme/build.gradle.kts b/supreme/build.gradle.kts index 87bcc678..16785479 100644 --- a/supreme/build.gradle.kts +++ b/supreme/build.gradle.kts @@ -26,7 +26,7 @@ buildscript { } -version = "0.1.1-PRE" +version = "0.2.0-SNAPSHOT" wireAndroidInstrumentedTests() From 7ff5e4b7afdf0bdc48d4c57fc58d2c8ea9a12369 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bernd=20Pr=C3=BCnster?= Date: Wed, 28 Aug 2024 14:57:57 +0200 Subject: [PATCH 22/73] update kotlin+kotest --- build.gradle.kts | 2 +- settings.gradle.kts | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/build.gradle.kts b/build.gradle.kts index 96223ddd..6c1d3b64 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,7 +1,7 @@ import org.jetbrains.dokka.gradle.DokkaMultiModuleTask plugins { - id("at.asitplus.gradle.conventions") version "2.0.0+20240725" + id("at.asitplus.gradle.conventions") version "2.0.20+20240828" id("com.android.library") version "8.2.0" apply (false) } group = "at.asitplus.signum" diff --git a/settings.gradle.kts b/settings.gradle.kts index d4c914d6..16448ad1 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,6 +1,8 @@ pluginManagement { includeBuild("swift-klib-plugin") repositories { + + maven("https://s01.oss.sonatype.org/content/repositories/snapshots") //KOTEST snapshot google() mavenCentral() gradlePluginPortal() From c0f9577c552588253d7e57d0b28509350b137ff9 Mon Sep 17 00:00:00 2001 From: Jakob Heher Date: Wed, 28 Aug 2024 15:16:26 +0200 Subject: [PATCH 23/73] platform signer cleanup --- .../signum/indispensable/JcaExtensions.kt | 16 +- .../supreme/os/AndroidKeyStoreProvider.kt | 119 +++---- .../signum/supreme/sign/EphemeralKeysImpl.kt | 2 +- .../signum/supreme/dsl/ConfigurationDSL.kt | 2 +- .../signum/supreme/os/SigningProvider.kt | 21 +- .../at/asitplus/signum/supreme/sign/Signer.kt | 5 + .../signum/supreme/os/IosKeychainProvider.kt | 25 +- .../asitplus/signum/supreme/os/JKSProvider.kt | 301 +++++++++++++++--- .../signum/supreme/sign/EphemeralKeysImpl.kt | 4 +- .../signum/supreme/os/JKSProviderTest.kt | 47 ++- 10 files changed, 395 insertions(+), 147 deletions(-) diff --git a/indispensable/src/jvmMain/kotlin/at/asitplus/signum/indispensable/JcaExtensions.kt b/indispensable/src/jvmMain/kotlin/at/asitplus/signum/indispensable/JcaExtensions.kt index c2056bb5..d18bdd44 100644 --- a/indispensable/src/jvmMain/kotlin/at/asitplus/signum/indispensable/JcaExtensions.kt +++ b/indispensable/src/jvmMain/kotlin/at/asitplus/signum/indispensable/JcaExtensions.kt @@ -37,13 +37,17 @@ val Digest.jcaPSSParams Digest.SHA512 -> PSSParameterSpec("SHA-512", "MGF1", MGF1ParameterSpec.SHA512, 64, 1) } +internal val isAndroid by lazy { + try { Class.forName("android.os.Build"); true } catch (_: ClassNotFoundException) { false } +} + private fun sigGetInstance(alg: String, provider: String?) = when (provider) { null -> Signature.getInstance(alg) else -> Signature.getInstance(alg, provider) } /** Get a pre-configured JCA instance for this algorithm */ -fun SignatureAlgorithm.getJCASignatureInstance(provider: String? = null, isAndroid: Boolean = false) = catching { +fun SignatureAlgorithm.getJCASignatureInstance(provider: String? = null) = catching { when (this) { is SignatureAlgorithm.ECDSA -> sigGetInstance("${this.digest.jcaAlgorithmComponent}withECDSA", provider) @@ -64,11 +68,11 @@ fun SignatureAlgorithm.getJCASignatureInstance(provider: String? = null, isAndro } } /** Get a pre-configured JCA instance for this algorithm */ -fun SpecializedSignatureAlgorithm.getJCASignatureInstance(provider: String? = null, isAndroid: Boolean = false) = - this.algorithm.getJCASignatureInstance(provider, isAndroid) +fun SpecializedSignatureAlgorithm.getJCASignatureInstance(provider: String? = null) = + this.algorithm.getJCASignatureInstance(provider) /** Get a pre-configured JCA instance for pre-hashed data for this algorithm */ -fun SignatureAlgorithm.getJCASignatureInstancePreHashed(provider: String? = null, isAndroid: Boolean = false) = catching { +fun SignatureAlgorithm.getJCASignatureInstancePreHashed(provider: String? = null) = catching { when (this) { is SignatureAlgorithm.ECDSA -> sigGetInstance("NONEwithECDSA", provider) is SignatureAlgorithm.RSA -> when (this.padding) { @@ -86,8 +90,8 @@ fun SignatureAlgorithm.getJCASignatureInstancePreHashed(provider: String? = null } /** Get a pre-configured JCA instance for pre-hashed data for this algorithm */ -fun SpecializedSignatureAlgorithm.getJCASignatureInstancePreHashed(provider: String? = null, isAndroid: Boolean = false) = - this.algorithm.getJCASignatureInstancePreHashed(provider, isAndroid) +fun SpecializedSignatureAlgorithm.getJCASignatureInstancePreHashed(provider: String? = null) = + this.algorithm.getJCASignatureInstancePreHashed(provider) val Digest.jcaName get() = when (this) { diff --git a/supreme/src/androidMain/kotlin/at/asitplus/signum/supreme/os/AndroidKeyStoreProvider.kt b/supreme/src/androidMain/kotlin/at/asitplus/signum/supreme/os/AndroidKeyStoreProvider.kt index 85224a5f..34a3c11a 100644 --- a/supreme/src/androidMain/kotlin/at/asitplus/signum/supreme/os/AndroidKeyStoreProvider.kt +++ b/supreme/src/androidMain/kotlin/at/asitplus/signum/supreme/os/AndroidKeyStoreProvider.kt @@ -58,16 +58,23 @@ internal sealed interface FragmentContext { } -class AndroidKeymasterConfiguration internal constructor(): PlatformSigningKeyConfiguration.SecureHardwareConfiguration() { +class AndroidKeymasterConfiguration internal constructor(): PlatformSigningKeyConfigurationBase.SecureHardwareConfiguration() { /** Whether a StrongBox TPM is required. */ var strongBox: FeaturePreference = PREFERRED } -class AndroidSigningKeyConfiguration internal constructor(): PlatformSigningKeyConfiguration() { +class AndroidSigningKeyConfiguration internal constructor(): PlatformSigningKeyConfigurationBase() { override val hardware = childOrNull(::AndroidKeymasterConfiguration) } -class AndroidSignerConfiguration: PlatformSignerConfiguration() { - class AuthnPrompt: PlatformSignerConfiguration.AuthnPrompt() { +class AndroidSignerConfiguration: PlatformSignerConfigurationBase() { + class AuthnPrompt: PlatformSignerConfigurationBase.AuthnPrompt() { + lateinit var activity: FragmentActivity + lateinit var fragment: Fragment + internal val context: FragmentContext? get() = when { + this::activity.isInitialized -> FragmentContext.OfActivity(activity) + this::fragment.isInitialized -> FragmentContext.OfFragment(fragment) + else -> null + } var subtitle: String? = null var description: String? = null var confirmationRequired: Boolean? = null @@ -105,45 +112,15 @@ internal inline fun resolveOption(what: String, valid: Array private fun attestationFor(chain: CertificateChain) = if (chain.size > 1) AndroidKeystoreAttestation(chain) else null -sealed class AndroidKeyStoreProviderImpl private constructor() : - SigningProviderI +class AndroidKeyStoreProvider: + SigningProviderI { - class WithoutContext internal constructor() : - AndroidKeyStoreProviderImpl() - { - override val context get() = null - } - - class WithContext internal constructor(override val context: FragmentContext) : - AndroidKeyStoreProviderImpl() - - companion object { - /** - * Instantiate the keystore provider without associating an activity or fragment. - * Biometric authentication will be impossible. - */ - operator fun invoke() = - WithoutContext() - - /** - * Instantiate the keystore provider associated with this particular activity. - */ - operator fun invoke(activity: FragmentActivity) = - WithContext(FragmentContext.OfActivity(activity)) - - /** - * Instantiate the keystore provider associated with this particular fragment. - */ - operator fun invoke(fragment: Fragment) = - WithContext(FragmentContext.OfFragment(fragment)) - } - private val ks: KeyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null, null) } @SuppressLint("WrongConstant") - final override suspend fun createSigningKey( + override suspend fun createSigningKey( alias: String, configure: DSLConfigureFn ) = catching { @@ -197,12 +174,10 @@ sealed class AndroidKeyStoreProviderImpl private return@catching getSignerForKey(alias, config.signer.v).getOrThrow() } - internal abstract val context: FragmentContext? - - final override suspend fun getSignerForKey( + override suspend fun getSignerForKey( alias: String, configure: DSLConfigureFn - ): KmmResult = catching { + ): KmmResult = catching { val jcaPrivateKey = ks.getKey(alias, null) as? PrivateKey ?: throw NoSuchElementException("No key for alias $alias exists") val config = DSL.resolve(::AndroidSignerConfiguration, configure) @@ -229,68 +204,65 @@ sealed class AndroidKeyStoreProviderImpl private } } - val result: AndroidKeystoreSigner = if (keyInfo.isUserAuthenticationRequired) { - val ctx = context - ?: throw IllegalStateException("Key requires biometric authentication, but no fragment/activity context is available.") + return@catching if (keyInfo.isUserAuthenticationRequired) { when (certificateChain.leaf.publicKey) { is CryptoPublicKey.EC -> LockedAndroidKeystoreSigner.ECDSA( - ctx, jcaPrivateKey, keyInfo, config, certificateChain, + jcaPrivateKey, alias, keyInfo, config, certificateChain, algorithm as SignatureAlgorithm.ECDSA) is CryptoPublicKey.Rsa -> LockedAndroidKeystoreSigner.RSA( - ctx, jcaPrivateKey, keyInfo, config, certificateChain, + jcaPrivateKey, alias, keyInfo, config, certificateChain, algorithm as SignatureAlgorithm.RSA) } } else { - val jcaSig = algorithm.getJCASignatureInstance(isAndroid=true) + val jcaSig = algorithm.getJCASignatureInstance() .getOrThrow().also { it.initSign(jcaPrivateKey) } when (val publicKey = certificateChain.leaf.publicKey) { is CryptoPublicKey.EC -> UnlockedAndroidKeystoreSigner.ECDSA( - jcaSig, keyInfo, attestationFor(certificateChain), publicKey, + jcaSig, alias, keyInfo, attestationFor(certificateChain), publicKey, algorithm as SignatureAlgorithm.ECDSA) is CryptoPublicKey.Rsa -> UnlockedAndroidKeystoreSigner.RSA( - jcaSig, keyInfo, attestationFor(certificateChain), publicKey, + jcaSig, alias, keyInfo, attestationFor(certificateChain), publicKey, algorithm as SignatureAlgorithm.RSA) } } - @Suppress("UNCHECKED_CAST") - return@catching result as SignerT } - final override suspend fun deleteSigningKey(alias: String) { + override suspend fun deleteSigningKey(alias: String) { ks.deleteEntry(alias) } } -typealias AndroidKeyStoreProvider = AndroidKeyStoreProviderImpl<*> - -interface AndroidKeystoreSigner : SignerI.Attestable { +interface AndroidKeystoreSigner : SignerI.Attestable, SignerI.WithAlias { val keyInfo: KeyInfo override val attestation: AndroidKeystoreAttestation? } sealed class UnlockedAndroidKeystoreSigner private constructor( private val jcaSig: JCASignatureObject, + override val alias: String, override val keyInfo: KeyInfo, override val attestation: AndroidKeystoreAttestation? ): SignerI.UnlockedHandle, AndroidKeystoreSigner { class ECDSA internal constructor(jcaSig: JCASignatureObject, + alias: String, keyInfo: KeyInfo, certificateChain: AndroidKeystoreAttestation?, override val publicKey: CryptoPublicKey.EC, override val signatureAlgorithm: SignatureAlgorithm.ECDSA - ) : UnlockedAndroidKeystoreSigner(jcaSig, keyInfo, certificateChain), SignerI.ECDSA + ) : UnlockedAndroidKeystoreSigner(jcaSig, alias, keyInfo, certificateChain), SignerI.ECDSA class RSA internal constructor(jcaSig: JCASignatureObject, + alias: String, keyInfo: KeyInfo, certificateChain: AndroidKeystoreAttestation?, override val publicKey: CryptoPublicKey.Rsa, override val signatureAlgorithm: SignatureAlgorithm.RSA - ) : UnlockedAndroidKeystoreSigner(jcaSig, keyInfo, certificateChain), SignerI.RSA + ) : UnlockedAndroidKeystoreSigner(jcaSig, alias, keyInfo, certificateChain), SignerI.RSA final override suspend fun sign(data: SignatureInput) = catching { require(data.format == null) @@ -307,14 +279,17 @@ sealed class UnlockedAndroidKeystoreSigner private constructor( } sealed class LockedAndroidKeystoreSigner private constructor( - private val context: FragmentContext, internal val jcaPrivateKey: PrivateKey, - override val keyInfo: KeyInfo, + final override val alias: String, + final override val keyInfo: KeyInfo, private val config: AndroidSignerConfiguration, certificateChain: CertificateChain ) : SignerI.TemporarilyUnlockable(), AndroidKeystoreSigner { - override val attestation = attestationFor(certificateChain) + private val context = config.unlockPrompt.v.context + ?: throw UnsupportedCryptoException("The requested key with alias $alias requires unlock. Pass either { fragment = } or { activity = } inside authPrompt {}.") + + final override val attestation = attestationFor(certificateChain) private sealed interface AuthResult { @JvmInline value class Success(val result: AuthenticationResult): AuthResult data class Error(val code: Int, val message: String): AuthResult @@ -367,7 +342,7 @@ sealed class LockedAndroidKeystoreSigner private constructor( protected abstract fun toUnlocked(jcaSig: JCASignatureObject): UnlockedAndroidKeystoreSigner final override suspend fun unlock(): KmmResult = - signatureAlgorithm.getJCASignatureInstance(isAndroid=true).onSuccess { + signatureAlgorithm.getJCASignatureInstance().onSuccess { if (needsAuthenticationForEveryUse) { it.initSign(jcaPrivateKey) attemptBiometry(config.unlockPrompt.v, CryptoObject(it)) @@ -381,28 +356,28 @@ sealed class LockedAndroidKeystoreSigner private constructor( } }.mapCatching(this::toUnlocked) - class ECDSA internal constructor(context: FragmentContext, - jcaPrivateKey: PrivateKey, + class ECDSA internal constructor(jcaPrivateKey: PrivateKey, + alias: String, keyInfo: KeyInfo, config: AndroidSignerConfiguration, certificateChain: CertificateChain, override val signatureAlgorithm: SignatureAlgorithm.ECDSA) - : LockedAndroidKeystoreSigner(context, jcaPrivateKey, keyInfo, config, certificateChain), SignerI.ECDSA { + : LockedAndroidKeystoreSigner(jcaPrivateKey, alias, keyInfo, config, certificateChain), SignerI.ECDSA { override val publicKey = certificateChain.leaf.publicKey as CryptoPublicKey.EC override fun toUnlocked(jcaSig: Signature) = - UnlockedAndroidKeystoreSigner.ECDSA(jcaSig, keyInfo, attestation, publicKey, signatureAlgorithm) + UnlockedAndroidKeystoreSigner.ECDSA(jcaSig, alias, keyInfo, attestation, publicKey, signatureAlgorithm) } - class RSA internal constructor(context: FragmentContext, - jcaPrivateKey: PrivateKey, + class RSA internal constructor(jcaPrivateKey: PrivateKey, + alias: String, keyInfo: KeyInfo, config: AndroidSignerConfiguration, certificateChain: CertificateChain, override val signatureAlgorithm: SignatureAlgorithm.RSA) - : LockedAndroidKeystoreSigner(context, jcaPrivateKey, keyInfo, config, certificateChain), SignerI.RSA { + : LockedAndroidKeystoreSigner(jcaPrivateKey, alias, keyInfo, config, certificateChain), SignerI.RSA { override val publicKey = certificateChain.leaf.publicKey as CryptoPublicKey.Rsa override fun toUnlocked(jcaSig: Signature) = - UnlockedAndroidKeystoreSigner.RSA(jcaSig, keyInfo, attestation, publicKey, signatureAlgorithm) + UnlockedAndroidKeystoreSigner.RSA(jcaSig, alias, keyInfo, attestation, publicKey, signatureAlgorithm) } } @@ -414,3 +389,9 @@ val AndroidKeystoreSigner.needsAuthenticationForEveryUse inline get() = val AndroidKeystoreSigner.needsAuthenticationWithTimeout inline get() = keyInfo.isUserAuthenticationRequired && (keyInfo.userAuthenticationValidityDurationSeconds > 0) + +actual typealias PlatformSigningProviderSignerConfiguration = AndroidSignerConfiguration +actual typealias PlatformSigningProvider = AndroidKeyStoreProvider +actual typealias PlatformSigningProviderConfiguration = PlatformSigningProviderConfigurationBase +internal actual fun makePlatformSigningProvider(config: PlatformSigningProviderConfiguration) = + AndroidKeyStoreProvider() diff --git a/supreme/src/androidMain/kotlin/at/asitplus/signum/supreme/sign/EphemeralKeysImpl.kt b/supreme/src/androidMain/kotlin/at/asitplus/signum/supreme/sign/EphemeralKeysImpl.kt index 2e0f0cb4..a8e2ccd9 100644 --- a/supreme/src/androidMain/kotlin/at/asitplus/signum/supreme/sign/EphemeralKeysImpl.kt +++ b/supreme/src/androidMain/kotlin/at/asitplus/signum/supreme/sign/EphemeralKeysImpl.kt @@ -26,7 +26,7 @@ sealed class AndroidEphemeralSigner (internal val privateKey: PrivateKey) : Sign is SignatureAlgorithm.ECDSA -> alg.digest else -> TODO("hmac unsupported") }).getOrThrow() - signatureAlgorithm.getJCASignatureInstancePreHashed(provider = null, isAndroid = true).getOrThrow().run { + signatureAlgorithm.getJCASignatureInstancePreHashed(provider = null).getOrThrow().run { initSign(privateKey) inputData.data.forEach { update(it) } sign().let { diff --git a/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/dsl/ConfigurationDSL.kt b/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/dsl/ConfigurationDSL.kt index 2301e01a..f536ebca 100644 --- a/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/dsl/ConfigurationDSL.kt +++ b/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/dsl/ConfigurationDSL.kt @@ -128,7 +128,7 @@ object DSL { Integrated() /** - * Marks a DSL substructure as unsupported. + * Marks an inherited DSL substructure as unsupported. Attempts to use it throw [UnsupportedOperationException]. Use very sparingly. */ protected fun unsupported(why: String): Unsupported = Unsupported(why) diff --git a/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/os/SigningProvider.kt b/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/os/SigningProvider.kt index 6cf7c960..cb30768e 100644 --- a/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/os/SigningProvider.kt +++ b/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/os/SigningProvider.kt @@ -13,7 +13,7 @@ import at.asitplus.signum.supreme.sign.SigningKeyConfiguration import kotlin.time.Duration import kotlin.time.Duration.Companion.seconds -open class PlatformSigningKeyConfiguration internal constructor(): SigningKeyConfiguration() { +open class PlatformSigningKeyConfigurationBase internal constructor(): SigningKeyConfiguration() { open class AttestationConfiguration internal constructor(): DSL.Data() { /** The server-provided attestation challenge */ lateinit var challenge: ByteArray @@ -97,7 +97,7 @@ open class SignerConfiguration internal constructor(): DSL.Data() { open val rsa = childOrDefault(::RSASignerConfiguration) } -open class PlatformSignerConfiguration internal constructor(): SignerConfiguration() { +open class PlatformSignerConfigurationBase internal constructor(): SignerConfiguration() { open class AuthnPrompt: DSL.Data() { /** The prompt message to show to the user when asking for unlock */ var message: String = "Please authorize cryptographic signature" @@ -107,11 +107,22 @@ open class PlatformSignerConfiguration internal constructor(): SignerConfigurati open val unlockPrompt = childOrDefault(::AuthnPrompt) } -interface SigningProviderI> { +open class PlatformSigningProviderConfigurationBase internal constructor(): DSL.Data() +expect class PlatformSigningProviderConfiguration internal constructor(): PlatformSigningProviderConfigurationBase +expect class PlatformSigningProviderSignerConfiguration: PlatformSignerConfigurationBase +expect class PlatformSigningProvider : SigningProviderI> +internal expect fun makePlatformSigningProvider(config: PlatformSigningProviderConfiguration): KmmResult + +interface SigningProviderI> { suspend fun createSigningKey(alias: String, configure: DSLConfigureFn = null) : KmmResult suspend fun getSignerForKey(alias: String, configure: DSLConfigureFn = null) : KmmResult suspend fun deleteSigningKey(alias: String) + + companion object { + operator fun invoke(configure: DSLConfigureFn = null) = + makePlatformSigningProvider(DSL.resolve(::PlatformSigningProviderConfiguration, configure)) + } } typealias SigningProvider = SigningProviderI<*,*,*> diff --git a/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/sign/Signer.kt b/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/sign/Signer.kt index 2f9654e0..e0d7fd05 100644 --- a/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/sign/Signer.kt +++ b/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/sign/Signer.kt @@ -61,6 +61,11 @@ interface Signer { override val publicKey: CryptoPublicKey.Rsa } + /** Some [Signer]s are retrieved from a signing provider, such as a key store, and have a string alias. */ + interface WithAlias: Signer { + val alias: String + } + /** Some [Signer]s might have an attestation of some sort */ interface Attestable: Signer { val attestation: AttestationT? diff --git a/supreme/src/iosMain/kotlin/at/asitplus/signum/supreme/os/IosKeychainProvider.kt b/supreme/src/iosMain/kotlin/at/asitplus/signum/supreme/os/IosKeychainProvider.kt index b1be05c8..9e8220db 100644 --- a/supreme/src/iosMain/kotlin/at/asitplus/signum/supreme/os/IosKeychainProvider.kt +++ b/supreme/src/iosMain/kotlin/at/asitplus/signum/supreme/os/IosKeychainProvider.kt @@ -123,20 +123,20 @@ private object KeychainTags { val PUBLIC_KEYS get() = tags.second } -class iosSecureEnclaveConfiguration internal constructor() : PlatformSigningKeyConfiguration.SecureHardwareConfiguration() { +class iosSecureEnclaveConfiguration internal constructor() : PlatformSigningKeyConfigurationBase.SecureHardwareConfiguration() { /** Set to true to allow this key to be backed up. */ var allowBackup = false enum class Availability { ALWAYS, AFTER_FIRST_UNLOCK, WHILE_UNLOCKED } /** Specify when this key should be available */ var availability = Availability.ALWAYS } -class iosSigningKeyConfiguration internal constructor(): PlatformSigningKeyConfiguration() { +class iosSigningKeyConfiguration internal constructor(): PlatformSigningKeyConfigurationBase() { override val hardware = childOrDefault(::iosSecureEnclaveConfiguration) { backing = DISCOURAGED } } -class iosSignerConfiguration internal constructor(): PlatformSignerConfiguration() { +class iosSignerConfiguration internal constructor(): PlatformSignerConfigurationBase() { } sealed class unlockedIosSigner(private val ownedArena: Arena, internal val privateKeyRef: SecKeyRef) : Signer.UnlockedHandle { @@ -158,7 +158,7 @@ sealed class unlockedIosSigner(private val ownedArena: Arena, internal val priva } protected abstract fun bytesToSignature(sigBytes: ByteArray): CryptoSignature - override suspend fun sign(data: SignatureInput): KmmResult = + final override suspend fun sign(data: SignatureInput): KmmResult = withContext(keychainThreads) { catching { if (!usable) throw IllegalStateException("Scoping violation; using key after it has been freed") require(data.format == null) { "Pre-hashed data is unsupported on iOS" } @@ -195,12 +195,12 @@ sealed class unlockedIosSigner(private val ownedArena: Arena, internal val priva } sealed class iosSigner( - val alias: String, - override val attestation: iosHomebrewAttestation?, + final override val alias: String, + final override val attestation: iosHomebrewAttestation?, private val config: iosSignerConfiguration -) : Signer.TemporarilyUnlockable(), Signer.Attestable { +) : Signer.TemporarilyUnlockable(), Signer.Attestable, Signer.WithAlias { - override suspend fun unlock(): KmmResult = withContext(keychainThreads) { catching { + final override suspend fun unlock(): KmmResult = withContext(keychainThreads) { catching { val arena = Arena() val privateKey = arena.alloc() try { @@ -266,7 +266,7 @@ sealed class iosSigner( } @OptIn(ExperimentalForeignApi::class) -object IosKeychainProvider: SigningProviderI, iosSignerConfiguration, iosSigningKeyConfiguration> { +object IosKeychainProvider: SigningProviderI, iosSignerConfiguration, iosSigningKeyConfiguration> { private fun MemScope.getPublicKey(alias: String): SecKeyRef? { val it = alloc() val query = cfDictionaryOf( @@ -514,5 +514,10 @@ object IosKeychainProvider: SigningProviderI, iosSignerConfigurati } } } - } + +actual typealias PlatformSigningProviderSignerConfiguration = iosSignerConfiguration +actual typealias PlatformSigningProvider = IosKeychainProvider +actual typealias PlatformSigningProviderConfiguration = PlatformSigningProviderConfigurationBase +internal actual fun makePlatformSigningProvider(config: PlatformSigningProviderConfiguration) = + IosKeychainProvider diff --git a/supreme/src/jvmMain/kotlin/at/asitplus/signum/supreme/os/JKSProvider.kt b/supreme/src/jvmMain/kotlin/at/asitplus/signum/supreme/os/JKSProvider.kt index 9c7f044f..12ec5fe9 100644 --- a/supreme/src/jvmMain/kotlin/at/asitplus/signum/supreme/os/JKSProvider.kt +++ b/supreme/src/jvmMain/kotlin/at/asitplus/signum/supreme/os/JKSProvider.kt @@ -29,7 +29,11 @@ import at.asitplus.signum.supreme.sign.SigningKeyConfiguration import at.asitplus.signum.supreme.sign.getKPGInstance import com.ionspin.kotlin.bignum.integer.base63.toJavaBigInteger import kotlinx.datetime.Clock -import java.security.KeyPairGenerator +import java.nio.channels.Channels +import java.nio.channels.FileChannel +import java.nio.channels.FileLock +import java.nio.file.Path +import java.nio.file.StandardOpenOption import java.security.KeyStore import java.security.PrivateKey import java.security.SecureRandom @@ -40,75 +44,100 @@ import java.security.spec.RSAKeyGenParameterSpec import kotlin.time.Duration import kotlin.time.Duration.Companion.days -class JKSSigningKeyConfiguration: PlatformSigningKeyConfiguration() { +class JKSSigningKeyConfiguration: PlatformSigningKeyConfigurationBase() { var provider: String? = null var privateKeyPassword: CharArray? = null var certificateValidityPeriod: Duration = 100.days } -class JKSSignerConfiguration: PlatformSignerConfiguration(), JvmEphemeralSignerCompatibleConfiguration { +class JKSSignerConfiguration: PlatformSignerConfigurationBase(), JvmEphemeralSignerCompatibleConfiguration { override var provider: String? = null var privateKeyPassword: CharArray? = null } -sealed interface JKSSigner: Signer, Signer.Attestable { +interface JKSSigner: Signer, Signer.Attestable, Signer.WithAlias { class EC internal constructor (config: JvmEphemeralSignerCompatibleConfiguration, privateKey: PrivateKey, publicKey: CryptoPublicKey.EC, signatureAlgorithm: SignatureAlgorithm.ECDSA, - certificate: X509Certificate) + certificate: X509Certificate, override val alias: String) : EphemeralSigner.EC(config, privateKey, publicKey, signatureAlgorithm), JKSSigner { override val attestation = SelfAttestation(certificate) } class RSA internal constructor (config: JvmEphemeralSignerCompatibleConfiguration, privateKey: PrivateKey, publicKey: CryptoPublicKey.Rsa, signatureAlgorithm: SignatureAlgorithm.RSA, - certificate: X509Certificate) + certificate: X509Certificate, override val alias: String) : EphemeralSigner.RSA(config, privateKey, publicKey, signatureAlgorithm), JKSSigner { override val attestation = SelfAttestation(certificate) } } -class JKSProvider(private val ks: KeyStore): SigningProviderI { +private fun keystoreGetInstance(type: String, provider: String?) = when (provider) { + null -> KeyStore.getInstance(type) + else -> KeyStore.getInstance(type, provider) +} + +sealed interface ReadAccessorBase: AutoCloseable { + val ks: KeyStore +} + +abstract class WriteAccessorBase: ReadAccessorBase { + protected var dirty = false; private set + fun markAsDirty() { dirty = true } +} + +sealed interface JKSAccessor { + fun forReading(): ReadAccessorBase + fun forWriting(): WriteAccessorBase +} + +class JKSProvider internal constructor (private val access: JKSAccessor) + : SigningProviderI { + override suspend fun createSigningKey( alias: String, configure: DSLConfigureFn ): KmmResult = catching { - if (ks.containsAlias(alias)) - throw NoSuchElementException("Key with alias $alias already exists") - val config = DSL.resolve(::JKSSigningKeyConfiguration, configure) - - val (jcaAlg,jcaSpec,certAlg) = when (val algSpec = config._algSpecific.v) { - is SigningKeyConfiguration.RSAConfiguration -> - Triple("RSA", RSAKeyGenParameterSpec(algSpec.bits, algSpec.publicExponent.toJavaBigInteger()), X509SignatureAlgorithm.RS256) - is SigningKeyConfiguration.ECConfiguration -> - Triple("EC", ECGenParameterSpec(algSpec.curve.jcaName), X509SignatureAlgorithm.ES256) - } - val keyPair = getKPGInstance(jcaAlg, config.provider).run { - initialize(jcaSpec) - generateKeyPair() + access.forWriting().use { ctx -> + if (ctx.ks.containsAlias(alias)) + throw NoSuchElementException("Key with alias $alias already exists") + val config = DSL.resolve(::JKSSigningKeyConfiguration, configure) + + val (jcaAlg,jcaSpec,certAlg) = when (val algSpec = config._algSpecific.v) { + is SigningKeyConfiguration.RSAConfiguration -> + Triple("RSA", RSAKeyGenParameterSpec(algSpec.bits, algSpec.publicExponent.toJavaBigInteger()), X509SignatureAlgorithm.RS256) + is SigningKeyConfiguration.ECConfiguration -> + Triple("EC", ECGenParameterSpec(algSpec.curve.jcaName), X509SignatureAlgorithm.ES256) + } + val keyPair = getKPGInstance(jcaAlg, config.provider).run { + initialize(jcaSpec) + generateKeyPair() + } + val cn = listOf(RelativeDistinguishedName(AttributeTypeAndValue.CommonName(Asn1String.UTF8(alias)))) + val publicKey = CryptoPublicKey.fromJcaPublicKey(keyPair.public).getOrThrow() + val tbsCert = TbsCertificate( + serialNumber = ByteArray(32).also { SecureRandom().nextBytes(it) }, + signatureAlgorithm = certAlg, + issuerName = cn, + subjectName = cn, + validFrom = Asn1Time(Clock.System.now()), + validUntil = Asn1Time(Clock.System.now() + config.certificateValidityPeriod), + publicKey = publicKey + ) + val cert = certAlg.getJCASignatureInstance(provider = config.provider).getOrThrow().run { + initSign(keyPair.private) + update(tbsCert.encodeToDer()) + sign() + }.let { X509Certificate(tbsCert, certAlg, CryptoSignature.parseFromJca(it, certAlg)) } + ctx.ks.setKeyEntry(alias, keyPair.private, config.privateKeyPassword, + arrayOf(cert.toJcaCertificate().getOrThrow())) + ctx.markAsDirty() + + getSigner(alias, DSL.resolve(::JKSSignerConfiguration, config.signer.v), keyPair.private, cert) } - val cn = listOf(RelativeDistinguishedName(AttributeTypeAndValue.CommonName(Asn1String.UTF8(alias)))) - val publicKey = CryptoPublicKey.fromJcaPublicKey(keyPair.public).getOrThrow() - val tbsCert = TbsCertificate( - serialNumber = ByteArray(32).also { SecureRandom().nextBytes(it) }, - signatureAlgorithm = certAlg, - issuerName = cn, - subjectName = cn, - validFrom = Asn1Time(Clock.System.now()), - validUntil = Asn1Time(Clock.System.now() + config.certificateValidityPeriod), - publicKey = publicKey - ) - val cert = certAlg.getJCASignatureInstance(provider = config.provider, isAndroid = false).getOrThrow().run { - initSign(keyPair.private) - update(tbsCert.encodeToDer()) - sign() - }.let { X509Certificate(tbsCert, certAlg, CryptoSignature.parseFromJca(it, certAlg)) } - ks.setKeyEntry(alias, keyPair.private, config.privateKeyPassword, - arrayOf(cert.toJcaCertificate().getOrThrow())) - - return@catching getSigner(DSL.resolve(::JKSSignerConfiguration, config.signer.v), keyPair.private, cert) } private fun getSigner( + alias: String, config: JKSSignerConfiguration, privateKey: PrivateKey, certificate: X509Certificate @@ -117,33 +146,201 @@ class JKSProvider(private val ks: KeyStore): SigningProviderI JKSSigner.RSA(config, privateKey as RSAPrivateKey, publicKey, SignatureAlgorithm.RSA( digest = if (config.rsa.v.digestSpecified) config.rsa.v.digest else Digest.SHA256, padding = if (config.rsa.v.paddingSpecified) config.rsa.v.padding else RSAPadding.PSS), - certificate) + certificate, alias) } override suspend fun getSignerForKey( alias: String, configure: DSLConfigureFn ): KmmResult = catching { - val config = DSL.resolve(::JKSSignerConfiguration, configure) - val privateKey = ks.getKey(alias, config.privateKeyPassword) as PrivateKey - val certificateChain = ks.getCertificateChain(alias).map { X509Certificate.decodeFromDer(it.encoded) } - return@catching getSigner(config, privateKey, certificateChain.leaf) + access.forReading().use { ctx -> + val config = DSL.resolve(::JKSSignerConfiguration, configure) + val privateKey = ctx.ks.getKey(alias, config.privateKeyPassword) as PrivateKey + val certificateChain = ctx.ks.getCertificateChain(alias).map { X509Certificate.decodeFromDer(it.encoded) } + return@catching getSigner(alias, config, privateKey, certificateChain.leaf) + } } override suspend fun deleteSigningKey(alias: String) { - if (ks.containsAlias(alias)) - ks.deleteEntry(alias) + access.forWriting().use { ctx -> + if (ctx.ks.containsAlias(alias)) { + ctx.ks.deleteEntry(alias) + ctx.markAsDirty() + } + } } companion object { - fun Ephemeral(provider: String? = null) = JKSProvider(when (provider) { - null -> KeyStore.getInstance(KeyStore.getDefaultType()) - else -> KeyStore.getInstance(KeyStore.getDefaultType(), provider) - }.apply { load(null) }) + fun Ephemeral(type: String = KeyStore.getDefaultType(), provider: String? = null) = + JKSProvider(DummyJKSAccessor(keystoreGetInstance(type, provider).apply { load(null) })) + } +} + +internal class DummyJKSAccessor(override val ks: KeyStore): JKSAccessor, WriteAccessorBase() { + override fun forReading() = this + override fun forWriting() = this + override fun close() {} +} + +internal class CallbackJKSAccessor(override val ks: KeyStore, private val callback: ((KeyStore)->Unit)?): ReadAccessorBase, JKSAccessor { + inner class WriteAccessor: WriteAccessorBase() { + override val ks: KeyStore get() = this@CallbackJKSAccessor.ks + override fun close() { if (dirty) this@CallbackJKSAccessor.callback?.invoke(this@CallbackJKSAccessor.ks) } + } + + override fun close() {} + override fun forReading() = this + override fun forWriting() = WriteAccessor() +} + +internal class JKSFileAccessor(opt: JKSProviderConfiguration.KeyStoreFile) : JKSAccessor { + val type = opt.storeType + val file = opt.file + val password = opt.password + val readOnly = opt.readOnly + val provider = opt.provider + init { + if (opt.createIfMissing && !readOnly) { + try { + FileChannel.open(file, StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE) + } catch (_: java.nio.file.FileAlreadyExistsException) { null } + ?.use { channel -> + channel.lock().use { + channel.truncate(0L) + keystoreGetInstance(type, provider).apply { load(null) } + .store(Channels.newOutputStream(channel), password) + } + } + } + } + inner class ReadAccessor: ReadAccessorBase { + private val channel: FileChannel + private val lock: FileLock + override val ks: KeyStore + init { + channel = FileChannel.open(file, StandardOpenOption.READ) + try { + lock = channel.lock(0L, Long.MAX_VALUE, true) + try { + ks = keystoreGetInstance(type, provider) + .apply { load(Channels.newInputStream(channel), password) } + } catch (x: Exception) { + lock.close() + throw x + } + } catch (x: Exception) { + channel.close() + throw x + } + } + + override fun close() { try { lock.close(); } finally { channel.close() } } + } + override fun forReading() = ReadAccessor() + + inner class WriteAccessor: WriteAccessorBase() { + private val channel: FileChannel + private val lock: FileLock + override val ks: KeyStore + init { + channel = FileChannel.open(file, StandardOpenOption.READ, StandardOpenOption.WRITE) + try { + lock = channel.lock(0L, Long.MAX_VALUE, false) + try { + ks = keystoreGetInstance(type, provider) + .apply { load(Channels.newInputStream(channel), password) } + } catch (x: Exception) { + lock.close() + throw x + } + } catch (x: Exception) { + channel.close() + throw x + } + } + + override fun close() { + try { + if (dirty) { + channel.truncate(0L) + ks.store(Channels.newOutputStream(channel), password) + } + } finally { + channel.use { channel -> + lock.close() + } + } + } + } + override fun forWriting() = WriteAccessor() +} + +actual typealias PlatformSigningProviderSignerConfiguration = JKSSignerConfiguration +actual typealias PlatformSigningProvider = JKSProvider +actual typealias PlatformSigningProviderConfiguration = JKSProviderConfiguration +internal actual fun makePlatformSigningProvider(config: JKSProviderConfiguration): KmmResult = catching { + when (val opt = config._keystore.v) { + is JKSProviderConfiguration.EphemeralKeyStore -> + JKSProvider.Ephemeral(opt.storeType, opt.provider) + is JKSProviderConfiguration.KeyStoreObject -> + JKSProvider(opt.flushCallback?.let { CallbackJKSAccessor(opt.store, it) } ?: DummyJKSAccessor(opt.store)) + is JKSProviderConfiguration.KeyStoreFile -> + JKSProvider(JKSFileAccessor(opt)) + } +} +class JKSProviderConfiguration internal constructor(): PlatformSigningProviderConfigurationBase() { + sealed class KeyStoreConfiguration constructor(): DSL.Data() + internal val _keystore = subclassOf(default = EphemeralKeyStore()) + + class EphemeralKeyStore internal constructor(): KeyStoreConfiguration() { + /** The KeyStore type to use */ + var storeType: String = KeyStore.getDefaultType() + /** The JCA provider to use. Leave `null` to not care. */ + var provider: String? = null + } + + class KeyStoreObject internal constructor(): KeyStoreConfiguration() { + /** The KeyStore object to use */ + lateinit var store: KeyStore + /** The function to be called when the keystore is modified. Can be `null`. */ + var flushCallback: ((KeyStore)->Unit)? = null + override fun validate() { + super.validate() + require(this::store.isInitialized) + } + } + + /** + * Constructs a keystore from a java KeyStore object. Use `keystoreObject { store = ... }`. + */ + val keystoreObject = _keystore.option(::KeyStoreObject) + + class KeyStoreFile internal constructor(): KeyStoreConfiguration() { + /** The KeyStore type to use */ + var storeType = KeyStore.getDefaultType() + /** The file to use */ + lateinit var file: Path + /** The password to protect the keystore with */ + var password: CharArray? = null + /** The JCA provider to use. Leave `null` to use any. */ + var provider: String? = null + /** Whether to open the keystore file in read-only mode. Changes can be made, but will not be flushed to disk. Defaults to false. */ + var readOnly = false + /** Whether to create the keystore file if missing. Defaults to true. Will be forced to false if `readOnly = true` is set. */ + var createIfMissing = true + + override fun validate() { + super.validate() + require(this::file.isInitialized) + } } + /** + * Accesses a keystore on disk. Automatically flushes back to disk. + */ + val keystoreFile = _keystore.option(::KeyStoreFile) } diff --git a/supreme/src/jvmMain/kotlin/at/asitplus/signum/supreme/sign/EphemeralKeysImpl.kt b/supreme/src/jvmMain/kotlin/at/asitplus/signum/supreme/sign/EphemeralKeysImpl.kt index 5ed6070a..1044075a 100644 --- a/supreme/src/jvmMain/kotlin/at/asitplus/signum/supreme/sign/EphemeralKeysImpl.kt +++ b/supreme/src/jvmMain/kotlin/at/asitplus/signum/supreme/sign/EphemeralKeysImpl.kt @@ -37,9 +37,9 @@ sealed class EphemeralSigner (internal val privateKey: PrivateKey, private val p }) { "Pre-hashed data (format ${data.format}) unsupported for algorithm $signatureAlgorithm" } } (if (preHashed) - signatureAlgorithm.getJCASignatureInstancePreHashed(provider = provider, isAndroid = false).getOrThrow() + signatureAlgorithm.getJCASignatureInstancePreHashed(provider = provider).getOrThrow() else - signatureAlgorithm.getJCASignatureInstance(provider = provider, isAndroid = false).getOrThrow()) + signatureAlgorithm.getJCASignatureInstance(provider = provider).getOrThrow()) .run { initSign(privateKey) data.data.forEach { update(it) } diff --git a/supreme/src/jvmTest/kotlin/at/asitplus/signum/supreme/os/JKSProviderTest.kt b/supreme/src/jvmTest/kotlin/at/asitplus/signum/supreme/os/JKSProviderTest.kt index 6f7cd8d6..b40c4648 100644 --- a/supreme/src/jvmTest/kotlin/at/asitplus/signum/supreme/os/JKSProviderTest.kt +++ b/supreme/src/jvmTest/kotlin/at/asitplus/signum/supreme/os/JKSProviderTest.kt @@ -8,10 +8,12 @@ import io.kotest.core.spec.style.FreeSpec import io.kotest.matchers.should import io.kotest.matchers.shouldBe import io.kotest.matchers.shouldNot +import io.kotest.property.azstring +import java.nio.file.Files import kotlin.random.Random class JKSProviderTest : FreeSpec({ - "create - get - delete" { + "Ephemeral" { val ks = JKSProvider.Ephemeral() val alias = "Elfenbeinschloss" ks.getSignerForKey(alias) shouldNot succeed @@ -23,4 +25,47 @@ class JKSProviderTest : FreeSpec({ val signature = signer.sign(data).getOrThrow() otherSigner.makeVerifier().getOrThrow().verify(data, signature) should succeed } + "File-based persistence" { + val tempfile = Files.createTempFile(Random.azstring(16),null).also { Files.delete(it) } + try { + val alias = "Elfenbeinturm" + + val ks1 = SigningProvider { + keystoreFile { + file = tempfile + password = "Schwertfischfilet".toCharArray() + } + }.getOrThrow().also { + it.getSignerForKey(alias) shouldNot succeed + it.createSigningKey(alias) should succeed + it.createSigningKey(alias) shouldNot succeed + it.getSignerForKey(alias) should succeed + it.deleteSigningKey(alias) + it.getSignerForKey(alias) shouldNot succeed + it.createSigningKey(alias) should succeed + } + + SigningProvider { + keystoreFile { + file = tempfile + password = "Bartfischfilet".toCharArray() + } + }.getOrThrow().let { + // wrong password should fail + it.getSignerForKey(alias) shouldNot succeed + } + + SigningProvider { + keystoreFile { + file = tempfile + password = "Schwertfischfilet".toCharArray() + } + }.getOrThrow().let { + it.getSignerForKey(alias) should succeed + it.deleteSigningKey(alias) + } + + ks1.getSignerForKey(alias) shouldNot succeed + } finally { Files.deleteIfExists(tempfile) } + } }) From d0a64a33e031add19f4f5a8fcf04830d701f4e31 Mon Sep 17 00:00:00 2001 From: Jakob Heher Date: Wed, 28 Aug 2024 16:39:41 +0200 Subject: [PATCH 24/73] KT-71036 workaround --- .../supreme/os/AndroidKeyStoreProvider.kt | 2 ++ .../signum/supreme/os/SigningProvider.kt | 4 ++- .../signum/supreme/os/IosKeychainProvider.kt | 6 +++- .../asitplus/signum/supreme/os/JKSProvider.kt | 32 +++++++++++-------- 4 files changed, 29 insertions(+), 15 deletions(-) diff --git a/supreme/src/androidMain/kotlin/at/asitplus/signum/supreme/os/AndroidKeyStoreProvider.kt b/supreme/src/androidMain/kotlin/at/asitplus/signum/supreme/os/AndroidKeyStoreProvider.kt index 34a3c11a..ac1c753e 100644 --- a/supreme/src/androidMain/kotlin/at/asitplus/signum/supreme/os/AndroidKeyStoreProvider.kt +++ b/supreme/src/androidMain/kotlin/at/asitplus/signum/supreme/os/AndroidKeyStoreProvider.kt @@ -390,7 +390,9 @@ val AndroidKeystoreSigner.needsAuthenticationWithTimeout inline get() = keyInfo.isUserAuthenticationRequired && (keyInfo.userAuthenticationValidityDurationSeconds > 0) +actual typealias PlatformSigningProviderSigner = AndroidKeystoreSigner actual typealias PlatformSigningProviderSignerConfiguration = AndroidSignerConfiguration +actual typealias PlatformSigningProviderSigningKeyConfiguration = AndroidSigningKeyConfiguration actual typealias PlatformSigningProvider = AndroidKeyStoreProvider actual typealias PlatformSigningProviderConfiguration = PlatformSigningProviderConfigurationBase internal actual fun makePlatformSigningProvider(config: PlatformSigningProviderConfiguration) = diff --git a/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/os/SigningProvider.kt b/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/os/SigningProvider.kt index cb30768e..554163ea 100644 --- a/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/os/SigningProvider.kt +++ b/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/os/SigningProvider.kt @@ -108,9 +108,11 @@ open class PlatformSignerConfigurationBase internal constructor(): SignerConfigu } open class PlatformSigningProviderConfigurationBase internal constructor(): DSL.Data() +expect interface PlatformSigningProviderSigner: Signer.WithAlias expect class PlatformSigningProviderConfiguration internal constructor(): PlatformSigningProviderConfigurationBase expect class PlatformSigningProviderSignerConfiguration: PlatformSignerConfigurationBase -expect class PlatformSigningProvider : SigningProviderI> +expect class PlatformSigningProviderSigningKeyConfiguration: PlatformSigningKeyConfigurationBase +expect class PlatformSigningProvider : SigningProviderI internal expect fun makePlatformSigningProvider(config: PlatformSigningProviderConfiguration): KmmResult interface SigningProviderI, Signer.WithAlias, Signer.TemporarilyUnlockable + sealed class iosSigner( final override val alias: String, final override val attestation: iosHomebrewAttestation?, private val config: iosSignerConfiguration -) : Signer.TemporarilyUnlockable(), Signer.Attestable, Signer.WithAlias { +) : Signer.TemporarilyUnlockable(), Signer.Attestable, Signer.WithAlias, iosSignerI { final override suspend fun unlock(): KmmResult = withContext(keychainThreads) { catching { val arena = Arena() @@ -516,7 +518,9 @@ object IosKeychainProvider: SigningProviderI, iosSignerConfiguratio } } +actual typealias PlatformSigningProviderSigner = iosSignerI actual typealias PlatformSigningProviderSignerConfiguration = iosSignerConfiguration +actual typealias PlatformSigningProviderSigningKeyConfiguration = iosSigningKeyConfiguration actual typealias PlatformSigningProvider = IosKeychainProvider actual typealias PlatformSigningProviderConfiguration = PlatformSigningProviderConfigurationBase internal actual fun makePlatformSigningProvider(config: PlatformSigningProviderConfiguration) = diff --git a/supreme/src/jvmMain/kotlin/at/asitplus/signum/supreme/os/JKSProvider.kt b/supreme/src/jvmMain/kotlin/at/asitplus/signum/supreme/os/JKSProvider.kt index 12ec5fe9..9756b2b1 100644 --- a/supreme/src/jvmMain/kotlin/at/asitplus/signum/supreme/os/JKSProvider.kt +++ b/supreme/src/jvmMain/kotlin/at/asitplus/signum/supreme/os/JKSProvider.kt @@ -176,6 +176,8 @@ class JKSProvider internal constructor (private val access: JKSAccessor) } companion object { + operator fun invoke(configure: DSLConfigureFn = null) = + makePlatformSigningProvider(DSL.resolve(::JKSProviderConfiguration, configure)) fun Ephemeral(type: String = KeyStore.getDefaultType(), provider: String? = null) = JKSProvider(DummyJKSAccessor(keystoreGetInstance(type, provider).apply { load(null) })) } @@ -280,19 +282,6 @@ internal class JKSFileAccessor(opt: JKSProviderConfiguration.KeyStoreFile) : JKS override fun forWriting() = WriteAccessor() } -actual typealias PlatformSigningProviderSignerConfiguration = JKSSignerConfiguration -actual typealias PlatformSigningProvider = JKSProvider -actual typealias PlatformSigningProviderConfiguration = JKSProviderConfiguration -internal actual fun makePlatformSigningProvider(config: JKSProviderConfiguration): KmmResult = catching { - when (val opt = config._keystore.v) { - is JKSProviderConfiguration.EphemeralKeyStore -> - JKSProvider.Ephemeral(opt.storeType, opt.provider) - is JKSProviderConfiguration.KeyStoreObject -> - JKSProvider(opt.flushCallback?.let { CallbackJKSAccessor(opt.store, it) } ?: DummyJKSAccessor(opt.store)) - is JKSProviderConfiguration.KeyStoreFile -> - JKSProvider(JKSFileAccessor(opt)) - } -} class JKSProviderConfiguration internal constructor(): PlatformSigningProviderConfigurationBase() { sealed class KeyStoreConfiguration constructor(): DSL.Data() internal val _keystore = subclassOf(default = EphemeralKeyStore()) @@ -344,3 +333,20 @@ class JKSProviderConfiguration internal constructor(): PlatformSigningProviderCo */ val keystoreFile = _keystore.option(::KeyStoreFile) } + +internal actual fun makePlatformSigningProvider(config: JKSProviderConfiguration): KmmResult = catching { + when (val opt = config._keystore.v) { + is JKSProviderConfiguration.EphemeralKeyStore -> + JKSProvider.Ephemeral(opt.storeType, opt.provider) + is JKSProviderConfiguration.KeyStoreObject -> + JKSProvider(opt.flushCallback?.let { CallbackJKSAccessor(opt.store, it) } ?: DummyJKSAccessor(opt.store)) + is JKSProviderConfiguration.KeyStoreFile -> + JKSProvider(JKSFileAccessor(opt)) + } +} + +actual typealias PlatformSigningProviderSigner = JKSSigner +actual typealias PlatformSigningProviderSignerConfiguration = JKSSignerConfiguration +actual typealias PlatformSigningProviderSigningKeyConfiguration = JKSSigningKeyConfiguration +actual typealias PlatformSigningProvider = JKSProvider +actual typealias PlatformSigningProviderConfiguration = JKSProviderConfiguration From 961ff678a935839a182918f115d7996e1e79c291 Mon Sep 17 00:00:00 2001 From: Jakob Heher Date: Wed, 28 Aug 2024 17:35:25 +0200 Subject: [PATCH 25/73] activity lifecycle callbacks on android --- supreme/src/androidMain/AndroidManifest.xml | 9 +++ .../signum/supreme/AndroidInitialization.kt | 61 +++++++++++++++++++ .../supreme/os/AndroidKeyStoreProvider.kt | 16 ++++- 3 files changed, 83 insertions(+), 3 deletions(-) create mode 100644 supreme/src/androidMain/AndroidManifest.xml create mode 100644 supreme/src/androidMain/kotlin/at/asitplus/signum/supreme/AndroidInitialization.kt diff --git a/supreme/src/androidMain/AndroidManifest.xml b/supreme/src/androidMain/AndroidManifest.xml new file mode 100644 index 00000000..edf0ce6c --- /dev/null +++ b/supreme/src/androidMain/AndroidManifest.xml @@ -0,0 +1,9 @@ + + + + + \ No newline at end of file diff --git a/supreme/src/androidMain/kotlin/at/asitplus/signum/supreme/AndroidInitialization.kt b/supreme/src/androidMain/kotlin/at/asitplus/signum/supreme/AndroidInitialization.kt new file mode 100644 index 00000000..f39f2006 --- /dev/null +++ b/supreme/src/androidMain/kotlin/at/asitplus/signum/supreme/AndroidInitialization.kt @@ -0,0 +1,61 @@ +package at.asitplus.signum.supreme + +import android.annotation.SuppressLint +import android.app.Activity +import android.app.Application +import android.content.ContentProvider +import android.content.ContentValues +import android.content.Context +import android.content.pm.ProviderInfo +import android.net.Uri +import android.os.Bundle +import io.github.aakira.napier.Napier + +@SuppressLint("StaticFieldLeak") +internal object AppLifecycleMonitor : Application.ActivityLifecycleCallbacks { + var currentActivity: Activity? = null + + override fun onActivityResumed(activity: Activity) { + Napier.v { "Current activity is now: $activity" } + currentActivity = activity + } + override fun onActivityDestroyed(activity: Activity) { + if (currentActivity == activity) { + Napier.v { "Clearing current activity" } + currentActivity = null + } + } + override fun onActivityStarted(activity: Activity) {} + override fun onActivityStopped(activity: Activity) {} + override fun onActivityPaused(activity: Activity) {} + override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {} + override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) {} + +} + +/** called exactly once, on application startup, as soon as the application context becomes available */ +private fun init(context: Application) { + context.registerActivityLifecycleCallbacks(AppLifecycleMonitor) + Napier.v { "Signum library initialized!" } +} + +class InitProvider: ContentProvider() { + override fun onCreate(): Boolean { + init(context as? Application ?: return false) + return true + } + + override fun attachInfo(context: Context?, info: ProviderInfo?) { + super.attachInfo(context, info) + require(info?.authority != ".SignumSupremeInitProvider") + { "You must specify an applicationId in your application's build.gradle(.kts) file!" } + } + + private fun no(): Nothing { throw UnsupportedOperationException("This provider is only used for library initialization.") } + override fun insert(uri: Uri, values: ContentValues?) = no() + override fun query(uri: Uri, projection: Array?, selection: String?, selectionArgs: Array?, sortOrder: String?) = no() + override fun update(uri: Uri, values: ContentValues?, selection: String?, selectionArgs: Array?) = no() + override fun delete(uri: Uri, selection: String?, selectionArgs: Array?) = no() + override fun getType(uri: Uri) = no() +} + diff --git a/supreme/src/androidMain/kotlin/at/asitplus/signum/supreme/os/AndroidKeyStoreProvider.kt b/supreme/src/androidMain/kotlin/at/asitplus/signum/supreme/os/AndroidKeyStoreProvider.kt index ac1c753e..808f6d6b 100644 --- a/supreme/src/androidMain/kotlin/at/asitplus/signum/supreme/os/AndroidKeyStoreProvider.kt +++ b/supreme/src/androidMain/kotlin/at/asitplus/signum/supreme/os/AndroidKeyStoreProvider.kt @@ -24,6 +24,7 @@ import at.asitplus.signum.indispensable.parseFromJca import at.asitplus.signum.indispensable.pki.CertificateChain import at.asitplus.signum.indispensable.pki.X509Certificate import at.asitplus.signum.indispensable.pki.leaf +import at.asitplus.signum.supreme.AppLifecycleMonitor import at.asitplus.signum.supreme.UnlockFailed import at.asitplus.signum.supreme.UnsupportedCryptoException import at.asitplus.signum.supreme.dsl.DISCOURAGED @@ -68,8 +69,14 @@ class AndroidSigningKeyConfiguration internal constructor(): PlatformSigningKeyC class AndroidSignerConfiguration: PlatformSignerConfigurationBase() { class AuthnPrompt: PlatformSignerConfigurationBase.AuthnPrompt() { + /** Explicitly specify the FragmentActivity to use for authentication prompts. + * You will not need to set this in most cases; the default is the current activity. */ lateinit var activity: FragmentActivity + + /** Explicitly set the Fragment to base authentication prompts on. + * You will not need to set this in most cases; the default is the current activity.*/ lateinit var fragment: Fragment + internal val context: FragmentContext? get() = when { this::activity.isInitialized -> FragmentContext.OfActivity(activity) this::fragment.isInitialized -> FragmentContext.OfFragment(fragment) @@ -287,7 +294,6 @@ sealed class LockedAndroidKeystoreSigner private constructor( ) : SignerI.TemporarilyUnlockable(), AndroidKeystoreSigner { private val context = config.unlockPrompt.v.context - ?: throw UnsupportedCryptoException("The requested key with alias $alias requires unlock. Pass either { fragment = } or { activity = } inside authPrompt {}.") final override val attestation = attestationFor(certificateChain) private sealed interface AuthResult { @@ -297,7 +303,11 @@ sealed class LockedAndroidKeystoreSigner private constructor( private suspend fun attemptBiometry(config: AndroidSignerConfiguration.AuthnPrompt, forSpecificKey: CryptoObject?) { val channel = Channel(capacity = Channel.RENDEZVOUS) - val executor = when (context) { + val effectiveContext = context ?: + (AppLifecycleMonitor.currentActivity as? FragmentActivity)?.let(FragmentContext::OfActivity) + ?: throw UnsupportedOperationException("The requested key with alias $alias requires unlock, but the current activity is not a FragmentActivity or could not be determined. " + + "Pass either { fragment = } or { activity = } inside authPrompt {}.") + val executor = when (effectiveContext) { is FragmentContext.OfActivity -> ContextCompat.getMainExecutor(context.activity) is FragmentContext.OfFragment -> ContextCompat.getMainExecutor(context.fragment.context) } @@ -324,7 +334,7 @@ sealed class LockedAndroidKeystoreSigner private constructor( config.invalidBiometryCallback?.invoke() } } - val prompt = when (context) { + val prompt = when (effectiveContext) { is FragmentContext.OfActivity -> BiometricPrompt(context.activity, executor, siphon) is FragmentContext.OfFragment -> BiometricPrompt(context.fragment, executor, siphon) } From 10bfc0a83267eb620c1bc9dd31b893fd2bec83cd Mon Sep 17 00:00:00 2001 From: Jakob Heher Date: Wed, 28 Aug 2024 17:35:34 +0200 Subject: [PATCH 26/73] Revert "update kotlin+kotest" This reverts commit 7ff5e4b7afdf0bdc48d4c57fc58d2c8ea9a12369. --- build.gradle.kts | 2 +- settings.gradle.kts | 2 -- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index 6c1d3b64..96223ddd 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,7 +1,7 @@ import org.jetbrains.dokka.gradle.DokkaMultiModuleTask plugins { - id("at.asitplus.gradle.conventions") version "2.0.20+20240828" + id("at.asitplus.gradle.conventions") version "2.0.0+20240725" id("com.android.library") version "8.2.0" apply (false) } group = "at.asitplus.signum" diff --git a/settings.gradle.kts b/settings.gradle.kts index 16448ad1..d4c914d6 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,8 +1,6 @@ pluginManagement { includeBuild("swift-klib-plugin") repositories { - - maven("https://s01.oss.sonatype.org/content/repositories/snapshots") //KOTEST snapshot google() mavenCentral() gradlePluginPortal() From 522c613e5f30e999fb53685618e017a872ea4f27 Mon Sep 17 00:00:00 2001 From: Jakob Heher Date: Wed, 28 Aug 2024 18:17:36 +0200 Subject: [PATCH 27/73] dummy out platform-agnostic provider construction until KT-71036 is fixed --- .../at/asitplus/cryptotest/App.android.kt | 71 +------------------ .../kotlin/at/asitplus/cryptotest/App.kt | 4 +- .../kotlin/at/asitplus/cryptotest/App.ios.kt | 66 ----------------- .../kotlin/at/asitplus/cryptotest/App.jvm.kt | 32 --------- .../supreme/os/AndroidKeyStoreProvider.kt | 12 ++-- .../signum/supreme/os/SigningProvider.kt | 9 +-- .../signum/supreme/os/IosKeychainProvider.kt | 6 +- .../asitplus/signum/supreme/os/JKSProvider.kt | 6 +- 8 files changed, 19 insertions(+), 187 deletions(-) diff --git a/demoapp/composeApp/src/androidMain/kotlin/at/asitplus/cryptotest/App.android.kt b/demoapp/composeApp/src/androidMain/kotlin/at/asitplus/cryptotest/App.android.kt index 9fd94b6c..b24efc27 100644 --- a/demoapp/composeApp/src/androidMain/kotlin/at/asitplus/cryptotest/App.android.kt +++ b/demoapp/composeApp/src/androidMain/kotlin/at/asitplus/cryptotest/App.android.kt @@ -33,73 +33,4 @@ class AppActivity : FragmentActivity() { } internal actual fun getSystemKeyStore(): SigningProvider = - AndroidKeyStoreProvider(fragmentActivity) - -/*internal actual suspend fun generateKey( - alg: CryptoAlgorithm, - attestation: ByteArray?, - withBiometricAuth: Duration? -): KmmResult { - val opsForUse = AndroidSpecificCryptoOps( - keyGenCustomization = { - withBiometricAuth?.also { - setUserAuthenticationRequired(true) - setUserAuthenticationParameters( - it.inWholeSeconds.toInt(), - KeyProperties.AUTH_BIOMETRIC_STRONG - ) - } ?: setUserAuthenticationRequired(false) - }) - return if (attestation == null) CryptoProvider.createSigningKey( - ALIAS, - alg, - opsForUse, - ).map { it to listOf() } - else { - CryptoProvider.createTbaP256Key( - ALIAS, - attestation, - opsForUse - ) - } -} - -fun setupBiometric(): AndroidSpecificCryptoOps.BiometricAuth { - val promptInfo = BiometricPrompt.PromptInfo.Builder() - .setTitle("Biometric Auth") - .setSubtitle("Authenticate private key usage") - .setNegativeButtonText("Abort") - .setAllowedAuthenticators(BIOMETRIC_STRONG) - .build() - - val biometricPrompt = BiometricPromptAdapter( - fragmentActivity!!, - executor!! - ) - return AndroidSpecificCryptoOps.BiometricAuth(promptInfo, biometricPrompt) -} - -internal actual suspend fun sign( - data: ByteArray, - alg: CryptoAlgorithm, - signingKey: CryptoPrivateKey -): KmmResult { - if (biometricPrompt == null) - biometricPrompt = setupBiometric() - (signingKey.platformSpecifics as AndroidSpecificCryptoOps).attachAuthenticationHandler { biometricPrompt!! } - return CryptoProvider.sign( - data, - signingKey, - alg - ) -} - -internal actual suspend fun loadPubKey() = CryptoProvider.getPublicKey(ALIAS) -internal actual suspend fun loadPrivateKey() = - CryptoProvider.getKeyPair(ALIAS, AndroidSpecificCryptoOps()) - -internal actual suspend fun storeCertChain(): KmmResult = - CryptoProvider.storeCertificateChain(ALIAS + "CRT_CHAIN", SAMPLE_CERT_CHAIN) - -internal actual suspend fun getCertChain(): KmmResult> = - CryptoProvider.getCertificateChain(ALIAS + "CRT_CHAIN")*/ \ No newline at end of file + AndroidKeyStoreProvider() diff --git a/demoapp/composeApp/src/commonMain/kotlin/at/asitplus/cryptotest/App.kt b/demoapp/composeApp/src/commonMain/kotlin/at/asitplus/cryptotest/App.kt index ce7d1a3d..70b22f2c 100644 --- a/demoapp/composeApp/src/commonMain/kotlin/at/asitplus/cryptotest/App.kt +++ b/demoapp/composeApp/src/commonMain/kotlin/at/asitplus/cryptotest/App.kt @@ -57,7 +57,7 @@ import at.asitplus.signum.supreme.sign.sign import at.asitplus.signum.supreme.sign.verify import at.asitplus.cryptotest.theme.AppTheme import at.asitplus.cryptotest.theme.LocalThemeIsDark -import at.asitplus.signum.supreme.os.PlatformSignerConfiguration +import at.asitplus.signum.supreme.os.PlatformSignerConfigurationBase import at.asitplus.signum.supreme.os.jsonEncoded import io.github.aakira.napier.DebugAntilog import io.github.aakira.napier.Napier @@ -122,7 +122,7 @@ val SAMPLE_CERT_CHAIN = listOf( const val ALIAS = "Bartschlüssel" -val SIGNER_CONFIG: (PlatformSignerConfiguration.()->Unit) = { +val SIGNER_CONFIG: (PlatformSignerConfigurationBase.()->Unit) = { unlockPrompt { message = "We're signing a thing!" cancelText = "No! Stop!" diff --git a/demoapp/composeApp/src/iosMain/kotlin/at/asitplus/cryptotest/App.ios.kt b/demoapp/composeApp/src/iosMain/kotlin/at/asitplus/cryptotest/App.ios.kt index 4eb75898..635a5e38 100644 --- a/demoapp/composeApp/src/iosMain/kotlin/at/asitplus/cryptotest/App.ios.kt +++ b/demoapp/composeApp/src/iosMain/kotlin/at/asitplus/cryptotest/App.ios.kt @@ -4,69 +4,3 @@ import at.asitplus.signum.supreme.os.IosKeychainProvider import at.asitplus.signum.supreme.os.SigningProvider internal actual fun getSystemKeyStore(): SigningProvider = IosKeychainProvider - -/*@OptIn(ExperimentalForeignApi::class) -internal actual suspend fun generateKey( - alg: CryptoAlgorithm, - attestation: ByteArray?, - withBiometricAuth: Duration? -): KmmResult { - - val specificCryptoOps = withBiometricAuth?.let { - IosSpecificCryptoOps.withSecAccessControlFlagsAndReuse( - kSecAccessControlTouchIDCurrentSet, withBiometricAuth - ) - } ?: IosSpecificCryptoOps.plain() - - - - val hasKey = CryptoProvider.hasKey(ALIAS, specificCryptoOps) - Napier.w { "Key with alias $ALIAS exists: $hasKey" } - - if (hasKey.getOrThrow()) { - Napier.w { "trying to clear key" } - println(CryptoProvider.deleteEntry(ALIAS, specificCryptoOps)) - } - - Napier.w { "creating signing key" } - - - return (if (attestation == null) { - CryptoProvider.createSigningKey( - ALIAS, - alg, - specificCryptoOps - ).map { it to listOf() } - } else CryptoProvider.createTbaP256Key( - ALIAS, - attestation, - specificCryptoOps - )) -} - -@OptIn(ExperimentalForeignApi::class) -internal actual suspend fun sign( - data: ByteArray, - alg: CryptoAlgorithm, - signingKey: CryptoPrivateKey -): KmmResult { - if (signingKey !is IosPrivateKey) throw IllegalArgumentException("Not an iOS Private Key!") - return CryptoProvider.sign(data, signingKey, alg) -} - -internal actual suspend fun loadPubKey() = CryptoProvider.getPublicKey(ALIAS) - -@OptIn(ExperimentalForeignApi::class) -internal actual suspend fun loadPrivateKey(): KmmResult = - CryptoProvider.getKeyPair(ALIAS, IosSpecificCryptoOps()) - -internal actual suspend fun storeCertChain(): KmmResult = - CryptoProvider.storeCertificateChain( - ALIAS + "CRT_CHAIN", - SAMPLE_CERT_CHAIN - ) - -internal actual suspend fun getCertChain(): KmmResult> = - CryptoProvider.getCertificateChain( - ALIAS + "CRT_CHAIN" - )*/ \ No newline at end of file diff --git a/demoapp/composeApp/src/jvmMain/kotlin/at/asitplus/cryptotest/App.jvm.kt b/demoapp/composeApp/src/jvmMain/kotlin/at/asitplus/cryptotest/App.jvm.kt index 89f6d4b8..14987077 100644 --- a/demoapp/composeApp/src/jvmMain/kotlin/at/asitplus/cryptotest/App.jvm.kt +++ b/demoapp/composeApp/src/jvmMain/kotlin/at/asitplus/cryptotest/App.jvm.kt @@ -3,35 +3,3 @@ package at.asitplus.cryptotest import at.asitplus.signum.supreme.os.SigningProvider internal actual fun getSystemKeyStore(): SigningProvider = TODO() - -/*val PROVIDER = BouncyCastleProvider() -val JVM_OPTS = - JvmSpecifics( - PROVIDER, - KeyStore.getInstance("PKCS12", PROVIDER).apply { load(null, null) }, - privateKeyPassword = null - ) - -internal actual suspend fun generateKey( - alg: CryptoAlgorithm, - attestation: ByteArray?, - withBiometricAuth: Duration?, - - ): KmmResult = CryptoProvider.createSigningKey(ALIAS, alg, JVM_OPTS).map { it to listOf() } - -internal actual suspend fun sign( - data: ByteArray, - alg: CryptoAlgorithm, - signingKey: CryptoPrivateKey -): KmmResult = CryptoProvider.sign(data, signingKey, alg) - -internal actual suspend fun loadPubKey() = CryptoProvider.getPublicKey(ALIAS, JVM_OPTS) -internal actual suspend fun loadPrivateKey() = CryptoProvider.getKeyPair(ALIAS, JVM_OPTS) - -internal actual suspend fun storeCertChain(): KmmResult = - CryptoProvider.storeCertificateChain(ALIAS + "CRT_CHAIN", SAMPLE_CERT_CHAIN, JVM_OPTS) - -internal actual suspend fun getCertChain(): KmmResult> = - CryptoProvider.getCertificateChain( - ALIAS + "CRT_CHAIN", JVM_OPTS - )*/ \ No newline at end of file diff --git a/supreme/src/androidMain/kotlin/at/asitplus/signum/supreme/os/AndroidKeyStoreProvider.kt b/supreme/src/androidMain/kotlin/at/asitplus/signum/supreme/os/AndroidKeyStoreProvider.kt index 808f6d6b..ca945031 100644 --- a/supreme/src/androidMain/kotlin/at/asitplus/signum/supreme/os/AndroidKeyStoreProvider.kt +++ b/supreme/src/androidMain/kotlin/at/asitplus/signum/supreme/os/AndroidKeyStoreProvider.kt @@ -308,8 +308,8 @@ sealed class LockedAndroidKeystoreSigner private constructor( ?: throw UnsupportedOperationException("The requested key with alias $alias requires unlock, but the current activity is not a FragmentActivity or could not be determined. " + "Pass either { fragment = } or { activity = } inside authPrompt {}.") val executor = when (effectiveContext) { - is FragmentContext.OfActivity -> ContextCompat.getMainExecutor(context.activity) - is FragmentContext.OfFragment -> ContextCompat.getMainExecutor(context.fragment.context) + is FragmentContext.OfActivity -> ContextCompat.getMainExecutor(effectiveContext.activity) + is FragmentContext.OfFragment -> ContextCompat.getMainExecutor(effectiveContext.fragment.context) } executor.asCoroutineDispatcher().let(::CoroutineScope).launch { val promptInfo = BiometricPrompt.PromptInfo.Builder().apply { @@ -335,8 +335,8 @@ sealed class LockedAndroidKeystoreSigner private constructor( } } val prompt = when (effectiveContext) { - is FragmentContext.OfActivity -> BiometricPrompt(context.activity, executor, siphon) - is FragmentContext.OfFragment -> BiometricPrompt(context.fragment, executor, siphon) + is FragmentContext.OfActivity -> BiometricPrompt(effectiveContext.activity, executor, siphon) + is FragmentContext.OfFragment -> BiometricPrompt(effectiveContext.fragment, executor, siphon) } when (forSpecificKey) { null -> prompt.authenticate(promptInfo) @@ -400,10 +400,10 @@ val AndroidKeystoreSigner.needsAuthenticationWithTimeout inline get() = keyInfo.isUserAuthenticationRequired && (keyInfo.userAuthenticationValidityDurationSeconds > 0) -actual typealias PlatformSigningProviderSigner = AndroidKeystoreSigner +/*actual typealias PlatformSigningProviderSigner = AndroidKeystoreSigner actual typealias PlatformSigningProviderSignerConfiguration = AndroidSignerConfiguration actual typealias PlatformSigningProviderSigningKeyConfiguration = AndroidSigningKeyConfiguration actual typealias PlatformSigningProvider = AndroidKeyStoreProvider actual typealias PlatformSigningProviderConfiguration = PlatformSigningProviderConfigurationBase internal actual fun makePlatformSigningProvider(config: PlatformSigningProviderConfiguration) = - AndroidKeyStoreProvider() + AndroidKeyStoreProvider()*/ diff --git a/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/os/SigningProvider.kt b/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/os/SigningProvider.kt index 554163ea..428e55c3 100644 --- a/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/os/SigningProvider.kt +++ b/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/os/SigningProvider.kt @@ -108,12 +108,13 @@ open class PlatformSignerConfigurationBase internal constructor(): SignerConfigu } open class PlatformSigningProviderConfigurationBase internal constructor(): DSL.Data() -expect interface PlatformSigningProviderSigner: Signer.WithAlias +// BLOCKED BY KT-71036 +/*expect interface PlatformSigningProviderSigner: Signer.WithAlias expect class PlatformSigningProviderConfiguration internal constructor(): PlatformSigningProviderConfigurationBase expect class PlatformSigningProviderSignerConfiguration: PlatformSignerConfigurationBase expect class PlatformSigningProviderSigningKeyConfiguration: PlatformSigningKeyConfigurationBase expect class PlatformSigningProvider : SigningProviderI -internal expect fun makePlatformSigningProvider(config: PlatformSigningProviderConfiguration): KmmResult +internal expect fun makePlatformSigningProvider(config: PlatformSigningProviderConfiguration): KmmResult*/ interface SigningProviderI = null) = - makePlatformSigningProvider(DSL.resolve(::PlatformSigningProviderConfiguration, configure)) + /*operator fun invoke(configure: DSLConfigureFn = null) = + makePlatformSigningProvider(DSL.resolve(::PlatformSigningProviderConfiguration, configure))*/ } } typealias SigningProvider = SigningProviderI<*,*,*> diff --git a/supreme/src/iosMain/kotlin/at/asitplus/signum/supreme/os/IosKeychainProvider.kt b/supreme/src/iosMain/kotlin/at/asitplus/signum/supreme/os/IosKeychainProvider.kt index 57e96ca4..08b933fb 100644 --- a/supreme/src/iosMain/kotlin/at/asitplus/signum/supreme/os/IosKeychainProvider.kt +++ b/supreme/src/iosMain/kotlin/at/asitplus/signum/supreme/os/IosKeychainProvider.kt @@ -194,8 +194,6 @@ sealed class unlockedIosSigner(private val ownedArena: Arena, internal val priva } -interface iosSignerI : Signer.Attestable, Signer.WithAlias, Signer.TemporarilyUnlockable - sealed class iosSigner( final override val alias: String, final override val attestation: iosHomebrewAttestation?, @@ -518,10 +516,10 @@ object IosKeychainProvider: SigningProviderI, iosSignerConfiguratio } } -actual typealias PlatformSigningProviderSigner = iosSignerI +/*actual typealias PlatformSigningProviderSigner = iosSigner<*> actual typealias PlatformSigningProviderSignerConfiguration = iosSignerConfiguration actual typealias PlatformSigningProviderSigningKeyConfiguration = iosSigningKeyConfiguration actual typealias PlatformSigningProvider = IosKeychainProvider actual typealias PlatformSigningProviderConfiguration = PlatformSigningProviderConfigurationBase internal actual fun makePlatformSigningProvider(config: PlatformSigningProviderConfiguration) = - IosKeychainProvider + IosKeychainProvider*/ diff --git a/supreme/src/jvmMain/kotlin/at/asitplus/signum/supreme/os/JKSProvider.kt b/supreme/src/jvmMain/kotlin/at/asitplus/signum/supreme/os/JKSProvider.kt index 9756b2b1..352fece2 100644 --- a/supreme/src/jvmMain/kotlin/at/asitplus/signum/supreme/os/JKSProvider.kt +++ b/supreme/src/jvmMain/kotlin/at/asitplus/signum/supreme/os/JKSProvider.kt @@ -334,7 +334,7 @@ class JKSProviderConfiguration internal constructor(): PlatformSigningProviderCo val keystoreFile = _keystore.option(::KeyStoreFile) } -internal actual fun makePlatformSigningProvider(config: JKSProviderConfiguration): KmmResult = catching { +internal /*actual*/ fun makePlatformSigningProvider(config: JKSProviderConfiguration): KmmResult = catching { when (val opt = config._keystore.v) { is JKSProviderConfiguration.EphemeralKeyStore -> JKSProvider.Ephemeral(opt.storeType, opt.provider) @@ -345,8 +345,8 @@ internal actual fun makePlatformSigningProvider(config: JKSProviderConfiguration } } -actual typealias PlatformSigningProviderSigner = JKSSigner +/*actual typealias PlatformSigningProviderSigner = JKSSigner actual typealias PlatformSigningProviderSignerConfiguration = JKSSignerConfiguration actual typealias PlatformSigningProviderSigningKeyConfiguration = JKSSigningKeyConfiguration actual typealias PlatformSigningProvider = JKSProvider -actual typealias PlatformSigningProviderConfiguration = JKSProviderConfiguration +actual typealias PlatformSigningProviderConfiguration = JKSProviderConfiguration*/ From d75e70b627421f171ca0ccfc17925e548dab9b0c Mon Sep 17 00:00:00 2001 From: Jakob Heher Date: Wed, 28 Aug 2024 19:41:32 +0200 Subject: [PATCH 28/73] ios timeout --- .../asitplus/signum/supreme/os/Attestation.kt | 8 +- .../at/asitplus/signum/supreme/sign/Signer.kt | 28 +-- .../supreme/hazmat/InternalsAccessors.kt | 4 +- .../signum/supreme/os/IosKeychainProvider.kt | 199 +++++++++++------- 4 files changed, 140 insertions(+), 99 deletions(-) diff --git a/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/os/Attestation.kt b/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/os/Attestation.kt index 7633664b..319d7eac 100644 --- a/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/os/Attestation.kt +++ b/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/os/Attestation.kt @@ -35,14 +35,14 @@ data class AndroidKeystoreAttestation ( @Serializable @SerialName("ios-appattest-assertion") -data class iosLegacyHomebrewAttestation( +data class IosLegacyHomebrewAttestation( @Serializable(with=ByteArrayBase64UrlSerializer::class) val attestation: ByteArray, @Serializable(with=ByteArrayBase64UrlSerializer::class) val assertion: ByteArray): Attestation { override fun equals(other: Any?): Boolean { if (this === other) return true - if (other !is iosLegacyHomebrewAttestation) return false + if (other !is IosLegacyHomebrewAttestation) return false if (!attestation.contentEquals(other.attestation)) return false return assertion.contentEquals(other.assertion) @@ -59,7 +59,7 @@ val StrictJson = Json { ignoreUnknownKeys = true; isLenient = false } @Serializable @SerialName("ios-appattest") -data class iosHomebrewAttestation( +data class IosHomebrewAttestation( @Serializable(with=ByteArrayBase64UrlSerializer::class) val attestation: ByteArray, @Serializable(with=ByteArrayBase64UrlSerializer::class) @@ -106,7 +106,7 @@ data class iosHomebrewAttestation( override fun equals(other: Any?): Boolean { if (this === other) return true - if (other !is iosHomebrewAttestation) return false + if (other !is IosHomebrewAttestation) return false if (!attestation.contentEquals(other.attestation)) return false return clientDataJSON.contentEquals(other.clientDataJSON) diff --git a/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/sign/Signer.kt b/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/sign/Signer.kt index e0d7fd05..d88cd251 100644 --- a/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/sign/Signer.kt +++ b/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/sign/Signer.kt @@ -1,7 +1,6 @@ package at.asitplus.signum.supreme.sign import at.asitplus.KmmResult -import at.asitplus.KmmResult.Companion.wrap import at.asitplus.catching import at.asitplus.signum.indispensable.CryptoPublicKey import at.asitplus.signum.indispensable.CryptoSignature @@ -10,6 +9,7 @@ import at.asitplus.signum.indispensable.ECCurve import at.asitplus.signum.indispensable.RSAPadding import at.asitplus.signum.indispensable.SignatureAlgorithm import at.asitplus.signum.indispensable.nativeDigest +import at.asitplus.signum.supreme.HazardousMaterials import at.asitplus.signum.supreme.UnlockFailed import at.asitplus.signum.supreme.dsl.DSL import at.asitplus.signum.supreme.dsl.DSLConfigureFn @@ -88,30 +88,22 @@ interface Signer { */ abstract class TemporarilyUnlockable : Signer { final override val mayRequireUserUnlock: Boolean get() = true - protected abstract suspend fun unlock(): KmmResult + + @HazardousMaterials + abstract suspend fun unlock(): KmmResult /** * Unlocks the signer, then executes the block with the [UnlockedHandle] as its receiver. * * The handle's validity is only guaranteed in the block scope. */ - @OptIn(ExperimentalStdlibApi::class) - suspend fun withUnlock(fn: suspend Handle.()->T): KmmResult = - /** this is .use() but for suspend functions */ - unlock().transform { h -> - val v = runCatching { fn(h) } - try { - h.close() - } catch (y: Throwable) { - (v.exceptionOrNull() - ?: return@transform KmmResult.failure(y)) - .addSuppressed(y) - } - v.wrap() - } + @OptIn(ExperimentalStdlibApi::class, HazardousMaterials::class) + suspend inline fun withUnlock(fn: Handle.()->T): KmmResult = + unlock().mapCatching { it.use(fn) } + @OptIn(ExperimentalStdlibApi::class, HazardousMaterials::class) final override suspend fun sign(data: SignatureInput): KmmResult = - withUnlock { sign(data).getOrThrow() } + unlock().transform { h -> h.use { it.sign(data) } } } companion object { @@ -138,7 +130,7 @@ val Signer.ECDSA.curve get() = publicKey.curve * Try to batch sign with this signer. * Might fail for unlockable signers that cannot be temporarily unlocked. */ -suspend fun Signer.withUnlock(fn: suspend Signer.()->T) = +suspend inline fun Signer.withUnlock(fn: Signer.()->T) = when (this.mayRequireUserUnlock) { true -> if (this is Signer.TemporarilyUnlockable<*>) diff --git a/supreme/src/iosMain/kotlin/at/asitplus/signum/supreme/hazmat/InternalsAccessors.kt b/supreme/src/iosMain/kotlin/at/asitplus/signum/supreme/hazmat/InternalsAccessors.kt index 417dc171..b7a023ea 100644 --- a/supreme/src/iosMain/kotlin/at/asitplus/signum/supreme/hazmat/InternalsAccessors.kt +++ b/supreme/src/iosMain/kotlin/at/asitplus/signum/supreme/hazmat/InternalsAccessors.kt @@ -2,7 +2,7 @@ package at.asitplus.signum.supreme.hazmat import at.asitplus.signum.supreme.HazardousMaterials -import at.asitplus.signum.supreme.os.unlockedIosSigner +import at.asitplus.signum.supreme.os.UnlockedIosSigner import at.asitplus.signum.supreme.sign.EphemeralKey import at.asitplus.signum.supreme.sign.EphemeralKeyBase import at.asitplus.signum.supreme.sign.EphemeralKeyRef @@ -16,6 +16,6 @@ val EphemeralKey.secKeyRef get() = ((this as? EphemeralKeyBase<*>)?.privateKey a @HazardousMaterials val Signer.secKeyRef get() = when (this) { is EphemeralSigner -> this.privateKey.key.value - is unlockedIosSigner -> this.privateKeyRef + is UnlockedIosSigner -> this.privateKeyRef else -> null } diff --git a/supreme/src/iosMain/kotlin/at/asitplus/signum/supreme/os/IosKeychainProvider.kt b/supreme/src/iosMain/kotlin/at/asitplus/signum/supreme/os/IosKeychainProvider.kt index 08b933fb..12188d72 100644 --- a/supreme/src/iosMain/kotlin/at/asitplus/signum/supreme/os/IosKeychainProvider.kt +++ b/supreme/src/iosMain/kotlin/at/asitplus/signum/supreme/os/IosKeychainProvider.kt @@ -30,7 +30,6 @@ import at.asitplus.signum.supreme.swiftasync import at.asitplus.signum.supreme.takeFromCF import at.asitplus.signum.supreme.toByteArray import at.asitplus.signum.supreme.toNSData -import at.asitplus.signum.supreme.os.* import io.github.aakira.napier.Napier import kotlinx.cinterop.Arena import kotlinx.cinterop.ExperimentalForeignApi @@ -100,7 +99,15 @@ import platform.Security.kSecUseAuthenticationContext import platform.Security.kSecUseAuthenticationUI import platform.Security.kSecUseAuthenticationUIAllow import at.asitplus.signum.indispensable.secKeyAlgorithm +import at.asitplus.signum.supreme.HazardousMaterials import at.asitplus.signum.supreme.sign.SigningKeyConfiguration +import kotlinx.serialization.Serializable +import kotlin.contracts.ExperimentalContracts +import kotlin.contracts.InvocationKind +import kotlin.contracts.contract +import kotlin.math.min +import kotlin.time.Duration +import kotlin.time.TimeSource val keychainThreads = newFixedThreadPoolContext(nThreads = 4, name = "iOS Keychain Operations") @@ -123,24 +130,24 @@ private object KeychainTags { val PUBLIC_KEYS get() = tags.second } -class iosSecureEnclaveConfiguration internal constructor() : PlatformSigningKeyConfigurationBase.SecureHardwareConfiguration() { +class IosSecureEnclaveConfiguration internal constructor() : PlatformSigningKeyConfigurationBase.SecureHardwareConfiguration() { /** Set to true to allow this key to be backed up. */ var allowBackup = false enum class Availability { ALWAYS, AFTER_FIRST_UNLOCK, WHILE_UNLOCKED } /** Specify when this key should be available */ var availability = Availability.ALWAYS } -class iosSigningKeyConfiguration internal constructor(): PlatformSigningKeyConfigurationBase() { - override val hardware = childOrDefault(::iosSecureEnclaveConfiguration) { +class IosSigningKeyConfiguration internal constructor(): PlatformSigningKeyConfigurationBase() { + override val hardware = childOrDefault(::IosSecureEnclaveConfiguration) { backing = DISCOURAGED } } -class iosSignerConfiguration internal constructor(): PlatformSignerConfigurationBase() { +class IosSignerConfiguration internal constructor(): PlatformSignerConfigurationBase() { } -sealed class unlockedIosSigner(private val ownedArena: Arena, internal val privateKeyRef: SecKeyRef) : Signer.UnlockedHandle { - abstract val parent: iosSigner<*> +sealed class UnlockedIosSigner(private val ownedArena: Arena, internal val privateKeyRef: SecKeyRef) : Signer.UnlockedHandle { + abstract val parent: IosSigner<*> val alias get() = parent.alias var usable = true @@ -172,8 +179,8 @@ sealed class unlockedIosSigner(private val ownedArena: Arena, internal val priva class ECDSA(ownedArena: Arena, privateKeyRef: SecKeyRef, - override val parent: iosSigner.ECDSA) - : unlockedIosSigner(ownedArena, privateKeyRef), Signer.ECDSA + override val parent: IosSigner.ECDSA) + : UnlockedIosSigner(ownedArena, privateKeyRef), Signer.ECDSA { override val signatureAlgorithm get() = parent.signatureAlgorithm override val publicKey get() = parent.publicKey @@ -183,8 +190,8 @@ sealed class unlockedIosSigner(private val ownedArena: Arena, internal val priva class RSA(ownedArena: Arena, privateKeyRef: SecKeyRef, - override val parent: iosSigner.RSA) - : unlockedIosSigner(ownedArena, privateKeyRef), Signer.RSA + override val parent: IosSigner.RSA) + : UnlockedIosSigner(ownedArena, privateKeyRef), Signer.RSA { override val signatureAlgorithm get() = parent.signatureAlgorithm override val publicKey get() = parent.publicKey @@ -194,40 +201,82 @@ sealed class unlockedIosSigner(private val ownedArena: Arena, internal val priva } -sealed class iosSigner( - final override val alias: String, - final override val attestation: iosHomebrewAttestation?, - private val config: iosSignerConfiguration -) : Signer.TemporarilyUnlockable(), Signer.Attestable, Signer.WithAlias, iosSignerI { +@Serializable +data class IosKeyMetadata( + internal val attestation: IosHomebrewAttestation?, + internal val unlockTimeout: Duration +) + +private object LAContextManager { + private data class PreviousAuthentication( + val authenticatedContext: LAContext, + val authenticationTime: TimeSource.Monotonic.ValueTimeMark) + private var previousAuthentication: PreviousAuthentication? = null + @OptIn(ExperimentalContracts::class) + inline fun withLAContext(keyMetadata: IosKeyMetadata, + signerConfig: IosSignerConfiguration, body: (LAContext)->T): T { + contract { callsInPlace(body, InvocationKind.AT_MOST_ONCE) } + + val reusable = previousAuthentication?.takeIf { + it.authenticationTime.elapsedNow() <= keyMetadata.unlockTimeout + } + if (reusable != null) + return body(reusable.authenticatedContext.apply { + /** Configure it to suit this signer just in case something has gone wrong */ + localizedReason = signerConfig.unlockPrompt.v.message + localizedCancelTitle = signerConfig.unlockPrompt.v.cancelText + }) + + val newContext = LAContext().apply { + localizedReason = signerConfig.unlockPrompt.v.message + localizedCancelTitle = signerConfig.unlockPrompt.v.cancelText + touchIDAuthenticationAllowableReuseDuration = min(10L,keyMetadata.unlockTimeout.inWholeSeconds).toDouble() + } + + return body(newContext).also { + // if this did not throw (e.g., succeeded)... + previousAuthentication = PreviousAuthentication(newContext, TimeSource.Monotonic.markNow()) + } + } +} +sealed class IosSigner( + final override val alias: String, + private val metadata: IosKeyMetadata, + private val config: IosSignerConfiguration +) : Signer.TemporarilyUnlockable(), Signer.Attestable, Signer.WithAlias { + final override val attestation get() = metadata.attestation + @HazardousMaterials final override suspend fun unlock(): KmmResult = withContext(keychainThreads) { catching { val arena = Arena() val privateKey = arena.alloc() try { - memScoped { - val query = cfDictionaryOf( - kSecClass to kSecClassKey, - kSecAttrKeyClass to kSecAttrKeyClassPrivate, - kSecAttrApplicationLabel to alias, - kSecAttrApplicationTag to KeychainTags.PRIVATE_KEYS, - kSecAttrKeyType to when (this@iosSigner) { - is ECDSA -> kSecAttrKeyTypeEC - is RSA -> kSecAttrKeyTypeRSA - }, - kSecMatchLimit to kSecMatchLimitOne, - kSecReturnRef to true, - - kSecUseAuthenticationContext to LAContext().apply { - setLocalizedReason(config.unlockPrompt.v.message) - setLocalizedCancelTitle(config.unlockPrompt.v.cancelText) - }, - kSecUseAuthenticationUI to kSecUseAuthenticationUIAllow - ) - val status = SecItemCopyMatching(query, privateKey.ptr.reinterpret()) - if ((status == errSecSuccess) && (privateKey.value != null)) { - return@memScoped /* continue below try/catch */ - } else { - throw CFCryptoOperationFailed(thing = "retrieve private key", osStatus = status) + LAContextManager.withLAContext(keyMetadata = metadata, signerConfig = config) { ctx -> + memScoped { + val query = cfDictionaryOf( + kSecClass to kSecClassKey, + kSecAttrKeyClass to kSecAttrKeyClassPrivate, + kSecAttrApplicationLabel to alias, + kSecAttrApplicationTag to KeychainTags.PRIVATE_KEYS, + kSecAttrKeyType to when (this@IosSigner) { + is ECDSA -> kSecAttrKeyTypeEC + is RSA -> kSecAttrKeyTypeRSA + }, + kSecMatchLimit to kSecMatchLimitOne, + kSecReturnRef to true, + + kSecUseAuthenticationContext to ctx, + kSecUseAuthenticationUI to kSecUseAuthenticationUIAllow + ) + val status = SecItemCopyMatching(query, privateKey.ptr.reinterpret()) + if ((status == errSecSuccess) && (privateKey.value != null)) { + return@withLAContext /* continue below try/catch */ + } else { + throw CFCryptoOperationFailed( + thing = "retrieve private key", + osStatus = status + ) + } } } } catch (e: Throwable) { @@ -235,13 +284,13 @@ sealed class iosSigner( throw e } /* if the block did not throw, the handle takes ownership of the arena */ - toUnlocked(arena, privateKey.value!!).also(unlockedIosSigner::checkSupport) + toUnlocked(arena, privateKey.value!!).also(UnlockedIosSigner::checkSupport) }} protected abstract fun toUnlocked(arena: Arena, key: SecKeyRef): H - class ECDSA(alias: String, attestation: iosHomebrewAttestation?, config: iosSignerConfiguration, + class ECDSA(alias: String, metadata: IosKeyMetadata, config: IosSignerConfiguration, override val publicKey: CryptoPublicKey.EC) - : iosSigner(alias, attestation, config), Signer.ECDSA + : IosSigner(alias, metadata, config), Signer.ECDSA { override val signatureAlgorithm = when (val digest = if (config.ec.v.digestSpecified) config.ec.v.digest else publicKey.curve.nativeDigest){ Digest.SHA256, Digest.SHA384, Digest.SHA512 -> SignatureAlgorithm.ECDSA(digest, publicKey.curve) @@ -249,24 +298,24 @@ sealed class iosSigner( } override fun toUnlocked(arena: Arena, key: SecKeyRef) = - unlockedIosSigner.ECDSA(arena, key, this) + UnlockedIosSigner.ECDSA(arena, key, this) } - class RSA(alias: String, attestation: iosHomebrewAttestation?, config: iosSignerConfiguration, + class RSA(alias: String, metadata: IosKeyMetadata, config: IosSignerConfiguration, override val publicKey: CryptoPublicKey.Rsa) - : iosSigner(alias, attestation, config), Signer.RSA + : IosSigner(alias, metadata, config), Signer.RSA { override val signatureAlgorithm = SignatureAlgorithm.RSA( digest = if (config.rsa.v.digestSpecified) config.rsa.v.digest else Digest.SHA512, padding = if (config.rsa.v.paddingSpecified) config.rsa.v.padding else RSAPadding.PSS) override fun toUnlocked(arena: Arena, key: SecKeyRef) = - unlockedIosSigner.RSA(arena, key, this) + UnlockedIosSigner.RSA(arena, key, this) } } @OptIn(ExperimentalForeignApi::class) -object IosKeychainProvider: SigningProviderI, iosSignerConfiguration, iosSigningKeyConfiguration> { +object IosKeychainProvider: SigningProviderI, IosSignerConfiguration, IosSigningKeyConfiguration> { private fun MemScope.getPublicKey(alias: String): SecKeyRef? { val it = alloc() val query = cfDictionaryOf( @@ -285,7 +334,7 @@ object IosKeychainProvider: SigningProviderI, iosSignerConfiguratio } } } - private fun storeKeyAttestation(alias: String, attestation: iosHomebrewAttestation) = memScoped { + private fun storeKeyMetadata(alias: String, metadata: IosKeyMetadata) = memScoped { val status = SecItemUpdate( cfDictionaryOf( kSecClass to kSecClassKey, @@ -293,13 +342,13 @@ object IosKeychainProvider: SigningProviderI, iosSignerConfiguratio kSecAttrApplicationLabel to alias, kSecAttrApplicationTag to KeychainTags.PUBLIC_KEYS), cfDictionaryOf( - kSecAttrLabel to Json.encodeToString(attestation) + kSecAttrLabel to Json.encodeToString(metadata) )) if (status != errSecSuccess) { throw CFCryptoOperationFailed(thing = "store key attestation", osStatus = status) } } - private fun getKeyAttestation(alias: String): iosHomebrewAttestation? = memScoped { + private fun getKeyMetadata(alias: String): IosKeyMetadata = memScoped { val it = alloc() val query = cfDictionaryOf( kSecClass to kSecClassKey, @@ -310,10 +359,7 @@ object IosKeychainProvider: SigningProviderI, iosSignerConfiguratio ) val status = SecItemCopyMatching(query, it.ptr.reinterpret()) return when (status) { - errSecSuccess -> it.value!!.let { attrs -> - attrs.get(kSecAttrLabel)?.let(Json::decodeFromString) - } - errSecItemNotFound -> null + errSecSuccess -> it.value!!.get(kSecAttrLabel).let(Json::decodeFromString) else -> { throw CFCryptoOperationFailed(thing = "retrieve attestation info", osStatus = status) } @@ -322,20 +368,20 @@ object IosKeychainProvider: SigningProviderI, iosSignerConfiguratio override suspend fun createSigningKey( alias: String, - configure: DSLConfigureFn - ): KmmResult> = withContext(keychainThreads) { catching { + configure: DSLConfigureFn + ): KmmResult> = withContext(keychainThreads) { catching { memScoped { if (getPublicKey(alias) != null) throw NoSuchElementException("Key with alias $alias already exists") } deleteSigningKey(alias) /* make sure there are no leftover private keys */ - val config = DSL.resolve(::iosSigningKeyConfiguration, configure) + val config = DSL.resolve(::IosSigningKeyConfiguration, configure) val availability = config.hardware.v.let { c-> when (c.availability) { - iosSecureEnclaveConfiguration.Availability.ALWAYS -> if (c.allowBackup) kSecAttrAccessibleAlways else kSecAttrAccessibleAlwaysThisDeviceOnly - iosSecureEnclaveConfiguration.Availability.AFTER_FIRST_UNLOCK -> if (c.allowBackup) kSecAttrAccessibleAfterFirstUnlock else kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly - iosSecureEnclaveConfiguration.Availability.WHILE_UNLOCKED -> if (c.allowBackup) kSecAttrAccessibleWhenUnlocked else kSecAttrAccessibleWhenUnlockedThisDeviceOnly + IosSecureEnclaveConfiguration.Availability.ALWAYS -> if (c.allowBackup) kSecAttrAccessibleAlways else kSecAttrAccessibleAlwaysThisDeviceOnly + IosSecureEnclaveConfiguration.Availability.AFTER_FIRST_UNLOCK -> if (c.allowBackup) kSecAttrAccessibleAfterFirstUnlock else kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly + IosSecureEnclaveConfiguration.Availability.WHILE_UNLOCKED -> if (c.allowBackup) kSecAttrAccessibleWhenUnlocked else kSecAttrAccessibleWhenUnlockedThisDeviceOnly } } val useSecureEnclave = when (config.hardware.v.backing) { @@ -436,7 +482,7 @@ object IosKeychainProvider: SigningProviderI, iosSignerConfiguratio } Napier.v { "created attestation key (keyId = $keyId)" } - val clientData = iosHomebrewAttestation.ClientData( + val clientData = IosHomebrewAttestation.ClientData( publicKey = publicKey, challenge = attestationConfig.challenge) val clientDataJSON = Json.encodeToString(clientData).encodeToByteArray() @@ -445,20 +491,23 @@ object IosKeychainProvider: SigningProviderI, iosSignerConfiguratio }.toByteArray() Napier.v { "attested key ($assertionKeyAttestation)" } - val attestation = iosHomebrewAttestation(attestation = assertionKeyAttestation, clientDataJSON = clientDataJSON) - storeKeyAttestation(alias, attestation) - return@let attestation + return@let IosHomebrewAttestation(attestation = assertionKeyAttestation, clientDataJSON = clientDataJSON) } } else null - Napier.v { "key $alias has attestation? ${attestation != null}" } + val metadata = IosKeyMetadata( + attestation = attestation, + unlockTimeout = config.hardware.v.protection.v?.timeout ?: Duration.ZERO + ).also { storeKeyMetadata(alias, it) } + + Napier.v { "key $alias metadata stored (has attestation? ${attestation != null})" } - val signerConfiguration = DSL.resolve(::iosSignerConfiguration, config.signer.v) + val signerConfiguration = DSL.resolve(::IosSignerConfiguration, config.signer.v) return@catching when (publicKey) { is CryptoPublicKey.EC -> - iosSigner.ECDSA(alias, attestation, signerConfiguration, publicKey) + IosSigner.ECDSA(alias, metadata, signerConfiguration, publicKey) is CryptoPublicKey.Rsa -> - iosSigner.RSA(alias, attestation, signerConfiguration, publicKey) + IosSigner.RSA(alias, metadata, signerConfiguration, publicKey) } }.also { val e = it.exceptionOrNull() @@ -470,9 +519,9 @@ object IosKeychainProvider: SigningProviderI, iosSignerConfiguratio override suspend fun getSignerForKey( alias: String, - configure: DSLConfigureFn - ): KmmResult> = withContext(keychainThreads) { catching { - val config = DSL.resolve(::iosSignerConfiguration, configure) + configure: DSLConfigureFn + ): KmmResult> = withContext(keychainThreads) { catching { + val config = DSL.resolve(::IosSignerConfiguration, configure) val publicKeyBytes: ByteArray = memScoped { val publicKey = getPublicKey(alias) ?: throw NoSuchElementException("No key for alias $alias exists") @@ -480,11 +529,11 @@ object IosKeychainProvider: SigningProviderI, iosSignerConfiguratio SecKeyCopyExternalRepresentation(publicKey, error) }.let { it.takeFromCF() }.toByteArray() } - val attestation = getKeyAttestation(alias) + val metadata = getKeyMetadata(alias) return@catching when (val publicKey = CryptoPublicKey.fromIosEncoded(publicKeyBytes)) { - is CryptoPublicKey.EC -> iosSigner.ECDSA(alias, attestation, config, publicKey) - is CryptoPublicKey.Rsa -> iosSigner.RSA(alias, attestation, config, publicKey) + is CryptoPublicKey.EC -> IosSigner.ECDSA(alias, metadata, config, publicKey) + is CryptoPublicKey.Rsa -> IosSigner.RSA(alias, metadata, config, publicKey) } }} From de918b1b8e00b7d6d3933d9cdb6f7dd7a9c5e4f3 Mon Sep 17 00:00:00 2001 From: Jakob Heher Date: Wed, 28 Aug 2024 19:44:45 +0200 Subject: [PATCH 29/73] don't overwrite existing keys --- .../at/asitplus/signum/supreme/os/AndroidKeyStoreProvider.kt | 3 +++ 1 file changed, 3 insertions(+) diff --git a/supreme/src/androidMain/kotlin/at/asitplus/signum/supreme/os/AndroidKeyStoreProvider.kt b/supreme/src/androidMain/kotlin/at/asitplus/signum/supreme/os/AndroidKeyStoreProvider.kt index ca945031..d371a412 100644 --- a/supreme/src/androidMain/kotlin/at/asitplus/signum/supreme/os/AndroidKeyStoreProvider.kt +++ b/supreme/src/androidMain/kotlin/at/asitplus/signum/supreme/os/AndroidKeyStoreProvider.kt @@ -131,6 +131,9 @@ class AndroidKeyStoreProvider: alias: String, configure: DSLConfigureFn ) = catching { + if (ks.containsAlias(alias)) { + throw NoSuchElementException("Key with alias $alias already exists") + } val config = DSL.resolve(::AndroidSigningKeyConfiguration, configure) val spec = KeyGenParameterSpec.Builder( alias, From d529b2a70578ed8db7c19f1ff4d2ddffd751944f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bernd=20Pr=C3=BCnster?= Date: Wed, 28 Aug 2024 23:24:36 +0200 Subject: [PATCH 30/73] version updates, changelog, ang fixes --- CHANGELOG.md | 7 ++++++- build.gradle.kts | 2 +- gradle.properties | 3 +++ indispensable-cosef/build.gradle.kts | 5 ++--- settings.gradle.kts | 1 + supreme/build.gradle.kts | 3 ++- .../asitplus/signum/supreme/os/AndroidKeyStoreProvider.kt | 2 ++ 7 files changed, 17 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c030bbe..590dff04 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,8 +3,13 @@ ## 3.0 -### NEXT +### 3.7.0 (Supreme 0.2.0) * Implement supreme signing capabilities +* Introduce Attestation Data Structure +* Dependency Updates: + * Kotlin 2.0.20 + * kotlinx.serialization 1.7.2 stable (bye, bye unofficial snapshot dependency!) + * kotlinx-datetime 0.6.1 ### 3.6.1 * Externalise `UVarInt` to multibase diff --git a/build.gradle.kts b/build.gradle.kts index 96223ddd..6a60096e 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,7 +1,7 @@ import org.jetbrains.dokka.gradle.DokkaMultiModuleTask plugins { - id("at.asitplus.gradle.conventions") version "2.0.0+20240725" + id("at.asitplus.gradle.conventions") version "2.0.20+20240829" id("com.android.library") version "8.2.0" apply (false) } group = "at.asitplus.signum" diff --git a/gradle.properties b/gradle.properties index 6ea3605d..e4aa60b0 100644 --- a/gradle.properties +++ b/gradle.properties @@ -3,6 +3,9 @@ kotlin.js.compiler=ir org.gradle.jvmargs=-Xmx4g -Dfile.encoding=UTF-8 artifactVersion = 3.7.0-SNAPSHOT +supremeVersion=0.2.0-SNAPSHOT + +#for swift plugin org.gradle.caching=false org.gradle.configuration-cache=false # This is not a well-defined property, the ASP convention plugin respects it, though diff --git a/indispensable-cosef/build.gradle.kts b/indispensable-cosef/build.gradle.kts index bddd3b55..f2be9fb0 100644 --- a/indispensable-cosef/build.gradle.kts +++ b/indispensable-cosef/build.gradle.kts @@ -25,11 +25,10 @@ kotlin { languageSettings.optIn("kotlin.ExperimentalUnsignedTypes") } - commonMain { + commonMain { dependencies { api(project(":indispensable")) - //noinspection UseTomlInstead - api("org.jetbrains.kotlinx:kotlinx-serialization-cbor:1.8.0-SNAPSHOT!!") + api(serialization("cbor")) implementation(napier()) implementation(libs.multibase) implementation(libs.bignum) //Intellij bug work-around diff --git a/settings.gradle.kts b/settings.gradle.kts index d4c914d6..cd9cb47e 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -3,6 +3,7 @@ pluginManagement { repositories { google() mavenCentral() + maven("https://s01.oss.sonatype.org/content/repositories/snapshots") //KOTEST snapshot gradlePluginPortal() maven { url = uri("https://raw.githubusercontent.com/a-sit-plus/gradle-conventions-plugin/mvn/repo") diff --git a/supreme/build.gradle.kts b/supreme/build.gradle.kts index 16785479..69fc196e 100644 --- a/supreme/build.gradle.kts +++ b/supreme/build.gradle.kts @@ -26,7 +26,8 @@ buildscript { } -version = "0.2.0-SNAPSHOT" +val supremeVersion: String by extra +version = supremeVersion wireAndroidInstrumentedTests() diff --git a/supreme/src/androidMain/kotlin/at/asitplus/signum/supreme/os/AndroidKeyStoreProvider.kt b/supreme/src/androidMain/kotlin/at/asitplus/signum/supreme/os/AndroidKeyStoreProvider.kt index d371a412..30f65260 100644 --- a/supreme/src/androidMain/kotlin/at/asitplus/signum/supreme/os/AndroidKeyStoreProvider.kt +++ b/supreme/src/androidMain/kotlin/at/asitplus/signum/supreme/os/AndroidKeyStoreProvider.kt @@ -25,6 +25,7 @@ import at.asitplus.signum.indispensable.pki.CertificateChain import at.asitplus.signum.indispensable.pki.X509Certificate import at.asitplus.signum.indispensable.pki.leaf import at.asitplus.signum.supreme.AppLifecycleMonitor +import at.asitplus.signum.supreme.HazardousMaterials import at.asitplus.signum.supreme.UnlockFailed import at.asitplus.signum.supreme.UnsupportedCryptoException import at.asitplus.signum.supreme.dsl.DISCOURAGED @@ -354,6 +355,7 @@ sealed class LockedAndroidKeystoreSigner private constructor( protected abstract fun toUnlocked(jcaSig: JCASignatureObject): UnlockedAndroidKeystoreSigner + @HazardousMaterials final override suspend fun unlock(): KmmResult = signatureAlgorithm.getJCASignatureInstance().onSuccess { if (needsAuthenticationForEveryUse) { From b18ea9f4acf3c4cacf9093ef7b52e5921aa889cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bernd=20Pr=C3=BCnster?= Date: Thu, 29 Aug 2024 00:09:53 +0200 Subject: [PATCH 31/73] fix jvm compile error --- .../kotlin/at/asitplus/signum/supreme/os/JKSProviderTest.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/supreme/src/jvmTest/kotlin/at/asitplus/signum/supreme/os/JKSProviderTest.kt b/supreme/src/jvmTest/kotlin/at/asitplus/signum/supreme/os/JKSProviderTest.kt index b40c4648..858b1f2d 100644 --- a/supreme/src/jvmTest/kotlin/at/asitplus/signum/supreme/os/JKSProviderTest.kt +++ b/supreme/src/jvmTest/kotlin/at/asitplus/signum/supreme/os/JKSProviderTest.kt @@ -30,7 +30,7 @@ class JKSProviderTest : FreeSpec({ try { val alias = "Elfenbeinturm" - val ks1 = SigningProvider { + val ks1 = JKSProvider { keystoreFile { file = tempfile password = "Schwertfischfilet".toCharArray() @@ -45,7 +45,7 @@ class JKSProviderTest : FreeSpec({ it.createSigningKey(alias) should succeed } - SigningProvider { + JKSProvider { keystoreFile { file = tempfile password = "Bartfischfilet".toCharArray() @@ -55,7 +55,7 @@ class JKSProviderTest : FreeSpec({ it.getSignerForKey(alias) shouldNot succeed } - SigningProvider { + JKSProvider { keystoreFile { file = tempfile password = "Schwertfischfilet".toCharArray() From e40c349e23e132f83a4e611c23b514d15c90a7e7 Mon Sep 17 00:00:00 2001 From: Jakob Heher Date: Thu, 29 Aug 2024 14:33:05 +0200 Subject: [PATCH 32/73] partial documentation commit --- .../supreme/os/AndroidKeyStoreProvider.kt | 14 ++- .../signum/supreme/os/SigningProvider.kt | 51 +++++++--- .../signum/supreme/sign/EphemeralKeys.kt | 35 +++++-- .../at/asitplus/signum/supreme/sign/Signer.kt | 46 +++++++-- .../signum/supreme/os/IosKeychainProvider.kt | 14 +-- .../asitplus/signum/supreme/os/JKSProvider.kt | 96 ++++++++++++++----- 6 files changed, 195 insertions(+), 61 deletions(-) diff --git a/supreme/src/androidMain/kotlin/at/asitplus/signum/supreme/os/AndroidKeyStoreProvider.kt b/supreme/src/androidMain/kotlin/at/asitplus/signum/supreme/os/AndroidKeyStoreProvider.kt index 30f65260..e1ef21b5 100644 --- a/supreme/src/androidMain/kotlin/at/asitplus/signum/supreme/os/AndroidKeyStoreProvider.kt +++ b/supreme/src/androidMain/kotlin/at/asitplus/signum/supreme/os/AndroidKeyStoreProvider.kt @@ -83,9 +83,13 @@ class AndroidSignerConfiguration: PlatformSignerConfigurationBase() { this::fragment.isInitialized -> FragmentContext.OfFragment(fragment) else -> null } + /** @see [BiometricPrompt.PromptInfo.Builder.setSubtitle] */ var subtitle: String? = null + /** @see [BiometricPrompt.PromptInfo.Builder.setDescription] */ var description: String? = null + /** @see [BiometricPrompt.PromptInfo.Builder.setConfirmationRequired] */ var confirmationRequired: Boolean? = null + /** @see [BiometricPrompt.PromptInfo.Builder.setAllowedAuthenticators] */ var allowedAuthenticators: Int? = null /** if the provided fingerprint could not be matched, but the user will be allowed to retry */ var invalidBiometryCallback: (()->Unit)? = null @@ -205,7 +209,7 @@ class AndroidKeyStoreProvider: is CryptoPublicKey.Rsa -> { val rsaConfig = config.rsa.v val digest = resolveOption("digest", keyInfo.digests, Digest.entries.asSequence(), rsaConfig.digestSpecified, rsaConfig.digest, Digest::jcaName) - val padding = resolveOption("padding", keyInfo.signaturePaddings, RSAPadding.entries.asSequence(), rsaConfig.paddingSpecified, rsaConfig.padding) { + val padding = resolveOption("padding", keyInfo.signaturePaddings, RSAPadding.entries.asSequence(), rsaConfig::padding) { when (it) { RSAPadding.PKCS1 -> KeyProperties.SIGNATURE_PADDING_RSA_PKCS1 RSAPadding.PSS -> KeyProperties.SIGNATURE_PADDING_RSA_PSS @@ -242,7 +246,7 @@ class AndroidKeyStoreProvider: } } - override suspend fun deleteSigningKey(alias: String) { + override suspend fun deleteSigningKey(alias: String) = catching { ks.deleteEntry(alias) } } @@ -409,6 +413,6 @@ val AndroidKeystoreSigner.needsAuthenticationWithTimeout inline get() = actual typealias PlatformSigningProviderSignerConfiguration = AndroidSignerConfiguration actual typealias PlatformSigningProviderSigningKeyConfiguration = AndroidSigningKeyConfiguration actual typealias PlatformSigningProvider = AndroidKeyStoreProvider -actual typealias PlatformSigningProviderConfiguration = PlatformSigningProviderConfigurationBase -internal actual fun makePlatformSigningProvider(config: PlatformSigningProviderConfiguration) = - AndroidKeyStoreProvider()*/ +actual typealias PlatformSigningProviderConfiguration = PlatformSigningProviderConfigurationBase*/ +internal actual fun getPlatformSigningProvider(configure: DSLConfigureFn): SigningProvider = + AndroidKeyStoreProvider() diff --git a/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/os/SigningProvider.kt b/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/os/SigningProvider.kt index 428e55c3..a99fe496 100644 --- a/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/os/SigningProvider.kt +++ b/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/os/SigningProvider.kt @@ -54,11 +54,11 @@ open class PlatformSigningKeyConfigurationBase() - - // TODO: figure out a reasonable common interface for biometry requirements } open class ECSignerConfiguration internal constructor(): DSL.Data() { @@ -67,7 +67,10 @@ open class ECSignerConfiguration internal constructor(): DSL.Data() { * Explicitly specify the digest to sign over. * Omit to default to the only supported digest. * - * If the key supports multiple digests, you need to explicitly specify the digest to use. + * If the key stored in hardware supports multiple digests, you need to explicitly specify the digest to use. + * (By default, hardware keys are configured to only support a single digest.) + * + * @see SigningKeyConfiguration.ECConfiguration.digests */ var digest: Digest? = null; set(v) { digestSpecified = true; field = v } } @@ -75,9 +78,12 @@ open class RSASignerConfiguration internal constructor(): DSL.Data() { internal var digestSpecified = false /** * Explicitly specify the digest to sign over. - * Omit to default to the only supported digest. + * Omit to default to a reasonable default choice. * - * If the key supports multiple digests, you need to explicitly specify the digest to use. + * If a key stored in hardware supports multiple digests, you need to explicitly specify the digest to use. + * (By default, hardware keys are configured to only support a single digest.) + * + * @see SigningKeyConfiguration.RSAConfiguration.digests */ var digest: Digest = Digest.SHA256; set(v) { digestSpecified = true; field = v } @@ -86,14 +92,19 @@ open class RSASignerConfiguration internal constructor(): DSL.Data() { * Explicitly specify the padding to use. * Omit to default to the only supported padding. * - * If the key supports multiple padding modes, you need to explicitly specify the digest to use. + * If the key stored in hardware supports multiple padding modes, you need to explicitly specify the digest to use. + * (By default, hardware keys are configured to only support a single digest.) + * + * @see SigningKeyConfiguration.RSAConfiguration.paddings */ var padding: RSAPadding = RSAPadding.PKCS1; set(v) { paddingSpecified = true; field = v } } open class SignerConfiguration internal constructor(): DSL.Data() { + /** Algorithm-specific configuration for a returned ECDSA signer. Ignored for RSA keys. */ open val ec = childOrDefault(::ECSignerConfiguration) + /** Algorithm-specific configuration for a returned RSA signer. Ignored for ECDSA keys. */ open val rsa = childOrDefault(::RSASignerConfiguration) } @@ -104,6 +115,7 @@ open class PlatformSignerConfigurationBase internal constructor(): SignerConfigu /** The message to show on the cancellation button */ var cancelText: String = "Abort" } + /** Configure the authorization prompt that will be shown to the user. */ open val unlockPrompt = childOrDefault(::AuthnPrompt) } @@ -115,17 +127,34 @@ expect class PlatformSigningProviderSignerConfiguration: PlatformSignerConfigura expect class PlatformSigningProviderSigningKeyConfiguration: PlatformSigningKeyConfigurationBase expect class PlatformSigningProvider : SigningProviderI internal expect fun makePlatformSigningProvider(config: PlatformSigningProviderConfiguration): KmmResult*/ +internal expect fun getPlatformSigningProvider(configure: DSLConfigureFn): SigningProvider interface SigningProviderI> { - suspend fun createSigningKey(alias: String, configure: DSLConfigureFn = null) : KmmResult - suspend fun getSignerForKey(alias: String, configure: DSLConfigureFn = null) : KmmResult - suspend fun deleteSigningKey(alias: String) + suspend fun createSigningKey(alias: String, configure: DSLConfigureFn = null): KmmResult + suspend fun getSignerForKey(alias: String, configure: DSLConfigureFn = null): KmmResult + suspend fun deleteSigningKey(alias: String): KmmResult companion object { - /*operator fun invoke(configure: DSLConfigureFn = null) = - makePlatformSigningProvider(DSL.resolve(::PlatformSigningProviderConfiguration, configure))*/ + operator fun invoke(configure: DSLConfigureFn = null) = + getPlatformSigningProvider(configure) } } +/** + * An interface to some underlying persistent storage for private key material. Stored keys are identified by a unique string "alias" for each key. + * You can [create signing keys][createSigningKey], [get signers for existing keys][getSignerForKey], or [delete signing keys][deleteSigningKey]. + * + * To obtain a platform signing provider in platform-agnostic code, use `SigningProvider()`. + * In platform-specific code, it is currently recommended to directly interface with your platform signing provider to get platform-specific functionality. + * (Platform-specific return types from `SigningProvider()` are currently blocked by KT-71036.) + * + * Created keys can be configured using the [SigningKeyConfiguration] DSL. + * Signers can be configured using the [SignerConfiguration] DSL. + * When creating a key, the returned signer's configuration is embedded in the signing key configuration as `signer {}`. + * + * @see JKSProvider + * @see AndroidKeyStoreProvider + * @see IosKeychainProvider + */ typealias SigningProvider = SigningProviderI<*,*,*> diff --git a/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/sign/EphemeralKeys.kt b/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/sign/EphemeralKeys.kt index cf50dae6..aab02c3a 100644 --- a/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/sign/EphemeralKeys.kt +++ b/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/sign/EphemeralKeys.kt @@ -1,5 +1,7 @@ package at.asitplus.signum.supreme.sign +import at.asitplus.KmmResult +import at.asitplus.catching import at.asitplus.signum.indispensable.CryptoPublicKey import at.asitplus.signum.indispensable.Digest import at.asitplus.signum.indispensable.RSAPadding @@ -27,20 +29,37 @@ expect class EphemeralSigningKeyConfiguration internal constructor(): EphemeralS typealias EphemeralSignerConfigurationBase = SignerConfiguration expect class EphemeralSignerConfiguration internal constructor(): SignerConfiguration +/** + * An ephemeral keypair, not stored in any kind of persistent storage. + * Can be either [EC] or [RSA]. Has a [CryptoPublicKey], and you can obtain a [Signer] from it. + * + * To generate a key, use + * ``` + * EphemeralKey { + * /* optional configuration */ + * } + * ``` + */ sealed interface EphemeralKey { val publicKey: CryptoPublicKey - fun signer(configure: DSLConfigureFn = null): Signer + + /** Create a signer that signs using this [EphemeralKey]. + * @see EphemeralSignerConfiguration */ + fun signer(configure: DSLConfigureFn = null): KmmResult + + /** An [EphemeralKey] suitable for ECDSA operations. */ interface EC: EphemeralKey { override val publicKey: CryptoPublicKey.EC - override fun signer(configure: DSLConfigureFn): Signer.ECDSA + override fun signer(configure: DSLConfigureFn): KmmResult } + /** An [EphemeralKey] suitable for RSA operations. */ interface RSA: EphemeralKey { override val publicKey: CryptoPublicKey.Rsa - override fun signer(configure: DSLConfigureFn): Signer.RSA + override fun signer(configure: DSLConfigureFn): KmmResult } companion object { operator fun invoke(configure: DSLConfigureFn = null) = - makeEphemeralKey(DSL.resolve(::EphemeralSigningKeyConfiguration, configure)) + catching { makeEphemeralKey(DSL.resolve(::EphemeralSigningKeyConfiguration, configure)) } } } @@ -52,7 +71,7 @@ internal sealed class EphemeralKeyBase privateKey: PrivateKeyT, override val publicKey: CryptoPublicKey.EC, val digests: Set) : EphemeralKeyBase(privateKey), EphemeralKey.EC { - override fun signer(configure: DSLConfigureFn): SignerT { + override fun signer(configure: DSLConfigureFn): KmmResult = catching { val config = DSL.resolve(::EphemeralSignerConfiguration, configure) val alg = config.ec.v val digest = when (alg.digestSpecified) { @@ -65,7 +84,7 @@ internal sealed class EphemeralKeyBase sequenceOf(publicKey.curve.nativeDigest, Digest.SHA256, Digest.SHA384, Digest.SHA512) .firstOrNull(digests::contains) ?: digests.first() } - return signerFactory(config, privateKey, publicKey, SignatureAlgorithm.ECDSA(digest, publicKey.curve)) + return@catching signerFactory(config, privateKey, publicKey, SignatureAlgorithm.ECDSA(digest, publicKey.curve)) } } @@ -74,7 +93,7 @@ internal sealed class EphemeralKeyBase privateKey: PrivateKeyT, override val publicKey: CryptoPublicKey.Rsa, val digests: Set, val paddings: Set) : EphemeralKeyBase(privateKey), EphemeralKey.RSA { - override fun signer(configure: DSLConfigureFn): SignerT { + override fun signer(configure: DSLConfigureFn): KmmResult = catching { val config = DSL.resolve(::EphemeralSignerConfiguration, configure) val alg = config.rsa.v val digest = when (alg.digestSpecified) { @@ -102,7 +121,7 @@ internal sealed class EphemeralKeyBase else -> paddings.first() } } - return signerFactory(config, privateKey, publicKey, SignatureAlgorithm.RSA(digest, padding)) + return@catching signerFactory(config, privateKey, publicKey, SignatureAlgorithm.RSA(digest, padding)) } } } diff --git a/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/sign/Signer.kt b/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/sign/Signer.kt index d88cd251..e1a2c722 100644 --- a/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/sign/Signer.kt +++ b/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/sign/Signer.kt @@ -14,6 +14,7 @@ import at.asitplus.signum.supreme.UnlockFailed import at.asitplus.signum.supreme.dsl.DSL import at.asitplus.signum.supreme.dsl.DSLConfigureFn import at.asitplus.signum.supreme.os.Attestation +import at.asitplus.signum.supreme.os.SigningProvider import com.ionspin.kotlin.bignum.integer.BigInteger open class SigningKeyConfiguration internal constructor(): DSL.Data() { @@ -40,6 +41,29 @@ open class SigningKeyConfiguration internal constructor(): DSL.Data() { open val rsa = _algSpecific.option(::RSAConfiguration) } +/** + * Shared interface of all objects that can sign data. + * Signatures are created using the [signatureAlgorithm], and can be verified using [publicKey], potentially with a [verifierFor] this object. + * + * Signers for your platform can be accessed using your platform's [SigningProvider]. + * + * Ephemeral signers can be obtained using + * ``` + * Signer.Ephemeral { + * /* optional key configuration */ + * } + * ``` + * This will generate a throwaway [EphemeralKey] and return a Signer for it. + * + * Any actual instantiation will have a [AlgTrait], which will be either [ECDSA] or [RSA]. + * Instantiations may also be [WithAlias], usually because they come from a [SigningProvider]. + * They may also be [Attestable]. + * + * Some signers [mayRequireUserUnlock]. If needed, they will ask for user interaction when you try to [sign] data. + * Of these signers, some are also [Signer.TemporarilyUnlockable]. + * These signers can be used to sign multiple times in rapid succession with only a single user interaction. + * + */ interface Signer { val signatureAlgorithm: SignatureAlgorithm val publicKey: CryptoPublicKey @@ -49,19 +73,19 @@ interface Signer { /** Any [Signer] instantiation must be [ECDSA] or [RSA] */ sealed interface AlgTrait : Signer - /** ECDSA signer */ + /** A [Signer] that signs using ECDSA. */ interface ECDSA : Signer.AlgTrait { override val signatureAlgorithm: SignatureAlgorithm.ECDSA override val publicKey: CryptoPublicKey.EC } - /** RSA signer */ + /** A [Signer] that signs using RSA. */ interface RSA : Signer.AlgTrait { override val signatureAlgorithm: SignatureAlgorithm.RSA override val publicKey: CryptoPublicKey.Rsa } - /** Some [Signer]s are retrieved from a signing provider, such as a key store, and have a string alias. */ + /** Some [Signer]s are retrieved from a signing provider, such as a key store, and have a string [alias]. */ interface WithAlias: Signer { val alias: String } @@ -71,11 +95,12 @@ interface Signer { val attestation: AttestationT? } + /** Signs data. Might ask for user confirmation first if this [Signer] [mayRequireUserUnlock]. */ suspend fun sign(data: SignatureInput): KmmResult /** * A handle to a [TemporarilyUnlockable] signer that is temporarily unlocked. - * The handle is only guaranteed to be valid within the scope of the block. + * The handle is only guaranteed to be valid within the scope of the [withUnlock] block. */ @OptIn(ExperimentalStdlibApi::class) interface UnlockedHandle: AutoCloseable, Signer { @@ -85,10 +110,14 @@ interface Signer { /** * A signer that can be temporarily unlocked. * Once unlocked, multiple signing operations can be performed with a single unlock. + * + * @see withUnlock */ abstract class TemporarilyUnlockable : Signer { final override val mayRequireUserUnlock: Boolean get() = true + /** Obtains a raw unlock handle. This is public because [withUnlock] needs to be inline. + * If you use it directly, closing the returned handle is your responsibility! */ @HazardousMaterials abstract suspend fun unlock(): KmmResult @@ -107,8 +136,8 @@ interface Signer { } companion object { - operator fun invoke(configure: DSLConfigureFn = null) = - EphemeralKey(configure).signer() + fun Ephemeral(configure: DSLConfigureFn = null) = + EphemeralKey(configure).transform(EphemeralKey::signer) } } @@ -127,8 +156,8 @@ fun Signer.makePlatformVerifier(configure: ConfigurePlatformVerifier = null) = s val Signer.ECDSA.curve get() = publicKey.curve /** - * Try to batch sign with this signer. - * Might fail for unlockable signers that cannot be temporarily unlocked. + * Try to batch sign with this [Signer]. + * Might fail for locked [Signer]s that are not [Signer.TemporarilyUnlockable]. */ suspend inline fun Signer.withUnlock(fn: Signer.()->T) = when (this.mayRequireUserUnlock) { @@ -140,4 +169,5 @@ suspend inline fun Signer.withUnlock(fn: Signer.()->T) = false -> catching { fn(this) } } +/** Sign the data. */ suspend inline fun Signer.sign(data: ByteArray) = sign(SignatureInput(data)) diff --git a/supreme/src/iosMain/kotlin/at/asitplus/signum/supreme/os/IosKeychainProvider.kt b/supreme/src/iosMain/kotlin/at/asitplus/signum/supreme/os/IosKeychainProvider.kt index 12188d72..eb84eab3 100644 --- a/supreme/src/iosMain/kotlin/at/asitplus/signum/supreme/os/IosKeychainProvider.kt +++ b/supreme/src/iosMain/kotlin/at/asitplus/signum/supreme/os/IosKeychainProvider.kt @@ -374,7 +374,7 @@ object IosKeychainProvider: SigningProviderI, IosSignerConfiguratio if (getPublicKey(alias) != null) throw NoSuchElementException("Key with alias $alias already exists") } - deleteSigningKey(alias) /* make sure there are no leftover private keys */ + deleteSigningKey(alias).getOrThrow() /* make sure there are no leftover private keys */ val config = DSL.resolve(::IosSigningKeyConfiguration, configure) @@ -513,7 +513,7 @@ object IosKeychainProvider: SigningProviderI, IosSignerConfiguratio val e = it.exceptionOrNull() if (e != null && e !is NoSuchElementException) { // get rid of any "partial" keys - runCatching { deleteSigningKey(alias) } + deleteSigningKey(alias) } }} @@ -537,7 +537,7 @@ object IosKeychainProvider: SigningProviderI, IosSignerConfiguratio } }} - override suspend fun deleteSigningKey(alias: String) = withContext(keychainThreads) { + override suspend fun deleteSigningKey(alias: String) = withContext(keychainThreads) { catching { memScoped { mapOf( "public key" to cfDictionaryOf( @@ -562,13 +562,13 @@ object IosKeychainProvider: SigningProviderI, IosSignerConfiguratio throw CryptoOperationFailed(it.joinToString(",")) } } - } + } } } /*actual typealias PlatformSigningProviderSigner = iosSigner<*> actual typealias PlatformSigningProviderSignerConfiguration = iosSignerConfiguration actual typealias PlatformSigningProviderSigningKeyConfiguration = iosSigningKeyConfiguration actual typealias PlatformSigningProvider = IosKeychainProvider -actual typealias PlatformSigningProviderConfiguration = PlatformSigningProviderConfigurationBase -internal actual fun makePlatformSigningProvider(config: PlatformSigningProviderConfiguration) = - IosKeychainProvider*/ +actual typealias PlatformSigningProviderConfiguration = PlatformSigningProviderConfigurationBase*/ +internal actual fun getPlatformSigningProvider(configure: DSLConfigureFn): SigningProvider = + IosKeychainProvider diff --git a/supreme/src/jvmMain/kotlin/at/asitplus/signum/supreme/os/JKSProvider.kt b/supreme/src/jvmMain/kotlin/at/asitplus/signum/supreme/os/JKSProvider.kt index 352fece2..c1a4a729 100644 --- a/supreme/src/jvmMain/kotlin/at/asitplus/signum/supreme/os/JKSProvider.kt +++ b/supreme/src/jvmMain/kotlin/at/asitplus/signum/supreme/os/JKSProvider.kt @@ -20,8 +20,10 @@ import at.asitplus.signum.indispensable.pki.TbsCertificate import at.asitplus.signum.indispensable.pki.X509Certificate import at.asitplus.signum.indispensable.pki.leaf import at.asitplus.signum.indispensable.toJcaCertificate +import at.asitplus.signum.supreme.UnsupportedCryptoException import at.asitplus.signum.supreme.dsl.DSL import at.asitplus.signum.supreme.dsl.DSLConfigureFn +import at.asitplus.signum.supreme.dsl.REQUIRED import at.asitplus.signum.supreme.sign.EphemeralSigner import at.asitplus.signum.supreme.sign.JvmEphemeralSignerCompatibleConfiguration import at.asitplus.signum.supreme.sign.Signer @@ -45,13 +47,18 @@ import kotlin.time.Duration import kotlin.time.Duration.Companion.days class JKSSigningKeyConfiguration: PlatformSigningKeyConfigurationBase() { + /** The registered JCA provider to use. */ var provider: String? = null + /** The password with which to protect the private key. */ var privateKeyPassword: CharArray? = null - var certificateValidityPeriod: Duration = 100.days + /** The lifetime of the private key's certificate. */ + var certificateValidityPeriod: Duration = (365*100).days } class JKSSignerConfiguration: PlatformSignerConfigurationBase(), JvmEphemeralSignerCompatibleConfiguration { + /** The registered JCA provider to use. */ override var provider: String? = null + /** The password protecting the stored private key. */ var privateKeyPassword: CharArray? = null } @@ -76,17 +83,37 @@ private fun keystoreGetInstance(type: String, provider: String?) = when (provide else -> KeyStore.getInstance(type, provider) } -sealed interface ReadAccessorBase: AutoCloseable { +/** Read handle, [requested][JKSAccessor.forReading] whenever the provider needs to perform a read operation. + * This handle should serve as a shared lock on the underlying data to avoid data races. */ +interface ReadAccessorBase: AutoCloseable { + /** An ephemeral JCA [KeyStore] object which the provider may read from within the lifetime of the [ReadAccessorBase]. */ val ks: KeyStore } -abstract class WriteAccessorBase: ReadAccessorBase { +/** Write handle, [requested][JKSAccessor.forWriting] whenever the provider needs to perform a write operation. + * This handle should serve as an exclusive lock on the underlying data to avoid data races. */ +abstract class WriteAccessorBase: AutoCloseable { + /** An ephemeral JCA [KeyStore] object which the provider may read from and write to within the lifetime of the [WriteAccessorBase]. */ + abstract val ks: KeyStore + /** If the provider has made changes to the keystore data, this is set to `true` before calling `.close()`. */ protected var dirty = false; private set fun markAsDirty() { dirty = true } } -sealed interface JKSAccessor { +/** + * Interface for advanced domain-specific keystore access. + * Allows for concurrency via [AutoCloseable] locking. + * + * @see forReading + * @see forWriting + */ +interface JKSAccessor { + /** Obtains an accessor handle for reading from the KeyStore. + * The handle will be closed when the provider is done reading from the KeyStore. */ fun forReading(): ReadAccessorBase + /** Obtains an accessor handle for reading from and writing to the KeyStore. + * The handle will be closed when the provider is done. + * Check the [dirty][WriteAccessorBase.dirty] flag to see if changes were made to the data. */ fun forWriting(): WriteAccessorBase } @@ -97,10 +124,12 @@ class JKSProvider internal constructor (private val access: JKSAccessor) alias: String, configure: DSLConfigureFn ): KmmResult = catching { + val config = DSL.resolve(::JKSSigningKeyConfiguration, configure) + if (config.hardware.v?.backing == REQUIRED) + throw UnsupportedCryptoException("Hardware storage is unsupported on the JVM") access.forWriting().use { ctx -> if (ctx.ks.containsAlias(alias)) throw NoSuchElementException("Key with alias $alias already exists") - val config = DSL.resolve(::JKSSigningKeyConfiguration, configure) val (jcaAlg,jcaSpec,certAlg) = when (val algSpec = config._algSpecific.v) { is SigningKeyConfiguration.RSAConfiguration -> @@ -166,7 +195,7 @@ class JKSProvider internal constructor (private val access: JKSAccessor) } } - override suspend fun deleteSigningKey(alias: String) { + override suspend fun deleteSigningKey(alias: String) = catching { access.forWriting().use { ctx -> if (ctx.ks.containsAlias(alias)) { ctx.ks.deleteEntry(alias) @@ -176,10 +205,12 @@ class JKSProvider internal constructor (private val access: JKSAccessor) } companion object { - operator fun invoke(configure: DSLConfigureFn = null) = + operator fun invoke(configure: DSLConfigureFn = null) = catching { makePlatformSigningProvider(DSL.resolve(::JKSProviderConfiguration, configure)) - fun Ephemeral(type: String = KeyStore.getDefaultType(), provider: String? = null) = + } + fun Ephemeral(type: String = KeyStore.getDefaultType(), provider: String? = null) = catching { JKSProvider(DummyJKSAccessor(keystoreGetInstance(type, provider).apply { load(null) })) + } } } @@ -282,21 +313,34 @@ internal class JKSFileAccessor(opt: JKSProviderConfiguration.KeyStoreFile) : JKS override fun forWriting() = WriteAccessor() } +/** + * Specifies what the keystore should be backed by. + * + * Options are: + * * [ephemeral] (the default) + * * [file] (backed by a file on disk) + * * [withBackingObject] (backed by the specified [KeyStore] object) + * * [customAccessor] (backed by a custom [JKSAccessor] object) + */ class JKSProviderConfiguration internal constructor(): PlatformSigningProviderConfigurationBase() { sealed class KeyStoreConfiguration constructor(): DSL.Data() internal val _keystore = subclassOf(default = EphemeralKeyStore()) + /** Constructs an ephemeral keystore. This is the default. */ + val ephemeral = _keystore.option(::EphemeralKeyStore) class EphemeralKeyStore internal constructor(): KeyStoreConfiguration() { - /** The KeyStore type to use */ + /** The KeyStore type to use. */ var storeType: String = KeyStore.getDefaultType() /** The JCA provider to use. Leave `null` to not care. */ var provider: String? = null } + /** Constructs a keystore that accesses the provided Java [KeyStore] object. Use `withBackingObject { store = ... }`. */ + val withBackingObject = _keystore.option(::KeyStoreObject) class KeyStoreObject internal constructor(): KeyStoreConfiguration() { /** The KeyStore object to use */ lateinit var store: KeyStore - /** The function to be called when the keystore is modified. Can be `null`. */ + /** The function to be called after the keystore has been modified. Can be `null`. */ var flushCallback: ((KeyStore)->Unit)? = null override fun validate() { super.validate() @@ -304,11 +348,8 @@ class JKSProviderConfiguration internal constructor(): PlatformSigningProviderCo } } - /** - * Constructs a keystore from a java KeyStore object. Use `keystoreObject { store = ... }`. - */ - val keystoreObject = _keystore.option(::KeyStoreObject) - + /** Accesses a keystore on disk. Automatically flushes back to disk. Use `file { path = ... }.`*/ + val file = _keystore.option(::KeyStoreFile) class KeyStoreFile internal constructor(): KeyStoreConfiguration() { /** The KeyStore type to use */ var storeType = KeyStore.getDefaultType() @@ -328,25 +369,36 @@ class JKSProviderConfiguration internal constructor(): PlatformSigningProviderCo require(this::file.isInitialized) } } - /** - * Accesses a keystore on disk. Automatically flushes back to disk. - */ - val keystoreFile = _keystore.option(::KeyStoreFile) + + /** Accesses a keystore via a custom [JKSAccessor]. Use `keystoreCustomAccessor { accessor = ... }` */ + val customAccessor = _keystore.option(::KeyStoreAccessor) + class KeyStoreAccessor internal constructor(): KeyStoreConfiguration() { + /** A custom [JKSAccessor] to use. */ + lateinit var accessor: JKSAccessor + + override fun validate() { + super.validate() + require(this::accessor.isInitialized) + } + } } -internal /*actual*/ fun makePlatformSigningProvider(config: JKSProviderConfiguration): KmmResult = catching { +internal /*actual*/ fun makePlatformSigningProvider(config: JKSProviderConfiguration): JKSProvider = when (val opt = config._keystore.v) { is JKSProviderConfiguration.EphemeralKeyStore -> - JKSProvider.Ephemeral(opt.storeType, opt.provider) + JKSProvider.Ephemeral(opt.storeType, opt.provider).getOrThrow() is JKSProviderConfiguration.KeyStoreObject -> JKSProvider(opt.flushCallback?.let { CallbackJKSAccessor(opt.store, it) } ?: DummyJKSAccessor(opt.store)) is JKSProviderConfiguration.KeyStoreFile -> JKSProvider(JKSFileAccessor(opt)) + is JKSProviderConfiguration.KeyStoreAccessor -> + JKSProvider(opt.accessor) } -} /*actual typealias PlatformSigningProviderSigner = JKSSigner actual typealias PlatformSigningProviderSignerConfiguration = JKSSignerConfiguration actual typealias PlatformSigningProviderSigningKeyConfiguration = JKSSigningKeyConfiguration actual typealias PlatformSigningProvider = JKSProvider actual typealias PlatformSigningProviderConfiguration = JKSProviderConfiguration*/ +internal actual fun getPlatformSigningProvider(configure: DSLConfigureFn): SigningProvider = + makePlatformSigningProvider(DSL.resolve(::JKSProviderConfiguration, configure)) From c9c8ae2bf370c73f107f9e65c4e699a12ad7754b Mon Sep 17 00:00:00 2001 From: Jakob Heher Date: Thu, 29 Aug 2024 14:37:52 +0200 Subject: [PATCH 33/73] fix testcase after rename --- .../signum/supreme/sign/EphemeralSignerCommonTests.kt | 4 ++-- .../kotlin/at/asitplus/signum/supreme/os/JKSProvider.kt | 2 +- .../at/asitplus/signum/supreme/os/JKSProviderTest.kt | 8 ++++---- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/supreme/src/commonTest/kotlin/at/asitplus/signum/supreme/sign/EphemeralSignerCommonTests.kt b/supreme/src/commonTest/kotlin/at/asitplus/signum/supreme/sign/EphemeralSignerCommonTests.kt index 50abfc50..e401e2c8 100644 --- a/supreme/src/commonTest/kotlin/at/asitplus/signum/supreme/sign/EphemeralSignerCommonTests.kt +++ b/supreme/src/commonTest/kotlin/at/asitplus/signum/supreme/sign/EphemeralSignerCommonTests.kt @@ -35,7 +35,7 @@ class EphemeralSignerCommonTests : FreeSpec({ val data = Random.Default.nextBytes(64) val signer: Signer val signature = try { - signer = Signer { rsa { digests = setOf(digest); paddings = setOf(padding); bits = keySize } } + signer = Signer.Ephemeral { rsa { digests = setOf(digest); paddings = setOf(padding); bits = keySize } }.getOrThrow() signer.sign(SignatureInput(data).let { if (preHashed) it.convertTo(digest).getOrThrow() else it }).getOrThrow() } catch (x: UnsupportedOperationException) { return@withData @@ -58,7 +58,7 @@ class EphemeralSignerCommonTests : FreeSpec({ } } }) { (crv, digest, preHashed) -> - val signer = Signer { ec { curve = crv; digests = setOf(digest) } } + val signer = Signer.Ephemeral { ec { curve = crv; digests = setOf(digest) } }.getOrThrow() signer.signatureAlgorithm.shouldBeInstanceOf().let { it.digest shouldBe digest it.requiredCurve shouldBeIn setOf(null, crv) diff --git a/supreme/src/jvmMain/kotlin/at/asitplus/signum/supreme/os/JKSProvider.kt b/supreme/src/jvmMain/kotlin/at/asitplus/signum/supreme/os/JKSProvider.kt index c1a4a729..3265a2b2 100644 --- a/supreme/src/jvmMain/kotlin/at/asitplus/signum/supreme/os/JKSProvider.kt +++ b/supreme/src/jvmMain/kotlin/at/asitplus/signum/supreme/os/JKSProvider.kt @@ -214,7 +214,7 @@ class JKSProvider internal constructor (private val access: JKSAccessor) } } -internal class DummyJKSAccessor(override val ks: KeyStore): JKSAccessor, WriteAccessorBase() { +internal class DummyJKSAccessor(override val ks: KeyStore): JKSAccessor, ReadAccessorBase, WriteAccessorBase() { override fun forReading() = this override fun forWriting() = this override fun close() {} diff --git a/supreme/src/jvmTest/kotlin/at/asitplus/signum/supreme/os/JKSProviderTest.kt b/supreme/src/jvmTest/kotlin/at/asitplus/signum/supreme/os/JKSProviderTest.kt index 858b1f2d..ec62f06b 100644 --- a/supreme/src/jvmTest/kotlin/at/asitplus/signum/supreme/os/JKSProviderTest.kt +++ b/supreme/src/jvmTest/kotlin/at/asitplus/signum/supreme/os/JKSProviderTest.kt @@ -14,7 +14,7 @@ import kotlin.random.Random class JKSProviderTest : FreeSpec({ "Ephemeral" { - val ks = JKSProvider.Ephemeral() + val ks = JKSProvider.Ephemeral().getOrThrow() val alias = "Elfenbeinschloss" ks.getSignerForKey(alias) shouldNot succeed val signer = ks.createSigningKey(alias).getOrThrow() @@ -31,7 +31,7 @@ class JKSProviderTest : FreeSpec({ val alias = "Elfenbeinturm" val ks1 = JKSProvider { - keystoreFile { + file { file = tempfile password = "Schwertfischfilet".toCharArray() } @@ -46,7 +46,7 @@ class JKSProviderTest : FreeSpec({ } JKSProvider { - keystoreFile { + file { file = tempfile password = "Bartfischfilet".toCharArray() } @@ -56,7 +56,7 @@ class JKSProviderTest : FreeSpec({ } JKSProvider { - keystoreFile { + file { file = tempfile password = "Schwertfischfilet".toCharArray() } From ee0e0d597c7a5675a577d30e1f148e1bf46b27f0 Mon Sep 17 00:00:00 2001 From: Jakob Heher Date: Thu, 29 Aug 2024 14:43:12 +0200 Subject: [PATCH 34/73] revert partial change that never happened --- .../at/asitplus/signum/supreme/os/AndroidKeyStoreProvider.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/supreme/src/androidMain/kotlin/at/asitplus/signum/supreme/os/AndroidKeyStoreProvider.kt b/supreme/src/androidMain/kotlin/at/asitplus/signum/supreme/os/AndroidKeyStoreProvider.kt index e1ef21b5..bde31933 100644 --- a/supreme/src/androidMain/kotlin/at/asitplus/signum/supreme/os/AndroidKeyStoreProvider.kt +++ b/supreme/src/androidMain/kotlin/at/asitplus/signum/supreme/os/AndroidKeyStoreProvider.kt @@ -209,7 +209,7 @@ class AndroidKeyStoreProvider: is CryptoPublicKey.Rsa -> { val rsaConfig = config.rsa.v val digest = resolveOption("digest", keyInfo.digests, Digest.entries.asSequence(), rsaConfig.digestSpecified, rsaConfig.digest, Digest::jcaName) - val padding = resolveOption("padding", keyInfo.signaturePaddings, RSAPadding.entries.asSequence(), rsaConfig::padding) { + val padding = resolveOption("padding", keyInfo.signaturePaddings, RSAPadding.entries.asSequence(), rsaConfig.paddingSpecified, rsaConfig.padding) { when (it) { RSAPadding.PKCS1 -> KeyProperties.SIGNATURE_PADDING_RSA_PKCS1 RSAPadding.PSS -> KeyProperties.SIGNATURE_PADDING_RSA_PSS From 3734d3a3587a648eff498dd6a4195a6612a12aa9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bernd=20Pr=C3=BCnster?= Date: Thu, 29 Aug 2024 15:33:56 +0200 Subject: [PATCH 35/73] cleanup build files --- demoapp/composeApp/build.gradle.kts | 2 +- indispensable/build.gradle.kts | 6 ------ supreme/build.gradle.kts | 4 +--- 3 files changed, 2 insertions(+), 10 deletions(-) diff --git a/demoapp/composeApp/build.gradle.kts b/demoapp/composeApp/build.gradle.kts index b8f9c4c1..c306205c 100644 --- a/demoapp/composeApp/build.gradle.kts +++ b/demoapp/composeApp/build.gradle.kts @@ -36,7 +36,7 @@ kotlin { } } commonMain.dependencies { - implementation("at.asitplus.signum:supreme:0.0.2-SNAPSHOT") { + implementation("at.asitplus.signum:supreme:0.2.0-SNAPSHOT") { isChanging = true } implementation(compose.runtime) diff --git a/indispensable/build.gradle.kts b/indispensable/build.gradle.kts index a8cda390..32879141 100644 --- a/indispensable/build.gradle.kts +++ b/indispensable/build.gradle.kts @@ -192,12 +192,6 @@ kotlin { } } - jvmTest { - dependencies { - implementation("io.kotest.extensions:kotest-assertions-compiler:1.0.0") - } - } - } } diff --git a/supreme/build.gradle.kts b/supreme/build.gradle.kts index 69fc196e..09287d8b 100644 --- a/supreme/build.gradle.kts +++ b/supreme/build.gradle.kts @@ -54,9 +54,7 @@ kotlin { implementation(napier()) api(project(":indispensable")) } - sourceSets.jvmTest.dependencies { - implementation("io.kotest.extensions:kotest-assertions-compiler:1.0.0") - } + sourceSets.androidMain.dependencies { implementation("androidx.biometric:biometric:1.2.0-alpha05") } From 3bcd70efad0d28127bf11a0668d87a9448f4d407 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bernd=20Pr=C3=BCnster?= Date: Thu, 29 Aug 2024 15:46:28 +0200 Subject: [PATCH 36/73] update AGP --- build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle.kts b/build.gradle.kts index 6a60096e..e7270272 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -2,7 +2,7 @@ import org.jetbrains.dokka.gradle.DokkaMultiModuleTask plugins { id("at.asitplus.gradle.conventions") version "2.0.20+20240829" - id("com.android.library") version "8.2.0" apply (false) + id("com.android.library") version "8.2.2" apply (false) } group = "at.asitplus.signum" From 8468cd36d55bfaeaf2a4c6c73ce00538702b00e9 Mon Sep 17 00:00:00 2001 From: Jakob Heher Date: Thu, 29 Aug 2024 16:30:40 +0200 Subject: [PATCH 37/73] the documentation commit --- .../at/asitplus/cryptotest/App.android.kt | 21 ---------- .../kotlin/at/asitplus/cryptotest/App.kt | 17 +++------ .../kotlin/at/asitplus/cryptotest/App.ios.kt | 4 -- .../kotlin/at/asitplus/cryptotest/App.jvm.kt | 3 -- .../asitplus/signum/indispensable/Digest.kt | 1 + .../asitplus/signum/indispensable/ECCurve.kt | 4 +- .../os/AndroidKeyStoreProviderTests.kt | 2 +- .../supreme/hazmat/InternalsAccessors.kt | 8 ++++ .../supreme/os/AndroidKeyStoreProvider.kt | 38 +++++++++++-------- .../at/asitplus/signum/supreme/sign/Signer.kt | 27 ++++++++++--- .../supreme/hazmat/InternalsAccessors.kt | 8 ++++ .../signum/supreme/os/IosKeychainProvider.kt | 13 +++---- .../supreme/hazmat/InternalsAccessors.kt | 3 ++ 13 files changed, 80 insertions(+), 69 deletions(-) diff --git a/demoapp/composeApp/src/androidMain/kotlin/at/asitplus/cryptotest/App.android.kt b/demoapp/composeApp/src/androidMain/kotlin/at/asitplus/cryptotest/App.android.kt index b24efc27..5ce1acd4 100644 --- a/demoapp/composeApp/src/androidMain/kotlin/at/asitplus/cryptotest/App.android.kt +++ b/demoapp/composeApp/src/androidMain/kotlin/at/asitplus/cryptotest/App.android.kt @@ -3,34 +3,13 @@ package at.asitplus.cryptotest import android.app.Application import android.os.Bundle import androidx.activity.compose.setContent -import androidx.compose.ui.platform.LocalContext import androidx.fragment.app.FragmentActivity -import at.asitplus.signum.supreme.os.AndroidKeyStoreProvider -import at.asitplus.signum.supreme.os.SigningProvider - - -class AndroidApp : Application() { - companion object { - lateinit var INSTANCE: AndroidApp - } - - override fun onCreate() { - super.onCreate() - INSTANCE = this - } -} - -private lateinit var fragmentActivity: FragmentActivity class AppActivity : FragmentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContent { App() - fragmentActivity = LocalContext.current as FragmentActivity } } } - -internal actual fun getSystemKeyStore(): SigningProvider = - AndroidKeyStoreProvider() diff --git a/demoapp/composeApp/src/commonMain/kotlin/at/asitplus/cryptotest/App.kt b/demoapp/composeApp/src/commonMain/kotlin/at/asitplus/cryptotest/App.kt index 70b22f2c..272a7272 100644 --- a/demoapp/composeApp/src/commonMain/kotlin/at/asitplus/cryptotest/App.kt +++ b/demoapp/composeApp/src/commonMain/kotlin/at/asitplus/cryptotest/App.kt @@ -336,7 +336,7 @@ internal fun App() { CoroutineScope(context).launch { canGenerate = false genTextOverride = "Creating…" - currentSigner = getSystemKeyStore().createSigningKey(ALIAS) { + currentSigner = SigningProvider{}.transform { it.createSigningKey(ALIAS) { signer(SIGNER_CONFIG) when (val alg = keyAlgorithm.algorithm) { @@ -381,7 +381,7 @@ internal fun App() { } } } - } + }} verifyState = null Napier.w { "created signing key! $currentSigner" } @@ -401,11 +401,11 @@ internal fun App() { CoroutineScope(context).launch { canGenerate = false genTextOverride = "Loading…" - getSystemKeyStore().getSignerForKey(ALIAS, SIGNER_CONFIG).let { + SigningProvider{}.transform { it.getSignerForKey(ALIAS, SIGNER_CONFIG).let { Napier.w { "Priv retrieved from native: $it" } currentSigner = it verifyState = null - } + }} //just to check //loadPubKey().let { Napier.w { "PubKey retrieved from native: $it" } } @@ -424,11 +424,8 @@ internal fun App() { CoroutineScope(context).launch { canGenerate = false genTextOverride = "Deleting…" - try { - getSystemKeyStore().deleteSigningKey(ALIAS) - } catch (e: Throwable) { - Napier.e("Failed to delete key", e) - } + SigningProvider{}.transform { it.deleteSigningKey(ALIAS) } + .onFailure { Napier.e("Failed to delete key", it) } currentSigner = null signatureData = null verifyState = null @@ -530,8 +527,6 @@ internal fun App() { } } -internal expect fun getSystemKeyStore(): SigningProvider - /*internal expect suspend fun generateKey( alg: CryptoAlgorithm, attestation: ByteArray?, diff --git a/demoapp/composeApp/src/iosMain/kotlin/at/asitplus/cryptotest/App.ios.kt b/demoapp/composeApp/src/iosMain/kotlin/at/asitplus/cryptotest/App.ios.kt index 635a5e38..0a55cc52 100644 --- a/demoapp/composeApp/src/iosMain/kotlin/at/asitplus/cryptotest/App.ios.kt +++ b/demoapp/composeApp/src/iosMain/kotlin/at/asitplus/cryptotest/App.ios.kt @@ -1,6 +1,2 @@ package at.asitplus.cryptotest -import at.asitplus.signum.supreme.os.IosKeychainProvider -import at.asitplus.signum.supreme.os.SigningProvider - -internal actual fun getSystemKeyStore(): SigningProvider = IosKeychainProvider diff --git a/demoapp/composeApp/src/jvmMain/kotlin/at/asitplus/cryptotest/App.jvm.kt b/demoapp/composeApp/src/jvmMain/kotlin/at/asitplus/cryptotest/App.jvm.kt index 14987077..0a55cc52 100644 --- a/demoapp/composeApp/src/jvmMain/kotlin/at/asitplus/cryptotest/App.jvm.kt +++ b/demoapp/composeApp/src/jvmMain/kotlin/at/asitplus/cryptotest/App.jvm.kt @@ -1,5 +1,2 @@ package at.asitplus.cryptotest -import at.asitplus.signum.supreme.os.SigningProvider - -internal actual fun getSystemKeyStore(): SigningProvider = TODO() diff --git a/indispensable/src/commonMain/kotlin/at/asitplus/signum/indispensable/Digest.kt b/indispensable/src/commonMain/kotlin/at/asitplus/signum/indispensable/Digest.kt index ff4db010..9930957e 100644 --- a/indispensable/src/commonMain/kotlin/at/asitplus/signum/indispensable/Digest.kt +++ b/indispensable/src/commonMain/kotlin/at/asitplus/signum/indispensable/Digest.kt @@ -13,6 +13,7 @@ enum class Digest(val outputLength: BitLength, override val oid: ObjectIdentifie SHA512(512.bit, KnownOIDs.sha_512); } +/** A digest well-suited to operations on this curve, with output length near the curve's coordinate length. */ val ECCurve.nativeDigest get() = when (this) { ECCurve.SECP_256_R_1 -> Digest.SHA256 ECCurve.SECP_384_R_1 -> Digest.SHA384 diff --git a/indispensable/src/commonMain/kotlin/at/asitplus/signum/indispensable/ECCurve.kt b/indispensable/src/commonMain/kotlin/at/asitplus/signum/indispensable/ECCurve.kt index 3405933d..0b154c0d 100644 --- a/indispensable/src/commonMain/kotlin/at/asitplus/signum/indispensable/ECCurve.kt +++ b/indispensable/src/commonMain/kotlin/at/asitplus/signum/indispensable/ECCurve.kt @@ -29,9 +29,11 @@ enum class ECCurve( val jwkName: String, override val oid: ObjectIdentifier, ) : Identifiable { - + /** NIST curve [secp256r1](https://neuromancer.sk/std/nist/P-256) */ SECP_256_R_1("P-256", KnownOIDs.prime256v1), + /** NIST curve [secp384r1](https://neuromancer.sk/std/nist/P-384) */ SECP_384_R_1("P-384", KnownOIDs.secp384r1), + /** NIST curve [secp521r1](https://neuromancer.sk/std/nist/P-521) */ SECP_521_R_1("P-521", KnownOIDs.secp521r1); val IDENTITY: ECPoint by lazy { diff --git a/supreme/src/androidInstrumentedTest/kotlin/at/asitplus/signum/supreme/os/AndroidKeyStoreProviderTests.kt b/supreme/src/androidInstrumentedTest/kotlin/at/asitplus/signum/supreme/os/AndroidKeyStoreProviderTests.kt index b85f52b8..4ee2e43d 100644 --- a/supreme/src/androidInstrumentedTest/kotlin/at/asitplus/signum/supreme/os/AndroidKeyStoreProviderTests.kt +++ b/supreme/src/androidInstrumentedTest/kotlin/at/asitplus/signum/supreme/os/AndroidKeyStoreProviderTests.kt @@ -14,7 +14,7 @@ class AndroidKeyStoreProviderTests: FreeSpec({ "Create attested keypair" { val alias = Random.azstring(32) val attestChallenge = Random.nextBytes(32) - val hardwareSigner = AndroidKeyStoreProvider().createSigningKey(alias) { + val hardwareSigner = AndroidKeyStoreProvider.createSigningKey(alias) { hardware { attestation { challenge = attestChallenge diff --git a/supreme/src/androidMain/kotlin/at/asitplus/signum/supreme/hazmat/InternalsAccessors.kt b/supreme/src/androidMain/kotlin/at/asitplus/signum/supreme/hazmat/InternalsAccessors.kt index da16eced..911499f4 100644 --- a/supreme/src/androidMain/kotlin/at/asitplus/signum/supreme/hazmat/InternalsAccessors.kt +++ b/supreme/src/androidMain/kotlin/at/asitplus/signum/supreme/hazmat/InternalsAccessors.kt @@ -2,17 +2,25 @@ package at.asitplus.signum.supreme.hazmat import at.asitplus.signum.supreme.HazardousMaterials import at.asitplus.signum.supreme.os.LockedAndroidKeystoreSigner +import at.asitplus.signum.supreme.os.UnlockedAndroidKeystoreSigner import at.asitplus.signum.supreme.sign.AndroidEphemeralSigner import at.asitplus.signum.supreme.sign.EphemeralKey import at.asitplus.signum.supreme.sign.EphemeralKeyBase import at.asitplus.signum.supreme.sign.Signer import java.security.PrivateKey +/** The underlying JCA [PrivateKey] object. */ @HazardousMaterials val EphemeralKey.jcaPrivateKey get() = (this as? EphemeralKeyBase<*>)?.privateKey as? PrivateKey + +/** The underlying JCA [PrivateKey] object. Not available for unlocked KeyStore signers; see [jcaSignatureInstance]. */ @HazardousMaterials val Signer.jcaPrivateKey get() = when (this) { is AndroidEphemeralSigner -> this.privateKey is LockedAndroidKeystoreSigner -> this.jcaPrivateKey else -> null } + +/** The underlying, unlocked JCA [Signature] object. */ +@HazardousMaterials +val UnlockedAndroidKeystoreSigner.jcaSignatureInstance get() = this.jcaSig diff --git a/supreme/src/androidMain/kotlin/at/asitplus/signum/supreme/os/AndroidKeyStoreProvider.kt b/supreme/src/androidMain/kotlin/at/asitplus/signum/supreme/os/AndroidKeyStoreProvider.kt index bde31933..6af110ec 100644 --- a/supreme/src/androidMain/kotlin/at/asitplus/signum/supreme/os/AndroidKeyStoreProvider.kt +++ b/supreme/src/androidMain/kotlin/at/asitplus/signum/supreme/os/AndroidKeyStoreProvider.kt @@ -78,11 +78,12 @@ class AndroidSignerConfiguration: PlatformSignerConfigurationBase() { * You will not need to set this in most cases; the default is the current activity.*/ lateinit var fragment: Fragment - internal val context: FragmentContext? get() = when { - this::activity.isInitialized -> FragmentContext.OfActivity(activity) + internal val explicitContext: FragmentContext? get() = when { this::fragment.isInitialized -> FragmentContext.OfFragment(fragment) + this::activity.isInitialized -> FragmentContext.OfActivity(activity) else -> null } + /** @see [BiometricPrompt.PromptInfo.Builder.setSubtitle] */ var subtitle: String? = null /** @see [BiometricPrompt.PromptInfo.Builder.setDescription] */ @@ -124,11 +125,14 @@ internal inline fun resolveOption(what: String, valid: Array private fun attestationFor(chain: CertificateChain) = if (chain.size > 1) AndroidKeystoreAttestation(chain) else null -class AndroidKeyStoreProvider: +/** + * A provider that manages keys in the [Android Key Store](https://developer.android.com/privacy-and-security/keystore). + */ +object AndroidKeyStoreProvider: SigningProviderI { - private val ks: KeyStore = + private val ks: KeyStore get() = KeyStore.getInstance("AndroidKeyStore").apply { load(null, null) } @SuppressLint("WrongConstant") @@ -193,11 +197,13 @@ class AndroidKeyStoreProvider: alias: String, configure: DSLConfigureFn ): KmmResult = catching { - val jcaPrivateKey = ks.getKey(alias, null) as? PrivateKey - ?: throw NoSuchElementException("No key for alias $alias exists") val config = DSL.resolve(::AndroidSignerConfiguration, configure) - val certificateChain = - ks.getCertificateChain(alias).map { X509Certificate.decodeFromDer(it.encoded) } + val (jcaPrivateKey, certificateChain) = ks.let { + Pair(it.getKey(alias, null) as? PrivateKey + ?: throw NoSuchElementException("No key for alias $alias exists"), + it.getCertificateChain(alias).map { X509Certificate.decodeFromDer(it.encoded) }) + } + val keyInfo = KeyFactory.getInstance(jcaPrivateKey.algorithm) .getKeySpec(jcaPrivateKey, KeyInfo::class.java) val algorithm = when (val publicKey = certificateChain.leaf.publicKey) { @@ -251,13 +257,14 @@ class AndroidKeyStoreProvider: } } -interface AndroidKeystoreSigner : SignerI.Attestable, SignerI.WithAlias { +sealed interface AndroidKeystoreSigner : SignerI.Attestable, SignerI.WithAlias { + /** @see KeyInfo */ val keyInfo: KeyInfo override val attestation: AndroidKeystoreAttestation? } sealed class UnlockedAndroidKeystoreSigner private constructor( - private val jcaSig: JCASignatureObject, + internal val jcaSig: JCASignatureObject, override val alias: String, override val keyInfo: KeyInfo, override val attestation: AndroidKeystoreAttestation? @@ -301,7 +308,9 @@ sealed class LockedAndroidKeystoreSigner private constructor( certificateChain: CertificateChain ) : SignerI.TemporarilyUnlockable(), AndroidKeystoreSigner { - private val context = config.unlockPrompt.v.context + final override val mayRequireUserUnlock: Boolean get() = this.needsAuthentication + + private val explicitContext = config.unlockPrompt.v.explicitContext final override val attestation = attestationFor(certificateChain) private sealed interface AuthResult { @@ -311,7 +320,7 @@ sealed class LockedAndroidKeystoreSigner private constructor( private suspend fun attemptBiometry(config: AndroidSignerConfiguration.AuthnPrompt, forSpecificKey: CryptoObject?) { val channel = Channel(capacity = Channel.RENDEZVOUS) - val effectiveContext = context ?: + val effectiveContext = explicitContext ?: (AppLifecycleMonitor.currentActivity as? FragmentActivity)?.let(FragmentContext::OfActivity) ?: throw UnsupportedOperationException("The requested key with alias $alias requires unlock, but the current activity is not a FragmentActivity or could not be determined. " + "Pass either { fragment = } or { activity = } inside authPrompt {}.") @@ -405,9 +414,6 @@ val AndroidKeystoreSigner.needsAuthentication inline get() = val AndroidKeystoreSigner.needsAuthenticationForEveryUse inline get() = keyInfo.isUserAuthenticationRequired && (keyInfo.userAuthenticationValidityDurationSeconds <= 0) -val AndroidKeystoreSigner.needsAuthenticationWithTimeout inline get() = - keyInfo.isUserAuthenticationRequired && - (keyInfo.userAuthenticationValidityDurationSeconds > 0) /*actual typealias PlatformSigningProviderSigner = AndroidKeystoreSigner actual typealias PlatformSigningProviderSignerConfiguration = AndroidSignerConfiguration @@ -415,4 +421,4 @@ actual typealias PlatformSigningProviderSigningKeyConfiguration = AndroidSigning actual typealias PlatformSigningProvider = AndroidKeyStoreProvider actual typealias PlatformSigningProviderConfiguration = PlatformSigningProviderConfigurationBase*/ internal actual fun getPlatformSigningProvider(configure: DSLConfigureFn): SigningProvider = - AndroidKeyStoreProvider() + AndroidKeyStoreProvider diff --git a/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/sign/Signer.kt b/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/sign/Signer.kt index e1a2c722..16eaa588 100644 --- a/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/sign/Signer.kt +++ b/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/sign/Signer.kt @@ -17,28 +17,45 @@ import at.asitplus.signum.supreme.os.Attestation import at.asitplus.signum.supreme.os.SigningProvider import com.ionspin.kotlin.bignum.integer.BigInteger +/** DSL for configuring a signing key. + * + * Defaults to an elliptic-curve key with a reasonable default configuration. + * + * @see ec + * @see rsa + */ open class SigningKeyConfiguration internal constructor(): DSL.Data() { sealed class AlgorithmSpecific: DSL.Data() + internal val _algSpecific = subclassOf(default = ECConfiguration()) + /** Generates an elliptic-curve key. */ + open val ec = _algSpecific.option(::ECConfiguration) + /** Generates an RSA key. */ + open val rsa = _algSpecific.option(::RSAConfiguration) + open class ECConfiguration internal constructor() : AlgorithmSpecific() { + /** The [ECCurve] on which to generate the key. Defaults to [P-256][ECCurve.SECP_256_R_1] */ var curve: ECCurve = ECCurve.SECP_256_R_1 private var _digests: Set? = null - /** Specify the digests supported by the key. If not specified, supports the curve's native digest only. */ + /** The digests supported by the key. If not specified, supports the curve's native digest only. */ open var digests: Set get() = _digests ?: setOf(curve.nativeDigest) set(v) { _digests = v } } - open val ec = _algSpecific.option(::ECConfiguration) open class RSAConfiguration internal constructor(): AlgorithmSpecific() { companion object { val F0 = BigInteger(3); val F4 = BigInteger(65537) } + /** The digests supported by the key. If not specified, defaults to [SHA256][Digest.SHA256]. */ open var digests: Set = setOf(Digest.SHA256) + /** The paddings supported by the key. If not specified, defaults to [RSA-PSS][RSAPadding.PSS]. */ open var paddings: Set = setOf(RSAPadding.PSS) - var bits: Int = 4096 + /** The bit size of the generated key. If not specified, defaults to 3072 bits. */ + var bits: Int = 3072 + /** The public exponent to use. Defaults to F4. + * This is treated as advisory, and may be ignored by some platforms. */ var publicExponent: BigInteger = F4 } - open val rsa = _algSpecific.option(::RSAConfiguration) } /** @@ -114,7 +131,7 @@ interface Signer { * @see withUnlock */ abstract class TemporarilyUnlockable : Signer { - final override val mayRequireUserUnlock: Boolean get() = true + override val mayRequireUserUnlock: Boolean get() = true /** Obtains a raw unlock handle. This is public because [withUnlock] needs to be inline. * If you use it directly, closing the returned handle is your responsibility! */ diff --git a/supreme/src/iosMain/kotlin/at/asitplus/signum/supreme/hazmat/InternalsAccessors.kt b/supreme/src/iosMain/kotlin/at/asitplus/signum/supreme/hazmat/InternalsAccessors.kt index b7a023ea..9348588a 100644 --- a/supreme/src/iosMain/kotlin/at/asitplus/signum/supreme/hazmat/InternalsAccessors.kt +++ b/supreme/src/iosMain/kotlin/at/asitplus/signum/supreme/hazmat/InternalsAccessors.kt @@ -11,8 +11,16 @@ import at.asitplus.signum.supreme.sign.Signer import kotlinx.cinterop.ExperimentalForeignApi import kotlinx.cinterop.value +/** The underlying SecKeyRef referencing the ephemeral key's private key. + * + * **⚠️ Must not be used beyond the EphemeralKey's lifetime. ⚠️** */ @HazardousMaterials val EphemeralKey.secKeyRef get() = ((this as? EphemeralKeyBase<*>)?.privateKey as? EphemeralKeyRef)?.key?.value + +/** The underlying SecKeyRef referencing the signer's private key. Only available on ephemeral signers or unlocked signers. + * Not available on locked signers. (The ref isn't retrieved from the keychain until unlock time.) + * + * **⚠️ Must not be used beyond the signer's lifetime/scope. ⚠️** */ @HazardousMaterials val Signer.secKeyRef get() = when (this) { is EphemeralSigner -> this.privateKey.key.value diff --git a/supreme/src/iosMain/kotlin/at/asitplus/signum/supreme/os/IosKeychainProvider.kt b/supreme/src/iosMain/kotlin/at/asitplus/signum/supreme/os/IosKeychainProvider.kt index eb84eab3..4ddcd5a5 100644 --- a/supreme/src/iosMain/kotlin/at/asitplus/signum/supreme/os/IosKeychainProvider.kt +++ b/supreme/src/iosMain/kotlin/at/asitplus/signum/supreme/os/IosKeychainProvider.kt @@ -110,9 +110,9 @@ import kotlin.time.Duration import kotlin.time.TimeSource -val keychainThreads = newFixedThreadPoolContext(nThreads = 4, name = "iOS Keychain Operations") +private val keychainThreads = newFixedThreadPoolContext(nThreads = 4, name = "iOS Keychain Operations") -private fun isSecureEnclaveSupportedCurve(c: SigningKeyConfiguration.AlgorithmSpecific): Boolean { +private fun isSecureEnclaveSupportedConfiguration(c: SigningKeyConfiguration.AlgorithmSpecific): Boolean { if (c !is SigningKeyConfiguration.ECConfiguration) return false return when (c.curve) { ECCurve.SECP_256_R_1 -> true @@ -143,8 +143,7 @@ class IosSigningKeyConfiguration internal constructor(): PlatformSigningKeyConfi } } -class IosSignerConfiguration internal constructor(): PlatformSignerConfigurationBase() { -} +class IosSignerConfiguration internal constructor(): PlatformSignerConfigurationBase() sealed class UnlockedIosSigner(private val ownedArena: Arena, internal val privateKeyRef: SecKeyRef) : Signer.UnlockedHandle { abstract val parent: IosSigner<*> @@ -173,7 +172,7 @@ sealed class UnlockedIosSigner(private val ownedArena: Arena, internal val priva val plaintext = data.data.fold(byteArrayOf(), ByteArray::plus).toNSData() val signatureBytes = corecall { SecKeyCreateSignature(privateKeyRef, algorithm, plaintext.giveToCF(), error) - }.let { it.takeFromCF().toByteArray() } + }.takeFromCF().toByteArray() return@catching bytesToSignature(signatureBytes) }} @@ -386,7 +385,7 @@ object IosKeychainProvider: SigningProviderI, IosSignerConfiguratio val useSecureEnclave = when (config.hardware.v.backing) { is REQUIRED -> true - is PREFERRED -> isSecureEnclaveSupportedCurve(config._algSpecific.v) + is PREFERRED -> isSecureEnclaveSupportedConfiguration(config._algSpecific.v) is DISCOURAGED -> false } @@ -452,7 +451,7 @@ object IosKeychainProvider: SigningProviderI, IosSignerConfiguratio val x = CFCryptoOperationFailed(thing = "generate key", osStatus = status) if ((status == -50) && useSecureEnclave && - !isSecureEnclaveSupportedCurve(config._algSpecific.v)) { + !isSecureEnclaveSupportedConfiguration(config._algSpecific.v)) { throw UnsupportedCryptoException("iOS Secure Enclave does not support this configuration.", x) } throw x diff --git a/supreme/src/jvmMain/kotlin/at/asitplus/signum/supreme/hazmat/InternalsAccessors.kt b/supreme/src/jvmMain/kotlin/at/asitplus/signum/supreme/hazmat/InternalsAccessors.kt index 4784383d..ba944973 100644 --- a/supreme/src/jvmMain/kotlin/at/asitplus/signum/supreme/hazmat/InternalsAccessors.kt +++ b/supreme/src/jvmMain/kotlin/at/asitplus/signum/supreme/hazmat/InternalsAccessors.kt @@ -7,7 +7,10 @@ import at.asitplus.signum.supreme.sign.EphemeralSigner import at.asitplus.signum.supreme.sign.Signer import java.security.PrivateKey +/** The underlying JCA [PrivateKey] object. */ @HazardousMaterials val EphemeralKey.jcaPrivateKey get() = (this as? EphemeralKeyBase<*>)?.privateKey as? PrivateKey + +/** The underlying JCA [PrivateKey] object. */ @HazardousMaterials val Signer.jcaPrivateKey get() = (this as? EphemeralSigner)?.privateKey From fd45323f10cadcf2a1fceb25ec78d857815cfba5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bernd=20Pr=C3=BCnster?= Date: Thu, 29 Aug 2024 18:00:11 +0200 Subject: [PATCH 38/73] update AGP --- demoapp/build.gradle.kts | 5 +---- demoapp/gradle/libs.versions.toml | 2 +- demoapp/settings.gradle.kts | 3 ++- 3 files changed, 4 insertions(+), 6 deletions(-) diff --git a/demoapp/build.gradle.kts b/demoapp/build.gradle.kts index 1f24cf56..feaefbdb 100644 --- a/demoapp/build.gradle.kts +++ b/demoapp/build.gradle.kts @@ -9,12 +9,9 @@ plugins { allprojects { repositories { maven(rootProject.projectDir.absolutePath+"/kmp-crypto/repo") + maven("https://s01.oss.sonatype.org/content/repositories/snapshots") maven(uri("https://raw.githubusercontent.com/a-sit-plus/kotlinx.serialization/mvn/repo")) mavenCentral() google() - maven { - url = uri("https://oss.sonatype.org/content/repositories/snapshots") - name = "bigNum" - } } } \ No newline at end of file diff --git a/demoapp/gradle/libs.versions.toml b/demoapp/gradle/libs.versions.toml index 45f2c876..9bebd6b4 100644 --- a/demoapp/gradle/libs.versions.toml +++ b/demoapp/gradle/libs.versions.toml @@ -3,7 +3,7 @@ biometric = "1.2.0-alpha05" kotlin = "1.9.23" compose = "1.6.1" -agp = "8.2.0" +agp = "8.2.2" androidx-appcompat = "1.6.1" androidx-activityCompose = "1.8.1" compose-uitooling = "1.5.4" diff --git a/demoapp/settings.gradle.kts b/demoapp/settings.gradle.kts index 2c36c0bc..ca3c5bb9 100644 --- a/demoapp/settings.gradle.kts +++ b/demoapp/settings.gradle.kts @@ -6,6 +6,8 @@ pluginManagement { google() gradlePluginPortal() mavenCentral() + + maven("https://s01.oss.sonatype.org/content/repositories/snapshots") maven { url = uri("https://raw.githubusercontent.com/a-sit-plus/gradle-conventions-plugin/mvn/repo") @@ -29,6 +31,5 @@ dependencyResolutionManagement { mavenCentral() mavenLocal() maven(uri("https://raw.githubusercontent.com/a-sit-plus/kotlinx.serialization/mvn/repo")) - maven("https://maven.pkg.jetbrains.space/kotlin/p/dokka/dev") } } From c6437b97faa02decfaf3892c5b47bd639af51838 Mon Sep 17 00:00:00 2001 From: Jakob Heher Date: Thu, 29 Aug 2024 18:04:57 +0200 Subject: [PATCH 39/73] WIP --- demoapp/composeApp/build.gradle.kts | 1 - demoapp/gradle/libs.versions.toml | 8 +- .../signum/supreme/os/IosKeychainProvider.kt | 87 +++++++++++++++---- 3 files changed, 76 insertions(+), 20 deletions(-) diff --git a/demoapp/composeApp/build.gradle.kts b/demoapp/composeApp/build.gradle.kts index c306205c..602ad4be 100644 --- a/demoapp/composeApp/build.gradle.kts +++ b/demoapp/composeApp/build.gradle.kts @@ -97,7 +97,6 @@ android { composeOptions { kotlinCompilerExtensionVersion = "1.5.4" } - buildToolsVersion = "30.0.3" packaging { resources.excludes.add("META-INF/versions/9/OSGI-INF/MANIFEST.MF") diff --git a/demoapp/gradle/libs.versions.toml b/demoapp/gradle/libs.versions.toml index 9bebd6b4..a1c81851 100644 --- a/demoapp/gradle/libs.versions.toml +++ b/demoapp/gradle/libs.versions.toml @@ -4,14 +4,14 @@ biometric = "1.2.0-alpha05" kotlin = "1.9.23" compose = "1.6.1" agp = "8.2.2" -androidx-appcompat = "1.6.1" -androidx-activityCompose = "1.8.1" -compose-uitooling = "1.5.4" +androidx-appcompat = "1.7.0" +androidx-activityCompose = "1.9.1" +compose-uitooling = "1.6.8" voyager = "1.0.0" composeImageLoader = "1.7.1" napier = "2.7.1" buildConfig = "4.1.1" -kotlinx-coroutines = "1.8.0" +kotlinx-coroutines = "1.8.1" [libraries] diff --git a/supreme/src/iosMain/kotlin/at/asitplus/signum/supreme/os/IosKeychainProvider.kt b/supreme/src/iosMain/kotlin/at/asitplus/signum/supreme/os/IosKeychainProvider.kt index 4ddcd5a5..6aa3134e 100644 --- a/supreme/src/iosMain/kotlin/at/asitplus/signum/supreme/os/IosKeychainProvider.kt +++ b/supreme/src/iosMain/kotlin/at/asitplus/signum/supreme/os/IosKeychainProvider.kt @@ -9,7 +9,6 @@ import at.asitplus.signum.indispensable.Digest import at.asitplus.signum.indispensable.ECCurve import at.asitplus.signum.indispensable.RSAPadding import at.asitplus.signum.indispensable.SignatureAlgorithm -import at.asitplus.signum.indispensable.nativeDigest import at.asitplus.signum.supreme.CFCryptoOperationFailed import at.asitplus.signum.supreme.CryptoOperationFailed import at.asitplus.signum.supreme.UnsupportedCryptoException @@ -101,6 +100,7 @@ import platform.Security.kSecUseAuthenticationUIAllow import at.asitplus.signum.indispensable.secKeyAlgorithm import at.asitplus.signum.supreme.HazardousMaterials import at.asitplus.signum.supreme.sign.SigningKeyConfiguration +import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlin.contracts.ExperimentalContracts import kotlin.contracts.InvocationKind @@ -201,9 +201,26 @@ sealed class UnlockedIosSigner(private val ownedArena: Arena, internal val priva } @Serializable -data class IosKeyMetadata( - internal val attestation: IosHomebrewAttestation?, - internal val unlockTimeout: Duration +internal sealed interface IosKeyAlgSpecificMetadata { + @Serializable + @SerialName("ecdsa") + data class ECDSA( + val supportedDigests: Set + ) : IosKeyAlgSpecificMetadata + + @Serializable + @SerialName("rsa") + data class RSA( + val supportedDigests: Set, + val supportedPaddings: Set + ): IosKeyAlgSpecificMetadata +} + +@Serializable +internal data class IosKeyMetadata( + val attestation: IosHomebrewAttestation?, + val unlockTimeout: Duration, + val algSpecific: IosKeyAlgSpecificMetadata ) private object LAContextManager { @@ -239,6 +256,26 @@ private object LAContextManager { } } +/** + * Resolve [what] differently based on whether the [v]alue was [spec]ified. + * + * * [spec] = `true`: Check if [valid] contains [nameMap] applied to [v], return [v] if yes, throw otherwise + * * [spec] = `false`: Check if [valid] contains exactly one element, if yes, return it, throw otherwise + */ +private inline fun resolveOption(what: String, valid: Set, spec: Boolean, v: E): E = + when (spec) { + true -> { + if (!valid.contains(v)) + throw IllegalArgumentException("Key does not support $what $v; supported: ${valid.joinToString(", ")}") + v + } + false -> { + if (valid.size != 1) + throw IllegalArgumentException("Key supports multiple ${what}s (${valid.joinToString(", ")}). You need to specify $what in signer configuration.") + valid.first() + } + } + sealed class IosSigner( final override val alias: String, private val metadata: IosKeyMetadata, @@ -287,26 +324,42 @@ sealed class IosSigner( }} protected abstract fun toUnlocked(arena: Arena, key: SecKeyRef): H - class ECDSA(alias: String, metadata: IosKeyMetadata, config: IosSignerConfiguration, + class ECDSA internal constructor(alias: String, metadata: IosKeyMetadata, config: IosSignerConfiguration, override val publicKey: CryptoPublicKey.EC) : IosSigner(alias, metadata, config), Signer.ECDSA { - override val signatureAlgorithm = when (val digest = if (config.ec.v.digestSpecified) config.ec.v.digest else publicKey.curve.nativeDigest){ - Digest.SHA256, Digest.SHA384, Digest.SHA512 -> SignatureAlgorithm.ECDSA(digest, publicKey.curve) - else -> throw UnsupportedCryptoException("ECDSA with $digest is not supported on iOS") + override val signatureAlgorithm: SignatureAlgorithm.ECDSA + init { + check (metadata.algSpecific is IosKeyAlgSpecificMetadata.ECDSA) + { "Metadata type mismatch (ECDSA key, metadata not ECDSA)" } + + signatureAlgorithm = when ( + val digest = resolveOption("digest", metadata.algSpecific.supportedDigests, config.ec.v.digestSpecified, config.ec.v.digest) + ){ + Digest.SHA256, Digest.SHA384, Digest.SHA512 -> SignatureAlgorithm.ECDSA(digest, publicKey.curve) + else -> throw UnsupportedCryptoException("ECDSA with $digest is not supported on iOS") + } } override fun toUnlocked(arena: Arena, key: SecKeyRef) = UnlockedIosSigner.ECDSA(arena, key, this) } - class RSA(alias: String, metadata: IosKeyMetadata, config: IosSignerConfiguration, + class RSA internal constructor (alias: String, metadata: IosKeyMetadata, config: IosSignerConfiguration, override val publicKey: CryptoPublicKey.Rsa) : IosSigner(alias, metadata, config), Signer.RSA { - override val signatureAlgorithm = SignatureAlgorithm.RSA( - digest = if (config.rsa.v.digestSpecified) config.rsa.v.digest else Digest.SHA512, - padding = if (config.rsa.v.paddingSpecified) config.rsa.v.padding else RSAPadding.PSS) + override val signatureAlgorithm: SignatureAlgorithm.RSA + + init { + check (metadata.algSpecific is IosKeyAlgSpecificMetadata.RSA) + { "Metadata type mismatch (RSA key, metadata not RSA) "} + + signatureAlgorithm = SignatureAlgorithm.RSA( + digest = resolveOption("digest", metadata.algSpecific.supportedDigests, config.rsa.v.digestSpecified, config.rsa.v.digest), + padding = resolveOption("padding", metadata.algSpecific.supportedPaddings, config.rsa.v.paddingSpecified, config.rsa.v.padding) + ) + } override fun toUnlocked(arena: Arena, key: SecKeyRef) = UnlockedIosSigner.RSA(arena, key, this) @@ -344,7 +397,7 @@ object IosKeychainProvider: SigningProviderI, IosSignerConfiguratio kSecAttrLabel to Json.encodeToString(metadata) )) if (status != errSecSuccess) { - throw CFCryptoOperationFailed(thing = "store key attestation", osStatus = status) + throw CFCryptoOperationFailed(thing = "store key metadata", osStatus = status) } } private fun getKeyMetadata(alias: String): IosKeyMetadata = memScoped { @@ -360,7 +413,7 @@ object IosKeychainProvider: SigningProviderI, IosSignerConfiguratio return when (status) { errSecSuccess -> it.value!!.get(kSecAttrLabel).let(Json::decodeFromString) else -> { - throw CFCryptoOperationFailed(thing = "retrieve attestation info", osStatus = status) + throw CFCryptoOperationFailed(thing = "retrieve key metadata", osStatus = status) } } } @@ -496,7 +549,11 @@ object IosKeychainProvider: SigningProviderI, IosSignerConfiguratio val metadata = IosKeyMetadata( attestation = attestation, - unlockTimeout = config.hardware.v.protection.v?.timeout ?: Duration.ZERO + unlockTimeout = config.hardware.v.protection.v?.timeout ?: Duration.ZERO, + algSpecific = when (val alg = config._algSpecific.v) { + is SigningKeyConfiguration.ECConfiguration -> IosKeyAlgSpecificMetadata.ECDSA(alg.digests) + is SigningKeyConfiguration.RSAConfiguration -> IosKeyAlgSpecificMetadata.RSA(alg.digests, alg.paddings) + } ).also { storeKeyMetadata(alias, it) } Napier.v { "key $alias metadata stored (has attestation? ${attestation != null})" } From 9fa0569b38a1124e93b6875ca9c28c1b84af6780 Mon Sep 17 00:00:00 2001 From: Jakob Heher Date: Thu, 29 Aug 2024 18:13:46 +0200 Subject: [PATCH 40/73] build fixes --- .../src/commonMain/kotlin/at/asitplus/cryptotest/App.kt | 4 ++-- .../kotlin/at/asitplus/signum/supreme/os/SigningProvider.kt | 4 +++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/demoapp/composeApp/src/commonMain/kotlin/at/asitplus/cryptotest/App.kt b/demoapp/composeApp/src/commonMain/kotlin/at/asitplus/cryptotest/App.kt index 272a7272..169e9be3 100644 --- a/demoapp/composeApp/src/commonMain/kotlin/at/asitplus/cryptotest/App.kt +++ b/demoapp/composeApp/src/commonMain/kotlin/at/asitplus/cryptotest/App.kt @@ -401,11 +401,11 @@ internal fun App() { CoroutineScope(context).launch { canGenerate = false genTextOverride = "Loading…" - SigningProvider{}.transform { it.getSignerForKey(ALIAS, SIGNER_CONFIG).let { + SigningProvider{}.transform { it.getSignerForKey(ALIAS, SIGNER_CONFIG) }.let { Napier.w { "Priv retrieved from native: $it" } currentSigner = it verifyState = null - }} + } //just to check //loadPubKey().let { Napier.w { "PubKey retrieved from native: $it" } } diff --git a/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/os/SigningProvider.kt b/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/os/SigningProvider.kt index a99fe496..1e35af3d 100644 --- a/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/os/SigningProvider.kt +++ b/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/os/SigningProvider.kt @@ -1,6 +1,7 @@ package at.asitplus.signum.supreme.os import at.asitplus.KmmResult +import at.asitplus.catching import at.asitplus.signum.indispensable.Digest import at.asitplus.signum.indispensable.RSAPadding import at.asitplus.signum.supreme.dsl.DISCOURAGED @@ -137,8 +138,9 @@ interface SigningProviderI companion object { - operator fun invoke(configure: DSLConfigureFn = null) = + operator fun invoke(configure: DSLConfigureFn = null) = catching { getPlatformSigningProvider(configure) + } } } /** From 349554263d3952ab9706656ccdd1b0501fc5f7f9 Mon Sep 17 00:00:00 2001 From: Jakob Heher Date: Thu, 29 Aug 2024 19:08:46 +0200 Subject: [PATCH 41/73] always pre-hash on ios --- .../signum/supreme/sign/EphemeralKeysImpl.kt | 6 +---- .../signum/supreme/sign/SignatureInput.kt | 7 ++++++ .../signum/supreme/os/IosKeychainProvider.kt | 24 +++++++++++++------ .../signum/supreme/sign/EphemeralKeysImpl.kt | 6 +---- 4 files changed, 26 insertions(+), 17 deletions(-) diff --git a/supreme/src/androidMain/kotlin/at/asitplus/signum/supreme/sign/EphemeralKeysImpl.kt b/supreme/src/androidMain/kotlin/at/asitplus/signum/supreme/sign/EphemeralKeysImpl.kt index a8e2ccd9..59e18626 100644 --- a/supreme/src/androidMain/kotlin/at/asitplus/signum/supreme/sign/EphemeralKeysImpl.kt +++ b/supreme/src/androidMain/kotlin/at/asitplus/signum/supreme/sign/EphemeralKeysImpl.kt @@ -21,11 +21,7 @@ actual class EphemeralSignerConfiguration internal actual constructor(): Ephemer sealed class AndroidEphemeralSigner (internal val privateKey: PrivateKey) : Signer { override val mayRequireUserUnlock = false override suspend fun sign(data: SignatureInput) = catching { - val inputData = data.convertTo(when (val alg = signatureAlgorithm) { - is SignatureAlgorithm.RSA -> alg.digest - is SignatureAlgorithm.ECDSA -> alg.digest - else -> TODO("hmac unsupported") - }).getOrThrow() + val inputData = data.convertTo(signatureAlgorithm.preHashedSignatureFormat).getOrThrow() signatureAlgorithm.getJCASignatureInstancePreHashed(provider = null).getOrThrow().run { initSign(privateKey) inputData.data.forEach { update(it) } diff --git a/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/sign/SignatureInput.kt b/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/sign/SignatureInput.kt index 2212cc6d..aca82702 100644 --- a/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/sign/SignatureInput.kt +++ b/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/sign/SignatureInput.kt @@ -2,6 +2,7 @@ package at.asitplus.signum.supreme.sign import at.asitplus.catching import at.asitplus.signum.indispensable.Digest +import at.asitplus.signum.indispensable.SignatureAlgorithm import at.asitplus.signum.indispensable.misc.BitLength import at.asitplus.signum.supreme.hash.digest import com.ionspin.kotlin.bignum.integer.BigInteger @@ -59,3 +60,9 @@ class SignatureInput private constructor ( } } } + +val SignatureAlgorithm.preHashedSignatureFormat: SignatureInputFormat get() = when(this) { + is SignatureAlgorithm.RSA -> this.digest + is SignatureAlgorithm.ECDSA -> this.digest + else -> TODO("HMAC unsupported") +} diff --git a/supreme/src/iosMain/kotlin/at/asitplus/signum/supreme/os/IosKeychainProvider.kt b/supreme/src/iosMain/kotlin/at/asitplus/signum/supreme/os/IosKeychainProvider.kt index 6aa3134e..d1c0871f 100644 --- a/supreme/src/iosMain/kotlin/at/asitplus/signum/supreme/os/IosKeychainProvider.kt +++ b/supreme/src/iosMain/kotlin/at/asitplus/signum/supreme/os/IosKeychainProvider.kt @@ -98,8 +98,11 @@ import platform.Security.kSecUseAuthenticationContext import platform.Security.kSecUseAuthenticationUI import platform.Security.kSecUseAuthenticationUIAllow import at.asitplus.signum.indispensable.secKeyAlgorithm +import at.asitplus.signum.indispensable.secKeyAlgorithmPreHashed import at.asitplus.signum.supreme.HazardousMaterials import at.asitplus.signum.supreme.sign.SigningKeyConfiguration +import at.asitplus.signum.supreme.sign.preHashedSignatureFormat +import at.asitplus.signum.supreme.sign.sign import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlin.contracts.ExperimentalContracts @@ -168,11 +171,13 @@ sealed class UnlockedIosSigner(private val ownedArena: Arena, internal val priva withContext(keychainThreads) { catching { if (!usable) throw IllegalStateException("Scoping violation; using key after it has been freed") require(data.format == null) { "Pre-hashed data is unsupported on iOS" } - val algorithm = signatureAlgorithm.secKeyAlgorithm - val plaintext = data.data.fold(byteArrayOf(), ByteArray::plus).toNSData() + val algorithm = signatureAlgorithm.secKeyAlgorithmPreHashed + val plaintext = data.convertTo(signatureAlgorithm.preHashedSignatureFormat).getOrThrow().data.first().toNSData() + Napier.v { "before sign" } val signatureBytes = corecall { SecKeyCreateSignature(privateKeyRef, algorithm, plaintext.giveToCF(), error) }.takeFromCF().toByteArray() + Napier.v { "after sign" } return@catching bytesToSignature(signatureBytes) }} @@ -229,19 +234,23 @@ private object LAContextManager { val authenticationTime: TimeSource.Monotonic.ValueTimeMark) private var previousAuthentication: PreviousAuthentication? = null @OptIn(ExperimentalContracts::class) - inline fun withLAContext(keyMetadata: IosKeyMetadata, + inline fun withLAContext(keyMetadata: IosKeyMetadata, signerConfig: IosSignerConfiguration, body: (LAContext)->T): T { contract { callsInPlace(body, InvocationKind.AT_MOST_ONCE) } val reusable = previousAuthentication?.takeIf { it.authenticationTime.elapsedNow() <= keyMetadata.unlockTimeout } - if (reusable != null) + if (reusable != null) { + Napier.v { "Re-using previous authentication context" } return body(reusable.authenticatedContext.apply { /** Configure it to suit this signer just in case something has gone wrong */ localizedReason = signerConfig.unlockPrompt.v.message localizedCancelTitle = signerConfig.unlockPrompt.v.cancelText }) + } + + Napier.v { "Requesting user authentication..." } val newContext = LAContext().apply { localizedReason = signerConfig.unlockPrompt.v.message @@ -249,10 +258,11 @@ private object LAContextManager { touchIDAuthenticationAllowableReuseDuration = min(10L,keyMetadata.unlockTimeout.inWholeSeconds).toDouble() } - return body(newContext).also { + return runCatching { body(newContext) }.also { + Napier.v { "Authentication succeeded? ${it.isSuccess}" } // if this did not throw (e.g., succeeded)... previousAuthentication = PreviousAuthentication(newContext, TimeSource.Monotonic.markNow()) - } + }.getOrThrow() } } @@ -306,7 +316,7 @@ sealed class IosSigner( ) val status = SecItemCopyMatching(query, privateKey.ptr.reinterpret()) if ((status == errSecSuccess) && (privateKey.value != null)) { - return@withLAContext /* continue below try/catch */ + return@memScoped /* continue below try/catch */ } else { throw CFCryptoOperationFailed( thing = "retrieve private key", diff --git a/supreme/src/iosMain/kotlin/at/asitplus/signum/supreme/sign/EphemeralKeysImpl.kt b/supreme/src/iosMain/kotlin/at/asitplus/signum/supreme/sign/EphemeralKeysImpl.kt index 8e36c42f..303274fe 100644 --- a/supreme/src/iosMain/kotlin/at/asitplus/signum/supreme/sign/EphemeralKeysImpl.kt +++ b/supreme/src/iosMain/kotlin/at/asitplus/signum/supreme/sign/EphemeralKeysImpl.kt @@ -42,11 +42,7 @@ actual class EphemeralSignerConfiguration internal actual constructor(): Ephemer sealed class EphemeralSigner(internal val privateKey: EphemeralKeyRef): Signer { final override val mayRequireUserUnlock: Boolean get() = false final override suspend fun sign(data: SignatureInput) = catching { - val inputData = data.convertTo(when (val alg = signatureAlgorithm) { - is SignatureAlgorithm.RSA -> alg.digest - is SignatureAlgorithm.ECDSA -> alg.digest - else -> TODO("hmac unsupported") - }).getOrThrow() + val inputData = data.convertTo(signatureAlgorithm.preHashedSignatureFormat).getOrThrow() val algorithm = signatureAlgorithm.secKeyAlgorithmPreHashed val input = inputData.data.single().toNSData() val signatureBytes = corecall { From 5b612d1a74dc734e25bf0fec74c133f61049a485 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bernd=20Pr=C3=BCnster?= Date: Thu, 29 Aug 2024 20:18:32 +0200 Subject: [PATCH 42/73] fix demoapp build --- demoapp/composeApp/build.gradle.kts | 1 + demoapp/gradle/libs.versions.toml | 7 ++++--- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/demoapp/composeApp/build.gradle.kts b/demoapp/composeApp/build.gradle.kts index 602ad4be..c84059f5 100644 --- a/demoapp/composeApp/build.gradle.kts +++ b/demoapp/composeApp/build.gradle.kts @@ -3,6 +3,7 @@ import com.android.build.api.dsl.Packaging plugins { alias(libs.plugins.multiplatform) alias(libs.plugins.compose) + alias(libs.plugins.compose.runtime) alias(libs.plugins.android.application) alias(libs.plugins.buildConfig) } diff --git a/demoapp/gradle/libs.versions.toml b/demoapp/gradle/libs.versions.toml index a1c81851..e315ecf6 100644 --- a/demoapp/gradle/libs.versions.toml +++ b/demoapp/gradle/libs.versions.toml @@ -1,8 +1,8 @@ [versions] biometric = "1.2.0-alpha05" -kotlin = "1.9.23" -compose = "1.6.1" +kotlin = "2.0.20" +compose = "1.6.11" agp = "8.2.2" androidx-appcompat = "1.7.0" androidx-activityCompose = "1.9.1" @@ -29,6 +29,7 @@ kotlinx-coroutines-swing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines- [plugins] multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } -compose = { id = "org.jetbrains.compose", version.ref = "compose" } +compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } +compose-runtime = { id = "org.jetbrains.compose", version.ref = "compose" } android-application = { id = "com.android.application", version.ref = "agp" } buildConfig = { id = "com.github.gmazzo.buildconfig", version.ref = "buildConfig" } From 7bef269eec9742215321c22b45bd609e4c590da4 Mon Sep 17 00:00:00 2001 From: Jakob Heher Date: Fri, 30 Aug 2024 15:58:14 +0200 Subject: [PATCH 43/73] the second coming of Signer, iOS edition --- .../kotlin/at/asitplus/cryptotest/App.kt | 10 +- .../signum/supreme/dsl/ConfigurationDSL.kt | 10 + .../signum/supreme/os/SigningProvider.kt | 93 +++-- .../at/asitplus/signum/supreme/sign/Signer.kt | 71 +--- .../asitplus/signum/supreme/InteropUtils.kt | 18 + .../supreme/hazmat/InternalsAccessors.kt | 24 +- .../signum/supreme/os/IosKeychainProvider.kt | 366 +++++++++--------- .../signum/supreme/sign/EphemeralKeysImpl.kt | 19 +- .../asitplus/signum/supreme/os/JKSProvider.kt | 4 +- 9 files changed, 311 insertions(+), 304 deletions(-) diff --git a/demoapp/composeApp/src/commonMain/kotlin/at/asitplus/cryptotest/App.kt b/demoapp/composeApp/src/commonMain/kotlin/at/asitplus/cryptotest/App.kt index 169e9be3..0c10dd70 100644 --- a/demoapp/composeApp/src/commonMain/kotlin/at/asitplus/cryptotest/App.kt +++ b/demoapp/composeApp/src/commonMain/kotlin/at/asitplus/cryptotest/App.kt @@ -50,14 +50,14 @@ import at.asitplus.signum.indispensable.X509SignatureAlgorithm import at.asitplus.signum.indispensable.nativeDigest import at.asitplus.signum.indispensable.pki.X509Certificate import at.asitplus.signum.supreme.dsl.PREFERRED -import at.asitplus.signum.supreme.os.SigningProvider import at.asitplus.signum.supreme.sign.Signer import at.asitplus.signum.supreme.sign.makeVerifier -import at.asitplus.signum.supreme.sign.sign import at.asitplus.signum.supreme.sign.verify import at.asitplus.cryptotest.theme.AppTheme import at.asitplus.cryptotest.theme.LocalThemeIsDark import at.asitplus.signum.supreme.os.PlatformSignerConfigurationBase +import at.asitplus.signum.supreme.os.PlatformSigningProvider +import at.asitplus.signum.supreme.os.SigningProvider import at.asitplus.signum.supreme.os.jsonEncoded import io.github.aakira.napier.DebugAntilog import io.github.aakira.napier.Napier @@ -336,7 +336,7 @@ internal fun App() { CoroutineScope(context).launch { canGenerate = false genTextOverride = "Creating…" - currentSigner = SigningProvider{}.transform { it.createSigningKey(ALIAS) { + currentSigner = PlatformSigningProvider{}.transform { it.createSigningKey(ALIAS) { signer(SIGNER_CONFIG) when (val alg = keyAlgorithm.algorithm) { @@ -401,7 +401,7 @@ internal fun App() { CoroutineScope(context).launch { canGenerate = false genTextOverride = "Loading…" - SigningProvider{}.transform { it.getSignerForKey(ALIAS, SIGNER_CONFIG) }.let { + PlatformSigningProvider{}.transform { it.getSignerForKey(ALIAS, SIGNER_CONFIG) }.let { Napier.w { "Priv retrieved from native: $it" } currentSigner = it verifyState = null @@ -424,7 +424,7 @@ internal fun App() { CoroutineScope(context).launch { canGenerate = false genTextOverride = "Deleting…" - SigningProvider{}.transform { it.deleteSigningKey(ALIAS) } + PlatformSigningProvider{}.transform { it.deleteSigningKey(ALIAS) } .onFailure { Napier.e("Failed to delete key", it) } currentSigner = null signatureData = null diff --git a/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/dsl/ConfigurationDSL.kt b/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/dsl/ConfigurationDSL.kt index f536ebca..d068158b 100644 --- a/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/dsl/ConfigurationDSL.kt +++ b/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/dsl/ConfigurationDSL.kt @@ -12,6 +12,16 @@ object DSL { fun resolve(factory: ()->T, config: DSLConfigureFn): T = (if (config == null) factory() else factory().apply(config)).also(DSL.Data::validate) + /** A collection of equivalent DSL configuration structures which shadow each other. + * @see getProperty */ + class ConfigStack(private vararg val stackedData: S) { + /** Retrieve a property from a stack of (partially-)configured DSL data. + * Each element of the stack should have an indication of whether the property is set, and a value of the property (which is only accessed if the property is set). + * This is commonly implemented using `lateinit var`s (with `internal val .. get() = this::prop.isInitialized` as the property checker).*/ + fun getProperty(getter: (S)->T, checker: (S)->Boolean, default: T): T = + when (val it = stackedData.firstOrNull(checker)) { null -> default; else -> getter(it) } + } + sealed interface Holder { val v: T } diff --git a/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/os/SigningProvider.kt b/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/os/SigningProvider.kt index 1e35af3d..88ce6bf3 100644 --- a/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/os/SigningProvider.kt +++ b/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/os/SigningProvider.kt @@ -2,6 +2,7 @@ package at.asitplus.signum.supreme.os import at.asitplus.KmmResult import at.asitplus.catching +import at.asitplus.signum.indispensable.CryptoSignature import at.asitplus.signum.indispensable.Digest import at.asitplus.signum.indispensable.RSAPadding import at.asitplus.signum.supreme.dsl.DISCOURAGED @@ -9,12 +10,17 @@ import at.asitplus.signum.supreme.dsl.DSL import at.asitplus.signum.supreme.dsl.DSLConfigureFn import at.asitplus.signum.supreme.dsl.FeaturePreference import at.asitplus.signum.supreme.dsl.REQUIRED +import at.asitplus.signum.supreme.sign.SignatureInput import at.asitplus.signum.supreme.sign.Signer import at.asitplus.signum.supreme.sign.SigningKeyConfiguration import kotlin.time.Duration import kotlin.time.Duration.Companion.seconds -open class PlatformSigningKeyConfigurationBase internal constructor(): SigningKeyConfiguration() { +open class SigningProviderSigningKeyConfigurationBase internal constructor() : SigningKeyConfiguration() { + /** Configure the signer that will be returned from [createSigningKey][SigningProviderI.createSigningKey] */ + open val signer = integratedReceiver() +} +open class PlatformSigningKeyConfigurationBase internal constructor(): SigningProviderSigningKeyConfigurationBase() { open class AttestationConfiguration internal constructor(): DSL.Data() { /** The server-provided attestation challenge */ lateinit var challenge: ByteArray @@ -57,13 +63,9 @@ open class PlatformSigningKeyConfigurationBase() } open class ECSignerConfiguration internal constructor(): DSL.Data() { - internal var digestSpecified = false /** * Explicitly specify the digest to sign over. * Omit to default to the only supported digest. @@ -74,9 +76,9 @@ open class ECSignerConfiguration internal constructor(): DSL.Data() { * @see SigningKeyConfiguration.ECConfiguration.digests */ var digest: Digest? = null; set(v) { digestSpecified = true; field = v } + internal var digestSpecified = false } open class RSASignerConfiguration internal constructor(): DSL.Data() { - internal var digestSpecified = false /** * Explicitly specify the digest to sign over. * Omit to default to a reasonable default choice. @@ -86,9 +88,9 @@ open class RSASignerConfiguration internal constructor(): DSL.Data() { * * @see SigningKeyConfiguration.RSAConfiguration.digests */ - var digest: Digest = Digest.SHA256; set(v) { digestSpecified = true; field = v } + lateinit var digest: Digest + internal val digestSpecified get() = this::digest.isInitialized - internal var paddingSpecified = false /** * Explicitly specify the padding to use. * Omit to default to the only supported padding. @@ -98,7 +100,8 @@ open class RSASignerConfiguration internal constructor(): DSL.Data() { * * @see SigningKeyConfiguration.RSAConfiguration.paddings */ - var padding: RSAPadding = RSAPadding.PKCS1; set(v) { paddingSpecified = true; field = v } + lateinit var padding: RSAPadding + internal val paddingSpecified get() = this::padding.isInitialized } @@ -109,15 +112,42 @@ open class SignerConfiguration internal constructor(): DSL.Data() { open val rsa = childOrDefault(::RSASignerConfiguration) } -open class PlatformSignerConfigurationBase internal constructor(): SignerConfiguration() { - open class AuthnPrompt: DSL.Data() { - /** The prompt message to show to the user when asking for unlock */ - var message: String = "Please authorize cryptographic signature" - /** The message to show on the cancellation button */ - var cancelText: String = "Abort" +open class UnlockPromptConfiguration: DSL.Data() { + /** The prompt message to show to the user when asking for unlock */ + lateinit var message: String + internal val messageSpecified get() = this::message.isInitialized + /** The message to show on the cancellation button */ + lateinit var cancelText: String + internal val cancelTextSpecified get() = this::cancelText.isInitialized + + companion object { + const val defaultMessage = "Please authorize cryptographic signature" + const val defaultCancelText = "Cancel" } +} +open class PlatformSignerConfigurationBase internal constructor(): SignerConfiguration() { /** Configure the authorization prompt that will be shown to the user. */ - open val unlockPrompt = childOrDefault(::AuthnPrompt) + open val unlockPrompt = childOrDefault(::UnlockPromptConfiguration) +} + +open class PlatformSigningProviderSignerSigningConfigurationBase internal constructor(): DSL.Data() { + open val unlockPrompt = childOrDefault(::UnlockPromptConfiguration) +} + +interface PlatformSigningProviderSigner + : Signer.WithAlias { + + suspend fun trySetupUninterruptedSigning(configure: DSLConfigureFn = null) : KmmResult = KmmResult.success(Unit) + override suspend fun trySetupUninterruptedSigning() = trySetupUninterruptedSigning(null) + + suspend fun sign(data: SignatureInput, configure: DSLConfigureFn = null) : KmmResult + suspend fun sign(data: ByteArray, configure: DSLConfigureFn = null) = + sign(SignatureInput(data), configure) + suspend fun sign(data: Sequence, configure: DSLConfigureFn = null) = + sign(SignatureInput(data), configure) + override suspend fun sign(data: SignatureInput) = sign(data, null) + override suspend fun sign(data: ByteArray) = sign(SignatureInput(data), null) + override suspend fun sign(data: Sequence) = sign(SignatureInput(data), null) } open class PlatformSigningProviderConfigurationBase internal constructor(): DSL.Data() @@ -128,28 +158,42 @@ expect class PlatformSigningProviderSignerConfiguration: PlatformSignerConfigura expect class PlatformSigningProviderSigningKeyConfiguration: PlatformSigningKeyConfigurationBase expect class PlatformSigningProvider : SigningProviderI internal expect fun makePlatformSigningProvider(config: PlatformSigningProviderConfiguration): KmmResult*/ -internal expect fun getPlatformSigningProvider(configure: DSLConfigureFn): SigningProvider +internal expect fun getPlatformSigningProvider(configure: DSLConfigureFn): PlatformSigningProvider -interface SigningProviderI, out SignerConfigT: PlatformSignerConfigurationBase, + out KeyConfigT: PlatformSigningKeyConfigurationBase<*>> + : SigningProviderI { + + companion object { + operator fun invoke(configure: DSLConfigureFn = null) = + catching { getPlatformSigningProvider(configure) } + } +} + +/** KT-71089 workaround + * @see SigningProvider */ +interface SigningProviderI> { suspend fun createSigningKey(alias: String, configure: DSLConfigureFn = null): KmmResult suspend fun getSignerForKey(alias: String, configure: DSLConfigureFn = null): KmmResult suspend fun deleteSigningKey(alias: String): KmmResult companion object { - operator fun invoke(configure: DSLConfigureFn = null) = catching { - getPlatformSigningProvider(configure) - } + fun Platform(configure: DSLConfigureFn = null) = + PlatformSigningProvider(configure) } } /** * An interface to some underlying persistent storage for private key material. Stored keys are identified by a unique string "alias" for each key. * You can [create signing keys][createSigningKey], [get signers for existing keys][getSignerForKey], or [delete signing keys][deleteSigningKey]. * - * To obtain a platform signing provider in platform-agnostic code, use `SigningProvider()`. + * To obtain a platform signing provider in platform-agnostic code, use `PlatformSigningProvider()`. * In platform-specific code, it is currently recommended to directly interface with your platform signing provider to get platform-specific functionality. - * (Platform-specific return types from `SigningProvider()` are currently blocked by KT-71036.) + * (Platform-specific return types from `PlatformSigningProvider()` are currently blocked by KT-71036.) * * Created keys can be configured using the [SigningKeyConfiguration] DSL. * Signers can be configured using the [SignerConfiguration] DSL. @@ -159,4 +203,7 @@ interface SigningProviderI + +/** @see PlatformSigningProvider */ typealias SigningProvider = SigningProviderI<*,*,*> diff --git a/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/sign/Signer.kt b/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/sign/Signer.kt index 16eaa588..fff7cef4 100644 --- a/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/sign/Signer.kt +++ b/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/sign/Signer.kt @@ -16,6 +16,7 @@ import at.asitplus.signum.supreme.dsl.DSLConfigureFn import at.asitplus.signum.supreme.os.Attestation import at.asitplus.signum.supreme.os.SigningProvider import com.ionspin.kotlin.bignum.integer.BigInteger +import io.matthewnelson.encoding.base16.Base16 /** DSL for configuring a signing key. * @@ -85,19 +86,19 @@ interface Signer { val signatureAlgorithm: SignatureAlgorithm val publicKey: CryptoPublicKey /** Whether the signer may ask for user interaction when [sign] is called */ - val mayRequireUserUnlock: Boolean + val mayRequireUserUnlock: Boolean get() = true /** Any [Signer] instantiation must be [ECDSA] or [RSA] */ - sealed interface AlgTrait : Signer + sealed interface AlgTrait: Signer /** A [Signer] that signs using ECDSA. */ - interface ECDSA : Signer.AlgTrait { + interface ECDSA: AlgTrait { override val signatureAlgorithm: SignatureAlgorithm.ECDSA override val publicKey: CryptoPublicKey.EC } /** A [Signer] that signs using RSA. */ - interface RSA : Signer.AlgTrait { + interface RSA: AlgTrait { override val signatureAlgorithm: SignatureAlgorithm.RSA override val publicKey: CryptoPublicKey.Rsa } @@ -112,45 +113,16 @@ interface Signer { val attestation: AttestationT? } + /** Try to ensure that the Signer is ready to immediately sign data, on a best-effort basis. + * For example, if user authorization allows signing for a given timeframe, this will prompts for authorization now. + * + * If ahead-of-time authorization makes no sense for this [Signer], does nothing. */ + suspend fun trySetupUninterruptedSigning(): KmmResult = KmmResult.success(Unit) + /** Signs data. Might ask for user confirmation first if this [Signer] [mayRequireUserUnlock]. */ suspend fun sign(data: SignatureInput): KmmResult - - /** - * A handle to a [TemporarilyUnlockable] signer that is temporarily unlocked. - * The handle is only guaranteed to be valid within the scope of the [withUnlock] block. - */ - @OptIn(ExperimentalStdlibApi::class) - interface UnlockedHandle: AutoCloseable, Signer { - override val mayRequireUserUnlock: Boolean get() = false - } - - /** - * A signer that can be temporarily unlocked. - * Once unlocked, multiple signing operations can be performed with a single unlock. - * - * @see withUnlock - */ - abstract class TemporarilyUnlockable : Signer { - override val mayRequireUserUnlock: Boolean get() = true - - /** Obtains a raw unlock handle. This is public because [withUnlock] needs to be inline. - * If you use it directly, closing the returned handle is your responsibility! */ - @HazardousMaterials - abstract suspend fun unlock(): KmmResult - - /** - * Unlocks the signer, then executes the block with the [UnlockedHandle] as its receiver. - * - * The handle's validity is only guaranteed in the block scope. - */ - @OptIn(ExperimentalStdlibApi::class, HazardousMaterials::class) - suspend inline fun withUnlock(fn: Handle.()->T): KmmResult = - unlock().mapCatching { it.use(fn) } - - @OptIn(ExperimentalStdlibApi::class, HazardousMaterials::class) - final override suspend fun sign(data: SignatureInput): KmmResult = - unlock().transform { h -> h.use { it.sign(data) } } - } + suspend fun sign(data: ByteArray) = sign(SignatureInput(data)) + suspend fun sign(data: Sequence) = sign(SignatureInput(data)) companion object { fun Ephemeral(configure: DSLConfigureFn = null) = @@ -171,20 +143,3 @@ fun Signer.makeVerifier(configure: ConfigurePlatformVerifier = null) = signature fun Signer.makePlatformVerifier(configure: ConfigurePlatformVerifier = null) = signatureAlgorithm.platformVerifierFor(publicKey, configure) val Signer.ECDSA.curve get() = publicKey.curve - -/** - * Try to batch sign with this [Signer]. - * Might fail for locked [Signer]s that are not [Signer.TemporarilyUnlockable]. - */ -suspend inline fun Signer.withUnlock(fn: Signer.()->T) = - when (this.mayRequireUserUnlock) { - true -> - if (this is Signer.TemporarilyUnlockable<*>) - this.withUnlock(fn) - else - KmmResult.failure(UnlockFailed("This signer needs authentication for every use")) - false -> catching { fn(this) } - } - -/** Sign the data. */ -suspend inline fun Signer.sign(data: ByteArray) = sign(SignatureInput(data)) diff --git a/supreme/src/iosMain/kotlin/at/asitplus/signum/supreme/InteropUtils.kt b/supreme/src/iosMain/kotlin/at/asitplus/signum/supreme/InteropUtils.kt index 2d5db41f..fb8ffd50 100644 --- a/supreme/src/iosMain/kotlin/at/asitplus/signum/supreme/InteropUtils.kt +++ b/supreme/src/iosMain/kotlin/at/asitplus/signum/supreme/InteropUtils.kt @@ -24,6 +24,24 @@ import platform.Foundation.create import platform.Security.SecCopyErrorMessageString import platform.darwin.OSStatus import platform.posix.memcpy +import kotlin.experimental.ExperimentalNativeApi +import kotlin.native.ref.createCleaner + +@OptIn(ExperimentalNativeApi::class) +class AutofreeVariable> internal constructor( + private val arena: Arena, + private val variable: CPointerVarOf) { + companion object { + internal inline operator fun > invoke(): AutofreeVariable { + val arena = Arena() + val variable = arena.alloc>() + return AutofreeVariable(arena, variable) + } + } + private val cleaner = createCleaner(arena, Arena::clear) + internal val ptr get() = variable.ptr + internal val value get() = variable.value +} internal fun NSData.toByteArray(): ByteArray = ByteArray(length.toInt()).apply { usePinned { diff --git a/supreme/src/iosMain/kotlin/at/asitplus/signum/supreme/hazmat/InternalsAccessors.kt b/supreme/src/iosMain/kotlin/at/asitplus/signum/supreme/hazmat/InternalsAccessors.kt index 9348588a..3066a169 100644 --- a/supreme/src/iosMain/kotlin/at/asitplus/signum/supreme/hazmat/InternalsAccessors.kt +++ b/supreme/src/iosMain/kotlin/at/asitplus/signum/supreme/hazmat/InternalsAccessors.kt @@ -1,29 +1,27 @@ @file:OptIn(ExperimentalForeignApi::class) package at.asitplus.signum.supreme.hazmat +import at.asitplus.signum.supreme.AutofreeVariable import at.asitplus.signum.supreme.HazardousMaterials -import at.asitplus.signum.supreme.os.UnlockedIosSigner +import at.asitplus.signum.supreme.os.IosSigner +import at.asitplus.signum.supreme.os.IosSignerSigningConfiguration import at.asitplus.signum.supreme.sign.EphemeralKey import at.asitplus.signum.supreme.sign.EphemeralKeyBase -import at.asitplus.signum.supreme.sign.EphemeralKeyRef import at.asitplus.signum.supreme.sign.EphemeralSigner import at.asitplus.signum.supreme.sign.Signer import kotlinx.cinterop.ExperimentalForeignApi -import kotlinx.cinterop.value +import platform.Security.SecKeyRef -/** The underlying SecKeyRef referencing the ephemeral key's private key. - * - * **⚠️ Must not be used beyond the EphemeralKey's lifetime. ⚠️** */ +/** The underlying SecKeyRef referencing the ephemeral key's private key. */ @HazardousMaterials -val EphemeralKey.secKeyRef get() = ((this as? EphemeralKeyBase<*>)?.privateKey as? EphemeralKeyRef)?.key?.value +@Suppress("UNCHECKED_CAST") +val EphemeralKey.secKeyRef get() = (this as? EphemeralKeyBase<*>)?.privateKey as? AutofreeVariable -/** The underlying SecKeyRef referencing the signer's private key. Only available on ephemeral signers or unlocked signers. - * Not available on locked signers. (The ref isn't retrieved from the keychain until unlock time.) - * - * **⚠️ Must not be used beyond the signer's lifetime/scope. ⚠️** */ +/** The underlying SecKeyRef referencing the signer's private key. + * **⚠️ If returned from a keychain signer, must be used immediately. Do not store long term. ⚠️** */ @HazardousMaterials val Signer.secKeyRef get() = when (this) { - is EphemeralSigner -> this.privateKey.key.value - is UnlockedIosSigner -> this.privateKeyRef + is EphemeralSigner -> this.privateKey + is IosSigner -> this.privateKeyManager.get(IosSignerSigningConfiguration()) else -> null } diff --git a/supreme/src/iosMain/kotlin/at/asitplus/signum/supreme/os/IosKeychainProvider.kt b/supreme/src/iosMain/kotlin/at/asitplus/signum/supreme/os/IosKeychainProvider.kt index d1c0871f..edef076b 100644 --- a/supreme/src/iosMain/kotlin/at/asitplus/signum/supreme/os/IosKeychainProvider.kt +++ b/supreme/src/iosMain/kotlin/at/asitplus/signum/supreme/os/IosKeychainProvider.kt @@ -30,7 +30,6 @@ import at.asitplus.signum.supreme.takeFromCF import at.asitplus.signum.supreme.toByteArray import at.asitplus.signum.supreme.toNSData import io.github.aakira.napier.Napier -import kotlinx.cinterop.Arena import kotlinx.cinterop.ExperimentalForeignApi import kotlinx.cinterop.MemScope import kotlinx.cinterop.alloc @@ -99,12 +98,12 @@ import platform.Security.kSecUseAuthenticationUI import platform.Security.kSecUseAuthenticationUIAllow import at.asitplus.signum.indispensable.secKeyAlgorithm import at.asitplus.signum.indispensable.secKeyAlgorithmPreHashed -import at.asitplus.signum.supreme.HazardousMaterials +import at.asitplus.signum.supreme.AutofreeVariable import at.asitplus.signum.supreme.sign.SigningKeyConfiguration import at.asitplus.signum.supreme.sign.preHashedSignatureFormat -import at.asitplus.signum.supreme.sign.sign import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable +import platform.Security.kSecUseAuthenticationUIFail import kotlin.contracts.ExperimentalContracts import kotlin.contracts.InvocationKind import kotlin.contracts.contract @@ -146,130 +145,10 @@ class IosSigningKeyConfiguration internal constructor(): PlatformSigningKeyConfi } } -class IosSignerConfiguration internal constructor(): PlatformSignerConfigurationBase() - -sealed class UnlockedIosSigner(private val ownedArena: Arena, internal val privateKeyRef: SecKeyRef) : Signer.UnlockedHandle { - abstract val parent: IosSigner<*> - val alias get() = parent.alias - - var usable = true - final override fun close() { - if (!usable) return - usable = false - ownedArena.clear() - } - - internal fun checkSupport() { - if (!SecKeyIsAlgorithmSupported(privateKeyRef, kSecKeyOperationTypeSign, signatureAlgorithm.secKeyAlgorithm)) { - close() - throw UnsupportedCryptoException("Requested operation is not supported by this key") - } - } - - protected abstract fun bytesToSignature(sigBytes: ByteArray): CryptoSignature - final override suspend fun sign(data: SignatureInput): KmmResult = - withContext(keychainThreads) { catching { - if (!usable) throw IllegalStateException("Scoping violation; using key after it has been freed") - require(data.format == null) { "Pre-hashed data is unsupported on iOS" } - val algorithm = signatureAlgorithm.secKeyAlgorithmPreHashed - val plaintext = data.convertTo(signatureAlgorithm.preHashedSignatureFormat).getOrThrow().data.first().toNSData() - Napier.v { "before sign" } - val signatureBytes = corecall { - SecKeyCreateSignature(privateKeyRef, algorithm, plaintext.giveToCF(), error) - }.takeFromCF().toByteArray() - Napier.v { "after sign" } - return@catching bytesToSignature(signatureBytes) - }} - - class ECDSA(ownedArena: Arena, - privateKeyRef: SecKeyRef, - override val parent: IosSigner.ECDSA) - : UnlockedIosSigner(ownedArena, privateKeyRef), Signer.ECDSA - { - override val signatureAlgorithm get() = parent.signatureAlgorithm - override val publicKey get() = parent.publicKey - override fun bytesToSignature(sigBytes: ByteArray) = - CryptoSignature.EC.decodeFromDer(sigBytes).withCurve(publicKey.curve) - } - - class RSA(ownedArena: Arena, - privateKeyRef: SecKeyRef, - override val parent: IosSigner.RSA) - : UnlockedIosSigner(ownedArena, privateKeyRef), Signer.RSA - { - override val signatureAlgorithm get() = parent.signatureAlgorithm - override val publicKey get() = parent.publicKey - override fun bytesToSignature(sigBytes: ByteArray) = - CryptoSignature.RSAorHMAC(sigBytes) - } - -} - -@Serializable -internal sealed interface IosKeyAlgSpecificMetadata { - @Serializable - @SerialName("ecdsa") - data class ECDSA( - val supportedDigests: Set - ) : IosKeyAlgSpecificMetadata - - @Serializable - @SerialName("rsa") - data class RSA( - val supportedDigests: Set, - val supportedPaddings: Set - ): IosKeyAlgSpecificMetadata -} - -@Serializable -internal data class IosKeyMetadata( - val attestation: IosHomebrewAttestation?, - val unlockTimeout: Duration, - val algSpecific: IosKeyAlgSpecificMetadata -) - -private object LAContextManager { - private data class PreviousAuthentication( - val authenticatedContext: LAContext, - val authenticationTime: TimeSource.Monotonic.ValueTimeMark) - private var previousAuthentication: PreviousAuthentication? = null - @OptIn(ExperimentalContracts::class) - inline fun withLAContext(keyMetadata: IosKeyMetadata, - signerConfig: IosSignerConfiguration, body: (LAContext)->T): T { - contract { callsInPlace(body, InvocationKind.AT_MOST_ONCE) } - - val reusable = previousAuthentication?.takeIf { - it.authenticationTime.elapsedNow() <= keyMetadata.unlockTimeout - } - if (reusable != null) { - Napier.v { "Re-using previous authentication context" } - return body(reusable.authenticatedContext.apply { - /** Configure it to suit this signer just in case something has gone wrong */ - localizedReason = signerConfig.unlockPrompt.v.message - localizedCancelTitle = signerConfig.unlockPrompt.v.cancelText - }) - } - - Napier.v { "Requesting user authentication..." } - - val newContext = LAContext().apply { - localizedReason = signerConfig.unlockPrompt.v.message - localizedCancelTitle = signerConfig.unlockPrompt.v.cancelText - touchIDAuthenticationAllowableReuseDuration = min(10L,keyMetadata.unlockTimeout.inWholeSeconds).toDouble() - } - - return runCatching { body(newContext) }.also { - Napier.v { "Authentication succeeded? ${it.isSuccess}" } - // if this did not throw (e.g., succeeded)... - previousAuthentication = PreviousAuthentication(newContext, TimeSource.Monotonic.markNow()) - }.getOrThrow() - } -} - /** * Resolve [what] differently based on whether the [v]alue was [spec]ified. * - * * [spec] = `true`: Check if [valid] contains [nameMap] applied to [v], return [v] if yes, throw otherwise + * * [spec] = `true`: Check if [valid] contains [v], return [v] if yes, throw otherwise * * [spec] = `false`: Check if [valid] contains exactly one element, if yes, return it, throw otherwise */ private inline fun resolveOption(what: String, valid: Set, spec: Boolean, v: E): E = @@ -286,57 +165,138 @@ private inline fun resolveOption(what: String, valid: Set, spec: } } -sealed class IosSigner( - final override val alias: String, - private val metadata: IosKeyMetadata, - private val config: IosSignerConfiguration -) : Signer.TemporarilyUnlockable(), Signer.Attestable, Signer.WithAlias { - final override val attestation get() = metadata.attestation - @HazardousMaterials - final override suspend fun unlock(): KmmResult = withContext(keychainThreads) { catching { - val arena = Arena() - val privateKey = arena.alloc() - try { - LAContextManager.withLAContext(keyMetadata = metadata, signerConfig = config) { ctx -> - memScoped { - val query = cfDictionaryOf( - kSecClass to kSecClassKey, - kSecAttrKeyClass to kSecAttrKeyClassPrivate, - kSecAttrApplicationLabel to alias, - kSecAttrApplicationTag to KeychainTags.PRIVATE_KEYS, - kSecAttrKeyType to when (this@IosSigner) { - is ECDSA -> kSecAttrKeyTypeEC - is RSA -> kSecAttrKeyTypeRSA - }, - kSecMatchLimit to kSecMatchLimitOne, - kSecReturnRef to true, - - kSecUseAuthenticationContext to ctx, - kSecUseAuthenticationUI to kSecUseAuthenticationUIAllow - ) - val status = SecItemCopyMatching(query, privateKey.ptr.reinterpret()) - if ((status == errSecSuccess) && (privateKey.value != null)) { - return@memScoped /* continue below try/catch */ +class IosSignerConfiguration internal constructor(): PlatformSignerConfigurationBase() + +private object LAContextStorage { + data class SuccessfulAuthentication( + val authnContext: LAContext, val authnTime: TimeSource.Monotonic.ValueTimeMark) + var successfulAuthentication: SuccessfulAuthentication? = null +} + +typealias IosSignerSigningConfiguration = PlatformSigningProviderSignerSigningConfigurationBase +sealed class IosSigner(final override val alias: String, + private val metadata: IosKeyMetadata, + private val signerConfig: IosSignerConfiguration) + : PlatformSigningProviderSigner { + + override val mayRequireUserUnlock get() = needsAuthentication + val needsAuthentication get() = metadata.needsUnlock + val needsAuthenticationForEveryUse get() = metadata.needsUnlock && (metadata.unlockTimeout == Duration.ZERO) + + interface PrivateKeyManager { fun get(signingConfig: IosSignerSigningConfiguration): AutofreeVariable } + internal val privateKeyManager = object : PrivateKeyManager { + private var storedKey: AutofreeVariable? = null + override fun get(signingConfig: IosSignerSigningConfiguration): AutofreeVariable { + + Napier.v { "Private Key access for alias $alias requested (needs unlock? ${metadata.needsUnlock}; timeout? ${metadata.unlockTimeout})" } + + val ctx: LAContext? /* the LAContext (potentially old if the timeout permits) to use */ + val recordable: Boolean /* whether this is a new context, which will prompt for actual authentication */ + if (metadata.needsUnlock) { + val previousAuthn = if (metadata.unlockTimeout != Duration.ZERO) LAContextStorage.successfulAuthentication else null + if ((previousAuthn != null) && (previousAuthn.authnTime.elapsedNow() < metadata.unlockTimeout)) { + // if we are allowed to reuse the key, and we have the key, then reuse the key + storedKey?.let { + Napier.v { "Re-using cached private key reference for alias $alias" } + return it + } + Napier.v { "Re-using successful LAContext to retrieve key with alias $alias" } + recordable = false + ctx = previousAuthn.authnContext + } else { + Napier.v { "Forcing user to authenticate a new LAContext for alias $alias" } + recordable = true + ctx = LAContext().apply { touchIDAuthenticationAllowableReuseDuration = min(10.0, metadata.unlockTimeout.inWholeSeconds.toDouble()) } + } + ctx.apply { + val stack = DSL.ConfigStack(signingConfig.unlockPrompt.v, signerConfig.unlockPrompt.v) + localizedReason = stack.getProperty(UnlockPromptConfiguration::message, + checker = UnlockPromptConfiguration::messageSpecified, default = UnlockPromptConfiguration.defaultMessage) + localizedCancelTitle = stack.getProperty(UnlockPromptConfiguration::cancelText, + checker = UnlockPromptConfiguration::cancelTextSpecified, default = UnlockPromptConfiguration.defaultCancelText) + } + } else { + recordable = false + ctx = null + } + + // ok, we need to get the key from the keychain + val newPrivateKey = AutofreeVariable() + memScoped { + val query = createCFDictionary { + kSecClass mapsTo kSecClassKey + kSecAttrKeyClass mapsTo kSecAttrKeyClassPrivate + kSecAttrApplicationLabel mapsTo alias + kSecAttrApplicationTag mapsTo KeychainTags.PRIVATE_KEYS + when (this@IosSigner) { + is ECDSA -> kSecAttrKeyType mapsTo kSecAttrKeyTypeEC + is RSA -> kSecAttrKeyType mapsTo kSecAttrKeyTypeRSA + } + kSecMatchLimit mapsTo kSecMatchLimitOne + kSecReturnRef mapsTo true + + if (ctx != null) { + kSecUseAuthenticationContext mapsTo ctx + kSecUseAuthenticationUI mapsTo kSecUseAuthenticationUIAllow } else { - throw CFCryptoOperationFailed( - thing = "retrieve private key", - osStatus = status - ) + kSecUseAuthenticationUI mapsTo kSecUseAuthenticationUIFail } } + val status = SecItemCopyMatching(query, newPrivateKey.ptr.reinterpret()) + if ((status == errSecSuccess) && (newPrivateKey.value != null)) { + return@memScoped + } else { + throw CFCryptoOperationFailed( + thing = "retrieve private key", + osStatus = status + ) + } } - } catch (e: Throwable) { - arena.clear() - throw e + if (!SecKeyIsAlgorithmSupported(newPrivateKey.value, kSecKeyOperationTypeSign, signatureAlgorithm.secKeyAlgorithmPreHashed)) { + throw UnsupportedCryptoException("Requested operation is not supported by this key") + } + + if (recordable && (ctx != null)) { + Napier.v { "Going to record successful LAContext after retrieving key $alias" } + // record the successful unlock timestamp and LAContext for reuse + // produce a dummy signature to ensure that the unlock has succeeded; this is required by secure enclave keys, which do not prompt for unlock until signing time + corecall { SecKeyCreateSignature(newPrivateKey.value, signatureAlgorithm.secKeyAlgorithmPreHashed, + ByteArray(signatureAlgorithm.preHashedSignatureFormat!!.outputLength.bytes.toInt()).toNSData().giveToCF(), error) } + + // if we have reached this point, the unlock operation has definitively succeeded + LAContextStorage.successfulAuthentication = LAContextStorage.SuccessfulAuthentication( + authnContext = ctx, authnTime = TimeSource.Monotonic.markNow()) + Napier.v { "Successfully recorded LAContext for future re-use" } + } + storedKey = newPrivateKey + return newPrivateKey + } + } + + final override suspend fun trySetupUninterruptedSigning(configure: DSLConfigureFn): KmmResult = + withContext(keychainThreads) { catching { + if (needsAuthentication && !needsAuthenticationForEveryUse) { + val config = DSL.resolve(::IosSignerSigningConfiguration, configure) + privateKeyManager.get(config) } - /* if the block did not throw, the handle takes ownership of the arena */ - toUnlocked(arena, privateKey.value!!).also(UnlockedIosSigner::checkSupport) + } } + + protected abstract fun bytesToSignature(sigBytes: ByteArray): CryptoSignature + final override suspend fun sign(data: SignatureInput, configure: DSLConfigureFn): KmmResult = + withContext(keychainThreads) { catching { + require(data.format == null) { "Pre-hashed data is unsupported on iOS" } + val signingConfig = DSL.resolve(::IosSignerSigningConfiguration, configure) + val algorithm = signatureAlgorithm.secKeyAlgorithmPreHashed + val plaintext = data.convertTo(signatureAlgorithm.preHashedSignatureFormat).getOrThrow().data.first().toNSData() + val signatureBytes = corecall { + SecKeyCreateSignature(privateKeyManager.get(signingConfig).value, algorithm, plaintext.giveToCF(), error) + }.takeFromCF().toByteArray() + return@catching bytesToSignature(signatureBytes) }} - protected abstract fun toUnlocked(arena: Arena, key: SecKeyRef): H - class ECDSA internal constructor(alias: String, metadata: IosKeyMetadata, config: IosSignerConfiguration, - override val publicKey: CryptoPublicKey.EC) - : IosSigner(alias, metadata, config), Signer.ECDSA + class ECDSA internal constructor + (alias: String, override val publicKey: CryptoPublicKey.EC, metadata: IosKeyMetadata, config: IosSignerConfiguration) + : IosSigner(alias, metadata, config), Signer.ECDSA { override val signatureAlgorithm: SignatureAlgorithm.ECDSA init { @@ -350,17 +310,15 @@ sealed class IosSigner( else -> throw UnsupportedCryptoException("ECDSA with $digest is not supported on iOS") } } - - override fun toUnlocked(arena: Arena, key: SecKeyRef) = - UnlockedIosSigner.ECDSA(arena, key, this) + override fun bytesToSignature(sigBytes: ByteArray) = + CryptoSignature.EC.decodeFromDer(sigBytes).withCurve(publicKey.curve) } - class RSA internal constructor (alias: String, metadata: IosKeyMetadata, config: IosSignerConfiguration, - override val publicKey: CryptoPublicKey.Rsa) - : IosSigner(alias, metadata, config), Signer.RSA + class RSA internal constructor + (alias: String, override val publicKey: CryptoPublicKey.Rsa, metadata: IosKeyMetadata, config: IosSignerConfiguration) + : IosSigner(alias, metadata, config), Signer.RSA { override val signatureAlgorithm: SignatureAlgorithm.RSA - init { check (metadata.algSpecific is IosKeyAlgSpecificMetadata.RSA) { "Metadata type mismatch (RSA key, metadata not RSA) "} @@ -370,14 +328,40 @@ sealed class IosSigner( padding = resolveOption("padding", metadata.algSpecific.supportedPaddings, config.rsa.v.paddingSpecified, config.rsa.v.padding) ) } - - override fun toUnlocked(arena: Arena, key: SecKeyRef) = - UnlockedIosSigner.RSA(arena, key, this) + override fun bytesToSignature(sigBytes: ByteArray) = + CryptoSignature.RSAorHMAC(sigBytes) } + +} + +@Serializable +internal sealed interface IosKeyAlgSpecificMetadata { + @Serializable + @SerialName("ecdsa") + data class ECDSA( + val supportedDigests: Set + ) : IosKeyAlgSpecificMetadata + + @Serializable + @SerialName("rsa") + data class RSA( + val supportedDigests: Set, + val supportedPaddings: Set + ): IosKeyAlgSpecificMetadata +} + +@Serializable +internal data class IosKeyMetadata( + val attestation: IosHomebrewAttestation?, + private val rawUnlockTimeout: Duration?, + val algSpecific: IosKeyAlgSpecificMetadata +) { + val needsUnlock inline get() = (rawUnlockTimeout != null) + val unlockTimeout inline get() = rawUnlockTimeout ?: Duration.INFINITE } @OptIn(ExperimentalForeignApi::class) -object IosKeychainProvider: SigningProviderI, IosSignerConfiguration, IosSigningKeyConfiguration> { +object IosKeychainProvider: PlatformSigningProviderI { private fun MemScope.getPublicKey(alias: String): SecKeyRef? { val it = alloc() val query = cfDictionaryOf( @@ -431,7 +415,7 @@ object IosKeychainProvider: SigningProviderI, IosSignerConfiguratio override suspend fun createSigningKey( alias: String, configure: DSLConfigureFn - ): KmmResult> = withContext(keychainThreads) { catching { + ): KmmResult = withContext(keychainThreads) { catching { memScoped { if (getPublicKey(alias) != null) throw NoSuchElementException("Key with alias $alias already exists") @@ -471,11 +455,12 @@ object IosKeychainProvider: SigningProviderI, IosSignerConfiguratio kSecAttrApplicationLabel mapsTo alias kSecAttrIsPermanent mapsTo true kSecAttrApplicationTag mapsTo KeychainTags.PRIVATE_KEYS - when (val factors = config.hardware.v.protection.v?.factors?.v) { + when (val hwProtection = config.hardware.v.protection.v) { null -> { kSecAttrAccessible mapsTo availability } else -> { + val factors = hwProtection.factors.v kSecAttrAccessControl mapsTo corecall { SecAccessControlCreateWithFlags( null, availability, @@ -559,7 +544,7 @@ object IosKeychainProvider: SigningProviderI, IosSignerConfiguratio val metadata = IosKeyMetadata( attestation = attestation, - unlockTimeout = config.hardware.v.protection.v?.timeout ?: Duration.ZERO, + rawUnlockTimeout = config.hardware.v.protection.v?.timeout, algSpecific = when (val alg = config._algSpecific.v) { is SigningKeyConfiguration.ECConfiguration -> IosKeyAlgSpecificMetadata.ECDSA(alg.digests) is SigningKeyConfiguration.RSAConfiguration -> IosKeyAlgSpecificMetadata.RSA(alg.digests, alg.paddings) @@ -571,9 +556,9 @@ object IosKeychainProvider: SigningProviderI, IosSignerConfiguratio val signerConfiguration = DSL.resolve(::IosSignerConfiguration, config.signer.v) return@catching when (publicKey) { is CryptoPublicKey.EC -> - IosSigner.ECDSA(alias, metadata, signerConfiguration, publicKey) + IosSigner.ECDSA(alias, publicKey, metadata, signerConfiguration) is CryptoPublicKey.Rsa -> - IosSigner.RSA(alias, metadata, signerConfiguration, publicKey) + IosSigner.RSA(alias, publicKey, metadata, signerConfiguration) } }.also { val e = it.exceptionOrNull() @@ -586,7 +571,7 @@ object IosKeychainProvider: SigningProviderI, IosSignerConfiguratio override suspend fun getSignerForKey( alias: String, configure: DSLConfigureFn - ): KmmResult> = withContext(keychainThreads) { catching { + ): KmmResult = withContext(keychainThreads) { catching { val config = DSL.resolve(::IosSignerConfiguration, configure) val publicKeyBytes: ByteArray = memScoped { val publicKey = getPublicKey(alias) @@ -595,11 +580,12 @@ object IosKeychainProvider: SigningProviderI, IosSignerConfiguratio SecKeyCopyExternalRepresentation(publicKey, error) }.let { it.takeFromCF() }.toByteArray() } + val publicKey = + CryptoPublicKey.fromIosEncoded(publicKeyBytes) val metadata = getKeyMetadata(alias) - return@catching when (val publicKey = - CryptoPublicKey.fromIosEncoded(publicKeyBytes)) { - is CryptoPublicKey.EC -> IosSigner.ECDSA(alias, metadata, config, publicKey) - is CryptoPublicKey.Rsa -> IosSigner.RSA(alias, metadata, config, publicKey) + return@catching when (publicKey) { + is CryptoPublicKey.EC -> IosSigner.ECDSA(alias, publicKey, metadata, config) + is CryptoPublicKey.Rsa -> IosSigner.RSA(alias, publicKey, metadata, config) } }} @@ -636,5 +622,5 @@ actual typealias PlatformSigningProviderSignerConfiguration = iosSignerConfigura actual typealias PlatformSigningProviderSigningKeyConfiguration = iosSigningKeyConfiguration actual typealias PlatformSigningProvider = IosKeychainProvider actual typealias PlatformSigningProviderConfiguration = PlatformSigningProviderConfigurationBase*/ -internal actual fun getPlatformSigningProvider(configure: DSLConfigureFn): SigningProvider = +internal actual fun getPlatformSigningProvider(configure: DSLConfigureFn): PlatformSigningProvider = IosKeychainProvider diff --git a/supreme/src/iosMain/kotlin/at/asitplus/signum/supreme/sign/EphemeralKeysImpl.kt b/supreme/src/iosMain/kotlin/at/asitplus/signum/supreme/sign/EphemeralKeysImpl.kt index 303274fe..4531a355 100644 --- a/supreme/src/iosMain/kotlin/at/asitplus/signum/supreme/sign/EphemeralKeysImpl.kt +++ b/supreme/src/iosMain/kotlin/at/asitplus/signum/supreme/sign/EphemeralKeysImpl.kt @@ -6,6 +6,7 @@ import at.asitplus.signum.indispensable.CryptoPublicKey import at.asitplus.signum.indispensable.CryptoSignature import at.asitplus.signum.indispensable.SignatureAlgorithm import at.asitplus.signum.indispensable.secKeyAlgorithmPreHashed +import at.asitplus.signum.supreme.AutofreeVariable import at.asitplus.signum.supreme.CFCryptoOperationFailed import at.asitplus.signum.supreme.cfDictionaryOf import at.asitplus.signum.supreme.corecall @@ -14,7 +15,6 @@ import at.asitplus.signum.supreme.giveToCF import at.asitplus.signum.supreme.takeFromCF import at.asitplus.signum.supreme.toByteArray import at.asitplus.signum.supreme.toNSData -import kotlinx.cinterop.Arena import kotlinx.cinterop.ExperimentalForeignApi import kotlinx.cinterop.alloc import kotlinx.cinterop.memScoped @@ -24,6 +24,7 @@ import platform.Foundation.NSData import platform.Security.SecKeyCopyExternalRepresentation import platform.Security.SecKeyCreateSignature import platform.Security.SecKeyGeneratePair +import platform.Security.SecKeyRef import platform.Security.SecKeyRefVar import platform.Security.errSecSuccess import platform.Security.kSecAttrIsPermanent @@ -33,12 +34,11 @@ import platform.Security.kSecAttrKeyTypeEC import platform.Security.kSecAttrKeyTypeRSA import platform.Security.kSecPrivateKeyAttrs import platform.Security.kSecPublicKeyAttrs -import kotlin.experimental.ExperimentalNativeApi -import kotlin.native.ref.createCleaner actual class EphemeralSigningKeyConfiguration internal actual constructor(): EphemeralSigningKeyConfigurationBase() actual class EphemeralSignerConfiguration internal actual constructor(): EphemeralSignerConfigurationBase() +private typealias EphemeralKeyRef = AutofreeVariable sealed class EphemeralSigner(internal val privateKey: EphemeralKeyRef): Signer { final override val mayRequireUserUnlock: Boolean get() = false final override suspend fun sign(data: SignatureInput) = catching { @@ -46,7 +46,7 @@ sealed class EphemeralSigner(internal val privateKey: EphemeralKeyRef): Signer { val algorithm = signatureAlgorithm.secKeyAlgorithmPreHashed val input = inputData.data.single().toNSData() val signatureBytes = corecall { - SecKeyCreateSignature(privateKey.key.value, algorithm, input.giveToCF(), error) + SecKeyCreateSignature(privateKey.value, algorithm, input.giveToCF(), error) }.let { it.takeFromCF().toByteArray() } return@catching when (val pubkey = publicKey) { is CryptoPublicKey.EC -> CryptoSignature.EC.decodeFromDer(signatureBytes).withCurve(pubkey.curve) @@ -62,15 +62,8 @@ sealed class EphemeralSigner(internal val privateKey: EphemeralKeyRef): Signer { : EphemeralSigner(privateKey), Signer.RSA } -class EphemeralKeyRef { - private val arena = Arena() - @OptIn(ExperimentalNativeApi::class) - private val cleaner = createCleaner(arena, Arena::clear) - val key = arena.alloc() -} - internal actual fun makeEphemeralKey(configuration: EphemeralSigningKeyConfiguration) : EphemeralKey { - val key = EphemeralKeyRef() + val key = AutofreeVariable() memScoped { val attr = createCFDictionary { when (val alg = configuration._algSpecific.v) { @@ -87,7 +80,7 @@ internal actual fun makeEphemeralKey(configuration: EphemeralSigningKeyConfigura kSecPublicKeyAttrs mapsTo cfDictionaryOf(kSecAttrIsPermanent to false) } val pubkey = alloc() - val status = SecKeyGeneratePair(attr, pubkey.ptr, key.key.ptr) + val status = SecKeyGeneratePair(attr, pubkey.ptr, key.ptr) if (status != errSecSuccess) { throw CFCryptoOperationFailed(thing = "generate ephemeral key", osStatus = status) } diff --git a/supreme/src/jvmMain/kotlin/at/asitplus/signum/supreme/os/JKSProvider.kt b/supreme/src/jvmMain/kotlin/at/asitplus/signum/supreme/os/JKSProvider.kt index 3265a2b2..0908e138 100644 --- a/supreme/src/jvmMain/kotlin/at/asitplus/signum/supreme/os/JKSProvider.kt +++ b/supreme/src/jvmMain/kotlin/at/asitplus/signum/supreme/os/JKSProvider.kt @@ -400,5 +400,5 @@ actual typealias PlatformSigningProviderSignerConfiguration = JKSSignerConfigura actual typealias PlatformSigningProviderSigningKeyConfiguration = JKSSigningKeyConfiguration actual typealias PlatformSigningProvider = JKSProvider actual typealias PlatformSigningProviderConfiguration = JKSProviderConfiguration*/ -internal actual fun getPlatformSigningProvider(configure: DSLConfigureFn): SigningProvider = - makePlatformSigningProvider(DSL.resolve(::JKSProviderConfiguration, configure)) +internal actual fun getPlatformSigningProvider(configure: DSLConfigureFn): PlatformSigningProvider = + throw UnsupportedOperationException("No default persistence mode is available on the JVM. Use JKSProvider {file {}} or similar. This will be natively available from the getPlatformSigningProvider {} DSL in a future release. (Blocked by KT-71036.)") From 9b6827f6f5181202c38c91790038ac3de3725ddd Mon Sep 17 00:00:00 2001 From: Jakob Heher Date: Fri, 30 Aug 2024 17:56:18 +0200 Subject: [PATCH 44/73] now also on android --- .../at/asitplus/cryptotest/App.android.kt | 2 + .../signum/indispensable/CryptoPublicKey.kt | 2 + .../indispensable/pki/AlternativeNames.kt | 1 + .../pki/X509CertificateExtension.kt | 1 + .../supreme/hazmat/InternalsAccessors.kt | 9 +- .../supreme/os/AndroidKeyStoreProvider.kt | 225 ++++++++---------- .../signum/supreme/dsl/ConfigurationDSL.kt | 28 ++- .../asitplus/signum/supreme/os/Attestation.kt | 1 + .../signum/supreme/os/SigningProvider.kt | 10 +- .../signum/supreme/os/IosKeychainProvider.kt | 6 +- 10 files changed, 141 insertions(+), 144 deletions(-) diff --git a/demoapp/composeApp/src/androidMain/kotlin/at/asitplus/cryptotest/App.android.kt b/demoapp/composeApp/src/androidMain/kotlin/at/asitplus/cryptotest/App.android.kt index 5ce1acd4..6d610bdb 100644 --- a/demoapp/composeApp/src/androidMain/kotlin/at/asitplus/cryptotest/App.android.kt +++ b/demoapp/composeApp/src/androidMain/kotlin/at/asitplus/cryptotest/App.android.kt @@ -5,6 +5,8 @@ import android.os.Bundle import androidx.activity.compose.setContent import androidx.fragment.app.FragmentActivity +class AndroidApp : Application() + class AppActivity : FragmentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) diff --git a/indispensable/src/commonMain/kotlin/at/asitplus/signum/indispensable/CryptoPublicKey.kt b/indispensable/src/commonMain/kotlin/at/asitplus/signum/indispensable/CryptoPublicKey.kt index 8f5723bf..b74c60bd 100644 --- a/indispensable/src/commonMain/kotlin/at/asitplus/signum/indispensable/CryptoPublicKey.kt +++ b/indispensable/src/commonMain/kotlin/at/asitplus/signum/indispensable/CryptoPublicKey.kt @@ -165,6 +165,7 @@ sealed class CryptoPublicKey : Asn1Encodable, Identifiable { * RSA Public key */ @Serializable + @ConsistentCopyVisibility data class Rsa @Throws(IllegalArgumentException::class) private constructor( @@ -296,6 +297,7 @@ sealed class CryptoPublicKey : Asn1Encodable, Identifiable { */ @Serializable @SerialName("EC") + @ConsistentCopyVisibility data class EC private constructor( val publicPoint: ECPoint.Normalized, val preferCompressedRepresentation: Boolean = true diff --git a/indispensable/src/commonMain/kotlin/at/asitplus/signum/indispensable/pki/AlternativeNames.kt b/indispensable/src/commonMain/kotlin/at/asitplus/signum/indispensable/pki/AlternativeNames.kt index c75848b7..f25b4a6b 100644 --- a/indispensable/src/commonMain/kotlin/at/asitplus/signum/indispensable/pki/AlternativeNames.kt +++ b/indispensable/src/commonMain/kotlin/at/asitplus/signum/indispensable/pki/AlternativeNames.kt @@ -18,6 +18,7 @@ import at.asitplus.signum.indispensable.pki.AlternativeNames.Companion.findSubje * See [RFC 5280, Section 4.2.1.6](https://datatracker.ietf.org/doc/html/rfc5280#section-4.2.1.6) * for details on the properties of this container class, as they are named accordingly. */ +@ConsistentCopyVisibility data class AlternativeNames @Throws(Throwable::class) private constructor(private val extensions: List) { diff --git a/indispensable/src/commonMain/kotlin/at/asitplus/signum/indispensable/pki/X509CertificateExtension.kt b/indispensable/src/commonMain/kotlin/at/asitplus/signum/indispensable/pki/X509CertificateExtension.kt index 14dcd1b8..79f89ea4 100644 --- a/indispensable/src/commonMain/kotlin/at/asitplus/signum/indispensable/pki/X509CertificateExtension.kt +++ b/indispensable/src/commonMain/kotlin/at/asitplus/signum/indispensable/pki/X509CertificateExtension.kt @@ -8,6 +8,7 @@ import kotlinx.serialization.Serializable * X.509 Certificate Extension */ @Serializable +@ConsistentCopyVisibility data class X509CertificateExtension @Throws(Asn1Exception::class) private constructor( override val oid: ObjectIdentifier, val value: Asn1Element, diff --git a/supreme/src/androidMain/kotlin/at/asitplus/signum/supreme/hazmat/InternalsAccessors.kt b/supreme/src/androidMain/kotlin/at/asitplus/signum/supreme/hazmat/InternalsAccessors.kt index 911499f4..eb8b266a 100644 --- a/supreme/src/androidMain/kotlin/at/asitplus/signum/supreme/hazmat/InternalsAccessors.kt +++ b/supreme/src/androidMain/kotlin/at/asitplus/signum/supreme/hazmat/InternalsAccessors.kt @@ -1,8 +1,7 @@ package at.asitplus.signum.supreme.hazmat import at.asitplus.signum.supreme.HazardousMaterials -import at.asitplus.signum.supreme.os.LockedAndroidKeystoreSigner -import at.asitplus.signum.supreme.os.UnlockedAndroidKeystoreSigner +import at.asitplus.signum.supreme.os.AndroidKeystoreSigner import at.asitplus.signum.supreme.sign.AndroidEphemeralSigner import at.asitplus.signum.supreme.sign.EphemeralKey import at.asitplus.signum.supreme.sign.EphemeralKeyBase @@ -17,10 +16,6 @@ val EphemeralKey.jcaPrivateKey get() = (this as? EphemeralKeyBase<*>)?.privateKe @HazardousMaterials val Signer.jcaPrivateKey get() = when (this) { is AndroidEphemeralSigner -> this.privateKey - is LockedAndroidKeystoreSigner -> this.jcaPrivateKey + is AndroidKeystoreSigner -> this.jcaPrivateKey else -> null } - -/** The underlying, unlocked JCA [Signature] object. */ -@HazardousMaterials -val UnlockedAndroidKeystoreSigner.jcaSignatureInstance get() = this.jcaSig diff --git a/supreme/src/androidMain/kotlin/at/asitplus/signum/supreme/os/AndroidKeyStoreProvider.kt b/supreme/src/androidMain/kotlin/at/asitplus/signum/supreme/os/AndroidKeyStoreProvider.kt index 6af110ec..afe7d160 100644 --- a/supreme/src/androidMain/kotlin/at/asitplus/signum/supreme/os/AndroidKeyStoreProvider.kt +++ b/supreme/src/androidMain/kotlin/at/asitplus/signum/supreme/os/AndroidKeyStoreProvider.kt @@ -25,7 +25,6 @@ import at.asitplus.signum.indispensable.pki.CertificateChain import at.asitplus.signum.indispensable.pki.X509Certificate import at.asitplus.signum.indispensable.pki.leaf import at.asitplus.signum.supreme.AppLifecycleMonitor -import at.asitplus.signum.supreme.HazardousMaterials import at.asitplus.signum.supreme.UnlockFailed import at.asitplus.signum.supreme.UnsupportedCryptoException import at.asitplus.signum.supreme.dsl.DISCOURAGED @@ -52,7 +51,6 @@ import java.security.spec.RSAKeyGenParameterSpec import java.time.Instant import java.util.Date import javax.security.auth.x500.X500Principal -import java.security.Signature as JCASignatureObject internal sealed interface FragmentContext { @JvmInline value class OfActivity(val activity: FragmentActivity): FragmentContext @@ -68,34 +66,48 @@ class AndroidSigningKeyConfiguration internal constructor(): PlatformSigningKeyC override val hardware = childOrNull(::AndroidKeymasterConfiguration) } -class AndroidSignerConfiguration: PlatformSignerConfigurationBase() { - class AuthnPrompt: PlatformSignerConfigurationBase.AuthnPrompt() { - /** Explicitly specify the FragmentActivity to use for authentication prompts. - * You will not need to set this in most cases; the default is the current activity. */ - lateinit var activity: FragmentActivity - - /** Explicitly set the Fragment to base authentication prompts on. - * You will not need to set this in most cases; the default is the current activity.*/ - lateinit var fragment: Fragment - - internal val explicitContext: FragmentContext? get() = when { - this::fragment.isInitialized -> FragmentContext.OfFragment(fragment) - this::activity.isInitialized -> FragmentContext.OfActivity(activity) - else -> null - } +class AndroidUnlockPromptConfiguration internal constructor(): UnlockPromptConfiguration() { + /** Explicitly specify the FragmentActivity to use for authentication prompts. + * You will not need to set this in most cases; the default is the current activity. */ + lateinit var activity: FragmentActivity + + /** Explicitly set the Fragment to base authentication prompts on. + * You will not need to set this in most cases; the default is the current activity.*/ + lateinit var fragment: Fragment - /** @see [BiometricPrompt.PromptInfo.Builder.setSubtitle] */ - var subtitle: String? = null - /** @see [BiometricPrompt.PromptInfo.Builder.setDescription] */ - var description: String? = null - /** @see [BiometricPrompt.PromptInfo.Builder.setConfirmationRequired] */ - var confirmationRequired: Boolean? = null - /** @see [BiometricPrompt.PromptInfo.Builder.setAllowedAuthenticators] */ - var allowedAuthenticators: Int? = null - /** if the provided fingerprint could not be matched, but the user will be allowed to retry */ - var invalidBiometryCallback: (()->Unit)? = null + internal val explicitContext: FragmentContext get() = when { + this::fragment.isInitialized -> FragmentContext.OfFragment(fragment) + else -> FragmentContext.OfActivity(activity) } - override val unlockPrompt = childOrDefault(::AuthnPrompt) + internal val hasExplicitContext get() = + (this::fragment.isInitialized || this::activity.isInitialized) + + internal val _subtitle = Stackable() + /** @see [BiometricPrompt.PromptInfo.Builder.setSubtitle] */ + var subtitle by _subtitle + + internal val _description = Stackable() + /** @see [BiometricPrompt.PromptInfo.Builder.setDescription] */ + var description by _description + + internal val _confirmationRequired = Stackable() + /** @see [BiometricPrompt.PromptInfo.Builder.setConfirmationRequired] */ + var confirmationRequired by _confirmationRequired + + internal val _allowedAuthenticators = Stackable() + /** @see [BiometricPrompt.PromptInfo.Builder.setAllowedAuthenticators] */ + var allowedAuthenticators by _allowedAuthenticators + + /** if the provided fingerprint could not be matched, but the user will be allowed to retry */ + var invalidBiometryCallback: (()->Unit)? = null +} + +class AndroidSignerConfiguration: PlatformSignerConfigurationBase() { + override val unlockPrompt = childOrDefault(::AndroidUnlockPromptConfiguration) +} + +class AndroidSignerSigningConfiguration: PlatformSigningProviderSignerSigningConfigurationBase() { + override val unlockPrompt = childOrDefault(::AndroidUnlockPromptConfiguration) } /** @@ -129,7 +141,7 @@ private fun attestationFor(chain: CertificateChain) = * A provider that manages keys in the [Android Key Store](https://developer.android.com/privacy-and-security/keystore). */ object AndroidKeyStoreProvider: - SigningProviderI + PlatformSigningProviderI { private val ks: KeyStore get() = @@ -225,30 +237,15 @@ object AndroidKeyStoreProvider: } } - return@catching if (keyInfo.isUserAuthenticationRequired) { - when (certificateChain.leaf.publicKey) { - is CryptoPublicKey.EC -> - LockedAndroidKeystoreSigner.ECDSA( - jcaPrivateKey, alias, keyInfo, config, certificateChain, - algorithm as SignatureAlgorithm.ECDSA) - is CryptoPublicKey.Rsa -> - LockedAndroidKeystoreSigner.RSA( - jcaPrivateKey, alias, keyInfo, config, certificateChain, - algorithm as SignatureAlgorithm.RSA) - } - } else { - val jcaSig = algorithm.getJCASignatureInstance() - .getOrThrow().also { it.initSign(jcaPrivateKey) } - when (val publicKey = certificateChain.leaf.publicKey) { - is CryptoPublicKey.EC -> - UnlockedAndroidKeystoreSigner.ECDSA( - jcaSig, alias, keyInfo, attestationFor(certificateChain), publicKey, - algorithm as SignatureAlgorithm.ECDSA) - is CryptoPublicKey.Rsa -> - UnlockedAndroidKeystoreSigner.RSA( - jcaSig, alias, keyInfo, attestationFor(certificateChain), publicKey, - algorithm as SignatureAlgorithm.RSA) - } + return@catching when (certificateChain.leaf.publicKey) { + is CryptoPublicKey.EC -> + AndroidKeystoreSigner.ECDSA( + jcaPrivateKey, alias, keyInfo, config, certificateChain, + algorithm as SignatureAlgorithm.ECDSA) + is CryptoPublicKey.Rsa -> + AndroidKeystoreSigner.RSA( + jcaPrivateKey, alias, keyInfo, config, certificateChain, + algorithm as SignatureAlgorithm.RSA) } } @@ -257,85 +254,44 @@ object AndroidKeyStoreProvider: } } -sealed interface AndroidKeystoreSigner : SignerI.Attestable, SignerI.WithAlias { - /** @see KeyInfo */ - val keyInfo: KeyInfo - override val attestation: AndroidKeystoreAttestation? -} - -sealed class UnlockedAndroidKeystoreSigner private constructor( - internal val jcaSig: JCASignatureObject, - override val alias: String, - override val keyInfo: KeyInfo, - override val attestation: AndroidKeystoreAttestation? -): SignerI.UnlockedHandle, AndroidKeystoreSigner { - - class ECDSA internal constructor(jcaSig: JCASignatureObject, - alias: String, - keyInfo: KeyInfo, - certificateChain: AndroidKeystoreAttestation?, - override val publicKey: CryptoPublicKey.EC, - override val signatureAlgorithm: SignatureAlgorithm.ECDSA - ) : UnlockedAndroidKeystoreSigner(jcaSig, alias, keyInfo, certificateChain), SignerI.ECDSA - - class RSA internal constructor(jcaSig: JCASignatureObject, - alias: String, - keyInfo: KeyInfo, - certificateChain: AndroidKeystoreAttestation?, - override val publicKey: CryptoPublicKey.Rsa, - override val signatureAlgorithm: SignatureAlgorithm.RSA - ) : UnlockedAndroidKeystoreSigner(jcaSig, alias, keyInfo, certificateChain), SignerI.RSA - - final override suspend fun sign(data: SignatureInput) = catching { - require(data.format == null) - data.data.forEach(jcaSig::update) - val jcaSignature = jcaSig.sign() - when (this) { - is ECDSA -> CryptoSignature.EC.parseFromJca(jcaSignature) - is RSA -> CryptoSignature.RSAorHMAC.parseFromJca(jcaSignature) - } - } - - final override fun close() {} - -} - -sealed class LockedAndroidKeystoreSigner private constructor( +sealed class AndroidKeystoreSigner private constructor( internal val jcaPrivateKey: PrivateKey, final override val alias: String, - final override val keyInfo: KeyInfo, + val keyInfo: KeyInfo, private val config: AndroidSignerConfiguration, certificateChain: CertificateChain -) : SignerI.TemporarilyUnlockable(), AndroidKeystoreSigner { +) : PlatformSigningProviderSigner, SignerI.Attestable { final override val mayRequireUserUnlock: Boolean get() = this.needsAuthentication - private val explicitContext = config.unlockPrompt.v.explicitContext - final override val attestation = attestationFor(certificateChain) private sealed interface AuthResult { @JvmInline value class Success(val result: AuthenticationResult): AuthResult data class Error(val code: Int, val message: String): AuthResult } - private suspend fun attemptBiometry(config: AndroidSignerConfiguration.AuthnPrompt, forSpecificKey: CryptoObject?) { + private suspend fun attemptBiometry(config: DSL.ConfigStack, forSpecificKey: CryptoObject?) { val channel = Channel(capacity = Channel.RENDEZVOUS) - val effectiveContext = explicitContext ?: - (AppLifecycleMonitor.currentActivity as? FragmentActivity)?.let(FragmentContext::OfActivity) - ?: throw UnsupportedOperationException("The requested key with alias $alias requires unlock, but the current activity is not a FragmentActivity or could not be determined. " + - "Pass either { fragment = } or { activity = } inside authPrompt {}.") + val effectiveContext = config.getProperty(AndroidUnlockPromptConfiguration::explicitContext, + checker = AndroidUnlockPromptConfiguration::hasExplicitContext, default = { + (AppLifecycleMonitor.currentActivity as? FragmentActivity)?.let(FragmentContext::OfActivity) + ?: throw UnsupportedOperationException("The requested key with alias $alias requires unlock, but the current activity is not a FragmentActivity or could not be determined. " + + "Pass either { fragment = } or { activity = } inside authPrompt {}.") + }) val executor = when (effectiveContext) { is FragmentContext.OfActivity -> ContextCompat.getMainExecutor(effectiveContext.activity) is FragmentContext.OfFragment -> ContextCompat.getMainExecutor(effectiveContext.fragment.context) } executor.asCoroutineDispatcher().let(::CoroutineScope).launch { val promptInfo = BiometricPrompt.PromptInfo.Builder().apply { - setTitle(config.message) - setNegativeButtonText(config.cancelText) - config.subtitle?.let(this::setSubtitle) - config.description?.let(this::setDescription) - config.allowedAuthenticators?.let(this::setAllowedAuthenticators) - config.confirmationRequired?.let(this::setConfirmationRequired) + setTitle(config.getProperty(AndroidUnlockPromptConfiguration::_message, + default = UnlockPromptConfiguration.defaultMessage)) + setNegativeButtonText(config.getProperty(AndroidUnlockPromptConfiguration::_cancelText, + default = UnlockPromptConfiguration.defaultCancelText)) + config.getProperty(AndroidUnlockPromptConfiguration::_subtitle,null)?.let(this::setSubtitle) + config.getProperty(AndroidUnlockPromptConfiguration::_description,null)?.let(this::setDescription) + config.getProperty(AndroidUnlockPromptConfiguration::_allowedAuthenticators,null)?.let(this::setAllowedAuthenticators) + config.getProperty(AndroidUnlockPromptConfiguration::_confirmationRequired,null)?.let(this::setConfirmationRequired) }.build() val siphon = object: BiometricPrompt.AuthenticationCallback() { private fun send(v: AuthResult) { @@ -348,7 +304,7 @@ sealed class LockedAndroidKeystoreSigner private constructor( send(AuthResult.Error(errorCode, errString.toString())) } override fun onAuthenticationFailed() { - config.invalidBiometryCallback?.invoke() + config.forEach { it.invalidBiometryCallback?.invoke() } } } val prompt = when (effectiveContext) { @@ -366,23 +322,40 @@ sealed class LockedAndroidKeystoreSigner private constructor( } } - protected abstract fun toUnlocked(jcaSig: JCASignatureObject): UnlockedAndroidKeystoreSigner - - @HazardousMaterials - final override suspend fun unlock(): KmmResult = - signatureAlgorithm.getJCASignatureInstance().onSuccess { + internal suspend fun getJCASignature(signingConfig: AndroidSignerSigningConfiguration): Signature = + signatureAlgorithm.getJCASignatureInstance().getOrThrow().also { if (needsAuthenticationForEveryUse) { it.initSign(jcaPrivateKey) - attemptBiometry(config.unlockPrompt.v, CryptoObject(it)) + attemptBiometry(DSL.ConfigStack(signingConfig.unlockPrompt.v, config.unlockPrompt.v), CryptoObject(it)) } else { try { it.initSign(jcaPrivateKey) } catch (_: UserNotAuthenticatedException) { - attemptBiometry(config.unlockPrompt.v, null) + attemptBiometry(DSL.ConfigStack(signingConfig.unlockPrompt.v, config.unlockPrompt.v), null) it.initSign(jcaPrivateKey) } } - }.mapCatching(this::toUnlocked) + } + + final override suspend fun trySetupUninterruptedSigning(configure: DSLConfigureFn) = catching { + if (needsAuthentication && !needsAuthenticationForEveryUse) { + getJCASignature(DSL.resolve(::AndroidSignerSigningConfiguration, configure)) + } + } + + final override suspend fun sign( + data: SignatureInput, + configure: DSLConfigureFn + ): KmmResult = catching { + require(data.format == null) + val jcaSig = getJCASignature(DSL.resolve(::AndroidSignerSigningConfiguration, configure)) + .let { data.data.forEach(it::update); it.sign() } + + return@catching when (this) { + is ECDSA -> CryptoSignature.EC.parseFromJca(jcaSig).withCurve(publicKey.curve) + is RSA -> CryptoSignature.RSAorHMAC.parseFromJca(jcaSig) + } + } class ECDSA internal constructor(jcaPrivateKey: PrivateKey, alias: String, @@ -390,10 +363,8 @@ sealed class LockedAndroidKeystoreSigner private constructor( config: AndroidSignerConfiguration, certificateChain: CertificateChain, override val signatureAlgorithm: SignatureAlgorithm.ECDSA) - : LockedAndroidKeystoreSigner(jcaPrivateKey, alias, keyInfo, config, certificateChain), SignerI.ECDSA { + : AndroidKeystoreSigner(jcaPrivateKey, alias, keyInfo, config, certificateChain), SignerI.ECDSA { override val publicKey = certificateChain.leaf.publicKey as CryptoPublicKey.EC - override fun toUnlocked(jcaSig: Signature) = - UnlockedAndroidKeystoreSigner.ECDSA(jcaSig, alias, keyInfo, attestation, publicKey, signatureAlgorithm) } class RSA internal constructor(jcaPrivateKey: PrivateKey, @@ -402,10 +373,8 @@ sealed class LockedAndroidKeystoreSigner private constructor( config: AndroidSignerConfiguration, certificateChain: CertificateChain, override val signatureAlgorithm: SignatureAlgorithm.RSA) - : LockedAndroidKeystoreSigner(jcaPrivateKey, alias, keyInfo, config, certificateChain), SignerI.RSA { + : AndroidKeystoreSigner(jcaPrivateKey, alias, keyInfo, config, certificateChain), SignerI.RSA { override val publicKey = certificateChain.leaf.publicKey as CryptoPublicKey.Rsa - override fun toUnlocked(jcaSig: Signature) = - UnlockedAndroidKeystoreSigner.RSA(jcaSig, alias, keyInfo, attestation, publicKey, signatureAlgorithm) } } @@ -420,5 +389,5 @@ actual typealias PlatformSigningProviderSignerConfiguration = AndroidSignerConfi actual typealias PlatformSigningProviderSigningKeyConfiguration = AndroidSigningKeyConfiguration actual typealias PlatformSigningProvider = AndroidKeyStoreProvider actual typealias PlatformSigningProviderConfiguration = PlatformSigningProviderConfigurationBase*/ -internal actual fun getPlatformSigningProvider(configure: DSLConfigureFn): SigningProvider = +internal actual fun getPlatformSigningProvider(configure: DSLConfigureFn): PlatformSigningProvider = AndroidKeyStoreProvider diff --git a/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/dsl/ConfigurationDSL.kt b/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/dsl/ConfigurationDSL.kt index d068158b..cb73a08f 100644 --- a/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/dsl/ConfigurationDSL.kt +++ b/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/dsl/ConfigurationDSL.kt @@ -14,12 +14,18 @@ object DSL { /** A collection of equivalent DSL configuration structures which shadow each other. * @see getProperty */ - class ConfigStack(private vararg val stackedData: S) { + internal class ConfigStack(private vararg val stackedData: S): Iterable by stackedData.asIterable() { /** Retrieve a property from a stack of (partially-)configured DSL data. * Each element of the stack should have an indication of whether the property is set, and a value of the property (which is only accessed if the property is set). * This is commonly implemented using `lateinit var`s (with `internal val .. get() = this::prop.isInitialized` as the property checker).*/ - fun getProperty(getter: (S)->T, checker: (S)->Boolean, default: T): T = - when (val it = stackedData.firstOrNull(checker)) { null -> default; else -> getter(it) } + fun getProperty(getter: (S)->T, checker: (S)->Boolean, default: ()->T): T = + try { getter(stackedData.first(checker)) } catch (_: NoSuchElementException) { default() } + fun getProperty(getter: (S)->T, checker: (S)->Boolean, default: T) = + try { getter(stackedData.first(checker)) } catch (_: NoSuchElementException) { default } + fun getProperty(getter: (S)->Data.Stackable, default: ()->T) : T { + for (e in stackedData) { val d = getter(e); if (d.isSet) return d.value }; return default() } + fun getProperty(getter: (S)->Data.Stackable, default: T): T { + for (e in stackedData) { val d = getter(e); if (d.isSet) return d.value }; return default } } sealed interface Holder { @@ -143,6 +149,22 @@ object DSL { protected fun unsupported(why: String): Unsupported = Unsupported(why) + /** + * Convenience delegate for multiple points of configuration DSLs. + * It keeps track of whether the value has been explicitly set, and is compatible with [ConfigStack.getProperty]. + * + * Use as `internal val _foo = Stackable(); var foo by _foo`, then access as `stack.getProperty(DSLType::_foo, default = 42)`. + */ + internal class Stackable() { + private var _storage: T? = null + @Suppress("UNCHECKED_CAST") + internal val value: T get() { check(isSet); return _storage as T } + internal var isSet: Boolean = false + operator fun getValue(thisRef: Data, property: KProperty<*>): T { return value } + operator fun setValue(thisRef: Data, property: KProperty<*>, v: T) { _storage = v; isSet = true; } + + } + /** * Invoked by `DSL.resolve()` after the configuration block runs. * Can be used for sanity checks. diff --git a/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/os/Attestation.kt b/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/os/Attestation.kt index 319d7eac..552736b7 100644 --- a/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/os/Attestation.kt +++ b/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/os/Attestation.kt @@ -68,6 +68,7 @@ data class IosHomebrewAttestation( companion object { const val THE_PURPOSE = "ios app-attest: secure enclave protected key" } @Serializable + @ConsistentCopyVisibility data class ClientData private constructor( private val purpose: String, @Serializable(with=IosPublicKeySerializer::class) diff --git a/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/os/SigningProvider.kt b/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/os/SigningProvider.kt index 88ce6bf3..87a26a74 100644 --- a/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/os/SigningProvider.kt +++ b/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/os/SigningProvider.kt @@ -113,12 +113,14 @@ open class SignerConfiguration internal constructor(): DSL.Data() { } open class UnlockPromptConfiguration: DSL.Data() { + + internal val _message = Stackable() /** The prompt message to show to the user when asking for unlock */ - lateinit var message: String - internal val messageSpecified get() = this::message.isInitialized + var message by _message + + internal val _cancelText = Stackable() /** The message to show on the cancellation button */ - lateinit var cancelText: String - internal val cancelTextSpecified get() = this::cancelText.isInitialized + var cancelText by _cancelText companion object { const val defaultMessage = "Please authorize cryptographic signature" diff --git a/supreme/src/iosMain/kotlin/at/asitplus/signum/supreme/os/IosKeychainProvider.kt b/supreme/src/iosMain/kotlin/at/asitplus/signum/supreme/os/IosKeychainProvider.kt index edef076b..0ff0e784 100644 --- a/supreme/src/iosMain/kotlin/at/asitplus/signum/supreme/os/IosKeychainProvider.kt +++ b/supreme/src/iosMain/kotlin/at/asitplus/signum/supreme/os/IosKeychainProvider.kt @@ -183,7 +183,7 @@ sealed class IosSigner(final override val alias: String, val needsAuthentication get() = metadata.needsUnlock val needsAuthenticationForEveryUse get() = metadata.needsUnlock && (metadata.unlockTimeout == Duration.ZERO) - interface PrivateKeyManager { fun get(signingConfig: IosSignerSigningConfiguration): AutofreeVariable } + internal interface PrivateKeyManager { fun get(signingConfig: IosSignerSigningConfiguration): AutofreeVariable } internal val privateKeyManager = object : PrivateKeyManager { private var storedKey: AutofreeVariable? = null override fun get(signingConfig: IosSignerSigningConfiguration): AutofreeVariable { @@ -268,7 +268,9 @@ sealed class IosSigner(final override val alias: String, authnContext = ctx, authnTime = TimeSource.Monotonic.markNow()) Napier.v { "Successfully recorded LAContext for future re-use" } } - storedKey = newPrivateKey + if (!needsAuthenticationForEveryUse) { + storedKey = newPrivateKey + } return newPrivateKey } } From 6de1296314ee9f753ed580ff4ae85c09c47e2a5c Mon Sep 17 00:00:00 2001 From: Jakob Heher Date: Mon, 2 Sep 2024 11:19:22 +0200 Subject: [PATCH 45/73] requested adjustments android edition --- .../kotlin/at/asitplus/cryptotest/App.kt | 3 +- .../os/AndroidKeyStoreProviderTests.kt | 3 +- .../supreme/hazmat/InternalsAccessors.kt | 3 +- .../supreme/os/AndroidKeyStoreProvider.kt | 11 ++--- .../signum/supreme/SignatureResult.kt | 41 +++++++++++++++++++ .../at/asitplus/signum/supreme/Throwables.kt | 3 -- .../signum/supreme/os/SigningProvider.kt | 10 +---- .../at/asitplus/signum/supreme/sign/Signer.kt | 3 +- .../sign/EphemeralSignerCommonTests.kt | 7 ++-- .../signum/supreme/os/IosKeychainProvider.kt | 13 +++--- .../asitplus/signum/supreme/os/JKSProvider.kt | 5 --- .../signum/supreme/os/JKSProviderTest.kt | 12 ++++-- 12 files changed, 71 insertions(+), 43 deletions(-) create mode 100644 supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/SignatureResult.kt diff --git a/demoapp/composeApp/src/commonMain/kotlin/at/asitplus/cryptotest/App.kt b/demoapp/composeApp/src/commonMain/kotlin/at/asitplus/cryptotest/App.kt index 0c10dd70..3c7619b8 100644 --- a/demoapp/composeApp/src/commonMain/kotlin/at/asitplus/cryptotest/App.kt +++ b/demoapp/composeApp/src/commonMain/kotlin/at/asitplus/cryptotest/App.kt @@ -59,6 +59,7 @@ import at.asitplus.signum.supreme.os.PlatformSignerConfigurationBase import at.asitplus.signum.supreme.os.PlatformSigningProvider import at.asitplus.signum.supreme.os.SigningProvider import at.asitplus.signum.supreme.os.jsonEncoded +import at.asitplus.signum.supreme.wrap import io.github.aakira.napier.DebugAntilog import io.github.aakira.napier.Napier import io.ktor.util.decodeBase64Bytes @@ -464,7 +465,7 @@ internal fun App() { CoroutineScope(context).launch { val data = inputData.encodeToByteArray() currentSigner!! - .transform { it.sign(data) } + .transform { it.sign(data).wrap() } .also { signatureData = it; verifyState = null } } diff --git a/supreme/src/androidInstrumentedTest/kotlin/at/asitplus/signum/supreme/os/AndroidKeyStoreProviderTests.kt b/supreme/src/androidInstrumentedTest/kotlin/at/asitplus/signum/supreme/os/AndroidKeyStoreProviderTests.kt index 4ee2e43d..046cf92b 100644 --- a/supreme/src/androidInstrumentedTest/kotlin/at/asitplus/signum/supreme/os/AndroidKeyStoreProviderTests.kt +++ b/supreme/src/androidInstrumentedTest/kotlin/at/asitplus/signum/supreme/os/AndroidKeyStoreProviderTests.kt @@ -5,6 +5,7 @@ import at.asitplus.signum.indispensable.SignatureAlgorithm import at.asitplus.signum.supreme.sign.sign import at.asitplus.signum.supreme.sign.verifierFor import at.asitplus.signum.supreme.sign.verify +import at.asitplus.signum.supreme.signature import br.com.colman.kotest.FreeSpec import io.kotest.matchers.types.shouldBeInstanceOf import io.kotest.property.azstring @@ -25,7 +26,7 @@ class AndroidKeyStoreProviderTests: FreeSpec({ publicKey.shouldBeInstanceOf() val plaintext = Random.nextBytes(64) - val signature = hardwareSigner.sign(plaintext).getOrThrow() + val signature = hardwareSigner.sign(plaintext).signature SignatureAlgorithm.ECDSAwithSHA256.verifierFor(publicKey).getOrThrow() .verify(plaintext, signature).getOrThrow() diff --git a/supreme/src/androidMain/kotlin/at/asitplus/signum/supreme/hazmat/InternalsAccessors.kt b/supreme/src/androidMain/kotlin/at/asitplus/signum/supreme/hazmat/InternalsAccessors.kt index eb8b266a..126900aa 100644 --- a/supreme/src/androidMain/kotlin/at/asitplus/signum/supreme/hazmat/InternalsAccessors.kt +++ b/supreme/src/androidMain/kotlin/at/asitplus/signum/supreme/hazmat/InternalsAccessors.kt @@ -1,5 +1,6 @@ package at.asitplus.signum.supreme.hazmat +import at.asitplus.signum.indispensable.getJCASignatureInstance import at.asitplus.signum.supreme.HazardousMaterials import at.asitplus.signum.supreme.os.AndroidKeystoreSigner import at.asitplus.signum.supreme.sign.AndroidEphemeralSigner @@ -12,7 +13,7 @@ import java.security.PrivateKey @HazardousMaterials val EphemeralKey.jcaPrivateKey get() = (this as? EphemeralKeyBase<*>)?.privateKey as? PrivateKey -/** The underlying JCA [PrivateKey] object. Not available for unlocked KeyStore signers; see [jcaSignatureInstance]. */ +/** The underlying JCA [PrivateKey] object. */ @HazardousMaterials val Signer.jcaPrivateKey get() = when (this) { is AndroidEphemeralSigner -> this.privateKey diff --git a/supreme/src/androidMain/kotlin/at/asitplus/signum/supreme/os/AndroidKeyStoreProvider.kt b/supreme/src/androidMain/kotlin/at/asitplus/signum/supreme/os/AndroidKeyStoreProvider.kt index afe7d160..95aef0cb 100644 --- a/supreme/src/androidMain/kotlin/at/asitplus/signum/supreme/os/AndroidKeyStoreProvider.kt +++ b/supreme/src/androidMain/kotlin/at/asitplus/signum/supreme/os/AndroidKeyStoreProvider.kt @@ -25,6 +25,7 @@ import at.asitplus.signum.indispensable.pki.CertificateChain import at.asitplus.signum.indispensable.pki.X509Certificate import at.asitplus.signum.indispensable.pki.leaf import at.asitplus.signum.supreme.AppLifecycleMonitor +import at.asitplus.signum.supreme.SignatureResult import at.asitplus.signum.supreme.UnlockFailed import at.asitplus.signum.supreme.UnsupportedCryptoException import at.asitplus.signum.supreme.dsl.DISCOURAGED @@ -35,6 +36,7 @@ import at.asitplus.signum.supreme.dsl.PREFERRED import at.asitplus.signum.supreme.dsl.REQUIRED import at.asitplus.signum.supreme.sign.SignatureInput import at.asitplus.signum.supreme.sign.SigningKeyConfiguration +import at.asitplus.signum.supreme.signCatching import com.ionspin.kotlin.bignum.integer.base63.toJavaBigInteger import at.asitplus.signum.supreme.sign.Signer as SignerI import kotlinx.coroutines.CoroutineScope @@ -346,12 +348,12 @@ sealed class AndroidKeystoreSigner private constructor( final override suspend fun sign( data: SignatureInput, configure: DSLConfigureFn - ): KmmResult = catching { + ): SignatureResult = signCatching { require(data.format == null) val jcaSig = getJCASignature(DSL.resolve(::AndroidSignerSigningConfiguration, configure)) .let { data.data.forEach(it::update); it.sign() } - return@catching when (this) { + return@signCatching when (this) { is ECDSA -> CryptoSignature.EC.parseFromJca(jcaSig).withCurve(publicKey.curve) is RSA -> CryptoSignature.RSAorHMAC.parseFromJca(jcaSig) } @@ -384,10 +386,5 @@ val AndroidKeystoreSigner.needsAuthenticationForEveryUse inline get() = keyInfo.isUserAuthenticationRequired && (keyInfo.userAuthenticationValidityDurationSeconds <= 0) -/*actual typealias PlatformSigningProviderSigner = AndroidKeystoreSigner -actual typealias PlatformSigningProviderSignerConfiguration = AndroidSignerConfiguration -actual typealias PlatformSigningProviderSigningKeyConfiguration = AndroidSigningKeyConfiguration -actual typealias PlatformSigningProvider = AndroidKeyStoreProvider -actual typealias PlatformSigningProviderConfiguration = PlatformSigningProviderConfigurationBase*/ internal actual fun getPlatformSigningProvider(configure: DSLConfigureFn): PlatformSigningProvider = AndroidKeyStoreProvider diff --git a/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/SignatureResult.kt b/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/SignatureResult.kt new file mode 100644 index 00000000..aebedbc3 --- /dev/null +++ b/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/SignatureResult.kt @@ -0,0 +1,41 @@ +package at.asitplus.signum.supreme + +import at.asitplus.KmmResult +import at.asitplus.catching +import at.asitplus.signum.indispensable.CryptoSignature +import kotlin.jvm.JvmInline + +/** These map to SignatureResult.Failure instead of SignatureResult.Error */ +sealed class UserInitiatedCancellationReason(message: String?, cause: Throwable?): Throwable(message, cause) +class UnlockFailed(message: String? = null, cause: Throwable? = null) : UserInitiatedCancellationReason(message, cause) + +sealed interface SignatureResult { + @JvmInline + value class Success(val signature: CryptoSignature): SignatureResult + @JvmInline + value class Failure(val problem: UserInitiatedCancellationReason): SignatureResult + @JvmInline + value class Error(val exception: Throwable): SignatureResult +} +val SignatureResult.isSuccess get() = (this is SignatureResult.Success) +/** Retrieves the contained signature, asserting it exists. If it does not exist, throws the contained problem. */ +val SignatureResult.signature: CryptoSignature get() = when (this) { + is SignatureResult.Success -> this.signature + is SignatureResult.Failure -> throw this.problem + is SignatureResult.Error -> throw this.exception +} +/** Retrieves the contained signature, if one exists. */ +val SignatureResult.signatureOrNull: CryptoSignature? get() = when (this) { + is SignatureResult.Success -> this.signature + else -> null +} +/** Transforms this SignatureResult into a [KmmResult]. Both [Failure] and [Error] map to [KmmResult.Failure]. */ +fun SignatureResult.wrap(): KmmResult = catching { this.signature } + +internal inline fun signCatching(fn: ()->CryptoSignature): SignatureResult = + runCatching { fn() }.fold( + onSuccess = SignatureResult::Success, + onFailure = { + if (it is UserInitiatedCancellationReason) SignatureResult.Failure(it) + else SignatureResult.Error(it) + }) diff --git a/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/Throwables.kt b/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/Throwables.kt index 3b70e06b..ff4c5257 100644 --- a/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/Throwables.kt +++ b/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/Throwables.kt @@ -6,7 +6,4 @@ annotation class HazardousMaterials sealed class CryptoException(message: String? = null, cause: Throwable? = null) : Throwable(message, cause) open class CryptoOperationFailed(message: String) : CryptoException(message) - open class UnsupportedCryptoException(message: String? = null, cause: Throwable? = null) : CryptoException(message, cause) - -class UnlockFailed(message: String? = null, cause: Throwable? = null) : Throwable(message, cause) diff --git a/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/os/SigningProvider.kt b/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/os/SigningProvider.kt index 87a26a74..28c50ec5 100644 --- a/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/os/SigningProvider.kt +++ b/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/os/SigningProvider.kt @@ -5,6 +5,7 @@ import at.asitplus.catching import at.asitplus.signum.indispensable.CryptoSignature import at.asitplus.signum.indispensable.Digest import at.asitplus.signum.indispensable.RSAPadding +import at.asitplus.signum.supreme.SignatureResult import at.asitplus.signum.supreme.dsl.DISCOURAGED import at.asitplus.signum.supreme.dsl.DSL import at.asitplus.signum.supreme.dsl.DSLConfigureFn @@ -142,7 +143,7 @@ interface PlatformSigningProviderSigner = null) : KmmResult = KmmResult.success(Unit) override suspend fun trySetupUninterruptedSigning() = trySetupUninterruptedSigning(null) - suspend fun sign(data: SignatureInput, configure: DSLConfigureFn = null) : KmmResult + suspend fun sign(data: SignatureInput, configure: DSLConfigureFn = null) : SignatureResult suspend fun sign(data: ByteArray, configure: DSLConfigureFn = null) = sign(SignatureInput(data), configure) suspend fun sign(data: Sequence, configure: DSLConfigureFn = null) = @@ -153,13 +154,6 @@ interface PlatformSigningProviderSigner -expect class PlatformSigningProvider : SigningProviderI -internal expect fun makePlatformSigningProvider(config: PlatformSigningProviderConfiguration): KmmResult*/ internal expect fun getPlatformSigningProvider(configure: DSLConfigureFn): PlatformSigningProvider /** KT-71089 workaround diff --git a/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/sign/Signer.kt b/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/sign/Signer.kt index fff7cef4..e3f7da47 100644 --- a/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/sign/Signer.kt +++ b/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/sign/Signer.kt @@ -10,6 +10,7 @@ import at.asitplus.signum.indispensable.RSAPadding import at.asitplus.signum.indispensable.SignatureAlgorithm import at.asitplus.signum.indispensable.nativeDigest import at.asitplus.signum.supreme.HazardousMaterials +import at.asitplus.signum.supreme.SignatureResult import at.asitplus.signum.supreme.UnlockFailed import at.asitplus.signum.supreme.dsl.DSL import at.asitplus.signum.supreme.dsl.DSLConfigureFn @@ -120,7 +121,7 @@ interface Signer { suspend fun trySetupUninterruptedSigning(): KmmResult = KmmResult.success(Unit) /** Signs data. Might ask for user confirmation first if this [Signer] [mayRequireUserUnlock]. */ - suspend fun sign(data: SignatureInput): KmmResult + suspend fun sign(data: SignatureInput): SignatureResult suspend fun sign(data: ByteArray) = sign(SignatureInput(data)) suspend fun sign(data: Sequence) = sign(SignatureInput(data)) diff --git a/supreme/src/commonTest/kotlin/at/asitplus/signum/supreme/sign/EphemeralSignerCommonTests.kt b/supreme/src/commonTest/kotlin/at/asitplus/signum/supreme/sign/EphemeralSignerCommonTests.kt index e401e2c8..1a7051ee 100644 --- a/supreme/src/commonTest/kotlin/at/asitplus/signum/supreme/sign/EphemeralSignerCommonTests.kt +++ b/supreme/src/commonTest/kotlin/at/asitplus/signum/supreme/sign/EphemeralSignerCommonTests.kt @@ -1,10 +1,10 @@ package at.asitplus.signum.supreme.sign -import at.asitplus.catching import at.asitplus.signum.indispensable.Digest import at.asitplus.signum.indispensable.ECCurve import at.asitplus.signum.indispensable.RSAPadding import at.asitplus.signum.indispensable.SignatureAlgorithm +import at.asitplus.signum.supreme.signature import at.asitplus.signum.supreme.succeed import com.ionspin.kotlin.bignum.integer.Quadruple import io.kotest.core.spec.style.FreeSpec @@ -13,7 +13,6 @@ import io.kotest.matchers.collections.shouldBeIn import io.kotest.matchers.should import io.kotest.matchers.shouldBe import io.kotest.matchers.types.shouldBeInstanceOf -import kotlinx.coroutines.cancel import kotlin.random.Random class EphemeralSignerCommonTests : FreeSpec({ @@ -36,7 +35,7 @@ class EphemeralSignerCommonTests : FreeSpec({ val signer: Signer val signature = try { signer = Signer.Ephemeral { rsa { digests = setOf(digest); paddings = setOf(padding); bits = keySize } }.getOrThrow() - signer.sign(SignatureInput(data).let { if (preHashed) it.convertTo(digest).getOrThrow() else it }).getOrThrow() + signer.sign(SignatureInput(data).let { if (preHashed) it.convertTo(digest).getOrThrow() else it }).signature } catch (x: UnsupportedOperationException) { return@withData } @@ -64,7 +63,7 @@ class EphemeralSignerCommonTests : FreeSpec({ it.requiredCurve shouldBeIn setOf(null, crv) } val data = Random.Default.nextBytes(64) - val signature = signer.sign(SignatureInput(data).let { if (preHashed) it.convertTo(digest).getOrThrow() else it }).getOrThrow() + val signature = signer.sign(SignatureInput(data).let { if (preHashed) it.convertTo(digest).getOrThrow() else it }).signature val verifier = signer.makeVerifier().getOrThrow() verifier.verify(data, signature) should succeed diff --git a/supreme/src/iosMain/kotlin/at/asitplus/signum/supreme/os/IosKeychainProvider.kt b/supreme/src/iosMain/kotlin/at/asitplus/signum/supreme/os/IosKeychainProvider.kt index 0ff0e784..3d6ee59a 100644 --- a/supreme/src/iosMain/kotlin/at/asitplus/signum/supreme/os/IosKeychainProvider.kt +++ b/supreme/src/iosMain/kotlin/at/asitplus/signum/supreme/os/IosKeychainProvider.kt @@ -99,8 +99,10 @@ import platform.Security.kSecUseAuthenticationUIAllow import at.asitplus.signum.indispensable.secKeyAlgorithm import at.asitplus.signum.indispensable.secKeyAlgorithmPreHashed import at.asitplus.signum.supreme.AutofreeVariable +import at.asitplus.signum.supreme.SignatureResult import at.asitplus.signum.supreme.sign.SigningKeyConfiguration import at.asitplus.signum.supreme.sign.preHashedSignatureFormat +import at.asitplus.signum.supreme.signCatching import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import platform.Security.kSecUseAuthenticationUIFail @@ -284,8 +286,8 @@ sealed class IosSigner(final override val alias: String, } } protected abstract fun bytesToSignature(sigBytes: ByteArray): CryptoSignature - final override suspend fun sign(data: SignatureInput, configure: DSLConfigureFn): KmmResult = - withContext(keychainThreads) { catching { + final override suspend fun sign(data: SignatureInput, configure: DSLConfigureFn): SignatureResult = + withContext(keychainThreads) { signCatching { require(data.format == null) { "Pre-hashed data is unsupported on iOS" } val signingConfig = DSL.resolve(::IosSignerSigningConfiguration, configure) val algorithm = signatureAlgorithm.secKeyAlgorithmPreHashed @@ -293,7 +295,7 @@ sealed class IosSigner(final override val alias: String, val signatureBytes = corecall { SecKeyCreateSignature(privateKeyManager.get(signingConfig).value, algorithm, plaintext.giveToCF(), error) }.takeFromCF().toByteArray() - return@catching bytesToSignature(signatureBytes) + return@signCatching bytesToSignature(signatureBytes) }} class ECDSA internal constructor @@ -619,10 +621,5 @@ object IosKeychainProvider: PlatformSigningProviderI -actual typealias PlatformSigningProviderSignerConfiguration = iosSignerConfiguration -actual typealias PlatformSigningProviderSigningKeyConfiguration = iosSigningKeyConfiguration -actual typealias PlatformSigningProvider = IosKeychainProvider -actual typealias PlatformSigningProviderConfiguration = PlatformSigningProviderConfigurationBase*/ internal actual fun getPlatformSigningProvider(configure: DSLConfigureFn): PlatformSigningProvider = IosKeychainProvider diff --git a/supreme/src/jvmMain/kotlin/at/asitplus/signum/supreme/os/JKSProvider.kt b/supreme/src/jvmMain/kotlin/at/asitplus/signum/supreme/os/JKSProvider.kt index 0908e138..399bbad6 100644 --- a/supreme/src/jvmMain/kotlin/at/asitplus/signum/supreme/os/JKSProvider.kt +++ b/supreme/src/jvmMain/kotlin/at/asitplus/signum/supreme/os/JKSProvider.kt @@ -395,10 +395,5 @@ internal /*actual*/ fun makePlatformSigningProvider(config: JKSProviderConfigura JKSProvider(opt.accessor) } -/*actual typealias PlatformSigningProviderSigner = JKSSigner -actual typealias PlatformSigningProviderSignerConfiguration = JKSSignerConfiguration -actual typealias PlatformSigningProviderSigningKeyConfiguration = JKSSigningKeyConfiguration -actual typealias PlatformSigningProvider = JKSProvider -actual typealias PlatformSigningProviderConfiguration = JKSProviderConfiguration*/ internal actual fun getPlatformSigningProvider(configure: DSLConfigureFn): PlatformSigningProvider = throw UnsupportedOperationException("No default persistence mode is available on the JVM. Use JKSProvider {file {}} or similar. This will be natively available from the getPlatformSigningProvider {} DSL in a future release. (Blocked by KT-71036.)") diff --git a/supreme/src/jvmTest/kotlin/at/asitplus/signum/supreme/os/JKSProviderTest.kt b/supreme/src/jvmTest/kotlin/at/asitplus/signum/supreme/os/JKSProviderTest.kt index ec62f06b..f5cf2687 100644 --- a/supreme/src/jvmTest/kotlin/at/asitplus/signum/supreme/os/JKSProviderTest.kt +++ b/supreme/src/jvmTest/kotlin/at/asitplus/signum/supreme/os/JKSProviderTest.kt @@ -3,6 +3,7 @@ package at.asitplus.signum.supreme.os import at.asitplus.signum.supreme.sign.makeVerifier import at.asitplus.signum.supreme.sign.sign import at.asitplus.signum.supreme.sign.verify +import at.asitplus.signum.supreme.signature import at.asitplus.signum.supreme.succeed import io.kotest.core.spec.style.FreeSpec import io.kotest.matchers.should @@ -22,18 +23,20 @@ class JKSProviderTest : FreeSpec({ otherSigner.attestation shouldBe signer.attestation val data = Random.Default.nextBytes(64) - val signature = signer.sign(data).getOrThrow() + val signature = signer.sign(data).signature otherSigner.makeVerifier().getOrThrow().verify(data, signature) should succeed } "File-based persistence" { val tempfile = Files.createTempFile(Random.azstring(16),null).also { Files.delete(it) } try { val alias = "Elfenbeinturm" + val correctPassword = "Schwertfischfilet".toCharArray() + val wrongPassword = "Bartfischfilet".toCharArray() val ks1 = JKSProvider { file { file = tempfile - password = "Schwertfischfilet".toCharArray() + password = correctPassword } }.getOrThrow().also { it.getSignerForKey(alias) shouldNot succeed @@ -48,7 +51,7 @@ class JKSProviderTest : FreeSpec({ JKSProvider { file { file = tempfile - password = "Bartfischfilet".toCharArray() + password = wrongPassword } }.getOrThrow().let { // wrong password should fail @@ -58,13 +61,14 @@ class JKSProviderTest : FreeSpec({ JKSProvider { file { file = tempfile - password = "Schwertfischfilet".toCharArray() + password = correctPassword } }.getOrThrow().let { it.getSignerForKey(alias) should succeed it.deleteSigningKey(alias) } + // check that ks1 "sees" the deletion that was made by ks3 ks1.getSignerForKey(alias) shouldNot succeed } finally { Files.deleteIfExists(tempfile) } } From 40861542f4aca65607d7a88f04eadd414d4deb16 Mon Sep 17 00:00:00 2001 From: Jakob Heher Date: Mon, 2 Sep 2024 14:08:02 +0200 Subject: [PATCH 46/73] requested changes ios edition --- .../supreme/os/AndroidKeyStoreProvider.kt | 13 ++--- .../signum/supreme/sign/EphemeralKeysImpl.kt | 6 +-- .../signum/supreme/os/SigningProvider.kt | 1 - .../at/asitplus/signum/supreme/sign/Signer.kt | 11 ++-- .../asitplus/signum/supreme/InteropUtils.kt | 11 ++-- .../signum/supreme/os/IosKeychainProvider.kt | 54 ++++++++++++------- .../signum/supreme/sign/EphemeralKeysImpl.kt | 6 +-- .../signum/supreme/sign/EphemeralKeysImpl.kt | 11 ++-- 8 files changed, 62 insertions(+), 51 deletions(-) diff --git a/supreme/src/androidMain/kotlin/at/asitplus/signum/supreme/os/AndroidKeyStoreProvider.kt b/supreme/src/androidMain/kotlin/at/asitplus/signum/supreme/os/AndroidKeyStoreProvider.kt index 95aef0cb..04db80c8 100644 --- a/supreme/src/androidMain/kotlin/at/asitplus/signum/supreme/os/AndroidKeyStoreProvider.kt +++ b/supreme/src/androidMain/kotlin/at/asitplus/signum/supreme/os/AndroidKeyStoreProvider.kt @@ -113,14 +113,15 @@ class AndroidSignerSigningConfiguration: PlatformSigningProviderSignerSigningCon } /** - * Resolve [what] differently based on whether the [v]alue was [spec]ified. + * Resolve [what] differently based on whether the [vA]lue was [spec]ified. * - * * [spec] = `true`: Check if [valid] contains [nameMap] applied to [v], return [v] if yes, throw otherwise + * * [spec] = `true`: Check if [valid] contains [nameMap] applied to [vA()][vA], return [vA()][vA] if yes, throw otherwise * * [spec] = `false`: Check if [valid] contains exactly one element, if yes, return the [E] from [possible] for which [nameMap] returns that element, throw otherwise */ -internal inline fun resolveOption(what: String, valid: Array, possible: Sequence, spec: Boolean, v: E, crossinline nameMap: (E)->String): E = +internal inline fun resolveOption(what: String, valid: Array, possible: Sequence, spec: Boolean, vA: ()->E, crossinline nameMap: (E)->String): E = when (spec) { true -> { + val v = vA() val vStr = nameMap(v) if (!valid.any { it.equals(vStr, ignoreCase=true) }) throw IllegalArgumentException("Key does not support $what $v; supported: ${valid.joinToString(", ")}") @@ -223,13 +224,13 @@ object AndroidKeyStoreProvider: val algorithm = when (val publicKey = certificateChain.leaf.publicKey) { is CryptoPublicKey.EC -> { val ecConfig = config.ec.v - val digest = resolveOption("digest", keyInfo.digests, Digest.entries.asSequence() + sequenceOf(null), ecConfig.digestSpecified, ecConfig.digest) { it?.jcaName ?: KeyProperties.DIGEST_NONE } + val digest = resolveOption("digest", keyInfo.digests, Digest.entries.asSequence() + sequenceOf(null), ecConfig.digestSpecified, { ecConfig.digest }) { it?.jcaName ?: KeyProperties.DIGEST_NONE } SignatureAlgorithm.ECDSA(digest, publicKey.curve) } is CryptoPublicKey.Rsa -> { val rsaConfig = config.rsa.v - val digest = resolveOption("digest", keyInfo.digests, Digest.entries.asSequence(), rsaConfig.digestSpecified, rsaConfig.digest, Digest::jcaName) - val padding = resolveOption("padding", keyInfo.signaturePaddings, RSAPadding.entries.asSequence(), rsaConfig.paddingSpecified, rsaConfig.padding) { + val digest = resolveOption("digest", keyInfo.digests, Digest.entries.asSequence(), rsaConfig.digestSpecified, { rsaConfig.digest }, Digest::jcaName) + val padding = resolveOption("padding", keyInfo.signaturePaddings, RSAPadding.entries.asSequence(), rsaConfig.paddingSpecified, { rsaConfig.padding }) { when (it) { RSAPadding.PKCS1 -> KeyProperties.SIGNATURE_PADDING_RSA_PKCS1 RSAPadding.PSS -> KeyProperties.SIGNATURE_PADDING_RSA_PSS diff --git a/supreme/src/androidMain/kotlin/at/asitplus/signum/supreme/sign/EphemeralKeysImpl.kt b/supreme/src/androidMain/kotlin/at/asitplus/signum/supreme/sign/EphemeralKeysImpl.kt index 59e18626..a6188d7d 100644 --- a/supreme/src/androidMain/kotlin/at/asitplus/signum/supreme/sign/EphemeralKeysImpl.kt +++ b/supreme/src/androidMain/kotlin/at/asitplus/signum/supreme/sign/EphemeralKeysImpl.kt @@ -1,7 +1,6 @@ package at.asitplus.signum.supreme.sign import android.security.keystore.KeyProperties -import at.asitplus.catching import at.asitplus.signum.indispensable.CryptoPublicKey import at.asitplus.signum.indispensable.CryptoSignature import at.asitplus.signum.indispensable.SignatureAlgorithm @@ -9,6 +8,7 @@ import at.asitplus.signum.indispensable.fromJcaPublicKey import at.asitplus.signum.indispensable.getJCASignatureInstancePreHashed import at.asitplus.signum.indispensable.jcaName import at.asitplus.signum.indispensable.parseFromJca +import at.asitplus.signum.supreme.signCatching import com.ionspin.kotlin.bignum.integer.base63.toJavaBigInteger import java.security.KeyPairGenerator import java.security.PrivateKey @@ -20,7 +20,7 @@ actual class EphemeralSignerConfiguration internal actual constructor(): Ephemer sealed class AndroidEphemeralSigner (internal val privateKey: PrivateKey) : Signer { override val mayRequireUserUnlock = false - override suspend fun sign(data: SignatureInput) = catching { + override suspend fun sign(data: SignatureInput) = signCatching { val inputData = data.convertTo(signatureAlgorithm.preHashedSignatureFormat).getOrThrow() signatureAlgorithm.getJCASignatureInstancePreHashed(provider = null).getOrThrow().run { initSign(privateKey) @@ -57,7 +57,7 @@ internal actual fun makeEphemeralKey(configuration: EphemeralSigningKeyConfigura generateKeyPair() }.let { pair -> EphemeralKeyBase.RSA(AndroidEphemeralSigner::RSA, - pair.private, CryptoPublicKey.fromJcaPublicKey(pair.public) as CryptoPublicKey.Rsa, + pair.private, CryptoPublicKey.fromJcaPublicKey(pair.public).getOrThrow() as CryptoPublicKey.Rsa, digests = alg.digests, paddings = alg.paddings) } } diff --git a/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/os/SigningProvider.kt b/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/os/SigningProvider.kt index 28c50ec5..e5dbac03 100644 --- a/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/os/SigningProvider.kt +++ b/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/os/SigningProvider.kt @@ -2,7 +2,6 @@ package at.asitplus.signum.supreme.os import at.asitplus.KmmResult import at.asitplus.catching -import at.asitplus.signum.indispensable.CryptoSignature import at.asitplus.signum.indispensable.Digest import at.asitplus.signum.indispensable.RSAPadding import at.asitplus.signum.supreme.SignatureResult diff --git a/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/sign/Signer.kt b/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/sign/Signer.kt index e3f7da47..12d42764 100644 --- a/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/sign/Signer.kt +++ b/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/sign/Signer.kt @@ -1,23 +1,18 @@ package at.asitplus.signum.supreme.sign import at.asitplus.KmmResult -import at.asitplus.catching import at.asitplus.signum.indispensable.CryptoPublicKey -import at.asitplus.signum.indispensable.CryptoSignature import at.asitplus.signum.indispensable.Digest import at.asitplus.signum.indispensable.ECCurve import at.asitplus.signum.indispensable.RSAPadding import at.asitplus.signum.indispensable.SignatureAlgorithm import at.asitplus.signum.indispensable.nativeDigest -import at.asitplus.signum.supreme.HazardousMaterials import at.asitplus.signum.supreme.SignatureResult -import at.asitplus.signum.supreme.UnlockFailed import at.asitplus.signum.supreme.dsl.DSL import at.asitplus.signum.supreme.dsl.DSLConfigureFn import at.asitplus.signum.supreme.os.Attestation import at.asitplus.signum.supreme.os.SigningProvider import com.ionspin.kotlin.bignum.integer.BigInteger -import io.matthewnelson.encoding.base16.Base16 /** DSL for configuring a signing key. * @@ -74,13 +69,13 @@ open class SigningKeyConfiguration internal constructor(): DSL.Data() { * ``` * This will generate a throwaway [EphemeralKey] and return a Signer for it. * - * Any actual instantiation will have a [AlgTrait], which will be either [ECDSA] or [RSA]. + * Any actual instantiation will have an [AlgTrait], which will be either [ECDSA] or [RSA]. * Instantiations may also be [WithAlias], usually because they come from a [SigningProvider]. * They may also be [Attestable]. * * Some signers [mayRequireUserUnlock]. If needed, they will ask for user interaction when you try to [sign] data. - * Of these signers, some are also [Signer.TemporarilyUnlockable]. - * These signers can be used to sign multiple times in rapid succession with only a single user interaction. + * You can try to authenticate a signer ahead of time using [trySetupUninterruptedSigning]; but it might do nothing for some Signers. + * There is never a guarantee that signing is uninterrupted if [mayRequireUserUnlock] is true. * */ interface Signer { diff --git a/supreme/src/iosMain/kotlin/at/asitplus/signum/supreme/InteropUtils.kt b/supreme/src/iosMain/kotlin/at/asitplus/signum/supreme/InteropUtils.kt index fb8ffd50..a6e564af 100644 --- a/supreme/src/iosMain/kotlin/at/asitplus/signum/supreme/InteropUtils.kt +++ b/supreme/src/iosMain/kotlin/at/asitplus/signum/supreme/InteropUtils.kt @@ -29,7 +29,7 @@ import kotlin.native.ref.createCleaner @OptIn(ExperimentalNativeApi::class) class AutofreeVariable> internal constructor( - private val arena: Arena, + arena: Arena, private val variable: CPointerVarOf) { companion object { internal inline operator fun > invoke(): AutofreeVariable { @@ -38,6 +38,7 @@ class AutofreeVariable> internal constructor( return AutofreeVariable(arena, variable) } } + @Suppress("UNUSED") private val cleaner = createCleaner(arena, Arena::clear) internal val ptr get() = variable.ptr internal val value get() = variable.value @@ -55,14 +56,14 @@ internal fun ByteArray.toNSData(): NSData = memScoped { } private fun NSError.toNiceString(): String { - val sb = StringBuilder("[Code $code] $localizedDescription\n") + val sb = StringBuilder("[${if(domain != null) "$domain error, " else ""}code $code] $localizedDescription\n") localizedFailureReason?.let { sb.append("Because: $it") } localizedRecoverySuggestion?.let { sb.append("Try: $it") } localizedRecoveryOptions?.let { sb.append("Try also:\n - ${it.joinToString("\n - ")}\n") } return sb.toString() } -class CFCryptoOperationFailed(thing: String, osStatus: OSStatus) : CryptoOperationFailed(buildMessage(thing, osStatus)) { +class CFCryptoOperationFailed(thing: String, val osStatus: OSStatus) : CryptoOperationFailed(buildMessage(thing, osStatus)) { companion object { private fun buildMessage(thing: String, osStatus: OSStatus): String { val errorMessage = SecCopyErrorMessageString(osStatus, null).takeFromCF() @@ -71,7 +72,7 @@ class CFCryptoOperationFailed(thing: String, osStatus: OSStatus) : CryptoOperati } } -class CoreFoundationException(message: String): Throwable(message) +class CoreFoundationException(val nsError: NSError): Throwable(nsError.toNiceString()) internal class corecall private constructor(val error: CPointer) { /** Helper for calling Core Foundation functions, and bridging exceptions across. * @@ -92,7 +93,7 @@ internal class corecall private constructor(val error: CPointer) when { (result != null) && (error == null) -> return result (result == null) && (error != null) -> - throw CoreFoundationException(error.takeFromCF().toNiceString()) + throw CoreFoundationException(error.takeFromCF()) else -> throw IllegalStateException("Invalid state returned by Core Foundation call") } } diff --git a/supreme/src/iosMain/kotlin/at/asitplus/signum/supreme/os/IosKeychainProvider.kt b/supreme/src/iosMain/kotlin/at/asitplus/signum/supreme/os/IosKeychainProvider.kt index 3d6ee59a..c3228b40 100644 --- a/supreme/src/iosMain/kotlin/at/asitplus/signum/supreme/os/IosKeychainProvider.kt +++ b/supreme/src/iosMain/kotlin/at/asitplus/signum/supreme/os/IosKeychainProvider.kt @@ -96,19 +96,24 @@ import platform.Security.kSecReturnRef import platform.Security.kSecUseAuthenticationContext import platform.Security.kSecUseAuthenticationUI import platform.Security.kSecUseAuthenticationUIAllow -import at.asitplus.signum.indispensable.secKeyAlgorithm import at.asitplus.signum.indispensable.secKeyAlgorithmPreHashed import at.asitplus.signum.supreme.AutofreeVariable +import at.asitplus.signum.supreme.CoreFoundationException import at.asitplus.signum.supreme.SignatureResult +import at.asitplus.signum.supreme.UnlockFailed import at.asitplus.signum.supreme.sign.SigningKeyConfiguration import at.asitplus.signum.supreme.sign.preHashedSignatureFormat import at.asitplus.signum.supreme.signCatching import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable +import platform.LocalAuthentication.LAErrorAuthenticationFailed +import platform.LocalAuthentication.LAErrorBiometryLockout +import platform.LocalAuthentication.LAErrorDomain +import platform.LocalAuthentication.LAErrorUserCancel +import platform.Security.errSecAuthFailed +import platform.Security.errSecUnsupportedKeyFormat +import platform.Security.errSecUserCanceled import platform.Security.kSecUseAuthenticationUIFail -import kotlin.contracts.ExperimentalContracts -import kotlin.contracts.InvocationKind -import kotlin.contracts.contract import kotlin.math.min import kotlin.time.Duration import kotlin.time.TimeSource @@ -148,14 +153,15 @@ class IosSigningKeyConfiguration internal constructor(): PlatformSigningKeyConfi } /** - * Resolve [what] differently based on whether the [v]alue was [spec]ified. + * Resolve [what] differently based on whether the [vA]lue was [spec]ified. * - * * [spec] = `true`: Check if [valid] contains [v], return [v] if yes, throw otherwise + * * [spec] = `true`: Check if [valid] contains [vA()][vA], return [vA()][vA] if yes, throw otherwise * * [spec] = `false`: Check if [valid] contains exactly one element, if yes, return it, throw otherwise */ -private inline fun resolveOption(what: String, valid: Set, spec: Boolean, v: E): E = +private inline fun resolveOption(what: String, valid: Set, spec: Boolean, vA: ()->E): E = when (spec) { true -> { + val v = vA() if (!valid.contains(v)) throw IllegalArgumentException("Key does not support $what $v; supported: ${valid.joinToString(", ")}") v @@ -212,10 +218,10 @@ sealed class IosSigner(final override val alias: String, } ctx.apply { val stack = DSL.ConfigStack(signingConfig.unlockPrompt.v, signerConfig.unlockPrompt.v) - localizedReason = stack.getProperty(UnlockPromptConfiguration::message, - checker = UnlockPromptConfiguration::messageSpecified, default = UnlockPromptConfiguration.defaultMessage) - localizedCancelTitle = stack.getProperty(UnlockPromptConfiguration::cancelText, - checker = UnlockPromptConfiguration::cancelTextSpecified, default = UnlockPromptConfiguration.defaultCancelText) + localizedReason = stack.getProperty(UnlockPromptConfiguration::_message, + default = UnlockPromptConfiguration.defaultMessage) + localizedCancelTitle = stack.getProperty(UnlockPromptConfiguration::_cancelText, + default = UnlockPromptConfiguration.defaultCancelText) } } else { recordable = false @@ -292,9 +298,21 @@ sealed class IosSigner(final override val alias: String, val signingConfig = DSL.resolve(::IosSignerSigningConfiguration, configure) val algorithm = signatureAlgorithm.secKeyAlgorithmPreHashed val plaintext = data.convertTo(signatureAlgorithm.preHashedSignatureFormat).getOrThrow().data.first().toNSData() - val signatureBytes = corecall { - SecKeyCreateSignature(privateKeyManager.get(signingConfig).value, algorithm, plaintext.giveToCF(), error) - }.takeFromCF().toByteArray() + val signatureBytes = try { + corecall { + SecKeyCreateSignature(privateKeyManager.get(signingConfig).value, algorithm, plaintext.giveToCF(), error) + }.takeFromCF().toByteArray() + } catch (x: CoreFoundationException) { /* secure enclave failure */ + if (x.nsError.domain == LAErrorDomain) when (x.nsError.code) { + LAErrorUserCancel, LAErrorAuthenticationFailed, LAErrorBiometryLockout -> throw UnlockFailed(x.nsError.localizedDescription, x) + else -> throw x + } else throw x + } catch (x: CFCryptoOperationFailed) { /* keychain failure */ + when (x.osStatus) { + errSecUserCanceled, errSecAuthFailed -> throw UnlockFailed(x.message, x) + else -> throw x + } + } return@signCatching bytesToSignature(signatureBytes) }} @@ -308,7 +326,7 @@ sealed class IosSigner(final override val alias: String, { "Metadata type mismatch (ECDSA key, metadata not ECDSA)" } signatureAlgorithm = when ( - val digest = resolveOption("digest", metadata.algSpecific.supportedDigests, config.ec.v.digestSpecified, config.ec.v.digest) + val digest = resolveOption("digest", metadata.algSpecific.supportedDigests, config.ec.v.digestSpecified, { config.ec.v.digest }) ){ Digest.SHA256, Digest.SHA384, Digest.SHA512 -> SignatureAlgorithm.ECDSA(digest, publicKey.curve) else -> throw UnsupportedCryptoException("ECDSA with $digest is not supported on iOS") @@ -328,8 +346,8 @@ sealed class IosSigner(final override val alias: String, { "Metadata type mismatch (RSA key, metadata not RSA) "} signatureAlgorithm = SignatureAlgorithm.RSA( - digest = resolveOption("digest", metadata.algSpecific.supportedDigests, config.rsa.v.digestSpecified, config.rsa.v.digest), - padding = resolveOption("padding", metadata.algSpecific.supportedPaddings, config.rsa.v.paddingSpecified, config.rsa.v.padding) + digest = resolveOption("digest", metadata.algSpecific.supportedDigests, config.rsa.v.digestSpecified, { config.rsa.v.digest }), + padding = resolveOption("padding", metadata.algSpecific.supportedPaddings, config.rsa.v.paddingSpecified, { config.rsa.v.padding }) ) } override fun bytesToSignature(sigBytes: ByteArray) = @@ -501,7 +519,7 @@ object IosKeychainProvider: PlatformSigningProviderI() }.toByteArray() } else { val x = CFCryptoOperationFailed(thing = "generate key", osStatus = status) - if ((status == -50) && + if ((status == errSecUnsupportedKeyFormat) && useSecureEnclave && !isSecureEnclaveSupportedConfiguration(config._algSpecific.v)) { throw UnsupportedCryptoException("iOS Secure Enclave does not support this configuration.", x) diff --git a/supreme/src/iosMain/kotlin/at/asitplus/signum/supreme/sign/EphemeralKeysImpl.kt b/supreme/src/iosMain/kotlin/at/asitplus/signum/supreme/sign/EphemeralKeysImpl.kt index 4531a355..a6bbed10 100644 --- a/supreme/src/iosMain/kotlin/at/asitplus/signum/supreme/sign/EphemeralKeysImpl.kt +++ b/supreme/src/iosMain/kotlin/at/asitplus/signum/supreme/sign/EphemeralKeysImpl.kt @@ -1,7 +1,6 @@ @file:OptIn(ExperimentalForeignApi::class) package at.asitplus.signum.supreme.sign -import at.asitplus.catching import at.asitplus.signum.indispensable.CryptoPublicKey import at.asitplus.signum.indispensable.CryptoSignature import at.asitplus.signum.indispensable.SignatureAlgorithm @@ -12,6 +11,7 @@ import at.asitplus.signum.supreme.cfDictionaryOf import at.asitplus.signum.supreme.corecall import at.asitplus.signum.supreme.createCFDictionary import at.asitplus.signum.supreme.giveToCF +import at.asitplus.signum.supreme.signCatching import at.asitplus.signum.supreme.takeFromCF import at.asitplus.signum.supreme.toByteArray import at.asitplus.signum.supreme.toNSData @@ -41,14 +41,14 @@ actual class EphemeralSignerConfiguration internal actual constructor(): Ephemer private typealias EphemeralKeyRef = AutofreeVariable sealed class EphemeralSigner(internal val privateKey: EphemeralKeyRef): Signer { final override val mayRequireUserUnlock: Boolean get() = false - final override suspend fun sign(data: SignatureInput) = catching { + final override suspend fun sign(data: SignatureInput) = signCatching { val inputData = data.convertTo(signatureAlgorithm.preHashedSignatureFormat).getOrThrow() val algorithm = signatureAlgorithm.secKeyAlgorithmPreHashed val input = inputData.data.single().toNSData() val signatureBytes = corecall { SecKeyCreateSignature(privateKey.value, algorithm, input.giveToCF(), error) }.let { it.takeFromCF().toByteArray() } - return@catching when (val pubkey = publicKey) { + return@signCatching when (val pubkey = publicKey) { is CryptoPublicKey.EC -> CryptoSignature.EC.decodeFromDer(signatureBytes).withCurve(pubkey.curve) is CryptoPublicKey.Rsa -> CryptoSignature.RSAorHMAC(signatureBytes) } diff --git a/supreme/src/jvmMain/kotlin/at/asitplus/signum/supreme/sign/EphemeralKeysImpl.kt b/supreme/src/jvmMain/kotlin/at/asitplus/signum/supreme/sign/EphemeralKeysImpl.kt index 1044075a..f5dbcff6 100644 --- a/supreme/src/jvmMain/kotlin/at/asitplus/signum/supreme/sign/EphemeralKeysImpl.kt +++ b/supreme/src/jvmMain/kotlin/at/asitplus/signum/supreme/sign/EphemeralKeysImpl.kt @@ -1,6 +1,5 @@ package at.asitplus.signum.supreme.sign -import at.asitplus.catching import at.asitplus.signum.indispensable.CryptoPublicKey import at.asitplus.signum.indispensable.CryptoSignature import at.asitplus.signum.indispensable.SignatureAlgorithm @@ -9,6 +8,7 @@ import at.asitplus.signum.indispensable.getJCASignatureInstance import at.asitplus.signum.indispensable.getJCASignatureInstancePreHashed import at.asitplus.signum.indispensable.jcaName import at.asitplus.signum.indispensable.parseFromJca +import at.asitplus.signum.supreme.signCatching import com.ionspin.kotlin.bignum.integer.base63.toJavaBigInteger import java.security.KeyPairGenerator import java.security.PrivateKey @@ -27,14 +27,11 @@ actual class EphemeralSignerConfiguration internal actual constructor(): Ephemer sealed class EphemeralSigner (internal val privateKey: PrivateKey, private val provider: String?) : Signer { override val mayRequireUserUnlock = false - override suspend fun sign(data: SignatureInput) = catching { + override suspend fun sign(data: SignatureInput) = signCatching { val preHashed = (data.format != null) if (preHashed) { - require (data.format == when (val alg = signatureAlgorithm) { - is SignatureAlgorithm.ECDSA -> alg.digest - is SignatureAlgorithm.RSA -> alg.digest - else -> TODO("HMAC is unsupported") - }) { "Pre-hashed data (format ${data.format}) unsupported for algorithm $signatureAlgorithm" } + require (data.format == signatureAlgorithm.preHashedSignatureFormat) + { "Pre-hashed data (format ${data.format}) unsupported for algorithm $signatureAlgorithm" } } (if (preHashed) signatureAlgorithm.getJCASignatureInstancePreHashed(provider = provider).getOrThrow() From 0d98161063370c202943f12d1a917c17a891172b Mon Sep 17 00:00:00 2001 From: Jakob Heher Date: Mon, 2 Sep 2024 14:32:16 +0200 Subject: [PATCH 47/73] fix ios cancellation detection --- .../at/asitplus/signum/supreme/os/IosKeychainProvider.kt | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/supreme/src/iosMain/kotlin/at/asitplus/signum/supreme/os/IosKeychainProvider.kt b/supreme/src/iosMain/kotlin/at/asitplus/signum/supreme/os/IosKeychainProvider.kt index c3228b40..85cf0c51 100644 --- a/supreme/src/iosMain/kotlin/at/asitplus/signum/supreme/os/IosKeychainProvider.kt +++ b/supreme/src/iosMain/kotlin/at/asitplus/signum/supreme/os/IosKeychainProvider.kt @@ -111,7 +111,6 @@ import platform.LocalAuthentication.LAErrorBiometryLockout import platform.LocalAuthentication.LAErrorDomain import platform.LocalAuthentication.LAErrorUserCancel import platform.Security.errSecAuthFailed -import platform.Security.errSecUnsupportedKeyFormat import platform.Security.errSecUserCanceled import platform.Security.kSecUseAuthenticationUIFail import kotlin.math.min @@ -519,10 +518,10 @@ object IosKeychainProvider: PlatformSigningProviderI() }.toByteArray() } else { val x = CFCryptoOperationFailed(thing = "generate key", osStatus = status) - if ((status == errSecUnsupportedKeyFormat) && + if ((status == -50) && useSecureEnclave && !isSecureEnclaveSupportedConfiguration(config._algSpecific.v)) { - throw UnsupportedCryptoException("iOS Secure Enclave does not support this configuration.", x) + throw UnsupportedCryptoException("The iOS Secure Enclave does not support this configuration.", x) } throw x } From 059a4f3013ad932c3639ccf4b6a868a871896629 Mon Sep 17 00:00:00 2001 From: Jakob Heher Date: Mon, 2 Sep 2024 14:33:09 +0200 Subject: [PATCH 48/73] fix ios cancellation detection --- .../src/commonMain/kotlin/at/asitplus/cryptotest/App.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/demoapp/composeApp/src/commonMain/kotlin/at/asitplus/cryptotest/App.kt b/demoapp/composeApp/src/commonMain/kotlin/at/asitplus/cryptotest/App.kt index 3c7619b8..f37303fc 100644 --- a/demoapp/composeApp/src/commonMain/kotlin/at/asitplus/cryptotest/App.kt +++ b/demoapp/composeApp/src/commonMain/kotlin/at/asitplus/cryptotest/App.kt @@ -57,7 +57,6 @@ import at.asitplus.cryptotest.theme.AppTheme import at.asitplus.cryptotest.theme.LocalThemeIsDark import at.asitplus.signum.supreme.os.PlatformSignerConfigurationBase import at.asitplus.signum.supreme.os.PlatformSigningProvider -import at.asitplus.signum.supreme.os.SigningProvider import at.asitplus.signum.supreme.os.jsonEncoded import at.asitplus.signum.supreme.wrap import io.github.aakira.napier.DebugAntilog From d64de45eba68af231910b2ae7fcf449aeb59d527 Mon Sep 17 00:00:00 2001 From: Jakob Heher Date: Mon, 2 Sep 2024 17:01:16 +0200 Subject: [PATCH 49/73] pin android to single thread --- .../supreme/os/AndroidKeyStoreProvider.kt | 24 +-- .../sign/EphemeralSignerCommonTests.kt | 156 +++++++++++++----- .../src/jvmTest/kotlin/ReadmeCompileTest.kt | 47 ------ .../signum/supreme/os/JKSProviderTest.kt | 1 - 4 files changed, 125 insertions(+), 103 deletions(-) delete mode 100644 supreme/src/jvmTest/kotlin/ReadmeCompileTest.kt diff --git a/supreme/src/androidMain/kotlin/at/asitplus/signum/supreme/os/AndroidKeyStoreProvider.kt b/supreme/src/androidMain/kotlin/at/asitplus/signum/supreme/os/AndroidKeyStoreProvider.kt index 04db80c8..997238d9 100644 --- a/supreme/src/androidMain/kotlin/at/asitplus/signum/supreme/os/AndroidKeyStoreProvider.kt +++ b/supreme/src/androidMain/kotlin/at/asitplus/signum/supreme/os/AndroidKeyStoreProvider.kt @@ -43,6 +43,8 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.asCoroutineDispatcher import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext import java.security.KeyFactory import java.security.KeyPairGenerator import java.security.KeyStore @@ -52,6 +54,7 @@ import java.security.spec.ECGenParameterSpec import java.security.spec.RSAKeyGenParameterSpec import java.time.Instant import java.util.Date +import java.util.concurrent.Executors import javax.security.auth.x500.X500Principal internal sealed interface FragmentContext { @@ -59,6 +62,7 @@ internal sealed interface FragmentContext { @JvmInline value class OfFragment(val fragment: Fragment): FragmentContext } +private val keystoreContext = Executors.newSingleThreadExecutor().asCoroutineDispatcher() class AndroidKeymasterConfiguration internal constructor(): PlatformSigningKeyConfigurationBase.SecureHardwareConfiguration() { /** Whether a StrongBox TPM is required. */ @@ -154,7 +158,7 @@ object AndroidKeyStoreProvider: override suspend fun createSigningKey( alias: String, configure: DSLConfigureFn - ) = catching { + ) = withContext(keystoreContext) { catching { if (ks.containsAlias(alias)) { throw NoSuchElementException("Key with alias $alias already exists") } @@ -206,12 +210,12 @@ object AndroidKeyStoreProvider: initialize(spec) }.generateKeyPair() return@catching getSignerForKey(alias, config.signer.v).getOrThrow() - } + }} override suspend fun getSignerForKey( alias: String, configure: DSLConfigureFn - ): KmmResult = catching { + ): KmmResult = withContext(keystoreContext) { catching { val config = DSL.resolve(::AndroidSignerConfiguration, configure) val (jcaPrivateKey, certificateChain) = ks.let { Pair(it.getKey(alias, null) as? PrivateKey @@ -250,11 +254,11 @@ object AndroidKeyStoreProvider: jcaPrivateKey, alias, keyInfo, config, certificateChain, algorithm as SignatureAlgorithm.RSA) } - } + }} - override suspend fun deleteSigningKey(alias: String) = catching { + override suspend fun deleteSigningKey(alias: String) = catching { withContext(keystoreContext) { ks.deleteEntry(alias) - } + }} } sealed class AndroidKeystoreSigner private constructor( @@ -342,23 +346,23 @@ sealed class AndroidKeystoreSigner private constructor( final override suspend fun trySetupUninterruptedSigning(configure: DSLConfigureFn) = catching { if (needsAuthentication && !needsAuthenticationForEveryUse) { - getJCASignature(DSL.resolve(::AndroidSignerSigningConfiguration, configure)) + withContext(keystoreContext) { getJCASignature(DSL.resolve(::AndroidSignerSigningConfiguration, configure)) } } } final override suspend fun sign( data: SignatureInput, configure: DSLConfigureFn - ): SignatureResult = signCatching { + ): SignatureResult = withContext(keystoreContext) { signCatching { require(data.format == null) val jcaSig = getJCASignature(DSL.resolve(::AndroidSignerSigningConfiguration, configure)) .let { data.data.forEach(it::update); it.sign() } - return@signCatching when (this) { + return@signCatching when (this@AndroidKeystoreSigner) { is ECDSA -> CryptoSignature.EC.parseFromJca(jcaSig).withCurve(publicKey.curve) is RSA -> CryptoSignature.RSAorHMAC.parseFromJca(jcaSig) } - } + }} class ECDSA internal constructor(jcaPrivateKey: PrivateKey, alias: String, diff --git a/supreme/src/commonTest/kotlin/at/asitplus/signum/supreme/sign/EphemeralSignerCommonTests.kt b/supreme/src/commonTest/kotlin/at/asitplus/signum/supreme/sign/EphemeralSignerCommonTests.kt index 1a7051ee..1d3d68a6 100644 --- a/supreme/src/commonTest/kotlin/at/asitplus/signum/supreme/sign/EphemeralSignerCommonTests.kt +++ b/supreme/src/commonTest/kotlin/at/asitplus/signum/supreme/sign/EphemeralSignerCommonTests.kt @@ -4,69 +4,135 @@ import at.asitplus.signum.indispensable.Digest import at.asitplus.signum.indispensable.ECCurve import at.asitplus.signum.indispensable.RSAPadding import at.asitplus.signum.indispensable.SignatureAlgorithm +import at.asitplus.signum.indispensable.nativeDigest import at.asitplus.signum.supreme.signature import at.asitplus.signum.supreme.succeed import com.ionspin.kotlin.bignum.integer.Quadruple import io.kotest.core.spec.style.FreeSpec import io.kotest.datatest.withData import io.kotest.matchers.collections.shouldBeIn +import io.kotest.matchers.collections.shouldNotBeIn import io.kotest.matchers.should import io.kotest.matchers.shouldBe +import io.kotest.matchers.shouldNot import io.kotest.matchers.types.shouldBeInstanceOf import kotlin.random.Random class EphemeralSignerCommonTests : FreeSpec({ - "RSA" - { - withData(nameFn = { (pad,dig,bits,pre) -> "$dig/$pad/${bits}bit${if (pre) "/pre" else ""}" }, sequence { - RSAPadding.entries.forEach { padding -> - Digest.entries.forEach { digest -> - when { - digest == Digest.SHA512 && padding == RSAPadding.PSS -> listOf(2048, 3072, 4096) - digest == Digest.SHA384 || digest == Digest.SHA512 || padding == RSAPadding.PSS -> listOf(1024, 2048, 3072, 4096) - else -> listOf(512, 1024, 2048, 3072, 4096) - }.forEach { keySize -> - yield(Quadruple(padding, digest, keySize, false)) - yield(Quadruple(padding, digest, keySize, true)) + "Functional" - { + "RSA" - { + withData( + nameFn = { (pad, dig, bits, pre) -> "$dig/$pad/${bits}bit${if (pre) "/pre" else ""}" }, + sequence { + RSAPadding.entries.forEach { padding -> + Digest.entries.forEach { digest -> + when { + digest == Digest.SHA512 && padding == RSAPadding.PSS + -> listOf(2048, 3072, 4096) + digest == Digest.SHA384 || digest == Digest.SHA512 || padding == RSAPadding.PSS + -> listOf(1024,2048,3072,4096) + else + -> listOf(512, 1024, 2048, 3072, 4096) + }.forEach { keySize -> + yield(Quadruple(padding, digest, keySize, false)) + yield(Quadruple(padding, digest, keySize, true)) + } + } } + }) { (padding, digest, keySize, preHashed) -> + val data = Random.Default.nextBytes(64) + val signer: Signer + val signature = try { + signer = Signer.Ephemeral { + rsa { + digests = setOf(digest); paddings = setOf(padding); bits = keySize + } + }.getOrThrow() + signer.sign(SignatureInput(data).let { + if (preHashed) it.convertTo(digest).getOrThrow() else it + }).signature + } catch (x: UnsupportedOperationException) { + return@withData } + signer.signatureAlgorithm.shouldBeInstanceOf().let { + it.digest shouldBe digest + it.padding shouldBe padding + } + + val verifier = signer.makeVerifier().getOrThrow() + verifier.verify(data, signature) should succeed } - }) { (padding, digest, keySize, preHashed) -> - val data = Random.Default.nextBytes(64) - val signer: Signer - val signature = try { - signer = Signer.Ephemeral { rsa { digests = setOf(digest); paddings = setOf(padding); bits = keySize } }.getOrThrow() - signer.sign(SignatureInput(data).let { if (preHashed) it.convertTo(digest).getOrThrow() else it }).signature - } catch (x: UnsupportedOperationException) { - return@withData - } - signer.signatureAlgorithm.shouldBeInstanceOf().let { - it.digest shouldBe digest - it.padding shouldBe padding - } + } + "ECDSA" - { + withData( + nameFn = { (crv, dig, pre) -> "$crv/$dig${if (pre) "/pre" else ""}" }, + sequence { + ECCurve.entries.forEach { curve -> + Digest.entries.forEach { digest -> + yield(Triple(curve, digest, false)) + yield(Triple(curve, digest, true)) + } + } + }) { (crv, digest, preHashed) -> + val signer = + Signer.Ephemeral { ec { curve = crv; digests = setOf(digest) } }.getOrThrow() + signer.signatureAlgorithm.shouldBeInstanceOf().let { + it.digest shouldBe digest + it.requiredCurve shouldBeIn setOf(null, crv) + } + val data = Random.Default.nextBytes(64) + val signature = signer.sign(SignatureInput(data).let { + if (preHashed) it.convertTo(digest).getOrThrow() else it + }).signature - val verifier = signer.makeVerifier().getOrThrow() - verifier.verify(data, signature) should succeed + val verifier = signer.makeVerifier().getOrThrow() + verifier.verify(data, signature) should succeed + } } } - "ECDSA" - { - withData(nameFn = { (crv,dig,pre) -> "$crv/$dig${if (pre) "/pre" else ""}" }, sequence { - ECCurve.entries.forEach { curve -> - Digest.entries.forEach { digest -> - yield(Triple(curve, digest, false)) - yield(Triple(curve, digest, true)) - } - } - }) { (crv, digest, preHashed) -> - val signer = Signer.Ephemeral { ec { curve = crv; digests = setOf(digest) } }.getOrThrow() - signer.signatureAlgorithm.shouldBeInstanceOf().let { - it.digest shouldBe digest - it.requiredCurve shouldBeIn setOf(null, crv) - } - val data = Random.Default.nextBytes(64) - val signature = signer.sign(SignatureInput(data).let { if (preHashed) it.convertTo(digest).getOrThrow() else it }).signature - - val verifier = signer.makeVerifier().getOrThrow() - verifier.verify(data, signature) should succeed + "Configuration" - { + "ECDSA" - { + "No digest specified (defaults to native)" { + val curve = Random.of(ECCurve.entries) + val key = EphemeralKey { ec { this.curve = curve } }.getOrThrow() + val signer = key.signer().getOrThrow() + signer.signatureAlgorithm.shouldBeInstanceOf().digest shouldBe curve.nativeDigest + } + "No digest specified, native disallowed, still succeeds" { + val curve = Random.of(ECCurve.entries) + val key = EphemeralKey { ec { this.curve = curve; digests = Digest.entries.filter { it != curve.nativeDigest }.toSet() } }.getOrThrow() + val signer = key.signer().getOrThrow() + signer.signatureAlgorithm.shouldBeInstanceOf().digest shouldNotBeIn setOf(curve.nativeDigest, null) + } + "All digests legal by default" { + val curve = Random.of(ECCurve.entries) + val key = EphemeralKey { ec { this.curve = curve } }.getOrThrow() + val nonNativeDigest = Random.of(Digest.entries.filter {it != curve.nativeDigest}) + val signer = key.signer { ec { digest = nonNativeDigest } }.getOrThrow() + signer.signatureAlgorithm.shouldBeInstanceOf().digest shouldBe nonNativeDigest + } + "Illegal digests should fail" { + val curve = Random.of(ECCurve.entries) + val key = EphemeralKey { ec { this.curve = curve; digests = Digest.entries.filter {it != curve.nativeDigest}.toSet() } }.getOrThrow() + key.signer{ ec { digest = curve.nativeDigest } } shouldNot succeed + } + "Null digest should work as a default" { + val key = EphemeralKey { ec { this.curve = Random.of(ECCurve.entries); digests = setOf(null) } }.getOrThrow() + val signer = key.signer().getOrThrow() + signer.signatureAlgorithm.shouldBeInstanceOf().digest shouldBe null + } + "Null digest should work if explicitly specified" { + val key = EphemeralKey { ec {} }.getOrThrow() + val signer = key.signer { ec { digest = null } }.getOrThrow() + signer.signatureAlgorithm.shouldBeInstanceOf().digest shouldBe null + } + } + "RSA" - { + "No digest specified" { + val key = EphemeralKey { rsa {} }.getOrThrow() + val signer = key.signer().getOrThrow() + signer.signatureAlgorithm.shouldBeInstanceOf() + } } } }) diff --git a/supreme/src/jvmTest/kotlin/ReadmeCompileTest.kt b/supreme/src/jvmTest/kotlin/ReadmeCompileTest.kt deleted file mode 100644 index 5a9c8e59..00000000 --- a/supreme/src/jvmTest/kotlin/ReadmeCompileTest.kt +++ /dev/null @@ -1,47 +0,0 @@ -import at.asitplus.signum.indispensable.CryptoPublicKey -import at.asitplus.signum.indispensable.CryptoSignature -import at.asitplus.signum.indispensable.SignatureAlgorithm -import at.asitplus.signum.indispensable.pki.X509Certificate -import at.asitplus.signum.supreme.sign.platformVerifierFor -import at.asitplus.signum.supreme.sign.verifierFor -import at.asitplus.signum.supreme.sign.verify -import io.kotest.core.spec.style.FreeSpec -import io.kotest.matchers.compilation.shouldCompile - -class ReadmeCompileTest : FreeSpec({ - "!Signature Verification" { -""" -val publicKey: CryptoPublicKey.EC = TODO("You have this and trust it.") -val plaintext = "You want to trust this.".encodeToByteArray() -val signature: CryptoSignature.EC = TODO("This was sent alongside the plaintext.") -val verifier = SignatureAlgorithm.ECDSAwithSHA256.verifierFor(publicKey).getOrThrow() -val isValid = verifier.verify(plaintext, signature).isSuccess -println("Looks good? %isValid") -""".replace('%','$').shouldCompile() - } - "!X509 Signature Verification" { -""" -val rootCert: X509Certificate = TODO("You have this and trust it.") -val untrustedCert: X509Certificate = TODO("You want to verify that this is trustworthy.") - -val verifier = untrustedCert.signatureAlgorithm.verifierFor(rootCert.publicKey).getOrThrow() -val plaintext = untrustedCert.tbsCertificate.encodeToDer() -val signature = untrustedCert.signature -val isValid = verifier.verify(plaintext, signature).isSuccess -println("Certificate looks trustworthy: %isValid") -""".replace('%','$').shouldCompile() - } - "!Platform Verifiers" { -""" -val publicKey: CryptoPublicKey.EC = TODO("You have this.") -val plaintext: ByteArray = TODO("This is the message.") -val signature: CryptoSignature.EC = TODO("And this is the signature.") - -val verifier = SignatureAlgorithm.ECDSAwithSHA512 - .platformVerifierFor(publicKey) { provider = "BC"} /* specify BouncyCastle */ - .getOrThrow() -val isValid = verifier.verify(plaintext, signature).isSuccess -println("Is it trustworthy? %isValid") -""".replace('%','$').shouldCompile() - } -}) diff --git a/supreme/src/jvmTest/kotlin/at/asitplus/signum/supreme/os/JKSProviderTest.kt b/supreme/src/jvmTest/kotlin/at/asitplus/signum/supreme/os/JKSProviderTest.kt index f5cf2687..f47354f8 100644 --- a/supreme/src/jvmTest/kotlin/at/asitplus/signum/supreme/os/JKSProviderTest.kt +++ b/supreme/src/jvmTest/kotlin/at/asitplus/signum/supreme/os/JKSProviderTest.kt @@ -1,7 +1,6 @@ package at.asitplus.signum.supreme.os import at.asitplus.signum.supreme.sign.makeVerifier -import at.asitplus.signum.supreme.sign.sign import at.asitplus.signum.supreme.sign.verify import at.asitplus.signum.supreme.signature import at.asitplus.signum.supreme.succeed From b98104cc2aeee2e8e425df55251a1064b5bbb142 Mon Sep 17 00:00:00 2001 From: Jakob Heher Date: Mon, 2 Sep 2024 18:24:10 +0200 Subject: [PATCH 50/73] readme --- README.md | 79 ++++++++++++++++++- .../kotlin/at/asitplus/cryptotest/App.kt | 8 +- .../supreme/os/AndroidKeyStoreProvider.kt | 6 +- .../signum/supreme/os/SigningProvider.kt | 38 ++++----- .../signum/supreme/os/IosKeychainProvider.kt | 2 +- .../asitplus/signum/supreme/os/JKSProvider.kt | 2 +- 6 files changed, 105 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index e06584cd..9d30a43e 100644 --- a/README.md +++ b/README.md @@ -99,6 +99,83 @@ material._
    +### Signature Creation + +To create a signature, obtain a `Signer` instance. +You can do this using `Signer.Ephemeral` to create a signer for a throwaway keypair: +```kotlin +val signer = Signer.Ephemeral {}.getOrThrow() +val plaintext = "You have this.".encodeToByteArray() +val signature = signer.sign(plaintext).signature +println("Signed using ${signer.signatureAlgorithm}: $signature") +``` + +If you want to create multiple signatures using the same ephemeral key, you can obtain an `EphemeralKey` instance, then create signers from it: +```kotlin +val key = EphemeralKey { rsa {} }.getOrThrow() +val sha256Signer = key.getSigner { rsa { digest = Digests.SHA256 } }.getOrThrow() +val sha384Signer = key.getSigner { rsa { digest = Digests.SHA384 } }.getOrThrow() +``` + +The instances can be configured using the configuration DSL. +Any unspecified parameters use sensible, secure defaults. + +#### Platform Signers + +On Android and iOS, signers using the systems' secure key storage can be retrieved. +To do this, use `PlatformSigningProvider` (in common code), or interact with `AndroidKeystoreProvider`/`IosKeychainProvider` (in platform-specific code). + +New keys can be created using `createSigningKey(alias: String) { /* configuration */ }`, +and signers for existing keys can be retrieved using `getSignerForKey(alias: String) { /* configuration */ }`. + +For example, creating an elliptic-curve key over P256, stored in secure hardware, and with key attestation using a random challenge provided by your server, might be done like this: +```kotlin +val serverChallenge: ByteArray = TODO("This was unpredictably chosen by your server.") +PlatformSigningProvider.createSigningKey(alias = "Swordfish") { + ec { + // you don't even need to specify the curve (P256 is the default) but we'll do it for demonstration purposes + curve = ECCurve.SECP_256_R_1 + // you could specify the supported digests explicity - if you do not, the curve's native digest (for P256, this is SHA256) is supported + } + hardware { + // you could use PREFERRED if you want the operation to succeed (without hardware backing) on devices that do not support it + backing = REQUIRED + attestation { challenge = serverChallenge } + protection { + timeout = 5.seconds + factors { + biometry = true + deviceLock = false + } + } + } +} +``` + +If this operation succeeds, it returns a `Signer`. The same `Signer` could later be retrieved using `PlatformSigningProvider.getSignerForKey(alias: String)`. + +When you use this `Signer` to sign data, the user would be prompted to authorize the signature using an enrolled fingerprint, because that's what you specified when creating the key. +You can configure the authentication prompt: +```kotlin +val plaintext = "A message".encodeToByteArray() +val signature = signer.sign(plaintext) { + unlockPrompt { + message = "Signing a message to Bobby" + } +}.signature +``` +... but you cannot change the fact that you configured this key to need biometry. Consider this when creating your keys. + +#### Key Attestation + +The Android KeyStore offers key attestation certificates for hardware-backed keys. +These certificates are exposed by the signer's `.attestation` property. + +For iOS, Apple does not provide this capability. +We instead piggy-back onto iOS App Attestation to provide a home-brew "key attestation" scheme. +The guarantees are different: you are trusting the OS, not the actual secure hardware; and you are trusting that our library properly interfaces with the OS. +Attestation types are serializable for transfer, and correspond to those in Indispensable's attestation module. + ### Signature Verification To verify a signature, obtain a `Verifier` instance using `verifierFor(k: PublicKey)`, either directly on a `SignatureAlgorithm`, or on one of the specialized algorithms (`X509SignatureAlgorithm`, `CoseAlgorithm`, ...). @@ -108,7 +185,7 @@ As an example, here's how to verify a basic signature using a public key: ```kotlin val publicKey: CryptoPublicKey.EC = TODO("You have this and trust it.") val plaintext = "You want to trust this.".encodeToByteArray() -val signature = TODO("This was sent alongside the plaintext.") +val signature: CryptoSignature = TODO("This was sent alongside the plaintext.") val verifier = SignatureAlgorithm.ECDSAwithSHA256.verifierFor(publicKey).getOrThrow() val isValid = verifier.verify(plaintext, signature).isSuccess println("Looks good? $isValid") diff --git a/demoapp/composeApp/src/commonMain/kotlin/at/asitplus/cryptotest/App.kt b/demoapp/composeApp/src/commonMain/kotlin/at/asitplus/cryptotest/App.kt index f37303fc..3d26dd29 100644 --- a/demoapp/composeApp/src/commonMain/kotlin/at/asitplus/cryptotest/App.kt +++ b/demoapp/composeApp/src/commonMain/kotlin/at/asitplus/cryptotest/App.kt @@ -336,7 +336,7 @@ internal fun App() { CoroutineScope(context).launch { canGenerate = false genTextOverride = "Creating…" - currentSigner = PlatformSigningProvider{}.transform { it.createSigningKey(ALIAS) { + currentSigner = PlatformSigningProvider.createSigningKey(ALIAS) { signer(SIGNER_CONFIG) when (val alg = keyAlgorithm.algorithm) { @@ -381,7 +381,7 @@ internal fun App() { } } } - }} + } verifyState = null Napier.w { "created signing key! $currentSigner" } @@ -401,7 +401,7 @@ internal fun App() { CoroutineScope(context).launch { canGenerate = false genTextOverride = "Loading…" - PlatformSigningProvider{}.transform { it.getSignerForKey(ALIAS, SIGNER_CONFIG) }.let { + PlatformSigningProvider.getSignerForKey(ALIAS, SIGNER_CONFIG).let { Napier.w { "Priv retrieved from native: $it" } currentSigner = it verifyState = null @@ -424,7 +424,7 @@ internal fun App() { CoroutineScope(context).launch { canGenerate = false genTextOverride = "Deleting…" - PlatformSigningProvider{}.transform { it.deleteSigningKey(ALIAS) } + PlatformSigningProvider.deleteSigningKey(ALIAS) .onFailure { Napier.e("Failed to delete key", it) } currentSigner = null signatureData = null diff --git a/supreme/src/androidMain/kotlin/at/asitplus/signum/supreme/os/AndroidKeyStoreProvider.kt b/supreme/src/androidMain/kotlin/at/asitplus/signum/supreme/os/AndroidKeyStoreProvider.kt index 997238d9..e0f88c46 100644 --- a/supreme/src/androidMain/kotlin/at/asitplus/signum/supreme/os/AndroidKeyStoreProvider.kt +++ b/supreme/src/androidMain/kotlin/at/asitplus/signum/supreme/os/AndroidKeyStoreProvider.kt @@ -144,9 +144,7 @@ internal inline fun resolveOption(what: String, valid: Array private fun attestationFor(chain: CertificateChain) = if (chain.size > 1) AndroidKeystoreAttestation(chain) else null -/** - * A provider that manages keys in the [Android Key Store](https://developer.android.com/privacy-and-security/keystore). - */ +/** A provider that manages keys in the [Android Key Store](https://developer.android.com/privacy-and-security/keystore). */ object AndroidKeyStoreProvider: PlatformSigningProviderI { @@ -391,5 +389,5 @@ val AndroidKeystoreSigner.needsAuthenticationForEveryUse inline get() = keyInfo.isUserAuthenticationRequired && (keyInfo.userAuthenticationValidityDurationSeconds <= 0) -internal actual fun getPlatformSigningProvider(configure: DSLConfigureFn): PlatformSigningProvider = +internal actual fun getPlatformSigningProvider(configure: DSLConfigureFn): PlatformSigningProviderI<*,*,*> = AndroidKeyStoreProvider diff --git a/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/os/SigningProvider.kt b/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/os/SigningProvider.kt index e5dbac03..894cb9c3 100644 --- a/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/os/SigningProvider.kt +++ b/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/os/SigningProvider.kt @@ -153,7 +153,7 @@ interface PlatformSigningProviderSigner): PlatformSigningProvider +internal expect fun getPlatformSigningProvider(configure: DSLConfigureFn): PlatformSigningProviderI<*,*,*> /** KT-71089 workaround * @see PlatformSigningProvider */ @@ -167,28 +167,13 @@ interface PlatformSigningProviderI catching { getPlatformSigningProvider(configure) } } } - -/** KT-71089 workaround - * @see SigningProvider */ -interface SigningProviderI> { - suspend fun createSigningKey(alias: String, configure: DSLConfigureFn = null): KmmResult - suspend fun getSignerForKey(alias: String, configure: DSLConfigureFn = null): KmmResult - suspend fun deleteSigningKey(alias: String): KmmResult - - companion object { - fun Platform(configure: DSLConfigureFn = null) = - PlatformSigningProvider(configure) - } -} /** * An interface to some underlying persistent storage for private key material. Stored keys are identified by a unique string "alias" for each key. * You can [create signing keys][createSigningKey], [get signers for existing keys][getSignerForKey], or [delete signing keys][deleteSigningKey]. * - * To obtain a platform signing provider in platform-agnostic code, use `PlatformSigningProvider()`. + * To obtain a platform signing provider in platform-agnostic code, use `PlatformSigningProvider`. * In platform-specific code, it is currently recommended to directly interface with your platform signing provider to get platform-specific functionality. - * (Platform-specific return types from `PlatformSigningProvider()` are currently blocked by KT-71036.) + * (Platform-specific types for `PlatformSigningProvider` are currently blocked by KT-71036.) * * Created keys can be configured using the [SigningKeyConfiguration] DSL. * Signers can be configured using the [SignerConfiguration] DSL. @@ -198,7 +183,22 @@ interface SigningProviderI +val PlatformSigningProvider get() = getPlatformSigningProvider(null) + +/** KT-71089 workaround + * @see SigningProvider */ +interface SigningProviderI> { + suspend fun createSigningKey(alias: String, configure: DSLConfigureFn = null): KmmResult + suspend fun getSignerForKey(alias: String, configure: DSLConfigureFn = null): KmmResult + suspend fun deleteSigningKey(alias: String): KmmResult + + companion object { + fun Platform(configure: DSLConfigureFn = null) = + getPlatformSigningProvider(configure) + } +} /** @see PlatformSigningProvider */ typealias SigningProvider = SigningProviderI<*,*,*> diff --git a/supreme/src/iosMain/kotlin/at/asitplus/signum/supreme/os/IosKeychainProvider.kt b/supreme/src/iosMain/kotlin/at/asitplus/signum/supreme/os/IosKeychainProvider.kt index 85cf0c51..af2acf62 100644 --- a/supreme/src/iosMain/kotlin/at/asitplus/signum/supreme/os/IosKeychainProvider.kt +++ b/supreme/src/iosMain/kotlin/at/asitplus/signum/supreme/os/IosKeychainProvider.kt @@ -638,5 +638,5 @@ object IosKeychainProvider: PlatformSigningProviderI): PlatformSigningProvider = +internal actual fun getPlatformSigningProvider(configure: DSLConfigureFn): PlatformSigningProviderI<*,*,*> = IosKeychainProvider diff --git a/supreme/src/jvmMain/kotlin/at/asitplus/signum/supreme/os/JKSProvider.kt b/supreme/src/jvmMain/kotlin/at/asitplus/signum/supreme/os/JKSProvider.kt index 399bbad6..b10986c6 100644 --- a/supreme/src/jvmMain/kotlin/at/asitplus/signum/supreme/os/JKSProvider.kt +++ b/supreme/src/jvmMain/kotlin/at/asitplus/signum/supreme/os/JKSProvider.kt @@ -395,5 +395,5 @@ internal /*actual*/ fun makePlatformSigningProvider(config: JKSProviderConfigura JKSProvider(opt.accessor) } -internal actual fun getPlatformSigningProvider(configure: DSLConfigureFn): PlatformSigningProvider = +internal actual fun getPlatformSigningProvider(configure: DSLConfigureFn): PlatformSigningProviderI<*,*,*> = throw UnsupportedOperationException("No default persistence mode is available on the JVM. Use JKSProvider {file {}} or similar. This will be natively available from the getPlatformSigningProvider {} DSL in a future release. (Blocked by KT-71036.)") From fc72ad001b9429ffb1d81dca8cd87b43eb40e90a Mon Sep 17 00:00:00 2001 From: Jakob Heher Date: Mon, 2 Sep 2024 18:30:59 +0200 Subject: [PATCH 51/73] delete the readme compile tests since @JesusMcCloud deleted the dependency --- .../src/jvmTest/kotlin/ReadmeCompileTests.kt | 103 ------------------ 1 file changed, 103 deletions(-) delete mode 100644 indispensable/src/jvmTest/kotlin/ReadmeCompileTests.kt diff --git a/indispensable/src/jvmTest/kotlin/ReadmeCompileTests.kt b/indispensable/src/jvmTest/kotlin/ReadmeCompileTests.kt deleted file mode 100644 index c146a3d1..00000000 --- a/indispensable/src/jvmTest/kotlin/ReadmeCompileTests.kt +++ /dev/null @@ -1,103 +0,0 @@ -import at.asitplus.signum.indispensable.asn1.Asn1 -import at.asitplus.signum.indispensable.asn1.Asn1.PrintableString -import at.asitplus.signum.indispensable.asn1.Asn1.Tagged -import at.asitplus.signum.indispensable.asn1.Asn1.UtcTime -import at.asitplus.signum.indispensable.asn1.Asn1.Utf8String -import at.asitplus.signum.indispensable.asn1.Asn1Primitive -import at.asitplus.signum.indispensable.asn1.Asn1String -import at.asitplus.signum.indispensable.asn1.BERTags -import at.asitplus.signum.indispensable.asn1.ObjectIdentifier -import io.kotest.core.spec.style.FreeSpec -import io.kotest.matchers.compilation.shouldCompile -import kotlinx.datetime.Clock -import kotlinx.datetime.Instant - -class ReadmeCompileTests : FreeSpec({ - "!Certificate Parsing" { - - """ -val cert = X509Certificate.decodeFromDer(certBytes) - -when (val pk = cert.publicKey) { - is CryptoPublicKey.EC -> println( - "Certificate with serial no. %{ - cert.tbsCertificate.serialNumber - } contains an EC public key using curve %{pk.curve}" - ) - - is CryptoPublicKey.Rsa -> println( - "Certificate with serial no. %{ - cert.tbsCertificate.serialNumber - } contains a %{pk.bits.number} bit RSA public key" - ) -} - -println("The full certificate is:\n%{Json { prettyPrint = true }.encodeToString(cert)}") - -println("Re-encoding it produces the same bytes? %{cert.encodeToDer() contentEquals certBytes}") -""".replace('%','$').shouldCompile() - } - - "!Creating a CSR" { - """ -val ecPublicKey: ECPublicKey = TODO("From platform-specific code") -val cryptoPublicKey = CryptoPublicKey.EC.fromJcaPublicKey(ecPublicKey).getOrThrow() - -val commonName = "DefaultCryptoService" -val signatureAlgorithm = X509SignatureAlgorithm.ES256 - - -val tbsCsr = TbsCertificationRequest( - version = 0, - subjectName = listOf(RelativeDistinguishedName(AttributeTypeAndValue.CommonName(Asn1String.UTF8(commonName)))), - publicKey = cryptoPublicKey -) -val signed: ByteArray = TODO("pass tbsCsr.encodeToDer() to platform code") -val csr = Pkcs10CertificationRequest(tbsCsr, signatureAlgorithm, signed) - -println(csr.encodeToDer()) -""".shouldCompile() - } - - "!ASN1 DSL for Creating ASN.1 Structures" { - """ -Asn1.Sequence { - +Tagged(1u) { - +Asn1Primitive(BERTags.BOOLEAN, byteArrayOf(0x00)) - } - +Asn1.Set { - +Asn1.Sequence { - +Asn1.SetOf { - +PrintableString("World") - +PrintableString("Hello") - } - +Asn1.Set { - +PrintableString("World") - +PrintableString("Hello") - +Utf8String("!!!") - } - - } - } - +Asn1.Null() - - +ObjectIdentifier("1.2.603.624.97") - - +Utf8String("Foo") - +PrintableString("Bar") - - +Asn1.Set { - +Asn1.Int(3) - +Asn1.Long(-65789876543L) - +Asn1.Bool(false) - +Asn1.Bool(true) - } - +Asn1.Sequence { - +Asn1.Null() - +Asn1String.Numeric("12345") - +UtcTime(Clock.System.now()) - } -} -""".shouldCompile() - } -}) From ae1f93b28562284d5eb58734e794f75853181cb4 Mon Sep 17 00:00:00 2001 From: Jakob Heher Date: Mon, 2 Sep 2024 18:57:17 +0200 Subject: [PATCH 52/73] test case fixes for kotlin 2.0.20 --- .../kotlin/CoseKeySerializationTest.kt | 22 +++++++++++-------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/indispensable-cosef/src/jvmTest/kotlin/CoseKeySerializationTest.kt b/indispensable-cosef/src/jvmTest/kotlin/CoseKeySerializationTest.kt index c969d31f..49451cee 100644 --- a/indispensable-cosef/src/jvmTest/kotlin/CoseKeySerializationTest.kt +++ b/indispensable-cosef/src/jvmTest/kotlin/CoseKeySerializationTest.kt @@ -23,19 +23,23 @@ import java.security.Security import java.security.interfaces.ECPublicKey import java.security.interfaces.RSAPublicKey +private fun CryptoPublicKey.EC.withCompressionPreference(v: Boolean) = + if (v) CryptoPublicKey.EC.fromCompressed(curve, xBytes, yCompressed) + else CryptoPublicKey.EC.fromUncompressed(curve, xBytes, yBytes) class CoseKeySerializationTest : FreeSpec({ Security.addProvider(BouncyCastleProvider()) "Serializing" - { "Manual" - { - val compressed = coseCompliantSerializer.encodeToByteArray(CryptoPublicKey.fromJcaPublicKey( - KeyPairGenerator.getInstance("EC").apply { - initialize(256) - }.genKeyPair().public - ).getOrThrow().run { - this as CryptoPublicKey.EC - this.copy(preferCompressedRepresentation = true) - }.toCoseKey(CoseAlgorithm.ES256).getOrThrow() + val compressed = coseCompliantSerializer.encodeToByteArray( + CryptoPublicKey.fromJcaPublicKey( + KeyPairGenerator.getInstance("EC").apply { + initialize(256) + }.genKeyPair().public + ).getOrThrow().run { + this as CryptoPublicKey.EC + this.withCompressionPreference(true) + }.toCoseKey(CoseAlgorithm.ES256).getOrThrow() ) val coseUncompressed = CryptoPublicKey.fromJcaPublicKey( KeyPairGenerator.getInstance("EC").apply { @@ -119,7 +123,7 @@ class CoseKeySerializationTest : FreeSpec({ .getOrThrow() .run { this as CryptoPublicKey.EC - this.copy(preferCompressedRepresentation = true) + this.withCompressionPreference(true) }.toCoseKey() .getOrThrow() From ada76009ab7f3ded7fd138cad145cee49183e4af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bernd=20Pr=C3=BCnster?= Date: Tue, 3 Sep 2024 06:18:01 +0200 Subject: [PATCH 53/73] cleanup demoapp readme --- demoapp/README.MD | 16 +++++----------- .../kotlin/at/asitplus/cryptotest/Main.kt | 1 - 2 files changed, 5 insertions(+), 12 deletions(-) diff --git a/demoapp/README.MD b/demoapp/README.MD index d0c3a164..3facf3fc 100644 --- a/demoapp/README.MD +++ b/demoapp/README.MD @@ -3,19 +3,13 @@ ![img.png](img.png) -This app showcases the KMP Crypto provider on the desktop, on Android and on iOS. -It is possible to generate key pairs, sign data, and verify the signature. - -On iOS, only P-256 keys can be attested. -The JVM does not support attestation. +This app showcases the _Supreme_ KMP Crypto provider on Android and on iOS. Demoing the JVM target would require additional configuration due to limitations of Kotlin. +It was decided to avoid this clutter for the demo app, since the Supreme test suite already showcases the JVM provider usage. -`*** TODO CLEANUP BELOW THIS LINE ***` - -Android and iOS support mandatory authentication for key usage. While somewhat similar functionality can be achieved on both platforms, iOS comes with some peculiarities. -Most prominently: It is possible to specify a max validity duration of an authentication context. This property, however, has jwf semantics, since a once authenticated LAContext will remain in this state for eternety. -Hence, reauthentication needs to be implemented manually. +It is possible to generate key pairs, sign data, and verify the signature. -The BiometricAuthAdapter on Android requires some more polishing, but it should clearly convey the underlying idea even in its current state. +Generation of attestation statements is also supported, although on iOS, only P-256 keys can be attested due to platform constreaints. +The default JVM provider does not natively support the creation of attestation statements. ## Before running! - check your system with [KDoctor](https://github.com/Kotlin/kdoctor) diff --git a/demoapp/composeApp/src/jvmMain/kotlin/at/asitplus/cryptotest/Main.kt b/demoapp/composeApp/src/jvmMain/kotlin/at/asitplus/cryptotest/Main.kt index 13e4ea3a..2dd69504 100644 --- a/demoapp/composeApp/src/jvmMain/kotlin/at/asitplus/cryptotest/Main.kt +++ b/demoapp/composeApp/src/jvmMain/kotlin/at/asitplus/cryptotest/Main.kt @@ -1,6 +1,5 @@ package at.asitplus.cryptotest -import androidx.compose.runtime.Composable import androidx.compose.ui.window.Window import androidx.compose.ui.window.application From 01c2286c52fd0563ac2e34fb388f1266a600c095 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bernd=20Pr=C3=BCnster?= Date: Tue, 3 Sep 2024 08:07:02 +0200 Subject: [PATCH 54/73] cleanup build files --- demoapp/build.gradle.kts | 3 --- demoapp/composeApp/build.gradle.kts | 8 ++------ demoapp/settings.gradle.kts | 3 ++- supreme/build.gradle.kts | 5 +++-- .../signum/supreme/os/AndroidKeyStoreProviderTests.kt | 8 +++----- 5 files changed, 10 insertions(+), 17 deletions(-) diff --git a/demoapp/build.gradle.kts b/demoapp/build.gradle.kts index feaefbdb..b235b462 100644 --- a/demoapp/build.gradle.kts +++ b/demoapp/build.gradle.kts @@ -3,14 +3,11 @@ plugins { alias(libs.plugins.compose).apply(false) alias(libs.plugins.android.application).apply(false) alias(libs.plugins.buildConfig).apply(false) - // id("at.asitplus.gradle.conventions") version "1.9.23+20240319+1" } allprojects { repositories { - maven(rootProject.projectDir.absolutePath+"/kmp-crypto/repo") maven("https://s01.oss.sonatype.org/content/repositories/snapshots") - maven(uri("https://raw.githubusercontent.com/a-sit-plus/kotlinx.serialization/mvn/repo")) mavenCentral() google() } diff --git a/demoapp/composeApp/build.gradle.kts b/demoapp/composeApp/build.gradle.kts index c84059f5..480e4183 100644 --- a/demoapp/composeApp/build.gradle.kts +++ b/demoapp/composeApp/build.gradle.kts @@ -37,7 +37,7 @@ kotlin { } } commonMain.dependencies { - implementation("at.asitplus.signum:supreme:0.2.0-SNAPSHOT") { + implementation("at.asitplus.signum:supreme:+") { isChanging = true } implementation(compose.runtime) @@ -76,8 +76,7 @@ android { compileSdk = 34 defaultConfig { - minSdk = 33 - targetSdk = 34 + minSdk = 30 applicationId = "at.asitplus.cryptotest.androidApp" versionCode = 1 @@ -95,9 +94,6 @@ android { buildFeatures { compose = true } - composeOptions { - kotlinCompilerExtensionVersion = "1.5.4" - } packaging { resources.excludes.add("META-INF/versions/9/OSGI-INF/MANIFEST.MF") diff --git a/demoapp/settings.gradle.kts b/demoapp/settings.gradle.kts index ca3c5bb9..f5914c6c 100644 --- a/demoapp/settings.gradle.kts +++ b/demoapp/settings.gradle.kts @@ -7,7 +7,9 @@ pluginManagement { gradlePluginPortal() mavenCentral() + //required for indispensable modules maven("https://s01.oss.sonatype.org/content/repositories/snapshots") + //required for indispensable modules maven { url = uri("https://raw.githubusercontent.com/a-sit-plus/gradle-conventions-plugin/mvn/repo") @@ -30,6 +32,5 @@ dependencyResolutionManagement { google() mavenCentral() mavenLocal() - maven(uri("https://raw.githubusercontent.com/a-sit-plus/kotlinx.serialization/mvn/repo")) } } diff --git a/supreme/build.gradle.kts b/supreme/build.gradle.kts index 09287d8b..48111528 100644 --- a/supreme/build.gradle.kts +++ b/supreme/build.gradle.kts @@ -73,7 +73,7 @@ android { namespace = "at.asitplus.signum.supreme" compileSdk = 34 defaultConfig { - minSdk = 33 + minSdk = 30 testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } @@ -95,11 +95,12 @@ android { } testOptions { + targetSdk=30 managedDevices { localDevices { create("pixel2api33") { device = "Pixel 2" - apiLevel = 33 + apiLevel = 30 systemImageSource = "aosp-atd" } } diff --git a/supreme/src/androidInstrumentedTest/kotlin/at/asitplus/signum/supreme/os/AndroidKeyStoreProviderTests.kt b/supreme/src/androidInstrumentedTest/kotlin/at/asitplus/signum/supreme/os/AndroidKeyStoreProviderTests.kt index 046cf92b..706810bd 100644 --- a/supreme/src/androidInstrumentedTest/kotlin/at/asitplus/signum/supreme/os/AndroidKeyStoreProviderTests.kt +++ b/supreme/src/androidInstrumentedTest/kotlin/at/asitplus/signum/supreme/os/AndroidKeyStoreProviderTests.kt @@ -2,16 +2,16 @@ package at.asitplus.signum.supreme.os import at.asitplus.signum.indispensable.CryptoPublicKey import at.asitplus.signum.indispensable.SignatureAlgorithm -import at.asitplus.signum.supreme.sign.sign import at.asitplus.signum.supreme.sign.verifierFor import at.asitplus.signum.supreme.sign.verify import at.asitplus.signum.supreme.signature import br.com.colman.kotest.FreeSpec +import io.kotest.matchers.shouldBe import io.kotest.matchers.types.shouldBeInstanceOf import io.kotest.property.azstring import kotlin.random.Random -class AndroidKeyStoreProviderTests: FreeSpec({ +class AndroidKeyStoreProviderTests : FreeSpec({ "Create attested keypair" { val alias = Random.azstring(32) val attestChallenge = Random.nextBytes(32) @@ -29,9 +29,7 @@ class AndroidKeyStoreProviderTests: FreeSpec({ val signature = hardwareSigner.sign(plaintext).signature SignatureAlgorithm.ECDSAwithSHA256.verifierFor(publicKey).getOrThrow() - .verify(plaintext, signature).getOrThrow() + .verify(plaintext, signature).getOrThrow() shouldBe Unit //no errors reported - // val certificateChain = hardwareSigner.certificateChain - // TODO verify attestation } }) From 5c6b68a055ca234e03a190b73edb47d687c199fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bernd=20Pr=C3=BCnster?= Date: Tue, 3 Sep 2024 08:12:54 +0200 Subject: [PATCH 55/73] fix comment in build file --- .../src/jvmMain/kotlin/at/asitplus/cryptotest/App.jvm.kt | 2 -- demoapp/settings.gradle.kts | 4 ++-- 2 files changed, 2 insertions(+), 4 deletions(-) delete mode 100644 demoapp/composeApp/src/jvmMain/kotlin/at/asitplus/cryptotest/App.jvm.kt diff --git a/demoapp/composeApp/src/jvmMain/kotlin/at/asitplus/cryptotest/App.jvm.kt b/demoapp/composeApp/src/jvmMain/kotlin/at/asitplus/cryptotest/App.jvm.kt deleted file mode 100644 index 0a55cc52..00000000 --- a/demoapp/composeApp/src/jvmMain/kotlin/at/asitplus/cryptotest/App.jvm.kt +++ /dev/null @@ -1,2 +0,0 @@ -package at.asitplus.cryptotest - diff --git a/demoapp/settings.gradle.kts b/demoapp/settings.gradle.kts index f5914c6c..62d6c572 100644 --- a/demoapp/settings.gradle.kts +++ b/demoapp/settings.gradle.kts @@ -7,9 +7,9 @@ pluginManagement { gradlePluginPortal() mavenCentral() - //required for indispensable modules + //required for indispensable modules composite build maven("https://s01.oss.sonatype.org/content/repositories/snapshots") - //required for indispensable modules + //required for indispensable modules composite build maven { url = uri("https://raw.githubusercontent.com/a-sit-plus/gradle-conventions-plugin/mvn/repo") From f86e8578a5932696139f80c11da00c7ac03c8b79 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bernd=20Pr=C3=BCnster?= Date: Tue, 3 Sep 2024 13:58:01 +0200 Subject: [PATCH 56/73] fix developmen.md for demo app --- demoapp/DEVELOPMENT.md | 1 - 1 file changed, 1 deletion(-) diff --git a/demoapp/DEVELOPMENT.md b/demoapp/DEVELOPMENT.md index a2a1753e..f860754a 100644 --- a/demoapp/DEVELOPMENT.md +++ b/demoapp/DEVELOPMENT.md @@ -2,5 +2,4 @@ * recursively clone this repo * set `sdk.dir=/absulute/path/to/Android/sdk` inside `kmp-crypto/local.properties` -* `cd kmp-crypto && ./gradlew publishAllPublicationsToLocalRepository` * import the this project into Android studio \ No newline at end of file From eef462b214053aeae64409f777c149e70b280f1b Mon Sep 17 00:00:00 2001 From: Jakob Heher Date: Tue, 3 Sep 2024 16:37:24 +0200 Subject: [PATCH 57/73] workaround android keystore bug --- .../supreme/os/AndroidKeyStoreProvider.kt | 87 +++++++++++++------ .../signum/supreme/os/SigningProvider.kt | 3 + 2 files changed, 62 insertions(+), 28 deletions(-) diff --git a/supreme/src/androidMain/kotlin/at/asitplus/signum/supreme/os/AndroidKeyStoreProvider.kt b/supreme/src/androidMain/kotlin/at/asitplus/signum/supreme/os/AndroidKeyStoreProvider.kt index e0f88c46..6891a365 100644 --- a/supreme/src/androidMain/kotlin/at/asitplus/signum/supreme/os/AndroidKeyStoreProvider.kt +++ b/supreme/src/androidMain/kotlin/at/asitplus/signum/supreme/os/AndroidKeyStoreProvider.kt @@ -1,6 +1,7 @@ package at.asitplus.signum.supreme.os import android.annotation.SuppressLint +import android.os.Build import android.security.keystore.KeyGenParameterSpec import android.security.keystore.KeyInfo import android.security.keystore.KeyProperties @@ -18,10 +19,11 @@ import at.asitplus.signum.indispensable.CryptoSignature import at.asitplus.signum.indispensable.Digest import at.asitplus.signum.indispensable.RSAPadding import at.asitplus.signum.indispensable.SignatureAlgorithm +import at.asitplus.signum.indispensable.asn1.Asn1StructuralException +import at.asitplus.signum.indispensable.fromJcaPublicKey import at.asitplus.signum.indispensable.getJCASignatureInstance import at.asitplus.signum.indispensable.jcaName import at.asitplus.signum.indispensable.parseFromJca -import at.asitplus.signum.indispensable.pki.CertificateChain import at.asitplus.signum.indispensable.pki.X509Certificate import at.asitplus.signum.indispensable.pki.leaf import at.asitplus.signum.supreme.AppLifecycleMonitor @@ -38,24 +40,26 @@ import at.asitplus.signum.supreme.sign.SignatureInput import at.asitplus.signum.supreme.sign.SigningKeyConfiguration import at.asitplus.signum.supreme.signCatching import com.ionspin.kotlin.bignum.integer.base63.toJavaBigInteger +import io.github.aakira.napier.Napier import at.asitplus.signum.supreme.sign.Signer as SignerI import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.asCoroutineDispatcher import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.launch -import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import java.security.KeyFactory import java.security.KeyPairGenerator import java.security.KeyStore import java.security.PrivateKey import java.security.Signature +import java.security.cert.CertificateFactory import java.security.spec.ECGenParameterSpec import java.security.spec.RSAKeyGenParameterSpec import java.time.Instant import java.util.Date import java.util.concurrent.Executors import javax.security.auth.x500.X500Principal +import kotlin.math.max internal sealed interface FragmentContext { @JvmInline value class OfActivity(val activity: FragmentActivity): FragmentContext @@ -141,9 +145,6 @@ internal inline fun resolveOption(what: String, valid: Array } } -private fun attestationFor(chain: CertificateChain) = - if (chain.size > 1) AndroidKeystoreAttestation(chain) else null - /** A provider that manages keys in the [Android Key Store](https://developer.android.com/privacy-and-security/keystore). */ object AndroidKeyStoreProvider: PlatformSigningProviderI @@ -194,10 +195,25 @@ object AndroidKeyStoreProvider: setAttestationChallenge(it.challenge) } hw.protection.v?.let { + setInvalidatedByBiometricEnrollment(it.factors.v.biometry && + !it.factors.v.biometryWithNewFactors) setUserAuthenticationRequired(true) - setUserAuthenticationParameters(it.timeout.inWholeSeconds.toInt(), - (if (it.factors.v.biometry) KeyProperties.AUTH_BIOMETRIC_STRONG else 0) or - (if (it.factors.v.deviceLock) KeyProperties.AUTH_DEVICE_CREDENTIAL else 0)) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + setUserAuthenticationParameters(it.timeout.inWholeSeconds.toInt(), + (if (it.factors.v.biometry) KeyProperties.AUTH_BIOMETRIC_STRONG else 0) or + (if (it.factors.v.deviceLock) KeyProperties.AUTH_DEVICE_CREDENTIAL else 0)) + } else { + it.factors.v.let { factors -> when { + factors.biometry && !factors.deviceLock -> { + @Suppress("DEPRECATION") + setUserAuthenticationValidityDurationSeconds(-1) + } + else -> { + @Suppress("DEPRECATION") + setUserAuthenticationValidityDurationSeconds(max(1, it.timeout.inWholeSeconds.toInt())) + } + }} + } } } }.build() @@ -215,15 +231,33 @@ object AndroidKeyStoreProvider: configure: DSLConfigureFn ): KmmResult = withContext(keystoreContext) { catching { val config = DSL.resolve(::AndroidSignerConfiguration, configure) - val (jcaPrivateKey, certificateChain) = ks.let { - Pair(it.getKey(alias, null) as? PrivateKey - ?: throw NoSuchElementException("No key for alias $alias exists"), - it.getCertificateChain(alias).map { X509Certificate.decodeFromDer(it.encoded) }) + val jcaPrivateKey = ks.getKey(alias, null) as? PrivateKey + ?: throw NoSuchElementException("No key for alias $alias exists") + val publicKey: CryptoPublicKey + val attestation: AndroidKeystoreAttestation? + ks.getCertificateChain(alias).let { chain -> + runCatching { chain.map { X509Certificate.decodeFromDer(it.encoded) } }.let { r -> + if (r.isSuccess) r.getOrThrow().let { + publicKey = it.leaf.publicKey + attestation = if (it.size > 1) AndroidKeystoreAttestation(it) else null + } else r.exceptionOrNull()!!.let { + if ((it is Asn1StructuralException) && + (Build.VERSION.SDK_INT <= Build.VERSION_CODES.R) && + (chain.size == 1) && + (chain.first().encoded.takeLast(5) == listOf(0x03,0x03,0x00,0x30,0x00).map(Int::toByte))) { + Napier.v { "Correcting Android 10 AKS signature bug" } + publicKey = CertificateFactory.getInstance("X.509") + .generateCertificate(chain.first().encoded.inputStream()) + .publicKey.let(CryptoPublicKey::fromJcaPublicKey).getOrThrow() + attestation = null + } else throw it + } + } } val keyInfo = KeyFactory.getInstance(jcaPrivateKey.algorithm) .getKeySpec(jcaPrivateKey, KeyInfo::class.java) - val algorithm = when (val publicKey = certificateChain.leaf.publicKey) { + val algorithm = when (publicKey) { is CryptoPublicKey.EC -> { val ecConfig = config.ec.v val digest = resolveOption("digest", keyInfo.digests, Digest.entries.asSequence() + sequenceOf(null), ecConfig.digestSpecified, { ecConfig.digest }) { it?.jcaName ?: KeyProperties.DIGEST_NONE } @@ -242,15 +276,15 @@ object AndroidKeyStoreProvider: } } - return@catching when (certificateChain.leaf.publicKey) { + return@catching when (publicKey) { is CryptoPublicKey.EC -> AndroidKeystoreSigner.ECDSA( - jcaPrivateKey, alias, keyInfo, config, certificateChain, - algorithm as SignatureAlgorithm.ECDSA) + jcaPrivateKey, alias, keyInfo, config, publicKey, + attestation, algorithm as SignatureAlgorithm.ECDSA) is CryptoPublicKey.Rsa -> AndroidKeystoreSigner.RSA( - jcaPrivateKey, alias, keyInfo, config, certificateChain, - algorithm as SignatureAlgorithm.RSA) + jcaPrivateKey, alias, keyInfo, config, publicKey, + attestation, algorithm as SignatureAlgorithm.RSA) } }} @@ -264,12 +298,11 @@ sealed class AndroidKeystoreSigner private constructor( final override val alias: String, val keyInfo: KeyInfo, private val config: AndroidSignerConfiguration, - certificateChain: CertificateChain + final override val attestation: AndroidKeystoreAttestation? ) : PlatformSigningProviderSigner, SignerI.Attestable { final override val mayRequireUserUnlock: Boolean get() = this.needsAuthentication - final override val attestation = attestationFor(certificateChain) private sealed interface AuthResult { @JvmInline value class Success(val result: AuthenticationResult): AuthResult data class Error(val code: Int, val message: String): AuthResult @@ -366,21 +399,19 @@ sealed class AndroidKeystoreSigner private constructor( alias: String, keyInfo: KeyInfo, config: AndroidSignerConfiguration, - certificateChain: CertificateChain, + override val publicKey: CryptoPublicKey.EC, + attestation: AndroidKeystoreAttestation?, override val signatureAlgorithm: SignatureAlgorithm.ECDSA) - : AndroidKeystoreSigner(jcaPrivateKey, alias, keyInfo, config, certificateChain), SignerI.ECDSA { - override val publicKey = certificateChain.leaf.publicKey as CryptoPublicKey.EC - } + : AndroidKeystoreSigner(jcaPrivateKey, alias, keyInfo, config, attestation), SignerI.ECDSA class RSA internal constructor(jcaPrivateKey: PrivateKey, alias: String, keyInfo: KeyInfo, config: AndroidSignerConfiguration, - certificateChain: CertificateChain, + override val publicKey: CryptoPublicKey.Rsa, + attestation: AndroidKeystoreAttestation?, override val signatureAlgorithm: SignatureAlgorithm.RSA) - : AndroidKeystoreSigner(jcaPrivateKey, alias, keyInfo, config, certificateChain), SignerI.RSA { - override val publicKey = certificateChain.leaf.publicKey as CryptoPublicKey.Rsa - } + : AndroidKeystoreSigner(jcaPrivateKey, alias, keyInfo, config, attestation), SignerI.RSA } val AndroidKeystoreSigner.needsAuthentication inline get() = diff --git a/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/os/SigningProvider.kt b/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/os/SigningProvider.kt index 894cb9c3..666badfb 100644 --- a/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/os/SigningProvider.kt +++ b/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/os/SigningProvider.kt @@ -32,11 +32,14 @@ open class PlatformSigningKeyConfigurationBase Date: Tue, 3 Sep 2024 16:45:44 +0200 Subject: [PATCH 58/73] current-set biometry on ios --- .../at/asitplus/signum/supreme/os/IosKeychainProvider.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/supreme/src/iosMain/kotlin/at/asitplus/signum/supreme/os/IosKeychainProvider.kt b/supreme/src/iosMain/kotlin/at/asitplus/signum/supreme/os/IosKeychainProvider.kt index af2acf62..77f8f8b5 100644 --- a/supreme/src/iosMain/kotlin/at/asitplus/signum/supreme/os/IosKeychainProvider.kt +++ b/supreme/src/iosMain/kotlin/at/asitplus/signum/supreme/os/IosKeychainProvider.kt @@ -112,6 +112,7 @@ import platform.LocalAuthentication.LAErrorDomain import platform.LocalAuthentication.LAErrorUserCancel import platform.Security.errSecAuthFailed import platform.Security.errSecUserCanceled +import platform.Security.kSecAccessControlBiometryCurrentSet import platform.Security.kSecUseAuthenticationUIFail import kotlin.math.min import kotlin.time.Duration @@ -487,7 +488,7 @@ object IosKeychainProvider: PlatformSigningProviderI kSecAccessControlUserPresence - factors.biometry -> kSecAccessControlBiometryAny + factors.biometry -> if (factors.biometryWithNewFactors) kSecAccessControlBiometryAny else kSecAccessControlBiometryCurrentSet else -> kSecAccessControlDevicePasscode }.let { if (useSecureEnclave) it or kSecAccessControlPrivateKeyUsage else it From fb96c9ee378125de2dc065cd498568571e8707dc Mon Sep 17 00:00:00 2001 From: Jakob Heher Date: Tue, 3 Sep 2024 17:09:28 +0200 Subject: [PATCH 59/73] signatureresult transforming --- .../signum/supreme/SignatureResult.kt | 45 +++++++++++++------ 1 file changed, 31 insertions(+), 14 deletions(-) diff --git a/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/SignatureResult.kt b/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/SignatureResult.kt index aebedbc3..1d8fcd9c 100644 --- a/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/SignatureResult.kt +++ b/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/SignatureResult.kt @@ -9,33 +9,50 @@ import kotlin.jvm.JvmInline sealed class UserInitiatedCancellationReason(message: String?, cause: Throwable?): Throwable(message, cause) class UnlockFailed(message: String? = null, cause: Throwable? = null) : UserInitiatedCancellationReason(message, cause) -sealed interface SignatureResult { +sealed interface SignatureResult { @JvmInline - value class Success(val signature: CryptoSignature): SignatureResult + value class Success(val signature: T): SignatureResult @JvmInline - value class Failure(val problem: UserInitiatedCancellationReason): SignatureResult + value class Failure(val problem: UserInitiatedCancellationReason): SignatureResult @JvmInline - value class Error(val exception: Throwable): SignatureResult + value class Error(val exception: Throwable): SignatureResult + companion object { + fun FromException(x: Throwable): SignatureResult = when (x) { + is UserInitiatedCancellationReason -> SignatureResult.Failure(x) + else -> SignatureResult.Error(x) + } + } } -val SignatureResult.isSuccess get() = (this is SignatureResult.Success) +val SignatureResult<*>.isSuccess get() = (this is SignatureResult.Success) /** Retrieves the contained signature, asserting it exists. If it does not exist, throws the contained problem. */ -val SignatureResult.signature: CryptoSignature get() = when (this) { +val SignatureResult.signature: T get() = when (this) { is SignatureResult.Success -> this.signature is SignatureResult.Failure -> throw this.problem is SignatureResult.Error -> throw this.exception } /** Retrieves the contained signature, if one exists. */ -val SignatureResult.signatureOrNull: CryptoSignature? get() = when (this) { +val SignatureResult.signatureOrNull: T? get() = when (this) { is SignatureResult.Success -> this.signature else -> null } /** Transforms this SignatureResult into a [KmmResult]. Both [Failure] and [Error] map to [KmmResult.Failure]. */ -fun SignatureResult.wrap(): KmmResult = catching { this.signature } +fun SignatureResult.asKmmResult(): KmmResult = catching { this.signature } -internal inline fun signCatching(fn: ()->CryptoSignature): SignatureResult = +/** Modifies the contained [CryptoSignature], usually in order to reinterpret it as a more narrow type. */ +inline fun SignatureResult.modify(block: (T)->S) = + when (this) { + is SignatureResult.Success -> SignatureResult.Success(block(this.signature)) + is SignatureResult.Failure -> this + is SignatureResult.Error -> this + } + +/** Modifies the contained [CryptoSignature], usually in order to reinterpret it as a more narrow type. */ +inline fun SignatureResult.modify(block: KmmResult.()->KmmResult) = + catching { this.signature }.block().fold( + onSuccess = { SignatureResult.Success(it) }, + onFailure = { SignatureResult.FromException(it) }) + +internal inline fun signCatching(fn: ()->CryptoSignature): SignatureResult = runCatching { fn() }.fold( - onSuccess = SignatureResult::Success, - onFailure = { - if (it is UserInitiatedCancellationReason) SignatureResult.Failure(it) - else SignatureResult.Error(it) - }) + onSuccess = { SignatureResult.Success(it) }, + onFailure = { SignatureResult.FromException(it) }) From e67557a453f949eabe578a7c4c96de4af76cb6e7 Mon Sep 17 00:00:00 2001 From: Jakob Heher Date: Tue, 3 Sep 2024 17:12:55 +0200 Subject: [PATCH 60/73] star wars --- .../at/asitplus/signum/supreme/os/AndroidKeyStoreProvider.kt | 2 +- .../kotlin/at/asitplus/signum/supreme/os/SigningProvider.kt | 2 +- .../commonMain/kotlin/at/asitplus/signum/supreme/sign/Signer.kt | 2 +- .../kotlin/at/asitplus/signum/supreme/os/IosKeychainProvider.kt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/supreme/src/androidMain/kotlin/at/asitplus/signum/supreme/os/AndroidKeyStoreProvider.kt b/supreme/src/androidMain/kotlin/at/asitplus/signum/supreme/os/AndroidKeyStoreProvider.kt index 6891a365..a48e144b 100644 --- a/supreme/src/androidMain/kotlin/at/asitplus/signum/supreme/os/AndroidKeyStoreProvider.kt +++ b/supreme/src/androidMain/kotlin/at/asitplus/signum/supreme/os/AndroidKeyStoreProvider.kt @@ -384,7 +384,7 @@ sealed class AndroidKeystoreSigner private constructor( final override suspend fun sign( data: SignatureInput, configure: DSLConfigureFn - ): SignatureResult = withContext(keystoreContext) { signCatching { + ): SignatureResult<*> = withContext(keystoreContext) { signCatching { require(data.format == null) val jcaSig = getJCASignature(DSL.resolve(::AndroidSignerSigningConfiguration, configure)) .let { data.data.forEach(it::update); it.sign() } diff --git a/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/os/SigningProvider.kt b/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/os/SigningProvider.kt index 666badfb..89ec3371 100644 --- a/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/os/SigningProvider.kt +++ b/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/os/SigningProvider.kt @@ -145,7 +145,7 @@ interface PlatformSigningProviderSigner = null) : KmmResult = KmmResult.success(Unit) override suspend fun trySetupUninterruptedSigning() = trySetupUninterruptedSigning(null) - suspend fun sign(data: SignatureInput, configure: DSLConfigureFn = null) : SignatureResult + suspend fun sign(data: SignatureInput, configure: DSLConfigureFn = null) : SignatureResult<*> suspend fun sign(data: ByteArray, configure: DSLConfigureFn = null) = sign(SignatureInput(data), configure) suspend fun sign(data: Sequence, configure: DSLConfigureFn = null) = diff --git a/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/sign/Signer.kt b/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/sign/Signer.kt index 12d42764..8bd32255 100644 --- a/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/sign/Signer.kt +++ b/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/sign/Signer.kt @@ -116,7 +116,7 @@ interface Signer { suspend fun trySetupUninterruptedSigning(): KmmResult = KmmResult.success(Unit) /** Signs data. Might ask for user confirmation first if this [Signer] [mayRequireUserUnlock]. */ - suspend fun sign(data: SignatureInput): SignatureResult + suspend fun sign(data: SignatureInput): SignatureResult<*> suspend fun sign(data: ByteArray) = sign(SignatureInput(data)) suspend fun sign(data: Sequence) = sign(SignatureInput(data)) diff --git a/supreme/src/iosMain/kotlin/at/asitplus/signum/supreme/os/IosKeychainProvider.kt b/supreme/src/iosMain/kotlin/at/asitplus/signum/supreme/os/IosKeychainProvider.kt index 77f8f8b5..60f587e9 100644 --- a/supreme/src/iosMain/kotlin/at/asitplus/signum/supreme/os/IosKeychainProvider.kt +++ b/supreme/src/iosMain/kotlin/at/asitplus/signum/supreme/os/IosKeychainProvider.kt @@ -292,7 +292,7 @@ sealed class IosSigner(final override val alias: String, } } protected abstract fun bytesToSignature(sigBytes: ByteArray): CryptoSignature - final override suspend fun sign(data: SignatureInput, configure: DSLConfigureFn): SignatureResult = + final override suspend fun sign(data: SignatureInput, configure: DSLConfigureFn): SignatureResult<*> = withContext(keychainThreads) { signCatching { require(data.format == null) { "Pre-hashed data is unsupported on iOS" } val signingConfig = DSL.resolve(::IosSignerSigningConfiguration, configure) From da90b114ede8b108bc6de8aa965f78ce3fe9c4b7 Mon Sep 17 00:00:00 2001 From: Jakob Heher Date: Tue, 3 Sep 2024 17:35:41 +0200 Subject: [PATCH 61/73] fix jvm clash --- .../kotlin/at/asitplus/signum/supreme/SignatureResult.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/SignatureResult.kt b/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/SignatureResult.kt index 1d8fcd9c..1cfb338b 100644 --- a/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/SignatureResult.kt +++ b/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/SignatureResult.kt @@ -39,7 +39,7 @@ val SignatureResult.signatureOrNull: T? get() = when (th fun SignatureResult.asKmmResult(): KmmResult = catching { this.signature } /** Modifies the contained [CryptoSignature], usually in order to reinterpret it as a more narrow type. */ -inline fun SignatureResult.modify(block: (T)->S) = +inline fun SignatureResult.map(block: (T)->S) = when (this) { is SignatureResult.Success -> SignatureResult.Success(block(this.signature)) is SignatureResult.Failure -> this From a3f9959ae73ba54e8e0973f7a7f43a4db709aa29 Mon Sep 17 00:00:00 2001 From: Jakob Heher Date: Tue, 3 Sep 2024 17:45:36 +0200 Subject: [PATCH 62/73] add link to readme --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 9d30a43e..ebe8f474 100644 --- a/README.md +++ b/README.md @@ -137,6 +137,7 @@ PlatformSigningProvider.createSigningKey(alias = "Swordfish") { curve = ECCurve.SECP_256_R_1 // you could specify the supported digests explicity - if you do not, the curve's native digest (for P256, this is SHA256) is supported } + // see https://a-sit-plus.github.io/signum/supreme/at.asitplus.signum.supreme.sign/-platform-signing-key-configuration-base/-secure-hardware-configuration/index.html hardware { // you could use PREFERRED if you want the operation to succeed (without hardware backing) on devices that do not support it backing = REQUIRED From becb46f4a10a557c5893441cf64c0c8e3bf546dc Mon Sep 17 00:00:00 2001 From: Jakob Heher Date: Tue, 3 Sep 2024 19:06:12 +0200 Subject: [PATCH 63/73] throws annotation as requested --- .../kotlin/at/asitplus/signum/supreme/SignatureResult.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/SignatureResult.kt b/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/SignatureResult.kt index 1cfb338b..6a6baab4 100644 --- a/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/SignatureResult.kt +++ b/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/SignatureResult.kt @@ -25,7 +25,7 @@ sealed interface SignatureResult { } val SignatureResult<*>.isSuccess get() = (this is SignatureResult.Success) /** Retrieves the contained signature, asserting it exists. If it does not exist, throws the contained problem. */ -val SignatureResult.signature: T get() = when (this) { +val SignatureResult.signature: T @Throws(Throwable::class) get() = when (this) { is SignatureResult.Success -> this.signature is SignatureResult.Failure -> throw this.problem is SignatureResult.Error -> throw this.exception From 7c8293a1dc7aebaa8227cb378659e2fd570f3260 Mon Sep 17 00:00:00 2001 From: Jakob Heher Date: Tue, 3 Sep 2024 19:55:28 +0200 Subject: [PATCH 64/73] Revert "throws annotation as requested" (because it breaks iOS) This reverts commit becb46f4a10a557c5893441cf64c0c8e3bf546dc. --- .../kotlin/at/asitplus/signum/supreme/SignatureResult.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/SignatureResult.kt b/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/SignatureResult.kt index 6a6baab4..1cfb338b 100644 --- a/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/SignatureResult.kt +++ b/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/SignatureResult.kt @@ -25,7 +25,7 @@ sealed interface SignatureResult { } val SignatureResult<*>.isSuccess get() = (this is SignatureResult.Success) /** Retrieves the contained signature, asserting it exists. If it does not exist, throws the contained problem. */ -val SignatureResult.signature: T @Throws(Throwable::class) get() = when (this) { +val SignatureResult.signature: T get() = when (this) { is SignatureResult.Success -> this.signature is SignatureResult.Failure -> throw this.problem is SignatureResult.Error -> throw this.exception From a7072e1c90f167675727a441040f2abb1d71e913 Mon Sep 17 00:00:00 2001 From: Jakob Heher Date: Wed, 4 Sep 2024 13:48:44 +0200 Subject: [PATCH 65/73] requested documentation --- README.md | 3 +++ .../asitplus/signum/supreme/SignatureResult.kt | 18 +++++++++++------- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index ebe8f474..324052d5 100644 --- a/README.md +++ b/README.md @@ -167,6 +167,9 @@ val signature = signer.sign(plaintext) { ``` ... but you cannot change the fact that you configured this key to need biometry. Consider this when creating your keys. +On the JVM, no native secure hardware storage is available. +File-based keystores can be accessed using [`JKSProvider { file {} }`](https://a-sit-plus.github.io/signum/supreme/at.asitplus.signum.supreme.os/-j-k-s-provider/.index.html). + #### Key Attestation The Android KeyStore offers key attestation certificates for hardware-backed keys. diff --git a/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/SignatureResult.kt b/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/SignatureResult.kt index 1cfb338b..be1ebc61 100644 --- a/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/SignatureResult.kt +++ b/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/SignatureResult.kt @@ -10,14 +10,16 @@ sealed class UserInitiatedCancellationReason(message: String?, cause: Throwable? class UnlockFailed(message: String? = null, cause: Throwable? = null) : UserInitiatedCancellationReason(message, cause) sealed interface SignatureResult { - @JvmInline - value class Success(val signature: T): SignatureResult - @JvmInline - value class Failure(val problem: UserInitiatedCancellationReason): SignatureResult - @JvmInline - value class Error(val exception: Throwable): SignatureResult + /** The signature succeeded. A signature is contained. */ + @JvmInline value class Success(val signature: T): SignatureResult + /** The signature failed for expected reasons. Typically, this is because the user cancelled the operation. */ + @JvmInline value class Failure(val problem: UserInitiatedCancellationReason): SignatureResult + /** The signature failed for an unexpected reason. The thrown exception is contained. */ + @JvmInline value class Error(val exception: Throwable): SignatureResult companion object { - fun FromException(x: Throwable): SignatureResult = when (x) { + /** Constructs a suitable failed SignatureResult from the exception. + * [UserInitiatedCancellationReason] and subclasses map to [Failure], anything else maps to [Error]. */ + fun FromException(x: Throwable): SignatureResult = when (x) { is UserInitiatedCancellationReason -> SignatureResult.Failure(x) else -> SignatureResult.Error(x) } @@ -52,6 +54,8 @@ inline fun SignatureResult.modify(bl onSuccess = { SignatureResult.Success(it) }, onFailure = { SignatureResult.FromException(it) }) +/** Runs the block, catches exceptions, and maps to [SignatureResult]. + * @see SignatureResult.FromException */ internal inline fun signCatching(fn: ()->CryptoSignature): SignatureResult = runCatching { fn() }.fold( onSuccess = { SignatureResult.Success(it) }, From 5767057699540908bb9495334e290a66dbfccbf5 Mon Sep 17 00:00:00 2001 From: Jakob Heher Date: Wed, 4 Sep 2024 14:03:27 +0200 Subject: [PATCH 66/73] sign() always returns RawByteEncodable signatures --- .../asitplus/signum/supreme/SignatureResult.kt | 17 +++++++++-------- .../signum/supreme/os/IosKeychainProvider.kt | 2 +- .../signum/supreme/sign/EphemeralKeysImpl.kt | 17 ++++++++++++----- 3 files changed, 22 insertions(+), 14 deletions(-) diff --git a/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/SignatureResult.kt b/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/SignatureResult.kt index be1ebc61..9fca72ed 100644 --- a/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/SignatureResult.kt +++ b/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/SignatureResult.kt @@ -9,9 +9,9 @@ import kotlin.jvm.JvmInline sealed class UserInitiatedCancellationReason(message: String?, cause: Throwable?): Throwable(message, cause) class UnlockFailed(message: String? = null, cause: Throwable? = null) : UserInitiatedCancellationReason(message, cause) -sealed interface SignatureResult { +sealed interface SignatureResult { /** The signature succeeded. A signature is contained. */ - @JvmInline value class Success(val signature: T): SignatureResult + @JvmInline value class Success(val signature: T): SignatureResult /** The signature failed for expected reasons. Typically, this is because the user cancelled the operation. */ @JvmInline value class Failure(val problem: UserInitiatedCancellationReason): SignatureResult /** The signature failed for an unexpected reason. The thrown exception is contained. */ @@ -27,21 +27,21 @@ sealed interface SignatureResult { } val SignatureResult<*>.isSuccess get() = (this is SignatureResult.Success) /** Retrieves the contained signature, asserting it exists. If it does not exist, throws the contained problem. */ -val SignatureResult.signature: T get() = when (this) { +val SignatureResult.signature: T get() = when (this) { is SignatureResult.Success -> this.signature is SignatureResult.Failure -> throw this.problem is SignatureResult.Error -> throw this.exception } /** Retrieves the contained signature, if one exists. */ -val SignatureResult.signatureOrNull: T? get() = when (this) { +val SignatureResult.signatureOrNull: T? get() = when (this) { is SignatureResult.Success -> this.signature else -> null } /** Transforms this SignatureResult into a [KmmResult]. Both [Failure] and [Error] map to [KmmResult.Failure]. */ -fun SignatureResult.asKmmResult(): KmmResult = catching { this.signature } +fun SignatureResult.asKmmResult(): KmmResult = catching { this.signature } /** Modifies the contained [CryptoSignature], usually in order to reinterpret it as a more narrow type. */ -inline fun SignatureResult.map(block: (T)->S) = +inline fun SignatureResult.map(block: (T)->S) = when (this) { is SignatureResult.Success -> SignatureResult.Success(block(this.signature)) is SignatureResult.Failure -> this @@ -49,14 +49,15 @@ inline fun SignatureResult.map(block } /** Modifies the contained [CryptoSignature], usually in order to reinterpret it as a more narrow type. */ -inline fun SignatureResult.modify(block: KmmResult.()->KmmResult) = +inline fun SignatureResult + .modify(block: KmmResult.()->KmmResult) = catching { this.signature }.block().fold( onSuccess = { SignatureResult.Success(it) }, onFailure = { SignatureResult.FromException(it) }) /** Runs the block, catches exceptions, and maps to [SignatureResult]. * @see SignatureResult.FromException */ -internal inline fun signCatching(fn: ()->CryptoSignature): SignatureResult = +internal inline fun signCatching(fn: ()->CryptoSignature.RawByteEncodable): SignatureResult<*> = runCatching { fn() }.fold( onSuccess = { SignatureResult.Success(it) }, onFailure = { SignatureResult.FromException(it) }) diff --git a/supreme/src/iosMain/kotlin/at/asitplus/signum/supreme/os/IosKeychainProvider.kt b/supreme/src/iosMain/kotlin/at/asitplus/signum/supreme/os/IosKeychainProvider.kt index 60f587e9..67183a45 100644 --- a/supreme/src/iosMain/kotlin/at/asitplus/signum/supreme/os/IosKeychainProvider.kt +++ b/supreme/src/iosMain/kotlin/at/asitplus/signum/supreme/os/IosKeychainProvider.kt @@ -291,7 +291,7 @@ sealed class IosSigner(final override val alias: String, } } } - protected abstract fun bytesToSignature(sigBytes: ByteArray): CryptoSignature + protected abstract fun bytesToSignature(sigBytes: ByteArray): CryptoSignature.RawByteEncodable final override suspend fun sign(data: SignatureInput, configure: DSLConfigureFn): SignatureResult<*> = withContext(keychainThreads) { signCatching { require(data.format == null) { "Pre-hashed data is unsupported on iOS" } diff --git a/supreme/src/jvmMain/kotlin/at/asitplus/signum/supreme/sign/EphemeralKeysImpl.kt b/supreme/src/jvmMain/kotlin/at/asitplus/signum/supreme/sign/EphemeralKeysImpl.kt index f5dbcff6..f756861a 100644 --- a/supreme/src/jvmMain/kotlin/at/asitplus/signum/supreme/sign/EphemeralKeysImpl.kt +++ b/supreme/src/jvmMain/kotlin/at/asitplus/signum/supreme/sign/EphemeralKeysImpl.kt @@ -40,18 +40,25 @@ sealed class EphemeralSigner (internal val privateKey: PrivateKey, private val p .run { initSign(privateKey) data.data.forEach { update(it) } - sign().let { - CryptoSignature.parseFromJca(it, signatureAlgorithm) - } + sign().let(::parseFromJca) } } + + protected abstract fun parseFromJca(bytes: ByteArray): CryptoSignature.RawByteEncodable + open class EC internal constructor (config: JvmEphemeralSignerCompatibleConfiguration, privateKey: PrivateKey, override val publicKey: CryptoPublicKey.EC, override val signatureAlgorithm: SignatureAlgorithm.ECDSA) - : EphemeralSigner(privateKey, config.provider), Signer.ECDSA + : EphemeralSigner(privateKey, config.provider), Signer.ECDSA { + + override fun parseFromJca(bytes: ByteArray) = CryptoSignature.EC.parseFromJca(bytes).withCurve(publicKey.curve) + } open class RSA internal constructor (config: JvmEphemeralSignerCompatibleConfiguration, privateKey: PrivateKey, override val publicKey: CryptoPublicKey.Rsa, override val signatureAlgorithm: SignatureAlgorithm.RSA) - : EphemeralSigner(privateKey, config.provider), Signer.RSA + : EphemeralSigner(privateKey, config.provider), Signer.RSA { + + override fun parseFromJca(bytes: ByteArray) = CryptoSignature.RSAorHMAC.parseFromJca(bytes) + } } internal fun getKPGInstance(alg: String, provider: String? = null) = From 694c2ac1f8292de4822f5633b25d226db66454ad Mon Sep 17 00:00:00 2001 From: Jakob Heher Date: Wed, 4 Sep 2024 14:18:55 +0200 Subject: [PATCH 67/73] fix andorid --- .../signum/supreme/sign/EphemeralKeysImpl.kt | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/supreme/src/androidMain/kotlin/at/asitplus/signum/supreme/sign/EphemeralKeysImpl.kt b/supreme/src/androidMain/kotlin/at/asitplus/signum/supreme/sign/EphemeralKeysImpl.kt index a6188d7d..84548b6d 100644 --- a/supreme/src/androidMain/kotlin/at/asitplus/signum/supreme/sign/EphemeralKeysImpl.kt +++ b/supreme/src/androidMain/kotlin/at/asitplus/signum/supreme/sign/EphemeralKeysImpl.kt @@ -25,18 +25,25 @@ sealed class AndroidEphemeralSigner (internal val privateKey: PrivateKey) : Sign signatureAlgorithm.getJCASignatureInstancePreHashed(provider = null).getOrThrow().run { initSign(privateKey) inputData.data.forEach { update(it) } - sign().let { - CryptoSignature.parseFromJca(it, signatureAlgorithm) - } + sign().let(::parseFromJca) } } + + protected abstract fun parseFromJca(bytes: ByteArray): CryptoSignature.RawByteEncodable + class EC (config: EphemeralSignerConfiguration, privateKey: PrivateKey, override val publicKey: CryptoPublicKey.EC, override val signatureAlgorithm: SignatureAlgorithm.ECDSA) - : AndroidEphemeralSigner(privateKey), Signer.ECDSA + : AndroidEphemeralSigner(privateKey), Signer.ECDSA { + + override fun parseFromJca(bytes: ByteArray) = CryptoSignature.EC.parseFromJca(bytes).withCurve(publicKey.curve) + } class RSA (config: EphemeralSignerConfiguration, privateKey: PrivateKey, override val publicKey: CryptoPublicKey.Rsa, override val signatureAlgorithm: SignatureAlgorithm.RSA) - : AndroidEphemeralSigner(privateKey), Signer.RSA + : AndroidEphemeralSigner(privateKey), Signer.RSA { + + override fun parseFromJca(bytes: ByteArray) = CryptoSignature.RSAorHMAC.parseFromJca(bytes) + } } internal actual fun makeEphemeralKey(configuration: EphemeralSigningKeyConfiguration) : EphemeralKey = From beeb94f8502c18e8c16ba285a9235fb3149fd2f0 Mon Sep 17 00:00:00 2001 From: Jakob Heher Date: Wed, 4 Sep 2024 15:23:09 +0200 Subject: [PATCH 68/73] demoapp jvm support --- .../at/asitplus/cryptotest/App.android.kt | 4 + .../kotlin/at/asitplus/cryptotest/App.kt | 84 ++++++++----------- .../kotlin/at/asitplus/cryptotest/App.ios.kt | 4 + .../kotlin/at/asitplus/cryptotest/Main.kt | 5 ++ .../signum/supreme/os/SigningProvider.kt | 2 +- 5 files changed, 51 insertions(+), 48 deletions(-) diff --git a/demoapp/composeApp/src/androidMain/kotlin/at/asitplus/cryptotest/App.android.kt b/demoapp/composeApp/src/androidMain/kotlin/at/asitplus/cryptotest/App.android.kt index 6d610bdb..ed9ea44a 100644 --- a/demoapp/composeApp/src/androidMain/kotlin/at/asitplus/cryptotest/App.android.kt +++ b/demoapp/composeApp/src/androidMain/kotlin/at/asitplus/cryptotest/App.android.kt @@ -4,6 +4,10 @@ import android.app.Application import android.os.Bundle import androidx.activity.compose.setContent import androidx.fragment.app.FragmentActivity +import at.asitplus.signum.supreme.os.PlatformSigningProvider +import at.asitplus.signum.supreme.os.SigningProvider + +actual val Provider: SigningProvider = PlatformSigningProvider class AndroidApp : Application() diff --git a/demoapp/composeApp/src/commonMain/kotlin/at/asitplus/cryptotest/App.kt b/demoapp/composeApp/src/commonMain/kotlin/at/asitplus/cryptotest/App.kt index 3d26dd29..2e53ffcc 100644 --- a/demoapp/composeApp/src/commonMain/kotlin/at/asitplus/cryptotest/App.kt +++ b/demoapp/composeApp/src/commonMain/kotlin/at/asitplus/cryptotest/App.kt @@ -55,10 +55,13 @@ import at.asitplus.signum.supreme.sign.makeVerifier import at.asitplus.signum.supreme.sign.verify import at.asitplus.cryptotest.theme.AppTheme import at.asitplus.cryptotest.theme.LocalThemeIsDark +import at.asitplus.signum.supreme.asKmmResult import at.asitplus.signum.supreme.os.PlatformSignerConfigurationBase +import at.asitplus.signum.supreme.os.PlatformSigningKeyConfigurationBase import at.asitplus.signum.supreme.os.PlatformSigningProvider +import at.asitplus.signum.supreme.os.SignerConfiguration +import at.asitplus.signum.supreme.os.SigningProvider import at.asitplus.signum.supreme.os.jsonEncoded -import at.asitplus.signum.supreme.wrap import io.github.aakira.napier.DebugAntilog import io.github.aakira.napier.Napier import io.ktor.util.decodeBase64Bytes @@ -120,12 +123,17 @@ val SAMPLE_CERT_CHAIN = listOf( "/q4AaOeMSQ+2b1tbFfLn" ).map { X509Certificate.decodeFromDer(it.replace("\n", "").decodeBase64Bytes()) } +/* because we also want it to work on the jvm; +you don't need this workaround for ios/android, just use PlatformSigningProvider directly */ +expect val Provider: SigningProvider const val ALIAS = "Bartschlüssel" -val SIGNER_CONFIG: (PlatformSignerConfigurationBase.()->Unit) = { - unlockPrompt { - message = "We're signing a thing!" - cancelText = "No! Stop!" +val SIGNER_CONFIG: (SignerConfiguration.()->Unit) = { + if (this is PlatformSignerConfigurationBase) { + unlockPrompt { + message = "We're signing a thing!" + cancelText = "No! Stop!" + } } rsa { padding = RSAPadding.PKCS1 @@ -336,9 +344,7 @@ internal fun App() { CoroutineScope(context).launch { canGenerate = false genTextOverride = "Creating…" - currentSigner = PlatformSigningProvider.createSigningKey(ALIAS) { - signer(SIGNER_CONFIG) - + currentSigner = Provider.createSigningKey(ALIAS) { when (val alg = keyAlgorithm.algorithm) { is SignatureAlgorithm.ECDSA -> { this@createSigningKey.ec { @@ -357,25 +363,29 @@ internal fun App() { else -> error("unreachable") } - val timeout = runCatching { - biometricAuth.substringBefore("s").trim().toInt() - }.getOrNull() + if (this is PlatformSigningKeyConfigurationBase) { + signer(SIGNER_CONFIG) + + val timeout = runCatching { + biometricAuth.substringBefore("s").trim().toInt() + }.getOrNull() - if (attestation || timeout != null) { - hardware { - backing = PREFERRED - if (attestation) { - attestation { - challenge = Random.nextBytes(16) + if (attestation || timeout != null) { + hardware { + backing = PREFERRED + if (attestation) { + attestation { + challenge = Random.nextBytes(16) + } } - } - if (timeout != null) { - protection { - this.timeout = timeout.seconds - factors { - biometry = true - deviceLock = true + if (timeout != null) { + protection { + this.timeout = timeout.seconds + factors { + biometry = true + deviceLock = true + } } } } @@ -401,7 +411,7 @@ internal fun App() { CoroutineScope(context).launch { canGenerate = false genTextOverride = "Loading…" - PlatformSigningProvider.getSignerForKey(ALIAS, SIGNER_CONFIG).let { + Provider.getSignerForKey(ALIAS, SIGNER_CONFIG).let { Napier.w { "Priv retrieved from native: $it" } currentSigner = it verifyState = null @@ -424,7 +434,7 @@ internal fun App() { CoroutineScope(context).launch { canGenerate = false genTextOverride = "Deleting…" - PlatformSigningProvider.deleteSigningKey(ALIAS) + Provider.deleteSigningKey(ALIAS) .onFailure { Napier.e("Failed to delete key", it) } currentSigner = null signatureData = null @@ -464,7 +474,7 @@ internal fun App() { CoroutineScope(context).launch { val data = inputData.encodeToByteArray() currentSigner!! - .transform { it.sign(data).wrap() } + .transform { it.sign(data).asKmmResult() } .also { signatureData = it; verifyState = null } } @@ -526,23 +536,3 @@ internal fun App() { } } } - -/*internal expect suspend fun generateKey( - alg: CryptoAlgorithm, - attestation: ByteArray?, - withBiometricAuth: Duration?, - - ): KmmResult - -internal expect suspend fun sign( - data: ByteArray, - alg: CryptoAlgorithm, - signingKey: CryptoPrivateKey -): KmmResult - -internal expect suspend fun loadPubKey(): KmmResult - -internal expect suspend fun loadPrivateKey(): KmmResult - -internal expect suspend fun storeCertChain(): KmmResult -internal expect suspend fun getCertChain(): KmmResult>*/ \ No newline at end of file diff --git a/demoapp/composeApp/src/iosMain/kotlin/at/asitplus/cryptotest/App.ios.kt b/demoapp/composeApp/src/iosMain/kotlin/at/asitplus/cryptotest/App.ios.kt index 0a55cc52..e1627a17 100644 --- a/demoapp/composeApp/src/iosMain/kotlin/at/asitplus/cryptotest/App.ios.kt +++ b/demoapp/composeApp/src/iosMain/kotlin/at/asitplus/cryptotest/App.ios.kt @@ -1,2 +1,6 @@ package at.asitplus.cryptotest +import at.asitplus.signum.supreme.os.PlatformSigningProvider +import at.asitplus.signum.supreme.os.SigningProvider + +actual val Provider: SigningProvider = PlatformSigningProvider diff --git a/demoapp/composeApp/src/jvmMain/kotlin/at/asitplus/cryptotest/Main.kt b/demoapp/composeApp/src/jvmMain/kotlin/at/asitplus/cryptotest/Main.kt index 2dd69504..f811e7cd 100644 --- a/demoapp/composeApp/src/jvmMain/kotlin/at/asitplus/cryptotest/Main.kt +++ b/demoapp/composeApp/src/jvmMain/kotlin/at/asitplus/cryptotest/Main.kt @@ -2,6 +2,11 @@ package at.asitplus.cryptotest import androidx.compose.ui.window.Window import androidx.compose.ui.window.application +import at.asitplus.signum.supreme.os.JKSProvider +import at.asitplus.signum.supreme.os.PlatformSigningProvider +import at.asitplus.signum.supreme.os.SigningProvider + +actual val Provider: SigningProvider = JKSProvider.Ephemeral().getOrThrow() fun main() = application { Window(onCloseRequest = ::exitApplication, title = "KMP-Crypto Demo") { diff --git a/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/os/SigningProvider.kt b/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/os/SigningProvider.kt index 89ec3371..6172c2f1 100644 --- a/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/os/SigningProvider.kt +++ b/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/os/SigningProvider.kt @@ -191,7 +191,7 @@ val PlatformSigningProvider get() = getPlatformSigningProvider(null) /** KT-71089 workaround * @see SigningProvider */ interface SigningProviderI> { suspend fun createSigningKey(alias: String, configure: DSLConfigureFn = null): KmmResult suspend fun getSignerForKey(alias: String, configure: DSLConfigureFn = null): KmmResult From 79275cf064f4609384926f054868515e29e75839 Mon Sep 17 00:00:00 2001 From: Jakob Heher Date: Wed, 4 Sep 2024 15:48:48 +0200 Subject: [PATCH 69/73] it's-a-read-me, mario --- README.md | 3 ++- .../src/jvmMain/kotlin/at/asitplus/cryptotest/Main.kt | 1 - 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 324052d5..b05d4d38 100644 --- a/README.md +++ b/README.md @@ -168,7 +168,8 @@ val signature = signer.sign(plaintext) { ... but you cannot change the fact that you configured this key to need biometry. Consider this when creating your keys. On the JVM, no native secure hardware storage is available. -File-based keystores can be accessed using [`JKSProvider { file {} }`](https://a-sit-plus.github.io/signum/supreme/at.asitplus.signum.supreme.os/-j-k-s-provider/.index.html). +File-based keystores can be accessed using [`JKSProvider { file { /* ... */ } }`](https://a-sit-plus.github.io/signum/supreme/at.asitplus.signum.supreme.os/-j-k-s-provider/.index.html). +Other keystores can be accessed using `JKSProvider { withBackingObject{ /* ... */ } }` or `JksProvider { customAccessor{ /* ... */ } }`. #### Key Attestation diff --git a/demoapp/composeApp/src/jvmMain/kotlin/at/asitplus/cryptotest/Main.kt b/demoapp/composeApp/src/jvmMain/kotlin/at/asitplus/cryptotest/Main.kt index f811e7cd..49e7a7a6 100644 --- a/demoapp/composeApp/src/jvmMain/kotlin/at/asitplus/cryptotest/Main.kt +++ b/demoapp/composeApp/src/jvmMain/kotlin/at/asitplus/cryptotest/Main.kt @@ -3,7 +3,6 @@ package at.asitplus.cryptotest import androidx.compose.ui.window.Window import androidx.compose.ui.window.application import at.asitplus.signum.supreme.os.JKSProvider -import at.asitplus.signum.supreme.os.PlatformSigningProvider import at.asitplus.signum.supreme.os.SigningProvider actual val Provider: SigningProvider = JKSProvider.Ephemeral().getOrThrow() From c6f0815df1147f6eb317a360b8a0d7e471b777e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bernd=20Pr=C3=BCnster?= Date: Wed, 4 Sep 2024 15:56:13 +0200 Subject: [PATCH 70/73] Oklahoma --- demoapp/composeApp/build.gradle.kts | 6 ++++++ .../kotlin/at/asitplus/signum/supreme/os/Attestation.kt | 2 ++ 2 files changed, 8 insertions(+) diff --git a/demoapp/composeApp/build.gradle.kts b/demoapp/composeApp/build.gradle.kts index 480e4183..c980810f 100644 --- a/demoapp/composeApp/build.gradle.kts +++ b/demoapp/composeApp/build.gradle.kts @@ -100,6 +100,12 @@ android { } } + +compose.desktop { + application { + mainClass = "at.asitplus.cryptotest.MainKt" + } +} buildConfig { // BuildConfig configuration here. // https://github.com/gmazzo/gradle-buildconfig-plugin#usage-in-kts diff --git a/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/os/Attestation.kt b/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/os/Attestation.kt index 552736b7..3ccfd17a 100644 --- a/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/os/Attestation.kt +++ b/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/os/Attestation.kt @@ -21,6 +21,8 @@ sealed interface Attestation { } } +@Serializable +@SerialName("self") data class SelfAttestation ( @Serializable(with=X509CertificateBase64UrlSerializer::class) @SerialName("x5c") From 0db13dcee9689ef084d4b5b39a03200e523a6a42 Mon Sep 17 00:00:00 2001 From: Jakob Heher Date: Wed, 4 Sep 2024 15:59:38 +0200 Subject: [PATCH 71/73] attestation repudiation commit --- .../asitplus/signum/supreme/os/JKSProvider.kt | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/supreme/src/jvmMain/kotlin/at/asitplus/signum/supreme/os/JKSProvider.kt b/supreme/src/jvmMain/kotlin/at/asitplus/signum/supreme/os/JKSProvider.kt index b10986c6..ae071601 100644 --- a/supreme/src/jvmMain/kotlin/at/asitplus/signum/supreme/os/JKSProvider.kt +++ b/supreme/src/jvmMain/kotlin/at/asitplus/signum/supreme/os/JKSProvider.kt @@ -62,20 +62,16 @@ class JKSSignerConfiguration: PlatformSignerConfigurationBase(), JvmEphemeralSig var privateKeyPassword: CharArray? = null } -interface JKSSigner: Signer, Signer.Attestable, Signer.WithAlias { +interface JKSSigner: Signer, Signer.WithAlias { class EC internal constructor (config: JvmEphemeralSignerCompatibleConfiguration, privateKey: PrivateKey, publicKey: CryptoPublicKey.EC, signatureAlgorithm: SignatureAlgorithm.ECDSA, - certificate: X509Certificate, override val alias: String) - : EphemeralSigner.EC(config, privateKey, publicKey, signatureAlgorithm), JKSSigner { - override val attestation = SelfAttestation(certificate) - } + override val alias: String) + : EphemeralSigner.EC(config, privateKey, publicKey, signatureAlgorithm), JKSSigner class RSA internal constructor (config: JvmEphemeralSignerCompatibleConfiguration, privateKey: PrivateKey, publicKey: CryptoPublicKey.Rsa, signatureAlgorithm: SignatureAlgorithm.RSA, - certificate: X509Certificate, override val alias: String) - : EphemeralSigner.RSA(config, privateKey, publicKey, signatureAlgorithm), JKSSigner { - override val attestation = SelfAttestation(certificate) - } + override val alias: String) + : EphemeralSigner.RSA(config, privateKey, publicKey, signatureAlgorithm), JKSSigner } private fun keystoreGetInstance(type: String, provider: String?) = when (provider) { @@ -175,12 +171,12 @@ class JKSProvider internal constructor (private val access: JKSAccessor) SignatureAlgorithm.ECDSA( digest = if (config.ec.v.digestSpecified) config.ec.v.digest else Digest.SHA256, requiredCurve = publicKey.curve), - certificate, alias) + alias) is CryptoPublicKey.Rsa -> JKSSigner.RSA(config, privateKey as RSAPrivateKey, publicKey, SignatureAlgorithm.RSA( digest = if (config.rsa.v.digestSpecified) config.rsa.v.digest else Digest.SHA256, padding = if (config.rsa.v.paddingSpecified) config.rsa.v.padding else RSAPadding.PSS), - certificate, alias) + alias) } override suspend fun getSignerForKey( From 432693d15945f72b838a45fb6ba05a507ea2d8e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bernd=20Pr=C3=BCnster?= Date: Wed, 4 Sep 2024 16:12:15 +0200 Subject: [PATCH 72/73] remove kmp-crypto remnants --- DEVELOPMENT.md | 4 ++-- README.md | 2 +- demoapp/DEVELOPMENT.md | 2 +- demoapp/README.MD | 2 +- .../src/commonMain/kotlin/at/asitplus/cryptotest/App.kt | 2 +- .../src/jvmMain/kotlin/at/asitplus/cryptotest/Main.kt | 2 +- supreme/build.gradle.kts | 2 +- .../at/asitplus/signum/supreme/os/IosKeychainProvider.kt | 2 +- 8 files changed, 9 insertions(+), 9 deletions(-) diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index fdf2cdc6..d0c1f80c 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -1,6 +1,6 @@ # Development -Development happens in branch [development](https://github.com/a-sit-plus/kmp-crypto/tree/development). The main branch always tracks the latest release. +Development happens in branch [development](https://github.com/a-sit-plus/signum/tree/development). The main branch always tracks the latest release. Hence, create PRs against `development`. Use dedicated `release/x.y.z` branches to prepare releases and create release PRs against `main`, which will then be merged back into `development`. **Clone recursively, since we depend on a forked swift-klib plugin which is includes ad a git submodule" @@ -53,4 +53,4 @@ To publish locally for testing, one can skip the signing tasks: ## Creating a new release -Create a release branch and do the usual commits, i.e. setting the version number and so on. Push it to Github. Run the workflow "Build iOS Framework", and attach the artefacts to the release info page on GitHub. Use the link from there to update the [Swift Package](https://github.com/a-sit-plus/swift-package-kmp-crypto), modifying `Package.swift` and entering the URLs. The checksum is the output of `sha256sum *framework.zip`. +Create a release branch and do the usual commits, i.e. setting the version number and so on. Push it to Github. Run the workflow "Build iOS Framework", and attach the artefacts to the release info page on GitHub. Use the link from there to update the [Swift Package](https://github.com/a-sit-plus/swift-package-signum), modifying `Package.swift` and entering the URLs. The checksum is the output of `sha256sum *framework.zip`. diff --git a/README.md b/README.md index b05d4d38..a81882f0 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,7 @@ This last bit means that **you can work with X509 Certificates, public keys, CSRs and arbitrary ASN.1 structures on iOS.** The very first bit means that you can verify signatures on the JVM, Android and on iOS. -**Do check out the full API docs [here](https://a-sit-plus.github.io/kmp-crypto/)**! +**Do check out the full API docs [here](https://a-sit-plus.github.io/signum/)**! ## Usage diff --git a/demoapp/DEVELOPMENT.md b/demoapp/DEVELOPMENT.md index f860754a..fa22a5e2 100644 --- a/demoapp/DEVELOPMENT.md +++ b/demoapp/DEVELOPMENT.md @@ -1,5 +1,5 @@ **REQUIRES a MacOS Host to build all modules** * recursively clone this repo -* set `sdk.dir=/absulute/path/to/Android/sdk` inside `kmp-crypto/local.properties` +* set `sdk.dir=/absulute/path/to/Android/sdk` inside `signum/local.properties` * import the this project into Android studio \ No newline at end of file diff --git a/demoapp/README.MD b/demoapp/README.MD index 3facf3fc..7b840d1d 100644 --- a/demoapp/README.MD +++ b/demoapp/README.MD @@ -1,4 +1,4 @@ -# KMP Crypto Demo Multiplatform (iOS, Android JVM) App +# Supreme Multiplatform (JVM, Android, iOS) Demo App ![img.png](img.png) diff --git a/demoapp/composeApp/src/commonMain/kotlin/at/asitplus/cryptotest/App.kt b/demoapp/composeApp/src/commonMain/kotlin/at/asitplus/cryptotest/App.kt index 2e53ffcc..bd831df8 100644 --- a/demoapp/composeApp/src/commonMain/kotlin/at/asitplus/cryptotest/App.kt +++ b/demoapp/composeApp/src/commonMain/kotlin/at/asitplus/cryptotest/App.kt @@ -204,7 +204,7 @@ internal fun App() { horizontalArrangement = Arrangement.Center ) { Text( - text = "KMP Crypto Demo", + text = "Supreme Demo", style = MaterialTheme.typography.titleMedium, modifier = Modifier.padding( top = 16.dp, diff --git a/demoapp/composeApp/src/jvmMain/kotlin/at/asitplus/cryptotest/Main.kt b/demoapp/composeApp/src/jvmMain/kotlin/at/asitplus/cryptotest/Main.kt index 49e7a7a6..0363f346 100644 --- a/demoapp/composeApp/src/jvmMain/kotlin/at/asitplus/cryptotest/Main.kt +++ b/demoapp/composeApp/src/jvmMain/kotlin/at/asitplus/cryptotest/Main.kt @@ -8,7 +8,7 @@ import at.asitplus.signum.supreme.os.SigningProvider actual val Provider: SigningProvider = JKSProvider.Ephemeral().getOrThrow() fun main() = application { - Window(onCloseRequest = ::exitApplication, title = "KMP-Crypto Demo") { + Window(onCloseRequest = ::exitApplication, title = "Supreme Demo") { App() } } \ No newline at end of file diff --git a/supreme/build.gradle.kts b/supreme/build.gradle.kts index 48111528..ce1bfd28 100644 --- a/supreme/build.gradle.kts +++ b/supreme/build.gradle.kts @@ -151,7 +151,7 @@ publishing { scm { connection.set("scm:git:git@github.com:a-sit-plus/signum.git") developerConnection.set("scm:git:git@github.com:a-sit-plus/signum.git") - url.set("https://github.com/a-sit-plus/kmp-crypto") + url.set("https://github.com/a-sit-plus/signum") } } } diff --git a/supreme/src/iosMain/kotlin/at/asitplus/signum/supreme/os/IosKeychainProvider.kt b/supreme/src/iosMain/kotlin/at/asitplus/signum/supreme/os/IosKeychainProvider.kt index 67183a45..0f4883d7 100644 --- a/supreme/src/iosMain/kotlin/at/asitplus/signum/supreme/os/IosKeychainProvider.kt +++ b/supreme/src/iosMain/kotlin/at/asitplus/signum/supreme/os/IosKeychainProvider.kt @@ -133,7 +133,7 @@ private object KeychainTags { private val tags by lazy { val bundleId = NSBundle.mainBundle.bundleIdentifier ?: throw UnsupportedCryptoException("Keychain access is unsupported outside of a Bundle") - Pair("kmp-crypto-privatekey-$bundleId", "kmp-crypto.publickey-$bundleId") + Pair("supreme.privatekey-$bundleId", "supreme.publickey-$bundleId") } val PRIVATE_KEYS get() = tags.first val PUBLIC_KEYS get() = tags.second From 910ccc64b2de46a95503b18ec2e7d0c8f70d4391 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bernd=20Pr=C3=BCnster?= Date: Wed, 4 Sep 2024 16:13:08 +0200 Subject: [PATCH 73/73] fix jks testcase --- .../kotlin/at/asitplus/signum/supreme/os/JKSProviderTest.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/supreme/src/jvmTest/kotlin/at/asitplus/signum/supreme/os/JKSProviderTest.kt b/supreme/src/jvmTest/kotlin/at/asitplus/signum/supreme/os/JKSProviderTest.kt index f47354f8..0977f3ba 100644 --- a/supreme/src/jvmTest/kotlin/at/asitplus/signum/supreme/os/JKSProviderTest.kt +++ b/supreme/src/jvmTest/kotlin/at/asitplus/signum/supreme/os/JKSProviderTest.kt @@ -19,7 +19,6 @@ class JKSProviderTest : FreeSpec({ ks.getSignerForKey(alias) shouldNot succeed val signer = ks.createSigningKey(alias).getOrThrow() val otherSigner = ks.getSignerForKey(alias).getOrThrow() - otherSigner.attestation shouldBe signer.attestation val data = Random.Default.nextBytes(64) val signature = signer.sign(data).signature