word-waltz / files / src / crypto / encryption.ts
encryption.ts
| 1 | /** |
| 2 | * crypto/encryption.ts — AES-GCM-256 encrypt / decrypt via Web Crypto API |
| 3 | * |
| 4 | * Key lives only in the URL fragment and is never sent to any server. |
| 5 | * |
| 6 | * Ciphertext envelope: { iv: <base64url>, ciphertext: <base64url> } |
| 7 | * |
| 8 | * Key encoding: raw 256-bit key exported as base64url for embedding in the URL. |
| 9 | */ |
| 10 | |
| 11 | import type { Encrypted } from '../types.js'; |
| 12 | |
| 13 | // --- base64url helpers ------------------------------------------------------- |
| 14 | |
| 15 | function bufToBase64url(buf: ArrayBuffer): string { |
| 16 | const bytes = new Uint8Array(buf); |
| 17 | let binary = ''; |
| 18 | for (const byte of bytes) binary += String.fromCharCode(byte); |
| 19 | return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''); |
| 20 | } |
| 21 | |
| 22 | function base64urlToBuf(b64url: string): ArrayBuffer { |
| 23 | const b64 = b64url.replace(/-/g, '+').replace(/_/g, '/'); |
| 24 | const padded = b64 + '='.repeat((4 - (b64.length % 4)) % 4); |
| 25 | const binary = atob(padded); |
| 26 | const buf = new Uint8Array(binary.length); |
| 27 | for (let i = 0; i < binary.length; i++) buf[i] = binary.charCodeAt(i); |
| 28 | return buf.buffer; |
| 29 | } |
| 30 | |
| 31 | // --- Key generation & serialisation ----------------------------------------- |
| 32 | |
| 33 | /** Generate a new AES-GCM-256 key. */ |
| 34 | export async function generateKey(): Promise<CryptoKey> { |
| 35 | return crypto.subtle.generateKey( |
| 36 | { name: 'AES-GCM', length: 256 }, |
| 37 | true, |
| 38 | ['encrypt', 'decrypt'], |
| 39 | ); |
| 40 | } |
| 41 | |
| 42 | /** Export a CryptoKey to a URL-safe base64 string. */ |
| 43 | export async function exportKey(key: CryptoKey): Promise<string> { |
| 44 | const raw = await crypto.subtle.exportKey('raw', key); |
| 45 | return bufToBase64url(raw); |
| 46 | } |
| 47 | |
| 48 | /** Import a base64url string back into a CryptoKey. */ |
| 49 | export async function importKey(base64urlKey: string): Promise<CryptoKey> { |
| 50 | const raw = base64urlToBuf(base64urlKey); |
| 51 | return crypto.subtle.importKey('raw', raw, { name: 'AES-GCM' }, true, ['encrypt', 'decrypt']); |
| 52 | } |
| 53 | |
| 54 | // --- Encrypt / decrypt ------------------------------------------------------- |
| 55 | |
| 56 | /** Encrypt a plain-text string. */ |
| 57 | export async function encrypt(plaintext: string, key: CryptoKey): Promise<Encrypted> { |
| 58 | const iv = crypto.getRandomValues(new Uint8Array(12)); |
| 59 | const encoded = new TextEncoder().encode(plaintext); |
| 60 | const ciphertextBuf = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, encoded); |
| 61 | return { |
| 62 | iv: bufToBase64url(iv.buffer as ArrayBuffer), |
| 63 | ciphertext: bufToBase64url(ciphertextBuf), |
| 64 | }; |
| 65 | } |
| 66 | |
| 67 | /** Decrypt a ciphertext envelope back to a plain-text string. */ |
| 68 | export async function decrypt(envelope: Encrypted, key: CryptoKey): Promise<string> { |
| 69 | const iv = new Uint8Array(base64urlToBuf(envelope.iv)); |
| 70 | const ciphertext = base64urlToBuf(envelope.ciphertext); |
| 71 | const plaintextBuf = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, ciphertext); |
| 72 | return new TextDecoder().decode(plaintextBuf); |
| 73 | } |
| 74 |