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.
Files changed (64) hide show
  1. package/CHANGELOG.md +149 -93
  2. package/CREDITS.md +14 -14
  3. package/LICENSE.md +76 -76
  4. package/LIMITATIONS.md +33 -23
  5. package/PRIVACY.md +30 -30
  6. package/README.md +170 -117
  7. package/SECURITY.md +78 -46
  8. package/action/post-comment.mjs +94 -89
  9. package/action.yml +62 -62
  10. package/bin/knosky.mjs +279 -105
  11. package/core/CONTRACT.md +70 -70
  12. package/core/append-only-checkpoint.mjs +215 -0
  13. package/core/audit-writer.mjs +317 -0
  14. package/core/benchmark-results.mjs +225 -225
  15. package/core/bundle.mjs +178 -178
  16. package/core/churn.mjs +23 -23
  17. package/core/ci.mjs +268 -268
  18. package/core/comparison.mjs +189 -189
  19. package/core/config.mjs +189 -189
  20. package/core/constants.mjs +13 -13
  21. package/core/contract.mjs +123 -123
  22. package/core/cross-repo.mjs +111 -111
  23. package/core/decision-codes.mjs +92 -0
  24. package/core/destination.mjs +161 -161
  25. package/core/district-classification.mjs +111 -0
  26. package/core/doctor-scorecard.mjs +369 -0
  27. package/core/domain-store.mjs +347 -0
  28. package/core/edges.mjs +43 -43
  29. package/core/escalate.mjs +68 -68
  30. package/core/freshness.mjs +198 -194
  31. package/core/fs-indexer.mjs +218 -218
  32. package/core/key-store.mjs +348 -348
  33. package/core/layout.mjs +46 -46
  34. package/core/ledger.mjs +176 -141
  35. package/core/local-ipc-identity.mjs +500 -0
  36. package/core/lod.mjs +155 -155
  37. package/core/mode-b.mjs +410 -0
  38. package/core/multi-model-benchmark.mjs +405 -405
  39. package/core/net-lockdown.mjs +421 -0
  40. package/core/onboarding.mjs +223 -223
  41. package/core/operator-auth.mjs +317 -0
  42. package/core/overlays.mjs +45 -45
  43. package/core/policy-lattice.mjs +142 -0
  44. package/core/pr-comment.mjs +198 -198
  45. package/core/protocol-spec.mjs +460 -460
  46. package/core/provenance.mjs +320 -0
  47. package/core/retrieve.mjs +63 -63
  48. package/core/route.mjs +304 -304
  49. package/core/schema.mjs +275 -275
  50. package/core/signing-tiers.mjs +1265 -0
  51. package/core/swarm-bench.mjs +106 -0
  52. package/core/swarm-coordinator.mjs +867 -0
  53. package/core/trust-root-rekey.mjs +410 -0
  54. package/mcp/server.mjs +264 -108
  55. package/package.json +56 -46
  56. package/renderer/art/kenney/buildingTiles_sheet.xml +130 -130
  57. package/renderer/art/kenney/cityDetails_sheet.xml +12 -12
  58. package/renderer/art/kenney/landscapeTiles_sheet.xml +129 -129
  59. package/renderer/art/kenney/sheet_allCars.xml +545 -545
  60. package/renderer/build-rich.mjs +43 -43
  61. package/renderer/city.template.html +808 -808
  62. package/ssot/decision-codes.json +133 -0
  63. package/ssot/ladder-l0-l3.md +232 -0
  64. 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
+ }