diff --git a/CHANGELOG.md b/CHANGELOG.md index 6904cc3..7e90592 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## Unreleased + +- Verify directory-signed dispatch tickets in the browser consumer before exposing route material. +- Fail closed on invalid or missing ticket evidence without silently downgrading to legacy discovery. +- Consume the canonical dispatch-ticket fixture used by the three server SDKs. +- Advance the browser provider compatibility declaration to SDK contract `0.7.102`. + ## 0.2.3 — 2026-08-02 - Separate the browser implementation/package version from its IICP SDK compatibility version. diff --git a/parity/dispatch-route-ticket-v1.json b/parity/dispatch-route-ticket-v1.json new file mode 100644 index 0000000..6464e6d --- /dev/null +++ b/parity/dispatch-route-ticket-v1.json @@ -0,0 +1,139 @@ +{ + "fixture_version": "1.0.0-draft", + "status": "pre-normative", + "purpose": "Cross-implementation validation vectors for disclosure-only IICP dispatch route tickets.", + "domain": "iicp:dispatch-route-ticket:v1\n", + "public_key_hex": "557a59f8820da4ad8a684fdc8a00355d30149b5ec22231ae331a62daa8dcf869", + "valid": { + "token": "eyJ2IjoxLCJ0eXAiOiJkaXNwYXRjaC1yb3V0ZS10aWNrZXQiLCJpc3MiOiJodHRwczovL2RpcmVjdG9yeS5leGFtcGxlLnRlc3QiLCJhdWQiOiJpaWNwLmRpcmVjdG9yeS5kaXNwYXRjaCIsImp0aSI6IjAxMjM0NTY3ODlhYmNkZWYwMTIzNDU2NyIsIm5vZGVfaWQiOiJub2RlLTAwMDAwMDAxIiwiaW50ZW50IjoidXJuOmlpY3A6aW50ZW50OmxsbTpjaGF0OnYxIiwiaWF0IjoxNzAwMDAwMDAwLCJleHAiOjQxMDI0NDQ4MDB9.86cf4831d15b1b2a73c7dd8cd2b4692a6f12a742ba4fd1b0ac30e6c60cfecf17bf8b9a0ac340677951649bd3403496883e9123b63d3313b8ad3438a8e3cf6a0d", + "claims": { + "v": 1, + "typ": "dispatch-route-ticket", + "iss": "https://directory.example.test", + "aud": "iicp.directory.dispatch", + "jti": "0123456789abcdef01234567", + "node_id": "node-00000001", + "intent": "urn:iicp:intent:llm:chat:v1", + "iat": 1700000000, + "exp": 4102444800 + } + }, + "invalid_cases": [ + "malformed_token", + "bad_signature", + "expired", + "wrong_issuer", + "wrong_audience", + "wrong_node", + "wrong_intent" + ], + "security": { + "control_plane_only": true, + "task_payload_fields_forbidden": [ + "prompt", + "messages", + "payload", + "input", + "chat", + "content", + "response" + ], + "ticket_semantics": "route_disclosure_only", + "single_use_enforcement": "not_provided_in_v1", + "safe_receipt_fields": [ + "ticket_id_prefix", + "node_id_prefix", + "intent", + "policy_decision", + "expiry" + ] + }, + "validation_vectors": [ + { + "name": "valid", + "token": "valid", + "issuer": "https://directory.example.test", + "node_id": "node-00000001", + "intent": "urn:iicp:intent:llm:chat:v1", + "now_s": 1800000000, + "expected": "valid" + }, + { + "name": "malformed_token", + "token": "not-a-ticket", + "issuer": "https://directory.example.test", + "node_id": "node-00000001", + "intent": "urn:iicp:intent:llm:chat:v1", + "now_s": 1800000000, + "expected": "rejected" + }, + { + "name": "bad_signature", + "token": "valid+0", + "issuer": "https://directory.example.test", + "node_id": "node-00000001", + "intent": "urn:iicp:intent:llm:chat:v1", + "now_s": 1800000000, + "expected": "rejected" + }, + { + "name": "expired", + "token": "valid", + "issuer": "https://directory.example.test", + "node_id": "node-00000001", + "intent": "urn:iicp:intent:llm:chat:v1", + "now_s": 4102444800, + "expected": "rejected" + }, + { + "name": "wrong_issuer", + "token": "valid", + "issuer": "https://other-directory.example.test", + "node_id": "node-00000001", + "intent": "urn:iicp:intent:llm:chat:v1", + "now_s": 1800000000, + "expected": "rejected" + }, + { + "name": "wrong_audience", + "token": "wrong_audience", + "issuer": "https://directory.example.test", + "node_id": "node-00000001", + "intent": "urn:iicp:intent:llm:chat:v1", + "now_s": 1800000000, + "expected": "rejected" + }, + { + "name": "wrong_node", + "token": "valid", + "issuer": "https://directory.example.test", + "node_id": "node-other", + "intent": "urn:iicp:intent:llm:chat:v1", + "now_s": 1800000000, + "expected": "rejected" + }, + { + "name": "wrong_intent", + "token": "valid", + "issuer": "https://directory.example.test", + "node_id": "node-00000001", + "intent": "urn:iicp:intent:llm:embedding:v1", + "now_s": 1800000000, + "expected": "rejected" + } + ], + "wrong_audience": { + "token": "eyJ2IjoxLCJ0eXAiOiJkaXNwYXRjaC1yb3V0ZS10aWNrZXQiLCJpc3MiOiJodHRwczovL2RpcmVjdG9yeS5leGFtcGxlLnRlc3QiLCJhdWQiOiJpaWNwLmRpcmVjdG9yeS53cm9uZy1hdWRpZW5jZSIsImp0aSI6IjAxMjM0NTY3ODlhYmNkZWYwMTIzNDU2NyIsIm5vZGVfaWQiOiJub2RlLTAwMDAwMDAxIiwiaW50ZW50IjoidXJuOmlpY3A6aW50ZW50OmxsbTpjaGF0OnYxIiwiaWF0IjoxNzAwMDAwMDAwLCJleHAiOjQxMDI0NDQ4MDB9.b085edb655d4c9cc81dd29cbc07082da43fd6cf9e79adc68aa674bd4e166c1e54da59b96b44b34ce7634e6130d784eea48c77bb19e1a6edb537b9e5e99fe6b0f", + "claims": { + "v": 1, + "typ": "dispatch-route-ticket", + "iss": "https://directory.example.test", + "aud": "iicp.directory.wrong-audience", + "jti": "0123456789abcdef01234567", + "node_id": "node-00000001", + "intent": "urn:iicp:intent:llm:chat:v1", + "iat": 1700000000, + "exp": 4102444800 + } + } +} diff --git a/src/dispatchTicket.ts b/src/dispatchTicket.ts new file mode 100644 index 0000000..e2a5643 --- /dev/null +++ b/src/dispatchTicket.ts @@ -0,0 +1,6 @@ +import { ed25519 } from "@noble/curves/ed25519.js"; +import { hexToBytes } from "@noble/hashes/utils.js"; +const DOMAIN = "iicp:dispatch-route-ticket:v1\n"; +export interface DispatchTicketClaims { v:number; typ:string; iss:string; aud:string; jti:string; node_id:string; intent:string; iat:number; exp:number; } +function b64(value:string): Uint8Array { const text=value.replace(/-/g,"+").replace(/_/g,"/")+"=".repeat((4-value.length%4)%4); const raw=atob(text); return Uint8Array.from(raw,c=>c.charCodeAt(0)); } +export function verifyDispatchTicket(token:string,keyHex:string,issuer:string,nodeId:string,intent:string,nowSec=Math.floor(Date.now()/1000)): DispatchTicketClaims | null { const [payload,sig,...extra]=token.split("."); if(!payload||!sig||extra.length||sig.length!==128)return null; try { if(!ed25519.verify(hexToBytes(sig),new TextEncoder().encode(DOMAIN+payload),hexToBytes(keyHex)))return null; const c=JSON.parse(new TextDecoder().decode(b64(payload))) as DispatchTicketClaims; return c.v===1&&c.typ==="dispatch-route-ticket"&&c.iss===issuer&&c.aud==="iicp.directory.dispatch"&&c.node_id===nodeId&&c.intent===intent&&c.exp>nowSec&&/^[0-9a-f]{24}$/.test(c.jti)?c:null; } catch{return null;} } diff --git a/src/iicpConsumer.ts b/src/iicpConsumer.ts index a8a106b..fcbdc62 100644 --- a/src/iicpConsumer.ts +++ b/src/iicpConsumer.ts @@ -18,6 +18,7 @@ // Epic: #446 · Dev: #447 · Research: research/wasm/WASM-1-feasibility.md (#292). import { encryptPayload, type CxPublicKey } from "./cxConfidentiality.js"; +import { verifyDispatchTicket } from "./dispatchTicket.js"; const REFUSED_INTENT_RULES = [ { category: "prohibited", rule_id: "eu-ai-act-social-scoring", label: "social scoring", fragments: ["social-scoring", "social_scoring", "social:scoring"] }, @@ -454,6 +455,7 @@ export class IicpBrowserClient { private readonly allowedRegions: string[]; private readonly requiredManifestIdentityLevel?: RequiredManifestIdentityLevel; private readonly routeDiscoveryMode: "auto" | "ticketed" | "legacy"; + private dispatchTicketKey?: string; constructor(cfg: ClientConfig = {}) { this.directory = (cfg.directory_url ?? DEFAULT_DIRECTORY_URL).replace(/\/+$/, ""); @@ -518,6 +520,23 @@ export class IicpBrowserClient { if (!route || typeof body.node_id !== "string") { throw new IicpError("ticketed route response is malformed", "ticket_malformed"); } + if (!this.dispatchTicketKey) { + const keyResponse = await fetch(`${this.directory}/api/v1/directory-key`, { + headers: { Accept: "application/json" }, + signal: AbortSignal.timeout(this.timeout), + }); + const keyBody = await keyResponse.json().catch(() => ({})) as Record; + if (keyResponse.status === 200 && typeof keyBody.public_key === "string") { + this.dispatchTicketKey = keyBody.public_key; + } + } + const issuer = this.directory.replace(/\/api$/, ""); + const claims = typeof body.ticket === "string" && this.dispatchTicketKey + ? verifyDispatchTicket(body.ticket, this.dispatchTicketKey, issuer, body.node_id, intent) + : null; + if (!claims) { + throw new IicpError("directory returned an unverifiable dispatch ticket", "ticket_unverified"); + } const node = { ...route, node_id: body.node_id, diff --git a/src/index.ts b/src/index.ts index 4534166..f726ac6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,6 +6,7 @@ // the WebLLM runtime helpers. Discovery + consume work from any https:// page; serving // needs a relay (see README). Parity with the Python/TS/Rust SDKs on the wire. export * from "./iicpConsumer.js"; +export * from "./dispatchTicket.js"; export * from "./cxConfidentiality.js"; export * from "./browserNodeProvider.js"; export * from "./webllmRuntime.js"; diff --git a/src/version.ts b/src/version.ts index 19711a6..841b51d 100644 --- a/src/version.ts +++ b/src/version.ts @@ -2,7 +2,7 @@ // Keep package.json synchronized with BROWSER_NODE_VERSION; the quality gate // rejects drift. SDK compatibility describes the registration contract only. export const BROWSER_NODE_VERSION = "0.2.3"; -export const BROWSER_NODE_SDK_COMPATIBILITY_VERSION = "0.7.101"; +export const BROWSER_NODE_SDK_COMPATIBILITY_VERSION = "0.7.102"; /** Backward-compatible registration value for directories that know only sdk_version. */ export const BROWSER_NODE_SDK_VERSION = BROWSER_NODE_SDK_COMPATIBILITY_VERSION; diff --git a/tests/client.test.ts b/tests/client.test.ts index 70e5c39..c4e534b 100644 --- a/tests/client.test.ts +++ b/tests/client.test.ts @@ -3,6 +3,7 @@ // fail-closed IICP-CX, and CIP envelope KATs. import { test } from "node:test"; import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; import { discoverUrl, cipConsumerEnvelope, @@ -73,22 +74,54 @@ test("client.discover can opt out of browser-usable filtering", async () => { }); }); -test("client.discover prefers ticketed dispatch and records the ticket prefix", async () => { +test("client.discover verifies ticketed dispatch and records only the ticket prefix", async () => { + const fixture = JSON.parse(readFileSync(new URL("../parity/dispatch-route-ticket-v1.json", import.meta.url), "utf8")); let sent: Record | null = null; - await withFetch(async (_input, init) => { + await withFetch(async (input, init) => { + const url = input.toString(); + if (url.endsWith("/v1/directory-key")) { + return new Response(JSON.stringify({ public_key: fixture.public_key_hex }), { status: 200 }); + } sent = JSON.parse(String(init?.body ?? "{}")); return new Response(JSON.stringify({ - node_id: "ticketed-node", + ticket: fixture.valid.token, + node_id: fixture.valid.claims.node_id, ticket_id_prefix: "ticket12", route: { endpoint: "https://relay.example/v1/relay-for/ticketed-node", browser_usable: true }, }), { status: 201 }); }, async () => { - const c = new IicpBrowserClient({ directory_url: "https://directory.test", route_discovery_mode: "ticketed" }); + const c = new IicpBrowserClient({ directory_url: fixture.valid.claims.iss, route_discovery_mode: "ticketed" }); const nodes = await c.discover("urn:iicp:intent:llm:chat:v1", { limit: 1 }); assert.equal(sent?.intent, "urn:iicp:intent:llm:chat:v1"); - assert.equal(nodes[0]?.node_id, "ticketed-node"); + assert.equal(nodes[0]?.node_id, fixture.valid.claims.node_id); assert.equal(nodes[0]?.dispatch_ticket_id_prefix, "ticket12"); + assert.equal(JSON.stringify(nodes).includes(fixture.valid.token), false); + }); +}); + +test("client.discover refuses an unverifiable ticket without legacy downgrade", async () => { + const fixture = JSON.parse(readFileSync(new URL("../parity/dispatch-route-ticket-v1.json", import.meta.url), "utf8")); + let legacyCalled = false; + await withFetch(async (input) => { + const url = input.toString(); + if (url.includes("/v1/discover")) legacyCalled = true; + if (url.endsWith("/v1/directory-key")) { + return new Response(JSON.stringify({ public_key: fixture.public_key_hex }), { status: 200 }); + } + return new Response(JSON.stringify({ + ticket: fixture.valid.token, + // The valid signature is deliberately paired with the wrong selected node. + node_id: "node-other", + route: { endpoint: "https://relay.example/v1/relay-for/ticketed-node", browser_usable: true }, + }), { status: 201 }); + }, async () => { + const c = new IicpBrowserClient({ directory_url: fixture.valid.claims.iss, route_discovery_mode: "auto" }); + await assert.rejects( + () => c.discover("urn:iicp:intent:llm:chat:v1", { limit: 1 }), + (error: unknown) => error instanceof IicpError && error.code === "ticket_unverified", + ); }); + assert.equal(legacyCalled, false); }); test("client refuses prohibited and declared high-risk intents before discovery", async () => { @@ -110,14 +143,21 @@ test("client refuses prohibited and declared high-risk intents before discovery" assert.equal(called, false); }); -test("strict region policy excludes a ticketed route outside the allowlist", async () => { - await withFetch(async () => new Response(JSON.stringify({ - node_id: "us-node", - ticket_id_prefix: "ticket34", - route: { endpoint: "https://us.example", browser_usable: true, region: "us-east" }, - }), { status: 201 }), async () => { +test("strict region policy excludes a verified ticketed route outside the allowlist", async () => { + const fixture = JSON.parse(readFileSync(new URL("../parity/dispatch-route-ticket-v1.json", import.meta.url), "utf8")); + await withFetch(async (input) => { + if (input.toString().endsWith("/v1/directory-key")) { + return new Response(JSON.stringify({ public_key: fixture.public_key_hex }), { status: 200 }); + } + return new Response(JSON.stringify({ + ticket: fixture.valid.token, + node_id: fixture.valid.claims.node_id, + ticket_id_prefix: "ticket34", + route: { endpoint: "https://us.example", browser_usable: true, region: "us-east" }, + }), { status: 201 }); + }, async () => { const c = new IicpBrowserClient({ - directory_url: "https://directory.test", + directory_url: fixture.valid.claims.iss, route_discovery_mode: "ticketed", allowed_regions: ["eu-central"], });