1/**2 * Runtime custody of shared-field identity: derives the account's x255193 * keypair from the account secret at boot, self-publishes the public key in4 * the app's key directory, and pins peers' fingerprints on first sight so a5 * later substitution — the one active attack a key-relaying server has — is6 * a detected failure, not a silent read.7 *8 * @module9 */10import {11 publicKeyFingerprint,12 SharedFieldError,13 sharedFieldPublicKey,14 unwrapFieldKey,15} from "../schema/shared-crypto.ts";16import {17 clearSharedFieldIdentity,18 installSharedFieldIdentity,19 installSharedFieldKey,20 requireSharedFieldIdentity,21 type SharedFieldIdentity,22 sharedKeyScope,23} from "../schema/shared-keyring.ts";24import type { SharedColumnConfig } from "../schema/shared-registry.ts";2526export { clearSharedFieldIdentity };2728/**29 * Derives the account's shared-field identity from the account secret: the30 * same IKM convention as the encrypted-column master key with a distinct31 * HKDF info string, so the two hierarchies never share key material.32 */33export async function deriveSharedFieldIdentity(secret: string): Promise<SharedFieldIdentity> {34 const material = await crypto.subtle.importKey(35 "raw",36 new TextEncoder().encode(secret) as BufferSource,37 "HKDF",38 false,39 ["deriveBits"],40 );41 const bits = await crypto.subtle.deriveBits(42 {43 name: "HKDF",44 hash: "SHA-256",45 salt: new Uint8Array(0) as BufferSource,46 info: new TextEncoder().encode("lofi:shared-fields:x25519:v1") as BufferSource,47 },48 material,49 256,50 );51 const identitySecret = new Uint8Array(bits);52 const publicKey = sharedFieldPublicKey(identitySecret);53 return { secret: identitySecret, publicKey, fingerprint: publicKeyFingerprint(publicKey) };54}5556/** Derives and installs the identity; the boot path calls this per client. */57export async function installSharedFieldIdentityFromSecret(secret: string): Promise<void> {58 installSharedFieldIdentity(await deriveSharedFieldIdentity(secret));59}6061// --- Fingerprint pinning -----------------------------------------------------62//63// Trust-on-first-sight per device: the first fingerprint observed for a peer64// (or the one carried out-of-band in a lofi2 sharing identity, which never65// gives the server a first-sight window) is pinned in localStorage. A66// directory row that later disagrees is refused until the user explicitly67// re-trusts, which is the detection the threat model promises.6869function pinStorageKey(appId: string): string {70 return `lofi:shared-fields:pins:${appId}`;71}7273function readPins(appId: string): Record<string, string> {74 try {75 const raw = localStorage.getItem(pinStorageKey(appId));76 if (!raw) return {};77 const parsed = JSON.parse(raw) as unknown;78 if (typeof parsed !== "object" || parsed === null) return {};79 return parsed as Record<string, string>;80 } catch {81 return {};82 }83}8485function writePins(appId: string, pins: Record<string, string>): void {86 try {87 localStorage.setItem(pinStorageKey(appId), JSON.stringify(pins));88 } catch {89 // Storage failures degrade to first-sight trust on the next read; the90 // unwrap path still authenticates cryptographically.91 }92}9394/** The pinned fingerprint for a peer, if this device has seen one. */95export function pinnedFingerprint(appId: string, userId: string): string | undefined {96 return readPins(appId)[userId];97}9899/**100 * Verifies a peer's observed fingerprint against this device's pin, pinning101 * on first sight. Returns false — and leaves the pin untouched — when a pin102 * exists and disagrees; callers refuse the key and surface the mismatch.103 */104export function verifyAndPinFingerprint(105 appId: string,106 userId: string,107 observed: string,108): boolean {109 const pins = readPins(appId);110 const pinned = pins[userId];111 if (pinned === undefined) {112 pins[userId] = observed;113 writePins(appId, pins);114 return true;115 }116 return pinned === observed;117}118119/**120 * Replaces a peer's pin after out-of-band verification — the explicit user121 * action that resolves a `peer-key-changed` refusal.122 */123export function trustPeerKey(appId: string, userId: string, fingerprint: string): void {124 const pins = readPins(appId);125 pins[userId] = fingerprint;126 writePins(appId, pins);127}128129/** Forgets every pin for an app; tests call this between scenarios. */130export function clearFingerprintPins(appId: string): void {131 try {132 localStorage.removeItem(pinStorageKey(appId));133 } catch {134 // Nothing to clear.135 }136}137138// --- Directory self-publication ----------------------------------------------139140type DirectoryRow = {141 id: string;142 user_id: string;143 algo: string;144 public_key: string;145 fingerprint: string;146};147148type DirectoryDb = {149 all(query: unknown): Promise<unknown[]>;150 insert(151 table: unknown,152 values: Record<string, unknown>,153 ): { wait(options: { tier: "local" | "global" }): Promise<unknown> };154};155156type DirectoryTable = {157 where(condition: Record<string, unknown>): unknown;158};159160function toBase64Url(bytes: Uint8Array): string {161 let binary = "";162 for (const byte of bytes) binary += String.fromCharCode(byte);163 return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", "");164}165166/** The outcome of a directory self-publication attempt. */167export type DirectoryPublication =168 | { state: "published" }169 | { state: "existing" }170 | { state: "self-key-conflict"; expected: string; observed: string };171172/**173 * Ensures the current account's public key is published in the app's key174 * directory: inserts on first sync, verifies on every later boot. A row that175 * exists with a *different* key is never overwritten — that shape means176 * directory tampering or a derivation bug, and silently replacing it would177 * hide exactly the event pinning exists to expose.178 */179export async function ensureDirectoryEntry(input: {180 db: DirectoryDb;181 directory: DirectoryTable;182 userId: string;183 identity: SharedFieldIdentity;184}): Promise<DirectoryPublication> {185 const rows = await input.db.all(186 input.directory.where({ user_id: input.userId }),187 ) as DirectoryRow[];188 const publicKey = toBase64Url(input.identity.publicKey);189 const existing = rows[0];190 if (existing !== undefined) {191 if (existing.public_key === publicKey) return { state: "existing" };192 return {193 state: "self-key-conflict",194 expected: publicKey,195 observed: existing.public_key,196 };197 }198 await input.db.insert(input.directory as never, {199 user_id: input.userId,200 algo: "x25519-v1",201 public_key: publicKey,202 fingerprint: input.identity.fingerprint,203 }).wait({ tier: "global" });204 return { state: "published" };205}206207/** Decodes a directory row's public key, refusing malformed entries. */208export function directoryPublicKey(row: { public_key: string; algo: string }): Uint8Array {209 if (row.algo !== "x25519-v1") {210 throw new SharedFieldError(211 "wrap-invalid",212 `directory entry carries unsupported algorithm "${row.algo}"`,213 );214 }215 const base64 = row.public_key.replaceAll("-", "+").replaceAll("_", "/");216 const padded = base64 + "=".repeat((4 - (base64.length % 4)) % 4);217 const binary = atob(padded);218 const bytes = new Uint8Array(binary.length);219 for (let index = 0; index < binary.length; index++) bytes[index] = binary.charCodeAt(index);220 if (bytes.length !== 32) {221 throw new SharedFieldError("wrap-invalid", "directory entry holds a malformed public key");222 }223 return bytes;224}225226// --- Wrapped-key watcher -----------------------------------------------------227228type WatcherDb = {229 subscribeAll(230 query: unknown,231 onDelta: (delta: { all: unknown[] }) => void,232 onError?: (error: unknown) => void,233 ): () => void;234 all(query: unknown): Promise<unknown[]>;235};236237type WrappedKeyRow = {238 groupId: string;239 recipient_user_id: string;240 sender_user_id: string;241 generation: number;242 wrapped_key: string;243 sender_fingerprint: string;244};245246/** One anomaly the watcher surfaces instead of installing a key. */247export type SharedFieldWatchAlert = {248 code: "peer-key-changed" | "wrap-invalid";249 userId: string;250 detail: string;251};252253/**254 * Watches the wrapped-key tables named by the shared-column registry for255 * rows addressed to this account, resolves each sender's public key through256 * the pin store, unwraps, and installs field keys into the keyring. A sender257 * whose published key disagrees with its pin, or a wrap that fails258 * authentication, surfaces as an alert and installs nothing — refusing the259 * key IS the detection.260 *261 * Returns a teardown that closes every subscription.262 */263export function startSharedFieldKeyWatcher(input: {264 db: WatcherDb;265 appId: string;266 userId: string;267 configs: readonly SharedColumnConfig[];268 findTable(name: string): { where(condition: Record<string, unknown>): unknown } | null;269 onAlert(alert: SharedFieldWatchAlert): void;270}): () => void {271 const stops: Array<() => void> = [];272 const watched = new Set<string>();273 for (const config of input.configs) {274 const watchKey = `${config.keys}|${config.directory}|${config.group}`;275 if (watched.has(watchKey)) continue;276 watched.add(watchKey);277 const keysTable = input.findTable(config.keys);278 const directoryTable = input.findTable(config.directory);279 if (!keysTable || !directoryTable) {280 input.onAlert({281 code: "wrap-invalid",282 userId: input.userId,283 detail: `shared column "${config.label}" names undeclared table ` +284 `"${!keysTable ? config.keys : config.directory}"`,285 });286 continue;287 }288 const processed = new Set<string>();289 const stop = input.db.subscribeAll(290 keysTable.where({ recipient_user_id: input.userId }),291 (delta) => {292 for (const raw of delta.all as WrappedKeyRow[]) {293 const rowKey = `${config.group}/${raw.groupId}#${raw.generation}@${raw.sender_user_id}` +294 `:${raw.wrapped_key}`;295 if (processed.has(rowKey)) continue;296 processed.add(rowKey);297 void installWrappedKey(input, config, directoryTable, raw);298 }299 },300 () => {301 // Subscription errors surface through the live-query stores consumers302 // actually watch; the watcher retries on the next runtime recreation.303 },304 );305 stops.push(stop);306 }307 return () => {308 for (const stop of stops) stop();309 };310}311312async function installWrappedKey(313 input: {314 db: WatcherDb;315 appId: string;316 userId: string;317 onAlert(alert: SharedFieldWatchAlert): void;318 },319 config: SharedColumnConfig,320 directoryTable: { where(condition: Record<string, unknown>): unknown },321 row: WrappedKeyRow,322): Promise<void> {323 try {324 const identity = requireSharedFieldIdentity();325 const entries = await input.db.all(326 directoryTable.where({ user_id: row.sender_user_id }),327 ) as Array<{ user_id: string; algo: string; public_key: string }>;328 const entry = entries[0];329 if (entry === undefined) {330 input.onAlert({331 code: "wrap-invalid",332 userId: row.sender_user_id,333 detail: `a wrap from ${row.sender_user_id} arrived before its directory entry`,334 });335 return;336 }337 const senderPublic = directoryPublicKey(entry);338 const observedFingerprint = publicKeyFingerprint(senderPublic);339 if (!verifyAndPinFingerprint(input.appId, row.sender_user_id, observedFingerprint)) {340 input.onAlert({341 code: "peer-key-changed",342 userId: row.sender_user_id,343 detail: `the published key for ${row.sender_user_id} no longer matches its pin; ` +344 "refusing its wraps until the key is re-trusted",345 });346 return;347 }348 if (row.sender_fingerprint !== observedFingerprint) {349 input.onAlert({350 code: "wrap-invalid",351 userId: row.sender_user_id,352 detail: "a wrap's declared sender fingerprint disagrees with the directory",353 });354 return;355 }356 const fieldKey = unwrapFieldKey({357 wrapped: row.wrapped_key,358 recipientSecret: identity.secret,359 senderPublic,360 context: {361 groupTable: config.group,362 groupId: row.groupId,363 generation: row.generation,364 recipientUserId: input.userId,365 senderUserId: row.sender_user_id,366 },367 });368 installSharedFieldKey(sharedKeyScope(config.group, row.groupId), row.generation, fieldKey);369 } catch (error) {370 input.onAlert({371 code: "wrap-invalid",372 userId: row.sender_user_id,373 detail: error instanceof SharedFieldError374 ? error.message375 : `a wrap failed to install: ${String(error)}`,376 });377 }378}