@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.
@@ -0,0 +1,296 @@
1
+ /**
2
+ * vaultControl.js — API programática de control del daemon del vault.
3
+ *
4
+ * Es la MISMA vía que usa la CLI (`ctl.js`): NO abre la identidad ni el proxy, no
5
+ * toca la maestra. Le da órdenes al daemon (único custodio) escribiendo peticiones
6
+ * en el dir de datos (0600) y disparando señales:
7
+ *
8
+ * SIGUSR1 → inicia un emparejamiento (vuelca pair.json)
9
+ * SIGUSR2 → consume approve/reject/revoke/secret/profile/dump-request y vuelca
10
+ * devices.json / secrets-list.json / profiles-list.json
11
+ *
12
+ * La usa la TUI (`src/tui/`). Se mantiene como capa fina y sin estado para que la
13
+ * TUI y la CLI no dupliquen el protocolo: si el daemon cambia el contrato de
14
+ * archivos/señales, se toca aquí y en `daemon.js`, en ningún otro lado.
15
+ *
16
+ * MULTI-PERFIL: cada función recibe opcionalmente `profile` (id o nombre). Sin él,
17
+ * el daemon apunta al perfil ACTIVO.
18
+ */
19
+ import fs from 'node:fs'
20
+ import path from 'node:path'
21
+ import { pubkeyId } from '@dotrino/identity/capabilities'
22
+ import { dataDir, readJson } from './paths.js'
23
+
24
+ const dir = dataDir()
25
+
26
+ // Nombres de archivos del contrato con el daemon (ver daemon.js). Único lugar.
27
+ const F = {
28
+ state: 'state.json',
29
+ pair: 'pair.json',
30
+ pending: 'pending-enroll.json',
31
+ devices: 'devices.json',
32
+ profilesList: 'profiles-list.json',
33
+ secretsList: 'secrets-list.json',
34
+ // peticiones (las escribe el control; el daemon las consume y borra)
35
+ pairReq: 'pair-request.json',
36
+ approveReq: 'approve-request.json',
37
+ rejectReq: 'reject-request.json',
38
+ revokeReq: 'revoke-request.json',
39
+ secretReq: 'secret-request.json',
40
+ profileReq: 'profile-request.json',
41
+ dumpReq: 'dump-request.json'
42
+ }
43
+
44
+ const p = (name) => path.join(dir, name)
45
+ const read = (name, fb = null) => readJson(p(name), fb)
46
+ const rm = (name) => { try { fs.rmSync(p(name), { force: true }) } catch (_) {} }
47
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
48
+
49
+ export function vaultDir () { return dir }
50
+
51
+ export function readState () { return read(F.state, null) }
52
+
53
+ /** ¿El pid está vivo? (kill 0 no envía señal, solo comprueba permiso/existencia). */
54
+ export function pidAlive (pid) { try { return !!pid && (process.kill(pid, 0) || true) } catch (_) { return false } }
55
+
56
+ /** ¿Hay un daemon corriendo? (state.json presente + pid vivo). */
57
+ export function daemonAlive () { const s = readState(); return !!(s && pidAlive(s.pid)) }
58
+
59
+ /** deviceId legible (8 hex agrupados AB12-CD34) a partir del pubkey `sub`. */
60
+ export async function deviceIdOf (sub) {
61
+ if (!sub) return '????-????'
62
+ const id = (await pubkeyId(sub)).slice(0, 8).toUpperCase()
63
+ return id.slice(0, 4) + '-' + id.slice(4, 8)
64
+ }
65
+
66
+ class DaemonDownError extends Error {
67
+ constructor () { super('el daemon del vault no está corriendo'); this.code = 'DAEMON_DOWN' }
68
+ }
69
+
70
+ /**
71
+ * Exige el daemon vivo ANTES de escribir cualquier petición. Es clave para las
72
+ * peticiones que llevan secretos (contraseña de perfil, valor de secreto): si el
73
+ * daemon está caído no habría quien las consuma ni borre, y quedarían en claro en
74
+ * disco. Mismo criterio que `requireDaemon()` de la CLI.
75
+ */
76
+ function requireAlive () {
77
+ const s = readState()
78
+ if (!s || !pidAlive(s.pid)) throw new DaemonDownError()
79
+ }
80
+
81
+ function writeReq (name, obj, profile) {
82
+ const body = { ...obj, at: Date.now() }
83
+ if (profile) body.profile = profile
84
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 })
85
+ fs.writeFileSync(p(name), JSON.stringify(body), { mode: 0o600 })
86
+ // `mode` de writeFileSync solo aplica al CREAR: re-chmod por si el archivo ya
87
+ // existía con permisos más laxos (defensa en profundidad; el dir ya es 0700).
88
+ try { fs.chmodSync(p(name), 0o600) } catch (_) {}
89
+ }
90
+
91
+ function signal (sig) {
92
+ const s = readState()
93
+ if (!s || !pidAlive(s.pid)) throw new DaemonDownError()
94
+ process.kill(s.pid, sig)
95
+ }
96
+
97
+ /**
98
+ * Dispara la señal y, si falla (daemon murió entre el chequeo y ahora), BORRA las
99
+ * peticiones que quedaron escritas para no dejar secretos en disco.
100
+ */
101
+ function signalOrCleanup (sig, reqFiles) {
102
+ try { signal(sig) } catch (e) { for (const f of reqFiles) rm(f); throw e }
103
+ }
104
+
105
+ /** Espera a que reaparezca un archivo de respuesta (con `.at`) tras borrarlo. */
106
+ async function waitFor (name, { tries = 60, interval = 100 } = {}) {
107
+ for (let i = 0; i < tries; i++) {
108
+ await sleep(interval)
109
+ const d = read(name, null)
110
+ if (d && d.at) return d
111
+ }
112
+ return null
113
+ }
114
+
115
+ // ---------------------------------------------------------------------------
116
+ // Perfiles / bóvedas + candado
117
+ // ---------------------------------------------------------------------------
118
+
119
+ /**
120
+ * Manda una orden de perfil y espera el volcado. `profile` (id/nombre) es tanto el
121
+ * OPERANDO (para use/rm/rename) como el DESTINO (para unlock/lock/password). La
122
+ * contraseña viaja en un archivo 0600 que el daemon borra al leerlo.
123
+ */
124
+ async function profileOp (op, { profile, name, password } = {}) {
125
+ requireAlive() // nunca escribir la contraseña si no hay quien la consuma
126
+ rm(F.profilesList)
127
+ const extra = {}
128
+ if (name != null) extra.name = name
129
+ if (password != null) extra.password = password
130
+ writeReq(F.profileReq, { op, ...extra }, profile)
131
+ signalOrCleanup('SIGUSR2', [F.profileReq])
132
+ const d = await waitFor(F.profilesList)
133
+ if (!d) throw new Error('el daemon no respondió')
134
+ if (d.error) throw new Error(d.error)
135
+ return d // { profiles:[{id,name,protected,locked,current,fingerprint,iss,createdAt}], current, done? }
136
+ }
137
+
138
+ export const listProfiles = () => profileOp('list')
139
+ export const addProfile = (name) => profileOp('add', { name })
140
+ export const useProfile = (profile) => profileOp('use', { profile })
141
+ export const renameProfile = (profile, name) => profileOp('rename', { profile, name })
142
+ export const removeProfile = (profile) => profileOp('rm', { profile })
143
+ export const unlockProfile = (profile, password) => profileOp('unlock', { profile, password })
144
+ export const lockProfile = (profile) => profileOp('lock', { profile })
145
+ export const setProfilePassword = (profile, password) => profileOp('password-set', { profile, password })
146
+ export const removeProfilePassword = (profile) => profileOp('password-rm', { profile })
147
+
148
+ // ---------------------------------------------------------------------------
149
+ // Volcado de dispositivos + secretos de un perfil
150
+ // ---------------------------------------------------------------------------
151
+
152
+ /**
153
+ * Fuerza el volcado del daemon (devices.json + secrets-list.json + profiles-list.json)
154
+ * para `profile` (o el activo) y devuelve las tres cosas ya parseadas.
155
+ */
156
+ export async function snapshot (profile) {
157
+ requireAlive()
158
+ rm(F.devices); rm(F.secretsList); rm(F.profilesList)
159
+ writeReq(F.dumpReq, {}, profile)
160
+ signalOrCleanup('SIGUSR2', [F.dumpReq])
161
+ const [devices, secrets, profiles] = await Promise.all([
162
+ waitFor(F.devices), waitFor(F.secretsList), waitFor(F.profilesList)
163
+ ])
164
+ return { devices, secrets, profiles }
165
+ }
166
+
167
+ /**
168
+ * Dispositivos enrolados/revocados del perfil, con su deviceId ya calculado.
169
+ * `issued` viene de identity.listDelegations(); el deviceId se deriva del `sub`.
170
+ */
171
+ export async function listDevices (profile) {
172
+ const { devices } = await snapshot(profile)
173
+ if (!devices) throw new Error('el daemon no respondió')
174
+ const issued = devices.issued || devices.active || devices.delegations || []
175
+ const withIds = await Promise.all(issued.map(async (d) => ({
176
+ ...d, deviceId: d.sub ? await deviceIdOf(d.sub) : '????-????'
177
+ })))
178
+ return { issued: withIds, revoked: devices.revoked || [], profile: devices.profile || null }
179
+ }
180
+
181
+ /** Revoca un dispositivo por su `nonce` (le ordena autoborrarse) y revuelca. */
182
+ export async function revokeDevice (nonce, profile) {
183
+ requireAlive()
184
+ writeReq(F.revokeReq, { nonce }, profile)
185
+ signalOrCleanup('SIGUSR2', [F.revokeReq])
186
+ await sleep(300)
187
+ return listDevices(profile)
188
+ }
189
+
190
+ // ---------------------------------------------------------------------------
191
+ // Secretos: scopes (namespaces) y variables (claves)
192
+ // ---------------------------------------------------------------------------
193
+
194
+ /** Scopes→[claves] del perfil (NUNCA los valores; el daemon no los expone). */
195
+ export async function listSecrets (profile) {
196
+ const { secrets } = await snapshot(profile)
197
+ if (!secrets) throw new Error('el daemon no respondió')
198
+ return secrets.ns || {}
199
+ }
200
+
201
+ /** Guarda/actualiza una variable. ns: [a-z0-9-]{1,32}. clave: [A-Z0-9_]{1,64}. */
202
+ export async function setSecret (ns, key, value, profile) {
203
+ requireAlive() // el VALOR es secreto: no escribirlo si el daemon está caído
204
+ rm(F.secretsList)
205
+ writeReq(F.secretReq, { op: 'set', ns, key, value }, profile)
206
+ writeReq(F.dumpReq, {}, profile)
207
+ signalOrCleanup('SIGUSR2', [F.secretReq, F.dumpReq])
208
+ const d = await waitFor(F.secretsList)
209
+ if (!d) throw new Error('el daemon no respondió')
210
+ if (!(d.ns?.[ns] || []).includes(key)) throw new Error('el daemon no aplicó el cambio (revisa los logs del servicio)')
211
+ return d.ns
212
+ }
213
+
214
+ /** Borra una variable. Si era la última del scope, el scope desaparece. */
215
+ export async function deleteSecret (ns, key, profile) {
216
+ requireAlive()
217
+ rm(F.secretsList)
218
+ writeReq(F.secretReq, { op: 'rm', ns, key }, profile)
219
+ writeReq(F.dumpReq, {}, profile)
220
+ signalOrCleanup('SIGUSR2', [F.secretReq, F.dumpReq])
221
+ const d = await waitFor(F.secretsList)
222
+ if (!d) throw new Error('el daemon no respondió')
223
+ if ((d.ns?.[ns] || []).includes(key)) throw new Error('el daemon no borró la variable (revisa los logs del servicio)')
224
+ return d.ns
225
+ }
226
+
227
+ /** Borra un scope entero (todas sus variables, una por una). */
228
+ export async function deleteScope (ns, profile) {
229
+ const all = await listSecrets(profile)
230
+ const keys = all[ns] || []
231
+ let ns2 = all
232
+ for (const k of keys) ns2 = await deleteSecret(ns, k, profile)
233
+ return ns2
234
+ }
235
+
236
+ // ---------------------------------------------------------------------------
237
+ // Emparejamiento de dispositivos (pares)
238
+ // ---------------------------------------------------------------------------
239
+
240
+ const PROFILE_URL = 'https://vault.dotrino.com/dispositivos#vault='
241
+
242
+ /** Codifica el QR crudo como URL de la consola de vault.dotrino.com (base64url del payload). */
243
+ export function pairUrl (qr) {
244
+ const payload = JSON.stringify(qr)
245
+ const b64 = Buffer.from(payload, 'utf8').toString('base64')
246
+ .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
247
+ return { url: PROFILE_URL + b64, payload }
248
+ }
249
+
250
+ /**
251
+ * Inicia un emparejamiento. `service` (opcional) enrola un SERVICIO con acceso
252
+ * SOLO a `vault:secrets:<service>` (no firma por ti ni lee tus datos).
253
+ * Devuelve { qr, expiresAt, url, payload }.
254
+ */
255
+ export async function startPairing ({ profile, service } = {}) {
256
+ requireAlive()
257
+ rm(F.pair); rm(F.pending)
258
+ writeReq(F.pairReq, service ? { service } : {}, profile)
259
+ signalOrCleanup('SIGUSR1', [F.pairReq])
260
+ for (let i = 0; i < 50; i++) {
261
+ await sleep(100)
262
+ const pr = read(F.pair, null)
263
+ if (pr?.expiresAt > Date.now()) {
264
+ const { url, payload } = pairUrl(pr.qr)
265
+ return { qr: pr.qr, expiresAt: pr.expiresAt, url, payload }
266
+ }
267
+ }
268
+ throw new Error('el daemon no inició el emparejamiento')
269
+ }
270
+
271
+ /** Dispositivo pendiente de aprobar (el que se conectó con el QR), o null. */
272
+ export function pendingEnroll () {
273
+ const pe = read(F.pending, null)
274
+ return pe?.deviceId ? pe : null
275
+ }
276
+
277
+ /**
278
+ * Aprueba el dispositivo pendiente escribiendo el CÓDIGO que MUESTRA el dispositivo
279
+ * (el vault no lo conoce). Firma el cert y se lo manda. Devuelve la lista de
280
+ * dispositivos ya actualizada.
281
+ */
282
+ export async function approvePending (code, profile) {
283
+ requireAlive()
284
+ writeReq(F.approveReq, { code: String(code) }, profile)
285
+ signalOrCleanup('SIGUSR2', [F.approveReq])
286
+ await sleep(400)
287
+ return listDevices(profile)
288
+ }
289
+
290
+ /** Rechaza el dispositivo pendiente. */
291
+ export async function rejectPending (deviceId, profile) {
292
+ requireAlive()
293
+ writeReq(F.rejectReq, { deviceId }, profile)
294
+ signalOrCleanup('SIGUSR2', [F.rejectReq])
295
+ await sleep(200)
296
+ }