@zanii/certify 0.0.0-stage → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (141) hide show
  1. package/LICENSE +159 -0
  2. package/README.md +144 -2
  3. package/dist/appliance-execution.d.ts +18 -0
  4. package/dist/appliance-execution.js +12 -0
  5. package/dist/appliance.d.ts +135 -0
  6. package/dist/appliance.js +253 -0
  7. package/dist/assurance-status.d.ts +58 -0
  8. package/dist/assurance-status.js +86 -0
  9. package/dist/audit-renewal.d.ts +39 -0
  10. package/dist/audit-renewal.js +62 -0
  11. package/dist/audit.d.ts +69 -0
  12. package/dist/audit.js +112 -0
  13. package/dist/beacon-client.d.ts +40 -0
  14. package/dist/beacon-client.js +151 -0
  15. package/dist/beacon.d.ts +84 -0
  16. package/dist/beacon.js +106 -0
  17. package/dist/bitstring-status.d.ts +56 -0
  18. package/dist/bitstring-status.js +135 -0
  19. package/dist/board.d.ts +218 -0
  20. package/dist/board.js +428 -0
  21. package/dist/bundle.d.ts +100 -0
  22. package/dist/bundle.js +122 -0
  23. package/dist/certificate.d.ts +161 -0
  24. package/dist/certificate.js +346 -0
  25. package/dist/cli-messages.json +148 -0
  26. package/dist/cli.d.ts +16 -0
  27. package/dist/cli.js +369 -0
  28. package/dist/current-issuer-vc.d.ts +20 -0
  29. package/dist/current-issuer-vc.js +60 -0
  30. package/dist/evidence-share.d.ts +89 -0
  31. package/dist/evidence-share.js +479 -0
  32. package/dist/federation.d.ts +103 -0
  33. package/dist/federation.js +240 -0
  34. package/dist/feed-snapshot.d.ts +97 -0
  35. package/dist/feed-snapshot.js +186 -0
  36. package/dist/fingerprint.d.ts +23 -0
  37. package/dist/fingerprint.js +48 -0
  38. package/dist/formats/status-v2.schema.json +31 -0
  39. package/dist/governance.d.ts +244 -0
  40. package/dist/governance.js +418 -0
  41. package/dist/identity.d.ts +79 -0
  42. package/dist/identity.js +146 -0
  43. package/dist/impartiality.d.ts +47 -0
  44. package/dist/impartiality.js +85 -0
  45. package/dist/index.d.ts +61 -0
  46. package/dist/index.js +63 -0
  47. package/dist/issuer-authorization.d.ts +4 -0
  48. package/dist/issuer-authorization.js +52 -0
  49. package/dist/issuer-resolution.d.ts +25 -0
  50. package/dist/issuer-resolution.js +89 -0
  51. package/dist/licence-owner-entry.d.ts +20 -0
  52. package/dist/licence-owner-entry.js +33 -0
  53. package/dist/licence-owner.d.ts +67 -0
  54. package/dist/licence-owner.js +180 -0
  55. package/dist/licence.d.ts +174 -0
  56. package/dist/licence.js +400 -0
  57. package/dist/live-evidence.d.ts +135 -0
  58. package/dist/live-evidence.js +210 -0
  59. package/dist/nitro-roots.d.ts +3 -0
  60. package/dist/nitro-roots.js +20 -0
  61. package/dist/nitro-x509.d.ts +16 -0
  62. package/dist/nitro-x509.js +141 -0
  63. package/dist/nitro.d.ts +74 -0
  64. package/dist/nitro.js +329 -0
  65. package/dist/normalization.d.ts +8 -0
  66. package/dist/normalization.js +10 -0
  67. package/dist/pq.d.ts +32 -0
  68. package/dist/pq.js +69 -0
  69. package/dist/profile.d.ts +15 -0
  70. package/dist/profile.js +165 -0
  71. package/dist/register.d.ts +79 -0
  72. package/dist/register.js +134 -0
  73. package/dist/rotation.d.ts +49 -0
  74. package/dist/rotation.js +124 -0
  75. package/dist/royalty-reconciliation.d.ts +24 -0
  76. package/dist/royalty-reconciliation.js +88 -0
  77. package/dist/runtime-client.d.ts +40 -0
  78. package/dist/runtime-client.js +190 -0
  79. package/dist/runtime-gateway.d.ts +26 -0
  80. package/dist/runtime-gateway.js +169 -0
  81. package/dist/runtime-knowledge.d.ts +28 -0
  82. package/dist/runtime-knowledge.js +75 -0
  83. package/dist/runtime-nitro.d.ts +30 -0
  84. package/dist/runtime-nitro.js +57 -0
  85. package/dist/runtime.d.ts +226 -0
  86. package/dist/runtime.js +254 -0
  87. package/dist/sandbox-delegation.d.ts +38 -0
  88. package/dist/sandbox-delegation.js +61 -0
  89. package/dist/schema-dialect.d.ts +1 -0
  90. package/dist/schema-dialect.js +97 -0
  91. package/dist/schemas/clause-map.schema.json +52 -0
  92. package/dist/schemas/profile.schema.json +81 -0
  93. package/dist/schemas/scenarios.schema.json +573 -0
  94. package/dist/schemas/suite.schema.json +138 -0
  95. package/dist/schemas/trajectory.schema.json +1396 -0
  96. package/dist/scheme-authority.d.ts +5 -0
  97. package/dist/scheme-authority.js +15 -0
  98. package/dist/scheme-entry.d.ts +25 -0
  99. package/dist/scheme-entry.js +85 -0
  100. package/dist/scheme-history.d.ts +5 -0
  101. package/dist/scheme-history.js +87 -0
  102. package/dist/scheme-log.d.ts +98 -0
  103. package/dist/scheme-log.js +266 -0
  104. package/dist/scheme-merkle.d.ts +2 -0
  105. package/dist/scheme-merkle.js +20 -0
  106. package/dist/scheme-text.d.ts +25 -0
  107. package/dist/scheme-text.js +46 -0
  108. package/dist/scheme-timing.d.ts +16 -0
  109. package/dist/scheme-timing.js +22 -0
  110. package/dist/scheme-transition.d.ts +47 -0
  111. package/dist/scheme-transition.js +101 -0
  112. package/dist/scoring.d.ts +35 -0
  113. package/dist/scoring.js +50 -0
  114. package/dist/shared.d.ts +16 -0
  115. package/dist/shared.js +20 -0
  116. package/dist/signer.d.ts +18 -0
  117. package/dist/signer.js +49 -0
  118. package/dist/signing-identity.d.ts +1 -0
  119. package/dist/signing-identity.js +11 -0
  120. package/dist/status.d.ts +113 -0
  121. package/dist/status.js +204 -0
  122. package/dist/succession.d.ts +21 -0
  123. package/dist/succession.js +67 -0
  124. package/dist/suite.d.ts +261 -0
  125. package/dist/suite.js +458 -0
  126. package/dist/trajectory.d.ts +216 -0
  127. package/dist/trajectory.js +409 -0
  128. package/dist/transcript-provenance.d.ts +25 -0
  129. package/dist/transcript-provenance.js +37 -0
  130. package/dist/transport-key.d.ts +3 -0
  131. package/dist/transport-key.js +16 -0
  132. package/dist/vc.d.ts +29 -0
  133. package/dist/vc.js +96 -0
  134. package/dist/verification-bounds.d.ts +5 -0
  135. package/dist/verification-bounds.js +78 -0
  136. package/dist/verification-policy.d.ts +32 -0
  137. package/dist/verification-policy.js +85 -0
  138. package/dist/witness.d.ts +19 -0
  139. package/dist/witness.js +80 -0
  140. package/package.json +65 -4
  141. package/src/cli.ts +331 -0
package/dist/nitro.js ADDED
@@ -0,0 +1,329 @@
1
+ // spec/attestation-nitro.md: an AWS Nitro Enclaves attestation document verifier, injected as the
2
+ // @zanii/attest platform verifier for platform `nitro`.
3
+ import { createHash, verify, X509Certificate } from "node:crypto";
4
+ import { nitroCertificatePathReasons } from "./nitro-x509.js";
5
+ import { AWS_NITRO_GOV_ROOT_PEM } from "./nitro-roots.js";
6
+ export { AWS_NITRO_GOV_ROOT_PEM, AWS_NITRO_GOV_ROOT_SHA256 } from "./nitro-roots.js";
7
+ /**
8
+ * Section 2: the AWS Nitro Enclaves Root-G1 certificate, from
9
+ * https://aws-nitro-enclaves.amazonaws.com/AWS_NitroEnclaves_Root-G1.zip, whose SHA-256
10
+ * fingerprint AWS publishes at https://docs.aws.amazon.com/enclaves/latest/user/verify-root.html
11
+ * (CN=aws.nitro-enclaves, C=US, O=Amazon, OU=AWS, valid 2019-10-28 to 2049-10-28).
12
+ */
13
+ export const AWS_NITRO_ROOT_PEM = `-----BEGIN CERTIFICATE-----
14
+ MIICETCCAZagAwIBAgIRAPkxdWgbkK/hHUbMtOTn+FYwCgYIKoZIzj0EAwMwSTEL
15
+ MAkGA1UEBhMCVVMxDzANBgNVBAoMBkFtYXpvbjEMMAoGA1UECwwDQVdTMRswGQYD
16
+ VQQDDBJhd3Mubml0cm8tZW5jbGF2ZXMwHhcNMTkxMDI4MTMyODA1WhcNNDkxMDI4
17
+ MTQyODA1WjBJMQswCQYDVQQGEwJVUzEPMA0GA1UECgwGQW1hem9uMQwwCgYDVQQL
18
+ DANBV1MxGzAZBgNVBAMMEmF3cy5uaXRyby1lbmNsYXZlczB2MBAGByqGSM49AgEG
19
+ BSuBBAAiA2IABPwCVOumCMHzaHDimtqQvkY4MpJzbolL//Zy2YlES1BR5TSksfbb
20
+ 48C8WBoyt7F2Bw7eEtaaP+ohG2bnUs990d0JX28TcPQXCEPZ3BABIeTPYwEoCWZE
21
+ h8l5YoQwTcU/9KNCMEAwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUkCW1DdkF
22
+ R+eWw5b6cp3PmanfS5YwDgYDVR0PAQH/BAQDAgGGMAoGCCqGSM49BAMDA2kAMGYC
23
+ MQCjfy+Rocm9Xue4YnwWmNJVA44fA0P5W2OpYow9OYCVRaEevL8uO1XYru5xtMPW
24
+ rfMCMQCi85sWBbJwKKXdS6BptQFuZbT73o/gBh1qUxl/nNr12UO8Yfwr6wPLb+6N
25
+ IwLz3/Y=
26
+ -----END CERTIFICATE-----
27
+ `;
28
+ /** The fingerprint AWS publishes for the root (verify-root.html), as `X509Certificate.fingerprint256` writes it. */
29
+ export const AWS_NITRO_ROOT_SHA256 = "64:1A:03:21:A3:E2:44:EF:E4:56:46:31:95:D6:06:31:7E:D7:CD:CC:3C:17:56:E0:98:93:F3:C6:8F:79:BB:5B";
30
+ /** Explicit operator policy only. Never select a root from the quote's subject, bundle or request. */
31
+ export function nitroRootForPartition(partition = "aws") {
32
+ if (partition === "aws")
33
+ return AWS_NITRO_ROOT_PEM;
34
+ if (partition === "aws-us-gov")
35
+ return AWS_NITRO_GOV_ROOT_PEM;
36
+ throw new Error("unsupported Nitro trust partition");
37
+ }
38
+ /** Section 5: default freshness window, and the allowed skew between the document and the attestation claim. */
39
+ export const NITRO_MAX_AGE_SECONDS = 300;
40
+ export const NITRO_MAX_SKEW_SECONDS = 300;
41
+ const MAX_DEPTH = 8;
42
+ function decodeCbor(b) {
43
+ let p = 0;
44
+ const need = (n) => {
45
+ if (n > b.length - p)
46
+ throw new Error("truncated CBOR");
47
+ };
48
+ const head = () => {
49
+ need(1);
50
+ const ib = b[p++];
51
+ const major = ib >> 5;
52
+ const ai = ib & 31;
53
+ if (ai < 24)
54
+ return [major, ai];
55
+ if (ai > 27)
56
+ throw new Error("unsupported CBOR length encoding");
57
+ const n = 1 << (ai - 24);
58
+ need(n);
59
+ let v = 0n;
60
+ for (let i = 0; i < n; i++)
61
+ v = (v << 8n) | BigInt(b[p++]);
62
+ if (v > BigInt(Number.MAX_SAFE_INTEGER))
63
+ throw new Error("CBOR integer too large");
64
+ return [major, Number(v)];
65
+ };
66
+ const item = (depth) => {
67
+ if (depth > MAX_DEPTH)
68
+ throw new Error("CBOR nested too deep");
69
+ const [major, v] = head();
70
+ switch (major) {
71
+ case 0:
72
+ return v;
73
+ case 1:
74
+ return -1 - v;
75
+ case 2:
76
+ need(v);
77
+ return b.subarray(p, (p += v));
78
+ case 3:
79
+ need(v);
80
+ return new TextDecoder("utf-8", { fatal: true }).decode(b.subarray(p, (p += v)));
81
+ case 4: {
82
+ need(v); // each item takes at least one byte
83
+ const out = [];
84
+ for (let i = 0; i < v; i++)
85
+ out.push(item(depth + 1));
86
+ return out;
87
+ }
88
+ case 5: {
89
+ need(v * 2);
90
+ const out = new Map();
91
+ for (let i = 0; i < v; i++) {
92
+ const k = item(depth + 1);
93
+ if (typeof k !== "number" && typeof k !== "string")
94
+ throw new Error("CBOR map key is not an integer or text");
95
+ if (out.has(k))
96
+ throw new Error("duplicate CBOR map key");
97
+ out.set(k, item(depth + 1));
98
+ }
99
+ return out;
100
+ }
101
+ case 6:
102
+ if (depth !== 0 || v !== 18)
103
+ throw new Error("unexpected CBOR tag");
104
+ return item(depth + 1);
105
+ default:
106
+ if (v === 20)
107
+ return false;
108
+ if (v === 21)
109
+ return true;
110
+ if (v === 22)
111
+ return null;
112
+ throw new Error("unsupported CBOR simple value");
113
+ }
114
+ };
115
+ const out = item(0);
116
+ if (p !== b.length)
117
+ throw new Error("trailing bytes after CBOR");
118
+ return out;
119
+ }
120
+ const cborHead = (major, n) => {
121
+ if (n < 24)
122
+ return Uint8Array.of((major << 5) | n);
123
+ if (n < 0x100)
124
+ return Uint8Array.of((major << 5) | 24, n);
125
+ if (n < 0x10000)
126
+ return Uint8Array.of((major << 5) | 25, n >> 8, n & 255);
127
+ return Uint8Array.of((major << 5) | 26, n >>> 24, (n >> 16) & 255, (n >> 8) & 255, n & 255);
128
+ };
129
+ const cborBytes = (b) => Buffer.concat([cborHead(2, b.length), b]);
130
+ /** RFC 9052 section 4.4: Sig_structure = ["Signature1", body_protected, external_aad (empty), payload]. */
131
+ export function sigStructure(protectedBytes, payload) {
132
+ return Buffer.concat([cborHead(4, 4), cborHead(3, 10), Buffer.from("Signature1"), cborBytes(protectedBytes), cborBytes(new Uint8Array(0)), cborBytes(payload)]);
133
+ }
134
+ // ---- The attestation document (section 3).
135
+ const isBytes = (v, min, max) => v instanceof Uint8Array && v.length >= min && v.length <= max;
136
+ const hex = (b) => Buffer.from(b).toString("hex");
137
+ function optionalBytes(m, key) {
138
+ const v = m.get(key);
139
+ if (v === undefined || v === null)
140
+ return null;
141
+ if (!isBytes(v, 0, 1024))
142
+ throw new Error(`${key} is not bytes of at most 1024`);
143
+ return v;
144
+ }
145
+ /** Sections 3 and 4: decode the COSE_Sign1 envelope and its attestation document; throws on any malformed part. */
146
+ export function parseNitroDocument(bytes) {
147
+ if (!(bytes instanceof Uint8Array) || bytes.byteLength < 1 || bytes.byteLength > 16384)
148
+ throw new Error("attestation document must contain 1 to 16384 bytes");
149
+ const cose = decodeCbor(bytes);
150
+ if (!Array.isArray(cose) || cose.length !== 4)
151
+ throw new Error("not a COSE_Sign1 array");
152
+ const [protectedBytes, unprotected, payload, signature] = cose;
153
+ if (!(protectedBytes instanceof Uint8Array) || !(unprotected instanceof Map) || !(payload instanceof Uint8Array) || !(signature instanceof Uint8Array))
154
+ throw new Error("COSE_Sign1 fields have the wrong types");
155
+ const header = decodeCbor(protectedBytes);
156
+ if (!(header instanceof Map) || header.get(1) !== -35)
157
+ throw new Error("protected header algorithm is not ES384 (-35)");
158
+ if (signature.length !== 96)
159
+ throw new Error("signature is not a 96-byte P-384 r||s");
160
+ const m = decodeCbor(payload);
161
+ if (!(m instanceof Map))
162
+ throw new Error("payload is not a CBOR map");
163
+ const moduleId = m.get("module_id");
164
+ if (typeof moduleId !== "string" || moduleId === "")
165
+ throw new Error("module_id missing");
166
+ if (m.get("digest") !== "SHA384")
167
+ throw new Error("digest is not SHA384");
168
+ const timestamp = m.get("timestamp");
169
+ if (typeof timestamp !== "number" || timestamp <= 0)
170
+ throw new Error("timestamp missing");
171
+ const rawPcrs = m.get("pcrs");
172
+ if (!(rawPcrs instanceof Map) || rawPcrs.size === 0)
173
+ throw new Error("pcrs missing");
174
+ const pcrs = {};
175
+ for (const [k, v] of rawPcrs) {
176
+ if (typeof k !== "number" || !Number.isInteger(k) || k < 0 || k > 31)
177
+ throw new Error("PCR index out of range");
178
+ if (!isBytes(v, 48, 48))
179
+ throw new Error(`PCR${k} is not a SHA-384 value`);
180
+ pcrs[k] = hex(v);
181
+ }
182
+ const certificate = m.get("certificate");
183
+ if (!isBytes(certificate, 1, 1024))
184
+ throw new Error("certificate missing");
185
+ const cabundle = m.get("cabundle");
186
+ if (!Array.isArray(cabundle) || cabundle.length === 0 || cabundle.length > 8 || !cabundle.every((c) => isBytes(c, 1, 1024)))
187
+ throw new Error("cabundle must contain 1 to 8 certificates of at most 1024 bytes");
188
+ return {
189
+ protectedBytes,
190
+ payload,
191
+ signature,
192
+ document: {
193
+ moduleId,
194
+ digest: "SHA384",
195
+ timestamp,
196
+ pcrs,
197
+ certificate,
198
+ cabundle: cabundle,
199
+ publicKey: optionalBytes(m, "public_key"),
200
+ userData: optionalBytes(m, "user_data"),
201
+ nonce: optionalBytes(m, "nonce"),
202
+ },
203
+ };
204
+ }
205
+ /** Section 4: cabundle[0] is the pinned root, each next certificate is issued by the one before, the leaf by the last. */
206
+ function chainReasons(doc, root) {
207
+ let certs;
208
+ try {
209
+ certs = [...doc.cabundle, doc.certificate].map((bytes) => { const c = new X509Certificate(bytes); if (!c.raw.equals(Buffer.from(bytes)))
210
+ throw new Error("trailing certificate bytes"); return c; });
211
+ }
212
+ catch {
213
+ return ["a certificate in the chain does not parse"];
214
+ }
215
+ const reasons = [];
216
+ const at = doc.timestamp;
217
+ if (!certs[0].raw.equals(root.raw))
218
+ reasons.push("cabundle does not start with the trusted root");
219
+ certs.forEach((c, i) => {
220
+ if (!(c.validFromDate.getTime() <= at && at <= c.validToDate.getTime()))
221
+ reasons.push(`certificate ${i} is not valid at the document timestamp`);
222
+ if (i === 0)
223
+ return;
224
+ const issuer = certs[i - 1];
225
+ if (!issuer.ca)
226
+ reasons.push(`certificate ${i - 1} is not a CA`);
227
+ try {
228
+ if (!c.checkIssued(issuer))
229
+ reasons.push(`certificate ${i} is not issued by certificate ${i - 1}`);
230
+ else if (!c.verify(issuer.publicKey))
231
+ reasons.push(`certificate ${i} signature does not verify`);
232
+ }
233
+ catch {
234
+ reasons.push(`certificate ${i} signature does not verify`);
235
+ }
236
+ });
237
+ return [...reasons, ...nitroCertificatePathReasons(certs)];
238
+ }
239
+ /** Section 4 and 5: the document's envelope, signature, chain, freshness, nonce and PCRs. */
240
+ export function verifyNitroDocument(bytes, opts = {}) {
241
+ let parsed;
242
+ try {
243
+ parsed = parseNitroDocument(bytes);
244
+ }
245
+ catch (error) {
246
+ return { ok: false, structural: false, quoteVerified: false, reasons: [`malformed attestation document: ${error.message}`], document: null };
247
+ }
248
+ const { document: doc } = parsed;
249
+ let root;
250
+ try {
251
+ if (opts.root !== undefined && !(opts.root instanceof X509Certificate) && (typeof opts.root !== "string" || Buffer.byteLength(opts.root) > 16384))
252
+ throw new Error();
253
+ root = opts.root instanceof X509Certificate ? opts.root : new X509Certificate(opts.root ?? AWS_NITRO_ROOT_PEM);
254
+ }
255
+ catch {
256
+ return { ok: false, structural: true, quoteVerified: false, reasons: ["trusted Nitro root certificate is invalid"], document: doc };
257
+ }
258
+ const reasons = chainReasons(doc, root);
259
+ let leafKey = null;
260
+ try {
261
+ leafKey = new X509Certificate(doc.certificate).publicKey;
262
+ }
263
+ catch {
264
+ // reported by chainReasons
265
+ }
266
+ const signed = leafKey !== null && leafKey.asymmetricKeyType === "ec" && leafKey.asymmetricKeyDetails?.namedCurve === "secp384r1" &&
267
+ verify("sha384", sigStructure(parsed.protectedBytes, parsed.payload), { key: leafKey, dsaEncoding: "ieee-p1363" }, parsed.signature);
268
+ if (!signed)
269
+ reasons.push("document signature does not verify with the leaf certificate's P-384 key");
270
+ const now = opts.now !== undefined ? Date.parse(opts.now) : Date.now();
271
+ // written as !(x <= limit) so an unparsable time (NaN) is refused, never passed
272
+ if (Number.isNaN(now))
273
+ reasons.push("now is not a timestamp");
274
+ else if (!(doc.timestamp <= now + NITRO_MAX_SKEW_SECONDS * 1000))
275
+ reasons.push("document timestamp is in the future");
276
+ else if (!(now - doc.timestamp <= (opts.maxAgeSeconds ?? NITRO_MAX_AGE_SECONDS) * 1000))
277
+ reasons.push("document is stale");
278
+ if (opts.nonce !== undefined) {
279
+ const want = typeof opts.nonce === "string" ? Buffer.from(opts.nonce, "utf8") : Buffer.from(opts.nonce);
280
+ if (doc.nonce === null || !want.equals(doc.nonce))
281
+ reasons.push("nonce does not match");
282
+ }
283
+ for (const [i, want] of Object.entries(opts.pcrs ?? {})) {
284
+ const got = doc.pcrs[Number(i)];
285
+ if (got === undefined)
286
+ reasons.push(`PCR${i} is missing`);
287
+ else if (got !== want.toLowerCase())
288
+ reasons.push(`PCR${i} does not match`);
289
+ }
290
+ const ok = reasons.length === 0;
291
+ return { ok, structural: true, quoteVerified: ok, reasons, document: doc };
292
+ }
293
+ /** The `measurement` a Nitro attestation claims: PCR0 (the enclave image), `sha384:<hex>`. */
294
+ export const nitroMeasurement = (doc) => `sha384:${doc.pcrs[0] ?? ""}`;
295
+ /**
296
+ * Section 6: a @zanii/attest QuoteVerifier for platform `nitro`. The Attestation carries only the
297
+ * quote's hash, so `quote` returns the raw document by `quoteHash` (from the deployer's upload or
298
+ * a store). It holds when the document hashes to `quoteHash`, verifies (section 5) with
299
+ * `nonce` = the attestation's nonce, its PCR0 is the claimed `measurement`, and its timestamp is
300
+ * within 300 seconds of `attestedAt`.
301
+ */
302
+ export function nitroQuoteVerifier(opts) {
303
+ return async (a) => (await checkNitroAttestation(a, opts)).ok;
304
+ }
305
+ /** `nitroQuoteVerifier` with its reasons. */
306
+ export async function checkNitroAttestation(a, opts) {
307
+ const fail = (reason) => ({ ok: false, structural: false, quoteVerified: false, reasons: [reason], document: null });
308
+ if (a.platform !== "nitro")
309
+ return fail("attestation platform is not nitro");
310
+ if (!a.quoteHash || !a.nonce)
311
+ return fail("attestation has no quote hash or nonce");
312
+ const bytes = await opts.quote(a.quoteHash);
313
+ if (!bytes)
314
+ return fail("no attestation document for this quote hash");
315
+ if (!(bytes instanceof Uint8Array) || bytes.byteLength < 1 || bytes.byteLength > 16384)
316
+ return fail("attestation document must contain 1 to 16384 bytes");
317
+ if (`sha256:${createHash("sha256").update(bytes).digest("hex")}` !== a.quoteHash)
318
+ return fail("attestation document does not hash to quote_hash");
319
+ const { quote: _quote, now, ...rest } = opts;
320
+ const r = verifyNitroDocument(bytes, { ...rest, nonce: a.nonce, ...(now ? { now: now() } : {}) });
321
+ if (r.document) {
322
+ if (nitroMeasurement(r.document) !== a.measurement)
323
+ r.reasons.push("measurement is not the document's PCR0");
324
+ if (!(Math.abs(r.document.timestamp - Date.parse(a.attestedAt)) <= NITRO_MAX_SKEW_SECONDS * 1000))
325
+ r.reasons.push("attested_at is not the document timestamp");
326
+ }
327
+ const ok = r.reasons.length === 0;
328
+ return { ...r, ok, quoteVerified: ok };
329
+ }
@@ -0,0 +1,8 @@
1
+ export interface NormalizationPolicy {
2
+ v: 1;
3
+ type: "certify.text-normalization-policy";
4
+ algorithm: "nfkc-arabic-v1";
5
+ unicode: "16.0" | "17.0";
6
+ }
7
+ export declare const runtimeNormalizationVersion: () => string;
8
+ export declare function normalizationPolicyReasons(policy: unknown): string[];
@@ -0,0 +1,10 @@
1
+ // Strict grading pins the runtime tables; legacy callers retain their original unversioned normalization.
2
+ import { isObj } from "./shared.js";
3
+ import { withinVerificationBudget } from "./verification-bounds.js";
4
+ export const runtimeNormalizationVersion = () => process.versions.unicode?.split(".").slice(0, 2).join(".") ?? "unknown";
5
+ export function normalizationPolicyReasons(policy) {
6
+ if (!withinVerificationBudget(policy, { maxBytes: 1024, maxNodes: 20, maxDepth: 2 }) || !isObj(policy) || Object.keys(policy).length !== 4 || policy.v !== 1 || policy.type !== "certify.text-normalization-policy" || policy.algorithm !== "nfkc-arabic-v1" || (policy.unicode !== "16.0" && policy.unicode !== "17.0"))
7
+ return ["invalid text normalization policy (v1)"];
8
+ const actual = runtimeNormalizationVersion();
9
+ return actual === policy.unicode ? [] : [`text normalization requires Unicode ${policy.unicode}; runtime has ${actual}; align runtimes before grading`];
10
+ }
package/dist/pq.d.ts ADDED
@@ -0,0 +1,32 @@
1
+ import { type PqBinding } from "@zanii/pq";
2
+ export interface PqCosignature {
3
+ v: 1;
4
+ type: "certify.pq-signature";
5
+ /** The issuer's @zanii/pq binding of its ML-DSA-65 key to its did:key. */
6
+ binding: PqBinding;
7
+ /** `mldsa65:<hex>` over the canonical bytes of the object without `sig`. */
8
+ sig_pq: string;
9
+ }
10
+ /** The binding hash a certificate (`claim.pq_binding`) or status document (`pq_binding`) commits to. */
11
+ export declare function pqCommitment(signed: unknown): string | null;
12
+ /** A binding usable by `issuer`: both its signatures verify and it is for that DID. */
13
+ export declare function checkPqBinding(binding: PqBinding, issuer: string): void;
14
+ /**
15
+ * Co-sign a signed certificate or status document that commits to `binding`. The ML-DSA signature
16
+ * covers the bytes the Ed25519 `sig` already covers, so classical verifiers see no change.
17
+ */
18
+ export declare function pqCosign(signed: {
19
+ issuer: string;
20
+ sig: string;
21
+ }, binding: PqBinding, pqSecretKey: Uint8Array): Promise<PqCosignature>;
22
+ /**
23
+ * certificate.md section 7a: what an issuer document's `require_pq` asks of a verifier:
24
+ * `required`, or `opted_out` when the caller explicitly allows no co-signature, else `not_required`.
25
+ * Given `issuedAt`, a certificate issued before the document's `require_pq_from` is `not_required`.
26
+ */
27
+ export declare function pqPolicyOf(issuerDoc: unknown, allowNoPq?: boolean, issuedAt?: string): "required" | "opted_out" | "not_required";
28
+ /** Both signatures verify over the same bytes, with the key of the binding the object commits to. */
29
+ export declare function verifyPqCosignature(signed: unknown, cosig: unknown): {
30
+ ok: boolean;
31
+ reasons: string[];
32
+ };
package/dist/pq.js ADDED
@@ -0,0 +1,69 @@
1
+ // spec/certificate.md section 7, spec/status.md section 5: an optional ML-DSA-65 co-signature
2
+ // (@zanii/pq) over the same bytes the Ed25519 signature covers, carried beside the object.
3
+ import { bindingHash, dualSignAsync, verifyDual, verifyPqBinding } from "@zanii/pq";
4
+ import { isIso, isObj, isSha, ms } from "./shared.js";
5
+ /** The binding hash a certificate (`claim.pq_binding`) or status document (`pq_binding`) commits to. */
6
+ export function pqCommitment(signed) {
7
+ if (!isObj(signed))
8
+ return null;
9
+ const v = signed.type === "credential" ? (isObj(signed.claim) ? signed.claim.pq_binding : null) : signed.pq_binding;
10
+ return isSha(v) ? v : null;
11
+ }
12
+ const unsignedOf = (signed) => {
13
+ const { sig: _omit, ...unsigned } = signed;
14
+ return unsigned;
15
+ };
16
+ /** A binding usable by `issuer`: both its signatures verify and it is for that DID. */
17
+ export function checkPqBinding(binding, issuer) {
18
+ if (!verifyPqBinding(binding).ok)
19
+ throw new Error("pq binding does not verify");
20
+ if (binding.did !== issuer)
21
+ throw new Error("pq binding is not for the issuer");
22
+ }
23
+ /**
24
+ * Co-sign a signed certificate or status document that commits to `binding`. The ML-DSA signature
25
+ * covers the bytes the Ed25519 `sig` already covers, so classical verifiers see no change.
26
+ */
27
+ export async function pqCosign(signed, binding, pqSecretKey) {
28
+ checkPqBinding(binding, signed.issuer);
29
+ if (pqCommitment(signed) !== bindingHash(binding))
30
+ throw new Error("the object does not commit to this pq binding");
31
+ if (!/^ed25519:[0-9a-f]{128}$/.test(String(signed.sig)))
32
+ throw new Error("the object is not signed");
33
+ // ponytail: dualSignAsync with the Ed25519 signature already made; the check below verifies both.
34
+ const ed = Buffer.from(signed.sig.slice(8), "hex");
35
+ const { sig_pq } = await dualSignAsync(unsignedOf(signed), async () => new Uint8Array(ed), pqSecretKey);
36
+ const out = { v: 1, type: "certify.pq-signature", binding, sig_pq };
37
+ if (!verifyPqCosignature(signed, out).ok)
38
+ throw new Error("pq co-signature does not verify");
39
+ return out;
40
+ }
41
+ /**
42
+ * certificate.md section 7a: what an issuer document's `require_pq` asks of a verifier:
43
+ * `required`, or `opted_out` when the caller explicitly allows no co-signature, else `not_required`.
44
+ * Given `issuedAt`, a certificate issued before the document's `require_pq_from` is `not_required`.
45
+ */
46
+ export function pqPolicyOf(issuerDoc, allowNoPq = false, issuedAt) {
47
+ if (!isObj(issuerDoc) || issuerDoc.require_pq !== true)
48
+ return "not_required";
49
+ if (issuedAt !== undefined && isIso(issuerDoc.require_pq_from) && ms(issuedAt) < ms(issuerDoc.require_pq_from))
50
+ return "not_required";
51
+ return allowNoPq === true ? "opted_out" : "required";
52
+ }
53
+ /** Both signatures verify over the same bytes, with the key of the binding the object commits to. */
54
+ export function verifyPqCosignature(signed, cosig) {
55
+ const no = (r) => ({ ok: false, reasons: [r] });
56
+ const committed = pqCommitment(signed);
57
+ if (!committed || !isObj(signed))
58
+ return no("the object does not commit to a post-quantum key");
59
+ if (!isObj(cosig) || cosig.v !== 1 || cosig.type !== "certify.pq-signature" || !isObj(cosig.binding))
60
+ return no("not a post-quantum co-signature (v1)");
61
+ const binding = cosig.binding;
62
+ if (bindingHash(binding) !== committed)
63
+ return no("post-quantum binding is not the one the object commits to");
64
+ const dual = verifyDual(unsignedOf(signed), String(signed.issuer), binding, {
65
+ sig_ed: String(signed.sig),
66
+ sig_pq: String(cosig.sig_pq),
67
+ });
68
+ return dual.ok ? { ok: true, reasons: [] } : no("post-quantum co-signature does not verify");
69
+ }
@@ -0,0 +1,15 @@
1
+ import clauseMapSchema from "./schemas/clause-map.schema.json";
2
+ import profileSchema from "./schemas/profile.schema.json";
3
+ type Schema = {
4
+ [k: string]: unknown;
5
+ };
6
+ /** Section 4: the errors of `value` against `schema` (keywords in the section 4 list), sorted. */
7
+ export declare function schemaErrors(value: unknown, schema: Schema): string[];
8
+ export interface ProfileCheck {
9
+ ok: boolean;
10
+ errors: string[];
11
+ }
12
+ export declare function validateProfile(profile: unknown, clauseMaps?: readonly unknown[]): ProfileCheck;
13
+ /** Section 3: a clause map is valid when it matches the schema and its clause ids are distinct. */
14
+ export declare function validateClauseMap(map: unknown): ProfileCheck;
15
+ export { clauseMapSchema, profileSchema };
@@ -0,0 +1,165 @@
1
+ // spec/profiles.md: sector profiles and clause maps, checked against spec/schemas with a small
2
+ // validator for the JSON Schema keywords those two schemas use (no dependency).
3
+ import { canonicalBytes } from "@zanii/core";
4
+ import clauseMapSchema from "./schemas/clause-map.schema.json" with { type: "json" };
5
+ import profileSchema from "./schemas/profile.schema.json" with { type: "json" };
6
+ import { isObj } from "./shared.js";
7
+ import { schemaDialectErrors } from "./schema-dialect.js";
8
+ import { withinVerificationBudget } from "./verification-bounds.js";
9
+ const typeOk = {
10
+ object: isObj,
11
+ array: Array.isArray,
12
+ string: (v) => typeof v === "string",
13
+ integer: (v) => Number.isInteger(v),
14
+ boolean: (v) => typeof v === "boolean",
15
+ };
16
+ /** Section 4: the errors of `value` against `schema` (keywords in the section 4 list), sorted. */
17
+ export function schemaErrors(value, schema) {
18
+ const invalid = schemaDialectErrors(schema);
19
+ if (invalid.length)
20
+ return invalid;
21
+ if (!withinVerificationBudget(value))
22
+ return ["$: value exceeds the validation JSON budget"];
23
+ const root = schema;
24
+ const out = [];
25
+ let remaining = 1_000_000;
26
+ let tooManyErrors = false;
27
+ let tooDeep = false;
28
+ const error = (message) => { if (out.length < 1000)
29
+ out.push(message);
30
+ else
31
+ tooManyErrors = true; };
32
+ const walk = (v, s, path, depth = 0) => {
33
+ if (tooManyErrors || --remaining < 0)
34
+ return;
35
+ if (depth > 128) {
36
+ tooDeep = true;
37
+ return;
38
+ }
39
+ if (typeof s.$ref === "string") {
40
+ const name = s.$ref.replace("#/$defs/", "");
41
+ walk(v, root.$defs[name], path, depth + 1);
42
+ return;
43
+ }
44
+ if (Array.isArray(s.anyOf)) {
45
+ let matched = false;
46
+ for (const branch of s.anyOf) {
47
+ const start = out.length;
48
+ walk(v, branch, path, depth + 1);
49
+ if (out.length === start)
50
+ matched = true;
51
+ out.length = start;
52
+ if (matched)
53
+ break;
54
+ }
55
+ if (!matched)
56
+ error(`${path}: must match anyOf`);
57
+ }
58
+ const encoded = "const" in s || Array.isArray(s.enum) ? Buffer.from(canonicalBytes(v)) : null;
59
+ const equal = (expected) => encoded?.equals(Buffer.from(canonicalBytes(expected)));
60
+ if ("const" in s && !equal(s.const))
61
+ error(`${path}: must be ${JSON.stringify(s.const)}`);
62
+ if (Array.isArray(s.enum) && !s.enum.some(equal))
63
+ error(`${path}: must be one of ${JSON.stringify(s.enum)}`);
64
+ if (typeof s.type === "string" && !typeOk[s.type]?.(v)) {
65
+ error(`${path}: must be ${s.type}`);
66
+ return;
67
+ }
68
+ if (typeof v === "string") {
69
+ if (typeof s.minLength === "number" && v.length < s.minLength)
70
+ error(`${path}: shorter than ${s.minLength}`);
71
+ if (typeof s.maxLength === "number" && v.length > s.maxLength)
72
+ error(`${path}: longer than ${s.maxLength}`);
73
+ if (typeof s.pattern === "string" && !new RegExp(s.pattern).test(v))
74
+ error(`${path}: does not match ${s.pattern}`);
75
+ }
76
+ if (typeof v === "number") {
77
+ if (typeof s.minimum === "number" && v < s.minimum)
78
+ error(`${path}: below ${s.minimum}`);
79
+ if (typeof s.maximum === "number" && v > s.maximum)
80
+ error(`${path}: above ${s.maximum}`);
81
+ }
82
+ if (Array.isArray(v)) {
83
+ if (typeof s.minItems === "number" && v.length < s.minItems)
84
+ error(`${path}: fewer than ${s.minItems} items`);
85
+ if (typeof s.maxItems === "number" && v.length > s.maxItems)
86
+ error(`${path}: more than ${s.maxItems} items`);
87
+ if (s.uniqueItems === true && new Set(v.map((x) => Buffer.from(canonicalBytes(x)).toString())).size !== v.length)
88
+ error(`${path}: items are not unique`);
89
+ if (isObj(s.items))
90
+ v.forEach((x, i) => walk(x, s.items, `${path}[${i}]`, depth + 1));
91
+ }
92
+ if (isObj(v)) {
93
+ const props = isObj(s.properties) ? s.properties : {};
94
+ for (const k of Array.isArray(s.required) ? s.required : [])
95
+ if (!Object.hasOwn(v, k))
96
+ error(`${path}: missing ${k}`);
97
+ for (const [k, x] of Object.entries(v)) {
98
+ if (Object.hasOwn(props, k) && isObj(props[k]))
99
+ walk(x, props[k], `${path}.${k}`, depth + 1);
100
+ else if (s.additionalProperties === false)
101
+ error(`${path}: unknown property ${k}`);
102
+ }
103
+ }
104
+ };
105
+ walk(value, schema, "$");
106
+ if (tooDeep)
107
+ return ["$: validation depth budget exceeded"];
108
+ if (remaining < 0)
109
+ return ["$: validation work budget exceeded"];
110
+ if (tooManyErrors)
111
+ return ["$: validation error budget exceeded"];
112
+ return out.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
113
+ }
114
+ /**
115
+ * Section 2: a profile is valid when it matches the schema, its category ids are distinct, every
116
+ * category's required levels are levels the profile offers, and (when `clauseMaps` are given)
117
+ * every clause it cites exists in a clause map the profile lists.
118
+ */
119
+ /** scheme.md section 2a: the scheme versions whose profiles may assess categories as multi-step workflows. */
120
+ const TRAJECTORY_SCHEMES = ["zanii-certify/0.4"];
121
+ export function validateProfile(profile, clauseMaps = []) {
122
+ const errors = schemaErrors(profile, profileSchema);
123
+ if (!errors.length) {
124
+ const p = profile;
125
+ if (p.levels.includes(3) && p.scheme === "zanii-certify/0.1")
126
+ errors.push("$.levels: level 3 needs scheme zanii-certify/0.2");
127
+ const seen = new Set();
128
+ for (const c of p.categories) {
129
+ if (seen.has(c.id))
130
+ errors.push(`$.categories: duplicate id ${c.id}`);
131
+ seen.add(c.id);
132
+ if (c.execution === "trajectory" && !TRAJECTORY_SCHEMES.includes(p.scheme))
133
+ errors.push(`$.categories.${c.id}: trajectory execution needs scheme zanii-certify/0.4`);
134
+ for (const l of c.required_levels)
135
+ if (!p.levels.includes(l))
136
+ errors.push(`$.categories.${c.id}: level ${l} not in levels`);
137
+ }
138
+ if (clauseMaps.length) {
139
+ const known = new Set();
140
+ for (const m of clauseMaps) {
141
+ const map = m;
142
+ if (!p.clause_maps.includes(`${map.slug}/${map.version}`))
143
+ continue;
144
+ for (const cl of map.clauses)
145
+ known.add(`${map.slug}:${cl.id}`);
146
+ }
147
+ for (const c of p.categories)
148
+ for (const ref of c.clauses)
149
+ if (!known.has(ref))
150
+ errors.push(`$.categories.${c.id}: unknown clause ${ref}`);
151
+ }
152
+ }
153
+ return { ok: errors.length === 0, errors: errors.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)) };
154
+ }
155
+ /** Section 3: a clause map is valid when it matches the schema and its clause ids are distinct. */
156
+ export function validateClauseMap(map) {
157
+ const errors = schemaErrors(map, clauseMapSchema);
158
+ if (!errors.length) {
159
+ const ids = map.clauses.map((c) => c.id);
160
+ if (new Set(ids).size !== ids.length)
161
+ errors.push("$.clauses: duplicate id");
162
+ }
163
+ return { ok: errors.length === 0, errors };
164
+ }
165
+ export { clauseMapSchema, profileSchema };