@dotrino/vaultd 0.38.0 → 0.49.0
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 +48 -3
- package/lib/README.md +7 -0
- package/lib/src/admin.js +4 -0
- package/lib/src/atrest.js +0 -0
- package/lib/src/enroll.js +12 -3
- package/lib/src/env.js +2 -2
- package/lib/src/protocol.js +12 -1
- package/lib/src/service.js +355 -54
- package/lib/src/sshAgent.js +100 -0
- package/lib/src/sshKeys.js +76 -0
- package/package.json +6 -6
- package/src/approvals.js +69 -0
- package/src/ctl.js +323 -22
- package/src/daemon.js +174 -15
- package/src/manager.js +6 -0
- package/src/profiles.js +82 -9
- package/src/sealKey.js +80 -0
- package/src/sealer.js +170 -0
- package/src/secretsStore.js +805 -90
- package/src/sshAgent.js +2 -0
- package/src/sshKeys.js +2 -0
- package/src/store.js +3 -1
- package/src/tui/app.js +124 -10
- package/src/tui/i18n.js +33 -6
- package/src/vault.js +839 -35
- package/src/vaultControl.js +50 -14
package/lib/src/service.js
CHANGED
|
@@ -22,9 +22,11 @@ import fs from 'node:fs'
|
|
|
22
22
|
import path from 'node:path'
|
|
23
23
|
import { createHash } from 'node:crypto'
|
|
24
24
|
import {
|
|
25
|
-
makeDeviceKey, signWithDevice, verifyDelegation,
|
|
26
|
-
makePairingCode, commitCode, pubkeyId
|
|
25
|
+
makeDeviceKey, makeDeviceEncKey, importDeviceEncKey, signWithDevice, verifyDelegation,
|
|
26
|
+
verifyDeviceSig, makePairingCode, commitCode, pubkeyId
|
|
27
27
|
} from '@dotrino/identity/capabilities'
|
|
28
|
+
import { openWrap, wrapForMember, decryptWithCek } from '@dotrino/identity/content'
|
|
29
|
+
import { verifyActa, sealKeyAt } from '@dotrino/identity/acta'
|
|
28
30
|
import { MSG, secretsScope, isValidSecretsNs } from './protocol.js'
|
|
29
31
|
import { makeEphemeralKey, openSealed } from './sealed.js'
|
|
30
32
|
import { parseInvite } from './invite.js'
|
|
@@ -159,6 +161,9 @@ async function identifyAsService (client, device) {
|
|
|
159
161
|
await client.identify({ data, signature })
|
|
160
162
|
}
|
|
161
163
|
|
|
164
|
+
/** Cuánto espera un agente a que alguien apruebe (el pedido vive 5 min en la bóveda). */
|
|
165
|
+
export const APPROVAL_TIMEOUT_MS = 5 * 60 * 1000 + 10 * 1000
|
|
166
|
+
|
|
162
167
|
function waitForMsg (client, predicate, timeoutMs = 30000) {
|
|
163
168
|
return new Promise((resolve, reject) => {
|
|
164
169
|
const off = client.on('message', (_from, payload) => {
|
|
@@ -222,47 +227,37 @@ function writeServiceIdentity (dir, obj) {
|
|
|
222
227
|
* Se llama ANTES de enrolar si ya había una identidad: la que va a descartarse.
|
|
223
228
|
* @returns {Promise<{device, cert, iss:string, replaced:object|null}>}
|
|
224
229
|
*/
|
|
225
|
-
|
|
230
|
+
/**
|
|
231
|
+
* EL ENROLAMIENTO, a secas: con una invitación de la bóveda (en cualquiera de sus
|
|
232
|
+
* formas) crea las DOS llaves del aparato —firma y cifrado—, pide el cert y lo
|
|
233
|
+
* verifica. **No persiste nada**: devuelve la identidad y quien llama decide dónde
|
|
234
|
+
* vive (`enrollService` la guarda como servicio; `@dotrino/remote-agent` como
|
|
235
|
+
* `link.json`). Es el único sitio del ecosistema donde se enrola un agente headless:
|
|
236
|
+
* si falta algo al enrolar, se añade aquí y lo heredan todos.
|
|
237
|
+
*
|
|
238
|
+
* @param {Object} opts
|
|
239
|
+
* @param {object|string} opts.qr La invitación: objeto, URL del QR o código pegado.
|
|
240
|
+
* @param {string} [opts.label]
|
|
241
|
+
* @param {string|null} [opts.expectedScope] Scope que el cert DEBE traer (null = no se exige).
|
|
242
|
+
* @param {(c:{deviceId:string, code:string})=>void} [opts.onCode]
|
|
243
|
+
* @param {number} [opts.approveTimeoutMs]
|
|
244
|
+
* @returns {Promise<{device, enc:{publickey:string, privateJwk:object}, cert, iss:string, proxy:string}>}
|
|
245
|
+
*/
|
|
246
|
+
export async function enrollWithVault ({ qr, label = 'agent', expectedScope = null, onCode, approveTimeoutMs = 180000 } = {}) {
|
|
226
247
|
// `parseInvite` y NO `JSON.parse`: el vault no imprime JSON desde hace rato.
|
|
227
|
-
// `dotrino-vault pair
|
|
228
|
-
//
|
|
229
|
-
// «qr inválido: no es JSON» y el enrolamiento de un servicio era imposible por
|
|
230
|
-
// este camino. Lo tapaba que el único servicio enrolado del ecosistema lo hizo
|
|
231
|
-
// cuando el formato todavía era JSON. `parseInvite` acepta todas las formas,
|
|
232
|
-
// incluida la vieja, así que esto entiende cualquier invitación.
|
|
248
|
+
// `dotrino-vault pair` emite la URL del QR y el código compacto (`c…`/`t…`, ver
|
|
249
|
+
// invite.js); `parseInvite` acepta todas las formas, incluida la vieja.
|
|
233
250
|
if (typeof qr === 'string') {
|
|
234
251
|
const o = parseInvite(qr)
|
|
235
|
-
if (!o) throw new Error('that does not look like a vault invitation (paste the output of `dotrino-vault pair
|
|
252
|
+
if (!o) throw new Error('that does not look like a vault invitation (paste the output of `dotrino-vault pair`)')
|
|
236
253
|
qr = o
|
|
237
254
|
}
|
|
238
255
|
if (!qr?.sn || !(qr.iss || qr.conn)) throw new Error('invalid qr: missing the vault or the nonce')
|
|
239
256
|
rejectAdoption(qr.m)
|
|
240
|
-
if (!isValidSecretsNs(ns)) throw new Error('invalid ns (use [a-z0-9-]{1,32}, e.g. "proxy")')
|
|
241
|
-
if (!dir) throw new Error('dir required (where to persist the service identity)')
|
|
242
|
-
label = label || 'service:' + ns
|
|
243
|
-
|
|
244
|
-
// La identidad que va a quedar descartada. Se avisa antes de tocar nada: para
|
|
245
|
-
// el proxy, por ejemplo, esta llave es además su identidad de red, así que
|
|
246
|
-
// reemplazarla le cambia el id de nodo y sus peers dejan de reconocerlo hasta
|
|
247
|
-
// que se re-pineen a mano.
|
|
248
|
-
const previous = readServiceIdentity(dir)
|
|
249
|
-
let replaced = null
|
|
250
|
-
if (previous?.device?.publickey) {
|
|
251
|
-
replaced = {
|
|
252
|
-
ns: previous.ns,
|
|
253
|
-
enrolledAt: previous.enrolledAt,
|
|
254
|
-
deviceId: (await pubkeyId(previous.device.publickey)).slice(0, 8).toUpperCase()
|
|
255
|
-
}
|
|
256
|
-
try { onReplace?.(replaced) } catch (_) {}
|
|
257
|
-
}
|
|
258
257
|
|
|
259
258
|
const client = await freshClient(qr.proxy || 'wss://proxy.dotrino.com')
|
|
260
259
|
// QR CORTO: se le pregunta a la bóveda quién es, punto a punto, presentando el `sn`.
|
|
261
260
|
if (!qr.iss) {
|
|
262
|
-
// `qr.conn` es una CITA (código de 6 caracteres, un solo uso): hay que
|
|
263
|
-
// canjearla para saber a qué conexión apunta. El canje lo resuelve el proxio
|
|
264
|
-
// que la emitió —lo dice el prefijo del propio código—, así que funciona
|
|
265
|
-
// aunque la bóveda esté en otro proxio de la malla.
|
|
266
261
|
const target = await resolveAppointment(client, qr.conn)
|
|
267
262
|
const hello = await new Promise((resolve, reject) => {
|
|
268
263
|
const off = client.on('message', (_f, p) => {
|
|
@@ -277,25 +272,22 @@ export async function enrollService ({ qr, ns, dir, label, onCode, onReplace, ap
|
|
|
277
272
|
}
|
|
278
273
|
try {
|
|
279
274
|
const device = await makeDeviceKey({ label })
|
|
275
|
+
// La llave de CIFRADO: es a la que la bóveda sella cada variable. Sin ella el
|
|
276
|
+
// aparato entra al acta pero no le llega ningún secreto, y no da error.
|
|
277
|
+
const enc = await makeDeviceEncKey()
|
|
280
278
|
const deviceId = (await pubkeyId(device.publickey)).slice(0, 8).toUpperCase().replace(/(.{4})(.{4})/, '$1-$2')
|
|
281
|
-
// Código ALEATORIO
|
|
282
|
-
//
|
|
279
|
+
// Código de emparejamiento ALEATORIO: se muestra y NO se envía. La bóveda lo
|
|
280
|
+
// aprende solo cuando un humano lo tipea → aprobar exige TENER esta máquina.
|
|
283
281
|
const code = makePairingCode()
|
|
284
|
-
// El COMPROMISO del código (nunca el código): la bóveda lo recompone con lo que
|
|
285
|
-
// tipeas y solo entonces firma el cert → aprobar exige haber leído esta pantalla.
|
|
286
282
|
const commit = await commitCode({ code, dpub: device.publickey, sn: qr.sn })
|
|
287
|
-
|
|
288
|
-
// si falta, asume `join` — pero un agente no debe apoyarse en un default
|
|
289
|
-
// para algo que decide de quién es la cuenta. Yendo dentro de `data`, viaja
|
|
290
|
-
// firmado: nadie en el medio puede convertirlo en una adopción.
|
|
291
|
-
const data = { op: 'enroll', intent: 'join', dpub: device.publickey, token: qr.token || qr.sn, sn: qr.sn, commit, label, ts: Date.now() }
|
|
283
|
+
const data = { op: 'enroll', intent: 'join', dpub: device.publickey, encPub: enc.encPublickey, token: qr.token || qr.sn, sn: qr.sn, commit, label, ts: Date.now() }
|
|
292
284
|
const { signature } = await signWithDevice({ privateJwk: device.privateJwk, data })
|
|
293
285
|
|
|
294
286
|
const enrolled = new Promise((resolve, reject) => {
|
|
295
287
|
const off = client.on('message', (_from, p) => {
|
|
296
288
|
if (!p || typeof p !== 'object') return
|
|
297
289
|
if (p.type === MSG.ENROLL_CHALLENGE) {
|
|
298
|
-
const show = onCode || (({ deviceId, code }) => console.log(`[vault
|
|
290
|
+
const show = onCode || (({ deviceId, code }) => console.log(`[vault] device ${deviceId} · approve it on the vault: dotrino-vault approve ${code}`))
|
|
299
291
|
show({ deviceId, code })
|
|
300
292
|
} else if (p.type === MSG.ENROLLED) { cleanup(); resolve(p) } else if (p.type === MSG.ERROR) { cleanup(); reject(new Error(p.error)) }
|
|
301
293
|
})
|
|
@@ -305,17 +297,110 @@ export async function enrollService ({ qr, ns, dir, label, onCode, onReplace, ap
|
|
|
305
297
|
client.sendByPubkey(qr.iss, { type: MSG.ENROLL, data, signature })
|
|
306
298
|
const res = await enrolled
|
|
307
299
|
|
|
308
|
-
// Validación estricta (igual que un dispositivo): cert de la maestra VISTA,
|
|
309
|
-
// para ESTA llave, y el código echado debe ser el nuestro (anti vault falso).
|
|
310
300
|
if (res.code !== code) throw new Error('the vault echoed a code other than the one shown (possible malicious relay)')
|
|
311
|
-
const v = await verifyDelegation({ cert: res.cert, expectedSub: device.publickey, expectedScope:
|
|
301
|
+
const v = await verifyDelegation({ cert: res.cert, expectedSub: device.publickey, ...(expectedScope ? { expectedScope } : {}) })
|
|
312
302
|
if (!v.ok) throw new Error('invalid cert: ' + v.reason)
|
|
313
303
|
if (res.cert.iss !== qr.iss) throw new Error('cert signed by a master other than the one in the QR')
|
|
314
304
|
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
305
|
+
return { device, enc: { publickey: enc.encPublickey, privateJwk: enc.encPrivateJwk }, cert: res.cert, iss: qr.iss, proxy: qr.proxy || 'wss://proxy.dotrino.com' }
|
|
306
|
+
} finally { client.close() }
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Enrola ESTE servicio contra el vault y persiste su identidad.
|
|
311
|
+
*
|
|
312
|
+
* UN AGENTE TIENE UNA SOLA IDENTIDAD, Y SE LA DA EL VAULT. A diferencia de un
|
|
313
|
+
* aparato —que puede llevar varios perfiles y hasta meter su cuenta al vault por
|
|
314
|
+
* adopción—, un agente no acumula identidades ni transfiere la suya: se enrola,
|
|
315
|
+
* el vault le cede una (llave propia + cert de la maestra) y **la anterior, si
|
|
316
|
+
* había, se descarta**. No hay fusión ni convivencia, y no hace falta: un agente
|
|
317
|
+
* es un servicio, no una persona; no tiene por qué "ser varios".
|
|
318
|
+
*
|
|
319
|
+
* Enrolar dos veces, entonces, no es un error a bloquear sino un REEMPLAZO — que
|
|
320
|
+
* es además la forma de rotar la identidad de un agente comprometido. Lo que sí
|
|
321
|
+
* hace falta es que se vea: se avisa por `onReplace` qué identidad se tira.
|
|
322
|
+
*
|
|
323
|
+
* En el vault se corre antes `dotrino-vault pair --service <ns>`; la invitación
|
|
324
|
+
* que imprime ese comando es el `qr` de aquí (en cualquiera de sus formas).
|
|
325
|
+
* Muestra un código por `onCode`: el dueño lo tipea en el vault
|
|
326
|
+
* (`dotrino-vault approve <código>`).
|
|
327
|
+
*
|
|
328
|
+
* @param {Object} opts
|
|
329
|
+
* @param {object|string} opts.qr La invitación: objeto, URL del QR o código pegado.
|
|
330
|
+
* @param {string} opts.ns Namespace de secretos del servicio (el mismo del pair).
|
|
331
|
+
* @param {string} opts.dir Dónde persistir `service-identity.json`.
|
|
332
|
+
* @param {string} [opts.label]
|
|
333
|
+
* @param {(c:{deviceId:string, code:string})=>void} [opts.onCode]
|
|
334
|
+
* @param {(prev:{ns:string, enrolledAt:number, deviceId:string})=>void} [opts.onReplace]
|
|
335
|
+
* Se llama ANTES de enrolar si ya había una identidad: la que va a descartarse.
|
|
336
|
+
* @returns {Promise<{device, cert, iss:string, replaced:object|null}>}
|
|
337
|
+
*/
|
|
338
|
+
export async function enrollService ({ qr, ns, dir, label, onCode, onReplace, approveTimeoutMs = 180000 } = {}) {
|
|
339
|
+
if (!isValidSecretsNs(ns)) throw new Error('invalid ns (use [a-z0-9-]{1,32}, e.g. "proxy")')
|
|
340
|
+
if (!dir) throw new Error('dir required (where to persist the service identity)')
|
|
341
|
+
label = label || 'service:' + ns
|
|
342
|
+
|
|
343
|
+
// La identidad que va a quedar descartada. Se avisa antes de tocar nada: para
|
|
344
|
+
// el proxy, por ejemplo, esta llave es además su identidad de red, así que
|
|
345
|
+
// reemplazarla le cambia el id de nodo y sus peers dejan de reconocerlo hasta
|
|
346
|
+
// que se re-pineen a mano.
|
|
347
|
+
const previous = readServiceIdentity(dir)
|
|
348
|
+
let replaced = null
|
|
349
|
+
if (previous?.device?.publickey) {
|
|
350
|
+
replaced = {
|
|
351
|
+
ns: previous.ns,
|
|
352
|
+
enrolledAt: previous.enrolledAt,
|
|
353
|
+
deviceId: (await pubkeyId(previous.device.publickey)).slice(0, 8).toUpperCase()
|
|
354
|
+
}
|
|
355
|
+
try { onReplace?.(replaced) } catch (_) {}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
const { device, enc, cert, iss, proxy } = await enrollWithVault({ qr, label, expectedScope: secretsScope(ns), onCode, approveTimeoutMs })
|
|
359
|
+
// v2: suma `enc`. El `device` (la llave de FIRMA) no se toca — de él sale el
|
|
360
|
+
// id de nodo del proxio y la fila del acta.
|
|
361
|
+
writeServiceIdentity(dir, { v: 2, ns, iss, proxy, device, enc, cert, enrolledAt: Date.now() })
|
|
362
|
+
return { device, enc, cert, iss, replaced }
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
export async function ensureEncKey ({ dir } = {}) {
|
|
366
|
+
const saved = readServiceIdentity(dir)
|
|
367
|
+
if (!saved) throw new Error('service not enrolled: run enrollService() first')
|
|
368
|
+
if (saved.enc?.publickey && saved.enc?.privateJwk) return { encPub: saved.enc.publickey, created: false }
|
|
369
|
+
const enc = await makeDeviceEncKey()
|
|
370
|
+
writeServiceIdentity(dir, { ...saved, v: 2, enc: { publickey: enc.encPublickey, privateJwk: enc.encPrivateJwk } })
|
|
371
|
+
return { encPub: enc.encPublickey, created: true }
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Registra en la bóveda la llave de cifrado de ESTE servicio, para que pueda sellarle
|
|
376
|
+
* sus variables. Genera la llave si falta.
|
|
377
|
+
*
|
|
378
|
+
* Va por `MSG.SECRETS` con `op:'enckey'` a propósito: no hace falta una constante nueva
|
|
379
|
+
* del protocolo, y así el trío de archivos vendorizado en el iframe de identidad no se
|
|
380
|
+
* mueve. Registrar una llave no da acceso a nada por sí solo —quien firma esta petición
|
|
381
|
+
* ya tiene la llave de firma del servicio, o sea ya lee ese namespace—, así que no exige
|
|
382
|
+
* la contraseña del perfil.
|
|
383
|
+
*/
|
|
384
|
+
export async function registerEncKey ({ dir, ns, proxyUrl, masterPubkey, device, cert, timeoutMs = 30000 } = {}) {
|
|
385
|
+
const saved = readServiceIdentity(dir)
|
|
386
|
+
const { encPub, created } = await ensureEncKey({ dir })
|
|
387
|
+
ns = ns || saved?.ns
|
|
388
|
+
proxyUrl = proxyUrl || saved?.proxy
|
|
389
|
+
masterPubkey = masterPubkey || saved?.iss
|
|
390
|
+
device = device || saved?.device
|
|
391
|
+
cert = cert || saved?.cert
|
|
392
|
+
if (!proxyUrl || !masterPubkey || !device || !cert) throw new Error('service not enrolled')
|
|
393
|
+
|
|
394
|
+
const client = await freshClient(proxyUrl)
|
|
395
|
+
try {
|
|
396
|
+
await identifyAsService(client, device)
|
|
397
|
+
const data = { op: 'enckey', ns, encPub, publickey: device.publickey, ts: Date.now() }
|
|
398
|
+
const { signature } = await signWithDevice({ privateJwk: device.privateJwk, data })
|
|
399
|
+
const pending = waitForMsg(client, (p) => p.type === MSG.SECRETS_RESULT || p.type === MSG.ERROR, timeoutMs)
|
|
400
|
+
client.sendByPubkey(masterPubkey, { type: MSG.SECRETS, data, signature, cert })
|
|
401
|
+
const res = await pending
|
|
402
|
+
if (res.type === MSG.ERROR) throw new Error(res.error)
|
|
403
|
+
return { encPub, created, ok: true }
|
|
319
404
|
} finally { client.close() }
|
|
320
405
|
}
|
|
321
406
|
|
|
@@ -325,9 +410,13 @@ export async function enrollService ({ qr, ns, dir, label, onCode, onReplace, ap
|
|
|
325
410
|
* Renueva el cert automáticamente si está por vencer (best-effort).
|
|
326
411
|
* @returns {Promise<Record<string,string>>} secretos KEY→valor
|
|
327
412
|
*/
|
|
328
|
-
export async function fetchSecrets ({ dir, ns, proxyUrl, masterPubkey, device, cert, timeoutMs = 30000 } = {}) {
|
|
413
|
+
export async function fetchSecrets ({ dir, ns, proxyUrl, masterPubkey, device, cert, enc, timeoutMs = 30000, onPending, approvalTimeoutMs = APPROVAL_TIMEOUT_MS } = {}) {
|
|
329
414
|
let saved = null
|
|
330
415
|
if (dir) saved = readServiceIdentity(dir)
|
|
416
|
+
// Sin `dir`: la identidad viene entera por parámetros — es el caso de un agente
|
|
417
|
+
// enrolado por `@dotrino/remote-agent` (su `link.json` trae `device`, `cert` y `enc`).
|
|
418
|
+
// Un mismo enrolamiento sirve para el plano de control y para los secretos.
|
|
419
|
+
if (!saved && enc) saved = { ns, iss: masterPubkey, proxy: proxyUrl, device, cert, enc }
|
|
331
420
|
ns = ns || saved?.ns
|
|
332
421
|
proxyUrl = proxyUrl || saved?.proxy
|
|
333
422
|
masterPubkey = masterPubkey || saved?.iss
|
|
@@ -362,9 +451,22 @@ export async function fetchSecrets ({ dir, ns, proxyUrl, masterPubkey, device, c
|
|
|
362
451
|
const { signature } = await signWithDevice({ privateJwk: device.privateJwk, data })
|
|
363
452
|
const pending = waitForMsg(client, (p) => p.type === MSG.SECRETS_RESULT || p.type === MSG.ERROR, timeoutMs)
|
|
364
453
|
client.sendByPubkey(masterPubkey, { type: MSG.SECRETS, data, signature, cert })
|
|
365
|
-
|
|
454
|
+
let res = await pending
|
|
366
455
|
if (res.type === MSG.ERROR) throw new Error(res.error)
|
|
367
456
|
|
|
457
|
+
// CAJÓN CON APROBACIÓN: la bóveda contesta «pendiente» (firmado) y la respuesta de
|
|
458
|
+
// verdad llega cuando el aparato que aprueba firme — por esta misma conexión, que
|
|
459
|
+
// sigue identificada. Se espera lo que dura el pedido; denegado o vencido es error.
|
|
460
|
+
if (res.body?.op === 'secrets.pending' && res.body.ns === ns) {
|
|
461
|
+
const okPending = await verifyDeviceSig({ publickey: masterPubkey, data: res.body, signature: res.signature })
|
|
462
|
+
if (!okPending) throw new Error('invalid master signature on the pending reply')
|
|
463
|
+
try { onPending?.({ id: res.body.id, ns, exp: res.body.exp }) } catch (_) {}
|
|
464
|
+
const until = typeof res.body.exp === 'number' ? Math.max(5000, res.body.exp - Date.now() + 5000) : approvalTimeoutMs
|
|
465
|
+
res = await waitForMsg(client, (p) => (p.type === MSG.SECRETS_RESULT && p.body?.op === 'secrets.result') || p.type === MSG.ERROR, Math.min(until, approvalTimeoutMs))
|
|
466
|
+
.catch((e) => { throw new Error(/timeout/.test(e.message) ? 'approval: nobody approved the request in time' : e.message) })
|
|
467
|
+
if (res.type === MSG.ERROR) throw new Error(res.error)
|
|
468
|
+
}
|
|
469
|
+
|
|
368
470
|
// Autenticidad: el cuerpo viene firmado por la MAESTRA pineada.
|
|
369
471
|
const body = res.body
|
|
370
472
|
if (!body || body.op !== 'secrets.result' || body.ns !== ns) throw new Error('malformed secrets reply')
|
|
@@ -373,11 +475,158 @@ export async function fetchSecrets ({ dir, ns, proxyUrl, masterPubkey, device, c
|
|
|
373
475
|
if (!ok) throw new Error('invalid master signature on the secrets reply')
|
|
374
476
|
|
|
375
477
|
const payload = await openSealed({ privateKey: eph.privateKey, enc: body.enc })
|
|
478
|
+
|
|
479
|
+
// DOS CAPAS DE SOBRE, y hacen cosas distintas:
|
|
480
|
+
// · la de fuera (`ek` efímera, recién abierta) tapa el TRAMO — el proxio no ve
|
|
481
|
+
// ni los nombres de tus variables;
|
|
482
|
+
// · la de dentro (`sealed`) tapa el REPOSO — la bóveda guarda lo que reparte
|
|
483
|
+
// sin poder abrirlo.
|
|
484
|
+
// Se quedan las dos: quitar la de fuera dejaría los nombres al aire.
|
|
485
|
+
if (payload?.sealed) return openSealedBundle(payload.sealed, saved, payload.acta, masterPubkey)
|
|
486
|
+
|
|
487
|
+
// Bóveda todavía en v3: manda los valores tal cual, como siempre. Desaparece
|
|
488
|
+
// cuando el último vault haya migrado (ver `docs/secretos-sellados.md`).
|
|
376
489
|
if (!payload || typeof payload.secrets !== 'object') throw new Error('malformed secrets envelope')
|
|
377
490
|
return payload.secrets
|
|
378
491
|
} finally { client.close() }
|
|
379
492
|
}
|
|
380
493
|
|
|
494
|
+
/**
|
|
495
|
+
* AGENTE SSH DELGADO (`dotrino-env ssh-agent`): este proceso no custodia nada. Lista las
|
|
496
|
+
* llaves públicas que la bóveda tiene registradas y, por cada reto, le pide a la bóveda
|
|
497
|
+
* que lo convierta en un PEDIDO que el teléfono firma. Cualquier aparato con `vault:sign`
|
|
498
|
+
* puede pedir; quien decide es el teléfono.
|
|
499
|
+
*/
|
|
500
|
+
function serviceArgs ({ dir, ns, proxyUrl, masterPubkey, device, cert }) {
|
|
501
|
+
let saved = dir ? readServiceIdentity(dir) : null
|
|
502
|
+
if (!saved && device && cert) saved = { ns, iss: masterPubkey, proxy: proxyUrl, device, cert }
|
|
503
|
+
const out = { ns: ns || saved?.ns, proxyUrl: proxyUrl || saved?.proxy, masterPubkey: masterPubkey || saved?.iss, device: device || saved?.device, cert: cert || saved?.cert }
|
|
504
|
+
if (!out.proxyUrl || !out.masterPubkey || !out.device || !out.cert) throw new Error('service not enrolled: run enrollService() first (service-identity.json missing)')
|
|
505
|
+
return out
|
|
506
|
+
}
|
|
507
|
+
async function sshRpc (args, data, { timeoutMs = 30000, waitResult = null } = {}) {
|
|
508
|
+
const { proxyUrl, masterPubkey, device, cert } = serviceArgs(args)
|
|
509
|
+
const client = await freshClient(proxyUrl)
|
|
510
|
+
try {
|
|
511
|
+
await identifyAsService(client, device)
|
|
512
|
+
const signed = { ...data, publickey: device.publickey, ts: Date.now() }
|
|
513
|
+
const { signature } = await signWithDevice({ privateJwk: device.privateJwk, data: signed })
|
|
514
|
+
const pending = waitForMsg(client, (p) => p.type === MSG.SECRETS_RESULT || p.type === MSG.ERROR, timeoutMs)
|
|
515
|
+
client.sendByPubkey(masterPubkey, { type: MSG.SECRETS, data: signed, signature, cert })
|
|
516
|
+
let res = await pending
|
|
517
|
+
if (res.type === MSG.ERROR) throw new Error(res.error)
|
|
518
|
+
if (waitResult && res.body?.op === 'ssh.pending') {
|
|
519
|
+
try { waitResult.onPending?.({ id: res.body.id, exp: res.body.exp }) } catch (_) {}
|
|
520
|
+
const until = typeof res.body.exp === 'number' ? Math.max(5000, res.body.exp - Date.now() + 5000) : APPROVAL_TIMEOUT_MS
|
|
521
|
+
res = await waitForMsg(client, (p) => (p.type === MSG.SECRETS_RESULT && p.body?.op === 'ssh.sign.result') || p.type === MSG.ERROR, Math.min(until, APPROVAL_TIMEOUT_MS))
|
|
522
|
+
.catch((e) => { throw new Error(/timeout/.test(e.message) ? 'ssh: nobody approved the request in time' : e.message) })
|
|
523
|
+
if (res.type === MSG.ERROR) throw new Error(res.error)
|
|
524
|
+
}
|
|
525
|
+
const ok = await verifyDeviceSig({ publickey: masterPubkey, data: res.body, signature: res.signature })
|
|
526
|
+
if (!ok) throw new Error('invalid master signature on the reply')
|
|
527
|
+
return res.body
|
|
528
|
+
} finally { client.close() }
|
|
529
|
+
}
|
|
530
|
+
/** Las llaves SSH públicas registradas en la bóveda: `[{ id, blob, comment }]`. */
|
|
531
|
+
export async function listSshKeys (args = {}) {
|
|
532
|
+
const body = await sshRpc(args, { op: 'ssh.keys.public' })
|
|
533
|
+
return Array.isArray(body.items) ? body.items : []
|
|
534
|
+
}
|
|
535
|
+
/** Pide la firma SSH de `data` con la llave `keyId`; devuelve el blob de firma (Buffer). */
|
|
536
|
+
export async function requestSshSign (args = {}, { keyId, data, onPending } = {}) {
|
|
537
|
+
const body = await sshRpc(args, { op: 'ssh.sign', key: keyId, data: Buffer.from(data).toString('base64') }, { waitResult: { onPending } })
|
|
538
|
+
if (typeof body.sig !== 'string') throw new Error('ssh: malformed signature reply')
|
|
539
|
+
return Buffer.from(body.sig, 'base64')
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/**
|
|
543
|
+
* Abre un bundle sellado: saca la CEK de la envoltura dirigida a este aparato y
|
|
544
|
+
* descifra con ella las variables privadas. Las públicas vienen en claro.
|
|
545
|
+
*
|
|
546
|
+
* Un fallo al abrir es un ERROR DURO, nunca un salto a lo del scope ni un valor
|
|
547
|
+
* omitido: silenciarlo convertiría una rotación mal sellada en «el servicio sigue
|
|
548
|
+
* con el valor viejo y nadie se entera», que es el peor modo de fallo de todo esto.
|
|
549
|
+
*/
|
|
550
|
+
/**
|
|
551
|
+
* ¿SALIÓ ESTE SOBRE DE MI BÓVEDA? (§8.8 de `dotrino-vault/docs/secretos-sellados.md`)
|
|
552
|
+
*
|
|
553
|
+
* Envolver una llave solo necesita públicas, así que **cualquiera puede fabricar un sobre
|
|
554
|
+
* válido** para este servicio: abrirlo prueba que es para mí, no que lo escribió quien
|
|
555
|
+
* debía. Lo que lo prueba es la firma, hecha con la llave de sellado que el acta nombra
|
|
556
|
+
* para el `seq` con el que se firmó — y el acta la firma la maestra, que es la que este
|
|
557
|
+
* agente lleva pineada desde que se enroló.
|
|
558
|
+
*
|
|
559
|
+
* Una firma que NO cuadra es un error duro: es exactamente el caso que esto viene a
|
|
560
|
+
* cazar. Un sobre SIN firma se acepta y se avisa: los hay de antes de que esto existiera
|
|
561
|
+
* y negarse a arrancar por eso apagaría servicios que llevan meses bien.
|
|
562
|
+
*/
|
|
563
|
+
export async function makeSealCheck (acta, masterPubkey, log = console.log) {
|
|
564
|
+
if (!acta) return () => {}
|
|
565
|
+
// El acta tiene que venir firmada por la maestra que este agente ya conoce. Si la
|
|
566
|
+
// selló otro (un traspaso que este agente no ha visto), no se puede establecer
|
|
567
|
+
// procedencia: se dice y se sigue, en vez de fingir que se comprobó.
|
|
568
|
+
const ok = acta.sealedBy === masterPubkey && (await verifyActa({ acta })).ok
|
|
569
|
+
if (!ok) {
|
|
570
|
+
log('[vault] ⚠ the record does not come from the master this agent knows: envelope provenance NOT checked')
|
|
571
|
+
return () => {}
|
|
572
|
+
}
|
|
573
|
+
let avisado = false
|
|
574
|
+
return async (owner, key, gen, e, seal) => {
|
|
575
|
+
if (!seal?.sig) {
|
|
576
|
+
if (!avisado) { avisado = true; log('[vault] ⚠ some envelopes carry no signature (sealed before this vault could sign)') }
|
|
577
|
+
return
|
|
578
|
+
}
|
|
579
|
+
const pub = sealKeyAt(acta, seal.seq)
|
|
580
|
+
if (!pub) throw new Error(`${key}: the record has no sealing key for #${seal.seq} (the envelope claims a record that does not exist)`)
|
|
581
|
+
const good = await verifyDeviceSig({ publickey: pub, data: { owner, key, gen, iv: e.iv, ct: e.ct }, signature: seal.sig })
|
|
582
|
+
if (!good) throw new Error(`${key}: the envelope signature does not check out — it did not come from this vault`)
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
async function openSealedBundle (sealed, ident, acta = null, masterPubkey = null) {
|
|
587
|
+
if (!ident?.enc?.privateJwk) {
|
|
588
|
+
throw new Error('this service has no encryption key: update @dotrino/vault and re-enroll it')
|
|
589
|
+
}
|
|
590
|
+
const mine = await importDeviceEncKey(ident.enc.privateJwk)
|
|
591
|
+
|
|
592
|
+
// UNA LLAVE POR GENERACIÓN, no una por cajón. Desde v5 cada escritura estrena
|
|
593
|
+
// generación —la bóveda no puede reutilizar una llave que no puede abrir—, así que dos
|
|
594
|
+
// variables del mismo cajón pueden venir de generaciones distintas. El bundle trae
|
|
595
|
+
// TODAS las envolturas de este aparato; se abren perezosamente, solo las que hagan
|
|
596
|
+
// falta. `sealed.ns`/`sealed.dev` (una sola, la vigente) siguen entrando: es el bundle
|
|
597
|
+
// de v4 y sirve para lo que ese vault selló.
|
|
598
|
+
const porGen = { ns: new Map(), dev: new Map() }
|
|
599
|
+
const añade = (cual, info) => { if (info?.wrap) porGen[cual].set(info.gen ?? 0, info.wrap) }
|
|
600
|
+
añade('ns', sealed.ns); añade('dev', sealed.dev)
|
|
601
|
+
for (const cual of ['ns', 'dev']) for (const info of sealed.wraps?.[cual] || []) añade(cual, info)
|
|
602
|
+
|
|
603
|
+
const abiertas = { ns: new Map(), dev: new Map() }
|
|
604
|
+
const cekDe = async (cual, gen) => {
|
|
605
|
+
if (abiertas[cual].has(gen)) return abiertas[cual].get(gen)
|
|
606
|
+
// Un bundle de v4 no traía `gen` en la envoltura: si solo hay una, es esa.
|
|
607
|
+
const wrap = porGen[cual].get(gen) ?? (porGen[cual].size === 1 ? [...porGen[cual].values()][0] : null)
|
|
608
|
+
if (!wrap) return null
|
|
609
|
+
const cek = await openWrap({ wrap, myEncPrivateKey: mine })
|
|
610
|
+
abiertas[cual].set(gen, cek)
|
|
611
|
+
return cek
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
const comprobarFirma = await makeSealCheck(acta, masterPubkey)
|
|
615
|
+
|
|
616
|
+
const out = {}
|
|
617
|
+
for (const [key, e] of Object.entries(sealed.entries || {})) {
|
|
618
|
+
if (e.pub) { out[key] = e.v; continue }
|
|
619
|
+
// `owner` dice de qué cajón salió, y `gen` con qué llave de ese cajón se abre.
|
|
620
|
+
const cual = String(e.owner || '').startsWith('dev:') ? 'dev' : 'ns'
|
|
621
|
+
const gen = e.gen ?? e.e?.gen ?? 0
|
|
622
|
+
await comprobarFirma(e.owner, key, gen, e.e, e.seal)
|
|
623
|
+
const cek = await cekDe(cual, gen)
|
|
624
|
+
if (!cek) throw new Error(`no key to open ${key}: this device has no wrapping for its drawer`)
|
|
625
|
+
out[key] = await decryptWithCek({ cek, envelope: e.e })
|
|
626
|
+
}
|
|
627
|
+
return out
|
|
628
|
+
}
|
|
629
|
+
|
|
381
630
|
/**
|
|
382
631
|
* Escucha los avisos de cambio de configuración de la bóveda.
|
|
383
632
|
*
|
|
@@ -489,8 +738,59 @@ export async function watchSecretsChanges ({
|
|
|
489
738
|
try { onRevoked?.({ nonce: body.nonce }) } catch (e) { log('[vault] ' + e.message) }
|
|
490
739
|
}
|
|
491
740
|
|
|
741
|
+
/**
|
|
742
|
+
* REPARTIR LA LLAVE DE MI CAJÓN a un miembro nuevo (§8.11 del diseño).
|
|
743
|
+
*
|
|
744
|
+
* Un aparato que entra después de escrita una variable no tiene envoltura de ella, y
|
|
745
|
+
* la bóveda no se la puede hacer: envolver exige abrir la llave, y abrirla pide la
|
|
746
|
+
* frase. Este agente SÍ la tiene abierta, así que la reparte él. No gana ningún poder
|
|
747
|
+
* haciéndolo —ya podía leer eso— y por eso es el único que puede hacerlo sin que
|
|
748
|
+
* nadie ceda nada.
|
|
749
|
+
*
|
|
750
|
+
* NO SE FÍA DE LO QUE LE MANDAN, y esto es lo que hace que sea seguro incluso si la
|
|
751
|
+
* bóveda estuviera comprometida:
|
|
752
|
+
*
|
|
753
|
+
* · la petición va firmada por la MAESTRA;
|
|
754
|
+
* · el acta viaja dentro y se comprueba aparte (también la firma la maestra);
|
|
755
|
+
* · **la llave pública del destinatario se saca del ACTA, nunca del mensaje** — si
|
|
756
|
+
* se cogiera del mensaje, quien lo mandara podría hacer que este agente envolviera
|
|
757
|
+
* la llave para una pública suya;
|
|
758
|
+
* · y el destinatario tiene que ser de ESTE cajón (`cn === ns`): un servicio no puede
|
|
759
|
+
* ampliar el acceso a nada que no sea lo suyo.
|
|
760
|
+
*/
|
|
761
|
+
const handleRewrap = async (payload) => {
|
|
762
|
+
const body = payload?.body
|
|
763
|
+
if (!body || body.op !== 'rewrap') return
|
|
764
|
+
const mineOwners = [`ns:${ns}`, `dev:${saved.device.publickey}`]
|
|
765
|
+
if (!mineOwners.includes(body.owner)) return log('[vault] rewrap for a drawer that is not mine: ignored')
|
|
766
|
+
if (!(await verifyDeviceSig({ publickey: master, data: body, signature: payload.signature }))) {
|
|
767
|
+
return log('[vault] rewrap request BADLY SIGNED: ignored')
|
|
768
|
+
}
|
|
769
|
+
const acta = body.acta
|
|
770
|
+
if (!acta || acta.sealedBy !== master || !(await verifyActa({ acta })).ok) {
|
|
771
|
+
return log('[vault] rewrap request without a valid record: ignored')
|
|
772
|
+
}
|
|
773
|
+
const target = (acta.members || []).find((m) => m.pub === body.target)
|
|
774
|
+
if (!target?.encPub) return log('[vault] rewrap: the target is not in the record (or has no encryption key)')
|
|
775
|
+
if (target.cn !== ns) return log(`[vault] rewrap: ${String(body.target).slice(0, 12)}… is not part of «${ns}»: refused`)
|
|
776
|
+
|
|
777
|
+
try {
|
|
778
|
+
const ident = readServiceIdentity(dir)
|
|
779
|
+
if (!ident?.enc?.privateJwk) return log('[vault] rewrap: this agent has no encryption key')
|
|
780
|
+
const cek = await openWrap({ wrap: body.wrap, myEncPrivateKey: await importDeviceEncKey(ident.enc.privateJwk) })
|
|
781
|
+
const wrap = await wrapForMember({ cek, memberEncPub: target.encPub })
|
|
782
|
+
const data = { op: 'rewrap.ok', owner: body.owner, gen: body.gen, target: body.target, wrap, ts: Date.now() }
|
|
783
|
+
const { signature } = await signWithDevice({ privateJwk: saved.device.privateJwk, data })
|
|
784
|
+
client.sendByPubkey(master, { type: MSG.REWRAP_OK, data, signature, cert: saved.cert })
|
|
785
|
+
log(`[vault] key handed to ${String(body.target).slice(0, 12)}… for ${body.owner} (gen ${body.gen})`)
|
|
786
|
+
} catch (e) {
|
|
787
|
+
log('[vault] rewrap failed: ' + e.message)
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
|
|
492
791
|
const handleMessage = async (payload) => {
|
|
493
792
|
if (payload?.type === MSG.REVOKED) return handleRevocation(payload)
|
|
793
|
+
if (payload?.type === MSG.REWRAP) return handleRewrap(payload)
|
|
494
794
|
if (payload?.type !== MSG.SECRETS_CHANGED) return
|
|
495
795
|
const body = payload.body
|
|
496
796
|
if (!body || body.op !== 'secrets.changed' || body.ns !== ns) return
|
|
@@ -646,15 +946,16 @@ function fingerprintOf (secrets) {
|
|
|
646
946
|
* servicio no opera hasta que esto resuelva — esa es la regla.
|
|
647
947
|
* @returns {Promise<Record<string,string>>}
|
|
648
948
|
*/
|
|
649
|
-
export async function waitForSecrets ({ dir, ns, proxyUrl, masterPubkey, device, cert, retryMs = 5000, maxRetryMs = 60000, onRetry } = {}) {
|
|
949
|
+
export async function waitForSecrets ({ dir, ns, proxyUrl, masterPubkey, device, cert, enc, retryMs = 5000, maxRetryMs = 60000, onRetry, onPending } = {}) {
|
|
650
950
|
let delay = retryMs
|
|
651
951
|
for (;;) {
|
|
652
952
|
try {
|
|
653
|
-
return await fetchSecrets({ dir, ns, proxyUrl, masterPubkey, device, cert })
|
|
953
|
+
return await fetchSecrets({ dir, ns, proxyUrl, masterPubkey, device, cert, enc, onPending })
|
|
654
954
|
} catch (e) {
|
|
655
955
|
// Lo NO transitorio no se arregla reintentando: falta de enrolamiento,
|
|
656
956
|
// cert revocado/vencido o scope equivocado exigen re-emparejar → se corta.
|
|
657
|
-
|
|
957
|
+
// Que te lo DENIEGUEN tampoco se reintenta: fue una decisión, no un tropiezo.
|
|
958
|
+
if (/not enrolled|invalid ns|unauthorized: (revoked|expired|scope|cn|untrusted-issuer|cert-device-mismatch|denied)/.test(e.message)) throw e
|
|
658
959
|
try { onRetry?.(e, delay) } catch (_) {}
|
|
659
960
|
await new Promise((r) => setTimeout(r, delay))
|
|
660
961
|
delay = Math.min(maxRetryMs, Math.round(delay * 1.6))
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AGENTE SSH del daemon: un socket Unix con el protocolo de `ssh-agent` (draft-miller-
|
|
3
|
+
* ssh-agent), para que el `ssh` del usuario firme con la llave que vive en su TELÉFONO.
|
|
4
|
+
*
|
|
5
|
+
* Lo que hace es poco a propósito: listar las llaves públicas registradas y, en cada
|
|
6
|
+
* `SIGN_REQUEST`, pedirle la firma al aparato que aprueba (`vault.requestSshSign`) y
|
|
7
|
+
* devolverla. No guarda llaves privadas, no acepta que le añadan (`ssh-add` de una llave
|
|
8
|
+
* del disco se rechaza: la idea es justo que no haya llaves en el disco) y no hace de
|
|
9
|
+
* proxy de nada más.
|
|
10
|
+
*
|
|
11
|
+
* export SSH_AUTH_SOCK=$XDG_RUNTIME_DIR/dotrino-vault/ssh-agent.sock
|
|
12
|
+
* ssh-add -L # las llaves del teléfono
|
|
13
|
+
* ssh mi-servidor # el teléfono pide tu «sí» y firma
|
|
14
|
+
*/
|
|
15
|
+
import fs from 'node:fs'
|
|
16
|
+
import net from 'node:net'
|
|
17
|
+
import path from 'node:path'
|
|
18
|
+
import { sshString, readStrings } from './sshKeys.js'
|
|
19
|
+
|
|
20
|
+
const AGENT_FAILURE = 5
|
|
21
|
+
const AGENT_SUCCESS = 6
|
|
22
|
+
const REQUEST_IDENTITIES = 11
|
|
23
|
+
const IDENTITIES_ANSWER = 12
|
|
24
|
+
const SIGN_REQUEST = 13
|
|
25
|
+
const SIGN_RESPONSE = 14
|
|
26
|
+
|
|
27
|
+
/** Dónde va el socket: en el directorio de ejecución del usuario si lo hay (se limpia solo). */
|
|
28
|
+
export function defaultSocketPath (dir) {
|
|
29
|
+
const run = process.env.XDG_RUNTIME_DIR
|
|
30
|
+
return run ? path.join(run, 'dotrino-vault', 'ssh-agent.sock') : path.join(dir, 'ssh-agent.sock')
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const frame = (type, payload = Buffer.alloc(0)) => {
|
|
34
|
+
const body = Buffer.concat([Buffer.from([type]), payload])
|
|
35
|
+
const len = Buffer.alloc(4); len.writeUInt32BE(body.length)
|
|
36
|
+
return Buffer.concat([len, body])
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* @param {{ socketPath: string, vault: () => { sshKeys: () => any[], requestSshSign: (a: { keyId: string, data: Buffer }) => Promise<Buffer> }, log?: Function }} opts
|
|
41
|
+
*/
|
|
42
|
+
export function startSshAgent ({ socketPath, vault, log = () => {}, refresh = null }) {
|
|
43
|
+
fs.mkdirSync(path.dirname(socketPath), { recursive: true, mode: 0o700 })
|
|
44
|
+
try { fs.unlinkSync(socketPath) } catch (_) {}
|
|
45
|
+
|
|
46
|
+
async function handle (type, payload) {
|
|
47
|
+
if (refresh) { try { await refresh() } catch (_) {} }
|
|
48
|
+
const v = vault()
|
|
49
|
+
if (type === REQUEST_IDENTITIES) {
|
|
50
|
+
const keys = v.sshKeys()
|
|
51
|
+
const n = Buffer.alloc(4); n.writeUInt32BE(keys.length)
|
|
52
|
+
const parts = keys.map((k) => Buffer.concat([sshString(Buffer.from(k.blob, 'base64')), sshString(Buffer.from(k.comment || ''))]))
|
|
53
|
+
return frame(IDENTITIES_ANSWER, Buffer.concat([n, ...parts]))
|
|
54
|
+
}
|
|
55
|
+
if (type === SIGN_REQUEST) {
|
|
56
|
+
const [blob, data] = readStrings(payload, 2)
|
|
57
|
+
const key = v.sshKeys().find((k) => k.blob === blob.toString('base64'))
|
|
58
|
+
if (!key) return frame(AGENT_FAILURE)
|
|
59
|
+
try {
|
|
60
|
+
const sig = await v.requestSshSign({ keyId: key.id, data })
|
|
61
|
+
return frame(SIGN_RESPONSE, sshString(sig))
|
|
62
|
+
} catch (e) {
|
|
63
|
+
log('[vault] ssh-agent: not signed: ' + e.message)
|
|
64
|
+
return frame(AGENT_FAILURE)
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
// Añadir/quitar llaves, candado, extensiones: nada de eso vive aquí.
|
|
68
|
+
return frame(AGENT_FAILURE)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const server = net.createServer((sock) => {
|
|
72
|
+
let buf = Buffer.alloc(0)
|
|
73
|
+
let busy = Promise.resolve()
|
|
74
|
+
sock.on('data', (chunk) => {
|
|
75
|
+
buf = Buffer.concat([buf, chunk])
|
|
76
|
+
while (buf.length >= 4) {
|
|
77
|
+
const len = buf.readUInt32BE(0)
|
|
78
|
+
if (len < 1 || len > 256 * 1024) { sock.destroy(); return }
|
|
79
|
+
if (buf.length < 4 + len) break
|
|
80
|
+
const type = buf[4]; const payload = buf.subarray(5, 4 + len)
|
|
81
|
+
buf = buf.subarray(4 + len)
|
|
82
|
+
// En orden: el protocolo es petición-respuesta, y un cliente que encadena dos
|
|
83
|
+
// firmas espera las respuestas en el mismo orden.
|
|
84
|
+
busy = busy.then(() => handle(type, payload)).then((out) => { if (!sock.destroyed) sock.write(out) }).catch(() => { if (!sock.destroyed) sock.write(frame(AGENT_FAILURE)) })
|
|
85
|
+
}
|
|
86
|
+
})
|
|
87
|
+
sock.on('error', () => {})
|
|
88
|
+
})
|
|
89
|
+
server.on('error', (e) => log('[vault] ssh-agent: ' + e.message))
|
|
90
|
+
server.listen(socketPath, () => {
|
|
91
|
+
try { fs.chmodSync(socketPath, 0o600) } catch (_) {}
|
|
92
|
+
log(`[vault] ssh-agent listening at ${socketPath} (export SSH_AUTH_SOCK=${socketPath})`)
|
|
93
|
+
})
|
|
94
|
+
return {
|
|
95
|
+
socketPath,
|
|
96
|
+
close () { try { server.close() } catch (_) {} try { fs.unlinkSync(socketPath) } catch (_) {} }
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export default { startSshAgent, defaultSocketPath, AGENT_SUCCESS }
|