C1-INTEGRATION.md

11.5 KB · raw

rendered · source

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:

@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)

  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):

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


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)