@dotrino/vaultd 0.46.2 → 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 CHANGED
@@ -73,7 +73,7 @@ solo de qué cert se emitió un día.
73
73
 
74
74
  ```sh
75
75
  dotrino-vault members # el acta: qué llaves son tuyas y qué puede hacer cada una
76
- dotrino-vault caps <ID> +firma # cambia permisos (+firma -guarda +lee …)
76
+ dotrino-vault caps <ID> +firma # cambia permisos (+firma -guarda +lee +administra +aprueba …)
77
77
  ```
78
78
 
79
79
  **Los certificados caducan y se renuevan solos.** Un cert dura **30 días**. Mientras
@@ -235,11 +235,12 @@ dotrino-vault approve <código> # aprueba tecleando los 6 dígitos que MUEST
235
235
  dotrino-vault reject <deviceId> # rechaza un dispositivo pendiente
236
236
  dotrino-vault devices # lista dispositivos enrolados / revocados
237
237
  dotrino-vault members # el acta del perfil: qué llaves son tuyas y qué puede cada una
238
- dotrino-vault caps <ID> ±permiso # cambia lo que puede un dispositivo (+firma -guarda +lee …)
238
+ dotrino-vault caps <ID> ±permiso # cambia lo que puede un dispositivo (+firma -guarda +lee +administra +aprueba …)
239
239
  dotrino-vault revoke <nonce> # revoca un dispositivo (le ordena autoborrarse)
240
240
  dotrino-vault activity [n] # bitácora de seguridad: firmas, renovaciones, enrolados, rechazos
241
241
  dotrino-vault pair --service <ns> # empareja un SERVICIO (proxy, geo…) con acceso SOLO a sus secretos
242
242
  dotrino-vault pair --scope <lista> # los PERMISOS del cert: sign,read,store,secrets:<ns>. Sin esto, sign,read,store.
243
+ dotrino-vault secret policy <ns> approval on|off # el cajón pide tu aprobación en cada lectura (15 min de ventana)
243
244
  # Se combina con --service: `--service eco --scope sign` = un bot que firma
244
245
  # como aparato del acta y lee SOLO su cajón. `admin` no se empareja (caps).
245
246
  dotrino-vault secret set <ns> <CLAVE> <valor> # variable del SCOPE: la comparten todos los
@@ -755,6 +756,47 @@ CLI de apoyo: `dotrino-env status` (qué hay enrolado aquí), `dotrino-env check
755
756
  los secretos en el entorno de un proceso que no es Node). Primer consumidor:
756
757
  `dotrino-proxy` (TURN de Cloudflare).
757
758
 
759
+ ### Aprobación por uso (el teléfono dice que sí) y la llave SSH en el teléfono
760
+
761
+ Un cajón puede exigir el **visto bueno de un aparato** en cada lectura: para lo que corre
762
+ en tu PC (un asistente, un script) y no debería llevarse tus claves sin que lo sepas.
763
+
764
+ ```sh
765
+ dotrino-vault caps <ID-del-teléfono> +aprueba # quién aprueba (no viaja en un QR)
766
+ dotrino-vault secret policy claude approval on # el cajón «claude» espera el sí
767
+ dotrino-env run --ns claude -- node mi-script.js # el proceso se queda esperando…
768
+ ```
769
+
770
+ …la bóveda apunta el pedido, avisa al teléfono (cola del proxio → aviso nativo), y solo su
771
+ firma entrega las claves — **al proceso que pidió, en memoria**, con una **ventana de
772
+ 15 min** por aparato y cajón para no pedir veinte veces. Lo denegado corta sin reintentos;
773
+ lo que nadie atiende vence a los 5 min; todo queda en `dotrino-vault activity`.
774
+
775
+ **La llave SSH vive en el teléfono.** El daemon expone un **agente SSH** (protocolo de
776
+ `ssh-agent`, socket en `$XDG_RUNTIME_DIR/dotrino-vault/ssh-agent.sock`) que **no guarda
777
+ ninguna llave privada**: la llave nace en el teléfono (vault.dotrino.com → *Llave SSH de
778
+ este aparato*; WebCrypto no extraíble, `ecdsa-sha2-nistp256`) y cada firma es un pedido
779
+ que apruebas ahí. En el PC no queda nada que copiar.
780
+
781
+ ```sh
782
+ dotrino-vault ssh # imprime el export SSH_AUTH_SOCK=… y la receta
783
+ dotrino-vault ssh keys # las llaves registradas (= ssh-add -L)
784
+ ssh mi-servidor # el teléfono pide tu «sí» y firma
785
+ ```
786
+
787
+ **La bóveda puede estar en OTRA máquina** (la de Dotrino en el VPS): en tu PC corre el
788
+ agente **delgado**, que no custodia nada y reenvía cada reto como un pedido a la bóveda
789
+ que enroló ese servicio:
790
+
791
+ ```sh
792
+ dotrino-env enroll --ns claude # una vez, contra la bóveda que sea
793
+ dotrino-env ssh-agent --ns claude # imprime export SSH_AUTH_SOCK=…
794
+ ```
795
+
796
+ Para no aprobar cada comando, reusa la conexión con `ControlMaster auto` +
797
+ `ControlPersist 15m` en `~/.ssh/config`: esa es la ventana de 15 min del SSH.
798
+ `DOTRINO_VAULT_SSH_AGENT=0` apaga el agente.
799
+
758
800
  ## Alcance
759
801
 
760
802
  - **v1 (este):** daemon headless en Node, **multi-perfil**. En **Linux** queda como
package/lib/README.md CHANGED
@@ -279,3 +279,10 @@ cert de una máquina vigente: sin esto toda máquina enrolada caducaba a los 30
279
279
  | bitácora, cifrado en reposo, candado, multi-perfil | son del daemon; en el navegador dependen de `@dotrino/identity` |
280
280
 
281
281
  MIT · parte de [Dotrino](https://dotrino.com).
282
+
283
+ ## Agente SSH delgado (`dotrino-env ssh-agent`)
284
+
285
+ La llave SSH vive en el teléfono del dueño (registrada en su bóveda). En esta máquina solo
286
+ corre un agente `ssh-agent` sin llaves: cada `SIGN_REQUEST` viaja a la bóveda como un
287
+ pedido que el teléfono aprueba firmando. `dotrino-env ssh-agent --ns <ns>` imprime el
288
+ `SSH_AUTH_SOCK`. Como librería: `listSshKeys(args)` y `requestSshSign(args, { keyId, data })`.
package/lib/src/env.js CHANGED
@@ -98,12 +98,12 @@ let lastApplied = null
98
98
  * `overridden` = las que YA tenían otro valor en el entorno y el vault pisó. Es
99
99
  * el dato que delata un `.env` rancio, así que se reporta en vez de callarse.
100
100
  */
101
- export async function loadEnv ({ ns, dir, override, wait = true, required = [], onRetry } = {}) {
101
+ export async function loadEnv ({ ns, dir, override, wait = true, required = [], onRetry, onPending } = {}) {
102
102
  if (override === undefined) override = overrideByDefault()
103
103
  ns = resolveNs(ns)
104
104
  dir = dir || serviceDir(ns)
105
105
  const load = wait ? waitForSecrets : fetchSecrets
106
- const secrets = await load({ dir, ns, onRetry })
106
+ const secrets = await load({ dir, ns, onRetry, onPending })
107
107
 
108
108
  const missing = required.filter((k) => !(k in secrets))
109
109
  if (missing.length) {
@@ -97,7 +97,8 @@ export const SCOPE = Object.freeze({
97
97
  // Consola remota (docs/consola-remota.md): admitir y expulsar miembros a distancia.
98
98
  // NO incluye cambiar permisos, traspasar el mando ni conceder `admin`: eso es el rol
99
99
  // de master y sigue siendo local. No se empareja — se concede desde el PC.
100
- ADMIN: 'vault:admin'
100
+ ADMIN: 'vault:admin',
101
+ APPROVE: 'vault:approve' // aprobar pedidos de secretos (cajones con `approval`); se concede a mano, como admin
101
102
  })
102
103
 
103
104
  /**
@@ -161,6 +161,9 @@ async function identifyAsService (client, device) {
161
161
  await client.identify({ data, signature })
162
162
  }
163
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
+
164
167
  function waitForMsg (client, predicate, timeoutMs = 30000) {
165
168
  return new Promise((resolve, reject) => {
166
169
  const off = client.on('message', (_from, payload) => {
@@ -407,7 +410,7 @@ export async function registerEncKey ({ dir, ns, proxyUrl, masterPubkey, device,
407
410
  * Renueva el cert automáticamente si está por vencer (best-effort).
408
411
  * @returns {Promise<Record<string,string>>} secretos KEY→valor
409
412
  */
410
- export async function fetchSecrets ({ dir, ns, proxyUrl, masterPubkey, device, cert, enc, timeoutMs = 30000 } = {}) {
413
+ export async function fetchSecrets ({ dir, ns, proxyUrl, masterPubkey, device, cert, enc, timeoutMs = 30000, onPending, approvalTimeoutMs = APPROVAL_TIMEOUT_MS } = {}) {
411
414
  let saved = null
412
415
  if (dir) saved = readServiceIdentity(dir)
413
416
  // Sin `dir`: la identidad viene entera por parámetros — es el caso de un agente
@@ -448,9 +451,22 @@ export async function fetchSecrets ({ dir, ns, proxyUrl, masterPubkey, device, c
448
451
  const { signature } = await signWithDevice({ privateJwk: device.privateJwk, data })
449
452
  const pending = waitForMsg(client, (p) => p.type === MSG.SECRETS_RESULT || p.type === MSG.ERROR, timeoutMs)
450
453
  client.sendByPubkey(masterPubkey, { type: MSG.SECRETS, data, signature, cert })
451
- const res = await pending
454
+ let res = await pending
452
455
  if (res.type === MSG.ERROR) throw new Error(res.error)
453
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
+
454
470
  // Autenticidad: el cuerpo viene firmado por la MAESTRA pineada.
455
471
  const body = res.body
456
472
  if (!body || body.op !== 'secrets.result' || body.ns !== ns) throw new Error('malformed secrets reply')
@@ -475,6 +491,54 @@ export async function fetchSecrets ({ dir, ns, proxyUrl, masterPubkey, device, c
475
491
  } finally { client.close() }
476
492
  }
477
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
+
478
542
  /**
479
543
  * Abre un bundle sellado: saca la CEK de la envoltura dirigida a este aparato y
480
544
  * descifra con ella las variables privadas. Las públicas vienen en claro.
@@ -882,15 +946,16 @@ function fingerprintOf (secrets) {
882
946
  * servicio no opera hasta que esto resuelva — esa es la regla.
883
947
  * @returns {Promise<Record<string,string>>}
884
948
  */
885
- export async function waitForSecrets ({ dir, ns, proxyUrl, masterPubkey, device, cert, enc, retryMs = 5000, maxRetryMs = 60000, onRetry } = {}) {
949
+ export async function waitForSecrets ({ dir, ns, proxyUrl, masterPubkey, device, cert, enc, retryMs = 5000, maxRetryMs = 60000, onRetry, onPending } = {}) {
886
950
  let delay = retryMs
887
951
  for (;;) {
888
952
  try {
889
- return await fetchSecrets({ dir, ns, proxyUrl, masterPubkey, device, cert, enc })
953
+ return await fetchSecrets({ dir, ns, proxyUrl, masterPubkey, device, cert, enc, onPending })
890
954
  } catch (e) {
891
955
  // Lo NO transitorio no se arregla reintentando: falta de enrolamiento,
892
956
  // cert revocado/vencido o scope equivocado exigen re-emparejar → se corta.
893
- 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
894
959
  try { onRetry?.(e, delay) } catch (_) {}
895
960
  await new Promise((r) => setTimeout(r, delay))
896
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 }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * LLAVES SSH del teléfono — solo la parte PÚBLICA, que es lo único que vive en esta
3
+ * máquina. La privada nace y muere en el aparato que aprueba (WebCrypto no extraíble hoy;
4
+ * el llavero del sistema en la app nativa): firmar un reto SSH es un pedido de aprobación
5
+ * más, y la firma vuelve con el «sí». Así la llave SSH no está en el disco del PC, que
6
+ * era exactamente lo que había que sacar de aquí.
7
+ *
8
+ * Formatos (RFC 4253 / 5656): el blob de una llave `ecdsa-sha2-nistp256` es
9
+ * string(tipo) string("nistp256") string(0x04‖X‖Y); su firma es string(tipo) string(mpint r ‖ mpint s).
10
+ * Puro: sin disco ni red — quien guarda es `vault.js`.
11
+ */
12
+ import { createPublicKey, createHash, verify as nodeVerify } from 'node:crypto'
13
+
14
+ export const ECDSA_P256 = 'ecdsa-sha2-nistp256'
15
+
16
+ const u32 = (n) => { const b = Buffer.alloc(4); b.writeUInt32BE(n >>> 0); return b }
17
+ export const sshString = (buf) => Buffer.concat([u32(buf.length), Buffer.from(buf)])
18
+ export function sshMpint (bytes) {
19
+ let b = Buffer.from(bytes)
20
+ while (b.length > 1 && b[0] === 0) b = b.subarray(1)
21
+ if (b[0] & 0x80) b = Buffer.concat([Buffer.from([0]), b])
22
+ return sshString(b)
23
+ }
24
+ /** Lee strings SSH encadenados: `[Buffer, …]`. */
25
+ export function readStrings (buf, max = 16) {
26
+ const out = []; let o = 0
27
+ while (o + 4 <= buf.length && out.length < max) {
28
+ const n = buf.readUInt32BE(o); o += 4
29
+ if (o + n > buf.length) throw new Error('ssh: truncated string')
30
+ out.push(buf.subarray(o, o + n)); o += n
31
+ }
32
+ return out
33
+ }
34
+
35
+ /** Blob de llave pública a partir de las coordenadas (base64url JWK). */
36
+ export function p256Blob ({ x, y }) {
37
+ const point = Buffer.concat([Buffer.from([4]), Buffer.from(x, 'base64url'), Buffer.from(y, 'base64url')])
38
+ return Buffer.concat([sshString(Buffer.from(ECDSA_P256)), sshString(Buffer.from('nistp256')), sshString(point)])
39
+ }
40
+
41
+ /**
42
+ * Acepta una línea `authorized_keys` (`ecdsa-sha2-nistp256 AAAA… comentario`) y devuelve
43
+ * lo que se guarda: tipo, blob, comentario y JWK pública. Solo P-256: es lo que firma
44
+ * WebCrypto y el llavero del teléfono; RSA no entra.
45
+ */
46
+ export function parsePublicKey (line) {
47
+ const [type, b64, ...rest] = String(line || '').trim().split(/\s+/)
48
+ if (type !== ECDSA_P256 || !b64) throw new Error(`ssh: only ${ECDSA_P256} keys are accepted`)
49
+ const blob = Buffer.from(b64, 'base64')
50
+ const [t, curve, point] = readStrings(blob, 3)
51
+ if (t.toString() !== ECDSA_P256 || curve.toString() !== 'nistp256' || point.length !== 65 || point[0] !== 4) throw new Error('ssh: malformed key blob')
52
+ const jwk = { kty: 'EC', crv: 'P-256', x: point.subarray(1, 33).toString('base64url'), y: point.subarray(33).toString('base64url') }
53
+ return { type, blob: blob.toString('base64'), comment: rest.join(' '), jwk, id: fingerprint(blob) }
54
+ }
55
+
56
+ /** `SHA256:…` como lo imprime `ssh-keygen -l`. */
57
+ export function fingerprint (blob) {
58
+ return 'SHA256:' + createHash('sha256').update(Buffer.from(blob)).digest('base64').replace(/=+$/, '')
59
+ }
60
+
61
+ /**
62
+ * Comprueba la firma cruda del teléfono (`r‖s`, 64 bytes, SHA-256 sobre `data`) y la
63
+ * convierte al blob de firma SSH. Una firma que no cuadra NO se convierte: devolver un
64
+ * blob inválido dejaría al `ssh` del usuario con un error opaco y a nosotros sin bitácora.
65
+ */
66
+ export function sshSignature ({ jwk, data, rawSig }) {
67
+ const sig = Buffer.from(rawSig, 'base64')
68
+ if (sig.length !== 64) throw new Error('ssh: raw signature must be 64 bytes (r||s)')
69
+ const key = createPublicKey({ key: { kty: 'EC', crv: 'P-256', x: jwk.x, y: jwk.y }, format: 'jwk' })
70
+ const ok = nodeVerify('sha256', Buffer.from(data), { key, dsaEncoding: 'ieee-p1363' }, sig)
71
+ if (!ok) throw new Error('ssh: the signature does not verify against the registered key')
72
+ const rs = Buffer.concat([sshMpint(sig.subarray(0, 32)), sshMpint(sig.subarray(32))])
73
+ return Buffer.concat([sshString(Buffer.from(ECDSA_P256)), sshString(rs)])
74
+ }
75
+
76
+ export default { ECDSA_P256, parsePublicKey, p256Blob, sshSignature, sshString, sshMpint, readStrings, fingerprint }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotrino/vaultd",
3
- "version": "0.46.2",
3
+ "version": "0.49.0",
4
4
  "type": "module",
5
5
  "description": "Certificador personal de Dotrino: daemon headless que custodia la clave maestra y delega capacidades a tus dispositivos por el proxy. Tu CA propia.",
6
6
  "bin": {
@@ -20,7 +20,7 @@
20
20
  "node": ">=20"
21
21
  },
22
22
  "dependencies": {
23
- "@dotrino/identity": "^0.57.0",
23
+ "@dotrino/identity": "^0.58.0",
24
24
  "@dotrino/proxy-client": "^0.10.1",
25
25
  "ws": "^8.18.0"
26
26
  },
@@ -0,0 +1,69 @@
1
+ /**
2
+ * PEDIDOS DE APROBACIÓN y VENTANAS de un cajón con `approval` (puro: sin red ni disco).
3
+ *
4
+ * Cuando un cajón exige aprobación por uso, leerlo ya no depende solo de tener el cert:
5
+ * el vault apunta el pedido, avisa a los aparatos con `approve` (el teléfono), y solo su
6
+ * firma libera los secretos. Aprobado, se abre una VENTANA para ese aparato y ese cajón
7
+ * (15 min): una sesión de trabajo no pide veinte veces. Lo que nadie aprueba vence solo.
8
+ *
9
+ * Lo que se guarda de cada pedido es lo justo para contestar después: el cajón, quién
10
+ * pide y su llave efímera `ek` (a la que se sella la respuesta). Nunca un valor.
11
+ */
12
+ const rnd = () => [...crypto.getRandomValues(new Uint8Array(8))].map((b) => b.toString(16).padStart(2, '0')).join('')
13
+
14
+ export const PENDING_TTL_MS = 5 * 60 * 1000
15
+ export const GRANT_TTL_MS = 15 * 60 * 1000
16
+
17
+ export function createApprovals ({ now = Date.now, pendingTtlMs = PENDING_TTL_MS, grantTtlMs = GRANT_TTL_MS } = {}) {
18
+ /** id → { id, ns, device, deviceId, label, ek, ts, exp } */
19
+ const pending = new Map()
20
+ /** `${ns}|${device}` → exp */
21
+ const grants = new Map()
22
+ const gkey = (ns, device) => ns + '|' + device
23
+
24
+ const publicOf = (p) => ({ id: p.id, kind: p.kind, ns: p.ns, deviceId: p.deviceId, label: p.label, ts: p.ts, exp: p.exp, ...(p.ssh ? { ssh: p.ssh } : {}) })
25
+
26
+ return {
27
+ /**
28
+ * Apunta un pedido. `kind`: `secrets` (leer un cajón; uno por cajón y aparato: pedir
29
+ * otra vez reemplaza al anterior) o `ssh` (firmar un reto SSH con la llave del
30
+ * teléfono; cada firma es un pedido, con `ssh: { key, data }` y sus callbacks).
31
+ */
32
+ request ({ kind = 'secrets', ns, device = null, deviceId, label = '', ek = null, ssh = null, resolve = null, reject = null }) {
33
+ if (kind === 'secrets') for (const [id, p] of pending) if (p.kind === 'secrets' && p.ns === ns && p.device === device) pending.delete(id)
34
+ const ts = now()
35
+ const p = { id: rnd(), kind, ns, device, deviceId, label, ek, ssh, resolve, reject, ts, exp: ts + pendingTtlMs }
36
+ pending.set(p.id, p)
37
+ return publicOf(p)
38
+ },
39
+ /** Los pedidos vivos, sin la `ek` (no hace falta fuera de aquí). */
40
+ list () { this.sweep(); return [...pending.values()].map(publicOf) },
41
+ /** Saca un pedido para resolverlo (aprobar o denegar). `null` si no existe o venció. */
42
+ take (id) {
43
+ this.sweep()
44
+ const p = pending.get(id)
45
+ if (p) pending.delete(id)
46
+ return p || null
47
+ },
48
+ /** Abre la ventana de ese aparato sobre ese cajón. */
49
+ grant (ns, device) { const exp = now() + grantTtlMs; grants.set(gkey(ns, device), exp); return exp },
50
+ /** ¿Sigue abierta la ventana? */
51
+ has (ns, device) {
52
+ const exp = grants.get(gkey(ns, device))
53
+ if (exp == null) return false
54
+ if (exp <= now()) { grants.delete(gkey(ns, device)); return false }
55
+ return true
56
+ },
57
+ /** Cierra las ventanas de un aparato (al revocarlo, por ejemplo). */
58
+ forget (device) { for (const k of [...grants.keys()]) if (k.endsWith('|' + device)) grants.delete(k) },
59
+ /** Tira lo vencido; devuelve los pedidos que vencieron, para anotarlos. */
60
+ sweep () {
61
+ const t = now(); const gone = []
62
+ for (const [id, p] of pending) if (p.exp <= t) { pending.delete(id); gone.push(publicOf(p)); try { p.reject?.(new Error('approval: nobody approved the request in time')) } catch (_) {} }
63
+ for (const [k, exp] of grants) if (exp <= t) grants.delete(k)
64
+ return gone
65
+ }
66
+ }
67
+ }
68
+
69
+ export default { createApprovals, PENDING_TTL_MS, GRANT_TTL_MS }
package/src/ctl.js CHANGED
@@ -27,6 +27,7 @@ import tty from 'node:tty'
27
27
  import { pubkeyId } from '@dotrino/identity/capabilities'
28
28
  import { dataDir, readJson } from './paths.js'
29
29
  import { assertVar } from './secretsStore.js'
30
+ import { isValidSecretsNs } from './protocol.js'
30
31
  import { parseEnvText, PAIR_RE } from '../lib/src/envtext.js'
31
32
  import { qrToString } from './qr.js'
32
33
  import { encodeInvite, inviteUrl } from '../lib/src/invite.js'
@@ -127,6 +128,7 @@ function cmdStatus () {
127
128
  }
128
129
  console.log(' fingerprint : %s', s.fingerprint)
129
130
  console.log(' proxy : %s', s.proxy)
131
+ if (s.sshAgent) console.log(' ssh-agent : %s (dotrino-vault ssh)', s.sshAgent)
130
132
  console.log(' pid : %s%s', s.pid, up ? '' : ' (no responde)')
131
133
  console.log(' datos : %s', dir)
132
134
  const profiles = s.profiles || []
@@ -196,6 +198,7 @@ async function cmdPair (args = []) {
196
198
  for (const tok of raw.split(',').map((t) => t.trim()).filter(Boolean)) {
197
199
  const t = ALIAS[tok] || tok
198
200
  if (t === 'admin' || t === 'administra') { console.error('`admin` no se empareja: concédelo desde el PC con dotrino-vault caps <ID> +administra'); process.exit(2) }
201
+ if (t === 'approve' || t === 'aprueba') { console.error('`approve` no se empareja: concédelo desde el PC con dotrino-vault caps <ID> +aprueba'); process.exit(2) }
199
202
  if (t === 'sign' || t === 'read' || t === 'store') { scope.push('vault:' + t); continue }
200
203
  const m = /^secrets:([a-z0-9-]{1,32})$/.exec(t)
201
204
  if (m) { scope.push('vault:secrets:' + m[1]); continue }
@@ -379,7 +382,7 @@ async function cmdMembers () {
379
382
  assertOpen(record)
380
383
  if (!record.members?.length) { console.log('Este perfil todavía no tiene acta.'); return }
381
384
 
382
- const CAP = { sign: 'firma', store: 'guarda', read: 'lee', secrets: 'lee sus claves', admin: `${B}administra el perfil${Z}` }
385
+ const CAP = { sign: 'firma', store: 'guarda', read: 'lee', secrets: 'lee sus claves', admin: `${B}administra el perfil${Z}`, approve: `${B}aprueba pedidos${Z}` }
383
386
  // El nombre del perfil es una pubkey JWK. Recortarla no la hace legible: la deja
384
387
  // pareciendo un error (`{"key_ops":["verify"],"e…`). Se muestra su huella corta, la
385
388
  // misma que se enseña al emparejar y en la lista de miembros.
@@ -399,7 +402,7 @@ async function cmdMembers () {
399
402
  // se mira quién es quién — y en la lista de variables ya seria tarde.
400
403
  if (m.cn && !m.canSeal) console.log(' %ssin llave de cifrado: NO puede leer sus variables%s', R, Z)
401
404
  }
402
- console.log('\n Cambiar permisos: dotrino-vault caps <ID> +firma | -firma | +guarda | -guarda | +lee | -lee | +administra')
405
+ console.log('\n Cambiar permisos: dotrino-vault caps <ID> +firma | -firma | +guarda | -guarda | +lee | -lee | +administra | +aprueba')
403
406
  console.log(' «Administra» deja conectar y quitar dispositivos desde ese aparato, sin venir aquí.')
404
407
  console.log(' No deja cambiar permisos ni traspasar el mando: eso solo se hace en esta máquina.')
405
408
  console.log(' Los servicios solo pueden abrir las claves de su propio nombre; eso no se cambia aquí.\n')
@@ -440,16 +443,42 @@ async function findMember (id) {
440
443
  return m
441
444
  }
442
445
 
446
+ /**
447
+ * `dotrino-vault ssh` — cómo usar la llave SSH del teléfono desde este PC. La llave
448
+ * privada no está aquí: el daemon expone un agente SSH y cada firma la aprueba el
449
+ * teléfono (`caps <ID> +aprueba`). Imprime lo que hay que poner en el shell.
450
+ */
451
+ async function cmdSsh (args = []) {
452
+ const s = requireDaemon()
453
+ if (!s.sshAgent) { console.error('El daemon no tiene el agente SSH activo (DOTRINO_VAULT_SSH_AGENT=0, o Windows).'); process.exit(1) }
454
+ if (args[0] === 'keys' || args[0] === '-L') {
455
+ try { execFileSync('ssh-add', ['-L'], { env: { ...process.env, SSH_AUTH_SOCK: s.sshAgent }, stdio: 'inherit' }) } catch (_) { process.exit(1) }
456
+ return
457
+ }
458
+ console.log('# La llave SSH vive en tu teléfono; este PC solo le pide la firma. Pon en tu shell:')
459
+ console.log('export SSH_AUTH_SOCK=%s', s.sshAgent)
460
+ console.log('')
461
+ console.log('# Registra la llave desde el teléfono (vault.dotrino.com → Llave SSH) y pega su línea')
462
+ console.log('# pública en ~/.ssh/authorized_keys de cada servidor. Para no aprobar cada comando,')
463
+ console.log('# reusa la conexión 15 min en ~/.ssh/config:')
464
+ console.log('# Host *')
465
+ console.log('# ControlMaster auto')
466
+ console.log('# ControlPath ~/.ssh/cm-%r@%h:%p')
467
+ console.log('# ControlPersist 15m')
468
+ console.log('')
469
+ console.log('# Ver las llaves registradas: dotrino-vault ssh keys')
470
+ }
471
+
443
472
  /** `dotrino-vault caps <ID> ±permiso` — cambia lo que puede hacer un dispositivo. */
444
473
  async function cmdCaps (args = []) {
445
474
  const [id, ...changes] = args
446
475
  if (!id || !changes.length) {
447
- console.error('uso: dotrino-vault caps <ID> +firma|-firma|+guarda|-guarda|+lee|-lee|+administra|-administra')
476
+ console.error('uso: dotrino-vault caps <ID> +firma|-firma|+guarda|-guarda|+lee|-lee|+administra|-administra|+aprueba|-aprueba')
448
477
  process.exit(2)
449
478
  }
450
479
  const CAP_BY_WORD = {
451
- firma: 'sign', guarda: 'store', lee: 'read', administra: 'admin',
452
- sign: 'sign', store: 'store', read: 'read', admin: 'admin'
480
+ firma: 'sign', guarda: 'store', lee: 'read', administra: 'admin', aprueba: 'approve',
481
+ sign: 'sign', store: 'store', read: 'read', admin: 'admin', approve: 'approve'
453
482
  }
454
483
  const s = requireDaemon()
455
484
  const m = await findMember(id)
@@ -708,6 +737,7 @@ async function cmdSecret (rest) {
708
737
  ' dotrino-vault secret device rm <ID> <CLAVE>',
709
738
  ' dotrino-vault secret list',
710
739
  ' dotrino-vault secret migrate SELLA los secretos (una vez)',
740
+ ' dotrino-vault secret policy <ns> approval on|off cada lectura espera tu aprobación (teléfono)',
711
741
  '',
712
742
  'CARGA LA CONFIGURACIÓN DE UN SERVICIO DE UNA VEZ (`set` con varios pares, o `import`):',
713
743
  'la bóveda la aplica entera y avisa UNA sola vez. De una en una, cada variable es un',
@@ -818,6 +848,20 @@ async function cmdSecret (rest) {
818
848
  }
819
849
 
820
850
  if (sub === 'migrate') return secretMigrate()
851
+ // `secret policy <ns> approval on|off`: el cajón pasa a pedir el visto bueno de un
852
+ // aparato con `aprueba` (el teléfono) en cada lectura, con ventana de 15 min.
853
+ if (sub === 'policy') {
854
+ const [ns, what, val] = args
855
+ if (!isValidSecretsNs(ns || '') || what !== 'approval' || !['on', 'off'].includes(val)) {
856
+ console.error('uso: dotrino-vault secret policy <ns> approval on|off'); process.exit(2)
857
+ }
858
+ writeReq('secret-request.json', { op: 'policy', ns, approval: val === 'on' })
859
+ sendSignal(s.pid, 'SIGUSR2')
860
+ console.log(val === 'on'
861
+ ? `Listo: cada lectura de «${ns}» esperará la aprobación de un aparato con caps <ID> +aprueba (ventana de 15 min).`
862
+ : `Listo: «${ns}» vuelve a entregarse sin aprobación.`)
863
+ return
864
+ }
821
865
 
822
866
  // --- VER un valor, su histórico y volver atrás ---------------------------------
823
867
  // Las tres van juntas porque son la misma idea: ver es lo único que la contraseña
@@ -1291,6 +1335,7 @@ export async function runCtl (argv) {
1291
1335
  case 'caps': return cmdCaps(rest)
1292
1336
  case 'revoke': return cmdRevoke(rest[0])
1293
1337
  case 'secret': return cmdSecret(rest)
1338
+ case 'ssh': return cmdSsh(rest)
1294
1339
  case 'activity': return cmdActivity(Number(rest[0]) || 30)
1295
1340
  case 'logs': return cmdLogs()
1296
1341
  case 'version':
package/src/daemon.js CHANGED
@@ -17,6 +17,7 @@
17
17
  import fs from 'node:fs'
18
18
  import path from 'node:path'
19
19
  import { startVaultManager } from './manager.js'
20
+ import { startSshAgent, defaultSocketPath } from './sshAgent.js'
20
21
  import { dataDir, writeJson, readJson } from './paths.js'
21
22
  import { VERSION } from './version.js'
22
23
 
@@ -63,6 +64,14 @@ export async function runDaemon () {
63
64
 
64
65
  const mgr = await startVaultManager({ root: dir, proxyUrl, onEnrollChallenge })
65
66
 
67
+ // --- agente SSH (la llave vive en el teléfono; ver sshAgent.js). `DOTRINO_VAULT_SSH_AGENT=0`
68
+ // lo apaga; cualquier otro valor es la ruta del socket.
69
+ let sshAgent = null
70
+ if (process.env.DOTRINO_VAULT_SSH_AGENT !== '0' && process.platform !== 'win32') {
71
+ const socketPath = process.env.DOTRINO_VAULT_SSH_AGENT || defaultSocketPath(dir)
72
+ try { sshAgent = startSshAgent({ socketPath, vault: () => mgr.current(), log: console.log }) } catch (e) { console.error('[vault] ssh-agent could not start: %s', e.message) }
73
+ }
74
+
66
75
  // --- state.json ---
67
76
  const stateFile = path.join(dir, 'state.json')
68
77
  const daemonVersion = VERSION
@@ -73,7 +82,7 @@ export async function runDaemon () {
73
82
  const cur = mgr.summary().find((p) => p.current) || {}
74
83
  writeJson(stateFile, {
75
84
  v: 2, version: daemonVersion, fingerprint: cur.fingerprint || null, iss: cur.iss || null,
76
- proxy: proxyUrl, pid: process.pid, startedAt: new Date().toISOString(),
85
+ proxy: proxyUrl, pid: process.pid, startedAt: new Date().toISOString(), sshAgent: sshAgent?.socketPath || null,
77
86
  current: mgr.currentId(), profiles: mgr.summary()
78
87
  })
79
88
  }
@@ -146,7 +155,8 @@ export async function runDaemon () {
146
155
  // PERMISOS, no tipos (2026-08-22): el cert lleva lo que pidió `pair --scope`, y si
147
156
  // no pidió nada, el juego de siempre. `--service <ns>` sigue siendo el atajo de
148
157
  // `vault:secrets:<ns>`. Se valida aquí porque es la maestra la que firma: nada
149
- // que no esté en esta lista entra en un cert, y `vault:admin` nunca por este camino.
158
+ // que no esté en esta lista entra en un cert, y `vault:admin` / `vault:approve` nunca
159
+ // por este camino (se conceden a mano con `caps`).
150
160
  const ALLOWED = (x) => x === 'vault:sign' || x === 'vault:read' || x === 'vault:store' || /^vault:secrets:[a-z0-9-]{1,32}$/.test(x)
151
161
  const asked = Array.isArray(pairReq?.scope) ? pairReq.scope.filter((x) => typeof x === 'string') : null
152
162
  if (asked && asked.some((x) => !ALLOWED(x))) {
@@ -396,6 +406,7 @@ export async function runDaemon () {
396
406
  console.log('[vault] %d secret(s) applied in one go: %s', changed.length, sec.pub ? 'device' : sec.ns)
397
407
  } else if (sec.op === 'set') { await vault.setSecret(sec.ns, sec.key, sec.value, sec.public); console.log('[vault] secret saved: %s/%s', sec.ns, sec.key) }
398
408
  else if (sec.op === 'rm') { await vault.deleteSecret(sec.ns, sec.key); console.log('[vault] secret deleted: %s/%s', sec.ns, sec.key) }
409
+ else if (sec.op === 'policy') { const r = await vault.setSecretPolicy(sec.ns, { approval: !!sec.approval }); console.log('[vault] %s: approval %s', sec.ns, r.approval ? 'REQUIRED (15 min window)' : 'off') }
399
410
  else if (sec.op === 'dev-set') { await vault.setDeviceSecret(sec.pub, sec.key, sec.value, sec.public); console.log('[vault] device secret saved: %s', sec.key) }
400
411
  else if (sec.op === 'dev-rm') { await vault.deleteDeviceSecret(sec.pub, sec.key); console.log('[vault] device secret deleted: %s', sec.key) }
401
412
  // Saldar lo que quedó a deber: heredarle a un aparato nuevo lo ya guardado y
@@ -605,6 +616,7 @@ export async function runDaemon () {
605
616
  const shutdown = (sig) => {
606
617
  console.log(`\n[vault] ${sig} → deteniendo…`)
607
618
  rm(pairFile); rm(pendingEnrollFile)
619
+ try { sshAgent?.close() } catch (_) {}
608
620
  try { mgr.close() } catch (_) {}
609
621
  process.exit(0)
610
622
  }
@@ -159,6 +159,11 @@ export function openSecretsStore (dir, { sealer = null, recipients = null, signe
159
159
  if (!data) data = { schemaVersion: SCHEMA_VERSION, ns: {}, dev: {}, recovery: null, history: [] }
160
160
  if (!data.dev) data.dev = {}
161
161
  if (!data.history) data.history = []
162
+ // 0.47.0/0.48.0 guardaron la política DENTRO del cajón; se saca a `policies` al abrir.
163
+ for (const [ns, bag] of Object.entries(data.ns || {})) {
164
+ const stray = bag?.policy && typeof bag.policy === 'object' && !('v' in bag.policy) ? bag.policy : null
165
+ if (stray) { delete bag.policy; if (stray.approval) { if (!data.policies) data.policies = {}; data.policies[ns] = { approval: true } } }
166
+ }
162
167
 
163
168
  // v3 → v5 y v4 → v5 NO se hacen al abrir, y es deliberado: el daemon tiene que poder
164
169
  // arrancar y SERVIR sin nada (un perfil bloqueado sigue atendiendo a sus agentes). Un
@@ -588,6 +593,30 @@ export function openSecretsStore (dir, { sealer = null, recipients = null, signe
588
593
  for (const ns of Object.keys(data.ns)) out[ns] = names(data.ns, ns)
589
594
  return out
590
595
  },
596
+ /**
597
+ * POLÍTICA de un cajón. Hoy una sola: `approval` — cada lectura espera el visto bueno
598
+ * de un aparato con `approve` (ver `approvals.js`). Los cajones de los servicios que
599
+ * corren solos (proxio, geo) van sin ella: no pueden esperar a un teléfono.
600
+ *
601
+ * Vive APARTE de las variables (`data.policies`), no dentro del cajón: en el formato
602
+ * v3 el cajón ES el mapa de variables y cualquier cosa que se meta ahí se reparte como
603
+ * si fuera una (pasó: `policy` salió en la lista y en el bundle).
604
+ */
605
+ policyOf (ns) { assertNs(ns); return { approval: !!data.policies?.[ns]?.approval } },
606
+ setPolicy (ns, patch) {
607
+ assertNs(ns)
608
+ if (!data.policies) data.policies = {}
609
+ data.policies[ns] = { ...(data.policies[ns] || {}), ...patch }
610
+ if (!data.policies[ns].approval) delete data.policies[ns]
611
+ save()
612
+ return this.policyOf(ns)
613
+ },
614
+ /** Solo los cajones con alguna política puesta (ns → política). */
615
+ policies () {
616
+ const out = {}
617
+ for (const [ns, p] of Object.entries(data.policies || {})) if (p?.approval) out[ns] = { approval: true }
618
+ return out
619
+ },
591
620
  /** Nombres y visibilidad (pub → [{key, public}]), sin valores. */
592
621
  listDevices () {
593
622
  const out = {}
@@ -0,0 +1,2 @@
1
+ export * from '../lib/src/sshAgent.js'
2
+ export { default } from '../lib/src/sshAgent.js'
package/src/sshKeys.js ADDED
@@ -0,0 +1,2 @@
1
+ export * from '../lib/src/sshKeys.js'
2
+ export { default } from '../lib/src/sshKeys.js'
package/src/tui/app.js CHANGED
@@ -36,6 +36,7 @@ import { createTerm, widthOf } from './term.js'
36
36
  import { qrToString } from '../qr.js'
37
37
  import { dict, otherLang, loadLang, saveLang } from './i18n.js'
38
38
  import * as vc from '../vaultControl.js'
39
+ import { VERSION } from '../version.js'
39
40
 
40
41
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
41
42
 
@@ -1465,7 +1466,10 @@ function render (term, st) {
1465
1466
  const ap = activeProfile(st)
1466
1467
  const apTxt = ap ? `${t.accent('●')} ${t.bold(ap.name || i.noName)} ${lockGlyph(ap)} ${t.muted('· ' + (ap.fingerprint || '—'))}` : t.muted('—')
1467
1468
  lines[1] = ' ' + i.activeVault + apTxt
1468
- lines[2] = ''
1469
+ // El .deb instala el binario pero NO reinicia el servicio (y si se reinicia ANTES de
1470
+ // instalar, peor: el daemon se queda con el binario viejo, ya borrado, y hay dos copias
1471
+ // en RAM). `status` ya lo avisa; aquí también, que es donde uno se queda mirando.
1472
+ lines[2] = (up && s?.version && VERSION !== 'dev' && s.version !== VERSION) ? ' ' + t.warn(i.daemonStale(s.version, VERSION)) : ''
1469
1473
  // Dispositivos/Scopes son pestañas de la bóveda activa (se entra desde Bóvedas);
1470
1474
  // el resto muestra su título simple.
1471
1475
  lines[3] = INNER_TABS.includes(st.screen) ? ' ' + renderTabs(st, t) : ' ' + t.title('» ' + title(i, st.screen))
package/src/tui/i18n.js CHANGED
@@ -26,6 +26,7 @@ const es = {
26
26
  // encabezado / estado
27
27
  daemonRunning: 'corriendo',
28
28
  daemonStopped: 'DETENIDO',
29
+ daemonStale: (running, installed) => `⚠ el servicio corre ${running} y el binario instalado es ${installed}: reinícialo (systemctl --user restart dotrino-vault)`,
29
30
  activeVault: 'Bóveda activa: ',
30
31
  noName: '(sin nombre)',
31
32
  tooSmall: 'Terminal muy pequeño',
@@ -324,6 +325,7 @@ const en = {
324
325
 
325
326
  daemonRunning: 'running',
326
327
  daemonStopped: 'STOPPED',
328
+ daemonStale: (running, installed) => `⚠ the service runs ${running} but the installed binary is ${installed}: restart it (systemctl --user restart dotrino-vault)`,
327
329
  activeVault: 'Active vault: ',
328
330
  tooSmall: 'Terminal too small',
329
331
  tooSmallHint: (cols, rows) => `Resize to ≥ 24×9 (now ${cols}×${rows}).`,
package/src/vault.js CHANGED
@@ -22,6 +22,8 @@ import { createTransport, masterPubkeyOf } from './transport.js'
22
22
  import { openStore } from './store.js'
23
23
  import { openThreadStore, STORE_READ_METHODS, PROFILE_EDIT_METHODS } from './threadStore.js'
24
24
  import { openSecretsStore, assertVar } from './secretsStore.js'
25
+ import { createApprovals } from './approvals.js'
26
+ import { parsePublicKey, sshSignature } from './sshKeys.js'
25
27
  import { makeSealer } from './sealer.js'
26
28
  import { openSealKeys } from './sealKey.js'
27
29
  import { seal } from '../lib/src/sealed.js'
@@ -69,6 +71,8 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
69
71
 
70
72
  const store = openStore(dir)
71
73
  const threads = openThreadStore(dir)
74
+ const approvals = createApprovals()
75
+ const approvalsSweeper = setInterval(() => { for (const g of approvals.sweep()) audit(g.kind === 'ssh' ? 'ssh.expired' : 'secrets.expired', { id: g.id, ns: g.ns, device: g.deviceId }) }, 30 * 1000); approvalsSweeper.unref?.()
72
76
  const secrets = openSecretsStore(dir, {
73
77
  sealer: makeSealer(),
74
78
  // A QUIÉN se le envuelve la llave de cada cajón: los servicios de ese namespace (o el
@@ -485,6 +489,8 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
485
489
  // da acceso a nada por sí solo. Quien firma esta petición ya tiene la llave de firma
486
490
  // del servicio y su cert, o sea que ya lee ese namespace. No hay escalada.
487
491
  if (p.data?.op === 'enckey') return handleEncKey(from, p)
492
+ if (['approvals', 'approve', 'deny', 'ssh.keys', 'ssh.key.add', 'ssh.key.rm'].includes(p.data?.op)) return handleApproval(from, p)
493
+ if (p.data?.op === 'ssh.sign' || p.data?.op === 'ssh.keys.public') return handleSshRemote(from, p)
488
494
  const ns = p.data?.ns
489
495
  if (!isValidSecretsNs(ns)) return reply(from, { type: MSG.ERROR, error: 'secrets: invalid namespace' })
490
496
  if (typeof p.data?.ek !== 'string') return reply(from, { type: MSG.ERROR, error: 'secrets: missing ek (requester ephemeral key)' })
@@ -501,29 +507,202 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
501
507
  audit('rejected', { what: 'secrets', ns, reason: 'cn' })
502
508
  return reply(from, { type: MSG.ERROR, error: `unauthorized: cn — the record does not recognise this member as the "${ns}" service` })
503
509
  }
504
- let enc
505
- try {
506
- // Mientras el archivo siga en v3 el cable NO cambia: se mandan los valores como
507
- // siempre. Solo tras la migración viajan sobres, y entonces quien los abre es el
508
- // agente con su llave. Así el despliegue del daemon se deshace con un reinicio,
509
- // porque hasta el primer desbloqueo no ha cambiado nada de lo que ve nadie.
510
- const b = secrets.bundleFor(ns, chk.device)
511
- // EL ACTA VIAJA CON EL BUNDLE (§8.8): es lo que le permite al agente comprobar que
512
- // los sobres los selló esta bóveda, y con qué llave —la que el acta nombra para el
513
- // `seq` con el que se firmaron—. No es un dato secreto: el acta es pública dentro
514
- // del perfil y el agente ya es miembro. Sin ella podría abrir igual, pero no sabría
515
- // de dónde salió lo que abre.
516
- const payload = b.legacy
517
- ? { secrets: Object.fromEntries(Object.entries(b.entries).map(([k, e]) => [k, e.v])) }
518
- : { sealed: b, acta: record || null }
519
- enc = await seal({ ek: p.data.ek, payload })
520
- } catch (e) {
510
+ // APROBACIÓN POR USO: si el cajón la exige y este aparato no tiene la ventana abierta,
511
+ // el pedido se apunta, se avisa a quien aprueba y se contesta «pendiente». La respuesta
512
+ // de verdad sale cuando el teléfono firme (`handleApproval`), sellada a la misma `ek`.
513
+ if (secrets.policyOf(ns).approval && !approvals.has(ns, chk.device)) {
514
+ const deviceId = await deviceIdOf(chk.device).catch(() => null)
515
+ const label = (record?.members || []).find((m) => m.pub === chk.device)?.label || ''
516
+ const pend = approvals.request({ ns, device: chk.device, deviceId, label, ek: p.data.ek })
517
+ audit('secrets.pending', { device: deviceId, ns, id: pend.id })
518
+ log(`[vault] ${ns}: ${deviceId || '????-????'} is waiting for approval (${pend.id})`)
519
+ const body = { op: 'secrets.pending', ns, id: pend.id, exp: pend.exp, ts: Date.now() }
520
+ const { signature } = await identity.signData(body)
521
+ reply(from, { type: MSG.SECRETS_RESULT, body, signature })
522
+ await notifyApprovers(pend, record)
523
+ return
524
+ }
525
+ let res
526
+ try { res = await resultFor(ns, chk.device, p.data.ek, record) } catch (e) {
521
527
  return reply(from, { type: MSG.ERROR, error: 'secrets: invalid ek' })
522
528
  }
529
+ audit('secrets', { device: await deviceIdOf(chk.device), ns })
530
+ reply(from, { type: MSG.SECRETS_RESULT, ...res })
531
+ }
532
+
533
+ /** El bundle de `ns` para `devicePub`, sellado a su `ek` y firmado por la maestra. */
534
+ async function resultFor (ns, devicePub, ek, record) {
535
+ // Mientras el archivo siga en v3 el cable NO cambia: se mandan los valores como
536
+ // siempre. Solo tras la migración viajan sobres, y entonces quien los abre es el
537
+ // agente con su llave. Así el despliegue del daemon se deshace con un reinicio,
538
+ // porque hasta el primer desbloqueo no ha cambiado nada de lo que ve nadie.
539
+ const b = secrets.bundleFor(ns, devicePub)
540
+ // EL ACTA VIAJA CON EL BUNDLE (§8.8): es lo que le permite al agente comprobar que
541
+ // los sobres los selló esta bóveda, y con qué llave —la que el acta nombra para el
542
+ // `seq` con el que se firmaron—. No es un dato secreto: el acta es pública dentro
543
+ // del perfil y el agente ya es miembro. Sin ella podría abrir igual, pero no sabría
544
+ // de dónde salió lo que abre.
545
+ const payload = b.legacy
546
+ ? { secrets: Object.fromEntries(Object.entries(b.entries).map(([k, e]) => [k, e.v])) }
547
+ : { sealed: b, acta: record || null }
548
+ const enc = await seal({ ek, payload })
523
549
  const body = { op: 'secrets.result', ns, enc, ts: Date.now() }
524
550
  const { signature } = await identity.signData(body)
525
- audit('secrets', { device: await deviceIdOf(chk.device), ns })
526
- reply(from, { type: MSG.SECRETS_RESULT, body, signature })
551
+ return { body, signature }
552
+ }
553
+
554
+ // ---------- LLAVES SSH DEL TELÉFONO (`sshKeys.js`) ----------
555
+ // Aquí solo viven las PÚBLICAS: la privada está en el aparato que aprueba. `ssh` en este
556
+ // PC habla con el agente del daemon (`sshAgent.js`), y cada firma es un pedido más.
557
+ const sshKeysFile = path.join(dir, 'ssh-keys.json')
558
+ const atRest = atRestFor(dir)
559
+ const readSshKeys = () => { const d = readJsonAt(sshKeysFile); return Array.isArray(d?.keys) ? d.keys : [] }
560
+ function readJsonAt (file) { try { return JSON.parse(atRest.decrypt(fs.readFileSync(file, 'utf8'))) } catch (_) { return null } }
561
+ const writeSshKeys = (keys) => fs.writeFileSync(sshKeysFile, atRest.encrypt(JSON.stringify({ v: 1, keys })), { mode: 0o600 })
562
+ const sshKeys = () => readSshKeys().map((k) => ({ id: k.id, type: k.type, blob: k.blob, comment: k.comment, deviceId: k.deviceId, addedAt: k.addedAt }))
563
+
564
+ /**
565
+ * FIRMAR UN RETO SSH: lo pide el agente local (el `ssh` del usuario) y lo resuelve el
566
+ * teléfono. No hay ventana: cada conexión es un «sí» — para no repetirlo veinte veces
567
+ * está `ControlMaster` en el `ssh_config`, que reusa la conexión 15 min.
568
+ */
569
+ function requestSshSign ({ keyId, data, askedBy = null, onPending = null }) {
570
+ const key = readSshKeys().find((k) => k.id === keyId)
571
+ if (!key) return Promise.reject(new Error('ssh: unknown key'))
572
+ return new Promise((resolve, reject) => {
573
+ const pend = approvals.request({
574
+ kind: 'ssh', ns: 'ssh', deviceId: askedBy || (fp.slice(0, 4).toUpperCase() + '-' + fp.slice(4, 8).toUpperCase()), label: 'ssh',
575
+ ssh: { key: key.id, comment: key.comment, data: Buffer.from(data).toString('base64') },
576
+ resolve, reject
577
+ })
578
+ try { onPending?.(pend) } catch (_) {}
579
+ audit('ssh.pending', { key: key.id, id: pend.id, device: askedBy || null })
580
+ log(`[vault] ssh: ${key.comment || key.id} is waiting for the phone to sign (${pend.id})`)
581
+ identity.profileActa?.().catch(() => null).then((r) => notifyApprovers(pend, r?.acta))
582
+ })
583
+ }
584
+
585
+ /**
586
+ * AGENTE SSH DELGADO en otra máquina (`dotrino-env ssh-agent`): un aparato con `vault:sign`
587
+ * pide que un reto se firme con una llave del teléfono. No hay nada que proteger en el
588
+ * que pide: solo convierte el reto en un pedido; quien firma es el teléfono.
589
+ */
590
+ async function handleSshRemote (from, p) {
591
+ const chk = await verifyChain({
592
+ data: p.data, signature: p.signature, cert: p.cert,
593
+ expectedScope: SCOPE.SIGN, trustedIssuer: master, revoked: await revocationSet()
594
+ })
595
+ if (!chk.ok) return denyChain(from, chk, p, 'ssh')
596
+ const answer = async (body) => {
597
+ body = { ...body, ts: Date.now() }
598
+ const { signature } = await identity.signData(body)
599
+ reply(from, { type: MSG.SECRETS_RESULT, body, signature })
600
+ }
601
+ if (p.data.op === 'ssh.keys.public') return answer({ op: 'ssh.keys.public', items: sshKeys().map((k) => ({ id: k.id, blob: k.blob, comment: k.comment })) })
602
+ const key = readSshKeys().find((k) => k.id === p.data.key)
603
+ if (!key || typeof p.data.data !== 'string') return reply(from, { type: MSG.ERROR, error: 'ssh: unknown key' })
604
+ const asker = await deviceIdOf(chk.device).catch(() => null)
605
+ let pendId = null
606
+ const done = requestSshSign({ keyId: key.id, data: Buffer.from(p.data.data, 'base64'), askedBy: asker, onPending: (pend) => { pendId = pend.id } })
607
+ await answer({ op: 'ssh.pending', id: pendId, exp: Date.now() + 5 * 60 * 1000 })
608
+ try {
609
+ const sig = await done
610
+ await answer({ op: 'ssh.sign.result', sig: Buffer.from(sig).toString('base64') })
611
+ } catch (e) { reply(from, { type: MSG.ERROR, error: e.message }) }
612
+ }
613
+
614
+ /**
615
+ * PEDIDOS DE APROBACIÓN (cajones con `approval`). Entran por `vault.secrets` con
616
+ * `op: approvals | approve | deny`, firmados por un aparato con `vault:approve` — que,
617
+ * como `admin`, no se empareja: se concede a mano (`caps <ID> +aprueba`). El acta tiene
618
+ * que decirlo también, para que quitar el permiso surta efecto en el acto.
619
+ */
620
+ async function handleApproval (from, p) {
621
+ const op = p.data?.op
622
+ const chk = await verifyChain({
623
+ data: p.data, signature: p.signature, cert: p.cert,
624
+ expectedScope: SCOPE.APPROVE, trustedIssuer: master, revoked: await revocationSet()
625
+ })
626
+ if (!chk.ok) return denyChain(from, chk, p, 'approval')
627
+ const record = (await identity.profileActa?.().catch(() => null))?.acta
628
+ if (record && !Acta.memberCan(record, chk.device, 'approve')) {
629
+ audit('rejected', { what: 'approval', reason: 'acta' })
630
+ return reply(from, { type: MSG.ERROR, error: 'unauthorized: acta — this member does not approve' })
631
+ }
632
+ const by = await deviceIdOf(chk.device).catch(() => null)
633
+ const answer = async (body) => {
634
+ body = { ...body, ts: Date.now() }
635
+ const { signature } = await identity.signData(body)
636
+ reply(from, { type: MSG.SECRETS_RESULT, body, signature })
637
+ }
638
+ if (op === 'approvals') return answer({ op: 'approvals', items: approvals.list() })
639
+ if (op === 'ssh.keys') return answer({ op: 'ssh.keys', items: sshKeys() })
640
+ if (op === 'ssh.key.add') {
641
+ let k
642
+ try { k = parsePublicKey(p.data?.pub) } catch (e) { return reply(from, { type: MSG.ERROR, error: e.message }) }
643
+ const keys = readSshKeys().filter((x) => x.id !== k.id)
644
+ keys.push({ ...k, deviceId: by, addedAt: Date.now() })
645
+ writeSshKeys(keys)
646
+ audit('ssh.key.add', { key: k.id, by })
647
+ log(`[vault] ssh: key ${k.id} (${k.comment || 'no comment'}) registered by ${by}`)
648
+ return answer({ op: 'ssh.key.add', id: k.id, ok: true })
649
+ }
650
+ if (op === 'ssh.key.rm') {
651
+ const keys = readSshKeys(); const n = keys.length
652
+ writeSshKeys(keys.filter((x) => x.id !== p.data?.id))
653
+ if (keys.length !== n) audit('ssh.key.rm', { key: p.data?.id, by })
654
+ return answer({ op: 'ssh.key.rm', ok: true })
655
+ }
656
+ const id = typeof p.data?.id === 'string' ? p.data.id : ''
657
+ const pend = approvals.take(id)
658
+ if (!pend) return reply(from, { type: MSG.ERROR, error: 'approval: unknown or expired request' })
659
+ if (pend.kind === 'ssh') {
660
+ if (op === 'deny') {
661
+ audit('ssh.denied', { key: pend.ssh.key, id, by })
662
+ try { pend.reject?.(new Error('ssh: denied from ' + by)) } catch (_) {}
663
+ return answer({ op: 'deny.result', id, ok: true })
664
+ }
665
+ if (op !== 'approve') return reply(from, { type: MSG.ERROR, error: 'approval: unknown op' })
666
+ const key = readSshKeys().find((k) => k.id === pend.ssh.key)
667
+ let blob
668
+ try { blob = sshSignature({ jwk: key.jwk, data: Buffer.from(pend.ssh.data, 'base64'), rawSig: p.data?.sig }) } catch (e) {
669
+ try { pend.reject?.(e) } catch (_) {}
670
+ audit('rejected', { what: 'ssh.sign', reason: e.message })
671
+ return reply(from, { type: MSG.ERROR, error: e.message })
672
+ }
673
+ audit('ssh.signed', { key: key.id, id, by })
674
+ log(`[vault] ssh: ${key.comment || key.id} signed by ${by}`)
675
+ try { pend.resolve?.(blob) } catch (_) {}
676
+ return answer({ op: 'approve.result', id, ok: true })
677
+ }
678
+ if (op === 'deny') {
679
+ audit('secrets.denied', { device: pend.deviceId, ns: pend.ns, id, by })
680
+ log(`[vault] ${pend.ns}: request of ${pend.deviceId} DENIED by ${by}`)
681
+ try { client.sendByPubkey(pend.device, { type: MSG.ERROR, error: `unauthorized: denied — the "${pend.ns}" request was denied from ${by}` }) } catch (_) {}
682
+ return answer({ op: 'deny.result', id, ok: true })
683
+ }
684
+ if (op !== 'approve') return reply(from, { type: MSG.ERROR, error: 'approval: unknown op' })
685
+ const exp = approvals.grant(pend.ns, pend.device)
686
+ let res
687
+ try { res = await resultFor(pend.ns, pend.device, pend.ek, record) } catch (e) {
688
+ return reply(from, { type: MSG.ERROR, error: 'approval: could not seal the reply: ' + e.message })
689
+ }
690
+ audit('secrets.approved', { device: pend.deviceId, ns: pend.ns, id, by, until: exp })
691
+ log(`[vault] ${pend.ns}: request of ${pend.deviceId} approved by ${by} (window until ${new Date(exp).toISOString()})`)
692
+ // Va por `sendByPubkey`: si el que pedía ya no está conectado, lo recoge al volver.
693
+ try { client.sendByPubkey(pend.device, { type: MSG.SECRETS_RESULT, ...res }) } catch (_) {}
694
+ return answer({ op: 'approve.result', id, ok: true, exp })
695
+ }
696
+
697
+ /** Aviso a los aparatos que aprueban (cola del proxio → push nativo si están apagados). */
698
+ async function notifyApprovers (pend, record) {
699
+ try {
700
+ const body = { ev: 'approval', id: pend.id, ns: pend.ns, deviceId: pend.deviceId, label: pend.label, exp: pend.exp, ts: Date.now() }
701
+ const { signature } = await identity.signData(body)
702
+ const who = (record?.members || []).filter((m) => Acta.memberCan(record, m.pub, 'approve')).map((m) => m.pub)
703
+ if (!who.length) log(`[vault] ${pend.ns}: nobody can approve (grant it with: dotrino-vault caps <ID> +aprueba)`)
704
+ for (const pub of who) { try { client.sendByPubkey(pub, { type: MSG.ADMIN_EVENT, body, signature }) } catch (_) {} }
705
+ } catch (e) { log('[vault] could not notify approvers:', e.message) }
527
706
  }
528
707
 
529
708
  client.on('message', async (from, payload) => {
@@ -1438,6 +1617,13 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
1438
1617
  startPairing: desk.startPairing,
1439
1618
  stopPairing: desk.stopPairing,
1440
1619
  listPending: desk.listPending,
1620
+ // Cajones con aprobación por uso (`approvals.js`).
1621
+ secretPolicy: (ns) => secrets.policyOf(ns),
1622
+ setSecretPolicy: async (ns, patch) => { const r = secrets.setPolicy(ns, patch); audit('secret.policy', { ns, ...r }); return r },
1623
+ secretPolicies: () => secrets.policies(),
1624
+ listApprovals: () => approvals.list(),
1625
+ sshKeys,
1626
+ requestSshSign,
1441
1627
  // Aprobar desde el PC avisa igual que aprobar a distancia: el resto de tus
1442
1628
  // dispositivos se entera de que entró alguien, venga de donde venga.
1443
1629
  approveDevice: async (code, adminKey) => {
@@ -1537,6 +1723,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
1537
1723
  return r
1538
1724
  },
1539
1725
  close () {
1726
+ clearInterval(approvalsSweeper)
1540
1727
  for (const t of pendingNotices.values()) clearTimeout(t)
1541
1728
  pendingNotices.clear()
1542
1729
  try { client.close() } catch (_) {} identity.destroy()