@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.
@@ -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
- function writeReq (name, obj, profile) {
95
- const body = { ...obj, at: Date.now() }
96
- if (profile) body.profile = profile
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
- fs.writeFileSync(p(name), JSON.stringify(body), { mode: 0o600 })
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
- /** Espera a que reaparezca un archivo de respuesta (con `.at`) tras borrarlo. */
130
- async function waitFor (name, { tries = 60, interval = 100 } = {}) {
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 && d.at) return 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
- writeReq(F.profileReq, { op, ...extra }, profile)
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
- if (d.error) throw coded(d.error, d.code) // p.ej. MASTER_WITH_MEMBERS (freno D12)
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
- export const setProfilePassword = (profile, password) => profileOp('password-set', { profile, password })
170
- export const removeProfilePassword = (profile) => profileOp('password-rm', { profile })
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.profilesList); rm(F.acta)
183
- writeReq(F.dumpReq, {}, profile)
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, profiles, acta] = await Promise.all([
190
- waitFor(F.devices), waitFor(F.secretsList), waitFor(F.profilesList), waitFor(F.acta)
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
- return { devices, secrets, profiles, acta }
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, acta } = await snapshot(profile)
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: agruparPorAparato(withIds, revoked),
286
+ issued: groupCertsByDevice(withIds, revoked),
212
287
  revoked,
213
- members: acta?.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 agruparPorAparato (lista, revoked = []) {
228
- const fuera = new Set(revoked.map((r) => r?.nonce || r))
229
- const porLlave = new Map()
230
- for (const d of lista) {
231
- if (d.revokedAt || fuera.has(d.nonce)) continue
232
- const clave = d.sub || d.nonce
233
- const y = porLlave.get(clave)
234
- if (!y) porLlave.set(clave, { ...d, nonces: [d.nonce] })
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 [...porLlave.values()]
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
- writeReq(F.dumpReq, {}, profile)
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
- writeReq(F.meReq, {}, profile)
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
- // Secretos: scopes (namespaces) y variables (claves)
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
- /** Scopes→[claves] del perfil (NUNCA los valores; el daemon no los expone). */
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.ns || {}
429
+ return shapeSecrets(secrets)
336
430
  }
337
431
 
338
- /** Guarda/actualiza una variable. ns: [a-z0-9-]{1,32}. clave: [A-Z0-9_]{1,64}. */
339
- export async function setSecret (ns, key, value, profile) {
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, { op: 'set', ns, key, value }, profile)
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
- if (!(d.ns?.[ns] || []).includes(key)) throw coded('the daemon did not apply the change (check the service logs)', 'NOT_APPLIED')
348
- return d.ns
449
+ assertOpen(d)
450
+ const out = shapeSecrets(d)
451
+ check(out)
452
+ return out
349
453
  }
350
454
 
351
- /** Borra una variable. Si era la última del scope, el scope desaparece. */
352
- export async function deleteSecret (ns, key, profile) {
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: 'rm', ns, key }, profile)
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
- if ((d.ns?.[ns] || []).includes(key)) throw coded('the daemon did not delete the variable (check the service logs)', 'NOT_DELETED')
361
- return d.ns
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
- /** Borra un scope entero (todas sus variables, una por una). */
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 all = await listSecrets(profile)
367
- const keys = all[ns] || []
368
- let ns2 = all
369
- for (const k of keys) ns2 = await deleteSecret(ns, k, profile)
370
- return ns2
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
- writeReq(F.approveReq, { code: String(code) }, profile)
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
- return listDevices(profile)
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
  }