aes.ts

4.5 KB · raw

1/**2 * aes.ts — AES-256-GCM encrypt/decrypt primitives for vault storage.3 *4 * Canonical AES module. `src/crypto/aes.ts` was a duplicate and has been5 * removed (Projects.md §4.10); if it reappears, delete it again and6 * redirect its imports here. All ciphertext in the vault slots is produced7 * 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.14 */1516const VERSION_BYTE = new Uint8Array([1]);1718function aadFor(aadTag?: string): Uint8Array {19  if (!aadTag) return VERSION_BYTE;20  return new TextEncoder().encode(aadTag);21}2223function hex(buf: ArrayBuffer | Uint8Array): string {24  const bytes = buf instanceof Uint8Array ? buf : new Uint8Array(buf);25  return Array.from(bytes)26    .map((byte) => byte.toString(16).padStart(2, "0"))27    .join("");28}2930function unhex(value: string): Uint8Array {31  const out = new Uint8Array(value.length / 2);32  for (let i = 0; i < value.length; i += 2) {33    out[i / 2] = parseInt(value.slice(i, i + 2), 16);34  }35  return out;36}3738export async function encrypt(39  keyBytes: Uint8Array,40  plaintext: string,41  aadTag?: string42): Promise<{ nonce: string; ciphertext: string; tag: string }> {43  const key = await crypto.subtle.importKey(44    "raw",45    keyBytes.buffer as ArrayBuffer,46    { name: "AES-GCM" },47    false,48    ["encrypt"]49  );5051  // Fresh 96-bit nonce per encryption. Reusing a nonce with the same key under52  // AES-GCM catastrophically breaks both confidentiality and authenticity —53  // random 96-bit nonces make collision probability negligible (~N²/2⁹⁷) across54  // any realistic vault lifetime. tagLength: 128 is the maximum and must not55  // be reduced; the tag covers ciphertext + AAD.56  const nonce = crypto.getRandomValues(new Uint8Array(12));57  const encoded = new TextEncoder().encode(plaintext);58  const aad = aadFor(aadTag);59  const raw = await crypto.subtle.encrypt(60    {61      name: "AES-GCM",62      iv: nonce,63      additionalData: aad.buffer.slice(aad.byteOffset, aad.byteOffset + aad.byteLength) as ArrayBuffer,64      tagLength: 128,65    },66    key,67    encoded68  );6970  // WebCrypto returns ciphertext || 128-bit auth tag as one buffer. Split at71  // (length - 16) so the two parts can be stored separately.72  const full = new Uint8Array(raw);73  const ciphertext = full.slice(0, full.length - 16);74  const tag = full.slice(full.length - 16);7576  return {77    nonce: hex(nonce.buffer),78    ciphertext: hex(ciphertext.buffer),79    tag: hex(tag.buffer),80  };81}8283export async function decrypt(84  keyBytes: Uint8Array,85  nonce: string,86  ciphertext: string,87  tag: string,88  aadTag?: string89): Promise<string> {90  const tryDecrypt = async (aad: Uint8Array): Promise<string> =>91    new TextDecoder().decode(92      await decryptWithAadBytes(keyBytes, nonce, ciphertext, tag, aad),93    );9495  try {96    return await tryDecrypt(aadFor(aadTag));97  } catch (err) {98    // L15 migration: legacy ciphertext was sealed with VERSION_BYTE only.99    // If a tagged decrypt fails, fall back so pre-L15 vaults still open.100    // The caller is responsible for re-sealing with the new AAD on next101    // write (which happens naturally — every vault mutation calls encrypt).102    if (aadTag) {103      return await tryDecrypt(VERSION_BYTE);104    }105    throw err;106  }107}108109async function decryptWithAadBytes(110  keyBytes: Uint8Array,111  nonce: string,112  ciphertext: string,113  tag: string,114  aad: Uint8Array,115): Promise<Uint8Array> {116  const key = await crypto.subtle.importKey(117    "raw",118    keyBytes.buffer as ArrayBuffer,119    { name: "AES-GCM" },120    false,121    ["decrypt"]122  );123124  const cipherBytes = unhex(ciphertext);125  const tagBytes = unhex(tag);126  const combined = new Uint8Array(cipherBytes.length + tagBytes.length);127  combined.set(cipherBytes);128  combined.set(tagBytes, cipherBytes.length);129130  const raw = await crypto.subtle.decrypt(131    {132      name: "AES-GCM",133      iv: unhex(nonce).buffer as ArrayBuffer,134      additionalData: aad.buffer.slice(135        aad.byteOffset,136        aad.byteOffset + aad.byteLength,137      ) as ArrayBuffer,138      tagLength: 128,139    },140    key,141    combined.buffer as ArrayBuffer,142  );143  return new Uint8Array(raw);144}145146export const _testSeam = { decryptWithAadBytes };147148export function zeroKey(keyBytes: Uint8Array): void {149  keyBytes.fill(0);150}