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 */17import {18 generateFieldKey,19 publicKeyFingerprint,20 SharedFieldError,21 type SharedFieldErrorCode,22 wrapFieldKey,23} from "../schema/shared-crypto.ts";24import {25 getSharedFieldKey,26 heldSharedFieldGenerations,27 installSharedFieldKey,28 requireSharedFieldIdentity,29 sharedKeyScope,30} from "../schema/shared-keyring.ts";31import {32 directoryPublicKey,33 ensureDirectoryEntry,34 verifyAndPinFingerprint,35} from "../runtime/shared-field-keys.ts";3637type LifecycleDb = {38 all(query: unknown): Promise<unknown[]>;39 insert(40 table: unknown,41 values: Record<string, unknown>,42 ): { wait(options: { tier: "local" | "global" }): Promise<unknown> };43};4445type QueryableTable = { where(condition: Record<string, unknown>): unknown };4647/** Everything the lifecycle needs about one group's shared-field wiring. */48export type SharedFieldLifecycleContext = {49 db: LifecycleDb;50 appId: string;51 userId: string;52 /** The group table's declared name — the key scope's first segment. */53 groupTable: string;54 fieldKeys: QueryableTable;55 directory: QueryableTable;56 members: QueryableTable;57};5859type WrapRow = {60 groupId: string;61 recipient_user_id: string;62 generation: number;63};6465type MemberRow = { user_id: string };6667type DirectoryEntry = { user_id: string; algo: string; public_key: string };6869/** Why a member could not receive a wrap; the pending state persists. */70export type WrapSkip = Extract<SharedFieldErrorCode, "no-directory-entry" | "peer-key-changed">;7172async function wrapTo(73 context: SharedFieldLifecycleContext,74 groupId: string,75 generation: number,76 fieldKey: Uint8Array,77 recipientUserId: string,78 expectedFingerprint?: string,79): Promise<WrapSkip | null> {80 const identity = requireSharedFieldIdentity();81 const entries = await context.db.all(82 context.directory.where({ user_id: recipientUserId }),83 ) as DirectoryEntry[];84 const entry = entries[0];85 if (entry === undefined) return "no-directory-entry";86 const recipientPublic = directoryPublicKey(entry);87 const observedFingerprint = publicKeyFingerprint(recipientPublic);88 // An out-of-band fingerprint (a lofi2 sharing identity) outranks the89 // directory: a mismatch means the directory is wrong, not the person.90 if (expectedFingerprint !== undefined && expectedFingerprint !== observedFingerprint) {91 return "peer-key-changed";92 }93 if (!verifyAndPinFingerprint(context.appId, recipientUserId, observedFingerprint)) {94 return "peer-key-changed";95 }96 const wrapped = wrapFieldKey({97 fieldKey,98 senderSecret: identity.secret,99 recipientPublic,100 context: {101 groupTable: context.groupTable,102 groupId,103 generation,104 recipientUserId,105 senderUserId: context.userId,106 },107 });108 await context.db.insert(context.fieldKeys as never, {109 groupId,110 recipient_user_id: recipientUserId,111 sender_user_id: context.userId,112 generation,113 wrapped_key: wrapped,114 recipient_fingerprint: observedFingerprint,115 sender_fingerprint: identity.fingerprint,116 }).wait({ tier: "global" });117 return null;118}119120async function remoteGenerations(121 context: SharedFieldLifecycleContext,122 groupId: string,123): Promise<WrapRow[]> {124 return await context.db.all(context.fieldKeys.where({ groupId })) as WrapRow[];125}126127/**128 * Mints generation 1 for a new group: the key installs locally and wraps to129 * the creator, so the group is writable immediately and other devices of the130 * creator's account unwrap through the ordinary watcher path. Requires the131 * creator's directory entry, publishing it if absent.132 */133export async function bootstrapGroupFieldKey(134 context: SharedFieldLifecycleContext,135 groupId: string,136): Promise<void> {137 const identity = requireSharedFieldIdentity();138 await ensureDirectoryEntry({139 db: context.db as never,140 directory: context.directory,141 userId: context.userId,142 identity,143 });144 const fieldKey = generateFieldKey();145 installSharedFieldKey(sharedKeyScope(context.groupTable, groupId), 1, fieldKey);146 const skip = await wrapTo(context, groupId, 1, fieldKey, context.userId);147 if (skip !== null) {148 throw new SharedFieldError(skip, "the group key could not be wrapped to its creator");149 }150}151152/**153 * Wraps every generation this device holds to a member, skipping wraps that154 * already exist. Returns the skip reason when the member cannot receive keys155 * yet — the member sits in the documented pending state until repair.156 */157export async function wrapHeldKeysForMember(158 context: SharedFieldLifecycleContext,159 groupId: string,160 recipientUserId: string,161 expectedFingerprint?: string,162): Promise<{ wrapped: number; skip: WrapSkip | null }> {163 const scope = sharedKeyScope(context.groupTable, groupId);164 const held = heldSharedFieldGenerations(scope);165 if (held.length === 0) return { wrapped: 0, skip: null };166 const existing = await remoteGenerations(context, groupId);167 const covered = new Set(168 existing169 .filter((row) => row.recipient_user_id === recipientUserId)170 .map((row) => row.generation),171 );172 let wrapped = 0;173 for (const generation of held) {174 if (covered.has(generation)) continue;175 const fieldKey = mustHold(scope, generation);176 const skip = await wrapTo(177 context,178 groupId,179 generation,180 fieldKey,181 recipientUserId,182 expectedFingerprint,183 );184 if (skip !== null) return { wrapped, skip };185 wrapped += 1;186 }187 return { wrapped, skip: null };188}189190/**191 * Rotates the group key after a removal: the next generation is minted past192 * every generation visible locally or remotely, installed locally, and193 * wrapped to every remaining member with a directory entry. Members without194 * one stay pending until repair; the removed member never receives the new195 * generation.196 */197export async function rotateGroupFieldKey(198 context: SharedFieldLifecycleContext,199 groupId: string,200 removedUserId: string,201): Promise<void> {202 const scope = sharedKeyScope(context.groupTable, groupId);203 const held = heldSharedFieldGenerations(scope);204 const remote = await remoteGenerations(context, groupId);205 const newest = Math.max(0, ...held, ...remote.map((row) => row.generation));206 const generation = newest + 1;207 const fieldKey = generateFieldKey();208 installSharedFieldKey(scope, generation, fieldKey);209 const members = await context.db.all(210 context.members.where({ groupId }),211 ) as MemberRow[];212 for (const member of members) {213 if (member.user_id === removedUserId) continue;214 await wrapTo(context, groupId, generation, fieldKey, member.user_id);215 }216}217218/**219 * Repairs missing wraps: every (member, held generation) pair without a220 * wrapped-key row receives one, directory permitting. The answer to the221 * inherent asynchrony of membership — some online device holding the key222 * eventually wraps; this is that device's move. Returns the number of wraps223 * inserted.224 */225export async function reconcileSharedFieldKeys(226 context: SharedFieldLifecycleContext,227 groupId: string,228): Promise<number> {229 const scope = sharedKeyScope(context.groupTable, groupId);230 const held = heldSharedFieldGenerations(scope);231 if (held.length === 0) return 0;232 const existing = await remoteGenerations(context, groupId);233 const covered = new Set(existing.map((row) => `${row.recipient_user_id}#${row.generation}`));234 const members = await context.db.all(context.members.where({ groupId })) as MemberRow[];235 let wrapped = 0;236 for (const member of members) {237 for (const generation of held) {238 if (covered.has(`${member.user_id}#${generation}`)) continue;239 const skip = await wrapTo(240 context,241 groupId,242 generation,243 mustHold(scope, generation),244 member.user_id,245 );246 if (skip === null) wrapped += 1;247 }248 }249 return wrapped;250}251252function mustHold(scope: string, generation: number): Uint8Array {253 const key = getSharedFieldKey(scope, generation);254 if (key === null) {255 throw new SharedFieldError("key-pending", `the field key for ${scope} vanished mid-operation`);256 }257 return key;258}