@dotrino/vaultd 0.6.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/src/ctl.js ADDED
@@ -0,0 +1,597 @@
1
+ /**
2
+ * ctl.js — CLI de control de dotrino-vault.
3
+ *
4
+ * NO abre la identidad ni el proxy: habla con el daemon (único custodio de la
5
+ * maestra) por archivos del dir de datos + señales. Emparejamiento ENDURECIDO
6
+ * (docs/pairing-protocol.md): la maestra solo firma el cert de un dispositivo
7
+ * DESPUÉS de que el dueño compara un código (SAS) y corre `approve`.
8
+ *
9
+ * status estado + fingerprint
10
+ * pair inicia un emparejamiento (muestra el QR y espera el dispositivo)
11
+ * pending muestra el dispositivo pendiente de aprobar + su código
12
+ * approve <id> aprueba un dispositivo (tras comparar el código)
13
+ * reject <id> rechaza un dispositivo pendiente
14
+ * devices lista dispositivos enrolados / revocados
15
+ * revoke <nonce> revoca un dispositivo (y le ordena autoborrarse)
16
+ * profile … perfiles (varias identidades en el mismo PC) y su contraseña
17
+ * unlock / lock candado del perfil (la contraseña solo hace falta para EDITAR)
18
+ * logs últimos logs del servicio
19
+ *
20
+ * MULTI-PERFIL: todos los comandos aceptan `--profile <id|nombre>`; sin él van al
21
+ * perfil ACTIVO (`profile use`).
22
+ */
23
+ import fs from 'node:fs'
24
+ import path from 'node:path'
25
+ import { execFileSync } from 'node:child_process'
26
+ import { pubkeyId } from '@dotrino/identity/capabilities'
27
+ import { dataDir, readJson } from './paths.js'
28
+ import { qrToString } from './qr.js'
29
+
30
+ const dir = dataDir()
31
+ const stateFile = path.join(dir, 'state.json')
32
+ const pairFile = path.join(dir, 'pair.json')
33
+ const pendingFile = path.join(dir, 'pending-enroll.json')
34
+ const devFile = path.join(dir, 'devices.json')
35
+ const profilesFile = path.join(dir, 'profiles-list.json')
36
+
37
+ // `--profile <id|nombre>`: a qué perfil apunta el comando (sin él, el activo).
38
+ // Se extrae de los argumentos antes de interpretarlos, así vale para todos.
39
+ let PROFILE = null
40
+ function takeProfileFlag (args) {
41
+ const i = args.findIndex((a) => a === '--profile' || a === '-p')
42
+ if (i < 0) return args
43
+ const val = args[i + 1]
44
+ if (!val || val.startsWith('-')) { console.error('uso: --profile <id|nombre>'); process.exit(2) }
45
+ PROFILE = val
46
+ return [...args.slice(0, i), ...args.slice(i + 2)]
47
+ }
48
+ /** Campo `profile` de las peticiones al daemon (omitido = perfil activo). */
49
+ const withProfile = (obj) => (PROFILE ? { ...obj, profile: PROFILE } : obj)
50
+ const writeReq = (name, obj) => fs.writeFileSync(path.join(dir, name), JSON.stringify(withProfile({ ...obj, at: Date.now() })), { mode: 0o600 })
51
+
52
+ const R = '\x1b[31m', B = '\x1b[1m', Z = '\x1b[0m' // rojo / negrita / reset
53
+ // La versión se inyecta en build (esbuild --define); en dev cae a 'dev'.
54
+ const VERSION = (typeof __VAULT_VERSION__ !== 'undefined') ? __VAULT_VERSION__ : 'dev'
55
+ const PROFILE_URL = 'https://vault.dotrino.com/dispositivos#vault='
56
+
57
+ /**
58
+ * Cómo se arranca el vault EN ESTA MÁQUINA. El CLI corre en Linux (servicio systemd),
59
+ * en Windows/macOS (por npm, en primer plano o al inicio de sesión) y dentro de un
60
+ * contenedor, así que decir siempre «systemctl» manda a la mitad de la gente a un
61
+ * comando que no existe.
62
+ */
63
+ const EN_DOCKER = !!process.env.DOTRINO_IN_DOCKER
64
+ const START_HINT = EN_DOCKER
65
+ ? 'docker start dotrino-vault'
66
+ : process.platform === 'linux'
67
+ ? 'systemctl --user start dotrino-vault'
68
+ : 'dotrino-vaultd (o: npx -y @dotrino/vaultd)'
69
+ const RESTART_HINT = EN_DOCKER
70
+ ? 'docker restart dotrino-vault'
71
+ : process.platform === 'linux'
72
+ ? 'systemctl --user restart dotrino-vault'
73
+ : 'cierra el vault y vuelve a arrancarlo'
74
+
75
+ function readState () {
76
+ const s = readJson(stateFile, null)
77
+ if (!s) {
78
+ console.error('El vault no parece haber arrancado todavía (no hay state.json en %s).', dir)
79
+ console.error('Arráncalo: %s', START_HINT)
80
+ process.exit(2)
81
+ }
82
+ return s
83
+ }
84
+ function alive (pid) { try { return !!pid && (process.kill(pid, 0) || true) } catch { return false } }
85
+ function sleep (ms) { return new Promise((r) => setTimeout(r, ms)) }
86
+ function requireDaemon () {
87
+ const s = readState()
88
+ if (!alive(s.pid)) { console.error('El daemon no está corriendo. Arráncalo: %s', START_HINT); process.exit(1) }
89
+ return s
90
+ }
91
+ function deviceIdOf (sub) {
92
+ // mismo formato que el daemon: 8 hex agrupados AB12-CD34
93
+ return pubkeyId(sub).then((id) => id.slice(0, 8).toUpperCase().replace(/(.{4})(.{4})/, '$1-$2'))
94
+ }
95
+
96
+ function cmdStatus () {
97
+ const s = readState()
98
+ const up = alive(s.pid)
99
+ console.log('dotrino-vault · %s', up ? 'corriendo' : 'DETENIDO (state.json viejo)')
100
+ console.log(' versión : %s', VERSION)
101
+ // El .deb instala el binario pero NO reinicia el servicio: si el daemon
102
+ // corriendo es más viejo que el CLI, avisar (nos mordió 3 veces).
103
+ if (s.version && s.version !== VERSION) {
104
+ console.log(' ⚠ el servicio corre la versión %s (binario instalado: %s).', s.version, VERSION)
105
+ console.log(' Reinicia para actualizarlo: %s', RESTART_HINT)
106
+ }
107
+ console.log(' fingerprint : %s', s.fingerprint)
108
+ console.log(' proxy : %s', s.proxy)
109
+ console.log(' pid : %s%s', s.pid, up ? '' : ' (no responde)')
110
+ console.log(' datos : %s', dir)
111
+ const profiles = s.profiles || []
112
+ if (profiles.length) {
113
+ console.log(' perfiles : %d', profiles.length)
114
+ for (const p of profiles) console.log(' %s %s', p.current ? '*' : ' ', describeProfile(p))
115
+ if (profiles.length > 1) console.log(' (el * es el perfil activo; los demás siguen atendiendo a sus dispositivos)')
116
+ }
117
+ if (!up) process.exitCode = 1
118
+ }
119
+
120
+ /** Una línea por perfil: nombre, id, huella y estado del candado. */
121
+ function describeProfile (p) {
122
+ const lock = !p.protected ? 'sin contraseña' : (p.locked ? `${B}🔒 bloqueado${Z}` : '🔓 desbloqueado')
123
+ return `${B}${p.name || '(sin nombre)'}${Z} ${p.id} ${p.fingerprint || '—'} ${lock}`
124
+ }
125
+
126
+ function showChallenge (pe) {
127
+ console.log('\n%sUn dispositivo quiere conectarse a tu bóveda:%s', B, Z)
128
+ console.log(' dispositivo : %s%s%s', B, pe.deviceId, Z)
129
+ console.log('\n Ingresa el código que MUESTRA el dispositivo (el vault no lo conoce):')
130
+ console.log(' %sdotrino-vault approve <código>%s', B, Z)
131
+ console.log(' Si no reconocés este dispositivo: dotrino-vault reject %s\n', pe.deviceId)
132
+ }
133
+
134
+ async function cmdPair (args = []) {
135
+ const s = requireDaemon()
136
+ try { fs.rmSync(pairFile, { force: true }) } catch (_) {}
137
+ try { fs.rmSync(pendingFile, { force: true }) } catch (_) {}
138
+ // --service <ns>: emparejar un SERVICIO (proxy, geo…) con cert limitado a
139
+ // vault:secrets:<ns> (no puede firmar como vos ni leer tus datos).
140
+ const svcIdx = args.indexOf('--service')
141
+ let service = null
142
+ if (svcIdx >= 0) {
143
+ service = args[svcIdx + 1]
144
+ if (!service || service.startsWith('-') || !/^[a-z0-9-]{1,32}$/.test(service)) {
145
+ console.error('uso: dotrino-vault pair --service <ns> (ns en minúsculas, p.ej. proxy)'); process.exit(2)
146
+ }
147
+ }
148
+ // La petición se escribe SIEMPRE (aunque no haya --service): lleva a qué perfil
149
+ // se empareja el dispositivo.
150
+ writeReq('pair-request.json', service ? { service } : {})
151
+ process.kill(s.pid, 'SIGUSR1')
152
+
153
+ let pair = null
154
+ for (let i = 0; i < 50; i++) { await sleep(100); const p = readJson(pairFile, null); if (p?.expiresAt > Date.now()) { pair = p; break } }
155
+ if (!pair) { console.error('No se recibió respuesta del daemon para el emparejamiento.'); process.exit(1) }
156
+
157
+ const payload = JSON.stringify(pair.qr)
158
+ const b64 = Buffer.from(payload, 'utf8').toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
159
+ const url = PROFILE_URL + b64
160
+ const mins = Math.round((pair.expiresAt - Date.now()) / 60000)
161
+ console.log('\nEscaneá este QR con el dispositivo que querés conectar (válido %d min):\n', mins)
162
+ console.log(qrToString(url)) // el QR abre vault.dotrino.com/dispositivos y empareja solo
163
+ console.log(`${R}${B}⚠ Este código deja LEER tus datos y FIRMAR con tu identidad.${Z}`)
164
+ console.log(`${R} NO lo compartas con nadie, ni con "soporte". Solo escaneálo en TU dispositivo.${Z}`)
165
+ console.log('\nO abrí esta dirección en el dispositivo:\n ' + url)
166
+ console.log('\nO pega este código en vault.dotrino.com/dispositivos :\n ' + payload)
167
+
168
+ // --save [archivo]: escribe la invitación (.dpair) para transferirla y abrirla en profile.
169
+ const saveIdx = args.indexOf('--save')
170
+ if (saveIdx >= 0) {
171
+ const next = args[saveIdx + 1]
172
+ const file = (next && !next.startsWith('-')) ? next : 'dotrino-invite.dpair'
173
+ try { fs.writeFileSync(file, url + '\n', { mode: 0o600 }); console.log('\nInvitación guardada en: %s\n (ábrela en vault.dotrino.com/dispositivos → «Abrir imagen o archivo». Es efímera y de un solo uso; no la compartas.)', file) }
174
+ catch (e) { console.error('No se pudo guardar la invitación:', e.message) }
175
+ }
176
+
177
+ // Esperar a que el dispositivo se conecte y mostrar su código para comparar.
178
+ console.log('\nEsperando a que el dispositivo se conecte… (Ctrl+C para salir)')
179
+ for (let i = 0; i < 1500; i++) { // ~2.5 min
180
+ await sleep(100)
181
+ const pe = readJson(pendingFile, null)
182
+ if (pe?.deviceId) { showChallenge(pe); return }
183
+ }
184
+ console.log('\nNingún dispositivo se conectó aún. Cuando lo haga: dotrino-vault pending')
185
+ }
186
+
187
+ function cmdPending () {
188
+ requireDaemon()
189
+ const pe = readJson(pendingFile, null)
190
+ if (!pe?.deviceId) { console.log('No hay ningún dispositivo pendiente de aprobar.'); return }
191
+ showChallenge(pe)
192
+ }
193
+
194
+ function cmdApprove (code) {
195
+ if (!code) { console.error('uso: dotrino-vault approve <código> (los dígitos que muestra el dispositivo)'); process.exit(2) }
196
+ const s = requireDaemon()
197
+ writeReq('approve-request.json', { code: String(code) })
198
+ process.kill(s.pid, 'SIGUSR2')
199
+ console.log('Aprobando con el código %s… verifica con: dotrino-vault devices', code)
200
+ }
201
+
202
+ function cmdReject (deviceId) {
203
+ if (!deviceId) { console.error('uso: dotrino-vault reject <deviceId>'); process.exit(2) }
204
+ const s = requireDaemon()
205
+ writeReq('reject-request.json', { deviceId })
206
+ process.kill(s.pid, 'SIGUSR2')
207
+ console.log('Rechazado %s.', deviceId)
208
+ }
209
+
210
+ /**
211
+ * `dotrino-vault members` — el ACTA del perfil: qué llaves son tuyas y qué puede hacer cada
212
+ * una. Es la misma información que muestra la consola de vault.dotrino.com.
213
+ */
214
+ async function cmdMembers () {
215
+ const s = requireDaemon()
216
+ const actaFile = path.join(dataDir(), 'acta.json')
217
+ try { fs.rmSync(actaFile, { force: true }) } catch (_) {}
218
+ writeReq('dump-request.json', {})
219
+ process.kill(s.pid, 'SIGUSR2')
220
+ let acta = null
221
+ for (let i = 0; i < 50; i++) { await sleep(100); const a = readJson(actaFile, null); if (a?.at) { acta = a; break } }
222
+ if (!acta) { console.error('El daemon no respondió.'); process.exit(1) }
223
+ if (!acta.members?.length) { console.log('Este perfil todavía no tiene acta.'); return }
224
+
225
+ const CAP = { sign: 'firma', store: 'guarda', read: 'lee', secrets: 'lee sus claves' }
226
+ // El nombre del perfil es una pubkey JWK. Recortarla no la hace legible: la deja
227
+ // pareciendo un error (`{"key_ops":["verify"],"e…`). Se muestra su huella corta, la
228
+ // misma que se enseña al emparejar y en la lista de miembros.
229
+ const perfilId = await deviceIdOf(acta.profileId).catch(() => '????-????')
230
+ console.log('\n%sPerfil%s %s · acta #%d\n', B, Z, perfilId, acta.seq)
231
+ for (const m of acta.members) {
232
+ const quien = m.label || m.id
233
+ const marcas = [
234
+ m.isMaster ? `${B}manda${Z}` : null,
235
+ m.isMe ? 'este dispositivo' : null,
236
+ m.cn ? `servicio «${m.cn}»` : null
237
+ ].filter(Boolean)
238
+ const caps = m.caps.length ? m.caps.map((c) => CAP[c] || c).join(', ') : '(sin permisos)'
239
+ console.log(' %s %s%s\n %s', m.id, quien, marcas.length ? ' [' + marcas.join(' · ') + ']' : '', caps)
240
+ }
241
+ console.log('\n Cambiar permisos: dotrino-vault caps <ID> +firma | -firma | +guarda | -guarda | +lee | -lee')
242
+ console.log(' Los servicios solo pueden abrir las claves de su propio nombre; eso no se cambia aquí.\n')
243
+ }
244
+
245
+ /** `dotrino-vault caps <ID> ±permiso` — cambia lo que puede hacer un dispositivo. */
246
+ async function cmdCaps (args = []) {
247
+ const [id, ...cambios] = args
248
+ if (!id || !cambios.length) {
249
+ console.error('uso: dotrino-vault caps <ID> +firma|-firma|+guarda|-guarda|+lee|-lee')
250
+ process.exit(2)
251
+ }
252
+ const NOMBRE = { firma: 'sign', guarda: 'store', lee: 'read', sign: 'sign', store: 'store', read: 'read' }
253
+ const s = requireDaemon()
254
+ const actaFile = path.join(dataDir(), 'acta.json')
255
+ try { fs.rmSync(actaFile, { force: true }) } catch (_) {}
256
+ writeReq('dump-request.json', {})
257
+ process.kill(s.pid, 'SIGUSR2')
258
+ let acta = null
259
+ for (let i = 0; i < 50; i++) { await sleep(100); const a = readJson(actaFile, null); if (a?.at) { acta = a; break } }
260
+ const m = acta?.members?.find((x) => x.id === id.toUpperCase())
261
+ if (!m) { console.error('No hay ningún dispositivo con ese identificador. Míralos con: dotrino-vault members'); process.exit(1) }
262
+
263
+ const caps = new Set(m.caps)
264
+ for (const c of cambios) {
265
+ const signo = c[0]
266
+ const cap = NOMBRE[c.slice(1).toLowerCase()]
267
+ if (!cap || (signo !== '+' && signo !== '-')) { console.error('permiso no reconocido: %s', c); process.exit(2) }
268
+ if (signo === '+') caps.add(cap); else caps.delete(cap)
269
+ }
270
+ writeReq('caps-request.json', { pub: m.pub, caps: [...caps] })
271
+ process.kill(s.pid, 'SIGUSR2')
272
+ console.log('Listo. Compruébalo con: dotrino-vault members')
273
+ }
274
+
275
+ async function cmdDevices () {
276
+ const s = requireDaemon()
277
+ try { fs.rmSync(devFile, { force: true }) } catch (_) {}
278
+ writeReq('dump-request.json', {}) // de qué perfil queremos los dispositivos
279
+ process.kill(s.pid, 'SIGUSR2')
280
+ let snap = null
281
+ for (let i = 0; i < 50; i++) { await sleep(100); const d = readJson(devFile, null); if (d?.at) { snap = d; break } }
282
+ if (!snap) { console.error('El daemon no respondió.'); process.exit(1) }
283
+ const active = snap.issued || snap.active || snap.delegations || []
284
+ const revoked = snap.revoked || []
285
+ console.log('Dispositivos enrolados: %d', active.length)
286
+ for (const d of active) {
287
+ const did = d.sub ? await deviceIdOf(d.sub) : '????-????'
288
+ console.log(' · %s %s%s%s', did, d.label || '(sin etiqueta)',
289
+ d.exp ? ' exp=' + new Date(d.exp).toISOString() : '',
290
+ d.nonce ? ' nonce=' + d.nonce : '')
291
+ }
292
+ if (revoked.length) {
293
+ console.log('Revocados: %d', revoked.length)
294
+ for (const r of revoked) console.log(' · nonce=%s', r.nonce)
295
+ }
296
+ console.log('\nPara revocar uno (y ordenarle autoborrarse): dotrino-vault revoke <nonce>')
297
+ }
298
+
299
+ function cmdRevoke (nonce) {
300
+ if (!nonce) { console.error('uso: dotrino-vault revoke <nonce>'); process.exit(2) }
301
+ const s = requireDaemon()
302
+ writeReq('revoke-request.json', { nonce })
303
+ process.kill(s.pid, 'SIGUSR2')
304
+ console.log('Revocación enviada para nonce=%s. El dispositivo se autoborrará al reconectar. Verifica: dotrino-vault devices', nonce)
305
+ }
306
+
307
+ /**
308
+ * Dir de datos de un perfil (o del activo). Cada perfil tiene el suyo, así que su
309
+ * bitácora también es propia. Cae al dir raíz si el daemon aún es mono-perfil.
310
+ */
311
+ function profileDir () {
312
+ const s = readState()
313
+ const list = s.profiles || []
314
+ if (!list.length) return dir // vault anterior al multi-perfil
315
+ const ref = PROFILE ? String(PROFILE).toLowerCase() : null
316
+ const p = ref
317
+ ? list.find((x) => x.id === PROFILE || (x.name || '').toLowerCase() === ref)
318
+ : (list.find((x) => x.current) || list[0])
319
+ if (!p) { console.error('el perfil no existe: %s', PROFILE); process.exit(1) }
320
+ return path.join(dir, 'p', p.id)
321
+ }
322
+
323
+ // Bitácora de actividad de seguridad (quién firmó/renovó/enroló y qué se rechazó).
324
+ function cmdActivity (n = 30) {
325
+ const f = path.join(profileDir(), 'activity.log')
326
+ let lines = []
327
+ try { lines = fs.readFileSync(f, 'utf8').trim().split('\n').filter(Boolean) } catch {
328
+ console.log('Sin actividad registrada todavía (o el servicio es anterior a 0.1.10).'); return
329
+ }
330
+ const ICON = { sign: '🖊 firma', renew: '♻ renovación', enroll: '➕ enrolado', revoke: '⛔ revocado', rejected: '🚫 RECHAZADO', secrets: '🔑 secretos leídos', 'secret.set': '🔑 secreto guardado', 'secret.rm': '🔑 secreto borrado' }
331
+ for (const line of lines.slice(-n)) {
332
+ try {
333
+ const e = JSON.parse(line)
334
+ const when = new Date(e.ts).toLocaleString()
335
+ const what = ICON[e.op] || e.op
336
+ const extra = [e.device, e.label, e.what, e.ns, e.key, e.reason, e.nonce].filter(Boolean).join(' · ')
337
+ console.log(`${when} ${what}${extra ? ' ' + extra : ''}`)
338
+ } catch {}
339
+ }
340
+ }
341
+
342
+ // Secretos de servicios: se cargan aquí (el dueño, en el PC del vault) y los
343
+ // leen los SERVICIOS enrolados con `pair --service <ns>`. Nunca se listan valores.
344
+ async function cmdSecret (rest) {
345
+ const [sub, ns, key, ...valueParts] = rest
346
+ const s = requireDaemon()
347
+ const secretsListFile = path.join(dir, 'secrets-list.json')
348
+ const signalAndWaitList = async () => {
349
+ try { fs.rmSync(secretsListFile, { force: true }) } catch (_) {}
350
+ writeReq('dump-request.json', {}) // de qué perfil son los secretos
351
+ process.kill(s.pid, 'SIGUSR2')
352
+ for (let i = 0; i < 50; i++) { await sleep(100); const d = readJson(secretsListFile, null); if (d?.at) return d }
353
+ console.error('El daemon no respondió.'); process.exit(1)
354
+ }
355
+ if (sub === 'list') {
356
+ const d = await signalAndWaitList()
357
+ const names = d.ns || {}
358
+ const nss = Object.keys(names)
359
+ if (!nss.length) { console.log('No hay secretos guardados. Agrega uno: dotrino-vault secret set <ns> <CLAVE> <valor>'); return }
360
+ for (const n of nss) {
361
+ console.log('%s%s%s (scope vault:secrets:%s)', B, n, Z, n)
362
+ for (const k of names[n]) console.log(' · %s', k)
363
+ }
364
+ return
365
+ }
366
+ if (sub === 'set' || sub === 'rm') {
367
+ const value = valueParts.join(' ')
368
+ if (!ns || !key || (sub === 'set' && !value)) {
369
+ console.error('uso: dotrino-vault secret set <ns> <CLAVE> <valor>\n dotrino-vault secret rm <ns> <CLAVE>'); process.exit(2)
370
+ }
371
+ writeReq('secret-request.json', sub === 'set' ? { op: 'set', ns, key, value } : { op: 'rm', ns, key })
372
+ const d = await signalAndWaitList()
373
+ const ok = sub === 'set' ? (d.ns?.[ns] || []).includes(key) : !(d.ns?.[ns] || []).includes(key)
374
+ if (ok) console.log(sub === 'set' ? 'Secreto guardado: %s/%s' : 'Secreto borrado: %s/%s', ns, key)
375
+ else { console.error('El daemon no aplicó el cambio (revisa: dotrino-vault logs)'); process.exit(1) }
376
+ return
377
+ }
378
+ console.error('uso: dotrino-vault secret {set|rm|list}'); process.exit(2)
379
+ }
380
+
381
+ /**
382
+ * Lee una contraseña del terminal SIN eco. Nunca se pasa como argumento: quedaría
383
+ * en `ps` y en el historial de la shell.
384
+ */
385
+ function askPassword (prompt) {
386
+ return new Promise((resolve, reject) => {
387
+ const stdin = process.stdin
388
+ if (!stdin.isTTY) return reject(new Error('hace falta un terminal para escribir la contraseña'))
389
+ process.stdout.write(prompt)
390
+ stdin.setRawMode(true); stdin.resume(); stdin.setEncoding('utf8')
391
+ let buf = ''
392
+ const done = (err, val) => {
393
+ stdin.setRawMode(false); stdin.pause(); stdin.removeListener('data', onData)
394
+ process.stdout.write('\n')
395
+ err ? reject(err) : resolve(val)
396
+ }
397
+ const onData = (ch) => {
398
+ for (const c of ch) {
399
+ if (c === '\n' || c === '\r' || c === '\u0004') return done(null, buf) // Enter / Ctrl-D
400
+ if (c === '\u0003') return done(new Error('cancelado')) // Ctrl-C
401
+ if (c === '\u007f' || c === '\b') { buf = buf.slice(0, -1); continue } // borrar
402
+ buf += c
403
+ }
404
+ }
405
+ stdin.on('data', onData)
406
+ })
407
+ }
408
+
409
+ /**
410
+ * Manda una orden de perfil/candado al daemon y espera su volcado. La contraseña
411
+ * (si la hay) viaja por un archivo 0600 dentro del dir 0700 del vault, que el
412
+ * daemon borra al leerlo — mismo camino que ya usan los secretos.
413
+ */
414
+ async function profileRequest (op, extra = {}) {
415
+ const s = requireDaemon()
416
+ try { fs.rmSync(profilesFile, { force: true }) } catch (_) {}
417
+ writeReq('profile-request.json', { op, ...extra })
418
+ process.kill(s.pid, 'SIGUSR2')
419
+ for (let i = 0; i < 100; i++) {
420
+ await sleep(100)
421
+ const d = readJson(profilesFile, null)
422
+ if (d?.at) return d
423
+ }
424
+ console.error('El daemon no respondió.'); process.exit(1)
425
+ }
426
+
427
+ function reportProfiles (d) {
428
+ if (d.error) { console.error('%s', d.error); process.exit(1) }
429
+ if (d.done) console.log('%s', d.done)
430
+ return d
431
+ }
432
+
433
+ async function cmdProfile (rest) {
434
+ const [sub, ...args] = rest
435
+ const name = args.join(' ').trim()
436
+ switch (sub || 'ls') {
437
+ case 'ls': {
438
+ const d = await profileRequest('list')
439
+ console.log('Perfiles del vault: %d', d.profiles.length)
440
+ for (const p of d.profiles) console.log(' %s %s', p.current ? '*' : ' ', describeProfile(p))
441
+ console.log('\nEl * es el perfil activo (el destino por defecto). Todos atienden a sus dispositivos a la vez.')
442
+ console.log('Apunta un comando a otro: dotrino-vault <comando> --profile <id|nombre>')
443
+ return
444
+ }
445
+ case 'add': {
446
+ if (!name) { console.error('uso: dotrino-vault profile add <nombre>'); process.exit(2) }
447
+ reportProfiles(await profileRequest('add', { name }))
448
+ console.log('Conecta un dispositivo a este perfil: dotrino-vault pair --profile "%s"', name)
449
+ return
450
+ }
451
+ case 'rename': {
452
+ if (!name) { console.error('uso: dotrino-vault profile rename <nombre nuevo> (usa --profile para elegir cuál)'); process.exit(2) }
453
+ reportProfiles(await profileRequest('rename', { name }))
454
+ return
455
+ }
456
+ case 'use': {
457
+ const ref = name || PROFILE
458
+ if (!ref) { console.error('uso: dotrino-vault profile use <id|nombre>'); process.exit(2) }
459
+ reportProfiles(await profileRequest('use', { profile: ref }))
460
+ return
461
+ }
462
+ case 'rm': {
463
+ const ref = name || PROFILE
464
+ if (!ref) { console.error('uso: dotrino-vault profile rm <id|nombre>'); process.exit(2) }
465
+ const d = await profileRequest('list')
466
+ const p = d.profiles.find((x) => x.id === ref || (x.name || '').toLowerCase() === ref.toLowerCase())
467
+ if (!p) { console.error('el perfil no existe: %s', ref); process.exit(1) }
468
+ console.log('\n%s%sEsto BORRA la identidad del perfil "%s" y todos sus datos.%s', R, B, p.name || p.id, Z)
469
+ console.log('%s Es irreversible: se pierde su clave, y sus dispositivos dejan de funcionar.%s', R, Z)
470
+ const typed = await askText(`\nEscribe el nombre del perfil para confirmar (${p.name || p.id}): `)
471
+ if (typed.trim() !== (p.name || p.id)) { console.log('Cancelado (no coincide).'); return }
472
+ reportProfiles(await profileRequest('rm', { profile: p.id }))
473
+ return
474
+ }
475
+ case 'password': {
476
+ const action = args[0]
477
+ if (action === 'rm') { reportProfiles(await profileRequest('password-rm')); return }
478
+ if (action && action !== 'set') { console.error('uso: dotrino-vault profile password [set|rm]'); process.exit(2) }
479
+ console.log('La contraseña solo se pide para EDITAR el perfil. Tus dispositivos siguen')
480
+ console.log('funcionando (firmando y leyendo) aunque el perfil esté bloqueado.')
481
+ const pwd = await askPassword('\nContraseña nueva (mínimo 4): ')
482
+ const again = await askPassword('Repítela: ')
483
+ if (pwd !== again) { console.error('Las contraseñas no coinciden.'); process.exit(1) }
484
+ reportProfiles(await profileRequest('password-set', { password: pwd }))
485
+ return
486
+ }
487
+ default:
488
+ console.error('uso: dotrino-vault profile {ls|add|rename|use|rm|password}'); process.exit(2)
489
+ }
490
+ }
491
+
492
+ /** Lee una línea del terminal (con eco): confirmaciones. */
493
+ function askText (prompt) {
494
+ return new Promise((resolve) => {
495
+ process.stdout.write(prompt)
496
+ process.stdin.resume(); process.stdin.setEncoding('utf8')
497
+ process.stdin.once('data', (d) => { process.stdin.pause(); resolve(String(d).replace(/\n$/, '')) })
498
+ })
499
+ }
500
+
501
+ async function cmdUnlock () {
502
+ const pwd = await askPassword('Contraseña del perfil: ')
503
+ reportProfiles(await profileRequest('unlock', { password: pwd }))
504
+ console.log('Ya puedes editar el perfil. Se vuelve a bloquear al reiniciar el servicio (o con: dotrino-vault lock).')
505
+ }
506
+
507
+ async function cmdLock () {
508
+ reportProfiles(await profileRequest('lock'))
509
+ }
510
+
511
+ function cmdLogs () {
512
+ try { process.stdout.write(execFileSync('journalctl', ['--user', '-u', 'dotrino-vault', '-n', '40', '--no-pager'], { encoding: 'utf8' })) }
513
+ catch { console.error('No se pudieron leer los logs. Prueba: journalctl --user -u dotrino-vault -f') }
514
+ }
515
+
516
+ // Import dinámico: así `dotrino-vault status` (el caso común) no carga la TUI.
517
+ async function cmdTui () {
518
+ if (!process.stdout.isTTY) { console.error('la TUI necesita un terminal interactivo (TTY).'); process.exit(2) }
519
+ const { runTui } = await import('./tui/app.js')
520
+ await runTui()
521
+ }
522
+
523
+ function help () {
524
+ console.log(`dotrino-vault — control del certificador personal
525
+
526
+ tui interfaz de terminal a pantalla completa (bóvedas, pares, secretos)
527
+ status estado del servicio + fingerprint
528
+ pair [--save <f>] inicia un emparejamiento (QR + espera); --save escribe la invitación (.dpair)
529
+ pair --service <ns> empareja un SERVICIO (proxy, geo…) con acceso SOLO a sus secretos
530
+ secret set <ns> <CLAVE> <valor> guarda un secreto para el servicio <ns>
531
+ secret rm <ns> <CLAVE> borra un secreto
532
+ secret list lista nombres de secretos (nunca valores)
533
+ pending muestra el dispositivo pendiente + su código a comparar
534
+ approve <código> aprueba el dispositivo tipeando el código que MUESTRA (el vault no lo sabe)
535
+ reject <deviceId> rechaza un dispositivo pendiente
536
+ devices lista dispositivos enrolados / revocados
537
+ members el acta del perfil: quién es tuyo y qué puede hacer
538
+ caps <ID> ±permiso cambia permisos (+firma -guarda …)
539
+ revoke <nonce> revoca un dispositivo (le ordena autoborrarse)
540
+ activity [n] bitácora de seguridad: firmas, renovaciones, enrolados, rechazos
541
+ logs últimos logs del servicio
542
+ version muestra la versión instalada
543
+
544
+ Perfiles (varias identidades tuyas en el mismo PC; todas atienden a la vez):
545
+ profile ls lista los perfiles (* = el activo, el destino por defecto)
546
+ profile add <nombre> crea un perfil (identidad nueva, vacía)
547
+ profile use <id|nombre> elige el perfil activo
548
+ profile rename <nombre> renombra un perfil
549
+ profile rm <id|nombre> BORRA un perfil y su identidad (irreversible)
550
+ --profile <id|nombre> apunta CUALQUIER comando a otro perfil
551
+ (p.ej. dotrino-vault pair --profile trabajo)
552
+
553
+ Contraseña del perfil (opcional; solo se pide para EDITAR el perfil — tus
554
+ dispositivos siguen firmando y leyendo aunque esté bloqueado):
555
+ profile password [set] pone o cambia la contraseña
556
+ profile password rm la quita
557
+ unlock desbloquea para poder editar
558
+ lock vuelve a bloquear (también al reiniciar el servicio)
559
+
560
+ Arrancar y parar, según dónde corra:
561
+ Linux (servicio) systemctl --user {start,stop,restart} dotrino-vault
562
+ journalctl --user -u dotrino-vault -f
563
+ Windows / macOS dotrino-vaultd (o sin instalar nada: npx -y @dotrino/vaultd)
564
+ Docker docker {start,stop,restart} dotrino-vault · docker logs -f dotrino-vault
565
+ (el CLI, dentro: docker exec -it dotrino-vault dotrino-vault status)`)
566
+ }
567
+
568
+ export async function runCtl (argv) {
569
+ const [cmd, ...rest] = takeProfileFlag(argv)
570
+ switch (cmd) {
571
+ case 'tui': return cmdTui()
572
+ case 'profile': return cmdProfile(rest)
573
+ case 'unlock': return cmdUnlock()
574
+ case 'lock': return cmdLock()
575
+ case 'status': return cmdStatus()
576
+ case 'pair': return cmdPair(rest)
577
+ case 'pending': return cmdPending()
578
+ case 'approve': return cmdApprove(rest[0])
579
+ case 'reject': return cmdReject(rest[0])
580
+ case 'devices': return cmdDevices()
581
+ case 'members': return cmdMembers()
582
+ case 'caps': return cmdCaps(rest)
583
+ case 'revoke': return cmdRevoke(rest[0])
584
+ case 'secret': return cmdSecret(rest)
585
+ case 'activity': return cmdActivity(Number(rest[0]) || 30)
586
+ case 'logs': return cmdLogs()
587
+ case 'version':
588
+ case '--version':
589
+ case '-v': console.log('dotrino-vault ' + VERSION); return
590
+ case undefined:
591
+ case 'help':
592
+ case '--help':
593
+ case '-h': return help()
594
+ default:
595
+ console.error('comando desconocido: %s', cmd); help(); process.exit(2)
596
+ }
597
+ }