* feat(schema): encrypted columns — server-blind account-private fields s.encryptedText(label) and s.encryptedJson<T>(label) seal field values on the client before they enter Jazz, so the sync store holds versioned ciphertext it cannot read: content becomes invisible to the store operator while structure (tables, rows, writers, timestamps, sizes) remains, and the engine syncs the column as an ordinary string. The engine applies column transforms synchronously, so the cipher is the audited @noble XChaCha20-Poly1305 rather than WebCrypto. Per-column subkeys derive via HKDF over the column label, which is also bound as associated data — ciphertext replayed into another column refuses to open. The runtime derives the master key from the account secret and installs it before creating the database client; touching an encrypted column earlier fails closed, so a missing key can never cause a plaintext write. Reading under a foreign account key throws EncryptedColumnError instead of returning garbage. Conformance (real engine): round-trip on insert/read/update, a where on the plaintext matches nothing — the store never held it — and a foreign key refuses. Unit: fail-closed paths, label domain separation, tamper, truncation, randomized sealing, stored-shape assertions. Part of #126 (Part 1); no upstream dependency. * chore: lock the noble cipher dependencies * docs: keep author-facing docs free of issue references * docs: drop the issue reference from the encrypted-columns section
- commit
- f2100b360c4d26c82da3ddd61003a8e755f85b87
- parent
- 1377b7771290d7dc338eca8a750aa9a61ac823cb
- author
- FelineStateMachine <dami.etoile@gmail.com>
| M | apps/ | 18 | ++- |
| M | apps/ | 14 | ++- |
| M | deno.json | 22 | +++- |
| M | deno.lock | 12 | ++- |
| M | docs/ | 27 | ++++ |
| M | package/ | 1 | + |
| M | package/ | 26 | ++++ |
| M | package/ | 70 | +++++++++++- |
| A | package/ | 187 | ++++++++++++++++++++++++++++++ |
| A | package/ | 126 | ++++++++++++++++++++ |
| M | package/ | 12 | ++ |
| M | package/ | 2 | +- |
@@ -2,6 +2,9 @@
22 "imports": {33 "@astrojs/check": "npm:@astrojs/check@0.9.9",44 "@astrojs/preact": "npm:@astrojs/preact@6.0.1",5+ "@noble/ciphers/chacha": "npm:@noble/ciphers@1.3.0/chacha",6+ "@noble/hashes/hkdf": "npm:@noble/hashes@1.8.0/hkdf",7+ "@noble/hashes/sha2": "npm:@noble/hashes@1.8.0/sha2",58 "@nzip/lofi": "../../package/runtime/mod.ts",69 "@nzip/lofi/": "../../package/",710 "@nzip/lofi/access": "../../package/access/mod.ts",@@ -9,8 +12,8 @@
912 "@nzip/lofi/build": "../../package/commands/build.ts",1013 "@nzip/lofi/dev": "../../package/commands/dev.ts",1114 "@nzip/lofi/doctor": "../../package/commands/doctor.ts",12- "@nzip/lofi/preview": "../../package/commands/preview.ts",1315 "@nzip/lofi/preact": "../../package/preact/mod.ts",16+ "@nzip/lofi/preview": "../../package/commands/preview.ts",1417 "@nzip/lofi/provision": "../../package/commands/provision.ts",1518 "@nzip/lofi/recipes/file-handler": "../../package/recipes/file-handler.ts",1619 "@nzip/lofi/recipes/launch-handler": "../../package/recipes/launch-handler.ts",@@ -29,16 +32,21 @@
2932 "jazz-tools/passkey-backup": "npm:jazz-tools@2.0.0-alpha.53/passkey-backup",3033 "jazz-tools/passphrase": "npm:jazz-tools@2.0.0-alpha.53/passphrase",3134 "preact": "npm:preact@10.29.7",35+ "preact-render-to-string": "npm:preact-render-to-string@6.7.0",3236 "preact/hooks": "npm:preact@10.29.7/hooks",3337 "preact/jsx-dev-runtime": "npm:preact@10.29.7/jsx-dev-runtime",3438 "preact/jsx-runtime": "npm:preact@10.29.7/jsx-runtime",35- "preact-render-to-string": "npm:preact-render-to-string@6.7.0",3639 "typescript": "npm:typescript@6.0.3",3740 "vite": "npm:vite@8.0.1"3841 },3942 "nodeModulesDir": "auto",4043 "compilerOptions": {41- "lib": ["deno.ns", "dom", "dom.iterable", "esnext"],44+ "lib": [45+ "deno.ns",46+ "dom",47+ "dom.iterable",48+ "esnext"49+ ],4250 "jsx": "react-jsx",4351 "jsxImportSource": "preact"4452 },@@ -63,7 +71,9 @@
6371 },6472 "lint": {6573 "rules": {66- "tags": ["recommended"]74+ "tags": [75+ "recommended"76+ ]6777 }6878 }6979 }@@ -3,6 +3,8 @@
33 "specifiers": {44 "npm:@astrojs/check@0.9.9": "0.9.9_typescript@6.0.3",55 "npm:@astrojs/preact@6.0.1": "6.0.1_preact@10.29.7__preact-render-to-string@6.7.0_@babel+core@7.29.7",6+ "npm:@noble/ciphers@1.3.0": "1.3.0",7+ "npm:@noble/hashes@1.8.0": "1.8.0",68 "npm:astro@7.0.9": "7.0.9_@emnapi+core@1.11.1_@emnapi+runtime@1.11.1",79 "npm:jazz-tools@2.0.0-alpha.53": "2.0.0-alpha.53",810 "npm:preact-render-to-string@6.7.0": "6.7.0_preact@10.29.7",@@ -936,6 +938,12 @@
936938 "@tybys/wasm-util"937939 ]938940 },941+ "@noble/ciphers@1.3.0": {942+ "integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw=="943+ },944+ "@noble/hashes@1.8.0": {945+ "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="946+ },939947 "@noble/hashes@2.2.0": {940948 "integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg=="941949 },@@ -1312,7 +1320,7 @@
13121320 "@scure/bip39@2.2.0": {13131321 "integrity": "sha512-T/Bj/YvYMNkIPq6EENO6/rcs2e7qTNuyoUXf0KBFDmp0ZDu0H2X4Lq6yC3i0c8PcWkov5EbW+yQZZbdMmk154A==",13141322 "dependencies": [1315- "@noble/hashes",1323+ "@noble/hashes@2.2.0",13161324 "@scure/base"13171325 ]13181326 },@@ -3104,6 +3112,8 @@
31043112 "dependencies": [31053113 "npm:@astrojs/check@0.9.9",31063114 "npm:@astrojs/preact@6.0.1",3115+ "npm:@noble/ciphers@1.3.0",3116+ "npm:@noble/hashes@1.8.0",31073117 "npm:astro@7.0.9",31083118 "npm:jazz-tools@2.0.0-alpha.53",31093119 "npm:preact-render-to-string@6.7.0",@@ -3116,6 +3126,8 @@
31163126 "dependencies": [31173127 "npm:@astrojs/check@0.9.9",31183128 "npm:@astrojs/preact@6.0.1",3129+ "npm:@noble/ciphers@1.3.0",3130+ "npm:@noble/hashes@1.8.0",31193131 "npm:astro@7.0.9",31203132 "npm:jazz-tools@2.0.0-alpha.53",31213133 "npm:playwright@1.61.1",@@ -29,6 +29,9 @@
2929 "imports": {3030 "@astrojs/check": "npm:@astrojs/check@0.9.9",3131 "@astrojs/preact": "npm:@astrojs/preact@6.0.1",32+ "@noble/ciphers/chacha": "npm:@noble/ciphers@1.3.0/chacha",33+ "@noble/hashes/hkdf": "npm:@noble/hashes@1.8.0/hkdf",34+ "@noble/hashes/sha2": "npm:@noble/hashes@1.8.0/sha2",3235 "astro": "npm:astro@7.0.9",3336 "astro/config": "npm:astro@7.0.9/config",3437 "jazz-tools": "npm:jazz-tools@2.0.0-alpha.53",@@ -46,7 +49,12 @@
4649 },4750 "nodeModulesDir": "auto",4851 "compilerOptions": {49- "lib": ["deno.ns", "dom", "dom.iterable", "esnext"],52+ "lib": [53+ "deno.ns",54+ "dom",55+ "dom.iterable",56+ "esnext"57+ ],5058 "jsx": "react-jsx",5159 "jsxImportSource": "preact"5260 },@@ -76,13 +84,19 @@
7684 "lineWidth": 100,7785 "semiColons": true,7886 "singleQuote": false,79- "exclude": ["website"]87+ "exclude": [88+ "website"89+ ]8090 },8191 "lint": {8292 "rules": {83- "tags": ["recommended"]93+ "tags": [94+ "recommended"95+ ]8496 },85- "exclude": ["website"]97+ "exclude": [98+ "website"99+ ]86100 },87101 "publish": {88102 "include": [@@ -3,6 +3,8 @@
33 "specifiers": {44 "npm:@astrojs/check@0.9.9": "0.9.9_typescript@6.0.3",55 "npm:@astrojs/preact@6.0.1": "6.0.1_preact@10.29.7__preact-render-to-string@6.7.0_@babel+core@7.29.7",6+ "npm:@noble/ciphers@1.3.0": "1.3.0",7+ "npm:@noble/hashes@1.8.0": "1.8.0",68 "npm:astro@7.0.9": "7.0.9_@emnapi+core@1.11.1_@emnapi+runtime@1.11.1",79 "npm:jazz-tools@2.0.0-alpha.53": "2.0.0-alpha.53",810 "npm:playwright@*": "1.61.1",@@ -938,6 +940,12 @@
938940 "@tybys/wasm-util"939941 ]940942 },943+ "@noble/ciphers@1.3.0": {944+ "integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw=="945+ },946+ "@noble/hashes@1.8.0": {947+ "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="948+ },941949 "@noble/hashes@2.2.0": {942950 "integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg=="943951 },@@ -1314,7 +1322,7 @@
13141322 "@scure/bip39@2.2.0": {13151323 "integrity": "sha512-T/Bj/YvYMNkIPq6EENO6/rcs2e7qTNuyoUXf0KBFDmp0ZDu0H2X4Lq6yC3i0c8PcWkov5EbW+yQZZbdMmk154A==",13161324 "dependencies": [1317- "@noble/hashes",1325+ "@noble/hashes@2.2.0",13181326 "@scure/base"13191327 ]13201328 },@@ -3125,6 +3133,8 @@
31253133 "dependencies": [31263134 "npm:@astrojs/check@0.9.9",31273135 "npm:@astrojs/preact@6.0.1",3136+ "npm:@noble/ciphers@1.3.0",3137+ "npm:@noble/hashes@1.8.0",31283138 "npm:astro@7.0.9",31293139 "npm:jazz-tools@2.0.0-alpha.53",31303140 "npm:playwright@1.61.1",@@ -111,6 +111,33 @@ CoValue types (`co.map`, CoText, FileStream). All three are verified with two sy
111111 watched the history diverges permanently from what a fresh boot reads. Avoid counter columns until112112 an alpha bump clears the pins (see [the decision record](decisions/schema-facade-alpha53.md)).113113 114+### Encrypted columns115+116+`s.encryptedText(label)` and `s.encryptedJson<T>(label)` seal a field on the client before it enters117+Jazz: the sync store holds versioned ciphertext (XChaCha20-Poly1305, a per-column subkey derived118+from the account secret), so the store operator sees that the column exists — sizes, writers,119+timestamps — but never its content. The runtime installs the key at boot; touching an encrypted120+column before then fails closed rather than writing plaintext.121+122+```ts123+notes: s.table({124+ title: s.string(),125+ body: s.encryptedText("notes.body"),126+ attachments: s.encryptedJson<{ name: string; size: number }[]>("notes.attachments"),127+}),128+```129+130+The `label` is the column's cryptographic identity, conventionally `"table.column"`: it binds the131+ciphertext to the column (a value replayed into another column refuses to open) and changing it132+later makes existing values unreadable — treat it like a column name. Constraints to design around:133+134+- **Account-private only, for now.** The key derives from the account secret, so every device135+ holding the account decrypts and nobody else does — including other members of a shared group. A136+ row read under a different account throws `EncryptedColumnError` instead of returning garbage.137+- **Not filterable, not policy-visible.** `where` on an encrypted column compares ciphertext and138+ matches nothing useful, and a permission policy must not reference one — the server cannot139+ evaluate what it cannot read. Filter and gate on plaintext columns beside it.140+114141 ## Bind an exact typed query115142 116143 ```ts@@ -4,6 +4,7 @@ import { BrowserAuthSecretStore, createDb, type Db } from "jazz-tools";
44 import { getLofiApp } from "./app.ts";55 import { createDiagnostics, type RuntimeDiagnostics } from "./diagnostics.ts";66 import { appId, databaseConfig, syncing } from "./config.ts";7+import { setEncryptedColumnKey } from "../schema/encrypted.ts";78 import { assertDurableBrowser } from "./device-capabilities.ts";89 import {910 createTableStore,@@ -169,6 +170,30 @@ async function migrateLocalRows(
169170 // sibling; the page lifecycle releases both ports safely.170171 }171172 173+// Encrypted columns fail closed until this runs: the master key derives from174+// the account secret, so it exists before the first database operation and is175+// re-derived whenever the runtime is recreated after a secret change.176+async function installEncryptedColumnKey(secret: string): Promise<void> {177+ const material = await crypto.subtle.importKey(178+ "raw",179+ new TextEncoder().encode(secret) as BufferSource,180+ "HKDF",181+ false,182+ ["deriveBits"],183+ );184+ const bits = await crypto.subtle.deriveBits(185+ {186+ name: "HKDF",187+ hash: "SHA-256",188+ salt: new Uint8Array(0) as BufferSource,189+ info: new TextEncoder().encode("lofi:encrypted-columns:v1") as BufferSource,190+ },191+ material,192+ 256,193+ );194+ setEncryptedColumnKey(new Uint8Array(bits));195+}196+172197 async function createClient(state: RuntimeSlot): Promise<Db> {173198 const namespaceState = readNamespaceState();174199 const connect = syncing();@@ -180,6 +205,7 @@ async function createClient(state: RuntimeSlot): Promise<Db> {
180205 return await runRuntimeStartup(runtimeMode, async () => {181206 assertDurableBrowser();182207 const secret = await resolveAccountSecret();208+ await installEncryptedColumnKey(secret);183209 const namespace = await accountNamespace(secret);184210 const db = await createDb(185211 databaseConfig(@@ -8,7 +8,15 @@
88 // application author can actually reach.99 import { createPolicyTestApp } from "jazz-tools/testing";1010 import { assert } from "../runtime/test-assert.ts";11-import { type ArrayColumn, type IntColumn, s, type StringColumn } from "./mod.ts";11+import {12+ type ArrayColumn,13+ clearEncryptedColumnKey,14+ EncryptedColumnError,15+ type IntColumn,16+ s,17+ setEncryptedColumnKey,18+ type StringColumn,19+} from "./mod.ts";1220 1321 const app = s.defineApp({1422 parents: s.table({ name: s.string() }),@@ -45,6 +53,11 @@ const app = s.defineApp({
4553 label: s.string(),4654 }),4755 gated: s.table({ visible: s.boolean(), title: s.string() }),56+ sealed: s.table({57+ body: s.encryptedText("sealed.body"),58+ meta: s.encryptedJson<{ note: string }>("sealed.meta"),59+ label: s.string(),60+ }),4861 });4962 5063 // CONFORMANCE FINDING (alpha.53): a `g-set` column anywhere in a schema makes@@ -78,6 +91,7 @@ const allowAllTables = [
7891 "refs",7992 "arrays",8093 "modifiers",94+ "sealed",8195 "transformed",8296 ] as const;8397 @@ -535,3 +549,57 @@ Deno.test("facade scalar columns round-trip, filter, and update through the real
535549 await testApp.shutdown();536550 }537551 });552+553+Deno.test("encrypted columns round-trip through the engine and store only ciphertext", async () => {554+ const testApp = await createPolicyTestApp(app, permissions, expectLike);555+ setEncryptedColumnKey(new Uint8Array(32).map((_, index) => index + 1));556+ try {557+ const db = testApp.as(session("author"));558+ const body = "the sealed plaintext body";559+ const row = await db.insert(app.sealed, {560+ body,561+ meta: { note: "sealed json" },562+ label: "first",563+ }).wait({ tier: "global" });564+ assert(row.body === body, `sealed: insert returned ${describeValue(row.body)}`);565+ const readBack = (await db.all(app.sealed.where({ id: row.id })))[0];566+ assert(readBack !== undefined, "sealed: row lost after insert");567+ assert(readBack.body === body, `sealed: read back ${describeValue(readBack.body)}`);568+ assert(569+ sameValue(readBack.meta, { note: "sealed json" }),570+ `sealed: json view was ${describeValue(readBack.meta)}`,571+ );572+573+ // Filters address the stored representation: the plaintext must match574+ // nothing, because the engine never held it.575+ const byPlaintext = await db.all(576+ app.sealed.where({ body } as never),577+ );578+ assert(579+ byPlaintext.length === 0,580+ `sealed: a where on the plaintext matched ${byPlaintext.length} rows — the store saw plaintext`,581+ );582+583+ await db.update(app.sealed, row.id, { body: "rewritten sealed body" }).wait({584+ tier: "global",585+ });586+ const updated = (await db.all(app.sealed.where({ id: row.id })))[0];587+ assert(588+ updated?.body === "rewritten sealed body",589+ `sealed: update read back ${describeValue(updated?.body)}`,590+ );591+592+ // The wrong account key must refuse loudly, never return garbage.593+ setEncryptedColumnKey(new Uint8Array(32).map((_, index) => 255 - index));594+ let refused = false;595+ try {596+ await db.all(app.sealed.where({ id: row.id }));597+ } catch (error) {598+ refused = error instanceof EncryptedColumnError && error.code === "corrupt";599+ }600+ assert(refused, "sealed: a foreign account key must fail authentication");601+ } finally {602+ clearEncryptedColumnKey();603+ await testApp.shutdown();604+ }605+});@@ -0,0 +1,187 @@
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 bound as associated data so ciphertext cannot be replayed13+ * across columns). Transforms are synchronous in the pinned engine, so the14+ * cipher is the audited `@noble` implementation rather than WebCrypto.15+ *16+ * Constraints that fall out of the design, enforced or documented:17+ * - The runtime installs the key at boot ({@link setEncryptedColumnKey});18+ * touching an encrypted column before that fails closed — plaintext is19+ * never written because a key was missing.20+ * - Filters and policies address the stored representation. A `where` on an21+ * encrypted column compares ciphertext (useless, harmless); a permission22+ * policy must not reference one — the server cannot evaluate what it23+ * cannot read.24+ * - Reading with the wrong account key (a shared row from another account)25+ * throws {@link EncryptedColumnError}. Encrypted columns belong in26+ * account-private tables; fields shared across accounts need a shared key,27+ * which this module does not provide.28+ *29+ * @module30+ */31+32+import { xchacha20poly1305 } from "@noble/ciphers/chacha";33+import { hkdf } from "@noble/hashes/hkdf";34+import { sha256 } from "@noble/hashes/sha2";35+import { schema, type StringColumn } from "jazz-tools";36+37+const textEncoder = new TextEncoder();38+const textDecoder = new TextDecoder();39+40+/** Raised when an encrypted column cannot seal or open a value. */41+export class EncryptedColumnError extends Error {42+ /** Stable error class name for diagnostics and error boundaries. */43+ override readonly name = "EncryptedColumnError";44+ /**45+ * `key-missing` — a column was used before the runtime installed the46+ * account key. `key-invalid` — an installed key had the wrong shape.47+ * `corrupt` — a stored value failed authentication: tampered, replayed48+ * from another column, or sealed under a different account's key.49+ */50+ readonly code: "key-missing" | "key-invalid" | "corrupt";51+ /** Creates an encrypted-column failure with a stable category. */52+ constructor(code: "key-missing" | "key-invalid" | "corrupt", message: string) {53+ super(message);54+ this.code = code;55+ }56+}57+58+// The 32-byte master key for this document's account, installed by the59+// runtime before any database operation; per-label subkeys derive from it.60+let masterKey: Uint8Array | null = null;61+const labelKeys = new Map<string, Uint8Array>();62+63+/**64+ * Installs the account-derived 32-byte master key for encrypted columns.65+ * The runtime calls this at boot (before creating the database client) and66+ * again whenever the account secret changes; tests inject a fixed key.67+ */68+export function setEncryptedColumnKey(key: Uint8Array): void {69+ if (key.length !== 32) {70+ throw new EncryptedColumnError("key-invalid", "the encrypted-column key must be 32 bytes");71+ }72+ masterKey = new Uint8Array(key);73+ labelKeys.clear();74+}75+76+/** Forgets the installed key; encrypted columns fail closed afterwards. */77+export function clearEncryptedColumnKey(): void {78+ masterKey = null;79+ labelKeys.clear();80+}81+82+function labelKey(label: string): Uint8Array {83+ if (masterKey === null) {84+ throw new EncryptedColumnError(85+ "key-missing",86+ `encrypted column "${label}" was used before the account key was installed`,87+ );88+ }89+ const cached = labelKeys.get(label);90+ if (cached) return cached;91+ const derived = hkdf(sha256, masterKey, undefined, `lofi:encrypted-column:${label}`, 32);92+ labelKeys.set(label, derived);93+ return derived;94+}95+96+function toBase64Url(bytes: Uint8Array): string {97+ let binary = "";98+ for (const byte of bytes) binary += String.fromCharCode(byte);99+ return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", "");100+}101+102+function fromBase64Url(text: string): Uint8Array {103+ const base64 = text.replaceAll("-", "+").replaceAll("_", "/");104+ const padded = base64 + "=".repeat((4 - (base64.length % 4)) % 4);105+ const binary = atob(padded);106+ const bytes = new Uint8Array(binary.length);107+ for (let index = 0; index < binary.length; index++) bytes[index] = binary.charCodeAt(index);108+ return bytes;109+}110+111+const STORED_PREFIX = "enc1.";112+const NONCE_LENGTH = 24;113+114+function seal(label: string, plaintext: string): string {115+ const nonce = crypto.getRandomValues(new Uint8Array(NONCE_LENGTH));116+ const cipher = xchacha20poly1305(117+ labelKey(label),118+ nonce,119+ textEncoder.encode(`lofi:encrypted-column:${label}`),120+ );121+ const ciphertext = cipher.encrypt(textEncoder.encode(plaintext));122+ const stored = new Uint8Array(NONCE_LENGTH + ciphertext.length);123+ stored.set(nonce);124+ stored.set(ciphertext, NONCE_LENGTH);125+ return STORED_PREFIX + toBase64Url(stored);126+}127+128+function open(label: string, stored: string): string {129+ if (typeof stored !== "string" || !stored.startsWith(STORED_PREFIX)) {130+ throw new EncryptedColumnError(131+ "corrupt",132+ `encrypted column "${label}" holds a value without the sealed-format prefix`,133+ );134+ }135+ const bytes = fromBase64Url(stored.slice(STORED_PREFIX.length));136+ if (bytes.length <= NONCE_LENGTH) {137+ throw new EncryptedColumnError(138+ "corrupt",139+ `encrypted column "${label}" holds a truncated value`,140+ );141+ }142+ const cipher = xchacha20poly1305(143+ labelKey(label),144+ bytes.slice(0, NONCE_LENGTH),145+ textEncoder.encode(`lofi:encrypted-column:${label}`),146+ );147+ try {148+ return textDecoder.decode(cipher.decrypt(bytes.slice(NONCE_LENGTH)));149+ } catch {150+ throw new EncryptedColumnError(151+ "corrupt",152+ `encrypted column "${label}" failed authentication — sealed under a different account key, ` +153+ "replayed from another column, or tampered with",154+ );155+ }156+}157+158+/**159+ * A text column sealed on the client before it enters Jazz. `label` is the160+ * column's stable identity, conventionally `"table.column"`; it domain-161+ * separates the subkey and is bound as associated data, so changing it later162+ * makes existing values unreadable — treat it like a column name.163+ *164+ * ```ts165+ * notes: s.table({166+ * title: s.string(),167+ * body: s.encryptedText("notes.body"),168+ * }),169+ * ```170+ */171+export function encryptedText(label: string): StringColumn<false, false, string> {172+ return schema.string().transform({173+ to: (value: string) => seal(label, value),174+ from: (value: string) => open(label, value),175+ }) as unknown as StringColumn<false, false, string>;176+}177+178+/**179+ * A JSON-value column sealed on the client before it enters Jazz; the view180+ * type is the parsed value. Same label semantics as {@link encryptedText}.181+ */182+export function encryptedJson<T = unknown>(label: string): StringColumn<false, false, T> {183+ return schema.string().transform({184+ to: (value: T) => seal(label, JSON.stringify(value)),185+ from: (value: string) => JSON.parse(open(label, value)) as T,186+ }) as unknown as StringColumn<false, false, T>;187+}@@ -0,0 +1,126 @@
1+// Encrypted-column contract: seal/open round-trip, fail-closed without a key,2+// label domain separation, tamper and wrong-key refusal, and the stored shape3+// (versioned prefix, no plaintext in any trivial encoding).4+import {5+ clearEncryptedColumnKey,6+ EncryptedColumnError,7+ encryptedJson,8+ encryptedText,9+ setEncryptedColumnKey,10+} from "./encrypted.ts";11+import { assert } from "../runtime/test-assert.ts";12+13+type Transformed = {14+ _columnTransforms?: never;15+} & { transform?: never };16+17+// The facade casts transformed columns back to typed builders; tests reach the18+// live transform pair through the runtime column object the same way the19+// engine does.20+function transformOf(column: unknown): {21+ to: (value: unknown) => string;22+ from: (value: string) => unknown;23+} {24+ const transform = (column as { _transform?: unknown })._transform ??25+ (column as { transform?: unknown }).transform;26+ assert(27+ typeof transform === "object" && transform !== null,28+ `column carries no transform pair: ${JSON.stringify(Object.keys(column as object))}`,29+ );30+ return transform as { to: (value: unknown) => string; from: (value: string) => unknown };31+}32+33+const key = new Uint8Array(32).map((_, index) => index + 1);34+const otherKey = new Uint8Array(32).map((_, index) => 101 + index);35+36+function withKey(body: () => void): () => void {37+ return () => {38+ setEncryptedColumnKey(key);39+ try {40+ body();41+ } finally {42+ clearEncryptedColumnKey();43+ }44+ };45+}46+47+function expectFailure(48+ run: () => unknown,49+ code: EncryptedColumnError["code"],50+ context: string,51+): void {52+ try {53+ run();54+ } catch (error) {55+ assert(56+ error instanceof EncryptedColumnError && error.code === code,57+ `${context}: expected ${code}, received ${String(error)}`,58+ );59+ return;60+ }61+ throw new Error(`${context}: expected ${code}, but the operation succeeded`);62+}63+64+Deno.test(65+ "text and json values round-trip and store versioned ciphertext",66+ withKey(() => {67+ const text = transformOf(encryptedText("t.body"));68+ const plaintext = "a private sentence nobody else reads";69+ const stored = text.to(plaintext);70+ assert(71+ stored.startsWith("enc1."),72+ `stored value lacks the version prefix: ${stored.slice(0, 12)}`,73+ );74+ assert(!stored.includes(plaintext), "stored value contains the plaintext");75+ assert(!stored.includes(btoa(plaintext)), "stored value contains base64 plaintext");76+ assert(text.from(stored) === plaintext, "text round-trip mismatch");77+ assert(text.to(plaintext) !== stored, "sealing must be randomized per write");78+79+ const json = transformOf(encryptedJson<{ n: number }>("t.meta"));80+ const value = { n: 42 };81+ const storedJson = json.to(value);82+ assert(!storedJson.includes("42"), "stored json contains plaintext content");83+ assert(84+ (json.from(storedJson) as { n: number }).n === 42,85+ "json round-trip mismatch",86+ );87+ }),88+);89+90+Deno.test("without an installed key every operation fails closed", () => {91+ clearEncryptedColumnKey();92+ const text = transformOf(encryptedText("t.body"));93+ expectFailure(() => text.to("value"), "key-missing", "seal without key");94+ // A structurally well-formed value: 40 base64url chars decode to 30 bytes,95+ // past the nonce-length check, so the missing key is what refuses.96+ expectFailure(() => text.from(`enc1.${"A".repeat(40)}`), "key-missing", "open without key");97+ expectFailure(98+ () => setEncryptedColumnKey(new Uint8Array(16)),99+ "key-invalid",100+ "short key",101+ );102+});103+104+Deno.test(105+ "labels domain-separate: ciphertext cannot replay across columns",106+ withKey(() => {107+ const first = transformOf(encryptedText("a.body"));108+ const second = transformOf(encryptedText("b.body"));109+ const stored = first.to("value");110+ expectFailure(() => second.from(stored), "corrupt", "cross-column replay");111+ }),112+);113+114+Deno.test(115+ "tampered, truncated, unprefixed, and foreign-key values are refused",116+ withKey(() => {117+ const text = transformOf(encryptedText("t.body"));118+ const stored = text.to("value");119+ const flipped = stored.slice(0, -2) + (stored.endsWith("aa") ? "bb" : "aa");120+ expectFailure(() => text.from(flipped), "corrupt", "tampered value");121+ expectFailure(() => text.from("enc1.AAAA"), "corrupt", "truncated value");122+ expectFailure(() => text.from("plaintext"), "corrupt", "unprefixed value");123+ setEncryptedColumnKey(otherKey);124+ expectFailure(() => text.from(stored), "corrupt", "foreign key");125+ }),126+);@@ -23,6 +23,7 @@
2323 * 2424 */2525 import { schema } from "jazz-tools";26+import { encryptedJson, encryptedText } from "./encrypted.ts";2627 import {2728 defineNestedApp,2829 defineNestedPermissions,@@ -73,6 +74,8 @@ export type SchemaDsl =
7374 defineNestedPermissions: typeof defineNestedPermissions;7475 mergeNestedPermissions: typeof mergeNestedPermissions;7576 flattenNestedSchema: typeof flattenNestedSchema;77+ encryptedText: typeof encryptedText;78+ encryptedJson: typeof encryptedJson;7679 };7780 7881 /**@@ -87,8 +90,17 @@ export const s: SchemaDsl = {
8790 defineNestedPermissions,8891 mergeNestedPermissions,8992 flattenNestedSchema,93+ encryptedText,94+ encryptedJson,9095 };9196 97+export {98+ clearEncryptedColumnKey,99+ EncryptedColumnError,100+ encryptedJson,101+ encryptedText,102+ setEncryptedColumnKey,103+} from "./encrypted.ts";92104 export {93105 defineNestedApp,94106 defineNestedPermissions,@@ -1,7 +1,7 @@
11 {22 ".env.example": "0cfad81a3ba6442ac15d7cbe44a00374bf4dbf8af68ca412e54fb100241ddeab",33 ".gitignore": "9b9fa4b95f8f728ce80e93fc224d430328786b620096b80f067a3190c410a00e",4- "deno.json": "45eda461a1f4abdecebedd019be66c54029057cec7f6fe425444cae2d652ab99",4+ "deno.json": "fa0b7dc7e8c5e39e31b3d8c38621b8b6290dff8a0e5278519d91b66f6d9897e8",55 "public/apple-touch-icon.png": "be2b77066d9681709210388413ddcd94318906799ddd6645934b789a04bd4a61",66 "public/favicon.svg": "35b92ad48231c470b348eaebe6d6fe14acf2ec4a86e6726a03ccb130328a0d85",77 "public/icon-192.png": "6ee711f9ec990ec55f789d7943813c5193973bf2ca9199078ee85d285317e07c",