@crawlcheck/sdk 1.1.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -196,8 +196,19 @@ export interface PreflightOptions {
196
196
  agent?: string;
197
197
  template?: string;
198
198
  policy?: PreflightPolicy; /** 8-128 characters of A-Z a-z 0-9 . _ : - signed into the receipt (replay binding). guard() sends a fresh one when you give none. */
199
- nonce?: string;
200
- }
199
+ nonce?: string; /** The exact operation this decision is for (method + url, or an MCP tool); signed into the receipt. */
200
+ operation?: Operation;
201
+ }
202
+ /** What a receipt can be scoped to. Send only the keys that apply; CrawlCheck signs them back exactly as sent. https://crawlcheck.io/spec/guard#operation */
203
+ export interface Operation {
204
+ method?: "GET" | "HEAD" | "POST" | "PUT" | "PATCH" | "DELETE" | "OPTIONS";
205
+ url?: string;
206
+ tool?: string;
207
+ tool_schema_sha256?: string;
208
+ scopes?: string[];
209
+ }
210
+ /** The operation for calling an MCP tool: its name, the sha256 of its definition (as in the lockfile) and the scopes you will use. */
211
+ export declare function toolOperation(tool: McpTool, scopes?: string[]): Promise<Operation>;
201
212
  /** A signed crawlcheck-decision receipt. Verify it with verifyDocument (or let guard() do it). */
202
213
  export interface DecisionReceipt {
203
214
  kind: "crawlcheck-decision";
@@ -237,6 +248,7 @@ export interface DecisionWant {
237
248
  action: Action | string;
238
249
  nonce?: string | null;
239
250
  now?: number;
251
+ operation?: Operation | null;
240
252
  }
241
253
  /**
242
254
  * Is this receipt about the operation you are about to perform? Checks the scope only (kind, domain, action, nonce,
package/dist/index.js CHANGED
@@ -2,12 +2,14 @@ import * as V from "./verify.js";
2
2
  import * as L from "./log.js";
3
3
  import * as S from "./staple.js";
4
4
  import * as M from "./mcp.js";
5
- function withTrust(v, kid, published) {
5
+ function withTrust(v, kid, published, related = []) {
6
6
  let checks = v.checks;
7
7
  let issuer = null;
8
8
  if (published) {
9
- issuer = !!kid && published.includes(kid);
10
- const kc = { id: "key_published", ok: issuer, why: issuer ? "key id " + kid + " is in the published key directory" : "key id " + (kid || "?") + " is NOT in the published key directory - reject this document" };
9
+ // kp3: keys are separated by purpose, so a decision's resolve answer or an answer's log head can carry another key; every one must be published
10
+ const miss = related.filter((k) => k !== kid && !published.includes(k));
11
+ issuer = !!kid && published.includes(kid) && miss.length === 0;
12
+ const kc = { id: "key_published", ok: issuer, why: issuer ? "key id " + kid + " is in the published key directory" + (related.length ? ", and so is every key it leans on" : "") : miss.length && kid && published.includes(kid) ? "the document leans on key " + miss.join(", ") + ", which is NOT published - reject this document" : "key id " + (kid || "?") + " is NOT in the published key directory - reject this document" };
11
13
  checks = checks.some((c) => c.id === "key_published") ? checks.map((c) => c.id === "key_published" ? kc : c) : checks.concat([kc]);
12
14
  }
13
15
  const ran = checks.filter((c) => c.ok !== null), failed = checks.filter((c) => c.ok === false);
@@ -65,6 +67,9 @@ export async function checkLock(lock, tools) {
65
67
  const same = lock?.tools_sha256 === now.tools_sha256 && !added.length && !removed.length && !modified.length;
66
68
  return { verdict: same ? "unchanged" : "changed", decision: same ? "connect" : "block", approved_tools_sha256: lock?.tools_sha256 ?? "", current_tools_sha256: now.tools_sha256, added, removed, modified };
67
69
  }
70
+ /** The operation for calling an MCP tool: its name, the sha256 of its definition (as in the lockfile) and the scopes you will use. */
71
+ export async function toolOperation(tool, scopes) { const o = { tool: String(tool?.name ?? ""), tool_schema_sha256: await lockToolHash(tool) }; if (scopes && scopes.length)
72
+ o.scopes = scopes.slice(); return o; }
68
73
  /** The longest a receipt may stay valid, per action (seconds). A receipt that claims longer is refused. Published at https://crawlcheck.io/spec/guard */
69
74
  export const ACTION_MAX_LIFETIME_S = { read: 86400, cite: 86400, connect: 3600, transact: 300, administer: 300 };
70
75
  /** The most clock skew a receipt may claim (seconds). */
@@ -86,6 +91,8 @@ export function checkDecision(receipt, want) {
86
91
  why.push("the receipt is for " + r.domain + ", not " + want.domain);
87
92
  if (String(r.action) !== String(want.action))
88
93
  why.push("the receipt is for action " + r.action + ", not " + want.action);
94
+ if (want.operation && canon(r.operation ?? null) !== canon(want.operation))
95
+ why.push(r.operation ? "the receipt is for another operation (" + canon(r.operation).slice(0, 120) + ")" : "the receipt is not scoped to an operation, and this request named one");
89
96
  if (want.nonce && r.nonce !== want.nonce)
90
97
  why.push(r.nonce ? "the receipt carries another request's nonce (replayed)" : "the receipt does not carry the nonce this request sent");
91
98
  const now = want.now ?? Date.now(), skew = Math.min(Math.max(Number(r.clock_skew_s) || 0, 0), MAX_CLOCK_SKEW_S) * 1000;
@@ -246,11 +253,13 @@ export class CrawlCheck {
246
253
  b.policy = opts.policy;
247
254
  if (opts.nonce)
248
255
  b.nonce = opts.nonce;
256
+ if (opts.operation)
257
+ b.operation = opts.operation;
249
258
  return this.post("/api/v1/preflight", b);
250
259
  }
251
260
  /** Verify a signed document here. With onlineKeys (default), its signing key must also be in the published directory. */
252
261
  async verifyDocument(doc, onlineKeys = true) {
253
- return withTrust(await V.verifyDataDoc(doc), doc?.signature?.kid, onlineKeys ? await this.publishedKeyIds() : null);
262
+ return withTrust(await V.verifyDataDoc(doc), doc?.signature?.kid, onlineKeys ? await this.publishedKeyIds() : null, V.relatedKids(doc));
254
263
  }
255
264
  /**
256
265
  * Preflight, verify the receipt, check it is for THIS operation, and decide. Proceeds on allow; warn only with allowWarn;
@@ -270,7 +279,7 @@ export class CrawlCheck {
270
279
  }
271
280
  if (this.log.forks.length)
272
281
  return Object.assign(no(["the transparency log has shown this client two histories (split view)"], receipt), { split_view: this.log.forks.slice() });
273
- const scope = checkDecision(receipt, { domain, action, nonce, now: opts.now });
282
+ const scope = checkDecision(receipt, { domain, action, nonce, now: opts.now, operation: opts.operation });
274
283
  if (!receipt || typeof receipt !== "object")
275
284
  return no(scope.why);
276
285
  const online = opts.onlineKeys ?? true;
@@ -278,7 +287,7 @@ export class CrawlCheck {
278
287
  return no(["onlineKeys is false and no publishedKids were pinned: the signer cannot be checked"], receipt);
279
288
  let verification;
280
289
  try {
281
- verification = withTrust(await V.verifyDataDoc(receipt), receipt?.signature?.kid, online ? await this.publishedKeyIds() : opts.publishedKids);
290
+ verification = withTrust(await V.verifyDataDoc(receipt), receipt?.signature?.kid, online ? await this.publishedKeyIds() : opts.publishedKids, V.relatedKids(receipt));
282
291
  }
283
292
  catch (e) {
284
293
  return no(["the receipt could not be verified: " + (e?.message || e)].concat(scope.why), receipt);
@@ -330,10 +339,11 @@ export class CrawlCheck {
330
339
  const dom = normalizeDomain(u.hostname);
331
340
  if (!dom)
332
341
  return { proceed: false, response: null, url: cur, hops, why: ["not a public domain name: " + u.hostname] };
333
- let g = seen.get(dom);
342
+ const op = { method: method, url: cur }; // op1: each hop's receipt names that exact request
343
+ let g = seen.get(method + " " + cur);
334
344
  if (!g) {
335
- g = await this.guard(dom, action, opts);
336
- seen.set(dom, g);
345
+ g = await this.guard(dom, action, Object.assign({}, opts, { operation: op }));
346
+ seen.set(method + " " + cur, g);
337
347
  }
338
348
  const hop = { url: cur, domain: dom, proceed: g.proceed, decision: g.receipt ? String(g.receipt.decision) : null, why: g.why };
339
349
  hops.push(hop);
package/dist/verify.d.ts CHANGED
@@ -19,7 +19,8 @@ export function verifyThresholdSigners(x: any): Promise<{
19
19
  values_match: boolean;
20
20
  why: never[];
21
21
  }[]>;
22
- export function trustOf(res: any, kid: any, publishedKids: any): any;
22
+ export function relatedKids(x: any): any[];
23
+ export function trustOf(res: any, kid: any, publishedKids: any, related: any): any;
23
24
  export function publishedKeyIds(base: any): Promise<string[]>;
24
25
  export function verifyBundle(b: any, opts: any): Promise<any>;
25
26
  export function verifyReceipt(rc: any, opts: any): Promise<any>;
package/dist/verify.js CHANGED
@@ -654,7 +654,7 @@ async function verifyDataDoc0(x) {
654
654
  }
655
655
  if (x.kind === "crawlcheck-decision") {
656
656
  const r = x.resolve || {};
657
- C("resolve_bound", /^[0-9a-f]{64}$/.test(String(r.sha256 || "")) && r.kid === x.signature.kid, /^[0-9a-f]{64}$/.test(String(r.sha256 || "")) ? "the decision names the resolve answer it was made from (" + String(r.sha256).slice(0, 16) + "…), signed by the same key" : "the decision does not name a resolve answer digest");
657
+ C("resolve_bound", /^[0-9a-f]{64}$/.test(String(r.sha256 || "")) && typeof r.kid === "string" && r.kid.length > 0, /^[0-9a-f]{64}$/.test(String(r.sha256 || "")) ? "the decision names the resolve answer it was made from (" + String(r.sha256).slice(0, 16) + "…), signed by key " + String(r.kid).slice(0, 12) + "… (online, it must be a published key)" : "the decision does not name a resolve answer digest");
658
658
  C("decision_known", ["allow", "warn", "require_confirmation", "block", "unsupported"].indexOf(x.decision) >= 0, "decision is " + x.decision);
659
659
  }
660
660
  if (x.kind === "crawlcheck-snapshot") {
@@ -678,7 +678,7 @@ async function verifyDataDoc0(x) {
678
678
  catch (e) {
679
679
  sv = null;
680
680
  }
681
- const same = !x.signature || !t.sth.signature || t.sth.signature.kid === x.signature.kid;
681
+ const same = true; // kp3: the log head may be signed by the log key; online, its key must be published (trustOf)
682
682
  C("log_head_signature", !!(sv && sv.verified) && t.sth.kind === "crawlcheck-log-sth" && t.sth.batch === t.batch && same, sv && sv.verified ? "batch " + t.batch + "'s signed tree head verifies (tree size " + t.sth.tree_size + ", chained to " + String(t.sth.prev_sth_sha256 || "genesis").slice(0, 16) + "…)" : "the signed tree head does NOT verify");
683
683
  }
684
684
  else
@@ -728,13 +728,41 @@ async function verifyDataDoc0(x) {
728
728
  // Run as a script under ANY file name (a browser saves a second download as "crawlcheck-verify (1).mjs"); imported, it
729
729
  // stays a library. The old test matched the file name only, so a renamed copy exited 0 having checked nothing.
730
730
  // vt1: integrity, issuer trust and completeness, reported apart. Same semantics as @crawlcheck/sdk and the Python package.
731
- export function trustOf(res, kid, publishedKids) {
731
+ // kp3 (2026-10-10): keys a document leans on besides its own signer (the resolve answer a decision names, the log head
732
+ // an answer carries). Since keys are separated by purpose these can differ; online, every one must be published.
733
+ export function relatedKids(x) {
734
+ const out = [];
735
+ try {
736
+ if (x && x.kind === "crawlcheck-decision" && x.resolve && x.resolve.kid)
737
+ out.push(x.resolve.kid);
738
+ const t = x && x.transparency;
739
+ if (t && t.sth && t.sth.signature && t.sth.signature.kid)
740
+ out.push(t.sth.signature.kid);
741
+ if (x && x.kind === "crawlcheck-resolve-as-of") {
742
+ if (x.answer && x.answer.signature && x.answer.signature.kid)
743
+ out.push(x.answer.signature.kid);
744
+ const p = x.log_inclusion;
745
+ if (p && p.sth && p.sth.signature && p.sth.signature.kid)
746
+ out.push(p.sth.signature.kid);
747
+ }
748
+ }
749
+ catch (e) { }
750
+ return out.filter((k, i) => typeof k === "string" && out.indexOf(k) === i);
751
+ }
752
+ export function trustOf(res, kid, publishedKids, related) {
732
753
  let checks = Array.isArray(res && res.checks) ? res.checks : [];
733
754
  let issuer = null;
734
755
  if (Array.isArray(publishedKids)) {
735
- issuer = !!kid && publishedKids.indexOf(kid) > -1;
736
- const kc = { id: "key_published", ok: issuer, why: issuer ? "key id " + kid + " is in the published key directory" : !kid ? "this document carries no signature, so its issuer cannot be authenticated" : "key id " + kid + " is NOT in the published key directory: reject this document" };
737
- checks = checks.some((c) => c.id === "key_published") ? checks.map((c) => c.id === "key_published" ? kc : c) : checks.concat([kc]);
756
+ const __rel = (related || []).filter((k) => k !== kid), __miss = __rel.filter((k) => publishedKids.indexOf(k) < 0);
757
+ issuer = !!kid && publishedKids.indexOf(kid) > -1 && __miss.length === 0;
758
+ if (!!kid && publishedKids.indexOf(kid) > -1 && __miss.length) {
759
+ const kc0 = { id: "key_published", ok: false, why: "the document is signed by a published key, but it leans on key " + __miss.join(", ") + ", which is NOT published: reject this document" };
760
+ checks = checks.some((c) => c.id === "key_published") ? checks.map((c) => c.id === "key_published" ? kc0 : c) : checks.concat([kc0]);
761
+ }
762
+ else {
763
+ const kc = { id: "key_published", ok: issuer, why: issuer ? "key id " + kid + " is in the published key directory" : !kid ? "this document carries no signature, so its issuer cannot be authenticated" : "key id " + kid + " is NOT in the published key directory: reject this document" };
764
+ checks = checks.some((c) => c.id === "key_published") ? checks.map((c) => c.id === "key_published" ? kc : c) : checks.concat([kc]);
765
+ }
738
766
  }
739
767
  const ran = checks.filter((c) => c.ok !== null), failed = checks.filter((c) => c.ok === false);
740
768
  const unavailable = checks.filter((c) => c.ok === null && c.id !== "key_published").map((c) => c.id);
@@ -795,7 +823,7 @@ async function verifyDnsTxt0(txt) {
795
823
  return { record: f, decisions: { read: f.read || null, cite: f.cite || null, connect: f.connect || null, transact: f.transact || null }, checks };
796
824
  }
797
825
  export async function verifyDnsTxt(txt, opts) { const r = await verifyDnsTxt0(txt); return trustOf(r, r.record && r.record.k, opts && opts.publishedKids); }
798
- export async function verifyDataDoc(x, opts) { return trustOf(await verifyDataDoc0(x), kidOf(x), opts && opts.publishedKids); }
826
+ export async function verifyDataDoc(x, opts) { return trustOf(await verifyDataDoc0(x), kidOf(x), opts && opts.publishedKids, relatedKids(x)); }
799
827
  const isMain = typeof process !== "undefined" && !!(process.argv && process.argv[1]) && await (async () => { try {
800
828
  const { pathToFileURL } = await import("node:url");
801
829
  const { realpathSync } = await import("node:fs");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crawlcheck/sdk",
3
- "version": "1.1.0",
3
+ "version": "1.3.0",
4
4
  "description": "Typed client for the CrawlCheck API (generated from its OpenAPI document) and the zero-dependency offline verifier for evidence bundles and remediation receipts.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",