987a154bae24
L15/SEC-009: slot-id domain separation in AES-GCM AAD (slot_a_v1, slot_b_v1, pw_root_v1, sync_identity_v1) with legacy VERSION_BYTE decrypt-fallback for migration
- time
- 2026-05-16 05:37 UTC · 5 months ago
- machine
- koh2
- parent
- 8b3a7582c2ae
↓ peach-browser-987a154b.tar.gzatlas at this save
files changed3 files
src/background/aes.ts+44 −17src/background/vault.test.ts+1 −1src/background/vault.ts+22 −18src/background/aes.ts+44 −17
@@ -4,12 +4,22 @@44 * Canonical AES module. `src/crypto/aes.ts` was a duplicate and has been55 * removed (Projects.md §4.10); if it reappears, delete it again and66 * redirect its imports here. All ciphertext in the vault slots is produced7- * by `encrypt`/`decrypt` below with a fresh random 96-bit nonce and the8- * 1-byte VERSION_BYTE as AAD.7+ * by `encrypt`/`decrypt` below with a fresh random 96-bit nonce.8+ *9+ * AAD (additionalData) carries an optional aadTag string for slot-scope10+ * domain separation (L15/SEC-009). The legacy single-byte version AAD is11+ * still accepted on decrypt-fallback so pre-L15 vaults open without a12+ * migration write — callers that want hardened AAD just pass an aadTag13+ * (e.g. "slot_a_v1") on both encrypt and decrypt.914 */1015 1116 const VERSION_BYTE = new Uint8Array([1]);1217 18+function aadFor(aadTag?: string): Uint8Array {19+ if (!aadTag) return VERSION_BYTE;20+ return new TextEncoder().encode(aadTag);21+}22+1323 function hex(buf: ArrayBuffer | Uint8Array): string {1424 const bytes = buf instanceof Uint8Array ? buf : new Uint8Array(buf);1525 return Array.from(bytes)@@ -27,7 +37,8 @@2737 2838 export async function encrypt(2939 keyBytes: Uint8Array,30- plaintext: string40+ plaintext: string,41+ aadTag?: string3142 ): Promise<{ nonce: string; ciphertext: string; tag: string }> {3243 const key = await crypto.subtle.importKey(3344 "raw",@@ -41,14 +52,15 @@4152 // AES-GCM catastrophically breaks both confidentiality and authenticity —4253 // random 96-bit nonces make collision probability negligible (~N²/2⁹⁷) across4354 // any realistic vault lifetime. tagLength: 128 is the maximum and must not44- // be reduced; the tag covers ciphertext + AAD (the version byte).55+ // be reduced; the tag covers ciphertext + AAD.4556 const nonce = crypto.getRandomValues(new Uint8Array(12));4657 const encoded = new TextEncoder().encode(plaintext);58+ const aad = aadFor(aadTag);4759 const raw = await crypto.subtle.encrypt(4860 {4961 name: "AES-GCM",5062 iv: nonce,51- additionalData: VERSION_BYTE.buffer as ArrayBuffer,63+ additionalData: aad.buffer.slice(aad.byteOffset, aad.byteOffset + aad.byteLength) as ArrayBuffer,5264 tagLength: 128,5365 },5466 key,@@ -72,7 +84,8 @@7284 keyBytes: Uint8Array,7385 nonce: string,7486 ciphertext: string,75- tag: string87+ tag: string,88+ aadTag?: string7689 ): Promise<string> {7790 const key = await crypto.subtle.importKey(7891 "raw",@@ -88,18 +101,32 @@88101 combined.set(cipherBytes);89102 combined.set(tagBytes, cipherBytes.length);90103 91- const raw = await crypto.subtle.decrypt(92- {93- name: "AES-GCM",94- iv: unhex(nonce).buffer as ArrayBuffer,95- additionalData: VERSION_BYTE.buffer as ArrayBuffer,96- tagLength: 128,97- },98- key,99- combined.buffer as ArrayBuffer100- );104+ const tryDecrypt = async (aad: Uint8Array): Promise<string> => {105+ const raw = await crypto.subtle.decrypt(106+ {107+ name: "AES-GCM",108+ iv: unhex(nonce).buffer as ArrayBuffer,109+ additionalData: aad.buffer.slice(aad.byteOffset, aad.byteOffset + aad.byteLength) as ArrayBuffer,110+ tagLength: 128,111+ },112+ key,113+ combined.buffer as ArrayBuffer114+ );115+ return new TextDecoder().decode(raw);116+ };101117 102- return new TextDecoder().decode(raw);118+ try {119+ return await tryDecrypt(aadFor(aadTag));120+ } catch (err) {121+ // L15 migration: legacy ciphertext was sealed with VERSION_BYTE only.122+ // If a tagged decrypt fails, fall back so pre-L15 vaults still open.123+ // The caller is responsible for re-sealing with the new AAD on next124+ // write (which happens naturally — every vault mutation calls encrypt).125+ if (aadTag) {126+ return await tryDecrypt(VERSION_BYTE);127+ }128+ throw err;129+ }103130 }104131 105132 export function zeroKey(keyBytes: Uint8Array): void {src/background/vault.test.ts+1 −1
@@ -255,7 +255,7 @@255255 stored.salt.match(/.{1,2}/g)!.map((value: string) => parseInt(value, 16))256256 );257257 const key = await deriveKey("correct horse battery staple", salt);258- const plain = await decrypt(key, stored.nonce, stored.ciphertext, stored.tag);258+ const plain = await decrypt(key, stored.nonce, stored.ciphertext, stored.tag, "slot_a_v1");259259 const parsed = JSON.parse(plain) as { entries: Array<{ id: string; deletedAt?: number }> };260260 261261 expect(parsed.entries).toHaveLength(1);src/background/vault.ts+22 −18
@@ -362,7 +362,7 @@362362 slotA: StoredVault363363 ): Promise<void> {364364 const plaintext = encodePayload(entries, duressConfigured);365- const { nonce, ciphertext, tag } = await encrypt(newKey, plaintext);365+ const { nonce, ciphertext, tag } = await encrypt(newKey, plaintext, "slot_a_v1");366366 await writeSlot(SLOT_A, {367367 ...slotA,368368 nonce,@@ -440,6 +440,7 @@440440 slotA.pw_root_nonce,441441 slotA.pw_root_ciphertext,442442 slotA.pw_root_tag,443+ "pw_root_v1",443444 );444445 return unhex(rootHex);445446 } catch {@@ -453,7 +454,7 @@453454 derivedKeyBytes: Uint8Array,454455 rootKeyBytes: Uint8Array,455456 ): Promise<{ pw_root_ciphertext: string; pw_root_nonce: string; pw_root_tag: string }> {456- const { nonce, ciphertext, tag } = await encrypt(derivedKeyBytes, hex(rootKeyBytes));457+ const { nonce, ciphertext, tag } = await encrypt(derivedKeyBytes, hex(rootKeyBytes), "pw_root_v1");457458 return {458459 pw_root_ciphertext: ciphertext,459460 pw_root_nonce: nonce,@@ -814,22 +815,24 @@814815 // constant-time invariants in Projects.md §3.4 are not bypassed.815816 export async function tryDecryptSlot(816817 vault: StoredVault,817- key: Uint8Array818+ key: Uint8Array,819+ aadTag?: string,818820 ): Promise<DecryptedPayload | null> {819- const res = await tryDecryptSlotResult(vault, key);821+ const res = await tryDecryptSlotResult(vault, key, aadTag);820822 return res.status === "ok" ? res.payload : null;821823 }822824 823825 export async function tryDecryptSlotResult(824826 vault: StoredVault,825- key: Uint8Array827+ key: Uint8Array,828+ aadTag?: string,826829 ): Promise<TryDecryptResult> {827830 if (!hasValidVaultSchema(vault)) {828831 return { status: "corrupted", reason: "invalid_slot_schema" };829832 }830833 let plain: string;831834 try {832- plain = await decrypt(key, vault.nonce, vault.ciphertext, vault.tag);835+ plain = await decrypt(key, vault.nonce, vault.ciphertext, vault.tag, aadTag);833836 } catch {834837 return { status: "wrong_key" };835838 }@@ -895,7 +898,7 @@895898 rootKey = unhex(rawRootKey);896899 897900 const plaintext = encodePayload(entries, duressConfigured);898- const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext);901+ const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext, "slot_a_v1");899902 900903 // Second wrapping of rootKey under derivedKey so future unlocks can rehydrate901904 // it without recovery words (H2/COR-006).@@ -960,7 +963,7 @@960963 if (encoded.length < targetBytes) {961964 plaintext = plaintext + " ".repeat(targetBytes - encoded.length);962965 }963- const { nonce, ciphertext, tag } = await encrypt(decoyKey, plaintext);966+ const { nonce, ciphertext, tag } = await encrypt(decoyKey, plaintext, "slot_b_v1");964967 await writeSlot(SLOT_B, {965968 version: 1,966969 salt: hex(decoySalt),@@ -1008,7 +1011,7 @@10081011 if (!slotA) return false;10091012 const key = await deriveKey(password, unhex(slotA.salt));10101013 try {1011- return (await tryDecryptSlot(slotA, key)) !== null;1014+ return (await tryDecryptSlot(slotA, key, "slot_a_v1")) !== null;10121015 } finally {10131016 zeroKey(key);10141017 }@@ -1038,10 +1041,10 @@10381041 // when a slot's schema is invalid even though AES-GCM authenticated (M7).10391042 const [resultA, resultB] = await Promise.all([10401043 keyA && slotA1041- ? tryDecryptSlotResult(slotA, keyA)1044+ ? tryDecryptSlotResult(slotA, keyA, "slot_a_v1")10421045 : Promise.resolve({ status: "wrong_key" } as TryDecryptResult),10431046 keyB && slotB1044- ? tryDecryptSlotResult(slotB, keyB)1047+ ? tryDecryptSlotResult(slotB, keyB, "slot_b_v1")10451048 : Promise.resolve({ status: "wrong_key" } as TryDecryptResult),10461049 ]);10471050 const payloadA = resultA.status === "ok" ? resultA.payload : null;@@ -1146,7 +1149,7 @@11461149 const masterKeyBytes = unhex(masterKeyHex);11471150 const stored = await loadSlot(SLOT_A);11481151 if (!stored) return { ok: false, error: "invalid_key" };1149- const payload = await tryDecryptSlot(stored, masterKeyBytes);1152+ const payload = await tryDecryptSlot(stored, masterKeyBytes, "slot_a_v1");11501153 if (!payload) return { ok: false, error: "invalid_key" };11511154 11521155 derivedKey = masterKeyBytes;@@ -1211,7 +1214,7 @@12111214 // Verify the passphrase against the supplied slotA WITHOUT writing anything.12121215 // If this fails the on-disk vault is left completely untouched.12131216 const trialKey = await deriveKey(params.masterPassphrase, unhex(parsedSlotA.salt));1214- const trialPayload = await tryDecryptSlot(parsedSlotA, trialKey);1217+ const trialPayload = await tryDecryptSlot(parsedSlotA, trialKey, "slot_a_v1");12151218 if (!trialPayload) {12161219 zeroKey(trialKey);12171220 return { ok: false, reason: "wrong_passphrase" };@@ -1633,7 +1636,7 @@16331636 await takeVaultSnapshot(reason);16341637 16351638 const plaintext = encodePayload(entries, duressConfigured);1636- const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext);1639+ const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext, "slot_a_v1");16371640 16381641 // Preserve existing recovery escrow fields.16391642 const nextSlotA: StoredVault = {@@ -1665,7 +1668,7 @@16651668 // Decoy plaintext always reports duressConfigured=false — the decoy user16661669 // sees a clean slate.16671670 const plaintext = encodePayload(entries, false);1668- const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext);1671+ const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext, "slot_b_v1");16691672 16701673 await writeSlot(SLOT_B, { ...slotB, nonce, ciphertext, tag });16711674 await stampLocalVaultMutation();@@ -2185,7 +2188,7 @@21852188 21862189 // Re-encrypt the in-memory entries with the new key21872190 const plaintext = encodePayload(entries, duressConfigured);2188- const { nonce, ciphertext, tag } = await encrypt(newKey, plaintext);2191+ const { nonce, ciphertext, tag } = await encrypt(newKey, plaintext, "slot_a_v1");21892192 21902193 // Pre-wrap root key under the new derived key. Recovery fields wrap rootKey21912194 // under recovery words and remain untouched — recovery phrase keeps working@@ -2354,6 +2357,7 @@23542357 parsed.enc_nonce,23552358 parsed.enc_priv,23562359 parsed.enc_tag,2360+ "sync_identity_v1",23572361 );23582362 const privateKeyBytes = unhex(privHex);23592363 return { privateKeyBytes, publicKeyBytes, publicKeyHex: bytesToHex(publicKeyBytes) };@@ -2383,7 +2387,7 @@23832387 publicKeyBytes: Uint8Array,23842388 ): Promise<void> {23852389 if (!derivedKey) throw new Error('vault_locked');2386- const enc = await encrypt(derivedKey, hex(privateKeyBytes));2390+ const enc = await encrypt(derivedKey, hex(privateKeyBytes), "sync_identity_v1");23872391 await chrome.storage.local.set({23882392 peach_sync_identity: JSON.stringify({23892393 pub: base64EncodeBytes(publicKeyBytes),@@ -2402,7 +2406,7 @@24022406 const slotA = await loadSlot(SLOT_A);24032407 if (!slotA) return false;24042408 try {2405- const payload = await tryDecryptSlot(slotA, derivedKey);2409+ const payload = await tryDecryptSlot(slotA, derivedKey, "slot_a_v1");24062410 if (!payload) return false;24072411 entries = payload.entries;24082412 buildDomainIndexSync(entries);