1# C1 — Forgot-Password Recovery (Recovery Key Escrow): Integration Contract23Draft implementation of spec C1. All draft artifacts live under `app/drafts/C1/`.4No shared/source file was edited by the subagent — every file below was read only:5`VaultRepository.kt`, `VaultCrypto.kt`, `VaultStore.kt`, `VaultEntry.kt`,6`RecoveryWords.kt`, `RecoveryWordList.kt`, `RecoveryWordsScreen.kt`,7`LockScreen.kt`, `LockViewModel.kt`, `PeachNav.kt`, `PeachAppNavHost.kt`, theme8files, `StrengthBar.kt`.910The drafts compile against the REAL v2.1.38 signatures **once the orchestrator11applies the data-model + repo deltas** described below (the new methods reference12`StoredVault.recoveryEscrow`, `RecoveryEscrow`, and `VaultCrypto.AAD_RECOVERY`,13which do not exist yet).1415---1617## 1. Draft files and their destinations1819| Draft file | Destination | Kind |20|---|---|---|21| `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**) |22| `C1-RecoveryEscrowTest.kt` | `app/src/test/java/com/peachpasswords/vault/RecoveryEscrowTest.kt` | New JUnit test (rename file to `RecoveryEscrowTest.kt`) |23| `C1-repo-additions.kt` | *(not a file)* — apply its members/edits INTO `vault/VaultRepository.kt` | Snippet |24| `C1-crypto-notes.md` | *(not a file)* — apply its model/crypto deltas | Notes |25| `C1-INTEGRATION.md` | stays in drafts | This doc |2627> The screen lives in a NEW package `com.peachpasswords.ui.recovery`. If you28> prefer to keep it under `ui.vault` or `ui.lock`, change the `package` line and29> the one PeachAppNavHost import — nothing else depends on the package.3031Public composable signature:3233```kotlin34@Composable35fun RecoveryWordEntryScreen(36 onSuccess: () -> Unit,37 onBack: () -> Unit,38 onRestore: () -> Unit = onBack, // defaults to onBack if you don't wire Import39 vm: RecoveryEntryViewModel = hiltViewModel(),40)41```4243---4445## 2. Shared-file edits the orchestrator must make4647### 2a. `vault/VaultStore.kt` — data model (from C1-crypto-notes.md §2)4849Add a nullable `recoveryEscrow` field to `StoredVault` and a new `RecoveryEscrow`50data class:5152```kotlin53@Serializable54data class StoredVault(55 val version: Int = 1,56 val salt: String,57 val blob: String,58 val recoveryEscrow: RecoveryEscrow? = null, // NEW59)6061@Serializable62data class RecoveryEscrow(63 val salt: String, // base64, 16B — HKDF salt for deriveRecoveryKey64 val blob: String, // base64 — VaultCrypto.encrypt(slotAPlaintext, recoveryKey, AAD_RECOVERY)65)66```6768`VaultCodec` already uses `ignoreUnknownKeys = true; encodeDefaults = true`, so69old vaults decode with `recoveryEscrow = null` and no migration is needed.7071### 2b. `vault/VaultCrypto.kt` — new AAD constant7273Add beside the other AAD constants:7475```kotlin76const val AAD_RECOVERY = "recovery_escrow_v1"77```7879### 2c. `vault/VaultRepository.kt` — methods + edits (from C1-repo-additions.kt)80811. **REPLACE the body of the existing `setRecoveryWords`** so it also seals the82 escrow (snippet §3a) — adds `buildRecoveryEscrow()` and83 `persistSlotAWithEscrow()` private helpers.842. **Edit `changePassword`** (snippet §3b): the single `val next = StoredVault(...)`85 line must carry `recoveryEscrow = slotA.recoveryEscrow` forward. The escrow86 seals the slot-A *plaintext*, which a password change does not alter, so it87 stays valid — preserve it, do not regenerate.883. **ADD** `unlockWithRecoveryWords(words): Boolean`,89 `resetPasswordWithRecoveryWords(words, newPassword): Boolean`, and90 `hasRecoveryEscrow(): Boolean` (snippet §3c).914. Add import `import javax.crypto.AEADBadTagException` (referenced in notes; the92 snippet itself catches broad `Exception`, so this import is optional).9394> **API-shape deviation from the brief, flagged:** the brief specifies95> `unlockWithRecoveryWords(words): ByteArray?` and96> `resetPasswordWithRecoveryKey(vaultKey, newPassword)`. There is no single97> detached "vault key" in this codebase that both decrypts slot A and survives a98> password change (slot A is wrapped under the rotating Argon2id key). So the99> escrow seals the **slot-A plaintext envelope**, `unlockWithRecoveryWords`100> returns `Boolean` and adopts that plaintext as the live session, and101> `resetPasswordWithRecoveryWords(words, newPassword)` re-seals + unlocks in one102> call. If you want to preserve the brief's exact signatures, they can wrap103> these, but the Boolean form is what the screen and tests use.104105### 2d. `ui/PeachNav.kt` — new route106107Add one destination (mirrors `RecoverySetup`/`RecoveryView`):108109```kotlin110data object RecoveryEntry : PeachNav("recovery/entry")111```112113### 2e. `ui/PeachAppNavHost.kt` — register the composable114115Add import `import com.peachpasswords.ui.recovery.RecoveryWordEntryScreen` and a116composable. The existing `nav`/`back` lambdas are in scope:117118```kotlin119composable(PeachNav.RecoveryEntry.route) {120 RecoveryWordEntryScreen(121 onSuccess = {122 navController.navigate(PeachNav.VaultList.route) {123 popUpTo(PeachNav.Lock.route) { inclusive = true }124 }125 },126 onBack = back,127 onRestore = { nav(PeachNav.Import) },128 )129}130```131132(`onSuccess` pops Lock off the back stack, matching how `RecoverySetup` routes133into the vault. `popUpTo(0)` as in the brief also works but would clear the whole134stack; `popUpTo(Lock)` is the established pattern here.)135136### 2f. `ui/lock/LockScreen.kt` — "Forgot your password?" entry point137138**Reality check / deviation flagged:** the brief assumes a `hasVault` unlock139state that already shows restore affordances. In the real `LockScreen`, the140restore tabs (`RestoreTabs`) render only in the `!state.hasVault` (create) branch.141The "Forgot your password?" link must be added to the **unlock** branch, where142there is currently no such affordance. Place it inside the `GlassPanel`, after143the biometric `"Use biometrics"` block, guarded by `state.hasVault`:144145```kotlin146if (state.hasVault) {147 Spacer(Modifier.height(8.dp))148 Text(149 "Forgot your password?",150 color = PeachColors.T2,151 style = PeachType.labelMono.copy(fontSize = 11.sp),152 textAlign = TextAlign.Center,153 modifier = Modifier.fillMaxWidth()154 .clip(RoundedCornerShape(8.dp))155 .clickable { onNavigate(PeachNav.RecoveryEntry) }156 .padding(vertical = 8.dp),157 )158}159```160161`LockScreen` already has `onNavigate: (PeachNav) -> Unit` in scope (it is how162`RestoreTabs` navigates), so **no new screen parameter is needed** — do NOT add163an `onForgotPassword` callback as the brief's pseudo-code implies; reuse164`onNavigate`. `PeachColors`, `PeachType`, `RoundedCornerShape`, `clickable`,165`clip`, `TextAlign` are all already imported in `LockScreen.kt`.166167---168169## 3. New resources / dependencies170171- **No new resources.** The brief says to load BIP39 from172 `res/raw/bip39_english.txt` — that file does **not** exist and is **not**173 needed. The 2048-word list is the Kotlin `val WORD_LIST` in174 `vault/RecoveryWordList.kt`, already used by `getWordSuggestions`,175 `findInvalidWords`, `wordsToRootKeyMaterial`. The screen imports `WORD_LIST`176 and `getWordSuggestions` directly. Do not add a raw asset.177- **No new Gradle dependencies.** Argon2id/HKDF/Blake3 are already provided by178 BouncyCastle (used by `VaultCrypto` / `RecoveryWords`). The recovery key uses179 the existing `deriveRecoveryKey` (HKDF-SHA256), not a new KDF.180- **No new string resources** — copy is inline (the codebase uses inline literals181 throughout, e.g. LockScreen, RecoveryWordsScreen).182183---184185## 4. Where the brief's assumed API differed from reality (full list)186187| # | Brief assumption | Reality / what the drafts use |188|---|---|---|189| 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`. |190| 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. |191| 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. |192| 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). |193| 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. |194| 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. |195| 7 | `secureRandom(16)`, `blake3(...)` helpers; add `recoveryWordsHash` | `VaultCrypto.generateSalt()` (16B) and the existing `recoveryWordsHash(words)` cover these; `setRecoveryWords` already stores the BLAKE3 hash. |196| 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. |197| 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). |198| 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. |199| 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. |200201---202203## 5. Security notes (for reviewer)204205- The escrow blob is AES-256-GCM under a key the recovery words derive via HKDF.206 AAD `recovery_escrow_v1` is distinct from `slot_a_v1`, so an escrow blob can207 never be swapped in as a slot-A blob.208- Wrong words surface as `AEADBadTagException` (GCM tag mismatch) or an209 `IllegalArgumentException` from `wordsToRootKeyMaterial` (bad BIP39 checksum/210 unknown word) — both map to `false` / `null` in the repo, and to the211 "Incorrect recovery words" UI error.212- All transient key material (`wordsToRootKeyMaterial` entropy, the213 `SecretKeySpec.encoded` copy, the decoded plaintext) is zeroed in `finally`214 blocks, consistent with the existing repo's `VaultCrypto.zero` discipline.215- The escrow weakens the threat model deliberately: anyone with the 12 words can216 decrypt the vault without the password (that is the feature). This matches the217 browser design referenced in CONTINUITY.md. Recovery is never offered for the218 decoy/duress slot (`persistSlotAWithEscrow` refuses in `decoyMode`).