@ijfw/memory-server 1.4.0 → 1.4.3

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.
@@ -45,6 +45,16 @@ import {
45
45
  SIGNATURE_PATTERN,
46
46
  PUBLISHER_KEY_ID_PATTERN,
47
47
  } from './extension-manifest-schema.js';
48
+ import {
49
+ SOFTWARE_BACKEND,
50
+ SSH_AGENT_BACKEND,
51
+ resolveBackend,
52
+ } from './hardware-signer.js';
53
+
54
+ // B15: re-export backend primitives so callers can sign/verify via the
55
+ // backend abstraction without a second import. Software backend remains
56
+ // the default; ssh-agent backend is opt-in via manifest.publisher_key_backend.
57
+ export { SOFTWARE_BACKEND, SSH_AGENT_BACKEND, resolveBackend };
48
58
 
49
59
  /**
50
60
  * Recursively sort object keys to produce a stable canonical representation.
@@ -580,6 +590,59 @@ export function signManifest(manifest, privateKeyPem) {
580
590
  return computeIntegrity(signed);
581
591
  }
582
592
 
593
+ /**
594
+ * B15: Backend-aware async manifest signer. Dispatches to the backend
595
+ * named by `manifest.publisher_key_backend` (default: 'software').
596
+ *
597
+ * The software backend reads the on-disk private PEM at
598
+ * `<home>/.ijfw/keys/<keyId>/private.pem` and signs in-process. The
599
+ * ssh-agent backend forwards the signing op to the running SSH agent
600
+ * — the private key never enters the IJFW process.
601
+ *
602
+ * Identity selection at sign time:
603
+ * - keyId is supplied via `opts.keyId`. The chosen backend uses keyId
604
+ * to look up its key material (PEM on disk for software; pubkey blob
605
+ * in backend.json for ssh-agent, then identity match in the agent).
606
+ *
607
+ * Returns a NEW manifest with `publisher_key_id`, `publisher_key_backend`
608
+ * (when explicitly non-software), `signature`, and re-computed `integrity`.
609
+ *
610
+ * @param {object} manifest
611
+ * @param {object} opts
612
+ * @param {string} opts.keyId required — the keyId to sign with
613
+ * @param {string} [opts.home] override for ~/.ijfw root (test isolation)
614
+ * @param {string} [opts.socketPath] override SSH_AUTH_SOCK (test isolation)
615
+ * @returns {Promise<object>}
616
+ */
617
+ export async function signManifestWithBackend(manifest, opts = {}) {
618
+ if (manifest === null || typeof manifest !== 'object' || Array.isArray(manifest)) {
619
+ throw new TypeError('signManifestWithBackend: manifest must be an object');
620
+ }
621
+ if (typeof opts.keyId !== 'string' || !PUBLISHER_KEY_ID_PATTERN.test(opts.keyId)) {
622
+ throw new TypeError('signManifestWithBackend: opts.keyId required (sha256 hex)');
623
+ }
624
+ const backendName = manifest.publisher_key_backend; // undefined OK
625
+ const backend = resolveBackend(backendName);
626
+
627
+ const toSign = {
628
+ ...manifest,
629
+ publisher_key_id: opts.keyId,
630
+ };
631
+ // Only emit `publisher_key_backend` field when explicitly non-default to
632
+ // keep software-backend manifests byte-identical to the v1.4.0 shape.
633
+ if (backendName !== undefined && backendName !== 'software') {
634
+ toSign.publisher_key_backend = backendName;
635
+ }
636
+ const bytes = canonicalSigningBytes(toSign);
637
+ const sigBuf = await backend.sign(bytes, opts.keyId, {
638
+ home: opts.home,
639
+ socketPath: opts.socketPath,
640
+ });
641
+ const signature = `ed25519:${Buffer.from(sigBuf).toString('base64')}`;
642
+ const signed = { ...toSign, signature };
643
+ return computeIntegrity(signed);
644
+ }
645
+
583
646
  /**
584
647
  * Verify a manifest's signature against a map of trusted publishers.
585
648
  *
@@ -641,6 +704,52 @@ export function verifyManifestSignature(manifest, trustedKeys) {
641
704
  return { valid: true, publisherKeyId: kid, reason: 'ok' };
642
705
  }
643
706
 
707
+ // === B6: Revoked publishers store ========================================
708
+
709
+ /**
710
+ * Read the revoked publishers list from ~/.ijfw/state/revoked-publishers.json.
711
+ * Returns an empty list when absent or malformed.
712
+ *
713
+ * @returns {Promise<Array<{keyId: string, revoked_at: string, reason: string, superseded_by: string|null}>>}
714
+ */
715
+ export async function readRevokedPublishers() {
716
+ const path = join(homedir(), '.ijfw', 'state', 'revoked-publishers.json');
717
+ let raw;
718
+ try {
719
+ raw = await readFile(path, 'utf8');
720
+ } catch {
721
+ return [];
722
+ }
723
+ let parsed;
724
+ try {
725
+ parsed = JSON.parse(raw);
726
+ } catch {
727
+ return [];
728
+ }
729
+ if (!parsed || !Array.isArray(parsed.revoked)) return [];
730
+ return parsed.revoked.filter(r => typeof r.keyId === 'string');
731
+ }
732
+
733
+ // Module-level revoked set cache — loaded once per process, refreshed by applyRegistry.
734
+ // Export for test isolation only (allows tests to reset after changing HOME).
735
+ export function _resetRevokedCacheForTest() { _revokedSet = null; }
736
+ let _revokedSet = null;
737
+
738
+ /**
739
+ * O(1) check: is a keyId on the revoked list?
740
+ * Loads the set on first call; cached for the process lifetime.
741
+ *
742
+ * @param {string} keyId
743
+ * @returns {Promise<boolean>}
744
+ */
745
+ export async function isRevoked(keyId) {
746
+ if (_revokedSet === null) {
747
+ const list = await readRevokedPublishers();
748
+ _revokedSet = new Set(list.map(r => r.keyId));
749
+ }
750
+ return _revokedSet.has(keyId);
751
+ }
752
+
644
753
  /**
645
754
  * Read the trusted publishers JSON. Returns `{publishers: {}}` when absent or
646
755
  * malformed (fail-closed for verification: no trusted keys means nothing is
@@ -701,6 +810,10 @@ export async function addTrustedPublisher(keyId, publicKey, name) {
701
810
  if (typeof publicKey !== 'string' || publicKey.indexOf('BEGIN PUBLIC KEY') === -1) {
702
811
  return { ok: false, error: 'publicKey must be PEM-encoded' };
703
812
  }
813
+ // B6: refuse to add a revoked publisher
814
+ if (await isRevoked(keyId)) {
815
+ return { ok: false, error: 'publisher revoked by IJFW registry' };
816
+ }
704
817
  let fp;
705
818
  try {
706
819
  fp = publicKeyFingerprint(publicKey);
@@ -720,6 +833,163 @@ export async function addTrustedPublisher(keyId, publicKey, name) {
720
833
  return { ok: true, store };
721
834
  }
722
835
 
836
+ // === B8: Key rotation + revocation ==========================================
837
+ //
838
+ // Rotation token is signed by the OLD private key — proof of control.
839
+ // An attacker without the old private key cannot produce a valid token.
840
+ // A publisher who lost their old private key must contact the registry
841
+ // maintainer for out-of-band manual key replacement (see docs/REGISTRY-MAINTAINER.md).
842
+ //
843
+ // Token shape: { rotated_at, old_key_id, new_key_id, new_public_key, signature }
844
+ // Canonical signing bytes: all fields except `signature`, sorted by key (sortKeysDeep).
845
+
846
+ /**
847
+ * Produce a rotation token asserting that newPublicKey supersedes the key
848
+ * identified by oldPrivateKey. Signed by the old private key.
849
+ *
850
+ * @param {string} oldPrivateKeyPem
851
+ * @param {string} newPublicKeyPem
852
+ * @param {object} [opts]
853
+ * @param {string} [opts.rotated_at] ISO timestamp (defaults to now)
854
+ * @returns {{ rotated_at: string, old_key_id: string, new_key_id: string, new_public_key: string, signature: string }}
855
+ */
856
+ export function signRotationToken(oldPrivateKeyPem, newPublicKeyPem, opts = {}) {
857
+ const priv = createPrivateKey(oldPrivateKeyPem);
858
+ // Derive old_key_id from the old private key's matching public key.
859
+ const oldPub = createPublicKey(priv);
860
+ const old_key_id = publicKeyFingerprint(oldPub.export({ type: 'spki', format: 'pem' }).toString());
861
+ const new_key_id = publicKeyFingerprint(newPublicKeyPem);
862
+
863
+ const token = {
864
+ rotated_at: opts.rotated_at || new Date().toISOString(),
865
+ old_key_id,
866
+ new_key_id,
867
+ new_public_key: newPublicKeyPem,
868
+ };
869
+
870
+ // Canonical signing bytes: sortKeysDeep of token (signature excluded — not present yet).
871
+ const bytes = Buffer.from(JSON.stringify(sortKeysDeep(token)), 'utf8');
872
+ const sigBuf = cryptoSign(null, bytes, priv);
873
+ return { ...token, signature: `ed25519:${sigBuf.toString('base64')}` };
874
+ }
875
+
876
+ /**
877
+ * B15: Backend-aware async rotation-token signer. Mirrors signRotationToken
878
+ * but dispatches the actual signing operation to the named backend.
879
+ *
880
+ * The token shape is identical to the software-backend version
881
+ * (`signRotationToken`); the only difference is WHO holds the private
882
+ * key. For ssh-agent backend, the OLD key's signing op is forwarded to
883
+ * the agent — the old private key never enters IJFW process memory.
884
+ *
885
+ * @param {object} opts
886
+ * @param {string} opts.oldKeyId required — keyId for the OLD key
887
+ * @param {string} opts.newPublicKeyPem required — PEM of the NEW key
888
+ * @param {string} [opts.backend] 'software' | 'ssh-agent' (default software)
889
+ * @param {string} [opts.rotated_at] ISO timestamp override
890
+ * @param {string} [opts.home] override for ~/.ijfw root (test isolation)
891
+ * @param {string} [opts.socketPath] override SSH_AUTH_SOCK (test isolation)
892
+ * @returns {Promise<{rotated_at, old_key_id, new_key_id, new_public_key, signature}>}
893
+ */
894
+ export async function signRotationTokenWithBackend(opts = {}) {
895
+ if (typeof opts.oldKeyId !== 'string' || !PUBLISHER_KEY_ID_PATTERN.test(opts.oldKeyId)) {
896
+ throw new TypeError('signRotationTokenWithBackend: opts.oldKeyId required (sha256 hex)');
897
+ }
898
+ if (typeof opts.newPublicKeyPem !== 'string' || opts.newPublicKeyPem.indexOf('BEGIN PUBLIC KEY') === -1) {
899
+ throw new TypeError('signRotationTokenWithBackend: opts.newPublicKeyPem must be PEM');
900
+ }
901
+ const backend = resolveBackend(opts.backend);
902
+ const new_key_id = publicKeyFingerprint(opts.newPublicKeyPem);
903
+
904
+ const token = {
905
+ rotated_at: opts.rotated_at || new Date().toISOString(),
906
+ old_key_id: opts.oldKeyId,
907
+ new_key_id,
908
+ new_public_key: opts.newPublicKeyPem,
909
+ };
910
+ const bytes = Buffer.from(JSON.stringify(sortKeysDeep(token)), 'utf8');
911
+ const sigBuf = await backend.sign(bytes, opts.oldKeyId, {
912
+ home: opts.home,
913
+ socketPath: opts.socketPath,
914
+ });
915
+ return { ...token, signature: `ed25519:${Buffer.from(sigBuf).toString('base64')}` };
916
+ }
917
+
918
+ /**
919
+ * Verify a rotation token against the old public key.
920
+ * Checks:
921
+ * 1. Signature is valid Ed25519 over canonical bytes (signature field excluded).
922
+ * 2. fingerprint(oldPublicKey) === token.old_key_id.
923
+ * 3. token.rotated_at is within opts.max_age_ms (default 90 days).
924
+ *
925
+ * @param {object} token
926
+ * @param {string} oldPublicKeyPem
927
+ * @param {object} [opts]
928
+ * @param {number} [opts.max_age_ms] Maximum token age in ms (default 90 days)
929
+ * @returns {{ valid: boolean, reason: string }}
930
+ */
931
+ export function verifyRotationToken(token, oldPublicKeyPem, opts = {}) {
932
+ if (!token || typeof token !== 'object') {
933
+ return { valid: false, reason: 'token must be an object' };
934
+ }
935
+ const { rotated_at, old_key_id, new_key_id, new_public_key, signature } = token;
936
+ if (!rotated_at || !old_key_id || !new_key_id || !new_public_key || !signature) {
937
+ return { valid: false, reason: 'token missing required fields' };
938
+ }
939
+ if (typeof signature !== 'string' || !signature.startsWith('ed25519:')) {
940
+ return { valid: false, reason: 'signature must be "ed25519:<base64>"' };
941
+ }
942
+
943
+ // Expiry check: reject tokens older than max_age_ms (default 90 days).
944
+ const MAX_AGE_MS = opts.max_age_ms ?? (90 * 24 * 60 * 60 * 1000);
945
+ const rotatedAtMs = new Date(rotated_at).getTime();
946
+ if (isNaN(rotatedAtMs)) {
947
+ return { valid: false, reason: 'rotated_at is not a valid date' };
948
+ }
949
+ if (Date.now() - rotatedAtMs > MAX_AGE_MS) {
950
+ return { valid: false, reason: 'rotation token expired' };
951
+ }
952
+
953
+ // Check old_key_id matches the supplied public key fingerprint.
954
+ let fp;
955
+ try {
956
+ fp = publicKeyFingerprint(oldPublicKeyPem);
957
+ } catch (err) {
958
+ return { valid: false, reason: `old public key parse failed: ${err.message}` };
959
+ }
960
+ if (fp !== old_key_id) {
961
+ return { valid: false, reason: `old_key_id mismatch: token says ${old_key_id} but supplied key fingerprints to ${fp}` };
962
+ }
963
+
964
+ // Reconstruct canonical signing bytes (exclude signature field).
965
+ const payload = { rotated_at, old_key_id, new_key_id, new_public_key };
966
+ const bytes = Buffer.from(JSON.stringify(sortKeysDeep(payload)), 'utf8');
967
+
968
+ let pubKey;
969
+ try {
970
+ pubKey = createPublicKey(oldPublicKeyPem);
971
+ } catch (err) {
972
+ return { valid: false, reason: `old public key unparseable: ${err.message}` };
973
+ }
974
+
975
+ let sigBuf;
976
+ try {
977
+ sigBuf = Buffer.from(signature.slice('ed25519:'.length), 'base64');
978
+ } catch {
979
+ return { valid: false, reason: 'signature base64 decode failed' };
980
+ }
981
+
982
+ let ok;
983
+ try {
984
+ ok = cryptoVerify(null, bytes, pubKey, sigBuf);
985
+ } catch (err) {
986
+ return { valid: false, reason: `verify threw: ${err.message}` };
987
+ }
988
+
989
+ if (!ok) return { valid: false, reason: 'signature does not verify' };
990
+ return { valid: true, reason: 'ok' };
991
+ }
992
+
723
993
  /**
724
994
  * Remove a trusted publisher entry by keyId. Idempotent.
725
995
  *
package/src/fs-lock.js ADDED
@@ -0,0 +1,205 @@
1
+ /**
2
+ * fs-lock.js — Cross-platform directory-based exclusive lock primitive.
3
+ *
4
+ * Frozen contract for v1.4.3 W9-A (consumed by W9-A1 cache writes + W9-A3
5
+ * quota counters). The directory itself is the lock; `mkdir` with
6
+ * `recursive:false` is atomic on POSIX + Windows, so any two processes racing
7
+ * to create the same path will see exactly one winner (EEXIST for the other).
8
+ *
9
+ * NO `process.on('exit')` cleanup. If a holder is SIGKILL'd between mkdir and
10
+ * the `finally` clause, the next caller's stale-recovery path handles it
11
+ * (single retry after rm).
12
+ *
13
+ * Closes SEC-H-01 (cross-process race) from v1.4.3 cross-audit round 1.
14
+ */
15
+
16
+ import { mkdir, writeFile, readFile, rm, stat } from 'node:fs/promises';
17
+ import { join, dirname } from 'node:path';
18
+
19
+ const DEFAULT_ACQUIRE_TIMEOUT_MS = 5000;
20
+ const DEFAULT_STALE_MS = 30000;
21
+ const BACKOFF_START_MS = 25;
22
+ const BACKOFF_MAX_MS = 250;
23
+
24
+ export class FsLockBusyError extends Error {
25
+ constructor(lockPath, timeoutMs) {
26
+ super(
27
+ `fs-lock: could not acquire "${lockPath}" within ${timeoutMs}ms (holder still alive)`,
28
+ );
29
+ this.name = 'FsLockBusyError';
30
+ this.code = 'FS_LOCK_BUSY';
31
+ this.lockPath = lockPath;
32
+ this.timeoutMs = timeoutMs;
33
+ }
34
+ }
35
+
36
+ export class FsLockStaleError extends Error {
37
+ constructor(lockPath, cause) {
38
+ super(
39
+ `fs-lock: stale lock recovery failed for "${lockPath}"${
40
+ cause ? `: ${cause.message || cause}` : ''
41
+ }`,
42
+ );
43
+ this.name = 'FsLockStaleError';
44
+ this.code = 'FS_LOCK_STALE';
45
+ this.lockPath = lockPath;
46
+ if (cause) this.cause = cause;
47
+ }
48
+ }
49
+
50
+ function sleep(ms) {
51
+ return new Promise((resolve) => setTimeout(resolve, ms));
52
+ }
53
+
54
+ async function readHolder(lockPath) {
55
+ try {
56
+ const raw = await readFile(join(lockPath, 'holder.json'), 'utf8');
57
+ const parsed = JSON.parse(raw);
58
+ if (
59
+ parsed &&
60
+ typeof parsed === 'object' &&
61
+ typeof parsed.acquired_at === 'number'
62
+ ) {
63
+ return parsed;
64
+ }
65
+ return null;
66
+ } catch {
67
+ // holder.json may not exist yet (mkdir landed, write hadn't), or be
68
+ // truncated. Treat as "no readable holder" — caller decides what to do.
69
+ return null;
70
+ }
71
+ }
72
+
73
+ async function tryAcquireOnce(lockPath) {
74
+ await mkdir(lockPath, { recursive: false });
75
+ // Write holder file inside the lock dir — purely forensic. The directory's
76
+ // existence is what holds the lock.
77
+ const holder = { pid: process.pid, acquired_at: Date.now() };
78
+ try {
79
+ await writeFile(
80
+ join(lockPath, 'holder.json'),
81
+ JSON.stringify(holder),
82
+ 'utf8',
83
+ );
84
+ } catch {
85
+ // Best-effort. The lock is still held even if forensic write failed.
86
+ }
87
+ return holder;
88
+ }
89
+
90
+ /**
91
+ * withFsLock(lockPath, fn, { staleMs, acquireTimeoutMs })
92
+ *
93
+ * See module docstring for contract details.
94
+ */
95
+ export async function withFsLock(lockPath, fn, opts = {}) {
96
+ const staleMs =
97
+ typeof opts.staleMs === 'number' ? opts.staleMs : DEFAULT_STALE_MS;
98
+ const acquireTimeoutMs =
99
+ typeof opts.acquireTimeoutMs === 'number'
100
+ ? opts.acquireTimeoutMs
101
+ : DEFAULT_ACQUIRE_TIMEOUT_MS;
102
+
103
+ // Ensure the lock's parent directory exists. `tryAcquireOnce` uses
104
+ // `mkdir(lockPath, { recursive: false })` which fails with ENOENT when any
105
+ // parent is missing — surfacing as a non-EEXIST error and breaking callers
106
+ // that expect locks to "just work" in fresh tmp HOMEs. Single best-effort
107
+ // recursive mkdir up-front is cheap (one stat on the common case) and makes
108
+ // the lock primitive safe to invoke against any path under a writable root.
109
+ // Surfaced as a Windows CI regression in v1.4.3 (test-extension-registry
110
+ // tests 523/527/534/535 — Linux/macOS passed only because earlier tests
111
+ // happened to create ~/.ijfw/state by side-effect).
112
+ try {
113
+ await mkdir(dirname(lockPath), { recursive: true });
114
+ } catch {
115
+ // Ignore — the subsequent mkdir(lockPath) will surface a clearer error.
116
+ }
117
+
118
+ const deadline = Date.now() + acquireTimeoutMs;
119
+ let staleRecoveryUsed = false;
120
+ let backoff = BACKOFF_START_MS;
121
+
122
+ // Acquire loop. We try mkdir; if EEXIST, decide between waiting and stale
123
+ // recovery; otherwise propagate the error.
124
+ // eslint-disable-next-line no-constant-condition
125
+ while (true) {
126
+ try {
127
+ await tryAcquireOnce(lockPath);
128
+ break;
129
+ } catch (err) {
130
+ if (err && err.code !== 'EEXIST') {
131
+ // Real filesystem error (ENOENT on parent, EACCES, etc.) — surface.
132
+ throw err;
133
+ }
134
+
135
+ // R12-M-01: when holder.json is missing or unparseable (crash between
136
+ // mkdir and the holder write, OR an attacker pre-creating an empty lock
137
+ // dir to starve callers), fall back to the lock directory's own mtime so
138
+ // stale-recovery can still fire. Without this fallback the local-DoS
139
+ // surface is "mkdir <lockPath> && walk away".
140
+ const holder = await readHolder(lockPath);
141
+ let age = null;
142
+ if (holder) {
143
+ age = Date.now() - holder.acquired_at;
144
+ } else {
145
+ try {
146
+ const st = await stat(lockPath);
147
+ age = Date.now() - st.mtimeMs;
148
+ } catch {
149
+ // The lock dir vanished between EEXIST and stat — fall through to
150
+ // the deadline check; the next loop iteration will try mkdir again.
151
+ age = null;
152
+ }
153
+ }
154
+ const isStale = age != null && age > staleMs;
155
+
156
+ if (isStale && !staleRecoveryUsed) {
157
+ staleRecoveryUsed = true;
158
+ try {
159
+ await rm(lockPath, { recursive: true, force: true });
160
+ } catch (rmErr) {
161
+ throw new FsLockStaleError(lockPath, rmErr);
162
+ }
163
+ try {
164
+ await tryAcquireOnce(lockPath);
165
+ break;
166
+ } catch (retryErr) {
167
+ if (retryErr && retryErr.code === 'EEXIST') {
168
+ throw new FsLockStaleError(lockPath, retryErr);
169
+ }
170
+ throw retryErr;
171
+ }
172
+ }
173
+
174
+ if (Date.now() >= deadline) {
175
+ throw new FsLockBusyError(lockPath, acquireTimeoutMs);
176
+ }
177
+
178
+ // Bounded exponential backoff; clamp to remaining time so we don't
179
+ // overshoot the deadline by a full BACKOFF_MAX_MS slice.
180
+ const remaining = Math.max(0, deadline - Date.now());
181
+ const waitMs = Math.min(backoff, BACKOFF_MAX_MS, remaining);
182
+ if (waitMs > 0) await sleep(waitMs);
183
+ backoff = Math.min(backoff * 2, BACKOFF_MAX_MS);
184
+ }
185
+ }
186
+
187
+ // Lock acquired — run fn and ALWAYS release.
188
+ let fnResult;
189
+ let fnError;
190
+ try {
191
+ fnResult = await fn();
192
+ } catch (err) {
193
+ fnError = err;
194
+ }
195
+
196
+ try {
197
+ await rm(lockPath, { recursive: true, force: true });
198
+ } catch {
199
+ // Release failures don't override the caller's result/error. The next
200
+ // caller's stale-recovery will reclaim if needed.
201
+ }
202
+
203
+ if (fnError !== undefined) throw fnError;
204
+ return fnResult;
205
+ }