@dotrino/vaultd 0.65.0 → 0.73.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/src/vault.js CHANGED
@@ -19,6 +19,7 @@ import { createEnrollDesk, deviceIdOf, DEVICE_TTL_MS } from '../lib/src/enroll.j
19
19
  import { createAdminDesk } from '../lib/src/admin.js'
20
20
  import { shouldNotifyRevoked } from '../lib/src/revocation.js'
21
21
  import { createTransport, masterPubkeyOf } from './transport.js'
22
+ import { startSealersPublisher } from './sealers.js'
22
23
  import { assertKeyOwnsDir } from './keyowner.js'
23
24
  import { openStore } from './store.js'
24
25
  import { openThreadStore, STORE_READ_METHODS, PROFILE_EDIT_METHODS } from './threadStore.js'
@@ -30,9 +31,10 @@ import { openSecretsStore, assertVar } from './secretsStore.js'
30
31
  import { createApprovals, PENDING_TTL_MS } from './approvals.js'
31
32
  import { makeSealer } from './sealer.js'
32
33
  import { openSealKeys } from './sealKey.js'
34
+ import { openCommKey, COMM_CN, COMM_CAPS } from './commKey.js'
33
35
  import { seal } from '../lib/src/sealed.js'
34
36
  import { dataDir, ensureDir } from './paths.js'
35
- import { atRestFor, kekFor, migrateFile } from './atrest.js'
37
+ import { atRestFor, kekFor, migrateFile, encryptText, decryptText } from './atrest.js'
36
38
  import { MSG, SCOPE, secretsScope, isValidSecretsNs } from './protocol.js'
37
39
 
38
40
  /**
@@ -52,7 +54,7 @@ const MAX_REPLY_BYTES = 768 * 1024
52
54
  * Solo bloquea EDITAR el perfil (`profileSet`): firmar/leer y el resto del store
53
55
  * siguen sirviendo a los dispositivos enrolados aunque esté bloqueado.
54
56
  */
55
- export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log, onEnrollChallenge, isLocked = () => false, hasPassword = () => true, deriveAdminKey = null, forAdoption = false, onAdopted } = {}) {
57
+ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log, onEnrollChallenge, isLocked = () => false, hasPassword = () => true, deriveAdminKey = null, openKey = null, forAdoption = false, onAdopted } = {}) {
56
58
  ensureDir(dir)
57
59
  // CIFRADO EN REPOSO ligado a esta máquina: ningún archivo del dir queda en claro, así
58
60
  // que copiarlos a otro equipo no sirve de nada. La identidad se migra AQUÍ (verificando
@@ -64,7 +66,21 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
64
66
  const r = migrateFile(path.join(dir, 'identity.json'), kekFor(dir))
65
67
  if (r === 'migrado') log('[vault] identity encrypted at rest (bound to this machine)')
66
68
  } catch (e) { log('[vault] could not encrypt the identity at rest:', e.message) }
67
- const identity = await Identity.connect({ dir, atRest: atRestFor(dir) })
69
+ /**
70
+ * EL CANDADO DE LA MAESTRA. La mitad privada se guarda sellada con la llave que sale de
71
+ * la contraseña del perfil (`openKey`), no con la de la máquina: cerrada, la maestra no
72
+ * está en memoria y no hay con qué sacarla del disco.
73
+ *
74
+ * `open` devuelve `null` con el perfil cerrado, y el pilar entonces carga la identidad
75
+ * SIN con qué firmar — se sabe quién eres, no se puede hablar por ti. Un perfil sin
76
+ * contraseña no tiene `openKey`: se queda como estaba, bajo la llave de máquina, y la
77
+ * consola ya lo dice en voz alta.
78
+ */
79
+ const keyLock = {
80
+ seal: async (texto) => { const k = openKey?.(); return k ? encryptText(texto, k) : null },
81
+ open: async (blob) => { const k = openKey?.(); if (!k) return null; try { return decryptText(blob, k) } catch (_) { return null } }
82
+ }
83
+ const identity = await Identity.connect({ dir, atRest: atRestFor(dir), keyLock })
68
84
  // ESTE DIRECTORIO ES DE ESTA LLAVE. Es lo único que hay que proteger cuando varias
69
85
  // bóvedas viven en un mismo disco: cada una con el suyo, para que nunca se mezclen.
70
86
  assertKeyOwnsDir(dir, identity.me?.publickey || null)
@@ -81,6 +97,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
81
97
  // abre nada, así que se usa sin la frase; su autoridad se la da el acta, que la nombra
82
98
  // y que sella únicamente la maestra. Se estrena una por acta: aquí está el proveedor.
83
99
  const sealKeys = openSealKeys(dir)
100
+ const commKey = openCommKey(dir)
84
101
  identity.setSealKeyProvider?.(() => sealKeys.mint())
85
102
 
86
103
  const store = openStore(dir)
@@ -176,7 +193,12 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
176
193
  recipients: (owner) => recipientsOf(owner),
177
194
  // La FIRMA del sobre: dice que salió de esta bóveda y con qué acta (§8.8).
178
195
  signer: (body) => signSeal(body),
179
- defaultKey: () => new Uint8Array(kekFor(dir))
196
+ // CON QUÉ se cierra la copia de recuperación cuando quien llama no trajo la frase:
197
+ // la llave del perfil si está ABIERTO, y si no la de la máquina. El orden importa —
198
+ // un perfil CON contraseña no se abre con la llave de la máquina, así que sin lo
199
+ // primero un aparato enrolado con la bóveda abierta se quedaba sin su cajón y solo
200
+ // se enteraba al arrancar (dueño, 2026-08-31).
201
+ defaultKey: () => openKey?.() || new Uint8Array(kekFor(dir))
180
202
  })
181
203
  // SIN CONTRASEÑA NO HAY SECRETO. Escribir no la pide (sellar solo necesita públicas,
182
204
  // §8.1), pero la copia de recuperación —la que deja al dueño VER sus valores— se cierra
@@ -214,7 +236,38 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
214
236
  }
215
237
  } catch (e) { log('[vault] could not set up the sealing key:', e.message) }
216
238
 
217
- const { client } = await createTransport({ identity, dir, url: proxyUrl })
239
+ /**
240
+ * LA LLAVE DE COMUNICACIÓN, dentro del acta.
241
+ *
242
+ * La maestra sella el acta y reenvuelve sobres; hablar por la red no es suyo. Esta es la
243
+ * que se identifica ante el proxio, y su autoridad la dice el ACTA: entra como un miembro
244
+ * más, con `cn: 'vault'`, así que el acta la trata como un servicio del perfil — puede
245
+ * hablar por la bóveda, no firmar por la persona.
246
+ *
247
+ * Solo se puede meter con el perfil ABIERTO (admitir un miembro es sellar el acta, y eso
248
+ * es de la maestra). Con el perfil cerrado no se toca nada: si ya está, se usa; y si no,
249
+ * `identify` se repliega a la maestra y lo dice. Por eso una bóveda que se actualiza
250
+ * tiene que abrirse UNA vez, y a partir de ahí ya puede vivir cerrada.
251
+ */
252
+ try {
253
+ const info = await identity.profileActa?.()
254
+ const acta = info?.acta
255
+ if (acta && !identity.masterLocked) {
256
+ const pub = await commKey.ensure()
257
+ const yaEsta = (acta.members || []).some((m) => m?.pub === pub)
258
+ if (!yaEsta && info?.isMaster) {
259
+ await identity.admitMember({ pub, label: 'esta bóveda', cn: COMM_CN, caps: [...COMM_CAPS] })
260
+ log('[vault] this vault is now a member of its own record: it talks with its own key, not the master one')
261
+ }
262
+ }
263
+ } catch (e) { log('[vault] could not put the communication key in the record:', e.message) }
264
+
265
+ const { client } = await createTransport({ identity, dir, url: proxyUrl, commKey, log })
266
+
267
+ // El registro público de cadenas de selladores: deposita, si hay a dónde, los eslabones
268
+ // que le dicen a un tercero si esta cuenta sigue sellada por quien él cree. Ver
269
+ // `sealers.js` — es una comodidad para quien lee, nunca una dependencia de la bóveda.
270
+ startSealersPublisher({ identity, client, log })
218
271
 
219
272
  async function revocationSet () {
220
273
  const { revoked } = await identity.listDelegations()
@@ -321,9 +374,35 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
321
374
  *
322
375
  * @returns {boolean} `true` si puede seguir; si no, ya contestó el «no».
323
376
  */
377
+ /**
378
+ * CON QUÉ SE JUZGA UN PAPEL: el acta que tiene esta bóveda.
379
+ *
380
+ * Sustituye a `trustedIssuer: master`, que comparaba contra UNA llave fija y por eso el
381
+ * multivault no podía existir — una segunda selladora sellaba el acta y luego sus papeles
382
+ * los rechazaban los diez mostradores. Ahora se compara contra la lista que dice el acta.
383
+ *
384
+ * Sin acta se devuelven nulos a propósito: `verifyDelegation` responde `no-acta` y el
385
+ * mostrador deniega. Es lo contrario de un repliegue — no hay con qué decidir, así que no
386
+ * se decide que sí.
387
+ */
388
+ async function contextoActa () {
389
+ const acta = (await identity.profileActa?.().catch(() => null))?.acta || null
390
+ if (!acta) return { actaSeq: null, sealers: null }
391
+ return { actaSeq: acta.seq, sealers: Acta.sealersOf(acta) }
392
+ }
393
+
324
394
  async function actaAllows (from, chk, scope, what) {
325
395
  const record = (await identity.profileActa?.().catch(() => null))?.acta || null
326
- if (!record || Acta.memberCanScope(record, chk.device, scope)) return true
396
+ // SIN ACTA NO SE ATIENDE. Esto era `if (!record || puede())`, o sea: sin acta, pasa. Un
397
+ // repliegue no dice «por si falta el dato», dice «si falta el dato, di que sí» — y el
398
+ // acta falta justo cuando algo se rompió (un archivo a medias, un `catch` que devolvió
399
+ // null), que es cuando menos hay que fiarse. Toda cuenta tiene acta desde hace versiones.
400
+ if (!record) {
401
+ audit('rejected', { what, reason: 'sin-acta' })
402
+ reply(from, { type: MSG.ERROR, error: 'unauthorized: this vault has no record to decide with' })
403
+ return false
404
+ }
405
+ if (Acta.memberCanScope(record, chk.device, scope)) return true
327
406
  audit('rejected', { what, reason: 'acta' })
328
407
  reply(from, { type: MSG.ERROR, error: 'unauthorized: acta — this member no longer has that permission' })
329
408
  return false
@@ -334,7 +413,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
334
413
  if (!isFresh(p.data)) { audit('rejected', { what: 'sign', reason: 'stale' }); return staleReply(from) }
335
414
  const chk = await verifyChain({
336
415
  data: p.data, signature: p.signature, cert: p.cert,
337
- expectedScope: SCOPE.SIGN, trustedIssuer: master, revoked: await revocationSet()
416
+ expectedScope: SCOPE.SIGN, ...(await contextoActa()), revoked: await revocationSet()
338
417
  })
339
418
  if (!chk.ok) return denyChain(from, chk, p, 'sign')
340
419
 
@@ -370,7 +449,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
370
449
  if (!isFresh(p.data)) return staleReply(from)
371
450
  const chk = await verifyChain({
372
451
  data: p.data, signature: p.signature, cert: p.cert,
373
- expectedScope: SCOPE.READ, trustedIssuer: master, revoked: await revocationSet()
452
+ expectedScope: SCOPE.READ, ...(await contextoActa()), revoked: await revocationSet()
374
453
  })
375
454
  if (!chk.ok) return denyChain(from, chk, p, 'get')
376
455
  if (!await actaAllows(from, chk, SCOPE.READ, 'get')) return
@@ -397,9 +476,9 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
397
476
  return reply(from, { type: MSG.ERROR, error: 'profile locked: unlock it on the vault machine (dotrino-vault unlock) to edit it' })
398
477
  }
399
478
  const revoked = await revocationSet()
400
- let chk = await verifyChain({ data: d, signature: p.signature, cert: p.cert, expectedScope: SCOPE.STORE, trustedIssuer: master, revoked })
479
+ let chk = await verifyChain({ data: d, signature: p.signature, cert: p.cert, expectedScope: SCOPE.STORE, ...(await contextoActa()), revoked })
401
480
  if (!chk.ok && STORE_READ_METHODS.has(d.method)) {
402
- chk = await verifyChain({ data: d, signature: p.signature, cert: p.cert, expectedScope: SCOPE.READ, trustedIssuer: master, revoked })
481
+ chk = await verifyChain({ data: d, signature: p.signature, cert: p.cert, expectedScope: SCOPE.READ, ...(await contextoActa()), revoked })
403
482
  }
404
483
  if (!chk.ok) return denyChain(from, chk, p, 'store')
405
484
  // El scope que valió es el que hay que preguntarle al acta: un método de lectura pudo
@@ -523,8 +602,12 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
523
602
 
524
603
  async function handleDevices (from, p) {
525
604
  if (!isFresh(p.data)) return staleReply(from)
526
- const chk = await verifyChain({ data: p.data, signature: p.signature, cert: p.cert, trustedIssuer: master, revoked: await revocationSet() })
605
+ const chk = await verifyChain({ data: p.data, signature: p.signature, cert: p.cert, ...(await contextoActa()), revoked: await revocationSet() })
527
606
  if (!chk.ok) return denyChain(from, chk, p, null)
607
+ // La lista de aparatos es el perfil: quién eres, cómo se llama cada máquina y qué puede.
608
+ // Este mostrador no preguntaba al acta, así que un aparato al que le quitaste `lee`
609
+ // seguía viendo tu inventario entero hasta que caducara su papel.
610
+ if (!await actaAllows(from, chk, SCOPE.READ, 'devices')) return
528
611
  const { issued, revoked } = await identity.listDelegations()
529
612
  // El acta viaja con la lista: así cada dispositivo se entera de los cambios de
530
613
  // política (quién manda, quién puede qué) sin un canal aparte.
@@ -582,7 +665,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
582
665
  const RENEW_TTL_MS = 30 * 24 * 60 * 60 * 1000
583
666
  async function handleRenew (from, p) {
584
667
  if (!isFresh(p.data)) { audit('rejected', { what: 'renew', reason: 'stale' }); return staleReply(from) }
585
- const chk = await verifyChain({ data: p.data, signature: p.signature, cert: p.cert, trustedIssuer: master, revoked: await revocationSet() })
668
+ const chk = await verifyChain({ data: p.data, signature: p.signature, cert: p.cert, ...(await contextoActa()), revoked: await revocationSet() })
586
669
  if (!chk.ok) return denyChain(from, chk, p, 'renew')
587
670
  // Reusar el label del cert original (si sigue registrado en delegations).
588
671
  const { issued } = await identity.listDelegations()
@@ -594,18 +677,23 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
594
677
  // QUITARLO tampoco surtía efecto hasta que el cert caducara, hasta un mes después.
595
678
  // Si el miembro ya no está en el acta, no se renueva nada: lo echaron.
596
679
  const record = (await identity.profileActa?.().catch(() => null))?.acta
597
- let scope = p.cert.scope
598
- if (record) {
599
- scope = Acta.memberScopes(record, p.cert.sub)
600
- if (!scope.length) {
601
- audit('rejected', { what: 'renew', device: await deviceIdOf(p.cert.sub), reason: 'not-a-member' })
602
- return reply(from, { type: MSG.ERROR, error: 'unauthorized: the record no longer lists this device' })
603
- }
680
+ // Sin acta no se renueva: caer al scope del papel viejo es reconceder a ciegas lo que
681
+ // el acta ya no dice. Un repliegue así no protege de nada, tapa.
682
+ if (!record) {
683
+ audit('rejected', { what: 'renew', device: await deviceIdOf(p.cert.sub), reason: 'sin-acta' })
684
+ return reply(from, { type: MSG.ERROR, error: 'unauthorized: this vault has no record to decide with' })
685
+ }
686
+ const scope = Acta.memberScopes(record, p.cert.sub)
687
+ if (!scope.length) {
688
+ audit('rejected', { what: 'renew', device: await deviceIdOf(p.cert.sub), reason: 'not-a-member' })
689
+ return reply(from, { type: MSG.ERROR, error: 'unauthorized: the record no longer lists this device' })
604
690
  }
605
691
  const { cert } = await identity.signDelegation(p.cert.sub, scope, { ttlMs: RENEW_TTL_MS, label: prev?.label || '' })
606
692
  audit('renew', { device: await deviceIdOf(p.cert.sub), label: prev?.label || '', scope })
607
693
  log(`[vault] cert renewed for ${await deviceIdOf(p.cert.sub)} (30 days)`)
608
- reply(from, { type: MSG.RENEWED, cert })
694
+ // El acta viaja con el papel: sin ella quien lo recibe no puede comprobar que lo firmó
695
+ // una selladora del perfil, que es lo que sustituyó a «lo firmó la maestra».
696
+ reply(from, { type: MSG.RENEWED, cert, acta: record })
609
697
  }
610
698
 
611
699
  // SECRETOS de servicios: un servicio enrolado (cert `vault:secrets:<ns>`)
@@ -628,9 +716,19 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
628
716
  if (!isValidSecretsNs(ns)) return reply(from, { type: MSG.ERROR, error: 'enckey: invalid namespace' })
629
717
  const chk = await verifyChain({
630
718
  data: p.data, signature: p.signature, cert: p.cert,
631
- expectedScope: secretsScope(ns), trustedIssuer: master, revoked: await revocationSet()
719
+ expectedScope: secretsScope(ns), ...(await contextoActa()), revoked: await revocationSet()
632
720
  })
633
721
  if (!chk.ok) return denyChain(from, chk, p, 'enckey')
722
+ // MISMA FRONTERA QUE SERVIR EL CAJÓN, y por la misma razón: esto no solo apunta una
723
+ // llave pública, acto seguido le ENVUELVE la del cajón (`spreadKey`, más abajo). Aquí
724
+ // solo se miraba el certificado, así que a un aparato al que el acta ya le había
725
+ // quitado `secrets` se le entregaba la llave igual, hasta 30 días. Es el mismo fallo
726
+ // que se cerró en `handleSign`, en otro mostrador.
727
+ const record = (await identity.profileActa?.().catch(() => null))?.acta
728
+ if (!record || !Acta.memberCanReadSecrets(record, chk.device, ns)) {
729
+ audit('rejected', { what: 'enckey', ns, reason: record ? 'cn' : 'sin-acta' })
730
+ return reply(from, { type: MSG.ERROR, error: `unauthorized: cn — the record does not recognise this member as the "${ns}" service` })
731
+ }
634
732
  try {
635
733
  await identity.setMemberEncPub({ pub: chk.device, encPub: p.data.encPub })
636
734
  audit('enckey', { device: await deviceIdOf(chk.device).catch(() => null), ns })
@@ -662,15 +760,15 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
662
760
  if (typeof p.data?.ek !== 'string') return reply(from, { type: MSG.ERROR, error: 'secrets: missing ek (requester ephemeral key)' })
663
761
  const chk = await verifyChain({
664
762
  data: p.data, signature: p.signature, cert: p.cert,
665
- expectedScope: secretsScope(ns), trustedIssuer: master, revoked: await revocationSet()
763
+ expectedScope: secretsScope(ns), ...(await contextoActa()), revoked: await revocationSet()
666
764
  })
667
765
  if (!chk.ok) return denyChain(from, chk, p, 'secrets')
668
766
  // FRONTERA DEL CN (acta): además del scope del cert, el acta tiene que decir que este
669
767
  // miembro es el servicio `ns`. Así el límite no depende solo de qué cert se emitió: la
670
768
  // llave del proxy no ve nada que no sea del proxy, y está escrito donde se puede comprobar.
671
769
  const record = (await identity.profileActa?.().catch(() => null))?.acta
672
- if (record && !Acta.memberCanReadSecrets(record, chk.device, ns)) {
673
- audit('rejected', { what: 'secrets', ns, reason: 'cn' })
770
+ if (!record || !Acta.memberCanReadSecrets(record, chk.device, ns)) {
771
+ audit('rejected', { what: 'secrets', ns, reason: record ? 'cn' : 'sin-acta' })
674
772
  return reply(from, { type: MSG.ERROR, error: `unauthorized: cn — the record does not recognise this member as the "${ns}" service` })
675
773
  }
676
774
  // APROBACIÓN: si este APARATO está marcado (`dotrino-vault approval <ID> on`), liberarle
@@ -748,12 +846,12 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
748
846
  const op = p.data?.op
749
847
  const chk = await verifyChain({
750
848
  data: p.data, signature: p.signature, cert: p.cert,
751
- expectedScope: SCOPE.APPROVE, trustedIssuer: master, revoked: await revocationSet()
849
+ expectedScope: SCOPE.APPROVE, ...(await contextoActa()), revoked: await revocationSet()
752
850
  })
753
851
  if (!chk.ok) return denyChain(from, chk, p, 'approval')
754
852
  const record = (await identity.profileActa?.().catch(() => null))?.acta
755
- if (record && !Acta.memberCan(record, chk.device, 'approve')) {
756
- audit('rejected', { what: 'approval', reason: 'acta' })
853
+ if (!record || !Acta.memberCan(record, chk.device, 'approve')) {
854
+ audit('rejected', { what: 'approval', reason: record ? 'acta' : 'sin-acta' })
757
855
  return reply(from, { type: MSG.ERROR, error: 'unauthorized: acta — this member does not approve' })
758
856
  }
759
857
  const by = await deviceIdOf(chk.device).catch(() => null)
@@ -1197,8 +1295,10 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
1197
1295
  const admin = createAdminDesk({
1198
1296
  desk,
1199
1297
  deviceIdOf,
1200
- ttlMs: DEVICE_TTL_MS,
1201
1298
  audit,
1299
+ // El MISMO candado que frena firmar y editar el perfil: revocar reescribe el acta y
1300
+ // configurar toca los secretos, y ninguna de las dos se hace con la bóveda cerrada.
1301
+ isLocked,
1202
1302
  notify: notifyMembers,
1203
1303
  readActivity,
1204
1304
  vars: varsDesk,
@@ -1209,7 +1309,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
1209
1309
  verify: async ({ data, signature, cert }) => {
1210
1310
  const chk = await verifyChain({
1211
1311
  data, signature, cert,
1212
- expectedScope: SCOPE.ADMIN, trustedIssuer: master, revoked: await revocationSet()
1312
+ expectedScope: SCOPE.ADMIN, ...(await contextoActa()), revoked: await revocationSet()
1213
1313
  })
1214
1314
  // Quitarse a UNO MISMO desde la consola remota entra por aquí: la segunda petición
1215
1315
  // que mande el aparato ya llega con el certificado retirado. Que se entere con el
@@ -1219,7 +1319,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
1219
1319
  return chk
1220
1320
  }
1221
1321
  const record = (await identity.profileActa?.().catch(() => null))?.acta
1222
- if (record && !Acta.memberCan(record, chk.device, 'admin')) return { ok: false, reason: 'acta' }
1322
+ if (!record || !Acta.memberCan(record, chk.device, 'admin')) return { ok: false, reason: record ? 'acta' : 'sin-acta' }
1223
1323
  return chk
1224
1324
  }
1225
1325
  })
@@ -1257,7 +1357,9 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
1257
1357
  // handlers); el resto de la regla vive en el módulo puro.
1258
1358
  if (!isFresh(p.data)) return staleReply(from)
1259
1359
  const r = await admin.handle(p.data, { signature: p.signature, cert: p.cert })
1260
- if (!r.ok) return reply(from, { type: MSG.ERROR, error: r.error })
1360
+ // El `code` viaja aparte del texto: el texto está en inglés y puede cambiar, el código
1361
+ // es lo que empareja quien llama (`vault-locked` ≠ «no autorizado»).
1362
+ if (!r.ok) return reply(from, { type: MSG.ERROR, error: r.error, ...(r.code ? { code: r.code } : {}) })
1261
1363
  reply(from, { type: MSG.ADMIN_RESULT, op: p.data.op, result: r.result })
1262
1364
  }
1263
1365
 
@@ -1891,6 +1993,25 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
1891
1993
 
1892
1994
  return {
1893
1995
  identity, client, store, threads, secrets, master, fingerprint: fp, dir,
1996
+ /**
1997
+ * SOLTAR LA MAESTRA. Cerrar el perfil tiene que sacarla de la memoria, no solo dejar de
1998
+ * cargarla en el siguiente arranque: si no, el candado seguiría siendo una bandera al
1999
+ * lado de una llave descifrada, que es exactamente lo que se quitó de en medio.
2000
+ *
2001
+ * Se recarga el par: sin la llave del perfil el pilar lo deja SIN privada, así que a
2002
+ * partir de aquí firmar se niega con `vault-locked`. Servir sigue igual — eso lo hace la
2003
+ * llave de comunicación, que no depende de la contraseña.
2004
+ */
2005
+ async dropMasterKey () {
2006
+ const r = await identity.reloadMasterKey?.()
2007
+ return { locked: r?.locked !== false }
2008
+ },
2009
+ /** Al abrir: recuperar la maestra y, si venía en claro de antes, dejarla sellada. */
2010
+ async takeMasterKey () {
2011
+ const r = await identity.reloadMasterKey?.()
2012
+ if (r?.locked === false) { try { await identity.sealMasterKey?.() } catch (_) {} }
2013
+ return { locked: r?.locked !== false }
2014
+ },
1894
2015
  startPairing: desk.startPairing,
1895
2016
  stopPairing: desk.stopPairing,
1896
2017
  listPending: desk.listPending,