# C1 — Forgot-Password Recovery (Recovery Key Escrow): Integration Contract Draft implementation of spec C1. All draft artifacts live under `app/drafts/C1/`. No shared/source file was edited by the subagent — every file below was read only: `VaultRepository.kt`, `VaultCrypto.kt`, `VaultStore.kt`, `VaultEntry.kt`, `RecoveryWords.kt`, `RecoveryWordList.kt`, `RecoveryWordsScreen.kt`, `LockScreen.kt`, `LockViewModel.kt`, `PeachNav.kt`, `PeachAppNavHost.kt`, theme files, `StrengthBar.kt`. The drafts compile against the REAL v2.1.38 signatures **once the orchestrator applies the data-model + repo deltas** described below (the new methods reference `StoredVault.recoveryEscrow`, `RecoveryEscrow`, and `VaultCrypto.AAD_RECOVERY`, which do not exist yet). --- ## 1. Draft files and their destinations | Draft file | Destination | Kind | |---|---|---| | `RecoveryWordEntryScreen.kt` | `app/src/main/java/com/peachpasswords/ui/recovery/RecoveryWordEntryScreen.kt` | New Compose screen + `@HiltViewModel RecoveryEntryViewModel` (package `com.peachpasswords.ui.recovery` — **new package dir**) | | `C1-RecoveryEscrowTest.kt` | `app/src/test/java/com/peachpasswords/vault/RecoveryEscrowTest.kt` | New JUnit test (rename file to `RecoveryEscrowTest.kt`) | | `C1-repo-additions.kt` | *(not a file)* — apply its members/edits INTO `vault/VaultRepository.kt` | Snippet | | `C1-crypto-notes.md` | *(not a file)* — apply its model/crypto deltas | Notes | | `C1-INTEGRATION.md` | stays in drafts | This doc | > The screen lives in a NEW package `com.peachpasswords.ui.recovery`. If you > prefer to keep it under `ui.vault` or `ui.lock`, change the `package` line and > the one PeachAppNavHost import — nothing else depends on the package. Public composable signature: ```kotlin @Composable fun RecoveryWordEntryScreen( onSuccess: () -> Unit, onBack: () -> Unit, onRestore: () -> Unit = onBack, // defaults to onBack if you don't wire Import vm: RecoveryEntryViewModel = hiltViewModel(), ) ``` --- ## 2. Shared-file edits the orchestrator must make ### 2a. `vault/VaultStore.kt` — data model (from C1-crypto-notes.md §2) Add a nullable `recoveryEscrow` field to `StoredVault` and a new `RecoveryEscrow` data class: ```kotlin @Serializable data class StoredVault( val version: Int = 1, val salt: String, val blob: String, val recoveryEscrow: RecoveryEscrow? = null, // NEW ) @Serializable data class RecoveryEscrow( val salt: String, // base64, 16B — HKDF salt for deriveRecoveryKey val blob: String, // base64 — VaultCrypto.encrypt(slotAPlaintext, recoveryKey, AAD_RECOVERY) ) ``` `VaultCodec` already uses `ignoreUnknownKeys = true; encodeDefaults = true`, so old vaults decode with `recoveryEscrow = null` and no migration is needed. ### 2b. `vault/VaultCrypto.kt` — new AAD constant Add beside the other AAD constants: ```kotlin const val AAD_RECOVERY = "recovery_escrow_v1" ``` ### 2c. `vault/VaultRepository.kt` — methods + edits (from C1-repo-additions.kt) 1. **REPLACE the body of the existing `setRecoveryWords`** so it also seals the escrow (snippet §3a) — adds `buildRecoveryEscrow()` and `persistSlotAWithEscrow()` private helpers. 2. **Edit `changePassword`** (snippet §3b): the single `val next = StoredVault(...)` line must carry `recoveryEscrow = slotA.recoveryEscrow` forward. The escrow seals the slot-A *plaintext*, which a password change does not alter, so it stays valid — preserve it, do not regenerate. 3. **ADD** `unlockWithRecoveryWords(words): Boolean`, `resetPasswordWithRecoveryWords(words, newPassword): Boolean`, and `hasRecoveryEscrow(): Boolean` (snippet §3c). 4. Add import `import javax.crypto.AEADBadTagException` (referenced in notes; the snippet itself catches broad `Exception`, so this import is optional). > **API-shape deviation from the brief, flagged:** the brief specifies > `unlockWithRecoveryWords(words): ByteArray?` and > `resetPasswordWithRecoveryKey(vaultKey, newPassword)`. There is no single > detached "vault key" in this codebase that both decrypts slot A and survives a > password change (slot A is wrapped under the rotating Argon2id key). So the > escrow seals the **slot-A plaintext envelope**, `unlockWithRecoveryWords` > returns `Boolean` and adopts that plaintext as the live session, and > `resetPasswordWithRecoveryWords(words, newPassword)` re-seals + unlocks in one > call. If you want to preserve the brief's exact signatures, they can wrap > these, but the Boolean form is what the screen and tests use. ### 2d. `ui/PeachNav.kt` — new route Add one destination (mirrors `RecoverySetup`/`RecoveryView`): ```kotlin data object RecoveryEntry : PeachNav("recovery/entry") ``` ### 2e. `ui/PeachAppNavHost.kt` — register the composable Add import `import com.peachpasswords.ui.recovery.RecoveryWordEntryScreen` and a composable. The existing `nav`/`back` lambdas are in scope: ```kotlin composable(PeachNav.RecoveryEntry.route) { RecoveryWordEntryScreen( onSuccess = { navController.navigate(PeachNav.VaultList.route) { popUpTo(PeachNav.Lock.route) { inclusive = true } } }, onBack = back, onRestore = { nav(PeachNav.Import) }, ) } ``` (`onSuccess` pops Lock off the back stack, matching how `RecoverySetup` routes into the vault. `popUpTo(0)` as in the brief also works but would clear the whole stack; `popUpTo(Lock)` is the established pattern here.) ### 2f. `ui/lock/LockScreen.kt` — "Forgot your password?" entry point **Reality check / deviation flagged:** the brief assumes a `hasVault` unlock state that already shows restore affordances. In the real `LockScreen`, the restore tabs (`RestoreTabs`) render only in the `!state.hasVault` (create) branch. The "Forgot your password?" link must be added to the **unlock** branch, where there is currently no such affordance. Place it inside the `GlassPanel`, after the biometric `"Use biometrics"` block, guarded by `state.hasVault`: ```kotlin if (state.hasVault) { Spacer(Modifier.height(8.dp)) Text( "Forgot your password?", color = PeachColors.T2, style = PeachType.labelMono.copy(fontSize = 11.sp), textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth() .clip(RoundedCornerShape(8.dp)) .clickable { onNavigate(PeachNav.RecoveryEntry) } .padding(vertical = 8.dp), ) } ``` `LockScreen` already has `onNavigate: (PeachNav) -> Unit` in scope (it is how `RestoreTabs` navigates), so **no new screen parameter is needed** — do NOT add an `onForgotPassword` callback as the brief's pseudo-code implies; reuse `onNavigate`. `PeachColors`, `PeachType`, `RoundedCornerShape`, `clickable`, `clip`, `TextAlign` are all already imported in `LockScreen.kt`. --- ## 3. New resources / dependencies - **No new resources.** The brief says to load BIP39 from `res/raw/bip39_english.txt` — that file does **not** exist and is **not** needed. The 2048-word list is the Kotlin `val WORD_LIST` in `vault/RecoveryWordList.kt`, already used by `getWordSuggestions`, `findInvalidWords`, `wordsToRootKeyMaterial`. The screen imports `WORD_LIST` and `getWordSuggestions` directly. Do not add a raw asset. - **No new Gradle dependencies.** Argon2id/HKDF/Blake3 are already provided by BouncyCastle (used by `VaultCrypto` / `RecoveryWords`). The recovery key uses the existing `deriveRecoveryKey` (HKDF-SHA256), not a new KDF. - **No new string resources** — copy is inline (the codebase uses inline literals throughout, e.g. LockScreen, RecoveryWordsScreen). --- ## 4. Where the brief's assumed API differed from reality (full list) | # | Brief assumption | Reality / what the drafts use | |---|---|---| | 1 | `StoredVault` has `slotA`, `slotDuress`, `recoveryEscrow` fields | `StoredVault` = `(version, salt, blob)`, one slot. Duress/decoy is a separate `StoredVault` under a different prefs key. Drafts add ONE nullable `recoveryEscrow` field to `StoredVault`. | | 2 | `VaultCrypto.encryptRaw/decryptRaw/encryptSlot` | None exist. Use `encrypt(plaintext, key, aad)` / `decrypt(data, key, aad)` (12-byte nonce managed internally; AAD is a required String). New `AAD_RECOVERY` constant. | | 3 | `deriveRecoveryKey(joinedWordString, salt)` via **Argon2id** | `deriveRecoveryKey(material: ByteArray, salt): SecretKeySpec` ALREADY EXISTS using **HKDF-SHA256** (`info = "peach-recovery-v1"`). `material` is the 16-byte entropy from `wordsToRootKeyMaterial(words)`, not the joined string. | | 4 | Escrow the "vault master key" from `currentVaultKey()` | `currentVaultKey()` is the rotating Argon2id password key — escrowing it breaks after a password change. Drafts escrow the **slot-A plaintext envelope** instead (stable; carries entries + rootKey). | | 5 | `unlockWithRecoveryWords(): ByteArray?` + `resetPasswordWithRecoveryKey(vaultKey, …)` | Returns `Boolean` and adopts the recovered payload; reset is `resetPasswordWithRecoveryWords(words, newPassword): Boolean` (re-seal + unlock in one call). No detached vault key. | | 6 | BIP39 list in `res/raw/bip39_english.txt`; "add it if missing" | Lives in Kotlin `WORD_LIST` (RecoveryWordList.kt). No raw asset. Note the list has 2052 entries (index 0 and 1 both `"abandon"`); the existing encode/decode already accounts for this — do not "fix" it. | | 7 | `secureRandom(16)`, `blake3(...)` helpers; add `recoveryWordsHash` | `VaultCrypto.generateSalt()` (16B) and the existing `recoveryWordsHash(words)` cover these; `setRecoveryWords` already stores the BLAKE3 hash. | | 8 | §3b: escrow needs no re-seal because it seals "the vault master key" | Wrong rationale, right conclusion: escrow seals the slot-A plaintext (unchanged by a password change), so it stays valid — but `changePassword` rebuilds `StoredVault` and would DROP the field. Required edit: carry `recoveryEscrow` forward. | | 9 | A shared `PeachField` underline component exists | It does not. `LockScreen` uses a private `UnderlineField`. The draft screen uses its own local underline fields (same idiom). | | 10 | LockScreen `hasVault` unlock state already shows restore affordances; add `onForgotPassword` callback | Restore tabs render only in the `!hasVault` (create) branch. Add the link to the unlock branch and reuse the existing `onNavigate(PeachNav.RecoveryEntry)` — no new callback param. | | 11 | `MIN_PASSWORD_LENGTH` / strength gate | Reuse `MIN_PASSWORD_LENGTH = 12` and `MIN_CREATE_STRENGTH = 2` from `ui/lock/LockViewModel.kt`; `scorePassword` returns 0–4. The screen gates on `score >= 2` + match + length, matching create-vault. | --- ## 5. Security notes (for reviewer) - The escrow blob is AES-256-GCM under a key the recovery words derive via HKDF. AAD `recovery_escrow_v1` is distinct from `slot_a_v1`, so an escrow blob can never be swapped in as a slot-A blob. - Wrong words surface as `AEADBadTagException` (GCM tag mismatch) or an `IllegalArgumentException` from `wordsToRootKeyMaterial` (bad BIP39 checksum/ unknown word) — both map to `false` / `null` in the repo, and to the "Incorrect recovery words" UI error. - All transient key material (`wordsToRootKeyMaterial` entropy, the `SecretKeySpec.encoded` copy, the decoded plaintext) is zeroed in `finally` blocks, consistent with the existing repo's `VaultCrypto.zero` discipline. - The escrow weakens the threat model deliberately: anyone with the 12 words can decrypt the vault without the password (that is the feature). This matches the browser design referenced in CONTINUITY.md. Recovery is never offered for the decoy/duress slot (`persistSlotAWithEscrow` refuses in `decoyMode`).