@crawlcheck/sdk 1.0.6 → 1.0.7

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/README.md CHANGED
@@ -37,6 +37,19 @@ await cc.get("/api/explain", { domain: "example.com", code: "NO_LLMS_TXT" });
37
37
  await cc.get("/api/explain", { website: "x" }); // compile error: not a parameter of this operation
38
38
  ```
39
39
 
40
+ ## Use the site's staple
41
+
42
+ A site can serve its own signed verdict in the `CrawlCheck-Staple` header (and at `/.well-known/crawlcheck-staple.json`). `resolveFor` uses it when it verifies, with no request to CrawlCheck, and falls back to a signed lookup when it is missing, expired, for another domain or tampered:
43
+
44
+ ```js
45
+ const res = await fetch("https://shop.example.com/");
46
+ const v = await client.resolveFor("shop.example.com", { response: res });
47
+ v.source; // "staple" or "network"
48
+ v.decisions.transact; // allow | warn | require_confirmation | block | unsupported
49
+ ```
50
+
51
+ `stapleCheck(record, { host, publishedKids })` does the offline check on its own. Spec: https://crawlcheck.io/spec/staple
52
+
40
53
  ## Do not trust the API: check the evidence yourself
41
54
 
42
55
  ```js
package/dist/index.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { paths, components } from "./gen/openapi.js";
2
+ import * as S from "./staple.js";
2
3
  export type { paths, components };
3
4
  /** One verifier check. ok: true passed, false FAILED, null could not run (why says why) - never a pass. */
4
5
  export interface VerifyCheck {
@@ -328,6 +329,33 @@ export declare const verifyWitnessToken: (token: string, sthSha256: string, jwks
328
329
  claims?: JsonObject;
329
330
  }>;
330
331
  export declare const GITHUB_OIDC_JWKS = "https://token.actions.githubusercontent.com/.well-known/jwks";
332
+ export type StapleDecisions = {
333
+ read: string | null;
334
+ cite: string | null;
335
+ connect: string | null;
336
+ transact: string | null;
337
+ };
338
+ export interface StapleCheck {
339
+ valid: boolean;
340
+ reason: string | null;
341
+ expired: boolean;
342
+ record: Record<string, string> | null;
343
+ decisions: StapleDecisions | null;
344
+ }
345
+ /** Parse a ccr1 record (CrawlCheck-Staple header value or <domain>._v.crawlcheck.io TXT). */
346
+ export declare const parseStaple: (record: string) => {
347
+ fields: Record<string, string>;
348
+ body: string;
349
+ sig: string;
350
+ };
351
+ /** The CrawlCheck-Staple header of a response (fetch Response, Headers or a plain header map), or null. */
352
+ export declare const stapleFromResponse: (res: unknown) => string | null;
353
+ /** Verify a staple offline: published key, signature, the record names opts.host (or a parent), fresh_until in the future. Expired or foreign staples are never valid. */
354
+ export declare const stapleCheck: (record: string, opts: {
355
+ host: string;
356
+ publishedKids: string[];
357
+ now?: number;
358
+ }) => Promise<StapleCheck>;
331
359
  export interface ClientOptions {
332
360
  /** Licence key (cc_ + 32 hex). Without one, licence-gated fields come back withheld, exactly as on the website. */
333
361
  key?: string;
@@ -369,6 +397,23 @@ export declare class CrawlCheck {
369
397
  post<P extends keyof paths>(path: P, body: BodyOf<P> | JsonObject, query?: QueryOf<P, "post">): Promise<ResponseOf<P, "post">>;
370
398
  /** The signed ResolveV1 answer: crawl policy, delivery, machine files, capabilities, entity, findings, freshness and a decision per action. */
371
399
  resolve(domain: string): Promise<ResponseOf<"/api/v1/resolve", "get">>;
400
+ private kidCache;
401
+ /** The key ids CrawlCheck publishes (/.well-known/http-message-signatures-directory), cached for an hour. */
402
+ publishedKids(): Promise<string[]>;
403
+ /**
404
+ * The verdict for a domain, preferring a valid staple the site sent (opts.staple, or opts.response's CrawlCheck-Staple
405
+ * header): no request to CrawlCheck is made when the staple verifies. An expired, foreign or invalid staple is ignored
406
+ * and the signed answer is fetched instead; source says which was used and staple says why.
407
+ */
408
+ resolveFor(domain: string, opts?: {
409
+ staple?: string | null;
410
+ response?: unknown;
411
+ }): Promise<{
412
+ source: "staple" | "network";
413
+ decisions: StapleDecisions;
414
+ staple: StapleCheck | null;
415
+ answer?: unknown;
416
+ }>;
372
417
  /** Up to 100 domains in one call; each answer is a full signed ResolveV1 document. */
373
418
  resolveBatch(domains: string[]): Promise<any>;
374
419
  /** Split-view check: the current head, GitHub's witness co-signature on the last witnessed head, and the chain from it down to every head this client holds. Reports the heads it holds to /api/v1/log/gossip unless report is false. */
package/dist/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import * as V from "./verify.js";
2
2
  import * as L from "./log.js";
3
+ import * as S from "./staple.js";
3
4
  function withTrust(v, kid, published) {
4
5
  let checks = v.checks;
5
6
  let issuer = null;
@@ -76,6 +77,12 @@ export const logGossip = L.logGossip;
76
77
  /** Verify the outside witness's co-signature: a GitHub OIDC JWT (RS256) whose audience is crawlcheck-sth:<head sha256>. */
77
78
  export const verifyWitnessToken = L.verifyWitnessToken;
78
79
  export const GITHUB_OIDC_JWKS = "https://token.actions.githubusercontent.com/.well-known/jwks";
80
+ /** Parse a ccr1 record (CrawlCheck-Staple header value or <domain>._v.crawlcheck.io TXT). */
81
+ export const parseStaple = S.parseStaple;
82
+ /** The CrawlCheck-Staple header of a response (fetch Response, Headers or a plain header map), or null. */
83
+ export const stapleFromResponse = S.stapleFromResponse;
84
+ /** Verify a staple offline: published key, signature, the record names opts.host (or a parent), fresh_until in the future. Expired or foreign staples are never valid. */
85
+ export const stapleCheck = S.stapleCheck;
79
86
  export class CrawlCheckError extends Error {
80
87
  status;
81
88
  body;
@@ -123,6 +130,41 @@ export class CrawlCheck {
123
130
  await logObserveAnswer(this.log, a).catch(() => null); // a head that contradicts one this client already holds is recorded in this.log.forks
124
131
  return a;
125
132
  }
133
+ kidCache = null;
134
+ /** The key ids CrawlCheck publishes (/.well-known/http-message-signatures-directory), cached for an hour. */
135
+ async publishedKids() {
136
+ if (this.kidCache && Date.now() - this.kidCache.at < 3600e3)
137
+ return this.kidCache.kids;
138
+ const r = await this.f(this.base + "/.well-known/http-message-signatures-directory", { headers: { accept: "application/json" } });
139
+ const j = await r.json();
140
+ const kids = ((j && j.keys) || []).map((k) => k.kid).filter(Boolean);
141
+ this.kidCache = { at: Date.now(), kids };
142
+ return kids;
143
+ }
144
+ /**
145
+ * The verdict for a domain, preferring a valid staple the site sent (opts.staple, or opts.response's CrawlCheck-Staple
146
+ * header): no request to CrawlCheck is made when the staple verifies. An expired, foreign or invalid staple is ignored
147
+ * and the signed answer is fetched instead; source says which was used and staple says why.
148
+ */
149
+ async resolveFor(domain, opts = {}) {
150
+ const rec = opts.staple || (opts.response ? stapleFromResponse(opts.response) : null);
151
+ let chk = null;
152
+ if (rec) {
153
+ let kids = [];
154
+ try {
155
+ kids = await this.publishedKids();
156
+ }
157
+ catch {
158
+ kids = [];
159
+ }
160
+ chk = await stapleCheck(rec, { host: normalizeDomain(domain), publishedKids: kids });
161
+ if (chk.valid && chk.decisions)
162
+ return { source: "staple", decisions: chk.decisions, staple: chk };
163
+ }
164
+ const a = await this.resolve(domain);
165
+ const act = (k) => (a && a.actions && a.actions[k] && a.actions[k].decision) || null;
166
+ return { source: "network", decisions: { read: act("read"), cite: act("cite"), connect: act("connect"), transact: act("transact") }, staple: chk, answer: a };
167
+ }
126
168
  /** Up to 100 domains in one call; each answer is a full signed ResolveV1 document. */
127
169
  async resolveBatch(domains) {
128
170
  const r = await this.post("/api/v1/resolve", { domains });
@@ -0,0 +1,21 @@
1
+ /** Parse a ccr1 record ("v=ccr1;d=...;s=..."); quoted TXT strings from dig are joined. */
2
+ export function parseStaple(record: any): {
3
+ fields: {};
4
+ body: string;
5
+ sig: string;
6
+ };
7
+ /** The staple a response carries, if any (a fetch Response, a Headers object or a plain header map). */
8
+ export function stapleFromResponse(res: any): any;
9
+ /**
10
+ * Check a staple offline. opts.host: the host the agent is talking to (required for a usable result);
11
+ * opts.publishedKids: the key ids CrawlCheck publishes (from /.well-known/http-message-signatures-directory);
12
+ * opts.now: epoch ms (default Date.now()).
13
+ * Returns { valid, reason, expired, record, decisions } and never throws.
14
+ */
15
+ export function stapleCheck(record: any, opts?: {}): Promise<{
16
+ valid: boolean;
17
+ reason: null;
18
+ expired: boolean;
19
+ record: null;
20
+ decisions: null;
21
+ }>;
package/dist/staple.js ADDED
@@ -0,0 +1,88 @@
1
+ // Stapled Resolve and Resolve over DNS: the compact ccr1 record a site sends in the CrawlCheck-Staple header (or that
2
+ // <domain>._v.crawlcheck.io TXT carries). Verified offline: key id = RFC 7638 thumbprint of the public key, the key is
3
+ // one CrawlCheck publishes, the Ed25519 signature covers the record, the record names the host (or a parent domain),
4
+ // and fresh_until is in the future. An expired or foreign staple is never used.
5
+ const STAPLE_PREFIX = "crawlcheck-dns-v1\n";
6
+ const te = new TextEncoder();
7
+ function b64uToBytes(s) { s = String(s || "").replace(/-/g, "+").replace(/_/g, "/"); s += "===".slice((s.length + 3) % 4); const b = atob(s), u = new Uint8Array(b.length); for (let i = 0; i < b.length; i++)
8
+ u[i] = b.charCodeAt(i); return u; }
9
+ function bytesToB64u(u) { let s = ""; for (let i = 0; i < u.length; i++)
10
+ s += String.fromCharCode(u[i]); return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); }
11
+ /** Parse a ccr1 record ("v=ccr1;d=...;s=..."); quoted TXT strings from dig are joined. */
12
+ export function parseStaple(record) {
13
+ const t = String(record || "").trim().replace(/"\s*"/g, "").replace(/^"|"$/g, "");
14
+ const i = t.lastIndexOf(";s=");
15
+ const body = i > 0 ? t.slice(0, i) : "", sig = i > 0 ? t.slice(i + 3) : "";
16
+ const f = {};
17
+ body.split(";").forEach((p) => { const j = p.indexOf("="); if (j > 0)
18
+ f[p.slice(0, j)] = p.slice(j + 1); });
19
+ return { fields: f, body, sig };
20
+ }
21
+ /** The staple a response carries, if any (a fetch Response, a Headers object or a plain header map). */
22
+ export function stapleFromResponse(res) {
23
+ const h = res && (res.headers || res);
24
+ if (!h)
25
+ return null;
26
+ if (typeof h.get === "function")
27
+ return h.get("crawlcheck-staple");
28
+ for (const k of Object.keys(h))
29
+ if (k.toLowerCase() === "crawlcheck-staple")
30
+ return h[k];
31
+ return null;
32
+ }
33
+ /**
34
+ * Check a staple offline. opts.host: the host the agent is talking to (required for a usable result);
35
+ * opts.publishedKids: the key ids CrawlCheck publishes (from /.well-known/http-message-signatures-directory);
36
+ * opts.now: epoch ms (default Date.now()).
37
+ * Returns { valid, reason, expired, record, decisions } and never throws.
38
+ */
39
+ export async function stapleCheck(record, opts = {}) {
40
+ const out = { valid: false, reason: null, expired: false, record: null, decisions: null };
41
+ try {
42
+ const { fields: f, body, sig } = parseStaple(record);
43
+ out.record = f;
44
+ if (f.v !== "ccr1" || !f.d || !f.k || !f.x || !sig) {
45
+ out.reason = "not a ccr1 record";
46
+ return out;
47
+ }
48
+ out.decisions = { read: f.read || null, cite: f.cite || null, connect: f.connect || null, transact: f.transact || null };
49
+ const now = opts.now || Date.now(), fu = Date.parse(f.f || "");
50
+ if (!isFinite(fu) || fu <= now) {
51
+ out.expired = true;
52
+ out.reason = "expired at " + (f.f || "?") + ": ask CrawlCheck instead";
53
+ return out;
54
+ }
55
+ const host = String(opts.host || "").toLowerCase().replace(/^www\./, "").replace(/\.$/, "");
56
+ const d = String(f.d).toLowerCase();
57
+ if (!host) {
58
+ out.reason = "no host given: a staple is only valid for the host it was served by";
59
+ return out;
60
+ }
61
+ if (host !== d && !host.endsWith("." + d)) {
62
+ out.reason = "the staple names " + d + ", not " + host;
63
+ return out;
64
+ }
65
+ const thumb = bytesToB64u(new Uint8Array(await crypto.subtle.digest("SHA-256", te.encode(JSON.stringify({ crv: "Ed25519", kty: "OKP", x: f.x })))));
66
+ if (thumb !== f.k) {
67
+ out.reason = "key id does not match the public key";
68
+ return out;
69
+ }
70
+ if (!Array.isArray(opts.publishedKids) || opts.publishedKids.indexOf(f.k) < 0) {
71
+ out.reason = Array.isArray(opts.publishedKids) ? "key " + f.k + " is not one CrawlCheck publishes" : "no published key list given (opts.publishedKids)";
72
+ return out;
73
+ }
74
+ const pk = await crypto.subtle.importKey("jwk", { kty: "OKP", crv: "Ed25519", x: f.x }, { name: "Ed25519" }, false, ["verify"]);
75
+ const ok = await crypto.subtle.verify({ name: "Ed25519" }, pk, b64uToBytes(sig), te.encode(STAPLE_PREFIX + body));
76
+ if (!ok) {
77
+ out.reason = "signature does not verify";
78
+ return out;
79
+ }
80
+ out.valid = true;
81
+ out.reason = "valid until " + f.f;
82
+ return out;
83
+ }
84
+ catch (e) {
85
+ out.reason = "could not check: " + (e && e.message || e);
86
+ return out;
87
+ }
88
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crawlcheck/sdk",
3
- "version": "1.0.6",
3
+ "version": "1.0.7",
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",
@@ -0,0 +1,7 @@
1
+ {
2
+ "kid": "pH8ZDz3tgRtZvHTZdqEmYHGpPI72pmDrjFpMSrNt3VA",
3
+ "valid": "v=ccr1;d=example.com;st=measured;read=allow;cite=warn;connect=require_confirmation;transact=block;o=2026-10-07T00:00:00.000Z;f=2099-01-01T00:00:00.000Z;l=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa;k=pH8ZDz3tgRtZvHTZdqEmYHGpPI72pmDrjFpMSrNt3VA;x=ozx_4zIp-D-K_X4h1VRwWAMfOnzsBchF2lImNSJPMSY;s=7UNCtVpvzFOPfXFrWh1TzhjtWty_DE9CcKEQmFTRdY83b2fKPMNYLfn0aHV4-5ja8Pm0OfgkptK8wzoHSpa_Cw",
4
+ "expired": "v=ccr1;d=example.com;st=measured;read=allow;cite=warn;connect=require_confirmation;transact=block;o=2026-10-07T00:00:00.000Z;f=2020-01-01T00:00:00.000Z;l=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa;k=pH8ZDz3tgRtZvHTZdqEmYHGpPI72pmDrjFpMSrNt3VA;x=ozx_4zIp-D-K_X4h1VRwWAMfOnzsBchF2lImNSJPMSY;s=cZTwNwGE2j88S1Q7em7FRQrCv66v0mATGMd1zXt55fcDX77MEmB1IJzCG88HkPtLQvy1cGv_zHg4D974Yx40DQ",
5
+ "foreign": "v=ccr1;d=other.example;st=measured;read=allow;cite=warn;connect=require_confirmation;transact=block;o=2026-10-07T00:00:00.000Z;f=2099-01-01T00:00:00.000Z;l=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa;k=pH8ZDz3tgRtZvHTZdqEmYHGpPI72pmDrjFpMSrNt3VA;x=ozx_4zIp-D-K_X4h1VRwWAMfOnzsBchF2lImNSJPMSY;s=rUaiUZvO9JpJ4SJbTV_37-e5NbLhhcV0rQ7XrsQlSkjy8vzdETGIWNiohxq0BzmQ0U7vI8NeC3Gh48BBEFOSAw",
6
+ "tampered": "v=ccr1;d=example.com;st=measured;read=allow;cite=warn;connect=require_confirmation;transact=allow;o=2026-10-07T00:00:00.000Z;f=2099-01-01T00:00:00.000Z;l=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa;k=pH8ZDz3tgRtZvHTZdqEmYHGpPI72pmDrjFpMSrNt3VA;x=ozx_4zIp-D-K_X4h1VRwWAMfOnzsBchF2lImNSJPMSY;s=7UNCtVpvzFOPfXFrWh1TzhjtWty_DE9CcKEQmFTRdY83b2fKPMNYLfn0aHV4-5ja8Pm0OfgkptK8wzoHSpa_Cw"
7
+ }
@@ -0,0 +1,14 @@
1
+ import test from "node:test"; import assert from "node:assert/strict"; import fs from "node:fs";
2
+ import { stapleCheck, stapleFromResponse, CrawlCheck } from "../dist/index.js";
3
+ const F = JSON.parse(fs.readFileSync(new URL("./fixtures/staple.json", import.meta.url), "utf8"));
4
+ const kids = [F.kid];
5
+ test("valid staple verifies offline", async () => { const c = await stapleCheck(F.valid, { host: "example.com", publishedKids: kids }); assert.equal(c.valid, true); assert.equal(c.decisions.transact, "block"); });
6
+ test("subdomain of the stapled domain is covered", async () => { assert.equal((await stapleCheck(F.valid, { host: "shop.example.com", publishedKids: kids })).valid, true); });
7
+ test("expired staple is ignored", async () => { const c = await stapleCheck(F.expired, { host: "example.com", publishedKids: kids }); assert.equal(c.valid, false); assert.equal(c.expired, true); });
8
+ test("staple for another domain is refused", async () => { assert.equal((await stapleCheck(F.foreign, { host: "example.com", publishedKids: kids })).valid, false); });
9
+ test("tampered staple fails the signature", async () => { const c = await stapleCheck(F.tampered, { host: "example.com", publishedKids: kids }); assert.equal(c.valid, false); assert.match(c.reason, /signature/); });
10
+ test("unpublished key is refused", async () => { assert.equal((await stapleCheck(F.valid, { host: "example.com", publishedKids: ["someone-else"] })).valid, false); });
11
+ test("header is read from a response", () => { assert.equal(stapleFromResponse({ headers: new Headers({ "CrawlCheck-Staple": F.valid }) }), F.valid); assert.equal(stapleFromResponse({ "crawlcheck-staple": "x" }), "x"); });
12
+ function mockClient(calls) { return new CrawlCheck({ fetch: async (u) => { calls.push(String(u)); if (String(u).includes("signatures-directory")) return new Response(JSON.stringify({ keys: [{ kid: F.kid }] })); return new Response(JSON.stringify({ kind: "crawlcheck-resolve", actions: { read: { decision: "warn" }, cite: { decision: "warn" }, connect: { decision: "unsupported" }, transact: { decision: "require_confirmation" } } })); } }); }
13
+ test("resolveFor prefers a valid staple and makes no resolve request", async () => { const calls = []; const r = await mockClient(calls).resolveFor("example.com", { staple: F.valid }); assert.equal(r.source, "staple"); assert.equal(calls.some((u) => u.includes("/api/v1/resolve")), false); });
14
+ test("resolveFor ignores an expired staple and asks CrawlCheck", async () => { const calls = []; const r = await mockClient(calls).resolveFor("example.com", { staple: F.expired }); assert.equal(r.source, "network"); assert.equal(r.decisions.read, "warn"); assert.equal(r.staple.expired, true); assert.equal(calls.some((u) => u.includes("/api/v1/resolve")), true); });