clone

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

main
lofi / package / schema / shared-keyring.ts
shared-keyring.ts 5.0K
1/**2 * The in-memory keyring for shared fields, mirroring the module-slot design3 * of the encrypted-column master key: synchronous column transforms can only4 * consult memory, so everything they need — the account's x25519 identity5 * now, unwrapped field keys as the read side lands — is installed by the6 * runtime before any database operation and read synchronously here.7 *8 * @module9 */10import { SharedFieldError } from "./shared-crypto.ts";1112/** The account's shared-field identity, derived from the account secret. */13export type SharedFieldIdentity = {14 /** 32-byte x25519 static secret; never leaves memory. */15 secret: Uint8Array;16 /** The matching public key, self-published in the key directory. */17 publicKey: Uint8Array;18 /** base64url SHA-256 of the public key — the pinnable identity. */19 fingerprint: string;20};2122let identity: SharedFieldIdentity | null = null;2324/**25 * Installs the account's shared-field identity. The runtime calls this at26 * boot (after the encrypted-column key) and again whenever the account27 * secret changes; tests inject fixed identities.28 */29export function installSharedFieldIdentity(next: SharedFieldIdentity): void {30 if (next.secret.length !== 32 || next.publicKey.length !== 32) {31 throw new SharedFieldError(32 "identity-missing",33 "a shared-field identity needs 32-byte x25519 keys",34 );35 }36 identity = {37 secret: new Uint8Array(next.secret),38 publicKey: new Uint8Array(next.publicKey),39 fingerprint: next.fingerprint,40 };41}4243/** The installed identity; throws `identity-missing` before boot installs it. */44export function requireSharedFieldIdentity(): SharedFieldIdentity {45 if (identity === null) {46 throw new SharedFieldError(47 "identity-missing",48 "shared fields were used before the account identity was installed",49 );50 }51 return identity;52}5354/** The installed identity, or null before boot installs one. */55export function sharedFieldIdentityOrNull(): SharedFieldIdentity | null {56 return identity;57}5859/** Forgets the installed identity; shared fields fail closed afterwards. */60export function clearSharedFieldIdentity(): void {61 identity = null;62}6364// --- Field keys --------------------------------------------------------------65//66// Unwrapped group field keys, keyed by scope ("groupTable/groupId") and67// generation. The runtime's wrapped-key watcher installs entries as wraps68// arrive and sync; the synchronous column transforms read them here. A read69// whose (scope, generation) is absent surfaces as a pending state, never an70// exception — key-pending is the normal condition of a freshly added member.7172const fieldKeys = new Map<string, Map<number, Uint8Array>>();73const keyringListeners = new Set<() => void>();7475/** The keyring scope of a group resource: `"groupTable/groupId"`. */76export function sharedKeyScope(groupTable: string, groupId: string): string {77 return `${groupTable}/${groupId}`;78}7980/** Installs an unwrapped field key and notifies keyring subscribers. */81export function installSharedFieldKey(82 scope: string,83 generation: number,84 key: Uint8Array,85): void {86 if (key.length !== 32) {87 throw new SharedFieldError("wrap-invalid", "a field key must be 32 bytes");88 }89 let generations = fieldKeys.get(scope);90 if (generations === undefined) {91 generations = new Map();92 fieldKeys.set(scope, generations);93 }94 const known = generations.get(generation);95 generations.set(generation, new Uint8Array(key));96 // Re-notification is what turns pending reads into plaintext; skip only97 // when the exact key was already present.98 if (known === undefined || !known.every((byte, index) => byte === key[index])) {99 for (const listener of [...keyringListeners]) listener();100 }101}102103/** The installed key for a scope and generation, or null while pending. */104export function getSharedFieldKey(scope: string, generation: number): Uint8Array | null {105 return fieldKeys.get(scope)?.get(generation) ?? null;106}107108/** Every generation installed for a scope, ascending; empty when none is. */109export 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}114115/** The newest generation installed for a scope, or null when none is. */116export function latestSharedFieldGeneration(scope: string): number | null {117 const generations = fieldKeys.get(scope);118 if (generations === undefined || generations.size === 0) return null;119 return Math.max(...generations.keys());120}121122/**123 * Subscribes to keyring changes. Live-query stores resubscribe on change so124 * rows previously surfaced as pending re-materialize into plaintext.125 */126export function subscribeSharedKeyring(listener: () => void): () => void {127 keyringListeners.add(listener);128 return () => {129 keyringListeners.delete(listener);130 };131}132133/** Empties every installed field key; tests and logout call this. */134export function clearSharedFieldKeys(): void {135 fieldKeys.clear();136 for (const listener of [...keyringListeners]) listener();137}