Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
139 changes: 139 additions & 0 deletions parity/dispatch-route-ticket-v1.json
Original file line number Diff line number Diff line change
@@ -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
}
}
}
6 changes: 6 additions & 0 deletions src/dispatchTicket.ts
Original file line number Diff line number Diff line change
@@ -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;} }
19 changes: 19 additions & 0 deletions src/iicpConsumer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"] },
Expand Down Expand Up @@ -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(/\/+$/, "");
Expand Down Expand Up @@ -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<string, unknown>;
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,
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
2 changes: 1 addition & 1 deletion src/version.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
64 changes: 52 additions & 12 deletions tests/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<string, unknown> | 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 () => {
Expand All @@ -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"],
});
Expand Down
Loading