clone

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

feat(schema): enc2 padded encrypted-column format and sealed number/date scalars (#152)

FelineStateMachine committed ee51c16
Plaintext pads to bucketed sizes (Padmé, 64-byte floor) before sealing, so
stored lengths reveal a size class rather than exact content length. The
format version binds into the associated data, making enc1/enc2 prefix
flips fail authentication in both directions; legacy enc1 values open
indefinitely and re-seal on their next write.

encryptedNumber and encryptedDate seal scalars over the same path; their
TEXT storage sidesteps the pinned i32 integer-column limit. A conformance
finding pins that a plaintext-number where throws in the query adapter
rather than matching zero rows.

Wires encrypted_test.ts into test:unit (previously ran in no CI task) and
adds padding.ts to the vendoring manifest.
commit
ee51c16f8ccbe3c9bc82ffc15febb3cb2e0b85b8
parent
f9b9339fbe8f8e2dce58f70e9b3ff19564f25219
author
FelineStateMachine <dami.etoile@gmail.com>
10 files changed, +411 -28
Mdeno.json2+-
Mdocs/data-and-ui.md19+++-
Adocs/decisions/encrypted-column-format-enc2.md64++++++++++++++
Mdocs/threat-model.md10+-
Mpackage/astro/mod.ts1+
Mpackage/schema/conformance_test.ts31+++++++
Mpackage/schema/encrypted.ts103++++++++++++++++++++---
Mpackage/schema/encrypted_test.ts133++++++++++++++++++++++++++++++-
Mpackage/schema/mod.ts11++-
Apackage/schema/padding.ts65+++++++++++++++
M deno.json +1 -1
@@ -63,7 +63,7 @@
6363 "check": "deno fmt --check . && deno lint && deno task doc && deno task docs:links && deno task test:unit && deno task test:access && deno task test:effects && deno task test:conformance",6464 "doc": "deno run --allow-run tools/doc_lint.ts",6565 "docs:links": "deno run --allow-read tools/docs_links.ts",66- "test:unit": "deno test --allow-env=LOFI_UPDATE_SNAPSHOT,CI --allow-net=127.0.0.1 --allow-read --allow-write=. --allow-run tools package/astro package/create_core_test.ts package/preact package/project_layout_doc_test.ts package/pwa_assets_test.ts package/recipes package/runtime package/schema/compat_test.ts package/schema/effects_test.ts package/schema/mod_test.ts package/schema/nested_test.ts package/testing package/tooling apps/reference/tests",66+ "test:unit": "deno test --allow-env=LOFI_UPDATE_SNAPSHOT,CI --allow-net=127.0.0.1 --allow-read --allow-write=. --allow-run tools package/astro package/create_core_test.ts package/preact package/project_layout_doc_test.ts package/pwa_assets_test.ts package/recipes package/runtime package/schema/compat_test.ts package/schema/effects_test.ts package/schema/encrypted_test.ts package/schema/mod_test.ts package/schema/nested_test.ts package/testing package/tooling apps/reference/tests",6767 "test:access": "deno test --allow-env --allow-net=127.0.0.1 --allow-read --allow-write --allow-run --allow-ffi package/access/*_test.ts",6868 "test:effects": "deno test --allow-env --allow-net=127.0.0.1 --allow-read --allow-write --allow-run --allow-ffi package/effects_sync_test.ts",6969 "test:conformance": "deno test --allow-env --allow-net=127.0.0.1 --allow-read --allow-write --allow-run --allow-ffi package/schema/merge_sync_test.ts && deno test --allow-env --allow-net=127.0.0.1 --allow-read --allow-write --allow-run --allow-ffi package/schema/migration_test.ts && deno test --allow-env --allow-net=127.0.0.1 --allow-read --allow-write --allow-run --allow-ffi package/schema/sliceable_test.ts && deno test --allow-env --allow-net=127.0.0.1 --allow-read --allow-write --allow-run --allow-ffi package/schema/nested_conformance_test.ts && deno test --allow-env --allow-net=127.0.0.1 --allow-read --allow-write --allow-run --allow-ffi package/schema/store_test.ts && deno test --allow-env --allow-net=127.0.0.1 --allow-read --allow-write --allow-run --allow-ffi package/schema/conformance_test.ts",
M docs/data-and-ui.md +14 -5
@@ -113,20 +113,29 @@ CoValue types (`co.map`, CoText, FileStream). All three are verified with two sy
113113 114114 ### Encrypted columns115115 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.116+`s.encryptedText(label)`, `s.encryptedJson<T>(label)`, `s.encryptedNumber(label)`, and117+`s.encryptedDate(label)` seal a field on the client before it enters Jazz: the sync store holds118+versioned ciphertext (XChaCha20-Poly1305, a per-column subkey derived from the account secret), so119+the store operator sees that the column exists — writers, timestamps, size classes — but never its120+content. Plaintext is padded to bucketed sizes before sealing, so stored lengths reveal which size121+class a value falls in rather than its exact length; every short scalar shares one class. The122+runtime installs the key at boot; touching an encrypted column before then fails closed rather than123+writing plaintext.121124 122125 ```ts123126 notes: s.table({124127 title: s.string(),125128 body: s.encryptedText("notes.body"),126129 attachments: s.encryptedJson<{ name: string; size: number }[]>("notes.attachments"),130+ balance: s.encryptedNumber("notes.balance"),131+ reviewedAt: s.encryptedDate("notes.reviewedAt"),127132 }),128133 ```129134 135+Sealed numbers view as `number` and sealed dates as `Date`, matching their plaintext counterparts;136+because the stored representation is text, sealed numbers are not subject to the 32-bit limit of137+plain integer columns. Non-finite numbers and invalid dates are rejected at write time.138+130139 The `label` is the column's cryptographic identity, conventionally `"table.column"`: it binds the131140 ciphertext to the column (a value replayed into another column refuses to open) and changing it132141 later makes existing values unreadable — treat it like a column name. Constraints to design around:
A docs/decisions/encrypted-column-format-enc2.md +64 -0
@@ -0,0 +1,64 @@
1+# Encrypted-column format enc2: padded plaintext, version-bound associated data2+3+Date: 2026-07-19\4+Decision: pad encrypted-column plaintext to bucketed sizes before sealing under a new `enc2.` stored5+format; bind the format version into the associated data; keep `enc1.` readable indefinitely with no6+migration; extend the sealed column set with number and date scalars7+8+## Question9+10+The stored ciphertext of an encrypted column leaked the exact plaintext length (nonce and AEAD11+overhead are constant), so the store operator could distinguish a 3-character note from a12+40-character one. How does the format hide content length without a migration pass, and how do two13+coexisting formats stay mutually non-replayable when the version marker is a string prefix outside14+the AEAD?15+16+## Contract IDs17+18+The affected documented semantics are the encrypted-columns section of `docs/data-and-ui.md` and the19+server's-view bullets of `docs/threat-model.md` ("rows of a certain size and cadence exist").20+21+## Exact versions22+23+`@nzip/lofi` 0.7.0 working tree; `jazz-tools@2.0.0-alpha.53` unchanged; cipher unchanged24+(XChaCha20-Poly1305 via `@noble/ciphers`, HKDF-SHA-256 per-label subkeys).25+26+## Control27+28+The pre-change behavior: `"enc1." + b64url(nonce24 || ct)` with the raw plaintext as the AEAD29+payload and `lofi:encrypted-column:<label>` as associated data. Ciphertext length = plaintext30+length + 40 bytes.31+32+## Decision33+34+1. **Padded payload** (`package/schema/padding.ts`): the sealed plaintext is a 4-byte big-endian35+ payload length, the payload, then zero fill to a bucket. The padded form is self-describing, so36+ the bucket schedule can evolve without a format bump.37+2. **Bucket schedule: Padmé with a 64-byte floor.** Every payload whose prefixed length is at or38+ under 64 bytes pads to 64 — short scalars (numbers, dates, names, titles) form one39+ indistinguishable class. Above the floor, Padmé bounds overhead near 12% while leaking O(log log40+ n) bits of length, against 2x worst-case for power-of-two buckets on large JSON.41+3. **Version-bound associated data.** enc2 seals with `lofi:encrypted-column:enc2:<label>`; enc142+ values authenticate against the unversioned string. The prefix sits outside the AEAD, so without43+ this an attacker could rewrite one prefix into the other and hand the wrong unpacking to an44+ authenticated plaintext: an enc1 value reparsed as enc2 would interpret its first four bytes as a45+ length. With version-bound AD, a flipped prefix fails authentication in either direction.46+4. **No migration.** `seal()` writes only enc2; `open()` dispatches on the prefix. Legacy values47+ re-seal as enc2 on their next ordinary write. Both formats remain valid indefinitely; the size48+ class the threat model can claim applies to values written from this version on.49+5. **Sealed scalars.** `encryptedNumber` and `encryptedDate` serialize inside the plaintext (ASCII50+ decimal; epoch milliseconds) over the same seal/open path. Their stored representation is TEXT,51+ so sealed numbers do not inherit the pinned i32 integer-column limit (asserted in conformance52+ with a 2^40 round trip). A conformance finding is pinned alongside: a `where` carrying a53+ plaintext number against a sealed column throws in the query adapter (type mismatch against the54+ TEXT stored type) rather than matching zero rows like the string case — loud is acceptable;55+ plaintext must never match.56+57+## Consequences58+59+The store operator's view of an encrypted column degrades from exact content lengths to size60+classes; writer identity, timing, and row cadence remain visible, as the threat model states.61+Padding costs at most 60 bytes on short values and ~12% on large ones. The 4-byte prefix caps a62+single sealed payload at 4 GiB, far past engine limits. `unpadPayload` validates the declared length63+but not the zero fill; the AEAD authenticates the whole padded plaintext, so fill tampering cannot64+occur without failing authentication first.
M docs/threat-model.md +6 -4
M package/astro/mod.ts +1 -0
M package/schema/conformance_test.ts +31 -0
@@ -56,6 +56,8 @@ const app = s.defineApp({
5656 sealed: s.table({5757 body: s.encryptedText("sealed.body"),5858 meta: s.encryptedJson<{ note: string }>("sealed.meta"),59+ amount: s.encryptedNumber("sealed.amount"),60+ at: s.encryptedDate("sealed.at"),5961 label: s.string(),6062 }),6163 });
@@ -556,9 +558,15 @@ Deno.test("encrypted columns round-trip through the engine and store only cipher
556558 try {557559 const db = testApp.as(session("author"));558560 const body = "the sealed plaintext body";561+ // The number exceeds the pinned i32 int-column limit on purpose: sealed562+ // numbers ride TEXT and must not inherit it.563+ const amount = 2 ** 40 + 0.5;564+ const at = new Date("2026-07-19T12:34:56.789Z");559565 const row = await db.insert(app.sealed, {560566 body,561567 meta: { note: "sealed json" },568+ amount,569+ at,562570 label: "first",563571 }).wait({ tier: "global" });564572 assert(row.body === body, `sealed: insert returned ${describeValue(row.body)}`);
@@ -569,6 +577,14 @@ Deno.test("encrypted columns round-trip through the engine and store only cipher
569577 sameValue(readBack.meta, { note: "sealed json" }),570578 `sealed: json view was ${describeValue(readBack.meta)}`,571579 );580+ assert(581+ readBack.amount === amount,582+ `sealed: number view was ${describeValue(readBack.amount)}`,583+ );584+ assert(585+ readBack.at instanceof Date && readBack.at.getTime() === at.getTime(),586+ `sealed: date view was ${describeValue(readBack.at)}`,587+ );572588 573589 // Filters address the stored representation: the plaintext must match574590 // nothing, because the engine never held it.
@@ -579,6 +595,21 @@ Deno.test("encrypted columns round-trip through the engine and store only cipher
579595 byPlaintext.length === 0,580596 `sealed: a where on the plaintext matched ${byPlaintext.length} rows — the store saw plaintext`,581597 );598+ // CONFORMANCE FINDING (alpha.53): a where on a sealed number column with599+ // the plaintext number throws in the query adapter (a number value600+ // against a TEXT-stored column is a type mismatch), rather than matching601+ // zero rows like the string case above. Pinned: loud is acceptable;602+ // plaintext must never match.603+ let plainNumberRefused = false;604+ try {605+ await db.all(app.sealed.where({ amount } as never));606+ } catch {607+ plainNumberRefused = true;608+ }609+ assert(610+ plainNumberRefused,611+ "sealed: pinned engine behavior changed — a plaintext-number where no longer throws",612+ );582613 583614 await db.update(app.sealed, row.id, { body: "rewritten sealed body" }).wait({584615 tier: "global",
M package/schema/encrypted.ts +89 -14
@@ -9,9 +9,15 @@
99 * Mechanics: an encrypted column is a stored `string` column whose1010 * TypeScript-boundary transform seals on insert/update and opens on read1111 * (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.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.1521 *1622 * Constraints that fall out of the design, enforced or documented:1723 * - The runtime installs the key at boot ({@link setEncryptedColumnKey});
@@ -33,6 +39,7 @@ import { xchacha20poly1305 } from "@noble/ciphers/chacha";
3339 import { hkdf } from "@noble/hashes/hkdf";3440 import { sha256 } from "@noble/hashes/sha2";3541 import { schema, type StringColumn } from "jazz-tools";42+import { padPayload, unpadPayload } from "./padding.ts";3643 3744 const textEncoder = new TextEncoder();3845 const textDecoder = new TextDecoder();
@@ -108,17 +115,25 @@ function fromBase64Url(text: string): Uint8Array {
108115 return bytes;109116 }110117 111-const STORED_PREFIX = "enc1.";118+const LEGACY_PREFIX = "enc1.";119+const STORED_PREFIX = "enc2.";112120 const NONCE_LENGTH = 24;113121 122+// The format version is bound as associated data because the prefix sits123+// outside the AEAD: without it, rewriting one prefix into the other would124+// hand the wrong unpacking to an authenticated plaintext.125+function associatedData(label: string, prefix: string): Uint8Array {126+ return textEncoder.encode(127+ prefix === LEGACY_PREFIX128+ ? `lofi:encrypted-column:${label}`129+ : `lofi:encrypted-column:enc2:${label}`,130+ );131+}132+114133 function seal(label: string, plaintext: string): string {115134 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));135+ const cipher = xchacha20poly1305(labelKey(label), nonce, associatedData(label, STORED_PREFIX));136+ const ciphertext = cipher.encrypt(padPayload(textEncoder.encode(plaintext)));122137 const stored = new Uint8Array(NONCE_LENGTH + ciphertext.length);123138 stored.set(nonce);124139 stored.set(ciphertext, NONCE_LENGTH);
@@ -126,13 +141,18 @@ function seal(label: string, plaintext: string): string {
126141 }127142 128143 function open(label: string, stored: string): string {129- if (typeof stored !== "string" || !stored.startsWith(STORED_PREFIX)) {144+ const prefix = typeof stored === "string" && stored.startsWith(STORED_PREFIX)145+ ? STORED_PREFIX146+ : typeof stored === "string" && stored.startsWith(LEGACY_PREFIX)147+ ? LEGACY_PREFIX148+ : null;149+ if (prefix === null) {130150 throw new EncryptedColumnError(131151 "corrupt",132152 `encrypted column "${label}" holds a value without the sealed-format prefix`,133153 );134154 }135- const bytes = fromBase64Url(stored.slice(STORED_PREFIX.length));155+ const bytes = fromBase64Url(stored.slice(prefix.length));136156 if (bytes.length <= NONCE_LENGTH) {137157 throw new EncryptedColumnError(138158 "corrupt",
@@ -142,10 +162,11 @@ function open(label: string, stored: string): string {
142162 const cipher = xchacha20poly1305(143163 labelKey(label),144164 bytes.slice(0, NONCE_LENGTH),145- textEncoder.encode(`lofi:encrypted-column:${label}`),165+ associatedData(label, prefix),146166 );147167 try {148- return textDecoder.decode(cipher.decrypt(bytes.slice(NONCE_LENGTH)));168+ const plaintext = cipher.decrypt(bytes.slice(NONCE_LENGTH));169+ return textDecoder.decode(prefix === STORED_PREFIX ? unpadPayload(plaintext) : plaintext);149170 } catch {150171 throw new EncryptedColumnError(151172 "corrupt",
@@ -185,3 +206,57 @@ export function encryptedJson<T = unknown>(label: string): StringColumn<false, f
185206 from: (value: string) => JSON.parse(open(label, value)) as T,186207 }) as unknown as StringColumn<false, false, T>;187208 }209+210+/**211+ * A number column sealed on the client before it enters Jazz; the view type212+ * is `number`. Same label semantics as {@link encryptedText}. The stored213+ * representation is text, so values beyond the plain integer column's 32-bit214+ * range round-trip exactly up to double precision. Non-finite values are215+ * rejected at write time.216+ */217+export function encryptedNumber(label: string): StringColumn<false, false, number> {218+ return schema.string().transform({219+ to: (value: number) => {220+ if (typeof value !== "number" || !Number.isFinite(value)) {221+ throw new TypeError(`encrypted column "${label}" only stores finite numbers`);222+ }223+ return seal(label, String(value));224+ },225+ from: (value: string) => {226+ const parsed = Number(open(label, value));227+ if (!Number.isFinite(parsed)) {228+ throw new EncryptedColumnError(229+ "corrupt",230+ `encrypted column "${label}" opened to a non-numeric payload`,231+ );232+ }233+ return parsed;234+ },235+ }) as unknown as StringColumn<false, false, number>;236+}237+238+/**239+ * A date column sealed on the client before it enters Jazz; the view type is240+ * `Date`, matching the plain timestamp column. Same label semantics as241+ * {@link encryptedText}. Invalid dates are rejected at write time.242+ */243+export function encryptedDate(label: string): StringColumn<false, false, Date> {244+ return schema.string().transform({245+ to: (value: Date) => {246+ if (!(value instanceof Date) || !Number.isFinite(value.getTime())) {247+ throw new TypeError(`encrypted column "${label}" only stores valid dates`);248+ }249+ return seal(label, String(value.getTime()));250+ },251+ from: (value: string) => {252+ const epochMs = Number(open(label, value));253+ if (!Number.isInteger(epochMs)) {254+ throw new EncryptedColumnError(255+ "corrupt",256+ `encrypted column "${label}" opened to a non-date payload`,257+ );258+ }259+ return new Date(epochMs);260+ },261+ }) as unknown as StringColumn<false, false, Date>;262+}
M package/schema/encrypted_test.ts +131 -2
@@ -4,10 +4,16 @@
44 import {55 clearEncryptedColumnKey,66 EncryptedColumnError,7+ encryptedDate,78 encryptedJson,9+ encryptedNumber,810 encryptedText,911 setEncryptedColumnKey,1012 } from "./encrypted.ts";13+import { paddedLength, padPayload, unpadPayload } from "./padding.ts";14+import { xchacha20poly1305 } from "@noble/ciphers/chacha";15+import { hkdf } from "@noble/hashes/hkdf";16+import { sha256 } from "@noble/hashes/sha2";1117 import { assert } from "../runtime/test-assert.ts";1218 1319 type Transformed = {
@@ -68,7 +74,7 @@ Deno.test(
6874 const plaintext = "a private sentence nobody else reads";6975 const stored = text.to(plaintext);7076 assert(71- stored.startsWith("enc1."),77+ stored.startsWith("enc2."),7278 `stored value lacks the version prefix: ${stored.slice(0, 12)}`,7379 );7480 assert(!stored.includes(plaintext), "stored value contains the plaintext");
@@ -118,9 +124,132 @@ Deno.test(
118124 const stored = text.to("value");119125 const flipped = stored.slice(0, -2) + (stored.endsWith("aa") ? "bb" : "aa");120126 expectFailure(() => text.from(flipped), "corrupt", "tampered value");121- expectFailure(() => text.from("enc1.AAAA"), "corrupt", "truncated value");127+ expectFailure(() => text.from("enc1.AAAA"), "corrupt", "truncated legacy value");128+ expectFailure(() => text.from("enc2.AAAA"), "corrupt", "truncated value");122129 expectFailure(() => text.from("plaintext"), "corrupt", "unprefixed value");123130 setEncryptedColumnKey(otherKey);124131 expectFailure(() => text.from(stored), "corrupt", "foreign key");125132 }),126133 );134+135+Deno.test("padding buckets are floored, self-describing, and reversible", () => {136+ assert(paddedLength(1) === 64, "tiny payloads pad to the floor");137+ assert(paddedLength(64) === 64, "the floor is inclusive");138+ assert(paddedLength(65) > 64, "past the floor the bucket grows");139+ for (const size of [0, 1, 60, 61, 500, 70_000]) {140+ const payload = new Uint8Array(size).map((_, index) => index % 251);141+ const padded = padPayload(payload);142+ assert(padded.length === paddedLength(4 + size), `bucket mismatch at ${size}`);143+ const recovered = unpadPayload(padded);144+ assert(145+ recovered.length === size && recovered.every((byte, index) => byte === payload[index]),146+ `padding round-trip mismatch at ${size}`,147+ );148+ }149+ const declaresPastEnd = new Uint8Array(64);150+ new DataView(declaresPastEnd.buffer).setUint32(0, 61);151+ let threw = false;152+ try {153+ unpadPayload(declaresPastEnd);154+ } catch {155+ threw = true;156+ }157+ assert(threw, "a length declared past the buffer must refuse");158+});159+160+Deno.test(161+ "stored lengths reveal the size class, not the payload length",162+ withKey(() => {163+ const text = transformOf(encryptedText("t.body"));164+ const short = text.to("a");165+ const longer = text.to("a".repeat(40));166+ assert(167+ short.length === longer.length,168+ `payloads in one bucket must store identically: ${short.length} vs ${longer.length}`,169+ );170+ const pastFloor = text.to("a".repeat(200));171+ assert(pastFloor.length > short.length, "a larger bucket must store longer");172+ }),173+);174+175+// A legacy value sealed exactly as the pre-padding format wrote it: subkey176+// from the same HKDF chain, the unversioned associated data, no padding.177+function sealLegacy(label: string, plaintext: string): string {178+ const subkey = hkdf(sha256, key, undefined, `lofi:encrypted-column:${label}`, 32);179+ const nonce = new Uint8Array(24).map((_, index) => 255 - index);180+ const cipher = xchacha20poly1305(181+ subkey,182+ nonce,183+ new TextEncoder().encode(`lofi:encrypted-column:${label}`),184+ );185+ const ciphertext = cipher.encrypt(new TextEncoder().encode(plaintext));186+ const bytes = new Uint8Array(24 + ciphertext.length);187+ bytes.set(nonce);188+ bytes.set(ciphertext, 24);189+ let binary = "";190+ for (const byte of bytes) binary += String.fromCharCode(byte);191+ return "enc1." + btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", "");192+}193+194+Deno.test(195+ "legacy enc1 values still open; prefix flips refuse in both directions",196+ withKey(() => {197+ const text = transformOf(encryptedText("t.body"));198+ const legacy = sealLegacy("t.body", "written before padding existed");199+ assert(200+ text.from(legacy) === "written before padding existed",201+ "legacy value failed to open",202+ );203+ expectFailure(204+ () => text.from("enc2." + legacy.slice("enc1.".length)),205+ "corrupt",206+ "legacy value replayed under the padded prefix",207+ );208+ const padded = text.to("value");209+ expectFailure(210+ () => text.from("enc1." + padded.slice("enc2.".length)),211+ "corrupt",212+ "padded value replayed under the legacy prefix",213+ );214+ }),215+);216+217+Deno.test(218+ "number and date values round-trip; invalid inputs refuse at the boundary",219+ withKey(() => {220+ const amount = transformOf(encryptedNumber("t.amount"));221+ for (const value of [0, -1.5, 2 ** 40 + 0.5, Number.MAX_SAFE_INTEGER]) {222+ const stored = amount.to(value);223+ assert(amount.from(stored) === value, `number round-trip mismatch at ${value}`);224+ }225+ for (const invalid of [Number.NaN, Number.POSITIVE_INFINITY]) {226+ let threw = false;227+ try {228+ amount.to(invalid);229+ } catch (error) {230+ threw = error instanceof TypeError;231+ }232+ assert(threw, `non-finite number ${invalid} must refuse with TypeError`);233+ }234+ expectFailure(235+ () => amount.from(transformOf(encryptedText("t.amount")).to("not a number")),236+ "corrupt",237+ "non-numeric payload",238+ );239+240+ const at = transformOf(encryptedDate("t.at"));241+ const moment = new Date("2026-07-19T12:34:56.789Z");242+ const storedDate = at.to(moment);243+ assert(244+ (at.from(storedDate) as Date).getTime() === moment.getTime(),245+ "date round-trip mismatch",246+ );247+ let rejectedInvalidDate = false;248+ try {249+ at.to(new Date(Number.NaN));250+ } catch (error) {251+ rejectedInvalidDate = error instanceof TypeError;252+ }253+ assert(rejectedInvalidDate, "an invalid date must refuse with TypeError");254+ }),255+);
M package/schema/mod.ts +9 -2
@@ -24,7 +24,7 @@
2424 */2525 import { schema } from "jazz-tools";2626 import { effect, insert, log, mutation, remove, update } from "./effects.ts";27-import { encryptedJson, encryptedText } from "./encrypted.ts";27+import { encryptedDate, encryptedJson, encryptedNumber, encryptedText } from "./encrypted.ts";2828 import {2929 defineNestedApp,3030 defineNestedPermissions,
@@ -77,6 +77,8 @@ export type SchemaDsl =
7777 flattenNestedSchema: typeof flattenNestedSchema;7878 encryptedText: typeof encryptedText;7979 encryptedJson: typeof encryptedJson;80+ encryptedNumber: typeof encryptedNumber;81+ encryptedDate: typeof encryptedDate;8082 mutation: typeof mutation;8183 effect: typeof effect;8284 log: typeof log;
@@ -90,7 +92,8 @@ export type SchemaDsl =
9092 * place of a raw `jazz-tools` import; Jazz member names and behavior are9193 * identical to the pinned Jazz 2 DSL. The lofi-owned members: nested9294 * namespaces ({@link defineNestedApp}), sealed columns ({@link encryptedText},93- * {@link encryptedJson}), and the verb grammar — {@link mutation} declares95+ * {@link encryptedJson}, {@link encryptedNumber}, {@link encryptedDate}), and96+ * the verb grammar — {@link mutation} declares9497 * callable verbs over {@link insert}, {@link update}, and {@link remove},9598 * carrying {@link effect} units and {@link log} entries. Verb calls return a9699 * `WriteHandle`, observed in UI through `useWrite` and `usePendingWrites`
@@ -104,6 +107,8 @@ export const s: SchemaDsl = {
104107 flattenNestedSchema,105108 encryptedText,106109 encryptedJson,110+ encryptedNumber,111+ encryptedDate,107112 mutation,108113 effect,109114 log,
@@ -115,7 +120,9 @@ export const s: SchemaDsl = {
115120 export {116121 clearEncryptedColumnKey,117122 EncryptedColumnError,123+ encryptedDate,118124 encryptedJson,125+ encryptedNumber,119126 encryptedText,120127 setEncryptedColumnKey,121128 } from "./encrypted.ts";
A package/schema/padding.ts +65 -0
@@ -0,0 +1,65 @@
1+/**2+ * Plaintext padding for encrypted columns: payloads are padded to bucket3+ * lengths before sealing, so the stored ciphertext reveals a size class4+ * rather than an exact content length.5+ *6+ * The bucket schedule is Padmé with a floor: every payload up to the floor7+ * pads to the floor (all short scalars share one indistinguishable class),8+ * and larger payloads round up to a Padmé bucket, which bounds overhead at9+ * roughly 12% while leaking only O(log log n) bits of length.10+ *11+ * The padded form is self-describing — a 4-byte big-endian payload length,12+ * the payload, then zero fill — so unpadding needs no knowledge of the13+ * schedule. The schedule can therefore evolve without a format version bump:14+ * values padded under an older schedule still open.15+ *16+ * @module17+ */18+19+/** Byte length of the payload-length prefix inside a padded plaintext. */20+export const PADDING_PREFIX_LENGTH = 4;21+22+/** Every padded plaintext is at least this long. */23+export const PADDING_FLOOR = 64;24+25+/**26+ * The bucket length for a raw length `n` (prefix included): the floor for27+ * short payloads, a Padmé bucket above it.28+ */29+export function paddedLength(n: number): number {30+ if (n <= PADDING_FLOOR) return PADDING_FLOOR;31+ const exponent = Math.floor(Math.log2(n));32+ const secondExponent = Math.floor(Math.log2(exponent)) + 1;33+ const step = 2 ** (exponent - secondExponent);34+ return Math.ceil(n / step) * step;35+}36+37+/**38+ * Pads a payload to its bucket: 4-byte big-endian length, payload, zero fill.39+ */40+export function padPayload(payload: Uint8Array): Uint8Array {41+ if (payload.length > 0xffff_ffff) {42+ throw new RangeError("payload exceeds the maximum padded size");43+ }44+ const total = paddedLength(PADDING_PREFIX_LENGTH + payload.length);45+ const padded = new Uint8Array(total);46+ new DataView(padded.buffer).setUint32(0, payload.length);47+ padded.set(payload, PADDING_PREFIX_LENGTH);48+ return padded;49+}50+51+/**52+ * Recovers the payload from a padded plaintext. Throws `RangeError` when the53+ * declared length cannot fit — the caller maps this to its corrupt-value54+ * error, though under an AEAD it indicates a codec bug rather than tampering.55+ */56+export function unpadPayload(padded: Uint8Array): Uint8Array {57+ if (padded.length < PADDING_PREFIX_LENGTH) {58+ throw new RangeError("padded plaintext is shorter than its length prefix");59+ }60+ const length = new DataView(padded.buffer, padded.byteOffset).getUint32(0);61+ if (PADDING_PREFIX_LENGTH + length > padded.length) {62+ throw new RangeError("padded plaintext declares a length past its end");63+ }64+ return padded.slice(PADDING_PREFIX_LENGTH, PADDING_PREFIX_LENGTH + length);65+}