BROWSER-SPEC.md

26.8 KB · raw

rendered · source

1# Peach Browser — Recovery + PeachScript Specification23Extracted from `peach-browser` source (read-only; never modified). Every claim cites `file:line`.4Reference paths are relative to `peach-browser/`. Android paths are relative to5`peach-android/app/src/main/java/com/peachpasswords/`.67This document is the implementation contract for MF-1 (recovery escrow) and MF-28(PeachScript format). A second agent should be able to implement from this without9re-reading the browser source.1011---1213## Recovery Architecture (MF-1)1415### A2.1 — Root key origin16The root key is a **fully random 32-byte value**, generated by17`crypto.getRandomValues(new Uint8Array(32))` (`vault.ts:282`, also `vault.ts:948`18in `createVault`). It is **not** the BIP39 entropy and is **not** derived from19the password. The recovery words encode only the **first 16 bytes** of it (see20A2.2). The root key is the *sync identity* key — it feeds `computeVaultId`, the21Noise static key, and the per-entry sync envelope (`SyncIdentity`), and it never22rotates on password change. It is **not** the vault-encryption key (that is the23Argon2id-derived `derivedKey`).2425> The audit's phrase "the root key *is* the BIP39 entropy" is imprecise: the root26> key is 32 random bytes; the words capture its first 16 bytes only. The escrow27> (A2.4) is what lets all 32 bytes be recovered from the 16-byte word material.2829### A2.2 — Word generation30Generate the random 32-byte root key **first**, then derive the 12 words from it:31`rootKeyToWords(rootKeyBytes)` (`recovery-words.ts:21-55`). Process:32- `entropy16 = rootKeyBytes[0..16)` (`recovery-words.ts:29`)33- `checksum = SHA-256(entropy16)[0] >> 4` — top 4 bits (`recovery-words.ts:30-36`)34- pack `(entropy16 << 4) | checksum` into a 132-bit big-endian integer35  (`recovery-words.ts:40-44`)36- slice MSB-first into 12 × 11-bit indices; each index → `WORD_LIST[index]`37  (`recovery-words.ts:46-52`)3839### A2.3 — Recovery IKM (the HKDF input keying material)40`wordMaterial = wordsToRootKeyMaterial(words)` (`vault.ts:286` at seal,41`vault.ts:330` at unseal). `wordsToRootKeyMaterial` (`recovery-words.ts:61-111`):42- map each word to its index via `WORD_LIST.indexOf(word)` (`recovery-words.ts:72`)43- repack into 132 bits, extract the top 128 bits as `entropy[16]`44  (`recovery-words.ts:88-92`)45- verify `SHA-256(entropy)[0] >> 4 == storedChecksum`; **throw on mismatch**46  (`recovery-words.ts:98-105`)47- return a **32-byte** buffer = `entropy(16) || zero(16)` (`recovery-words.ts:108-110`)4849So the IKM is **32 bytes: 16 reconstructed-entropy bytes followed by 16 zero50bytes**. It is **not** BLAKE3 of the words and **not** the raw 32-byte root key.5152> **Duplicate-`abandon` quirk (shared latent bug).** `WORD_LIST` has `"abandon"`53> at **both index 0 and index 1** (verified: browser `recovery-word-list.ts` has54> 2049 entries; Android `RecoveryWordList.kt` is byte-identical — diff empty).55> `indexOf("abandon")` always returns 0, so any word that `rootKeyToWords`56> emitted from index 1 cannot be inverted, and `wordsToRootKeyMaterial` then57> throws on the checksum. This affects ~0.6% of random root keys (P(any of 1258> 11-bit groups == 1) ≈ 12/2048). The browser's `createVault` calls59> `generateAndWrapRootKey` with **no retry** (`vault.ts:947`), so those ~0.6% of60> root keys make vault creation **throw**. See Implementation Notes for how61> Android handles this without breaking parity.6263### A2.4 — Wrapped object (what the escrow contains)64The recovery escrow wraps **only the 32-byte root key**, hex-encoded to a 64-char65ASCII string then UTF-8 encoded before encryption (`vault.ts:293`):66`encoded = TextEncoder().encode(hex(rootKeyBytes))` → **64 bytes of ASCII hex**.67It does **not** wrap the vault entries, the slot-A payload, or the settings.6869### A2.5 — HKDF + AES-GCM parameters70Recovery-key derivation `deriveRecoveryKey(wordMaterial, salt)`71(`recovery-words.ts:117-142`):72- KDF: **HKDF-SHA256**73- IKM: the 32-byte `wordMaterial` from A2.374- salt: random **16 bytes** (`recoverySalt`, `vault.ts:287`)75- info: UTF-8 of **`"peach-recovery-v1"`** (`recovery-words.ts:135`)76- output: a **256-bit AES-GCM** key7778Wrapping (`vault.ts:292-307`):79- AES-**256**-GCM, **12-byte** random nonce (`vault.ts:292`)80- **AAD = the single byte `0x01`** (`new Uint8Array([1])`, `vault.ts:298`)81- tag length 128; WebCrypto returns `ciphertext || tag`, split last 16 bytes as82  the tag (`vault.ts:304-307`)83- stored fields on slot A: `recovery_salt`, `recovery_wrapped_root_key`84  (ciphertext, hex), `recovery_nonce` (hex), `recovery_tag` (hex)85  (`vault.ts:963-966`)8687Unwrap `unwrapRootKeyForRecovery` (`vault.ts:323-355`): recompute the recovery88key from the words + stored salt, AES-GCM-decrypt `(nonce, ct||tag, AAD=0x01)`,89UTF-8-decode to the hex string, `unhex` → the 32-byte root key.9091### A2.6 — `changePassword` behavior92A password change **does not re-seal the recovery escrow**. The `recovery_*`93fields are intentionally left untouched (`vault.ts:363-364` comment). Only the94`pw_root_*` fields (the root key wrapped under the *password-derived* key) are95re-wrapped (`rewrapRootKeyForPassword`, `vault.ts:366-375`). The same 12 recovery96words therefore keep working across password changes.9798### A2.7 — Word list99Standard BIP39 **English 2048-word** list — but the shipped list has a **2049th100entry**: `"abandon"` is duplicated at index 0 and 1 (see A2.3 quirk). Android's101`RecoveryWordList.kt` is **byte-identical** to the browser list (verified via102diff). No further modifications.103104### A2.8 — Entropy length / word count / checksum105128 bits of entropy + 4-bit checksum = 132 bits = 12 × 11-bit words106(`recovery-words.ts:8-15`). Checksum = top 4 bits of `SHA-256(entropy16)`107(`recovery-words.ts:36`). The root key carries 256 bits but only its first 128108bits are word-encoded; the remaining 128 bits are recoverable solely via the109escrow.110111### Recovery FLOW semantics (important — not entry recovery)112`RECOVER_WITH_WORDS` (`messages.ts:312-347`): unwrap the **root key** only, derive113a new password key, re-wrap root key under it, then `changePasswordWithNewKey`114re-encrypts the vault using the **in-memory `entries`** (`vault.ts:386`). During a115forgot-password recovery the vault was never unlocked, so `entries` is empty — the116browser recovery **resets the password and preserves sync identity but does not117restore entries locally**; entries return via sync. The words alone never carry118entries.119120---121122## Divergences from Android (MF-1)123124| # | Point | Browser | Android (current) | Status |125|---|-------|---------|-------------------|--------|126| 1 | Root key origin | random 32B (`vault.ts:282`) | random 32B (`VaultRepository.kt:117`) | **match** |127| 2 | Word generation | `rootKeyToWords(rootKey)` first-16B (`recovery-words.ts:21`) | `rootKeyToWords(syncMasterKey())` (`RecoveryWordsScreen.kt:65`) | **match** |128| 3 | Recovery IKM | `wordsToRootKeyMaterial` = 16 entropy ‖ 16 zero (`vault.ts:286`) | `wordKeyMaterial` = **BLAKE3(joined words)** (`RecoveryWords.kt:76-83`) | **DIVERGENCE** |129| 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** |130| 5 | HKDF (hash/info/salt/len) | SHA-256 / `peach-recovery-v1` / 16B / 32B (`recovery-words.ts:130-141`) | identical (`RecoveryWords.kt:85-91`) | **match** |131| 6 | AEAD AAD | single byte `0x01` (`vault.ts:298`) | string `"recovery_escrow_v1"` (`VaultCrypto.kt:32`) | **DIVERGENCE** |132| 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) |133| 8 | Word list / entropy / checksum | 2049-word list, 128+4 bits (`recovery-word-list.ts`) | byte-identical list (`RecoveryWordList.kt`) | **match** |134135**The three parity-breaking divergences are #3 (IKM), #4 (wrapped object), and136#6 (AAD).** Fixing all three makes the **same 12 words + the same salt/nonce/ct/tag137decrypt to the same 32-byte root key on both platforms.**138139### Chosen fix (per orchestrator decision: "parity + keep offline restore")140Android keeps its offline forgot-password restore **and** gains byte-compatible141cross-platform root-key recovery:1421431. Add a **browser-byte-compatible root-key escrow** on slot A:144   `RootKeyEscrow{ salt, wrappedRootKey, nonce, tag }` where the wrapped plaintext145   is the **ASCII hex of the 32-byte root key**, AES-256-GCM under146   `HKDF-SHA256(wordsToRootKeyMaterial(words), salt, info="peach-recovery-v1")`,147   **AAD = byte `0x01`**, 12-byte nonce. Byte-for-byte the browser algorithm.1482. **Keep** Android's existing whole-payload `recoveryEscrow{salt, blob}` for149   offline entry restore — but derive its key from the **same**150   `wordsToRootKeyMaterial` IKM (not BLAKE3), under a **distinct** string AAD so151   the two blobs can never be confused.1523. `unlockWithRecoveryWords`: prefer the whole-payload escrow (full local153   restore); fall back to the root-key escrow (root key only) for a vault that154   has only the browser-format escrow.1554. `changePassword`: leave the root-key escrow **stable** (browser parity); keep156   refreshing the whole-payload escrow so offline restore stays current157   (Android-local behavior).1585. Generate root keys whose words **round-trip** (retry loop) so the159   duplicate-`abandon` quirk never produces a doomed vault (see Implementation160   Notes). The resulting key space is a strict, fully cross-compatible subset.161162---163164## PeachScript Format (MF-2)165166PeachScript = a binary payload (`peachPayload.ts`) re-encoded into Private-Use-Area167text (`peachScript.ts`). Two layers:168169### B2.1 — Outer encoding (PUA text codec) — `peachScript.ts`170Each payload **byte `b` → one code point `U+E000 + b`** (`peachScript.ts:16-24`);171decode is the inverse, rejecting any code point outside `U+E000..U+E0FF` with172`INVALID_PEACHSCRIPT_CHARACTER` (`peachScript.ts:33-44`). No base conversion, no173padding. `normalisePeachScript` strips all whitespace before decode174(`peachScript.ts:47-49`). One PUA char ↔ one byte; iterate by code point175(`Array.from(str)`), not UTF-16 unit.176177### B2.2 — Magic bytes178`MAGIC = 0x50434853` = ASCII **"PCHS"**, written **big-endian** at body offset 0179(`peachPayload.ts:48,179`). Note: the magic lives in the **inner body** (inside180the encrypted envelope), not at the head of the file.181182### B2.3 — Outer envelope and header structure183`buildPayload` returns `[ salt(16) || nonce(12) || ciphertext || tag(16) ]`184(`peachPayload.ts:198-203`). Decrypting the ciphertext yields the **wrapped185plaintext**:186187```188wrapped = [ FORMAT(1) | inner_len(4, LE) | inner | zero-pad to size bucket ]189```190- `FORMAT = 0x03` (FORMAT_V3) (`peachPayload.ts:82-86,193-194`). Old clients see191  0x03 where they expect PCHS (0x50) and fail with BAD_MAGIC — intentional.192- `inner_len` = u32 little-endian length of `inner` (`peachPayload.ts:195`).193- padding: zero-fill up to the next **size bucket** (B2.8) (`peachPayload.ts:196`).194195`inner = [ body | checksum(4) ]` (`peachPayload.ts:186`), where:196```197body = [ HEADER(8) | SECTION* ]198HEADER = [ MAGIC(4, BE) | VERSION(1)=0x01 | sectionCount(1) | reserved(2)=0 ]199                                                       (peachPayload.ts:177-184)200```201Each `SECTION`:202```203[ type(1) | rawLen(4, LE) | compLen(4, LE) | zstdCompressed(compLen) ]204                                                       (peachPayload.ts:316-324)205```206Section types: ENTRIES `0x01`, SETTINGS `0x02`, LICENSE `0x03`, RECALL `0x04`,207DECLINED_CARDS `0x05` (`peachPayload.ts:92-96`). Decoders **skip unknown section208types** (`peachPayload.ts:300-303`).209210### B2.4 — Encryption211- KDF: **Argon2id** over the **UTF-8 bytes** of the passphrase212  (`peachPayload.ts:920-930`; `hash-wasm` UTF-8-encodes the string), params213  **m=65536 KiB, t=3, p=4, 32-byte** output, **16-byte salt**.214- Cipher: **AES-256-GCM**, **12-byte** nonce, **128-bit** tag215  (`peachPayload.ts:944-953`). No AAD (`crypto.subtle.encrypt` called with only216  `iv` + `tagLength` — **no `additionalData`**).217- Envelope layout: `salt(16) ‖ nonce(12) ‖ ct ‖ tag(16)` (`peachPayload.ts:203`).218- Auth-tag failure on parse → `INVALID_PASSPHRASE` (`peachPayload.ts:232-233`).219220### B2.5 — Compression221**zstd**, **level 19**, applied **per section** to the raw section bytes222(`buildSection`, `peachPayload.ts:317`). Parse decompresses each section and223asserts `data.length === rawLen` else `SECTION_LENGTH_MISMATCH`224(`peachPayload.ts:281-282`). Standard zstd frames — interoperable across225implementations (decompression is spec-deterministic; compressed bytes are not226required to match).227228### B2.6 — Entry packing / string table — `peachPayload.ts:339-601`229ENTRIES section body:230```231[ entryCount(4, LE) | stringTable | entryBody* ]            (peachPayload.ts:412-421)232stringTable = [ count(2, LE) | (len(2, LE) | utf8bytes)* ]  (peachPayload.ts:355-373)233```234String table is **1-indexed**; index `0` = absent/undefined235(`peachPayload.ts:344-352,391-394`). Titles are pre-interned for all entries236(`peachPayload.ts:400-401`).237238Per-entry common prefix (`encodeEntry`, `peachPayload.ts:443-469`):239```240[ type(1) | uuid(16 raw) | flags(1) | flags2(1, v3 only)241  | createdSec(4, LE) | updatedDelta(4, LE) | deletedSec(4, LE) | titleIdx(2, LE) ]242```243- `uuid`: 16 raw bytes from the 32 hex chars (`peachPayload.ts:792-806`).244- `createdSec = floor(createdAt/1000)`, `updatedDelta = max(0, updatedSec-createdSec)`,245  `deletedSec = deletedAt? floor(deletedAt/1000):0` (`peachPayload.ts:450-453`).246- `flags`/`flags2` are back-patched after the body is written247  (`peachPayload.ts:598-599`).248249Flags (`peachPayload.ts:104-111`): `F_HAS_TOTP=1<<0`, `F_HAS_TAGS=1<<1`,250`F_HAS_NOTES=1<<2`, `F_HAS_CANARY=1<<3`, `F_HAS_PWCHANGED=1<<4`, `F_HAS_BREACH=1<<5`,251`F_HAS_ADDITIONAL_URLS=1<<6`, `F_HAS_DELETED=1<<7`. flags2 (`peachPayload.ts:122`):252`F2_HAS_EMAIL=1<<0` (login, v3 only).253254**LOGIN** (type `0x01`, `peachPayload.ts:475-519`), written in this exact order:255```256urlIdx(2) | usernameIdx(2) | lpString password(u8 len)257[ if F2_HAS_EMAIL ] lpString email(u8 len)258[ if F_HAS_TOTP   ] lpString totp(u8 len)259[ if F_HAS_TAGS   ] u8 count, count× u16 tagIdx260[ if F_HAS_NOTES  ] u16 notesIdx261[ if F_HAS_CANARY ] u16 webhookUrlIdx, u16 labelIdx262[ if F_HAS_PWCHANGED ] u32 (floor(pwChangedAt/1000) - createdSec)263[ if F_HAS_BREACH ] u32 breach264[ if F_HAS_ADDITIONAL_URLS ] u8 count, count× u16 urlIdx265```266`lpString` = `[ len(1 or 2) | utf8bytes ]`; password/email/totp use 1-byte length267(`peachPayload.ts:856-867`). Tag/url counts capped at 0xff.268269**NOTE** (type `0x02`, `peachPayload.ts:520-531`): `[if F_HAS_NOTES] u16 notesIdx`,270`[if F_HAS_TAGS] u8 count, count× u16 tagIdx` (notes before tags).271272**CARD** (type `0x03`, `peachPayload.ts:532-551`):273```274u16 cardholderIdx | lpString cardNumber(u8) | lpString expMonth(u8)275| lpString expYear(u8) | lpString cvc(u8) | u16 cardNicknameIdx276| u8 networkByte | u16 last4Idx277[ if F_HAS_TAGS ] u8 count, count× u16 tagIdx278[ if F_HAS_NOTES ] u16 notesIdx279```280`networkByte`: index into `["visa","mastercard","amex","discover","unknown"]`;281absent → `0xff` (`peachPayload.ts:124,777-786`).282283**PASSKEY** (type `0x04`, `peachPayload.ts:552-596`): a JSON blob, `u32 len` then284`len` UTF-8 bytes. The blob carries the passkey fields (rpId, rpName, credentialId,285publicKeyCose, privateKeyJwk, userHandle, userName, userDisplayName, algorithm,286signCount, aaguid, backupEligible, backupState, lastUsedAt) **plus the full login287field set** (password, url, username, email, totp, notes, tags, canary,288passwordChangedAt, breach, additionalUrls) when the entry has a nested `passkey`289object. `entryTypeByte` returns PASSKEY when `entry.passkey` is set, regardless of290`entry.type` (`peachPayload.ts:760-775`).291292### B2.7 — Checksum293**BLAKE3 truncated to the first 4 bytes** of the hash, over `body` (header +294sections), appended as the last 4 bytes of `inner` (`peachPayload.ts:185-186,295988-996`). Verified with a constant-time compare before parsing; mismatch →296`CHECKSUM_FAILED` (`peachPayload.ts:258-259`). It lives **inside** the AEAD; it297exists to fail fast on compressed-payload corruption, not for authentication298(AES-GCM's tag does that).299300### B2.8 — Size-bucket padding301After building `[FORMAT|len|inner]`, zero-pad up to the next bucket in302`[1024, 4096, 16384, 65536, 262144]` (`peachPayload.ts:60`); beyond the largest,303round up to the next power of two (`peachPayload.ts:64-78`). Padding is inside the304AEAD, so it is safe zero-fill. Purpose: coarsen file-size so vault size can't be305inferred (privacy).306307### B2.9 — Version field308Two version concepts: (a) **body VERSION = 0x01** in the header309(`peachPayload.ts:49,180`), checked on parse → `UNSUPPORTED_VERSION` otherwise310(`peachPayload.ts:264`); (b) **FORMAT byte** (0x01 legacy / 0x02 / **0x03 current**)311as the first wrapped-plaintext byte (`peachPayload.ts:241-252`). v1 = no length312prefix / no padding; v2/v3 = `[FORMAT|len|inner|pad]`; v3 adds login email + flags2.313314### B2.10 — Plaintext (decrypted payload) structure315Binary, not JSON. After decrypt: `[FORMAT|inner_len|inner|pad]` →316`inner=[body|checksum]` → `body=[header|sections]`. SETTINGS/LICENSE/RECALL/317DECLINED_CARDS section *contents* are JSON (`encodeJsonSection`,318`peachPayload.ts:327-333`); ENTRIES is binary-packed (B2.6).319320### B2.11 — Fields included321ENTRIES carries, per type, every field listed in B2.6 — including tags, notes,322canary, breach, passwordChangedAt, additionalUrls, email (login); all card fields323incl. network + last4; full passkey JSON. Timestamps: createdAt, modifiedAt (as324delta), deletedAt, passwordChangedAt (as delta). The browser export also emits325SETTINGS, and optionally LICENSE/RECALL/DECLINED_CARDS sections326(`peachPayload.ts:158-175`). **Not** carried in ENTRIES: `passwordHistory`,327`isWeak`/`isReused` (computed), favicon.328329### B2.12 — Import parsing330`parsePayload` is the exact reverse of `buildPayload` (`peachPayload.ts:215-310`):331decrypt → detect FORMAT → strip length prefix/padding → checksum → header → loop332sections (decompress, dispatch by type, **skip unknown**). Errors:333`PAYLOAD_TOO_SHORT`, `INVALID_PASSPHRASE`, `BAD_MAGIC`, `UNSUPPORTED_VERSION:n`,334`CHECKSUM_FAILED`, `SECTION_LENGTH_MISMATCH`, `UNKNOWN_ENTRY_TYPE:n`.335336---337338## Divergences from Android (MF-2)339340Android current PeachScript (`ui/vault/PeachScriptExport.kt`,341`ui/vault/PeachScriptImport.kt`) is an **entirely different format**: a JSON object342`{"peachscript":1,"encrypted":true,"kdf":"argon2id","salt":…,"blob":…}` where343`blob = nonce(12) ‖ AES-256-GCM(plaintext-JSON entries)` under AAD344`"peachscript_export_v1"`. It shares **no** container, magic, compression, checksum,345entry packing, AAD, or PUA encoding with the browser.346347| # | Point | Browser | Android (current) | Status |348|---|-------|---------|-------------------|--------|349| 1 | Outer encoding | PUA `U+E000+byte` (`peachScript.ts`) | none (raw JSON text) | **missing** |350| 2 | Magic | `PCHS` BE in body (`peachPayload.ts:48`) | `"peachscript":1` JSON key | **divergence** |351| 3 | Header | 8B MAGIC/VER/count (`peachPayload.ts:177`) | JSON keys | **missing** |352| 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** |353| 5 | Compression | zstd L19 per section | none | **missing** |354| 6 | Entry packing / string table | binary + u16 string table | JSON, 6 fields only | **missing** |355| 7 | Checksum | BLAKE3[0..4) of body | none | **missing** |356| 8 | Size-bucket padding | `[1k,4k,16k,64k,256k]` | none | **missing** |357| 9 | Version | body VER 0x01 + FORMAT 0x03 | `"peachscript":1` | **divergence** |358| 10 | Plaintext | binary packed | JSON | **divergence** |359| 11 | Fields included | all (tags/card/passkey/canary/breach/ts) | 6 (title/user/pass/url/notes/totp) | **missing** |360| 12 | Import parsing | reverse of export | bespoke JSON regex parser | **divergence** |361362**Every point diverges or is missing.** MF-2 requires a full port (chosen:363implement the browser format on Android with zstd-jni — see Implementation Notes).364365---366367## Implementation Notes368369### Library choices (Android)370- **zstd**: `com.github.luben:zstd-jni:1.5.6-3` (reference libzstd; native `.so`371  per ABI). `Zstd.compress(raw, 19)` and `Zstd.decompress(comp, rawLen)`. Chosen372  because the format mandates zstd and decompression is interoperable across373  implementations. **Byte-exact compressed output is not portable across zstd374  builds**, so the cross-platform KAT pins the *decoded structure* (and uses a375  browser-produced fixture for parse), not raw compressed bytes.376- **BLAKE3**: BouncyCastle `Blake3Digest(256)`, first 4 bytes (already used in377  `RecoveryWords.kt`).378- **Argon2id**: BouncyCastle `Argon2BytesGenerator` with the **UTF-8 byte**379  overload `generateBytes(password.toByteArray(UTF_8), out)` — matches the380  browser's UTF-8 KDF input for PeachScript (avoids MF-4 *within the PeachScript381  backup path*; MF-4 for the vault slot itself is out of scope here).382- **AES-256-GCM**: JCE `AES/GCM/NoPadding`, 12-byte nonce, 128-bit tag. The383  PeachScript envelope uses **no AAD** (unlike the vault slots).384385### [ANDROID BUG FOUND + FIXED] `rootKeyToWords` checksum sign-extension386The cross-platform KAT surfaced a pre-existing Android bug: `RecoveryWords.kt`387computed the BIP39 checksum as `entropyHash[0].toInt() ushr 4`. A `Byte` ≥ 0x80388sign-extends to a negative `Int`, so the unsigned shift produced a 28-bit value389instead of the 4-bit checksum — corrupting `rootKeyToWords` (and390`wordsToRootKeyMaterial`'s check) for ~50% of root keys (those whose391`SHA-256(entropy16)[0] >= 0x80`). It was latent because the old escrow keyed off392BLAKE3 and never used `wordsToRootKeyMaterial`. Fixed to393`(entropyHash[0].toInt() and 0xff) ushr 4` (browser parity, `recovery-words.ts:36`).394Without this, no Android recovery word would match the browser. Both occurrences395fixed (`rootKeyToWords`, `wordsToRootKeyMaterial`).396397### [SHARED LATENT BUG] duplicate-`abandon` root keys (~0.6%)398The browser's `createVault` (`vault.ts:947`) and `migrateToRecovery`399(`vault.ts:511`) call `generateAndWrapRootKey` with no retry, so ~0.6% of random400root keys (those whose `rootKeyToWords` output includes an index-1 `"abandon"`)401make recovery setup **throw** (`wordsToRootKeyMaterial` checksum failure). This is402a genuine reference bug. **Android deviates deliberately and safely**: it403generates root keys in a bounded retry loop until `rootKeyToWords` →404`wordsToRootKeyMaterial` round-trips, producing a strict, fully cross-compatible405**subset** of root keys. No vault Android creates is browser-incompatible, and the406~0.6% crash is avoided. (Recommend reporting upstream; `peach-browser` was not407modified.) A checksum-invalid phrase entered at recovery is rejected on **both**408platforms — that is correct parity, not a divergence.409410### Card `last4` (relates to MF-3, out of scope)411Android `VaultEntry` has no `last4` field (the browser does). For PeachScript412round-trip the codec derives `last4` from the last 4 digits of `cardNumber` on413export and drops the browser's `last4` on import (the full `cardNumber` already414carries it). Android `cardNetwork` (String) maps to/from the browser `network`415byte by the same value set. No `VaultEntry` change is made here; MF-3 (rename +416`last4`) remains a separate fix.417418### Tests that change behavior (necessary, not regressions)419- `RecoveryEscrowTest` test #6 (`escrow_round_trips_for_phrase_with_duplicate_abandon`)420  asserts the **old BLAKE3** behavior (round-trips a checksum-invalid all-`abandon`421  phrase). Under browser parity that phrase is **rejected**. The test is rewritten422  to assert rejection of a checksum-invalid phrase (matching the browser) + a423  parity KAT is added. Count preserved.424- `ExportRoundTripTest` PeachScript cases and `ImportParsersTest` PeachScript cases425  assert the **old JSON** format being replaced; they are updated to the new426  format. New `PeachScriptTest.kt` adds KATs + full-fidelity round-trip.427428---429430## Integration Contract431432### New files (drafts → integrate into `app/src/main/java/com/peachpasswords/`)433- `vault/PeachScript.kt` — PUA text codec (`encodePeachScript`/`decodePeachScript`/434  `normalisePeachScript`/`isPeachScript`).435- `vault/PeachPayloadCodec.kt` — binary PCHS payload (`buildPayload`/`parsePayload`,436  string table, entry packing, zstd, BLAKE3, Argon2id-UTF8, AES-GCM, uuid/network).437438### Files to replace439- `ui/vault/PeachScriptExport.kt` — `buildEncrypted(entries, backupPassword): String`440  now returns the PUA-encoded PCHS payload. (Drop the JSON `buildPlaintext`.)441- `ui/vault/PeachScriptImport.kt` — `looksLikePeachScript` → PUA sniff; `isEncrypted`442  → always true; `decryptAndParse` / new `parseFull` return **full** `VaultEntry`.443- `vault/RecoveryWords.kt` — recovery IKM via `wordsToRootKeyMaterial` (retire444  `wordKeyMaterial` from the escrow path); add round-trip-checked root-key gen helper.445- `vault/VaultCrypto.kt` — add raw-`ByteArray`-AAD encrypt/decrypt + `AAD_RECOVERY_ROOTKEY = byte 0x01`.446- `vault/VaultStore.kt` — add `RootKeyEscrow{salt,wrappedRootKey,nonce,tag}` to `StoredVault`.447- `vault/VaultRepository.kt` — seal/unseal both escrows; round-trip-checked root-key gen;448  leave root-key escrow stable on `changePassword`.449450### Files to update (integration)451- `ui/vault/ImportScreen.kt` — PeachScript path consumes full `VaultEntry` (preserve all fields).452- `ui/vault/ExportScreen.kt` — saves `.peach` (unchanged call shape).453- Tests: `RecoveryEscrowTest.kt`, `ExportRoundTripTest.kt`, `ImportParsersTest.kt` updated;454  add `RecoveryEscrowTest`/`PeachScriptTest` KATs.455456### New dependency (`app/build.gradle`)457```gradle458implementation "com.github.luben:zstd-jni:1.5.6-3@aar"   // Android jniLibs (lib/<abi>/*.so)459testImplementation "com.github.luben:zstd-jni:1.5.6-3"    // desktop natives for host unit tests460```461The plain jar bundles only desktop natives (would `UnsatisfiedLinkError` on-device);462the `@aar` ships the Android `.so`. Both are needed: the AAR for the device, the jar463for JVM unit tests. Verified: APK contains `lib/{arm64-v8a,armeabi-v7a,x86,x86_64}/libzstd-jni-1.5.6-3.so`.464No `AndroidManifest.xml` changes.465466### Integration order4671. zstd-jni dependency → sync.4682. MF-1: `VaultCrypto` + `VaultStore` + `RecoveryWords` + `VaultRepository` → build → `RecoveryEscrowTest`.4693. MF-2: `PeachScript` + `PeachPayloadCodec` → build → `PeachScriptTest`.4704. Replace `PeachScriptExport`/`Import`, update `ImportScreen` → build → `ExportRoundTripTest`/`ImportParsersTest`.4715. `assembleDebug` + `lintDebug` + full unit suite.472473### Known-answer vectors the orchestrator must verify against the browser474- **MF-1 KAT**: fixed 32-byte root key → fixed words (`rootKeyToWords`); fixed475  recovery salt → `deriveRecoveryKey(wordsToRootKeyMaterial(words), salt)` → wrap476  `hex(rootKey)` (AAD 0x01) → confirm a browser-computed `recovery_wrapped_root_key`477  unwraps to the same root key. (Generate the browser vector with a tiny Node478  script against `recovery-words.ts`/`vault.ts` if a live cross-check is wanted.)479- **MF-2 KAT**: a small browser-produced `.peach` blob (PUA text) → Android480  `parseFull` yields the expected entries (pins PUA + envelope + zstd + packing).481  Plus Android self-round-trip and Unicode/empty-vault/wrong-password cases.