Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
180136b
refactor(rust): move android rust core to :core:rust
OffRange Oct 10, 2026
11099e1
refactor(rust): restructure code
OffRange Oct 10, 2026
b918b42
chore(rust): drop unused zeroize dependency from keygo-uniffi
OffRange Oct 10, 2026
3e04277
build(rust): bump passkey to 0.6 and argon2 to 0.6
OffRange Oct 10, 2026
d75bf0a
fix(passkey): report the user's name, not the relying party's
OffRange Oct 10, 2026
0e47b1f
test(rust): pin the key hierarchy and passkey wire formats with goldens
OffRange Oct 10, 2026
460c8b9
refactor(crypto): one Sealed type and three traits for the key hierarchy
OffRange Oct 10, 2026
6c1e688
refactor(rust): simpler passkey, TOTP and CSV core APIs
OffRange Oct 10, 2026
a525e9e
build(rust): LTO release profile, forbid unsafe in core, drop ed25519…
OffRange Oct 10, 2026
4ba3500
build(rust): declare tokio once as a workspace dependency
OffRange Oct 10, 2026
9d72683
chore: ignore OS-generated files (macOS, Windows, Linux)
OffRange Oct 10, 2026
196c656
build(rust): leave stripping to the packaging tool
OffRange Oct 10, 2026
a27aa4b
refactor(rust): review cleanups across core and the UniFFI layer
OffRange Oct 11, 2026
cbbcd8c
refactor(rust): trim comments to the ones that aid reading
OffRange Oct 11, 2026
5ea8ff5
refactor(rust): type every AAD and close the raw-bytes escape hatch
OffRange Oct 11, 2026
fa7a4cb
fix(rust): refuse to sign a passkey assertion for another relying party
OffRange Oct 11, 2026
7401e06
refactor(crypto): seal account records under their own key type
OffRange Oct 12, 2026
c9c73c4
fix(rust): cap Argon2 iterations read from a backup header
OffRange Oct 12, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ jobs:

defaults:
run:
working-directory: ./rust/rust-code
working-directory: ./rust

steps:
- uses: actions/checkout@v6
Expand All @@ -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
Expand All @@ -68,7 +68,7 @@ jobs:

defaults:
run:
working-directory: ./rust/rust-code
working-directory: ./rust

steps:
- uses: actions/checkout@v6
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
71 changes: 70 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
.gitattributes
18 changes: 13 additions & 5 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -54,7 +54,7 @@ Android password manager using Clean Architecture per module:
| `:automation` | `@VaultItem` annotation + automation support |
| `:automation-processor` | KSP processor generating code from `@VaultItem` |
| `:legacy-migration` | The whole v1 to v2 migration: main password, item import (high risk) |
| `:rust` | Rust crypto/passkey ops via UniFFI-generated Kotlin bindings |
| `:core:rust` | Rust crypto/passkey ops via UniFFI-generated Kotlin bindings; Rust sources in `rust/crates/` |

## Key Patterns

Expand Down Expand Up @@ -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)
Expand All @@ -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.

Expand Down Expand Up @@ -155,10 +163,10 @@ carries its own rules:
(for example, validating that a side-effecting API was invoked)
- Do not use mocks as the default way to model dependencies when a fake or testFixture exists
- Run broader tests for cross-module or security changes
- **Rust fakes** — `:rust` uses UniFFI (not raw JNI) to generate Kotlin bindings. UniFFI emits
- **Rust fakes** — `:core:rust` uses UniFFI (not raw JNI) to generate Kotlin bindings. UniFFI emits
`KeyWrapperInterface`/`ItemManagerInterface`/`VaultManagerInterface`/`CardFormatterInterface`/
`CsvBackupManagerInterface`/`JsonBackupManagerInterface`/`RustPasskeyInterface`/
`TotpServiceInterface` for test seams; fakes live in `:rust` testFixtures
`TotpServiceInterface` for test seams; fakes live in `:core:rust` testFixtures
(`de.davis.keygo.rust`).
Never instantiate the real UniFFI classes (`KeyWrapper()`, etc.) in JVM unit
tests — their default constructors require the native Rust library at runtime.
Expand Down
4 changes: 2 additions & 2 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
Expand Down
6 changes: 3 additions & 3 deletions core/identity/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
}
2 changes: 1 addition & 1 deletion core/item/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ data class CreditCard(
override val blueprint: SecretBlueprint<String, out SecretField<String>> = CardNumber

companion object : SecretBlueprint<String, CardNumber>() {
override val label: String = "credit_card_number"
override val kind: SecretKind = SecretKind.CardNumber
override val codec: SecretCodec<String> = SecretCodec.StringCodec

override fun createField(payload: EncryptedPayload): CardNumber = CardNumber(payload)
Expand All @@ -49,7 +49,7 @@ data class CreditCard(
override val blueprint: SecretBlueprint<String, out SecretField<String>> = CVV

companion object : SecretBlueprint<String, CVV>() {
override val label: String = "credit_card_cvv"
override val kind: SecretKind = SecretKind.Cvv
override val codec: SecretCodec<String> = SecretCodec.StringCodec

override fun createField(payload: EncryptedPayload): CVV = CVV(payload)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ data class Passkey(
override val blueprint: SecretBlueprint<ByteArray, out SecretField<ByteArray>> = PrivateKey

companion object : SecretBlueprint<ByteArray, PrivateKey>() {
override val label: String = "passkey_private_key"
override val kind: SecretKind = SecretKind.PasskeyPrivateKey
override val codec: SecretCodec<ByteArray> = SecretCodec.ByteArrayCodec

override fun createField(payload: EncryptedPayload): PrivateKey = PrivateKey(payload)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ data class PasswordSecret(
override val blueprint: SecretBlueprint<String, out SecretField<String>> = PasswordSecret

companion object : SecretBlueprint<String, PasswordSecret>() {
override val label: String = "password"
override val kind: SecretKind = SecretKind.Password
override val codec: SecretCodec<String> = SecretCodec.StringCodec

override fun createField(payload: EncryptedPayload): PasswordSecret =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,17 @@ interface SecretField<T> {
val blueprint: SecretBlueprint<T, out SecretField<T>>
}

enum class SecretKind {
Password,
CardNumber,
Cvv,
PasskeyPrivateKey,
TotpSecret,
PasswordHealthReport,
}

abstract class SecretBlueprint<T, F : SecretField<T>> {
abstract val label: String
abstract val kind: SecretKind
abstract val codec: SecretCodec<T>

abstract fun createField(payload: EncryptedPayload): F
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ data class Totp(
override val blueprint: SecretBlueprint<String, out SecretField<String>> = Secret

companion object : SecretBlueprint<String, Secret>() {
override val label: String = "totp_secret"
override val kind: SecretKind = SecretKind.TotpSecret
override val codec: SecretCodec<String> = SecretCodec.StringCodec

override fun createField(payload: EncryptedPayload): Secret = Secret(payload)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ class SecretFieldTest {
override val blueprint: SecretBlueprint<String, out SecretField<String>> = TestSecretField

companion object : SecretBlueprint<String, TestSecretField>() {
override val label: String = "test_label"
override val kind: SecretKind = SecretKind.Password
override val codec: SecretCodec<String> = SecretCodec.StringCodec

override fun createField(payload: EncryptedPayload): TestSecretField =
Expand Down
1 change: 1 addition & 0 deletions core/rust/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
/build
10 changes: 7 additions & 3 deletions rust/build.gradle.kts → core/rust/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ plugins {
alias(libs.plugins.keygo.android.library)
}

val uniffiDir = "build/generated/source/uniffi"

android {
namespace = "de.davis.keygo.rust"

Expand All @@ -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"
}
}

Expand Down Expand Up @@ -45,12 +47,14 @@ val buildRust by tasks.register<Exec>("buildRust") {
providers.gradleProperty("rust.compile").getOrElse("true").toBoolean()
}

workingDir = projectDir.resolve("rust-code")
workingDir = rootDir.resolve("rust")

commandLine(
"make",
"all",
"MIN_SDK=${libs.versions.minSdk.get()}",
"JNI_DIR=${projectDir.resolve("$uniffiDir/jniLibs")}",
"KOTLIN_OUT=${projectDir.resolve("$uniffiDir/kotlin")}",
)
}

Expand Down
File renamed without changes.
File renamed without changes.
4 changes: 4 additions & 0 deletions core/rust/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest>

</manifest>
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -23,7 +23,7 @@ suspend fun RustPasskeyInterface.authenticateWithResult(

suspend fun RustPasskeyInterface.registerWithResult(
requestJson: String
): Result<RegistrationResponse, PasskeyException> = withContext(Dispatchers.Default) {
): Result<Registration, PasskeyException> = withContext(Dispatchers.Default) {
runCatching {
register(requestJson)
}.fold(
Expand All @@ -34,7 +34,7 @@ suspend fun RustPasskeyInterface.registerWithResult(

fun RustPasskeyInterface.getPasskeyInformation(
requestJson: String
): Result<PasskeyInformation, PasskeyException> = runCatching {
): Result<CreationInfo, PasskeyException> = runCatching {
passkeyInformation(requestJson)
}.fold(
onSuccess = { Result.Success(it) },
Expand Down
Loading
Loading