@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.
- package/README.md +67 -0
- package/package.json +1 -1
- package/src/.registry-meta-key.pem +3 -0
- package/src/active-extension-writer.js +314 -8
- package/src/dashboard-aggregator.js +165 -0
- package/src/dashboard-charts.js +239 -0
- package/src/dashboard-client.html +411 -1
- package/src/dashboard-server.js +350 -0
- package/src/dispatch/active-cli.js +141 -0
- package/src/dispatch/extension.js +272 -1
- 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-installer.js +39 -0
- package/src/extension-manifest-schema.js +25 -0
- package/src/extension-permission-check.mjs +140 -0
- package/src/extension-quota-tracker.js +305 -0
- package/src/extension-registry-ws.js +347 -0
- package/src/extension-registry.js +1289 -0
- package/src/extension-signer.js +270 -0
- package/src/fs-lock.js +205 -0
- package/src/hardware-signer.js +493 -0
- package/src/ide-detect.js +122 -0
- package/src/memory-feedback.js +194 -10
- package/src/runtime-mediator.js +61 -1
- package/src/server.js +180 -18
|
@@ -0,0 +1,493 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* hardware-signer.js — IJFW v1.4.3 W9-A2 / B15
|
|
3
|
+
*
|
|
4
|
+
* Backend abstraction for publisher signing operations. Two backends:
|
|
5
|
+
*
|
|
6
|
+
* SOFTWARE_BACKEND — private key on disk (PEM), signs in-process via
|
|
7
|
+
* node:crypto. Existing v1.4.0 behavior, preserved for back-compat.
|
|
8
|
+
*
|
|
9
|
+
* SSH_AGENT_BACKEND — private key never enters the IJFW process. Signing
|
|
10
|
+
* forwarded to the user's running ssh-agent (or hardware-token-backed
|
|
11
|
+
* agent like YubiKey, Solokey, gpg-agent's SSH socket, Pageant on
|
|
12
|
+
* Windows). Implements the OpenSSH agent wire protocol over UNIX
|
|
13
|
+
* sockets (or named-pipes on Windows) using node:net only.
|
|
14
|
+
*
|
|
15
|
+
* Backend resolution is FAIL-CLOSED (SEC-L-02): unknown backend names throw
|
|
16
|
+
* rather than silently fall through to software. This means a manifest with
|
|
17
|
+
* `publisher_key_backend: 'libfido2'` (not yet implemented in v1.4.3) is a
|
|
18
|
+
* hard error at sign-time, not a quiet downgrade to a weaker backend.
|
|
19
|
+
*
|
|
20
|
+
* Identity selection (SEC-H-03): when the ssh-agent backend signs, the
|
|
21
|
+
* agent is asked to enumerate identities. The expected public-key blob is
|
|
22
|
+
* loaded from `~/.ijfw/keys/<keyId>/backend.json` (`pubkey_blob_hex`) and
|
|
23
|
+
* matched against each agent identity by raw key material with
|
|
24
|
+
* `crypto.timingSafeEqual`. The SSH key comment is NEVER used for matching
|
|
25
|
+
* — comments are user-supplied strings and can collide; only raw public-key
|
|
26
|
+
* bytes are trustworthy.
|
|
27
|
+
*
|
|
28
|
+
* Spec: .planning/1.4.3/HANDOFF-1.4.3.md §B15
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import {
|
|
32
|
+
createHash,
|
|
33
|
+
createPublicKey,
|
|
34
|
+
createPrivateKey,
|
|
35
|
+
sign as cryptoSign,
|
|
36
|
+
verify as cryptoVerify,
|
|
37
|
+
generateKeyPairSync,
|
|
38
|
+
timingSafeEqual,
|
|
39
|
+
} from 'node:crypto';
|
|
40
|
+
import { connect as netConnect } from 'node:net';
|
|
41
|
+
import { readFile } from 'node:fs/promises';
|
|
42
|
+
import { homedir } from 'node:os';
|
|
43
|
+
import { join } from 'node:path';
|
|
44
|
+
|
|
45
|
+
// === SSH agent wire-protocol constants =====================================
|
|
46
|
+
// See draft-miller-ssh-agent (OpenSSH agent protocol).
|
|
47
|
+
const SSH2_AGENTC_REQUEST_IDENTITIES = 11;
|
|
48
|
+
const SSH2_AGENT_IDENTITIES_ANSWER = 12;
|
|
49
|
+
const SSH2_AGENTC_SIGN_REQUEST = 13;
|
|
50
|
+
const SSH2_AGENT_SIGN_RESPONSE = 14;
|
|
51
|
+
const SSH2_AGENT_FAILURE = 5;
|
|
52
|
+
|
|
53
|
+
// SSH wire string for the Ed25519 algorithm id. Prefix of every Ed25519
|
|
54
|
+
// pubkey blob and signature blob.
|
|
55
|
+
const SSH_ED25519_ALG = 'ssh-ed25519';
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Encode a buffer/string as an SSH wire "string" — uint32 length + bytes.
|
|
59
|
+
*
|
|
60
|
+
* @param {Buffer|string} v
|
|
61
|
+
* @returns {Buffer}
|
|
62
|
+
*/
|
|
63
|
+
function sshWireString(v) {
|
|
64
|
+
const body = Buffer.isBuffer(v) ? v : Buffer.from(v, 'utf8');
|
|
65
|
+
const len = Buffer.alloc(4);
|
|
66
|
+
len.writeUInt32BE(body.length, 0);
|
|
67
|
+
return Buffer.concat([len, body]);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Decode a sequence of SSH wire strings from `buf` starting at `offset`.
|
|
72
|
+
* Returns the consumed slices and the new offset.
|
|
73
|
+
*
|
|
74
|
+
* @param {Buffer} buf
|
|
75
|
+
* @param {number} offset
|
|
76
|
+
* @returns {{ value: Buffer, next: number }}
|
|
77
|
+
*/
|
|
78
|
+
function readSshString(buf, offset) {
|
|
79
|
+
if (offset + 4 > buf.length) {
|
|
80
|
+
throw new Error('SSH wire: truncated length prefix');
|
|
81
|
+
}
|
|
82
|
+
const len = buf.readUInt32BE(offset);
|
|
83
|
+
const start = offset + 4;
|
|
84
|
+
const end = start + len;
|
|
85
|
+
if (end > buf.length) {
|
|
86
|
+
throw new Error('SSH wire: truncated body');
|
|
87
|
+
}
|
|
88
|
+
return { value: buf.slice(start, end), next: end };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Compute the Ed25519 pubkey SSH wire blob from a 32-byte raw public key.
|
|
93
|
+
* Format: SSH-string("ssh-ed25519") || SSH-string(raw32).
|
|
94
|
+
*
|
|
95
|
+
* @param {Buffer} raw32
|
|
96
|
+
* @returns {Buffer}
|
|
97
|
+
*/
|
|
98
|
+
function ed25519PubkeyBlob(raw32) {
|
|
99
|
+
return Buffer.concat([
|
|
100
|
+
sshWireString(SSH_ED25519_ALG),
|
|
101
|
+
sshWireString(raw32),
|
|
102
|
+
]);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Pull the raw 32-byte Ed25519 public key out of an SPKI-DER buffer. DER
|
|
107
|
+
* shape is fixed-size for Ed25519 (12-byte header + 32-byte key). We
|
|
108
|
+
* tolerate small header variation by scanning for a 0x00,0x21 BIT STRING
|
|
109
|
+
* tail and lifting the last 32 bytes.
|
|
110
|
+
*
|
|
111
|
+
* @param {Buffer} spkiDer
|
|
112
|
+
* @returns {Buffer} 32-byte raw key
|
|
113
|
+
*/
|
|
114
|
+
function ed25519RawFromSpkiDer(spkiDer) {
|
|
115
|
+
if (spkiDer.length < 32) {
|
|
116
|
+
throw new Error('Ed25519 SPKI DER too short');
|
|
117
|
+
}
|
|
118
|
+
return spkiDer.slice(spkiDer.length - 32);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Build an SPKI-DER for an Ed25519 raw public key. Fixed prefix (id-Ed25519
|
|
123
|
+
* AlgorithmIdentifier + BIT STRING wrapping) per RFC 8410.
|
|
124
|
+
*
|
|
125
|
+
* @param {Buffer} raw32
|
|
126
|
+
* @returns {Buffer}
|
|
127
|
+
*/
|
|
128
|
+
function ed25519SpkiDerFromRaw(raw32) {
|
|
129
|
+
if (raw32.length !== 32) {
|
|
130
|
+
throw new Error('Ed25519 raw key must be 32 bytes');
|
|
131
|
+
}
|
|
132
|
+
// 30 2A — SEQUENCE (42)
|
|
133
|
+
// 30 05 — SEQUENCE (5)
|
|
134
|
+
// 06 03 2B 65 70 — OID 1.3.101.112 (Ed25519)
|
|
135
|
+
// 03 21 00 <32 bytes> — BIT STRING
|
|
136
|
+
const prefix = Buffer.from([
|
|
137
|
+
0x30, 0x2a,
|
|
138
|
+
0x30, 0x05,
|
|
139
|
+
0x06, 0x03, 0x2b, 0x65, 0x70,
|
|
140
|
+
0x03, 0x21, 0x00,
|
|
141
|
+
]);
|
|
142
|
+
return Buffer.concat([prefix, raw32]);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Convert raw Ed25519 public key bytes to a PEM string.
|
|
147
|
+
*
|
|
148
|
+
* @param {Buffer} raw32
|
|
149
|
+
* @returns {string}
|
|
150
|
+
*/
|
|
151
|
+
export function ed25519PemFromRaw(raw32) {
|
|
152
|
+
const der = ed25519SpkiDerFromRaw(raw32);
|
|
153
|
+
const b64 = der.toString('base64');
|
|
154
|
+
// 64-char lines per PEM convention.
|
|
155
|
+
const wrapped = b64.match(/.{1,64}/g).join('\n');
|
|
156
|
+
return `-----BEGIN PUBLIC KEY-----\n${wrapped}\n-----END PUBLIC KEY-----\n`;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Compute keyId fingerprint matching `extension-signer.js::publicKeyFingerprint`.
|
|
161
|
+
* Takes a PEM-encoded public key and returns sha256(spki-der) hex.
|
|
162
|
+
*
|
|
163
|
+
* Re-implemented here (rather than imported) to avoid a circular import
|
|
164
|
+
* between `extension-signer.js` (which imports this module for backend
|
|
165
|
+
* dispatch) and `hardware-signer.js`.
|
|
166
|
+
*
|
|
167
|
+
* @param {string} publicKeyPem
|
|
168
|
+
* @returns {string} 64-char lowercase hex
|
|
169
|
+
*/
|
|
170
|
+
export function publicKeyFingerprint(publicKeyPem) {
|
|
171
|
+
const key = createPublicKey(publicKeyPem);
|
|
172
|
+
const der = key.export({ type: 'spki', format: 'der' });
|
|
173
|
+
return createHash('sha256').update(der).digest('hex');
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// === SSH agent client =====================================================
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Send one request frame and await one response frame over the SSH agent
|
|
180
|
+
* socket. Frame format: uint32 length || payload.
|
|
181
|
+
*
|
|
182
|
+
* @param {string} socketPath
|
|
183
|
+
* @param {Buffer} payload single-message payload (type byte + body)
|
|
184
|
+
* @param {number} [timeoutMs]
|
|
185
|
+
* @returns {Promise<Buffer>} response payload (type byte + body)
|
|
186
|
+
*/
|
|
187
|
+
function agentRequest(socketPath, payload, timeoutMs = 5000) {
|
|
188
|
+
return new Promise((resolve, reject) => {
|
|
189
|
+
const sock = netConnect(socketPath);
|
|
190
|
+
let settled = false;
|
|
191
|
+
const settle = (err, val) => {
|
|
192
|
+
if (settled) return;
|
|
193
|
+
settled = true;
|
|
194
|
+
try { sock.destroy(); } catch { /* ignore */ }
|
|
195
|
+
if (err) reject(err); else resolve(val);
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
const timer = setTimeout(
|
|
199
|
+
() => settle(new Error(`SSH agent request timed out after ${timeoutMs}ms`)),
|
|
200
|
+
timeoutMs,
|
|
201
|
+
);
|
|
202
|
+
|
|
203
|
+
let buf = Buffer.alloc(0);
|
|
204
|
+
let expected = null;
|
|
205
|
+
sock.on('connect', () => {
|
|
206
|
+
const lenPrefix = Buffer.alloc(4);
|
|
207
|
+
lenPrefix.writeUInt32BE(payload.length, 0);
|
|
208
|
+
sock.write(Buffer.concat([lenPrefix, payload]));
|
|
209
|
+
});
|
|
210
|
+
sock.on('data', chunk => {
|
|
211
|
+
buf = Buffer.concat([buf, chunk]);
|
|
212
|
+
if (expected === null && buf.length >= 4) {
|
|
213
|
+
expected = buf.readUInt32BE(0);
|
|
214
|
+
}
|
|
215
|
+
if (expected !== null && buf.length >= 4 + expected) {
|
|
216
|
+
clearTimeout(timer);
|
|
217
|
+
settle(null, buf.slice(4, 4 + expected));
|
|
218
|
+
}
|
|
219
|
+
});
|
|
220
|
+
sock.on('error', err => {
|
|
221
|
+
clearTimeout(timer);
|
|
222
|
+
settle(new Error(`SSH agent socket error: ${err.message}`));
|
|
223
|
+
});
|
|
224
|
+
sock.on('close', () => {
|
|
225
|
+
clearTimeout(timer);
|
|
226
|
+
if (!settled) {
|
|
227
|
+
settle(new Error('SSH agent closed connection before responding'));
|
|
228
|
+
}
|
|
229
|
+
});
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Get the SSH agent socket path from env. Throws a clear error when unset.
|
|
235
|
+
*
|
|
236
|
+
* @returns {string}
|
|
237
|
+
*/
|
|
238
|
+
function requireAgentSocket() {
|
|
239
|
+
const sock = process.env.SSH_AUTH_SOCK;
|
|
240
|
+
if (!sock || typeof sock !== 'string' || sock.length === 0) {
|
|
241
|
+
throw new Error(
|
|
242
|
+
'SSH agent not available; set SSH_AUTH_SOCK or use --backend software',
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
return sock;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* List all identities the agent is willing to enumerate. Returns
|
|
250
|
+
* { blob: Buffer, comment: string } per identity. Filters are applied by
|
|
251
|
+
* the caller — we return the raw list so SEC-H-03 selection can run on
|
|
252
|
+
* raw key material.
|
|
253
|
+
*
|
|
254
|
+
* @param {string} [socketPath]
|
|
255
|
+
* @returns {Promise<Array<{ blob: Buffer, comment: string }>>}
|
|
256
|
+
*/
|
|
257
|
+
export async function listAgentIdentities(socketPath) {
|
|
258
|
+
const sock = socketPath || requireAgentSocket();
|
|
259
|
+
const payload = Buffer.from([SSH2_AGENTC_REQUEST_IDENTITIES]);
|
|
260
|
+
const resp = await agentRequest(sock, payload);
|
|
261
|
+
if (resp.length < 1) throw new Error('SSH agent: empty response');
|
|
262
|
+
const type = resp[0];
|
|
263
|
+
if (type !== SSH2_AGENT_IDENTITIES_ANSWER) {
|
|
264
|
+
throw new Error(`SSH agent: unexpected response type ${type}`);
|
|
265
|
+
}
|
|
266
|
+
if (resp.length < 5) throw new Error('SSH agent: truncated identities count');
|
|
267
|
+
const count = resp.readUInt32BE(1);
|
|
268
|
+
let off = 5;
|
|
269
|
+
const out = [];
|
|
270
|
+
for (let i = 0; i < count; i++) {
|
|
271
|
+
const blobRead = readSshString(resp, off);
|
|
272
|
+
off = blobRead.next;
|
|
273
|
+
const commentRead = readSshString(resp, off);
|
|
274
|
+
off = commentRead.next;
|
|
275
|
+
out.push({
|
|
276
|
+
blob: Buffer.from(blobRead.value),
|
|
277
|
+
comment: commentRead.value.toString('utf8'),
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
return out;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Ask the agent to sign `payload` with the identity whose pubkey blob is
|
|
285
|
+
* `keyBlob`. Returns the raw 64-byte Ed25519 signature (unwrapped from the
|
|
286
|
+
* SSH wire signature blob).
|
|
287
|
+
*
|
|
288
|
+
* @param {Buffer} keyBlob full SSH wire pubkey blob (ssh-ed25519 + raw32)
|
|
289
|
+
* @param {Buffer} payload bytes to sign
|
|
290
|
+
* @param {string} [socketPath]
|
|
291
|
+
* @returns {Promise<Buffer>} 64-byte raw signature
|
|
292
|
+
*/
|
|
293
|
+
export async function agentSign(keyBlob, payload, socketPath) {
|
|
294
|
+
const sock = socketPath || requireAgentSocket();
|
|
295
|
+
const flags = Buffer.alloc(4); // flags=0
|
|
296
|
+
const body = Buffer.concat([
|
|
297
|
+
sshWireString(keyBlob),
|
|
298
|
+
sshWireString(payload),
|
|
299
|
+
flags,
|
|
300
|
+
]);
|
|
301
|
+
const reqType = Buffer.from([SSH2_AGENTC_SIGN_REQUEST]);
|
|
302
|
+
const resp = await agentRequest(sock, Buffer.concat([reqType, body]));
|
|
303
|
+
if (resp.length < 1) throw new Error('SSH agent: empty sign response');
|
|
304
|
+
const type = resp[0];
|
|
305
|
+
if (type === SSH2_AGENT_FAILURE) {
|
|
306
|
+
throw new Error('SSH agent: sign request failed (SSH_AGENT_FAILURE)');
|
|
307
|
+
}
|
|
308
|
+
if (type !== SSH2_AGENT_SIGN_RESPONSE) {
|
|
309
|
+
throw new Error(`SSH agent: unexpected sign response type ${type}`);
|
|
310
|
+
}
|
|
311
|
+
// The body is one SSH wire string containing the signature blob:
|
|
312
|
+
// ssh-string("ssh-ed25519") || ssh-string(raw64-sig)
|
|
313
|
+
const sigBlob = readSshString(resp, 1).value;
|
|
314
|
+
let off = 0;
|
|
315
|
+
const alg = readSshString(sigBlob, off);
|
|
316
|
+
off = alg.next;
|
|
317
|
+
if (alg.value.toString('utf8') !== SSH_ED25519_ALG) {
|
|
318
|
+
throw new Error(`SSH agent: signature alg is not ssh-ed25519 (got ${alg.value.toString('utf8')})`);
|
|
319
|
+
}
|
|
320
|
+
const rawSig = readSshString(sigBlob, off).value;
|
|
321
|
+
if (rawSig.length !== 64) {
|
|
322
|
+
throw new Error(`SSH agent: Ed25519 signature must be 64 bytes (got ${rawSig.length})`);
|
|
323
|
+
}
|
|
324
|
+
return Buffer.from(rawSig);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// === Backend implementations ==============================================
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* SOFTWARE backend — wraps node:crypto Ed25519 ops over PEM keys on disk.
|
|
331
|
+
*
|
|
332
|
+
* For `sign(payload, keyId, { home })`:
|
|
333
|
+
* - Reads `<home>/.ijfw/keys/<keyId>/private.pem`.
|
|
334
|
+
* - Returns the 64-byte raw Ed25519 signature as a Uint8Array (Buffer).
|
|
335
|
+
*/
|
|
336
|
+
const softwareBackend = Object.freeze({
|
|
337
|
+
async sign(payload, keyId, opts = {}) {
|
|
338
|
+
const home = opts.home || homedir();
|
|
339
|
+
const privPath = join(home, '.ijfw', 'keys', keyId, 'private.pem');
|
|
340
|
+
const pem = await readFile(privPath, 'utf8');
|
|
341
|
+
const key = createPrivateKey(pem);
|
|
342
|
+
return cryptoSign(null, Buffer.from(payload), key);
|
|
343
|
+
},
|
|
344
|
+
async verify(payload, signature, publicKeyPem) {
|
|
345
|
+
const key = createPublicKey(publicKeyPem);
|
|
346
|
+
return cryptoVerify(null, Buffer.from(payload), key, Buffer.from(signature));
|
|
347
|
+
},
|
|
348
|
+
async getPublicKey(keyId, opts = {}) {
|
|
349
|
+
const home = opts.home || homedir();
|
|
350
|
+
const pubPath = join(home, '.ijfw', 'keys', keyId, 'public.pem');
|
|
351
|
+
return readFile(pubPath, 'utf8');
|
|
352
|
+
},
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* SSH-AGENT backend — defers signing to the running SSH agent. Identity
|
|
357
|
+
* is resolved at sign-time by raw public-key blob (SEC-H-03), NEVER by
|
|
358
|
+
* the user-supplied comment.
|
|
359
|
+
*/
|
|
360
|
+
const sshAgentBackend = Object.freeze({
|
|
361
|
+
async sign(payload, keyId, opts = {}) {
|
|
362
|
+
const home = opts.home || homedir();
|
|
363
|
+
const backendPath = join(home, '.ijfw', 'keys', keyId, 'backend.json');
|
|
364
|
+
let backend;
|
|
365
|
+
try {
|
|
366
|
+
backend = JSON.parse(await readFile(backendPath, 'utf8'));
|
|
367
|
+
} catch (err) {
|
|
368
|
+
throw new Error(
|
|
369
|
+
`SSH agent backend manifest not found for keyId ${keyId} at ${backendPath}: ${err.message}`,
|
|
370
|
+
);
|
|
371
|
+
}
|
|
372
|
+
if (backend.backend !== 'ssh-agent') {
|
|
373
|
+
throw new Error(
|
|
374
|
+
`keyId ${keyId} backend.json is not ssh-agent (got ${JSON.stringify(backend.backend)})`,
|
|
375
|
+
);
|
|
376
|
+
}
|
|
377
|
+
if (typeof backend.pubkey_blob_hex !== 'string' || backend.pubkey_blob_hex.length === 0) {
|
|
378
|
+
throw new Error(`keyId ${keyId} backend.json missing pubkey_blob_hex`);
|
|
379
|
+
}
|
|
380
|
+
const expected = Buffer.from(backend.pubkey_blob_hex, 'hex');
|
|
381
|
+
const identities = await listAgentIdentities(opts.socketPath);
|
|
382
|
+
// Filter to Ed25519-only — the expected blob is always ssh-ed25519.
|
|
383
|
+
const ed25519Prefix = sshWireString(SSH_ED25519_ALG);
|
|
384
|
+
const candidates = identities.filter(
|
|
385
|
+
ident => ident.blob.length >= ed25519Prefix.length
|
|
386
|
+
&& ident.blob.slice(0, ed25519Prefix.length).equals(ed25519Prefix),
|
|
387
|
+
);
|
|
388
|
+
// Constant-time match by full blob. Length-mismatched entries are
|
|
389
|
+
// discarded before timingSafeEqual (which throws on length mismatch).
|
|
390
|
+
const matches = candidates.filter(ident => {
|
|
391
|
+
if (ident.blob.length !== expected.length) return false;
|
|
392
|
+
return timingSafeEqual(ident.blob, expected);
|
|
393
|
+
});
|
|
394
|
+
if (matches.length === 0) {
|
|
395
|
+
throw new Error(
|
|
396
|
+
`SSH agent identity not found for keyId ${keyId}; expected pubkey blob ${backend.pubkey_blob_hex.slice(0, 32)}...`,
|
|
397
|
+
);
|
|
398
|
+
}
|
|
399
|
+
if (matches.length > 1) {
|
|
400
|
+
throw new Error(`Ambiguous SSH agent identities for keyId ${keyId}`);
|
|
401
|
+
}
|
|
402
|
+
return agentSign(matches[0].blob, Buffer.from(payload), opts.socketPath);
|
|
403
|
+
},
|
|
404
|
+
/**
|
|
405
|
+
* SSH agent doesn't expose a verify primitive — Ed25519 signatures are
|
|
406
|
+
* universally verifiable with the raw public key, so delegate to the
|
|
407
|
+
* software backend's verify. Verify is always cryptographic, never
|
|
408
|
+
* agent-mediated.
|
|
409
|
+
*/
|
|
410
|
+
async verify(payload, signature, publicKeyPem) {
|
|
411
|
+
return softwareBackend.verify(payload, signature, publicKeyPem);
|
|
412
|
+
},
|
|
413
|
+
async getPublicKey(keyId, opts = {}) {
|
|
414
|
+
return softwareBackend.getPublicKey(keyId, opts);
|
|
415
|
+
},
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
export const SOFTWARE_BACKEND = softwareBackend;
|
|
419
|
+
export const SSH_AGENT_BACKEND = sshAgentBackend;
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* Resolve a backend by name. FAIL-CLOSED (SEC-L-02): unknown names throw
|
|
423
|
+
* rather than silently falling back to software.
|
|
424
|
+
*
|
|
425
|
+
* undefined / 'software' → SOFTWARE_BACKEND
|
|
426
|
+
* 'ssh-agent' → SSH_AGENT_BACKEND
|
|
427
|
+
* anything else → throws
|
|
428
|
+
*
|
|
429
|
+
* @param {string|undefined} name
|
|
430
|
+
* @returns {{ sign: Function, verify: Function, getPublicKey: Function }}
|
|
431
|
+
*/
|
|
432
|
+
export function resolveBackend(name) {
|
|
433
|
+
if (name === undefined || name === 'software') return SOFTWARE_BACKEND;
|
|
434
|
+
if (name === 'ssh-agent') return SSH_AGENT_BACKEND;
|
|
435
|
+
throw new Error(`Unsupported signing backend: ${name}`);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* Helper: build the Ed25519 SSH wire pubkey blob from a PEM-encoded
|
|
440
|
+
* public key. Useful when enrolling a key — caller has a PEM and needs
|
|
441
|
+
* the corresponding blob to store in `backend.json`.
|
|
442
|
+
*
|
|
443
|
+
* @param {string} publicKeyPem
|
|
444
|
+
* @returns {Buffer}
|
|
445
|
+
*/
|
|
446
|
+
export function pubkeyBlobFromPem(publicKeyPem) {
|
|
447
|
+
const key = createPublicKey(publicKeyPem);
|
|
448
|
+
const der = key.export({ type: 'spki', format: 'der' });
|
|
449
|
+
const raw = ed25519RawFromSpkiDer(der);
|
|
450
|
+
return ed25519PubkeyBlob(raw);
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* Build a fresh Ed25519 keypair purely for fixtures / tests. NOT used at
|
|
455
|
+
* production sign-time (production keys are generated via
|
|
456
|
+
* `extension-signer.js::generatePublisherKeypair`). Exposed here only for
|
|
457
|
+
* the pure-Node mock-agent test harness.
|
|
458
|
+
*
|
|
459
|
+
* @returns {{ publicKeyPem: string, privateKeyPem: string, rawPub: Buffer, rawPriv: Buffer, pubkeyBlob: Buffer }}
|
|
460
|
+
*/
|
|
461
|
+
export function _testGenerateEd25519Fixture() {
|
|
462
|
+
const { publicKey, privateKey } = generateKeyPairSync('ed25519');
|
|
463
|
+
const publicKeyPem = publicKey.export({ type: 'spki', format: 'pem' }).toString();
|
|
464
|
+
const privateKeyPem = privateKey.export({ type: 'pkcs8', format: 'pem' }).toString();
|
|
465
|
+
const spkiDer = publicKey.export({ type: 'spki', format: 'der' });
|
|
466
|
+
const rawPub = ed25519RawFromSpkiDer(spkiDer);
|
|
467
|
+
// PKCS#8 for Ed25519 has the raw private key in the last 32 bytes of the
|
|
468
|
+
// OCTET STRING payload. We don't need the raw private for the tests
|
|
469
|
+
// (the mock agent re-imports the PEM directly), so this is a placeholder.
|
|
470
|
+
const rawPriv = Buffer.alloc(0);
|
|
471
|
+
return {
|
|
472
|
+
publicKeyPem,
|
|
473
|
+
privateKeyPem,
|
|
474
|
+
rawPub: Buffer.from(rawPub),
|
|
475
|
+
rawPriv,
|
|
476
|
+
pubkeyBlob: ed25519PubkeyBlob(Buffer.from(rawPub)),
|
|
477
|
+
};
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/**
|
|
481
|
+
* Low-level helpers exported for the mock SSH agent test harness only.
|
|
482
|
+
*/
|
|
483
|
+
export const _testInternals = Object.freeze({
|
|
484
|
+
sshWireString,
|
|
485
|
+
readSshString,
|
|
486
|
+
ed25519PubkeyBlob,
|
|
487
|
+
SSH2_AGENTC_REQUEST_IDENTITIES,
|
|
488
|
+
SSH2_AGENT_IDENTITIES_ANSWER,
|
|
489
|
+
SSH2_AGENTC_SIGN_REQUEST,
|
|
490
|
+
SSH2_AGENT_SIGN_RESPONSE,
|
|
491
|
+
SSH2_AGENT_FAILURE,
|
|
492
|
+
SSH_ED25519_ALG,
|
|
493
|
+
});
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ide-detect.js — IJFW v1.4.3 W9-B / B18
|
|
3
|
+
*
|
|
4
|
+
* Auto-detects which IDE is hosting the MCP server. 4-step probe:
|
|
5
|
+
* 1. process.env.IJFW_IDE_ID (explicit override)
|
|
6
|
+
* 2. process.env.npm_config_user_agent substring match
|
|
7
|
+
* 3. Parent process inspection (POSIX `ps`, Windows `wmic`/`powershell`)
|
|
8
|
+
* 4. Fallback 'unknown' — ARCH-L-01: emit one-time stderr info notice
|
|
9
|
+
*
|
|
10
|
+
* Result is cached per-process at first call (no perf cost on hot paths).
|
|
11
|
+
*
|
|
12
|
+
* Known IDE binary substrings (case-insensitive): claude, codex, gemini,
|
|
13
|
+
* cursor, windsurf, copilot, hermes, wayland.
|
|
14
|
+
*
|
|
15
|
+
* NOTE: parent-process inspection is performed synchronously via
|
|
16
|
+
* `child_process.spawnSync`. It is intentionally short-timeout and
|
|
17
|
+
* best-effort: any failure falls through to step 4.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { spawnSync } from 'node:child_process';
|
|
21
|
+
|
|
22
|
+
const KNOWN_IDES = ['claude', 'codex', 'gemini', 'cursor', 'windsurf', 'copilot', 'hermes', 'wayland'];
|
|
23
|
+
|
|
24
|
+
const ID_PATTERN = /^[a-z0-9-]+$/;
|
|
25
|
+
|
|
26
|
+
let _cachedIde = null;
|
|
27
|
+
let _unknownLoggedThisProcess = false;
|
|
28
|
+
|
|
29
|
+
function matchKnownIde(s) {
|
|
30
|
+
if (typeof s !== 'string') return null;
|
|
31
|
+
const lower = s.toLowerCase();
|
|
32
|
+
for (const id of KNOWN_IDES) {
|
|
33
|
+
if (lower.includes(id)) return id;
|
|
34
|
+
}
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function detectFromUserAgent() {
|
|
39
|
+
const ua = process.env.npm_config_user_agent;
|
|
40
|
+
return matchKnownIde(ua);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function detectFromParentProcess() {
|
|
44
|
+
const ppid = process.ppid;
|
|
45
|
+
if (!ppid || ppid <= 0) return null;
|
|
46
|
+
try {
|
|
47
|
+
if (process.platform === 'win32') {
|
|
48
|
+
// Use PowerShell as primary; fall back to wmic if it fails.
|
|
49
|
+
const r = spawnSync(
|
|
50
|
+
'powershell.exe',
|
|
51
|
+
['-NoProfile', '-Command', `Get-WmiObject Win32_Process -Filter "ProcessId=${ppid}" | Select-Object -ExpandProperty Name`],
|
|
52
|
+
{ encoding: 'utf8', timeout: 1500 },
|
|
53
|
+
);
|
|
54
|
+
if (r.status === 0 && r.stdout) {
|
|
55
|
+
const match = matchKnownIde(r.stdout);
|
|
56
|
+
if (match) return match;
|
|
57
|
+
}
|
|
58
|
+
const r2 = spawnSync('wmic', ['process', 'where', `ProcessId=${ppid}`, 'get', 'Name'], { encoding: 'utf8', timeout: 1500 });
|
|
59
|
+
if (r2.status === 0 && r2.stdout) {
|
|
60
|
+
return matchKnownIde(r2.stdout);
|
|
61
|
+
}
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
// POSIX
|
|
65
|
+
const r = spawnSync('ps', ['-o', 'comm=', '-p', String(ppid)], { encoding: 'utf8', timeout: 1500 });
|
|
66
|
+
if (r.status === 0 && r.stdout) {
|
|
67
|
+
return matchKnownIde(r.stdout);
|
|
68
|
+
}
|
|
69
|
+
return null;
|
|
70
|
+
} catch {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Detect the host IDE id. Cached at first call.
|
|
77
|
+
*
|
|
78
|
+
* @returns {string} one of KNOWN_IDES, an env-var value matching ID_PATTERN,
|
|
79
|
+
* or 'unknown'.
|
|
80
|
+
*/
|
|
81
|
+
export function detectIde(_opts = {}) {
|
|
82
|
+
if (_cachedIde !== null) return _cachedIde;
|
|
83
|
+
|
|
84
|
+
// 1) explicit env var override
|
|
85
|
+
const envOverride = process.env.IJFW_IDE_ID;
|
|
86
|
+
if (typeof envOverride === 'string' && envOverride.length > 0 && ID_PATTERN.test(envOverride)) {
|
|
87
|
+
_cachedIde = envOverride;
|
|
88
|
+
return _cachedIde;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// 2) npm_config_user_agent
|
|
92
|
+
const uaMatch = detectFromUserAgent();
|
|
93
|
+
if (uaMatch) {
|
|
94
|
+
_cachedIde = uaMatch;
|
|
95
|
+
return _cachedIde;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// 3) parent process inspection
|
|
99
|
+
const ppMatch = detectFromParentProcess();
|
|
100
|
+
if (ppMatch) {
|
|
101
|
+
_cachedIde = ppMatch;
|
|
102
|
+
return _cachedIde;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// 4) fallback
|
|
106
|
+
_cachedIde = 'unknown';
|
|
107
|
+
if (!_unknownLoggedThisProcess) {
|
|
108
|
+
process.stderr.write('[ijfw] info: IDE detection unavailable; cross-IDE conflict detection disabled. Set IJFW_IDE_ID to override.\n');
|
|
109
|
+
_unknownLoggedThisProcess = true;
|
|
110
|
+
}
|
|
111
|
+
return _cachedIde;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Test-only: clear cache + reset one-time stderr notice flag.
|
|
116
|
+
*/
|
|
117
|
+
export function _resetIdeCacheForTest() {
|
|
118
|
+
_cachedIde = null;
|
|
119
|
+
_unknownLoggedThisProcess = false;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export const KNOWN_IDE_LIST = Object.freeze([...KNOWN_IDES]);
|