C1-INTEGRATION.md
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 underui.vaultorui.lock, change thepackageline and the one PeachAppNavHost import — nothing else depends on the package.
Public composable signature:
@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:
@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:
const val AAD_RECOVERY = "recovery_escrow_v1"
2c. vault/VaultRepository.kt — methods + edits (from C1-repo-additions.kt)
- REPLACE the body of the existing
setRecoveryWordsso it also seals the escrow (snippet §3a) — addsbuildRecoveryEscrow()andpersistSlotAWithEscrow()private helpers. - Edit
changePassword(snippet §3b): the singleval next = StoredVault(...)line must carryrecoveryEscrow = slotA.recoveryEscrowforward. The escrow seals the slot-A plaintext, which a password change does not alter, so it stays valid — preserve it, do not regenerate. - ADD
unlockWithRecoveryWords(words): Boolean,resetPasswordWithRecoveryWords(words, newPassword): Boolean, andhasRecoveryEscrow(): Boolean(snippet §3c). - Add import
import javax.crypto.AEADBadTagException(referenced in notes; the snippet itself catches broadException, so this import is optional).
API-shape deviation from the brief, flagged: the brief specifies
unlockWithRecoveryWords(words): ByteArray?andresetPasswordWithRecoveryKey(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,unlockWithRecoveryWordsreturnsBooleanand adopts that plaintext as the live session, andresetPasswordWithRecoveryWords(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):
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:
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:
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 Kotlinval WORD_LISTinvault/RecoveryWordList.kt, already used bygetWordSuggestions,findInvalidWords,wordsToRootKeyMaterial. The screen importsWORD_LISTandgetWordSuggestionsdirectly. 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 existingderiveRecoveryKey(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_v1is distinct fromslot_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 anIllegalArgumentExceptionfromwordsToRootKeyMaterial(bad BIP39 checksum/ unknown word) — both map tofalse/nullin the repo, and to the "Incorrect recovery words" UI error. - All transient key material (
wordsToRootKeyMaterialentropy, theSecretKeySpec.encodedcopy, the decoded plaintext) is zeroed infinallyblocks, consistent with the existing repo'sVaultCrypto.zerodiscipline. - 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 (
persistSlotAWithEscrowrefuses indecoyMode).