# Peach Browser — Recovery + PeachScript Specification Extracted from `peach-browser` source (read-only; never modified). Every claim cites `file:line`. Reference paths are relative to `peach-browser/`. Android paths are relative to `peach-android/app/src/main/java/com/peachpasswords/`. This document is the implementation contract for MF-1 (recovery escrow) and MF-2 (PeachScript format). A second agent should be able to implement from this without re-reading the browser source. --- ## Recovery Architecture (MF-1) ### A2.1 — Root key origin The root key is a **fully random 32-byte value**, generated by `crypto.getRandomValues(new Uint8Array(32))` (`vault.ts:282`, also `vault.ts:948` in `createVault`). It is **not** the BIP39 entropy and is **not** derived from the password. The recovery words encode only the **first 16 bytes** of it (see A2.2). The root key is the *sync identity* key — it feeds `computeVaultId`, the Noise static key, and the per-entry sync envelope (`SyncIdentity`), and it never rotates on password change. It is **not** the vault-encryption key (that is the Argon2id-derived `derivedKey`). > The audit's phrase "the root key *is* the BIP39 entropy" is imprecise: the root > key is 32 random bytes; the words capture its first 16 bytes only. The escrow > (A2.4) is what lets all 32 bytes be recovered from the 16-byte word material. ### A2.2 — Word generation Generate the random 32-byte root key **first**, then derive the 12 words from it: `rootKeyToWords(rootKeyBytes)` (`recovery-words.ts:21-55`). Process: - `entropy16 = rootKeyBytes[0..16)` (`recovery-words.ts:29`) - `checksum = SHA-256(entropy16)[0] >> 4` — top 4 bits (`recovery-words.ts:30-36`) - pack `(entropy16 << 4) | checksum` into a 132-bit big-endian integer (`recovery-words.ts:40-44`) - slice MSB-first into 12 × 11-bit indices; each index → `WORD_LIST[index]` (`recovery-words.ts:46-52`) ### A2.3 — Recovery IKM (the HKDF input keying material) `wordMaterial = wordsToRootKeyMaterial(words)` (`vault.ts:286` at seal, `vault.ts:330` at unseal). `wordsToRootKeyMaterial` (`recovery-words.ts:61-111`): - map each word to its index via `WORD_LIST.indexOf(word)` (`recovery-words.ts:72`) - repack into 132 bits, extract the top 128 bits as `entropy[16]` (`recovery-words.ts:88-92`) - verify `SHA-256(entropy)[0] >> 4 == storedChecksum`; **throw on mismatch** (`recovery-words.ts:98-105`) - return a **32-byte** buffer = `entropy(16) || zero(16)` (`recovery-words.ts:108-110`) So the IKM is **32 bytes: 16 reconstructed-entropy bytes followed by 16 zero bytes**. It is **not** BLAKE3 of the words and **not** the raw 32-byte root key. > **Duplicate-`abandon` quirk (shared latent bug).** `WORD_LIST` has `"abandon"` > at **both index 0 and index 1** (verified: browser `recovery-word-list.ts` has > 2049 entries; Android `RecoveryWordList.kt` is byte-identical — diff empty). > `indexOf("abandon")` always returns 0, so any word that `rootKeyToWords` > emitted from index 1 cannot be inverted, and `wordsToRootKeyMaterial` then > throws on the checksum. This affects ~0.6% of random root keys (P(any of 12 > 11-bit groups == 1) ≈ 12/2048). The browser's `createVault` calls > `generateAndWrapRootKey` with **no retry** (`vault.ts:947`), so those ~0.6% of > root keys make vault creation **throw**. See Implementation Notes for how > Android handles this without breaking parity. ### A2.4 — Wrapped object (what the escrow contains) The recovery escrow wraps **only the 32-byte root key**, hex-encoded to a 64-char ASCII string then UTF-8 encoded before encryption (`vault.ts:293`): `encoded = TextEncoder().encode(hex(rootKeyBytes))` → **64 bytes of ASCII hex**. It does **not** wrap the vault entries, the slot-A payload, or the settings. ### A2.5 — HKDF + AES-GCM parameters Recovery-key derivation `deriveRecoveryKey(wordMaterial, salt)` (`recovery-words.ts:117-142`): - KDF: **HKDF-SHA256** - IKM: the 32-byte `wordMaterial` from A2.3 - salt: random **16 bytes** (`recoverySalt`, `vault.ts:287`) - info: UTF-8 of **`"peach-recovery-v1"`** (`recovery-words.ts:135`) - output: a **256-bit AES-GCM** key Wrapping (`vault.ts:292-307`): - AES-**256**-GCM, **12-byte** random nonce (`vault.ts:292`) - **AAD = the single byte `0x01`** (`new Uint8Array([1])`, `vault.ts:298`) - tag length 128; WebCrypto returns `ciphertext || tag`, split last 16 bytes as the tag (`vault.ts:304-307`) - stored fields on slot A: `recovery_salt`, `recovery_wrapped_root_key` (ciphertext, hex), `recovery_nonce` (hex), `recovery_tag` (hex) (`vault.ts:963-966`) Unwrap `unwrapRootKeyForRecovery` (`vault.ts:323-355`): recompute the recovery key from the words + stored salt, AES-GCM-decrypt `(nonce, ct||tag, AAD=0x01)`, UTF-8-decode to the hex string, `unhex` → the 32-byte root key. ### A2.6 — `changePassword` behavior A password change **does not re-seal the recovery escrow**. The `recovery_*` fields are intentionally left untouched (`vault.ts:363-364` comment). Only the `pw_root_*` fields (the root key wrapped under the *password-derived* key) are re-wrapped (`rewrapRootKeyForPassword`, `vault.ts:366-375`). The same 12 recovery words therefore keep working across password changes. ### A2.7 — Word list Standard BIP39 **English 2048-word** list — but the shipped list has a **2049th entry**: `"abandon"` is duplicated at index 0 and 1 (see A2.3 quirk). Android's `RecoveryWordList.kt` is **byte-identical** to the browser list (verified via diff). No further modifications. ### A2.8 — Entropy length / word count / checksum 128 bits of entropy + 4-bit checksum = 132 bits = 12 × 11-bit words (`recovery-words.ts:8-15`). Checksum = top 4 bits of `SHA-256(entropy16)` (`recovery-words.ts:36`). The root key carries 256 bits but only its first 128 bits are word-encoded; the remaining 128 bits are recoverable solely via the escrow. ### Recovery FLOW semantics (important — not entry recovery) `RECOVER_WITH_WORDS` (`messages.ts:312-347`): unwrap the **root key** only, derive a new password key, re-wrap root key under it, then `changePasswordWithNewKey` re-encrypts the vault using the **in-memory `entries`** (`vault.ts:386`). During a forgot-password recovery the vault was never unlocked, so `entries` is empty — the browser recovery **resets the password and preserves sync identity but does not restore entries locally**; entries return via sync. The words alone never carry entries. --- ## Divergences from Android (MF-1) | # | Point | Browser | Android (current) | Status | |---|-------|---------|-------------------|--------| | 1 | Root key origin | random 32B (`vault.ts:282`) | random 32B (`VaultRepository.kt:117`) | **match** | | 2 | Word generation | `rootKeyToWords(rootKey)` first-16B (`recovery-words.ts:21`) | `rootKeyToWords(syncMasterKey())` (`RecoveryWordsScreen.kt:65`) | **match** | | 3 | Recovery IKM | `wordsToRootKeyMaterial` = 16 entropy ‖ 16 zero (`vault.ts:286`) | `wordKeyMaterial` = **BLAKE3(joined words)** (`RecoveryWords.kt:76-83`) | **DIVERGENCE** | | 4 | Wrapped object | the 32-byte root key as **ASCII hex** (`vault.ts:293`) | the **entire slot-A plaintext payload** (`VaultRepository.kt:639-654`) | **DIVERGENCE** | | 5 | HKDF (hash/info/salt/len) | SHA-256 / `peach-recovery-v1` / 16B / 32B (`recovery-words.ts:130-141`) | identical (`RecoveryWords.kt:85-91`) | **match** | | 6 | AEAD AAD | single byte `0x01` (`vault.ts:298`) | string `"recovery_escrow_v1"` (`VaultCrypto.kt:32`) | **DIVERGENCE** | | 7 | `changePassword` re-seals escrow? | **no** (`vault.ts:363-364`) | **yes** — re-seals whole-payload escrow (`VaultRepository.kt:265-289`) | divergence (Android-local; see fix) | | 8 | Word list / entropy / checksum | 2049-word list, 128+4 bits (`recovery-word-list.ts`) | byte-identical list (`RecoveryWordList.kt`) | **match** | **The three parity-breaking divergences are #3 (IKM), #4 (wrapped object), and #6 (AAD).** Fixing all three makes the **same 12 words + the same salt/nonce/ct/tag decrypt to the same 32-byte root key on both platforms.** ### Chosen fix (per orchestrator decision: "parity + keep offline restore") Android keeps its offline forgot-password restore **and** gains byte-compatible cross-platform root-key recovery: 1. Add a **browser-byte-compatible root-key escrow** on slot A: `RootKeyEscrow{ salt, wrappedRootKey, nonce, tag }` where the wrapped plaintext is the **ASCII hex of the 32-byte root key**, AES-256-GCM under `HKDF-SHA256(wordsToRootKeyMaterial(words), salt, info="peach-recovery-v1")`, **AAD = byte `0x01`**, 12-byte nonce. Byte-for-byte the browser algorithm. 2. **Keep** Android's existing whole-payload `recoveryEscrow{salt, blob}` for offline entry restore — but derive its key from the **same** `wordsToRootKeyMaterial` IKM (not BLAKE3), under a **distinct** string AAD so the two blobs can never be confused. 3. `unlockWithRecoveryWords`: prefer the whole-payload escrow (full local restore); fall back to the root-key escrow (root key only) for a vault that has only the browser-format escrow. 4. `changePassword`: leave the root-key escrow **stable** (browser parity); keep refreshing the whole-payload escrow so offline restore stays current (Android-local behavior). 5. Generate root keys whose words **round-trip** (retry loop) so the duplicate-`abandon` quirk never produces a doomed vault (see Implementation Notes). The resulting key space is a strict, fully cross-compatible subset. --- ## PeachScript Format (MF-2) PeachScript = a binary payload (`peachPayload.ts`) re-encoded into Private-Use-Area text (`peachScript.ts`). Two layers: ### B2.1 — Outer encoding (PUA text codec) — `peachScript.ts` Each payload **byte `b` → one code point `U+E000 + b`** (`peachScript.ts:16-24`); decode is the inverse, rejecting any code point outside `U+E000..U+E0FF` with `INVALID_PEACHSCRIPT_CHARACTER` (`peachScript.ts:33-44`). No base conversion, no padding. `normalisePeachScript` strips all whitespace before decode (`peachScript.ts:47-49`). One PUA char ↔ one byte; iterate by code point (`Array.from(str)`), not UTF-16 unit. ### B2.2 — Magic bytes `MAGIC = 0x50434853` = ASCII **"PCHS"**, written **big-endian** at body offset 0 (`peachPayload.ts:48,179`). Note: the magic lives in the **inner body** (inside the encrypted envelope), not at the head of the file. ### B2.3 — Outer envelope and header structure `buildPayload` returns `[ salt(16) || nonce(12) || ciphertext || tag(16) ]` (`peachPayload.ts:198-203`). Decrypting the ciphertext yields the **wrapped plaintext**: ``` wrapped = [ FORMAT(1) | inner_len(4, LE) | inner | zero-pad to size bucket ] ``` - `FORMAT = 0x03` (FORMAT_V3) (`peachPayload.ts:82-86,193-194`). Old clients see 0x03 where they expect PCHS (0x50) and fail with BAD_MAGIC — intentional. - `inner_len` = u32 little-endian length of `inner` (`peachPayload.ts:195`). - padding: zero-fill up to the next **size bucket** (B2.8) (`peachPayload.ts:196`). `inner = [ body | checksum(4) ]` (`peachPayload.ts:186`), where: ``` body = [ HEADER(8) | SECTION* ] HEADER = [ MAGIC(4, BE) | VERSION(1)=0x01 | sectionCount(1) | reserved(2)=0 ] (peachPayload.ts:177-184) ``` Each `SECTION`: ``` [ type(1) | rawLen(4, LE) | compLen(4, LE) | zstdCompressed(compLen) ] (peachPayload.ts:316-324) ``` Section types: ENTRIES `0x01`, SETTINGS `0x02`, LICENSE `0x03`, RECALL `0x04`, DECLINED_CARDS `0x05` (`peachPayload.ts:92-96`). Decoders **skip unknown section types** (`peachPayload.ts:300-303`). ### B2.4 — Encryption - KDF: **Argon2id** over the **UTF-8 bytes** of the passphrase (`peachPayload.ts:920-930`; `hash-wasm` UTF-8-encodes the string), params **m=65536 KiB, t=3, p=4, 32-byte** output, **16-byte salt**. - Cipher: **AES-256-GCM**, **12-byte** nonce, **128-bit** tag (`peachPayload.ts:944-953`). No AAD (`crypto.subtle.encrypt` called with only `iv` + `tagLength` — **no `additionalData`**). - Envelope layout: `salt(16) ‖ nonce(12) ‖ ct ‖ tag(16)` (`peachPayload.ts:203`). - Auth-tag failure on parse → `INVALID_PASSPHRASE` (`peachPayload.ts:232-233`). ### B2.5 — Compression **zstd**, **level 19**, applied **per section** to the raw section bytes (`buildSection`, `peachPayload.ts:317`). Parse decompresses each section and asserts `data.length === rawLen` else `SECTION_LENGTH_MISMATCH` (`peachPayload.ts:281-282`). Standard zstd frames — interoperable across implementations (decompression is spec-deterministic; compressed bytes are not required to match). ### B2.6 — Entry packing / string table — `peachPayload.ts:339-601` ENTRIES section body: ``` [ entryCount(4, LE) | stringTable | entryBody* ] (peachPayload.ts:412-421) stringTable = [ count(2, LE) | (len(2, LE) | utf8bytes)* ] (peachPayload.ts:355-373) ``` String table is **1-indexed**; index `0` = absent/undefined (`peachPayload.ts:344-352,391-394`). Titles are pre-interned for all entries (`peachPayload.ts:400-401`). Per-entry common prefix (`encodeEntry`, `peachPayload.ts:443-469`): ``` [ type(1) | uuid(16 raw) | flags(1) | flags2(1, v3 only) | createdSec(4, LE) | updatedDelta(4, LE) | deletedSec(4, LE) | titleIdx(2, LE) ] ``` - `uuid`: 16 raw bytes from the 32 hex chars (`peachPayload.ts:792-806`). - `createdSec = floor(createdAt/1000)`, `updatedDelta = max(0, updatedSec-createdSec)`, `deletedSec = deletedAt? floor(deletedAt/1000):0` (`peachPayload.ts:450-453`). - `flags`/`flags2` are back-patched after the body is written (`peachPayload.ts:598-599`). Flags (`peachPayload.ts:104-111`): `F_HAS_TOTP=1<<0`, `F_HAS_TAGS=1<<1`, `F_HAS_NOTES=1<<2`, `F_HAS_CANARY=1<<3`, `F_HAS_PWCHANGED=1<<4`, `F_HAS_BREACH=1<<5`, `F_HAS_ADDITIONAL_URLS=1<<6`, `F_HAS_DELETED=1<<7`. flags2 (`peachPayload.ts:122`): `F2_HAS_EMAIL=1<<0` (login, v3 only). **LOGIN** (type `0x01`, `peachPayload.ts:475-519`), written in this exact order: ``` urlIdx(2) | usernameIdx(2) | lpString password(u8 len) [ if F2_HAS_EMAIL ] lpString email(u8 len) [ if F_HAS_TOTP ] lpString totp(u8 len) [ if F_HAS_TAGS ] u8 count, count× u16 tagIdx [ if F_HAS_NOTES ] u16 notesIdx [ if F_HAS_CANARY ] u16 webhookUrlIdx, u16 labelIdx [ if F_HAS_PWCHANGED ] u32 (floor(pwChangedAt/1000) - createdSec) [ if F_HAS_BREACH ] u32 breach [ if F_HAS_ADDITIONAL_URLS ] u8 count, count× u16 urlIdx ``` `lpString` = `[ len(1 or 2) | utf8bytes ]`; password/email/totp use 1-byte length (`peachPayload.ts:856-867`). Tag/url counts capped at 0xff. **NOTE** (type `0x02`, `peachPayload.ts:520-531`): `[if F_HAS_NOTES] u16 notesIdx`, `[if F_HAS_TAGS] u8 count, count× u16 tagIdx` (notes before tags). **CARD** (type `0x03`, `peachPayload.ts:532-551`): ``` u16 cardholderIdx | lpString cardNumber(u8) | lpString expMonth(u8) | lpString expYear(u8) | lpString cvc(u8) | u16 cardNicknameIdx | u8 networkByte | u16 last4Idx [ if F_HAS_TAGS ] u8 count, count× u16 tagIdx [ if F_HAS_NOTES ] u16 notesIdx ``` `networkByte`: index into `["visa","mastercard","amex","discover","unknown"]`; absent → `0xff` (`peachPayload.ts:124,777-786`). **PASSKEY** (type `0x04`, `peachPayload.ts:552-596`): a JSON blob, `u32 len` then `len` UTF-8 bytes. The blob carries the passkey fields (rpId, rpName, credentialId, publicKeyCose, privateKeyJwk, userHandle, userName, userDisplayName, algorithm, signCount, aaguid, backupEligible, backupState, lastUsedAt) **plus the full login field set** (password, url, username, email, totp, notes, tags, canary, passwordChangedAt, breach, additionalUrls) when the entry has a nested `passkey` object. `entryTypeByte` returns PASSKEY when `entry.passkey` is set, regardless of `entry.type` (`peachPayload.ts:760-775`). ### B2.7 — Checksum **BLAKE3 truncated to the first 4 bytes** of the hash, over `body` (header + sections), appended as the last 4 bytes of `inner` (`peachPayload.ts:185-186, 988-996`). Verified with a constant-time compare before parsing; mismatch → `CHECKSUM_FAILED` (`peachPayload.ts:258-259`). It lives **inside** the AEAD; it exists to fail fast on compressed-payload corruption, not for authentication (AES-GCM's tag does that). ### B2.8 — Size-bucket padding After building `[FORMAT|len|inner]`, zero-pad up to the next bucket in `[1024, 4096, 16384, 65536, 262144]` (`peachPayload.ts:60`); beyond the largest, round up to the next power of two (`peachPayload.ts:64-78`). Padding is inside the AEAD, so it is safe zero-fill. Purpose: coarsen file-size so vault size can't be inferred (privacy). ### B2.9 — Version field Two version concepts: (a) **body VERSION = 0x01** in the header (`peachPayload.ts:49,180`), checked on parse → `UNSUPPORTED_VERSION` otherwise (`peachPayload.ts:264`); (b) **FORMAT byte** (0x01 legacy / 0x02 / **0x03 current**) as the first wrapped-plaintext byte (`peachPayload.ts:241-252`). v1 = no length prefix / no padding; v2/v3 = `[FORMAT|len|inner|pad]`; v3 adds login email + flags2. ### B2.10 — Plaintext (decrypted payload) structure Binary, not JSON. After decrypt: `[FORMAT|inner_len|inner|pad]` → `inner=[body|checksum]` → `body=[header|sections]`. SETTINGS/LICENSE/RECALL/ DECLINED_CARDS section *contents* are JSON (`encodeJsonSection`, `peachPayload.ts:327-333`); ENTRIES is binary-packed (B2.6). ### B2.11 — Fields included ENTRIES carries, per type, every field listed in B2.6 — including tags, notes, canary, breach, passwordChangedAt, additionalUrls, email (login); all card fields incl. network + last4; full passkey JSON. Timestamps: createdAt, modifiedAt (as delta), deletedAt, passwordChangedAt (as delta). The browser export also emits SETTINGS, and optionally LICENSE/RECALL/DECLINED_CARDS sections (`peachPayload.ts:158-175`). **Not** carried in ENTRIES: `passwordHistory`, `isWeak`/`isReused` (computed), favicon. ### B2.12 — Import parsing `parsePayload` is the exact reverse of `buildPayload` (`peachPayload.ts:215-310`): decrypt → detect FORMAT → strip length prefix/padding → checksum → header → loop sections (decompress, dispatch by type, **skip unknown**). Errors: `PAYLOAD_TOO_SHORT`, `INVALID_PASSPHRASE`, `BAD_MAGIC`, `UNSUPPORTED_VERSION:n`, `CHECKSUM_FAILED`, `SECTION_LENGTH_MISMATCH`, `UNKNOWN_ENTRY_TYPE:n`. --- ## Divergences from Android (MF-2) Android current PeachScript (`ui/vault/PeachScriptExport.kt`, `ui/vault/PeachScriptImport.kt`) is an **entirely different format**: a JSON object `{"peachscript":1,"encrypted":true,"kdf":"argon2id","salt":…,"blob":…}` where `blob = nonce(12) ‖ AES-256-GCM(plaintext-JSON entries)` under AAD `"peachscript_export_v1"`. It shares **no** container, magic, compression, checksum, entry packing, AAD, or PUA encoding with the browser. | # | Point | Browser | Android (current) | Status | |---|-------|---------|-------------------|--------| | 1 | Outer encoding | PUA `U+E000+byte` (`peachScript.ts`) | none (raw JSON text) | **missing** | | 2 | Magic | `PCHS` BE in body (`peachPayload.ts:48`) | `"peachscript":1` JSON key | **divergence** | | 3 | Header | 8B MAGIC/VER/count (`peachPayload.ts:177`) | JSON keys | **missing** | | 4 | Encryption | Argon2id(UTF-8) + AES-GCM, no AAD, `salt‖nonce‖ct‖tag` | Argon2id(UTF-16 `char[]`) + AES-GCM, AAD `peachscript_export_v1`, `nonce‖ct‖tag` | **divergence** | | 5 | Compression | zstd L19 per section | none | **missing** | | 6 | Entry packing / string table | binary + u16 string table | JSON, 6 fields only | **missing** | | 7 | Checksum | BLAKE3[0..4) of body | none | **missing** | | 8 | Size-bucket padding | `[1k,4k,16k,64k,256k]` | none | **missing** | | 9 | Version | body VER 0x01 + FORMAT 0x03 | `"peachscript":1` | **divergence** | | 10 | Plaintext | binary packed | JSON | **divergence** | | 11 | Fields included | all (tags/card/passkey/canary/breach/ts) | 6 (title/user/pass/url/notes/totp) | **missing** | | 12 | Import parsing | reverse of export | bespoke JSON regex parser | **divergence** | **Every point diverges or is missing.** MF-2 requires a full port (chosen: implement the browser format on Android with zstd-jni — see Implementation Notes). --- ## Implementation Notes ### Library choices (Android) - **zstd**: `com.github.luben:zstd-jni:1.5.6-3` (reference libzstd; native `.so` per ABI). `Zstd.compress(raw, 19)` and `Zstd.decompress(comp, rawLen)`. Chosen because the format mandates zstd and decompression is interoperable across implementations. **Byte-exact compressed output is not portable across zstd builds**, so the cross-platform KAT pins the *decoded structure* (and uses a browser-produced fixture for parse), not raw compressed bytes. - **BLAKE3**: BouncyCastle `Blake3Digest(256)`, first 4 bytes (already used in `RecoveryWords.kt`). - **Argon2id**: BouncyCastle `Argon2BytesGenerator` with the **UTF-8 byte** overload `generateBytes(password.toByteArray(UTF_8), out)` — matches the browser's UTF-8 KDF input for PeachScript (avoids MF-4 *within the PeachScript backup path*; MF-4 for the vault slot itself is out of scope here). - **AES-256-GCM**: JCE `AES/GCM/NoPadding`, 12-byte nonce, 128-bit tag. The PeachScript envelope uses **no AAD** (unlike the vault slots). ### [ANDROID BUG FOUND + FIXED] `rootKeyToWords` checksum sign-extension The cross-platform KAT surfaced a pre-existing Android bug: `RecoveryWords.kt` computed the BIP39 checksum as `entropyHash[0].toInt() ushr 4`. A `Byte` ≥ 0x80 sign-extends to a negative `Int`, so the unsigned shift produced a 28-bit value instead of the 4-bit checksum — corrupting `rootKeyToWords` (and `wordsToRootKeyMaterial`'s check) for ~50% of root keys (those whose `SHA-256(entropy16)[0] >= 0x80`). It was latent because the old escrow keyed off BLAKE3 and never used `wordsToRootKeyMaterial`. Fixed to `(entropyHash[0].toInt() and 0xff) ushr 4` (browser parity, `recovery-words.ts:36`). Without this, no Android recovery word would match the browser. Both occurrences fixed (`rootKeyToWords`, `wordsToRootKeyMaterial`). ### [SHARED LATENT BUG] duplicate-`abandon` root keys (~0.6%) The browser's `createVault` (`vault.ts:947`) and `migrateToRecovery` (`vault.ts:511`) call `generateAndWrapRootKey` with no retry, so ~0.6% of random root keys (those whose `rootKeyToWords` output includes an index-1 `"abandon"`) make recovery setup **throw** (`wordsToRootKeyMaterial` checksum failure). This is a genuine reference bug. **Android deviates deliberately and safely**: it generates root keys in a bounded retry loop until `rootKeyToWords` → `wordsToRootKeyMaterial` round-trips, producing a strict, fully cross-compatible **subset** of root keys. No vault Android creates is browser-incompatible, and the ~0.6% crash is avoided. (Recommend reporting upstream; `peach-browser` was not modified.) A checksum-invalid phrase entered at recovery is rejected on **both** platforms — that is correct parity, not a divergence. ### Card `last4` (relates to MF-3, out of scope) Android `VaultEntry` has no `last4` field (the browser does). For PeachScript round-trip the codec derives `last4` from the last 4 digits of `cardNumber` on export and drops the browser's `last4` on import (the full `cardNumber` already carries it). Android `cardNetwork` (String) maps to/from the browser `network` byte by the same value set. No `VaultEntry` change is made here; MF-3 (rename + `last4`) remains a separate fix. ### Tests that change behavior (necessary, not regressions) - `RecoveryEscrowTest` test #6 (`escrow_round_trips_for_phrase_with_duplicate_abandon`) asserts the **old BLAKE3** behavior (round-trips a checksum-invalid all-`abandon` phrase). Under browser parity that phrase is **rejected**. The test is rewritten to assert rejection of a checksum-invalid phrase (matching the browser) + a parity KAT is added. Count preserved. - `ExportRoundTripTest` PeachScript cases and `ImportParsersTest` PeachScript cases assert the **old JSON** format being replaced; they are updated to the new format. New `PeachScriptTest.kt` adds KATs + full-fidelity round-trip. --- ## Integration Contract ### New files (drafts → integrate into `app/src/main/java/com/peachpasswords/`) - `vault/PeachScript.kt` — PUA text codec (`encodePeachScript`/`decodePeachScript`/ `normalisePeachScript`/`isPeachScript`). - `vault/PeachPayloadCodec.kt` — binary PCHS payload (`buildPayload`/`parsePayload`, string table, entry packing, zstd, BLAKE3, Argon2id-UTF8, AES-GCM, uuid/network). ### Files to replace - `ui/vault/PeachScriptExport.kt` — `buildEncrypted(entries, backupPassword): String` now returns the PUA-encoded PCHS payload. (Drop the JSON `buildPlaintext`.) - `ui/vault/PeachScriptImport.kt` — `looksLikePeachScript` → PUA sniff; `isEncrypted` → always true; `decryptAndParse` / new `parseFull` return **full** `VaultEntry`. - `vault/RecoveryWords.kt` — recovery IKM via `wordsToRootKeyMaterial` (retire `wordKeyMaterial` from the escrow path); add round-trip-checked root-key gen helper. - `vault/VaultCrypto.kt` — add raw-`ByteArray`-AAD encrypt/decrypt + `AAD_RECOVERY_ROOTKEY = byte 0x01`. - `vault/VaultStore.kt` — add `RootKeyEscrow{salt,wrappedRootKey,nonce,tag}` to `StoredVault`. - `vault/VaultRepository.kt` — seal/unseal both escrows; round-trip-checked root-key gen; leave root-key escrow stable on `changePassword`. ### Files to update (integration) - `ui/vault/ImportScreen.kt` — PeachScript path consumes full `VaultEntry` (preserve all fields). - `ui/vault/ExportScreen.kt` — saves `.peach` (unchanged call shape). - Tests: `RecoveryEscrowTest.kt`, `ExportRoundTripTest.kt`, `ImportParsersTest.kt` updated; add `RecoveryEscrowTest`/`PeachScriptTest` KATs. ### New dependency (`app/build.gradle`) ```gradle implementation "com.github.luben:zstd-jni:1.5.6-3@aar" // Android jniLibs (lib//*.so) testImplementation "com.github.luben:zstd-jni:1.5.6-3" // desktop natives for host unit tests ``` The plain jar bundles only desktop natives (would `UnsatisfiedLinkError` on-device); the `@aar` ships the Android `.so`. Both are needed: the AAR for the device, the jar for JVM unit tests. Verified: APK contains `lib/{arm64-v8a,armeabi-v7a,x86,x86_64}/libzstd-jni-1.5.6-3.so`. No `AndroidManifest.xml` changes. ### Integration order 1. zstd-jni dependency → sync. 2. MF-1: `VaultCrypto` + `VaultStore` + `RecoveryWords` + `VaultRepository` → build → `RecoveryEscrowTest`. 3. MF-2: `PeachScript` + `PeachPayloadCodec` → build → `PeachScriptTest`. 4. Replace `PeachScriptExport`/`Import`, update `ImportScreen` → build → `ExportRoundTripTest`/`ImportParsersTest`. 5. `assembleDebug` + `lintDebug` + full unit suite. ### Known-answer vectors the orchestrator must verify against the browser - **MF-1 KAT**: fixed 32-byte root key → fixed words (`rootKeyToWords`); fixed recovery salt → `deriveRecoveryKey(wordsToRootKeyMaterial(words), salt)` → wrap `hex(rootKey)` (AAD 0x01) → confirm a browser-computed `recovery_wrapped_root_key` unwraps to the same root key. (Generate the browser vector with a tiny Node script against `recovery-words.ts`/`vault.ts` if a live cross-check is wanted.) - **MF-2 KAT**: a small browser-produced `.peach` blob (PUA text) → Android `parseFull` yields the expected entries (pins PUA + envelope + zstd + packing). Plus Android self-round-trip and Unicode/empty-vault/wrong-password cases.