@emilia-protocol/gate 0.18.2 → 0.20.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/CHANGELOG.md +56 -0
- package/README.md +64 -0
- package/action-refusal-statement.js +1 -0
- package/coverage-reconciliation-attestation.js +1 -0
- package/dist/action-refusal-statement.d.ts +109 -0
- package/dist/action-refusal-statement.d.ts.map +1 -0
- package/dist/action-refusal-statement.js +333 -0
- package/dist/action-refusal-statement.js.map +1 -0
- package/dist/coverage-reconciliation-attestation.d.ts +29 -0
- package/dist/coverage-reconciliation-attestation.d.ts.map +1 -0
- package/dist/coverage-reconciliation-attestation.js +111 -0
- package/dist/coverage-reconciliation-attestation.js.map +1 -0
- package/dist/gate-qualification-v2.d.ts +4 -0
- package/dist/gate-qualification-v2.d.ts.map +1 -1
- package/dist/gate-qualification-v2.js +39 -47
- package/dist/gate-qualification-v2.js.map +1 -1
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -1
- package/dist/loss-allocation-schedule.d.ts +53 -0
- package/dist/loss-allocation-schedule.d.ts.map +1 -0
- package/dist/loss-allocation-schedule.js +351 -0
- package/dist/loss-allocation-schedule.js.map +1 -0
- package/dist/loss-experience-feed.d.ts +82 -0
- package/dist/loss-experience-feed.d.ts.map +1 -0
- package/dist/loss-experience-feed.js +314 -0
- package/dist/loss-experience-feed.js.map +1 -0
- package/dist/open-exposure-ledger-postgres.d.ts +40 -0
- package/dist/open-exposure-ledger-postgres.d.ts.map +1 -0
- package/dist/open-exposure-ledger-postgres.js +577 -0
- package/dist/open-exposure-ledger-postgres.js.map +1 -0
- package/dist/open-exposure-ledger.d.ts +243 -0
- package/dist/open-exposure-ledger.d.ts.map +1 -0
- package/dist/open-exposure-ledger.js +794 -0
- package/dist/open-exposure-ledger.js.map +1 -0
- package/dist/receipt-census.d.ts +21 -0
- package/dist/receipt-census.d.ts.map +1 -0
- package/dist/receipt-census.js +162 -0
- package/dist/receipt-census.js.map +1 -0
- package/dist/reliance-program.d.ts +88 -0
- package/dist/reliance-program.d.ts.map +1 -0
- package/dist/reliance-program.js +437 -0
- package/dist/reliance-program.js.map +1 -0
- package/dist/reliance-risk-crypto.d.ts +28 -0
- package/dist/reliance-risk-crypto.d.ts.map +1 -0
- package/dist/reliance-risk-crypto.js +110 -0
- package/dist/reliance-risk-crypto.js.map +1 -0
- package/dist/remedy-program.d.ts.map +1 -1
- package/dist/remedy-program.js +28 -18
- package/dist/remedy-program.js.map +1 -1
- package/loss-allocation-schedule.js +1 -0
- package/loss-experience-feed.js +1 -0
- package/open-exposure-ledger-postgres.js +1 -0
- package/open-exposure-ledger.js +1 -0
- package/package.json +42 -2
- package/receipt-census.js +1 -0
- package/reliance-program.js +3 -0
- package/src/action-refusal-statement.ts +396 -0
- package/src/coverage-reconciliation-attestation.ts +107 -0
- package/src/gate-qualification-v2.ts +56 -46
- package/src/index.ts +7 -0
- package/src/loss-allocation-schedule.ts +427 -0
- package/src/loss-experience-feed.ts +398 -0
- package/src/open-exposure-ledger-postgres.ts +701 -0
- package/src/open-exposure-ledger.ts +1213 -0
- package/src/receipt-census.ts +163 -0
- package/src/reliance-program.ts +499 -0
- package/src/reliance-risk-crypto.ts +114 -0
- package/src/remedy-program.ts +28 -17
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
/**
|
|
3
|
+
* Exact-action technical refusal evidence. It is not a legal determination,
|
|
4
|
+
* an adverse-benefit denial, an authorization grant, or proof of delivery.
|
|
5
|
+
*/
|
|
6
|
+
import {
|
|
7
|
+
RISK_CAID, RISK_DIGEST, riskClone, riskDigest, riskExact, riskIdentifier,
|
|
8
|
+
riskInstant, riskRecord, signRiskBody, verifyRiskBody, type RiskRecord,
|
|
9
|
+
type TrustedRiskKeys,
|
|
10
|
+
} from './reliance-risk-crypto.js';
|
|
11
|
+
|
|
12
|
+
export const ACTION_REFUSAL_STATEMENT_VERSION = 'EP-ACTION-REFUSAL-STATEMENT-v1';
|
|
13
|
+
export const ACTION_REFUSAL_CLAIM_BOUNDARY = 'technical_refusal_not_legal_or_benefit_determination';
|
|
14
|
+
export const ACTION_REFUSAL_CLASSES = Object.freeze([
|
|
15
|
+
'verification_failed',
|
|
16
|
+
'action_mismatch',
|
|
17
|
+
'evidence_unsatisfied',
|
|
18
|
+
'authorization_refused',
|
|
19
|
+
'replay_detected',
|
|
20
|
+
'expired',
|
|
21
|
+
'indeterminate',
|
|
22
|
+
]);
|
|
23
|
+
|
|
24
|
+
const PROGRAM_KEYS = ['program_id', 'version', 'source_digest', 'program_digest'] as const;
|
|
25
|
+
const DELIVERY_KEYS = ['channel', 'recipient_id', 'delivered_at', 'custody_digest'] as const;
|
|
26
|
+
const CUSTODY_KEYS = ['custodian_id', 'acknowledged_at', 'evidence_digest'] as const;
|
|
27
|
+
const ANCHOR_KEYS = ['method', 'evidence_digest'] as const;
|
|
28
|
+
const SEMANTIC_KEYS = ['verification', 'match', 'satisfaction', 'authorization'] as const;
|
|
29
|
+
const BODY_KEYS = [
|
|
30
|
+
'@version', 'refusal_id', 'relying_party_id', 'caid', 'action_digest',
|
|
31
|
+
'program', 'failed_requirement_ids', 'evidence_digests',
|
|
32
|
+
'challenge_digests', 'nonce', 'refused_at', 'expires_at', 'refusal_class',
|
|
33
|
+
'semantics', 'delivery', 'custody', 'transparency_anchor', 'claim_boundary',
|
|
34
|
+
] as const;
|
|
35
|
+
const INPUT_KEYS = new Set([
|
|
36
|
+
...BODY_KEYS.filter((key) => key !== '@version' && key !== 'semantics'
|
|
37
|
+
&& key !== 'delivery' && key !== 'custody' && key !== 'transparency_anchor'
|
|
38
|
+
&& key !== 'challenge_digests'),
|
|
39
|
+
'semantics', 'delivery', 'custody', 'transparency_anchor',
|
|
40
|
+
'challenge_digest', 'challenge_digests',
|
|
41
|
+
]);
|
|
42
|
+
const EXPECTED_KEYS = [
|
|
43
|
+
'caid', 'action_digest', 'relying_party_id', 'program_id', 'program_version',
|
|
44
|
+
'source_digest', 'program_digest', 'nonce',
|
|
45
|
+
] as const;
|
|
46
|
+
|
|
47
|
+
const VERIFICATION = new Set(['VERIFIED', 'NOT_VERIFIED', 'INDETERMINATE']);
|
|
48
|
+
const MATCH = new Set(['MATCH', 'MISMATCH', 'INDETERMINATE']);
|
|
49
|
+
const SATISFACTION = new Set(['SATISFIED', 'NOT_SATISFIED', 'INDETERMINATE']);
|
|
50
|
+
const AUTHORIZATION = new Set(['AUTHORIZED', 'NOT_AUTHORIZED', 'NOT_EVALUATED', 'INDETERMINATE']);
|
|
51
|
+
|
|
52
|
+
export type ActionRefusalExpectedBindings = {
|
|
53
|
+
caid?: string;
|
|
54
|
+
action_digest?: string;
|
|
55
|
+
relying_party_id?: string;
|
|
56
|
+
program_id?: string;
|
|
57
|
+
program_version?: number;
|
|
58
|
+
source_digest?: string;
|
|
59
|
+
program_digest?: string;
|
|
60
|
+
nonce?: string;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
export type ActionRefusalReplayStore = {
|
|
64
|
+
durable: boolean;
|
|
65
|
+
consume(
|
|
66
|
+
relyingPartyId: string,
|
|
67
|
+
nonce: string,
|
|
68
|
+
refusalDigest: string,
|
|
69
|
+
): Promise<{ accepted: boolean; reason: string | null }>;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
function canonicalSet(
|
|
73
|
+
value: unknown,
|
|
74
|
+
validator: (entry: unknown) => boolean,
|
|
75
|
+
maximum: number,
|
|
76
|
+
requireNonEmpty: boolean,
|
|
77
|
+
): value is string[] {
|
|
78
|
+
if (!Array.isArray(value) || value.length > maximum
|
|
79
|
+
|| (requireNonEmpty && value.length === 0) || !value.every(validator)
|
|
80
|
+
|| new Set(value).size !== value.length) return false;
|
|
81
|
+
return value.every((entry, index) => index === 0
|
|
82
|
+
|| Buffer.from(value[index - 1]).compare(Buffer.from(entry)) < 0);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function sortedUnique(
|
|
86
|
+
value: unknown,
|
|
87
|
+
validator: (entry: unknown) => boolean,
|
|
88
|
+
maximum: number,
|
|
89
|
+
label: string,
|
|
90
|
+
requireNonEmpty: boolean,
|
|
91
|
+
): string[] {
|
|
92
|
+
if (!Array.isArray(value) || value.length > maximum
|
|
93
|
+
|| (requireNonEmpty && value.length === 0) || !value.every(validator)) {
|
|
94
|
+
throw new TypeError(`action refusal ${label} are invalid`);
|
|
95
|
+
}
|
|
96
|
+
if (new Set(value).size !== value.length) {
|
|
97
|
+
throw new TypeError(`action refusal ${label} are duplicate`);
|
|
98
|
+
}
|
|
99
|
+
return [...value].sort((left, right) => Buffer.from(left).compare(Buffer.from(right)));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function defaultSemantics(refusalClass: unknown): RiskRecord {
|
|
103
|
+
const base = {
|
|
104
|
+
verification: 'VERIFIED',
|
|
105
|
+
match: 'MATCH',
|
|
106
|
+
satisfaction: 'NOT_SATISFIED',
|
|
107
|
+
authorization: 'NOT_EVALUATED',
|
|
108
|
+
};
|
|
109
|
+
if (refusalClass === 'verification_failed') return { ...base, verification: 'NOT_VERIFIED' };
|
|
110
|
+
if (refusalClass === 'action_mismatch') return { ...base, match: 'MISMATCH' };
|
|
111
|
+
if (refusalClass === 'authorization_refused') return { ...base, authorization: 'NOT_AUTHORIZED' };
|
|
112
|
+
if (refusalClass === 'indeterminate') return { ...base, satisfaction: 'INDETERMINATE' };
|
|
113
|
+
return base;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function validSemantics(value: unknown, refusalClass: unknown): value is RiskRecord {
|
|
117
|
+
if (!riskExact(value, SEMANTIC_KEYS)
|
|
118
|
+
|| !VERIFICATION.has(value.verification) || !MATCH.has(value.match)
|
|
119
|
+
|| !SATISFACTION.has(value.satisfaction) || !AUTHORIZATION.has(value.authorization)
|
|
120
|
+
|| value.satisfaction === 'SATISFIED') return false;
|
|
121
|
+
if (refusalClass === 'verification_failed') return value.verification === 'NOT_VERIFIED';
|
|
122
|
+
if (refusalClass === 'action_mismatch') return value.match === 'MISMATCH';
|
|
123
|
+
if (refusalClass === 'evidence_unsatisfied') return value.satisfaction === 'NOT_SATISFIED';
|
|
124
|
+
if (refusalClass === 'authorization_refused') return value.authorization === 'NOT_AUTHORIZED';
|
|
125
|
+
if (refusalClass === 'indeterminate') return Object.values(value).includes('INDETERMINATE');
|
|
126
|
+
return true;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function validate(value: unknown): asserts value is RiskRecord {
|
|
130
|
+
if (!riskRecord(value)) throw new TypeError('action refusal statement shape is invalid');
|
|
131
|
+
const { issuer, ...body } = value;
|
|
132
|
+
if (issuer !== undefined && (!riskExact(issuer, ['id', 'key_id'])
|
|
133
|
+
|| !riskIdentifier(issuer.id) || !riskIdentifier(issuer.key_id))) {
|
|
134
|
+
throw new TypeError('action refusal issuer is invalid');
|
|
135
|
+
}
|
|
136
|
+
if (!riskExact(body, BODY_KEYS) || body['@version'] !== ACTION_REFUSAL_STATEMENT_VERSION
|
|
137
|
+
|| !riskIdentifier(body.refusal_id) || !riskIdentifier(body.relying_party_id)
|
|
138
|
+
|| typeof body.caid !== 'string' || !RISK_CAID.test(body.caid)
|
|
139
|
+
|| typeof body.action_digest !== 'string' || !RISK_DIGEST.test(body.action_digest)
|
|
140
|
+
|| !riskExact(body.program, PROGRAM_KEYS) || !riskIdentifier(body.program.program_id)
|
|
141
|
+
|| !Number.isSafeInteger(body.program.version) || body.program.version < 1
|
|
142
|
+
|| typeof body.program.source_digest !== 'string' || !RISK_DIGEST.test(body.program.source_digest)
|
|
143
|
+
|| typeof body.program.program_digest !== 'string' || !RISK_DIGEST.test(body.program.program_digest)
|
|
144
|
+
|| !riskIdentifier(body.nonce) || !ACTION_REFUSAL_CLASSES.includes(body.refusal_class)
|
|
145
|
+
|| !validSemantics(body.semantics, body.refusal_class)
|
|
146
|
+
|| body.claim_boundary !== ACTION_REFUSAL_CLAIM_BOUNDARY) {
|
|
147
|
+
throw new TypeError('action refusal statement shape is invalid');
|
|
148
|
+
}
|
|
149
|
+
if (!canonicalSet(body.failed_requirement_ids, riskIdentifier, 128, true)) {
|
|
150
|
+
throw new TypeError('action refusal requirements are invalid, duplicate, or unsorted');
|
|
151
|
+
}
|
|
152
|
+
if (!canonicalSet(
|
|
153
|
+
body.evidence_digests,
|
|
154
|
+
(entry) => typeof entry === 'string' && RISK_DIGEST.test(entry),
|
|
155
|
+
256,
|
|
156
|
+
false,
|
|
157
|
+
) || !canonicalSet(
|
|
158
|
+
body.challenge_digests,
|
|
159
|
+
(entry) => typeof entry === 'string' && RISK_DIGEST.test(entry),
|
|
160
|
+
256,
|
|
161
|
+
true,
|
|
162
|
+
)) throw new TypeError('action refusal evidence or challenge binding is invalid');
|
|
163
|
+
const refused = riskInstant(body.refused_at);
|
|
164
|
+
const expires = riskInstant(body.expires_at);
|
|
165
|
+
if (!Number.isFinite(refused) || !Number.isFinite(expires) || expires <= refused) {
|
|
166
|
+
throw new TypeError('action refusal time window is invalid');
|
|
167
|
+
}
|
|
168
|
+
if (body.delivery !== null && (!riskExact(body.delivery, DELIVERY_KEYS)
|
|
169
|
+
|| !riskIdentifier(body.delivery.channel) || !riskIdentifier(body.delivery.recipient_id)
|
|
170
|
+
|| !Number.isFinite(riskInstant(body.delivery.delivered_at))
|
|
171
|
+
|| riskInstant(body.delivery.delivered_at) < refused
|
|
172
|
+
|| riskInstant(body.delivery.delivered_at) > expires
|
|
173
|
+
|| typeof body.delivery.custody_digest !== 'string'
|
|
174
|
+
|| !RISK_DIGEST.test(body.delivery.custody_digest))) {
|
|
175
|
+
throw new TypeError('action refusal delivery evidence is invalid');
|
|
176
|
+
}
|
|
177
|
+
if (body.custody !== null && (body.delivery === null
|
|
178
|
+
|| !riskExact(body.custody, CUSTODY_KEYS)
|
|
179
|
+
|| !riskIdentifier(body.custody.custodian_id)
|
|
180
|
+
|| !Number.isFinite(riskInstant(body.custody.acknowledged_at))
|
|
181
|
+
|| riskInstant(body.custody.acknowledged_at) < riskInstant(body.delivery.delivered_at)
|
|
182
|
+
|| riskInstant(body.custody.acknowledged_at) > expires
|
|
183
|
+
|| typeof body.custody.evidence_digest !== 'string'
|
|
184
|
+
|| !RISK_DIGEST.test(body.custody.evidence_digest))) {
|
|
185
|
+
throw new TypeError('action refusal custody evidence is invalid');
|
|
186
|
+
}
|
|
187
|
+
if (body.transparency_anchor !== null && (!riskExact(body.transparency_anchor, ANCHOR_KEYS)
|
|
188
|
+
|| !riskIdentifier(body.transparency_anchor.method)
|
|
189
|
+
|| typeof body.transparency_anchor.evidence_digest !== 'string'
|
|
190
|
+
|| !RISK_DIGEST.test(body.transparency_anchor.evidence_digest))) {
|
|
191
|
+
throw new TypeError('action refusal transparency anchor is invalid');
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function normalizedInput(input: RiskRecord): RiskRecord {
|
|
196
|
+
const required = [
|
|
197
|
+
'refusal_id', 'relying_party_id', 'caid', 'action_digest', 'program',
|
|
198
|
+
'failed_requirement_ids', 'evidence_digests', 'nonce', 'refused_at',
|
|
199
|
+
'expires_at', 'refusal_class', 'claim_boundary',
|
|
200
|
+
];
|
|
201
|
+
if (!riskRecord(input) || !required.every((key) => Object.hasOwn(input, key))
|
|
202
|
+
|| !Object.keys(input).every((key) => INPUT_KEYS.has(key))
|
|
203
|
+
|| (Object.hasOwn(input, 'challenge_digest') === Object.hasOwn(input, 'challenge_digests'))) {
|
|
204
|
+
throw new TypeError('action refusal input shape is invalid');
|
|
205
|
+
}
|
|
206
|
+
const rawChallenges = input.challenge_digests ?? [input.challenge_digest];
|
|
207
|
+
return {
|
|
208
|
+
'@version': ACTION_REFUSAL_STATEMENT_VERSION,
|
|
209
|
+
refusal_id: input.refusal_id,
|
|
210
|
+
relying_party_id: input.relying_party_id,
|
|
211
|
+
caid: input.caid,
|
|
212
|
+
action_digest: input.action_digest,
|
|
213
|
+
program: input.program,
|
|
214
|
+
failed_requirement_ids: sortedUnique(
|
|
215
|
+
input.failed_requirement_ids, riskIdentifier, 128, 'requirements', true,
|
|
216
|
+
),
|
|
217
|
+
evidence_digests: sortedUnique(
|
|
218
|
+
input.evidence_digests,
|
|
219
|
+
(entry) => typeof entry === 'string' && RISK_DIGEST.test(entry),
|
|
220
|
+
256,
|
|
221
|
+
'evidence digests',
|
|
222
|
+
false,
|
|
223
|
+
),
|
|
224
|
+
challenge_digests: sortedUnique(
|
|
225
|
+
rawChallenges,
|
|
226
|
+
(entry) => typeof entry === 'string' && RISK_DIGEST.test(entry),
|
|
227
|
+
256,
|
|
228
|
+
'challenge digests',
|
|
229
|
+
true,
|
|
230
|
+
),
|
|
231
|
+
nonce: input.nonce,
|
|
232
|
+
refused_at: input.refused_at,
|
|
233
|
+
expires_at: input.expires_at,
|
|
234
|
+
refusal_class: input.refusal_class,
|
|
235
|
+
semantics: input.semantics ?? defaultSemantics(input.refusal_class),
|
|
236
|
+
delivery: input.delivery ?? null,
|
|
237
|
+
custody: input.custody ?? null,
|
|
238
|
+
transparency_anchor: input.transparency_anchor ?? null,
|
|
239
|
+
claim_boundary: input.claim_boundary,
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
export function signActionRefusalStatement(
|
|
244
|
+
input: RiskRecord,
|
|
245
|
+
signer: { issuer_id: string; key_id: string; private_key: any },
|
|
246
|
+
) {
|
|
247
|
+
const body = normalizedInput(input);
|
|
248
|
+
validate(body);
|
|
249
|
+
return signRiskBody(ACTION_REFUSAL_STATEMENT_VERSION, body, signer);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export function actionRefusalStatementDigest(statement: unknown): string {
|
|
253
|
+
return riskDigest(statement);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function expectedMismatch(body: RiskRecord, expected: unknown): string | null {
|
|
257
|
+
if (expected === undefined) return null;
|
|
258
|
+
if (!riskRecord(expected) || !Object.keys(expected).every((key) => EXPECTED_KEYS.includes(key as any))) {
|
|
259
|
+
return 'expected_binding_invalid';
|
|
260
|
+
}
|
|
261
|
+
const bindings: RiskRecord = {
|
|
262
|
+
caid: body.caid,
|
|
263
|
+
action_digest: body.action_digest,
|
|
264
|
+
relying_party_id: body.relying_party_id,
|
|
265
|
+
program_id: body.program.program_id,
|
|
266
|
+
program_version: body.program.version,
|
|
267
|
+
source_digest: body.program.source_digest,
|
|
268
|
+
program_digest: body.program.program_digest,
|
|
269
|
+
nonce: body.nonce,
|
|
270
|
+
};
|
|
271
|
+
for (const key of Object.keys(expected)) {
|
|
272
|
+
if (bindings[key] !== expected[key]) return `${key}_mismatch`;
|
|
273
|
+
}
|
|
274
|
+
return null;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
export function verifyActionRefusalStatement(statement: unknown, options: {
|
|
278
|
+
trusted_keys?: TrustedRiskKeys;
|
|
279
|
+
now?: string | number;
|
|
280
|
+
max_future_skew_sec?: number;
|
|
281
|
+
expected?: ActionRefusalExpectedBindings;
|
|
282
|
+
} = {}) {
|
|
283
|
+
const refuse = (reason: string, refusalDigest: string | null = null) => ({
|
|
284
|
+
accepted: false as const,
|
|
285
|
+
verified: false as const,
|
|
286
|
+
reason,
|
|
287
|
+
refusal_digest: refusalDigest,
|
|
288
|
+
semantics: null,
|
|
289
|
+
delivery_evidence: 'NOT_EVIDENCED' as const,
|
|
290
|
+
custody_evidence: 'NOT_EVIDENCED' as const,
|
|
291
|
+
transparency_anchor: 'NOT_REFERENCED' as const,
|
|
292
|
+
claim_boundary: ACTION_REFUSAL_CLAIM_BOUNDARY,
|
|
293
|
+
});
|
|
294
|
+
const signed = verifyRiskBody(statement, ACTION_REFUSAL_STATEMENT_VERSION, options.trusted_keys);
|
|
295
|
+
if (!signed.valid || !signed.body) return refuse(signed.reason ?? 'refusal_invalid');
|
|
296
|
+
try { validate(signed.body); } catch { return refuse('refusal_schema_invalid', signed.artifact_digest); }
|
|
297
|
+
const now = options.now === undefined
|
|
298
|
+
? Date.now() : (typeof options.now === 'string' ? Date.parse(options.now) : Number(options.now));
|
|
299
|
+
const skew = options.max_future_skew_sec ?? 30;
|
|
300
|
+
if (!Number.isFinite(now) || !Number.isSafeInteger(skew) || skew < 0 || skew > 3600) {
|
|
301
|
+
return refuse('verification_time_invalid', signed.artifact_digest);
|
|
302
|
+
}
|
|
303
|
+
if (riskInstant(signed.body.refused_at) > now + skew * 1000) {
|
|
304
|
+
return refuse('refusal_from_future', signed.artifact_digest);
|
|
305
|
+
}
|
|
306
|
+
if (now >= riskInstant(signed.body.expires_at)) {
|
|
307
|
+
return refuse('refusal_expired', signed.artifact_digest);
|
|
308
|
+
}
|
|
309
|
+
const mismatch = expectedMismatch(signed.body, options.expected);
|
|
310
|
+
if (mismatch) return refuse(mismatch, signed.artifact_digest);
|
|
311
|
+
return {
|
|
312
|
+
accepted: true as const,
|
|
313
|
+
verified: true as const,
|
|
314
|
+
reason: null,
|
|
315
|
+
refusal_digest: signed.artifact_digest,
|
|
316
|
+
relying_party_id: signed.body.relying_party_id,
|
|
317
|
+
nonce: signed.body.nonce,
|
|
318
|
+
semantics: riskClone(signed.body.semantics),
|
|
319
|
+
delivery_evidence: signed.body.delivery === null ? 'NOT_EVIDENCED' : 'REFERENCED',
|
|
320
|
+
custody_evidence: signed.body.custody === null ? 'NOT_EVIDENCED' : 'REFERENCED',
|
|
321
|
+
transparency_anchor: signed.body.transparency_anchor === null
|
|
322
|
+
? 'NOT_REFERENCED' : 'REFERENCED_NOT_EXTERNALLY_VERIFIED',
|
|
323
|
+
claim_boundary: ACTION_REFUSAL_CLAIM_BOUNDARY,
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
export function createMemoryActionRefusalReplayStore(): ActionRefusalReplayStore {
|
|
328
|
+
const consumed = new Map<string, string>();
|
|
329
|
+
return Object.freeze({
|
|
330
|
+
durable: false,
|
|
331
|
+
async consume(relyingPartyId: string, nonce: string, refusalDigest: string) {
|
|
332
|
+
const key = JSON.stringify([relyingPartyId, nonce]);
|
|
333
|
+
const existing = consumed.get(key);
|
|
334
|
+
if (existing === undefined) {
|
|
335
|
+
consumed.set(key, refusalDigest);
|
|
336
|
+
return { accepted: true, reason: null };
|
|
337
|
+
}
|
|
338
|
+
if (existing === refusalDigest) return { accepted: false, reason: 'statement_replay' };
|
|
339
|
+
return { accepted: false, reason: 'nonce_equivocation' };
|
|
340
|
+
},
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
export async function acceptActionRefusalStatement(statement: unknown, options: {
|
|
345
|
+
trusted_keys?: TrustedRiskKeys;
|
|
346
|
+
now?: string | number;
|
|
347
|
+
max_future_skew_sec?: number;
|
|
348
|
+
expected?: ActionRefusalExpectedBindings;
|
|
349
|
+
replayStore?: ActionRefusalReplayStore;
|
|
350
|
+
allowEphemeralReplayStore?: boolean;
|
|
351
|
+
} = {}) {
|
|
352
|
+
const verification = verifyActionRefusalStatement(statement, options);
|
|
353
|
+
const refused = (reason: string, checked = false, durable = false) => ({
|
|
354
|
+
...verification,
|
|
355
|
+
accepted: false as const,
|
|
356
|
+
reason,
|
|
357
|
+
replay_checked: checked,
|
|
358
|
+
replay_store_durable: durable,
|
|
359
|
+
});
|
|
360
|
+
if (!verification.verified) return refused(verification.reason);
|
|
361
|
+
if (typeof verification.refusal_digest !== 'string') return refused('refusal_digest_missing');
|
|
362
|
+
if (!riskExact(options.expected, EXPECTED_KEYS)) return refused('complete_expected_binding_required');
|
|
363
|
+
const store = options.replayStore;
|
|
364
|
+
if (!store || typeof store.consume !== 'function' || typeof store.durable !== 'boolean') {
|
|
365
|
+
return refused('replay_store_required');
|
|
366
|
+
}
|
|
367
|
+
if (!store.durable && options.allowEphemeralReplayStore !== true) {
|
|
368
|
+
return refused('durable_replay_store_required');
|
|
369
|
+
}
|
|
370
|
+
let result: unknown;
|
|
371
|
+
try {
|
|
372
|
+
result = await store.consume(
|
|
373
|
+
verification.relying_party_id,
|
|
374
|
+
verification.nonce,
|
|
375
|
+
verification.refusal_digest,
|
|
376
|
+
);
|
|
377
|
+
} catch {
|
|
378
|
+
return refused('replay_store_unavailable', false, store.durable);
|
|
379
|
+
}
|
|
380
|
+
if (!riskExact(result, ['accepted', 'reason']) || typeof result.accepted !== 'boolean'
|
|
381
|
+
|| (result.reason !== null && typeof result.reason !== 'string')) {
|
|
382
|
+
return refused('replay_store_result_invalid', false, store.durable);
|
|
383
|
+
}
|
|
384
|
+
if (!result.accepted) {
|
|
385
|
+
const reason = result.reason === 'statement_replay' || result.reason === 'nonce_equivocation'
|
|
386
|
+
? result.reason : 'replay_store_refused';
|
|
387
|
+
return refused(reason, true, store.durable);
|
|
388
|
+
}
|
|
389
|
+
if (result.reason !== null) return refused('replay_store_result_invalid', true, store.durable);
|
|
390
|
+
return {
|
|
391
|
+
...verification,
|
|
392
|
+
accepted: true as const,
|
|
393
|
+
replay_checked: true,
|
|
394
|
+
replay_store_durable: store.durable,
|
|
395
|
+
};
|
|
396
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
/** Signed period reconciliation of supplied populations; never proof that the supplied population is complete. */
|
|
3
|
+
import {
|
|
4
|
+
RISK_DIGEST, riskExact, riskIdentifier, riskInstant,
|
|
5
|
+
signRiskBody, verifyRiskBody, type RiskRecord, type TrustedRiskKeys,
|
|
6
|
+
} from './reliance-risk-crypto.js';
|
|
7
|
+
|
|
8
|
+
export const COVERAGE_RECONCILIATION_ATTESTATION_VERSION = 'EP-COVERAGE-RECONCILIATION-ATTESTATION-v1';
|
|
9
|
+
export const COVERAGE_RECONCILIATION_CLAIM_BOUNDARY = 'signed_reconciliation_of_supplied_populations_not_population_completeness';
|
|
10
|
+
const PROGRAM_KEYS = ['program_id', 'version', 'source_digest', 'program_digest'] as const;
|
|
11
|
+
const PERIOD_KEYS = ['start', 'end'] as const;
|
|
12
|
+
const POPULATION_KEYS = ['inventory_id', 'population_root', 'count'] as const;
|
|
13
|
+
const JOIN_KEYS = ['matched', 'effect_without_receipt', 'receipt_without_effect', 'indeterminate', 'excluded', 'exception'] as const;
|
|
14
|
+
const ANCHOR_KEYS = ['method', 'evidence_digest'] as const;
|
|
15
|
+
const BODY_KEYS = ['@version', 'attestation_id', 'relying_party_id', 'program', 'period', 'coverage_report_hash', 'census_digest', 'system_of_record', 'receipt_population', 'joins', 'issued_at', 'expires_at', 'timestamp_anchor', 'claim_boundary'] as const;
|
|
16
|
+
|
|
17
|
+
function count(value: unknown): value is number { return Number.isSafeInteger(value) && Number(value) >= 0; }
|
|
18
|
+
function sameProgram(actual: RiskRecord, expected: RiskRecord): boolean {
|
|
19
|
+
return actual.program_id === expected.program_id && actual.version === expected.version
|
|
20
|
+
&& actual.source_digest === expected.source_digest && actual.program_digest === expected.program_digest;
|
|
21
|
+
}
|
|
22
|
+
function validExpectedProgram(value: unknown): value is RiskRecord {
|
|
23
|
+
return riskExact(value, PROGRAM_KEYS) && riskIdentifier(value.program_id)
|
|
24
|
+
&& Number.isSafeInteger(value.version) && value.version >= 1
|
|
25
|
+
&& RISK_DIGEST.test(value.source_digest) && RISK_DIGEST.test(value.program_digest);
|
|
26
|
+
}
|
|
27
|
+
function validate(value: unknown): asserts value is RiskRecord {
|
|
28
|
+
if (!riskExact(value, BODY_KEYS) || value['@version'] !== COVERAGE_RECONCILIATION_ATTESTATION_VERSION
|
|
29
|
+
|| !riskIdentifier(value.attestation_id) || !riskIdentifier(value.relying_party_id)
|
|
30
|
+
|| !riskExact(value.program, PROGRAM_KEYS) || !riskIdentifier(value.program.program_id)
|
|
31
|
+
|| !Number.isSafeInteger(value.program.version) || value.program.version < 1
|
|
32
|
+
|| !RISK_DIGEST.test(value.program.source_digest) || !RISK_DIGEST.test(value.program.program_digest)
|
|
33
|
+
|| !riskExact(value.period, PERIOD_KEYS) || !RISK_DIGEST.test(value.coverage_report_hash)
|
|
34
|
+
|| !RISK_DIGEST.test(value.census_digest)
|
|
35
|
+
|| !riskExact(value.system_of_record, POPULATION_KEYS) || !riskExact(value.receipt_population, POPULATION_KEYS)
|
|
36
|
+
|| !riskExact(value.joins, JOIN_KEYS) || value.claim_boundary !== COVERAGE_RECONCILIATION_CLAIM_BOUNDARY) {
|
|
37
|
+
throw new TypeError('coverage reconciliation attestation shape is invalid');
|
|
38
|
+
}
|
|
39
|
+
const start = riskInstant(value.period.start); const end = riskInstant(value.period.end);
|
|
40
|
+
const issued = riskInstant(value.issued_at); const expires = riskInstant(value.expires_at);
|
|
41
|
+
if (!Number.isFinite(start) || !Number.isFinite(end) || end <= start || !Number.isFinite(issued)
|
|
42
|
+
|| !Number.isFinite(expires) || expires <= issued || issued < end) throw new TypeError('coverage reconciliation period is invalid');
|
|
43
|
+
for (const population of [value.system_of_record, value.receipt_population]) {
|
|
44
|
+
if (!riskIdentifier(population.inventory_id) || !RISK_DIGEST.test(population.population_root) || !count(population.count)) {
|
|
45
|
+
throw new TypeError('coverage reconciliation population is invalid');
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
if (!JOIN_KEYS.every((key) => count(value.joins[key]))) throw new TypeError('coverage reconciliation counts are invalid');
|
|
49
|
+
if (value.system_of_record.count !== value.joins.matched + value.joins.effect_without_receipt + value.joins.excluded + value.joins.exception
|
|
50
|
+
|| value.receipt_population.count !== value.joins.matched + value.joins.receipt_without_effect + value.joins.indeterminate) {
|
|
51
|
+
throw new TypeError('coverage reconciliation population conservation failed');
|
|
52
|
+
}
|
|
53
|
+
if (value.timestamp_anchor !== null && (!riskExact(value.timestamp_anchor, ANCHOR_KEYS)
|
|
54
|
+
|| !riskIdentifier(value.timestamp_anchor.method) || !RISK_DIGEST.test(value.timestamp_anchor.evidence_digest))) {
|
|
55
|
+
throw new TypeError('coverage timestamp anchor is invalid');
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function signCoverageReconciliationAttestation(input: RiskRecord, signer: { issuer_id: string; key_id: string; private_key: any }) {
|
|
60
|
+
const body: RiskRecord = { '@version': COVERAGE_RECONCILIATION_ATTESTATION_VERSION, ...input };
|
|
61
|
+
validate(body);
|
|
62
|
+
if (signer.issuer_id !== body.relying_party_id) throw new TypeError('coverage attestation issuer must be relying party');
|
|
63
|
+
return signRiskBody(COVERAGE_RECONCILIATION_ATTESTATION_VERSION, body, signer);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function verifyCoverageReconciliationAttestation(attestation: unknown, options: {
|
|
67
|
+
trusted_keys?: TrustedRiskKeys; now?: string | number; expected_program?: RiskRecord;
|
|
68
|
+
expected_census_digest?: string; expected_relying_party_id?: string;
|
|
69
|
+
} = {}) {
|
|
70
|
+
const refuse = (reason: string, verified = false, attestationDigest: string | null = null) => ({
|
|
71
|
+
accepted: false,
|
|
72
|
+
verified,
|
|
73
|
+
reason,
|
|
74
|
+
attestation_digest: attestationDigest,
|
|
75
|
+
claim_boundary: COVERAGE_RECONCILIATION_CLAIM_BOUNDARY,
|
|
76
|
+
});
|
|
77
|
+
const signed = verifyRiskBody(attestation, COVERAGE_RECONCILIATION_ATTESTATION_VERSION, options.trusted_keys);
|
|
78
|
+
if (!signed.valid || !signed.body) return refuse(signed.reason ?? 'attestation_invalid');
|
|
79
|
+
const { issuer, ...payload } = signed.body;
|
|
80
|
+
if (issuer.id !== payload.relying_party_id) {
|
|
81
|
+
return refuse('relying_party_issuer_mismatch', true, signed.artifact_digest);
|
|
82
|
+
}
|
|
83
|
+
try { validate(payload); } catch {
|
|
84
|
+
return refuse('population_conservation_or_schema_invalid', true, signed.artifact_digest);
|
|
85
|
+
}
|
|
86
|
+
const now = options.now === undefined ? Date.now() : (typeof options.now === 'string' ? Date.parse(options.now) : Number(options.now));
|
|
87
|
+
if (!Number.isFinite(now)) return refuse('verification_time_invalid', true, signed.artifact_digest);
|
|
88
|
+
if (now < riskInstant(payload.issued_at)) return refuse('attestation_not_yet_issued', true, signed.artifact_digest);
|
|
89
|
+
if (now >= riskInstant(payload.expires_at)) return refuse('attestation_expired', true, signed.artifact_digest);
|
|
90
|
+
if (options.expected_program === undefined || options.expected_census_digest === undefined
|
|
91
|
+
|| options.expected_relying_party_id === undefined) {
|
|
92
|
+
return refuse('context_binding_required', true, signed.artifact_digest);
|
|
93
|
+
}
|
|
94
|
+
if (!validExpectedProgram(options.expected_program)) {
|
|
95
|
+
return refuse('expected_program_invalid', true, signed.artifact_digest);
|
|
96
|
+
}
|
|
97
|
+
if (!sameProgram(payload.program, options.expected_program)) {
|
|
98
|
+
return refuse('program_binding_mismatch', true, signed.artifact_digest);
|
|
99
|
+
}
|
|
100
|
+
if (options.expected_census_digest !== payload.census_digest) {
|
|
101
|
+
return refuse('census_digest_mismatch', true, signed.artifact_digest);
|
|
102
|
+
}
|
|
103
|
+
if (options.expected_relying_party_id !== payload.relying_party_id) {
|
|
104
|
+
return refuse('relying_party_mismatch', true, signed.artifact_digest);
|
|
105
|
+
}
|
|
106
|
+
return { accepted: true, verified: true, reason: null, attestation_digest: signed.artifact_digest, claim_boundary: COVERAGE_RECONCILIATION_CLAIM_BOUNDARY };
|
|
107
|
+
}
|