@dotrino/vaultd 0.49.0 → 0.51.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/index.js +124 -7
- package/lib/src/invite.js +6 -4
- 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 +68 -119
- package/src/daemon.js +16 -13
- package/src/passwords.js +81 -0
- package/src/secretsStore.js +3 -29
- package/src/tui/i18n.js +2 -2
- package/src/vault.js +223 -114
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,6 +187,9 @@ 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) {
|
|
@@ -238,7 +240,7 @@ async function cmdPair (args = []) {
|
|
|
238
240
|
}
|
|
239
241
|
// La petición se escribe SIEMPRE (aunque no haya --service): lleva a qué perfil
|
|
240
242
|
// se empareja el dispositivo.
|
|
241
|
-
writeReq('pair-request.json', { ...(service ? { service } : {}), ...(scope ? { scope } : {}), ...(adopt ? { mode: 'adopt' } : {}) })
|
|
243
|
+
writeReq('pair-request.json', { ...(service ? { service } : {}), ...(scope ? { scope } : {}), ...(adopt ? { mode: 'adopt' } : {}), ...(approval ? { approval: true } : {}) })
|
|
242
244
|
sendSignal(s.pid, 'SIGUSR1')
|
|
243
245
|
|
|
244
246
|
let pair = null
|
|
@@ -265,14 +267,14 @@ async function cmdPair (args = []) {
|
|
|
265
267
|
console.log(`${R}${B}⚠ Este código deja LEER tus datos y FIRMAR con tu identidad.${Z}`)
|
|
266
268
|
console.log(`${R} NO lo compartas con nadie, ni con "soporte". Solo escanéalo en TU dispositivo.${Z}`)
|
|
267
269
|
console.log('\nO abre esta dirección en el dispositivo:\n ' + url)
|
|
268
|
-
console.log('\nO pega este código en vault.dotrino.com/
|
|
270
|
+
console.log('\nO pega este código en vault.dotrino.com/vault :\n ' + b64)
|
|
269
271
|
|
|
270
272
|
// --save [archivo]: escribe la invitación (.dpair) para transferirla y abrirla en profile.
|
|
271
273
|
const saveIdx = args.indexOf('--save')
|
|
272
274
|
if (saveIdx >= 0) {
|
|
273
275
|
const next = args[saveIdx + 1]
|
|
274
276
|
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/
|
|
277
|
+
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
278
|
catch (e) { console.error('No se pudo guardar la invitación:', e.message) }
|
|
277
279
|
}
|
|
278
280
|
|
|
@@ -402,7 +404,8 @@ async function cmdMembers () {
|
|
|
402
404
|
// se mira quién es quién — y en la lista de variables ya seria tarde.
|
|
403
405
|
if (m.cn && !m.canSeal) console.log(' %ssin llave de cifrado: NO puede leer sus variables%s', R, Z)
|
|
404
406
|
}
|
|
405
|
-
console.log('\n Cambiar permisos: dotrino-vault caps <ID> +firma | -firma | +guarda | -guarda | +lee | -lee | +administra | +aprueba')
|
|
407
|
+
console.log('\n Cambiar permisos: dotrino-vault caps <ID> +firma | -firma | +guarda | -guarda | +lee | -lee | +administra | +aprueba | +permiso')
|
|
408
|
+
console.log(' «Permiso»: ese aparato solo recibe claves privadas cuando lo apruebas desde un aparato con «aprueba» (en cada arranque).')
|
|
406
409
|
console.log(' «Administra» deja conectar y quitar dispositivos desde ese aparato, sin venir aquí.')
|
|
407
410
|
console.log(' No deja cambiar permisos ni traspasar el mando: eso solo se hace en esta máquina.')
|
|
408
411
|
console.log(' Los servicios solo pueden abrir las claves de su propio nombre; eso no se cambia aquí.\n')
|
|
@@ -444,36 +447,53 @@ async function findMember (id) {
|
|
|
444
447
|
}
|
|
445
448
|
|
|
446
449
|
/**
|
|
447
|
-
* `dotrino-vault
|
|
448
|
-
*
|
|
449
|
-
*
|
|
450
|
+
* `dotrino-vault approval <ID> on|off` — ese aparato solo recibe claves privadas con el
|
|
451
|
+
* visto bueno de un aparato con `aprueba` (el teléfono). Es propiedad del APARATO, no del
|
|
452
|
+
* cajón: el VPS desatendido no pide; la PC del dueño sí. Pide en cada petición, que para un
|
|
453
|
+
* servicio bien hecho es una por arranque.
|
|
450
454
|
*/
|
|
451
|
-
async function
|
|
452
|
-
const
|
|
453
|
-
if (!
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
455
|
+
async function cmdApproval (args = []) {
|
|
456
|
+
const [id, val] = args
|
|
457
|
+
if (!id || !['on', 'off'].includes(val)) { console.error('uso: dotrino-vault approval <ID> on|off'); process.exit(2) }
|
|
458
|
+
const m = await findMember(id)
|
|
459
|
+
writeReq('secret-request.json', { op: 'approval', pub: m.pub, id: m.id, approval: val === 'on' })
|
|
460
|
+
sendSignal(requireDaemon().pid, 'SIGUSR2')
|
|
461
|
+
console.log(val === 'on'
|
|
462
|
+
? `Listo: ${m.id} solo recibirá claves privadas cuando un aparato con caps <ID> +aprueba lo apruebe (en cada arranque).`
|
|
463
|
+
: `Listo: ${m.id} vuelve a recibir sus claves sin aprobación.`)
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* `dotrino-vault passwords <ID> on|off` — deja (o no) que ese aparato pida contraseñas
|
|
468
|
+
* de tu bóveda.
|
|
469
|
+
*
|
|
470
|
+
* Es una lista de ESTA bóveda, no del acta, por lo mismo que `approval`: es ella la que
|
|
471
|
+
* entrega. El acta manda igual — si revocas el aparato allí, deja de pedir aunque siga
|
|
472
|
+
* en esta lista. Y si además le pones `approval on`, cada arranque suyo te pedirá el
|
|
473
|
+
* visto bueno en el teléfono.
|
|
474
|
+
*/
|
|
475
|
+
async function cmdPasswords (args = []) {
|
|
476
|
+
const [id, val] = args
|
|
477
|
+
if (!id || !['on', 'off'].includes(val)) {
|
|
478
|
+
console.error('uso: dotrino-vault passwords <ID> on|off')
|
|
479
|
+
process.exit(1)
|
|
480
|
+
}
|
|
481
|
+
const m = await findMember(id)
|
|
482
|
+
writeReq('secret-request.json', { op: 'passwords', pub: m.pub, id: m.id, label: m.label || '', on: val === 'on' })
|
|
483
|
+
sendSignal(requireDaemon().pid, 'SIGUSR2')
|
|
484
|
+
if (val === 'on') {
|
|
485
|
+
console.log(`Listo: ${m.id} puede pedir contraseñas de tu bóveda (de una en una; nunca la lista entera).`)
|
|
486
|
+
console.log('Reinicia el vault: al arrancar imprime el código de enlace que se pega en la extensión.')
|
|
487
|
+
} else {
|
|
488
|
+
console.log(`Listo: ${m.id} deja de poder pedir contraseñas.`)
|
|
457
489
|
}
|
|
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
490
|
}
|
|
471
491
|
|
|
472
492
|
/** `dotrino-vault caps <ID> ±permiso` — cambia lo que puede hacer un dispositivo. */
|
|
473
493
|
async function cmdCaps (args = []) {
|
|
474
494
|
const [id, ...changes] = args
|
|
475
495
|
if (!id || !changes.length) {
|
|
476
|
-
console.error('uso: dotrino-vault caps <ID> +firma|-firma|+guarda|-guarda|+lee|-lee|+administra|-administra|+aprueba|-aprueba')
|
|
496
|
+
console.error('uso: dotrino-vault caps <ID> +firma|-firma|+guarda|-guarda|+lee|-lee|+administra|-administra|+aprueba|-aprueba|+permiso|-permiso')
|
|
477
497
|
process.exit(2)
|
|
478
498
|
}
|
|
479
499
|
const CAP_BY_WORD = {
|
|
@@ -483,8 +503,21 @@ async function cmdCaps (args = []) {
|
|
|
483
503
|
const s = requireDaemon()
|
|
484
504
|
const m = await findMember(id)
|
|
485
505
|
|
|
486
|
-
|
|
506
|
+
// `+permiso` / `-permiso` no es una capacidad del acta: es la marca de la bóveda «este
|
|
507
|
+
// aparato pide aprobación al recibir claves». Se cambia aquí, como cualquier permiso.
|
|
508
|
+
const rest = []
|
|
487
509
|
for (const c of changes) {
|
|
510
|
+
const w = c.slice(1).toLowerCase()
|
|
511
|
+
if ((c[0] === '+' || c[0] === '-') && (w === 'permiso' || w === 'approval')) {
|
|
512
|
+
writeReq('secret-request.json', { op: 'approval', pub: m.pub, id: m.id, approval: c[0] === '+' })
|
|
513
|
+
sendSignal(s.pid, 'SIGUSR2')
|
|
514
|
+
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.`)
|
|
515
|
+
await sleep(300)
|
|
516
|
+
} else rest.push(c)
|
|
517
|
+
}
|
|
518
|
+
if (!rest.length) return
|
|
519
|
+
const caps = new Set(m.caps)
|
|
520
|
+
for (const c of rest) {
|
|
488
521
|
const sign = c[0]
|
|
489
522
|
const cap = CAP_BY_WORD[c.slice(1).toLowerCase()]
|
|
490
523
|
if (!cap || (sign !== '+' && sign !== '-')) { console.error('permiso no reconocido: %s', c); process.exit(2) }
|
|
@@ -737,7 +770,6 @@ async function cmdSecret (rest) {
|
|
|
737
770
|
' dotrino-vault secret device rm <ID> <CLAVE>',
|
|
738
771
|
' dotrino-vault secret list',
|
|
739
772
|
' 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
773
|
'',
|
|
742
774
|
'CARGA LA CONFIGURACIÓN DE UN SERVICIO DE UNA VEZ (`set` con varios pares, o `import`):',
|
|
743
775
|
'la bóveda la aplica entera y avisa UNA sola vez. De una en una, cada variable es un',
|
|
@@ -848,95 +880,6 @@ async function cmdSecret (rest) {
|
|
|
848
880
|
}
|
|
849
881
|
|
|
850
882
|
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
883
|
if (sub === 'list') {
|
|
941
884
|
const d = await signalAndWaitList()
|
|
942
885
|
const names = d.ns || {}
|
|
@@ -1247,6 +1190,7 @@ function help () {
|
|
|
1247
1190
|
estrena una cuenta VACÍA en este vault y mete ahí al dispositivo
|
|
1248
1191
|
(sin la bandera entra a la cuenta activa, o a la de --profile)
|
|
1249
1192
|
pair --service <ns> empareja un SERVICIO (proxy, geo…) con acceso SOLO a sus secretos
|
|
1193
|
+
pair --approval el aparato que entre pedirá tu aprobación (teléfono) al recibir claves
|
|
1250
1194
|
pair --scope <lista> los PERMISOS del cert: sign,read,store,secrets:<ns> (sin esto: sign,read,store;
|
|
1251
1195
|
se combina con --service: --service eco --scope sign = bot que firma y lee su cajón)
|
|
1252
1196
|
secret set <ns> <CLAVE> <valor> variable del scope <ns>: la comparten TODOS los
|
|
@@ -1262,6 +1206,10 @@ function help () {
|
|
|
1262
1206
|
secret device set <ID> CLAVE=valor …
|
|
1263
1207
|
secret device import <ID> [archivo.env]
|
|
1264
1208
|
secret device rm <ID> <CLAVE> borra una variable de ese aparato
|
|
1209
|
+
passwords <ID> on|off deja que ese aparato pida CONTRASEÑAS de tu bóveda
|
|
1210
|
+
(de una en una; nunca la lista entera). Con
|
|
1211
|
+
approval on además te pedirá el visto bueno en el
|
|
1212
|
+
teléfono. Reinicia el vault tras el primer on
|
|
1265
1213
|
secret list lista los dos cajones: el valor de las públicas,
|
|
1266
1214
|
tapadas las privadas
|
|
1267
1215
|
secret show [device] <ns|ID> <CLAVE>
|
|
@@ -1335,7 +1283,8 @@ export async function runCtl (argv) {
|
|
|
1335
1283
|
case 'caps': return cmdCaps(rest)
|
|
1336
1284
|
case 'revoke': return cmdRevoke(rest[0])
|
|
1337
1285
|
case 'secret': return cmdSecret(rest)
|
|
1338
|
-
case '
|
|
1286
|
+
case 'approval': return cmdApproval(rest)
|
|
1287
|
+
case 'passwords': return cmdPasswords(rest)
|
|
1339
1288
|
case 'activity': return cmdActivity(Number(rest[0]) || 30)
|
|
1340
1289
|
case 'logs': return cmdLogs()
|
|
1341
1290
|
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,12 @@ 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 === 'passwords') {
|
|
409
|
+
const r = await vault.setPasswordDevice(sec.pub, { label: sec.label, on: !!sec.on })
|
|
410
|
+
console.log('[vault] passwords: device %s %s', sec.id || '?', sec.on ? 'ALLOWED' : 'removed')
|
|
411
|
+
if (sec.on) console.log('[vault] restart the vault so it starts answering password requests')
|
|
412
|
+
}
|
|
413
|
+
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
414
|
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
415
|
else if (sec.op === 'dev-rm') { await vault.deleteDeviceSecret(sec.pub, sec.key); console.log('[vault] device secret deleted: %s', sec.key) }
|
|
412
416
|
// Saldar lo que quedó a deber: heredarle a un aparato nuevo lo ya guardado y
|
|
@@ -616,7 +620,6 @@ export async function runDaemon () {
|
|
|
616
620
|
const shutdown = (sig) => {
|
|
617
621
|
console.log(`\n[vault] ${sig} → deteniendo…`)
|
|
618
622
|
rm(pairFile); rm(pendingEnrollFile)
|
|
619
|
-
try { sshAgent?.close() } catch (_) {}
|
|
620
623
|
try { mgr.close() } catch (_) {}
|
|
621
624
|
process.exit(0)
|
|
622
625
|
}
|
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/i18n.js
CHANGED
|
@@ -230,7 +230,7 @@ const es = {
|
|
|
230
230
|
pairAccount: (name, min) => `Cuenta que se comparte: ${name} · válido ~${min} min`,
|
|
231
231
|
pairScan: 'Escanéalo, o abre esta dirección en el dispositivo:',
|
|
232
232
|
pairUrl: 'URL: ',
|
|
233
|
-
pairPaste: 'O pega este código en vault.dotrino.com/
|
|
233
|
+
pairPaste: 'O pega este código en vault.dotrino.com/vault:',
|
|
234
234
|
pairWarning: '⚠ Este código deja LEER tus datos y FIRMAR con tu identidad. No lo compartas.',
|
|
235
235
|
pairConnected: (id) => `⧗ Se conectó: ${id} — pulsa A y escribe el código que muestra.`,
|
|
236
236
|
pairWaiting: 'Esperando a que el dispositivo se conecte…',
|
|
@@ -515,7 +515,7 @@ const en = {
|
|
|
515
515
|
pairAccount: (name, min) => `Account being shared: ${name} · valid ~${min} min`,
|
|
516
516
|
pairScan: 'Scan it, or open this address on the device:',
|
|
517
517
|
pairUrl: 'URL: ',
|
|
518
|
-
pairPaste: 'Or paste this code into vault.dotrino.com/
|
|
518
|
+
pairPaste: 'Or paste this code into vault.dotrino.com/vault:',
|
|
519
519
|
pairWarning: '⚠ This code lets someone READ your data and SIGN as you. Do not share it.',
|
|
520
520
|
pairConnected: (id) => `⧗ Connected: ${id} — press A and type the code it shows.`,
|
|
521
521
|
pairWaiting: 'Waiting for the device to connect…',
|