knosky 0.4.1 → 0.6.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.
@@ -0,0 +1,194 @@
1
+ // KnoSky ledger-anchored freshness attestation (SAT-444, SAT-474).
2
+ //
3
+ // Closes the clock-skew / key-resurrection gap by basing freshness on the
4
+ // repository's commit count rather than wall-clock time. A `ledger_seq`
5
+ // is a monotone integer — the number of commits reachable from HEAD —
6
+ // which cannot be fabricated by adjusting the system clock and can only
7
+ // decrease when history is rewritten (the ledger-truncation attack caught
8
+ // by the high-water-mark guard in core/ledger.mjs).
9
+ //
10
+ // SAT-474: validateFreshnessWithHwm() routes through the *persisted*
11
+ // checkAndAdvance() guard (core/ledger.mjs) so that the rollback defence
12
+ // survives process restarts. validateFreshness() is retained for callers
13
+ // that manage their own in-memory lastSeq (e.g. protocol-spec validation).
14
+ // Pure Node stdlib, ESM — no third-party dependencies.
15
+
16
+ import { execFileSync } from 'node:child_process';
17
+ import { checkAndAdvance } from './ledger.mjs';
18
+ import { MAX_PLAUSIBLE_LEDGER_SEQ } from './constants.mjs';
19
+
20
+ // ---------------------------------------------------------------------------
21
+ // extractLedgerSeq — read the ledger_seq stored in a city envelope
22
+ // ---------------------------------------------------------------------------
23
+
24
+ /**
25
+ * Extract the `ledger_seq` from a city envelope or any artifact that carries
26
+ * one. Returns null when absent, not a non-negative integer, or implausibly
27
+ * large (see MAX_PLAUSIBLE_LEDGER_SEQ).
28
+ *
29
+ * @param {object} obj City envelope or comparable artifact object.
30
+ * @returns {number|null}
31
+ */
32
+ export function extractLedgerSeq(obj) {
33
+ if (!obj || typeof obj !== 'object') return null;
34
+ const seq = obj.ledger_seq;
35
+ if (typeof seq !== 'number' || !Number.isInteger(seq) || seq < 0) return null;
36
+ if (seq > MAX_PLAUSIBLE_LEDGER_SEQ) return null;
37
+ return seq;
38
+ }
39
+
40
+ // ---------------------------------------------------------------------------
41
+ // computeLedgerSeq — derive a ledger_seq from a git repo on disk
42
+ // ---------------------------------------------------------------------------
43
+
44
+ /**
45
+ * Compute the ledger_seq for `root` by counting all commits reachable from
46
+ * HEAD (`git rev-list --count HEAD`). Returns 0 when git is unavailable or
47
+ * the directory has no commit history (e.g. a brand-new repo with no commits).
48
+ *
49
+ * @param {string} root Absolute path to the git working tree.
50
+ * @returns {number} Non-negative integer commit count.
51
+ */
52
+ export function computeLedgerSeq(root) {
53
+ try {
54
+ const out = execFileSync(
55
+ 'git',
56
+ ['rev-list', '--count', 'HEAD'],
57
+ { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] },
58
+ ).trim();
59
+ const n = parseInt(out, 10);
60
+ return Number.isFinite(n) && n >= 0 ? n : 0;
61
+ } catch {
62
+ // No git / no commits / non-repo directory → 0 (safe: sequence starts fresh)
63
+ return 0;
64
+ }
65
+ }
66
+
67
+ // ---------------------------------------------------------------------------
68
+ // checkHighWaterMark — V13 guard against ledger-truncation attack
69
+ // ---------------------------------------------------------------------------
70
+
71
+ /**
72
+ * High-water-mark guard (V13).
73
+ *
74
+ * Returns `{ ok: true }` when `newSeq` is strictly greater than `lastSeq`,
75
+ * meaning the city has advanced — its ledger has grown (or this is the first
76
+ * load, indicated by `lastSeq === null`).
77
+ *
78
+ * Returns `{ ok: false, reason }` when `newSeq` is ≤ `lastSeq`, which means
79
+ * the ledger has been truncated or replayed — a potential key-resurrection or
80
+ * rollback attack. Callers MUST treat stale/equal as suspicious.
81
+ *
82
+ * @param {number|null} lastSeq Previously accepted ledger_seq, or null on
83
+ * first load (unconditionally accepted).
84
+ * @param {number|null} newSeq Ledger_seq from the incoming artifact, or
85
+ * null when absent (treated as 0 — conservative).
86
+ * @returns {{ ok: boolean, reason?: string }}
87
+ */
88
+ export function checkHighWaterMark(lastSeq, newSeq) {
89
+ // Treat missing seq as 0 — conservative: an artifact with no ledger anchoring
90
+ // is as trustworthy as a brand-new repo.
91
+ const effective = (typeof newSeq === 'number' && Number.isInteger(newSeq) && newSeq >= 0)
92
+ ? newSeq
93
+ : 0;
94
+
95
+ // First load: no last seq to compare against — unconditionally accept.
96
+ if (lastSeq === null || lastSeq === undefined) {
97
+ return { ok: true };
98
+ }
99
+
100
+ if (typeof lastSeq !== 'number' || !Number.isInteger(lastSeq) || lastSeq < 0) {
101
+ // Corrupt lastSeq — treat it as 0 (reset the watermark)
102
+ return { ok: true };
103
+ }
104
+
105
+ if (effective > lastSeq) {
106
+ return { ok: true };
107
+ }
108
+
109
+ // equal: rolled back to same point (replay) or no new commits
110
+ // less: ledger truncation — history was rewritten
111
+ const direction = effective < lastSeq ? 'ledger truncated' : 'ledger not advanced';
112
+ return {
113
+ ok: false,
114
+ reason:
115
+ direction +
116
+ ': incoming ledger_seq ' + effective +
117
+ ' is not greater than last accepted ' + lastSeq +
118
+ ' — possible rollback or key-resurrection attack',
119
+ };
120
+ }
121
+
122
+ // ---------------------------------------------------------------------------
123
+ // validateFreshness — full attestation check for an incoming artifact
124
+ // ---------------------------------------------------------------------------
125
+
126
+ /**
127
+ * Validate the freshness of an incoming artifact against an optional stored
128
+ * high-water mark.
129
+ *
130
+ * Combines:
131
+ * (1) structural check: `ledger_seq` is present and a non-negative integer
132
+ * (2) V13 high-water-mark guard: `ledger_seq` must exceed the last accepted
133
+ * value (pass `null` on first load to skip this check)
134
+ *
135
+ * @param {object} artifact City envelope or protocol artifact.
136
+ * @param {number|null} lastSeq Last accepted ledger_seq (null = first load).
137
+ * @returns {{ ok: boolean, ledger_seq: number|null, errors: string[] }}
138
+ */
139
+ export function validateFreshness(artifact, lastSeq = null) {
140
+ const errors = [];
141
+ const seq = extractLedgerSeq(artifact);
142
+
143
+ if (seq === null) {
144
+ errors.push('ledger_seq is missing or not a non-negative integer');
145
+ }
146
+
147
+ const hwm = checkHighWaterMark(lastSeq, seq);
148
+ if (!hwm.ok) {
149
+ errors.push(hwm.reason);
150
+ }
151
+
152
+ return { ok: errors.length === 0, ledger_seq: seq, errors };
153
+ }
154
+
155
+ // ---------------------------------------------------------------------------
156
+ // validateFreshnessWithHwm — persisted-HWM variant (SAT-474)
157
+ // ---------------------------------------------------------------------------
158
+
159
+ /**
160
+ * Validate freshness through the **persisted** high-water-mark guard
161
+ * (`core/ledger.mjs` `checkAndAdvance`).
162
+ *
163
+ * Unlike `validateFreshness`, this function reads and updates the HWM from
164
+ * `hwmPath` on disk, so the rollback defence is durable across process
165
+ * restarts. Callers that previously maintained an in-memory `lastSeq` and
166
+ * passed it to `validateFreshness` should migrate to this function and a
167
+ * stable `hwmPath` in their data directory.
168
+ *
169
+ * Semantics of the persisted guard (from `checkAndAdvance`):
170
+ * - seq STRICTLY LESS than HWM → rejected (anti-truncation guard)
171
+ * - seq EQUAL to HWM → accepted (idempotent replay)
172
+ * - seq GREATER than HWM → accepted and HWM advanced
173
+ *
174
+ * @param {object} artifact City envelope or protocol artifact.
175
+ * @param {string} hwmPath Path to the independently-persisted HWM file.
176
+ * @returns {{ ok: boolean, ledger_seq: number|null, errors: string[] }}
177
+ */
178
+ export function validateFreshnessWithHwm(artifact, hwmPath) {
179
+ const errors = [];
180
+ const seq = extractLedgerSeq(artifact);
181
+
182
+ if (seq === null) {
183
+ errors.push('ledger_seq is missing or not a non-negative integer');
184
+ // Cannot call checkAndAdvance with a null seq — return early.
185
+ return { ok: false, ledger_seq: null, errors };
186
+ }
187
+
188
+ const result = checkAndAdvance(seq, hwmPath);
189
+ if (!result.ok) {
190
+ errors.push(result.error);
191
+ }
192
+
193
+ return { ok: errors.length === 0, ledger_seq: seq, errors };
194
+ }
@@ -7,11 +7,15 @@ import { execSync } from 'node:child_process';
7
7
  import { SCHEMA_VERSION, IGNORE_DEFAULTS, deriveCategories, serializeNode, validateCity, setRedactTerms, findSecrets } from './contract.mjs';
8
8
  import { extractImportSpecifiers, resolveSpec } from './edges.mjs';
9
9
  import { gitChurn } from './churn.mjs';
10
+ import { computeLedgerSeq } from './freshness.mjs';
10
11
 
11
12
  const args = Object.fromEntries(process.argv.slice(2).map((a, i, arr) => a.startsWith('--') ? [a.slice(2), arr[i + 1]] : []).filter(Boolean));
12
13
  const ROOT = args.root && path.resolve(args.root);
13
14
  const OUT = args.out || null;
14
- const MAX = parseInt(args.max || '6000', 10);
15
+ // Validate --max: a non-numeric/zero/negative value must NOT silently disable the
16
+ // cap (NaN comparisons are always false, so `scanned > MAX` would never trip) —
17
+ // that's a resource-exhaustion opening on a hostile/malformed CI invocation.
18
+ const MAX = (() => { const n = parseInt(args.max || '6000', 10); return (Number.isFinite(n) && n > 0) ? n : 6000; })();
15
19
  const SHARE_SAFE = process.argv.includes('--share-safe');
16
20
  const INCLUDE_ABS = process.argv.includes('--include-absolute-root');
17
21
  const ALLOW_LEAKS = process.argv.includes('--allow-leaks');
@@ -160,10 +164,15 @@ const preList = preHits.map(h => h[0] + ':' + h[1]).join(', ');
160
164
  const nodes = raw.map(serializeNode);
161
165
  const catIds = [...new Set(nodes.map(n => n.category))].sort();
162
166
  const categories = deriveCategories(catIds);
167
+ // Ledger-anchored freshness (SAT-444): monotone commit count, used as a
168
+ // replay-resistant sequence number. Consumers compare against the last
169
+ // accepted ledger_seq (V13 high-water-mark guard) to detect rollbacks.
170
+ const ledger_seq = computeLedgerSeq(ROOT);
163
171
  const city = {
164
172
  schema_version: SCHEMA_VERSION,
165
173
  generated_at: new Date().toISOString(),
166
174
  source: { kind: 'fs', ref: INCLUDE_ABS ? ROOT : path.basename(ROOT), rev: REV },
175
+ ledger_seq,
167
176
  categories, node_count: nodes.length, nodes,
168
177
  };
169
178
 
@@ -0,0 +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
+ }