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
3 files src/background/aes.ts+44 −17src/background/vault.test.ts+1 −1src/background/vault.ts+22 −18
src/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 {

view file

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

view file

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

view file