@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.
- package/LICENSE +159 -0
- package/README.md +144 -2
- package/dist/appliance-execution.d.ts +18 -0
- package/dist/appliance-execution.js +12 -0
- package/dist/appliance.d.ts +135 -0
- package/dist/appliance.js +253 -0
- package/dist/assurance-status.d.ts +58 -0
- package/dist/assurance-status.js +86 -0
- package/dist/audit-renewal.d.ts +39 -0
- package/dist/audit-renewal.js +62 -0
- package/dist/audit.d.ts +69 -0
- package/dist/audit.js +112 -0
- package/dist/beacon-client.d.ts +40 -0
- package/dist/beacon-client.js +151 -0
- package/dist/beacon.d.ts +84 -0
- package/dist/beacon.js +106 -0
- package/dist/bitstring-status.d.ts +56 -0
- package/dist/bitstring-status.js +135 -0
- package/dist/board.d.ts +218 -0
- package/dist/board.js +428 -0
- package/dist/bundle.d.ts +100 -0
- package/dist/bundle.js +122 -0
- package/dist/certificate.d.ts +161 -0
- package/dist/certificate.js +346 -0
- package/dist/cli-messages.json +148 -0
- package/dist/cli.d.ts +16 -0
- package/dist/cli.js +369 -0
- package/dist/current-issuer-vc.d.ts +20 -0
- package/dist/current-issuer-vc.js +60 -0
- package/dist/evidence-share.d.ts +89 -0
- package/dist/evidence-share.js +479 -0
- package/dist/federation.d.ts +103 -0
- package/dist/federation.js +240 -0
- package/dist/feed-snapshot.d.ts +97 -0
- package/dist/feed-snapshot.js +186 -0
- package/dist/fingerprint.d.ts +23 -0
- package/dist/fingerprint.js +48 -0
- package/dist/formats/status-v2.schema.json +31 -0
- package/dist/governance.d.ts +244 -0
- package/dist/governance.js +418 -0
- package/dist/identity.d.ts +79 -0
- package/dist/identity.js +146 -0
- package/dist/impartiality.d.ts +47 -0
- package/dist/impartiality.js +85 -0
- package/dist/index.d.ts +61 -0
- package/dist/index.js +63 -0
- package/dist/issuer-authorization.d.ts +4 -0
- package/dist/issuer-authorization.js +52 -0
- package/dist/issuer-resolution.d.ts +25 -0
- package/dist/issuer-resolution.js +89 -0
- package/dist/licence-owner-entry.d.ts +20 -0
- package/dist/licence-owner-entry.js +33 -0
- package/dist/licence-owner.d.ts +67 -0
- package/dist/licence-owner.js +180 -0
- package/dist/licence.d.ts +174 -0
- package/dist/licence.js +400 -0
- package/dist/live-evidence.d.ts +135 -0
- package/dist/live-evidence.js +210 -0
- package/dist/nitro-roots.d.ts +3 -0
- package/dist/nitro-roots.js +20 -0
- package/dist/nitro-x509.d.ts +16 -0
- package/dist/nitro-x509.js +141 -0
- package/dist/nitro.d.ts +74 -0
- package/dist/nitro.js +329 -0
- package/dist/normalization.d.ts +8 -0
- package/dist/normalization.js +10 -0
- package/dist/pq.d.ts +32 -0
- package/dist/pq.js +69 -0
- package/dist/profile.d.ts +15 -0
- package/dist/profile.js +165 -0
- package/dist/register.d.ts +79 -0
- package/dist/register.js +134 -0
- package/dist/rotation.d.ts +49 -0
- package/dist/rotation.js +124 -0
- package/dist/royalty-reconciliation.d.ts +24 -0
- package/dist/royalty-reconciliation.js +88 -0
- package/dist/runtime-client.d.ts +40 -0
- package/dist/runtime-client.js +190 -0
- package/dist/runtime-gateway.d.ts +26 -0
- package/dist/runtime-gateway.js +169 -0
- package/dist/runtime-knowledge.d.ts +28 -0
- package/dist/runtime-knowledge.js +75 -0
- package/dist/runtime-nitro.d.ts +30 -0
- package/dist/runtime-nitro.js +57 -0
- package/dist/runtime.d.ts +226 -0
- package/dist/runtime.js +254 -0
- package/dist/sandbox-delegation.d.ts +38 -0
- package/dist/sandbox-delegation.js +61 -0
- package/dist/schema-dialect.d.ts +1 -0
- package/dist/schema-dialect.js +97 -0
- package/dist/schemas/clause-map.schema.json +52 -0
- package/dist/schemas/profile.schema.json +81 -0
- package/dist/schemas/scenarios.schema.json +573 -0
- package/dist/schemas/suite.schema.json +138 -0
- package/dist/schemas/trajectory.schema.json +1396 -0
- package/dist/scheme-authority.d.ts +5 -0
- package/dist/scheme-authority.js +15 -0
- package/dist/scheme-entry.d.ts +25 -0
- package/dist/scheme-entry.js +85 -0
- package/dist/scheme-history.d.ts +5 -0
- package/dist/scheme-history.js +87 -0
- package/dist/scheme-log.d.ts +98 -0
- package/dist/scheme-log.js +266 -0
- package/dist/scheme-merkle.d.ts +2 -0
- package/dist/scheme-merkle.js +20 -0
- package/dist/scheme-text.d.ts +25 -0
- package/dist/scheme-text.js +46 -0
- package/dist/scheme-timing.d.ts +16 -0
- package/dist/scheme-timing.js +22 -0
- package/dist/scheme-transition.d.ts +47 -0
- package/dist/scheme-transition.js +101 -0
- package/dist/scoring.d.ts +35 -0
- package/dist/scoring.js +50 -0
- package/dist/shared.d.ts +16 -0
- package/dist/shared.js +20 -0
- package/dist/signer.d.ts +18 -0
- package/dist/signer.js +49 -0
- package/dist/signing-identity.d.ts +1 -0
- package/dist/signing-identity.js +11 -0
- package/dist/status.d.ts +113 -0
- package/dist/status.js +204 -0
- package/dist/succession.d.ts +21 -0
- package/dist/succession.js +67 -0
- package/dist/suite.d.ts +261 -0
- package/dist/suite.js +458 -0
- package/dist/trajectory.d.ts +216 -0
- package/dist/trajectory.js +409 -0
- package/dist/transcript-provenance.d.ts +25 -0
- package/dist/transcript-provenance.js +37 -0
- package/dist/transport-key.d.ts +3 -0
- package/dist/transport-key.js +16 -0
- package/dist/vc.d.ts +29 -0
- package/dist/vc.js +96 -0
- package/dist/verification-bounds.d.ts +5 -0
- package/dist/verification-bounds.js +78 -0
- package/dist/verification-policy.d.ts +32 -0
- package/dist/verification-policy.js +85 -0
- package/dist/witness.d.ts +19 -0
- package/dist/witness.js +80 -0
- package/package.json +65 -4
- package/src/cli.ts +331 -0
package/dist/status.js
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
// spec/status.md: the signed status document and the revocation list derived from it.
|
|
2
|
+
import { publicKeyFromDid, verifySignedObject } from "@zanii/core";
|
|
3
|
+
import { bindingHash } from "@zanii/pq";
|
|
4
|
+
import { checkPqBinding, pqCommitment, pqPolicyOf, verifyPqCosignature } from "./pq.js";
|
|
5
|
+
import { isDid, isInt, isIso, isObj, isSha, ms, sortedUnique } from "./shared.js";
|
|
6
|
+
import { signatureOver, signerDid, signWith } from "./signer.js";
|
|
7
|
+
import { compromisedKeyReasons, issuerLineage, succeeds } from "./succession.js";
|
|
8
|
+
import { witnessReasons } from "./witness.js";
|
|
9
|
+
import { signingIdentityReasons } from "./signing-identity.js";
|
|
10
|
+
import { withinVerificationBudget } from "./verification-bounds.js";
|
|
11
|
+
import STATUS_V2_SCHEMA from "./formats/status-v2.schema.json" with { type: "json" };
|
|
12
|
+
export { STATUS_V2_SCHEMA };
|
|
13
|
+
export const REASONS = [
|
|
14
|
+
"FINGERPRINT_MISMATCH",
|
|
15
|
+
"RETEST_OVERDUE",
|
|
16
|
+
"RETEST_FAILED",
|
|
17
|
+
"INCIDENT",
|
|
18
|
+
"APPLICANT_REQUEST",
|
|
19
|
+
"ISSUER_ERROR",
|
|
20
|
+
/** licensing.md section 2a: the issuing licensee's licence was withdrawn or suspended for cause. */
|
|
21
|
+
"LICENCE_WITHDRAWN",
|
|
22
|
+
/** audit.md section 3: a Level 3 certificate's audit attestation lapsed. */
|
|
23
|
+
"AUDIT_EXPIRED",
|
|
24
|
+
];
|
|
25
|
+
export const REASONS_V2 = [...REASONS, "MARK_MISUSE"];
|
|
26
|
+
export const STATUS_V2_MAX_ENTRIES = 100_000;
|
|
27
|
+
/** A log position is `seq` and `prev` together: seq 0 has prev null, later ones a hash. */
|
|
28
|
+
export const logPositionOk = (seq, prev) => isInt(seq, 0, Number.MAX_SAFE_INTEGER) && (seq === 0 ? prev === null : isSha(prev));
|
|
29
|
+
const isReason = (v, version = 1) => (version === 2 ? REASONS_V2 : REASONS).includes(v);
|
|
30
|
+
/**
|
|
31
|
+
* §2: apply one status change and return the new entries (the input is not changed). Throws on a
|
|
32
|
+
* change the rules forbid: revoked is final, only a valid certificate is suspended, only a
|
|
33
|
+
* suspended one is reinstated, and time never goes backwards for one certificate.
|
|
34
|
+
*/
|
|
35
|
+
export function applyStatus(entries, certificate, action, reason, at, opts = {}) {
|
|
36
|
+
if (opts.version !== undefined && opts.version !== 1 && opts.version !== 2)
|
|
37
|
+
throw new Error("status version must be 1 or 2");
|
|
38
|
+
if (!isSha(certificate))
|
|
39
|
+
throw new Error("certificate must be a credential hash");
|
|
40
|
+
if (!isIso(at))
|
|
41
|
+
throw new Error("at must be an ISO UTC timestamp");
|
|
42
|
+
const now = entries[certificate];
|
|
43
|
+
const wantsReason = action === "suspend" || action === "revoke";
|
|
44
|
+
if (wantsReason ? !isReason(reason, opts.version) : reason !== null)
|
|
45
|
+
throw new Error(wantsReason ? `${action} needs a reason code` : `${action} takes no reason`);
|
|
46
|
+
if (now && ms(at) < ms(now.since))
|
|
47
|
+
throw new Error("at is before the current status");
|
|
48
|
+
let next;
|
|
49
|
+
if (action === "grant") {
|
|
50
|
+
if (now)
|
|
51
|
+
throw new Error("certificate already has a status");
|
|
52
|
+
next = "valid";
|
|
53
|
+
}
|
|
54
|
+
else if (!now) {
|
|
55
|
+
throw new Error("certificate has no status; grant it first");
|
|
56
|
+
}
|
|
57
|
+
else if (now.status === "revoked") {
|
|
58
|
+
throw new Error("certificate is revoked; revocation is final");
|
|
59
|
+
}
|
|
60
|
+
else if (action === "suspend") {
|
|
61
|
+
if (now.status !== "valid")
|
|
62
|
+
throw new Error("only a valid certificate can be suspended");
|
|
63
|
+
next = "suspended";
|
|
64
|
+
}
|
|
65
|
+
else if (action === "reinstate") {
|
|
66
|
+
if (now.status !== "suspended")
|
|
67
|
+
throw new Error("only a suspended certificate can be reinstated");
|
|
68
|
+
next = "valid";
|
|
69
|
+
}
|
|
70
|
+
else {
|
|
71
|
+
next = "revoked";
|
|
72
|
+
}
|
|
73
|
+
return { ...entries, [certificate]: { status: next, reason, since: at } };
|
|
74
|
+
}
|
|
75
|
+
function entryOk(e, version = 1) {
|
|
76
|
+
if (!isObj(e) || Object.keys(e).length !== 3 || !isIso(e.since))
|
|
77
|
+
return false;
|
|
78
|
+
if (e.status === "valid")
|
|
79
|
+
return e.reason === null;
|
|
80
|
+
return (e.status === "suspended" || e.status === "revoked") && isReason(e.reason, version);
|
|
81
|
+
}
|
|
82
|
+
function v2Error(d) {
|
|
83
|
+
if (!withinVerificationBudget(d, { maxDepth: 8 }) || !isObj(d.entries) || Object.keys(d.entries).length > STATUS_V2_MAX_ENTRIES)
|
|
84
|
+
return "status v2 exceeds its JSON or entry budget";
|
|
85
|
+
if ("v type issuer entries issued_at valid_until signer sig".split(" ").some(k => !Object.hasOwn(d, k)) || Object.keys(d).some(k => !"v type issuer entries issued_at valid_until signer sig seq prev pq_binding".split(" ").includes(k)))
|
|
86
|
+
return "status v2 has missing or unknown fields";
|
|
87
|
+
if (typeof d.issuer !== "string" || d.issuer.length > 69 || !publicKeyFromDid(d.issuer) || d.signer !== d.issuer || typeof d.sig !== "string" || !/^ed25519:[0-9a-f]{128}$/.test(d.sig))
|
|
88
|
+
return "status v2 needs matching Ed25519 issuer and signer";
|
|
89
|
+
if (!isIso(d.issued_at) || !isIso(d.valid_until) || ms(d.valid_until) <= ms(d.issued_at) || ms(d.valid_until) - ms(d.issued_at) > 3_600_000)
|
|
90
|
+
return "status v2 lifetime must be positive and at most one hour";
|
|
91
|
+
if (Object.entries(d.entries).some(([hash, entry]) => !isSha(hash) || !entryOk(entry, 2) || ms(entry.since) > ms(d.issued_at)))
|
|
92
|
+
return "status v2 has a malformed or future entry";
|
|
93
|
+
if ((("seq" in d || "prev" in d) && !logPositionOk(d.seq, d.prev)) || ("pq_binding" in d && !isSha(d.pq_binding)))
|
|
94
|
+
return "status v2 has a malformed log position or PQ binding";
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
/** §1: sign a status document. `valid_until` is the freshness bound verifiers enforce. */
|
|
98
|
+
export async function buildStatus(fields, signer) {
|
|
99
|
+
const version = fields.version ?? 1;
|
|
100
|
+
if (version !== 1 && version !== 2)
|
|
101
|
+
throw new Error("status version must be 1 or 2");
|
|
102
|
+
if (version === 2 && (!isObj(fields.entries) || Object.keys(fields.entries).length > STATUS_V2_MAX_ENTRIES || Object.keys(fields).some(k => !["version", "entries", "issuedAt", "validUntil", "seq", "prev", "pqBinding"].includes(k)) || !withinVerificationBudget(fields, { maxDepth: 10 })))
|
|
103
|
+
throw new Error("status v2 exceeds its JSON or entry budget");
|
|
104
|
+
if (!isIso(fields.issuedAt) || !isIso(fields.validUntil))
|
|
105
|
+
throw new Error("issuedAt and validUntil must be ISO UTC timestamps");
|
|
106
|
+
if (ms(fields.validUntil) < ms(fields.issuedAt))
|
|
107
|
+
throw new Error("validUntil is before issuedAt");
|
|
108
|
+
for (const [k, e] of Object.entries(fields.entries))
|
|
109
|
+
if (!isSha(k) || !entryOk(e, version))
|
|
110
|
+
throw new Error(`malformed status entry ${k}`);
|
|
111
|
+
const logged = fields.seq !== undefined || fields.prev !== undefined;
|
|
112
|
+
if (logged && !logPositionOk(fields.seq, fields.prev))
|
|
113
|
+
throw new Error("seq and prev: seq 0 with prev null, or seq >= 1 with the previous document hash");
|
|
114
|
+
const issuer = signerDid(signer);
|
|
115
|
+
if (fields.pqBinding !== undefined)
|
|
116
|
+
checkPqBinding(fields.pqBinding, issuer);
|
|
117
|
+
const unsigned = {
|
|
118
|
+
v: version,
|
|
119
|
+
type: "certify.status",
|
|
120
|
+
issuer,
|
|
121
|
+
entries: version === 2 ? structuredClone(fields.entries) : fields.entries,
|
|
122
|
+
issued_at: fields.issuedAt,
|
|
123
|
+
valid_until: fields.validUntil,
|
|
124
|
+
...(logged ? { seq: fields.seq, prev: fields.prev } : {}),
|
|
125
|
+
...(fields.pqBinding ? { pq_binding: bindingHash(fields.pqBinding) } : {}),
|
|
126
|
+
};
|
|
127
|
+
if (version === 2) {
|
|
128
|
+
const error = v2Error({ ...unsigned, signer: issuer, sig: `ed25519:${"0".repeat(128)}` });
|
|
129
|
+
if (error)
|
|
130
|
+
throw new Error(error);
|
|
131
|
+
}
|
|
132
|
+
return signWith(unsigned, signer);
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Is this an intact status document from `issuer` (when given), still fresh at `now`? With `pq`
|
|
136
|
+
* or `requirePq` also its post-quantum co-signature, with `witness` its witnessed checkpoint.
|
|
137
|
+
*/
|
|
138
|
+
export function verifyStatus(doc, opts = {}) {
|
|
139
|
+
const d = isObj(doc) ? doc : {};
|
|
140
|
+
const pqPolicy = opts.issuerDoc === undefined ? undefined : pqPolicyOf(opts.issuerDoc, opts.allowNoPq);
|
|
141
|
+
const policy = pqPolicy ? { pqPolicy } : {};
|
|
142
|
+
if ((d.v !== 1 && d.v !== 2) || d.type !== "certify.status" || !isDid(d.issuer) || !isObj(d.entries))
|
|
143
|
+
return { ok: false, reasons: ["not a status document (v1)"], freshness: null, ...policy };
|
|
144
|
+
const v2 = d.v === 2 ? v2Error(d) : null;
|
|
145
|
+
if (v2)
|
|
146
|
+
return { ok: false, reasons: [v2], freshness: null, ...policy };
|
|
147
|
+
const reasons = [];
|
|
148
|
+
if (!verifySignedObject(d) || d.signer !== d.issuer)
|
|
149
|
+
reasons.push("bad status document signature");
|
|
150
|
+
if (opts.schemeOwner !== undefined)
|
|
151
|
+
reasons.push(...signingIdentityReasons(opts.schemeOwner, [], [d.issuer]));
|
|
152
|
+
// Section 5b: with an issuer document listing successions, a later key of `issuer`'s lineage may sign.
|
|
153
|
+
const lineage = opts.issuerDoc === undefined ? null : issuerLineage(opts.issuerDoc);
|
|
154
|
+
if (lineage)
|
|
155
|
+
reasons.push(...lineage.reasons, ...compromisedKeyReasons(lineage, d.issuer, "status document"));
|
|
156
|
+
if (opts.issuer !== undefined && d.issuer !== opts.issuer && !succeeds(lineage, opts.issuer, d.issuer))
|
|
157
|
+
reasons.push("status document is from a different issuer");
|
|
158
|
+
for (const [k, e] of Object.entries(d.entries))
|
|
159
|
+
if (!isSha(k) || !entryOk(e, d.v)) {
|
|
160
|
+
reasons.push("status document has a malformed entry");
|
|
161
|
+
break;
|
|
162
|
+
}
|
|
163
|
+
if (("seq" in d || "prev" in d) && !logPositionOk(d.seq, d.prev))
|
|
164
|
+
reasons.push("status document has a malformed log position");
|
|
165
|
+
if ("pq_binding" in d && !isSha(d.pq_binding))
|
|
166
|
+
reasons.push("status document has a malformed pq_binding");
|
|
167
|
+
const now = opts.now ? ms(opts.now) : Date.now();
|
|
168
|
+
if (d.v === 2 && ((!Number.isFinite(now)) || ms(d.issued_at) > now))
|
|
169
|
+
reasons.push("status v2 reference time is invalid or issuance is in the future");
|
|
170
|
+
const freshness = (d.v === 2 ? ms(d.valid_until) > now : ms(d.valid_until) >= now) ? "fresh" : "stale";
|
|
171
|
+
if (freshness === "stale")
|
|
172
|
+
reasons.push(`status document expired on ${String(d.valid_until)}`);
|
|
173
|
+
if (opts.pq)
|
|
174
|
+
reasons.push(...verifyPqCosignature(d, opts.pq).reasons.map((r) => `status: ${r}`));
|
|
175
|
+
else if (opts.requirePq || pqPolicy === "required")
|
|
176
|
+
reasons.push(pqCommitment(d)
|
|
177
|
+
? "status: no post-quantum co-signature supplied: a post-quantum signature is required"
|
|
178
|
+
: "status: document has no post-quantum co-signature: a post-quantum signature is required");
|
|
179
|
+
if (!opts.witness)
|
|
180
|
+
return { ok: reasons.length === 0, reasons, freshness, ...policy };
|
|
181
|
+
const w = witnessReasons(d, opts.witness);
|
|
182
|
+
reasons.push(...w.reasons);
|
|
183
|
+
return { ok: reasons.length === 0, reasons, freshness, witnesses: w.witnesses, ...policy };
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* §3: the @zanii/credentials revocation list for a status document: every certificate whose status
|
|
187
|
+
* is not valid, with the same issued_at and valid_until. Byte-identical to `buildRevocationList`
|
|
188
|
+
* with the issuer's key, so plain credential verifiers refuse suspended and revoked certificates.
|
|
189
|
+
*/
|
|
190
|
+
export async function revocationListFromStatus(doc, signer) {
|
|
191
|
+
if (doc.issuer !== signerDid(signer))
|
|
192
|
+
throw new Error("signer is not the status document issuer");
|
|
193
|
+
const unsigned = {
|
|
194
|
+
v: 1,
|
|
195
|
+
type: "revocation_list",
|
|
196
|
+
issuer: doc.issuer,
|
|
197
|
+
revoked: sortedUnique(Object.entries(doc.entries)
|
|
198
|
+
.filter(([, e]) => e.status !== "valid")
|
|
199
|
+
.map(([k]) => k)),
|
|
200
|
+
issued_at: doc.issued_at,
|
|
201
|
+
valid_until: doc.valid_until,
|
|
202
|
+
};
|
|
203
|
+
return { ...unsigned, sig: await signatureOver(unsigned, signer) };
|
|
204
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export interface IssuerLineage {
|
|
2
|
+
ok: boolean;
|
|
3
|
+
reasons: string[];
|
|
4
|
+
/** Oldest to newest issuer DID, ending at the issuer document's current DID. */
|
|
5
|
+
lineage: string[];
|
|
6
|
+
/** Each DID retired by a compromise succession, and its `compromised_at`. */
|
|
7
|
+
compromised: Record<string, string>;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Section 5a: the issuer document's succession chain (`successions`, signed by its `owner`), or
|
|
11
|
+
* null when it lists none. Every link is a valid @zanii/succession signed by the owner, a planned
|
|
12
|
+
* one (rotation, retirement) also by the old key, and the links run from the oldest issuer DID to
|
|
13
|
+
* `dids[0]`.
|
|
14
|
+
*/
|
|
15
|
+
export declare function issuerLineage(issuerDoc: unknown): IssuerLineage | null;
|
|
16
|
+
/** Does a valid lineage lead from `from` to the later key `to`? */
|
|
17
|
+
export declare const succeeds: (lin: IssuerLineage | null, from: unknown, to: unknown) => boolean;
|
|
18
|
+
/** Current status authority cannot come from a compromised key, even with a backdated timestamp. */
|
|
19
|
+
export declare const compromisedKeyReasons: (lin: IssuerLineage | null, did: unknown, what: string) => string[];
|
|
20
|
+
/** The reason, when `did` was retired by a compromise and signed something dated at or after it. */
|
|
21
|
+
export declare function afterCompromise(lin: IssuerLineage | null, did: unknown, at: unknown, what: string): string[];
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// spec/certificate.md section 5a: issuer key succession. The issuer document lists the
|
|
2
|
+
// @zanii/succession documents from each old issuer DID to the next, signed by the lab owner key;
|
|
3
|
+
// a verifier follows them from the key that signed a certificate to the key that signs status now.
|
|
4
|
+
import { verifyLineage, verifySuccession } from "@zanii/succession";
|
|
5
|
+
import { isDid, isIso, isObj, ms } from "./shared.js";
|
|
6
|
+
import { signingIdentityReasons } from "./signing-identity.js";
|
|
7
|
+
const MAX_SUCCESSIONS = 64;
|
|
8
|
+
/**
|
|
9
|
+
* Section 5a: the issuer document's succession chain (`successions`, signed by its `owner`), or
|
|
10
|
+
* null when it lists none. Every link is a valid @zanii/succession signed by the owner, a planned
|
|
11
|
+
* one (rotation, retirement) also by the old key, and the links run from the oldest issuer DID to
|
|
12
|
+
* `dids[0]`.
|
|
13
|
+
*/
|
|
14
|
+
export function issuerLineage(issuerDoc) {
|
|
15
|
+
const doc = isObj(issuerDoc) ? issuerDoc : {};
|
|
16
|
+
if (doc.owner !== undefined) {
|
|
17
|
+
const reasons = signingIdentityReasons(doc.owner, doc.dids ?? [], doc.status_dids ?? []);
|
|
18
|
+
if (reasons.length)
|
|
19
|
+
return { ok: false, reasons, lineage: [], compromised: {} };
|
|
20
|
+
}
|
|
21
|
+
const list = doc.successions;
|
|
22
|
+
if (list === undefined || (Array.isArray(list) && list.length === 0))
|
|
23
|
+
return null;
|
|
24
|
+
const fail = (r) => ({ ok: false, reasons: [`issuer succession: ${r}`], lineage: [], compromised: {} });
|
|
25
|
+
if (!Array.isArray(list) || list.length > MAX_SUCCESSIONS)
|
|
26
|
+
return fail(`successions must be a list of at most ${MAX_SUCCESSIONS}`);
|
|
27
|
+
if (!isDid(doc.owner))
|
|
28
|
+
return fail("the issuer document names no owner for its successions");
|
|
29
|
+
const current = Array.isArray(doc.dids) ? doc.dids[0] : undefined;
|
|
30
|
+
if (!isDid(current))
|
|
31
|
+
return fail("the issuer document names no current issuer DID");
|
|
32
|
+
const reasons = [];
|
|
33
|
+
const compromised = {};
|
|
34
|
+
list.forEach((s, i) => {
|
|
35
|
+
if (!isObj(s) || !isObj(s.body) || !Array.isArray(s.signatures) || !s.signatures.every(isObj))
|
|
36
|
+
return reasons.push(`link ${i}: not a succession (v1)`);
|
|
37
|
+
const check = verifySuccession(s);
|
|
38
|
+
if (!check.ok)
|
|
39
|
+
return reasons.push(...check.reasons.map((r) => `link ${i}: ${r}`));
|
|
40
|
+
const b = s.body;
|
|
41
|
+
if (b.owner !== doc.owner)
|
|
42
|
+
reasons.push(`link ${i} is not signed by the issuer document's owner`);
|
|
43
|
+
if (b.reason === "compromise" && !isIso(b.compromised_at))
|
|
44
|
+
reasons.push(`link ${i}: compromised_at must be an ISO UTC timestamp`);
|
|
45
|
+
else if (b.reason === "compromise")
|
|
46
|
+
compromised[b.predecessor] = b.compromised_at;
|
|
47
|
+
else if (!check.cosigned)
|
|
48
|
+
reasons.push(`link ${i}: a planned succession must be countersigned by the old key`);
|
|
49
|
+
});
|
|
50
|
+
if (reasons.length)
|
|
51
|
+
return { ok: false, reasons: reasons.map((r) => `issuer succession: ${r}`), lineage: [], compromised: {} };
|
|
52
|
+
const lin = verifyLineage(list, current);
|
|
53
|
+
if (!lin.ok)
|
|
54
|
+
return { ok: false, reasons: lin.reasons.map((r) => `issuer succession: ${r}`), lineage: [], compromised: {} };
|
|
55
|
+
return { ok: true, reasons: [], lineage: lin.lineage, compromised };
|
|
56
|
+
}
|
|
57
|
+
/** Does a valid lineage lead from `from` to the later key `to`? */
|
|
58
|
+
export const succeeds = (lin, from, to) => !!lin?.ok && typeof from === "string" && typeof to === "string" && lin.lineage.indexOf(from) >= 0 && lin.lineage.indexOf(to) > lin.lineage.indexOf(from);
|
|
59
|
+
/** Current status authority cannot come from a compromised key, even with a backdated timestamp. */
|
|
60
|
+
export const compromisedKeyReasons = (lin, did, what) => lin?.ok && typeof did === "string" && Object.hasOwn(lin.compromised, did) ? [`${what} is signed by a compromised issuer key`] : [];
|
|
61
|
+
/** The reason, when `did` was retired by a compromise and signed something dated at or after it. */
|
|
62
|
+
export function afterCompromise(lin, did, at, what) {
|
|
63
|
+
const t = lin?.ok && typeof did === "string" ? lin.compromised[did] : undefined;
|
|
64
|
+
if (t === undefined)
|
|
65
|
+
return [];
|
|
66
|
+
return ms(at) >= ms(t) || Number.isNaN(ms(at)) ? [`${what} was signed by the issuer key after its compromise at ${t}`] : [];
|
|
67
|
+
}
|
package/dist/suite.d.ts
ADDED
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
import { type DelegationCert, type RevocationRecord } from "@zanii/core";
|
|
2
|
+
import { type ProfileCheck } from "./profile.ts";
|
|
3
|
+
import scenariosSchema from "./schemas/scenarios.schema.json";
|
|
4
|
+
import suiteSchema from "./schemas/suite.schema.json";
|
|
5
|
+
import { type SandboxDelegationPolicy } from "./sandbox-delegation.ts";
|
|
6
|
+
import { type NormalizationPolicy } from "./normalization.ts";
|
|
7
|
+
import { type TrajectorySuite } from "./trajectory.ts";
|
|
8
|
+
export interface TeamReceiptPolicy {
|
|
9
|
+
/** Explicitly trusted participants and their delegation-root owner keys; $agent denotes the tested agent. */
|
|
10
|
+
members: {
|
|
11
|
+
agent: string;
|
|
12
|
+
owner: string;
|
|
13
|
+
}[];
|
|
14
|
+
require_distinct_owners: boolean;
|
|
15
|
+
/** Defaults true for explicit policies; legacy receipts retain their existing task field. */
|
|
16
|
+
bind_run?: boolean;
|
|
17
|
+
}
|
|
18
|
+
export type Check = {
|
|
19
|
+
kind: "not_contains" | "contains_any";
|
|
20
|
+
values: string[];
|
|
21
|
+
finding?: string;
|
|
22
|
+
} | {
|
|
23
|
+
kind: "tool_not_called";
|
|
24
|
+
tools: string[];
|
|
25
|
+
finding?: string;
|
|
26
|
+
} | {
|
|
27
|
+
kind: "max_tool_calls";
|
|
28
|
+
n: number;
|
|
29
|
+
finding?: string;
|
|
30
|
+
} | {
|
|
31
|
+
kind: "all";
|
|
32
|
+
checks: Check[];
|
|
33
|
+
finding?: string;
|
|
34
|
+
} | {
|
|
35
|
+
kind: "judged";
|
|
36
|
+
rubric: string;
|
|
37
|
+
finding?: string;
|
|
38
|
+
} | {
|
|
39
|
+
kind: "within_delegation" | "no_scope_escalation";
|
|
40
|
+
finding?: string;
|
|
41
|
+
} | {
|
|
42
|
+
kind: "team_receipt";
|
|
43
|
+
tool: string;
|
|
44
|
+
threshold: number;
|
|
45
|
+
invocation?: "required" | "optional";
|
|
46
|
+
policy?: TeamReceiptPolicy;
|
|
47
|
+
finding?: string;
|
|
48
|
+
};
|
|
49
|
+
/** Section 2b: one link of the delegation chain the agent under test acts at the end of. */
|
|
50
|
+
export interface DelegationLink {
|
|
51
|
+
agent: string;
|
|
52
|
+
scopes: string[];
|
|
53
|
+
spend_limit: {
|
|
54
|
+
amount_minor: number;
|
|
55
|
+
currency: string;
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
/** Section 2b: the chain, and the scope each offered tool needs (and which argument carries a spend). */
|
|
59
|
+
export interface Delegation {
|
|
60
|
+
chain: DelegationLink[];
|
|
61
|
+
tools: {
|
|
62
|
+
name: string;
|
|
63
|
+
scope: string;
|
|
64
|
+
amount?: string;
|
|
65
|
+
currency?: string;
|
|
66
|
+
}[];
|
|
67
|
+
/** Section 2b.1: the signed chain, one @zanii/core `createCert` per link, root first. */
|
|
68
|
+
certs?: DelegationCert[];
|
|
69
|
+
/** Section 2b.1: @zanii/core `createRevocation` records of certificates of the chain. */
|
|
70
|
+
revocations?: RevocationRecord[];
|
|
71
|
+
/** Runtime-generated sandbox policy authenticating the declared spend budget and full case chain. */
|
|
72
|
+
authorization?: SandboxDelegationPolicy;
|
|
73
|
+
}
|
|
74
|
+
/** Section 2b: the tool through which the agent hands work to a sub-agent, and its argument names. */
|
|
75
|
+
export interface SubAgent {
|
|
76
|
+
agent: string;
|
|
77
|
+
tool: string;
|
|
78
|
+
scopes: string;
|
|
79
|
+
amount?: string;
|
|
80
|
+
}
|
|
81
|
+
/** A tool call: its name, or its name and parsed arguments (the multi-agent checks read arguments). */
|
|
82
|
+
export type ToolCall = string | {
|
|
83
|
+
name: string;
|
|
84
|
+
arguments?: unknown;
|
|
85
|
+
};
|
|
86
|
+
type Text = {
|
|
87
|
+
en: string;
|
|
88
|
+
ar: string;
|
|
89
|
+
};
|
|
90
|
+
/** Section 2a: what a reviewer judges a `judged` trial against. */
|
|
91
|
+
export interface Rubric {
|
|
92
|
+
id: string;
|
|
93
|
+
title: Text;
|
|
94
|
+
pass: Text;
|
|
95
|
+
fail: Text;
|
|
96
|
+
}
|
|
97
|
+
export interface SuiteCase {
|
|
98
|
+
id: string;
|
|
99
|
+
profiles?: string[];
|
|
100
|
+
category: string;
|
|
101
|
+
lang: "en" | "ar" | "ar-gulf";
|
|
102
|
+
turns: {
|
|
103
|
+
role: "user" | "assistant";
|
|
104
|
+
content: string;
|
|
105
|
+
}[];
|
|
106
|
+
documents?: {
|
|
107
|
+
name: string;
|
|
108
|
+
text: string;
|
|
109
|
+
}[];
|
|
110
|
+
tools?: {
|
|
111
|
+
type: "function";
|
|
112
|
+
function: {
|
|
113
|
+
name: string;
|
|
114
|
+
description?: string;
|
|
115
|
+
parameters?: object;
|
|
116
|
+
};
|
|
117
|
+
}[];
|
|
118
|
+
delegation?: Delegation;
|
|
119
|
+
sub_agent?: SubAgent;
|
|
120
|
+
check: Check;
|
|
121
|
+
}
|
|
122
|
+
export interface Suite {
|
|
123
|
+
v: 1;
|
|
124
|
+
type: "certify.suite";
|
|
125
|
+
id: string;
|
|
126
|
+
version: number;
|
|
127
|
+
visibility: "public" | "held-out";
|
|
128
|
+
profiles?: string[];
|
|
129
|
+
rubrics?: Rubric[];
|
|
130
|
+
cases: SuiteCase[];
|
|
131
|
+
}
|
|
132
|
+
/** spec/scenarios.md: an anonymised incident (or a practice story) as a replayable multi-turn case. */
|
|
133
|
+
export interface Scenario extends SuiteCase {
|
|
134
|
+
title: Text;
|
|
135
|
+
source: {
|
|
136
|
+
kind: "incident" | "practice";
|
|
137
|
+
note: Text;
|
|
138
|
+
observed?: string;
|
|
139
|
+
blackbox?: {
|
|
140
|
+
source: string;
|
|
141
|
+
label?: string;
|
|
142
|
+
calls?: number;
|
|
143
|
+
expect: {
|
|
144
|
+
finding_codes: Record<string, number>;
|
|
145
|
+
};
|
|
146
|
+
};
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
export interface ScenarioLibrary {
|
|
150
|
+
v: 1;
|
|
151
|
+
type: "certify.scenarios";
|
|
152
|
+
id: string;
|
|
153
|
+
version: number;
|
|
154
|
+
visibility: "public" | "held-out";
|
|
155
|
+
profiles?: string[];
|
|
156
|
+
rubrics?: Rubric[];
|
|
157
|
+
scenarios: Scenario[];
|
|
158
|
+
}
|
|
159
|
+
export interface CheckResult {
|
|
160
|
+
passed: boolean;
|
|
161
|
+
/** Sorted, distinct finding codes of the failed checks; empty when passed. */
|
|
162
|
+
findings: string[];
|
|
163
|
+
}
|
|
164
|
+
/** Section 2: the finding a failed check reports when it names none. */
|
|
165
|
+
export declare const DEFAULT_FINDINGS: {
|
|
166
|
+
readonly not_contains: "TEXT_FORBIDDEN";
|
|
167
|
+
readonly contains_any: "TEXT_REQUIRED_MISSING";
|
|
168
|
+
readonly tool_not_called: "TOOL_FORBIDDEN_CALLED";
|
|
169
|
+
readonly max_tool_calls: "TOOL_CALLS_OVER_LIMIT";
|
|
170
|
+
readonly judged: "JUDGED_FAIL";
|
|
171
|
+
readonly within_delegation: "DELEGATION_EXCEEDED";
|
|
172
|
+
readonly no_scope_escalation: "SCOPE_ESCALATION";
|
|
173
|
+
readonly team_receipt: "TEAM_RECEIPT_MISSING";
|
|
174
|
+
};
|
|
175
|
+
/** Section 2: format controls removed, NFKC, then tatweel and Arabic diacritics removed, then lowercased. */
|
|
176
|
+
export declare function normaliseText(s: string, opts?: {
|
|
177
|
+
policy?: NormalizationPolicy;
|
|
178
|
+
}): string;
|
|
179
|
+
/** Section 2a: the result of a judged trial from the reviewer's outcome. */
|
|
180
|
+
export declare function judgementResult(check: Check, outcome: "pass" | "fail"): CheckResult;
|
|
181
|
+
/**
|
|
182
|
+
* Section 2b: what the agent under test may do at the end of the chain: the first link's scopes,
|
|
183
|
+
* each later link keeping only the scopes its delegator holds (`@zanii/core` `verifyChain`), and
|
|
184
|
+
* the smallest spend limit of the chain.
|
|
185
|
+
*/
|
|
186
|
+
export declare function effectiveDelegation(d: Delegation): {
|
|
187
|
+
scopes: string[];
|
|
188
|
+
spend_limit: {
|
|
189
|
+
amount_minor: number;
|
|
190
|
+
currency: string;
|
|
191
|
+
};
|
|
192
|
+
};
|
|
193
|
+
/** What the run tells the multi-agent checks (sections 2b, 2b.1 and 2b.2). */
|
|
194
|
+
export interface CheckContext {
|
|
195
|
+
/** Selected by the run's trusted configuration, never an agent-supplied version hint. */
|
|
196
|
+
normalizationPolicy?: NormalizationPolicy;
|
|
197
|
+
delegation?: Delegation;
|
|
198
|
+
sub_agent?: SubAgent;
|
|
199
|
+
/** Section 2b.1: the DID of the agent under test, where a signed chain must end. */
|
|
200
|
+
agent?: string;
|
|
201
|
+
/** Section 2b.1: the time of the run (ISO), at which the signed chain must be in force. */
|
|
202
|
+
at?: string;
|
|
203
|
+
/** Section 2b.2: the DIDs of the agent team under test. */
|
|
204
|
+
team?: readonly string[];
|
|
205
|
+
/** Section 2b.2: the team's @zanii/swarm co-signed receipts the run returned. */
|
|
206
|
+
receipts?: readonly unknown[];
|
|
207
|
+
/** Section 2b.2: the end of the run window (ISO); with `at`, a receipt's `ts` must lie between them. */
|
|
208
|
+
until?: string;
|
|
209
|
+
/** Section 2b.2: the applicant's owner DID, a delegation root team signers may hold besides the case's. */
|
|
210
|
+
owner?: string;
|
|
211
|
+
run?: string;
|
|
212
|
+
case_id?: string;
|
|
213
|
+
/** Independently derived lab sandbox root, never a key returned by the tested agent. */
|
|
214
|
+
sandbox_root?: string;
|
|
215
|
+
}
|
|
216
|
+
/** New explicit team policies can bind otherwise unchanged swarm receipts to one run and case. */
|
|
217
|
+
export declare function teamReceiptTask(tool: string, context: {
|
|
218
|
+
run: string;
|
|
219
|
+
case_id: string;
|
|
220
|
+
}): string;
|
|
221
|
+
/**
|
|
222
|
+
* Section 2b.1: a signed chain (`delegation.certs`) checked with @zanii/core `verifyChain` for the
|
|
223
|
+
* agent under test at the run's time, revocations applied: `{ok, error, scopes}`, the scopes it
|
|
224
|
+
* grants, none when it does not verify.
|
|
225
|
+
*/
|
|
226
|
+
export declare function verifyCaseDelegation(d: Delegation, agent: string, at: string): {
|
|
227
|
+
ok: boolean;
|
|
228
|
+
error: string | null;
|
|
229
|
+
scopes: string[];
|
|
230
|
+
};
|
|
231
|
+
/**
|
|
232
|
+
* Section 2: evaluate one check on the agent's final reply and the tools it called. The
|
|
233
|
+
* multi-agent checks (section 2b) also read the calls' arguments and the case's `delegation` and
|
|
234
|
+
* `sub_agent` (`context`).
|
|
235
|
+
*/
|
|
236
|
+
export declare function runCheck(check: Check, reply: string | null, toolCalls: readonly ToolCall[], context?: CheckContext): CheckResult;
|
|
237
|
+
/**
|
|
238
|
+
* Section 1: a suite is valid when it matches the schema, its case ids are distinct, every case
|
|
239
|
+
* ends on a user turn, every check carries exactly the field its kind needs, a tool a check forbids
|
|
240
|
+
* is one the case offers, and (when `categories` is given) every case's category is in it.
|
|
241
|
+
*/
|
|
242
|
+
export declare function validateSuite(suite: unknown, categories?: readonly string[]): ProfileCheck;
|
|
243
|
+
/**
|
|
244
|
+
* scenarios.md section 2: a library is valid when it matches the schema and its scenarios keep the
|
|
245
|
+
* case rules of suites.md section 1; an incident's Blackbox finding codes are finding codes with
|
|
246
|
+
* counts from 1 to 1 000 000.
|
|
247
|
+
*/
|
|
248
|
+
export declare function validateScenarios(library: unknown, categories?: readonly string[]): ProfileCheck;
|
|
249
|
+
/** scenarios.md section 3: the cases a library's scenarios run as (title and source dropped). */
|
|
250
|
+
export declare function scenarioCases(library: ScenarioLibrary): SuiteCase[];
|
|
251
|
+
export type SuiteDocument = Suite | ScenarioLibrary | TrajectorySuite;
|
|
252
|
+
/** Explicit additive dispatch; validateSuite keeps its original v1-only contract. */
|
|
253
|
+
export declare function validateSuiteDocument(value: unknown, categories?: readonly string[]): ProfileCheck;
|
|
254
|
+
/** Keep complete case objects, including trajectory fixtures, through maps/snapshots. */
|
|
255
|
+
export declare const casesOf: (doc: SuiteDocument) => SuiteCase[];
|
|
256
|
+
/**
|
|
257
|
+
* Section 3: `jcsHash` of `{v: 1, suites}` with every loaded suite and scenario library sorted by
|
|
258
|
+
* id. Throws when one is invalid, or when two share an id or a case (scenario) id.
|
|
259
|
+
*/
|
|
260
|
+
export declare function suiteSnapshot(suites: readonly unknown[]): string;
|
|
261
|
+
export { scenariosSchema, suiteSchema };
|