12345678910111213const 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 57 58 59 60 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 76 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}