BROWSER-SPEC.md

26.8 KB · raw

rendered · source

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:

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

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

Wrapping (vault.ts:292-307):

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 ]

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

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

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)

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


Integration Contract

New files (drafts → integrate into app/src/main/java/com/peachpasswords/)

Files to replace

Files to update (integration)

New dependency (app/build.gradle)

implementation "com.github.luben:zstd-jni:1.5.6-3@aar"   // Android jniLibs (lib/<abi>/*.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