@emilia-protocol/gate 0.18.2 → 0.19.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 +27 -0
- package/README.md +42 -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/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/remedy-program.d.ts.map +1 -1
- package/dist/remedy-program.js +28 -18
- package/dist/remedy-program.js.map +1 -1
- package/package.json +7 -2
- package/reliance-program.js +3 -0
- package/src/gate-qualification-v2.ts +56 -46
- package/src/reliance-program.ts +499 -0
- package/src/remedy-program.ts +28 -17
|
@@ -0,0 +1,499 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
/**
|
|
3
|
+
* Customer-owned Reliance Program source and deterministic compiler.
|
|
4
|
+
*
|
|
5
|
+
* The signed source is the relying party's policy artifact. Compilation emits
|
|
6
|
+
* the existing Gate Trust Program wire format; it does not create another
|
|
7
|
+
* authorization engine or let a presenter select the acceptance bar.
|
|
8
|
+
*/
|
|
9
|
+
import crypto from 'node:crypto';
|
|
10
|
+
import { canonicalize, hashCanonical } from './execution-binding.js';
|
|
11
|
+
import {
|
|
12
|
+
TRUST_PROGRAM_VERSION,
|
|
13
|
+
trustProgramDigest,
|
|
14
|
+
validateTrustProgram,
|
|
15
|
+
} from './trust-program.js';
|
|
16
|
+
import { createPinnedEvidenceAdapter } from './trust-program-adapters.js';
|
|
17
|
+
|
|
18
|
+
export const RELIANCE_PROGRAM_SOURCE_VERSION = 'EP-RELIANCE-PROGRAM-SOURCE-v1';
|
|
19
|
+
export const RELIANCE_PROGRAM_VERSION = 'EP-RELIANCE-PROGRAM-v1';
|
|
20
|
+
export const RELIANCE_PROGRAM_SIGNATURE_ALGORITHM = 'Ed25519';
|
|
21
|
+
export const RELIANCE_PROGRAM_ADMISSIBILITY_EVIDENCE = 'ep-admissibility-evaluation';
|
|
22
|
+
export const RELIANCE_PROGRAM_ADMISSIBILITY_VERIFIER = 'ep-admissibility-profile:v1';
|
|
23
|
+
|
|
24
|
+
const DOMAIN = `${RELIANCE_PROGRAM_VERSION}\0`;
|
|
25
|
+
const DIGEST = /^sha256:[0-9a-f]{64}$/;
|
|
26
|
+
const ID = /^[A-Za-z0-9][A-Za-z0-9._:@/-]{0,255}$/;
|
|
27
|
+
const TRUST_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
|
28
|
+
const CAID = /^caid:1:[a-z][a-z0-9.-]*\.[1-9][0-9]*:jcs-sha256:[A-Za-z0-9_-]{43}$/;
|
|
29
|
+
const SOURCE_KEYS = new Set([
|
|
30
|
+
'@version', 'program_id', 'version', 'relying_party', 'root_caid',
|
|
31
|
+
'action_digest', 'valid_from', 'expires_at', 'stages', 'execution',
|
|
32
|
+
]);
|
|
33
|
+
const RELYING_PARTY_KEYS = new Set(['id', 'key_id']);
|
|
34
|
+
const STAGE_KEYS = new Set(['stage_id', 'depends_on', 'rule', 'profiles']);
|
|
35
|
+
const PROFILE_REF_KEYS = new Set([
|
|
36
|
+
'profile_id', 'profile_hash', 'evaluation_max_age_sec', 'revocation_required',
|
|
37
|
+
]);
|
|
38
|
+
const EXECUTION_KEYS = new Set([
|
|
39
|
+
'depends_on', 'consequence_mode', 'capability_template_digest', 'escrow_profile_digest',
|
|
40
|
+
]);
|
|
41
|
+
const ENVELOPE_KEYS = new Set(['@version', 'source', 'source_digest', 'signature']);
|
|
42
|
+
const SIGNATURE_KEYS = new Set(['algorithm', 'key_id', 'value']);
|
|
43
|
+
const TRUSTED_SIGNER_KEYS = new Set(['relying_party_id', 'public_key']);
|
|
44
|
+
const MAX_STAGES = 64;
|
|
45
|
+
const MAX_PROFILES_PER_STAGE = 64;
|
|
46
|
+
const MAX_PROFILE_CATALOG = 1024;
|
|
47
|
+
const MAX_PROFILE_BYTES = 262_144;
|
|
48
|
+
|
|
49
|
+
type JsonRecord = Record<string, any>;
|
|
50
|
+
|
|
51
|
+
export interface CompiledRelianceProgram {
|
|
52
|
+
version: typeof RELIANCE_PROGRAM_VERSION;
|
|
53
|
+
source_digest: string;
|
|
54
|
+
relying_party_id: string;
|
|
55
|
+
program: JsonRecord;
|
|
56
|
+
program_digest: string;
|
|
57
|
+
trace: Array<{
|
|
58
|
+
stage_id: string;
|
|
59
|
+
requirement_id: string;
|
|
60
|
+
profile_id: string;
|
|
61
|
+
profile_hash: string;
|
|
62
|
+
}>;
|
|
63
|
+
claim_boundary: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface AdmissibilityProfileTrustAdapterOptions {
|
|
67
|
+
profile: JsonRecord;
|
|
68
|
+
evaluate: (profile: JsonRecord, bundle: unknown, context: {
|
|
69
|
+
now?: string | number;
|
|
70
|
+
expectedProfileHash: string;
|
|
71
|
+
}) => any | Promise<any>;
|
|
72
|
+
project: (input: {
|
|
73
|
+
evaluation: Readonly<JsonRecord>;
|
|
74
|
+
bundle: unknown;
|
|
75
|
+
}) => {
|
|
76
|
+
subjects: string[];
|
|
77
|
+
key_fingerprints: string[];
|
|
78
|
+
issued_at: string;
|
|
79
|
+
expires_at: string;
|
|
80
|
+
revocation_checked_at?: string | null;
|
|
81
|
+
} | Promise<{
|
|
82
|
+
subjects: string[];
|
|
83
|
+
key_fingerprints: string[];
|
|
84
|
+
issued_at: string;
|
|
85
|
+
expires_at: string;
|
|
86
|
+
revocation_checked_at?: string | null;
|
|
87
|
+
}>;
|
|
88
|
+
now?: string | number | (() => string | number);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export class RelianceProgramValidationError extends TypeError {
|
|
92
|
+
readonly code: string;
|
|
93
|
+
constructor(code: string, message: string) {
|
|
94
|
+
super(message);
|
|
95
|
+
this.name = 'RelianceProgramValidationError';
|
|
96
|
+
this.code = code;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function refuse(code: string, message: string): never {
|
|
101
|
+
throw new RelianceProgramValidationError(code, message);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function isRecord(value: unknown): value is JsonRecord {
|
|
105
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) return false;
|
|
106
|
+
const prototype = Object.getPrototypeOf(value);
|
|
107
|
+
return prototype === Object.prototype || prototype === null;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function isDataRecord(value: unknown): value is JsonRecord {
|
|
111
|
+
return isRecord(value) && Reflect.ownKeys(value).every((key) => {
|
|
112
|
+
if (typeof key !== 'string') return false;
|
|
113
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, key);
|
|
114
|
+
return descriptor?.enumerable === true && Object.hasOwn(descriptor, 'value');
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function exact(value: unknown, keys: Set<string>): value is JsonRecord {
|
|
119
|
+
return isDataRecord(value)
|
|
120
|
+
&& Reflect.ownKeys(value).length === keys.size
|
|
121
|
+
&& Object.keys(value).every((key) => keys.has(key));
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function canonicalCopy<T>(value: T): T {
|
|
125
|
+
return JSON.parse(canonicalize(value));
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function deepFreeze<T>(value: T): T {
|
|
129
|
+
if (!value || typeof value !== 'object' || Object.isFrozen(value)) return value;
|
|
130
|
+
Object.freeze(value);
|
|
131
|
+
for (const child of Object.values(value as JsonRecord)) deepFreeze(child);
|
|
132
|
+
return value;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function digest(value: unknown): string {
|
|
136
|
+
return `sha256:${hashCanonical(value)}`;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function strictInstant(value: unknown): number {
|
|
140
|
+
if (typeof value !== 'string'
|
|
141
|
+
|| !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?Z$/.test(value)) return NaN;
|
|
142
|
+
const parsed = Date.parse(value);
|
|
143
|
+
return Number.isFinite(parsed) ? parsed : NaN;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function exactIdentifiers(value: unknown, maximum = MAX_STAGES): value is string[] {
|
|
147
|
+
return Array.isArray(value) && value.length <= maximum
|
|
148
|
+
&& value.every((entry) => typeof entry === 'string' && TRUST_ID.test(entry))
|
|
149
|
+
&& new Set(value).size === value.length;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function validateRule(rule: unknown, profileCount: number): boolean {
|
|
153
|
+
if (!isDataRecord(rule) || !['all', 'any', 'threshold'].includes(rule.mode)) return false;
|
|
154
|
+
const keys = rule.mode === 'threshold'
|
|
155
|
+
? new Set(['mode', 'required', 'distinct_subjects', 'distinct_keys'])
|
|
156
|
+
: new Set(['mode', 'distinct_subjects', 'distinct_keys']);
|
|
157
|
+
if (!exact(rule, keys)
|
|
158
|
+
|| typeof rule.distinct_subjects !== 'boolean'
|
|
159
|
+
|| typeof rule.distinct_keys !== 'boolean') return false;
|
|
160
|
+
if (rule.mode !== 'threshold') return true;
|
|
161
|
+
return Number.isSafeInteger(rule.required) && rule.required >= 1 && rule.required <= profileCount;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function validateSource(value: unknown): asserts value is JsonRecord {
|
|
165
|
+
try { canonicalize(value); } catch {
|
|
166
|
+
refuse('source_not_canonical', 'reliance program source is not canonicalizable JSON');
|
|
167
|
+
}
|
|
168
|
+
if (!exact(value, SOURCE_KEYS) || value['@version'] !== RELIANCE_PROGRAM_SOURCE_VERSION
|
|
169
|
+
|| !exact(value.relying_party, RELYING_PARTY_KEYS)) {
|
|
170
|
+
refuse('source_schema_invalid', 'reliance program source is not a closed v1 object');
|
|
171
|
+
}
|
|
172
|
+
if (typeof value.program_id !== 'string' || !TRUST_ID.test(value.program_id)
|
|
173
|
+
|| !Number.isSafeInteger(value.version) || value.version < 1
|
|
174
|
+
|| typeof value.relying_party.id !== 'string' || !ID.test(value.relying_party.id)
|
|
175
|
+
|| typeof value.relying_party.key_id !== 'string' || !TRUST_ID.test(value.relying_party.key_id)
|
|
176
|
+
|| typeof value.root_caid !== 'string' || !CAID.test(value.root_caid)
|
|
177
|
+
|| typeof value.action_digest !== 'string' || !DIGEST.test(value.action_digest)) {
|
|
178
|
+
refuse('source_binding_invalid', 'relying party, CAID, or action binding is invalid');
|
|
179
|
+
}
|
|
180
|
+
const validFrom = strictInstant(value.valid_from);
|
|
181
|
+
const expiresAt = strictInstant(value.expires_at);
|
|
182
|
+
if (!Number.isFinite(validFrom) || !Number.isFinite(expiresAt) || expiresAt <= validFrom) {
|
|
183
|
+
refuse('source_time_window_invalid', 'reliance program validity window is invalid');
|
|
184
|
+
}
|
|
185
|
+
if (!Array.isArray(value.stages) || value.stages.length < 1 || value.stages.length > MAX_STAGES) {
|
|
186
|
+
refuse('source_stage_count_invalid', 'reliance program stage count is invalid');
|
|
187
|
+
}
|
|
188
|
+
const stageIds = new Set<string>();
|
|
189
|
+
for (const stage of value.stages) {
|
|
190
|
+
if (!exact(stage, STAGE_KEYS) || typeof stage.stage_id !== 'string'
|
|
191
|
+
|| !TRUST_ID.test(stage.stage_id) || stageIds.has(stage.stage_id)
|
|
192
|
+
|| !exactIdentifiers(stage.depends_on)
|
|
193
|
+
|| !Array.isArray(stage.profiles) || stage.profiles.length < 1
|
|
194
|
+
|| stage.profiles.length > MAX_PROFILES_PER_STAGE
|
|
195
|
+
|| !validateRule(stage.rule, stage.profiles.length)) {
|
|
196
|
+
refuse('source_stage_invalid', 'reliance program stage is invalid');
|
|
197
|
+
}
|
|
198
|
+
stageIds.add(stage.stage_id);
|
|
199
|
+
const profileIds = new Set<string>();
|
|
200
|
+
for (const reference of stage.profiles) {
|
|
201
|
+
if (!exact(reference, PROFILE_REF_KEYS)
|
|
202
|
+
|| typeof reference.profile_id !== 'string' || !ID.test(reference.profile_id)
|
|
203
|
+
|| profileIds.has(reference.profile_id)
|
|
204
|
+
|| typeof reference.profile_hash !== 'string' || !DIGEST.test(reference.profile_hash)
|
|
205
|
+
|| !Number.isSafeInteger(reference.evaluation_max_age_sec)
|
|
206
|
+
|| reference.evaluation_max_age_sec < 1 || reference.evaluation_max_age_sec > 31_536_000
|
|
207
|
+
|| typeof reference.revocation_required !== 'boolean') {
|
|
208
|
+
refuse('source_profile_reference_invalid', 'admissibility profile reference is invalid');
|
|
209
|
+
}
|
|
210
|
+
profileIds.add(reference.profile_id);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
if (!exact(value.execution, EXECUTION_KEYS)
|
|
214
|
+
|| !exactIdentifiers(value.execution.depends_on)
|
|
215
|
+
|| value.execution.depends_on.length < 1
|
|
216
|
+
|| !['receipt-program', 'action-escrow'].includes(value.execution.consequence_mode)
|
|
217
|
+
|| (value.execution.consequence_mode === 'receipt-program'
|
|
218
|
+
&& (!DIGEST.test(value.execution.capability_template_digest)
|
|
219
|
+
|| value.execution.escrow_profile_digest !== null))
|
|
220
|
+
|| (value.execution.consequence_mode === 'action-escrow'
|
|
221
|
+
&& (!DIGEST.test(value.execution.escrow_profile_digest)
|
|
222
|
+
|| value.execution.capability_template_digest !== null))) {
|
|
223
|
+
refuse('source_execution_invalid', 'reliance program consequence owner is invalid');
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function signingBytes(source: JsonRecord): Buffer {
|
|
228
|
+
return Buffer.concat([
|
|
229
|
+
Buffer.from(DOMAIN, 'utf8'),
|
|
230
|
+
Buffer.from(canonicalize(source), 'utf8'),
|
|
231
|
+
]);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function publicKey(value: unknown): crypto.KeyObject | null {
|
|
235
|
+
try {
|
|
236
|
+
if (value instanceof crypto.KeyObject) {
|
|
237
|
+
const key = value.type === 'public' ? value : crypto.createPublicKey(value as any);
|
|
238
|
+
return key.asymmetricKeyType === 'ed25519' ? key : null;
|
|
239
|
+
}
|
|
240
|
+
if (typeof value !== 'string' || !/^[A-Za-z0-9_-]+$/.test(value)) return null;
|
|
241
|
+
const bytes = Buffer.from(value, 'base64url');
|
|
242
|
+
if (bytes.toString('base64url') !== value) return null;
|
|
243
|
+
const key = crypto.createPublicKey({ key: bytes, type: 'spki', format: 'der' });
|
|
244
|
+
return key.asymmetricKeyType === 'ed25519' ? key : null;
|
|
245
|
+
} catch { return null; }
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
export function relianceProgramSourceDigest(source: unknown): string {
|
|
249
|
+
validateSource(source);
|
|
250
|
+
return digest(source);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
export function signRelianceProgram(source: unknown, privateKey: crypto.KeyLike): JsonRecord {
|
|
254
|
+
validateSource(source);
|
|
255
|
+
const key = privateKey instanceof crypto.KeyObject
|
|
256
|
+
? privateKey : crypto.createPrivateKey(privateKey as any);
|
|
257
|
+
if (key.asymmetricKeyType !== 'ed25519') {
|
|
258
|
+
refuse('signing_key_invalid', 'reliance program signing key must be Ed25519');
|
|
259
|
+
}
|
|
260
|
+
const frozenSource = canonicalCopy(source);
|
|
261
|
+
const signature = crypto.sign(null, signingBytes(frozenSource), key).toString('base64url');
|
|
262
|
+
return deepFreeze({
|
|
263
|
+
'@version': RELIANCE_PROGRAM_VERSION,
|
|
264
|
+
source: frozenSource,
|
|
265
|
+
source_digest: digest(frozenSource),
|
|
266
|
+
signature: {
|
|
267
|
+
algorithm: RELIANCE_PROGRAM_SIGNATURE_ALGORITHM,
|
|
268
|
+
key_id: frozenSource.relying_party.key_id,
|
|
269
|
+
value: signature,
|
|
270
|
+
},
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
export function verifyRelianceProgram(
|
|
275
|
+
envelope: unknown,
|
|
276
|
+
{ trustedKeys = {} }: { trustedKeys?: Record<string, unknown> } = {},
|
|
277
|
+
): JsonRecord {
|
|
278
|
+
if (!exact(envelope, ENVELOPE_KEYS) || envelope['@version'] !== RELIANCE_PROGRAM_VERSION
|
|
279
|
+
|| !exact(envelope.signature, SIGNATURE_KEYS)
|
|
280
|
+
|| envelope.signature.algorithm !== RELIANCE_PROGRAM_SIGNATURE_ALGORITHM
|
|
281
|
+
|| typeof envelope.source_digest !== 'string' || !DIGEST.test(envelope.source_digest)
|
|
282
|
+
|| typeof envelope.signature.key_id !== 'string'
|
|
283
|
+
|| typeof envelope.signature.value !== 'string'
|
|
284
|
+
|| !/^[A-Za-z0-9_-]+$/.test(envelope.signature.value)
|
|
285
|
+
|| Buffer.from(envelope.signature.value, 'base64url').length !== 64
|
|
286
|
+
|| Buffer.from(envelope.signature.value, 'base64url').toString('base64url') !== envelope.signature.value) {
|
|
287
|
+
return { valid: false, reason: 'envelope_schema_invalid', source_digest: null };
|
|
288
|
+
}
|
|
289
|
+
try { validateSource(envelope.source); } catch (error) {
|
|
290
|
+
return { valid: false, reason: error instanceof RelianceProgramValidationError
|
|
291
|
+
? error.code : 'source_schema_invalid', source_digest: null };
|
|
292
|
+
}
|
|
293
|
+
if (envelope.signature.key_id !== envelope.source.relying_party.key_id) {
|
|
294
|
+
return { valid: false, reason: 'signature_key_mismatch', source_digest: envelope.source_digest };
|
|
295
|
+
}
|
|
296
|
+
const computed = digest(envelope.source);
|
|
297
|
+
if (computed !== envelope.source_digest) {
|
|
298
|
+
return { valid: false, reason: 'source_digest_mismatch', source_digest: computed };
|
|
299
|
+
}
|
|
300
|
+
if (!isRecord(trustedKeys) || !Object.hasOwn(trustedKeys, envelope.signature.key_id)) {
|
|
301
|
+
return { valid: false, reason: 'relying_party_key_untrusted', source_digest: computed };
|
|
302
|
+
}
|
|
303
|
+
const signer = trustedKeys[envelope.signature.key_id];
|
|
304
|
+
if (!exact(signer, TRUSTED_SIGNER_KEYS)
|
|
305
|
+
|| signer.relying_party_id !== envelope.source.relying_party.id) {
|
|
306
|
+
return { valid: false, reason: 'relying_party_identity_mismatch', source_digest: computed };
|
|
307
|
+
}
|
|
308
|
+
const key = publicKey(signer.public_key);
|
|
309
|
+
if (!key) {
|
|
310
|
+
return { valid: false, reason: 'relying_party_key_untrusted', source_digest: computed };
|
|
311
|
+
}
|
|
312
|
+
const valid = crypto.verify(
|
|
313
|
+
null,
|
|
314
|
+
signingBytes(envelope.source),
|
|
315
|
+
key,
|
|
316
|
+
Buffer.from(envelope.signature.value, 'base64url'),
|
|
317
|
+
);
|
|
318
|
+
if (!valid) return { valid: false, reason: 'signature_invalid', source_digest: computed };
|
|
319
|
+
return {
|
|
320
|
+
valid: true,
|
|
321
|
+
reason: null,
|
|
322
|
+
source_digest: computed,
|
|
323
|
+
relying_party_id: envelope.source.relying_party.id,
|
|
324
|
+
key_id: envelope.signature.key_id,
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function profileMap(profiles: unknown): Map<string, JsonRecord> {
|
|
329
|
+
if (!Array.isArray(profiles) || profiles.length > MAX_PROFILE_CATALOG) {
|
|
330
|
+
refuse('profile_catalog_invalid', 'profile catalog must be a bounded array');
|
|
331
|
+
}
|
|
332
|
+
const result = new Map<string, JsonRecord>();
|
|
333
|
+
for (const profile of profiles) {
|
|
334
|
+
if (!isDataRecord(profile) || typeof profile.id !== 'string' || !ID.test(profile.id)
|
|
335
|
+
|| typeof profile.profile_hash !== 'string' || !DIGEST.test(profile.profile_hash)
|
|
336
|
+
|| result.has(profile.id)) {
|
|
337
|
+
refuse('profile_catalog_invalid', 'profile catalog contains an invalid or duplicate entry');
|
|
338
|
+
}
|
|
339
|
+
const { profile_hash: profileHash, ...profileBody } = profile;
|
|
340
|
+
let computed: string;
|
|
341
|
+
try {
|
|
342
|
+
const canonical = canonicalize(profileBody);
|
|
343
|
+
if (Buffer.byteLength(canonical, 'utf8') > MAX_PROFILE_BYTES) {
|
|
344
|
+
refuse('profile_catalog_invalid', `profile ${profile.id} exceeds the size limit`);
|
|
345
|
+
}
|
|
346
|
+
computed = `sha256:${crypto.createHash('sha256').update(canonical, 'utf8').digest('hex')}`;
|
|
347
|
+
} catch (error) {
|
|
348
|
+
if (error instanceof RelianceProgramValidationError) throw error;
|
|
349
|
+
refuse('profile_integrity_invalid', `profile ${profile.id} is not canonicalizable`);
|
|
350
|
+
}
|
|
351
|
+
if (computed !== profileHash) {
|
|
352
|
+
refuse('profile_integrity_invalid', `profile ${profile.id} self-hash does not match its content`);
|
|
353
|
+
}
|
|
354
|
+
result.set(profile.id, profile);
|
|
355
|
+
}
|
|
356
|
+
return result;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function verifiedProfile(profile: unknown): JsonRecord {
|
|
360
|
+
const catalog = profileMap([profile]);
|
|
361
|
+
return catalog.values().next().value as JsonRecord;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* Adapt the existing Admissibility Profile evaluator into one constructor-
|
|
366
|
+
* pinned Trust Program verifier. The runtime artifact supplies evidence only;
|
|
367
|
+
* the profile, evaluator, projection, and clock are all relying-party owned.
|
|
368
|
+
*/
|
|
369
|
+
export function createAdmissibilityProfileTrustAdapter({
|
|
370
|
+
profile,
|
|
371
|
+
evaluate,
|
|
372
|
+
project,
|
|
373
|
+
now,
|
|
374
|
+
}: AdmissibilityProfileTrustAdapterOptions) {
|
|
375
|
+
const pinnedProfile = canonicalCopy(verifiedProfile(profile));
|
|
376
|
+
if (typeof evaluate !== 'function' || typeof project !== 'function') {
|
|
377
|
+
refuse('admissibility_adapter_invalid', 'admissibility evaluator and projection are required');
|
|
378
|
+
}
|
|
379
|
+
const profileHash = pinnedProfile.profile_hash;
|
|
380
|
+
return createPinnedEvidenceAdapter({
|
|
381
|
+
policyDigest: profileHash,
|
|
382
|
+
trustedConfiguration: {
|
|
383
|
+
profile_id: pinnedProfile.id,
|
|
384
|
+
profile_hash: profileHash,
|
|
385
|
+
},
|
|
386
|
+
verify: async (evidence, context) => {
|
|
387
|
+
if (!exact(evidence, new Set(['bundle']))) {
|
|
388
|
+
return { valid: false, reason: 'admissibility_evidence_schema_invalid' };
|
|
389
|
+
}
|
|
390
|
+
const evaluationTime = typeof now === 'function' ? now() : now;
|
|
391
|
+
let evaluation: any;
|
|
392
|
+
try {
|
|
393
|
+
evaluation = await evaluate(pinnedProfile, evidence.bundle, {
|
|
394
|
+
...(evaluationTime === undefined ? {} : { now: evaluationTime }),
|
|
395
|
+
expectedProfileHash: profileHash,
|
|
396
|
+
});
|
|
397
|
+
} catch {
|
|
398
|
+
return { valid: false, reason: 'admissibility_evaluation_failed' };
|
|
399
|
+
}
|
|
400
|
+
if (!isDataRecord(evaluation)
|
|
401
|
+
|| evaluation.profile_hash !== profileHash
|
|
402
|
+
|| evaluation.verdict !== 'admissible') {
|
|
403
|
+
const verdict = typeof evaluation?.verdict === 'string'
|
|
404
|
+
&& /^[a-z_]{1,64}$/.test(evaluation.verdict)
|
|
405
|
+
? evaluation.verdict : 'unverifiable';
|
|
406
|
+
return { valid: false, reason: `admissibility_${verdict}` };
|
|
407
|
+
}
|
|
408
|
+
let projection: any;
|
|
409
|
+
try {
|
|
410
|
+
projection = await project({
|
|
411
|
+
evaluation: Object.freeze(canonicalCopy(evaluation)),
|
|
412
|
+
bundle: evidence.bundle,
|
|
413
|
+
});
|
|
414
|
+
} catch {
|
|
415
|
+
return { valid: false, reason: 'admissibility_projection_failed' };
|
|
416
|
+
}
|
|
417
|
+
if (!isDataRecord(projection)) {
|
|
418
|
+
return { valid: false, reason: 'admissibility_projection_invalid' };
|
|
419
|
+
}
|
|
420
|
+
return {
|
|
421
|
+
valid: true,
|
|
422
|
+
binding_digest: context.expectedBindingDigest,
|
|
423
|
+
policy_digest: profileHash,
|
|
424
|
+
subjects: projection.subjects,
|
|
425
|
+
key_fingerprints: projection.key_fingerprints,
|
|
426
|
+
issued_at: projection.issued_at,
|
|
427
|
+
expires_at: projection.expires_at,
|
|
428
|
+
revocation_checked_at: projection.revocation_checked_at ?? null,
|
|
429
|
+
};
|
|
430
|
+
},
|
|
431
|
+
});
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
export function compileRelianceProgram(
|
|
435
|
+
envelope: unknown,
|
|
436
|
+
{ trustedKeys = {}, profiles = [] }: {
|
|
437
|
+
trustedKeys?: Record<string, unknown>;
|
|
438
|
+
profiles?: unknown[];
|
|
439
|
+
} = {},
|
|
440
|
+
): CompiledRelianceProgram {
|
|
441
|
+
const verified = verifyRelianceProgram(envelope, { trustedKeys });
|
|
442
|
+
if (verified.valid !== true) {
|
|
443
|
+
refuse(verified.reason ?? 'source_unverified', 'reliance program source did not verify');
|
|
444
|
+
}
|
|
445
|
+
const signed = envelope as JsonRecord;
|
|
446
|
+
const source = canonicalCopy(signed.source);
|
|
447
|
+
const catalog = profileMap(profiles);
|
|
448
|
+
const trace: CompiledRelianceProgram['trace'] = [];
|
|
449
|
+
const stages = source.stages.map((stage: JsonRecord) => ({
|
|
450
|
+
stage_id: stage.stage_id,
|
|
451
|
+
depends_on: [...stage.depends_on],
|
|
452
|
+
rule: canonicalCopy(stage.rule),
|
|
453
|
+
requirements: stage.profiles.map((reference: JsonRecord, index: number) => {
|
|
454
|
+
const profile = catalog.get(reference.profile_id);
|
|
455
|
+
if (!profile || profile.profile_hash !== reference.profile_hash) {
|
|
456
|
+
refuse('profile_pin_mismatch', `profile ${reference.profile_id} does not match the relying-party pin`);
|
|
457
|
+
}
|
|
458
|
+
const requirementId = `admissibility-${String(index + 1).padStart(2, '0')}`;
|
|
459
|
+
trace.push({
|
|
460
|
+
stage_id: stage.stage_id,
|
|
461
|
+
requirement_id: requirementId,
|
|
462
|
+
profile_id: reference.profile_id,
|
|
463
|
+
profile_hash: reference.profile_hash,
|
|
464
|
+
});
|
|
465
|
+
return {
|
|
466
|
+
requirement_id: requirementId,
|
|
467
|
+
evidence_type: RELIANCE_PROGRAM_ADMISSIBILITY_EVIDENCE,
|
|
468
|
+
verifier_profile: RELIANCE_PROGRAM_ADMISSIBILITY_VERIFIER,
|
|
469
|
+
policy_digest: reference.profile_hash,
|
|
470
|
+
max_age_sec: reference.evaluation_max_age_sec,
|
|
471
|
+
revocation_required: reference.revocation_required,
|
|
472
|
+
};
|
|
473
|
+
}),
|
|
474
|
+
}));
|
|
475
|
+
const program = {
|
|
476
|
+
'@version': TRUST_PROGRAM_VERSION,
|
|
477
|
+
program_id: source.program_id,
|
|
478
|
+
version: source.version,
|
|
479
|
+
root_caid: source.root_caid,
|
|
480
|
+
action_digest: source.action_digest,
|
|
481
|
+
valid_from: source.valid_from,
|
|
482
|
+
expires_at: source.expires_at,
|
|
483
|
+
stages,
|
|
484
|
+
execution: canonicalCopy(source.execution),
|
|
485
|
+
};
|
|
486
|
+
const checked = validateTrustProgram(program);
|
|
487
|
+
if (!checked.valid) {
|
|
488
|
+
refuse('compiled_program_invalid', `compiled Trust Program is invalid: ${checked.reason}`);
|
|
489
|
+
}
|
|
490
|
+
return deepFreeze({
|
|
491
|
+
version: RELIANCE_PROGRAM_VERSION,
|
|
492
|
+
source_digest: verified.source_digest,
|
|
493
|
+
relying_party_id: verified.relying_party_id,
|
|
494
|
+
program,
|
|
495
|
+
program_digest: trustProgramDigest(program),
|
|
496
|
+
trace,
|
|
497
|
+
claim_boundary: 'Compilation proves a pinned RP program maps to the existing Trust Program; it does not prove evidence sufficiency, authorization, or execution.',
|
|
498
|
+
});
|
|
499
|
+
}
|
package/src/remedy-program.ts
CHANGED
|
@@ -1035,61 +1035,70 @@ export function createRemedyProgramKernel(options: RemedyProgramKernelConfig) {
|
|
|
1035
1035
|
const loaded = await load(value.tenantId, value.instanceId);
|
|
1036
1036
|
if (!loaded.ok) return loaded;
|
|
1037
1037
|
const state = loaded.state as DataRecord;
|
|
1038
|
+
const decided = (ok: boolean, reason?: string, fields: DataRecord = {}) => pass({
|
|
1039
|
+
ok,
|
|
1040
|
+
...(reason ? { reason } : {}),
|
|
1041
|
+
program_digest: state.remedy_profile_digest,
|
|
1042
|
+
...fields,
|
|
1043
|
+
}) as RemedyProgramResult;
|
|
1038
1044
|
const requestDigest = digest(value);
|
|
1039
|
-
if (state.status === 'original_proved_no_effect') return
|
|
1045
|
+
if (state.status === 'original_proved_no_effect') return decided(false, 'remedy_case_terminal');
|
|
1040
1046
|
if (state.original.outcome === 'indeterminate'
|
|
1041
1047
|
&& state.original_reconciliation?.outcome !== 'executed') {
|
|
1042
|
-
return
|
|
1048
|
+
return decided(false, 'original_effect_indeterminate');
|
|
1043
1049
|
}
|
|
1044
1050
|
const priorOperation = allAttempts(state).find((attempt) => (
|
|
1045
1051
|
attempt.remedy_operation_id === value.authorization.remedy_operation_id
|
|
1046
1052
|
));
|
|
1047
1053
|
if (priorOperation) {
|
|
1048
1054
|
return priorOperation.request_digest === requestDigest
|
|
1049
|
-
?
|
|
1050
|
-
:
|
|
1055
|
+
? decided(true, undefined, { idempotent: true, state })
|
|
1056
|
+
: decided(false, 'remedy_operation_replayed');
|
|
1057
|
+
}
|
|
1058
|
+
if (state.remaining_units === 0) return decided(false, 'remedy_limit_exhausted');
|
|
1059
|
+
if (['remedied', 'resolved_no_remedy'].includes(state.status)) {
|
|
1060
|
+
return decided(false, 'remedy_case_terminal');
|
|
1051
1061
|
}
|
|
1052
|
-
if (state.remaining_units === 0) return fail('remedy_limit_exhausted');
|
|
1053
|
-
if (['remedied', 'resolved_no_remedy'].includes(state.status)) return fail('remedy_case_terminal');
|
|
1054
1062
|
if (state.active_remedy !== null) {
|
|
1055
1063
|
return state.status === 'remedy_indeterminate'
|
|
1056
|
-
?
|
|
1064
|
+
? decided(false, 'remedy_indeterminate')
|
|
1065
|
+
: decided(false, 'remedy_already_active');
|
|
1057
1066
|
}
|
|
1058
1067
|
if (!['disputed', 'partially_remedied'].includes(state.status) || state.dispute === null) {
|
|
1059
|
-
return
|
|
1068
|
+
return decided(false, 'remedy_case_unavailable');
|
|
1060
1069
|
}
|
|
1061
1070
|
if (evidenceUsed(state, value.authorization.evidence_id, value.authorization.evidence_digest)) {
|
|
1062
|
-
return
|
|
1071
|
+
return decided(false, 'evidence_replayed');
|
|
1063
1072
|
}
|
|
1064
1073
|
const at = transitionTime(state);
|
|
1065
|
-
if (typeof at !== 'number') return at;
|
|
1074
|
+
if (typeof at !== 'number') return decided(false, at.reason);
|
|
1066
1075
|
if (instant(value.authorization.authorized_at) > at
|
|
1067
1076
|
|| instant(value.authorization.authorized_at) < instant(state.dispute.opened_at)) {
|
|
1068
|
-
return
|
|
1077
|
+
return decided(false, 'remedy_authorization_time_invalid');
|
|
1069
1078
|
}
|
|
1070
1079
|
const disputeRemaining = state.dispute.requested_units - state.remedied_units;
|
|
1071
1080
|
if (value.authorization.units > state.remaining_units || value.authorization.units > disputeRemaining) {
|
|
1072
|
-
return
|
|
1081
|
+
return decided(false, 'remedy_limit_exceeded');
|
|
1073
1082
|
}
|
|
1074
1083
|
if (value.authorization.remedy_operation_id === state.original.operation_id
|
|
1075
1084
|
|| value.authorization.remedy_action_digest === state.original.action_digest) {
|
|
1076
|
-
return
|
|
1085
|
+
return decided(false, 'remedy_must_be_compensating');
|
|
1077
1086
|
}
|
|
1078
1087
|
const verified = await invoke(verifyRemedyAuthorization, {
|
|
1079
1088
|
authorization: clone(value.authorization), expected: expectedContext(state),
|
|
1080
1089
|
});
|
|
1081
1090
|
if (!verified || verified.ok !== true || !exactKeys(verified, VERIFIED_AUTHORIZATION_KEYS)) {
|
|
1082
|
-
return
|
|
1091
|
+
return decided(false, 'remedy_authorization_invalid');
|
|
1083
1092
|
}
|
|
1084
1093
|
if (!validRemedyOwner(verified as DataRecord)) {
|
|
1085
|
-
return
|
|
1094
|
+
return decided(false, 'remedy_owner_invalid');
|
|
1086
1095
|
}
|
|
1087
1096
|
if ([...AUTHORIZATION_KEYS].some((key) => !same(verified[key], value.authorization[key]))
|
|
1088
1097
|
|| verified.dispute_id !== state.dispute.dispute_id
|
|
1089
1098
|
|| verified.original_operation_id !== state.original.operation_id
|
|
1090
1099
|
|| verified.destination_binding_digest !== state.destination_binding_digest
|
|
1091
1100
|
|| verified.unit !== state.unit) {
|
|
1092
|
-
return
|
|
1101
|
+
return decided(false, 'remedy_authorization_binding_mismatch');
|
|
1093
1102
|
}
|
|
1094
1103
|
const next = touch(state, at);
|
|
1095
1104
|
next.status = 'remedy_authorized';
|
|
@@ -1108,7 +1117,9 @@ export function createRemedyProgramKernel(options: RemedyProgramKernelConfig) {
|
|
|
1108
1117
|
};
|
|
1109
1118
|
consumeEvidence(next, verified.evidence_id, verified.evidence_digest);
|
|
1110
1119
|
const committed = await commit(state, next);
|
|
1111
|
-
return committed.ok
|
|
1120
|
+
return committed.ok
|
|
1121
|
+
? decided(true, undefined, { state: committed.state })
|
|
1122
|
+
: decided(false, committed.reason);
|
|
1112
1123
|
}
|
|
1113
1124
|
|
|
1114
1125
|
async function claimRemedy(input: unknown): Promise<RemedyProgramResult> {
|