@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
package/dist/board.js ADDED
@@ -0,0 +1,428 @@
1
+ // spec/governance-board.md: the scheme governance board (k of n; each membership change signed by
2
+ // k of the outgoing board, the first by the scheme owner) and the append-only log of scheme and
3
+ // profile releases the board signs. A lab may only use a scheme or profile version with a release.
4
+ import { jcsHash } from "@zanii/core";
5
+ import { isDid, isInt, isIso, isObj, isSha, ms, SLUG, sortedUnique } from "./shared.js";
6
+ import { signatureOk, signatureOver, signerDid } from "./signer.js";
7
+ import { logPositionOk } from "./status.js";
8
+ import { schemeAuthority } from "./scheme-authority.js";
9
+ export async function bodySignature(body, signer) {
10
+ if ("signatures" in body)
11
+ throw new Error("sign the body, without signatures");
12
+ return { signer: signerDid(signer), sig: await signatureOver(body, signer) };
13
+ }
14
+ const bySigner = (a, b) => (a.signer < b.signer ? -1 : a.signer > b.signer ? 1 : 0);
15
+ /** The document with its signatures sorted by signer; one signer signs once. */
16
+ export function withSignatures(body, sigs) {
17
+ if ("signatures" in body)
18
+ throw new Error("the body already carries signatures");
19
+ const sorted = sigs.map((s) => ({ signer: s.signer, sig: s.sig })).sort(bySigner);
20
+ if (new Set(sorted.map((s) => s.signer)).size !== sorted.length)
21
+ throw new Error("a signer signs once");
22
+ return { ...body, signatures: sorted };
23
+ }
24
+ /**
25
+ * Section 1: the signers of `doc` whose signature verifies, and whether `signatures` is well formed
26
+ * (1 to 64 `{signer, sig}`, strictly sorted by signer, each verifying, each in `allowed` when given).
27
+ */
28
+ export function signersOf(doc, allowed) {
29
+ const { signatures, ...body } = doc;
30
+ if (!Array.isArray(signatures) || signatures.length < 1 || signatures.length > (doc.type === "certify.board-transition" ? 65 : 64))
31
+ return { signers: [], wellFormed: false };
32
+ const signers = [];
33
+ let wellFormed = true;
34
+ let last = "";
35
+ for (const s of signatures) {
36
+ if (!isObj(s) || Object.keys(s).length !== 2 || !isDid(s.signer) || s.signer <= last) {
37
+ wellFormed = false;
38
+ continue;
39
+ }
40
+ last = s.signer;
41
+ if (!signatureOk(body, s.sig, s.signer) || (allowed && !allowed.includes(s.signer)))
42
+ wellFormed = false;
43
+ else
44
+ signers.push(s.signer);
45
+ }
46
+ return { signers, wellFormed };
47
+ }
48
+ const sortedDistinct = (xs) => Array.isArray(xs) && xs.every((x) => typeof x === "string") && sortedUnique(xs).join("\n") === xs.join("\n");
49
+ function boardError(b) {
50
+ if (!isDid(b.owner))
51
+ return "owner must be a did:key";
52
+ if (!logPositionOk(b.seq, b.prev))
53
+ return "seq and prev are not a log position";
54
+ const m = b.members;
55
+ if (!Array.isArray(m) || m.length < 1 || m.length > 64 || !m.every(isDid) || !sortedDistinct(m))
56
+ return "members must be 1 to 64 sorted, distinct did:keys";
57
+ if (!isInt(b.threshold, 1, m.length))
58
+ return "threshold must be an integer from 1 to the number of members";
59
+ if (!isIso(b.effective_from))
60
+ return "effective_from must be an ISO timestamp";
61
+ if ("ledger" in b && !isDid(b.ledger))
62
+ return "ledger must be a did:key";
63
+ return null;
64
+ }
65
+ /** The unsigned board (each signer signs it with `bodySignature`). */
66
+ export function boardBody(f) {
67
+ const body = {
68
+ v: 1,
69
+ type: "certify.board",
70
+ owner: f.owner,
71
+ seq: f.seq,
72
+ prev: f.prev,
73
+ members: Array.isArray(f.members) ? sortedUnique(f.members) : f.members,
74
+ threshold: f.threshold,
75
+ effective_from: f.effectiveFrom,
76
+ ...(f.ledger !== undefined ? { ledger: f.ledger } : {}),
77
+ };
78
+ const err = boardError(body);
79
+ if (err)
80
+ throw new Error(err);
81
+ return body;
82
+ }
83
+ /** A board signed by `signers`: the owner for the first board, k of the previous board after. */
84
+ export async function buildBoard(f, signers) {
85
+ const body = boardBody(f);
86
+ return withSignatures(body, await Promise.all(signers.map((s) => bodySignature(body, s))));
87
+ }
88
+ export const isBoard = (b) => isObj(b) && b.v === 1 && b.type === "certify.board" && Object.keys(b).length === ("ledger" in b ? 10 : 9);
89
+ const ownerReason = (owner, opts) => opts.owner === undefined
90
+ ? ["no trusted scheme owner supplied: the owner was NOT checked"]
91
+ : owner !== opts.owner
92
+ ? ["the board log is not the trusted scheme owner's"]
93
+ : [];
94
+ /**
95
+ * Section 2: the boards form one log: board i at seq i naming board i - 1, one owner, each taking
96
+ * effect after the previous one; board 0 signed by the owner, every later board by at least the
97
+ * previous board's threshold of its members, and no other signature.
98
+ */
99
+ export function verifyBoardLog(boards, opts = {}) {
100
+ if (!Array.isArray(boards) || boards.length === 0)
101
+ return { ok: false, reasons: ["the board log is empty"] };
102
+ const reasons = [];
103
+ boards.forEach((b, i) => {
104
+ const prev = i > 0 ? boards[i - 1] : null;
105
+ if (!isBoard(b)) {
106
+ reasons.push(`board ${i} is not a board (v1)`);
107
+ return;
108
+ }
109
+ const err = boardError(b);
110
+ if (err) {
111
+ reasons.push(`board ${i} is malformed: ${err}`);
112
+ return;
113
+ }
114
+ if (b.seq !== i)
115
+ reasons.push(`board ${i} is not at position ${i}`);
116
+ if (b.prev !== (i > 0 ? jcsHash(prev) : null))
117
+ reasons.push(`board ${i} does not name the previous board`);
118
+ if (i > 0 && isObj(boards[0]) && b.owner !== boards[0].owner)
119
+ reasons.push(`board ${i} names another scheme owner`);
120
+ if (i > 0 && isBoard(prev) && !(ms(b.effective_from) > ms(prev.effective_from)))
121
+ reasons.push(`board ${i} does not take effect after the previous board`);
122
+ const allowed = i === 0 ? [b.owner] : isBoard(prev) && Array.isArray(prev.members) ? prev.members : [];
123
+ const need = i === 0 ? 1 : isBoard(prev) && Number.isInteger(prev.threshold) ? prev.threshold : 1;
124
+ const s = signersOf(b, allowed);
125
+ if (!s.wellFormed)
126
+ reasons.push(`board ${i} carries a signature that is malformed, invalid or not by an authorised signer`);
127
+ if (i === 0 && s.signers.length < 1)
128
+ reasons.push("board 0 is not signed by the scheme owner");
129
+ if (i > 0 && s.signers.length < need)
130
+ reasons.push(`board ${i} is signed by ${s.signers.length} of the ${need} previous board members required`);
131
+ });
132
+ reasons.push(...ownerReason(isObj(boards[0]) ? boards[0].owner : undefined, opts));
133
+ return { ok: reasons.length === 0, reasons };
134
+ }
135
+ // ---------------------------------------------------------------- 3. releases
136
+ export const RELEASE_KINDS = ["scheme", "profile"];
137
+ const SCHEME_REF = /^zanii-certify\/(0|[1-9][0-9]{0,3})\.(0|[1-9][0-9]{0,3})$/;
138
+ const PROFILE_REF = new RegExp(`^${SLUG.source.slice(1, -1)}/[1-9][0-9]{0,5}$`);
139
+ const text = (v) => typeof v === "string" && v.length >= 1 && v.length <= 4000;
140
+ export const isText = (t) => isObj(t) && Object.keys(t).length === 2 && text(t.en) && text(t.ar);
141
+ export function subjectOk(s) {
142
+ if (!isObj(s) || Object.keys(s).length !== 2 || typeof s.ref !== "string")
143
+ return false;
144
+ return s.kind === "scheme" ? SCHEME_REF.test(s.ref) : s.kind === "profile" && PROFILE_REF.test(s.ref);
145
+ }
146
+ function releaseError(r) {
147
+ if (!logPositionOk(r.seq, r.prev))
148
+ return "seq and prev are not a log position";
149
+ if (!subjectOk(r.subject))
150
+ return "subject must be {kind: scheme, ref: zanii-certify/<m>.<n>} or {kind: profile, ref: <slug>/<version>}";
151
+ if (!isSha(r.sha256))
152
+ return "sha256 must be a sha256 hash";
153
+ if (!isIso(r.effective_from))
154
+ return "effective_from must be an ISO timestamp";
155
+ if (!isText(r.notes))
156
+ return "notes need en and ar text (1-4000)";
157
+ if (!isSha(r.board))
158
+ return "board must be a board hash";
159
+ return null;
160
+ }
161
+ export function releaseBody(f) {
162
+ const body = {
163
+ v: 1,
164
+ type: "certify.release",
165
+ seq: f.seq,
166
+ prev: f.prev,
167
+ subject: { kind: f.subject?.kind, ref: f.subject?.ref },
168
+ sha256: f.sha256,
169
+ effective_from: f.effectiveFrom,
170
+ notes: { en: f.notes?.en, ar: f.notes?.ar },
171
+ board: f.board,
172
+ };
173
+ const err = releaseError(body);
174
+ if (err)
175
+ throw new Error(err);
176
+ return body;
177
+ }
178
+ /** A release signed by `signers` (members of the board it names). */
179
+ export async function buildRelease(f, signers) {
180
+ const body = releaseBody(f);
181
+ return withSignatures(body, await Promise.all(signers.map((s) => bodySignature(body, s))));
182
+ }
183
+ export const isRelease = (r) => isObj(r) && r.v === 1 && r.type === "certify.release" && Object.keys(r).length === 10;
184
+ /**
185
+ * A board-signed document (release, withdrawal, body register) against a board log already checked:
186
+ * the board it names is in the log and in force at its effective_from, and k of its members signed it.
187
+ */
188
+ export function boardSignedReasons(r, boards, noun) {
189
+ const reasons = [];
190
+ const i = boards.findIndex((b) => isBoard(b) && jcsHash(b) === r.board);
191
+ const board = boards[i];
192
+ if (!isBoard(board))
193
+ return [`${noun} names a board that is not in the board log`];
194
+ const next = boards[i + 1];
195
+ const at = ms(r.effective_from);
196
+ if (!(ms(board.effective_from) <= at) || (isBoard(next) && !(ms(next.effective_from) > at)))
197
+ reasons.push(`the named board is not the board in force at the ${noun}'s effective_from`);
198
+ const need = Number.isInteger(board.threshold) ? board.threshold : 1;
199
+ const s = signersOf(r, Array.isArray(board.members) ? board.members : []);
200
+ if (!s.wellFormed)
201
+ reasons.push(`${noun} carries a signature that is malformed, invalid or not by a board member`);
202
+ if (s.signers.length < need)
203
+ reasons.push(`${noun} is signed by ${s.signers.length} of the ${need} board members required`);
204
+ return reasons;
205
+ }
206
+ /** One release against a board log already checked: fields, its board in force, k board signatures. */
207
+ function releaseReasons(r, boards) {
208
+ if (!isRelease(r))
209
+ return ["not a scheme release (v1)"];
210
+ const err = releaseError(r);
211
+ if (err)
212
+ return [`release is malformed: ${err}`];
213
+ return boardSignedReasons(r, boards, "release");
214
+ }
215
+ /** Section 3: one release, with the board log it names (checked with `verifyBoardLog`). */
216
+ export function verifyRelease(release, boards, opts = {}) {
217
+ const reasons = [...verifyBoardLog(boards, opts).reasons, ...releaseReasons(release, Array.isArray(boards) ? boards : [])];
218
+ return { ok: reasons.length === 0, reasons };
219
+ }
220
+ export const subjectKey = (s) => (isObj(s) ? `${String(s.kind)} ${String(s.ref)}` : "?");
221
+ /** Section 4: the release log: every release valid, at seq i naming release i - 1, effective_from never going back, each subject released once. */
222
+ export function verifyReleaseLog(releases, boards, opts = {}) {
223
+ if (opts.schemeLog !== undefined) {
224
+ const { reasons } = schemeAuthority(opts.schemeLog, opts.owner, { "certify.board": boards, "certify.release": releases });
225
+ return { ok: reasons.length === 0, reasons };
226
+ }
227
+ const reasons = [...verifyBoardLog(boards, opts).reasons];
228
+ if (!Array.isArray(releases) || releases.length === 0)
229
+ return { ok: false, reasons: [...reasons, "the release log is empty"] };
230
+ const seen = new Set();
231
+ releases.forEach((r, i) => {
232
+ reasons.push(...releaseReasons(r, Array.isArray(boards) ? boards : []).map((x) => `release ${i}: ${x}`));
233
+ if (!isRelease(r))
234
+ return;
235
+ const prev = i > 0 ? releases[i - 1] : null;
236
+ if (r.seq !== i)
237
+ reasons.push(`release ${i} is not at position ${i}`);
238
+ if (r.prev !== (i > 0 ? jcsHash(prev) : null))
239
+ reasons.push(`release ${i} does not name the previous release`);
240
+ if (i > 0 && isRelease(prev) && !(ms(r.effective_from) >= ms(prev.effective_from)))
241
+ reasons.push(`release ${i} takes effect before the previous release`);
242
+ const key = subjectKey(r.subject);
243
+ if (seen.has(key))
244
+ reasons.push(`release ${i} releases ${key} again`);
245
+ seen.add(key);
246
+ });
247
+ return { ok: reasons.length === 0, reasons };
248
+ }
249
+ /**
250
+ * Section 5: may a lab use `subject` at `at`? The release log must verify, release the subject, take
251
+ * effect no later than `at` and (with `sha256`) for exactly that content. With `withdrawals`
252
+ * (section 6), a release withdrawn at or before `at` may not be used.
253
+ */
254
+ export function requireRelease(releases, boards, subject, at, opts = {}) {
255
+ if (!isIso(at))
256
+ throw new Error("at must be an ISO timestamp");
257
+ const authority = opts.schemeLog !== undefined ? schemeAuthority(opts.schemeLog, opts.owner, { "certify.board": boards, "certify.release": releases, ...(opts.withdrawals ? { "certify.release-withdrawal": opts.withdrawals } : {}) }) : null;
258
+ const reasons = [...(authority ? authority.reasons : verifyReleaseLog(releases, boards, opts).reasons)];
259
+ const key = subjectKey(subject);
260
+ const r = (Array.isArray(releases) ? releases : []).find((x) => isRelease(x) && !authority?.canceled.has(jcsHash(x)) && subjectKey(x.subject) === key);
261
+ if (!r)
262
+ reasons.push(`${key} has no release`);
263
+ else {
264
+ if (!(ms(r.effective_from) <= ms(at)))
265
+ reasons.push(`${key} is not released until ${r.effective_from}`);
266
+ if (opts.sha256 !== undefined && r.sha256 !== opts.sha256)
267
+ reasons.push(`${key} content is not the released content`);
268
+ }
269
+ if (opts.withdrawals === undefined)
270
+ return { ok: reasons.length === 0, reasons, release: r ?? null };
271
+ if (!authority)
272
+ reasons.push(...withdrawalReasons(opts.withdrawals, releases, boards));
273
+ const w = r ? withdrawalOf((Array.isArray(opts.withdrawals) ? opts.withdrawals : []).filter(x => !authority?.canceled.size || !authority.canceled.has(jcsHash(x))), r) : null;
274
+ if (w && ms(w.effective_from) <= ms(at))
275
+ reasons.push(`${key} is withdrawn from ${w.effective_from} (${w.reason})`);
276
+ return { ok: reasons.length === 0, reasons, release: r ?? null, withdrawal: w };
277
+ }
278
+ /**
279
+ * Section 4a: may `board` be appended to the board log now? The board log with it verifies, it takes
280
+ * effect after the effective_from of every release already in the release log, and the release log
281
+ * still verifies against the longer board log (a board change never breaks a release).
282
+ */
283
+ export function verifyBoardAppend(boards, releases, board, opts = {}) {
284
+ const next = [...(Array.isArray(boards) ? boards : []), board];
285
+ const rel = Array.isArray(releases) ? releases : [];
286
+ const reasons = [...verifyBoardLog(next, opts).reasons];
287
+ if (isBoard(board) && isIso(board.effective_from) && rel.some((r) => isRelease(r) && isIso(r.effective_from) && !(ms(board.effective_from) > ms(r.effective_from))))
288
+ reasons.push("the board does not take effect after every release's effective_from");
289
+ if (rel.length)
290
+ reasons.push(...verifyReleaseLog(rel, next, opts).reasons);
291
+ return { ok: reasons.length === 0, reasons: [...new Set(reasons)] };
292
+ }
293
+ /**
294
+ * Section 4a: may `release` be appended to the release log now? The release log with it verifies,
295
+ * and it is signed by the newest board of the board log (a board that was replaced signs nothing more).
296
+ */
297
+ export function verifyReleaseAppend(releases, boards, release, opts = {}) {
298
+ const reasons = [...verifyReleaseLog([...(Array.isArray(releases) ? releases : []), release], boards, opts).reasons];
299
+ const last = Array.isArray(boards) ? boards.at(-1) : undefined;
300
+ if (isRelease(release) && !(isBoard(last) && release.board === jcsHash(last)))
301
+ reasons.push("the release is not signed by the newest board of the board log");
302
+ return { ok: reasons.length === 0, reasons: [...new Set(reasons)] };
303
+ }
304
+ // ---------------------------------------------------------------- 6. release withdrawals
305
+ export const WITHDRAWAL_REASONS = ["DEFECT", "SECURITY", "LEGAL", "OTHER"];
306
+ function withdrawalError(w) {
307
+ if (!logPositionOk(w.seq, w.prev))
308
+ return "seq and prev are not a log position";
309
+ if (!subjectOk(w.subject))
310
+ return "subject must be {kind: scheme, ref: zanii-certify/<m>.<n>} or {kind: profile, ref: <slug>/<version>}";
311
+ if (!isSha(w.release))
312
+ return "release must be a release hash";
313
+ if (!WITHDRAWAL_REASONS.includes(w.reason))
314
+ return `reason must be one of ${WITHDRAWAL_REASONS.join(", ")}`;
315
+ if (!isText(w.notes))
316
+ return "notes need en and ar text (1-4000)";
317
+ if (!isIso(w.effective_from))
318
+ return "effective_from must be an ISO timestamp";
319
+ if (!isSha(w.board))
320
+ return "board must be a board hash";
321
+ return null;
322
+ }
323
+ // Shared by the incremental verifier; field rules have one implementation.
324
+ export { boardError as boardFieldError, releaseError as releaseFieldError, withdrawalError as withdrawalFieldError };
325
+ export function releaseWithdrawalBody(f) {
326
+ const body = {
327
+ v: 1,
328
+ type: "certify.release-withdrawal",
329
+ seq: f.seq,
330
+ prev: f.prev,
331
+ subject: { kind: f.subject?.kind, ref: f.subject?.ref },
332
+ release: f.release,
333
+ reason: f.reason,
334
+ notes: { en: f.notes?.en, ar: f.notes?.ar },
335
+ effective_from: f.effectiveFrom,
336
+ board: f.board,
337
+ };
338
+ const err = withdrawalError(body);
339
+ if (err)
340
+ throw new Error(err);
341
+ return body;
342
+ }
343
+ /** A release withdrawal signed by `signers` (k members of the board it names). */
344
+ export async function buildReleaseWithdrawal(f, signers) {
345
+ const body = releaseWithdrawalBody(f);
346
+ return withSignatures(body, await Promise.all(signers.map((s) => bodySignature(body, s))));
347
+ }
348
+ export const isWithdrawal = (w) => isObj(w) && w.v === 1 && w.type === "certify.release-withdrawal" && Object.keys(w).length === 11;
349
+ /** The withdrawal of `release` in a list (a release is withdrawn at most once), or null. */
350
+ function withdrawalOf(withdrawals, release) {
351
+ const h = jcsHash(release);
352
+ return (Array.isArray(withdrawals) ? withdrawals : []).find((w) => isWithdrawal(w) && w.release === h) ?? null;
353
+ }
354
+ /** Section 6 without the release log's own reasons. */
355
+ function withdrawalReasons(withdrawals, releases, boards) {
356
+ if (!Array.isArray(withdrawals))
357
+ return ["the withdrawal log is not a list"];
358
+ const rel = Array.isArray(releases) ? releases : [];
359
+ const bl = Array.isArray(boards) ? boards : [];
360
+ const reasons = [];
361
+ const seen = new Set();
362
+ withdrawals.forEach((w, i) => {
363
+ if (!isWithdrawal(w)) {
364
+ reasons.push(`withdrawal ${i} is not a release withdrawal (v1)`);
365
+ return;
366
+ }
367
+ const err = withdrawalError(w);
368
+ if (err) {
369
+ reasons.push(`withdrawal ${i} is malformed: ${err}`);
370
+ return;
371
+ }
372
+ const prev = i > 0 ? withdrawals[i - 1] : null;
373
+ if (w.seq !== i)
374
+ reasons.push(`withdrawal ${i} is not at position ${i}`);
375
+ if (w.prev !== (i > 0 ? jcsHash(prev) : null))
376
+ reasons.push(`withdrawal ${i} does not name the previous withdrawal`);
377
+ if (i > 0 && isWithdrawal(prev) && !(ms(w.effective_from) >= ms(prev.effective_from)))
378
+ reasons.push(`withdrawal ${i} takes effect before the previous withdrawal`);
379
+ const r = rel.find((x) => isRelease(x) && jcsHash(x) === w.release);
380
+ if (!isRelease(r))
381
+ reasons.push(`withdrawal ${i} names a release that is not in the release log`);
382
+ else {
383
+ if (subjectKey(r.subject) !== subjectKey(w.subject))
384
+ reasons.push(`withdrawal ${i} subject is not the release's subject`);
385
+ if (!(ms(w.effective_from) >= ms(r.effective_from)))
386
+ reasons.push(`withdrawal ${i} takes effect before the release`);
387
+ }
388
+ if (seen.has(w.release))
389
+ reasons.push(`withdrawal ${i} withdraws a release again`);
390
+ seen.add(w.release);
391
+ reasons.push(...boardSignedReasons(w, bl, "withdrawal").map((x) => `withdrawal ${i}: ${x}`));
392
+ });
393
+ return reasons;
394
+ }
395
+ /**
396
+ * Section 6: the withdrawal log: the release log verifies, every withdrawal at seq i naming
397
+ * withdrawal i - 1, effective_from never going back, naming a release of the log with its subject,
398
+ * taking effect no earlier than it, each release withdrawn once, signed by k of the board in force.
399
+ */
400
+ export function verifyWithdrawalLog(withdrawals, releases, boards, opts = {}) {
401
+ if (opts.schemeLog !== undefined) {
402
+ const { reasons } = schemeAuthority(opts.schemeLog, opts.owner, { "certify.board": boards, "certify.release": releases, "certify.release-withdrawal": withdrawals });
403
+ return { ok: reasons.length === 0, reasons };
404
+ }
405
+ const reasons = [...verifyReleaseLog(releases, boards, opts).reasons, ...withdrawalReasons(withdrawals, releases, boards)];
406
+ return { ok: reasons.length === 0, reasons };
407
+ }
408
+ /**
409
+ * Section 6: a certificate issued under a withdrawn scheme or profile version is flagged, never
410
+ * failed. `ok` is whether the withdrawal log verified; `flags` names each withdrawal that touches
411
+ * the certificate's scheme or profile.
412
+ */
413
+ export function withdrawalFlags(cert, logs) {
414
+ const authority = logs.schemeLog !== undefined ? schemeAuthority(logs.schemeLog, logs.owner, { "certify.board": logs.boards, "certify.release": logs.releases, "certify.release-withdrawal": logs.withdrawals }) : null;
415
+ const reasons = authority ? authority.reasons : verifyWithdrawalLog(logs.withdrawals, logs.releases, logs.boards, logs.owner === undefined ? {} : { owner: logs.owner }).reasons;
416
+ const claim = isObj(cert?.claim) ? cert.claim : {};
417
+ const flags = [];
418
+ for (const subject of [{ kind: "scheme", ref: claim.scheme }, { kind: "profile", ref: claim.profile }]) {
419
+ const key = subjectKey(subject);
420
+ const r = (Array.isArray(logs.releases) ? logs.releases : []).find((x) => isRelease(x) && !authority?.canceled.has(jcsHash(x)) && subjectKey(x.subject) === key);
421
+ const w = r ? withdrawalOf((Array.isArray(logs.withdrawals) ? logs.withdrawals : []).filter(x => !authority?.canceled.size || !authority.canceled.has(jcsHash(x))), r) : null;
422
+ if (!w)
423
+ continue;
424
+ const after = ms(cert?.issued_at) >= ms(w.effective_from) ? "; the certificate was issued after it" : "";
425
+ flags.push(`${key} was withdrawn from ${w.effective_from} (${w.reason})${after}`);
426
+ }
427
+ return { ok: reasons.length === 0, reasons, flags };
428
+ }
@@ -0,0 +1,100 @@
1
+ import { type Staff } from "./impartiality.ts";
2
+ import { type CategoryScore } from "./scoring.ts";
3
+ import { type ApplianceExecution, type ApplianceExportIdentity } from "./appliance-execution.ts";
4
+ export type { ApplianceExecution, ApplianceExportIdentity } from "./appliance-execution.ts";
5
+ export interface CategoryCount {
6
+ category: string;
7
+ trials: number;
8
+ passes: number;
9
+ threshold_ppm: number;
10
+ findings: readonly string[];
11
+ }
12
+ export interface CategoryResult extends CategoryScore {
13
+ category: string;
14
+ trials: number;
15
+ passes: number;
16
+ threshold_ppm: number;
17
+ findings: string[];
18
+ }
19
+ export interface Evidence {
20
+ case_id: string;
21
+ /** The hash-chain head of the case's recorded transcript. */
22
+ chain_head: string;
23
+ blackbox_session?: string;
24
+ }
25
+ export interface BundleInput {
26
+ execution?: ApplianceExecution | null;
27
+ profile: string;
28
+ level: number;
29
+ fingerprint: string;
30
+ plan: {
31
+ categories: readonly string[];
32
+ trials: number;
33
+ };
34
+ results: readonly CategoryCount[];
35
+ evidence: readonly Evidence[];
36
+ suite_snapshot: string;
37
+ staff: Staff;
38
+ started_at: string;
39
+ ended_at: string;
40
+ /** Default: pickScheme (0.1 for Levels 1 and 2, 0.2 for Level 3); the profile's scheme. */
41
+ scheme?: string;
42
+ /** Section 4: a partial re-test, and the bundle of the earlier passing run that covers the rest. */
43
+ partial?: {
44
+ covered_by: string;
45
+ } | null;
46
+ /** Section 2a (scheme 0.4): the categories assessed as simulated multi-step workflows, and the agent interface tested. */
47
+ assessment?: Assessment | null;
48
+ }
49
+ /** What a 0.4 run assessed: `voice_text` is a voice agent tested through its text interface only, never its audio. */
50
+ export interface Assessment {
51
+ trajectory_categories: string[];
52
+ interface: "text" | "voice_text";
53
+ }
54
+ export interface ResultBundle {
55
+ execution?: ApplianceExecution;
56
+ v: 1;
57
+ type: "certify.result-bundle";
58
+ scheme: string;
59
+ profile: string;
60
+ level: number;
61
+ fingerprint: string;
62
+ plan: {
63
+ categories: string[];
64
+ trials: number;
65
+ };
66
+ results: CategoryResult[];
67
+ evidence: Evidence[];
68
+ suite_snapshot: string;
69
+ staff: {
70
+ testers: string[];
71
+ reviewer: string | null;
72
+ decider: string;
73
+ };
74
+ started_at: string;
75
+ ended_at: string;
76
+ partial?: {
77
+ covered_by: string;
78
+ };
79
+ assessment?: Assessment;
80
+ }
81
+ export interface ApplianceExportBundle extends Omit<ResultBundle, "v" | "staff" | "execution" | "partial"> {
82
+ v: 2;
83
+ phase: "undecided";
84
+ staff: {
85
+ testers: string[];
86
+ reviewer: null;
87
+ decider: null;
88
+ };
89
+ execution: ApplianceExportIdentity;
90
+ }
91
+ export type ApplianceExportBundleInput = Omit<BundleInput, "staff" | "partial" | "execution"> & {
92
+ testers: readonly string[];
93
+ execution: ApplianceExportIdentity;
94
+ };
95
+ /** Existing v1 output is unchanged unless explicit verified appliance provenance is supplied. */
96
+ export declare function buildBundle(input: BundleInput): ResultBundle;
97
+ /** An appliance supplies execution evidence; it never occupies a human reviewer or decider role. */
98
+ export declare function buildApplianceExportBundle(input: ApplianceExportBundleInput): ApplianceExportBundle;
99
+ /** The bundle's identity: what the certificate's `claim.bundle` names. */
100
+ export declare const bundleHash: (bundle: ResultBundle | ApplianceExportBundle) => string;
package/dist/bundle.js ADDED
@@ -0,0 +1,122 @@
1
+ // spec/result-bundle.md: what a test run produced, hashed into the certificate. Never item text.
2
+ import { jcsHash } from "@zanii/core";
3
+ import { LEVELS, pickScheme } from "./certificate.js";
4
+ import { staffRefusals } from "./impartiality.js";
5
+ import { scoreCategory } from "./scoring.js";
6
+ import { isDid, isInt, isIso, isSha, ms, SLUG, sortedUnique } from "./shared.js";
7
+ import { applianceExecution, applianceExportIdentity } from "./appliance-execution.js";
8
+ const CATEGORY = /^[a-z][a-z0-9_]{0,63}$/;
9
+ const FINDING = /^[A-Z][A-Z0-9_]{0,63}$/;
10
+ const CASE_ID = /^[A-Za-z0-9._:-]{1,128}$/;
11
+ const PROFILE_REF = new RegExp(`^${SLUG.source.slice(1, -1)}/[1-9][0-9]*$`);
12
+ const byKey = (k) => (a, b) => k(a) < k(b) ? -1 : k(a) > k(b) ? 1 : 0;
13
+ function normalizedBundle(input, source) {
14
+ if (!PROFILE_REF.test(input.profile))
15
+ throw new Error("profile must be <slug>/<version>");
16
+ if (!LEVELS.includes(input.level))
17
+ throw new Error("level must be 1, 2 or 3");
18
+ const scheme = pickScheme(input.scheme, input.level);
19
+ if (!isSha(input.fingerprint))
20
+ throw new Error("fingerprint must be a fingerprint id");
21
+ if (!isSha(input.suite_snapshot))
22
+ throw new Error("suite_snapshot must be a sha256");
23
+ if (!isIso(input.started_at) || !isIso(input.ended_at) || ms(input.ended_at) < ms(input.started_at))
24
+ throw new Error("started_at and ended_at must be ISO UTC timestamps, in order");
25
+ const categories = sortedUnique(input.plan.categories);
26
+ if (!categories.length || categories.length !== input.plan.categories.length)
27
+ throw new Error("plan.categories must be distinct and not empty");
28
+ if (!categories.every((c) => CATEGORY.test(c)))
29
+ throw new Error("bad category id");
30
+ if (!isInt(input.plan.trials, 1, 1000))
31
+ throw new Error("plan.trials must be 1-1000");
32
+ const results = input.results.map((r) => {
33
+ const findings = sortedUnique(r.findings);
34
+ if (!findings.every((f) => FINDING.test(f)))
35
+ throw new Error(`bad finding code in ${r.category}`);
36
+ return {
37
+ category: r.category,
38
+ trials: r.trials,
39
+ passes: r.passes,
40
+ ...scoreCategory(r.passes, r.trials, r.threshold_ppm),
41
+ threshold_ppm: r.threshold_ppm,
42
+ findings,
43
+ };
44
+ });
45
+ results.sort(byKey((r) => r.category));
46
+ if (results.map((r) => r.category).join() !== categories.join())
47
+ throw new Error("results must have exactly one entry per planned category");
48
+ const evidence = input.evidence.map((e) => {
49
+ if (!CASE_ID.test(e.case_id) || !isSha(e.chain_head))
50
+ throw new Error("bad evidence entry");
51
+ const out = { case_id: e.case_id, chain_head: e.chain_head };
52
+ if (e.blackbox_session != null) {
53
+ if (!CASE_ID.test(e.blackbox_session))
54
+ throw new Error("bad blackbox_session");
55
+ out.blackbox_session = e.blackbox_session;
56
+ }
57
+ return out;
58
+ });
59
+ evidence.sort(byKey((e) => e.case_id));
60
+ if (new Set(evidence.map((e) => e.case_id)).size !== evidence.length)
61
+ throw new Error("evidence case_id values must be distinct");
62
+ const { testers, reviewer, decider } = input.staff;
63
+ if (!testers.length || !testers.every(isDid) || !source && !isDid(decider))
64
+ throw new Error("staff needs tester and decider DIDs");
65
+ if (!source && (reviewer === null ? input.level !== 1 : !isDid(reviewer)))
66
+ throw new Error("a reviewer DID is required above Level 1");
67
+ const refusals = source ? [] : staffRefusals({ testers: [...testers], reviewer, decider: decider }, []);
68
+ if (refusals.length)
69
+ throw new Error(`staffing refused: ${refusals.join(", ")}`);
70
+ const partial = input.partial ?? null;
71
+ if (partial !== null && (Object.keys(partial).length !== 1 || !isSha(partial.covered_by)))
72
+ throw new Error("partial must be {covered_by: <bundle hash>}");
73
+ const assessment = input.assessment == null ? null : assessmentOf(input.assessment, categories, scheme);
74
+ const execution = input.execution == null ? undefined : applianceExecution(input.execution), machine = source?.appliance ?? execution?.appliance;
75
+ if (machine && [...testers, reviewer, decider].includes(machine))
76
+ throw new Error("an appliance cannot be recorded as human staff");
77
+ const bundle = {
78
+ v: 1,
79
+ type: "certify.result-bundle",
80
+ scheme,
81
+ profile: input.profile,
82
+ level: input.level,
83
+ fingerprint: input.fingerprint,
84
+ plan: { categories, trials: input.plan.trials },
85
+ results,
86
+ evidence,
87
+ suite_snapshot: input.suite_snapshot,
88
+ staff: { testers: sortedUnique(testers), reviewer, decider: decider },
89
+ started_at: input.started_at,
90
+ ended_at: input.ended_at,
91
+ ...(partial ? { partial: { covered_by: partial.covered_by } } : {}),
92
+ ...(execution ? { execution } : {}),
93
+ ...(assessment ? { assessment } : {}),
94
+ };
95
+ if (source) {
96
+ const { partial: ignored, ...base } = bundle;
97
+ return { ...base, v: 2, phase: "undecided", staff: { testers: sortedUnique(testers), reviewer: null, decider: null }, execution: source };
98
+ }
99
+ return bundle;
100
+ }
101
+ function assessmentOf(a, categories, scheme) {
102
+ if (scheme !== "zanii-certify/0.4")
103
+ throw new Error("assessment needs scheme zanii-certify/0.4");
104
+ if (typeof a !== "object" || Object.keys(a).sort().join() !== "interface,trajectory_categories" || !Array.isArray(a.trajectory_categories)
105
+ || (a.interface !== "text" && a.interface !== "voice_text"))
106
+ throw new Error("assessment must be {trajectory_categories, interface}");
107
+ const workflow = sortedUnique(a.trajectory_categories);
108
+ if (workflow.length !== a.trajectory_categories.length || !workflow.every((c) => categories.includes(c)))
109
+ throw new Error("assessment.trajectory_categories must be distinct planned categories");
110
+ return { trajectory_categories: workflow, interface: a.interface };
111
+ }
112
+ /** Existing v1 output is unchanged unless explicit verified appliance provenance is supplied. */
113
+ export function buildBundle(input) { return normalizedBundle(input); }
114
+ /** An appliance supplies execution evidence; it never occupies a human reviewer or decider role. */
115
+ export function buildApplianceExportBundle(input) {
116
+ if (!Array.isArray(input.testers) || input.testers.length > 64 || !Array.isArray(input.results) || input.results.length > 10000 || !Array.isArray(input.evidence) || input.evidence.length > 10000 || !Array.isArray(input.plan?.categories) || input.plan.categories.length > 10000)
117
+ throw new Error("appliance bundle exceeds its staff or result bounds");
118
+ const source = applianceExportIdentity(input.execution);
119
+ return normalizedBundle({ ...input, execution: null, partial: null, staff: { testers: input.testers, reviewer: null, decider: null } }, source);
120
+ }
121
+ /** The bundle's identity: what the certificate's `claim.bundle` names. */
122
+ export const bundleHash = (bundle) => jcsHash(bundle);