@zanii/certify 0.5.0 → 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/src/cli.ts ADDED
@@ -0,0 +1,331 @@
1
+ #!/usr/bin/env node
2
+ // spec/cli.md: the `certify` command. Its output is byte-identical with the Python `certify`.
3
+
4
+ import { readFileSync } from "node:fs";
5
+ import { canonicalBytes } from "@zanii/core";
6
+ import {
7
+ type Credential,
8
+ credentialHash,
9
+ type IssuerResolver,
10
+ type RevocationList,
11
+ resolveIssuer,
12
+ } from "@zanii/credentials";
13
+ import { claimReasons, verifyCertificate } from "./certificate.ts";
14
+ import { type AuditAttestation, verifyAuditAttestation } from "./audit.ts";
15
+ import messages from "./cli-messages.json" with { type: "json" };
16
+ import { buildFingerprint, type Fingerprint, promptSha256, sha256Bytes, toolsSha256 } from "./fingerprint.ts";
17
+ import { type PqCosignature, pqPolicyOf } from "./pq.ts";
18
+ import { fetchJsonBounded, type ResolutionFailure, resolveIssuerDetailed } from "./issuer-resolution.ts";
19
+ import { type BodyRegisterOptions, registerTrust } from "./register.ts";
20
+ import { isDid, isInt, isIso, isObj } from "./shared.ts";
21
+ import { type StatusDocument, type StatusWitness, verifyStatus } from "./status.ts";
22
+ import { isIssuerPolicy, type ReleaseVerificationPolicy } from "./verification-policy.ts";
23
+ import { witnessReasons } from "./witness.ts";
24
+ import { verifyCertificateVc } from "./vc.ts";
25
+
26
+ /** spec/cli.md section 4. */
27
+ export const EXIT = { valid: 0, invalid: 1, partial: 2, usage: 3, online: 4 } as const;
28
+
29
+ type Lang = keyof typeof messages;
30
+ type Key = keyof (typeof messages)["en"];
31
+ export interface CliIo {
32
+ fetch?: typeof fetch;
33
+ out?: (text: string) => void;
34
+ err?: (text: string) => void;
35
+ }
36
+
37
+ const OPTIONS: Record<string, string[]> = {
38
+ verify: ["--status", "--issuer-doc", "--fingerprint", "--online", "--now", "--lang", "--pq", "--audit", "--trusted-bodies", "--body-register", "--status-witness", "--release-policy", "--strict-policy"],
39
+ fingerprint: ["--model", "--prompt-file", "--tools-file", "--policy-file", "--knowledge-file", "--lang"],
40
+ };
41
+ // Control and bidirectional formatting characters from input files are not written to the terminal.
42
+ const UNSAFE = /[\u0000-\u001f\u007f-\u009f‎‏‪-‮⁦-⁩]/g;
43
+
44
+ /** A value from an input file as printed: strings and integers as they are, nothing for a missing value, `?` otherwise. */
45
+ const show = (v: unknown): string =>
46
+ typeof v === "string" ? v.replace(UNSAFE, "?") : Number.isInteger(v) ? String(v) : v == null ? "" : "?";
47
+
48
+ class Stop extends Error {
49
+ exit: number;
50
+ constructor(text: string, exit: number) {
51
+ super(text);
52
+ this.exit = exit;
53
+ }
54
+ }
55
+
56
+ /** Run `certify` with `argv` (without the program name); returns the exit code. */
57
+ export async function main(argv: readonly string[], io: CliIo = {}): Promise<number> {
58
+ const out = io.out ?? ((s: string) => process.stdout.write(s));
59
+ const err = io.err ?? ((s: string) => process.stderr.write(s));
60
+ const li = argv.indexOf("--lang");
61
+ const lang: Lang = argv[li + 1] === "ar" && li >= 0 ? "ar" : "en";
62
+ const t = (key: Key, ...args: unknown[]) =>
63
+ messages[lang][key].replace(/\{(\d)\}/g, (_, i) => show(args[Number(i)]));
64
+ const fail = (key: Key, ...args: unknown[]) => new Stop(t("error", t(key, ...args)), EXIT.usage);
65
+ try {
66
+ const [cmd, ...rest] = argv;
67
+ if (cmd === "--help-v2") { out(`${t("usage_v2")}\n`); return EXIT.valid; }
68
+ if (cmd === "--help" || cmd === "-h") {
69
+ out(`${t("usage")}\n`);
70
+ return EXIT.valid;
71
+ }
72
+ if (!cmd || !OPTIONS[cmd]) throw new Stop(t("usage"), EXIT.usage);
73
+ const pos: string[] = [];
74
+ const opts: Record<string, string> = {};
75
+ for (let i = 0; i < rest.length; i++) {
76
+ const a = rest[i]!;
77
+ if (!a.startsWith("--")) pos.push(a);
78
+ else if (!OPTIONS[cmd].includes(a)) throw fail("unknown_option", a);
79
+ else if (i + 1 >= rest.length) throw fail("missing_value", a);
80
+ else opts[a] = rest[++i]!;
81
+ }
82
+ if (opts["--lang"] !== undefined && opts["--lang"] !== "en" && opts["--lang"] !== "ar") throw fail("bad_lang");
83
+ const read = (path: string): Uint8Array => {
84
+ try {
85
+ return readFileSync(path);
86
+ } catch {
87
+ throw fail("cannot_read", path);
88
+ }
89
+ };
90
+ const text = (path: string): string => {
91
+ try {
92
+ return new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(read(path));
93
+ } catch (e) {
94
+ throw e instanceof Stop ? e : fail("not_text", path);
95
+ }
96
+ };
97
+ const json = (path: string): unknown => {
98
+ const s = text(path);
99
+ try {
100
+ return JSON.parse(s);
101
+ } catch {
102
+ throw fail("not_json", path);
103
+ }
104
+ };
105
+
106
+ if (cmd === "fingerprint") {
107
+ if (pos.length || opts["--model"] === undefined) throw new Stop(t("usage"), EXIT.usage);
108
+ const p = opts["--prompt-file"];
109
+ const tf = opts["--tools-file"];
110
+ let tools: unknown = tf === undefined ? null : json(tf);
111
+ if (isObj(tools) && Array.isArray(tools.tools)) tools = tools.tools;
112
+ if (tf !== undefined && !Array.isArray(tools)) throw fail("not_tools", tf);
113
+ const pack = (k: string) => (opts[k] === undefined ? null : sha256Bytes(read(opts[k]!)));
114
+ let fp: Fingerprint;
115
+ try {
116
+ fp = buildFingerprint({
117
+ model: opts["--model"],
118
+ prompt: p === undefined ? null : promptSha256(text(p)),
119
+ tools: Array.isArray(tools) ? toolsSha256(tools) : null,
120
+ policy: pack("--policy-file"),
121
+ knowledge: pack("--knowledge-file"),
122
+ });
123
+ } catch (e) {
124
+ throw e instanceof Stop ? e : new Stop(t("error", (e as Error).message), EXIT.usage);
125
+ }
126
+ out(`${new TextDecoder().decode(canonicalBytes(fp))}\n`);
127
+ return EXIT.valid;
128
+ }
129
+
130
+ // verify
131
+ if (pos.length !== 1) throw new Stop(t("usage"), EXIT.usage);
132
+ const now = opts["--now"];
133
+ if (now !== undefined && !isIso(now)) throw fail("bad_now");
134
+ const input = json(pos[0]!);
135
+ // A W3C VC export (spec/vc.md) is verified, then its certificate is checked as any other.
136
+ const vc = isObj(input) && Array.isArray(input.type) && input.type.includes("VerifiableCredential") ? verifyCertificateVc(input, now ? { at: now } : {}) : null;
137
+ if (vc && !vc.certificate) {
138
+ out(`${t("vc_bad")}\n${t("result_invalid")}\n`);
139
+ return EXIT.invalid;
140
+ }
141
+ const cert = (vc?.certificate ?? input) as Credential;
142
+ let status: StatusDocument | undefined;
143
+ let list: RevocationList | undefined;
144
+ let onlineStatusPq: PqCosignature | undefined;
145
+ const takeStatus = (v: unknown, where: string) => {
146
+ if (isObj(v) && v.type === "certify.status") status = v as unknown as StatusDocument;
147
+ else if (isObj(v) && isObj(v.status) && v.status.type === "certify.status" && isObj(v.revocation_list) && v.revocation_list.type === "revocation_list") {
148
+ status = v.status as unknown as StatusDocument;
149
+ list = v.revocation_list as unknown as RevocationList;
150
+ if (isObj(v.pq)) onlineStatusPq = v.pq as unknown as PqCosignature;
151
+ } else throw fail("not_status", where);
152
+ };
153
+ if (opts["--status"] !== undefined) takeStatus(json(opts["--status"]), opts["--status"]);
154
+ const base = opts["--online"];
155
+ const doFetch = io.fetch ?? fetch;
156
+ if (base !== undefined && !status) {
157
+ let url = base;
158
+ try {
159
+ const u = new URL("/v1/status", base);
160
+ if (u.protocol !== "https:" && u.protocol !== "http:") throw new Error("not http(s)");
161
+ url = u.href;
162
+ const res = await fetchJsonBounded(url, { fetch: doFetch, maxBytes: 8_388_608 });
163
+ if (!res.ok) throw new Error(res.code);
164
+ takeStatus(res.document, url);
165
+ } catch (e) {
166
+ throw e instanceof Stop ? e : new Stop(t("error", t("online_failed", url)), EXIT.online);
167
+ }
168
+ }
169
+ let resolver: IssuerResolver | undefined;
170
+ // certificate.md section 5: the issuer document's status_dids, when it lists them
171
+ let issuerDoc: unknown;
172
+ let resolutionFailure: ResolutionFailure | undefined;
173
+ if (opts["--issuer-doc"] !== undefined) {
174
+ const doc = json(opts["--issuer-doc"]);
175
+ issuerDoc = doc;
176
+ resolver = (d, domain) => resolveIssuer(d, domain, { fetchImpl: async () => Response.json(doc) });
177
+ } else if (base !== undefined) {
178
+ const resolved = await resolveIssuerDetailed(String(cert?.issuer), String(cert?.issuer_domain), { fetch: doFetch });
179
+ if (resolved.ok) issuerDoc = resolved.document;
180
+ else resolutionFailure = resolved;
181
+ resolver = (d, domain) => issuerDoc === undefined ? null : resolveIssuer(d, domain, { fetchImpl: async () => Response.json(issuerDoc) });
182
+ }
183
+ const sd = isObj(issuerDoc) ? issuerDoc.status_dids : undefined;
184
+ const statusDids = Array.isArray(sd) ? sd.filter((x): x is string => typeof x === "string") : undefined;
185
+ const fingerprint = opts["--fingerprint"] === undefined ? undefined : (json(opts["--fingerprint"]) as Fingerprint);
186
+ const policyFile = opts["--strict-policy"] === undefined ? undefined : json(opts["--strict-policy"]);
187
+ if (policyFile !== undefined && !isIssuerPolicy(policyFile)) throw fail("bad_policy", "--strict-policy");
188
+ const issuerPolicy = isIssuerPolicy(policyFile) ? policyFile : undefined;
189
+ const audit = opts["--audit"] === undefined ? undefined : json(opts["--audit"]) as AuditAttestation;
190
+ if (audit !== undefined && (!isObj(audit) || audit.type !== "certify.audit-attestation")) throw fail("bad_policy", "--audit");
191
+ const trustedBodies = opts["--trusted-bodies"] === undefined ? undefined : json(opts["--trusted-bodies"]) as string[];
192
+ if (trustedBodies !== undefined && (!Array.isArray(trustedBodies) || trustedBodies.length > 64 || !trustedBodies.every(isDid))) throw fail("bad_policy", "--trusted-bodies");
193
+ const bodyRegister = opts["--body-register"] === undefined ? undefined : json(opts["--body-register"]) as BodyRegisterOptions;
194
+ if (bodyRegister !== undefined && (!isObj(bodyRegister) || !isDid(bodyRegister.owner) || !Array.isArray(bodyRegister.boards) || bodyRegister.boards.length > 10_000 || !Array.isArray(bodyRegister.registers) || bodyRegister.registers.length > 10_000)) throw fail("bad_policy", "--body-register");
195
+ const statusWitness = opts["--status-witness"] === undefined ? undefined : json(opts["--status-witness"]) as StatusWitness;
196
+ if (statusWitness !== undefined && (!isObj(statusWitness) || !isObj(statusWitness.checkpoint) || !Array.isArray(statusWitness.proof) || statusWitness.proof.length > 32
197
+ || !Array.isArray(statusWitness.cosignatures) || statusWitness.cosignatures.length > 64 || !Array.isArray(statusWitness.trusted) || statusWitness.trusted.length > 64
198
+ || !statusWitness.trusted.every(isDid) || !isInt(statusWitness.k, 1, 64))) throw fail("bad_policy", "--status-witness");
199
+ const releases = opts["--release-policy"] === undefined ? undefined : json(opts["--release-policy"]) as ReleaseVerificationPolicy;
200
+ if (releases !== undefined && !isObj(releases)) throw fail("bad_policy", "--release-policy");
201
+ // spec/cli.md section 2: `--pq <file>` holds the co-signatures ({certificate, status} or one
202
+ // for the certificate); `--pq off` opts out of the issuer document's require_pq, and is printed.
203
+ const pqArg = opts["--pq"];
204
+ const pqFile = pqArg === undefined || pqArg === "off" ? undefined : json(pqArg);
205
+ const pqPair = isObj(pqFile) && pqFile.type !== "certify.pq-signature" ? pqFile : { certificate: pqFile };
206
+ let certPq = isObj(pqPair.certificate) ? (pqPair.certificate as unknown as PqCosignature) : undefined;
207
+ let statusPq = isObj(pqPair.status) ? (pqPair.status as unknown as PqCosignature) : undefined;
208
+ if (pqFile !== undefined && !certPq && !statusPq) throw fail("not_pq", pqArg);
209
+
210
+ const c = (isObj(cert) ? cert : {}) as Record<string, unknown>;
211
+ if (c.v !== 1 || c.type !== "credential" || !c.issuer || !c.holder || !c.issuer_domain) {
212
+ out(`${t("not_certificate")}\n${t("result_invalid")}\n`);
213
+ return EXIT.invalid;
214
+ }
215
+ const onlinePq = base !== undefined && pqArg !== "off" && (pqPolicyOf(issuerPolicy ?? issuerDoc, false, String(c.issued_at)) === "required");
216
+ if (onlinePq) {
217
+ statusPq ??= onlineStatusPq;
218
+ if (status && !statusPq) {
219
+ const url = new URL("/v1/status", base).href;
220
+ const response = await fetchJsonBounded(url, { fetch: doFetch, maxBytes: 8_388_608 });
221
+ if (!response.ok) throw new Stop(t("error", t("online_failed", url)), EXIT.online);
222
+ if (isObj(response.document) && isObj(response.document.pq)) statusPq = response.document.pq as unknown as PqCosignature;
223
+ }
224
+ if (!certPq) {
225
+ let url: string;
226
+ try { url = new URL(`/v1/certificates/${encodeURIComponent(credentialHash(cert))}`, base).href; }
227
+ catch { throw new Stop(t("error", t("online_failed", base)), EXIT.online); }
228
+ const response = await fetchJsonBounded(url, { fetch: doFetch, maxBytes: 1_048_576 });
229
+ if (!response.ok) throw new Stop(t("error", t("online_failed", url)), EXIT.online);
230
+ if (isObj(response.document) && isObj(response.document.pq)) certPq = response.document.pq as unknown as PqCosignature;
231
+ }
232
+ }
233
+ const r = await verifyCertificate(cert, {
234
+ ...(status ? { status } : {}),
235
+ ...(list ? { revocationList: list } : {}),
236
+ ...(resolver ? { resolver } : {}),
237
+ ...(statusDids ? { statusDids } : {}),
238
+ ...(fingerprint ? { fingerprint } : {}),
239
+ ...(now ? { now } : {}),
240
+ ...(issuerDoc !== undefined ? { issuerDoc } : {}),
241
+ ...(pqArg === "off" ? { allowNoPq: true } : {}),
242
+ ...(certPq ? { pq: certPq } : {}),
243
+ ...(statusPq ? { statusPq } : {}),
244
+ ...(issuerPolicy ? { issuerPolicy } : {}), ...(audit !== undefined ? { audit } : {}), ...(trustedBodies ? { trustedBodies } : {}),
245
+ ...(bodyRegister ? { bodyRegister } : {}), ...(statusWitness ? { statusWitness } : {}), ...(releases !== undefined ? { releases } : {}),
246
+ });
247
+ const cr = r.credential.reasons;
248
+ const claim = isObj(c.claim) ? c.claim : {};
249
+ const lines = [
250
+ t("certificate", credentialHash(cert)),
251
+ t("holder", c.holder),
252
+ t("profile", claim.profile, claim.level),
253
+ t("issuer", c.issuer, c.issuer_domain),
254
+ t("expires", c.exp),
255
+ ];
256
+ const marks: string[] = [];
257
+ const check = (name: Key, mark: "ok" | "fail" | "skip", detail: string) => {
258
+ marks.push(mark);
259
+ lines.push(`[${mark}] ${t(name)}: ${detail}`);
260
+ };
261
+ if (vc) check("vc", "ok", t("vc_ok"));
262
+ const sigBad = cr.includes("bad issuer signature");
263
+ check("signature", sigBad ? "fail" : "ok", t(sigBad ? "sig_bad" : "sig_ok"));
264
+ const expBad = cr.some((x) => x === "invalid exp" || x.startsWith("credential expired on"));
265
+ check("expiry", expBad ? "fail" : "ok", t(expBad ? "exp_bad" : "exp_ok", c.exp));
266
+ const claimBad = claimReasons(cert).length > 0;
267
+ check("claim", claimBad ? "fail" : "ok", t(claimBad ? "claim_bad" : "claim_ok"));
268
+ if (!status) check("status", "skip", t("not_supplied"));
269
+ else {
270
+ // after a key rotation the document is signed by another DID the issuer domain publishes
271
+ const rotated = status.issuer !== c.issuer && !!statusDids?.includes(status.issuer) && !!resolver && !!(await resolver(status.issuer, String(c.issuer_domain)));
272
+ const s = verifyStatus(status, { issuer: rotated ? status.issuer : String(c.issuer), ...(now ? { now } : {}) });
273
+ const e = r.status;
274
+ if (s.reasons.some((x) => !x.startsWith("status document expired on")) || r.reasons.includes("status document is not signed by a current status key of the issuer"))
275
+ check("status", "fail", t("status_invalid"));
276
+ else if (s.freshness === "stale") check("status", "fail", t("status_stale", status.valid_until));
277
+ else if (!e) check("status", "fail", t("status_unlisted"));
278
+ else if (e.status === "valid") check("status", "ok", t("status_valid", e.since));
279
+ else check("status", "fail", t(e.status === "suspended" ? "status_suspended" : "status_revoked", e.reason, e.since));
280
+ }
281
+ if (!list) check("revocation", "skip", t("not_supplied"));
282
+ else if (["revocation list is from a different issuer", "revocation list signature is invalid", "revocation list is not a revocation list (v1)", "revocation list is not signed by a current status key of the issuer"].some((x) => cr.includes(x)))
283
+ check("revocation", "fail", t("rev_invalid"));
284
+ else if (cr.includes("credential has been REVOKED by its issuer")) check("revocation", "fail", t("rev_revoked"));
285
+ else if (cr.some((x) => x.startsWith("revocation list expired on"))) check("revocation", "fail", t("rev_stale", list.valid_until));
286
+ else check("revocation", "ok", t("rev_ok"));
287
+ if (!resolver) check("issuer_domain", "skip", t("issuer_skip"));
288
+ else {
289
+ const bad = cr.some((x) => x.includes(" is not published by "));
290
+ check("issuer_domain", bad ? "fail" : "ok", resolutionFailure && resolutionFailure.code !== "key_missing" ? t("issuer_resolution", resolutionFailure.code) : t(bad ? "issuer_bad" : "issuer_ok", c.issuer_domain));
291
+ }
292
+ if (r.fingerprint === null) check("fingerprint", "skip", t("not_supplied"));
293
+ else check("fingerprint", r.fingerprint === "match" ? "ok" : "fail", t(r.fingerprint === "match" ? "fp_ok" : "fp_bad"));
294
+ if (claim.level === 3) {
295
+ if (audit === undefined) check("audit", "skip", t("audit_skip"));
296
+ else {
297
+ const reg = bodyRegister ? registerTrust(cert, audit?.body, bodyRegister) : null;
298
+ const checked = verifyAuditAttestation(audit, { certificate: cert, trustedBodies: reg?.trusted ?? trustedBodies, ...(now ? { now } : {}) });
299
+ const bad = !!reg?.reasons.length || !checked.ok;
300
+ check("audit", bad ? "fail" : "ok", t(bad ? "audit_bad" : "audit_ok"));
301
+ }
302
+ }
303
+ if (statusWitness) {
304
+ const checked = status ? witnessReasons(status as unknown as Record<string, unknown>, statusWitness) : null;
305
+ check("witness", checked && !checked.reasons.length ? "ok" : "fail", t(checked && !checked.reasons.length ? "policy_ok" : "policy_bad"));
306
+ }
307
+ for (const [policy, key, prefix] of [[issuerPolicy, "issuer_policy", "issuer policy:"], [releases, "release_policy", "release policy:"]] as const) if (policy !== undefined) {
308
+ const bad = r.reasons.filter((reason) => reason.startsWith(prefix));
309
+ check(key, bad.length ? "fail" : "ok", bad.length ? bad.join("; ") : t("policy_ok"));
310
+ }
311
+ if (r.pqPolicy === "opted_out") check("pq", "skip", t("pq_optout"));
312
+ else if (r.pqPolicy === "required" || pqFile !== undefined || onlinePq || issuerPolicy?.require_pq) {
313
+ const bad = r.reasons.filter((x) => x.includes("post-quantum"));
314
+ if (!bad.length) check("pq", "ok", t("pq_ok"));
315
+ else check("pq", "fail", t(bad.every((x) => x.endsWith("a post-quantum signature is required")) ? "pq_missing" : "pq_bad"));
316
+ }
317
+
318
+ // A reason no line explains (none is expected) still makes the certificate invalid.
319
+ const failed = marks.includes("fail") || (!marks.includes("skip") && !r.ok);
320
+ const code = failed ? EXIT.invalid : marks.includes("skip") ? EXIT.partial : EXIT.valid;
321
+ lines.push(t(code === EXIT.valid ? "result_valid" : code === EXIT.invalid ? "result_invalid" : "result_partial"));
322
+ out(`${lines.join("\n")}\n`);
323
+ return resolutionFailure && ["dns", "tls", "timeout", "network", "busy", "http", "too_large"].includes(resolutionFailure.code) ? EXIT.online : code;
324
+ } catch (e) {
325
+ if (!(e instanceof Stop)) throw e;
326
+ err(`${e.message}\n`);
327
+ return e.exit;
328
+ }
329
+ }
330
+
331
+ if (import.meta.main) process.exitCode = await main(process.argv.slice(2));