diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 2aafa5061..22e9cbed9 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -43,7 +43,7 @@ jobs: defaults: run: - working-directory: ./rust/rust-code + working-directory: ./rust steps: - uses: actions/checkout@v6 @@ -52,7 +52,7 @@ jobs: path: | ~/.cargo/registry ~/.cargo/git - rust/rust-code/target + rust/target key: ${{ runner.os }}-cargo-host-${{ hashFiles('**/Cargo.lock') }} restore-keys: ${{ runner.os }}-cargo-host- - name: Check formatting @@ -68,7 +68,7 @@ jobs: defaults: run: - working-directory: ./rust/rust-code + working-directory: ./rust steps: - uses: actions/checkout@v6 @@ -77,7 +77,7 @@ jobs: path: | ~/.cargo/registry ~/.cargo/git - rust/rust-code/target + rust/target key: ${{ runner.os }}-cargo-host-${{ hashFiles('**/Cargo.lock') }} restore-keys: ${{ runner.os }}-cargo-host- - name: Build @@ -113,7 +113,7 @@ jobs: path: | ~/.cargo/registry ~/.cargo/git - rust/rust-code/target + rust/target key: ${{ runner.os }}-cargo-android-${{ hashFiles('**/Cargo.lock') }} restore-keys: ${{ runner.os }}-cargo-android- - name: Grant execute permission for gradlew diff --git a/.gitignore b/.gitignore index 23ac73ffc..239b62946 100644 --- a/.gitignore +++ b/.gitignore @@ -183,10 +183,79 @@ google-services.json ### Kotlin .kotlin/ +### Linux template +*~ + +# temporary files which can be created if a process still has a handle open of a deleted file +.fuse_hidden* + +# KDE directory preferences +.directory + +# Linux trash folder which might appear on any partition or disk +.Trash-* + +# .nfs files are created when an open file is removed but is still being accessed +.nfs* + +### Windows template +# Windows thumbnail cache files +Thumbs.db +Thumbs.db:encryptable +ehthumbs.db +ehthumbs_vista.db + +# Dump file +*.stackdump + +# Folder config file +[Dd]esktop.ini + +# Recycle Bin used on file shares +$RECYCLE.BIN/ + +# Windows Installer files +*.cab +*.msi +*.msix +*.msm +*.msp + +# Windows shortcuts +*.lnk + +### macOS template +# General +.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + docs/superpowers/ .superpowers/ .claude/ .worktrees/ graphify-out/ -.gitattributes \ No newline at end of file +.gitattributes diff --git a/CLAUDE.md b/CLAUDE.md index 869f43688..68b883c07 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -15,8 +15,8 @@ repository. ## Build & Test ```bash -make -C rust/rust-code test # Rust unit tests -make -C rust/rust-code bindgen # Generate uniffi bindings +make -C rust test # Rust unit tests +make -C rust bindgen # Generate uniffi bindings ./gradlew build # Full build ./gradlew test # All unit tests ./gradlew :app:test # Module-specific tests (preferred for small changes) @@ -54,7 +54,7 @@ Android password manager using Clean Architecture per module: | `:automation` | `@VaultItem` annotation + automation support | | `:automation-processor` | KSP processor generating code from `@VaultItem` | | `:legacy-migration` | The whole v1 to v2 migration: main password, item import (high risk) | -| `:rust` | Rust crypto/passkey ops via UniFFI-generated Kotlin bindings | +| `:core:rust` | Rust crypto/passkey ops via UniFFI-generated Kotlin bindings; Rust sources in `rust/crates/` | ## Key Patterns @@ -84,6 +84,7 @@ Password / Biometric ↓ unwrap ARK (Account Root Key) ────── Session (in-memory); wrapped in account_registry.pb; optionally escrowed in backup_ark_data.pb (see Backup Escrow) + ├─ unwrap (one per account record) → NamespaceKey ── never leaves Rust (see Account records) ↓ unwrap (one per vault) VaultKey ──────────────────── wrapped in VaultEntity.keyInformation (Room) ↓ unwrap (one per item) @@ -98,6 +99,13 @@ Password / Biometric both inside `ProtoAccount` in `account_registry.pb`. - **VaultKey / ItemKey** — wrapped with AES-256-GCM; moving items between vaults re-wraps only the ItemKey, not the ciphertext. +- **Account records** — data owned by the account rather than a vault (the password-health + report) goes through `AccountRecordSealer`: a fresh `NamespaceKey` per seal, wrapped by the ARK. + Both AADs carry a domain tag (`v1:ns/key`, `v1:ns/data`) so they never encode like a vault's or + an item's. Seal and open run inside Rust (`ArkSession.seal_record` / `open_record`); the key never + crosses UniFFI. Only recomputable caches belong here: 2.1.0 reports used another layout and are + simply rescanned. The namespace UUID and domains are on-disk identity, pinned by + `namespace_aads_are_pinned` and `account_record_still_opens`. - **AAD** (`itemId + vaultId`) — bound to every ciphertext; prevents transplant attacks. - **Rust FFI** (`de.davis.keygo.rust`) implements all wrap/unwrap/derive operations. @@ -155,10 +163,10 @@ carries its own rules: (for example, validating that a side-effecting API was invoked) - Do not use mocks as the default way to model dependencies when a fake or testFixture exists - Run broader tests for cross-module or security changes -- **Rust fakes** — `:rust` uses UniFFI (not raw JNI) to generate Kotlin bindings. UniFFI emits +- **Rust fakes** — `:core:rust` uses UniFFI (not raw JNI) to generate Kotlin bindings. UniFFI emits `KeyWrapperInterface`/`ItemManagerInterface`/`VaultManagerInterface`/`CardFormatterInterface`/ `CsvBackupManagerInterface`/`JsonBackupManagerInterface`/`RustPasskeyInterface`/ - `TotpServiceInterface` for test seams; fakes live in `:rust` testFixtures + `TotpServiceInterface` for test seams; fakes live in `:core:rust` testFixtures (`de.davis.keygo.rust`). Never instantiate the real UniFFI classes (`KeyWrapper()`, etc.) in JVM unit tests — their default constructors require the native Rust library at runtime. diff --git a/app/build.gradle.kts b/app/build.gradle.kts index f1f74e24b..054008d9f 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -65,7 +65,7 @@ android { "proguard-rules.pro" ) - // Packages symbols for the :rust cdylibs into the AAB so Play can + // Packages symbols for the :core:rust cdylibs into the AAB so Play can // symbolicate native crashes. Stripped before delivery to devices. ndk { debugSymbolLevel = "SYMBOL_TABLE" @@ -128,7 +128,7 @@ dependencies { implementation(libs.aboutlibraries.compose.m3) - implementation(projects.rust) + implementation(projects.core.rust) implementation(projects.core.item) implementation(projects.core.identity) implementation(projects.core.ui) diff --git a/core/identity/build.gradle.kts b/core/identity/build.gradle.kts index a08eedff7..60d1bddbc 100644 --- a/core/identity/build.gradle.kts +++ b/core/identity/build.gradle.kts @@ -15,7 +15,7 @@ dependencies { api(projects.core.biometrics) api(projects.core.security) implementation(projects.core.item) - implementation(projects.rust) + implementation(projects.core.rust) // Datastore implementation(libs.androidx.datastore) @@ -25,8 +25,8 @@ dependencies { testImplementation(testFixtures(projects.core.item)) testImplementation(testFixtures(projects.core.security)) testImplementation(testFixtures(projects.core.util)) - testImplementation(testFixtures(projects.rust)) + testImplementation(testFixtures(projects.core.rust)) testFixturesApi(projects.core.util) - testFixturesImplementation(projects.rust) + testFixturesImplementation(projects.core.rust) } diff --git a/core/item/build.gradle.kts b/core/item/build.gradle.kts index 4ee338c05..f893403a9 100644 --- a/core/item/build.gradle.kts +++ b/core/item/build.gradle.kts @@ -36,7 +36,7 @@ dependencies { testFixturesImplementation(libs.kotlinx.coroutines.core) testFixturesApi(projects.core.util) testFixturesImplementation(projects.core.security) - testFixturesImplementation(projects.rust) + testFixturesImplementation(projects.core.rust) testFixturesImplementation(project.dependencies.platform(libs.androidx.compose.bom)) testFixturesImplementation(libs.androidx.compose.runtime) { because("https://issuetracker.google.com/issues/259523353#comment32") diff --git a/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/model/CreditCard.kt b/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/model/CreditCard.kt index 9d7849085..abf19b68a 100644 --- a/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/model/CreditCard.kt +++ b/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/model/CreditCard.kt @@ -36,7 +36,7 @@ data class CreditCard( override val blueprint: SecretBlueprint> = CardNumber companion object : SecretBlueprint() { - override val label: String = "credit_card_number" + override val kind: SecretKind = SecretKind.CardNumber override val codec: SecretCodec = SecretCodec.StringCodec override fun createField(payload: EncryptedPayload): CardNumber = CardNumber(payload) @@ -49,7 +49,7 @@ data class CreditCard( override val blueprint: SecretBlueprint> = CVV companion object : SecretBlueprint() { - override val label: String = "credit_card_cvv" + override val kind: SecretKind = SecretKind.Cvv override val codec: SecretCodec = SecretCodec.StringCodec override fun createField(payload: EncryptedPayload): CVV = CVV(payload) diff --git a/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/model/Passkey.kt b/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/model/Passkey.kt index fb65b83c9..56e3d444e 100644 --- a/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/model/Passkey.kt +++ b/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/model/Passkey.kt @@ -39,7 +39,7 @@ data class Passkey( override val blueprint: SecretBlueprint> = PrivateKey companion object : SecretBlueprint() { - override val label: String = "passkey_private_key" + override val kind: SecretKind = SecretKind.PasskeyPrivateKey override val codec: SecretCodec = SecretCodec.ByteArrayCodec override fun createField(payload: EncryptedPayload): PrivateKey = PrivateKey(payload) diff --git a/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/model/PasswordSecret.kt b/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/model/PasswordSecret.kt index 2da1cb6be..b14595a00 100644 --- a/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/model/PasswordSecret.kt +++ b/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/model/PasswordSecret.kt @@ -6,7 +6,7 @@ data class PasswordSecret( override val blueprint: SecretBlueprint> = PasswordSecret companion object : SecretBlueprint() { - override val label: String = "password" + override val kind: SecretKind = SecretKind.Password override val codec: SecretCodec = SecretCodec.StringCodec override fun createField(payload: EncryptedPayload): PasswordSecret = diff --git a/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/model/Secret.kt b/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/model/Secret.kt index ab59b4f79..e3cb144e4 100644 --- a/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/model/Secret.kt +++ b/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/model/Secret.kt @@ -52,8 +52,17 @@ interface SecretField { val blueprint: SecretBlueprint> } +enum class SecretKind { + Password, + CardNumber, + Cvv, + PasskeyPrivateKey, + TotpSecret, + PasswordHealthReport, +} + abstract class SecretBlueprint> { - abstract val label: String + abstract val kind: SecretKind abstract val codec: SecretCodec abstract fun createField(payload: EncryptedPayload): F diff --git a/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/model/Totp.kt b/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/model/Totp.kt index 556ac3207..17835fadd 100644 --- a/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/model/Totp.kt +++ b/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/model/Totp.kt @@ -25,7 +25,7 @@ data class Totp( override val blueprint: SecretBlueprint> = Secret companion object : SecretBlueprint() { - override val label: String = "totp_secret" + override val kind: SecretKind = SecretKind.TotpSecret override val codec: SecretCodec = SecretCodec.StringCodec override fun createField(payload: EncryptedPayload): Secret = Secret(payload) diff --git a/core/item/src/test/kotlin/de/davis/keygo/core/item/domain/model/SecretFieldTest.kt b/core/item/src/test/kotlin/de/davis/keygo/core/item/domain/model/SecretFieldTest.kt index 23c744622..060f1a9ea 100644 --- a/core/item/src/test/kotlin/de/davis/keygo/core/item/domain/model/SecretFieldTest.kt +++ b/core/item/src/test/kotlin/de/davis/keygo/core/item/domain/model/SecretFieldTest.kt @@ -16,7 +16,7 @@ class SecretFieldTest { override val blueprint: SecretBlueprint> = TestSecretField companion object : SecretBlueprint() { - override val label: String = "test_label" + override val kind: SecretKind = SecretKind.Password override val codec: SecretCodec = SecretCodec.StringCodec override fun createField(payload: EncryptedPayload): TestSecretField = diff --git a/core/rust/.gitignore b/core/rust/.gitignore new file mode 100644 index 000000000..796b96d1c --- /dev/null +++ b/core/rust/.gitignore @@ -0,0 +1 @@ +/build diff --git a/rust/build.gradle.kts b/core/rust/build.gradle.kts similarity index 74% rename from rust/build.gradle.kts rename to core/rust/build.gradle.kts index f4f71a48e..a98a98d5d 100644 --- a/rust/build.gradle.kts +++ b/core/rust/build.gradle.kts @@ -2,6 +2,8 @@ plugins { alias(libs.plugins.keygo.android.library) } +val uniffiDir = "build/generated/source/uniffi" + android { namespace = "de.davis.keygo.rust" @@ -13,8 +15,8 @@ android { sourceSets { getByName("main") { - jniLibs.directories += "build/generated/source/uniffi/jniLibs" - kotlin.directories += "build/generated/source/uniffi/kotlin" + jniLibs.directories += "$uniffiDir/jniLibs" + kotlin.directories += "$uniffiDir/kotlin" } } @@ -45,12 +47,14 @@ val buildRust by tasks.register("buildRust") { providers.gradleProperty("rust.compile").getOrElse("true").toBoolean() } - workingDir = projectDir.resolve("rust-code") + workingDir = rootDir.resolve("rust") commandLine( "make", "all", "MIN_SDK=${libs.versions.minSdk.get()}", + "JNI_DIR=${projectDir.resolve("$uniffiDir/jniLibs")}", + "KOTLIN_OUT=${projectDir.resolve("$uniffiDir/kotlin")}", ) } diff --git a/rust/consumer-rules.pro b/core/rust/consumer-rules.pro similarity index 100% rename from rust/consumer-rules.pro rename to core/rust/consumer-rules.pro diff --git a/rust/proguard-rules.pro b/core/rust/proguard-rules.pro similarity index 100% rename from rust/proguard-rules.pro rename to core/rust/proguard-rules.pro diff --git a/core/rust/src/main/AndroidManifest.xml b/core/rust/src/main/AndroidManifest.xml new file mode 100644 index 000000000..44008a433 --- /dev/null +++ b/core/rust/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/rust/src/main/kotlin/de/davis/keygo/rust/backup/BackupResult.kt b/core/rust/src/main/kotlin/de/davis/keygo/rust/backup/BackupResult.kt similarity index 100% rename from rust/src/main/kotlin/de/davis/keygo/rust/backup/BackupResult.kt rename to core/rust/src/main/kotlin/de/davis/keygo/rust/backup/BackupResult.kt diff --git a/rust/src/main/kotlin/de/davis/keygo/rust/backup/CsvBackupManager.kt b/core/rust/src/main/kotlin/de/davis/keygo/rust/backup/CsvBackupManager.kt similarity index 100% rename from rust/src/main/kotlin/de/davis/keygo/rust/backup/CsvBackupManager.kt rename to core/rust/src/main/kotlin/de/davis/keygo/rust/backup/CsvBackupManager.kt diff --git a/rust/src/main/kotlin/de/davis/keygo/rust/backup/JsonBackupManager.kt b/core/rust/src/main/kotlin/de/davis/keygo/rust/backup/JsonBackupManager.kt similarity index 100% rename from rust/src/main/kotlin/de/davis/keygo/rust/backup/JsonBackupManager.kt rename to core/rust/src/main/kotlin/de/davis/keygo/rust/backup/JsonBackupManager.kt diff --git a/rust/src/main/kotlin/de/davis/keygo/rust/card/Card.kt b/core/rust/src/main/kotlin/de/davis/keygo/rust/card/Card.kt similarity index 100% rename from rust/src/main/kotlin/de/davis/keygo/rust/card/Card.kt rename to core/rust/src/main/kotlin/de/davis/keygo/rust/card/Card.kt diff --git a/rust/src/main/kotlin/de/davis/keygo/rust/di/RustModule.kt b/core/rust/src/main/kotlin/de/davis/keygo/rust/di/RustModule.kt similarity index 100% rename from rust/src/main/kotlin/de/davis/keygo/rust/di/RustModule.kt rename to core/rust/src/main/kotlin/de/davis/keygo/rust/di/RustModule.kt diff --git a/rust/src/main/kotlin/de/davis/keygo/rust/item/ItemManager.kt b/core/rust/src/main/kotlin/de/davis/keygo/rust/item/ItemManager.kt similarity index 100% rename from rust/src/main/kotlin/de/davis/keygo/rust/item/ItemManager.kt rename to core/rust/src/main/kotlin/de/davis/keygo/rust/item/ItemManager.kt diff --git a/rust/src/main/kotlin/de/davis/keygo/rust/passkey/PasskeyManager.kt b/core/rust/src/main/kotlin/de/davis/keygo/rust/passkey/PasskeyManager.kt similarity index 81% rename from rust/src/main/kotlin/de/davis/keygo/rust/passkey/PasskeyManager.kt rename to core/rust/src/main/kotlin/de/davis/keygo/rust/passkey/PasskeyManager.kt index d8fe0e0df..27c673ceb 100644 --- a/rust/src/main/kotlin/de/davis/keygo/rust/passkey/PasskeyManager.kt +++ b/core/rust/src/main/kotlin/de/davis/keygo/rust/passkey/PasskeyManager.kt @@ -1,9 +1,9 @@ package de.davis.keygo.rust.passkey import de.davis.keygo.core.util.Result +import de.davisalessandro.keygo.rust.CreationInfo import de.davisalessandro.keygo.rust.PasskeyException -import de.davisalessandro.keygo.rust.PasskeyInformation -import de.davisalessandro.keygo.rust.RegistrationResponse +import de.davisalessandro.keygo.rust.Registration import de.davisalessandro.keygo.rust.RustPasskeyInterface import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -23,7 +23,7 @@ suspend fun RustPasskeyInterface.authenticateWithResult( suspend fun RustPasskeyInterface.registerWithResult( requestJson: String -): Result = withContext(Dispatchers.Default) { +): Result = withContext(Dispatchers.Default) { runCatching { register(requestJson) }.fold( @@ -34,7 +34,7 @@ suspend fun RustPasskeyInterface.registerWithResult( fun RustPasskeyInterface.getPasskeyInformation( requestJson: String -): Result = runCatching { +): Result = runCatching { passkeyInformation(requestJson) }.fold( onSuccess = { Result.Success(it) }, diff --git a/rust/src/main/kotlin/de/davis/keygo/rust/totp/Totp.kt b/core/rust/src/main/kotlin/de/davis/keygo/rust/totp/Totp.kt similarity index 100% rename from rust/src/main/kotlin/de/davis/keygo/rust/totp/Totp.kt rename to core/rust/src/main/kotlin/de/davis/keygo/rust/totp/Totp.kt diff --git a/rust/src/main/kotlin/de/davis/keygo/rust/vault/VaultManager.kt b/core/rust/src/main/kotlin/de/davis/keygo/rust/vault/VaultManager.kt similarity index 100% rename from rust/src/main/kotlin/de/davis/keygo/rust/vault/VaultManager.kt rename to core/rust/src/main/kotlin/de/davis/keygo/rust/vault/VaultManager.kt diff --git a/rust/src/main/kotlin/de/davis/keygo/rust/wrap/KeyWrapper.kt b/core/rust/src/main/kotlin/de/davis/keygo/rust/wrap/KeyWrapper.kt similarity index 100% rename from rust/src/main/kotlin/de/davis/keygo/rust/wrap/KeyWrapper.kt rename to core/rust/src/main/kotlin/de/davis/keygo/rust/wrap/KeyWrapper.kt diff --git a/rust/src/testFixtures/kotlin/de/davis/keygo/rust/FakeCardFormatter.kt b/core/rust/src/testFixtures/kotlin/de/davis/keygo/rust/FakeCardFormatter.kt similarity index 100% rename from rust/src/testFixtures/kotlin/de/davis/keygo/rust/FakeCardFormatter.kt rename to core/rust/src/testFixtures/kotlin/de/davis/keygo/rust/FakeCardFormatter.kt diff --git a/rust/src/testFixtures/kotlin/de/davis/keygo/rust/FakeCsvBackupManager.kt b/core/rust/src/testFixtures/kotlin/de/davis/keygo/rust/FakeCsvBackupManager.kt similarity index 100% rename from rust/src/testFixtures/kotlin/de/davis/keygo/rust/FakeCsvBackupManager.kt rename to core/rust/src/testFixtures/kotlin/de/davis/keygo/rust/FakeCsvBackupManager.kt diff --git a/rust/src/testFixtures/kotlin/de/davis/keygo/rust/FakeItemManager.kt b/core/rust/src/testFixtures/kotlin/de/davis/keygo/rust/FakeItemManager.kt similarity index 85% rename from rust/src/testFixtures/kotlin/de/davis/keygo/rust/FakeItemManager.kt rename to core/rust/src/testFixtures/kotlin/de/davis/keygo/rust/FakeItemManager.kt index 51e8874bf..31ab04a53 100644 --- a/rust/src/testFixtures/kotlin/de/davis/keygo/rust/FakeItemManager.kt +++ b/core/rust/src/testFixtures/kotlin/de/davis/keygo/rust/FakeItemManager.kt @@ -2,6 +2,7 @@ package de.davis.keygo.rust import de.davisalessandro.keygo.rust.EncryptedItemBlob import de.davisalessandro.keygo.rust.ItemCryptoException +import de.davisalessandro.keygo.rust.ItemDataAad import de.davisalessandro.keygo.rust.ItemKey import de.davisalessandro.keygo.rust.ItemManagerInterface import java.security.SecureRandom @@ -23,19 +24,19 @@ class FakeItemManager : ItemManagerInterface { override fun encryptItemData( itemKey: ItemKey, data: ByteArray, - aad: ByteArray, + aad: ItemDataAad, ): EncryptedItemBlob { val nonce = ByteArray(12).also { SecureRandom().nextBytes(it) } - val ciphertext = xorStream(data, itemKey, aad, nonce) - record[Key(itemKey.toList(), ciphertext.toList(), aad.toList(), nonce.toList())] = data + val ciphertext = xorStream(data, itemKey, aad.toString().encodeToByteArray(), nonce) + record[Key(itemKey.toList(), ciphertext.toList(), aad, nonce.toList())] = data return EncryptedItemBlob(ciphertext = ciphertext, nonce = nonce) } override fun decryptItemData( itemKey: ItemKey, blob: EncryptedItemBlob, - aad: ByteArray, - ): ByteArray = record[Key(itemKey.toList(), blob.ciphertext.toList(), aad.toList(), blob.nonce.toList())] + aad: ItemDataAad, + ): ByteArray = record[Key(itemKey.toList(), blob.ciphertext.toList(), aad, blob.nonce.toList())] ?: throw ItemCryptoException.DecryptionFailed() private fun xorStream( @@ -57,7 +58,7 @@ class FakeItemManager : ItemManagerInterface { private data class Key( val itemKey: List, val ciphertext: List, - val aad: List, + val aad: ItemDataAad, val nonce: List, ) } diff --git a/rust/src/testFixtures/kotlin/de/davis/keygo/rust/FakeJsonBackupManager.kt b/core/rust/src/testFixtures/kotlin/de/davis/keygo/rust/FakeJsonBackupManager.kt similarity index 100% rename from rust/src/testFixtures/kotlin/de/davis/keygo/rust/FakeJsonBackupManager.kt rename to core/rust/src/testFixtures/kotlin/de/davis/keygo/rust/FakeJsonBackupManager.kt diff --git a/rust/src/testFixtures/kotlin/de/davis/keygo/rust/FakeKeyWrapper.kt b/core/rust/src/testFixtures/kotlin/de/davis/keygo/rust/FakeKeyWrapper.kt similarity index 100% rename from rust/src/testFixtures/kotlin/de/davis/keygo/rust/FakeKeyWrapper.kt rename to core/rust/src/testFixtures/kotlin/de/davis/keygo/rust/FakeKeyWrapper.kt diff --git a/rust/src/testFixtures/kotlin/de/davis/keygo/rust/FakePasskeyManager.kt b/core/rust/src/testFixtures/kotlin/de/davis/keygo/rust/FakePasskeyManager.kt similarity index 80% rename from rust/src/testFixtures/kotlin/de/davis/keygo/rust/FakePasskeyManager.kt rename to core/rust/src/testFixtures/kotlin/de/davis/keygo/rust/FakePasskeyManager.kt index 6f3811e31..ac91e0141 100644 --- a/rust/src/testFixtures/kotlin/de/davis/keygo/rust/FakePasskeyManager.kt +++ b/core/rust/src/testFixtures/kotlin/de/davis/keygo/rust/FakePasskeyManager.kt @@ -1,7 +1,7 @@ package de.davis.keygo.rust -import de.davisalessandro.keygo.rust.PasskeyInformation -import de.davisalessandro.keygo.rust.RegistrationResponse +import de.davisalessandro.keygo.rust.CreationInfo +import de.davisalessandro.keygo.rust.Registration import de.davisalessandro.keygo.rust.RustPasskeyInterface class FakePasskeyManager : RustPasskeyInterface { @@ -20,10 +20,10 @@ class FakePasskeyManager : RustPasskeyInterface { var authenticateResult: String? = null /** Result returned by [register]. Throws if null. */ - var registerResult: RegistrationResponse? = null + var registerResult: Registration? = null /** Result returned by [passkeyInformation]. */ - var passkeyInformationResult: PasskeyInformation = PasskeyInformation( + var passkeyInformationResult: CreationInfo = CreationInfo( excludeCredentials = emptyList(), rp = "example.com", ) @@ -37,12 +37,12 @@ class FakePasskeyManager : RustPasskeyInterface { return authenticateResult ?: error("authenticateResult not set on FakePasskeyManager") } - override fun passkeyInformation(jsonRequest: String): PasskeyInformation { + override fun passkeyInformation(jsonRequest: String): CreationInfo { passkeyInformationCalls += jsonRequest return passkeyInformationResult } - override suspend fun register(jsonRequest: String): RegistrationResponse { + override suspend fun register(jsonRequest: String): Registration { registerCalls += jsonRequest return registerResult ?: error("registerResult not set on FakePasskeyManager") } diff --git a/rust/src/testFixtures/kotlin/de/davis/keygo/rust/FakeTotpService.kt b/core/rust/src/testFixtures/kotlin/de/davis/keygo/rust/FakeTotpService.kt similarity index 100% rename from rust/src/testFixtures/kotlin/de/davis/keygo/rust/FakeTotpService.kt rename to core/rust/src/testFixtures/kotlin/de/davis/keygo/rust/FakeTotpService.kt diff --git a/rust/src/testFixtures/kotlin/de/davis/keygo/rust/FakeVaultManager.kt b/core/rust/src/testFixtures/kotlin/de/davis/keygo/rust/FakeVaultManager.kt similarity index 100% rename from rust/src/testFixtures/kotlin/de/davis/keygo/rust/FakeVaultManager.kt rename to core/rust/src/testFixtures/kotlin/de/davis/keygo/rust/FakeVaultManager.kt diff --git a/core/security/build.gradle.kts b/core/security/build.gradle.kts index 72802d022..7648f0a3b 100644 --- a/core/security/build.gradle.kts +++ b/core/security/build.gradle.kts @@ -16,17 +16,17 @@ dependencies { implementation(projects.core.item) api(projects.core.util) - api(projects.rust) + api(projects.core.rust) testImplementation(libs.robolectric) - testImplementation(testFixtures(projects.rust)) + testImplementation(testFixtures(projects.core.rust)) testImplementation(testFixtures(projects.core.item)) testImplementation(testFixtures(projects.core.util)) testFixturesApi(projects.core.item) - testFixturesApi(projects.rust) + testFixturesApi(projects.core.rust) testFixturesImplementation(libs.kotlinx.coroutines.core) - testFixturesImplementation(testFixtures(projects.rust)) + testFixturesImplementation(testFixtures(projects.core.rust)) testFixturesImplementation(project.dependencies.platform(libs.androidx.compose.bom)) testFixturesImplementation(libs.androidx.compose.runtime) { because("https://issuetracker.google.com/issues/259523353#comment32") diff --git a/core/security/src/main/kotlin/de/davis/keygo/core/security/data/SessionImpl.kt b/core/security/src/main/kotlin/de/davis/keygo/core/security/data/SessionImpl.kt index 8ba33bea5..eacf73825 100644 --- a/core/security/src/main/kotlin/de/davis/keygo/core/security/data/SessionImpl.kt +++ b/core/security/src/main/kotlin/de/davis/keygo/core/security/data/SessionImpl.kt @@ -5,11 +5,13 @@ import de.davis.keygo.core.security.domain.Session import de.davis.keygo.core.security.domain.SessionError import de.davis.keygo.core.security.domain.toSessionError import de.davis.keygo.core.util.Result +import de.davisalessandro.keygo.rust.AccountRecord import de.davisalessandro.keygo.rust.ArkCredential import de.davisalessandro.keygo.rust.ArkSessionException import de.davisalessandro.keygo.rust.ArkSessionInterface import de.davisalessandro.keygo.rust.NewAccount import de.davisalessandro.keygo.rust.PasswordWrapped +import de.davisalessandro.keygo.rust.SecretKind import de.davisalessandro.keygo.rust.WrappedKeyBlob import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow @@ -80,6 +82,22 @@ internal class SessionImpl( catching { binding.unwrapVaultKey(wrapped, vaultId) } } + override suspend fun sealAccountRecord( + namespace: UUID, + kind: SecretKind, + plaintext: ByteArray, + ): Result = withContext(Dispatchers.Default) { + catching { binding.sealAccountRecord(namespace, kind, plaintext) } + } + + override suspend fun openAccountRecord( + namespace: UUID, + kind: SecretKind, + record: AccountRecord, + ): Result = withContext(Dispatchers.Default) { + catching { binding.openAccountRecord(namespace, kind, record) } + } + override fun endSession() { binding.end() syncIsActive() diff --git a/core/security/src/main/kotlin/de/davis/keygo/core/security/data/crypto/AccountRecordSealerImpl.kt b/core/security/src/main/kotlin/de/davis/keygo/core/security/data/crypto/AccountRecordSealerImpl.kt new file mode 100644 index 000000000..02676976f --- /dev/null +++ b/core/security/src/main/kotlin/de/davis/keygo/core/security/data/crypto/AccountRecordSealerImpl.kt @@ -0,0 +1,61 @@ +package de.davis.keygo.core.security.data.crypto + +import de.davis.keygo.core.item.domain.model.SecretKind +import de.davis.keygo.core.security.domain.Session +import de.davis.keygo.core.security.domain.SessionError +import de.davis.keygo.core.security.domain.crypto.AccountRecordSealer +import de.davis.keygo.core.security.domain.crypto.model.CryptographicData +import de.davis.keygo.core.security.domain.crypto.model.SealedAccountRecord +import de.davis.keygo.core.security.domain.model.AccountRecordError +import de.davis.keygo.core.util.Result +import de.davis.keygo.core.util.mapFailure +import de.davis.keygo.core.util.mapSuccess +import de.davisalessandro.keygo.rust.AccountRecord +import de.davisalessandro.keygo.rust.EncryptedItemBlob +import org.koin.core.annotation.Single +import java.util.UUID + +@Single +internal class AccountRecordSealerImpl( + private val session: Session, +) : AccountRecordSealer { + + override suspend fun seal( + namespace: UUID, + kind: SecretKind, + plaintext: ByteArray, + ): Result = + session.sealAccountRecord( + namespace = namespace, + kind = kind.toFfi(), + plaintext = plaintext, + ).mapSuccess { it.toSealedAccountRecord() }.mapFailure { it.toAccountRecordError() } + + override suspend fun open( + namespace: UUID, + kind: SecretKind, + record: SealedAccountRecord, + ): Result = + session.openAccountRecord( + namespace = namespace, + kind = kind.toFfi(), + record = record.toAccountRecord(), + ).mapFailure { it.toAccountRecordError() } +} + +private fun SessionError.toAccountRecordError(): AccountRecordError = when (this) { + SessionError.Locked -> AccountRecordError.NoActiveSession + SessionError.DecryptionFailed -> AccountRecordError.Corrupted + SessionError.WrongPassword, is SessionError.Derivation, is SessionError.KeyWrap -> + AccountRecordError.KeyUnwrapFailed +} + +private fun AccountRecord.toSealedAccountRecord() = SealedAccountRecord( + wrappedKey = wrappedKey.toKeyInformation(), + data = CryptographicData(data = data.ciphertext, iv = data.nonce), +) + +private fun SealedAccountRecord.toAccountRecord() = AccountRecord( + wrappedKey = wrappedKey.toWrappedKeyBlob(), + data = EncryptedItemBlob(ciphertext = data.data, nonce = data.iv), +) diff --git a/core/security/src/main/kotlin/de/davis/keygo/core/security/data/crypto/CryptographicScopeImpl.kt b/core/security/src/main/kotlin/de/davis/keygo/core/security/data/crypto/CryptographicScopeImpl.kt index b9737cb0b..969d391b9 100644 --- a/core/security/src/main/kotlin/de/davis/keygo/core/security/data/crypto/CryptographicScopeImpl.kt +++ b/core/security/src/main/kotlin/de/davis/keygo/core/security/data/crypto/CryptographicScopeImpl.kt @@ -1,15 +1,17 @@ package de.davis.keygo.core.security.data.crypto import de.davis.keygo.core.item.domain.model.KeyInformation +import de.davis.keygo.core.item.domain.model.SecretKind import de.davis.keygo.core.security.domain.crypto.CryptographicScope import de.davis.keygo.core.security.domain.crypto.model.CryptographicData import de.davis.keygo.rust.item.ItemManager import de.davisalessandro.keygo.rust.EncryptedItemBlob import de.davisalessandro.keygo.rust.ItemAad +import de.davisalessandro.keygo.rust.ItemDataAad import de.davisalessandro.keygo.rust.ItemKey +import de.davisalessandro.keygo.rust.SecretKind as FfiSecretKind import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.withContext -import java.nio.ByteBuffer import kotlin.coroutines.CoroutineContext internal class CryptographicScopeImpl( @@ -19,13 +21,13 @@ internal class CryptographicScopeImpl( private val wrapAction: suspend CoroutineScope.() -> KeyInformation, ) : CryptographicScope { override suspend fun ByteArray.encrypt( - label: String, + kind: SecretKind, context: CoroutineContext, ): CryptographicData = withContext(context) { val ct = itemManager.encryptItemData( itemKey = itemKey, data = this@encrypt, - aad = buildDataAad(label), + aad = dataAad(kind), ) CryptographicData( @@ -35,7 +37,7 @@ internal class CryptographicScopeImpl( } override suspend fun CryptographicData.decrypt( - label: String, + kind: SecretKind, context: CoroutineContext, ): ByteArray = withContext(context) { val blob = EncryptedItemBlob( @@ -46,27 +48,24 @@ internal class CryptographicScopeImpl( itemManager.decryptItemData( itemKey = itemKey, blob = blob, - aad = buildDataAad(label), + aad = dataAad(kind), ) } override suspend fun wrapCurrentItemKey(context: CoroutineContext): KeyInformation = withContext(context, block = wrapAction) - // The vault binding is already enforced by the item-key wrap layer (item key wrapped under - // vault key with vault id in AAD), so the data AAD only needs to bind ciphertext to its - // owning item and field. This lets us move an item between vaults by rewrapping the item key - // alone, without touching the encrypted secrets. - private fun buildDataAad(label: String): ByteArray { - val labelBytes = label.toByteArray(Charsets.UTF_8) - return ByteBuffer.allocate(UUID_BYTES + labelBytes.size) - .putLong(itemAad.itemId.mostSignificantBits) - .putLong(itemAad.itemId.leastSignificantBits) - .put(labelBytes) - .array() - } + private fun dataAad(kind: SecretKind) = ItemDataAad( + itemId = itemAad.itemId, + kind = kind.toFfi(), + ) +} - private companion object { - const val UUID_BYTES = 16 - } +internal fun SecretKind.toFfi(): FfiSecretKind = when (this) { + SecretKind.Password -> FfiSecretKind.PASSWORD + SecretKind.CardNumber -> FfiSecretKind.CARD_NUMBER + SecretKind.Cvv -> FfiSecretKind.CVV + SecretKind.PasskeyPrivateKey -> FfiSecretKind.PASSKEY_PRIVATE_KEY + SecretKind.TotpSecret -> FfiSecretKind.TOTP_SECRET + SecretKind.PasswordHealthReport -> FfiSecretKind.PASSWORD_HEALTH_REPORT } diff --git a/core/security/src/main/kotlin/de/davis/keygo/core/security/data/crypto/CryptographicScopeProviderFactoryImpl.kt b/core/security/src/main/kotlin/de/davis/keygo/core/security/data/crypto/CryptographicScopeProviderFactoryImpl.kt index 0bb965df0..742752423 100644 --- a/core/security/src/main/kotlin/de/davis/keygo/core/security/data/crypto/CryptographicScopeProviderFactoryImpl.kt +++ b/core/security/src/main/kotlin/de/davis/keygo/core/security/data/crypto/CryptographicScopeProviderFactoryImpl.kt @@ -5,7 +5,6 @@ import de.davis.keygo.core.security.domain.Session import de.davis.keygo.core.security.domain.crypto.CryptographicScopeProvider import de.davis.keygo.core.security.domain.crypto.CryptographicScopeProviderFactory import de.davis.keygo.rust.item.ItemManager -import de.davis.keygo.rust.vault.VaultManager import de.davis.keygo.rust.wrap.KeyWrapper import org.koin.core.annotation.Single @@ -14,7 +13,6 @@ internal class CryptographicScopeProviderFactoryImpl( private val itemRepository: ItemRepository, private val itemManager: ItemManager, private val keyWrapper: KeyWrapper, - private val vaultManager: VaultManager, ) : CryptographicScopeProviderFactory { override fun forSession(session: Session): CryptographicScopeProvider = @@ -23,6 +21,5 @@ internal class CryptographicScopeProviderFactoryImpl( itemRepository, itemManager, keyWrapper, - vaultManager ) } diff --git a/core/security/src/main/kotlin/de/davis/keygo/core/security/data/crypto/CryptographicScopeProviderImpl.kt b/core/security/src/main/kotlin/de/davis/keygo/core/security/data/crypto/CryptographicScopeProviderImpl.kt index 8792d7e61..b194c61e8 100644 --- a/core/security/src/main/kotlin/de/davis/keygo/core/security/data/crypto/CryptographicScopeProviderImpl.kt +++ b/core/security/src/main/kotlin/de/davis/keygo/core/security/data/crypto/CryptographicScopeProviderImpl.kt @@ -15,7 +15,6 @@ import de.davis.keygo.core.util.mapFailure import de.davis.keygo.core.util.mapSuccess import de.davis.keygo.core.util.resultBinding import de.davis.keygo.rust.item.ItemManager -import de.davis.keygo.rust.vault.VaultManager import de.davis.keygo.rust.wrap.KeyWrapper import de.davis.keygo.rust.wrap.unwrapItemKeyWithResult import de.davis.keygo.rust.wrap.wrapItemKeyWithResult @@ -23,7 +22,6 @@ import de.davisalessandro.keygo.rust.ItemAad import de.davisalessandro.keygo.rust.KeyWrapException import de.davisalessandro.keygo.rust.WrappedKeyBlob import org.koin.core.annotation.Single -import java.util.UUID @Single internal class CryptographicScopeProviderImpl( @@ -31,7 +29,6 @@ internal class CryptographicScopeProviderImpl( private val itemRepository: ItemRepository, private val itemManager: ItemManager, private val keyWrapper: KeyWrapper, - private val vaultManager: VaultManager, ) : CryptographicScopeProvider { override suspend fun itemScope( @@ -87,32 +84,6 @@ internal class CryptographicScopeProviderImpl( ).block() } - override suspend fun accountScope( - namespace: UUID, - wrapped: KeyInformation?, - block: suspend CryptographicScope.() -> R - ): Result = resultBinding { - val (key, wrappedKey) = if (wrapped != null) { - val unwrapped = session.unwrapVaultKey( - wrapped = wrapped.toWrappedKeyBlob(), - vaultId = namespace, - ).mapFailure { it.toCryptoScopeError() }.bind() - unwrapped to wrapped - } else { - val fresh = vaultManager.createNewVaultKey() - val blob = session.wrapVaultKey(vaultKey = fresh, vaultId = namespace) - .mapFailure { it.toCryptoScopeError() }.bind() - fresh to blob.toKeyInformation() - } - - CryptographicScopeImpl( - itemKey = key, - itemAad = ItemAad(itemId = namespace, vaultId = namespace), - itemManager = itemManager, - wrapAction = { wrappedKey }, - ).block() - } - override suspend fun rewrapItemKey( sourceVault: WrappedVaultKeyInformation, sourceItem: WrappedItemKeyInformation, @@ -151,14 +122,14 @@ internal class CryptographicScopeProviderImpl( private fun SessionError.toCryptoScopeError(): CryptoScopeError = when (this) { SessionError.Locked -> CryptoScopeError.NoActiveSession is SessionError.KeyWrap -> CryptoScopeError.KeyWrapError(cause) - SessionError.WrongPassword, is SessionError.Derivation -> + SessionError.WrongPassword, is SessionError.Derivation, SessionError.DecryptionFailed -> CryptoScopeError.KeyWrapError(KeyWrapException.UnwrapFailed()) } -private fun KeyInformation.toWrappedKeyBlob() = WrappedKeyBlob( +internal fun KeyInformation.toWrappedKeyBlob() = WrappedKeyBlob( ciphertext = wrappedKey, nonce = keyNonce ) -private fun WrappedKeyBlob.toKeyInformation(): KeyInformation = +internal fun WrappedKeyBlob.toKeyInformation(): KeyInformation = KeyInformation(wrappedKey = ciphertext, keyNonce = nonce) diff --git a/core/security/src/main/kotlin/de/davis/keygo/core/security/domain/Session.kt b/core/security/src/main/kotlin/de/davis/keygo/core/security/domain/Session.kt index 8a6a6e380..19278db5a 100644 --- a/core/security/src/main/kotlin/de/davis/keygo/core/security/domain/Session.kt +++ b/core/security/src/main/kotlin/de/davis/keygo/core/security/domain/Session.kt @@ -3,10 +3,12 @@ package de.davis.keygo.core.security.domain import de.davis.keygo.core.util.Result import de.davis.keygo.core.util.fold import de.davis.keygo.core.util.resultBinding +import de.davisalessandro.keygo.rust.AccountRecord import de.davisalessandro.keygo.rust.ArkCredential import de.davisalessandro.keygo.rust.ArkSessionException import de.davisalessandro.keygo.rust.NewAccount import de.davisalessandro.keygo.rust.PasswordWrapped +import de.davisalessandro.keygo.rust.SecretKind import de.davisalessandro.keygo.rust.WrappedKeyBlob import kotlinx.coroutines.flow.StateFlow import java.util.UUID @@ -59,6 +61,22 @@ interface Session { vaultId: UUID, ): Result + /** + * Seals [plaintext] as the account-wide record [namespace], under a fresh key each time. The + * namespace key never leaves Rust. + */ + suspend fun sealAccountRecord( + namespace: UUID, + kind: SecretKind, + plaintext: ByteArray, + ): Result + + suspend fun openAccountRecord( + namespace: UUID, + kind: SecretKind, + record: AccountRecord, + ): Result + fun endSession() } @@ -87,5 +105,6 @@ internal fun ArkSessionException.toSessionError(): SessionError = when (this) { is ArkSessionException.Locked -> SessionError.Locked is ArkSessionException.WrongPassword -> SessionError.WrongPassword is ArkSessionException.Derivation -> SessionError.Derivation(v1) + is ArkSessionException.DecryptionFailed -> SessionError.DecryptionFailed is ArkSessionException.KeyWrap -> SessionError.KeyWrap(v1) } diff --git a/core/security/src/main/kotlin/de/davis/keygo/core/security/domain/SessionError.kt b/core/security/src/main/kotlin/de/davis/keygo/core/security/domain/SessionError.kt index a1fbc98ac..067c80d27 100644 --- a/core/security/src/main/kotlin/de/davis/keygo/core/security/domain/SessionError.kt +++ b/core/security/src/main/kotlin/de/davis/keygo/core/security/domain/SessionError.kt @@ -7,5 +7,6 @@ sealed interface SessionError { data object Locked : SessionError data object WrongPassword : SessionError data class Derivation(val message: String) : SessionError + data object DecryptionFailed : SessionError data class KeyWrap(val cause: KeyWrapException) : SessionError } diff --git a/core/security/src/main/kotlin/de/davis/keygo/core/security/domain/crypto/AccountRecordSealer.kt b/core/security/src/main/kotlin/de/davis/keygo/core/security/domain/crypto/AccountRecordSealer.kt new file mode 100644 index 000000000..dfb5db7ea --- /dev/null +++ b/core/security/src/main/kotlin/de/davis/keygo/core/security/domain/crypto/AccountRecordSealer.kt @@ -0,0 +1,29 @@ +package de.davis.keygo.core.security.domain.crypto + +import de.davis.keygo.core.item.domain.model.SecretKind +import de.davis.keygo.core.security.domain.crypto.model.SealedAccountRecord +import de.davis.keygo.core.security.domain.model.AccountRecordError +import de.davis.keygo.core.util.Result +import java.util.UUID + +/** + * Seals account-wide records that belong to no vault, such as the password-health report. Each + * record has its own key, wrapped under the ARK and bound to the record's namespace. + * + * A namespace is part of its record's on-disk identity: changing it makes the record unreadable. + */ +interface AccountRecordSealer { + + /** Seals under a fresh key, so a record never depends on an older one still opening. */ + suspend fun seal( + namespace: UUID, + kind: SecretKind, + plaintext: ByteArray, + ): Result + + suspend fun open( + namespace: UUID, + kind: SecretKind, + record: SealedAccountRecord, + ): Result +} diff --git a/core/security/src/main/kotlin/de/davis/keygo/core/security/domain/crypto/CryptographicScope.kt b/core/security/src/main/kotlin/de/davis/keygo/core/security/domain/crypto/CryptographicScope.kt index 9c0d69be1..bafc7540d 100644 --- a/core/security/src/main/kotlin/de/davis/keygo/core/security/domain/crypto/CryptographicScope.kt +++ b/core/security/src/main/kotlin/de/davis/keygo/core/security/domain/crypto/CryptographicScope.kt @@ -1,6 +1,7 @@ package de.davis.keygo.core.security.domain.crypto import de.davis.keygo.core.item.domain.model.KeyInformation +import de.davis.keygo.core.item.domain.model.SecretKind import de.davis.keygo.core.security.domain.crypto.model.CryptographicData import kotlinx.coroutines.Dispatchers import kotlin.coroutines.CoroutineContext @@ -8,12 +9,12 @@ import kotlin.coroutines.CoroutineContext interface CryptographicScope { suspend fun ByteArray.encrypt( - label: String, + kind: SecretKind, context: CoroutineContext = Dispatchers.Default, ): CryptographicData suspend fun CryptographicData.decrypt( - label: String, + kind: SecretKind, context: CoroutineContext = Dispatchers.Default, ): ByteArray diff --git a/core/security/src/main/kotlin/de/davis/keygo/core/security/domain/crypto/CryptographicScopeProvider.kt b/core/security/src/main/kotlin/de/davis/keygo/core/security/domain/crypto/CryptographicScopeProvider.kt index 6fd3d4d99..f946598b3 100644 --- a/core/security/src/main/kotlin/de/davis/keygo/core/security/domain/crypto/CryptographicScopeProvider.kt +++ b/core/security/src/main/kotlin/de/davis/keygo/core/security/domain/crypto/CryptographicScopeProvider.kt @@ -6,7 +6,6 @@ import de.davis.keygo.core.security.domain.crypto.model.WrappedItemKeyInformatio import de.davis.keygo.core.security.domain.crypto.model.WrappedVaultKeyInformation import de.davis.keygo.core.security.domain.model.CryptoScopeError import de.davis.keygo.core.util.Result -import java.util.UUID interface CryptographicScopeProvider { @@ -21,12 +20,6 @@ interface CryptographicScopeProvider { block: suspend CryptographicScope.() -> R, ): Result - suspend fun accountScope( - namespace: UUID, - wrapped: KeyInformation?, - block: suspend CryptographicScope.() -> R, - ): Result - /** * Re-wraps an item's key from one vault to another without exposing the unwrapped item key * or touching the item's encrypted secrets. The destination AAD reuses the item id but is diff --git a/core/security/src/main/kotlin/de/davis/keygo/core/security/domain/crypto/SecretExt.kt b/core/security/src/main/kotlin/de/davis/keygo/core/security/domain/crypto/SecretExt.kt index b35bc44b4..6b09e1970 100644 --- a/core/security/src/main/kotlin/de/davis/keygo/core/security/domain/crypto/SecretExt.kt +++ b/core/security/src/main/kotlin/de/davis/keygo/core/security/domain/crypto/SecretExt.kt @@ -12,7 +12,7 @@ suspend fun > SecretBlueprint.encrypt( value: T, ctx: CoroutineContext = Dispatchers.Default ): F = with(scope) { - val encrypted = codec.encode(value).encrypt(label = label, context = ctx) + val encrypted = codec.encode(value).encrypt(kind = kind, context = ctx) createField(encrypted.toPayload()) } @@ -20,7 +20,7 @@ context(scope: CryptographicScope) suspend fun SecretField.decrypt( ctx: CoroutineContext = Dispatchers.Default ): T = with(scope) { - payload.toCryptographicData().decrypt(label = blueprint.label, context = ctx) + payload.toCryptographicData().decrypt(kind = blueprint.kind, context = ctx) .let { decryptedBytes -> blueprint.codec.decode(decryptedBytes) } diff --git a/core/security/src/main/kotlin/de/davis/keygo/core/security/domain/crypto/model/SealedAccountRecord.kt b/core/security/src/main/kotlin/de/davis/keygo/core/security/domain/crypto/model/SealedAccountRecord.kt new file mode 100644 index 000000000..bf028a0e3 --- /dev/null +++ b/core/security/src/main/kotlin/de/davis/keygo/core/security/domain/crypto/model/SealedAccountRecord.kt @@ -0,0 +1,8 @@ +package de.davis.keygo.core.security.domain.crypto.model + +import de.davis.keygo.core.item.domain.model.KeyInformation + +class SealedAccountRecord( + val wrappedKey: KeyInformation, + val data: CryptographicData, +) diff --git a/core/security/src/main/kotlin/de/davis/keygo/core/security/domain/model/AccountRecordError.kt b/core/security/src/main/kotlin/de/davis/keygo/core/security/domain/model/AccountRecordError.kt new file mode 100644 index 000000000..de4ce9025 --- /dev/null +++ b/core/security/src/main/kotlin/de/davis/keygo/core/security/domain/model/AccountRecordError.kt @@ -0,0 +1,11 @@ +package de.davis.keygo.core.security.domain.model + +sealed interface AccountRecordError { + data object NoActiveSession : AccountRecordError + + /** The record's key did not unwrap: wrong namespace, another account's key, or a damaged key. */ + data object KeyUnwrapFailed : AccountRecordError + + /** The key unwrapped but the data did not decrypt: damaged ciphertext or the wrong kind. */ + data object Corrupted : AccountRecordError +} diff --git a/core/security/src/test/kotlin/de/davis/keygo/core/security/crypto/CryptographicScopeImplTest.kt b/core/security/src/test/kotlin/de/davis/keygo/core/security/crypto/CryptographicScopeImplTest.kt index 731704125..5771d8104 100644 --- a/core/security/src/test/kotlin/de/davis/keygo/core/security/crypto/CryptographicScopeImplTest.kt +++ b/core/security/src/test/kotlin/de/davis/keygo/core/security/crypto/CryptographicScopeImplTest.kt @@ -2,6 +2,7 @@ package de.davis.keygo.core.security.crypto import de.davis.keygo.core.item.FakeItemRepository import de.davis.keygo.core.item.domain.model.KeyInformation +import de.davis.keygo.core.item.domain.model.SecretKind import de.davis.keygo.core.security.FakeSession import de.davis.keygo.core.security.data.crypto.CryptographicScopeProviderImpl import de.davis.keygo.core.security.domain.crypto.model.CryptographicData @@ -12,7 +13,6 @@ import de.davis.keygo.core.util.assertSuccess import de.davis.keygo.core.util.getOrNull import de.davis.keygo.rust.FakeItemManager import de.davis.keygo.rust.FakeKeyWrapper -import de.davis.keygo.rust.FakeVaultManager import de.davisalessandro.keygo.rust.ItemAad import kotlinx.coroutines.Deferred import kotlinx.coroutines.async @@ -41,10 +41,9 @@ class CryptographicScopeImplTest { itemRepository, itemManager, keyWrapper, - FakeVaultManager(), ) - private val label = "password" + private val kind = SecretKind.Password private suspend fun wrappedVaultKeyInformation( vaultId: UUID = UUID.randomUUID(), @@ -77,7 +76,7 @@ class CryptographicScopeImplTest { wrappedVaultKeyInformation = vaultInfo, wrappedItemKeyInformation = WrappedItemKeyInformation(itemAad = aad), ) { - plaintext.encrypt(label) to wrapCurrentItemKey() + plaintext.encrypt(kind) to wrapCurrentItemKey() }.assertSuccess() val recovered = provider.itemScope( @@ -86,7 +85,7 @@ class CryptographicScopeImplTest { itemAad = aad, wrappedItemKey = wrapped, ), - ) { cipher.decrypt(label) }.assertSuccess() + ) { cipher.decrypt(kind) }.assertSuccess() assertContentEquals(plaintext, recovered) } @@ -102,8 +101,8 @@ class CryptographicScopeImplTest { wrappedVaultKeyInformation = vaultInfo, wrappedItemKeyInformation = WrappedItemKeyInformation(itemAad = aad), ) { - val a = first.encrypt(label) - val b = second.encrypt(label) + val a = first.encrypt(kind) + val b = second.encrypt(kind) (a to b) to wrapCurrentItemKey() }.assertSuccess() @@ -114,8 +113,8 @@ class CryptographicScopeImplTest { wrappedItemKey = wrapped, ), ) { - assertContentEquals(first, encrypted.first.decrypt(label)) - assertContentEquals(second, encrypted.second.decrypt(label)) + assertContentEquals(first, encrypted.first.decrypt(kind)) + assertContentEquals(second, encrypted.second.decrypt(kind)) } } @@ -129,7 +128,7 @@ class CryptographicScopeImplTest { wrappedVaultKeyInformation = vaultInfo, wrappedItemKeyInformation = WrappedItemKeyInformation(itemAad = aad), ) { - plaintext.encrypt(label) to wrapCurrentItemKey() + plaintext.encrypt(kind) to wrapCurrentItemKey() }.assertSuccess() val otherVault = wrappedVaultKeyInformation() @@ -139,11 +138,11 @@ class CryptographicScopeImplTest { itemAad = aad, wrappedItemKey = wrapped, ), - ) { cipher.decrypt(label) }.assertFailure() + ) { cipher.decrypt(kind) }.assertFailure() } @Test - fun `decrypting with a different label fails`() = runTest { + fun `decrypting with a different kind fails`() = runTest { val vaultInfo = wrappedVaultKeyInformation() val aad = itemAad(vaultInfo.vaultId) val plaintext = "secret".toByteArray() @@ -152,7 +151,7 @@ class CryptographicScopeImplTest { wrappedVaultKeyInformation = vaultInfo, wrappedItemKeyInformation = WrappedItemKeyInformation(itemAad = aad), ) { - plaintext.encrypt(label = "password") to wrapCurrentItemKey() + plaintext.encrypt(SecretKind.Password) to wrapCurrentItemKey() }.assertSuccess() assertFailsWith { @@ -162,12 +161,12 @@ class CryptographicScopeImplTest { itemAad = aad, wrappedItemKey = wrapped, ), - ) { cipher.decrypt(label = "totp_secret") } + ) { cipher.decrypt(SecretKind.TotpSecret) } } } @Test - fun `decrypting with a different itemAad under same label fails`() = runTest { + fun `decrypting with a different itemAad under same kind fails`() = runTest { val vaultInfo = wrappedVaultKeyInformation() val aad = itemAad(vaultInfo.vaultId) val plaintext = "secret".toByteArray() @@ -176,7 +175,7 @@ class CryptographicScopeImplTest { wrappedVaultKeyInformation = vaultInfo, wrappedItemKeyInformation = WrappedItemKeyInformation(itemAad = aad), ) { - plaintext.encrypt(label) to wrapCurrentItemKey() + plaintext.encrypt(kind) to wrapCurrentItemKey() }.assertSuccess() provider.itemScope( @@ -185,7 +184,7 @@ class CryptographicScopeImplTest { itemAad = itemAad(vaultInfo.vaultId), wrappedItemKey = wrapped, ), - ) { cipher.decrypt(label) }.assertFailure() + ) { cipher.decrypt(kind) }.assertFailure() } @Test @@ -198,7 +197,7 @@ class CryptographicScopeImplTest { wrappedVaultKeyInformation = vaultInfo, wrappedItemKeyInformation = WrappedItemKeyInformation(itemAad = aad), ) { - plaintext.encrypt(label) to wrapCurrentItemKey() + plaintext.encrypt(kind) to wrapCurrentItemKey() }.assertSuccess() provider.itemScope( @@ -207,7 +206,7 @@ class CryptographicScopeImplTest { itemAad = itemAad(vaultInfo.vaultId), wrappedItemKey = wrapped, ), - ) { cipher.decrypt(label) }.assertFailure() + ) { cipher.decrypt(kind) }.assertFailure() } @Test @@ -233,7 +232,7 @@ class CryptographicScopeImplTest { wrappedVaultKeyInformation = vaultInfo, wrappedItemKeyInformation = WrappedItemKeyInformation(itemAad = aad), ) { - ByteArray(0).encrypt(label) to wrapCurrentItemKey() + ByteArray(0).encrypt(kind) to wrapCurrentItemKey() }.assertSuccess() val recovered = provider.itemScope( @@ -242,7 +241,7 @@ class CryptographicScopeImplTest { itemAad = aad, wrappedItemKey = wrapped, ), - ) { cipher.decrypt(label) }.assertSuccess() + ) { cipher.decrypt(kind) }.assertSuccess() assertContentEquals(ByteArray(0), recovered) } @@ -258,7 +257,7 @@ class CryptographicScopeImplTest { wrappedVaultKeyInformation = vaultInfo, wrappedItemKeyInformation = WrappedItemKeyInformation(itemAad = aad), ) { - List(attempts) { plaintext.encrypt(label) } + List(attempts) { plaintext.encrypt(kind) } }.assertSuccess() val uniqueCiphertexts = ciphertexts.map { it.data.contentToString() }.toSet() @@ -278,7 +277,7 @@ class CryptographicScopeImplTest { } @Test - fun `different labels yield different ciphertext for same plaintext`() = runTest { + fun `different kinds yield different ciphertext for same plaintext`() = runTest { val vaultInfo = wrappedVaultKeyInformation() val aad = itemAad(vaultInfo.vaultId) val plaintext = "Same data, different field".toByteArray() @@ -288,8 +287,8 @@ class CryptographicScopeImplTest { wrappedItemKeyInformation = WrappedItemKeyInformation(itemAad = aad), ) { Triple( - plaintext.encrypt(label = "password"), - plaintext.encrypt(label = "totp_secret"), + plaintext.encrypt(SecretKind.Password), + plaintext.encrypt(SecretKind.TotpSecret), wrapCurrentItemKey(), ) }.assertSuccess() @@ -303,7 +302,7 @@ class CryptographicScopeImplTest { wrappedItemKey = wrapped, ), ) { - passwordCipher.decrypt(label = "password") to totpCipher.decrypt(label = "totp_secret") + passwordCipher.decrypt(SecretKind.Password) to totpCipher.decrypt(SecretKind.TotpSecret) }.assertSuccess() assertContentEquals(plaintext, recoveredPassword) @@ -320,7 +319,7 @@ class CryptographicScopeImplTest { wrappedVaultKeyInformation = vaultInfo, wrappedItemKeyInformation = WrappedItemKeyInformation(itemAad = aad), ) { - plaintext.encrypt(label) to wrapCurrentItemKey() + plaintext.encrypt(kind) to wrapCurrentItemKey() }.assertSuccess() val tampered = CryptographicData( @@ -338,7 +337,7 @@ class CryptographicScopeImplTest { itemAad = aad, wrappedItemKey = wrapped, ), - ) { tampered.decrypt(label) } + ) { tampered.decrypt(kind) } } } @@ -354,8 +353,8 @@ class CryptographicScopeImplTest { ) { val jobs: List> = List(10) { async { - val encrypted = plaintext.encrypt(label) - val decrypted = encrypted.decrypt(label) + val encrypted = plaintext.encrypt(kind) + val decrypted = encrypted.decrypt(kind) plaintext.contentEquals(decrypted) } } @@ -378,7 +377,7 @@ class CryptographicScopeImplTest { wrappedVaultKeyInformation = vaultInfo, wrappedItemKeyInformation = WrappedItemKeyInformation(itemAad = aad), ) { - plaintext.encrypt(label) to wrapCurrentItemKey() + plaintext.encrypt(kind) to wrapCurrentItemKey() }.assertSuccess() val recovered = provider.itemScope( @@ -387,7 +386,7 @@ class CryptographicScopeImplTest { itemAad = aad, wrappedItemKey = wrapped, ), - ) { cipher.decrypt(label) }.assertSuccess() + ) { cipher.decrypt(kind) }.assertSuccess() assertContentEquals(plaintext, recovered) } @@ -405,7 +404,7 @@ class CryptographicScopeImplTest { wrappedVaultKeyInformation = vaultInfo, wrappedItemKeyInformation = WrappedItemKeyInformation(itemAad = aad), ) { - plaintext.encrypt(label) to wrapCurrentItemKey() + plaintext.encrypt(kind) to wrapCurrentItemKey() }.assertSuccess() val recovered = provider.itemScope( @@ -414,7 +413,7 @@ class CryptographicScopeImplTest { itemAad = aad, wrappedItemKey = wrapped, ), - ) { cipher.decrypt(label) }.assertSuccess() + ) { cipher.decrypt(kind) }.assertSuccess() assertContentEquals(plaintext, recovered, "size=$size should round-trip") } diff --git a/core/security/src/test/kotlin/de/davis/keygo/core/security/data/crypto/AccountRecordSealerImplTest.kt b/core/security/src/test/kotlin/de/davis/keygo/core/security/data/crypto/AccountRecordSealerImplTest.kt new file mode 100644 index 000000000..51b9a9764 --- /dev/null +++ b/core/security/src/test/kotlin/de/davis/keygo/core/security/data/crypto/AccountRecordSealerImplTest.kt @@ -0,0 +1,92 @@ +package de.davis.keygo.core.security.data.crypto + +import de.davis.keygo.core.item.domain.model.SecretKind +import de.davis.keygo.core.security.FakeSession +import de.davis.keygo.core.security.domain.crypto.model.SealedAccountRecord +import de.davis.keygo.core.security.domain.model.AccountRecordError +import de.davis.keygo.core.util.assertFailure +import de.davis.keygo.core.util.assertSuccess +import kotlinx.coroutines.test.runTest +import java.util.UUID +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFalse + +class AccountRecordSealerImplTest { + + private val session = FakeSession(startUnlocked = true) + private val sealer = AccountRecordSealerImpl(session) + + @Test + fun `a sealed record opens to its plaintext`() = runTest { + val record = sealer.seal(NAMESPACE, KIND, PLAINTEXT).assertSuccess() + + assertContentEquals(PLAINTEXT, sealer.open(NAMESPACE, KIND, record).assertSuccess()) + } + + @Test + fun `every seal uses a fresh key`() = runTest { + val first = sealer.seal(NAMESPACE, KIND, PLAINTEXT).assertSuccess() + val second = sealer.seal(NAMESPACE, KIND, PLAINTEXT).assertSuccess() + + assertFalse(first.wrappedKey.wrappedKey.contentEquals(second.wrappedKey.wrappedKey)) + } + + @Test + fun `a record under another namespace is a key failure`() = runTest { + val record = sealer.seal(NAMESPACE, KIND, PLAINTEXT).assertSuccess() + + assertEquals( + AccountRecordError.KeyUnwrapFailed, + sealer.open(UUID.randomUUID(), KIND, record).assertFailure(), + ) + } + + @Test + fun `a record opened as another kind is corrupted`() = runTest { + val record = sealer.seal(NAMESPACE, KIND, PLAINTEXT).assertSuccess() + + assertEquals( + AccountRecordError.Corrupted, + sealer.open(NAMESPACE, SecretKind.Password, record).assertFailure(), + ) + } + + @Test + fun `tampered data is corrupted`() = runTest { + val record = sealer.seal(NAMESPACE, KIND, PLAINTEXT).assertSuccess() + val tampered = SealedAccountRecord( + wrappedKey = record.wrappedKey, + data = record.data.copy( + data = record.data.data.copyOf().also { it[0] = (it[0] + 1).toByte() }, + ), + ) + + assertEquals( + AccountRecordError.Corrupted, + sealer.open(NAMESPACE, KIND, tampered).assertFailure(), + ) + } + + @Test + fun `a locked session can neither seal nor open`() = runTest { + val record = sealer.seal(NAMESPACE, KIND, PLAINTEXT).assertSuccess() + session.endSession() + + assertEquals( + AccountRecordError.NoActiveSession, + sealer.seal(NAMESPACE, KIND, PLAINTEXT).assertFailure(), + ) + assertEquals( + AccountRecordError.NoActiveSession, + sealer.open(NAMESPACE, KIND, record).assertFailure(), + ) + } + + private companion object { + val NAMESPACE: UUID = UUID.fromString("39050172-ce6d-4f4d-8787-6e12ab44eb0e") + val KIND = SecretKind.PasswordHealthReport + val PLAINTEXT = byteArrayOf(1, 2, 3, 4) + } +} diff --git a/core/security/src/test/kotlin/de/davis/keygo/core/security/data/crypto/CryptographicScopeProviderImplTest.kt b/core/security/src/test/kotlin/de/davis/keygo/core/security/data/crypto/CryptographicScopeProviderImplTest.kt index 578657cb5..bb3289760 100644 --- a/core/security/src/test/kotlin/de/davis/keygo/core/security/data/crypto/CryptographicScopeProviderImplTest.kt +++ b/core/security/src/test/kotlin/de/davis/keygo/core/security/data/crypto/CryptographicScopeProviderImplTest.kt @@ -13,7 +13,6 @@ import de.davis.keygo.core.util.Result import de.davis.keygo.core.util.isFailure import de.davis.keygo.rust.FakeItemManager import de.davis.keygo.rust.FakeKeyWrapper -import de.davis.keygo.rust.FakeVaultManager import de.davisalessandro.keygo.rust.ItemAad import de.davisalessandro.keygo.rust.KeyWrapException import kotlinx.coroutines.test.runTest @@ -30,7 +29,6 @@ class CryptographicScopeProviderImplTest { itemRepository = FakeItemRepository(), itemManager = FakeItemManager(), keyWrapper = FakeKeyWrapper(), - vaultManager = FakeVaultManager(), ) private fun wrappedVaultKeyInformation(vaultId: java.util.UUID) = WrappedVaultKeyInformation( diff --git a/core/security/src/testFixtures/kotlin/de/davis/keygo/core/security/FakeSession.kt b/core/security/src/testFixtures/kotlin/de/davis/keygo/core/security/FakeSession.kt index 626b78b6a..943797984 100644 --- a/core/security/src/testFixtures/kotlin/de/davis/keygo/core/security/FakeSession.kt +++ b/core/security/src/testFixtures/kotlin/de/davis/keygo/core/security/FakeSession.kt @@ -4,11 +4,14 @@ import de.davis.keygo.core.security.domain.ExportArk import de.davis.keygo.core.security.domain.Session import de.davis.keygo.core.security.domain.SessionError import de.davis.keygo.core.util.Result +import de.davisalessandro.keygo.rust.AccountRecord import de.davisalessandro.keygo.rust.ArkCredential +import de.davisalessandro.keygo.rust.EncryptedItemBlob import de.davisalessandro.keygo.rust.KeyWrapException import de.davisalessandro.keygo.rust.NewAccount import de.davisalessandro.keygo.rust.NoHandle import de.davisalessandro.keygo.rust.PasswordWrapped +import de.davisalessandro.keygo.rust.SecretKind import de.davisalessandro.keygo.rust.WrappedKeyBlob import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -71,7 +74,7 @@ class FakeSession(startUnlocked: Boolean = false) : Session { if (failDerivation) return Result.Failure(SessionError.Derivation("forced")) val recovered = unwrap(kek(password, salt), wrapped, userId) - ?: return Result.Failure(SessionError.KeyWrap(KeyWrapException.UnwrapFailed())) + ?: return Result.Failure(SessionError.WrongPassword) ark = recovered _isActive.value = true @@ -154,6 +157,38 @@ class FakeSession(startUnlocked: Boolean = false) : Session { return Result.Success(recovered) } + override suspend fun sealAccountRecord( + namespace: UUID, + kind: SecretKind, + plaintext: ByteArray, + ): Result { + val active = ark ?: return Result.Failure(SessionError.Locked) + val key = randomBytes(32) + val data = wrap(key, plaintext, recordId(namespace, kind)) + + return Result.Success( + AccountRecord( + wrappedKey = wrap(active, key, namespace), + data = EncryptedItemBlob(ciphertext = data.ciphertext, nonce = data.nonce), + ), + ) + } + + override suspend fun openAccountRecord( + namespace: UUID, + kind: SecretKind, + record: AccountRecord, + ): Result { + val active = ark ?: return Result.Failure(SessionError.Locked) + val key = unwrap(active, record.wrappedKey, namespace) + ?: return Result.Failure(SessionError.KeyWrap(KeyWrapException.UnwrapFailed())) + val data = WrappedKeyBlob(ciphertext = record.data.ciphertext, nonce = record.data.nonce) + + return unwrap(key, data, recordId(namespace, kind)) + ?.let { Result.Success(it) } + ?: Result.Failure(SessionError.DecryptionFailed) + } + override fun endSession() { ark = null _isActive.value = false @@ -209,6 +244,10 @@ class FakeSession(startUnlocked: Boolean = false) : Session { } } + /** Binds a record's data to its kind as well as its namespace, as the Rust AAD does. */ + private fun recordId(namespace: UUID, kind: SecretKind): UUID = + UUID.nameUUIDFromBytes("$namespace/$kind".toByteArray()) + private fun randomBytes(size: Int): ByteArray = random.nextBytes(size) private fun randomUUID(): UUID = UUID(random.nextLong(), random.nextLong()) diff --git a/core/security/src/testFixtures/kotlin/de/davis/keygo/core/security/crypto/BindingAccountRecordSealer.kt b/core/security/src/testFixtures/kotlin/de/davis/keygo/core/security/crypto/BindingAccountRecordSealer.kt new file mode 100644 index 000000000..799e13e86 --- /dev/null +++ b/core/security/src/testFixtures/kotlin/de/davis/keygo/core/security/crypto/BindingAccountRecordSealer.kt @@ -0,0 +1,12 @@ +package de.davis.keygo.core.security.crypto + +import de.davis.keygo.core.security.data.crypto.AccountRecordSealerImpl +import de.davis.keygo.core.security.domain.Session +import de.davis.keygo.core.security.domain.crypto.AccountRecordSealer + +/** + * Exposes the internal production [AccountRecordSealer] over [session] for tests. + */ +@Suppress("TestFunctionName") +fun BindingAccountRecordSealer(session: Session): AccountRecordSealer = + AccountRecordSealerImpl(session) diff --git a/core/security/src/testFixtures/kotlin/de/davis/keygo/core/security/crypto/BindingCryptographicScopeProvider.kt b/core/security/src/testFixtures/kotlin/de/davis/keygo/core/security/crypto/BindingCryptographicScopeProvider.kt index d2429bbe5..e41cd40ee 100644 --- a/core/security/src/testFixtures/kotlin/de/davis/keygo/core/security/crypto/BindingCryptographicScopeProvider.kt +++ b/core/security/src/testFixtures/kotlin/de/davis/keygo/core/security/crypto/BindingCryptographicScopeProvider.kt @@ -6,13 +6,12 @@ import de.davis.keygo.core.security.domain.Session import de.davis.keygo.core.security.domain.crypto.CryptographicScopeProvider import de.davisalessandro.keygo.rust.ItemManagerInterface import de.davisalessandro.keygo.rust.KeyWrapperInterface -import de.davisalessandro.keygo.rust.VaultManagerInterface /** * Constructs the production [CryptographicScopeProvider] backed by the supplied fakes. * * Use when a test depends on the AAD-binding semantics: ciphertext bound to - * (vaultId, itemId, label), item keys wrapped under the vault key. For tests + * (vaultId, itemId, secret kind), item keys wrapped under the vault key. For tests * that only need a deterministic round-trip with no AAD enforcement, * [FakeCryptographicScopeProvider] is simpler. */ @@ -22,11 +21,9 @@ fun BindingCryptographicScopeProvider( itemRepository: ItemRepository, itemManager: ItemManagerInterface, keyWrapper: KeyWrapperInterface, - vaultManager: VaultManagerInterface, ): CryptographicScopeProvider = CryptographicScopeProviderImpl( session = session, itemRepository = itemRepository, itemManager = itemManager, keyWrapper = keyWrapper, - vaultManager = vaultManager, ) diff --git a/core/security/src/testFixtures/kotlin/de/davis/keygo/core/security/crypto/FakeCryptographicScopeProvider.kt b/core/security/src/testFixtures/kotlin/de/davis/keygo/core/security/crypto/FakeCryptographicScopeProvider.kt index 8bb556fc8..01580a7b0 100644 --- a/core/security/src/testFixtures/kotlin/de/davis/keygo/core/security/crypto/FakeCryptographicScopeProvider.kt +++ b/core/security/src/testFixtures/kotlin/de/davis/keygo/core/security/crypto/FakeCryptographicScopeProvider.kt @@ -2,6 +2,7 @@ package de.davis.keygo.core.security.crypto import de.davis.keygo.core.item.domain.alias.ItemId import de.davis.keygo.core.item.domain.model.KeyInformation +import de.davis.keygo.core.item.domain.model.SecretKind import de.davis.keygo.core.item.domain.repository.ItemRepository import de.davis.keygo.core.security.domain.crypto.CryptographicScope import de.davis.keygo.core.security.domain.crypto.CryptographicScopeProvider @@ -11,7 +12,6 @@ import de.davis.keygo.core.security.domain.crypto.model.WrappedVaultKeyInformati import de.davis.keygo.core.security.domain.model.CryptoScopeError import de.davis.keygo.core.util.Result import java.util.Collections -import java.util.UUID import kotlin.coroutines.CoroutineContext import kotlin.experimental.xor @@ -21,15 +21,13 @@ class FakeCryptographicScopeProvider( sealed interface CallHistory { - class EncryptCall(val label: String, val plaintext: ByteArray) : CallHistory - class DecryptCall(val label: String, val data: ByteArray) : CallHistory + class EncryptCall(val kind: SecretKind, val plaintext: ByteArray) : CallHistory + class DecryptCall(val kind: SecretKind, val data: ByteArray) : CallHistory class RewrapCall( val sourceVault: WrappedVaultKeyInformation, val sourceItem: WrappedItemKeyInformation, val destinationVault: WrappedVaultKeyInformation, ) : CallHistory - - class AccountScopeCall(val namespace: UUID, val hadKey: Boolean) : CallHistory } // Synchronized: BackupCollector calls encrypt/decrypt concurrently across items, and this @@ -39,9 +37,6 @@ class FakeCryptographicScopeProvider( get() = callHistory.filterIsInstance() val rewrapCalls get() = callHistory.filterIsInstance() - val accountScopeCalls - get() = callHistory.filterIsInstance() - /** Result returned by the next [rewrapItemKey] call. */ var rewrapResult: Result = @@ -81,17 +76,6 @@ class FakeCryptographicScopeProvider( return block(recordingScope()).let(Result::Success) } - override suspend fun accountScope( - namespace: UUID, - wrapped: KeyInformation?, - block: suspend CryptographicScope.() -> R - ): Result { - callHistory += CallHistory.AccountScopeCall(namespace, wrapped != null) - itemScopeFailure?.let { return Result.Failure(it) } - - return block(recordingScope()).let(Result::Success) - } - override suspend fun rewrapItemKey( sourceVault: WrappedVaultKeyInformation, sourceItem: WrappedItemKeyInformation, @@ -103,18 +87,18 @@ class FakeCryptographicScopeProvider( private fun recordingScope() = object : CryptographicScope { override suspend fun ByteArray.encrypt( - label: String, + kind: SecretKind, context: CoroutineContext, ): CryptographicData { - callHistory += CallHistory.EncryptCall(label, this.copyOf()) + callHistory += CallHistory.EncryptCall(kind, this.copyOf()) return CryptographicData(data = transform(this), iv = IV) } override suspend fun CryptographicData.decrypt( - label: String, + kind: SecretKind, context: CoroutineContext, ): ByteArray { - callHistory += CallHistory.DecryptCall(label, this.data.copyOf()) + callHistory += CallHistory.DecryptCall(kind, this.data.copyOf()) return transform(data) } diff --git a/feature/auth/build.gradle.kts b/feature/auth/build.gradle.kts index 63a75ac50..425071400 100644 --- a/feature/auth/build.gradle.kts +++ b/feature/auth/build.gradle.kts @@ -14,12 +14,12 @@ dependencies { implementation(projects.core.ui) implementation(projects.legacyMigration) - testImplementation(projects.rust) + testImplementation(projects.core.rust) testImplementation(libs.robolectric) testImplementation(testFixtures(projects.core.biometrics)) testImplementation(testFixtures(projects.core.identity)) testImplementation(testFixtures(projects.core.item)) testImplementation(testFixtures(projects.core.security)) - testImplementation(testFixtures(projects.rust)) + testImplementation(testFixtures(projects.core.rust)) testImplementation(testFixtures(projects.legacyMigration)) } diff --git a/feature/autofill/build.gradle.kts b/feature/autofill/build.gradle.kts index 5f99e9a69..c16885ee0 100644 --- a/feature/autofill/build.gradle.kts +++ b/feature/autofill/build.gradle.kts @@ -47,7 +47,7 @@ dependencies { testImplementation(testFixtures(projects.core.item)) testImplementation(testFixtures(projects.core.util)) testImplementation(testFixtures(projects.core.security)) - testImplementation(testFixtures(projects.rust)) + testImplementation(testFixtures(projects.core.rust)) testImplementation(libs.robolectric) testImplementation(libs.androidx.junit) diff --git a/feature/backup/build.gradle.kts b/feature/backup/build.gradle.kts index c86132099..ec4f14654 100644 --- a/feature/backup/build.gradle.kts +++ b/feature/backup/build.gradle.kts @@ -26,7 +26,7 @@ dependencies { implementation(projects.core.util) implementation(projects.core.item) implementation(projects.core.security) - implementation(projects.rust) + implementation(projects.core.rust) implementation(projects.feature.item.core) implementation(projects.feature.vault) @@ -34,7 +34,7 @@ dependencies { testImplementation(testFixtures(projects.core.item)) testImplementation(testFixtures(projects.core.security)) testImplementation(testFixtures(projects.core.util)) - testImplementation(testFixtures(projects.rust)) + testImplementation(testFixtures(projects.core.rust)) testImplementation(libs.robolectric) testFixturesApi(projects.core.util) diff --git a/feature/credentials/build.gradle.kts b/feature/credentials/build.gradle.kts index f55aae43d..9c558c53b 100644 --- a/feature/credentials/build.gradle.kts +++ b/feature/credentials/build.gradle.kts @@ -15,7 +15,7 @@ dependencies { implementation(libs.androidx.credentials) implementation(libs.kotlinx.serialization.json) - implementation(projects.rust) + implementation(projects.core.rust) implementation(projects.core.identity) implementation(projects.core.item) implementation(projects.core.ui) @@ -28,6 +28,6 @@ dependencies { testImplementation(testFixtures(projects.core.identity)) testImplementation(testFixtures(projects.core.item)) testImplementation(testFixtures(projects.core.security)) - testImplementation(testFixtures(projects.rust)) + testImplementation(testFixtures(projects.core.rust)) } diff --git a/feature/credentials/src/main/kotlin/de/davis/keygo/feature/credentials/presentation/create/activity/CreatePasskeyViewModel.kt b/feature/credentials/src/main/kotlin/de/davis/keygo/feature/credentials/presentation/create/activity/CreatePasskeyViewModel.kt index 21a16e4a9..ade5805a0 100644 --- a/feature/credentials/src/main/kotlin/de/davis/keygo/feature/credentials/presentation/create/activity/CreatePasskeyViewModel.kt +++ b/feature/credentials/src/main/kotlin/de/davis/keygo/feature/credentials/presentation/create/activity/CreatePasskeyViewModel.kt @@ -25,7 +25,7 @@ import de.davis.keygo.feature.item.core.domain.usecase.CreateNewOrUpdateLoginUse import de.davis.keygo.rust.passkey.PasskeyManager import de.davis.keygo.rust.passkey.getPasskeyInformation import de.davis.keygo.rust.passkey.registerWithResult -import de.davisalessandro.keygo.rust.RegistrationResponse +import de.davisalessandro.keygo.rust.Registration import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableStateFlow @@ -146,7 +146,7 @@ internal class CreatePasskeyViewModel( } } - private fun RegistrationResponse.toNewPasskey() = NewPasskey( + private fun Registration.toNewPasskey() = NewPasskey( credentialId = credentialId, rp = rp, user = PasskeyUser(name = userName, displayName = userDisplayName), diff --git a/feature/item/core/build.gradle.kts b/feature/item/core/build.gradle.kts index 41bc1422b..8f5bfd484 100644 --- a/feature/item/core/build.gradle.kts +++ b/feature/item/core/build.gradle.kts @@ -31,6 +31,6 @@ dependencies { testImplementation(testFixtures(projects.core.item)) testImplementation(testFixtures(projects.core.security)) testImplementation(testFixtures(projects.core.util)) - testImplementation(testFixtures(projects.rust)) + testImplementation(testFixtures(projects.core.rust)) } diff --git a/feature/item/core/src/test/kotlin/de/davis/keygo/feature/item/core/domain/usecase/CreateNewOrUpdateCreditCardUseCaseTest.kt b/feature/item/core/src/test/kotlin/de/davis/keygo/feature/item/core/domain/usecase/CreateNewOrUpdateCreditCardUseCaseTest.kt index 0085dc495..096bca377 100644 --- a/feature/item/core/src/test/kotlin/de/davis/keygo/feature/item/core/domain/usecase/CreateNewOrUpdateCreditCardUseCaseTest.kt +++ b/feature/item/core/src/test/kotlin/de/davis/keygo/feature/item/core/domain/usecase/CreateNewOrUpdateCreditCardUseCaseTest.kt @@ -144,11 +144,11 @@ class CreateNewOrUpdateCreditCardUseCaseTest { val stored = storedById(result.getOrNull()) assertNotNull(stored) - val labels = cryptoProvider.encryptCalls.map { it.label } - assertContains(labels, CreditCard.CardNumber.label) - assertContains(labels, CreditCard.CVV.label) + val kinds = cryptoProvider.encryptCalls.map { it.kind } + assertContains(kinds, CreditCard.CardNumber.kind) + assertContains(kinds, CreditCard.CVV.kind) - val numberCall = cryptoProvider.encryptCalls.single { it.label == CreditCard.CardNumber.label } + val numberCall = cryptoProvider.encryptCalls.single { it.kind == CreditCard.CardNumber.kind } assertContentEquals(plaintextNumber.encodeToByteArray(), numberCall.plaintext) assertNotNull(stored.cardNumber) @@ -240,7 +240,7 @@ class CreateNewOrUpdateCreditCardUseCaseTest { assertTrue(result.isSuccess(), "result: $result") - val numberCall = cryptoProvider.encryptCalls.single { it.label == CreditCard.CardNumber.label } + val numberCall = cryptoProvider.encryptCalls.single { it.kind == CreditCard.CardNumber.kind } assertContentEquals(plaintextNumber.encodeToByteArray(), numberCall.plaintext) val stored = creditCardRepository.getCreditCardById(existing.id) diff --git a/feature/item/core/src/test/kotlin/de/davis/keygo/feature/item/core/domain/usecase/CreateNewOrUpdateLoginUseCaseTest.kt b/feature/item/core/src/test/kotlin/de/davis/keygo/feature/item/core/domain/usecase/CreateNewOrUpdateLoginUseCaseTest.kt index 5968cdb73..1631eee0a 100644 --- a/feature/item/core/src/test/kotlin/de/davis/keygo/feature/item/core/domain/usecase/CreateNewOrUpdateLoginUseCaseTest.kt +++ b/feature/item/core/src/test/kotlin/de/davis/keygo/feature/item/core/domain/usecase/CreateNewOrUpdateLoginUseCaseTest.kt @@ -138,7 +138,7 @@ class CreateNewOrUpdateLoginUseCaseTest { FakeCryptographicScopeProvider.transform("private".encodeToByteArray()), row.privateKey.payload.ciphertext, ) - assertContains(cryptoProvider.encryptCalls.map { it.label }, Passkey.PrivateKey.label) + assertContains(cryptoProvider.encryptCalls.map { it.kind }, Passkey.PrivateKey.kind) } @Test @@ -847,15 +847,15 @@ class CreateNewOrUpdateLoginUseCaseTest { val stored = storedById(result.getOrNull()) assertNotNull(stored) - val labels = cryptoProvider.encryptCalls.map { it.label } - assertContains(labels, PasswordSecret.label) - assertContains(labels, Totp.Secret.label) - assertEquals(2, labels.size) + val kinds = cryptoProvider.encryptCalls.map { it.kind } + assertContains(kinds, PasswordSecret.kind) + assertContains(kinds, Totp.Secret.kind) + assertEquals(2, kinds.size) - val passwordCall = cryptoProvider.encryptCalls.single { it.label == PasswordSecret.label } + val passwordCall = cryptoProvider.encryptCalls.single { it.kind == PasswordSecret.kind } assertContentEquals(plaintextPassword.encodeToByteArray(), passwordCall.plaintext) - val totpCall = cryptoProvider.encryptCalls.single { it.label == Totp.Secret.label } + val totpCall = cryptoProvider.encryptCalls.single { it.kind == Totp.Secret.kind } assertContentEquals(plaintextTotp.encodeToByteArray(), totpCall.plaintext) val storedSecret = stored.passwordCredential!!.secret diff --git a/feature/item/create/build.gradle.kts b/feature/item/create/build.gradle.kts index 2ecbe6e6e..68df073a3 100644 --- a/feature/item/create/build.gradle.kts +++ b/feature/item/create/build.gradle.kts @@ -25,7 +25,7 @@ dependencies { testImplementation(testFixtures(projects.core.item)) testImplementation(testFixtures(projects.core.security)) testImplementation(testFixtures(projects.core.util)) - testImplementation(testFixtures(projects.rust)) + testImplementation(testFixtures(projects.core.rust)) testImplementation(libs.robolectric) testImplementation(libs.androidx.junit) } diff --git a/feature/item/view/build.gradle.kts b/feature/item/view/build.gradle.kts index ac593eaf2..96f685495 100644 --- a/feature/item/view/build.gradle.kts +++ b/feature/item/view/build.gradle.kts @@ -19,6 +19,6 @@ dependencies { testImplementation(testFixtures(projects.core.item)) testImplementation(testFixtures(projects.core.security)) - testImplementation(testFixtures(projects.rust)) + testImplementation(testFixtures(projects.core.rust)) } diff --git a/feature/onboarding/build.gradle.kts b/feature/onboarding/build.gradle.kts index 8fab926c8..139c5a4da 100644 --- a/feature/onboarding/build.gradle.kts +++ b/feature/onboarding/build.gradle.kts @@ -23,6 +23,6 @@ dependencies { testImplementation(testFixtures(projects.core.identity)) testImplementation(testFixtures(projects.core.item)) testImplementation(testFixtures(projects.core.security)) - testImplementation(testFixtures(projects.rust)) + testImplementation(testFixtures(projects.core.rust)) testImplementation(testFixtures(projects.feature.autofill)) } diff --git a/feature/password-health/build.gradle.kts b/feature/password-health/build.gradle.kts index daf56b254..2e62df4e5 100644 --- a/feature/password-health/build.gradle.kts +++ b/feature/password-health/build.gradle.kts @@ -32,6 +32,6 @@ dependencies { testImplementation(testFixtures(projects.core.util)) testImplementation(testFixtures(projects.core.item)) testImplementation(testFixtures(projects.core.security)) - testImplementation(testFixtures(projects.rust)) + testImplementation(testFixtures(projects.core.rust)) testImplementation(libs.robolectric) } diff --git a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/data/repository/HealthReportStoreRepositoryImpl.kt b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/data/repository/HealthReportStoreRepositoryImpl.kt index f9f424e4d..74861400d 100644 --- a/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/data/repository/HealthReportStoreRepositoryImpl.kt +++ b/feature/password-health/src/main/kotlin/de/davis/keygo/feature/password_health/data/repository/HealthReportStoreRepositoryImpl.kt @@ -1,11 +1,13 @@ package de.davis.keygo.feature.password_health.data.repository import androidx.datastore.core.DataStore -import com.google.protobuf.kotlin.isNotEmpty import com.google.protobuf.kotlin.toByteString import de.davis.keygo.core.item.domain.model.KeyInformation -import de.davis.keygo.core.security.domain.crypto.CryptographicScopeProvider +import de.davis.keygo.core.item.domain.model.SecretKind +import de.davis.keygo.core.security.domain.crypto.AccountRecordSealer import de.davis.keygo.core.security.domain.crypto.model.CryptographicData +import de.davis.keygo.core.security.domain.crypto.model.SealedAccountRecord +import de.davis.keygo.core.security.domain.model.AccountRecordError import de.davis.keygo.core.util.Result import de.davis.keygo.core.util.asResult import de.davis.keygo.core.util.resultBinding @@ -16,7 +18,6 @@ import de.davis.keygo.feature.password_health.di.annotation.HealthReportStore import de.davis.keygo.feature.password_health.domain.model.HealthReportStoreError import de.davis.keygo.feature.password_health.domain.model.StoredHealthReport import de.davis.keygo.feature.password_health.domain.repository.HealthReportStoreRepository -import de.davisalessandro.keygo.rust.ItemCryptoException import kotlinx.coroutines.flow.first import org.koin.core.annotation.Single import java.util.UUID @@ -25,29 +26,23 @@ import java.util.UUID internal class HealthReportStoreRepositoryImpl( @HealthReportStore private val dataStore: DataStore, - private val cryptographicScopeProvider: CryptographicScopeProvider, + private val accountRecordSealer: AccountRecordSealer, ) : HealthReportStoreRepository { override suspend fun storeReport(report: StoredHealthReport): Result = resultBinding { - val stored = dataStore.data.first() - .takeIf { it.wrappedKey.isNotEmpty() } - - val compressedReport = report.toCompressedByteArray() - - val (sealedReport, key) = cryptographicScopeProvider.accountScope( + val sealed = accountRecordSealer.seal( namespace = REPORT_NAMESPACE, - wrapped = stored?.toWrappedKey(), - ) { - compressedReport.encrypt(REPORT_LABEL) to wrapCurrentItemKey() - }.bind { HealthReportStoreError.CryptoFailed } + kind = SecretKind.PasswordHealthReport, + plaintext = report.toCompressedByteArray(), + ).bind { HealthReportStoreError.CryptoFailed } dataStore.updateData { it.toBuilder() - .setWrappedKey(key.wrappedKey.toByteString()) - .setWrappedKeyNonce(key.keyNonce.toByteString()) - .setCiphertext(sealedReport.data.toByteString()) - .setIv(sealedReport.iv.toByteString()) + .setWrappedKey(sealed.wrappedKey.wrappedKey.toByteString()) + .setWrappedKeyNonce(sealed.wrappedKey.keyNonce.toByteString()) + .setCiphertext(sealed.data.data.toByteString()) + .setIv(sealed.data.iv.toByteString()) .build() } } @@ -59,35 +54,32 @@ internal class HealthReportStoreRepositoryImpl( return Result.Failure(HealthReportStoreError.NoReportStored) } - val compressedReport = cryptographicScopeProvider.accountScope( + val compressedReport = accountRecordSealer.open( namespace = REPORT_NAMESPACE, - wrapped = stored.toWrappedKey(), - ) { - try { - CryptographicData( - data = stored.ciphertext.toByteArray(), - iv = stored.iv.toByteArray(), - ).decrypt(REPORT_LABEL) - } catch (_: ItemCryptoException) { - null - } - }.bind { HealthReportStoreError.CryptoFailed } + kind = SecretKind.PasswordHealthReport, + record = stored.toSealedRecord(), + ).bind { + if (it == AccountRecordError.Corrupted) HealthReportStoreError.CorruptedReport + else HealthReportStoreError.CryptoFailed + } - compressedReport?.toStoredHealthReport() + compressedReport.toStoredHealthReport() .asResult(HealthReportStoreError.CorruptedReport) .bind() } - private fun ProtoHealthReportStore.toWrappedKey() = KeyInformation( - wrappedKey = wrappedKey.toByteArray(), - keyNonce = wrappedKeyNonce.toByteArray(), + private fun ProtoHealthReportStore.toSealedRecord() = SealedAccountRecord( + wrappedKey = KeyInformation( + wrappedKey = wrappedKey.toByteArray(), + keyNonce = wrappedKeyNonce.toByteArray(), + ), + data = CryptographicData(data = ciphertext.toByteArray(), iv = iv.toByteArray()), ) companion object { - // On-disk identity: the AAD domain separator for every stored report. Never change it; - // doing so makes every previously stored report permanently undecryptable. + // On-disk identity: part of every stored report's AAD. Changing it makes stored reports + // unreadable, which costs only a rescan: the store is a cache. private val REPORT_NAMESPACE: UUID = UUID.fromString("39050172-ce6d-4f4d-8787-6e12ab44eb0e") - private const val REPORT_LABEL = "password_health_report" } } diff --git a/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/data/repository/HealthReportStoreRepositoryImplTest.kt b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/data/repository/HealthReportStoreRepositoryImplTest.kt index 0486a0a79..504d5869c 100644 --- a/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/data/repository/HealthReportStoreRepositoryImplTest.kt +++ b/feature/password-health/src/test/kotlin/de/davis/keygo/feature/password_health/data/repository/HealthReportStoreRepositoryImplTest.kt @@ -1,14 +1,13 @@ package de.davis.keygo.feature.password_health.data.repository import com.google.protobuf.kotlin.toByteString -import de.davis.keygo.core.item.FakeItemRepository import de.davis.keygo.core.item.domain.model.KeyInformation import de.davis.keygo.core.item.domain.model.PasswordScore +import de.davis.keygo.core.item.domain.model.SecretKind import de.davis.keygo.core.security.FakeSession -import de.davis.keygo.core.security.crypto.BindingCryptographicScopeProvider -import de.davis.keygo.core.security.domain.SessionError +import de.davis.keygo.core.security.crypto.BindingAccountRecordSealer import de.davis.keygo.core.security.domain.crypto.model.CryptographicData -import de.davis.keygo.core.util.Result +import de.davis.keygo.core.security.domain.crypto.model.SealedAccountRecord import de.davis.keygo.core.util.assertFailure import de.davis.keygo.core.util.assertSuccess import de.davis.keygo.feature.password_health.data.local.model.ProtoHealthReportStore @@ -26,30 +25,22 @@ import de.davis.keygo.feature.password_health.domain.model.StoredHealthReport import de.davis.keygo.feature.password_health.domain.report.breach import de.davis.keygo.feature.password_health.domain.report.id import de.davis.keygo.feature.password_health.domain.report.storedItem -import de.davis.keygo.rust.FakeItemManager -import de.davis.keygo.rust.FakeKeyWrapper -import de.davis.keygo.rust.FakeVaultManager import kotlinx.coroutines.test.runTest import java.io.ByteArrayOutputStream import java.util.UUID import java.util.zip.GZIPOutputStream import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertNotEquals import kotlin.test.assertTrue import kotlin.time.Instant class HealthReportStoreRepositoryImplTest { private val session = FakeSession(startUnlocked = true) - private val provider = BindingCryptographicScopeProvider( - session = session, - itemRepository = FakeItemRepository(), - itemManager = FakeItemManager(), - keyWrapper = FakeKeyWrapper(), - vaultManager = FakeVaultManager(), - ) + private val sealer = BindingAccountRecordSealer(session) private val dataStore = FakeDataStore(ProtoHealthReportStore.getDefaultInstance()) - private val repository = HealthReportStoreRepositoryImpl(dataStore, provider) + private val repository = HealthReportStoreRepositoryImpl(dataStore, sealer) private val report = StoredHealthReport( items = listOf( @@ -57,7 +48,12 @@ class HealthReportStoreRepositoryImplTest { storedItem(id(1), breach = breach(0, CHECKED_AT)), storedItem(id(2), unreadable = true), ), - relationalFindings = listOf(HealthFinding.Relation(setOf(id(0), id(1)), RelationType.Reused)), + relationalFindings = listOf( + HealthFinding.Relation( + setOf(id(0), id(1)), + RelationType.Reused + ) + ), gaps = mapOf(CheckKind.Breach to CheckGap(GapReason.Unreachable, setOf(id(2)))), breachCheckEnabled = true, ) @@ -97,46 +93,44 @@ class HealthReportStoreRepositoryImplTest { } @Test - fun laterReportsReuseTheFirstKey() = runTest { + fun everyReportGetsAFreshKey() = runTest { repository.storeReport(report).assertSuccess() val firstKey = dataStore.state.value.wrappedKey - repository.storeReport(report.copy(breachCheckEnabled = false)).assertSuccess() + repository.storeReport(report).assertSuccess() - assertEquals(firstKey, dataStore.state.value.wrappedKey) + assertNotEquals(firstKey, dataStore.state.value.wrappedKey) } @Test fun aLockedSessionCannotStoreAndLeavesTheStoreUntouched() = runTest { val locked = HealthReportStoreRepositoryImpl( dataStore, - BindingCryptographicScopeProvider( - session = FakeSession(startUnlocked = false), - itemRepository = FakeItemRepository(), - itemManager = FakeItemManager(), - keyWrapper = FakeKeyWrapper(), - vaultManager = FakeVaultManager(), - ), + BindingAccountRecordSealer(FakeSession(startUnlocked = false)), ) - assertEquals(HealthReportStoreError.CryptoFailed, locked.storeReport(report).assertFailure()) + assertEquals( + HealthReportStoreError.CryptoFailed, + locked.storeReport(report).assertFailure() + ) assertEquals(ProtoHealthReportStore.getDefaultInstance(), dataStore.state.value) } + /** Also the upgrade from 2.1.0, whose reports were sealed under another AAD. */ @Test - fun aKeyThatWillNotUnwrapIsACryptoFailureOnStore() = runTest { + fun aReportWhoseKeyWillNotUnwrapIsReplacedByTheNextStore() = runTest { + repository.storeReport(report).assertSuccess() + damageTheWrappedKey() + repository.storeReport(report).assertSuccess() - val before = dataStore.state.value - session.unwrapVaultKeyFailure = SessionError.Locked - assertEquals(HealthReportStoreError.CryptoFailed, repository.storeReport(report).assertFailure()) - assertEquals(before, dataStore.state.value) + assertEquals(report, repository.load().assertSuccess()) } @Test fun aKeyThatWillNotUnwrapIsACryptoFailureOnLoad() = runTest { repository.storeReport(report).assertSuccess() - session.unwrapVaultKeyFailure = SessionError.Locked + damageTheWrappedKey() assertEquals(HealthReportStoreError.CryptoFailed, repository.load().assertFailure()) } @@ -172,29 +166,35 @@ class HealthReportStoreRepositoryImplTest { @Test fun theReportIsSealedUnderItsFixedNamespaceAndLabel() = runTest { repository.storeReport(report).assertSuccess() - val stored = dataStore.state.value - - val opened = provider.accountScope( + val opened = sealer.open( namespace = REPORT_NAMESPACE, - wrapped = stored.wrappedKey(), - ) { - CryptographicData(stored.ciphertext.toByteArray(), stored.iv.toByteArray()) - .decrypt(REPORT_LABEL) - } + kind = SecretKind.PasswordHealthReport, + record = dataStore.state.value.sealedRecord(), + ).assertSuccess() - assertEquals(report, (opened as Result.Success).success.toStoredHealthReport()) + assertEquals(report, opened.toStoredHealthReport()) } @Test fun theKeyIsBoundToTheReportNamespace() = runTest { repository.storeReport(report).assertSuccess() - val opened = provider.accountScope( + sealer.open( namespace = UUID.randomUUID(), - wrapped = dataStore.state.value.wrappedKey(), - ) { } + kind = SecretKind.PasswordHealthReport, + record = dataStore.state.value.sealedRecord(), + ).assertFailure() + } - assertTrue(opened is Result.Failure) + @Test + fun theDataIsBoundToTheReportLabel() = runTest { + repository.storeReport(report).assertSuccess() + + sealer.open( + namespace = REPORT_NAMESPACE, + kind = SecretKind.Password, + record = dataStore.state.value.sealedRecord(), + ).assertFailure() } @Test @@ -259,17 +259,31 @@ class HealthReportStoreRepositoryImplTest { /** Replaces the stored report with [plaintext], sealed the way the repository seals one. */ private suspend fun seal(plaintext: ByteArray) { - val stored = dataStore.state.value - val sealed = provider.accountScope(REPORT_NAMESPACE, stored.wrappedKey()) { - plaintext.encrypt(REPORT_LABEL) - }.assertSuccess() - - dataStore.state.value = stored.toBuilder() - .setCiphertext(sealed.data.toByteString()) - .setIv(sealed.iv.toByteString()) + val sealed = sealer.seal( + namespace = REPORT_NAMESPACE, + kind = SecretKind.PasswordHealthReport, + plaintext = plaintext, + ).assertSuccess() + + dataStore.state.value = dataStore.state.value.toBuilder() + .setWrappedKey(sealed.wrappedKey.wrappedKey.toByteString()) + .setWrappedKeyNonce(sealed.wrappedKey.keyNonce.toByteString()) + .setCiphertext(sealed.data.data.toByteString()) + .setIv(sealed.data.iv.toByteString()) .build() } + private fun damageTheWrappedKey() { + val stored = dataStore.state.value + val damaged = stored.wrappedKey.toByteArray().also { it[0] = (it[0] + 1).toByte() } + dataStore.state.value = stored.toBuilder().setWrappedKey(damaged.toByteString()).build() + } + + private fun ProtoHealthReportStore.sealedRecord() = SealedAccountRecord( + wrappedKey = wrappedKey(), + data = CryptographicData(ciphertext.toByteArray(), iv.toByteArray()), + ) + private fun ProtoHealthReportStore.wrappedKey() = KeyInformation( wrappedKey = wrappedKey.toByteArray(), keyNonce = wrappedKeyNonce.toByteArray(), @@ -282,8 +296,7 @@ class HealthReportStoreRepositoryImplTest { private companion object { val CHECKED_AT: Instant = Instant.fromEpochMilliseconds(1_700_000_000_000) - // Pinned on purpose: changing either makes every stored report undecryptable. + // Pinned on purpose: changing it makes every stored report unreadable. val REPORT_NAMESPACE: UUID = UUID.fromString("39050172-ce6d-4f4d-8787-6e12ab44eb0e") - const val REPORT_LABEL = "password_health_report" } } diff --git a/feature/settings/build.gradle.kts b/feature/settings/build.gradle.kts index f48db9797..b69d6fd85 100644 --- a/feature/settings/build.gradle.kts +++ b/feature/settings/build.gradle.kts @@ -26,7 +26,7 @@ dependencies { testImplementation(testFixtures(projects.core.identity)) testImplementation(testFixtures(projects.core.security)) testImplementation(testFixtures(projects.core.util)) - testImplementation(testFixtures(projects.rust)) + testImplementation(testFixtures(projects.core.rust)) testImplementation(testFixtures(projects.feature.autofill)) testImplementation(testFixtures(projects.feature.backup)) diff --git a/feature/totp/build.gradle.kts b/feature/totp/build.gradle.kts index 4ecca6874..c7b7aeeeb 100644 --- a/feature/totp/build.gradle.kts +++ b/feature/totp/build.gradle.kts @@ -21,7 +21,7 @@ android { dependencies { implementation(libs.com.google.accompanist.permissions) - implementation(projects.rust) + implementation(projects.core.rust) implementation(projects.core.ui) implementation(projects.core.security) implementation(projects.core.item) @@ -38,7 +38,7 @@ dependencies { testImplementation(testFixtures(projects.core.security)) testImplementation(testFixtures(projects.core.item)) testImplementation(testFixtures(projects.core.util)) - testImplementation(testFixtures(projects.rust)) + testImplementation(testFixtures(projects.core.rust)) testImplementation(libs.robolectric) testImplementation(libs.androidx.junit) } diff --git a/feature/vault/build.gradle.kts b/feature/vault/build.gradle.kts index fc871907f..dc3e4bad1 100644 --- a/feature/vault/build.gradle.kts +++ b/feature/vault/build.gradle.kts @@ -11,11 +11,11 @@ dependencies { implementation(projects.core.ui) implementation(projects.core.util) implementation(projects.core.security) - implementation(projects.rust) + implementation(projects.core.rust) testImplementation(testFixtures(projects.core.util)) testImplementation(testFixtures(projects.core.item)) testImplementation(testFixtures(projects.core.security)) - testImplementation(testFixtures(projects.rust)) + testImplementation(testFixtures(projects.core.rust)) } diff --git a/feature/vault/src/test/kotlin/de/davis/keygo/feature/vault/domain/usecase/MoveItemsToVaultUseCaseTest.kt b/feature/vault/src/test/kotlin/de/davis/keygo/feature/vault/domain/usecase/MoveItemsToVaultUseCaseTest.kt index 0bb19296c..a86d3934f 100644 --- a/feature/vault/src/test/kotlin/de/davis/keygo/feature/vault/domain/usecase/MoveItemsToVaultUseCaseTest.kt +++ b/feature/vault/src/test/kotlin/de/davis/keygo/feature/vault/domain/usecase/MoveItemsToVaultUseCaseTest.kt @@ -34,7 +34,6 @@ import de.davis.keygo.feature.vault.domain.model.MoveItemsError import de.davis.keygo.feature.vault.domain.model.MoveItemsProgress import de.davis.keygo.rust.FakeItemManager import de.davis.keygo.rust.FakeKeyWrapper -import de.davis.keygo.rust.FakeVaultManager import de.davisalessandro.keygo.rust.ItemAad import de.davisalessandro.keygo.rust.KeyWrapException import kotlinx.coroutines.runBlocking @@ -55,14 +54,12 @@ class MoveItemsToVaultUseCaseTest { private val itemRepository = FakeItemRepository(loginRepository) private val itemManager = FakeItemManager() private val keyWrapper = FakeKeyWrapper() - private val vaultManager = FakeVaultManager() private val cryptographicScopeProvider: CryptographicScopeProvider = BindingCryptographicScopeProvider( session, itemRepository, itemManager, keyWrapper, - vaultManager ) private val vaultRepository = FakeVaultRepository() diff --git a/rust/.gitignore b/rust/.gitignore index 900aa1dca..f197bce0d 100644 --- a/rust/.gitignore +++ b/rust/.gitignore @@ -1,5 +1,3 @@ -/build - ### Rust template # Generated by Cargo # will have compiled files and executables diff --git a/rust/rust-code/Cargo.lock b/rust/Cargo.lock similarity index 72% rename from rust/rust-code/Cargo.lock rename to rust/Cargo.lock index 5535e3147..06737697a 100644 --- a/rust/rust-code/Cargo.lock +++ b/rust/Cargo.lock @@ -8,19 +8,19 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1973cfbc1a2daf9cf550e74e1f088c28e7f7d8c1e1418fb6c9dc5184b7e84c99" dependencies = [ - "crypto-common 0.2.2", + "crypto-common", "inout", ] [[package]] name = "aes" -version = "0.9.2" +version = "0.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8eb277bec05f56a0e0591f155a484cbd0f4f07ff2905051a48c72f004f7ed58" +checksum = "35f0f96ce78e38c3dc6d8948aa8163d06385be74000f3c7a95bf1eef35d3ea32" dependencies = [ "cipher", "cpubits", - "cpufeatures 0.3.0", + "cpufeatures", ] [[package]] @@ -37,15 +37,6 @@ dependencies = [ "polyval", ] -[[package]] -name = "aho-corasick" -version = "1.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" -dependencies = [ - "memchr", -] - [[package]] name = "anstyle" version = "1.0.14" @@ -60,21 +51,21 @@ checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" [[package]] name = "argon2" -version = "0.5.3" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +checksum = "134c52ddac6d63c576bef8168db10c83c49c26444ecbc68060fef078925a901c" dependencies = [ "base64ct", "blake2", - "cpufeatures 0.2.17", + "cpufeatures", "password-hash", ] [[package]] name = "askama" -version = "0.16.0" +version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1bf825125edd887a019d0a3a837dcc5499a68b0d034cc3eb594070c3e18addc" +checksum = "6024d73179f43f15ccd2b881bfea6fee7f3a46ec53f33b52210dea749ebebaa4" dependencies = [ "askama_macros", "itoa", @@ -85,9 +76,9 @@ dependencies = [ [[package]] name = "askama_derive" -version = "0.16.0" +version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1c7065972a130eafa84215f21352ae15b4a7393da48c1f5e103904490736738" +checksum = "071ee5ebf2138e3ad180e0aacf6940c2cab5e6d8333741d9925c7bee2b153f39" dependencies = [ "askama_parser", "basic-toml", @@ -98,23 +89,23 @@ dependencies = [ "rustc-hash", "serde", "serde_derive", - "syn 2.0.119", + "syn 3.0.7", ] [[package]] name = "askama_macros" -version = "0.16.0" +version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e23b1d2c4bd39a41971f6124cef4cc6fd0540913ecb90919b69ab3bbe44ae1a" +checksum = "643e1c7cbb6aec1d920332fe51a7c0d8219e273dcb8602db03f5263e4d16487b" dependencies = [ "askama_derive", ] [[package]] name = "askama_parser" -version = "0.16.0" +version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7db09fde9143e7ac4513358fb32ee32847125b63b18ea715afd487956da715da" +checksum = "2c5ae75772275d268b03ab8bdccdd12117b6169ee23256942b34e46c9f476583" dependencies = [ "rustc-hash", "serde", @@ -125,9 +116,9 @@ dependencies = [ [[package]] name = "async-compat" -version = "0.2.5" +version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1ba85bc55464dcbf728b56d97e119d673f4cf9062be330a9a26f3acf504a590" +checksum = "4c97d7ff3c25d6c10d64170c12acaf5d4245e76dece3779c1d92b153a64f11df" dependencies = [ "futures-core", "futures-io", @@ -144,7 +135,7 @@ checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" dependencies = [ "proc-macro2", "quote", - "syn 3.0.4", + "syn 3.0.7", ] [[package]] @@ -155,9 +146,9 @@ checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "base16ct" -version = "0.2.0" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" +checksum = "fd307490d624467aa6f74b0eabb77633d1f758a7b25f12bceb0b22e08d9726f6" [[package]] name = "base32" @@ -197,26 +188,17 @@ dependencies = [ [[package]] name = "bitflags" -version = "2.13.1" +version = "2.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" [[package]] name = "blake2" -version = "0.10.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" -dependencies = [ - "digest 0.10.7", -] - -[[package]] -name = "block-buffer" -version = "0.10.4" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +checksum = "5b5d4d889834ee8ecfc0f8426ad30faf7cdcb10f741a8e6d7224d95325479f6f" dependencies = [ - "generic-array", + "digest", ] [[package]] @@ -242,9 +224,9 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "camino" -version = "1.2.5" +version = "1.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb1307f12aa967b5a58416e87b3653360e0fd614a016b6e970db08fecbb1b80d" +checksum = "bbbad30e4b4c14a39e3cc8aed085a12a327257c316619c93581e017bc52be591" dependencies = [ "serde_core", ] @@ -275,19 +257,19 @@ dependencies = [ [[package]] name = "cfg-if" -version = "1.0.4" +version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" [[package]] name = "chacha20" -version = "0.10.1" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" dependencies = [ "cfg-if", - "cpufeatures 0.3.0", - "rand_core 0.10.1", + "cpufeatures", + "rand_core", ] [[package]] @@ -323,16 +305,16 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c" dependencies = [ - "block-buffer 0.12.1", - "crypto-common 0.2.2", + "block-buffer", + "crypto-common", "inout", ] [[package]] name = "clap" -version = "4.6.6" +version = "4.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca" +checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946" dependencies = [ "clap_builder", "clap_derive", @@ -340,9 +322,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.6.6" +version = "4.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889" +checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d" dependencies = [ "anstyle", "clap_lex", @@ -351,21 +333,21 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.6.4" +version = "4.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" +checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0" dependencies = [ "heck", "proc-macro2", "quote", - "syn 3.0.4", + "syn 3.0.7", ] [[package]] name = "clap_lex" -version = "1.1.0" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" +checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486" [[package]] name = "cmov" @@ -373,12 +355,6 @@ version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" -[[package]] -name = "const-oid" -version = "0.9.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" - [[package]] name = "const-oid" version = "0.10.2" @@ -409,18 +385,9 @@ checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae" [[package]] name = "cpufeatures" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" -dependencies = [ - "libc", -] - -[[package]] -name = "cpufeatures" -version = "0.3.0" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" dependencies = [ "libc", ] @@ -433,35 +400,29 @@ checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" [[package]] name = "crypto-bigint" -version = "0.5.5" +version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +checksum = "1a52aa3fcda4e6302a9f48734f234d35d4721b96f8fe07d073f07ce9df4f0271" dependencies = [ - "generic-array", - "rand_core 0.6.4", + "cpubits", + "ctutils", + "getrandom", + "hybrid-array", + "num-traits", + "rand_core", "subtle", "zeroize", ] -[[package]] -name = "crypto-common" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" -dependencies = [ - "generic-array", - "typenum", -] - [[package]] name = "crypto-common" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" dependencies = [ - "getrandom 0.4.3", + "getrandom", "hybrid-array", - "rand_core 0.10.1", + "rand_core", ] [[package]] @@ -496,11 +457,12 @@ dependencies = [ [[package]] name = "ctutils" -version = "0.4.2" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +checksum = "03bb0e1cc970d482d121d9a1744999169b69a07470b3d644a7894e53fcaf4574" dependencies = [ "cmov", + "subtle", ] [[package]] @@ -510,11 +472,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5eed333089e2e1c1ac8c6c0398e5e2497b4c9926ca6d0365ed1e099afa5bc23" dependencies = [ "cfg-if", - "cpufeatures 0.3.0", + "cpufeatures", "curve25519-dalek-derive", - "digest 0.11.3", + "digest", "fiat-crypto", - "rand_core 0.10.1", + "rand_core", "rustc_version", "subtle", "zeroize", @@ -539,42 +501,24 @@ checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" [[package]] name = "der" -version = "0.7.10" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +checksum = "a878c850e9e421b20262e9b41f9c860e4785fa07541c266b62ff9d1ef998a80a" dependencies = [ - "const-oid 0.9.6", + "const-oid", "pem-rfc7468", "zeroize", ] -[[package]] -name = "difflib" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" - -[[package]] -name = "digest" -version = "0.10.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" -dependencies = [ - "block-buffer 0.10.4", - "const-oid 0.9.6", - "crypto-common 0.1.6", - "subtle", -] - [[package]] name = "digest" version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ - "block-buffer 0.12.1", - "const-oid 0.10.2", - "crypto-common 0.2.2", + "block-buffer", + "const-oid", + "crypto-common", "ctutils", ] @@ -586,27 +530,22 @@ checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" dependencies = [ "proc-macro2", "quote", - "syn 3.0.4", + "syn 3.0.7", ] -[[package]] -name = "downcast" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1435fa1053d8b2fbbe9be7e97eca7f33d37b28409959813daefc1446a14247f1" - [[package]] name = "ecdsa" -version = "0.16.9" +version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +checksum = "c0681a4fc24c767085329728d8dfba959af91228aa4610cca4f8ce317ba46ae0" dependencies = [ "der", - "digest 0.10.7", + "digest", "elliptic-curve", "rfc6979", - "signature 2.2.0", + "signature", "spki", + "zeroize", ] [[package]] @@ -615,7 +554,8 @@ version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29fcf32e6c73d1079f83ab4d782de2d81620346a5f38c6237a86a22f8368980a" dependencies = [ - "signature 3.0.0", + "pkcs8", + "signature", ] [[package]] @@ -626,38 +566,37 @@ checksum = "6ebaa1a2bf1290ab3bfe5a7b771d050ebffab2711c19a81691c683a5144a25de" dependencies = [ "curve25519-dalek", "ed25519", - "rand_core 0.10.1", - "sha2 0.11.0", - "signature 3.0.0", + "rand_core", + "serde", + "sha2", + "signature", "subtle", "zeroize", ] [[package]] name = "either" -version = "1.18.0" +version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" +checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be" [[package]] name = "elliptic-curve" -version = "0.13.8" +version = "0.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +checksum = "9d65aa39b3a5c1c9c1b745c9a019234bb7a21b77abcb4f4d266d706e2d577d65" dependencies = [ "base16ct", - "base64ct", "crypto-bigint", - "digest 0.10.7", + "crypto-common", + "digest", "ff", - "generic-array", "group", + "hybrid-array", "pem-rfc7468", "pkcs8", - "rand_core 0.6.4", + "rand_core", "sec1", - "serde_json", - "serdect", "subtle", "zeroize", ] @@ -695,11 +634,11 @@ checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" [[package]] name = "ff" -version = "0.13.1" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +checksum = "a1f686ab92a9fb0eaf188f6c6c87b89490baa6fdb0db4544ba4dc47f7942489f" dependencies = [ - "rand_core 0.6.4", + "rand_core", "subtle", ] @@ -709,15 +648,6 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24" -[[package]] -name = "float-cmp" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4" -dependencies = [ - "num-traits", -] - [[package]] name = "form_urlencoded" version = "1.2.2" @@ -727,20 +657,11 @@ dependencies = [ "percent-encoding", ] -[[package]] -name = "fragile" -version = "2.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8878864ba14bb86e818a412bfd6f18f9eabd4ec0f008a28e8f7eb61db532fcf9" -dependencies = [ - "futures-core", -] - [[package]] name = "fs-err" -version = "3.3.1" +version = "3.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b91aa448ca50d7e79433bdf3ee8d99215430d2ec02ade5aefab2a073a1822e8a" +checksum = "b5c95b673b8f6f7235229ae11c5642d81b04c2e64c1e2fb417bc0cf73ca45f29" dependencies = [ "autocfg", ] @@ -775,30 +696,6 @@ dependencies = [ "slab", ] -[[package]] -name = "generic-array" -version = "0.14.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2" -dependencies = [ - "typenum", - "version_check", - "zeroize", -] - -[[package]] -name = "getrandom" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" -dependencies = [ - "cfg-if", - "js-sys", - "libc", - "wasi", - "wasm-bindgen", -] - [[package]] name = "getrandom" version = "0.4.3" @@ -808,7 +705,7 @@ dependencies = [ "cfg-if", "libc", "r-efi", - "rand_core 0.10.1", + "rand_core", ] [[package]] @@ -830,12 +727,12 @@ dependencies = [ [[package]] name = "group" -version = "0.13.0" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +checksum = "7fd1a1c7a5206c5b7a3f5a0d7ccd3ff85d0c8f5133d62a02680255b0004af5f4" dependencies = [ "ff", - "rand_core 0.6.4", + "rand_core", "subtle", ] @@ -868,16 +765,7 @@ version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018" dependencies = [ - "hmac 0.13.0", -] - -[[package]] -name = "hmac" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" -dependencies = [ - "digest 0.10.7", + "hmac", ] [[package]] @@ -886,16 +774,19 @@ version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" dependencies = [ - "digest 0.11.3", + "digest", ] [[package]] name = "hybrid-array" -version = "0.4.14" +version = "0.4.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b" +checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" dependencies = [ + "ctutils", + "subtle", "typenum", + "zeroize", ] [[package]] @@ -1004,9 +895,9 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.14.0" +version = "2.14.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" dependencies = [ "equivalent", "hashbrown", @@ -1023,15 +914,6 @@ dependencies = [ "hybrid-array", ] -[[package]] -name = "itertools" -version = "0.10.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" -dependencies = [ - "either", -] - [[package]] name = "itertools" version = "0.14.0" @@ -1049,9 +931,9 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "js-sys" -version = "0.3.104" +version = "0.3.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" dependencies = [ "cfg-if", "futures-util", @@ -1059,15 +941,13 @@ dependencies = [ ] [[package]] -name = "keygo-bindings" -version = "0.1.0" +name = "keccak" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8f198d1db720e4940b5a493201d199d9f24f568f8f746bd13706243a2f71598" dependencies = [ - "keygo-core", - "thiserror", - "tokio", - "uniffi", - "uuid", - "zeroize", + "cfg-if", + "cpufeatures", ] [[package]] @@ -1078,24 +958,22 @@ dependencies = [ "aes-gcm-siv", "argon2", "async-trait", - "base32", "base64", "bcs", "ciborium", "coset", "csv", - "ed25519-dalek", "email_address", "hkdf", "passkey", "passkey-authenticator", "passkey-types", - "rand 0.10.2", "serde", "serde_json", - "sha2 0.11.0", + "sha2", "subtle", "thiserror", + "tokio", "totp-rs", "url", "uuid", @@ -1103,16 +981,21 @@ dependencies = [ ] [[package]] -name = "lazy_static" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +name = "keygo-uniffi" +version = "0.1.0" +dependencies = [ + "keygo-core", + "thiserror", + "tokio", + "uniffi", + "uuid", +] [[package]] name = "libc" -version = "0.2.189" +version = "0.2.190" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" +checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78" [[package]] name = "linux-raw-sys" @@ -1145,30 +1028,30 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" [[package]] -name = "mockall" -version = "0.11.4" +name = "ml-dsa" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c84490118f2ee2d74570d114f3d0493cbf02790df303d2707606c3e14e07c96" +checksum = "add6b9d92e496f16f4526d68ff29da1483aba4b119baeab8bed3b9e3544a6f3d" dependencies = [ - "cfg-if", - "downcast", - "fragile", - "lazy_static", - "mockall_derive", - "predicates", - "predicates-tree", + "const-oid", + "crypto-common", + "ctutils", + "hybrid-array", + "module-lattice", + "pkcs8", + "shake", + "signature", ] [[package]] -name = "mockall_derive" -version = "0.11.4" +name = "module-lattice" +version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22ce75669015c4f47b289fd4d4f56e894e4c96003ffdf3ac51313126f94c6cbb" +checksum = "0c61b87c9683ab7cb1c6871d261ad5479b6b10ceb52c4352aaca3b5d35a8febe" dependencies = [ - "cfg-if", - "proc-macro2", - "quote", - "syn 1.0.109", + "ctutils", + "hybrid-array", + "num-traits", ] [[package]] @@ -1181,12 +1064,6 @@ dependencies = [ "minimal-lexical", ] -[[package]] -name = "normalize-line-endings" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61807f77802ff30975e01f4f071c8ba10c022052f98b3294119f3e615d13e5be" - [[package]] name = "num-traits" version = "0.2.19" @@ -1204,55 +1081,55 @@ checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] name = "p256" -version = "0.13.2" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +checksum = "d2c9239b2dbc807adbbe147e8cf72ea7450c3a0aabe62cb8e75ff4ec22e1f72a" dependencies = [ "ecdsa", "elliptic-curve", + "primefield", "primeorder", - "sha2 0.10.9", + "sha2", ] [[package]] name = "passkey" -version = "0.5.0" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e47eb8971740eff7e395beb540a88a0fc302c205b433ff437f2c4759cba0c5ec" +checksum = "91be8b7aaa8f305eab1fae1b17d05f986c07497210edd303e61aa95121374f65" dependencies = [ "passkey-authenticator", "passkey-client", + "passkey-crypto", "passkey-transports", "passkey-types", ] [[package]] name = "passkey-authenticator" -version = "0.5.0" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20bf5800e3f6287580da985fb88e678f37c078d62242cb536941c44d852ab37c" +checksum = "5fb7e1cb79a932ac3a5ceb0fa73b25a6028ef1bd69a007441e6cce9ecf2e6e0f" dependencies = [ "async-trait", - "coset", + "ciborium", "log", - "mockall", - "p256", + "passkey-crypto", "passkey-types", - "rand 0.8.8", "tokio", ] [[package]] name = "passkey-client" -version = "0.5.0" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d99509cbbdfe526c617e8265716fba277562a7d771fef047ac9e25686d1c502" +checksum = "96a005ba82014459c51985f7e6f4b606a85ea5e8d1c9bbd6bc053e2b2b0c7f4f" dependencies = [ "ciborium", - "coset", "idna", - "itertools 0.14.0", + "itertools", "passkey-authenticator", + "passkey-crypto", "passkey-types", "public-suffix", "serde", @@ -1260,30 +1137,44 @@ dependencies = [ "url", ] +[[package]] +name = "passkey-crypto" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5108c58974bacdae414ee66edf0c009818509ccf6be4a911032147336e3da27" +dependencies = [ + "coset", + "ed25519-dalek", + "getrandom", + "hmac", + "log", + "ml-dsa", + "p256", + "rand", + "sha2", + "signature", + "zeroize", +] + [[package]] name = "passkey-transports" -version = "0.1.0" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e04110fadc73577990f04153a2508989242d9c90a645aeaad98ef30e0decfbb" +checksum = "da1d2254d95148c50740caacfea0d7580ad6fb048c8204566a6d233261931228" [[package]] name = "passkey-types" -version = "0.5.0" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2a1fae06bd6bc619675d30b50cb38ca649837cc9e6a92a00b14743cfbdb1390" +checksum = "f4ed76f185c29d0d0c226ecf653531bb962e63eff3bf6d5cd5062288116138d2" dependencies = [ "bitflags", "ciborium", - "coset", "data-encoding", - "getrandom 0.2.17", - "hmac 0.12.1", "indexmap", - "p256", - "rand 0.8.8", + "passkey-crypto", "serde", "serde_json", - "sha2 0.10.9", "strum", "url", "zeroize", @@ -1291,20 +1182,19 @@ dependencies = [ [[package]] name = "password-hash" -version = "0.5.0" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +checksum = "aab41826031698d6ffcd9cff78ef56ef998e39dc7e5067cdfebe373842d4723b" dependencies = [ - "base64ct", - "rand_core 0.6.4", - "subtle", + "getrandom", + "phc", ] [[package]] name = "pem-rfc7468" -version = "0.7.0" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +checksum = "a6305423e0e7738146434843d1694d621cce767262b2a86910beab705e4493d9" dependencies = [ "base64ct", ] @@ -1315,6 +1205,17 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "phc" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44dc769b75f93afdddd8c7fa12d685292ddeff1e66f7f0f3a234cf1818afe892" +dependencies = [ + "base64ct", + "ctutils", + "getrandom", +] + [[package]] name = "pin-project-lite" version = "0.2.17" @@ -1323,9 +1224,9 @@ checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] name = "pkcs8" -version = "0.10.2" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +checksum = "451913da69c775a56034ea8d9003d27ee8948e12443eae7c038ba100a4f21cb7" dependencies = [ "der", "spki", @@ -1344,7 +1245,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0fa31d631f2b2cb2a544d0aa321ce847a94764d701ca2becc411138b93d49cd" dependencies = [ "cpubits", - "cpufeatures 0.3.0", + "cpufeatures", "universal-hash", ] @@ -1358,51 +1259,30 @@ dependencies = [ ] [[package]] -name = "ppv-lite86" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" -dependencies = [ - "zerocopy", -] - -[[package]] -name = "predicates" -version = "2.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59230a63c37f3e18569bdb90e4a89cbf5bf8b06fea0b84e65ea10cc4df47addd" -dependencies = [ - "difflib", - "float-cmp", - "itertools 0.10.5", - "normalize-line-endings", - "predicates-core", - "regex", -] - -[[package]] -name = "predicates-core" -version = "1.0.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cad38746f3166b4031b1a0d39ad9f954dd291e7854fcc0eed52ee41a0b50d144" - -[[package]] -name = "predicates-tree" -version = "1.0.13" +name = "primefield" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0de1b847b39c8131db0467e9df1ff60e6d0562ab8e9a16e568ad0fdb372e2f2" +checksum = "c555a6e4eb7d4e158fcb028c835c3b8642206ddc279b5c6b202ef9a8bdb592f4" dependencies = [ - "predicates-core", - "termtree", + "crypto-bigint", + "crypto-common", + "ff", + "rand_core", + "subtle", + "zeroize", ] [[package]] name = "primeorder" -version = "0.13.6" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +checksum = "5c9f42978c78a00e3d68f69fc03e57a234debae69da4020a4fb588fcdcd07b06" dependencies = [ "elliptic-curve", + "once_cell", + "primefield", + "serdect", + "wnaf", ] [[package]] @@ -1437,43 +1317,13 @@ checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" [[package]] name = "rand" -version = "0.8.8" +version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" -dependencies = [ - "libc", - "rand_chacha", - "rand_core 0.6.4", -] - -[[package]] -name = "rand" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" dependencies = [ "chacha20", - "getrandom 0.4.3", - "rand_core 0.10.1", -] - -[[package]] -name = "rand_chacha" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" -dependencies = [ - "ppv-lite86", - "rand_core 0.6.4", -] - -[[package]] -name = "rand_core" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" -dependencies = [ - "getrandom 0.2.17", + "getrandom", + "rand_core", ] [[package]] @@ -1482,43 +1332,14 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" -[[package]] -name = "regex" -version = "1.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" -dependencies = [ - "aho-corasick", - "memchr", - "regex-automata", - "regex-syntax", -] - -[[package]] -name = "regex-automata" -version = "0.4.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" -dependencies = [ - "aho-corasick", - "memchr", - "regex-syntax", -] - -[[package]] -name = "regex-syntax" -version = "0.8.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" - [[package]] name = "rfc6979" -version = "0.4.0" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +checksum = "b4a459cddafb3fe76b31fd8f1108007566c40301feb64dc7b54656eb7388172b" dependencies = [ - "hmac 0.12.1", - "subtle", + "crypto-bigint", + "hmac", ] [[package]] @@ -1538,9 +1359,9 @@ dependencies = [ [[package]] name = "rustix" -version = "1.1.4" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" dependencies = [ "bitflags", "errno", @@ -1583,15 +1404,14 @@ dependencies = [ [[package]] name = "sec1" -version = "0.7.3" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +checksum = "d56d437c2f19203ce5f7122e507831de96f3d2d4d3be5af44a0b0a09d8a80e4d" dependencies = [ "base16ct", + "ctutils", "der", - "generic-array", - "pkcs8", - "serdect", + "hybrid-array", "subtle", "zeroize", ] @@ -1633,7 +1453,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 3.0.4", + "syn 3.0.7", ] [[package]] @@ -1652,18 +1472,18 @@ dependencies = [ [[package]] name = "serde_spanned" -version = "1.1.1" +version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +checksum = "4e7523beb55eece201a2356bee0bbca0d1ab466c14c07703b2e0ee6d42cb0c2c" dependencies = [ "serde_core", ] [[package]] name = "serdect" -version = "0.2.0" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a84f14a19e9a014bb9f4512488d9829a68e04ecabffb0f9904cd1ace94598177" +checksum = "66cf8fedced2fcf12406bcb34223dffb92eaf34908ede12fed414c82b7f00b3e" dependencies = [ "base16ct", "serde", @@ -1676,19 +1496,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" dependencies = [ "cfg-if", - "cpufeatures 0.3.0", - "digest 0.11.3", -] - -[[package]] -name = "sha2" -version = "0.10.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" -dependencies = [ - "cfg-if", - "cpufeatures 0.2.17", - "digest 0.10.7", + "cpufeatures", + "digest", ] [[package]] @@ -1698,18 +1507,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" dependencies = [ "cfg-if", - "cpufeatures 0.3.0", - "digest 0.11.3", + "cpufeatures", + "digest", ] [[package]] -name = "signature" -version = "2.2.0" +name = "shake" +version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +checksum = "09057cb2149ad4cbd2da1e26b351f9a4c354219421229c69c3063e6f61947c4a" dependencies = [ - "digest 0.10.7", - "rand_core 0.6.4", + "digest", + "keccak", + "sponge-cursor", ] [[package]] @@ -1718,14 +1528,15 @@ version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28d567dcbaf0049cb8ac2608a76cd95ff9e4412e1899d389ee400918ca7537f5" dependencies = [ - "rand_core 0.10.1", + "digest", + "rand_core", ] [[package]] name = "siphasher" -version = "1.0.3" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" +checksum = "33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256" [[package]] name = "slab" @@ -1735,9 +1546,9 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" -version = "1.15.2" +version = "1.16.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" +checksum = "5b3dc8af474f516a851ff4bd12db780f948b9250ad37211e4eec0bccea54e01b" [[package]] name = "smawk" @@ -1747,14 +1558,20 @@ checksum = "e8e2fb0f499abb4d162f2bedad68f5ef91a1682b5a03596ddb67efd37768d100" [[package]] name = "spki" -version = "0.7.3" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +checksum = "7ef958a98b9d5da290cfc78946e9f3e61e1e62a18db0d92cac0b83cc161491a9" dependencies = [ "base64ct", "der", ] +[[package]] +name = "sponge-cursor" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a0219bd7d979d58245a4f41f695e1ac9f8befdffadd7f61f1bae9e39abc6620" + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -1800,17 +1617,6 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" -[[package]] -name = "syn" -version = "1.0.109" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - [[package]] name = "syn" version = "2.0.119" @@ -1824,9 +1630,9 @@ dependencies = [ [[package]] name = "syn" -version = "3.0.4" +version = "3.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f" +checksum = "d62a2e0561533f2ca2561d0cf27fd9fedb640a1bf2616ff5d5c80d99017faadc" dependencies = [ "proc-macro2", "quote", @@ -1835,13 +1641,13 @@ dependencies = [ [[package]] name = "synstructure" -version = "0.13.2" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.7", ] [[package]] @@ -1851,45 +1657,40 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.3", + "getrandom", "once_cell", "rustix", "windows-sys", ] -[[package]] -name = "termtree" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" - [[package]] name = "textwrap" -version = "0.16.2" +version = "0.16.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057" +checksum = "6ecfad6c3abc80a577f2b91c1e412ee57e7a060d430b553c1b0c940974ebcd49" dependencies = [ "smawk", + "unicode-width", ] [[package]] name = "thiserror" -version = "2.0.20" +version = "2.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "2.0.20" +version = "2.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" dependencies = [ "proc-macro2", "quote", - "syn 3.0.4", + "syn 3.0.7", ] [[package]] @@ -1904,18 +1705,30 @@ dependencies = [ [[package]] name = "tokio" -version = "1.53.1" +version = "1.53.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +checksum = "e95f91fcc7a621e8b030f6aa23c71fe9838ae2fb4d8118b75602a328f5144044" dependencies = [ "pin-project-lite", + "tokio-macros", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.7", ] [[package]] name = "toml" -version = "1.1.4+spec-1.1.0" +version = "1.1.8+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3aace63f4bbcdfc2c965b059de67119c89c4017a70d633be6c104910f67056f5" +checksum = "20489e00e4d8741d6be680764cc12e270655e375a20d1011e844a9c3379e678d" dependencies = [ "indexmap", "serde_core", @@ -1928,27 +1741,27 @@ dependencies = [ [[package]] name = "toml_datetime" -version = "1.1.1+spec-1.1.0" +version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +checksum = "2b86d767906c6c42421dcba507eb9d203e779497710a47782a224bb871653053" dependencies = [ "serde_core", ] [[package]] name = "toml_parser" -version = "1.1.3+spec-1.1.0" +version = "1.1.5+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c" dependencies = [ "winnow", ] [[package]] name = "toml_writer" -version = "1.1.2+spec-1.1.0" +version = "1.1.3+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" +checksum = "06bdbd8cfc056b8d2e2e85f29b56a3bdbecb527cef81eb39e3e7b98af4652770" [[package]] name = "totp-rs" @@ -1958,12 +1771,12 @@ checksum = "49d0f9b7aa5def86d3a8d8ba926fe8c1990924dd715d2b5fc54a4aa3788c11c1" dependencies = [ "base32", "constant_time_eq", - "hmac 0.13.0", + "hmac", "percent-encoding", - "rand 0.10.2", + "rand", "serde", "sha1", - "sha2 0.11.0", + "sha2", "url", "zeroize", ] @@ -1976,15 +1789,21 @@ checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" [[package]] name = "unicode-ident" -version = "1.0.24" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" + +[[package]] +name = "unicode-width" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" [[package]] name = "uniffi" -version = "0.32.0" +version = "0.32.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a782a48d72cfd7a2d65cfc7c691dbf5375c43104b3c195f7eccc716dcc3540c8" +checksum = "76407f5f396a2c949a069eff4a9fca9ce462410eb872bffef4c2b7b89804a77a" dependencies = [ "anyhow", "camino", @@ -1996,11 +1815,18 @@ dependencies = [ "uniffi_pipeline", ] +[[package]] +name = "uniffi-bindgen" +version = "0.1.0" +dependencies = [ + "uniffi", +] + [[package]] name = "uniffi_bindgen" -version = "0.32.0" +version = "0.32.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "533b0312c73e3b54eb78a4b257ceae390962dd4767995778309a74644643f9ac" +checksum = "3df3ced8f0eda3de99f6d50820e8628f443da8e4f447f477d1403ceba29dacbd" dependencies = [ "anyhow", "askama", @@ -2024,9 +1850,9 @@ dependencies = [ [[package]] name = "uniffi_core" -version = "0.32.0" +version = "0.32.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e32e261c5b0dfaba6488f536e71957dddd6b1a498ac7eb791bee56b60a086be" +checksum = "f7530ae8efeaaa488622865966763fe0294832779fff80508c36d69c6a874a6a" dependencies = [ "anyhow", "async-compat", @@ -2037,9 +1863,9 @@ dependencies = [ [[package]] name = "uniffi_internal_macros" -version = "0.32.0" +version = "0.32.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84ae78069a5e6772ef694fd5bdb628532c88d2c2f0e7142bf6a384636eadb1af" +checksum = "0f4bad017164375d99450d70495014810d15a84ba9da6ed14b9628b1424223ba" dependencies = [ "anyhow", "indexmap", @@ -2050,9 +1876,9 @@ dependencies = [ [[package]] name = "uniffi_macros" -version = "0.32.0" +version = "0.32.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "330be6770532e86320df31f54c70bb0be67588594e8e77fa56e9083a3fed5d0d" +checksum = "5b855a58da153739ce6e863f6e296edc3716a093768c4d3d957f17f5e4317c60" dependencies = [ "camino", "fs-err", @@ -2067,9 +1893,9 @@ dependencies = [ [[package]] name = "uniffi_meta" -version = "0.32.0" +version = "0.32.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78de021f5547e56ab16c665a49d67d4fd3d31e77422f7739a2e9359d328cd9e7" +checksum = "af0c88664a9cb9559856f5a250283c9708923b303170e2e5c7db8f4ad65e9459" dependencies = [ "anyhow", "siphasher", @@ -2079,9 +1905,9 @@ dependencies = [ [[package]] name = "uniffi_pipeline" -version = "0.32.0" +version = "0.32.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f8201bb1907ed8a42d80e11cbc25c8a033e7a31c3cff1d911f56eedb81d4948" +checksum = "d00f5c044b4a3dca0b1226c92ddaeea626f337470e22180dd182160c87a06b0b" dependencies = [ "anyhow", "heck", @@ -2092,9 +1918,9 @@ dependencies = [ [[package]] name = "uniffi_udl" -version = "0.32.0" +version = "0.32.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6e57996bc58009cc29bf04845d627ae313c2547b87171c1c349d6c51a1656c0" +checksum = "55dfd9c778d6b39cb5acd541d9ff63431974035d2bad83c897b766a6ee3152ac" dependencies = [ "anyhow", "textwrap", @@ -2108,7 +1934,7 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f4987bdc12753382e0bec4a65c50738ffaabc998b9cdd1f952fb5f39b0048a96" dependencies = [ - "crypto-common 0.2.2", + "crypto-common", "ctutils", ] @@ -2133,33 +1959,21 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" [[package]] name = "uuid" -version = "1.25.0" +version = "1.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f053576934f05a761a402421fbbe3d425d9366f75f978806a037b3ca481abecc" +checksum = "7cc1186384beb7dd8eedea376413fd654937285ea6c9cfbb928dc3043ea4b606" dependencies = [ - "getrandom 0.4.3", + "getrandom", "js-sys", "serde_core", "wasm-bindgen", ] -[[package]] -name = "version_check" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" - -[[package]] -name = "wasi" -version = "0.11.1+wasi-snapshot-preview1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" - [[package]] name = "wasm-bindgen" -version = "0.2.127" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" dependencies = [ "cfg-if", "once_cell", @@ -2170,9 +1984,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.127" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -2180,22 +1994,22 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.127" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.7", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.127" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" dependencies = [ "unicode-ident", ] @@ -2233,6 +2047,18 @@ dependencies = [ "memchr", ] +[[package]] +name = "wnaf" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "795ca18b3fdb5e62bf982199278341ddcf7ebf7d32e25e212ad05d496e95f6fa" +dependencies = [ + "ff", + "group", + "hybrid-array", + "primefield", +] + [[package]] name = "writeable" version = "0.6.4" @@ -2252,30 +2078,30 @@ dependencies = [ [[package]] name = "yoke-derive" -version = "0.8.2" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.7", "synstructure", ] [[package]] name = "zerocopy" -version = "0.8.56" +version = "0.8.62" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" +checksum = "86502bf56ac7c77571a32e2647bb2a15894565e981fb2a48d7bde2d91c965a9d" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.56" +version = "0.8.62" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" +checksum = "5457206954b06561e2608c7e19cf58b1926586d999c246eebe4502f7e2039d1a" dependencies = [ "proc-macro2", "quote", @@ -2293,21 +2119,21 @@ dependencies = [ [[package]] name = "zerofrom-derive" -version = "0.1.7" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.7", "synstructure", ] [[package]] name = "zeroize" -version = "1.9.0" +version = "1.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879" dependencies = [ "zeroize_derive", ] @@ -2353,7 +2179,7 @@ checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" dependencies = [ "proc-macro2", "quote", - "syn 3.0.4", + "syn 3.0.7", ] [[package]] diff --git a/rust/rust-code/Cargo.toml b/rust/Cargo.toml similarity index 60% rename from rust/rust-code/Cargo.toml rename to rust/Cargo.toml index ea28c111f..49a59cffe 100644 --- a/rust/rust-code/Cargo.toml +++ b/rust/Cargo.toml @@ -1,7 +1,7 @@ [workspace] members = [ - "core", - "bindings", + "crates/*", + "tools/*", ] resolver = "3" @@ -11,4 +11,11 @@ edition = "2024" [workspace.dependencies] thiserror = "2.0.18" +tokio = "1.48.0" uuid = { version = "1.23.1", features = ["serde", "v4"] } +uniffi = "0.32.0" +zeroize = "1.8.2" + +[profile.release] +lto = true +codegen-units = 1 diff --git a/rust/rust-code/Makefile b/rust/Makefile similarity index 83% rename from rust/rust-code/Makefile rename to rust/Makefile index c8d29fed2..d5c2ee732 100644 --- a/rust/rust-code/Makefile +++ b/rust/Makefile @@ -3,10 +3,10 @@ ABIS ?= arm64-v8a armeabi-v7a x86_64 MIN_SDK ?= 26 PROFILE ?= release -CRATE := keygo-bindings +CRATE := keygo-uniffi LIB_NAME := libkeygo_bindings.so -JNI_DIR := ../build/generated/source/uniffi/jniLibs -KOTLIN_OUT := ../build/generated/source/uniffi/kotlin/ +JNI_DIR ?= ../core/rust/build/generated/source/uniffi/jniLibs +KOTLIN_OUT ?= ../core/rust/build/generated/source/uniffi/kotlin/ BINDGEN_ABI := arm64-v8a BINDGEN_TRIPLE := aarch64-linux-android @@ -32,6 +32,8 @@ help: ## Show this help # MIN_SDK ?= 26 Android API level for the NDK linker # PROFILE ?= release Cargo profile (release|debug) # NDK ?= $$ANDROID_NDK_HOME Path to the Android NDK +# JNI_DIR ?= ../core/rust/build/... Output dir for the per-ABI cdylibs +# KOTLIN_OUT ?= ../core/rust/build/... Output dir for the generated Kotlin all: build bindgen ## Build cdylibs and generate Kotlin bindings @@ -43,11 +45,11 @@ tools: ## Install cargo-ndk and missing rustup targets (idempotent) echo "$$installed" | grep -qx "$$t" || rustup target add "$$t"; \ done -build: tools ## Build cdylibs per ABI into ../src/main/jniLibs +build: tools ## Build cdylibs per ABI into JNI_DIR cargo ndk $(CARGO_NDK_FLAGS) -o $(JNI_DIR) build --$(PROFILE) -p $(CRATE) bindgen: build ## Generate Kotlin UniFFI bindings - cargo run -p $(CRATE) --bin uniffi-bindgen -- generate \ + cargo run -p uniffi-bindgen -- generate \ --library target/$(BINDGEN_TRIPLE)/$(PROFILE)/$(LIB_NAME) \ --language kotlin \ --no-format \ diff --git a/rust/rust-code/core/Cargo.toml b/rust/crates/keygo-core/Cargo.toml similarity index 64% rename from rust/rust-code/core/Cargo.toml rename to rust/crates/keygo-core/Cargo.toml index 3e1656875..22e4981a7 100644 --- a/rust/rust-code/core/Cargo.toml +++ b/rust/crates/keygo-core/Cargo.toml @@ -6,30 +6,30 @@ edition.workspace = true [dependencies] thiserror.workspace = true uuid.workspace = true +zeroize.workspace = true aead = "0.6.0" aes-gcm-siv = "0.12.1" async-trait = "0.1.89" ciborium = "0.2.2" coset = "0.4.2" -ed25519-dalek = { version = "3.0.0", features = ["rand_core"] } -passkey = "0.5.0" -passkey-authenticator = { version = "0.5.0", features = ["tokio", "testable"] } +passkey = "0.6.0" +passkey-authenticator = { version = "0.6.0", features = ["tokio"] } # Needed so passkey JSON responses are serialized into base64 strings -passkey-types = { version = "0.5.0", features = ["serialize_bytes_as_base64_string"] } +passkey-types = { version = "0.6.0", features = ["serialize_bytes_as_base64_string"] } -argon2 = "0.5.3" -base32 = "0.5.1" +argon2 = "0.6.0" base64 = "0.23.1" bcs = "0.2.0" csv = "1.4.0" email_address = "0.2.9" hkdf = "0.13.0" -rand = { version = "0.10.0", features = ["sys_rng"] } serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.149" sha2 = "0.11.0" subtle = "2.6" totp-rs = { version = "6.0.0", features = ["otpauth", "zeroize"] } url = "2.5.8" -zeroize = "1.8.2" + +[dev-dependencies] +tokio = { workspace = true, features = ["rt", "macros"] } diff --git a/rust/rust-code/core/src/ark_session.rs b/rust/crates/keygo-core/src/ark_session.rs similarity index 52% rename from rust/rust-code/core/src/ark_session.rs rename to rust/crates/keygo-core/src/ark_session.rs index 48463b307..ed0a40605 100644 --- a/rust/rust-code/core/src/ark_session.rs +++ b/rust/crates/keygo-core/src/ark_session.rs @@ -1,10 +1,9 @@ -use crate::crypto::error::CryptoError; -use crate::crypto::primitive::argon2::MIN_SALT_LEN; -use crate::crypto::primitive::wrap_key::{AeadWrappedKey, KeyWrapper}; -use crate::crypto::random::random_bytes; -use crate::crypto::types::{UserId, VaultId}; -use crate::crypto::{AccountRootKey, KeyMaterial, RootKEK, TryDeriveFrom, VaultKey}; -use std::sync::Mutex; +use crate::crypto::kdf::new_salt; +use crate::crypto::{ + AccountRootKey, CryptoError, Encrypts, NamespaceDataAad, NamespaceId, NamespaceKey, + NamespaceKeyAad, RootKEK, Sealed, SecretKind, SymmetricKey, UserId, VaultId, VaultKey, Wraps, +}; +use std::sync::{Mutex, MutexGuard}; use subtle::ConstantTimeEq; use zeroize::Zeroizing; @@ -15,55 +14,51 @@ pub enum ArkSessionError { #[error("Wrong password")] WrongPassword, #[error("Key derivation failed: {0}")] - Derivation(String), + Derivation(#[source] CryptoError), #[error("{0}")] KeyWrap(#[from] CryptoError), } -pub struct NewAccount { - pub user_id: UserId, +type ArkSessionResult = Result; + +pub struct PasswordWrappedArk { pub salt: Vec, - pub password_wrapped_ark: AeadWrappedKey, - pub vault_id: VaultId, - pub wrapped_vault_key: AeadWrappedKey, + pub wrapped: Sealed, } -pub struct PasswordWrapped { - pub salt: Vec, - pub wrapped: AeadWrappedKey, +impl PasswordWrappedArk { + /// Any unwrap failure is `WrongPassword`: AEAD cannot tell the causes apart. + fn open(&self, password: &str, user_id: UserId) -> ArkSessionResult { + derive_kek(password, &self.salt)? + .unwrap_key(&self.wrapped, &user_id) + .map_err(|e| match e { + CryptoError::KeyUnwrapFailed => ArkSessionError::WrongPassword, + other => other.into(), + }) + } } -type ArkSessionResult = Result; +pub struct NewAccount { + pub user_id: UserId, + pub password_wrapped_ark: PasswordWrappedArk, + pub vault_id: VaultId, + pub wrapped_vault_key: Sealed, +} -const PASSWORD_DOMAIN: &[u8] = b"v1:kek/pwd"; -const SALT_LEN: usize = MIN_SALT_LEN; +/// One account-wide record and the key that seals it, wrapped under the ARK. +pub struct AccountRecord { + pub wrapped_key: Sealed, + pub data: Sealed<[u8]>, +} +#[derive(Default)] pub struct ArkSession { ark: Mutex>, } -impl Default for ArkSession { - fn default() -> Self { - Self::new() - } -} - impl ArkSession { pub fn new() -> Self { - Self { - ark: Mutex::new(None), - } - } - - fn unlock( - &self, - kek: RootKEK, - wrapped_key: AeadWrappedKey, - aad: UserId, - ) -> ArkSessionResult<()> { - let ark = kek.unwrap_key(&wrapped_key, &aad)?; - *self.lock() = Some(ark); - Ok(()) + Self::default() } pub fn end(&self) { @@ -74,47 +69,23 @@ impl ArkSession { self.lock().is_some() } - fn lock(&self) -> std::sync::MutexGuard<'_, Option> { - self.ark.lock().unwrap_or_else(|e| e.into_inner()) - } - - pub fn unwrap_vault_key( - &self, - wrapped: AeadWrappedKey, - aad: VaultId, - ) -> ArkSessionResult { - let guard = self.lock(); - let ark = guard.as_ref().ok_or(ArkSessionError::Locked)?; - Ok(ark.unwrap_key(&wrapped, &aad)?) - } - - pub fn wrap_vault_key( - &self, - vault_key: VaultKey, - aad: VaultId, - ) -> ArkSessionResult> { - let guard = self.lock(); - let ark = guard.as_ref().ok_or(ArkSessionError::Locked)?; - Ok(ark.wrap_key(&vault_key, &aad)?) - } - pub fn create_account(&self, password: &str) -> ArkSessionResult { let user_id = UserId::new_v4(); let vault_id = VaultId::new_v4(); - let ark = AccountRootKey::generate_random(); - let vault_key = VaultKey::generate_random(); - - let salt = random_bytes::().to_vec(); - let kek = derive_kek(password, &salt)?; + let ark = AccountRootKey::generate(); + let vault_key = VaultKey::generate(); - let password_wrapped_ark = kek.wrap_key(&ark, &user_id)?; + let salt = new_salt().to_vec(); + let password_wrapped_ark = PasswordWrappedArk { + wrapped: derive_kek(password, &salt)?.wrap_key(&ark, &user_id)?, + salt, + }; let wrapped_vault_key = ark.wrap_key(&vault_key, &vault_id)?; *self.lock() = Some(ark); Ok(NewAccount { user_id, - salt, password_wrapped_ark, vault_id, wrapped_vault_key, @@ -124,16 +95,16 @@ impl ArkSession { pub fn unlock_with_password( &self, password: &str, - salt: &[u8], - wrapped: AeadWrappedKey, + wrapped: &PasswordWrappedArk, user_id: UserId, ) -> ArkSessionResult<()> { - let kek = derive_kek(password, salt)?; - self.unlock(kek, wrapped, user_id) + let ark = wrapped.open(password, user_id)?; + *self.lock() = Some(ark); + Ok(()) } pub fn unlock_with_ark(&self, ark: &[u8]) -> ArkSessionResult<()> { - let ark = AccountRootKey::try_from_bytes(ark)?; + let ark = AccountRootKey::from_bytes(ark)?; *self.lock() = Some(ark); Ok(()) } @@ -142,62 +113,103 @@ impl ArkSession { self.with_ark(|ark| Zeroizing::new(ark.as_bytes().to_vec())) } + /// Fails unless `wrapped` opens to the ARK this session already holds. pub fn verify_password( &self, password: &str, - salt: &[u8], - wrapped: AeadWrappedKey, + wrapped: &PasswordWrappedArk, user_id: UserId, ) -> ArkSessionResult<()> { - let kek = derive_kek(password, salt)?; - let stored = kek - .unwrap_key(&wrapped, &user_id) - .map_err(|_| ArkSessionError::WrongPassword)?; - - let guard = self.lock(); - let live = guard.as_ref().ok_or(ArkSessionError::Locked)?; - if bool::from(live.as_bytes().ct_eq(stored.as_bytes())) { - Ok(()) - } else { - Err(ArkSessionError::WrongPassword) - } + let stored = wrapped.open(password, user_id)?; + + self.with_live_ark(|live| { + if bool::from(live.as_bytes().ct_eq(stored.as_bytes())) { + Ok(()) + } else { + Err(ArkSessionError::WrongPassword) + } + }) } pub fn verify_ark(&self, candidate: &[u8]) -> ArkSessionResult { - let guard = self.lock(); - let ark = guard.as_ref().ok_or(ArkSessionError::Locked)?; - Ok(ark.as_bytes().ct_eq(candidate).into()) + self.with_live_ark(|ark| Ok(ark.as_bytes().ct_eq(candidate).into())) } pub fn rewrap_for_new_password( &self, new_password: &str, user_id: UserId, - ) -> ArkSessionResult { - let salt = random_bytes::().to_vec(); + ) -> ArkSessionResult { + // Argon2 is slow; derive outside the lock. + let salt = new_salt().to_vec(); let kek = derive_kek(new_password, &salt)?; + let wrapped = self.with_live_ark(|ark| Ok(kek.wrap_key(ark, &user_id)?))?; + Ok(PasswordWrappedArk { salt, wrapped }) + } - let guard = self.lock(); - let ark = guard.as_ref().ok_or(ArkSessionError::Locked)?; - let wrapped = kek.wrap_key(ark, &user_id)?; + pub fn wrap_vault_key( + &self, + vault_key: &VaultKey, + vault_id: VaultId, + ) -> ArkSessionResult> { + self.with_live_ark(|ark| Ok(ark.wrap_key(vault_key, &vault_id)?)) + } - Ok(PasswordWrapped { salt, wrapped }) + pub fn unwrap_vault_key( + &self, + wrapped: &Sealed, + vault_id: VaultId, + ) -> ArkSessionResult { + self.with_live_ark(|ark| Ok(ark.unwrap_key(wrapped, &vault_id)?)) } - pub fn with_ark(&self, f: impl FnOnce(&AccountRootKey) -> R) -> ArkSessionResult { - let ark = { - let guard = self.lock(); - let live = guard.as_ref().ok_or(ArkSessionError::Locked)?; - AccountRootKey::try_from_bytes(live.as_bytes())? - }; + /// Seals `plaintext` under a fresh key, so a record never depends on an older one opening. + pub fn seal_record( + &self, + namespace: NamespaceId, + kind: SecretKind, + plaintext: &[u8], + ) -> ArkSessionResult { + let key = NamespaceKey::generate(); + let wrapped_key = + self.with_live_ark(|ark| Ok(ark.wrap_key(&key, &NamespaceKeyAad { namespace })?))?; + let data = key.encrypt(plaintext, &NamespaceDataAad { namespace, kind })?; + Ok(AccountRecord { wrapped_key, data }) + } + + pub fn open_record( + &self, + namespace: NamespaceId, + kind: SecretKind, + record: &AccountRecord, + ) -> ArkSessionResult> { + let key = self.with_live_ark(|ark| { + Ok(ark.unwrap_key(&record.wrapped_key, &NamespaceKeyAad { namespace })?) + })?; + Ok(key.decrypt(&record.data, &NamespaceDataAad { namespace, kind })?) + } + /// Runs `f` on a copy of the ARK outside the lock, so `f` may re-enter the session. + pub fn with_ark(&self, f: impl FnOnce(&AccountRootKey) -> R) -> ArkSessionResult { + let ark = self.with_live_ark(|live| Ok(AccountRootKey::from_bytes(live.as_bytes())?))?; Ok(f(&ark)) } + + /// Runs `f` under the lock; `f` must not re-enter the session. + fn with_live_ark( + &self, + f: impl FnOnce(&AccountRootKey) -> ArkSessionResult, + ) -> ArkSessionResult { + f(self.lock().as_ref().ok_or(ArkSessionError::Locked)?) + } + + fn lock(&self) -> MutexGuard<'_, Option> { + self.ark.lock().unwrap_or_else(|e| e.into_inner()) + } } fn derive_kek(password: &str, salt: &[u8]) -> ArkSessionResult { - RootKEK::try_derive_from(password.as_bytes(), salt, PASSWORD_DOMAIN) - .map_err(|e| ArkSessionError::Derivation(format!("{e}"))) + RootKEK::from_password(password.as_bytes(), salt).map_err(ArkSessionError::Derivation) } #[cfg(test)] @@ -224,12 +236,7 @@ mod tests { session.end(); session - .unlock_with_password( - PASSWORD, - &account.salt, - account.password_wrapped_ark, - account.user_id, - ) + .unlock_with_password(PASSWORD, &account.password_wrapped_ark, account.user_id) .unwrap(); assert!(session.is_active()); @@ -241,7 +248,7 @@ mod tests { assert!( session - .unwrap_vault_key(account.wrapped_vault_key, account.vault_id) + .unwrap_vault_key(&account.wrapped_vault_key, account.vault_id) .is_ok() ); } @@ -251,14 +258,10 @@ mod tests { let (session, account) = unlocked(); session.end(); - let result = session.unlock_with_password( - "wrong", - &account.salt, - account.password_wrapped_ark, - account.user_id, - ); + let result = + session.unlock_with_password("wrong", &account.password_wrapped_ark, account.user_id); - assert!(matches!(result, Err(ArkSessionError::KeyWrap(_)))); + assert!(matches!(result, Err(ArkSessionError::WrongPassword))); assert!(!session.is_active()); } @@ -267,16 +270,10 @@ mod tests { let (session, account) = unlocked(); session.end(); - // The blob was wrapped with account.user_id as AAD; unlocking with a different id must - // fail the AEAD tag check, the same binding that stops a blob transplant between users. - let result = session.unlock_with_password( - PASSWORD, - &account.salt, - account.password_wrapped_ark, - UserId::new_v4(), - ); + let result = + session.unlock_with_password(PASSWORD, &account.password_wrapped_ark, UserId::new_v4()); - assert!(matches!(result, Err(ArkSessionError::KeyWrap(_)))); + assert!(matches!(result, Err(ArkSessionError::WrongPassword))); assert!(!session.is_active()); } @@ -285,15 +282,9 @@ mod tests { let (session, account) = unlocked(); let original = session.export_ark().unwrap(); - let result = session.unlock_with_password( - "wrong", - &account.salt, - account.password_wrapped_ark, - account.user_id, - ); + let result = + session.unlock_with_password("wrong", &account.password_wrapped_ark, account.user_id); - // A failed unlock attempt must not log the user out: the session stays active and keeps - // holding the ARK it had before the attempt. assert!(result.is_err()); assert!(session.is_active()); assert_eq!(session.export_ark().unwrap(), original); @@ -307,11 +298,10 @@ mod tests { let second = ArkSession::new(); second.unlock_with_ark(&exported).unwrap(); - // Both sessions hold the same ARK, so a key wrapped by one unwraps under the other. let wrapped = session - .wrap_vault_key(VaultKey::generate_random(), account.vault_id) + .wrap_vault_key(&VaultKey::generate(), account.vault_id) .unwrap(); - assert!(second.unwrap_vault_key(wrapped, account.vault_id).is_ok()); + assert!(second.unwrap_vault_key(&wrapped, account.vault_id).is_ok()); } #[test] @@ -342,7 +332,7 @@ mod tests { assert!( session - .verify_password(PASSWORD, &wrapped.salt, wrapped.wrapped, account.user_id) + .verify_password(PASSWORD, &wrapped, account.user_id) .is_ok() ); @@ -350,7 +340,7 @@ mod tests { .rewrap_for_new_password(PASSWORD, account.user_id) .unwrap(); assert!(matches!( - session.verify_password("nope", &wrapped.salt, wrapped.wrapped, account.user_id), + session.verify_password("nope", &wrapped, account.user_id), Err(ArkSessionError::WrongPassword) )); } @@ -358,17 +348,10 @@ mod tests { #[test] fn verify_password_rejects_a_blob_that_holds_a_different_ark() { let (session, _) = unlocked(); - // Same password, another account: the blob opens, but around a key this session does not - // hold. Rewrapping after this would put the new password around the wrong ARK. let (_, other) = unlocked(); assert!(matches!( - session.verify_password( - PASSWORD, - &other.salt, - other.password_wrapped_ark, - other.user_id, - ), + session.verify_password(PASSWORD, &other.password_wrapped_ark, other.user_id), Err(ArkSessionError::WrongPassword) )); } @@ -379,12 +362,7 @@ mod tests { session.end(); assert!(matches!( - session.verify_password( - PASSWORD, - &account.salt, - account.password_wrapped_ark, - account.user_id, - ), + session.verify_password(PASSWORD, &account.password_wrapped_ark, account.user_id), Err(ArkSessionError::Locked) )); } @@ -421,15 +399,9 @@ mod tests { session.end(); session - .unlock_with_password( - "new-password", - &rewrapped.salt, - rewrapped.wrapped, - account.user_id, - ) + .unlock_with_password("new-password", &rewrapped, account.user_id) .unwrap(); - // Same ARK, only the wrapping changed. assert_eq!(ark_before, session.export_ark().unwrap()); } @@ -457,32 +429,91 @@ mod tests { )); } - /// Known-answer test: pins `PASSWORD_DOMAIN` together with the Argon2 cost profile and the - /// derived key length behind it. Every shipped account's ARK is wrapped under a KEK derived - /// with this exact domain and parameter set, so if any of them drift, no existing account's - /// password can unlock it again. This is the tripwire for that. + const NAMESPACE: NamespaceId = NamespaceId(uuid::Uuid::from_u128( + 0x5555_5555_5555_4555_8555_5555_5555_5555, + )); + const KIND: SecretKind = SecretKind::PasswordHealthReport; + #[test] - fn password_domain_is_pinned() { - assert_eq!(PASSWORD_DOMAIN, b"v1:kek/pwd"); + fn a_sealed_record_opens_to_its_plaintext() { + let (session, _) = unlocked(); - const SALT: [u8; 16] = [7; 16]; - let kek = derive_kek("hunter2", &SALT).unwrap(); + let record = session.seal_record(NAMESPACE, KIND, b"report").unwrap(); assert_eq!( - kek.as_bytes(), - &[ - 243, 77, 26, 134, 177, 95, 102, 67, 54, 167, 232, 38, 115, 170, 132, 28, 98, 29, - 146, 108, 157, 245, 225, 131, 93, 9, 236, 235, 207, 6, 219, 103, - ][..] + session.open_record(NAMESPACE, KIND, &record).unwrap(), + b"report" ); } + #[test] + fn every_seal_uses_a_fresh_key() { + let (session, _) = unlocked(); + + let first = session.seal_record(NAMESPACE, KIND, b"report").unwrap(); + let second = session.seal_record(NAMESPACE, KIND, b"report").unwrap(); + + assert_ne!( + first.wrapped_key.ciphertext(), + second.wrapped_key.ciphertext() + ); + } + + #[test] + fn a_record_does_not_open_under_another_namespace() { + let (session, _) = unlocked(); + let record = session.seal_record(NAMESPACE, KIND, b"report").unwrap(); + + assert!(matches!( + session.open_record(NamespaceId::new_v4(), KIND, &record), + Err(ArkSessionError::KeyWrap(CryptoError::KeyUnwrapFailed)) + )); + } + + #[test] + fn a_record_does_not_open_as_another_kind() { + let (session, _) = unlocked(); + let record = session.seal_record(NAMESPACE, KIND, b"report").unwrap(); + + assert!(matches!( + session.open_record(NAMESPACE, SecretKind::Password, &record), + Err(ArkSessionError::KeyWrap(CryptoError::DecryptionFailed)) + )); + } + + #[test] + fn a_record_does_not_open_under_another_account() { + let (session, _) = unlocked(); + let (other, _) = unlocked(); + let record = session.seal_record(NAMESPACE, KIND, b"report").unwrap(); + + assert!(matches!( + other.open_record(NAMESPACE, KIND, &record), + Err(ArkSessionError::KeyWrap(CryptoError::KeyUnwrapFailed)) + )); + } + + #[test] + fn records_need_an_unlocked_session() { + let (session, _) = unlocked(); + let record = session.seal_record(NAMESPACE, KIND, b"report").unwrap(); + session.end(); + + assert!(matches!( + session.seal_record(NAMESPACE, KIND, b"report"), + Err(ArkSessionError::Locked) + )); + assert!(matches!( + session.open_record(NAMESPACE, KIND, &record), + Err(ArkSessionError::Locked) + )); + } + #[test] fn with_ark_does_not_hold_the_lock_across_the_closure() { let (session, _) = unlocked(); - // Every one of these takes the same lock. Under a lock held across the closure they would - // all deadlock rather than fail, so this test hanging is itself the regression signal. + // All take the same lock; a hang here means a deadlock. let reentered = session .with_ark(|ark| { let exported = session.export_ark().unwrap(); @@ -500,8 +531,6 @@ mod tests { let (session, _) = unlocked(); let original = session.export_ark().unwrap(); - // Ending the session mid-closure is the case the clone exists for: `f` keeps working on - // the key it was handed instead of reading a slot that is now empty. let observed = session .with_ark(|ark| { session.end(); diff --git a/rust/rust-code/core/src/b64.rs b/rust/crates/keygo-core/src/b64.rs similarity index 94% rename from rust/rust-code/core/src/b64.rs rename to rust/crates/keygo-core/src/b64.rs index 4eb2124cd..ae0ea696a 100644 --- a/rust/rust-code/core/src/b64.rs +++ b/rust/crates/keygo-core/src/b64.rs @@ -10,7 +10,6 @@ pub(crate) fn decode(encoded: &str) -> Result, base64::DecodeError> { STANDARD.decode(encoded) } -/// serde adapter for `#[serde(with = "crate::b64")]` on `Vec` fields. pub(crate) fn serialize(bytes: &[u8], serializer: S) -> Result { serializer.serialize_str(&encode(bytes)) } diff --git a/rust/rust-code/core/src/backup/encryption.rs b/rust/crates/keygo-core/src/backup/encryption.rs similarity index 72% rename from rust/rust-code/core/src/backup/encryption.rs rename to rust/crates/keygo-core/src/backup/encryption.rs index 77feb3042..50953b0b1 100644 --- a/rust/rust-code/core/src/backup/encryption.rs +++ b/rust/crates/keygo-core/src/backup/encryption.rs @@ -1,14 +1,11 @@ use crate::b64; use crate::backup::BackupError; use crate::backup::BackupKey; -use crate::crypto::AccountRootKey; -use crate::crypto::primitive::aead_data::{AeadCiphertext, AeadEncryptor}; -use crate::crypto::primitive::argon2::Argon2Params; -use crate::crypto::random::random_bytes; +use crate::crypto::kdf::argon2::Argon2Params; +use crate::crypto::kdf::new_salt; +use crate::crypto::{AccountRootKey, Encrypts, Sealed, aad}; use serde::{Deserialize, Serialize}; -const NONCE_LEN: usize = 12; - #[derive(Serialize, Deserialize)] pub struct EncryptionHeader { pub source: KeySource, @@ -55,14 +52,15 @@ impl Kdf { } } -/// Evolve it only via a version bump, never by editing fields in place. -#[derive(Serialize)] -pub struct BackupAad { - pub version: u32, - pub source: KeySource, +aad! { + /// Wire format: change only with a version bump. + pub struct BackupAad { + pub version: u32, + pub source: KeySource, + } } -impl AeadEncryptor for BackupKey { +impl Encrypts for BackupKey { type Aad = BackupAad; } @@ -82,7 +80,7 @@ pub fn seal( cred: BackupCredential<'_>, version: u32, ) -> Result { - let salt = random_bytes::<16>(); + let salt = new_salt(); let (backup_key, source, kdf) = match cred { BackupCredential::Passphrase(passphrase) => { let argon_param = Argon2Params::default(); @@ -99,35 +97,29 @@ pub fn seal( ), }; let aad = BackupAad { version, source }; - let ciphertext = backup_key.encrypt_data(plaintext, &aad)?; + let (nonce, ciphertext) = backup_key.encrypt(plaintext, &aad)?.into_parts(); Ok(SealedPayload { - header: EncryptionHeader { - source, - kdf, - nonce: ciphertext.nonce_bytes().to_vec(), - }, - ciphertext: ciphertext.ciphertext().to_vec(), + header: EncryptionHeader { source, kdf, nonce }, + ciphertext, }) } pub fn open( header: &EncryptionHeader, - ciphertext: &[u8], + ciphertext: Vec, cred: BackupCredential<'_>, version: u32, ) -> Result, BackupError> { - if header.nonce.len() != NONCE_LEN { - return Err(BackupError::MalformedHeader); - } + // Before key derivation, which may run Argon2. + let sealed = + Sealed::from_parts(&header.nonce, ciphertext).map_err(|_| BackupError::MalformedHeader)?; let key = match (header.source, &header.kdf, cred) { - // Credential's source disagrees with the header's declared source. (KeySource::Passphrase, _, BackupCredential::Ark(_)) | (KeySource::Ark, _, BackupCredential::Passphrase(_)) => { return Err(BackupError::CredentialMismatch); } - // Source matches credential and the KDF matches the source: derive. ( KeySource::Passphrase, Kdf::Argon2id { @@ -150,7 +142,7 @@ pub fn open( BackupKey::from_ark(ark, salt)? } - // Source matches credential but the KDF disagrees with the source. + // KDF does not match the source. (KeySource::Passphrase, _, BackupCredential::Passphrase(_)) | (KeySource::Ark, _, BackupCredential::Ark(_)) => { return Err(BackupError::MalformedHeader); @@ -161,18 +153,15 @@ pub fn open( version, source: header.source, }; - let parts = AeadCiphertext::::from_parts_bytes(ciphertext.to_vec(), &header.nonce); - Ok(key.decrypt_data(&parts, &aad)?) + Ok(key.decrypt(&sealed, &aad)?) } #[cfg(test)] mod tests { use super::*; use crate::backup::CURRENT_VERSION; - use crate::crypto::AccountRootKey; - use crate::crypto::KeyMaterial; - use crate::crypto::error::CryptoError; - use crate::crypto::primitive::argon2::MAX_ARGON2_MEM_KIB; + use crate::crypto::kdf::argon2::MAX_ARGON2_MEM_KIB; + use crate::crypto::{CryptoError, SymmetricKey}; #[test] fn key_source_serialization() { @@ -208,8 +197,8 @@ mod tests { version: CURRENT_VERSION, source: KeySource::Passphrase, }; - let ct = key.encrypt_data(b"secret-bytes", &aad).unwrap(); - let pt = key.decrypt_data(&ct, &aad).unwrap(); + let ct = key.encrypt(b"secret-bytes", &aad).unwrap(); + let pt = key.decrypt(&ct, &aad).unwrap(); assert_eq!(pt, b"secret-bytes"); } @@ -225,9 +214,9 @@ mod tests { version: 2, source: KeySource::Passphrase, }; - let ct = key.encrypt_data(b"secret", &aad).unwrap(); + let ct = key.encrypt(b"secret", &aad).unwrap(); assert!(matches!( - key.decrypt_data(&ct, &wrong), + key.decrypt(&ct, &wrong), Err(CryptoError::DecryptionFailed), )); } @@ -236,7 +225,7 @@ mod tests { fn seal_then_open_round_trips() { let cred = BackupCredential::Passphrase(b"pw"); let sealed = seal(b"hello-bytes", cred, CURRENT_VERSION).unwrap(); - let pt = open(&sealed.header, &sealed.ciphertext, cred, CURRENT_VERSION).unwrap(); + let pt = open(&sealed.header, sealed.ciphertext, cred, CURRENT_VERSION).unwrap(); assert_eq!(pt, b"hello-bytes"); } @@ -245,13 +234,13 @@ mod tests { let cred = BackupCredential::Passphrase(b"pw"); let mut sealed = seal(b"x", cred, CURRENT_VERSION).unwrap(); sealed.header.nonce.push(0); - let err = open(&sealed.header, &sealed.ciphertext, cred, CURRENT_VERSION).unwrap_err(); + let err = open(&sealed.header, sealed.ciphertext, cred, CURRENT_VERSION).unwrap_err(); assert!(matches!(err, BackupError::MalformedHeader)); } #[test] fn ark_seal_records_hkdf_kdf() { - let ark = AccountRootKey::try_from_bytes(&[5u8; 32]).unwrap(); + let ark = AccountRootKey::from_bytes(&[5u8; 32]).unwrap(); let sealed = seal(b"x", BackupCredential::Ark(&ark), CURRENT_VERSION).unwrap(); assert!(matches!(sealed.header.source, KeySource::Ark)); assert!(matches!(sealed.header.kdf, Kdf::HkdfSha256 { .. })); @@ -259,21 +248,20 @@ mod tests { #[test] fn ark_seal_then_open_round_trips() { - let ark = AccountRootKey::try_from_bytes(&[5u8; 32]).unwrap(); + let ark = AccountRootKey::from_bytes(&[5u8; 32]).unwrap(); let cred = BackupCredential::Ark(&ark); let sealed = seal(b"hello-ark", cred, CURRENT_VERSION).unwrap(); - let pt = open(&sealed.header, &sealed.ciphertext, cred, CURRENT_VERSION).unwrap(); + let pt = open(&sealed.header, sealed.ciphertext, cred, CURRENT_VERSION).unwrap(); assert_eq!(pt, b"hello-ark"); } #[test] fn open_rejects_ark_source_with_argon2id_kdf() { - let ark = AccountRootKey::try_from_bytes(&[5u8; 32]).unwrap(); + let ark = AccountRootKey::from_bytes(&[5u8; 32]).unwrap(); let cred = BackupCredential::Ark(&ark); let mut sealed = seal(b"x", cred, CURRENT_VERSION).unwrap(); - // Header still says source=Ark but now carries a passphrase-style KDF. sealed.header.kdf = Kdf::argon2id(vec![1u8; 16], Argon2Params::default()); - let err = open(&sealed.header, &sealed.ciphertext, cred, CURRENT_VERSION).unwrap_err(); + let err = open(&sealed.header, sealed.ciphertext, cred, CURRENT_VERSION).unwrap_err(); assert!(matches!(err, BackupError::MalformedHeader)); } @@ -282,7 +270,7 @@ mod tests { let cred = BackupCredential::Passphrase(b"pw"); let mut sealed = seal(b"x", cred, CURRENT_VERSION).unwrap(); sealed.header.kdf = Kdf::hkdf_sha256(vec![1u8; 16]); - let err = open(&sealed.header, &sealed.ciphertext, cred, CURRENT_VERSION).unwrap_err(); + let err = open(&sealed.header, sealed.ciphertext, cred, CURRENT_VERSION).unwrap_err(); assert!(matches!(err, BackupError::MalformedHeader)); } @@ -290,13 +278,27 @@ mod tests { fn open_rejects_argon2_params_exceeding_memory_limit() { let cred = BackupCredential::Passphrase(b"pw"); let mut sealed = seal(b"x", cred, CURRENT_VERSION).unwrap(); - // A hostile header claiming an enormous memory cost must fail key - // derivation cleanly - not abort the process, and not be silently ignored - // (which is what happened before the params were threaded through). if let Kdf::Argon2id { mem_kib, .. } = &mut sealed.header.kdf { *mem_kib = MAX_ARGON2_MEM_KIB + 1; } - let err = open(&sealed.header, &sealed.ciphertext, cred, CURRENT_VERSION).unwrap_err(); - assert!(matches!(err, BackupError::Crypto(CryptoError::KdfError(_)),)); + let err = open(&sealed.header, sealed.ciphertext, cred, CURRENT_VERSION).unwrap_err(); + assert!(matches!( + err, + BackupError::Crypto(CryptoError::Argon2MemoryTooHigh { .. }) + )); + } + + #[test] + fn open_rejects_argon2_params_exceeding_iteration_limit() { + let cred = BackupCredential::Passphrase(b"pw"); + let mut sealed = seal(b"x", cred, CURRENT_VERSION).unwrap(); + if let Kdf::Argon2id { iters, .. } = &mut sealed.header.kdf { + *iters = u32::MAX; + } + let err = open(&sealed.header, sealed.ciphertext, cred, CURRENT_VERSION).unwrap_err(); + assert!(matches!( + err, + BackupError::Crypto(CryptoError::Argon2ItersTooHigh { .. }) + )); } } diff --git a/rust/rust-code/core/src/backup/error.rs b/rust/crates/keygo-core/src/backup/error.rs similarity index 93% rename from rust/rust-code/core/src/backup/error.rs rename to rust/crates/keygo-core/src/backup/error.rs index fa9d14074..de9dbd28a 100644 --- a/rust/rust-code/core/src/backup/error.rs +++ b/rust/crates/keygo-core/src/backup/error.rs @@ -1,4 +1,4 @@ -use crate::crypto::error::CryptoError; +use crate::crypto::CryptoError; #[derive(Debug, thiserror::Error)] pub enum BackupError { diff --git a/rust/rust-code/core/src/backup/format/csv/detect.rs b/rust/crates/keygo-core/src/backup/format/csv/detect.rs similarity index 86% rename from rust/rust-code/core/src/backup/format/csv/detect.rs rename to rust/crates/keygo-core/src/backup/format/csv/detect.rs index 83fcf3398..3123fc2d3 100644 --- a/rust/rust-code/core/src/backup/format/csv/detect.rs +++ b/rust/crates/keygo-core/src/backup/format/csv/detect.rs @@ -7,7 +7,6 @@ use crate::url::sanitize_to_https_url; const DELIMITERS: [u8; 4] = *b",;\t|"; -/// Strip a leading UTF-8 BOM, if present. pub(super) fn strip_bom(data: &str) -> &str { data.strip_prefix('\u{feff}').unwrap_or(data) } @@ -83,10 +82,7 @@ const HEADER_CONTAINS: u32 = 30; const VALUE_MAX: u32 = 50; const MIN_SCORE: u32 = 25; -/// Lowercase a header and collapse every run of non-alphanumeric characters -/// (spaces, `_`, `-`, `.`, `/`, ...) into a single space, trimming the ends. This -/// makes `login_uri`, `Login-URI`, `login.uri`, and `Login URI` all compare -/// equal, so a header matches the keyword tables regardless of separator style. +/// Lowercase and collapse separator runs to one space, so `login_uri` == `Login-URI`. fn normalize_header(header: &str) -> String { let mut out = String::with_capacity(header.len()); let mut pending_space = false; @@ -104,7 +100,7 @@ fn normalize_header(header: &str) -> String { out } -/// Score a single field against an already-[`normalize_header`]d header. +/// `header` must already be normalized. fn header_score(field: Field, header: &str) -> u32 { let (exact, contains): (&[&str], &[&str]) = match field { Field::Title => ( @@ -232,8 +228,7 @@ fn profile_column(samples: &[StringRecord], col: usize) -> Profile { } total += 1; - // url and email are mutually exclusive: looks_like_url already - // rejects anything that parses as an email. + // looks_like_url already rejects emails. if looks_like_url(cell) { url += 1; } else if looks_like_email(cell) { @@ -253,9 +248,7 @@ fn profile_column(samples: &[StringRecord], col: usize) -> Profile { } } -/// Greedy best-fit assignment: each column maps to at most one field and each -/// field to at most one column, taking the highest scores first. Ties resolve by -/// field declaration order, then column index, for determinism. +/// Greedy best-fit, highest score first; ties break by field order, then column. pub(super) fn build_mapping( headers: &[String], samples: &[StringRecord], @@ -287,7 +280,7 @@ pub(super) fn build_mapping( continue; } let field = ALL_FIELDS[field_idx]; - mapping.set(field, col); + mapping.set(field, col as u32); confidence.set(field, Confidence::from_score(score)); used_cols[col] = true; used_fields[field_idx] = true; @@ -319,7 +312,7 @@ mod tests { assert_eq!(m.username, Some(2)); assert_eq!(m.password, Some(3)); assert_eq!(m.notes, Some(4)); - assert_eq!(c.password, Some(Confidence::High)); // exact header match + assert_eq!(c.password, Some(Confidence::High)); } #[test] @@ -370,7 +363,6 @@ mod tests { #[test] fn value_sniffing_drives_vague_headers() { - // Columns 1 and 2 have meaningless headers; only their values reveal them. let headers = hdrs(&["name", "field_a", "field_b"]); let samples = rows(&[ &["Site One", "alice@example.com", "https://one.example"], @@ -378,9 +370,9 @@ mod tests { ]); let (m, c) = build_mapping(&headers, &samples); assert_eq!(m.title, Some(0)); - assert_eq!(m.username, Some(1)); // emails - assert_eq!(m.url, Some(2)); // urls - assert_eq!(c.username, Some(Confidence::Medium)); // value-only match + assert_eq!(m.username, Some(1)); + assert_eq!(m.url, Some(2)); + assert_eq!(c.username, Some(Confidence::Medium)); } #[test] @@ -392,9 +384,6 @@ mod tests { #[test] fn header_separators_normalize_to_exact_match() { - // Underscore, hyphen, dot, mixed case, and repeated spaces all normalize - // to one exact-match phrase and earn High (not merely "contains") - // confidence. for h in [ "login_username", "login-username", @@ -431,8 +420,6 @@ mod tests { #[test] fn semicolon_wins_when_commas_only_inside_fields() { - // header has no commas; a data cell does. The semicolon count is - // consistent across lines, so it must win over the ragged comma count. let data = "name;url;notes\nSite;https://x.com;\"a, b, c\""; assert_eq!(detect_delimiter(data), b';'); } @@ -447,7 +434,7 @@ mod tests { fn email_detection() { assert!(looks_like_email("alice@example.com")); assert!(looks_like_email("a.b+c@mail.co.uk")); - assert!(!looks_like_email("alice@localhost")); // no dot in domain + assert!(!looks_like_email("alice@localhost")); assert!(!looks_like_email("not an email")); assert!(!looks_like_email("https://example.com")); assert!(!looks_like_email("")); @@ -457,8 +444,8 @@ mod tests { fn url_detection() { assert!(looks_like_url("https://example.com/login")); assert!(looks_like_url("http://sub.example.org")); - assert!(looks_like_url("example.com")); // bare host - assert!(!looks_like_url("alice@example.com")); // email, not url + assert!(looks_like_url("example.com")); + assert!(!looks_like_url("alice@example.com")); assert!(!looks_like_url("just a note")); assert!(!looks_like_url("")); } @@ -468,10 +455,10 @@ mod tests { assert!(looks_like_totp( "otpauth://totp/Example:alice?secret=JBSWY3DPEHPK3PXP" )); - assert!(looks_like_totp("JBSWY3DPEHPK3PXP234")); // base32, >=16 chars - assert!(!looks_like_totp("jbsw y3dp ehpk 3pxp 234")); // spaced/lowercase: not importable as-is - assert!(!looks_like_totp("short")); // too short - assert!(!looks_like_totp("has-symbols-!@#$%^&*()")); // not base32 + assert!(looks_like_totp("JBSWY3DPEHPK3PXP234")); + assert!(!looks_like_totp("jbsw y3dp ehpk 3pxp 234")); + assert!(!looks_like_totp("short")); + assert!(!looks_like_totp("has-symbols-!@#$%^&*()")); assert!(!looks_like_totp("")); } } diff --git a/rust/rust-code/core/src/backup/format/csv/mod.rs b/rust/crates/keygo-core/src/backup/format/csv/mod.rs similarity index 85% rename from rust/rust-code/core/src/backup/format/csv/mod.rs rename to rust/crates/keygo-core/src/backup/format/csv/mod.rs index ac517c735..91541e701 100644 --- a/rust/rust-code/core/src/backup/format/csv/mod.rs +++ b/rust/crates/keygo-core/src/backup/format/csv/mod.rs @@ -1,10 +1,3 @@ -//! CSV import and export. -//! -//! [`analyze`] inspects an unknown CSV and proposes a [`ColumnMapping`]; the user -//! edits it; [`import`] then reads the file under that mapping. [`export`] writes -//! one back out in a chosen [`ExportPreset`]. The column-guessing heuristics that -//! back `analyze` live in `detect`. - mod detect; use csv::StringRecord; @@ -14,12 +7,12 @@ use crate::backup::{Backup, BackupError, Login, Vault}; #[derive(Debug, Default, PartialEq, Eq)] pub struct ColumnMapping { - pub title: Option, - pub url: Option, - pub username: Option, - pub password: Option, - pub notes: Option, - pub totp: Option, + pub title: Option, + pub url: Option, + pub username: Option, + pub password: Option, + pub notes: Option, + pub totp: Option, } #[derive(Debug, PartialEq, Eq)] @@ -90,8 +83,6 @@ const ALL_FIELDS: [Field; 6] = [ ]; impl Field { - /// Borrow this field's value from a login, or `None` for an absent optional. - /// The title is always present. fn read(self, login: &Login) -> Option<&str> { match self { Field::Title => Some(login.title.as_str()), @@ -105,7 +96,7 @@ impl Field { } impl ColumnMapping { - fn set(&mut self, field: Field, idx: usize) { + fn set(&mut self, field: Field, idx: u32) { match field { Field::Title => self.title = Some(idx), Field::Url => self.url = Some(idx), @@ -132,11 +123,7 @@ impl FieldConfidence { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ExportPreset { - /// KeyGo's own layout: every field, with headers that [`analyze`] maps back - /// at High confidence, so a backup round-trips through [`import`]. KeyGo, - /// A browser-importable layout (Chrome, Edge, ...): `name,url,username,password,note` - /// and no TOTP column. Browser, } @@ -163,17 +150,10 @@ impl ExportPreset { } } -/// How many data rows feed the analysis. Must be `>= DISPLAY_SAMPLES`. +/// Must be `>= DISPLAY_SAMPLES`. const SAMPLE_ROWS: usize = 10; const DISPLAY_SAMPLES: usize = 5; -/// Inspect a CSV and return a review-ready analysis: the columns (with a few -/// sample values each), a suggested editable mapping, and per-field confidence. -/// -/// Column types are inferred from the header names and from the first -/// `SAMPLE_ROWS` parseable data rows (malformed rows are skipped). Only those -/// rows are read, so the result is deterministic and the cost is independent of -/// file size. pub fn analyze(data: &str) -> Result { let data = strip_bom(data); if data.trim().is_empty() { @@ -191,8 +171,7 @@ pub fn analyze(data: &str) -> Result { return Err(BackupError::EmptyCsv); } - // Read at most the first SAMPLE_ROWS parseable rows, skipping malformed - // ones. `take` keeps this lazy, so a large file is never fully scanned. + // `take` keeps this lazy; large files are never fully scanned. let samples: Vec = reader .records() .filter_map(Result::ok) @@ -245,8 +224,8 @@ pub fn import(data: &str, mapping: &ColumnMapping) -> Result<(Backup, ImportRepo } }; - let field = |col: Option| -> Option { - col.and_then(|i| record.get(i)) + let field = |col: Option| -> Option { + col.and_then(|i| record.get(i as usize)) .map(str::trim) .filter(|s| !s.is_empty()) .map(str::to_owned) @@ -323,8 +302,6 @@ mod tests { #[test] fn analyze_samples_only_leading_rows_deterministically() { - // 12 data rows, but only the first SAMPLE_ROWS feed the analysis and the - // result is stable across runs (no randomness). let mut csv = String::from("name,login_uri,login_username\n"); for i in 0..12 { csv.push_str(&format!( @@ -334,12 +311,10 @@ mod tests { let a1 = analyze(&csv).unwrap(); let a2 = analyze(&csv).unwrap(); assert_eq!(a1.columns[0].sample_values, a2.columns[0].sample_values); - // Display samples are the first rows, in file order. assert_eq!( a1.columns[0].sample_values, vec!["Site 0", "Site 1", "Site 2", "Site 3", "Site 4"] ); - // Separator-styled headers map (and value sniffing agrees). assert_eq!(a1.suggested.url, Some(1)); assert_eq!(a1.suggested.username, Some(2)); assert_eq!(a1.confidence.url, Some(Confidence::High)); @@ -396,20 +371,19 @@ Bank,https://bank.example,bob,hunter2,\n"; vec!["https://mail.example".to_string()] ); assert_eq!(v.logins[0].notes.as_deref(), Some("primary")); - assert_eq!(v.logins[1].notes, None); // empty cell -> None + assert_eq!(v.logins[1].notes, None); } #[test] fn import_honors_user_edited_mapping() { let mut a = analyze(CHROME_CSV).unwrap(); - a.suggested.notes = None; // user removes the notes mapping + a.suggested.notes = None; let (backup, _) = import(CHROME_CSV, &a.suggested).unwrap(); assert!(backup.vaults[0].logins.iter().all(|l| l.notes.is_none())); } #[test] fn import_skips_empty_rows_and_reports() { - // 2 valid rows, 1 all-empty row. let data = "name,username,password\nA,alice,pw1\n,,\nB,bob,pw2\n"; let mapping = ColumnMapping { title: Some(0), @@ -430,7 +404,6 @@ Bank,https://bank.example,bob,hunter2,\n"; #[test] fn import_tolerates_ragged_rows() { - // Second row has fewer columns than the header; it must not error. let data = "name,username,password\nFull,alice,pw\nPartial,bob\n"; let mapping = ColumnMapping { title: Some(0), @@ -442,7 +415,7 @@ Bank,https://bank.example,bob,hunter2,\n"; assert_eq!(report.imported, 2); let partial = &backup.vaults[0].logins[1]; assert_eq!(partial.username.as_deref(), Some("bob")); - assert_eq!(partial.password, None); // missing column -> None + assert_eq!(partial.password, None); } #[test] @@ -457,8 +430,8 @@ Bank,https://bank.example,bob,hunter2,\n"; let (backup, _) = import(data, &mapping).unwrap(); let logins = &backup.vaults[0].logins; assert_eq!(logins[0].title, "https://only-url.example"); // url fallback - assert_eq!(logins[1].title, "carol"); // username fallback - assert_eq!(logins[2].title, "Untitled"); // last-resort + assert_eq!(logins[1].title, "carol"); + assert_eq!(logins[2].title, "Untitled"); } #[test] @@ -504,7 +477,6 @@ Bank,https://bank.example,bob,hunter2,\n"; lines[1], "Email,https://mail.example,alice,s3cr3t,primary,JBSWY3DPEHPK3PXP" ); - // Only the title is set; every optional becomes an empty cell. assert_eq!(lines[2], "Bare,,,,,"); } @@ -523,7 +495,6 @@ Bank,https://bank.example,bob,hunter2,\n"; let lines: Vec<&str> = csv.lines().collect(); assert_eq!(lines[0], "name,url,username,password,note"); assert_eq!(lines[1], "Email,https://mail.example,alice,s3cr3t,primary"); - // The browser layout carries no TOTP column. assert!(!csv.contains("JBSWY3DPEHPK3PXP")); } @@ -555,7 +526,7 @@ Bank,https://bank.example,bob,hunter2,\n"; .lines() .map(str::to_owned) .collect(); - assert_eq!(lines.len(), 3); // header + one row per login + assert_eq!(lines.len(), 3); assert_eq!(lines[1], "A,,,,"); assert_eq!(lines[2], "B,,,,"); } @@ -594,7 +565,6 @@ Bank,https://bank.example,bob,hunter2,\n"; ..Default::default() }]); let csv = export(&backup, ExportPreset::KeyGo).unwrap(); - // The exact escaping is the csv crate's job; assert it re-imports losslessly. let analysis = analyze(&csv).unwrap(); let (restored, _) = import(&csv, &analysis.suggested).unwrap(); let l = &restored.vaults[0].logins[0]; @@ -604,10 +574,7 @@ Bank,https://bank.example,bob,hunter2,\n"; #[test] fn export_preset_headers_analyze_as_high_confidence() { - // Every header an export preset emits must be recognized by `analyze` at - // High confidence; otherwise an exported file would re-import with a weak - // or missing column mapping. A "contains"-only match scores Low, so this - // assertion fails if any preset header drifts off an exact keyword. + // Preset headers must map at High confidence, or exports re-import poorly. fn confidence_of(c: &FieldConfidence, field: Field) -> Option<&Confidence> { match field { Field::Title => c.title.as_ref(), @@ -620,7 +587,6 @@ Bank,https://bank.example,bob,hunter2,\n"; } for preset in [ExportPreset::KeyGo, ExportPreset::Browser] { - // Drive the real export path: an empty backup yields just the header row. let csv = export(&Backup { vaults: vec![] }, preset).unwrap(); let analysis = analyze(&csv).unwrap(); for &(header, field) in preset.columns() { diff --git a/rust/rust-code/core/src/backup/format/json.rs b/rust/crates/keygo-core/src/backup/format/json.rs similarity index 87% rename from rust/rust-code/core/src/backup/format/json.rs rename to rust/crates/keygo-core/src/backup/format/json.rs index 8dcc6e4e4..5d5e4de3f 100644 --- a/rust/rust-code/core/src/backup/format/json.rs +++ b/rust/crates/keygo-core/src/backup/format/json.rs @@ -3,8 +3,6 @@ use crate::backup::encryption::{self, BackupCredential, EncryptionHeader, KeySou use crate::backup::{Backup, BackupError, CURRENT_VERSION, MIN_SUPPORTED_VERSION}; use serde::{Deserialize, Serialize}; -/// A JSON backup is always sealed - there is no plaintext envelope. `payload` is the base64 -/// ciphertext whose header sits in `encryption`. #[derive(Serialize, Deserialize)] pub struct BackupEnvelope { pub version: u32, @@ -26,11 +24,10 @@ pub fn export(backup: &Backup, cred: BackupCredential<'_>) -> Result) -> Result { let env = parse_envelope(data)?; let ciphertext = b64::decode(&env.payload).map_err(|_| BackupError::Base64)?; - let payload_bytes = encryption::open(&env.encryption, &ciphertext, cred, env.version)?; + let payload_bytes = encryption::open(&env.encryption, ciphertext, cred, env.version)?; Ok(serde_json::from_slice(&payload_bytes)?) } -/// Report which credential a backup file needs, without decrypting it. pub fn inspect(data: &str) -> Result { Ok(parse_envelope(data)?.encryption.source) } @@ -48,9 +45,7 @@ mod tests { use super::*; use crate::backup::encryption::{Kdf, KeySource}; use crate::backup::{Card, Login, Passkey, Vault}; - use crate::crypto::AccountRootKey; - use crate::crypto::KeyMaterial; - use crate::crypto::error::CryptoError; + use crate::crypto::{AccountRootKey, CryptoError, SymmetricKey}; fn sample_backup() -> Backup { Backup { @@ -90,10 +85,8 @@ mod tests { ); } - // A real v1 passphrase-encrypted backup, frozen at generation time. Its job is - // to fail loudly if the on-disk format or - critically - the BCS layout of - // `BackupAad` ever drifts, since either makes existing backups undecryptable - // with no compile error. Regenerate ONLY alongside a deliberate version bump. + // Frozen v1 passphrase backup; fails if the format or `BackupAad`'s BCS layout drifts. + // Regenerate only with a version bump. const GOLDEN_V1_PASSPHRASE: &[u8] = b"golden-pw"; const GOLDEN_V1: &str = r#"{"version":1,"encryption":{"source":"passphrase","kdf":{"type":"argon2id","salt":"wFYCrxdwhGR19jk2BBUasQ==","mem_kib":65536,"iters":3,"lanes":4},"nonce":"V8dD5ja/CUoL2vYg"},"payload":"GpZBGMDvOgwOPVpst4TnyCMrC+lXMQ8KOPEgeK8GSTZafA/YGBCO+9J7BFmHvtuSuXAaNrlsEviPxFdQCCGJlljW1lJoGJ2Cny0RDlw+75fdH/a4MNwVplSvSJYxeZoYO3wEh8RDyHw3fHlXrQ/u+en1+0psRkdt1Gnvkv+ULxKEOsjTOz0fqzioOYWK/oyNW1h6qJ6x09aTOsBzv1Jv6Wx3vMNzqXGCgFxI9UTzWmdp7hs2JRHHcegTzMaX2cw1lV+shWNpyqEu1anSC6Zc8qfk1vxDj06xGjWZsFeBCfk/8j5Eu5y/c/nDKvcQKC8I3PmPXBBrCmoi/mRDHqu2sMSJQKqBebLv4MkWJUjV3CvfWDJcBHv/ovfNAgmNhNEzZJBA8iC5Pwat0vxopszcsU2bpwg0bPG3hawQkrLkz9sJGnJEsJHSpPSsBj/fS/FqnvkeyrEGH+4TbUqX26LSbFVgmLR3LJtLvP9M3xv99dWsdeqH+C9YmKsXtvXbXCcA20ygL7wc6oCgQbFWGwA7N1lnk1oKDDdaftCu"}"#; @@ -104,8 +97,6 @@ mod tests { BackupCredential::Passphrase(GOLDEN_V1_PASSPHRASE), ) .unwrap(); - // Assert on stable, semantic values so the test survives future additive - // schema changes (new Option fields) without needing a fresh golden. let vault = &backup.vaults[0]; let login = &vault.logins[0]; let card = &vault.cards[0]; @@ -158,8 +149,6 @@ mod tests { #[test] fn golden_v1_vault_has_no_icon() { - // The frozen golden predates the field; serde(default) must read it as an empty string - // rather than failing the whole import. let backup = import( GOLDEN_V1, BackupCredential::Passphrase(GOLDEN_V1_PASSPHRASE), @@ -170,8 +159,6 @@ mod tests { #[test] fn golden_v1_login_has_no_passkeys() { - // The frozen golden predates the field; serde(default) must read it as an empty list - // rather than failing the whole import. let backup = import( GOLDEN_V1, BackupCredential::Passphrase(GOLDEN_V1_PASSPHRASE), @@ -182,9 +169,6 @@ mod tests { #[test] fn golden_v1_login_has_no_websites() { - // The frozen golden predates the field (and its predecessor was a single `website` - // string, not this list); serde(default) must read it as an empty list rather than - // failing the whole import. let backup = import( GOLDEN_V1, BackupCredential::Passphrase(GOLDEN_V1_PASSPHRASE), @@ -230,15 +214,14 @@ mod tests { json_eq(&restored, &original); } - // A real v1 ARK-encrypted backup, frozen at generation time, mirroring - // GOLDEN_V1. It fails loudly if the ARK wire format or HKDF derivation drifts. - // Regenerate ONLY alongside a deliberate version bump. + // Frozen v1 ARK backup; fails if the ARK wire format or HKDF derivation drifts. + // Regenerate only with a version bump. const GOLDEN_V1_ARK_KEY: [u8; 32] = [9u8; 32]; const GOLDEN_V1_ARK: &str = r#"{"version":1,"encryption":{"source":"ark","kdf":{"type":"hkdf_sha256","salt":"DbSH5vXm1d1XLo2gANptBQ=="},"nonce":"r2uEXqscOq6avV5g"},"payload":"6KlcJQxQ7q2SnHyJ/0YTWEs/GMZk4npIW8+0AUdVyA58gUMk4IUoNYpuCJEZ+6O0SAuaDd68JLpDgcTc3QmSCjxjCpSehOiPXIACk8+yXlb1oS9wlu1+cSuKrNqKJdMdvmSx/3UabH6rznZvN/qyHC6SMK71vtBoG9hgHt7wquYHNv1RIL7Rd5m1BMpmivooS6gfbviYHxVruxSXXSI/KBQ9wjf/XFpZeP5oTubY6snFkfTAJmpDIWY4K/ZVhQX8yLzqFnocRndYpSDHqCn7QbcKpalZcs6vKP5pJUsZl4SuX/3DhOQ1Sn2oHzfpUyVo6TB4+CuZ4fZppnbr4b+/A1kIphHkIMWkNgICnpvS25Yxc4XwtK31ytIa6Ngt2GK5pb6Wh5CS2gRWMWKC6qyexFhZR0UA9ZnczIzp1Y8YuLaTVqk5ZHH63IBqG0Z8pEpohIyyyaX80rmmv4MGwv89+VSCsRsR2+MeiMKe7YA/Gu1FlisLlpiO6Kw4w7P2N2f6JVBgtk/H9aLh3GfsgVHlfNHQzfN6zVXhRffk"}"#; #[test] fn golden_v1_ark_still_decrypts() { - let ark = AccountRootKey::try_from_bytes(&GOLDEN_V1_ARK_KEY).unwrap(); + let ark = AccountRootKey::from_bytes(&GOLDEN_V1_ARK_KEY).unwrap(); let backup = import(GOLDEN_V1_ARK, BackupCredential::Ark(&ark)).unwrap(); let vault = &backup.vaults[0]; let login = &vault.logins[0]; @@ -252,7 +235,7 @@ mod tests { #[test] fn ark_round_trip() { - let ark = AccountRootKey::try_from_bytes(&[5u8; 32]).unwrap(); + let ark = AccountRootKey::from_bytes(&[5u8; 32]).unwrap(); let original = sample_backup(); let json = export(&original, BackupCredential::Ark(&ark)).unwrap(); let restored = import(&json, BackupCredential::Ark(&ark)).unwrap(); @@ -271,8 +254,8 @@ mod tests { #[test] fn wrong_ark_fails() { - let right = AccountRootKey::try_from_bytes(&[5u8; 32]).unwrap(); - let wrong = AccountRootKey::try_from_bytes(&[6u8; 32]).unwrap(); + let right = AccountRootKey::from_bytes(&[5u8; 32]).unwrap(); + let wrong = AccountRootKey::from_bytes(&[6u8; 32]).unwrap(); let json = export(&sample_backup(), BackupCredential::Ark(&right)).unwrap(); let err = import(&json, BackupCredential::Ark(&wrong)).unwrap_err(); assert!(matches!( @@ -284,12 +267,15 @@ mod tests { #[test] fn empty_passphrase_is_rejected() { let err = export(&sample_backup(), BackupCredential::Passphrase(b"")).unwrap_err(); - assert!(matches!(err, BackupError::Crypto(CryptoError::KdfError(_)))); + assert!(matches!( + err, + BackupError::Crypto(CryptoError::EmptyPassword) + )); } #[test] fn credential_source_mismatch_fails() { - let ark = AccountRootKey::try_from_bytes(&[5u8; 32]).unwrap(); + let ark = AccountRootKey::from_bytes(&[5u8; 32]).unwrap(); let json = export(&sample_backup(), BackupCredential::Ark(&ark)).unwrap(); let err = import(&json, BackupCredential::Passphrase(b"x")).unwrap_err(); assert!(matches!(err, BackupError::CredentialMismatch)); @@ -323,8 +309,6 @@ mod tests { #[test] fn plaintext_envelope_is_rejected() { - // An envelope with no encryption header is no longer a shape this format admits: the - // field is required, so a plaintext file fails to parse rather than importing silently. let v = serde_json::json!({ "version": 1, "encryption": null, @@ -408,7 +392,7 @@ mod tests { #[test] fn inspect_reports_ark() { - let ark = AccountRootKey::try_from_bytes(&[5u8; 32]).unwrap(); + let ark = AccountRootKey::from_bytes(&[5u8; 32]).unwrap(); let json = export(&sample_backup(), BackupCredential::Ark(&ark)).unwrap(); assert!(matches!(inspect(&json).unwrap(), KeySource::Ark)); } diff --git a/rust/rust-code/core/src/backup/format/mod.rs b/rust/crates/keygo-core/src/backup/format/mod.rs similarity index 100% rename from rust/rust-code/core/src/backup/format/mod.rs rename to rust/crates/keygo-core/src/backup/format/mod.rs diff --git a/rust/rust-code/core/src/backup/key.rs b/rust/crates/keygo-core/src/backup/key.rs similarity index 71% rename from rust/rust-code/core/src/backup/key.rs rename to rust/crates/keygo-core/src/backup/key.rs index 4df86f847..28eeb4b41 100644 --- a/rust/rust-code/core/src/backup/key.rs +++ b/rust/crates/keygo-core/src/backup/key.rs @@ -1,16 +1,12 @@ -use crate::crypto::AccountRootKey; -use crate::crypto::KeyMaterial; -use crate::crypto::error::CryptoResult; -use crate::crypto::primitive::argon2::{Argon2Params, derive_argon2id_with_params}; -use crate::crypto::primitive::hkdf::derive_hkdf_sha256; -use crate::define_aead_key; -use aes_gcm_siv::Aes256GcmSiv; +use crate::crypto::kdf::argon2::{Argon2Params, derive_argon2id}; +use crate::crypto::kdf::hkdf::derive_hkdf_sha256; +use crate::crypto::{AccountRootKey, CryptoResult, SymmetricKey, symmetric_key}; const DOMAIN_PASSPHRASE: &[u8] = b"v1:backup/passphrase"; const DOMAIN_ARK: &[u8] = b"v1:backup/ark"; -define_aead_key! { - pub struct BackupKey(Aes256GcmSiv); +symmetric_key! { + pub struct BackupKey; } impl BackupKey { @@ -19,21 +15,18 @@ impl BackupKey { salt: &[u8], params: Argon2Params, ) -> CryptoResult { - let derived = derive_argon2id_with_params(passphrase, salt, DOMAIN_PASSPHRASE, params)?; - Self::try_from_bytes(&derived) + let derived = derive_argon2id(passphrase, salt, DOMAIN_PASSPHRASE, params)?; + Self::from_bytes(derived.as_slice()) } pub(crate) fn from_ark(ark: &AccountRootKey, salt: &[u8]) -> CryptoResult { - let derived = derive_hkdf_sha256(ark.as_bytes(), salt, DOMAIN_ARK)?; - Self::try_from_bytes(&derived) + Self::from_bytes(derive_hkdf_sha256(ark.as_bytes(), salt, DOMAIN_ARK).as_slice()) } } #[cfg(test)] mod tests { use super::*; - use crate::crypto::AccountRootKey; - use crate::crypto::KeyMaterial; const SALT: &[u8] = &[3u8; 16]; @@ -69,7 +62,7 @@ mod tests { #[test] fn ark_key_is_deterministic() { - let ark = AccountRootKey::try_from_bytes(&[7u8; 32]).unwrap(); + let ark = AccountRootKey::from_bytes(&[7u8; 32]).unwrap(); let a = BackupKey::from_ark(&ark, SALT).unwrap(); let b = BackupKey::from_ark(&ark, SALT).unwrap(); assert_eq!(a.as_bytes(), b.as_bytes()); @@ -78,7 +71,7 @@ mod tests { #[test] fn passphrase_and_ark_paths_are_domain_separated() { let raw = [7u8; 32]; - let ark = AccountRootKey::try_from_bytes(&raw).unwrap(); + let ark = AccountRootKey::from_bytes(&raw).unwrap(); let pass = BackupKey::from_passphrase(&raw, SALT, Argon2Params::default()).unwrap(); let ark_key = BackupKey::from_ark(&ark, SALT).unwrap(); assert_ne!(pass.as_bytes(), ark_key.as_bytes()); diff --git a/rust/crates/keygo-core/src/backup/mod.rs b/rust/crates/keygo-core/src/backup/mod.rs new file mode 100644 index 000000000..2bd02fc64 --- /dev/null +++ b/rust/crates/keygo-core/src/backup/mod.rs @@ -0,0 +1,19 @@ +mod encryption; +mod error; +mod format; +mod key; +mod model; + +pub use encryption::{BackupCredential, KeySource}; +pub use error::BackupError; +pub use format::csv::{ + ColumnMapping, Confidence, CsvAnalysis, CsvColumn, ExportPreset, FieldConfidence, ImportReport, +}; +pub use format::{csv, json}; +pub use key::BackupKey; +pub use model::{Backup, Card, Login, Passkey, Vault}; + +pub const CURRENT_VERSION: u32 = 1; + +/// Oldest readable version. On a bump, keep older versions and their AAD layout readable. +pub const MIN_SUPPORTED_VERSION: u32 = 1; diff --git a/rust/rust-code/core/src/backup/model.rs b/rust/crates/keygo-core/src/backup/model.rs similarity index 71% rename from rust/rust-code/core/src/backup/model.rs rename to rust/crates/keygo-core/src/backup/model.rs index 00a65e8df..91da0c871 100644 --- a/rust/rust-code/core/src/backup/model.rs +++ b/rust/crates/keygo-core/src/backup/model.rs @@ -8,10 +8,6 @@ pub struct Backup { #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct Vault { pub name: String, - /// Names an icon from the client's own set; the format assigns it no meaning beyond a label to - /// hand back on restore. `default` covers producers that have none to give - CSV imports, and - /// backups written before the field existed, including the frozen v1 goldens - which read as an - /// empty string and let the client fall back to its own default. #[serde(default)] pub icon: String, pub logins: Vec, @@ -49,12 +45,8 @@ backup_item! { pub username: Option, pub password: Option, pub totp_secret: Option, - /// A login can be associated with several sites. `default` keeps pre-field backups - - /// including the frozen v1 goldens - readable. #[serde(default)] pub websites: Vec, - /// A login can hold several passkeys (one per RP). `default` keeps pre-field backups - - /// including the frozen v1 goldens - readable. #[serde(default)] pub passkeys: Vec, } diff --git a/rust/rust-code/core/src/card/expiration.rs b/rust/crates/keygo-core/src/card/expiration.rs similarity index 75% rename from rust/rust-code/core/src/card/expiration.rs rename to rust/crates/keygo-core/src/card/expiration.rs index 433524018..a38341b66 100644 --- a/rust/rust-code/core/src/card/expiration.rs +++ b/rust/crates/keygo-core/src/card/expiration.rs @@ -1,18 +1,11 @@ -//! `MM/YY` expiration-date formatting for text being typed or deleted live. - use super::digits_only; const SEPARATOR: char = '/'; -/// Applies expiration-date formatting to an edit, given the `previous` text and the `proposed` -/// text the user just produced. The separator is auto-inserted while typing forward; deletions -/// erase it together with adjacent digits so the user can never get stuck on `MM/`: -/// -/// - `12/34` -> `12/3` -> `12` -> `1` -> `` (each backspace removes one visible character) -/// - deleting the separator itself also drops the preceding month digit (`12/` -> `1`, `05/` -> `0`) +/// Formats an edit live. Each backspace removes one visible char and never sticks on `MM/`. pub fn format_expiration_after_edit(previous: &str, proposed: &str) -> String { let deleting = proposed.chars().count() < previous.chars().count(); - // Backspacing the separator leaves the bare month behind; take the month digit with it. + // Deleting the separator also takes the month digit. let deleted_separator = deleting && previous.ends_with(SEPARATOR) && !proposed.ends_with(SEPARATOR); let source = if deleted_separator { @@ -25,14 +18,7 @@ pub fn format_expiration_after_edit(previous: &str, proposed: &str) -> String { format_expiration_inner(&source, !deleting) } -/// Reformats arbitrary `input` into a partial-or-complete `MM/YY` expiration date. -/// -/// - A leading digit greater than `1` is padded into a complete single-digit month (`5` -> `05`). -/// - A second month digit that would form an invalid month is dropped (`13` -> `1`, `00` -> `0`). -/// - The separator follows a complete month; the year is capped at two digits. -/// -/// `append_trailing_separator` controls whether a complete month with no year yet renders as -/// `MM/` (typing forward) or plain `MM` (deleting, so the user can erase back into the month). +/// `append_trailing_separator` renders a lone month as `MM/` (typing) or `MM` (deleting). fn format_expiration_inner(input: &str, append_trailing_separator: bool) -> String { let digits = digits_only(input); let Some(first) = digits.chars().next() else { @@ -40,7 +26,7 @@ fn format_expiration_inner(input: &str, append_trailing_separator: bool) -> Stri }; let (month, year_digits): (String, &str) = if first > '1' { - // 2..=9 can only be a single-digit month, so pad it and treat it as complete. + // 2..=9 can only be a one-digit month. (format!("0{first}"), &digits[1..]) } else { let second = digits[1..].chars().next(); @@ -122,7 +108,6 @@ mod tests { #[test] fn expiration_edit_deletes_year_digits_then_the_separator_with_the_month() { - // 12/34 -> 12/3 -> 12 -> 1 -> "" (one visible char per backspace) assert_eq!(format_expiration_after_edit("12/34", "12/3"), "12/3"); assert_eq!(format_expiration_after_edit("12/3", "12/"), "12"); assert_eq!(format_expiration_after_edit("12/", "12"), "1"); @@ -132,7 +117,6 @@ mod tests { #[test] fn expiration_edit_collapses_a_padded_month_on_separator_deletion() { - // 05/ -> delete -> 0 assert_eq!(format_expiration_after_edit("05/", "05"), "0"); } } diff --git a/rust/crates/keygo-core/src/card/mod.rs b/rust/crates/keygo-core/src/card/mod.rs new file mode 100644 index 000000000..95036fa22 --- /dev/null +++ b/rust/crates/keygo-core/src/card/mod.rs @@ -0,0 +1,13 @@ +mod expiration; +mod network; +mod number; + +pub use expiration::format_expiration_after_edit; +pub use network::CardNetwork; +pub use number::Card; + +pub const MAX_PAN_DIGITS: usize = 19; + +fn digits_only(s: &str) -> String { + s.chars().filter(char::is_ascii_digit).collect() +} diff --git a/rust/rust-code/core/src/card/network.rs b/rust/crates/keygo-core/src/card/network.rs similarity index 79% rename from rust/rust-code/core/src/card/network.rs rename to rust/crates/keygo-core/src/card/network.rs index e3244ed32..e7087402e 100644 --- a/rust/rust-code/core/src/card/network.rs +++ b/rust/crates/keygo-core/src/card/network.rs @@ -1,6 +1,3 @@ -//! Card-network identification from the leading digits (the IIN/BIN) and the -//! per-network metadata that drives capping, validation, and grouping. - use super::{MAX_PAN_DIGITS, digits_only}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -17,18 +14,10 @@ pub enum CardNetwork { } impl CardNetwork { - /// Detect the network from a (possibly dirty) number's leading digits. pub fn detect(number: &str) -> CardNetwork { Self::from_digits(&digits_only(number)) } - /// Detect the network from already-cleaned `digits` by IIN range. More - /// specific ranges are checked before broader ones so overlapping prefixes - /// resolve correctly. - /// - /// Callers that have already stripped non-digits (such as - /// [`Card::parse`](crate::card::Card::parse)) use this directly to avoid a - /// redundant clean; [`CardNetwork::detect`] is the dirty-input wrapper. pub(super) fn from_digits(digits: &str) -> CardNetwork { use CardNetwork::*; if digits.is_empty() { @@ -39,7 +28,7 @@ impl CardNetwork { if matches!(prefix(digits, 2), Some(34 | 37)) { return Amex; } - // JCB: 3528-3589 (checked before the broader Diners "3" ranges) + // JCB: 3528-3589, before Diners if matches!(prefix(digits, 4), Some(3528..=3589)) { return Jcb; } @@ -59,8 +48,7 @@ impl CardNetwork { { return Mastercard; } - // Discover: 6011, 644-649, 65, and the 622126-622925 co-brand range - // (checked before UnionPay's broad "62"). + // Discover: 6011, 644-649, 65, 622126-622925 (before UnionPay) if matches!(prefix(digits, 4), Some(6011)) || matches!(prefix(digits, 3), Some(644..=649)) || matches!(prefix(digits, 2), Some(65)) @@ -72,7 +60,7 @@ impl CardNetwork { if matches!(prefix(digits, 2), Some(62 | 81)) { return UnionPay; } - // Maestro: a selection of the common debit ranges + // Maestro: common debit ranges if matches!(prefix(digits, 2), Some(50 | 56 | 57 | 58 | 63 | 67 | 69)) { return Maestro; } @@ -104,14 +92,11 @@ impl CardNetwork { CardNetwork::Jcb => &[16, 17, 18, 19], CardNetwork::UnionPay => &[16, 17, 18, 19], CardNetwork::Maestro => &[12, 13, 14, 15, 16, 17, 18, 19], - // Unrecognised IIN: accept the full plausible PAN range so an unknown network - // is still structurally validatable by length + Luhn (see `Card::is_valid`). + // Unknown IIN: any plausible length, so Luhn can still validate it. CardNetwork::Unknown => &[12, 13, 14, 15, 16, 17, 18, 19], } } - /// The longest number this network issues. Unknown networks fall back to the global - /// [`MAX_PAN_DIGITS`]. pub fn max_len(self) -> usize { self.valid_lengths() .iter() @@ -127,7 +112,6 @@ impl CardNetwork { } } - /// Group sizes used to space a number of `len` digits. pub(super) fn groups(self, len: usize) -> Vec { match self { CardNetwork::Amex => fit_pattern(&[4, 6, 5], len), @@ -141,9 +125,7 @@ fn prefix(digits: &str, n: usize) -> Option { digits.get(..n).and_then(|p| p.parse().ok()) } -/// Truncate a fixed grouping `pattern` (e.g. Amex's `[4, 6, 5]`) to cover exactly -/// `len` digits, so a partially typed number is spaced the same way it will be once -/// complete. Any digits beyond the pattern trail in fours as a safety net. +/// Cuts `pattern` to `len` digits so partial input groups like the full number. fn fit_pattern(pattern: &[usize], len: usize) -> Vec { let mut groups = Vec::new(); let mut remaining = len; @@ -213,7 +195,6 @@ mod tests { #[test] fn jcb_beats_diners_in_3500_range() { - // 3530 is JCB even though "3x" is otherwise Diners territory. assert_eq!(CardNetwork::detect("3530111333300000"), CardNetwork::Jcb); } diff --git a/rust/rust-code/core/src/card/number.rs b/rust/crates/keygo-core/src/card/number.rs similarity index 80% rename from rust/rust-code/core/src/card/number.rs rename to rust/crates/keygo-core/src/card/number.rs index 654cd847d..b6bd07d91 100644 --- a/rust/rust-code/core/src/card/number.rs +++ b/rust/crates/keygo-core/src/card/number.rs @@ -1,6 +1,3 @@ -//! The primary account number (PAN): parse "dirty" input into a cleaned, capped, -//! network-grouped [`Card`], and validate it. - use std::fmt; use super::digits_only; @@ -14,13 +11,10 @@ pub struct Card { } impl Card { - /// Parse a (possibly dirty) number: strip non-digits, detect the network, - /// cap to that network's maximum length, and group for display. pub fn parse(input: &str) -> Card { let cleaned = digits_only(input); let network = CardNetwork::from_digits(&cleaned); - // Capping never changes detection: the IIN lives in the first six - // digits, well within every network's maximum. + // The IIN is in the first six digits, so capping never changes detection. let digits: String = cleaned.chars().take(network.max_len()).collect(); let formatted = group(&digits, &network.groups(digits.len())); Card { @@ -30,8 +24,7 @@ impl Card { } } - /// Digit offsets at which a grouping space falls, e.g. `[4, 8, 12]` for a - /// 16-digit number. + /// Offsets of grouping spaces, e.g. `[4, 8, 12]`. pub fn space_indices(&self) -> Vec { let mut indices = Vec::new(); let mut pos = 0; @@ -74,10 +67,7 @@ impl Card { sum.is_multiple_of(10) } - /// Structurally valid: a length the (possibly [`Unknown`](CardNetwork::Unknown)) network - /// accepts and a passing Luhn check. The network is deliberately *not* gated on being - /// recognised: an unrecognised IIN is still acceptable as long as its length is plausible - /// and the checksum holds. + /// Valid length and Luhn; the network need not be recognised. pub fn is_valid(&self) -> bool { self.is_length_valid() && self.is_luhn_valid() } @@ -106,7 +96,6 @@ fn group(digits: &str, sizes: &[usize]) -> String { idx = end; } - // Safety net for any digits beyond the declared pattern. while idx < bytes.len() { out.push(' '); let end = (idx + 4).min(bytes.len()); @@ -151,7 +140,6 @@ mod tests { #[test] fn amex_groups_progressively_while_typing() { - // Prefix already identifies Amex, so partial input uses 4-6-5, not fours. assert_eq!(Card::parse("341234").formatted, "3412 34"); assert_eq!(Card::parse("3412345678").formatted, "3412 345678"); assert_eq!(Card::parse("34123456789012").formatted, "3412 345678 9012"); @@ -173,7 +161,6 @@ mod tests { #[test] fn caps_visa_at_nineteen() { - // 25 digits in -> Visa keeps its 19-digit maximum. let card = Card::parse("4111111111111111111111111"); assert_eq!(card.formatted, "4111 1111 1111 1111 111"); assert_eq!(card.digits.len(), 19); @@ -181,7 +168,6 @@ mod tests { #[test] fn caps_amex_at_fifteen() { - // 20 digits in, Amex prefix -> capped to 15 and grouped 4-6-5. let card = Card::parse("37828224631000512345"); assert_eq!(card.formatted, "3782 822463 10005"); assert_eq!(card.digits.len(), 15); @@ -194,7 +180,6 @@ mod tests { #[test] fn unknown_network_caps_at_global_max() { - // Unrecognised prefix -> falls back to MAX_PAN_DIGITS. assert_eq!( Card::parse("9999999999999999999999").digits.len(), MAX_PAN_DIGITS @@ -203,7 +188,6 @@ mod tests { #[test] fn card_digits_strips_and_caps() { - // Separators removed, no grouping, capped to the network maximum. assert_eq!( Card::parse("4111 1111-1111_1111").digits, "4111111111111111" @@ -211,7 +195,7 @@ mod tests { assert_eq!( Card::parse("37828224631000512345").digits, "378282246310005" - ); // Amex -> 15 + ); assert_eq!(Card::parse("").digits, ""); } @@ -221,9 +205,9 @@ mod tests { Card::parse("4111111111111111").space_indices(), vec![4, 8, 12] ); - assert_eq!(Card::parse("378282246310005").space_indices(), vec![4, 10]); // Amex 4-6-5 - assert_eq!(Card::parse("41111").space_indices(), vec![4]); // partial: "4111 1" - assert_eq!(Card::parse("411").space_indices(), Vec::::new()); // single group + assert_eq!(Card::parse("378282246310005").space_indices(), vec![4, 10]); + assert_eq!(Card::parse("41111").space_indices(), vec![4]); + assert_eq!(Card::parse("411").space_indices(), Vec::::new()); assert_eq!(Card::parse("").space_indices(), Vec::::new()); } @@ -251,20 +235,15 @@ mod tests { fn full_validation() { assert!(Card::parse("4111 1111 1111 1111").is_valid()); assert!(Card::parse("378282246310005").is_valid()); - // Visa prefix but wrong length -> not valid. assert!(!Card::parse("411111111111").is_valid()); - // Right length, but Luhn fails. assert!(!Card::parse("4111111111111112").is_valid()); } #[test] fn unknown_network_validates_on_length_and_luhn() { - // 9-prefix is an unrecognised network, but a plausible-length, Luhn-valid number is - // still accepted: validity is not gated on recognising the network. let card = Card::parse("9999999999999995"); assert_eq!(card.network, crate::card::CardNetwork::Unknown); assert!(card.is_valid()); - // 11 digits is shorter than any plausible PAN -> rejected on length alone. assert!(!Card::parse("99999999995").is_valid()); } } diff --git a/rust/crates/keygo-core/src/crypto/aad.rs b/rust/crates/keygo-core/src/crypto/aad.rs new file mode 100644 index 000000000..c5558f024 --- /dev/null +++ b/rust/crates/keygo-core/src/crypto/aad.rs @@ -0,0 +1,23 @@ +use crate::crypto::{CryptoResult, UserId, VaultId}; +use serde::Serialize; + +/// AADs are BCS-encoded; the encoding is part of the stored format. +pub trait Aad: Serialize {} + +macro_rules! aad { + ($(#[$meta:meta])* $vis:vis struct $name:ident { $($body:tt)* }) => { + $(#[$meta])* + #[derive(::serde::Serialize)] + $vis struct $name { $($body)* } + + impl $crate::crypto::Aad for $name {} + }; +} +pub(crate) use aad; + +impl Aad for UserId {} +impl Aad for VaultId {} + +pub(crate) fn encode_aad(aad: &impl Aad) -> CryptoResult> { + Ok(bcs::to_bytes(aad)?) +} diff --git a/rust/crates/keygo-core/src/crypto/cipher.rs b/rust/crates/keygo-core/src/crypto/cipher.rs new file mode 100644 index 000000000..7466abfbe --- /dev/null +++ b/rust/crates/keygo-core/src/crypto/cipher.rs @@ -0,0 +1,93 @@ +use crate::crypto::{CryptoError, CryptoResult}; +use aead::array::typenum::Unsigned; +use aead::{Aead, AeadCore, Generate, KeyInit, KeySizeUser, Nonce, Payload}; +use aes_gcm_siv::Aes256GcmSiv; +use std::marker::PhantomData; + +pub type Cipher = Aes256GcmSiv; +pub type CipherKey = aead::Key; + +pub const KEY_LEN: usize = <::KeySize as Unsigned>::USIZE; +pub const NONCE_LEN: usize = <::NonceSize as Unsigned>::USIZE; + +pub struct Sealed { + nonce: Nonce, + ciphertext: Vec, + content: PhantomData Box>, +} + +impl Sealed { + pub fn from_parts(nonce: &[u8], ciphertext: Vec) -> CryptoResult { + let nonce = + Nonce::::try_from(nonce).map_err(|_| CryptoError::InvalidNonceLength { + expected: NONCE_LEN, + got: nonce.len(), + })?; + Ok(Self { + nonce, + ciphertext, + content: PhantomData, + }) + } + + pub fn nonce(&self) -> &[u8] { + self.nonce.as_slice() + } + + pub fn ciphertext(&self) -> &[u8] { + &self.ciphertext + } + + /// `(nonce, ciphertext)` + pub fn into_parts(self) -> (Vec, Vec) { + (self.nonce.to_vec(), self.ciphertext) + } + + pub(crate) fn seal(key: &CipherKey, plaintext: &[u8], aad: &[u8]) -> Result { + let nonce = Nonce::::generate(); + let ciphertext = Cipher::new(key).encrypt( + &nonce, + Payload { + msg: plaintext, + aad, + }, + )?; + Ok(Self { + nonce, + ciphertext, + content: PhantomData, + }) + } + + pub(crate) fn open(&self, key: &CipherKey, aad: &[u8]) -> Result, aead::Error> { + Cipher::new(key).decrypt( + &self.nonce, + Payload { + msg: &self.ciphertext, + aad, + }, + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn from_parts_rejects_a_wrong_length_nonce() { + assert!(matches!( + Sealed::<[u8]>::from_parts(&[0; 8], vec![]), + Err(CryptoError::InvalidNonceLength { + expected: NONCE_LEN, + got: 8, + }) + )); + } + + #[test] + fn parts_round_trip() { + let sealed = Sealed::<[u8]>::from_parts(&[7; NONCE_LEN], vec![1, 2, 3]).unwrap(); + assert_eq!(sealed.into_parts(), (vec![7; NONCE_LEN], vec![1, 2, 3])); + } +} diff --git a/rust/crates/keygo-core/src/crypto/error.rs b/rust/crates/keygo-core/src/crypto/error.rs new file mode 100644 index 000000000..5a0351ebf --- /dev/null +++ b/rust/crates/keygo-core/src/crypto/error.rs @@ -0,0 +1,35 @@ +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum CryptoError { + #[error("AEAD encryption failed")] + EncryptionFailed, + #[error("AEAD decryption failed: ciphertext invalid or tampered")] + DecryptionFailed, + + #[error("Key wrap failed")] + KeyWrapFailed, + #[error("Key unwrap failed: wrong key or corrupted data")] + KeyUnwrapFailed, + + #[error("Invalid key length: expected {expected} bytes, got {got} bytes")] + InvalidKeyLength { expected: usize, got: usize }, + #[error("Invalid nonce length: expected {expected} bytes, got {got} bytes")] + InvalidNonceLength { expected: usize, got: usize }, + + #[error("AAD encoding failed: {0}")] + AadEncoding(#[from] bcs::Error), + + #[error("Password must not be empty")] + EmptyPassword, + #[error("Salt too short: expected at least {min} bytes, got {got} bytes")] + SaltTooShort { min: usize, got: usize }, + #[error("Argon2 memory cost too high: {got} KiB exceeds the {max} KiB limit")] + Argon2MemoryTooHigh { max: u32, got: u32 }, + #[error("Argon2 iteration count too high: {got} exceeds the {max} limit")] + Argon2ItersTooHigh { max: u32, got: u32 }, + #[error("Argon2 failed: {0}")] + Argon2(argon2::Error), +} + +pub type CryptoResult = Result; diff --git a/rust/crates/keygo-core/src/crypto/golden_tests.rs b/rust/crates/keygo-core/src/crypto/golden_tests.rs new file mode 100644 index 000000000..8a5059fc4 --- /dev/null +++ b/rust/crates/keygo-core/src/crypto/golden_tests.rs @@ -0,0 +1,118 @@ +//! Shipped ciphertexts under fixed keys. They must keep opening; regenerate only with a migration. + +use crate::crypto::{ + AccountRootKey, Encrypts, ItemAad, ItemDataAad, ItemId, ItemKey, NamespaceDataAad, NamespaceId, + NamespaceKey, NamespaceKeyAad, RootKEK, Sealed, SecretKind, SymmetricKey, UserId, VaultId, + VaultKey, Wraps, +}; +use uuid::Uuid; + +const USER_ID: UserId = UserId(Uuid::from_u128(0x1111_1111_1111_4111_8111_1111_1111_1111)); +const VAULT_ID: VaultId = VaultId(Uuid::from_u128(0x2222_2222_2222_4222_8222_2222_2222_2222)); +const ITEM_ID: ItemId = ItemId(Uuid::from_u128(0x3333_3333_3333_4333_8333_3333_3333_3333)); +const NAMESPACE_ID: NamespaceId = + NamespaceId(Uuid::from_u128(0x5555_5555_5555_4555_8555_5555_5555_5555)); + +const KEK: [u8; 32] = [4; 32]; +const ARK: [u8; 32] = [1; 32]; +const VAULT_KEY: [u8; 32] = [2; 32]; +const ITEM_KEY: [u8; 32] = [3; 32]; +const NAMESPACE_KEY: [u8; 32] = [5; 32]; + +const WRAPPED_ARK: (&str, &str) = ( + "U/SS+Svk6Yv/KYpM", + "Y5MbYTqChWfQdtQGMsql2ktBdsR8/fpaSNV5rL46+4Fplqw1uE2wvIaeFnDMGoF+", +); +const WRAPPED_VAULT_KEY: (&str, &str) = ( + "zBY65MRmoCKkpnqw", + "A71Cm/YeTWJcwR7jTPZ5STYXH6ivVDkxGBKqh4EYgFaUBUFX5785SDcIXLh9A9j6", +); +const WRAPPED_ITEM_KEY: (&str, &str) = ( + "JynhAutrXQRAHS1c", + "D58m240ktQyE5IheW9sB4zFcdZY0T7zQ33qzrIWY9Iq6HkhM6weLprmSPQ2//RbA", +); +/// Sealed by the 2.1.0 path, with the AAD built by hand as `ITEM_ID || "password"`. +const ITEM_DATA: (&str, &str) = ( + "EPAic05x44CkZoRK", + "OPtIsA8vfGNIVOsCp2wM+wtEQFJkhvA5X4v+js1gKio=", +); +const ITEM_DATA_PLAINTEXT: &[u8] = b"secret item data"; +/// 2.1.0 records used another layout and do not open; they hold only a recomputable cache. +const WRAPPED_NAMESPACE_KEY: (&str, &str) = ( + "ZBYHD4Ba5f8x0Iqp", + "WlgyNZ7gqtj96qSp69IwZjvaRteVojcjS21Jlu0pCv7kMB/SHDQ5DXw9TCOniyo8", +); +const ACCOUNT_RECORD: (&str, &str) = ( + "jnjYy46BgaZ9dpz0", + "AsaNIah7v0htQHg4v6FjcqDp/jmqVuEydndsjgKE8BCNKcPwaw==", +); +const ACCOUNT_RECORD_PLAINTEXT: &[u8] = b"secret account record"; + +fn sealed((nonce, ciphertext): (&str, &str)) -> Sealed { + Sealed::from_parts( + &crate::b64::decode(nonce).unwrap(), + crate::b64::decode(ciphertext).unwrap(), + ) + .unwrap() +} + +#[test] +fn password_wrapped_ark_still_unwraps() { + let kek = RootKEK::from_bytes(&KEK).unwrap(); + let ark = kek.unwrap_key(&sealed(WRAPPED_ARK), &USER_ID).unwrap(); + assert_eq!(ark.as_bytes(), ARK); +} + +#[test] +fn vault_key_still_unwraps() { + let ark = AccountRootKey::from_bytes(&ARK).unwrap(); + let vault_key: VaultKey = ark + .unwrap_key(&sealed(WRAPPED_VAULT_KEY), &VAULT_ID) + .unwrap(); + assert_eq!(vault_key.as_bytes(), VAULT_KEY); +} + +#[test] +fn item_key_still_unwraps() { + let vault_key = VaultKey::from_bytes(&VAULT_KEY).unwrap(); + let aad = ItemAad { + item_id: ITEM_ID, + vault_id: VAULT_ID, + }; + let item_key = vault_key + .unwrap_key(&sealed(WRAPPED_ITEM_KEY), &aad) + .unwrap(); + assert_eq!(item_key.as_bytes(), ITEM_KEY); +} + +#[test] +fn item_data_still_decrypts() { + let item_key = ItemKey::from_bytes(&ITEM_KEY).unwrap(); + let aad = ItemDataAad { + item_id: ITEM_ID, + kind: SecretKind::Password, + }; + let plaintext = item_key.decrypt(&sealed(ITEM_DATA), &aad).unwrap(); + assert_eq!(plaintext, ITEM_DATA_PLAINTEXT); +} + +#[test] +fn account_record_still_opens() { + let ark = AccountRootKey::from_bytes(&ARK).unwrap(); + let key: NamespaceKey = ark + .unwrap_key( + &sealed(WRAPPED_NAMESPACE_KEY), + &NamespaceKeyAad { + namespace: NAMESPACE_ID, + }, + ) + .unwrap(); + assert_eq!(key.as_bytes(), NAMESPACE_KEY); + + let aad = NamespaceDataAad { + namespace: NAMESPACE_ID, + kind: SecretKind::PasswordHealthReport, + }; + let plaintext = key.decrypt(&sealed(ACCOUNT_RECORD), &aad).unwrap(); + assert_eq!(plaintext, ACCOUNT_RECORD_PLAINTEXT); +} diff --git a/rust/crates/keygo-core/src/crypto/ids.rs b/rust/crates/keygo-core/src/crypto/ids.rs new file mode 100644 index 000000000..52f1f7178 --- /dev/null +++ b/rust/crates/keygo-core/src/crypto/ids.rs @@ -0,0 +1,18 @@ +use serde::Serialize; +use uuid::Uuid; + +macro_rules! id { + ($($name:ident),+) => { + $(#[derive(Serialize, Clone, Copy, PartialEq, Eq, Hash, Debug)] + #[serde(transparent)] + pub struct $name(pub Uuid); + + impl $name { + pub fn new_v4() -> Self { + Self(Uuid::new_v4()) + } + })+ + }; +} + +id!(UserId, VaultId, ItemId, NamespaceId); diff --git a/rust/crates/keygo-core/src/crypto/kdf/argon2.rs b/rust/crates/keygo-core/src/crypto/kdf/argon2.rs new file mode 100644 index 000000000..1045de374 --- /dev/null +++ b/rust/crates/keygo-core/src/crypto/kdf/argon2.rs @@ -0,0 +1,182 @@ +use crate::crypto::{CryptoError, CryptoResult, KEY_LEN}; +use argon2::{Algorithm, Argon2, Params, Version}; +use zeroize::Zeroizing; + +pub(crate) const MIN_SALT_LEN: usize = 16; +pub(crate) const MAX_ARGON2_MEM_KIB: u32 = 256 * 1024; +pub(crate) const MAX_ARGON2_ITERS: u32 = 16; + +#[derive(Clone, Copy)] +pub(crate) struct Argon2Params { + /// Memory cost in KiB + pub mem_kib: u32, + pub iters: u32, + pub lanes: u32, +} + +impl Default for Argon2Params { + fn default() -> Self { + Self { + mem_kib: 64 * 1024, + iters: 3, + lanes: 4, + } + } +} + +impl Argon2Params { + fn build(self) -> CryptoResult> { + if self.mem_kib > MAX_ARGON2_MEM_KIB { + return Err(CryptoError::Argon2MemoryTooHigh { + max: MAX_ARGON2_MEM_KIB, + got: self.mem_kib, + }); + } + if self.iters > MAX_ARGON2_ITERS { + return Err(CryptoError::Argon2ItersTooHigh { + max: MAX_ARGON2_ITERS, + got: self.iters, + }); + } + let params = Params::new(self.mem_kib, self.iters, self.lanes, Some(KEY_LEN)) + .map_err(CryptoError::Argon2)?; + Ok(Argon2::new(Algorithm::Argon2id, Version::V0x13, params)) + } +} + +/// Argon2id. `domain` is appended to the salt to separate derivations. +/// Rejects costs above `MAX_ARGON2_MEM_KIB` or `MAX_ARGON2_ITERS`; the caller zeroizes the password. +pub(crate) fn derive_argon2id( + password: &[u8], + salt: &[u8], + domain: &[u8], + params: Argon2Params, +) -> CryptoResult> { + if password.is_empty() { + return Err(CryptoError::EmptyPassword); + } + if salt.len() < MIN_SALT_LEN { + return Err(CryptoError::SaltTooShort { + min: MIN_SALT_LEN, + got: salt.len(), + }); + } + + let full_salt = [salt, domain].concat(); + let mut out = Zeroizing::new([0u8; KEY_LEN]); + params + .build()? + .hash_password_into(password, &full_salt, out.as_mut_slice()) + .map_err(CryptoError::Argon2)?; + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + + const PW: &[u8] = b"correct horse battery staple"; + const SALT: [u8; 16] = [0x11; 16]; + + fn derive(password: &[u8], salt: &[u8], domain: &[u8]) -> CryptoResult<[u8; KEY_LEN]> { + derive_argon2id(password, salt, domain, Argon2Params::default()).map(|k| *k) + } + + #[test] + fn deterministic_for_same_inputs() { + assert_eq!( + derive(PW, &SALT, b"pwd").unwrap(), + derive(PW, &SALT, b"pwd").unwrap() + ); + } + + #[test] + fn different_salt_different_key() { + assert_ne!( + derive(PW, &SALT, b"pwd").unwrap(), + derive(PW, &[0x22; 16], b"pwd").unwrap() + ); + } + + #[test] + fn different_domain_different_key() { + assert_ne!( + derive(PW, &SALT, b"v1:kek/pwd").unwrap(), + derive(PW, &SALT, b"v2:kek/pwd").unwrap() + ); + } + + #[test] + fn rejects_empty_password() { + assert!(matches!( + derive(b"", &SALT, b"pwd"), + Err(CryptoError::EmptyPassword) + )); + } + + #[test] + fn rejects_short_salt() { + assert!(matches!( + derive(PW, &[0u8; 8], b"pwd"), + Err(CryptoError::SaltTooShort { min: 16, got: 8 }) + )); + } + + #[test] + fn different_params_different_key() { + let custom = Argon2Params { + iters: Argon2Params::default().iters + 1, + ..Argon2Params::default() + }; + assert_ne!( + *derive_argon2id(PW, &SALT, b"pwd", custom).unwrap(), + derive(PW, &SALT, b"pwd").unwrap() + ); + } + + #[test] + fn rejects_excessive_memory() { + let params = Argon2Params { + mem_kib: MAX_ARGON2_MEM_KIB + 1, + ..Argon2Params::default() + }; + assert!(matches!( + derive_argon2id(PW, &SALT, b"pwd", params), + Err(CryptoError::Argon2MemoryTooHigh { .. }) + )); + } + + #[test] + fn rejects_excessive_iterations() { + let params = Argon2Params { + iters: MAX_ARGON2_ITERS + 1, + ..Argon2Params::default() + }; + assert!(matches!( + derive_argon2id(PW, &SALT, b"pwd", params), + Err(CryptoError::Argon2ItersTooHigh { + max: MAX_ARGON2_ITERS, + got, + }) if got == MAX_ARGON2_ITERS + 1 + )); + } + + #[test] + fn default_params_are_within_the_limits() { + let params = Argon2Params::default(); + assert!(params.mem_kib <= MAX_ARGON2_MEM_KIB); + assert!(params.iters <= MAX_ARGON2_ITERS); + } + + #[test] + fn rejects_degenerate_params() { + let params = Argon2Params { + iters: 0, + ..Argon2Params::default() + }; + assert!(matches!( + derive_argon2id(PW, &SALT, b"pwd", params), + Err(CryptoError::Argon2(_)) + )); + } +} diff --git a/rust/crates/keygo-core/src/crypto/kdf/hkdf.rs b/rust/crates/keygo-core/src/crypto/kdf/hkdf.rs new file mode 100644 index 000000000..968ba2851 --- /dev/null +++ b/rust/crates/keygo-core/src/crypto/kdf/hkdf.rs @@ -0,0 +1,52 @@ +use crate::crypto::KEY_LEN; +use hkdf::Hkdf; +use sha2::Sha256; +use zeroize::Zeroizing; + +pub(crate) fn derive_hkdf_sha256(ikm: &[u8], salt: &[u8], info: &[u8]) -> Zeroizing<[u8; KEY_LEN]> { + let mut out = Zeroizing::new([0u8; KEY_LEN]); + Hkdf::::new(Some(salt), ikm) + .expand(info, out.as_mut_slice()) + .expect("KEY_LEN is far below HKDF-SHA256's 8160 byte output limit"); + out +} + +#[cfg(test)] +mod tests { + use super::*; + + const IKM: &[u8] = &[7u8; 32]; + const SALT: &[u8] = &[0x11; 16]; + + #[test] + fn deterministic_for_same_inputs() { + assert_eq!( + *derive_hkdf_sha256(IKM, SALT, b"info"), + *derive_hkdf_sha256(IKM, SALT, b"info") + ); + } + + #[test] + fn different_salt_different_key() { + assert_ne!( + *derive_hkdf_sha256(IKM, SALT, b"info"), + *derive_hkdf_sha256(IKM, &[0x22; 16], b"info") + ); + } + + #[test] + fn different_info_different_key() { + assert_ne!( + *derive_hkdf_sha256(IKM, SALT, b"info-a"), + *derive_hkdf_sha256(IKM, SALT, b"info-b") + ); + } + + #[test] + fn different_ikm_different_key() { + assert_ne!( + *derive_hkdf_sha256(IKM, SALT, b"info"), + *derive_hkdf_sha256(&[8u8; 32], SALT, b"info") + ); + } +} diff --git a/rust/crates/keygo-core/src/crypto/kdf/mod.rs b/rust/crates/keygo-core/src/crypto/kdf/mod.rs new file mode 100644 index 000000000..14b02cc1b --- /dev/null +++ b/rust/crates/keygo-core/src/crypto/kdf/mod.rs @@ -0,0 +1,10 @@ +pub(crate) mod argon2; +pub(crate) mod hkdf; + +use aead::Generate; + +pub(crate) const SALT_LEN: usize = 16; + +pub(crate) fn new_salt() -> [u8; SALT_LEN] { + <[u8; SALT_LEN]>::generate() +} diff --git a/rust/crates/keygo-core/src/crypto/key.rs b/rust/crates/keygo-core/src/crypto/key.rs new file mode 100644 index 000000000..617e6df8f --- /dev/null +++ b/rust/crates/keygo-core/src/crypto/key.rs @@ -0,0 +1,188 @@ +use crate::crypto::aad::encode_aad; +use crate::crypto::{Aad, CipherKey, CryptoError, CryptoResult, Sealed}; +use zeroize::{ZeroizeOnDrop, Zeroizing}; + +pub trait SymmetricKey: Sized + ZeroizeOnDrop { + fn from_bytes(bytes: &[u8]) -> CryptoResult; + + fn as_bytes(&self) -> &[u8] { + self.cipher_key().as_slice() + } + + #[doc(hidden)] + fn cipher_key(&self) -> &CipherKey; +} + +pub trait Wraps: SymmetricKey { + type Aad: Aad; + + fn wrap_key(&self, key: &K, aad: &Self::Aad) -> CryptoResult> { + Sealed::seal(self.cipher_key(), key.as_bytes(), &encode_aad(aad)?) + .map_err(|_| CryptoError::KeyWrapFailed) + } + + fn unwrap_key(&self, wrapped: &Sealed, aad: &Self::Aad) -> CryptoResult { + let bytes = wrapped + .open(self.cipher_key(), &encode_aad(aad)?) + .map(Zeroizing::new) + .map_err(|_| CryptoError::KeyUnwrapFailed)?; + K::from_bytes(&bytes) + } +} + +pub trait Encrypts: SymmetricKey { + type Aad: Aad; + + fn encrypt(&self, plaintext: &[u8], aad: &Self::Aad) -> CryptoResult> { + Sealed::seal(self.cipher_key(), plaintext, &encode_aad(aad)?) + .map_err(|_| CryptoError::EncryptionFailed) + } + + fn decrypt(&self, sealed: &Sealed<[u8]>, aad: &Self::Aad) -> CryptoResult> { + sealed + .open(self.cipher_key(), &encode_aad(aad)?) + .map_err(|_| CryptoError::DecryptionFailed) + } +} + +macro_rules! symmetric_key { + ($(#[$meta:meta])* $vis:vis struct $name:ident;) => { + $(#[$meta])* + #[derive(::zeroize::Zeroize, ::zeroize::ZeroizeOnDrop)] + $vis struct $name($crate::crypto::CipherKey); + + impl $crate::crypto::SymmetricKey for $name { + fn from_bytes(bytes: &[u8]) -> $crate::crypto::CryptoResult { + $crate::crypto::CipherKey::try_from(bytes).map(Self).map_err(|_| { + $crate::crypto::CryptoError::InvalidKeyLength { + expected: $crate::crypto::KEY_LEN, + got: bytes.len(), + } + }) + } + + fn cipher_key(&self) -> &$crate::crypto::CipherKey { + &self.0 + } + } + }; +} +pub(crate) use symmetric_key; + +#[cfg(test)] +mod tests { + use super::*; + use crate::crypto::aad; + + symmetric_key! { + struct TestWrappingKey; + } + symmetric_key! { + struct TestKey; + } + + aad! { + struct TestAad { + scope: &'static str, + } + } + + impl Wraps for TestWrappingKey { + type Aad = TestAad; + } + + impl Encrypts for TestKey { + type Aad = TestAad; + } + + fn wrapping_key(byte: u8) -> TestWrappingKey { + TestWrappingKey::from_bytes(&[byte; 32]).unwrap() + } + + fn key(byte: u8) -> TestKey { + TestKey::from_bytes(&[byte; 32]).unwrap() + } + + fn aad(scope: &'static str) -> TestAad { + TestAad { scope } + } + + #[test] + fn from_bytes_rejects_a_wrong_length() { + assert!(matches!( + TestKey::from_bytes(&[0; 16]), + Err(CryptoError::InvalidKeyLength { + expected: 32, + got: 16, + }) + )); + } + + #[test] + fn wrap_round_trip() { + let wrapped = wrapping_key(7).wrap_key(&key(1), &aad("rt")).unwrap(); + let unwrapped = wrapping_key(7).unwrap_key(&wrapped, &aad("rt")).unwrap(); + + assert_eq!(unwrapped.as_bytes(), key(1).as_bytes()); + } + + #[test] + fn unwrap_with_the_wrong_key_fails() { + let wrapped = wrapping_key(7).wrap_key(&key(1), &aad("k")).unwrap(); + + assert!(matches!( + wrapping_key(8).unwrap_key(&wrapped, &aad("k")), + Err(CryptoError::KeyUnwrapFailed) + )); + } + + #[test] + fn unwrap_with_the_wrong_aad_fails() { + let wrapped = wrapping_key(7).wrap_key(&key(1), &aad("right")).unwrap(); + + assert!(matches!( + wrapping_key(7).unwrap_key(&wrapped, &aad("wrong")), + Err(CryptoError::KeyUnwrapFailed) + )); + } + + #[test] + fn encrypt_round_trip() { + let sealed = key(1).encrypt(b"hello world", &aad("rt")).unwrap(); + + assert_eq!(key(1).decrypt(&sealed, &aad("rt")).unwrap(), b"hello world"); + } + + #[test] + fn decrypt_with_the_wrong_key_fails() { + let sealed = key(1).encrypt(b"data", &aad("k")).unwrap(); + + assert!(matches!( + key(2).decrypt(&sealed, &aad("k")), + Err(CryptoError::DecryptionFailed) + )); + } + + #[test] + fn decrypt_with_the_wrong_aad_fails() { + let sealed = key(1).encrypt(b"data", &aad("right")).unwrap(); + + assert!(matches!( + key(1).decrypt(&sealed, &aad("wrong")), + Err(CryptoError::DecryptionFailed) + )); + } + + #[test] + fn decrypt_of_a_tampered_ciphertext_fails() { + let sealed = key(1).encrypt(b"data", &aad("t")).unwrap(); + let (nonce, mut ciphertext) = sealed.into_parts(); + ciphertext[0] ^= 0x01; + let tampered = Sealed::from_parts(&nonce, ciphertext).unwrap(); + + assert!(matches!( + key(1).decrypt(&tampered, &aad("t")), + Err(CryptoError::DecryptionFailed) + )); + } +} diff --git a/rust/crates/keygo-core/src/crypto/keys.rs b/rust/crates/keygo-core/src/crypto/keys.rs new file mode 100644 index 000000000..946c55ab1 --- /dev/null +++ b/rust/crates/keygo-core/src/crypto/keys.rs @@ -0,0 +1,307 @@ +//! Key hierarchy and the AAD of each wrap: +//! +//! ```text +//! RootKEK --UserId--> AccountRootKey --VaultId--> VaultKey --ItemAad--> ItemKey --ItemDataAad--> item data +//! \ +//! --NamespaceKeyAad--> NamespaceKey --NamespaceDataAad--> account record +//! ``` + +use crate::crypto::kdf::argon2::{Argon2Params, derive_argon2id}; +use crate::crypto::{ + Aad, CipherKey, CryptoResult, Encrypts, ItemId, NamespaceId, SymmetricKey, UserId, VaultId, + Wraps, aad, symmetric_key, +}; +use aead::Generate; +use serde::{Serialize, Serializer}; + +/// Part of every password KEK's salt; must never change. +const PASSWORD_DOMAIN: &[u8] = b"v1:kek/pwd"; + +/// Part of every namespace-key wrap AAD; must never change. +const NAMESPACE_KEY_DOMAIN: &str = "v1:ns/key"; + +/// Part of every account-record AAD; must never change. +const NAMESPACE_DATA_DOMAIN: &str = "v1:ns/data"; + +symmetric_key! { + pub struct RootKEK; +} + +symmetric_key! { + pub struct AccountRootKey; +} + +symmetric_key! { + pub struct VaultKey; +} + +symmetric_key! { + pub struct ItemKey; +} + +symmetric_key! { + /// Seals one account-wide record that belongs to no vault. + pub struct NamespaceKey; +} + +aad! { + pub struct ItemAad { + pub item_id: ItemId, + pub vault_id: VaultId, + } +} + +/// The vault is left out on purpose: the item key's wrap binds it, so moving an item between +/// vaults rewraps only the item key. +pub struct ItemDataAad { + pub item_id: ItemId, + pub kind: SecretKind, +} + +/// One byte string, `item_id || label`: the layout 2.1.0 shipped, when callers built it by hand. +impl Serialize for ItemDataAad { + fn serialize(&self, serializer: S) -> Result { + let mut bytes = self.item_id.0.as_bytes().to_vec(); + bytes.extend_from_slice(self.kind.label().as_bytes()); + serializer.serialize_bytes(&bytes) + } +} + +impl Aad for ItemDataAad {} + +pub struct NamespaceKeyAad { + pub namespace: NamespaceId, +} + +impl Serialize for NamespaceKeyAad { + fn serialize(&self, serializer: S) -> Result { + (NAMESPACE_KEY_DOMAIN, self.namespace).serialize(serializer) + } +} + +impl Aad for NamespaceKeyAad {} + +pub struct NamespaceDataAad { + pub namespace: NamespaceId, + pub kind: SecretKind, +} + +impl Serialize for NamespaceDataAad { + fn serialize(&self, serializer: S) -> Result { + (NAMESPACE_DATA_DOMAIN, self.namespace, self.kind.label()).serialize(serializer) + } +} + +impl Aad for NamespaceDataAad {} + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum SecretKind { + Password, + CardNumber, + Cvv, + PasskeyPrivateKey, + TotpSecret, + PasswordHealthReport, +} + +impl SecretKind { + /// Part of every item-data AAD; must never change. + const fn label(self) -> &'static str { + match self { + Self::Password => "password", + Self::CardNumber => "credit_card_number", + Self::Cvv => "credit_card_cvv", + Self::PasskeyPrivateKey => "passkey_private_key", + Self::TotpSecret => "totp_secret", + Self::PasswordHealthReport => "password_health_report", + } + } +} + +impl Wraps for RootKEK { + type Aad = UserId; // TODO: bind the credential epoch +} + +impl Wraps for AccountRootKey { + type Aad = VaultId; // TODO: bind vault_key_epoch +} + +impl Wraps for VaultKey { + type Aad = ItemAad; +} + +impl Encrypts for ItemKey { + type Aad = ItemDataAad; +} + +impl Wraps for AccountRootKey { + type Aad = NamespaceKeyAad; +} + +impl Encrypts for NamespaceKey { + type Aad = NamespaceDataAad; +} + +impl RootKEK { + pub fn from_password(password: &[u8], salt: &[u8]) -> CryptoResult { + let derived = derive_argon2id(password, salt, PASSWORD_DOMAIN, Argon2Params::default())?; + Self::from_bytes(derived.as_slice()) + } +} + +macro_rules! impl_generate { + ($($key:ident),+) => { + $(impl $key { + pub fn generate() -> Self { + Self(CipherKey::generate()) + } + })+ + }; +} + +impl_generate!(AccountRootKey, VaultKey, ItemKey, NamespaceKey); + +#[cfg(test)] +mod tests { + use super::*; + use crate::crypto::Sealed; + + const SALT: &[u8] = &[6; 16]; + + #[test] + fn same_password_and_salt_give_the_same_kek() { + let a = RootKEK::from_password(b"hunter2", SALT).unwrap(); + let b = RootKEK::from_password(b"hunter2", SALT).unwrap(); + assert_eq!(a.as_bytes(), b.as_bytes()); + } + + #[test] + fn different_salts_give_different_keks() { + let a = RootKEK::from_password(b"hunter2", &[7; 16]).unwrap(); + let b = RootKEK::from_password(b"hunter2", SALT).unwrap(); + assert_ne!(a.as_bytes(), b.as_bytes()); + } + + /// Pins `PASSWORD_DOMAIN` and the Argon2 profile; drift locks out every account. + #[test] + fn password_domain_is_pinned() { + assert_eq!(PASSWORD_DOMAIN, b"v1:kek/pwd"); + + let kek = RootKEK::from_password(b"hunter2", &[7; 16]).unwrap(); + + assert_eq!( + kek.as_bytes(), + &[ + 243, 77, 26, 134, 177, 95, 102, 67, 54, 167, 232, 38, 115, 170, 132, 28, 98, 29, + 146, 108, 157, 245, 225, 131, 93, 9, 236, 235, 207, 6, 219, 103, + ][..] + ); + } + + /// Pins every label; drift makes that field undecryptable everywhere. + #[test] + fn secret_kind_labels_are_pinned() { + let labels = [ + SecretKind::Password, + SecretKind::CardNumber, + SecretKind::Cvv, + SecretKind::PasskeyPrivateKey, + SecretKind::TotpSecret, + SecretKind::PasswordHealthReport, + ] + .map(SecretKind::label); + + assert_eq!( + labels, + [ + "password", + "credit_card_number", + "credit_card_cvv", + "passkey_private_key", + "totp_secret", + "password_health_report", + ] + ); + } + + #[test] + fn item_data_aad_encodes_like_the_hand_built_bytes() { + let item_id = ItemId::new_v4(); + let mut hand_built = item_id.0.as_bytes().to_vec(); + hand_built.extend_from_slice(b"totp_secret"); + + let aad = ItemDataAad { + item_id, + kind: SecretKind::TotpSecret, + }; + + assert_eq!( + bcs::to_bytes(&aad).unwrap(), + bcs::to_bytes(&hand_built).unwrap() + ); + } + + const NAMESPACE: NamespaceId = NamespaceId(uuid::Uuid::from_u128(1)); + + /// Pins both namespace AAD layouts; drift makes every account record unreadable. + #[test] + fn namespace_aads_are_pinned() { + let id = NAMESPACE.0.as_bytes(); + let mut key_aad = vec![9]; + key_aad.extend_from_slice(b"v1:ns/key"); + key_aad.push(16); + key_aad.extend_from_slice(id); + + let mut data_aad = vec![10]; + data_aad.extend_from_slice(b"v1:ns/data"); + data_aad.push(16); + data_aad.extend_from_slice(id); + data_aad.push(22); + data_aad.extend_from_slice(b"password_health_report"); + + assert_eq!( + bcs::to_bytes(&NamespaceKeyAad { + namespace: NAMESPACE + }) + .unwrap(), + key_aad + ); + assert_eq!( + bcs::to_bytes(&NamespaceDataAad { + namespace: NAMESPACE, + kind: SecretKind::PasswordHealthReport, + }) + .unwrap(), + data_aad + ); + } + + #[test] + fn a_wrapped_vault_key_never_unwraps_as_a_namespace_key() { + let ark = AccountRootKey::generate(); + let wrapped = ark + .wrap_key(&VaultKey::generate(), &VaultId(NAMESPACE.0)) + .unwrap(); + let as_namespace_key = + Sealed::::from_parts(wrapped.nonce(), wrapped.ciphertext().to_vec()) + .unwrap(); + + assert!( + ark.unwrap_key( + &as_namespace_key, + &NamespaceKeyAad { + namespace: NAMESPACE + } + ) + .is_err() + ); + } + + #[test] + fn generated_keys_differ() { + assert_ne!( + VaultKey::generate().as_bytes(), + VaultKey::generate().as_bytes() + ); + } +} diff --git a/rust/crates/keygo-core/src/crypto/mod.rs b/rust/crates/keygo-core/src/crypto/mod.rs new file mode 100644 index 000000000..a026d9d93 --- /dev/null +++ b/rust/crates/keygo-core/src/crypto/mod.rs @@ -0,0 +1,22 @@ +pub(crate) mod aad; +mod cipher; +mod error; +mod ids; +pub(crate) mod kdf; +mod key; +mod keys; + +pub use aad::Aad; +pub(crate) use aad::aad; +pub use cipher::{Cipher, CipherKey, KEY_LEN, NONCE_LEN, Sealed}; +pub use error::{CryptoError, CryptoResult}; +pub use ids::{ItemId, NamespaceId, UserId, VaultId}; +pub(crate) use key::symmetric_key; +pub use key::{Encrypts, SymmetricKey, Wraps}; +pub use keys::{ + AccountRootKey, ItemAad, ItemDataAad, ItemKey, NamespaceDataAad, NamespaceKey, NamespaceKeyAad, + RootKEK, SecretKind, VaultKey, +}; + +#[cfg(test)] +mod golden_tests; diff --git a/rust/rust-code/core/src/lib.rs b/rust/crates/keygo-core/src/lib.rs similarity index 82% rename from rust/rust-code/core/src/lib.rs rename to rust/crates/keygo-core/src/lib.rs index 11ca2d60d..f6915a921 100644 --- a/rust/rust-code/core/src/lib.rs +++ b/rust/crates/keygo-core/src/lib.rs @@ -1,3 +1,5 @@ +#![forbid(unsafe_code)] + pub mod ark_session; mod b64; pub mod backup; diff --git a/rust/crates/keygo-core/src/passkey/authentication.rs b/rust/crates/keygo-core/src/passkey/authentication.rs new file mode 100644 index 000000000..5355f38f0 --- /dev/null +++ b/rust/crates/keygo-core/src/passkey/authentication.rs @@ -0,0 +1,73 @@ +use crate::passkey::authenticator::authenticator; +use crate::passkey::{PasskeyError, codec, origin, parse_request, to_json}; +use passkey::client::Client; +use passkey::types::webauthn::{CredentialRequestOptions, PublicKeyCredentialRequestOptions}; + +pub async fn authenticate( + request_json: &str, + stored_passkey: &[u8], + client_data_hash: Option>, +) -> Result { + let options: PublicKeyCredentialRequestOptions = parse_request(request_json)?; + let passkey = codec::decode(stored_passkey)?; + // TODO passkey-rs's Option store does not filter by rp_id yet (see its TODO); drop this once it + // does and refuses_to_sign_for_another_relying_party still passes without it + if options.rp_id.as_deref() != Some(passkey.rp_id.as_str()) { + return Err(PasskeyError::RpMismatch); + } + let origin = origin(options.rp_id.as_deref())?; + + let mut client = Client::new(authenticator(Some(passkey))); + let credential = client + .authenticate( + origin, + CredentialRequestOptions { + public_key: options, + }, + client_data_hash, + ) + .await + .map_err(PasskeyError::Webauthn)?; + + to_json(&credential) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::passkey::registration::register; + + const REGISTRATION: &str = r#"{ + "rp": { "id": "example.com", "name": "Example Corp" }, + "user": { "id": "AQIDBA", "name": "alice@example.com", "displayName": "Alice" }, + "challenge": "AAECAwQFBgcICQoLDA0ODw", + "pubKeyCredParams": [{ "type": "public-key", "alg": -7 }] + }"#; + + fn assertion_request(rp_id: &str) -> String { + format!(r#"{{ "challenge": "AAECAwQFBgcICQoLDA0ODw", "rpId": "{rp_id}" }}"#) + } + + #[tokio::test] + async fn signs_for_the_relying_party_it_was_registered_with() { + let stored = register(REGISTRATION).await.unwrap().private_key; + + assert!( + authenticate(&assertion_request("example.com"), &stored, None) + .await + .is_ok() + ); + } + + #[tokio::test] + async fn refuses_to_sign_for_another_relying_party() { + let stored = register(REGISTRATION).await.unwrap().private_key; + + let result = authenticate(&assertion_request("evil.com"), &stored, None).await; + + assert!( + matches!(result, Err(PasskeyError::RpMismatch)), + "{result:?}" + ); + } +} diff --git a/rust/rust-code/core/src/passkey/authenticator.rs b/rust/crates/keygo-core/src/passkey/authenticator.rs similarity index 61% rename from rust/rust-code/core/src/passkey/authenticator.rs rename to rust/crates/keygo-core/src/passkey/authenticator.rs index b2422567f..44fbbbea8 100644 --- a/rust/rust-code/core/src/passkey/authenticator.rs +++ b/rust/crates/keygo-core/src/passkey/authenticator.rs @@ -1,12 +1,14 @@ use passkey::authenticator::{Authenticator, CredentialStore, UserCheck, UserValidationMethod}; +use passkey::crypto::rust_crypto::RustCryptoBackend; use passkey::types::Passkey; use passkey::types::ctap2::{Aaguid, Ctap2Error}; use passkey_authenticator::UiHint; -pub(crate) struct KeyGoUserValidation {} +/// Always reports presence and verification: the app verifies the user first. +pub(super) struct PreVerifiedUser; #[async_trait::async_trait] -impl UserValidationMethod for KeyGoUserValidation { +impl UserValidationMethod for PreVerifiedUser { type PasskeyItem = Passkey; async fn check_user<'a>( @@ -30,10 +32,13 @@ impl UserValidationMethod for KeyGoUserValidation { } } -pub(crate) fn keygo_authenticator( +pub(super) fn authenticator( store: S, -) -> Authenticator { - let aaguid = Aaguid::new_empty(); - - Authenticator::new(aaguid, store, KeyGoUserValidation {}) +) -> Authenticator { + Authenticator::new( + Aaguid::new_empty(), + store, + PreVerifiedUser, + RustCryptoBackend, + ) } diff --git a/rust/crates/keygo-core/src/passkey/codec.rs b/rust/crates/keygo-core/src/passkey/codec.rs new file mode 100644 index 000000000..ab94faa05 --- /dev/null +++ b/rust/crates/keygo-core/src/passkey/codec.rs @@ -0,0 +1,120 @@ +//! Stored passkey format. The `V1` tag and [`StoredPasskey`] field names must not change. + +use coset::{CborSerializable, CoseError, CoseKey}; +use passkey::types::Passkey; +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +#[derive(Serialize, Deserialize)] +struct StoredPasskey { + key: Vec, + credential_id: Vec, + rp_id: String, + user_handle: Option>, + username: Option, + user_display_name: Option, + counter: Option, +} + +#[derive(Serialize, Deserialize)] +enum Versioned { + V1(StoredPasskey), +} + +#[derive(Debug, Error)] +pub enum CodecError { + #[error("cose key error: {0:?}")] + Cose(CoseError), + #[error("cbor encode error: {0}")] + CborEncode(#[from] ciborium::ser::Error), + #[error("cbor decode error: {0}")] + CborDecode(#[from] ciborium::de::Error), +} + +pub(crate) fn encode(passkey: Passkey) -> Result, CodecError> { + let stored = StoredPasskey { + key: passkey.key.to_vec().map_err(CodecError::Cose)?, + credential_id: passkey.credential_id.to_vec(), + rp_id: passkey.rp_id, + user_handle: passkey.user_handle.map(|b| b.to_vec()), + username: passkey.username, + user_display_name: passkey.user_display_name, + counter: passkey.counter, + }; + let mut buf = Vec::new(); + ciborium::into_writer(&Versioned::V1(stored), &mut buf)?; + Ok(buf) +} + +pub(crate) fn decode(bytes: &[u8]) -> Result { + let Versioned::V1(stored) = ciborium::from_reader(bytes)?; + Ok(Passkey { + key: CoseKey::from_slice(&stored.key).map_err(CodecError::Cose)?, + credential_id: stored.credential_id.into(), + rp_id: stored.rp_id, + user_handle: stored.user_handle.map(Into::into), + username: stored.username, + user_display_name: stored.user_display_name, + counter: stored.counter, + extensions: Default::default(), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use coset::CoseKeyBuilder; + use coset::iana; + + fn sample_passkey() -> Passkey { + let key = CoseKeyBuilder::new_okp_key() + .algorithm(iana::Algorithm::EdDSA) + .build(); + Passkey { + key, + credential_id: vec![1, 2, 3, 4].into(), + rp_id: "example.com".to_string(), + user_handle: Some(vec![9, 9, 9].into()), + username: Some("alice".to_string()), + user_display_name: Some("Alice".to_string()), + counter: Some(42), + extensions: Default::default(), + } + } + + const GOLDEN_V1: &str = "oWJWMadja2V5hRiiAQEDGCdtY3JlZGVudGlhbF9pZIQBAgMEZXJwX2lka2V4YW1wbGUuY29ta3VzZXJfaGFuZGxlgwkJCWh1c2VybmFtZWVhbGljZXF1c2VyX2Rpc3BsYXlfbmFtZWVBbGljZWdjb3VudGVyGCo="; + + #[test] + fn golden_v1_still_decodes() { + let original = sample_passkey(); + let decoded = decode(&crate::b64::decode(GOLDEN_V1).unwrap()).unwrap(); + + assert_eq!(decoded.key, original.key); + assert_eq!(decoded.credential_id, original.credential_id); + assert_eq!(decoded.rp_id, original.rp_id); + assert_eq!(decoded.user_handle, original.user_handle); + assert_eq!(decoded.username, original.username); + assert_eq!(decoded.user_display_name, original.user_display_name); + assert_eq!(decoded.counter, original.counter); + } + + #[test] + fn encoding_is_stable() { + assert_eq!( + crate::b64::encode(encode(sample_passkey()).unwrap()), + GOLDEN_V1 + ); + } + + #[test] + fn roundtrip_preserves_fields() { + let original = sample_passkey(); + let bytes = encode(original.clone()).expect("encode"); + let decoded = decode(&bytes).expect("decode"); + + assert_eq!(decoded.credential_id, original.credential_id); + assert_eq!(decoded.rp_id, original.rp_id); + assert_eq!(decoded.user_handle, original.user_handle); + assert_eq!(decoded.counter, original.counter); + } +} diff --git a/rust/crates/keygo-core/src/passkey/mod.rs b/rust/crates/keygo-core/src/passkey/mod.rs new file mode 100644 index 000000000..88a594664 --- /dev/null +++ b/rust/crates/keygo-core/src/passkey/mod.rs @@ -0,0 +1,43 @@ +mod authentication; +mod authenticator; +mod codec; +mod registration; + +pub use authentication::authenticate; +pub use codec::CodecError; +pub use registration::{CreationInfo, Registration, creation_info, register}; + +use crate::url::sanitize_to_https_url; +use passkey::client::WebauthnError; +use thiserror::Error; +use url::Url; + +#[derive(Debug, Error)] +pub enum PasskeyError { + #[error("invalid request json: {0}")] + InvalidRequest(#[source] serde_json::Error), + #[error("invalid relying party domain")] + InvalidDomain, + #[error("webauthn error: {0:?}")] + Webauthn(WebauthnError), + #[error("passkey codec error: {0}")] + Codec(#[from] CodecError), + #[error("could not encode the response: {0}")] + ResponseEncoding(#[source] serde_json::Error), + #[error("the authenticator stored no credential")] + NoCredentialCreated, + #[error("the passkey belongs to another relying party")] + RpMismatch, +} + +fn parse_request(json: &str) -> Result { + serde_json::from_str(json).map_err(PasskeyError::InvalidRequest) +} + +fn origin(rp_id: Option<&str>) -> Result { + sanitize_to_https_url(rp_id.unwrap_or_default()).map_err(|_| PasskeyError::InvalidDomain) +} + +fn to_json(response: &impl serde::Serialize) -> Result { + serde_json::to_string(response).map_err(PasskeyError::ResponseEncoding) +} diff --git a/rust/crates/keygo-core/src/passkey/registration.rs b/rust/crates/keygo-core/src/passkey/registration.rs new file mode 100644 index 000000000..3a5c8f065 --- /dev/null +++ b/rust/crates/keygo-core/src/passkey/registration.rs @@ -0,0 +1,86 @@ +use crate::passkey::authenticator::authenticator; +use crate::passkey::{PasskeyError, codec, origin, parse_request, to_json}; +use passkey::client::{Client, DefaultClientData}; +use passkey::types::webauthn::{CredentialCreationOptions, PublicKeyCredentialCreationOptions}; + +pub struct Registration { + pub response: String, + pub user_name: String, + pub user_display_name: String, + pub credential_id: Vec, + pub private_key: Vec, + pub rp: String, +} + +pub struct CreationInfo { + pub exclude_credentials: Vec>, + pub rp: String, +} + +pub fn creation_info(request_json: &str) -> Result { + let options: PublicKeyCredentialCreationOptions = parse_request(request_json)?; + Ok(CreationInfo { + exclude_credentials: options + .exclude_credentials + .unwrap_or_default() + .into_iter() + .map(|descriptor| descriptor.id.into()) + .collect(), + rp: options.rp.id.unwrap_or_default(), + }) +} + +pub async fn register(request_json: &str) -> Result { + let options: PublicKeyCredentialCreationOptions = parse_request(request_json)?; + let origin = origin(options.rp.id.as_deref())?; + let user_name = options.user.name.clone(); + let user_display_name = options.user.display_name.clone(); + + let mut client = Client::new(authenticator(None)); + let credential = client + .register( + origin, + CredentialCreationOptions { + public_key: options, + }, + DefaultClientData, + ) + .await + .map_err(PasskeyError::Webauthn)?; + + let passkey = client + .authenticator() + .store() + .clone() + .ok_or(PasskeyError::NoCredentialCreated)?; + + Ok(Registration { + response: to_json(&credential)?, + user_name, + user_display_name, + credential_id: passkey.credential_id.to_vec(), + rp: passkey.rp_id.clone(), + private_key: codec::encode(passkey)?, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + const REQUEST: &str = r#"{ + "rp": { "id": "example.com", "name": "Example Corp" }, + "user": { "id": "AQIDBA", "name": "alice@example.com", "displayName": "Alice" }, + "challenge": "AAECAwQFBgcICQoLDA0ODw", + "pubKeyCredParams": [{ "type": "public-key", "alg": -7 }] + }"#; + + #[tokio::test] + async fn registration_reports_the_users_name_not_the_relying_partys() { + let registration = register(REQUEST).await.unwrap(); + + assert_eq!(registration.user_name, "alice@example.com"); + assert_eq!(registration.user_display_name, "Alice"); + assert_eq!(registration.rp, "example.com"); + } +} diff --git a/rust/rust-code/core/src/totp.rs b/rust/crates/keygo-core/src/totp.rs similarity index 52% rename from rust/rust-code/core/src/totp.rs rename to rust/crates/keygo-core/src/totp.rs index 77675b595..774888031 100644 --- a/rust/rust-code/core/src/totp.rs +++ b/rust/crates/keygo-core/src/totp.rs @@ -1,154 +1,144 @@ use thiserror::Error; pub use totp_rs::Algorithm; -use totp_rs::{Builder, Secret, SecretParseError, Totp, TotpError as TOTPError}; +use totp_rs::{Builder, Secret, SecretParseError, Totp}; -/// A secret long enough to clear `Builder::build`'s 128 bit floor, swapped in -/// for the real one while validating. Plenty of providers hand out 80 bit -/// secrets and refusing those would make the app useless for them, so that -/// floor is the one rule we drop. +/// Stand-in that clears totp-rs's 128-bit floor; real 80-bit secrets are common. const SECRET_FLOOR_STANDIN: [u8; 16] = [0; 16]; #[derive(Debug, Error)] pub enum TotpError { - #[error("url error: {0}")] - Url(TOTPError), - #[error("secret parse error: {0}")] + #[error("invalid totp: {0}")] + Invalid(totp_rs::TotpError), + #[error("invalid secret: {0}")] Secret(SecretParseError), } -/// Every check `Builder::build` runs, with its 128 bit secret floor replaced by -/// "the secret must not be empty". -/// -/// The checks are deliberately not restated here. Copying them means the copy -/// silently rots whenever totp-rs adds one, which is exactly how the -/// step-of-zero check 6.0 introduced went missing: a `period=0` URI parsed -/// fine, and generating a code from it divided by zero. Building against a -/// stand-in secret keeps the rules the library's to define, including any it -/// adds later. -fn validate(totp: &Totp) -> Result<(), TotpError> { - if totp.secret().is_empty() { - return Err(TotpError::Url(TOTPError::SecretTooShort { bits: 0 })); - } - - Builder::new() - .with_algorithm(totp.algorithm()) - .with_digits(totp.digits()) - .with_skew(totp.skew()) - .with_step_duration(totp.step()) - .with_secret(SECRET_FLOOR_STANDIN) - .with_issuer(totp.issuer()) - .with_account_name(totp.account_name()) - .build() - .map(|_| ()) - .map_err(TotpError::Url) -} - -fn build_totp( - algorithm: Algorithm, - digits: u8, - step: u64, - secret: String, - issuer: Option, - account_name: String, -) -> Result { - let secret = Secret::try_from_base32(secret).map_err(TotpError::Secret)?; - let totp = Builder::new() - .with_algorithm(algorithm) - .with_digits(digits) - .with_skew(1) - .with_step_duration(step) - .with_secret(secret) - .with_issuer(issuer) - .with_account_name(account_name) - .build_noncompliant(); - validate(&totp)?; - - Ok(totp) -} - -pub fn get_totp( - algorithm: Algorithm, - digits: u8, - step: u64, - secret: String, -) -> Result { - let totp = build_totp(algorithm, digits, step, secret, None, "".to_string())?; - Ok(totp.generate_current().to_string()) +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct TotpParams { + pub algorithm: Algorithm, + pub digits: u8, + /// Seconds each code is valid for. + pub period: u64, } pub struct TotpInfo { pub secret: String, pub issuer: Option, pub account_name: String, - pub algorithm: Algorithm, - pub digits: u8, - pub period: u64, + pub params: TotpParams, +} + +pub fn generate(params: TotpParams, secret: &str) -> Result { + Ok(build(params, secret, None, "")? + .generate_current() + .to_string()) +} + +pub fn to_uri( + params: TotpParams, + secret: &str, + issuer: Option<&str>, + account_name: &str, +) -> Result { + build(params, secret, issuer, account_name)? + .to_url() + .map_err(TotpError::Invalid) } -pub fn get_totp_info_from_uri(uri: String) -> Result { - let totp = Totp::from_url_unchecked(uri).map_err(TotpError::Url)?; +pub fn parse_uri(uri: &str) -> Result { + let totp = Totp::from_url_unchecked(uri).map_err(TotpError::Invalid)?; validate(&totp)?; Ok(TotpInfo { secret: totp.secret().to_base32(), issuer: totp.issuer().map(String::from), account_name: totp.account_name().to_string(), - algorithm: totp.algorithm(), - digits: totp.digits(), - period: totp.step(), + params: TotpParams { + algorithm: totp.algorithm(), + digits: totp.digits(), + period: totp.step(), + }, }) } -pub fn get_totp_url( - algorithm: Algorithm, - digits: u8, - step: u64, - secret: String, - issuer: Option, - account_name: String, -) -> Result { - let totp = build_totp(algorithm, digits, step, secret, issuer, account_name)?; - totp.to_url().map_err(TotpError::Url) +pub(crate) fn is_valid_totp_secret(s: &str) -> bool { + Secret::try_from_base32(s).is_ok() } -pub(crate) fn is_valid_totp_secret(s: &str) -> bool { - base32::decode(base32::Alphabet::Rfc4648 { padding: false }, s).is_some() +fn build( + params: TotpParams, + secret: &str, + issuer: Option<&str>, + account_name: &str, +) -> Result { + let secret = Secret::try_from_base32(secret).map_err(TotpError::Secret)?; + let totp = Builder::new() + .with_algorithm(params.algorithm) + .with_digits(params.digits) + .with_skew(1) + .with_step_duration(params.period) + .with_secret(secret) + .with_issuer(issuer) + .with_account_name(account_name) + .build_noncompliant(); + validate(&totp)?; + + Ok(totp) +} + +/// Runs totp-rs's own checks on a stand-in secret, dropping only the 128-bit floor. +/// Restating them would miss new ones, as with 6.0's `period=0` check. +fn validate(totp: &Totp) -> Result<(), TotpError> { + if totp.secret().is_empty() { + return Err(TotpError::Invalid(totp_rs::TotpError::SecretTooShort { + bits: 0, + })); + } + + Builder::new() + .with_algorithm(totp.algorithm()) + .with_digits(totp.digits()) + .with_skew(totp.skew()) + .with_step_duration(totp.step()) + .with_secret(SECRET_FLOOR_STANDIN) + .with_issuer(totp.issuer()) + .with_account_name(totp.account_name()) + .build() + .map(|_| ()) + .map_err(TotpError::Invalid) } #[cfg(test)] mod tests { use super::*; - /// 80 bits, the kind of secret services like GitHub hand out. const SHORT_SECRET: &str = "JBSWY3DPEHPK3PXP"; - /// 160 bits, the length RFC 4226 recommends. const LONG_SECRET: &str = "JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP"; + const SHA1_6_30: TotpParams = TotpParams { + algorithm: Algorithm::SHA1, + digits: 6, + period: 30, + }; + fn uri(secret: &str) -> String { format!("otpauth://totp/GitHub:alice?secret={secret}&issuer=GitHub") } fn url_with_issuer(issuer: &str) -> Result { - get_totp_url( - Algorithm::SHA1, - 6, - 30, - SHORT_SECRET.to_string(), - Some(issuer.to_string()), - "alice".to_string(), - ) + to_uri(SHA1_6_30, SHORT_SECRET, Some(issuer), "alice") } #[test] fn generates_code_for_secret_below_128_bits() { - let code = get_totp(Algorithm::SHA1, 6, 30, SHORT_SECRET.to_string()).unwrap(); + let code = generate(SHA1_6_30, SHORT_SECRET).unwrap(); assert_eq!(6, code.len()); } #[test] fn parses_uri_regardless_of_secret_length() { - let short = get_totp_info_from_uri(uri(SHORT_SECRET)).unwrap(); - let long = get_totp_info_from_uri(uri(LONG_SECRET)).unwrap(); + let short = parse_uri(&uri(SHORT_SECRET)).unwrap(); + let long = parse_uri(&uri(LONG_SECRET)).unwrap(); assert_eq!(SHORT_SECRET, short.secret); assert_eq!(LONG_SECRET, long.secret); @@ -163,12 +153,21 @@ mod tests { #[test] fn rejects_empty_secret() { - assert!(get_totp(Algorithm::SHA1, 6, 30, "".to_string()).is_err()); + assert!(generate(SHA1_6_30, "").is_err()); } #[test] fn rejects_digits_outside_rfc_range() { - assert!(get_totp(Algorithm::SHA1, 9, 30, SHORT_SECRET.to_string()).is_err()); + assert!( + generate( + TotpParams { + digits: 9, + ..SHA1_6_30 + }, + SHORT_SECRET + ) + .is_err() + ); } #[test] @@ -178,48 +177,42 @@ mod tests { #[test] fn rejects_uri_without_secret() { - assert!(get_totp_info_from_uri(uri("")).is_err()); + assert!(parse_uri(&uri("")).is_err()); } #[test] fn rejects_secret_that_is_not_base32() { - assert!(get_totp(Algorithm::SHA1, 6, 30, "not base32!".to_string()).is_err()); + assert!(generate(SHA1_6_30, "not base32!").is_err()); } #[test] fn rejects_step_of_zero() { - assert!(get_totp(Algorithm::SHA1, 6, 0, SHORT_SECRET.to_string()).is_err()); + assert!( + generate( + TotpParams { + period: 0, + ..SHA1_6_30 + }, + SHORT_SECRET + ) + .is_err() + ); } - /// A step of zero used to reach `generate`, where dividing the timestamp by - /// it panicked. Anyone who can hand the app a URI picks the period, so this - /// has to fail as an error rather than take the process down. #[test] fn rejects_uri_with_period_of_zero() { let uri = format!("otpauth://totp/GitHub:alice?secret={SHORT_SECRET}&period=0"); - assert!(get_totp_info_from_uri(uri).is_err()); + assert!(parse_uri(&uri).is_err()); } - /// totp-rs 6.0 made an empty account name an error rather than emitting - /// `otpauth://totp/?secret=...` the way 5.x did. The label is required by - /// the otpauth format, so the caller has to supply one. #[test] fn requires_account_name_for_url() { - let url = get_totp_url( - Algorithm::SHA1, - 6, - 30, - SHORT_SECRET.to_string(), - Some("GitHub".to_string()), - "".to_string(), - ); + let url = to_uri(SHA1_6_30, SHORT_SECRET, Some("GitHub"), ""); assert!(url.is_err()); } - /// The RFC 6238 appendix B vectors, which pin the generated codes to known - /// answers across upgrades of the hashing crates underneath totp-rs. #[test] fn matches_rfc6238_test_vectors() { const TIMES: [u64; 6] = [ @@ -256,15 +249,12 @@ mod tests { ]; for (algorithm, secret, expected) in cases { - let totp = build_totp( + let params = TotpParams { algorithm, - 8, - 30, - secret.to_string(), - None, - "alice".to_string(), - ) - .unwrap(); + digits: 8, + period: 30, + }; + let totp = build(params, secret, None, "alice").unwrap(); for (time, expected) in TIMES.iter().zip(expected) { assert_eq!( diff --git a/rust/rust-code/core/src/url.rs b/rust/crates/keygo-core/src/url.rs similarity index 75% rename from rust/rust-code/core/src/url.rs rename to rust/crates/keygo-core/src/url.rs index ff4f6dbce..471018634 100644 --- a/rust/rust-code/core/src/url.rs +++ b/rust/crates/keygo-core/src/url.rs @@ -10,29 +10,20 @@ pub enum UrlSanitizeError { NonHttpScheme(String), } -/// Normalize to an http(s) Url and reject others: -/// - Accepts `http://` or `https://` (any case). -/// - If starts with `//`, prefixes `https:`. -/// - Otherwise prefixes `https://`. -/// - Explicitly rejects any `scheme://` whose scheme != http/https (e.g., ftp, ws, wss). -/// - Also rejects certain non-slash schemes like `mailto:`, `data:`, `javascript:`. pub fn sanitize_to_https_url(input: &str) -> Result { let mut input = input.trim().to_string(); if input.is_empty() { return Err(UrlSanitizeError::Empty); } - // 1) If it has "://", treat the part before as the scheme and gate it. if let Some(idx) = input.find("://") { let scheme = &input[..idx].to_ascii_lowercase(); if scheme != "http" && scheme != "https" { return Err(UrlSanitizeError::NonHttpScheme(scheme.to_string())); } } else { - // 2) Handle common non-slash schemes like "mailto:", "data:", "javascript:", "file:" - // (Do NOT treat "example.com:8080" as a scheme.) + // Colon-only schemes; "example.com:8080" is not one. if let Some(colon) = input.find(':') { - // Check only well-known colon-only schemes to avoid misclassifying host:port. let maybe_scheme = input[..colon].to_ascii_lowercase(); if matches!( maybe_scheme.as_str(), @@ -42,18 +33,15 @@ pub fn sanitize_to_https_url(input: &str) -> Result { } } - // 3) Scheme-relative? if input.starts_with("//") { input = format!("https:{input}"); } else if !input.starts_with("http://") && !input.starts_with("https://") { - // 4) No scheme: default to https input = format!("https://{input}"); } } let url = Url::parse(&input).map_err(|e| UrlSanitizeError::InvalidUrl(e.to_string()))?; - // Final guard (defensive) match url.scheme() { "http" | "https" => Ok(url), other => Err(UrlSanitizeError::NonHttpScheme(other.to_string())), diff --git a/rust/crates/keygo-uniffi/Cargo.toml b/rust/crates/keygo-uniffi/Cargo.toml new file mode 100644 index 000000000..178c003ce --- /dev/null +++ b/rust/crates/keygo-uniffi/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "keygo-uniffi" +version.workspace = true +edition.workspace = true + +[lib] +name = "keygo_bindings" +crate-type = ["cdylib"] + +[dependencies] +keygo-core = { path = "../keygo-core" } +thiserror.workspace = true +uuid.workspace = true + +tokio = { workspace = true, features = ["rt", "rt-multi-thread"] } +uniffi = { workspace = true, features = ["tokio"] } diff --git a/rust/rust-code/bindings/src/ark_session.rs b/rust/crates/keygo-uniffi/src/ark_session.rs similarity index 50% rename from rust/rust-code/bindings/src/ark_session.rs rename to rust/crates/keygo-uniffi/src/ark_session.rs index 4d2f42a81..7c2ed73c9 100644 --- a/rust/rust-code/bindings/src/ark_session.rs +++ b/rust/crates/keygo-uniffi/src/ark_session.rs @@ -1,10 +1,10 @@ +use crate::item::EncryptedItemBlob; use crate::key_wrap::{KeyWrapError, WrappedKeyBlob}; use keygo_core::ark_session::{ - ArkSession as CoreArkSession, ArkSessionError as CoreArkSessionError, + AccountRecord as CoreAccountRecord, ArkSession as CoreArkSession, + ArkSessionError as CoreArkSessionError, NewAccount as CoreNewAccount, PasswordWrappedArk, }; -use keygo_core::crypto::VaultKey; -use keygo_core::crypto::primitive::wrap_key::{AeadWrappedKey, WrappedKey}; -use keygo_core::crypto::types::{UserId, VaultId}; +use keygo_core::crypto::{CryptoError, NamespaceId, SecretKind, UserId, VaultId, VaultKey}; use std::sync::Arc; #[derive(Debug, thiserror::Error, uniffi::Error)] @@ -15,6 +15,8 @@ pub enum ArkSessionError { WrongPassword, #[error("Key derivation failed: {0}")] Derivation(String), + #[error("Decryption failed: ciphertext invalid or tampered")] + DecryptionFailed, #[error("{0}")] KeyWrap(#[from] KeyWrapError), } @@ -24,12 +26,19 @@ impl From for ArkSessionError { match value { CoreArkSessionError::Locked => Self::Locked, CoreArkSessionError::WrongPassword => Self::WrongPassword, - CoreArkSessionError::Derivation(msg) => Self::Derivation(msg), - CoreArkSessionError::KeyWrap(crypto_error) => Self::KeyWrap(crypto_error.into()), + CoreArkSessionError::Derivation(e) => Self::Derivation(e.to_string()), + CoreArkSessionError::KeyWrap(CryptoError::DecryptionFailed) => Self::DecryptionFailed, + CoreArkSessionError::KeyWrap(e) => e.into(), } } } +impl From for ArkSessionError { + fn from(value: CryptoError) -> Self { + Self::KeyWrap(value.into()) + } +} + #[derive(uniffi::Record)] pub struct NewAccount { pub user_id: UserId, @@ -39,30 +48,73 @@ pub struct NewAccount { pub wrapped_vault_key: WrappedKeyBlob, } +impl From for NewAccount { + fn from(account: CoreNewAccount) -> Self { + Self { + user_id: account.user_id, + salt: account.password_wrapped_ark.salt, + password_wrapped_ark: account.password_wrapped_ark.wrapped.into(), + vault_id: account.vault_id, + wrapped_vault_key: account.wrapped_vault_key.into(), + } + } +} + #[derive(uniffi::Record)] pub struct PasswordWrapped { pub salt: Vec, pub wrapped: WrappedKeyBlob, } -fn blob(wrapped: &impl WrappedKey) -> WrappedKeyBlob -where - T: keygo_core::crypto::KeyMaterial, - W: keygo_core::crypto::AeadKey, -{ - WrappedKeyBlob { - ciphertext: wrapped.ciphertext().to_vec(), - nonce: wrapped.nonce_bytes().to_vec(), +impl From for PasswordWrapped { + fn from(value: PasswordWrappedArk) -> Self { + Self { + salt: value.salt, + wrapped: value.wrapped.into(), + } + } +} + +#[derive(uniffi::Record)] +pub struct AccountRecord { + pub wrapped_key: WrappedKeyBlob, + pub data: EncryptedItemBlob, +} + +impl From for AccountRecord { + fn from(record: CoreAccountRecord) -> Self { + Self { + wrapped_key: record.wrapped_key.into(), + data: record.data.into(), + } + } +} + +impl AccountRecord { + fn into_core(self) -> Result { + Ok(CoreAccountRecord { + wrapped_key: self.wrapped_key.into_sealed()?, + data: self.data.into_sealed()?, + }) } } +fn password_wrapped_ark( + salt: Vec, + wrapped: WrappedKeyBlob, +) -> Result { + Ok(PasswordWrappedArk { + salt, + wrapped: wrapped.into_sealed()?, + }) +} + #[derive(uniffi::Object)] pub struct ArkCredential { ark_session: Arc, } impl ArkCredential { - /// The session this credential borrows its key from. pub(crate) fn session(&self) -> &CoreArkSession { &self.ark_session.session } @@ -91,14 +143,7 @@ impl ArkSession { } pub fn create_account(&self, password: String) -> Result { - let account = self.session.create_account(&password)?; - Ok(NewAccount { - user_id: account.user_id, - salt: account.salt, - password_wrapped_ark: blob(&account.password_wrapped_ark), - vault_id: account.vault_id, - wrapped_vault_key: blob(&account.wrapped_vault_key), - }) + Ok(self.session.create_account(&password)?.into()) } pub fn unlock_with_password( @@ -108,10 +153,10 @@ impl ArkSession { wrapped: WrappedKeyBlob, user_id: UserId, ) -> Result<(), ArkSessionError> { - let wrapped = AeadWrappedKey::from_parts_bytes(wrapped.ciphertext, &wrapped.nonce); + let wrapped = password_wrapped_ark(salt, wrapped)?; Ok(self .session - .unlock_with_password(&password, &salt, wrapped, user_id)?) + .unlock_with_password(&password, &wrapped, user_id)?) } pub fn unlock_with_ark(&self, ark: Vec) -> Result<(), ArkSessionError> { @@ -129,10 +174,8 @@ impl ArkSession { wrapped: WrappedKeyBlob, user_id: UserId, ) -> Result<(), ArkSessionError> { - let wrapped = AeadWrappedKey::from_parts_bytes(wrapped.ciphertext, &wrapped.nonce); - Ok(self - .session - .verify_password(&password, &salt, wrapped, user_id)?) + let wrapped = password_wrapped_ark(salt, wrapped)?; + Ok(self.session.verify_password(&password, &wrapped, user_id)?) } pub fn verify_ark(&self, ark: Vec) -> Result { @@ -144,13 +187,10 @@ impl ArkSession { new_password: String, user_id: UserId, ) -> Result { - let wrapped = self + Ok(self .session - .rewrap_for_new_password(&new_password, user_id)?; - Ok(PasswordWrapped { - salt: wrapped.salt, - wrapped: blob(&wrapped.wrapped), - }) + .rewrap_for_new_password(&new_password, user_id)? + .into()) } pub fn unwrap_vault_key( @@ -158,10 +198,9 @@ impl ArkSession { wrapped: WrappedKeyBlob, vault_id: VaultId, ) -> Result { - let wrapped = AeadWrappedKey::from_parts_bytes(wrapped.ciphertext, &wrapped.nonce); - self.session - .unwrap_vault_key(wrapped, vault_id) - .map_err(ArkSessionError::from) + Ok(self + .session + .unwrap_vault_key(&wrapped.into_sealed()?, vault_id)?) } pub fn wrap_vault_key( @@ -169,10 +208,30 @@ impl ArkSession { vault_key: VaultKey, vault_id: VaultId, ) -> Result { - self.session - .wrap_vault_key(vault_key, vault_id) - .map_err(ArkSessionError::from) - .map(|wrapped| blob(&wrapped)) + Ok(self.session.wrap_vault_key(&vault_key, vault_id)?.into()) + } + + pub fn seal_account_record( + &self, + namespace: NamespaceId, + kind: SecretKind, + plaintext: Vec, + ) -> Result { + Ok(self + .session + .seal_record(namespace, kind, &plaintext)? + .into()) + } + + pub fn open_account_record( + &self, + namespace: NamespaceId, + kind: SecretKind, + record: AccountRecord, + ) -> Result, ArkSessionError> { + Ok(self + .session + .open_record(namespace, kind, &record.into_core()?)?) } pub fn ark_credential(self: Arc) -> Arc { diff --git a/rust/crates/keygo-uniffi/src/backup/csv.rs b/rust/crates/keygo-uniffi/src/backup/csv.rs new file mode 100644 index 000000000..33d36282e --- /dev/null +++ b/rust/crates/keygo-uniffi/src/backup/csv.rs @@ -0,0 +1,69 @@ +use keygo_core::backup::{ + Backup, ColumnMapping, Confidence, CsvAnalysis, CsvColumn, ExportPreset, FieldConfidence, + ImportReport, +}; + +#[uniffi::remote(Enum)] +enum Confidence { + High, + Medium, + Low, +} + +#[uniffi::remote(Enum)] +enum ExportPreset { + KeyGo, + Browser, +} + +#[uniffi::remote(Record)] +struct CsvColumn { + pub index: u32, + pub header: String, + pub sample_values: Vec, +} + +#[uniffi::remote(Record)] +struct ImportReport { + pub imported: u32, + pub skipped: u32, +} + +#[uniffi::remote(Record)] +struct FieldConfidence { + pub title: Option, + pub url: Option, + pub username: Option, + pub password: Option, + pub notes: Option, + pub totp: Option, +} + +#[derive(uniffi::Enum)] +pub enum JsonEncryption { + Passphrase, + Ark, +} + +#[uniffi::remote(Record)] +struct ColumnMapping { + pub title: Option, + pub url: Option, + pub username: Option, + pub password: Option, + pub notes: Option, + pub totp: Option, +} + +#[uniffi::remote(Record)] +struct CsvAnalysis { + pub columns: Vec, + pub suggested: ColumnMapping, + pub confidence: FieldConfidence, +} + +#[derive(uniffi::Record)] +pub struct CsvImportResult { + pub backup: Backup, + pub report: ImportReport, +} diff --git a/rust/rust-code/bindings/src/backup/mod.rs b/rust/crates/keygo-uniffi/src/backup/mod.rs similarity index 91% rename from rust/rust-code/bindings/src/backup/mod.rs rename to rust/crates/keygo-uniffi/src/backup/mod.rs index 5bd854ad5..e4d4af495 100644 --- a/rust/rust-code/bindings/src/backup/mod.rs +++ b/rust/crates/keygo-uniffi/src/backup/mod.rs @@ -5,11 +5,11 @@ use std::sync::Arc; use keygo_core::ark_session::ArkSessionError; use keygo_core::backup::{ - Backup, BackupCredential as CoreCredential, BackupError as CoreError, ExportPreset, KeySource, - csv as core_csv, json as core_json, + Backup, BackupCredential as CoreCredential, BackupError as CoreError, ColumnMapping, + CsvAnalysis, ExportPreset, KeySource, csv as core_csv, json as core_json, }; -use self::csv::{ColumnMapping, CsvAnalysis, CsvImportResult, JsonEncryption}; +use self::csv::{CsvImportResult, JsonEncryption}; use crate::ark_session::ArkCredential; #[derive(uniffi::Enum)] @@ -19,8 +19,7 @@ pub enum BackupCredential { } impl BackupCredential { - /// Run `f` with the core credential. The ARK is borrowed from the session for exactly the - /// length of the call, so it is never copied out to build a credential. + /// ARK credentials get a copy of the live ARK, zeroized on return. fn with_core( &self, f: impl FnOnce(CoreCredential<'_>) -> Result, @@ -127,7 +126,7 @@ impl CsvBackupManager { } pub fn analyze(&self, data: String) -> Result { - Ok(core_csv::analyze(&data)?.into()) + Ok(core_csv::analyze(&data)?) } pub fn import( @@ -135,7 +134,7 @@ impl CsvBackupManager { data: String, mapping: ColumnMapping, ) -> Result { - let (backup, report) = core_csv::import(&data, &mapping.into())?; + let (backup, report) = core_csv::import(&data, &mapping)?; Ok(CsvImportResult { backup, report }) } @@ -146,7 +145,7 @@ impl CsvBackupManager { #[cfg(test)] mod tests { - use keygo_core::crypto::KeyMaterial; + use keygo_core::crypto::SymmetricKey; use super::*; use crate::ark_session::ArkSession; diff --git a/rust/rust-code/bindings/src/backup/model.rs b/rust/crates/keygo-uniffi/src/backup/model.rs similarity index 100% rename from rust/rust-code/bindings/src/backup/model.rs rename to rust/crates/keygo-uniffi/src/backup/model.rs diff --git a/rust/rust-code/bindings/src/card.rs b/rust/crates/keygo-uniffi/src/card.rs similarity index 100% rename from rust/rust-code/bindings/src/card.rs rename to rust/crates/keygo-uniffi/src/card.rs diff --git a/rust/rust-code/bindings/src/item.rs b/rust/crates/keygo-uniffi/src/item.rs similarity index 58% rename from rust/rust-code/bindings/src/item.rs rename to rust/crates/keygo-uniffi/src/item.rs index 2d67970e0..084c23621 100644 --- a/rust/rust-code/bindings/src/item.rs +++ b/rust/crates/keygo-uniffi/src/item.rs @@ -1,39 +1,55 @@ -use keygo_core::crypto::KeyMaterial; -use keygo_core::crypto::error::CryptoError; -use keygo_core::crypto::primitive::aead_data::{AeadCiphertext, AeadEncryptor}; -use keygo_core::crypto::types::{ItemId, VaultId}; -use keygo_core::crypto::{ItemAad, ItemDataAad, ItemKey}; +use keygo_core::crypto::{ + CryptoError, Encrypts, ItemAad, ItemDataAad, ItemId, ItemKey, Sealed, SecretKind, VaultId, +}; use std::sync::Arc; -uniffi::custom_type!(ItemKey, Vec, { - remote, - try_lift: |bytes| { - ItemKey::try_from_bytes(&bytes) - .map_err(|e| uniffi::deps::anyhow::anyhow!("{e:?}")) - }, - lower: |key| key.as_bytes().to_vec(), -}); - #[uniffi::remote(Record)] pub struct ItemAad { pub item_id: ItemId, pub vault_id: VaultId, } +#[uniffi::remote(Record)] +pub struct ItemDataAad { + pub item_id: ItemId, + pub kind: SecretKind, +} + +#[uniffi::remote(Enum)] +pub enum SecretKind { + Password, + CardNumber, + Cvv, + PasskeyPrivateKey, + TotpSecret, + PasswordHealthReport, +} + #[derive(uniffi::Record)] pub struct EncryptedItemBlob { pub ciphertext: Vec, pub nonce: Vec, } +impl From> for EncryptedItemBlob { + fn from(sealed: Sealed<[u8]>) -> Self { + let (nonce, ciphertext) = sealed.into_parts(); + Self { ciphertext, nonce } + } +} + +impl EncryptedItemBlob { + pub(crate) fn into_sealed(self) -> Result, CryptoError> { + Sealed::from_parts(&self.nonce, self.ciphertext) + } +} + #[derive(Debug, thiserror::Error, uniffi::Error)] pub enum ItemCryptoError { #[error("Encryption failed")] EncryptionFailed, #[error("Decryption failed: ciphertext invalid or tampered")] DecryptionFailed, - #[error("Invalid key material")] - InvalidKey, #[error("Invalid key length: expected {expected} bytes, got {got} bytes")] InvalidKeyLength { expected: u64, got: u64 }, #[error("{0}")] @@ -45,12 +61,11 @@ impl From for ItemCryptoError { match value { CryptoError::EncryptionFailed => Self::EncryptionFailed, CryptoError::DecryptionFailed => Self::DecryptionFailed, - CryptoError::InvalidKey => Self::InvalidKey, CryptoError::InvalidKeyLength { expected, got } => Self::InvalidKeyLength { expected: expected as u64, got: got as u64, }, - other => Self::Other(format!("{other}")), + other => Self::Other(other.to_string()), } } } @@ -66,31 +81,24 @@ impl ItemManager { } pub fn create_new_item_key(&self) -> ItemKey { - ItemKey::generate_random() + ItemKey::generate() } pub fn encrypt_item_data( &self, item_key: ItemKey, data: Vec, - aad: Vec, + aad: ItemDataAad, ) -> Result { - let aad = ItemDataAad(aad); - let ct = item_key.encrypt_data(&data, &aad)?; - Ok(EncryptedItemBlob { - ciphertext: ct.ciphertext().to_vec(), - nonce: ct.nonce_bytes().to_vec(), - }) + Ok(item_key.encrypt(&data, &aad)?.into()) } pub fn decrypt_item_data( &self, item_key: ItemKey, blob: EncryptedItemBlob, - aad: Vec, + aad: ItemDataAad, ) -> Result, ItemCryptoError> { - let aad = ItemDataAad(aad); - let ciphertext = AeadCiphertext::::from_parts_bytes(blob.ciphertext, &blob.nonce); - Ok(item_key.decrypt_data(&ciphertext, &aad)?) + Ok(item_key.decrypt(&blob.into_sealed()?, &aad)?) } } diff --git a/rust/rust-code/bindings/src/key_wrap.rs b/rust/crates/keygo-uniffi/src/key_wrap.rs similarity index 53% rename from rust/rust-code/bindings/src/key_wrap.rs rename to rust/crates/keygo-uniffi/src/key_wrap.rs index d67de3762..336613032 100644 --- a/rust/rust-code/bindings/src/key_wrap.rs +++ b/rust/crates/keygo-uniffi/src/key_wrap.rs @@ -1,8 +1,4 @@ -use keygo_core::crypto::KeyMaterial; -use keygo_core::crypto::VaultKey; -use keygo_core::crypto::error::CryptoError; -use keygo_core::crypto::primitive::wrap_key::{KeyWrapper as KeyWrapperTrait, WrappedKey}; -use keygo_core::crypto::{ItemAad, ItemKey}; +use keygo_core::crypto::{CryptoError, ItemAad, ItemKey, Sealed, VaultKey, Wraps}; use std::sync::Arc; #[derive(uniffi::Record)] @@ -11,14 +7,25 @@ pub struct WrappedKeyBlob { pub nonce: Vec, } +impl From> for WrappedKeyBlob { + fn from(sealed: Sealed) -> Self { + let (nonce, ciphertext) = sealed.into_parts(); + Self { ciphertext, nonce } + } +} + +impl WrappedKeyBlob { + pub(crate) fn into_sealed(self) -> Result, CryptoError> { + Sealed::from_parts(&self.nonce, self.ciphertext) + } +} + #[derive(Debug, thiserror::Error, uniffi::Error)] pub enum KeyWrapError { #[error("Key wrap failed")] WrapFailed, #[error("Key unwrap failed: wrong key or corrupted data")] UnwrapFailed, - #[error("Invalid key material")] - InvalidKey, #[error("Invalid key length: expected {expected} bytes, got {got} bytes")] InvalidKeyLength { expected: u64, got: u64 }, #[error("{0}")] @@ -30,45 +37,15 @@ impl From for KeyWrapError { match value { CryptoError::KeyWrapFailed => Self::WrapFailed, CryptoError::KeyUnwrapFailed => Self::UnwrapFailed, - CryptoError::InvalidKey => Self::InvalidKey, CryptoError::InvalidKeyLength { expected, got } => Self::InvalidKeyLength { expected: expected as u64, got: got as u64, }, - other => Self::Other(format!("{other}")), + other => Self::Other(other.to_string()), } } } -pub(crate) fn wrap( - wrapper: &Wrapper, - target: &Target, - aad: &Wrapper::Aad, -) -> Result -where - Target: KeyMaterial, - Wrapper: KeyWrapperTrait, -{ - let wrapped = wrapper.wrap_key(target, aad)?; - Ok(WrappedKeyBlob { - ciphertext: wrapped.ciphertext().to_vec(), - nonce: wrapped.nonce_bytes().to_vec(), - }) -} - -pub(crate) fn unwrap( - wrapper: &Wrapper, - blob: &WrappedKeyBlob, - aad: &Wrapper::Aad, -) -> Result -where - Target: KeyMaterial, - Wrapper: KeyWrapperTrait, -{ - let wrapped = Wrapper::Wrapped::from_parts_bytes(blob.ciphertext.clone(), &blob.nonce); - Ok(wrapper.unwrap_key(&wrapped, aad)?) -} - #[derive(uniffi::Object)] pub struct KeyWrapper; @@ -85,7 +62,7 @@ impl KeyWrapper { item_key: ItemKey, aad: ItemAad, ) -> Result { - wrap::(&vault_key, &item_key, &aad) + Ok(vault_key.wrap_key(&item_key, &aad)?.into()) } pub fn unwrap_item_key( @@ -94,6 +71,6 @@ impl KeyWrapper { wrapped: WrappedKeyBlob, aad: ItemAad, ) -> Result { - unwrap::(&vault_key, &wrapped, &aad) + Ok(vault_key.unwrap_key(&wrapped.into_sealed()?, &aad)?) } } diff --git a/rust/rust-code/bindings/src/lib.rs b/rust/crates/keygo-uniffi/src/lib.rs similarity index 100% rename from rust/rust-code/bindings/src/lib.rs rename to rust/crates/keygo-uniffi/src/lib.rs diff --git a/rust/crates/keygo-uniffi/src/passkey.rs b/rust/crates/keygo-uniffi/src/passkey.rs new file mode 100644 index 000000000..8483c93c0 --- /dev/null +++ b/rust/crates/keygo-uniffi/src/passkey.rs @@ -0,0 +1,77 @@ +use keygo_core::passkey::{ + self as core, CreationInfo, PasskeyError as CorePasskeyError, Registration, +}; +use std::sync::Arc; + +#[derive(Debug, thiserror::Error, uniffi::Error)] +pub enum PasskeyError { + #[error("invalid json format")] + InvalidJsonFormat, + #[error("invalid url")] + InvalidDomain, + #[error("webauthn error: {reason}")] + Webauthn { reason: String }, + #[error("key codec error: {reason}")] + KeyCodec { reason: String }, +} + +impl From for PasskeyError { + fn from(value: CorePasskeyError) -> Self { + match value { + CorePasskeyError::InvalidRequest(_) => Self::InvalidJsonFormat, + CorePasskeyError::InvalidDomain | CorePasskeyError::RpMismatch => Self::InvalidDomain, + CorePasskeyError::Webauthn(e) => Self::Webauthn { + reason: format!("{e:?}"), + }, + other @ (CorePasskeyError::Codec(_) + | CorePasskeyError::ResponseEncoding(_) + | CorePasskeyError::NoCredentialCreated) => Self::KeyCodec { + reason: other.to_string(), + }, + } + } +} + +#[uniffi::remote(Record)] +pub struct Registration { + pub response: String, + pub user_name: String, + pub user_display_name: String, + pub credential_id: Vec, + pub private_key: Vec, + pub rp: String, +} + +#[uniffi::remote(Record)] +pub struct CreationInfo { + pub exclude_credentials: Vec>, + pub rp: String, +} + +#[derive(uniffi::Object)] +pub struct RustPasskey; + +#[uniffi::export(async_runtime = "tokio")] +impl RustPasskey { + #[uniffi::constructor] + pub fn new() -> Arc { + Arc::new(Self) + } + + pub async fn register(&self, json_request: String) -> Result { + Ok(core::register(&json_request).await?) + } + + pub fn passkey_information(&self, json_request: String) -> Result { + Ok(core::creation_info(&json_request)?) + } + + pub async fn authenticate( + &self, + json_request: String, + passkey: Vec, + client_data_hash: Option>, + ) -> Result { + Ok(core::authenticate(&json_request, &passkey, client_data_hash).await?) + } +} diff --git a/rust/rust-code/bindings/src/totp.rs b/rust/crates/keygo-uniffi/src/totp.rs similarity index 65% rename from rust/rust-code/bindings/src/totp.rs rename to rust/crates/keygo-uniffi/src/totp.rs index 14eae82ec..93b54d85e 100644 --- a/rust/rust-code/bindings/src/totp.rs +++ b/rust/crates/keygo-uniffi/src/totp.rs @@ -1,9 +1,6 @@ use std::sync::Arc; -use keygo_core::totp::{ - TotpInfo as CoreTotpInfo, get_totp as core_get_totp, - get_totp_info_from_uri as core_get_totp_info_from_uri, get_totp_url as core_get_totp_url, -}; +use keygo_core::totp::{self as core, TotpInfo as CoreTotpInfo, TotpParams}; use thiserror::Error; #[derive(Debug, Clone, Copy, uniffi::Enum)] @@ -26,10 +23,7 @@ impl From for keygo_core::totp::Algorithm { impl TryFrom for Algorithm { type Error = TotpError; - /// `totp_rs::Algorithm` is `#[non_exhaustive]`, so a variant this binding - /// does not expose stays representable no matter what we match on. Reaching - /// one means the input named an algorithm we cannot hand to Kotlin, which - /// is an error to report, not a reason to unwind across the FFI boundary. + /// `Algorithm` is non-exhaustive; an unknown variant is an error, not a panic across FFI. fn try_from(value: keygo_core::totp::Algorithm) -> Result { match value { keygo_core::totp::Algorithm::SHA1 => Ok(Algorithm::Sha1), @@ -58,9 +52,9 @@ impl TryFrom for TotpInfo { secret: value.secret, issuer: value.issuer, account_name: value.account_name, - algorithm: value.algorithm.try_into()?, - digits: i32::from(value.digits), - period: i32::try_from(value.period).map_err(|_| TotpError::InvalidInput)?, + algorithm: value.params.algorithm.try_into()?, + digits: i32::from(value.params.digits), + period: i32::try_from(value.params.period).map_err(|_| TotpError::InvalidInput)?, }) } } @@ -80,6 +74,14 @@ impl From for TotpError { } } +fn params(algorithm: Algorithm, digits: i32, period: i32) -> Result { + Ok(TotpParams { + algorithm: algorithm.into(), + digits: u8::try_from(digits).map_err(|_| TotpError::InvalidInput)?, + period: u64::try_from(period).map_err(|_| TotpError::InvalidInput)?, + }) +} + #[derive(uniffi::Object)] pub struct TotpService; @@ -97,9 +99,7 @@ impl TotpService { step: i32, secret: String, ) -> Result { - let digits = u8::try_from(digits).map_err(|_| TotpError::InvalidInput)?; - let step = u64::try_from(step).map_err(|_| TotpError::InvalidInput)?; - core_get_totp(algorithm.into(), digits, step, secret).map_err(Into::into) + Ok(core::generate(params(algorithm, digits, step)?, &secret)?) } pub fn get_url( @@ -111,16 +111,15 @@ impl TotpService { issuer: Option, account_name: String, ) -> Result { - let digits = u8::try_from(digits).map_err(|_| TotpError::InvalidInput)?; - let step = u64::try_from(step).map_err(|_| TotpError::InvalidInput)?; - - core_get_totp_url(algorithm.into(), digits, step, secret, issuer, account_name) - .map_err(Into::into) + Ok(core::to_uri( + params(algorithm, digits, step)?, + &secret, + issuer.as_deref(), + &account_name, + )?) } pub fn get_info_from_uri(&self, uri: String) -> Result { - core_get_totp_info_from_uri(uri) - .map_err(TotpError::from)? - .try_into() + core::parse_uri(&uri)?.try_into() } } diff --git a/rust/crates/keygo-uniffi/src/types.rs b/rust/crates/keygo-uniffi/src/types.rs new file mode 100644 index 000000000..1eb26e63f --- /dev/null +++ b/rust/crates/keygo-uniffi/src/types.rs @@ -0,0 +1,44 @@ +use keygo_core::crypto::{ItemId, ItemKey, NamespaceId, SymmetricKey, UserId, VaultId, VaultKey}; +use uuid::Uuid; + +uniffi::custom_type!(Uuid, String, { + remote, + try_lift: |s| Uuid::parse_str(&s).map_err(|e| uniffi::deps::anyhow::anyhow!("{e}")), + lower: |u| u.to_string(), +}); + +uniffi::custom_type!(UserId, Uuid, { + remote, + try_lift: |u| Ok(UserId(u)), + lower: |id| id.0, +}); + +uniffi::custom_type!(VaultId, Uuid, { + remote, + try_lift: |u| Ok(VaultId(u)), + lower: |id| id.0, +}); + +uniffi::custom_type!(ItemId, Uuid, { + remote, + try_lift: |u| Ok(ItemId(u)), + lower: |id| id.0, +}); + +uniffi::custom_type!(NamespaceId, Uuid, { + remote, + try_lift: |u| Ok(NamespaceId(u)), + lower: |id| id.0, +}); + +uniffi::custom_type!(VaultKey, Vec, { + remote, + try_lift: |bytes| VaultKey::from_bytes(&bytes).map_err(|e| uniffi::deps::anyhow::anyhow!("{e}")), + lower: |key| key.as_bytes().to_vec(), +}); + +uniffi::custom_type!(ItemKey, Vec, { + remote, + try_lift: |bytes| ItemKey::from_bytes(&bytes).map_err(|e| uniffi::deps::anyhow::anyhow!("{e}")), + lower: |key| key.as_bytes().to_vec(), +}); diff --git a/rust/rust-code/bindings/src/vault.rs b/rust/crates/keygo-uniffi/src/vault.rs similarity index 89% rename from rust/rust-code/bindings/src/vault.rs rename to rust/crates/keygo-uniffi/src/vault.rs index e74f66381..bd3c40ba4 100644 --- a/rust/rust-code/bindings/src/vault.rs +++ b/rust/crates/keygo-uniffi/src/vault.rs @@ -12,6 +12,6 @@ impl VaultManager { } pub fn create_new_vault_key(&self) -> VaultKey { - VaultKey::generate_random() + VaultKey::generate() } } diff --git a/rust/rust-code/bindings/uniffi.toml b/rust/crates/keygo-uniffi/uniffi.toml similarity index 100% rename from rust/rust-code/bindings/uniffi.toml rename to rust/crates/keygo-uniffi/uniffi.toml diff --git a/rust/rust-code/.cargo/config.toml b/rust/rust-code/.cargo/config.toml deleted file mode 100644 index 71772e9f6..000000000 --- a/rust/rust-code/.cargo/config.toml +++ /dev/null @@ -1,26 +0,0 @@ -# Android NDK cross-compile linkers. -# -# Requires the NDK toolchain `bin/` on PATH, e.g.: -# export PATH="$ANDROID_NDK_HOME/toolchains/llvm/prebuilt/linux-x86_64/bin:$PATH" -# -# Min SDK: 26 (matches app/minSdk). Bump the API number in the linker name -# if the app minSdk changes. -# -# Prefer `cargo ndk -t build --release -p keygo-bindings` over setting -# PATH manually, since it picks the right linker/sysroot automatically. - -[target.aarch64-linux-android] -linker = "aarch64-linux-android26-clang" -ar = "llvm-ar" - -[target.armv7-linux-androideabi] -linker = "armv7a-linux-androideabi26-clang" -ar = "llvm-ar" - -[target.x86_64-linux-android] -linker = "x86_64-linux-android26-clang" -ar = "llvm-ar" - -[target.i686-linux-android] -linker = "i686-linux-android26-clang" -ar = "llvm-ar" diff --git a/rust/rust-code/bindings/Cargo.toml b/rust/rust-code/bindings/Cargo.toml deleted file mode 100644 index d44eab026..000000000 --- a/rust/rust-code/bindings/Cargo.toml +++ /dev/null @@ -1,21 +0,0 @@ -[package] -name = "keygo-bindings" -version.workspace = true -edition.workspace = true - -[lib] -name = "keygo_bindings" -crate-type = ["cdylib", "staticlib"] - -[dependencies] -keygo-core = { path = "../core" } -thiserror.workspace = true -uuid.workspace = true -zeroize = "1.8.2" - -tokio = { version = "1.48.0", features = ["rt", "rt-multi-thread"] } -uniffi = { version = "0.32.0", features = ["tokio", "cli"] } - -[[bin]] -name = "uniffi-bindgen" -path = "uniffi-bindgen.rs" diff --git a/rust/rust-code/bindings/src/backup/csv.rs b/rust/rust-code/bindings/src/backup/csv.rs deleted file mode 100644 index d2b4ab708..000000000 --- a/rust/rust-code/bindings/src/backup/csv.rs +++ /dev/null @@ -1,105 +0,0 @@ -use keygo_core::backup::{ - Backup, ColumnMapping as CoreColumnMapping, Confidence, CsvAnalysis as CoreCsvAnalysis, - CsvColumn, ExportPreset, FieldConfidence, ImportReport, -}; - -#[uniffi::remote(Enum)] -enum Confidence { - High, - Medium, - Low, -} - -#[uniffi::remote(Enum)] -enum ExportPreset { - KeyGo, - Browser, -} - -#[uniffi::remote(Record)] -struct CsvColumn { - pub index: u32, - pub header: String, - pub sample_values: Vec, -} - -#[uniffi::remote(Record)] -struct ImportReport { - pub imported: u32, - pub skipped: u32, -} - -#[uniffi::remote(Record)] -struct FieldConfidence { - pub title: Option, - pub url: Option, - pub username: Option, - pub password: Option, - pub notes: Option, - pub totp: Option, -} - -#[derive(uniffi::Enum)] -pub enum JsonEncryption { - Passphrase, - Ark, -} - -#[derive(uniffi::Record, Default)] -pub struct ColumnMapping { - pub title: Option, - pub url: Option, - pub username: Option, - pub password: Option, - pub notes: Option, - pub totp: Option, -} - -impl From for ColumnMapping { - fn from(m: CoreColumnMapping) -> Self { - Self { - title: m.title.map(|i| i as u32), - url: m.url.map(|i| i as u32), - username: m.username.map(|i| i as u32), - password: m.password.map(|i| i as u32), - notes: m.notes.map(|i| i as u32), - totp: m.totp.map(|i| i as u32), - } - } -} - -impl From for CoreColumnMapping { - fn from(m: ColumnMapping) -> Self { - Self { - title: m.title.map(|i| i as usize), - url: m.url.map(|i| i as usize), - username: m.username.map(|i| i as usize), - password: m.password.map(|i| i as usize), - notes: m.notes.map(|i| i as usize), - totp: m.totp.map(|i| i as usize), - } - } -} - -#[derive(uniffi::Record)] -pub struct CsvAnalysis { - pub columns: Vec, - pub suggested: ColumnMapping, - pub confidence: FieldConfidence, -} - -impl From for CsvAnalysis { - fn from(a: CoreCsvAnalysis) -> Self { - Self { - columns: a.columns, - suggested: a.suggested.into(), - confidence: a.confidence, - } - } -} - -#[derive(uniffi::Record)] -pub struct CsvImportResult { - pub backup: Backup, - pub report: ImportReport, -} diff --git a/rust/rust-code/bindings/src/passkey.rs b/rust/rust-code/bindings/src/passkey.rs deleted file mode 100644 index 282f624af..000000000 --- a/rust/rust-code/bindings/src/passkey.rs +++ /dev/null @@ -1,127 +0,0 @@ -use keygo_core::passkey::{ - KeyGoRegistrationResponse, PasskeyInformation as CorePasskeyInformation, RegistrationError, - get_passkey_information, register_passkey, -}; -use keygo_core::passkey::{ProviderError, provide_passkey}; -use std::sync::Arc; - -#[derive(Debug, thiserror::Error, uniffi::Error)] -pub enum PasskeyError { - #[error("invalid json format")] - InvalidJsonFormat, - #[error("invalid url")] - InvalidDomain, - #[error("webauthn error: {reason}")] - Webauthn { reason: String }, - #[error("key codec error: {reason}")] - KeyCodec { reason: String }, -} - -impl From for PasskeyError { - fn from(value: RegistrationError) -> Self { - match value { - RegistrationError::InvalidJsonFormat => Self::InvalidJsonFormat, - RegistrationError::InvalidDomain => Self::InvalidDomain, - RegistrationError::WebauthnError(e) => Self::Webauthn { - reason: format!("{e:?}"), - }, - RegistrationError::KeyEncodeError(e) => Self::KeyCodec { - reason: format!("{e:?}"), - }, - } - } -} - -impl From for PasskeyError { - fn from(value: ProviderError) -> Self { - match value { - ProviderError::InvalidJsonFormat => Self::InvalidJsonFormat, - ProviderError::InvalidDomain => Self::InvalidDomain, - ProviderError::WebauthnError(e) => Self::Webauthn { - reason: format!("{e:?}"), - }, - ProviderError::PasskeyDecodeError(e) => Self::KeyCodec { - reason: format!("{e:?}"), - }, - } - } -} - -#[derive(uniffi::Record)] -pub struct RegistrationResponse { - pub response: String, - pub user_name: String, - pub user_display_name: String, - pub credential_id: Vec, - pub private_key: Vec, - pub rp: String, -} - -impl From for RegistrationResponse { - fn from(value: KeyGoRegistrationResponse) -> Self { - Self { - response: value.response, - user_name: value.user_name, - user_display_name: value.user_display_name, - credential_id: value.credential_id, - private_key: value.private_key, - rp: value.rp, - } - } -} - -#[derive(uniffi::Record)] -pub struct PasskeyInformation { - pub exclude_credentials: Vec>, - pub rp: String, -} - -impl From for PasskeyInformation { - fn from(value: CorePasskeyInformation) -> Self { - Self { - exclude_credentials: value.exclude_credentials, - rp: value.rp, - } - } -} - -#[derive(uniffi::Object)] -pub struct RustPasskey; - -#[uniffi::export(async_runtime = "tokio")] -impl RustPasskey { - #[uniffi::constructor] - pub fn new() -> Arc { - Arc::new(Self) - } - - pub async fn register( - &self, - json_request: String, - ) -> Result { - register_passkey(&json_request) - .await - .map(Into::into) - .map_err(Into::into) - } - - pub fn passkey_information( - &self, - json_request: String, - ) -> Result { - get_passkey_information(&json_request) - .map(Into::into) - .map_err(Into::into) - } - - pub async fn authenticate( - &self, - json_request: String, - passkey: Vec, - client_data_hash: Option>, - ) -> Result { - provide_passkey(&json_request, &passkey, client_data_hash) - .await - .map_err(Into::into) - } -} diff --git a/rust/rust-code/bindings/src/types.rs b/rust/rust-code/bindings/src/types.rs deleted file mode 100644 index 911d98bce..000000000 --- a/rust/rust-code/bindings/src/types.rs +++ /dev/null @@ -1,17 +0,0 @@ -use keygo_core::crypto::{KeyMaterial, VaultKey}; -use uuid::Uuid; - -uniffi::custom_type!(Uuid, String, { - remote, - try_lift: |s| Uuid::parse_str(&s).map_err(|e| uniffi::deps::anyhow::anyhow!("{e}")), - lower: |u| u.to_string(), -}); - -uniffi::custom_type!(VaultKey, Vec, { - remote, - try_lift: |bytes| { - VaultKey::try_from_bytes(&bytes) - .map_err(|e| uniffi::deps::anyhow::anyhow!("{e:?}")) - }, - lower: |key| key.as_bytes().to_vec(), -}); diff --git a/rust/rust-code/core/src/backup/mod.rs b/rust/rust-code/core/src/backup/mod.rs deleted file mode 100644 index cea814172..000000000 --- a/rust/rust-code/core/src/backup/mod.rs +++ /dev/null @@ -1,25 +0,0 @@ -mod encryption; -mod error; -mod format; -mod key; -mod model; - -pub use encryption::{BackupCredential, KeySource}; -pub use error::BackupError; -pub use format::csv::{ - ColumnMapping, Confidence, CsvAnalysis, CsvColumn, ExportPreset, FieldConfidence, ImportReport, -}; -pub use format::{csv, json}; -pub use key::BackupKey; -pub use model::{Backup, Card, Login, Passkey, Vault}; - -/// Version stamped into newly written backups. -pub const CURRENT_VERSION: u32 = 1; - -/// Oldest envelope version this build can still read. Backups are long-lived: a -/// file written by an old build may be restored by a much newer one. When -/// [`CURRENT_VERSION`] is bumped, keep older versions readable here (and -/// preserve their exact AAD/BCS layout - see `encryption::BackupAad`) instead of -/// rejecting them. Per-version decode branches belong in the format's `import`, -/// keyed off the envelope version. -pub const MIN_SUPPORTED_VERSION: u32 = 1; diff --git a/rust/rust-code/core/src/card/mod.rs b/rust/rust-code/core/src/card/mod.rs deleted file mode 100644 index 2a80b08c1..000000000 --- a/rust/rust-code/core/src/card/mod.rs +++ /dev/null @@ -1,37 +0,0 @@ -//! Credit-card field helpers, split by concern: -//! -//! - network detection: the issuing [`CardNetwork`] and its metadata (valid -//! lengths, CVV length, grouping convention); -//! - number handling: parse, cap, group, and validate a PAN via [`Card`]; -//! - expiration formatting: render an `MM/YY` date as it is typed or deleted. -//! -//! All entry points accept "dirty" input: spaces, dashes, and any other -//! non-digit characters are ignored, so they work equally well on a pasted -//! value or on text being typed live into a field. -//! -//! ``` -//! use keygo_core::card::{Card, CardNetwork}; -//! -//! let card = Card::parse("378282246310005"); -//! assert_eq!(card.network, CardNetwork::Amex); -//! assert_eq!(card.formatted, "3782 822463 10005"); -//! assert!(card.is_valid()); -//! ``` - -mod expiration; -mod network; -mod number; - -pub use expiration::format_expiration_after_edit; -pub use network::CardNetwork; -pub use number::Card; - -/// The longest PAN any supported network issues; the cap of last resort while -/// the network is still [`CardNetwork::Unknown`]. -pub const MAX_PAN_DIGITS: usize = 19; - -/// Strip everything but ASCII digits. Shared by every submodule, since they all -/// accept "dirty" input. -fn digits_only(s: &str) -> String { - s.chars().filter(char::is_ascii_digit).collect() -} diff --git a/rust/rust-code/core/src/crypto/error.rs b/rust/rust-code/core/src/crypto/error.rs deleted file mode 100644 index fbb15c725..000000000 --- a/rust/rust-code/core/src/crypto/error.rs +++ /dev/null @@ -1,30 +0,0 @@ -use thiserror::Error; - -#[derive(Debug, Error)] -pub enum CryptoError { - #[error("AEAD encryption failed")] - EncryptionFailed, - #[error("AEAD decryption failed: ciphertext invalid or tampered")] - DecryptionFailed, - - #[error("Key wrap failed")] - KeyWrapFailed, - #[error("Key unwrap failed: wrong key or corrupted data")] - KeyUnwrapFailed, - - #[error("BCS serialisation failed: {0}")] - BCS(String), - - #[error("Signature verification failed")] - SignatureInvalid, - - #[error("Invalid key material")] - InvalidKey, - - #[error("Key derivation failed: {0}")] - KdfError(String), - #[error("Invalid key length: expected {expected} bytes, got {got} bytes")] - InvalidKeyLength { expected: usize, got: usize }, -} - -pub type CryptoResult = Result; diff --git a/rust/rust-code/core/src/crypto/key.rs b/rust/rust-code/core/src/crypto/key.rs deleted file mode 100644 index 0f54a2f9f..000000000 --- a/rust/rust-code/core/src/crypto/key.rs +++ /dev/null @@ -1,14 +0,0 @@ -use crate::crypto::error::CryptoResult; -use aead::{Aead, AeadCore, Key, KeyInit}; -use zeroize::{Zeroize, ZeroizeOnDrop}; - -pub trait KeyMaterial: Sized { - fn try_from_bytes(bytes: &[u8]) -> CryptoResult; - fn as_bytes(&self) -> &[u8]; -} - -pub trait AeadKey: KeyMaterial + Zeroize + ZeroizeOnDrop + Sized { - type Algorithm: AeadCore + Aead + KeyInit; - - fn key(&self) -> &Key; -} diff --git a/rust/rust-code/core/src/crypto/keys/account_root_key.rs b/rust/rust-code/core/src/crypto/keys/account_root_key.rs deleted file mode 100644 index 2bade0da7..000000000 --- a/rust/rust-code/core/src/crypto/keys/account_root_key.rs +++ /dev/null @@ -1,6 +0,0 @@ -use crate::define_aead_key; -use aes_gcm_siv::Aes256GcmSiv; - -define_aead_key! { - random pub struct AccountRootKey(Aes256GcmSiv); -} diff --git a/rust/rust-code/core/src/crypto/keys/item_key.rs b/rust/rust-code/core/src/crypto/keys/item_key.rs deleted file mode 100644 index 082bc1454..000000000 --- a/rust/rust-code/core/src/crypto/keys/item_key.rs +++ /dev/null @@ -1,25 +0,0 @@ -use crate::crypto::VaultKey; -use crate::crypto::primitive::aead_data::AeadEncryptor; -use crate::crypto::types::{ItemId, VaultId}; -use crate::{define_aead_key, define_wrap}; -use aes_gcm_siv::Aes256GcmSiv; -use serde::Serialize; - -define_aead_key! { - random pub struct ItemKey(Aes256GcmSiv); -} - -#[derive(Serialize)] -pub struct ItemAad { - pub item_id: ItemId, - pub vault_id: VaultId, -} - -#[derive(Serialize)] -pub struct ItemDataAad(pub Vec); - -define_wrap!(VaultKey => ItemKey, aad = ItemAad); - -impl AeadEncryptor for ItemKey { - type Aad = ItemDataAad; -} diff --git a/rust/rust-code/core/src/crypto/keys/mod.rs b/rust/rust-code/core/src/crypto/keys/mod.rs deleted file mode 100644 index 464857873..000000000 --- a/rust/rust-code/core/src/crypto/keys/mod.rs +++ /dev/null @@ -1,17 +0,0 @@ -mod account_root_key; -mod item_key; -mod root_kek; -mod signing_key; -mod vault_key; - -use crate::crypto::error::CryptoResult; - -pub use account_root_key::AccountRootKey; -pub use item_key::{ItemAad, ItemDataAad, ItemKey}; -pub use root_kek::RootKEK; -pub use signing_key::ScopedSigningKey; -pub use vault_key::VaultKey; - -pub trait TryDeriveFrom: Sized { - fn try_derive_from(source: T, salt: &[u8], domain: &[u8]) -> CryptoResult; -} diff --git a/rust/rust-code/core/src/crypto/keys/root_kek.rs b/rust/rust-code/core/src/crypto/keys/root_kek.rs deleted file mode 100644 index 7f5fb6ada..000000000 --- a/rust/rust-code/core/src/crypto/keys/root_kek.rs +++ /dev/null @@ -1,54 +0,0 @@ -use crate::crypto::AccountRootKey; -use crate::crypto::KeyMaterial; -use crate::crypto::TryDeriveFrom; -use crate::crypto::error::CryptoResult; -use crate::crypto::primitive::argon2::derive_argon2id; -use crate::crypto::types::UserId; -use crate::{define_aead_key, define_wrap}; -use aes_gcm_siv::Aes256GcmSiv; - -define_aead_key! { - /// ### Root Key Encryption Key - /// - /// Used to wrap the [AccountRootKey] (ARK). Derived on every login from either the user's - /// password or the user's recovery key via Argon2id. Never stored directly. - pub struct RootKEK(Aes256GcmSiv); -} - -define_wrap!(RootKEK => AccountRootKey, aad = UserId); // TODO: also include epoch: pw wrapped: pwd_cred_epoch, rk wrapped: rk_cred_epoch - -impl<'a> TryDeriveFrom<&'a [u8]> for RootKEK { - fn try_derive_from(source: &'a [u8], salt: &[u8], domain: &[u8]) -> CryptoResult { - let derived = derive_argon2id(source, salt, domain)?; - ::try_from_bytes(&derived) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - const SALT: &[u8] = &[6; 16]; - const DOMAIN: &[u8] = b"v1:kek/pwd"; - - #[test] - fn same_credential_same_password_same_key() { - let a = RootKEK::try_derive_from(b"hunter2", SALT, DOMAIN).unwrap(); - let b = RootKEK::try_derive_from(b"hunter2", SALT, DOMAIN).unwrap(); - assert_eq!(a.as_bytes(), b.as_bytes()); - } - - #[test] - fn different_salt_different_keys() { - let a = RootKEK::try_derive_from(b"hunter2", &[7; 16], DOMAIN).unwrap(); - let b = RootKEK::try_derive_from(b"hunter2", SALT, DOMAIN).unwrap(); - assert_ne!(a.as_bytes(), b.as_bytes()); - } - - #[test] - fn different_domain_different_keys() { - let a = RootKEK::try_derive_from(b"hunter2", SALT, DOMAIN).unwrap(); - let b = RootKEK::try_derive_from(b"hunter2", SALT, b"v2:kek/pwd").unwrap(); - assert_ne!(a.as_bytes(), b.as_bytes()); - } -} diff --git a/rust/rust-code/core/src/crypto/keys/signing_key.rs b/rust/rust-code/core/src/crypto/keys/signing_key.rs deleted file mode 100644 index 35f291396..000000000 --- a/rust/rust-code/core/src/crypto/keys/signing_key.rs +++ /dev/null @@ -1,77 +0,0 @@ -use crate::crypto::KeyMaterial; -use crate::crypto::error::{CryptoError, CryptoResult}; -use crate::crypto::primitive::wrap_key::KeyWrapper; -use ed25519_dalek::{SECRET_KEY_LENGTH, Signature, Signer, SigningKey}; -use rand::rand_core::UnwrapErr; -use rand::rngs::SysRng; -use std::marker::PhantomData; - -pub struct ScopedSigningKey -where - Wrapper: KeyWrapper, -{ - signing_key: SigningKey, - _wrapper: PhantomData Wrapper>, -} - -impl ScopedSigningKey -where - Wrapper: KeyWrapper, -{ - pub fn generate() -> Self { - Self { - signing_key: SigningKey::generate(&mut UnwrapErr(SysRng)), - _wrapper: PhantomData, - } - } - - pub fn unwrap_signing_key( - wrapped_key: &Wrapper::Wrapped, - aad: &Wrapper::Aad, - wrapper: &Wrapper, - ) -> CryptoResult { - let signing_key = wrapper.unwrap_key(wrapped_key, aad)?; - Ok(Self { - signing_key, - _wrapper: PhantomData, - }) - } - - pub fn wrapped_signing_key( - &self, - aad: &Wrapper::Aad, - wrapper: &Wrapper, - ) -> CryptoResult { - wrapper.wrap_key(&self.signing_key, aad) - } - - pub fn public_key_bytes(&self) -> [u8; 32] { - self.signing_key.verifying_key().to_bytes() - } - - pub fn sign(&self, message: &[u8]) -> Signature { - self.signing_key.sign(message) - } - - pub fn verify(&self, message: &[u8], signature: &Signature) -> CryptoResult<()> { - self.signing_key - .verify(message, signature) - .map_err(|_| CryptoError::SignatureInvalid) - } -} - -impl KeyMaterial for SigningKey { - fn try_from_bytes(bytes: &[u8]) -> CryptoResult { - let key = bytes - .try_into() - .map_err(|_| CryptoError::InvalidKeyLength { - expected: SECRET_KEY_LENGTH, - got: bytes.len(), - })?; - Ok(Self::from_bytes(&key)) - } - - fn as_bytes(&self) -> &[u8] { - self.as_bytes() - } -} diff --git a/rust/rust-code/core/src/crypto/keys/vault_key.rs b/rust/rust-code/core/src/crypto/keys/vault_key.rs deleted file mode 100644 index 198d10998..000000000 --- a/rust/rust-code/core/src/crypto/keys/vault_key.rs +++ /dev/null @@ -1,10 +0,0 @@ -use crate::crypto::AccountRootKey; -use crate::crypto::types::VaultId; -use crate::{define_aead_key, define_wrap}; -use aes_gcm_siv::Aes256GcmSiv; - -define_aead_key! { - random pub struct VaultKey(Aes256GcmSiv); -} - -define_wrap!(AccountRootKey => VaultKey, aad = VaultId); //TODO: also include epoch: vault_key_epoch diff --git a/rust/rust-code/core/src/crypto/macros.rs b/rust/rust-code/core/src/crypto/macros.rs deleted file mode 100644 index b3ec2305b..000000000 --- a/rust/rust-code/core/src/crypto/macros.rs +++ /dev/null @@ -1,92 +0,0 @@ -#[macro_export] -macro_rules! define_wrap { - ($wrapper:ident => $key:path, aad = $aad:path) => { - impl $crate::crypto::primitive::wrap_key::KeyWrapper<$key> for $wrapper { - type Aad = $aad; - type Wrapped = $crate::crypto::primitive::wrap_key::AeadWrappedKey<$key, Self>; - } - }; -} - -#[macro_export] -macro_rules! define_scoped_signing_key { - (wrapper = $wrapper:ident, key = $key:ident, wrapped_key = $wrapped:ident, aad = $aad:path $(,)?) => { - pub type $key = $crate::crypto::ScopedSigningKey<$wrapper>; - pub type $wrapped = <$wrapper as $crate::crypto::primitive::wrap_key::KeyWrapper< - ::ed25519_dalek::SigningKey, - >>::Wrapped; - - $crate::define_wrap!($wrapper => ::ed25519_dalek::SigningKey, aad = $aad); - - impl $wrapper { - pub fn wrap_signing_key( - &self, - signing_key: $key, - aad: &$aad, - ) -> $crate::crypto::error::CryptoResult<$wrapped> { - signing_key.wrapped_signing_key(aad, self) - } - - pub fn unwrap_signing_key( - &self, - wrapped_key: &$wrapped, - aad: &$aad, - ) -> $crate::crypto::error::CryptoResult<$key> { - $key::unwrap_signing_key(wrapped_key, aad, self) - } - } - }; -} - -#[macro_export] -macro_rules! define_aead_key { - ( - $(#[$meta:meta])* - random $vis:vis struct $name:ident($algo:ident); - ) => { - $crate::define_aead_key! { - $(#[$meta])* - $vis struct $name($algo); - } - - impl $name { - pub fn generate_random() -> Self { - let key = <::aead::Key::<$algo> as ::aead::Generate>::generate(); - Self(key) - } - } - }; - - ( - $(#[$meta:meta])* - $vis:vis struct $name:ident($algo:ident); - ) => { - $(#[$meta])* - #[derive(::zeroize::Zeroize, ::zeroize::ZeroizeOnDrop)] - $vis struct $name(::aead::Key<$algo>); - - impl $crate::crypto::AeadKey for $name { - type Algorithm = $algo; - - fn key(&self) -> &::aead::Key { - &self.0 - } - } - - impl $crate::crypto::KeyMaterial for $name { - fn try_from_bytes(bytes: &[u8]) -> $crate::crypto::error::CryptoResult { - let key = - ::aead::Key::<$algo>::try_from(bytes).map_err(|_| $crate::crypto::error::CryptoError::InvalidKeyLength { - expected: <<$algo as ::aead::KeySizeUser>::KeySize as ::aead::array::typenum::Unsigned>::USIZE, - got: bytes.len(), - })?; - - Ok(Self(key)) - } - - fn as_bytes(&self) -> &[u8] { - self.0.as_slice() - } - } - }; -} diff --git a/rust/rust-code/core/src/crypto/mod.rs b/rust/rust-code/core/src/crypto/mod.rs deleted file mode 100644 index c427f0d3c..000000000 --- a/rust/rust-code/core/src/crypto/mod.rs +++ /dev/null @@ -1,13 +0,0 @@ -pub mod error; -mod key; -mod keys; -mod macros; -pub mod primitive; -pub mod random; -pub mod types; - -pub use key::{AeadKey, KeyMaterial}; -pub use keys::{ - AccountRootKey, ItemAad, ItemDataAad, ItemKey, RootKEK, ScopedSigningKey, TryDeriveFrom, - VaultKey, -}; diff --git a/rust/rust-code/core/src/crypto/primitive/aead_data.rs b/rust/rust-code/core/src/crypto/primitive/aead_data.rs deleted file mode 100644 index 59723ab1d..000000000 --- a/rust/rust-code/core/src/crypto/primitive/aead_data.rs +++ /dev/null @@ -1,167 +0,0 @@ -use crate::crypto::AeadKey; -use crate::crypto::error::{CryptoError, CryptoResult}; -use aead::{Aead, Generate, KeyInit, Nonce, Payload}; -use serde::Serialize; - -pub struct AeadCiphertext -where - K: AeadKey, -{ - ciphertext: Vec, - nonce: Nonce, -} - -impl AeadCiphertext -where - K: AeadKey, -{ - pub fn from_parts(ciphertext: Vec, nonce: Nonce) -> Self { - Self { ciphertext, nonce } - } - - pub fn from_parts_bytes(ciphertext: Vec, nonce: &[u8]) -> Self { - Self::from_parts( - ciphertext, - Nonce::::try_from(nonce).expect("invalid nonce length"), - ) - } - - pub fn ciphertext(&self) -> &[u8] { - &self.ciphertext - } - - pub fn nonce(&self) -> &Nonce { - &self.nonce - } - - pub fn nonce_bytes(&self) -> &[u8] { - self.nonce.as_slice() - } -} - -pub trait AeadEncryptor: AeadKey { - type Aad: Serialize; - - fn encrypt_data(&self, data: &[u8], aad: &Self::Aad) -> CryptoResult> { - let aad_bytes = bcs::to_bytes(aad).map_err(|e| CryptoError::BCS(e.to_string()))?; - let cipher = Self::Algorithm::new(self.key()); - let nonce = Nonce::::generate(); - let ciphertext = cipher - .encrypt( - &nonce, - Payload { - msg: data, - aad: &aad_bytes, - }, - ) - .map_err(|_| CryptoError::EncryptionFailed)?; - - Ok(AeadCiphertext::from_parts(ciphertext, nonce)) - } - - fn decrypt_data( - &self, - ciphertext: &AeadCiphertext, - aad: &Self::Aad, - ) -> CryptoResult> { - let aad_bytes = bcs::to_bytes(aad).map_err(|e| CryptoError::BCS(e.to_string()))?; - let cipher = Self::Algorithm::new(self.key()); - cipher - .decrypt( - ciphertext.nonce(), - Payload { - msg: ciphertext.ciphertext(), - aad: &aad_bytes, - }, - ) - .map_err(|_| CryptoError::DecryptionFailed) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::define_aead_key; - use aes_gcm_siv::Aes256GcmSiv; - use serde::Serialize; - - define_aead_key! { - random struct TestKey(Aes256GcmSiv); - } - - #[derive(Serialize)] - struct TestAad { - scope: &'static str, - } - - impl AeadEncryptor for TestKey { - type Aad = TestAad; - } - - #[test] - fn round_trip() { - let key = TestKey::generate_random(); - let aad = TestAad { scope: "rt" }; - let plaintext = b"hello world"; - - let ct = key.encrypt_data(plaintext, &aad).unwrap(); - let pt = key.decrypt_data(&ct, &aad).unwrap(); - - assert_eq!(pt, plaintext); - } - - #[test] - fn wrong_aad_fails() { - let key = TestKey::generate_random(); - let aad = TestAad { scope: "right" }; - let wrong = TestAad { scope: "wrong" }; - - let ct = key.encrypt_data(b"data", &aad).unwrap(); - assert!(matches!( - key.decrypt_data(&ct, &wrong), - Err(CryptoError::DecryptionFailed) - )); - } - - #[test] - fn wrong_key_fails() { - let key = TestKey::generate_random(); - let other = TestKey::generate_random(); - let aad = TestAad { scope: "k" }; - - let ct = key.encrypt_data(b"data", &aad).unwrap(); - assert!(matches!( - other.decrypt_data(&ct, &aad), - Err(CryptoError::DecryptionFailed) - )); - } - - #[test] - fn tampered_ciphertext_fails() { - let key = TestKey::generate_random(); - let aad = TestAad { scope: "t" }; - - let ct = key.encrypt_data(b"data", &aad).unwrap(); - let mut bytes = ct.ciphertext().to_vec(); - bytes[0] ^= 0x01; - let tampered = AeadCiphertext::::from_parts_bytes(bytes, ct.nonce_bytes()); - - assert!(matches!( - key.decrypt_data(&tampered, &aad), - Err(CryptoError::DecryptionFailed) - )); - } - - #[test] - fn nonce_round_trip_via_bytes() { - let key = TestKey::generate_random(); - let aad = TestAad { scope: "n" }; - - let ct = key.encrypt_data(b"payload", &aad).unwrap(); - let rebuilt = - AeadCiphertext::::from_parts_bytes(ct.ciphertext().to_vec(), ct.nonce_bytes()); - - let pt = key.decrypt_data(&rebuilt, &aad).unwrap(); - assert_eq!(pt, b"payload"); - } -} diff --git a/rust/rust-code/core/src/crypto/primitive/argon2.rs b/rust/rust-code/core/src/crypto/primitive/argon2.rs deleted file mode 100644 index d52da3b67..000000000 --- a/rust/rust-code/core/src/crypto/primitive/argon2.rs +++ /dev/null @@ -1,181 +0,0 @@ -use crate::crypto::error::{CryptoError, CryptoResult}; -use argon2::{Algorithm, Argon2, Params, Version}; - -pub const MIN_SALT_LEN: usize = 16; -pub const DERIVED_KEY_LEN: usize = 32; -pub(crate) const MAX_ARGON2_MEM_KIB: u32 = 256 * 1024; - -#[derive(Clone, Copy)] -pub(crate) struct Argon2Params { - /// Memory cost in KiB - pub mem_kib: u32, - /// Iterations / time cost - pub iters: u32, - /// Degree of parallelism - pub lanes: u32, -} - -impl Default for Argon2Params { - fn default() -> Self { - Self { - mem_kib: 64 * 1024, - iters: 3, - lanes: 4, - } - } -} - -fn build_argon<'a>(params: Argon2Params) -> CryptoResult> { - if params.mem_kib > MAX_ARGON2_MEM_KIB { - return Err(CryptoError::KdfError(format!( - "argon2 mem_kib too large: {} > {}", - params.mem_kib, MAX_ARGON2_MEM_KIB, - ))); - } - let params = Params::new( - params.mem_kib, - params.iters, - params.lanes, - Some(DERIVED_KEY_LEN), - ) - .map_err(|e| CryptoError::KdfError(e.to_string()))?; - Ok(Argon2::new(Algorithm::Argon2id, Version::V0x13, params)) -} - -/// Derive `DERIVED_KEY_LEN` bytes from a password using Argon2id with the -/// [`Argon2Params::default`] cost profile. See [`derive_argon2id_with_params`]. -pub(crate) fn derive_argon2id( - password: &[u8], - salt: &[u8], - domain: &[u8], -) -> CryptoResult<[u8; DERIVED_KEY_LEN]> { - derive_argon2id_with_params(password, salt, domain, Argon2Params::default()) -} - -/// Derive `DERIVED_KEY_LEN` bytes from a password using Argon2id with explicit -/// cost [`Argon2Params`], so a derivation can be reproduced from costs recorded -/// at seal time. Invalid or over-limit params yield [`CryptoError::KdfError`] -/// rather than panicking. -/// -/// The salt must be caller-provided, at least `MIN_SALT_LEN` bytes, and persisted with the -/// credential so the same KEK can be re-derived on later logins. `domain` is mixed into the salt -/// as a label to separate otherwise-identical derivations (e.g. password-KEK vs recovery-KEK). -/// -/// The password is not zeroized by this function - the caller owns the password buffer and must -/// wrap it in `Zeroizing` / a secret type at the FFI boundary. -pub(crate) fn derive_argon2id_with_params( - password: &[u8], - salt: &[u8], - domain: &[u8], - params: Argon2Params, -) -> CryptoResult<[u8; DERIVED_KEY_LEN]> { - if password.is_empty() { - return Err(CryptoError::KdfError("empty password".into())); - } - if salt.len() < MIN_SALT_LEN { - return Err(CryptoError::KdfError(format!( - "salt too short: {} < {}", - salt.len(), - MIN_SALT_LEN, - ))); - } - - // Bind domain label into the salt so that changing that produces an independent key. - let mut full_salt = Vec::with_capacity(salt.len() + domain.len()); - full_salt.extend_from_slice(salt); - full_salt.extend_from_slice(domain); - - let argon2 = build_argon(params)?; - - let mut out = [0u8; DERIVED_KEY_LEN]; - argon2 - .hash_password_into(password, &full_salt, out.as_mut_slice()) - .map_err(|e| CryptoError::KdfError(e.to_string()))?; - Ok(out) -} - -#[cfg(test)] -mod tests { - use super::*; - - const PW: &[u8] = b"correct horse battery staple"; - const SALT: [u8; 16] = [0x11; 16]; - - #[test] - fn deterministic_for_same_inputs() { - let a = derive_argon2id(PW, &SALT, b"pwd").unwrap(); - let b = derive_argon2id(PW, &SALT, b"pwd").unwrap(); - assert_eq!(a, b); - } - - #[test] - fn different_salt_different_key() { - let a = derive_argon2id(PW, &SALT, b"pwd").unwrap(); - let b = derive_argon2id(PW, &[0x22; 16], b"pwd").unwrap(); - assert_ne!(a, b); - } - - #[test] - fn rejects_empty_password() { - assert!(derive_argon2id(b"", &SALT, b"pwd").is_err()); - } - - #[test] - fn rejects_short_salt() { - assert!(derive_argon2id(PW, &[0u8; 8], b"pwd").is_err()); - } - - #[test] - fn different_params_different_key() { - let a = derive_argon2id_with_params(PW, &SALT, b"pwd", Argon2Params::default()).unwrap(); - let b = derive_argon2id_with_params( - PW, - &SALT, - b"pwd", - Argon2Params { - iters: Argon2Params::default().iters + 1, - ..Argon2Params::default() - }, - ) - .unwrap(); - assert_ne!(a, b); - } - - #[test] - fn default_params_match_plain_derive() { - let plain = derive_argon2id(PW, &SALT, b"pwd").unwrap(); - let explicit = - derive_argon2id_with_params(PW, &SALT, b"pwd", Argon2Params::default()).unwrap(); - assert_eq!(plain, explicit); - } - - #[test] - fn rejects_excessive_memory() { - let err = derive_argon2id_with_params( - PW, - &SALT, - b"pwd", - Argon2Params { - mem_kib: MAX_ARGON2_MEM_KIB + 1, - ..Argon2Params::default() - }, - ) - .unwrap_err(); - assert!(matches!(err, CryptoError::KdfError(_))); - } - - #[test] - fn rejects_degenerate_params() { - let err = derive_argon2id_with_params( - PW, - &SALT, - b"pwd", - Argon2Params { - iters: 0, - ..Argon2Params::default() - }, - ) - .unwrap_err(); - assert!(matches!(err, CryptoError::KdfError(_))); - } -} diff --git a/rust/rust-code/core/src/crypto/primitive/hkdf.rs b/rust/rust-code/core/src/crypto/primitive/hkdf.rs deleted file mode 100644 index ebe6856ed..000000000 --- a/rust/rust-code/core/src/crypto/primitive/hkdf.rs +++ /dev/null @@ -1,65 +0,0 @@ -use crate::crypto::error::{CryptoError, CryptoResult}; -use hkdf::Hkdf; -use sha2::Sha256; - -/// Length of the derived key material (bytes). -pub const DERIVED_KEY_LEN: usize = 32; - -/// Derive `DERIVED_KEY_LEN` bytes from high-entropy keying material using -/// HKDF-SHA256 (RFC 5869). -/// -/// Unlike [`super::argon2::derive_argon2id`], this performs no memory-hard -/// stretch: it is for inputs that are already uniformly random (e.g. the ARK), -/// where a password KDF would add cost without security. `salt` is the HKDF -/// extract salt (a fresh per-use random value); `info` is the HKDF expand label -/// used to domain-separate otherwise-identical derivations. -/// -/// HKDF accepts any salt length (including empty), so - unlike the Argon2id -/// primitive - no minimum-salt check is enforced here. -pub(crate) fn derive_hkdf_sha256( - ikm: &[u8], - salt: &[u8], - info: &[u8], -) -> CryptoResult<[u8; DERIVED_KEY_LEN]> { - let hk = Hkdf::::new(Some(salt), ikm); - let mut out = [0u8; DERIVED_KEY_LEN]; - hk.expand(info, &mut out) - .map_err(|e| CryptoError::KdfError(e.to_string()))?; - Ok(out) -} - -#[cfg(test)] -mod tests { - use super::*; - - const IKM: &[u8] = &[7u8; 32]; - const SALT: &[u8] = &[0x11; 16]; - - #[test] - fn deterministic_for_same_inputs() { - let a = derive_hkdf_sha256(IKM, SALT, b"info").unwrap(); - let b = derive_hkdf_sha256(IKM, SALT, b"info").unwrap(); - assert_eq!(a, b); - } - - #[test] - fn different_salt_different_key() { - let a = derive_hkdf_sha256(IKM, SALT, b"info").unwrap(); - let b = derive_hkdf_sha256(IKM, &[0x22; 16], b"info").unwrap(); - assert_ne!(a, b); - } - - #[test] - fn different_info_different_key() { - let a = derive_hkdf_sha256(IKM, SALT, b"info-a").unwrap(); - let b = derive_hkdf_sha256(IKM, SALT, b"info-b").unwrap(); - assert_ne!(a, b); - } - - #[test] - fn different_ikm_different_key() { - let a = derive_hkdf_sha256(IKM, SALT, b"info").unwrap(); - let b = derive_hkdf_sha256(&[8u8; 32], SALT, b"info").unwrap(); - assert_ne!(a, b); - } -} diff --git a/rust/rust-code/core/src/crypto/primitive/mod.rs b/rust/rust-code/core/src/crypto/primitive/mod.rs deleted file mode 100644 index deae068a2..000000000 --- a/rust/rust-code/core/src/crypto/primitive/mod.rs +++ /dev/null @@ -1,4 +0,0 @@ -pub mod aead_data; -pub mod argon2; -pub mod hkdf; -pub mod wrap_key; diff --git a/rust/rust-code/core/src/crypto/primitive/wrap_key.rs b/rust/rust-code/core/src/crypto/primitive/wrap_key.rs deleted file mode 100644 index 1edd21222..000000000 --- a/rust/rust-code/core/src/crypto/primitive/wrap_key.rs +++ /dev/null @@ -1,278 +0,0 @@ -use crate::crypto::error::{CryptoError, CryptoResult}; -use crate::crypto::{AeadKey, KeyMaterial}; -use aead::{Aead, Generate, KeyInit, Nonce, Payload}; -use serde::Serialize; -use std::marker::PhantomData; -use zeroize::Zeroizing; - -pub trait WrappedKey: Sized -where - Target: KeyMaterial, - Wrapper: AeadKey, -{ - fn from_parts(ciphertext: Vec, nonce: Nonce) -> Self; - fn from_parts_bytes(ciphertext: Vec, nonce: &[u8]) -> Self; - fn ciphertext(&self) -> &[u8]; - fn nonce(&self) -> &Nonce; - fn nonce_bytes(&self) -> &[u8] { - self.nonce().as_slice() - } -} - -pub struct AeadWrappedKey -where - Target: KeyMaterial, - Wrapper: AeadKey, -{ - ciphertext: Vec, - nonce: Nonce, - _target: PhantomData Target>, -} - -impl WrappedKey for AeadWrappedKey -where - Target: KeyMaterial, - Wrapper: AeadKey, -{ - fn from_parts(ciphertext: Vec, nonce: Nonce) -> Self { - Self { - ciphertext, - nonce, - _target: PhantomData, - } - } - - fn from_parts_bytes(ciphertext: Vec, nonce: &[u8]) -> Self { - Self::from_parts( - ciphertext, - Nonce::::try_from(nonce).expect("invalid nonce length"), - ) - } - - fn ciphertext(&self) -> &[u8] { - &self.ciphertext - } - - fn nonce(&self) -> &Nonce { - &self.nonce - } -} - -pub trait KeyWrapper: AeadKey -where - Target: KeyMaterial, -{ - type Aad: Serialize; - type Wrapped: WrappedKey; - - fn wrap_key(&self, key_to_wrap: &Target, aad: &Self::Aad) -> CryptoResult { - let aad = bcs::to_bytes(aad).map_err(|e| CryptoError::BCS(e.to_string()))?; - wrap_bytes::(self.key(), key_to_wrap.as_bytes(), &aad) - } - - fn unwrap_key(&self, wrapped_key: &Self::Wrapped, aad: &Self::Aad) -> CryptoResult { - let aad = bcs::to_bytes(aad).map_err(|e| CryptoError::BCS(e.to_string()))?; - let unwrapped = unwrap_bytes::(self.key(), wrapped_key, &aad)?; - Target::try_from_bytes(&unwrapped) - } -} - -fn wrap_bytes( - key: &aead::Key, - key_to_wrap: &[u8], - aad: &[u8], -) -> CryptoResult -where - Target: KeyMaterial, - Wrapper: KeyWrapper, -{ - let cipher = Wrapper::Algorithm::new(key); - let nonce = Nonce::::generate(); - let ciphertext = cipher - .encrypt( - &nonce, - Payload { - msg: key_to_wrap, - aad, - }, - ) - .map_err(|_| CryptoError::KeyWrapFailed)?; - - Ok(Wrapper::Wrapped::from_parts(ciphertext, nonce)) -} - -fn unwrap_bytes( - key: &aead::Key, - wrapped_key: &Wrapper::Wrapped, - aad: &[u8], -) -> CryptoResult>> -where - Target: KeyMaterial, - Wrapper: KeyWrapper, -{ - let cipher = Wrapper::Algorithm::new(key); - let plaintext = cipher - .decrypt( - wrapped_key.nonce(), - Payload { - msg: wrapped_key.ciphertext(), - aad, - }, - ) - .map_err(|_| CryptoError::KeyUnwrapFailed)?; - - Ok(Zeroizing::new(plaintext)) -} - -#[cfg(test)] -mod tests { - use super::KeyWrapper; - use crate::crypto::error::{CryptoError, CryptoResult}; - use crate::crypto::{AeadKey, KeyMaterial}; - use aead::Key; - use aes_gcm_siv::Aes256GcmSiv; - use serde::Serialize; - use zeroize::{Zeroize, ZeroizeOnDrop}; - - #[derive(Serialize)] - struct TestAad { - context: &'static str, - } - - #[derive(Zeroize, ZeroizeOnDrop)] - struct TestWrappingKey(Key); - - struct TestWrappedSecret { - bytes: [u8; 32], - } - - type TestWrappedBlob = super::AeadWrappedKey; - - impl KeyMaterial for TestWrappingKey { - fn try_from_bytes(bytes: &[u8]) -> CryptoResult { - let key = Key::::try_from(bytes).map_err(|_| { - CryptoError::InvalidKeyLength { - expected: 32, - got: bytes.len(), - } - })?; - - Ok(Self(key)) - } - - fn as_bytes(&self) -> &[u8] { - self.0.as_slice() - } - } - - impl AeadKey for TestWrappingKey { - type Algorithm = Aes256GcmSiv; - - fn key(&self) -> &Key { - &self.0 - } - } - - impl KeyMaterial for TestWrappedSecret { - fn try_from_bytes(bytes: &[u8]) -> CryptoResult { - let bytes: [u8; 32] = bytes - .try_into() - .map_err(|_| CryptoError::InvalidKeyLength { - expected: 32, - got: bytes.len(), - })?; - if bytes[0] != 0xAA { - return Err(CryptoError::InvalidKey); - } - - Ok(Self { bytes }) - } - - fn as_bytes(&self) -> &[u8] { - &self.bytes - } - } - - impl KeyWrapper for TestWrappingKey { - type Aad = TestAad; - type Wrapped = TestWrappedBlob; - } - - fn test_wrapping_key(byte: u8) -> TestWrappingKey { - TestWrappingKey::try_from_bytes(&[byte; 32]).unwrap() - } - - fn test_wrapped_secret() -> TestWrappedSecret { - let mut bytes = [0x11; 32]; - bytes[0] = 0xAA; - TestWrappedSecret { bytes } - } - - fn invalid_wrapped_secret() -> TestWrappedSecret { - TestWrappedSecret { bytes: [0x11; 32] } - } - - #[test] - fn round_trip() { - let wrapping_key = test_wrapping_key(7); - let wrapped_secret = test_wrapped_secret(); - let aad = TestAad { - context: "round-trip", - }; - - let wrapped = wrapping_key.wrap_key(&wrapped_secret, &aad).unwrap(); - let unwrapped = wrapping_key.unwrap_key(&wrapped, &aad).unwrap(); - - assert_eq!(wrapped_secret.as_bytes(), unwrapped.as_bytes()); - } - - #[test] - fn wrong_wrapping_key_fails() { - let wrapping_key = test_wrapping_key(7); - let wrong_key = test_wrapping_key(8); - let wrapped_secret = test_wrapped_secret(); - let aad = TestAad { - context: "wrong-key", - }; - - let wrapped = wrapping_key.wrap_key(&wrapped_secret, &aad).unwrap(); - - assert!(matches!( - wrong_key.unwrap_key(&wrapped, &aad), - Err(CryptoError::KeyUnwrapFailed) - )); - } - - #[test] - fn wrong_aad_fails() { - let wrapping_key = test_wrapping_key(7); - let wrapped_secret = test_wrapped_secret(); - let correct_aad = TestAad { context: "correct" }; - let wrong_aad = TestAad { context: "wrong" }; - - let wrapped = wrapping_key - .wrap_key(&wrapped_secret, &correct_aad) - .unwrap(); - - assert!(matches!( - wrapping_key.unwrap_key(&wrapped, &wrong_aad), - Err(CryptoError::KeyUnwrapFailed) - )); - } - - #[test] - fn invalid_key_material_fails_after_decrypt() { - let wrapping_key = test_wrapping_key(7); - let invalid_secret = invalid_wrapped_secret(); - let aad = TestAad { - context: "invalid-material", - }; - - let wrapped = wrapping_key.wrap_key(&invalid_secret, &aad).unwrap(); - - assert!(matches!( - wrapping_key.unwrap_key(&wrapped, &aad), - Err(CryptoError::InvalidKey) - )); - } -} diff --git a/rust/rust-code/core/src/crypto/random.rs b/rust/rust-code/core/src/crypto/random.rs deleted file mode 100644 index eea905fc3..000000000 --- a/rust/rust-code/core/src/crypto/random.rs +++ /dev/null @@ -1,10 +0,0 @@ -use rand::Rng; -use rand::rand_core::UnwrapErr; -use rand::rngs::SysRng; - -/// Generate a random byte array of the specified length. -pub fn random_bytes() -> [u8; N] { - let mut buf = [0u8; N]; - UnwrapErr(&mut SysRng).fill_bytes(&mut buf); - buf -} diff --git a/rust/rust-code/core/src/crypto/types.rs b/rust/rust-code/core/src/crypto/types.rs deleted file mode 100644 index fe90bfb4e..000000000 --- a/rust/rust-code/core/src/crypto/types.rs +++ /dev/null @@ -1,5 +0,0 @@ -use uuid::Uuid; - -pub type UserId = Uuid; -pub type VaultId = Uuid; -pub type ItemId = Uuid; diff --git a/rust/rust-code/core/src/passkey/keygo_passkey.rs b/rust/rust-code/core/src/passkey/keygo_passkey.rs deleted file mode 100644 index bd2030f60..000000000 --- a/rust/rust-code/core/src/passkey/keygo_passkey.rs +++ /dev/null @@ -1,114 +0,0 @@ -use coset::{CborSerializable, CoseError, CoseKey}; -use passkey::types::Passkey; -use serde::{Deserialize, Serialize}; -use thiserror::Error; - -#[derive(Serialize, Deserialize)] -pub struct KeyGoPasskey { - key: Vec, - credential_id: Vec, - rp_id: String, - user_handle: Option>, - username: Option, - user_display_name: Option, - counter: Option, -} - -#[derive(Serialize, Deserialize)] -pub enum KeyGoPasskeyWire { - V1(KeyGoPasskey), -} - -#[derive(Debug, Error)] -pub enum PasskeyCodecError { - #[error("cose key error: {0:?}")] - Cose(CoseError), - #[error("cbor encode error: {0}")] - CborEncode(#[from] ciborium::ser::Error), - #[error("cbor decode error: {0}")] - CborDecode(#[from] ciborium::de::Error), -} - -impl TryFrom for KeyGoPasskey { - type Error = PasskeyCodecError; - - fn try_from(value: Passkey) -> Result { - let key = value.key.to_vec().map_err(PasskeyCodecError::Cose)?; - Ok(Self { - key, - credential_id: value.credential_id.to_vec(), - rp_id: value.rp_id, - user_handle: value.user_handle.map(|b| b.to_vec()), - username: value.username, - user_display_name: value.user_display_name, - counter: value.counter, - }) - } -} - -impl TryFrom for Passkey { - type Error = PasskeyCodecError; - - fn try_from(value: KeyGoPasskey) -> Result { - let key = CoseKey::from_slice(&value.key).map_err(PasskeyCodecError::Cose)?; - Ok(Self { - key, - credential_id: value.credential_id.into(), - rp_id: value.rp_id, - user_handle: value.user_handle.map(|b| b.into()), - username: value.username, - user_display_name: value.user_display_name, - counter: value.counter, - extensions: Default::default(), - }) - } -} - -pub fn to_bytes(passkey: Passkey) -> Result, PasskeyCodecError> { - let wire = KeyGoPasskeyWire::V1(KeyGoPasskey::try_from(passkey)?); - let mut buf = Vec::new(); - ciborium::into_writer(&wire, &mut buf)?; - Ok(buf) -} - -pub fn passkey_from_bytes(bytes: &[u8]) -> Result { - let wire: KeyGoPasskeyWire = ciborium::from_reader(bytes)?; - match wire { - KeyGoPasskeyWire::V1(kgo_pk) => Passkey::try_from(kgo_pk), - } -} - -#[cfg(test)] -mod tests { - use super::*; - use coset::CoseKeyBuilder; - use coset::iana; - - fn sample_passkey() -> Passkey { - let key = CoseKeyBuilder::new_okp_key() - .algorithm(iana::Algorithm::EdDSA) - .build(); - Passkey { - key, - credential_id: vec![1, 2, 3, 4].into(), - rp_id: "example.com".to_string(), - user_handle: Some(vec![9, 9, 9].into()), - username: Some("alice".to_string()), - user_display_name: Some("Alice".to_string()), - counter: Some(42), - extensions: Default::default(), - } - } - - #[test] - fn roundtrip_preserves_fields() { - let original = sample_passkey(); - let bytes = to_bytes(original.clone()).expect("encode"); - let decoded = passkey_from_bytes(&bytes).expect("decode"); - - assert_eq!(decoded.credential_id, original.credential_id); - assert_eq!(decoded.rp_id, original.rp_id); - assert_eq!(decoded.user_handle, original.user_handle); - assert_eq!(decoded.counter, original.counter); - } -} diff --git a/rust/rust-code/core/src/passkey/mod.rs b/rust/rust-code/core/src/passkey/mod.rs deleted file mode 100644 index 9fcf4f0f7..000000000 --- a/rust/rust-code/core/src/passkey/mod.rs +++ /dev/null @@ -1,11 +0,0 @@ -mod authenticator; -mod keygo_passkey; -mod provider; -mod registration; - -pub use keygo_passkey::PasskeyCodecError; -pub use provider::{ProviderError, provide_passkey}; -pub use registration::{ - KeyGoRegistrationResponse, PasskeyInformation, RegistrationError, get_passkey_information, - register_passkey, -}; diff --git a/rust/rust-code/core/src/passkey/provider.rs b/rust/rust-code/core/src/passkey/provider.rs deleted file mode 100644 index b565a49c0..000000000 --- a/rust/rust-code/core/src/passkey/provider.rs +++ /dev/null @@ -1,48 +0,0 @@ -use crate::passkey::authenticator::keygo_authenticator; -use crate::passkey::keygo_passkey::{PasskeyCodecError, passkey_from_bytes}; -use crate::url::sanitize_to_https_url; -use passkey::client::{Client, WebauthnError}; -use passkey::types::webauthn::{CredentialRequestOptions, PublicKeyCredentialRequestOptions}; -use thiserror::Error; - -#[derive(Debug, Error)] -pub enum ProviderError { - #[error("invalid json format")] - InvalidJsonFormat, - #[error("invalid url")] - InvalidDomain, - #[error("webauthn error: {0:?}")] - WebauthnError(WebauthnError), - #[error("passkey decode error: {0:?}")] - PasskeyDecodeError(PasskeyCodecError), -} - -pub async fn provide_passkey( - json_request: &str, - passkey: &[u8], - client_data_hash: Option>, -) -> Result { - let request_options: PublicKeyCredentialRequestOptions = - serde_json::from_str(json_request).map_err(|_| ProviderError::InvalidJsonFormat)?; - - let passkey = passkey_from_bytes(passkey).map_err(ProviderError::PasskeyDecodeError)?; - - let authenticator = keygo_authenticator(Some(passkey)); - let mut client = Client::new(authenticator); - - let domain = request_options.rp_id.as_deref().unwrap_or_default(); - let domain = sanitize_to_https_url(domain).map_err(|_| ProviderError::InvalidDomain)?; - - let options = CredentialRequestOptions { - public_key: request_options, - }; - - let credential = client - .authenticate(domain, options, client_data_hash) - .await - .map_err(ProviderError::WebauthnError)?; - - let response_json = serde_json::to_string(&credential).unwrap(); - - Ok(response_json) -} diff --git a/rust/rust-code/core/src/passkey/registration.rs b/rust/rust-code/core/src/passkey/registration.rs deleted file mode 100644 index d35f049f7..000000000 --- a/rust/rust-code/core/src/passkey/registration.rs +++ /dev/null @@ -1,91 +0,0 @@ -use crate::passkey::authenticator::keygo_authenticator; -use crate::passkey::keygo_passkey::{PasskeyCodecError, to_bytes}; -use crate::passkey::registration::RegistrationError::{ - InvalidDomain, InvalidJsonFormat, KeyEncodeError, -}; -use crate::url::sanitize_to_https_url; -use passkey::client::{Client, DefaultClientData, WebauthnError}; -use passkey::types::webauthn::{CredentialCreationOptions, PublicKeyCredentialCreationOptions}; -use thiserror::Error; - -pub struct KeyGoRegistrationResponse { - pub response: String, - pub user_name: String, - pub user_display_name: String, - pub credential_id: Vec, - pub private_key: Vec, - pub rp: String, -} - -#[derive(Debug, Error)] -pub enum RegistrationError { - #[error("invalid json format")] - InvalidJsonFormat, - #[error("invalid url")] - InvalidDomain, - #[error("webauthn error: {0:?}")] - WebauthnError(WebauthnError), - #[error("key encode error: {0:?}")] - KeyEncodeError(PasskeyCodecError), -} - -pub struct PasskeyInformation { - pub exclude_credentials: Vec>, - pub rp: String, -} - -pub fn get_passkey_information( - json_request: &str, -) -> Result { - let creation_options: PublicKeyCredentialCreationOptions = - serde_json::from_str(json_request).map_err(|_| InvalidJsonFormat)?; - - let exclude_credentials: Vec> = creation_options - .exclude_credentials - .unwrap_or_default() - .into_iter() - .map(|desc| desc.id.into()) - .collect(); - let rp = creation_options.rp.id.unwrap_or_default(); - - Ok(PasskeyInformation { - exclude_credentials, - rp, - }) -} - -pub async fn register_passkey( - json_request: &str, -) -> Result { - let creation_options: PublicKeyCredentialCreationOptions = - serde_json::from_str(json_request).map_err(|_| InvalidJsonFormat)?; - - let domain = creation_options.rp.id.as_deref().unwrap_or_default(); - let user_name = creation_options.rp.name.clone(); - let user_display_name = creation_options.user.display_name.clone(); - - let authenticator = keygo_authenticator(None); - let mut client = Client::new(authenticator); - - let domain = sanitize_to_https_url(domain).map_err(|_| InvalidDomain)?; - let options = CredentialCreationOptions { - public_key: creation_options, - }; - let pub_key_credential = client - .register(domain, options, DefaultClientData) - .await - .map_err(RegistrationError::WebauthnError)?; - let response_json = serde_json::to_string(&pub_key_credential).unwrap(); - - let response = client.authenticator().store().clone().unwrap(); - - let credential_id = response.credential_id.clone().into(); - Ok(KeyGoRegistrationResponse { - response: response_json, - user_name, - user_display_name, - credential_id, - rp: response.rp_id.clone(), - private_key: to_bytes(response).map_err(KeyEncodeError)?, - }) -} diff --git a/rust/rust-code/rust-toolchain.toml b/rust/rust-toolchain.toml similarity index 100% rename from rust/rust-code/rust-toolchain.toml rename to rust/rust-toolchain.toml diff --git a/rust/src/main/AndroidManifest.xml b/rust/src/main/AndroidManifest.xml deleted file mode 100644 index a5918e68a..000000000 --- a/rust/src/main/AndroidManifest.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - - \ No newline at end of file diff --git a/rust/tools/uniffi-bindgen/Cargo.toml b/rust/tools/uniffi-bindgen/Cargo.toml new file mode 100644 index 000000000..70f794cb2 --- /dev/null +++ b/rust/tools/uniffi-bindgen/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "uniffi-bindgen" +version.workspace = true +edition.workspace = true +publish = false + +[dependencies] +uniffi = { workspace = true, features = ["cli"] } diff --git a/rust/rust-code/bindings/uniffi-bindgen.rs b/rust/tools/uniffi-bindgen/src/main.rs similarity index 100% rename from rust/rust-code/bindings/uniffi-bindgen.rs rename to rust/tools/uniffi-bindgen/src/main.rs diff --git a/settings.gradle.kts b/settings.gradle.kts index 6cdf5b03d..82d2505ab 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -36,7 +36,7 @@ include(":core:util") include(":core:security") include(":core:biometrics") include(":core:ui") -include(":rust") +include(":core:rust") include(":feature:credentials") include(":feature:list_screen") include(":feature:item:core")