@dotrino/vaultd 0.26.2 → 0.46.2
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 +145 -25
- package/bin/dotrino-vaultd.js +4 -4
- package/lib/README.md +13 -2
- package/lib/src/admin.js +92 -3
- package/lib/src/atrest.js +0 -0
- package/lib/src/config.js +1 -1
- package/lib/src/enroll.js +38 -25
- package/lib/src/env.js +37 -17
- package/lib/src/envtext.js +94 -0
- package/lib/src/index.js +4 -4
- package/lib/src/invite.js +8 -8
- package/lib/src/protocol.js +22 -0
- package/lib/src/service.js +497 -135
- package/package.json +11 -6
- package/src/ctl.js +639 -98
- package/src/daemon.js +321 -52
- package/src/manager.js +12 -6
- package/src/profiles.js +109 -13
- package/src/sealKey.js +80 -0
- package/src/sealer.js +170 -0
- package/src/secretsStore.js +881 -29
- package/src/store.js +3 -1
- package/src/transport.js +2 -2
- package/src/tui/app.js +625 -129
- package/src/tui/i18n.js +145 -24
- package/src/vault.js +972 -48
- package/src/vaultControl.js +286 -61
package/src/ctl.js
CHANGED
|
@@ -23,8 +23,11 @@
|
|
|
23
23
|
import fs from 'node:fs'
|
|
24
24
|
import path from 'node:path'
|
|
25
25
|
import { execFileSync } from 'node:child_process'
|
|
26
|
+
import tty from 'node:tty'
|
|
26
27
|
import { pubkeyId } from '@dotrino/identity/capabilities'
|
|
27
28
|
import { dataDir, readJson } from './paths.js'
|
|
29
|
+
import { assertVar } from './secretsStore.js'
|
|
30
|
+
import { parseEnvText, PAIR_RE } from '../lib/src/envtext.js'
|
|
28
31
|
import { qrToString } from './qr.js'
|
|
29
32
|
import { encodeInvite, inviteUrl } from '../lib/src/invite.js'
|
|
30
33
|
import { VERSION } from './version.js'
|
|
@@ -49,7 +52,18 @@ function takeProfileFlag (args) {
|
|
|
49
52
|
}
|
|
50
53
|
/** Campo `profile` de las peticiones al daemon (omitido = perfil activo). */
|
|
51
54
|
const withProfile = (obj) => (PROFILE ? { ...obj, profile: PROFILE } : obj)
|
|
52
|
-
|
|
55
|
+
/**
|
|
56
|
+
* Deja una petición para el daemon. ATÓMICA (escribir aparte y renombrar): el daemon
|
|
57
|
+
* vigila la carpeta con `fs.watch`, que avisa al CREAR el archivo y no al terminar de
|
|
58
|
+
* escribirlo, así que escrito en el sitio se puede leer a medias — y una petición que no
|
|
59
|
+
* parsea se pierde con todos sus datos.
|
|
60
|
+
*/
|
|
61
|
+
const writeReq = (name, obj) => {
|
|
62
|
+
const dest = path.join(dir, name)
|
|
63
|
+
const tmp = dest + '.tmp'
|
|
64
|
+
fs.writeFileSync(tmp, JSON.stringify(withProfile({ ...obj, at: Date.now() })), { mode: 0o600 })
|
|
65
|
+
fs.renameSync(tmp, dest)
|
|
66
|
+
}
|
|
53
67
|
|
|
54
68
|
const R = '\x1b[31m', B = '\x1b[1m', Z = '\x1b[0m' // rojo / negrita / reset
|
|
55
69
|
// La versión se inyecta en build (esbuild --define); en dev cae a 'dev'.
|
|
@@ -60,13 +74,13 @@ const R = '\x1b[31m', B = '\x1b[1m', Z = '\x1b[0m' // rojo / negrita / reset
|
|
|
60
74
|
* contenedor, así que decir siempre «systemctl» manda a la mitad de la gente a un
|
|
61
75
|
* comando que no existe.
|
|
62
76
|
*/
|
|
63
|
-
const
|
|
64
|
-
const START_HINT =
|
|
77
|
+
const IN_DOCKER = !!process.env.DOTRINO_IN_DOCKER
|
|
78
|
+
const START_HINT = IN_DOCKER
|
|
65
79
|
? 'docker start dotrino-vault'
|
|
66
80
|
: process.platform === 'linux'
|
|
67
81
|
? 'systemctl --user start dotrino-vault'
|
|
68
82
|
: 'dotrino-vaultd (o: npx -y @dotrino/vaultd)'
|
|
69
|
-
const RESTART_HINT =
|
|
83
|
+
const RESTART_HINT = IN_DOCKER
|
|
70
84
|
? 'docker restart dotrino-vault'
|
|
71
85
|
: process.platform === 'linux'
|
|
72
86
|
? 'systemctl --user restart dotrino-vault'
|
|
@@ -88,7 +102,7 @@ function alive (pid) { try { return !!pid && (process.kill(pid, 0) || true) } ca
|
|
|
88
102
|
* vigila la carpeta igualmente, así que la petición se atiende en cuanto se escribe el
|
|
89
103
|
* archivo; la señal solo se ahorra la latencia del watcher.
|
|
90
104
|
*/
|
|
91
|
-
function
|
|
105
|
+
function sendSignal (pid, sig) { try { process.kill(pid, sig) } catch (_) { /* Windows, o el daemon ya se enteró */ } }
|
|
92
106
|
function sleep (ms) { return new Promise((r) => setTimeout(r, ms)) }
|
|
93
107
|
function requireDaemon () {
|
|
94
108
|
const s = readState()
|
|
@@ -138,6 +152,20 @@ function showChallenge (pe) {
|
|
|
138
152
|
console.log(' Si no reconoces este dispositivo: dotrino-vault reject %s\n', pe.deviceId)
|
|
139
153
|
}
|
|
140
154
|
|
|
155
|
+
/**
|
|
156
|
+
* EL CANDADO. Un perfil con contraseña y bloqueado no se ve ni se toca desde esta consola:
|
|
157
|
+
* el daemon contesta `locked` y sin contenido, y aquí se corta con un mensaje que dice qué
|
|
158
|
+
* hacer. Los aparatos ya emparejados siguen atendidos — lo que está cerrado es esta
|
|
159
|
+
* pantalla, no la bóveda.
|
|
160
|
+
*/
|
|
161
|
+
function assertOpen (d) {
|
|
162
|
+
if (d?.locked) {
|
|
163
|
+
console.error('Perfil bloqueado. Ábrelo con: dotrino-vault unlock')
|
|
164
|
+
process.exit(1)
|
|
165
|
+
}
|
|
166
|
+
return d
|
|
167
|
+
}
|
|
168
|
+
|
|
141
169
|
async function cmdPair (args = []) {
|
|
142
170
|
const s = requireDaemon()
|
|
143
171
|
try { fs.rmSync(pairFile, { force: true }) } catch (_) {}
|
|
@@ -152,6 +180,30 @@ async function cmdPair (args = []) {
|
|
|
152
180
|
console.error('uso: dotrino-vault pair --service <ns> (ns en minúsculas, p.ej. proxy)'); process.exit(2)
|
|
153
181
|
}
|
|
154
182
|
}
|
|
183
|
+
// --scope <lista>: los PERMISOS del cert, y nada más. No hay tipos de aparato
|
|
184
|
+
// (2026-08-22, dueño): un aparato es un aparato y lo que puede hacer. Sin --scope,
|
|
185
|
+
// el juego de siempre (sign,read,store); `--service <ns>` es el atajo de
|
|
186
|
+
// `secrets:<ns>`, y los dos se combinan (`--service eco --scope sign` = un bot que
|
|
187
|
+
// firma como aparato del acta y lee solo su cajón). `admin` no se empareja: se
|
|
188
|
+
// concede desde el PC (`caps <ID> +administra`).
|
|
189
|
+
const scIdx = args.indexOf('--scope')
|
|
190
|
+
let scope = null
|
|
191
|
+
if (scIdx >= 0) {
|
|
192
|
+
const raw = args[scIdx + 1]
|
|
193
|
+
if (!raw || raw.startsWith('-')) { console.error('uso: dotrino-vault pair --scope sign,read,store,secrets:<ns>'); process.exit(2) }
|
|
194
|
+
const ALIAS = { firma: 'sign', lee: 'read', guarda: 'store' }
|
|
195
|
+
scope = []
|
|
196
|
+
for (const tok of raw.split(',').map((t) => t.trim()).filter(Boolean)) {
|
|
197
|
+
const t = ALIAS[tok] || tok
|
|
198
|
+
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) }
|
|
199
|
+
if (t === 'sign' || t === 'read' || t === 'store') { scope.push('vault:' + t); continue }
|
|
200
|
+
const m = /^secrets:([a-z0-9-]{1,32})$/.exec(t)
|
|
201
|
+
if (m) { scope.push('vault:secrets:' + m[1]); continue }
|
|
202
|
+
console.error('permiso desconocido: %s (sign | read | store | secrets:<ns>)', tok); process.exit(2)
|
|
203
|
+
}
|
|
204
|
+
if (service) scope.push('vault:secrets:' + service)
|
|
205
|
+
scope = [...new Set(scope)]
|
|
206
|
+
}
|
|
155
207
|
// `--new-account [nombre]`: la otra respuesta a «¿a qué cuenta entra?». En vez de
|
|
156
208
|
// meter el dispositivo en una cuenta que ya vive aquí, se ESTRENA una (vacía) y
|
|
157
209
|
// entra a ella; las demás no se tocan. En la TUI esto es una pregunta con sus
|
|
@@ -183,11 +235,16 @@ async function cmdPair (args = []) {
|
|
|
183
235
|
}
|
|
184
236
|
// La petición se escribe SIEMPRE (aunque no haya --service): lleva a qué perfil
|
|
185
237
|
// se empareja el dispositivo.
|
|
186
|
-
writeReq('pair-request.json', { ...(service ? { service } : {}), ...(adopt ? { mode: 'adopt' } : {}) })
|
|
187
|
-
|
|
238
|
+
writeReq('pair-request.json', { ...(service ? { service } : {}), ...(scope ? { scope } : {}), ...(adopt ? { mode: 'adopt' } : {}) })
|
|
239
|
+
sendSignal(s.pid, 'SIGUSR1')
|
|
188
240
|
|
|
189
241
|
let pair = null
|
|
190
|
-
for (let i = 0; i < 50; i++) {
|
|
242
|
+
for (let i = 0; i < 50; i++) {
|
|
243
|
+
await sleep(100)
|
|
244
|
+
const p = readJson(pairFile, null)
|
|
245
|
+
assertOpen(p) // el candado se contesta por el mismo archivo, para no dejar esperando
|
|
246
|
+
if (p?.expiresAt > Date.now()) { pair = p; break }
|
|
247
|
+
}
|
|
191
248
|
if (!pair) { console.error('No se recibió respuesta del daemon para el emparejamiento.'); process.exit(1) }
|
|
192
249
|
|
|
193
250
|
// Una sola forma para las dos cosas: la invitación compacta (base64url de ~100
|
|
@@ -237,7 +294,7 @@ function cmdApprove (code) {
|
|
|
237
294
|
if (!code) { console.error('uso: dotrino-vault approve <código> (los dígitos que muestra el dispositivo)'); process.exit(2) }
|
|
238
295
|
const s = requireDaemon()
|
|
239
296
|
writeReq('approve-request.json', { code: String(code) })
|
|
240
|
-
|
|
297
|
+
sendSignal(s.pid, 'SIGUSR2')
|
|
241
298
|
console.log('Aprobando con el código %s… verifica con: dotrino-vault devices', code)
|
|
242
299
|
}
|
|
243
300
|
|
|
@@ -245,7 +302,7 @@ function cmdReject (deviceId) {
|
|
|
245
302
|
if (!deviceId) { console.error('uso: dotrino-vault reject <deviceId>'); process.exit(2) }
|
|
246
303
|
const s = requireDaemon()
|
|
247
304
|
writeReq('reject-request.json', { deviceId })
|
|
248
|
-
|
|
305
|
+
sendSignal(s.pid, 'SIGUSR2')
|
|
249
306
|
console.log('Rechazado %s.', deviceId)
|
|
250
307
|
}
|
|
251
308
|
|
|
@@ -265,12 +322,13 @@ async function cmdMe () {
|
|
|
265
322
|
const meFile = path.join(dir, 'me.json')
|
|
266
323
|
try { fs.rmSync(meFile, { force: true }) } catch (_) {}
|
|
267
324
|
writeReq('me-request.json', {})
|
|
268
|
-
|
|
325
|
+
sendSignal(s.pid, 'SIGUSR2')
|
|
269
326
|
let dump = null
|
|
270
327
|
for (let n = 0; n < 50; n++) { await sleep(100); const d = readJson(meFile, null); if (d?.at) { dump = d; break } }
|
|
271
328
|
// El volcado es contenido del usuario: se lee y se BORRA, no se queda ahí suelto.
|
|
272
329
|
try { fs.rmSync(meFile, { force: true }) } catch (_) {}
|
|
273
330
|
if (!dump) { console.error('La bóveda no respondió. ¿Está corriendo? dotrino-vault status'); process.exit(1) }
|
|
331
|
+
assertOpen(dump)
|
|
274
332
|
|
|
275
333
|
const me = dump.me
|
|
276
334
|
if (!me) {
|
|
@@ -279,8 +337,8 @@ async function cmdMe () {
|
|
|
279
337
|
return
|
|
280
338
|
}
|
|
281
339
|
|
|
282
|
-
const
|
|
283
|
-
console.log('\n%sPerfil%s · actualizado %s\n', B, Z,
|
|
340
|
+
const when = me.updatedAt ? new Date(me.updatedAt).toLocaleString() : '—'
|
|
341
|
+
console.log('\n%sPerfil%s · actualizado %s\n', B, Z, when)
|
|
284
342
|
console.log(' nombre : %s', me.nickname || '(sin nombre)')
|
|
285
343
|
console.log(' foto : %s', me.avatar
|
|
286
344
|
? `sí · ${me.avatar.type || 'desconocido'} · ${(me.avatar.bytes / 1024).toFixed(1)} KB`
|
|
@@ -289,17 +347,17 @@ async function cmdMe () {
|
|
|
289
347
|
// Los campos estándar. `visible` es del usuario: teléfono y dirección nacen ocultos.
|
|
290
348
|
const STD = [['nombres', 'nombres'], ['apellidos', 'apellidos'], ['email', 'correo'],
|
|
291
349
|
['telefono', 'teléfono'], ['direccion', 'dirección']]
|
|
292
|
-
const
|
|
293
|
-
if (
|
|
350
|
+
const filled = STD.filter(([k]) => me[k])
|
|
351
|
+
if (filled.length) {
|
|
294
352
|
console.log('')
|
|
295
|
-
for (const [k,
|
|
296
|
-
console.log(' %s: %s%s',
|
|
353
|
+
for (const [k, label] of filled) {
|
|
354
|
+
console.log(' %s: %s%s', label.padEnd(12), me[k], me[k + 'Visible'] === false ? ' (oculto)' : '')
|
|
297
355
|
}
|
|
298
356
|
}
|
|
299
|
-
for (const [
|
|
300
|
-
if (!Array.isArray(
|
|
301
|
-
console.log('\n %s:',
|
|
302
|
-
for (const x of
|
|
357
|
+
for (const [title, list] of [['Enlaces', me.links], ['Otros datos', me.fields]]) {
|
|
358
|
+
if (!Array.isArray(list) || !list.length) continue
|
|
359
|
+
console.log('\n %s:', title)
|
|
360
|
+
for (const x of list) console.log(' %s %s%s', (x.type || x.label || '').padEnd(12), x.value, x.visible === false ? ' (oculto)' : '')
|
|
303
361
|
}
|
|
304
362
|
|
|
305
363
|
console.log('')
|
|
@@ -311,30 +369,35 @@ async function cmdMe () {
|
|
|
311
369
|
*/
|
|
312
370
|
async function cmdMembers () {
|
|
313
371
|
const s = requireDaemon()
|
|
314
|
-
const
|
|
315
|
-
try { fs.rmSync(
|
|
372
|
+
const recordFile = path.join(dataDir(), 'acta.json')
|
|
373
|
+
try { fs.rmSync(recordFile, { force: true }) } catch (_) {}
|
|
316
374
|
writeReq('dump-request.json', {})
|
|
317
|
-
|
|
318
|
-
let
|
|
319
|
-
for (let i = 0; i < 50; i++) { await sleep(100); const a = readJson(
|
|
320
|
-
if (!
|
|
321
|
-
|
|
375
|
+
sendSignal(s.pid, 'SIGUSR2')
|
|
376
|
+
let record = null
|
|
377
|
+
for (let i = 0; i < 50; i++) { await sleep(100); const a = readJson(recordFile, null); if (a?.at) { record = a; break } }
|
|
378
|
+
if (!record) { console.error('El daemon no respondió.'); process.exit(1) }
|
|
379
|
+
assertOpen(record)
|
|
380
|
+
if (!record.members?.length) { console.log('Este perfil todavía no tiene acta.'); return }
|
|
322
381
|
|
|
323
382
|
const CAP = { sign: 'firma', store: 'guarda', read: 'lee', secrets: 'lee sus claves', admin: `${B}administra el perfil${Z}` }
|
|
324
383
|
// El nombre del perfil es una pubkey JWK. Recortarla no la hace legible: la deja
|
|
325
384
|
// pareciendo un error (`{"key_ops":["verify"],"e…`). Se muestra su huella corta, la
|
|
326
385
|
// misma que se enseña al emparejar y en la lista de miembros.
|
|
327
|
-
const
|
|
328
|
-
console.log('\n%sPerfil%s %s · acta #%d\n', B, Z,
|
|
329
|
-
for (const m of
|
|
330
|
-
const
|
|
331
|
-
const
|
|
386
|
+
const profileId = await deviceIdOf(record.profileId).catch(() => '????-????')
|
|
387
|
+
console.log('\n%sPerfil%s %s · acta #%d\n', B, Z, profileId, record.seq)
|
|
388
|
+
for (const m of record.members) {
|
|
389
|
+
const who = m.label || m.id
|
|
390
|
+
const marks = [
|
|
332
391
|
m.isMaster ? `${B}Master${Z}` : null,
|
|
333
392
|
m.isMe ? 'este dispositivo' : null,
|
|
334
393
|
m.cn ? `servicio «${m.cn}»` : null
|
|
335
394
|
].filter(Boolean)
|
|
336
395
|
const caps = m.caps.length ? m.caps.map((c) => CAP[c] || c).join(', ') : '(sin permisos)'
|
|
337
|
-
console.log(' %s %s%s\n %s', m.id,
|
|
396
|
+
console.log(' %s %s%s\n %s', m.id, who, marks.length ? ' [' + marks.join(' · ') + ']' : '', caps)
|
|
397
|
+
// Un servicio SIN llave de cifrado no puede leer ninguna variable privada: van
|
|
398
|
+
// selladas a esa llave. Se dice aquí, junto a él, porque es el único sitio donde
|
|
399
|
+
// se mira quién es quién — y en la lista de variables ya seria tarde.
|
|
400
|
+
if (m.cn && !m.canSeal) console.log(' %ssin llave de cifrado: NO puede leer sus variables%s', R, Z)
|
|
338
401
|
}
|
|
339
402
|
console.log('\n Cambiar permisos: dotrino-vault caps <ID> +firma | -firma | +guarda | -guarda | +lee | -lee | +administra')
|
|
340
403
|
console.log(' «Administra» deja conectar y quitar dispositivos desde ese aparato, sin venir aquí.')
|
|
@@ -350,55 +413,56 @@ async function cmdMembers () {
|
|
|
350
413
|
* tener que revocar y volver a emparejar.
|
|
351
414
|
*/
|
|
352
415
|
async function cmdLabel (args = []) {
|
|
353
|
-
const [id, ...
|
|
354
|
-
const
|
|
355
|
-
if (!id || !
|
|
416
|
+
const [id, ...rest] = args
|
|
417
|
+
const name = rest.join(' ').trim()
|
|
418
|
+
if (!id || !name) {
|
|
356
419
|
console.error('uso: dotrino-vault label <ID> <nombre> (p.ej. label AB12-CD34 "Teléfono de casa")')
|
|
357
420
|
process.exit(2)
|
|
358
421
|
}
|
|
359
|
-
const m = await
|
|
360
|
-
writeReq('label-request.json', { pub: m.pub, label:
|
|
361
|
-
|
|
362
|
-
console.log('Listo: %s ahora se llama «%s». Compruébalo con: dotrino-vault members', m.id,
|
|
422
|
+
const m = await findMember(id)
|
|
423
|
+
writeReq('label-request.json', { pub: m.pub, label: name })
|
|
424
|
+
sendSignal(requireDaemon().pid, 'SIGUSR2')
|
|
425
|
+
console.log('Listo: %s ahora se llama «%s». Compruébalo con: dotrino-vault members', m.id, name.slice(0, 60))
|
|
363
426
|
}
|
|
364
427
|
|
|
365
428
|
/** Busca un miembro del acta por su identificador (AB12-CD34) o se rinde con un mensaje claro. */
|
|
366
|
-
async function
|
|
429
|
+
async function findMember (id) {
|
|
367
430
|
const s = requireDaemon()
|
|
368
|
-
const
|
|
369
|
-
try { fs.rmSync(
|
|
431
|
+
const recordFile = path.join(dataDir(), 'acta.json')
|
|
432
|
+
try { fs.rmSync(recordFile, { force: true }) } catch (_) {}
|
|
370
433
|
writeReq('dump-request.json', {})
|
|
371
|
-
|
|
372
|
-
let
|
|
373
|
-
for (let i = 0; i < 50; i++) { await sleep(100); const a = readJson(
|
|
374
|
-
|
|
434
|
+
sendSignal(s.pid, 'SIGUSR2')
|
|
435
|
+
let record = null
|
|
436
|
+
for (let i = 0; i < 50; i++) { await sleep(100); const a = readJson(recordFile, null); if (a?.at) { record = a; break } }
|
|
437
|
+
assertOpen(record)
|
|
438
|
+
const m = record?.members?.find((x) => x.id === String(id).toUpperCase())
|
|
375
439
|
if (!m) { console.error('No hay ningún dispositivo con ese identificador. Míralos con: dotrino-vault members'); process.exit(1) }
|
|
376
440
|
return m
|
|
377
441
|
}
|
|
378
442
|
|
|
379
443
|
/** `dotrino-vault caps <ID> ±permiso` — cambia lo que puede hacer un dispositivo. */
|
|
380
444
|
async function cmdCaps (args = []) {
|
|
381
|
-
const [id, ...
|
|
382
|
-
if (!id || !
|
|
445
|
+
const [id, ...changes] = args
|
|
446
|
+
if (!id || !changes.length) {
|
|
383
447
|
console.error('uso: dotrino-vault caps <ID> +firma|-firma|+guarda|-guarda|+lee|-lee|+administra|-administra')
|
|
384
448
|
process.exit(2)
|
|
385
449
|
}
|
|
386
|
-
const
|
|
450
|
+
const CAP_BY_WORD = {
|
|
387
451
|
firma: 'sign', guarda: 'store', lee: 'read', administra: 'admin',
|
|
388
452
|
sign: 'sign', store: 'store', read: 'read', admin: 'admin'
|
|
389
453
|
}
|
|
390
454
|
const s = requireDaemon()
|
|
391
|
-
const m = await
|
|
455
|
+
const m = await findMember(id)
|
|
392
456
|
|
|
393
457
|
const caps = new Set(m.caps)
|
|
394
|
-
for (const c of
|
|
395
|
-
const
|
|
396
|
-
const cap =
|
|
397
|
-
if (!cap || (
|
|
398
|
-
if (
|
|
458
|
+
for (const c of changes) {
|
|
459
|
+
const sign = c[0]
|
|
460
|
+
const cap = CAP_BY_WORD[c.slice(1).toLowerCase()]
|
|
461
|
+
if (!cap || (sign !== '+' && sign !== '-')) { console.error('permiso no reconocido: %s', c); process.exit(2) }
|
|
462
|
+
if (sign === '+') caps.add(cap); else caps.delete(cap)
|
|
399
463
|
}
|
|
400
464
|
writeReq('caps-request.json', { pub: m.pub, caps: [...caps] })
|
|
401
|
-
|
|
465
|
+
sendSignal(s.pid, 'SIGUSR2')
|
|
402
466
|
console.log('Listo. Compruébalo con: dotrino-vault members')
|
|
403
467
|
}
|
|
404
468
|
|
|
@@ -406,25 +470,26 @@ async function cmdDevices () {
|
|
|
406
470
|
const s = requireDaemon()
|
|
407
471
|
try { fs.rmSync(devFile, { force: true }) } catch (_) {}
|
|
408
472
|
writeReq('dump-request.json', {}) // de qué perfil queremos los dispositivos
|
|
409
|
-
|
|
473
|
+
sendSignal(s.pid, 'SIGUSR2')
|
|
410
474
|
let snap = null
|
|
411
475
|
for (let i = 0; i < 50; i++) { await sleep(100); const d = readJson(devFile, null); if (d?.at) { snap = d; break } }
|
|
412
476
|
if (!snap) { console.error('El daemon no respondió.'); process.exit(1) }
|
|
477
|
+
assertOpen(snap)
|
|
413
478
|
const revoked = snap.revoked || []
|
|
414
|
-
const
|
|
479
|
+
const revokedSet = new Set(revoked.map((r) => r?.nonce || r))
|
|
415
480
|
// UN APARATO, UNA LÍNEA. El daemon lleva la cuenta por CERTIFICADO —correcto para él,
|
|
416
481
|
// porque revocar es revocar un papel—, pero renovar emite uno nuevo cada 30 días: un
|
|
417
482
|
// aparato de un año salía doce veces, y los ya retirados seguían contando como
|
|
418
483
|
// enrolados. Se agrupa por llave y se dice cuántos certificados tiene.
|
|
419
|
-
const
|
|
484
|
+
const byKey = new Map()
|
|
420
485
|
for (const d of (snap.issued || snap.active || snap.delegations || [])) {
|
|
421
|
-
if (d.revokedAt ||
|
|
422
|
-
const
|
|
423
|
-
const y =
|
|
424
|
-
if (!y)
|
|
486
|
+
if (d.revokedAt || revokedSet.has(d.nonce)) continue
|
|
487
|
+
const key = d.sub || d.nonce
|
|
488
|
+
const y = byKey.get(key)
|
|
489
|
+
if (!y) byKey.set(key, { ...d, certs: 1 })
|
|
425
490
|
else { y.certs++; if ((d.exp || 0) > (y.exp || 0)) Object.assign(y, { ...d, certs: y.certs }) }
|
|
426
491
|
}
|
|
427
|
-
const active = [...
|
|
492
|
+
const active = [...byKey.values()]
|
|
428
493
|
console.log('Dispositivos enrolados: %d', active.length)
|
|
429
494
|
for (const d of active) {
|
|
430
495
|
const did = d.sub ? await deviceIdOf(d.sub) : '????-????'
|
|
@@ -454,14 +519,14 @@ async function cmdRevoke (arg) {
|
|
|
454
519
|
const esId = /^[0-9a-f]{4}-?[0-9a-f]{4}$/i.test(arg)
|
|
455
520
|
const s = requireDaemon()
|
|
456
521
|
if (esId) {
|
|
457
|
-
const m = await
|
|
522
|
+
const m = await findMember(arg.toUpperCase().includes('-') ? arg.toUpperCase() : arg.toUpperCase().replace(/(.{4})(.{4})/, '$1-$2'))
|
|
458
523
|
writeReq('revoke-request.json', { sub: m.pub })
|
|
459
|
-
|
|
524
|
+
sendSignal(s.pid, 'SIGUSR2')
|
|
460
525
|
console.log('Quitado %s (todos sus certificados). Se autoborrará al reconectar. Verifica: dotrino-vault devices', m.id)
|
|
461
526
|
return
|
|
462
527
|
}
|
|
463
528
|
writeReq('revoke-request.json', { nonce: arg })
|
|
464
|
-
|
|
529
|
+
sendSignal(s.pid, 'SIGUSR2')
|
|
465
530
|
console.log('Revocación enviada para nonce=%s. El dispositivo se autoborrará al reconectar. Verifica: dotrino-vault devices', arg)
|
|
466
531
|
}
|
|
467
532
|
|
|
@@ -478,6 +543,10 @@ function profileDir () {
|
|
|
478
543
|
? list.find((x) => x.id === PROFILE || (x.name || '').toLowerCase() === ref)
|
|
479
544
|
: (list.find((x) => x.current) || list[0])
|
|
480
545
|
if (!p) { console.error('el perfil no existe: %s', PROFILE); process.exit(1) }
|
|
546
|
+
// El candado también aquí: esto es la única puerta que lee el directorio del perfil sin
|
|
547
|
+
// pasar por el daemon (la bitácora), y sin esta línea `activity` seguía contando quién
|
|
548
|
+
// firmó y cuándo con la bóveda cerrada.
|
|
549
|
+
assertOpen(p)
|
|
481
550
|
return path.join(dir, 'p', p.id)
|
|
482
551
|
}
|
|
483
552
|
|
|
@@ -500,43 +569,445 @@ function cmdActivity (n = 30) {
|
|
|
500
569
|
}
|
|
501
570
|
}
|
|
502
571
|
|
|
503
|
-
//
|
|
504
|
-
// leen los SERVICIOS enrolados con `pair --service <ns>`. Nunca se listan valores.
|
|
572
|
+
// Variables de entorno de los servicios: se cargan aquí (el dueño, en el PC del vault) y
|
|
573
|
+
// las leen los SERVICIOS enrolados con `pair --service <ns>`. Nunca se listan valores.
|
|
574
|
+
//
|
|
575
|
+
// DOS CAJONES: las del SCOPE (`secret set <ns> …`) las comparten todos los aparatos que
|
|
576
|
+
// sirven ese namespace; las del APARATO (`secret device set <ID> …`) las lee solo ese
|
|
577
|
+
// aparato y PISAN a las del scope con el mismo nombre. Ahí va lo que cambia de máquina a
|
|
578
|
+
// máquina (el puerto, la URL pública) sin tener que partir el ns en uno por servidor.
|
|
579
|
+
/**
|
|
580
|
+
* `KEY=valor KEY2=valor2` — la forma de CARGAR VARIAS de una vez. Devuelve `null` si no
|
|
581
|
+
* son todos pares, para que la forma clásica de tres argumentos (`set ns CLAVE valor`,
|
|
582
|
+
* donde el valor puede llevar espacios y hasta un `=`) siga funcionando igual.
|
|
583
|
+
*/
|
|
584
|
+
function asPairs (args) {
|
|
585
|
+
if (!args.length) return null
|
|
586
|
+
const out = []
|
|
587
|
+
for (const a of args) {
|
|
588
|
+
const m = PAIR_RE.exec(a)
|
|
589
|
+
if (!m) return null
|
|
590
|
+
out.push({ op: 'set', key: m[1], value: m[2] })
|
|
591
|
+
}
|
|
592
|
+
return out
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
/** Un `.env` con un problema no se carga A MEDIAS: se dice qué línea y no se escribe nada. */
|
|
596
|
+
function abortEnv (errors) {
|
|
597
|
+
console.error('%sNo se cargó nada%s:', R, Z)
|
|
598
|
+
for (const e of errors) console.error(' · %s', envErrorText(e))
|
|
599
|
+
process.exit(2)
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
/** Los códigos del lector de `.env`, en la lengua del CLI. */
|
|
603
|
+
function envErrorText (e) {
|
|
604
|
+
if (e.code === 'shape') return `línea ${e.line}: no tiene la forma CLAVE=valor`
|
|
605
|
+
if (e.code === 'key') return `línea ${e.line}: «${e.key}» va en MAYÚSCULAS_CON_GUION_BAJO`
|
|
606
|
+
if (e.code === 'novalue') return `línea ${e.line}: ${e.key} no tiene valor (para quitarla: secret rm)`
|
|
607
|
+
if (e.code === 'dup') return `línea ${e.line}: ${e.key} ya venía en la línea ${e.first}`
|
|
608
|
+
return 'no hay ninguna variable que cargar'
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
/** El problema de una variable, en español, o `null`. Las reglas son las del cajón. */
|
|
612
|
+
function problemWith (key, value) {
|
|
613
|
+
try { assertVar(key, value); return null } catch (e) {
|
|
614
|
+
if (/invalid key/.test(e.message)) return 'el nombre va en MAYÚSCULAS_CON_GUION_BAJO (p. ej. TURN_KEY_ID)'
|
|
615
|
+
if (/non-empty/.test(e.message)) return 'no tiene valor (para quitarla: secret rm)'
|
|
616
|
+
if (/too long/.test(e.message)) return 'el valor es demasiado largo'
|
|
617
|
+
return e.message
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
|
|
505
621
|
async function cmdSecret (rest) {
|
|
506
|
-
|
|
622
|
+
// --public / --private: si el VALOR puede salir de esta máquina hacia la consola remota.
|
|
623
|
+
// Se sacan de la línea antes de partirla, para que puedan ir en cualquier posición y no
|
|
624
|
+
// se cuelen dentro del valor (que es lo último y puede llevar espacios).
|
|
625
|
+
let isPublic
|
|
626
|
+
const rest2 = rest.filter((a) => {
|
|
627
|
+
if (a === '--public') { isPublic = true; return false }
|
|
628
|
+
if (a === '--private') { isPublic = false; return false }
|
|
629
|
+
return true
|
|
630
|
+
})
|
|
631
|
+
const [sub, ...args] = rest2
|
|
507
632
|
const s = requireDaemon()
|
|
633
|
+
|
|
634
|
+
/**
|
|
635
|
+
* La contraseña del perfil, y SOLO si el perfil la tiene.
|
|
636
|
+
*
|
|
637
|
+
* Desde v5 la piden únicamente las operaciones que **LEEN** un valor: verlo, cambiarle
|
|
638
|
+
* la visibilidad (para enseñarlo hay que sacarlo), convertir el archivo y saldar lo que
|
|
639
|
+
* quedó a deber. **Guardar no la pide**: sellar solo necesita las públicas de quien va
|
|
640
|
+
* a leer (`docs/secretos-sellados.md` §8.1). Borrar tampoco, porque tirar un sobre no
|
|
641
|
+
* obliga a abrirlo.
|
|
642
|
+
*/
|
|
643
|
+
let cachedPwd
|
|
644
|
+
const adminPassword = async () => {
|
|
645
|
+
if (cachedPwd !== undefined) return cachedPwd
|
|
646
|
+
const d = await profileRequest('list')
|
|
647
|
+
const list = Array.isArray(d.profiles) ? d.profiles : []
|
|
648
|
+
const p = PROFILE
|
|
649
|
+
? list.find((x) => x.id === PROFILE || x.name === PROFILE)
|
|
650
|
+
: (list.find((x) => x.current) || list[0])
|
|
651
|
+
if (!p?.protected) { cachedPwd = null; return null }
|
|
652
|
+
// Nunca por argumento: quedaría en `ps` y en el historial de la shell. Si no hay
|
|
653
|
+
// terminal se dice qué falta, en vez de fallar con «el daemon no aplicó el cambio».
|
|
654
|
+
let pwd
|
|
655
|
+
try { pwd = await askPassword('Contraseña del perfil: ') } catch (_) {
|
|
656
|
+
console.error('Este perfil tiene contraseña y hace falta para guardar una variable.')
|
|
657
|
+
console.error('Ejecútalo desde un terminal (por ssh, con -t).')
|
|
658
|
+
process.exit(1)
|
|
659
|
+
}
|
|
660
|
+
if (!pwd) { console.error('Cancelado.'); process.exit(1) }
|
|
661
|
+
cachedPwd = pwd
|
|
662
|
+
return pwd
|
|
663
|
+
}
|
|
508
664
|
const secretsListFile = path.join(dir, 'secrets-list.json')
|
|
509
665
|
const signalAndWaitList = async () => {
|
|
510
666
|
try { fs.rmSync(secretsListFile, { force: true }) } catch (_) {}
|
|
511
667
|
writeReq('dump-request.json', {}) // de qué perfil son los secretos
|
|
512
|
-
|
|
513
|
-
for (let i = 0; i < 50; i++) {
|
|
668
|
+
sendSignal(s.pid, 'SIGUSR2')
|
|
669
|
+
for (let i = 0; i < 50; i++) {
|
|
670
|
+
await sleep(100)
|
|
671
|
+
const d = readJson(secretsListFile, null)
|
|
672
|
+
// El volcado lleva el valor de las públicas: se borra en cuanto se tiene, para que no
|
|
673
|
+
// se quede esperando en el disco a que copien la carpeta (ver `daemon.js`).
|
|
674
|
+
if (d?.at) { try { fs.rmSync(secretsListFile, { force: true }) } catch (_) {} ; return assertOpen(d) }
|
|
675
|
+
}
|
|
514
676
|
console.error('El daemon no respondió.'); process.exit(1)
|
|
515
677
|
}
|
|
678
|
+
/**
|
|
679
|
+
* SELLA el almacén: pasa un `secrets.json` v3 (valores que esta bóveda puede leer) a
|
|
680
|
+
* v4 (cada privada cifrada a la llave de su destinatario). Corre UNA vez.
|
|
681
|
+
*
|
|
682
|
+
* Es una operación con nombre propio y no un efecto de desbloquear, porque es el
|
|
683
|
+
* punto de no retorno: a partir de aquí la contraseña es lo ÚNICO que puede
|
|
684
|
+
* re-sellar, y perderla impide rotar una variable o sumar un aparato al namespace.
|
|
685
|
+
* Deja `secrets.json.v3.bak` al lado para poder volver.
|
|
686
|
+
*/
|
|
687
|
+
async function secretMigrate () {
|
|
688
|
+
console.log('Esto SELLA las variables privadas a la llave de cada aparato.')
|
|
689
|
+
console.log('A partir de aquí, esta contraseña es lo único que puede volver a sellarlas:')
|
|
690
|
+
console.log('si la pierdes no podrás rotar una variable ni sumar un aparato al namespace.')
|
|
691
|
+
console.log('Se deja una copia en secrets.json.v3.bak por si hay que volver.\n')
|
|
692
|
+
const pwd = await askPassword('Contraseña del perfil: ')
|
|
693
|
+
if (!pwd) { console.error('Cancelado.'); process.exit(1) }
|
|
694
|
+
writeReq('secret-request.json', { op: 'migrate', password: pwd })
|
|
695
|
+
console.log('\nPedido enviado. Mira el resultado con: journalctl --user -u dotrino-vault -n 20')
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
const USAGE = [
|
|
699
|
+
'uso: dotrino-vault secret set <ns> <CLAVE> <valor> [--public|--private]',
|
|
700
|
+
' (la comparten todos los aparatos del ns)',
|
|
701
|
+
' dotrino-vault secret set <ns> CLAVE=valor [CLAVE2=valor2 …] varias DE UNA VEZ',
|
|
702
|
+
' dotrino-vault secret import <ns> [archivo.env] desde un .env (o stdin)',
|
|
703
|
+
' dotrino-vault secret rm <ns> <CLAVE>',
|
|
704
|
+
' dotrino-vault secret device set <ID> <CLAVE> <valor> [--public|--private]',
|
|
705
|
+
' (solo la lee ese aparato, y pisa a la del ns)',
|
|
706
|
+
' dotrino-vault secret device set <ID> CLAVE=valor [CLAVE2=valor2 …]',
|
|
707
|
+
' dotrino-vault secret device import <ID> [archivo.env]',
|
|
708
|
+
' dotrino-vault secret device rm <ID> <CLAVE>',
|
|
709
|
+
' dotrino-vault secret list',
|
|
710
|
+
' dotrino-vault secret migrate SELLA los secretos (una vez)',
|
|
711
|
+
'',
|
|
712
|
+
'CARGA LA CONFIGURACIÓN DE UN SERVICIO DE UNA VEZ (`set` con varios pares, o `import`):',
|
|
713
|
+
'la bóveda la aplica entera y avisa UNA sola vez. De una en una, cada variable es un',
|
|
714
|
+
'cambio de configuración y el servicio se reinicia a media carga.',
|
|
715
|
+
'',
|
|
716
|
+
'Pública o privada dice UNA cosa: si el VALOR puede salir de esta máquina hacia la',
|
|
717
|
+
'consola remota (vault.dotrino.com). Se nace privada. El servicio recibe las dos igual.'
|
|
718
|
+
].join('\n')
|
|
719
|
+
|
|
720
|
+
/**
|
|
721
|
+
* Manda un grupo entero al daemon y comprueba que llegó completo.
|
|
722
|
+
*
|
|
723
|
+
* Se valida TODO aquí antes de escribir nada: si una variable del archivo está mal, no
|
|
724
|
+
* se carga ninguna. Media configuración aplicada es peor que ninguna, porque el
|
|
725
|
+
* servicio arranca con ella y parece que funcionó.
|
|
726
|
+
*/
|
|
727
|
+
const sendBatch = async ({ ns = null, pub = null, items, where }) => {
|
|
728
|
+
const bad = items.map((it) => [it.key, problemWith(it.key, it.value)]).filter(([, p]) => p)
|
|
729
|
+
if (bad.length) {
|
|
730
|
+
console.error('%sNo se cargó nada%s. Revisa:', R, Z)
|
|
731
|
+
for (const [key, p] of bad) console.error(' · %s: %s', key, p)
|
|
732
|
+
process.exit(2)
|
|
733
|
+
}
|
|
734
|
+
const withVisibility = items.map((it) => (isPublic === undefined ? it : { ...it, public: isPublic }))
|
|
735
|
+
// Guardar NO pide la contraseña (§8.1).
|
|
736
|
+
const base = pub ? { op: 'batch', pub, items: withVisibility } : { op: 'batch', ns, items: withVisibility }
|
|
737
|
+
writeReq('secret-request.json', base)
|
|
738
|
+
const d = await signalAndWaitList()
|
|
739
|
+
const list = pub
|
|
740
|
+
? ((Array.isArray(d.dev) ? d.dev : []).find((x) => x.pub === pub)?.keys || [])
|
|
741
|
+
: (d.ns?.[ns] || [])
|
|
742
|
+
const missing = items.filter((it) => !has(list, it.key)).map((it) => it.key)
|
|
743
|
+
if (missing.length) {
|
|
744
|
+
if (d?.secretError) return noSeAplico(d)
|
|
745
|
+
console.error('El daemon no guardó: %s (revisa: dotrino-vault logs)', missing.join(', '))
|
|
746
|
+
process.exit(1)
|
|
747
|
+
}
|
|
748
|
+
console.log('%d variables guardadas en %s%s%s%s', items.length, B, where, Z,
|
|
749
|
+
isPublic === undefined ? '' : isPublic ? ' (públicas)' : ' (privadas)')
|
|
750
|
+
console.log('Un solo aviso de cambio: el servicio se reinicia una vez, con todo puesto.')
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
/** El texto del `.env`: de un archivo, o de la entrada estándar si no se da ninguno. */
|
|
754
|
+
const readEnvText = (file) => {
|
|
755
|
+
if (file) {
|
|
756
|
+
try { return fs.readFileSync(file, 'utf8') } catch (e) {
|
|
757
|
+
console.error('No se pudo leer %s: %s', file, e.message); process.exit(1)
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
if (process.stdin.isTTY) {
|
|
761
|
+
console.error('%s\n\nimport necesita un archivo, o el .env por la entrada estándar:', USAGE)
|
|
762
|
+
console.error(' dotrino-vault secret import proxy .env')
|
|
763
|
+
console.error(' cat .env | dotrino-vault secret import proxy')
|
|
764
|
+
process.exit(2)
|
|
765
|
+
}
|
|
766
|
+
return fs.readFileSync(0, 'utf8')
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
/**
|
|
770
|
+
* Una variable en la lista: su nombre y su valor. La PÚBLICA enseña el suyo (pública
|
|
771
|
+
* quiere decir que ese valor puede salir de esta máquina: taparlo aquí, delante de su
|
|
772
|
+
* dueño, era lo único que la marca no significaba). La privada no se muestra.
|
|
773
|
+
*/
|
|
774
|
+
const printVar = (k) => console.log(' · %s %s', k.key, k.public ? `${k.value ?? ''} (pública)` : '••••••')
|
|
775
|
+
/**
|
|
776
|
+
* Por qué no se aplicó. El daemon deja el motivo en el volcado siguiente; sin él lo
|
|
777
|
+
* único que se podía decir era «revisa los logs», que no ayuda a quien acaba de
|
|
778
|
+
* escribir mal la contraseña.
|
|
779
|
+
*/
|
|
780
|
+
const noSeAplico = (d) => {
|
|
781
|
+
if (d?.secretError?.code === 'WRONG_PASSWORD') console.error('Contraseña incorrecta.')
|
|
782
|
+
else if (d?.secretError?.error) console.error('No se aplicó: %s', d.secretError.error)
|
|
783
|
+
else console.error('El daemon no aplicó el cambio (revisa: dotrino-vault logs)')
|
|
784
|
+
process.exit(1)
|
|
785
|
+
}
|
|
786
|
+
const has = (list, key) => (list || []).some((x) => x.key === key)
|
|
787
|
+
/**
|
|
788
|
+
* Los cajones que quedaron a deber un sellado. Sale ARRIBA del todo en rojo porque no
|
|
789
|
+
* es un detalle: mientras esté ahí, los aparatos de ese cajón NO están leyendo sus
|
|
790
|
+
* variables, y la bóveda no puede arreglarlo sola — necesita la contraseña.
|
|
791
|
+
*/
|
|
792
|
+
/**
|
|
793
|
+
* Un perfil SIN contraseña abre sus variables privadas con la llave de esta máquina,
|
|
794
|
+
* cuyo material vive en este mismo disco: una copia del disco las abre. Es un default
|
|
795
|
+
* deliberado —tiene que seguir funcionando— pero NO es lo mismo, y callarlo es
|
|
796
|
+
* exactamente el error del comentario mentiroso de `atrest.js`.
|
|
797
|
+
*/
|
|
798
|
+
const printNoPassword = async () => {
|
|
799
|
+
const d = await profileRequest('list')
|
|
800
|
+
const list = Array.isArray(d.profiles) ? d.profiles : []
|
|
801
|
+
const p = PROFILE ? list.find((x) => x.id === PROFILE || x.name === PROFILE) : (list.find((x) => x.current) || list[0])
|
|
802
|
+
if (!p || p.protected) return
|
|
803
|
+
console.log('%sEste perfil no tiene contraseña%s: las privadas se abren con la llave de ESTA', B, Z)
|
|
804
|
+
console.log('máquina, cuyo material vive en este mismo disco — o sea que una copia del disco')
|
|
805
|
+
console.log('las abre. Ponle una con: dotrino-vault profile password\n')
|
|
806
|
+
}
|
|
807
|
+
const printPending = (d) => {
|
|
808
|
+
const pend = Object.entries(d?.pending || {})
|
|
809
|
+
if (!pend.length) return
|
|
810
|
+
console.error('%sHay %d cajón(es) sin sellar%s:', R, pend.length, Z)
|
|
811
|
+
for (const [owner, info] of pend) {
|
|
812
|
+
console.error(' · %s — %s', owner, info?.kind === 'rotate'
|
|
813
|
+
? 'se fue un miembro y no se pudo rotar su llave'
|
|
814
|
+
: 'entró un aparato y no se le pudo entregar la llave')
|
|
815
|
+
}
|
|
816
|
+
console.error('Sus aparatos NO leen sus variables. Se arregla guardando una variable')
|
|
817
|
+
console.error('de ese cajón con la contraseña: dotrino-vault secret set <ns> <CLAVE> <valor>\n')
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
if (sub === 'migrate') return secretMigrate()
|
|
821
|
+
|
|
822
|
+
// --- VER un valor, su histórico y volver atrás ---------------------------------
|
|
823
|
+
// Las tres van juntas porque son la misma idea: ver es lo único que la contraseña
|
|
824
|
+
// guarda, y revertir es ver + volver a guardar (§8.3/§8.4).
|
|
825
|
+
const ownerDe = async (kind, ref) => {
|
|
826
|
+
if (kind === 'ns') return `ns:${ref}`
|
|
827
|
+
const m = await findMember(ref)
|
|
828
|
+
return `dev:${m.pub}`
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
if (sub === 'show' || sub === 'history' || sub === 'revert') {
|
|
832
|
+
// `secret show device <ID> <CLAVE>` mira el cajón de un aparato.
|
|
833
|
+
const esDev = args[0] === 'device'
|
|
834
|
+
const [ref, key, extra] = esDev ? args.slice(1) : args
|
|
835
|
+
if (!ref || (sub !== 'history' && !key)) {
|
|
836
|
+
console.error('uso: dotrino-vault secret %s [device] <ns|ID> <CLAVE>%s', sub, sub === 'revert' ? ' <marca>' : '')
|
|
837
|
+
process.exit(2)
|
|
838
|
+
}
|
|
839
|
+
const owner = await ownerDe(esDev ? 'dev' : 'ns', ref)
|
|
840
|
+
|
|
841
|
+
if (sub === 'history') {
|
|
842
|
+
writeReq('secret-request.json', { op: 'history', owner, key: key || null })
|
|
843
|
+
const d = await signalAndWaitList()
|
|
844
|
+
const items = d.history?.items || []
|
|
845
|
+
if (!items.length) { console.log('No hay versiones anteriores de %s%s.', owner, key ? `/${key}` : ''); return }
|
|
846
|
+
console.log('\n%sVersiones anteriores%s (la de arriba es la más reciente)\n', B, Z)
|
|
847
|
+
for (const h of items) {
|
|
848
|
+
// Sin `by` no es que falte un dato: es que se escribió AQUÍ, desde esta máquina.
|
|
849
|
+
// Lo que llega por la consola remota sí trae de qué aparato vino.
|
|
850
|
+
console.log(' %s %s%s%s %s%s', new Date(h.ts).toISOString(), B, h.key, Z,
|
|
851
|
+
h.by ? `desde ${h.by.slice(0, 12)}…` : 'desde esta máquina', h.signed ? '' : ' (sobre sin firma)')
|
|
852
|
+
}
|
|
853
|
+
console.log('\nPara volver a una: dotrino-vault secret revert %s%s <CLAVE> <marca>\n',
|
|
854
|
+
esDev ? 'device ' : '', ref)
|
|
855
|
+
return
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
if (sub === 'revert') {
|
|
859
|
+
if (!extra) { console.error('uso: dotrino-vault secret revert [device] <ns|ID> <CLAVE> <marca>'); process.exit(2) }
|
|
860
|
+
const ts = Number.isFinite(Number(extra)) ? Number(extra) : Date.parse(extra)
|
|
861
|
+
if (!Number.isFinite(ts)) { console.error('La marca es la que enseña `secret history` (fecha ISO).'); process.exit(2) }
|
|
862
|
+
const password = await adminPassword()
|
|
863
|
+
writeReq('secret-request.json', { op: 'revert', owner, key, ts, ...(password ? { password } : {}) })
|
|
864
|
+
const d = await signalAndWaitList()
|
|
865
|
+
if (d?.secretError) return noSeAplico(d)
|
|
866
|
+
console.log('Restaurada: %s/%s', owner, key)
|
|
867
|
+
return
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
// `show`: el valor. Es lo único que la contraseña guarda en esta máquina.
|
|
871
|
+
const password = await adminPassword()
|
|
872
|
+
writeReq('secret-request.json', { op: 'reveal', owner, key, ...(password ? { password } : {}) })
|
|
873
|
+
const d = await signalAndWaitList()
|
|
874
|
+
if (d?.secretError) return noSeAplico(d)
|
|
875
|
+
if (d.revealed?.owner !== owner || d.revealed?.key !== key) {
|
|
876
|
+
console.error('El daemon no devolvió el valor (revisa: dotrino-vault logs)')
|
|
877
|
+
process.exit(1)
|
|
878
|
+
}
|
|
879
|
+
if (d.revealed.value == null) { console.error('No existe esa variable.'); process.exit(1) }
|
|
880
|
+
console.log(d.revealed.value)
|
|
881
|
+
return
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
// Saldar lo que quedó a deber: heredarle a un aparato lo ya guardado, rotar de verdad.
|
|
885
|
+
if (sub === 'settle') {
|
|
886
|
+
const password = await adminPassword()
|
|
887
|
+
writeReq('secret-request.json', { op: 'settle', ...(password ? { password } : {}) })
|
|
888
|
+
const d = await signalAndWaitList()
|
|
889
|
+
if (d?.secretError) return noSeAplico(d)
|
|
890
|
+
const pend = Object.keys(d.pending || {})
|
|
891
|
+
if (!pend.length) console.log('Nada pendiente.')
|
|
892
|
+
else console.log('Siguen pendientes: %s', pend.join(', '))
|
|
893
|
+
return
|
|
894
|
+
}
|
|
895
|
+
|
|
516
896
|
if (sub === 'list') {
|
|
517
897
|
const d = await signalAndWaitList()
|
|
518
898
|
const names = d.ns || {}
|
|
519
899
|
const nss = Object.keys(names)
|
|
520
|
-
|
|
900
|
+
const dev = Array.isArray(d.dev) ? d.dev : []
|
|
901
|
+
if (!nss.length && !dev.length) {
|
|
902
|
+
console.log('No hay variables guardadas. Agrega una: dotrino-vault secret set <ns> <CLAVE> <valor>')
|
|
903
|
+
return
|
|
904
|
+
}
|
|
905
|
+
if (nss.length) console.log('\n%sPor scope%s (las comparten todos los aparatos del perfil)\n', B, Z)
|
|
521
906
|
for (const n of nss) {
|
|
522
907
|
console.log('%s%s%s (scope vault:secrets:%s)', B, n, Z, n)
|
|
523
|
-
for (const k of names[n])
|
|
908
|
+
for (const k of names[n]) printVar(k)
|
|
909
|
+
}
|
|
910
|
+
if (dev.length) console.log('\n%sPor aparato%s (solo las lee ese aparato; pisan a las del scope)\n', B, Z)
|
|
911
|
+
for (const x of dev) {
|
|
912
|
+
const who = [x.label, x.cn ? `servicio «${x.cn}»` : null, x.orphan ? 'YA NO ESTÁ EN EL ACTA' : null].filter(Boolean).join(' · ')
|
|
913
|
+
console.log('%s%s%s%s', B, x.id, Z, who ? ' ' + who : '')
|
|
914
|
+
for (const k of x.keys) printVar(k)
|
|
915
|
+
}
|
|
916
|
+
console.log('\n(pública) = su valor se puede ver desde la consola remota. Las demás no salen de aquí.\n')
|
|
917
|
+
printPending(d)
|
|
918
|
+
await printNoPassword()
|
|
919
|
+
return
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
// Por APARATO: `secret device set|rm|visibility <ID> <CLAVE> [valor|public|private]`.
|
|
923
|
+
if (sub === 'device') {
|
|
924
|
+
const [op, id, key, ...valueParts] = args
|
|
925
|
+
const value = valueParts.join(' ')
|
|
926
|
+
const ops = ['set', 'rm', 'visibility', 'import']
|
|
927
|
+
const asGroup = op === 'import' || (op === 'set' && !!asPairs(args.slice(2)))
|
|
928
|
+
if (!ops.includes(op) || !id || (!asGroup && (!key || (op === 'set' && !value)))) { console.error(USAGE); process.exit(2) }
|
|
929
|
+
const m = await findMember(id)
|
|
930
|
+
// Se avisa aquí, con nombre y apellido, en vez de dejar que el daemon lo rechace y la
|
|
931
|
+
// CLI diga «no aplicó el cambio»: quien escribe esto quiere saber POR QUÉ no vale.
|
|
932
|
+
if (!m.cn) {
|
|
933
|
+
console.error('%s no es un servicio, y solo los servicios leen variables.', m.id)
|
|
934
|
+
console.error('Empareja el servicio con: dotrino-vault pair --service <ns>')
|
|
935
|
+
process.exit(1)
|
|
524
936
|
}
|
|
937
|
+
if (op === 'import') {
|
|
938
|
+
const { items, errors } = parseEnvText(readEnvText(args[2]))
|
|
939
|
+
if (errors.length) return abortEnv(errors)
|
|
940
|
+
return sendBatch({ pub: m.pub, items, where: m.id })
|
|
941
|
+
}
|
|
942
|
+
if (asGroup) return sendBatch({ pub: m.pub, items: asPairs(args.slice(2)), where: m.id })
|
|
943
|
+
if (key && PAIR_RE.test(key) && args.length > 3) {
|
|
944
|
+
console.error('%s\n\nO todos los argumentos son CLAVE=valor, o es una sola variable.', USAGE)
|
|
945
|
+
process.exit(2)
|
|
946
|
+
}
|
|
947
|
+
const req = op === 'set'
|
|
948
|
+
? { op: 'dev-set', pub: m.pub, key, value, ...(isPublic === undefined ? {} : { public: isPublic }) }
|
|
949
|
+
: op === 'rm'
|
|
950
|
+
? { op: 'dev-rm', pub: m.pub, key }
|
|
951
|
+
: { op: 'dev-vis', pub: m.pub, key, public: wantsPublic(value, USAGE) }
|
|
952
|
+
// Solo la visibilidad pide la frase: enseñar un valor obliga a sacarlo.
|
|
953
|
+
const password = op === 'vis' ? await adminPassword() : null
|
|
954
|
+
writeReq('secret-request.json', password ? { ...req, password } : req)
|
|
955
|
+
const d = await signalAndWaitList()
|
|
956
|
+
const keys = (Array.isArray(d.dev) ? d.dev : []).find((x) => x.pub === m.pub)?.keys || []
|
|
957
|
+
const ok = op === 'rm' ? !has(keys, key) : has(keys, key)
|
|
958
|
+
if (!ok) return noSeAplico(d)
|
|
959
|
+
if (op === 'rm') console.log('Variable borrada: %s/%s', m.id, key)
|
|
960
|
+
else console.log('Variable guardada: %s/%s%s', m.id, key, (keys.find((x) => x.key === key)?.public) ? ' (pública)' : '')
|
|
525
961
|
return
|
|
526
962
|
}
|
|
527
|
-
|
|
963
|
+
|
|
964
|
+
// Desde un `.env`: el caso real de estrenar un servicio, y el que de una en una
|
|
965
|
+
// reiniciaba al agente una vez por variable.
|
|
966
|
+
if (sub === 'import') {
|
|
967
|
+
const [ns, file] = args
|
|
968
|
+
if (!ns) { console.error(USAGE); process.exit(2) }
|
|
969
|
+
const { items, errors } = parseEnvText(readEnvText(file))
|
|
970
|
+
if (errors.length) return abortEnv(errors)
|
|
971
|
+
return sendBatch({ ns, items, where: ns })
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
if (sub === 'set' || sub === 'rm' || sub === 'visibility') {
|
|
975
|
+
const [ns, key, ...valueParts] = args
|
|
528
976
|
const value = valueParts.join(' ')
|
|
529
|
-
|
|
530
|
-
|
|
977
|
+
// `set <ns> CLAVE=valor CLAVE2=valor2` — varias de una vez, un solo aviso.
|
|
978
|
+
const pairs = sub === 'set' ? asPairs(args.slice(1)) : null
|
|
979
|
+
if (ns && pairs) return sendBatch({ ns, items: pairs, where: ns })
|
|
980
|
+
// Mezclar las dos formas (`K1=v1 CLAVE valor`) no es ninguna de las dos: mejor
|
|
981
|
+
// decirlo que guardar una variable llamada «K1=v1».
|
|
982
|
+
if (sub === 'set' && key && PAIR_RE.test(key) && args.length > 2) {
|
|
983
|
+
console.error('%s\n\nO todos los argumentos son CLAVE=valor, o es una sola variable: set <ns> <CLAVE> <valor>.', USAGE)
|
|
984
|
+
process.exit(2)
|
|
531
985
|
}
|
|
532
|
-
|
|
986
|
+
if (!ns || !key || (sub === 'set' && !value)) { console.error(USAGE); process.exit(2) }
|
|
987
|
+
const req = sub === 'set'
|
|
988
|
+
? { op: 'set', ns, key, value, ...(isPublic === undefined ? {} : { public: isPublic }) }
|
|
989
|
+
: sub === 'rm'
|
|
990
|
+
? { op: 'rm', ns, key }
|
|
991
|
+
: { op: 'vis', ns, key, public: wantsPublic(value, USAGE) }
|
|
992
|
+
// Solo la visibilidad pide la frase: enseñar un valor obliga a sacarlo.
|
|
993
|
+
const password = sub === 'visibility' ? await adminPassword() : null
|
|
994
|
+
writeReq('secret-request.json', password ? { ...req, password } : req)
|
|
533
995
|
const d = await signalAndWaitList()
|
|
534
|
-
const
|
|
535
|
-
|
|
536
|
-
|
|
996
|
+
const list = d.ns?.[ns] || []
|
|
997
|
+
const ok = sub === 'rm' ? !has(list, key) : has(list, key)
|
|
998
|
+
if (!ok) return noSeAplico(d)
|
|
999
|
+
if (sub === 'rm') console.log('Secreto borrado: %s/%s', ns, key)
|
|
1000
|
+
else console.log('Secreto guardado: %s/%s%s', ns, key, (list.find((x) => x.key === key)?.public) ? ' (pública)' : '')
|
|
537
1001
|
return
|
|
538
1002
|
}
|
|
539
|
-
console.error(
|
|
1003
|
+
console.error(USAGE); process.exit(2)
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
/** `visibility … public|private` — el único argumento que acepta, y sin adivinar. */
|
|
1007
|
+
function wantsPublic (word, usage) {
|
|
1008
|
+
if (word === 'public') return true
|
|
1009
|
+
if (word === 'private') return false
|
|
1010
|
+
console.error('%s\n\nvisibility acepta «public» o «private».', usage); process.exit(2)
|
|
540
1011
|
}
|
|
541
1012
|
|
|
542
1013
|
/**
|
|
@@ -545,20 +1016,31 @@ async function cmdSecret (rest) {
|
|
|
545
1016
|
*/
|
|
546
1017
|
function askPassword (prompt) {
|
|
547
1018
|
return new Promise((resolve, reject) => {
|
|
548
|
-
|
|
549
|
-
|
|
1019
|
+
// Se lee del TERMINAL, no de la entrada estándar. `secret import` ya usa stdin para
|
|
1020
|
+
// el `.env` (`cat .env | dotrino-vault secret import proxy`) y aun así hay que poder
|
|
1021
|
+
// escribir la contraseña; lo mismo vale para cualquier tubería.
|
|
1022
|
+
let stdin = process.stdin
|
|
1023
|
+
let own = null
|
|
1024
|
+
if (!stdin.isTTY) {
|
|
1025
|
+
try {
|
|
1026
|
+
own = fs.openSync('/dev/tty', 'r')
|
|
1027
|
+
stdin = new tty.ReadStream(own)
|
|
1028
|
+
} catch (_) { return reject(new Error('a terminal is required to type the password')) }
|
|
1029
|
+
}
|
|
550
1030
|
process.stdout.write(prompt)
|
|
551
1031
|
stdin.setRawMode(true); stdin.resume(); stdin.setEncoding('utf8')
|
|
552
1032
|
let buf = ''
|
|
553
1033
|
const done = (err, val) => {
|
|
554
|
-
stdin.setRawMode(false)
|
|
1034
|
+
try { stdin.setRawMode(false) } catch (_) {}
|
|
1035
|
+
stdin.removeListener('data', onData)
|
|
1036
|
+
if (own !== null) { try { stdin.destroy() } catch (_) {} } else stdin.pause()
|
|
555
1037
|
process.stdout.write('\n')
|
|
556
1038
|
err ? reject(err) : resolve(val)
|
|
557
1039
|
}
|
|
558
1040
|
const onData = (ch) => {
|
|
559
1041
|
for (const c of ch) {
|
|
560
1042
|
if (c === '\n' || c === '\r' || c === '\u0004') return done(null, buf) // Enter / Ctrl-D
|
|
561
|
-
if (c === '\u0003') return done(new Error('
|
|
1043
|
+
if (c === '\u0003') return done(new Error('cancelled')) // Ctrl-C
|
|
562
1044
|
if (c === '\u007f' || c === '\b') { buf = buf.slice(0, -1); continue } // borrar
|
|
563
1045
|
buf += c
|
|
564
1046
|
}
|
|
@@ -576,7 +1058,7 @@ async function profileRequest (op, extra = {}) {
|
|
|
576
1058
|
const s = requireDaemon()
|
|
577
1059
|
try { fs.rmSync(profilesFile, { force: true }) } catch (_) {}
|
|
578
1060
|
writeReq('profile-request.json', { op, ...extra })
|
|
579
|
-
|
|
1061
|
+
sendSignal(s.pid, 'SIGUSR2')
|
|
580
1062
|
for (let i = 0; i < 100; i++) {
|
|
581
1063
|
await sleep(100)
|
|
582
1064
|
const d = readJson(profilesFile, null)
|
|
@@ -586,7 +1068,14 @@ async function profileRequest (op, extra = {}) {
|
|
|
586
1068
|
}
|
|
587
1069
|
|
|
588
1070
|
function reportProfiles (d) {
|
|
589
|
-
if (d.error) {
|
|
1071
|
+
if (d.error) {
|
|
1072
|
+
// Los dos rechazos del candado se dicen con palabras y con el dato que hace falta; el
|
|
1073
|
+
// resto se reenvía tal cual (son diagnósticos del servicio).
|
|
1074
|
+
if (d.code === 'WRONG_PASSWORD') console.error('Contraseña incorrecta%s.', d.tries ? ` — van ${d.tries} intentos fallidos` : '')
|
|
1075
|
+
else if (d.code === 'TOO_MANY_TRIES') console.error('Demasiados intentos: espera %s s antes de volver a probar.', d.waitSec || '?')
|
|
1076
|
+
else console.error('%s', d.error)
|
|
1077
|
+
process.exit(1)
|
|
1078
|
+
}
|
|
590
1079
|
if (d.done) console.log('%s', d.done)
|
|
591
1080
|
return d
|
|
592
1081
|
}
|
|
@@ -635,14 +1124,37 @@ async function cmdProfile (rest) {
|
|
|
635
1124
|
}
|
|
636
1125
|
case 'password': {
|
|
637
1126
|
const action = args[0]
|
|
638
|
-
|
|
1127
|
+
// QUITARLA pide la actual, y no por trámite: con ella se abre la copia maestra de
|
|
1128
|
+
// los secretos para volver a cerrarla con la llave de esta máquina. Sin ese paso,
|
|
1129
|
+
// quitar la contraseña dejaría las variables privadas ilegibles para siempre.
|
|
1130
|
+
if (action === 'rm') {
|
|
1131
|
+
console.log('Al quitarla, las variables privadas pasan a abrirse con la llave de ESTA')
|
|
1132
|
+
console.log('máquina: siguen cifradas en el disco, pero su material vive en ese mismo')
|
|
1133
|
+
console.log('disco, así que una copia del disco las abre. Los aparatos no se enteran.')
|
|
1134
|
+
const cur = await askPassword('\nContraseña actual: ')
|
|
1135
|
+
if (!cur) { console.error('Cancelado.'); process.exit(1) }
|
|
1136
|
+
reportProfiles(await profileRequest('password-rm', { password: cur }))
|
|
1137
|
+
return
|
|
1138
|
+
}
|
|
639
1139
|
if (action && action !== 'set') { console.error('uso: dotrino-vault profile password [set|rm]'); process.exit(2) }
|
|
640
|
-
console.log('La contraseña
|
|
641
|
-
console.log('funcionando (firmando
|
|
642
|
-
|
|
1140
|
+
console.log('La contraseña se pide para EDITAR el perfil y para escribir variables')
|
|
1141
|
+
console.log('privadas. Tus dispositivos siguen funcionando (firmando, leyendo y')
|
|
1142
|
+
console.log('recibiendo su configuración) aunque el perfil esté bloqueado.')
|
|
1143
|
+
// Si ya hay una, hace falta para poder abrir la copia maestra y re-sellarla. El
|
|
1144
|
+
// camino recomendado para CAMBIARLA es quitarla y ponerla: así cada paso pide
|
|
1145
|
+
// solo lo que necesita.
|
|
1146
|
+
const actual = await askPassword('\nContraseña actual (vacío si no tiene): ')
|
|
1147
|
+
// Se pide una FRASE, y se dice cómo sacarla, porque lo que da fuerza es la
|
|
1148
|
+
// longitud y que no la elija un humano: un modismo tiene la entropía de un
|
|
1149
|
+
// modismo, no la de su longitud. Desde el sellado, esta frase es lo único que
|
|
1150
|
+
// separa una copia del disco de las variables privadas.
|
|
1151
|
+
console.log('\nUsa VARIAS PALABRAS AL AZAR (mínimo 12 caracteres). Para que las elija')
|
|
1152
|
+
console.log('la máquina y no tú:')
|
|
1153
|
+
console.log(" grep -x '[a-z]\\{4,8\\}' /usr/share/dict/words | shuf -n5 | paste -sd-")
|
|
1154
|
+
const pwd = await askPassword('\nContraseña nueva: ')
|
|
643
1155
|
const again = await askPassword('Repítela: ')
|
|
644
1156
|
if (pwd !== again) { console.error('Las contraseñas no coinciden.'); process.exit(1) }
|
|
645
|
-
reportProfiles(await profileRequest('password-set', { password: pwd }))
|
|
1157
|
+
reportProfiles(await profileRequest('password-set', { password: pwd, current: actual || undefined }))
|
|
646
1158
|
return
|
|
647
1159
|
}
|
|
648
1160
|
default:
|
|
@@ -691,9 +1203,38 @@ function help () {
|
|
|
691
1203
|
estrena una cuenta VACÍA en este vault y mete ahí al dispositivo
|
|
692
1204
|
(sin la bandera entra a la cuenta activa, o a la de --profile)
|
|
693
1205
|
pair --service <ns> empareja un SERVICIO (proxy, geo…) con acceso SOLO a sus secretos
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
secret
|
|
1206
|
+
pair --scope <lista> los PERMISOS del cert: sign,read,store,secrets:<ns> (sin esto: sign,read,store;
|
|
1207
|
+
se combina con --service: --service eco --scope sign = bot que firma y lee su cajón)
|
|
1208
|
+
secret set <ns> <CLAVE> <valor> variable del scope <ns>: la comparten TODOS los
|
|
1209
|
+
aparatos del perfil que sirven ese namespace
|
|
1210
|
+
secret set <ns> CLAVE=valor CLAVE2=valor2 …
|
|
1211
|
+
carga VARIAS de una vez: se aplican juntas y el
|
|
1212
|
+
servicio recibe UN solo aviso (se reinicia una vez)
|
|
1213
|
+
secret import <ns> [archivo.env] lo mismo desde un .env (o por la entrada estándar)
|
|
1214
|
+
secret rm <ns> <CLAVE> borra una variable del scope
|
|
1215
|
+
secret device set <ID> <CLAVE> <valor>
|
|
1216
|
+
variable de UN aparato: solo la lee él, y pisa a la
|
|
1217
|
+
del scope que se llame igual (puerto, URL pública…)
|
|
1218
|
+
secret device set <ID> CLAVE=valor …
|
|
1219
|
+
secret device import <ID> [archivo.env]
|
|
1220
|
+
secret device rm <ID> <CLAVE> borra una variable de ese aparato
|
|
1221
|
+
secret list lista los dos cajones: el valor de las públicas,
|
|
1222
|
+
tapadas las privadas
|
|
1223
|
+
secret show [device] <ns|ID> <CLAVE>
|
|
1224
|
+
VE el valor de una privada. Es lo único que pide la
|
|
1225
|
+
contraseña del perfil: en esta máquina es lo único
|
|
1226
|
+
que la separa de una copia del disco
|
|
1227
|
+
secret history [device] <ns|ID> [CLAVE]
|
|
1228
|
+
las versiones anteriores (quién y cuándo)
|
|
1229
|
+
secret revert [device] <ns|ID> <CLAVE> <marca>
|
|
1230
|
+
vuelve a una versión anterior
|
|
1231
|
+
secret settle salda lo pendiente: hereda lo ya guardado a un
|
|
1232
|
+
aparato nuevo y rota de verdad el cajón del que se fue
|
|
1233
|
+
--public | --private (al hacer un set) si el VALOR puede salir de esta
|
|
1234
|
+
máquina hacia la consola remota. Se nace privada,
|
|
1235
|
+
y una privada NO se vuelve pública (bórrala y créala).
|
|
1236
|
+
secret visibility <ns> <CLAVE> private tapa una pública sin tocar el valor
|
|
1237
|
+
secret device visibility <ID> <CLAVE> private
|
|
697
1238
|
pending muestra el dispositivo pendiente + su código a comparar
|
|
698
1239
|
approve <código> aprueba el dispositivo tipeando el código que MUESTRA (el vault no lo sabe)
|
|
699
1240
|
reject <deviceId> rechaza un dispositivo pendiente
|