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

  1. A fresh 256-bit AES-GCM key (DEK) is generated for every record
  2. Each field is encrypted with that DEK — unique IV per field, prepended to the ciphertext
  3. The DEK is wrapped with RSA-OAEP twice: once for the record owner, once for the backend
  4. 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,
}
}
添加评论
点赞收藏
点踩分享查看原文
评论
?
参与讨论