aes.ts

3.9 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 aadTag string for slot-scope domain10 * separation (L15/SEC-009). Current vaults always use an explicit tag.11 */1213const VERSION_BYTE = new Uint8Array([1]);1415function aadFor(aadTag?: string): Uint8Array {16  if (!aadTag) return VERSION_BYTE;17  return new TextEncoder().encode(aadTag);18}1920function hex(buf: ArrayBuffer | Uint8Array): string {21  const bytes = buf instanceof Uint8Array ? buf : new Uint8Array(buf);22  return Array.from(bytes)23    .map((byte) => byte.toString(16).padStart(2, "0"))24    .join("");25}2627function unhex(value: string): Uint8Array {28  const out = new Uint8Array(value.length / 2);29  for (let i = 0; i < value.length; i += 2) {30    out[i / 2] = parseInt(value.slice(i, i + 2), 16);31  }32  return out;33}3435export async function encrypt(36  keyBytes: Uint8Array,37  plaintext: string,38  aadTag?: string39): Promise<{ nonce: string; ciphertext: string; tag: string }> {40  const key = await crypto.subtle.importKey(41    "raw",42    keyBytes.buffer as ArrayBuffer,43    { name: "AES-GCM" },44    false,45    ["encrypt"]46  );4748  // Fresh 96-bit nonce per encryption. Reusing a nonce with the same key under49  // AES-GCM catastrophically breaks both confidentiality and authenticity —50  // random 96-bit nonces make collision probability negligible (~N²/2⁹⁷) across51  // any realistic vault lifetime. tagLength: 128 is the maximum and must not52  // be reduced; the tag covers ciphertext + AAD.53  const nonce = crypto.getRandomValues(new Uint8Array(12));54  const encoded = new TextEncoder().encode(plaintext);55  const aad = aadFor(aadTag);56  const raw = await crypto.subtle.encrypt(57    {58      name: "AES-GCM",59      iv: nonce,60      additionalData: aad.buffer.slice(aad.byteOffset, aad.byteOffset + aad.byteLength) as ArrayBuffer,61      tagLength: 128,62    },63    key,64    encoded65  );6667  // WebCrypto returns ciphertext || 128-bit auth tag as one buffer. Split at68  // (length - 16) so the two parts can be stored separately.69  const full = new Uint8Array(raw);70  const ciphertext = full.slice(0, full.length - 16);71  const tag = full.slice(full.length - 16);7273  return {74    nonce: hex(nonce.buffer),75    ciphertext: hex(ciphertext.buffer),76    tag: hex(tag.buffer),77  };78}7980export async function decrypt(81  keyBytes: Uint8Array,82  nonce: string,83  ciphertext: string,84  tag: string,85  aadTag?: string86): Promise<string> {87  const tryDecrypt = async (aad: Uint8Array): Promise<string> =>88    new TextDecoder().decode(89      await decryptWithAadBytes(keyBytes, nonce, ciphertext, tag, aad),90    );9192  return await tryDecrypt(aadFor(aadTag));93}9495async function decryptWithAadBytes(96  keyBytes: Uint8Array,97  nonce: string,98  ciphertext: string,99  tag: string,100  aad: Uint8Array,101): Promise<Uint8Array> {102  const key = await crypto.subtle.importKey(103    "raw",104    keyBytes.buffer as ArrayBuffer,105    { name: "AES-GCM" },106    false,107    ["decrypt"]108  );109110  const cipherBytes = unhex(ciphertext);111  const tagBytes = unhex(tag);112  const combined = new Uint8Array(cipherBytes.length + tagBytes.length);113  combined.set(cipherBytes);114  combined.set(tagBytes, cipherBytes.length);115116  const raw = await crypto.subtle.decrypt(117    {118      name: "AES-GCM",119      iv: unhex(nonce).buffer as ArrayBuffer,120      additionalData: aad.buffer.slice(121        aad.byteOffset,122        aad.byteOffset + aad.byteLength,123      ) as ArrayBuffer,124      tagLength: 128,125    },126    key,127    combined.buffer as ArrayBuffer,128  );129  return new Uint8Array(raw);130}131132export const _testSeam = { decryptWithAadBytes };133134export function zeroKey(keyBytes: Uint8Array): void {135  keyBytes.fill(0);136}