Stage 5 of shared-field encryption (#154). The data-and-ui guide gains the shared-columns section (state-valued reads, the write-path requirement, lazy rekey, asynchronous key delivery, detection); the permissions guide covers the two helper tables and their policy touches; the threat model's shared-field promise becomes a shipped section with the residual exposures stated plainly, and the roadmap bullet narrows to engine-authenticated member keys; the decision record captures the full construction and its rationale.
- commit
- e19bbb16848687409e5ddde8f2ccf392f4938778
- parent
- 6f4c59107bbac51ba8566b8ae3a5c6a16f23c018
- author
- FelineStateMachine <dami.etoile@gmail.com>
| M | docs/ | 69 | ++++++++++++++++++++++++- |
| A | docs/ | 84 | ++++++++++++++++++++++++++++++ |
| M | docs/ | 14 | +++++ |
| M | docs/ | 32 | ++++++++++-- |
@@ -140,9 +140,10 @@ The `label` is the column's cryptographic identity, conventionally `"table.colum
140140 ciphertext to the column (a value replayed into another column refuses to open) and changing it141141 later makes existing values unreadable — treat it like a column name. Constraints to design around:142142 143-- **Account-private only, for now.** The key derives from the account secret, so every device144- holding the account decrypts and nobody else does — including other members of a shared group. A145- row read under a different account throws `EncryptedColumnError` instead of returning garbage.143+- **Account-private.** The key derives from the account secret, so every device holding the account144+ decrypts and nobody else does — including other members of a shared group. A row read under a145+ different account throws `EncryptedColumnError` instead of returning garbage. Fields the whole146+ group should read use [shared encrypted columns](#shared-encrypted-columns) instead.146147 - **Not filterable, not policy-visible — enforced.** A `where` on an encrypted column is a compile147148 error (the column type is excluded from filter positions), and a permission policy referencing one148149 fails configuration with `AccessError` — the server cannot evaluate what it cannot read. Gate on@@ -182,6 +183,68 @@ keeps its own label. Optionals, defaults, non-lww merge strategies, and transfor
182183 are configuration errors: each would either leak plaintext below the seal boundary or silently183184 discard author intent.184185 186+### Shared encrypted columns187+188+`s.sharedEncryptedText(label, options)` and `s.sharedEncryptedJson<T>(label, options)` seal a field189+under a **group field key**: every member of the group holding the key reads it; the store operator190+never does. The key travels as ordinary synced data — wrapped to each member's public key, published191+in a key directory, delivered through the group's wrapped-key table — so the server relays key192+material it cannot open, and substituting keys is a detected active attack rather than silent193+reading.194+195+```ts196+const schema = {197+ workspaces: s.table({ name: s.string() }),198+ workspaceMembers: groupMembershipTable("workspaces"),199+ workspaceFieldKeys: sharedFieldKeyTable("workspaces"),200+ keyDirectory: sharedFieldDirectoryTable(),201+ docs: s.table({202+ workspaceId: s.ref("workspaces"),203+ title: s.string(),204+ body: s.sharedEncryptedText("docs.body", {205+ group: "workspaces",206+ groupIdColumn: "workspaceId",207+ keys: "workspaceFieldKeys",208+ directory: "keyDirectory",209+ }),210+ }),211+};212+```213+214+The two helper tables come from `@nzip/lofi/access` and get their policies from215+`sharedFieldAccess({ directory })` and the `fieldKeys` option of `groupAccess`; group operations216+created with `fieldKeys`/`directory` tables then mint, deliver, and rotate keys automatically —217+creation bootstraps the first key, an arriving member receives every held generation, and removal218+rotates to a generation the removed member never receives.219+220+**Reads are state-valued.** A shared column's row value is a `SharedFieldValue<T>`:221+`{ state: "ready", value }` once the key is installed, `{ state: "pending-key" }` while the wrap is222+still in flight — the normal condition of a freshly added member — and `{ state: "corrupt" }` when223+authentication fails. Render the pending state; never treat it as an error. Live queries224+re-materialize automatically when the key arrives. `sharedFieldReady` narrows, and225+`unwrapSharedField` throws for callers who prefer exceptions.226+227+**Writes go through lofi's write path** — the store or a declared verb — which resolves the row's228+group from `groupIdColumn` and seals under the newest key this device holds before anything is229+journaled. A write that bypasses the framework fails closed rather than storing plaintext. An update230+that patches a shared column must include the group column in the verb path (the synchronous journal231+cannot fetch the row); the store path fetches it automatically.232+233+Constraints to design around, in addition to everything that applies to encrypted columns:234+235+- **Removal is lazy rekey.** A removed member never receives keys minted after their removal, so236+ content written afterwards is sealed to them — but generations they already held keep opening,237+ because they already possess that key material and re-encrypting CRDT history is unsound under238+ concurrent merges. Removal protects future content.239+- **Key delivery is asynchronous.** A new member reads `pending-key` until a device holding the key240+ comes online and wraps for them; `reconcileSharedFieldKeys` on the group operations repairs241+ missing wraps. Members who have not yet published a directory entry cannot receive keys.242+- **Substitution is detected, not prevented.** Peer keys pin on first sight per device, and a243+ sharing identity minted by a shared-field-capable app carries the account's key fingerprint, so244+ members added person-to-person are pinned with no server first-sight window. A key that later245+ disagrees surfaces in `diagnostics.sharedFieldAlerts` and its wraps are refused until the user246+ explicitly re-trusts.247+185248 ## Bind an exact typed query186249 187250 ```ts@@ -0,0 +1,84 @@
1+# Shared-field encryption: lofi-owned key distribution over a key-relaying server2+3+Date: 2026-07-19\4+Decision: implement group-readable, server-sealed columns with a lofi-owned construction —5+self-derived x25519 account identities, a self-published key directory with trust-on-first-sight6+fingerprint pinning, authenticated (not anonymous) key wraps distributed as ordinary synced rows,7+state-valued reads over an in-memory keyring, mutation-layer sealing, and lazy rekey on removal8+9+## Question10+11+Account-private encrypted columns seal content from the store operator but are unreadable to every12+other account by design. Fields a whole group should read need a field key distributed to members —13+and key distribution through the store is mediated by exactly the adversary the sealing exists for.14+What construction keeps the server unable to read shared content, makes its remaining attack15+detectable, and fits an engine whose column transforms are synchronous and whose client API exposes16+no account key material?17+18+## Contract IDs19+20+The threat-model disclosure page's shared-fields section and residual-exposure statements; the21+shared-columns section of `docs/data-and-ui.md`; the `sharedFieldAccess`/`groupAccess(fieldKeys)`22+template semantics in `docs/permissions.md`.23+24+## Exact versions25+26+`@nzip/lofi` 0.7.0 working tree; `jazz-tools@2.0.0-alpha.53` unchanged; `@noble/curves@1.9.7`27+(x25519) added alongside the pinned `@noble/ciphers@1.3.0` and `@noble/hashes@1.8.0`.28+29+## Control30+31+Account-private encrypted columns only: a row shared with another account is undecryptable for them,32+and reads refuse loudly. No group-readable sealed fields existed.33+34+## Decision35+36+1. **Self-derived identities, because the engine exposes none.** The pinned client API surfaces only37+ `user_id` strings — no account public keys, no sealed-box primitive. Each account derives an38+ x25519 keypair from its account secret (HKDF, info `lofi:shared-fields:x25519:v1`, key separated39+ from the encrypted-column hierarchy) and self-publishes the public key in a directory table40+ compiled to world-readable, self-only-write policy. Public keys are public; integrity comes from41+ pinning, not hiding.42+2. **Authenticated wraps, because the server knows every public key.** An anonymous sealed box would43+ let the server mint a fresh "generation" wrapped to every member and harvest future writes. The44+ wrap therefore mixes an ephemeral exchange with the sender's static key: forging one requires a45+ member's secret. Full row context — group table, group id, generation, recipient, sender — binds46+ as associated data, so wraps cannot replay across any of those axes.47+3. **Detection by pinning, with an out-of-band upgrade.** Devices pin peer fingerprints on first48+ sight; sharing identities minted by a shared-field-capable app carry the account's fingerprint49+ (`lofi2:<appId>:<userId>:<fingerprint>`), so members added person-to-person are pinned with no50+ server first-sight window. A published key that later disagrees is refused — fields stay sealed,51+ the mismatch surfaces as a diagnostics alert, and only explicit re-trust resolves it.52+4. **State-valued reads over a keyring, because transforms are synchronous.** The stored value53+ carries its scope and generation (`encs1.<groupTable>.<groupId>.<generation>.…`), the read54+ transform resolves keys from an in-memory keyring the wrapped-key watcher fills, and a missing55+ key surfaces as `pending-key` rather than an exception — a thrown transform fails the whole56+ query, and key-pending is the normal state of a freshly added member. Live queries resubscribe on57+ keyring change, so pending rows re-materialize into plaintext.58+5. **Mutation-layer sealing, because only the row knows its group.** The write path resolves the59+ group from the configured sibling column and seals with the newest held generation before60+ journaling; the column transform merely verifies the sealed prefix, so a write bypassing the61+ framework fails closed instead of storing plaintext.62+6. **Lazy rekey on removal.** Removal mints a generation past everything visible locally or63+ remotely, wrapped only to remaining members. Old generations are not re-encrypted: the removed64+ member already possesses that key material, and rewriting merged CRDT history is unsound. Removal65+ protects future content, and the documentation says exactly that.66+7. **Policy routing around an engine propagation gap.** During verification, cross-account sync was67+ found not to deliver rows whose read policy is an exists-based function condition, while68+ `always()` and direct object conditions propagate (pinned by an engine canary). The construction69+ is unaffected by design — wrapped keys ride a direct recipient condition, the directory reads70+ `always()`, and content protection comes from sealing rather than read-policy scoping — but the71+ finding stands on its own for the collaboration templates and is tracked as a standing upstream72+ issue.73+74+## Consequences75+76+For shared fields the server is an active attacker rather than a passive reader, and its one77+remaining move — key substitution during membership changes — is a detected failure. Residual78+exposures, stated rather than hidden: trust-on-first-sight for peers first seen only through the79+directory; removed members keep the generations they already held; structure, timing, and size-class80+metadata remain visible. Key delivery is asynchronous — new members read `pending-key` until an81+online key-holding device wraps for them, with a repair operation closing gaps. The construction is82+designed to be superseded: engine-authenticated account keys, when the pinned engine exposes them,83+replace the directory and pin store with identity material the server cannot substitute at all,84+reviewed on every version bump.@@ -100,6 +100,20 @@ The templates do not replace the schema surface. Use `s.table()`, `s.defineApp()
100100 `s.definePermissions()` directly whenever they do not fit. `defineAccessPolicies` also accepts a101101 final raw-policy callback for a small extension without hiding the underlying policy builder.102102 103+### Hosting shared encrypted columns104+105+A group whose resources declare [shared encrypted columns](data-and-ui.md#shared-encrypted-columns)106+needs two more tables and two policy touches: `sharedFieldDirectoryTable()` compiled by107+`sharedFieldAccess({ directory })` — world-readable, self-only writes, since public keys are public108+and integrity comes from fingerprint pinning — and `sharedFieldKeyTable("workspaces")` passed as109+`fieldKeys` to `groupAccess`, readable by each row's recipient or any member and writable by a110+member signing as themselves. Group operations created with the same `fieldKeys`/`directory` tables111+then handle the key lifecycle: creation mints the first key, `addMember` delivers held keys (a112+member who has not yet published a directory entry reads `pending-key` until113+`reconcileSharedFieldKeys` repairs the gap), and `removeMember` rotates so future content is sealed114+to the removed member. Policies can never reference the sealed columns themselves — gate on115+plaintext columns, and let sealing bound what the server can read.116+103117 ## Fixed group roles104118 105119 | Role | Read | Create | Edit own | Edit any | Manage members |@@ -46,6 +46,27 @@ column is account-private — a row shared with another account is undecryptable
4646 and reads refuse loudly rather than return garbage. Details:4747 [encrypted columns](data-and-ui.md#encrypted-columns).4848 49+## Shared fields: content the server relays but cannot read50+51+Shared encrypted columns extend sealing to group collaboration: a per-group field key seals the52+content, and the key itself travels as ordinary synced data — wrapped to each member's public key,53+which every account self-publishes in a key directory. The wrap is authenticated with the sender's54+own key, so a server that knows every public key still cannot mint one; forging a wrap requires a55+member's secret.56+57+For shared fields the server is therefore an **active attacker, not a passive reader**: its one move58+is substituting public keys during membership changes, and that move is detected. Each device pins59+peer keys on first sight, sharing identities carry the account's key fingerprint so members added60+person-to-person are pinned with no server first-sight window, and a key that later disagrees is61+refused — the field stays sealed and the mismatch surfaces as an alert, until the user re-trusts62+after out-of-band verification.63+64+The residual exposures, stated plainly: a peer first seen only through the directory is trusted on65+first sight by that device; a removed member keeps the key generations they already held (removal66+seals future content, not history — re-encrypting merged history is unsound); and structure, timing,67+and size-class metadata remain visible exactly as for account-private fields. Details:68+[shared encrypted columns](data-and-ui.md#shared-encrypted-columns).69+4970 ## Tickets: what possession grants5071 5172 An app-connect ticket is a bearer credential with 256-bit entropy.@@ -76,13 +97,14 @@ inheriting the page's capability.
7697 7798 ## Where the lines move7899 79-- **Shared-field encryption** is the designed next step: distributing a field key wrapped to each80- member's account key, stored as ordinary synced data the server cannot open. That demotes the81- server from passive reader to active attacker for shared fields — it could only attempt key82- substitution during membership changes, a detectable act rather than silent reading.100+- **Engine-authenticated member keys** would upgrade the shared-field trust story: today's key101+ directory is self-published and pinned; identity material the sync engine itself authenticates102+ would remove the first-sight window entirely. Tracked against the pinned engine and reviewed on103+ every version bump.83104 - **Metadata blindness is out of scope** under the pinned engine: a server that evaluates policies84105 and queries must see structure. A fully blind relay is a different architecture and is not85106 claimed.86107 87108 Read this page as the definition behind the phrase "the user holds the keys": identity, recovery,88-location, and at-rest custody today, with content blindness per field where the schema declares it.109+location, and at-rest custody, with content blindness per field where the schema declares it —110+account-private or shared across a group.