aes.ts

3.1 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 and the8 * 1-byte VERSION_BYTE as AAD.9 */1011const VERSION_BYTE = new Uint8Array([1]);1213function hex(buf: ArrayBuffer | Uint8Array): string {14  const bytes = buf instanceof Uint8Array ? buf : new Uint8Array(buf);15  return Array.from(bytes)16    .map((byte) => byte.toString(16).padStart(2, "0"))17    .join("");18}1920function unhex(value: string): Uint8Array {21  const out = new Uint8Array(value.length / 2);22  for (let i = 0; i < value.length; i += 2) {23    out[i / 2] = parseInt(value.slice(i, i + 2), 16);24  }25  return out;26}2728export async function encrypt(29  keyBytes: Uint8Array,30  plaintext: string31): Promise<{ nonce: string; ciphertext: string; tag: string }> {32  const key = await crypto.subtle.importKey(33    "raw",34    keyBytes.buffer as ArrayBuffer,35    { name: "AES-GCM" },36    false,37    ["encrypt"]38  );3940  // Fresh 96-bit nonce per encryption. Reusing a nonce with the same key under41  // AES-GCM catastrophically breaks both confidentiality and authenticity —42  // random 96-bit nonces make collision probability negligible (~N²/2⁹⁷) across43  // any realistic vault lifetime. tagLength: 128 is the maximum and must not44  // be reduced; the tag covers ciphertext + AAD (the version byte).45  const nonce = crypto.getRandomValues(new Uint8Array(12));46  const encoded = new TextEncoder().encode(plaintext);47  const raw = await crypto.subtle.encrypt(48    {49      name: "AES-GCM",50      iv: nonce,51      additionalData: VERSION_BYTE.buffer as ArrayBuffer,52      tagLength: 128,53    },54    key,55    encoded56  );5758  // WebCrypto returns ciphertext || 128-bit auth tag as one buffer. Split at59  // (length - 16) so the two parts can be stored separately.60  const full = new Uint8Array(raw);61  const ciphertext = full.slice(0, full.length - 16);62  const tag = full.slice(full.length - 16);6364  return {65    nonce: hex(nonce.buffer),66    ciphertext: hex(ciphertext.buffer),67    tag: hex(tag.buffer),68  };69}7071export async function decrypt(72  keyBytes: Uint8Array,73  nonce: string,74  ciphertext: string,75  tag: string76): Promise<string> {77  const key = await crypto.subtle.importKey(78    "raw",79    keyBytes.buffer as ArrayBuffer,80    { name: "AES-GCM" },81    false,82    ["decrypt"]83  );8485  const cipherBytes = unhex(ciphertext);86  const tagBytes = unhex(tag);87  const combined = new Uint8Array(cipherBytes.length + tagBytes.length);88  combined.set(cipherBytes);89  combined.set(tagBytes, cipherBytes.length);9091  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  );101102  return new TextDecoder().decode(raw);103}104105export function zeroKey(keyBytes: Uint8Array): void {106  keyBytes.fill(0);107}