@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/lib/src/service.js
CHANGED
|
@@ -458,8 +458,7 @@ export async function fetchSecrets ({ dir, ns, proxyUrl, masterPubkey, device, c
|
|
|
458
458
|
// verdad llega cuando el aparato que aprueba firme — por esta misma conexión, que
|
|
459
459
|
// sigue identificada. Se espera lo que dura el pedido; denegado o vencido es error.
|
|
460
460
|
if (res.body?.op === 'secrets.pending' && res.body.ns === ns) {
|
|
461
|
-
|
|
462
|
-
if (!okPending) throw new Error('invalid master signature on the pending reply')
|
|
461
|
+
await verifyResponder({ body: res.body, seal: res.seal, acta: res.acta, masterPubkey })
|
|
463
462
|
try { onPending?.({ id: res.body.id, ns, exp: res.body.exp }) } catch (_) {}
|
|
464
463
|
const until = typeof res.body.exp === 'number' ? Math.max(5000, res.body.exp - Date.now() + 5000) : approvalTimeoutMs
|
|
465
464
|
res = await waitForMsg(client, (p) => (p.type === MSG.SECRETS_RESULT && p.body?.op === 'secrets.result') || p.type === MSG.ERROR, Math.min(until, approvalTimeoutMs))
|
|
@@ -467,14 +466,20 @@ export async function fetchSecrets ({ dir, ns, proxyUrl, masterPubkey, device, c
|
|
|
467
466
|
if (res.type === MSG.ERROR) throw new Error(res.error)
|
|
468
467
|
}
|
|
469
468
|
|
|
470
|
-
//
|
|
469
|
+
// AUTENTICIDAD: la firma NO es de la maestra. El acta dice qué llave vale para esto
|
|
470
|
+
// (`sealPub`, buscada por `seq`), que es la misma regla que ya gobierna los sobres.
|
|
471
|
+
// La maestra solo sella el acta y reenvuelve; servir no es cosa suya, y por eso puede
|
|
472
|
+
// quedarse cerrada.
|
|
471
473
|
const body = res.body
|
|
472
474
|
if (!body || body.op !== 'secrets.result' || body.ns !== ns) throw new Error('malformed secrets reply')
|
|
473
475
|
if (typeof body.ts !== 'number' || Math.abs(Date.now() - body.ts) > FRESH_WINDOW_MS) throw new Error('stale secrets reply')
|
|
474
|
-
const ok = await verifyDeviceSig({ publickey: masterPubkey, data: body, signature: res.signature })
|
|
475
|
-
if (!ok) throw new Error('invalid master signature on the secrets reply')
|
|
476
476
|
|
|
477
|
+
// Se abre ANTES de comprobar la firma, y no es un descuido: el sobre de fuera va
|
|
478
|
+
// sellado a la efímera que acabamos de estrenar, así que abrirlo no prueba nada ni
|
|
479
|
+
// confía en nadie — solo saca el acta con la que SÍ se comprueba. Nada de lo que hay
|
|
480
|
+
// dentro se usa hasta después de verificar.
|
|
477
481
|
const payload = await openSealed({ privateKey: eph.privateKey, enc: body.enc })
|
|
482
|
+
await verifyResponder({ body, seal: res.seal, acta: payload?.acta, masterPubkey })
|
|
478
483
|
|
|
479
484
|
// DOS CAPAS DE SOBRE, y hacen cosas distintas:
|
|
480
485
|
// · la de fuera (`ek` efímera, recién abierta) tapa el TRAMO — el proxio no ve
|
|
@@ -499,6 +504,40 @@ export async function fetchSecrets ({ dir, ns, proxyUrl, masterPubkey, device, c
|
|
|
499
504
|
* omitido: silenciarlo convertiría una rotación mal sellada en «el servicio sigue
|
|
500
505
|
* con el valor viejo y nadie se entera», que es el peor modo de fallo de todo esto.
|
|
501
506
|
*/
|
|
507
|
+
/**
|
|
508
|
+
* ¿QUIÉN CONTESTÓ, Y PUEDE? La misma regla que gobierna los sobres, aplicada al
|
|
509
|
+
* transporte: **el acta dice qué llave firma qué**.
|
|
510
|
+
*
|
|
511
|
+
* La maestra no entra aquí. Sus dos trabajos son sellar el acta y reenvolver los sobres
|
|
512
|
+
* de todos los aparatos; servir una petición no es ninguno de los dos, así que exigir su
|
|
513
|
+
* firma la obligaba a estar despierta todo el tiempo — y de paso hacía imposible que
|
|
514
|
+
* sirviera nadie más.
|
|
515
|
+
*
|
|
516
|
+
* Lo que se comprueba, en orden:
|
|
517
|
+
* 1. el acta que llega va firmada por la maestra que este agente lleva pineada desde
|
|
518
|
+
* que se enroló (o por la que aquella nombró al traspasar);
|
|
519
|
+
* 2. la firma del cuerpo cuadra con la llave de sellado que ESA acta nombra para el
|
|
520
|
+
* `seq` con el que se firmó.
|
|
521
|
+
*
|
|
522
|
+
* Sin el paso 1 el paso 2 no vale nada: un acta cualquiera nombraría la llave que
|
|
523
|
+
* quisiera.
|
|
524
|
+
*/
|
|
525
|
+
export async function verifyResponder ({ body, seal, acta, masterPubkey }) {
|
|
526
|
+
if (!seal?.sig || typeof seal.seq !== 'number') {
|
|
527
|
+
throw new Error('the reply carries no sealing signature: this vault is too old to serve (update it)')
|
|
528
|
+
}
|
|
529
|
+
if (!acta) throw new Error('the reply carries no record: cannot tell which key was allowed to sign it')
|
|
530
|
+
if (acta.sealedBy !== masterPubkey) {
|
|
531
|
+
throw new Error('the record was not sealed by the master this agent knows: refusing the reply')
|
|
532
|
+
}
|
|
533
|
+
if (!(await verifyActa({ acta })).ok) throw new Error('the record does not verify')
|
|
534
|
+
const pub = sealKeyAt(acta, seal.seq)
|
|
535
|
+
if (!pub) throw new Error(`the record names no sealing key for #${seal.seq}`)
|
|
536
|
+
if (!(await verifyDeviceSig({ publickey: pub, data: body, signature: seal.sig }))) {
|
|
537
|
+
throw new Error('the reply signature does not check out against the key the record names')
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
|
|
502
541
|
/**
|
|
503
542
|
* ¿SALIÓ ESTE SOBRE DE MI BÓVEDA? (§8.8 de `dotrino-vault/docs/secretos-sellados.md`)
|
|
504
543
|
*
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dotrino/vaultd",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.56.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Certificador personal de Dotrino: daemon headless que custodia la clave maestra y delega capacidades a tus dispositivos por el proxy. Tu CA propia.",
|
|
6
6
|
"bin": {
|
|
@@ -20,8 +20,8 @@
|
|
|
20
20
|
"node": ">=20"
|
|
21
21
|
},
|
|
22
22
|
"dependencies": {
|
|
23
|
-
"@dotrino/identity": "^0.
|
|
24
|
-
"@dotrino/passmanager": "^0.
|
|
23
|
+
"@dotrino/identity": "^0.63.0",
|
|
24
|
+
"@dotrino/passmanager": "^0.9.0",
|
|
25
25
|
"@dotrino/proxy-client": "^0.13.1",
|
|
26
26
|
"ws": "^8.18.0"
|
|
27
27
|
},
|
|
@@ -44,7 +44,7 @@
|
|
|
44
44
|
"!lib/src/types.d.ts"
|
|
45
45
|
],
|
|
46
46
|
"devDependencies": {
|
|
47
|
-
"@dotrino/remote-agent": "^0.3
|
|
47
|
+
"@dotrino/remote-agent": "^0.5.3",
|
|
48
48
|
"@types/node": "^22.0.0",
|
|
49
49
|
"typescript": "5.9.3"
|
|
50
50
|
}
|
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()
|
|
@@ -190,6 +191,32 @@ async function cmdPair (args = []) {
|
|
|
190
191
|
// --approval: el aparato que entre pedirá tu aprobación (teléfono) en cada petición de
|
|
191
192
|
// claves privadas. Por defecto NO pide; se cambia después con `caps <ID> +permiso`.
|
|
192
193
|
const approval = args.includes('--approval')
|
|
194
|
+
// --admin: el aparato que entre por esta invitación podrá ADMINISTRAR (la consola).
|
|
195
|
+
// El QR no lleva nada: es una nota local de la bóveda y el permiso se aplica al aprobar
|
|
196
|
+
// con el código que tecleas aquí. Ahorra el `caps <ID> +administra` de después, que en un
|
|
197
|
+
// contenedor es otro `docker exec` y una vuelta a buscar el ID.
|
|
198
|
+
const admin = args.includes('--admin')
|
|
199
|
+
// --quiet: escupe SOLO la invitación (una línea) y termina. Sin QR y sin esperar.
|
|
200
|
+
// Para desplegar: en un contenedor no hay quien mire un QR pintado en una terminal, y un
|
|
201
|
+
// `pair` que se queda 2 minutos y medio esperando no se puede meter en un script.
|
|
202
|
+
const quiet = args.includes('--quiet')
|
|
203
|
+
// --kms <config.json>: el sitio que se va a crear (--new-account o --adopt) NACE con
|
|
204
|
+
// su clave de disco en el KMS. Va aquí y no en un paso posterior porque es el único
|
|
205
|
+
// momento que sirve: la llave de este aparato se genera al crear el perfil, y una
|
|
206
|
+
// migración posterior no deshace que haya existido bajo la clave de la máquina.
|
|
207
|
+
const kmsIdx = args.indexOf('--kms')
|
|
208
|
+
let pairKek = null
|
|
209
|
+
if (kmsIdx >= 0) {
|
|
210
|
+
const f = args[kmsIdx + 1]
|
|
211
|
+
if (!f || f.startsWith('-')) { console.error('uso: dotrino-vault pair --adopt --kms <config.json>'); process.exit(2) }
|
|
212
|
+
try { pairKek = JSON.parse(fs.readFileSync(f, 'utf8')) } catch (e) {
|
|
213
|
+
console.error('No se pudo leer %s: %s', f, e.message); process.exit(2)
|
|
214
|
+
}
|
|
215
|
+
try { probeKek(dataDir(), pairKek) } catch (e) {
|
|
216
|
+
console.error('El KMS no respondió (%s): %s', e.code || 'error', e.message)
|
|
217
|
+
console.error('No se creó ninguna cuenta.'); process.exit(1)
|
|
218
|
+
}
|
|
219
|
+
}
|
|
193
220
|
const scIdx = args.indexOf('--scope')
|
|
194
221
|
let scope = null
|
|
195
222
|
if (scIdx >= 0) {
|
|
@@ -201,6 +228,7 @@ async function cmdPair (args = []) {
|
|
|
201
228
|
const t = ALIAS[tok] || tok
|
|
202
229
|
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
230
|
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) }
|
|
231
|
+
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
232
|
if (t === 'sign' || t === 'read' || t === 'store') { scope.push('vault:' + t); continue }
|
|
205
233
|
// El gestor de contraseñas SÍ se empareja con su permiso puesto: es lo único que
|
|
206
234
|
// va a hacer ese aparato, y pedirlo en dos pasos era el paso que nadie daba.
|
|
@@ -220,11 +248,11 @@ async function cmdPair (args = []) {
|
|
|
220
248
|
if (naIdx >= 0) {
|
|
221
249
|
const next = args[naIdx + 1]
|
|
222
250
|
const name = (next && !next.startsWith('-')) ? next : `cuenta ${new Date().toISOString().slice(0, 10)}`
|
|
223
|
-
const d = await profileRequest('add', { name })
|
|
251
|
+
const d = await profileRequest('add', { name, ...(pairKek ? { kek: pairKek } : {}) })
|
|
224
252
|
if (d.error) { console.error('%s', d.error); process.exit(1) }
|
|
225
253
|
if (!d.id) { console.error('El daemon no dijo qué cuenta creó.'); process.exit(1) }
|
|
226
254
|
PROFILE = d.id // el emparejamiento y los comandos siguientes apuntan a ELLA
|
|
227
|
-
console.log('Cuenta nueva: %s (%s)', name, d.id)
|
|
255
|
+
console.log('Cuenta nueva%s: %s (%s)', pairKek ? ' (clave del disco en el KMS)' : '', name, d.id)
|
|
228
256
|
}
|
|
229
257
|
// `--adopt [nombre]`: la TERCERA respuesta a «¿de qué cuenta hablamos?» — el camino A.
|
|
230
258
|
// Aquí la cuenta NO sale de esta bóveda: la trae el aparato y esta bóveda pasa a
|
|
@@ -235,15 +263,28 @@ async function cmdPair (args = []) {
|
|
|
235
263
|
if (adopt) {
|
|
236
264
|
const next = args[adIdx + 1]
|
|
237
265
|
const name = (next && !next.startsWith('-')) ? next : `cuenta del dispositivo`
|
|
238
|
-
const d = await profileRequest('add', { name, adopt: true })
|
|
266
|
+
const d = await profileRequest('add', { name, adopt: true, ...(pairKek ? { kek: pairKek } : {}) })
|
|
239
267
|
if (d.error) { console.error('%s', d.error); process.exit(1) }
|
|
240
268
|
if (!d.id) { console.error('El daemon no dijo qué cuenta creó.'); process.exit(1) }
|
|
241
269
|
PROFILE = d.id
|
|
242
|
-
console.log('Cuenta a la espera de adoptar la del dispositivo: %s (%s)', name, d.id)
|
|
270
|
+
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
271
|
}
|
|
272
|
+
// `--kms` sin un sitio que crear no hace NADA, y callárselo es lo peor que se puede
|
|
273
|
+
// hacer aquí: el dueño se quedaría creyendo que su aparato nació en el KMS.
|
|
274
|
+
if (pairKek && !adopt && naIdx < 0) {
|
|
275
|
+
console.error('--kms solo sirve cuando se crea el sitio, porque la llave del aparato')
|
|
276
|
+
console.error('se genera en ese momento. Úsalo con una de las dos:')
|
|
277
|
+
console.error(' dotrino-vault pair --adopt --kms <config.json> (la cuenta la trae el aparato)')
|
|
278
|
+
console.error(' dotrino-vault pair --new-account --kms <config.json> (cuenta nueva, nacida aquí)')
|
|
279
|
+
console.error('')
|
|
280
|
+
console.error('Emparejar contra una cuenta que ya vive en esta bóveda no cambia su clave de')
|
|
281
|
+
console.error('disco: esa ya se escribió cuando se creó. Ver docs/llaves-de-hardware.md.')
|
|
282
|
+
process.exit(2)
|
|
283
|
+
}
|
|
284
|
+
|
|
244
285
|
// La petición se escribe SIEMPRE (aunque no haya --service): lleva a qué perfil
|
|
245
286
|
// se empareja el dispositivo.
|
|
246
|
-
writeReq('pair-request.json', { ...(service ? { service } : {}), ...(scope ? { scope } : {}), ...(adopt ? { mode: 'adopt' } : {}), ...(approval ? { approval: true } : {}) })
|
|
287
|
+
writeReq('pair-request.json', { ...(service ? { service } : {}), ...(scope ? { scope } : {}), ...(adopt ? { mode: 'adopt' } : {}), ...(approval ? { approval: true } : {}), ...(admin ? { admin: true } : {}) })
|
|
247
288
|
sendSignal(s.pid, 'SIGUSR1')
|
|
248
289
|
|
|
249
290
|
let pair = null
|
|
@@ -264,9 +305,12 @@ async function cmdPair (args = []) {
|
|
|
264
305
|
// UNA (la activa, o la de --profile). Decirlo evita enrolar el dispositivo en la
|
|
265
306
|
// equivocada; es la misma línea que muestra la TUI.
|
|
266
307
|
const acct = pair.profileName || pair.profile
|
|
308
|
+
// --quiet: la invitación y nada más, para poder capturarla desde un script.
|
|
309
|
+
if (quiet) { console.log(url); return }
|
|
267
310
|
if (acct) console.log('\nCuenta que se comparte: %s%s', acct, pair.profileName && pair.profile ? ` (${pair.profile})` : '')
|
|
268
311
|
console.log('\nEscanea este QR con el dispositivo que quieres conectar (válido %d min):\n', mins)
|
|
269
312
|
console.log(qrToString(url)) // el QR abre la consola de dispositivos y empareja solo
|
|
313
|
+
if (admin) console.log(`${R}${B}⚠ Y además podrá ADMINISTRAR: admitir y quitar aparatos.${Z}`)
|
|
270
314
|
console.log(`${R}${B}⚠ Este código deja LEER tus datos y FIRMAR con tu identidad.${Z}`)
|
|
271
315
|
console.log(`${R} NO lo compartas con nadie, ni con "soporte". Solo escanéalo en TU dispositivo.${Z}`)
|
|
272
316
|
console.log('\nO abre esta dirección en el dispositivo:\n ' + url)
|
|
@@ -306,6 +350,80 @@ function cmdApprove (code) {
|
|
|
306
350
|
console.log('Aprobando con el código %s… verifica con: dotrino-vault devices', code)
|
|
307
351
|
}
|
|
308
352
|
|
|
353
|
+
/**
|
|
354
|
+
* `dotrino-vault join <invitación>` — ESTA bóveda entra en la cuenta de OTRA.
|
|
355
|
+
*
|
|
356
|
+
* Es el papel contrario a `pair`: aquí no se invita, se acepta. Y lo que entra en el acta
|
|
357
|
+
* es la llave de ESTA bóveda, no una de aparato inventada — por eso después se le puede
|
|
358
|
+
* dar `+sella` y que sea el respaldo de verdad de la otra.
|
|
359
|
+
*
|
|
360
|
+
* El código que sale por pantalla hay que TIPEARLO en la otra bóveda (`approve`): una
|
|
361
|
+
* invitación interceptada no basta para entrar.
|
|
362
|
+
*/
|
|
363
|
+
async function cmdJoin (rest) {
|
|
364
|
+
const args = rest || []
|
|
365
|
+
// `--name <n>`: cómo se va a llamar aquí la cuenta ajena. Es un perfil MÁS de esta
|
|
366
|
+
// bóveda, y en una máquina que respalda a varias hace falta distinguirlas.
|
|
367
|
+
const nIdx = args.indexOf('--name')
|
|
368
|
+
const name = nIdx >= 0 && args[nIdx + 1] && !args[nIdx + 1].startsWith('-') ? args[nIdx + 1] : null
|
|
369
|
+
// `--kms <config.json>`: el perfil que se cree para la cuenta ajena NACE con su clave de
|
|
370
|
+
// disco en el KMS. Va aquí por la misma razón que en `pair`: es el único momento que
|
|
371
|
+
// sirve, porque la llave de esta bóveda para esa cuenta se genera al crear el perfil.
|
|
372
|
+
const kIdx = args.indexOf('--kms')
|
|
373
|
+
let kek = null
|
|
374
|
+
if (kIdx >= 0) {
|
|
375
|
+
const f = args[kIdx + 1]
|
|
376
|
+
if (!f || f.startsWith('-')) { console.error('uso: dotrino-vault join <invitación> --kms <config.json>'); process.exit(2) }
|
|
377
|
+
try { kek = JSON.parse(fs.readFileSync(f, 'utf8')) } catch (e) {
|
|
378
|
+
console.error('No se pudo leer %s: %s', f, e.message); process.exit(2)
|
|
379
|
+
}
|
|
380
|
+
try { probeKek(dataDir(), kek) } catch (e) {
|
|
381
|
+
console.error('El KMS no respondió (%s): %s', e.code || 'error', e.message)
|
|
382
|
+
console.error('No se creó ninguna cuenta.'); process.exit(1)
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
// Lo que queda tras quitar las banderas Y SUS VALORES es la invitación.
|
|
386
|
+
const consumidos = new Set()
|
|
387
|
+
for (const [i, v] of [[nIdx, name], [kIdx, kek]]) if (i >= 0) { consumidos.add(i); if (v != null) consumidos.add(i + 1) }
|
|
388
|
+
const texto = args.filter((a, i) => !consumidos.has(i) && !a.startsWith('-')).join(' ').trim()
|
|
389
|
+
if (!texto) {
|
|
390
|
+
console.error('uso: dotrino-vault join <invitación> [--name <n>] [--kms <config.json>]')
|
|
391
|
+
console.error(' (la invitación es lo que imprime «pair» en la otra bóveda)')
|
|
392
|
+
process.exit(2)
|
|
393
|
+
}
|
|
394
|
+
const qr = parseInvite(texto)
|
|
395
|
+
if (!qr?.sn || !qr?.iss || !qr?.proxy) {
|
|
396
|
+
console.error('Esa invitación no se entiende. Pega la línea completa que imprime «dotrino-vault pair».')
|
|
397
|
+
process.exit(2)
|
|
398
|
+
}
|
|
399
|
+
const s = requireDaemon()
|
|
400
|
+
const res = path.join(dir, 'join.json')
|
|
401
|
+
try { fs.rmSync(res, { force: true }) } catch (_) {}
|
|
402
|
+
writeReq('join-request.json', { qr, label: 'bóveda', ...(name ? { name } : {}), ...(kek ? { kek } : {}) })
|
|
403
|
+
sendSignal(s.pid, 'SIGUSR2')
|
|
404
|
+
|
|
405
|
+
console.log('Entrando en la cuenta de la otra bóveda…')
|
|
406
|
+
let visto = null
|
|
407
|
+
for (let i = 0; i < 900; i++) { // hasta 3 min: hay un humano tipeando al otro lado
|
|
408
|
+
await sleep(200)
|
|
409
|
+
const d = readJson(res, null)
|
|
410
|
+
if (!d) continue
|
|
411
|
+
if (d.code && d.code !== visto) {
|
|
412
|
+
visto = d.code
|
|
413
|
+
console.log('\n Tipea este código en la OTRA bóveda: dotrino-vault approve %s\n', d.code)
|
|
414
|
+
}
|
|
415
|
+
if (d.state === 'done') {
|
|
416
|
+
console.log('Listo: esta bóveda ya es miembro de esa cuenta (acta #%s).', d.seq ?? '?')
|
|
417
|
+
if (d.profile) console.log('La cuenta vive aquí como el perfil %s (dotrino-vault profile ls).', d.profile)
|
|
418
|
+
console.log('Para que además pueda SELLAR, en la otra: dotrino-vault caps <ID> +sella')
|
|
419
|
+
return
|
|
420
|
+
}
|
|
421
|
+
if (d.state === 'error') { console.error('No se pudo entrar: %s', d.error); process.exit(1) }
|
|
422
|
+
}
|
|
423
|
+
console.error('Se agotó la espera. ¿Se aprobó el código en la otra bóveda?')
|
|
424
|
+
process.exit(1)
|
|
425
|
+
}
|
|
426
|
+
|
|
309
427
|
function cmdReject (deviceId) {
|
|
310
428
|
if (!deviceId) { console.error('uso: dotrino-vault reject <deviceId>'); process.exit(2) }
|
|
311
429
|
const s = requireDaemon()
|
|
@@ -407,7 +525,7 @@ async function cmdMembers () {
|
|
|
407
525
|
// se mira quién es quién — y en la lista de variables ya seria tarde.
|
|
408
526
|
if (m.cn && !m.canSeal) console.log(' %ssin llave de cifrado: NO puede leer sus variables%s', R, Z)
|
|
409
527
|
}
|
|
410
|
-
console.log('\n Cambiar permisos: dotrino-vault caps <ID> +firma | -firma | +guarda | -guarda | +lee | -lee | +administra | +aprueba | +contrasenas | +permiso')
|
|
528
|
+
console.log('\n Cambiar permisos: dotrino-vault caps <ID> +firma | -firma | +guarda | -guarda | +lee | -lee | +administra | +aprueba | +contrasenas | +sella | +permiso')
|
|
411
529
|
console.log(' «Permiso»: ese aparato solo recibe claves privadas cuando lo apruebas desde un aparato con «aprueba» (en cada arranque).')
|
|
412
530
|
console.log(' «Administra» deja conectar y quitar dispositivos desde ese aparato, sin venir aquí.')
|
|
413
531
|
console.log(' No deja cambiar permisos ni traspasar el mando: eso solo se hace en esta máquina.')
|
|
@@ -470,11 +588,15 @@ async function cmdApproval (args = []) {
|
|
|
470
588
|
async function cmdCaps (args = []) {
|
|
471
589
|
const [id, ...changes] = args
|
|
472
590
|
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')
|
|
591
|
+
console.error('uso: dotrino-vault caps <ID> +firma|-firma|+guarda|-guarda|+lee|-lee|+administra|-administra|+aprueba|-aprueba|+contrasenas|-contrasenas|+sella|-sella|+permiso|-permiso')
|
|
474
592
|
process.exit(2)
|
|
475
593
|
}
|
|
476
594
|
const CAP_BY_WORD = {
|
|
477
595
|
firma: 'sign', guarda: 'store', lee: 'read', administra: 'admin', aprueba: 'approve',
|
|
596
|
+
// `sella`: SELLAR EL ACTA. Es lo que convierte a otra bóveda en respaldo de esta —
|
|
597
|
+
// podrá admitir aparatos y cambiar permisos si esta se pierde. No es un traspaso:
|
|
598
|
+
// quien manda sigue mandando. Como `administra`, no se empareja: se concede aquí.
|
|
599
|
+
sella: 'sealer', sealer: 'sealer',
|
|
478
600
|
// `contraseñas`: el gestor (la extensión, la app del teléfono) puede PEDIR
|
|
479
601
|
// credenciales de a una. Se acepta con y sin tilde: nadie escribe la ñ en una CLI.
|
|
480
602
|
contrasenas: 'passwords', 'contraseñas': 'passwords',
|
|
@@ -593,6 +715,124 @@ function profileDir () {
|
|
|
593
715
|
return path.join(dir, 'p', p.id)
|
|
594
716
|
}
|
|
595
717
|
|
|
718
|
+
// DE DÓNDE SALE la clave que cifra el disco (`lib/src/kek.js`). Por defecto se deriva de
|
|
719
|
+
// esta máquina; se puede pasar a un KMS, y entonces una copia del disco deja de servir.
|
|
720
|
+
function cmdAtrest (rest) {
|
|
721
|
+
// OJO: la clave es POR PERFIL. `dir` aquí dentro es el del perfil al que apunta el
|
|
722
|
+
// comando (--profile, o el activo); la raíz de la bóveda es otra cosa y tiene la suya.
|
|
723
|
+
const vaultRoot = dataDir()
|
|
724
|
+
const dir = profileDir()
|
|
725
|
+
const sub = rest[0] || 'status'
|
|
726
|
+
|
|
727
|
+
if (sub === 'status') {
|
|
728
|
+
const cfg = readKekConfig(dir)
|
|
729
|
+
const archivos = encryptedFilesIn(dir)
|
|
730
|
+
if (cfg.provider === 'machine') {
|
|
731
|
+
console.log('Clave del disco: derivada de ESTA máquina (machine-id + salt).')
|
|
732
|
+
console.log(' Aviso: el material vive en el mismo disco que los datos, así que una')
|
|
733
|
+
console.log(' copia del disco entero la abre. Para cerrarlo: dotrino-vault atrest rekey <config.json>')
|
|
734
|
+
} else {
|
|
735
|
+
console.log('Clave del disco: envuelta por ' + (cfg.label || cfg.wrap?.cmd || 'un programa externo') + '.')
|
|
736
|
+
console.log(' Una copia del disco NO basta: hace falta además poder desenvolverla.')
|
|
737
|
+
}
|
|
738
|
+
console.log('Archivos cifrados: ' + (archivos.length ? archivos.join(', ') : 'ninguno todavía'))
|
|
739
|
+
|
|
740
|
+
// ES POR PERFIL, y eso hay que verlo: en la misma bóveda un perfil puede ir con KMS
|
|
741
|
+
// y el de al lado con la clave de la máquina. Sin esta lista, «lo puse en el KMS» es
|
|
742
|
+
// una creencia y no un hecho comprobable.
|
|
743
|
+
const otros = (readState().profiles || [])
|
|
744
|
+
if (otros.length > 1) {
|
|
745
|
+
console.log('')
|
|
746
|
+
console.log('Los demás perfiles de esta bóveda (cada uno lleva su propia clave):')
|
|
747
|
+
for (const q of otros) {
|
|
748
|
+
let pv = 'machine'
|
|
749
|
+
try { pv = readKekConfig(path.join(vaultRoot, 'p', q.id)).provider } catch (_) { pv = '?' }
|
|
750
|
+
const marca = q.current ? ' (activo)' : ''
|
|
751
|
+
console.log(' ' + (q.name || q.id) + marca + ': ' + (pv === 'machine' ? 'esta máquina' : 'programa externo / KMS'))
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
// El REGISTRO de perfiles vive en la raíz y tiene su propia clave: poner un perfil en
|
|
755
|
+
// el KMS no lo cubre. Dentro está el verificador del candado y la lista de perfiles,
|
|
756
|
+
// no el contenido de ninguno — pero conviene no creer que ya está protegido.
|
|
757
|
+
let raiz = 'machine'
|
|
758
|
+
try { raiz = readKekConfig(vaultRoot).provider } catch (_) {}
|
|
759
|
+
if (raiz === 'machine' && cfg.provider !== 'machine') {
|
|
760
|
+
console.log('')
|
|
761
|
+
console.log('Ojo: el registro de perfiles (la raíz de la bóveda) sigue con la clave de')
|
|
762
|
+
console.log('esta máquina. No guarda el contenido de ningún perfil, pero no está en el KMS.')
|
|
763
|
+
}
|
|
764
|
+
return
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
if (sub === 'test') {
|
|
768
|
+
// Comprueba el ida y vuelta SIN tocar los datos: es lo que hay que correr ANTES de
|
|
769
|
+
// un rekey, y lo que dice si el KMS sigue respondiendo.
|
|
770
|
+
try {
|
|
771
|
+
const r = probeKek(dir)
|
|
772
|
+
if (r.provider === 'machine') console.log('OK — proveedor «machine»: no hay nada externo que probar.')
|
|
773
|
+
else console.log('OK — ' + (r.label || 'el programa externo') + ' envuelve y desenvuelve (' + r.wrappedBytes + ' bytes).')
|
|
774
|
+
} catch (e) {
|
|
775
|
+
console.error('FALLA (' + (e.code || 'error') + '): ' + e.message)
|
|
776
|
+
process.exitCode = 1
|
|
777
|
+
}
|
|
778
|
+
return
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
if (sub === 'rekey') {
|
|
782
|
+
const file = rest[1]
|
|
783
|
+
if (!file) {
|
|
784
|
+
console.error('Falta el archivo de configuración: dotrino-vault atrest rekey <config.json>')
|
|
785
|
+
console.error('Para volver a la clave de la máquina: dotrino-vault atrest rekey --machine')
|
|
786
|
+
process.exitCode = 1; return
|
|
787
|
+
}
|
|
788
|
+
let cfg
|
|
789
|
+
if (file === '--machine') cfg = { provider: 'machine' }
|
|
790
|
+
else {
|
|
791
|
+
try { cfg = JSON.parse(fs.readFileSync(file, 'utf8')) } catch (e) {
|
|
792
|
+
console.error('No se pudo leer ' + file + ': ' + e.message); process.exitCode = 1; return
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
// FRENO: recifrar un perfil que YA tiene identidad no le da raíz de hardware, y
|
|
796
|
+
// dejar creer que sí es peor que no ofrecerlo. Su maestra se escribió bajo la clave
|
|
797
|
+
// vieja; cualquier copia del disco anterior a este momento la sigue abriendo, y eso
|
|
798
|
+
// no hay recifrado que lo deshaga. Lo que sí sirve está en el mensaje.
|
|
799
|
+
const tieneIdentidad = fs.existsSync(path.join(dir, 'identity.json'))
|
|
800
|
+
if (tieneIdentidad && cfg.provider !== 'machine' && !rest.includes('--anyway')) {
|
|
801
|
+
console.error('Este perfil ya tiene identidad, así que recifrarlo NO le da raíz en el KMS.')
|
|
802
|
+
console.error('')
|
|
803
|
+
console.error('Su maestra se generó y se escribió bajo la clave de esta máquina. Una copia')
|
|
804
|
+
console.error('del disco anterior a este momento la sigue abriendo, para siempre, y eso no')
|
|
805
|
+
console.error('lo deshace ningún recifrado: solo protege de aquí en adelante.')
|
|
806
|
+
console.error('')
|
|
807
|
+
console.error('Para una identidad con raíz en el KMS, tiene que NACER así:')
|
|
808
|
+
console.error(' 1. dotrino-vault profile add <nombre> --kms <config.json>')
|
|
809
|
+
console.error(' 2. enrólalo al acta de la cuenta como un aparato más')
|
|
810
|
+
console.error(' 3. pásale el sellado y revoca el aparato viejo')
|
|
811
|
+
console.error(' (el profileId no cambia: la génesis sigue siendo el nombre de la cuenta)')
|
|
812
|
+
console.error('')
|
|
813
|
+
console.error('Si aun así quieres recifrar —porque el disco nunca salió de tu control—:')
|
|
814
|
+
console.error(' dotrino-vault atrest rekey %s --anyway', file)
|
|
815
|
+
process.exitCode = 1; return
|
|
816
|
+
}
|
|
817
|
+
try {
|
|
818
|
+
const r = rekeyDir(dir, cfg)
|
|
819
|
+
console.log('Listo: ' + r.from + ' → ' + r.to + '. Recifrados ' + r.files.length + ' archivos.')
|
|
820
|
+
if (r.backups.length) {
|
|
821
|
+
console.log('Copias de seguridad (bórralas cuando compruebes que todo abre):')
|
|
822
|
+
for (const b of r.backups) console.log(' ' + b)
|
|
823
|
+
}
|
|
824
|
+
console.log('Reinicia el servicio para que tome la clave nueva.')
|
|
825
|
+
} catch (e) {
|
|
826
|
+
console.error('NO se cambió nada (' + (e.code || 'error') + '): ' + e.message)
|
|
827
|
+
process.exitCode = 1
|
|
828
|
+
}
|
|
829
|
+
return
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
console.error('Uso: dotrino-vault atrest [status|test|rekey <config.json>|rekey --machine]')
|
|
833
|
+
process.exitCode = 1
|
|
834
|
+
}
|
|
835
|
+
|
|
596
836
|
// Bitácora de actividad de seguridad (quién firmó/renovó/enroló y qué se rechazó).
|
|
597
837
|
function cmdActivity (n = 30) {
|
|
598
838
|
const f = path.join(profileDir(), 'activity.log')
|
|
@@ -1049,7 +1289,13 @@ function reportProfiles (d) {
|
|
|
1049
1289
|
}
|
|
1050
1290
|
|
|
1051
1291
|
async function cmdProfile (rest) {
|
|
1052
|
-
const [sub, ...
|
|
1292
|
+
const [sub, ...rawArgs] = rest
|
|
1293
|
+
// `--kms <archivo>` se saca ANTES de armar el nombre: el nombre se compone juntando
|
|
1294
|
+
// los argumentos sueltos, así que si no se quita acabaría llamándose «midevault --kms
|
|
1295
|
+
// cfg.json».
|
|
1296
|
+
const kmsAt = rawArgs.indexOf('--kms')
|
|
1297
|
+
const kmsFile = kmsAt !== -1 ? rawArgs[kmsAt + 1] : null
|
|
1298
|
+
const args = kmsAt !== -1 ? rawArgs.filter((_, i) => i !== kmsAt && i !== kmsAt + 1) : rawArgs
|
|
1053
1299
|
const name = args.join(' ').trim()
|
|
1054
1300
|
switch (sub || 'ls') {
|
|
1055
1301
|
case 'ls': {
|
|
@@ -1061,8 +1307,24 @@ async function cmdProfile (rest) {
|
|
|
1061
1307
|
return
|
|
1062
1308
|
}
|
|
1063
1309
|
case 'add': {
|
|
1064
|
-
if (!name) { console.error('uso: dotrino-vault profile add <nombre>'); process.exit(2) }
|
|
1065
|
-
|
|
1310
|
+
if (!name) { console.error('uso: dotrino-vault profile add <nombre> [--kms <config.json>]'); process.exit(2) }
|
|
1311
|
+
// NACER con el KMS es la única forma de que la maestra no haya existido nunca bajo
|
|
1312
|
+
// la clave de esta máquina. Migrar después no da lo mismo y no se ofrece como si
|
|
1313
|
+
// lo diera (ver el freno de `atrest rekey`).
|
|
1314
|
+
let kek = null
|
|
1315
|
+
if (kmsAt !== -1) {
|
|
1316
|
+
if (!kmsFile || kmsFile.startsWith('-')) { console.error('uso: --kms <config.json>'); process.exit(2) }
|
|
1317
|
+
try { kek = JSON.parse(fs.readFileSync(kmsFile, 'utf8')) } catch (e) {
|
|
1318
|
+
console.error('No se pudo leer %s: %s', kmsFile, e.message); process.exit(2)
|
|
1319
|
+
}
|
|
1320
|
+
// Probar aquí ANTES de mandar la orden: si el KMS no responde, mejor enterarse
|
|
1321
|
+
// sin haber creado nada.
|
|
1322
|
+
try { probeKek(dataDir(), kek) } catch (e) {
|
|
1323
|
+
console.error('El KMS no respondió (%s): %s', e.code || 'error', e.message)
|
|
1324
|
+
console.error('No se creó ningún perfil.'); process.exit(1)
|
|
1325
|
+
}
|
|
1326
|
+
}
|
|
1327
|
+
reportProfiles(await profileRequest('add', { name, ...(kek ? { kek } : {}) }))
|
|
1066
1328
|
console.log('Conecta un dispositivo a este perfil: dotrino-vault pair --profile "%s"', name)
|
|
1067
1329
|
return
|
|
1068
1330
|
}
|
|
@@ -1141,8 +1403,13 @@ function askText (prompt) {
|
|
|
1141
1403
|
|
|
1142
1404
|
async function cmdUnlock () {
|
|
1143
1405
|
const pwd = await askPassword('Contraseña del perfil: ')
|
|
1144
|
-
|
|
1145
|
-
|
|
1406
|
+
// El daemon devuelve cuánto aguanta abierto: se dice AQUÍ, al abrirlo, que es cuando
|
|
1407
|
+
// sirve de algo. Encontrárselo cerrado sin haberlo leído nunca parece una avería.
|
|
1408
|
+
const out = await profileRequest('unlock', { password: pwd })
|
|
1409
|
+
reportProfiles(out)
|
|
1410
|
+
const min = Math.max(1, Math.round((out?.autoLockMs || 5 * 60 * 1000) / 60000))
|
|
1411
|
+
console.log(`Ya puedes editar el perfil. Se vuelve a bloquear solo tras ${min} min sin usarse` +
|
|
1412
|
+
' (o al reiniciar el servicio, o con: dotrino-vault lock).')
|
|
1146
1413
|
}
|
|
1147
1414
|
|
|
1148
1415
|
async function cmdLock () {
|
|
@@ -1167,11 +1434,19 @@ function help () {
|
|
|
1167
1434
|
tui interfaz de terminal a pantalla completa (bóvedas, pares, secretos)
|
|
1168
1435
|
status estado del servicio + fingerprint
|
|
1169
1436
|
pair [--save <f>] inicia un emparejamiento (QR + espera); --save escribe la invitación (.dpair)
|
|
1437
|
+
pair --kms <config.json>
|
|
1438
|
+
el sitio que se cree (--adopt o --new-account) NACE con su clave
|
|
1439
|
+
de disco en el KMS. Es el único momento que sirve: la llave del
|
|
1440
|
+
aparato se genera al crear el perfil
|
|
1170
1441
|
pair --new-account [nombre]
|
|
1171
1442
|
estrena una cuenta VACÍA en este vault y mete ahí al dispositivo
|
|
1172
1443
|
(sin la bandera entra a la cuenta activa, o a la de --profile)
|
|
1173
1444
|
pair --service <ns> empareja un SERVICIO (proxy, geo…) con acceso SOLO a sus secretos
|
|
1174
1445
|
pair --approval el aparato que entre pedirá tu aprobación (teléfono) al recibir claves
|
|
1446
|
+
pair --admin el aparato que entre podrá ADMINISTRAR (es lo que es una consola).
|
|
1447
|
+
El QR no lleva nada: el permiso se aplica al aprobar el código aquí
|
|
1448
|
+
pair --quiet escupe SOLO la invitación (una línea) y termina: sin QR y sin
|
|
1449
|
+
esperar. Para desplegar (un contenedor no mira un QR en pantalla)
|
|
1175
1450
|
pair --scope <lista> los PERMISOS del cert: sign,read,store,contrasenas,secrets:<ns> (sin esto: sign,read,store;
|
|
1176
1451
|
se combina con --service: --service eco --scope sign = bot que firma y lee su cajón)
|
|
1177
1452
|
secret set <ns> <CLAVE> <valor> variable del scope <ns>: la comparten TODOS los
|
|
@@ -1204,6 +1479,14 @@ function help () {
|
|
|
1204
1479
|
y una privada NO se vuelve pública (bórrala y créala).
|
|
1205
1480
|
secret visibility <ns> <CLAVE> private tapa una pública sin tocar el valor
|
|
1206
1481
|
secret device visibility <ID> <CLAVE> private
|
|
1482
|
+
join <invitación> [--name <n>] [--kms <config.json>]
|
|
1483
|
+
ESTA bóveda ENTRA en la cuenta de otra (el papel contrario a pair).
|
|
1484
|
+
Entra con su propia llave, así que después se le puede dar +sella
|
|
1485
|
+
y ser el respaldo de esa cuenta. La cuenta ajena queda aquí como un
|
|
1486
|
+
PERFIL más (--name la nombra; --kms le pone la clave de disco en el
|
|
1487
|
+
KMS, y solo vale ahora: el perfil se está creando)
|
|
1488
|
+
Al desplegar, esto mismo va por el entorno y sin entrar a nada:
|
|
1489
|
+
DOTRINO_JOIN (o DOTRINO_JOIN_FILE) + DOTRINO_JOIN_NAME
|
|
1207
1490
|
pending muestra el dispositivo pendiente + su código a comparar
|
|
1208
1491
|
approve <código> aprueba el dispositivo tipeando el código que MUESTRA (el vault no lo sabe)
|
|
1209
1492
|
reject <deviceId> rechaza un dispositivo pendiente
|
|
@@ -1212,7 +1495,14 @@ function help () {
|
|
|
1212
1495
|
members el acta del perfil: quién es tuyo y qué puede hacer
|
|
1213
1496
|
label <ID> <nombre> renombra un dispositivo (el nombre con el que lo reconoces)
|
|
1214
1497
|
caps <ID> ±permiso cambia permisos (+firma -guarda +administra +contrasenas …)
|
|
1498
|
+
+sella = OTRA BÓVEDA que puede sellar el acta de esta cuenta, para
|
|
1499
|
+
que perder una máquina no se la lleve. No es un traspaso
|
|
1215
1500
|
revoke <ID|nonce> quita un dispositivo (con el ID, todos sus certificados)
|
|
1501
|
+
atrest status de dónde sale la clave que cifra el disco (esta máquina, o un KMS)
|
|
1502
|
+
atrest test comprueba que el KMS envuelve y desenvuelve, SIN tocar los datos
|
|
1503
|
+
atrest rekey <f> cambia de proveedor: descifra con la vieja y recifra con la nueva
|
|
1504
|
+
(--machine para volver a la clave de esta máquina). Editar
|
|
1505
|
+
atrest.json a mano NO vale: dejaría el perfil ilegible
|
|
1216
1506
|
activity [n] bitácora de seguridad: firmas, renovaciones, enrolados, rechazos
|
|
1217
1507
|
logs últimos logs del servicio
|
|
1218
1508
|
version muestra la versión instalada
|
|
@@ -1220,6 +1510,10 @@ function help () {
|
|
|
1220
1510
|
Perfiles (varias identidades tuyas en el mismo PC; todas atienden a la vez):
|
|
1221
1511
|
profile ls lista los perfiles (* = el activo, el destino por defecto)
|
|
1222
1512
|
profile add <nombre> crea un perfil (identidad nueva, vacía)
|
|
1513
|
+
profile add <nombre> --kms <f> ...y su clave de disco NACE en el KMS que diga <f>.
|
|
1514
|
+
Es la única forma de que la maestra no exista nunca
|
|
1515
|
+
bajo la clave de esta máquina: migrar después no da
|
|
1516
|
+
lo mismo (una copia vieja del disco la sigue abriendo)
|
|
1223
1517
|
profile use <id|nombre> elige el perfil activo
|
|
1224
1518
|
profile rename <nombre> renombra un perfil
|
|
1225
1519
|
profile rm <id|nombre> BORRA un perfil y su identidad (irreversible)
|
|
@@ -1231,7 +1525,8 @@ dispositivos siguen firmando y leyendo aunque esté bloqueado):
|
|
|
1231
1525
|
profile password [set] pone o cambia la contraseña
|
|
1232
1526
|
profile password rm la quita
|
|
1233
1527
|
unlock desbloquea para poder editar
|
|
1234
|
-
lock vuelve a bloquear (también
|
|
1528
|
+
lock vuelve a bloquear (también solo, a los 5 min sin
|
|
1529
|
+
usarse, y al reiniciar el servicio)
|
|
1235
1530
|
|
|
1236
1531
|
Arrancar y parar, según dónde corra:
|
|
1237
1532
|
Linux (servicio) systemctl --user {start,stop,restart} dotrino-vault
|
|
@@ -1250,6 +1545,7 @@ export async function runCtl (argv) {
|
|
|
1250
1545
|
case 'lock': return cmdLock()
|
|
1251
1546
|
case 'status': return cmdStatus()
|
|
1252
1547
|
case 'pair': return cmdPair(rest)
|
|
1548
|
+
case 'join': return cmdJoin(rest)
|
|
1253
1549
|
case 'pending': return cmdPending()
|
|
1254
1550
|
case 'approve': return cmdApprove(rest[0])
|
|
1255
1551
|
case 'reject': return cmdReject(rest[0])
|
|
@@ -1261,6 +1557,7 @@ export async function runCtl (argv) {
|
|
|
1261
1557
|
case 'revoke': return cmdRevoke(rest[0])
|
|
1262
1558
|
case 'secret': return cmdSecret(rest)
|
|
1263
1559
|
case 'approval': return cmdApproval(rest)
|
|
1560
|
+
case 'atrest': return cmdAtrest(rest)
|
|
1264
1561
|
case 'activity': return cmdActivity(Number(rest[0]) || 30)
|
|
1265
1562
|
case 'logs': return cmdLogs()
|
|
1266
1563
|
case 'version':
|