/** * aes.ts — AES-256-GCM encrypt/decrypt primitives for vault storage. * * Canonical AES module. `src/crypto/aes.ts` was a duplicate and has been * removed (Projects.md §4.10); if it reappears, delete it again and * redirect its imports here. All ciphertext in the vault slots is produced * by `encrypt`/`decrypt` below with a fresh random 96-bit nonce. * * AAD (additionalData) carries an aadTag string for slot-scope domain * separation (L15/SEC-009). Current vaults always use an explicit tag. */ const VERSION_BYTE = new Uint8Array([1]); function aadFor(aadTag?: string): Uint8Array { if (!aadTag) return VERSION_BYTE; return new TextEncoder().encode(aadTag); } function hex(buf: ArrayBuffer | Uint8Array): string { const bytes = buf instanceof Uint8Array ? buf : new Uint8Array(buf); return Array.from(bytes) .map((byte) => byte.toString(16).padStart(2, "0")) .join(""); } function unhex(value: string): Uint8Array { const out = new Uint8Array(value.length / 2); for (let i = 0; i < value.length; i += 2) { out[i / 2] = parseInt(value.slice(i, i + 2), 16); } return out; } export async function encrypt( keyBytes: Uint8Array, plaintext: string, aadTag?: string ): Promise<{ nonce: string; ciphertext: string; tag: string }> { const key = await crypto.subtle.importKey( "raw", keyBytes.buffer as ArrayBuffer, { name: "AES-GCM" }, false, ["encrypt"] ); // Fresh 96-bit nonce per encryption. Reusing a nonce with the same key under // AES-GCM catastrophically breaks both confidentiality and authenticity — // random 96-bit nonces make collision probability negligible (~N²/2⁹⁷) across // any realistic vault lifetime. tagLength: 128 is the maximum and must not // be reduced; the tag covers ciphertext + AAD. const nonce = crypto.getRandomValues(new Uint8Array(12)); const encoded = new TextEncoder().encode(plaintext); const aad = aadFor(aadTag); const raw = await crypto.subtle.encrypt( { name: "AES-GCM", iv: nonce, additionalData: aad.buffer.slice(aad.byteOffset, aad.byteOffset + aad.byteLength) as ArrayBuffer, tagLength: 128, }, key, encoded ); // WebCrypto returns ciphertext || 128-bit auth tag as one buffer. Split at // (length - 16) so the two parts can be stored separately. const full = new Uint8Array(raw); const ciphertext = full.slice(0, full.length - 16); const tag = full.slice(full.length - 16); return { nonce: hex(nonce.buffer), ciphertext: hex(ciphertext.buffer), tag: hex(tag.buffer), }; } export async function decrypt( keyBytes: Uint8Array, nonce: string, ciphertext: string, tag: string, aadTag?: string ): Promise { const tryDecrypt = async (aad: Uint8Array): Promise => new TextDecoder().decode( await decryptWithAadBytes(keyBytes, nonce, ciphertext, tag, aad), ); return await tryDecrypt(aadFor(aadTag)); } async function decryptWithAadBytes( keyBytes: Uint8Array, nonce: string, ciphertext: string, tag: string, aad: Uint8Array, ): Promise { const key = await crypto.subtle.importKey( "raw", keyBytes.buffer as ArrayBuffer, { name: "AES-GCM" }, false, ["decrypt"] ); const cipherBytes = unhex(ciphertext); const tagBytes = unhex(tag); const combined = new Uint8Array(cipherBytes.length + tagBytes.length); combined.set(cipherBytes); combined.set(tagBytes, cipherBytes.length); const raw = await crypto.subtle.decrypt( { name: "AES-GCM", iv: unhex(nonce).buffer as ArrayBuffer, additionalData: aad.buffer.slice( aad.byteOffset, aad.byteOffset + aad.byteLength, ) as ArrayBuffer, tagLength: 128, }, key, combined.buffer as ArrayBuffer, ); return new Uint8Array(raw); } export const _testSeam = { decryptWithAadBytes }; export function zeroKey(keyBytes: Uint8Array): void { keyBytes.fill(0); }