@goodandready/dsh-key-rotation 0.8.78 → 0.8.79

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.
@@ -0,0 +1,456 @@
1
+ // lib/ops-config-io.js — full configuration export and import (#504).
2
+ import crypto from 'node:crypto';
3
+ import { readFileSync } from 'node:fs';
4
+
5
+ let PLUGIN_VERSION = '0.8.68';
6
+ try {
7
+ const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
8
+ if (pkg.version) PLUGIN_VERSION = pkg.version;
9
+ } catch (_) { /* fallback */ }
10
+ import { json, readJson, isTrustedBridgeRequest, NS } from '../http-bridge.js';
11
+ import { CONFIG_EXPORT_PATH, CONFIG_IMPORT_PATH, IMPORT_PATH } from './paths.js';
12
+ import { keyTail } from '../pool.js';
13
+ import { plainConfig } from '../index.js';
14
+
15
+ export const MAX_PBKDF2_ITERATIONS = 200000;
16
+ export const MIN_PBKDF2_ITERATIONS = 10000;
17
+ export const DEFAULT_PBKDF2_ITERATIONS = 100000;
18
+
19
+ export function deriveKeyAsync(passphrase, salt, iterations) {
20
+ return new Promise((resolve, reject) => {
21
+ crypto.pbkdf2(passphrase, salt, iterations, 32, 'sha256', (err, derivedKey) => {
22
+ if (err) reject(err);
23
+ else resolve(derivedKey);
24
+ });
25
+ });
26
+ }
27
+
28
+ function validateAndExtractCryptoParams(payload) {
29
+ if (!payload || typeof payload !== 'object') {
30
+ throw new Error('Invalid encrypted config payload structure');
31
+ }
32
+ if (!payload.salt || !payload.iv || !payload.tag || !payload.ciphertext) {
33
+ throw new Error('Missing salt, iv, tag or ciphertext in payload');
34
+ }
35
+ const iters = Number(payload.iterations) || DEFAULT_PBKDF2_ITERATIONS;
36
+ if (!Number.isFinite(iters) || iters < MIN_PBKDF2_ITERATIONS || iters > MAX_PBKDF2_ITERATIONS) {
37
+ throw new Error(`PBKDF2 iterations must be between ${MIN_PBKDF2_ITERATIONS} and ${MAX_PBKDF2_ITERATIONS}`);
38
+ }
39
+ const salt = Buffer.from(payload.salt, 'hex');
40
+ const iv = Buffer.from(payload.iv, 'hex');
41
+ const tag = Buffer.from(payload.tag, 'hex');
42
+ const ciphertext = Buffer.from(payload.ciphertext, 'hex');
43
+ if (salt.length !== 16 || iv.length !== 12 || tag.length !== 16) {
44
+ throw new Error('Malformed salt, iv or auth tag length in encrypted payload');
45
+ }
46
+ return { iters, salt, iv, tag, ciphertext };
47
+ }
48
+
49
+ export function encryptConfig(plainText, passphrase) {
50
+ const salt = crypto.randomBytes(16);
51
+ const key = crypto.pbkdf2Sync(passphrase, salt, DEFAULT_PBKDF2_ITERATIONS, 32, 'sha256');
52
+ const iv = crypto.randomBytes(12);
53
+ const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
54
+ const encrypted = Buffer.concat([cipher.update(plainText, 'utf8'), cipher.final()]);
55
+ const tag = cipher.getAuthTag();
56
+ return {
57
+ encrypted: true,
58
+ algorithm: 'aes-256-gcm',
59
+ kdf: 'pbkdf2',
60
+ iterations: DEFAULT_PBKDF2_ITERATIONS,
61
+ salt: salt.toString('hex'),
62
+ iv: iv.toString('hex'),
63
+ tag: tag.toString('hex'),
64
+ ciphertext: encrypted.toString('hex'),
65
+ };
66
+ }
67
+
68
+ export function decryptConfig(payload, passphrase) {
69
+ const { iters, salt, iv, tag, ciphertext } = validateAndExtractCryptoParams(payload);
70
+ const key = crypto.pbkdf2Sync(passphrase, salt, iters, 32, 'sha256');
71
+ const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);
72
+ decipher.setAuthTag(tag);
73
+ const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
74
+ return decrypted.toString('utf8');
75
+ }
76
+
77
+ export async function decryptConfigAsync(payload, passphrase) {
78
+ const { iters, salt, iv, tag, ciphertext } = validateAndExtractCryptoParams(payload);
79
+ const key = await deriveKeyAsync(passphrase, salt, iters);
80
+ const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);
81
+ decipher.setAuthTag(tag);
82
+ const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
83
+ return decrypted.toString('utf8');
84
+ }
85
+
86
+ export function maskConfig(config) {
87
+ if (!config || typeof config !== 'object') return config;
88
+ const clone = JSON.parse(JSON.stringify(config));
89
+ if (Array.isArray(clone.providers)) {
90
+ for (const prov of clone.providers) {
91
+ if (Array.isArray(prov.keys)) {
92
+ prov.keys = prov.keys.map((k) => {
93
+ if (typeof k === 'string') return k.length > 8 ? `sk-...${k.slice(-4)}` : '***';
94
+ if (k && typeof k === 'object') {
95
+ return {
96
+ ...k,
97
+ value: typeof k.value === 'string' ? (k.value.length > 8 ? `sk-...${k.value.slice(-4)}` : '***') : '***',
98
+ };
99
+ }
100
+ return '***';
101
+ });
102
+ }
103
+ }
104
+ }
105
+ function maskWebhookString(val) {
106
+ if (!val || typeof val !== 'string') return val;
107
+ try {
108
+ const u = new URL(val);
109
+ if (u.username) u.username = '***';
110
+ if (u.password) u.password = '***';
111
+ if (u.search) u.search = '?token=***';
112
+ const parts = u.pathname.split('/');
113
+ if (parts.length > 2) {
114
+ parts[parts.length - 1] = '***';
115
+ u.pathname = parts.join('/');
116
+ }
117
+ return u.toString();
118
+ } catch (_) {
119
+ return '***';
120
+ }
121
+ }
122
+
123
+ if (clone.webhookUrl) {
124
+ clone.webhookUrl = maskWebhookString(clone.webhookUrl);
125
+ }
126
+ if (clone.notifyWebhook) {
127
+ clone.notifyWebhook = maskWebhookString(clone.notifyWebhook);
128
+ }
129
+ if (clone.webhookActionToken) clone.webhookActionToken = '***';
130
+ return clone;
131
+ }
132
+
133
+ export function diffConfigs(currentConfig, incomingConfig, mode = 'merge') {
134
+ const currentProviders = Array.isArray(currentConfig?.providers) ? currentConfig.providers : [];
135
+ const incomingProviders = Array.isArray(incomingConfig?.providers) ? incomingConfig.providers : [];
136
+
137
+ const currentMap = new Map();
138
+ for (const p of currentProviders) {
139
+ const name = p.provider || p.name || p.id;
140
+ if (name) currentMap.set(name, p);
141
+ }
142
+
143
+ const createdProviders = [];
144
+ const updatedProviders = [];
145
+ let keysAdded = 0;
146
+ let keysUpdated = 0;
147
+
148
+ for (const p of incomingProviders) {
149
+ const name = p.provider || p.name || p.id;
150
+ if (!name) continue;
151
+ const inKeys = Array.isArray(p.keys) ? p.keys : [];
152
+ if (!currentMap.has(name)) {
153
+ createdProviders.push(name);
154
+ keysAdded += inKeys.length;
155
+ } else {
156
+ updatedProviders.push(name);
157
+ const curP = currentMap.get(name);
158
+ const curKeys = new Set(Array.isArray(curP.keys) ? curP.keys.map((k) => (typeof k === 'string' ? k : k?.value)) : []);
159
+ for (const k of inKeys) {
160
+ const val = typeof k === 'string' ? k : k?.value;
161
+ if (!curKeys.has(val)) keysAdded++;
162
+ else keysUpdated++;
163
+ }
164
+ }
165
+ }
166
+
167
+ const removedProviders = [];
168
+ if (mode === 'replace') {
169
+ const incomingNames = new Set(incomingProviders.map((p) => p.provider || p.name || p.id).filter(Boolean));
170
+ for (const [name] of currentMap.entries()) {
171
+ if (!incomingNames.has(name)) removedProviders.push(name);
172
+ }
173
+ }
174
+
175
+ const totalKeys = incomingProviders.reduce((acc, p) => acc + (Array.isArray(p.keys) ? p.keys.length : 0), 0);
176
+
177
+ return {
178
+ mode,
179
+ createdProviders,
180
+ updatedProviders,
181
+ removedProviders,
182
+ keysAdded,
183
+ keysUpdated,
184
+ totalKeys,
185
+ };
186
+ }
187
+
188
+ export function registerConfigIoRoutes(ctx, deps) {
189
+ // ── GET & POST /dsh-key-rotation/export (#552: support POST for safe passphrase transmission) ──
190
+ ctx.effect(() => ctx.webServer.register({
191
+ kind: 'exact',
192
+ path: CONFIG_EXPORT_PATH,
193
+ handler: async (req, res) => {
194
+ if (req.method !== 'GET' && req.method !== 'POST') {
195
+ json(res, 405, { error: { code: 'method', message: 'GET or POST only' } });
196
+ return;
197
+ }
198
+ if (!isTrustedBridgeRequest(req)) {
199
+ json(res, 403, { error: { code: 'forbidden', message: 'dsh-key-rotation: export is local-only' } });
200
+ return;
201
+ }
202
+
203
+ let masked = true;
204
+ let passphrase = null;
205
+
206
+ if (req.method === 'POST') {
207
+ let body = {};
208
+ try {
209
+ body = await readJson(req);
210
+ } catch (_) {
211
+ body = {};
212
+ }
213
+ masked = body.masked !== false;
214
+ passphrase = typeof body.passphrase === 'string' && body.passphrase.trim() ? body.passphrase.trim() : null;
215
+ } else {
216
+ const urlObj = new URL(req.url, 'http://127.0.0.1');
217
+ const maskedParam = urlObj.searchParams.get('masked');
218
+ masked = maskedParam === null ? true : maskedParam !== 'false';
219
+ if (urlObj.searchParams.has('passphrase')) {
220
+ passphrase = urlObj.searchParams.get('passphrase') || null;
221
+ try {
222
+ res.setHeader?.('X-Deprecated-Security-Warning', 'Passphrase in query string is deprecated and will be removed; use POST /dsh-key-rotation/export with body');
223
+ } catch (_) {}
224
+ }
225
+ }
226
+
227
+ const settings = typeof ctx?.get === 'function' ? ctx.get('settings') : (ctx?.settings || null);
228
+ let currentConfig = {};
229
+ try {
230
+ if (settings && typeof settings.get === 'function') {
231
+ currentConfig = settings.get(NS) || {};
232
+ } else if (settings && typeof settings.describe === 'function') {
233
+ const desc = settings.describe({ redactSecrets: false })?.find((c) => c.ns === NS);
234
+ currentConfig = desc?.value || {};
235
+ } else {
236
+ currentConfig = deps.buildRuntime().cfg0 || {};
237
+ }
238
+ } catch (_) {
239
+ currentConfig = deps.buildRuntime().cfg0 || {};
240
+ }
241
+
242
+ const rawConfig = plainConfig(currentConfig);
243
+ const exportData = masked ? maskConfig(rawConfig) : rawConfig;
244
+
245
+ if (passphrase) {
246
+ try {
247
+ const encryptedPayload = await encryptConfig(JSON.stringify(exportData), passphrase);
248
+ json(res, 200, {
249
+ ok: true,
250
+ version: PLUGIN_VERSION,
251
+ exportedAt: new Date().toISOString(),
252
+ masked,
253
+ encrypted: true,
254
+ ...encryptedPayload,
255
+ });
256
+ return;
257
+ } catch (err) {
258
+ json(res, 500, { error: { code: 'encryption-failed', message: err.message } });
259
+ return;
260
+ }
261
+ }
262
+
263
+ json(res, 200, {
264
+ ok: true,
265
+ version: PLUGIN_VERSION,
266
+ exportedAt: new Date().toISOString(),
267
+ masked,
268
+ encrypted: false,
269
+ config: exportData,
270
+ });
271
+ },
272
+ }));
273
+
274
+ // ── POST /dsh-key-rotation/config-import ──
275
+ const handleImport = async (req, res) => {
276
+ if (req.method !== 'POST') {
277
+ json(res, 405, { error: { code: 'method', message: 'POST only' } });
278
+ return;
279
+ }
280
+ if (!isTrustedBridgeRequest(req)) {
281
+ json(res, 403, { error: { code: 'forbidden', message: 'dsh-key-rotation: import is local-only' } });
282
+ return;
283
+ }
284
+
285
+ let body;
286
+ try {
287
+ body = await readJson(req);
288
+ } catch (e) {
289
+ json(res, 400, { error: { code: 'bad-request', message: String(e?.message ?? e) } });
290
+ return;
291
+ }
292
+
293
+ let incomingConfig = null;
294
+ const passphrase = body.passphrase || null;
295
+
296
+ if (body.encrypted && body.ciphertext) {
297
+ if (!passphrase) {
298
+ json(res, 400, { error: { code: 'passphrase-required', message: 'Passphrase is required for encrypted configuration' } });
299
+ return;
300
+ }
301
+ try {
302
+ const decryptedStr = await decryptConfigAsync(body, passphrase);
303
+ incomingConfig = JSON.parse(decryptedStr);
304
+ } catch (err) {
305
+ json(res, 400, { error: { code: 'decryption-failed', message: `Decryption failed: ${err.message}` } });
306
+ return;
307
+ }
308
+ } else if (body.config) {
309
+ incomingConfig = body.config;
310
+ } else if (body.providers) {
311
+ incomingConfig = body;
312
+ } else {
313
+ json(res, 400, { error: { code: 'invalid-payload', message: 'Missing config or providers in request' } });
314
+ return;
315
+ }
316
+
317
+ if (!incomingConfig || typeof incomingConfig !== 'object') {
318
+ json(res, 400, { error: { code: 'invalid-config', message: 'Imported configuration must be an object' } });
319
+ return;
320
+ }
321
+
322
+ const mode = body.mode === 'replace' ? 'replace' : 'merge';
323
+ const dryRun = Boolean(body.dryRun);
324
+
325
+ const settings = typeof ctx?.get === 'function' ? ctx.get('settings') : (ctx?.settings || null);
326
+ if (!settings || typeof settings.replace !== 'function') {
327
+ // Fail closed when settings service is missing or lacks replace (#533)
328
+ json(res, 503, { error: { code: 'settings-unavailable', message: 'Settings service is not available or does not support writing configuration' } });
329
+ return;
330
+ }
331
+
332
+ let currentConfig = {};
333
+ let currentRevision = null;
334
+ try {
335
+ if (typeof settings.describe === 'function') {
336
+ const desc = settings.describe({ redactSecrets: false })?.find((c) => c.ns === NS);
337
+ if (desc) {
338
+ currentConfig = desc.value || {};
339
+ currentRevision = desc.revision ?? null;
340
+ }
341
+ }
342
+ if (!currentConfig || Object.keys(currentConfig).length === 0) {
343
+ if (typeof settings.get === 'function') {
344
+ currentConfig = settings.get(NS) || {};
345
+ }
346
+ }
347
+ } catch (_) {
348
+ currentConfig = deps.buildRuntime().cfg0 || {};
349
+ }
350
+ const rawCurrent = plainConfig(currentConfig);
351
+
352
+ // Concurrency control: validate expectedRevision format and match (#553)
353
+ if (body.expectedRevision !== undefined && body.expectedRevision !== null) {
354
+ if (!Number.isSafeInteger(body.expectedRevision) || body.expectedRevision < 0) {
355
+ json(res, 400, {
356
+ error: {
357
+ code: 'bad-request',
358
+ message: 'expectedRevision must be a non-negative integer',
359
+ },
360
+ });
361
+ return;
362
+ }
363
+ if (currentRevision !== null && body.expectedRevision !== currentRevision) {
364
+ json(res, 409, {
365
+ error: {
366
+ code: 'conflict',
367
+ message: `Configuration has been modified concurrently (expected revision ${body.expectedRevision}, current is ${currentRevision})`,
368
+ currentRevision,
369
+ },
370
+ });
371
+ return;
372
+ }
373
+ }
374
+
375
+ const preview = diffConfigs(rawCurrent, incomingConfig, mode);
376
+
377
+ if (dryRun) {
378
+ json(res, 200, {
379
+ ok: true,
380
+ dryRun: true,
381
+ revision: currentRevision,
382
+ preview,
383
+ });
384
+ return;
385
+ }
386
+
387
+ // Apply configuration
388
+ let mergedConfig;
389
+ if (mode === 'replace') {
390
+ mergedConfig = { ...rawCurrent, ...incomingConfig };
391
+ if (Array.isArray(incomingConfig.providers)) {
392
+ mergedConfig.providers = incomingConfig.providers;
393
+ }
394
+ } else {
395
+ // Merge mode
396
+ mergedConfig = { ...rawCurrent, ...incomingConfig };
397
+ const curProviders = Array.isArray(rawCurrent.providers) ? [...rawCurrent.providers] : [];
398
+ const curMap = new Map();
399
+ for (let i = 0; i < curProviders.length; i++) {
400
+ const name = curProviders[i].provider || curProviders[i].name || curProviders[i].id;
401
+ if (name) curMap.set(name, i);
402
+ }
403
+ const inProviders = Array.isArray(incomingConfig.providers) ? incomingConfig.providers : [];
404
+ for (const p of inProviders) {
405
+ const name = p.provider || p.name || p.id;
406
+ if (!name) continue;
407
+ if (curMap.has(name)) {
408
+ const idx = curMap.get(name);
409
+ curProviders[idx] = { ...curProviders[idx], ...p };
410
+ } else {
411
+ curProviders.push(p);
412
+ }
413
+ }
414
+ mergedConfig.providers = curProviders;
415
+ }
416
+
417
+ try {
418
+ await settings.replace(NS, mergedConfig, body.expectedRevision ?? currentRevision ?? undefined);
419
+ } catch (err) {
420
+ const isConflict = err?.code === 'SETTINGS_CONFLICT' ||
421
+ err?.status === 409 ||
422
+ err?.code === 'CONFLICT' ||
423
+ /conflict|revision|stale/i.test(err?.message || '');
424
+ if (isConflict) {
425
+ let latestRevision = null;
426
+ try {
427
+ latestRevision = settings.describe({ redactSecrets: false })?.find((c) => c.ns === NS)?.revision ?? null;
428
+ } catch (_) {}
429
+ json(res, 409, {
430
+ error: {
431
+ code: 'conflict',
432
+ message: `Configuration has been modified concurrently: ${err.message}`,
433
+ currentRevision: latestRevision ?? currentRevision,
434
+ },
435
+ });
436
+ return;
437
+ }
438
+ json(res, 500, { error: { code: 'settings-write-failed', message: err.message } });
439
+ return;
440
+ }
441
+
442
+ json(res, 200, {
443
+ ok: true,
444
+ applied: true,
445
+ mode,
446
+ revision: typeof currentRevision === 'number' ? currentRevision + 1 : undefined,
447
+ preview,
448
+ });
449
+ };
450
+
451
+ ctx.effect(() => ctx.webServer.register({
452
+ kind: 'exact',
453
+ path: CONFIG_IMPORT_PATH,
454
+ handler: handleImport,
455
+ }));
456
+ }
@@ -0,0 +1,13 @@
1
+ // lib/routes/guard.js — Route security guards and payload parsing (#522).
2
+
3
+ export {
4
+ isTrustedBridgeRequest,
5
+ hasNonLoopbackForwarding,
6
+ isDesktopShellRequest,
7
+ isLoopbackAddress,
8
+ } from '../pool.js';
9
+
10
+ export {
11
+ json,
12
+ readJson,
13
+ } from '../http-bridge.js';
@@ -0,0 +1,43 @@
1
+ // lib/routes/index.js — Unified HTTP route registration (#522).
2
+
3
+ import { registerStatusRoutes } from './status.js';
4
+ import { registerTelemetryRoutes } from './telemetry.js';
5
+ import { registerKeyRoutes } from './keys.js';
6
+ import { registerTestRoutes } from './testing.js';
7
+ import { registerWebhookActionRoute } from './webhook.js';
8
+ import { registerMetricsRoutes } from './metrics.js';
9
+ import { registerBalanceRoutes } from './balance.js';
10
+ import { registerConfigIoRoutes } from './config.js';
11
+ import { registerQuickActionRoutes } from './quick-action.js';
12
+
13
+ export {
14
+ registerStatusRoutes,
15
+ registerTelemetryRoutes,
16
+ registerKeyRoutes,
17
+ registerTestRoutes,
18
+ registerWebhookActionRoute,
19
+ registerMetricsRoutes,
20
+ registerBalanceRoutes,
21
+ registerConfigIoRoutes,
22
+ registerQuickActionRoutes,
23
+ };
24
+
25
+ export * from './paths.js';
26
+ export * from './guard.js';
27
+
28
+ /**
29
+ * Registers all operational HTTP routes for dsh-key-rotation in ctx.webServer.
30
+ * @param {object} ctx cordis context
31
+ * @param {object} deps live dependencies from apply()
32
+ */
33
+ export function registerOpsRoutes(ctx, deps) {
34
+ registerStatusRoutes(ctx, deps);
35
+ registerTelemetryRoutes(ctx, deps);
36
+ registerKeyRoutes(ctx, deps);
37
+ registerTestRoutes(ctx, deps);
38
+ registerWebhookActionRoute(ctx, deps);
39
+ registerMetricsRoutes(ctx, deps);
40
+ registerBalanceRoutes(ctx, deps);
41
+ registerConfigIoRoutes(ctx, deps);
42
+ registerQuickActionRoutes(ctx, deps);
43
+ }