clone

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

feat/141-demo-review-fixes
lofi / package / runtime / provision_test.ts
provision_test.ts 8.6K
1// Provision-capability custody contract: memory-only hold, PRF sealing with2// the credential that actually evaluated it, lock/unlock ceremonies, failure3// mapping (cancelled, prf-unavailable, credential-mismatch), and the4// no-plaintext-bearer guarantee for the sealed record.5import { AuthError } from "./auth.ts";6import { anchorAppId } from "./data-sink.ts";7import { EnvelopeError, parseSealedEnvelope } from "./envelope.ts";8import {9 clearProvisionCapability,10 heldProvisionCapability,11 holdProvisionCapability,12 lockProvisionCapability,13 provisionCapabilityStatus,14 sealProvisionCapability,15 unlockProvisionCapability,16} from "./provision.ts";17import { defineLofiApp } from "./app.ts";18import { assert } from "./test-assert.ts";1920defineLofiApp({21 name: "lofi-test",22 databaseName: "lofi-test",23 schema: {},24 storage: "durable",25 credentialOrigins: [],26 sync: { adapter: "jazz" },27});2829const test = (globalThis as unknown as {30 Deno: { test(name: string, body: () => void | Promise<void>): void };31}).Deno.test;3233const provisionKey = `lofi:provision:${anchorAppId}`;34const bearerUrl = `https://node.example:4802/t/${"p".repeat(43)}`;3536function withCleanState(body: () => void | Promise<void>): () => Promise<void> {37 return async () => {38 clearProvisionCapability();39 try {40 await body();41 } finally {42 clearProvisionCapability();43 }44 };45}4647// A 37-byte authenticator-data buffer (rpIdHash[32] + flags + signCount[4]).48// The WebAuthn "backup eligible" (BE) flag is bit 0x08 of byte index 32.49function authenticatorData(backupEligible: boolean): ArrayBuffer {50 const bytes = new Uint8Array(37);51 if (backupEligible) bytes[32] |= 0x08;52 return bytes.buffer;53}5455type PrfEval = { eval?: { first?: BufferSource } };5657// A fake PRF authenticator: one credential, deterministic PRF output per eval58// salt (as a real authenticator computes it), user-verifying get() only.59function fakePrfAuthenticator(options: {60 rawId?: Uint8Array;61 portable?: boolean;62 prf?: boolean;63 cancel?: boolean;64} = {}): CredentialsContainer {65 const rawId = options.rawId ?? new Uint8Array([1, 2, 3, 4]);66 const outputs = new Map<string, Uint8Array>();67 return {68 create: () => Promise.reject(new Error("unused")),69 get: (request: CredentialRequestOptions) => {70 if (options.cancel) {71 return Promise.reject(Object.assign(new Error("cancelled"), { name: "NotAllowedError" }));72 }73 const extensions = (request.publicKey?.extensions ?? {}) as { prf?: PrfEval };74 const salt = extensions.prf?.eval?.first;75 let first: ArrayBuffer | undefined;76 if (salt && options.prf !== false) {77 const key = Array.from(new Uint8Array(salt as ArrayBuffer)).join(",");78 if (!outputs.has(key)) {79 outputs.set(key, crypto.getRandomValues(new Uint8Array(32)));80 }81 // A fresh buffer per ceremony, as a real authenticator re-derives it.82 first = (outputs.get(key) as Uint8Array).slice().buffer as ArrayBuffer;83 }84 return Promise.resolve({85 rawId: rawId.buffer,86 response: { authenticatorData: authenticatorData(options.portable ?? true) },87 getClientExtensionResults: () => (first ? { prf: { results: { first } } } : {}),88 } as unknown as Credential);89 },90 store: () => Promise.reject(new Error("unused")),91 preventSilentAccess: () => Promise.resolve(),92 } as unknown as CredentialsContainer;93}9495const rpId = "app.example.com";9697test(98 "holding is memory-only: usable now, nothing at rest, gone on clear",99 withCleanState(() => {100 assert(heldProvisionCapability() === null, "a clean device holds nothing");101 holdProvisionCapability(bearerUrl);102 assert(heldProvisionCapability() === bearerUrl, "the held URL must read back");103 const status = provisionCapabilityStatus();104 assert(status.held && !status.sealed, "holding must not create a sealed record");105 assert(localStorage.getItem(provisionKey) === null, "holding must write nothing to storage");106 clearProvisionCapability();107 assert(heldProvisionCapability() === null, "clearing must forget the capability");108 }),109);110111test(112 "sealing records the asserting credential and keeps the bearer out of storage",113 withCleanState(async () => {114 holdProvisionCapability(bearerUrl);115 const credentials = fakePrfAuthenticator({ portable: true });116 const sealed = await sealProvisionCapability({ credentials, rpId });117 assert(sealed.portable, "the portable flag must come from the asserting credential");118 const status = provisionCapabilityStatus();119 assert(120 status.held && status.sealed && status.portable === true,121 "status must reflect the seal",122 );123 const raw = localStorage.getItem(provisionKey) ?? "";124 assert(!raw.includes(bearerUrl), "the bearer URL must not reach storage in cleartext");125 assert(!raw.includes("p".repeat(43)), "the bearer secret must not reach storage in cleartext");126 assert(!raw.includes(btoa(bearerUrl)), "nor in a trivial base64 encoding");127 const record = JSON.parse(raw) as { v: number; sealed: unknown };128 assert(129 record.v === 1 && parseSealedEnvelope(record.sealed) !== null,130 "the record at rest must be a sealed envelope",131 );132 }),133);134135test(136 "lock forgets memory, the ceremony unlocks, and the same salt re-derives",137 withCleanState(async () => {138 holdProvisionCapability(bearerUrl);139 const credentials = fakePrfAuthenticator({ portable: false });140 await sealProvisionCapability({ credentials, rpId });141 lockProvisionCapability();142 assert(heldProvisionCapability() === null, "locking must forget the in-memory capability");143 assert(provisionCapabilityStatus().sealed, "locking must keep the sealed record");144 const unlocked = await unlockProvisionCapability({ credentials, rpId });145 assert(unlocked === bearerUrl, "the ceremony must return the exact bearer URL");146 assert(heldProvisionCapability() === bearerUrl, "an unlock must hold the capability again");147 }),148);149150test(151 "a different passkey cannot unlock the sealed record",152 withCleanState(async () => {153 holdProvisionCapability(bearerUrl);154 await sealProvisionCapability({ credentials: fakePrfAuthenticator(), rpId });155 lockProvisionCapability();156 const impostor = fakePrfAuthenticator({ rawId: new Uint8Array([9, 9, 9, 9]) });157 try {158 await unlockProvisionCapability({ credentials: impostor, rpId });159 throw new Error("an impostor credential must not unlock the record");160 } catch (error) {161 assert(162 error instanceof AuthError && error.code === "credential-mismatch",163 `expected credential-mismatch, received ${String(error)}`,164 );165 }166 assert(provisionCapabilityStatus().sealed, "the sealed record must survive the attempt");167 }),168);169170test(171 "cancelled and prf-unavailable ceremonies leave state intact",172 withCleanState(async () => {173 holdProvisionCapability(bearerUrl);174 try {175 await sealProvisionCapability({ credentials: fakePrfAuthenticator({ prf: false }), rpId });176 throw new Error("a no-PRF authenticator must not seal");177 } catch (error) {178 assert(179 error instanceof AuthError && error.code === "prf-unavailable",180 `expected prf-unavailable, received ${String(error)}`,181 );182 }183 assert(localStorage.getItem(provisionKey) === null, "a failed seal must store nothing");184 assert(heldProvisionCapability() === bearerUrl, "the capability must stay held");185186 await sealProvisionCapability({ credentials: fakePrfAuthenticator(), rpId });187 lockProvisionCapability();188 try {189 await unlockProvisionCapability({190 credentials: fakePrfAuthenticator({ cancel: true }),191 rpId,192 });193 throw new Error("a cancelled ceremony must not unlock");194 } catch (error) {195 assert(196 error instanceof AuthError && error.code === "cancelled",197 `expected cancelled, received ${String(error)}`,198 );199 }200 assert(heldProvisionCapability() === null, "a cancelled unlock must not hold anything");201 assert(provisionCapabilityStatus().sealed, "the sealed record must survive a cancellation");202 }),203);204205test(206 "sealing without a held capability and unlocking without a record report locked",207 withCleanState(async () => {208 for (209 const attempt of [210 () => sealProvisionCapability({ credentials: fakePrfAuthenticator(), rpId }),211 () => unlockProvisionCapability({ credentials: fakePrfAuthenticator(), rpId }),212 ]213 ) {214 try {215 await attempt();216 throw new Error("an empty device must not seal or unlock");217 } catch (error) {218 assert(219 error instanceof EnvelopeError && error.code === "locked",220 `expected locked, received ${String(error)}`,221 );222 }223 }224 }),225);