clone

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

main
lofi / package / runtime / shared_field_keys_test.ts
shared_field_keys_test.ts 9.7K
1// Shared-field identity custody: deterministic derivation with key2// separation, trust-on-first-sight pinning, and directory self-publication3// against a stub database.4import {5 clearFingerprintPins,6 deriveSharedFieldIdentity,7 directoryPublicKey,8 ensureDirectoryEntry,9 pinnedFingerprint,10 trustPeerKey,11 verifyAndPinFingerprint,12} from "./shared-field-keys.ts";13import { generateFieldKey, SharedFieldError, wrapFieldKey } from "../schema/shared-crypto.ts";14import {15 clearSharedFieldIdentity,16 clearSharedFieldKeys,17 getSharedFieldKey,18 installSharedFieldIdentity,19 sharedKeyScope,20} from "../schema/shared-keyring.ts";21import { startSharedFieldKeyWatcher } from "./shared-field-keys.ts";22import { assert } from "./test-assert.ts";2324const APP = "pin-test-app";2526Deno.test("identity derivation is deterministic and separated from the column key", async () => {27 const first = await deriveSharedFieldIdentity("account-secret-a");28 const second = await deriveSharedFieldIdentity("account-secret-a");29 assert(30 first.fingerprint === second.fingerprint &&31 first.publicKey.every((byte, index) => byte === second.publicKey[index]),32 "the same account secret must derive the same identity",33 );34 const other = await deriveSharedFieldIdentity("account-secret-b");35 assert(other.fingerprint !== first.fingerprint, "distinct secrets must derive distinct keys");3637 // Key separation: the x25519 secret must differ from the encrypted-column38 // master key derived from the same account secret (distinct HKDF info).39 const material = await crypto.subtle.importKey(40 "raw",41 new TextEncoder().encode("account-secret-a"),42 "HKDF",43 false,44 ["deriveBits"],45 );46 const columnKey = new Uint8Array(47 await crypto.subtle.deriveBits(48 {49 name: "HKDF",50 hash: "SHA-256",51 salt: new Uint8Array(0),52 info: new TextEncoder().encode("lofi:encrypted-columns:v1"),53 },54 material,55 256,56 ),57 );58 assert(59 !first.secret.every((byte, index) => byte === columnKey[index]),60 "the identity secret must not equal the column master key",61 );62});6364Deno.test("pins are trust-on-first-sight and refuse silent substitution", () => {65 clearFingerprintPins(APP);66 try {67 assert(pinnedFingerprint(APP, "alice") === undefined, "no pin before first sight");68 assert(verifyAndPinFingerprint(APP, "alice", "fp-1"), "first sight must pin");69 assert(pinnedFingerprint(APP, "alice") === "fp-1", "the pin must persist");70 assert(verifyAndPinFingerprint(APP, "alice", "fp-1"), "a matching key must verify");71 assert(!verifyAndPinFingerprint(APP, "alice", "fp-2"), "a changed key must refuse");72 assert(pinnedFingerprint(APP, "alice") === "fp-1", "a refusal must not move the pin");73 trustPeerKey(APP, "alice", "fp-2");74 assert(verifyAndPinFingerprint(APP, "alice", "fp-2"), "explicit trust must re-pin");75 } finally {76 clearFingerprintPins(APP);77 }78});7980Deno.test("directory rows decode strictly", async () => {81 const identity = await deriveSharedFieldIdentity("account-secret-a");82 let stored = "";83 {84 let binary = "";85 for (const byte of identity.publicKey) binary += String.fromCharCode(byte);86 stored = btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", "");87 }88 const decoded = directoryPublicKey({ algo: "x25519-v1", public_key: stored });89 assert(90 decoded.every((byte, index) => byte === identity.publicKey[index]),91 "a published key must decode to the derived key",92 );93 for (94 const [row, label] of [95 [{ algo: "rsa", public_key: stored }, "unsupported algorithm"],96 [{ algo: "x25519-v1", public_key: "AAAA" }, "truncated key"],97 ] as const98 ) {99 let refused = false;100 try {101 directoryPublicKey(row);102 } catch (error) {103 refused = error instanceof SharedFieldError && error.code === "wrap-invalid";104 }105 assert(refused, `${label} must refuse with wrap-invalid`);106 }107});108109Deno.test("directory publication inserts once and reports conflicts", async () => {110 const identity = await deriveSharedFieldIdentity("account-secret-a");111 const rows: Record<string, unknown>[] = [];112 const db = {113 all: (_query: unknown) => Promise.resolve([...rows]),114 insert: (_table: unknown, values: Record<string, unknown>) => ({115 wait: (_options: { tier: "local" | "global" }) => {116 rows.push(values);117 return Promise.resolve(values);118 },119 }),120 };121 const directory = { where: (condition: Record<string, unknown>) => condition };122123 const first = await ensureDirectoryEntry({ db, directory, userId: "alice", identity });124 assert(first.state === "published", "first boot must publish");125 assert(126 rows.length === 1 && rows[0].algo === "x25519-v1" &&127 rows[0].fingerprint === identity.fingerprint,128 "the published row must carry the key material",129 );130131 const second = await ensureDirectoryEntry({ db, directory, userId: "alice", identity });132 assert(second.state === "existing" && rows.length === 1, "a later boot must not republish");133134 rows[0] = { ...rows[0], public_key: "tampered-key" };135 const third = await ensureDirectoryEntry({ db, directory, userId: "alice", identity });136 assert(137 third.state === "self-key-conflict",138 "a mismatched self row must surface as a conflict, never be overwritten",139 );140 assert(rows.length === 1 && rows[0].public_key === "tampered-key", "no overwrite occurred");141});142143Deno.test("the watcher unwraps valid wraps and refuses forgeries and substitutions", async () => {144 clearFingerprintPins("watch-app");145 clearSharedFieldKeys();146 try {147 const alice = await deriveSharedFieldIdentity("alice-secret");148 const bob = await deriveSharedFieldIdentity("bob-secret");149 const mallory = await deriveSharedFieldIdentity("mallory-secret");150 installSharedFieldIdentity(bob);151152 const fieldKey = generateFieldKey();153 const context = {154 groupTable: "workspaces",155 groupId: "ws-1",156 generation: 1,157 recipientUserId: "bob",158 senderUserId: "alice",159 };160 const validWrap = wrapFieldKey({161 fieldKey,162 senderSecret: alice.secret,163 recipientPublic: bob.publicKey,164 context,165 });166 const forgedWrap = wrapFieldKey({167 fieldKey: generateFieldKey(),168 senderSecret: mallory.secret,169 recipientPublic: bob.publicKey,170 context: { ...context, generation: 2 },171 });172173 const encode = (bytes: Uint8Array) => {174 let binary = "";175 for (const byte of bytes) binary += String.fromCharCode(byte);176 return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", "");177 };178 const directoryRows = [{179 user_id: "alice",180 algo: "x25519-v1",181 public_key: encode(alice.publicKey),182 }];183 const wrapRows = [184 {185 groupId: "ws-1",186 recipient_user_id: "bob",187 sender_user_id: "alice",188 generation: 1,189 wrapped_key: validWrap,190 sender_fingerprint: alice.fingerprint,191 },192 // Forged: Mallory minted it but the row claims Alice sent it.193 {194 groupId: "ws-1",195 recipient_user_id: "bob",196 sender_user_id: "alice",197 generation: 2,198 wrapped_key: forgedWrap,199 sender_fingerprint: alice.fingerprint,200 },201 ];202203 const delivery: { fn: ((delta: { all: unknown[] }) => void) | null } = { fn: null };204 const db = {205 subscribeAll: (_query: unknown, onDelta: (delta: { all: unknown[] }) => void) => {206 delivery.fn = onDelta;207 return () => {};208 },209 all: (_query: unknown) => Promise.resolve([...directoryRows]),210 };211 const alerts: Array<{ code: string; userId: string }> = [];212 const stop = startSharedFieldKeyWatcher({213 db,214 appId: "watch-app",215 userId: "bob",216 configs: [{217 label: "docs.body",218 kind: "text",219 group: "workspaces",220 groupIdColumn: "workspaceId",221 keys: "workspaceFieldKeys",222 directory: "keyDirectory",223 }],224 findTable: (name) => ({ where: (condition) => ({ name, condition }) }),225 onAlert: (alert) => alerts.push(alert),226 });227 assert(delivery.fn !== null, "the watcher must subscribe to the key table");228 delivery.fn!({ all: wrapRows });229 // Unwrap resolution is async (directory fetch); yield until settled.230 await new Promise((resolve) => setTimeout(resolve, 20));231232 const scope = sharedKeyScope("workspaces", "ws-1");233 const installed = getSharedFieldKey(scope, 1);234 assert(235 installed !== null && installed.every((byte, index) => byte === fieldKey[index]),236 "the valid wrap must install its field key",237 );238 assert(getSharedFieldKey(scope, 2) === null, "the forged wrap must install nothing");239 assert(240 alerts.some((alert) => alert.code === "wrap-invalid" && alert.userId === "alice"),241 "the forged wrap must surface an alert",242 );243244 // A substituted directory key refuses before any unwrap: Alice's pin is245 // set from the valid round; swap the directory to Mallory's key.246 directoryRows[0] = {247 user_id: "alice",248 algo: "x25519-v1",249 public_key: encode(mallory.publicKey),250 };251 alerts.length = 0;252 delivery.fn!({253 all: [{254 groupId: "ws-1",255 recipient_user_id: "bob",256 sender_user_id: "alice",257 generation: 3,258 wrapped_key: validWrap,259 sender_fingerprint: mallory.fingerprint,260 }],261 });262 await new Promise((resolve) => setTimeout(resolve, 20));263 assert(264 alerts.some((alert) => alert.code === "peer-key-changed"),265 "a substituted directory key must surface peer-key-changed",266 );267 assert(getSharedFieldKey(scope, 3) === null, "a substituted key must install nothing");268 stop();269 } finally {270 clearFingerprintPins("watch-app");271 clearSharedFieldKeys();272 clearSharedFieldIdentity();273 }274});