Stage 4 of shared-field encryption (#154). The mutation layer — the only place that sees the sibling group column naming a row's key scope — now seals shared columns with the newest held generation before anything is journaled or handed to the engine, so the durable journal holds ciphertext and replayed writes stay sealed; the no-seal path stays fully synchronous so pending-write observability is unchanged. Writes without a held key or a resolvable scope refuse loudly. The group lifecycle mints, delivers, and rotates keys: creation bootstraps generation 1 wrapped to the creator; an arriving member receives every held generation, pinned out-of-band by the lofi2 fingerprint their sharing identity carries; removal rotates to a generation the removed member never receives, wrapped to the remaining members; reconcileSharedFieldKeys repairs missing wraps. Rotation is deliberately lazy — the removed member keeps the key material they already had, and re-encrypting CRDT history is unsound under concurrent merges; removal protects future content. createGroupOperations wires the lifecycle in behind optional fieldKeys/directory tables; membership writes never wait on key delivery. The lifecycle sync test drives the whole arc against the real server: bootstrap, sealed generation-1 write, member delivery, watcher unwrap and ready read, rotation, and the removed member reading old content while post-rotation content stays pending.
- commit
- 6f4c59107bbac51ba8566b8ae3a5c6a16f23c018
- parent
- 0c57905cf51886a9b410489e455412896735a4e0
- author
- FelineStateMachine <dami.etoile@gmail.com>
| M | package/ | 8 | + |
| M | package/ | 85 | +++++++- |
| A | package/ | 260 | +++++++++++++++++++++++ |
| A | package/ | 334 | ++++++++++++++++++++++++++++++ |
| M | package/ | 2 | +- |
| M | package/ | 2 | + |
| A | package/ | 136 | ++++++++++++ |
| M | package/ | 1 | + |
| A | package/ | 126 | +++++++++++ |
| M | package/ | 18 | +- |
| M | package/ | 35 | +++- |
| M | package/ | 9 | +- |
| M | package/ | 7 | + |
| M | package/ | 2 | + |
@@ -19,6 +19,14 @@ export {
1919 sharingIdentity,2020 type SharingIdentityDetails,2121 } from "./identity.ts";22+export {23+ bootstrapGroupFieldKey,24+ reconcileSharedFieldKeys,25+ rotateGroupFieldKey,26+ type SharedFieldLifecycleContext,27+ wrapHeldKeysForMember,28+ type WrapSkip,29+} from "./shared-field-lifecycle.ts";2230 export {2331 type AccessRuntimeTable,2432 createGroupOperations,@@ -3,7 +3,19 @@ import { syncing } from "../runtime/config.ts";
33 import { type DurableWrite, settleDurableWrite } from "../runtime/durability.ts";44 import { getRuntime, updateRuntimeDiagnostics } from "../runtime/runtime.ts";55 import { AccessError } from "./errors.ts";6-import { decodeSharingIdentity, type SharingIdentity } from "./identity.ts";6+import {7+ bootstrapGroupFieldKey,8+ reconcileSharedFieldKeys,9+ rotateGroupFieldKey,10+ type SharedFieldLifecycleContext,11+ wrapHeldKeysForMember,12+} from "./shared-field-lifecycle.ts";13+import { activeAppId } from "../runtime/config.ts";14+import {15+ decodeSharingIdentity,16+ decodeSharingIdentityDetails,17+ type SharingIdentity,18+} from "./identity.ts";719 import { type GroupRole, groupRoleCapabilities, groupRoles } from "./schema.ts";820 921 /** Minimum row shape accepted by collaboration operations. */@@ -158,12 +170,41 @@ export function createGroupOperations<
158170 >(config: {159171 groups: AccessRuntimeTable<Group, GroupInit>;160172 members: AccessRuntimeTable<Member, MemberInit>;173+ /** Wrapped-field-key and directory tables, for groups hosting shared174+ * encrypted columns; the group lifecycle then mints, wraps, and rotates175+ * field keys automatically. */176+ fieldKeys?: AccessRuntimeTable<Identified, unknown>;177+ directory?: AccessRuntimeTable<Identified, unknown>;161178 }): GroupOperations<Group, GroupInit, Member> {162179 const findMember = async (groupId: string, userId: string): Promise<Member | null> => {163180 const { db } = await getRuntime();164181 return await db.one(config.members.where({ groupId, user_id: userId } as never));165182 };166183 184+ // The lifecycle context resolves lazily per call: the runtime, principal,185+ // and app id all belong to the moment of the operation.186+ const lifecycleContext = async (): Promise<SharedFieldLifecycleContext | null> => {187+ if (!config.fieldKeys || !config.directory) return null;188+ const { db } = await getRuntime();189+ const userId = db.getAuthState().session?.user_id;190+ if (!userId) {191+ throw new AccessError("invalid-identity", "The current Jazz principal is not ready.");192+ }193+ const groupTable = (config.groups as unknown as { _table?: string })._table;194+ if (!groupTable) {195+ throw new AccessError("configuration", "The group table does not expose its name.");196+ }197+ return {198+ db: db as never,199+ appId: activeAppId(),200+ userId,201+ groupTable,202+ fieldKeys: config.fieldKeys as never,203+ directory: config.directory as never,204+ members: config.members as never,205+ };206+ };207+167208 return {168209 async createGroup(values: GroupInit): Promise<{ group: Group; membership: Member }> {169210 requireSync("Creating a group");@@ -179,6 +220,8 @@ export function createGroupOperations<
179220 user_id: userId,180221 ...groupRoleCapabilities("admin"),181222 } as MemberInit));223+ const lifecycle = await lifecycleContext();224+ if (lifecycle) await bootstrapGroupFieldKey(lifecycle, group.id);182225 return { group, membership };183226 } catch (cause) {184227 // The creator's direct delete authority on their own group row@@ -196,7 +239,8 @@ export function createGroupOperations<
196239 ): Promise<Member> {197240 requireSync("Adding a group member");198241 assertRole(role);199- const userId = decodeSharingIdentity(recipient);242+ const details = decodeSharingIdentityDetails(recipient);243+ const userId = details.userId;200244 const existing = await findMember(groupId, userId);201245 if (existing) {202246 throw new AccessError(@@ -205,13 +249,22 @@ export function createGroupOperations<
205249 );206250 }207251 const { db } = await getRuntime();208- return await settle(252+ const membership = await settle(209253 db.insert(config.members, {210254 groupId,211255 user_id: userId,212256 ...groupRoleCapabilities(role),213257 } as MemberInit),214258 );259+ // Key delivery is best-effort here: a member without a directory entry260+ // sits in the documented pending state until a key-holding device runs261+ // reconcileSharedFieldKeys. The membership itself never waits on keys.262+ const lifecycle = await lifecycleContext();263+ if (lifecycle) {264+ await wrapHeldKeysForMember(lifecycle, groupId, userId, details.fingerprint)265+ .catch(() => undefined);266+ }267+ return membership;215268 },216269 217270 async changeRole(@@ -234,10 +287,17 @@ export function createGroupOperations<
234287 235288 async removeMember(groupId: string, recipient: SharingIdentity | string): Promise<void> {236289 requireSync("Removing a group member");237- const member = await findMember(groupId, decodeSharingIdentity(recipient));290+ const removedUserId = decodeSharingIdentity(recipient);291+ const member = await findMember(groupId, removedUserId);238292 if (!member) return;239293 const { db } = await getRuntime();240294 await settle(db.delete(config.members, member.id));295+ // Lazy rekey: future writes seal under a generation the removed member296+ // never receives; already-held generations remain readable to them.297+ const lifecycle = await lifecycleContext();298+ if (lifecycle) {299+ await rotateGroupFieldKey(lifecycle, groupId, removedUserId).catch(() => undefined);300+ }241301 },242302 243303 async leaveGroup(groupId: string): Promise<void> {@@ -257,6 +317,19 @@ export function createGroupOperations<
257317 const { db } = await getRuntime();258318 return await db.all(config.members.where({ groupId } as never));259319 },320+321+ async reconcileSharedFieldKeys(groupId: string): Promise<number> {322+ requireSync("Repairing shared-field keys");323+ const lifecycle = await lifecycleContext();324+ if (!lifecycle) {325+ throw new AccessError(326+ "configuration",327+ "These group operations were created without fieldKeys/directory tables; declare " +328+ "them to host shared encrypted columns.",329+ );330+ }331+ return await reconcileSharedFieldKeys(lifecycle, groupId);332+ },260333 };261334 }262335 @@ -276,4 +349,8 @@ export type GroupOperations<Group, GroupInit, Member> = {
276349 removeMember(groupId: string, recipient: SharingIdentity | string): Promise<void>;277350 leaveGroup(groupId: string): Promise<void>;278351 listMembers(groupId: string): Promise<Member[]>;352+ /** Repairs missing field-key wraps for a group hosting shared columns;353+ * throws a configuration error when the operations were created without354+ * `fieldKeys`/`directory` tables. Returns the number of wraps inserted. */355+ reconcileSharedFieldKeys(groupId: string): Promise<number>;279356 };@@ -0,0 +1,260 @@
1+/**2+ * Group field-key lifecycle: minting the first generation with the group,3+ * wrapping held keys to arriving members, rotating on removal, and repairing4+ * missing wraps. Every operation takes an injected context — the same5+ * dependency shape the wrapped-key watcher uses — so the sync test suite6+ * drives it against real accounts and the group operations wire it to the7+ * live runtime.8+ *9+ * Rotation is deliberately lazy: removal bumps the generation so future10+ * writes seal under a key the removed member never receives, while old11+ * generations remain readable to them — they already possess that key12+ * material, and re-encrypting CRDT history is unsound under concurrent13+ * merges. Removal protects future content; the docs say exactly that.14+ *15+ * @module16+ */17+import {18+ generateFieldKey,19+ publicKeyFingerprint,20+ SharedFieldError,21+ wrapFieldKey,22+} from "../schema/shared-crypto.ts";23+import {24+ getSharedFieldKey,25+ heldSharedFieldGenerations,26+ installSharedFieldKey,27+ requireSharedFieldIdentity,28+ sharedKeyScope,29+} from "../schema/shared-keyring.ts";30+import {31+ directoryPublicKey,32+ ensureDirectoryEntry,33+ verifyAndPinFingerprint,34+} from "../runtime/shared-field-keys.ts";35+36+type LifecycleDb = {37+ all(query: unknown): Promise<unknown[]>;38+ insert(39+ table: unknown,40+ values: Record<string, unknown>,41+ ): { wait(options: { tier: "local" | "global" }): Promise<unknown> };42+};43+44+type QueryableTable = { where(condition: Record<string, unknown>): unknown };45+46+/** Everything the lifecycle needs about one group's shared-field wiring. */47+export type SharedFieldLifecycleContext = {48+ db: LifecycleDb;49+ appId: string;50+ userId: string;51+ /** The group table's declared name — the key scope's first segment. */52+ groupTable: string;53+ fieldKeys: QueryableTable;54+ directory: QueryableTable;55+ members: QueryableTable;56+};57+58+type WrapRow = {59+ groupId: string;60+ recipient_user_id: string;61+ generation: number;62+};63+64+type MemberRow = { user_id: string };65+66+type DirectoryEntry = { user_id: string; algo: string; public_key: string };67+68+/** Why a member could not receive a wrap; the pending state persists. */69+export type WrapSkip = "no-directory-entry" | "peer-key-changed";70+71+async function wrapTo(72+ context: SharedFieldLifecycleContext,73+ groupId: string,74+ generation: number,75+ fieldKey: Uint8Array,76+ recipientUserId: string,77+ expectedFingerprint?: string,78+): Promise<WrapSkip | null> {79+ const identity = requireSharedFieldIdentity();80+ const entries = await context.db.all(81+ context.directory.where({ user_id: recipientUserId }),82+ ) as DirectoryEntry[];83+ const entry = entries[0];84+ if (entry === undefined) return "no-directory-entry";85+ const recipientPublic = directoryPublicKey(entry);86+ const observedFingerprint = publicKeyFingerprint(recipientPublic);87+ // An out-of-band fingerprint (a lofi2 sharing identity) outranks the88+ // directory: a mismatch means the directory is wrong, not the person.89+ if (expectedFingerprint !== undefined && expectedFingerprint !== observedFingerprint) {90+ return "peer-key-changed";91+ }92+ if (!verifyAndPinFingerprint(context.appId, recipientUserId, observedFingerprint)) {93+ return "peer-key-changed";94+ }95+ const wrapped = wrapFieldKey({96+ fieldKey,97+ senderSecret: identity.secret,98+ recipientPublic,99+ context: {100+ groupTable: context.groupTable,101+ groupId,102+ generation,103+ recipientUserId,104+ senderUserId: context.userId,105+ },106+ });107+ await context.db.insert(context.fieldKeys as never, {108+ groupId,109+ recipient_user_id: recipientUserId,110+ sender_user_id: context.userId,111+ generation,112+ wrapped_key: wrapped,113+ recipient_fingerprint: observedFingerprint,114+ sender_fingerprint: identity.fingerprint,115+ }).wait({ tier: "global" });116+ return null;117+}118+119+async function remoteGenerations(120+ context: SharedFieldLifecycleContext,121+ groupId: string,122+): Promise<WrapRow[]> {123+ return await context.db.all(context.fieldKeys.where({ groupId })) as WrapRow[];124+}125+126+/**127+ * Mints generation 1 for a new group: the key installs locally and wraps to128+ * the creator, so the group is writable immediately and other devices of the129+ * creator's account unwrap through the ordinary watcher path. Requires the130+ * creator's directory entry, publishing it if absent.131+ */132+export async function bootstrapGroupFieldKey(133+ context: SharedFieldLifecycleContext,134+ groupId: string,135+): Promise<void> {136+ const identity = requireSharedFieldIdentity();137+ await ensureDirectoryEntry({138+ db: context.db as never,139+ directory: context.directory,140+ userId: context.userId,141+ identity,142+ });143+ const fieldKey = generateFieldKey();144+ installSharedFieldKey(sharedKeyScope(context.groupTable, groupId), 1, fieldKey);145+ const skip = await wrapTo(context, groupId, 1, fieldKey, context.userId);146+ if (skip !== null) {147+ throw new SharedFieldError(148+ skip === "no-directory-entry" ? "no-directory-entry" : "peer-key-changed",149+ "the group key could not be wrapped to its creator",150+ );151+ }152+}153+154+/**155+ * Wraps every generation this device holds to a member, skipping wraps that156+ * already exist. Returns the skip reason when the member cannot receive keys157+ * yet — the member sits in the documented pending state until repair.158+ */159+export async function wrapHeldKeysForMember(160+ context: SharedFieldLifecycleContext,161+ groupId: string,162+ recipientUserId: string,163+ expectedFingerprint?: string,164+): Promise<{ wrapped: number; skip: WrapSkip | null }> {165+ const scope = sharedKeyScope(context.groupTable, groupId);166+ const held = heldSharedFieldGenerations(scope);167+ if (held.length === 0) return { wrapped: 0, skip: null };168+ const existing = await remoteGenerations(context, groupId);169+ const covered = new Set(170+ existing171+ .filter((row) => row.recipient_user_id === recipientUserId)172+ .map((row) => row.generation),173+ );174+ let wrapped = 0;175+ for (const generation of held) {176+ if (covered.has(generation)) continue;177+ const fieldKey = mustHold(scope, generation);178+ const skip = await wrapTo(179+ context,180+ groupId,181+ generation,182+ fieldKey,183+ recipientUserId,184+ expectedFingerprint,185+ );186+ if (skip !== null) return { wrapped, skip };187+ wrapped += 1;188+ }189+ return { wrapped, skip: null };190+}191+192+/**193+ * Rotates the group key after a removal: the next generation is minted past194+ * every generation visible locally or remotely, installed locally, and195+ * wrapped to every remaining member with a directory entry. Members without196+ * one stay pending until repair; the removed member never receives the new197+ * generation.198+ */199+export async function rotateGroupFieldKey(200+ context: SharedFieldLifecycleContext,201+ groupId: string,202+ removedUserId: string,203+): Promise<void> {204+ const scope = sharedKeyScope(context.groupTable, groupId);205+ const held = heldSharedFieldGenerations(scope);206+ const remote = await remoteGenerations(context, groupId);207+ const newest = Math.max(0, ...held, ...remote.map((row) => row.generation));208+ const generation = newest + 1;209+ const fieldKey = generateFieldKey();210+ installSharedFieldKey(scope, generation, fieldKey);211+ const members = await context.db.all(212+ context.members.where({ groupId }),213+ ) as MemberRow[];214+ for (const member of members) {215+ if (member.user_id === removedUserId) continue;216+ await wrapTo(context, groupId, generation, fieldKey, member.user_id);217+ }218+}219+220+/**221+ * Repairs missing wraps: every (member, held generation) pair without a222+ * wrapped-key row receives one, directory permitting. The answer to the223+ * inherent asynchrony of membership — some online device holding the key224+ * eventually wraps; this is that device's move. Returns the number of wraps225+ * inserted.226+ */227+export async function reconcileSharedFieldKeys(228+ context: SharedFieldLifecycleContext,229+ groupId: string,230+): Promise<number> {231+ const scope = sharedKeyScope(context.groupTable, groupId);232+ const held = heldSharedFieldGenerations(scope);233+ if (held.length === 0) return 0;234+ const existing = await remoteGenerations(context, groupId);235+ const covered = new Set(existing.map((row) => `${row.recipient_user_id}#${row.generation}`));236+ const members = await context.db.all(context.members.where({ groupId })) as MemberRow[];237+ let wrapped = 0;238+ for (const member of members) {239+ for (const generation of held) {240+ if (covered.has(`${member.user_id}#${generation}`)) continue;241+ const skip = await wrapTo(242+ context,243+ groupId,244+ generation,245+ mustHold(scope, generation),246+ member.user_id,247+ );248+ if (skip === null) wrapped += 1;249+ }250+ }251+ return wrapped;252+}253+254+function mustHold(scope: string, generation: number): Uint8Array {255+ const key = getSharedFieldKey(scope, generation);256+ if (key === null) {257+ throw new SharedFieldError("key-pending", `the field key for ${scope} vanished mid-operation`);258+ }259+ return key;260+}@@ -0,0 +1,334 @@
1+// The group field-key lifecycle end to end against the real pinned server:2+// creation mints and self-wraps generation 1, an arriving member receives3+// held keys (pinned out-of-band by their lofi2 fingerprint), removal rotates4+// to a generation the removed member never receives — and the removed member5+// keeps reading old-generation content while new content stays pending for6+// them, which is exactly the documented lazy-rekey posture.7+8+import { createDb, schema as jazz } from "jazz-tools";9+import { deploy, startLocalJazzServer } from "jazz-tools/testing";10+import {11+ bootstrapGroupFieldKey,12+ defineAccessPolicies,13+ groupAccess,14+ groupMembershipTable,15+ groupRoleCapabilities,16+ reconcileSharedFieldKeys,17+ rotateGroupFieldKey,18+ sharedFieldAccess,19+ sharedFieldDirectoryTable,20+ sharedFieldKeyTable,21+ type SharedFieldLifecycleContext,22+ wrapHeldKeysForMember,23+} from "./mod.ts";24+import { s } from "../schema/mod.ts";25+import {26+ clearSharedColumnRegistry,27+ clearSharedFieldKeys,28+ type SharedFieldValue,29+} from "../schema/mod.ts";30+import { clearEncryptedColumnRegistry } from "../schema/encrypted.ts";31+import { clearSharedFieldIdentity, installSharedFieldIdentity } from "../schema/shared-keyring.ts";32+import { sealSharedColumnValuesSync } from "../runtime/shared-field-write.ts";33+import {34+ clearFingerprintPins,35+ deriveSharedFieldIdentity,36+ ensureDirectoryEntry,37+ startSharedFieldKeyWatcher,38+} from "../runtime/shared-field-keys.ts";39+import { assert } from "../runtime/test-assert.ts";40+41+const APP_ID = "lifecycle-app";42+const SHARED_OPTIONS = {43+ group: "workspaces",44+ groupIdColumn: "workspaceId",45+ keys: "workspaceFieldKeys",46+ directory: "keyDirectory",47+} as const;48+49+const app = s.defineApp({50+ workspaces: jazz.table({ name: jazz.string() }),51+ workspaceMembers: groupMembershipTable("workspaces"),52+ workspaceFieldKeys: sharedFieldKeyTable("workspaces"),53+ keyDirectory: sharedFieldDirectoryTable(),54+ docs: jazz.table({55+ workspaceId: jazz.ref("workspaces"),56+ title: jazz.string(),57+ body: s.sharedEncryptedText("lifecycle.docs.body", SHARED_OPTIONS),58+ }),59+});60+// Docs read openly: exists-gated rows do not propagate cross-account at the61+// pin (engine canary in shared_directory_sync_test.ts); content protection62+// here is the sealing under test.63+const permissions = defineAccessPolicies(app, [64+ sharedFieldAccess({ directory: app.keyDirectory }),65+ groupAccess({66+ groups: app.workspaces,67+ members: app.workspaceMembers,68+ resources: [],69+ groupId: "workspaceId",70+ fieldKeys: app.workspaceFieldKeys,71+ }),72+], (context) => {73+ context.policy.docs.allowRead.always();74+ context.policy.docs.allowInsert.always();75+ context.policy.docs.allowUpdate.always();76+ context.policy.docs.allowDelete.always();77+});78+79+function secret(fill: number): string {80+ let binary = "";81+ for (const byte of new Uint8Array(32).fill(fill)) binary += String.fromCharCode(byte);82+ return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, "");83+}84+85+async function within<T>(operation: Promise<T>, label: string, milliseconds = 10_000): Promise<T> {86+ let timer: ReturnType<typeof setTimeout> | undefined;87+ try {88+ return await Promise.race([89+ operation,90+ new Promise<never>((_resolve, reject) => {91+ timer = setTimeout(() => reject(new Error(`${label} timed out`)), milliseconds);92+ }),93+ ]);94+ } finally {95+ if (timer !== undefined) clearTimeout(timer);96+ }97+}98+99+async function until(check: () => boolean, label: string, milliseconds = 10_000): Promise<void> {100+ const deadline = Date.now() + milliseconds;101+ while (!check()) {102+ if (Date.now() > deadline) throw new Error(`${label} did not settle`);103+ await new Promise((resolve) => setTimeout(resolve, 50));104+ }105+}106+107+Deno.test("the key lifecycle: bootstrap, member delivery, and lazy rekey", async () => {108+ clearFingerprintPins(APP_ID);109+ clearSharedFieldKeys();110+ const server = await startLocalJazzServer({ allowLocalFirstAuth: true });111+ await deploy({112+ appId: server.appId,113+ serverUrl: server.url,114+ adminSecret: server.adminSecret,115+ schema: app,116+ permissions,117+ });118+ const alice = await createDb({119+ appId: server.appId,120+ serverUrl: server.url,121+ secret: secret(1),122+ userBranch: "main",123+ driver: { type: "memory" },124+ });125+ const bob = await createDb({126+ appId: server.appId,127+ serverUrl: server.url,128+ secret: secret(2),129+ userBranch: "main",130+ driver: { type: "memory" },131+ });132+ let stopWatcher: (() => void) | null = null;133+ try {134+ const aliceId = alice.getAuthState().session?.user_id as string;135+ const bobId = bob.getAuthState().session?.user_id as string;136+ const aliceIdentity = await deriveSharedFieldIdentity("alice-lifecycle-secret");137+ const bobIdentity = await deriveSharedFieldIdentity("bob-lifecycle-secret");138+ const aliceContext: SharedFieldLifecycleContext = {139+ db: alice as never,140+ appId: APP_ID,141+ userId: aliceId,142+ groupTable: "workspaces",143+ fieldKeys: app.workspaceFieldKeys,144+ directory: app.keyDirectory,145+ members: app.workspaceMembers,146+ };147+148+ // --- Phase A: Alice creates the group; bootstrap mints generation 1.149+ installSharedFieldIdentity(aliceIdentity);150+ const workspace = await within(151+ alice.insert(app.workspaces, { name: "lifecycle" }).wait({ tier: "global" }),152+ "workspace insert",153+ );154+ await within(155+ alice.insert(app.workspaceMembers, {156+ groupId: workspace.id,157+ user_id: aliceId,158+ ...groupRoleCapabilities("admin"),159+ }).wait({ tier: "global" }),160+ "alice membership",161+ );162+ await within(bootstrapGroupFieldKey(aliceContext, workspace.id), "bootstrap");163+164+ // The mutation-layer sealer picks up generation 1 transparently.165+ const sealedInsert = sealSharedColumnValuesSync("docs", {166+ workspaceId: workspace.id,167+ title: "first",168+ body: "written under generation one",169+ });170+ assert(171+ String(sealedInsert.body).startsWith(`encs1.workspaces.${workspace.id}.1.`),172+ "the sealer must use the bootstrapped generation",173+ );174+ const firstDoc = await within(175+ alice.insert(app.docs, sealedInsert as never).wait({ tier: "global" }),176+ "first doc insert",177+ );178+179+ // --- Phase B: Bob joins; his lofi2 fingerprint pins him out-of-band and180+ // Alice's device wraps generation 1 to him.181+ await within(182+ ensureDirectoryEntry({183+ db: bob as never,184+ directory: app.keyDirectory,185+ userId: bobId,186+ identity: bobIdentity,187+ }).then(() => undefined),188+ "bob publishes",189+ );190+ await within(191+ alice.insert(app.workspaceMembers, {192+ groupId: workspace.id,193+ user_id: bobId,194+ ...groupRoleCapabilities("writer"),195+ }).wait({ tier: "global" }),196+ "bob membership",197+ );198+ const delivery = await within(199+ wrapHeldKeysForMember(aliceContext, workspace.id, bobId, bobIdentity.fingerprint),200+ "wrap held keys for bob",201+ );202+ assert(203+ delivery.wrapped === 1 && delivery.skip === null,204+ `bob must receive one wrap (saw ${JSON.stringify(delivery)})`,205+ );206+ // Repair finds nothing missing afterwards.207+ assert(208+ (await within(reconcileSharedFieldKeys(aliceContext, workspace.id), "reconcile")) === 0,209+ "reconcile after full delivery must be a no-op",210+ );211+212+ // --- Phase C: Bob's device (fresh keyring) unwraps through the watcher213+ // and reads generation-one content.214+ clearSharedFieldKeys();215+ installSharedFieldIdentity(bobIdentity);216+ stopWatcher = startSharedFieldKeyWatcher({217+ db: bob as never,218+ appId: APP_ID,219+ userId: bobId,220+ configs: [{ label: "lifecycle.docs.body", kind: "text", ...SHARED_OPTIONS }],221+ findTable: (name) =>222+ name === "workspaceFieldKeys"223+ ? app.workspaceFieldKeys224+ : name === "keyDirectory"225+ ? app.keyDirectory226+ : null,227+ onAlert: (alert) => {228+ throw new Error(`unexpected watcher alert: ${alert.code} — ${alert.detail}`);229+ },230+ });231+ let firstBody: SharedFieldValue<string> | undefined;232+ await until(() => {233+ void bob.all(app.docs.where({ id: firstDoc.id })).then((rows) => {234+ firstBody = rows[0]?.body as unknown as SharedFieldValue<string>;235+ });236+ return firstBody?.state === "ready";237+ }, "bob reads generation one");238+ assert(239+ firstBody?.state === "ready" && firstBody.value === "written under generation one",240+ "bob must decrypt generation-one content",241+ );242+ stopWatcher();243+ stopWatcher = null;244+245+ // --- Phase D: Bob is removed; rotation mints generation 2 wrapped only246+ // to the remaining member.247+ installSharedFieldIdentity(aliceIdentity);248+ const bobMembership = await within(249+ alice.all(app.workspaceMembers.where({ groupId: workspace.id, user_id: bobId } as never)),250+ "find bob's membership",251+ );252+ await within(253+ alice.delete(app.workspaceMembers, (bobMembership[0] as { id: string }).id)254+ .wait({ tier: "global" }),255+ "remove bob",256+ );257+ await within(rotateGroupFieldKey(aliceContext, workspace.id, bobId), "rotate");258+ const wraps = await within(259+ alice.all(app.workspaceFieldKeys.where({ groupId: workspace.id } as never)),260+ "list wraps",261+ ) as Array<{ recipient_user_id: string; generation: number }>;262+ assert(263+ !wraps.some((row) => row.recipient_user_id === bobId && row.generation === 2),264+ "the removed member must never receive the new generation",265+ );266+ assert(267+ wraps.some((row) => row.recipient_user_id === aliceId && row.generation === 2),268+ "the remaining member must receive the new generation",269+ );270+271+ const sealedSecond = sealSharedColumnValuesSync("docs", {272+ workspaceId: workspace.id,273+ title: "second",274+ body: "written after the rotation",275+ });276+ assert(277+ String(sealedSecond.body).startsWith(`encs1.workspaces.${workspace.id}.2.`),278+ "post-rotation writes must seal under generation two",279+ );280+ const secondDoc = await within(281+ alice.insert(app.docs, sealedSecond as never).wait({ tier: "global" }),282+ "second doc insert",283+ );284+285+ // --- Phase E: Bob's device holds only generation 1 — old content stays286+ // readable, new content stays pending. Lazy rekey, stated honestly.287+ clearSharedFieldKeys();288+ installSharedFieldIdentity(bobIdentity);289+ stopWatcher = startSharedFieldKeyWatcher({290+ db: bob as never,291+ appId: APP_ID,292+ userId: bobId,293+ configs: [{ label: "lifecycle.docs.body", kind: "text", ...SHARED_OPTIONS }],294+ findTable: (name) =>295+ name === "workspaceFieldKeys"296+ ? app.workspaceFieldKeys297+ : name === "keyDirectory"298+ ? app.keyDirectory299+ : null,300+ onAlert: () => {},301+ });302+ let oldBody: SharedFieldValue<string> | undefined;303+ await until(() => {304+ void bob.all(app.docs.where({ id: firstDoc.id })).then((rows) => {305+ oldBody = rows[0]?.body as unknown as SharedFieldValue<string>;306+ });307+ return oldBody?.state === "ready";308+ }, "bob re-reads generation one");309+ let newBody: SharedFieldValue<string> | undefined;310+ await until(() => {311+ void bob.all(app.docs.where({ id: secondDoc.id })).then((rows) => {312+ newBody = rows[0]?.body as unknown as SharedFieldValue<string>;313+ });314+ return newBody !== undefined;315+ }, "bob sees the second doc row");316+ assert(317+ newBody?.state === "pending-key" && newBody.generation === 2,318+ `generation-two content must stay pending for the removed member ` +319+ `(saw ${JSON.stringify(newBody)})`,320+ );321+ } finally {322+ stopWatcher?.();323+ clearSharedFieldIdentity();324+ clearSharedFieldKeys();325+ clearFingerprintPins(APP_ID);326+ clearSharedColumnRegistry();327+ clearEncryptedColumnRegistry();328+ await Promise.allSettled([329+ within(alice.logout(), "Alice cleanup", 3_000),330+ within(bob.logout(), "Bob cleanup", 3_000),331+ ]);332+ await within(server.stop(), "server cleanup", 3_000).catch(() => undefined);333+ }334+});@@ -206,7 +206,7 @@ Deno.test("a wrapped key turns Bob's pending read into plaintext", async () => {
206206 db: bob as never,207207 appId: "read-sync-app",208208 userId: bobId,209- configs: [{ label: "readsync.docs.body", ...SHARED_OPTIONS }],209+ configs: [{ label: "readsync.docs.body", kind: "text", ...SHARED_OPTIONS }],210210 findTable: (name) =>211211 name === "workspaceFieldKeys"212212 ? app.workspaceFieldKeys@@ -51,6 +51,7 @@ export const runtimeFiles = [
5151 "schema-compat.ts",5252 "session.ts",5353 "shared-field-keys.ts",54+ "shared-field-write.ts",5455 "startup-recovery.ts",5556 "storage-fork.ts",5657 "store-status.ts",@@ -72,6 +73,7 @@ export const accessFiles = [
7273 "operations.ts",7374 "policies.ts",7475 "schema.ts",76+ "shared-field-lifecycle.ts",7577 ] as const;7678 7779 /** Schema facade modules vendored into `.lofi/`; kept in lockstep by the manifest test. */@@ -0,0 +1,136 @@
1+/**2+ * The write side of shared encrypted columns: the mutation layer sees whole3+ * rows, so it — not the column transform — resolves each shared column's4+ * group scope from the configured sibling column, picks the newest field key5+ * this device holds, and seals the value before anything is journaled or6+ * handed to the engine. The column transform then only verifies the sealed7+ * prefix, so a write that bypasses this path fails closed instead of storing8+ * plaintext.9+ *10+ * @module11+ */12+import { encryptedColumnsOf } from "../schema/encrypted.ts";13+import { sealSharedValue, SharedFieldError } from "../schema/shared-crypto.ts";14+import {15+ getSharedFieldKey,16+ latestSharedFieldGeneration,17+ sharedKeyScope,18+} from "../schema/shared-keyring.ts";19+import { sharedColumnConfig } from "../schema/shared-registry.ts";20+21+const SEALED_PREFIX = "encs1.";22+23+type SharedWrite = {24+ column: string;25+ label: string;26+ kind: "text" | "json";27+ group: string;28+ groupIdColumn: string;29+};30+31+function sharedWritesOf(tableName: string, values: Record<string, unknown>): SharedWrite[] {32+ const encrypted = encryptedColumnsOf(tableName);33+ if (encrypted === undefined) return [];34+ const writes: SharedWrite[] = [];35+ for (const [column, label] of encrypted) {36+ if (!(column in values)) continue;37+ const config = sharedColumnConfig(label);38+ if (config === undefined) continue;39+ const value = values[column];40+ // Already sealed (a replayed journal entry or caller-provided seal).41+ if (typeof value === "string" && value.startsWith(SEALED_PREFIX)) continue;42+ writes.push({43+ column,44+ label,45+ kind: config.kind,46+ group: config.group,47+ groupIdColumn: config.groupIdColumn,48+ });49+ }50+ return writes;51+}52+53+function sealOne(54+ write: SharedWrite,55+ groupId: unknown,56+ value: unknown,57+): string {58+ if (typeof groupId !== "string" || !groupId) {59+ throw new SharedFieldError(60+ "unscoped-write",61+ `shared column "${write.label}" needs the row's "${write.groupIdColumn}" to resolve its ` +62+ "key scope — include it in the write",63+ );64+ }65+ const scope = sharedKeyScope(write.group, groupId);66+ const generation = latestSharedFieldGeneration(scope);67+ if (generation === null) {68+ throw new SharedFieldError(69+ "key-pending",70+ `no field key for ${scope} is installed on this device yet — writing waits for the ` +71+ "group key to arrive",72+ );73+ }74+ const fieldKey = getSharedFieldKey(scope, generation);75+ if (fieldKey === null) {76+ throw new SharedFieldError("key-pending", `the field key for ${scope} is not installed`);77+ }78+ const plaintext = write.kind === "json" ? JSON.stringify(value) : String(value);79+ return sealSharedValue({80+ plaintext,81+ fieldKey,82+ label: write.label,83+ scope: { groupTable: write.group, groupId, generation },84+ });85+}86+87+/** Whether a write touches any shared column that still needs sealing. */88+export function hasSharedColumnWrites(89+ tableName: string,90+ values: Record<string, unknown>,91+): boolean {92+ return sharedWritesOf(tableName, values).length > 0;93+}94+95+/**96+ * Seals every shared column present in a write, resolving the group id from97+ * the values themselves. Throws `unscoped-write` when a shared column is98+ * written without its group column — the synchronous path cannot fetch the99+ * row; include the group column in the patch or use the async variant.100+ */101+export function sealSharedColumnValuesSync(102+ tableName: string,103+ values: Record<string, unknown>,104+): Record<string, unknown> {105+ const writes = sharedWritesOf(tableName, values);106+ if (writes.length === 0) return values;107+ const sealed = { ...values };108+ for (const write of writes) {109+ sealed[write.column] = sealOne(write, values[write.groupIdColumn], values[write.column]);110+ }111+ return sealed;112+}113+114+/**115+ * Seals every shared column present in a write, fetching the existing row116+ * for updates whose patch omits the group column.117+ */118+export async function sealSharedColumnValues(119+ tableName: string,120+ values: Record<string, unknown>,121+ fetchRow?: () => Promise<Record<string, unknown> | null>,122+): Promise<Record<string, unknown>> {123+ const writes = sharedWritesOf(tableName, values);124+ if (writes.length === 0) return values;125+ const sealed = { ...values };126+ let row: Record<string, unknown> | null | undefined;127+ for (const write of writes) {128+ let groupId = values[write.groupIdColumn];129+ if ((typeof groupId !== "string" || !groupId) && fetchRow) {130+ row ??= await fetchRow();131+ groupId = row?.[write.groupIdColumn];132+ }133+ sealed[write.column] = sealOne(write, groupId, values[write.column]);134+ }135+ return sealed;136+}@@ -215,6 +215,7 @@ Deno.test("the watcher unwraps valid wraps and refuses forgeries and substitutio
215215 userId: "bob",216216 configs: [{217217 label: "docs.body",218+ kind: "text",218219 group: "workspaces",219220 groupIdColumn: "workspaceId",220221 keys: "workspaceFieldKeys",@@ -0,0 +1,126 @@
1+// The write-side sealer: shared columns seal with the newest held2+// generation, resolve their scope from the sibling group column (or a3+// fetched row for updates), refuse without a key, and pass pre-sealed and4+// non-shared values through untouched.5+import { sealSharedColumnValues, sealSharedColumnValuesSync } from "./shared-field-write.ts";6+import { clearEncryptedColumnRegistry, registerEncryptedColumns } from "../schema/encrypted.ts";7+import { clearSharedColumnRegistry, registerSharedColumn } from "../schema/shared-registry.ts";8+import {9+ clearSharedFieldKeys,10+ installSharedFieldKey,11+ sharedKeyScope,12+} from "../schema/shared-keyring.ts";13+import { generateFieldKey, openSharedValue, SharedFieldError } from "../schema/shared-crypto.ts";14+import { assert } from "./test-assert.ts";15+16+const LABEL = "writer.docs.body";17+18+function arrange(): Uint8Array {19+ clearEncryptedColumnRegistry();20+ clearSharedColumnRegistry();21+ clearSharedFieldKeys();22+ registerEncryptedColumns({23+ docs: { body: { __marker: true } },24+ });25+ // The harvest walks builder tags; register directly for the unit scope.26+ registerSharedColumn({27+ label: LABEL,28+ kind: "text",29+ group: "workspaces",30+ groupIdColumn: "workspaceId",31+ keys: "workspaceFieldKeys",32+ directory: "keyDirectory",33+ });34+ const fieldKey = generateFieldKey();35+ installSharedFieldKey(sharedKeyScope("workspaces", "ws-1"), 1, fieldKey);36+ installSharedFieldKey(sharedKeyScope("workspaces", "ws-1"), 3, fieldKey);37+ return fieldKey;38+}39+40+function cleanup(): void {41+ clearEncryptedColumnRegistry();42+ clearSharedColumnRegistry();43+ clearSharedFieldKeys();44+}45+46+// registerEncryptedColumns tags via builder symbols; the unit registry needs47+// the (table, column, label) mapping directly, so stub the tag lookup by48+// registering through the real harvest with a tagged builder object.49+import { encryptedText } from "../schema/encrypted.ts";50+function registerDocsBody(): void {51+ clearEncryptedColumnRegistry();52+ const tagged = encryptedText(LABEL);53+ registerEncryptedColumns({ docs: { body: tagged } });54+}55+56+Deno.test("inserts seal with the newest held generation", () => {57+ const fieldKey = arrange();58+ registerDocsBody();59+ try {60+ const sealed = sealSharedColumnValuesSync("docs", {61+ workspaceId: "ws-1",62+ title: "plain",63+ body: "shared words",64+ });65+ const stored = sealed.body as string;66+ assert(stored.startsWith("encs1.workspaces.ws-1.3."), "the newest generation must seal");67+ assert(sealed.title === "plain", "plaintext columns pass through");68+ assert(69+ openSharedValue({ stored, fieldKey, label: LABEL }) === "shared words",70+ "the sealed value must open under the field key",71+ );72+ // Idempotence: an already-sealed value passes through.73+ const again = sealSharedColumnValuesSync("docs", { workspaceId: "ws-1", body: stored });74+ assert(again.body === stored, "a pre-sealed value must pass through");75+ } finally {76+ cleanup();77+ }78+});79+80+Deno.test("writes without a key or scope refuse loudly", () => {81+ arrange();82+ registerDocsBody();83+ try {84+ let pending = false;85+ try {86+ sealSharedColumnValuesSync("docs", { workspaceId: "ws-other", body: "x" });87+ } catch (error) {88+ pending = error instanceof SharedFieldError && error.code === "key-pending";89+ }90+ assert(pending, "a scope without a held key must refuse with key-pending");91+92+ let unscoped = false;93+ try {94+ sealSharedColumnValuesSync("docs", { body: "x" });95+ } catch (error) {96+ unscoped = error instanceof SharedFieldError && error.code === "unscoped-write";97+ }98+ assert(unscoped, "a patch without the group column must refuse in the sync path");99+ } finally {100+ cleanup();101+ }102+});103+104+Deno.test("the async path resolves the scope from the fetched row", async () => {105+ const fieldKey = arrange();106+ registerDocsBody();107+ try {108+ const sealed = await sealSharedColumnValues(109+ "docs",110+ { body: "patched words" },111+ () => Promise.resolve({ id: "row-1", workspaceId: "ws-1" }),112+ );113+ const stored = sealed.body as string;114+ assert(stored.startsWith("encs1.workspaces.ws-1.3."), "the fetched scope must seal");115+ assert(116+ openSharedValue({ stored, fieldKey, label: LABEL }) === "patched words",117+ "the update seal must open",118+ );119+120+ // Tables without shared columns pass through untouched.121+ const untouched = await sealSharedColumnValues("elsewhere", { body: "x" });122+ assert(untouched.body === "x", "non-shared tables must pass through");123+ } finally {124+ cleanup();125+ }126+});@@ -1,3 +1,4 @@
1+import { sealSharedColumnValuesSync } from "./shared-field-write.ts";12 import type { Db, MutationErrorEvent, TableProxy } from "jazz-tools";23 import { syncing } from "./config.ts";34 import type { RuntimeDiagnostics } from "./diagnostics.ts";@@ -99,23 +100,34 @@ export class TableMutationStore<T extends TableRow, Init> {
99100 this.#environment.getLedger().perform<T>({100101 kind: "insert",101102 table: this.#table as TableProxy<unknown, unknown>,102- values,103+ values: this.#sealSharedColumns(values as Record<string, unknown>) as Init,103104 })104105 );105106 }106107 107- /** Updates a row. Awaiting the returned handle resolves at `saved`. */108+ /** Updates a row. Awaiting the returned handle resolves at `saved`.109+ * A patch touching a shared encrypted column must include the row's group110+ * column — the verb path seals synchronously before journaling and cannot111+ * fetch the row. */108112 update(id: string, patch: Partial<Init>): WriteHandle<void> {109113 return this.#track(() =>110114 this.#environment.getLedger().perform<void>({111115 kind: "update",112116 table: this.#table as TableProxy<unknown, unknown>,113117 id,114- patch: patch as Record<string, unknown>,118+ patch: this.#sealSharedColumns(patch as Record<string, unknown>),115119 })116120 );117121 }118122 123+ // Sealing precedes journaling so the durable journal holds ciphertext and124+ // replayed writes stay sealed.125+ #sealSharedColumns(values: Record<string, unknown>): Record<string, unknown> {126+ const tableName = (this.#table as unknown as { _table?: string })._table;127+ if (!tableName) return values;128+ return sealSharedColumnValuesSync(tableName, values);129+ }130+119131 /** Deletes a row. Awaiting the returned handle resolves at `saved`. */120132 remove(id: string): WriteHandle<void> {121133 return this.#track(() =>@@ -1,3 +1,4 @@
1+import { hasSharedColumnWrites, sealSharedColumnValues } from "./shared-field-write.ts";12 import type { Db, MutationErrorEvent, QueryBuilder, TableProxy } from "jazz-tools";23 // Package-owned reactive table store.34 import type { RuntimeDiagnostics } from "./diagnostics.ts";@@ -143,13 +144,39 @@ export class TableStore<T extends TableRow, Init> {
143144 };144145 145146 /** Inserts a row and waits for local durability before resolving. */146- async insert(values: Init): Promise<void> {147- await this.#perform(() => this.#db.insert(this.#table, values));147+ insert(values: Init): Promise<void> {148+ // The no-seal path stays fully synchronous up to #perform so pending149+ // writes register before the caller's next turn — the observability the150+ // ledger and tests rely on.151+ const tableName = (this.#table as unknown as { _table?: string })._table;152+ if (!tableName || !hasSharedColumnWrites(tableName, values as Record<string, unknown>)) {153+ return this.#perform(() => this.#db.insert(this.#table, values));154+ }155+ return (async () => {156+ const sealed = await sealSharedColumnValues(tableName, values as Record<string, unknown>);157+ await this.#perform(() => this.#db.insert(this.#table, sealed as Init));158+ })();148159 }149160 150161 /** Updates a row and waits for local durability before resolving. */151- async update(id: string, patch: Partial<Init>): Promise<void> {152- await this.#perform(() => this.#db.update(this.#table, id, patch));162+ update(id: string, patch: Partial<Init>): Promise<void> {163+ const tableName = (this.#table as unknown as { _table?: string })._table;164+ if (!tableName || !hasSharedColumnWrites(tableName, patch as Record<string, unknown>)) {165+ return this.#perform(() => this.#db.update(this.#table, id, patch));166+ }167+ return (async () => {168+ // Shared columns seal here — before the engine — because only the169+ // mutation layer can fetch the row whose group column names the scope.170+ const sealed = await sealSharedColumnValues(171+ tableName,172+ patch as Record<string, unknown>,173+ () =>174+ this.#db.one(175+ (this.#table as unknown as { where(input: unknown): never }).where({ id }),176+ ) as Promise<Record<string, unknown> | null>,177+ );178+ await this.#perform(() => this.#db.update(this.#table, id, sealed as Partial<Init>));179+ })();153180 }154181 155182 /** Deletes a row and waits for local durability before resolving. */@@ -31,7 +31,7 @@ export type SharedFieldValue<T> =
3131 | { state: "corrupt"; code: "corrupt" | "unscoped-write" };3232 3333 /** Options wiring a shared column to its group, keys, and directory tables. */34-export type SharedColumnOptions = Omit<SharedColumnConfig, "label">;34+export type SharedColumnOptions = Omit<SharedColumnConfig, "label" | "kind">;3535 3636 /** Whether a shared field value holds decrypted content. */3737 export function sharedFieldReady<T>(@@ -92,9 +92,10 @@ function guardedWrite(label: string, value: unknown): string {
9292 function sharedColumn<T>(9393 label: string,9494 options: SharedColumnOptions,95+ kind: "text" | "json",9596 decode: (plaintext: string) => T,9697 ): EncryptedColumn<SharedFieldValue<T>> {97- registerSharedColumn({ label, ...options });98+ registerSharedColumn({ label, kind, ...options });9899 const built = schema.string().transform({99100 to: (value: unknown) => guardedWrite(label, value),100101 from: (value: string) => openStored(label, value, decode),@@ -126,7 +127,7 @@ export function sharedEncryptedText(
126127 label: string,127128 options: SharedColumnOptions,128129 ): EncryptedColumn<SharedFieldValue<string>> {129- return sharedColumn(label, options, (plaintext) => plaintext);130+ return sharedColumn(label, options, "text", (plaintext) => plaintext);130131 }131132 132133 /**@@ -138,5 +139,5 @@ export function sharedEncryptedJson<T = unknown>(
138139 label: string,139140 options: SharedColumnOptions,140141 ): EncryptedColumn<SharedFieldValue<T>> {141- return sharedColumn(label, options, (plaintext) => JSON.parse(plaintext) as T);142+ return sharedColumn(label, options, "json", (plaintext) => JSON.parse(plaintext) as T);142143 }@@ -105,6 +105,13 @@ export function getSharedFieldKey(scope: string, generation: number): Uint8Array
105105 return fieldKeys.get(scope)?.get(generation) ?? null;106106 }107107 108+/** Every generation installed for a scope, ascending; empty when none is. */109+export function heldSharedFieldGenerations(scope: string): number[] {110+ const generations = fieldKeys.get(scope);111+ if (generations === undefined) return [];112+ return [...generations.keys()].sort((left, right) => left - right);113+}114+108115 /** The newest generation installed for a scope, or null when none is. */109116 export function latestSharedFieldGeneration(scope: string): number | null {110117 const generations = fieldKeys.get(scope);@@ -12,6 +12,8 @@
1212 export type SharedColumnConfig = {1313 /** The column's cryptographic label, conventionally `"table.column"`. */1414 label: string;15+ /** How the runtime value serializes into the sealed plaintext. */16+ kind: "text" | "json";1517 /** The group table whose membership scopes the field key. */1618 group: string;1719 /** The sibling column on the row that references the group. */