@zanii/certify 0.0.0-stage → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +159 -0
- package/README.md +144 -2
- package/dist/appliance-execution.d.ts +18 -0
- package/dist/appliance-execution.js +12 -0
- package/dist/appliance.d.ts +135 -0
- package/dist/appliance.js +253 -0
- package/dist/assurance-status.d.ts +58 -0
- package/dist/assurance-status.js +86 -0
- package/dist/audit-renewal.d.ts +39 -0
- package/dist/audit-renewal.js +62 -0
- package/dist/audit.d.ts +69 -0
- package/dist/audit.js +112 -0
- package/dist/beacon-client.d.ts +40 -0
- package/dist/beacon-client.js +151 -0
- package/dist/beacon.d.ts +84 -0
- package/dist/beacon.js +106 -0
- package/dist/bitstring-status.d.ts +56 -0
- package/dist/bitstring-status.js +135 -0
- package/dist/board.d.ts +218 -0
- package/dist/board.js +428 -0
- package/dist/bundle.d.ts +100 -0
- package/dist/bundle.js +122 -0
- package/dist/certificate.d.ts +161 -0
- package/dist/certificate.js +346 -0
- package/dist/cli-messages.json +148 -0
- package/dist/cli.d.ts +16 -0
- package/dist/cli.js +369 -0
- package/dist/current-issuer-vc.d.ts +20 -0
- package/dist/current-issuer-vc.js +60 -0
- package/dist/evidence-share.d.ts +89 -0
- package/dist/evidence-share.js +479 -0
- package/dist/federation.d.ts +103 -0
- package/dist/federation.js +240 -0
- package/dist/feed-snapshot.d.ts +97 -0
- package/dist/feed-snapshot.js +186 -0
- package/dist/fingerprint.d.ts +23 -0
- package/dist/fingerprint.js +48 -0
- package/dist/formats/status-v2.schema.json +31 -0
- package/dist/governance.d.ts +244 -0
- package/dist/governance.js +418 -0
- package/dist/identity.d.ts +79 -0
- package/dist/identity.js +146 -0
- package/dist/impartiality.d.ts +47 -0
- package/dist/impartiality.js +85 -0
- package/dist/index.d.ts +61 -0
- package/dist/index.js +63 -0
- package/dist/issuer-authorization.d.ts +4 -0
- package/dist/issuer-authorization.js +52 -0
- package/dist/issuer-resolution.d.ts +25 -0
- package/dist/issuer-resolution.js +89 -0
- package/dist/licence-owner-entry.d.ts +20 -0
- package/dist/licence-owner-entry.js +33 -0
- package/dist/licence-owner.d.ts +67 -0
- package/dist/licence-owner.js +180 -0
- package/dist/licence.d.ts +174 -0
- package/dist/licence.js +400 -0
- package/dist/live-evidence.d.ts +135 -0
- package/dist/live-evidence.js +210 -0
- package/dist/nitro-roots.d.ts +3 -0
- package/dist/nitro-roots.js +20 -0
- package/dist/nitro-x509.d.ts +16 -0
- package/dist/nitro-x509.js +141 -0
- package/dist/nitro.d.ts +74 -0
- package/dist/nitro.js +329 -0
- package/dist/normalization.d.ts +8 -0
- package/dist/normalization.js +10 -0
- package/dist/pq.d.ts +32 -0
- package/dist/pq.js +69 -0
- package/dist/profile.d.ts +15 -0
- package/dist/profile.js +165 -0
- package/dist/register.d.ts +79 -0
- package/dist/register.js +134 -0
- package/dist/rotation.d.ts +49 -0
- package/dist/rotation.js +124 -0
- package/dist/royalty-reconciliation.d.ts +24 -0
- package/dist/royalty-reconciliation.js +88 -0
- package/dist/runtime-client.d.ts +40 -0
- package/dist/runtime-client.js +190 -0
- package/dist/runtime-gateway.d.ts +26 -0
- package/dist/runtime-gateway.js +169 -0
- package/dist/runtime-knowledge.d.ts +28 -0
- package/dist/runtime-knowledge.js +75 -0
- package/dist/runtime-nitro.d.ts +30 -0
- package/dist/runtime-nitro.js +57 -0
- package/dist/runtime.d.ts +226 -0
- package/dist/runtime.js +254 -0
- package/dist/sandbox-delegation.d.ts +38 -0
- package/dist/sandbox-delegation.js +61 -0
- package/dist/schema-dialect.d.ts +1 -0
- package/dist/schema-dialect.js +97 -0
- package/dist/schemas/clause-map.schema.json +52 -0
- package/dist/schemas/profile.schema.json +81 -0
- package/dist/schemas/scenarios.schema.json +573 -0
- package/dist/schemas/suite.schema.json +138 -0
- package/dist/schemas/trajectory.schema.json +1396 -0
- package/dist/scheme-authority.d.ts +5 -0
- package/dist/scheme-authority.js +15 -0
- package/dist/scheme-entry.d.ts +25 -0
- package/dist/scheme-entry.js +85 -0
- package/dist/scheme-history.d.ts +5 -0
- package/dist/scheme-history.js +87 -0
- package/dist/scheme-log.d.ts +98 -0
- package/dist/scheme-log.js +266 -0
- package/dist/scheme-merkle.d.ts +2 -0
- package/dist/scheme-merkle.js +20 -0
- package/dist/scheme-text.d.ts +25 -0
- package/dist/scheme-text.js +46 -0
- package/dist/scheme-timing.d.ts +16 -0
- package/dist/scheme-timing.js +22 -0
- package/dist/scheme-transition.d.ts +47 -0
- package/dist/scheme-transition.js +101 -0
- package/dist/scoring.d.ts +35 -0
- package/dist/scoring.js +50 -0
- package/dist/shared.d.ts +16 -0
- package/dist/shared.js +20 -0
- package/dist/signer.d.ts +18 -0
- package/dist/signer.js +49 -0
- package/dist/signing-identity.d.ts +1 -0
- package/dist/signing-identity.js +11 -0
- package/dist/status.d.ts +113 -0
- package/dist/status.js +204 -0
- package/dist/succession.d.ts +21 -0
- package/dist/succession.js +67 -0
- package/dist/suite.d.ts +261 -0
- package/dist/suite.js +458 -0
- package/dist/trajectory.d.ts +216 -0
- package/dist/trajectory.js +409 -0
- package/dist/transcript-provenance.d.ts +25 -0
- package/dist/transcript-provenance.js +37 -0
- package/dist/transport-key.d.ts +3 -0
- package/dist/transport-key.js +16 -0
- package/dist/vc.d.ts +29 -0
- package/dist/vc.js +96 -0
- package/dist/verification-bounds.d.ts +5 -0
- package/dist/verification-bounds.js +78 -0
- package/dist/verification-policy.d.ts +32 -0
- package/dist/verification-policy.js +85 -0
- package/dist/witness.d.ts +19 -0
- package/dist/witness.js +80 -0
- package/package.json +65 -4
- package/src/cli.ts +331 -0
package/dist/audit.js
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// spec/audit.md: the control audit attestation a Level 3 certificate references by hash, signed by
|
|
2
|
+
// the accredited certification body that ran the audit.
|
|
3
|
+
import { jcsHash, verifySignedObject } from "@zanii/core";
|
|
4
|
+
import { isDid, isIso, isObj, ms, sortedUnique } from "./shared.js";
|
|
5
|
+
import { signerDid, signWith } from "./signer.js";
|
|
6
|
+
export const AUDIT_OUTCOMES = ["conforming", "minor_nonconformities", "major_nonconformities"];
|
|
7
|
+
/** A clause or Annex A control id, by number only (`8.2`, `A.6.2.4`). */
|
|
8
|
+
export const CONTROL = /^(A\.)?[0-9]{1,3}(\.[0-9]{1,3}){0,4}$/;
|
|
9
|
+
const STANDARD = /^[A-Za-z0-9][A-Za-z0-9 ./:_-]{0,99}$/;
|
|
10
|
+
const COUNTRY = /^[A-Z]{2}$/;
|
|
11
|
+
const text = (v, max) => typeof v === "string" && v.length >= 1 && v.length <= max;
|
|
12
|
+
/** Section 1's field rules; the first broken rule, or null. `controls` must already be sorted. */
|
|
13
|
+
function fieldError(a) {
|
|
14
|
+
const ap = isObj(a.applicant) ? a.applicant : {};
|
|
15
|
+
const sc = isObj(a.scope) ? a.scope : {};
|
|
16
|
+
if (!isDid(a.holder))
|
|
17
|
+
return "holder must be a did:key";
|
|
18
|
+
if (!isObj(a.applicant) || Object.keys(ap).length !== 2 || !text(ap.name, 200) || typeof ap.country !== "string" || !COUNTRY.test(ap.country))
|
|
19
|
+
return "applicant needs a name (1-200) and an ISO 3166-1 alpha-2 country";
|
|
20
|
+
if (!isObj(a.scope) || Object.keys(sc).length !== 2 || !text(sc.en, 500) || !text(sc.ar, 500))
|
|
21
|
+
return "scope needs en and ar text (1-500)";
|
|
22
|
+
if (typeof a.standard !== "string" || !STANDARD.test(a.standard))
|
|
23
|
+
return "standard is not a standard reference";
|
|
24
|
+
const c = a.controls;
|
|
25
|
+
if (!Array.isArray(c) || c.length < 1 || c.length > 500 || !c.every((x) => typeof x === "string" && CONTROL.test(x)))
|
|
26
|
+
return "controls must be 1-500 clause ids by number";
|
|
27
|
+
if (sortedUnique(c).join("\n") !== c.join("\n"))
|
|
28
|
+
return "controls must be sorted and distinct";
|
|
29
|
+
if (!isIso(a.audited_at) || !isIso(a.valid_until) || !isIso(a.issued_at))
|
|
30
|
+
return "dates must be ISO UTC timestamps";
|
|
31
|
+
if (ms(a.valid_until) <= ms(a.audited_at) || ms(a.issued_at) < ms(a.audited_at))
|
|
32
|
+
return "valid_until must be after audited_at, issued_at not before it";
|
|
33
|
+
if (!AUDIT_OUTCOMES.includes(a.outcome))
|
|
34
|
+
return "unknown outcome";
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
/** Sign an audit attestation as the certification body (`signer` holds the body's key). */
|
|
38
|
+
export async function buildAuditAttestation(fields, signer) {
|
|
39
|
+
const unsigned = {
|
|
40
|
+
v: 1,
|
|
41
|
+
type: "certify.audit-attestation",
|
|
42
|
+
body: signerDid(signer),
|
|
43
|
+
holder: fields.holder,
|
|
44
|
+
applicant: fields.applicant,
|
|
45
|
+
scope: fields.scope,
|
|
46
|
+
standard: fields.standard,
|
|
47
|
+
controls: Array.isArray(fields.controls) ? sortedUnique(fields.controls) : fields.controls,
|
|
48
|
+
audited_at: fields.auditedAt,
|
|
49
|
+
valid_until: fields.validUntil,
|
|
50
|
+
outcome: fields.outcome,
|
|
51
|
+
issued_at: fields.issuedAt,
|
|
52
|
+
};
|
|
53
|
+
const err = fieldError(unsigned);
|
|
54
|
+
if (err)
|
|
55
|
+
throw new Error(err);
|
|
56
|
+
return signWith(unsigned, signer);
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Verify an audit attestation: signed by its body, well formed, from a body in `trustedBodies`,
|
|
60
|
+
* still valid at `now`, not a major nonconformity, and (with `certificate`) for that certificate's
|
|
61
|
+
* holder and applicant, referenced by its `claim.audit` and dated no later than its issue.
|
|
62
|
+
*/
|
|
63
|
+
export function verifyAuditAttestation(att, opts = {}) {
|
|
64
|
+
const a = isObj(att) ? att : {};
|
|
65
|
+
if (a.v !== 1 || a.type !== "certify.audit-attestation" || !isDid(a.body) || Object.keys(a).length !== 14)
|
|
66
|
+
return { ok: false, reasons: ["not an audit attestation (v1)"] };
|
|
67
|
+
const reasons = [];
|
|
68
|
+
if (!verifySignedObject(a) || a.signer !== a.body)
|
|
69
|
+
reasons.push("bad audit attestation signature");
|
|
70
|
+
const err = fieldError(a);
|
|
71
|
+
if (err)
|
|
72
|
+
reasons.push(`audit attestation is malformed: ${err}`);
|
|
73
|
+
if (!opts.trustedBodies)
|
|
74
|
+
reasons.push("no trusted certification bodies supplied: the audit body was NOT checked");
|
|
75
|
+
else if (!opts.trustedBodies.includes(a.body))
|
|
76
|
+
reasons.push("audit body is not a trusted certification body");
|
|
77
|
+
const now = opts.now ? ms(opts.now) : Date.now();
|
|
78
|
+
if (ms(a.valid_until) < now)
|
|
79
|
+
reasons.push(`audit attestation expired on ${String(a.valid_until)}`);
|
|
80
|
+
if (a.outcome === "major_nonconformities")
|
|
81
|
+
reasons.push("audit outcome is major_nonconformities");
|
|
82
|
+
const cert = opts.certificate;
|
|
83
|
+
if (cert) {
|
|
84
|
+
const claim = isObj(cert.claim) ? cert.claim : {};
|
|
85
|
+
const ca = isObj(claim.applicant) ? claim.applicant : {};
|
|
86
|
+
const ap = isObj(a.applicant) ? a.applicant : {};
|
|
87
|
+
if (a.holder !== cert.holder)
|
|
88
|
+
reasons.push("audit attestation is for a different agent");
|
|
89
|
+
if (ap.name !== ca.name || ap.country !== ca.country)
|
|
90
|
+
reasons.push("audit attestation is for a different applicant");
|
|
91
|
+
if (claim.audit !== jcsHash(a))
|
|
92
|
+
reasons.push("certificate does not reference this audit attestation");
|
|
93
|
+
if (ms(a.audited_at) > ms(cert.issued_at))
|
|
94
|
+
reasons.push("audit is dated after the certificate");
|
|
95
|
+
}
|
|
96
|
+
return { ok: reasons.length === 0, reasons };
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Section 3: has the audit attestation of a Level 3 certificate lapsed at `now`? Surveillance then
|
|
100
|
+
* suspends the certificate with reason AUDIT_EXPIRED. False below Level 3; throws when `audit` is
|
|
101
|
+
* not the attestation the certificate references.
|
|
102
|
+
*/
|
|
103
|
+
export function auditLapsed(certificate, audit, now) {
|
|
104
|
+
if (!isIso(now))
|
|
105
|
+
throw new Error("now must be an ISO timestamp");
|
|
106
|
+
const claim = isObj(certificate?.claim) ? certificate.claim : {};
|
|
107
|
+
if (claim.level !== 3)
|
|
108
|
+
return false;
|
|
109
|
+
if (claim.audit !== jcsHash(audit))
|
|
110
|
+
throw new Error("the certificate does not reference this audit attestation");
|
|
111
|
+
return !(ms(audit.valid_until) >= ms(now));
|
|
112
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { type Credential } from "@zanii/credentials";
|
|
2
|
+
import type { Fingerprint } from "./fingerprint.ts";
|
|
3
|
+
import type { Signer } from "./signer.ts";
|
|
4
|
+
export declare const BEACON_INTERVAL_MS = 300000;
|
|
5
|
+
type FingerprintValue = string | Fingerprint;
|
|
6
|
+
export interface BeaconOptions {
|
|
7
|
+
/** The lab origin, for example `https://lab.zanii.agency`; reports go to `/v1/beacon`. */
|
|
8
|
+
url: string;
|
|
9
|
+
/** The certificate, or its certificate hash. */
|
|
10
|
+
certificate: Credential | string;
|
|
11
|
+
/** Holds the agent's key (the certificate holder). */
|
|
12
|
+
signer: Signer;
|
|
13
|
+
/** The fingerprint the agent runs now, or a function returning it (read at every report). */
|
|
14
|
+
fingerprint: FingerprintValue | (() => FingerprintValue | Promise<FingerprintValue>);
|
|
15
|
+
/** Default 300 000 (five minutes); 1 000 to 2 147 483 647. */
|
|
16
|
+
intervalMs?: number;
|
|
17
|
+
fetch?: typeof fetch;
|
|
18
|
+
/** Optional agent-bound admission credential; never use a management API key. */
|
|
19
|
+
transportKey?: string;
|
|
20
|
+
/** Called when a started report finishes, including failures; busy rejections are returned only. */
|
|
21
|
+
onResult?: (result: BeaconResult) => void;
|
|
22
|
+
}
|
|
23
|
+
export interface BeaconResult {
|
|
24
|
+
/** True when the lab accepted the report (202). */
|
|
25
|
+
ok: boolean;
|
|
26
|
+
/** The HTTP status; 0 when the request did not complete. */
|
|
27
|
+
status: number;
|
|
28
|
+
/** The lab's JSON reply (`{accepted, match, action}` or `{error, code}`), or null. */
|
|
29
|
+
body: unknown;
|
|
30
|
+
/** Set when the request did not complete or the report could not be built. */
|
|
31
|
+
error?: string;
|
|
32
|
+
}
|
|
33
|
+
export interface BeaconHandle {
|
|
34
|
+
/** Send one report now; a concurrent underlying report returns a busy failure. */
|
|
35
|
+
send: () => Promise<BeaconResult>;
|
|
36
|
+
stop: () => void;
|
|
37
|
+
}
|
|
38
|
+
/** Start reporting: one report now, then one every `intervalMs`. */
|
|
39
|
+
export declare function startBeacon(opts: BeaconOptions): BeaconHandle;
|
|
40
|
+
export {};
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
// spec/beacon.md section 4: the client a certified agent runs in production. It signs a beacon
|
|
2
|
+
// report with the agent's key and posts it to the lab (api.md 4.3), now and then every interval.
|
|
3
|
+
import { randomBytes } from "node:crypto";
|
|
4
|
+
import { credentialHash } from "@zanii/credentials";
|
|
5
|
+
import { buildBeacon } from "./beacon.js";
|
|
6
|
+
import { isSha } from "./shared.js";
|
|
7
|
+
import { machineTransportHeaders, redactTransportText } from "./transport-key.js";
|
|
8
|
+
export const BEACON_INTERVAL_MS = 300_000;
|
|
9
|
+
const MIN_INTERVAL_MS = 1_000;
|
|
10
|
+
const MAX_INTERVAL_MS = 2_147_483_647;
|
|
11
|
+
const TIMEOUT_MS = 10_000;
|
|
12
|
+
const MAX_RESPONSE_BYTES = 32 * 1024;
|
|
13
|
+
/** Start reporting: one report now, then one every `intervalMs`. */
|
|
14
|
+
export function startBeacon(opts) {
|
|
15
|
+
const endpoint = new URL("/v1/beacon", opts.url);
|
|
16
|
+
if ((endpoint.protocol !== "https:" && endpoint.protocol !== "http:") || endpoint.username || endpoint.password)
|
|
17
|
+
throw new Error("url must be http(s) without credentials");
|
|
18
|
+
const transportKey = opts.transportKey, transportHeaders = machineTransportHeaders(endpoint, transportKey);
|
|
19
|
+
const certificate = typeof opts.certificate === "string" ? opts.certificate : credentialHash(opts.certificate);
|
|
20
|
+
if (!isSha(certificate))
|
|
21
|
+
throw new Error("certificate must be a certificate or its hash");
|
|
22
|
+
const intervalMs = opts.intervalMs ?? BEACON_INTERVAL_MS;
|
|
23
|
+
if (!Number.isSafeInteger(intervalMs) || intervalMs < MIN_INTERVAL_MS || intervalMs > MAX_INTERVAL_MS)
|
|
24
|
+
throw new Error(`intervalMs must be an integer from ${MIN_INTERVAL_MS} to ${MAX_INTERVAL_MS}`);
|
|
25
|
+
const doFetch = opts.fetch ?? fetch;
|
|
26
|
+
let running = false, automatic = true;
|
|
27
|
+
let timer = null, controller = null;
|
|
28
|
+
const failure = (error) => ({ ok: false, status: 0, body: null, error: redactTransportText(error instanceof Error ? error.message : String(error), transportKey) });
|
|
29
|
+
const notify = (result) => {
|
|
30
|
+
try {
|
|
31
|
+
void Promise.resolve(opts.onResult?.(result)).catch(() => { });
|
|
32
|
+
}
|
|
33
|
+
catch { /* Observers cannot interrupt reporting. */ }
|
|
34
|
+
};
|
|
35
|
+
const check = (signal) => { if (signal.aborted)
|
|
36
|
+
throw signal.reason; };
|
|
37
|
+
async function report(signal) {
|
|
38
|
+
check(signal);
|
|
39
|
+
const fp = typeof opts.fingerprint === "function" ? await opts.fingerprint() : opts.fingerprint;
|
|
40
|
+
check(signal);
|
|
41
|
+
const report = await buildBeacon({
|
|
42
|
+
certificate,
|
|
43
|
+
fingerprint: typeof fp === "string" ? fp : fp.id,
|
|
44
|
+
...(typeof fp === "string" ? {} : { fingerprintManifest: fp }),
|
|
45
|
+
observedAt: new Date().toISOString(),
|
|
46
|
+
nonce: randomBytes(18).toString("base64url"),
|
|
47
|
+
}, opts.signer);
|
|
48
|
+
check(signal);
|
|
49
|
+
const res = await doFetch(endpoint.href, {
|
|
50
|
+
method: "POST",
|
|
51
|
+
redirect: "error", credentials: "omit",
|
|
52
|
+
headers: { "content-type": "application/json; charset=utf-8", ...transportHeaders },
|
|
53
|
+
body: JSON.stringify(report),
|
|
54
|
+
signal,
|
|
55
|
+
});
|
|
56
|
+
if (signal.aborted) {
|
|
57
|
+
void res.body?.cancel().catch(() => { });
|
|
58
|
+
check(signal);
|
|
59
|
+
}
|
|
60
|
+
if (res.redirected || (res.url && new URL(res.url).origin !== endpoint.origin)) {
|
|
61
|
+
void res.body?.cancel().catch(() => { });
|
|
62
|
+
throw new Error("beacon endpoint redirected the request");
|
|
63
|
+
}
|
|
64
|
+
const length = res.headers.get("content-length");
|
|
65
|
+
if (length && (!/^\d+$/.test(length) || Number(length) > MAX_RESPONSE_BYTES)) {
|
|
66
|
+
void res.body?.cancel().catch(() => { });
|
|
67
|
+
throw new Error("beacon response exceeds 32KiB");
|
|
68
|
+
}
|
|
69
|
+
const reader = res.body?.getReader(), bytes = new Uint8Array(MAX_RESPONSE_BYTES);
|
|
70
|
+
const cancelled = () => { void reader?.cancel().catch(() => { }); };
|
|
71
|
+
signal.addEventListener("abort", cancelled, { once: true });
|
|
72
|
+
let size = 0, reads = 0;
|
|
73
|
+
try {
|
|
74
|
+
if (reader)
|
|
75
|
+
for (;;) {
|
|
76
|
+
const part = await reader.read();
|
|
77
|
+
check(signal);
|
|
78
|
+
if (part.done)
|
|
79
|
+
break;
|
|
80
|
+
if (size + part.value.byteLength > MAX_RESPONSE_BYTES)
|
|
81
|
+
throw new Error("beacon response exceeds 32KiB");
|
|
82
|
+
bytes.set(part.value, size);
|
|
83
|
+
size += part.value.byteLength;
|
|
84
|
+
if (++reads % 64 === 0)
|
|
85
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
86
|
+
check(signal);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
catch (error) {
|
|
90
|
+
cancelled();
|
|
91
|
+
throw error;
|
|
92
|
+
}
|
|
93
|
+
finally {
|
|
94
|
+
signal.removeEventListener("abort", cancelled);
|
|
95
|
+
reader?.releaseLock();
|
|
96
|
+
}
|
|
97
|
+
let body = null;
|
|
98
|
+
try {
|
|
99
|
+
body = JSON.parse(redactTransportText(new TextDecoder("utf-8", { fatal: true }).decode(bytes.subarray(0, size)), transportKey));
|
|
100
|
+
}
|
|
101
|
+
catch { /* Non-JSON replies retain their HTTP status. */ }
|
|
102
|
+
return { ok: res.status === 202, status: res.status, body };
|
|
103
|
+
}
|
|
104
|
+
function schedule() {
|
|
105
|
+
if (automatic && !running) {
|
|
106
|
+
timer = setTimeout(() => { timer = null; void send(); }, intervalMs);
|
|
107
|
+
timer.unref();
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
function send() {
|
|
111
|
+
if (running)
|
|
112
|
+
return Promise.resolve(failure("beacon report is busy"));
|
|
113
|
+
if (timer) {
|
|
114
|
+
clearTimeout(timer);
|
|
115
|
+
timer = null;
|
|
116
|
+
}
|
|
117
|
+
running = true;
|
|
118
|
+
const control = new AbortController();
|
|
119
|
+
controller = control;
|
|
120
|
+
let finish, settled = false;
|
|
121
|
+
const result = new Promise((resolve) => { finish = resolve; });
|
|
122
|
+
const settle = (value) => { if (!settled) {
|
|
123
|
+
settled = true;
|
|
124
|
+
finish(value);
|
|
125
|
+
notify(value);
|
|
126
|
+
} };
|
|
127
|
+
const abort = () => settle(failure(control.signal.reason));
|
|
128
|
+
control.signal.addEventListener("abort", abort, { once: true });
|
|
129
|
+
const deadline = setTimeout(() => control.abort(new Error("beacon report exceeded its 10000ms deadline")), TIMEOUT_MS);
|
|
130
|
+
control.signal.addEventListener("abort", () => clearTimeout(deadline), { once: true });
|
|
131
|
+
const cleanup = () => {
|
|
132
|
+
clearTimeout(deadline);
|
|
133
|
+
control.signal.removeEventListener("abort", abort);
|
|
134
|
+
controller = null;
|
|
135
|
+
running = false;
|
|
136
|
+
schedule();
|
|
137
|
+
};
|
|
138
|
+
// Keep the slot until callbacks actually settle, even when timeout already returned a failure.
|
|
139
|
+
void report(control.signal).then((value) => { cleanup(); settle(value); }, (error) => { cleanup(); settle(failure(error)); });
|
|
140
|
+
return result;
|
|
141
|
+
}
|
|
142
|
+
void send();
|
|
143
|
+
return { send, stop: () => {
|
|
144
|
+
automatic = false;
|
|
145
|
+
if (timer) {
|
|
146
|
+
clearTimeout(timer);
|
|
147
|
+
timer = null;
|
|
148
|
+
}
|
|
149
|
+
controller?.abort(new Error("beacon reporting stopped"));
|
|
150
|
+
} };
|
|
151
|
+
}
|
package/dist/beacon.d.ts
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { type QuoteVerifier } from "@zanii/attest";
|
|
2
|
+
import { type Credential } from "@zanii/credentials";
|
|
3
|
+
import { type Signer } from "./signer.ts";
|
|
4
|
+
import { type Fingerprint } from "./fingerprint.ts";
|
|
5
|
+
export declare const NONCE: RegExp;
|
|
6
|
+
/** Section 3: how far a report's observed_at may run ahead of the verifier's clock. */
|
|
7
|
+
export declare const MAX_SKEW_SECONDS = 300;
|
|
8
|
+
export interface BeaconReport {
|
|
9
|
+
v: 1;
|
|
10
|
+
type: "certify.beacon";
|
|
11
|
+
agent: string;
|
|
12
|
+
certificate: string;
|
|
13
|
+
fingerprint: string;
|
|
14
|
+
/** Optional original manifest; its recomputed id must equal fingerprint. */
|
|
15
|
+
fingerprint_manifest?: Fingerprint;
|
|
16
|
+
observed_at: string;
|
|
17
|
+
nonce: string;
|
|
18
|
+
/** Section 5: the platform's attestation of the code that produced this report. */
|
|
19
|
+
attestation?: BeaconAttestation;
|
|
20
|
+
signer: string;
|
|
21
|
+
sig: string;
|
|
22
|
+
}
|
|
23
|
+
/** Section 5: a @zanii/attest Attestation in its wire form (attestationHash's field names). */
|
|
24
|
+
export interface BeaconAttestation {
|
|
25
|
+
v: 1;
|
|
26
|
+
/** `nitro`, `sgx`, `tdx`, `sev-snp`, `tpm`, ... the root of trust. */
|
|
27
|
+
platform: string;
|
|
28
|
+
/** The code measurement (PCR digest, MRENCLAVE, ...). */
|
|
29
|
+
measurement: string;
|
|
30
|
+
/** Hash of the raw quote, which the platform verifier checks. */
|
|
31
|
+
quote_hash: string;
|
|
32
|
+
/** `beaconChallenge` of this report: the report data the quote binds. */
|
|
33
|
+
nonce: string;
|
|
34
|
+
attested_at: string;
|
|
35
|
+
}
|
|
36
|
+
/** Section 5: the challenge an attested report's quote binds: the report's own fields, hashed. */
|
|
37
|
+
export declare const beaconChallenge: (f: {
|
|
38
|
+
certificate: string;
|
|
39
|
+
fingerprint: string;
|
|
40
|
+
observedAt: string;
|
|
41
|
+
nonce: string;
|
|
42
|
+
}) => string;
|
|
43
|
+
/** Sign a beacon report as the agent (`signer` holds the agent's key). */
|
|
44
|
+
export declare function buildBeacon(fields: {
|
|
45
|
+
certificate: string;
|
|
46
|
+
fingerprint: string;
|
|
47
|
+
observedAt: string;
|
|
48
|
+
nonce: string;
|
|
49
|
+
attestation?: BeaconAttestation;
|
|
50
|
+
fingerprintManifest?: Fingerprint;
|
|
51
|
+
}, signer: Signer): Promise<BeaconReport>;
|
|
52
|
+
export interface BeaconCheck {
|
|
53
|
+
/** The report is authentic, well formed, not from the future and (when given) for this certificate. */
|
|
54
|
+
ok: boolean;
|
|
55
|
+
reasons: string[];
|
|
56
|
+
/** Whether the reported fingerprint is the certified one; null without a certificate. */
|
|
57
|
+
match: boolean | null;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Verify a beacon report. With `certificate`, also check it names that certificate and its holder
|
|
61
|
+
* and say whether the fingerprint matches. `maxAgeSeconds` refuses old reports (replay window).
|
|
62
|
+
*/
|
|
63
|
+
export declare function verifyBeacon(report: unknown, opts?: {
|
|
64
|
+
certificate?: Credential;
|
|
65
|
+
now?: string;
|
|
66
|
+
maxAgeSeconds?: number;
|
|
67
|
+
}): BeaconCheck;
|
|
68
|
+
export interface AttestedBeaconCheck extends BeaconCheck {
|
|
69
|
+
/** true: the platform verifier verified the quote (and the measurement is listed); false: it
|
|
70
|
+
* refused it, or the measurement is not listed; null: no attestation or no verifier, NOT verified. */
|
|
71
|
+
attested: boolean | null;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Section 5: `verifyBeacon`, then the report's attestation through the injected platform
|
|
75
|
+
* verifier (@zanii/attest `verifyAttestation`). Honest by default: without `verifyQuote` the
|
|
76
|
+
* attestation is not verified (`attested: null`) and the report counts as self-reported.
|
|
77
|
+
*/
|
|
78
|
+
export declare function verifyAttestedBeacon(report: unknown, opts?: {
|
|
79
|
+
certificate?: Credential;
|
|
80
|
+
now?: string;
|
|
81
|
+
maxAgeSeconds?: number;
|
|
82
|
+
verifyQuote?: QuoteVerifier;
|
|
83
|
+
measurements?: readonly string[];
|
|
84
|
+
}): Promise<AttestedBeaconCheck>;
|
package/dist/beacon.js
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// spec/beacon.md: the production agent reports its current fingerprint, signed by its own DID.
|
|
2
|
+
import { verifyAttestation } from "@zanii/attest";
|
|
3
|
+
import { credentialHash } from "@zanii/credentials";
|
|
4
|
+
import { jcsHash, verifySignedObject } from "@zanii/core";
|
|
5
|
+
import { isDid, isIso, isObj, isSha, ms } from "./shared.js";
|
|
6
|
+
import { signerDid, signWith } from "./signer.js";
|
|
7
|
+
import { checkFingerprint } from "./fingerprint.js";
|
|
8
|
+
export const NONCE = /^[A-Za-z0-9_-]{16,128}$/;
|
|
9
|
+
/** Section 3: how far a report's observed_at may run ahead of the verifier's clock. */
|
|
10
|
+
export const MAX_SKEW_SECONDS = 300;
|
|
11
|
+
const PLATFORM = /^[a-z0-9][a-z0-9-]{0,31}$/;
|
|
12
|
+
const MEASUREMENT = /^[A-Za-z0-9:._+/=-]{1,256}$/;
|
|
13
|
+
/** Section 5: the challenge an attested report's quote binds: the report's own fields, hashed. */
|
|
14
|
+
export const beaconChallenge = (f) => jcsHash({ v: 1, type: "certify.beacon-challenge", certificate: f.certificate, fingerprint: f.fingerprint, observed_at: f.observedAt, nonce: f.nonce });
|
|
15
|
+
function attestationOk(a, b) {
|
|
16
|
+
if (!isObj(a) || Object.keys(a).length !== 6 || a.v !== 1)
|
|
17
|
+
return false;
|
|
18
|
+
const challenge = beaconChallenge({ certificate: String(b.certificate), fingerprint: String(b.fingerprint), observedAt: String(b.observed_at), nonce: String(b.nonce) });
|
|
19
|
+
return (typeof a.platform === "string" && PLATFORM.test(a.platform) &&
|
|
20
|
+
typeof a.measurement === "string" && MEASUREMENT.test(a.measurement) &&
|
|
21
|
+
isSha(a.quote_hash) && a.nonce === challenge && isIso(a.attested_at) &&
|
|
22
|
+
Math.abs(ms(a.attested_at) - ms(b.observed_at)) <= MAX_SKEW_SECONDS * 1000);
|
|
23
|
+
}
|
|
24
|
+
/** Sign a beacon report as the agent (`signer` holds the agent's key). */
|
|
25
|
+
export async function buildBeacon(fields, signer) {
|
|
26
|
+
if (!isSha(fields.certificate))
|
|
27
|
+
throw new Error("certificate must be a credential hash");
|
|
28
|
+
if (!isSha(fields.fingerprint))
|
|
29
|
+
throw new Error("fingerprint must be a fingerprint id");
|
|
30
|
+
if (!isIso(fields.observedAt))
|
|
31
|
+
throw new Error("observedAt must be an ISO UTC timestamp");
|
|
32
|
+
if (!NONCE.test(fields.nonce))
|
|
33
|
+
throw new Error("nonce must be 16-128 of [A-Za-z0-9_-]");
|
|
34
|
+
if (fields.fingerprintManifest !== undefined && (!checkFingerprint(fields.fingerprintManifest) || fields.fingerprintManifest.id !== fields.fingerprint))
|
|
35
|
+
throw new Error("beacon fingerprint manifest is malformed or does not match the reported fingerprint");
|
|
36
|
+
const unsigned = {
|
|
37
|
+
v: 1,
|
|
38
|
+
type: "certify.beacon",
|
|
39
|
+
agent: signerDid(signer),
|
|
40
|
+
certificate: fields.certificate,
|
|
41
|
+
fingerprint: fields.fingerprint,
|
|
42
|
+
...(fields.fingerprintManifest !== undefined ? { fingerprint_manifest: { ...fields.fingerprintManifest } } : {}),
|
|
43
|
+
observed_at: fields.observedAt,
|
|
44
|
+
nonce: fields.nonce,
|
|
45
|
+
...(fields.attestation !== undefined ? { attestation: fields.attestation } : {}),
|
|
46
|
+
};
|
|
47
|
+
if (fields.attestation !== undefined && !attestationOk(fields.attestation, unsigned))
|
|
48
|
+
throw new Error("attestation is malformed or does not bind this report's beaconChallenge");
|
|
49
|
+
return signWith(unsigned, signer);
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Verify a beacon report. With `certificate`, also check it names that certificate and its holder
|
|
53
|
+
* and say whether the fingerprint matches. `maxAgeSeconds` refuses old reports (replay window).
|
|
54
|
+
*/
|
|
55
|
+
export function verifyBeacon(report, opts = {}) {
|
|
56
|
+
const b = isObj(report) ? report : {};
|
|
57
|
+
if (b.v !== 1 || b.type !== "certify.beacon" || !isDid(b.agent) || Object.keys(b).length !== 9 + Number("attestation" in b) + Number("fingerprint_manifest" in b))
|
|
58
|
+
return { ok: false, reasons: ["not a beacon report (v1)"], match: null };
|
|
59
|
+
const reasons = [];
|
|
60
|
+
if (!verifySignedObject(b))
|
|
61
|
+
reasons.push("bad beacon signature");
|
|
62
|
+
if (b.signer !== b.agent)
|
|
63
|
+
reasons.push("beacon is not signed by its agent");
|
|
64
|
+
if (!isSha(b.certificate) || !isSha(b.fingerprint) || !isIso(b.observed_at) || typeof b.nonce !== "string" || !NONCE.test(b.nonce))
|
|
65
|
+
reasons.push("beacon has a malformed field");
|
|
66
|
+
if ("attestation" in b && !attestationOk(b.attestation, b))
|
|
67
|
+
reasons.push("beacon attestation is malformed or does not bind the report");
|
|
68
|
+
if ("fingerprint_manifest" in b && (!checkFingerprint(b.fingerprint_manifest) || b.fingerprint_manifest.id !== b.fingerprint))
|
|
69
|
+
reasons.push("beacon fingerprint manifest is malformed or does not match the reported fingerprint");
|
|
70
|
+
const now = opts.now ? ms(opts.now) : Date.now();
|
|
71
|
+
const at = ms(b.observed_at);
|
|
72
|
+
if (at > now + MAX_SKEW_SECONDS * 1000)
|
|
73
|
+
reasons.push("beacon observed_at is in the future");
|
|
74
|
+
if (opts.maxAgeSeconds !== undefined && at < now - opts.maxAgeSeconds * 1000)
|
|
75
|
+
reasons.push("beacon is older than the accepted window");
|
|
76
|
+
let match = null;
|
|
77
|
+
if (opts.certificate) {
|
|
78
|
+
if (b.certificate !== credentialHash(opts.certificate))
|
|
79
|
+
reasons.push("beacon names a different certificate");
|
|
80
|
+
if (b.agent !== opts.certificate.holder)
|
|
81
|
+
reasons.push("beacon agent is not the certificate holder");
|
|
82
|
+
match = b.fingerprint === opts.certificate.claim?.fingerprint;
|
|
83
|
+
}
|
|
84
|
+
return { ok: reasons.length === 0, reasons, match };
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Section 5: `verifyBeacon`, then the report's attestation through the injected platform
|
|
88
|
+
* verifier (@zanii/attest `verifyAttestation`). Honest by default: without `verifyQuote` the
|
|
89
|
+
* attestation is not verified (`attested: null`) and the report counts as self-reported.
|
|
90
|
+
*/
|
|
91
|
+
export async function verifyAttestedBeacon(report, opts = {}) {
|
|
92
|
+
const base = verifyBeacon(report, opts);
|
|
93
|
+
const a = isObj(report) ? report.attestation : undefined;
|
|
94
|
+
if (!base.ok || !isObj(a) || !opts.verifyQuote)
|
|
95
|
+
return { ...base, attested: null };
|
|
96
|
+
const r = await verifyAttestation({ v: 1, platform: String(a.platform), measurement: String(a.measurement), quoteHash: String(a.quote_hash), nonce: String(a.nonce), attestedAt: String(a.attested_at) }, { verifyQuote: opts.verifyQuote });
|
|
97
|
+
const reasons = [...base.reasons];
|
|
98
|
+
let attested = r.quoteVerified === true;
|
|
99
|
+
if (!attested)
|
|
100
|
+
reasons.push("beacon attestation quote verification failed");
|
|
101
|
+
else if (opts.measurements && !opts.measurements.includes(String(a.measurement))) {
|
|
102
|
+
attested = false;
|
|
103
|
+
reasons.push("beacon attestation measurement is not a certified measurement");
|
|
104
|
+
}
|
|
105
|
+
return { ok: reasons.length === 0, reasons, match: base.match, attested };
|
|
106
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { type Credential } from "@zanii/credentials";
|
|
2
|
+
import { type VerifiableCredential } from "@zanii/vc";
|
|
3
|
+
import { type Signer } from "./signer.ts";
|
|
4
|
+
import { type CertificateVcCheck } from "./vc.ts";
|
|
5
|
+
export declare const BITSTRING_STATUS_SLOTS = 131072;
|
|
6
|
+
export type BitstringPurpose = "revocation" | "suspension";
|
|
7
|
+
export interface BitstringStatusEntry {
|
|
8
|
+
id: string;
|
|
9
|
+
type: "BitstringStatusListEntry";
|
|
10
|
+
statusPurpose: BitstringPurpose;
|
|
11
|
+
statusListIndex: string;
|
|
12
|
+
statusListCredential: string;
|
|
13
|
+
}
|
|
14
|
+
export interface BitstringStatusListInput {
|
|
15
|
+
id: string;
|
|
16
|
+
purpose: BitstringPurpose;
|
|
17
|
+
entries: readonly {
|
|
18
|
+
index: number;
|
|
19
|
+
value: boolean;
|
|
20
|
+
}[];
|
|
21
|
+
validFrom: string;
|
|
22
|
+
validUntil: string;
|
|
23
|
+
created: string;
|
|
24
|
+
previous?: VerifiableCredential;
|
|
25
|
+
}
|
|
26
|
+
export interface BitstringStatusTrust {
|
|
27
|
+
issuers: readonly string[];
|
|
28
|
+
now: string;
|
|
29
|
+
}
|
|
30
|
+
declare const entryShape: (s: unknown) => s is BitstringStatusEntry;
|
|
31
|
+
export { entryShape as isBitstringStatusEntry };
|
|
32
|
+
export declare function bitstringStatusEntry(input: {
|
|
33
|
+
index: number;
|
|
34
|
+
list: string;
|
|
35
|
+
purpose: BitstringPurpose;
|
|
36
|
+
}): BitstringStatusEntry;
|
|
37
|
+
export declare function buildBitstringStatusList(input: BitstringStatusListInput, signer: Signer): Promise<VerifiableCredential>;
|
|
38
|
+
export declare function verifyBitstringStatusList(doc: unknown, opts: BitstringStatusTrust): {
|
|
39
|
+
ok: boolean;
|
|
40
|
+
reasons: string[];
|
|
41
|
+
bits: Uint8Array | null;
|
|
42
|
+
};
|
|
43
|
+
export declare function verifyBitstringStatusEntry(entry: unknown, doc: unknown, opts: BitstringStatusTrust): {
|
|
44
|
+
ok: boolean;
|
|
45
|
+
reasons: string[];
|
|
46
|
+
value: boolean | null;
|
|
47
|
+
};
|
|
48
|
+
export declare function certificateToBitstringVc(cert: Credential, signer: Signer, opts: {
|
|
49
|
+
created: string;
|
|
50
|
+
index: number;
|
|
51
|
+
revocationList: string;
|
|
52
|
+
suspensionList: string;
|
|
53
|
+
}): Promise<VerifiableCredential>;
|
|
54
|
+
export declare function verifyCertificateBitstringVc(vc: unknown, opts?: {
|
|
55
|
+
at?: string;
|
|
56
|
+
}): CertificateVcCheck;
|