@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/ctl.js
CHANGED
|
@@ -30,7 +30,8 @@ import { assertVar } from './secretsStore.js'
|
|
|
30
30
|
import { isValidSecretsNs } from './protocol.js'
|
|
31
31
|
import { parseEnvText, PAIR_RE } from '../lib/src/envtext.js'
|
|
32
32
|
import { qrToString } from './qr.js'
|
|
33
|
-
import { encodeInvite, inviteUrl } from '../lib/src/invite.js'
|
|
33
|
+
import { encodeInvite, inviteUrl, parseInvite } from '../lib/src/invite.js'
|
|
34
|
+
import { readConfig as readKekConfig, probe as probeKek, rekeyDir, encryptedFilesIn, CONFIG_FILE as KEK_CONFIG_FILE } from '../lib/src/atrest.js'
|
|
34
35
|
import { VERSION } from './version.js'
|
|
35
36
|
|
|
36
37
|
const dir = dataDir()
|
|
@@ -67,6 +68,20 @@ const writeReq = (name, obj) => {
|
|
|
67
68
|
}
|
|
68
69
|
|
|
69
70
|
const R = '\x1b[31m', B = '\x1b[1m', Z = '\x1b[0m' // rojo / negrita / reset
|
|
71
|
+
const D = '\x1b[2m' // apagado: para el dato que acompaña sin competir con el nombre
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* La fecha en que entró un aparato, corta y en local. Sin hora: lo que se busca al mirar
|
|
75
|
+
* esta lista es «¿de cuándo es este?», no el minuto exacto — y una fecha larga en cada
|
|
76
|
+
* línea convierte la lista en un muro.
|
|
77
|
+
*
|
|
78
|
+
* En español, como el resto de esta CLI: con el idioma del sistema salía «Jul 29, 2026»
|
|
79
|
+
* en medio de una frase en español, que es peor que cualquiera de los dos por separado.
|
|
80
|
+
*/
|
|
81
|
+
function fechaCorta (ms) {
|
|
82
|
+
try { return new Date(ms).toLocaleDateString('es', { year: 'numeric', month: 'short', day: 'numeric' }) }
|
|
83
|
+
catch (_) { return '' }
|
|
84
|
+
}
|
|
70
85
|
// La versión se inyecta en build (esbuild --define); en dev cae a 'dev'.
|
|
71
86
|
|
|
72
87
|
/**
|
|
@@ -190,6 +205,32 @@ async function cmdPair (args = []) {
|
|
|
190
205
|
// --approval: el aparato que entre pedirá tu aprobación (teléfono) en cada petición de
|
|
191
206
|
// claves privadas. Por defecto NO pide; se cambia después con `caps <ID> +permiso`.
|
|
192
207
|
const approval = args.includes('--approval')
|
|
208
|
+
// --admin: el aparato que entre por esta invitación podrá ADMINISTRAR (la consola).
|
|
209
|
+
// El QR no lleva nada: es una nota local de la bóveda y el permiso se aplica al aprobar
|
|
210
|
+
// con el código que tecleas aquí. Ahorra el `caps <ID> +administra` de después, que en un
|
|
211
|
+
// contenedor es otro `docker exec` y una vuelta a buscar el ID.
|
|
212
|
+
const admin = args.includes('--admin')
|
|
213
|
+
// --quiet: escupe SOLO la invitación (una línea) y termina. Sin QR y sin esperar.
|
|
214
|
+
// Para desplegar: en un contenedor no hay quien mire un QR pintado en una terminal, y un
|
|
215
|
+
// `pair` que se queda 2 minutos y medio esperando no se puede meter en un script.
|
|
216
|
+
const quiet = args.includes('--quiet')
|
|
217
|
+
// --kms <config.json>: el sitio que se va a crear (--new-account o --adopt) NACE con
|
|
218
|
+
// su clave de disco en el KMS. Va aquí y no en un paso posterior porque es el único
|
|
219
|
+
// momento que sirve: la llave de este aparato se genera al crear el perfil, y una
|
|
220
|
+
// migración posterior no deshace que haya existido bajo la clave de la máquina.
|
|
221
|
+
const kmsIdx = args.indexOf('--kms')
|
|
222
|
+
let pairKek = null
|
|
223
|
+
if (kmsIdx >= 0) {
|
|
224
|
+
const f = args[kmsIdx + 1]
|
|
225
|
+
if (!f || f.startsWith('-')) { console.error('uso: dotrino-vault pair --adopt --kms <config.json>'); process.exit(2) }
|
|
226
|
+
try { pairKek = JSON.parse(fs.readFileSync(f, 'utf8')) } catch (e) {
|
|
227
|
+
console.error('No se pudo leer %s: %s', f, e.message); process.exit(2)
|
|
228
|
+
}
|
|
229
|
+
try { probeKek(dataDir(), pairKek) } catch (e) {
|
|
230
|
+
console.error('El KMS no respondió (%s): %s', e.code || 'error', e.message)
|
|
231
|
+
console.error('No se creó ninguna cuenta.'); process.exit(1)
|
|
232
|
+
}
|
|
233
|
+
}
|
|
193
234
|
const scIdx = args.indexOf('--scope')
|
|
194
235
|
let scope = null
|
|
195
236
|
if (scIdx >= 0) {
|
|
@@ -201,6 +242,7 @@ async function cmdPair (args = []) {
|
|
|
201
242
|
const t = ALIAS[tok] || tok
|
|
202
243
|
if (t === 'admin' || t === 'administra') { console.error('`admin` no se empareja: concédelo desde el PC con dotrino-vault caps <ID> +administra'); process.exit(2) }
|
|
203
244
|
if (t === 'approve' || t === 'aprueba') { console.error('`approve` no se empareja: concédelo desde el PC con dotrino-vault caps <ID> +aprueba'); process.exit(2) }
|
|
245
|
+
if (t === 'sealer' || t === 'sella') { console.error('`sella` no se empareja: concédelo desde el PC con dotrino-vault caps <ID> +sella'); process.exit(2) }
|
|
204
246
|
if (t === 'sign' || t === 'read' || t === 'store') { scope.push('vault:' + t); continue }
|
|
205
247
|
// El gestor de contraseñas SÍ se empareja con su permiso puesto: es lo único que
|
|
206
248
|
// va a hacer ese aparato, y pedirlo en dos pasos era el paso que nadie daba.
|
|
@@ -220,11 +262,11 @@ async function cmdPair (args = []) {
|
|
|
220
262
|
if (naIdx >= 0) {
|
|
221
263
|
const next = args[naIdx + 1]
|
|
222
264
|
const name = (next && !next.startsWith('-')) ? next : `cuenta ${new Date().toISOString().slice(0, 10)}`
|
|
223
|
-
const d = await profileRequest('add', { name })
|
|
265
|
+
const d = await profileRequest('add', { name, ...(pairKek ? { kek: pairKek } : {}) })
|
|
224
266
|
if (d.error) { console.error('%s', d.error); process.exit(1) }
|
|
225
267
|
if (!d.id) { console.error('El daemon no dijo qué cuenta creó.'); process.exit(1) }
|
|
226
268
|
PROFILE = d.id // el emparejamiento y los comandos siguientes apuntan a ELLA
|
|
227
|
-
console.log('Cuenta nueva: %s (%s)', name, d.id)
|
|
269
|
+
console.log('Cuenta nueva%s: %s (%s)', pairKek ? ' (clave del disco en el KMS)' : '', name, d.id)
|
|
228
270
|
}
|
|
229
271
|
// `--adopt [nombre]`: la TERCERA respuesta a «¿de qué cuenta hablamos?» — el camino A.
|
|
230
272
|
// Aquí la cuenta NO sale de esta bóveda: la trae el aparato y esta bóveda pasa a
|
|
@@ -235,15 +277,28 @@ async function cmdPair (args = []) {
|
|
|
235
277
|
if (adopt) {
|
|
236
278
|
const next = args[adIdx + 1]
|
|
237
279
|
const name = (next && !next.startsWith('-')) ? next : `cuenta del dispositivo`
|
|
238
|
-
const d = await profileRequest('add', { name, adopt: true })
|
|
280
|
+
const d = await profileRequest('add', { name, adopt: true, ...(pairKek ? { kek: pairKek } : {}) })
|
|
239
281
|
if (d.error) { console.error('%s', d.error); process.exit(1) }
|
|
240
282
|
if (!d.id) { console.error('El daemon no dijo qué cuenta creó.'); process.exit(1) }
|
|
241
283
|
PROFILE = d.id
|
|
242
|
-
console.log('Cuenta a la espera de adoptar la del dispositivo: %s (%s)', name, d.id)
|
|
284
|
+
console.log('Cuenta a la espera de adoptar la del dispositivo%s: %s (%s)', pairKek ? ' (clave del disco en el KMS)' : '', name, d.id)
|
|
243
285
|
}
|
|
286
|
+
// `--kms` sin un sitio que crear no hace NADA, y callárselo es lo peor que se puede
|
|
287
|
+
// hacer aquí: el dueño se quedaría creyendo que su aparato nació en el KMS.
|
|
288
|
+
if (pairKek && !adopt && naIdx < 0) {
|
|
289
|
+
console.error('--kms solo sirve cuando se crea el sitio, porque la llave del aparato')
|
|
290
|
+
console.error('se genera en ese momento. Úsalo con una de las dos:')
|
|
291
|
+
console.error(' dotrino-vault pair --adopt --kms <config.json> (la cuenta la trae el aparato)')
|
|
292
|
+
console.error(' dotrino-vault pair --new-account --kms <config.json> (cuenta nueva, nacida aquí)')
|
|
293
|
+
console.error('')
|
|
294
|
+
console.error('Emparejar contra una cuenta que ya vive en esta bóveda no cambia su clave de')
|
|
295
|
+
console.error('disco: esa ya se escribió cuando se creó. Ver docs/llaves-de-hardware.md.')
|
|
296
|
+
process.exit(2)
|
|
297
|
+
}
|
|
298
|
+
|
|
244
299
|
// La petición se escribe SIEMPRE (aunque no haya --service): lleva a qué perfil
|
|
245
300
|
// se empareja el dispositivo.
|
|
246
|
-
writeReq('pair-request.json', { ...(service ? { service } : {}), ...(scope ? { scope } : {}), ...(adopt ? { mode: 'adopt' } : {}), ...(approval ? { approval: true } : {}) })
|
|
301
|
+
writeReq('pair-request.json', { ...(service ? { service } : {}), ...(scope ? { scope } : {}), ...(adopt ? { mode: 'adopt' } : {}), ...(approval ? { approval: true } : {}), ...(admin ? { admin: true } : {}) })
|
|
247
302
|
sendSignal(s.pid, 'SIGUSR1')
|
|
248
303
|
|
|
249
304
|
let pair = null
|
|
@@ -264,9 +319,12 @@ async function cmdPair (args = []) {
|
|
|
264
319
|
// UNA (la activa, o la de --profile). Decirlo evita enrolar el dispositivo en la
|
|
265
320
|
// equivocada; es la misma línea que muestra la TUI.
|
|
266
321
|
const acct = pair.profileName || pair.profile
|
|
322
|
+
// --quiet: la invitación y nada más, para poder capturarla desde un script.
|
|
323
|
+
if (quiet) { console.log(url); return }
|
|
267
324
|
if (acct) console.log('\nCuenta que se comparte: %s%s', acct, pair.profileName && pair.profile ? ` (${pair.profile})` : '')
|
|
268
325
|
console.log('\nEscanea este QR con el dispositivo que quieres conectar (válido %d min):\n', mins)
|
|
269
326
|
console.log(qrToString(url)) // el QR abre la consola de dispositivos y empareja solo
|
|
327
|
+
if (admin) console.log(`${R}${B}⚠ Y además podrá ADMINISTRAR: admitir y quitar aparatos.${Z}`)
|
|
270
328
|
console.log(`${R}${B}⚠ Este código deja LEER tus datos y FIRMAR con tu identidad.${Z}`)
|
|
271
329
|
console.log(`${R} NO lo compartas con nadie, ni con "soporte". Solo escanéalo en TU dispositivo.${Z}`)
|
|
272
330
|
console.log('\nO abre esta dirección en el dispositivo:\n ' + url)
|
|
@@ -306,6 +364,80 @@ function cmdApprove (code) {
|
|
|
306
364
|
console.log('Aprobando con el código %s… verifica con: dotrino-vault devices', code)
|
|
307
365
|
}
|
|
308
366
|
|
|
367
|
+
/**
|
|
368
|
+
* `dotrino-vault join <invitación>` — ESTA bóveda entra en la cuenta de OTRA.
|
|
369
|
+
*
|
|
370
|
+
* Es el papel contrario a `pair`: aquí no se invita, se acepta. Y lo que entra en el acta
|
|
371
|
+
* es la llave de ESTA bóveda, no una de aparato inventada — por eso después se le puede
|
|
372
|
+
* dar `+sella` y que sea el respaldo de verdad de la otra.
|
|
373
|
+
*
|
|
374
|
+
* El código que sale por pantalla hay que TIPEARLO en la otra bóveda (`approve`): una
|
|
375
|
+
* invitación interceptada no basta para entrar.
|
|
376
|
+
*/
|
|
377
|
+
async function cmdJoin (rest) {
|
|
378
|
+
const args = rest || []
|
|
379
|
+
// `--name <n>`: cómo se va a llamar aquí la cuenta ajena. Es un perfil MÁS de esta
|
|
380
|
+
// bóveda, y en una máquina que respalda a varias hace falta distinguirlas.
|
|
381
|
+
const nIdx = args.indexOf('--name')
|
|
382
|
+
const name = nIdx >= 0 && args[nIdx + 1] && !args[nIdx + 1].startsWith('-') ? args[nIdx + 1] : null
|
|
383
|
+
// `--kms <config.json>`: el perfil que se cree para la cuenta ajena NACE con su clave de
|
|
384
|
+
// disco en el KMS. Va aquí por la misma razón que en `pair`: es el único momento que
|
|
385
|
+
// sirve, porque la llave de esta bóveda para esa cuenta se genera al crear el perfil.
|
|
386
|
+
const kIdx = args.indexOf('--kms')
|
|
387
|
+
let kek = null
|
|
388
|
+
if (kIdx >= 0) {
|
|
389
|
+
const f = args[kIdx + 1]
|
|
390
|
+
if (!f || f.startsWith('-')) { console.error('uso: dotrino-vault join <invitación> --kms <config.json>'); process.exit(2) }
|
|
391
|
+
try { kek = JSON.parse(fs.readFileSync(f, 'utf8')) } catch (e) {
|
|
392
|
+
console.error('No se pudo leer %s: %s', f, e.message); process.exit(2)
|
|
393
|
+
}
|
|
394
|
+
try { probeKek(dataDir(), kek) } catch (e) {
|
|
395
|
+
console.error('El KMS no respondió (%s): %s', e.code || 'error', e.message)
|
|
396
|
+
console.error('No se creó ninguna cuenta.'); process.exit(1)
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
// Lo que queda tras quitar las banderas Y SUS VALORES es la invitación.
|
|
400
|
+
const consumidos = new Set()
|
|
401
|
+
for (const [i, v] of [[nIdx, name], [kIdx, kek]]) if (i >= 0) { consumidos.add(i); if (v != null) consumidos.add(i + 1) }
|
|
402
|
+
const texto = args.filter((a, i) => !consumidos.has(i) && !a.startsWith('-')).join(' ').trim()
|
|
403
|
+
if (!texto) {
|
|
404
|
+
console.error('uso: dotrino-vault join <invitación> [--name <n>] [--kms <config.json>]')
|
|
405
|
+
console.error(' (la invitación es lo que imprime «pair» en la otra bóveda)')
|
|
406
|
+
process.exit(2)
|
|
407
|
+
}
|
|
408
|
+
const qr = parseInvite(texto)
|
|
409
|
+
if (!qr?.sn || !qr?.iss || !qr?.proxy) {
|
|
410
|
+
console.error('Esa invitación no se entiende. Pega la línea completa que imprime «dotrino-vault pair».')
|
|
411
|
+
process.exit(2)
|
|
412
|
+
}
|
|
413
|
+
const s = requireDaemon()
|
|
414
|
+
const res = path.join(dir, 'join.json')
|
|
415
|
+
try { fs.rmSync(res, { force: true }) } catch (_) {}
|
|
416
|
+
writeReq('join-request.json', { qr, label: 'bóveda', ...(name ? { name } : {}), ...(kek ? { kek } : {}) })
|
|
417
|
+
sendSignal(s.pid, 'SIGUSR2')
|
|
418
|
+
|
|
419
|
+
console.log('Entrando en la cuenta de la otra bóveda…')
|
|
420
|
+
let visto = null
|
|
421
|
+
for (let i = 0; i < 900; i++) { // hasta 3 min: hay un humano tipeando al otro lado
|
|
422
|
+
await sleep(200)
|
|
423
|
+
const d = readJson(res, null)
|
|
424
|
+
if (!d) continue
|
|
425
|
+
if (d.code && d.code !== visto) {
|
|
426
|
+
visto = d.code
|
|
427
|
+
console.log('\n Tipea este código en la OTRA bóveda: dotrino-vault approve %s\n', d.code)
|
|
428
|
+
}
|
|
429
|
+
if (d.state === 'done') {
|
|
430
|
+
console.log('Listo: esta bóveda ya es miembro de esa cuenta (acta #%s).', d.seq ?? '?')
|
|
431
|
+
if (d.profile) console.log('La cuenta vive aquí como el perfil %s (dotrino-vault profile ls).', d.profile)
|
|
432
|
+
console.log('Para que además pueda SELLAR, en la otra: dotrino-vault caps <ID> +sella')
|
|
433
|
+
return
|
|
434
|
+
}
|
|
435
|
+
if (d.state === 'error') { console.error('No se pudo entrar: %s', d.error); process.exit(1) }
|
|
436
|
+
}
|
|
437
|
+
console.error('Se agotó la espera. ¿Se aprobó el código en la otra bóveda?')
|
|
438
|
+
process.exit(1)
|
|
439
|
+
}
|
|
440
|
+
|
|
309
441
|
function cmdReject (deviceId) {
|
|
310
442
|
if (!deviceId) { console.error('uso: dotrino-vault reject <deviceId>'); process.exit(2) }
|
|
311
443
|
const s = requireDaemon()
|
|
@@ -387,7 +519,14 @@ async function cmdMembers () {
|
|
|
387
519
|
assertOpen(record)
|
|
388
520
|
if (!record.members?.length) { console.log('Este perfil todavía no tiene acta.'); return }
|
|
389
521
|
|
|
390
|
-
|
|
522
|
+
// `sealer` y `passwords` faltaban y salían crudos, en inglés, entre los demás en
|
|
523
|
+
// español. Se resaltan como `admin` y `approve`: los cuatro cambian lo que ese aparato
|
|
524
|
+
// puede hacerle a la cuenta, y eso se lee de un vistazo o no se lee.
|
|
525
|
+
const CAP = {
|
|
526
|
+
sign: 'firma', store: 'guarda', read: 'lee', secrets: 'lee sus claves',
|
|
527
|
+
admin: `${B}administra el perfil${Z}`, approve: `${B}aprueba pedidos${Z}`,
|
|
528
|
+
sealer: `${B}sella el acta${Z}`, passwords: `${B}pide contraseñas${Z}`
|
|
529
|
+
}
|
|
391
530
|
// El nombre del perfil es una pubkey JWK. Recortarla no la hace legible: la deja
|
|
392
531
|
// pareciendo un error (`{"key_ops":["verify"],"e…`). Se muestra su huella corta, la
|
|
393
532
|
// misma que se enseña al emparejar y en la lista de miembros.
|
|
@@ -401,13 +540,19 @@ async function cmdMembers () {
|
|
|
401
540
|
m.cn ? `servicio «${m.cn}»` : null
|
|
402
541
|
].filter(Boolean)
|
|
403
542
|
const caps = m.caps.length ? m.caps.map((c) => CAP[c] || c).join(', ') : '(sin permisos)'
|
|
404
|
-
|
|
543
|
+
// CUÁNDO ENTRÓ. El nombre lo pone el propio aparato al emparejarse y muchas veces no
|
|
544
|
+
// distingue nada —dos teléfonos con el mismo apodo, o «cli» a secas—, así que la fecha
|
|
545
|
+
// es lo que deja reconocer cuál es cuál y ver si hay uno que no recuerdas haber
|
|
546
|
+
// conectado. El acta ya la guardaba (`addedAt`); solo no se enseñaba.
|
|
547
|
+
console.log(' %s %s%s%s\n %s', m.id, who,
|
|
548
|
+
m.addedAt ? ` ${D}· conectado el ${fechaCorta(m.addedAt)}${Z}` : '',
|
|
549
|
+
marks.length ? ' [' + marks.join(' · ') + ']' : '', caps)
|
|
405
550
|
// Un servicio SIN llave de cifrado no puede leer ninguna variable privada: van
|
|
406
551
|
// selladas a esa llave. Se dice aquí, junto a él, porque es el único sitio donde
|
|
407
552
|
// se mira quién es quién — y en la lista de variables ya seria tarde.
|
|
408
553
|
if (m.cn && !m.canSeal) console.log(' %ssin llave de cifrado: NO puede leer sus variables%s', R, Z)
|
|
409
554
|
}
|
|
410
|
-
console.log('\n Cambiar permisos: dotrino-vault caps <ID> +firma | -firma | +guarda | -guarda | +lee | -lee | +administra | +aprueba | +contrasenas | +permiso')
|
|
555
|
+
console.log('\n Cambiar permisos: dotrino-vault caps <ID> +firma | -firma | +guarda | -guarda | +lee | -lee | +administra | +aprueba | +contrasenas | +sella | +permiso')
|
|
411
556
|
console.log(' «Permiso»: ese aparato solo recibe claves privadas cuando lo apruebas desde un aparato con «aprueba» (en cada arranque).')
|
|
412
557
|
console.log(' «Administra» deja conectar y quitar dispositivos desde ese aparato, sin venir aquí.')
|
|
413
558
|
console.log(' No deja cambiar permisos ni traspasar el mando: eso solo se hace en esta máquina.')
|
|
@@ -470,11 +615,15 @@ async function cmdApproval (args = []) {
|
|
|
470
615
|
async function cmdCaps (args = []) {
|
|
471
616
|
const [id, ...changes] = args
|
|
472
617
|
if (!id || !changes.length) {
|
|
473
|
-
console.error('uso: dotrino-vault caps <ID> +firma|-firma|+guarda|-guarda|+lee|-lee|+administra|-administra|+aprueba|-aprueba|+contrasenas|-contrasenas|+permiso|-permiso')
|
|
618
|
+
console.error('uso: dotrino-vault caps <ID> +firma|-firma|+guarda|-guarda|+lee|-lee|+administra|-administra|+aprueba|-aprueba|+contrasenas|-contrasenas|+sella|-sella|+permiso|-permiso')
|
|
474
619
|
process.exit(2)
|
|
475
620
|
}
|
|
476
621
|
const CAP_BY_WORD = {
|
|
477
622
|
firma: 'sign', guarda: 'store', lee: 'read', administra: 'admin', aprueba: 'approve',
|
|
623
|
+
// `sella`: SELLAR EL ACTA. Es lo que convierte a otra bóveda en respaldo de esta —
|
|
624
|
+
// podrá admitir aparatos y cambiar permisos si esta se pierde. No es un traspaso:
|
|
625
|
+
// quien manda sigue mandando. Como `administra`, no se empareja: se concede aquí.
|
|
626
|
+
sella: 'sealer', sealer: 'sealer',
|
|
478
627
|
// `contraseñas`: el gestor (la extensión, la app del teléfono) puede PEDIR
|
|
479
628
|
// credenciales de a una. Se acepta con y sin tilde: nadie escribe la ñ en una CLI.
|
|
480
629
|
contrasenas: 'passwords', 'contraseñas': 'passwords',
|
|
@@ -593,6 +742,124 @@ function profileDir () {
|
|
|
593
742
|
return path.join(dir, 'p', p.id)
|
|
594
743
|
}
|
|
595
744
|
|
|
745
|
+
// DE DÓNDE SALE la clave que cifra el disco (`lib/src/kek.js`). Por defecto se deriva de
|
|
746
|
+
// esta máquina; se puede pasar a un KMS, y entonces una copia del disco deja de servir.
|
|
747
|
+
function cmdAtrest (rest) {
|
|
748
|
+
// OJO: la clave es POR PERFIL. `dir` aquí dentro es el del perfil al que apunta el
|
|
749
|
+
// comando (--profile, o el activo); la raíz de la bóveda es otra cosa y tiene la suya.
|
|
750
|
+
const vaultRoot = dataDir()
|
|
751
|
+
const dir = profileDir()
|
|
752
|
+
const sub = rest[0] || 'status'
|
|
753
|
+
|
|
754
|
+
if (sub === 'status') {
|
|
755
|
+
const cfg = readKekConfig(dir)
|
|
756
|
+
const archivos = encryptedFilesIn(dir)
|
|
757
|
+
if (cfg.provider === 'machine') {
|
|
758
|
+
console.log('Clave del disco: derivada de ESTA máquina (machine-id + salt).')
|
|
759
|
+
console.log(' Aviso: el material vive en el mismo disco que los datos, así que una')
|
|
760
|
+
console.log(' copia del disco entero la abre. Para cerrarlo: dotrino-vault atrest rekey <config.json>')
|
|
761
|
+
} else {
|
|
762
|
+
console.log('Clave del disco: envuelta por ' + (cfg.label || cfg.wrap?.cmd || 'un programa externo') + '.')
|
|
763
|
+
console.log(' Una copia del disco NO basta: hace falta además poder desenvolverla.')
|
|
764
|
+
}
|
|
765
|
+
console.log('Archivos cifrados: ' + (archivos.length ? archivos.join(', ') : 'ninguno todavía'))
|
|
766
|
+
|
|
767
|
+
// ES POR PERFIL, y eso hay que verlo: en la misma bóveda un perfil puede ir con KMS
|
|
768
|
+
// y el de al lado con la clave de la máquina. Sin esta lista, «lo puse en el KMS» es
|
|
769
|
+
// una creencia y no un hecho comprobable.
|
|
770
|
+
const otros = (readState().profiles || [])
|
|
771
|
+
if (otros.length > 1) {
|
|
772
|
+
console.log('')
|
|
773
|
+
console.log('Los demás perfiles de esta bóveda (cada uno lleva su propia clave):')
|
|
774
|
+
for (const q of otros) {
|
|
775
|
+
let pv = 'machine'
|
|
776
|
+
try { pv = readKekConfig(path.join(vaultRoot, 'p', q.id)).provider } catch (_) { pv = '?' }
|
|
777
|
+
const marca = q.current ? ' (activo)' : ''
|
|
778
|
+
console.log(' ' + (q.name || q.id) + marca + ': ' + (pv === 'machine' ? 'esta máquina' : 'programa externo / KMS'))
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
// El REGISTRO de perfiles vive en la raíz y tiene su propia clave: poner un perfil en
|
|
782
|
+
// el KMS no lo cubre. Dentro está el verificador del candado y la lista de perfiles,
|
|
783
|
+
// no el contenido de ninguno — pero conviene no creer que ya está protegido.
|
|
784
|
+
let raiz = 'machine'
|
|
785
|
+
try { raiz = readKekConfig(vaultRoot).provider } catch (_) {}
|
|
786
|
+
if (raiz === 'machine' && cfg.provider !== 'machine') {
|
|
787
|
+
console.log('')
|
|
788
|
+
console.log('Ojo: el registro de perfiles (la raíz de la bóveda) sigue con la clave de')
|
|
789
|
+
console.log('esta máquina. No guarda el contenido de ningún perfil, pero no está en el KMS.')
|
|
790
|
+
}
|
|
791
|
+
return
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
if (sub === 'test') {
|
|
795
|
+
// Comprueba el ida y vuelta SIN tocar los datos: es lo que hay que correr ANTES de
|
|
796
|
+
// un rekey, y lo que dice si el KMS sigue respondiendo.
|
|
797
|
+
try {
|
|
798
|
+
const r = probeKek(dir)
|
|
799
|
+
if (r.provider === 'machine') console.log('OK — proveedor «machine»: no hay nada externo que probar.')
|
|
800
|
+
else console.log('OK — ' + (r.label || 'el programa externo') + ' envuelve y desenvuelve (' + r.wrappedBytes + ' bytes).')
|
|
801
|
+
} catch (e) {
|
|
802
|
+
console.error('FALLA (' + (e.code || 'error') + '): ' + e.message)
|
|
803
|
+
process.exitCode = 1
|
|
804
|
+
}
|
|
805
|
+
return
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
if (sub === 'rekey') {
|
|
809
|
+
const file = rest[1]
|
|
810
|
+
if (!file) {
|
|
811
|
+
console.error('Falta el archivo de configuración: dotrino-vault atrest rekey <config.json>')
|
|
812
|
+
console.error('Para volver a la clave de la máquina: dotrino-vault atrest rekey --machine')
|
|
813
|
+
process.exitCode = 1; return
|
|
814
|
+
}
|
|
815
|
+
let cfg
|
|
816
|
+
if (file === '--machine') cfg = { provider: 'machine' }
|
|
817
|
+
else {
|
|
818
|
+
try { cfg = JSON.parse(fs.readFileSync(file, 'utf8')) } catch (e) {
|
|
819
|
+
console.error('No se pudo leer ' + file + ': ' + e.message); process.exitCode = 1; return
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
// FRENO: recifrar un perfil que YA tiene identidad no le da raíz de hardware, y
|
|
823
|
+
// dejar creer que sí es peor que no ofrecerlo. Su maestra se escribió bajo la clave
|
|
824
|
+
// vieja; cualquier copia del disco anterior a este momento la sigue abriendo, y eso
|
|
825
|
+
// no hay recifrado que lo deshaga. Lo que sí sirve está en el mensaje.
|
|
826
|
+
const tieneIdentidad = fs.existsSync(path.join(dir, 'identity.json'))
|
|
827
|
+
if (tieneIdentidad && cfg.provider !== 'machine' && !rest.includes('--anyway')) {
|
|
828
|
+
console.error('Este perfil ya tiene identidad, así que recifrarlo NO le da raíz en el KMS.')
|
|
829
|
+
console.error('')
|
|
830
|
+
console.error('Su maestra se generó y se escribió bajo la clave de esta máquina. Una copia')
|
|
831
|
+
console.error('del disco anterior a este momento la sigue abriendo, para siempre, y eso no')
|
|
832
|
+
console.error('lo deshace ningún recifrado: solo protege de aquí en adelante.')
|
|
833
|
+
console.error('')
|
|
834
|
+
console.error('Para una identidad con raíz en el KMS, tiene que NACER así:')
|
|
835
|
+
console.error(' 1. dotrino-vault profile add <nombre> --kms <config.json>')
|
|
836
|
+
console.error(' 2. enrólalo al acta de la cuenta como un aparato más')
|
|
837
|
+
console.error(' 3. pásale el sellado y revoca el aparato viejo')
|
|
838
|
+
console.error(' (el profileId no cambia: la génesis sigue siendo el nombre de la cuenta)')
|
|
839
|
+
console.error('')
|
|
840
|
+
console.error('Si aun así quieres recifrar —porque el disco nunca salió de tu control—:')
|
|
841
|
+
console.error(' dotrino-vault atrest rekey %s --anyway', file)
|
|
842
|
+
process.exitCode = 1; return
|
|
843
|
+
}
|
|
844
|
+
try {
|
|
845
|
+
const r = rekeyDir(dir, cfg)
|
|
846
|
+
console.log('Listo: ' + r.from + ' → ' + r.to + '. Recifrados ' + r.files.length + ' archivos.')
|
|
847
|
+
if (r.backups.length) {
|
|
848
|
+
console.log('Copias de seguridad (bórralas cuando compruebes que todo abre):')
|
|
849
|
+
for (const b of r.backups) console.log(' ' + b)
|
|
850
|
+
}
|
|
851
|
+
console.log('Reinicia el servicio para que tome la clave nueva.')
|
|
852
|
+
} catch (e) {
|
|
853
|
+
console.error('NO se cambió nada (' + (e.code || 'error') + '): ' + e.message)
|
|
854
|
+
process.exitCode = 1
|
|
855
|
+
}
|
|
856
|
+
return
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
console.error('Uso: dotrino-vault atrest [status|test|rekey <config.json>|rekey --machine]')
|
|
860
|
+
process.exitCode = 1
|
|
861
|
+
}
|
|
862
|
+
|
|
596
863
|
// Bitácora de actividad de seguridad (quién firmó/renovó/enroló y qué se rechazó).
|
|
597
864
|
function cmdActivity (n = 30) {
|
|
598
865
|
const f = path.join(profileDir(), 'activity.log')
|
|
@@ -1049,7 +1316,13 @@ function reportProfiles (d) {
|
|
|
1049
1316
|
}
|
|
1050
1317
|
|
|
1051
1318
|
async function cmdProfile (rest) {
|
|
1052
|
-
const [sub, ...
|
|
1319
|
+
const [sub, ...rawArgs] = rest
|
|
1320
|
+
// `--kms <archivo>` se saca ANTES de armar el nombre: el nombre se compone juntando
|
|
1321
|
+
// los argumentos sueltos, así que si no se quita acabaría llamándose «midevault --kms
|
|
1322
|
+
// cfg.json».
|
|
1323
|
+
const kmsAt = rawArgs.indexOf('--kms')
|
|
1324
|
+
const kmsFile = kmsAt !== -1 ? rawArgs[kmsAt + 1] : null
|
|
1325
|
+
const args = kmsAt !== -1 ? rawArgs.filter((_, i) => i !== kmsAt && i !== kmsAt + 1) : rawArgs
|
|
1053
1326
|
const name = args.join(' ').trim()
|
|
1054
1327
|
switch (sub || 'ls') {
|
|
1055
1328
|
case 'ls': {
|
|
@@ -1061,8 +1334,24 @@ async function cmdProfile (rest) {
|
|
|
1061
1334
|
return
|
|
1062
1335
|
}
|
|
1063
1336
|
case 'add': {
|
|
1064
|
-
if (!name) { console.error('uso: dotrino-vault profile add <nombre>'); process.exit(2) }
|
|
1065
|
-
|
|
1337
|
+
if (!name) { console.error('uso: dotrino-vault profile add <nombre> [--kms <config.json>]'); process.exit(2) }
|
|
1338
|
+
// NACER con el KMS es la única forma de que la maestra no haya existido nunca bajo
|
|
1339
|
+
// la clave de esta máquina. Migrar después no da lo mismo y no se ofrece como si
|
|
1340
|
+
// lo diera (ver el freno de `atrest rekey`).
|
|
1341
|
+
let kek = null
|
|
1342
|
+
if (kmsAt !== -1) {
|
|
1343
|
+
if (!kmsFile || kmsFile.startsWith('-')) { console.error('uso: --kms <config.json>'); process.exit(2) }
|
|
1344
|
+
try { kek = JSON.parse(fs.readFileSync(kmsFile, 'utf8')) } catch (e) {
|
|
1345
|
+
console.error('No se pudo leer %s: %s', kmsFile, e.message); process.exit(2)
|
|
1346
|
+
}
|
|
1347
|
+
// Probar aquí ANTES de mandar la orden: si el KMS no responde, mejor enterarse
|
|
1348
|
+
// sin haber creado nada.
|
|
1349
|
+
try { probeKek(dataDir(), kek) } catch (e) {
|
|
1350
|
+
console.error('El KMS no respondió (%s): %s', e.code || 'error', e.message)
|
|
1351
|
+
console.error('No se creó ningún perfil.'); process.exit(1)
|
|
1352
|
+
}
|
|
1353
|
+
}
|
|
1354
|
+
reportProfiles(await profileRequest('add', { name, ...(kek ? { kek } : {}) }))
|
|
1066
1355
|
console.log('Conecta un dispositivo a este perfil: dotrino-vault pair --profile "%s"', name)
|
|
1067
1356
|
return
|
|
1068
1357
|
}
|
|
@@ -1172,11 +1461,19 @@ function help () {
|
|
|
1172
1461
|
tui interfaz de terminal a pantalla completa (bóvedas, pares, secretos)
|
|
1173
1462
|
status estado del servicio + fingerprint
|
|
1174
1463
|
pair [--save <f>] inicia un emparejamiento (QR + espera); --save escribe la invitación (.dpair)
|
|
1464
|
+
pair --kms <config.json>
|
|
1465
|
+
el sitio que se cree (--adopt o --new-account) NACE con su clave
|
|
1466
|
+
de disco en el KMS. Es el único momento que sirve: la llave del
|
|
1467
|
+
aparato se genera al crear el perfil
|
|
1175
1468
|
pair --new-account [nombre]
|
|
1176
1469
|
estrena una cuenta VACÍA en este vault y mete ahí al dispositivo
|
|
1177
1470
|
(sin la bandera entra a la cuenta activa, o a la de --profile)
|
|
1178
1471
|
pair --service <ns> empareja un SERVICIO (proxy, geo…) con acceso SOLO a sus secretos
|
|
1179
1472
|
pair --approval el aparato que entre pedirá tu aprobación (teléfono) al recibir claves
|
|
1473
|
+
pair --admin el aparato que entre podrá ADMINISTRAR (es lo que es una consola).
|
|
1474
|
+
El QR no lleva nada: el permiso se aplica al aprobar el código aquí
|
|
1475
|
+
pair --quiet escupe SOLO la invitación (una línea) y termina: sin QR y sin
|
|
1476
|
+
esperar. Para desplegar (un contenedor no mira un QR en pantalla)
|
|
1180
1477
|
pair --scope <lista> los PERMISOS del cert: sign,read,store,contrasenas,secrets:<ns> (sin esto: sign,read,store;
|
|
1181
1478
|
se combina con --service: --service eco --scope sign = bot que firma y lee su cajón)
|
|
1182
1479
|
secret set <ns> <CLAVE> <valor> variable del scope <ns>: la comparten TODOS los
|
|
@@ -1209,6 +1506,14 @@ function help () {
|
|
|
1209
1506
|
y una privada NO se vuelve pública (bórrala y créala).
|
|
1210
1507
|
secret visibility <ns> <CLAVE> private tapa una pública sin tocar el valor
|
|
1211
1508
|
secret device visibility <ID> <CLAVE> private
|
|
1509
|
+
join <invitación> [--name <n>] [--kms <config.json>]
|
|
1510
|
+
ESTA bóveda ENTRA en la cuenta de otra (el papel contrario a pair).
|
|
1511
|
+
Entra con su propia llave, así que después se le puede dar +sella
|
|
1512
|
+
y ser el respaldo de esa cuenta. La cuenta ajena queda aquí como un
|
|
1513
|
+
PERFIL más (--name la nombra; --kms le pone la clave de disco en el
|
|
1514
|
+
KMS, y solo vale ahora: el perfil se está creando)
|
|
1515
|
+
Al desplegar, esto mismo va por el entorno y sin entrar a nada:
|
|
1516
|
+
DOTRINO_JOIN (o DOTRINO_JOIN_FILE) + DOTRINO_JOIN_NAME
|
|
1212
1517
|
pending muestra el dispositivo pendiente + su código a comparar
|
|
1213
1518
|
approve <código> aprueba el dispositivo tipeando el código que MUESTRA (el vault no lo sabe)
|
|
1214
1519
|
reject <deviceId> rechaza un dispositivo pendiente
|
|
@@ -1217,7 +1522,14 @@ function help () {
|
|
|
1217
1522
|
members el acta del perfil: quién es tuyo y qué puede hacer
|
|
1218
1523
|
label <ID> <nombre> renombra un dispositivo (el nombre con el que lo reconoces)
|
|
1219
1524
|
caps <ID> ±permiso cambia permisos (+firma -guarda +administra +contrasenas …)
|
|
1525
|
+
+sella = OTRA BÓVEDA que puede sellar el acta de esta cuenta, para
|
|
1526
|
+
que perder una máquina no se la lleve. No es un traspaso
|
|
1220
1527
|
revoke <ID|nonce> quita un dispositivo (con el ID, todos sus certificados)
|
|
1528
|
+
atrest status de dónde sale la clave que cifra el disco (esta máquina, o un KMS)
|
|
1529
|
+
atrest test comprueba que el KMS envuelve y desenvuelve, SIN tocar los datos
|
|
1530
|
+
atrest rekey <f> cambia de proveedor: descifra con la vieja y recifra con la nueva
|
|
1531
|
+
(--machine para volver a la clave de esta máquina). Editar
|
|
1532
|
+
atrest.json a mano NO vale: dejaría el perfil ilegible
|
|
1221
1533
|
activity [n] bitácora de seguridad: firmas, renovaciones, enrolados, rechazos
|
|
1222
1534
|
logs últimos logs del servicio
|
|
1223
1535
|
version muestra la versión instalada
|
|
@@ -1225,6 +1537,10 @@ function help () {
|
|
|
1225
1537
|
Perfiles (varias identidades tuyas en el mismo PC; todas atienden a la vez):
|
|
1226
1538
|
profile ls lista los perfiles (* = el activo, el destino por defecto)
|
|
1227
1539
|
profile add <nombre> crea un perfil (identidad nueva, vacía)
|
|
1540
|
+
profile add <nombre> --kms <f> ...y su clave de disco NACE en el KMS que diga <f>.
|
|
1541
|
+
Es la única forma de que la maestra no exista nunca
|
|
1542
|
+
bajo la clave de esta máquina: migrar después no da
|
|
1543
|
+
lo mismo (una copia vieja del disco la sigue abriendo)
|
|
1228
1544
|
profile use <id|nombre> elige el perfil activo
|
|
1229
1545
|
profile rename <nombre> renombra un perfil
|
|
1230
1546
|
profile rm <id|nombre> BORRA un perfil y su identidad (irreversible)
|
|
@@ -1256,6 +1572,7 @@ export async function runCtl (argv) {
|
|
|
1256
1572
|
case 'lock': return cmdLock()
|
|
1257
1573
|
case 'status': return cmdStatus()
|
|
1258
1574
|
case 'pair': return cmdPair(rest)
|
|
1575
|
+
case 'join': return cmdJoin(rest)
|
|
1259
1576
|
case 'pending': return cmdPending()
|
|
1260
1577
|
case 'approve': return cmdApprove(rest[0])
|
|
1261
1578
|
case 'reject': return cmdReject(rest[0])
|
|
@@ -1267,6 +1584,7 @@ export async function runCtl (argv) {
|
|
|
1267
1584
|
case 'revoke': return cmdRevoke(rest[0])
|
|
1268
1585
|
case 'secret': return cmdSecret(rest)
|
|
1269
1586
|
case 'approval': return cmdApproval(rest)
|
|
1587
|
+
case 'atrest': return cmdAtrest(rest)
|
|
1270
1588
|
case 'activity': return cmdActivity(Number(rest[0]) || 30)
|
|
1271
1589
|
case 'logs': return cmdLogs()
|
|
1272
1590
|
case 'version':
|