clone

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

feat/184-task-notice-migration
lofi / docs / decisions / encrypted-column-format-enc2.md
encrypted-column-format-enc2.md 3.9K rendered
1# Encrypted-column format enc2: padded plaintext, version-bound associated data23Date: 2026-07-19\4Decision: pad encrypted-column plaintext to bucketed sizes before sealing under a new `enc2.` stored5format; bind the format version into the associated data; keep `enc1.` readable indefinitely with no6migration; extend the sealed column set with number and date scalars78## Question910The stored ciphertext of an encrypted column leaked the exact plaintext length (nonce and AEAD11overhead are constant), so the store operator could distinguish a 3-character note from a1240-character one. How does the format hide content length without a migration pass, and how do two13coexisting formats stay mutually non-replayable when the version marker is a string prefix outside14the AEAD?1516## Contract IDs1718The affected documented semantics are the encrypted-columns section of `docs/data-and-ui.md` and the19server's-view bullets of `docs/threat-model.md` ("rows of a certain size and cadence exist").2021## Exact versions2223`@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).2526## Control2728The pre-change behavior: `"enc1." + b64url(nonce24 || ct)` with the raw plaintext as the AEAD29payload and `lofi:encrypted-column:<label>` as associated data. Ciphertext length = plaintext30length + 40 bytes.3132## Decision33341. **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.372. **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.413. **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.464. **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.495. **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.5657## Consequences5859The store operator's view of an encrypted column degrades from exact content lengths to size60classes; writer identity, timing, and row cadence remain visible, as the threat model states.61Padding costs at most 60 bytes on short values and ~12% on large ones. The 4-byte prefix caps a62single sealed payload at 4 GiB, far past engine limits. `unpadPayload` validates the declared length63but not the zero fill; the AEAD authenticates the whole padded plaintext, so fill tampering cannot64occur without failing authentication first.