From 180136b46375345f00093ac8ad0b8a95098c3b21 Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Sat, 10 Oct 2026 11:05:58 +0200 Subject: [PATCH 01/18] refactor(rust): move android rust core to :core:rust --- CLAUDE.md | 7 ++++--- app/build.gradle.kts | 4 ++-- core/identity/build.gradle.kts | 6 +++--- core/item/build.gradle.kts | 2 +- core/rust/.gitignore | 1 + {rust => core/rust}/build.gradle.kts | 2 +- {rust => core/rust}/consumer-rules.pro | 0 {rust => core/rust}/proguard-rules.pro | 0 core/rust/src/main/AndroidManifest.xml | 4 ++++ .../kotlin/de/davis/keygo/rust/backup/BackupResult.kt | 0 .../kotlin/de/davis/keygo/rust/backup/CsvBackupManager.kt | 0 .../de/davis/keygo/rust/backup/JsonBackupManager.kt | 0 .../src/main/kotlin/de/davis/keygo/rust/card/Card.kt | 0 .../src/main/kotlin/de/davis/keygo/rust/di/RustModule.kt | 0 .../main/kotlin/de/davis/keygo/rust/item/ItemManager.kt | 0 .../kotlin/de/davis/keygo/rust/passkey/PasskeyManager.kt | 0 .../src/main/kotlin/de/davis/keygo/rust/totp/Totp.kt | 0 .../main/kotlin/de/davis/keygo/rust/vault/VaultManager.kt | 0 .../main/kotlin/de/davis/keygo/rust/wrap/KeyWrapper.kt | 0 .../kotlin/de/davis/keygo/rust/FakeCardFormatter.kt | 0 .../kotlin/de/davis/keygo/rust/FakeCsvBackupManager.kt | 0 .../kotlin/de/davis/keygo/rust/FakeItemManager.kt | 0 .../kotlin/de/davis/keygo/rust/FakeJsonBackupManager.kt | 0 .../kotlin/de/davis/keygo/rust/FakeKeyWrapper.kt | 0 .../kotlin/de/davis/keygo/rust/FakePasskeyManager.kt | 0 .../kotlin/de/davis/keygo/rust/FakeTotpService.kt | 0 .../kotlin/de/davis/keygo/rust/FakeVaultManager.kt | 0 core/security/build.gradle.kts | 8 ++++---- feature/auth/build.gradle.kts | 4 ++-- feature/autofill/build.gradle.kts | 2 +- feature/backup/build.gradle.kts | 4 ++-- feature/credentials/build.gradle.kts | 4 ++-- feature/item/core/build.gradle.kts | 2 +- feature/item/create/build.gradle.kts | 2 +- feature/item/view/build.gradle.kts | 2 +- feature/onboarding/build.gradle.kts | 2 +- feature/password-health/build.gradle.kts | 2 +- feature/settings/build.gradle.kts | 2 +- feature/totp/build.gradle.kts | 4 ++-- feature/vault/build.gradle.kts | 4 ++-- rust/.gitignore | 2 -- rust/rust-code/Makefile | 4 ++-- rust/src/main/AndroidManifest.xml | 4 ---- settings.gradle.kts | 2 +- 44 files changed, 40 insertions(+), 40 deletions(-) create mode 100644 core/rust/.gitignore rename {rust => core/rust}/build.gradle.kts (95%) rename {rust => core/rust}/consumer-rules.pro (100%) rename {rust => core/rust}/proguard-rules.pro (100%) create mode 100644 core/rust/src/main/AndroidManifest.xml rename {rust => core/rust}/src/main/kotlin/de/davis/keygo/rust/backup/BackupResult.kt (100%) rename {rust => core/rust}/src/main/kotlin/de/davis/keygo/rust/backup/CsvBackupManager.kt (100%) rename {rust => core/rust}/src/main/kotlin/de/davis/keygo/rust/backup/JsonBackupManager.kt (100%) rename {rust => core/rust}/src/main/kotlin/de/davis/keygo/rust/card/Card.kt (100%) rename {rust => core/rust}/src/main/kotlin/de/davis/keygo/rust/di/RustModule.kt (100%) rename {rust => core/rust}/src/main/kotlin/de/davis/keygo/rust/item/ItemManager.kt (100%) rename {rust => core/rust}/src/main/kotlin/de/davis/keygo/rust/passkey/PasskeyManager.kt (100%) rename {rust => core/rust}/src/main/kotlin/de/davis/keygo/rust/totp/Totp.kt (100%) rename {rust => core/rust}/src/main/kotlin/de/davis/keygo/rust/vault/VaultManager.kt (100%) rename {rust => core/rust}/src/main/kotlin/de/davis/keygo/rust/wrap/KeyWrapper.kt (100%) rename {rust => core/rust}/src/testFixtures/kotlin/de/davis/keygo/rust/FakeCardFormatter.kt (100%) rename {rust => core/rust}/src/testFixtures/kotlin/de/davis/keygo/rust/FakeCsvBackupManager.kt (100%) rename {rust => core/rust}/src/testFixtures/kotlin/de/davis/keygo/rust/FakeItemManager.kt (100%) rename {rust => core/rust}/src/testFixtures/kotlin/de/davis/keygo/rust/FakeJsonBackupManager.kt (100%) rename {rust => core/rust}/src/testFixtures/kotlin/de/davis/keygo/rust/FakeKeyWrapper.kt (100%) rename {rust => core/rust}/src/testFixtures/kotlin/de/davis/keygo/rust/FakePasskeyManager.kt (100%) rename {rust => core/rust}/src/testFixtures/kotlin/de/davis/keygo/rust/FakeTotpService.kt (100%) rename {rust => core/rust}/src/testFixtures/kotlin/de/davis/keygo/rust/FakeVaultManager.kt (100%) delete mode 100644 rust/src/main/AndroidManifest.xml diff --git a/CLAUDE.md b/CLAUDE.md index 869f43688..632c89754 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -54,7 +54,8 @@ 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; the Rust | +| | sources it builds live in `rust/rust-code/` | ## Key Patterns @@ -155,10 +156,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/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 95% rename from rust/build.gradle.kts rename to core/rust/build.gradle.kts index f4f71a48e..3f4f40118 100644 --- a/rust/build.gradle.kts +++ b/core/rust/build.gradle.kts @@ -45,7 +45,7 @@ val buildRust by tasks.register("buildRust") { providers.gradleProperty("rust.compile").getOrElse("true").toBoolean() } - workingDir = projectDir.resolve("rust-code") + workingDir = rootDir.resolve("rust/rust-code") commandLine( "make", 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 100% 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 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 100% 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 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 100% 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 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/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/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/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/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/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/Makefile b/rust/rust-code/Makefile index c8d29fed2..aeeb528fb 100644 --- a/rust/rust-code/Makefile +++ b/rust/rust-code/Makefile @@ -5,8 +5,8 @@ MIN_SDK ?= 26 PROFILE ?= release CRATE := keygo-bindings 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 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/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") From 11099e1ed56486da3b2e10d9cb6ddf4138897fa8 Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Sat, 10 Oct 2026 13:11:32 +0200 Subject: [PATCH 02/18] refactor(rust): restructure code --- .github/workflows/ci.yaml | 10 +- CLAUDE.md | 7 +- core/rust/build.gradle.kts | 10 +- rust/{rust-code => }/Cargo.lock | 304 +++++++++--------- rust/{rust-code => }/Cargo.toml | 6 +- rust/{rust-code => }/Makefile | 12 +- .../core => crates/keygo-core}/Cargo.toml | 2 +- .../keygo-core}/src/ark_session.rs | 0 .../core => crates/keygo-core}/src/b64.rs | 0 .../keygo-core}/src/backup/encryption.rs | 0 .../keygo-core}/src/backup/error.rs | 0 .../src/backup/format/csv/detect.rs | 0 .../keygo-core}/src/backup/format/csv/mod.rs | 0 .../keygo-core}/src/backup/format/json.rs | 0 .../keygo-core}/src/backup/format/mod.rs | 0 .../keygo-core}/src/backup/key.rs | 0 .../keygo-core}/src/backup/mod.rs | 0 .../keygo-core}/src/backup/model.rs | 0 .../keygo-core}/src/card/expiration.rs | 0 .../keygo-core}/src/card/mod.rs | 0 .../keygo-core}/src/card/network.rs | 0 .../keygo-core}/src/card/number.rs | 0 .../keygo-core}/src/crypto/error.rs | 0 .../keygo-core}/src/crypto/key.rs | 0 .../src/crypto/keys/account_root_key.rs | 0 .../keygo-core}/src/crypto/keys/item_key.rs | 0 .../keygo-core}/src/crypto/keys/mod.rs | 0 .../keygo-core}/src/crypto/keys/root_kek.rs | 0 .../src/crypto/keys/signing_key.rs | 0 .../keygo-core}/src/crypto/keys/vault_key.rs | 0 .../keygo-core}/src/crypto/macros.rs | 0 .../keygo-core}/src/crypto/mod.rs | 0 .../src/crypto/primitive/aead_data.rs | 0 .../src/crypto/primitive/argon2.rs | 0 .../keygo-core}/src/crypto/primitive/hkdf.rs | 0 .../keygo-core}/src/crypto/primitive/mod.rs | 0 .../src/crypto/primitive/wrap_key.rs | 0 .../keygo-core}/src/crypto/random.rs | 0 .../keygo-core}/src/crypto/types.rs | 0 .../core => crates/keygo-core}/src/lib.rs | 0 .../keygo-core}/src/passkey/authenticator.rs | 0 .../keygo-core}/src/passkey/keygo_passkey.rs | 0 .../keygo-core}/src/passkey/mod.rs | 0 .../keygo-core}/src/passkey/provider.rs | 0 .../keygo-core}/src/passkey/registration.rs | 0 .../core => crates/keygo-core}/src/totp.rs | 0 .../core => crates/keygo-core}/src/url.rs | 0 rust/crates/keygo-uniffi/Cargo.toml | 17 + .../keygo-uniffi}/src/ark_session.rs | 0 .../keygo-uniffi}/src/backup/csv.rs | 0 .../keygo-uniffi}/src/backup/mod.rs | 0 .../keygo-uniffi}/src/backup/model.rs | 0 .../keygo-uniffi}/src/card.rs | 0 .../keygo-uniffi}/src/item.rs | 0 .../keygo-uniffi}/src/key_wrap.rs | 0 .../keygo-uniffi}/src/lib.rs | 0 .../keygo-uniffi}/src/passkey.rs | 0 .../keygo-uniffi}/src/totp.rs | 0 .../keygo-uniffi}/src/types.rs | 0 .../keygo-uniffi}/src/vault.rs | 0 .../keygo-uniffi}/uniffi.toml | 0 rust/rust-code/.cargo/config.toml | 26 -- rust/rust-code/bindings/Cargo.toml | 21 -- rust/{rust-code => }/rust-toolchain.toml | 0 rust/tools/uniffi-bindgen/Cargo.toml | 8 + .../uniffi-bindgen/src/main.rs} | 0 66 files changed, 211 insertions(+), 212 deletions(-) rename rust/{rust-code => }/Cargo.lock (89%) rename rust/{rust-code => }/Cargo.toml (75%) rename rust/{rust-code => }/Makefile (83%) rename rust/{rust-code/core => crates/keygo-core}/Cargo.toml (97%) rename rust/{rust-code/core => crates/keygo-core}/src/ark_session.rs (100%) rename rust/{rust-code/core => crates/keygo-core}/src/b64.rs (100%) rename rust/{rust-code/core => crates/keygo-core}/src/backup/encryption.rs (100%) rename rust/{rust-code/core => crates/keygo-core}/src/backup/error.rs (100%) rename rust/{rust-code/core => crates/keygo-core}/src/backup/format/csv/detect.rs (100%) rename rust/{rust-code/core => crates/keygo-core}/src/backup/format/csv/mod.rs (100%) rename rust/{rust-code/core => crates/keygo-core}/src/backup/format/json.rs (100%) rename rust/{rust-code/core => crates/keygo-core}/src/backup/format/mod.rs (100%) rename rust/{rust-code/core => crates/keygo-core}/src/backup/key.rs (100%) rename rust/{rust-code/core => crates/keygo-core}/src/backup/mod.rs (100%) rename rust/{rust-code/core => crates/keygo-core}/src/backup/model.rs (100%) rename rust/{rust-code/core => crates/keygo-core}/src/card/expiration.rs (100%) rename rust/{rust-code/core => crates/keygo-core}/src/card/mod.rs (100%) rename rust/{rust-code/core => crates/keygo-core}/src/card/network.rs (100%) rename rust/{rust-code/core => crates/keygo-core}/src/card/number.rs (100%) rename rust/{rust-code/core => crates/keygo-core}/src/crypto/error.rs (100%) rename rust/{rust-code/core => crates/keygo-core}/src/crypto/key.rs (100%) rename rust/{rust-code/core => crates/keygo-core}/src/crypto/keys/account_root_key.rs (100%) rename rust/{rust-code/core => crates/keygo-core}/src/crypto/keys/item_key.rs (100%) rename rust/{rust-code/core => crates/keygo-core}/src/crypto/keys/mod.rs (100%) rename rust/{rust-code/core => crates/keygo-core}/src/crypto/keys/root_kek.rs (100%) rename rust/{rust-code/core => crates/keygo-core}/src/crypto/keys/signing_key.rs (100%) rename rust/{rust-code/core => crates/keygo-core}/src/crypto/keys/vault_key.rs (100%) rename rust/{rust-code/core => crates/keygo-core}/src/crypto/macros.rs (100%) rename rust/{rust-code/core => crates/keygo-core}/src/crypto/mod.rs (100%) rename rust/{rust-code/core => crates/keygo-core}/src/crypto/primitive/aead_data.rs (100%) rename rust/{rust-code/core => crates/keygo-core}/src/crypto/primitive/argon2.rs (100%) rename rust/{rust-code/core => crates/keygo-core}/src/crypto/primitive/hkdf.rs (100%) rename rust/{rust-code/core => crates/keygo-core}/src/crypto/primitive/mod.rs (100%) rename rust/{rust-code/core => crates/keygo-core}/src/crypto/primitive/wrap_key.rs (100%) rename rust/{rust-code/core => crates/keygo-core}/src/crypto/random.rs (100%) rename rust/{rust-code/core => crates/keygo-core}/src/crypto/types.rs (100%) rename rust/{rust-code/core => crates/keygo-core}/src/lib.rs (100%) rename rust/{rust-code/core => crates/keygo-core}/src/passkey/authenticator.rs (100%) rename rust/{rust-code/core => crates/keygo-core}/src/passkey/keygo_passkey.rs (100%) rename rust/{rust-code/core => crates/keygo-core}/src/passkey/mod.rs (100%) rename rust/{rust-code/core => crates/keygo-core}/src/passkey/provider.rs (100%) rename rust/{rust-code/core => crates/keygo-core}/src/passkey/registration.rs (100%) rename rust/{rust-code/core => crates/keygo-core}/src/totp.rs (100%) rename rust/{rust-code/core => crates/keygo-core}/src/url.rs (100%) create mode 100644 rust/crates/keygo-uniffi/Cargo.toml rename rust/{rust-code/bindings => crates/keygo-uniffi}/src/ark_session.rs (100%) rename rust/{rust-code/bindings => crates/keygo-uniffi}/src/backup/csv.rs (100%) rename rust/{rust-code/bindings => crates/keygo-uniffi}/src/backup/mod.rs (100%) rename rust/{rust-code/bindings => crates/keygo-uniffi}/src/backup/model.rs (100%) rename rust/{rust-code/bindings => crates/keygo-uniffi}/src/card.rs (100%) rename rust/{rust-code/bindings => crates/keygo-uniffi}/src/item.rs (100%) rename rust/{rust-code/bindings => crates/keygo-uniffi}/src/key_wrap.rs (100%) rename rust/{rust-code/bindings => crates/keygo-uniffi}/src/lib.rs (100%) rename rust/{rust-code/bindings => crates/keygo-uniffi}/src/passkey.rs (100%) rename rust/{rust-code/bindings => crates/keygo-uniffi}/src/totp.rs (100%) rename rust/{rust-code/bindings => crates/keygo-uniffi}/src/types.rs (100%) rename rust/{rust-code/bindings => crates/keygo-uniffi}/src/vault.rs (100%) rename rust/{rust-code/bindings => crates/keygo-uniffi}/uniffi.toml (100%) delete mode 100644 rust/rust-code/.cargo/config.toml delete mode 100644 rust/rust-code/bindings/Cargo.toml rename rust/{rust-code => }/rust-toolchain.toml (100%) create mode 100644 rust/tools/uniffi-bindgen/Cargo.toml rename rust/{rust-code/bindings/uniffi-bindgen.rs => tools/uniffi-bindgen/src/main.rs} (100%) 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/CLAUDE.md b/CLAUDE.md index 632c89754..4693869d1 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,8 +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) | -| `:core:rust` | Rust crypto/passkey ops via UniFFI-generated Kotlin bindings; the Rust | -| | sources it builds live in `rust/rust-code/` | +| `:core:rust` | Rust crypto/passkey ops via UniFFI-generated Kotlin bindings; Rust sources in `rust/crates/` | ## Key Patterns diff --git a/core/rust/build.gradle.kts b/core/rust/build.gradle.kts index 3f4f40118..a98a98d5d 100644 --- a/core/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 = rootDir.resolve("rust/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/rust-code/Cargo.lock b/rust/Cargo.lock similarity index 89% rename from rust/rust-code/Cargo.lock rename to rust/Cargo.lock index 5535e3147..f8689fef1 100644 --- a/rust/rust-code/Cargo.lock +++ b/rust/Cargo.lock @@ -14,13 +14,13 @@ dependencies = [ [[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 0.3.1", ] [[package]] @@ -72,9 +72,9 @@ dependencies = [ [[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 +85,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 +98,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 +125,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 +144,7 @@ checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" dependencies = [ "proc-macro2", "quote", - "syn 3.0.4", + "syn 3.0.7", ] [[package]] @@ -197,9 +197,9 @@ 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" @@ -242,9 +242,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,18 +275,18 @@ 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", + "cpufeatures 0.3.1", "rand_core 0.10.1", ] @@ -330,9 +330,9 @@ dependencies = [ [[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 +340,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 +351,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" @@ -418,9 +418,9 @@ dependencies = [ [[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", ] @@ -496,9 +496,9 @@ 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", ] @@ -510,7 +510,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5eed333089e2e1c1ac8c6c0398e5e2497b4c9926ca6d0365ed1e099afa5bc23" dependencies = [ "cfg-if", - "cpufeatures 0.3.0", + "cpufeatures 0.3.1", "curve25519-dalek-derive", "digest 0.11.3", "fiat-crypto", @@ -586,7 +586,7 @@ checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" dependencies = [ "proc-macro2", "quote", - "syn 3.0.4", + "syn 3.0.7", ] [[package]] @@ -635,9 +635,9 @@ dependencies = [ [[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" @@ -738,9 +738,9 @@ dependencies = [ [[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", ] @@ -891,9 +891,9 @@ dependencies = [ [[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 = [ "typenum", ] @@ -1004,9 +1004,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", @@ -1049,27 +1049,15 @@ 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", "wasm-bindgen", ] -[[package]] -name = "keygo-bindings" -version = "0.1.0" -dependencies = [ - "keygo-core", - "thiserror", - "tokio", - "uniffi", - "uuid", - "zeroize", -] - [[package]] name = "keygo-core" version = "0.1.0" @@ -1090,7 +1078,7 @@ dependencies = [ "passkey", "passkey-authenticator", "passkey-types", - "rand 0.10.2", + "rand 0.10.3", "serde", "serde_json", "sha2 0.11.0", @@ -1102,17 +1090,29 @@ dependencies = [ "zeroize", ] +[[package]] +name = "keygo-uniffi" +version = "0.1.0" +dependencies = [ + "keygo-core", + "thiserror", + "tokio", + "uniffi", + "uuid", + "zeroize", +] + [[package]] name = "lazy_static" -version = "1.5.0" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb" [[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" @@ -1344,7 +1344,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0fa31d631f2b2cb2a544d0aa321ce847a94764d701ca2becc411138b93d49cd" dependencies = [ "cpubits", - "cpufeatures 0.3.0", + "cpufeatures 0.3.1", "universal-hash", ] @@ -1448,9 +1448,9 @@ dependencies = [ [[package]] name = "rand" -version = "0.10.2" +version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" dependencies = [ "chacha20", "getrandom 0.4.3", @@ -1538,9 +1538,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", @@ -1633,7 +1633,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 3.0.4", + "syn 3.0.7", ] [[package]] @@ -1652,9 +1652,9 @@ 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", ] @@ -1676,7 +1676,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" dependencies = [ "cfg-if", - "cpufeatures 0.3.0", + "cpufeatures 0.3.1", "digest 0.11.3", ] @@ -1698,7 +1698,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" dependencies = [ "cfg-if", - "cpufeatures 0.3.0", + "cpufeatures 0.3.1", "digest 0.11.3", ] @@ -1723,9 +1723,9 @@ dependencies = [ [[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 +1735,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" @@ -1824,9 +1824,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 +1835,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]] @@ -1865,31 +1865,32 @@ 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 +1905,18 @@ 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", ] [[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 +1929,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" @@ -1960,7 +1961,7 @@ dependencies = [ "constant_time_eq", "hmac 0.13.0", "percent-encoding", - "rand 0.10.2", + "rand 0.10.3", "serde", "sha1", "sha2 0.11.0", @@ -1976,15 +1977,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 = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +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 +2003,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 +2038,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 +2051,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 +2064,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 +2081,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 +2093,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 +2106,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", @@ -2133,9 +2147,9 @@ 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", "js-sys", @@ -2157,9 +2171,9 @@ 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 +2184,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 +2194,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", ] @@ -2252,30 +2266,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 +2307,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 +2367,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 75% rename from rust/rust-code/Cargo.toml rename to rust/Cargo.toml index ea28c111f..82a282d4f 100644 --- a/rust/rust-code/Cargo.toml +++ b/rust/Cargo.toml @@ -1,7 +1,7 @@ [workspace] members = [ - "core", - "bindings", + "crates/*", + "tools/*", ] resolver = "3" @@ -12,3 +12,5 @@ edition = "2024" [workspace.dependencies] thiserror = "2.0.18" uuid = { version = "1.23.1", features = ["serde", "v4"] } +uniffi = "0.32.0" +zeroize = "1.8.2" diff --git a/rust/rust-code/Makefile b/rust/Makefile similarity index 83% rename from rust/rust-code/Makefile rename to rust/Makefile index aeeb528fb..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 := ../../core/rust/build/generated/source/uniffi/jniLibs -KOTLIN_OUT := ../../core/rust/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 97% rename from rust/rust-code/core/Cargo.toml rename to rust/crates/keygo-core/Cargo.toml index 3e1656875..11b782d3e 100644 --- a/rust/rust-code/core/Cargo.toml +++ b/rust/crates/keygo-core/Cargo.toml @@ -6,6 +6,7 @@ edition.workspace = true [dependencies] thiserror.workspace = true uuid.workspace = true +zeroize.workspace = true aead = "0.6.0" aes-gcm-siv = "0.12.1" @@ -32,4 +33,3 @@ sha2 = "0.11.0" subtle = "2.6" totp-rs = { version = "6.0.0", features = ["otpauth", "zeroize"] } url = "2.5.8" -zeroize = "1.8.2" diff --git a/rust/rust-code/core/src/ark_session.rs b/rust/crates/keygo-core/src/ark_session.rs similarity index 100% rename from rust/rust-code/core/src/ark_session.rs rename to rust/crates/keygo-core/src/ark_session.rs diff --git a/rust/rust-code/core/src/b64.rs b/rust/crates/keygo-core/src/b64.rs similarity index 100% rename from rust/rust-code/core/src/b64.rs rename to rust/crates/keygo-core/src/b64.rs diff --git a/rust/rust-code/core/src/backup/encryption.rs b/rust/crates/keygo-core/src/backup/encryption.rs similarity index 100% rename from rust/rust-code/core/src/backup/encryption.rs rename to rust/crates/keygo-core/src/backup/encryption.rs diff --git a/rust/rust-code/core/src/backup/error.rs b/rust/crates/keygo-core/src/backup/error.rs similarity index 100% rename from rust/rust-code/core/src/backup/error.rs rename to rust/crates/keygo-core/src/backup/error.rs 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 100% rename from rust/rust-code/core/src/backup/format/csv/detect.rs rename to rust/crates/keygo-core/src/backup/format/csv/detect.rs 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 100% rename from rust/rust-code/core/src/backup/format/csv/mod.rs rename to rust/crates/keygo-core/src/backup/format/csv/mod.rs diff --git a/rust/rust-code/core/src/backup/format/json.rs b/rust/crates/keygo-core/src/backup/format/json.rs similarity index 100% rename from rust/rust-code/core/src/backup/format/json.rs rename to rust/crates/keygo-core/src/backup/format/json.rs 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 100% rename from rust/rust-code/core/src/backup/key.rs rename to rust/crates/keygo-core/src/backup/key.rs diff --git a/rust/rust-code/core/src/backup/mod.rs b/rust/crates/keygo-core/src/backup/mod.rs similarity index 100% rename from rust/rust-code/core/src/backup/mod.rs rename to rust/crates/keygo-core/src/backup/mod.rs diff --git a/rust/rust-code/core/src/backup/model.rs b/rust/crates/keygo-core/src/backup/model.rs similarity index 100% rename from rust/rust-code/core/src/backup/model.rs rename to rust/crates/keygo-core/src/backup/model.rs diff --git a/rust/rust-code/core/src/card/expiration.rs b/rust/crates/keygo-core/src/card/expiration.rs similarity index 100% rename from rust/rust-code/core/src/card/expiration.rs rename to rust/crates/keygo-core/src/card/expiration.rs diff --git a/rust/rust-code/core/src/card/mod.rs b/rust/crates/keygo-core/src/card/mod.rs similarity index 100% rename from rust/rust-code/core/src/card/mod.rs rename to rust/crates/keygo-core/src/card/mod.rs diff --git a/rust/rust-code/core/src/card/network.rs b/rust/crates/keygo-core/src/card/network.rs similarity index 100% rename from rust/rust-code/core/src/card/network.rs rename to rust/crates/keygo-core/src/card/network.rs diff --git a/rust/rust-code/core/src/card/number.rs b/rust/crates/keygo-core/src/card/number.rs similarity index 100% rename from rust/rust-code/core/src/card/number.rs rename to rust/crates/keygo-core/src/card/number.rs diff --git a/rust/rust-code/core/src/crypto/error.rs b/rust/crates/keygo-core/src/crypto/error.rs similarity index 100% rename from rust/rust-code/core/src/crypto/error.rs rename to rust/crates/keygo-core/src/crypto/error.rs diff --git a/rust/rust-code/core/src/crypto/key.rs b/rust/crates/keygo-core/src/crypto/key.rs similarity index 100% rename from rust/rust-code/core/src/crypto/key.rs rename to rust/crates/keygo-core/src/crypto/key.rs diff --git a/rust/rust-code/core/src/crypto/keys/account_root_key.rs b/rust/crates/keygo-core/src/crypto/keys/account_root_key.rs similarity index 100% rename from rust/rust-code/core/src/crypto/keys/account_root_key.rs rename to rust/crates/keygo-core/src/crypto/keys/account_root_key.rs diff --git a/rust/rust-code/core/src/crypto/keys/item_key.rs b/rust/crates/keygo-core/src/crypto/keys/item_key.rs similarity index 100% rename from rust/rust-code/core/src/crypto/keys/item_key.rs rename to rust/crates/keygo-core/src/crypto/keys/item_key.rs diff --git a/rust/rust-code/core/src/crypto/keys/mod.rs b/rust/crates/keygo-core/src/crypto/keys/mod.rs similarity index 100% rename from rust/rust-code/core/src/crypto/keys/mod.rs rename to rust/crates/keygo-core/src/crypto/keys/mod.rs diff --git a/rust/rust-code/core/src/crypto/keys/root_kek.rs b/rust/crates/keygo-core/src/crypto/keys/root_kek.rs similarity index 100% rename from rust/rust-code/core/src/crypto/keys/root_kek.rs rename to rust/crates/keygo-core/src/crypto/keys/root_kek.rs diff --git a/rust/rust-code/core/src/crypto/keys/signing_key.rs b/rust/crates/keygo-core/src/crypto/keys/signing_key.rs similarity index 100% rename from rust/rust-code/core/src/crypto/keys/signing_key.rs rename to rust/crates/keygo-core/src/crypto/keys/signing_key.rs diff --git a/rust/rust-code/core/src/crypto/keys/vault_key.rs b/rust/crates/keygo-core/src/crypto/keys/vault_key.rs similarity index 100% rename from rust/rust-code/core/src/crypto/keys/vault_key.rs rename to rust/crates/keygo-core/src/crypto/keys/vault_key.rs diff --git a/rust/rust-code/core/src/crypto/macros.rs b/rust/crates/keygo-core/src/crypto/macros.rs similarity index 100% rename from rust/rust-code/core/src/crypto/macros.rs rename to rust/crates/keygo-core/src/crypto/macros.rs diff --git a/rust/rust-code/core/src/crypto/mod.rs b/rust/crates/keygo-core/src/crypto/mod.rs similarity index 100% rename from rust/rust-code/core/src/crypto/mod.rs rename to rust/crates/keygo-core/src/crypto/mod.rs diff --git a/rust/rust-code/core/src/crypto/primitive/aead_data.rs b/rust/crates/keygo-core/src/crypto/primitive/aead_data.rs similarity index 100% rename from rust/rust-code/core/src/crypto/primitive/aead_data.rs rename to rust/crates/keygo-core/src/crypto/primitive/aead_data.rs diff --git a/rust/rust-code/core/src/crypto/primitive/argon2.rs b/rust/crates/keygo-core/src/crypto/primitive/argon2.rs similarity index 100% rename from rust/rust-code/core/src/crypto/primitive/argon2.rs rename to rust/crates/keygo-core/src/crypto/primitive/argon2.rs diff --git a/rust/rust-code/core/src/crypto/primitive/hkdf.rs b/rust/crates/keygo-core/src/crypto/primitive/hkdf.rs similarity index 100% rename from rust/rust-code/core/src/crypto/primitive/hkdf.rs rename to rust/crates/keygo-core/src/crypto/primitive/hkdf.rs diff --git a/rust/rust-code/core/src/crypto/primitive/mod.rs b/rust/crates/keygo-core/src/crypto/primitive/mod.rs similarity index 100% rename from rust/rust-code/core/src/crypto/primitive/mod.rs rename to rust/crates/keygo-core/src/crypto/primitive/mod.rs diff --git a/rust/rust-code/core/src/crypto/primitive/wrap_key.rs b/rust/crates/keygo-core/src/crypto/primitive/wrap_key.rs similarity index 100% rename from rust/rust-code/core/src/crypto/primitive/wrap_key.rs rename to rust/crates/keygo-core/src/crypto/primitive/wrap_key.rs diff --git a/rust/rust-code/core/src/crypto/random.rs b/rust/crates/keygo-core/src/crypto/random.rs similarity index 100% rename from rust/rust-code/core/src/crypto/random.rs rename to rust/crates/keygo-core/src/crypto/random.rs diff --git a/rust/rust-code/core/src/crypto/types.rs b/rust/crates/keygo-core/src/crypto/types.rs similarity index 100% rename from rust/rust-code/core/src/crypto/types.rs rename to rust/crates/keygo-core/src/crypto/types.rs diff --git a/rust/rust-code/core/src/lib.rs b/rust/crates/keygo-core/src/lib.rs similarity index 100% rename from rust/rust-code/core/src/lib.rs rename to rust/crates/keygo-core/src/lib.rs diff --git a/rust/rust-code/core/src/passkey/authenticator.rs b/rust/crates/keygo-core/src/passkey/authenticator.rs similarity index 100% rename from rust/rust-code/core/src/passkey/authenticator.rs rename to rust/crates/keygo-core/src/passkey/authenticator.rs diff --git a/rust/rust-code/core/src/passkey/keygo_passkey.rs b/rust/crates/keygo-core/src/passkey/keygo_passkey.rs similarity index 100% rename from rust/rust-code/core/src/passkey/keygo_passkey.rs rename to rust/crates/keygo-core/src/passkey/keygo_passkey.rs diff --git a/rust/rust-code/core/src/passkey/mod.rs b/rust/crates/keygo-core/src/passkey/mod.rs similarity index 100% rename from rust/rust-code/core/src/passkey/mod.rs rename to rust/crates/keygo-core/src/passkey/mod.rs diff --git a/rust/rust-code/core/src/passkey/provider.rs b/rust/crates/keygo-core/src/passkey/provider.rs similarity index 100% rename from rust/rust-code/core/src/passkey/provider.rs rename to rust/crates/keygo-core/src/passkey/provider.rs diff --git a/rust/rust-code/core/src/passkey/registration.rs b/rust/crates/keygo-core/src/passkey/registration.rs similarity index 100% rename from rust/rust-code/core/src/passkey/registration.rs rename to rust/crates/keygo-core/src/passkey/registration.rs diff --git a/rust/rust-code/core/src/totp.rs b/rust/crates/keygo-core/src/totp.rs similarity index 100% rename from rust/rust-code/core/src/totp.rs rename to rust/crates/keygo-core/src/totp.rs diff --git a/rust/rust-code/core/src/url.rs b/rust/crates/keygo-core/src/url.rs similarity index 100% rename from rust/rust-code/core/src/url.rs rename to rust/crates/keygo-core/src/url.rs diff --git a/rust/crates/keygo-uniffi/Cargo.toml b/rust/crates/keygo-uniffi/Cargo.toml new file mode 100644 index 000000000..d391df7e7 --- /dev/null +++ b/rust/crates/keygo-uniffi/Cargo.toml @@ -0,0 +1,17 @@ +[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 +zeroize.workspace = true + +tokio = { version = "1.48.0", 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 100% rename from rust/rust-code/bindings/src/ark_session.rs rename to rust/crates/keygo-uniffi/src/ark_session.rs diff --git a/rust/rust-code/bindings/src/backup/csv.rs b/rust/crates/keygo-uniffi/src/backup/csv.rs similarity index 100% rename from rust/rust-code/bindings/src/backup/csv.rs rename to rust/crates/keygo-uniffi/src/backup/csv.rs diff --git a/rust/rust-code/bindings/src/backup/mod.rs b/rust/crates/keygo-uniffi/src/backup/mod.rs similarity index 100% rename from rust/rust-code/bindings/src/backup/mod.rs rename to rust/crates/keygo-uniffi/src/backup/mod.rs 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 100% rename from rust/rust-code/bindings/src/item.rs rename to rust/crates/keygo-uniffi/src/item.rs diff --git a/rust/rust-code/bindings/src/key_wrap.rs b/rust/crates/keygo-uniffi/src/key_wrap.rs similarity index 100% rename from rust/rust-code/bindings/src/key_wrap.rs rename to rust/crates/keygo-uniffi/src/key_wrap.rs 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/rust-code/bindings/src/passkey.rs b/rust/crates/keygo-uniffi/src/passkey.rs similarity index 100% rename from rust/rust-code/bindings/src/passkey.rs rename to rust/crates/keygo-uniffi/src/passkey.rs diff --git a/rust/rust-code/bindings/src/totp.rs b/rust/crates/keygo-uniffi/src/totp.rs similarity index 100% rename from rust/rust-code/bindings/src/totp.rs rename to rust/crates/keygo-uniffi/src/totp.rs diff --git a/rust/rust-code/bindings/src/types.rs b/rust/crates/keygo-uniffi/src/types.rs similarity index 100% rename from rust/rust-code/bindings/src/types.rs rename to rust/crates/keygo-uniffi/src/types.rs diff --git a/rust/rust-code/bindings/src/vault.rs b/rust/crates/keygo-uniffi/src/vault.rs similarity index 100% rename from rust/rust-code/bindings/src/vault.rs rename to rust/crates/keygo-uniffi/src/vault.rs 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/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/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 From b918b4298e4c667cb973b5851b02cd346026dee3 Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Sat, 10 Oct 2026 15:37:03 +0200 Subject: [PATCH 03/18] chore(rust): drop unused zeroize dependency from keygo-uniffi Co-Authored-By: Claude Opus 5.5 --- rust/Cargo.lock | 1 - rust/crates/keygo-uniffi/Cargo.toml | 1 - 2 files changed, 2 deletions(-) diff --git a/rust/Cargo.lock b/rust/Cargo.lock index f8689fef1..e65535568 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -1099,7 +1099,6 @@ dependencies = [ "tokio", "uniffi", "uuid", - "zeroize", ] [[package]] diff --git a/rust/crates/keygo-uniffi/Cargo.toml b/rust/crates/keygo-uniffi/Cargo.toml index d391df7e7..71060a2c0 100644 --- a/rust/crates/keygo-uniffi/Cargo.toml +++ b/rust/crates/keygo-uniffi/Cargo.toml @@ -11,7 +11,6 @@ crate-type = ["cdylib"] keygo-core = { path = "../keygo-core" } thiserror.workspace = true uuid.workspace = true -zeroize.workspace = true tokio = { version = "1.48.0", features = ["rt", "rt-multi-thread"] } uniffi = { workspace = true, features = ["tokio"] } From 3e0427715ca5808122192aaec9f9e5734108c880 Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Sat, 10 Oct 2026 15:39:51 +0200 Subject: [PATCH 04/18] build(rust): bump passkey to 0.6 and argon2 to 0.6 passkey 0.6 makes the authenticator's crypto backend pluggable; pass the crate's default RustCryptoBackend. argon2 0.6 needs no code changes; the password-domain KAT and both golden backups still pass. Co-Authored-By: Claude Opus 5.5 --- rust/Cargo.lock | 505 +++++++++--------- rust/crates/keygo-core/Cargo.toml | 8 +- .../keygo-core/src/passkey/authenticator.rs | 5 +- 3 files changed, 249 insertions(+), 269 deletions(-) diff --git a/rust/Cargo.lock b/rust/Cargo.lock index e65535568..c2a7efa64 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -8,7 +8,7 @@ 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", ] @@ -20,7 +20,7 @@ checksum = "35f0f96ce78e38c3dc6d8948aa8163d06385be74000f3c7a95bf1eef35d3ea32" dependencies = [ "cipher", "cpubits", - "cpufeatures 0.3.1", + "cpufeatures", ] [[package]] @@ -60,13 +60,13 @@ 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", ] @@ -155,9 +155,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" @@ -203,20 +203,11 @@ 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]] @@ -286,8 +277,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" dependencies = [ "cfg-if", - "cpufeatures 0.3.1", - "rand_core 0.10.1", + "cpufeatures", + "rand_core", ] [[package]] @@ -323,8 +314,8 @@ 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", ] @@ -373,12 +364,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" @@ -407,15 +392,6 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" 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.1" @@ -433,35 +409,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]] @@ -501,6 +471,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "03bb0e1cc970d482d121d9a1744999169b69a07470b3d644a7894e53fcaf4574" dependencies = [ "cmov", + "subtle", ] [[package]] @@ -510,11 +481,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5eed333089e2e1c1ac8c6c0398e5e2497b4c9926ca6d0365ed1e099afa5bc23" dependencies = [ "cfg-if", - "cpufeatures 0.3.1", + "cpufeatures", "curve25519-dalek-derive", - "digest 0.11.3", + "digest", "fiat-crypto", - "rand_core 0.10.1", + "rand_core", "rustc_version", "subtle", "zeroize", @@ -539,11 +510,11 @@ 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", ] @@ -554,27 +525,15 @@ 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", ] @@ -597,16 +556,17 @@ 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 +575,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,9 +587,10 @@ 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", ] @@ -641,23 +603,21 @@ 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 +655,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", ] @@ -775,30 +735,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 +744,7 @@ dependencies = [ "cfg-if", "libc", "r-efi", - "rand_core 0.10.1", + "rand_core", ] [[package]] @@ -830,12 +766,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 +804,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,7 +813,7 @@ version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" dependencies = [ - "digest 0.11.3", + "digest", ] [[package]] @@ -895,7 +822,10 @@ version = "0.4.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" dependencies = [ + "ctutils", + "subtle", "typenum", + "zeroize", ] [[package]] @@ -1058,6 +988,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "keccak" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8f198d1db720e4940b5a493201d199d9f24f568f8f746bd13706243a2f71598" +dependencies = [ + "cfg-if", + "cpufeatures", +] + [[package]] name = "keygo-core" version = "0.1.0" @@ -1078,10 +1018,10 @@ dependencies = [ "passkey", "passkey-authenticator", "passkey-types", - "rand 0.10.3", + "rand", "serde", "serde_json", - "sha2 0.11.0", + "sha2", "subtle", "thiserror", "totp-rs", @@ -1143,6 +1083,22 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" +[[package]] +name = "ml-dsa" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "add6b9d92e496f16f4526d68ff29da1483aba4b119baeab8bed3b9e3544a6f3d" +dependencies = [ + "const-oid", + "crypto-common", + "ctutils", + "hybrid-array", + "module-lattice", + "pkcs8", + "shake", + "signature", +] + [[package]] name = "mockall" version = "0.11.4" @@ -1170,6 +1126,17 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "module-lattice" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c61b87c9683ab7cb1c6871d261ad5479b6b10ceb52c4352aaca3b5d35a8febe" +dependencies = [ + "ctutils", + "hybrid-array", + "num-traits", +] + [[package]] name = "nom" version = "7.1.3" @@ -1203,55 +1170,56 @@ 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", "passkey-authenticator", + "passkey-crypto", "passkey-types", "public-suffix", "serde", @@ -1259,30 +1227,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", @@ -1290,20 +1272,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", ] @@ -1314,6 +1295,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" @@ -1322,9 +1314,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", @@ -1343,7 +1335,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0fa31d631f2b2cb2a544d0aa321ce847a94764d701ca2becc411138b93d49cd" dependencies = [ "cpubits", - "cpufeatures 0.3.1", + "cpufeatures", "universal-hash", ] @@ -1356,15 +1348,6 @@ dependencies = [ "zerovec", ] -[[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" @@ -1395,13 +1378,31 @@ dependencies = [ "termtree", ] +[[package]] +name = "primefield" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c555a6e4eb7d4e158fcb028c835c3b8642206ddc279b5c6b202ef9a8bdb592f4" +dependencies = [ + "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]] @@ -1434,17 +1435,6 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" -[[package]] -name = "rand" -version = "0.8.8" -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.3" @@ -1452,27 +1442,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" 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]] @@ -1512,12 +1483,12 @@ 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]] @@ -1582,15 +1553,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", ] @@ -1660,9 +1630,9 @@ dependencies = [ [[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", @@ -1675,19 +1645,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" dependencies = [ "cfg-if", - "cpufeatures 0.3.1", - "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]] @@ -1697,18 +1656,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" dependencies = [ "cfg-if", - "cpufeatures 0.3.1", - "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]] @@ -1717,7 +1677,8 @@ 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]] @@ -1746,14 +1707,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" @@ -1850,7 +1817,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.3", + "getrandom", "once_cell", "rustix", "windows-sys", @@ -1909,6 +1876,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" 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]] @@ -1958,12 +1937,12 @@ checksum = "49d0f9b7aa5def86d3a8d8ba926fe8c1990924dd715d2b5fc54a4aa3788c11c1" dependencies = [ "base32", "constant_time_eq", - "hmac 0.13.0", + "hmac", "percent-encoding", - "rand 0.10.3", + "rand", "serde", "sha1", - "sha2 0.11.0", + "sha2", "url", "zeroize", ] @@ -2121,7 +2100,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", ] @@ -2150,24 +2129,12 @@ version = "1.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" 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.129" @@ -2246,6 +2213,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" diff --git a/rust/crates/keygo-core/Cargo.toml b/rust/crates/keygo-core/Cargo.toml index 11b782d3e..890b51d7a 100644 --- a/rust/crates/keygo-core/Cargo.toml +++ b/rust/crates/keygo-core/Cargo.toml @@ -14,12 +14,12 @@ 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", "testable"] } # 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" +argon2 = "0.6.0" base32 = "0.5.1" base64 = "0.23.1" bcs = "0.2.0" diff --git a/rust/crates/keygo-core/src/passkey/authenticator.rs b/rust/crates/keygo-core/src/passkey/authenticator.rs index b2422567f..b555dcdf6 100644 --- a/rust/crates/keygo-core/src/passkey/authenticator.rs +++ b/rust/crates/keygo-core/src/passkey/authenticator.rs @@ -1,4 +1,5 @@ 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; @@ -32,8 +33,8 @@ impl UserValidationMethod for KeyGoUserValidation { pub(crate) fn keygo_authenticator( store: S, -) -> Authenticator { +) -> Authenticator { let aaguid = Aaguid::new_empty(); - Authenticator::new(aaguid, store, KeyGoUserValidation {}) + Authenticator::new(aaguid, store, KeyGoUserValidation {}, RustCryptoBackend) } From d75bf0afd0d05cde80658618c2e809e8278244bb Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Sat, 10 Oct 2026 15:41:18 +0200 Subject: [PATCH 05/18] fix(passkey): report the user's name, not the relying party's register_passkey filled user_name from rp.name, so every new passkey was stored under the site's display name ("Example Corp") instead of the account it belongs to ("alice@example.com"). Co-Authored-By: Claude Opus 5.5 --- rust/Cargo.lock | 1 + rust/crates/keygo-core/Cargo.toml | 3 +++ .../keygo-core/src/passkey/registration.rs | 23 ++++++++++++++++++- 3 files changed, 26 insertions(+), 1 deletion(-) diff --git a/rust/Cargo.lock b/rust/Cargo.lock index c2a7efa64..2838b5448 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -1024,6 +1024,7 @@ dependencies = [ "sha2", "subtle", "thiserror", + "tokio", "totp-rs", "url", "uuid", diff --git a/rust/crates/keygo-core/Cargo.toml b/rust/crates/keygo-core/Cargo.toml index 890b51d7a..ac8e63052 100644 --- a/rust/crates/keygo-core/Cargo.toml +++ b/rust/crates/keygo-core/Cargo.toml @@ -33,3 +33,6 @@ sha2 = "0.11.0" subtle = "2.6" totp-rs = { version = "6.0.0", features = ["otpauth", "zeroize"] } url = "2.5.8" + +[dev-dependencies] +tokio = { version = "1.48.0", features = ["rt", "macros"] } diff --git a/rust/crates/keygo-core/src/passkey/registration.rs b/rust/crates/keygo-core/src/passkey/registration.rs index d35f049f7..3094c0d59 100644 --- a/rust/crates/keygo-core/src/passkey/registration.rs +++ b/rust/crates/keygo-core/src/passkey/registration.rs @@ -61,7 +61,7 @@ pub async fn register_passkey( 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_name = creation_options.user.name.clone(); let user_display_name = creation_options.user.display_name.clone(); let authenticator = keygo_authenticator(None); @@ -89,3 +89,24 @@ pub async fn register_passkey( private_key: to_bytes(response).map_err(KeyEncodeError)?, }) } + +#[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_passkey(REQUEST).await.unwrap(); + + assert_eq!(registration.user_name, "alice@example.com"); + assert_eq!(registration.user_display_name, "Alice"); + assert_eq!(registration.rp, "example.com"); + } +} From 0e47b1f0f81a7bcfd0d8ab04b49db931b36eace3 Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Sat, 10 Oct 2026 15:42:21 +0200 Subject: [PATCH 06/18] test(rust): pin the key hierarchy and passkey wire formats with goldens Only backups and the password KEK had known-answer tests. The wrapped ARK, vault and item keys, item ciphertext, and stored passkey bytes are all persisted too, and any drift in cipher, nonce layout, or AAD encoding would make them unreadable without a compile error. Freeze one of each. Co-Authored-By: Claude Opus 5.5 --- rust/crates/keygo-core/src/crypto/mod.rs | 3 +++ .../keygo-core/src/passkey/keygo_passkey.rs | 26 +++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/rust/crates/keygo-core/src/crypto/mod.rs b/rust/crates/keygo-core/src/crypto/mod.rs index c427f0d3c..b1ad72a21 100644 --- a/rust/crates/keygo-core/src/crypto/mod.rs +++ b/rust/crates/keygo-core/src/crypto/mod.rs @@ -11,3 +11,6 @@ pub use keys::{ AccountRootKey, ItemAad, ItemDataAad, ItemKey, RootKEK, ScopedSigningKey, TryDeriveFrom, VaultKey, }; + +#[cfg(test)] +mod golden_tests; diff --git a/rust/crates/keygo-core/src/passkey/keygo_passkey.rs b/rust/crates/keygo-core/src/passkey/keygo_passkey.rs index bd2030f60..51c55440b 100644 --- a/rust/crates/keygo-core/src/passkey/keygo_passkey.rs +++ b/rust/crates/keygo-core/src/passkey/keygo_passkey.rs @@ -100,6 +100,32 @@ mod tests { } } + /// `sample_passkey` as encoded when the format shipped. Every stored passkey is in this + /// shape, so the decoder must keep reading it: the `V1` tag and the field names are the format. + const GOLDEN_V1: &str = "oWJWMadja2V5hRiiAQEDGCdtY3JlZGVudGlhbF9pZIQBAgMEZXJwX2lka2V4YW1wbGUuY29ta3VzZXJfaGFuZGxlgwkJCWh1c2VybmFtZWVhbGljZXF1c2VyX2Rpc3BsYXlfbmFtZWVBbGljZWdjb3VudGVyGCo="; + + #[test] + fn golden_v1_still_decodes() { + let original = sample_passkey(); + let decoded = passkey_from_bytes(&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(to_bytes(sample_passkey()).unwrap()), + GOLDEN_V1 + ); + } + #[test] fn roundtrip_preserves_fields() { let original = sample_passkey(); From 460c8b9c72a7c1a3c886dae31b6c570f9001303d Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Sat, 10 Oct 2026 15:49:04 +0200 Subject: [PATCH 07/18] refactor(crypto): one Sealed type and three traits for the key hierarchy Every key uses AES-256-GCM-SIV, so name the cipher once and drop the per-key algorithm generic. That collapses the old surface: - AeadWrappedKey, AeadCiphertext and the WrappedKey trait become Sealed, where T is the wrapped key type or [u8] for data. Sealed::from_parts returns an error on a bad nonce instead of panicking, which a corrupt row from Kotlin could previously trigger across the FFI. - KeyMaterial/AeadKey/KeyWrapper/AeadEncryptor become SymmetricKey, Wraps and Encrypts. The whole hierarchy now reads in keys.rs. - define_aead_key!/define_wrap! become one crate-private symmetric_key!. - TryDeriveFrom becomes RootKEK::from_password, owning PASSWORD_DOMAIN. - ItemDataAad becomes Encrypts::Aad = [u8]; BCS encodes both identically. - Unused ScopedSigningKey and its macro are removed. Errors carry their cause: BCS/KDF strings become AadEncoding, EmptyPassword, SaltTooShort, Argon2MemoryTooHigh, Argon2 and InvalidNonceLength. Derived KDF output is zeroized. ArkSession takes wrapped keys by reference and bundles salt + wrapped ARK as PasswordWrappedArk. Argon2 still runs outside the session lock. All goldens and the password KAT pass with their vectors unchanged. The Kotlin-facing FFI is unchanged: regenerated bindings differ only in the UniFFI checksums of methods taking ItemKey, whose declaration moved. Co-Authored-By: Claude Opus 5.5 --- rust/crates/keygo-core/src/ark_session.rs | 283 +++++++----------- .../keygo-core/src/backup/encryption.rs | 53 ++-- rust/crates/keygo-core/src/backup/error.rs | 2 +- .../keygo-core/src/backup/format/json.rs | 21 +- rust/crates/keygo-core/src/backup/key.rs | 27 +- rust/crates/keygo-core/src/crypto/cipher.rs | 104 +++++++ rust/crates/keygo-core/src/crypto/error.rs | 25 +- .../keygo-core/src/crypto/golden_tests.rs | 81 +++++ .../src/crypto/{types.rs => ids.rs} | 0 .../keygo-core/src/crypto/kdf/argon2.rs | 162 ++++++++++ rust/crates/keygo-core/src/crypto/kdf/hkdf.rs | 58 ++++ rust/crates/keygo-core/src/crypto/kdf/mod.rs | 2 + rust/crates/keygo-core/src/crypto/key.rs | 197 +++++++++++- rust/crates/keygo-core/src/crypto/keys.rs | 133 ++++++++ .../src/crypto/keys/account_root_key.rs | 6 - .../keygo-core/src/crypto/keys/item_key.rs | 25 -- rust/crates/keygo-core/src/crypto/keys/mod.rs | 17 -- .../keygo-core/src/crypto/keys/root_kek.rs | 54 ---- .../keygo-core/src/crypto/keys/signing_key.rs | 77 ----- .../keygo-core/src/crypto/keys/vault_key.rs | 10 - rust/crates/keygo-core/src/crypto/macros.rs | 92 ------ rust/crates/keygo-core/src/crypto/mod.rs | 21 +- .../src/crypto/primitive/aead_data.rs | 167 ----------- .../keygo-core/src/crypto/primitive/argon2.rs | 181 ----------- .../keygo-core/src/crypto/primitive/hkdf.rs | 65 ---- .../keygo-core/src/crypto/primitive/mod.rs | 4 - .../src/crypto/primitive/wrap_key.rs | 278 ----------------- rust/crates/keygo-core/src/crypto/random.rs | 2 +- rust/crates/keygo-uniffi/src/ark_session.rs | 91 +++--- rust/crates/keygo-uniffi/src/backup/mod.rs | 2 +- rust/crates/keygo-uniffi/src/item.rs | 44 ++- rust/crates/keygo-uniffi/src/key_wrap.rs | 55 ++-- rust/crates/keygo-uniffi/src/types.rs | 13 +- rust/crates/keygo-uniffi/src/vault.rs | 2 +- 34 files changed, 1005 insertions(+), 1349 deletions(-) create mode 100644 rust/crates/keygo-core/src/crypto/cipher.rs create mode 100644 rust/crates/keygo-core/src/crypto/golden_tests.rs rename rust/crates/keygo-core/src/crypto/{types.rs => ids.rs} (100%) create mode 100644 rust/crates/keygo-core/src/crypto/kdf/argon2.rs create mode 100644 rust/crates/keygo-core/src/crypto/kdf/hkdf.rs create mode 100644 rust/crates/keygo-core/src/crypto/kdf/mod.rs create mode 100644 rust/crates/keygo-core/src/crypto/keys.rs delete mode 100644 rust/crates/keygo-core/src/crypto/keys/account_root_key.rs delete mode 100644 rust/crates/keygo-core/src/crypto/keys/item_key.rs delete mode 100644 rust/crates/keygo-core/src/crypto/keys/mod.rs delete mode 100644 rust/crates/keygo-core/src/crypto/keys/root_kek.rs delete mode 100644 rust/crates/keygo-core/src/crypto/keys/signing_key.rs delete mode 100644 rust/crates/keygo-core/src/crypto/keys/vault_key.rs delete mode 100644 rust/crates/keygo-core/src/crypto/macros.rs delete mode 100644 rust/crates/keygo-core/src/crypto/primitive/aead_data.rs delete mode 100644 rust/crates/keygo-core/src/crypto/primitive/argon2.rs delete mode 100644 rust/crates/keygo-core/src/crypto/primitive/hkdf.rs delete mode 100644 rust/crates/keygo-core/src/crypto/primitive/mod.rs delete mode 100644 rust/crates/keygo-core/src/crypto/primitive/wrap_key.rs diff --git a/rust/crates/keygo-core/src/ark_session.rs b/rust/crates/keygo-core/src/ark_session.rs index 48463b307..844b8ee26 100644 --- a/rust/crates/keygo-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::kdf::argon2::MIN_SALT_LEN; 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::{ + AccountRootKey, CryptoError, RootKEK, Sealed, SymmetricKey, UserId, VaultId, VaultKey, Wraps, +}; +use std::sync::{Mutex, MutexGuard}; use subtle::ConstantTimeEq; use zeroize::Zeroizing; @@ -15,55 +14,44 @@ 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, - pub salt: Vec, - pub password_wrapped_ark: AeadWrappedKey, - pub vault_id: VaultId, - pub wrapped_vault_key: AeadWrappedKey, -} +type ArkSessionResult = Result; + +const SALT_LEN: usize = MIN_SALT_LEN; -pub struct PasswordWrapped { +/// The ARK wrapped under a key derived from the user's password, plus the salt that derivation +/// needs. The user id is the wrap's AAD. +pub struct PasswordWrappedArk { pub salt: Vec, - pub wrapped: AeadWrappedKey, + pub wrapped: Sealed, } -type ArkSessionResult = Result; +impl PasswordWrappedArk { + fn open(&self, password: &str, user_id: UserId) -> ArkSessionResult { + Ok(derive_kek(password, &self.salt)?.unwrap_key(&self.wrapped, &user_id)?) + } +} -const PASSWORD_DOMAIN: &[u8] = b"v1:kek/pwd"; -const SALT_LEN: usize = MIN_SALT_LEN; +pub struct NewAccount { + pub user_id: UserId, + pub password_wrapped_ark: PasswordWrappedArk, + pub vault_id: VaultId, + pub wrapped_vault_key: Sealed, +} +/// Holds the ARK while the vault is unlocked. +#[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 +62,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(); + 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 +88,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 +106,87 @@ impl ArkSession { self.with_ark(|ark| Zeroizing::new(ark.as_bytes().to_vec())) } + /// Succeeds only if `password` opens `wrapped` and what it opens is the ARK this session + /// holds, so a rewrap after this cannot put a new password around a different account's key. 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 = match wrapped.open(password, user_id) { + Err(ArkSessionError::KeyWrap(_)) => return Err(ArkSessionError::WrongPassword), + other => other?, + }; + + 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 { + // Derive before taking the lock: Argon2 is slow, and nothing else may wait on it. + let salt = new_salt(); 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)?)) } + /// Runs `f` on a copy of the live ARK, without holding the session lock, so `f` may call back + /// into the session and keeps a usable key even if the session ends meanwhile. 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())? - }; - + let ark = self.with_live_ark(|live| Ok(AccountRootKey::from_bytes(live.as_bytes())?))?; Ok(f(&ark)) } + + /// Runs `f` on the live ARK while holding the session lock. `f` must not call back into 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 new_salt() -> Vec { + random_bytes::().to_vec() } 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 +213,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 +225,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,12 +235,8 @@ 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!(!session.is_active()); @@ -269,12 +249,8 @@ mod tests { // 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!(!session.is_active()); @@ -285,12 +261,8 @@ 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. @@ -309,9 +281,9 @@ mod tests { // 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 +314,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 +322,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) )); } @@ -363,12 +335,7 @@ mod tests { 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 +346,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,12 +383,7 @@ 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. @@ -457,26 +414,6 @@ 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. - #[test] - fn password_domain_is_pinned() { - assert_eq!(PASSWORD_DOMAIN, b"v1:kek/pwd"); - - const SALT: [u8; 16] = [7; 16]; - let kek = derive_kek("hunter2", &SALT).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, - ][..] - ); - } - #[test] fn with_ark_does_not_hold_the_lock_across_the_closure() { let (session, _) = unlocked(); diff --git a/rust/crates/keygo-core/src/backup/encryption.rs b/rust/crates/keygo-core/src/backup/encryption.rs index 77feb3042..e97940473 100644 --- a/rust/crates/keygo-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::kdf::argon2::Argon2Params; use crate::crypto::random::random_bytes; +use crate::crypto::{AccountRootKey, Encrypts, Sealed}; use serde::{Deserialize, Serialize}; -const NONCE_LEN: usize = 12; - #[derive(Serialize, Deserialize)] pub struct EncryptionHeader { pub source: KeySource, @@ -62,7 +59,7 @@ pub struct BackupAad { pub source: KeySource, } -impl AeadEncryptor for BackupKey { +impl Encrypts for BackupKey { type Aad = BackupAad; } @@ -99,14 +96,10 @@ 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, }) } @@ -116,9 +109,9 @@ pub fn open( cred: BackupCredential<'_>, version: u32, ) -> Result, BackupError> { - if header.nonce.len() != NONCE_LEN { - return Err(BackupError::MalformedHeader); - } + // Checked before deriving the key, which may cost a full Argon2 run. + let sealed = Sealed::from_parts(&header.nonce, ciphertext.to_vec()) + .map_err(|_| BackupError::MalformedHeader)?; let key = match (header.source, &header.kdf, cred) { // Credential's source disagrees with the header's declared source. @@ -161,18 +154,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 +198,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 +215,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), )); } @@ -251,7 +241,7 @@ mod tests { #[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,7 +249,7 @@ 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(); @@ -268,7 +258,7 @@ mod tests { #[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. @@ -297,6 +287,9 @@ mod tests { *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(_)),)); + assert!(matches!( + err, + BackupError::Crypto(CryptoError::Argon2MemoryTooHigh { .. }) + )); } } diff --git a/rust/crates/keygo-core/src/backup/error.rs b/rust/crates/keygo-core/src/backup/error.rs index fa9d14074..de9dbd28a 100644 --- a/rust/crates/keygo-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/crates/keygo-core/src/backup/format/json.rs b/rust/crates/keygo-core/src/backup/format/json.rs index 8dcc6e4e4..57e0a2c70 100644 --- a/rust/crates/keygo-core/src/backup/format/json.rs +++ b/rust/crates/keygo-core/src/backup/format/json.rs @@ -48,9 +48,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 { @@ -238,7 +236,7 @@ mod tests { #[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 +250,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 +269,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 +282,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)); @@ -408,7 +409,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/crates/keygo-core/src/backup/key.rs b/rust/crates/keygo-core/src/backup/key.rs index 4df86f847..28eeb4b41 100644 --- a/rust/crates/keygo-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/crypto/cipher.rs b/rust/crates/keygo-core/src/crypto/cipher.rs new file mode 100644 index 000000000..4e358e662 --- /dev/null +++ b/rust/crates/keygo-core/src/crypto/cipher.rs @@ -0,0 +1,104 @@ +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 serde::Serialize; +use std::marker::PhantomData; + +/// The AEAD behind every key in the hierarchy. +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; + +/// A ciphertext and the nonce it was sealed under. +/// +/// `T` names what is inside: a key type for a wrapped key, `[u8]` for data. A wrapped vault key +/// is a `Sealed` and cannot be handed to code that unwraps a `Sealed`. +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, + }, + ) + } +} + +/// AADs are BCS-encoded. The encoding is part of every stored ciphertext's format. +pub(crate) fn encode_aad(aad: &(impl Serialize + ?Sized)) -> CryptoResult> { + Ok(bcs::to_bytes(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 index fbb15c725..799d164f0 100644 --- a/rust/crates/keygo-core/src/crypto/error.rs +++ b/rust/crates/keygo-core/src/crypto/error.rs @@ -12,19 +12,22 @@ pub enum CryptoError { #[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 }, + #[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 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..235dd44b9 --- /dev/null +++ b/rust/crates/keygo-core/src/crypto/golden_tests.rs @@ -0,0 +1,81 @@ +//! Ciphertexts produced when the key hierarchy shipped, under fixed keys and ids. Every vault in +//! the field holds blobs of exactly this shape, so each one must keep opening: a change to the +//! cipher, the nonce layout, or the BCS encoding of an AAD fails here instead of on a device. +//! Regenerate only alongside a deliberate, migrated format change. + +use crate::crypto::{ + AccountRootKey, Encrypts, ItemAad, ItemKey, RootKEK, Sealed, SymmetricKey, VaultKey, Wraps, +}; +use uuid::Uuid; + +const USER_ID: Uuid = Uuid::from_u128(0x1111_1111_1111_4111_8111_1111_1111_1111); +const VAULT_ID: Uuid = Uuid::from_u128(0x2222_2222_2222_4222_8222_2222_2222_2222); +const ITEM_ID: Uuid = Uuid::from_u128(0x3333_3333_3333_4333_8333_3333_3333_3333); + +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 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", +); +const ITEM_DATA: (&str, &str) = ( + "t78N42bK5A+RKqzk", + "qD1SvmKQlBXgEE4MzPeJ3ZWJKduWgqkOU+xSRBxlBsY=", +); +const ITEM_DATA_AAD: &[u8] = b"item-aad"; +const ITEM_DATA_PLAINTEXT: &[u8] = b"secret item data"; + +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 = 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 plaintext = item_key.decrypt(&sealed(ITEM_DATA), ITEM_DATA_AAD).unwrap(); + assert_eq!(plaintext, ITEM_DATA_PLAINTEXT); +} diff --git a/rust/crates/keygo-core/src/crypto/types.rs b/rust/crates/keygo-core/src/crypto/ids.rs similarity index 100% rename from rust/crates/keygo-core/src/crypto/types.rs rename to rust/crates/keygo-core/src/crypto/ids.rs 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..ec430b73f --- /dev/null +++ b/rust/crates/keygo-core/src/crypto/kdf/argon2.rs @@ -0,0 +1,162 @@ +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; + +#[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, + } + } +} + +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, + }); + } + 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)) + } +} + +/// Derive a key from a password with Argon2id. +/// +/// The salt must be at least `MIN_SALT_LEN` bytes and persisted with the credential, so the same +/// key can be derived again later. `domain` is appended to the salt as a label, separating +/// otherwise identical derivations (e.g. the password KEK from a backup key). The costs are explicit +/// so a derivation can be reproduced from costs recorded when it was first made; a header asking +/// for more than `MAX_ARGON2_MEM_KIB` is rejected rather than allowed to exhaust memory. +/// +/// The password is not zeroized here: the caller owns that buffer. +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_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..a05d69b1f --- /dev/null +++ b/rust/crates/keygo-core/src/crypto/kdf/hkdf.rs @@ -0,0 +1,58 @@ +use crate::crypto::KEY_LEN; +use hkdf::Hkdf; +use sha2::Sha256; +use zeroize::Zeroizing; + +/// Derive a key from high-entropy keying material with 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 extract salt (a fresh per-use random value); `info` is the +/// expand label that domain-separates otherwise identical derivations. +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..05050ff9a --- /dev/null +++ b/rust/crates/keygo-core/src/crypto/kdf/mod.rs @@ -0,0 +1,2 @@ +pub(crate) mod argon2; +pub(crate) mod hkdf; diff --git a/rust/crates/keygo-core/src/crypto/key.rs b/rust/crates/keygo-core/src/crypto/key.rs index 0f54a2f9f..2bcc58bf7 100644 --- a/rust/crates/keygo-core/src/crypto/key.rs +++ b/rust/crates/keygo-core/src/crypto/key.rs @@ -1,14 +1,195 @@ -use crate::crypto::error::CryptoResult; -use aead::{Aead, AeadCore, Key, KeyInit}; -use zeroize::{Zeroize, ZeroizeOnDrop}; +use crate::crypto::cipher::encode_aad; +use crate::crypto::{CipherKey, CryptoError, CryptoResult, Sealed}; +use serde::Serialize; +use zeroize::{ZeroizeOnDrop, Zeroizing}; + +/// A key for the hierarchy's [`Cipher`](crate::crypto::Cipher). Declare one with +/// [`symmetric_key!`]. +pub trait SymmetricKey: Sized + ZeroizeOnDrop { + fn from_bytes(bytes: &[u8]) -> CryptoResult; -pub trait KeyMaterial: Sized { - fn try_from_bytes(bytes: &[u8]) -> CryptoResult; fn as_bytes(&self) -> &[u8]; + + #[doc(hidden)] + fn cipher_key(&self) -> &CipherKey; +} + +/// `Self` wraps keys of type `K`. Each wrap is bound to an `Aad`, so it only unwraps in the +/// context it was made for. +pub trait Wraps: SymmetricKey { + type Aad: Serialize + ?Sized; + + 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) + } +} + +/// `Self` encrypts data. Each ciphertext is bound to an `Aad`. +pub trait Encrypts: SymmetricKey { + type Aad: Serialize + ?Sized; + + 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) + } } -pub trait AeadKey: KeyMaterial + Zeroize + ZeroizeOnDrop + Sized { - type Algorithm: AeadCore + Aead + KeyInit; +/// Declares a zeroize-on-drop [`SymmetricKey`] newtype. +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 as_bytes(&self) -> &[u8] { + self.0.as_slice() + } + + fn cipher_key(&self) -> &$crate::crypto::CipherKey { + &self.0 + } + } + }; +} +pub(crate) use symmetric_key; + +#[cfg(test)] +mod tests { + use super::*; + + symmetric_key! { + struct TestWrappingKey; + } + symmetric_key! { + struct TestKey; + } + + #[derive(Serialize)] + 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(); - fn key(&self) -> &Key; + 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..2ae6547a9 --- /dev/null +++ b/rust/crates/keygo-core/src/crypto/keys.rs @@ -0,0 +1,133 @@ +//! The key hierarchy, and what each wrap is bound to: +//! +//! ```text +//! RootKEK --UserId--> AccountRootKey --VaultId--> VaultKey --ItemAad--> ItemKey --[u8]--> item data +//! ``` + +use crate::crypto::kdf::argon2::{Argon2Params, derive_argon2id}; +use crate::crypto::{ + CipherKey, CryptoResult, Encrypts, ItemId, SymmetricKey, UserId, VaultId, Wraps, symmetric_key, +}; +use aead::Generate; +use serde::Serialize; + +/// Mixed into the password KEK's salt. Every account's ARK is wrapped under a KEK derived with +/// it, so it can never change. +const PASSWORD_DOMAIN: &[u8] = b"v1:kek/pwd"; + +symmetric_key! { + /// Root key-encryption key: wraps the [`AccountRootKey`]. Derived from the user's password on + /// every unlock and never stored. + pub struct RootKEK; +} + +symmetric_key! { + /// One per account. Held by the session while unlocked. + pub struct AccountRootKey; +} + +symmetric_key! { + /// One per vault. + pub struct VaultKey; +} + +symmetric_key! { + /// One per item. Moving an item between vaults rewraps this key, not the item's data. + pub struct ItemKey; +} + +/// Binds a wrapped [`ItemKey`] to its item and vault, so it cannot be transplanted. +#[derive(Serialize)] +pub struct ItemAad { + pub item_id: ItemId, + pub vault_id: VaultId, +} + +impl Wraps for RootKEK { + type Aad = UserId; // TODO: also include epoch: pw wrapped: pwd_cred_epoch, rk wrapped: rk_cred_epoch +} + +impl Wraps for AccountRootKey { + type Aad = VaultId; //TODO: also include epoch: vault_key_epoch +} + +impl Wraps for VaultKey { + type Aad = ItemAad; +} + +impl Encrypts for ItemKey { + type Aad = [u8]; +} + +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()) + } +} + +impl AccountRootKey { + pub fn generate() -> Self { + Self(CipherKey::generate()) + } +} + +impl VaultKey { + pub fn generate() -> Self { + Self(CipherKey::generate()) + } +} + +impl ItemKey { + pub fn generate() -> Self { + Self(CipherKey::generate()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + 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()); + } + + /// 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. + #[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, + ][..] + ); + } + + #[test] + fn generated_keys_differ() { + assert_ne!( + VaultKey::generate().as_bytes(), + VaultKey::generate().as_bytes() + ); + } +} diff --git a/rust/crates/keygo-core/src/crypto/keys/account_root_key.rs b/rust/crates/keygo-core/src/crypto/keys/account_root_key.rs deleted file mode 100644 index 2bade0da7..000000000 --- a/rust/crates/keygo-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/crates/keygo-core/src/crypto/keys/item_key.rs b/rust/crates/keygo-core/src/crypto/keys/item_key.rs deleted file mode 100644 index 082bc1454..000000000 --- a/rust/crates/keygo-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/crates/keygo-core/src/crypto/keys/mod.rs b/rust/crates/keygo-core/src/crypto/keys/mod.rs deleted file mode 100644 index 464857873..000000000 --- a/rust/crates/keygo-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/crates/keygo-core/src/crypto/keys/root_kek.rs b/rust/crates/keygo-core/src/crypto/keys/root_kek.rs deleted file mode 100644 index 7f5fb6ada..000000000 --- a/rust/crates/keygo-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/crates/keygo-core/src/crypto/keys/signing_key.rs b/rust/crates/keygo-core/src/crypto/keys/signing_key.rs deleted file mode 100644 index 35f291396..000000000 --- a/rust/crates/keygo-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/crates/keygo-core/src/crypto/keys/vault_key.rs b/rust/crates/keygo-core/src/crypto/keys/vault_key.rs deleted file mode 100644 index 198d10998..000000000 --- a/rust/crates/keygo-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/crates/keygo-core/src/crypto/macros.rs b/rust/crates/keygo-core/src/crypto/macros.rs deleted file mode 100644 index b3ec2305b..000000000 --- a/rust/crates/keygo-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/crates/keygo-core/src/crypto/mod.rs b/rust/crates/keygo-core/src/crypto/mod.rs index b1ad72a21..67355a336 100644 --- a/rust/crates/keygo-core/src/crypto/mod.rs +++ b/rust/crates/keygo-core/src/crypto/mod.rs @@ -1,16 +1,17 @@ -pub mod error; +mod cipher; +mod error; +mod ids; +pub(crate) mod kdf; mod key; mod keys; -mod macros; -pub mod primitive; -pub mod random; -pub mod types; +pub(crate) mod random; -pub use key::{AeadKey, KeyMaterial}; -pub use keys::{ - AccountRootKey, ItemAad, ItemDataAad, ItemKey, RootKEK, ScopedSigningKey, TryDeriveFrom, - VaultKey, -}; +pub use cipher::{Cipher, CipherKey, KEY_LEN, NONCE_LEN, Sealed}; +pub use error::{CryptoError, CryptoResult}; +pub use ids::{ItemId, UserId, VaultId}; +pub(crate) use key::symmetric_key; +pub use key::{Encrypts, SymmetricKey, Wraps}; +pub use keys::{AccountRootKey, ItemAad, ItemKey, RootKEK, VaultKey}; #[cfg(test)] mod golden_tests; diff --git a/rust/crates/keygo-core/src/crypto/primitive/aead_data.rs b/rust/crates/keygo-core/src/crypto/primitive/aead_data.rs deleted file mode 100644 index 59723ab1d..000000000 --- a/rust/crates/keygo-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/crates/keygo-core/src/crypto/primitive/argon2.rs b/rust/crates/keygo-core/src/crypto/primitive/argon2.rs deleted file mode 100644 index d52da3b67..000000000 --- a/rust/crates/keygo-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/crates/keygo-core/src/crypto/primitive/hkdf.rs b/rust/crates/keygo-core/src/crypto/primitive/hkdf.rs deleted file mode 100644 index ebe6856ed..000000000 --- a/rust/crates/keygo-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/crates/keygo-core/src/crypto/primitive/mod.rs b/rust/crates/keygo-core/src/crypto/primitive/mod.rs deleted file mode 100644 index deae068a2..000000000 --- a/rust/crates/keygo-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/crates/keygo-core/src/crypto/primitive/wrap_key.rs b/rust/crates/keygo-core/src/crypto/primitive/wrap_key.rs deleted file mode 100644 index 1edd21222..000000000 --- a/rust/crates/keygo-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/crates/keygo-core/src/crypto/random.rs b/rust/crates/keygo-core/src/crypto/random.rs index eea905fc3..9cd0f6146 100644 --- a/rust/crates/keygo-core/src/crypto/random.rs +++ b/rust/crates/keygo-core/src/crypto/random.rs @@ -3,7 +3,7 @@ use rand::rand_core::UnwrapErr; use rand::rngs::SysRng; /// Generate a random byte array of the specified length. -pub fn random_bytes() -> [u8; N] { +pub(crate) fn random_bytes() -> [u8; N] { let mut buf = [0u8; N]; UnwrapErr(&mut SysRng).fill_bytes(&mut buf); buf diff --git a/rust/crates/keygo-uniffi/src/ark_session.rs b/rust/crates/keygo-uniffi/src/ark_session.rs index 4d2f42a81..0fd877996 100644 --- a/rust/crates/keygo-uniffi/src/ark_session.rs +++ b/rust/crates/keygo-uniffi/src/ark_session.rs @@ -1,10 +1,9 @@ use crate::key_wrap::{KeyWrapError, WrappedKeyBlob}; use keygo_core::ark_session::{ 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, UserId, VaultId, VaultKey}; use std::sync::Arc; #[derive(Debug, thiserror::Error, uniffi::Error)] @@ -24,12 +23,18 @@ 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(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,23 +44,43 @@ 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(), + } } } +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, @@ -91,14 +116,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 +126,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 +147,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 +160,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 +171,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 +181,7 @@ 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 ark_credential(self: Arc) -> Arc { diff --git a/rust/crates/keygo-uniffi/src/backup/mod.rs b/rust/crates/keygo-uniffi/src/backup/mod.rs index 5bd854ad5..34d96452b 100644 --- a/rust/crates/keygo-uniffi/src/backup/mod.rs +++ b/rust/crates/keygo-uniffi/src/backup/mod.rs @@ -146,7 +146,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/crates/keygo-uniffi/src/item.rs b/rust/crates/keygo-uniffi/src/item.rs index 2d67970e0..0128b66e7 100644 --- a/rust/crates/keygo-uniffi/src/item.rs +++ b/rust/crates/keygo-uniffi/src/item.rs @@ -1,19 +1,6 @@ -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, ItemId, ItemKey, Sealed, 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, @@ -26,6 +13,19 @@ pub struct EncryptedItemBlob { pub nonce: Vec, } +impl From> for EncryptedItemBlob { + fn from(sealed: Sealed<[u8]>) -> Self { + let (nonce, ciphertext) = sealed.into_parts(); + Self { ciphertext, nonce } + } +} + +impl EncryptedItemBlob { + 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")] @@ -45,12 +45,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,7 +65,7 @@ impl ItemManager { } pub fn create_new_item_key(&self) -> ItemKey { - ItemKey::generate_random() + ItemKey::generate() } pub fn encrypt_item_data( @@ -75,12 +74,7 @@ impl ItemManager { data: Vec, aad: Vec, ) -> 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( @@ -89,8 +83,6 @@ impl ItemManager { blob: EncryptedItemBlob, aad: Vec, ) -> 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/crates/keygo-uniffi/src/key_wrap.rs b/rust/crates/keygo-uniffi/src/key_wrap.rs index d67de3762..05b2cd92b 100644 --- a/rust/crates/keygo-uniffi/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,6 +7,19 @@ 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")] @@ -30,45 +39,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 +64,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 +73,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/crates/keygo-uniffi/src/types.rs b/rust/crates/keygo-uniffi/src/types.rs index 911d98bce..fbee8721d 100644 --- a/rust/crates/keygo-uniffi/src/types.rs +++ b/rust/crates/keygo-uniffi/src/types.rs @@ -1,4 +1,4 @@ -use keygo_core::crypto::{KeyMaterial, VaultKey}; +use keygo_core::crypto::{ItemKey, SymmetricKey, VaultKey}; use uuid::Uuid; uniffi::custom_type!(Uuid, String, { @@ -9,9 +9,12 @@ uniffi::custom_type!(Uuid, String, { uniffi::custom_type!(VaultKey, Vec, { remote, - try_lift: |bytes| { - VaultKey::try_from_bytes(&bytes) - .map_err(|e| uniffi::deps::anyhow::anyhow!("{e:?}")) - }, + 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/crates/keygo-uniffi/src/vault.rs b/rust/crates/keygo-uniffi/src/vault.rs index e74f66381..bd3c40ba4 100644 --- a/rust/crates/keygo-uniffi/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() } } From 6c1e688bac16c2cb9998071d8604021b059a3d78 Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Sat, 10 Oct 2026 15:52:45 +0200 Subject: [PATCH 08/18] refactor(rust): simpler passkey, TOTP and CSV core APIs passkey: RegistrationError and ProviderError overlapped variant for variant; merge them into one PasskeyError, mapped once at the FFI. register and authenticate share request parsing, origin and response encoding. The remaining unwrap()s become NoCredentialCreated and ResponseEncoding errors instead of panics in an async FFI call. The codec's wire types go private behind encode/decode, so their serde shape is no longer public API. totp: algorithm, digits and period travel as one TotpParams instead of three positional arguments, and secrets are borrowed. get_totp, get_totp_url and get_totp_info_from_uri become generate, to_uri and parse_uri. csv: ColumnMapping uses u32 like CsvColumn.index, so the FFI re-exports the core type instead of keeping a usize-to-u32 copy of it. The Kotlin-facing API is unchanged. Co-Authored-By: Claude Opus 5.5 --- .../src/backup/format/csv/detect.rs | 2 +- .../keygo-core/src/backup/format/csv/mod.rs | 19 +- .../keygo-core/src/passkey/authentication.rs | 29 +++ .../keygo-core/src/passkey/authenticator.rs | 19 +- .../passkey/{keygo_passkey.rs => codec.rs} | 85 +++---- rust/crates/keygo-core/src/passkey/mod.rs | 50 +++- .../crates/keygo-core/src/passkey/provider.rs | 48 ---- .../keygo-core/src/passkey/registration.rs | 113 ++++----- rust/crates/keygo-core/src/totp.rs | 236 ++++++++++-------- rust/crates/keygo-uniffi/src/backup/csv.rs | 48 +--- rust/crates/keygo-uniffi/src/backup/mod.rs | 10 +- rust/crates/keygo-uniffi/src/passkey.rs | 56 ++--- rust/crates/keygo-uniffi/src/totp.rs | 39 +-- 13 files changed, 347 insertions(+), 407 deletions(-) create mode 100644 rust/crates/keygo-core/src/passkey/authentication.rs rename rust/crates/keygo-core/src/passkey/{keygo_passkey.rs => codec.rs} (57%) delete mode 100644 rust/crates/keygo-core/src/passkey/provider.rs diff --git a/rust/crates/keygo-core/src/backup/format/csv/detect.rs b/rust/crates/keygo-core/src/backup/format/csv/detect.rs index 83fcf3398..e3086b4fe 100644 --- a/rust/crates/keygo-core/src/backup/format/csv/detect.rs +++ b/rust/crates/keygo-core/src/backup/format/csv/detect.rs @@ -287,7 +287,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; diff --git a/rust/crates/keygo-core/src/backup/format/csv/mod.rs b/rust/crates/keygo-core/src/backup/format/csv/mod.rs index ac517c735..5a3c7a011 100644 --- a/rust/crates/keygo-core/src/backup/format/csv/mod.rs +++ b/rust/crates/keygo-core/src/backup/format/csv/mod.rs @@ -12,14 +12,15 @@ use csv::StringRecord; use self::detect::{build_mapping, build_reader, strip_bom}; use crate::backup::{Backup, BackupError, Login, Vault}; +/// Which column (by index) holds each field. #[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)] @@ -105,7 +106,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), @@ -245,8 +246,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) 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..feac301b7 --- /dev/null +++ b/rust/crates/keygo-core/src/passkey/authentication.rs @@ -0,0 +1,29 @@ +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}; + +/// Answer a WebAuthn assertion request with a stored passkey, returning the response JSON. +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)?; + 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) +} diff --git a/rust/crates/keygo-core/src/passkey/authenticator.rs b/rust/crates/keygo-core/src/passkey/authenticator.rs index b555dcdf6..2675e3191 100644 --- a/rust/crates/keygo-core/src/passkey/authenticator.rs +++ b/rust/crates/keygo-core/src/passkey/authenticator.rs @@ -4,10 +4,12 @@ use passkey::types::Passkey; use passkey::types::ctap2::{Aaguid, Ctap2Error}; use passkey_authenticator::UiHint; -pub(crate) struct KeyGoUserValidation {} +/// Reports presence and verification as requested: the app has already verified the user by the +/// time a request reaches Rust. +pub(super) struct PreVerifiedUser; #[async_trait::async_trait] -impl UserValidationMethod for KeyGoUserValidation { +impl UserValidationMethod for PreVerifiedUser { type PasskeyItem = Passkey; async fn check_user<'a>( @@ -31,10 +33,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 {}, RustCryptoBackend) +) -> Authenticator { + Authenticator::new( + Aaguid::new_empty(), + store, + PreVerifiedUser, + RustCryptoBackend, + ) } diff --git a/rust/crates/keygo-core/src/passkey/keygo_passkey.rs b/rust/crates/keygo-core/src/passkey/codec.rs similarity index 57% rename from rust/crates/keygo-core/src/passkey/keygo_passkey.rs rename to rust/crates/keygo-core/src/passkey/codec.rs index 51c55440b..dafb1a9d5 100644 --- a/rust/crates/keygo-core/src/passkey/keygo_passkey.rs +++ b/rust/crates/keygo-core/src/passkey/codec.rs @@ -1,10 +1,13 @@ +//! The bytes a passkey is stored as. Shipped passkeys are in this format: the `V1` tag and the +//! field names of [`StoredPasskey`] must not change. + use coset::{CborSerializable, CoseError, CoseKey}; use passkey::types::Passkey; use serde::{Deserialize, Serialize}; use thiserror::Error; #[derive(Serialize, Deserialize)] -pub struct KeyGoPasskey { +struct StoredPasskey { key: Vec, credential_id: Vec, rp_id: String, @@ -15,12 +18,12 @@ pub struct KeyGoPasskey { } #[derive(Serialize, Deserialize)] -pub enum KeyGoPasskeyWire { - V1(KeyGoPasskey), +enum Versioned { + V1(StoredPasskey), } #[derive(Debug, Error)] -pub enum PasskeyCodecError { +pub enum CodecError { #[error("cose key error: {0:?}")] Cose(CoseError), #[error("cbor encode error: {0}")] @@ -29,53 +32,33 @@ pub enum PasskeyCodecError { 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)?); +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(&wire, &mut buf)?; + ciborium::into_writer(&Versioned::V1(stored), &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), - } +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)] @@ -107,7 +90,7 @@ mod tests { #[test] fn golden_v1_still_decodes() { let original = sample_passkey(); - let decoded = passkey_from_bytes(&crate::b64::decode(GOLDEN_V1).unwrap()).unwrap(); + let decoded = decode(&crate::b64::decode(GOLDEN_V1).unwrap()).unwrap(); assert_eq!(decoded.key, original.key); assert_eq!(decoded.credential_id, original.credential_id); @@ -121,7 +104,7 @@ mod tests { #[test] fn encoding_is_stable() { assert_eq!( - crate::b64::encode(to_bytes(sample_passkey()).unwrap()), + crate::b64::encode(encode(sample_passkey()).unwrap()), GOLDEN_V1 ); } @@ -129,8 +112,8 @@ mod tests { #[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"); + 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); diff --git a/rust/crates/keygo-core/src/passkey/mod.rs b/rust/crates/keygo-core/src/passkey/mod.rs index 9fcf4f0f7..b74afdafe 100644 --- a/rust/crates/keygo-core/src/passkey/mod.rs +++ b/rust/crates/keygo-core/src/passkey/mod.rs @@ -1,11 +1,45 @@ +//! WebAuthn registration and authentication, backed by the `passkey` crate's software +//! authenticator. The user is verified on the Kotlin side before either call is made. + +mod authentication; mod authenticator; -mod keygo_passkey; -mod provider; +mod codec; mod registration; -pub use keygo_passkey::PasskeyCodecError; -pub use provider::{ProviderError, provide_passkey}; -pub use registration::{ - KeyGoRegistrationResponse, PasskeyInformation, RegistrationError, get_passkey_information, - register_passkey, -}; +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, +} + +fn parse_request(json: &str) -> Result { + serde_json::from_str(json).map_err(PasskeyError::InvalidRequest) +} + +/// The origin a request is made for, from the relying party id it names. +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/provider.rs b/rust/crates/keygo-core/src/passkey/provider.rs deleted file mode 100644 index b565a49c0..000000000 --- a/rust/crates/keygo-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/crates/keygo-core/src/passkey/registration.rs b/rust/crates/keygo-core/src/passkey/registration.rs index 3094c0d59..b69977285 100644 --- a/rust/crates/keygo-core/src/passkey/registration.rs +++ b/rust/crates/keygo-core/src/passkey/registration.rs @@ -1,92 +1,69 @@ -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 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}; -use thiserror::Error; -pub struct KeyGoRegistrationResponse { +/// A newly created passkey: the response JSON for the relying party, and what to store. +pub struct Registration { pub response: String, pub user_name: String, pub user_display_name: String, pub credential_id: Vec, + /// The passkey in its stored encoding. 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 { +/// What the app needs from a creation request before asking the user to confirm it. +pub struct CreationInfo { 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 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_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.user.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) +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(RegistrationError::WebauthnError)?; - let response_json = serde_json::to_string(&pub_key_credential).unwrap(); + .map_err(PasskeyError::Webauthn)?; - let response = client.authenticator().store().clone().unwrap(); + let passkey = client + .authenticator() + .store() + .clone() + .ok_or(PasskeyError::NoCredentialCreated)?; - let credential_id = response.credential_id.clone().into(); - Ok(KeyGoRegistrationResponse { - response: response_json, + Ok(Registration { + response: to_json(&credential)?, user_name, user_display_name, - credential_id, - rp: response.rp_id.clone(), - private_key: to_bytes(response).map_err(KeyEncodeError)?, + credential_id: passkey.credential_id.to_vec(), + rp: passkey.rp_id.clone(), + private_key: codec::encode(passkey)?, }) } @@ -103,7 +80,7 @@ mod tests { #[tokio::test] async fn registration_reports_the_users_name_not_the_relying_partys() { - let registration = register_passkey(REQUEST).await.unwrap(); + let registration = register(REQUEST).await.unwrap(); assert_eq!(registration.user_name, "alice@example.com"); assert_eq!(registration.user_display_name, "Alice"); diff --git a/rust/crates/keygo-core/src/totp.rs b/rust/crates/keygo-core/src/totp.rs index 77675b595..dde30f31d 100644 --- a/rust/crates/keygo-core/src/totp.rs +++ b/rust/crates/keygo-core/src/totp.rs @@ -1,6 +1,6 @@ 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 @@ -10,12 +10,89 @@ 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), } +/// How codes are generated from a secret. +#[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 { + /// Base32, as it appears in an `otpauth://` URI. + pub secret: String, + pub issuer: Option, + pub account_name: String, + pub params: TotpParams, +} + +/// The current code for a base32 `secret`. +pub fn generate(params: TotpParams, secret: &str) -> Result { + Ok(build(params, secret, None, "")? + .generate_current() + .to_string()) +} + +/// An `otpauth://` URI for a base32 `secret`. +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 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(), + params: TotpParams { + algorithm: totp.algorithm(), + digits: totp.digits(), + period: totp.step(), + }, + }) +} + +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) +} + /// Every check `Builder::build` runs, with its 128 bit secret floor replaced by /// "the secret must not be empty". /// @@ -27,7 +104,9 @@ pub enum TotpError { /// adds later. fn validate(totp: &Totp) -> Result<(), TotpError> { if totp.secret().is_empty() { - return Err(TotpError::Url(TOTPError::SecretTooShort { bits: 0 })); + return Err(TotpError::Invalid(totp_rs::TotpError::SecretTooShort { + bits: 0, + })); } Builder::new() @@ -40,79 +119,7 @@ fn validate(totp: &Totp) -> Result<(), TotpError> { .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()) -} - -pub struct TotpInfo { - pub secret: String, - pub issuer: Option, - pub account_name: String, - pub algorithm: Algorithm, - pub digits: u8, - pub period: u64, -} - -pub fn get_totp_info_from_uri(uri: String) -> Result { - let totp = Totp::from_url_unchecked(uri).map_err(TotpError::Url)?; - 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(), - }) -} - -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 { - base32::decode(base32::Alphabet::Rfc4648 { padding: false }, s).is_some() + .map_err(TotpError::Invalid) } #[cfg(test)] @@ -124,31 +131,30 @@ mod tests { /// 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 +169,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,17 +193,26 @@ 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 @@ -198,7 +222,7 @@ mod tests { 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 @@ -206,14 +230,7 @@ mod tests { /// 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()); } @@ -256,15 +273,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/crates/keygo-uniffi/src/backup/csv.rs b/rust/crates/keygo-uniffi/src/backup/csv.rs index d2b4ab708..33d36282e 100644 --- a/rust/crates/keygo-uniffi/src/backup/csv.rs +++ b/rust/crates/keygo-uniffi/src/backup/csv.rs @@ -1,6 +1,6 @@ use keygo_core::backup::{ - Backup, ColumnMapping as CoreColumnMapping, Confidence, CsvAnalysis as CoreCsvAnalysis, - CsvColumn, ExportPreset, FieldConfidence, ImportReport, + Backup, ColumnMapping, Confidence, CsvAnalysis, CsvColumn, ExportPreset, FieldConfidence, + ImportReport, }; #[uniffi::remote(Enum)] @@ -45,8 +45,8 @@ pub enum JsonEncryption { Ark, } -#[derive(uniffi::Record, Default)] -pub struct ColumnMapping { +#[uniffi::remote(Record)] +struct ColumnMapping { pub title: Option, pub url: Option, pub username: Option, @@ -55,49 +55,13 @@ pub struct ColumnMapping { 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 { +#[uniffi::remote(Record)] +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, diff --git a/rust/crates/keygo-uniffi/src/backup/mod.rs b/rust/crates/keygo-uniffi/src/backup/mod.rs index 34d96452b..959f8ace7 100644 --- a/rust/crates/keygo-uniffi/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)] @@ -127,7 +127,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 +135,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 }) } diff --git a/rust/crates/keygo-uniffi/src/passkey.rs b/rust/crates/keygo-uniffi/src/passkey.rs index 282f624af..03f62ac3b 100644 --- a/rust/crates/keygo-uniffi/src/passkey.rs +++ b/rust/crates/keygo-uniffi/src/passkey.rs @@ -1,8 +1,6 @@ use keygo_core::passkey::{ - KeyGoRegistrationResponse, PasskeyInformation as CorePasskeyInformation, RegistrationError, - get_passkey_information, register_passkey, + self as core, CreationInfo, PasskeyError as CorePasskeyError, Registration, }; -use keygo_core::passkey::{ProviderError, provide_passkey}; use std::sync::Arc; #[derive(Debug, thiserror::Error, uniffi::Error)] @@ -17,31 +15,18 @@ pub enum PasskeyError { KeyCodec { reason: String }, } -impl From for PasskeyError { - fn from(value: RegistrationError) -> Self { +impl From for PasskeyError { + fn from(value: CorePasskeyError) -> Self { match value { - RegistrationError::InvalidJsonFormat => Self::InvalidJsonFormat, - RegistrationError::InvalidDomain => Self::InvalidDomain, - RegistrationError::WebauthnError(e) => Self::Webauthn { + CorePasskeyError::InvalidRequest(_) => Self::InvalidJsonFormat, + CorePasskeyError::InvalidDomain => Self::InvalidDomain, + CorePasskeyError::Webauthn(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:?}"), + other @ (CorePasskeyError::Codec(_) + | CorePasskeyError::ResponseEncoding(_) + | CorePasskeyError::NoCredentialCreated) => Self::KeyCodec { + reason: other.to_string(), }, } } @@ -57,8 +42,8 @@ pub struct RegistrationResponse { pub rp: String, } -impl From for RegistrationResponse { - fn from(value: KeyGoRegistrationResponse) -> Self { +impl From for RegistrationResponse { + fn from(value: Registration) -> Self { Self { response: value.response, user_name: value.user_name, @@ -76,8 +61,8 @@ pub struct PasskeyInformation { pub rp: String, } -impl From for PasskeyInformation { - fn from(value: CorePasskeyInformation) -> Self { +impl From for PasskeyInformation { + fn from(value: CreationInfo) -> Self { Self { exclude_credentials: value.exclude_credentials, rp: value.rp, @@ -99,19 +84,14 @@ impl RustPasskey { &self, json_request: String, ) -> Result { - register_passkey(&json_request) - .await - .map(Into::into) - .map_err(Into::into) + Ok(core::register(&json_request).await?.into()) } pub fn passkey_information( &self, json_request: String, ) -> Result { - get_passkey_information(&json_request) - .map(Into::into) - .map_err(Into::into) + Ok(core::creation_info(&json_request)?.into()) } pub async fn authenticate( @@ -120,8 +100,6 @@ impl RustPasskey { passkey: Vec, client_data_hash: Option>, ) -> Result { - provide_passkey(&json_request, &passkey, client_data_hash) - .await - .map_err(Into::into) + Ok(core::authenticate(&json_request, &passkey, client_data_hash).await?) } } diff --git a/rust/crates/keygo-uniffi/src/totp.rs b/rust/crates/keygo-uniffi/src/totp.rs index 14eae82ec..48f328368 100644 --- a/rust/crates/keygo-uniffi/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)] @@ -58,9 +55,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 +77,15 @@ impl From for TotpError { } } +/// Kotlin hands these over as `Int`; anything negative or out of range is rejected here. +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 +103,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 +115,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() } } From a525e9e70b71957e97400315097af72893fd0c23 Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Sat, 10 Oct 2026 15:56:31 +0200 Subject: [PATCH 09/18] build(rust): LTO release profile, forbid unsafe in core, drop ed25519-dalek - [profile.release] lto + codegen-units = 1 shrinks the arm64 .so from 3.96 MB to 2.78 MB. Symbols are kept (strip = debuginfo) because uniffi-bindgen reads its metadata from the library; bindings generated from the LTO build match exactly. panic stays unwind so UniFFI can turn a panic into a Kotlin exception. - keygo-core has no unsafe code; forbid it so it stays that way. - ed25519-dalek was only used by the removed ScopedSigningKey. Co-Authored-By: Claude Opus 5.5 --- rust/Cargo.lock | 1 - rust/Cargo.toml | 5 +++++ rust/crates/keygo-core/Cargo.toml | 1 - rust/crates/keygo-core/src/lib.rs | 2 ++ 4 files changed, 7 insertions(+), 2 deletions(-) diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 2838b5448..de2bef08d 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -1012,7 +1012,6 @@ dependencies = [ "ciborium", "coset", "csv", - "ed25519-dalek", "email_address", "hkdf", "passkey", diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 82a282d4f..f8dd375bf 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -14,3 +14,8 @@ thiserror = "2.0.18" uuid = { version = "1.23.1", features = ["serde", "v4"] } uniffi = "0.32.0" zeroize = "1.8.2" + +[profile.release] +lto = true +codegen-units = 1 +strip = "debuginfo" diff --git a/rust/crates/keygo-core/Cargo.toml b/rust/crates/keygo-core/Cargo.toml index ac8e63052..313e150ef 100644 --- a/rust/crates/keygo-core/Cargo.toml +++ b/rust/crates/keygo-core/Cargo.toml @@ -13,7 +13,6 @@ 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.6.0" passkey-authenticator = { version = "0.6.0", features = ["tokio", "testable"] } # Needed so passkey JSON responses are serialized into base64 strings diff --git a/rust/crates/keygo-core/src/lib.rs b/rust/crates/keygo-core/src/lib.rs index 11ca2d60d..f6915a921 100644 --- a/rust/crates/keygo-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; From 4ba3500f874022147cd323aebbc845c694a67674 Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Sat, 10 Oct 2026 16:40:05 +0200 Subject: [PATCH 10/18] build(rust): declare tokio once as a workspace dependency keygo-uniffi and keygo-core (dev) both depend on it; the workspace now owns the version and each crate keeps its own features. Co-Authored-By: Claude Opus 5.5 --- rust/Cargo.toml | 1 + rust/crates/keygo-core/Cargo.toml | 2 +- rust/crates/keygo-uniffi/Cargo.toml | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/rust/Cargo.toml b/rust/Cargo.toml index f8dd375bf..1f2f95f17 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -11,6 +11,7 @@ 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" diff --git a/rust/crates/keygo-core/Cargo.toml b/rust/crates/keygo-core/Cargo.toml index 313e150ef..9913cee5b 100644 --- a/rust/crates/keygo-core/Cargo.toml +++ b/rust/crates/keygo-core/Cargo.toml @@ -34,4 +34,4 @@ totp-rs = { version = "6.0.0", features = ["otpauth", "zeroize"] } url = "2.5.8" [dev-dependencies] -tokio = { version = "1.48.0", features = ["rt", "macros"] } +tokio = { workspace = true, features = ["rt", "macros"] } diff --git a/rust/crates/keygo-uniffi/Cargo.toml b/rust/crates/keygo-uniffi/Cargo.toml index 71060a2c0..178c003ce 100644 --- a/rust/crates/keygo-uniffi/Cargo.toml +++ b/rust/crates/keygo-uniffi/Cargo.toml @@ -12,5 +12,5 @@ keygo-core = { path = "../keygo-core" } thiserror.workspace = true uuid.workspace = true -tokio = { version = "1.48.0", features = ["rt", "rt-multi-thread"] } +tokio = { workspace = true, features = ["rt", "rt-multi-thread"] } uniffi = { workspace = true, features = ["tokio"] } From 9d72683e18daacc14e3f196039131752c4a15b7d Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Sat, 10 Oct 2026 17:31:58 +0200 Subject: [PATCH 11/18] chore: ignore OS-generated files (macOS, Windows, Linux) Add the standard templates for files the operating system drops into working folders: Finder's .DS_Store and friends, Windows thumbnail and shortcut files, Linux editor backups and trash folders. Co-Authored-By: Claude Opus 5.5 --- .gitignore | 71 +++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 70 insertions(+), 1 deletion(-) 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 From 196c656f4f4cfd4a1e0e98cd0148617d5aafa4b1 Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Sat, 10 Oct 2026 18:28:32 +0200 Subject: [PATCH 12/18] build(rust): leave stripping to the packaging tool AGP strips native libraries when it packages the APK, after extracting their symbol tables for Play Console (debugSymbolLevel = SYMBOL_TABLE). Other consumers, such as a future PyO3 wheel built by maturin, strip on their own terms too. The Cargo strip setting had no measurable effect here anyway: the arm64 library is byte-for-byte the same size with or without it. The size win from the release profile is LTO + codegen-units = 1: the stripped arm64 library that ships goes from 2.67 MB to 2.25 MB. Co-Authored-By: Claude Opus 5.5 --- rust/Cargo.toml | 1 - 1 file changed, 1 deletion(-) diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 1f2f95f17..49a59cffe 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -19,4 +19,3 @@ zeroize = "1.8.2" [profile.release] lto = true codegen-units = 1 -strip = "debuginfo" From a27aa4bc2b3d62e0c3d4da126d1a2961ba47f3ee Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Sun, 11 Oct 2026 02:44:23 +0200 Subject: [PATCH 13/18] refactor(rust): review cleanups across core and the UniFFI layer - Unlock reports a failed password unwrap as WrongPassword, the same as verify_password; it used to surface as KeyWrap(UnwrapFailed). Kotlin already mapped both to "incorrect password", so the app behaves the same. FakeSession follows. - Salts come from aead's Generate like keys and nonces, through one kdf::new_salt and SALT_LEN. crypto/random.rs and the direct rand dependency are gone. - The CSV TOTP check decodes with totp-rs's Secret::try_from_base32, which calls the same base32 decoder, so the base32 dependency is gone. - passkey-authenticator no longer enables "testable", which only pulled mockall into the shipped library. - encryption::open takes the ciphertext by value, saving a full copy of every imported JSON backup. - Registration and CreationInfo cross the FFI as remote records instead of field-for-field copies. Kotlin: RegistrationResponse -> Registration, PasskeyInformation -> CreationInfo. - Drop the never-constructed InvalidKey variants from KeyWrapError and ItemCryptoError. - SymmetricKey::as_bytes is a default method; one local macro supplies generate() for the ARK, vault and item keys. - with_core's comment now says the ARK is copied for the call, which is what with_ark does. Co-Authored-By: Claude Opus 5.5 --- .../keygo/rust/passkey/PasskeyManager.kt | 8 +- .../de/davis/keygo/rust/FakePasskeyManager.kt | 12 +- .../davis/keygo/core/security/FakeSession.kt | 2 +- .../create/activity/CreatePasskeyViewModel.kt | 4 +- rust/Cargo.lock | 168 +----------------- rust/crates/keygo-core/Cargo.toml | 4 +- rust/crates/keygo-core/src/ark_session.rs | 31 ++-- .../keygo-core/src/backup/encryption.rs | 22 +-- .../keygo-core/src/backup/format/json.rs | 2 +- rust/crates/keygo-core/src/crypto/kdf/mod.rs | 9 + rust/crates/keygo-core/src/crypto/key.rs | 8 +- rust/crates/keygo-core/src/crypto/keys.rs | 24 +-- rust/crates/keygo-core/src/crypto/mod.rs | 1 - rust/crates/keygo-core/src/crypto/random.rs | 10 -- rust/crates/keygo-core/src/totp.rs | 2 +- rust/crates/keygo-uniffi/src/backup/mod.rs | 4 +- rust/crates/keygo-uniffi/src/item.rs | 2 - rust/crates/keygo-uniffi/src/key_wrap.rs | 2 - rust/crates/keygo-uniffi/src/passkey.rs | 44 +---- 19 files changed, 73 insertions(+), 286 deletions(-) delete mode 100644 rust/crates/keygo-core/src/crypto/random.rs diff --git a/core/rust/src/main/kotlin/de/davis/keygo/rust/passkey/PasskeyManager.kt b/core/rust/src/main/kotlin/de/davis/keygo/rust/passkey/PasskeyManager.kt index d8fe0e0df..27c673ceb 100644 --- a/core/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/core/rust/src/testFixtures/kotlin/de/davis/keygo/rust/FakePasskeyManager.kt b/core/rust/src/testFixtures/kotlin/de/davis/keygo/rust/FakePasskeyManager.kt index 6f3811e31..ac91e0141 100644 --- a/core/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/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..c549cfcd5 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 @@ -71,7 +71,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 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/rust/Cargo.lock b/rust/Cargo.lock index de2bef08d..06737697a 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -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" @@ -519,12 +510,6 @@ dependencies = [ "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.11.3" @@ -548,12 +533,6 @@ dependencies = [ "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.17.0" @@ -669,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" @@ -687,15 +657,6 @@ 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.2" @@ -953,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" @@ -1006,7 +958,6 @@ dependencies = [ "aes-gcm-siv", "argon2", "async-trait", - "base32", "base64", "bcs", "ciborium", @@ -1017,7 +968,6 @@ dependencies = [ "passkey", "passkey-authenticator", "passkey-types", - "rand", "serde", "serde_json", "sha2", @@ -1041,12 +991,6 @@ dependencies = [ "uuid", ] -[[package]] -name = "lazy_static" -version = "1.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb" - [[package]] name = "libc" version = "0.2.190" @@ -1099,33 +1043,6 @@ dependencies = [ "signature", ] -[[package]] -name = "mockall" -version = "0.11.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c84490118f2ee2d74570d114f3d0493cbf02790df303d2707606c3e14e07c96" -dependencies = [ - "cfg-if", - "downcast", - "fragile", - "lazy_static", - "mockall_derive", - "predicates", - "predicates-tree", -] - -[[package]] -name = "mockall_derive" -version = "0.11.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22ce75669015c4f47b289fd4d4f56e894e4c96003ffdf3ac51313126f94c6cbb" -dependencies = [ - "cfg-if", - "proc-macro2", - "quote", - "syn 1.0.109", -] - [[package]] name = "module-lattice" version = "0.2.3" @@ -1147,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" @@ -1203,7 +1114,6 @@ dependencies = [ "async-trait", "ciborium", "log", - "mockall", "passkey-crypto", "passkey-types", "tokio", @@ -1217,7 +1127,7 @@ checksum = "96a005ba82014459c51985f7e6f4b606a85ea5e8d1c9bbd6bc053e2b2b0c7f4f" dependencies = [ "ciborium", "idna", - "itertools 0.14.0", + "itertools", "passkey-authenticator", "passkey-crypto", "passkey-types", @@ -1348,36 +1258,6 @@ dependencies = [ "zerovec", ] -[[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" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0de1b847b39c8131db0467e9df1ff60e6d0562ab8e9a16e568ad0fdb372e2f2" -dependencies = [ - "predicates-core", - "termtree", -] - [[package]] name = "primefield" version = "0.14.0" @@ -1452,35 +1332,6 @@ 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.6.0" @@ -1766,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" @@ -1823,12 +1663,6 @@ dependencies = [ "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.4" diff --git a/rust/crates/keygo-core/Cargo.toml b/rust/crates/keygo-core/Cargo.toml index 9913cee5b..22e4981a7 100644 --- a/rust/crates/keygo-core/Cargo.toml +++ b/rust/crates/keygo-core/Cargo.toml @@ -14,18 +14,16 @@ async-trait = "0.1.89" ciborium = "0.2.2" coset = "0.4.2" passkey = "0.6.0" -passkey-authenticator = { version = "0.6.0", features = ["tokio", "testable"] } +passkey-authenticator = { version = "0.6.0", features = ["tokio"] } # Needed so passkey JSON responses are serialized into base64 strings passkey-types = { version = "0.6.0", features = ["serialize_bytes_as_base64_string"] } argon2 = "0.6.0" -base32 = "0.5.1" 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" diff --git a/rust/crates/keygo-core/src/ark_session.rs b/rust/crates/keygo-core/src/ark_session.rs index 844b8ee26..11d7744e7 100644 --- a/rust/crates/keygo-core/src/ark_session.rs +++ b/rust/crates/keygo-core/src/ark_session.rs @@ -1,5 +1,4 @@ -use crate::crypto::kdf::argon2::MIN_SALT_LEN; -use crate::crypto::random::random_bytes; +use crate::crypto::kdf::new_salt; use crate::crypto::{ AccountRootKey, CryptoError, RootKEK, Sealed, SymmetricKey, UserId, VaultId, VaultKey, Wraps, }; @@ -21,8 +20,6 @@ pub enum ArkSessionError { type ArkSessionResult = Result; -const SALT_LEN: usize = MIN_SALT_LEN; - /// The ARK wrapped under a key derived from the user's password, plus the salt that derivation /// needs. The user id is the wrap's AAD. pub struct PasswordWrappedArk { @@ -31,8 +28,15 @@ pub struct PasswordWrappedArk { } impl PasswordWrappedArk { + /// A failed unwrap is reported as [`ArkSessionError::WrongPassword`]: the AEAD tag cannot tell + /// a wrong password from a wrong user id or a corrupted blob. fn open(&self, password: &str, user_id: UserId) -> ArkSessionResult { - Ok(derive_kek(password, &self.salt)?.unwrap_key(&self.wrapped, &user_id)?) + derive_kek(password, &self.salt)? + .unwrap_key(&self.wrapped, &user_id) + .map_err(|e| match e { + CryptoError::KeyUnwrapFailed => ArkSessionError::WrongPassword, + other => other.into(), + }) } } @@ -68,7 +72,7 @@ impl ArkSession { let ark = AccountRootKey::generate(); let vault_key = VaultKey::generate(); - let salt = new_salt(); + let salt = new_salt().to_vec(); let password_wrapped_ark = PasswordWrappedArk { wrapped: derive_kek(password, &salt)?.wrap_key(&ark, &user_id)?, salt, @@ -114,10 +118,7 @@ impl ArkSession { wrapped: &PasswordWrappedArk, user_id: UserId, ) -> ArkSessionResult<()> { - let stored = match wrapped.open(password, user_id) { - Err(ArkSessionError::KeyWrap(_)) => return Err(ArkSessionError::WrongPassword), - other => other?, - }; + let stored = wrapped.open(password, user_id)?; self.with_live_ark(|live| { if bool::from(live.as_bytes().ct_eq(stored.as_bytes())) { @@ -138,7 +139,7 @@ impl ArkSession { user_id: UserId, ) -> ArkSessionResult { // Derive before taking the lock: Argon2 is slow, and nothing else may wait on it. - let salt = new_salt(); + 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 }) @@ -181,10 +182,6 @@ impl ArkSession { } } -fn new_salt() -> Vec { - random_bytes::().to_vec() -} - fn derive_kek(password: &str, salt: &[u8]) -> ArkSessionResult { RootKEK::from_password(password.as_bytes(), salt).map_err(ArkSessionError::Derivation) } @@ -238,7 +235,7 @@ mod tests { 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()); } @@ -252,7 +249,7 @@ mod tests { 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()); } diff --git a/rust/crates/keygo-core/src/backup/encryption.rs b/rust/crates/keygo-core/src/backup/encryption.rs index e97940473..70d558451 100644 --- a/rust/crates/keygo-core/src/backup/encryption.rs +++ b/rust/crates/keygo-core/src/backup/encryption.rs @@ -2,7 +2,7 @@ use crate::b64; use crate::backup::BackupError; use crate::backup::BackupKey; use crate::crypto::kdf::argon2::Argon2Params; -use crate::crypto::random::random_bytes; +use crate::crypto::kdf::new_salt; use crate::crypto::{AccountRootKey, Encrypts, Sealed}; use serde::{Deserialize, Serialize}; @@ -79,7 +79,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(); @@ -105,13 +105,13 @@ pub fn seal( pub fn open( header: &EncryptionHeader, - ciphertext: &[u8], + ciphertext: Vec, cred: BackupCredential<'_>, version: u32, ) -> Result, BackupError> { // Checked before deriving the key, which may cost a full Argon2 run. - let sealed = Sealed::from_parts(&header.nonce, ciphertext.to_vec()) - .map_err(|_| BackupError::MalformedHeader)?; + 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. @@ -226,7 +226,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"); } @@ -235,7 +235,7 @@ 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)); } @@ -252,7 +252,7 @@ mod tests { 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"); } @@ -263,7 +263,7 @@ mod tests { 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)); } @@ -272,7 +272,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)); } @@ -286,7 +286,7 @@ mod tests { 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(); + let err = open(&sealed.header, sealed.ciphertext, cred, CURRENT_VERSION).unwrap_err(); assert!(matches!( err, BackupError::Crypto(CryptoError::Argon2MemoryTooHigh { .. }) diff --git a/rust/crates/keygo-core/src/backup/format/json.rs b/rust/crates/keygo-core/src/backup/format/json.rs index 57e0a2c70..36a8ba08a 100644 --- a/rust/crates/keygo-core/src/backup/format/json.rs +++ b/rust/crates/keygo-core/src/backup/format/json.rs @@ -26,7 +26,7 @@ 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)?) } diff --git a/rust/crates/keygo-core/src/crypto/kdf/mod.rs b/rust/crates/keygo-core/src/crypto/kdf/mod.rs index 05050ff9a..f8d138c57 100644 --- a/rust/crates/keygo-core/src/crypto/kdf/mod.rs +++ b/rust/crates/keygo-core/src/crypto/kdf/mod.rs @@ -1,2 +1,11 @@ pub(crate) mod argon2; pub(crate) mod hkdf; + +use aead::Generate; + +/// Length of every salt this crate generates, for either KDF. +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 index 2bcc58bf7..b929ca537 100644 --- a/rust/crates/keygo-core/src/crypto/key.rs +++ b/rust/crates/keygo-core/src/crypto/key.rs @@ -8,7 +8,9 @@ use zeroize::{ZeroizeOnDrop, Zeroizing}; pub trait SymmetricKey: Sized + ZeroizeOnDrop { fn from_bytes(bytes: &[u8]) -> CryptoResult; - fn as_bytes(&self) -> &[u8]; + fn as_bytes(&self) -> &[u8] { + self.cipher_key().as_slice() + } #[doc(hidden)] fn cipher_key(&self) -> &CipherKey; @@ -66,10 +68,6 @@ macro_rules! symmetric_key { }) } - fn as_bytes(&self) -> &[u8] { - self.0.as_slice() - } - fn cipher_key(&self) -> &$crate::crypto::CipherKey { &self.0 } diff --git a/rust/crates/keygo-core/src/crypto/keys.rs b/rust/crates/keygo-core/src/crypto/keys.rs index 2ae6547a9..46357a362 100644 --- a/rust/crates/keygo-core/src/crypto/keys.rs +++ b/rust/crates/keygo-core/src/crypto/keys.rs @@ -66,23 +66,17 @@ impl RootKEK { } } -impl AccountRootKey { - pub fn generate() -> Self { - Self(CipherKey::generate()) - } -} - -impl VaultKey { - pub fn generate() -> Self { - Self(CipherKey::generate()) - } +macro_rules! impl_generate { + ($($key:ident),+) => { + $(impl $key { + pub fn generate() -> Self { + Self(CipherKey::generate()) + } + })+ + }; } -impl ItemKey { - pub fn generate() -> Self { - Self(CipherKey::generate()) - } -} +impl_generate!(AccountRootKey, VaultKey, ItemKey); #[cfg(test)] mod tests { diff --git a/rust/crates/keygo-core/src/crypto/mod.rs b/rust/crates/keygo-core/src/crypto/mod.rs index 67355a336..745c50f75 100644 --- a/rust/crates/keygo-core/src/crypto/mod.rs +++ b/rust/crates/keygo-core/src/crypto/mod.rs @@ -4,7 +4,6 @@ mod ids; pub(crate) mod kdf; mod key; mod keys; -pub(crate) mod random; pub use cipher::{Cipher, CipherKey, KEY_LEN, NONCE_LEN, Sealed}; pub use error::{CryptoError, CryptoResult}; diff --git a/rust/crates/keygo-core/src/crypto/random.rs b/rust/crates/keygo-core/src/crypto/random.rs deleted file mode 100644 index 9cd0f6146..000000000 --- a/rust/crates/keygo-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(crate) fn random_bytes() -> [u8; N] { - let mut buf = [0u8; N]; - UnwrapErr(&mut SysRng).fill_bytes(&mut buf); - buf -} diff --git a/rust/crates/keygo-core/src/totp.rs b/rust/crates/keygo-core/src/totp.rs index dde30f31d..1586c3729 100644 --- a/rust/crates/keygo-core/src/totp.rs +++ b/rust/crates/keygo-core/src/totp.rs @@ -69,7 +69,7 @@ pub fn parse_uri(uri: &str) -> Result { } pub(crate) fn is_valid_totp_secret(s: &str) -> bool { - base32::decode(base32::Alphabet::Rfc4648 { padding: false }, s).is_some() + Secret::try_from_base32(s).is_ok() } fn build( diff --git a/rust/crates/keygo-uniffi/src/backup/mod.rs b/rust/crates/keygo-uniffi/src/backup/mod.rs index 959f8ace7..41155bc7f 100644 --- a/rust/crates/keygo-uniffi/src/backup/mod.rs +++ b/rust/crates/keygo-uniffi/src/backup/mod.rs @@ -19,8 +19,8 @@ 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. + /// Run `f` with the core credential. An ARK credential hands `f` a copy of the session's live + /// ARK that is dropped, and zeroized, when the call returns. fn with_core( &self, f: impl FnOnce(CoreCredential<'_>) -> Result, diff --git a/rust/crates/keygo-uniffi/src/item.rs b/rust/crates/keygo-uniffi/src/item.rs index 0128b66e7..01e07da8d 100644 --- a/rust/crates/keygo-uniffi/src/item.rs +++ b/rust/crates/keygo-uniffi/src/item.rs @@ -32,8 +32,6 @@ pub enum ItemCryptoError { 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}")] diff --git a/rust/crates/keygo-uniffi/src/key_wrap.rs b/rust/crates/keygo-uniffi/src/key_wrap.rs index 05b2cd92b..336613032 100644 --- a/rust/crates/keygo-uniffi/src/key_wrap.rs +++ b/rust/crates/keygo-uniffi/src/key_wrap.rs @@ -26,8 +26,6 @@ pub enum KeyWrapError { 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}")] diff --git a/rust/crates/keygo-uniffi/src/passkey.rs b/rust/crates/keygo-uniffi/src/passkey.rs index 03f62ac3b..b394b22e7 100644 --- a/rust/crates/keygo-uniffi/src/passkey.rs +++ b/rust/crates/keygo-uniffi/src/passkey.rs @@ -32,8 +32,8 @@ impl From for PasskeyError { } } -#[derive(uniffi::Record)] -pub struct RegistrationResponse { +#[uniffi::remote(Record)] +pub struct Registration { pub response: String, pub user_name: String, pub user_display_name: String, @@ -42,34 +42,12 @@ pub struct RegistrationResponse { pub rp: String, } -impl From for RegistrationResponse { - fn from(value: Registration) -> 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 { +#[uniffi::remote(Record)] +pub struct CreationInfo { pub exclude_credentials: Vec>, pub rp: String, } -impl From for PasskeyInformation { - fn from(value: CreationInfo) -> Self { - Self { - exclude_credentials: value.exclude_credentials, - rp: value.rp, - } - } -} - #[derive(uniffi::Object)] pub struct RustPasskey; @@ -80,18 +58,12 @@ impl RustPasskey { Arc::new(Self) } - pub async fn register( - &self, - json_request: String, - ) -> Result { - Ok(core::register(&json_request).await?.into()) + 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)?.into()) + pub fn passkey_information(&self, json_request: String) -> Result { + Ok(core::creation_info(&json_request)?) } pub async fn authenticate( From cbbcd8c9bc9b6a45a40c2578f569334ac0d3606f Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Sun, 11 Oct 2026 10:08:57 +0200 Subject: [PATCH 14/18] refactor(rust): trim comments to the ones that aid reading Drop comments that restate names or narrate tests, and shorten the rest to a line or two. Format freezes, locking contracts, and other non-obvious invariants stay. Co-Authored-By: Claude Opus 5.5 --- rust/crates/keygo-core/src/ark_session.rs | 30 +++-------- rust/crates/keygo-core/src/b64.rs | 1 - .../keygo-core/src/backup/encryption.rs | 12 ++--- .../src/backup/format/csv/detect.rs | 43 ++++++--------- .../keygo-core/src/backup/format/csv/mod.rs | 53 ++++--------------- .../keygo-core/src/backup/format/json.rs | 25 ++------- rust/crates/keygo-core/src/backup/mod.rs | 8 +-- rust/crates/keygo-core/src/backup/model.rs | 8 --- rust/crates/keygo-core/src/card/expiration.rs | 24 ++------- rust/crates/keygo-core/src/card/mod.rs | 24 --------- rust/crates/keygo-core/src/card/network.rs | 29 ++-------- rust/crates/keygo-core/src/card/number.rs | 35 +++--------- rust/crates/keygo-core/src/crypto/cipher.rs | 7 +-- .../keygo-core/src/crypto/golden_tests.rs | 5 +- .../keygo-core/src/crypto/kdf/argon2.rs | 13 +---- rust/crates/keygo-core/src/crypto/kdf/hkdf.rs | 6 --- rust/crates/keygo-core/src/crypto/kdf/mod.rs | 1 - rust/crates/keygo-core/src/crypto/key.rs | 6 --- rust/crates/keygo-core/src/crypto/keys.rs | 20 ++----- .../keygo-core/src/passkey/authentication.rs | 1 - .../keygo-core/src/passkey/authenticator.rs | 3 +- rust/crates/keygo-core/src/passkey/codec.rs | 5 +- rust/crates/keygo-core/src/passkey/mod.rs | 4 -- .../keygo-core/src/passkey/registration.rs | 3 -- rust/crates/keygo-core/src/totp.rs | 30 ++--------- rust/crates/keygo-core/src/url.rs | 14 +---- rust/crates/keygo-uniffi/src/ark_session.rs | 1 - rust/crates/keygo-uniffi/src/backup/mod.rs | 3 +- rust/crates/keygo-uniffi/src/totp.rs | 6 +-- 29 files changed, 71 insertions(+), 349 deletions(-) diff --git a/rust/crates/keygo-core/src/ark_session.rs b/rust/crates/keygo-core/src/ark_session.rs index 11d7744e7..d800862df 100644 --- a/rust/crates/keygo-core/src/ark_session.rs +++ b/rust/crates/keygo-core/src/ark_session.rs @@ -20,16 +20,13 @@ pub enum ArkSessionError { type ArkSessionResult = Result; -/// The ARK wrapped under a key derived from the user's password, plus the salt that derivation -/// needs. The user id is the wrap's AAD. pub struct PasswordWrappedArk { pub salt: Vec, pub wrapped: Sealed, } impl PasswordWrappedArk { - /// A failed unwrap is reported as [`ArkSessionError::WrongPassword`]: the AEAD tag cannot tell - /// a wrong password from a wrong user id or a corrupted blob. + /// 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) @@ -47,7 +44,6 @@ pub struct NewAccount { pub wrapped_vault_key: Sealed, } -/// Holds the ARK while the vault is unlocked. #[derive(Default)] pub struct ArkSession { ark: Mutex>, @@ -110,8 +106,7 @@ impl ArkSession { self.with_ark(|ark| Zeroizing::new(ark.as_bytes().to_vec())) } - /// Succeeds only if `password` opens `wrapped` and what it opens is the ARK this session - /// holds, so a rewrap after this cannot put a new password around a different account's key. + /// Fails unless `wrapped` opens to the ARK this session already holds. pub fn verify_password( &self, password: &str, @@ -138,7 +133,7 @@ impl ArkSession { new_password: &str, user_id: UserId, ) -> ArkSessionResult { - // Derive before taking the lock: Argon2 is slow, and nothing else may wait on it. + // 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)?))?; @@ -161,15 +156,13 @@ impl ArkSession { self.with_live_ark(|ark| Ok(ark.unwrap_key(wrapped, &vault_id)?)) } - /// Runs `f` on a copy of the live ARK, without holding the session lock, so `f` may call back - /// into the session and keeps a usable key even if the session ends meanwhile. + /// 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` on the live ARK while holding the session lock. `f` must not call back into the - /// session. + /// Runs `f` under the lock; `f` must not re-enter the session. fn with_live_ark( &self, f: impl FnOnce(&AccountRootKey) -> ArkSessionResult, @@ -244,8 +237,6 @@ 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.password_wrapped_ark, UserId::new_v4()); @@ -261,8 +252,6 @@ mod tests { 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); @@ -276,7 +265,6 @@ 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(), account.vault_id) .unwrap(); @@ -327,8 +315,6 @@ 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!( @@ -383,7 +369,6 @@ mod tests { .unlock_with_password("new-password", &rewrapped, account.user_id) .unwrap(); - // Same ARK, only the wrapping changed. assert_eq!(ark_before, session.export_ark().unwrap()); } @@ -415,8 +400,7 @@ mod tests { 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(); @@ -434,8 +418,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/crates/keygo-core/src/b64.rs b/rust/crates/keygo-core/src/b64.rs index 4eb2124cd..ae0ea696a 100644 --- a/rust/crates/keygo-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/crates/keygo-core/src/backup/encryption.rs b/rust/crates/keygo-core/src/backup/encryption.rs index 70d558451..241f81ba8 100644 --- a/rust/crates/keygo-core/src/backup/encryption.rs +++ b/rust/crates/keygo-core/src/backup/encryption.rs @@ -52,7 +52,7 @@ impl Kdf { } } -/// Evolve it only via a version bump, never by editing fields in place. +/// Wire format: change only with a version bump. #[derive(Serialize)] pub struct BackupAad { pub version: u32, @@ -109,18 +109,16 @@ pub fn open( cred: BackupCredential<'_>, version: u32, ) -> Result, BackupError> { - // Checked before deriving the key, which may cost a full Argon2 run. + // 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 { @@ -143,7 +141,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); @@ -261,7 +259,6 @@ mod tests { 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(); assert!(matches!(err, BackupError::MalformedHeader)); @@ -280,9 +277,6 @@ 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; } diff --git a/rust/crates/keygo-core/src/backup/format/csv/detect.rs b/rust/crates/keygo-core/src/backup/format/csv/detect.rs index e3086b4fe..3123fc2d3 100644 --- a/rust/crates/keygo-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], @@ -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/crates/keygo-core/src/backup/format/csv/mod.rs b/rust/crates/keygo-core/src/backup/format/csv/mod.rs index 5a3c7a011..91541e701 100644 --- a/rust/crates/keygo-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; @@ -12,7 +5,6 @@ use csv::StringRecord; use self::detect::{build_mapping, build_reader, strip_bom}; use crate::backup::{Backup, BackupError, Login, Vault}; -/// Which column (by index) holds each field. #[derive(Debug, Default, PartialEq, Eq)] pub struct ColumnMapping { pub title: Option, @@ -91,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()), @@ -133,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, } @@ -164,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() { @@ -192,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) @@ -324,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!( @@ -335,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)); @@ -397,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), @@ -431,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), @@ -443,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] @@ -458,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] @@ -505,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,,,,,"); } @@ -524,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")); } @@ -556,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,,,,"); } @@ -595,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]; @@ -605,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(), @@ -621,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/crates/keygo-core/src/backup/format/json.rs b/rust/crates/keygo-core/src/backup/format/json.rs index 36a8ba08a..5d5e4de3f 100644 --- a/rust/crates/keygo-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, @@ -30,7 +28,6 @@ pub fn import(data: &str, cred: BackupCredential<'_>) -> Result Result { Ok(parse_envelope(data)?.encryption.source) } @@ -88,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"}"#; @@ -102,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]; @@ -156,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), @@ -168,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), @@ -180,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), @@ -228,9 +214,8 @@ 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"}"#; @@ -324,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, diff --git a/rust/crates/keygo-core/src/backup/mod.rs b/rust/crates/keygo-core/src/backup/mod.rs index cea814172..2bd02fc64 100644 --- a/rust/crates/keygo-core/src/backup/mod.rs +++ b/rust/crates/keygo-core/src/backup/mod.rs @@ -13,13 +13,7 @@ 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. +/// 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/crates/keygo-core/src/backup/model.rs b/rust/crates/keygo-core/src/backup/model.rs index 00a65e8df..91da0c871 100644 --- a/rust/crates/keygo-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/crates/keygo-core/src/card/expiration.rs b/rust/crates/keygo-core/src/card/expiration.rs index 433524018..a38341b66 100644 --- a/rust/crates/keygo-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 index 2a80b08c1..95036fa22 100644 --- a/rust/crates/keygo-core/src/card/mod.rs +++ b/rust/crates/keygo-core/src/card/mod.rs @@ -1,23 +1,3 @@ -//! 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; @@ -26,12 +6,8 @@ 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/crates/keygo-core/src/card/network.rs b/rust/crates/keygo-core/src/card/network.rs index e3244ed32..e7087402e 100644 --- a/rust/crates/keygo-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/crates/keygo-core/src/card/number.rs b/rust/crates/keygo-core/src/card/number.rs index 654cd847d..b6bd07d91 100644 --- a/rust/crates/keygo-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/cipher.rs b/rust/crates/keygo-core/src/crypto/cipher.rs index 4e358e662..1fc468773 100644 --- a/rust/crates/keygo-core/src/crypto/cipher.rs +++ b/rust/crates/keygo-core/src/crypto/cipher.rs @@ -5,17 +5,12 @@ use aes_gcm_siv::Aes256GcmSiv; use serde::Serialize; use std::marker::PhantomData; -/// The AEAD behind every key in the hierarchy. 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; -/// A ciphertext and the nonce it was sealed under. -/// -/// `T` names what is inside: a key type for a wrapped key, `[u8]` for data. A wrapped vault key -/// is a `Sealed` and cannot be handed to code that unwraps a `Sealed`. pub struct Sealed { nonce: Nonce, ciphertext: Vec, @@ -76,7 +71,7 @@ impl Sealed { } } -/// AADs are BCS-encoded. The encoding is part of every stored ciphertext's format. +/// AADs are BCS-encoded; the encoding is part of the stored format. pub(crate) fn encode_aad(aad: &(impl Serialize + ?Sized)) -> CryptoResult> { Ok(bcs::to_bytes(aad)?) } diff --git a/rust/crates/keygo-core/src/crypto/golden_tests.rs b/rust/crates/keygo-core/src/crypto/golden_tests.rs index 235dd44b9..8d365b39d 100644 --- a/rust/crates/keygo-core/src/crypto/golden_tests.rs +++ b/rust/crates/keygo-core/src/crypto/golden_tests.rs @@ -1,7 +1,4 @@ -//! Ciphertexts produced when the key hierarchy shipped, under fixed keys and ids. Every vault in -//! the field holds blobs of exactly this shape, so each one must keep opening: a change to the -//! cipher, the nonce layout, or the BCS encoding of an AAD fails here instead of on a device. -//! Regenerate only alongside a deliberate, migrated format change. +//! Shipped ciphertexts under fixed keys. They must keep opening; regenerate only with a migration. use crate::crypto::{ AccountRootKey, Encrypts, ItemAad, ItemKey, RootKEK, Sealed, SymmetricKey, VaultKey, Wraps, diff --git a/rust/crates/keygo-core/src/crypto/kdf/argon2.rs b/rust/crates/keygo-core/src/crypto/kdf/argon2.rs index ec430b73f..300aa1b89 100644 --- a/rust/crates/keygo-core/src/crypto/kdf/argon2.rs +++ b/rust/crates/keygo-core/src/crypto/kdf/argon2.rs @@ -9,9 +9,7 @@ pub(crate) const MAX_ARGON2_MEM_KIB: u32 = 256 * 1024; pub(crate) struct Argon2Params { /// Memory cost in KiB pub mem_kib: u32, - /// Iterations / time cost pub iters: u32, - /// Degree of parallelism pub lanes: u32, } @@ -39,15 +37,8 @@ impl Argon2Params { } } -/// Derive a key from a password with Argon2id. -/// -/// The salt must be at least `MIN_SALT_LEN` bytes and persisted with the credential, so the same -/// key can be derived again later. `domain` is appended to the salt as a label, separating -/// otherwise identical derivations (e.g. the password KEK from a backup key). The costs are explicit -/// so a derivation can be reproduced from costs recorded when it was first made; a header asking -/// for more than `MAX_ARGON2_MEM_KIB` is rejected rather than allowed to exhaust memory. -/// -/// The password is not zeroized here: the caller owns that buffer. +/// Argon2id. `domain` is appended to the salt to separate derivations. +/// Rejects costs above `MAX_ARGON2_MEM_KIB`; the caller zeroizes the password. pub(crate) fn derive_argon2id( password: &[u8], salt: &[u8], diff --git a/rust/crates/keygo-core/src/crypto/kdf/hkdf.rs b/rust/crates/keygo-core/src/crypto/kdf/hkdf.rs index a05d69b1f..968ba2851 100644 --- a/rust/crates/keygo-core/src/crypto/kdf/hkdf.rs +++ b/rust/crates/keygo-core/src/crypto/kdf/hkdf.rs @@ -3,12 +3,6 @@ use hkdf::Hkdf; use sha2::Sha256; use zeroize::Zeroizing; -/// Derive a key from high-entropy keying material with 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 extract salt (a fresh per-use random value); `info` is the -/// expand label that domain-separates otherwise identical derivations. 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) diff --git a/rust/crates/keygo-core/src/crypto/kdf/mod.rs b/rust/crates/keygo-core/src/crypto/kdf/mod.rs index f8d138c57..14b02cc1b 100644 --- a/rust/crates/keygo-core/src/crypto/kdf/mod.rs +++ b/rust/crates/keygo-core/src/crypto/kdf/mod.rs @@ -3,7 +3,6 @@ pub(crate) mod hkdf; use aead::Generate; -/// Length of every salt this crate generates, for either KDF. pub(crate) const SALT_LEN: usize = 16; pub(crate) fn new_salt() -> [u8; SALT_LEN] { diff --git a/rust/crates/keygo-core/src/crypto/key.rs b/rust/crates/keygo-core/src/crypto/key.rs index b929ca537..a8b0cd731 100644 --- a/rust/crates/keygo-core/src/crypto/key.rs +++ b/rust/crates/keygo-core/src/crypto/key.rs @@ -3,8 +3,6 @@ use crate::crypto::{CipherKey, CryptoError, CryptoResult, Sealed}; use serde::Serialize; use zeroize::{ZeroizeOnDrop, Zeroizing}; -/// A key for the hierarchy's [`Cipher`](crate::crypto::Cipher). Declare one with -/// [`symmetric_key!`]. pub trait SymmetricKey: Sized + ZeroizeOnDrop { fn from_bytes(bytes: &[u8]) -> CryptoResult; @@ -16,8 +14,6 @@ pub trait SymmetricKey: Sized + ZeroizeOnDrop { fn cipher_key(&self) -> &CipherKey; } -/// `Self` wraps keys of type `K`. Each wrap is bound to an `Aad`, so it only unwraps in the -/// context it was made for. pub trait Wraps: SymmetricKey { type Aad: Serialize + ?Sized; @@ -35,7 +31,6 @@ pub trait Wraps: SymmetricKey { } } -/// `Self` encrypts data. Each ciphertext is bound to an `Aad`. pub trait Encrypts: SymmetricKey { type Aad: Serialize + ?Sized; @@ -51,7 +46,6 @@ pub trait Encrypts: SymmetricKey { } } -/// Declares a zeroize-on-drop [`SymmetricKey`] newtype. macro_rules! symmetric_key { ($(#[$meta:meta])* $vis:vis struct $name:ident;) => { $(#[$meta])* diff --git a/rust/crates/keygo-core/src/crypto/keys.rs b/rust/crates/keygo-core/src/crypto/keys.rs index 46357a362..24f97f6b7 100644 --- a/rust/crates/keygo-core/src/crypto/keys.rs +++ b/rust/crates/keygo-core/src/crypto/keys.rs @@ -1,4 +1,4 @@ -//! The key hierarchy, and what each wrap is bound to: +//! Key hierarchy and the AAD of each wrap: //! //! ```text //! RootKEK --UserId--> AccountRootKey --VaultId--> VaultKey --ItemAad--> ItemKey --[u8]--> item data @@ -11,32 +11,25 @@ use crate::crypto::{ use aead::Generate; use serde::Serialize; -/// Mixed into the password KEK's salt. Every account's ARK is wrapped under a KEK derived with -/// it, so it can never change. +/// Part of every password KEK's salt; must never change. const PASSWORD_DOMAIN: &[u8] = b"v1:kek/pwd"; symmetric_key! { - /// Root key-encryption key: wraps the [`AccountRootKey`]. Derived from the user's password on - /// every unlock and never stored. pub struct RootKEK; } symmetric_key! { - /// One per account. Held by the session while unlocked. pub struct AccountRootKey; } symmetric_key! { - /// One per vault. pub struct VaultKey; } symmetric_key! { - /// One per item. Moving an item between vaults rewraps this key, not the item's data. pub struct ItemKey; } -/// Binds a wrapped [`ItemKey`] to its item and vault, so it cannot be transplanted. #[derive(Serialize)] pub struct ItemAad { pub item_id: ItemId, @@ -44,11 +37,11 @@ pub struct ItemAad { } impl Wraps for RootKEK { - type Aad = UserId; // TODO: also include epoch: pw wrapped: pwd_cred_epoch, rk wrapped: rk_cred_epoch + type Aad = UserId; // TODO: bind the credential epoch } impl Wraps for AccountRootKey { - type Aad = VaultId; //TODO: also include epoch: vault_key_epoch + type Aad = VaultId; // TODO: bind vault_key_epoch } impl Wraps for VaultKey { @@ -98,10 +91,7 @@ mod tests { assert_ne!(a.as_bytes(), b.as_bytes()); } - /// 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. + /// 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"); diff --git a/rust/crates/keygo-core/src/passkey/authentication.rs b/rust/crates/keygo-core/src/passkey/authentication.rs index feac301b7..d3993af55 100644 --- a/rust/crates/keygo-core/src/passkey/authentication.rs +++ b/rust/crates/keygo-core/src/passkey/authentication.rs @@ -3,7 +3,6 @@ use crate::passkey::{PasskeyError, codec, origin, parse_request, to_json}; use passkey::client::Client; use passkey::types::webauthn::{CredentialRequestOptions, PublicKeyCredentialRequestOptions}; -/// Answer a WebAuthn assertion request with a stored passkey, returning the response JSON. pub async fn authenticate( request_json: &str, stored_passkey: &[u8], diff --git a/rust/crates/keygo-core/src/passkey/authenticator.rs b/rust/crates/keygo-core/src/passkey/authenticator.rs index 2675e3191..44fbbbea8 100644 --- a/rust/crates/keygo-core/src/passkey/authenticator.rs +++ b/rust/crates/keygo-core/src/passkey/authenticator.rs @@ -4,8 +4,7 @@ use passkey::types::Passkey; use passkey::types::ctap2::{Aaguid, Ctap2Error}; use passkey_authenticator::UiHint; -/// Reports presence and verification as requested: the app has already verified the user by the -/// time a request reaches Rust. +/// Always reports presence and verification: the app verifies the user first. pub(super) struct PreVerifiedUser; #[async_trait::async_trait] diff --git a/rust/crates/keygo-core/src/passkey/codec.rs b/rust/crates/keygo-core/src/passkey/codec.rs index dafb1a9d5..ab94faa05 100644 --- a/rust/crates/keygo-core/src/passkey/codec.rs +++ b/rust/crates/keygo-core/src/passkey/codec.rs @@ -1,5 +1,4 @@ -//! The bytes a passkey is stored as. Shipped passkeys are in this format: the `V1` tag and the -//! field names of [`StoredPasskey`] must not change. +//! Stored passkey format. The `V1` tag and [`StoredPasskey`] field names must not change. use coset::{CborSerializable, CoseError, CoseKey}; use passkey::types::Passkey; @@ -83,8 +82,6 @@ mod tests { } } - /// `sample_passkey` as encoded when the format shipped. Every stored passkey is in this - /// shape, so the decoder must keep reading it: the `V1` tag and the field names are the format. const GOLDEN_V1: &str = "oWJWMadja2V5hRiiAQEDGCdtY3JlZGVudGlhbF9pZIQBAgMEZXJwX2lka2V4YW1wbGUuY29ta3VzZXJfaGFuZGxlgwkJCWh1c2VybmFtZWVhbGljZXF1c2VyX2Rpc3BsYXlfbmFtZWVBbGljZWdjb3VudGVyGCo="; #[test] diff --git a/rust/crates/keygo-core/src/passkey/mod.rs b/rust/crates/keygo-core/src/passkey/mod.rs index b74afdafe..d3b85ce04 100644 --- a/rust/crates/keygo-core/src/passkey/mod.rs +++ b/rust/crates/keygo-core/src/passkey/mod.rs @@ -1,6 +1,3 @@ -//! WebAuthn registration and authentication, backed by the `passkey` crate's software -//! authenticator. The user is verified on the Kotlin side before either call is made. - mod authentication; mod authenticator; mod codec; @@ -35,7 +32,6 @@ fn parse_request(json: &str) -> Result) -> Result { sanitize_to_https_url(rp_id.unwrap_or_default()).map_err(|_| PasskeyError::InvalidDomain) } diff --git a/rust/crates/keygo-core/src/passkey/registration.rs b/rust/crates/keygo-core/src/passkey/registration.rs index b69977285..3a5c8f065 100644 --- a/rust/crates/keygo-core/src/passkey/registration.rs +++ b/rust/crates/keygo-core/src/passkey/registration.rs @@ -3,18 +3,15 @@ use crate::passkey::{PasskeyError, codec, origin, parse_request, to_json}; use passkey::client::{Client, DefaultClientData}; use passkey::types::webauthn::{CredentialCreationOptions, PublicKeyCredentialCreationOptions}; -/// A newly created passkey: the response JSON for the relying party, and what to store. pub struct Registration { pub response: String, pub user_name: String, pub user_display_name: String, pub credential_id: Vec, - /// The passkey in its stored encoding. pub private_key: Vec, pub rp: String, } -/// What the app needs from a creation request before asking the user to confirm it. pub struct CreationInfo { pub exclude_credentials: Vec>, pub rp: String, diff --git a/rust/crates/keygo-core/src/totp.rs b/rust/crates/keygo-core/src/totp.rs index 1586c3729..774888031 100644 --- a/rust/crates/keygo-core/src/totp.rs +++ b/rust/crates/keygo-core/src/totp.rs @@ -2,10 +2,7 @@ use thiserror::Error; pub use totp_rs::Algorithm; 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)] @@ -16,7 +13,6 @@ pub enum TotpError { Secret(SecretParseError), } -/// How codes are generated from a secret. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct TotpParams { pub algorithm: Algorithm, @@ -26,21 +22,18 @@ pub struct TotpParams { } pub struct TotpInfo { - /// Base32, as it appears in an `otpauth://` URI. pub secret: String, pub issuer: Option, pub account_name: String, pub params: TotpParams, } -/// The current code for a base32 `secret`. pub fn generate(params: TotpParams, secret: &str) -> Result { Ok(build(params, secret, None, "")? .generate_current() .to_string()) } -/// An `otpauth://` URI for a base32 `secret`. pub fn to_uri( params: TotpParams, secret: &str, @@ -93,15 +86,8 @@ fn build( Ok(totp) } -/// 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. +/// 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 { @@ -126,9 +112,7 @@ fn validate(totp: &Totp) -> Result<(), TotpError> { 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 { @@ -215,9 +199,6 @@ mod tests { ); } - /// 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"); @@ -225,9 +206,6 @@ mod tests { 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 = to_uri(SHA1_6_30, SHORT_SECRET, Some("GitHub"), ""); @@ -235,8 +213,6 @@ mod tests { 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] = [ diff --git a/rust/crates/keygo-core/src/url.rs b/rust/crates/keygo-core/src/url.rs index ff4f6dbce..471018634 100644 --- a/rust/crates/keygo-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/src/ark_session.rs b/rust/crates/keygo-uniffi/src/ark_session.rs index 0fd877996..17b7a0c71 100644 --- a/rust/crates/keygo-uniffi/src/ark_session.rs +++ b/rust/crates/keygo-uniffi/src/ark_session.rs @@ -87,7 +87,6 @@ pub struct ArkCredential { } impl ArkCredential { - /// The session this credential borrows its key from. pub(crate) fn session(&self) -> &CoreArkSession { &self.ark_session.session } diff --git a/rust/crates/keygo-uniffi/src/backup/mod.rs b/rust/crates/keygo-uniffi/src/backup/mod.rs index 41155bc7f..e4d4af495 100644 --- a/rust/crates/keygo-uniffi/src/backup/mod.rs +++ b/rust/crates/keygo-uniffi/src/backup/mod.rs @@ -19,8 +19,7 @@ pub enum BackupCredential { } impl BackupCredential { - /// Run `f` with the core credential. An ARK credential hands `f` a copy of the session's live - /// ARK that is dropped, and zeroized, when the call returns. + /// ARK credentials get a copy of the live ARK, zeroized on return. fn with_core( &self, f: impl FnOnce(CoreCredential<'_>) -> Result, diff --git a/rust/crates/keygo-uniffi/src/totp.rs b/rust/crates/keygo-uniffi/src/totp.rs index 48f328368..93b54d85e 100644 --- a/rust/crates/keygo-uniffi/src/totp.rs +++ b/rust/crates/keygo-uniffi/src/totp.rs @@ -23,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), @@ -77,7 +74,6 @@ impl From for TotpError { } } -/// Kotlin hands these over as `Int`; anything negative or out of range is rejected here. fn params(algorithm: Algorithm, digits: i32, period: i32) -> Result { Ok(TotpParams { algorithm: algorithm.into(), From 5ea8ff565ce152df4d93521ac1a221a4ca923eef Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Sun, 11 Oct 2026 12:59:46 +0200 Subject: [PATCH 15/18] refactor(rust): type every AAD and close the raw-bytes escape hatch - Item data is encrypted under ItemDataAad { item_id, kind: SecretKind } instead of bytes built by hand in Kotlin. Rust now owns the pinned labels, and the custom Serialize keeps the 2.1.0 layout (item_id || label) byte for byte. A new golden vector, sealed by the old raw-bytes path, proves it and replaces the synthetic b"item-aad" one. - Kotlin: SecretBlueprint.label: String -> kind: SecretKind, a domain enum in core:item. core:security maps it to the UniFFI enum with an exhaustive when; the health report uses PasswordHealthReport. - UserId, VaultId and ItemId are newtypes, so swapping them in a wrap or an ItemAad no longer compiles. BCS encodes them as before, and Kotlin still sees java.util.UUID. - Wraps/Encrypts require an Aad, declared through aad! or an explicit impl, so [u8] and bare Uuids are no longer accepted. Co-Authored-By: Claude Opus 5.5 --- .../core/item/domain/model/CreditCard.kt | 4 +- .../keygo/core/item/domain/model/Passkey.kt | 2 +- .../core/item/domain/model/PasswordSecret.kt | 2 +- .../keygo/core/item/domain/model/Secret.kt | 11 +- .../keygo/core/item/domain/model/Totp.kt | 2 +- .../core/item/domain/model/SecretFieldTest.kt | 2 +- .../de/davis/keygo/rust/FakeItemManager.kt | 13 +-- .../data/crypto/CryptographicScopeImpl.kt | 39 ++++--- .../domain/crypto/CryptographicScope.kt | 5 +- .../core/security/domain/crypto/SecretExt.kt | 4 +- .../crypto/CryptographicScopeImplTest.kt | 65 +++++------ .../BindingCryptographicScopeProvider.kt | 2 +- .../crypto/FakeCryptographicScopeProvider.kt | 13 +-- .../CreateNewOrUpdateCreditCardUseCaseTest.kt | 10 +- .../CreateNewOrUpdateLoginUseCaseTest.kt | 14 +-- .../HealthReportStoreRepositoryImpl.kt | 6 +- .../HealthReportStoreRepositoryImplTest.kt | 6 +- .../keygo-core/src/backup/encryption.rs | 13 +-- rust/crates/keygo-core/src/crypto/aad.rs | 23 ++++ rust/crates/keygo-core/src/crypto/cipher.rs | 6 -- .../keygo-core/src/crypto/golden_tests.rs | 21 ++-- rust/crates/keygo-core/src/crypto/ids.rs | 19 +++- rust/crates/keygo-core/src/crypto/key.rs | 17 +-- rust/crates/keygo-core/src/crypto/keys.rs | 101 ++++++++++++++++-- rust/crates/keygo-core/src/crypto/mod.rs | 5 +- rust/crates/keygo-uniffi/src/item.rs | 24 ++++- rust/crates/keygo-uniffi/src/types.rs | 20 +++- 27 files changed, 312 insertions(+), 137 deletions(-) create mode 100644 rust/crates/keygo-core/src/crypto/aad.rs 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/src/testFixtures/kotlin/de/davis/keygo/rust/FakeItemManager.kt b/core/rust/src/testFixtures/kotlin/de/davis/keygo/rust/FakeItemManager.kt index 51e8874bf..31ab04a53 100644 --- a/core/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/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..3a24df05f 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 - } +private 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/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/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/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..e5d983619 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 @@ -44,7 +45,7 @@ class CryptographicScopeImplTest { FakeVaultManager(), ) - private val label = "password" + private val kind = SecretKind.Password private suspend fun wrappedVaultKeyInformation( vaultId: UUID = UUID.randomUUID(), @@ -77,7 +78,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 +87,7 @@ class CryptographicScopeImplTest { itemAad = aad, wrappedItemKey = wrapped, ), - ) { cipher.decrypt(label) }.assertSuccess() + ) { cipher.decrypt(kind) }.assertSuccess() assertContentEquals(plaintext, recovered) } @@ -102,8 +103,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 +115,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 +130,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 +140,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 +153,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 +163,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 +177,7 @@ class CryptographicScopeImplTest { wrappedVaultKeyInformation = vaultInfo, wrappedItemKeyInformation = WrappedItemKeyInformation(itemAad = aad), ) { - plaintext.encrypt(label) to wrapCurrentItemKey() + plaintext.encrypt(kind) to wrapCurrentItemKey() }.assertSuccess() provider.itemScope( @@ -185,7 +186,7 @@ class CryptographicScopeImplTest { itemAad = itemAad(vaultInfo.vaultId), wrappedItemKey = wrapped, ), - ) { cipher.decrypt(label) }.assertFailure() + ) { cipher.decrypt(kind) }.assertFailure() } @Test @@ -198,7 +199,7 @@ class CryptographicScopeImplTest { wrappedVaultKeyInformation = vaultInfo, wrappedItemKeyInformation = WrappedItemKeyInformation(itemAad = aad), ) { - plaintext.encrypt(label) to wrapCurrentItemKey() + plaintext.encrypt(kind) to wrapCurrentItemKey() }.assertSuccess() provider.itemScope( @@ -207,7 +208,7 @@ class CryptographicScopeImplTest { itemAad = itemAad(vaultInfo.vaultId), wrappedItemKey = wrapped, ), - ) { cipher.decrypt(label) }.assertFailure() + ) { cipher.decrypt(kind) }.assertFailure() } @Test @@ -233,7 +234,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 +243,7 @@ class CryptographicScopeImplTest { itemAad = aad, wrappedItemKey = wrapped, ), - ) { cipher.decrypt(label) }.assertSuccess() + ) { cipher.decrypt(kind) }.assertSuccess() assertContentEquals(ByteArray(0), recovered) } @@ -258,7 +259,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 +279,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 +289,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 +304,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 +321,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 +339,7 @@ class CryptographicScopeImplTest { itemAad = aad, wrappedItemKey = wrapped, ), - ) { tampered.decrypt(label) } + ) { tampered.decrypt(kind) } } } @@ -354,8 +355,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 +379,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 +388,7 @@ class CryptographicScopeImplTest { itemAad = aad, wrappedItemKey = wrapped, ), - ) { cipher.decrypt(label) }.assertSuccess() + ) { cipher.decrypt(kind) }.assertSuccess() assertContentEquals(plaintext, recovered) } @@ -405,7 +406,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 +415,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/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..2c44fc1b7 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 @@ -12,7 +12,7 @@ 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. */ 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..e765dc93e 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 @@ -21,8 +22,8 @@ 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, @@ -103,18 +104,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/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/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..4cffce087 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 @@ -4,6 +4,7 @@ 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.item.domain.model.SecretKind import de.davis.keygo.core.security.domain.crypto.CryptographicScopeProvider import de.davis.keygo.core.security.domain.crypto.model.CryptographicData import de.davis.keygo.core.util.Result @@ -39,7 +40,7 @@ internal class HealthReportStoreRepositoryImpl( namespace = REPORT_NAMESPACE, wrapped = stored?.toWrappedKey(), ) { - compressedReport.encrypt(REPORT_LABEL) to wrapCurrentItemKey() + compressedReport.encrypt(SecretKind.PasswordHealthReport) to wrapCurrentItemKey() }.bind { HealthReportStoreError.CryptoFailed } dataStore.updateData { @@ -67,7 +68,7 @@ internal class HealthReportStoreRepositoryImpl( CryptographicData( data = stored.ciphertext.toByteArray(), iv = stored.iv.toByteArray(), - ).decrypt(REPORT_LABEL) + ).decrypt(SecretKind.PasswordHealthReport) } catch (_: ItemCryptoException) { null } @@ -88,6 +89,5 @@ internal class HealthReportStoreRepositoryImpl( // doing so makes every previously stored report permanently undecryptable. 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..c9421a126 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 @@ -4,6 +4,7 @@ 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 @@ -179,7 +180,7 @@ class HealthReportStoreRepositoryImplTest { wrapped = stored.wrappedKey(), ) { CryptographicData(stored.ciphertext.toByteArray(), stored.iv.toByteArray()) - .decrypt(REPORT_LABEL) + .decrypt(SecretKind.PasswordHealthReport) } assertEquals(report, (opened as Result.Success).success.toStoredHealthReport()) @@ -261,7 +262,7 @@ class HealthReportStoreRepositoryImplTest { private suspend fun seal(plaintext: ByteArray) { val stored = dataStore.state.value val sealed = provider.accountScope(REPORT_NAMESPACE, stored.wrappedKey()) { - plaintext.encrypt(REPORT_LABEL) + plaintext.encrypt(SecretKind.PasswordHealthReport) }.assertSuccess() dataStore.state.value = stored.toBuilder() @@ -284,6 +285,5 @@ class HealthReportStoreRepositoryImplTest { // Pinned on purpose: changing either makes every stored report undecryptable. val REPORT_NAMESPACE: UUID = UUID.fromString("39050172-ce6d-4f4d-8787-6e12ab44eb0e") - const val REPORT_LABEL = "password_health_report" } } diff --git a/rust/crates/keygo-core/src/backup/encryption.rs b/rust/crates/keygo-core/src/backup/encryption.rs index 241f81ba8..127b85cba 100644 --- a/rust/crates/keygo-core/src/backup/encryption.rs +++ b/rust/crates/keygo-core/src/backup/encryption.rs @@ -3,7 +3,7 @@ use crate::backup::BackupError; use crate::backup::BackupKey; use crate::crypto::kdf::argon2::Argon2Params; use crate::crypto::kdf::new_salt; -use crate::crypto::{AccountRootKey, Encrypts, Sealed}; +use crate::crypto::{AccountRootKey, Encrypts, Sealed, aad}; use serde::{Deserialize, Serialize}; #[derive(Serialize, Deserialize)] @@ -52,11 +52,12 @@ impl Kdf { } } -/// Wire format: change only with a version bump. -#[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 Encrypts for BackupKey { 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 index 1fc468773..7466abfbe 100644 --- a/rust/crates/keygo-core/src/crypto/cipher.rs +++ b/rust/crates/keygo-core/src/crypto/cipher.rs @@ -2,7 +2,6 @@ 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 serde::Serialize; use std::marker::PhantomData; pub type Cipher = Aes256GcmSiv; @@ -71,11 +70,6 @@ impl Sealed { } } -/// AADs are BCS-encoded; the encoding is part of the stored format. -pub(crate) fn encode_aad(aad: &(impl Serialize + ?Sized)) -> CryptoResult> { - Ok(bcs::to_bytes(aad)?) -} - #[cfg(test)] mod tests { use super::*; diff --git a/rust/crates/keygo-core/src/crypto/golden_tests.rs b/rust/crates/keygo-core/src/crypto/golden_tests.rs index 8d365b39d..eda681bdc 100644 --- a/rust/crates/keygo-core/src/crypto/golden_tests.rs +++ b/rust/crates/keygo-core/src/crypto/golden_tests.rs @@ -1,13 +1,14 @@ //! Shipped ciphertexts under fixed keys. They must keep opening; regenerate only with a migration. use crate::crypto::{ - AccountRootKey, Encrypts, ItemAad, ItemKey, RootKEK, Sealed, SymmetricKey, VaultKey, Wraps, + AccountRootKey, Encrypts, ItemAad, ItemDataAad, ItemId, ItemKey, RootKEK, Sealed, SecretKind, + SymmetricKey, UserId, VaultId, VaultKey, Wraps, }; use uuid::Uuid; -const USER_ID: Uuid = Uuid::from_u128(0x1111_1111_1111_4111_8111_1111_1111_1111); -const VAULT_ID: Uuid = Uuid::from_u128(0x2222_2222_2222_4222_8222_2222_2222_2222); -const ITEM_ID: Uuid = Uuid::from_u128(0x3333_3333_3333_4333_8333_3333_3333_3333); +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 KEK: [u8; 32] = [4; 32]; const ARK: [u8; 32] = [1; 32]; @@ -26,11 +27,11 @@ 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) = ( - "t78N42bK5A+RKqzk", - "qD1SvmKQlBXgEE4MzPeJ3ZWJKduWgqkOU+xSRBxlBsY=", + "EPAic05x44CkZoRK", + "OPtIsA8vfGNIVOsCp2wM+wtEQFJkhvA5X4v+js1gKio=", ); -const ITEM_DATA_AAD: &[u8] = b"item-aad"; const ITEM_DATA_PLAINTEXT: &[u8] = b"secret item data"; fn sealed((nonce, ciphertext): (&str, &str)) -> Sealed { @@ -73,6 +74,10 @@ fn item_key_still_unwraps() { #[test] fn item_data_still_decrypts() { let item_key = ItemKey::from_bytes(&ITEM_KEY).unwrap(); - let plaintext = item_key.decrypt(&sealed(ITEM_DATA), ITEM_DATA_AAD).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); } diff --git a/rust/crates/keygo-core/src/crypto/ids.rs b/rust/crates/keygo-core/src/crypto/ids.rs index fe90bfb4e..a33b13a02 100644 --- a/rust/crates/keygo-core/src/crypto/ids.rs +++ b/rust/crates/keygo-core/src/crypto/ids.rs @@ -1,5 +1,18 @@ +use serde::Serialize; use uuid::Uuid; -pub type UserId = Uuid; -pub type VaultId = Uuid; -pub type ItemId = 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); diff --git a/rust/crates/keygo-core/src/crypto/key.rs b/rust/crates/keygo-core/src/crypto/key.rs index a8b0cd731..617e6df8f 100644 --- a/rust/crates/keygo-core/src/crypto/key.rs +++ b/rust/crates/keygo-core/src/crypto/key.rs @@ -1,6 +1,5 @@ -use crate::crypto::cipher::encode_aad; -use crate::crypto::{CipherKey, CryptoError, CryptoResult, Sealed}; -use serde::Serialize; +use crate::crypto::aad::encode_aad; +use crate::crypto::{Aad, CipherKey, CryptoError, CryptoResult, Sealed}; use zeroize::{ZeroizeOnDrop, Zeroizing}; pub trait SymmetricKey: Sized + ZeroizeOnDrop { @@ -15,7 +14,7 @@ pub trait SymmetricKey: Sized + ZeroizeOnDrop { } pub trait Wraps: SymmetricKey { - type Aad: Serialize + ?Sized; + type Aad: Aad; fn wrap_key(&self, key: &K, aad: &Self::Aad) -> CryptoResult> { Sealed::seal(self.cipher_key(), key.as_bytes(), &encode_aad(aad)?) @@ -32,7 +31,7 @@ pub trait Wraps: SymmetricKey { } pub trait Encrypts: SymmetricKey { - type Aad: Serialize + ?Sized; + type Aad: Aad; fn encrypt(&self, plaintext: &[u8], aad: &Self::Aad) -> CryptoResult> { Sealed::seal(self.cipher_key(), plaintext, &encode_aad(aad)?) @@ -73,6 +72,7 @@ pub(crate) use symmetric_key; #[cfg(test)] mod tests { use super::*; + use crate::crypto::aad; symmetric_key! { struct TestWrappingKey; @@ -81,9 +81,10 @@ mod tests { struct TestKey; } - #[derive(Serialize)] - struct TestAad { - scope: &'static str, + aad! { + struct TestAad { + scope: &'static str, + } } impl Wraps for TestWrappingKey { diff --git a/rust/crates/keygo-core/src/crypto/keys.rs b/rust/crates/keygo-core/src/crypto/keys.rs index 24f97f6b7..6ed381e95 100644 --- a/rust/crates/keygo-core/src/crypto/keys.rs +++ b/rust/crates/keygo-core/src/crypto/keys.rs @@ -1,15 +1,16 @@ //! Key hierarchy and the AAD of each wrap: //! //! ```text -//! RootKEK --UserId--> AccountRootKey --VaultId--> VaultKey --ItemAad--> ItemKey --[u8]--> item data +//! RootKEK --UserId--> AccountRootKey --VaultId--> VaultKey --ItemAad--> ItemKey --ItemDataAad--> item data //! ``` use crate::crypto::kdf::argon2::{Argon2Params, derive_argon2id}; use crate::crypto::{ - CipherKey, CryptoResult, Encrypts, ItemId, SymmetricKey, UserId, VaultId, Wraps, symmetric_key, + Aad, CipherKey, CryptoResult, Encrypts, ItemId, SymmetricKey, UserId, VaultId, Wraps, aad, + symmetric_key, }; use aead::Generate; -use serde::Serialize; +use serde::{Serialize, Serializer}; /// Part of every password KEK's salt; must never change. const PASSWORD_DOMAIN: &[u8] = b"v1:kek/pwd"; @@ -30,10 +31,53 @@ symmetric_key! { pub struct ItemKey; } -#[derive(Serialize)] -pub struct ItemAad { +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 vault_id: VaultId, + 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 {} + +#[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 { @@ -49,7 +93,7 @@ impl Wraps for VaultKey { } impl Encrypts for ItemKey { - type Aad = [u8]; + type Aad = ItemDataAad; } impl RootKEK { @@ -107,6 +151,49 @@ mod tests { ); } + /// 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() + ); + } + #[test] fn generated_keys_differ() { assert_ne!( diff --git a/rust/crates/keygo-core/src/crypto/mod.rs b/rust/crates/keygo-core/src/crypto/mod.rs index 745c50f75..834aec29f 100644 --- a/rust/crates/keygo-core/src/crypto/mod.rs +++ b/rust/crates/keygo-core/src/crypto/mod.rs @@ -1,3 +1,4 @@ +pub(crate) mod aad; mod cipher; mod error; mod ids; @@ -5,12 +6,14 @@ 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, UserId, VaultId}; pub(crate) use key::symmetric_key; pub use key::{Encrypts, SymmetricKey, Wraps}; -pub use keys::{AccountRootKey, ItemAad, ItemKey, RootKEK, VaultKey}; +pub use keys::{AccountRootKey, ItemAad, ItemDataAad, ItemKey, RootKEK, SecretKind, VaultKey}; #[cfg(test)] mod golden_tests; diff --git a/rust/crates/keygo-uniffi/src/item.rs b/rust/crates/keygo-uniffi/src/item.rs index 01e07da8d..46796c5f4 100644 --- a/rust/crates/keygo-uniffi/src/item.rs +++ b/rust/crates/keygo-uniffi/src/item.rs @@ -1,4 +1,6 @@ -use keygo_core::crypto::{CryptoError, Encrypts, ItemAad, ItemId, ItemKey, Sealed, VaultId}; +use keygo_core::crypto::{ + CryptoError, Encrypts, ItemAad, ItemDataAad, ItemId, ItemKey, Sealed, SecretKind, VaultId, +}; use std::sync::Arc; #[uniffi::remote(Record)] @@ -7,6 +9,22 @@ pub struct ItemAad { 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, @@ -70,7 +88,7 @@ impl ItemManager { &self, item_key: ItemKey, data: Vec, - aad: Vec, + aad: ItemDataAad, ) -> Result { Ok(item_key.encrypt(&data, &aad)?.into()) } @@ -79,7 +97,7 @@ impl ItemManager { &self, item_key: ItemKey, blob: EncryptedItemBlob, - aad: Vec, + aad: ItemDataAad, ) -> Result, ItemCryptoError> { Ok(item_key.decrypt(&blob.into_sealed()?, &aad)?) } diff --git a/rust/crates/keygo-uniffi/src/types.rs b/rust/crates/keygo-uniffi/src/types.rs index fbee8721d..e571d72ad 100644 --- a/rust/crates/keygo-uniffi/src/types.rs +++ b/rust/crates/keygo-uniffi/src/types.rs @@ -1,4 +1,4 @@ -use keygo_core::crypto::{ItemKey, SymmetricKey, VaultKey}; +use keygo_core::crypto::{ItemId, ItemKey, SymmetricKey, UserId, VaultId, VaultKey}; use uuid::Uuid; uniffi::custom_type!(Uuid, String, { @@ -7,6 +7,24 @@ uniffi::custom_type!(Uuid, String, { 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!(VaultKey, Vec, { remote, try_lift: |bytes| VaultKey::from_bytes(&bytes).map_err(|e| uniffi::deps::anyhow::anyhow!("{e}")), From fa7a4cb7557ab6430532a39f8f1f6c6a91e014cf Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Sun, 11 Oct 2026 21:38:55 +0200 Subject: [PATCH 16/18] fix(rust): refuse to sign a passkey assertion for another relying party passkey-rs 0.6.0's Option store ignores the request's rp_id (upstream TODO), so a passkey registered for example.com signed an assertion for evil.com. authenticate() now compares the request's rp_id with the one sealed inside the stored passkey and fails with RpMismatch, which reaches Kotlin as the existing InvalidDomain. The new test pins the behaviour; once upstream filters by rp_id and the test passes without our check, the check can go. Co-Authored-By: Claude Opus 5.5 --- .../keygo-core/src/passkey/authentication.rs | 45 +++++++++++++++++++ rust/crates/keygo-core/src/passkey/mod.rs | 2 + rust/crates/keygo-uniffi/src/passkey.rs | 2 +- 3 files changed, 48 insertions(+), 1 deletion(-) diff --git a/rust/crates/keygo-core/src/passkey/authentication.rs b/rust/crates/keygo-core/src/passkey/authentication.rs index d3993af55..5355f38f0 100644 --- a/rust/crates/keygo-core/src/passkey/authentication.rs +++ b/rust/crates/keygo-core/src/passkey/authentication.rs @@ -10,6 +10,11 @@ pub async fn authenticate( ) -> 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))); @@ -26,3 +31,43 @@ pub async fn authenticate( 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/crates/keygo-core/src/passkey/mod.rs b/rust/crates/keygo-core/src/passkey/mod.rs index d3b85ce04..88a594664 100644 --- a/rust/crates/keygo-core/src/passkey/mod.rs +++ b/rust/crates/keygo-core/src/passkey/mod.rs @@ -26,6 +26,8 @@ pub enum PasskeyError { 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 { diff --git a/rust/crates/keygo-uniffi/src/passkey.rs b/rust/crates/keygo-uniffi/src/passkey.rs index b394b22e7..8483c93c0 100644 --- a/rust/crates/keygo-uniffi/src/passkey.rs +++ b/rust/crates/keygo-uniffi/src/passkey.rs @@ -19,7 +19,7 @@ impl From for PasskeyError { fn from(value: CorePasskeyError) -> Self { match value { CorePasskeyError::InvalidRequest(_) => Self::InvalidJsonFormat, - CorePasskeyError::InvalidDomain => Self::InvalidDomain, + CorePasskeyError::InvalidDomain | CorePasskeyError::RpMismatch => Self::InvalidDomain, CorePasskeyError::Webauthn(e) => Self::Webauthn { reason: format!("{e:?}"), }, From 7401e068f9bbf21b243d4d8eb98c3d066358cdde Mon Sep 17 00:00:00 2001 From: Davis Date: Mon, 12 Oct 2026 17:12:27 +0200 Subject: [PATCH 17/18] refactor(crypto): seal account records under their own key type accountScope handed a vault key to CryptographicScopeImpl as an item key. That compiled only because UniFFI lowers both to ByteArray, so the password-health report bypassed the key types the Rust refactor added. - Rust: NamespaceKey, wrapped by the ARK under NamespaceKeyAad and encrypting under NamespaceDataAad. Both AADs carry a domain tag (v1:ns/key, v1:ns/data), so a namespace key's wrap can never encode like a vault key's. ArkSession seals and opens records itself, so the key never crosses UniFFI. A new DecryptionFailed error separates a corrupted record from a key that will not unwrap. - Kotlin: AccountRecordSealer (seal/open returning Result) replaces accountScope. HealthReportStoreRepositoryImpl no longer catches ItemCryptoException, and CryptographicScopeProvider loses accountScope and its VaultManager dependency. - Every seal uses a fresh key, so a stored key that no longer unwraps cannot block later writes. Breaking: reports stored by 2.1.0 used the old layout and no longer open. The store is a cache, so a failed load triggers a rescan and the next store overwrites the old report. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 8 ++ .../keygo/core/security/data/SessionImpl.kt | 18 +++ .../data/crypto/AccountRecordSealerImpl.kt | 61 +++++++++ .../data/crypto/CryptographicScopeImpl.kt | 2 +- .../CryptographicScopeProviderFactoryImpl.kt | 3 - .../crypto/CryptographicScopeProviderImpl.kt | 35 +---- .../keygo/core/security/domain/Session.kt | 19 +++ .../core/security/domain/SessionError.kt | 1 + .../domain/crypto/AccountRecordSealer.kt | 29 +++++ .../crypto/CryptographicScopeProvider.kt | 7 - .../crypto/model/SealedAccountRecord.kt | 8 ++ .../domain/model/AccountRecordError.kt | 11 ++ .../crypto/CryptographicScopeImplTest.kt | 2 - .../crypto/AccountRecordSealerImplTest.kt | 92 +++++++++++++ .../CryptographicScopeProviderImplTest.kt | 2 - .../davis/keygo/core/security/FakeSession.kt | 39 ++++++ .../crypto/BindingAccountRecordSealer.kt | 12 ++ .../BindingCryptographicScopeProvider.kt | 3 - .../crypto/FakeCryptographicScopeProvider.kt | 17 --- .../HealthReportStoreRepositoryImpl.kt | 64 ++++----- .../HealthReportStoreRepositoryImplTest.kt | 123 ++++++++++-------- .../usecase/MoveItemsToVaultUseCaseTest.kt | 3 - rust/crates/keygo-core/src/ark_session.rs | 115 +++++++++++++++- .../keygo-core/src/crypto/golden_tests.rs | 41 +++++- rust/crates/keygo-core/src/crypto/ids.rs | 2 +- rust/crates/keygo-core/src/crypto/keys.rs | 109 +++++++++++++++- rust/crates/keygo-core/src/crypto/mod.rs | 7 +- rust/crates/keygo-uniffi/src/ark_session.rs | 57 +++++++- rust/crates/keygo-uniffi/src/item.rs | 2 +- rust/crates/keygo-uniffi/src/types.rs | 8 +- 30 files changed, 724 insertions(+), 176 deletions(-) create mode 100644 core/security/src/main/kotlin/de/davis/keygo/core/security/data/crypto/AccountRecordSealerImpl.kt create mode 100644 core/security/src/main/kotlin/de/davis/keygo/core/security/domain/crypto/AccountRecordSealer.kt create mode 100644 core/security/src/main/kotlin/de/davis/keygo/core/security/domain/crypto/model/SealedAccountRecord.kt create mode 100644 core/security/src/main/kotlin/de/davis/keygo/core/security/domain/model/AccountRecordError.kt create mode 100644 core/security/src/test/kotlin/de/davis/keygo/core/security/data/crypto/AccountRecordSealerImplTest.kt create mode 100644 core/security/src/testFixtures/kotlin/de/davis/keygo/core/security/crypto/BindingAccountRecordSealer.kt diff --git a/CLAUDE.md b/CLAUDE.md index 4693869d1..68b883c07 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. 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 3a24df05f..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 @@ -61,7 +61,7 @@ internal class CryptographicScopeImpl( ) } -private fun SecretKind.toFfi(): FfiSecretKind = when (this) { +internal fun SecretKind.toFfi(): FfiSecretKind = when (this) { SecretKind.Password -> FfiSecretKind.PASSWORD SecretKind.CardNumber -> FfiSecretKind.CARD_NUMBER SecretKind.Cvv -> FfiSecretKind.CVV 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/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/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 e5d983619..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 @@ -13,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 @@ -42,7 +41,6 @@ class CryptographicScopeImplTest { itemRepository, itemManager, keyWrapper, - FakeVaultManager(), ) private val kind = SecretKind.Password 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 c549cfcd5..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 @@ -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 2c44fc1b7..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,7 +6,6 @@ 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. @@ -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 e765dc93e..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 @@ -12,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 @@ -29,8 +28,6 @@ class FakeCryptographicScopeProvider( 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 @@ -40,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 = @@ -82,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, 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 4cffce087..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,12 +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.item.domain.model.SecretKind -import de.davis.keygo.core.security.domain.crypto.CryptographicScopeProvider +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 @@ -17,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 @@ -26,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(SecretKind.PasswordHealthReport) 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() } } @@ -60,33 +54,31 @@ 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(SecretKind.PasswordHealthReport) - } 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") } 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 c9421a126..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,15 +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 @@ -27,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( @@ -58,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, ) @@ -98,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()) } @@ -173,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(SecretKind.PasswordHealthReport) - } + 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 @@ -260,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(SecretKind.PasswordHealthReport) - }.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(), @@ -283,7 +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") } } 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/crates/keygo-core/src/ark_session.rs b/rust/crates/keygo-core/src/ark_session.rs index d800862df..ed0a40605 100644 --- a/rust/crates/keygo-core/src/ark_session.rs +++ b/rust/crates/keygo-core/src/ark_session.rs @@ -1,6 +1,7 @@ use crate::crypto::kdf::new_salt; use crate::crypto::{ - AccountRootKey, CryptoError, RootKEK, Sealed, SymmetricKey, UserId, VaultId, VaultKey, Wraps, + AccountRootKey, CryptoError, Encrypts, NamespaceDataAad, NamespaceId, NamespaceKey, + NamespaceKeyAad, RootKEK, Sealed, SecretKind, SymmetricKey, UserId, VaultId, VaultKey, Wraps, }; use std::sync::{Mutex, MutexGuard}; use subtle::ConstantTimeEq; @@ -44,6 +45,12 @@ pub struct NewAccount { pub wrapped_vault_key: Sealed, } +/// 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>, @@ -156,6 +163,32 @@ impl ArkSession { self.with_live_ark(|ark| Ok(ark.unwrap_key(wrapped, &vault_id)?)) } + /// 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())?))?; @@ -396,6 +429,86 @@ mod tests { )); } + const NAMESPACE: NamespaceId = NamespaceId(uuid::Uuid::from_u128( + 0x5555_5555_5555_4555_8555_5555_5555_5555, + )); + const KIND: SecretKind = SecretKind::PasswordHealthReport; + + #[test] + fn a_sealed_record_opens_to_its_plaintext() { + let (session, _) = unlocked(); + + let record = session.seal_record(NAMESPACE, KIND, b"report").unwrap(); + + assert_eq!( + 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(); diff --git a/rust/crates/keygo-core/src/crypto/golden_tests.rs b/rust/crates/keygo-core/src/crypto/golden_tests.rs index eda681bdc..8a5059fc4 100644 --- a/rust/crates/keygo-core/src/crypto/golden_tests.rs +++ b/rust/crates/keygo-core/src/crypto/golden_tests.rs @@ -1,19 +1,23 @@ //! Shipped ciphertexts under fixed keys. They must keep opening; regenerate only with a migration. use crate::crypto::{ - AccountRootKey, Encrypts, ItemAad, ItemDataAad, ItemId, ItemKey, RootKEK, Sealed, SecretKind, - SymmetricKey, UserId, VaultId, VaultKey, Wraps, + 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", @@ -33,6 +37,16 @@ const ITEM_DATA: (&str, &str) = ( "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( @@ -52,7 +66,7 @@ fn password_wrapped_ark_still_unwraps() { #[test] fn vault_key_still_unwraps() { let ark = AccountRootKey::from_bytes(&ARK).unwrap(); - let vault_key = ark + let vault_key: VaultKey = ark .unwrap_key(&sealed(WRAPPED_VAULT_KEY), &VAULT_ID) .unwrap(); assert_eq!(vault_key.as_bytes(), VAULT_KEY); @@ -81,3 +95,24 @@ fn item_data_still_decrypts() { 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 index a33b13a02..52f1f7178 100644 --- a/rust/crates/keygo-core/src/crypto/ids.rs +++ b/rust/crates/keygo-core/src/crypto/ids.rs @@ -15,4 +15,4 @@ macro_rules! id { }; } -id!(UserId, VaultId, ItemId); +id!(UserId, VaultId, ItemId, NamespaceId); diff --git a/rust/crates/keygo-core/src/crypto/keys.rs b/rust/crates/keygo-core/src/crypto/keys.rs index 6ed381e95..946c55ab1 100644 --- a/rust/crates/keygo-core/src/crypto/keys.rs +++ b/rust/crates/keygo-core/src/crypto/keys.rs @@ -2,12 +2,14 @@ //! //! ```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, SymmetricKey, UserId, VaultId, Wraps, aad, - symmetric_key, + Aad, CipherKey, CryptoResult, Encrypts, ItemId, NamespaceId, SymmetricKey, UserId, VaultId, + Wraps, aad, symmetric_key, }; use aead::Generate; use serde::{Serialize, Serializer}; @@ -15,6 +17,12 @@ 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; } @@ -31,6 +39,11 @@ 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, @@ -56,6 +69,31 @@ impl Serialize for ItemDataAad { 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, @@ -96,6 +134,14 @@ 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())?; @@ -113,11 +159,12 @@ macro_rules! impl_generate { }; } -impl_generate!(AccountRootKey, VaultKey, ItemKey); +impl_generate!(AccountRootKey, VaultKey, ItemKey, NamespaceKey); #[cfg(test)] mod tests { use super::*; + use crate::crypto::Sealed; const SALT: &[u8] = &[6; 16]; @@ -194,6 +241,62 @@ mod tests { ); } + 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!( diff --git a/rust/crates/keygo-core/src/crypto/mod.rs b/rust/crates/keygo-core/src/crypto/mod.rs index 834aec29f..a026d9d93 100644 --- a/rust/crates/keygo-core/src/crypto/mod.rs +++ b/rust/crates/keygo-core/src/crypto/mod.rs @@ -10,10 +10,13 @@ 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, UserId, VaultId}; +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, RootKEK, SecretKind, VaultKey}; +pub use keys::{ + AccountRootKey, ItemAad, ItemDataAad, ItemKey, NamespaceDataAad, NamespaceKey, NamespaceKeyAad, + RootKEK, SecretKind, VaultKey, +}; #[cfg(test)] mod golden_tests; diff --git a/rust/crates/keygo-uniffi/src/ark_session.rs b/rust/crates/keygo-uniffi/src/ark_session.rs index 17b7a0c71..7c2ed73c9 100644 --- a/rust/crates/keygo-uniffi/src/ark_session.rs +++ b/rust/crates/keygo-uniffi/src/ark_session.rs @@ -1,9 +1,10 @@ +use crate::item::EncryptedItemBlob; use crate::key_wrap::{KeyWrapError, WrappedKeyBlob}; use keygo_core::ark_session::{ - ArkSession as CoreArkSession, ArkSessionError as CoreArkSessionError, - NewAccount as CoreNewAccount, PasswordWrappedArk, + AccountRecord as CoreAccountRecord, ArkSession as CoreArkSession, + ArkSessionError as CoreArkSessionError, NewAccount as CoreNewAccount, PasswordWrappedArk, }; -use keygo_core::crypto::{CryptoError, UserId, VaultId, VaultKey}; +use keygo_core::crypto::{CryptoError, NamespaceId, SecretKind, UserId, VaultId, VaultKey}; use std::sync::Arc; #[derive(Debug, thiserror::Error, uniffi::Error)] @@ -14,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,6 +27,7 @@ impl From for ArkSessionError { CoreArkSessionError::Locked => Self::Locked, CoreArkSessionError::WrongPassword => Self::WrongPassword, CoreArkSessionError::Derivation(e) => Self::Derivation(e.to_string()), + CoreArkSessionError::KeyWrap(CryptoError::DecryptionFailed) => Self::DecryptionFailed, CoreArkSessionError::KeyWrap(e) => e.into(), } } @@ -71,6 +75,30 @@ impl From for PasswordWrapped { } } +#[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, @@ -183,6 +211,29 @@ impl ArkSession { 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 { Arc::new(ArkCredential { ark_session: self }) } diff --git a/rust/crates/keygo-uniffi/src/item.rs b/rust/crates/keygo-uniffi/src/item.rs index 46796c5f4..084c23621 100644 --- a/rust/crates/keygo-uniffi/src/item.rs +++ b/rust/crates/keygo-uniffi/src/item.rs @@ -39,7 +39,7 @@ impl From> for EncryptedItemBlob { } impl EncryptedItemBlob { - fn into_sealed(self) -> Result, CryptoError> { + pub(crate) fn into_sealed(self) -> Result, CryptoError> { Sealed::from_parts(&self.nonce, self.ciphertext) } } diff --git a/rust/crates/keygo-uniffi/src/types.rs b/rust/crates/keygo-uniffi/src/types.rs index e571d72ad..1eb26e63f 100644 --- a/rust/crates/keygo-uniffi/src/types.rs +++ b/rust/crates/keygo-uniffi/src/types.rs @@ -1,4 +1,4 @@ -use keygo_core::crypto::{ItemId, ItemKey, SymmetricKey, UserId, VaultId, VaultKey}; +use keygo_core::crypto::{ItemId, ItemKey, NamespaceId, SymmetricKey, UserId, VaultId, VaultKey}; use uuid::Uuid; uniffi::custom_type!(Uuid, String, { @@ -25,6 +25,12 @@ uniffi::custom_type!(ItemId, Uuid, { 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}")), From c9c73c4b2bf7737acdf20c44cc08c9c9a518b9bf Mon Sep 17 00:00:00 2001 From: Davis Date: Mon, 12 Oct 2026 20:19:10 +0200 Subject: [PATCH 18/18] fix(rust): cap Argon2 iterations read from a backup header A backup's Argon2 parameters come from its header, and only the memory cost was bounded, so a crafted file with iters = u32::MAX hung the import. Argon2Params::build() now rejects more than MAX_ARGON2_ITERS (16) with Argon2ItersTooHigh, beside the existing memory check. Every backup so far was written with the default of 3 iterations, so none is rejected; a test keeps the defaults inside both caps. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../keygo-core/src/backup/encryption.rs | 14 +++++++++ rust/crates/keygo-core/src/crypto/error.rs | 2 ++ .../keygo-core/src/crypto/kdf/argon2.rs | 31 ++++++++++++++++++- 3 files changed, 46 insertions(+), 1 deletion(-) diff --git a/rust/crates/keygo-core/src/backup/encryption.rs b/rust/crates/keygo-core/src/backup/encryption.rs index 127b85cba..50953b0b1 100644 --- a/rust/crates/keygo-core/src/backup/encryption.rs +++ b/rust/crates/keygo-core/src/backup/encryption.rs @@ -287,4 +287,18 @@ mod tests { 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/crates/keygo-core/src/crypto/error.rs b/rust/crates/keygo-core/src/crypto/error.rs index 799d164f0..5a0351ebf 100644 --- a/rust/crates/keygo-core/src/crypto/error.rs +++ b/rust/crates/keygo-core/src/crypto/error.rs @@ -26,6 +26,8 @@ pub enum CryptoError { 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), } diff --git a/rust/crates/keygo-core/src/crypto/kdf/argon2.rs b/rust/crates/keygo-core/src/crypto/kdf/argon2.rs index 300aa1b89..1045de374 100644 --- a/rust/crates/keygo-core/src/crypto/kdf/argon2.rs +++ b/rust/crates/keygo-core/src/crypto/kdf/argon2.rs @@ -4,6 +4,7 @@ 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 { @@ -31,6 +32,12 @@ impl Argon2Params { 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)) @@ -38,7 +45,7 @@ impl Argon2Params { } /// Argon2id. `domain` is appended to the salt to separate derivations. -/// Rejects costs above `MAX_ARGON2_MEM_KIB`; the caller zeroizes the password. +/// 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], @@ -139,6 +146,28 @@ mod tests { )); } + #[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 {