@ijfw/memory-server 1.4.0 → 1.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -641,6 +641,52 @@ export function verifyManifestSignature(manifest, trustedKeys) {
641
641
  return { valid: true, publisherKeyId: kid, reason: 'ok' };
642
642
  }
643
643
 
644
+ // === B6: Revoked publishers store ========================================
645
+
646
+ /**
647
+ * Read the revoked publishers list from ~/.ijfw/state/revoked-publishers.json.
648
+ * Returns an empty list when absent or malformed.
649
+ *
650
+ * @returns {Promise<Array<{keyId: string, revoked_at: string, reason: string, superseded_by: string|null}>>}
651
+ */
652
+ export async function readRevokedPublishers() {
653
+ const path = join(homedir(), '.ijfw', 'state', 'revoked-publishers.json');
654
+ let raw;
655
+ try {
656
+ raw = await readFile(path, 'utf8');
657
+ } catch {
658
+ return [];
659
+ }
660
+ let parsed;
661
+ try {
662
+ parsed = JSON.parse(raw);
663
+ } catch {
664
+ return [];
665
+ }
666
+ if (!parsed || !Array.isArray(parsed.revoked)) return [];
667
+ return parsed.revoked.filter(r => typeof r.keyId === 'string');
668
+ }
669
+
670
+ // Module-level revoked set cache — loaded once per process, refreshed by applyRegistry.
671
+ // Export for test isolation only (allows tests to reset after changing HOME).
672
+ export function _resetRevokedCacheForTest() { _revokedSet = null; }
673
+ let _revokedSet = null;
674
+
675
+ /**
676
+ * O(1) check: is a keyId on the revoked list?
677
+ * Loads the set on first call; cached for the process lifetime.
678
+ *
679
+ * @param {string} keyId
680
+ * @returns {Promise<boolean>}
681
+ */
682
+ export async function isRevoked(keyId) {
683
+ if (_revokedSet === null) {
684
+ const list = await readRevokedPublishers();
685
+ _revokedSet = new Set(list.map(r => r.keyId));
686
+ }
687
+ return _revokedSet.has(keyId);
688
+ }
689
+
644
690
  /**
645
691
  * Read the trusted publishers JSON. Returns `{publishers: {}}` when absent or
646
692
  * malformed (fail-closed for verification: no trusted keys means nothing is
@@ -701,6 +747,10 @@ export async function addTrustedPublisher(keyId, publicKey, name) {
701
747
  if (typeof publicKey !== 'string' || publicKey.indexOf('BEGIN PUBLIC KEY') === -1) {
702
748
  return { ok: false, error: 'publicKey must be PEM-encoded' };
703
749
  }
750
+ // B6: refuse to add a revoked publisher
751
+ if (await isRevoked(keyId)) {
752
+ return { ok: false, error: 'publisher revoked by IJFW registry' };
753
+ }
704
754
  let fp;
705
755
  try {
706
756
  fp = publicKeyFingerprint(publicKey);
@@ -720,6 +770,121 @@ export async function addTrustedPublisher(keyId, publicKey, name) {
720
770
  return { ok: true, store };
721
771
  }
722
772
 
773
+ // === B8: Key rotation + revocation ==========================================
774
+ //
775
+ // Rotation token is signed by the OLD private key — proof of control.
776
+ // An attacker without the old private key cannot produce a valid token.
777
+ // A publisher who lost their old private key must contact the registry
778
+ // maintainer for out-of-band manual key replacement (see docs/REGISTRY-MAINTAINER.md).
779
+ //
780
+ // Token shape: { rotated_at, old_key_id, new_key_id, new_public_key, signature }
781
+ // Canonical signing bytes: all fields except `signature`, sorted by key (sortKeysDeep).
782
+
783
+ /**
784
+ * Produce a rotation token asserting that newPublicKey supersedes the key
785
+ * identified by oldPrivateKey. Signed by the old private key.
786
+ *
787
+ * @param {string} oldPrivateKeyPem
788
+ * @param {string} newPublicKeyPem
789
+ * @param {object} [opts]
790
+ * @param {string} [opts.rotated_at] ISO timestamp (defaults to now)
791
+ * @returns {{ rotated_at: string, old_key_id: string, new_key_id: string, new_public_key: string, signature: string }}
792
+ */
793
+ export function signRotationToken(oldPrivateKeyPem, newPublicKeyPem, opts = {}) {
794
+ const priv = createPrivateKey(oldPrivateKeyPem);
795
+ // Derive old_key_id from the old private key's matching public key.
796
+ const oldPub = createPublicKey(priv);
797
+ const old_key_id = publicKeyFingerprint(oldPub.export({ type: 'spki', format: 'pem' }).toString());
798
+ const new_key_id = publicKeyFingerprint(newPublicKeyPem);
799
+
800
+ const token = {
801
+ rotated_at: opts.rotated_at || new Date().toISOString(),
802
+ old_key_id,
803
+ new_key_id,
804
+ new_public_key: newPublicKeyPem,
805
+ };
806
+
807
+ // Canonical signing bytes: sortKeysDeep of token (signature excluded — not present yet).
808
+ const bytes = Buffer.from(JSON.stringify(sortKeysDeep(token)), 'utf8');
809
+ const sigBuf = cryptoSign(null, bytes, priv);
810
+ return { ...token, signature: `ed25519:${sigBuf.toString('base64')}` };
811
+ }
812
+
813
+ /**
814
+ * Verify a rotation token against the old public key.
815
+ * Checks:
816
+ * 1. Signature is valid Ed25519 over canonical bytes (signature field excluded).
817
+ * 2. fingerprint(oldPublicKey) === token.old_key_id.
818
+ * 3. token.rotated_at is within opts.max_age_ms (default 90 days).
819
+ *
820
+ * @param {object} token
821
+ * @param {string} oldPublicKeyPem
822
+ * @param {object} [opts]
823
+ * @param {number} [opts.max_age_ms] Maximum token age in ms (default 90 days)
824
+ * @returns {{ valid: boolean, reason: string }}
825
+ */
826
+ export function verifyRotationToken(token, oldPublicKeyPem, opts = {}) {
827
+ if (!token || typeof token !== 'object') {
828
+ return { valid: false, reason: 'token must be an object' };
829
+ }
830
+ const { rotated_at, old_key_id, new_key_id, new_public_key, signature } = token;
831
+ if (!rotated_at || !old_key_id || !new_key_id || !new_public_key || !signature) {
832
+ return { valid: false, reason: 'token missing required fields' };
833
+ }
834
+ if (typeof signature !== 'string' || !signature.startsWith('ed25519:')) {
835
+ return { valid: false, reason: 'signature must be "ed25519:<base64>"' };
836
+ }
837
+
838
+ // Expiry check: reject tokens older than max_age_ms (default 90 days).
839
+ const MAX_AGE_MS = opts.max_age_ms ?? (90 * 24 * 60 * 60 * 1000);
840
+ const rotatedAtMs = new Date(rotated_at).getTime();
841
+ if (isNaN(rotatedAtMs)) {
842
+ return { valid: false, reason: 'rotated_at is not a valid date' };
843
+ }
844
+ if (Date.now() - rotatedAtMs > MAX_AGE_MS) {
845
+ return { valid: false, reason: 'rotation token expired' };
846
+ }
847
+
848
+ // Check old_key_id matches the supplied public key fingerprint.
849
+ let fp;
850
+ try {
851
+ fp = publicKeyFingerprint(oldPublicKeyPem);
852
+ } catch (err) {
853
+ return { valid: false, reason: `old public key parse failed: ${err.message}` };
854
+ }
855
+ if (fp !== old_key_id) {
856
+ return { valid: false, reason: `old_key_id mismatch: token says ${old_key_id} but supplied key fingerprints to ${fp}` };
857
+ }
858
+
859
+ // Reconstruct canonical signing bytes (exclude signature field).
860
+ const payload = { rotated_at, old_key_id, new_key_id, new_public_key };
861
+ const bytes = Buffer.from(JSON.stringify(sortKeysDeep(payload)), 'utf8');
862
+
863
+ let pubKey;
864
+ try {
865
+ pubKey = createPublicKey(oldPublicKeyPem);
866
+ } catch (err) {
867
+ return { valid: false, reason: `old public key unparseable: ${err.message}` };
868
+ }
869
+
870
+ let sigBuf;
871
+ try {
872
+ sigBuf = Buffer.from(signature.slice('ed25519:'.length), 'base64');
873
+ } catch {
874
+ return { valid: false, reason: 'signature base64 decode failed' };
875
+ }
876
+
877
+ let ok;
878
+ try {
879
+ ok = cryptoVerify(null, bytes, pubKey, sigBuf);
880
+ } catch (err) {
881
+ return { valid: false, reason: `verify threw: ${err.message}` };
882
+ }
883
+
884
+ if (!ok) return { valid: false, reason: 'signature does not verify' };
885
+ return { valid: true, reason: 'ok' };
886
+ }
887
+
723
888
  /**
724
889
  * Remove a trusted publisher entry by keyId. Idempotent.
725
890
  *
@@ -89,20 +89,16 @@ export async function readRecentReceipts(projectRoot, limit = 50) {
89
89
  }
90
90
 
91
91
  /**
92
- * detectPatterns(receipts, opts)
92
+ * detectRepeatedFail(receipts, opts)
93
93
  *
94
94
  * Examines the last `opts.window` (default 10) receipts for repeated FAIL/FLAG
95
95
  * on the same affected_artifacts[].type value.
96
96
  *
97
- * If a single artifact_type appears in >= opts.threshold (default 3) receipts
98
- * within the window with a FAIL or FLAG verdict, one pattern object is emitted
99
- * for that artifact_type.
100
- *
101
97
  * @param {object[]} receipts
102
98
  * @param {{ threshold?: number, window?: number }} [opts]
103
99
  * @returns {Array<{ kind: string, artifact_type: string, count: number, threshold: number, sample: string[] }>}
104
100
  */
105
- export function detectPatterns(receipts, opts = {}) {
101
+ function detectRepeatedFail(receipts, opts = {}) {
106
102
  const threshold = typeof opts.threshold === 'number' ? opts.threshold : 3;
107
103
  const window = typeof opts.window === 'number' ? opts.window : 10;
108
104
 
@@ -152,6 +148,192 @@ export function detectPatterns(receipts, opts = {}) {
152
148
  return patterns;
153
149
  }
154
150
 
151
+ /**
152
+ * detectRisingFailRate(receipts, opts)
153
+ *
154
+ * Compares the fail rate in the most recent `window` receipts to the `window`
155
+ * receipts before that. If the rate rose by >= minRise (absolute), emits a
156
+ * rising-fail-rate pattern.
157
+ *
158
+ * @param {object[]} receipts
159
+ * @param {{ window?: number, minRise?: number }} [opts]
160
+ * @returns {Array<{ kind: string, from_rate: number, to_rate: number, window: number, suggestion: string }>}
161
+ */
162
+ export function detectRisingFailRate(receipts, opts = {}) {
163
+ try {
164
+ const window = typeof opts.window === 'number' && opts.window > 0 ? opts.window : 20;
165
+ const minRise = typeof opts.minRise === 'number' ? opts.minRise : 0.2;
166
+
167
+ if (!Array.isArray(receipts) || receipts.length < 2) return [];
168
+
169
+ const recent = receipts.slice(0, window);
170
+ const prior = receipts.slice(window, window * 2);
171
+
172
+ if (prior.length === 0) return [];
173
+
174
+ const failRate = (arr) => {
175
+ const valid = arr.filter((r) => r && typeof r === 'object' && typeof r.verdict === 'string');
176
+ if (valid.length === 0) return 0;
177
+ return valid.filter((r) => FAIL_VERDICTS.has(r.verdict)).length / valid.length;
178
+ };
179
+
180
+ const fromRate = failRate(prior);
181
+ const toRate = failRate(recent);
182
+
183
+ if (toRate - fromRate < minRise) return [];
184
+
185
+ const fromPct = Math.round(fromRate * 100);
186
+ const toPct = Math.round(toRate * 100);
187
+
188
+ return [{
189
+ kind: 'rising-fail-rate',
190
+ from_rate: fromRate,
191
+ to_rate: toRate,
192
+ window,
193
+ suggestion: `gate fail rate rose from ${fromPct}% to ${toPct}% in the last ${window} receipts — consider rolling back the most recent changes`,
194
+ }];
195
+ } catch {
196
+ return [];
197
+ }
198
+ }
199
+
200
+ /**
201
+ * detectCrossSkillCorrelation(receipts, opts)
202
+ *
203
+ * Looks at the last `window` receipts. If >= minDistinctGates distinct gate_id
204
+ * prefixes (split on first `-` or `:`) have a FAIL/FLAG verdict, emits a
205
+ * cross-skill-correlation pattern.
206
+ *
207
+ * @param {object[]} receipts
208
+ * @param {{ window?: number, minDistinctGates?: number }} [opts]
209
+ * @returns {Array<{ kind: string, distinct_gates: number, window: number, suggestion: string }>}
210
+ */
211
+ export function detectCrossSkillCorrelation(receipts, opts = {}) {
212
+ try {
213
+ const window = typeof opts.window === 'number' && opts.window > 0 ? opts.window : 10;
214
+ const minDistinctGates = typeof opts.minDistinctGates === 'number' ? opts.minDistinctGates : 3;
215
+
216
+ if (!Array.isArray(receipts) || receipts.length === 0) return [];
217
+
218
+ const windowReceipts = receipts.slice(0, window);
219
+ const prefixes = new Set();
220
+
221
+ for (const receipt of windowReceipts) {
222
+ if (!receipt || typeof receipt !== 'object') continue;
223
+ if (!FAIL_VERDICTS.has(receipt.verdict)) continue;
224
+ if (typeof receipt.gate_id !== 'string' || receipt.gate_id.length === 0) continue;
225
+
226
+ // Take the prefix before the first `-` or `:`
227
+ const prefix = receipt.gate_id.split(/[-:]/)[0];
228
+ if (prefix) prefixes.add(prefix);
229
+ }
230
+
231
+ if (prefixes.size < minDistinctGates) return [];
232
+
233
+ return [{
234
+ kind: 'cross-skill-correlation',
235
+ distinct_gates: prefixes.size,
236
+ window,
237
+ suggestion: `${prefixes.size} different gates flagged in the last ${window} receipts — review project state, not individual artifacts`,
238
+ }];
239
+ } catch {
240
+ return [];
241
+ }
242
+ }
243
+
244
+ /**
245
+ * detectRegression(receipts, opts)
246
+ *
247
+ * For each unique (gate_id, artifact_type) key in receipts: if the most recent
248
+ * `failWindow` receipts were all FAIL/FLAG but the `passWindow` receipts before
249
+ * that were all PASS, emits a regression pattern.
250
+ *
251
+ * artifact_type is the TYPE field (e.g. 'chapter'), never the ID.
252
+ *
253
+ * @param {object[]} receipts
254
+ * @param {{ passWindow?: number, failWindow?: number }} [opts]
255
+ * @returns {Array<{ kind: string, gate_id: string, artifact_type: string, suggestion: string }>}
256
+ */
257
+ export function detectRegression(receipts, opts = {}) {
258
+ try {
259
+ const passWindow = typeof opts.passWindow === 'number' && opts.passWindow > 0 ? opts.passWindow : 5;
260
+ const failWindow = typeof opts.failWindow === 'number' && opts.failWindow > 0 ? opts.failWindow : 2;
261
+
262
+ if (!Array.isArray(receipts) || receipts.length === 0) return [];
263
+
264
+ // Build per-(gate_id, artifact_type) ordered lists (receipts[0] = most recent).
265
+ // receipts are assumed newest-first (as returned by readRecentReceipts).
266
+ const streams = new Map(); // key -> [receipt, ...]
267
+
268
+ for (const receipt of receipts) {
269
+ if (!receipt || typeof receipt !== 'object') continue;
270
+ if (typeof receipt.gate_id !== 'string' || receipt.gate_id.length === 0) continue;
271
+ if (!Array.isArray(receipt.affected_artifacts)) continue;
272
+
273
+ const seenTypes = new Set();
274
+ for (const artifact of receipt.affected_artifacts) {
275
+ if (!artifact || typeof artifact !== 'object') continue;
276
+ if (typeof artifact.type !== 'string' || artifact.type.length === 0) continue;
277
+
278
+ const t = artifact.type;
279
+ if (seenTypes.has(t)) continue;
280
+ seenTypes.add(t);
281
+
282
+ const key = `${receipt.gate_id}\x00${t}`;
283
+ if (!streams.has(key)) streams.set(key, []);
284
+ streams.get(key).push(receipt);
285
+ }
286
+ }
287
+
288
+ const patterns = [];
289
+
290
+ for (const [key, stream] of streams.entries()) {
291
+ if (stream.length < failWindow + passWindow) continue;
292
+
293
+ const recentSlice = stream.slice(0, failWindow);
294
+ const priorSlice = stream.slice(failWindow, failWindow + passWindow);
295
+
296
+ const allRecentFail = recentSlice.every((r) => FAIL_VERDICTS.has(r.verdict));
297
+ const allPriorPass = priorSlice.every((r) => r.verdict === 'PASS');
298
+
299
+ if (!allRecentFail || !allPriorPass) continue;
300
+
301
+ const [gate_id, artifact_type] = key.split('\x00');
302
+ patterns.push({
303
+ kind: 'regression',
304
+ gate_id,
305
+ artifact_type,
306
+ suggestion: `gate ${gate_id} on ${artifact_type} was passing last ${passWindow} runs; failing now — likely regression`,
307
+ });
308
+ }
309
+
310
+ return patterns;
311
+ } catch {
312
+ return [];
313
+ }
314
+ }
315
+
316
+ /**
317
+ * detectPatterns(receipts, opts)
318
+ *
319
+ * Dispatcher: runs all four detectors and returns the union in deterministic
320
+ * order: repeated-fail-on-same-artifact, rising-fail-rate, cross-skill-correlation,
321
+ * regression.
322
+ *
323
+ * @param {object[]} receipts
324
+ * @param {{ threshold?: number, window?: number }} [opts]
325
+ * @returns {object[]}
326
+ */
327
+ export function detectPatterns(receipts, opts = {}) {
328
+ if (!Array.isArray(receipts)) return [];
329
+ return [
330
+ ...detectRepeatedFail(receipts, opts),
331
+ ...detectRisingFailRate(receipts, opts),
332
+ ...detectCrossSkillCorrelation(receipts, opts),
333
+ ...detectRegression(receipts, opts),
334
+ ];
335
+ }
336
+
155
337
  /**
156
338
  * getFeedbackSuggestions(projectRoot, opts)
157
339
  *
@@ -178,10 +360,12 @@ export async function getFeedbackSuggestions(projectRoot, opts = {}) {
178
360
  const receipts = await readRecentReceipts(projectRoot, limit);
179
361
  const patterns = detectPatterns(receipts, { threshold, window });
180
362
 
181
- return patterns.map(
182
- (p) =>
183
- `Pattern detected: ${p.count}/${window} recent gates flagged on ${p.artifact_type} -- consider reviewing ${p.artifact_type} scope`,
184
- );
363
+ return patterns.map((p) => {
364
+ if (p.kind === 'repeated-fail-on-same-artifact') {
365
+ return `Pattern detected: ${p.count}/${window} recent gates flagged on ${p.artifact_type} -- consider reviewing ${p.artifact_type} scope`;
366
+ }
367
+ return `Pattern detected: ${p.suggestion}`;
368
+ });
185
369
  } catch {
186
370
  return [];
187
371
  }
@@ -17,10 +17,14 @@
17
17
  * pass -- that would defeat the sandbox.
18
18
  */
19
19
 
20
- import { readFile, mkdir, appendFile } from 'node:fs/promises';
20
+ import { readFile, mkdir, appendFile, rename, stat } from 'node:fs/promises';
21
21
  import { join } from 'node:path';
22
22
  import { homedir } from 'node:os';
23
23
 
24
+ // Log rotation: when permission-events.jsonl exceeds this many lines, rename
25
+ // to .0 (overwriting any prior .0) and start fresh. Total on disk = 2 * cap.
26
+ const ROTATION_LINE_CAP = 10_000;
27
+
24
28
  // Sentinel returned from getActiveExtension when the file exists but is
25
29
  // invalid. Callers compare with === to distinguish from null (no file).
26
30
  const MALFORMED = Object.freeze({ __malformed: true });
@@ -123,14 +127,39 @@ export function checkPermission(action, target, activeExt) {
123
127
  };
124
128
  }
125
129
 
130
+ /**
131
+ * Rotate permission-events.jsonl if it exceeds ROTATION_LINE_CAP lines.
132
+ * Renames current file to .0 (overwriting any prior .0) then the caller
133
+ * appends to the fresh empty file. Best effort -- never throws.
134
+ */
135
+ async function maybeRotateEventLog(logPath) {
136
+ try {
137
+ let st;
138
+ try { st = await stat(logPath); } catch { return; } // ENOENT = no rotation needed
139
+ if (st.size === 0) return;
140
+ // Count newlines in a single Buffer read. This is the amortised cost.
141
+ const buf = await readFile(logPath);
142
+ let count = 0;
143
+ for (let i = 0; i < buf.length; i++) {
144
+ if (buf[i] === 0x0a) count++; // '\n'
145
+ }
146
+ if (count < ROTATION_LINE_CAP) return;
147
+ await rename(logPath, logPath + '.0');
148
+ } catch {
149
+ // Rotation failure is non-fatal.
150
+ }
151
+ }
152
+
126
153
  /**
127
154
  * Append one JSON line to ~/.ijfw/state/permission-events.jsonl. Best effort:
128
155
  * never throws. The forensic trail is a nice-to-have, not a critical path.
156
+ * Rotation check runs on each append (cost amortised).
129
157
  */
130
158
  export async function logPermissionEvent(event, opts = {}) {
131
159
  try {
132
160
  const home = opts.homeDir || process.env.HOME || homedir();
133
161
  await mkdir(stateDir(home), { recursive: true });
162
+ await maybeRotateEventLog(eventLogPath(home));
134
163
  const line = JSON.stringify(event) + '\n';
135
164
  await appendFile(eventLogPath(home), line, 'utf8');
136
165
  } catch {