@zanii/certify 0.0.0-stage → 0.5.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 (133) hide show
  1. package/LICENSE +159 -0
  2. package/README.md +112 -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 +38 -0
  14. package/dist/beacon-client.js +52 -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 +92 -0
  22. package/dist/bundle.js +109 -0
  23. package/dist/certificate.d.ts +161 -0
  24. package/dist/certificate.js +345 -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 +242 -0
  40. package/dist/governance.js +411 -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 +58 -0
  46. package/dist/index.js +59 -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 +20 -0
  70. package/dist/profile.js +161 -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 +38 -0
  78. package/dist/runtime-client.js +185 -0
  79. package/dist/runtime-gateway.d.ts +24 -0
  80. package/dist/runtime-gateway.js +167 -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 +80 -0
  93. package/dist/schemas/scenarios.schema.json +573 -0
  94. package/dist/schemas/suite.schema.json +138 -0
  95. package/dist/scheme-authority.d.ts +5 -0
  96. package/dist/scheme-authority.js +15 -0
  97. package/dist/scheme-entry.d.ts +25 -0
  98. package/dist/scheme-entry.js +85 -0
  99. package/dist/scheme-history.d.ts +5 -0
  100. package/dist/scheme-history.js +87 -0
  101. package/dist/scheme-log.d.ts +98 -0
  102. package/dist/scheme-log.js +266 -0
  103. package/dist/scheme-merkle.d.ts +2 -0
  104. package/dist/scheme-merkle.js +20 -0
  105. package/dist/scheme-text.d.ts +25 -0
  106. package/dist/scheme-text.js +46 -0
  107. package/dist/scheme-timing.d.ts +16 -0
  108. package/dist/scheme-timing.js +22 -0
  109. package/dist/scheme-transition.d.ts +47 -0
  110. package/dist/scheme-transition.js +101 -0
  111. package/dist/scoring.d.ts +35 -0
  112. package/dist/scoring.js +50 -0
  113. package/dist/shared.d.ts +16 -0
  114. package/dist/shared.js +20 -0
  115. package/dist/signer.d.ts +18 -0
  116. package/dist/signer.js +49 -0
  117. package/dist/signing-identity.d.ts +1 -0
  118. package/dist/signing-identity.js +11 -0
  119. package/dist/status.d.ts +113 -0
  120. package/dist/status.js +204 -0
  121. package/dist/succession.d.ts +21 -0
  122. package/dist/succession.js +67 -0
  123. package/dist/suite.d.ts +257 -0
  124. package/dist/suite.js +441 -0
  125. package/dist/vc.d.ts +29 -0
  126. package/dist/vc.js +96 -0
  127. package/dist/verification-bounds.d.ts +5 -0
  128. package/dist/verification-bounds.js +76 -0
  129. package/dist/verification-policy.d.ts +32 -0
  130. package/dist/verification-policy.js +85 -0
  131. package/dist/witness.d.ts +19 -0
  132. package/dist/witness.js +80 -0
  133. package/package.json +57 -3
@@ -0,0 +1,16 @@
1
+ export declare const SHA256: RegExp;
2
+ export declare const ISO_UTC: RegExp;
3
+ export declare const DID: RegExp;
4
+ export declare const SLUG: RegExp;
5
+ /** A real UTC instant in the section 2 form (Date.parse alone accepts 2026-02-30). */
6
+ export declare const isIso: (v: unknown) => v is string;
7
+ export declare const isSha: (v: unknown) => v is string;
8
+ export declare const isDid: (v: unknown) => v is string;
9
+ export declare const isObj: (v: unknown) => v is Record<string, unknown>;
10
+ export declare const isInt: (v: unknown, min: number, max: number) => v is number;
11
+ /** Milliseconds since the epoch; NaN for anything that is not an ISO timestamp. */
12
+ export declare const ms: (v: unknown) => number;
13
+ /** `t` plus `days` whole days, as `toISOString()` writes it. */
14
+ export declare const addDays: (t: string, days: number) => string;
15
+ /** Sorted (UTF-16 code unit order, as JCS sorts keys), duplicates removed. */
16
+ export declare const sortedUnique: (xs: readonly string[]) => string[];
package/dist/shared.js ADDED
@@ -0,0 +1,20 @@
1
+ // Field rules shared by every Certify format (spec/README.md §2).
2
+ export const SHA256 = /^sha256:[0-9a-f]{64}$/;
3
+ export const ISO_UTC = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{3})?Z$/;
4
+ export const DID = /^did:key:z[1-9A-HJ-NP-Za-km-z]{40,60}$/;
5
+ export const SLUG = /^[a-z0-9][a-z0-9-]{0,63}$/;
6
+ /** A real UTC instant in the section 2 form (Date.parse alone accepts 2026-02-30). */
7
+ export const isIso = (v) => typeof v === "string" &&
8
+ ISO_UTC.test(v) &&
9
+ !Number.isNaN(Date.parse(v)) &&
10
+ new Date(v).toISOString().slice(0, 19) === v.slice(0, 19);
11
+ export const isSha = (v) => typeof v === "string" && SHA256.test(v);
12
+ export const isDid = (v) => typeof v === "string" && DID.test(v);
13
+ export const isObj = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
14
+ export const isInt = (v, min, max) => Number.isInteger(v) && v >= min && v <= max;
15
+ /** Milliseconds since the epoch; NaN for anything that is not an ISO timestamp. */
16
+ export const ms = (v) => (isIso(v) ? Date.parse(v) : Number.NaN);
17
+ /** `t` plus `days` whole days, as `toISOString()` writes it. */
18
+ export const addDays = (t, days) => new Date(Date.parse(t) + days * 86_400_000).toISOString();
19
+ /** Sorted (UTF-16 code unit order, as JCS sorts keys), duplicates removed. */
20
+ export const sortedUnique = (xs) => [...new Set(xs)].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
@@ -0,0 +1,18 @@
1
+ export interface Signer {
2
+ /** The raw 32-byte Ed25519 public key. */
3
+ publicKey: Uint8Array;
4
+ /** Returns the raw 64-byte Ed25519 signature over `message`. */
5
+ sign: (message: Uint8Array) => Promise<Uint8Array>;
6
+ }
7
+ /** A Signer over a raw 32-byte Ed25519 seed held in this process (tests, development). */
8
+ export declare function keySigner(privateKey: Uint8Array): Signer;
9
+ export declare const signerDid: (signer: Signer) => string;
10
+ /** `ed25519:<hex>` over the RFC 8785 bytes of `unsigned`. */
11
+ export declare function signatureOver(unsigned: object, signer: Signer): Promise<string>;
12
+ /** Does `sig` (`ed25519:<hex>`) by `did` verify over the RFC 8785 bytes of `unsigned`? Never throws. */
13
+ export declare function signatureOk(unsigned: object, sig: unknown, did: string): boolean;
14
+ /** @zanii/core signObject with a Signer: the same bytes, verified by verifySignedObject. */
15
+ export declare function signWith<T extends object>(obj: T, signer: Signer): Promise<T & {
16
+ signer: string;
17
+ sig: string;
18
+ }>;
package/dist/signer.js ADDED
@@ -0,0 +1,49 @@
1
+ // spec/certificate.md §4: everything Certify signs goes through a Signer, the same shape as
2
+ // @zanii/sdk AgentSigner and @zanii/kms RemoteSigner, so the issuer key can live in a KMS or HSM.
3
+ import { createPrivateKey, createPublicKey, sign as nodeSign, verify as nodeVerify } from "node:crypto";
4
+ import { canonicalBytes, didFromPublicKey, publicKeyFromDid } from "@zanii/core";
5
+ const PKCS8_ED25519 = Buffer.from("302e020100300506032b657004220420", "hex");
6
+ const SPKI_ED25519 = Buffer.from("302a300506032b6570032100", "hex");
7
+ /** A Signer over a raw 32-byte Ed25519 seed held in this process (tests, development). */
8
+ export function keySigner(privateKey) {
9
+ if (privateKey.length !== 32)
10
+ throw new Error("ed25519 private key must be 32 bytes");
11
+ const key = createPrivateKey({
12
+ key: Buffer.concat([PKCS8_ED25519, privateKey]),
13
+ format: "der",
14
+ type: "pkcs8",
15
+ });
16
+ const spki = createPublicKey(key).export({ format: "der", type: "spki" });
17
+ return {
18
+ publicKey: new Uint8Array(spki.subarray(spki.length - 32)),
19
+ sign: async (message) => new Uint8Array(nodeSign(null, message, key)),
20
+ };
21
+ }
22
+ export const signerDid = (signer) => didFromPublicKey(signer.publicKey);
23
+ /** `ed25519:<hex>` over the RFC 8785 bytes of `unsigned`. */
24
+ export async function signatureOver(unsigned, signer) {
25
+ const sig = await signer.sign(canonicalBytes(unsigned));
26
+ if (sig.length !== 64)
27
+ throw new Error("signer returned a signature that is not 64 bytes");
28
+ return `ed25519:${Buffer.from(sig).toString("hex")}`;
29
+ }
30
+ /** Does `sig` (`ed25519:<hex>`) by `did` verify over the RFC 8785 bytes of `unsigned`? Never throws. */
31
+ export function signatureOk(unsigned, sig, did) {
32
+ const pub = publicKeyFromDid(did);
33
+ if (!pub || typeof sig !== "string" || !/^ed25519:[0-9a-f]{128}$/.test(sig))
34
+ return false;
35
+ try {
36
+ const key = createPublicKey({ key: Buffer.concat([SPKI_ED25519, pub]), format: "der", type: "spki" });
37
+ return nodeVerify(null, canonicalBytes(unsigned), key, Buffer.from(sig.slice(8), "hex"));
38
+ }
39
+ catch {
40
+ return false;
41
+ }
42
+ }
43
+ /** @zanii/core signObject with a Signer: the same bytes, verified by verifySignedObject. */
44
+ export async function signWith(obj, signer) {
45
+ if ("sig" in obj)
46
+ throw new Error("signWith: the object already carries a sig");
47
+ const unsigned = { ...obj, signer: signerDid(signer) };
48
+ return { ...unsigned, sig: await signatureOver(unsigned, signer) };
49
+ }
@@ -0,0 +1 @@
1
+ export declare function signingIdentityReasons(owner: unknown, issuers: unknown, statusIssuers?: unknown): string[];
@@ -0,0 +1,11 @@
1
+ // A witness log id is its signing DID: an owner and a status issuer must never share that key.
2
+ import { publicKeyFromDid } from "@zanii/core";
3
+ const did = (v) => typeof v === "string" && v.length <= 69 && publicKeyFromDid(v) !== null;
4
+ export function signingIdentityReasons(owner, issuers, statusIssuers = []) {
5
+ if (!did(owner))
6
+ return ["signing identity: an actual Ed25519 owner DID is required"];
7
+ const valid = (list) => Array.isArray(list) && list.length <= 256 && Array.from(list).every(did) && new Set(list).size === list.length;
8
+ if (!valid(issuers) || !valid(statusIssuers))
9
+ return ["signing identity: issuer and status identities must be distinct bounded Ed25519 DID lists"];
10
+ return issuers.includes(owner) || statusIssuers.includes(owner) ? ["signing identity: the owner key must not also be an issuer or status signing key"] : [];
11
+ }
@@ -0,0 +1,113 @@
1
+ import { type SignedTreeHead } from "@zanii/core";
2
+ import type { RevocationList } from "@zanii/credentials";
3
+ import { type PqBinding } from "@zanii/pq";
4
+ import type { WitnessCosignature } from "@zanii/witness";
5
+ import { type PqCosignature } from "./pq.ts";
6
+ import { type Signer } from "./signer.ts";
7
+ import STATUS_V2_SCHEMA from "./formats/status-v2.schema.json";
8
+ export { STATUS_V2_SCHEMA };
9
+ export declare const REASONS: readonly ["FINGERPRINT_MISMATCH", "RETEST_OVERDUE", "RETEST_FAILED", "INCIDENT", "APPLICANT_REQUEST", "ISSUER_ERROR",
10
+ /** licensing.md section 2a: the issuing licensee's licence was withdrawn or suspended for cause. */
11
+ "LICENCE_WITHDRAWN",
12
+ /** audit.md section 3: a Level 3 certificate's audit attestation lapsed. */
13
+ "AUDIT_EXPIRED"];
14
+ export declare const REASONS_V2: readonly ["FINGERPRINT_MISMATCH", "RETEST_OVERDUE", "RETEST_FAILED", "INCIDENT", "APPLICANT_REQUEST", "ISSUER_ERROR", "LICENCE_WITHDRAWN", "AUDIT_EXPIRED", "MARK_MISUSE"];
15
+ export declare const STATUS_V2_MAX_ENTRIES = 100000;
16
+ export type Reason = (typeof REASONS_V2)[number];
17
+ export type StatusVersion = 1 | 2;
18
+ export type CertificateStatus = "valid" | "suspended" | "revoked";
19
+ export type StatusAction = "grant" | "suspend" | "revoke" | "reinstate";
20
+ export interface StatusEntry {
21
+ status: CertificateStatus;
22
+ /** Null when valid; the reason code when suspended or revoked. */
23
+ reason: Reason | null;
24
+ since: string;
25
+ }
26
+ /** Keyed by the certificate's credential hash. */
27
+ export type StatusEntries = Record<string, StatusEntry>;
28
+ export interface StatusDocument {
29
+ v: StatusVersion;
30
+ type: "certify.status";
31
+ issuer: string;
32
+ entries: StatusEntries;
33
+ issued_at: string;
34
+ valid_until: string;
35
+ /** spec/witness.md: the document's position in the status log, from 0. */
36
+ seq?: number;
37
+ /** spec/witness.md: jcsHash of the previous document in the log; null at seq 0. */
38
+ prev?: string | null;
39
+ /** Section 5: bindingHash of the issuer's @zanii/pq binding, when co-signed. */
40
+ pq_binding?: string;
41
+ signer: string;
42
+ sig: string;
43
+ }
44
+ /** spec/witness.md section 3: what a verifier needs to require a witnessed checkpoint. */
45
+ export interface StatusWitness {
46
+ /** The issuer's signed tree head over the status log (`statusCheckpoint`). */
47
+ checkpoint: SignedTreeHead;
48
+ /** The inclusion proof of the document at index `seq` (`statusInclusionProof`), hex. */
49
+ proof: string[];
50
+ cosignatures: WitnessCosignature[];
51
+ /** The witness DIDs this verifier trusts. */
52
+ trusted: string[];
53
+ /** How many of them must have co-signed the checkpoint. */
54
+ k: number;
55
+ }
56
+ /** A log position is `seq` and `prev` together: seq 0 has prev null, later ones a hash. */
57
+ export declare const logPositionOk: (seq: unknown, prev: unknown) => boolean;
58
+ /**
59
+ * §2: apply one status change and return the new entries (the input is not changed). Throws on a
60
+ * change the rules forbid: revoked is final, only a valid certificate is suspended, only a
61
+ * suspended one is reinstated, and time never goes backwards for one certificate.
62
+ */
63
+ export declare function applyStatus(entries: StatusEntries, certificate: string, action: StatusAction, reason: Reason | null, at: string, opts?: {
64
+ version?: StatusVersion;
65
+ }): StatusEntries;
66
+ /** §1: sign a status document. `valid_until` is the freshness bound verifiers enforce. */
67
+ export declare function buildStatus(fields: {
68
+ version?: StatusVersion;
69
+ entries: StatusEntries;
70
+ issuedAt: string;
71
+ validUntil: string;
72
+ /** spec/witness.md: place the document in the status log (both or neither). */
73
+ seq?: number;
74
+ prev?: string | null;
75
+ /** Section 5: commit to the issuer's @zanii/pq binding so the document can be co-signed. */
76
+ pqBinding?: PqBinding;
77
+ }, signer: Signer): Promise<StatusDocument>;
78
+ export interface StatusCheck {
79
+ ok: boolean;
80
+ reasons: string[];
81
+ freshness: "fresh" | "stale" | null;
82
+ /** Only with a `witness` policy: the trusted witnesses that co-signed the checkpoint. */
83
+ witnesses?: string[];
84
+ /** Only with `issuerDoc` (section 5a): `required`, `opted_out` or `not_required`. */
85
+ pqPolicy?: "required" | "opted_out" | "not_required";
86
+ }
87
+ export interface StatusVerifyOptions {
88
+ /** Independently known scheme log owner, which cannot share this status signing identity. */
89
+ schemeOwner?: string;
90
+ issuer?: string;
91
+ now?: string;
92
+ /** Section 5: the document's post-quantum co-signature. */
93
+ pq?: PqCosignature;
94
+ /** Section 5: refuse a document without a valid post-quantum co-signature. */
95
+ requirePq?: boolean;
96
+ /** spec/witness.md: require the document in a checkpoint co-signed by k trusted witnesses. */
97
+ witness?: StatusWitness;
98
+ /** Section 5a: the issuer document; `require_pq: true` in it makes `requirePq` the default. */
99
+ issuerDoc?: unknown;
100
+ /** Section 5a: explicitly verify without the co-signature the issuer document requires. */
101
+ allowNoPq?: boolean;
102
+ }
103
+ /**
104
+ * Is this an intact status document from `issuer` (when given), still fresh at `now`? With `pq`
105
+ * or `requirePq` also its post-quantum co-signature, with `witness` its witnessed checkpoint.
106
+ */
107
+ export declare function verifyStatus(doc: unknown, opts?: StatusVerifyOptions): StatusCheck;
108
+ /**
109
+ * §3: the @zanii/credentials revocation list for a status document: every certificate whose status
110
+ * is not valid, with the same issued_at and valid_until. Byte-identical to `buildRevocationList`
111
+ * with the issuer's key, so plain credential verifiers refuse suspended and revoked certificates.
112
+ */
113
+ export declare function revocationListFromStatus(doc: StatusDocument, signer: Signer): Promise<RevocationList>;
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
+ }