clone

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

main
lofi / package / schema / encrypted.ts
encrypted.ts 16.5K
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 /** Carries the decrypted view type without affecting the runtime shape. */72 readonly [encryptedColumnBrand]?: TView;73 /** Encrypted columns cannot define plaintext defaults. */74 default(value: never): never;75 /** Encrypted columns cannot merge ciphertext values. */76 merge(strategy: never): never;77 /** Encrypted columns cannot replace their sealing transform. */78 transform(transform: never): never;79 /**80 * Encrypted columns cannot be optional.81 *82 * The `this: never` parameter keeps the method arity-compatible with the83 * base builder while making every call site a compile error.84 */85 optional(this: never): never;86}8788const textEncoder = new TextEncoder();89const textDecoder = new TextDecoder();9091/** Raised when an encrypted column cannot seal or open a value. */92export class EncryptedColumnError extends Error {93 /** Stable error class name for diagnostics and error boundaries. */94 override readonly name = "EncryptedColumnError";95 /**96 * `key-missing` — a column was used before the runtime installed the97 * account key. `key-invalid` — an installed key had the wrong shape.98 * `corrupt` — a stored value failed authentication: tampered, replayed99 * from another column, or sealed under a different account's key.100 */101 readonly code: "key-missing" | "key-invalid" | "corrupt";102 /** Creates an encrypted-column failure with a stable category. */103 constructor(code: "key-missing" | "key-invalid" | "corrupt", message: string) {104 super(message);105 this.code = code;106 }107}108109// The 32-byte master key for this document's account, installed by the110// runtime before any database operation; per-label subkeys derive from it.111let masterKey: Uint8Array | null = null;112const labelKeys = new Map<string, Uint8Array>();113114/**115 * Installs the account-derived 32-byte master key for encrypted columns.116 * The runtime calls this at boot (before creating the database client) and117 * again whenever the account secret changes; tests inject a fixed key.118 */119export function setEncryptedColumnKey(key: Uint8Array): void {120 if (key.length !== 32) {121 throw new EncryptedColumnError("key-invalid", "the encrypted-column key must be 32 bytes");122 }123 masterKey = new Uint8Array(key);124 labelKeys.clear();125}126127/** Forgets the installed key; encrypted columns fail closed afterwards. */128export function clearEncryptedColumnKey(): void {129 masterKey = null;130 labelKeys.clear();131}132133function labelKey(label: string): Uint8Array {134 if (masterKey === null) {135 throw new EncryptedColumnError(136 "key-missing",137 `encrypted column "${label}" was used before the account key was installed`,138 );139 }140 const cached = labelKeys.get(label);141 if (cached) return cached;142 const derived = hkdf(sha256, masterKey, undefined, `lofi:encrypted-column:${label}`, 32);143 labelKeys.set(label, derived);144 return derived;145}146147function toBase64Url(bytes: Uint8Array): string {148 let binary = "";149 for (const byte of bytes) binary += String.fromCharCode(byte);150 return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", "");151}152153function fromBase64Url(text: string): Uint8Array {154 const base64 = text.replaceAll("-", "+").replaceAll("_", "/");155 const padded = base64 + "=".repeat((4 - (base64.length % 4)) % 4);156 const binary = atob(padded);157 const bytes = new Uint8Array(binary.length);158 for (let index = 0; index < binary.length; index++) bytes[index] = binary.charCodeAt(index);159 return bytes;160}161162const LEGACY_PREFIX = "enc1.";163const STORED_PREFIX = "enc2.";164const NONCE_LENGTH = 24;165166// The format version is bound as associated data because the prefix sits167// outside the AEAD: without it, rewriting one prefix into the other would168// hand the wrong unpacking to an authenticated plaintext.169function associatedData(label: string, prefix: string): Uint8Array {170 return textEncoder.encode(171 prefix === LEGACY_PREFIX172 ? `lofi:encrypted-column:${label}`173 : `lofi:encrypted-column:enc2:${label}`,174 );175}176177function seal(label: string, plaintext: string): string {178 const nonce = crypto.getRandomValues(new Uint8Array(NONCE_LENGTH));179 const cipher = xchacha20poly1305(labelKey(label), nonce, associatedData(label, STORED_PREFIX));180 const ciphertext = cipher.encrypt(padPayload(textEncoder.encode(plaintext)));181 const stored = new Uint8Array(NONCE_LENGTH + ciphertext.length);182 stored.set(nonce);183 stored.set(ciphertext, NONCE_LENGTH);184 return STORED_PREFIX + toBase64Url(stored);185}186187function open(label: string, stored: string): string {188 const prefix = typeof stored === "string" && stored.startsWith(STORED_PREFIX)189 ? STORED_PREFIX190 : typeof stored === "string" && stored.startsWith(LEGACY_PREFIX)191 ? LEGACY_PREFIX192 : null;193 if (prefix === null) {194 throw new EncryptedColumnError(195 "corrupt",196 `encrypted column "${label}" holds a value without the sealed-format prefix`,197 );198 }199 const bytes = fromBase64Url(stored.slice(prefix.length));200 if (bytes.length <= NONCE_LENGTH) {201 throw new EncryptedColumnError(202 "corrupt",203 `encrypted column "${label}" holds a truncated value`,204 );205 }206 const cipher = xchacha20poly1305(207 labelKey(label),208 bytes.slice(0, NONCE_LENGTH),209 associatedData(label, prefix),210 );211 try {212 const plaintext = cipher.decrypt(bytes.slice(NONCE_LENGTH));213 return textDecoder.decode(prefix === STORED_PREFIX ? unpadPayload(plaintext) : plaintext);214 } catch {215 throw new EncryptedColumnError(216 "corrupt",217 `encrypted column "${label}" failed authentication — sealed under a different account key, ` +218 "replayed from another column, or tampered with",219 );220 }221}222223// Runtime identity of encrypted columns: the factory tags each builder with224// its label, and the schema facade's define wrappers harvest the tags into a225// table/column registry that the access layer consults — the built schema226// types encrypted columns as ordinary TEXT, so the builders are the only227// place the distinction exists.228const ENCRYPTED_LABEL = Symbol("lofi.encryptedColumnLabel");229230const registry = new Map<string, Map<string, string>>();231const labelLocations = new Map<string, string>();232233/** The encrypted-column label carried by a builder, if it has one. */234export function encryptedColumnLabelOf(builder: unknown): string | undefined {235 if (typeof builder !== "object" || builder === null) return undefined;236 return (builder as { [ENCRYPTED_LABEL]?: string })[ENCRYPTED_LABEL];237}238239type TableDefinitionLike = {240 __jazzTableDefinition?: boolean;241 columns?: Record<string, unknown>;242};243244function tableColumnsOf(definition: unknown): Record<string, unknown> {245 const like = definition as TableDefinitionLike;246 if (like && typeof like === "object" && like.__jazzTableDefinition === true && like.columns) {247 return like.columns;248 }249 return (definition ?? {}) as Record<string, unknown>;250}251252/**253 * Records every encrypted column of a schema definition under its table and254 * column name. The schema facade's `defineSchema`/`defineApp` wrappers call255 * this; it is idempotent for a stable definition. A label reused by a second256 * column is reported: two columns sharing a label share a subkey and their257 * ciphertext replays across them.258 */259export function registerEncryptedColumns(definition: unknown): void {260 if (typeof definition !== "object" || definition === null) return;261 for (const [tableName, tableDefinition] of Object.entries(definition)) {262 for (const [columnName, builder] of Object.entries(tableColumnsOf(tableDefinition))) {263 const label = encryptedColumnLabelOf(builder);264 if (label === undefined) continue;265 const location = `${tableName}.${columnName}`;266 const existing = labelLocations.get(label);267 if (existing !== undefined && existing !== location) {268 console.warn(269 `lofi schema: encrypted-column label "${label}" is used by both ${existing} and ` +270 `${location}; shared labels share a subkey, so their ciphertext replays across ` +271 "the two columns — give each column a unique label",272 );273 }274 labelLocations.set(label, location);275 let table = registry.get(tableName);276 if (table === undefined) {277 table = new Map();278 registry.set(tableName, table);279 }280 table.set(columnName, label);281 }282 }283}284285/** Whether the named column of the named table is an encrypted column. */286export function isEncryptedColumn(tableName: string, columnName: string): boolean {287 return registry.get(tableName)?.has(columnName) ?? false;288}289290/** The registered encrypted columns of a table, keyed by column name. */291export function encryptedColumnsOf(tableName: string): ReadonlyMap<string, string> | undefined {292 return registry.get(tableName);293}294295/** Empties the encrypted-column registry; tests call this between schemas. */296export function clearEncryptedColumnRegistry(): void {297 registry.clear();298 labelLocations.clear();299}300301/**302 * Filters live-query rows on decrypted values, client-side. Encrypted columns303 * cannot appear in `where` — the store holds ciphertext — but rows reaching304 * the caller are already decrypted, so arbitrary predicates run locally:305 * narrow the query with plaintext filters first, then match here, and only306 * then apply any limit (a `limit()` before the predicate under-fetches).307 * Sorting on an encrypted column is the same pattern: sort the returned rows,308 * never `orderBy` the column (ciphertext order is arbitrary).309 */310export function matchDecrypted<T>(rows: readonly T[], predicate: (row: T) => boolean): T[] {311 return rows.filter(predicate);312}313314/**315 * Tags a live builder as an encrypted column so the registry harvest — and316 * through it the access layer's policy guard — treats it as sealed. The317 * shared-column factories tag their builders with this too.318 */319export function markEncryptedColumn<T extends object>(builder: T, label: string): T {320 Object.defineProperty(builder, ENCRYPTED_LABEL, { value: label });321 return builder;322}323324function sealedColumn<TView>(325 label: string,326 transform: { to(value: TView): string; from(value: string): TView },327): EncryptedColumn<TView> {328 const built = schema.string().transform(transform);329 markEncryptedColumn(built, label);330 return built as unknown as EncryptedColumn<TView>;331}332333/**334 * A text column sealed on the client before it enters Jazz. `label` is the335 * column's stable identity, conventionally `"table.column"`; it domain-336 * separates the subkey and is bound as associated data, so changing it later337 * makes existing values unreadable — treat it like a column name.338 *339 * ```ts340 * notes: s.table({341 * title: s.string(),342 * body: s.encryptedText("notes.body"),343 * }),344 * ```345 */346export function encryptedText(label: string): EncryptedColumn<string> {347 return sealedColumn(label, {348 to: (value: string) => seal(label, value),349 from: (value: string) => open(label, value),350 });351}352353/**354 * A JSON-value column sealed on the client before it enters Jazz; the view355 * type is the parsed value. Same label semantics as {@link encryptedText}.356 */357export function encryptedJson<T = unknown>(label: string): EncryptedColumn<T> {358 return sealedColumn(label, {359 to: (value: T) => seal(label, JSON.stringify(value)),360 from: (value: string) => JSON.parse(open(label, value)) as T,361 });362}363364/**365 * A number column sealed on the client before it enters Jazz; the view type366 * is `number`. Same label semantics as {@link encryptedText}. The stored367 * representation is text, so values beyond the plain integer column's 32-bit368 * range round-trip exactly up to double precision. Non-finite values are369 * rejected at write time.370 */371export function encryptedNumber(label: string): EncryptedColumn<number> {372 return sealedColumn(label, {373 to: (value: number) => {374 if (typeof value !== "number" || !Number.isFinite(value)) {375 throw new TypeError(`encrypted column "${label}" only stores finite numbers`);376 }377 return seal(label, String(value));378 },379 from: (value: string) => {380 const parsed = Number(open(label, value));381 if (!Number.isFinite(parsed)) {382 throw new EncryptedColumnError(383 "corrupt",384 `encrypted column "${label}" opened to a non-numeric payload`,385 );386 }387 return parsed;388 },389 });390}391392/**393 * A date column sealed on the client before it enters Jazz; the view type is394 * `Date`, matching the plain timestamp column. Same label semantics as395 * {@link encryptedText}. Invalid dates are rejected at write time.396 */397export function encryptedDate(label: string): EncryptedColumn<Date> {398 return sealedColumn(label, {399 to: (value: Date) => {400 if (!(value instanceof Date) || !Number.isFinite(value.getTime())) {401 throw new TypeError(`encrypted column "${label}" only stores valid dates`);402 }403 return seal(label, String(value.getTime()));404 },405 from: (value: string) => {406 const epochMs = Number(open(label, value));407 if (!Number.isInteger(epochMs)) {408 throw new EncryptedColumnError(409 "corrupt",410 `encrypted column "${label}" opened to a non-date payload`,411 );412 }413 return new Date(epochMs);414 },415 });416}