BROWSER-SPEC.md
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) | checksuminto 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-
abandonquirk (shared latent bug).WORD_LISThas"abandon"at both index 0 and index 1 (verified: browserrecovery-word-list.tshas 2049 entries; AndroidRecoveryWordList.ktis byte-identical — diff empty).indexOf("abandon")always returns 0, so any word thatrootKeyToWordsemitted from index 1 cannot be inverted, andwordsToRootKeyMaterialthen throws on the checksum. This affects ~0.6% of random root keys (P(any of 12 11-bit groups == 1) ≈ 12/2048). The browser'screateVaultcallsgenerateAndWrapRootKeywith 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
wordMaterialfrom 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:
- 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 underHKDF-SHA256(wordsToRootKeyMaterial(words), salt, info="peach-recovery-v1"), AAD = byte0x01, 12-byte nonce. Byte-for-byte the browser algorithm. - Keep Android's existing whole-payload
recoveryEscrow{salt, blob}for offline entry restore — but derive its key from the samewordsToRootKeyMaterialIKM (not BLAKE3), under a distinct string AAD so the two blobs can never be confused. 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.changePassword: leave the root-key escrow stable (browser parity); keep refreshing the whole-payload escrow so offline restore stays current (Android-local behavior).- Generate root keys whose words round-trip (retry loop) so the
duplicate-
abandonquirk 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 ofinner(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-wasmUTF-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.encryptcalled with onlyiv+tagLength— noadditionalData). - 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/flags2are 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.soper ABI).Zstd.compress(raw, 19)andZstd.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 inRecoveryWords.kt). - Argon2id: BouncyCastle
Argon2BytesGeneratorwith the UTF-8 byte overloadgenerateBytes(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)
RecoveryEscrowTesttest #6 (escrow_round_trips_for_phrase_with_duplicate_abandon) asserts the old BLAKE3 behavior (round-trips a checksum-invalid all-abandonphrase). 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.ExportRoundTripTestPeachScript cases andImportParsersTestPeachScript cases assert the old JSON format being replaced; they are updated to the new format. NewPeachScriptTest.ktadds 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): Stringnow returns the PUA-encoded PCHS payload. (Drop the JSONbuildPlaintext.)ui/vault/PeachScriptImport.kt—looksLikePeachScript→ PUA sniff;isEncrypted→ always true;decryptAndParse/ newparseFullreturn fullVaultEntry.vault/RecoveryWords.kt— recovery IKM viawordsToRootKeyMaterial(retirewordKeyMaterialfrom 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— addRootKeyEscrow{salt,wrappedRootKey,nonce,tag}toStoredVault.vault/VaultRepository.kt— seal/unseal both escrows; round-trip-checked root-key gen; leave root-key escrow stable onchangePassword.
Files to update (integration)
ui/vault/ImportScreen.kt— PeachScript path consumes fullVaultEntry(preserve all fields).ui/vault/ExportScreen.kt— saves.peach(unchanged call shape).- Tests:
RecoveryEscrowTest.kt,ExportRoundTripTest.kt,ImportParsersTest.ktupdated; addRecoveryEscrowTest/PeachScriptTestKATs.
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
- zstd-jni dependency → sync.
- MF-1:
VaultCrypto+VaultStore+RecoveryWords+VaultRepository→ build →RecoveryEscrowTest. - MF-2:
PeachScript+PeachPayloadCodec→ build →PeachScriptTest. - Replace
PeachScriptExport/Import, updateImportScreen→ build →ExportRoundTripTest/ImportParsersTest. 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)→ wraphex(rootKey)(AAD 0x01) → confirm a browser-computedrecovery_wrapped_root_keyunwraps to the same root key. (Generate the browser vector with a tiny Node script againstrecovery-words.ts/vault.tsif a live cross-check is wanted.) - MF-2 KAT: a small browser-produced
.peachblob (PUA text) → AndroidparseFullyields the expected entries (pins PUA + envelope + zstd + packing). Plus Android self-round-trip and Unicode/empty-vault/wrong-password cases.