@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/README.md +301 -0
- package/bin/dotrino-vault-tui.js +28 -0
- package/bin/dotrino-vault.js +23 -0
- package/bin/dotrino-vaultd.js +29 -0
- package/bin/sea-entry.js +29 -0
- package/lib/README.md +139 -0
- package/lib/src/config.js +26 -0
- package/lib/src/enroll.js +293 -0
- package/lib/src/env.js +95 -0
- package/lib/src/index.js +166 -0
- package/lib/src/protocol.js +53 -0
- package/lib/src/sealed.js +84 -0
- package/lib/src/service.js +258 -0
- package/package.json +41 -0
- package/src/atrest.js +0 -0
- package/src/client.js +149 -0
- package/src/ctl.js +597 -0
- package/src/daemon.js +217 -0
- package/src/manager.js +88 -0
- package/src/node-globals.js +37 -0
- package/src/paths.js +47 -0
- package/src/profiles.js +214 -0
- package/src/protocol.js +6 -0
- package/src/qr.js +61 -0
- package/src/secretsStore.js +61 -0
- package/src/store.js +64 -0
- package/src/threadStore.js +111 -0
- package/src/transport.js +64 -0
- package/src/tui/app.js +722 -0
- package/src/tui/term.js +278 -0
- package/src/vault.js +303 -0
- package/src/vaultControl.js +296 -0
- package/vendor/qrcode-generator.cjs +2297 -0
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
|
+
}
|