Shared-field encryption: lofi-owned key distribution over a key-relaying server
Date: 2026-07-19
Decision: implement group-readable, server-sealed columns with a lofi-owned construction —
self-derived x25519 account identities, a self-published key directory with trust-on-first-sight
fingerprint pinning, authenticated (not anonymous) key wraps distributed as ordinary synced rows,
state-valued reads over an in-memory keyring, mutation-layer sealing, and lazy rekey on removal
Question
Account-private encrypted columns seal content from the store operator but are unreadable to every other account by design. Fields a whole group should read need a field key distributed to members — and key distribution through the store is mediated by exactly the adversary the sealing exists for. What construction keeps the server unable to read shared content, makes its remaining attack detectable, and fits an engine whose column transforms are synchronous and whose client API exposes no account key material?
Contract IDs
The threat-model disclosure page's shared-fields section and residual-exposure statements; the
shared-columns section of docs/data-and-ui.md; the sharedFieldAccess/groupAccess(fieldKeys)
template semantics in docs/permissions.md.
Exact versions
@nzip/lofi 0.7.0 working tree; [email protected] unchanged; @noble/[email protected]
(x25519) added alongside the pinned @noble/[email protected] and @noble/[email protected].
Control
Account-private encrypted columns only: a row shared with another account is undecryptable for them, and reads refuse loudly. No group-readable sealed fields existed.
Decision
- Self-derived identities, because the engine exposes none. The pinned client API surfaces only
user_idstrings — no account public keys, no sealed-box primitive. Each account derives an x25519 keypair from its account secret (HKDF, infolofi:shared-fields:x25519:v1, key separated from the encrypted-column hierarchy) and self-publishes the public key in a directory table compiled to world-readable, self-only-write policy. Public keys are public; integrity comes from pinning, not hiding. - Authenticated wraps, because the server knows every public key. An anonymous sealed box would let the server mint a fresh "generation" wrapped to every member and harvest future writes. The wrap therefore mixes an ephemeral exchange with the sender's static key: forging one requires a member's secret. Full row context — group table, group id, generation, recipient, sender — binds as associated data, so wraps cannot replay across any of those axes.
- Detection by pinning, with an out-of-band upgrade. Devices pin peer fingerprints on first
sight; sharing identities minted by a shared-field-capable app carry the account's fingerprint
(
lofi2:<appId>:<userId>:<fingerprint>), so members added person-to-person are pinned with no server first-sight window. A published key that later disagrees is refused — fields stay sealed, the mismatch surfaces as a diagnostics alert, and only explicit re-trust resolves it. - State-valued reads over a keyring, because transforms are synchronous. The stored value
carries its scope and generation (
encs1.<groupTable>.<groupId>.<generation>.…), the read transform resolves keys from an in-memory keyring the wrapped-key watcher fills, and a missing key surfaces aspending-keyrather than an exception — a thrown transform fails the whole query, and key-pending is the normal state of a freshly added member. Live queries resubscribe on keyring change, so pending rows re-materialize into plaintext. - Mutation-layer sealing, because only the row knows its group. The write path resolves the group from the configured sibling column and seals with the newest held generation before journaling; the column transform merely verifies the sealed prefix, so a write bypassing the framework fails closed instead of storing plaintext.
- Lazy rekey on removal. Removal mints a generation past everything visible locally or remotely, wrapped only to remaining members. Old generations are not re-encrypted: the removed member already possesses that key material, and rewriting merged CRDT history is unsound. Removal protects future content, and the documentation says exactly that.
- Policy routing around an engine propagation gap. During verification, cross-account sync was
found not to deliver rows whose read policy is an exists-based function condition, while
always()and direct object conditions propagate (pinned by an engine canary). The construction is unaffected by design — wrapped keys ride a direct recipient condition, the directory readsalways(), and content protection comes from sealing rather than read-policy scoping — but the finding stands on its own for the collaboration templates and is tracked as a standing upstream issue.
Consequences
For shared fields the server is an active attacker rather than a passive reader, and its one
remaining move — key substitution during membership changes — is a detected failure. Residual
exposures, stated rather than hidden: trust-on-first-sight for peers first seen only through the
directory; removed members keep the generations they already held; structure, timing, and size-class
metadata remain visible. Key delivery is asynchronous — new members read pending-key until an
online key-holding device wraps for them, with a repair operation closing gaps. The construction is
designed to be superseded: engine-authenticated account keys, when the pinned engine exposes them,
replace the directory and pin store with identity material the server cannot substitute at all,
reviewed on every version bump.