1// Client proof-of-possession contract: signatures match the cross-repo2// fixtures byte for byte, the device keypair persists per app, and the3// pre-connect exchange yields the token URL or degrades to null.4import {5 completePopExchange,6 exportDevicePublicKey,7 getOrCreatePopKeyPair,8 memoryPopKeyStore,9 popMessage,10 signPopMessage,11} from "./pop.ts";12import { assert } from "./test-assert.ts";1314function fromBase64Url(text: string): Uint8Array {15 const base64 = text.replaceAll("-", "+").replaceAll("_", "/");16 const padded = base64 + "=".repeat((4 - (base64.length % 4)) % 4);17 const binary = atob(padded);18 const bytes = new Uint8Array(binary.length);19 for (let index = 0; index < binary.length; index++) bytes[index] = binary.charCodeAt(index);20 return bytes;21}2223type PopFixtures = {24 privateJwk: JsonWebKey;25 spki: string;26 appId: string;27 ticketId: string;28 nonce: string;29 signature: string;30};3132async function loadFixtures(): Promise<PopFixtures> {33 return JSON.parse(34 await Deno.readTextFile(new URL("../testdata/pop-fixtures.json", import.meta.url)),35 ) as PopFixtures;36}3738Deno.test("signatures conform to the node's fixture vectors", async () => {39 const fixtures = await loadFixtures();40 const message = popMessage(fixtures.appId, fixtures.ticketId, fixtures.nonce);4142 // The fixture's own signature verifies under its public key — the message43 // bytes this client builds are the bytes the node verifies.44 const publicKey = await crypto.subtle.importKey(45 "spki",46 fromBase64Url(fixtures.spki).buffer as ArrayBuffer,47 { name: "ECDSA", namedCurve: "P-256" },48 false,49 ["verify"],50 );51 assert(52 await crypto.subtle.verify(53 { name: "ECDSA", hash: "SHA-256" },54 publicKey,55 fromBase64Url(fixtures.signature).buffer as ArrayBuffer,56 message.buffer as ArrayBuffer,57 ),58 "the fixture signature must verify over this client's message bytes",59 );6061 // A signature this client produces with the fixture's private key verifies62 // the same way — signing and verification agree end to end.63 const privateKey = await crypto.subtle.importKey(64 "jwk",65 fixtures.privateJwk,66 { name: "ECDSA", namedCurve: "P-256" },67 false,68 ["sign"],69 );70 const signed = await signPopMessage(71 { privateKey, publicKey } as CryptoKeyPair,72 message,73 );74 assert(75 await crypto.subtle.verify(76 { name: "ECDSA", hash: "SHA-256" },77 publicKey,78 fromBase64Url(signed).buffer as ArrayBuffer,79 message.buffer as ArrayBuffer,80 ),81 "a client-produced signature must verify under the fixture key",82 );83});8485Deno.test("the device keypair persists per app and exports its public half", async () => {86 const store = memoryPopKeyStore();87 const first = await getOrCreatePopKeyPair("app-a", store);88 const again = await getOrCreatePopKeyPair("app-a", store);89 assert(first.privateKey === again.privateKey, "the same app must reuse its keypair");90 const other = await getOrCreatePopKeyPair("app-b", store);91 assert(other.privateKey !== first.privateKey, "apps must not share keypairs");92 assert(first.privateKey.extractable === false, "the signing key must be non-extractable");93 const exported = await exportDevicePublicKey(first);94 assert(95 exported.alg === "ES256" && exported.spki.length > 80,96 "the public half must export as base64url SPKI",97 );98});99100Deno.test("the exchange yields the token URL and degrades to null", async () => {101 const store = memoryPopKeyStore();102 const keyPair = await getOrCreatePopKeyPair("app-x", store);103 const exported = await exportDevicePublicKey(keyPair);104105 const requests: string[] = [];106 const respondingFetcher = (async (input: RequestInfo | URL, init?: RequestInit) => {107 const url = String(input);108 requests.push(url);109 if (url.endsWith("/pop/challenge")) {110 return Response.json({ v: 1, id: "chal-1", nonce: "nonce-1", expiresIn: 120 });111 }112 if (url.endsWith("/pop/answer")) {113 const body = JSON.parse(String(init?.body)) as { id: string; sig: string };114 // Verify the signature exactly as the node would.115 const publicKey = await crypto.subtle.importKey(116 "spki",117 fromBase64Url(exported.spki).buffer as ArrayBuffer,118 { name: "ECDSA", namedCurve: "P-256" },119 false,120 ["verify"],121 );122 const valid = body.id === "chal-1" && await crypto.subtle.verify(123 { name: "ECDSA", hash: "SHA-256" },124 publicKey,125 fromBase64Url(body.sig).buffer as ArrayBuffer,126 popMessage("app-x", "ticket-1", "nonce-1").buffer as ArrayBuffer,127 );128 return valid129 ? Response.json({ v: 1, connect: "tok-abc", expiresIn: 86400 })130 : Response.json({ error: "invalid_ticket" }, { status: 401 });131 }132 return new Response("not found", { status: 404 });133 }) as typeof fetch;134135 const connectUrl = await completePopExchange({136 serverUrl: "http://node/t/secret",137 appId: "app-x",138 ticketId: "ticket-1",139 keyPair,140 fetcher: respondingFetcher,141 });142 assert(connectUrl === "http://node/t/secret/c/tok-abc", `unexpected url: ${connectUrl}`);143 assert(requests.length === 2, "the exchange is exactly two requests");144145 // A node without the exchange (404) degrades to null, as does a refusal.146 const failingFetcher =147 (() => Promise.resolve(new Response("not found", { status: 404 }))) as typeof fetch;148 assert(149 await completePopExchange({150 serverUrl: "http://node/t/secret",151 appId: "app-x",152 ticketId: "ticket-1",153 keyPair,154 fetcher: failingFetcher,155 }) === null,156 "a missing exchange must degrade to null",157 );158});