@crawlcheck/sdk 1.0.3 → 1.0.4
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 +28 -0
- package/dist/index.js +57 -3
- package/dist/verify.d.ts +7 -80
- package/dist/verify.js +73 -11
- package/package.json +1 -1
- package/test/sdk.test.js +19 -1
package/dist/index.d.ts
CHANGED
|
@@ -115,6 +115,29 @@ export type BodyOf<P extends keyof paths> = Op<P, "post"> extends {
|
|
|
115
115
|
};
|
|
116
116
|
} ? B : JsonObject;
|
|
117
117
|
export type Action = "read" | "cite" | "connect" | "transact" | "administer";
|
|
118
|
+
/** The header an agent sends with its request so the site can see it checked CrawlCheck first. */
|
|
119
|
+
export declare const RECEIPT_HEADER = "CrawlCheck-Receipt";
|
|
120
|
+
/** The domain the way CrawlCheck hashes and stores it: lowercased, no scheme, no leading www., no path or port. "" when not a domain. */
|
|
121
|
+
export declare function normalizeDomain(v: string): string;
|
|
122
|
+
/** { "CrawlCheck-Receipt": "v=1; sha256=..." } for a decision receipt. Send it only to the domain the receipt names, before expires_at. */
|
|
123
|
+
export declare function receiptHeader(receipt: {
|
|
124
|
+
sha256: string;
|
|
125
|
+
}): Record<string, string>;
|
|
126
|
+
export interface PrivateLookup {
|
|
127
|
+
found: boolean;
|
|
128
|
+
ready: boolean;
|
|
129
|
+
in_bucket: number | null;
|
|
130
|
+
answer: JsonObject | null;
|
|
131
|
+
verification: (DocumentVerification & Trust) | null;
|
|
132
|
+
accepted: boolean;
|
|
133
|
+
why?: string;
|
|
134
|
+
}
|
|
135
|
+
export interface ReceiptCheck {
|
|
136
|
+
accept: boolean;
|
|
137
|
+
why: string;
|
|
138
|
+
check: JsonObject | null;
|
|
139
|
+
verification: (DocumentVerification & Trust) | null;
|
|
140
|
+
}
|
|
118
141
|
export type Decision = "allow" | "warn" | "require_confirmation" | "block" | "unsupported";
|
|
119
142
|
export interface PreflightPolicy {
|
|
120
143
|
max_age_hours?: number;
|
|
@@ -204,7 +227,12 @@ export declare class CrawlCheck {
|
|
|
204
227
|
proceed: boolean;
|
|
205
228
|
receipt: DecisionReceipt;
|
|
206
229
|
verification: DocumentVerification & Trust;
|
|
230
|
+
header: Record<string, string> | null;
|
|
207
231
|
}>;
|
|
232
|
+
/** Look a domain up without sending it: only the first prefixLen hex characters of sha256(domain) leave this process. found false with ready true means no public measurement. */
|
|
233
|
+
resolvePrivate(domain: string, prefixLen?: number): Promise<PrivateLookup>;
|
|
234
|
+
/** For sites: check a CrawlCheck-Receipt header received with a request to host. accept only when the receipt is held, names host, has not expired, decided allow or warn, and verifies with a published key. */
|
|
235
|
+
checkReceiptHeader(headerValue: string, host: string): Promise<ReceiptCheck>;
|
|
208
236
|
/** Signed change feed (what changed since a time, optionally for one domain). */
|
|
209
237
|
changes(q?: QueryOf<"/api/v1/changes">): Promise<JsonObject>;
|
|
210
238
|
/** Report what happened after acting. Outcomes are kept apart from the signed record and never rewrite it. */
|
package/dist/index.js
CHANGED
|
@@ -26,6 +26,19 @@ export const verifyResolution = V.verifyResolution;
|
|
|
26
26
|
export const canon = V.canon;
|
|
27
27
|
export const merkleRootHex = V.merkleRootHex;
|
|
28
28
|
export const otsInspect = V.otsInspect;
|
|
29
|
+
/** The header an agent sends with its request so the site can see it checked CrawlCheck first. */
|
|
30
|
+
export const RECEIPT_HEADER = "CrawlCheck-Receipt";
|
|
31
|
+
/** The domain the way CrawlCheck hashes and stores it: lowercased, no scheme, no leading www., no path or port. "" when not a domain. */
|
|
32
|
+
export function normalizeDomain(v) {
|
|
33
|
+
const d = String(v ?? "").trim().toLowerCase().replace(/^https?:\/\//, "").replace(/^www\./, "").replace(/[\/?#].*$/, "").replace(/:\d+$/, "");
|
|
34
|
+
return /^[a-z0-9-]+(\.[a-z0-9-]+)+$/.test(d) ? d : "";
|
|
35
|
+
}
|
|
36
|
+
/** { "CrawlCheck-Receipt": "v=1; sha256=..." } for a decision receipt. Send it only to the domain the receipt names, before expires_at. */
|
|
37
|
+
export function receiptHeader(receipt) { return { [RECEIPT_HEADER]: "v=1; sha256=" + receipt.sha256 }; }
|
|
38
|
+
async function sha256Hex(s) {
|
|
39
|
+
const b = await globalThis.crypto.subtle.digest("SHA-256", new TextEncoder().encode(s));
|
|
40
|
+
return Array.from(new Uint8Array(b), (x) => x.toString(16).padStart(2, "0")).join("");
|
|
41
|
+
}
|
|
29
42
|
export class CrawlCheckError extends Error {
|
|
30
43
|
status;
|
|
31
44
|
body;
|
|
@@ -88,14 +101,55 @@ export class CrawlCheck {
|
|
|
88
101
|
const receipt = await this.preflight(domain, action, opts);
|
|
89
102
|
const verification = await this.verifyDocument(receipt, opts.onlineKeys ?? true);
|
|
90
103
|
if (!verification.accepted && (opts.onlineKeys ?? true))
|
|
91
|
-
return { proceed: false, receipt, verification };
|
|
104
|
+
return { proceed: false, receipt, verification, header: null };
|
|
92
105
|
if (!verification.verified)
|
|
93
|
-
return { proceed: false, receipt, verification };
|
|
106
|
+
return { proceed: false, receipt, verification, header: null };
|
|
94
107
|
const d = receipt.decision;
|
|
95
108
|
let proceed = d === "allow" || (d === "warn" && !!opts.allowWarn);
|
|
96
109
|
if (d === "require_confirmation" && opts.confirm)
|
|
97
110
|
proceed = !!(await opts.confirm(receipt));
|
|
98
|
-
return { proceed, receipt, verification };
|
|
111
|
+
return { proceed, receipt, verification, header: proceed ? receiptHeader(receipt) : null };
|
|
112
|
+
}
|
|
113
|
+
/** Look a domain up without sending it: only the first prefixLen hex characters of sha256(domain) leave this process. found false with ready true means no public measurement. */
|
|
114
|
+
async resolvePrivate(domain, prefixLen = 2) {
|
|
115
|
+
const d = normalizeDomain(domain);
|
|
116
|
+
if (!d)
|
|
117
|
+
throw new Error("not a domain name: " + domain);
|
|
118
|
+
if (prefixLen < 2 || prefixLen > 8)
|
|
119
|
+
throw new Error("prefixLen must be 2 to 8");
|
|
120
|
+
const h = await sha256Hex(d);
|
|
121
|
+
let r;
|
|
122
|
+
try {
|
|
123
|
+
r = await this.req("GET", "/api/v1/resolve-prefix/" + h.slice(0, prefixLen));
|
|
124
|
+
}
|
|
125
|
+
catch (e) {
|
|
126
|
+
if (e instanceof CrawlCheckError && e.status === 503)
|
|
127
|
+
return { found: false, ready: false, in_bucket: null, answer: null, verification: null, accepted: false, why: "the prefix index is not built yet" };
|
|
128
|
+
throw e;
|
|
129
|
+
}
|
|
130
|
+
const hit = (r.answers || []).find((a) => a.domain_sha256 === h);
|
|
131
|
+
if (!hit)
|
|
132
|
+
return { found: false, ready: true, in_bucket: r.in_bucket ?? null, answer: null, verification: null, accepted: false };
|
|
133
|
+
const verification = await this.verifyDocument(hit.answer);
|
|
134
|
+
return { found: true, ready: true, in_bucket: r.in_bucket ?? null, answer: hit.answer, verification, accepted: !!verification.accepted && normalizeDomain(hit.answer.domain) === d };
|
|
135
|
+
}
|
|
136
|
+
/** For sites: check a CrawlCheck-Receipt header received with a request to host. accept only when the receipt is held, names host, has not expired, decided allow or warn, and verifies with a published key. */
|
|
137
|
+
async checkReceiptHeader(headerValue, host) {
|
|
138
|
+
const m = String(headerValue || "").match(/sha256=([0-9a-fA-F]{64})/) || String(headerValue || "").trim().match(/^([0-9a-fA-F]{64})$/);
|
|
139
|
+
if (!m)
|
|
140
|
+
return { accept: false, why: "no receipt digest in the header", check: null, verification: null };
|
|
141
|
+
const sha = m[1].toLowerCase(), h = normalizeDomain(host);
|
|
142
|
+
let r;
|
|
143
|
+
try {
|
|
144
|
+
r = await this.req("GET", "/api/v1/decisions/" + sha, { host: h });
|
|
145
|
+
}
|
|
146
|
+
catch (e) {
|
|
147
|
+
if (e instanceof CrawlCheckError && e.status === 404)
|
|
148
|
+
return { accept: false, why: "CrawlCheck holds no receipt with that digest", check: e.body, verification: null };
|
|
149
|
+
throw e;
|
|
150
|
+
}
|
|
151
|
+
const rc = r.receipt || {}, verification = await this.verifyDocument(rc);
|
|
152
|
+
return { accept: !!r.accept && !!verification.accepted && rc.sha256 === sha && normalizeDomain(rc.domain) === h, why: r.reason, check: r, verification };
|
|
99
153
|
}
|
|
100
154
|
/** Signed change feed (what changed since a time, optionally for one domain). */
|
|
101
155
|
changes(q = {}) { return this.get("/api/v1/changes", q); }
|
package/dist/verify.d.ts
CHANGED
|
@@ -4,88 +4,15 @@ export function otsInspect(u8: any): Promise<{
|
|
|
4
4
|
digest: string;
|
|
5
5
|
attestations: any[];
|
|
6
6
|
}>;
|
|
7
|
-
export function verifyBundle(b: any): Promise<{
|
|
8
|
-
verified: boolean;
|
|
9
|
-
summary: string;
|
|
10
|
-
report: any;
|
|
11
|
-
checks: any[];
|
|
12
|
-
experiences: any;
|
|
13
|
-
note: string;
|
|
14
|
-
}>;
|
|
15
|
-
export function verifyReceipt(rc: any): Promise<{
|
|
16
|
-
verified: boolean;
|
|
17
|
-
summary: string;
|
|
18
|
-
receipt: {
|
|
19
|
-
id: any;
|
|
20
|
-
domain: any;
|
|
21
|
-
verdict: any;
|
|
22
|
-
before: any;
|
|
23
|
-
after: any;
|
|
24
|
-
};
|
|
25
|
-
checks: any[];
|
|
26
|
-
note: string;
|
|
27
|
-
}>;
|
|
28
|
-
export function verifyExport(x: any): Promise<{
|
|
29
|
-
verified: boolean;
|
|
30
|
-
summary: string;
|
|
31
|
-
export: {
|
|
32
|
-
domain: any;
|
|
33
|
-
window: any;
|
|
34
|
-
records: number;
|
|
35
|
-
receipts: number;
|
|
36
|
-
generated_at: any;
|
|
37
|
-
};
|
|
38
|
-
checks: any[];
|
|
39
|
-
records: ({
|
|
40
|
-
id: any;
|
|
41
|
-
verified: null;
|
|
42
|
-
failed: never[];
|
|
43
|
-
why: any;
|
|
44
|
-
scanned_at?: undefined;
|
|
45
|
-
ran?: undefined;
|
|
46
|
-
} | {
|
|
47
|
-
id: any;
|
|
48
|
-
scanned_at: any;
|
|
49
|
-
verified: boolean | null;
|
|
50
|
-
failed: any[];
|
|
51
|
-
ran: number;
|
|
52
|
-
why: string | null;
|
|
53
|
-
})[];
|
|
54
|
-
receipts: {
|
|
55
|
-
id: any;
|
|
56
|
-
verified: boolean | null;
|
|
57
|
-
failed: any[];
|
|
58
|
-
why: string | null;
|
|
59
|
-
}[];
|
|
60
|
-
note: string;
|
|
61
|
-
}>;
|
|
62
7
|
export function dspVerdict(m: any): {
|
|
63
8
|
verdict: string;
|
|
64
9
|
effect: string;
|
|
65
10
|
rule: string;
|
|
66
11
|
};
|
|
67
|
-
export function
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
code: any;
|
|
75
|
-
verdict: any;
|
|
76
|
-
effect: any;
|
|
77
|
-
observers: any;
|
|
78
|
-
};
|
|
79
|
-
checks: any[];
|
|
80
|
-
note: string;
|
|
81
|
-
}>;
|
|
82
|
-
export function verifyDataDoc(x: any): Promise<{
|
|
83
|
-
verified: boolean;
|
|
84
|
-
summary: string;
|
|
85
|
-
document: {
|
|
86
|
-
kind: any;
|
|
87
|
-
domain: any;
|
|
88
|
-
sha256: any;
|
|
89
|
-
};
|
|
90
|
-
checks: any[];
|
|
91
|
-
}>;
|
|
12
|
+
export function trustOf(res: any, kid: any, publishedKids: any): any;
|
|
13
|
+
export function publishedKeyIds(base: any): Promise<string[]>;
|
|
14
|
+
export function verifyBundle(b: any, opts: any): Promise<any>;
|
|
15
|
+
export function verifyReceipt(rc: any, opts: any): Promise<any>;
|
|
16
|
+
export function verifyExport(x: any, opts: any): Promise<any>;
|
|
17
|
+
export function verifyResolution(x: any, opts: any): Promise<any>;
|
|
18
|
+
export function verifyDataDoc(x: any, opts: any): Promise<any>;
|
package/dist/verify.js
CHANGED
|
@@ -20,6 +20,14 @@
|
|
|
20
20
|
// decision_root (manifest v3) the sealed root over the decisions recomputes from the decision leaves (licence bundles)
|
|
21
21
|
// lineage_root (manifest v3) the sealed root over the extracted business facts recomputes from their leaves (licence bundles)
|
|
22
22
|
// A check that cannot run (a withheld subject, an unsealed day) is null with the reason, never a pass.
|
|
23
|
+
// Every result also separates the questions a single "verified" used to answer at once (vt1, 2026-10-06):
|
|
24
|
+
// integrity_valid no check over the bytes failed (hashes, signature, proof paths)
|
|
25
|
+
// issuer_trusted the signing key is in CrawlCheck's published key directory: true / false, or null offline
|
|
26
|
+
// accepted integrity_valid AND issuer_trusted === true. Act on this, never on verified alone.
|
|
27
|
+
// accepted_strict accepted AND every check ran (legacy and pre-manifest records stay false: less was sealed)
|
|
28
|
+
// assurance "complete" or "partial", with unavailable[] naming the checks that could not run
|
|
29
|
+
// Offline, issuer_trusted is null and accepted is false. Close it with --online (fetches the key directory) or
|
|
30
|
+
// pass the published key ids yourself: verifyReceipt(doc, { publishedKids: [...] }).
|
|
23
31
|
// What it cannot do offline: read a Bitcoin block header. It prints the block height and the merkle root the
|
|
24
32
|
// proof commits to; compare that value with any Bitcoin node or block explorer.
|
|
25
33
|
const MEG_SIG_PREFIX = "crawlcheck-meg-manifest-v1\n";
|
|
@@ -136,7 +144,7 @@ export async function otsInspect(u8) {
|
|
|
136
144
|
return { digest: hex(digest), attestations };
|
|
137
145
|
}
|
|
138
146
|
// ── the bundle ────────────────────────────────────────────────────────────────
|
|
139
|
-
|
|
147
|
+
async function verifyBundle0(b) {
|
|
140
148
|
if (!subtle)
|
|
141
149
|
throw new Error("this runtime has no WebCrypto (crypto.subtle)");
|
|
142
150
|
const checks = [];
|
|
@@ -274,7 +282,7 @@ export async function verifyBundle(b) {
|
|
|
274
282
|
// verification verdict, and is signed with the observer's Ed25519 key over sha256(canonical JSON of everything but
|
|
275
283
|
// receipt_id and signature). Change any field and receipt_hash fails; swap the key and key_id fails.
|
|
276
284
|
const RCPT_SIG_PREFIX = "crawlcheck-receipt-v1\n";
|
|
277
|
-
|
|
285
|
+
async function verifyReceipt0(rc) {
|
|
278
286
|
if (!subtle)
|
|
279
287
|
throw new Error("this runtime has no WebCrypto (crypto.subtle)");
|
|
280
288
|
const checks = [];
|
|
@@ -322,7 +330,7 @@ export async function verifyReceipt(rc) {
|
|
|
322
330
|
// export_sha256 and signature); then every bundle and every receipt inside it is verified exactly as it would be on
|
|
323
331
|
// its own. One altered byte anywhere fails export_hash; one altered bundle fails its own checks.
|
|
324
332
|
const EXPORT_SIG_PREFIX = "crawlcheck-evidence-export-v1\n";
|
|
325
|
-
|
|
333
|
+
async function verifyExport0(x) {
|
|
326
334
|
if (!subtle)
|
|
327
335
|
throw new Error("this runtime has no WebCrypto (crypto.subtle)");
|
|
328
336
|
const checks = [];
|
|
@@ -432,7 +440,7 @@ export function dspVerdict(m) {
|
|
|
432
440
|
return { verdict: "fixed_since", effect: "stands_for_record", rule: "the rule holds over the original bytes, and every network now finds the condition gone: correct when made, fixed since" };
|
|
433
441
|
return { verdict: "no_longer_observed", effect: "stands_for_record", rule: "every network now finds the condition gone, and the original bytes could not be re-checked, so whether it was ever wrong cannot be decided" };
|
|
434
442
|
}
|
|
435
|
-
|
|
443
|
+
async function verifyResolution0(x) {
|
|
436
444
|
if (!subtle)
|
|
437
445
|
throw new Error("this runtime has no WebCrypto (crypto.subtle)");
|
|
438
446
|
const checks = [];
|
|
@@ -483,13 +491,13 @@ export async function verifyResolution(x) {
|
|
|
483
491
|
// Signed over sha256(canonical JSON of every field except sha256 and signature) with the prefix below. A deletion
|
|
484
492
|
// record carries counts per family and the digest of the sorted deleted-key list, never the data.
|
|
485
493
|
const DATA_SIG_PREFIX = "crawlcheck-data-v1\n";
|
|
486
|
-
|
|
494
|
+
async function verifyDataDoc0(x) {
|
|
487
495
|
if (!subtle)
|
|
488
496
|
throw new Error("this runtime has no WebCrypto (crypto.subtle)");
|
|
489
497
|
const checks = [];
|
|
490
498
|
const C = (id, ok, why) => checks.push({ id, ok, why });
|
|
491
|
-
if (!x || !/^crawlcheck-(data-inventory|data-export-part|deletion-record|resolve|decision)$/.test(String(x.kind)) || !x.signature)
|
|
492
|
-
throw new Error("not a CrawlCheck data inventory, export part, deletion record, resolve answer or
|
|
499
|
+
if (!x || !/^crawlcheck-(data-inventory|data-export-part|deletion-record|resolve|decision|snapshot)$/.test(String(x.kind)) || !x.signature)
|
|
500
|
+
throw new Error("not a CrawlCheck data inventory, export part, deletion record, resolve answer, decision receipt or snapshot manifest");
|
|
493
501
|
const body = {};
|
|
494
502
|
for (const k of Object.keys(x))
|
|
495
503
|
if (k !== "sha256" && k !== "signature")
|
|
@@ -533,6 +541,10 @@ export async function verifyDataDoc(x) {
|
|
|
533
541
|
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");
|
|
534
542
|
C("decision_known", ["allow", "warn", "require_confirmation", "block", "unsupported"].indexOf(x.decision) >= 0, "decision is " + x.decision);
|
|
535
543
|
}
|
|
544
|
+
if (x.kind === "crawlcheck-snapshot") {
|
|
545
|
+
const ps = Array.isArray(x.parts) ? x.parts : [];
|
|
546
|
+
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 + ")");
|
|
547
|
+
}
|
|
536
548
|
if (x.kind === "crawlcheck-deletion-record")
|
|
537
549
|
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 + ")");
|
|
538
550
|
const ran = checks.filter((c) => c.ok !== null), failed = checks.filter((c) => c.ok === false);
|
|
@@ -541,6 +553,43 @@ export async function verifyDataDoc(x) {
|
|
|
541
553
|
// ── command line ─────────────────────────────────────────────────────────────
|
|
542
554
|
// Run as a script under ANY file name (a browser saves a second download as "crawlcheck-verify (1).mjs"); imported, it
|
|
543
555
|
// stays a library. The old test matched the file name only, so a renamed copy exited 0 having checked nothing.
|
|
556
|
+
// vt1: integrity, issuer trust and completeness, reported apart. Same semantics as @crawlcheck/sdk and the Python package.
|
|
557
|
+
export function trustOf(res, kid, publishedKids) {
|
|
558
|
+
let checks = Array.isArray(res && res.checks) ? res.checks : [];
|
|
559
|
+
let issuer = null;
|
|
560
|
+
if (Array.isArray(publishedKids)) {
|
|
561
|
+
issuer = !!kid && publishedKids.indexOf(kid) > -1;
|
|
562
|
+
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" };
|
|
563
|
+
checks = checks.some((c) => c.id === "key_published") ? checks.map((c) => c.id === "key_published" ? kc : c) : checks.concat([kc]);
|
|
564
|
+
}
|
|
565
|
+
const ran = checks.filter((c) => c.ok !== null), failed = checks.filter((c) => c.ok === false);
|
|
566
|
+
const unavailable = checks.filter((c) => c.ok === null && c.id !== "key_published").map((c) => c.id);
|
|
567
|
+
const verified = ran.length > 0 && failed.length === 0;
|
|
568
|
+
const integrity_valid = ran.some((c) => c.id !== "key_published") && checks.filter((c) => c.id !== "key_published").every((c) => c.ok !== false);
|
|
569
|
+
const accepted = integrity_valid && issuer === true;
|
|
570
|
+
return Object.assign({}, res, { checks, verified, summary: failed.length ? failed.length + " check(s) FAILED" : ran.length + " of " + checks.length + " checks ran and passed",
|
|
571
|
+
integrity_valid, issuer_trusted: issuer, accepted, accepted_strict: accepted && unavailable.length === 0,
|
|
572
|
+
assurance: unavailable.length ? "partial" : "complete", unavailable });
|
|
573
|
+
}
|
|
574
|
+
// The published Ed25519 key ids (RFC 7638 thumbprints) from the observer's key directory.
|
|
575
|
+
export async function publishedKeyIds(base) {
|
|
576
|
+
const r = await fetch(String(base || "https://crawlcheck.io").replace(/\/+$/, "") + "/.well-known/http-message-signatures-directory");
|
|
577
|
+
if (!r.ok)
|
|
578
|
+
throw new Error("key directory answered HTTP " + r.status);
|
|
579
|
+
const d = await r.json(), out = [];
|
|
580
|
+
for (const k of (d && d.keys) || []) {
|
|
581
|
+
if (!k || !k.x)
|
|
582
|
+
continue;
|
|
583
|
+
out.push(b64u(await sha256(enc.encode(JSON.stringify({ crv: k.crv, kty: k.kty, x: k.x })))));
|
|
584
|
+
}
|
|
585
|
+
return out;
|
|
586
|
+
}
|
|
587
|
+
const kidOf = (o) => (o && o.signature && o.signature.kid) || (o && o.manifest && o.manifest.signature && o.manifest.signature.kid) || undefined;
|
|
588
|
+
export async function verifyBundle(b, opts) { return trustOf(await verifyBundle0(b), b && b.manifest && b.manifest.signature && b.manifest.signature.kid, opts && opts.publishedKids); }
|
|
589
|
+
export async function verifyReceipt(rc, opts) { return trustOf(await verifyReceipt0(rc), rc && rc.signature && rc.signature.kid, opts && opts.publishedKids); }
|
|
590
|
+
export async function verifyExport(x, opts) { return trustOf(await verifyExport0(x), kidOf(x), opts && opts.publishedKids); }
|
|
591
|
+
export async function verifyResolution(x, opts) { return trustOf(await verifyResolution0(x), kidOf(x), opts && opts.publishedKids); }
|
|
592
|
+
export async function verifyDataDoc(x, opts) { return trustOf(await verifyDataDoc0(x), kidOf(x), opts && opts.publishedKids); }
|
|
544
593
|
const isMain = typeof process !== "undefined" && !!(process.argv && process.argv[1]) && await (async () => { try {
|
|
545
594
|
const { pathToFileURL } = await import("node:url");
|
|
546
595
|
const { realpathSync } = await import("node:fs");
|
|
@@ -550,9 +599,10 @@ catch (e) {
|
|
|
550
599
|
return false;
|
|
551
600
|
} })();
|
|
552
601
|
if (isMain) {
|
|
553
|
-
const
|
|
602
|
+
const online = process.argv.includes("--online");
|
|
603
|
+
const f = process.argv.slice(2).filter((a) => a !== "--online")[0];
|
|
554
604
|
if (!f) {
|
|
555
|
-
console.log("usage: node crawlcheck-verify.mjs <bundle.json | receipt.json | export.json | resolution.json | deletion.json | answer.json | decision.json>");
|
|
605
|
+
console.log("usage: node crawlcheck-verify.mjs <bundle.json | receipt.json | export.json | resolution.json | deletion.json | answer.json | decision.json | manifest.json>");
|
|
556
606
|
process.exit(2);
|
|
557
607
|
}
|
|
558
608
|
const { readFile } = await import("node:fs/promises");
|
|
@@ -560,8 +610,18 @@ if (isMain) {
|
|
|
560
610
|
const isRc = doc && (doc.kind === "crawlcheck-remediation-receipt" || (doc.receipt && doc.receipt.kind === "crawlcheck-remediation-receipt"));
|
|
561
611
|
const isEx = doc && doc.kind === "crawlcheck-evidence-export";
|
|
562
612
|
const isDr = doc && doc.kind === "crawlcheck-dispute-resolution";
|
|
563
|
-
const isDt = doc && /^crawlcheck-(data-inventory|data-export-part|deletion-record|resolve|decision)$/.test(String(doc.kind));
|
|
564
|
-
|
|
613
|
+
const isDt = doc && /^crawlcheck-(data-inventory|data-export-part|deletion-record|resolve|decision|snapshot)$/.test(String(doc.kind));
|
|
614
|
+
let kids;
|
|
615
|
+
if (online) {
|
|
616
|
+
try {
|
|
617
|
+
kids = await publishedKeyIds();
|
|
618
|
+
}
|
|
619
|
+
catch (e) {
|
|
620
|
+
console.log("could not read the published key directory: " + e.message);
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
const vo = { publishedKids: kids };
|
|
624
|
+
const res = isDt ? await verifyDataDoc(doc, vo) : isDr ? await verifyResolution(doc, vo) : isEx ? await verifyExport(doc, vo) : isRc ? await verifyReceipt(doc.kind ? doc : doc.receipt, vo) : await verifyBundle(doc, vo);
|
|
565
625
|
const mark = (ok) => ok === true ? "PASS" : ok === false ? "FAIL" : " -- ";
|
|
566
626
|
if (isDt)
|
|
567
627
|
console.log("CrawlCheck " + doc.kind.replace("crawlcheck-", "").replace(/-/g, " ") + ": " + (doc.domain || "?") + " " + String(doc.sha256 || "").slice(0, 16) + "…");
|
|
@@ -582,5 +642,7 @@ if (isMain) {
|
|
|
582
642
|
for (const r of res.receipts)
|
|
583
643
|
console.log(mark(r.verified) + " receipt " + String(r.id || "?").slice(0, 20) + (r.failed && r.failed.length ? " failed: " + r.failed.join(", ") : ""));
|
|
584
644
|
console.log(res.summary);
|
|
645
|
+
console.log("integrity " + (res.integrity_valid ? "valid" : "NOT valid") + " · issuer " + (res.issuer_trusted === true ? "trusted (key published)" : res.issuer_trusted === false ? "NOT trusted (key not published)" : "not checked offline: run with --online") + " · assurance " + res.assurance + (res.unavailable.length ? " (" + res.unavailable.join(", ") + " could not run)" : ""));
|
|
646
|
+
console.log(res.accepted ? (res.accepted_strict ? "ACCEPTED" : "ACCEPTED, partial assurance") : "NOT ACCEPTED" + (res.integrity_valid && res.issuer_trusted === null ? ": issuer unknown offline" : ""));
|
|
585
647
|
process.exit(res.checks.some((c) => c.ok === false) ? 1 : 0);
|
|
586
648
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@crawlcheck/sdk",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.4",
|
|
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",
|
package/test/sdk.test.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Runs against the live API. npm test
|
|
2
2
|
import test from "node:test"; import assert from "node:assert/strict";
|
|
3
|
-
import { CrawlCheck, verifyBundle, verifyReceipt, CrawlCheckError, verifyDocument } from "../dist/index.js";
|
|
3
|
+
import { CrawlCheck, verifyBundle, verifyReceipt, CrawlCheckError, verifyDocument, normalizeDomain, receiptHeader } from "../dist/index.js";
|
|
4
4
|
const c = new CrawlCheck();
|
|
5
5
|
|
|
6
6
|
test("conformance: every published fixture gives its expected output through this package's verifier", async () => {
|
|
@@ -68,3 +68,21 @@ test("live preflight: the decision receipt verifies offline, its key is publishe
|
|
|
68
68
|
assert.equal((await verifyReceipt(foreign)).verified, true, "self-consistent offline");
|
|
69
69
|
assert.ok(!ids.includes(foreign.signature.kid), "but not a CrawlCheck key, so never accepted");
|
|
70
70
|
});
|
|
71
|
+
|
|
72
|
+
test("receipt header: a site accepts the receipt for its own host only", async () => {
|
|
73
|
+
const rc = await c.preflight("github.com", "read", { agent: "crawlcheck-sdk-test" });
|
|
74
|
+
const hv = receiptHeader(rc)["CrawlCheck-Receipt"];
|
|
75
|
+
assert.match(hv, /^v=1; sha256=[0-9a-f]{64}$/);
|
|
76
|
+
const ok = await c.checkReceiptHeader(hv, "https://www.github.com/x");
|
|
77
|
+
assert.equal(ok.accept, rc.decision === "allow" || rc.decision === "warn", JSON.stringify(ok.why));
|
|
78
|
+
assert.equal((await c.checkReceiptHeader(hv, "gitlab.com")).accept, false);
|
|
79
|
+
assert.equal((await c.checkReceiptHeader("v=1; sha256=" + "c".repeat(64), "github.com")).accept, false);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("private lookup sends only a hash prefix and verifies what it finds", async () => {
|
|
83
|
+
const r = await c.resolvePrivate("github.com");
|
|
84
|
+
assert.ok(r.ready === true || r.ready === false);
|
|
85
|
+
if (r.found) { assert.equal(r.accepted, true); assert.equal(r.answer.domain, "github.com"); }
|
|
86
|
+
assert.equal(normalizeDomain("HTTPS://WWW.Example.com:443/a?b"), "example.com");
|
|
87
|
+
});
|
|
88
|
+
|