@dotrino/vaultd 0.53.0 → 0.58.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/bin/dotrino-vaultd.js +27 -1
- package/lib/src/atrest.js +116 -2
- package/lib/src/enroll.js +1 -1
- package/lib/src/kek.js +293 -0
- package/lib/src/lock.js +117 -0
- package/lib/src/protocol.js +9 -1
- package/lib/src/service.js +47 -9
- package/package.json +4 -4
- package/src/client.js +3 -3
- package/src/ctl.js +331 -13
- package/src/daemon.js +179 -15
- package/src/keyowner.js +83 -0
- package/src/manager.js +33 -4
- package/src/passwords.js +15 -4
- package/src/profiles.js +173 -16
- package/src/selfupdate.js +86 -0
- package/src/vault.js +137 -19
package/src/daemon.js
CHANGED
|
@@ -16,8 +16,11 @@
|
|
|
16
16
|
*/
|
|
17
17
|
import fs from 'node:fs'
|
|
18
18
|
import path from 'node:path'
|
|
19
|
+
import { takeLock } from '../lib/src/lock.js'
|
|
19
20
|
import { startVaultManager } from './manager.js'
|
|
20
21
|
import { dataDir, writeJson, readJson } from './paths.js'
|
|
22
|
+
import { parseInvite } from '../lib/src/invite.js'
|
|
23
|
+
import { watchBinary } from './selfupdate.js'
|
|
21
24
|
import { VERSION } from './version.js'
|
|
22
25
|
|
|
23
26
|
const readJsonSafe = (f) => readJson(f, null)
|
|
@@ -32,21 +35,33 @@ const rm = (f) => { try { fs.rmSync(f, { force: true }) } catch (_) {} }
|
|
|
32
35
|
* datos, los dos con tu identidad y los dos conectados al proxy; el segundo pisaba el pid
|
|
33
36
|
* de `state.json`, así que el CLI solo le hablaba a uno y el otro quedaba de fantasma.
|
|
34
37
|
*
|
|
35
|
-
* El candado
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
+
* El candado vive en `vault.lock` (ver `lib/src/lock.js`) y funciona TAMBIÉN entre
|
|
39
|
+
* máquinas: `O_EXCL` para la exclusión y un latido para que un corte de luz no deje el
|
|
40
|
+
* directorio bloqueado para siempre. Antes era un pid, y un pid no cruza contenedores ni
|
|
41
|
+
* hosts — dos contenedores sobre el mismo volumen arrancaban los dos.
|
|
42
|
+
*
|
|
43
|
+
* Con `DOTRINO_VAULT_DIR` distintos conviven cuantas quieras, y es así como se ponen
|
|
44
|
+
* varias bóvedas en un mismo disco: cada una con su directorio entero, sin nada
|
|
45
|
+
* compartido. Lo que colisiona es el directorio, no el programa.
|
|
38
46
|
*/
|
|
39
47
|
function assertSingleInstance (dir) {
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
48
|
+
try {
|
|
49
|
+
const { release } = takeLock(dir)
|
|
50
|
+
// Se suelta al salir por las buenas. Si el proceso muere de golpe el candado se queda,
|
|
51
|
+
// y por eso caduca por latido: uno inmortal sería peor que ninguno.
|
|
52
|
+
const soltar = () => { try { release() } catch (_) {} }
|
|
53
|
+
process.once('exit', soltar)
|
|
54
|
+
for (const sig of ['SIGINT', 'SIGTERM']) process.once(sig, () => { soltar(); process.exit(0) })
|
|
55
|
+
} catch (e) {
|
|
56
|
+
if (e?.code !== 'vault-locked') throw e
|
|
57
|
+
console.error('A vault is already running on this data.')
|
|
58
|
+
console.error(' %s', e.message)
|
|
59
|
+
console.error(' data: %s', dir)
|
|
60
|
+
console.error('Two vaults on the same directory are not two vaults: they are the SAME one')
|
|
61
|
+
console.error('running twice — same master key, both sealing records as the same sealer.')
|
|
62
|
+
console.error('Stop the other one, or use DOTRINO_VAULT_DIR to give this one its own.')
|
|
63
|
+
process.exit(3)
|
|
64
|
+
}
|
|
50
65
|
}
|
|
51
66
|
|
|
52
67
|
export async function runDaemon () {
|
|
@@ -129,6 +144,19 @@ export async function runDaemon () {
|
|
|
129
144
|
// --- SIGUSR1: iniciar emparejamiento ---
|
|
130
145
|
const pairFile = path.join(dir, 'pair.json')
|
|
131
146
|
let pendingApproval = false // lo pidió `pair --approval`; se aplica al aprobar
|
|
147
|
+
/**
|
|
148
|
+
* Lo pidió `pair --admin`: el aparato que entre por ESTA invitación podrá administrar.
|
|
149
|
+
*
|
|
150
|
+
* Sigue en pie la regla de que **ningún QR concede administración**: el QR no lleva
|
|
151
|
+
* nada. Lo que hay es una nota LOCAL de esta bóveda, y el permiso se aplica en el
|
|
152
|
+
* mismo gesto que ya era la puerta —aprobar con el código tecleado aquí—, exactamente
|
|
153
|
+
* igual que `--approval`. Es el mismo `caps <ID> +administra` que harías a mano un
|
|
154
|
+
* segundo después, sin tener que ir a buscar el ID.
|
|
155
|
+
*
|
|
156
|
+
* Existe por el contenedor: allí cada paso cuesta un `docker exec`, y el primer aparato
|
|
157
|
+
* de una bóveda recién desplegada es SIEMPRE la consola.
|
|
158
|
+
*/
|
|
159
|
+
let pendingAdmin = false
|
|
132
160
|
const pairReqFile = path.join(dir, 'pair-request.json')
|
|
133
161
|
async function handlePairingRequest () {
|
|
134
162
|
try {
|
|
@@ -174,6 +202,7 @@ export async function runDaemon () {
|
|
|
174
202
|
// `pair --approval`: el aparato que entre por esta invitación pedirá el visto bueno del
|
|
175
203
|
// teléfono cada vez que reciba claves privadas (se aplica al aprobarlo, abajo).
|
|
176
204
|
pendingApproval = !!pairReq?.approval
|
|
205
|
+
pendingAdmin = !!pairReq?.admin
|
|
177
206
|
// `profile`/`profileName`: la CUENTA del vault a la que entra el dispositivo.
|
|
178
207
|
// Con varias bóvedas en el mismo daemon, el QR sale de UNA y quien empareja
|
|
179
208
|
// tiene que verlo (lo muestran la TUI y `dotrino-vault pair`). El nombre viaja
|
|
@@ -209,6 +238,10 @@ export async function runDaemon () {
|
|
|
209
238
|
const rejectReqFile = path.join(dir, 'reject-request.json')
|
|
210
239
|
const revokeReqFile = path.join(dir, 'revoke-request.json')
|
|
211
240
|
const secretReqFile = path.join(dir, 'secret-request.json')
|
|
241
|
+
// MULTIVAULT: esta bóveda se UNE a la cuenta de otra. Va por aquí y no por `pair` porque
|
|
242
|
+
// es el papel contrario — aquí no se invita a nadie, se acepta una invitación ajena.
|
|
243
|
+
const joinReqFile = path.join(dir, 'join-request.json')
|
|
244
|
+
const joinResFile = path.join(dir, 'join.json')
|
|
212
245
|
const secretsListFile = path.join(dir, 'secrets-list.json')
|
|
213
246
|
/**
|
|
214
247
|
* Por qué falló la última orden de variables. La consola pide el cambio y el volcado
|
|
@@ -279,7 +312,7 @@ export async function runDaemon () {
|
|
|
279
312
|
case 'list': return {} // el volcado de perfiles ya se hace abajo
|
|
280
313
|
// `id`: quien la crea necesita saber CUÁL quedó, no adivinar por nombre (dos
|
|
281
314
|
// cuentas pueden llamarse igual). Lo usa «emparejar en una cuenta nueva».
|
|
282
|
-
case 'add': { const p = await mgr.add(req.name, { adopt: !!req.adopt }); return { done: `perfil creado: ${p.name || p.id}`, id: p.id, adopt: !!req.adopt } }
|
|
315
|
+
case 'add': { const p = await mgr.add(req.name, { adopt: !!req.adopt, kek: req.kek || null }); return { done: `perfil creado: ${p.name || p.id}${req.kek ? ' (clave del disco en el KMS)' : ''}`, id: p.id, adopt: !!req.adopt } }
|
|
283
316
|
case 'rm': { const r = await mgr.remove(req.profile); return { done: `perfil borrado: ${r.name || r.id}` } }
|
|
284
317
|
case 'rename': { const p = mgr.profiles.rename(ref(), req.name); return { done: `perfil renombrado: ${p.name}` } }
|
|
285
318
|
case 'use': { const p = mgr.profiles.setCurrent(ref()); return { done: `perfil activo: ${p.name || p.id}` } }
|
|
@@ -354,6 +387,18 @@ export async function runDaemon () {
|
|
|
354
387
|
const r = await vault.approveDevice(appr.code); rm(pendingEnrollFile); rm(pairFile)
|
|
355
388
|
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) } }
|
|
356
389
|
pendingApproval = false
|
|
390
|
+
// `pair --admin`: se le SUMA `admin` a lo que ya tiene, no se le reescriben los
|
|
391
|
+
// permisos — el aparato acaba de entrar con el scope que pidió la invitación.
|
|
392
|
+
if (pendingAdmin && r?.deviceId) {
|
|
393
|
+
try {
|
|
394
|
+
const rec = await vault.profileMembers()
|
|
395
|
+
const m = (rec?.members || []).find((x) => x.id === r.deviceId)
|
|
396
|
+
if (!m?.pub) throw new Error('the device is not in the record yet')
|
|
397
|
+
await vault.setCaps(m.pub, [...new Set([...(m.caps || []), 'admin'])])
|
|
398
|
+
console.log('[vault] the new device can ADMINISTER this account (console)')
|
|
399
|
+
} catch (e) { console.error('[vault] could not grant admin: %s', e.message) }
|
|
400
|
+
}
|
|
401
|
+
pendingAdmin = false
|
|
357
402
|
console.log('[vault] aprobado %s', r.deviceId)
|
|
358
403
|
answer({ ok: true, deviceId: r.deviceId || null })
|
|
359
404
|
} catch (e) {
|
|
@@ -394,6 +439,62 @@ export async function runDaemon () {
|
|
|
394
439
|
} catch (e) { console.error('[vault] revocation failed:', e.message) }
|
|
395
440
|
rm(revokeReqFile)
|
|
396
441
|
}
|
|
442
|
+
/**
|
|
443
|
+
* UNIRSE A LA CUENTA DE OTRA BÓVEDA (multivault). La llave que entra como miembro es
|
|
444
|
+
* la de ESTA bóveda —no una de aparato inventada—, que es lo que después permite
|
|
445
|
+
* darle `+sella` y que sea el respaldo de verdad de la otra.
|
|
446
|
+
*
|
|
447
|
+
* El código de confirmación lo genera esta bóveda y hay que TIPEARLO en la otra: la
|
|
448
|
+
* misma defensa de siempre, para que una invitación interceptada no baste.
|
|
449
|
+
*/
|
|
450
|
+
const join = readJsonSafe(joinReqFile)
|
|
451
|
+
if (join?.qr) {
|
|
452
|
+
rm(joinReqFile)
|
|
453
|
+
rm(joinResFile)
|
|
454
|
+
// LA CUENTA AJENA VA EN UN PERFIL DEL GESTOR, no en una cuenta interna de la
|
|
455
|
+
// identidad. Antes esto usaba `enrollDevice(…, { join: 'new' })`, que crea una
|
|
456
|
+
// cuenta más DENTRO de la identidad de un perfil que ya existía —y el gestor no se
|
|
457
|
+
// enteraba—: no había instancia de bóveda para ella, nadie se identificaba en el
|
|
458
|
+
// proxio con esa llave, y el aviso de la otra bóveda —con el acta donde acababa de
|
|
459
|
+
// conceder `sella`— no llegaba a ninguna parte. Se unía y no servía para nada.
|
|
460
|
+
//
|
|
461
|
+
// Un perfil nace vacío y `adopt: true`, que es la marca que deja a `joinProfile`
|
|
462
|
+
// cambiar su acta recién nacida por la que traiga la otra bóveda; sin ella, unirse
|
|
463
|
+
// sería pisar una cuenta con datos y se rechaza, que es lo correcto por defecto.
|
|
464
|
+
let nacido = null
|
|
465
|
+
try {
|
|
466
|
+
const p = await mgr.add(join.name || 'cuenta de la otra bóveda', { adopt: true, kek: join.kek || null })
|
|
467
|
+
nacido = p.id
|
|
468
|
+
const id = mgr.get(p.id)?.identity
|
|
469
|
+
if (!id) throw new Error('no identity for the new profile')
|
|
470
|
+
const off = id.onVault?.((e) => {
|
|
471
|
+
if (e?.phase === 'challenge' && e.code) {
|
|
472
|
+
console.log('[vault] type this code in the other vault: %s', e.code)
|
|
473
|
+
writeJson(joinResFile, { at: Date.now(), code: e.code, state: 'waiting', profile: p.id })
|
|
474
|
+
}
|
|
475
|
+
})
|
|
476
|
+
// `'current'` y no `'new'`: el perfil que se acaba de crear ES el sitio, y su
|
|
477
|
+
// llave recién hecha es la que entra en el acta de la otra bóveda. Crear ahí
|
|
478
|
+
// dentro otra cuenta más sería el bug de arriba otra vez, un nivel más abajo.
|
|
479
|
+
const r = await id.enrollDevice(join.qr, { label: join.label || 'bóveda', join: 'current' })
|
|
480
|
+
off?.()
|
|
481
|
+
// LA CUENTA RECIÉN ADOPTADA PASA A SER LA ACTIVA, y no es un capricho: es la
|
|
482
|
+
// razón por la que se hizo el `join`. Sin esto, el `Perfil 1` vacío que nace en
|
|
483
|
+
// el primer arranque sigue siendo el destino por defecto y todo lo que hagas
|
|
484
|
+
// después —emparejar un aparato, aprobarlo— entra en la cuenta equivocada sin
|
|
485
|
+
// decir nada. En un contenedor que arranca para respaldar una cuenta, ese perfil
|
|
486
|
+
// vacío no es más que un accidente del primer arranque.
|
|
487
|
+
try { mgr.profiles.setCurrent(p.id) } catch (_) {}
|
|
488
|
+
console.log('[vault] joined the account of the other vault (record #%s) as profile %s (now the active one)', r?.acta?.seq ?? '?', p.id)
|
|
489
|
+
writeJson(joinResFile, { at: Date.now(), state: 'done', seq: r?.acta?.seq ?? null, profile: p.id })
|
|
490
|
+
} catch (e) {
|
|
491
|
+
console.error('[vault] could not join:', e.message)
|
|
492
|
+
// El perfil nació para esto y está vacío: si el intento no llegó a término se va
|
|
493
|
+
// con él. Si no, cada reintento dejaba una cuenta fantasma en el conmutador.
|
|
494
|
+
if (nacido) { try { await mgr.remove(nacido) } catch (_) {} }
|
|
495
|
+
writeJson(joinResFile, { at: Date.now(), state: 'error', error: e.message })
|
|
496
|
+
}
|
|
497
|
+
}
|
|
397
498
|
// Secretos: `secret set/rm` (por SCOPE) y `secret device set/rm` (por APARATO),
|
|
398
499
|
// del CLI o de la TUI. El archivo con el valor vive un instante en el mismo dir
|
|
399
500
|
// 0700 del vault y se borra al consumir.
|
|
@@ -642,7 +743,7 @@ export async function runDaemon () {
|
|
|
642
743
|
|
|
643
744
|
// --- apagado limpio ---
|
|
644
745
|
const shutdown = (sig) => {
|
|
645
|
-
console.log(`\n[vault] ${sig} →
|
|
746
|
+
console.log(`\n[vault] ${sig} → stopping…`)
|
|
646
747
|
rm(pairFile); rm(pendingEnrollFile)
|
|
647
748
|
try { mgr.close() } catch (_) {}
|
|
648
749
|
process.exit(0)
|
|
@@ -650,6 +751,69 @@ export async function runDaemon () {
|
|
|
650
751
|
process.on('SIGTERM', () => shutdown('SIGTERM'))
|
|
651
752
|
process.on('SIGINT', () => shutdown('SIGINT'))
|
|
652
753
|
|
|
754
|
+
/**
|
|
755
|
+
* ARRANCAR YA UNIDO A UNA CUENTA — lo que hace desplegable un contenedor.
|
|
756
|
+
*
|
|
757
|
+
* El problema del contenedor no es la bóveda: es el PRIMER APARATO. Una bóveda recién
|
|
758
|
+
* levantada tiene que **enseñar** una invitación y **recibir** de vuelta un código
|
|
759
|
+
* tecleado, y un contenedor no tiene pantalla ni teclado. Con una sola bóveda no hay
|
|
760
|
+
* salida: alguien tiene que entrar (`docker exec`, `kubectl exec`, ECS Exec).
|
|
761
|
+
*
|
|
762
|
+
* Con dos, sí la hay, y es este camino: **la invitación la hace la bóveda que TIENE un
|
|
763
|
+
* humano delante** (tu PC) y el contenedor solo la acepta. Entonces todo lo interactivo
|
|
764
|
+
* pasa del lado donde hay alguien —el código se teclea allí— y el contenedor no
|
|
765
|
+
* necesita más que dos cosas que ya tiene: la invitación al arrancar, y su registro.
|
|
766
|
+
*
|
|
767
|
+
* docker run -e DOTRINO_JOIN="$(dotrino-vault pair --quiet)" …
|
|
768
|
+
* docker logs -f dotrino-vault → «type this code in the other vault: 123456»
|
|
769
|
+
* dotrino-vault approve 123456 (en tu PC)
|
|
770
|
+
*
|
|
771
|
+
* `DOTRINO_JOIN_FILE` es lo mismo apuntando a un archivo, y es lo que hay que usar en
|
|
772
|
+
* serio: una variable de entorno la ve cualquiera con `docker inspect`, y aunque la
|
|
773
|
+
* invitación caduque y sea de un solo uso, no tiene por qué quedar ahí escrita.
|
|
774
|
+
*
|
|
775
|
+
* SE HACE UNA VEZ. La invitación consumida se anota y no se vuelve a intentar aunque la
|
|
776
|
+
* variable siga puesta: si no, cada reinicio del contenedor pediría entrar otra vez.
|
|
777
|
+
*/
|
|
778
|
+
function bootstrapJoin () {
|
|
779
|
+
const raw = process.env.DOTRINO_JOIN_FILE
|
|
780
|
+
? (() => { try { return fs.readFileSync(process.env.DOTRINO_JOIN_FILE, 'utf8') } catch (e) { console.error('[vault] could not read DOTRINO_JOIN_FILE: %s', e.message); return '' } })()
|
|
781
|
+
: (process.env.DOTRINO_JOIN || '')
|
|
782
|
+
const texto = String(raw).trim()
|
|
783
|
+
if (!texto) return
|
|
784
|
+
|
|
785
|
+
let qr = null
|
|
786
|
+
try { qr = parseInvite(texto) } catch (_) {}
|
|
787
|
+
if (!qr?.sn || !qr?.iss || !qr?.proxy) {
|
|
788
|
+
console.error('[vault] the invitation in DOTRINO_JOIN is not valid; ignoring it')
|
|
789
|
+
return
|
|
790
|
+
}
|
|
791
|
+
const marca = path.join(dir, 'bootstrap.json')
|
|
792
|
+
const hecho = readJsonSafe(marca)
|
|
793
|
+
if (hecho?.sn === qr.sn) return // ya se usó: un reinicio no vuelve a pedir entrar
|
|
794
|
+
|
|
795
|
+
writeJson(marca, { at: Date.now(), sn: qr.sn, iss: qr.iss })
|
|
796
|
+
writeJson(path.join(dir, 'join-request.json'), {
|
|
797
|
+
qr,
|
|
798
|
+
label: 'bóveda',
|
|
799
|
+
...(process.env.DOTRINO_JOIN_NAME ? { name: process.env.DOTRINO_JOIN_NAME } : {})
|
|
800
|
+
})
|
|
801
|
+
console.log('[vault] joining the account of another vault (invitation from the deployment)…')
|
|
802
|
+
console.log('[vault] the code to type in the OTHER vault will appear below')
|
|
803
|
+
serve()
|
|
804
|
+
}
|
|
805
|
+
bootstrapJoin()
|
|
806
|
+
|
|
807
|
+
// ACTUALIZAR EL PAQUETE = ACTUALIZAR EL SERVICIO. El instalador no puede reiniciarnos
|
|
808
|
+
// (somos un servicio de USUARIO y él corre como root), así que lo hacemos nosotros: si
|
|
809
|
+
// el binario cambia en el disco, nos vamos y systemd nos levanta con el nuevo. Solo bajo
|
|
810
|
+
// systemd; ver `src/selfupdate.js` para por qué eso no es negociable.
|
|
811
|
+
const vigia = watchBinary({
|
|
812
|
+
log: console.log,
|
|
813
|
+
exit: () => shutdown('new binary installed')
|
|
814
|
+
})
|
|
815
|
+
if (vigia) console.log('[vault] watching for updates: a new binary restarts the service on its own')
|
|
816
|
+
|
|
653
817
|
console.log('[vault] servicio listo.')
|
|
654
818
|
return mgr
|
|
655
819
|
}
|
package/src/keyowner.js
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* keyowner.js — ESTE DIRECTORIO ES DE ESTA LLAVE, Y DE NINGUNA OTRA.
|
|
3
|
+
*
|
|
4
|
+
* Es lo único que hay que proteger (dueño, 2026-08-30): *«cada proceso tenga un directorio
|
|
5
|
+
* alusivo a su llave para que nunca se mezclen»*.
|
|
6
|
+
*
|
|
7
|
+
* Y lo que hay detrás es que **no hay datos compartidos**. Cada bóveda tiene su directorio
|
|
8
|
+
* entero —su identidad, sus perfiles, sus sobres— así que dos procesos nunca escriben el
|
|
9
|
+
* mismo archivo. No hace falta fusionar nada ni repartir carpetas por dentro: hace falta
|
|
10
|
+
* que nadie acabe, por accidente, arrancando con la identidad de otro.
|
|
11
|
+
*
|
|
12
|
+
* Cómo se accidenta uno: un `docker compose` duplicado, un `DOTRINO_VAULT_DIR` copiado de
|
|
13
|
+
* otro servicio, un respaldo restaurado encima. Sin esto, el proceso arranca tan tranquilo
|
|
14
|
+
* con una maestra que no es la suya y se identifica en el proxio como otro.
|
|
15
|
+
*
|
|
16
|
+
* La marca va EN CLARO a propósito: es una pubkey, no un secreto, y tiene que poder
|
|
17
|
+
* leerse antes de abrir nada.
|
|
18
|
+
*/
|
|
19
|
+
import fs from 'node:fs'
|
|
20
|
+
import path from 'node:path'
|
|
21
|
+
import { pubkeyId } from '@dotrino/identity/capabilities'
|
|
22
|
+
|
|
23
|
+
export const OWNER_FILE = 'key.json'
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* EL NOMBRE DE LA CARPETA SALE DE LA LLAVE.
|
|
27
|
+
*
|
|
28
|
+
* Una llave, una carpeta (dueño, 2026-08-30): un proceso con varios perfiles tiene varias
|
|
29
|
+
* llaves maestras y, por tanto, varias carpetas. Con el nombre derivado de la llave eso
|
|
30
|
+
* deja de ser algo que se comprueba y pasa a ser imposible por construcción — dos llaves
|
|
31
|
+
* no pueden caer en la misma carpeta porque no se llaman igual.
|
|
32
|
+
*
|
|
33
|
+
* Empieza por la huella que se le enseña a una persona (`AB12-CD34`, la misma que sale en
|
|
34
|
+
* `members` y en `profile ls`), para poder mirar el disco y reconocer de quién es cada
|
|
35
|
+
* una; y sigue con 16 hex más, porque para *garantizar* que no chocan hacen falta más bits
|
|
36
|
+
* de los que caben en algo que se lee en voz alta. Quitándole los guiones, el nombre
|
|
37
|
+
* EMPIEZA por el mismo id que la bóveda imprime al arrancar (`listo · id 0571465f61a2fac8`),
|
|
38
|
+
* así que se puede casar el log con la carpeta de un vistazo.
|
|
39
|
+
*
|
|
40
|
+
* Ojo con la palabra «maestra»: cuando esta bóveda se une a la cuenta de otra (`join`), su
|
|
41
|
+
* llave sigue siendo la misma pero pasa a ser MIEMBRO del acta ajena, no su master. Lo que
|
|
42
|
+
* nombra la carpeta es la llave que esta bóveda tiene para ese perfil, que no cambia
|
|
43
|
+
* nunca; lo que cambia es su papel.
|
|
44
|
+
*/
|
|
45
|
+
export async function keyDirName (pub) {
|
|
46
|
+
const h = await pubkeyId(pub) // SHA-256 del JWK canónico, 64 hex
|
|
47
|
+
const label = h.slice(0, 8).toUpperCase()
|
|
48
|
+
return `${label.slice(0, 4)}-${label.slice(4, 8)}-${h.slice(8, 24)}`
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** De quién es este directorio, o `null` si todavía no lo dice. */
|
|
52
|
+
export function keyOwnerOf (dir) {
|
|
53
|
+
try { return JSON.parse(fs.readFileSync(path.join(dir, OWNER_FILE), 'utf8'))?.pub || null } catch (_) { return null }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Comprueba que este directorio es de esta llave, o lo marca si está por estrenar.
|
|
58
|
+
* Lanza con `code: 'key-mismatch'` si es de otra — sin tocar nada.
|
|
59
|
+
*/
|
|
60
|
+
export function assertKeyOwnsDir (dir, pub) {
|
|
61
|
+
if (!pub) return null // sin llave todavía: no hay nada que comparar
|
|
62
|
+
const previo = keyOwnerOf(dir)
|
|
63
|
+
|
|
64
|
+
if (previo && previo !== pub) {
|
|
65
|
+
const e = new Error(
|
|
66
|
+
'this data directory belongs to a DIFFERENT identity. Two vaults must never share one: ' +
|
|
67
|
+
'each process needs its own directory, or one ends up running with the other\'s master key. ' +
|
|
68
|
+
'Point this one at its own DOTRINO_VAULT_DIR. Nothing was modified.'
|
|
69
|
+
)
|
|
70
|
+
e.code = 'key-mismatch'
|
|
71
|
+
throw e
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (!previo) {
|
|
75
|
+
try {
|
|
76
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 })
|
|
77
|
+
fs.writeFileSync(path.join(dir, OWNER_FILE), JSON.stringify({ pub, at: Date.now() }, null, 2) + '\n', { mode: 0o600 })
|
|
78
|
+
} catch (_) { /* si no se puede marcar, no se bloquea el arranque */ }
|
|
79
|
+
}
|
|
80
|
+
return pub
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export default { assertKeyOwnsDir, keyOwnerOf, keyDirName, OWNER_FILE }
|
package/src/manager.js
CHANGED
|
@@ -12,6 +12,8 @@ import { startVault } from './vault.js'
|
|
|
12
12
|
import { openProfiles } from './profiles.js'
|
|
13
13
|
import { installNodeGlobals } from './node-globals.js'
|
|
14
14
|
import { dataDir, ensureDir } from './paths.js'
|
|
15
|
+
import { Identity } from '@dotrino/identity/node'
|
|
16
|
+
import { atRestFor } from './atrest.js'
|
|
15
17
|
|
|
16
18
|
/**
|
|
17
19
|
* D12 (`docs/acta-de-perfil.md`): la bóveda **no** borra una cuenta que ella manda si
|
|
@@ -49,7 +51,26 @@ export async function startVaultManager ({ root = dataDir(), proxyUrl, log = con
|
|
|
49
51
|
onAutoLock: (id) => log('[vault] profile %s locked itself after %d min idle (the console; devices keep working)',
|
|
50
52
|
id, Math.round(profiles.autoLockMs / 60000))
|
|
51
53
|
})
|
|
52
|
-
|
|
54
|
+
/**
|
|
55
|
+
* ACUÑAR (o simplemente LEER) la llave de una carpeta, y cerrarla enseguida.
|
|
56
|
+
*
|
|
57
|
+
* Existe porque el nombre de la carpeta sale de la llave y hay que saber cuál es antes
|
|
58
|
+
* de poder ponérselo. La llave se genera en memoria y se escribe acto seguido —no hay
|
|
59
|
+
* costura entre las dos cosas en `Identity.connect`—, así que se acuña donde sea y
|
|
60
|
+
* después se mueve la carpeta.
|
|
61
|
+
*
|
|
62
|
+
* Se CIERRA (`destroy`) antes de devolver: quien llama va a renombrar la carpeta, y una
|
|
63
|
+
* identidad abierta se quedó con la ruta vieja como texto.
|
|
64
|
+
*/
|
|
65
|
+
async function mintKey (dir) {
|
|
66
|
+
const identity = await Identity.connect({ dir, atRest: atRestFor(dir) })
|
|
67
|
+
try {
|
|
68
|
+
if (!identity.me?.publickey) await identity.setMyNickname('')
|
|
69
|
+
return identity.me?.publickey || null
|
|
70
|
+
} finally { try { identity.destroy() } catch (_) {} }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const migrated = await profiles.migrate(mintKey)
|
|
53
74
|
if (migrated?.migrated) log('[vault] identidad mono-perfil migrada al perfil %s', migrated.id)
|
|
54
75
|
|
|
55
76
|
const running = new Map() // id -> instancia de startVault
|
|
@@ -79,7 +100,15 @@ export async function startVaultManager ({ root = dataDir(), proxyUrl, log = con
|
|
|
79
100
|
}
|
|
80
101
|
|
|
81
102
|
for (const p of profiles.list()) {
|
|
82
|
-
|
|
103
|
+
// ANTES de abrir: la carpeta tiene que llamarse como su llave. Es toda la migración
|
|
104
|
+
// (mover la data a la carpeta que le toca) y va aquí porque es el único momento en que
|
|
105
|
+
// la identidad está cerrada — renombrarla abierta la deja escribiendo en la ruta vieja.
|
|
106
|
+
let id = p.id
|
|
107
|
+
try {
|
|
108
|
+
id = await profiles.ensureNamedByKey(p.id, mintKey)
|
|
109
|
+
if (id !== p.id) log('[vault] profile %s renamed to %s (the folder is named after its key)', p.id, id)
|
|
110
|
+
} catch (e) { log('[vault] could not name the folder of %s after its key: %s', p.id, e.message) }
|
|
111
|
+
try { await open(id) } catch (e) { log('[vault] no se pudo abrir el perfil %s: %s', id, e.message) }
|
|
83
112
|
}
|
|
84
113
|
|
|
85
114
|
const get = (id) => {
|
|
@@ -109,8 +138,8 @@ export async function startVaultManager ({ root = dataDir(), proxyUrl, log = con
|
|
|
109
138
|
* (camino A): la bóveda pone el sitio y la llave que entrará como miembro, pero la
|
|
110
139
|
* cuenta la trae el dispositivo y se adopta al emparejar.
|
|
111
140
|
*/
|
|
112
|
-
async add (name, { adopt = false } = {}) {
|
|
113
|
-
const p = profiles.add(name, { adopt })
|
|
141
|
+
async add (name, { adopt = false, kek = null } = {}) {
|
|
142
|
+
const p = await profiles.add(name, { adopt, kek, mintKey })
|
|
114
143
|
await open(p.id)
|
|
115
144
|
log('[vault] perfil creado%s: %s (%s)', adopt ? ' (a la espera de adoptar una cuenta)' : '', p.name || '(sin nombre)', p.id)
|
|
116
145
|
return profiles.get(p.id)
|
package/src/passwords.js
CHANGED
|
@@ -27,7 +27,8 @@ export const PASSWORDS_CAP = 'passwords'
|
|
|
27
27
|
* `cek` la llave de la bóveda de contraseñas (CryptoKey AES-GCM)
|
|
28
28
|
* `isAllowed(pubkey)` qué aparatos pueden pedir — lo decide el acta, no esto
|
|
29
29
|
* `encPubOf(pubkey)` su llave de cifrado, para sellarle la respuesta
|
|
30
|
-
* `needsApproval(pubkey)` si ese aparato está marcado para aprobar
|
|
30
|
+
* `needsApproval(pubkey)` si ese aparato está marcado para aprobar. Se compone con el
|
|
31
|
+
* criterio del propio protocolo: solo lo PRIVADO pregunta
|
|
31
32
|
* `approve({ pubkey, op })` pide el visto bueno (el teléfono) y espera
|
|
32
33
|
* `devices()` / `unlink(pubkey)` administración, opcional
|
|
33
34
|
* `audit(op, info)` la bitácora del vault
|
|
@@ -53,9 +54,19 @@ export function createPasswordDesk (opts = {}) {
|
|
|
53
54
|
vault,
|
|
54
55
|
isAllowed,
|
|
55
56
|
encPubOf,
|
|
56
|
-
//
|
|
57
|
-
//
|
|
58
|
-
|
|
57
|
+
// Dos condiciones, y las dos tienen que darse:
|
|
58
|
+
//
|
|
59
|
+
// · que ese APARATO esté marcado para aprobar (la política del vault: se pide una
|
|
60
|
+
// vez y dura mientras el vault siga encendido, igual que los cajones de secretos);
|
|
61
|
+
// · y que lo que pide sea PRIVADO — una contraseña, un código de dos pasos, o un
|
|
62
|
+
// campo que el usuario marcó. Rellenar un nombre no es sacar un secreto, y pedir
|
|
63
|
+
// permiso para todo enseña a decir que sí sin mirar.
|
|
64
|
+
//
|
|
65
|
+
// El segundo criterio se toma del propio responder (`wantsPrivate`) en vez de
|
|
66
|
+
// reescribirlo aquí: si cada bóveda tuviera su idea de qué es privado, serían tres
|
|
67
|
+
// bóvedas distintas otra vez.
|
|
68
|
+
needsApproval: async (op, payload, pubkey) =>
|
|
69
|
+
needsApproval(pubkey) && await responder.wantsPrivate(op, payload),
|
|
59
70
|
approve,
|
|
60
71
|
admin: devices && unlink ? { devices, unlink } : null,
|
|
61
72
|
onRequest: (r) => {
|