12345678910111213141516const VERSION_BYTE = new Uint8Array([1]);1718function aadFor(aadTag?: string): Uint8Array {19 if (!aadTag) return VERSION_BYTE;20 return new TextEncoder().encode(aadTag);21}2223function hex(buf: ArrayBuffer | Uint8Array): string {24 const bytes = buf instanceof Uint8Array ? buf : new Uint8Array(buf);25 return Array.from(bytes)26 .map((byte) => byte.toString(16).padStart(2, "0"))27 .join("");28}2930function unhex(value: string): Uint8Array {31 const out = new Uint8Array(value.length / 2);32 for (let i = 0; i < value.length; i += 2) {33 out[i / 2] = parseInt(value.slice(i, i + 2), 16);34 }35 return out;36}3738export async function encrypt(39 keyBytes: Uint8Array,40 plaintext: string,41 aadTag?: string42): Promise<{ nonce: string; ciphertext: string; tag: string }> {43 const key = await crypto.subtle.importKey(44 "raw",45 keyBytes.buffer as ArrayBuffer,46 { name: "AES-GCM" },47 false,48 ["encrypt"]49 );5051 52 53 54 55 56 const nonce = crypto.getRandomValues(new Uint8Array(12));57 const encoded = new TextEncoder().encode(plaintext);58 const aad = aadFor(aadTag);59 const raw = await crypto.subtle.encrypt(60 {61 name: "AES-GCM",62 iv: nonce,63 additionalData: aad.buffer.slice(aad.byteOffset, aad.byteOffset + aad.byteLength) as ArrayBuffer,64 tagLength: 128,65 },66 key,67 encoded68 );6970 71 72 const full = new Uint8Array(raw);73 const ciphertext = full.slice(0, full.length - 16);74 const tag = full.slice(full.length - 16);7576 return {77 nonce: hex(nonce.buffer),78 ciphertext: hex(ciphertext.buffer),79 tag: hex(tag.buffer),80 };81}8283export async function decrypt(84 keyBytes: Uint8Array,85 nonce: string,86 ciphertext: string,87 tag: string,88 aadTag?: string89): Promise<string> {90 const key = await crypto.subtle.importKey(91 "raw",92 keyBytes.buffer as ArrayBuffer,93 { name: "AES-GCM" },94 false,95 ["decrypt"]96 );9798 const cipherBytes = unhex(ciphertext);99 const tagBytes = unhex(tag);100 const combined = new Uint8Array(cipherBytes.length + tagBytes.length);101 combined.set(cipherBytes);102 combined.set(tagBytes, cipherBytes.length);103104 const tryDecrypt = async (aad: Uint8Array): Promise<string> => {105 const raw = await crypto.subtle.decrypt(106 {107 name: "AES-GCM",108 iv: unhex(nonce).buffer as ArrayBuffer,109 additionalData: aad.buffer.slice(aad.byteOffset, aad.byteOffset + aad.byteLength) as ArrayBuffer,110 tagLength: 128,111 },112 key,113 combined.buffer as ArrayBuffer114 );115 return new TextDecoder().decode(raw);116 };117118 try {119 return await tryDecrypt(aadFor(aadTag));120 } catch (err) {121 122 123 124 125 if (aadTag) {126 return await tryDecrypt(VERSION_BYTE);127 }128 throw err;129 }130}131132export function zeroKey(keyBytes: Uint8Array): void {133 keyBytes.fill(0);134}