@emilia-protocol/gate 0.8.0 → 0.9.1
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/README.md +7 -0
- package/action-control-manifest.js +9 -3
- package/breakglass.js +349 -0
- package/demo.mjs +10 -1
- package/deploy/helm/README.md +110 -0
- package/deploy/helm/emilia-gate/Chart.yaml +39 -0
- package/deploy/helm/emilia-gate/templates/NOTES.txt +48 -0
- package/deploy/helm/emilia-gate/templates/configmap.yaml +27 -0
- package/deploy/helm/emilia-gate/templates/deployment.yaml +134 -0
- package/deploy/helm/emilia-gate/templates/service.yaml +28 -0
- package/deploy/helm/emilia-gate/templates/servicemonitor.yaml +41 -0
- package/deploy/helm/emilia-gate/values.yaml +169 -0
- package/deploy/terraform/README.md +138 -0
- package/deploy/terraform/main.tf +256 -0
- package/deploy/terraform/outputs.tf +33 -0
- package/deploy/terraform/variables.tf +205 -0
- package/deploy/terraform/versions.tf +18 -0
- package/eg1-conformance.js +111 -13
- package/enterprise.js +174 -0
- package/index.js +257 -28
- package/metering.js +227 -0
- package/metrics.js +232 -0
- package/package.json +41 -5
- package/reliance-packet.js +80 -2
- package/reports/art14.js +0 -0
- package/reports/auditor-workpaper.js +538 -0
- package/reports/reperform.js +365 -0
- package/reports/underwriter.js +340 -0
- package/roster.js +265 -0
- package/siem.js +236 -0
- package/store-postgres.js +129 -0
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
/**
|
|
3
|
+
* EMILIA Gate — underwriter control attestation (AI-liability loss-run analogue).
|
|
4
|
+
*
|
|
5
|
+
* The artifact an AI-liability underwriter prices premium credit against — the
|
|
6
|
+
* MFA-for-cyber analogue: evidence that a deny-by-default authorization control
|
|
7
|
+
* was IN FORCE and OPERATING over the policy period, computed from the gate's
|
|
8
|
+
* tamper-evident evidence log. Pure function: same entries + same options in,
|
|
9
|
+
* identical JSON out (pin `now` for a byte-stable artifact).
|
|
10
|
+
*
|
|
11
|
+
* HONESTY BOUNDARY (carried inside the artifact): this attests CONTROL
|
|
12
|
+
* OPERATION only. It does not attest the business correctness of any authorized
|
|
13
|
+
* action, and it is not an insurance document until adopted by the carrier.
|
|
14
|
+
* Near-miss / remediation narrative belongs to the broker — the builder emits
|
|
15
|
+
* those fields as null and NEVER fabricates prose.
|
|
16
|
+
*
|
|
17
|
+
* Fail closed: a missing insured or an invalid period is an error, not a guess.
|
|
18
|
+
* Entries that cannot be verified as log records (unparseable time, missing
|
|
19
|
+
* hash, unknown kind, decision without an allow verdict) are EXCLUDED from every
|
|
20
|
+
* attested count and surfaced as integrity_warnings — the attestation never
|
|
21
|
+
* counts what it cannot account for. A zero-activity period is a valid (boring)
|
|
22
|
+
* attestation, not an error.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
export const UNDERWRITER_ATTESTATION_VERSION = 'EP-GATE-UNDERWRITER-ATTESTATION-v1';
|
|
26
|
+
|
|
27
|
+
// The gate's assurance vocabulary. `software` is the single-approver baseline;
|
|
28
|
+
// the distribution always carries all three so an underwriter can see zeros.
|
|
29
|
+
const KNOWN_TIERS = ['class_a', 'quorum', 'software'];
|
|
30
|
+
|
|
31
|
+
function toMs(t) {
|
|
32
|
+
if (t == null) return null;
|
|
33
|
+
const ms = typeof t === 'number' ? t : Date.parse(t);
|
|
34
|
+
return Number.isFinite(ms) ? ms : null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Deterministic object from a Map: keys sorted, never insertion-ordered. */
|
|
38
|
+
function sortedObject(map) {
|
|
39
|
+
const out = {};
|
|
40
|
+
for (const k of [...map.keys()].sort()) out[k] = map.get(k);
|
|
41
|
+
return out;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function countBy(items, keyFn) {
|
|
45
|
+
const m = new Map();
|
|
46
|
+
for (const it of items) {
|
|
47
|
+
const k = keyFn(it);
|
|
48
|
+
m.set(k, (m.get(k) || 0) + 1);
|
|
49
|
+
}
|
|
50
|
+
return m;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Tier histogram seeded with the known tiers so zeros are explicit. */
|
|
54
|
+
function tierDistribution(values) {
|
|
55
|
+
const m = new Map(KNOWN_TIERS.map((t) => [t, 0]));
|
|
56
|
+
for (const v of values) {
|
|
57
|
+
const k = typeof v === 'string' && v.length ? v : 'unspecified';
|
|
58
|
+
m.set(k, (m.get(k) || 0) + 1);
|
|
59
|
+
}
|
|
60
|
+
return sortedObject(m);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Action family = the namespace segment ('payment.release' -> 'payment'). */
|
|
64
|
+
function actionFamily(action) {
|
|
65
|
+
if (typeof action !== 'string' || action.length === 0) return 'unknown';
|
|
66
|
+
const dot = action.indexOf('.');
|
|
67
|
+
return dot > 0 ? action.slice(0, dot) : action;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Build the underwriter attestation over a slice of the evidence log.
|
|
72
|
+
* @param {Array<object>} entries evidence.all() (or a durable export of it)
|
|
73
|
+
* @param {object} o
|
|
74
|
+
* @param {string} o.insured named insured (required)
|
|
75
|
+
* @param {string} [o.policyRef] carrier policy/submission reference (null until bound)
|
|
76
|
+
* @param {string|number} o.periodStart inclusive period start (ISO or epoch ms)
|
|
77
|
+
* @param {string|number} o.periodEnd inclusive period end (ISO or epoch ms)
|
|
78
|
+
* @param {number|function} [o.now] clock for generated_at (pin for determinism)
|
|
79
|
+
* @returns {object} EP-GATE-UNDERWRITER-ATTESTATION-v1 document
|
|
80
|
+
*/
|
|
81
|
+
export function buildUnderwriterAttestation(entries = [], {
|
|
82
|
+
insured, policyRef = null, periodStart, periodEnd, now = Date.now,
|
|
83
|
+
} = {}) {
|
|
84
|
+
if (!insured || typeof insured !== 'string') {
|
|
85
|
+
throw new Error('underwriter attestation: insured (named insured) is required');
|
|
86
|
+
}
|
|
87
|
+
const startMs = toMs(periodStart);
|
|
88
|
+
const endMs = toMs(periodEnd);
|
|
89
|
+
if (startMs == null || endMs == null) {
|
|
90
|
+
throw new Error('underwriter attestation: periodStart and periodEnd must be valid ISO strings or epoch ms');
|
|
91
|
+
}
|
|
92
|
+
if (startMs > endMs) {
|
|
93
|
+
throw new Error('underwriter attestation: periodStart must not be after periodEnd');
|
|
94
|
+
}
|
|
95
|
+
if (!Array.isArray(entries)) {
|
|
96
|
+
throw new Error('underwriter attestation: entries must be an array (evidence.all())');
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Scope + integrity pass. Out-of-window records are simply out of scope;
|
|
100
|
+
// records we cannot verify as log entries are warned AND excluded — never
|
|
101
|
+
// attested over. Exclusion is the conservative direction for premium credit.
|
|
102
|
+
const warnings = [];
|
|
103
|
+
const inScope = [];
|
|
104
|
+
entries.forEach((e, index) => {
|
|
105
|
+
const ref = { index, seq: e && typeof e === 'object' && Number.isInteger(e.seq) ? e.seq : null };
|
|
106
|
+
if (!e || typeof e !== 'object') { warnings.push({ ...ref, reason: 'not_an_object' }); return; }
|
|
107
|
+
const t = toMs(e.at);
|
|
108
|
+
if (t == null) { warnings.push({ ...ref, reason: 'unparseable_at' }); return; }
|
|
109
|
+
if (t < startMs || t > endMs) return;
|
|
110
|
+
if (typeof e.hash !== 'string' || e.hash.length === 0) { warnings.push({ ...ref, reason: 'missing_hash' }); return; }
|
|
111
|
+
if (e.kind !== 'decision' && e.kind !== 'execution') { warnings.push({ ...ref, reason: 'unknown_kind' }); return; }
|
|
112
|
+
if (e.kind === 'decision' && typeof e.allow !== 'boolean') { warnings.push({ ...ref, reason: 'decision_missing_allow' }); return; }
|
|
113
|
+
inScope.push({ entry: e, t });
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
const decisions = inScope.filter((x) => x.entry.kind === 'decision');
|
|
117
|
+
const executions = inScope.filter((x) => x.entry.kind === 'execution').map((x) => x.entry);
|
|
118
|
+
|
|
119
|
+
// 'not_guarded' pass-throughs ran WITHOUT the control — they are the
|
|
120
|
+
// uncontrolled surface, reported separately from protected-action volume.
|
|
121
|
+
const uncontrolled = decisions.filter((x) => x.entry.reason === 'not_guarded').map((x) => x.entry);
|
|
122
|
+
const guarded = decisions.filter((x) => x.entry.reason !== 'not_guarded');
|
|
123
|
+
const guardedAllows = guarded.filter((x) => x.entry.allow === true).map((x) => x.entry);
|
|
124
|
+
const denies = guarded.filter((x) => x.entry.allow === false).map((x) => x.entry);
|
|
125
|
+
|
|
126
|
+
const families = new Map();
|
|
127
|
+
for (const { entry: d } of guarded) {
|
|
128
|
+
const fam = actionFamily(d.action);
|
|
129
|
+
const f = families.get(fam) || { decisions: 0, allowed: 0, denied: 0 };
|
|
130
|
+
f.decisions += 1;
|
|
131
|
+
if (d.allow) f.allowed += 1; else f.denied += 1;
|
|
132
|
+
families.set(fam, f);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const reasons = sortedObject(countBy(denies, (d) => String(d.reason ?? 'unspecified')));
|
|
136
|
+
const replayBlocked = denies.filter((d) => d.reason === 'replay_refused').length;
|
|
137
|
+
|
|
138
|
+
// Hard actions = quorum required by the manifest, per the recorded decision.
|
|
139
|
+
const hard = guarded.filter((x) => x.entry.required_tier === 'quorum');
|
|
140
|
+
const hardAllowed = hard.filter((x) => x.entry.allow === true).length;
|
|
141
|
+
|
|
142
|
+
// Replay defense bypassed = the gate authorized with consumptionMode 'none'
|
|
143
|
+
// (one-time consumption explicitly skipped). An underwriter must see this.
|
|
144
|
+
const replayBypassed = guardedAllows.filter((a) => a.consumption_mode === 'none').length;
|
|
145
|
+
|
|
146
|
+
let firstAt = null; let lastAt = null;
|
|
147
|
+
for (const { entry: d, t } of decisions) {
|
|
148
|
+
if (firstAt === null || t < firstAt.t) firstAt = { t, at: d.at };
|
|
149
|
+
if (lastAt === null || t > lastAt.t) lastAt = { t, at: d.at };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const nowMs = typeof now === 'function' ? now() : now;
|
|
153
|
+
|
|
154
|
+
return {
|
|
155
|
+
'@version': UNDERWRITER_ATTESTATION_VERSION,
|
|
156
|
+
product: 'EMILIA Gate',
|
|
157
|
+
generated_at: new Date(nowMs).toISOString(),
|
|
158
|
+
insured,
|
|
159
|
+
policy_ref: policyRef ?? null,
|
|
160
|
+
period: { start: new Date(startMs).toISOString(), end: new Date(endMs).toISOString() },
|
|
161
|
+
honesty: {
|
|
162
|
+
attests: 'Operation of a deny-by-default authorization control (EMILIA Gate) over the stated period, computed from the gate\'s tamper-evident evidence log.',
|
|
163
|
+
does_not_attest: [
|
|
164
|
+
'The business correctness or wisdom of any authorized action.',
|
|
165
|
+
'Identity proofing, authority enrollment, or issuer/approver key custody, which remain external trust roots.',
|
|
166
|
+
'Any period, system, or enforcement point outside the supplied evidence log.',
|
|
167
|
+
],
|
|
168
|
+
status: 'Not an insurance document. This attestation carries no coverage effect until adopted by the carrier.',
|
|
169
|
+
},
|
|
170
|
+
control_in_force: {
|
|
171
|
+
control: 'EMILIA Gate — Trusted Action Firewall',
|
|
172
|
+
mode: 'deny_by_default',
|
|
173
|
+
statement: 'Guarded actions execute only with a valid, in-scope, sufficiently-assured, fresh, one-time authorization receipt; absent or insufficient proof is refused, and every decision is appended to a hash-chained evidence log.',
|
|
174
|
+
guarded_decisions: guarded.length,
|
|
175
|
+
first_decision_at: firstAt?.at ?? null,
|
|
176
|
+
last_decision_at: lastAt?.at ?? null,
|
|
177
|
+
},
|
|
178
|
+
volume: {
|
|
179
|
+
guarded_decisions: guarded.length,
|
|
180
|
+
allowed: guardedAllows.length,
|
|
181
|
+
denied: denies.length,
|
|
182
|
+
by_action_family: sortedObject(families),
|
|
183
|
+
},
|
|
184
|
+
denials: {
|
|
185
|
+
total: denies.length,
|
|
186
|
+
// 0/0 is 'no guarded activity', not a 0% denial rate — represent as null.
|
|
187
|
+
rate: guarded.length === 0 ? null : denies.length / guarded.length,
|
|
188
|
+
reasons,
|
|
189
|
+
},
|
|
190
|
+
replay: { attempts_blocked: replayBlocked },
|
|
191
|
+
assurance: {
|
|
192
|
+
required_tier_distribution: tierDistribution(guarded.map((x) => x.entry.required_tier)),
|
|
193
|
+
credited_tier_distribution_on_allow: tierDistribution(guardedAllows.map((a) => a.have_tier)),
|
|
194
|
+
},
|
|
195
|
+
quorum_usage: {
|
|
196
|
+
hard_action_decisions: hard.length,
|
|
197
|
+
allowed: hardAllowed,
|
|
198
|
+
denied: hard.length - hardAllowed,
|
|
199
|
+
},
|
|
200
|
+
exceptions: {
|
|
201
|
+
uncontrolled_passthroughs: uncontrolled.length,
|
|
202
|
+
uncontrolled_actions: [...new Set(uncontrolled.map((d) => (typeof d.action === 'string' && d.action) || 'unknown'))].sort(),
|
|
203
|
+
replay_defense_bypassed: replayBypassed,
|
|
204
|
+
},
|
|
205
|
+
executions: {
|
|
206
|
+
recorded: executions.length,
|
|
207
|
+
executed: executions.filter((x) => x.outcome === 'executed').length,
|
|
208
|
+
failed: executions.filter((x) => x.outcome === 'failed').length,
|
|
209
|
+
},
|
|
210
|
+
// Broker fields. NEVER machine-generated — the builder only ever emits null.
|
|
211
|
+
narrative: { near_misses: null, remediation: null, completed_by: 'broker' },
|
|
212
|
+
evidence: {
|
|
213
|
+
log_entries_supplied: entries.length,
|
|
214
|
+
in_scope: inScope.length,
|
|
215
|
+
first_hash: inScope[0]?.entry.hash ?? null,
|
|
216
|
+
last_hash: inScope[inScope.length - 1]?.entry.hash ?? null,
|
|
217
|
+
integrity_warnings: warnings,
|
|
218
|
+
},
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/* ------------------------------- markdown ------------------------------- */
|
|
223
|
+
|
|
224
|
+
/** Table cells must not break the table; the source strings are log-derived. */
|
|
225
|
+
function cell(v) {
|
|
226
|
+
return String(v ?? '—').replace(/\|/g, '\\|').replace(/\r?\n/g, ' ');
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function pct(rate) {
|
|
230
|
+
return rate == null ? 'n/a' : `${(rate * 100).toFixed(2)}%`;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function countTable(header, obj) {
|
|
234
|
+
const keys = Object.keys(obj);
|
|
235
|
+
if (keys.length === 0) return ['_None in period._'];
|
|
236
|
+
return [
|
|
237
|
+
`| ${header} | Count |`,
|
|
238
|
+
'|---|---:|',
|
|
239
|
+
...keys.map((k) => `| ${cell(k)} | ${obj[k]} |`),
|
|
240
|
+
];
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const BROKER_PLACEHOLDER = '_(left to the broker — not machine-generated)_';
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Render the attestation for a submission packet. Refuses any document that is
|
|
247
|
+
* not an EP-GATE-UNDERWRITER-ATTESTATION-v1 — never renders what it cannot
|
|
248
|
+
* vouch the shape of. Narrative fields render exactly as present in the pack
|
|
249
|
+
* (the broker fills them into the JSON); null renders as a placeholder.
|
|
250
|
+
*/
|
|
251
|
+
export function renderMarkdown(pack) {
|
|
252
|
+
if (!pack || typeof pack !== 'object' || pack['@version'] !== UNDERWRITER_ATTESTATION_VERSION) {
|
|
253
|
+
throw new Error(`renderMarkdown requires an ${UNDERWRITER_ATTESTATION_VERSION} document`);
|
|
254
|
+
}
|
|
255
|
+
const fams = pack.volume.by_action_family;
|
|
256
|
+
const famKeys = Object.keys(fams);
|
|
257
|
+
const req = pack.assurance.required_tier_distribution;
|
|
258
|
+
const cred = pack.assurance.credited_tier_distribution_on_allow;
|
|
259
|
+
const tierKeys = [...new Set([...Object.keys(req), ...Object.keys(cred)])].sort();
|
|
260
|
+
const warnings = pack.evidence.integrity_warnings;
|
|
261
|
+
|
|
262
|
+
const lines = [
|
|
263
|
+
'# Underwriter Control Attestation — EMILIA Gate',
|
|
264
|
+
'',
|
|
265
|
+
`\`${UNDERWRITER_ATTESTATION_VERSION}\` · generated ${pack.generated_at}`,
|
|
266
|
+
'',
|
|
267
|
+
`**Insured:** ${cell(pack.insured)} · **Policy:** ${cell(pack.policy_ref)} · **Period:** ${pack.period.start} → ${pack.period.end}`,
|
|
268
|
+
'',
|
|
269
|
+
`> **Attests:** ${pack.honesty.attests}`,
|
|
270
|
+
`> **Does not attest:** ${pack.honesty.does_not_attest.join(' ')}`,
|
|
271
|
+
`> **Status:** ${pack.honesty.status}`,
|
|
272
|
+
'',
|
|
273
|
+
'## Control in force',
|
|
274
|
+
'',
|
|
275
|
+
`- Control: ${pack.control_in_force.control} (${pack.control_in_force.mode})`,
|
|
276
|
+
`- ${pack.control_in_force.statement}`,
|
|
277
|
+
`- Guarded decisions in period: ${pack.control_in_force.guarded_decisions}`
|
|
278
|
+
+ (pack.control_in_force.first_decision_at
|
|
279
|
+
? ` (first ${pack.control_in_force.first_decision_at}, last ${pack.control_in_force.last_decision_at})`
|
|
280
|
+
: ''),
|
|
281
|
+
'',
|
|
282
|
+
'## Protected-action volume',
|
|
283
|
+
'',
|
|
284
|
+
...(famKeys.length === 0
|
|
285
|
+
? ['_No guarded decisions in the period._']
|
|
286
|
+
: [
|
|
287
|
+
'| Action family | Decisions | Allowed | Denied |',
|
|
288
|
+
'|---|---:|---:|---:|',
|
|
289
|
+
...famKeys.map((k) => `| ${cell(k)} | ${fams[k].decisions} | ${fams[k].allowed} | ${fams[k].denied} |`),
|
|
290
|
+
]),
|
|
291
|
+
'',
|
|
292
|
+
'## Denials',
|
|
293
|
+
'',
|
|
294
|
+
`- Denials: ${pack.denials.total} of ${pack.volume.guarded_decisions} guarded decisions (${pct(pack.denials.rate)})`,
|
|
295
|
+
'',
|
|
296
|
+
...countTable('Denial reason', pack.denials.reasons),
|
|
297
|
+
'',
|
|
298
|
+
'## Replay defense',
|
|
299
|
+
'',
|
|
300
|
+
`- Replay attempts blocked: ${pack.replay.attempts_blocked}`,
|
|
301
|
+
'',
|
|
302
|
+
'## Assurance tiers',
|
|
303
|
+
'',
|
|
304
|
+
'| Tier | Required (guarded) | Credited (allowed) |',
|
|
305
|
+
'|---|---:|---:|',
|
|
306
|
+
...tierKeys.map((t) => `| ${cell(t)} | ${req[t] ?? 0} | ${cred[t] ?? 0} |`),
|
|
307
|
+
'',
|
|
308
|
+
'## Quorum usage on hard actions',
|
|
309
|
+
'',
|
|
310
|
+
`- Decisions requiring quorum: ${pack.quorum_usage.hard_action_decisions} (allowed ${pack.quorum_usage.allowed}, denied ${pack.quorum_usage.denied})`,
|
|
311
|
+
'',
|
|
312
|
+
'## Exceptions / uncontrolled actions',
|
|
313
|
+
'',
|
|
314
|
+
`- Uncontrolled pass-throughs (not guarded by manifest): ${pack.exceptions.uncontrolled_passthroughs}`,
|
|
315
|
+
`- Uncontrolled actions: ${pack.exceptions.uncontrolled_actions.length ? pack.exceptions.uncontrolled_actions.map(cell).join(', ') : '—'}`,
|
|
316
|
+
`- Replay defense bypassed (consumption mode "none"): ${pack.exceptions.replay_defense_bypassed}`,
|
|
317
|
+
'',
|
|
318
|
+
'## Executions',
|
|
319
|
+
'',
|
|
320
|
+
`- Recorded: ${pack.executions.recorded} (executed ${pack.executions.executed}, failed ${pack.executions.failed})`,
|
|
321
|
+
'',
|
|
322
|
+
'## Near-miss narrative (broker)',
|
|
323
|
+
'',
|
|
324
|
+
`- Near misses: ${pack.narrative.near_misses == null ? BROKER_PLACEHOLDER : cell(pack.narrative.near_misses)}`,
|
|
325
|
+
`- Remediation: ${pack.narrative.remediation == null ? BROKER_PLACEHOLDER : cell(pack.narrative.remediation)}`,
|
|
326
|
+
'',
|
|
327
|
+
'## Evidence basis',
|
|
328
|
+
'',
|
|
329
|
+
`- Log entries supplied: ${pack.evidence.log_entries_supplied} · in scope: ${pack.evidence.in_scope}`,
|
|
330
|
+
`- First hash: ${cell(pack.evidence.first_hash)} · Last hash: ${cell(pack.evidence.last_hash)}`,
|
|
331
|
+
`- Integrity warnings: ${warnings.length}`,
|
|
332
|
+
...(warnings.length
|
|
333
|
+
? warnings.map((w) => ` - index ${w.index}${w.seq != null ? ` (seq ${w.seq})` : ''}: ${cell(w.reason)}`)
|
|
334
|
+
: []),
|
|
335
|
+
'',
|
|
336
|
+
];
|
|
337
|
+
return lines.join('\n');
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
export default { UNDERWRITER_ATTESTATION_VERSION, buildUnderwriterAttestation, renderMarkdown };
|
package/roster.js
ADDED
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
/**
|
|
3
|
+
* EMILIA Gate — signer-roster sync from an enterprise IdP (EP-GATE-ROSTER-v1).
|
|
4
|
+
*
|
|
5
|
+
* The boring key story: WHO may approve is an HR fact, not a crypto fact. This
|
|
6
|
+
* module turns a SCIM-like IdP export into a versioned signer roster and
|
|
7
|
+
* reconciles a key registry (key-registry.js) against it, so a deprovisioned
|
|
8
|
+
* employee STOPS being an acceptable approver on the next sync. Fail closed:
|
|
9
|
+
* - only `active === true` users' keys are ever pinned; anything else
|
|
10
|
+
* (false, missing, truthy-but-not-boolean) never pins;
|
|
11
|
+
* - a user absent from the import — offboarded, or silently dropped by a
|
|
12
|
+
* broken IdP export — has every previously pinned key revoked;
|
|
13
|
+
* - a kid claimed by two principals (or carrying two different key
|
|
14
|
+
* materials) is CONTESTED: it pins nothing and is revoked if present;
|
|
15
|
+
* - an import that would leave ZERO active signers requires an explicit
|
|
16
|
+
* `allowEmpty` acknowledgment, so an empty/broken IdP response cannot
|
|
17
|
+
* silently mass-revoke every approver.
|
|
18
|
+
*
|
|
19
|
+
* importRoster/diffRoster are pure (inputs in, artifact out; `importedAt` is
|
|
20
|
+
* the caller-supplied clock). applyRosterToRegistry mutates the given registry
|
|
21
|
+
* through its real API: key-registry CAN express revocation (`revoke(kid, at)`,
|
|
22
|
+
* hard and fail-closed), so reconciliation performs it directly and the
|
|
23
|
+
* returned `revoked` list is the exact set of revocations performed.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
export const ROSTER_VERSION = 'EP-GATE-ROSTER-v1';
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Import a SCIM-like IdP user export into a signer roster.
|
|
30
|
+
* @param {Array<{id:string, userName:string, active:boolean, emails?:any, keys?:Array<{kid:string, publicKey:string}>}>} idpUsers
|
|
31
|
+
* @param {object} o
|
|
32
|
+
* @param {string} o.source IdP provenance, e.g. 'scim:okta:acme' (required)
|
|
33
|
+
* @param {string|number} [o.importedAt] import time (ISO or ms); the caller's clock
|
|
34
|
+
* @param {boolean} [o.allowEmpty=false] acknowledge an import with zero active signers
|
|
35
|
+
* @returns {{version:string, source:string, imported_at:string, signers:Array<{principal:string, kid:string, publicKey:string, active:boolean}>, integrity_warnings:object[]}}
|
|
36
|
+
*/
|
|
37
|
+
export function importRoster(idpUsers, { source, importedAt, allowEmpty = false } = {}) {
|
|
38
|
+
if (!Array.isArray(idpUsers)) throw new Error('roster import: idpUsers must be an array');
|
|
39
|
+
if (!source || typeof source !== 'string') {
|
|
40
|
+
throw new Error('roster import: source (IdP provenance, e.g. "scim:okta:acme") is required');
|
|
41
|
+
}
|
|
42
|
+
const atMs = importedAt == null
|
|
43
|
+
? Date.now()
|
|
44
|
+
: (typeof importedAt === 'number' ? importedAt : Date.parse(importedAt));
|
|
45
|
+
if (!Number.isFinite(atMs)) throw new Error('roster import: importedAt is not a valid time');
|
|
46
|
+
|
|
47
|
+
const warnings = [];
|
|
48
|
+
const candidates = [];
|
|
49
|
+
const usersByPrincipal = new Map();
|
|
50
|
+
|
|
51
|
+
for (const u of idpUsers) {
|
|
52
|
+
// A user without a stable id AND a userName cannot be diffed or revoked
|
|
53
|
+
// reliably later — excluded, never an approver.
|
|
54
|
+
if (!u || typeof u !== 'object'
|
|
55
|
+
|| typeof u.id !== 'string' || !u.id
|
|
56
|
+
|| typeof u.userName !== 'string' || !u.userName) {
|
|
57
|
+
warnings.push({ code: 'malformed_user', id: u?.id ?? null, userName: u?.userName ?? null });
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
const principal = u.userName;
|
|
61
|
+
usersByPrincipal.set(principal, (usersByPrincipal.get(principal) || 0) + 1);
|
|
62
|
+
const active = u.active === true; // strictly boolean true; anything else never pins
|
|
63
|
+
for (const k of Array.isArray(u.keys) ? u.keys : []) {
|
|
64
|
+
if (!k || typeof k.kid !== 'string' || !k.kid
|
|
65
|
+
|| typeof k.publicKey !== 'string' || !k.publicKey) {
|
|
66
|
+
warnings.push({ code: 'malformed_key', principal, kid: k?.kid ?? null });
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
candidates.push({ principal, kid: k.kid, publicKey: k.publicKey, active });
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Two IdP users claiming one principal: identity is ambiguous — neither pins.
|
|
74
|
+
const contestedPrincipals = new Set();
|
|
75
|
+
for (const [principal, count] of usersByPrincipal) {
|
|
76
|
+
if (count > 1) {
|
|
77
|
+
contestedPrincipals.add(principal);
|
|
78
|
+
warnings.push({ code: 'duplicate_principal', principal, users: count });
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
let entries = candidates.filter((c) => !contestedPrincipals.has(c.principal));
|
|
82
|
+
|
|
83
|
+
// Exact-duplicate rows collapse silently (same principal + kid + key).
|
|
84
|
+
const seen = new Set();
|
|
85
|
+
entries = entries.filter((c) => {
|
|
86
|
+
const id = `${c.principal}\n${c.kid}\n${c.publicKey}`;
|
|
87
|
+
if (seen.has(id)) return false;
|
|
88
|
+
seen.add(id);
|
|
89
|
+
return true;
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
// One kid claimed by two principals, or one kid carrying two key materials,
|
|
93
|
+
// is an integrity failure: the contested kid pins NOTHING for anyone.
|
|
94
|
+
const byKid = new Map();
|
|
95
|
+
for (const c of entries) {
|
|
96
|
+
const g = byKid.get(c.kid) || { principals: new Set(), keys: new Set() };
|
|
97
|
+
g.principals.add(c.principal);
|
|
98
|
+
g.keys.add(c.publicKey);
|
|
99
|
+
byKid.set(c.kid, g);
|
|
100
|
+
}
|
|
101
|
+
const contestedKids = new Set();
|
|
102
|
+
for (const [kid, g] of byKid) {
|
|
103
|
+
if (g.principals.size > 1 || g.keys.size > 1) {
|
|
104
|
+
contestedKids.add(kid);
|
|
105
|
+
warnings.push({ code: 'duplicate_kid', kid, principals: [...g.principals].sort() });
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
entries = entries.filter((c) => !contestedKids.has(c.kid));
|
|
109
|
+
|
|
110
|
+
// Mass-deprovision guard: a roster with zero active signers would, on apply,
|
|
111
|
+
// revoke EVERY approver. That is indistinguishable from a broken IdP export,
|
|
112
|
+
// so it requires the operator's explicit acknowledgment.
|
|
113
|
+
if (!allowEmpty && !entries.some((e) => e.active)) {
|
|
114
|
+
throw new Error('roster import: zero active signers (mass-deprovision guard) — pass allowEmpty:true to acknowledge revoking every approver');
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
entries.sort((a, b) => (a.principal < b.principal ? -1 : a.principal > b.principal ? 1
|
|
118
|
+
: a.kid < b.kid ? -1 : a.kid > b.kid ? 1 : 0));
|
|
119
|
+
|
|
120
|
+
return {
|
|
121
|
+
version: ROSTER_VERSION,
|
|
122
|
+
source,
|
|
123
|
+
imported_at: new Date(atMs).toISOString(),
|
|
124
|
+
signers: entries.map(({ principal, kid, publicKey, active }) => ({ principal, kid, publicKey, active })),
|
|
125
|
+
integrity_warnings: warnings,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** A roster must be well-formed before it can drive a diff or a registry mutation. */
|
|
130
|
+
function assertRoster(r, name) {
|
|
131
|
+
if (!r || r.version !== ROSTER_VERSION || !Array.isArray(r.signers)) {
|
|
132
|
+
throw new Error(`roster: ${name} is not an ${ROSTER_VERSION} roster`);
|
|
133
|
+
}
|
|
134
|
+
for (const s of r.signers) {
|
|
135
|
+
if (!s || typeof s.principal !== 'string' || !s.principal
|
|
136
|
+
|| typeof s.kid !== 'string' || !s.kid
|
|
137
|
+
|| typeof s.publicKey !== 'string' || !s.publicKey
|
|
138
|
+
|| typeof s.active !== 'boolean') {
|
|
139
|
+
throw new Error(`roster: ${name} contains a malformed signer entry`);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function groupByPrincipal(roster) {
|
|
145
|
+
const m = new Map();
|
|
146
|
+
for (const s of roster.signers) {
|
|
147
|
+
const g = m.get(s.principal) || { kids: new Set(), active: false };
|
|
148
|
+
g.kids.add(s.kid);
|
|
149
|
+
if (s.active === true) g.active = true;
|
|
150
|
+
m.set(s.principal, g);
|
|
151
|
+
}
|
|
152
|
+
return m;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Diff two rosters at the principal level.
|
|
157
|
+
* `removed`/`deactivated` carry the PREVIOUS roster's kids — the revocation
|
|
158
|
+
* candidates; `added` carries the next roster's kids.
|
|
159
|
+
* @returns {{added:Array<{principal:string,kids:string[]}>, removed:Array<{principal:string,kids:string[]}>, deactivated:Array<{principal:string,kids:string[]}>}}
|
|
160
|
+
*/
|
|
161
|
+
export function diffRoster(previous, next) {
|
|
162
|
+
assertRoster(previous, 'previous');
|
|
163
|
+
assertRoster(next, 'next');
|
|
164
|
+
const prev = groupByPrincipal(previous);
|
|
165
|
+
const nxt = groupByPrincipal(next);
|
|
166
|
+
const added = [];
|
|
167
|
+
const removed = [];
|
|
168
|
+
const deactivated = [];
|
|
169
|
+
for (const [principal, g] of nxt) {
|
|
170
|
+
if (!prev.has(principal)) added.push({ principal, kids: [...g.kids].sort() });
|
|
171
|
+
}
|
|
172
|
+
for (const [principal, g] of prev) {
|
|
173
|
+
if (!nxt.has(principal)) { removed.push({ principal, kids: [...g.kids].sort() }); continue; }
|
|
174
|
+
if (g.active && !nxt.get(principal).active) deactivated.push({ principal, kids: [...g.kids].sort() });
|
|
175
|
+
}
|
|
176
|
+
const byPrincipal = (a, b) => (a.principal < b.principal ? -1 : a.principal > b.principal ? 1 : 0);
|
|
177
|
+
added.sort(byPrincipal); removed.sort(byPrincipal); deactivated.sort(byPrincipal);
|
|
178
|
+
return { added, removed, deactivated };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Reconcile a key registry (createKeyRegistry) against a roster:
|
|
183
|
+
* - PIN each ACTIVE, uncontested signer's key not already present;
|
|
184
|
+
* - REVOKE every registry kid not owned by an active roster signer — absent
|
|
185
|
+
* or inactive means deprovisioned. The registry passed here must therefore
|
|
186
|
+
* be DEDICATED to roster-managed approver keys.
|
|
187
|
+
* key-registry's API DOES express revocation (revoke(kid, at) — hard,
|
|
188
|
+
* fail-closed), so revocations are performed directly; `revoked` is the exact
|
|
189
|
+
* set performed, returned for the caller's evidence trail.
|
|
190
|
+
* A kid the registry has EVER revoked is never re-pinned (a rehire gets a new
|
|
191
|
+
* key; revoked key material stays dead).
|
|
192
|
+
* @param {object} roster an EP-GATE-ROSTER-v1 roster
|
|
193
|
+
* @param {object} registry createKeyRegistry() instance (add/revoke/status)
|
|
194
|
+
* @param {object} [o]
|
|
195
|
+
* @param {string|number} [o.revokedAt=roster.imported_at] revocation timestamp
|
|
196
|
+
* @returns {{pinned:Array<{principal:string,kid:string}>, already_pinned:string[], revoked:Array<{kid:string,revoked_at:string|number,reason:string}>, refused:Array<{principal:string,kid:string,reason:string}>}}
|
|
197
|
+
*/
|
|
198
|
+
export function applyRosterToRegistry(roster, registry, { revokedAt } = {}) {
|
|
199
|
+
assertRoster(roster, 'roster');
|
|
200
|
+
// Fail closed on the registry too: never coerce a flat key array here —
|
|
201
|
+
// asKeyRegistry would build a DETACHED registry and every revocation this
|
|
202
|
+
// function performs would be silently lost.
|
|
203
|
+
if (!registry || typeof registry.add !== 'function'
|
|
204
|
+
|| typeof registry.revoke !== 'function' || typeof registry.status !== 'function') {
|
|
205
|
+
throw new Error('roster apply: registry must expose add/revoke/status (createKeyRegistry)');
|
|
206
|
+
}
|
|
207
|
+
const at = revokedAt ?? roster.imported_at;
|
|
208
|
+
|
|
209
|
+
// Recompute kid contests here as well — a hand-built roster must not bypass
|
|
210
|
+
// the import-time duplicate-kid integrity check.
|
|
211
|
+
const byKid = new Map();
|
|
212
|
+
for (const s of roster.signers) {
|
|
213
|
+
const g = byKid.get(s.kid) || { principals: new Set(), keys: new Set() };
|
|
214
|
+
g.principals.add(s.principal);
|
|
215
|
+
g.keys.add(s.publicKey);
|
|
216
|
+
byKid.set(s.kid, g);
|
|
217
|
+
}
|
|
218
|
+
const contested = new Set();
|
|
219
|
+
for (const [kid, g] of byKid) {
|
|
220
|
+
if (g.principals.size > 1 || g.keys.size > 1) contested.add(kid);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const activeByKid = new Map();
|
|
224
|
+
for (const s of roster.signers) {
|
|
225
|
+
if (s.active === true && !contested.has(s.kid)) activeByKid.set(s.kid, s);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const status = registry.status();
|
|
229
|
+
const everRevoked = new Set(status.filter((e) => e.revoked).map((e) => e.kid));
|
|
230
|
+
const present = new Set(status.map((e) => e.kid));
|
|
231
|
+
|
|
232
|
+
const pinned = [];
|
|
233
|
+
const alreadyPinned = [];
|
|
234
|
+
const revoked = [];
|
|
235
|
+
const refused = [];
|
|
236
|
+
|
|
237
|
+
for (const s of roster.signers) {
|
|
238
|
+
if (s.active === true && contested.has(s.kid)) {
|
|
239
|
+
refused.push({ principal: s.principal, kid: s.kid, reason: 'contested_kid' });
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
for (const [kid, s] of activeByKid) {
|
|
244
|
+
if (everRevoked.has(kid)) {
|
|
245
|
+
refused.push({ principal: s.principal, kid, reason: 'kid_previously_revoked' });
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
if (present.has(kid)) { alreadyPinned.push(kid); continue; }
|
|
249
|
+
registry.add({ kid, key: s.publicKey });
|
|
250
|
+
pinned.push({ principal: s.principal, kid });
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// Absent-or-inactive (and contested) kids stop being acceptable NOW.
|
|
254
|
+
const done = new Set();
|
|
255
|
+
for (const e of status) {
|
|
256
|
+
if (e.revoked || done.has(e.kid) || activeByKid.has(e.kid)) continue;
|
|
257
|
+
registry.revoke(e.kid, at);
|
|
258
|
+
done.add(e.kid);
|
|
259
|
+
revoked.push({ kid: e.kid, revoked_at: at, reason: contested.has(e.kid) ? 'contested_kid' : 'absent_or_inactive' });
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
return { pinned, already_pinned: alreadyPinned, revoked, refused };
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
export default { ROSTER_VERSION, importRoster, diffRoster, applyRosterToRegistry };
|