@zanii/blackbox 0.2.0 → 0.4.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.
Files changed (94) hide show
  1. package/README.md +26 -1
  2. package/dist/a2a/index.d.ts +77 -0
  3. package/dist/a2a/index.js +305 -0
  4. package/dist/agents/index.d.ts +8 -0
  5. package/dist/analysis/accuracy.d.ts +24 -0
  6. package/dist/analysis/accuracy.js +45 -0
  7. package/dist/analysis/credential.d.ts +101 -0
  8. package/dist/analysis/credential.js +142 -0
  9. package/dist/analysis/faults.js +115 -0
  10. package/dist/analysis/grounding.d.ts +122 -0
  11. package/dist/analysis/grounding.js +445 -0
  12. package/dist/analysis/hallucination.d.ts +32 -0
  13. package/dist/analysis/hallucination.js +357 -0
  14. package/dist/analysis/index.d.ts +23 -0
  15. package/dist/analysis/index.js +93 -0
  16. package/dist/analysis/memory.d.ts +8 -0
  17. package/dist/analysis/memory.js +35 -8
  18. package/dist/analysis/reference.d.ts +49 -0
  19. package/dist/analysis/reference.js +164 -0
  20. package/dist/analysis/taxonomy.d.ts +18 -0
  21. package/dist/analysis/taxonomy.js +66 -0
  22. package/dist/approvals/index.d.ts +23 -0
  23. package/dist/approvals/index.js +48 -0
  24. package/dist/archive/index.d.ts +39 -0
  25. package/dist/archive/index.js +96 -0
  26. package/dist/archive/parquet.d.ts +2 -0
  27. package/dist/archive/parquet.js +185 -0
  28. package/dist/badge/index.d.ts +16 -0
  29. package/dist/badge/index.js +48 -0
  30. package/dist/bom/index.d.ts +14 -0
  31. package/dist/bom/index.js +152 -0
  32. package/dist/cli.js +114 -10
  33. package/dist/compliance/art12.d.ts +35 -0
  34. package/dist/compliance/art12.js +190 -0
  35. package/dist/compliance/index.d.ts +36 -2
  36. package/dist/compliance/index.js +78 -11
  37. package/dist/compliance/zanii.d.ts +29 -0
  38. package/dist/compliance/zanii.js +84 -0
  39. package/dist/constitution/index.d.ts +57 -0
  40. package/dist/constitution/index.js +131 -0
  41. package/dist/cv/index.d.ts +39 -0
  42. package/dist/cv/index.js +108 -0
  43. package/dist/disclosure/index.d.ts +31 -0
  44. package/dist/disclosure/index.js +113 -0
  45. package/dist/encryption/index.d.ts +9 -0
  46. package/dist/encryption/index.js +31 -0
  47. package/dist/evidence/index.d.ts +60 -0
  48. package/dist/evidence/index.js +151 -0
  49. package/dist/federation/index.d.ts +35 -0
  50. package/dist/federation/index.js +102 -0
  51. package/dist/finance/index.d.ts +126 -0
  52. package/dist/finance/index.js +320 -0
  53. package/dist/fleet/index.js +9 -0
  54. package/dist/gov/index.d.ts +108 -0
  55. package/dist/gov/index.js +225 -0
  56. package/dist/health/index.d.ts +120 -0
  57. package/dist/health/index.js +233 -0
  58. package/dist/index.d.ts +34 -5
  59. package/dist/index.js +34 -5
  60. package/dist/memory/index.d.ts +36 -0
  61. package/dist/memory/index.js +85 -0
  62. package/dist/occurrence/index.d.ts +11 -0
  63. package/dist/occurrence/index.js +18 -0
  64. package/dist/ocsf/index.d.ts +1 -1
  65. package/dist/ocsf/index.js +36 -3
  66. package/dist/otlp/index.d.ts +8 -1
  67. package/dist/otlp/index.js +258 -1
  68. package/dist/packs/index.js +44 -4
  69. package/dist/policy/delta.js +7 -1
  70. package/dist/policy/index.d.ts +40 -6
  71. package/dist/policy/index.js +186 -8
  72. package/dist/policy/zanii.d.ts +31 -0
  73. package/dist/policy/zanii.js +87 -0
  74. package/dist/pq/index.d.ts +23 -0
  75. package/dist/pq/index.js +104 -0
  76. package/dist/search/index.d.ts +23 -0
  77. package/dist/search/index.js +69 -0
  78. package/dist/session/index.d.ts +107 -1
  79. package/dist/session/index.js +189 -11
  80. package/dist/sla/index.d.ts +61 -0
  81. package/dist/sla/index.js +197 -0
  82. package/dist/succession/index.d.ts +50 -0
  83. package/dist/succession/index.js +123 -0
  84. package/dist/timestamp/index.d.ts +24 -0
  85. package/dist/timestamp/index.js +274 -0
  86. package/dist/tokens/index.d.ts +6 -0
  87. package/dist/tokens/index.js +46 -0
  88. package/dist/transparency/index.d.ts +188 -0
  89. package/dist/transparency/index.js +712 -0
  90. package/dist/version.d.ts +1 -1
  91. package/dist/version.js +1 -1
  92. package/dist/walls/index.d.ts +31 -0
  93. package/dist/walls/index.js +119 -0
  94. package/package.json +1 -1
@@ -0,0 +1,197 @@
1
+ // Service agreements proven from receipts (spec/anchoring.md §14): Zanii's SLA, co-signed terms
2
+ // whose compliance is computed from the provider's consecutive receipt chain, the same verdict for
3
+ // everyone (@zanii/sla). Pure; mirrors sdks/python/src/zanii_blackbox/sla.py; pinned by
4
+ // spec/vectors/sla.json.
5
+ import { canonicalBytes, jcsHash, publicKeyFromDid, receiptHash, scopeCovers, verifyReceipt, } from "@zanii/core";
6
+ import { parseMoney } from "../finance/index.js";
7
+ import { ed25519Sign, ed25519Verify } from "../transparency/index.js";
8
+ const ms = (ts) => {
9
+ if (typeof ts !== "string")
10
+ return null;
11
+ const t = Date.parse(ts);
12
+ return Number.isNaN(t) ? null : t;
13
+ };
14
+ const isCount = (v) => Number.isSafeInteger(v) && v >= 0;
15
+ /** Zanii's SLA terms. `commitments` holds at least one of min_actions, max_actions, max_gap_ms. */
16
+ export function buildSlaBody(o) {
17
+ if (!o.provider || !o.client)
18
+ throw new Error("provider and client are required");
19
+ if (o.provider === o.client)
20
+ throw new Error("provider and client must differ");
21
+ if (!o.scope)
22
+ throw new Error("scope is required (the target pattern the service receipts match)");
23
+ const start = ms(o.window.start);
24
+ const end = ms(o.window.end);
25
+ if (start === null || end === null)
26
+ throw new Error("window start/end must be ISO timestamps");
27
+ if (start >= end)
28
+ throw new Error("window start must be before end");
29
+ if (ms(o.createdAt) === null)
30
+ throw new Error("created_at must be an ISO timestamp");
31
+ const c = { ...o.commitments };
32
+ for (const [k, v] of Object.entries(c))
33
+ if (v !== undefined && v !== null && !isCount(v))
34
+ throw new Error(`commitment ${k} must be a non-negative integer`);
35
+ if (c.min_actions == null && c.max_actions == null && c.max_gap_ms == null)
36
+ throw new Error("an SLA with no commitments commits to nothing");
37
+ if (c.min_actions != null && c.max_actions != null && c.min_actions > c.max_actions)
38
+ throw new Error("min_actions must not exceed max_actions");
39
+ return {
40
+ v: 1,
41
+ type: "sla",
42
+ provider: o.provider,
43
+ client: o.client,
44
+ scope: o.scope,
45
+ window: { start: o.window.start, end: o.window.end },
46
+ commitments: c,
47
+ created_at: o.createdAt,
48
+ ...(o.price ? { price: parseMoney(o.price.amount, o.price.currency) } : {}),
49
+ ...(o.ref !== undefined ? { ref: o.ref } : {}),
50
+ };
51
+ }
52
+ /** One party signs the terms. Collect the provider's and the client's. */
53
+ export const signSla = (body, did, privateKey) => ({
54
+ did,
55
+ sig: `ed25519:${Buffer.from(ed25519Sign(privateKey, canonicalBytes(body))).toString("hex")}`,
56
+ });
57
+ export const assembleSla = (body, signatures) => ({
58
+ body,
59
+ signatures,
60
+ });
61
+ export const slaHash = (sla) => jcsHash(sla);
62
+ const sigValid = (body, did, sig) => {
63
+ const pub = publicKeyFromDid(did);
64
+ const m = typeof sig === "string" ? /^ed25519:([0-9a-f]{128})$/.exec(sig) : null;
65
+ return Boolean(pub && m && ed25519Verify(pub, canonicalBytes(body), Buffer.from(m[1], "hex")));
66
+ };
67
+ /** Zanii's check: sane terms, and BOTH provider and client signed exactly these. */
68
+ export function verifySla(sla) {
69
+ const reasons = [];
70
+ const e = (sla ?? {});
71
+ const b = e.body ?? {};
72
+ if (b.v !== 1 || b.type !== "sla")
73
+ reasons.push("not an sla (v1)");
74
+ const { provider, client } = b;
75
+ if (!provider || !client || provider === client)
76
+ reasons.push("missing/invalid parties");
77
+ if (!b.scope)
78
+ reasons.push("missing scope");
79
+ const w = (b.window ?? {});
80
+ const start = ms(w.start);
81
+ const end = ms(w.end);
82
+ if (start === null || end === null || start >= end)
83
+ reasons.push("invalid window");
84
+ const c = (b.commitments ?? {});
85
+ if (c.min_actions == null && c.max_actions == null && c.max_gap_ms == null)
86
+ reasons.push("no commitments");
87
+ if (reasons.length === 0)
88
+ for (const [party, role] of [
89
+ [provider, "provider"],
90
+ [client, "client"],
91
+ ]) {
92
+ const s = (e.signatures ?? []).find((x) => x.did === party);
93
+ if (!s)
94
+ reasons.push(`missing signature from ${role}`);
95
+ else if (!sigValid(b, String(s.did), s.sig))
96
+ reasons.push(`invalid signature from ${role}`);
97
+ }
98
+ return { ok: reasons.length === 0, reasons };
99
+ }
100
+ /**
101
+ * Zanii's verdict over the provider's consecutive receipt chain for the window. Breaches:
102
+ * INVALID_RECEIPT, INCOMPLETE_RECORD (a seam: something recorded was left out), TOO_FEW_ACTIONS,
103
+ * TOO_MANY_ACTIONS, MAX_GAP_EXCEEDED. `asOf` (default the window's end) caps the trailing gap.
104
+ */
105
+ export function assessCompliance(sla, receipts, asOf) {
106
+ const agreement = verifySla(sla);
107
+ if (!agreement.ok)
108
+ return {
109
+ ok: false,
110
+ breaches: agreement.reasons.map((r) => ({ kind: "INVALID_RECEIPT", detail: `sla: ${r}` })),
111
+ metrics: { actions: 0, max_gap_ms: null },
112
+ };
113
+ const b = sla.body;
114
+ const breaches = [];
115
+ const winStart = ms(b.window.start);
116
+ const winEnd = ms(b.window.end);
117
+ if (asOf !== undefined && ms(asOf) === null)
118
+ throw new Error("as_of must be an ISO timestamp");
119
+ const asOfMs = Math.min(asOf !== undefined ? ms(asOf) : winEnd, winEnd);
120
+ // a stable sort, as Python's sorted
121
+ const sorted = receipts
122
+ .map((r, i) => ({ r, i, t: ms(r.ts) ?? 0 }))
123
+ .sort((x, y) => x.t - y.t || x.i - y.i)
124
+ .map((x) => x.r);
125
+ for (const r of sorted) {
126
+ let check;
127
+ try {
128
+ check = verifyReceipt(r);
129
+ }
130
+ catch (error) {
131
+ check = { ok: false, error: error instanceof Error ? error.message : String(error) };
132
+ }
133
+ if (!check.ok)
134
+ breaches.push({
135
+ kind: "INVALID_RECEIPT",
136
+ detail: check.error || "invalid receipt",
137
+ receipt: receiptHash(r),
138
+ });
139
+ else if (r.agent_id !== b.provider)
140
+ breaches.push({
141
+ kind: "INVALID_RECEIPT",
142
+ detail: `receipt by ${r.agent_id}, not the provider`,
143
+ receipt: receiptHash(r),
144
+ });
145
+ }
146
+ for (let i = 1; i < sorted.length; i++)
147
+ if (sorted[i].prev !== receiptHash(sorted[i - 1]))
148
+ breaches.push({
149
+ kind: "INCOMPLETE_RECORD",
150
+ detail: `chain break between receipts #${i - 1} and #${i} — the slice is not the provider's consecutive record`,
151
+ receipt: receiptHash(sorted[i]),
152
+ });
153
+ const matching = sorted.filter((r) => {
154
+ const t = ms(r.ts);
155
+ return (t !== null && winStart <= t && t <= winEnd && scopeCovers(String(b.scope), r.target || ""));
156
+ });
157
+ const c = b.commitments;
158
+ if (c.min_actions != null && matching.length < c.min_actions)
159
+ breaches.push({
160
+ kind: "TOO_FEW_ACTIONS",
161
+ detail: `${matching.length} matching actions < committed minimum ${c.min_actions}`,
162
+ });
163
+ if (c.max_actions != null && matching.length > c.max_actions)
164
+ breaches.push({
165
+ kind: "TOO_MANY_ACTIONS",
166
+ detail: `${matching.length} matching actions > committed maximum ${c.max_actions}`,
167
+ });
168
+ let maxGap = null;
169
+ if (matching.length > 0) {
170
+ const times = matching.map((r) => ms(r.ts));
171
+ const gaps = [[times[0] - winStart, null]];
172
+ for (let i = 1; i < times.length; i++)
173
+ gaps.push([times[i] - times[i - 1], matching[i - 1]]);
174
+ const last = times[times.length - 1];
175
+ if (asOfMs > last)
176
+ gaps.push([asOfMs - last, matching[matching.length - 1]]);
177
+ maxGap = Math.max(...gaps.map(([g]) => g));
178
+ if (c.max_gap_ms != null)
179
+ for (const [g, after] of gaps)
180
+ if (g > c.max_gap_ms)
181
+ breaches.push({
182
+ kind: "MAX_GAP_EXCEEDED",
183
+ detail: `${g}ms of silence > committed max ${c.max_gap_ms}ms`,
184
+ ...(after ? { receipt: receiptHash(after) } : {}),
185
+ });
186
+ }
187
+ else if (c.max_gap_ms != null && asOfMs - winStart > c.max_gap_ms)
188
+ breaches.push({
189
+ kind: "MAX_GAP_EXCEEDED",
190
+ detail: `no matching actions for ${asOfMs - winStart}ms > committed max ${c.max_gap_ms}ms`,
191
+ });
192
+ return {
193
+ ok: breaches.length === 0,
194
+ breaches,
195
+ metrics: { actions: matching.length, max_gap_ms: maxGap },
196
+ };
197
+ }
@@ -0,0 +1,50 @@
1
+ export type SuccessionReason = "rotation" | "compromise" | "retirement";
2
+ export interface SuccessionBody {
3
+ v: 1;
4
+ type: "succession";
5
+ predecessor: string;
6
+ successor: string;
7
+ owner: string;
8
+ reason: SuccessionReason;
9
+ ts: string;
10
+ compromised_at?: string;
11
+ note?: string;
12
+ }
13
+ export interface Succession {
14
+ body: SuccessionBody;
15
+ signatures: Array<{
16
+ did: string;
17
+ sig: string;
18
+ }>;
19
+ }
20
+ export declare function buildSuccessionBody(input: {
21
+ predecessor: string;
22
+ successor: string;
23
+ owner: string;
24
+ reason: string;
25
+ ts: string;
26
+ compromised_at?: string;
27
+ note?: string;
28
+ }): SuccessionBody;
29
+ /** Signs as the owner (required) or the predecessor (optional: a planned handover). */
30
+ export declare const signSuccession: (body: SuccessionBody, did: string, seed: Uint8Array) => {
31
+ did: string;
32
+ sig: string;
33
+ };
34
+ export declare const assembleSuccession: (body: SuccessionBody, signatures: Succession["signatures"]) => Succession;
35
+ export declare const successionHash: (s: Succession) => string;
36
+ /** Structure sane and the owner signed; `cosigned` when the predecessor countersigned too. */
37
+ export declare function verifySuccession(s: unknown): {
38
+ ok: boolean;
39
+ reasons: string[];
40
+ cosigned: boolean;
41
+ };
42
+ /**
43
+ * Walks the successions back from `did`: every link verifies, links connect, one owner throughout
44
+ * (a change of owner is a sale, not a succession). `lineage` is oldest first.
45
+ */
46
+ export declare function verifyLineage(successions: unknown[], did: string): {
47
+ ok: boolean;
48
+ reasons: string[];
49
+ lineage: string[];
50
+ };
@@ -0,0 +1,123 @@
1
+ // Owner-signed succession of the gateway identity (spec/anchoring.md §7), in Zanii's format:
2
+ // byte-identical bodies, signatures and hashes to `@zanii/succession` / `zanii.succession`
3
+ // (vectors: spec/vectors/succession.json). The Python SDK re-exports zanii.succession.
4
+ //
5
+ // Succession proves continuity of authority (the owner says B follows A), not which code ran.
6
+ // After a compromise the predecessor's signature isn't asked for (the attacker holds that key), and
7
+ // `compromised_at` brackets the window its old receipts can be trusted in.
8
+ import { canonicalBytes, jcsHash, publicKeyFromDid } from "@zanii/core";
9
+ import { ed25519Sign, ed25519Verify } from "../transparency/index.js";
10
+ const REASONS = new Set(["rotation", "compromise", "retirement"]);
11
+ const validTs = (ts) => typeof ts === "string" && !Number.isNaN(Date.parse(ts));
12
+ export function buildSuccessionBody(input) {
13
+ const { predecessor, successor, owner, reason, ts, compromised_at, note } = input;
14
+ if (!predecessor || !successor || !owner)
15
+ throw new Error("predecessor, successor, and owner are required");
16
+ if (predecessor === successor)
17
+ throw new Error("an agent cannot succeed itself");
18
+ if (!REASONS.has(reason))
19
+ throw new Error(`invalid reason '${reason}'`);
20
+ if (!validTs(ts))
21
+ throw new Error("ts must be an ISO timestamp");
22
+ if (reason === "compromise") {
23
+ if (!validTs(compromised_at))
24
+ throw new Error("a 'compromise' succession requires compromised_at");
25
+ }
26
+ else if (compromised_at !== undefined)
27
+ throw new Error("compromised_at is only meaningful with reason 'compromise'");
28
+ return {
29
+ v: 1,
30
+ type: "succession",
31
+ predecessor,
32
+ successor,
33
+ owner,
34
+ reason: reason,
35
+ ts,
36
+ ...(compromised_at !== undefined ? { compromised_at } : {}),
37
+ ...(note !== undefined ? { note } : {}),
38
+ };
39
+ }
40
+ /** Signs as the owner (required) or the predecessor (optional: a planned handover). */
41
+ export const signSuccession = (body, did, seed) => ({
42
+ did,
43
+ sig: `ed25519:${Buffer.from(ed25519Sign(seed, canonicalBytes(body))).toString("hex")}`,
44
+ });
45
+ export const assembleSuccession = (body, signatures) => ({ body, signatures });
46
+ export const successionHash = (s) => jcsHash(s);
47
+ function sigValid(body, did, sig) {
48
+ const pub = publicKeyFromDid(did);
49
+ const m = typeof sig === "string" ? /^ed25519:([0-9a-f]{128})$/.exec(sig) : null;
50
+ if (!pub || !m)
51
+ return false;
52
+ return ed25519Verify(pub, canonicalBytes(body), Buffer.from(m[1], "hex"));
53
+ }
54
+ /** Structure sane and the owner signed; `cosigned` when the predecessor countersigned too. */
55
+ export function verifySuccession(s) {
56
+ const reasons = [];
57
+ const e = (s ?? {});
58
+ const b = (e.body ?? {});
59
+ if (b.v !== 1 || b.type !== "succession")
60
+ reasons.push("not a succession (v1)");
61
+ const { predecessor: pred, successor: succ, owner } = b;
62
+ if (!pred || !succ || !owner || pred === succ)
63
+ reasons.push("missing/invalid parties");
64
+ if (!REASONS.has(String(b.reason)))
65
+ reasons.push("invalid reason");
66
+ if (b.reason === "compromise" && !validTs(b.compromised_at))
67
+ reasons.push("a 'compromise' succession requires a valid compromised_at");
68
+ if (!validTs(b.ts))
69
+ reasons.push("missing/invalid ts");
70
+ let cosigned = false;
71
+ if (reasons.length === 0) {
72
+ const sigs = Array.isArray(e.signatures) ? e.signatures : [];
73
+ const ownerSig = sigs.find((x) => x?.did === owner);
74
+ if (!ownerSig || !sigValid(b, ownerSig.did, ownerSig.sig))
75
+ reasons.push("missing/invalid OWNER signature");
76
+ const predSig = sigs.find((x) => x?.did === pred);
77
+ cosigned = predSig !== undefined && sigValid(b, predSig.did, predSig.sig);
78
+ }
79
+ return { ok: reasons.length === 0, reasons, cosigned };
80
+ }
81
+ /**
82
+ * Walks the successions back from `did`: every link verifies, links connect, one owner throughout
83
+ * (a change of owner is a sale, not a succession). `lineage` is oldest first.
84
+ */
85
+ export function verifyLineage(successions, did) {
86
+ if (successions.length === 0)
87
+ return { ok: true, reasons: [], lineage: [did] };
88
+ const bySuccessor = new Map();
89
+ for (const s of successions) {
90
+ const check = verifySuccession(s);
91
+ if (!check.ok) {
92
+ const label = s?.body?.successor ?? "?";
93
+ return { ok: false, reasons: check.reasons.map((r) => `link ${label}: ${r}`), lineage: [] };
94
+ }
95
+ const link = s;
96
+ if (bySuccessor.has(link.body.successor))
97
+ return {
98
+ ok: false,
99
+ reasons: [`two successions claim successor ${link.body.successor}`],
100
+ lineage: [],
101
+ };
102
+ bySuccessor.set(link.body.successor, link);
103
+ }
104
+ const links = successions;
105
+ const owner = links[0]?.body.owner;
106
+ if (!links.every((s) => s.body.owner === owner))
107
+ return { ok: false, reasons: ["lineage spans multiple owners"], lineage: [] };
108
+ const lineage = [did];
109
+ const seen = new Set([did]);
110
+ let cursor = did;
111
+ for (let link = bySuccessor.get(cursor); link; link = bySuccessor.get(cursor)) {
112
+ const prev = link.body.predecessor;
113
+ if (seen.has(prev))
114
+ return { ok: false, reasons: ["succession cycle detected"], lineage: [] };
115
+ seen.add(prev);
116
+ lineage.unshift(prev);
117
+ cursor = prev;
118
+ }
119
+ const reasons = lineage.length === links.length + 1
120
+ ? []
121
+ : [`${links.length + 1 - lineage.length} succession(s) do not connect to ${did}'s lineage`];
122
+ return { ok: reasons.length === 0, reasons, lineage };
123
+ }
@@ -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
+ }
@@ -0,0 +1,6 @@
1
+ /** The token for one value: `<kind_tok:base64url(nonce ‖ AES-256-GCM(value, aad=kind))>`. */
2
+ export declare function tokenFor(kind: string, value: string, key: Uint8Array): string;
3
+ /** A token's value, or null when it isn't one, or isn't this key's. */
4
+ export declare function resolveToken(token: string, key: Uint8Array): string | null;
5
+ /** Every token in `text` this key resolves, put back; any other is left as it is. */
6
+ export declare function resolveTokens(text: string, key: Uint8Array): string;