@ijfw/memory-server 1.4.1 → 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.
- package/package.json +1 -1
- package/src/active-extension-writer.js +284 -4
- package/src/dashboard-aggregator.js +165 -0
- package/src/dashboard-charts.js +239 -0
- package/src/dashboard-client.html +201 -0
- package/src/dashboard-server.js +107 -0
- package/src/dispatch/active-cli.js +141 -0
- package/src/dispatch/extension.js +38 -0
- package/src/dispatch/quota-cli.js +42 -0
- package/src/dispatch/registry-cli.js +339 -0
- package/src/dispatch/signer-cli.js +311 -0
- package/src/extension-manifest-schema.js +25 -0
- package/src/extension-permission-check.mjs +61 -0
- package/src/extension-quota-tracker.js +305 -0
- package/src/extension-registry-ws.js +347 -0
- package/src/extension-registry.js +819 -149
- package/src/extension-signer.js +105 -0
- package/src/fs-lock.js +205 -0
- package/src/hardware-signer.js +493 -0
- package/src/ide-detect.js +122 -0
- package/src/runtime-mediator.js +31 -0
- package/src/server.js +180 -18
package/src/extension-signer.js
CHANGED
|
@@ -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
|
*
|
|
@@ -810,6 +873,48 @@ export function signRotationToken(oldPrivateKeyPem, newPublicKeyPem, opts = {})
|
|
|
810
873
|
return { ...token, signature: `ed25519:${sigBuf.toString('base64')}` };
|
|
811
874
|
}
|
|
812
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
|
+
|
|
813
918
|
/**
|
|
814
919
|
* Verify a rotation token against the old public key.
|
|
815
920
|
* Checks:
|
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
|
+
}
|