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 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}2627const MAX_CIPHERTEXT_BYTES = 8 * 1024 * 1024;2829function unhex(value: string, field: string, options: { empty?: boolean; exactBytes?: number } = {}): Uint8Array {30  if (typeof value !== "string" || value.length % 2 !== 0 || !/^[0-9a-f]*$/i.test(value)) {31    throw new Error(`invalid ${field}`);32  }33  if (options.exactBytes !== undefined && value.length !== options.exactBytes * 2) {34    throw new Error(`invalid ${field}`);35  }36  if (!options.empty && value.length === 0) throw new Error(`invalid ${field}`);37  if (value.length / 2 > MAX_CIPHERTEXT_BYTES) throw new Error(`${field} too large`);38  const out = new Uint8Array(value.length / 2);39  for (let i = 0; i < value.length; i += 2) out[i / 2] = Number.parseInt(value.slice(i, i + 2), 16);40  return out;41}4243export async function encrypt(44  keyBytes: Uint8Array,45  plaintext: string,46  aadTag?: string47): Promise<{ nonce: string; ciphertext: string; tag: string }> {48  const key = await crypto.subtle.importKey(49    "raw",50    keyBytes.buffer as ArrayBuffer,51    { name: "AES-GCM" },52    false,53    ["encrypt"]54  );5556  // Fresh 96-bit nonce per encryption. Reusing a nonce with the same key under57  // AES-GCM catastrophically breaks both confidentiality and authenticity —58  // random 96-bit nonces make collision probability negligible (~N²/2⁹⁷) across59  // any realistic vault lifetime. tagLength: 128 is the maximum and must not60  // be reduced; the tag covers ciphertext + AAD.61  const nonce = crypto.getRandomValues(new Uint8Array(12));62  const encoded = new TextEncoder().encode(plaintext);63  const aad = aadFor(aadTag);64  const raw = await crypto.subtle.encrypt(65    {66      name: "AES-GCM",67      iv: nonce,68      additionalData: aad.buffer.slice(aad.byteOffset, aad.byteOffset + aad.byteLength) as ArrayBuffer,69      tagLength: 128,70    },71    key,72    encoded73  );7475  // WebCrypto returns ciphertext || 128-bit auth tag as one buffer. Split at76  // (length - 16) so the two parts can be stored separately.77  const full = new Uint8Array(raw);78  const ciphertext = full.slice(0, full.length - 16);79  const tag = full.slice(full.length - 16);8081  return {82    nonce: hex(nonce.buffer),83    ciphertext: hex(ciphertext.buffer),84    tag: hex(tag.buffer),85  };86}8788export async function decrypt(89  keyBytes: Uint8Array,90  nonce: string,91  ciphertext: string,92  tag: string,93  aadTag?: string94): Promise<string> {95  const tryDecrypt = async (aad: Uint8Array): Promise<string> =>96    new TextDecoder().decode(97      await decryptWithAadBytes(keyBytes, nonce, ciphertext, tag, aad),98    );99100  return await tryDecrypt(aadFor(aadTag));101}102103async function decryptWithAadBytes(104  keyBytes: Uint8Array,105  nonce: string,106  ciphertext: string,107  tag: string,108  aad: Uint8Array,109): Promise<Uint8Array> {110  const nonceBytes = unhex(nonce, "nonce", { exactBytes: 12 });111  const cipherBytes = unhex(ciphertext, "ciphertext");112  const tagBytes = unhex(tag, "tag", { exactBytes: 16 });113  const combined = new Uint8Array(cipherBytes.length + tagBytes.length);114  combined.set(cipherBytes);115  combined.set(tagBytes, cipherBytes.length);116117  const key = await crypto.subtle.importKey(118    "raw",119    keyBytes.buffer as ArrayBuffer,120    { name: "AES-GCM" },121    false,122    ["decrypt"],123  );124125  const raw = await crypto.subtle.decrypt(126    {127      name: "AES-GCM",128      iv: nonceBytes.buffer as ArrayBuffer,129      additionalData: aad.buffer.slice(aad.byteOffset, aad.byteOffset + aad.byteLength) as ArrayBuffer,130      tagLength: 128,131    },132    key,133    combined.buffer as ArrayBuffer,134  );135  return new Uint8Array(raw);136}137138export const _testSeam = { decryptWithAadBytes };139140export function zeroKey(keyBytes: Uint8Array): void {141  keyBytes.fill(0);142}