fuzzyworld

word-waltz / files / src / crypto / encryption.ts

encryption.ts

2.7 KB · history · blame

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
11import type { Encrypted } from '../types.js';
12
13// --- base64url helpers -------------------------------------------------------
14
15function 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
22function 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. */
34export 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. */
43export 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. */
49export 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. */
57export 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. */
68export 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