clone

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

main
lofi / docs / decisions / shared-field-encryption-alpha53.md
shared-field-encryption-alpha53.md 5.7K rendered
1# Shared-field encryption: lofi-owned key distribution over a key-relaying server23Date: 2026-07-19\4Decision: implement group-readable, server-sealed columns with a lofi-owned construction —5self-derived x25519 account identities, a self-published key directory with trust-on-first-sight6fingerprint pinning, authenticated (not anonymous) key wraps distributed as ordinary synced rows,7state-valued reads over an in-memory keyring, mutation-layer sealing, and lazy rekey on removal89## Question1011Account-private encrypted columns seal content from the store operator but are unreadable to every12other account by design. Fields a whole group should read need a field key distributed to members —13and key distribution through the store is mediated by exactly the adversary the sealing exists for.14What construction keeps the server unable to read shared content, makes its remaining attack15detectable, and fits an engine whose column transforms are synchronous and whose client API exposes16no account key material?1718## Contract IDs1920The threat-model disclosure page's shared-fields section and residual-exposure statements; the21shared-columns section of `docs/data-and-ui.md`; the `sharedFieldAccess`/`groupAccess(fieldKeys)`22template semantics in `docs/permissions.md`.2324## Exact versions2526`@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`.2829## Control3031Account-private encrypted columns only: a row shared with another account is undecryptable for them,32and reads refuse loudly. No group-readable sealed fields existed.3334## Decision35361. **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.422. **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.473. **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.524. **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.585. **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.626. **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.667. **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.7374## Consequences7576For shared fields the server is an active attacker rather than a passive reader, and its one77remaining move — key substitution during membership changes — is a detected failure. Residual78exposures, stated rather than hidden: trust-on-first-sight for peers first seen only through the79directory; removed members keep the generations they already held; structure, timing, and size-class80metadata remain visible. Key delivery is asynchronous — new members read `pending-key` until an81online key-holding device wraps for them, with a repair operation closing gaps. The construction is82designed to be superseded: engine-authenticated account keys, when the pinned engine exposes them,83replace the directory and pin store with identity material the server cannot substitute at all,84reviewed on every version bump.