knosky 0.6.2 → 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 -824
- package/ssot/decision-codes.json +133 -0
- package/ssot/ladder-l0-l3.md +232 -0
- package/ssot/tool-menu.json +130 -0
package/core/key-store.mjs
CHANGED
|
@@ -1,348 +1,348 @@
|
|
|
1
|
-
// KnoSky signing-key rotation/revocation lifecycle (SAT-446 / Hardening Addendum 2).
|
|
2
|
-
// In-process HMAC-SHA256 key store — local-first, no external KMS, no telemetry.
|
|
3
|
-
// Callers own persistence; this module is pure in-memory state + crypto.
|
|
4
|
-
// Pure Node stdlib, ESM — no third-party dependencies.
|
|
5
|
-
import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
6
|
-
|
|
7
|
-
// ---------------------------------------------------------------------------
|
|
8
|
-
// Internal helpers
|
|
9
|
-
// ---------------------------------------------------------------------------
|
|
10
|
-
|
|
11
|
-
/** Generate a random 16-byte key id as a 32-char lowercase hex string. */
|
|
12
|
-
function newKeyId() {
|
|
13
|
-
return randomBytes(16).toString('hex');
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
/**
|
|
17
|
-
* Compute HMAC-SHA256 over a canonical JSON serialisation of `payload`.
|
|
18
|
-
* "Canonical" = top-level keys sorted alphabetically before serialisation.
|
|
19
|
-
* Returns a lowercase 64-char hex string.
|
|
20
|
-
*
|
|
21
|
-
* @param {Buffer} keyBuf
|
|
22
|
-
* @param {object} payload
|
|
23
|
-
* @returns {string}
|
|
24
|
-
*/
|
|
25
|
-
function hmacPayload(keyBuf, payload) {
|
|
26
|
-
const sorted = Object.fromEntries(
|
|
27
|
-
Object.keys(payload).sort().map(k => [k, payload[k]]),
|
|
28
|
-
);
|
|
29
|
-
return createHmac('sha256', keyBuf).update(JSON.stringify(sorted)).digest('hex');
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
/**
|
|
33
|
-
* Strip `key_id` and `sig` from a manifest object to get the signing payload.
|
|
34
|
-
* These two fields are metadata added *after* signing and must not be part
|
|
35
|
-
* of the signed content.
|
|
36
|
-
*
|
|
37
|
-
* @param {object} manifest
|
|
38
|
-
* @returns {object}
|
|
39
|
-
*/
|
|
40
|
-
function signingPayload(manifest) {
|
|
41
|
-
const out = {};
|
|
42
|
-
for (const [k, v] of Object.entries(manifest)) {
|
|
43
|
-
if (k === 'key_id' || k === 'sig') continue;
|
|
44
|
-
out[k] = v;
|
|
45
|
-
}
|
|
46
|
-
return out;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
// ---------------------------------------------------------------------------
|
|
50
|
-
// createKeyStore
|
|
51
|
-
// ---------------------------------------------------------------------------
|
|
52
|
-
|
|
53
|
-
/**
|
|
54
|
-
* Create a new in-memory key store with a single initial active key.
|
|
55
|
-
*
|
|
56
|
-
* The returned object has the following shape:
|
|
57
|
-
* ```
|
|
58
|
-
* {
|
|
59
|
-
* keys: Map<key_id, KeyEntry>,
|
|
60
|
-
* activeKeyId: string | null,
|
|
61
|
-
* }
|
|
62
|
-
* ```
|
|
63
|
-
*
|
|
64
|
-
* where each `KeyEntry` is:
|
|
65
|
-
* ```
|
|
66
|
-
* { key_id: string, status: 'active'|'rotated'|'revoked', created_at: string, raw: Buffer }
|
|
67
|
-
* ```
|
|
68
|
-
*
|
|
69
|
-
* @returns {{ keys: Map<string, object>, activeKeyId: string }}
|
|
70
|
-
*/
|
|
71
|
-
export function createKeyStore() {
|
|
72
|
-
const ks = { keys: new Map(), activeKeyId: null };
|
|
73
|
-
_issueKey(ks);
|
|
74
|
-
return ks;
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
/**
|
|
78
|
-
* @internal
|
|
79
|
-
* Issue a new active key. Demotes the current active key to `'rotated'` if
|
|
80
|
-
* one exists (rotated keys remain verifiable; only revoked ones are rejected).
|
|
81
|
-
*
|
|
82
|
-
* @param {{ keys: Map<string,object>, activeKeyId: string|null }} ks
|
|
83
|
-
* @returns {string} The newly-issued key_id.
|
|
84
|
-
*/
|
|
85
|
-
function _issueKey(ks) {
|
|
86
|
-
// Demote any current active key → 'rotated' (still usable for verification).
|
|
87
|
-
if (ks.activeKeyId !== null) {
|
|
88
|
-
const prev = ks.keys.get(ks.activeKeyId);
|
|
89
|
-
if (prev && prev.status === 'active') prev.status = 'rotated';
|
|
90
|
-
}
|
|
91
|
-
const key_id = newKeyId();
|
|
92
|
-
ks.keys.set(key_id, {
|
|
93
|
-
key_id,
|
|
94
|
-
status: 'active',
|
|
95
|
-
created_at: new Date().toISOString(),
|
|
96
|
-
raw: randomBytes(32),
|
|
97
|
-
});
|
|
98
|
-
ks.activeKeyId = key_id;
|
|
99
|
-
return key_id;
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
// ---------------------------------------------------------------------------
|
|
103
|
-
// rotateKey
|
|
104
|
-
// ---------------------------------------------------------------------------
|
|
105
|
-
|
|
106
|
-
/**
|
|
107
|
-
* Rotate the signing key: demote the current active key to `'rotated'` and
|
|
108
|
-
* issue a fresh one.
|
|
109
|
-
*
|
|
110
|
-
* Manifests already signed with the old (now `'rotated'`) key remain
|
|
111
|
-
* verifiable. Only `'revoked'` keys are rejected by {@link verifyManifest}.
|
|
112
|
-
*
|
|
113
|
-
* @param {{ keys: Map<string,object>, activeKeyId: string|null }} ks
|
|
114
|
-
* @returns {string} The new active key_id.
|
|
115
|
-
*/
|
|
116
|
-
export function rotateKey(ks) {
|
|
117
|
-
return _issueKey(ks);
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
// ---------------------------------------------------------------------------
|
|
121
|
-
// revokeKey / makeRevocationApproval
|
|
122
|
-
// ---------------------------------------------------------------------------
|
|
123
|
-
|
|
124
|
-
/**
|
|
125
|
-
* The canonical payload that approval signers HMAC to authorise a revocation.
|
|
126
|
-
* Keys are sorted alphabetically (matching {@link hmacPayload}).
|
|
127
|
-
*
|
|
128
|
-
* @param {string} targetKeyId
|
|
129
|
-
* @returns {{ action: string, target_key_id: string }}
|
|
130
|
-
*/
|
|
131
|
-
function revocationPayload(targetKeyId) {
|
|
132
|
-
// Return in key-sorted order so hmacPayload produces a deterministic digest.
|
|
133
|
-
return { action: 'revoke_key', target_key_id: targetKeyId };
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
/**
|
|
137
|
-
* Produce a revocation-approval token signed by `signingKeyId`.
|
|
138
|
-
*
|
|
139
|
-
* The token is an `{ key_id, sig }` object that can be collected from multiple
|
|
140
|
-
* non-revoked keys and passed as the `approvals` array to {@link revokeKey}
|
|
141
|
-
* to satisfy the quorum requirement.
|
|
142
|
-
*
|
|
143
|
-
* Throws if `signingKeyId` is unknown, already revoked, or equal to
|
|
144
|
-
* `targetKeyId` (a key may not approve its own revocation).
|
|
145
|
-
*
|
|
146
|
-
* @param {{ keys: Map<string,object> }} ks
|
|
147
|
-
* @param {string} signingKeyId
|
|
148
|
-
* @param {string} targetKeyId
|
|
149
|
-
* @returns {{ key_id: string, sig: string }}
|
|
150
|
-
*/
|
|
151
|
-
export function makeRevocationApproval(ks, signingKeyId, targetKeyId) {
|
|
152
|
-
if (signingKeyId === targetKeyId) {
|
|
153
|
-
throw new Error('makeRevocationApproval: signing key and target key must differ');
|
|
154
|
-
}
|
|
155
|
-
const entry = ks.keys.get(signingKeyId);
|
|
156
|
-
if (!entry) {
|
|
157
|
-
throw new Error(`makeRevocationApproval: unknown key_id ${JSON.stringify(signingKeyId)}`);
|
|
158
|
-
}
|
|
159
|
-
if (entry.status === 'revoked') {
|
|
160
|
-
throw new Error(`makeRevocationApproval: signing key ${JSON.stringify(signingKeyId)} is revoked`);
|
|
161
|
-
}
|
|
162
|
-
const sig = hmacPayload(entry.raw, revocationPayload(targetKeyId));
|
|
163
|
-
return { key_id: signingKeyId, sig };
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
/**
|
|
167
|
-
* Revoke a key by id.
|
|
168
|
-
*
|
|
169
|
-
* After revocation {@link verifyManifest} rejects any manifest that was
|
|
170
|
-
* signed with the revoked key. Revocation is permanent within a key-store
|
|
171
|
-
* instance. The operation is idempotent — revoking an already-revoked key
|
|
172
|
-
* is a no-op.
|
|
173
|
-
*
|
|
174
|
-
* If the revoked key was the active signing key, `activeKeyId` is cleared to
|
|
175
|
-
* `null`; callers must {@link rotateKey} to obtain a new active key.
|
|
176
|
-
*
|
|
177
|
-
* **Authorization / quorum gate (SAT-472)**
|
|
178
|
-
*
|
|
179
|
-
* Let `M` be the number of non-revoked keys other than `keyId`. The
|
|
180
|
-
* required quorum is `Math.floor(M / 2) + 1` (strict majority of peers).
|
|
181
|
-
* When `M === 0` (the target is the only remaining key) no approvals are
|
|
182
|
-
* needed. Each approval in the `approvals` array must be a valid
|
|
183
|
-
* {@link makeRevocationApproval} token signed by a distinct, non-revoked key
|
|
184
|
-
* that is not `keyId` itself.
|
|
185
|
-
*
|
|
186
|
-
* This ensures a single compromised key cannot unilaterally revoke all
|
|
187
|
-
* others: it can only contribute one approval, which is always less than
|
|
188
|
-
* the strict majority needed when `M >= 2`.
|
|
189
|
-
*
|
|
190
|
-
* Throws if `keyId` is not present in the store or if the quorum is not met.
|
|
191
|
-
*
|
|
192
|
-
* @param {{ keys: Map<string,object>, activeKeyId: string|null }} ks
|
|
193
|
-
* @param {string} keyId
|
|
194
|
-
* @param {{ key_id: string, sig: string }[]} [approvals=[]]
|
|
195
|
-
*/
|
|
196
|
-
export function revokeKey(ks, keyId, approvals = []) {
|
|
197
|
-
if (!ks.keys.has(keyId)) {
|
|
198
|
-
throw new Error(`revokeKey: unknown key_id ${JSON.stringify(keyId)}`);
|
|
199
|
-
}
|
|
200
|
-
const entry = ks.keys.get(keyId);
|
|
201
|
-
if (entry.status === 'revoked') return; // idempotent
|
|
202
|
-
|
|
203
|
-
// Count non-revoked peer keys (all keys except the revocation target).
|
|
204
|
-
const peers = [...ks.keys.values()].filter(
|
|
205
|
-
e => e.key_id !== keyId && e.status !== 'revoked',
|
|
206
|
-
);
|
|
207
|
-
const required = peers.length > 0 ? Math.floor(peers.length / 2) + 1 : 0;
|
|
208
|
-
|
|
209
|
-
if (required > 0) {
|
|
210
|
-
// Validate each approval token over the canonical revocation payload.
|
|
211
|
-
const payload = revocationPayload(keyId);
|
|
212
|
-
const seen = new Set();
|
|
213
|
-
let valid = 0;
|
|
214
|
-
|
|
215
|
-
for (const approval of approvals) {
|
|
216
|
-
const { key_id: aKeyId, sig } = approval ?? {};
|
|
217
|
-
if (!aKeyId || !sig) continue;
|
|
218
|
-
if (aKeyId === keyId) continue; // target cannot self-approve
|
|
219
|
-
if (seen.has(aKeyId)) continue; // deduplicate per signer
|
|
220
|
-
|
|
221
|
-
const aEntry = ks.keys.get(aKeyId);
|
|
222
|
-
if (!aEntry || aEntry.status === 'revoked') continue;
|
|
223
|
-
|
|
224
|
-
const expected = hmacPayload(aEntry.raw, payload);
|
|
225
|
-
if (typeof sig !== 'string' || sig.length !== expected.length) continue;
|
|
226
|
-
|
|
227
|
-
let match = false;
|
|
228
|
-
try {
|
|
229
|
-
match = timingSafeEqual(Buffer.from(expected, 'hex'), Buffer.from(sig, 'hex'));
|
|
230
|
-
} catch { match = false; }
|
|
231
|
-
|
|
232
|
-
if (match) {
|
|
233
|
-
seen.add(aKeyId);
|
|
234
|
-
valid++;
|
|
235
|
-
}
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
if (valid < required) {
|
|
239
|
-
throw new Error(
|
|
240
|
-
`revokeKey: quorum not met — ${required} approval(s) required from peer keys, got ${valid}`,
|
|
241
|
-
);
|
|
242
|
-
}
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
entry.status = 'revoked';
|
|
246
|
-
// If the active key was just revoked, clear the active pointer.
|
|
247
|
-
if (ks.activeKeyId === keyId) ks.activeKeyId = null;
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
// ---------------------------------------------------------------------------
|
|
251
|
-
// signManifest
|
|
252
|
-
// ---------------------------------------------------------------------------
|
|
253
|
-
|
|
254
|
-
/**
|
|
255
|
-
* Sign a manifest with the store's current active key.
|
|
256
|
-
*
|
|
257
|
-
* Returns a shallow copy of `manifest` with two new fields appended:
|
|
258
|
-
* - `key_id` — identifies the signing key for later verification.
|
|
259
|
-
* - `sig` — lowercase hex HMAC-SHA256 over the canonical signing payload
|
|
260
|
-
* (all manifest fields except `key_id` and `sig` themselves,
|
|
261
|
-
* with top-level keys sorted alphabetically).
|
|
262
|
-
*
|
|
263
|
-
* Throws if there is no active key in the store (e.g. after revoking the
|
|
264
|
-
* active key without rotating first).
|
|
265
|
-
*
|
|
266
|
-
* @param {{ keys: Map<string,object>, activeKeyId: string|null }} ks
|
|
267
|
-
* @param {object} manifest Typically the result of {@link makeIntentManifest}.
|
|
268
|
-
* @returns {object} Manifest copy decorated with `key_id` and `sig`.
|
|
269
|
-
*/
|
|
270
|
-
export function signManifest(ks, manifest) {
|
|
271
|
-
const entry = ks.activeKeyId != null ? ks.keys.get(ks.activeKeyId) : null;
|
|
272
|
-
if (!entry || entry.status !== 'active') {
|
|
273
|
-
throw new Error('signManifest: no active key in store — rotate to obtain one');
|
|
274
|
-
}
|
|
275
|
-
const payload = signingPayload(manifest);
|
|
276
|
-
const sig = hmacPayload(entry.raw, payload);
|
|
277
|
-
return { ...manifest, key_id: entry.key_id, sig };
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
// ---------------------------------------------------------------------------
|
|
281
|
-
// verifyManifest
|
|
282
|
-
// ---------------------------------------------------------------------------
|
|
283
|
-
|
|
284
|
-
/**
|
|
285
|
-
* Verify a signed manifest.
|
|
286
|
-
*
|
|
287
|
-
* Returns `{ ok: true }` when the signature is valid and the signing key is
|
|
288
|
-
* present and not revoked. Otherwise returns `{ ok: false, reason }`.
|
|
289
|
-
*
|
|
290
|
-
* Never throws on malformed input — treats it as a verification failure.
|
|
291
|
-
*
|
|
292
|
-
* | `reason` | Meaning |
|
|
293
|
-
* |-------------------|-----------------------------------------------------|
|
|
294
|
-
* | `'missing_key_id'`| `key_id` field absent from the manifest. |
|
|
295
|
-
* | `'unknown_key'` | `key_id` is not registered in this store. |
|
|
296
|
-
* | `'key_revoked'` | The key exists but has been revoked. |
|
|
297
|
-
* | `'bad_signature'` | HMAC did not match — data was tampered or wrong key.|
|
|
298
|
-
*
|
|
299
|
-
* @param {{ keys: Map<string,object>, activeKeyId: string|null }} ks
|
|
300
|
-
* @param {object} signedManifest
|
|
301
|
-
* @returns {{ ok: boolean, reason?: string }}
|
|
302
|
-
*/
|
|
303
|
-
export function verifyManifest(ks, signedManifest) {
|
|
304
|
-
const { key_id, sig } = signedManifest;
|
|
305
|
-
|
|
306
|
-
if (!key_id) return { ok: false, reason: 'missing_key_id' };
|
|
307
|
-
|
|
308
|
-
const entry = ks.keys.get(key_id);
|
|
309
|
-
if (!entry) return { ok: false, reason: 'unknown_key' };
|
|
310
|
-
if (entry.status === 'revoked') return { ok: false, reason: 'key_revoked' };
|
|
311
|
-
|
|
312
|
-
const payload = signingPayload(signedManifest);
|
|
313
|
-
const expected = hmacPayload(entry.raw, payload);
|
|
314
|
-
|
|
315
|
-
// Use timingSafeEqual to prevent timing-side-channel leaks.
|
|
316
|
-
// Both buffers must have the same length; a length mismatch is a sig failure.
|
|
317
|
-
const eBuf = Buffer.from(expected, 'hex');
|
|
318
|
-
if (typeof sig !== 'string' || sig.length !== expected.length) {
|
|
319
|
-
return { ok: false, reason: 'bad_signature' };
|
|
320
|
-
}
|
|
321
|
-
const aBuf = Buffer.from(sig, 'hex');
|
|
322
|
-
let match;
|
|
323
|
-
try {
|
|
324
|
-
match = timingSafeEqual(eBuf, aBuf);
|
|
325
|
-
} catch {
|
|
326
|
-
match = false;
|
|
327
|
-
}
|
|
328
|
-
return match ? { ok: true } : { ok: false, reason: 'bad_signature' };
|
|
329
|
-
}
|
|
330
|
-
|
|
331
|
-
// ---------------------------------------------------------------------------
|
|
332
|
-
// getKey
|
|
333
|
-
// ---------------------------------------------------------------------------
|
|
334
|
-
|
|
335
|
-
/**
|
|
336
|
-
* Return the metadata for a registered key (without the raw key material).
|
|
337
|
-
* Returns `null` if `keyId` is not in the store.
|
|
338
|
-
*
|
|
339
|
-
* @param {{ keys: Map<string,object> }} ks
|
|
340
|
-
* @param {string} keyId
|
|
341
|
-
* @returns {{ key_id: string, status: string, created_at: string } | null}
|
|
342
|
-
*/
|
|
343
|
-
export function getKey(ks, keyId) {
|
|
344
|
-
const entry = ks.keys.get(keyId);
|
|
345
|
-
if (!entry) return null;
|
|
346
|
-
// Never expose the raw key material to callers.
|
|
347
|
-
return { key_id: entry.key_id, status: entry.status, created_at: entry.created_at };
|
|
348
|
-
}
|
|
1
|
+
// KnoSky signing-key rotation/revocation lifecycle (SAT-446 / Hardening Addendum 2).
|
|
2
|
+
// In-process HMAC-SHA256 key store — local-first, no external KMS, no telemetry.
|
|
3
|
+
// Callers own persistence; this module is pure in-memory state + crypto.
|
|
4
|
+
// Pure Node stdlib, ESM — no third-party dependencies.
|
|
5
|
+
import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
6
|
+
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
// Internal helpers
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
|
|
11
|
+
/** Generate a random 16-byte key id as a 32-char lowercase hex string. */
|
|
12
|
+
function newKeyId() {
|
|
13
|
+
return randomBytes(16).toString('hex');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Compute HMAC-SHA256 over a canonical JSON serialisation of `payload`.
|
|
18
|
+
* "Canonical" = top-level keys sorted alphabetically before serialisation.
|
|
19
|
+
* Returns a lowercase 64-char hex string.
|
|
20
|
+
*
|
|
21
|
+
* @param {Buffer} keyBuf
|
|
22
|
+
* @param {object} payload
|
|
23
|
+
* @returns {string}
|
|
24
|
+
*/
|
|
25
|
+
function hmacPayload(keyBuf, payload) {
|
|
26
|
+
const sorted = Object.fromEntries(
|
|
27
|
+
Object.keys(payload).sort().map(k => [k, payload[k]]),
|
|
28
|
+
);
|
|
29
|
+
return createHmac('sha256', keyBuf).update(JSON.stringify(sorted)).digest('hex');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Strip `key_id` and `sig` from a manifest object to get the signing payload.
|
|
34
|
+
* These two fields are metadata added *after* signing and must not be part
|
|
35
|
+
* of the signed content.
|
|
36
|
+
*
|
|
37
|
+
* @param {object} manifest
|
|
38
|
+
* @returns {object}
|
|
39
|
+
*/
|
|
40
|
+
function signingPayload(manifest) {
|
|
41
|
+
const out = {};
|
|
42
|
+
for (const [k, v] of Object.entries(manifest)) {
|
|
43
|
+
if (k === 'key_id' || k === 'sig') continue;
|
|
44
|
+
out[k] = v;
|
|
45
|
+
}
|
|
46
|
+
return out;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
// createKeyStore
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Create a new in-memory key store with a single initial active key.
|
|
55
|
+
*
|
|
56
|
+
* The returned object has the following shape:
|
|
57
|
+
* ```
|
|
58
|
+
* {
|
|
59
|
+
* keys: Map<key_id, KeyEntry>,
|
|
60
|
+
* activeKeyId: string | null,
|
|
61
|
+
* }
|
|
62
|
+
* ```
|
|
63
|
+
*
|
|
64
|
+
* where each `KeyEntry` is:
|
|
65
|
+
* ```
|
|
66
|
+
* { key_id: string, status: 'active'|'rotated'|'revoked', created_at: string, raw: Buffer }
|
|
67
|
+
* ```
|
|
68
|
+
*
|
|
69
|
+
* @returns {{ keys: Map<string, object>, activeKeyId: string }}
|
|
70
|
+
*/
|
|
71
|
+
export function createKeyStore() {
|
|
72
|
+
const ks = { keys: new Map(), activeKeyId: null };
|
|
73
|
+
_issueKey(ks);
|
|
74
|
+
return ks;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* @internal
|
|
79
|
+
* Issue a new active key. Demotes the current active key to `'rotated'` if
|
|
80
|
+
* one exists (rotated keys remain verifiable; only revoked ones are rejected).
|
|
81
|
+
*
|
|
82
|
+
* @param {{ keys: Map<string,object>, activeKeyId: string|null }} ks
|
|
83
|
+
* @returns {string} The newly-issued key_id.
|
|
84
|
+
*/
|
|
85
|
+
function _issueKey(ks) {
|
|
86
|
+
// Demote any current active key → 'rotated' (still usable for verification).
|
|
87
|
+
if (ks.activeKeyId !== null) {
|
|
88
|
+
const prev = ks.keys.get(ks.activeKeyId);
|
|
89
|
+
if (prev && prev.status === 'active') prev.status = 'rotated';
|
|
90
|
+
}
|
|
91
|
+
const key_id = newKeyId();
|
|
92
|
+
ks.keys.set(key_id, {
|
|
93
|
+
key_id,
|
|
94
|
+
status: 'active',
|
|
95
|
+
created_at: new Date().toISOString(),
|
|
96
|
+
raw: randomBytes(32),
|
|
97
|
+
});
|
|
98
|
+
ks.activeKeyId = key_id;
|
|
99
|
+
return key_id;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ---------------------------------------------------------------------------
|
|
103
|
+
// rotateKey
|
|
104
|
+
// ---------------------------------------------------------------------------
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Rotate the signing key: demote the current active key to `'rotated'` and
|
|
108
|
+
* issue a fresh one.
|
|
109
|
+
*
|
|
110
|
+
* Manifests already signed with the old (now `'rotated'`) key remain
|
|
111
|
+
* verifiable. Only `'revoked'` keys are rejected by {@link verifyManifest}.
|
|
112
|
+
*
|
|
113
|
+
* @param {{ keys: Map<string,object>, activeKeyId: string|null }} ks
|
|
114
|
+
* @returns {string} The new active key_id.
|
|
115
|
+
*/
|
|
116
|
+
export function rotateKey(ks) {
|
|
117
|
+
return _issueKey(ks);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ---------------------------------------------------------------------------
|
|
121
|
+
// revokeKey / makeRevocationApproval
|
|
122
|
+
// ---------------------------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* The canonical payload that approval signers HMAC to authorise a revocation.
|
|
126
|
+
* Keys are sorted alphabetically (matching {@link hmacPayload}).
|
|
127
|
+
*
|
|
128
|
+
* @param {string} targetKeyId
|
|
129
|
+
* @returns {{ action: string, target_key_id: string }}
|
|
130
|
+
*/
|
|
131
|
+
function revocationPayload(targetKeyId) {
|
|
132
|
+
// Return in key-sorted order so hmacPayload produces a deterministic digest.
|
|
133
|
+
return { action: 'revoke_key', target_key_id: targetKeyId };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Produce a revocation-approval token signed by `signingKeyId`.
|
|
138
|
+
*
|
|
139
|
+
* The token is an `{ key_id, sig }` object that can be collected from multiple
|
|
140
|
+
* non-revoked keys and passed as the `approvals` array to {@link revokeKey}
|
|
141
|
+
* to satisfy the quorum requirement.
|
|
142
|
+
*
|
|
143
|
+
* Throws if `signingKeyId` is unknown, already revoked, or equal to
|
|
144
|
+
* `targetKeyId` (a key may not approve its own revocation).
|
|
145
|
+
*
|
|
146
|
+
* @param {{ keys: Map<string,object> }} ks
|
|
147
|
+
* @param {string} signingKeyId
|
|
148
|
+
* @param {string} targetKeyId
|
|
149
|
+
* @returns {{ key_id: string, sig: string }}
|
|
150
|
+
*/
|
|
151
|
+
export function makeRevocationApproval(ks, signingKeyId, targetKeyId) {
|
|
152
|
+
if (signingKeyId === targetKeyId) {
|
|
153
|
+
throw new Error('makeRevocationApproval: signing key and target key must differ');
|
|
154
|
+
}
|
|
155
|
+
const entry = ks.keys.get(signingKeyId);
|
|
156
|
+
if (!entry) {
|
|
157
|
+
throw new Error(`makeRevocationApproval: unknown key_id ${JSON.stringify(signingKeyId)}`);
|
|
158
|
+
}
|
|
159
|
+
if (entry.status === 'revoked') {
|
|
160
|
+
throw new Error(`makeRevocationApproval: signing key ${JSON.stringify(signingKeyId)} is revoked`);
|
|
161
|
+
}
|
|
162
|
+
const sig = hmacPayload(entry.raw, revocationPayload(targetKeyId));
|
|
163
|
+
return { key_id: signingKeyId, sig };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Revoke a key by id.
|
|
168
|
+
*
|
|
169
|
+
* After revocation {@link verifyManifest} rejects any manifest that was
|
|
170
|
+
* signed with the revoked key. Revocation is permanent within a key-store
|
|
171
|
+
* instance. The operation is idempotent — revoking an already-revoked key
|
|
172
|
+
* is a no-op.
|
|
173
|
+
*
|
|
174
|
+
* If the revoked key was the active signing key, `activeKeyId` is cleared to
|
|
175
|
+
* `null`; callers must {@link rotateKey} to obtain a new active key.
|
|
176
|
+
*
|
|
177
|
+
* **Authorization / quorum gate (SAT-472)**
|
|
178
|
+
*
|
|
179
|
+
* Let `M` be the number of non-revoked keys other than `keyId`. The
|
|
180
|
+
* required quorum is `Math.floor(M / 2) + 1` (strict majority of peers).
|
|
181
|
+
* When `M === 0` (the target is the only remaining key) no approvals are
|
|
182
|
+
* needed. Each approval in the `approvals` array must be a valid
|
|
183
|
+
* {@link makeRevocationApproval} token signed by a distinct, non-revoked key
|
|
184
|
+
* that is not `keyId` itself.
|
|
185
|
+
*
|
|
186
|
+
* This ensures a single compromised key cannot unilaterally revoke all
|
|
187
|
+
* others: it can only contribute one approval, which is always less than
|
|
188
|
+
* the strict majority needed when `M >= 2`.
|
|
189
|
+
*
|
|
190
|
+
* Throws if `keyId` is not present in the store or if the quorum is not met.
|
|
191
|
+
*
|
|
192
|
+
* @param {{ keys: Map<string,object>, activeKeyId: string|null }} ks
|
|
193
|
+
* @param {string} keyId
|
|
194
|
+
* @param {{ key_id: string, sig: string }[]} [approvals=[]]
|
|
195
|
+
*/
|
|
196
|
+
export function revokeKey(ks, keyId, approvals = []) {
|
|
197
|
+
if (!ks.keys.has(keyId)) {
|
|
198
|
+
throw new Error(`revokeKey: unknown key_id ${JSON.stringify(keyId)}`);
|
|
199
|
+
}
|
|
200
|
+
const entry = ks.keys.get(keyId);
|
|
201
|
+
if (entry.status === 'revoked') return; // idempotent
|
|
202
|
+
|
|
203
|
+
// Count non-revoked peer keys (all keys except the revocation target).
|
|
204
|
+
const peers = [...ks.keys.values()].filter(
|
|
205
|
+
e => e.key_id !== keyId && e.status !== 'revoked',
|
|
206
|
+
);
|
|
207
|
+
const required = peers.length > 0 ? Math.floor(peers.length / 2) + 1 : 0;
|
|
208
|
+
|
|
209
|
+
if (required > 0) {
|
|
210
|
+
// Validate each approval token over the canonical revocation payload.
|
|
211
|
+
const payload = revocationPayload(keyId);
|
|
212
|
+
const seen = new Set();
|
|
213
|
+
let valid = 0;
|
|
214
|
+
|
|
215
|
+
for (const approval of approvals) {
|
|
216
|
+
const { key_id: aKeyId, sig } = approval ?? {};
|
|
217
|
+
if (!aKeyId || !sig) continue;
|
|
218
|
+
if (aKeyId === keyId) continue; // target cannot self-approve
|
|
219
|
+
if (seen.has(aKeyId)) continue; // deduplicate per signer
|
|
220
|
+
|
|
221
|
+
const aEntry = ks.keys.get(aKeyId);
|
|
222
|
+
if (!aEntry || aEntry.status === 'revoked') continue;
|
|
223
|
+
|
|
224
|
+
const expected = hmacPayload(aEntry.raw, payload);
|
|
225
|
+
if (typeof sig !== 'string' || sig.length !== expected.length) continue;
|
|
226
|
+
|
|
227
|
+
let match = false;
|
|
228
|
+
try {
|
|
229
|
+
match = timingSafeEqual(Buffer.from(expected, 'hex'), Buffer.from(sig, 'hex'));
|
|
230
|
+
} catch { match = false; }
|
|
231
|
+
|
|
232
|
+
if (match) {
|
|
233
|
+
seen.add(aKeyId);
|
|
234
|
+
valid++;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
if (valid < required) {
|
|
239
|
+
throw new Error(
|
|
240
|
+
`revokeKey: quorum not met — ${required} approval(s) required from peer keys, got ${valid}`,
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
entry.status = 'revoked';
|
|
246
|
+
// If the active key was just revoked, clear the active pointer.
|
|
247
|
+
if (ks.activeKeyId === keyId) ks.activeKeyId = null;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// ---------------------------------------------------------------------------
|
|
251
|
+
// signManifest
|
|
252
|
+
// ---------------------------------------------------------------------------
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Sign a manifest with the store's current active key.
|
|
256
|
+
*
|
|
257
|
+
* Returns a shallow copy of `manifest` with two new fields appended:
|
|
258
|
+
* - `key_id` — identifies the signing key for later verification.
|
|
259
|
+
* - `sig` — lowercase hex HMAC-SHA256 over the canonical signing payload
|
|
260
|
+
* (all manifest fields except `key_id` and `sig` themselves,
|
|
261
|
+
* with top-level keys sorted alphabetically).
|
|
262
|
+
*
|
|
263
|
+
* Throws if there is no active key in the store (e.g. after revoking the
|
|
264
|
+
* active key without rotating first).
|
|
265
|
+
*
|
|
266
|
+
* @param {{ keys: Map<string,object>, activeKeyId: string|null }} ks
|
|
267
|
+
* @param {object} manifest Typically the result of {@link makeIntentManifest}.
|
|
268
|
+
* @returns {object} Manifest copy decorated with `key_id` and `sig`.
|
|
269
|
+
*/
|
|
270
|
+
export function signManifest(ks, manifest) {
|
|
271
|
+
const entry = ks.activeKeyId != null ? ks.keys.get(ks.activeKeyId) : null;
|
|
272
|
+
if (!entry || entry.status !== 'active') {
|
|
273
|
+
throw new Error('signManifest: no active key in store — rotate to obtain one');
|
|
274
|
+
}
|
|
275
|
+
const payload = signingPayload(manifest);
|
|
276
|
+
const sig = hmacPayload(entry.raw, payload);
|
|
277
|
+
return { ...manifest, key_id: entry.key_id, sig };
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// ---------------------------------------------------------------------------
|
|
281
|
+
// verifyManifest
|
|
282
|
+
// ---------------------------------------------------------------------------
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Verify a signed manifest.
|
|
286
|
+
*
|
|
287
|
+
* Returns `{ ok: true }` when the signature is valid and the signing key is
|
|
288
|
+
* present and not revoked. Otherwise returns `{ ok: false, reason }`.
|
|
289
|
+
*
|
|
290
|
+
* Never throws on malformed input — treats it as a verification failure.
|
|
291
|
+
*
|
|
292
|
+
* | `reason` | Meaning |
|
|
293
|
+
* |-------------------|-----------------------------------------------------|
|
|
294
|
+
* | `'missing_key_id'`| `key_id` field absent from the manifest. |
|
|
295
|
+
* | `'unknown_key'` | `key_id` is not registered in this store. |
|
|
296
|
+
* | `'key_revoked'` | The key exists but has been revoked. |
|
|
297
|
+
* | `'bad_signature'` | HMAC did not match — data was tampered or wrong key.|
|
|
298
|
+
*
|
|
299
|
+
* @param {{ keys: Map<string,object>, activeKeyId: string|null }} ks
|
|
300
|
+
* @param {object} signedManifest
|
|
301
|
+
* @returns {{ ok: boolean, reason?: string }}
|
|
302
|
+
*/
|
|
303
|
+
export function verifyManifest(ks, signedManifest) {
|
|
304
|
+
const { key_id, sig } = signedManifest;
|
|
305
|
+
|
|
306
|
+
if (!key_id) return { ok: false, reason: 'missing_key_id' };
|
|
307
|
+
|
|
308
|
+
const entry = ks.keys.get(key_id);
|
|
309
|
+
if (!entry) return { ok: false, reason: 'unknown_key' };
|
|
310
|
+
if (entry.status === 'revoked') return { ok: false, reason: 'key_revoked' };
|
|
311
|
+
|
|
312
|
+
const payload = signingPayload(signedManifest);
|
|
313
|
+
const expected = hmacPayload(entry.raw, payload);
|
|
314
|
+
|
|
315
|
+
// Use timingSafeEqual to prevent timing-side-channel leaks.
|
|
316
|
+
// Both buffers must have the same length; a length mismatch is a sig failure.
|
|
317
|
+
const eBuf = Buffer.from(expected, 'hex');
|
|
318
|
+
if (typeof sig !== 'string' || sig.length !== expected.length) {
|
|
319
|
+
return { ok: false, reason: 'bad_signature' };
|
|
320
|
+
}
|
|
321
|
+
const aBuf = Buffer.from(sig, 'hex');
|
|
322
|
+
let match;
|
|
323
|
+
try {
|
|
324
|
+
match = timingSafeEqual(eBuf, aBuf);
|
|
325
|
+
} catch {
|
|
326
|
+
match = false;
|
|
327
|
+
}
|
|
328
|
+
return match ? { ok: true } : { ok: false, reason: 'bad_signature' };
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// ---------------------------------------------------------------------------
|
|
332
|
+
// getKey
|
|
333
|
+
// ---------------------------------------------------------------------------
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Return the metadata for a registered key (without the raw key material).
|
|
337
|
+
* Returns `null` if `keyId` is not in the store.
|
|
338
|
+
*
|
|
339
|
+
* @param {{ keys: Map<string,object> }} ks
|
|
340
|
+
* @param {string} keyId
|
|
341
|
+
* @returns {{ key_id: string, status: string, created_at: string } | null}
|
|
342
|
+
*/
|
|
343
|
+
export function getKey(ks, keyId) {
|
|
344
|
+
const entry = ks.keys.get(keyId);
|
|
345
|
+
if (!entry) return null;
|
|
346
|
+
// Never expose the raw key material to callers.
|
|
347
|
+
return { key_id: entry.key_id, status: entry.status, created_at: entry.created_at };
|
|
348
|
+
}
|