@dotrino/vaultd 0.49.1 → 0.52.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 +25 -31
- package/lib/README.md +6 -5
- package/lib/src/enroll.js +1 -1
- package/lib/src/index.js +124 -7
- package/lib/src/invite.js +6 -4
- package/lib/src/protocol.js +5 -1
- package/lib/src/service.js +1 -49
- package/lib/src/sshAgent.js +21 -27
- package/lib/src/sshKeys.js +87 -45
- package/package.json +4 -3
- package/src/approvals.js +16 -36
- package/src/ctl.js +50 -125
- package/src/daemon.js +11 -13
- package/src/passwords.js +81 -0
- package/src/secretsStore.js +3 -29
- package/src/tui/app.js +1 -1
- package/src/tui/i18n.js +10 -6
- package/src/vault.js +239 -114
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dotrino/vaultd",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.52.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,8 +20,9 @@
|
|
|
20
20
|
"node": ">=20"
|
|
21
21
|
},
|
|
22
22
|
"dependencies": {
|
|
23
|
-
"@dotrino/identity": "^0.
|
|
24
|
-
"@dotrino/
|
|
23
|
+
"@dotrino/identity": "^0.61.0",
|
|
24
|
+
"@dotrino/passmanager": "^0.1.0",
|
|
25
|
+
"@dotrino/proxy-client": "^0.13.1",
|
|
25
26
|
"ws": "^8.18.0"
|
|
26
27
|
},
|
|
27
28
|
"license": "MIT",
|
package/src/approvals.js
CHANGED
|
@@ -1,38 +1,30 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* PEDIDOS DE APROBACIÓN
|
|
2
|
+
* PEDIDOS DE APROBACIÓN (puro: sin red ni disco).
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* Un aparato marcado con `approval on` no recibe claves privadas solo por tener el cert: el
|
|
5
|
+
* vault apunta el pedido, avisa a los aparatos con `approve` (el teléfono), y solo su firma
|
|
6
|
+
* libera los secretos. No hay ventana: cada petición es un pedido, y para un servicio bien
|
|
7
|
+
* hecho eso es UNA por arranque (pide al iniciar, se queda las claves en memoria). Lo que
|
|
8
|
+
* nadie aprueba vence solo a los 5 min.
|
|
8
9
|
*
|
|
9
|
-
* Lo que se guarda de cada pedido es lo justo para contestar después: el cajón, quién
|
|
10
|
-
*
|
|
10
|
+
* Lo que se guarda de cada pedido es lo justo para contestar después: el cajón, quién pide
|
|
11
|
+
* y su llave efímera `ek` (a la que se sella la respuesta). Nunca un valor.
|
|
11
12
|
*/
|
|
12
13
|
const rnd = () => [...crypto.getRandomValues(new Uint8Array(8))].map((b) => b.toString(16).padStart(2, '0')).join('')
|
|
13
14
|
|
|
14
15
|
export const PENDING_TTL_MS = 5 * 60 * 1000
|
|
15
|
-
export const GRANT_TTL_MS = 15 * 60 * 1000
|
|
16
16
|
|
|
17
|
-
export function createApprovals ({ now = Date.now, pendingTtlMs = PENDING_TTL_MS
|
|
17
|
+
export function createApprovals ({ now = Date.now, pendingTtlMs = PENDING_TTL_MS } = {}) {
|
|
18
18
|
/** id → { id, ns, device, deviceId, label, ek, ts, exp } */
|
|
19
19
|
const pending = new Map()
|
|
20
|
-
|
|
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 } : {}) })
|
|
20
|
+
const publicOf = (p) => ({ id: p.id, ns: p.ns, deviceId: p.deviceId, label: p.label, ts: p.ts, exp: p.exp })
|
|
25
21
|
|
|
26
22
|
return {
|
|
27
|
-
/**
|
|
28
|
-
|
|
29
|
-
|
|
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)
|
|
23
|
+
/** Apunta un pedido. Uno por cajón y aparato: pedir otra vez reemplaza al anterior. */
|
|
24
|
+
request ({ ns, device, deviceId, label = '', ek }) {
|
|
25
|
+
for (const [id, p] of pending) if (p.ns === ns && p.device === device) pending.delete(id)
|
|
34
26
|
const ts = now()
|
|
35
|
-
const p = { id: rnd(),
|
|
27
|
+
const p = { id: rnd(), ns, device, deviceId, label, ek, ts, exp: ts + pendingTtlMs }
|
|
36
28
|
pending.set(p.id, p)
|
|
37
29
|
return publicOf(p)
|
|
38
30
|
},
|
|
@@ -45,25 +37,13 @@ export function createApprovals ({ now = Date.now, pendingTtlMs = PENDING_TTL_MS
|
|
|
45
37
|
if (p) pending.delete(id)
|
|
46
38
|
return p || null
|
|
47
39
|
},
|
|
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
40
|
/** Tira lo vencido; devuelve los pedidos que vencieron, para anotarlos. */
|
|
60
41
|
sweep () {
|
|
61
42
|
const t = now(); const gone = []
|
|
62
|
-
for (const [id, p] of pending) if (p.exp <= t) { pending.delete(id); gone.push(publicOf(p))
|
|
63
|
-
for (const [k, exp] of grants) if (exp <= t) grants.delete(k)
|
|
43
|
+
for (const [id, p] of pending) if (p.exp <= t) { pending.delete(id); gone.push(publicOf(p)) }
|
|
64
44
|
return gone
|
|
65
45
|
}
|
|
66
46
|
}
|
|
67
47
|
}
|
|
68
48
|
|
|
69
|
-
export default { createApprovals, PENDING_TTL_MS
|
|
49
|
+
export default { createApprovals, PENDING_TTL_MS }
|
package/src/ctl.js
CHANGED
|
@@ -128,7 +128,6 @@ function cmdStatus () {
|
|
|
128
128
|
}
|
|
129
129
|
console.log(' fingerprint : %s', s.fingerprint)
|
|
130
130
|
console.log(' proxy : %s', s.proxy)
|
|
131
|
-
if (s.sshAgent) console.log(' ssh-agent : %s (dotrino-vault ssh)', s.sshAgent)
|
|
132
131
|
console.log(' pid : %s%s', s.pid, up ? '' : ' (no responde)')
|
|
133
132
|
console.log(' datos : %s', dir)
|
|
134
133
|
const profiles = s.profiles || []
|
|
@@ -188,21 +187,27 @@ async function cmdPair (args = []) {
|
|
|
188
187
|
// `secrets:<ns>`, y los dos se combinan (`--service eco --scope sign` = un bot que
|
|
189
188
|
// firma como aparato del acta y lee solo su cajón). `admin` no se empareja: se
|
|
190
189
|
// concede desde el PC (`caps <ID> +administra`).
|
|
190
|
+
// --approval: el aparato que entre pedirá tu aprobación (teléfono) en cada petición de
|
|
191
|
+
// claves privadas. Por defecto NO pide; se cambia después con `caps <ID> +permiso`.
|
|
192
|
+
const approval = args.includes('--approval')
|
|
191
193
|
const scIdx = args.indexOf('--scope')
|
|
192
194
|
let scope = null
|
|
193
195
|
if (scIdx >= 0) {
|
|
194
196
|
const raw = args[scIdx + 1]
|
|
195
197
|
if (!raw || raw.startsWith('-')) { console.error('uso: dotrino-vault pair --scope sign,read,store,secrets:<ns>'); process.exit(2) }
|
|
196
|
-
const ALIAS = { firma: 'sign', lee: 'read', guarda: 'store' }
|
|
198
|
+
const ALIAS = { firma: 'sign', lee: 'read', guarda: 'store', contrasenas: 'passwords', 'contraseñas': 'passwords' }
|
|
197
199
|
scope = []
|
|
198
200
|
for (const tok of raw.split(',').map((t) => t.trim()).filter(Boolean)) {
|
|
199
201
|
const t = ALIAS[tok] || tok
|
|
200
202
|
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
203
|
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) }
|
|
202
204
|
if (t === 'sign' || t === 'read' || t === 'store') { scope.push('vault:' + t); continue }
|
|
205
|
+
// El gestor de contraseñas SÍ se empareja con su permiso puesto: es lo único que
|
|
206
|
+
// va a hacer ese aparato, y pedirlo en dos pasos era el paso que nadie daba.
|
|
207
|
+
if (t === 'passwords') { scope.push('vault:passwords'); continue }
|
|
203
208
|
const m = /^secrets:([a-z0-9-]{1,32})$/.exec(t)
|
|
204
209
|
if (m) { scope.push('vault:secrets:' + m[1]); continue }
|
|
205
|
-
console.error('permiso desconocido: %s (sign | read | store | secrets:<ns>)', tok); process.exit(2)
|
|
210
|
+
console.error('permiso desconocido: %s (sign | read | store | contrasenas | secrets:<ns>)', tok); process.exit(2)
|
|
206
211
|
}
|
|
207
212
|
if (service) scope.push('vault:secrets:' + service)
|
|
208
213
|
scope = [...new Set(scope)]
|
|
@@ -238,7 +243,7 @@ async function cmdPair (args = []) {
|
|
|
238
243
|
}
|
|
239
244
|
// La petición se escribe SIEMPRE (aunque no haya --service): lleva a qué perfil
|
|
240
245
|
// se empareja el dispositivo.
|
|
241
|
-
writeReq('pair-request.json', { ...(service ? { service } : {}), ...(scope ? { scope } : {}), ...(adopt ? { mode: 'adopt' } : {}) })
|
|
246
|
+
writeReq('pair-request.json', { ...(service ? { service } : {}), ...(scope ? { scope } : {}), ...(adopt ? { mode: 'adopt' } : {}), ...(approval ? { approval: true } : {}) })
|
|
242
247
|
sendSignal(s.pid, 'SIGUSR1')
|
|
243
248
|
|
|
244
249
|
let pair = null
|
|
@@ -265,14 +270,14 @@ async function cmdPair (args = []) {
|
|
|
265
270
|
console.log(`${R}${B}⚠ Este código deja LEER tus datos y FIRMAR con tu identidad.${Z}`)
|
|
266
271
|
console.log(`${R} NO lo compartas con nadie, ni con "soporte". Solo escanéalo en TU dispositivo.${Z}`)
|
|
267
272
|
console.log('\nO abre esta dirección en el dispositivo:\n ' + url)
|
|
268
|
-
console.log('\nO pega este código en vault.dotrino.com/
|
|
273
|
+
console.log('\nO pega este código en vault.dotrino.com/vault :\n ' + b64)
|
|
269
274
|
|
|
270
275
|
// --save [archivo]: escribe la invitación (.dpair) para transferirla y abrirla en profile.
|
|
271
276
|
const saveIdx = args.indexOf('--save')
|
|
272
277
|
if (saveIdx >= 0) {
|
|
273
278
|
const next = args[saveIdx + 1]
|
|
274
279
|
const file = (next && !next.startsWith('-')) ? next : 'dotrino-invite.dpair'
|
|
275
|
-
try { fs.writeFileSync(file, url + '\n', { mode: 0o600 }); console.log('\nInvitación guardada en: %s\n (ábrela en vault.dotrino.com/
|
|
280
|
+
try { fs.writeFileSync(file, url + '\n', { mode: 0o600 }); console.log('\nInvitación guardada en: %s\n (ábrela en vault.dotrino.com/vault → «Abrir imagen o archivo». Es efímera y de un solo uso; no la compartas.)', file) }
|
|
276
281
|
catch (e) { console.error('No se pudo guardar la invitación:', e.message) }
|
|
277
282
|
}
|
|
278
283
|
|
|
@@ -402,7 +407,8 @@ async function cmdMembers () {
|
|
|
402
407
|
// se mira quién es quién — y en la lista de variables ya seria tarde.
|
|
403
408
|
if (m.cn && !m.canSeal) console.log(' %ssin llave de cifrado: NO puede leer sus variables%s', R, Z)
|
|
404
409
|
}
|
|
405
|
-
console.log('\n Cambiar permisos: dotrino-vault caps <ID> +firma | -firma | +guarda | -guarda | +lee | -lee | +administra | +aprueba')
|
|
410
|
+
console.log('\n Cambiar permisos: dotrino-vault caps <ID> +firma | -firma | +guarda | -guarda | +lee | -lee | +administra | +aprueba | +contrasenas | +permiso')
|
|
411
|
+
console.log(' «Permiso»: ese aparato solo recibe claves privadas cuando lo apruebas desde un aparato con «aprueba» (en cada arranque).')
|
|
406
412
|
console.log(' «Administra» deja conectar y quitar dispositivos desde ese aparato, sin venir aquí.')
|
|
407
413
|
console.log(' No deja cambiar permisos ni traspasar el mando: eso solo se hace en esta máquina.')
|
|
408
414
|
console.log(' Los servicios solo pueden abrir las claves de su propio nombre; eso no se cambia aquí.\n')
|
|
@@ -444,47 +450,55 @@ async function findMember (id) {
|
|
|
444
450
|
}
|
|
445
451
|
|
|
446
452
|
/**
|
|
447
|
-
* `dotrino-vault
|
|
448
|
-
*
|
|
449
|
-
*
|
|
453
|
+
* `dotrino-vault approval <ID> on|off` — ese aparato solo recibe claves privadas con el
|
|
454
|
+
* visto bueno de un aparato con `aprueba` (el teléfono). Es propiedad del APARATO, no del
|
|
455
|
+
* cajón: el VPS desatendido no pide; la PC del dueño sí. Pide en cada petición, que para un
|
|
456
|
+
* servicio bien hecho es una por arranque.
|
|
450
457
|
*/
|
|
451
|
-
async function
|
|
452
|
-
const
|
|
453
|
-
if (!
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
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')
|
|
458
|
+
async function cmdApproval (args = []) {
|
|
459
|
+
const [id, val] = args
|
|
460
|
+
if (!id || !['on', 'off'].includes(val)) { console.error('uso: dotrino-vault approval <ID> on|off'); process.exit(2) }
|
|
461
|
+
const m = await findMember(id)
|
|
462
|
+
writeReq('secret-request.json', { op: 'approval', pub: m.pub, id: m.id, approval: val === 'on' })
|
|
463
|
+
sendSignal(requireDaemon().pid, 'SIGUSR2')
|
|
464
|
+
console.log(val === 'on'
|
|
465
|
+
? `Listo: ${m.id} solo recibirá claves privadas cuando un aparato con caps <ID> +aprueba lo apruebe (en cada arranque).`
|
|
466
|
+
: `Listo: ${m.id} vuelve a recibir sus claves sin aprobación.`)
|
|
470
467
|
}
|
|
471
468
|
|
|
472
469
|
/** `dotrino-vault caps <ID> ±permiso` — cambia lo que puede hacer un dispositivo. */
|
|
473
470
|
async function cmdCaps (args = []) {
|
|
474
471
|
const [id, ...changes] = args
|
|
475
472
|
if (!id || !changes.length) {
|
|
476
|
-
console.error('uso: dotrino-vault caps <ID> +firma|-firma|+guarda|-guarda|+lee|-lee|+administra|-administra|+aprueba|-aprueba')
|
|
473
|
+
console.error('uso: dotrino-vault caps <ID> +firma|-firma|+guarda|-guarda|+lee|-lee|+administra|-administra|+aprueba|-aprueba|+contrasenas|-contrasenas|+permiso|-permiso')
|
|
477
474
|
process.exit(2)
|
|
478
475
|
}
|
|
479
476
|
const CAP_BY_WORD = {
|
|
480
477
|
firma: 'sign', guarda: 'store', lee: 'read', administra: 'admin', aprueba: 'approve',
|
|
481
|
-
|
|
478
|
+
// `contraseñas`: el gestor (la extensión, la app del teléfono) puede PEDIR
|
|
479
|
+
// credenciales de a una. Se acepta con y sin tilde: nadie escribe la ñ en una CLI.
|
|
480
|
+
contrasenas: 'passwords', 'contraseñas': 'passwords',
|
|
481
|
+
sign: 'sign', store: 'store', read: 'read', admin: 'admin', approve: 'approve',
|
|
482
|
+
passwords: 'passwords'
|
|
482
483
|
}
|
|
483
484
|
const s = requireDaemon()
|
|
484
485
|
const m = await findMember(id)
|
|
485
486
|
|
|
486
|
-
|
|
487
|
+
// `+permiso` / `-permiso` no es una capacidad del acta: es la marca de la bóveda «este
|
|
488
|
+
// aparato pide aprobación al recibir claves». Se cambia aquí, como cualquier permiso.
|
|
489
|
+
const rest = []
|
|
487
490
|
for (const c of changes) {
|
|
491
|
+
const w = c.slice(1).toLowerCase()
|
|
492
|
+
if ((c[0] === '+' || c[0] === '-') && (w === 'permiso' || w === 'approval')) {
|
|
493
|
+
writeReq('secret-request.json', { op: 'approval', pub: m.pub, id: m.id, approval: c[0] === '+' })
|
|
494
|
+
sendSignal(s.pid, 'SIGUSR2')
|
|
495
|
+
console.log(c[0] === '+' ? `Listo: ${m.id} pedirá tu aprobación en cada petición de claves.` : `Listo: ${m.id} ya no pide aprobación.`)
|
|
496
|
+
await sleep(300)
|
|
497
|
+
} else rest.push(c)
|
|
498
|
+
}
|
|
499
|
+
if (!rest.length) return
|
|
500
|
+
const caps = new Set(m.caps)
|
|
501
|
+
for (const c of rest) {
|
|
488
502
|
const sign = c[0]
|
|
489
503
|
const cap = CAP_BY_WORD[c.slice(1).toLowerCase()]
|
|
490
504
|
if (!cap || (sign !== '+' && sign !== '-')) { console.error('permiso no reconocido: %s', c); process.exit(2) }
|
|
@@ -737,7 +751,6 @@ async function cmdSecret (rest) {
|
|
|
737
751
|
' dotrino-vault secret device rm <ID> <CLAVE>',
|
|
738
752
|
' dotrino-vault secret list',
|
|
739
753
|
' 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)',
|
|
741
754
|
'',
|
|
742
755
|
'CARGA LA CONFIGURACIÓN DE UN SERVICIO DE UNA VEZ (`set` con varios pares, o `import`):',
|
|
743
756
|
'la bóveda la aplica entera y avisa UNA sola vez. De una en una, cada variable es un',
|
|
@@ -848,95 +861,6 @@ async function cmdSecret (rest) {
|
|
|
848
861
|
}
|
|
849
862
|
|
|
850
863
|
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
|
-
}
|
|
865
|
-
|
|
866
|
-
// --- VER un valor, su histórico y volver atrás ---------------------------------
|
|
867
|
-
// Las tres van juntas porque son la misma idea: ver es lo único que la contraseña
|
|
868
|
-
// guarda, y revertir es ver + volver a guardar (§8.3/§8.4).
|
|
869
|
-
const ownerDe = async (kind, ref) => {
|
|
870
|
-
if (kind === 'ns') return `ns:${ref}`
|
|
871
|
-
const m = await findMember(ref)
|
|
872
|
-
return `dev:${m.pub}`
|
|
873
|
-
}
|
|
874
|
-
|
|
875
|
-
if (sub === 'show' || sub === 'history' || sub === 'revert') {
|
|
876
|
-
// `secret show device <ID> <CLAVE>` mira el cajón de un aparato.
|
|
877
|
-
const esDev = args[0] === 'device'
|
|
878
|
-
const [ref, key, extra] = esDev ? args.slice(1) : args
|
|
879
|
-
if (!ref || (sub !== 'history' && !key)) {
|
|
880
|
-
console.error('uso: dotrino-vault secret %s [device] <ns|ID> <CLAVE>%s', sub, sub === 'revert' ? ' <marca>' : '')
|
|
881
|
-
process.exit(2)
|
|
882
|
-
}
|
|
883
|
-
const owner = await ownerDe(esDev ? 'dev' : 'ns', ref)
|
|
884
|
-
|
|
885
|
-
if (sub === 'history') {
|
|
886
|
-
writeReq('secret-request.json', { op: 'history', owner, key: key || null })
|
|
887
|
-
const d = await signalAndWaitList()
|
|
888
|
-
const items = d.history?.items || []
|
|
889
|
-
if (!items.length) { console.log('No hay versiones anteriores de %s%s.', owner, key ? `/${key}` : ''); return }
|
|
890
|
-
console.log('\n%sVersiones anteriores%s (la de arriba es la más reciente)\n', B, Z)
|
|
891
|
-
for (const h of items) {
|
|
892
|
-
// Sin `by` no es que falte un dato: es que se escribió AQUÍ, desde esta máquina.
|
|
893
|
-
// Lo que llega por la consola remota sí trae de qué aparato vino.
|
|
894
|
-
console.log(' %s %s%s%s %s%s', new Date(h.ts).toISOString(), B, h.key, Z,
|
|
895
|
-
h.by ? `desde ${h.by.slice(0, 12)}…` : 'desde esta máquina', h.signed ? '' : ' (sobre sin firma)')
|
|
896
|
-
}
|
|
897
|
-
console.log('\nPara volver a una: dotrino-vault secret revert %s%s <CLAVE> <marca>\n',
|
|
898
|
-
esDev ? 'device ' : '', ref)
|
|
899
|
-
return
|
|
900
|
-
}
|
|
901
|
-
|
|
902
|
-
if (sub === 'revert') {
|
|
903
|
-
if (!extra) { console.error('uso: dotrino-vault secret revert [device] <ns|ID> <CLAVE> <marca>'); process.exit(2) }
|
|
904
|
-
const ts = Number.isFinite(Number(extra)) ? Number(extra) : Date.parse(extra)
|
|
905
|
-
if (!Number.isFinite(ts)) { console.error('La marca es la que enseña `secret history` (fecha ISO).'); process.exit(2) }
|
|
906
|
-
const password = await adminPassword()
|
|
907
|
-
writeReq('secret-request.json', { op: 'revert', owner, key, ts, ...(password ? { password } : {}) })
|
|
908
|
-
const d = await signalAndWaitList()
|
|
909
|
-
if (d?.secretError) return noSeAplico(d)
|
|
910
|
-
console.log('Restaurada: %s/%s', owner, key)
|
|
911
|
-
return
|
|
912
|
-
}
|
|
913
|
-
|
|
914
|
-
// `show`: el valor. Es lo único que la contraseña guarda en esta máquina.
|
|
915
|
-
const password = await adminPassword()
|
|
916
|
-
writeReq('secret-request.json', { op: 'reveal', owner, key, ...(password ? { password } : {}) })
|
|
917
|
-
const d = await signalAndWaitList()
|
|
918
|
-
if (d?.secretError) return noSeAplico(d)
|
|
919
|
-
if (d.revealed?.owner !== owner || d.revealed?.key !== key) {
|
|
920
|
-
console.error('El daemon no devolvió el valor (revisa: dotrino-vault logs)')
|
|
921
|
-
process.exit(1)
|
|
922
|
-
}
|
|
923
|
-
if (d.revealed.value == null) { console.error('No existe esa variable.'); process.exit(1) }
|
|
924
|
-
console.log(d.revealed.value)
|
|
925
|
-
return
|
|
926
|
-
}
|
|
927
|
-
|
|
928
|
-
// Saldar lo que quedó a deber: heredarle a un aparato lo ya guardado, rotar de verdad.
|
|
929
|
-
if (sub === 'settle') {
|
|
930
|
-
const password = await adminPassword()
|
|
931
|
-
writeReq('secret-request.json', { op: 'settle', ...(password ? { password } : {}) })
|
|
932
|
-
const d = await signalAndWaitList()
|
|
933
|
-
if (d?.secretError) return noSeAplico(d)
|
|
934
|
-
const pend = Object.keys(d.pending || {})
|
|
935
|
-
if (!pend.length) console.log('Nada pendiente.')
|
|
936
|
-
else console.log('Siguen pendientes: %s', pend.join(', '))
|
|
937
|
-
return
|
|
938
|
-
}
|
|
939
|
-
|
|
940
864
|
if (sub === 'list') {
|
|
941
865
|
const d = await signalAndWaitList()
|
|
942
866
|
const names = d.ns || {}
|
|
@@ -1247,7 +1171,8 @@ function help () {
|
|
|
1247
1171
|
estrena una cuenta VACÍA en este vault y mete ahí al dispositivo
|
|
1248
1172
|
(sin la bandera entra a la cuenta activa, o a la de --profile)
|
|
1249
1173
|
pair --service <ns> empareja un SERVICIO (proxy, geo…) con acceso SOLO a sus secretos
|
|
1250
|
-
pair --
|
|
1174
|
+
pair --approval el aparato que entre pedirá tu aprobación (teléfono) al recibir claves
|
|
1175
|
+
pair --scope <lista> los PERMISOS del cert: sign,read,store,contrasenas,secrets:<ns> (sin esto: sign,read,store;
|
|
1251
1176
|
se combina con --service: --service eco --scope sign = bot que firma y lee su cajón)
|
|
1252
1177
|
secret set <ns> <CLAVE> <valor> variable del scope <ns>: la comparten TODOS los
|
|
1253
1178
|
aparatos del perfil que sirven ese namespace
|
|
@@ -1286,7 +1211,7 @@ function help () {
|
|
|
1286
1211
|
me tu perfil (nombre, foto, datos) tal como lo tiene la bóveda
|
|
1287
1212
|
members el acta del perfil: quién es tuyo y qué puede hacer
|
|
1288
1213
|
label <ID> <nombre> renombra un dispositivo (el nombre con el que lo reconoces)
|
|
1289
|
-
caps <ID> ±permiso cambia permisos (+firma -guarda +administra …)
|
|
1214
|
+
caps <ID> ±permiso cambia permisos (+firma -guarda +administra +contrasenas …)
|
|
1290
1215
|
revoke <ID|nonce> quita un dispositivo (con el ID, todos sus certificados)
|
|
1291
1216
|
activity [n] bitácora de seguridad: firmas, renovaciones, enrolados, rechazos
|
|
1292
1217
|
logs últimos logs del servicio
|
|
@@ -1335,7 +1260,7 @@ export async function runCtl (argv) {
|
|
|
1335
1260
|
case 'caps': return cmdCaps(rest)
|
|
1336
1261
|
case 'revoke': return cmdRevoke(rest[0])
|
|
1337
1262
|
case 'secret': return cmdSecret(rest)
|
|
1338
|
-
case '
|
|
1263
|
+
case 'approval': return cmdApproval(rest)
|
|
1339
1264
|
case 'activity': return cmdActivity(Number(rest[0]) || 30)
|
|
1340
1265
|
case 'logs': return cmdLogs()
|
|
1341
1266
|
case 'version':
|
package/src/daemon.js
CHANGED
|
@@ -17,7 +17,6 @@
|
|
|
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'
|
|
21
20
|
import { dataDir, writeJson, readJson } from './paths.js'
|
|
22
21
|
import { VERSION } from './version.js'
|
|
23
22
|
|
|
@@ -64,14 +63,6 @@ export async function runDaemon () {
|
|
|
64
63
|
|
|
65
64
|
const mgr = await startVaultManager({ root: dir, proxyUrl, onEnrollChallenge })
|
|
66
65
|
|
|
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
|
-
|
|
75
66
|
// --- state.json ---
|
|
76
67
|
const stateFile = path.join(dir, 'state.json')
|
|
77
68
|
const daemonVersion = VERSION
|
|
@@ -82,7 +73,7 @@ export async function runDaemon () {
|
|
|
82
73
|
const cur = mgr.summary().find((p) => p.current) || {}
|
|
83
74
|
writeJson(stateFile, {
|
|
84
75
|
v: 2, version: daemonVersion, fingerprint: cur.fingerprint || null, iss: cur.iss || null,
|
|
85
|
-
proxy: proxyUrl, pid: process.pid, startedAt: new Date().toISOString(),
|
|
76
|
+
proxy: proxyUrl, pid: process.pid, startedAt: new Date().toISOString(),
|
|
86
77
|
current: mgr.currentId(), profiles: mgr.summary()
|
|
87
78
|
})
|
|
88
79
|
}
|
|
@@ -127,6 +118,7 @@ export async function runDaemon () {
|
|
|
127
118
|
|
|
128
119
|
// --- SIGUSR1: iniciar emparejamiento ---
|
|
129
120
|
const pairFile = path.join(dir, 'pair.json')
|
|
121
|
+
let pendingApproval = false // lo pidió `pair --approval`; se aplica al aprobar
|
|
130
122
|
const pairReqFile = path.join(dir, 'pair-request.json')
|
|
131
123
|
async function handlePairingRequest () {
|
|
132
124
|
try {
|
|
@@ -166,7 +158,12 @@ export async function runDaemon () {
|
|
|
166
158
|
const scope = asked?.length
|
|
167
159
|
? [...new Set(asked)]
|
|
168
160
|
: isService ? ['vault:secrets:' + pairReq.service] : ['vault:sign', 'vault:read', 'vault:store']
|
|
169
|
-
|
|
161
|
+
// La etiqueta por defecto de un servicio es su ns a secas: la lista ya marca [servicio «ns»],
|
|
162
|
+
// y el prefijo «service:» confundía (el cajón se llama claude, no service:claude).
|
|
163
|
+
const label = pairReq?.label || (isService ? pairReq.service : 'cli')
|
|
164
|
+
// `pair --approval`: el aparato que entre por esta invitación pedirá el visto bueno del
|
|
165
|
+
// teléfono cada vez que reciba claves privadas (se aplica al aprobarlo, abajo).
|
|
166
|
+
pendingApproval = !!pairReq?.approval
|
|
170
167
|
// `profile`/`profileName`: la CUENTA del vault a la que entra el dispositivo.
|
|
171
168
|
// Con varias bóvedas en el mismo daemon, el QR sale de UNA y quien empareja
|
|
172
169
|
// tiene que verlo (lo muestran la TUI y `dotrino-vault pair`). El nombre viaja
|
|
@@ -327,6 +324,8 @@ export async function runDaemon () {
|
|
|
327
324
|
const vault = targetOf(appr)
|
|
328
325
|
if (!vault) throw Object.assign(new Error('profile locked'), { code: 'PROFILE_LOCKED' })
|
|
329
326
|
const r = await vault.approveDevice(appr.code); rm(pendingEnrollFile); rm(pairFile)
|
|
327
|
+
if (pendingApproval && r?.cert?.sub) { try { await vault.setApproval(r.cert.sub, true); console.log('[vault] the new device will need approval on every key request') } catch (e) { console.error('[vault] could not flag approval: %s', e.message) } }
|
|
328
|
+
pendingApproval = false
|
|
330
329
|
console.log('[vault] aprobado %s', r.deviceId)
|
|
331
330
|
answer({ ok: true, deviceId: r.deviceId || null })
|
|
332
331
|
} catch (e) {
|
|
@@ -406,7 +405,7 @@ export async function runDaemon () {
|
|
|
406
405
|
console.log('[vault] %d secret(s) applied in one go: %s', changed.length, sec.pub ? 'device' : sec.ns)
|
|
407
406
|
} 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) }
|
|
408
407
|
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 === '
|
|
408
|
+
else if (sec.op === 'approval') { const r = await vault.setApproval(sec.pub, !!sec.approval); console.log('[vault] device %s: approval %s', sec.id || '?', r.approval ? 'REQUIRED on every key request' : 'off') }
|
|
410
409
|
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) }
|
|
411
410
|
else if (sec.op === 'dev-rm') { await vault.deleteDeviceSecret(sec.pub, sec.key); console.log('[vault] device secret deleted: %s', sec.key) }
|
|
412
411
|
// Saldar lo que quedó a deber: heredarle a un aparato nuevo lo ya guardado y
|
|
@@ -616,7 +615,6 @@ export async function runDaemon () {
|
|
|
616
615
|
const shutdown = (sig) => {
|
|
617
616
|
console.log(`\n[vault] ${sig} → deteniendo…`)
|
|
618
617
|
rm(pairFile); rm(pendingEnrollFile)
|
|
619
|
-
try { sshAgent?.close() } catch (_) {}
|
|
620
618
|
try { mgr.close() } catch (_) {}
|
|
621
619
|
process.exit(0)
|
|
622
620
|
}
|
package/src/passwords.js
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LA BÓVEDA DE CONTRASEÑAS del dueño, atendida por el vault.
|
|
3
|
+
*
|
|
4
|
+
* `dotrino-passmanager serve` es la versión mínima: bóveda propia, aprobación por
|
|
5
|
+
* consola, bitácora propia. Esto es lo mismo pero dentro del vault, que ya tiene lo
|
|
6
|
+
* que allí había que improvisar:
|
|
7
|
+
*
|
|
8
|
+
* · el ACTA dice qué aparatos existen y qué puede cada uno
|
|
9
|
+
* · la APROBACIÓN ya sabe avisar al teléfono y esperar su firma (`caps +aprueba`)
|
|
10
|
+
* · la BITÁCORA ya existe y es la misma que audita firmas y enrolamientos
|
|
11
|
+
*
|
|
12
|
+
* Lo que NO cambia es el protocolo: un aparato pide una credencial por dominio y
|
|
13
|
+
* recibe esa sola. `list` sigue sin existir en remoto.
|
|
14
|
+
*
|
|
15
|
+
* Módulo aislado a propósito: recibe todo lo que necesita y no toca el núcleo. Un
|
|
16
|
+
* fallo aquí no puede tumbar la CA.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { LocalVault, VaultResponder } from '@dotrino/passmanager'
|
|
20
|
+
|
|
21
|
+
export const PASSWORDS_CAP = 'passwords'
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @param {object} opts
|
|
25
|
+
* `client` cliente del proxio del vault, ya identificado
|
|
26
|
+
* `store` `{ get(k), set(k,v) }` donde viven las entradas cifradas
|
|
27
|
+
* `cek` la llave de la bóveda de contraseñas (CryptoKey AES-GCM)
|
|
28
|
+
* `isAllowed(pubkey)` qué aparatos pueden pedir — lo decide el acta, no esto
|
|
29
|
+
* `encPubOf(pubkey)` su llave de cifrado, para sellarle la respuesta
|
|
30
|
+
* `needsApproval(pubkey)` si ese aparato está marcado para aprobar
|
|
31
|
+
* `approve({ pubkey, op })` pide el visto bueno (el teléfono) y espera
|
|
32
|
+
* `devices()` / `unlink(pubkey)` administración, opcional
|
|
33
|
+
* `audit(op, info)` la bitácora del vault
|
|
34
|
+
*/
|
|
35
|
+
export function createPasswordDesk (opts = {}) {
|
|
36
|
+
const {
|
|
37
|
+
client, store, cek,
|
|
38
|
+
isAllowed = () => false,
|
|
39
|
+
encPubOf = () => null,
|
|
40
|
+
needsApproval = () => true,
|
|
41
|
+
approve = async () => false,
|
|
42
|
+
devices = null,
|
|
43
|
+
unlink = null,
|
|
44
|
+
audit = () => {},
|
|
45
|
+
log = () => {},
|
|
46
|
+
} = opts
|
|
47
|
+
|
|
48
|
+
const vault = new LocalVault(store)
|
|
49
|
+
if (cek) vault.unlock(cek)
|
|
50
|
+
|
|
51
|
+
const responder = new VaultResponder({
|
|
52
|
+
client,
|
|
53
|
+
vault,
|
|
54
|
+
isAllowed,
|
|
55
|
+
encPubOf,
|
|
56
|
+
// La aprobación es del APARATO y dura mientras el vault siga encendido, igual que
|
|
57
|
+
// los cajones de secretos: una por arranque, sin ventana que vigilar.
|
|
58
|
+
needsApproval: (op, _payload, pubkey) => op === 'get' && needsApproval(pubkey),
|
|
59
|
+
approve,
|
|
60
|
+
admin: devices && unlink ? { devices, unlink } : null,
|
|
61
|
+
onRequest: (r) => {
|
|
62
|
+
// La bitácora NO lleva qué credencial se pidió: eso es contenido del usuario y
|
|
63
|
+
// el criterio del vault es apuntar la operación, no el payload.
|
|
64
|
+
audit('passwords', { op: r.op, outcome: r.outcome, device: (r.from || '').slice(0, 24) })
|
|
65
|
+
if (r.outcome !== 'served') {
|
|
66
|
+
log('[vault] passwords: %s %s', r.op, r.outcome)
|
|
67
|
+
}
|
|
68
|
+
},
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
return {
|
|
72
|
+
start () { responder.start(); return this },
|
|
73
|
+
stop () { responder.stop() },
|
|
74
|
+
/** Al bloquear el perfil, la bóveda de contraseñas se cierra con él. */
|
|
75
|
+
lock () { vault.lock() },
|
|
76
|
+
unlock (nuevaCek) { vault.unlock(nuevaCek) },
|
|
77
|
+
/** Para la consola local: aquí SÍ se puede listar, porque aquí está la llave. */
|
|
78
|
+
vault,
|
|
79
|
+
responder,
|
|
80
|
+
}
|
|
81
|
+
}
|
package/src/secretsStore.js
CHANGED
|
@@ -159,11 +159,9 @@ 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
|
|
163
|
-
for (const
|
|
164
|
-
|
|
165
|
-
if (stray) { delete bag.policy; if (stray.approval) { if (!data.policies) data.policies = {}; data.policies[ns] = { approval: true } } }
|
|
166
|
-
}
|
|
162
|
+
// 0.47.0/0.48.0 guardaron una «política» DENTRO del cajón (salía como variable): fuera.
|
|
163
|
+
for (const bag of Object.values(data.ns || {})) if (bag?.policy && typeof bag.policy === 'object' && !('v' in bag.policy)) delete bag.policy
|
|
164
|
+
delete data.policies
|
|
167
165
|
|
|
168
166
|
// v3 → v5 y v4 → v5 NO se hacen al abrir, y es deliberado: el daemon tiene que poder
|
|
169
167
|
// arrancar y SERVIR sin nada (un perfil bloqueado sigue atendiendo a sus agentes). Un
|
|
@@ -593,30 +591,6 @@ export function openSecretsStore (dir, { sealer = null, recipients = null, signe
|
|
|
593
591
|
for (const ns of Object.keys(data.ns)) out[ns] = names(data.ns, ns)
|
|
594
592
|
return out
|
|
595
593
|
},
|
|
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
|
-
},
|
|
620
594
|
/** Nombres y visibilidad (pub → [{key, public}]), sin valores. */
|
|
621
595
|
listDevices () {
|
|
622
596
|
const out = {}
|
package/src/tui/app.js
CHANGED
|
@@ -318,7 +318,7 @@ function pairModeRows (st, t) {
|
|
|
318
318
|
* una marca de si los tiene. El de administrar va aparte y avisado: es el único que deja
|
|
319
319
|
* a ese aparato meter y sacar dispositivos sin venir aquí.
|
|
320
320
|
*/
|
|
321
|
-
const CAPS_ORDER = ['sign', 'store', 'read', 'admin']
|
|
321
|
+
const CAPS_ORDER = ['sign', 'store', 'read', 'admin', 'passwords']
|
|
322
322
|
|
|
323
323
|
function capsRows (st, t) {
|
|
324
324
|
const i = L(st)
|