Client-side E2EE for a clinical SaaS — hybrid encryption (AES-256-GCM + RSA-OAEP) using only the Web Crypto API, no dependencies.
vault.ts — Client-side hybrid encryption
A standalone TypeScript module for end-to-end encryption in the browser. No dependencies — only the built-in Web Crypto API.
How it works
- A fresh 256-bit AES-GCM key (DEK) is generated for every record
- Each field is encrypted with that DEK — unique IV per field, prepended to the ciphertext
- The DEK is wrapped with RSA-OAEP twice: once for the record owner, once for the backend
- The server stores only encrypted bytes — it cannot read the data it holds
Usage
const sealed = await seal({ firstName: 'Ali', notes: 'First session...' }, ownerPublicKeyPem)
// sealed.fields.firstName → encrypted base64
// sealed.ownerEnvelope → DEK, sealed for the owner's RSA key
const plain = await unseal(sealed, privateKey)
// plain.firstName → 'Ali' (TypeScript infers the shape from T)| /** | |
| * Client-side hybrid encryption — AES-256-GCM for data, RSA-OAEP for key delivery. | |
| * | |
| * Every record gets a fresh 256-bit DEK. That DEK is sealed in two independent | |
| * RSA-OAEP envelopes: one for the record owner, one for the backend transit key. | |
| * Sensitive data never leaves the browser in plaintext. | |
| * | |
| * const sealed = await seal({ firstName: 'Ali', notes: 'İlk seans...' }, ownerPem) | |
| * const plain = await unseal(sealed, privateKey) | |
| * plain.firstName // 'Ali' ← TypeScript knows the shape | |
| */ | |
| // ─── Base64 ────────────────────────────────────────────────────────────────── | |
| const b64 = (buf: ArrayBuffer): string => | |
| btoa(new Uint8Array(buf).reduce((s, b) => s + String.fromCharCode(b), '')) | |
| const unb64 = (s: string): ArrayBuffer => | |
| Uint8Array.from(atob(s), c => c.charCodeAt(0)).buffer | |
| // ─── RSA-OAEP ──────────────────────────────────────────────────────────────── | |
| async function importSpki(pem: string): Promise { | |
| return crypto.subtle.importKey( | |
| 'spki', | |
| unb64(pem.replace(/-----[^-]+-----|\s/g, '')), | |
| { name: 'RSA-OAEP', hash: 'SHA-256' }, | |
| false, | |
| ['encrypt'], | |
| ) | |
| } | |
| // ─── AES-256-GCM ───────────────────────────────────────────────────────────── | |
| // Wire format: [IV(12 B) | CipherText | GCM Tag(16 B)] | |
| async function sealField(dek: CryptoKey, plaintext: string): Promise { | |
| const iv = crypto.getRandomValues(new Uint8Array(12)) | |
| const ct = await crypto.subtle.encrypt( | |
| { name: 'AES-GCM', iv }, | |
| dek, | |
| new TextEncoder().encode(plaintext), | |
| ) | |
| const out = new Uint8Array(12 + ct.byteLength) | |
| out.set(iv) | |
| out.set(new Uint8Array(ct), 12) | |
| return b64(out.buffer) | |
| } | |
| async function openField(dek: CryptoKey, sealed: string): Promise { | |
| const buf = new Uint8Array(unb64(sealed)) | |
| const pt = await crypto.subtle.decrypt( | |
| { name: 'AES-GCM', iv: buf.slice(0, 12) }, | |
| dek, | |
| buf.slice(12), | |
| ) | |
| return new TextDecoder().decode(pt) | |
| } | |
| // ─── Types ─────────────────────────────────────────────────────────────────── | |
| export type SealedRecord> = { | |
| fields: { [K in keyof T]: string } | |
| ownerEnvelope: string // DEK sealed for the record owner | |
| backendEnvelope: string // DEK sealed for the backend transit key | |
| } | |
| // ─── Public API ────────────────────────────────────────────────────────────── | |
| /** | |
| * Encrypts every field with a fresh DEK, then wraps that DEK in two RSA-OAEP | |
| * envelopes. Returns a payload the API accepts as-is. | |
| */ | |
| export async function seal>( | |
| plaintext: T, | |
| ownerPublicKeyPem: string, | |
| backendPublicKeyPem = import.meta.env.VITE_BACKEND_PUBLIC_KEY as string, | |
| ): Promise> { | |
| const dek = await crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, true, ['encrypt']) | |
| const rawDek = await crypto.subtle.exportKey('raw', dek) | |
| const [fields, ownerKey, backendKey] = await Promise.all([ | |
| Promise.all( | |
| Object.entries(plaintext).map(async ([k, v]) => [k, await sealField(dek, v)] as const), | |
| ).then(Object.fromEntries) as Promise['fields']>, | |
| importSpki(ownerPublicKeyPem), | |
| importSpki(backendPublicKeyPem), | |
| ]) | |
| const [ownerEnvelope, backendEnvelope] = await Promise.all([ | |
| crypto.subtle.encrypt({ name: 'RSA-OAEP' }, ownerKey, rawDek).then(b64), | |
| crypto.subtle.encrypt({ name: 'RSA-OAEP' }, backendKey, rawDek).then(b64), | |
| ]) | |
| return { fields, ownerEnvelope, backendEnvelope } | |
| } | |
| /** | |
| * Unwraps the owner envelope with the caller's RSA private key, then decrypts | |
| * every field. Returns the original plaintext with the same shape as the input. | |
| */ | |
| export async function unseal>( | |
| sealed: Pick, 'fields' | 'ownerEnvelope'>, | |
| privateKey: CryptoKey, | |
| ): Promise { | |
| const rawDek = await crypto.subtle.decrypt( | |
| { name: 'RSA-OAEP' }, | |
| privateKey, | |
| unb64(sealed.ownerEnvelope), | |
| ) | |
| const dek = await crypto.subtle.importKey('raw', rawDek, 'AES-GCM', false, ['decrypt']) | |
| return Object.fromEntries( | |
| await Promise.all( | |
| Object.entries(sealed.fields).map(async ([k, v]) => [k, await openField(dek, v)]), | |
| ), | |
| ) as T | |
| } | |
| /** | |
| * Converts a SealedRecord to the shape the backend expects in request bodies. | |
| */ | |
| export function toPayload>(sealed: SealedRecord) { | |
| return { | |
| values: Object.entries(sealed.fields).map(([name, value]) => ({ name, value })), | |
| dekEncryptedForUser: sealed.ownerEnvelope, | |
| dekEncryptedForBackend: sealed.backendEnvelope, | |
| } | |
| } |
评论
?
参与讨论