@emilia-protocol/gate 0.19.0 → 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.
Files changed (55) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/README.md +22 -0
  3. package/action-refusal-statement.js +1 -0
  4. package/coverage-reconciliation-attestation.js +1 -0
  5. package/dist/action-refusal-statement.d.ts +109 -0
  6. package/dist/action-refusal-statement.d.ts.map +1 -0
  7. package/dist/action-refusal-statement.js +333 -0
  8. package/dist/action-refusal-statement.js.map +1 -0
  9. package/dist/coverage-reconciliation-attestation.d.ts +29 -0
  10. package/dist/coverage-reconciliation-attestation.d.ts.map +1 -0
  11. package/dist/coverage-reconciliation-attestation.js +111 -0
  12. package/dist/coverage-reconciliation-attestation.js.map +1 -0
  13. package/dist/index.d.ts +7 -0
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +7 -0
  16. package/dist/index.js.map +1 -1
  17. package/dist/loss-allocation-schedule.d.ts +53 -0
  18. package/dist/loss-allocation-schedule.d.ts.map +1 -0
  19. package/dist/loss-allocation-schedule.js +351 -0
  20. package/dist/loss-allocation-schedule.js.map +1 -0
  21. package/dist/loss-experience-feed.d.ts +82 -0
  22. package/dist/loss-experience-feed.d.ts.map +1 -0
  23. package/dist/loss-experience-feed.js +314 -0
  24. package/dist/loss-experience-feed.js.map +1 -0
  25. package/dist/open-exposure-ledger-postgres.d.ts +40 -0
  26. package/dist/open-exposure-ledger-postgres.d.ts.map +1 -0
  27. package/dist/open-exposure-ledger-postgres.js +577 -0
  28. package/dist/open-exposure-ledger-postgres.js.map +1 -0
  29. package/dist/open-exposure-ledger.d.ts +243 -0
  30. package/dist/open-exposure-ledger.d.ts.map +1 -0
  31. package/dist/open-exposure-ledger.js +794 -0
  32. package/dist/open-exposure-ledger.js.map +1 -0
  33. package/dist/receipt-census.d.ts +21 -0
  34. package/dist/receipt-census.d.ts.map +1 -0
  35. package/dist/receipt-census.js +162 -0
  36. package/dist/receipt-census.js.map +1 -0
  37. package/dist/reliance-risk-crypto.d.ts +28 -0
  38. package/dist/reliance-risk-crypto.d.ts.map +1 -0
  39. package/dist/reliance-risk-crypto.js +110 -0
  40. package/dist/reliance-risk-crypto.js.map +1 -0
  41. package/loss-allocation-schedule.js +1 -0
  42. package/loss-experience-feed.js +1 -0
  43. package/open-exposure-ledger-postgres.js +1 -0
  44. package/open-exposure-ledger.js +1 -0
  45. package/package.json +36 -1
  46. package/receipt-census.js +1 -0
  47. package/src/action-refusal-statement.ts +396 -0
  48. package/src/coverage-reconciliation-attestation.ts +107 -0
  49. package/src/index.ts +7 -0
  50. package/src/loss-allocation-schedule.ts +427 -0
  51. package/src/loss-experience-feed.ts +398 -0
  52. package/src/open-exposure-ledger-postgres.ts +701 -0
  53. package/src/open-exposure-ledger.ts +1213 -0
  54. package/src/receipt-census.ts +163 -0
  55. package/src/reliance-risk-crypto.ts +114 -0
@@ -0,0 +1,163 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ /** Governed-taxonomy aggregate receipt census with coarse primary suppression. */
3
+ import { RISK_DIGEST, riskDigest, riskExact, riskIdentifier, riskInstant, riskRecord, riskFreeze, type RiskRecord } from './reliance-risk-crypto.js';
4
+
5
+ export const RECEIPT_CENSUS_VERSION = 'EP-RECEIPT-CENSUS-v1';
6
+ export const RECEIPT_CENSUS_CLAIM_BOUNDARY = 'aggregate_observation_with_primary_suppression_not_differential_privacy_or_identifier_detection_not_causation_coverage_legal_liability_adjudication_solvency_payment_population_completeness_or_authorization';
7
+ const PERIOD_KEYS = ['start', 'end'] as const;
8
+ const PROGRAM_KEYS = ['program_id', 'version', 'source_digest', 'program_digest'] as const;
9
+ const BUCKET_KEYS = ['action_class', 'program_version', 'outcome', 'count', 'open_exposure_amount_minor', 'reported_loss_amount_minor', 'currency'] as const;
10
+ const BODY_KEYS = ['@version', 'census_id', 'relying_party_id', 'period', 'program', 'taxonomy_digest', 'minimum_bucket_count', 'buckets', 'suppressed', 'source_inventory_digest', 'generated_at', 'claim_boundary'] as const;
11
+
12
+ export interface ReceiptCensusTaxonomy {
13
+ taxonomy_id: string;
14
+ allowed_action_classes: readonly string[];
15
+ allowed_outcomes: readonly string[];
16
+ }
17
+
18
+ const utf8 = new TextEncoder();
19
+
20
+ function utf8ByteCompare(left: string, right: string): number {
21
+ const a = utf8.encode(left);
22
+ const b = utf8.encode(right);
23
+ const length = Math.min(a.length, b.length);
24
+ for (let index = 0; index < length; index += 1) {
25
+ if (a[index] !== b[index]) return a[index] - b[index];
26
+ }
27
+ return a.length - b.length;
28
+ }
29
+
30
+ function normalizeTaxonomy(value: unknown): RiskRecord {
31
+ if (!riskExact(value, ['taxonomy_id', 'allowed_action_classes', 'allowed_outcomes'])
32
+ || !riskIdentifier(value.taxonomy_id)
33
+ || !Array.isArray(value.allowed_action_classes) || value.allowed_action_classes.length < 1
34
+ || value.allowed_action_classes.length > 10_000
35
+ || !value.allowed_action_classes.every(riskIdentifier)
36
+ || new Set(value.allowed_action_classes).size !== value.allowed_action_classes.length
37
+ || !Array.isArray(value.allowed_outcomes) || value.allowed_outcomes.length < 1
38
+ || value.allowed_outcomes.length > 1_000
39
+ || !value.allowed_outcomes.every(riskIdentifier)
40
+ || new Set(value.allowed_outcomes).size !== value.allowed_outcomes.length) {
41
+ throw new TypeError('receipt census taxonomy is invalid');
42
+ }
43
+ return {
44
+ taxonomy_id: value.taxonomy_id,
45
+ allowed_action_classes: [...value.allowed_action_classes].sort(utf8ByteCompare),
46
+ allowed_outcomes: [...value.allowed_outcomes].sort(utf8ByteCompare),
47
+ };
48
+ }
49
+
50
+ export function receiptCensusTaxonomyDigest(taxonomy: ReceiptCensusTaxonomy): string {
51
+ return riskDigest(normalizeTaxonomy(taxonomy));
52
+ }
53
+
54
+ function money(value: unknown): value is string { return typeof value === 'string' && /^(?:0|[1-9][0-9]{0,39})$/.test(value); }
55
+ function validProgram(value: unknown): value is RiskRecord {
56
+ return riskExact(value, PROGRAM_KEYS) && riskIdentifier(value.program_id)
57
+ && Number.isSafeInteger(value.version) && value.version >= 1
58
+ && RISK_DIGEST.test(value.source_digest) && RISK_DIGEST.test(value.program_digest);
59
+ }
60
+ function validBucket(value: unknown): value is RiskRecord {
61
+ return riskExact(value, BUCKET_KEYS) && riskIdentifier(value.action_class)
62
+ && Number.isSafeInteger(value.program_version) && value.program_version >= 1
63
+ && riskIdentifier(value.outcome) && Number.isSafeInteger(value.count) && value.count >= 0
64
+ && money(value.open_exposure_amount_minor) && money(value.reported_loss_amount_minor)
65
+ && typeof value.currency === 'string' && /^[A-Z]{3}$/.test(value.currency);
66
+ }
67
+
68
+ function bucketKey(bucket: RiskRecord): string {
69
+ return JSON.stringify([
70
+ bucket.action_class,
71
+ bucket.program_version,
72
+ bucket.outcome,
73
+ bucket.currency,
74
+ ]);
75
+ }
76
+
77
+ function uniqueSortedBuckets(buckets: RiskRecord[]): boolean {
78
+ let prior: string | null = null;
79
+ for (const bucket of buckets) {
80
+ const current = bucketKey(bucket);
81
+ if (prior !== null && utf8ByteCompare(prior, current) >= 0) return false;
82
+ prior = current;
83
+ }
84
+ return true;
85
+ }
86
+
87
+ export function createReceiptCensus(input: RiskRecord, taxonomy: ReceiptCensusTaxonomy): RiskRecord {
88
+ const normalizedTaxonomy = normalizeTaxonomy(taxonomy);
89
+ const allowedActionClasses = new Set(normalizedTaxonomy.allowed_action_classes as string[]);
90
+ const allowedOutcomes = new Set(normalizedTaxonomy.allowed_outcomes as string[]);
91
+ const allowedInput = ['census_id', 'relying_party_id', 'period', 'program', 'minimum_bucket_count', 'buckets', 'source_inventory_digest', 'generated_at'];
92
+ if (!riskExact(input, allowedInput) || !riskIdentifier(input.census_id) || !riskIdentifier(input.relying_party_id)
93
+ || !riskExact(input.period, PERIOD_KEYS) || !validProgram(input.program)
94
+ || !Number.isSafeInteger(input.minimum_bucket_count) || input.minimum_bucket_count < 2
95
+ || input.minimum_bucket_count > 1000 || !Array.isArray(input.buckets) || input.buckets.length > 10_000
96
+ || !input.buckets.every(validBucket) || !RISK_DIGEST.test(input.source_inventory_digest)
97
+ || !Number.isFinite(riskInstant(input.generated_at))) throw new TypeError('receipt census input or bucket fields are invalid');
98
+ if (!input.buckets.every((bucket: RiskRecord) => allowedActionClasses.has(bucket.action_class)
99
+ && allowedOutcomes.has(bucket.outcome) && bucket.program_version === input.program.version)) {
100
+ throw new TypeError('receipt census bucket is outside the relying-party taxonomy');
101
+ }
102
+ const start = riskInstant(input.period.start); const end = riskInstant(input.period.end);
103
+ if (!Number.isFinite(start) || !Number.isFinite(end) || end <= start || riskInstant(input.generated_at) < end) {
104
+ throw new TypeError('receipt census period is invalid');
105
+ }
106
+ const keys = input.buckets.map((bucket: RiskRecord) => bucketKey(bucket));
107
+ if (new Set(keys).size !== keys.length) throw new TypeError('receipt census contains duplicate bucket identities');
108
+ const visible = input.buckets
109
+ .filter((bucket: RiskRecord) => bucket.count >= input.minimum_bucket_count)
110
+ .map((bucket: RiskRecord) => ({ ...bucket }))
111
+ .sort((left: RiskRecord, right: RiskRecord) => utf8ByteCompare(bucketKey(left), bucketKey(right)));
112
+ const hidden = input.buckets.filter((bucket: RiskRecord) => bucket.count < input.minimum_bucket_count);
113
+ const body = {
114
+ '@version': RECEIPT_CENSUS_VERSION,
115
+ census_id: input.census_id,
116
+ relying_party_id: input.relying_party_id,
117
+ period: input.period,
118
+ program: input.program,
119
+ taxonomy_digest: riskDigest(normalizedTaxonomy),
120
+ minimum_bucket_count: input.minimum_bucket_count,
121
+ buckets: visible,
122
+ suppressed: { bucket_count: hidden.length, record_count: hidden.reduce((sum: number, bucket: RiskRecord) => sum + bucket.count, 0) },
123
+ source_inventory_digest: input.source_inventory_digest,
124
+ generated_at: input.generated_at,
125
+ claim_boundary: RECEIPT_CENSUS_CLAIM_BOUNDARY,
126
+ };
127
+ return riskFreeze({ ...body, census_digest: riskDigest(body) });
128
+ }
129
+
130
+ export function validateReceiptCensus(value: unknown, taxonomy?: ReceiptCensusTaxonomy) {
131
+ try {
132
+ if (taxonomy === undefined) return { valid: false, reason: 'census_taxonomy_required' };
133
+ const normalizedTaxonomy = normalizeTaxonomy(taxonomy);
134
+ const taxonomyDigest = riskDigest(normalizedTaxonomy);
135
+ const allowedActionClasses = new Set(normalizedTaxonomy.allowed_action_classes as string[]);
136
+ const allowedOutcomes = new Set(normalizedTaxonomy.allowed_outcomes as string[]);
137
+ if (!riskRecord(value) || !Object.hasOwn(value, 'census_digest')) return { valid: false, reason: 'census_shape_invalid' };
138
+ const { census_digest: supplied, ...body } = value;
139
+ if (!riskExact(body, BODY_KEYS) || body['@version'] !== RECEIPT_CENSUS_VERSION
140
+ || !riskIdentifier(body.census_id) || !riskIdentifier(body.relying_party_id)
141
+ || !riskExact(body.period, PERIOD_KEYS) || !validProgram(body.program)
142
+ || body.taxonomy_digest !== taxonomyDigest
143
+ || !Number.isSafeInteger(body.minimum_bucket_count) || body.minimum_bucket_count < 2
144
+ || body.minimum_bucket_count > 1000
145
+ || !Array.isArray(body.buckets) || body.buckets.length > 10_000
146
+ || !body.buckets.every(validBucket)
147
+ || !body.buckets.every((bucket: RiskRecord) => allowedActionClasses.has(bucket.action_class)
148
+ && allowedOutcomes.has(bucket.outcome) && bucket.program_version === body.program.version)
149
+ || !body.buckets.every((bucket: RiskRecord) => bucket.count >= body.minimum_bucket_count)
150
+ || !uniqueSortedBuckets(body.buckets)
151
+ || !riskExact(body.suppressed, ['bucket_count', 'record_count'])
152
+ || !Number.isSafeInteger(body.suppressed.bucket_count) || body.suppressed.bucket_count < 0
153
+ || !Number.isSafeInteger(body.suppressed.record_count) || body.suppressed.record_count < 0
154
+ || !RISK_DIGEST.test(body.source_inventory_digest)
155
+ || !Number.isFinite(riskInstant(body.generated_at))
156
+ || body.claim_boundary !== RECEIPT_CENSUS_CLAIM_BOUNDARY
157
+ || !RISK_DIGEST.test(supplied) || riskDigest(body) !== supplied) return { valid: false, reason: 'census_integrity_invalid' };
158
+ const start = riskInstant(body.period.start); const end = riskInstant(body.period.end);
159
+ if (!Number.isFinite(start) || !Number.isFinite(end) || end <= start
160
+ || riskInstant(body.generated_at) < end) return { valid: false, reason: 'census_period_invalid' };
161
+ return { valid: true, reason: null, census_digest: supplied };
162
+ } catch { return { valid: false, reason: 'census_invalid' }; }
163
+ }
@@ -0,0 +1,114 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ import crypto from 'node:crypto';
3
+ import { canonicalize, hashCanonical } from './execution-binding.js';
4
+
5
+ export type RiskRecord = Record<string, any>;
6
+ export type TrustedRiskKeys = Record<string, { issuer_id: string; public_key: string }>;
7
+
8
+ export const RISK_DIGEST = /^sha256:[0-9a-f]{64}$/;
9
+ export const RISK_CAID = /^caid:1:[a-z][a-z0-9-]*(?:\.[a-z][a-z0-9-]*)*\.[1-9][0-9]*:jcs-sha256:[A-Za-z0-9_-]{43}$/;
10
+ export const RISK_ID = /^[A-Za-z0-9][A-Za-z0-9:_.@/+\-]{0,511}$/;
11
+ const RFC3339 = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.(\d{1,9}))?Z$/;
12
+
13
+ export function riskRecord(value: unknown): value is RiskRecord {
14
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) return false;
15
+ const prototype = Object.getPrototypeOf(value);
16
+ if (prototype !== Object.prototype && prototype !== null) return false;
17
+ return Reflect.ownKeys(value).every((key) => {
18
+ if (typeof key !== 'string') return false;
19
+ const descriptor = Object.getOwnPropertyDescriptor(value, key);
20
+ return descriptor?.enumerable === true && Object.hasOwn(descriptor, 'value');
21
+ });
22
+ }
23
+
24
+ export function riskExact(value: unknown, keys: readonly string[]): value is RiskRecord {
25
+ return riskRecord(value) && Reflect.ownKeys(value).length === keys.length
26
+ && keys.every((key) => Object.hasOwn(value, key));
27
+ }
28
+
29
+ export function riskIdentifier(value: unknown): value is string {
30
+ return typeof value === 'string' && RISK_ID.test(value);
31
+ }
32
+
33
+ export function riskInstant(value: unknown): number {
34
+ if (typeof value !== 'string') return NaN;
35
+ const match = value.match(RFC3339);
36
+ if (!match) return NaN;
37
+ const parsed = Date.parse(value);
38
+ if (!Number.isFinite(parsed)) return NaN;
39
+ return new Date(parsed).toISOString().slice(0, 19) === value.slice(0, 19) ? parsed : NaN;
40
+ }
41
+
42
+ export function riskDigest(value: unknown): string {
43
+ return `sha256:${hashCanonical(value)}`;
44
+ }
45
+
46
+ export function riskClone<T>(value: T): T {
47
+ return JSON.parse(canonicalize(value));
48
+ }
49
+
50
+ export function riskFreeze<T>(value: T): T {
51
+ if (!value || typeof value !== 'object' || Object.isFrozen(value)) return value;
52
+ Object.freeze(value);
53
+ for (const child of Object.values(value as RiskRecord)) riskFreeze(child);
54
+ return value;
55
+ }
56
+
57
+ function signingBytes(version: string, body: RiskRecord): Buffer {
58
+ return Buffer.from(`${version}\0${canonicalize(body)}`, 'utf8');
59
+ }
60
+
61
+ export function signRiskBody(
62
+ version: string,
63
+ bodyInput: RiskRecord,
64
+ signer: { issuer_id: string; key_id: string; private_key: crypto.KeyLike },
65
+ ): RiskRecord {
66
+ const key = signer.private_key instanceof crypto.KeyObject
67
+ ? signer.private_key : crypto.createPrivateKey(signer.private_key);
68
+ if (key.asymmetricKeyType !== 'ed25519') throw new TypeError('risk artifact signing key must be Ed25519');
69
+ if (!riskIdentifier(signer.issuer_id) || !riskIdentifier(signer.key_id)) throw new TypeError('risk artifact issuer is invalid');
70
+ const body = riskClone({ ...bodyInput, issuer: { id: signer.issuer_id, key_id: signer.key_id } });
71
+ const bodyDigest = riskDigest(body);
72
+ const proof = {
73
+ algorithm: 'Ed25519',
74
+ key_id: signer.key_id,
75
+ body_digest: bodyDigest,
76
+ signature_b64u: crypto.sign(null, signingBytes(version, body), key).toString('base64url'),
77
+ };
78
+ return riskFreeze({ ...body, proof });
79
+ }
80
+
81
+ export function verifyRiskBody(
82
+ artifact: unknown,
83
+ version: string,
84
+ trustedKeys: TrustedRiskKeys | undefined,
85
+ ): { valid: boolean; reason: string | null; body: RiskRecord | null; artifact_digest: string | null } {
86
+ const refuse = (reason: string) => ({ valid: false, reason, body: null, artifact_digest: null });
87
+ try {
88
+ if (!riskRecord(artifact) || !riskRecord(artifact.proof) || !riskRecord(artifact.issuer)) return refuse('artifact_shape_invalid');
89
+ const proof = artifact.proof;
90
+ const { proof: _proof, ...body } = artifact;
91
+ if (artifact['@version'] !== version || !riskExact(artifact.issuer, ['id', 'key_id'])
92
+ || !riskIdentifier(artifact.issuer.id) || !riskIdentifier(artifact.issuer.key_id)
93
+ || !riskExact(proof, ['algorithm', 'key_id', 'body_digest', 'signature_b64u'])
94
+ || proof.algorithm !== 'Ed25519' || proof.key_id !== artifact.issuer.key_id
95
+ || typeof proof.body_digest !== 'string' || !RISK_DIGEST.test(proof.body_digest)
96
+ || typeof proof.signature_b64u !== 'string' || !/^[A-Za-z0-9_-]+$/.test(proof.signature_b64u)) {
97
+ return refuse('artifact_signature_envelope_invalid');
98
+ }
99
+ if (riskDigest(body) !== proof.body_digest) return refuse('digest_mismatch');
100
+ const pin = trustedKeys?.[artifact.issuer.key_id];
101
+ if (!pin || pin.issuer_id !== artifact.issuer.id) return refuse('issuer_untrusted');
102
+ const keyBytes = Buffer.from(pin.public_key, 'base64url');
103
+ if (keyBytes.toString('base64url') !== pin.public_key) return refuse('pinned_key_invalid');
104
+ const key = crypto.createPublicKey({ key: keyBytes, type: 'spki', format: 'der' });
105
+ if (key.asymmetricKeyType !== 'ed25519') return refuse('pinned_key_invalid');
106
+ const signature = Buffer.from(proof.signature_b64u, 'base64url');
107
+ if (signature.length !== 64 || signature.toString('base64url') !== proof.signature_b64u
108
+ || !crypto.verify(null, signingBytes(version, body), key, signature)) return refuse('signature_invalid');
109
+ return { valid: true, reason: null, body, artifact_digest: riskDigest(artifact) };
110
+ } catch {
111
+ return refuse('artifact_invalid');
112
+ }
113
+ }
114
+