@dotrino/vaultd 0.77.0 → 0.79.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/lib/src/admin.js CHANGED
@@ -60,7 +60,20 @@ export const ADMIN_OPS = Object.freeze([
60
60
  ])
61
61
 
62
62
  /**
63
- * LO ÚNICO QUE SE ATIENDE CON EL CANDADO ECHADO: mirar. Ni una operación que cambie algo.
63
+ * QUÉ SE ATIENDE CON EL CANDADO ECHADO.
64
+ *
65
+ * No es «solo mirar», y confundirlo fue un error: **guardar una variable NO necesita la
66
+ * maestra**. Sellar un valor usa las llaves PÚBLICAS de quien va a leerlo, así que la
67
+ * bóveda puede hacerlo sin ningún secreto suyo (`docs/secretos-sellados.md` §8.1) — es
68
+ * justo lo que hace barata la rotación. Meterlo en el mismo saco que revocar dejaba la
69
+ * consola sin poder configurar nada con el perfil cerrado, que es su trabajo diario.
70
+ *
71
+ * Lo que SÍ se queda fuera es `revoke`: reescribe el acta, y sellar el acta es de la
72
+ * maestra. Cerrada no hay con qué.
73
+ *
74
+ * (Si el cajón tiene su llave sellada con la frase, repartirla a los miembros puede quedar
75
+ * a deber hasta que alguien abra: eso ya lo dice la bóveda y se salda solo. Guardar el
76
+ * valor, que es lo que se pidió, se hace igual.)
64
77
  *
65
78
  * El candado ES de la consola —los aparatos ya emparejados siguen leyendo, guardando y
66
79
  * firmando con SU llave— así que cerrar el perfil tiene que cerrar exactamente esto. No
@@ -72,7 +85,7 @@ export const ADMIN_OPS = Object.freeze([
72
85
  * Se dejan las de LEER a propósito: si se cortaran todas, la consola no podría ni
73
86
  * enterarse de por qué no funciona y parecería rota. Así puede decir «está cerrada».
74
87
  */
75
- export const ADMIN_OPS_WHILE_LOCKED = Object.freeze(['audit', 'vars'])
88
+ export const ADMIN_OPS_WHILE_LOCKED = Object.freeze(['audit', 'vars', 'var.set', 'var.setMany'])
76
89
 
77
90
  /** Cuánto se recuerda un nonce ya usado (el doble de la ventana de frescura). */
78
91
  export const ADMIN_NONCE_TTL_MS = 10 * 60 * 1000
@@ -136,7 +149,7 @@ export function createAdminDesk ({
136
149
  }
137
150
  const by = await deviceIdOf(chk.device).catch(() => null)
138
151
 
139
- // CERRADA NO SE ADMINISTRA. Va después de autorizar (el candado es estado del perfil,
152
+ // CERRADA NO SE SELLA EL ACTA. Va después de autorizar (el candado es estado del perfil,
140
153
  // y a un desconocido no se le cuenta) y ANTES de quemar el nonce: quien reintente
141
154
  // tras abrir la bóveda no tiene por qué generar otro.
142
155
  //
package/lib/src/env.js CHANGED
@@ -29,18 +29,63 @@ export function serviceRoot () {
29
29
  return process.env.DOTRINO_ENV_HOME || path.join(os.homedir(), '.dotrino', 'service')
30
30
  }
31
31
 
32
- /** Directorio de la identidad del servicio `ns` (`DOTRINO_ENV_DIR` lo pisa). */
33
- export function serviceDir (ns) {
32
+ /**
33
+ * DE QUÉ BÓVEDA VINO ESTO: `~/.dotrino/service/<bóveda>/<ns>`.
34
+ *
35
+ * Es una ETIQUETA TUYA, de esta máquina: nadie la manda por la red, no entra en el acta y
36
+ * no se compara con nada — puedes llamarla como quieras. Sirve solo para que dos bóvedas
37
+ * puedan tener un cajón que se llame igual.
38
+ *
39
+ * Se llama `--vault` y no `--profile` a propósito (dueño): `dotrino-vault --profile` ya
40
+ * significa OTRA cosa —el perfil de la bóveda— y los dos comandos se usan seguidos. Dos
41
+ * banderas con el mismo nombre y distinto significado se confunden el día que hay prisa.
42
+ *
43
+ * Antes la ruta era `<raíz>/<ns>` y el nombre del cajón era una CLAVE GLOBAL del usuario:
44
+ * si dos bóvedas usaban `aws`, el segundo enrolamiento pisaba al primero y lo dejaba
45
+ * inservible —avisaba, pero el papel del primero seguía existiendo en su bóveda sin
46
+ * identidad en disco que lo usara—. Obligar a nombrar el perfil quita esa colisión de
47
+ * raíz en vez de pedirle al usuario que se acuerde de no repetir nombres.
48
+ *
49
+ * Mismo alfabeto que el `ns` porque es lo mismo: un segmento de ruta.
50
+ */
51
+ export const isValidVaultLabel = (p) => typeof p === 'string' && /^[a-z0-9-]{1,32}$/.test(p)
52
+
53
+ /** Directorio de la identidad del servicio `ns` bajo la etiqueta de bóveda `vault`. */
54
+ export function serviceDir (vault, ns) {
34
55
  if (process.env.DOTRINO_ENV_DIR) return process.env.DOTRINO_ENV_DIR
56
+ if (!isValidVaultLabel(vault)) {
57
+ throw new Error('missing vault label: pass --vault <nombre> (use [a-z0-9-]{1,32}). It is a label of YOUR machine: it says which vault this drawer came from')
58
+ }
35
59
  if (!isValidSecretsNs(ns)) throw new Error('invalid ns (use [a-z0-9-]{1,32}, e.g. "myapp")')
36
- return path.join(serviceRoot(), ns)
60
+ return path.join(serviceRoot(), vault, ns)
37
61
  }
38
62
 
39
- /** Namespaces ya enrolados en esta máquina. */
63
+ /** La etiqueta de bóveda: `--vault`, o `DOTRINO_ENV_VAULT`. */
64
+ export const resolveVaultLabel = (v) => v || process.env.DOTRINO_ENV_VAULT || null
65
+
66
+ /**
67
+ * Lo enrolado en esta máquina: `[{ vault, ns }]`. Recorre DOS niveles.
68
+ *
69
+ * Y avisa de lo que quedó en el nivel de antes (`<raíz>/<ns>`): no se lee ni se usa —
70
+ * repescarlo en silencio sería fingir que la migración no hace falta— pero callarlo
71
+ * dejaría al dueño con un servicio que «desapareció» sin explicación.
72
+ */
40
73
  export function listEnrolled () {
41
- let names = []
42
- try { names = fs.readdirSync(serviceRoot()) } catch (_) { return [] }
43
- return names.filter((ns) => isValidSecretsNs(ns) && readServiceIdentity(path.join(serviceRoot(), ns)))
74
+ const out = []
75
+ let dirs = []
76
+ try { dirs = fs.readdirSync(serviceRoot(), { withFileTypes: true }) } catch (_) { return out }
77
+ for (const d of dirs) {
78
+ if (!d.isDirectory() || !isValidVaultLabel(d.name)) continue
79
+ // Un enrolamiento del formato viejo es un directorio con `service-identity.json`
80
+ // DENTRO; uno del nuevo tiene subdirectorios. Así se distinguen sin adivinar.
81
+ if (readServiceIdentity(path.join(serviceRoot(), d.name))) { out.push({ legacy: true, ns: d.name }); continue }
82
+ let hijos = []
83
+ try { hijos = fs.readdirSync(path.join(serviceRoot(), d.name)) } catch (_) { continue }
84
+ for (const ns of hijos) {
85
+ if (isValidSecretsNs(ns) && readServiceIdentity(path.join(serviceRoot(), d.name, ns))) out.push({ vault: d.name, ns })
86
+ }
87
+ }
88
+ return out
44
89
  }
45
90
 
46
91
  /**
@@ -98,10 +143,12 @@ let lastApplied = null
98
143
  * `overridden` = las que YA tenían otro valor en el entorno y el vault pisó. Es
99
144
  * el dato que delata un `.env` rancio, así que se reporta en vez de callarse.
100
145
  */
101
- export async function loadEnv ({ ns, dir, override, wait = true, required = [], onRetry, onPending } = {}) {
146
+ export async function loadEnv ({ ns, vault, dir, override, wait = true, required = [], onRetry, onPending } = {}) {
102
147
  if (override === undefined) override = overrideByDefault()
103
148
  ns = resolveNs(ns)
104
- dir = dir || serviceDir(ns)
149
+ // La ETIQUETA DE BÓVEDA es obligatoria salvo que se diga el directorio a pelo: es lo que
150
+ // hace que dos bóvedas puedan tener un cajón con el mismo nombre sin pisarse.
151
+ dir = dir || serviceDir(resolveVaultLabel(vault), ns)
105
152
  const load = wait ? waitForSecrets : fetchSecrets
106
153
  const secrets = await load({ dir, ns, onRetry, onPending })
107
154
 
@@ -199,9 +246,9 @@ export function applyEnv (secrets, override = overrideByDefault()) {
199
246
  * también. Se elige 0 porque salir a propósito no es un fallo.
200
247
  * @returns {Promise<{stop:()=>void}>}
201
248
  */
202
- export async function watchEnv ({ ns, dir, applied = lastApplied ?? undefined, onUpdate, exitCode = 0, quiet = false, ...rest } = {}) {
249
+ export async function watchEnv ({ ns, vault, dir, applied = lastApplied ?? undefined, onUpdate, exitCode = 0, quiet = false, ...rest } = {}) {
203
250
  ns = resolveNs(ns)
204
- dir = dir || serviceDir(ns)
251
+ dir = dir || serviceDir(resolveVaultLabel(vault), ns)
205
252
  const say = (m) => { if (!quiet) console.error(m) }
206
253
 
207
254
  const exitNow = (reason) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotrino/vaultd",
3
- "version": "0.77.0",
3
+ "version": "0.79.0",
4
4
  "type": "module",
5
5
  "description": "Certificador personal de Dotrino: daemon headless que custodia la clave maestra y delega capacidades a tus dispositivos por el proxy. Tu CA propia.",
6
6
  "bin": {
@@ -20,7 +20,7 @@
20
20
  "node": ">=20"
21
21
  },
22
22
  "dependencies": {
23
- "@dotrino/identity": "0.74.1",
23
+ "@dotrino/identity": "0.75.0",
24
24
  "@dotrino/passmanager": "^0.9.0",
25
25
  "@dotrino/proxy-client": "^0.13.1",
26
26
  "ws": "^8.18.0"
package/src/ctl.js CHANGED
@@ -202,9 +202,10 @@ async function cmdPair (args = []) {
202
202
  // `secrets:<ns>`, y los dos se combinan (`--service eco --scope sign` = un bot que
203
203
  // firma como aparato del acta y lee solo su cajón). `admin` no se empareja: se
204
204
  // concede desde el PC (`caps <ID> +administra`).
205
- // --approval: el aparato que entre pedirá tu aprobación (teléfono) en cada petición de
206
- // claves privadas. Por defecto NO pide; se cambia después con `caps <ID> +permiso`.
207
- const approval = args.includes('--approval')
205
+ // (Aquí vivía `--approval`. Ya no hace falta: PEDIR APROBACIÓN ES EL DEFECTO, y lo
206
+ // contrario —`unattended`— no se empareja, por el mismo motivo que `admin`: un QR que
207
+ // circula no puede conceder «llévate mis claves privadas sin preguntar». Se concede
208
+ // después y a mano con `caps <ID> +desatendido`.)
208
209
  // --name <n>: CÓMO SE VA A LLAMAR el aparato que entre, decidido aquí y antes de nada.
209
210
  // Sin esto el nombre lo ponía el propio aparato, que por defecto usa el apodo del
210
211
  // PERFIL: acababas con varios dispositivos llamados igual que tú, y para distinguirlos
@@ -311,7 +312,7 @@ async function cmdPair (args = []) {
311
312
 
312
313
  // La petición se escribe SIEMPRE (aunque no haya --service): lleva a qué perfil
313
314
  // se empareja el dispositivo.
314
- writeReq('pair-request.json', { ...(service ? { service } : {}), ...(scope ? { scope } : {}), ...(adopt ? { mode: 'adopt' } : {}), ...(approval ? { approval: true } : {}), ...(admin ? { admin: true } : {}), ...(label ? { label } : {}) })
315
+ writeReq('pair-request.json', { ...(service ? { service } : {}), ...(scope ? { scope } : {}), ...(adopt ? { mode: 'adopt' } : {}), ...(admin ? { admin: true } : {}), ...(label ? { label } : {}) })
315
316
  sendSignal(s.pid, 'SIGUSR1')
316
317
 
317
318
  let pair = null
@@ -575,7 +576,7 @@ async function cmdMembers () {
575
576
  // se mira quién es quién — y en la lista de variables ya seria tarde.
576
577
  if (m.cn && !m.canSeal) console.log(' %ssin llave de cifrado: NO puede leer sus variables%s', R, Z)
577
578
  }
578
- console.log('\n Cambiar permisos: dotrino-vault caps <ID> +firma | -firma | +guarda | -guarda | +lee | -lee | +administra | +aprueba | +contrasenas | +sella | +permiso')
579
+ console.log('\n Cambiar permisos: dotrino-vault caps <ID> +firma | -firma | +guarda | -guarda | +lee | -lee | +administra | +aprueba | +contrasenas | +sella | +desatendido')
579
580
  console.log(' «Permiso»: ese aparato solo recibe claves privadas cuando lo apruebas desde un aparato con «aprueba» (en cada arranque).')
580
581
  console.log(' «Administra» deja conectar y quitar dispositivos desde ese aparato, sin venir aquí.')
581
582
  console.log(' No deja cambiar permisos ni traspasar el mando: eso solo se hace en esta máquina.')
@@ -617,28 +618,22 @@ async function findMember (id) {
617
618
  return m
618
619
  }
619
620
 
620
- /**
621
- * `dotrino-vault approval <ID> on|off` — ese aparato solo recibe claves privadas con el
622
- * visto bueno de un aparato con `aprueba` (el teléfono). Es propiedad del APARATO, no del
623
- * cajón: el VPS desatendido no pide; la PC del dueño sí. Pide en cada petición, que para un
624
- * servicio bien hecho es una por arranque.
621
+ /*
622
+ * Aquí vivía `dotrino-vault approval <ID> on|off`. Ya no existe: pedir aprobación para
623
+ * recibir claves privadas dejó de ser una marca local de esta bóveda y pasó a ser un
624
+ * PERMISO DEL ACTA (`unattended`, dueño 2026-09-01). Se concede y se quita como cualquier
625
+ * otro, y por lo tanto viaja, se ve en la pantalla de permisos y lo respeta cualquier
626
+ * bóveda de la cuenta:
627
+ *
628
+ * dotrino-vault caps <ID> +desatendido ← se lleva las claves solo
629
+ * dotrino-vault caps <ID> -desatendido ← vuelve a pedir permiso (el defecto)
625
630
  */
626
- async function cmdApproval (args = []) {
627
- const [id, val] = args
628
- if (!id || !['on', 'off'].includes(val)) { console.error('uso: dotrino-vault approval <ID> on|off'); process.exit(2) }
629
- const m = await findMember(id)
630
- writeReq('secret-request.json', { op: 'approval', pub: m.pub, id: m.id, approval: val === 'on' })
631
- sendSignal(requireDaemon().pid, 'SIGUSR2')
632
- console.log(val === 'on'
633
- ? `Listo: ${m.id} solo recibirá claves privadas cuando un aparato con caps <ID> +aprueba lo apruebe (en cada arranque).`
634
- : `Listo: ${m.id} vuelve a recibir sus claves sin aprobación.`)
635
- }
636
631
 
637
632
  /** `dotrino-vault caps <ID> ±permiso` — cambia lo que puede hacer un dispositivo. */
638
633
  async function cmdCaps (args = []) {
639
634
  const [id, ...changes] = args
640
635
  if (!id || !changes.length) {
641
- console.error('uso: dotrino-vault caps <ID> +firma|-firma|+guarda|-guarda|+lee|-lee|+administra|-administra|+aprueba|-aprueba|+contrasenas|-contrasenas|+sella|-sella|+permiso|-permiso')
636
+ console.error('uso: dotrino-vault caps <ID> +firma|-firma|+guarda|-guarda|+lee|-lee|+administra|-administra|+aprueba|-aprueba|+contrasenas|-contrasenas|+sella|-sella|+desatendido|-desatendido')
642
637
  process.exit(2)
643
638
  }
644
639
  const CAP_BY_WORD = {
@@ -650,25 +645,34 @@ async function cmdCaps (args = []) {
650
645
  // `contraseñas`: el gestor (la extensión, la app del teléfono) puede PEDIR
651
646
  // credenciales de a una. Se acepta con y sin tilde: nadie escribe la ñ en una CLI.
652
647
  contrasenas: 'passwords', 'contraseñas': 'passwords',
648
+ // `desatendido`: RECIBE CLAVES PRIVADAS SIN QUE NADIE APRUEBE. Sin él, la bóveda no
649
+ // entrega nada hasta que un aparato con `aprueba` lo firme (una vez por arranque).
650
+ //
651
+ // No se llama `permiso` —como la marca local de antes— a propósito: aquella significaba
652
+ // «este aparato PIDE permiso» y esta significa lo contrario. Reusar la palabra con el
653
+ // sentido invertido es la clase de trampa que se paga una madrugada.
654
+ desatendido: 'unattended', unattended: 'unattended',
653
655
  sign: 'sign', store: 'store', read: 'read', admin: 'admin', approve: 'approve',
654
656
  passwords: 'passwords'
655
657
  }
656
658
  const s = requireDaemon()
657
659
  const m = await findMember(id)
658
660
 
659
- // `+permiso` / `-permiso` no es una capacidad del acta: es la marca de la bóveda «este
660
- // aparato pide aprobación al recibir claves». Se cambia aquí, como cualquier permiso.
661
- const rest = []
661
+ // El desvío de `+permiso` a una marca local de la bóveda se fue: ahora TODO es un
662
+ // permiso del acta y sigue el mismo camino, incluido `desatendido`.
663
+ //
664
+ // Y `permiso` se rechaza en voz alta en vez de ignorarse: significaba lo CONTRARIO
665
+ // («este aparato pide aprobación»), así que quien lo teclee de memoria estaría pidiendo
666
+ // justo lo opuesto a lo que cree.
662
667
  for (const c of changes) {
663
668
  const w = c.slice(1).toLowerCase()
664
- if ((c[0] === '+' || c[0] === '-') && (w === 'permiso' || w === 'approval')) {
665
- writeReq('secret-request.json', { op: 'approval', pub: m.pub, id: m.id, approval: c[0] === '+' })
666
- sendSignal(s.pid, 'SIGUSR2')
667
- console.log(c[0] === '+' ? `Listo: ${m.id} pedirá tu aprobación en cada petición de claves.` : `Listo: ${m.id} ya no pide aprobación.`)
668
- await sleep(300)
669
- } else rest.push(c)
669
+ if (w === 'permiso' || w === 'approval') {
670
+ console.error('`%s` ya no existe. Pedir aprobación es el DEFECTO; lo que se concede es lo contrario:', w)
671
+ console.error(' dotrino-vault caps %s +desatendido ← se lleva las claves privadas sin aprobación', m.id)
672
+ process.exit(2)
673
+ }
670
674
  }
671
- if (!rest.length) return
675
+ const rest = changes
672
676
  const caps = new Set(m.caps)
673
677
  for (const c of rest) {
674
678
  const sign = c[0]
@@ -1495,7 +1499,6 @@ function help () {
1495
1499
  pair --name <nombre> cómo se llamará el aparato que entre. Sin esto el nombre lo pone
1496
1500
  ÉL, y por defecto usa el apodo del perfil: acabas con varios
1497
1501
  dispositivos llamados igual que tú
1498
- pair --approval el aparato que entre pedirá tu aprobación (teléfono) al recibir claves
1499
1502
  pair --admin el aparato que entre podrá ADMINISTRAR (es lo que es una consola).
1500
1503
  El QR no lleva nada: el permiso se aplica al aprobar el código aquí
1501
1504
  pair --quiet escupe SOLO la invitación (una línea) y termina: sin QR y sin
package/src/daemon.js CHANGED
@@ -143,7 +143,6 @@ export async function runDaemon () {
143
143
 
144
144
  // --- SIGUSR1: iniciar emparejamiento ---
145
145
  const pairFile = path.join(dir, 'pair.json')
146
- let pendingApproval = false // lo pidió `pair --approval`; se aplica al aprobar
147
146
  /**
148
147
  * Lo pidió `pair --admin`: el aparato que entre por ESTA invitación podrá administrar.
149
148
  *
@@ -199,9 +198,6 @@ export async function runDaemon () {
199
198
  // La etiqueta por defecto de un servicio es su ns a secas: la lista ya marca [servicio «ns»],
200
199
  // y el prefijo «service:» confundía (el cajón se llama claude, no service:claude).
201
200
  const label = pairReq?.label || (isService ? pairReq.service : 'cli')
202
- // `pair --approval`: el aparato que entre por esta invitación pedirá el visto bueno del
203
- // teléfono cada vez que reciba claves privadas (se aplica al aprobarlo, abajo).
204
- pendingApproval = !!pairReq?.approval
205
201
  pendingAdmin = !!pairReq?.admin
206
202
  // `profile`/`profileName`: la CUENTA del vault a la que entra el dispositivo.
207
203
  // Con varias bóvedas en el mismo daemon, el QR sale de UNA y quien empareja
@@ -392,8 +388,6 @@ export async function runDaemon () {
392
388
  const vault = targetOf(appr)
393
389
  if (!vault) throw Object.assign(new Error('profile locked'), { code: 'PROFILE_LOCKED' })
394
390
  const r = await vault.approveDevice(appr.code); rm(pendingEnrollFile); rm(pairFile)
395
- if (pendingApproval && r?.cert?.sub) { try { await vault.setApproval(r.cert.sub, true); console.log('[vault] the new device will need approval on every key request') } catch (e) { console.error('[vault] could not flag approval: %s', e.message) } }
396
- pendingApproval = false
397
391
  // `pair --admin`: se le SUMA `admin` a lo que ya tiene, no se le reescriben los
398
392
  // permisos — el aparato acaba de entrar con el scope que pidió la invitación.
399
393
  if (pendingAdmin && r?.deviceId) {
@@ -542,7 +536,6 @@ export async function runDaemon () {
542
536
  console.log('[vault] %d secret(s) applied in one go: %s', changed.length, sec.pub ? 'device' : sec.ns)
543
537
  } else if (sec.op === 'set') { await vault.setSecret(sec.ns, sec.key, sec.value, sec.public); console.log('[vault] secret saved: %s/%s', sec.ns, sec.key) }
544
538
  else if (sec.op === 'rm') { await vault.deleteSecret(sec.ns, sec.key); console.log('[vault] secret deleted: %s/%s', sec.ns, sec.key) }
545
- else if (sec.op === 'approval') { const r = await vault.setApproval(sec.pub, !!sec.approval); console.log('[vault] device %s: approval %s', sec.id || '?', r.approval ? 'REQUIRED on every key request' : 'off') }
546
539
  else if (sec.op === 'dev-set') { await vault.setDeviceSecret(sec.pub, sec.key, sec.value, sec.public); console.log('[vault] device secret saved: %s', sec.key) }
547
540
  else if (sec.op === 'dev-rm') { await vault.deleteDeviceSecret(sec.pub, sec.key); console.log('[vault] device secret deleted: %s', sec.key) }
548
541
  // Saldar lo que quedó a deber: heredarle a un aparato nuevo lo ya guardado y
package/src/passwords.js CHANGED
@@ -27,7 +27,8 @@ export const PASSWORDS_CAP = 'passwords'
27
27
  * `cek` la llave de la bóveda de contraseñas (CryptoKey AES-GCM)
28
28
  * `isAllowed(pubkey)` qué aparatos pueden pedir — lo decide el acta, no esto
29
29
  * `encPubOf(pubkey)` su llave de cifrado, para sellarle la respuesta
30
- * `needsApproval(pubkey)` si ese aparato está marcado para aprobar. Se compone con el
30
+ * `needsApproval(pubkey)` (async) si ese aparato tiene que pedir aprobación según el
31
+ * ACTA (`unattended`). Se compone con el
31
32
  * criterio del propio protocolo: solo lo PRIVADO pregunta
32
33
  * `approve({ pubkey, op })` pide el visto bueno (el teléfono) y espera
33
34
  * `devices()` / `unlink(pubkey)` administración, opcional
@@ -66,7 +67,11 @@ export function createPasswordDesk (opts = {}) {
66
67
  // reescribirlo aquí: si cada bóveda tuviera su idea de qué es privado, serían tres
67
68
  // bóvedas distintas otra vez.
68
69
  needsApproval: async (op, payload, pubkey) =>
69
- needsApproval(pubkey) && await responder.wantsPrivate(op, payload),
70
+ // `await` OBLIGATORIO: `needsApproval` pasó a ser asíncrona (lee el acta en vivo), y
71
+ // una promesa SIEMPRE es verdadera. Sin el await esto decía «sí, pide aprobación»
72
+ // para todo el mundo, y en el otro sentido —si algún día devuelve false— diría que
73
+ // no hace falta. Un `&&` sobre una promesa es una comprobación que no comprueba.
74
+ await needsApproval(pubkey) && await responder.wantsPrivate(op, payload),
70
75
  approve,
71
76
  admin: devices && unlink ? { devices, unlink } : null,
72
77
  onRequest: (r) => {
package/src/transport.js CHANGED
@@ -66,7 +66,16 @@ export async function createTransport ({ identity, dir, url = DEFAULT_PROXY, com
66
66
  if (comm && esMiembro) {
67
67
  const data = { op: 'identify', publickey: comm, token: client.token, ts: Date.now() }
68
68
  const signature = await commKey.sign(data)
69
- if (signature) return client.identify({ data, signature, acta: record })
69
+ if (signature) {
70
+ await client.identify({ data, signature, acta: record })
71
+ // SE DICE QUE SE IDENTIFICÓ, y con qué llave. Este camino era MUDO: al pasar de la
72
+ // maestra a la llave de comunicación, el único rastro de «estoy alcanzable» que
73
+ // quedaba en el log era el del repliegue. Así que cuando la consola decía «no
74
+ // contesta», no había forma de saber desde aquí si la bóveda estaba atada al proxio
75
+ // o no. Una línea por reconexión es barata; no tenerla cuesta una hora de sondeos.
76
+ log('[vault] identified on the proxy with the communication key · record #%s', record?.seq ?? '?')
77
+ return
78
+ }
70
79
  }
71
80
 
72
81
  // REPLIEGUE, y solo para migrar: una bóveda que todavía no ha metido su llave de
package/src/tui/app.js CHANGED
@@ -332,8 +332,13 @@ function pairModeRows (st, t) {
332
332
  * `sealer` es justo el que hay que entender para el multivault. Se ven siempre, aunque en
333
333
  * un teléfono normal `sealer` no lo vaya a usar nadie: verlo apagado explica el modelo
334
334
  * (dueño, 2026-08-31).
335
+ *
336
+ * `unattended` entró por lo mismo (dueño, 2026-09-01): era una marca local de la bóveda
337
+ * —invisible aquí y solo tocable por la CLI— y encima con el sentido invertido. Es EL
338
+ * permiso que hay que entender para un servidor: dice si esa máquina se lleva tus claves
339
+ * privadas sola o si te lo pregunta. Verlo apagado es la mitad del mensaje.
335
340
  */
336
- const CAPS_ORDER = ['sign', 'store', 'read', 'admin', 'approve', 'passwords', 'sealer']
341
+ const CAPS_ORDER = ['sign', 'store', 'read', 'admin', 'approve', 'passwords', 'sealer', 'unattended']
337
342
 
338
343
  function capsRows (st, t) {
339
344
  const i = L(st)
package/src/tui/i18n.js CHANGED
@@ -48,7 +48,8 @@ const es = {
48
48
  admin: 'Administrar el perfil',
49
49
  passwords: 'Pedir tus contraseñas',
50
50
  approve: 'Aprobar pedidos',
51
- sealer: 'Sellar el acta'
51
+ sealer: 'Sellar el acta',
52
+ unattended: 'Recibir claves sin aprobación'
52
53
  },
53
54
  capHint: {
54
55
  sign: 'usar tu identidad en las apps del ecosistema',
@@ -61,7 +62,8 @@ const es = {
61
62
  // daemon, o un navegador que se puso de bóveda). En un teléfono normal se concede y no
62
63
  // lo usa nadie. Se enseña igual, y se dice: verlo desactivado explica el modelo;
63
64
  // esconderlo hacía creer que no existe (dueño, 2026-08-31).
64
- sealer: 'admitir aparatos y cambiar permisos él solo, si esta bóveda no está. Solo sirve en otra BÓVEDA'
65
+ sealer: 'admitir aparatos y cambiar permisos él solo, si esta bóveda no está. Solo sirve en otra BÓVEDA',
66
+ unattended: 'llevarse claves privadas SIN preguntarte. Apagado, cada vez que arranque tienes que aprobarlo desde un aparato con «Aprobar»'
65
67
  },
66
68
  confirmAdmin: (id) => `¿Dejar que ${id} conecte y quite dispositivos sin venir aquí?`,
67
69
  capGiven: (n) => `Concedido: ${n}`,
@@ -358,7 +360,8 @@ const en = {
358
360
  admin: 'Manage the profile',
359
361
  passwords: 'Ask for your passwords',
360
362
  approve: 'Approve requests',
361
- sealer: 'Seal the record'
363
+ sealer: 'Seal the record',
364
+ unattended: 'Take keys without approval'
362
365
  },
363
366
  capHint: {
364
367
  sign: 'use your identity across the ecosystem apps',
@@ -367,7 +370,8 @@ const en = {
367
370
  admin: 'connect and remove devices from that device, without coming here',
368
371
  passwords: 'the manager: ask for one credential per site, never the whole list',
369
372
  approve: 'give the go-ahead when another device asks for private keys',
370
- sealer: 'admit devices and change permissions on its own if this vault is gone. Only useful on another VAULT'
373
+ sealer: 'admit devices and change permissions on its own if this vault is gone. Only useful on another VAULT',
374
+ unattended: 'take private keys WITHOUT asking you. Off, you must approve it from a device with «Approve» every time it starts'
371
375
  },
372
376
  confirmAdmin: (id) => `Let ${id} connect and remove devices without coming here?`,
373
377
  capGiven: (n) => `Granted: ${n}`,
package/src/vault.js CHANGED
@@ -113,18 +113,25 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
113
113
  * dos clases de espera.
114
114
  */
115
115
  const waiters = new Map()
116
- // APARATOS QUE PIDEN APROBACIÓN: una lista de llaves en `approval.json` (cifrado en reposo
117
- // como todo el dir). Es decisión de esta bóveda, no del acta: es ella la que entrega.
118
- const approvalFile = path.join(dir, 'approval.json')
119
- const approvalAtRest = atRestFor(dir)
120
- const readSupervised = () => { try { const d = JSON.parse(approvalAtRest.decrypt(fs.readFileSync(approvalFile, 'utf8'))); return Array.isArray(d?.members) ? d.members : [] } catch (_) { return [] } }
121
- const needsApproval = (pub) => readSupervised().includes(pub)
122
- async function setApproval (pub, on) {
123
- const cur = new Set(readSupervised())
124
- if (on) cur.add(pub); else cur.delete(pub)
125
- fs.writeFileSync(approvalFile, approvalAtRest.encrypt(JSON.stringify({ v: 1, members: [...cur] })), { mode: 0o600 })
126
- audit('approval', { device: await deviceIdOf(pub).catch(() => null), on: !!on })
127
- return { approval: !!on }
116
+ /**
117
+ * ¿ESTE APARATO TIENE QUE PEDIR PERMISO PARA RECIBIR CLAVES PRIVADAS?
118
+ *
119
+ * Lo dice el ACTA, con el permiso `unattended`: quien lo tiene se las lleva solo; quien
120
+ * no, espera a que un aparato con `approve` lo firme.
121
+ *
122
+ * Antes era al revés y vivía aquí: una lista local de «estos SÍ piden permiso», así que
123
+ * un aparato nuevo nacía pudiendo llevarse las claves y nadie elegía eso — se elegía por
124
+ * omisión. Ahora hay que conceder a propósito quién puede llevárselas solo, y **si falta
125
+ * el dato se pide permiso**, que es lo que hay que hacer cuando no se sabe (dueño,
126
+ * 2026-09-01).
127
+ *
128
+ * Y al estar en el acta lo respeta cualquier bóveda de la cuenta, se ve en la pantalla de
129
+ * permisos como los demás, y se quita quitándolo — sin acordarse de un registro escondido
130
+ * en una máquina.
131
+ */
132
+ function needsApproval (pub, record) {
133
+ if (!record) return true // sin acta no se decide que sí: se pide permiso
134
+ return !Acta.memberCan(record, pub, 'unattended')
128
135
  }
129
136
  // LA BÓVEDA DE CONTRASEÑAS: entradas y su llave, en el dir del perfil y cifradas en
130
137
  // reposo como todo lo demás. QUIÉN PUEDE PEDIR LO DICE EL ACTA (capacidad
@@ -285,8 +292,26 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
285
292
  return new Set(revoked.map((r) => r.nonce))
286
293
  }
287
294
 
295
+ /**
296
+ * CONTESTAR SIN MATAR LA CONEXIÓN.
297
+ *
298
+ * El proxio corta los frames a 1 MB (`maxPayload`), y `ws` no «descarta» el que se pasa:
299
+ * CIERRA EL SOCKET con un 1009. La bóveda se queda muda, sin un solo error en su log, y
300
+ * desde fuera se ve igual que si estuviera apagada. Eso ya pasó y duró tres días.
301
+ *
302
+ * Así que una respuesta que no quepa no se manda: se sustituye por un error, que sí cabe.
303
+ * Una bóveda que dice «no cabe» se arregla; una muda no se puede ni diagnosticar.
304
+ */
288
305
  const reply = (to, obj) => {
289
- try { client.send(to, obj) } catch (e) { log('[vault] could not reply:', e.message) }
306
+ try {
307
+ const bytes = Buffer.byteLength(JSON.stringify(obj))
308
+ if (bytes > MAX_REPLY_BYTES) {
309
+ log(`[vault] the reply to ${obj?.type} does not fit (${bytes} bytes > ${MAX_REPLY_BYTES}): sending an error instead of killing the connection`)
310
+ audit('reply-too-big', { type: obj?.type || null, bytes })
311
+ return client.send(to, { type: MSG.ERROR, error: `reply too big (${bytes} bytes): ask for less at a time` })
312
+ }
313
+ client.send(to, obj)
314
+ } catch (e) { log('[vault] could not reply:', e.message) }
290
315
  }
291
316
 
292
317
  // FRESCURA anti-replay: toda petición firmada debe traer `data.ts` dentro de una
@@ -680,7 +705,9 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
680
705
  if (!msg.chain?.length) msg.chain = null
681
706
  const left = msg.chain?.length || 0
682
707
  log(`[vault] record chain trimmed to fit the transport: ${left}/${full} link(s), ${size(msg)} bytes`)
683
- if (size(msg) > MAX_REPLY_BYTES) log(`[vault] ⚠ the reply STILL does not fit (${size(msg)} bytes): the proxy will drop it`)
708
+ // Si tras podar la cadena SIGUE sin caber, no se avisa y se manda igual —eso mataba la
709
+ // conexión—: se devuelve tal cual y `reply` lo sustituye por un error. El aviso de aquí
710
+ // sobra porque el de `reply` dice lo mismo y además dice qué hizo.
684
711
  return msg
685
712
  }
686
713
 
@@ -815,7 +842,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
815
842
  // claves en memoria y no vuelve a pedir. El pedido se apunta, se avisa a quien aprueba y se
816
843
  // contesta «pendiente»; la respuesta de verdad sale cuando el teléfono firme
817
844
  // (`handleApproval`), sellada a la misma `ek`.
818
- if (needsApproval(chk.device)) {
845
+ if (needsApproval(chk.device, record)) {
819
846
  const deviceId = await deviceIdOf(chk.device).catch(() => null)
820
847
  const label = (record?.members || []).find((m) => m.pub === chk.device)?.label || ''
821
848
  const pend = approvals.request({ ns, device: chk.device, deviceId, label, ek: p.data.ek })
@@ -1037,7 +1064,11 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
1037
1064
  // que haya una segunda lista que acordarse de tocar.
1038
1065
  isAllowed: (pub) => !!actaCache && Acta.memberCan(actaCache, pub, 'passwords'),
1039
1066
  encPubOf: (pub) => (actaCache?.members || []).find((m) => m.pub === pub)?.encPub || null,
1040
- needsApproval: (pub) => needsApproval(pub),
1067
+ // EN VIVO, no del caché: conceder o quitar `unattended` tiene que surtir efecto en
1068
+ // el acto. Con el caché, la mesa de contraseñas seguía decidiendo con la foto del
1069
+ // último refresco — y en el sentido peligroso: un aparato al que le acabas de quitar
1070
+ // el permiso se seguía sirviendo solo.
1071
+ needsApproval: async (pub) => needsApproval(pub, await refreshActa()),
1041
1072
  // El teléfono: se apunta el pedido, se le avisa y esta promesa espera su firma.
1042
1073
  // Es el mismo camino que ya recorren los cajones de secretos.
1043
1074
  approve: async ({ pubkey, op }) => {
@@ -2031,6 +2062,9 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
2031
2062
 
2032
2063
  return {
2033
2064
  identity, client, store, threads, secrets, master, fingerprint: fp, dir,
2065
+ // Se expone para poder PROBAR que una respuesta que no cabe no mata la conexión: es el
2066
+ // único punto por el que sale todo, y el fallo que cierra vive justo ahí.
2067
+ reply,
2034
2068
  /**
2035
2069
  * SOLTAR LA MAESTRA. Cerrar el perfil tiene que sacarla de la memoria, no solo dejar de
2036
2070
  * cargarla en el siguiente arranque: si no, el candado seguiría siendo una bandera al
@@ -2053,11 +2087,10 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
2053
2087
  startPairing: desk.startPairing,
2054
2088
  stopPairing: desk.stopPairing,
2055
2089
  listPending: desk.listPending,
2056
- // Cajones con aprobación por uso (`approvals.js`).
2057
- // Aparatos que piden aprobación al recibir claves (`approvals.js`).
2058
- needsApproval,
2059
- setApproval,
2060
- supervised: () => readSupervised(),
2090
+ // ¿Pide permiso este aparato para recibir claves privadas? Lo dice el ACTA
2091
+ // (`unattended`), no una lista de esta máquina — por eso ya no hay `setApproval` ni
2092
+ // `supervised`: se concede y se quita como cualquier otro permiso, con `caps`.
2093
+ needsApproval: async (pub) => needsApproval(pub, await refreshActa()),
2061
2094
  // La bóveda de contraseñas (`passwords.js`). Aquí SÍ se lista: es donde está la
2062
2095
  // llave. Lo que no puede es listarla un aparato.
2063
2096
  passwordDevices,