clone

a deno framework for local-first web apps that open instantly, work offline and sync when users choose

feat/141-demo-review-fixes
lofi / package / schema / encrypted.ts
encrypted.ts 16.1K
1/**2 * Encrypted columns: field values are sealed on the client before they enter3 * Jazz, so the sync store holds ciphertext it cannot read. The key derives4 * from the account secret, every device holding the account decrypts, and5 * nobody else — including the store operator — can. The store still sees structure6 * (tables, row identities, writers, timestamps, sizes); it stops seeing7 * content.8 *9 * Mechanics: an encrypted column is a stored `string` column whose10 * TypeScript-boundary transform seals on insert/update and opens on read11 * (XChaCha20-Poly1305; per-column subkeys via HKDF over the column label,12 * with the label and format version bound as associated data so ciphertext13 * cannot be replayed across columns or format versions). Transforms are14 * synchronous in the pinned engine, so the cipher is the audited `@noble`15 * implementation rather than WebCrypto.16 *17 * Plaintext is padded to bucket sizes before sealing ({@link ../padding.ts}),18 * so the store sees a size class, not an exact content length. New values are19 * written in the padded `enc2.` format; legacy unpadded `enc1.` values remain20 * readable and re-seal as `enc2.` on their next write.21 *22 * Constraints that fall out of the design, enforced or documented:23 * - The runtime installs the key at boot ({@link setEncryptedColumnKey});24 * touching an encrypted column before that fails closed — plaintext is25 * never written because a key was missing.26 * - Filters and policies address the stored representation, which is27 * ciphertext, so both are rejected: a `where` on an encrypted column is a28 * compile error ({@link EncryptedColumn} collapses the filter position to29 * `never`), and a permission policy referencing one fails configuration —30 * the server cannot evaluate what it cannot read. Filter decrypted rows31 * client-side with {@link matchDecrypted}.32 * - Reading with the wrong account key (a shared row from another account)33 * throws {@link EncryptedColumnError}. Encrypted columns belong in34 * account-private tables; fields shared across accounts need a shared key,35 * which this module does not provide.36 *37 * @module38 */3940import { xchacha20poly1305 } from "@noble/ciphers/chacha";41import { hkdf } from "@noble/hashes/hkdf";42import { sha256 } from "@noble/hashes/sha2";43import { schema, type TypedColumnBuilder } from "jazz-tools";44import { padPayload, unpadPayload } from "./padding.ts";4546declare const encryptedColumnBrand: unique symbol;4748/**49 * The phantom stored SQL type of encrypted columns. The union is deliberate:50 * the engine's where-input mapping branches on a single stored sql type, so a51 * union matches no branch and every filter position for the column collapses52 * to `never`. Row and insert types read the view type and are unaffected.53 */54export type EncryptedStoredSql = "TEXT" | "BYTEA";5556/**57 * The column type of every `s.encrypted*` constructor. Structurally a valid58 * table column with the declared view type, but excluded from `where` filters59 * at compile time — the store holds ciphertext, so a filter could only ever60 * compare sealed bytes. The chaining modifiers are disabled: a `.default()`61 * would be applied below the seal boundary as plaintext, `.merge()` semantics62 * other than last-write-wins cannot operate on ciphertext, `.transform()`63 * would replace the seal itself, and `.optional()` stays disabled until the64 * engine's null handling of transformed columns is pinned.65 */66export interface EncryptedColumn<TView> extends67 Omit<68 TypedColumnBuilder<EncryptedStoredSql, false, undefined, false, TView>,69 "default" | "merge" | "transform" | "optional"70 > {71 readonly [encryptedColumnBrand]?: TView;72 default(value: never): never;73 merge(strategy: never): never;74 transform(transform: never): never;75 // The `this: never` parameter keeps the method arity-compatible with the76 // base builder while making every call site a compile error.77 optional(this: never): never;78}7980const textEncoder = new TextEncoder();81const textDecoder = new TextDecoder();8283/** Raised when an encrypted column cannot seal or open a value. */84export class EncryptedColumnError extends Error {85 /** Stable error class name for diagnostics and error boundaries. */86 override readonly name = "EncryptedColumnError";87 /**88 * `key-missing` — a column was used before the runtime installed the89 * account key. `key-invalid` — an installed key had the wrong shape.90 * `corrupt` — a stored value failed authentication: tampered, replayed91 * from another column, or sealed under a different account's key.92 */93 readonly code: "key-missing" | "key-invalid" | "corrupt";94 /** Creates an encrypted-column failure with a stable category. */95 constructor(code: "key-missing" | "key-invalid" | "corrupt", message: string) {96 super(message);97 this.code = code;98 }99}100101// The 32-byte master key for this document's account, installed by the102// runtime before any database operation; per-label subkeys derive from it.103let masterKey: Uint8Array | null = null;104const labelKeys = new Map<string, Uint8Array>();105106/**107 * Installs the account-derived 32-byte master key for encrypted columns.108 * The runtime calls this at boot (before creating the database client) and109 * again whenever the account secret changes; tests inject a fixed key.110 */111export function setEncryptedColumnKey(key: Uint8Array): void {112 if (key.length !== 32) {113 throw new EncryptedColumnError("key-invalid", "the encrypted-column key must be 32 bytes");114 }115 masterKey = new Uint8Array(key);116 labelKeys.clear();117}118119/** Forgets the installed key; encrypted columns fail closed afterwards. */120export function clearEncryptedColumnKey(): void {121 masterKey = null;122 labelKeys.clear();123}124125function labelKey(label: string): Uint8Array {126 if (masterKey === null) {127 throw new EncryptedColumnError(128 "key-missing",129 `encrypted column "${label}" was used before the account key was installed`,130 );131 }132 const cached = labelKeys.get(label);133 if (cached) return cached;134 const derived = hkdf(sha256, masterKey, undefined, `lofi:encrypted-column:${label}`, 32);135 labelKeys.set(label, derived);136 return derived;137}138139function toBase64Url(bytes: Uint8Array): string {140 let binary = "";141 for (const byte of bytes) binary += String.fromCharCode(byte);142 return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", "");143}144145function fromBase64Url(text: string): Uint8Array {146 const base64 = text.replaceAll("-", "+").replaceAll("_", "/");147 const padded = base64 + "=".repeat((4 - (base64.length % 4)) % 4);148 const binary = atob(padded);149 const bytes = new Uint8Array(binary.length);150 for (let index = 0; index < binary.length; index++) bytes[index] = binary.charCodeAt(index);151 return bytes;152}153154const LEGACY_PREFIX = "enc1.";155const STORED_PREFIX = "enc2.";156const NONCE_LENGTH = 24;157158// The format version is bound as associated data because the prefix sits159// outside the AEAD: without it, rewriting one prefix into the other would160// hand the wrong unpacking to an authenticated plaintext.161function associatedData(label: string, prefix: string): Uint8Array {162 return textEncoder.encode(163 prefix === LEGACY_PREFIX164 ? `lofi:encrypted-column:${label}`165 : `lofi:encrypted-column:enc2:${label}`,166 );167}168169function seal(label: string, plaintext: string): string {170 const nonce = crypto.getRandomValues(new Uint8Array(NONCE_LENGTH));171 const cipher = xchacha20poly1305(labelKey(label), nonce, associatedData(label, STORED_PREFIX));172 const ciphertext = cipher.encrypt(padPayload(textEncoder.encode(plaintext)));173 const stored = new Uint8Array(NONCE_LENGTH + ciphertext.length);174 stored.set(nonce);175 stored.set(ciphertext, NONCE_LENGTH);176 return STORED_PREFIX + toBase64Url(stored);177}178179function open(label: string, stored: string): string {180 const prefix = typeof stored === "string" && stored.startsWith(STORED_PREFIX)181 ? STORED_PREFIX182 : typeof stored === "string" && stored.startsWith(LEGACY_PREFIX)183 ? LEGACY_PREFIX184 : null;185 if (prefix === null) {186 throw new EncryptedColumnError(187 "corrupt",188 `encrypted column "${label}" holds a value without the sealed-format prefix`,189 );190 }191 const bytes = fromBase64Url(stored.slice(prefix.length));192 if (bytes.length <= NONCE_LENGTH) {193 throw new EncryptedColumnError(194 "corrupt",195 `encrypted column "${label}" holds a truncated value`,196 );197 }198 const cipher = xchacha20poly1305(199 labelKey(label),200 bytes.slice(0, NONCE_LENGTH),201 associatedData(label, prefix),202 );203 try {204 const plaintext = cipher.decrypt(bytes.slice(NONCE_LENGTH));205 return textDecoder.decode(prefix === STORED_PREFIX ? unpadPayload(plaintext) : plaintext);206 } catch {207 throw new EncryptedColumnError(208 "corrupt",209 `encrypted column "${label}" failed authentication — sealed under a different account key, ` +210 "replayed from another column, or tampered with",211 );212 }213}214215// Runtime identity of encrypted columns: the factory tags each builder with216// its label, and the schema facade's define wrappers harvest the tags into a217// table/column registry that the access layer consults — the built schema218// types encrypted columns as ordinary TEXT, so the builders are the only219// place the distinction exists.220const ENCRYPTED_LABEL = Symbol("lofi.encryptedColumnLabel");221222const registry = new Map<string, Map<string, string>>();223const labelLocations = new Map<string, string>();224225/** The encrypted-column label carried by a builder, if it has one. */226export function encryptedColumnLabelOf(builder: unknown): string | undefined {227 if (typeof builder !== "object" || builder === null) return undefined;228 return (builder as { [ENCRYPTED_LABEL]?: string })[ENCRYPTED_LABEL];229}230231type TableDefinitionLike = {232 __jazzTableDefinition?: boolean;233 columns?: Record<string, unknown>;234};235236function tableColumnsOf(definition: unknown): Record<string, unknown> {237 const like = definition as TableDefinitionLike;238 if (like && typeof like === "object" && like.__jazzTableDefinition === true && like.columns) {239 return like.columns;240 }241 return (definition ?? {}) as Record<string, unknown>;242}243244/**245 * Records every encrypted column of a schema definition under its table and246 * column name. The schema facade's `defineSchema`/`defineApp` wrappers call247 * this; it is idempotent for a stable definition. A label reused by a second248 * column is reported: two columns sharing a label share a subkey and their249 * ciphertext replays across them.250 */251export function registerEncryptedColumns(definition: unknown): void {252 if (typeof definition !== "object" || definition === null) return;253 for (const [tableName, tableDefinition] of Object.entries(definition)) {254 for (const [columnName, builder] of Object.entries(tableColumnsOf(tableDefinition))) {255 const label = encryptedColumnLabelOf(builder);256 if (label === undefined) continue;257 const location = `${tableName}.${columnName}`;258 const existing = labelLocations.get(label);259 if (existing !== undefined && existing !== location) {260 console.warn(261 `lofi schema: encrypted-column label "${label}" is used by both ${existing} and ` +262 `${location}; shared labels share a subkey, so their ciphertext replays across ` +263 "the two columns — give each column a unique label",264 );265 }266 labelLocations.set(label, location);267 let table = registry.get(tableName);268 if (table === undefined) {269 table = new Map();270 registry.set(tableName, table);271 }272 table.set(columnName, label);273 }274 }275}276277/** Whether the named column of the named table is an encrypted column. */278export function isEncryptedColumn(tableName: string, columnName: string): boolean {279 return registry.get(tableName)?.has(columnName) ?? false;280}281282/** The registered encrypted columns of a table, keyed by column name. */283export function encryptedColumnsOf(tableName: string): ReadonlyMap<string, string> | undefined {284 return registry.get(tableName);285}286287/** Empties the encrypted-column registry; tests call this between schemas. */288export function clearEncryptedColumnRegistry(): void {289 registry.clear();290 labelLocations.clear();291}292293/**294 * Filters live-query rows on decrypted values, client-side. Encrypted columns295 * cannot appear in `where` — the store holds ciphertext — but rows reaching296 * the caller are already decrypted, so arbitrary predicates run locally:297 * narrow the query with plaintext filters first, then match here, and only298 * then apply any limit (a `limit()` before the predicate under-fetches).299 * Sorting on an encrypted column is the same pattern: sort the returned rows,300 * never `orderBy` the column (ciphertext order is arbitrary).301 */302export function matchDecrypted<T>(rows: readonly T[], predicate: (row: T) => boolean): T[] {303 return rows.filter(predicate);304}305306/**307 * Tags a live builder as an encrypted column so the registry harvest — and308 * through it the access layer's policy guard — treats it as sealed. The309 * shared-column factories tag their builders with this too.310 */311export function markEncryptedColumn<T extends object>(builder: T, label: string): T {312 Object.defineProperty(builder, ENCRYPTED_LABEL, { value: label });313 return builder;314}315316function sealedColumn<TView>(317 label: string,318 transform: { to(value: TView): string; from(value: string): TView },319): EncryptedColumn<TView> {320 const built = schema.string().transform(transform);321 markEncryptedColumn(built, label);322 return built as unknown as EncryptedColumn<TView>;323}324325/**326 * A text column sealed on the client before it enters Jazz. `label` is the327 * column's stable identity, conventionally `"table.column"`; it domain-328 * separates the subkey and is bound as associated data, so changing it later329 * makes existing values unreadable — treat it like a column name.330 *331 * ```ts332 * notes: s.table({333 * title: s.string(),334 * body: s.encryptedText("notes.body"),335 * }),336 * ```337 */338export function encryptedText(label: string): EncryptedColumn<string> {339 return sealedColumn(label, {340 to: (value: string) => seal(label, value),341 from: (value: string) => open(label, value),342 });343}344345/**346 * A JSON-value column sealed on the client before it enters Jazz; the view347 * type is the parsed value. Same label semantics as {@link encryptedText}.348 */349export function encryptedJson<T = unknown>(label: string): EncryptedColumn<T> {350 return sealedColumn(label, {351 to: (value: T) => seal(label, JSON.stringify(value)),352 from: (value: string) => JSON.parse(open(label, value)) as T,353 });354}355356/**357 * A number column sealed on the client before it enters Jazz; the view type358 * is `number`. Same label semantics as {@link encryptedText}. The stored359 * representation is text, so values beyond the plain integer column's 32-bit360 * range round-trip exactly up to double precision. Non-finite values are361 * rejected at write time.362 */363export function encryptedNumber(label: string): EncryptedColumn<number> {364 return sealedColumn(label, {365 to: (value: number) => {366 if (typeof value !== "number" || !Number.isFinite(value)) {367 throw new TypeError(`encrypted column "${label}" only stores finite numbers`);368 }369 return seal(label, String(value));370 },371 from: (value: string) => {372 const parsed = Number(open(label, value));373 if (!Number.isFinite(parsed)) {374 throw new EncryptedColumnError(375 "corrupt",376 `encrypted column "${label}" opened to a non-numeric payload`,377 );378 }379 return parsed;380 },381 });382}383384/**385 * A date column sealed on the client before it enters Jazz; the view type is386 * `Date`, matching the plain timestamp column. Same label semantics as387 * {@link encryptedText}. Invalid dates are rejected at write time.388 */389export function encryptedDate(label: string): EncryptedColumn<Date> {390 return sealedColumn(label, {391 to: (value: Date) => {392 if (!(value instanceof Date) || !Number.isFinite(value.getTime())) {393 throw new TypeError(`encrypted column "${label}" only stores valid dates`);394 }395 return seal(label, String(value.getTime()));396 },397 from: (value: string) => {398 const epochMs = Number(open(label, value));399 if (!Number.isInteger(epochMs)) {400 throw new EncryptedColumnError(401 "corrupt",402 `encrypted column "${label}" opened to a non-date payload`,403 );404 }405 return new Date(epochMs);406 },407 });408}