@dotrino/vaultd 0.46.2 → 0.49.1
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 +44 -2
- package/lib/README.md +7 -0
- package/lib/src/env.js +2 -2
- package/lib/src/protocol.js +2 -1
- package/lib/src/service.js +70 -5
- package/lib/src/sshAgent.js +100 -0
- package/lib/src/sshKeys.js +76 -0
- package/package.json +2 -2
- package/src/approvals.js +69 -0
- package/src/ctl.js +50 -5
- package/src/daemon.js +14 -2
- package/src/secretsStore.js +29 -0
- package/src/sshAgent.js +2 -0
- package/src/sshKeys.js +2 -0
- package/src/tui/app.js +5 -1
- package/src/tui/i18n.js +2 -0
- package/src/vault.js +206 -19
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) {
|
package/lib/src/protocol.js
CHANGED
|
@@ -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
|
/**
|
package/lib/src/service.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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.
|
|
3
|
+
"version": "0.49.1",
|
|
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.
|
|
23
|
+
"@dotrino/identity": "^0.58.0",
|
|
24
24
|
"@dotrino/proxy-client": "^0.10.1",
|
|
25
25
|
"ws": "^8.18.0"
|
|
26
26
|
},
|
package/src/approvals.js
ADDED
|
@@ -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`
|
|
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
|
}
|
package/src/secretsStore.js
CHANGED
|
@@ -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 = {}
|
package/src/sshAgent.js
ADDED
package/src/sshKeys.js
ADDED
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
|
-
|
|
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
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
const
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
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
|
-
|
|
526
|
-
|
|
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()
|