@zanii/certify 0.0.0-stage → 0.6.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 (141) hide show
  1. package/LICENSE +159 -0
  2. package/README.md +144 -2
  3. package/dist/appliance-execution.d.ts +18 -0
  4. package/dist/appliance-execution.js +12 -0
  5. package/dist/appliance.d.ts +135 -0
  6. package/dist/appliance.js +253 -0
  7. package/dist/assurance-status.d.ts +58 -0
  8. package/dist/assurance-status.js +86 -0
  9. package/dist/audit-renewal.d.ts +39 -0
  10. package/dist/audit-renewal.js +62 -0
  11. package/dist/audit.d.ts +69 -0
  12. package/dist/audit.js +112 -0
  13. package/dist/beacon-client.d.ts +40 -0
  14. package/dist/beacon-client.js +151 -0
  15. package/dist/beacon.d.ts +84 -0
  16. package/dist/beacon.js +106 -0
  17. package/dist/bitstring-status.d.ts +56 -0
  18. package/dist/bitstring-status.js +135 -0
  19. package/dist/board.d.ts +218 -0
  20. package/dist/board.js +428 -0
  21. package/dist/bundle.d.ts +100 -0
  22. package/dist/bundle.js +122 -0
  23. package/dist/certificate.d.ts +161 -0
  24. package/dist/certificate.js +346 -0
  25. package/dist/cli-messages.json +148 -0
  26. package/dist/cli.d.ts +16 -0
  27. package/dist/cli.js +369 -0
  28. package/dist/current-issuer-vc.d.ts +20 -0
  29. package/dist/current-issuer-vc.js +60 -0
  30. package/dist/evidence-share.d.ts +89 -0
  31. package/dist/evidence-share.js +479 -0
  32. package/dist/federation.d.ts +103 -0
  33. package/dist/federation.js +240 -0
  34. package/dist/feed-snapshot.d.ts +97 -0
  35. package/dist/feed-snapshot.js +186 -0
  36. package/dist/fingerprint.d.ts +23 -0
  37. package/dist/fingerprint.js +48 -0
  38. package/dist/formats/status-v2.schema.json +31 -0
  39. package/dist/governance.d.ts +244 -0
  40. package/dist/governance.js +418 -0
  41. package/dist/identity.d.ts +79 -0
  42. package/dist/identity.js +146 -0
  43. package/dist/impartiality.d.ts +47 -0
  44. package/dist/impartiality.js +85 -0
  45. package/dist/index.d.ts +61 -0
  46. package/dist/index.js +63 -0
  47. package/dist/issuer-authorization.d.ts +4 -0
  48. package/dist/issuer-authorization.js +52 -0
  49. package/dist/issuer-resolution.d.ts +25 -0
  50. package/dist/issuer-resolution.js +89 -0
  51. package/dist/licence-owner-entry.d.ts +20 -0
  52. package/dist/licence-owner-entry.js +33 -0
  53. package/dist/licence-owner.d.ts +67 -0
  54. package/dist/licence-owner.js +180 -0
  55. package/dist/licence.d.ts +174 -0
  56. package/dist/licence.js +400 -0
  57. package/dist/live-evidence.d.ts +135 -0
  58. package/dist/live-evidence.js +210 -0
  59. package/dist/nitro-roots.d.ts +3 -0
  60. package/dist/nitro-roots.js +20 -0
  61. package/dist/nitro-x509.d.ts +16 -0
  62. package/dist/nitro-x509.js +141 -0
  63. package/dist/nitro.d.ts +74 -0
  64. package/dist/nitro.js +329 -0
  65. package/dist/normalization.d.ts +8 -0
  66. package/dist/normalization.js +10 -0
  67. package/dist/pq.d.ts +32 -0
  68. package/dist/pq.js +69 -0
  69. package/dist/profile.d.ts +15 -0
  70. package/dist/profile.js +165 -0
  71. package/dist/register.d.ts +79 -0
  72. package/dist/register.js +134 -0
  73. package/dist/rotation.d.ts +49 -0
  74. package/dist/rotation.js +124 -0
  75. package/dist/royalty-reconciliation.d.ts +24 -0
  76. package/dist/royalty-reconciliation.js +88 -0
  77. package/dist/runtime-client.d.ts +40 -0
  78. package/dist/runtime-client.js +190 -0
  79. package/dist/runtime-gateway.d.ts +26 -0
  80. package/dist/runtime-gateway.js +169 -0
  81. package/dist/runtime-knowledge.d.ts +28 -0
  82. package/dist/runtime-knowledge.js +75 -0
  83. package/dist/runtime-nitro.d.ts +30 -0
  84. package/dist/runtime-nitro.js +57 -0
  85. package/dist/runtime.d.ts +226 -0
  86. package/dist/runtime.js +254 -0
  87. package/dist/sandbox-delegation.d.ts +38 -0
  88. package/dist/sandbox-delegation.js +61 -0
  89. package/dist/schema-dialect.d.ts +1 -0
  90. package/dist/schema-dialect.js +97 -0
  91. package/dist/schemas/clause-map.schema.json +52 -0
  92. package/dist/schemas/profile.schema.json +81 -0
  93. package/dist/schemas/scenarios.schema.json +573 -0
  94. package/dist/schemas/suite.schema.json +138 -0
  95. package/dist/schemas/trajectory.schema.json +1396 -0
  96. package/dist/scheme-authority.d.ts +5 -0
  97. package/dist/scheme-authority.js +15 -0
  98. package/dist/scheme-entry.d.ts +25 -0
  99. package/dist/scheme-entry.js +85 -0
  100. package/dist/scheme-history.d.ts +5 -0
  101. package/dist/scheme-history.js +87 -0
  102. package/dist/scheme-log.d.ts +98 -0
  103. package/dist/scheme-log.js +266 -0
  104. package/dist/scheme-merkle.d.ts +2 -0
  105. package/dist/scheme-merkle.js +20 -0
  106. package/dist/scheme-text.d.ts +25 -0
  107. package/dist/scheme-text.js +46 -0
  108. package/dist/scheme-timing.d.ts +16 -0
  109. package/dist/scheme-timing.js +22 -0
  110. package/dist/scheme-transition.d.ts +47 -0
  111. package/dist/scheme-transition.js +101 -0
  112. package/dist/scoring.d.ts +35 -0
  113. package/dist/scoring.js +50 -0
  114. package/dist/shared.d.ts +16 -0
  115. package/dist/shared.js +20 -0
  116. package/dist/signer.d.ts +18 -0
  117. package/dist/signer.js +49 -0
  118. package/dist/signing-identity.d.ts +1 -0
  119. package/dist/signing-identity.js +11 -0
  120. package/dist/status.d.ts +113 -0
  121. package/dist/status.js +204 -0
  122. package/dist/succession.d.ts +21 -0
  123. package/dist/succession.js +67 -0
  124. package/dist/suite.d.ts +261 -0
  125. package/dist/suite.js +458 -0
  126. package/dist/trajectory.d.ts +216 -0
  127. package/dist/trajectory.js +409 -0
  128. package/dist/transcript-provenance.d.ts +25 -0
  129. package/dist/transcript-provenance.js +37 -0
  130. package/dist/transport-key.d.ts +3 -0
  131. package/dist/transport-key.js +16 -0
  132. package/dist/vc.d.ts +29 -0
  133. package/dist/vc.js +96 -0
  134. package/dist/verification-bounds.d.ts +5 -0
  135. package/dist/verification-bounds.js +78 -0
  136. package/dist/verification-policy.d.ts +32 -0
  137. package/dist/verification-policy.js +85 -0
  138. package/dist/witness.d.ts +19 -0
  139. package/dist/witness.js +80 -0
  140. package/package.json +65 -4
  141. package/src/cli.ts +331 -0
@@ -0,0 +1,479 @@
1
+ // spec/evidence-share.md: evidence shared outside the lab. Customer identifiers and configured
2
+ // secrets are replaced by keyed tokens (Blackbox identifier detection and fingerprints), and every
3
+ // entry is committed field by field (@zanii/redact) so the receiver checks what it was shown
4
+ // against the chain heads without seeing what was removed.
5
+ import { createHmac, hkdfSync } from "node:crypto";
6
+ import { CORE_IDENTIFIERS, canaryIdentifier, dataFingerprint, luhn, normalizeValue } from "@zanii/blackbox";
7
+ import { canonicalBytes, jcsHash, leafHash, merkleRoot, verifySignedObject } from "@zanii/core";
8
+ import { flattenJson, verifyDisclosure } from "@zanii/redact";
9
+ import { isIso, isObj, isSha, sortedUnique } from "./shared.js";
10
+ import { signWith } from "./signer.js";
11
+ import { withinVerificationBudget } from "./verification-bounds.js";
12
+ const SHARE_SOURCE_BYTES = 8_388_608, SHARE_ENTRY_BYTES = 2_097_152, SHARE_FIELDS = 50_000, SHARE_ENTRY_FIELDS = 4096;
13
+ const sourceBudget = (value, maxBytes = SHARE_SOURCE_BYTES) => withinVerificationBudget(value, { maxBytes, maxDepth: 24, maxNodes: 250_000 });
14
+ const redactionOptionsBudget = (opts) => withinVerificationBudget({ secrets: opts.secrets ?? [], kinds: opts.kinds ?? [], withhold: opts.withhold ?? [] }, { maxBytes: 65_536, maxDepth: 4, maxNodes: 4096 });
15
+ /** Section 1: the GCC formats Blackbox's core identifiers do not cover. */
16
+ export const GCC_IDENTIFIERS = [
17
+ { id: "emirates_id", title: "Emirates ID", pattern: "(?<![0-9])784[ -]?[0-9]{4}[ -]?[0-9]{7}[ -]?[0-9](?![0-9])", normalize: "digits", personal: true },
18
+ {
19
+ id: "phone_gcc",
20
+ title: "Phone number (GCC)",
21
+ pattern: "(?<![0-9+])(?:(?:\\+|00)(?:971|966|965|974|973|968)[ -]?[0-9](?:[ -]?[0-9]){6,8}|05[0-9](?:[ -]?[0-9]){7})(?![0-9])",
22
+ normalize: "digits",
23
+ personal: true,
24
+ },
25
+ {
26
+ id: "passport",
27
+ title: "Passport number",
28
+ pattern: "(?:[Pp]assport|PASSPORT|جواز(?: السفر)?)(?: (?:[Nn]o\\.?|[Nn]umber|رقم))?[ :#]{0,3}([A-Z0-9]{6,9})(?![A-Za-z0-9])",
29
+ normalize: "upper",
30
+ personal: true,
31
+ group: 1,
32
+ },
33
+ ];
34
+ const byId = new Map([...CORE_IDENTIFIERS, ...GCC_IDENTIFIERS].map((i) => [i.id, i]));
35
+ /** Section 1: earlier kinds win an overlapping span; secrets come before all of them. */
36
+ const ORDER = ["emirates_id", "card", "iban", "email", "phone_e164", "phone_gcc", "passport"];
37
+ /** Section 1: every identifier kind `redactEvidence` knows, sorted. */
38
+ export const REDACTION_KINDS = sortedUnique(ORDER);
39
+ const TENANT = "certify.evidence-share";
40
+ const TOKEN = /\[redacted:[a-z0-9_]{1,32}:[0-9a-f]{32}\]/;
41
+ /** Top-level entry fields that are the chain's own links, never redacted. */
42
+ const LINKS = new Set(["seq", "prev", "hash"]);
43
+ const keyBytes = (key) => (typeof key === "string" ? Buffer.from(key, "utf8") : key);
44
+ /** Section 1: the token key and the salt key: the redaction key itself (version 1), or HKDF-SHA256 of it under the run's seed. */
45
+ function keysOf(opts) {
46
+ if (!(typeof opts.key === "string" || opts.key instanceof Uint8Array) || keyBytes(opts.key).length < 16)
47
+ throw new Error("key must be at least 16 bytes");
48
+ const seed = opts.saltSeed;
49
+ if (seed === undefined)
50
+ return { token: opts.key, salt: opts.key };
51
+ if (!(seed instanceof Uint8Array) || seed.length !== 32)
52
+ throw new Error("saltSeed must be 32 bytes");
53
+ const derive = (label) => new Uint8Array(hkdfSync("sha256", keyBytes(opts.key), seed, label, 32));
54
+ return { token: derive("certify-evidence-token-v2"), salt: derive("certify-evidence-salt-v2") };
55
+ }
56
+ function identifiersOf(opts) {
57
+ const kinds = opts.kinds ?? REDACTION_KINDS;
58
+ if (!Array.isArray(kinds) || !kinds.every((k) => REDACTION_KINDS.includes(k)))
59
+ throw new Error(`kinds must be among ${REDACTION_KINDS.join(", ")}`);
60
+ const secrets = opts.secrets ?? [];
61
+ if (!Array.isArray(secrets) || !secrets.every((s) => typeof s === "string"))
62
+ throw new Error("secrets must be strings");
63
+ const out = secrets.some((s) => s !== "") ? [{ ...canaryIdentifier(secrets), id: "secret", title: "Configured secret" }] : [];
64
+ return [...out, ...ORDER.filter((k) => kinds.includes(k)).map((k) => byId.get(k))];
65
+ }
66
+ function withholdOf(opts) {
67
+ const w = opts.withhold ?? [];
68
+ if (!Array.isArray(w) || !w.every((p) => typeof p === "string" && /^\/./.test(p)))
69
+ throw new Error("withhold must be RFC 6901 pointers");
70
+ return [...w];
71
+ }
72
+ /** Section 1: Unicode spaces as a space, and a dot or slash between two digits as a space (one code unit for one). */
73
+ const SPACES = /[   -    ]/g;
74
+ const DIGIT_SEPARATOR = /(?<=[0-9])[./](?=[0-9])/g;
75
+ const fold = (s) => s.replace(SPACES, " ").replace(DIGIT_SEPARATOR, " ");
76
+ const asciiDigits = (s) => s.replace(/[٠-٩]/g, (c) => String.fromCharCode(c.charCodeAt(0) - 0x0660 + 48)).replace(/[۰-۹]/g, (c) => String.fromCharCode(c.charCodeAt(0) - 0x06f0 + 48));
77
+ /** Section 1: wrapped values: percent-encoded runs (holding a `%XX`) and base64 tokens of 8 to 1024 characters. */
78
+ const PERCENT_RUN = /(?:[A-Za-z0-9._~+-]|%[0-9A-Fa-f]{2})+/g;
79
+ const JSON_ESCAPE_RUN = /(?:[A-Za-z0-9._~+-]|\\u[0-9A-Fa-f]{4})+/g;
80
+ const QUOTED_PRINTABLE_RUN = /(?:[A-Za-z0-9._~+-]|=[0-9A-Fa-f]{2}|=\r?\n)+/g;
81
+ const HTML_ENTITY_RUN = /(?:[A-Za-z0-9._~+-]|&(?:#(?:[xX][0-9A-Fa-f]{1,6}|[0-9]{1,7});?|[A-Za-z][A-Za-z0-9]{1,31};))+/g;
82
+ const HEX_RUN = /(?<![0-9A-Fa-f])(?:[0-9A-Fa-f]{2}){4,512}(?![0-9A-Fa-f])/g;
83
+ const BASE64_RUN = /(?<![A-Za-z0-9+/=_-])[A-Za-z0-9+/_-]{8,1024}={0,2}(?![A-Za-z0-9+/=_-])/g;
84
+ const B64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
85
+ const utf8 = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
86
+ const HTML_ASCII = {
87
+ AMP: "&", DiacriticalGrave: "`", GT: ">", Hat: "^", LT: "<", MediumSpace: "\u205f", NewLine: "\n", NonBreakingSpace: "\u00a0", QUOT: "\"", Tab: "\t", UnderBar: "_", VerticalLine: "|",
88
+ ThinSpace: "\u2009", VeryThinSpace: "\u200a", amp: "&", apos: "'", ast: "*", bsol: "\\", colon: ":", comma: ",", commat: "@", dollar: "$", emsp: "\u2003", emsp13: "\u2004", emsp14: "\u2005", ensp: "\u2002", equals: "=", excl: "!", grave: "`", gt: ">", hairsp: "\u200a", lbrace: "{", lbrack: "[", lcub: "{", lowbar: "_", lpar: "(", lsqb: "[", lt: "<", midast: "*", nbsp: "\u00a0", num: "#", numsp: "\u2007", percnt: "%", period: ".", plus: "+", puncsp: "\u2008", quest: "?", quot: "\"", rbrace: "}", rbrack: "]", rcub: "}", rpar: ")", rsqb: "]", semi: ";", sol: "/", thinsp: "\u2009", verbar: "|", vert: "|",
89
+ };
90
+ function strictText(bytes) {
91
+ try {
92
+ return utf8.decode(Uint8Array.from(bytes));
93
+ }
94
+ catch {
95
+ return null;
96
+ }
97
+ }
98
+ /** Base64, standard or URL-safe, padding optional, the unused bits zero, as strict UTF-8; else null. */
99
+ function unBase64(s) {
100
+ const body = s.replace(/=+$/, "");
101
+ if (body.length % 4 === 1)
102
+ return null;
103
+ const out = [];
104
+ let acc = 0;
105
+ let bits = 0;
106
+ for (const ch of body) {
107
+ acc = (acc << 6) | B64.indexOf(ch === "-" ? "+" : ch === "_" ? "/" : ch);
108
+ bits += 6;
109
+ if (bits >= 8) {
110
+ bits -= 8;
111
+ out.push((acc >> bits) & 255);
112
+ }
113
+ acc &= (1 << bits) - 1;
114
+ }
115
+ return acc === 0 ? strictText(out) : null;
116
+ }
117
+ /** A percent-encoded run's bytes as strict UTF-8; else null. */
118
+ function unPercent(s) {
119
+ const out = [];
120
+ for (let i = 0; i < s.length; i++) {
121
+ if (s[i] === "%") {
122
+ out.push(Number.parseInt(s.slice(i + 1, i + 3), 16));
123
+ i += 2;
124
+ }
125
+ else
126
+ out.push(s.charCodeAt(i));
127
+ }
128
+ return strictText(out);
129
+ }
130
+ function unJsonEscapes(s) {
131
+ return s.includes("\\u") ? s.replace(/\\u([0-9A-Fa-f]{4})/g, (_m, hex) => String.fromCharCode(Number.parseInt(hex, 16))) : null;
132
+ }
133
+ function unQuotedPrintable(s) {
134
+ if (!/=[0-9A-Fa-f]{2}|=\r?\n/.test(s))
135
+ return null;
136
+ const out = [];
137
+ for (let i = 0; i < s.length; i++) {
138
+ if (s[i] !== "=")
139
+ out.push(s.charCodeAt(i));
140
+ else if (s[i + 1] === "\r" || s[i + 1] === "\n")
141
+ i += s[i + 1] === "\r" ? 2 : 1;
142
+ else {
143
+ out.push(Number.parseInt(s.slice(i + 1, i + 3), 16));
144
+ i += 2;
145
+ }
146
+ }
147
+ return strictText(out);
148
+ }
149
+ function unHex(s) {
150
+ if (s.length % 2)
151
+ return null;
152
+ const out = [];
153
+ for (let i = 0; i < s.length; i += 2)
154
+ out.push(Number.parseInt(s.slice(i, i + 2), 16));
155
+ return strictText(out);
156
+ }
157
+ function unHtml(s) {
158
+ if (!s.includes("&"))
159
+ return null;
160
+ return s.replace(/&(?:#(?:[xX][0-9A-Fa-f]{1,6}|[0-9]{1,7});?|[A-Za-z][A-Za-z0-9]{1,31};)/g, (ref) => {
161
+ const numeric = /^&#([xX]?)([0-9A-Fa-f]+);?$/.exec(ref);
162
+ if (numeric) {
163
+ const point = Number.parseInt(numeric[2], numeric[1] ? 16 : 10);
164
+ return point > 0 && point <= 0x10ffff && !(point >= 0xd800 && point <= 0xdfff) ? String.fromCodePoint(point) : "\ufffd";
165
+ }
166
+ return HTML_ASCII[ref.slice(1, -1)] ?? ref;
167
+ });
168
+ }
169
+ const overlaps = (spans, start, end) => spans.some(([a, b]) => start < b && end > a);
170
+ const tokenOf = (key, kind, value) => `[redacted:${kind}:${dataFingerprint(key, TENANT, kind, value).slice(3)}]`;
171
+ /** Section 1: the matches in one text, kind by kind, then kind by kind in its folded form where it differs. */
172
+ function matches(text, idents) {
173
+ const spans = [];
174
+ for (const t of new Set([text, asciiDigits(text), fold(text), fold(asciiDigits(text))]))
175
+ for (const ident of idents)
176
+ for (const m of t.matchAll(new RegExp(ident.pattern, ident.group ? "gd" : "g"))) {
177
+ const [start, end] = ident.group ? m.indices[1] : [m.index, m.index + m[0].length];
178
+ if (end === start || overlaps(spans, start, end))
179
+ continue;
180
+ const value = normalizeValue(asciiDigits(text.slice(start, end)), ident.normalize);
181
+ if (value === "" || (ident.check === "luhn" && !luhn(value)))
182
+ continue;
183
+ spans.push([start, end, ident.id, value]);
184
+ }
185
+ return spans;
186
+ }
187
+ const wrappers = [[PERCENT_RUN, unPercent, true], [JSON_ESCAPE_RUN, unJsonEscapes, false], [QUOTED_PRINTABLE_RUN, unQuotedPrintable, false], [HTML_ENTITY_RUN, unHtml, false], [HEX_RUN, unHex, false], [BASE64_RUN, unBase64, false]];
188
+ function redactTextAt(text, idents, key, counts, depth) {
189
+ const taken = matches(text, idents);
190
+ const spans = taken.map(([a, b, kind, value]) => {
191
+ counts[kind] = (counts[kind] ?? 0) + 1;
192
+ return [a, b, tokenOf(key, kind, value)];
193
+ });
194
+ if (depth < 2)
195
+ for (const [re, decode, percent] of wrappers)
196
+ for (const m of text.matchAll(re)) {
197
+ const [start, end] = [m.index, m.index + m[0].length];
198
+ if ((percent && !m[0].includes("%")) || overlaps(taken, start, end))
199
+ continue;
200
+ const inner = decode(m[0]);
201
+ if (inner === null)
202
+ continue;
203
+ const redacted = redactTextAt(inner, idents, key, counts, depth + 1);
204
+ if (!redacted.changed)
205
+ continue;
206
+ taken.push([start, end, "", ""]);
207
+ spans.push([start, end, redacted.text]);
208
+ }
209
+ let out = text;
210
+ for (const [a, b, t] of spans.sort((x, y) => y[0] - x[0]))
211
+ out = out.slice(0, a) + t + out.slice(b);
212
+ return { text: out, changed: spans.length > 0 };
213
+ }
214
+ /** Section 1: one string with every direct or wrapped match replaced; counts added to `counts`. */
215
+ function redactText(text, idents, key, counts) {
216
+ return redactTextAt(text, idents, key, counts, 0).text;
217
+ }
218
+ const escapePointer = (k) => k.replace(/~/g, "~0").replace(/\//g, "~1");
219
+ /** Section 1: each leaf of a withheld field as a `withheld` token of its JCS text; containers stay, so the fields are the same. */
220
+ function withheldValue(v, key, counts) {
221
+ if (Array.isArray(v) && v.length)
222
+ return v.map((x) => withheldValue(x, key, counts));
223
+ if (isObj(v) && Object.keys(v).length)
224
+ return Object.fromEntries(Object.entries(v).map(([k, x]) => [k, withheldValue(x, key, counts)]));
225
+ counts.withheld = (counts.withheld ?? 0) + 1;
226
+ return tokenOf(key, "withheld", Buffer.from(canonicalBytes(v)).toString("utf8"));
227
+ }
228
+ function redactValue(v, w, at) {
229
+ if (w.withhold.includes(at))
230
+ return withheldValue(v, w.key, w.counts);
231
+ if (typeof v === "string")
232
+ return redactText(v, w.idents, w.key, w.counts);
233
+ if (Array.isArray(v))
234
+ return v.map((x, i) => redactValue(x, w, `${at}/${i}`));
235
+ if (isObj(v))
236
+ return Object.fromEntries(Object.entries(v).map(([k, x]) => [k, redactValue(x, w, `${at}/${escapePointer(k)}`)]));
237
+ return v;
238
+ }
239
+ const sortCounts = (c) => Object.fromEntries(sortedUnique(Object.keys(c)).map((k) => [k, c[k]]));
240
+ /**
241
+ * Section 1: the entries with every identifier of the chosen kinds and every configured secret
242
+ * replaced by a keyed token, in every string value (the top-level `seq`, `prev` and `hash` are
243
+ * kept), every withheld field replaced whole, and how many of each kind were replaced.
244
+ */
245
+ export function redactEvidence(entries, opts) {
246
+ if (!sourceBudget(entries) || !redactionOptionsBudget(opts))
247
+ throw new Error("evidence share source exceeds its JSON budget");
248
+ const w = { key: keysOf(opts).token, idents: identifiersOf(opts), withhold: withholdOf(opts), counts: {} };
249
+ if (!Array.isArray(entries) || !entries.every(isObj))
250
+ throw new Error("entries must be objects");
251
+ const out = entries.map((e) => Object.fromEntries(Object.entries(e).map(([k, v]) => [k, LINKS.has(k) ? v : redactValue(v, w, `/${escapePointer(k)}`)])));
252
+ return { entries: out, redactions: sortCounts(w.counts) };
253
+ }
254
+ // ---------------------------------------------------------------- 2. the share
255
+ export const AUDIENCES = ["reviewer", "regulator", "applicant"];
256
+ const CHAIN = /^[A-Za-z0-9._:-]{1,128}$/;
257
+ const MAX_ITEMS = 10_000;
258
+ const hex = (b) => Buffer.from(b).toString("hex");
259
+ const hmacHex = (key, data) => createHmac("sha256", keyBytes(key)).update(data).digest("hex");
260
+ const same = (a, b) => Buffer.from(canonicalBytes(a)).equals(Buffer.from(canonicalBytes(b)));
261
+ const withoutHash = (e) => Object.fromEntries(Object.entries(e).filter(([k]) => k !== "hash"));
262
+ /** Section 2: the chain rules: seq from 0, each prev the hash before (null first), each hash the entry's jcsHash. */
263
+ function chainError(chain, entries) {
264
+ if (!CHAIN.test(chain))
265
+ return "chain must match ^[A-Za-z0-9._:-]{1,128}$";
266
+ if (!Array.isArray(entries) || entries.length < 1)
267
+ return `chain ${chain} has no entries`;
268
+ for (const [i, e] of entries.entries()) {
269
+ if (!isObj(e) || e.seq !== i || e.prev !== (i === 0 ? null : entries[i - 1].hash) || !isSha(e.hash) || e.hash !== jcsHash(withoutHash(e)))
270
+ return `chain ${chain} entry ${i} does not chain: seq, prev or hash`;
271
+ }
272
+ return null;
273
+ }
274
+ /**
275
+ * Section 2: @zanii/redact's commitment to the fields, with salts derived from the salt key (its
276
+ * `commit` draws random ones), so the share is reproducible while the salt key exists; the envelope
277
+ * and disclosures are @zanii/redact's and verify with its `verifyDisclosure`.
278
+ */
279
+ function commitments(saltKey, chain, original) {
280
+ const fields = flattenJson(original);
281
+ const keys = sortedUnique(Object.keys(fields));
282
+ const salts = keys.map((k) => hmacHex(saltKey, `certify-evidence-salt\n${chain}\n${String(original.seq)}\n${k}`).slice(0, 32));
283
+ const leaves = keys.map((k, i) => leafHash(canonicalBytes(["zr-field-v1", k, fields[k], salts[i]])));
284
+ // Reuse each subtree across disclosures; rebuilding it per field made wide entries quadratic.
285
+ const levels = [leaves];
286
+ while (levels.at(-1).length > 1) {
287
+ const level = levels.at(-1);
288
+ const next = [];
289
+ for (let i = 0; i < level.length; i += 2)
290
+ next.push(i + 1 < level.length ? merkleRoot([level[i], level[i + 1]]) : level[i]);
291
+ levels.push(next);
292
+ }
293
+ const root = `sha256:${hex(levels.at(-1)[0])}`;
294
+ const disclose = (i) => {
295
+ const proof = [];
296
+ let at = i;
297
+ for (const level of levels.slice(0, -1)) {
298
+ const sibling = at % 2 ? at - 1 : at + 1;
299
+ if (sibling < level.length)
300
+ proof.push(hex(level[sibling]));
301
+ at = Math.floor(at / 2);
302
+ }
303
+ return { _zr: "sd-disclosure-v1", key: keys[i], value: fields[keys[i]], salt: salts[i], index: i, size: keys.length, proof, root };
304
+ };
305
+ return { fields, keys, root, disclose };
306
+ }
307
+ function commitEntry(saltKey, chain, original, redacted) {
308
+ const { fields, keys, root, disclose } = commitments(saltKey, chain, original);
309
+ const shown = flattenJson(redacted);
310
+ const disclosures = keys.flatMap((k, i) => (same(fields[k], shown[k]) ? [disclose(i)] : []));
311
+ return { envelope: { _zr: "sd-v1", root, keys }, disclosures };
312
+ }
313
+ /**
314
+ * Section 4: the @zanii/redact disclosure of one field (`pointer`) of an original chain entry, as a
315
+ * share built with the same key and seed committed it: a field the share hid, opened for one
316
+ * receiver. Throws when the entry has no such field.
317
+ */
318
+ export function discloseEvidenceField(entry, chain, pointer, opts) {
319
+ if (!sourceBudget(entry, SHARE_ENTRY_BYTES))
320
+ throw new Error("evidence entry exceeds its JSON budget");
321
+ if (!isObj(entry) || typeof chain !== "string" || !CHAIN.test(chain))
322
+ throw new Error("entry must be an object and chain a chain id");
323
+ if (Object.keys(flattenJson(entry)).length > SHARE_ENTRY_FIELDS)
324
+ throw new Error("evidence entry exceeds its field budget");
325
+ const c = commitments(keysOf(opts).salt, chain, entry);
326
+ const i = c.keys.indexOf(pointer);
327
+ if (i < 0)
328
+ throw new Error(`the entry has no field ${pointer}`);
329
+ return c.disclose(i);
330
+ }
331
+ /** Section 2: redact whole evidence chains and sign them, with their heads and commitments, for one audience. */
332
+ export async function buildEvidenceShare(f, signer) {
333
+ return signWith(buildEvidenceShareDraft(f), signer);
334
+ }
335
+ /** Internal detached-signing stage used by the server's bounded private broker worker. */
336
+ export function buildEvidenceShareDraft(f) {
337
+ if (!AUDIENCES.includes(f.audience))
338
+ throw new Error(`audience must be one of ${AUDIENCES.join(", ")}`);
339
+ if (!isIso(f.sharedAt))
340
+ throw new Error("sharedAt must be an ISO UTC timestamp");
341
+ if (!Array.isArray(f.chains) || f.chains.length < 1)
342
+ throw new Error("chains must list at least one chain");
343
+ if (f.chains.reduce((n, c) => n + (Array.isArray(c?.entries) ? c.entries.length : 0), 0) > MAX_ITEMS)
344
+ throw new Error(`at most ${MAX_ITEMS} entries`);
345
+ if (!sourceBudget(f.chains) || !redactionOptionsBudget(f))
346
+ throw new Error("evidence share source exceeds its JSON budget");
347
+ let fields = 0;
348
+ for (const chain of f.chains)
349
+ if (chain && Array.isArray(chain.entries))
350
+ for (const entry of chain.entries) {
351
+ if (!sourceBudget(entry, SHARE_ENTRY_BYTES))
352
+ throw new Error("evidence entry exceeds its JSON budget");
353
+ const count = Object.keys(flattenJson(entry)).length;
354
+ fields += count;
355
+ if (count > SHARE_ENTRY_FIELDS || fields > SHARE_FIELDS)
356
+ throw new Error("evidence share exceeds its field budget");
357
+ }
358
+ const chains = [...f.chains].sort((a, b) => (a.chain < b.chain ? -1 : a.chain > b.chain ? 1 : 0));
359
+ for (const [i, c] of chains.entries()) {
360
+ const err = chainError(c?.chain, c?.entries);
361
+ if (err)
362
+ throw new Error(err);
363
+ if (i > 0 && c.chain === chains[i - 1].chain)
364
+ throw new Error(`chain ${c.chain} is listed twice`);
365
+ }
366
+ const saltKey = keysOf(f).salt;
367
+ const counts = {};
368
+ const items = [];
369
+ for (const c of chains) {
370
+ const r = redactEvidence(c.entries, f);
371
+ for (const [k, n] of Object.entries(r.redactions))
372
+ counts[k] = (counts[k] ?? 0) + n;
373
+ c.entries.forEach((e, i) => items.push({ chain: c.chain, entry: r.entries[i], ...commitEntry(saltKey, c.chain, e, r.entries[i]) }));
374
+ }
375
+ const kinds = sortedUnique([
376
+ ...(f.kinds ?? REDACTION_KINDS),
377
+ ...((f.secrets ?? []).some((s) => s !== "") ? ["secret"] : []),
378
+ ...((f.withhold ?? []).length ? ["withheld"] : []),
379
+ ]);
380
+ const heads = chains.map((c) => ({ chain: c.chain, head: c.entries.at(-1).hash, size: c.entries.length }));
381
+ const doc = { v: 1, type: "certify.evidence-share", audience: f.audience, heads, kinds, redactions: sortCounts(counts), items, shared_at: f.sharedAt };
382
+ if (!withinVerificationBudget(doc, { maxBytes: 67_107_840 }))
383
+ throw new Error("evidence share exceeds its output JSON budget");
384
+ return doc;
385
+ }
386
+ const disclosureOk = (d, env) => {
387
+ try {
388
+ return verifyDisclosure(d, env);
389
+ }
390
+ catch {
391
+ return false;
392
+ }
393
+ };
394
+ /** Section 3: is every shared field committed, every other one a token, every chain whole and ending at its head? */
395
+ function itemReasons(items, heads) {
396
+ const out = [];
397
+ let at = 0;
398
+ for (const h of heads) {
399
+ let prev = null;
400
+ for (let seq = 0; seq < h.size; seq++, at++) {
401
+ const it = items[at];
402
+ const label = `${h.chain} entry ${seq}`;
403
+ if (!isObj(it) || it.chain !== h.chain || !isObj(it.entry) || !isObj(it.envelope) || !Array.isArray(it.disclosures)) {
404
+ out.push(`${label} is missing or malformed`);
405
+ return out;
406
+ }
407
+ const env = it.envelope;
408
+ const shown = flattenJson(it.entry);
409
+ const keys = sortedUnique(Object.keys(shown));
410
+ if (env._zr !== "sd-v1" || !Array.isArray(env.keys) || keys.join("\n") !== env.keys.join("\n"))
411
+ out.push(`${label}: its fields are not the committed ones`);
412
+ const disclosed = new Set();
413
+ const offered = new Set();
414
+ let bad = false;
415
+ for (const d of it.disclosures) {
416
+ const k = isObj(d) && typeof d.key === "string" ? d.key : null;
417
+ if (k === null || offered.has(k) || !(k in shown) || !disclosureOk(d, env) || !same(d.value, shown[k]))
418
+ bad = true;
419
+ else
420
+ disclosed.add(k);
421
+ if (k !== null)
422
+ offered.add(k);
423
+ }
424
+ if (bad)
425
+ out.push(`${label}: a disclosure does not verify against its commitment`);
426
+ for (const k of keys)
427
+ if (!offered.has(k) && !(typeof shown[k] === "string" && TOKEN.test(shown[k])))
428
+ out.push(`${label}: field ${k} is neither disclosed nor redacted`);
429
+ const e = it.entry;
430
+ if (!disclosed.has("/seq") || !disclosed.has("/hash") || !disclosed.has("/prev") || e.seq !== seq || e.prev !== prev || !isSha(e.hash))
431
+ out.push(`${label}: seq, prev and hash must be disclosed and chain`);
432
+ prev = e.hash;
433
+ }
434
+ if (prev !== h.head)
435
+ out.push(`chain ${h.chain} does not end at its head`);
436
+ }
437
+ if (at !== items.length)
438
+ out.push("items hold entries of no listed chain");
439
+ return out;
440
+ }
441
+ const headOk = (h) => isObj(h) && Object.keys(h).length === 3 && typeof h.chain === "string" && CHAIN.test(h.chain) && isSha(h.head) && Number.isInteger(h.size) && h.size >= 1;
442
+ /**
443
+ * Section 3: `signers` are the lab DIDs this receiver trusts; `heads` the chain heads it holds from
444
+ * elsewhere (the result bundle's), each of which the share must carry unchanged.
445
+ */
446
+ export function verifyEvidenceShare(doc, opts = {}) {
447
+ if (!withinVerificationBudget(doc))
448
+ return { ok: false, reasons: ["evidence share exceeds its JSON verification budget"] };
449
+ const d = isObj(doc) ? doc : {};
450
+ const heads = d.heads;
451
+ if (d.v !== 1 || d.type !== "certify.evidence-share" || Object.keys(d).length !== 10 || !Array.isArray(heads) || !Array.isArray(d.items))
452
+ return { ok: false, reasons: ["not an evidence share (v1)"] };
453
+ const reasons = [];
454
+ if (!verifySignedObject(d))
455
+ reasons.push("bad evidence share signature");
456
+ if (!opts.signers)
457
+ reasons.push("no trusted signers supplied: the signer was NOT checked");
458
+ else if (!opts.signers.includes(d.signer))
459
+ reasons.push("evidence share signer is not a trusted lab key");
460
+ if (!AUDIENCES.includes(d.audience))
461
+ reasons.push("audience must be reviewer, regulator or applicant");
462
+ if (!isIso(d.shared_at))
463
+ reasons.push("shared_at must be an ISO timestamp");
464
+ const kinds = d.kinds;
465
+ if (!Array.isArray(kinds) || !kinds.every((k) => k === "secret" || k === "withheld" || REDACTION_KINDS.includes(k)) || sortedUnique(kinds).join("\n") !== kinds.join("\n"))
466
+ reasons.push("kinds must be sorted, distinct redaction kinds");
467
+ const r = d.redactions;
468
+ if (!isObj(r) || !Object.entries(r).every(([k, n]) => Array.isArray(kinds) && kinds.includes(k) && Number.isInteger(n) && n >= 1))
469
+ reasons.push("redactions must count kinds of the share");
470
+ if (heads.length < 1 || !heads.every(headOk) || heads.some((h, i) => i > 0 && !(h.chain > heads[i - 1].chain)) || heads.reduce((n, h) => n + h.size, 0) > MAX_ITEMS)
471
+ return { ok: false, reasons: [...reasons, "heads must be 1 or more sorted, distinct {chain, head, size}"] };
472
+ for (const want of opts.heads ?? []) {
473
+ const got = heads.find((h) => h.chain === want.chain);
474
+ if (!got || got.head !== want.head || got.size !== want.size)
475
+ reasons.push(`chain ${String(want.chain)} is not at the expected head`);
476
+ }
477
+ reasons.push(...itemReasons(d.items, heads));
478
+ return { ok: reasons.length === 0, reasons };
479
+ }
@@ -0,0 +1,103 @@
1
+ import { type SignedTreeHead } from "@zanii/core";
2
+ import { type Signer } from "./signer.ts";
3
+ declare const STATUSES: readonly ["valid", "suspended", "revoked"];
4
+ /** Section 1: the most entries one page carries. */
5
+ export declare const MAX_FEED_ENTRIES = 10000;
6
+ export declare const MAX_FEED_PAGE_BYTES: number;
7
+ export interface FeedEntry {
8
+ certificate: string;
9
+ profile: string;
10
+ level: number;
11
+ status: (typeof STATUSES)[number];
12
+ since: string;
13
+ licence: string | null;
14
+ }
15
+ export interface FeedPage {
16
+ v: 1;
17
+ type: "certify.feed";
18
+ instance: string;
19
+ region: string;
20
+ issuers: string[];
21
+ seq: number;
22
+ prev: string | null;
23
+ entries: FeedEntry[];
24
+ issued_at: string;
25
+ signer: string;
26
+ sig: string;
27
+ }
28
+ export interface FeedPageInput {
29
+ instance: string;
30
+ region: string;
31
+ issuers: readonly string[];
32
+ seq: number;
33
+ prev: string | null;
34
+ entries: readonly FeedEntry[];
35
+ issuedAt: string;
36
+ }
37
+ declare const entryOk: (e: unknown) => e is FeedEntry;
38
+ export { entryOk as isFeedEntry };
39
+ /** Section 1: one feed page, signed by one of the instance's issuers. */
40
+ export declare function buildFeedPage(f: FeedPageInput, signer: Signer): Promise<FeedPage>;
41
+ export interface FeedCheck {
42
+ ok: boolean;
43
+ reasons: string[];
44
+ /** The instance's current entries (the last state of each certificate), sorted by certificate. */
45
+ entries: FeedEntry[];
46
+ }
47
+ /** A previously authenticated prefix; callers must commit its entries and cursor atomically. */
48
+ export interface FeedCursor {
49
+ instance: string;
50
+ region: string | null;
51
+ size: number;
52
+ last_hash: string | null;
53
+ last_issued_at: string | null;
54
+ }
55
+ /** Validate one extension using authenticated prior certificate state, without reloading its history. */
56
+ export declare function verifyFeedPage(page: unknown, opts: {
57
+ cursor: FeedCursor;
58
+ issuers: readonly string[];
59
+ entry: (certificate: string) => FeedEntry | undefined;
60
+ }): FeedCheck;
61
+ /** Section 2: the signed tree head over the feed (leaf i = page i), by the issuer that signed the last page. */
62
+ export declare function feedCheckpoint(pages: readonly FeedPage[], ts: string, signer: Signer): Promise<SignedTreeHead>;
63
+ /**
64
+ * Section 1 and 2: the pages form one feed of one instance and region (page i at seq i naming page
65
+ * i - 1, issued_at never going back, each signed by one of its issuers and, with `issuers`, by a
66
+ * trusted one); a certificate keeps its profile, level and licence, its `since` never goes back and
67
+ * a revoked certificate stays revoked. With `checkpoint`, it is a tree head over exactly these pages
68
+ * signed by an issuer of the last page.
69
+ */
70
+ export declare function verifyFeed(pages: readonly unknown[], opts?: {
71
+ issuers?: readonly string[];
72
+ checkpoint?: SignedTreeHead;
73
+ }): FeedCheck;
74
+ export interface FederatedFeed {
75
+ pages: readonly unknown[];
76
+ /** The issuer DIDs the registry trusts for this instance. */
77
+ issuers: readonly string[];
78
+ checkpoint?: SignedTreeHead;
79
+ }
80
+ export interface FeedConflict {
81
+ kind: "instance" | "issuer" | "certificate";
82
+ /** The instance id, issuer DID or certificate hash in conflict. */
83
+ id: string;
84
+ /** The instances involved, sorted (an instance twice when two feeds claim one id). */
85
+ instances: string[];
86
+ }
87
+ export interface MergedRegistry {
88
+ entries: (FeedEntry & {
89
+ instance: string;
90
+ region: string;
91
+ })[];
92
+ conflicts: FeedConflict[];
93
+ rejected: {
94
+ instance: string;
95
+ reasons: string[];
96
+ }[];
97
+ }
98
+ /**
99
+ * Section 3: verify each feed and merge them. A feed that does not verify is rejected; an instance
100
+ * id published by two feeds, an issuer DID authenticated by signed pages of two instances and a certificate listed by two
101
+ * instances are conflicts. Conflicting instances, issuers and certificates are left out of `entries`.
102
+ */
103
+ export declare function mergeFeeds(feeds: readonly FederatedFeed[]): MergedRegistry;