/** * 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(""); } const MAX_CIPHERTEXT_BYTES = 8 * 1024 * 1024; function unhex(value: string, field: string, options: { empty?: boolean; exactBytes?: number } = {}): Uint8Array { if (typeof value !== "string" || value.length % 2 !== 0 || !/^[0-9a-f]*$/i.test(value)) { throw new Error(`invalid ${field}`); } if (options.exactBytes !== undefined && value.length !== options.exactBytes * 2) { throw new Error(`invalid ${field}`); } if (!options.empty && value.length === 0) throw new Error(`invalid ${field}`); if (value.length / 2 > MAX_CIPHERTEXT_BYTES) throw new Error(`${field} too large`); const out = new Uint8Array(value.length / 2); for (let i = 0; i < value.length; i += 2) out[i / 2] = Number.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 nonceBytes = unhex(nonce, "nonce", { exactBytes: 12 }); const cipherBytes = unhex(ciphertext, "ciphertext"); const tagBytes = unhex(tag, "tag", { exactBytes: 16 }); const combined = new Uint8Array(cipherBytes.length + tagBytes.length); combined.set(cipherBytes); combined.set(tagBytes, cipherBytes.length); const key = await crypto.subtle.importKey( "raw", keyBytes.buffer as ArrayBuffer, { name: "AES-GCM" }, false, ["decrypt"], ); const raw = await crypto.subtle.decrypt( { name: "AES-GCM", iv: nonceBytes.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); }