@zanii/blackbox 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,6 +2,25 @@
2
2
  import { callsOf } from "../reconcile/record.js";
3
3
  import { canonical } from "../reconcile/shared.js";
4
4
  import { checkCompensations } from "../undo/index.js";
5
+ const HINTS = ["read_only", "destructive", "idempotent", "open_world"];
6
+ /**
7
+ * spec/policy.md §1: a tool's annotations (MCP `readOnlyHint`, `destructiveHint`, `idempotentHint`,
8
+ * `openWorldHint`) as hints. Missing ones take MCP's defaults: not read-only, destructive, not
9
+ * idempotent, open-world; destructive and idempotent only mean something for a tool that writes.
10
+ * An unknown tool gets the defaults too. Hints are the server's word, not proof.
11
+ */
12
+ export function mcpToolHints(annotations) {
13
+ const a = (typeof annotations === "object" && annotations !== null ? annotations : {});
14
+ const readOnly = a.readOnlyHint === true;
15
+ return {
16
+ read_only: readOnly,
17
+ destructive: !readOnly && a.destructiveHint !== false,
18
+ idempotent: !readOnly && a.idempotentHint === true,
19
+ open_world: a.openWorldHint !== false,
20
+ };
21
+ }
22
+ const hintsMatch = (want, have) => want === undefined ||
23
+ (have !== undefined && Object.entries(want).every(([k, v]) => have[k] === v));
5
24
  const MAX_ARGS = 64 * 1024;
6
25
  /** Escapes a string for use inside a regular expression. */
7
26
  const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
@@ -26,6 +45,15 @@ export function loadPolicy(bytes) {
26
45
  }
27
46
  if (r.audit !== undefined && typeof r.audit !== "boolean")
28
47
  throw new Error(`policy ${r.id}: audit must be true or false`);
48
+ if (r.hints !== undefined) {
49
+ const h = r.hints;
50
+ if (typeof h !== "object" ||
51
+ h === null ||
52
+ Array.isArray(h) ||
53
+ Object.keys(h).length === 0 ||
54
+ !Object.entries(h).every(([k, v]) => HINTS.includes(k) && typeof v === "boolean"))
55
+ throw new Error(`policy ${r.id}: hints must set some of read_only, destructive, idempotent, open_world to true or false`);
56
+ }
29
57
  }
30
58
  const ids = doc.rules.map((r) => r.id);
31
59
  const twice = ids.find((id, i) => ids.indexOf(id) !== i);
@@ -45,7 +73,10 @@ function checkShadowed(rules) {
45
73
  .slice(0, j)
46
74
  .find((e) => glob(e.tool).test(later.tool) &&
47
75
  (e.args_match === undefined ||
48
- (e.args_match === later.args_match && !!e.ignore_case === !!later.ignore_case)));
76
+ (e.args_match === later.args_match && !!e.ignore_case === !!later.ignore_case)) &&
77
+ (e.hints === undefined ||
78
+ (later.hints !== undefined &&
79
+ Object.entries(e.hints).every(([k, v]) => later.hints[k] === v))));
49
80
  if (first && first.action !== later.action)
50
81
  throw new Error(`policy ${later.id}: never applies: ${first.id} matches the same calls first (${first.action})`);
51
82
  });
@@ -59,12 +90,15 @@ export function compilePolicy(policy) {
59
90
  : new RegExp(rule.args_match, rule.ignore_case ? "i" : ""),
60
91
  }));
61
92
  }
62
- /** The first rule that matches this call, or null (allowed). `names`: the tool's names (an MCP tool has two). */
63
- export function decide(compiled, names, args) {
93
+ /** The first rule that matches this call, or null (allowed). `names`: the tool's names (an MCP tool
94
+ * has two). `hints`: an MCP tool's annotations; a rule with `hints` never matches a call without. */
95
+ export function decide(compiled, names, args, hints) {
64
96
  let text = null;
65
97
  for (const c of compiled) {
66
98
  if (c.rule.audit === true || !names.some((n) => c.tool.test(n)))
67
99
  continue;
100
+ if (!hintsMatch(c.rule.hints, hints))
101
+ continue;
68
102
  if (c.args) {
69
103
  text ??= canonical(args ?? null).slice(0, MAX_ARGS);
70
104
  if (!c.args.test(text))
@@ -79,10 +113,11 @@ export function decide(compiled, names, args) {
79
113
  return null;
80
114
  }
81
115
  /** N3 (idea C5): every audit rule this call matches, and what it would do if it were enforced. */
82
- export function audited(compiled, names, args) {
116
+ export function audited(compiled, names, args, hints) {
83
117
  const text = canonical(args ?? null).slice(0, MAX_ARGS);
84
118
  return compiled
85
119
  .filter((c) => c.rule.audit === true && names.some((n) => c.tool.test(n)))
120
+ .filter((c) => hintsMatch(c.rule.hints, hints))
86
121
  .filter((c) => !c.args || c.args.test(text))
87
122
  .map((c) => ({ rule: c.rule.id, would: c.rule.action }));
88
123
  }
@@ -76,6 +76,16 @@ export interface FlightPlan {
76
76
  }
77
77
  /** Audit S17: what the gateway says about this session right now (GET /v1/sessions/:id/state). */
78
78
  /** What `land()` found (spec/findings.md §5). */
79
+ export interface EgressCheck {
80
+ url: string;
81
+ /** The request got any answer: the agent can reach the internet around the gateway. */
82
+ open: boolean;
83
+ }
84
+ /** spec/data.md §6: a direct HTTPS request to `url` (default https://example.com). Never throws. */
85
+ export declare function checkEgress(options?: {
86
+ url?: string;
87
+ timeoutMs?: number;
88
+ }): Promise<EgressCheck>;
79
89
  export interface LandingResult {
80
90
  /** Only a `satisfied` verdict lands: `unverified` never counts as a pass. */
81
91
  landed: boolean;
@@ -115,6 +125,8 @@ export interface LlmCallIds {
115
125
  }
116
126
  /** N4 (idea R6): the same parts always give the same event id (sha256, 128 bits). */
117
127
  export declare function stableEventId(...parts: readonly string[]): string;
128
+ /** The image type of a screenshot, by its first bytes: PNG, JPEG or WebP. */
129
+ export declare function imageType(image: Uint8Array): "image/png" | "image/jpeg" | "image/webp" | null;
118
130
  /** Opens (or joins) a session. Never throws: on failure it returns a disabled session and logs why. */
119
131
  export declare function session(options: SessionOptions): Promise<BlackboxSession>;
120
132
  export declare class BlackboxSession {
@@ -142,6 +154,11 @@ export declare class BlackboxSession {
142
154
  keepToken?: boolean);
143
155
  event(type: string, name?: string, data?: Record<string, unknown>, options?: {
144
156
  eventId?: string;
157
+ /** spec/sdk.md §1.1: an image the event's body is (see `screen`). */
158
+ attachment?: {
159
+ contentType: string;
160
+ bytes: Uint8Array;
161
+ };
145
162
  }): void;
146
163
  step(name: string, data?: Record<string, unknown>): void;
147
164
  toolCall(name: string, args?: unknown): void;
@@ -149,6 +166,15 @@ export declare class BlackboxSession {
149
166
  ok?: boolean;
150
167
  [key: string]: unknown;
151
168
  }): void;
169
+ /** spec/data.md §7: a subject's consent for a purpose. The subject is fingerprinted at capture,
170
+ * so it must be an identifier the deployment knows (an e-mail, an Emirates ID, a pack's own). */
171
+ consent(subject: string, purpose: string, granted: boolean): void;
172
+ /** spec/data.md §6: tries the internet directly, outside the gateway, and records the answer.
173
+ * `open: true` means the gateway isn't the only way out (EGRESS_OPEN). */
174
+ egressCheck(options?: {
175
+ url?: string;
176
+ timeoutMs?: number;
177
+ }): Promise<EgressCheck>;
152
178
  llmCall(ids: LlmCallIds): void;
153
179
  note(text: string): void;
154
180
  /** Files the flight plan (spec/findings.md §5), unless one was filed when the session opened. */
@@ -196,6 +222,17 @@ export declare class BlackboxSession {
196
222
  nearMiss(description: string): void;
197
223
  /** spec/attestation.md: re-runs a read-only command and records whether the output matches. */
198
224
  attest(argv: string[], claimed: string, cwd?: string, claimedExit?: number): Attestation | undefined;
225
+ /**
226
+ * spec/sdk.md §1.1: a computer-use screenshot (PNG, JPEG or WebP, at most 2 MB), recorded as the
227
+ * body of a `screen` event, so its hash is in the chain. `action` says what the agent did or is
228
+ * about to do (≤ 500 characters). Mask what mustn't be kept before calling: the image isn't
229
+ * redacted. One that's too big or not an image is dropped, and the event says why.
230
+ */
231
+ screen(image: Uint8Array, options?: {
232
+ action?: string;
233
+ width?: number;
234
+ height?: number;
235
+ }): void;
199
236
  /** spec/agents.md §1: the agent's memory store wrote, revoked or returned memories. */
200
237
  memoryWrite(memoryId: string, summary?: string): void;
201
238
  memoryRevoke(memoryId: string, reason?: string): void;
@@ -7,6 +7,17 @@ import { request as httpsRequest } from "node:https";
7
7
  import { tmpdir } from "node:os";
8
8
  import { join } from "node:path";
9
9
  import { attest, isReadOnly } from "../attest/index.js";
10
+ /** spec/data.md §6: a direct HTTPS request to `url` (default https://example.com). Never throws. */
11
+ export async function checkEgress(options = {}) {
12
+ const url = options.url ?? "https://example.com";
13
+ try {
14
+ await fetch(url, { method: "HEAD", signal: AbortSignal.timeout(options.timeoutMs ?? 5000) });
15
+ return { url, open: true };
16
+ }
17
+ catch {
18
+ return { url, open: false };
19
+ }
20
+ }
10
21
  const TYPE = /^[a-z][a-z0-9_.]{0,63}$/;
11
22
  /** N4 (idea R6): a caller's logical id for an event, so a re-sent one is recorded as a duplicate. */
12
23
  const EVENT_ID = /^[A-Za-z0-9._:-]{1,128}$/;
@@ -19,6 +30,20 @@ const MAX_DATA = 64 * 1024;
19
30
  const PREVIEW = 4096;
20
31
  const BATCH = 500;
21
32
  const READ_CHUNK = 4 * 1024 * 1024;
33
+ /** spec/sdk.md §1.1: a screenshot's bytes at most; base64 in its spool line, it stays under READ_CHUNK. */
34
+ const MAX_ATTACHMENT = 2 * 1024 * 1024;
35
+ /** The image type of a screenshot, by its first bytes: PNG, JPEG or WebP. */
36
+ export function imageType(image) {
37
+ const b = Buffer.from(image.buffer, image.byteOffset, image.byteLength);
38
+ if (b.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])))
39
+ return "image/png";
40
+ if (b.subarray(0, 3).equals(Buffer.from([0xff, 0xd8, 0xff])))
41
+ return "image/jpeg";
42
+ if (b.subarray(0, 4).toString("latin1") === "RIFF" &&
43
+ b.subarray(8, 12).toString("latin1") === "WEBP")
44
+ return "image/webp";
45
+ return null;
46
+ }
22
47
  const LOCK_WAIT_MS = 2_000;
23
48
  const LOCK_STALE_MS = 10_000;
24
49
  /** Opens (or joins) a session. Never throws: on failure it returns a disabled session and logs why. */
@@ -159,6 +184,14 @@ export class BlackboxSession {
159
184
  ...(eventId === undefined ? {} : { event_id: eventId }),
160
185
  ts: new Date().toISOString(),
161
186
  ...(kept === undefined ? {} : { data: kept }),
187
+ ...(options.attachment
188
+ ? {
189
+ attachment: {
190
+ content_type: options.attachment.contentType,
191
+ data: Buffer.from(options.attachment.bytes).toString("base64"),
192
+ },
193
+ }
194
+ : {}),
162
195
  });
163
196
  appendFileSync(this.spool, `${line}\n`);
164
197
  this.nextSeq++;
@@ -181,6 +214,18 @@ export class BlackboxSession {
181
214
  toolResult(name, result) {
182
215
  this.event("tool.result", name, result);
183
216
  }
217
+ /** spec/data.md §7: a subject's consent for a purpose. The subject is fingerprinted at capture,
218
+ * so it must be an identifier the deployment knows (an e-mail, an Emirates ID, a pack's own). */
219
+ consent(subject, purpose, granted) {
220
+ this.event("consent", granted ? "granted" : "withdrawn", { subject, purpose });
221
+ }
222
+ /** spec/data.md §6: tries the internet directly, outside the gateway, and records the answer.
223
+ * `open: true` means the gateway isn't the only way out (EGRESS_OPEN). */
224
+ async egressCheck(options = {}) {
225
+ const result = await checkEgress(options);
226
+ this.event("egress_check", result.open ? "open" : "closed", { url: result.url });
227
+ return result;
228
+ }
184
229
  llmCall(ids) {
185
230
  this.event("llm.call", ids.provider, { ...ids });
186
231
  }
@@ -268,6 +313,30 @@ export class BlackboxSession {
268
313
  this.event("attestation", argv[0], { ...result });
269
314
  return result;
270
315
  }
316
+ /**
317
+ * spec/sdk.md §1.1: a computer-use screenshot (PNG, JPEG or WebP, at most 2 MB), recorded as the
318
+ * body of a `screen` event, so its hash is in the chain. `action` says what the agent did or is
319
+ * about to do (≤ 500 characters). Mask what mustn't be kept before calling: the image isn't
320
+ * redacted. One that's too big or not an image is dropped, and the event says why.
321
+ */
322
+ screen(image, options = {}) {
323
+ const data = {
324
+ ...(options.action ? { action: options.action.slice(0, 500) } : {}),
325
+ ...(Number.isSafeInteger(options.width) ? { width: options.width } : {}),
326
+ ...(Number.isSafeInteger(options.height) ? { height: options.height } : {}),
327
+ };
328
+ const type = imageType(image);
329
+ if (!type || image.length > MAX_ATTACHMENT) {
330
+ this.event("screen", undefined, {
331
+ ...data,
332
+ attachment_dropped: type
333
+ ? `${image.length} bytes, over 2 MB`
334
+ : "not a PNG, JPEG or WebP image",
335
+ });
336
+ return;
337
+ }
338
+ this.event("screen", undefined, data, { attachment: { contentType: type, bytes: image } });
339
+ }
271
340
  /** spec/agents.md §1: the agent's memory store wrote, revoked or returned memories. */
272
341
  memoryWrite(memoryId, summary) {
273
342
  this.event("memory.write", undefined, { memory_id: memoryId, ...(summary ? { summary } : {}) });
@@ -0,0 +1,24 @@
1
+ /** A DER TimeStampReq for a SHA-256 digest, asking for the TSA's certificate; with a nonce when given. */
2
+ export declare function timestampRequest(digest: Uint8Array, nonce?: Uint8Array): Uint8Array;
3
+ export interface TimestampReport {
4
+ ok: boolean;
5
+ /** ISO-8601, from the token's GeneralizedTime as written. */
6
+ gen_time: string | null;
7
+ serial: string | null;
8
+ policy: string | null;
9
+ /** SHA-256 of the TSA certificate that signed it. */
10
+ signer: string | null;
11
+ /** True when the signer chains to one of the trust anchors given; null when none were given. */
12
+ chain: boolean | null;
13
+ problems: string[];
14
+ }
15
+ /**
16
+ * Checks an RFC 3161 TimeStampResp (or its token, the ContentInfo) offline against the SHA-256
17
+ * `digest` that was timestamped. `roots`: PEM trust anchors; without them the chain isn't checked
18
+ * (`chain: null`) and the report says only that the token is well-formed and signed by its own cert.
19
+ */
20
+ export declare function verifyTimestamp(token: Uint8Array, options: {
21
+ digest: Uint8Array;
22
+ nonce?: Uint8Array;
23
+ roots?: readonly string[];
24
+ }): TimestampReport;
@@ -0,0 +1,274 @@
1
+ // RFC 3161 timestamps (spec/timestamp.md): the request a gateway sends a Time-Stamp Authority, and an
2
+ // offline check of the token it gets back: the imprint, the CMS signature (RSA PKCS#1 v1.5 or ECDSA,
3
+ // SHA-256/384/512), the signer certificate (ESSCertID v1 or v2), its timeStamping EKU and validity,
4
+ // and the chain to trust anchors you give. node:crypto only. Mirrors zanii_blackbox/timestamp.py.
5
+ import { createHash, verify as cryptoVerify, X509Certificate } from "node:crypto";
6
+ function parse(buf, depth = 0) {
7
+ const nodes = parseAll(buf, depth);
8
+ if (nodes.length !== 1)
9
+ throw new Error("der: expected one element");
10
+ return nodes[0];
11
+ }
12
+ function parseAll(buf, depth) {
13
+ if (depth > 40)
14
+ throw new Error("der: too deep");
15
+ const out = [];
16
+ let at = 0;
17
+ while (at < buf.length) {
18
+ const start = at;
19
+ const tag = buf[at++];
20
+ if ((tag & 0x1f) === 0x1f)
21
+ throw new Error("der: high tag numbers aren't used here");
22
+ let len = buf[at++];
23
+ if (len === undefined)
24
+ throw new Error("der: truncated");
25
+ if (len === 0x80)
26
+ throw new Error("der: indefinite length");
27
+ if (len > 0x80) {
28
+ const n = len & 0x7f;
29
+ if (n > 4 || at + n > buf.length)
30
+ throw new Error("der: bad length");
31
+ len = 0;
32
+ for (let i = 0; i < n; i++)
33
+ len = len * 256 + buf[at++];
34
+ }
35
+ if (at + len > buf.length)
36
+ throw new Error("der: truncated");
37
+ const value = buf.subarray(at, at + len);
38
+ at += len;
39
+ const constructed = (tag & 0x20) !== 0;
40
+ out.push({
41
+ tag,
42
+ raw: buf.subarray(start, at),
43
+ value,
44
+ children: constructed ? parseAll(value, depth + 1) : [],
45
+ });
46
+ }
47
+ return out;
48
+ }
49
+ /** OID content bytes as dotted text. */
50
+ function oid(n) {
51
+ if (n.tag !== 0x06)
52
+ throw new Error("der: expected an OID");
53
+ const b = n.value;
54
+ const first = b[0];
55
+ const parts = [Math.floor(first / 40), first % 40];
56
+ let v = 0;
57
+ for (let i = 1; i < b.length; i++) {
58
+ v = v * 128 + (b[i] & 0x7f);
59
+ if ((b[i] & 0x80) === 0) {
60
+ parts.push(v);
61
+ v = 0;
62
+ }
63
+ }
64
+ return parts.join(".");
65
+ }
66
+ const child = (n, i) => {
67
+ const c = n?.children[i];
68
+ if (!c)
69
+ throw new Error("der: missing element");
70
+ return c;
71
+ };
72
+ const hex = (b) => Buffer.from(b).toString("hex");
73
+ // ---------------------------------------------------------------- OIDs
74
+ const SIGNED_DATA = "1.2.840.113549.1.7.2";
75
+ const TST_INFO = "1.2.840.113549.1.9.16.1.4";
76
+ const CONTENT_TYPE = "1.2.840.113549.1.9.3";
77
+ const MESSAGE_DIGEST = "1.2.840.113549.1.9.4";
78
+ const SIGNING_CERT = "1.2.840.113549.1.9.16.2.12";
79
+ const SIGNING_CERT_V2 = "1.2.840.113549.1.9.16.2.47";
80
+ const TIME_STAMPING = "1.3.6.1.5.5.7.3.8";
81
+ const HASHES = {
82
+ "2.16.840.1.101.3.4.2.1": "sha256",
83
+ "2.16.840.1.101.3.4.2.2": "sha384",
84
+ "2.16.840.1.101.3.4.2.3": "sha512",
85
+ "1.3.14.3.2.26": "sha1",
86
+ };
87
+ const SIGNATURES = {
88
+ "1.2.840.113549.1.1.1": null, // rsaEncryption: the signer's digest algorithm
89
+ "1.2.840.113549.1.1.11": "sha256",
90
+ "1.2.840.113549.1.1.12": "sha384",
91
+ "1.2.840.113549.1.1.13": "sha512",
92
+ "1.2.840.10045.4.3.2": "sha256",
93
+ "1.2.840.10045.4.3.3": "sha384",
94
+ "1.2.840.10045.4.3.4": "sha512",
95
+ };
96
+ // ---------------------------------------------------------------- the request
97
+ const der = (tag, content) => {
98
+ const n = content.length;
99
+ const len = n < 0x80 ? [n] : n < 0x100 ? [0x81, n] : [0x82, n >> 8, n & 0xff];
100
+ return new Uint8Array(Buffer.concat([Buffer.from([tag, ...len]), Buffer.from(content)]));
101
+ };
102
+ /** A DER TimeStampReq for a SHA-256 digest, asking for the TSA's certificate; with a nonce when given. */
103
+ export function timestampRequest(digest, nonce) {
104
+ if (digest.length !== 32)
105
+ throw new Error("a SHA-256 digest is 32 bytes");
106
+ const algId = Buffer.from("300d06096086480165030402010500", "hex");
107
+ const imprint = der(0x30, new Uint8Array(Buffer.concat([algId, Buffer.from(der(0x04, digest))])));
108
+ let n = null;
109
+ if (nonce) {
110
+ let i = 0;
111
+ while (i < nonce.length - 1 && nonce[i] === 0)
112
+ i++;
113
+ const trimmed = nonce.subarray(i);
114
+ n = der(0x02, trimmed[0] & 0x80 ? new Uint8Array([0, ...trimmed]) : trimmed);
115
+ }
116
+ return der(0x30, new Uint8Array(Buffer.concat([
117
+ Buffer.from([0x02, 0x01, 0x01]),
118
+ Buffer.from(imprint),
119
+ ...(n ? [Buffer.from(n)] : []),
120
+ Buffer.from([0x01, 0x01, 0xff]),
121
+ ])));
122
+ }
123
+ const isoOf = (generalized) => {
124
+ const m = /^([0-9]{4})([0-9]{2})([0-9]{2})([0-9]{2})([0-9]{2})([0-9]{2})(\.[0-9]+)?Z$/.exec(generalized);
125
+ return m ? `${m[1]}-${m[2]}-${m[3]}T${m[4]}:${m[5]}:${m[6]}${m[7] ?? ""}Z` : null;
126
+ };
127
+ /** The TSTInfo's fields we check. */
128
+ function tstInfo(bytes) {
129
+ const t = parse(bytes);
130
+ const imprint = child(t, 2);
131
+ const genTime = Buffer.from(child(t, 4).value).toString("latin1");
132
+ let nonce = null;
133
+ for (const c of t.children.slice(5))
134
+ if (c.tag === 0x02)
135
+ nonce = hex(c.value).replace(/^00(?=[0-9a-f]{2})/, "");
136
+ return {
137
+ policy: oid(child(t, 1)),
138
+ hashAlg: oid(child(child(imprint, 0), 0)),
139
+ hashed: child(imprint, 1).value,
140
+ serial: hex(child(t, 3).value),
141
+ genTime,
142
+ nonce,
143
+ };
144
+ }
145
+ /**
146
+ * Checks an RFC 3161 TimeStampResp (or its token, the ContentInfo) offline against the SHA-256
147
+ * `digest` that was timestamped. `roots`: PEM trust anchors; without them the chain isn't checked
148
+ * (`chain: null`) and the report says only that the token is well-formed and signed by its own cert.
149
+ */
150
+ export function verifyTimestamp(token, options) {
151
+ const report = {
152
+ ok: false,
153
+ gen_time: null,
154
+ serial: null,
155
+ policy: null,
156
+ signer: null,
157
+ chain: null,
158
+ problems: [],
159
+ };
160
+ const fail = (why) => {
161
+ report.problems.push(why);
162
+ return report;
163
+ };
164
+ try {
165
+ let top = parse(token);
166
+ // A TimeStampResp: [PKIStatusInfo, token]; a token: [OID signedData, [0] SignedData]
167
+ if (child(top, 0).tag === 0x30) {
168
+ const status = child(child(top, 0), 0).value;
169
+ if (status.length !== 1 || (status[0] !== 0 && status[0] !== 1))
170
+ return fail("the TSA didn't grant the timestamp");
171
+ top = child(top, 1);
172
+ }
173
+ if (oid(child(top, 0)) !== SIGNED_DATA)
174
+ return fail("not a CMS SignedData token");
175
+ const sd = child(child(top, 1), 0);
176
+ const encap = child(sd, 2);
177
+ if (oid(child(encap, 0)) !== TST_INFO)
178
+ return fail("the token doesn't carry a TSTInfo");
179
+ const tstBytes = child(child(encap, 1), 0).value;
180
+ const info = tstInfo(tstBytes);
181
+ report.gen_time = isoOf(info.genTime);
182
+ report.serial = info.serial;
183
+ report.policy = info.policy;
184
+ if (info.hashAlg !== "2.16.840.1.101.3.4.2.1" || hex(info.hashed) !== hex(options.digest))
185
+ fail("the token is for another digest");
186
+ if (options.nonce && info.nonce !== hex(options.nonce).replace(/^(00)+(?=[0-9a-f]{2})/, ""))
187
+ fail("the nonce isn't the one sent");
188
+ const certs = sd.children.find((c) => c.tag === 0xa0)?.children.map((c) => c.raw) ?? [];
189
+ const signerInfos = sd.children.at(-1);
190
+ if (signerInfos?.tag !== 0x31 || signerInfos.children.length !== 1)
191
+ return fail("a token has exactly one signer");
192
+ const si = child(signerInfos, 0);
193
+ const digestAlg = HASHES[oid(child(child(si, 2), 0))];
194
+ const attrs = child(si, 3);
195
+ if (attrs.tag !== 0xa0 || !digestAlg)
196
+ return fail("the signer's attributes or digest aren't supported");
197
+ const sigAlgOid = oid(child(child(si, 4), 0));
198
+ const sig = child(si, 5).value;
199
+ // signed attributes: contentType, messageDigest over the TSTInfo, and the signing certificate
200
+ const attr = (id) => attrs.children.find((a) => oid(child(a, 0)) === id);
201
+ const ct = attr(CONTENT_TYPE);
202
+ if (!ct || oid(child(child(ct, 1), 0)) !== TST_INFO)
203
+ fail("the signed content type isn't TSTInfo");
204
+ const md = attr(MESSAGE_DIGEST);
205
+ if (!md ||
206
+ hex(child(child(md, 1), 0).value) !== createHash(digestAlg).update(tstBytes).digest("hex"))
207
+ fail("the signed message digest isn't the TSTInfo's");
208
+ const v1 = attr(SIGNING_CERT);
209
+ const v2 = attr(SIGNING_CERT_V2);
210
+ let want = null;
211
+ if (v2) {
212
+ const id = child(child(child(child(v2, 1), 0), 0), 0); // SigningCertificateV2.certs[0]
213
+ const first = child(id, 0);
214
+ want =
215
+ first.tag === 0x30
216
+ ? { alg: HASHES[oid(child(first, 0))] ?? "unknown", hash: hex(child(id, 1).value) }
217
+ : { alg: "sha256", hash: hex(first.value) };
218
+ }
219
+ else if (v1) {
220
+ const id = child(child(child(child(v1, 1), 0), 0), 0);
221
+ want = { alg: "sha1", hash: hex(child(id, 0).value) };
222
+ }
223
+ if (!want)
224
+ return fail("the token doesn't name its signing certificate");
225
+ const signerDer = certs.find((c) => createHash(want.alg).update(c).digest("hex") === want.hash);
226
+ if (!signerDer)
227
+ return fail("the signing certificate isn't in the token");
228
+ const cert = new X509Certificate(Buffer.from(signerDer));
229
+ report.signer = createHash("sha256").update(signerDer).digest("hex");
230
+ // the signature over the DER of the signed attributes, re-tagged as a SET
231
+ const signed = Buffer.from(attrs.raw);
232
+ signed[0] = 0x31;
233
+ if (!(sigAlgOid in SIGNATURES))
234
+ return fail(`the signature algorithm ${sigAlgOid} isn't supported`);
235
+ const hashName = SIGNATURES[sigAlgOid] ?? digestAlg;
236
+ const key = cert.publicKey;
237
+ const good = key.asymmetricKeyType === "ec"
238
+ ? cryptoVerify(hashName, signed, { key, dsaEncoding: "der" }, sig)
239
+ : cryptoVerify(hashName, signed, key, sig);
240
+ if (!good)
241
+ fail("the TSA's signature doesn't verify");
242
+ if (!(cert.keyUsage ?? []).includes(TIME_STAMPING))
243
+ fail("the signing certificate isn't for time-stamping");
244
+ const at = report.gen_time ? Date.parse(report.gen_time) : Number.NaN;
245
+ if (!(Date.parse(cert.validFrom) <= at && at <= Date.parse(cert.validTo)))
246
+ fail("the signing certificate wasn't valid at the stamped time");
247
+ if (options.roots?.length)
248
+ report.chain = chains(cert, certs, options.roots);
249
+ if (report.chain === false)
250
+ fail("the signing certificate doesn't chain to a trusted root");
251
+ }
252
+ catch {
253
+ return fail("not a well-formed timestamp");
254
+ }
255
+ report.ok = report.problems.length === 0;
256
+ return report;
257
+ }
258
+ /** Walks issuer links from the signer through the token's certificates to a root (at most 6 hops). */
259
+ function chains(signer, certs, roots) {
260
+ const anchors = roots.map((r) => new X509Certificate(r));
261
+ const pool = certs.map((c) => new X509Certificate(Buffer.from(c)));
262
+ let current = signer;
263
+ for (let hop = 0; hop < 6; hop++) {
264
+ if (anchors.some((a) => current.checkIssued(a) && current.verify(a.publicKey)))
265
+ return true;
266
+ const next = pool.find((c) => c.fingerprint256 !== current.fingerprint256 &&
267
+ current.checkIssued(c) &&
268
+ current.verify(c.publicKey));
269
+ if (!next)
270
+ return false;
271
+ current = next;
272
+ }
273
+ return false;
274
+ }