@ijfw/memory-server 1.4.1 → 1.4.4
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/cross-orchestrator.js +164 -2
- package/src/dashboard-aggregator.js +165 -0
- package/src/dashboard-charts.js +239 -0
- package/src/dashboard-client-planning.html +273 -0
- package/src/dashboard-client.html +213 -1
- package/src/dashboard-server.js +186 -0
- package/src/dispatch/active-cli.js +141 -0
- package/src/dispatch/extension.js +40 -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/dispatch/wave-cli.js +128 -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/orchestrator/review.js +101 -0
- package/src/orchestrator/status-protocol.js +168 -0
- package/src/orchestrator/verification-gate.js +97 -0
- package/src/orchestrator/wave-state.js +255 -0
- package/src/runtime-mediator.js +31 -0
- package/src/server.js +180 -18
- package/src/swarm-config.js +32 -8
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dispatch/registry-cli.js — IJFW v1.4.3/B14 + B17 registry CLI handlers.
|
|
3
|
+
*
|
|
4
|
+
* Frozen export contract (Wave A invariants):
|
|
5
|
+
* export const handlers = {
|
|
6
|
+
* '<subcommand>': async (args, ctx) => ({ ok, output?, error? }),
|
|
7
|
+
* ...
|
|
8
|
+
* };
|
|
9
|
+
* export const subcommandHelp = {
|
|
10
|
+
* '<subcommand>': 'one-line description',
|
|
11
|
+
* ...
|
|
12
|
+
* };
|
|
13
|
+
*
|
|
14
|
+
* Subcommands owned by this module:
|
|
15
|
+
* - registry-list
|
|
16
|
+
* - registry-add <name> <url> [<meta-key-path>]
|
|
17
|
+
* - registry-remove <name>
|
|
18
|
+
* - registry-prioritize <name> <position>
|
|
19
|
+
* - registry-status
|
|
20
|
+
* - trust-registry --emergency [<url>]
|
|
21
|
+
*
|
|
22
|
+
* Phase D will wire these into dispatch/extension.js via
|
|
23
|
+
* `Object.entries(handlers)`. Each handler accepts a token array (already
|
|
24
|
+
* tokenized + dequoted) and the dispatch ctx (cwd, homedir, etc.).
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { readFile, writeFile, mkdir, stat } from 'node:fs/promises';
|
|
28
|
+
import { join } from 'node:path';
|
|
29
|
+
import { homedir as osHomedir } from 'node:os';
|
|
30
|
+
import { createPublicKey } from 'node:crypto';
|
|
31
|
+
|
|
32
|
+
import {
|
|
33
|
+
loadRegistrySources,
|
|
34
|
+
refreshTrustFromAllRegistries,
|
|
35
|
+
readSourceCache,
|
|
36
|
+
RegistrySourcesError,
|
|
37
|
+
META_KEY_SENTINEL,
|
|
38
|
+
IJFW_REGISTRY_META_KEY_PEM,
|
|
39
|
+
SOURCE_NAME_PATTERN,
|
|
40
|
+
} from '../extension-registry.js';
|
|
41
|
+
|
|
42
|
+
function homedir(ctx) {
|
|
43
|
+
return (ctx && ctx.homedir) || osHomedir();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function registriesConfigPath(ctx) {
|
|
47
|
+
return join(homedir(ctx), '.ijfw', 'registries.json');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function readRegistriesFile(ctx) {
|
|
51
|
+
const path = registriesConfigPath(ctx);
|
|
52
|
+
try {
|
|
53
|
+
const raw = await readFile(path, 'utf8');
|
|
54
|
+
const parsed = JSON.parse(raw);
|
|
55
|
+
if (!parsed || typeof parsed !== 'object' || !Array.isArray(parsed.registries)) {
|
|
56
|
+
throw new Error('registries.json: invalid shape');
|
|
57
|
+
}
|
|
58
|
+
return { path, doc: parsed };
|
|
59
|
+
} catch (err) {
|
|
60
|
+
if (err && err.code === 'ENOENT') {
|
|
61
|
+
return {
|
|
62
|
+
path,
|
|
63
|
+
doc: { schema_version: '1.0', registries: [] },
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
throw err;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function writeRegistriesFile(ctx, doc) {
|
|
71
|
+
const path = registriesConfigPath(ctx);
|
|
72
|
+
await mkdir(join(homedir(ctx), '.ijfw'), { recursive: true });
|
|
73
|
+
const tmp = `${path}.tmp-${process.pid}-${Date.now()}`;
|
|
74
|
+
await writeFile(tmp, JSON.stringify(doc, null, 2) + '\n', 'utf8');
|
|
75
|
+
const { rename } = await import('node:fs/promises');
|
|
76
|
+
await rename(tmp, path);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function tokenize(args) {
|
|
80
|
+
if (Array.isArray(args)) return args.filter((x) => x !== undefined && x !== null);
|
|
81
|
+
const s = String(args || '').trim();
|
|
82
|
+
if (!s) return [];
|
|
83
|
+
// Simple whitespace split for CLI surface.
|
|
84
|
+
return s.split(/\s+/);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ---------------------------------------------------------------------------
|
|
88
|
+
// Handlers
|
|
89
|
+
// ---------------------------------------------------------------------------
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* registry-list — print all configured registries in priority order.
|
|
93
|
+
*/
|
|
94
|
+
async function registryList(_args, _ctx) {
|
|
95
|
+
let sources;
|
|
96
|
+
try {
|
|
97
|
+
sources = await loadRegistrySources();
|
|
98
|
+
} catch (err) {
|
|
99
|
+
if (err instanceof RegistrySourcesError) {
|
|
100
|
+
return { ok: false, error: `registries.json invalid (${err.reason}): ${err.message}` };
|
|
101
|
+
}
|
|
102
|
+
throw err;
|
|
103
|
+
}
|
|
104
|
+
const lines = ['IJFW Registry Sources (priority order):', ''];
|
|
105
|
+
for (const src of sources) {
|
|
106
|
+
const usingEmbedded =
|
|
107
|
+
src.meta_key_pem === IJFW_REGISTRY_META_KEY_PEM ? ' [meta_key=<embedded>]' : '';
|
|
108
|
+
lines.push(` [${src.priority}] ${src.name}`);
|
|
109
|
+
lines.push(` url: ${src.url}`);
|
|
110
|
+
lines.push(
|
|
111
|
+
` ttl: publishers=${Math.round(src.publisher_ttl_ms / 1000)}s revocation=${Math.round(src.revocation_ttl_ms / 1000)}s${usingEmbedded}`,
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
return { ok: true, output: lines.join('\n') };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* registry-add <name> <url> [<meta-key-path>]
|
|
119
|
+
*
|
|
120
|
+
* Appends to ~/.ijfw/registries.json. Validates name/url/PEM. Refuses
|
|
121
|
+
* duplicate names. Persists in priority-append order.
|
|
122
|
+
*/
|
|
123
|
+
async function registryAdd(args, ctx) {
|
|
124
|
+
const tokens = tokenize(args);
|
|
125
|
+
if (tokens.length < 2) {
|
|
126
|
+
return { ok: false, error: 'usage: registry-add <name> <url> [<meta-key-path>]' };
|
|
127
|
+
}
|
|
128
|
+
const [name, url, metaKeyPath] = tokens;
|
|
129
|
+
|
|
130
|
+
if (!SOURCE_NAME_PATTERN.test(name)) {
|
|
131
|
+
return { ok: false, error: `name must match /^[a-z0-9_-]+$/, got '${name}'` };
|
|
132
|
+
}
|
|
133
|
+
let parsedUrl;
|
|
134
|
+
try {
|
|
135
|
+
parsedUrl = new URL(url);
|
|
136
|
+
} catch {
|
|
137
|
+
return { ok: false, error: `invalid URL: ${url}` };
|
|
138
|
+
}
|
|
139
|
+
if (parsedUrl.protocol !== 'https:') {
|
|
140
|
+
return { ok: false, error: `url must use HTTPS, got ${parsedUrl.protocol}` };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
let metaKeyPem = META_KEY_SENTINEL;
|
|
144
|
+
if (metaKeyPath) {
|
|
145
|
+
let raw;
|
|
146
|
+
try {
|
|
147
|
+
raw = await readFile(metaKeyPath, 'utf8');
|
|
148
|
+
} catch (err) {
|
|
149
|
+
return { ok: false, error: `cannot read meta-key at ${metaKeyPath}: ${err.message}` };
|
|
150
|
+
}
|
|
151
|
+
try {
|
|
152
|
+
createPublicKey(raw);
|
|
153
|
+
} catch (err) {
|
|
154
|
+
return { ok: false, error: `meta-key PEM parse failed: ${err.message}` };
|
|
155
|
+
}
|
|
156
|
+
metaKeyPem = raw;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const { doc } = await readRegistriesFile(ctx);
|
|
160
|
+
if (doc.registries.some((r) => r.name === name)) {
|
|
161
|
+
return { ok: false, error: `registry '${name}' already exists` };
|
|
162
|
+
}
|
|
163
|
+
const nextPriority =
|
|
164
|
+
doc.registries.reduce((max, r) => (typeof r.priority === 'number' ? Math.max(max, r.priority) : max), -1) + 1;
|
|
165
|
+
doc.registries.push({
|
|
166
|
+
name,
|
|
167
|
+
url,
|
|
168
|
+
meta_key_pem: metaKeyPem,
|
|
169
|
+
priority: nextPriority,
|
|
170
|
+
publisher_ttl_ms: 24 * 60 * 60 * 1000,
|
|
171
|
+
revocation_ttl_ms: 5 * 60 * 1000,
|
|
172
|
+
});
|
|
173
|
+
await writeRegistriesFile(ctx, doc);
|
|
174
|
+
return { ok: true, output: `added registry '${name}' at priority ${nextPriority}` };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* registry-remove <name>
|
|
179
|
+
*/
|
|
180
|
+
async function registryRemove(args, ctx) {
|
|
181
|
+
const tokens = tokenize(args);
|
|
182
|
+
if (tokens.length < 1) {
|
|
183
|
+
return { ok: false, error: 'usage: registry-remove <name>' };
|
|
184
|
+
}
|
|
185
|
+
const name = tokens[0];
|
|
186
|
+
const { doc } = await readRegistriesFile(ctx);
|
|
187
|
+
const idx = doc.registries.findIndex((r) => r.name === name);
|
|
188
|
+
if (idx === -1) {
|
|
189
|
+
return { ok: false, error: `registry '${name}' not found` };
|
|
190
|
+
}
|
|
191
|
+
doc.registries.splice(idx, 1);
|
|
192
|
+
await writeRegistriesFile(ctx, doc);
|
|
193
|
+
return { ok: true, output: `removed registry '${name}'` };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* registry-prioritize <name> <position>
|
|
198
|
+
*/
|
|
199
|
+
async function registryPrioritize(args, ctx) {
|
|
200
|
+
const tokens = tokenize(args);
|
|
201
|
+
if (tokens.length < 2) {
|
|
202
|
+
return { ok: false, error: 'usage: registry-prioritize <name> <position>' };
|
|
203
|
+
}
|
|
204
|
+
const [name, posRaw] = tokens;
|
|
205
|
+
const position = Number.parseInt(posRaw, 10);
|
|
206
|
+
if (!Number.isFinite(position)) {
|
|
207
|
+
return { ok: false, error: `position must be a non-negative integer, got '${posRaw}'` };
|
|
208
|
+
}
|
|
209
|
+
const { doc } = await readRegistriesFile(ctx);
|
|
210
|
+
const target = doc.registries.find((r) => r.name === name);
|
|
211
|
+
if (!target) {
|
|
212
|
+
return { ok: false, error: `registry '${name}' not found` };
|
|
213
|
+
}
|
|
214
|
+
target.priority = position;
|
|
215
|
+
doc.registries.sort((a, b) => (a.priority || 0) - (b.priority || 0));
|
|
216
|
+
// Renumber to ensure unique priorities.
|
|
217
|
+
doc.registries.forEach((r, i) => {
|
|
218
|
+
r.priority = i;
|
|
219
|
+
});
|
|
220
|
+
await writeRegistriesFile(ctx, doc);
|
|
221
|
+
return { ok: true, output: `set '${name}' to priority ${position} (renumbered)` };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* registry-status — per-source cache age + fetch state.
|
|
226
|
+
*/
|
|
227
|
+
async function registryStatus(_args, _ctx) {
|
|
228
|
+
let sources;
|
|
229
|
+
try {
|
|
230
|
+
sources = await loadRegistrySources();
|
|
231
|
+
} catch (err) {
|
|
232
|
+
if (err instanceof RegistrySourcesError) {
|
|
233
|
+
return { ok: false, error: `registries.json invalid (${err.reason}): ${err.message}` };
|
|
234
|
+
}
|
|
235
|
+
throw err;
|
|
236
|
+
}
|
|
237
|
+
const lines = ['IJFW Registry Status:', ''];
|
|
238
|
+
for (const src of sources) {
|
|
239
|
+
const { cache, corrupt, reason } = await readSourceCache(src);
|
|
240
|
+
lines.push(` [${src.priority}] ${src.name} (${src.url})`);
|
|
241
|
+
if (corrupt) {
|
|
242
|
+
lines.push(` CORRUPT (${reason}) — next refresh will rebuild`);
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
const pubAt = cache.publishers_fetched_at || '(never)';
|
|
246
|
+
const revAt = cache.revocation_fetched_at || '(never)';
|
|
247
|
+
const pubCount = cache.publishers ? Object.keys(cache.publishers).length : 0;
|
|
248
|
+
const revCount = Array.isArray(cache.revoked) ? cache.revoked.length : 0;
|
|
249
|
+
lines.push(` publishers_fetched_at: ${pubAt} (${pubCount} entries)`);
|
|
250
|
+
lines.push(` revocation_fetched_at: ${revAt} (${revCount} revoked)`);
|
|
251
|
+
}
|
|
252
|
+
return { ok: true, output: lines.join('\n') };
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* trust-registry --emergency [<url>] — bypass caches; force fresh fetch.
|
|
257
|
+
*
|
|
258
|
+
* Without --emergency, fall through to the regular split-TTL path.
|
|
259
|
+
*/
|
|
260
|
+
async function trustRegistry(args, _ctx) {
|
|
261
|
+
const tokens = tokenize(args);
|
|
262
|
+
const emergency = tokens.includes('--emergency');
|
|
263
|
+
const remaining = tokens.filter((t) => t !== '--emergency');
|
|
264
|
+
|
|
265
|
+
// Optional URL arg post-emergency. With a URL we target only that single
|
|
266
|
+
// source via loadRegistrySources()'s back-compat fall-back. Without, full
|
|
267
|
+
// federation refresh.
|
|
268
|
+
const url = remaining[0];
|
|
269
|
+
|
|
270
|
+
const opts = { emergency };
|
|
271
|
+
if (url) {
|
|
272
|
+
// Synthesize a one-shot source descriptor.
|
|
273
|
+
opts.sources = [
|
|
274
|
+
{
|
|
275
|
+
name: 'cli',
|
|
276
|
+
url,
|
|
277
|
+
meta_key_pem: IJFW_REGISTRY_META_KEY_PEM,
|
|
278
|
+
priority: 0,
|
|
279
|
+
publisher_ttl_ms: 24 * 60 * 60 * 1000,
|
|
280
|
+
revocation_ttl_ms: 5 * 60 * 1000,
|
|
281
|
+
},
|
|
282
|
+
];
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
let result;
|
|
286
|
+
try {
|
|
287
|
+
result = await refreshTrustFromAllRegistries(opts);
|
|
288
|
+
} catch (err) {
|
|
289
|
+
if (err instanceof RegistrySourcesError) {
|
|
290
|
+
return { ok: false, error: `registries.json invalid (${err.reason}): ${err.message}` };
|
|
291
|
+
}
|
|
292
|
+
return { ok: false, error: err.message };
|
|
293
|
+
}
|
|
294
|
+
if (!result.ok) {
|
|
295
|
+
return { ok: false, error: result.error || 'refresh failed' };
|
|
296
|
+
}
|
|
297
|
+
const summary = result.multi
|
|
298
|
+
? `sources=${result.multi.sources.length} global_revocations=${result.multi.global_revocations.length} conflicts=${result.multi.conflicts.length}`
|
|
299
|
+
: 'no diff';
|
|
300
|
+
const warnings = result.warnings && result.warnings.length > 0
|
|
301
|
+
? `\nwarnings:\n${result.warnings.map((w) => ` - ${w}`).join('\n')}`
|
|
302
|
+
: '';
|
|
303
|
+
return {
|
|
304
|
+
ok: true,
|
|
305
|
+
output: `trust-registry${emergency ? ' --emergency' : ''}: ${summary}${warnings}`,
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// ---------------------------------------------------------------------------
|
|
310
|
+
// Frozen exports (Wave-A invariant)
|
|
311
|
+
// ---------------------------------------------------------------------------
|
|
312
|
+
|
|
313
|
+
export const handlers = Object.freeze({
|
|
314
|
+
'registry-list': registryList,
|
|
315
|
+
'registry-add': registryAdd,
|
|
316
|
+
'registry-remove': registryRemove,
|
|
317
|
+
'registry-prioritize': registryPrioritize,
|
|
318
|
+
'registry-status': registryStatus,
|
|
319
|
+
'trust-registry': trustRegistry,
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
export const subcommandHelp = Object.freeze({
|
|
323
|
+
'registry-list': 'list configured registries in priority order',
|
|
324
|
+
'registry-add': 'add <name> <url> [<meta-key-path>] — append a registry source',
|
|
325
|
+
'registry-remove': 'remove <name> — remove a registry source by name',
|
|
326
|
+
'registry-prioritize': 'prioritize <name> <position> — change a source\'s priority',
|
|
327
|
+
'registry-status': 'show per-source cache state (publishers + revocation TTLs)',
|
|
328
|
+
'trust-registry': 'refresh trust from all sources; --emergency bypasses cache',
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
// Helper for tests
|
|
332
|
+
export const _testInternals = Object.freeze({
|
|
333
|
+
registriesConfigPath,
|
|
334
|
+
readRegistriesFile,
|
|
335
|
+
writeRegistriesFile,
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
// Avoid an "unused" lint hit on `stat` (kept for future use).
|
|
339
|
+
void stat;
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dispatch/signer-cli.js — IJFW v1.4.3 W9-A2 / B15
|
|
3
|
+
*
|
|
4
|
+
* CLI handlers for signing-key management. Exported as the frozen
|
|
5
|
+
* `{ handlers, subcommandHelp }` shape so the Phase D orchestrator can wire
|
|
6
|
+
* them into the top-level dispatch table without per-area editing.
|
|
7
|
+
*
|
|
8
|
+
* Subcommands:
|
|
9
|
+
*
|
|
10
|
+
* keygen <author> [--backend software|ssh-agent] [--ssh-key-comment <c>]
|
|
11
|
+
* - Default backend: software (existing v1.4.0 behavior — generates a
|
|
12
|
+
* fresh Ed25519 keypair on disk).
|
|
13
|
+
* - --backend ssh-agent: NO private-key generation in IJFW. Connects
|
|
14
|
+
* to the running SSH agent, enumerates Ed25519 identities, selects
|
|
15
|
+
* one to enroll. Writes only the public material:
|
|
16
|
+
* ~/.ijfw/keys/<keyId>/public.pem
|
|
17
|
+
* ~/.ijfw/keys/<keyId>/backend.json
|
|
18
|
+
* { backend, pubkey_blob_hex, keyId, ssh_key_comment }
|
|
19
|
+
* The comment is recorded for display only; sign-time identity
|
|
20
|
+
* selection matches on pubkey_blob_hex (SEC-H-03).
|
|
21
|
+
*
|
|
22
|
+
* keygen-fido2 <author>
|
|
23
|
+
* - Deferred stub. Prints a message routing users to ssh-agent or the
|
|
24
|
+
* default software backend. Exits 0 (deferred, not failed).
|
|
25
|
+
*
|
|
26
|
+
* Spec: .planning/1.4.3/HANDOFF-1.4.3.md §B15
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { mkdir, writeFile, chmod } from 'node:fs/promises';
|
|
30
|
+
import { homedir } from 'node:os';
|
|
31
|
+
import { join } from 'node:path';
|
|
32
|
+
|
|
33
|
+
import {
|
|
34
|
+
generatePublisherKeypair,
|
|
35
|
+
} from '../extension-signer.js';
|
|
36
|
+
import {
|
|
37
|
+
listAgentIdentities,
|
|
38
|
+
pubkeyBlobFromPem,
|
|
39
|
+
ed25519PemFromRaw,
|
|
40
|
+
publicKeyFingerprint,
|
|
41
|
+
_testInternals,
|
|
42
|
+
} from '../hardware-signer.js';
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Parse argv-style array (or whitespace-split string) into `{ positional, flags }`.
|
|
46
|
+
* Supports `--flag` (boolean) and `--flag value` (string value).
|
|
47
|
+
*
|
|
48
|
+
* @param {string|string[]} input
|
|
49
|
+
* @returns {{ positional: string[], flags: Record<string, string|boolean> }}
|
|
50
|
+
*/
|
|
51
|
+
function parseArgs(input) {
|
|
52
|
+
const tokens = Array.isArray(input)
|
|
53
|
+
? input.slice()
|
|
54
|
+
: String(input || '').trim().split(/\s+/).filter(Boolean);
|
|
55
|
+
const positional = [];
|
|
56
|
+
const flags = {};
|
|
57
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
58
|
+
const tok = tokens[i];
|
|
59
|
+
if (tok.startsWith('--')) {
|
|
60
|
+
const name = tok.slice(2);
|
|
61
|
+
const next = tokens[i + 1];
|
|
62
|
+
if (next !== undefined && !next.startsWith('--')) {
|
|
63
|
+
flags[name] = next;
|
|
64
|
+
i += 1;
|
|
65
|
+
} else {
|
|
66
|
+
flags[name] = true;
|
|
67
|
+
}
|
|
68
|
+
} else {
|
|
69
|
+
positional.push(tok);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return { positional, flags };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Extract the SSH wire ssh-ed25519 alg prefix used for filtering Ed25519
|
|
77
|
+
* identities returned by the agent.
|
|
78
|
+
*/
|
|
79
|
+
const ED25519_ALG_PREFIX = _testInternals.sshWireString(_testInternals.SSH_ED25519_ALG);
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Build the per-key directory path under the (possibly overridden) home.
|
|
83
|
+
*/
|
|
84
|
+
function keysDir(home, keyId) {
|
|
85
|
+
return join(home, '.ijfw', 'keys', keyId);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Convert an SSH-wire Ed25519 pubkey blob back into PEM. Useful when
|
|
90
|
+
* enrolling — the agent gives us the wire blob, but downstream verify
|
|
91
|
+
* paths want PEM.
|
|
92
|
+
*
|
|
93
|
+
* @param {Buffer} blob
|
|
94
|
+
* @returns {string} PEM
|
|
95
|
+
*/
|
|
96
|
+
function ed25519PemFromBlob(blob) {
|
|
97
|
+
// Blob shape: ssh-string("ssh-ed25519") || ssh-string(raw32).
|
|
98
|
+
// Skip the alg prefix; the trailing string is the raw key.
|
|
99
|
+
const algLen = ED25519_ALG_PREFIX.length;
|
|
100
|
+
// The raw key follows; the leading 4 bytes are the length (always 32).
|
|
101
|
+
const raw = blob.slice(algLen + 4);
|
|
102
|
+
if (raw.length !== 32) {
|
|
103
|
+
throw new Error(`Expected 32-byte Ed25519 raw key, got ${raw.length}`);
|
|
104
|
+
}
|
|
105
|
+
return ed25519PemFromRaw(raw);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Filter agent identities to Ed25519 only.
|
|
110
|
+
*
|
|
111
|
+
* @param {Array<{blob: Buffer, comment: string}>} identities
|
|
112
|
+
* @returns {Array<{blob: Buffer, comment: string}>}
|
|
113
|
+
*/
|
|
114
|
+
function ed25519Only(identities) {
|
|
115
|
+
return identities.filter(
|
|
116
|
+
i => i.blob.length >= ED25519_ALG_PREFIX.length
|
|
117
|
+
&& i.blob.slice(0, ED25519_ALG_PREFIX.length).equals(ED25519_ALG_PREFIX),
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Enrol an existing SSH-agent identity as an IJFW publisher key.
|
|
123
|
+
*
|
|
124
|
+
* Workflow:
|
|
125
|
+
* 1. Connect to SSH_AUTH_SOCK (errors clearly if unavailable).
|
|
126
|
+
* 2. List identities; filter to Ed25519.
|
|
127
|
+
* 3. Resolve a single candidate. Selection precedence:
|
|
128
|
+
* a. If --ssh-key-comment is provided, prefer that comment.
|
|
129
|
+
* b. Otherwise: exactly 1 Ed25519 identity → auto-pick. Multiple
|
|
130
|
+
* identities → fail with usage hint (interactive picker not yet
|
|
131
|
+
* wired into MCP transport).
|
|
132
|
+
* 4. Compute keyId = sha256(SPKI-DER of the agent-key's PEM).
|
|
133
|
+
* 5. Write public.pem + backend.json to ~/.ijfw/keys/<keyId>/.
|
|
134
|
+
*
|
|
135
|
+
* @param {object} args
|
|
136
|
+
* @param {string} args.author informational author label
|
|
137
|
+
* @param {string} [args.sshKeyComment] disambiguator
|
|
138
|
+
* @param {string} [args.home] override ~/.ijfw root (test isolation)
|
|
139
|
+
* @param {string} [args.socketPath] override SSH_AUTH_SOCK (test isolation)
|
|
140
|
+
* @returns {Promise<{ ok: true, keyId: string, dir: string, ssh_key_comment: string, backend: 'ssh-agent' } | { ok: false, error: string }>}
|
|
141
|
+
*/
|
|
142
|
+
async function enrolSshAgentKey(args) {
|
|
143
|
+
const home = args.home || homedir();
|
|
144
|
+
let identities;
|
|
145
|
+
try {
|
|
146
|
+
identities = await listAgentIdentities(args.socketPath);
|
|
147
|
+
} catch (err) {
|
|
148
|
+
return { ok: false, error: err.message };
|
|
149
|
+
}
|
|
150
|
+
const candidates = ed25519Only(identities);
|
|
151
|
+
if (candidates.length === 0) {
|
|
152
|
+
return {
|
|
153
|
+
ok: false,
|
|
154
|
+
error: 'SSH agent has no Ed25519 identities; add one with `ssh-keygen -t ed25519` and `ssh-add`',
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
let chosen;
|
|
158
|
+
if (args.sshKeyComment) {
|
|
159
|
+
const matches = candidates.filter(c => c.comment === args.sshKeyComment);
|
|
160
|
+
if (matches.length === 0) {
|
|
161
|
+
return {
|
|
162
|
+
ok: false,
|
|
163
|
+
error: `SSH agent has no Ed25519 identity with comment ${JSON.stringify(args.sshKeyComment)}`,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
if (matches.length > 1) {
|
|
167
|
+
return {
|
|
168
|
+
ok: false,
|
|
169
|
+
error: `Multiple SSH agent identities share comment ${JSON.stringify(args.sshKeyComment)}; comments are not unique — disambiguate by re-running ssh-add or removing duplicates`,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
chosen = matches[0];
|
|
173
|
+
} else if (candidates.length === 1) {
|
|
174
|
+
chosen = candidates[0];
|
|
175
|
+
} else {
|
|
176
|
+
return {
|
|
177
|
+
ok: false,
|
|
178
|
+
error: `Multiple Ed25519 identities in SSH agent (${candidates.length}); pass --ssh-key-comment <c> to disambiguate. Comments seen: ${candidates.map(c => JSON.stringify(c.comment)).join(', ')}`,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
let pem;
|
|
183
|
+
try {
|
|
184
|
+
pem = ed25519PemFromBlob(chosen.blob);
|
|
185
|
+
} catch (err) {
|
|
186
|
+
return { ok: false, error: `failed to convert agent blob to PEM: ${err.message}` };
|
|
187
|
+
}
|
|
188
|
+
const keyId = publicKeyFingerprint(pem);
|
|
189
|
+
// Belt-and-braces self-consistency check: the blob we just stored should
|
|
190
|
+
// round-trip back through PEM and yield the same blob.
|
|
191
|
+
const roundTripBlob = pubkeyBlobFromPem(pem);
|
|
192
|
+
if (!roundTripBlob.equals(chosen.blob)) {
|
|
193
|
+
return {
|
|
194
|
+
ok: false,
|
|
195
|
+
error: 'internal: pubkey blob round-trip via PEM differed; refusing to enrol',
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const dir = keysDir(home, keyId);
|
|
200
|
+
await mkdir(dir, { recursive: true, mode: 0o700 });
|
|
201
|
+
try { await chmod(dir, 0o700); } catch { /* best-effort */ }
|
|
202
|
+
await writeFile(join(dir, 'public.pem'), pem, 'utf8');
|
|
203
|
+
try { await chmod(join(dir, 'public.pem'), 0o644); } catch { /* best-effort */ }
|
|
204
|
+
const backendJson = {
|
|
205
|
+
backend: 'ssh-agent',
|
|
206
|
+
pubkey_blob_hex: chosen.blob.toString('hex'),
|
|
207
|
+
keyId,
|
|
208
|
+
ssh_key_comment: chosen.comment, // display only — never used for matching
|
|
209
|
+
};
|
|
210
|
+
await writeFile(
|
|
211
|
+
join(dir, 'backend.json'),
|
|
212
|
+
JSON.stringify(backendJson, null, 2) + '\n',
|
|
213
|
+
'utf8',
|
|
214
|
+
);
|
|
215
|
+
if (typeof args.author === 'string' && args.author.length > 0) {
|
|
216
|
+
try {
|
|
217
|
+
await writeFile(
|
|
218
|
+
join(dir, 'author.txt'),
|
|
219
|
+
`${args.author}\n${new Date().toISOString()}\n`,
|
|
220
|
+
'utf8',
|
|
221
|
+
);
|
|
222
|
+
} catch { /* non-fatal */ }
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
return {
|
|
226
|
+
ok: true,
|
|
227
|
+
keyId,
|
|
228
|
+
dir,
|
|
229
|
+
ssh_key_comment: chosen.comment,
|
|
230
|
+
backend: 'ssh-agent',
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* keygen handler. Dispatches to software (default) or ssh-agent backend
|
|
236
|
+
* per --backend.
|
|
237
|
+
*
|
|
238
|
+
* @param {string|string[]} args
|
|
239
|
+
* @param {object} [ctx]
|
|
240
|
+
* @returns {Promise<object>}
|
|
241
|
+
*/
|
|
242
|
+
async function keygenHandler(args, ctx = {}) {
|
|
243
|
+
const { positional, flags } = parseArgs(args);
|
|
244
|
+
const author = positional[0] || '';
|
|
245
|
+
const backend = flags.backend === true ? undefined : flags.backend;
|
|
246
|
+
|
|
247
|
+
if (backend === undefined || backend === 'software') {
|
|
248
|
+
const kp = await generatePublisherKeypair(author);
|
|
249
|
+
return {
|
|
250
|
+
ok: true,
|
|
251
|
+
backend: 'software',
|
|
252
|
+
keyId: kp.keyId,
|
|
253
|
+
dir: kp.dir,
|
|
254
|
+
publicKey: kp.publicKey,
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
if (backend === 'ssh-agent') {
|
|
259
|
+
const sshKeyComment = flags['ssh-key-comment'] === true
|
|
260
|
+
? undefined
|
|
261
|
+
: flags['ssh-key-comment'];
|
|
262
|
+
return enrolSshAgentKey({
|
|
263
|
+
author,
|
|
264
|
+
sshKeyComment,
|
|
265
|
+
home: ctx.home,
|
|
266
|
+
socketPath: ctx.socketPath,
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Fail-closed per SEC-L-02. Unknown backend names must not silently fall
|
|
271
|
+
// through to software.
|
|
272
|
+
return {
|
|
273
|
+
ok: false,
|
|
274
|
+
error: `Unsupported --backend value ${JSON.stringify(backend)}; valid: software, ssh-agent`,
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* keygen-fido2 handler — deferred stub.
|
|
280
|
+
*
|
|
281
|
+
* Native libfido2 bindings would be IJFW's first native prod dep; that's
|
|
282
|
+
* a v1.5.0+ architecture decision. For v1.4.3, FIDO2-backed signing is
|
|
283
|
+
* available transitively via ssh-agent (modern YubiKey/Solokey speak
|
|
284
|
+
* SSH agent natively).
|
|
285
|
+
*
|
|
286
|
+
* @returns {Promise<{ ok: true, deferred: true, message: string }>}
|
|
287
|
+
*/
|
|
288
|
+
async function keygenFido2Handler(_args, ctx = {}) {
|
|
289
|
+
const msg = 'FIDO2/libfido2 path deferred to v1.5.0; use --backend ssh-agent or default software backend';
|
|
290
|
+
// Write to stderr for CLI visibility without disturbing JSON-stdout
|
|
291
|
+
// consumers. Optionally inject a writer via ctx for tests.
|
|
292
|
+
const stderr = ctx.stderr || process.stderr;
|
|
293
|
+
try { stderr.write(`${msg}\n`); } catch { /* ignore */ }
|
|
294
|
+
return { ok: true, deferred: true, message: msg };
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
export const handlers = Object.freeze({
|
|
298
|
+
keygen: keygenHandler,
|
|
299
|
+
'keygen-fido2': keygenFido2Handler,
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
export const subcommandHelp = Object.freeze({
|
|
303
|
+
keygen: 'keygen <author> [--backend software|ssh-agent] [--ssh-key-comment <c>] — generate or enrol a publisher signing key',
|
|
304
|
+
'keygen-fido2': 'keygen-fido2 <author> — deferred to v1.5.0; use --backend ssh-agent instead',
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
// Test-only exports.
|
|
308
|
+
export const _testOnly = Object.freeze({
|
|
309
|
+
parseArgs,
|
|
310
|
+
enrolSshAgentKey,
|
|
311
|
+
});
|