@dotrino/vaultd 0.26.2 → 0.38.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 +142 -25
- package/bin/dotrino-vaultd.js +4 -4
- package/lib/README.md +13 -2
- package/lib/src/admin.js +88 -3
- package/lib/src/config.js +1 -1
- package/lib/src/enroll.js +26 -22
- 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 +12 -0
- package/lib/src/service.js +218 -92
- package/package.json +9 -4
- package/src/ctl.js +375 -90
- package/src/daemon.js +167 -45
- package/src/manager.js +6 -6
- package/src/profiles.js +27 -4
- package/src/secretsStore.js +191 -25
- package/src/transport.js +2 -2
- package/src/tui/app.js +512 -126
- package/src/tui/i18n.js +118 -22
- package/src/vault.js +351 -44
- package/src/vaultControl.js +253 -64
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
|
|
|
@@ -179,17 +237,30 @@ export const removeProfilePassword = (profile) => profileOp('password-rm', { pro
|
|
|
179
237
|
*/
|
|
180
238
|
export async function snapshot (profile) {
|
|
181
239
|
requireAlive()
|
|
182
|
-
rm(F.devices); rm(F.secretsList); rm(F.
|
|
183
|
-
|
|
240
|
+
rm(F.devices); rm(F.secretsList); rm(F.acta)
|
|
241
|
+
const since = Date.now()
|
|
242
|
+
const id = await writeReq(F.dumpReq, {}, profile)
|
|
184
243
|
signalOrCleanup('SIGUSR2', [F.dumpReq])
|
|
185
244
|
// El ACTA entra en el volcado normal: es la lista de dispositivos de verdad, y las
|
|
186
245
|
// delegaciones son su reflejo. Sin ella, un miembro sin certificados (revocado a medias,
|
|
187
246
|
// o con el papel caducado) no salía en ninguna pantalla del PC — invisible y, por lo
|
|
188
247
|
// tanto, imposible de quitar desde aquí.
|
|
189
|
-
const [devices, secrets,
|
|
190
|
-
waitFor(F.devices), waitFor(F.secretsList
|
|
248
|
+
const [devices, secrets, record] = await Promise.all([
|
|
249
|
+
waitFor(F.devices, { req: id, since }), waitFor(F.secretsList, { req: id, since }),
|
|
250
|
+
waitFor(F.acta, { req: id, since })
|
|
191
251
|
])
|
|
192
|
-
|
|
252
|
+
// El volcado de variables lleva el valor de las PÚBLICAS, así que se borra en cuanto se
|
|
253
|
+
// tiene: en la memoria de quien lo pidió, no esperando en el disco a que copien la
|
|
254
|
+
// carpeta (ahí es donde el cifrado en reposo dejaría de servir de nada).
|
|
255
|
+
rm(F.secretsList)
|
|
256
|
+
assertOpen(devices); assertOpen(secrets); assertOpen(record)
|
|
257
|
+
// LA LISTA DE BÓVEDAS NO SE ESPERA AQUÍ. `profiles-list.json` es la respuesta a una
|
|
258
|
+
// petición de PERFIL (`dumpProfiles`), y el daemon dejó de volcarlo por su cuenta —
|
|
259
|
+
// volcarlo sin que nadie preguntara se llevaba por delante las respuestas de verdad.
|
|
260
|
+
// Seguir esperándolo aquí era esperar seis segundos, los de rendirse, a un archivo que
|
|
261
|
+
// ya no iba a llegar: CADA refresco de la TUI costaba eso, con el daemon contestando lo
|
|
262
|
+
// suyo en 100 ms. Quien necesita la lista llama a `listProfiles()`, que sí la pide.
|
|
263
|
+
return { devices, secrets, record, profiles: read(F.profilesList, null) }
|
|
193
264
|
}
|
|
194
265
|
|
|
195
266
|
/**
|
|
@@ -197,7 +268,7 @@ export async function snapshot (profile) {
|
|
|
197
268
|
* `issued` viene de identity.listDelegations(); el deviceId se deriva del `sub`.
|
|
198
269
|
*/
|
|
199
270
|
export async function listDevices (profile) {
|
|
200
|
-
const { devices,
|
|
271
|
+
const { devices, record } = await snapshot(profile)
|
|
201
272
|
if (!devices) throw coded('the daemon did not reply', 'NO_REPLY')
|
|
202
273
|
const issued = devices.issued || devices.active || devices.delegations || []
|
|
203
274
|
const revoked = devices.revoked || []
|
|
@@ -208,9 +279,9 @@ export async function listDevices (profile) {
|
|
|
208
279
|
// certificados son su reflejo. Quien pinte la lista los necesita a la vez, o acaba
|
|
209
280
|
// enseñando solo a los que tienen papel — y el que hay que quitar es justo el que no.
|
|
210
281
|
return {
|
|
211
|
-
issued:
|
|
282
|
+
issued: groupCertsByDevice(withIds, revoked),
|
|
212
283
|
revoked,
|
|
213
|
-
members:
|
|
284
|
+
members: record?.members || [],
|
|
214
285
|
profile: devices.profile || null
|
|
215
286
|
}
|
|
216
287
|
}
|
|
@@ -224,20 +295,20 @@ export async function listDevices (profile) {
|
|
|
224
295
|
* como «enrolados». Se agrupa por llave, se queda el más nuevo y se guardan TODOS sus
|
|
225
296
|
* nonces, que es lo que hace falta para retirarlo entero.
|
|
226
297
|
*/
|
|
227
|
-
export function
|
|
228
|
-
const
|
|
229
|
-
const
|
|
230
|
-
for (const d of
|
|
231
|
-
if (d.revokedAt ||
|
|
232
|
-
const
|
|
233
|
-
const y =
|
|
234
|
-
if (!y)
|
|
298
|
+
export function groupCertsByDevice (list, revoked = []) {
|
|
299
|
+
const out = new Set(revoked.map((r) => r?.nonce || r))
|
|
300
|
+
const byKey = new Map()
|
|
301
|
+
for (const d of list) {
|
|
302
|
+
if (d.revokedAt || out.has(d.nonce)) continue
|
|
303
|
+
const key = d.sub || d.nonce
|
|
304
|
+
const y = byKey.get(key)
|
|
305
|
+
if (!y) byKey.set(key, { ...d, nonces: [d.nonce] })
|
|
235
306
|
else {
|
|
236
307
|
y.nonces.push(d.nonce)
|
|
237
308
|
if ((d.exp || 0) > (y.exp || 0)) Object.assign(y, { ...d, nonces: y.nonces })
|
|
238
309
|
}
|
|
239
310
|
}
|
|
240
|
-
return [...
|
|
311
|
+
return [...byKey.values()]
|
|
241
312
|
}
|
|
242
313
|
|
|
243
314
|
/**
|
|
@@ -246,7 +317,7 @@ export function agruparPorAparato (lista, revoked = []) {
|
|
|
246
317
|
*/
|
|
247
318
|
export async function setDeviceLabel (pub, label, profile) {
|
|
248
319
|
requireAlive()
|
|
249
|
-
writeReq(F.labelReq, { pub, label }, profile)
|
|
320
|
+
await writeReq(F.labelReq, { pub, label }, profile)
|
|
250
321
|
signalOrCleanup('SIGUSR2', [F.labelReq])
|
|
251
322
|
await sleep(400)
|
|
252
323
|
return listDevices(profile)
|
|
@@ -260,10 +331,12 @@ export async function setDeviceLabel (pub, label, profile) {
|
|
|
260
331
|
export async function listMembers (profile) {
|
|
261
332
|
requireAlive()
|
|
262
333
|
rm(F.acta)
|
|
263
|
-
|
|
334
|
+
const since = Date.now()
|
|
335
|
+
const id = await writeReq(F.dumpReq, {}, profile)
|
|
264
336
|
signalOrCleanup('SIGUSR2', [F.dumpReq])
|
|
265
|
-
const d = await waitFor(F.acta)
|
|
337
|
+
const d = await waitFor(F.acta, { req: id, since })
|
|
266
338
|
if (!d) throw coded('the daemon did not reply', 'NO_REPLY')
|
|
339
|
+
assertOpen(d)
|
|
267
340
|
return d.members || []
|
|
268
341
|
}
|
|
269
342
|
|
|
@@ -277,7 +350,7 @@ export async function listMembers (profile) {
|
|
|
277
350
|
*/
|
|
278
351
|
export async function setDeviceCaps (pub, caps, profile) {
|
|
279
352
|
requireAlive()
|
|
280
|
-
writeReq(F.capsReq, { pub, caps }, profile)
|
|
353
|
+
await writeReq(F.capsReq, { pub, caps }, profile)
|
|
281
354
|
signalOrCleanup('SIGUSR2', [F.capsReq])
|
|
282
355
|
await sleep(600)
|
|
283
356
|
return listDevices(profile)
|
|
@@ -291,7 +364,7 @@ export async function setDeviceCaps (pub, caps, profile) {
|
|
|
291
364
|
export async function revokeDevice (target, profile) {
|
|
292
365
|
requireAlive()
|
|
293
366
|
const req = typeof target === 'string' ? { nonce: target } : { sub: target?.sub, nonce: target?.nonce }
|
|
294
|
-
writeReq(F.revokeReq, req, profile)
|
|
367
|
+
await writeReq(F.revokeReq, req, profile)
|
|
295
368
|
signalOrCleanup('SIGUSR2', [F.revokeReq])
|
|
296
369
|
await sleep(300)
|
|
297
370
|
return listDevices(profile)
|
|
@@ -315,59 +388,160 @@ export async function revokeDevice (target, profile) {
|
|
|
315
388
|
export async function getMe (profile) {
|
|
316
389
|
requireAlive()
|
|
317
390
|
rm(F.me)
|
|
318
|
-
|
|
391
|
+
const since = Date.now()
|
|
392
|
+
const id = await writeReq(F.meReq, {}, profile)
|
|
319
393
|
signalOrCleanup('SIGUSR2', [F.meReq])
|
|
320
|
-
const d = await waitFor(F.me)
|
|
394
|
+
const d = await waitFor(F.me, { req: id, since })
|
|
321
395
|
rm(F.me)
|
|
322
396
|
if (!d) throw coded('the daemon did not reply', 'NO_REPLY')
|
|
397
|
+
assertOpen(d)
|
|
323
398
|
return d.me || null
|
|
324
399
|
}
|
|
325
400
|
|
|
326
401
|
|
|
327
402
|
// ---------------------------------------------------------------------------
|
|
328
|
-
//
|
|
403
|
+
// Variables de entorno: DOS CAJONES
|
|
404
|
+
//
|
|
405
|
+
// · por SCOPE (`ns`) — las comparten todos los aparatos del perfil que sirven
|
|
406
|
+
// ese namespace.
|
|
407
|
+
// · por APARATO (`dev`) — solo las lee ese aparato, y PISAN a las del scope.
|
|
408
|
+
//
|
|
409
|
+
// Las dos listas viajan juntas en el mismo volcado (`secrets-list.json`), así que
|
|
410
|
+
// todas estas funciones devuelven lo mismo: `{ ns, dev }`. Nunca hay valores en
|
|
411
|
+
// ninguna de las dos: el daemon no los expone.
|
|
329
412
|
// ---------------------------------------------------------------------------
|
|
330
413
|
|
|
331
|
-
/**
|
|
414
|
+
/**
|
|
415
|
+
* Da forma al volcado: `{ ns: {<scope>: [{key, public}]}, dev: [{pub,id,label,cn,keys,orphan}] }`.
|
|
416
|
+
* `public` dice si el VALOR puede salir de la máquina de la bóveda hacia la consola remota;
|
|
417
|
+
* el valor en sí no está aquí ni en ningún volcado.
|
|
418
|
+
*/
|
|
419
|
+
const shapeSecrets = (d) => ({ ns: d?.ns || {}, dev: Array.isArray(d?.dev) ? d.dev : [] })
|
|
420
|
+
|
|
421
|
+
/** Los dos cajones del perfil (NUNCA los valores). */
|
|
332
422
|
export async function listSecrets (profile) {
|
|
333
423
|
const { secrets } = await snapshot(profile)
|
|
334
424
|
if (!secrets) throw coded('the daemon did not reply', 'NO_REPLY')
|
|
335
|
-
return secrets
|
|
425
|
+
return shapeSecrets(secrets)
|
|
336
426
|
}
|
|
337
427
|
|
|
338
|
-
/**
|
|
339
|
-
|
|
428
|
+
/**
|
|
429
|
+
* Manda una orden de secreto y espera el volcado, comprobando que se aplicó de verdad:
|
|
430
|
+
* el daemon puede rechazarla (clave inválida, aparato que no es un servicio) y quedarse
|
|
431
|
+
* callado, y un «guardado» que no guardó nada es la peor forma de fallar en esto.
|
|
432
|
+
*/
|
|
433
|
+
async function secretOp (req, profile, check) {
|
|
340
434
|
requireAlive() // el VALOR es secreto: no escribirlo si el daemon está caído
|
|
341
435
|
rm(F.secretsList)
|
|
342
|
-
writeReq(F.secretReq,
|
|
343
|
-
writeReq(F.dumpReq, {}, profile)
|
|
436
|
+
await writeReq(F.secretReq, req, profile)
|
|
437
|
+
await writeReq(F.dumpReq, {}, profile)
|
|
344
438
|
signalOrCleanup('SIGUSR2', [F.secretReq, F.dumpReq])
|
|
345
439
|
const d = await waitFor(F.secretsList)
|
|
440
|
+
rm(F.secretsList) // lleva el valor de las públicas: no se queda en el disco (ver `snapshot`)
|
|
346
441
|
if (!d) throw coded('the daemon did not reply', 'NO_REPLY')
|
|
347
|
-
|
|
348
|
-
|
|
442
|
+
assertOpen(d)
|
|
443
|
+
const out = shapeSecrets(d)
|
|
444
|
+
check(out)
|
|
445
|
+
return out
|
|
349
446
|
}
|
|
350
447
|
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
448
|
+
const keysOf = (out, pub) => (out.dev.find((x) => x.pub === pub)?.keys) || []
|
|
449
|
+
/** ¿Está esa clave en la lista? Las listas traen `{key, public}`, nunca el valor. */
|
|
450
|
+
const has = (list, key) => (list || []).some((x) => x.key === key)
|
|
451
|
+
const visibilityOf = (list, key) => !!(list || []).find((x) => x.key === key)?.public
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* Guarda/actualiza una variable de SCOPE. ns: [a-z0-9-]{1,32}. clave: [A-Z0-9_]{1,64}.
|
|
455
|
+
* `isPublic` opcional: sin decir nada conserva la visibilidad que ya tenía (y una nueva
|
|
456
|
+
* nace privada, o sea que su valor no sale de la máquina de la bóveda).
|
|
457
|
+
*/
|
|
458
|
+
export function setSecret (ns, key, value, profile, isPublic) {
|
|
459
|
+
return secretOp({ op: 'set', ns, key, value, ...(isPublic === undefined ? {} : { public: !!isPublic }) }, profile, (out) => {
|
|
460
|
+
if (!has(out.ns[ns], key)) throw coded('the daemon did not apply the change (check the service logs)', 'NOT_APPLIED')
|
|
461
|
+
})
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/** Borra una variable de SCOPE. Si era la última, el scope desaparece. */
|
|
465
|
+
export function deleteSecret (ns, key, profile) {
|
|
466
|
+
return secretOp({ op: 'rm', ns, key }, profile, (out) => {
|
|
467
|
+
if (has(out.ns[ns], key)) throw coded('the daemon did not delete the variable (check the service logs)', 'NOT_DELETED')
|
|
468
|
+
})
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/**
|
|
472
|
+
* Cambia SOLO quién puede ver el valor: `public` deja que la consola remota lo vea,
|
|
473
|
+
* `private` lo encierra en esta máquina. No toca el valor (ni hace falta conocerlo).
|
|
474
|
+
*/
|
|
475
|
+
export function setSecretVisibility (ns, key, isPublic, profile) {
|
|
476
|
+
return secretOp({ op: 'vis', ns, key, public: !!isPublic }, profile, (out) => {
|
|
477
|
+
if (visibilityOf(out.ns[ns], key) !== !!isPublic) throw coded('the daemon did not apply the change (check the service logs)', 'NOT_APPLIED')
|
|
478
|
+
})
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
export function setDeviceSecretVisibility (pub, key, isPublic, profile) {
|
|
482
|
+
return secretOp({ op: 'dev-vis', pub, key, public: !!isPublic }, profile, (out) => {
|
|
483
|
+
if (visibilityOf(keysOf(out, pub), key) !== !!isPublic) throw coded('the daemon did not apply the change (check the service logs)', 'NOT_APPLIED')
|
|
484
|
+
})
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/**
|
|
488
|
+
* CARGA VARIAS DE UNA VEZ: un solo viaje al daemon, un solo guardado y —lo que de
|
|
489
|
+
* verdad importa— UN solo aviso a los servicios de ese scope.
|
|
490
|
+
*
|
|
491
|
+
* De una en una, cada variable es un cambio de configuración: el servicio obedece el
|
|
492
|
+
* primero, sale, lo levanta su supervisor y arranca con lo que hubiera puesto en ese
|
|
493
|
+
* momento, mientras quien administra sigue escribiendo el resto. Por eso cargar
|
|
494
|
+
* configuración tiene que llegar entera hasta abajo, y no romperse en N órdenes.
|
|
495
|
+
*
|
|
496
|
+
* @param {Array<{op:'set'|'rm', key:string, value?:string, public?:boolean}>} items
|
|
497
|
+
*/
|
|
498
|
+
export function applySecrets (ns, items, profile) {
|
|
499
|
+
return secretOp({ op: 'batch', ns, items }, profile, (out) => {
|
|
500
|
+
const missing = items.find((it) => (it.op === 'rm' ? has(out.ns[ns], it.key) : !has(out.ns[ns], it.key)))
|
|
501
|
+
if (missing) throw coded('the daemon did not apply the change (check the service logs)', 'NOT_APPLIED')
|
|
502
|
+
})
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/** Lo mismo para el cajón de UN aparato. */
|
|
506
|
+
export function applyDeviceSecrets (pub, items, profile) {
|
|
507
|
+
return secretOp({ op: 'batch', pub, items }, profile, (out) => {
|
|
508
|
+
const missing = items.find((it) => (it.op === 'rm' ? has(keysOf(out, pub), it.key) : !has(keysOf(out, pub), it.key)))
|
|
509
|
+
if (missing) throw coded('the daemon did not apply the change (check the service logs)', 'NOT_APPLIED')
|
|
510
|
+
})
|
|
362
511
|
}
|
|
363
512
|
|
|
364
|
-
/** Borra un scope entero
|
|
513
|
+
/** Borra un scope entero: todas sus variables juntas, para no avisar una vez por variable. */
|
|
365
514
|
export async function deleteScope (ns, profile) {
|
|
366
|
-
const
|
|
367
|
-
const keys =
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
515
|
+
const out = await listSecrets(profile)
|
|
516
|
+
const keys = (out.ns[ns] || []).map(({ key }) => ({ op: 'rm', key }))
|
|
517
|
+
if (!keys.length) return out
|
|
518
|
+
return applySecrets(ns, keys, profile)
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/**
|
|
522
|
+
* Guarda/actualiza una variable de UN APARATO, identificado por su llave (`pub`, la
|
|
523
|
+
* misma que trae el acta). Solo la lee ese aparato, y le gana a la del scope con el
|
|
524
|
+
* mismo nombre.
|
|
525
|
+
*/
|
|
526
|
+
export function setDeviceSecret (pub, key, value, profile, isPublic) {
|
|
527
|
+
return secretOp({ op: 'dev-set', pub, key, value, ...(isPublic === undefined ? {} : { public: !!isPublic }) }, profile, (out) => {
|
|
528
|
+
if (!has(keysOf(out, pub), key)) throw coded('the daemon did not apply the change (check the service logs)', 'NOT_APPLIED')
|
|
529
|
+
})
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/** Borra una variable de un aparato. */
|
|
533
|
+
export function deleteDeviceSecret (pub, key, profile) {
|
|
534
|
+
return secretOp({ op: 'dev-rm', pub, key }, profile, (out) => {
|
|
535
|
+
if (has(keysOf(out, pub), key)) throw coded('the daemon did not delete the variable (check the service logs)', 'NOT_DELETED')
|
|
536
|
+
})
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/** Borra TODAS las variables de un aparato, juntas (mismo motivo que `deleteScope`). */
|
|
540
|
+
export async function deleteDeviceVars (pub, profile) {
|
|
541
|
+
const out = await listSecrets(profile)
|
|
542
|
+
const keys = keysOf(out, pub).map(({ key }) => ({ op: 'rm', key }))
|
|
543
|
+
if (!keys.length) return out
|
|
544
|
+
return applyDeviceSecrets(pub, keys, profile)
|
|
371
545
|
}
|
|
372
546
|
|
|
373
547
|
// ---------------------------------------------------------------------------
|
|
@@ -397,11 +571,12 @@ export function pairUrl (qr) {
|
|
|
397
571
|
export async function startPairing ({ profile, service } = {}) {
|
|
398
572
|
requireAlive()
|
|
399
573
|
rm(F.pair); rm(F.pending)
|
|
400
|
-
writeReq(F.pairReq, service ? { service } : {}, profile)
|
|
574
|
+
await writeReq(F.pairReq, service ? { service } : {}, profile)
|
|
401
575
|
signalOrCleanup('SIGUSR1', [F.pairReq])
|
|
402
576
|
for (let i = 0; i < 50; i++) {
|
|
403
577
|
await sleep(100)
|
|
404
578
|
const pr = read(F.pair, null)
|
|
579
|
+
assertOpen(pr)
|
|
405
580
|
if (pr?.expiresAt > Date.now()) {
|
|
406
581
|
const { url, payload, code } = pairUrl(pr.qr)
|
|
407
582
|
// `profile`/`profileName`: DE QUÉ CUENTA del vault sale este QR. El vault
|
|
@@ -426,16 +601,30 @@ export function pendingEnroll () {
|
|
|
426
601
|
*/
|
|
427
602
|
export async function approvePending (code, profile) {
|
|
428
603
|
requireAlive()
|
|
429
|
-
|
|
604
|
+
rm(F.approve)
|
|
605
|
+
const since = Date.now()
|
|
606
|
+
const id = await writeReq(F.approveReq, { code: String(code) }, profile)
|
|
430
607
|
signalOrCleanup('SIGUSR2', [F.approveReq])
|
|
608
|
+
// SE ESPERA LA RESPUESTA. Antes se daba por hecho: con el código equivocado —que la
|
|
609
|
+
// bóveda rechaza sin firmar nada— la pantalla decía «Dispositivo aprobado» igual, se
|
|
610
|
+
// olvidaba del pendiente y no había forma de reintentar desde aquí.
|
|
611
|
+
const r = await waitFor(F.approve, { req: id, since, tries: 40 })
|
|
612
|
+
// Sin respuesta: un daemon anterior a esto. Se sigue como antes en vez de inventar un
|
|
613
|
+
// error (el emparejamiento pudo salir bien).
|
|
614
|
+
if (r && r.ok === false) throw coded(r.error || 'the code does not match', r.code || 'APPROVE_FAILED')
|
|
431
615
|
await sleep(400)
|
|
432
|
-
|
|
616
|
+
// LA LISTA ES UN EXTRA, NO PARTE DE APROBAR. Si el volcado no llega, el aparato ya
|
|
617
|
+
// entró igual: el certificado se firmó y el acta se selló antes de esto. Dejar que ese
|
|
618
|
+
// fallo suba convertía un emparejamiento correcto en «el daemon no respondió», que es
|
|
619
|
+
// lo contrario de lo que pasó — y el dueño se queda sin saber si repetirlo o no.
|
|
620
|
+
// Se devuelve null y quien llama refresca por su cuenta.
|
|
621
|
+
try { return await listDevices(profile) } catch (_) { return null }
|
|
433
622
|
}
|
|
434
623
|
|
|
435
624
|
/** Rechaza el dispositivo pendiente. */
|
|
436
625
|
export async function rejectPending (deviceId, profile) {
|
|
437
626
|
requireAlive()
|
|
438
|
-
writeReq(F.rejectReq, { deviceId }, profile)
|
|
627
|
+
await writeReq(F.rejectReq, { deviceId }, profile)
|
|
439
628
|
signalOrCleanup('SIGUSR2', [F.rejectReq])
|
|
440
629
|
await sleep(200)
|
|
441
630
|
}
|