@dotrino/vaultd 0.78.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/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.78.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/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
@@ -835,7 +842,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
835
842
  // claves en memoria y no vuelve a pedir. El pedido se apunta, se avisa a quien aprueba y se
836
843
  // contesta «pendiente»; la respuesta de verdad sale cuando el teléfono firme
837
844
  // (`handleApproval`), sellada a la misma `ek`.
838
- if (needsApproval(chk.device)) {
845
+ if (needsApproval(chk.device, record)) {
839
846
  const deviceId = await deviceIdOf(chk.device).catch(() => null)
840
847
  const label = (record?.members || []).find((m) => m.pub === chk.device)?.label || ''
841
848
  const pend = approvals.request({ ns, device: chk.device, deviceId, label, ek: p.data.ek })
@@ -1057,7 +1064,11 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
1057
1064
  // que haya una segunda lista que acordarse de tocar.
1058
1065
  isAllowed: (pub) => !!actaCache && Acta.memberCan(actaCache, pub, 'passwords'),
1059
1066
  encPubOf: (pub) => (actaCache?.members || []).find((m) => m.pub === pub)?.encPub || null,
1060
- 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()),
1061
1072
  // El teléfono: se apunta el pedido, se le avisa y esta promesa espera su firma.
1062
1073
  // Es el mismo camino que ya recorren los cajones de secretos.
1063
1074
  approve: async ({ pubkey, op }) => {
@@ -2076,11 +2087,10 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
2076
2087
  startPairing: desk.startPairing,
2077
2088
  stopPairing: desk.stopPairing,
2078
2089
  listPending: desk.listPending,
2079
- // Cajones con aprobación por uso (`approvals.js`).
2080
- // Aparatos que piden aprobación al recibir claves (`approvals.js`).
2081
- needsApproval,
2082
- setApproval,
2083
- 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()),
2084
2094
  // La bóveda de contraseñas (`passwords.js`). Aquí SÍ se lista: es donde está la
2085
2095
  // llave. Lo que no puede es listarla un aparato.
2086
2096
  passwordDevices,