@dotrino/vaultd 0.38.0 → 0.49.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
@@ -22,6 +22,10 @@ import { createTransport, masterPubkeyOf } from './transport.js'
22
22
  import { openStore } from './store.js'
23
23
  import { openThreadStore, STORE_READ_METHODS, PROFILE_EDIT_METHODS } from './threadStore.js'
24
24
  import { openSecretsStore, assertVar } from './secretsStore.js'
25
+ import { createApprovals } from './approvals.js'
26
+ import { parsePublicKey, sshSignature } from './sshKeys.js'
27
+ import { makeSealer } from './sealer.js'
28
+ import { openSealKeys } from './sealKey.js'
25
29
  import { seal } from '../lib/src/sealed.js'
26
30
  import { dataDir, ensureDir } from './paths.js'
27
31
  import { atRestFor, machineKey, migrateFile } from './atrest.js'
@@ -37,7 +41,7 @@ import { MSG, SCOPE, secretsScope, isValidSecretsNs } from './protocol.js'
37
41
  * Solo bloquea EDITAR el perfil (`profileSet`): firmar/leer y el resto del store
38
42
  * siguen sirviendo a los dispositivos enrolados aunque esté bloqueado.
39
43
  */
40
- export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log, onEnrollChallenge, isLocked = () => false, forAdoption = false, onAdopted } = {}) {
44
+ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log, onEnrollChallenge, isLocked = () => false, hasPassword = () => true, deriveAdminKey = null, forAdoption = false, onAdopted } = {}) {
41
45
  ensureDir(dir)
42
46
  // CIFRADO EN REPOSO ligado a esta máquina: ningún archivo del dir queda en claro, así
43
47
  // que copiarlos a otro equipo no sirve de nada. La identidad se migra AQUÍ (verificando
@@ -59,12 +63,62 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
59
63
  try { await identity.prepareForAdoption() } catch (e) { log('[vault] could not prepare the profile for adoption:', e.message) }
60
64
  }
61
65
 
66
+ // LA LLAVE DE SELLADO (§8.8/§8.9): con ella se FIRMAN los sobres de los secretos. No
67
+ // abre nada, así que se usa sin la frase; su autoridad se la da el acta, que la nombra
68
+ // y que sella únicamente la maestra. Se estrena una por acta: aquí está el proveedor.
69
+ const sealKeys = openSealKeys(dir)
70
+ identity.setSealKeyProvider?.(() => sealKeys.mint())
71
+
62
72
  const store = openStore(dir)
63
73
  const threads = openThreadStore(dir)
64
- const secrets = openSecretsStore(dir)
74
+ const approvals = createApprovals()
75
+ const approvalsSweeper = setInterval(() => { for (const g of approvals.sweep()) audit(g.kind === 'ssh' ? 'ssh.expired' : 'secrets.expired', { id: g.id, ns: g.ns, device: g.deviceId }) }, 30 * 1000); approvalsSweeper.unref?.()
76
+ const secrets = openSecretsStore(dir, {
77
+ sealer: makeSealer(),
78
+ // A QUIÉN se le envuelve la llave de cada cajón: los servicios de ese namespace (o el
79
+ // propio aparato, si el cajón es suyo) MÁS los aparatos que administran. Sale del
80
+ // acta, y por eso lo pone el vault: el store no conoce el acta.
81
+ recipients: (owner) => recipientsOf(owner),
82
+ // La FIRMA del sobre: dice que salió de esta bóveda y con qué acta (§8.8).
83
+ signer: (body) => signSeal(body),
84
+ defaultKey: () => new Uint8Array(machineKey(dir))
85
+ })
86
+ // SIN CONTRASEÑA NO HAY SECRETO. Escribir no la pide (sellar solo necesita públicas,
87
+ // §8.1), pero la copia de recuperación —la que deja al dueño VER sus valores— se cierra
88
+ // con ella. Sin contraseña se cae a la llave de la máquina, que es la protección de
89
+ // siempre, pero su material vive en este mismo disco: una copia del disco lo abre.
90
+ // Se dice en voz alta: prometer una protección que no está puesta es peor que no
91
+ // tenerla (docs/secretos-sellados.md §2.3).
92
+ try {
93
+ if (!hasPassword()) {
94
+ log('[vault] this profile has NO password: private variables are sealed with a key derived from this machine,')
95
+ log('[vault] so a copy of this disk opens them. Set one with `dotrino-vault profile password`.')
96
+ }
97
+ } catch (_) {}
98
+
65
99
  const master = await masterPubkeyOf(identity)
66
100
  const fp = (await pubkeyId(master)).slice(0, 16)
67
101
 
102
+ /**
103
+ * El acta tiene que nombrar una llave de sellado QUE SEA NUESTRA. Si no nombra ninguna
104
+ * (un acta de antes de esto) o nombra una cuya privada no tenemos (el disco se
105
+ * restauró, o el acta la selló otro master), se estrena: firmar sobres es de esta
106
+ * máquina y no puede quedar a medias.
107
+ *
108
+ * Solo lo intenta el master —es el único que sella actas— y no bloquea el arranque: sin
109
+ * llave los sobres salen sin firma, que es lo que ya pasaba antes de §8.8.
110
+ */
111
+ try {
112
+ const info = await identity.profileActa?.()
113
+ const acta = info?.acta
114
+ if (info?.isMaster && acta && (!acta.sealPub || !sealKeys.has(acta.sealPub))) {
115
+ const r = await identity.rotateSealKey()
116
+ // Sin `%s`: este `log` va con un prefijo por delante, así que el formato no es lo
117
+ // primero y `console.log` no lo sustituye (salía «record #%s 2»).
118
+ log(`[vault] new sealing key in record #${r.seq}`)
119
+ }
120
+ } catch (e) { log('[vault] could not set up the sealing key:', e.message) }
121
+
68
122
  const { client } = await createTransport({ identity, dir, url: proxyUrl })
69
123
 
70
124
  async function revocationSet () {
@@ -138,6 +192,15 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
138
192
  const n = secrets.forgetDevice(sub)
139
193
  if (n) log(`[vault] dropped ${n} variable(s) of the removed device`)
140
194
  } catch (e) { log('[vault] could not drop the device variables:', e.message) }
195
+ // Su cajón propio se va entero y eso es inmediato y completo (estaba sellado solo
196
+ // a él). Lo que comparte —la CEK de su namespace— hay que ROTARLO, porque quitarle
197
+ // la envoltura no basta: si guardó la CEK sigue abriendo todo lo cifrado con ella.
198
+ //
199
+ // Pero rotar exige la contraseña, y quitar un aparato es el interruptor de
200
+ // emergencia: el gesto que se hace desde el teléfono cuando se perdió una máquina.
201
+ // Un interruptor que pide una frase que quizá no tienes a mano no es un
202
+ // interruptor. Así que se INTENTA, y si no se puede queda anotado y a la vista.
203
+ markRotationDue(sub).catch((e) => log('[vault] could not rotate after the removal:', e.message))
141
204
  },
142
205
  defaultScope: [SCOPE.READ],
143
206
  onChallenge ({ deviceId, scope }) {
@@ -388,8 +451,46 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
388
451
  // servicio verifica contra su iss pineada — un relay no puede inyectar
389
452
  // secretos falsos). Replay inerte: cada petición usa una ek nueva.
390
453
  // data: { op:'secrets', ns, ek, publickey, ts }
454
+ /**
455
+ * Un servicio que se enroló antes de que existieran las llaves de cifrado registra la
456
+ * suya. Es la alternativa a re-enrolarlo: re-enrolar le cambia la pubkey, y su cajón
457
+ * de variables va indexado por ella — se quedaría sin configuración sin decirlo.
458
+ */
459
+ async function handleEncKey (from, p) {
460
+ const ns = p.data?.ns
461
+ if (!isValidSecretsNs(ns)) return reply(from, { type: MSG.ERROR, error: 'enckey: invalid namespace' })
462
+ const chk = await verifyChain({
463
+ data: p.data, signature: p.signature, cert: p.cert,
464
+ expectedScope: secretsScope(ns), trustedIssuer: master, revoked: await revocationSet()
465
+ })
466
+ if (!chk.ok) return denyChain(from, chk, p, 'enckey')
467
+ try {
468
+ await identity.setMemberEncPub({ pub: chk.device, encPub: p.data.encPub })
469
+ audit('enckey', { device: await deviceIdOf(chk.device).catch(() => null), ns })
470
+ log(`[vault] ${ns}: encryption key registered for ${await deviceIdOf(chk.device).catch(() => '????-????')}`)
471
+ // Ya puede recibir sobres: se le envuelve la llave de su cajón en el acto, o
472
+ // seguiría sin poder abrir nada hasta la siguiente escritura.
473
+ await spreadKey(`ns:${ns}`, await nsMembers(ns)).catch((e) => log('[vault] could not hand it the key:', e.message))
474
+ const body = { op: 'secrets.result', ns, enc: null, ok: true, ts: Date.now() }
475
+ const { signature } = await identity.signData(body)
476
+ reply(from, { type: MSG.SECRETS_RESULT, body, signature })
477
+ } catch (e) {
478
+ reply(from, { type: MSG.ERROR, error: 'enckey: ' + e.message })
479
+ }
480
+ }
481
+
391
482
  async function handleSecrets (from, p) {
392
483
  if (!isFresh(p.data)) { audit('rejected', { what: 'secrets', reason: 'stale' }); return staleReply(from) }
484
+ // REGISTRAR LA LLAVE DE CIFRADO de un servicio ya enrolado. Va por aquí, y no por
485
+ // un mensaje nuevo, para no tocar `protocol.js` — que está vendorizado en el iframe
486
+ // de identidad y obligaría a re-vendorizar.
487
+ //
488
+ // No exige la contraseña del perfil, y el argumento importa: registrar una llave no
489
+ // da acceso a nada por sí solo. Quien firma esta petición ya tiene la llave de firma
490
+ // del servicio y su cert, o sea que ya lee ese namespace. No hay escalada.
491
+ if (p.data?.op === 'enckey') return handleEncKey(from, p)
492
+ if (['approvals', 'approve', 'deny', 'ssh.keys', 'ssh.key.add', 'ssh.key.rm'].includes(p.data?.op)) return handleApproval(from, p)
493
+ if (p.data?.op === 'ssh.sign' || p.data?.op === 'ssh.keys.public') return handleSshRemote(from, p)
393
494
  const ns = p.data?.ns
394
495
  if (!isValidSecretsNs(ns)) return reply(from, { type: MSG.ERROR, error: 'secrets: invalid namespace' })
395
496
  if (typeof p.data?.ek !== 'string') return reply(from, { type: MSG.ERROR, error: 'secrets: missing ek (requester ephemeral key)' })
@@ -406,16 +507,202 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
406
507
  audit('rejected', { what: 'secrets', ns, reason: 'cn' })
407
508
  return reply(from, { type: MSG.ERROR, error: `unauthorized: cn — the record does not recognise this member as the "${ns}" service` })
408
509
  }
409
- let enc
410
- try {
411
- enc = await seal({ ek: p.data.ek, payload: { secrets: secrets.get(ns, chk.device) } })
412
- } catch (e) {
510
+ // APROBACIÓN POR USO: si el cajón la exige y este aparato no tiene la ventana abierta,
511
+ // el pedido se apunta, se avisa a quien aprueba y se contesta «pendiente». La respuesta
512
+ // de verdad sale cuando el teléfono firme (`handleApproval`), sellada a la misma `ek`.
513
+ if (secrets.policyOf(ns).approval && !approvals.has(ns, chk.device)) {
514
+ const deviceId = await deviceIdOf(chk.device).catch(() => null)
515
+ const label = (record?.members || []).find((m) => m.pub === chk.device)?.label || ''
516
+ const pend = approvals.request({ ns, device: chk.device, deviceId, label, ek: p.data.ek })
517
+ audit('secrets.pending', { device: deviceId, ns, id: pend.id })
518
+ log(`[vault] ${ns}: ${deviceId || '????-????'} is waiting for approval (${pend.id})`)
519
+ const body = { op: 'secrets.pending', ns, id: pend.id, exp: pend.exp, ts: Date.now() }
520
+ const { signature } = await identity.signData(body)
521
+ reply(from, { type: MSG.SECRETS_RESULT, body, signature })
522
+ await notifyApprovers(pend, record)
523
+ return
524
+ }
525
+ let res
526
+ try { res = await resultFor(ns, chk.device, p.data.ek, record) } catch (e) {
413
527
  return reply(from, { type: MSG.ERROR, error: 'secrets: invalid ek' })
414
528
  }
529
+ audit('secrets', { device: await deviceIdOf(chk.device), ns })
530
+ reply(from, { type: MSG.SECRETS_RESULT, ...res })
531
+ }
532
+
533
+ /** El bundle de `ns` para `devicePub`, sellado a su `ek` y firmado por la maestra. */
534
+ async function resultFor (ns, devicePub, ek, record) {
535
+ // Mientras el archivo siga en v3 el cable NO cambia: se mandan los valores como
536
+ // siempre. Solo tras la migración viajan sobres, y entonces quien los abre es el
537
+ // agente con su llave. Así el despliegue del daemon se deshace con un reinicio,
538
+ // porque hasta el primer desbloqueo no ha cambiado nada de lo que ve nadie.
539
+ const b = secrets.bundleFor(ns, devicePub)
540
+ // EL ACTA VIAJA CON EL BUNDLE (§8.8): es lo que le permite al agente comprobar que
541
+ // los sobres los selló esta bóveda, y con qué llave —la que el acta nombra para el
542
+ // `seq` con el que se firmaron—. No es un dato secreto: el acta es pública dentro
543
+ // del perfil y el agente ya es miembro. Sin ella podría abrir igual, pero no sabría
544
+ // de dónde salió lo que abre.
545
+ const payload = b.legacy
546
+ ? { secrets: Object.fromEntries(Object.entries(b.entries).map(([k, e]) => [k, e.v])) }
547
+ : { sealed: b, acta: record || null }
548
+ const enc = await seal({ ek, payload })
415
549
  const body = { op: 'secrets.result', ns, enc, ts: Date.now() }
416
550
  const { signature } = await identity.signData(body)
417
- audit('secrets', { device: await deviceIdOf(chk.device), ns })
418
- reply(from, { type: MSG.SECRETS_RESULT, body, signature })
551
+ return { body, signature }
552
+ }
553
+
554
+ // ---------- LLAVES SSH DEL TELÉFONO (`sshKeys.js`) ----------
555
+ // Aquí solo viven las PÚBLICAS: la privada está en el aparato que aprueba. `ssh` en este
556
+ // PC habla con el agente del daemon (`sshAgent.js`), y cada firma es un pedido más.
557
+ const sshKeysFile = path.join(dir, 'ssh-keys.json')
558
+ const atRest = atRestFor(dir)
559
+ const readSshKeys = () => { const d = readJsonAt(sshKeysFile); return Array.isArray(d?.keys) ? d.keys : [] }
560
+ function readJsonAt (file) { try { return JSON.parse(atRest.decrypt(fs.readFileSync(file, 'utf8'))) } catch (_) { return null } }
561
+ const writeSshKeys = (keys) => fs.writeFileSync(sshKeysFile, atRest.encrypt(JSON.stringify({ v: 1, keys })), { mode: 0o600 })
562
+ const sshKeys = () => readSshKeys().map((k) => ({ id: k.id, type: k.type, blob: k.blob, comment: k.comment, deviceId: k.deviceId, addedAt: k.addedAt }))
563
+
564
+ /**
565
+ * FIRMAR UN RETO SSH: lo pide el agente local (el `ssh` del usuario) y lo resuelve el
566
+ * teléfono. No hay ventana: cada conexión es un «sí» — para no repetirlo veinte veces
567
+ * está `ControlMaster` en el `ssh_config`, que reusa la conexión 15 min.
568
+ */
569
+ function requestSshSign ({ keyId, data, askedBy = null, onPending = null }) {
570
+ const key = readSshKeys().find((k) => k.id === keyId)
571
+ if (!key) return Promise.reject(new Error('ssh: unknown key'))
572
+ return new Promise((resolve, reject) => {
573
+ const pend = approvals.request({
574
+ kind: 'ssh', ns: 'ssh', deviceId: askedBy || (fp.slice(0, 4).toUpperCase() + '-' + fp.slice(4, 8).toUpperCase()), label: 'ssh',
575
+ ssh: { key: key.id, comment: key.comment, data: Buffer.from(data).toString('base64') },
576
+ resolve, reject
577
+ })
578
+ try { onPending?.(pend) } catch (_) {}
579
+ audit('ssh.pending', { key: key.id, id: pend.id, device: askedBy || null })
580
+ log(`[vault] ssh: ${key.comment || key.id} is waiting for the phone to sign (${pend.id})`)
581
+ identity.profileActa?.().catch(() => null).then((r) => notifyApprovers(pend, r?.acta))
582
+ })
583
+ }
584
+
585
+ /**
586
+ * AGENTE SSH DELGADO en otra máquina (`dotrino-env ssh-agent`): un aparato con `vault:sign`
587
+ * pide que un reto se firme con una llave del teléfono. No hay nada que proteger en el
588
+ * que pide: solo convierte el reto en un pedido; quien firma es el teléfono.
589
+ */
590
+ async function handleSshRemote (from, p) {
591
+ const chk = await verifyChain({
592
+ data: p.data, signature: p.signature, cert: p.cert,
593
+ expectedScope: SCOPE.SIGN, trustedIssuer: master, revoked: await revocationSet()
594
+ })
595
+ if (!chk.ok) return denyChain(from, chk, p, 'ssh')
596
+ const answer = async (body) => {
597
+ body = { ...body, ts: Date.now() }
598
+ const { signature } = await identity.signData(body)
599
+ reply(from, { type: MSG.SECRETS_RESULT, body, signature })
600
+ }
601
+ if (p.data.op === 'ssh.keys.public') return answer({ op: 'ssh.keys.public', items: sshKeys().map((k) => ({ id: k.id, blob: k.blob, comment: k.comment })) })
602
+ const key = readSshKeys().find((k) => k.id === p.data.key)
603
+ if (!key || typeof p.data.data !== 'string') return reply(from, { type: MSG.ERROR, error: 'ssh: unknown key' })
604
+ const asker = await deviceIdOf(chk.device).catch(() => null)
605
+ let pendId = null
606
+ const done = requestSshSign({ keyId: key.id, data: Buffer.from(p.data.data, 'base64'), askedBy: asker, onPending: (pend) => { pendId = pend.id } })
607
+ await answer({ op: 'ssh.pending', id: pendId, exp: Date.now() + 5 * 60 * 1000 })
608
+ try {
609
+ const sig = await done
610
+ await answer({ op: 'ssh.sign.result', sig: Buffer.from(sig).toString('base64') })
611
+ } catch (e) { reply(from, { type: MSG.ERROR, error: e.message }) }
612
+ }
613
+
614
+ /**
615
+ * PEDIDOS DE APROBACIÓN (cajones con `approval`). Entran por `vault.secrets` con
616
+ * `op: approvals | approve | deny`, firmados por un aparato con `vault:approve` — que,
617
+ * como `admin`, no se empareja: se concede a mano (`caps <ID> +aprueba`). El acta tiene
618
+ * que decirlo también, para que quitar el permiso surta efecto en el acto.
619
+ */
620
+ async function handleApproval (from, p) {
621
+ const op = p.data?.op
622
+ const chk = await verifyChain({
623
+ data: p.data, signature: p.signature, cert: p.cert,
624
+ expectedScope: SCOPE.APPROVE, trustedIssuer: master, revoked: await revocationSet()
625
+ })
626
+ if (!chk.ok) return denyChain(from, chk, p, 'approval')
627
+ const record = (await identity.profileActa?.().catch(() => null))?.acta
628
+ if (record && !Acta.memberCan(record, chk.device, 'approve')) {
629
+ audit('rejected', { what: 'approval', reason: 'acta' })
630
+ return reply(from, { type: MSG.ERROR, error: 'unauthorized: acta — this member does not approve' })
631
+ }
632
+ const by = await deviceIdOf(chk.device).catch(() => null)
633
+ const answer = async (body) => {
634
+ body = { ...body, ts: Date.now() }
635
+ const { signature } = await identity.signData(body)
636
+ reply(from, { type: MSG.SECRETS_RESULT, body, signature })
637
+ }
638
+ if (op === 'approvals') return answer({ op: 'approvals', items: approvals.list() })
639
+ if (op === 'ssh.keys') return answer({ op: 'ssh.keys', items: sshKeys() })
640
+ if (op === 'ssh.key.add') {
641
+ let k
642
+ try { k = parsePublicKey(p.data?.pub) } catch (e) { return reply(from, { type: MSG.ERROR, error: e.message }) }
643
+ const keys = readSshKeys().filter((x) => x.id !== k.id)
644
+ keys.push({ ...k, deviceId: by, addedAt: Date.now() })
645
+ writeSshKeys(keys)
646
+ audit('ssh.key.add', { key: k.id, by })
647
+ log(`[vault] ssh: key ${k.id} (${k.comment || 'no comment'}) registered by ${by}`)
648
+ return answer({ op: 'ssh.key.add', id: k.id, ok: true })
649
+ }
650
+ if (op === 'ssh.key.rm') {
651
+ const keys = readSshKeys(); const n = keys.length
652
+ writeSshKeys(keys.filter((x) => x.id !== p.data?.id))
653
+ if (keys.length !== n) audit('ssh.key.rm', { key: p.data?.id, by })
654
+ return answer({ op: 'ssh.key.rm', ok: true })
655
+ }
656
+ const id = typeof p.data?.id === 'string' ? p.data.id : ''
657
+ const pend = approvals.take(id)
658
+ if (!pend) return reply(from, { type: MSG.ERROR, error: 'approval: unknown or expired request' })
659
+ if (pend.kind === 'ssh') {
660
+ if (op === 'deny') {
661
+ audit('ssh.denied', { key: pend.ssh.key, id, by })
662
+ try { pend.reject?.(new Error('ssh: denied from ' + by)) } catch (_) {}
663
+ return answer({ op: 'deny.result', id, ok: true })
664
+ }
665
+ if (op !== 'approve') return reply(from, { type: MSG.ERROR, error: 'approval: unknown op' })
666
+ const key = readSshKeys().find((k) => k.id === pend.ssh.key)
667
+ let blob
668
+ try { blob = sshSignature({ jwk: key.jwk, data: Buffer.from(pend.ssh.data, 'base64'), rawSig: p.data?.sig }) } catch (e) {
669
+ try { pend.reject?.(e) } catch (_) {}
670
+ audit('rejected', { what: 'ssh.sign', reason: e.message })
671
+ return reply(from, { type: MSG.ERROR, error: e.message })
672
+ }
673
+ audit('ssh.signed', { key: key.id, id, by })
674
+ log(`[vault] ssh: ${key.comment || key.id} signed by ${by}`)
675
+ try { pend.resolve?.(blob) } catch (_) {}
676
+ return answer({ op: 'approve.result', id, ok: true })
677
+ }
678
+ if (op === 'deny') {
679
+ audit('secrets.denied', { device: pend.deviceId, ns: pend.ns, id, by })
680
+ log(`[vault] ${pend.ns}: request of ${pend.deviceId} DENIED by ${by}`)
681
+ try { client.sendByPubkey(pend.device, { type: MSG.ERROR, error: `unauthorized: denied — the "${pend.ns}" request was denied from ${by}` }) } catch (_) {}
682
+ return answer({ op: 'deny.result', id, ok: true })
683
+ }
684
+ if (op !== 'approve') return reply(from, { type: MSG.ERROR, error: 'approval: unknown op' })
685
+ const exp = approvals.grant(pend.ns, pend.device)
686
+ let res
687
+ try { res = await resultFor(pend.ns, pend.device, pend.ek, record) } catch (e) {
688
+ return reply(from, { type: MSG.ERROR, error: 'approval: could not seal the reply: ' + e.message })
689
+ }
690
+ audit('secrets.approved', { device: pend.deviceId, ns: pend.ns, id, by, until: exp })
691
+ log(`[vault] ${pend.ns}: request of ${pend.deviceId} approved by ${by} (window until ${new Date(exp).toISOString()})`)
692
+ // Va por `sendByPubkey`: si el que pedía ya no está conectado, lo recoge al volver.
693
+ try { client.sendByPubkey(pend.device, { type: MSG.SECRETS_RESULT, ...res }) } catch (_) {}
694
+ return answer({ op: 'approve.result', id, ok: true, exp })
695
+ }
696
+
697
+ /** Aviso a los aparatos que aprueban (cola del proxio → push nativo si están apagados). */
698
+ async function notifyApprovers (pend, record) {
699
+ try {
700
+ const body = { ev: 'approval', id: pend.id, ns: pend.ns, deviceId: pend.deviceId, label: pend.label, exp: pend.exp, ts: Date.now() }
701
+ const { signature } = await identity.signData(body)
702
+ const who = (record?.members || []).filter((m) => Acta.memberCan(record, m.pub, 'approve')).map((m) => m.pub)
703
+ if (!who.length) log(`[vault] ${pend.ns}: nobody can approve (grant it with: dotrino-vault caps <ID> +aprueba)`)
704
+ for (const pub of who) { try { client.sendByPubkey(pub, { type: MSG.ADMIN_EVENT, body, signature }) } catch (_) {} }
705
+ } catch (e) { log('[vault] could not notify approvers:', e.message) }
419
706
  }
420
707
 
421
708
  client.on('message', async (from, payload) => {
@@ -431,6 +718,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
431
718
  if (payload.type === MSG.DEVICES) return await handleDevices(from, payload)
432
719
  if (payload.type === MSG.RENEW) return await handleRenew(from, payload)
433
720
  if (payload.type === MSG.SECRETS) return await handleSecrets(from, payload)
721
+ if (payload.type === MSG.REWRAP_OK) return await handleRewrapOk(payload)
434
722
  if (payload.type === MSG.ADMIN) return await handleAdmin(from, payload)
435
723
  if (payload.type === MSG.RENOUNCE) return await handleRenounce(from, payload)
436
724
  } catch (e) {
@@ -577,6 +865,24 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
577
865
  * 2. **Lo que sale, sale CIFRADO** con la clave de contenido del perfil: el proxy
578
866
  * transporta el sobre y no ve nada. Igual que el contenido del usuario (`store`).
579
867
  */
868
+ /**
869
+ * La llave de administración a partir de lo que venga en el sobre de la consola
870
+ * remota. `undefined` si no trae contraseña — entonces el store cae a la de la
871
+ * máquina, que es lo correcto para un perfil que no tiene ninguna.
872
+ *
873
+ * La contraseña no se guarda: se deriva, se usa y se suelta con el sobre.
874
+ */
875
+ /**
876
+ * La llave derivada de la contraseña, si el sobre la traía. Desde §8 solo la piden las
877
+ * operaciones que LEEN (ver un valor, cambiar su visibilidad): escribir no.
878
+ */
879
+ async function adminKeyFrom (payload) {
880
+ const pwd = payload?.password
881
+ if (typeof pwd !== 'string' || !pwd) return undefined
882
+ if (typeof deriveAdminKey !== 'function') return undefined
883
+ return deriveAdminKey(pwd)
884
+ }
885
+
580
886
  const varsDesk = {
581
887
  async list () {
582
888
  // `listSecrets`/`listDeviceSecrets` ya traen el valor de las públicas y solo de esas:
@@ -585,16 +891,30 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
585
891
  // aprender cómo se llaman tus variables ni qué servicios corres.
586
892
  return {
587
893
  enc: await identity.sealContent(JSON.stringify({
588
- ns: listSecrets(), dev: await listDeviceSecrets()
894
+ ns: listSecrets(), dev: await listDeviceSecrets(),
895
+ // Aparatos que están en el acta pero no pueden abrir lo suyo: hay que
896
+ // enseñarlo donde se administra, no solo en el log del servicio.
897
+ incomplete: await incompleteMembers(),
898
+ // Lo que quedó a deber un sellado: quien administra a distancia tiene que
899
+ // poder verlo, porque es exactamente lo que él puede saldar (escribiendo una
900
+ // variable con la contraseña) y la bóveda no.
901
+ pending: await secretDebts(),
902
+ // Y si el perfil NO tiene contraseña, decirlo: sus privadas se abren con la
903
+ // llave de la máquina de la bóveda, cuyo material vive en ese mismo disco.
904
+ // El comentario de aquí decía «la consola lo dice en voz alta» y la consola
905
+ // no decía nada — el mismo error que el comentario mentiroso de `atrest.js`.
906
+ hasPassword: (() => { try { return !!hasPassword() } catch (_) { return true } })()
589
907
  }))
590
908
  }
591
909
  },
592
- async set ({ ns, pub, key, enc, public: isPublic }) {
910
+ async set ({ ns, pub, key, enc, public: isPublic, by: who = null }) {
593
911
  const payload = JSON.parse(await identity.openContent(enc))
594
912
  const value = payload?.value
595
913
  if (typeof value !== 'string' || !value) throw new Error('var.set: the sealed envelope must carry a non-empty value')
596
- if (ns) setSecret(ns, key, value, isPublic)
597
- else await setDeviceSecret(pub, key, value, isPublic)
914
+ // NO PIDE LA CONTRASEÑA (§8.1): sellar solo necesita las públicas de quien va a
915
+ // leer. Lo que se guarda es quién lo escribió, para que el histórico lo diga.
916
+ if (ns) await setSecret(ns, key, value, isPublic, { by: who })
917
+ else await setDeviceSecret(pub, key, value, isPublic, { by: who })
598
918
  return { ok: true, key }
599
919
  },
600
920
  /**
@@ -607,7 +927,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
607
927
  * Los NOMBRES también viajan dentro del sobre —no solo los valores—: el proxy
608
928
  * transporta y no tiene por qué aprender cómo se llama la configuración de un servicio.
609
929
  */
610
- async setMany ({ ns, pub, enc, public: isPublic }) {
930
+ async setMany ({ ns, pub, enc, public: isPublic, by: who = null }) {
611
931
  const payload = JSON.parse(await identity.openContent(enc))
612
932
  const items = payload?.items
613
933
  if (!Array.isArray(items) || !items.length) throw new Error('var.setMany: the sealed envelope must carry the variables')
@@ -620,7 +940,9 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
620
940
  value: it?.value,
621
941
  ...(typeof it?.public === 'boolean' ? { public: it.public } : (isPublic === undefined ? {} : { public: isPublic }))
622
942
  }))
623
- const keys = ns ? applySecrets(ns, list) : await applyDeviceSecrets(pub, list)
943
+ // NO PIDE LA CONTRASEÑA (§8.1). Y sí, `applySecrets` va con `await` sin él la
944
+ // escritura quedaba al aire y la respuesta salía antes de guardar nada.
945
+ const keys = ns ? await applySecrets(ns, list, { by: who }) : await applyDeviceSecrets(pub, list, { by: who })
624
946
  return { ok: true, keys }
625
947
  }
626
948
  }
@@ -694,8 +1016,421 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
694
1016
 
695
1017
  // API local de secretos (CLI/UI del dueño; audita cada cambio). `isPublic` es opcional:
696
1018
  // sin decir nada, la variable conserva su visibilidad (y una nueva nace privada).
697
- function setSecret (ns, key, value, isPublic) { secrets.set(ns, key, value, isPublic); audit('secret.set', { ns, key }); scheduleNotice(ns) }
698
- function deleteSecret (ns, key) { const ok = secrets.delete(ns, key); if (ok) { audit('secret.rm', { ns, key }); scheduleNotice(ns) } return ok }
1019
+ /**
1020
+ * La llave con la que se abre la copia maestra.
1021
+ *
1022
+ * Con contraseña, la deriva quien llama y llega aquí por operación. **Sin
1023
+ * contraseña se cae a la llave de la máquina**, que es exactamente la protección
1024
+ * que había antes de todo esto: el disco sigue cifrado, pero su material vive en
1025
+ * ese mismo disco, así que una copia del disco lo abre.
1026
+ *
1027
+ * Es un default deliberado —un perfil sin contraseña tiene que seguir funcionando—
1028
+ * pero NO es equivalente, y por eso la consola lo dice en voz alta (§2.3 del
1029
+ * diseño). Prometer una protección que no está puesta es peor que no tenerla.
1030
+ */
1031
+ const adminKeyOr = (adminKey) => adminKey || new Uint8Array(machineKey(dir))
1032
+
1033
+ // `adminKey` es la llave derivada de la contraseña del perfil, y va POR OPERACIÓN: se
1034
+ // usa para sellar y se suelta. Solo hace falta para escribir una privada — servir,
1035
+ // listar y borrar no la piden (ver `secretsStore.js`).
1036
+ /**
1037
+ * Los miembros que deben poder abrir un cajón: los SERVICIOS de ese namespace
1038
+ * (miembros del acta con ese `cn`). La bóveda NO entra en la lista — envolverle la
1039
+ * CEK a ella misma sería devolverle la capacidad de leerlo todo, que es justo lo
1040
+ * que este diseño quita.
1041
+ */
1042
+ async function nsMembers (ns) {
1043
+ const record = (await identity.profileActa?.().catch(() => null))?.acta
1044
+ return (record?.members || []).filter((m) => m.cn === ns)
1045
+ }
1046
+
1047
+ /**
1048
+ * Los APARATOS QUE ADMINISTRAN: miembros sin CN (no son servicios) con llave de
1049
+ * cifrado. Son los que pueden VER y REVERTIR desde la consola sin teclear la frase en
1050
+ * ningún sitio, que es todo el punto del §8.2 — la capacidad de leer se muda de una
1051
+ * frase que se escribe en cualquier parte a una llave que no sale del aparato.
1052
+ *
1053
+ * La bóveda NO entra en la lista, ni aquí ni en `nsMembers`: envolverle la llave a
1054
+ * ella misma sería devolverle la capacidad de leerlo todo, que es justo lo que este
1055
+ * diseño quita. Hay un test que lo afirma.
1056
+ */
1057
+ async function adminDevices () {
1058
+ const record = (await identity.profileActa?.().catch(() => null))?.acta
1059
+ return (record?.members || []).filter((m) => !m.cn && m.encPub && m.pub !== master)
1060
+ }
1061
+
1062
+ /**
1063
+ * Los destinatarios de un cajón. Y la regla que decide quién NO entra, que es la que
1064
+ * importa (decidida por el dueño el 2026-08-22):
1065
+ *
1066
+ * **UN CAJÓN CON DUEÑO NO SE ENVUELVE PARA QUIEN ADMINISTRA.** Si el acta dice que
1067
+ * hay un servicio que consume ese `ns`, sus variables son suyas y de nadie más: el
1068
+ * token de R2 o las llaves de TURN no se pueden abrir desde un navegador, ni aunque
1069
+ * alguien se lleve tu portátil con la sesión puesta.
1070
+ *
1071
+ * Dos cosas que hacen que esto se sostenga:
1072
+ *
1073
+ * · **No es una negativa, es que no existe.** Se podría haber dejado el envoltorio y
1074
+ * que la bóveda se negara a entregarlo, pero entonces la protección sería una
1075
+ * política: el envoltorio seguiría en el disco, y quien tuviera el disco más la
1076
+ * llave de un aparato que administra lo abriría sin preguntarle a nadie. Lo que no
1077
+ * se crea no se puede saltar.
1078
+ * · **El criterio es ESTRUCTURAL, no de tiempo de ejecución.** Se mira el acta —qué
1079
+ * dice la maestra que existe—, no quién está encendido. «Hay un servicio activo»
1080
+ * se puede forzar esperando a que ese servicio esté caído.
1081
+ *
1082
+ * Y sigue habiendo quien pueda repartir: el propio SERVICIO re-envuelve para un
1083
+ * miembro nuevo de su cajón (ya tiene la CEK, así que no gana nada), y la frase
1084
+ * abre la envoltura de recuperación, que `wrapAll` añade siempre.
1085
+ */
1086
+ /**
1087
+ * Llega la envoltura que hizo un servicio. Se comprueba QUIÉN la firma —tiene que ser
1088
+ * el aparato al que se le pidió, no cualquiera que pase por el proxy— y se reparte a
1089
+ * quien esté esperándola. Guardarla es cosa de `delegateRewrap`, que es quien sabe
1090
+ * qué pidió; aquí solo se valida el remitente.
1091
+ */
1092
+ async function handleRewrapOk (payload) {
1093
+ const d = payload?.data
1094
+ if (!d || d.op !== 'rewrap.ok' || !d.wrap) return
1095
+ const signer = payload?.cert?.sub
1096
+ if (!signer) return
1097
+ if (!(await verifyDeviceSig({ publickey: signer, data: d, signature: payload.signature }))) {
1098
+ return log('[vault] a handed key arrived BADLY SIGNED: ignored')
1099
+ }
1100
+ // Y que quien firma sea de ese cajón: si no, no tenía por qué poder abrir esa llave.
1101
+ const record = (await identity.profileActa?.().catch(() => null))?.acta
1102
+ const m = (record?.members || []).find((x) => x.pub === signer)
1103
+ const ns = d.owner?.startsWith('ns:') ? d.owner.slice(3) : null
1104
+ if (!m || (ns && m.cn !== ns)) return log('[vault] a handed key arrived from someone outside that drawer: ignored')
1105
+ for (const fn of [...rewrapWaiters]) { try { fn(d) } catch (_) {} }
1106
+ }
1107
+
1108
+ /** Suscriptores de `vault.rewrap.ok` (la envoltura que devuelve un servicio). */
1109
+ const rewrapWaiters = new Set()
1110
+ const onRewrapOk = (fn) => { rewrapWaiters.add(fn); return () => rewrapWaiters.delete(fn) }
1111
+
1112
+ async function recipientsOf (owner) {
1113
+ if (owner.startsWith('ns:')) {
1114
+ const owned = await nsMembers(owner.slice(3))
1115
+ // Sin dueño (un cajón personal, o uno cuyo servicio ya no está) sí entra quien
1116
+ // administra: si no, no quedaría nadie que pudiera abrirlo sin la frase.
1117
+ return owned.length ? owned : await adminDevices()
1118
+ }
1119
+ const pub = owner.slice(owner.indexOf(':') + 1)
1120
+ const m = await memberOf(pub)
1121
+ // El cajón propio de un aparato de SERVICIO es tan suyo como el de su ns.
1122
+ if (m?.cn) return [m]
1123
+ return [...(m ? [m] : []), ...await adminDevices()]
1124
+ }
1125
+
1126
+ /**
1127
+ * Firma un sobre con la LLAVE DE SELLADO que nombra el acta (§8.8). Devuelve el `seq`
1128
+ * del acta junto a la firma: es lo que le dice a quien verifica con qué llave
1129
+ * comprobarla, porque esa llave rota con el acta (§8.9).
1130
+ *
1131
+ * Si no hay llave —o no es nuestra— el sobre sale SIN firma. Guardar la configuración
1132
+ * es más importante que poder demostrar después de dónde salió.
1133
+ */
1134
+ async function signSeal (body) {
1135
+ try {
1136
+ const acta = (await identity.profileActa?.().catch(() => null))?.acta
1137
+ if (!acta?.sealPub) return null
1138
+ const sig = await sealKeys.sign(acta.sealPub, body)
1139
+ return sig ? { seq: acta.seq, sig } : null
1140
+ } catch (_) { return null }
1141
+ }
1142
+
1143
+ /**
1144
+ * Sellar no basta: hay que REPARTIR la llave. Tras cada escritura se envuelve la CEK
1145
+ * del cajón a sus miembros actuales — si no, el servicio recibe sobres que no puede
1146
+ * abrir y se queda reintentando para siempre, sin decir por qué.
1147
+ *
1148
+ * No avisa de cambio a nadie: el texto cifrado de los valores no se mueve, y avisar
1149
+ * reiniciaría a todos los nodos del ns para nada.
1150
+ */
1151
+ /**
1152
+ * Se fue un miembro de un namespace: su CEK deja de ser de fiar. Se intenta rotar en
1153
+ * el acto y, si no se puede (perfil sin desbloquear), el cajón queda MARCADO — la
1154
+ * consola y el CLI lo enseñan, y la siguiente escritura desbloqueada lo salda.
1155
+ *
1156
+ * Una deuda que no se ve es una deuda que no se paga, y aquí la deuda es que alguien
1157
+ * que ya no está podría abrir lo que se escriba mañana.
1158
+ */
1159
+ async function markRotationDue (sub) {
1160
+ const m = await memberOf(sub)
1161
+ const ns = m?.cn
1162
+ if (!ns) return
1163
+ try {
1164
+ const r = await secrets.rotate(`ns:${ns}`, await nsMembers(ns))
1165
+ if (r?.rotated != null) {
1166
+ log(`[vault] ns:${ns}: key rotated after removing a member (${r.rotated} variable(s) re-encrypted)`)
1167
+ audit('secret.rotate', { ns, keys: r.rotated })
1168
+ scheduleNotice(ns)
1169
+ return
1170
+ }
1171
+ } catch (e) {
1172
+ store.setSetting(`rotate-due:${ns}`, String(Date.now()))
1173
+ log(`[vault] ns:${ns}: PENDING ROTATION - a member left and its key could not be rotated (${e.message})`)
1174
+ audit('secret.rotate-due', { ns, reason: e.message })
1175
+ }
1176
+ }
1177
+
1178
+ /**
1179
+ * Lo que este perfil debe volver a sellar, para que las listas puedan DECIRLO. Son dos
1180
+ * deudas distintas y las dos acaban igual —los miembros no leen sus variables— así que
1181
+ * salen juntas:
1182
+ *
1183
+ * - `rotate`: se fue un miembro y no se pudo rotar la llave del namespace.
1184
+ * - `rewrap`: entró uno (o registró su llave de cifrado) y no se le pudo envolver.
1185
+ *
1186
+ * Ambas se saldan solas en la siguiente escritura con contraseña. Mientras tanto,
1187
+ * enseñarlas es la diferencia entre un servicio mal configurado y uno mal configurado
1188
+ * que además nadie ve.
1189
+ */
1190
+ /**
1191
+ * Los aparatos INCOMPLETOS: los que están en el acta pero no pueden abrir alguna de
1192
+ * las variables que les tocan (§8.7). Pasa siempre que entra uno nuevo — envolverle su
1193
+ * llave exige abrir la CEK, y eso pide la frase, que por el camino del enrolamiento no
1194
+ * hay quien la teclee.
1195
+ *
1196
+ * Que no puedan leer es CORRECTO y no se relaja. Lo que se arregla aquí es que se
1197
+ * vea: hasta ahora un servicio recién enrolado aparecía en la lista como cualquier
1198
+ * otro y arrancaba sin configuración, repitiendo un error en su propio log que nadie
1199
+ * mira. Con esto, la consola y la TUI pueden decir exactamente qué falta y qué hacer
1200
+ * (`dotrino-vault secret settle`, que sí pide la frase).
1201
+ *
1202
+ * @returns {Promise<Array<{ pub: string, cn: string|null, owners: Record<string, string[]> }>>}
1203
+ */
1204
+ async function incompleteMembers () {
1205
+ if (secrets.isLegacy?.()) return []
1206
+ const record = (await identity.profileActa?.().catch(() => null))?.acta
1207
+ const out = []
1208
+ for (const m of record?.members || []) {
1209
+ if (!m.encPub || m.pub === master) continue
1210
+ const owners = {}
1211
+ // Un servicio lee su cajón de ns y el suyo propio; un aparato que administra
1212
+ // puede DESTAPAR cualquiera, así que se le miran todos: sin envoltura, el botón
1213
+ // «Ver» de la consola le fallaría sin explicar por qué.
1214
+ // Un aparato que administra NO debe tener envoltura de los cajones con dueño
1215
+ // (`recipientsOf`), así que no se le cuenta como falta: lo que ahí falta es a
1216
+ // propósito y ponerlo en la lista sería pedir que se «arregle» lo correcto.
1217
+ const ownedNs = new Set((record?.members || []).filter((x) => x.cn).map((x) => x.cn))
1218
+ const drawers = m.cn
1219
+ ? [`ns:${m.cn}`, `dev:${m.pub}`]
1220
+ : [...Object.keys(secrets.list()).filter((ns) => !ownedNs.has(ns)).map((ns) => `ns:${ns}`), `dev:${m.pub}`]
1221
+ for (const owner of drawers) {
1222
+ const missing = secrets.missingFor(owner, m.pub)
1223
+ if (missing.length) owners[owner] = missing
1224
+ }
1225
+ if (Object.keys(owners).length) out.push({ pub: m.pub, cn: m.cn || null, owners })
1226
+ }
1227
+ return out
1228
+ }
1229
+
1230
+ /**
1231
+ * Lo que quedó a deber una ROTACIÓN: un aparato salió conociendo la llave vigente y
1232
+ * no se pudo rotar en ese momento. Eso sí es una nota, porque es un hecho pasado que el
1233
+ * estado de hoy no enseña (quitarle la envoltura no le quita lo que ya supo).
1234
+ */
1235
+ function rotationsDue () {
1236
+ const out = {}
1237
+ for (const [k, v] of Object.entries(store.listSettings?.() || {})) {
1238
+ // La salida va SIEMPRE por owner (`ns:proxy`), aunque el ajuste guarde solo el
1239
+ // nombre del namespace: una sola forma para quien lo lee.
1240
+ if (k.startsWith('rotate-due:')) out['ns:' + k.slice('rotate-due:'.length)] = { kind: 'rotate', at: Number(v) || 0 }
1241
+ }
1242
+ return out
1243
+ }
1244
+
1245
+ /**
1246
+ * TODO lo que está a deber, por cajón: las rotaciones anotadas y, CALCULADO, cada
1247
+ * envoltura que falta. La falta de una envoltura no se anota nunca: se mira. Una nota
1248
+ * se desincroniza en cuanto alguien salda la deuda por un camino que no pasa por donde
1249
+ * se anotó (pasó dos veces: el reparto por el hermano y el rehacer al abrir), y
1250
+ * entonces la consola avisa de algo que ya no existe. Lo que se calcula no miente.
1251
+ * @returns {Promise<Record<string, { kind: 'rotate'|'rewrap', at?: number, members?: { pub: string, keys: string[] }[] }>>}
1252
+ */
1253
+ async function secretDebts () {
1254
+ const out = rotationsDue()
1255
+ for (const m of await incompleteMembers()) {
1256
+ for (const [owner, keys] of Object.entries(m.owners)) {
1257
+ if (out[owner]?.kind === 'rotate') continue
1258
+ out[owner] = out[owner] || { kind: 'rewrap', members: [] }
1259
+ out[owner].members.push({ pub: m.pub, keys })
1260
+ }
1261
+ }
1262
+ return out
1263
+ }
1264
+
1265
+ async function spreadKey (owner, members, adminKey) {
1266
+ if (secrets.isLegacy()) return null
1267
+ // Si este cajón quedó a deber una rotación (se fue alguien y no se pudo rotar),
1268
+ // se salda AHORA, que es cuando hay con qué. Rotar incluye re-envolver, así que
1269
+ // sustituye al reparto en vez de sumarse.
1270
+ const ns = owner.startsWith('ns:') ? owner.slice(3) : null
1271
+ if (ns && store.getSetting(`rotate-due:${ns}`)) {
1272
+ const rot = await secrets.rotate(owner, members, adminKey)
1273
+ store.setSetting(`rotate-due:${ns}`, undefined)
1274
+ log(`[vault] ns:${ns}: pending rotation settled (${rot?.rotated ?? 0} variable(s) re-encrypted)`)
1275
+ audit('secret.rotate', { ns, keys: rot?.rotated ?? 0, pending: true })
1276
+ return rot
1277
+ }
1278
+ try {
1279
+ const r = await secrets.rewrap(owner, members, adminKey)
1280
+ if (r?.sinLlave?.length) {
1281
+ log(`[vault] ${owner}: ${r.sinLlave.length} member(s) without an encryption key - they will NOT be able to read their variables`)
1282
+ audit('secret.nokey', { owner, count: r.sinLlave.length })
1283
+ }
1284
+ return r
1285
+ } catch (e) {
1286
+ // SIN la contraseña no hay forma de envolverle su llave, y esto pasa por caminos
1287
+ // donde no hay a quién pedírsela: un servicio que acaba de registrar su llave de
1288
+ // cifrado llega por el proxio, no por una consola. Antes se perdía en un `.catch`
1289
+ // del que llamaba y el servicio se quedaba sin variables SIN QUE NADIE SE ENTERARA
1290
+ // — el modo de fallo que más caro sale aquí. Se dice y se audita; la deuda no se
1291
+ // anota: se CALCULA (`secretDebts`), así no hay nota que se quede vieja cuando la
1292
+ // salde un hermano o el abrir la bóveda.
1293
+ log(`[vault] ${owner}: could not hand out its key (${e.message}); its members will not read their variables until a sibling hands it out or the vault is opened`)
1294
+ audit('secret.rewrap-due', { owner, reason: e.message })
1295
+ throw e
1296
+ }
1297
+ }
1298
+
1299
+ /**
1300
+ * Escribir NO pide la frase (§8.1): el sobre se sella con las públicas de quien lo va
1301
+ * a leer. `by` es el aparato que lo escribió, y va al histórico.
1302
+ */
1303
+ async function setSecret (ns, key, value, isPublic, { by = null } = {}) {
1304
+ await secrets.set(ns, key, value, isPublic, { by })
1305
+ await settleDebts(`ns:${ns}`, () => nsMembers(ns))
1306
+ audit('secret.set', { ns, key }); scheduleNotice(ns)
1307
+ }
1308
+
1309
+ /**
1310
+ * SALDAR LAS DEUDAS DEL PERFIL, con la frase en la mano. Es lo que hay que llamar tras
1311
+ * desbloquear: heredarle a un aparato nuevo lo que ya estaba guardado, y rotar de
1312
+ * verdad el cajón del que salió alguien. Las dos cosas exigen ABRIR, y abrir es lo
1313
+ * único que la frase guarda (§8.3).
1314
+ *
1315
+ * No lanza: devuelve qué pasó con cada cajón, porque una deuda que no se puede saldar
1316
+ * tiene que seguir viéndose en la lista en vez de tumbar la operación entera.
1317
+ */
1318
+ /**
1319
+ * PIDE AL SERVICIO QUE REPARTA la llave de su cajón a un miembro nuevo (§8.11).
1320
+ *
1321
+ * Es la única forma de completar a un aparato sin la frase y sin que nadie guarde
1322
+ * llaves de más: la bóveda no puede abrir la CEK, pero el servicio que la consume la
1323
+ * tiene abierta, y re-envolverla no le da ningún poder que no tuviera.
1324
+ *
1325
+ * La bóveda manda su propia envoltura de esa generación —la del servicio, que él ya
1326
+ * podía abrir— junto al ACTA firmada. El servicio saca de ahí la pública del
1327
+ * destinatario y contesta con la envoltura nueva, que se guarda con `putWrap`, que
1328
+ * solo AÑADE. Si el servicio no está encendido, la deuda se queda a la vista.
1329
+ *
1330
+ * @returns {Promise<{ done: number, asked: number }>}
1331
+ */
1332
+ async function delegateRewrap (owner, targetPub, { timeoutMs = 15000 } = {}) {
1333
+ const ns = owner.startsWith('ns:') ? owner.slice(3) : null
1334
+ if (!ns) return { done: 0, asked: 0 }
1335
+ const record = (await identity.profileActa?.().catch(() => null))?.acta
1336
+ if (!record) return { done: 0, asked: 0 }
1337
+ // Quien puede repartir: un miembro de ESE cajón que no sea el propio destinatario.
1338
+ const helpers = (record.members || []).filter((m) => m.cn === ns && m.pub !== targetPub && m.encPub)
1339
+ const pending = secrets.wrapsToShare(owner, targetPub, helpers[0]?.pub || '')
1340
+ if (!helpers.length || !pending.length) return { done: 0, asked: 0 }
1341
+
1342
+ let done = 0
1343
+ for (const gen of pending) {
1344
+ const body = { op: 'rewrap', owner, gen: gen.gen, wrap: gen.mine, target: targetPub, acta: record, ts: Date.now() }
1345
+ const { signature } = await identity.signData(body)
1346
+ const answer = new Promise((resolve) => {
1347
+ const off = onRewrapOk((d) => {
1348
+ if (d.owner !== owner || d.gen !== gen.gen || d.target !== targetPub) return
1349
+ off(); resolve(d.wrap)
1350
+ })
1351
+ setTimeout(() => { off(); resolve(null) }, timeoutMs).unref?.()
1352
+ })
1353
+ try { client.sendByPubkey(helpers[0].pub, { type: MSG.REWRAP, body, signature }) } catch (e) {
1354
+ log(`[vault] ${owner}: could not ask for the key to be handed out (${e.message})`)
1355
+ continue
1356
+ }
1357
+ const wrap = await answer
1358
+ if (!wrap) continue
1359
+ try { secrets.putWrap(owner, gen.gen, targetPub, wrap); done++ } catch (e) {
1360
+ log(`[vault] ${owner}: the handed key was refused (${e.message})`)
1361
+ }
1362
+ }
1363
+ if (done) {
1364
+ audit('secret.delegated', { owner, target: await deviceIdOf(targetPub).catch(() => null), gens: done })
1365
+ log(`[vault] ${owner}: ${done} key(s) handed out by its own service`)
1366
+ }
1367
+ return { done, asked: pending.length }
1368
+ }
1369
+
1370
+ async function resealAll (adminKey = null) {
1371
+ const out = { drawers: 0, wrapped: 0, dropped: 0, sinLlave: [] }
1372
+ for (const owner of secrets.owners?.() || []) {
1373
+ const before = new Set(secrets.recipientsIn(owner))
1374
+ const members = await recipientsOf(owner)
1375
+ try {
1376
+ const r = await secrets.rewrap(owner, members, adminKey, { exact: true })
1377
+ const after = new Set(secrets.recipientsIn(owner))
1378
+ out.drawers++
1379
+ out.wrapped += r?.wrapped || 0
1380
+ out.dropped += [...before].filter((p) => !after.has(p)).length
1381
+ for (const p of r?.sinLlave || []) out.sinLlave.push(p)
1382
+ } catch (e) {
1383
+ log(`[vault] ${owner}: could not reseal (${e.message})`)
1384
+ }
1385
+ }
1386
+ if (out.dropped) audit('secret.reseal', { drawers: out.drawers, dropped: out.dropped })
1387
+ return out
1388
+ }
1389
+
1390
+ async function settleSecretDebts (adminKey = null) {
1391
+ const out = {}
1392
+ // Las deudas ANOTADAS más las que se ven MIRANDO: un aparato puede quedarse sin
1393
+ // envoltura sin que nadie llegara a anotar nada (basta con que el apunte se pierda
1394
+ // por un camino que no pase por `spreadKey`), y entonces `settle` contestaba «nada
1395
+ // pendiente» mientras el servicio repetía en su log que no podía leer. Se calcula,
1396
+ // que es barato y no depende de que alguien se acordara de apuntarlo.
1397
+ const debts = new Set(Object.keys(rotationsDue()))
1398
+ for (const m of await incompleteMembers()) for (const owner of Object.keys(m.owners)) debts.add(owner)
1399
+
1400
+ for (const owner of debts) {
1401
+ const k = owner.slice(owner.indexOf(':') + 1)
1402
+ // Los destinatarios de un cajón no son solo quien lo consume: también quien lo
1403
+ // administra (`recipientsOf`). Envolver solo para los primeros dejaba a la consola
1404
+ // sin poder destapar lo que ella misma acababa de saldar.
1405
+ const members = await recipientsOf(owner)
1406
+ try { out[owner] = await spreadKey(owner, members, adminKey) } catch (e) {
1407
+ // Sin frase, el último recurso es pedírselo a quien sí puede: el propio servicio.
1408
+ let delegated = 0
1409
+ for (const m of await incompleteMembers()) {
1410
+ if (!m.owners[owner]) continue
1411
+ delegated += (await delegateRewrap(owner, m.pub).catch(() => ({ done: 0 }))).done
1412
+ }
1413
+ out[owner] = delegated ? { delegated } : { error: e.message }
1414
+ }
1415
+ }
1416
+ return out
1417
+ }
1418
+
1419
+ /**
1420
+ * Si este cajón quedó a deber un re-envoltorio o una rotación, se intenta saldar ahora.
1421
+ * Sin frase solo se puede en un perfil que no tiene contraseña —ahí la copia de
1422
+ * recuperación se abre con la llave de la máquina—, y en uno que sí la tiene se queda
1423
+ * anotado, que es lo que las listas enseñan. No se propaga el error: la variable YA se
1424
+ * guardó, y el que escribe no tiene por qué enterarse de una deuda vieja.
1425
+ */
1426
+ async function settleDebts (owner, membersFn) {
1427
+ const ns = owner.startsWith('ns:') ? owner.slice(3) : null
1428
+ const owed = (ns && store.getSetting(`rotate-due:${ns}`)) ||
1429
+ (await incompleteMembers()).some((m) => m.owners[owner])
1430
+ if (!owed) return null
1431
+ try { return await spreadKey(owner, await membersFn(), null) } catch (_) { return null }
1432
+ }
1433
+ async function deleteSecret (ns, key) { const ok = await secrets.delete(ns, key); if (ok) { audit('secret.rm', { ns, key }); scheduleNotice(ns) } return ok }
699
1434
 
700
1435
  /**
701
1436
  * CARGAR CONFIGURACIÓN ES UNA TRANSACCIÓN: muchas variables, UN aviso.
@@ -716,40 +1451,44 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
716
1451
  * @returns {string[]} las claves que efectivamente cambiaron (un `rm` de lo que no
717
1452
  * estaba no cambia nada, y no tiene por qué reiniciar a nadie).
718
1453
  */
719
- function applySecrets (ns, items) {
1454
+ async function applySecrets (ns, items, { by = null } = {}) {
720
1455
  const list = assertItems(items)
721
1456
  const changed = []
722
- secrets.batch(() => {
1457
+ await secrets.batch(async () => {
723
1458
  for (const it of list) {
724
1459
  if (it.op === 'rm') {
725
- if (secrets.delete(ns, it.key)) { audit('secret.rm', { ns, key: it.key }); changed.push(it.key) }
1460
+ if (await secrets.delete(ns, it.key)) { audit('secret.rm', { ns, key: it.key }); changed.push(it.key) }
726
1461
  } else {
727
- secrets.set(ns, it.key, it.value, it.public)
1462
+ await secrets.set(ns, it.key, it.value, it.public, { by })
728
1463
  audit('secret.set', { ns, key: it.key })
729
1464
  changed.push(it.key)
730
1465
  }
731
1466
  }
732
1467
  })
733
- if (changed.length) scheduleNotice(ns)
1468
+ if (changed.length) {
1469
+ await settleDebts(`ns:${ns}`, () => nsMembers(ns))
1470
+ scheduleNotice(ns)
1471
+ }
734
1472
  return changed
735
1473
  }
736
1474
 
737
1475
  /** Lo mismo para el cajón de UN aparato (el aviso va solo a él). */
738
- async function applyDeviceSecrets (pub, items) {
1476
+ async function applyDeviceSecrets (pub, items, { by = null } = {}) {
739
1477
  const list = assertItems(items)
740
1478
  const m = await requireService(pub)
741
1479
  const changed = []
742
- secrets.batch(() => {
1480
+ await secrets.batch(async () => {
743
1481
  for (const it of list) {
744
1482
  if (it.op === 'rm') {
745
- if (secrets.deleteDevice(pub, it.key)) changed.push(it.key)
1483
+ if (await secrets.deleteDevice(pub, it.key)) changed.push(it.key)
746
1484
  } else {
747
- secrets.setDevice(pub, it.key, it.value, it.public)
1485
+ await secrets.setDevice(pub, it.key, it.value, it.public, { by })
748
1486
  changed.push(it.key)
749
1487
  }
750
1488
  }
751
1489
  })
752
1490
  if (changed.length) {
1491
+ await settleDebts(`dev:${pub}`, async () => [m].filter(Boolean))
753
1492
  const device = await deviceIdOf(pub).catch(() => null)
754
1493
  for (const it of list) {
755
1494
  if (!changed.includes(it.key)) continue
@@ -786,12 +1525,21 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
786
1525
  return out
787
1526
  }
788
1527
  /** Cambiar SOLO quién puede ver el valor (no toca el valor ni avisa: el servicio lee lo mismo). */
789
- function setSecretVisibility (ns, key, isPublic) {
790
- const ok = secrets.setVisibility(ns, key, isPublic)
1528
+ async function setSecretVisibility (ns, key, isPublic, adminKey) {
1529
+ const ok = await secrets.setVisibility(ns, key, isPublic, adminKey)
791
1530
  if (ok) audit('secret.visibility', { ns, key, public: !!isPublic })
792
1531
  return ok
793
1532
  }
794
1533
 
1534
+ /**
1535
+ * El bundle de un ns ABIERTO, para diagnosticar y para las pruebas. No lo usa el
1536
+ * camino de servir —ahí los sobres salen cerrados y los abre el agente—, y por eso
1537
+ * este sí pide poder abrir la copia maestra.
1538
+ */
1539
+ async function openSecrets (ns, devicePub = null, adminKey) {
1540
+ return secrets.openBundle(ns, devicePub, adminKey)
1541
+ }
1542
+
795
1543
  /** El miembro del acta con esa llave, o `null` (también si la bóveda todavía no tiene acta). */
796
1544
  async function memberOf (pub) {
797
1545
  const record = (await identity.profileActa?.().catch(() => null))?.acta
@@ -816,16 +1564,17 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
816
1564
  return m
817
1565
  }
818
1566
 
819
- async function setDeviceSecret (pub, key, value, isPublic) {
1567
+ async function setDeviceSecret (pub, key, value, isPublic, { by = null } = {}) {
820
1568
  const m = await requireService(pub)
821
- secrets.setDevice(pub, key, value, isPublic)
1569
+ await secrets.setDevice(pub, key, value, isPublic, { by })
1570
+ await settleDebts(`dev:${pub}`, async () => [await memberOf(pub)].filter(Boolean))
822
1571
  audit('secret.set', { device: await deviceIdOf(pub).catch(() => null), ns: m?.cn || null, key, scope: 'device' })
823
1572
  scheduleDeviceNotice(pub)
824
1573
  }
825
1574
 
826
1575
  async function deleteDeviceSecret (pub, key) {
827
1576
  const m = await memberOf(pub)
828
- const ok = secrets.deleteDevice(pub, key)
1577
+ const ok = await secrets.deleteDevice(pub, key)
829
1578
  if (ok) {
830
1579
  audit('secret.rm', { device: await deviceIdOf(pub).catch(() => null), ns: m?.cn || null, key, scope: 'device' })
831
1580
  scheduleDeviceNotice(pub)
@@ -833,8 +1582,8 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
833
1582
  return ok
834
1583
  }
835
1584
 
836
- async function setDeviceSecretVisibility (pub, key, isPublic) {
837
- const ok = secrets.setDeviceVisibility(pub, key, isPublic)
1585
+ async function setDeviceSecretVisibility (pub, key, isPublic, adminKey) {
1586
+ const ok = await secrets.setDeviceVisibility(pub, key, isPublic, adminKey)
838
1587
  if (ok) audit('secret.visibility', { device: await deviceIdOf(pub).catch(() => null), key, public: !!isPublic, scope: 'device' })
839
1588
  return ok
840
1589
  }
@@ -864,14 +1613,38 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
864
1613
  }
865
1614
 
866
1615
  return {
867
- identity, client, store, threads, secrets, master, fingerprint: fp,
1616
+ identity, client, store, threads, secrets, master, fingerprint: fp, dir,
868
1617
  startPairing: desk.startPairing,
869
1618
  stopPairing: desk.stopPairing,
870
1619
  listPending: desk.listPending,
1620
+ // Cajones con aprobación por uso (`approvals.js`).
1621
+ secretPolicy: (ns) => secrets.policyOf(ns),
1622
+ setSecretPolicy: async (ns, patch) => { const r = secrets.setPolicy(ns, patch); audit('secret.policy', { ns, ...r }); return r },
1623
+ secretPolicies: () => secrets.policies(),
1624
+ listApprovals: () => approvals.list(),
1625
+ sshKeys,
1626
+ requestSshSign,
871
1627
  // Aprobar desde el PC avisa igual que aprobar a distancia: el resto de tus
872
1628
  // dispositivos se entera de que entró alguien, venga de donde venga.
873
- approveDevice: async (code) => {
1629
+ approveDevice: async (code, adminKey) => {
874
1630
  const r = await desk.approve(code)
1631
+ // Un servicio que ENTRA a un namespace que ya tiene variables necesita su
1632
+ // envoltura de la CEK, o recibirá sobres que no puede abrir y se quedará
1633
+ // reintentando en silencio. Se reparte aquí, que es por donde pasan las dos
1634
+ // puertas de aprobar (el PC y la consola remota), y no en `enroll.js`, que es
1635
+ // el archivo vendorizado en el iframe de identidad.
1636
+ const m = r?.cert?.sub ? await memberOf(r.cert.sub) : null
1637
+ if (m?.cn) {
1638
+ await spreadKey(`ns:${m.cn}`, await nsMembers(m.cn), adminKey).catch(async (e) => {
1639
+ log('[vault] could not hand the key to the new service:', e.message)
1640
+ // Sin la frase la bóveda no puede envolvérsela… pero un HERMANO suyo sí: otro
1641
+ // servicio del mismo cajón ya tiene la llave abierta (§8.11). Si contesta, el
1642
+ // recién llegado arranca completo; si no hay ninguno encendido, la deuda queda
1643
+ // a la vista y se salda al abrir la bóveda.
1644
+ const r2 = await delegateRewrap(`ns:${m.cn}`, m.pub).catch(() => ({ done: 0 }))
1645
+ if (!r2.done) log(`[vault] ns:${m.cn}: nobody could hand it the key — it stays in debt until the vault is opened`)
1646
+ })
1647
+ }
875
1648
  await notifyMembers('enrolled', { deviceId: r?.deviceId || null, by: 'pc' })
876
1649
  return r
877
1650
  },
@@ -879,13 +1652,43 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
879
1652
  // El mostrador que atiende a la consola remota. Se expone para poder probar la
880
1653
  // frontera de verdad (que el valor de una privada no salga ni dentro del sobre).
881
1654
  vars: varsDesk,
882
- setSecret, deleteSecret, listSecrets, setSecretVisibility,
1655
+ setSecret, deleteSecret, listSecrets, setSecretVisibility, openSecrets,
1656
+ // Sella un `secrets.json` v3 entero. Es el punto de no retorno del despliegue y
1657
+ // por eso es una operación con nombre propio, no algo que ocurra de refilón al
1658
+ // desbloquear: deja `secrets.json.v3.bak` para poder volver.
1659
+ /**
1660
+ * Convierte el archivo de secretos al formato nuevo. `membersOf` es opcional y lo
1661
+ * normal es NO pasarlo: por defecto se usa la misma lista de destinatarios que
1662
+ * cualquier escritura —los servicios del cajón MÁS los aparatos que administran—.
1663
+ *
1664
+ * Pasarla a mano fue un error real: la conversión envolvía solo a los servicios, y
1665
+ * entonces la consola del dueño no podía ver nada de lo que ya había, aunque el
1666
+ * diseño dice que sí (§8.2). Lo de siempre: dos sitios decidiendo lo mismo.
1667
+ */
1668
+ resealAll,
1669
+ delegateRewrap,
1670
+ incompleteMembers,
1671
+ migrateSecrets: (membersOf, adminKey) => secrets.migrate(
1672
+ membersOf || ((owner) => recipientsOf(owner)), adminKey
1673
+ ),
1674
+ settleSecretDebts,
1675
+ revealSecret: (owner, key, adminKey) => secrets.reveal(owner, key, adminKey),
1676
+ secretHistory: (owner, key) => secrets.history(owner, key),
1677
+ revealSecretHistory: (owner, key, ts, adminKey) => secrets.revealHistory(owner, key, ts, adminKey),
1678
+ revertSecret: (owner, key, ts, opts) => secrets.revert(owner, key, ts, opts),
1679
+ // Cambiar la contraseña del perfil obliga a volver a cerrar la copia maestra con
1680
+ // la llave nueva, o los secretos quedarían ilegibles. No toca los sobres.
1681
+ rekeySecrets: (oldKey, newKey) => secrets.rekeyRecovery(oldKey, newKey),
883
1682
  setDeviceSecret, deleteDeviceSecret, listDeviceSecrets, setDeviceSecretVisibility,
884
1683
  applySecrets, applyDeviceSecrets,
885
1684
  listDevices: () => identity.listDelegations(),
886
1685
  // Acta del perfil (quién es del perfil y qué puede cada uno): lo que muestran
887
1686
  // `dotrino-vault members` y la consola de vault.dotrino.com.
888
1687
  profileMembers: () => identity.profileMembers(),
1688
+ // Los namespaces que quedaron a deber una rotación (se fue un miembro y no se pudo
1689
+ // rotar su llave). Lo enseñan `secret list` y la consola: si no se ve, no se salda.
1690
+ rotationsDue,
1691
+ secretDebts,
889
1692
  // ¿Es ESTA bóveda la que sella el acta? Lo usa el freno de borrado (D12).
890
1693
  isMaster: () => identity.isMaster(),
891
1694
  setCaps: async (pub, caps) => {
@@ -920,6 +1723,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
920
1723
  return r
921
1724
  },
922
1725
  close () {
1726
+ clearInterval(approvalsSweeper)
923
1727
  for (const t of pendingNotices.values()) clearTimeout(t)
924
1728
  pendingNotices.clear()
925
1729
  try { client.close() } catch (_) {} identity.destroy()