@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/vaultControl.js
CHANGED
|
@@ -34,6 +34,7 @@ const F = {
|
|
|
34
34
|
secretsList: 'secrets-list.json',
|
|
35
35
|
me: 'me.json',
|
|
36
36
|
acta: 'acta.json',
|
|
37
|
+
approve: 'approve.json',
|
|
37
38
|
// peticiones (las escribe el control; el daemon las consume y borra)
|
|
38
39
|
pairReq: 'pair-request.json',
|
|
39
40
|
approveReq: 'approve-request.json',
|
|
@@ -91,14 +92,41 @@ function requireAlive () {
|
|
|
91
92
|
if (!s || !pidAlive(s.pid)) throw new DaemonDownError()
|
|
92
93
|
}
|
|
93
94
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
95
|
+
/** Contador de peticiones de ESTE proceso: junto al pid, identifica cada una. */
|
|
96
|
+
let reqSeq = 0
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Escribe una petición y devuelve su `id`.
|
|
100
|
+
*
|
|
101
|
+
* ESPERA A QUE EL DAEMON SE HAYA LLEVADO LA ANTERIOR. Cada tipo de petición es UN
|
|
102
|
+
* archivo con nombre fijo, así que escribir la siguiente antes de que se consuma la
|
|
103
|
+
* primera la BORRABA sin que nadie se enterara. Es exactamente lo que le pasaba al
|
|
104
|
+
* candado: la TUI manda `unlock` y, pegado, el `list` para repintar; si el daemon
|
|
105
|
+
* estaba ocupado, el `list` se comía el `unlock` —contraseña incluida— y la bóveda
|
|
106
|
+
* seguía cerrada sin un solo error por ninguna parte. Se vuelve a preguntar la
|
|
107
|
+
* contraseña, y otra vez lo mismo.
|
|
108
|
+
*/
|
|
109
|
+
async function writeReq (name, obj, profile) {
|
|
97
110
|
fs.mkdirSync(dir, { recursive: true, mode: 0o700 })
|
|
98
|
-
|
|
111
|
+
// Hasta 3 s por la anterior; pasado eso, se pisa (el daemon no la va a atender ya, y
|
|
112
|
+
// dejar a quien pide esperando para siempre es peor).
|
|
113
|
+
for (let i = 0; i < 30 && fs.existsSync(p(name)); i++) await sleep(100)
|
|
114
|
+
const id = `${process.pid}-${++reqSeq}`
|
|
115
|
+
const body = { ...obj, id, at: Date.now() }
|
|
116
|
+
if (profile) body.profile = profile
|
|
117
|
+
// ATÓMICO (escribir aparte y renombrar), y no por manía: el daemon vigila esta carpeta
|
|
118
|
+
// con `fs.watch`, que avisa al CREAR el archivo — no al terminar de escribirlo. Escrito
|
|
119
|
+
// en el sitio, el daemon podía leerlo a medias, y como una petición sin `id` no es de
|
|
120
|
+
// nadie, se perdía: quien la había pedido esperaba seis segundos y leía «el daemon no
|
|
121
|
+
// respondió», con el daemon sano. `rename` es atómico dentro del mismo sistema de
|
|
122
|
+
// archivos, así que el vigilante o no ve nada, o ve la petición ENTERA.
|
|
123
|
+
const tmp = p(name) + '.tmp'
|
|
124
|
+
fs.writeFileSync(tmp, JSON.stringify(body), { mode: 0o600 })
|
|
125
|
+
fs.renameSync(tmp, p(name))
|
|
99
126
|
// `mode` de writeFileSync solo aplica al CREAR: re-chmod por si el archivo ya
|
|
100
127
|
// existía con permisos más laxos (defensa en profundidad; el dir ya es 0700).
|
|
101
128
|
try { fs.chmodSync(p(name), 0o600) } catch (_) {}
|
|
129
|
+
return id
|
|
102
130
|
}
|
|
103
131
|
|
|
104
132
|
/**
|
|
@@ -126,12 +154,39 @@ function signalOrCleanup (sig, reqFiles) {
|
|
|
126
154
|
try { signal(sig) } catch (e) { for (const f of reqFiles) rm(f); throw e }
|
|
127
155
|
}
|
|
128
156
|
|
|
129
|
-
/**
|
|
130
|
-
|
|
157
|
+
/**
|
|
158
|
+
* EL CANDADO, del lado de quien pregunta. El daemon contesta los volcados de un perfil
|
|
159
|
+
* bloqueado con `locked: true` y sin contenido; aquí eso se convierte en un error con
|
|
160
|
+
* código para que la CLI y la TUI digan lo mismo: hay que desbloquearlo, y no es que la
|
|
161
|
+
* bóveda esté vacía o rota.
|
|
162
|
+
*/
|
|
163
|
+
function assertOpen (d) {
|
|
164
|
+
if (d?.locked) throw coded('profile locked: unlock it with your password (dotrino-vault unlock)', 'PROFILE_LOCKED')
|
|
165
|
+
return d
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Espera a que reaparezca un archivo de respuesta (con `.at`) tras borrarlo.
|
|
170
|
+
*
|
|
171
|
+
* Con `req`, solo vale LA RESPUESTA A ESA PETICIÓN (el daemon devuelve el `id` en
|
|
172
|
+
* `req`). El daemon vuelca la lista de perfiles también por su cuenta —cada repaso, y
|
|
173
|
+
* al atender cualquier otra cosa—, así que sin esto se podía tomar por respuesta un
|
|
174
|
+
* volcado de hace un instante, escrito ANTES de que la petición se atendiera: el
|
|
175
|
+
* `unlock` contestaba con la foto de cuando aún estaba cerrada.
|
|
176
|
+
*
|
|
177
|
+
* Un daemon viejo no devuelve `req`: entonces vale cualquier volcado posterior a la
|
|
178
|
+
* petición (que es lo que se hacía siempre, y sigue siendo mejor que nada).
|
|
179
|
+
*/
|
|
180
|
+
async function waitFor (name, { tries = 60, interval = 100, req = null, since = 0 } = {}) {
|
|
131
181
|
for (let i = 0; i < tries; i++) {
|
|
132
182
|
await sleep(interval)
|
|
133
183
|
const d = read(name, null)
|
|
134
|
-
if (d
|
|
184
|
+
if (!d || !d.at || d.at < since) continue
|
|
185
|
+
if (!req || d.req === req) return d
|
|
186
|
+
// Un daemon viejo NO trae el campo. Que venga con `null` significa lo contrario: el
|
|
187
|
+
// daemon sabe marcarlas y este volcado no contesta a nadie (es uno de los suyos), así
|
|
188
|
+
// que se sigue esperando el que sí lleva nuestro id.
|
|
189
|
+
if (!('req' in d)) return d
|
|
135
190
|
}
|
|
136
191
|
return null
|
|
137
192
|
}
|
|
@@ -151,11 +206,14 @@ async function profileOp (op, { profile, name, password } = {}) {
|
|
|
151
206
|
const extra = {}
|
|
152
207
|
if (name != null) extra.name = name
|
|
153
208
|
if (password != null) extra.password = password
|
|
154
|
-
|
|
209
|
+
const since = Date.now()
|
|
210
|
+
const id = await writeReq(F.profileReq, { op, ...extra }, profile)
|
|
155
211
|
signalOrCleanup('SIGUSR2', [F.profileReq])
|
|
156
|
-
const d = await waitFor(F.profilesList)
|
|
212
|
+
const d = await waitFor(F.profilesList, { req: id, since })
|
|
157
213
|
if (!d) throw coded('the daemon did not reply', 'NO_REPLY')
|
|
158
|
-
|
|
214
|
+
// p.ej. MASTER_WITH_MEMBERS (freno D12) o WRONG_PASSWORD. Los datos del error viajan
|
|
215
|
+
// pegados (cuánto hay que esperar, cuántos intentos van): quien lo pinta los necesita.
|
|
216
|
+
if (d.error) throw Object.assign(coded(d.error, d.code), { waitSec: d.waitSec, tries: d.tries })
|
|
159
217
|
return d // { profiles:[{id,name,protected,locked,current,fingerprint,iss,createdAt}], current, done? }
|
|
160
218
|
}
|
|
161
219
|
|
|
@@ -166,8 +224,12 @@ export const renameProfile = (profile, name) => profileOp('rename', { profile, n
|
|
|
166
224
|
export const removeProfile = (profile) => profileOp('rm', { profile })
|
|
167
225
|
export const unlockProfile = (profile, password) => profileOp('unlock', { profile, password })
|
|
168
226
|
export const lockProfile = (profile) => profileOp('lock', { profile })
|
|
169
|
-
|
|
170
|
-
|
|
227
|
+
// `current`: la contraseña que YA tenía el perfil. Hace falta para abrir la copia
|
|
228
|
+
// maestra de los secretos y volver a cerrarla con la nueva — sin ese paso, cambiar la
|
|
229
|
+
// contraseña dejaría las variables privadas ilegibles.
|
|
230
|
+
export const setProfilePassword = (profile, password, current) => profileOp('password-set', { profile, password, ...(current ? { current } : {}) })
|
|
231
|
+
// Quitarla también la pide: se re-sella a la llave de la máquina antes de que se vaya.
|
|
232
|
+
export const removeProfilePassword = (profile, password) => profileOp('password-rm', { profile, password })
|
|
171
233
|
|
|
172
234
|
// ---------------------------------------------------------------------------
|
|
173
235
|
// Volcado de dispositivos + secretos de un perfil
|
|
@@ -179,17 +241,30 @@ export const removeProfilePassword = (profile) => profileOp('password-rm', { pro
|
|
|
179
241
|
*/
|
|
180
242
|
export async function snapshot (profile) {
|
|
181
243
|
requireAlive()
|
|
182
|
-
rm(F.devices); rm(F.secretsList); rm(F.
|
|
183
|
-
|
|
244
|
+
rm(F.devices); rm(F.secretsList); rm(F.acta)
|
|
245
|
+
const since = Date.now()
|
|
246
|
+
const id = await writeReq(F.dumpReq, {}, profile)
|
|
184
247
|
signalOrCleanup('SIGUSR2', [F.dumpReq])
|
|
185
248
|
// El ACTA entra en el volcado normal: es la lista de dispositivos de verdad, y las
|
|
186
249
|
// delegaciones son su reflejo. Sin ella, un miembro sin certificados (revocado a medias,
|
|
187
250
|
// o con el papel caducado) no salía en ninguna pantalla del PC — invisible y, por lo
|
|
188
251
|
// tanto, imposible de quitar desde aquí.
|
|
189
|
-
const [devices, secrets,
|
|
190
|
-
waitFor(F.devices), waitFor(F.secretsList
|
|
252
|
+
const [devices, secrets, record] = await Promise.all([
|
|
253
|
+
waitFor(F.devices, { req: id, since }), waitFor(F.secretsList, { req: id, since }),
|
|
254
|
+
waitFor(F.acta, { req: id, since })
|
|
191
255
|
])
|
|
192
|
-
|
|
256
|
+
// El volcado de variables lleva el valor de las PÚBLICAS, así que se borra en cuanto se
|
|
257
|
+
// tiene: en la memoria de quien lo pidió, no esperando en el disco a que copien la
|
|
258
|
+
// carpeta (ahí es donde el cifrado en reposo dejaría de servir de nada).
|
|
259
|
+
rm(F.secretsList)
|
|
260
|
+
assertOpen(devices); assertOpen(secrets); assertOpen(record)
|
|
261
|
+
// LA LISTA DE BÓVEDAS NO SE ESPERA AQUÍ. `profiles-list.json` es la respuesta a una
|
|
262
|
+
// petición de PERFIL (`dumpProfiles`), y el daemon dejó de volcarlo por su cuenta —
|
|
263
|
+
// volcarlo sin que nadie preguntara se llevaba por delante las respuestas de verdad.
|
|
264
|
+
// Seguir esperándolo aquí era esperar seis segundos, los de rendirse, a un archivo que
|
|
265
|
+
// ya no iba a llegar: CADA refresco de la TUI costaba eso, con el daemon contestando lo
|
|
266
|
+
// suyo en 100 ms. Quien necesita la lista llama a `listProfiles()`, que sí la pide.
|
|
267
|
+
return { devices, secrets, record, profiles: read(F.profilesList, null) }
|
|
193
268
|
}
|
|
194
269
|
|
|
195
270
|
/**
|
|
@@ -197,7 +272,7 @@ export async function snapshot (profile) {
|
|
|
197
272
|
* `issued` viene de identity.listDelegations(); el deviceId se deriva del `sub`.
|
|
198
273
|
*/
|
|
199
274
|
export async function listDevices (profile) {
|
|
200
|
-
const { devices,
|
|
275
|
+
const { devices, record } = await snapshot(profile)
|
|
201
276
|
if (!devices) throw coded('the daemon did not reply', 'NO_REPLY')
|
|
202
277
|
const issued = devices.issued || devices.active || devices.delegations || []
|
|
203
278
|
const revoked = devices.revoked || []
|
|
@@ -208,9 +283,9 @@ export async function listDevices (profile) {
|
|
|
208
283
|
// certificados son su reflejo. Quien pinte la lista los necesita a la vez, o acaba
|
|
209
284
|
// enseñando solo a los que tienen papel — y el que hay que quitar es justo el que no.
|
|
210
285
|
return {
|
|
211
|
-
issued:
|
|
286
|
+
issued: groupCertsByDevice(withIds, revoked),
|
|
212
287
|
revoked,
|
|
213
|
-
members:
|
|
288
|
+
members: record?.members || [],
|
|
214
289
|
profile: devices.profile || null
|
|
215
290
|
}
|
|
216
291
|
}
|
|
@@ -224,20 +299,20 @@ export async function listDevices (profile) {
|
|
|
224
299
|
* como «enrolados». Se agrupa por llave, se queda el más nuevo y se guardan TODOS sus
|
|
225
300
|
* nonces, que es lo que hace falta para retirarlo entero.
|
|
226
301
|
*/
|
|
227
|
-
export function
|
|
228
|
-
const
|
|
229
|
-
const
|
|
230
|
-
for (const d of
|
|
231
|
-
if (d.revokedAt ||
|
|
232
|
-
const
|
|
233
|
-
const y =
|
|
234
|
-
if (!y)
|
|
302
|
+
export function groupCertsByDevice (list, revoked = []) {
|
|
303
|
+
const out = new Set(revoked.map((r) => r?.nonce || r))
|
|
304
|
+
const byKey = new Map()
|
|
305
|
+
for (const d of list) {
|
|
306
|
+
if (d.revokedAt || out.has(d.nonce)) continue
|
|
307
|
+
const key = d.sub || d.nonce
|
|
308
|
+
const y = byKey.get(key)
|
|
309
|
+
if (!y) byKey.set(key, { ...d, nonces: [d.nonce] })
|
|
235
310
|
else {
|
|
236
311
|
y.nonces.push(d.nonce)
|
|
237
312
|
if ((d.exp || 0) > (y.exp || 0)) Object.assign(y, { ...d, nonces: y.nonces })
|
|
238
313
|
}
|
|
239
314
|
}
|
|
240
|
-
return [...
|
|
315
|
+
return [...byKey.values()]
|
|
241
316
|
}
|
|
242
317
|
|
|
243
318
|
/**
|
|
@@ -246,7 +321,7 @@ export function agruparPorAparato (lista, revoked = []) {
|
|
|
246
321
|
*/
|
|
247
322
|
export async function setDeviceLabel (pub, label, profile) {
|
|
248
323
|
requireAlive()
|
|
249
|
-
writeReq(F.labelReq, { pub, label }, profile)
|
|
324
|
+
await writeReq(F.labelReq, { pub, label }, profile)
|
|
250
325
|
signalOrCleanup('SIGUSR2', [F.labelReq])
|
|
251
326
|
await sleep(400)
|
|
252
327
|
return listDevices(profile)
|
|
@@ -260,10 +335,12 @@ export async function setDeviceLabel (pub, label, profile) {
|
|
|
260
335
|
export async function listMembers (profile) {
|
|
261
336
|
requireAlive()
|
|
262
337
|
rm(F.acta)
|
|
263
|
-
|
|
338
|
+
const since = Date.now()
|
|
339
|
+
const id = await writeReq(F.dumpReq, {}, profile)
|
|
264
340
|
signalOrCleanup('SIGUSR2', [F.dumpReq])
|
|
265
|
-
const d = await waitFor(F.acta)
|
|
341
|
+
const d = await waitFor(F.acta, { req: id, since })
|
|
266
342
|
if (!d) throw coded('the daemon did not reply', 'NO_REPLY')
|
|
343
|
+
assertOpen(d)
|
|
267
344
|
return d.members || []
|
|
268
345
|
}
|
|
269
346
|
|
|
@@ -277,7 +354,7 @@ export async function listMembers (profile) {
|
|
|
277
354
|
*/
|
|
278
355
|
export async function setDeviceCaps (pub, caps, profile) {
|
|
279
356
|
requireAlive()
|
|
280
|
-
writeReq(F.capsReq, { pub, caps }, profile)
|
|
357
|
+
await writeReq(F.capsReq, { pub, caps }, profile)
|
|
281
358
|
signalOrCleanup('SIGUSR2', [F.capsReq])
|
|
282
359
|
await sleep(600)
|
|
283
360
|
return listDevices(profile)
|
|
@@ -291,7 +368,7 @@ export async function setDeviceCaps (pub, caps, profile) {
|
|
|
291
368
|
export async function revokeDevice (target, profile) {
|
|
292
369
|
requireAlive()
|
|
293
370
|
const req = typeof target === 'string' ? { nonce: target } : { sub: target?.sub, nonce: target?.nonce }
|
|
294
|
-
writeReq(F.revokeReq, req, profile)
|
|
371
|
+
await writeReq(F.revokeReq, req, profile)
|
|
295
372
|
signalOrCleanup('SIGUSR2', [F.revokeReq])
|
|
296
373
|
await sleep(300)
|
|
297
374
|
return listDevices(profile)
|
|
@@ -315,59 +392,192 @@ export async function revokeDevice (target, profile) {
|
|
|
315
392
|
export async function getMe (profile) {
|
|
316
393
|
requireAlive()
|
|
317
394
|
rm(F.me)
|
|
318
|
-
|
|
395
|
+
const since = Date.now()
|
|
396
|
+
const id = await writeReq(F.meReq, {}, profile)
|
|
319
397
|
signalOrCleanup('SIGUSR2', [F.meReq])
|
|
320
|
-
const d = await waitFor(F.me)
|
|
398
|
+
const d = await waitFor(F.me, { req: id, since })
|
|
321
399
|
rm(F.me)
|
|
322
400
|
if (!d) throw coded('the daemon did not reply', 'NO_REPLY')
|
|
401
|
+
assertOpen(d)
|
|
323
402
|
return d.me || null
|
|
324
403
|
}
|
|
325
404
|
|
|
326
405
|
|
|
327
406
|
// ---------------------------------------------------------------------------
|
|
328
|
-
//
|
|
407
|
+
// Variables de entorno: DOS CAJONES
|
|
408
|
+
//
|
|
409
|
+
// · por SCOPE (`ns`) — las comparten todos los aparatos del perfil que sirven
|
|
410
|
+
// ese namespace.
|
|
411
|
+
// · por APARATO (`dev`) — solo las lee ese aparato, y PISAN a las del scope.
|
|
412
|
+
//
|
|
413
|
+
// Las dos listas viajan juntas en el mismo volcado (`secrets-list.json`), así que
|
|
414
|
+
// todas estas funciones devuelven lo mismo: `{ ns, dev }`. Nunca hay valores en
|
|
415
|
+
// ninguna de las dos: el daemon no los expone.
|
|
329
416
|
// ---------------------------------------------------------------------------
|
|
330
417
|
|
|
331
|
-
/**
|
|
418
|
+
/**
|
|
419
|
+
* Da forma al volcado: `{ ns: {<scope>: [{key, public}]}, dev: [{pub,id,label,cn,keys,orphan}] }`.
|
|
420
|
+
* `public` dice si el VALOR puede salir de la máquina de la bóveda hacia la consola remota;
|
|
421
|
+
* el valor en sí no está aquí ni en ningún volcado.
|
|
422
|
+
*/
|
|
423
|
+
const shapeSecrets = (d) => ({ ns: d?.ns || {}, dev: Array.isArray(d?.dev) ? d.dev : [] })
|
|
424
|
+
|
|
425
|
+
/** Los dos cajones del perfil (NUNCA los valores). */
|
|
332
426
|
export async function listSecrets (profile) {
|
|
333
427
|
const { secrets } = await snapshot(profile)
|
|
334
428
|
if (!secrets) throw coded('the daemon did not reply', 'NO_REPLY')
|
|
335
|
-
return secrets
|
|
429
|
+
return shapeSecrets(secrets)
|
|
336
430
|
}
|
|
337
431
|
|
|
338
|
-
/**
|
|
339
|
-
|
|
432
|
+
/**
|
|
433
|
+
* Manda una orden de secreto y espera el volcado, comprobando que se aplicó de verdad:
|
|
434
|
+
* el daemon puede rechazarla (clave inválida, aparato que no es un servicio) y quedarse
|
|
435
|
+
* callado, y un «guardado» que no guardó nada es la peor forma de fallar en esto.
|
|
436
|
+
*/
|
|
437
|
+
async function secretOp (req, profile, check) {
|
|
438
|
+
// `req` puede llevar la CONTRASEÑA del perfil (las operaciones que sellan la
|
|
439
|
+
// necesitan para abrir la copia maestra). Vive un instante en un archivo 0600 del
|
|
440
|
+
// dir del vault y el daemon lo borra al consumirlo.
|
|
340
441
|
requireAlive() // el VALOR es secreto: no escribirlo si el daemon está caído
|
|
341
442
|
rm(F.secretsList)
|
|
342
|
-
writeReq(F.secretReq,
|
|
343
|
-
writeReq(F.dumpReq, {}, profile)
|
|
443
|
+
await writeReq(F.secretReq, req, profile)
|
|
444
|
+
await writeReq(F.dumpReq, {}, profile)
|
|
344
445
|
signalOrCleanup('SIGUSR2', [F.secretReq, F.dumpReq])
|
|
345
446
|
const d = await waitFor(F.secretsList)
|
|
447
|
+
rm(F.secretsList) // lleva el valor de las públicas: no se queda en el disco (ver `snapshot`)
|
|
346
448
|
if (!d) throw coded('the daemon did not reply', 'NO_REPLY')
|
|
347
|
-
|
|
348
|
-
|
|
449
|
+
assertOpen(d)
|
|
450
|
+
const out = shapeSecrets(d)
|
|
451
|
+
check(out)
|
|
452
|
+
return out
|
|
349
453
|
}
|
|
350
454
|
|
|
351
|
-
/**
|
|
352
|
-
|
|
455
|
+
/**
|
|
456
|
+
* VER el valor de una privada. Es lo único que pide la contraseña desde v5 (§8.3), y en
|
|
457
|
+
* esta máquina no hay otra manera: la llave de este aparato es una llave que vive en
|
|
458
|
+
* este mismo disco, así que si abriera sin frase, una copia del disco abriría todo.
|
|
459
|
+
*
|
|
460
|
+
* El valor viaja por el volcado —igual que el de las públicas— y quien lo lee borra el
|
|
461
|
+
* archivo en el acto; no se queda esperando a que alguien copie la carpeta.
|
|
462
|
+
*/
|
|
463
|
+
export async function revealSecret (owner, key, profile, password) {
|
|
353
464
|
requireAlive()
|
|
354
465
|
rm(F.secretsList)
|
|
355
|
-
writeReq(F.secretReq, { op: '
|
|
356
|
-
writeReq(F.dumpReq, {}, profile)
|
|
466
|
+
await writeReq(F.secretReq, { op: 'reveal', owner, key, ...(password ? { password } : {}) }, profile)
|
|
467
|
+
await writeReq(F.dumpReq, {}, profile)
|
|
357
468
|
signalOrCleanup('SIGUSR2', [F.secretReq, F.dumpReq])
|
|
358
469
|
const d = await waitFor(F.secretsList)
|
|
470
|
+
rm(F.secretsList)
|
|
359
471
|
if (!d) throw coded('the daemon did not reply', 'NO_REPLY')
|
|
360
|
-
|
|
361
|
-
|
|
472
|
+
assertOpen(d)
|
|
473
|
+
if (d.revealed?.owner !== owner || d.revealed?.key !== key) {
|
|
474
|
+
throw coded('the daemon did not return the value (check the service logs)', 'NOT_REVEALED')
|
|
475
|
+
}
|
|
476
|
+
return d.revealed.value
|
|
362
477
|
}
|
|
363
478
|
|
|
364
|
-
/**
|
|
479
|
+
/** SALDA lo que quedó a deber (heredar lo viejo, rotar de verdad). Pide la contraseña. */
|
|
480
|
+
export function settleSecrets (profile, password) {
|
|
481
|
+
return secretOp({ op: 'settle', ...(password ? { password } : {}) }, profile, () => {})
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
const keysOf = (out, pub) => (out.dev.find((x) => x.pub === pub)?.keys) || []
|
|
485
|
+
/** ¿Está esa clave en la lista? Las listas traen `{key, public}`, nunca el valor. */
|
|
486
|
+
const has = (list, key) => (list || []).some((x) => x.key === key)
|
|
487
|
+
const visibilityOf = (list, key) => !!(list || []).find((x) => x.key === key)?.public
|
|
488
|
+
|
|
489
|
+
/**
|
|
490
|
+
* Guarda/actualiza una variable de SCOPE. ns: [a-z0-9-]{1,32}. clave: [A-Z0-9_]{1,64}.
|
|
491
|
+
* `isPublic` opcional: sin decir nada conserva la visibilidad que ya tenía (y una nueva
|
|
492
|
+
* nace privada, o sea que su valor no sale de la máquina de la bóveda).
|
|
493
|
+
*/
|
|
494
|
+
export function setSecret (ns, key, value, profile, isPublic, password) {
|
|
495
|
+
return secretOp({ op: 'set', ns, key, value, ...(password ? { password } : {}), ...(isPublic === undefined ? {} : { public: !!isPublic }) }, profile, (out) => {
|
|
496
|
+
if (!has(out.ns[ns], key)) throw coded('the daemon did not apply the change (check the service logs)', 'NOT_APPLIED')
|
|
497
|
+
})
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/** Borra una variable de SCOPE. Si era la última, el scope desaparece. */
|
|
501
|
+
export function deleteSecret (ns, key, profile) {
|
|
502
|
+
return secretOp({ op: 'rm', ns, key }, profile, (out) => {
|
|
503
|
+
if (has(out.ns[ns], key)) throw coded('the daemon did not delete the variable (check the service logs)', 'NOT_DELETED')
|
|
504
|
+
})
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/**
|
|
508
|
+
* Cambia SOLO quién puede ver el valor: `public` deja que la consola remota lo vea,
|
|
509
|
+
* `private` lo encierra en esta máquina. No toca el valor (ni hace falta conocerlo).
|
|
510
|
+
*/
|
|
511
|
+
export function setSecretVisibility (ns, key, isPublic, profile, password) {
|
|
512
|
+
return secretOp({ op: 'vis', ns, key, public: !!isPublic, ...(password ? { password } : {}) }, profile, (out) => {
|
|
513
|
+
if (visibilityOf(out.ns[ns], key) !== !!isPublic) throw coded('the daemon did not apply the change (check the service logs)', 'NOT_APPLIED')
|
|
514
|
+
})
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
export function setDeviceSecretVisibility (pub, key, isPublic, profile, password) {
|
|
518
|
+
return secretOp({ op: 'dev-vis', pub, key, public: !!isPublic, ...(password ? { password } : {}) }, profile, (out) => {
|
|
519
|
+
if (visibilityOf(keysOf(out, pub), key) !== !!isPublic) throw coded('the daemon did not apply the change (check the service logs)', 'NOT_APPLIED')
|
|
520
|
+
})
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/**
|
|
524
|
+
* CARGA VARIAS DE UNA VEZ: un solo viaje al daemon, un solo guardado y —lo que de
|
|
525
|
+
* verdad importa— UN solo aviso a los servicios de ese scope.
|
|
526
|
+
*
|
|
527
|
+
* De una en una, cada variable es un cambio de configuración: el servicio obedece el
|
|
528
|
+
* primero, sale, lo levanta su supervisor y arranca con lo que hubiera puesto en ese
|
|
529
|
+
* momento, mientras quien administra sigue escribiendo el resto. Por eso cargar
|
|
530
|
+
* configuración tiene que llegar entera hasta abajo, y no romperse en N órdenes.
|
|
531
|
+
*
|
|
532
|
+
* @param {Array<{op:'set'|'rm', key:string, value?:string, public?:boolean}>} items
|
|
533
|
+
*/
|
|
534
|
+
export function applySecrets (ns, items, profile, password) {
|
|
535
|
+
return secretOp({ op: 'batch', ns, items, ...(password ? { password } : {}) }, profile, (out) => {
|
|
536
|
+
const missing = items.find((it) => (it.op === 'rm' ? has(out.ns[ns], it.key) : !has(out.ns[ns], it.key)))
|
|
537
|
+
if (missing) throw coded('the daemon did not apply the change (check the service logs)', 'NOT_APPLIED')
|
|
538
|
+
})
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/** Lo mismo para el cajón de UN aparato. */
|
|
542
|
+
export function applyDeviceSecrets (pub, items, profile, password) {
|
|
543
|
+
return secretOp({ op: 'batch', pub, items, ...(password ? { password } : {}) }, profile, (out) => {
|
|
544
|
+
const missing = items.find((it) => (it.op === 'rm' ? has(keysOf(out, pub), it.key) : !has(keysOf(out, pub), it.key)))
|
|
545
|
+
if (missing) throw coded('the daemon did not apply the change (check the service logs)', 'NOT_APPLIED')
|
|
546
|
+
})
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/** Borra un scope entero: todas sus variables juntas, para no avisar una vez por variable. */
|
|
365
550
|
export async function deleteScope (ns, profile) {
|
|
366
|
-
const
|
|
367
|
-
const keys =
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
551
|
+
const out = await listSecrets(profile)
|
|
552
|
+
const keys = (out.ns[ns] || []).map(({ key }) => ({ op: 'rm', key }))
|
|
553
|
+
if (!keys.length) return out
|
|
554
|
+
return applySecrets(ns, keys, profile)
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/**
|
|
558
|
+
* Guarda/actualiza una variable de UN APARATO, identificado por su llave (`pub`, la
|
|
559
|
+
* misma que trae el acta). Solo la lee ese aparato, y le gana a la del scope con el
|
|
560
|
+
* mismo nombre.
|
|
561
|
+
*/
|
|
562
|
+
export function setDeviceSecret (pub, key, value, profile, isPublic, password) {
|
|
563
|
+
return secretOp({ op: 'dev-set', pub, key, value, ...(password ? { password } : {}), ...(isPublic === undefined ? {} : { public: !!isPublic }) }, profile, (out) => {
|
|
564
|
+
if (!has(keysOf(out, pub), key)) throw coded('the daemon did not apply the change (check the service logs)', 'NOT_APPLIED')
|
|
565
|
+
})
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
/** Borra una variable de un aparato. */
|
|
569
|
+
export function deleteDeviceSecret (pub, key, profile) {
|
|
570
|
+
return secretOp({ op: 'dev-rm', pub, key }, profile, (out) => {
|
|
571
|
+
if (has(keysOf(out, pub), key)) throw coded('the daemon did not delete the variable (check the service logs)', 'NOT_DELETED')
|
|
572
|
+
})
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
/** Borra TODAS las variables de un aparato, juntas (mismo motivo que `deleteScope`). */
|
|
576
|
+
export async function deleteDeviceVars (pub, profile) {
|
|
577
|
+
const out = await listSecrets(profile)
|
|
578
|
+
const keys = keysOf(out, pub).map(({ key }) => ({ op: 'rm', key }))
|
|
579
|
+
if (!keys.length) return out
|
|
580
|
+
return applyDeviceSecrets(pub, keys, profile)
|
|
371
581
|
}
|
|
372
582
|
|
|
373
583
|
// ---------------------------------------------------------------------------
|
|
@@ -397,11 +607,12 @@ export function pairUrl (qr) {
|
|
|
397
607
|
export async function startPairing ({ profile, service } = {}) {
|
|
398
608
|
requireAlive()
|
|
399
609
|
rm(F.pair); rm(F.pending)
|
|
400
|
-
writeReq(F.pairReq, service ? { service } : {}, profile)
|
|
610
|
+
await writeReq(F.pairReq, service ? { service } : {}, profile)
|
|
401
611
|
signalOrCleanup('SIGUSR1', [F.pairReq])
|
|
402
612
|
for (let i = 0; i < 50; i++) {
|
|
403
613
|
await sleep(100)
|
|
404
614
|
const pr = read(F.pair, null)
|
|
615
|
+
assertOpen(pr)
|
|
405
616
|
if (pr?.expiresAt > Date.now()) {
|
|
406
617
|
const { url, payload, code } = pairUrl(pr.qr)
|
|
407
618
|
// `profile`/`profileName`: DE QUÉ CUENTA del vault sale este QR. El vault
|
|
@@ -426,16 +637,30 @@ export function pendingEnroll () {
|
|
|
426
637
|
*/
|
|
427
638
|
export async function approvePending (code, profile) {
|
|
428
639
|
requireAlive()
|
|
429
|
-
|
|
640
|
+
rm(F.approve)
|
|
641
|
+
const since = Date.now()
|
|
642
|
+
const id = await writeReq(F.approveReq, { code: String(code) }, profile)
|
|
430
643
|
signalOrCleanup('SIGUSR2', [F.approveReq])
|
|
644
|
+
// SE ESPERA LA RESPUESTA. Antes se daba por hecho: con el código equivocado —que la
|
|
645
|
+
// bóveda rechaza sin firmar nada— la pantalla decía «Dispositivo aprobado» igual, se
|
|
646
|
+
// olvidaba del pendiente y no había forma de reintentar desde aquí.
|
|
647
|
+
const r = await waitFor(F.approve, { req: id, since, tries: 40 })
|
|
648
|
+
// Sin respuesta: un daemon anterior a esto. Se sigue como antes en vez de inventar un
|
|
649
|
+
// error (el emparejamiento pudo salir bien).
|
|
650
|
+
if (r && r.ok === false) throw coded(r.error || 'the code does not match', r.code || 'APPROVE_FAILED')
|
|
431
651
|
await sleep(400)
|
|
432
|
-
|
|
652
|
+
// LA LISTA ES UN EXTRA, NO PARTE DE APROBAR. Si el volcado no llega, el aparato ya
|
|
653
|
+
// entró igual: el certificado se firmó y el acta se selló antes de esto. Dejar que ese
|
|
654
|
+
// fallo suba convertía un emparejamiento correcto en «el daemon no respondió», que es
|
|
655
|
+
// lo contrario de lo que pasó — y el dueño se queda sin saber si repetirlo o no.
|
|
656
|
+
// Se devuelve null y quien llama refresca por su cuenta.
|
|
657
|
+
try { return await listDevices(profile) } catch (_) { return null }
|
|
433
658
|
}
|
|
434
659
|
|
|
435
660
|
/** Rechaza el dispositivo pendiente. */
|
|
436
661
|
export async function rejectPending (deviceId, profile) {
|
|
437
662
|
requireAlive()
|
|
438
|
-
writeReq(F.rejectReq, { deviceId }, profile)
|
|
663
|
+
await writeReq(F.rejectReq, { deviceId }, profile)
|
|
439
664
|
signalOrCleanup('SIGUSR2', [F.rejectReq])
|
|
440
665
|
await sleep(200)
|
|
441
666
|
}
|