diff --git a/.gitignore b/.gitignore index a1f44262..b049cf5a 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 @@ -40,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/CHANGELOG.md b/CHANGELOG.md index c6035854..590dff04 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +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/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 e06584cd..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 @@ -99,6 +99,88 @@ 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 + } + // 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 + 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. + +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). +Other keystores can be accessed using `JKSProvider { withBackingObject{ /* ... */ } }` or `JksProvider { customAccessor{ /* ... */ } }`. + +#### 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 +190,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/build.gradle.kts b/build.gradle.kts index 96223ddd..e7270272 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,8 +1,8 @@ import org.jetbrains.dokka.gradle.DokkaMultiModuleTask plugins { - id("at.asitplus.gradle.conventions") version "2.0.0+20240725" - id("com.android.library") version "8.2.0" apply (false) + id("at.asitplus.gradle.conventions") version "2.0.20+20240829" + id("com.android.library") version "8.2.2" apply (false) } group = "at.asitplus.signum" diff --git a/demoapp/DEVELOPMENT.md b/demoapp/DEVELOPMENT.md new file mode 100644 index 00000000..fa22a5e2 --- /dev/null +++ b/demoapp/DEVELOPMENT.md @@ -0,0 +1,5 @@ +**REQUIRES a MacOS Host to build all modules** + +* recursively clone this repo +* 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 new file mode 100644 index 00000000..7b840d1d --- /dev/null +++ b/demoapp/README.MD @@ -0,0 +1,33 @@ +# Supreme Multiplatform (JVM, Android, iOS) Demo App + + +![img.png](img.png) + +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. + +It is possible to generate key pairs, sign data, and verify the signature. + +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) + - 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..b235b462 --- /dev/null +++ b/demoapp/build.gradle.kts @@ -0,0 +1,14 @@ +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) +} + +allprojects { + repositories { + maven("https://s01.oss.sonatype.org/content/repositories/snapshots") + mavenCentral() + google() + } +} \ 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..c980810f --- /dev/null +++ b/demoapp/composeApp/build.gradle.kts @@ -0,0 +1,113 @@ +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) +} + +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:+") { + 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 = 30 + + 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 + } + + packaging { + resources.excludes.add("META-INF/versions/9/OSGI-INF/MANIFEST.MF") + } +} + + +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/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..ed9ea44a --- /dev/null +++ b/demoapp/composeApp/src/androidMain/kotlin/at/asitplus/cryptotest/App.android.kt @@ -0,0 +1,21 @@ +package at.asitplus.cryptotest + +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() + +class AppActivity : FragmentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContent { + App() + } + } +} 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..bd831df8 --- /dev/null +++ b/demoapp/composeApp/src/commonMain/kotlin/at/asitplus/cryptotest/App.kt @@ -0,0 +1,538 @@ +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.sign.Signer +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 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()) } + +/* 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: (SignerConfiguration.()->Unit) = { + if (this is PlatformSignerConfigurationBase) { + 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 = "Supreme 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 = Provider.createSigningKey(ALIAS) { + 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) + paddings = RSAPadding.entries.toSet() + bits = 1024 + } + } + else -> error("unreachable") + } + + 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 (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…" + Provider.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…" + Provider.deleteSigningKey(ALIAS) + .onFailure { Napier.e("Failed to delete key", it) } + 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).asKmmResult() } + .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") }) + } + } + } +} 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..e1627a17 --- /dev/null +++ b/demoapp/composeApp/src/iosMain/kotlin/at/asitplus/cryptotest/App.ios.kt @@ -0,0 +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/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/Main.kt b/demoapp/composeApp/src/jvmMain/kotlin/at/asitplus/cryptotest/Main.kt new file mode 100644 index 00000000..0363f346 --- /dev/null +++ b/demoapp/composeApp/src/jvmMain/kotlin/at/asitplus/cryptotest/Main.kt @@ -0,0 +1,14 @@ +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.SigningProvider + +actual val Provider: SigningProvider = JKSProvider.Ephemeral().getOrThrow() + +fun main() = application { + Window(onCloseRequest = ::exitApplication, title = "Supreme 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..e315ecf6 --- /dev/null +++ b/demoapp/gradle/libs.versions.toml @@ -0,0 +1,35 @@ +[versions] + +biometric = "1.2.0-alpha05" +kotlin = "2.0.20" +compose = "1.6.11" +agp = "8.2.2" +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.1" + +[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.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" } diff --git a/demoapp/gradle/wrapper/gradle-wrapper.jar b/demoapp/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 00000000..7454180f Binary files /dev/null and b/demoapp/gradle/wrapper/gradle-wrapper.jar differ 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 00000000..8cfb4927 Binary files /dev/null and b/demoapp/img.png differ diff --git a/demoapp/iosApp/iosApp.xcodeproj/project.pbxproj b/demoapp/iosApp/iosApp.xcodeproj/project.pbxproj new file mode 100644 index 00000000..fb7d63bf --- /dev/null +++ b/demoapp/iosApp/iosApp.xcodeproj/project.pbxproj @@ -0,0 +1,392 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 56; + objects = { + +/* Begin PBXBuildFile section */ + A93A953B29CC810C00F8E227 /* iosApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = A93A953A29CC810C00F8E227 /* iosApp.swift */; }; + A93A953F29CC810D00F8E227 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A93A953E29CC810D00F8E227 /* Assets.xcassets */; }; + A93A954229CC810D00F8E227 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A93A954129CC810D00F8E227 /* Preview Assets.xcassets */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + A93A953729CC810C00F8E227 /* CryptoTest App.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "CryptoTest App.app"; sourceTree = BUILT_PRODUCTS_DIR; }; + A93A953A29CC810C00F8E227 /* iosApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = iosApp.swift; sourceTree = ""; }; + A93A953E29CC810D00F8E227 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + A93A954129CC810D00F8E227 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; + CEC69C902BAB545100A8FEA5 /* iosApp.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = iosApp.entitlements; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + A93A953429CC810C00F8E227 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + A93A952E29CC810C00F8E227 = { + isa = PBXGroup; + children = ( + A93A953929CC810C00F8E227 /* iosApp */, + A93A953829CC810C00F8E227 /* Products */, + C4127409AE3703430489E7BC /* Frameworks */, + ); + sourceTree = ""; + }; + A93A953829CC810C00F8E227 /* Products */ = { + isa = PBXGroup; + children = ( + A93A953729CC810C00F8E227 /* CryptoTest App.app */, + ); + name = Products; + sourceTree = ""; + }; + A93A953929CC810C00F8E227 /* iosApp */ = { + isa = PBXGroup; + children = ( + CEC69C902BAB545100A8FEA5 /* iosApp.entitlements */, + A93A953A29CC810C00F8E227 /* iosApp.swift */, + A93A953E29CC810D00F8E227 /* Assets.xcassets */, + A93A954029CC810D00F8E227 /* Preview Content */, + ); + path = iosApp; + sourceTree = ""; + }; + A93A954029CC810D00F8E227 /* Preview Content */ = { + isa = PBXGroup; + children = ( + A93A954129CC810D00F8E227 /* Preview Assets.xcassets */, + ); + path = "Preview Content"; + sourceTree = ""; + }; + C4127409AE3703430489E7BC /* Frameworks */ = { + isa = PBXGroup; + children = ( + ); + name = Frameworks; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + A93A953629CC810C00F8E227 /* iosApp */ = { + isa = PBXNativeTarget; + buildConfigurationList = A93A954529CC810D00F8E227 /* Build configuration list for PBXNativeTarget "iosApp" */; + buildPhases = ( + A9D80A052AAB5CDE006C8738 /* ShellScript */, + A93A953329CC810C00F8E227 /* Sources */, + A93A953429CC810C00F8E227 /* Frameworks */, + A93A953529CC810C00F8E227 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = iosApp; + productName = iosApp; + productReference = A93A953729CC810C00F8E227 /* CryptoTest App.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + A93A952F29CC810C00F8E227 /* Project object */ = { + isa = PBXProject; + attributes = { + LastSwiftUpdateCheck = 1420; + LastUpgradeCheck = 1420; + TargetAttributes = { + A93A953629CC810C00F8E227 = { + CreatedOnToolsVersion = 14.2; + }; + }; + }; + buildConfigurationList = A93A953229CC810C00F8E227 /* Build configuration list for PBXProject "iosApp" */; + compatibilityVersion = "Xcode 14.0"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = A93A952E29CC810C00F8E227; + productRefGroup = A93A953829CC810C00F8E227 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + A93A953629CC810C00F8E227 /* iosApp */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + A93A953529CC810C00F8E227 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + A93A954229CC810D00F8E227 /* Preview Assets.xcassets in Resources */, + A93A953F29CC810D00F8E227 /* Assets.xcassets in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + A9D80A052AAB5CDE006C8738 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "cd \"$SRCROOT/..\"\n./gradlew :composeApp:embedAndSignAppleFrameworkForXcode\n"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + A93A953329CC810C00F8E227 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + A93A953B29CC810C00F8E227 /* iosApp.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + A93A954329CC810D00F8E227 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 16.2; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + A93A954429CC810D00F8E227 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 16.2; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + A93A954629CC810D00F8E227 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = iosApp/iosApp.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_ASSET_PATHS = "\"iosApp/Preview Content\""; + DEVELOPMENT_TEAM = 9CYHJNG644; + ENABLE_PREVIEWS = YES; + FRAMEWORK_SEARCH_PATHS = ( + "${inherited}", + "$(SRCROOT)/../composeApp/build/xcode-frameworks/$(CONFIGURATION)/$(SDK_NAME)", + ); + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_NSFaceIDUsageDescription = "Unlocking Private Keys for Signing"; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + OTHER_LDFLAGS = ( + "${inherited}", + "-framework", + ComposeApp, + ); + PRODUCT_BUNDLE_IDENTIFIER = at.asitplus.cryptotest.iosApp; + PRODUCT_NAME = "CryptoTest App"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + A93A954729CC810D00F8E227 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = iosApp/iosApp.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_ASSET_PATHS = "\"iosApp/Preview Content\""; + DEVELOPMENT_TEAM = 9CYHJNG644; + ENABLE_PREVIEWS = YES; + FRAMEWORK_SEARCH_PATHS = ( + "${inherited}", + "$(SRCROOT)/../composeApp/build/xcode-frameworks/$(CONFIGURATION)/$(SDK_NAME)", + ); + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_NSFaceIDUsageDescription = "Unlocking Private Keys for Signing"; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + OTHER_LDFLAGS = ( + "${inherited}", + "-framework", + ComposeApp, + ); + PRODUCT_BUNDLE_IDENTIFIER = at.asitplus.cryptotest.iosApp; + PRODUCT_NAME = "CryptoTest App"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + A93A953229CC810C00F8E227 /* Build configuration list for PBXProject "iosApp" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + A93A954329CC810D00F8E227 /* Debug */, + A93A954429CC810D00F8E227 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + A93A954529CC810D00F8E227 /* Build configuration list for PBXNativeTarget "iosApp" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + A93A954629CC810D00F8E227 /* Debug */, + A93A954729CC810D00F8E227 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = A93A952F29CC810C00F8E227 /* Project object */; +} diff --git a/demoapp/iosApp/iosApp.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/demoapp/iosApp/iosApp.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 00000000..919434a6 --- /dev/null +++ b/demoapp/iosApp/iosApp.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + 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/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..62d6c572 --- /dev/null +++ b/demoapp/settings.gradle.kts @@ -0,0 +1,36 @@ +rootProject.name = "CryptoTest-App" +include(":composeApp") + +pluginManagement { + repositories { + google() + gradlePluginPortal() + mavenCentral() + + //required for indispensable modules composite build + maven("https://s01.oss.sonatype.org/content/repositories/snapshots") + //required for indispensable modules composite build + 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() + } +} 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/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() 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/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/indispensable/src/commonMain/kotlin/at/asitplus/signum/indispensable/CryptoPublicKey.kt b/indispensable/src/commonMain/kotlin/at/asitplus/signum/indispensable/CryptoPublicKey.kt index 92ebbbe9..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( @@ -224,7 +225,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 +286,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 } } @@ -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 @@ -409,7 +411,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..9930957e 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,17 @@ 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); +} + +/** 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 + 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..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,10 +29,12 @@ enum class ECCurve( val jwkName: String, 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); + /** 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 { 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/io/Encoding.kt b/indispensable/src/commonMain/kotlin/at/asitplus/signum/indispensable/io/Encoding.kt index bd3d867b..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,21 +1,23 @@ package at.asitplus.signum.indispensable.io -import at.asitplus.catching +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 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 +26,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 +34,78 @@ 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 { +open 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 +) + +/** De-/serializes a public key as a Base64Url-encoded IOS encoding public key */ +object IosPublicKeySerializer: TransformingSerializerTemplate( + parent = ByteArrayBase64UrlSerializer, + encodeAs = CryptoPublicKey::iosEncoded, + decodeAs = CryptoPublicKey::fromIosEncoded) + +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/commonMain/kotlin/at/asitplus/signum/indispensable/pki/AlternativeNames.kt b/indispensable/src/commonMain/kotlin/at/asitplus/signum/indispensable/pki/AlternativeNames.kt index 9b17d7bf..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) { @@ -109,12 +110,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/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/indispensable/src/iosMain/kotlin/CommonCryptoExtensions.kt b/indispensable/src/iosMain/kotlin/CommonCryptoExtensions.kt new file mode 100644 index 00000000..123c3eb2 --- /dev/null +++ b/indispensable/src/iosMain/kotlin/CommonCryptoExtensions.kt @@ -0,0 +1,92 @@ +@file:OptIn(ExperimentalForeignApi::class) +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 +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") +}!! +val SpecializedSignatureAlgorithm.secKeyAlgorithm get() = + this.algorithm.secKeyAlgorithm + +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") +}!! + +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 35e33af4..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,6 +37,10 @@ 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) @@ -52,10 +56,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) + } + } } } } @@ -63,6 +71,28 @@ fun SignatureAlgorithm.getJCASignatureInstance(provider: String? = null) = catch 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) = 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) = + this.algorithm.getJCASignatureInstancePreHashed(provider) + val Digest.jcaName get() = when (this) { Digest.SHA256 -> "SHA-256" 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() - } -}) 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/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/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/settings.gradle.kts b/settings.gradle.kts index 76aec1ac..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") @@ -16,12 +17,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 4e47a0f3..ce1bfd28 100644 --- a/supreme/build.gradle.kts +++ b/supreme/build.gradle.kts @@ -26,7 +26,8 @@ buildscript { } -version = "0.1.1-PRE" +val supremeVersion: String by extra +version = supremeVersion wireAndroidInstrumentedTests() @@ -52,19 +53,11 @@ 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") - } - /* + sourceSets.androidMain.dependencies { implementation("androidx.biometric:biometric:1.2.0-alpha05") } - */ } @@ -80,7 +73,7 @@ android { namespace = "at.asitplus.signum.supreme" compileSdk = 34 defaultConfig { - minSdk = 33 + minSdk = 30 testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } @@ -102,11 +95,12 @@ android { } testOptions { + targetSdk=30 managedDevices { localDevices { create("pixel2api33") { device = "Pixel 2" - apiLevel = 33 + apiLevel = 30 systemImageSource = "aosp-atd" } } @@ -157,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/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..706810bd --- /dev/null +++ b/supreme/src/androidInstrumentedTest/kotlin/at/asitplus/signum/supreme/os/AndroidKeyStoreProviderTests.kt @@ -0,0 +1,35 @@ +package at.asitplus.signum.supreme.os + +import at.asitplus.signum.indispensable.CryptoPublicKey +import at.asitplus.signum.indispensable.SignatureAlgorithm +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({ + "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).signature + + SignatureAlgorithm.ECDSAwithSHA256.verifierFor(publicKey).getOrThrow() + .verify(plaintext, signature).getOrThrow() shouldBe Unit //no errors reported + + } +}) 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/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/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..126900aa --- /dev/null +++ b/supreme/src/androidMain/kotlin/at/asitplus/signum/supreme/hazmat/InternalsAccessors.kt @@ -0,0 +1,22 @@ +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 +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. */ +@HazardousMaterials +val Signer.jcaPrivateKey get() = when (this) { + is AndroidEphemeralSigner -> this.privateKey + is AndroidKeystoreSigner -> 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 new file mode 100644 index 00000000..a48e144b --- /dev/null +++ b/supreme/src/androidMain/kotlin/at/asitplus/signum/supreme/os/AndroidKeyStoreProvider.kt @@ -0,0 +1,424 @@ +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 +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.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.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 +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.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.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 + @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. */ + var strongBox: FeaturePreference = PREFERRED +} +class AndroidSigningKeyConfiguration internal constructor(): PlatformSigningKeyConfigurationBase() { + override val hardware = childOrNull(::AndroidKeymasterConfiguration) +} + +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 + + internal val explicitContext: FragmentContext get() = when { + this::fragment.isInitialized -> FragmentContext.OfFragment(fragment) + else -> FragmentContext.OfActivity(activity) + } + 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) +} + +/** + * Resolve [what] differently based on whether the [vA]lue was [spec]ified. + * + * * [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, 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(", ")}") + 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") + } + } + +/** A provider that manages keys in the [Android Key Store](https://developer.android.com/privacy-and-security/keystore). */ +object AndroidKeyStoreProvider: + PlatformSigningProviderI +{ + + private val ks: KeyStore get() = + KeyStore.getInstance("AndroidKeyStore").apply { load(null, null) } + + @SuppressLint("WrongConstant") + override suspend fun createSigningKey( + alias: String, + configure: DSLConfigureFn + ) = withContext(keystoreContext) { catching { + if (ks.containsAlias(alias)) { + throw NoSuchElementException("Key with alias $alias already exists") + } + 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 { + setInvalidatedByBiometricEnrollment(it.factors.v.biometry && + !it.factors.v.biometryWithNewFactors) + setUserAuthenticationRequired(true) + 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() + 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() + }} + + override suspend fun getSignerForKey( + alias: String, + configure: DSLConfigureFn + ): KmmResult = withContext(keystoreContext) { catching { + val config = DSL.resolve(::AndroidSignerConfiguration, configure) + 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 (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) + } + } + + return@catching when (publicKey) { + is CryptoPublicKey.EC -> + AndroidKeystoreSigner.ECDSA( + jcaPrivateKey, alias, keyInfo, config, publicKey, + attestation, algorithm as SignatureAlgorithm.ECDSA) + is CryptoPublicKey.Rsa -> + AndroidKeystoreSigner.RSA( + jcaPrivateKey, alias, keyInfo, config, publicKey, + attestation, algorithm as SignatureAlgorithm.RSA) + } + }} + + override suspend fun deleteSigningKey(alias: String) = catching { withContext(keystoreContext) { + ks.deleteEntry(alias) + }} +} + +sealed class AndroidKeystoreSigner private constructor( + internal val jcaPrivateKey: PrivateKey, + final override val alias: String, + val keyInfo: KeyInfo, + private val config: AndroidSignerConfiguration, + final override val attestation: AndroidKeystoreAttestation? +) : PlatformSigningProviderSigner, SignerI.Attestable { + + final override val mayRequireUserUnlock: Boolean get() = this.needsAuthentication + + 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: DSL.ConfigStack, forSpecificKey: CryptoObject?) { + val channel = Channel(capacity = Channel.RENDEZVOUS) + 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.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) { + 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.forEach { it.invalidBiometryCallback?.invoke() } + } + } + val prompt = when (effectiveContext) { + is FragmentContext.OfActivity -> BiometricPrompt(effectiveContext.activity, executor, siphon) + is FragmentContext.OfFragment -> BiometricPrompt(effectiveContext.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})") + } + } + + internal suspend fun getJCASignature(signingConfig: AndroidSignerSigningConfiguration): Signature = + signatureAlgorithm.getJCASignatureInstance().getOrThrow().also { + if (needsAuthenticationForEveryUse) { + it.initSign(jcaPrivateKey) + attemptBiometry(DSL.ConfigStack(signingConfig.unlockPrompt.v, config.unlockPrompt.v), CryptoObject(it)) + } else { + try { + it.initSign(jcaPrivateKey) + } catch (_: UserNotAuthenticatedException) { + attemptBiometry(DSL.ConfigStack(signingConfig.unlockPrompt.v, config.unlockPrompt.v), null) + it.initSign(jcaPrivateKey) + } + } + } + + final override suspend fun trySetupUninterruptedSigning(configure: DSLConfigureFn) = catching { + if (needsAuthentication && !needsAuthenticationForEveryUse) { + withContext(keystoreContext) { getJCASignature(DSL.resolve(::AndroidSignerSigningConfiguration, configure)) } + } + } + + final override suspend fun sign( + data: SignatureInput, + configure: DSLConfigureFn + ): 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@AndroidKeystoreSigner) { + is ECDSA -> CryptoSignature.EC.parseFromJca(jcaSig).withCurve(publicKey.curve) + is RSA -> CryptoSignature.RSAorHMAC.parseFromJca(jcaSig) + } + }} + + class ECDSA internal constructor(jcaPrivateKey: PrivateKey, + alias: String, + keyInfo: KeyInfo, + config: AndroidSignerConfiguration, + override val publicKey: CryptoPublicKey.EC, + attestation: AndroidKeystoreAttestation?, + override val signatureAlgorithm: SignatureAlgorithm.ECDSA) + : AndroidKeystoreSigner(jcaPrivateKey, alias, keyInfo, config, attestation), SignerI.ECDSA + + class RSA internal constructor(jcaPrivateKey: PrivateKey, + alias: String, + keyInfo: KeyInfo, + config: AndroidSignerConfiguration, + override val publicKey: CryptoPublicKey.Rsa, + attestation: AndroidKeystoreAttestation?, + override val signatureAlgorithm: SignatureAlgorithm.RSA) + : AndroidKeystoreSigner(jcaPrivateKey, alias, keyInfo, config, attestation), SignerI.RSA +} + +val AndroidKeystoreSigner.needsAuthentication inline get() = + keyInfo.isUserAuthenticationRequired +val AndroidKeystoreSigner.needsAuthenticationForEveryUse inline get() = + keyInfo.isUserAuthenticationRequired && + (keyInfo.userAuthenticationValidityDurationSeconds <= 0) + +internal actual fun getPlatformSigningProvider(configure: DSLConfigureFn): PlatformSigningProviderI<*,*,*> = + 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 new file mode 100644 index 00000000..84548b6d --- /dev/null +++ b/supreme/src/androidMain/kotlin/at/asitplus/signum/supreme/sign/EphemeralKeysImpl.kt @@ -0,0 +1,71 @@ +package at.asitplus.signum.supreme.sign + +import android.security.keystore.KeyProperties +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.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 +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 (internal val privateKey: PrivateKey) : Signer { + override val mayRequireUserUnlock = false + override suspend fun sign(data: SignatureInput) = signCatching { + val inputData = data.convertTo(signatureAlgorithm.preHashedSignatureFormat).getOrThrow() + signatureAlgorithm.getJCASignatureInstancePreHashed(provider = null).getOrThrow().run { + initSign(privateKey) + inputData.data.forEach { update(it) } + 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 { + + 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 { + + override fun parseFromJca(bytes: ByteArray) = CryptoSignature.RSAorHMAC.parseFromJca(bytes) + } +} + +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 -> + EphemeralKeyBase.EC(AndroidEphemeralSigner::EC, + pair.private, CryptoPublicKey.fromJcaPublicKey(pair.public).getOrThrow() as CryptoPublicKey.EC, + digests = alg.digests) + } + } + is SigningKeyConfiguration.RSAConfiguration -> { + KeyPairGenerator.getInstance(KeyProperties.KEY_ALGORITHM_RSA).run { + initialize(RSAKeyGenParameterSpec(alg.bits, alg.publicExponent.toJavaBigInteger())) + generateKeyPair() + }.let { pair -> + EphemeralKeyBase.RSA(AndroidEphemeralSigner::RSA, + pair.private, CryptoPublicKey.fromJcaPublicKey(pair.public).getOrThrow() as CryptoPublicKey.Rsa, + digests = alg.digests, paddings = alg.paddings) + } + } + } 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/SignatureResult.kt b/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/SignatureResult.kt new file mode 100644 index 00000000..9fca72ed --- /dev/null +++ b/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/SignatureResult.kt @@ -0,0 +1,63 @@ +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 { + /** 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 { + /** 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) + } + } +} +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) { + 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) { + 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 } + +/** Modifies the contained [CryptoSignature], usually in order to reinterpret it as a more narrow type. */ +inline fun SignatureResult.map(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) }) + +/** Runs the block, catches exceptions, and maps to [SignatureResult]. + * @see SignatureResult.FromException */ +internal inline fun signCatching(fn: ()->CryptoSignature.RawByteEncodable): SignatureResult<*> = + runCatching { fn() }.fold( + onSuccess = { SignatureResult.Success(it) }, + onFailure = { SignatureResult.FromException(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 72a5921e..ff4c5257 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,9 @@ package at.asitplus.signum.supreme -sealed class CryptoException(message: String? = null, cause: Throwable? = null) : Throwable(message, cause) -class CryptoOperationFailed(message: String) : CryptoException(message) +@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 -class UnsupportedCryptoException(message: String? = null, cause: Throwable? = null) : CryptoException(message, cause) +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) 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..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 @@ -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 @@ -7,8 +9,24 @@ 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) + + /** A collection of equivalent DSL configuration structures which shadow each other. + * @see getProperty */ + 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 = + 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 { val v: T @@ -18,9 +36,9 @@ object DSL { operator fun invoke(configure: Target.()->Unit) } - /** Constructed by: [DSL.Data.child]. */ - class DirectHolder internal constructor(default: T, private val factory: ()->(T & Any)) - : Invokable { + /** 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 override val v: T get() = _v @@ -28,7 +46,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,19 +59,28 @@ 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 } } + /** 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 @@ -61,24 +88,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 +118,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 +130,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) /** @@ -115,6 +143,28 @@ object DSL { protected fun integratedReceiver(): Integrated = Integrated() + /** + * Marks an inherited DSL substructure as unsupported. Attempts to use it throw [UnsupportedOperationException]. Use very sparingly. + */ + 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/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/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/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..3ccfd17a --- /dev/null +++ b/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/os/Attestation.kt @@ -0,0 +1,125 @@ +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.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 +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonClassDiscriminator + +@Serializable +@JsonClassDiscriminator("typ") +sealed interface Attestation { + companion object { + fun fromJSON(v: String) = Json.decodeFromString(v) + } +} + +@Serializable +@SerialName("self") +data class SelfAttestation ( + @Serializable(with=X509CertificateBase64UrlSerializer::class) + @SerialName("x5c") + val certificate: X509Certificate) : Attestation + +@Serializable +@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(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 (!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 + } +} + +val StrictJson = Json { ignoreUnknownKeys = true; isLenient = false } + +@Serializable +@SerialName("ios-appattest") +data class IosHomebrewAttestation( + @Serializable(with=ByteArrayBase64UrlSerializer::class) + val attestation: ByteArray, + @Serializable(with=ByteArrayBase64UrlSerializer::class) + val clientDataJSON: ByteArray): Attestation { + + 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) + 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 + + 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) 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..6172c2f1 --- /dev/null +++ b/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/os/SigningProvider.kt @@ -0,0 +1,207 @@ +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.SignatureResult +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.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 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 + 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 additional biometric factors can be added without invalidating the key */ + var biometryWithNewFactors = false; set(v) { field = v; if (v) 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" } + require (biometry || !biometryWithNewFactors) { "You cannot allow future biometric factors but disallow current ones" } + } + } + + 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)."} + } + } + + /** Require that this key is stored in some kind of hardware-backed storage, such as Android Keymaster or Apple Secure Enclave. */ + open val hardware = childOrNull(::SecureHardwareConfiguration) +} + +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 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 } + internal var digestSpecified = false +} +open class RSASignerConfiguration internal constructor(): DSL.Data() { + /** + * Explicitly specify the digest to sign over. + * Omit to default to a reasonable default choice. + * + * 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 + */ + lateinit var digest: Digest + internal val digestSpecified get() = this::digest.isInitialized + + /** + * Explicitly specify the padding to use. + * Omit to default to the only supported padding. + * + * 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 + */ + lateinit var padding: RSAPadding + internal val paddingSpecified get() = this::padding.isInitialized + + +} +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) +} + +open class UnlockPromptConfiguration: DSL.Data() { + + internal val _message = Stackable() + /** The prompt message to show to the user when asking for unlock */ + var message by _message + + internal val _cancelText = Stackable() + /** The message to show on the cancellation button */ + var cancelText by _cancelText + + 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(::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) : SignatureResult<*> + 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() +internal expect fun getPlatformSigningProvider(configure: DSLConfigureFn): PlatformSigningProviderI<*,*,*> + +/** KT-71089 workaround + * @see PlatformSigningProvider */ +interface PlatformSigningProviderI, + out SignerConfigT: PlatformSignerConfigurationBase, + out KeyConfigT: PlatformSigningKeyConfigurationBase<*>> + : SigningProviderI { + + companion object { + operator fun invoke(configure: DSLConfigureFn = null) = + catching { 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 `PlatformSigningProvider`. + * In platform-specific code, it is currently recommended to directly interface with your platform signing provider to get platform-specific functionality. + * (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. + * When creating a key, the returned signer's configuration is embedded in the signing key configuration as `signer {}`. + * + * @see JKSProvider + * @see AndroidKeyStoreProvider + * @see IosKeychainProvider + */ +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/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..aab02c3a --- /dev/null +++ b/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/sign/EphemeralKeys.kt @@ -0,0 +1,127 @@ +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 +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 +import at.asitplus.signum.supreme.os.SignerConfiguration + +internal expect fun makeEphemeralKey(configuration: EphemeralSigningKeyConfiguration) : EphemeralKey + +open class EphemeralSigningKeyConfigurationBase 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) +} +expect class EphemeralSigningKeyConfiguration internal constructor(): EphemeralSigningKeyConfigurationBase + +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 + + /** 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): KmmResult + } + /** An [EphemeralKey] suitable for RSA operations. */ + interface RSA: EphemeralKey { + override val publicKey: CryptoPublicKey.Rsa + override fun signer(configure: DSLConfigureFn): KmmResult + } + companion object { + operator fun invoke(configure: DSLConfigureFn = null) = + catching { makeEphemeralKey(DSL.resolve(::EphemeralSigningKeyConfiguration, configure)) } + } +} + +internal sealed class EphemeralKeyBase + (internal 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 { + + override fun signer(configure: DSLConfigureFn): KmmResult = catching { + val config = DSL.resolve(::EphemeralSignerConfiguration, configure) + val alg = config.ec.v + val digest = when (alg.digestSpecified) { + true -> { + require (digests.contains(alg.digest)) + { "Digest ${alg.digest} unsupported (supported: ${digests.joinToString(",")}" } + alg.digest + } + false -> + sequenceOf(publicKey.curve.nativeDigest, Digest.SHA256, Digest.SHA384, Digest.SHA512) + .firstOrNull(digests::contains) ?: digests.first() + } + return@catching signerFactory(config, privateKey, publicKey, SignatureAlgorithm.ECDSA(digest, publicKey.curve)) + } + } + + 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 { + + override fun signer(configure: DSLConfigureFn): KmmResult = catching { + val config = DSL.resolve(::EphemeralSignerConfiguration, configure) + val alg = config.rsa.v + val digest = when (alg.digestSpecified) { + true -> { + require (digests.contains(alg.digest)) + { "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() + } + } + val padding = when (alg.paddingSpecified) { + true -> { + require (paddings.contains(alg.padding)) + { "Padding ${alg.padding} unsupported (supported: ${paddings.joinToString(", ")}" } + alg.padding + } + false -> when { + paddings.contains(RSAPadding.PSS) -> RSAPadding.PSS + paddings.contains(RSAPadding.PKCS1) -> RSAPadding.PKCS1 + else -> paddings.first() + } + } + return@catching signerFactory(config, privateKey, publicKey, SignatureAlgorithm.RSA(digest, padding)) + } + } +} 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/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..8bd32255 --- /dev/null +++ b/supreme/src/commonMain/kotlin/at/asitplus/signum/supreme/sign/Signer.kt @@ -0,0 +1,141 @@ +package at.asitplus.signum.supreme.sign + +import at.asitplus.KmmResult +import at.asitplus.signum.indispensable.CryptoPublicKey +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.SignatureResult +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 + +/** 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 + /** 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 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) + /** 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 + } +} + +/** + * 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 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. + * 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 { + val signatureAlgorithm: SignatureAlgorithm + val publicKey: CryptoPublicKey + /** Whether the signer may ask for user interaction when [sign] is called */ + val mayRequireUserUnlock: Boolean get() = true + + /** Any [Signer] instantiation must be [ECDSA] or [RSA] */ + sealed interface AlgTrait: Signer + + /** A [Signer] that signs using ECDSA. */ + interface ECDSA: AlgTrait { + override val signatureAlgorithm: SignatureAlgorithm.ECDSA + override val publicKey: CryptoPublicKey.EC + } + + /** A [Signer] that signs using RSA. */ + interface RSA: 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]. */ + interface WithAlias: Signer { + val alias: String + } + + /** Some [Signer]s might have an attestation of some sort */ + interface Attestable: 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): SignatureResult<*> + suspend fun sign(data: ByteArray) = sign(SignatureInput(data)) + suspend fun sign(data: Sequence) = sign(SignatureInput(data)) + + companion object { + fun Ephemeral(configure: DSLConfigureFn = null) = + EphemeralKey(configure).transform(EphemeralKey::signer) + } +} + +/** + * 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 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/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..1d3d68a6 --- /dev/null +++ b/supreme/src/commonTest/kotlin/at/asitplus/signum/supreme/sign/EphemeralSignerCommonTests.kt @@ -0,0 +1,138 @@ +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.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({ + "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 + } + } + "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/iosMain/kotlin/at/asitplus/signum/supreme/InteropUtils.kt b/supreme/src/iosMain/kotlin/at/asitplus/signum/supreme/InteropUtils.kt index ec157da6..a6e564af 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,103 @@ 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 +import kotlin.experimental.ExperimentalNativeApi +import kotlin.native.ref.createCleaner + +@OptIn(ExperimentalNativeApi::class) +class AutofreeVariable> internal constructor( + 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) + } + } + @Suppress("UNUSED") + private val cleaner = createCleaner(arena, Arena::clear) + internal val ptr get() = variable.ptr + internal val value get() = variable.value +} -@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("[${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, val 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(val nsError: NSError): Throwable(nsError.toNiceString()) +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()) + 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 +120,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/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/hazmat/InternalsAccessors.kt b/supreme/src/iosMain/kotlin/at/asitplus/signum/supreme/hazmat/InternalsAccessors.kt new file mode 100644 index 00000000..3066a169 --- /dev/null +++ b/supreme/src/iosMain/kotlin/at/asitplus/signum/supreme/hazmat/InternalsAccessors.kt @@ -0,0 +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.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.EphemeralSigner +import at.asitplus.signum.supreme.sign.Signer +import kotlinx.cinterop.ExperimentalForeignApi +import platform.Security.SecKeyRef + +/** The underlying SecKeyRef referencing the ephemeral key's private key. */ +@HazardousMaterials +@Suppress("UNCHECKED_CAST") +val EphemeralKey.secKeyRef get() = (this as? EphemeralKeyBase<*>)?.privateKey as? AutofreeVariable + +/** 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 + 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 new file mode 100644 index 00000000..0f4883d7 --- /dev/null +++ b/supreme/src/iosMain/kotlin/at/asitplus/signum/supreme/os/IosKeychainProvider.kt @@ -0,0 +1,643 @@ +@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.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 io.github.aakira.napier.Napier +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.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.errSecUserCanceled +import platform.Security.kSecAccessControlBiometryCurrentSet +import platform.Security.kSecUseAuthenticationUIFail +import kotlin.math.min +import kotlin.time.Duration +import kotlin.time.TimeSource + + +private val keychainThreads = newFixedThreadPoolContext(nThreads = 4, name = "iOS Keychain Operations") + +private fun isSecureEnclaveSupportedConfiguration(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("supreme.privatekey-$bundleId", "supreme.publickey-$bundleId") + } + val PRIVATE_KEYS get() = tags.first + val PUBLIC_KEYS get() = tags.second +} + +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) { + backing = DISCOURAGED + } +} + +/** + * Resolve [what] differently based on whether the [vA]lue was [spec]ified. + * + * * [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, 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 + } + false -> { + if (valid.size != 1) + throw IllegalArgumentException("Key supports multiple ${what}s (${valid.joinToString(", ")}). You need to specify $what in signer configuration.") + valid.first() + } + } + +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) + + internal 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, + default = UnlockPromptConfiguration.defaultMessage) + localizedCancelTitle = stack.getProperty(UnlockPromptConfiguration::_cancelText, + 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 { + 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 + ) + } + } + 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" } + } + if (!needsAuthenticationForEveryUse) { + 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) + } + } } + + 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" } + val signingConfig = DSL.resolve(::IosSignerSigningConfiguration, configure) + val algorithm = signatureAlgorithm.secKeyAlgorithmPreHashed + val plaintext = data.convertTo(signatureAlgorithm.preHashedSignatureFormat).getOrThrow().data.first().toNSData() + 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) + }} + + 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 { + 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 bytesToSignature(sigBytes: ByteArray) = + CryptoSignature.EC.decodeFromDer(sigBytes).withCurve(publicKey.curve) + } + + 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) "} + + 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 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: PlatformSigningProviderI { + 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 storeKeyMetadata(alias: String, metadata: IosKeyMetadata) = memScoped { + val status = SecItemUpdate( + cfDictionaryOf( + kSecClass to kSecClassKey, + kSecAttrKeyClass to kSecAttrKeyClassPublic, + kSecAttrApplicationLabel to alias, + kSecAttrApplicationTag to KeychainTags.PUBLIC_KEYS), + cfDictionaryOf( + kSecAttrLabel to Json.encodeToString(metadata) + )) + if (status != errSecSuccess) { + throw CFCryptoOperationFailed(thing = "store key metadata", osStatus = status) + } + } + private fun getKeyMetadata(alias: String): IosKeyMetadata = 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!!.get(kSecAttrLabel).let(Json::decodeFromString) + else -> { + throw CFCryptoOperationFailed(thing = "retrieve key metadata", osStatus = status) + } + } + } + + override suspend fun createSigningKey( + alias: String, + configure: DSLConfigureFn + ): KmmResult = withContext(keychainThreads) { catching { + memScoped { + if (getPublicKey(alias) != null) + throw NoSuchElementException("Key with alias $alias already exists") + } + deleteSigningKey(alias).getOrThrow() /* 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 -> isSecureEnclaveSupportedConfiguration(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 hwProtection = config.hardware.v.protection.v) { + null -> { + kSecAttrAccessible mapsTo availability + } + else -> { + val factors = hwProtection.factors.v + kSecAttrAccessControl mapsTo corecall { + SecAccessControlCreateWithFlags( + null, availability, + when { + (factors.biometry && factors.deviceLock) -> kSecAccessControlUserPresence + factors.biometry -> if (factors.biometryWithNewFactors) kSecAccessControlBiometryAny else kSecAccessControlBiometryCurrentSet + 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 && + !isSecureEnclaveSupportedConfiguration(config._algSpecific.v)) { + throw UnsupportedCryptoException("The 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 clientData = IosHomebrewAttestation.ClientData( + publicKey = publicKey, challenge = attestationConfig.challenge) + val clientDataJSON = Json.encodeToString(clientData).encodeToByteArray() + + val assertionKeyAttestation = swiftasync { + service.attestKey(keyId, Digest.SHA256.digest(clientDataJSON).toNSData(), callback) + }.toByteArray() + Napier.v { "attested key ($assertionKeyAttestation)" } + + return@let IosHomebrewAttestation(attestation = assertionKeyAttestation, clientDataJSON = clientDataJSON) + } + } else null + + val metadata = IosKeyMetadata( + attestation = attestation, + 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) + } + ).also { storeKeyMetadata(alias, it) } + + Napier.v { "key $alias metadata stored (has attestation? ${attestation != null})" } + + val signerConfiguration = DSL.resolve(::IosSignerConfiguration, config.signer.v) + return@catching when (publicKey) { + is CryptoPublicKey.EC -> + IosSigner.ECDSA(alias, publicKey, metadata, signerConfiguration) + is CryptoPublicKey.Rsa -> + IosSigner.RSA(alias, publicKey, metadata, signerConfiguration) + } + }.also { + val e = it.exceptionOrNull() + if (e != null && e !is NoSuchElementException) { + // get rid of any "partial" keys + deleteSigningKey(alias) + } + }} + + override suspend fun getSignerForKey( + alias: String, + 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") + return@memScoped corecall { + SecKeyCopyExternalRepresentation(publicKey, error) + }.let { it.takeFromCF() }.toByteArray() + } + val publicKey = + CryptoPublicKey.fromIosEncoded(publicKeyBytes) + val metadata = getKeyMetadata(alias) + return@catching when (publicKey) { + is CryptoPublicKey.EC -> IosSigner.ECDSA(alias, publicKey, metadata, config) + is CryptoPublicKey.Rsa -> IosSigner.RSA(alias, publicKey, metadata, config) + } + }} + + override suspend fun deleteSigningKey(alias: String) = withContext(keychainThreads) { catching { + 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(",")) + } + } + } } +} + +internal actual fun getPlatformSigningProvider(configure: DSLConfigureFn): PlatformSigningProviderI<*,*,*> = + 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 new file mode 100644 index 00000000..a6bbed10 --- /dev/null +++ b/supreme/src/iosMain/kotlin/at/asitplus/signum/supreme/sign/EphemeralKeysImpl.kt @@ -0,0 +1,97 @@ +@file:OptIn(ExperimentalForeignApi::class) +package at.asitplus.signum.supreme.sign + +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 +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 +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.SecKeyRef +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.kSecPrivateKeyAttrs +import platform.Security.kSecPublicKeyAttrs + +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) = 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@signCatching when (val pubkey = publicKey) { + is CryptoPublicKey.EC -> CryptoSignature.EC.decodeFromDer(signatureBytes).withCurve(pubkey.curve) + is CryptoPublicKey.Rsa -> CryptoSignature.RSAorHMAC(signatureBytes) + } + } + class EC(config: EphemeralSignerConfiguration, privateKey: EphemeralKeyRef, + override val publicKey: CryptoPublicKey.EC, override val signatureAlgorithm: SignatureAlgorithm.ECDSA) + : EphemeralSigner(privateKey), Signer.ECDSA + + class RSA(config: EphemeralSignerConfiguration, privateKey: EphemeralKeyRef, + override val publicKey: CryptoPublicKey.Rsa, override val signatureAlgorithm: SignatureAlgorithm.RSA) + : EphemeralSigner(privateKey), Signer.RSA +} + +internal actual fun makeEphemeralKey(configuration: EphemeralSigningKeyConfiguration) : EphemeralKey { + val key = AutofreeVariable() + 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.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/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/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() 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..ba944973 --- /dev/null +++ b/supreme/src/jvmMain/kotlin/at/asitplus/signum/supreme/hazmat/InternalsAccessors.kt @@ -0,0 +1,16 @@ +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 + +/** 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 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..ae071601 --- /dev/null +++ b/supreme/src/jvmMain/kotlin/at/asitplus/signum/supreme/os/JKSProvider.kt @@ -0,0 +1,395 @@ +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.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 +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.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 +import java.security.interfaces.ECPrivateKey +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: PlatformSigningKeyConfigurationBase() { + /** The registered JCA provider to use. */ + var provider: String? = null + /** The password with which to protect the private key. */ + var privateKeyPassword: CharArray? = null + /** 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 +} + +interface JKSSigner: Signer, Signer.WithAlias { + class EC internal constructor (config: JvmEphemeralSignerCompatibleConfiguration, privateKey: PrivateKey, + publicKey: CryptoPublicKey.EC, signatureAlgorithm: SignatureAlgorithm.ECDSA, + 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, + override val alias: String) + : EphemeralSigner.RSA(config, privateKey, publicKey, signatureAlgorithm), JKSSigner +} + +private fun keystoreGetInstance(type: String, provider: String?) = when (provider) { + null -> KeyStore.getInstance(type) + else -> KeyStore.getInstance(type, provider) +} + +/** 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 +} + +/** 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 } +} + +/** + * 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 +} + +class JKSProvider internal constructor (private val access: JKSAccessor) + : SigningProviderI { + + override suspend fun createSigningKey( + 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 (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) + } + } + + private fun getSigner( + alias: String, + 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), + 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), + alias) + } + + override suspend fun getSignerForKey( + alias: String, + configure: DSLConfigureFn + ): KmmResult = catching { + 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) = catching { + access.forWriting().use { ctx -> + if (ctx.ks.containsAlias(alias)) { + ctx.ks.deleteEntry(alias) + ctx.markAsDirty() + } + } + } + + companion object { + operator fun invoke(configure: DSLConfigureFn = null) = catching { + makePlatformSigningProvider(DSL.resolve(::JKSProviderConfiguration, configure)) + } + fun Ephemeral(type: String = KeyStore.getDefaultType(), provider: String? = null) = catching { + JKSProvider(DummyJKSAccessor(keystoreGetInstance(type, provider).apply { load(null) })) + } + } +} + +internal class DummyJKSAccessor(override val ks: KeyStore): JKSAccessor, ReadAccessorBase, 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() +} + +/** + * 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. */ + 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 after the keystore has been modified. Can be `null`. */ + var flushCallback: ((KeyStore)->Unit)? = null + override fun validate() { + super.validate() + require(this::store.isInitialized) + } + } + + /** 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() + /** 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 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): JKSProvider = + when (val opt = config._keystore.v) { + is JKSProviderConfiguration.EphemeralKeyStore -> + 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) + } + +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.)") 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..f756861a --- /dev/null +++ b/supreme/src/jvmMain/kotlin/at/asitplus/signum/supreme/sign/EphemeralKeysImpl.kt @@ -0,0 +1,92 @@ +package at.asitplus.signum.supreme.sign + +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.signCatching +import com.ionspin.kotlin.bignum.integer.base63.toJavaBigInteger +import java.security.KeyPairGenerator +import java.security.PrivateKey +import java.security.spec.ECGenParameterSpec +import java.security.spec.RSAKeyGenParameterSpec + +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 (internal val privateKey: PrivateKey, private val provider: String?) : Signer { + override val mayRequireUserUnlock = false + override suspend fun sign(data: SignatureInput) = signCatching { + val preHashed = (data.format != null) + if (preHashed) { + require (data.format == signatureAlgorithm.preHashedSignatureFormat) + { "Pre-hashed data (format ${data.format}) unsupported for algorithm $signatureAlgorithm" } + } + (if (preHashed) + signatureAlgorithm.getJCASignatureInstancePreHashed(provider = provider).getOrThrow() + else + signatureAlgorithm.getJCASignatureInstance(provider = provider).getOrThrow()) + .run { + initSign(privateKey) + data.data.forEach { update(it) } + 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 { + + 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 { + + override fun parseFromJca(bytes: ByteArray) = CryptoSignature.RSAorHMAC.parseFromJca(bytes) + } +} + +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 -> { + getKPGInstance("EC", configuration.provider).run { + initialize(ECGenParameterSpec(alg.curve.jcaName)) + generateKeyPair() + }.let { pair -> + EphemeralKeyBase.EC(EphemeralSigner::EC, + pair.private, CryptoPublicKey.fromJcaPublicKey(pair.public).getOrThrow() as CryptoPublicKey.EC, + digests = alg.digests) + } + } + is SigningKeyConfiguration.RSAConfiguration -> { + getKPGInstance("RSA", configuration.provider).run { + initialize(RSAKeyGenParameterSpec(alg.bits, alg.publicExponent.toJavaBigInteger())) + generateKeyPair() + }.let { pair -> + EphemeralKeyBase.RSA(EphemeralSigner::RSA, + pair.private, CryptoPublicKey.fromJcaPublicKey(pair.public).getOrThrow() as CryptoPublicKey.Rsa, + digests = alg.digests, paddings = alg.paddings) + } + } + } 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/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 new file mode 100644 index 00000000..0977f3ba --- /dev/null +++ b/supreme/src/jvmTest/kotlin/at/asitplus/signum/supreme/os/JKSProviderTest.kt @@ -0,0 +1,73 @@ +package at.asitplus.signum.supreme.os + +import at.asitplus.signum.supreme.sign.makeVerifier +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 +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({ + "Ephemeral" { + val ks = JKSProvider.Ephemeral().getOrThrow() + val alias = "Elfenbeinschloss" + ks.getSignerForKey(alias) shouldNot succeed + val signer = ks.createSigningKey(alias).getOrThrow() + val otherSigner = ks.getSignerForKey(alias).getOrThrow() + + val data = Random.Default.nextBytes(64) + 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 = correctPassword + } + }.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 + } + + JKSProvider { + file { + file = tempfile + password = wrongPassword + } + }.getOrThrow().let { + // wrong password should fail + it.getSignerForKey(alias) shouldNot succeed + } + + JKSProvider { + file { + file = tempfile + 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) } + } +}) 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