knosky 0.6.3 → 0.7.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 +149 -93
- package/CREDITS.md +14 -14
- package/LICENSE.md +76 -76
- package/LIMITATIONS.md +33 -23
- package/PRIVACY.md +30 -30
- package/README.md +170 -117
- package/SECURITY.md +78 -46
- package/action/post-comment.mjs +94 -89
- package/action.yml +62 -62
- package/bin/knosky.mjs +279 -105
- package/core/CONTRACT.md +70 -70
- package/core/append-only-checkpoint.mjs +215 -0
- package/core/audit-writer.mjs +317 -0
- package/core/benchmark-results.mjs +225 -225
- package/core/bundle.mjs +178 -178
- package/core/churn.mjs +23 -23
- package/core/ci.mjs +268 -268
- package/core/comparison.mjs +189 -189
- package/core/config.mjs +189 -189
- package/core/constants.mjs +13 -13
- package/core/contract.mjs +123 -123
- package/core/cross-repo.mjs +111 -111
- package/core/decision-codes.mjs +92 -0
- package/core/destination.mjs +161 -161
- package/core/district-classification.mjs +111 -0
- package/core/doctor-scorecard.mjs +369 -0
- package/core/domain-store.mjs +347 -0
- package/core/edges.mjs +43 -43
- package/core/escalate.mjs +68 -68
- package/core/freshness.mjs +198 -194
- package/core/fs-indexer.mjs +218 -218
- package/core/key-store.mjs +348 -348
- package/core/layout.mjs +46 -46
- package/core/ledger.mjs +176 -141
- package/core/local-ipc-identity.mjs +500 -0
- package/core/lod.mjs +155 -155
- package/core/mode-b.mjs +410 -0
- package/core/multi-model-benchmark.mjs +405 -405
- package/core/net-lockdown.mjs +421 -0
- package/core/onboarding.mjs +223 -223
- package/core/operator-auth.mjs +317 -0
- package/core/overlays.mjs +45 -45
- package/core/policy-lattice.mjs +142 -0
- package/core/pr-comment.mjs +198 -198
- package/core/protocol-spec.mjs +460 -460
- package/core/provenance.mjs +320 -0
- package/core/retrieve.mjs +63 -63
- package/core/route.mjs +304 -304
- package/core/schema.mjs +275 -275
- package/core/signing-tiers.mjs +1265 -0
- package/core/swarm-bench.mjs +106 -0
- package/core/swarm-coordinator.mjs +867 -0
- package/core/trust-root-rekey.mjs +410 -0
- package/mcp/server.mjs +264 -108
- package/package.json +56 -46
- package/renderer/art/kenney/buildingTiles_sheet.xml +130 -130
- package/renderer/art/kenney/cityDetails_sheet.xml +12 -12
- package/renderer/art/kenney/landscapeTiles_sheet.xml +129 -129
- package/renderer/art/kenney/sheet_allCars.xml +545 -545
- package/renderer/build-rich.mjs +43 -43
- package/renderer/city.template.html +808 -808
- package/ssot/decision-codes.json +133 -0
- package/ssot/ladder-l0-l3.md +232 -0
- package/ssot/tool-menu.json +130 -0
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
// KnoSky F0.1 — Trust-root recovery/re-key ceremony (SAT-545).
|
|
2
|
+
//
|
|
3
|
+
// Implements two distinct re-key paths for the trust-root lifecycle:
|
|
4
|
+
//
|
|
5
|
+
// Ordinary path (D-193)
|
|
6
|
+
// A keyholder or hardware key is lost/compromised. A majority of the
|
|
7
|
+
// REMAINING non-revoked keyholders co-sign a re-key request. The new key
|
|
8
|
+
// does NOT become active immediately — a mandatory 48–72 h cooling-off
|
|
9
|
+
// window is enforced. The event type is `TRUST_ROOT_REKEY`, which is
|
|
10
|
+
// permanently logged and CANNOT be emitted through the ordinary exception-
|
|
11
|
+
// signing API (assembleLedgerEntry in signing-tiers.mjs only produces
|
|
12
|
+
// EXCEPTION_GRANTED — structural firewall, not a runtime check).
|
|
13
|
+
//
|
|
14
|
+
// Emergency override path (D-194)
|
|
15
|
+
// UNANIMOUS sign-off from EVERY currently-uncompromised (i.e. non-revoked)
|
|
16
|
+
// keyholder is required — unconditionally stricter than the ordinary majority
|
|
17
|
+
// path. A non-empty justification string is mandatory (empty = reject).
|
|
18
|
+
// The event type is `EMERGENCY_REKEY`, separately logged, unskippable, and
|
|
19
|
+
// deliberately alarming — it must never become the normal path.
|
|
20
|
+
//
|
|
21
|
+
// N=1 (sole-keyholder) semantics (D-193 / D-194 boundary note):
|
|
22
|
+
// At N=1, the ordinary-path majority formula (Math.floor(1/2)+1 = 1) and the
|
|
23
|
+
// emergency unanimity requirement (1 of 1) both reduce to a single required
|
|
24
|
+
// approval. This is a mathematical boundary analogous to the N=2 quorum
|
|
25
|
+
// degenerate case for revocation (documented in SECURITY.md as D-163). It
|
|
26
|
+
// does NOT mean the two paths are interchangeable: the event types, the
|
|
27
|
+
// activation-delay rule (ordinary only), and the mandatory-justification rule
|
|
28
|
+
// (emergency only) remain distinct at all N, including N=1. The "strictly
|
|
29
|
+
// harder" principal applies in the domain of audit/logging and operational
|
|
30
|
+
// friction — not solely in approval count.
|
|
31
|
+
//
|
|
32
|
+
// Tamper-evidence: both events are written to the append-only checkpoint via
|
|
33
|
+
// core/append-only-checkpoint.mjs (SAT-546/SAT-561 wiring), so they are
|
|
34
|
+
// tamper-evident like every other trust-layer event.
|
|
35
|
+
//
|
|
36
|
+
// No egress, no new hand-rolled crypto: all signing uses the quorum primitives
|
|
37
|
+
// already in core/key-store.mjs (hmacPayload is reproduced locally as it is
|
|
38
|
+
// not exported from that module; the crypto is identical: HMAC-SHA256 over a
|
|
39
|
+
// canonical JSON payload via Node's own node:crypto).
|
|
40
|
+
//
|
|
41
|
+
// Pure Node stdlib, ESM — no third-party dependencies.
|
|
42
|
+
|
|
43
|
+
import { createHmac, timingSafeEqual } from 'node:crypto';
|
|
44
|
+
import { appendCheckpointEntry, openCheckpoint } from './append-only-checkpoint.mjs';
|
|
45
|
+
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
// Re-key activation window constants (D-193)
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
|
|
50
|
+
/** Minimum activation delay in milliseconds: 48 hours. */
|
|
51
|
+
export const REKEY_DELAY_MIN_MS = 48 * 60 * 60 * 1000;
|
|
52
|
+
|
|
53
|
+
/** Maximum activation delay in milliseconds: 72 hours. */
|
|
54
|
+
export const REKEY_DELAY_MAX_MS = 72 * 60 * 60 * 1000;
|
|
55
|
+
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
// Internal helpers (package-private; not exported)
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Compute HMAC-SHA256 over a canonical JSON serialisation of `payload`.
|
|
62
|
+
* Keys are sorted alphabetically. Returns a lowercase 64-char hex string.
|
|
63
|
+
*
|
|
64
|
+
* Mirrors the identical helper inside core/key-store.mjs; duplicated here
|
|
65
|
+
* because key-store.mjs does not export it (it is intentionally internal).
|
|
66
|
+
* Crypto semantics are unchanged — both helpers produce the same HMAC given
|
|
67
|
+
* the same key and payload.
|
|
68
|
+
*
|
|
69
|
+
* @param {Buffer} keyBuf
|
|
70
|
+
* @param {object} payload
|
|
71
|
+
* @returns {string}
|
|
72
|
+
*/
|
|
73
|
+
function _hmacPayload(keyBuf, payload) {
|
|
74
|
+
const sorted = Object.fromEntries(
|
|
75
|
+
Object.keys(payload).sort().map(k => [k, payload[k]]),
|
|
76
|
+
);
|
|
77
|
+
return createHmac('sha256', keyBuf).update(JSON.stringify(sorted)).digest('hex');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The canonical payload that approval signers HMAC to authorise a re-key.
|
|
82
|
+
* Keys are sorted alphabetically (matching _hmacPayload).
|
|
83
|
+
*
|
|
84
|
+
* @param {string} newKeyId Identifier of the proposed replacement key.
|
|
85
|
+
* @param {string} eventType `'TRUST_ROOT_REKEY'` or `'EMERGENCY_REKEY'`.
|
|
86
|
+
* @returns {{ action: string, event_type: string, new_key_id: string }}
|
|
87
|
+
*/
|
|
88
|
+
function _rekeyPayload(newKeyId, eventType) {
|
|
89
|
+
// action + event_type + new_key_id — alphabetically sorted already.
|
|
90
|
+
return { action: 'rekey_trust_root', event_type: eventType, new_key_id: newKeyId };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Collect the set of currently non-revoked keys from the store.
|
|
95
|
+
*
|
|
96
|
+
* @param {{ keys: Map<string, object> }} ks
|
|
97
|
+
* @returns {object[]} Array of KeyEntry objects with status !== 'revoked'.
|
|
98
|
+
*/
|
|
99
|
+
function _goodKeys(ks) {
|
|
100
|
+
return [...ks.keys.values()].filter(e => e.status !== 'revoked');
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ---------------------------------------------------------------------------
|
|
104
|
+
// makeRekeyApproval
|
|
105
|
+
// ---------------------------------------------------------------------------
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Produce a re-key approval token signed by `signingKeyId`.
|
|
109
|
+
*
|
|
110
|
+
* The token is a `{ key_id, sig }` object that can be collected from multiple
|
|
111
|
+
* non-revoked keyholders and passed as the `approvals` array to either
|
|
112
|
+
* {@link rekey} (ordinary path) or {@link emergencyRekey} (emergency path).
|
|
113
|
+
*
|
|
114
|
+
* The approval binds to the proposed `newKeyId` and to the `eventType`
|
|
115
|
+
* (`'TRUST_ROOT_REKEY'` or `'EMERGENCY_REKEY'`) — a token for one path CANNOT
|
|
116
|
+
* satisfy the other because the canonical HMAC payload includes `event_type`.
|
|
117
|
+
*
|
|
118
|
+
* Throws if `signingKeyId` is unknown, revoked, or equal to `newKeyId` (the
|
|
119
|
+
* incoming replacement key may not approve its own introduction).
|
|
120
|
+
*
|
|
121
|
+
* @param {{ keys: Map<string,object> }} ks
|
|
122
|
+
* @param {string} signingKeyId The existing non-revoked key signing the approval.
|
|
123
|
+
* @param {string} newKeyId The proposed new key's identifier.
|
|
124
|
+
* @param {'TRUST_ROOT_REKEY'|'EMERGENCY_REKEY'} eventType
|
|
125
|
+
* @returns {{ key_id: string, sig: string }}
|
|
126
|
+
*/
|
|
127
|
+
export function makeRekeyApproval(ks, signingKeyId, newKeyId, eventType) {
|
|
128
|
+
if (signingKeyId === newKeyId) {
|
|
129
|
+
throw new Error('makeRekeyApproval: signing key and new key must differ');
|
|
130
|
+
}
|
|
131
|
+
if (eventType !== 'TRUST_ROOT_REKEY' && eventType !== 'EMERGENCY_REKEY') {
|
|
132
|
+
throw new Error(`makeRekeyApproval: eventType must be 'TRUST_ROOT_REKEY' or 'EMERGENCY_REKEY', got ${JSON.stringify(eventType)}`);
|
|
133
|
+
}
|
|
134
|
+
const entry = ks.keys.get(signingKeyId);
|
|
135
|
+
if (!entry) {
|
|
136
|
+
throw new Error(`makeRekeyApproval: unknown key_id ${JSON.stringify(signingKeyId)}`);
|
|
137
|
+
}
|
|
138
|
+
if (entry.status === 'revoked') {
|
|
139
|
+
throw new Error(`makeRekeyApproval: signing key ${JSON.stringify(signingKeyId)} is revoked`);
|
|
140
|
+
}
|
|
141
|
+
const sig = _hmacPayload(entry.raw, _rekeyPayload(newKeyId, eventType));
|
|
142
|
+
return { key_id: signingKeyId, sig };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// ---------------------------------------------------------------------------
|
|
146
|
+
// _validateApprovals — shared quorum-verification helper
|
|
147
|
+
// ---------------------------------------------------------------------------
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* @internal
|
|
151
|
+
* Validate a set of re-key approval tokens.
|
|
152
|
+
*
|
|
153
|
+
* Returns `{ valid: number, seenIds: Set<string> }`.
|
|
154
|
+
*
|
|
155
|
+
* Each approval must:
|
|
156
|
+
* - carry a `key_id` and `sig`
|
|
157
|
+
* - reference a distinct, non-revoked key in `ks`
|
|
158
|
+
* - NOT be `newKeyId` (the new key cannot approve its own introduction)
|
|
159
|
+
* - carry a correct HMAC over `_rekeyPayload(newKeyId, eventType)`
|
|
160
|
+
*
|
|
161
|
+
* Tokens that fail any of these checks are silently skipped.
|
|
162
|
+
* Duplicate signers (same `key_id` appearing more than once) are deduplicated.
|
|
163
|
+
*
|
|
164
|
+
* @param {{ keys: Map<string,object> }} ks
|
|
165
|
+
* @param {string} newKeyId
|
|
166
|
+
* @param {'TRUST_ROOT_REKEY'|'EMERGENCY_REKEY'} eventType
|
|
167
|
+
* @param {{ key_id: string, sig: string }[]} approvals
|
|
168
|
+
* @returns {{ valid: number, seenIds: Set<string> }}
|
|
169
|
+
*/
|
|
170
|
+
function _validateApprovals(ks, newKeyId, eventType, approvals) {
|
|
171
|
+
const payload = _rekeyPayload(newKeyId, eventType);
|
|
172
|
+
const seen = new Set();
|
|
173
|
+
let valid = 0;
|
|
174
|
+
|
|
175
|
+
for (const approval of approvals) {
|
|
176
|
+
const { key_id: aKeyId, sig } = approval ?? {};
|
|
177
|
+
if (!aKeyId || !sig) continue;
|
|
178
|
+
if (aKeyId === newKeyId) continue; // new key cannot self-approve
|
|
179
|
+
if (seen.has(aKeyId)) continue; // deduplicate per signer
|
|
180
|
+
|
|
181
|
+
const aEntry = ks.keys.get(aKeyId);
|
|
182
|
+
if (!aEntry || aEntry.status === 'revoked') continue;
|
|
183
|
+
|
|
184
|
+
const expected = _hmacPayload(aEntry.raw, payload);
|
|
185
|
+
if (typeof sig !== 'string' || sig.length !== expected.length) continue;
|
|
186
|
+
|
|
187
|
+
let match = false;
|
|
188
|
+
try {
|
|
189
|
+
match = timingSafeEqual(Buffer.from(expected, 'hex'), Buffer.from(sig, 'hex'));
|
|
190
|
+
} catch { match = false; }
|
|
191
|
+
|
|
192
|
+
if (match) {
|
|
193
|
+
seen.add(aKeyId);
|
|
194
|
+
valid++;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return { valid, seenIds: seen };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// ---------------------------------------------------------------------------
|
|
202
|
+
// rekey — ordinary path (D-193)
|
|
203
|
+
// ---------------------------------------------------------------------------
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Ordinary trust-root re-key ceremony.
|
|
207
|
+
*
|
|
208
|
+
* A majority of the currently non-revoked keyholders (`Math.floor(N/2)+1`
|
|
209
|
+
* where `N` is the count of non-revoked keys in `ks`) must co-sign the
|
|
210
|
+
* re-key request. If the quorum is met, the new key is staged with a
|
|
211
|
+
* mandatory 48–72 h activation delay — it does NOT become the active key
|
|
212
|
+
* immediately. The calling code is responsible for activating the key
|
|
213
|
+
* once `activatesAt` has passed.
|
|
214
|
+
*
|
|
215
|
+
* **N=1 semantics:** at N=1 the majority formula reduces to 1 (the sole
|
|
216
|
+
* keyholder must approve). See the module header for the N=1 boundary note.
|
|
217
|
+
*
|
|
218
|
+
* The returned event object carries `event: 'TRUST_ROOT_REKEY'` and is
|
|
219
|
+
* written to the append-only checkpoint when `checkpointPath` is supplied.
|
|
220
|
+
*
|
|
221
|
+
* @param {{ keys: Map<string,object>, activeKeyId: string|null }} ks
|
|
222
|
+
* The current key store. Not mutated — staging is pure data.
|
|
223
|
+
* @param {string} newKeyId
|
|
224
|
+
* A caller-assigned identifier for the proposed replacement key. The
|
|
225
|
+
* calling code is responsible for keeping this stable until activation.
|
|
226
|
+
* @param {{ key_id: string, sig: string }[]} approvals
|
|
227
|
+
* Approval tokens produced by {@link makeRekeyApproval} for event type
|
|
228
|
+
* `'TRUST_ROOT_REKEY'`.
|
|
229
|
+
* @param {object} [opts]
|
|
230
|
+
* @param {number} [opts.now] Override `Date.now()` (for tests).
|
|
231
|
+
* @param {string} [opts.checkpointPath] Path to the append-only JSONL checkpoint.
|
|
232
|
+
* @returns {{
|
|
233
|
+
* event: 'TRUST_ROOT_REKEY',
|
|
234
|
+
* new_key_id: string,
|
|
235
|
+
* valid_approvals: number,
|
|
236
|
+
* required_approvals: number,
|
|
237
|
+
* signed_at: string,
|
|
238
|
+
* activates_at: string,
|
|
239
|
+
* activates_after_ms: number,
|
|
240
|
+
* }}
|
|
241
|
+
* @throws {Error} If the quorum is not met.
|
|
242
|
+
*/
|
|
243
|
+
export function rekey(ks, newKeyId, approvals = [], opts = {}) {
|
|
244
|
+
const now = opts.now ?? Date.now();
|
|
245
|
+
|
|
246
|
+
if (!newKeyId || typeof newKeyId !== 'string') {
|
|
247
|
+
throw new TypeError('rekey: newKeyId must be a non-empty string');
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const good = _goodKeys(ks);
|
|
251
|
+
const N = good.length;
|
|
252
|
+
// Ordinary-path quorum: strict majority of non-revoked keys.
|
|
253
|
+
// Formula: Math.floor(N/2)+1 — the same strict-majority formula used
|
|
254
|
+
// by the key-store's peer-approval quorum gate (core/key-store.mjs).
|
|
255
|
+
// At N=0 (no non-revoked keys) there is no one to co-sign; we still
|
|
256
|
+
// require at least 1 to prevent a completely unguarded store from
|
|
257
|
+
// triggering a re-key with zero approvals.
|
|
258
|
+
const required = N > 0 ? Math.floor(N / 2) + 1 : 1;
|
|
259
|
+
|
|
260
|
+
const { valid } = _validateApprovals(ks, newKeyId, 'TRUST_ROOT_REKEY', approvals);
|
|
261
|
+
|
|
262
|
+
if (valid < required) {
|
|
263
|
+
throw new Error(
|
|
264
|
+
`rekey: quorum not met — ${required} approval(s) required from non-revoked keyholders, got ${valid}`,
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const signedAt = new Date(now).toISOString();
|
|
269
|
+
const activatesAfterMs = REKEY_DELAY_MIN_MS; // 48 h lower bound
|
|
270
|
+
const activatesAt = new Date(now + activatesAfterMs).toISOString();
|
|
271
|
+
|
|
272
|
+
const entry = {
|
|
273
|
+
event: 'TRUST_ROOT_REKEY',
|
|
274
|
+
new_key_id: newKeyId,
|
|
275
|
+
valid_approvals: valid,
|
|
276
|
+
required_approvals: required,
|
|
277
|
+
signed_at: signedAt,
|
|
278
|
+
activates_at: activatesAt,
|
|
279
|
+
activates_after_ms: activatesAfterMs,
|
|
280
|
+
};
|
|
281
|
+
|
|
282
|
+
// Write to the append-only checkpoint so the event is tamper-evident.
|
|
283
|
+
if (opts.checkpointPath != null) {
|
|
284
|
+
try {
|
|
285
|
+
openCheckpoint(opts.checkpointPath);
|
|
286
|
+
appendCheckpointEntry(opts.checkpointPath, entry);
|
|
287
|
+
} catch { /* best-effort — checkpoint failure must not suppress the event */ }
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
return entry;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// ---------------------------------------------------------------------------
|
|
294
|
+
// emergencyRekey — emergency override path (D-194)
|
|
295
|
+
// ---------------------------------------------------------------------------
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Emergency trust-root re-key (override, D-194).
|
|
299
|
+
*
|
|
300
|
+
* UNCONDITIONALLY stricter than the ordinary path:
|
|
301
|
+
* - UNANIMOUS sign-off from EVERY currently non-revoked keyholder is required
|
|
302
|
+
* (not just a majority). A single missing approval fails the ceremony.
|
|
303
|
+
* - A non-empty `justification` string is mandatory — an empty or absent
|
|
304
|
+
* justification is rejected before any signature verification.
|
|
305
|
+
* - The event type is `EMERGENCY_REKEY`, permanently and separately logged;
|
|
306
|
+
* it CANNOT be emitted through the `assembleLedgerEntry` API in
|
|
307
|
+
* signing-tiers.mjs (that function hardcodes `event: 'EXCEPTION_GRANTED'`).
|
|
308
|
+
* - There is intentionally NO activation delay: because the full unanimous
|
|
309
|
+
* quorum is the gate (vs. majority), the additional 48-72 h window would
|
|
310
|
+
* add friction without adding cryptographic strength when ALL keyholders
|
|
311
|
+
* are already on record. This also keeps the emergency path distinguishable
|
|
312
|
+
* from the ordinary path in operational terms.
|
|
313
|
+
*
|
|
314
|
+
* **N=1 semantics:** at N=1 unanimity = 1 required approval (same as majority).
|
|
315
|
+
* See the module header for the documented boundary note.
|
|
316
|
+
*
|
|
317
|
+
* @param {{ keys: Map<string,object>, activeKeyId: string|null }} ks
|
|
318
|
+
* @param {string} newKeyId
|
|
319
|
+
* Identifier for the proposed replacement key.
|
|
320
|
+
* @param {{ key_id: string, sig: string }[]} approvals
|
|
321
|
+
* Approval tokens produced by {@link makeRekeyApproval} for event type
|
|
322
|
+
* `'EMERGENCY_REKEY'`. MUST cover every non-revoked keyholder.
|
|
323
|
+
* @param {string} justification
|
|
324
|
+
* Mandatory human-readable reason (non-empty).
|
|
325
|
+
* @param {object} [opts]
|
|
326
|
+
* @param {number} [opts.now] Override `Date.now()` (for tests).
|
|
327
|
+
* @param {string} [opts.checkpointPath] Path to the append-only JSONL checkpoint.
|
|
328
|
+
* @returns {{
|
|
329
|
+
* event: 'EMERGENCY_REKEY',
|
|
330
|
+
* new_key_id: string,
|
|
331
|
+
* valid_approvals: number,
|
|
332
|
+
* required_approvals: number,
|
|
333
|
+
* missing_signers: number,
|
|
334
|
+
* justification: string,
|
|
335
|
+
* signed_at: string,
|
|
336
|
+
* }}
|
|
337
|
+
* @throws {Error} If justification is empty, or if unanimity is not achieved.
|
|
338
|
+
*/
|
|
339
|
+
export function emergencyRekey(ks, newKeyId, approvals = [], justification, opts = {}) {
|
|
340
|
+
if (!justification || typeof justification !== 'string' || justification.trim().length === 0) {
|
|
341
|
+
throw new Error('emergencyRekey: justification must be a non-empty string — cannot be omitted on the emergency path');
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
if (!newKeyId || typeof newKeyId !== 'string') {
|
|
345
|
+
throw new TypeError('emergencyRekey: newKeyId must be a non-empty string');
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const now = opts.now ?? Date.now();
|
|
349
|
+
const good = _goodKeys(ks);
|
|
350
|
+
const N = good.length;
|
|
351
|
+
|
|
352
|
+
// Emergency path: unanimity required — all N non-revoked keyholders must sign.
|
|
353
|
+
// At N=0 (no non-revoked keys) we still require at least 1 approval to prevent
|
|
354
|
+
// a vacuously "unanimous" empty-set approval from an exhausted store.
|
|
355
|
+
const required = Math.max(N, 1);
|
|
356
|
+
|
|
357
|
+
const { valid } = _validateApprovals(ks, newKeyId, 'EMERGENCY_REKEY', approvals);
|
|
358
|
+
const missing = required - valid;
|
|
359
|
+
|
|
360
|
+
if (valid < required) {
|
|
361
|
+
throw new Error(
|
|
362
|
+
`emergencyRekey: unanimity not achieved — all ${required} non-revoked keyholder(s) must approve, got ${valid} (${missing} missing)`,
|
|
363
|
+
);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
const signedAt = new Date(now).toISOString();
|
|
367
|
+
|
|
368
|
+
const entry = {
|
|
369
|
+
event: 'EMERGENCY_REKEY',
|
|
370
|
+
new_key_id: newKeyId,
|
|
371
|
+
valid_approvals: valid,
|
|
372
|
+
required_approvals: required,
|
|
373
|
+
missing_signers: 0,
|
|
374
|
+
justification,
|
|
375
|
+
signed_at: signedAt,
|
|
376
|
+
};
|
|
377
|
+
|
|
378
|
+
// Write to the append-only checkpoint so the event is tamper-evident.
|
|
379
|
+
if (opts.checkpointPath != null) {
|
|
380
|
+
try {
|
|
381
|
+
openCheckpoint(opts.checkpointPath);
|
|
382
|
+
appendCheckpointEntry(opts.checkpointPath, entry);
|
|
383
|
+
} catch { /* best-effort */ }
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
return entry;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// ---------------------------------------------------------------------------
|
|
390
|
+
// isRekeyActive — check whether a staged TRUST_ROOT_REKEY has passed its window
|
|
391
|
+
// ---------------------------------------------------------------------------
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* Return whether a staged re-key event (from {@link rekey}) has passed its
|
|
395
|
+
* mandatory activation window.
|
|
396
|
+
*
|
|
397
|
+
* Does NOT activate the key — the calling code is responsible for actually
|
|
398
|
+
* inserting the new key into the store once this returns `true`. This
|
|
399
|
+
* function is intentionally pure (no side-effects) so it can be called to
|
|
400
|
+
* check scheduling without risk of inadvertent activation.
|
|
401
|
+
*
|
|
402
|
+
* @param {{ activates_at: string }} rekeyEvent A `TRUST_ROOT_REKEY` event object.
|
|
403
|
+
* @param {number} [now] Override `Date.now()` (for tests).
|
|
404
|
+
* @returns {boolean}
|
|
405
|
+
*/
|
|
406
|
+
export function isRekeyActive(rekeyEvent, now = Date.now()) {
|
|
407
|
+
const activatesAt = Date.parse(rekeyEvent?.activates_at);
|
|
408
|
+
if (!Number.isFinite(activatesAt)) return false;
|
|
409
|
+
return now >= activatesAt;
|
|
410
|
+
}
|