@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
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
// spec/federation.md: each instance (UAE, KSA, a licensee) publishes a signed, append-only feed of
|
|
2
|
+
// its certificates and their status, checkpointed like the status log; the public registry verifies
|
|
3
|
+
// every feed and merges them, reporting conflicts between feeds and never resolving them.
|
|
4
|
+
import { canonicalBytes, createSTHAsync, jcsHash, leafHash, merkleRoot, verifySignedObject, verifySTH } from "@zanii/core";
|
|
5
|
+
import { isDid, isInt, isIso, isObj, isSha, ms, SLUG, sortedUnique } from "./shared.js";
|
|
6
|
+
import { signerDid, signWith } from "./signer.js";
|
|
7
|
+
import { logPositionOk } from "./status.js";
|
|
8
|
+
import { withinVerificationBudget } from "./verification-bounds.js";
|
|
9
|
+
const PROFILE_REF = new RegExp(`^${SLUG.source.slice(1, -1)}/[1-9][0-9]*$`);
|
|
10
|
+
const STATUSES = ["valid", "suspended", "revoked"];
|
|
11
|
+
/** Section 1: the most entries one page carries. */
|
|
12
|
+
export const MAX_FEED_ENTRIES = 10_000;
|
|
13
|
+
export const MAX_FEED_PAGE_BYTES = 4 * 1024 * 1024;
|
|
14
|
+
const entryOk = (e) => isObj(e) && Object.keys(e).length === 6 && isSha(e.certificate) && typeof e.profile === "string" && e.profile.length <= 128 && PROFILE_REF.test(e.profile) &&
|
|
15
|
+
isInt(e.level, 1, 3) && STATUSES.includes(e.status) && isIso(e.since) && (e.licence === null || isSha(e.licence));
|
|
16
|
+
export { entryOk as isFeedEntry };
|
|
17
|
+
function pageError(p) {
|
|
18
|
+
if (typeof p.instance !== "string" || !SLUG.test(p.instance))
|
|
19
|
+
return "instance must be a slug";
|
|
20
|
+
if (typeof p.region !== "string" || !SLUG.test(p.region))
|
|
21
|
+
return "region must be a slug";
|
|
22
|
+
const i = p.issuers;
|
|
23
|
+
if (!Array.isArray(i) || i.length < 1 || i.length > 16 || !i.every(isDid) || sortedUnique(i).join("\n") !== i.join("\n"))
|
|
24
|
+
return "issuers must be 1 to 16 sorted, distinct did:keys";
|
|
25
|
+
if (!logPositionOk(p.seq, p.prev))
|
|
26
|
+
return "seq and prev are not a log position";
|
|
27
|
+
const e = p.entries;
|
|
28
|
+
if (!Array.isArray(e) || e.length > MAX_FEED_ENTRIES || !e.every(entryOk))
|
|
29
|
+
return `entries must be up to ${MAX_FEED_ENTRIES} well formed entries`;
|
|
30
|
+
if (e.some((x, n) => n > 0 && !(x.certificate > e[n - 1].certificate)))
|
|
31
|
+
return "entries must be sorted by certificate, each once";
|
|
32
|
+
if (!isIso(p.issued_at))
|
|
33
|
+
return "issued_at must be an ISO timestamp";
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
/** Section 1: one feed page, signed by one of the instance's issuers. */
|
|
37
|
+
export async function buildFeedPage(f, signer) {
|
|
38
|
+
if (Array.isArray(f.entries) && f.entries.length > MAX_FEED_ENTRIES)
|
|
39
|
+
throw new Error(`entries must be up to ${MAX_FEED_ENTRIES} well formed entries`);
|
|
40
|
+
if (!withinVerificationBudget(f, { maxBytes: MAX_FEED_PAGE_BYTES - 1024, maxNodes: 150_000 }))
|
|
41
|
+
throw new Error("the feed page exceeds the verification byte/depth budget");
|
|
42
|
+
const entries = Array.isArray(f.entries)
|
|
43
|
+
? f.entries
|
|
44
|
+
.map((e) => ({ certificate: e?.certificate, profile: e?.profile, level: e?.level, status: e?.status, since: e?.since, licence: e?.licence }))
|
|
45
|
+
.sort((a, b) => (a.certificate < b.certificate ? -1 : a.certificate > b.certificate ? 1 : 0))
|
|
46
|
+
: f.entries;
|
|
47
|
+
const unsigned = {
|
|
48
|
+
v: 1,
|
|
49
|
+
type: "certify.feed",
|
|
50
|
+
instance: f.instance,
|
|
51
|
+
region: f.region,
|
|
52
|
+
issuers: Array.isArray(f.issuers) ? sortedUnique(f.issuers) : f.issuers,
|
|
53
|
+
seq: f.seq,
|
|
54
|
+
prev: f.prev,
|
|
55
|
+
entries,
|
|
56
|
+
issued_at: f.issuedAt,
|
|
57
|
+
};
|
|
58
|
+
const err = pageError(unsigned);
|
|
59
|
+
if (err)
|
|
60
|
+
throw new Error(err);
|
|
61
|
+
if (!unsigned.issuers.includes(signerDid(signer)))
|
|
62
|
+
throw new Error("a feed page is signed by one of its issuers");
|
|
63
|
+
return signWith(unsigned, signer);
|
|
64
|
+
}
|
|
65
|
+
const leaves = (pages) => pages.map((p) => leafHash(canonicalBytes(p)));
|
|
66
|
+
const rootOf = (pages) => `sha256:${Buffer.from(merkleRoot(leaves(pages))).toString("hex")}`;
|
|
67
|
+
/** Validate one extension using authenticated prior certificate state, without reloading its history. */
|
|
68
|
+
export function verifyFeedPage(page, opts) {
|
|
69
|
+
const i = opts.cursor.size;
|
|
70
|
+
const bad = (reason) => ({ ok: false, reasons: [`feed page ${i} ${reason}`], entries: [] });
|
|
71
|
+
if (!isInt(i, 0, Number.MAX_SAFE_INTEGER) || i > 0 && (!isSha(opts.cursor.last_hash) || !isIso(opts.cursor.last_issued_at) || typeof opts.cursor.region !== "string"))
|
|
72
|
+
return bad("has an invalid authenticated cursor");
|
|
73
|
+
if (!withinVerificationBudget(page, { maxBytes: MAX_FEED_PAGE_BYTES, maxNodes: 150_000 }))
|
|
74
|
+
return bad("exceeds the verification byte/depth budget");
|
|
75
|
+
if (!isObj(page) || page.v !== 1 || page.type !== "certify.feed" || Object.keys(page).length !== 11)
|
|
76
|
+
return bad("is not a signed feed page");
|
|
77
|
+
const malformed = pageError(page);
|
|
78
|
+
if (malformed)
|
|
79
|
+
return bad(`is malformed: ${malformed}`);
|
|
80
|
+
if (!verifySignedObject(page))
|
|
81
|
+
return bad("is not a signed feed page");
|
|
82
|
+
const p = page, before = opts.cursor;
|
|
83
|
+
const reasons = [];
|
|
84
|
+
if (!p.issuers.includes(p.signer))
|
|
85
|
+
reasons.push(`feed page ${i} is not signed by one of its issuers`);
|
|
86
|
+
if (!opts.issuers.includes(p.signer))
|
|
87
|
+
reasons.push(`feed page ${i} is not signed by a trusted instance issuer`);
|
|
88
|
+
if (p.seq !== i)
|
|
89
|
+
reasons.push(`feed page ${i} is not at position ${i}`);
|
|
90
|
+
if (p.prev !== before.last_hash)
|
|
91
|
+
reasons.push(`feed page ${i} does not name the previous page`);
|
|
92
|
+
if (p.instance !== before.instance)
|
|
93
|
+
reasons.push(`feed page ${i} is of instance ${p.instance}, not ${before.instance}`);
|
|
94
|
+
else if (before.region !== null && p.region !== before.region)
|
|
95
|
+
reasons.push(`feed page ${i} is for another instance or region`);
|
|
96
|
+
if (before.last_issued_at !== null && ms(p.issued_at) < ms(before.last_issued_at))
|
|
97
|
+
reasons.push(`feed page ${i} is older than the previous page`);
|
|
98
|
+
if (reasons.length)
|
|
99
|
+
return { ok: false, reasons, entries: [] };
|
|
100
|
+
for (const entry of p.entries) {
|
|
101
|
+
const was = opts.entry(entry.certificate);
|
|
102
|
+
if (!was)
|
|
103
|
+
continue;
|
|
104
|
+
if (was.profile !== entry.profile || was.level !== entry.level || was.licence !== entry.licence)
|
|
105
|
+
reasons.push(`feed page ${i} changes the profile, level or licence of ${entry.certificate}`);
|
|
106
|
+
if (ms(entry.since) < ms(was.since))
|
|
107
|
+
reasons.push(`feed page ${i} moves ${entry.certificate} back in time`);
|
|
108
|
+
if (was.status === "revoked" && entry.status !== "revoked")
|
|
109
|
+
reasons.push(`feed page ${i} changes revoked certificate ${entry.certificate}`);
|
|
110
|
+
}
|
|
111
|
+
return { ok: !reasons.length, reasons, entries: reasons.length ? [] : p.entries };
|
|
112
|
+
}
|
|
113
|
+
/** Section 2: the signed tree head over the feed (leaf i = page i), by the issuer that signed the last page. */
|
|
114
|
+
export async function feedCheckpoint(pages, ts, signer) {
|
|
115
|
+
if (!withinVerificationBudget(pages))
|
|
116
|
+
throw new Error("the feed exceeds the verification byte/depth budget");
|
|
117
|
+
const check = verifyFeed(pages, { issuers: sortedUnique(pages.flatMap((p) => (isObj(p) && Array.isArray(p.issuers) ? p.issuers : []))) });
|
|
118
|
+
if (!check.ok)
|
|
119
|
+
throw new Error(check.reasons[0]);
|
|
120
|
+
if (!pages.at(-1).issuers.includes(signerDid(signer)))
|
|
121
|
+
throw new Error("signer is not an issuer of the feed");
|
|
122
|
+
if (!isIso(ts) || ms(ts) < ms(pages.at(-1).issued_at))
|
|
123
|
+
throw new Error("ts must be an ISO UTC timestamp, not before the last page");
|
|
124
|
+
return createSTHAsync({ logId: signerDid(signer), size: pages.length, root: rootOf(pages), ts }, signer.sign);
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Section 1 and 2: the pages form one feed of one instance and region (page i at seq i naming page
|
|
128
|
+
* i - 1, issued_at never going back, each signed by one of its issuers and, with `issuers`, by a
|
|
129
|
+
* trusted one); a certificate keeps its profile, level and licence, its `since` never goes back and
|
|
130
|
+
* a revoked certificate stays revoked. With `checkpoint`, it is a tree head over exactly these pages
|
|
131
|
+
* signed by an issuer of the last page.
|
|
132
|
+
*/
|
|
133
|
+
export function verifyFeed(pages, opts = {}) {
|
|
134
|
+
if (!withinVerificationBudget(pages))
|
|
135
|
+
return { ok: false, reasons: ["the feed exceeds the verification byte/depth budget"], entries: [] };
|
|
136
|
+
if (!Array.isArray(pages) || pages.length === 0)
|
|
137
|
+
return { ok: false, reasons: ["the feed is empty"], entries: [] };
|
|
138
|
+
const reasons = [];
|
|
139
|
+
const state = new Map();
|
|
140
|
+
const first = isObj(pages[0]) ? pages[0] : {};
|
|
141
|
+
pages.forEach((p, i) => {
|
|
142
|
+
if (!withinVerificationBudget(p, { maxBytes: MAX_FEED_PAGE_BYTES, maxNodes: 150_000 })) {
|
|
143
|
+
reasons.push(`feed page ${i} exceeds the verification byte/depth budget`);
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
const prev = i > 0 ? pages[i - 1] : null;
|
|
147
|
+
if (!isObj(p) || p.v !== 1 || p.type !== "certify.feed" || Object.keys(p).length !== 11 || !verifySignedObject(p)) {
|
|
148
|
+
reasons.push(`feed page ${i} is not a signed feed page`);
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
const err = pageError(p);
|
|
152
|
+
if (err) {
|
|
153
|
+
reasons.push(`feed page ${i} is malformed: ${err}`);
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
if (!p.issuers.includes(p.signer))
|
|
157
|
+
reasons.push(`feed page ${i} is not signed by one of its issuers`);
|
|
158
|
+
if (opts.issuers && !opts.issuers.includes(p.signer))
|
|
159
|
+
reasons.push(`feed page ${i} is not signed by a trusted instance issuer`);
|
|
160
|
+
if (p.seq !== i)
|
|
161
|
+
reasons.push(`feed page ${i} is not at position ${i}`);
|
|
162
|
+
if (p.prev !== (i > 0 ? jcsHash(prev) : null))
|
|
163
|
+
reasons.push(`feed page ${i} does not name the previous page`);
|
|
164
|
+
if (p.instance !== first.instance || p.region !== first.region)
|
|
165
|
+
reasons.push(`feed page ${i} is for another instance or region`);
|
|
166
|
+
if (i > 0 && isObj(prev) && !(ms(p.issued_at) >= ms(prev.issued_at)))
|
|
167
|
+
reasons.push(`feed page ${i} is older than the previous page`);
|
|
168
|
+
for (const e of p.entries) {
|
|
169
|
+
const was = state.get(e.certificate);
|
|
170
|
+
if (was) {
|
|
171
|
+
if (was.profile !== e.profile || was.level !== e.level || was.licence !== e.licence)
|
|
172
|
+
reasons.push(`feed page ${i} changes the profile, level or licence of ${e.certificate}`);
|
|
173
|
+
if (!(ms(e.since) >= ms(was.since)))
|
|
174
|
+
reasons.push(`feed page ${i} moves ${e.certificate} back in time`);
|
|
175
|
+
if (was.status === "revoked" && e.status !== "revoked")
|
|
176
|
+
reasons.push(`feed page ${i} changes revoked certificate ${e.certificate}`);
|
|
177
|
+
}
|
|
178
|
+
state.set(e.certificate, e);
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
if (!opts.issuers)
|
|
182
|
+
reasons.push("no trusted instance issuers supplied: the signer was NOT checked");
|
|
183
|
+
if (opts.checkpoint !== undefined) {
|
|
184
|
+
const cp = opts.checkpoint;
|
|
185
|
+
const last = pages.at(-1);
|
|
186
|
+
const signers = isObj(last) && Array.isArray(last.issuers) ? last.issuers : [];
|
|
187
|
+
if (!isObj(cp) || !signers.includes(cp.log_id) || !verifySTH(cp))
|
|
188
|
+
reasons.push("feed checkpoint is not signed by an issuer of the feed");
|
|
189
|
+
else if (cp.size !== pages.length || cp.root !== rootOf(pages))
|
|
190
|
+
reasons.push("feed checkpoint is not over this feed");
|
|
191
|
+
}
|
|
192
|
+
const entries = [...state.values()].sort((a, b) => (a.certificate < b.certificate ? -1 : 1));
|
|
193
|
+
return { ok: reasons.length === 0, reasons, entries };
|
|
194
|
+
}
|
|
195
|
+
const cmp = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
|
|
196
|
+
/**
|
|
197
|
+
* Section 3: verify each feed and merge them. A feed that does not verify is rejected; an instance
|
|
198
|
+
* id published by two feeds, an issuer DID authenticated by signed pages of two instances and a certificate listed by two
|
|
199
|
+
* instances are conflicts. Conflicting instances, issuers and certificates are left out of `entries`.
|
|
200
|
+
*/
|
|
201
|
+
export function mergeFeeds(feeds) {
|
|
202
|
+
if (!withinVerificationBudget(feeds))
|
|
203
|
+
return { entries: [], conflicts: [], rejected: [{ instance: "", reasons: ["the federation merge exceeds the verification byte/depth budget"] }] };
|
|
204
|
+
const rejected = [];
|
|
205
|
+
const ok = [];
|
|
206
|
+
for (const f of feeds) {
|
|
207
|
+
const pages = Array.isArray(f?.pages) ? f.pages : [];
|
|
208
|
+
const head = isObj(pages[0]) ? pages[0] : {};
|
|
209
|
+
const instance = typeof head.instance === "string" ? head.instance : "";
|
|
210
|
+
const check = verifyFeed(pages, { issuers: f?.issuers ?? [], ...(f?.checkpoint !== undefined ? { checkpoint: f.checkpoint } : {}) });
|
|
211
|
+
if (!check.ok)
|
|
212
|
+
rejected.push({ instance, reasons: check.reasons });
|
|
213
|
+
else {
|
|
214
|
+
ok.push({ instance, region: head.region, authenticatedIssuers: [...new Set(pages.map((p) => p.signer))], entries: check.entries });
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
const conflicts = [];
|
|
218
|
+
const group = (kind, pairs) => {
|
|
219
|
+
const by = new Map();
|
|
220
|
+
for (const [id, inst] of pairs)
|
|
221
|
+
by.set(id, [...(by.get(id) ?? []), inst]);
|
|
222
|
+
for (const [id, insts] of by)
|
|
223
|
+
if (insts.length > 1)
|
|
224
|
+
conflicts.push({ kind, id, instances: insts.sort(cmp) });
|
|
225
|
+
};
|
|
226
|
+
group("instance", ok.map((f) => [f.instance, f.instance]));
|
|
227
|
+
const twice = new Set(conflicts.map((c) => c.id));
|
|
228
|
+
const kept = ok.filter((f) => !twice.has(f.instance));
|
|
229
|
+
group("issuer", kept.flatMap((f) => f.authenticatedIssuers.map((d) => [d, f.instance])));
|
|
230
|
+
group("certificate", kept.flatMap((f) => f.entries.map((e) => [e.certificate, f.instance])));
|
|
231
|
+
const contestedIssuers = new Set(conflicts.filter((c) => c.kind === "issuer").map((c) => c.id));
|
|
232
|
+
const contested = new Set(conflicts.filter((c) => c.kind === "certificate").map((c) => c.id));
|
|
233
|
+
const entries = kept.filter((f) => !f.authenticatedIssuers.some((d) => contestedIssuers.has(d)))
|
|
234
|
+
.flatMap((f) => f.entries.filter((e) => !contested.has(e.certificate)).map((e) => ({ ...e, instance: f.instance, region: f.region })))
|
|
235
|
+
.sort((a, b) => cmp(a.certificate, b.certificate));
|
|
236
|
+
const order = { instance: 0, issuer: 1, certificate: 2 };
|
|
237
|
+
conflicts.sort((a, b) => order[a.kind] - order[b.kind] || cmp(a.id, b.id));
|
|
238
|
+
rejected.sort((a, b) => cmp(a.instance, b.instance));
|
|
239
|
+
return { entries, conflicts, rejected };
|
|
240
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { type SignedTreeHead } from "@zanii/core";
|
|
2
|
+
import { type FeedEntry, type FeedPage } from "./federation.ts";
|
|
3
|
+
import { type Signer } from "./signer.ts";
|
|
4
|
+
export declare const FEED_SNAPSHOT_CHUNK_ENTRIES = 100;
|
|
5
|
+
export declare const MAX_FEED_SNAPSHOT_BYTES = 4259840;
|
|
6
|
+
export declare const MAX_FEED_SNAPSHOT_CHUNK_BYTES = 262144;
|
|
7
|
+
export interface FeedSnapshotNode {
|
|
8
|
+
level: number;
|
|
9
|
+
index: number;
|
|
10
|
+
hash: string;
|
|
11
|
+
}
|
|
12
|
+
export interface FeedSnapshotManifest {
|
|
13
|
+
v: 1;
|
|
14
|
+
type: "certify.feed-snapshot";
|
|
15
|
+
mode: "current-state-attestation";
|
|
16
|
+
instance: string;
|
|
17
|
+
region: string;
|
|
18
|
+
feed_checkpoint: SignedTreeHead;
|
|
19
|
+
head_page: FeedPage;
|
|
20
|
+
head_proof: string[];
|
|
21
|
+
frontier: FeedSnapshotNode[];
|
|
22
|
+
state_root: string;
|
|
23
|
+
chunk_count: number;
|
|
24
|
+
entry_count: number;
|
|
25
|
+
created_at: string;
|
|
26
|
+
signer: string;
|
|
27
|
+
sig: string;
|
|
28
|
+
}
|
|
29
|
+
/** Authenticated header returned only after manifest verification; callers persist it with the accepted manifest. */
|
|
30
|
+
export interface FeedSnapshotHeader {
|
|
31
|
+
id: string;
|
|
32
|
+
instance: string;
|
|
33
|
+
region: string;
|
|
34
|
+
feed_checkpoint: SignedTreeHead;
|
|
35
|
+
head_hash: string;
|
|
36
|
+
last_issued_at: string;
|
|
37
|
+
last_issuers: string[];
|
|
38
|
+
frontier: FeedSnapshotNode[];
|
|
39
|
+
state_root: string;
|
|
40
|
+
chunk_count: number;
|
|
41
|
+
entry_count: number;
|
|
42
|
+
head_entries: readonly FeedEntry[];
|
|
43
|
+
}
|
|
44
|
+
export interface FeedSnapshotCursor {
|
|
45
|
+
snapshot: string;
|
|
46
|
+
next_chunk: number;
|
|
47
|
+
entry_count: number;
|
|
48
|
+
last_certificate: string | null;
|
|
49
|
+
complete: boolean;
|
|
50
|
+
}
|
|
51
|
+
export interface FeedSnapshotChunk {
|
|
52
|
+
snapshot: string;
|
|
53
|
+
index: number;
|
|
54
|
+
entries: FeedEntry[];
|
|
55
|
+
proof: string[];
|
|
56
|
+
}
|
|
57
|
+
/** The complete RFC subtrees covering a prefix, left to right, with exact positions. */
|
|
58
|
+
export declare function feedSnapshotFrontier(size: number, get: (level: number, index: number) => string | undefined): FeedSnapshotNode[];
|
|
59
|
+
export declare function buildFeedSnapshot(f: {
|
|
60
|
+
instance: string;
|
|
61
|
+
region: string;
|
|
62
|
+
checkpoint: SignedTreeHead;
|
|
63
|
+
head: FeedPage;
|
|
64
|
+
headProof: string[];
|
|
65
|
+
frontier: FeedSnapshotNode[];
|
|
66
|
+
stateRoot: string;
|
|
67
|
+
chunkCount: number;
|
|
68
|
+
entryCount: number;
|
|
69
|
+
createdAt: string;
|
|
70
|
+
}, signer: Signer): Promise<FeedSnapshotManifest>;
|
|
71
|
+
export declare function verifyFeedSnapshot(doc: unknown, opts: {
|
|
72
|
+
issuers: readonly string[];
|
|
73
|
+
instance: string;
|
|
74
|
+
previous?: {
|
|
75
|
+
checkpoint: SignedTreeHead;
|
|
76
|
+
proof: unknown;
|
|
77
|
+
};
|
|
78
|
+
}): {
|
|
79
|
+
ok: boolean;
|
|
80
|
+
reasons: string[];
|
|
81
|
+
header?: FeedSnapshotHeader;
|
|
82
|
+
};
|
|
83
|
+
export declare const feedSnapshotCursor: (header: FeedSnapshotHeader) => FeedSnapshotCursor;
|
|
84
|
+
export declare function feedSnapshotChunkHash(index: number, entries: readonly FeedEntry[]): string;
|
|
85
|
+
/** A verified manifest header and an authenticated prior-state range lookup are mandatory.
|
|
86
|
+
* knownInRange returns ALL prior entries in (after,through], or the tail when through=null, up to101.
|
|
87
|
+
* More than100 proves a100-entry chunk omitted known state, so no full-history array is needed. */
|
|
88
|
+
export declare function verifyFeedSnapshotChunk(chunk: unknown, opts: {
|
|
89
|
+
header: FeedSnapshotHeader;
|
|
90
|
+
cursor: FeedSnapshotCursor;
|
|
91
|
+
knownInRange: (after: string | null, through: string | null) => readonly FeedEntry[];
|
|
92
|
+
}): {
|
|
93
|
+
ok: boolean;
|
|
94
|
+
reasons: string[];
|
|
95
|
+
entries: FeedEntry[];
|
|
96
|
+
cursor?: FeedSnapshotCursor;
|
|
97
|
+
};
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
// Optional current-state attestation. This authenticates the publisher's summarized state; it
|
|
2
|
+
// does NOT claim the verifier replayed historical deltas. Original feeds remain independently usable.
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
import { canonicalBytes, jcsHash, leafHash, verifyConsistency, verifyInclusion, verifySignedObject, verifySTH } from "@zanii/core";
|
|
5
|
+
import { isFeedEntry, verifyFeedPage } from "./federation.js";
|
|
6
|
+
import { isDid, isInt, isIso, isObj, isSha, ms, SLUG } from "./shared.js";
|
|
7
|
+
import { signerDid, signWith } from "./signer.js";
|
|
8
|
+
import { withinVerificationBudget } from "./verification-bounds.js";
|
|
9
|
+
export const FEED_SNAPSHOT_CHUNK_ENTRIES = 100;
|
|
10
|
+
export const MAX_FEED_SNAPSHOT_BYTES = 4_259_840;
|
|
11
|
+
export const MAX_FEED_SNAPSHOT_CHUNK_BYTES = 262_144;
|
|
12
|
+
const HEX = /^[0-9a-f]{64}$/, hex = (value) => Buffer.from(value).toString("hex");
|
|
13
|
+
const bytes = (value) => new Uint8Array(Buffer.from(value.replace(/^sha256:/, ""), "hex"));
|
|
14
|
+
const proofShape = (value) => Array.isArray(value) && value.length <= 64 && value.every((h) => typeof h === "string" && HEX.test(h));
|
|
15
|
+
const join = (left, right) => createHash("sha256").update(Buffer.from([1])).update(bytes(left)).update(bytes(right)).digest("hex");
|
|
16
|
+
const widthOf = (remaining) => { let width = 1; while (width * 2 <= remaining)
|
|
17
|
+
width *= 2; return width; };
|
|
18
|
+
/** The complete RFC subtrees covering a prefix, left to right, with exact positions. */
|
|
19
|
+
export function feedSnapshotFrontier(size, get) {
|
|
20
|
+
if (!isInt(size, 1, Number.MAX_SAFE_INTEGER))
|
|
21
|
+
throw new Error("snapshot prefix size is invalid");
|
|
22
|
+
const out = [];
|
|
23
|
+
let start = 0, remaining = size;
|
|
24
|
+
while (remaining) {
|
|
25
|
+
const width = widthOf(remaining), level = Math.log2(width), index = start / width, hash = get(level, index);
|
|
26
|
+
if (!hash || !HEX.test(hash))
|
|
27
|
+
throw new Error("snapshot frontier subtree is missing");
|
|
28
|
+
out.push({ level, index, hash });
|
|
29
|
+
start += width;
|
|
30
|
+
remaining -= width;
|
|
31
|
+
}
|
|
32
|
+
return out;
|
|
33
|
+
}
|
|
34
|
+
function frontierRoot(size, value) {
|
|
35
|
+
if (!isInt(size, 1, Number.MAX_SAFE_INTEGER) || !Array.isArray(value) || value.length < 1 || value.length > 53)
|
|
36
|
+
return null;
|
|
37
|
+
let start = 0, remaining = size;
|
|
38
|
+
const hashes = [];
|
|
39
|
+
for (const node of value) {
|
|
40
|
+
const width = widthOf(remaining), level = Math.log2(width);
|
|
41
|
+
if (!remaining || !isObj(node) || Object.keys(node).length !== 3 || node.level !== level || node.index !== start / width || typeof node.hash !== "string" || !HEX.test(node.hash))
|
|
42
|
+
return null;
|
|
43
|
+
hashes.push(node.hash);
|
|
44
|
+
start += width;
|
|
45
|
+
remaining -= width;
|
|
46
|
+
}
|
|
47
|
+
if (remaining)
|
|
48
|
+
return null;
|
|
49
|
+
let root = hashes.pop();
|
|
50
|
+
while (hashes.length)
|
|
51
|
+
root = join(hashes.pop(), root);
|
|
52
|
+
return `sha256:${root}`;
|
|
53
|
+
}
|
|
54
|
+
function manifestReasons(doc, issuers, signature = true) {
|
|
55
|
+
if (!withinVerificationBudget(doc, { maxBytes: MAX_FEED_SNAPSHOT_BYTES, maxNodes: 175_000 }))
|
|
56
|
+
return ["snapshot manifest exceeds the JSON verification budget"];
|
|
57
|
+
if (!isObj(doc) || Object.keys(doc).length !== (signature ? 15 : 13) || doc.v !== 1 || doc.type !== "certify.feed-snapshot" || doc.mode !== "current-state-attestation")
|
|
58
|
+
return ["not a current-state snapshot manifest (v1)"];
|
|
59
|
+
if (!Array.isArray(issuers) || issuers.length < 1 || issuers.length > 16 || !issuers.every(isDid))
|
|
60
|
+
return ["snapshot requires independently trusted instance issuers"];
|
|
61
|
+
if (typeof doc.instance !== "string" || !SLUG.test(doc.instance) || typeof doc.region !== "string" || !SLUG.test(doc.region)
|
|
62
|
+
|| !isInt(doc.chunk_count, 1, Number.MAX_SAFE_INTEGER) || !isInt(doc.entry_count, 0, Number.MAX_SAFE_INTEGER) || !isSha(doc.state_root) || !isIso(doc.created_at))
|
|
63
|
+
return ["snapshot manifest fields are malformed"];
|
|
64
|
+
if (signature && (!issuers.includes(doc.signer) || !verifySignedObject(doc)))
|
|
65
|
+
return ["snapshot is not signed by an independently trusted instance issuer"];
|
|
66
|
+
const cp = doc.feed_checkpoint, head = doc.head_page;
|
|
67
|
+
if (!isObj(cp) || !verifySTH(cp) || !issuers.includes(cp.log_id) || !isInt(cp.size, 1, Number.MAX_SAFE_INTEGER) || !isSha(cp.root))
|
|
68
|
+
return ["snapshot feed checkpoint is not independently trusted"];
|
|
69
|
+
if (!isObj(head) || head.instance !== doc.instance || head.region !== doc.region || head.seq !== cp.size - 1 || !Array.isArray(head.issuers) || !head.issuers.includes(cp.log_id))
|
|
70
|
+
return ["snapshot head does not match its checkpoint"];
|
|
71
|
+
const page = verifyFeedPage(head, { issuers, cursor: { instance: doc.instance, region: head.region, size: head.seq, last_hash: head.prev, last_issued_at: head.seq ? head.issued_at : null }, entry: () => undefined });
|
|
72
|
+
if (!page.ok)
|
|
73
|
+
return ["snapshot head page is invalid", ...page.reasons];
|
|
74
|
+
if (ms(cp.ts) < ms(head.issued_at))
|
|
75
|
+
return ["snapshot feed checkpoint precedes its authenticated head page"];
|
|
76
|
+
if (!proofShape(doc.head_proof) || !verifyInclusion(leafHash(canonicalBytes(head)), head.seq, cp.size, doc.head_proof.map(bytes), bytes(cp.root)))
|
|
77
|
+
return ["snapshot head is not included in its feed checkpoint"];
|
|
78
|
+
if (frontierRoot(cp.size, doc.frontier) !== cp.root)
|
|
79
|
+
return ["snapshot frontier does not authenticate its feed prefix"];
|
|
80
|
+
if (ms(doc.created_at) < ms(cp.ts) || ms(doc.created_at) < ms(head.issued_at))
|
|
81
|
+
return ["snapshot creation time precedes its feed checkpoint"];
|
|
82
|
+
if (Number(doc.chunk_count) > Math.max(1, Number(doc.entry_count)) || Number(doc.entry_count) > Number(doc.chunk_count) * FEED_SNAPSHOT_CHUNK_ENTRIES)
|
|
83
|
+
return ["snapshot chunk and entry counts disagree"];
|
|
84
|
+
return [];
|
|
85
|
+
}
|
|
86
|
+
export async function buildFeedSnapshot(f, signer) {
|
|
87
|
+
const body = { v: 1, type: "certify.feed-snapshot", mode: "current-state-attestation", instance: f.instance, region: f.region,
|
|
88
|
+
feed_checkpoint: f.checkpoint, head_page: f.head, head_proof: f.headProof, frontier: f.frontier, state_root: f.stateRoot,
|
|
89
|
+
chunk_count: f.chunkCount, entry_count: f.entryCount, created_at: f.createdAt };
|
|
90
|
+
if (!withinVerificationBudget(body, { maxBytes: MAX_FEED_SNAPSHOT_BYTES - 1024, maxNodes: 175_000 }))
|
|
91
|
+
throw new Error("snapshot manifest exceeds the JSON verification budget");
|
|
92
|
+
const trusted = [...new Set([...f.head.issuers, signerDid(signer)])], bad = manifestReasons(body, trusted, false);
|
|
93
|
+
if (bad.length)
|
|
94
|
+
throw new Error(bad[0]);
|
|
95
|
+
return signWith(body, signer);
|
|
96
|
+
}
|
|
97
|
+
export function verifyFeedSnapshot(doc, opts) {
|
|
98
|
+
const reasons = manifestReasons(doc, opts.issuers);
|
|
99
|
+
if (reasons.length)
|
|
100
|
+
return { ok: false, reasons };
|
|
101
|
+
const manifest = doc, cp = manifest.feed_checkpoint;
|
|
102
|
+
if (manifest.instance !== opts.instance)
|
|
103
|
+
reasons.push("snapshot belongs to another instance");
|
|
104
|
+
if (opts.previous) {
|
|
105
|
+
const old = opts.previous.checkpoint;
|
|
106
|
+
if (!old || !verifySTH(old) || !opts.issuers.includes(old.log_id) || old.size > cp.size || !proofShape(opts.previous.proof)
|
|
107
|
+
|| !verifyConsistency(old.size, cp.size, bytes(old.root), bytes(cp.root), opts.previous.proof.map(bytes)))
|
|
108
|
+
reasons.push("snapshot does not extend the retained feed checkpoint");
|
|
109
|
+
}
|
|
110
|
+
if (reasons.length)
|
|
111
|
+
return { ok: false, reasons };
|
|
112
|
+
return { ok: true, reasons, header: { id: jcsHash(manifest), instance: manifest.instance, region: manifest.region, feed_checkpoint: cp,
|
|
113
|
+
head_hash: jcsHash(manifest.head_page), last_issued_at: manifest.head_page.issued_at, last_issuers: [...manifest.head_page.issuers],
|
|
114
|
+
frontier: manifest.frontier.map((n) => ({ ...n })), state_root: manifest.state_root, chunk_count: manifest.chunk_count, entry_count: manifest.entry_count, head_entries: manifest.head_page.entries } };
|
|
115
|
+
}
|
|
116
|
+
export const feedSnapshotCursor = (header) => ({ snapshot: header.id, next_chunk: 0, entry_count: 0, last_certificate: null, complete: false });
|
|
117
|
+
export function feedSnapshotChunkHash(index, entries) {
|
|
118
|
+
if (!isInt(index, 0, Number.MAX_SAFE_INTEGER) || !Array.isArray(entries) || entries.length > FEED_SNAPSHOT_CHUNK_ENTRIES || !entries.every(isFeedEntry)
|
|
119
|
+
|| !withinVerificationBudget(entries, { maxBytes: MAX_FEED_SNAPSHOT_CHUNK_BYTES, maxNodes: 10_000 }))
|
|
120
|
+
throw new Error("snapshot chunk is malformed or exceeds its byte budget");
|
|
121
|
+
return hex(leafHash(canonicalBytes({ index, entries })));
|
|
122
|
+
}
|
|
123
|
+
/** A verified manifest header and an authenticated prior-state range lookup are mandatory.
|
|
124
|
+
* knownInRange returns ALL prior entries in (after,through], or the tail when through=null, up to101.
|
|
125
|
+
* More than100 proves a100-entry chunk omitted known state, so no full-history array is needed. */
|
|
126
|
+
export function verifyFeedSnapshotChunk(chunk, opts) {
|
|
127
|
+
const out = (reason) => ({ ok: false, reasons: [reason], entries: [] });
|
|
128
|
+
if (!withinVerificationBudget(chunk, { maxBytes: MAX_FEED_SNAPSHOT_CHUNK_BYTES, maxNodes: 10_000 }))
|
|
129
|
+
return out("snapshot chunk exceeds the JSON verification budget");
|
|
130
|
+
const { header: h, cursor: c } = opts;
|
|
131
|
+
if (!isObj(chunk) || Object.keys(chunk).length !== 4 || chunk.snapshot !== h.id || chunk.index !== c.next_chunk || c.snapshot !== h.id || c.complete
|
|
132
|
+
|| !isInt(c.next_chunk, 0, h.chunk_count - 1) || !isInt(c.entry_count, 0, h.entry_count) || c.last_certificate !== null && !isSha(c.last_certificate))
|
|
133
|
+
return out("snapshot chunk does not extend its cursor");
|
|
134
|
+
const entries = chunk.entries;
|
|
135
|
+
if (!Array.isArray(entries) || entries.length > FEED_SNAPSHOT_CHUNK_ENTRIES || !entries.every(isFeedEntry) || !proofShape(chunk.proof)
|
|
136
|
+
|| !entries.length && !(h.chunk_count === 1 && h.entry_count === 0) || entries.some((e, i) => e.certificate <= (i ? entries[i - 1].certificate : c.last_certificate ?? "")))
|
|
137
|
+
return out("snapshot entries are malformed or out of order");
|
|
138
|
+
if (entries.some((entry) => ms(entry.since) > ms(h.last_issued_at)))
|
|
139
|
+
return out("snapshot certificate state is newer than its authenticated feed prefix");
|
|
140
|
+
if (!verifyInclusion(bytes(feedSnapshotChunkHash(c.next_chunk, entries)), c.next_chunk, h.chunk_count, chunk.proof.map(bytes), bytes(h.state_root)))
|
|
141
|
+
return out("snapshot chunk is not included in its signed state root");
|
|
142
|
+
const last = entries.at(-1)?.certificate ?? c.last_certificate, known = opts.knownInRange(c.last_certificate, last);
|
|
143
|
+
if (!Array.isArray(known) || known.length > FEED_SNAPSHOT_CHUNK_ENTRIES || !known.every(isFeedEntry))
|
|
144
|
+
return out("snapshot omits known certificate state");
|
|
145
|
+
const current = new Map(entries.map((entry) => [entry.certificate, entry]));
|
|
146
|
+
const bound = (key) => {
|
|
147
|
+
if (key === null)
|
|
148
|
+
return 0;
|
|
149
|
+
let lo = 0, hi = h.head_entries.length;
|
|
150
|
+
while (lo < hi) {
|
|
151
|
+
const middle = Math.floor((lo + hi) / 2);
|
|
152
|
+
if (h.head_entries[middle].certificate <= key)
|
|
153
|
+
lo = middle + 1;
|
|
154
|
+
else
|
|
155
|
+
hi = middle;
|
|
156
|
+
}
|
|
157
|
+
return lo;
|
|
158
|
+
};
|
|
159
|
+
const headStart = bound(c.last_certificate), headEnd = last === null ? h.head_entries.length : bound(last);
|
|
160
|
+
if (headEnd - headStart > 100)
|
|
161
|
+
return out("snapshot omits state from its authenticated head page");
|
|
162
|
+
for (let i = headStart; i < headEnd; i++) {
|
|
163
|
+
const expected = h.head_entries[i], entry = current.get(expected.certificate);
|
|
164
|
+
if (!entry || ["profile", "level", "licence", "status", "since"].some((key) => entry[key] !== expected[key]))
|
|
165
|
+
return out("snapshot contradicts its authenticated head page");
|
|
166
|
+
}
|
|
167
|
+
for (const prior of known) {
|
|
168
|
+
const entry = current.get(prior.certificate);
|
|
169
|
+
if (!entry)
|
|
170
|
+
return out("snapshot omits known certificate state");
|
|
171
|
+
if (entry.profile !== prior.profile || entry.level !== prior.level || entry.licence !== prior.licence || ms(entry.since) < ms(prior.since)
|
|
172
|
+
|| prior.status === "revoked" && entry.status !== "revoked")
|
|
173
|
+
return out("snapshot changes immutable or revoked certificate state");
|
|
174
|
+
}
|
|
175
|
+
const complete = c.next_chunk + 1 === h.chunk_count, count = c.entry_count + entries.length;
|
|
176
|
+
if (count > h.entry_count || complete && count !== h.entry_count)
|
|
177
|
+
return out("snapshot entry count does not match its manifest");
|
|
178
|
+
if (complete) {
|
|
179
|
+
const tail = opts.knownInRange(last, null);
|
|
180
|
+
if (!Array.isArray(tail) || tail.length)
|
|
181
|
+
return out("snapshot omits known certificate state after its final chunk");
|
|
182
|
+
if (bound(last) < h.head_entries.length)
|
|
183
|
+
return out("snapshot omits state from its authenticated head page");
|
|
184
|
+
}
|
|
185
|
+
return { ok: true, reasons: [], entries, cursor: { snapshot: h.id, next_chunk: c.next_chunk + 1, entry_count: count, last_certificate: last, complete } };
|
|
186
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { promptSha256 } from "@zanii/blackbox";
|
|
2
|
+
export { promptSha256 };
|
|
3
|
+
export interface FingerprintInput {
|
|
4
|
+
model: string;
|
|
5
|
+
prompt: string | null;
|
|
6
|
+
tools: string | null;
|
|
7
|
+
policy: string | null;
|
|
8
|
+
knowledge: string | null;
|
|
9
|
+
}
|
|
10
|
+
export interface Fingerprint extends FingerprintInput {
|
|
11
|
+
v: 1;
|
|
12
|
+
type: "certify.fingerprint";
|
|
13
|
+
configuration: string;
|
|
14
|
+
id: string;
|
|
15
|
+
}
|
|
16
|
+
/** `sha256:<hex>` of raw bytes (a policy or knowledge pack file). Strings are UTF-8. */
|
|
17
|
+
export declare function sha256Bytes(data: Uint8Array | string): string;
|
|
18
|
+
/** The Blackbox gateway's `tools_sha256` for these tool definitions; null when there are none. */
|
|
19
|
+
export declare function toolsSha256(definitions: readonly unknown[]): string | null;
|
|
20
|
+
/** Build a fingerprint. Throws on a malformed input. */
|
|
21
|
+
export declare function buildFingerprint(input: FingerprintInput): Fingerprint;
|
|
22
|
+
/** Is this a well-formed fingerprint whose configuration and id recompute? Never throws. */
|
|
23
|
+
export declare function checkFingerprint(fp: unknown): fp is Fingerprint;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// spec/fingerprint.md: the agent fingerprint a certificate is bound to.
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { configurationId, promptSha256, toolsHash } from "@zanii/blackbox";
|
|
4
|
+
import { jcsHash } from "@zanii/core";
|
|
5
|
+
import { isObj, isSha } from "./shared.js";
|
|
6
|
+
export { promptSha256 };
|
|
7
|
+
const PARTS = ["prompt", "tools", "policy", "knowledge"];
|
|
8
|
+
/** `sha256:<hex>` of raw bytes (a policy or knowledge pack file). Strings are UTF-8. */
|
|
9
|
+
export function sha256Bytes(data) {
|
|
10
|
+
return `sha256:${createHash("sha256").update(data).digest("hex")}`;
|
|
11
|
+
}
|
|
12
|
+
/** The Blackbox gateway's `tools_sha256` for these tool definitions; null when there are none. */
|
|
13
|
+
export function toolsSha256(definitions) {
|
|
14
|
+
return toolsHash({ tools: definitions });
|
|
15
|
+
}
|
|
16
|
+
/** Build a fingerprint. Throws on a malformed input. */
|
|
17
|
+
export function buildFingerprint(input) {
|
|
18
|
+
if (typeof input.model !== "string" || input.model.length < 1 || input.model.length > 200)
|
|
19
|
+
throw new Error("model must be 1-200 characters");
|
|
20
|
+
for (const k of PARTS)
|
|
21
|
+
if (input[k] !== null && !isSha(input[k]))
|
|
22
|
+
throw new Error(`${k} must be sha256: and 64 hex, or null`);
|
|
23
|
+
const body = {
|
|
24
|
+
v: 1,
|
|
25
|
+
type: "certify.fingerprint",
|
|
26
|
+
model: input.model,
|
|
27
|
+
prompt: input.prompt,
|
|
28
|
+
tools: input.tools,
|
|
29
|
+
policy: input.policy,
|
|
30
|
+
knowledge: input.knowledge,
|
|
31
|
+
configuration: configurationId(input.prompt, input.tools, input.model),
|
|
32
|
+
};
|
|
33
|
+
return { ...body, id: jcsHash(body) };
|
|
34
|
+
}
|
|
35
|
+
/** Is this a well-formed fingerprint whose configuration and id recompute? Never throws. */
|
|
36
|
+
export function checkFingerprint(fp) {
|
|
37
|
+
if (!isObj(fp) || fp.v !== 1 || fp.type !== "certify.fingerprint")
|
|
38
|
+
return false;
|
|
39
|
+
if (Object.keys(fp).length !== 9)
|
|
40
|
+
return false;
|
|
41
|
+
try {
|
|
42
|
+
const again = buildFingerprint(fp);
|
|
43
|
+
return again.configuration === fp.configuration && again.id === fp.id;
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://zanii.agency/certify/formats/status-v2.schema.json",
|
|
4
|
+
"title": "Zanii Certify status version 2",
|
|
5
|
+
"$comment": "Use a complete draft 2020-12 validator for this standalone format schema. SDK verification additionally checks actual DIDs, signatures, cross-field times, trust, PQ and witness policy.",
|
|
6
|
+
"type": "object",
|
|
7
|
+
"required": ["v", "type", "issuer", "entries", "issued_at", "valid_until", "signer", "sig"],
|
|
8
|
+
"additionalProperties": false,
|
|
9
|
+
"properties": {
|
|
10
|
+
"v": { "const": 2 }, "type": { "const": "certify.status" },
|
|
11
|
+
"issuer": { "$ref": "#/$defs/did" }, "signer": { "$ref": "#/$defs/did" },
|
|
12
|
+
"sig": { "type": "string", "pattern": "^ed25519:[0-9a-f]{128}$", "maxLength": 136 },
|
|
13
|
+
"issued_at": { "$ref": "#/$defs/time" }, "valid_until": { "$ref": "#/$defs/time" },
|
|
14
|
+
"entries": { "type": "object", "maxProperties": 100000, "patternProperties": { "^sha256:[0-9a-f]{64}$": { "$ref": "#/$defs/entry" } }, "additionalProperties": false },
|
|
15
|
+
"seq": { "type": "integer", "minimum": 0, "maximum": 9007199254740991 },
|
|
16
|
+
"prev": { "anyOf": [{ "type": "null" }, { "$ref": "#/$defs/hash" }] },
|
|
17
|
+
"pq_binding": { "$ref": "#/$defs/hash" }
|
|
18
|
+
},
|
|
19
|
+
"dependentRequired": { "seq": ["prev"], "prev": ["seq"] },
|
|
20
|
+
"allOf": [{ "if": { "required": ["seq"], "properties": { "seq": { "const": 0 } } }, "then": { "properties": { "prev": { "type": "null" } } }, "else": { "properties": { "prev": { "$ref": "#/$defs/hash" } } } }],
|
|
21
|
+
"$defs": {
|
|
22
|
+
"did": { "type": "string", "pattern": "^did:key:z[1-9A-HJ-NP-Za-km-z]{40,60}$", "maxLength": 69 },
|
|
23
|
+
"hash": { "type": "string", "pattern": "^sha256:[0-9a-f]{64}$", "maxLength": 71 },
|
|
24
|
+
"time": { "type": "string", "format": "date-time", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\\.[0-9]{3})?Z$", "maxLength": 24 },
|
|
25
|
+
"reason": { "enum": ["FINGERPRINT_MISMATCH", "RETEST_OVERDUE", "RETEST_FAILED", "INCIDENT", "APPLICANT_REQUEST", "ISSUER_ERROR", "LICENCE_WITHDRAWN", "AUDIT_EXPIRED", "MARK_MISUSE"] },
|
|
26
|
+
"entry": { "type": "object", "required": ["status", "reason", "since"], "additionalProperties": false,
|
|
27
|
+
"properties": { "status": { "enum": ["valid", "suspended", "revoked"] }, "reason": { "anyOf": [{ "type": "null" }, { "$ref": "#/$defs/reason" }] }, "since": { "$ref": "#/$defs/time" } },
|
|
28
|
+
"oneOf": [{ "properties": { "status": { "const": "valid" }, "reason": { "type": "null" } } }, { "properties": { "status": { "enum": ["suspended", "revoked"] }, "reason": { "$ref": "#/$defs/reason" } } }]
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|