@crawlcheck/sdk 1.7.0 → 1.9.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/README.md +8 -0
- package/dist/index.d.ts +54 -3
- package/dist/index.js +41 -5
- package/dist/log.d.ts +1 -0
- package/dist/log.js +28 -5
- package/dist/mcp.js +5 -1
- package/dist/verify.d.ts +62 -0
- package/dist/verify.js +146 -0
- package/package.json +1 -1
- package/test/fixtures/guard-acceptance.json +1392 -449
- package/test/fixtures/key-policy.json +1983 -0
- package/test/guard-acceptance.test.js +1 -0
- package/test/key-policy.test.js +20 -0
- package/test/mcp.test.js +8 -1
- package/test/split-view.test.js +14 -0
package/README.md
CHANGED
|
@@ -18,6 +18,14 @@ const { proceed, receipt } = await cc.guard("example.com", "read", { agent: "MyA
|
|
|
18
18
|
|
|
19
19
|
Every verification returns `integrity_valid`, `issuer_trusted` and `accepted`. Act on `accepted`: it needs the signing key to be one CrawlCheck publishes, so a document someone signed with their own key is `verified` offline but never `accepted`. The client methods (`verifyDocument`, `verifyOffline`, `verifyReceiptOffline`, `guard`) check the published key directory for you.
|
|
20
20
|
|
|
21
|
+
Since 1.9.0 `logCheck` reads the chain of signed heads 200 per request (`/api/v1/log/sths`) instead of one per batch, so a first check on a client holding no heads takes one or two requests; an older server is still walked one head per request.
|
|
22
|
+
|
|
23
|
+
Since 1.8.0 an MCP connect decision names the server and, with a lockfile, its approved `tools_sha256` (`preflight(domain, "connect", { mcp: { url, tools_sha256 } })`; `withPreflight` sends it for you). CrawlCheck reads the live tools from its own network: a changed tool list comes back as `block`, and the signed receipt's `assessed` block says exactly which endpoint and tool state it covered.
|
|
24
|
+
|
|
25
|
+
Since 1.8.0 they also apply the signing policy (https://crawlcheck.io/keys#policy): which key purpose may sign which object at which time (decision receipts move to the decision key at 2026-11-09 17:00 UTC), retired keys (valid only for documents signed before they stopped) and compromised keys (never valid on their signature alone). The result carries `key_policy_valid`, and `accepted` needs it. Offline, build the same input from copies you saved: `keyPolicyFrom(savedDirectory, savedRotationRecord)`, then `applyKeyPolicy(result, doc, kp)` or `guard(domain, action, { onlineKeys: false, keyPolicy })`. A saved copy cannot know a key retired or compromised after you saved it.
|
|
26
|
+
|
|
27
|
+
`accepted` says who signed a document. It is not permission to act: a valid receipt can say `block`, `warn` or `require_confirmation`. `guard()` decides that part.
|
|
28
|
+
|
|
21
29
|
`preflight(domain, action, { agent, template, policy })` returns the signed receipt without deciding for you; `verifyDocument(doc)` verifies any signed resolve answer or decision receipt offline. `resolve(domain)` returns the full signed ResolveV1 answer.
|
|
22
30
|
|
|
23
31
|
## Give your agent the check as a tool
|
package/dist/index.d.ts
CHANGED
|
@@ -16,8 +16,50 @@ export interface VerifyCheck {
|
|
|
16
16
|
export interface Trust {
|
|
17
17
|
integrity_valid: boolean;
|
|
18
18
|
issuer_trusted: boolean | null;
|
|
19
|
-
accepted: boolean;
|
|
19
|
+
accepted: boolean; /** conf2: the signing policy accepts this key for this object at its signing time (purpose, retired, compromised). null: not decided. */
|
|
20
|
+
key_policy_valid?: boolean | null; /** conf2: stable reason: ok, ok_bound, key_wrong_purpose, key_not_yet_active, key_retired, key_compromised, key_unknown, no_signing_time, or related_<code> for a key the document leans on. */
|
|
21
|
+
key_policy_code?: string | null;
|
|
22
|
+
}
|
|
23
|
+
/** conf2: the key directory and rotation record as the signing policy reads them (keyPolicyFrom / publishedKeyPolicy). */
|
|
24
|
+
export interface KeyPolicyInput {
|
|
25
|
+
v: 2;
|
|
26
|
+
kids: string[];
|
|
27
|
+
keys: Record<string, {
|
|
28
|
+
purpose: string;
|
|
29
|
+
start: number | null;
|
|
30
|
+
}>;
|
|
31
|
+
retired: Record<string, {
|
|
32
|
+
purpose: string;
|
|
33
|
+
stopped: number | null;
|
|
34
|
+
}>;
|
|
35
|
+
compromised: Record<string, number>;
|
|
36
|
+
rotation: boolean;
|
|
37
|
+
read_at?: string;
|
|
38
|
+
source?: string;
|
|
39
|
+
}
|
|
40
|
+
export interface KeyPolicyResult {
|
|
41
|
+
ok: boolean | null;
|
|
42
|
+
code: string;
|
|
43
|
+
known: boolean;
|
|
44
|
+
purpose: string | null;
|
|
45
|
+
expected: string | null;
|
|
46
|
+
why: string;
|
|
20
47
|
}
|
|
48
|
+
/** The signing policy as data (https://crawlcheck.io/keys#policy): object purposes, purpose chains, start times. */
|
|
49
|
+
export declare const KEY_POLICY: {
|
|
50
|
+
v: 2;
|
|
51
|
+
object_purpose: Record<string, string>;
|
|
52
|
+
chains: Record<string, string[]>;
|
|
53
|
+
starts: Record<string, string | null>;
|
|
54
|
+
bound: Record<string, string>;
|
|
55
|
+
purpose_aliases: Record<string, string>;
|
|
56
|
+
};
|
|
57
|
+
/** Build the policy input from a key directory you hold and, optionally, the rotation record saved with it. */
|
|
58
|
+
export declare const keyPolicyFrom: (directory: unknown, rotation?: unknown) => Promise<KeyPolicyInput>;
|
|
59
|
+
/** Does the signing policy accept this document's signing key for this object at its signing time? */
|
|
60
|
+
export declare const keyPolicyCheck: (doc: unknown, kp: KeyPolicyInput) => KeyPolicyResult;
|
|
61
|
+
/** Apply the signing policy to a verification: key_published (retired keys included), key_purpose, and the trust fields. */
|
|
62
|
+
export declare const applyKeyPolicy: <T>(res: T, doc: unknown, kp: KeyPolicyInput) => T & Trust;
|
|
21
63
|
export interface BundleVerification extends Partial<Trust> {
|
|
22
64
|
verified: boolean;
|
|
23
65
|
summary: string;
|
|
@@ -197,7 +239,11 @@ export interface PreflightOptions {
|
|
|
197
239
|
template?: string;
|
|
198
240
|
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
241
|
nonce?: string; /** The exact operation this decision is for (method + url, or an MCP tool); signed into the receipt. */
|
|
200
|
-
operation?: Operation;
|
|
242
|
+
operation?: Operation; /** An MCP server on this domain and the tool-list hash you approved (mcp-lockfile tools_sha256). The decision then reads the live tools, blocks when they differ, and its signed `assessed` block names exactly what it covered. https://crawlcheck.io/spec/mcp-lockfile */
|
|
243
|
+
mcp?: {
|
|
244
|
+
url: string;
|
|
245
|
+
tools_sha256?: string;
|
|
246
|
+
};
|
|
201
247
|
}
|
|
202
248
|
/** 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
249
|
export interface Operation {
|
|
@@ -236,6 +282,8 @@ export interface GuardOptions extends PreflightOptions {
|
|
|
236
282
|
onlineKeys?: boolean;
|
|
237
283
|
/** Pinned CrawlCheck key ids, for offline use with onlineKeys: false. */
|
|
238
284
|
publishedKids?: string[];
|
|
285
|
+
/** conf2: a pinned key policy (keyPolicyFrom(savedDirectory, savedRotation)), for offline use with onlineKeys: false. Checks key purpose too. */
|
|
286
|
+
keyPolicy?: KeyPolicyInput;
|
|
239
287
|
/** Testing clock (ms since epoch). Default Date.now(). */
|
|
240
288
|
now?: number;
|
|
241
289
|
}
|
|
@@ -619,7 +667,10 @@ export declare class CrawlCheck {
|
|
|
619
667
|
}): Promise<RevocationSync>;
|
|
620
668
|
/** The policy engine: a signed decision receipt (allow | warn | require_confirmation | block | unsupported). A policy can only make the decision stricter. */
|
|
621
669
|
preflight(domain: string, action?: Action, opts?: PreflightOptions): Promise<DecisionReceipt>;
|
|
622
|
-
|
|
670
|
+
private kpCache;
|
|
671
|
+
/** conf2: the key directory and rotation record, read now (cached 10 minutes), as the signing policy reads them. */
|
|
672
|
+
publishedKeyPolicy(): Promise<KeyPolicyInput>;
|
|
673
|
+
/** Verify a signed document here. With onlineKeys (default), its signing key must also be in the published directory and the signing policy must accept it. */
|
|
623
674
|
verifyDocument(doc: unknown, onlineKeys?: boolean): Promise<DocumentVerification & Trust>;
|
|
624
675
|
/**
|
|
625
676
|
* Preflight, verify the receipt, check it is for THIS operation, and decide. Proceeds on allow; warn only with allowWarn;
|
package/dist/index.js
CHANGED
|
@@ -3,6 +3,14 @@ import * as L from "./log.js";
|
|
|
3
3
|
import * as S from "./staple.js";
|
|
4
4
|
import * as M from "./mcp.js";
|
|
5
5
|
import * as R from "./revocations.js";
|
|
6
|
+
/** The signing policy as data (https://crawlcheck.io/keys#policy): object purposes, purpose chains, start times. */
|
|
7
|
+
export const KEY_POLICY = V.KEY_POLICY;
|
|
8
|
+
/** Build the policy input from a key directory you hold and, optionally, the rotation record saved with it. */
|
|
9
|
+
export const keyPolicyFrom = V.keyPolicyFrom;
|
|
10
|
+
/** Does the signing policy accept this document's signing key for this object at its signing time? */
|
|
11
|
+
export const keyPolicyCheck = V.keyPolicyCheck;
|
|
12
|
+
/** Apply the signing policy to a verification: key_published (retired keys included), key_purpose, and the trust fields. */
|
|
13
|
+
export const applyKeyPolicy = V.applyKeyPolicy;
|
|
6
14
|
function withTrust(v, kid, published, related = []) {
|
|
7
15
|
let checks = v.checks;
|
|
8
16
|
let issuer = null;
|
|
@@ -302,11 +310,34 @@ export class CrawlCheck {
|
|
|
302
310
|
b.nonce = opts.nonce;
|
|
303
311
|
if (opts.operation)
|
|
304
312
|
b.operation = opts.operation;
|
|
313
|
+
if (opts.mcp && opts.mcp.url)
|
|
314
|
+
b.mcp = (opts.mcp.tools_sha256 ? { url: opts.mcp.url, tools_sha256: opts.mcp.tools_sha256 } : { url: opts.mcp.url });
|
|
305
315
|
return this.post("/api/v1/preflight", b);
|
|
306
316
|
}
|
|
307
|
-
|
|
317
|
+
kpCache = null;
|
|
318
|
+
/** conf2: the key directory and rotation record, read now (cached 10 minutes), as the signing policy reads them. */
|
|
319
|
+
async publishedKeyPolicy() {
|
|
320
|
+
if (this.kpCache && Date.now() - this.kpCache.at < 600e3)
|
|
321
|
+
return this.kpCache.kp;
|
|
322
|
+
const d = await this.req("GET", "/.well-known/http-message-signatures-directory");
|
|
323
|
+
let rot = null;
|
|
324
|
+
try {
|
|
325
|
+
const r = await this.f(this.base + "/.well-known/security-key-rotation.json", { headers: { accept: "application/json" } });
|
|
326
|
+
if (r.ok)
|
|
327
|
+
rot = await r.json();
|
|
328
|
+
}
|
|
329
|
+
catch {
|
|
330
|
+
rot = null;
|
|
331
|
+
}
|
|
332
|
+
const kp = Object.assign(await keyPolicyFrom(d, rot), { read_at: new Date().toISOString(), source: "online" });
|
|
333
|
+
this.kpCache = { at: Date.now(), kp };
|
|
334
|
+
return kp;
|
|
335
|
+
}
|
|
336
|
+
/** Verify a signed document here. With onlineKeys (default), its signing key must also be in the published directory and the signing policy must accept it. */
|
|
308
337
|
async verifyDocument(doc, onlineKeys = true) {
|
|
309
|
-
|
|
338
|
+
const kp = onlineKeys ? await this.publishedKeyPolicy() : null;
|
|
339
|
+
const v = withTrust(await V.verifyDataDoc(doc), doc?.signature?.kid, kp ? kp.kids : null, V.relatedKids(doc));
|
|
340
|
+
return kp && doc?.signature ? applyKeyPolicy(v, doc, kp) : v;
|
|
310
341
|
}
|
|
311
342
|
/**
|
|
312
343
|
* Preflight, verify the receipt, check it is for THIS operation, and decide. Proceeds on allow; warn only with allowWarn;
|
|
@@ -333,11 +364,14 @@ export class CrawlCheck {
|
|
|
333
364
|
if (!receipt || typeof receipt !== "object")
|
|
334
365
|
return no(scope.why);
|
|
335
366
|
const online = opts.onlineKeys ?? true;
|
|
336
|
-
if (!online && !(Array.isArray(opts.publishedKids) && opts.publishedKids.length))
|
|
337
|
-
return no(["onlineKeys is false and no publishedKids were pinned: the signer cannot be checked"], receipt);
|
|
367
|
+
if (!online && !opts.keyPolicy && !(Array.isArray(opts.publishedKids) && opts.publishedKids.length))
|
|
368
|
+
return no(["onlineKeys is false and no keyPolicy or publishedKids were pinned: the signer cannot be checked"], receipt);
|
|
338
369
|
let verification;
|
|
339
370
|
try {
|
|
340
|
-
|
|
371
|
+
const kp = online ? await this.publishedKeyPolicy() : opts.keyPolicy || null; // conf2: purpose, retired and compromised keys, not only "published"
|
|
372
|
+
verification = withTrust(await V.verifyDataDoc(receipt), receipt?.signature?.kid, kp ? kp.kids : opts.publishedKids, V.relatedKids(receipt));
|
|
373
|
+
if (kp)
|
|
374
|
+
verification = applyKeyPolicy(verification, receipt, kp);
|
|
341
375
|
}
|
|
342
376
|
catch (e) {
|
|
343
377
|
return no(["the receipt could not be verified: " + (e?.message || e)].concat(scope.why), receipt);
|
|
@@ -347,6 +381,8 @@ export class CrawlCheck {
|
|
|
347
381
|
why.push("integrity failed: " + verification.checks.filter((c) => c.ok === false).map((c) => c.id).join(", "));
|
|
348
382
|
if (verification.issuer_trusted !== true)
|
|
349
383
|
why.push("signed by a key that is not in CrawlCheck's published key directory");
|
|
384
|
+
else if (verification.key_policy_valid === false)
|
|
385
|
+
why.push("the signing policy rejects this key for a decision receipt: " + (verification.checks.find((c) => c.id === "key_purpose") || {}).why);
|
|
350
386
|
if (why.length)
|
|
351
387
|
return no(why, receipt, verification);
|
|
352
388
|
const d = receipt.decision;
|
package/dist/log.d.ts
CHANGED
|
@@ -110,6 +110,7 @@ export function verifyWitnessToken(token: any, sthSha256: any, jwks: any): Promi
|
|
|
110
110
|
* holds (at most opts.maxWalk batches), observing every link, so any head this client was shown that the witness's
|
|
111
111
|
* chain does not contain surfaces as a fork. Heads newer than the witnessed one are listed as unwitnessed.
|
|
112
112
|
*/
|
|
113
|
+
export function walkHeads(get: any, hi: any, lo: any): Promise<any[]>;
|
|
113
114
|
export function logCheck(store: any, get: any, opts?: {}): Promise<{
|
|
114
115
|
consistent: boolean;
|
|
115
116
|
forks: any;
|
package/dist/log.js
CHANGED
|
@@ -146,6 +146,32 @@ export async function verifyWitnessToken(token, sthSha256, jwks) {
|
|
|
146
146
|
* holds (at most opts.maxWalk batches), observing every link, so any head this client was shown that the witness's
|
|
147
147
|
* chain does not contain surfaces as a fork. Heads newer than the witnessed one are listed as unwitnessed.
|
|
148
148
|
*/
|
|
149
|
+
// Signed heads for batches hi down to lo, newest first. Since 1.9.0 one request per 200 batches (/api/v1/log/sths);
|
|
150
|
+
// a server without that route is walked one head per request as before.
|
|
151
|
+
export async function walkHeads(get, hi, lo) {
|
|
152
|
+
const out = [];
|
|
153
|
+
let k = hi;
|
|
154
|
+
while (k >= lo) {
|
|
155
|
+
const n = Math.min(200, k - lo + 1);
|
|
156
|
+
const d = await get("/api/v1/log/sths?to=" + k + "&n=" + n).catch(() => null);
|
|
157
|
+
if (!d || !Array.isArray(d.sths))
|
|
158
|
+
break;
|
|
159
|
+
const got = {};
|
|
160
|
+
for (const s of d.sths)
|
|
161
|
+
if (s && Number.isInteger(s.batch))
|
|
162
|
+
got[s.batch] = s;
|
|
163
|
+
for (let b = k; b > k - n; b--)
|
|
164
|
+
if (got[b])
|
|
165
|
+
out.push(got[b]);
|
|
166
|
+
k -= n;
|
|
167
|
+
}
|
|
168
|
+
for (; k >= lo; k--) {
|
|
169
|
+
const d = await get("/api/v1/log/sth/" + k).catch(() => null);
|
|
170
|
+
if (d && d.sth)
|
|
171
|
+
out.push(d.sth);
|
|
172
|
+
}
|
|
173
|
+
return out;
|
|
174
|
+
}
|
|
149
175
|
export async function logCheck(store, get, opts = {}) {
|
|
150
176
|
const before = store.forks.length, o = { publishedKids: opts.publishedKids };
|
|
151
177
|
const log = await get("/api/v1/log");
|
|
@@ -166,11 +192,8 @@ export async function logCheck(store, get, opts = {}) {
|
|
|
166
192
|
store.witnessed = { batch: w.batch, sha256: sth.sha256, at: witnessed.at, run_id: witnessed.run_id };
|
|
167
193
|
const held = Object.keys(store.heads).map(Number).filter((k) => k < w.batch);
|
|
168
194
|
const lo = held.length ? Math.max(Math.min(...held), w.batch - (opts.maxWalk || 200)) : w.batch;
|
|
169
|
-
for (
|
|
170
|
-
|
|
171
|
-
if (d && d.sth)
|
|
172
|
-
await logObserve(store, d.sth, o);
|
|
173
|
-
}
|
|
195
|
+
for (const s of await walkHeads(get, w.batch - 1, lo))
|
|
196
|
+
await logObserve(store, s, o);
|
|
174
197
|
}
|
|
175
198
|
const wb = store.witnessed ? store.witnessed.batch : -1, now = Date.now(), maxAge = (opts.maxUnwitnessedMinutes || 120) * 60000;
|
|
176
199
|
const unwitnessed = Object.keys(store.heads).map(Number).filter((k) => k > wb).sort((a, b) => a - b).map((k) => ({ batch: k, sha256: store.heads[k].sha256, created_at: store.heads[k].created_at }));
|
package/dist/mcp.js
CHANGED
|
@@ -60,8 +60,12 @@ export function mcpPreflight(cc, opts = {}) {
|
|
|
60
60
|
return false;
|
|
61
61
|
return true;
|
|
62
62
|
};
|
|
63
|
+
// tscope1: send the server URL and, with a lockfile, its approved tools_sha256, so CrawlCheck reads the live tools from its own
|
|
64
|
+
// vantage and the signed receipt names the endpoint and tool state it covered (a changed list comes back as block).
|
|
65
|
+
const mcpScope = function () { if (!state.url || !/^https:/i.test(state.url))
|
|
66
|
+
return undefined; const h = opts.lock && typeof opts.lock.tools_sha256 === "string" ? opts.lock.tools_sha256 : null; return h ? { url: state.url, tools_sha256: h } : { url: state.url }; };
|
|
63
67
|
const decide = async function (host) {
|
|
64
|
-
const g = await cc.guard(host, "connect", { template, policy: opts.policy, agent: opts.agent, confirm: opts.confirm, allowWarn: !!opts.allowWarn, onlineKeys: opts.onlineKeys });
|
|
68
|
+
const g = await cc.guard(host, "connect", { template, policy: opts.policy, agent: opts.agent, confirm: opts.confirm, allowWarn: !!opts.allowWarn, onlineKeys: opts.onlineKeys, mcp: mcpScope() });
|
|
65
69
|
state.receipt = g.receipt;
|
|
66
70
|
state.verification = g.verification;
|
|
67
71
|
state.decidedAt = Date.now();
|
package/dist/verify.d.ts
CHANGED
|
@@ -20,6 +20,42 @@ export function verifyThresholdSigners(x: any): Promise<{
|
|
|
20
20
|
why: never[];
|
|
21
21
|
}[]>;
|
|
22
22
|
export function relatedKids(x: any): any[];
|
|
23
|
+
export function keyPolicyFrom(directory: any, rotation: any): Promise<{
|
|
24
|
+
v: number;
|
|
25
|
+
kids: string[];
|
|
26
|
+
keys: {};
|
|
27
|
+
retired: {};
|
|
28
|
+
compromised: {};
|
|
29
|
+
rotation: boolean;
|
|
30
|
+
}>;
|
|
31
|
+
export function signedAtOf(x: any): number | null;
|
|
32
|
+
export function keyPolicyCheck(x: any, kp: any): {
|
|
33
|
+
ok: false | null;
|
|
34
|
+
code: string | undefined;
|
|
35
|
+
known: boolean;
|
|
36
|
+
purpose: any;
|
|
37
|
+
expected: null;
|
|
38
|
+
why: string | undefined;
|
|
39
|
+
} | {
|
|
40
|
+
ok: boolean;
|
|
41
|
+
code: string;
|
|
42
|
+
known: boolean;
|
|
43
|
+
purpose: any;
|
|
44
|
+
expected: any;
|
|
45
|
+
why: string;
|
|
46
|
+
};
|
|
47
|
+
export function applyKeyPolicy(res: any, x: any, kp: any): any;
|
|
48
|
+
export function publishedKeyPolicy(base: any): Promise<{
|
|
49
|
+
v: number;
|
|
50
|
+
kids: string[];
|
|
51
|
+
keys: {};
|
|
52
|
+
retired: {};
|
|
53
|
+
compromised: {};
|
|
54
|
+
rotation: boolean;
|
|
55
|
+
} & {
|
|
56
|
+
read_at: string;
|
|
57
|
+
source: string;
|
|
58
|
+
}>;
|
|
23
59
|
export function trustOf(res: any, kid: any, publishedKids: any, related: any): any;
|
|
24
60
|
export function publishedKeyIds(base: any): Promise<string[]>;
|
|
25
61
|
export function verifyBundle(b: any, opts: any): Promise<any>;
|
|
@@ -28,3 +64,29 @@ export function verifyExport(x: any, opts: any): Promise<any>;
|
|
|
28
64
|
export function verifyResolution(x: any, opts: any): Promise<any>;
|
|
29
65
|
export function verifyDnsTxt(txt: any, opts: any): Promise<any>;
|
|
30
66
|
export function verifyDataDoc(x: any, opts: any): Promise<any>;
|
|
67
|
+
export namespace KEY_POLICY {
|
|
68
|
+
let v: number;
|
|
69
|
+
let object_purpose: {
|
|
70
|
+
"crawlcheck-decision": string;
|
|
71
|
+
"crawlcheck-log-sth": string;
|
|
72
|
+
"*": string;
|
|
73
|
+
};
|
|
74
|
+
let chains: {
|
|
75
|
+
documents: string[];
|
|
76
|
+
decisions: string[];
|
|
77
|
+
"log heads": string[];
|
|
78
|
+
};
|
|
79
|
+
let starts: {
|
|
80
|
+
requests: null;
|
|
81
|
+
documents: string;
|
|
82
|
+
decisions: string;
|
|
83
|
+
"log heads": string;
|
|
84
|
+
};
|
|
85
|
+
let bound: {
|
|
86
|
+
"crawlcheck-resolve": string;
|
|
87
|
+
"crawlcheck-decision": string;
|
|
88
|
+
};
|
|
89
|
+
let purpose_aliases: {
|
|
90
|
+
"requests and documents": string;
|
|
91
|
+
};
|
|
92
|
+
}
|
package/dist/verify.js
CHANGED
|
@@ -751,6 +751,152 @@ export function relatedKids(x) {
|
|
|
751
751
|
catch (e) { }
|
|
752
752
|
return out.filter((k, i) => typeof k === "string" && out.indexOf(k) === i);
|
|
753
753
|
}
|
|
754
|
+
// conf2: the signing policy (https://crawlcheck.io/keys#policy, machine copy spec/key-policy/policy.json) as code. Which key
|
|
755
|
+
// PURPOSE may sign which object at which time, retired keys, compromised keys. Same rule in the Python package.
|
|
756
|
+
export const KEY_POLICY = { v: 2,
|
|
757
|
+
object_purpose: { "crawlcheck-decision": "decisions", "crawlcheck-log-sth": "log heads", "*": "documents" },
|
|
758
|
+
chains: { documents: ["documents", "requests"], decisions: ["decisions", "documents", "requests"], "log heads": ["log heads", "documents", "requests"] },
|
|
759
|
+
starts: { requests: null, documents: "2026-10-11T00:00:00Z", decisions: "2026-11-09T17:00:00Z", "log heads": "2026-11-09T17:00:00Z" },
|
|
760
|
+
bound: { "crawlcheck-resolve": "transparency.sth", "crawlcheck-decision": "resolve" },
|
|
761
|
+
purpose_aliases: { "requests and documents": "requests" } };
|
|
762
|
+
const kpPurpose = (p) => { p = String(p || "requests"); return KEY_POLICY.purpose_aliases[p] || p; };
|
|
763
|
+
const kpMs = (v) => { if (v === null || v === undefined || v === "")
|
|
764
|
+
return null; if (typeof v === "number")
|
|
765
|
+
return v < 1e11 ? v * 1000 : v; const t = Date.parse(String(v)); return isFinite(t) ? t : null; };
|
|
766
|
+
// The key directory (and, when you have it, the rotation record) as the input every key-policy check reads.
|
|
767
|
+
export async function keyPolicyFrom(directory, rotation) {
|
|
768
|
+
const keys = {}, retired = {}, compromised = {};
|
|
769
|
+
for (const k of (directory && directory.keys) || []) {
|
|
770
|
+
if (!k || !k.x)
|
|
771
|
+
continue;
|
|
772
|
+
const kid = b64u(await sha256(enc.encode(JSON.stringify({ crv: k.crv || "Ed25519", kty: k.kty || "OKP", x: k.x }))));
|
|
773
|
+
const purpose = kpPurpose(k.crawlcheck_purpose);
|
|
774
|
+
keys[kid] = { purpose, start: kpMs(k.crawlcheck_signs_from) ?? kpMs(k.nbf) ?? kpMs(KEY_POLICY.starts[purpose]) };
|
|
775
|
+
}
|
|
776
|
+
for (const r of (rotation && rotation.retired) || [])
|
|
777
|
+
if (r && r.thumbprint)
|
|
778
|
+
retired[r.thumbprint] = { purpose: kpPurpose(r.purpose), stopped: kpMs(r.stopped_signing_at) ?? kpMs(r.retired_at) };
|
|
779
|
+
for (const c of (rotation && rotation.compromised) || [])
|
|
780
|
+
if (c && c.thumbprint)
|
|
781
|
+
compromised[c.thumbprint] = kpMs(c.compromised_since) ?? 0;
|
|
782
|
+
return { v: 2, kids: Object.keys(keys), keys, retired, compromised, rotation: !!rotation };
|
|
783
|
+
}
|
|
784
|
+
export function signedAtOf(x) {
|
|
785
|
+
const s = x && x.signature, c = [s && s.signed_at, x && x.decided_at, x && x.answered_at, x && x.sealed_at, x && x.generated_at, x && x.issued_at, x && x.at];
|
|
786
|
+
for (const v of c) {
|
|
787
|
+
const t = kpMs(v);
|
|
788
|
+
if (t !== null)
|
|
789
|
+
return t;
|
|
790
|
+
}
|
|
791
|
+
return null;
|
|
792
|
+
}
|
|
793
|
+
function kpBoundKid(x) {
|
|
794
|
+
const path = KEY_POLICY.bound[x && x.kind];
|
|
795
|
+
if (!path)
|
|
796
|
+
return null;
|
|
797
|
+
const o = path.split(".").reduce((a, p) => (a && typeof a === "object" ? a[p] : undefined), x);
|
|
798
|
+
return (o && o.signature && o.signature.kid) || (o && typeof o.kid === "string" ? o.kid : null);
|
|
799
|
+
}
|
|
800
|
+
// One key against the policy at time t. Returns { known, ok, purpose, why } (ok null: could not be decided).
|
|
801
|
+
function kpKey(kid, t, kp) {
|
|
802
|
+
if (!kid)
|
|
803
|
+
return { known: false, ok: false, code: "key_unknown", why: "no signing key named" };
|
|
804
|
+
if (kp.compromised[kid] !== undefined)
|
|
805
|
+
return { known: false, ok: false, code: "key_compromised", why: "key " + kid + " is listed as compromised since " + new Date(kp.compromised[kid]).toISOString() + ": its signature alone proves nothing (" + (t !== null && t >= kp.compromised[kid] ? "signed after that moment" : "only an outside anchor still proves documents signed before it") + ")" };
|
|
806
|
+
const k = kp.keys[kid];
|
|
807
|
+
if (k) {
|
|
808
|
+
if (t !== null && k.start !== null && t < k.start)
|
|
809
|
+
return { known: true, ok: false, purpose: k.purpose, code: "key_not_yet_active", why: "key " + kid + " (" + k.purpose + ") signs nothing before " + new Date(k.start).toISOString() };
|
|
810
|
+
return { known: true, ok: true, purpose: k.purpose, start: k.start };
|
|
811
|
+
}
|
|
812
|
+
const r = kp.retired[kid];
|
|
813
|
+
if (r) {
|
|
814
|
+
if (t === null)
|
|
815
|
+
return { known: true, ok: null, purpose: r.purpose, code: "no_signing_time", why: "retired key, and the document carries no signing time" };
|
|
816
|
+
return r.stopped !== null && t <= r.stopped ? { known: true, ok: true, purpose: r.purpose, retired: true, why: "retired key, signed before it stopped signing (" + new Date(r.stopped).toISOString() + ")" } : { known: false, ok: false, code: "key_retired", why: "key " + kid + " stopped signing at " + (r.stopped !== null ? new Date(r.stopped).toISOString() : "an unknown time") + "; this was signed after" };
|
|
817
|
+
}
|
|
818
|
+
return { known: false, ok: false, code: "key_unknown", why: "key " + kid + " is not in the published key directory" + (kp.rotation ? " or the rotation record" : "") };
|
|
819
|
+
}
|
|
820
|
+
function kpExpected(objPurpose, t, kp) {
|
|
821
|
+
for (const p of KEY_POLICY.chains[objPurpose]) {
|
|
822
|
+
if (p === "requests")
|
|
823
|
+
return p;
|
|
824
|
+
if (Object.keys(kp.keys).some((kid) => kp.keys[kid].purpose === p && kp.compromised[kid] === undefined && (t === null || kp.keys[kid].start === null || kp.keys[kid].start <= t)))
|
|
825
|
+
return p;
|
|
826
|
+
}
|
|
827
|
+
return "requests";
|
|
828
|
+
}
|
|
829
|
+
// Does the policy accept this signature on this document? { ok: true | false | null, code, purpose, expected, why }
|
|
830
|
+
// code (stable): ok, ok_bound, key_wrong_purpose, key_not_yet_active, key_retired, key_compromised, key_unknown, no_signing_time;
|
|
831
|
+
// applyKeyPolicy adds related_<code> when the key a document leans on (its answer, its log head) fails.
|
|
832
|
+
export function keyPolicyCheck(x, kp) {
|
|
833
|
+
const kid = x && x.signature && x.signature.kid, t = signedAtOf(x);
|
|
834
|
+
const objPurpose = KEY_POLICY.object_purpose[x && x.kind] || KEY_POLICY.object_purpose["*"];
|
|
835
|
+
const k = kpKey(kid, t, kp);
|
|
836
|
+
if (k.ok !== true)
|
|
837
|
+
return { ok: k.ok, code: k.code, known: k.known, purpose: k.purpose || null, expected: null, why: k.why };
|
|
838
|
+
if (t === null)
|
|
839
|
+
return { ok: null, code: "no_signing_time", known: true, purpose: k.purpose, expected: null, why: "the document carries no signing time, so the key's purpose cannot be checked against the policy" };
|
|
840
|
+
const expected = kpExpected(objPurpose, t, kp), chain = KEY_POLICY.chains[objPurpose], at = new Date(t).toISOString();
|
|
841
|
+
if (k.purpose === expected)
|
|
842
|
+
return { ok: true, code: "ok", known: true, purpose: k.purpose, expected, why: "a " + k.purpose + " key signs " + objPurpose + " at " + at + (k.retired ? " (" + k.why + ")" : "") };
|
|
843
|
+
const later = chain.indexOf(k.purpose) > chain.indexOf(expected);
|
|
844
|
+
const bound = kpBoundKid(x), decisionKeyLive = objPurpose === "decisions" && expected === "decisions";
|
|
845
|
+
if (later && bound && bound === kid && !decisionKeyLive)
|
|
846
|
+
return { ok: true, code: "ok_bound", known: true, purpose: k.purpose, expected, why: "signed by the " + k.purpose + " key that signed the object it is bound to (" + KEY_POLICY.bound[x.kind] + "), as the policy allows" };
|
|
847
|
+
return { ok: false, code: "key_wrong_purpose", known: true, purpose: k.purpose, expected, why: "a " + k.purpose + " key does not sign " + objPurpose + " at " + at + ": the " + expected + " key does" + (chain.indexOf(k.purpose) < 0 ? " (" + k.purpose + " keys never sign them)" : "") };
|
|
848
|
+
}
|
|
849
|
+
// Apply the policy to a verification result: key_published (retired keys included), key_purpose, and the trust fields.
|
|
850
|
+
export function applyKeyPolicy(res, x, kp) {
|
|
851
|
+
if (!res || !kp)
|
|
852
|
+
return res;
|
|
853
|
+
const main = keyPolicyCheck(x, kp), t = signedAtOf(x), rel = [];
|
|
854
|
+
let relCode = null;
|
|
855
|
+
if (x && x.kind === "crawlcheck-decision" && x.resolve && x.resolve.kid) {
|
|
856
|
+
const r = kpKey(x.resolve.kid, t, kp);
|
|
857
|
+
if (r.ok !== true) {
|
|
858
|
+
rel.push("the Resolve answer it names: " + r.why);
|
|
859
|
+
relCode = relCode || "related_" + (r.code || "key_unknown");
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
const sth = x && x.transparency && x.transparency.sth;
|
|
863
|
+
if (sth && sth.signature) {
|
|
864
|
+
const r = keyPolicyCheck(sth, kp);
|
|
865
|
+
if (r.ok === false) {
|
|
866
|
+
rel.push("its log head: " + r.why);
|
|
867
|
+
relCode = relCode || "related_" + r.code;
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
const kpub = { id: "key_published", ok: main.known && !rel.length, why: main.known && !rel.length ? "key " + (x.signature && x.signature.kid) + " is " + (kp.keys[x.signature && x.signature.kid] ? "in the published key directory" : "a retired key listed in the rotation record") + ((x && (x.kind === "crawlcheck-decision" || sth)) ? ", and so is every key it leans on" : "") : (main.known ? "it leans on a key the policy rejects: " + rel.join("; ") : main.why) + ": reject this document" };
|
|
871
|
+
const kpur = { id: "key_purpose", ok: rel.length ? false : main.ok, code: rel.length && main.ok !== false ? relCode : main.code, why: rel.length ? rel.join("; ") : main.why };
|
|
872
|
+
let checks = (res.checks || []).filter((c) => c.id !== "key_published" && c.id !== "key_purpose").concat([kpub, kpur]);
|
|
873
|
+
const ran = checks.filter((c) => c.ok !== null), failed = checks.filter((c) => c.ok === false);
|
|
874
|
+
const own = checks.filter((c) => c.id !== "key_published" && c.id !== "key_purpose");
|
|
875
|
+
const integrity_valid = own.some((c) => c.ok !== null) && own.every((c) => c.ok !== false);
|
|
876
|
+
const unavailable = checks.filter((c) => c.ok === null).map((c) => c.id);
|
|
877
|
+
const accepted = integrity_valid && kpub.ok === true && kpur.ok !== false;
|
|
878
|
+
return Object.assign({}, res, { checks, verified: ran.length > 0 && failed.length === 0, summary: failed.length ? failed.length + " check(s) FAILED" : ran.length + " of " + checks.length + " checks ran and passed",
|
|
879
|
+
integrity_valid, issuer_trusted: kpub.ok, key_policy_valid: kpur.ok, key_policy_code: kpur.code, accepted, accepted_strict: accepted && unavailable.length === 0, assurance: unavailable.length ? "partial" : "complete", unavailable });
|
|
880
|
+
}
|
|
881
|
+
// The directory and the rotation record, read now (online). The rotation record is optional: without it retired and
|
|
882
|
+
// compromised keys are unknown, and only keys still in the directory are accepted.
|
|
883
|
+
export async function publishedKeyPolicy(base) {
|
|
884
|
+
const b = String(base || "https://crawlcheck.io").replace(/\/+$/, "");
|
|
885
|
+
const r = await fetch(b + "/.well-known/http-message-signatures-directory");
|
|
886
|
+
if (!r.ok)
|
|
887
|
+
throw new Error("key directory answered HTTP " + r.status);
|
|
888
|
+
const d = await r.json();
|
|
889
|
+
let rot = null;
|
|
890
|
+
try {
|
|
891
|
+
const q = await fetch(b + "/.well-known/security-key-rotation.json");
|
|
892
|
+
if (q.ok)
|
|
893
|
+
rot = await q.json();
|
|
894
|
+
}
|
|
895
|
+
catch (e) {
|
|
896
|
+
rot = null;
|
|
897
|
+
}
|
|
898
|
+
return Object.assign(await keyPolicyFrom(d, rot), { read_at: new Date().toISOString(), source: "online" });
|
|
899
|
+
}
|
|
754
900
|
export function trustOf(res, kid, publishedKids, related) {
|
|
755
901
|
let checks = Array.isArray(res && res.checks) ? res.checks : [];
|
|
756
902
|
let issuer = null;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@crawlcheck/sdk",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.9.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",
|