@crawlcheck/sdk 1.0.6 → 1.0.8
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 +13 -0
- package/dist/index.d.ts +45 -0
- package/dist/index.js +42 -0
- package/dist/staple.d.ts +21 -0
- package/dist/staple.js +88 -0
- package/dist/verify.d.ts +10 -0
- package/dist/verify.js +181 -3
- package/package.json +1 -1
- package/test/fixtures/staple.json +7 -0
- package/test/staple.test.js +14 -0
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 });
|
package/dist/staple.d.ts
ADDED
|
@@ -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/dist/verify.d.ts
CHANGED
|
@@ -10,10 +10,20 @@ export function dspVerdict(m: any): {
|
|
|
10
10
|
rule: string;
|
|
11
11
|
};
|
|
12
12
|
export function rfcRootV(leafHex: any, index: any, size: any, path: any): Promise<string | null>;
|
|
13
|
+
export function tvReachV(envelope: any): any;
|
|
14
|
+
export function verifyThresholdSigners(x: any): Promise<{
|
|
15
|
+
observer_id: any;
|
|
16
|
+
network: any;
|
|
17
|
+
signature: boolean;
|
|
18
|
+
binding: boolean;
|
|
19
|
+
values_match: boolean;
|
|
20
|
+
why: never[];
|
|
21
|
+
}[]>;
|
|
13
22
|
export function trustOf(res: any, kid: any, publishedKids: any): any;
|
|
14
23
|
export function publishedKeyIds(base: any): Promise<string[]>;
|
|
15
24
|
export function verifyBundle(b: any, opts: any): Promise<any>;
|
|
16
25
|
export function verifyReceipt(rc: any, opts: any): Promise<any>;
|
|
17
26
|
export function verifyExport(x: any, opts: any): Promise<any>;
|
|
18
27
|
export function verifyResolution(x: any, opts: any): Promise<any>;
|
|
28
|
+
export function verifyDnsTxt(txt: any, opts: any): Promise<any>;
|
|
19
29
|
export function verifyDataDoc(x: any, opts: any): Promise<any>;
|
package/dist/verify.js
CHANGED
|
@@ -519,6 +519,90 @@ export async function rfcRootV(leafHex, index, size, path) {
|
|
|
519
519
|
}
|
|
520
520
|
return sn === 0 ? hex(r) : null;
|
|
521
521
|
}
|
|
522
|
+
// tv1: threshold-signed verdicts. Every signer is checked on its own: its Ed25519 signature over its envelope, the
|
|
523
|
+
// binding of its key to its identity (GitHub's RS256 token, a self-signed enrolment, or the published key directory),
|
|
524
|
+
// and the field values recomputed from its envelope. The verdict holds when signers on at least k distinct networks pass.
|
|
525
|
+
const TV_OBS_PREFIX = "crawlcheck-observation-v1\n", TV_ENROL_PREFIX = "crawlcheck-observer-enrol-v1\n";
|
|
526
|
+
function tvFinalV(u) { try {
|
|
527
|
+
const x = new URL(u);
|
|
528
|
+
return x.hostname.toLowerCase().replace(/^www\./, "") + (x.pathname.replace(/\/+$/, "") || "/");
|
|
529
|
+
}
|
|
530
|
+
catch (e) {
|
|
531
|
+
return null;
|
|
532
|
+
} }
|
|
533
|
+
export function tvReachV(envelope) {
|
|
534
|
+
return (envelope && Array.isArray(envelope.fetches) ? envelope.fetches : []).map((f) => ({ identity: String(f.identity), status: typeof f.status === "number" ? f.status : null, final: f.url_final ? tvFinalV(f.url_final) : null, failed: !!f.error }))
|
|
535
|
+
.sort((a, b) => (a.identity < b.identity ? -1 : a.identity > b.identity ? 1 : 0));
|
|
536
|
+
}
|
|
537
|
+
async function tvEd(x, msg, sig) { try {
|
|
538
|
+
const pk = await subtle.importKey("jwk", { kty: "OKP", crv: "Ed25519", x }, { name: "Ed25519" }, false, ["verify"]);
|
|
539
|
+
return await subtle.verify({ name: "Ed25519" }, pk, b64(sig), enc.encode(msg));
|
|
540
|
+
}
|
|
541
|
+
catch (e) {
|
|
542
|
+
return null;
|
|
543
|
+
} }
|
|
544
|
+
async function tvJwt(token, jwk) {
|
|
545
|
+
const p = String(token || "").split(".");
|
|
546
|
+
if (p.length !== 3 || !jwk || jwk.kty !== "RSA")
|
|
547
|
+
return { ok: false };
|
|
548
|
+
let h, c;
|
|
549
|
+
try {
|
|
550
|
+
h = JSON.parse(new TextDecoder().decode(b64(p[0])));
|
|
551
|
+
c = JSON.parse(new TextDecoder().decode(b64(p[1])));
|
|
552
|
+
}
|
|
553
|
+
catch (e) {
|
|
554
|
+
return { ok: false };
|
|
555
|
+
}
|
|
556
|
+
if (h.alg !== "RS256" || (jwk.kid && h.kid !== jwk.kid))
|
|
557
|
+
return { ok: false, claims: c };
|
|
558
|
+
try {
|
|
559
|
+
const k = await subtle.importKey("jwk", { kty: "RSA", n: jwk.n, e: jwk.e, alg: "RS256", ext: true }, { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" }, false, ["verify"]);
|
|
560
|
+
return { ok: await subtle.verify("RSASSA-PKCS1-v1_5", k, b64(p[2]), enc.encode(p[0] + "." + p[1])), claims: c, kid: h.kid };
|
|
561
|
+
}
|
|
562
|
+
catch (e) {
|
|
563
|
+
return { ok: null, claims: c };
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
export async function verifyThresholdSigners(x) {
|
|
567
|
+
const st = x.statement || {}, want = canon(st.values || []), rows = [];
|
|
568
|
+
for (const s of Array.isArray(x.signers) ? x.signers : []) {
|
|
569
|
+
const r = { observer_id: s.observer_id, network: s.network && s.network.class || null, signature: false, binding: false, values_match: false, why: [] };
|
|
570
|
+
const ev = s.envelope || {}, key = ev.key || {};
|
|
571
|
+
r.signature = !!(await tvEd(key.x, TV_OBS_PREFIX + canon(ev), s.sig));
|
|
572
|
+
if (!r.signature)
|
|
573
|
+
r.why.push("signature does not verify under the envelope key");
|
|
574
|
+
const b = s.binding || {};
|
|
575
|
+
if (b.kind === "github_oidc") {
|
|
576
|
+
const j = await tvJwt(b.token, b.github_key), c = j.claims || {};
|
|
577
|
+
const aud = "crawlcheck-observer:" + b64u(await sha256(b64(key.x || "")));
|
|
578
|
+
r.binding = j.ok === true && c.iss === "https://token.actions.githubusercontent.com" && c.repository === b.repository && String(c.job_workflow_ref || c.workflow_ref || "").indexOf(b.workflow_ref_prefix || "\u0000") === 0 && (Array.isArray(c.aud) ? c.aud : [c.aud]).includes(aud) && (!c.runner_environment || c.runner_environment === "github-hosted");
|
|
579
|
+
r.github_kid = j.kid || null;
|
|
580
|
+
if (!r.binding)
|
|
581
|
+
r.why.push("GitHub token does not bind this key to the observer workflow");
|
|
582
|
+
}
|
|
583
|
+
else if (b.kind === "enrolled_ed25519") {
|
|
584
|
+
const q = b.enrolment && b.enrolment.request;
|
|
585
|
+
r.binding = !!(b.key && b.key.x === key.x && q && q.observer_id === s.observer_id && q.key && q.key.x === key.x && await tvEd(key.x, TV_ENROL_PREFIX + canon(q), b.enrolment.sig));
|
|
586
|
+
if (!r.binding)
|
|
587
|
+
r.why.push("the key is not the one this observer enrolled with");
|
|
588
|
+
}
|
|
589
|
+
else if (b.kind === "published_key_directory") {
|
|
590
|
+
const thumb = b64u(await sha256(enc.encode(JSON.stringify({ crv: "Ed25519", kty: "OKP", x: key.x }))));
|
|
591
|
+
r.binding = thumb === b.kid && thumb === (x.signature && x.signature.kid);
|
|
592
|
+
r.kid = thumb;
|
|
593
|
+
if (!r.binding)
|
|
594
|
+
r.why.push("key thumbprint is not the stated (published) key id");
|
|
595
|
+
}
|
|
596
|
+
else
|
|
597
|
+
r.why.push("unknown binding " + b.kind);
|
|
598
|
+
r.values_match = canon(tvReachV(ev)) === want;
|
|
599
|
+
if (!r.values_match)
|
|
600
|
+
r.why.push("the values in this envelope differ from the statement");
|
|
601
|
+
r.passes = r.signature && r.binding && r.values_match;
|
|
602
|
+
rows.push(r);
|
|
603
|
+
}
|
|
604
|
+
return rows;
|
|
605
|
+
}
|
|
522
606
|
// ── data inventories, export parts and deletion records ─────────────────────────────────────────────────────────
|
|
523
607
|
// Signed over sha256(canonical JSON of every field except sha256 and signature) with the prefix below. A deletion
|
|
524
608
|
// record carries counts per family and the digest of the sorted deleted-key list, never the data.
|
|
@@ -528,7 +612,7 @@ async function verifyDataDoc0(x) {
|
|
|
528
612
|
throw new Error("this runtime has no WebCrypto (crypto.subtle)");
|
|
529
613
|
const checks = [];
|
|
530
614
|
const C = (id, ok, why) => checks.push({ id, ok, why });
|
|
531
|
-
if (!x || !/^crawlcheck-(data-inventory|data-export-part|deletion-record|resolve|decision|snapshot|visitor|conduct|traffic-index|anchor|anchor-check|content-clock|origin|claims|read-equivalence|rights|rights-feed|mcp-scan|mcp-lock|mcp-lock-check|mcp-shadow|mcp-auth|impersonation|task-canaries|incident|revocations|high-risk|log-sth)$/.test(String(x.kind)) || !x.signature)
|
|
615
|
+
if (!x || !/^crawlcheck-(data-inventory|data-export-part|deletion-record|resolve|decision|snapshot|visitor|conduct|traffic-index|anchor|anchor-check|content-clock|origin|claims|read-equivalence|rights|rights-feed|mcp-scan|mcp-lock|mcp-lock-check|mcp-shadow|mcp-auth|impersonation|task-canaries|agent-safety-benchmark|compliance-pack|share-of-preflight|incident|revocations|high-risk|log-sth|threshold-verdict|resolve-as-of)$/.test(String(x.kind)) || !x.signature)
|
|
532
616
|
throw new Error("not a CrawlCheck data inventory, export part, deletion record, resolve answer, decision receipt, snapshot manifest, visitor verdict, conduct record, traffic index, citation anchor, content clock, origin decision, claim ledger, read-equivalence certificate, rights answer, rights-feed manifest, MCP tool scan, MCP lockfile or lockfile check");
|
|
533
617
|
const body = {};
|
|
534
618
|
for (const k of Object.keys(x))
|
|
@@ -577,6 +661,10 @@ async function verifyDataDoc0(x) {
|
|
|
577
661
|
const ps = Array.isArray(x.parts) ? x.parts : [];
|
|
578
662
|
C("parts", ps.length > 0 && ps.every((p) => /^part-\d{4}\.ndjson\.gz$/.test(String(p.path)) && /^[0-9a-f]{64}$/.test(String(p.sha256)) && p.records > 0) && ps.reduce((a, p) => a + p.records, 0) === x.records, ps.reduce((a, p) => a + (p.records || 0), 0) === x.records ? ps.length + " parts, " + x.records + " records; check each downloaded part with sha256sum against its sha256, then each line with this tool" : "the parts' record counts do not add up to records (" + x.records + ")");
|
|
579
663
|
}
|
|
664
|
+
if (x.kind === "crawlcheck-resolve" && Array.isArray(x.absences)) { // sa1: every absence states what was checked, from where and when
|
|
665
|
+
const bad = x.absences.filter((a) => !(a && a.what && (a.result === "absent" || a.result === "none") && a.at && !isNaN(Date.parse(a.at)) && a.from && a.from.observer && typeof a.scope === "string" && Array.isArray(a.checked) && a.checked.length && a.checked.every((c) => c && c.method && (a.result !== "absent" || c.status === undefined || c.status === 404 || c.status === 410))));
|
|
666
|
+
C("absences_scoped", bad.length === 0, x.absences.length + " absence record(s)" + (x.absences.length ? " (" + x.absences.map((a) => a.what).join(", ") + ")" : "") + (bad.length ? "; " + bad.length + " lack a definite check, a time, a vantage or a scope" : ", each naming what was requested, the answer, the vantage and the time"));
|
|
667
|
+
}
|
|
580
668
|
if (x.kind === "crawlcheck-resolve" && x.transparency) { // tl1: the answer's place in the Resolve transparency log
|
|
581
669
|
const t = x.transparency, leaf = hex(await sha256(cat(new Uint8Array([0]), enc.encode(canon(tlContentV(x))))));
|
|
582
670
|
C("log_leaf", leaf === t.leaf_hash, leaf === t.leaf_hash ? "the answer's decision content hashes to its log leaf " + leaf.slice(0, 16) + "…" : "the decision content hashes to " + leaf + ", the answer names " + t.leaf_hash);
|
|
@@ -596,6 +684,41 @@ async function verifyDataDoc0(x) {
|
|
|
596
684
|
else
|
|
597
685
|
C("log_inclusion", null, "pending: the leaf is queued and merged within " + (t.merged_within_minutes || 30) + " minutes; fetch the proof at " + (t.proof || "the log"));
|
|
598
686
|
}
|
|
687
|
+
if (x.kind === "crawlcheck-resolve-as-of") { // ah1: the answer in force at as_of, as signed then
|
|
688
|
+
let av = null;
|
|
689
|
+
try {
|
|
690
|
+
av = await verifyDataDoc0(x.answer);
|
|
691
|
+
}
|
|
692
|
+
catch (e) {
|
|
693
|
+
av = null;
|
|
694
|
+
}
|
|
695
|
+
C("answer_signature", !!(av && av.verified) && !!x.answer && x.answer.kind === "crawlcheck-resolve" && x.answer.domain === x.domain, av && av.verified ? "the archived answer verifies on its own (signed " + (x.answer.signature && x.answer.signature.signed_at) + ")" : "the archived answer does NOT verify");
|
|
696
|
+
const leaf = x.answer ? hex(await sha256(cat(new Uint8Array([0]), enc.encode(canon(tlContentV(x.answer)))))) : null;
|
|
697
|
+
C("answer_leaf", leaf === x.leaf_hash, leaf === x.leaf_hash ? "the answer's decision content is leaf " + String(leaf).slice(0, 16) + "…" : "the answer's content hashes to " + leaf + ", not the stated leaf");
|
|
698
|
+
const w = x.in_force || {}, t = Date.parse(x.as_of);
|
|
699
|
+
C("in_force_window", Date.parse(w.from) <= t && (w.until == null || t < Date.parse(w.until)), "as_of " + x.as_of + " falls in [" + w.from + ", " + (w.until || "now") + ")");
|
|
700
|
+
const pr = x.log_inclusion;
|
|
701
|
+
if (pr && pr.sth) {
|
|
702
|
+
const root = await rfcRootV(x.leaf_hash, pr.index, pr.batch_size, pr.path || []);
|
|
703
|
+
let sv = null;
|
|
704
|
+
try {
|
|
705
|
+
sv = await verifyDataDoc0(pr.sth);
|
|
706
|
+
}
|
|
707
|
+
catch (e) {
|
|
708
|
+
sv = null;
|
|
709
|
+
}
|
|
710
|
+
C("log_inclusion", root !== null && root === pr.sth.batch_root && !!(sv && sv.verified), root === pr.sth.batch_root ? "the leaf is in batch " + pr.batch + " of the signed log (head created " + pr.sth.created_at + ")" : "the inclusion proof does NOT reach the signed batch root");
|
|
711
|
+
}
|
|
712
|
+
else
|
|
713
|
+
C("log_inclusion", null, "not merged into the log yet; merged within 30 minutes of first appearing");
|
|
714
|
+
}
|
|
715
|
+
if (x.kind === "crawlcheck-threshold-verdict") { // tv1
|
|
716
|
+
const rows = await verifyThresholdSigners(x), k = x.threshold && x.threshold.k || 2;
|
|
717
|
+
rows.forEach((r) => C("signer:" + r.observer_id, r.passes, r.passes ? "signature, key binding (" + (x.signers.find((s) => s.observer_id === r.observer_id).binding.kind) + ") and recomputed " + (x.statement && x.statement.family) + " values all check (network " + r.network + ")" : r.why.join("; ")));
|
|
718
|
+
const nets = new Set(rows.filter((r) => r.passes).map((r) => r.network));
|
|
719
|
+
C("threshold", nets.size >= k, nets.size + " distinct network(s) signed identical values (" + [...nets].join(", ") + "); the verdict needs " + k);
|
|
720
|
+
C("key_bindings_online", null, "offline the bindings are checked against keys the document carries; online, compare the GitHub key id with " + "https://token.actions.githubusercontent.com/.well-known/jwks and enrolled keys with https://crawlcheck.io/.well-known/crawlcheck-observers.json");
|
|
721
|
+
}
|
|
599
722
|
if (x.kind === "crawlcheck-deletion-record")
|
|
600
723
|
C("counts", (x.deleted || []).reduce((a, f) => a + (f.keys || 0), 0) === x.deleted_total, "the per-family counts add up to deleted_total (" + x.deleted_total + ")");
|
|
601
724
|
const ran = checks.filter((c) => c.ok !== null), failed = checks.filter((c) => c.ok === false);
|
|
@@ -640,6 +763,38 @@ export async function verifyBundle(b, opts) { return trustOf(await verifyBundle0
|
|
|
640
763
|
export async function verifyReceipt(rc, opts) { return trustOf(await verifyReceipt0(rc), rc && rc.signature && rc.signature.kid, opts && opts.publishedKids); }
|
|
641
764
|
export async function verifyExport(x, opts) { return trustOf(await verifyExport0(x), kidOf(x), opts && opts.publishedKids); }
|
|
642
765
|
export async function verifyResolution(x, opts) { return trustOf(await verifyResolution0(x), kidOf(x), opts && opts.publishedKids); }
|
|
766
|
+
// dns1: a Resolve-over-DNS TXT record (ccr1). Strings joined without spaces; quotes from dig output are stripped.
|
|
767
|
+
async function verifyDnsTxt0(txt) {
|
|
768
|
+
if (!subtle)
|
|
769
|
+
throw new Error("this runtime has no WebCrypto (crypto.subtle)");
|
|
770
|
+
const checks = [], C = (id, ok, why) => checks.push({ id, ok, why });
|
|
771
|
+
const t = String(txt || "").trim().replace(/"\s*"/g, "").replace(/^"|"$/g, "").replace(/\;/g, ";");
|
|
772
|
+
const i = t.lastIndexOf(";s="), body = i > 0 ? t.slice(0, i) : "", sig = i > 0 ? t.slice(i + 3) : "";
|
|
773
|
+
const f = {};
|
|
774
|
+
body.split(";").forEach((p) => { const j = p.indexOf("="); if (j > 0)
|
|
775
|
+
f[p.slice(0, j)] = p.slice(j + 1); });
|
|
776
|
+
C("format", f.v === "ccr1" && !!f.d && !!f.k && !!f.x && !!sig, f.v === "ccr1" ? "ccr1 record for " + (f.d || "?") + " (" + (f.st || "?") + ")" : "not a ccr1 record");
|
|
777
|
+
if (f.x && f.k) {
|
|
778
|
+
const thumb = b64u(await sha256(enc.encode(JSON.stringify({ crv: "Ed25519", kty: "OKP", x: f.x }))));
|
|
779
|
+
C("key_id", thumb === f.k, thumb === f.k ? "public key thumbprint (RFC 7638) is the key id " + thumb.slice(0, 12) + "…" : "thumbprint " + thumb + " does not match key id " + f.k);
|
|
780
|
+
let ok = null;
|
|
781
|
+
try {
|
|
782
|
+
const pk = await subtle.importKey("jwk", { kty: "OKP", crv: "Ed25519", x: f.x }, { name: "Ed25519" }, false, ["verify"]);
|
|
783
|
+
ok = await subtle.verify({ name: "Ed25519" }, pk, b64(sig), enc.encode("crawlcheck-dns-v1\n" + body));
|
|
784
|
+
}
|
|
785
|
+
catch (e) {
|
|
786
|
+
ok = null;
|
|
787
|
+
C("signature", null, "this runtime cannot verify Ed25519: " + (e && e.message || e));
|
|
788
|
+
}
|
|
789
|
+
if (ok !== null)
|
|
790
|
+
C("signature", ok, ok ? "Ed25519 signature over the record verifies" : "signature does NOT verify over this record");
|
|
791
|
+
}
|
|
792
|
+
const fu = Date.parse(f.f || "");
|
|
793
|
+
C("fresh", isFinite(fu) ? fu > Date.now() : false, isFinite(fu) ? (fu > Date.now() ? "fresh until " + f.f : "expired at " + f.f + ": ask again") : "no fresh_until");
|
|
794
|
+
C("key_published", null, "offline this cannot be decided: compare key id " + (f.k || "?") + " with the ids published at https://crawlcheck.io/.well-known/http-message-signatures-directory");
|
|
795
|
+
return { record: f, decisions: { read: f.read || null, cite: f.cite || null, connect: f.connect || null, transact: f.transact || null }, checks };
|
|
796
|
+
}
|
|
797
|
+
export async function verifyDnsTxt(txt, opts) { const r = await verifyDnsTxt0(txt); return trustOf(r, r.record && r.record.k, opts && opts.publishedKids); }
|
|
643
798
|
export async function verifyDataDoc(x, opts) { return trustOf(await verifyDataDoc0(x), kidOf(x), opts && opts.publishedKids); }
|
|
644
799
|
const isMain = typeof process !== "undefined" && !!(process.argv && process.argv[1]) && await (async () => { try {
|
|
645
800
|
const { pathToFileURL } = await import("node:url");
|
|
@@ -651,9 +806,32 @@ catch (e) {
|
|
|
651
806
|
} })();
|
|
652
807
|
if (isMain) {
|
|
653
808
|
const online = process.argv.includes("--online");
|
|
809
|
+
if (process.argv.includes("--dns")) { // dns1: a Resolve-over-DNS TXT record
|
|
810
|
+
let txt = process.argv.slice(2).filter((a) => a !== "--online" && a !== "--dns").join(" ");
|
|
811
|
+
if (!txt) {
|
|
812
|
+
const { readFileSync } = await import("node:fs");
|
|
813
|
+
txt = readFileSync(0, "utf8");
|
|
814
|
+
}
|
|
815
|
+
let kd;
|
|
816
|
+
if (online) {
|
|
817
|
+
try {
|
|
818
|
+
kd = await publishedKeyIds();
|
|
819
|
+
}
|
|
820
|
+
catch (e) {
|
|
821
|
+
console.log("could not read the published key directory: " + e.message);
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
const r = await verifyDnsTxt(txt, { publishedKids: kd });
|
|
825
|
+
console.log("CrawlCheck Resolve over DNS: " + (r.record.d || "?") + " " + (r.record.st || "?") + " · read " + r.decisions.read + " · cite " + r.decisions.cite + " · connect " + r.decisions.connect + " · transact " + r.decisions.transact);
|
|
826
|
+
for (const c of r.checks)
|
|
827
|
+
console.log((c.ok === true ? "PASS" : c.ok === false ? "FAIL" : " -- ") + " " + c.id.padEnd(23) + c.why);
|
|
828
|
+
console.log(r.summary);
|
|
829
|
+
console.log(r.accepted ? "ACCEPTED" : "NOT ACCEPTED" + (r.integrity_valid && r.issuer_trusted === null ? ": issuer unknown offline (run with --online)" : ""));
|
|
830
|
+
process.exit(r.checks.some((c) => c.ok === false) ? 1 : 0);
|
|
831
|
+
}
|
|
654
832
|
const f = process.argv.slice(2).filter((a) => a !== "--online")[0];
|
|
655
833
|
if (!f) {
|
|
656
|
-
console.log("usage: node crawlcheck-verify.mjs <bundle.json | receipt.json | export.json | resolution.json | deletion.json | answer.json | decision.json | manifest.json>");
|
|
834
|
+
console.log("usage: node crawlcheck-verify.mjs [--online] [--dns \"<ccr1 TXT record>\"] <bundle.json | receipt.json | export.json | resolution.json | deletion.json | answer.json | decision.json | manifest.json>");
|
|
657
835
|
process.exit(2);
|
|
658
836
|
}
|
|
659
837
|
const { readFile } = await import("node:fs/promises");
|
|
@@ -661,7 +839,7 @@ if (isMain) {
|
|
|
661
839
|
const isRc = doc && (doc.kind === "crawlcheck-remediation-receipt" || (doc.receipt && doc.receipt.kind === "crawlcheck-remediation-receipt"));
|
|
662
840
|
const isEx = doc && doc.kind === "crawlcheck-evidence-export";
|
|
663
841
|
const isDr = doc && doc.kind === "crawlcheck-dispute-resolution";
|
|
664
|
-
const isDt = doc && /^crawlcheck-(data-inventory|data-export-part|deletion-record|resolve|decision|snapshot|visitor|conduct|traffic-index|anchor|anchor-check|content-clock|origin|claims|read-equivalence|rights|rights-feed|mcp-scan|mcp-lock|mcp-lock-check|mcp-shadow|mcp-auth|impersonation|task-canaries|incident|revocations|high-risk|log-sth)$/.test(String(doc.kind));
|
|
842
|
+
const isDt = doc && /^crawlcheck-(data-inventory|data-export-part|deletion-record|resolve|decision|snapshot|visitor|conduct|traffic-index|anchor|anchor-check|content-clock|origin|claims|read-equivalence|rights|rights-feed|mcp-scan|mcp-lock|mcp-lock-check|mcp-shadow|mcp-auth|impersonation|task-canaries|agent-safety-benchmark|compliance-pack|share-of-preflight|incident|revocations|high-risk|log-sth|threshold-verdict|resolve-as-of)$/.test(String(doc.kind));
|
|
665
843
|
let kids;
|
|
666
844
|
if (online) {
|
|
667
845
|
try {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@crawlcheck/sdk",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.8",
|
|
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); });
|