@dotrino/vaultd 0.52.0 → 0.56.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 +14 -7
- package/bin/dotrino-vaultd.js +27 -1
- package/lib/src/atrest.js +116 -2
- 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 +44 -5
- package/package.json +4 -4
- package/src/ctl.js +311 -14
- package/src/daemon.js +204 -22
- package/src/keyowner.js +83 -0
- package/src/manager.js +41 -6
- package/src/passwords.js +15 -4
- package/src/profiles.js +227 -25
- package/src/tui/app.js +77 -2
- package/src/tui/i18n.js +2 -0
- package/src/vault.js +137 -19
package/src/daemon.js
CHANGED
|
@@ -16,8 +16,10 @@
|
|
|
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'
|
|
21
23
|
import { VERSION } from './version.js'
|
|
22
24
|
|
|
23
25
|
const readJsonSafe = (f) => readJson(f, null)
|
|
@@ -32,21 +34,33 @@ const rm = (f) => { try { fs.rmSync(f, { force: true }) } catch (_) {} }
|
|
|
32
34
|
* datos, los dos con tu identidad y los dos conectados al proxy; el segundo pisaba el pid
|
|
33
35
|
* de `state.json`, así que el CLI solo le hablaba a uno y el otro quedaba de fantasma.
|
|
34
36
|
*
|
|
35
|
-
* El candado
|
|
36
|
-
*
|
|
37
|
-
*
|
|
37
|
+
* El candado vive en `vault.lock` (ver `lib/src/lock.js`) y funciona TAMBIÉN entre
|
|
38
|
+
* máquinas: `O_EXCL` para la exclusión y un latido para que un corte de luz no deje el
|
|
39
|
+
* directorio bloqueado para siempre. Antes era un pid, y un pid no cruza contenedores ni
|
|
40
|
+
* hosts — dos contenedores sobre el mismo volumen arrancaban los dos.
|
|
41
|
+
*
|
|
42
|
+
* Con `DOTRINO_VAULT_DIR` distintos conviven cuantas quieras, y es así como se ponen
|
|
43
|
+
* varias bóvedas en un mismo disco: cada una con su directorio entero, sin nada
|
|
44
|
+
* compartido. Lo que colisiona es el directorio, no el programa.
|
|
38
45
|
*/
|
|
39
46
|
function assertSingleInstance (dir) {
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
47
|
+
try {
|
|
48
|
+
const { release } = takeLock(dir)
|
|
49
|
+
// Se suelta al salir por las buenas. Si el proceso muere de golpe el candado se queda,
|
|
50
|
+
// y por eso caduca por latido: uno inmortal sería peor que ninguno.
|
|
51
|
+
const soltar = () => { try { release() } catch (_) {} }
|
|
52
|
+
process.once('exit', soltar)
|
|
53
|
+
for (const sig of ['SIGINT', 'SIGTERM']) process.once(sig, () => { soltar(); process.exit(0) })
|
|
54
|
+
} catch (e) {
|
|
55
|
+
if (e?.code !== 'vault-locked') throw e
|
|
56
|
+
console.error('A vault is already running on this data.')
|
|
57
|
+
console.error(' %s', e.message)
|
|
58
|
+
console.error(' data: %s', dir)
|
|
59
|
+
console.error('Two vaults on the same directory are not two vaults: they are the SAME one')
|
|
60
|
+
console.error('running twice — same master key, both sealing records as the same sealer.')
|
|
61
|
+
console.error('Stop the other one, or use DOTRINO_VAULT_DIR to give this one its own.')
|
|
62
|
+
process.exit(3)
|
|
63
|
+
}
|
|
50
64
|
}
|
|
51
65
|
|
|
52
66
|
export async function runDaemon () {
|
|
@@ -74,6 +88,9 @@ export async function runDaemon () {
|
|
|
74
88
|
writeJson(stateFile, {
|
|
75
89
|
v: 2, version: daemonVersion, fingerprint: cur.fingerprint || null, iss: cur.iss || null,
|
|
76
90
|
proxy: proxyUrl, pid: process.pid, startedAt: new Date().toISOString(),
|
|
91
|
+
// Cuánto aguanta abierto el candado sin usarse. Va en la foto para que la consola
|
|
92
|
+
// pueda DECIRLO en vez de llevar su propio número (que se desincronizaría).
|
|
93
|
+
autoLockMs: mgr.profiles.autoLockMs,
|
|
77
94
|
current: mgr.currentId(), profiles: mgr.summary()
|
|
78
95
|
})
|
|
79
96
|
}
|
|
@@ -105,7 +122,14 @@ export async function runDaemon () {
|
|
|
105
122
|
const resolveTarget = (req) => {
|
|
106
123
|
try {
|
|
107
124
|
const id = req?.profile ? mgr.resolve(req.profile) : mgr.currentId()
|
|
108
|
-
|
|
125
|
+
const locked = mgr.profiles.isLocked(id)
|
|
126
|
+
// ESTO CUENTA COMO USO: el candado se cierra solo a los 5 min de no usarse
|
|
127
|
+
// (`profiles.js`), y quien lo usa es esta consola. Va aquí y no en cada `case`
|
|
128
|
+
// porque toda petición de la CLI/TUI pasa por este punto, y así ninguna se olvida.
|
|
129
|
+
// Lo que un aparato pida por el proxy NO pasa por aquí, que es justo lo que se
|
|
130
|
+
// quiere: el candado no es suyo y no debe alargarlo.
|
|
131
|
+
if (!locked) mgr.profiles.touch(id)
|
|
132
|
+
return { id, vault: mgr.get(id), locked }
|
|
109
133
|
} catch (e) { console.error('[vault] invalid profile in the request:', e.message); return null }
|
|
110
134
|
}
|
|
111
135
|
/** La bóveda destino, o `null` si el perfil está bloqueado (la petición no se atiende). */
|
|
@@ -119,6 +143,19 @@ export async function runDaemon () {
|
|
|
119
143
|
// --- SIGUSR1: iniciar emparejamiento ---
|
|
120
144
|
const pairFile = path.join(dir, 'pair.json')
|
|
121
145
|
let pendingApproval = false // lo pidió `pair --approval`; se aplica al aprobar
|
|
146
|
+
/**
|
|
147
|
+
* Lo pidió `pair --admin`: el aparato que entre por ESTA invitación podrá administrar.
|
|
148
|
+
*
|
|
149
|
+
* Sigue en pie la regla de que **ningún QR concede administración**: el QR no lleva
|
|
150
|
+
* nada. Lo que hay es una nota LOCAL de esta bóveda, y el permiso se aplica en el
|
|
151
|
+
* mismo gesto que ya era la puerta —aprobar con el código tecleado aquí—, exactamente
|
|
152
|
+
* igual que `--approval`. Es el mismo `caps <ID> +administra` que harías a mano un
|
|
153
|
+
* segundo después, sin tener que ir a buscar el ID.
|
|
154
|
+
*
|
|
155
|
+
* Existe por el contenedor: allí cada paso cuesta un `docker exec`, y el primer aparato
|
|
156
|
+
* de una bóveda recién desplegada es SIEMPRE la consola.
|
|
157
|
+
*/
|
|
158
|
+
let pendingAdmin = false
|
|
122
159
|
const pairReqFile = path.join(dir, 'pair-request.json')
|
|
123
160
|
async function handlePairingRequest () {
|
|
124
161
|
try {
|
|
@@ -164,6 +201,7 @@ export async function runDaemon () {
|
|
|
164
201
|
// `pair --approval`: el aparato que entre por esta invitación pedirá el visto bueno del
|
|
165
202
|
// teléfono cada vez que reciba claves privadas (se aplica al aprobarlo, abajo).
|
|
166
203
|
pendingApproval = !!pairReq?.approval
|
|
204
|
+
pendingAdmin = !!pairReq?.admin
|
|
167
205
|
// `profile`/`profileName`: la CUENTA del vault a la que entra el dispositivo.
|
|
168
206
|
// Con varias bóvedas en el mismo daemon, el QR sale de UNA y quien empareja
|
|
169
207
|
// tiene que verlo (lo muestran la TUI y `dotrino-vault pair`). El nombre viaja
|
|
@@ -199,6 +237,10 @@ export async function runDaemon () {
|
|
|
199
237
|
const rejectReqFile = path.join(dir, 'reject-request.json')
|
|
200
238
|
const revokeReqFile = path.join(dir, 'revoke-request.json')
|
|
201
239
|
const secretReqFile = path.join(dir, 'secret-request.json')
|
|
240
|
+
// MULTIVAULT: esta bóveda se UNE a la cuenta de otra. Va por aquí y no por `pair` porque
|
|
241
|
+
// es el papel contrario — aquí no se invita a nadie, se acepta una invitación ajena.
|
|
242
|
+
const joinReqFile = path.join(dir, 'join-request.json')
|
|
243
|
+
const joinResFile = path.join(dir, 'join.json')
|
|
202
244
|
const secretsListFile = path.join(dir, 'secrets-list.json')
|
|
203
245
|
/**
|
|
204
246
|
* Por qué falló la última orden de variables. La consola pide el cambio y el volcado
|
|
@@ -247,13 +289,29 @@ export async function runDaemon () {
|
|
|
247
289
|
if (r?.rekeyed) console.log('[vault] secrets master re-sealed (%d drawer(s))', r.drawers)
|
|
248
290
|
}
|
|
249
291
|
|
|
292
|
+
/**
|
|
293
|
+
* BORRA la llave derivada en cuanto se usó. Es lo único de la contraseña que se puede
|
|
294
|
+
* borrar de verdad: un `string` de JS no se puede pisar (lo copia y lo mueve el motor
|
|
295
|
+
* hasta que pase el recolector), pero un `Uint8Array` sí, y es el que lleva el material
|
|
296
|
+
* con el que se abre la copia maestra. Vale lo que vale — no hace milagros con un
|
|
297
|
+
* volcado de memoria tomado en el instante justo —, pero acorta la ventana de horas a
|
|
298
|
+
* milisegundos, que es la diferencia entre «estaba ahí» y «estuvo».
|
|
299
|
+
*/
|
|
300
|
+
const wipe = (k) => { try { if (k instanceof Uint8Array) k.fill(0) } catch (_) {} }
|
|
301
|
+
|
|
250
302
|
async function handleProfileRequest (req) {
|
|
251
|
-
|
|
303
|
+
// Resolver el destino de una orden de perfil CUENTA COMO USO: estira el plazo del
|
|
304
|
+
// bloqueo automático igual que cualquier otra cosa que se haga desde la consola.
|
|
305
|
+
const ref = () => {
|
|
306
|
+
const id = mgr.resolve(req.profile || mgr.currentId())
|
|
307
|
+
mgr.profiles.touch(id)
|
|
308
|
+
return id
|
|
309
|
+
}
|
|
252
310
|
switch (req.op) {
|
|
253
311
|
case 'list': return {} // el volcado de perfiles ya se hace abajo
|
|
254
312
|
// `id`: quien la crea necesita saber CUÁL quedó, no adivinar por nombre (dos
|
|
255
313
|
// cuentas pueden llamarse igual). Lo usa «emparejar en una cuenta nueva».
|
|
256
|
-
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 } }
|
|
314
|
+
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 } }
|
|
257
315
|
case 'rm': { const r = await mgr.remove(req.profile); return { done: `perfil borrado: ${r.name || r.id}` } }
|
|
258
316
|
case 'rename': { const p = mgr.profiles.rename(ref(), req.name); return { done: `perfil renombrado: ${p.name}` } }
|
|
259
317
|
case 'use': { const p = mgr.profiles.setCurrent(ref()); return { done: `perfil activo: ${p.name || p.id}` } }
|
|
@@ -266,8 +324,9 @@ export async function runDaemon () {
|
|
|
266
324
|
const id = ref()
|
|
267
325
|
await mgr.profiles.unlock(id, req.password)
|
|
268
326
|
let note = ''
|
|
327
|
+
let ak = null
|
|
269
328
|
try {
|
|
270
|
-
|
|
329
|
+
ak = await mgr.profiles.adminKey(id, req.password)
|
|
271
330
|
// REHACER el llavero, no solo saldar: con la frase delante se puede dejar cada
|
|
272
331
|
// cajón envuelto para exactamente quien dice el acta — creando lo que falta,
|
|
273
332
|
// reemplazando lo que alguien metiera mal y quitando lo que sobre.
|
|
@@ -277,7 +336,8 @@ export async function runDaemon () {
|
|
|
277
336
|
if (r?.dropped) note = ` · llavero al día (${r.dropped} envoltura(s) de más retirada(s))`
|
|
278
337
|
else if (r?.wrapped) note = ' · llavero al día'
|
|
279
338
|
} catch (e) { console.error('[vault] could not rebuild the keyring on unlock:', e.message) }
|
|
280
|
-
|
|
339
|
+
finally { wipe(ak) }
|
|
340
|
+
return { done: 'perfil desbloqueado' + note, autoLockMs: mgr.profiles.autoLockMs }
|
|
281
341
|
}
|
|
282
342
|
case 'lock': { mgr.profiles.lock(ref()); return { done: 'perfil bloqueado' } }
|
|
283
343
|
// PONER contraseña: los secretos pasan de abrirse con la llave de la máquina a
|
|
@@ -291,7 +351,7 @@ export async function runDaemon () {
|
|
|
291
351
|
const vieja = tenia ? await mgr.profiles.adminKey(id, req.current) : null
|
|
292
352
|
await mgr.profiles.setPassword(id, req.password)
|
|
293
353
|
const nueva = await mgr.profiles.adminKey(id, req.password)
|
|
294
|
-
await rekey(id, vieja, nueva)
|
|
354
|
+
try { await rekey(id, vieja, nueva) } finally { wipe(vieja); wipe(nueva) }
|
|
295
355
|
return { done: 'contraseña guardada' }
|
|
296
356
|
}
|
|
297
357
|
// QUITARLA: al revés. Se abre la copia maestra con la frase y se vuelve a cerrar
|
|
@@ -301,7 +361,7 @@ export async function runDaemon () {
|
|
|
301
361
|
const id = ref()
|
|
302
362
|
if (!req.password) throw new Error('removing the password needs the current one: the secrets must be re-sealed before it goes')
|
|
303
363
|
const vieja = await mgr.profiles.adminKey(id, req.password)
|
|
304
|
-
await rekey(id, vieja, null)
|
|
364
|
+
try { await rekey(id, vieja, null) } finally { wipe(vieja) }
|
|
305
365
|
mgr.profiles.removePassword(id)
|
|
306
366
|
return { done: 'contraseña quitada · los secretos ahora se abren con la llave de esta máquina' }
|
|
307
367
|
}
|
|
@@ -326,6 +386,18 @@ export async function runDaemon () {
|
|
|
326
386
|
const r = await vault.approveDevice(appr.code); rm(pendingEnrollFile); rm(pairFile)
|
|
327
387
|
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
388
|
pendingApproval = false
|
|
389
|
+
// `pair --admin`: se le SUMA `admin` a lo que ya tiene, no se le reescriben los
|
|
390
|
+
// permisos — el aparato acaba de entrar con el scope que pidió la invitación.
|
|
391
|
+
if (pendingAdmin && r?.deviceId) {
|
|
392
|
+
try {
|
|
393
|
+
const rec = await vault.profileMembers()
|
|
394
|
+
const m = (rec?.members || []).find((x) => x.id === r.deviceId)
|
|
395
|
+
if (!m?.pub) throw new Error('the device is not in the record yet')
|
|
396
|
+
await vault.setCaps(m.pub, [...new Set([...(m.caps || []), 'admin'])])
|
|
397
|
+
console.log('[vault] the new device can ADMINISTER this account (console)')
|
|
398
|
+
} catch (e) { console.error('[vault] could not grant admin: %s', e.message) }
|
|
399
|
+
}
|
|
400
|
+
pendingAdmin = false
|
|
329
401
|
console.log('[vault] aprobado %s', r.deviceId)
|
|
330
402
|
answer({ ok: true, deviceId: r.deviceId || null })
|
|
331
403
|
} catch (e) {
|
|
@@ -366,6 +438,62 @@ export async function runDaemon () {
|
|
|
366
438
|
} catch (e) { console.error('[vault] revocation failed:', e.message) }
|
|
367
439
|
rm(revokeReqFile)
|
|
368
440
|
}
|
|
441
|
+
/**
|
|
442
|
+
* UNIRSE A LA CUENTA DE OTRA BÓVEDA (multivault). La llave que entra como miembro es
|
|
443
|
+
* la de ESTA bóveda —no una de aparato inventada—, que es lo que después permite
|
|
444
|
+
* darle `+sella` y que sea el respaldo de verdad de la otra.
|
|
445
|
+
*
|
|
446
|
+
* El código de confirmación lo genera esta bóveda y hay que TIPEARLO en la otra: la
|
|
447
|
+
* misma defensa de siempre, para que una invitación interceptada no baste.
|
|
448
|
+
*/
|
|
449
|
+
const join = readJsonSafe(joinReqFile)
|
|
450
|
+
if (join?.qr) {
|
|
451
|
+
rm(joinReqFile)
|
|
452
|
+
rm(joinResFile)
|
|
453
|
+
// LA CUENTA AJENA VA EN UN PERFIL DEL GESTOR, no en una cuenta interna de la
|
|
454
|
+
// identidad. Antes esto usaba `enrollDevice(…, { join: 'new' })`, que crea una
|
|
455
|
+
// cuenta más DENTRO de la identidad de un perfil que ya existía —y el gestor no se
|
|
456
|
+
// enteraba—: no había instancia de bóveda para ella, nadie se identificaba en el
|
|
457
|
+
// proxio con esa llave, y el aviso de la otra bóveda —con el acta donde acababa de
|
|
458
|
+
// conceder `sella`— no llegaba a ninguna parte. Se unía y no servía para nada.
|
|
459
|
+
//
|
|
460
|
+
// Un perfil nace vacío y `adopt: true`, que es la marca que deja a `joinProfile`
|
|
461
|
+
// cambiar su acta recién nacida por la que traiga la otra bóveda; sin ella, unirse
|
|
462
|
+
// sería pisar una cuenta con datos y se rechaza, que es lo correcto por defecto.
|
|
463
|
+
let nacido = null
|
|
464
|
+
try {
|
|
465
|
+
const p = await mgr.add(join.name || 'cuenta de la otra bóveda', { adopt: true, kek: join.kek || null })
|
|
466
|
+
nacido = p.id
|
|
467
|
+
const id = mgr.get(p.id)?.identity
|
|
468
|
+
if (!id) throw new Error('no identity for the new profile')
|
|
469
|
+
const off = id.onVault?.((e) => {
|
|
470
|
+
if (e?.phase === 'challenge' && e.code) {
|
|
471
|
+
console.log('[vault] type this code in the other vault: %s', e.code)
|
|
472
|
+
writeJson(joinResFile, { at: Date.now(), code: e.code, state: 'waiting', profile: p.id })
|
|
473
|
+
}
|
|
474
|
+
})
|
|
475
|
+
// `'current'` y no `'new'`: el perfil que se acaba de crear ES el sitio, y su
|
|
476
|
+
// llave recién hecha es la que entra en el acta de la otra bóveda. Crear ahí
|
|
477
|
+
// dentro otra cuenta más sería el bug de arriba otra vez, un nivel más abajo.
|
|
478
|
+
const r = await id.enrollDevice(join.qr, { label: join.label || 'bóveda', join: 'current' })
|
|
479
|
+
off?.()
|
|
480
|
+
// LA CUENTA RECIÉN ADOPTADA PASA A SER LA ACTIVA, y no es un capricho: es la
|
|
481
|
+
// razón por la que se hizo el `join`. Sin esto, el `Perfil 1` vacío que nace en
|
|
482
|
+
// el primer arranque sigue siendo el destino por defecto y todo lo que hagas
|
|
483
|
+
// después —emparejar un aparato, aprobarlo— entra en la cuenta equivocada sin
|
|
484
|
+
// decir nada. En un contenedor que arranca para respaldar una cuenta, ese perfil
|
|
485
|
+
// vacío no es más que un accidente del primer arranque.
|
|
486
|
+
try { mgr.profiles.setCurrent(p.id) } catch (_) {}
|
|
487
|
+
console.log('[vault] joined the account of the other vault (record #%s) as profile %s (now the active one)', r?.acta?.seq ?? '?', p.id)
|
|
488
|
+
writeJson(joinResFile, { at: Date.now(), state: 'done', seq: r?.acta?.seq ?? null, profile: p.id })
|
|
489
|
+
} catch (e) {
|
|
490
|
+
console.error('[vault] could not join:', e.message)
|
|
491
|
+
// El perfil nació para esto y está vacío: si el intento no llegó a término se va
|
|
492
|
+
// con él. Si no, cada reintento dejaba una cuenta fantasma en el conmutador.
|
|
493
|
+
if (nacido) { try { await mgr.remove(nacido) } catch (_) {} }
|
|
494
|
+
writeJson(joinResFile, { at: Date.now(), state: 'error', error: e.message })
|
|
495
|
+
}
|
|
496
|
+
}
|
|
369
497
|
// Secretos: `secret set/rm` (por SCOPE) y `secret device set/rm` (por APARATO),
|
|
370
498
|
// del CLI o de la TUI. El archivo con el valor vive un instante en el mismo dir
|
|
371
499
|
// 0700 del vault y se borra al consumir.
|
|
@@ -373,6 +501,7 @@ export async function runDaemon () {
|
|
|
373
501
|
if (sec?.op) {
|
|
374
502
|
rm(secretReqFile) // puede llevar la contraseña: fuera del disco cuanto antes
|
|
375
503
|
lastSecretError = null
|
|
504
|
+
let ak // fuera del try para poder BORRARLA pase lo que pase (ver `wipe`)
|
|
376
505
|
try {
|
|
377
506
|
const vault = targetOf(sec)
|
|
378
507
|
// Carga en GRUPO (`secret set ns K=v K2=v2`, `secret import`): todas las
|
|
@@ -384,7 +513,7 @@ export async function runDaemon () {
|
|
|
384
513
|
// convertir el archivo, rotar re-cifrando—: escribir no (§8.1). Sin ella se cae
|
|
385
514
|
// a la llave de la máquina, que es la protección de antes de esto (y el vault lo
|
|
386
515
|
// avisa al arrancar).
|
|
387
|
-
|
|
516
|
+
ak = sec.password ? await mgr.profiles.adminKey(sec.profile ? mgr.resolve(sec.profile) : mgr.currentId(), sec.password) : undefined
|
|
388
517
|
if (sec.op === 'migrate') {
|
|
389
518
|
// Sin lista a mano: la pone el vault, y es la MISMA que usa cualquier
|
|
390
519
|
// escritura (servicios del cajón + aparatos que administran). Con una lista
|
|
@@ -440,7 +569,7 @@ export async function runDaemon () {
|
|
|
440
569
|
code: e.code || (/wrong password/i.test(e.message) ? 'WRONG_PASSWORD' : 'SECRET_FAILED')
|
|
441
570
|
}
|
|
442
571
|
console.error('[vault] secret failed:', e.message)
|
|
443
|
-
}
|
|
572
|
+
} finally { wipe(ak) }
|
|
444
573
|
}
|
|
445
574
|
// Perfiles / candado.
|
|
446
575
|
const preq = readJsonSafe(profileReqFile)
|
|
@@ -621,6 +750,59 @@ export async function runDaemon () {
|
|
|
621
750
|
process.on('SIGTERM', () => shutdown('SIGTERM'))
|
|
622
751
|
process.on('SIGINT', () => shutdown('SIGINT'))
|
|
623
752
|
|
|
753
|
+
/**
|
|
754
|
+
* ARRANCAR YA UNIDO A UNA CUENTA — lo que hace desplegable un contenedor.
|
|
755
|
+
*
|
|
756
|
+
* El problema del contenedor no es la bóveda: es el PRIMER APARATO. Una bóveda recién
|
|
757
|
+
* levantada tiene que **enseñar** una invitación y **recibir** de vuelta un código
|
|
758
|
+
* tecleado, y un contenedor no tiene pantalla ni teclado. Con una sola bóveda no hay
|
|
759
|
+
* salida: alguien tiene que entrar (`docker exec`, `kubectl exec`, ECS Exec).
|
|
760
|
+
*
|
|
761
|
+
* Con dos, sí la hay, y es este camino: **la invitación la hace la bóveda que TIENE un
|
|
762
|
+
* humano delante** (tu PC) y el contenedor solo la acepta. Entonces todo lo interactivo
|
|
763
|
+
* pasa del lado donde hay alguien —el código se teclea allí— y el contenedor no
|
|
764
|
+
* necesita más que dos cosas que ya tiene: la invitación al arrancar, y su registro.
|
|
765
|
+
*
|
|
766
|
+
* docker run -e DOTRINO_JOIN="$(dotrino-vault pair --quiet)" …
|
|
767
|
+
* docker logs -f dotrino-vault → «type this code in the other vault: 123456»
|
|
768
|
+
* dotrino-vault approve 123456 (en tu PC)
|
|
769
|
+
*
|
|
770
|
+
* `DOTRINO_JOIN_FILE` es lo mismo apuntando a un archivo, y es lo que hay que usar en
|
|
771
|
+
* serio: una variable de entorno la ve cualquiera con `docker inspect`, y aunque la
|
|
772
|
+
* invitación caduque y sea de un solo uso, no tiene por qué quedar ahí escrita.
|
|
773
|
+
*
|
|
774
|
+
* SE HACE UNA VEZ. La invitación consumida se anota y no se vuelve a intentar aunque la
|
|
775
|
+
* variable siga puesta: si no, cada reinicio del contenedor pediría entrar otra vez.
|
|
776
|
+
*/
|
|
777
|
+
function bootstrapJoin () {
|
|
778
|
+
const raw = process.env.DOTRINO_JOIN_FILE
|
|
779
|
+
? (() => { 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 '' } })()
|
|
780
|
+
: (process.env.DOTRINO_JOIN || '')
|
|
781
|
+
const texto = String(raw).trim()
|
|
782
|
+
if (!texto) return
|
|
783
|
+
|
|
784
|
+
let qr = null
|
|
785
|
+
try { qr = parseInvite(texto) } catch (_) {}
|
|
786
|
+
if (!qr?.sn || !qr?.iss || !qr?.proxy) {
|
|
787
|
+
console.error('[vault] the invitation in DOTRINO_JOIN is not valid; ignoring it')
|
|
788
|
+
return
|
|
789
|
+
}
|
|
790
|
+
const marca = path.join(dir, 'bootstrap.json')
|
|
791
|
+
const hecho = readJsonSafe(marca)
|
|
792
|
+
if (hecho?.sn === qr.sn) return // ya se usó: un reinicio no vuelve a pedir entrar
|
|
793
|
+
|
|
794
|
+
writeJson(marca, { at: Date.now(), sn: qr.sn, iss: qr.iss })
|
|
795
|
+
writeJson(path.join(dir, 'join-request.json'), {
|
|
796
|
+
qr,
|
|
797
|
+
label: 'bóveda',
|
|
798
|
+
...(process.env.DOTRINO_JOIN_NAME ? { name: process.env.DOTRINO_JOIN_NAME } : {})
|
|
799
|
+
})
|
|
800
|
+
console.log('[vault] joining the account of another vault (invitation from the deployment)…')
|
|
801
|
+
console.log('[vault] the code to type in the OTHER vault will appear below')
|
|
802
|
+
serve()
|
|
803
|
+
}
|
|
804
|
+
bootstrapJoin()
|
|
805
|
+
|
|
624
806
|
console.log('[vault] servicio listo.')
|
|
625
807
|
return mgr
|
|
626
808
|
}
|
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
|
|
@@ -35,15 +37,40 @@ export function assertCanRemove ({ isMaster, memberCount, name = '' }) {
|
|
|
35
37
|
throw e
|
|
36
38
|
}
|
|
37
39
|
|
|
38
|
-
export async function startVaultManager ({ root = dataDir(), proxyUrl, log = console.log, onEnrollChallenge } = {}) {
|
|
40
|
+
export async function startVaultManager ({ root = dataDir(), proxyUrl, log = console.log, onEnrollChallenge, autoLockMs } = {}) {
|
|
39
41
|
ensureDir(root)
|
|
40
42
|
// El keypair de transporte del proxy-client es del PROCESO, no de la identidad:
|
|
41
43
|
// se instala apuntando a la RAÍZ (no al dir de un perfil) para que los perfiles
|
|
42
44
|
// no se peleen por el archivo ni se lo lleven al borrarse.
|
|
43
45
|
installNodeGlobals(root)
|
|
44
46
|
|
|
45
|
-
const profiles = openProfiles(root
|
|
46
|
-
|
|
47
|
+
const profiles = openProfiles(root, {
|
|
48
|
+
...(autoLockMs === undefined ? {} : { autoLockMs }),
|
|
49
|
+
// Que se cierre solo tiene que VERSE: si no, quien vuelve y encuentra la consola
|
|
50
|
+
// pidiendo la contraseña otra vez cree que algo se rompió.
|
|
51
|
+
onAutoLock: (id) => log('[vault] profile %s locked itself after %d min idle (the console; devices keep working)',
|
|
52
|
+
id, Math.round(profiles.autoLockMs / 60000))
|
|
53
|
+
})
|
|
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)
|
|
47
74
|
if (migrated?.migrated) log('[vault] identidad mono-perfil migrada al perfil %s', migrated.id)
|
|
48
75
|
|
|
49
76
|
const running = new Map() // id -> instancia de startVault
|
|
@@ -73,7 +100,15 @@ export async function startVaultManager ({ root = dataDir(), proxyUrl, log = con
|
|
|
73
100
|
}
|
|
74
101
|
|
|
75
102
|
for (const p of profiles.list()) {
|
|
76
|
-
|
|
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) }
|
|
77
112
|
}
|
|
78
113
|
|
|
79
114
|
const get = (id) => {
|
|
@@ -103,8 +138,8 @@ export async function startVaultManager ({ root = dataDir(), proxyUrl, log = con
|
|
|
103
138
|
* (camino A): la bóveda pone el sitio y la llave que entrará como miembro, pero la
|
|
104
139
|
* cuenta la trae el dispositivo y se adopta al emparejar.
|
|
105
140
|
*/
|
|
106
|
-
async add (name, { adopt = false } = {}) {
|
|
107
|
-
const p = profiles.add(name, { adopt })
|
|
141
|
+
async add (name, { adopt = false, kek = null } = {}) {
|
|
142
|
+
const p = await profiles.add(name, { adopt, kek, mintKey })
|
|
108
143
|
await open(p.id)
|
|
109
144
|
log('[vault] perfil creado%s: %s (%s)', adopt ? ' (a la espera de adoptar una cuenta)' : '', p.name || '(sin nombre)', p.id)
|
|
110
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) => {
|