@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,244 @@
|
|
|
1
|
+
import { type Credential } from "@zanii/credentials";
|
|
2
|
+
import type { RiskProfile } from "@zanii/insurance";
|
|
3
|
+
import { type ResultBundle } from "./bundle.ts";
|
|
4
|
+
import { type Signer } from "./signer.ts";
|
|
5
|
+
import type { StatusEntry } from "./status.ts";
|
|
6
|
+
type Text = {
|
|
7
|
+
en: string;
|
|
8
|
+
ar: string;
|
|
9
|
+
};
|
|
10
|
+
type Lang = "en" | "ar";
|
|
11
|
+
export declare const RISK_NOTE = "a certificate attests lab test results at a point in time plus surveillance; it is not a guarantee of behaviour, and what was not tested is unknown, not good";
|
|
12
|
+
export interface CertifyRiskFacts {
|
|
13
|
+
certificate: string;
|
|
14
|
+
profile: string;
|
|
15
|
+
level: number;
|
|
16
|
+
status: StatusEntry["status"];
|
|
17
|
+
reason: string | null;
|
|
18
|
+
since: string;
|
|
19
|
+
issued_at: string;
|
|
20
|
+
exp: string;
|
|
21
|
+
expired: boolean;
|
|
22
|
+
days_certified: number;
|
|
23
|
+
days_since_status_change: number;
|
|
24
|
+
categories: number;
|
|
25
|
+
categories_passed: number;
|
|
26
|
+
min_margin_ppm: number;
|
|
27
|
+
suspensions: number;
|
|
28
|
+
incident_suspensions: number;
|
|
29
|
+
retests_passed: number;
|
|
30
|
+
retests_failed: number;
|
|
31
|
+
incident_retests: number;
|
|
32
|
+
overdue_retests: number;
|
|
33
|
+
conflicts: string[];
|
|
34
|
+
under_committee_review: boolean;
|
|
35
|
+
}
|
|
36
|
+
/** The @zanii/insurance RiskProfile with certification facts in place of ledger facts. */
|
|
37
|
+
export type CertifyRiskProfile = Omit<RiskProfile, "type" | "facts"> & {
|
|
38
|
+
type: "certify.risk_profile";
|
|
39
|
+
facts: CertifyRiskFacts;
|
|
40
|
+
};
|
|
41
|
+
export interface RiskProfileInput {
|
|
42
|
+
certificate: Credential;
|
|
43
|
+
bundle: ResultBundle;
|
|
44
|
+
status: StatusEntry;
|
|
45
|
+
history: readonly {
|
|
46
|
+
action: "suspend" | "revoke" | "reinstate";
|
|
47
|
+
reason: string | null;
|
|
48
|
+
at: string;
|
|
49
|
+
}[];
|
|
50
|
+
retests: readonly {
|
|
51
|
+
outcome: "grant" | "refuse";
|
|
52
|
+
at: string;
|
|
53
|
+
incident: boolean;
|
|
54
|
+
}[];
|
|
55
|
+
/** Complete retained-history counts, when a caller aggregates rather than loading history. */
|
|
56
|
+
historyFacts?: Pick<CertifyRiskFacts, "suspensions" | "incident_suspensions" | "retests_passed" | "retests_failed" | "incident_retests">;
|
|
57
|
+
overdueRetests: number;
|
|
58
|
+
underCommitteeReview: boolean;
|
|
59
|
+
asOf: string;
|
|
60
|
+
}
|
|
61
|
+
/** Underwriting facts about one certificate: pure and deterministic. */
|
|
62
|
+
export declare function certifyRiskProfile(input: RiskProfileInput): CertifyRiskProfile;
|
|
63
|
+
/** Section 1a: a risk profile signed by the lab's report key, so a holder can check the lab produced it. */
|
|
64
|
+
export type SignedRiskProfile = CertifyRiskProfile & {
|
|
65
|
+
signer: string;
|
|
66
|
+
sig: string;
|
|
67
|
+
};
|
|
68
|
+
export declare function signRiskProfile(profile: CertifyRiskProfile, signer: Signer): Promise<SignedRiskProfile>;
|
|
69
|
+
/** Section 1a: signed and (with `signers`, the lab's report keys) by a trusted key. */
|
|
70
|
+
export declare function verifyRiskProfile(doc: unknown, opts?: {
|
|
71
|
+
signers?: readonly string[];
|
|
72
|
+
at?: string;
|
|
73
|
+
maxAgeSeconds?: number;
|
|
74
|
+
}): GovernanceCheck;
|
|
75
|
+
interface ClauseMapLike {
|
|
76
|
+
slug: string;
|
|
77
|
+
version: number;
|
|
78
|
+
title: Text;
|
|
79
|
+
source: Record<string, unknown>;
|
|
80
|
+
clauses: {
|
|
81
|
+
id: string;
|
|
82
|
+
title: Text;
|
|
83
|
+
ref: string;
|
|
84
|
+
interpretation: boolean;
|
|
85
|
+
}[];
|
|
86
|
+
}
|
|
87
|
+
interface ProfileLike {
|
|
88
|
+
slug: string;
|
|
89
|
+
version: number;
|
|
90
|
+
clause_maps: string[];
|
|
91
|
+
categories: {
|
|
92
|
+
id: string;
|
|
93
|
+
title: Text;
|
|
94
|
+
clauses: string[];
|
|
95
|
+
}[];
|
|
96
|
+
}
|
|
97
|
+
export interface FilingPackInput {
|
|
98
|
+
lang: Lang;
|
|
99
|
+
lab: {
|
|
100
|
+
name: string;
|
|
101
|
+
domain: string;
|
|
102
|
+
};
|
|
103
|
+
clauseMap: ClauseMapLike;
|
|
104
|
+
profile: ProfileLike;
|
|
105
|
+
certificate: Credential;
|
|
106
|
+
status: StatusEntry;
|
|
107
|
+
bundle: ResultBundle;
|
|
108
|
+
/** The signed PDF report in `lang`: its file name and file hash. */
|
|
109
|
+
report: {
|
|
110
|
+
name: string;
|
|
111
|
+
sha256: string;
|
|
112
|
+
};
|
|
113
|
+
/** Optional evidence-share.json attachment, hashed as a file. */
|
|
114
|
+
evidenceShare?: {
|
|
115
|
+
sha256: string;
|
|
116
|
+
};
|
|
117
|
+
generatedAt: string;
|
|
118
|
+
}
|
|
119
|
+
export type FilingPack = Record<string, unknown> & {
|
|
120
|
+
v: 1;
|
|
121
|
+
type: "certify.filing-pack";
|
|
122
|
+
signer: string;
|
|
123
|
+
sig: string;
|
|
124
|
+
};
|
|
125
|
+
/** The per-clause filing pack, signed by the report key. */
|
|
126
|
+
export declare function buildFilingPack(input: FilingPackInput, signer: Signer): Promise<FilingPack>;
|
|
127
|
+
export interface GovernanceCheck {
|
|
128
|
+
ok: boolean;
|
|
129
|
+
reasons: string[];
|
|
130
|
+
}
|
|
131
|
+
/** Verify a filing pack's signer and the file hashes of supplied report and evidence-share bytes. */
|
|
132
|
+
export declare function verifyFilingPack(pack: unknown, opts?: {
|
|
133
|
+
signers?: readonly string[];
|
|
134
|
+
report?: Uint8Array;
|
|
135
|
+
evidenceShare?: Uint8Array;
|
|
136
|
+
}): GovernanceCheck;
|
|
137
|
+
export declare const CASE_KINDS: readonly ["objection", "appeal", "complaint"];
|
|
138
|
+
export type CaseKind = (typeof CASE_KINDS)[number];
|
|
139
|
+
export declare const OUTCOMES: Record<CaseKind, readonly string[]>;
|
|
140
|
+
export interface GovernanceOutcomeInput {
|
|
141
|
+
case: {
|
|
142
|
+
kind: CaseKind;
|
|
143
|
+
id: string;
|
|
144
|
+
};
|
|
145
|
+
decision: string | null;
|
|
146
|
+
certificate: string | null;
|
|
147
|
+
outcome: string;
|
|
148
|
+
note: string;
|
|
149
|
+
handledBy: string;
|
|
150
|
+
decidedAt: string;
|
|
151
|
+
/** An objection signed by an approved browser key: the lab's approval of that key for `handledBy`. */
|
|
152
|
+
approval?: SigningKeyApproval;
|
|
153
|
+
}
|
|
154
|
+
export type GovernanceOutcome = {
|
|
155
|
+
v: 1;
|
|
156
|
+
type: "certify.governance-outcome";
|
|
157
|
+
case: {
|
|
158
|
+
kind: CaseKind;
|
|
159
|
+
id: string;
|
|
160
|
+
};
|
|
161
|
+
decision: string | null;
|
|
162
|
+
certificate: string | null;
|
|
163
|
+
outcome: string;
|
|
164
|
+
note: string;
|
|
165
|
+
handled_by: string;
|
|
166
|
+
decided_at: string;
|
|
167
|
+
approval?: SigningKeyApproval;
|
|
168
|
+
signer: string;
|
|
169
|
+
sig: string;
|
|
170
|
+
};
|
|
171
|
+
/** The unsigned outcome (the committee member signs this with their own key). */
|
|
172
|
+
export declare function governanceOutcomeBody(f: GovernanceOutcomeInput): Omit<GovernanceOutcome, "signer" | "sig">;
|
|
173
|
+
/** Sign a governance outcome: an objection by the committee member who decided it, an appeal or complaint by the lab issuer. */
|
|
174
|
+
export declare function buildGovernanceOutcome(f: GovernanceOutcomeInput, signer: Signer): Promise<GovernanceOutcome>;
|
|
175
|
+
/**
|
|
176
|
+
* `issuers`: the issuer DIDs that sign appeal and complaint outcomes and signing key approvals.
|
|
177
|
+
* `committee`: the committee members' DIDs; given, an objection outcome must be handled by one of
|
|
178
|
+
* them (without it any DID that signs as `handled_by` passes, so an external verifier passes the
|
|
179
|
+
* lab's committee). `approvals`: the lab's later approval documents (revocations), from its
|
|
180
|
+
* public list; an objection signed by an approved key must be decided while the approval was in force.
|
|
181
|
+
* `external`: the active external committee members (section 3b); given, an objection is decided by one.
|
|
182
|
+
*/
|
|
183
|
+
export declare function verifyGovernanceOutcome(doc: unknown, opts?: {
|
|
184
|
+
issuers?: readonly string[];
|
|
185
|
+
committee?: readonly string[];
|
|
186
|
+
approvals?: readonly unknown[];
|
|
187
|
+
external?: readonly string[];
|
|
188
|
+
}): GovernanceCheck;
|
|
189
|
+
/** Section 3a: the lab issuer's public statement that `key` signs objection outcomes for the committee member `staff`. */
|
|
190
|
+
export type SigningKeyApproval = {
|
|
191
|
+
v: 1;
|
|
192
|
+
type: "certify.signing-key-approval";
|
|
193
|
+
staff: string;
|
|
194
|
+
key: string;
|
|
195
|
+
approved_at: string;
|
|
196
|
+
revoked_at: string | null;
|
|
197
|
+
signer: string;
|
|
198
|
+
sig: string;
|
|
199
|
+
};
|
|
200
|
+
export interface SigningKeyApprovalInput {
|
|
201
|
+
staff: string;
|
|
202
|
+
key: string;
|
|
203
|
+
approvedAt: string;
|
|
204
|
+
revokedAt?: string | null;
|
|
205
|
+
}
|
|
206
|
+
/** The unsigned approval; a revocation is a new approval document with `revoked_at` set. */
|
|
207
|
+
export declare function signingKeyApprovalBody(f: SigningKeyApprovalInput): Omit<SigningKeyApproval, "signer" | "sig">;
|
|
208
|
+
/** Signed by the lab issuer. */
|
|
209
|
+
export declare function buildSigningKeyApproval(f: SigningKeyApprovalInput, signer: Signer): Promise<SigningKeyApproval>;
|
|
210
|
+
/** Section 3a: well formed and signed by one of `issuers` (the issuer document's `dids`). */
|
|
211
|
+
export declare function verifySigningKeyApproval(doc: unknown, opts?: {
|
|
212
|
+
issuers?: readonly string[];
|
|
213
|
+
}): GovernanceCheck;
|
|
214
|
+
export interface AccreditationExport {
|
|
215
|
+
manifest: {
|
|
216
|
+
v: 1;
|
|
217
|
+
type: "certify.accreditation-export";
|
|
218
|
+
lab: {
|
|
219
|
+
name: string;
|
|
220
|
+
domain: string;
|
|
221
|
+
};
|
|
222
|
+
generated_at: string;
|
|
223
|
+
sections: {
|
|
224
|
+
name: string;
|
|
225
|
+
count: number;
|
|
226
|
+
sha256: string;
|
|
227
|
+
}[];
|
|
228
|
+
signer: string;
|
|
229
|
+
sig: string;
|
|
230
|
+
};
|
|
231
|
+
sections: Record<string, Record<string, unknown>[]>;
|
|
232
|
+
}
|
|
233
|
+
export declare function buildAccreditationExport(input: {
|
|
234
|
+
lab: {
|
|
235
|
+
name: string;
|
|
236
|
+
domain: string;
|
|
237
|
+
};
|
|
238
|
+
generatedAt: string;
|
|
239
|
+
sections: Record<string, Record<string, unknown>[]>;
|
|
240
|
+
}, signer: Signer): Promise<AccreditationExport>;
|
|
241
|
+
export declare function verifyAccreditationExport(archive: unknown, opts?: {
|
|
242
|
+
signers?: readonly string[];
|
|
243
|
+
}): GovernanceCheck;
|
|
244
|
+
export {};
|
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
// spec/governance.md: the risk profile for insurers and buyers (the @zanii/insurance RiskProfile
|
|
2
|
+
// shape, facts never a score), the regulator filing pack, the signed governance outcome of an
|
|
3
|
+
// objection, appeal or complaint, and the signed accreditation export.
|
|
4
|
+
import { jcsHash, verifySignedObject } from "@zanii/core";
|
|
5
|
+
import { credentialHash } from "@zanii/credentials";
|
|
6
|
+
import { bundleHash } from "./bundle.js";
|
|
7
|
+
import { sha256Bytes } from "./fingerprint.js";
|
|
8
|
+
import { isDid, isInt, isIso, isObj, isSha, ms } from "./shared.js";
|
|
9
|
+
import { signWith } from "./signer.js";
|
|
10
|
+
const DAY = 86_400_000;
|
|
11
|
+
const days = (from, to) => Math.max(0, Math.floor((ms(to) - ms(from)) / DAY));
|
|
12
|
+
const claimOf = (c) => c.claim;
|
|
13
|
+
// ---------------------------------------------------------------- 1. risk profile
|
|
14
|
+
export const RISK_NOTE = "a certificate attests lab test results at a point in time plus surveillance; it is not a guarantee of behaviour, and what was not tested is unknown, not good";
|
|
15
|
+
/** Underwriting facts about one certificate: pure and deterministic. */
|
|
16
|
+
export function certifyRiskProfile(input) {
|
|
17
|
+
const { certificate: c, bundle: b, status: s } = input;
|
|
18
|
+
if (!isIso(input.asOf))
|
|
19
|
+
throw new Error("asOf must be an ISO timestamp");
|
|
20
|
+
const claim = claimOf(c);
|
|
21
|
+
if (bundleHash(b) !== claim.bundle)
|
|
22
|
+
throw new Error("bundle is not the certificate's (claim.bundle)");
|
|
23
|
+
if (!b.results.length)
|
|
24
|
+
throw new Error("bundle has no results");
|
|
25
|
+
if (!Number.isInteger(input.overdueRetests) || input.overdueRetests < 0)
|
|
26
|
+
throw new Error("overdueRetests must be a non-negative integer");
|
|
27
|
+
const count = (xs, f) => xs.filter(f).length;
|
|
28
|
+
const totals = input.historyFacts ?? {
|
|
29
|
+
suspensions: count(input.history, h => h.action === "suspend"),
|
|
30
|
+
incident_suspensions: count(input.history, h => h.action === "suspend" && h.reason === "INCIDENT"),
|
|
31
|
+
retests_passed: count(input.retests, r => r.outcome === "grant"),
|
|
32
|
+
retests_failed: count(input.retests, r => r.outcome === "refuse"),
|
|
33
|
+
incident_retests: count(input.retests, r => r.incident),
|
|
34
|
+
};
|
|
35
|
+
if (Object.keys(totals).sort().join(",") !== "incident_retests,incident_suspensions,retests_failed,retests_passed,suspensions" ||
|
|
36
|
+
!Object.values(totals).every(n => Number.isSafeInteger(n) && n >= 0) ||
|
|
37
|
+
totals.incident_suspensions > totals.suspensions || totals.incident_retests > totals.retests_passed + totals.retests_failed)
|
|
38
|
+
throw new Error("historyFacts must contain consistent non-negative safe integer counts");
|
|
39
|
+
const facts = {
|
|
40
|
+
certificate: credentialHash(c),
|
|
41
|
+
profile: claim.profile,
|
|
42
|
+
level: claim.level,
|
|
43
|
+
status: s.status,
|
|
44
|
+
reason: s.reason,
|
|
45
|
+
since: s.since,
|
|
46
|
+
issued_at: c.issued_at,
|
|
47
|
+
exp: c.exp,
|
|
48
|
+
expired: ms(input.asOf) > ms(c.exp),
|
|
49
|
+
days_certified: days(c.issued_at, input.asOf),
|
|
50
|
+
days_since_status_change: days(s.since, input.asOf),
|
|
51
|
+
categories: b.results.length,
|
|
52
|
+
categories_passed: count(b.results, (r) => r.verdict === "pass"),
|
|
53
|
+
min_margin_ppm: Math.min(...b.results.map((r) => r.low_ppm - r.threshold_ppm)),
|
|
54
|
+
...totals,
|
|
55
|
+
overdue_retests: input.overdueRetests,
|
|
56
|
+
conflicts: [...claim.conflicts],
|
|
57
|
+
under_committee_review: input.underCommitteeReview === true,
|
|
58
|
+
};
|
|
59
|
+
const flags = [];
|
|
60
|
+
if (facts.status === "revoked")
|
|
61
|
+
flags.push("REVOKED");
|
|
62
|
+
if (facts.status === "suspended")
|
|
63
|
+
flags.push("SUSPENDED");
|
|
64
|
+
if (facts.expired)
|
|
65
|
+
flags.push("EXPIRED");
|
|
66
|
+
if (facts.overdue_retests > 0)
|
|
67
|
+
flags.push("RETEST_OVERDUE");
|
|
68
|
+
if (facts.retests_failed > 0)
|
|
69
|
+
flags.push("RETEST_FAILED_HISTORY");
|
|
70
|
+
if (facts.incident_suspensions > 0)
|
|
71
|
+
flags.push("INCIDENT_HISTORY");
|
|
72
|
+
if (facts.conflicts.length > 0)
|
|
73
|
+
flags.push("CONFLICTS_DECLARED");
|
|
74
|
+
if (facts.under_committee_review)
|
|
75
|
+
flags.push("UNDER_COMMITTEE_REVIEW");
|
|
76
|
+
return { v: 1, type: "certify.risk_profile", did: c.holder, owner: claim.applicant.name, as_of: input.asOf, facts, flags, note: RISK_NOTE };
|
|
77
|
+
}
|
|
78
|
+
export async function signRiskProfile(profile, signer) {
|
|
79
|
+
if (!isObj(profile) || profile.v !== 1 || profile.type !== "certify.risk_profile" || !isObj(profile.facts))
|
|
80
|
+
throw new Error("not a risk profile (v1)");
|
|
81
|
+
return signWith(profile, signer);
|
|
82
|
+
}
|
|
83
|
+
/** Section 1a: signed and (with `signers`, the lab's report keys) by a trusted key. */
|
|
84
|
+
export function verifyRiskProfile(doc, opts = {}) {
|
|
85
|
+
const p = isObj(doc) ? doc : {};
|
|
86
|
+
if (p.v !== 1 || p.type !== "certify.risk_profile" || !isObj(p.facts) || Object.keys(p).length !== 10)
|
|
87
|
+
return { ok: false, reasons: ["not a signed risk profile (v1)"] };
|
|
88
|
+
const reasons = [];
|
|
89
|
+
if (!verifySignedObject(p))
|
|
90
|
+
reasons.push("bad risk profile signature");
|
|
91
|
+
if (!opts.signers)
|
|
92
|
+
reasons.push("no trusted signers supplied: the signer was NOT checked");
|
|
93
|
+
else if (!opts.signers.includes(p.signer))
|
|
94
|
+
reasons.push("risk profile signer is not a trusted signer");
|
|
95
|
+
if (opts.at !== undefined || opts.maxAgeSeconds !== undefined) {
|
|
96
|
+
if (!isIso(opts.at) || !isInt(opts.maxAgeSeconds, 1, 31_536_000))
|
|
97
|
+
reasons.push("risk profile freshness needs an ISO reference time and maxAgeSeconds from 1 to 31536000");
|
|
98
|
+
else if (!isIso(p.as_of))
|
|
99
|
+
reasons.push("risk profile as_of is not an ISO timestamp");
|
|
100
|
+
else if (ms(p.as_of) > ms(opts.at))
|
|
101
|
+
reasons.push("risk profile is from after the reference time");
|
|
102
|
+
else if (ms(opts.at) - ms(p.as_of) >= opts.maxAgeSeconds * 1000)
|
|
103
|
+
reasons.push("risk profile is stale at the reference time");
|
|
104
|
+
}
|
|
105
|
+
return { ok: reasons.length === 0, reasons };
|
|
106
|
+
}
|
|
107
|
+
const T = {
|
|
108
|
+
en: {
|
|
109
|
+
interpretation: (ref) => `Mapped by the lab's interpretation of ${ref}.`,
|
|
110
|
+
opening: (lab, profile, level) => `Tested by ${lab} under Zanii Certify profile ${profile}, Level ${level}.`,
|
|
111
|
+
category: (t, v, p, n, lo, hi, th) => `${t}: ${v}, ${p} of ${n} trials passed, 95% range ${lo} to ${hi} ppm against a threshold of ${th} ppm.`,
|
|
112
|
+
closing: (c, s, since) => `Certificate ${c}, status ${s} since ${since}.`,
|
|
113
|
+
notCovered: "Not covered by this certificate's tests.",
|
|
114
|
+
verdict: { pass: "pass", fail: "fail", inconclusive: "inconclusive" },
|
|
115
|
+
status: { valid: "valid", suspended: "suspended", revoked: "revoked" },
|
|
116
|
+
notes: (domain, c) => `Clauses marked interpretation are the lab's reading of the source document, not the regulator's own mapping. The certificate attests test results at a point in time plus surveillance; check its live status at https://${domain}/v1/certificates/${c}.`,
|
|
117
|
+
},
|
|
118
|
+
ar: {
|
|
119
|
+
interpretation: (ref) => `هذا الربط تفسير من المختبر لـ ${ref}.`,
|
|
120
|
+
opening: (lab, profile, level) => `اختبره ${lab} وفق ملف زاني سيرتيفاي ${profile}، المستوى ${level}.`,
|
|
121
|
+
category: (t, v, p, n, lo, hi, th) => `${t}: ${v}، نجحت ${p} من ${n} محاولة، نطاق الثقة 95% من ${lo} إلى ${hi} جزء من مليون مقابل عتبة ${th} جزء من مليون.`,
|
|
122
|
+
closing: (c, s, since) => `الشهادة ${c}، حالتها ${s} منذ ${since}.`,
|
|
123
|
+
notCovered: "لا تغطيه اختبارات هذه الشهادة.",
|
|
124
|
+
verdict: { pass: "ناجح", fail: "راسب", inconclusive: "غير حاسم" },
|
|
125
|
+
status: { valid: "سارية", suspended: "موقوفة", revoked: "ملغاة" },
|
|
126
|
+
notes: (domain, c) => `البنود الموسومة بأنها تفسير هي قراءة المختبر للوثيقة المصدر وليست ربطا صادرا عن الجهة التنظيمية. تشهد الشهادة على نتائج اختبار في وقت محدد مع المراقبة اللاحقة؛ تحقق من حالتها الحالية في https://${domain}/v1/certificates/${c}.`,
|
|
127
|
+
},
|
|
128
|
+
};
|
|
129
|
+
/** The per-clause filing pack, signed by the report key. */
|
|
130
|
+
export async function buildFilingPack(input, signer) {
|
|
131
|
+
const { lang, clauseMap: m, profile: p, certificate: c, bundle: b, status: s } = input;
|
|
132
|
+
if (lang !== "en" && lang !== "ar")
|
|
133
|
+
throw new Error("lang must be en or ar");
|
|
134
|
+
if (!isIso(input.generatedAt))
|
|
135
|
+
throw new Error("generatedAt must be an ISO timestamp");
|
|
136
|
+
if (!isSha(input.report.sha256))
|
|
137
|
+
throw new Error("report.sha256 must be a sha256 hash");
|
|
138
|
+
if (input.evidenceShare !== undefined && (!isObj(input.evidenceShare) || !isSha(input.evidenceShare.sha256)))
|
|
139
|
+
throw new Error("evidenceShare.sha256 must be a sha256 hash");
|
|
140
|
+
const claim = claimOf(c);
|
|
141
|
+
const mapRef = `${m.slug}/${m.version}`;
|
|
142
|
+
if (`${p.slug}/${p.version}` !== claim.profile)
|
|
143
|
+
throw new Error("profile is not the certificate's (claim.profile)");
|
|
144
|
+
if (!p.clause_maps.includes(mapRef))
|
|
145
|
+
throw new Error("the profile does not cite this clause map");
|
|
146
|
+
if (bundleHash(b) !== claim.bundle)
|
|
147
|
+
throw new Error("bundle is not the certificate's (claim.bundle)");
|
|
148
|
+
const t = T[lang];
|
|
149
|
+
const hash = credentialHash(c);
|
|
150
|
+
const attach = [input.report.name, "result-bundle.json", "certificate.json", ...(input.evidenceShare ? ["evidence-share.json"] : [])];
|
|
151
|
+
const results = new Map(b.results.map((r) => [r.category, r]));
|
|
152
|
+
const notEvidenced = [];
|
|
153
|
+
const clauses = m.clauses.map((cl) => {
|
|
154
|
+
const categories = p.categories
|
|
155
|
+
.filter((cat) => cat.clauses.includes(`${m.slug}:${cl.id}`) && results.has(cat.id))
|
|
156
|
+
.sort((x, y) => (x.id < y.id ? -1 : x.id > y.id ? 1 : 0))
|
|
157
|
+
.map((cat) => {
|
|
158
|
+
const r = results.get(cat.id);
|
|
159
|
+
return {
|
|
160
|
+
id: cat.id, title: cat.title[lang], verdict: r.verdict, trials: r.trials, passes: r.passes,
|
|
161
|
+
rate_ppm: r.rate_ppm, low_ppm: r.low_ppm, high_ppm: r.high_ppm, threshold_ppm: r.threshold_ppm,
|
|
162
|
+
};
|
|
163
|
+
});
|
|
164
|
+
let paste;
|
|
165
|
+
if (!categories.length) {
|
|
166
|
+
notEvidenced.push(cl.id);
|
|
167
|
+
paste = t.notCovered;
|
|
168
|
+
}
|
|
169
|
+
else
|
|
170
|
+
paste = [
|
|
171
|
+
...(cl.interpretation ? [t.interpretation(cl.ref)] : []),
|
|
172
|
+
t.opening(input.lab.name, claim.profile, claim.level),
|
|
173
|
+
...categories.map((x) => t.category(x.title, t.verdict[x.verdict] ?? x.verdict, x.passes, x.trials, x.low_ppm, x.high_ppm, x.threshold_ppm)),
|
|
174
|
+
t.closing(hash, t.status[s.status] ?? s.status, s.since),
|
|
175
|
+
].join(" ");
|
|
176
|
+
return { id: cl.id, ref: cl.ref, title: cl.title[lang], interpretation: cl.interpretation, categories, paste, attach: categories.length ? attach : [] };
|
|
177
|
+
});
|
|
178
|
+
return signWith({
|
|
179
|
+
v: 1,
|
|
180
|
+
type: "certify.filing-pack",
|
|
181
|
+
lang,
|
|
182
|
+
lab: { name: input.lab.name, domain: input.lab.domain },
|
|
183
|
+
map: { ref: mapRef, title: m.title[lang], source: m.source },
|
|
184
|
+
certificate: hash,
|
|
185
|
+
holder: c.holder,
|
|
186
|
+
applicant: claim.applicant,
|
|
187
|
+
profile: claim.profile,
|
|
188
|
+
level: claim.level,
|
|
189
|
+
status: { status: s.status, reason: s.reason, since: s.since },
|
|
190
|
+
clauses,
|
|
191
|
+
not_evidenced: notEvidenced,
|
|
192
|
+
attachments: [
|
|
193
|
+
{ name: "certificate.json", sha256: hash },
|
|
194
|
+
{ name: "result-bundle.json", sha256: claim.bundle },
|
|
195
|
+
{ name: input.report.name, sha256: input.report.sha256 },
|
|
196
|
+
...(input.evidenceShare ? [{ name: "evidence-share.json", sha256: input.evidenceShare.sha256 }] : []),
|
|
197
|
+
],
|
|
198
|
+
notes: t.notes(input.lab.domain, hash),
|
|
199
|
+
generated_at: input.generatedAt,
|
|
200
|
+
}, signer);
|
|
201
|
+
}
|
|
202
|
+
/** Verify a filing pack's signer and the file hashes of supplied report and evidence-share bytes. */
|
|
203
|
+
export function verifyFilingPack(pack, opts = {}) {
|
|
204
|
+
const p = isObj(pack) ? pack : {};
|
|
205
|
+
if (p.v !== 1 || p.type !== "certify.filing-pack")
|
|
206
|
+
return { ok: false, reasons: ["not a filing pack (v1)"] };
|
|
207
|
+
const reasons = [];
|
|
208
|
+
if (!verifySignedObject(p))
|
|
209
|
+
reasons.push("bad filing pack signature");
|
|
210
|
+
if (!opts.signers)
|
|
211
|
+
reasons.push("no trusted signers supplied: the signer was NOT checked");
|
|
212
|
+
else if (!opts.signers.includes(p.signer))
|
|
213
|
+
reasons.push("filing pack signer is not a trusted signer");
|
|
214
|
+
if (opts.report) {
|
|
215
|
+
const name = `test-report-${String(p.lang)}.pdf`;
|
|
216
|
+
const a = (Array.isArray(p.attachments) ? p.attachments : []).find((x) => isObj(x) && x.name === name);
|
|
217
|
+
if (!a || a.sha256 !== sha256Bytes(opts.report))
|
|
218
|
+
reasons.push("the report is not the one the filing pack names");
|
|
219
|
+
}
|
|
220
|
+
if (opts.evidenceShare) {
|
|
221
|
+
const a = (Array.isArray(p.attachments) ? p.attachments : []).find((x) => isObj(x) && x.name === "evidence-share.json");
|
|
222
|
+
if (!a || a.sha256 !== sha256Bytes(opts.evidenceShare))
|
|
223
|
+
reasons.push("the evidence share is not the one the filing pack names");
|
|
224
|
+
}
|
|
225
|
+
return { ok: reasons.length === 0, reasons };
|
|
226
|
+
}
|
|
227
|
+
// ---------------------------------------------------------------- 3. governance outcome
|
|
228
|
+
export const CASE_KINDS = ["objection", "appeal", "complaint"];
|
|
229
|
+
export const OUTCOMES = {
|
|
230
|
+
objection: ["upheld", "dismissed"],
|
|
231
|
+
appeal: ["upheld", "partly_upheld", "rejected"],
|
|
232
|
+
complaint: ["upheld", "partly_upheld", "rejected"],
|
|
233
|
+
};
|
|
234
|
+
const RECORD_ID = /^[a-z]{3}_[a-z2-7]{24}$/;
|
|
235
|
+
function outcomeError(o) {
|
|
236
|
+
const k = isObj(o.case) ? o.case : {};
|
|
237
|
+
if (!isObj(o.case) || Object.keys(k).length !== 2 || !CASE_KINDS.includes(k.kind) || typeof k.id !== "string" || !RECORD_ID.test(k.id))
|
|
238
|
+
return "case must be {kind, id} with a known kind and a record id";
|
|
239
|
+
if (o.decision !== null && (typeof o.decision !== "string" || !RECORD_ID.test(o.decision)))
|
|
240
|
+
return "decision must be a decision id or null";
|
|
241
|
+
if (o.certificate !== null && !isSha(o.certificate))
|
|
242
|
+
return "certificate must be a certificate hash or null";
|
|
243
|
+
if (o.decision === null && o.certificate === null)
|
|
244
|
+
return "decision and certificate cannot both be null";
|
|
245
|
+
if (!OUTCOMES[k.kind].includes(o.outcome))
|
|
246
|
+
return `outcome must be one of ${OUTCOMES[k.kind].join(", ")}`;
|
|
247
|
+
if (typeof o.note !== "string" || o.note.length < 1 || o.note.length > 4000)
|
|
248
|
+
return "note must be 1 to 4000 characters";
|
|
249
|
+
if (!isDid(o.handled_by))
|
|
250
|
+
return "handled_by must be a did:key";
|
|
251
|
+
if (!isIso(o.decided_at))
|
|
252
|
+
return "decided_at must be an ISO timestamp";
|
|
253
|
+
if ("approval" in o && (k.kind !== "objection" || !isObj(o.approval)))
|
|
254
|
+
return "approval is a signing key approval, on an objection outcome only";
|
|
255
|
+
return null;
|
|
256
|
+
}
|
|
257
|
+
/** The unsigned outcome (the committee member signs this with their own key). */
|
|
258
|
+
export function governanceOutcomeBody(f) {
|
|
259
|
+
const body = {
|
|
260
|
+
v: 1,
|
|
261
|
+
type: "certify.governance-outcome",
|
|
262
|
+
case: { kind: f.case?.kind, id: f.case?.id },
|
|
263
|
+
decision: f.decision,
|
|
264
|
+
certificate: f.certificate,
|
|
265
|
+
outcome: f.outcome,
|
|
266
|
+
note: f.note,
|
|
267
|
+
handled_by: f.handledBy,
|
|
268
|
+
decided_at: f.decidedAt,
|
|
269
|
+
...(f.approval !== undefined ? { approval: f.approval } : {}),
|
|
270
|
+
};
|
|
271
|
+
const err = outcomeError(body);
|
|
272
|
+
if (err)
|
|
273
|
+
throw new Error(err);
|
|
274
|
+
return body;
|
|
275
|
+
}
|
|
276
|
+
/** Sign a governance outcome: an objection by the committee member who decided it, an appeal or complaint by the lab issuer. */
|
|
277
|
+
export async function buildGovernanceOutcome(f, signer) {
|
|
278
|
+
return signWith(governanceOutcomeBody(f), signer);
|
|
279
|
+
}
|
|
280
|
+
/**
|
|
281
|
+
* `issuers`: the issuer DIDs that sign appeal and complaint outcomes and signing key approvals.
|
|
282
|
+
* `committee`: the committee members' DIDs; given, an objection outcome must be handled by one of
|
|
283
|
+
* them (without it any DID that signs as `handled_by` passes, so an external verifier passes the
|
|
284
|
+
* lab's committee). `approvals`: the lab's later approval documents (revocations), from its
|
|
285
|
+
* public list; an objection signed by an approved key must be decided while the approval was in force.
|
|
286
|
+
* `external`: the active external committee members (section 3b); given, an objection is decided by one.
|
|
287
|
+
*/
|
|
288
|
+
export function verifyGovernanceOutcome(doc, opts = {}) {
|
|
289
|
+
const o = isObj(doc) ? doc : {};
|
|
290
|
+
if (o.v !== 1 || o.type !== "certify.governance-outcome" || Object.keys(o).length !== ("approval" in o ? 12 : 11))
|
|
291
|
+
return { ok: false, reasons: ["not a governance outcome (v1)"] };
|
|
292
|
+
const reasons = [];
|
|
293
|
+
if (!verifySignedObject(o))
|
|
294
|
+
reasons.push("bad governance outcome signature");
|
|
295
|
+
const err = outcomeError(o);
|
|
296
|
+
if (err)
|
|
297
|
+
reasons.push(`governance outcome is malformed: ${err}`);
|
|
298
|
+
const kind = isObj(o.case) ? o.case.kind : undefined;
|
|
299
|
+
if (kind === "objection" && "approval" in o) {
|
|
300
|
+
reasons.push(...approvalInForce(o, opts));
|
|
301
|
+
if (opts.committee && !opts.committee.includes(o.handled_by))
|
|
302
|
+
reasons.push("objection outcome signer is not a committee member");
|
|
303
|
+
}
|
|
304
|
+
else if (kind === "objection") {
|
|
305
|
+
if (o.signer !== o.handled_by)
|
|
306
|
+
reasons.push("an objection outcome is signed by the committee member who decided it");
|
|
307
|
+
else if (opts.committee && !opts.committee.includes(o.signer))
|
|
308
|
+
reasons.push("objection outcome signer is not a committee member");
|
|
309
|
+
}
|
|
310
|
+
else if (!opts.issuers)
|
|
311
|
+
reasons.push("no trusted issuers supplied: the signer was NOT checked");
|
|
312
|
+
else if (!opts.issuers.includes(o.signer))
|
|
313
|
+
reasons.push("governance outcome signer is not a trusted issuer");
|
|
314
|
+
// Section 3b: given the external members, an objection is decided by one of them.
|
|
315
|
+
if (kind === "objection" && opts.external && !opts.external.includes(o.handled_by))
|
|
316
|
+
reasons.push("an objection outcome is decided by an external committee member");
|
|
317
|
+
return { ok: reasons.length === 0, reasons };
|
|
318
|
+
}
|
|
319
|
+
function approvalError(a) {
|
|
320
|
+
if (!isDid(a.staff))
|
|
321
|
+
return "staff must be a did:key";
|
|
322
|
+
if (!isDid(a.key) || a.key === a.staff)
|
|
323
|
+
return "key must be a did:key other than staff";
|
|
324
|
+
if (!isIso(a.approved_at))
|
|
325
|
+
return "approved_at must be an ISO timestamp";
|
|
326
|
+
if (a.revoked_at !== null && !(isIso(a.revoked_at) && ms(a.revoked_at) >= ms(a.approved_at)))
|
|
327
|
+
return "revoked_at must be null or an ISO timestamp not before approved_at";
|
|
328
|
+
return null;
|
|
329
|
+
}
|
|
330
|
+
/** The unsigned approval; a revocation is a new approval document with `revoked_at` set. */
|
|
331
|
+
export function signingKeyApprovalBody(f) {
|
|
332
|
+
const body = { v: 1, type: "certify.signing-key-approval", staff: f.staff, key: f.key, approved_at: f.approvedAt, revoked_at: f.revokedAt ?? null };
|
|
333
|
+
const err = approvalError(body);
|
|
334
|
+
if (err)
|
|
335
|
+
throw new Error(err);
|
|
336
|
+
return body;
|
|
337
|
+
}
|
|
338
|
+
/** Signed by the lab issuer. */
|
|
339
|
+
export async function buildSigningKeyApproval(f, signer) {
|
|
340
|
+
return signWith(signingKeyApprovalBody(f), signer);
|
|
341
|
+
}
|
|
342
|
+
/** Section 3a: well formed and signed by one of `issuers` (the issuer document's `dids`). */
|
|
343
|
+
export function verifySigningKeyApproval(doc, opts = {}) {
|
|
344
|
+
const a = isObj(doc) ? doc : {};
|
|
345
|
+
if (a.v !== 1 || a.type !== "certify.signing-key-approval" || Object.keys(a).length !== 8)
|
|
346
|
+
return { ok: false, reasons: ["not a signing key approval (v1)"] };
|
|
347
|
+
const reasons = [];
|
|
348
|
+
if (!verifySignedObject(a))
|
|
349
|
+
reasons.push("bad signing key approval signature");
|
|
350
|
+
const err = approvalError(a);
|
|
351
|
+
if (err)
|
|
352
|
+
reasons.push(`signing key approval is malformed: ${err}`);
|
|
353
|
+
if (!opts.issuers)
|
|
354
|
+
reasons.push("no trusted issuers supplied: the approval signer was NOT checked");
|
|
355
|
+
else if (!opts.issuers.includes(a.signer))
|
|
356
|
+
reasons.push("signing key approval signer is not a trusted issuer");
|
|
357
|
+
return { ok: reasons.length === 0, reasons };
|
|
358
|
+
}
|
|
359
|
+
/** An objection outcome's approval: for its signer and member, in force at `decided_at` (no revocation in `approvals` at or before it). */
|
|
360
|
+
function approvalInForce(o, opts) {
|
|
361
|
+
const a = o.approval;
|
|
362
|
+
const check = verifySigningKeyApproval(a, opts);
|
|
363
|
+
if (!check.ok)
|
|
364
|
+
return check.reasons;
|
|
365
|
+
if (a.staff !== o.handled_by || a.key !== o.signer)
|
|
366
|
+
return ["the signing key approval is not for this outcome's signer and handled_by"];
|
|
367
|
+
const at = ms(o.decided_at);
|
|
368
|
+
if (!(ms(a.approved_at) <= at))
|
|
369
|
+
return ["the signing key was not approved when the outcome was decided"];
|
|
370
|
+
const revoked = [a, ...(opts.approvals ?? [])].some((x) => isObj(x) && x.staff === a.staff && x.key === a.key && x.revoked_at !== null && verifySigningKeyApproval(x, opts).ok && !(at < ms(x.revoked_at)));
|
|
371
|
+
return revoked ? ["the signing key approval was revoked when the outcome was decided"] : [];
|
|
372
|
+
}
|
|
373
|
+
// ---------------------------------------------------------------- 4. accreditation export
|
|
374
|
+
const SECTION = /^[a-z][a-z0-9_]{0,63}$/;
|
|
375
|
+
export async function buildAccreditationExport(input, signer) {
|
|
376
|
+
if (!isIso(input.generatedAt))
|
|
377
|
+
throw new Error("generatedAt must be an ISO timestamp");
|
|
378
|
+
const names = Object.keys(input.sections).sort();
|
|
379
|
+
for (const n of names) {
|
|
380
|
+
if (!SECTION.test(n))
|
|
381
|
+
throw new Error(`section name ${n} is not allowed`);
|
|
382
|
+
if (!Array.isArray(input.sections[n]) || !input.sections[n].every(isObj))
|
|
383
|
+
throw new Error(`section ${n} must be a list of objects`);
|
|
384
|
+
}
|
|
385
|
+
const manifest = await signWith({
|
|
386
|
+
v: 1,
|
|
387
|
+
type: "certify.accreditation-export",
|
|
388
|
+
lab: { name: input.lab.name, domain: input.lab.domain },
|
|
389
|
+
generated_at: input.generatedAt,
|
|
390
|
+
sections: names.map((name) => ({ name, count: input.sections[name].length, sha256: jcsHash({ items: input.sections[name] }) })),
|
|
391
|
+
}, signer);
|
|
392
|
+
return { manifest, sections: Object.fromEntries(names.map((n) => [n, input.sections[n]])) };
|
|
393
|
+
}
|
|
394
|
+
export function verifyAccreditationExport(archive, opts = {}) {
|
|
395
|
+
const a = isObj(archive) ? archive : {};
|
|
396
|
+
const m = isObj(a.manifest) ? a.manifest : {};
|
|
397
|
+
if (m.v !== 1 || m.type !== "certify.accreditation-export" || !isObj(a.sections) || !Array.isArray(m.sections))
|
|
398
|
+
return { ok: false, reasons: ["not an accreditation export (v1)"] };
|
|
399
|
+
const reasons = [];
|
|
400
|
+
if (!verifySignedObject(m))
|
|
401
|
+
reasons.push("bad accreditation export signature");
|
|
402
|
+
if (!opts.signers)
|
|
403
|
+
reasons.push("no trusted signers supplied: the signer was NOT checked");
|
|
404
|
+
else if (!opts.signers.includes(m.signer))
|
|
405
|
+
reasons.push("accreditation export signer is not a trusted signer");
|
|
406
|
+
const listed = m.sections.map((s) => String(isObj(s) ? s.name : ""));
|
|
407
|
+
const present = Object.keys(a.sections).sort();
|
|
408
|
+
if (listed.join("\n") !== present.join("\n"))
|
|
409
|
+
reasons.push("the archive's sections are not the manifest's");
|
|
410
|
+
for (const s of m.sections) {
|
|
411
|
+
const items = a.sections[s.name];
|
|
412
|
+
if (!Array.isArray(items))
|
|
413
|
+
continue;
|
|
414
|
+
if (items.length !== s.count || jcsHash({ items }) !== s.sha256)
|
|
415
|
+
reasons.push(`section ${s.name} does not match the manifest`);
|
|
416
|
+
}
|
|
417
|
+
return { ok: reasons.length === 0, reasons };
|
|
418
|
+
}
|