@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.
@@ -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, verifyDeviceSig,
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
- export async function enrollService ({ qr, ns, dir, label, onCode, onReplace, approveTimeoutMs = 180000 } = {}) {
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 --service` emite la URL del QR y el código compacto
228
- // (`c…`/`t…`, ver invite.js), así que un `JSON.parse` fallaba SIEMPRE con
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 --service <ns>`)')
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 generado AQUÍ: el vault no lo conoce; solo puede echarlo
282
- // de vuelta si el dueño lo tipeó (= tiene esta pantalla a la vista).
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
- // `intent: 'join'` EXPLÍCITO. La bóveda lo compara con el modo que abrió y,
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-service] device ${deviceId} · approve it on the vault: dotrino-vault approve ${code}`))
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: secretsScope(ns) })
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
- // Reemplazo, no acumulación: el archivo se sobrescribe entero y la identidad
316
- // anterior deja de existir en este agente.
317
- writeServiceIdentity(dir, { v: 1, ns, iss: qr.iss, proxy: qr.proxy, device, cert: res.cert, enrolledAt: Date.now() })
318
- return { device, cert: res.cert, iss: qr.iss, replaced }
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
- const res = await pending
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
- if (/not enrolled|invalid ns|unauthorized: (revoked|expired|scope|cn|untrusted-issuer|cert-device-mismatch)/.test(e.message)) throw e
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 }