@dotrino/vaultd 0.51.0 → 0.52.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/enroll.js CHANGED
@@ -52,7 +52,7 @@ export const MSG_REVOKED = 'vault.revoked'
52
52
  export const MSG_ERROR = 'vault.error'
53
53
 
54
54
  /** Los scopes del cert se corresponden 1:1 con las capacidades del acta (§D7). */
55
- const SCOPE_TO_CAP = { 'vault:sign': 'sign', 'vault:store': 'store', 'vault:read': 'read', 'vault:admin': 'admin' }
55
+ const SCOPE_TO_CAP = { 'vault:sign': 'sign', 'vault:store': 'store', 'vault:read': 'read', 'vault:admin': 'admin', 'vault:passwords': 'passwords' }
56
56
  export const scopeToCaps = (scope) =>
57
57
  (Array.isArray(scope) ? scope : [scope]).map((s) => SCOPE_TO_CAP[s]).filter(Boolean)
58
58
 
@@ -98,7 +98,11 @@ export const SCOPE = Object.freeze({
98
98
  // NO incluye cambiar permisos, traspasar el mando ni conceder `admin`: eso es el rol
99
99
  // de master y sigue siendo local. No se empareja — se concede desde el PC.
100
100
  ADMIN: 'vault:admin',
101
- APPROVE: 'vault:approve' // aprobar pedidos de secretos (cajones con `approval`); se concede a mano, como admin
101
+ APPROVE: 'vault:approve', // aprobar pedidos de secretos (cajones con `approval`); se concede a mano, como admin
102
+ // El gestor de contraseñas: pedir credenciales de la bóveda, de a una y por dominio.
103
+ // Nunca lista la bóveda entera. Este SÍ se empareja (`pair --scope contrasenas`): es
104
+ // lo primero que hace la extensión, y no tendría sentido obligar a un segundo paso.
105
+ PASSWORDS: 'vault:passwords'
102
106
  })
103
107
 
104
108
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotrino/vaultd",
3
- "version": "0.51.0",
3
+ "version": "0.52.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.60.3",
23
+ "@dotrino/identity": "^0.61.0",
24
24
  "@dotrino/passmanager": "^0.1.0",
25
25
  "@dotrino/proxy-client": "^0.13.1",
26
26
  "ws": "^8.18.0"
package/src/ctl.js CHANGED
@@ -195,16 +195,19 @@ async function cmdPair (args = []) {
195
195
  if (scIdx >= 0) {
196
196
  const raw = args[scIdx + 1]
197
197
  if (!raw || raw.startsWith('-')) { console.error('uso: dotrino-vault pair --scope sign,read,store,secrets:<ns>'); process.exit(2) }
198
- const ALIAS = { firma: 'sign', lee: 'read', guarda: 'store' }
198
+ const ALIAS = { firma: 'sign', lee: 'read', guarda: 'store', contrasenas: 'passwords', 'contraseñas': 'passwords' }
199
199
  scope = []
200
200
  for (const tok of raw.split(',').map((t) => t.trim()).filter(Boolean)) {
201
201
  const t = ALIAS[tok] || tok
202
202
  if (t === 'admin' || t === 'administra') { console.error('`admin` no se empareja: concédelo desde el PC con dotrino-vault caps <ID> +administra'); process.exit(2) }
203
203
  if (t === 'approve' || t === 'aprueba') { console.error('`approve` no se empareja: concédelo desde el PC con dotrino-vault caps <ID> +aprueba'); process.exit(2) }
204
204
  if (t === 'sign' || t === 'read' || t === 'store') { scope.push('vault:' + t); continue }
205
+ // El gestor de contraseñas SÍ se empareja con su permiso puesto: es lo único que
206
+ // va a hacer ese aparato, y pedirlo en dos pasos era el paso que nadie daba.
207
+ if (t === 'passwords') { scope.push('vault:passwords'); continue }
205
208
  const m = /^secrets:([a-z0-9-]{1,32})$/.exec(t)
206
209
  if (m) { scope.push('vault:secrets:' + m[1]); continue }
207
- console.error('permiso desconocido: %s (sign | read | store | secrets:<ns>)', tok); process.exit(2)
210
+ console.error('permiso desconocido: %s (sign | read | store | contrasenas | secrets:<ns>)', tok); process.exit(2)
208
211
  }
209
212
  if (service) scope.push('vault:secrets:' + service)
210
213
  scope = [...new Set(scope)]
@@ -404,7 +407,7 @@ async function cmdMembers () {
404
407
  // se mira quién es quién — y en la lista de variables ya seria tarde.
405
408
  if (m.cn && !m.canSeal) console.log(' %ssin llave de cifrado: NO puede leer sus variables%s', R, Z)
406
409
  }
407
- console.log('\n Cambiar permisos: dotrino-vault caps <ID> +firma | -firma | +guarda | -guarda | +lee | -lee | +administra | +aprueba | +permiso')
410
+ console.log('\n Cambiar permisos: dotrino-vault caps <ID> +firma | -firma | +guarda | -guarda | +lee | -lee | +administra | +aprueba | +contrasenas | +permiso')
408
411
  console.log(' «Permiso»: ese aparato solo recibe claves privadas cuando lo apruebas desde un aparato con «aprueba» (en cada arranque).')
409
412
  console.log(' «Administra» deja conectar y quitar dispositivos desde ese aparato, sin venir aquí.')
410
413
  console.log(' No deja cambiar permisos ni traspasar el mando: eso solo se hace en esta máquina.')
@@ -463,42 +466,20 @@ async function cmdApproval (args = []) {
463
466
  : `Listo: ${m.id} vuelve a recibir sus claves sin aprobación.`)
464
467
  }
465
468
 
466
- /**
467
- * `dotrino-vault passwords <ID> on|off` — deja (o no) que ese aparato pida contraseñas
468
- * de tu bóveda.
469
- *
470
- * Es una lista de ESTA bóveda, no del acta, por lo mismo que `approval`: es ella la que
471
- * entrega. El acta manda igual — si revocas el aparato allí, deja de pedir aunque siga
472
- * en esta lista. Y si además le pones `approval on`, cada arranque suyo te pedirá el
473
- * visto bueno en el teléfono.
474
- */
475
- async function cmdPasswords (args = []) {
476
- const [id, val] = args
477
- if (!id || !['on', 'off'].includes(val)) {
478
- console.error('uso: dotrino-vault passwords <ID> on|off')
479
- process.exit(1)
480
- }
481
- const m = await findMember(id)
482
- writeReq('secret-request.json', { op: 'passwords', pub: m.pub, id: m.id, label: m.label || '', on: val === 'on' })
483
- sendSignal(requireDaemon().pid, 'SIGUSR2')
484
- if (val === 'on') {
485
- console.log(`Listo: ${m.id} puede pedir contraseñas de tu bóveda (de una en una; nunca la lista entera).`)
486
- console.log('Reinicia el vault: al arrancar imprime el código de enlace que se pega en la extensión.')
487
- } else {
488
- console.log(`Listo: ${m.id} deja de poder pedir contraseñas.`)
489
- }
490
- }
491
-
492
469
  /** `dotrino-vault caps <ID> ±permiso` — cambia lo que puede hacer un dispositivo. */
493
470
  async function cmdCaps (args = []) {
494
471
  const [id, ...changes] = args
495
472
  if (!id || !changes.length) {
496
- console.error('uso: dotrino-vault caps <ID> +firma|-firma|+guarda|-guarda|+lee|-lee|+administra|-administra|+aprueba|-aprueba|+permiso|-permiso')
473
+ console.error('uso: dotrino-vault caps <ID> +firma|-firma|+guarda|-guarda|+lee|-lee|+administra|-administra|+aprueba|-aprueba|+contrasenas|-contrasenas|+permiso|-permiso')
497
474
  process.exit(2)
498
475
  }
499
476
  const CAP_BY_WORD = {
500
477
  firma: 'sign', guarda: 'store', lee: 'read', administra: 'admin', aprueba: 'approve',
501
- sign: 'sign', store: 'store', read: 'read', admin: 'admin', approve: 'approve'
478
+ // `contraseñas`: el gestor (la extensión, la app del teléfono) puede PEDIR
479
+ // credenciales de a una. Se acepta con y sin tilde: nadie escribe la ñ en una CLI.
480
+ contrasenas: 'passwords', 'contraseñas': 'passwords',
481
+ sign: 'sign', store: 'store', read: 'read', admin: 'admin', approve: 'approve',
482
+ passwords: 'passwords'
502
483
  }
503
484
  const s = requireDaemon()
504
485
  const m = await findMember(id)
@@ -1191,7 +1172,7 @@ function help () {
1191
1172
  (sin la bandera entra a la cuenta activa, o a la de --profile)
1192
1173
  pair --service <ns> empareja un SERVICIO (proxy, geo…) con acceso SOLO a sus secretos
1193
1174
  pair --approval el aparato que entre pedirá tu aprobación (teléfono) al recibir claves
1194
- pair --scope <lista> los PERMISOS del cert: sign,read,store,secrets:<ns> (sin esto: sign,read,store;
1175
+ pair --scope <lista> los PERMISOS del cert: sign,read,store,contrasenas,secrets:<ns> (sin esto: sign,read,store;
1195
1176
  se combina con --service: --service eco --scope sign = bot que firma y lee su cajón)
1196
1177
  secret set <ns> <CLAVE> <valor> variable del scope <ns>: la comparten TODOS los
1197
1178
  aparatos del perfil que sirven ese namespace
@@ -1206,10 +1187,6 @@ function help () {
1206
1187
  secret device set <ID> CLAVE=valor …
1207
1188
  secret device import <ID> [archivo.env]
1208
1189
  secret device rm <ID> <CLAVE> borra una variable de ese aparato
1209
- passwords <ID> on|off deja que ese aparato pida CONTRASEÑAS de tu bóveda
1210
- (de una en una; nunca la lista entera). Con
1211
- approval on además te pedirá el visto bueno en el
1212
- teléfono. Reinicia el vault tras el primer on
1213
1190
  secret list lista los dos cajones: el valor de las públicas,
1214
1191
  tapadas las privadas
1215
1192
  secret show [device] <ns|ID> <CLAVE>
@@ -1234,7 +1211,7 @@ function help () {
1234
1211
  me tu perfil (nombre, foto, datos) tal como lo tiene la bóveda
1235
1212
  members el acta del perfil: quién es tuyo y qué puede hacer
1236
1213
  label <ID> <nombre> renombra un dispositivo (el nombre con el que lo reconoces)
1237
- caps <ID> ±permiso cambia permisos (+firma -guarda +administra …)
1214
+ caps <ID> ±permiso cambia permisos (+firma -guarda +administra +contrasenas …)
1238
1215
  revoke <ID|nonce> quita un dispositivo (con el ID, todos sus certificados)
1239
1216
  activity [n] bitácora de seguridad: firmas, renovaciones, enrolados, rechazos
1240
1217
  logs últimos logs del servicio
@@ -1284,7 +1261,6 @@ export async function runCtl (argv) {
1284
1261
  case 'revoke': return cmdRevoke(rest[0])
1285
1262
  case 'secret': return cmdSecret(rest)
1286
1263
  case 'approval': return cmdApproval(rest)
1287
- case 'passwords': return cmdPasswords(rest)
1288
1264
  case 'activity': return cmdActivity(Number(rest[0]) || 30)
1289
1265
  case 'logs': return cmdLogs()
1290
1266
  case 'version':
package/src/daemon.js CHANGED
@@ -405,11 +405,6 @@ export async function runDaemon () {
405
405
  console.log('[vault] %d secret(s) applied in one go: %s', changed.length, sec.pub ? 'device' : sec.ns)
406
406
  } 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) }
407
407
  else if (sec.op === 'rm') { await vault.deleteSecret(sec.ns, sec.key); console.log('[vault] secret deleted: %s/%s', sec.ns, sec.key) }
408
- else if (sec.op === 'passwords') {
409
- const r = await vault.setPasswordDevice(sec.pub, { label: sec.label, on: !!sec.on })
410
- console.log('[vault] passwords: device %s %s', sec.id || '?', sec.on ? 'ALLOWED' : 'removed')
411
- if (sec.on) console.log('[vault] restart the vault so it starts answering password requests')
412
- }
413
408
  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') }
414
409
  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) }
415
410
  else if (sec.op === 'dev-rm') { await vault.deleteDeviceSecret(sec.pub, sec.key); console.log('[vault] device secret deleted: %s', sec.key) }
package/src/tui/app.js CHANGED
@@ -318,7 +318,7 @@ function pairModeRows (st, t) {
318
318
  * una marca de si los tiene. El de administrar va aparte y avisado: es el único que deja
319
319
  * a ese aparato meter y sacar dispositivos sin venir aquí.
320
320
  */
321
- const CAPS_ORDER = ['sign', 'store', 'read', 'admin']
321
+ const CAPS_ORDER = ['sign', 'store', 'read', 'admin', 'passwords']
322
322
 
323
323
  function capsRows (st, t) {
324
324
  const i = L(st)
package/src/tui/i18n.js CHANGED
@@ -45,13 +45,15 @@ const es = {
45
45
  sign: 'Firmar como tú',
46
46
  store: 'Guardar tus datos',
47
47
  read: 'Leer tus datos',
48
- admin: 'Administrar el perfil'
48
+ admin: 'Administrar el perfil',
49
+ passwords: 'Pedir tus contraseñas'
49
50
  },
50
51
  capHint: {
51
52
  sign: 'usar tu identidad en las apps del ecosistema',
52
53
  store: 'escribir en tu bóveda (perfil, contenido, datos sensibles)',
53
54
  read: 'ver lo que guardaste',
54
- admin: 'conectar y quitar dispositivos desde ese aparato, sin venir aquí'
55
+ admin: 'conectar y quitar dispositivos desde ese aparato, sin venir aquí',
56
+ passwords: 'el gestor: pedir una credencial por sitio, nunca la lista entera'
55
57
  },
56
58
  confirmAdmin: (id) => `¿Dejar que ${id} conecte y quite dispositivos sin venir aquí?`,
57
59
  capGiven: (n) => `Concedido: ${n}`,
@@ -341,13 +343,15 @@ const en = {
341
343
  sign: 'Sign as you',
342
344
  store: 'Save your data',
343
345
  read: 'Read your data',
344
- admin: 'Manage the profile'
346
+ admin: 'Manage the profile',
347
+ passwords: 'Ask for your passwords'
345
348
  },
346
349
  capHint: {
347
350
  sign: 'use your identity across the ecosystem apps',
348
351
  store: 'write to your vault (profile, content, sensitive data)',
349
352
  read: 'see what you saved',
350
- admin: 'connect and remove devices from that device, without coming here'
353
+ admin: 'connect and remove devices from that device, without coming here',
354
+ passwords: 'the manager: ask for one credential per site, never the whole list'
351
355
  },
352
356
  confirmAdmin: (id) => `Let ${id} connect and remove devices without coming here?`,
353
357
  capGiven: (n) => `Granted: ${n}`,
package/src/vault.js CHANGED
@@ -102,9 +102,10 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
102
102
  return { approval: !!on }
103
103
  }
104
104
  // LA BÓVEDA DE CONTRASEÑAS: entradas y su llave, en el dir del perfil y cifradas en
105
- // reposo como todo lo demás. Quién puede pedir es una LISTA DE ESTA BÓVEDA, no del
106
- // acta por lo mismo que `approval.json`: es ella la que entrega. El acta sigue
107
- // mandando en que el aparato exista y no esté revocado, y eso se comprueba aparte.
105
+ // reposo como todo lo demás. QUIÉN PUEDE PEDIR LO DICE EL ACTA (capacidad
106
+ // `passwords`), como cualquier otro permiso: aquí no hay una segunda lista de
107
+ // aparatos. Tenerla obligaba a acordarse de dos sitios al quitar un aparato, y era
108
+ // además un emparejamiento paralelo al del ecosistema.
108
109
  const passwordsFile = path.join(dir, 'passwords.json')
109
110
  const passwordsAtRest = atRestFor(dir)
110
111
  const readPasswordsFile = () => {
@@ -118,13 +119,14 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
118
119
  const passwordsStore = {
119
120
  async get (k) { return readPasswordsFile()?.data?.[k] },
120
121
  async set (k, v) {
121
- const d = readPasswordsFile() || { v: 1, data: {}, devices: [] }
122
+ const d = readPasswordsFile() || { v: 1, data: {} }
122
123
  d.data = { ...(d.data || {}), [k]: v }
123
124
  writePasswordsFile(d)
124
125
  },
125
126
  }
126
127
 
127
- const passwordDevices = () => readPasswordsFile()?.devices || []
128
+ /** Los aparatos que el acta autoriza a pedir credenciales (`caps <ID> +contraseñas`). */
129
+ const passwordDevices = () => (actaCache?.members || []).filter((m) => Acta.memberCan(actaCache, m.pub, 'passwords'))
128
130
 
129
131
  /**
130
132
  * El acta vigente, en caché.
@@ -153,20 +155,10 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
153
155
  }
154
156
  const key = await crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt'])
155
157
  const raw = new Uint8Array(await crypto.subtle.exportKey('raw', key))
156
- writePasswordsFile({ ...(d || { v: 1, data: {} }), cek: Buffer.from(raw).toString('base64'), devices: d?.devices || [] })
158
+ writePasswordsFile({ ...(d || { v: 1, data: {} }), cek: Buffer.from(raw).toString('base64') })
157
159
  return key
158
160
  }
159
161
 
160
- /** Autorizar/retirar un aparato para pedir contraseñas (`dotrino-vault passwords`). */
161
- async function setPasswordDevice (pub, { label = '', on = true } = {}) {
162
- const d = readPasswordsFile() || { v: 1, data: {}, devices: [] }
163
- const resto = (d.devices || []).filter((x) => x.pubkey !== pub)
164
- if (on) resto.push({ pubkey: pub, label: String(label || '').slice(0, 60), ts: Date.now() })
165
- writePasswordsFile({ ...d, devices: resto })
166
- audit('passwords.device', { device: await deviceIdOf(pub).catch(() => null), on: !!on })
167
- return { ok: true }
168
- }
169
-
170
162
  const approvalsSweeper = setInterval(() => { for (const g of approvals.sweep()) audit('secrets.expired', { id: g.id, ns: g.ns, device: g.deviceId }) }, 30 * 1000); approvalsSweeper.unref?.()
171
163
  const secrets = openSecretsStore(dir, {
172
164
  sealer: makeSealer(),
@@ -764,69 +756,94 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
764
756
  // ----- LA BÓVEDA DE CONTRASEÑAS -----
765
757
  //
766
758
  // Se monta al final y envuelto: es una pieza opcional y un fallo suyo no puede tumbar
767
- // la CA. Solo se levanta si hay algún aparato autorizado — sin eso no hay a quién
759
+ // la CA. Solo se levanta cuando el acta autoriza a alguien — sin eso no hay a quién
768
760
  // responder, y crear la llave por si acaso sería crear un secreto que nadie pidió.
761
+ //
762
+ // Y se levanta EN CUANTO pasa, no en el siguiente arranque: conceder el permiso ya es
763
+ // un acto explícito del dueño, y pedirle además que reinicie la bóveda es de las cosas
764
+ // que se olvidan y luego parecen un fallo del gestor.
769
765
  let passwords = null
770
- try {
771
- if (passwordDevices().length) {
772
- const { createPasswordDesk } = await import('./passwords.js')
773
- await refreshActa()
774
- const actaSweeper = setInterval(refreshActa, 5000)
775
- actaSweeper.unref?.()
776
- passwords = createPasswordDesk({
777
- client,
778
- store: passwordsStore,
779
- cek: await passwordsKey(),
780
- // DOS condiciones, y hacen falta las dos: que esta bóveda lo haya autorizado
781
- // (su lista) y que el ACTA siga reconociéndolo. Revocar un aparato en el acta
782
- // le corta esto también, sin tener que acordarse de dos sitios.
783
- isAllowed: (pub) => {
784
- if (!passwordDevices().some((d) => d.pubkey === pub)) return false
785
- return (actaCache?.members || []).some((m) => m.pub === pub)
786
- },
787
- encPubOf: (pub) => (actaCache?.members || []).find((m) => m.pub === pub)?.encPub || null,
788
- needsApproval: (pub) => needsApproval(pub),
789
- // El teléfono: se apunta el pedido, se le avisa y esta promesa espera su firma.
790
- // Es el mismo camino que ya recorren los cajones de secretos.
791
- approve: async ({ pubkey, op }) => {
792
- const deviceId = await deviceIdOf(pubkey).catch(() => null)
793
- const label = (actaCache?.members || []).find((m) => m.pub === pubkey)?.label || ''
794
- const pend = approvals.request({ ns: 'passwords', device: pubkey, deviceId, label, ek: '' })
795
- audit('passwords.pending', { device: deviceId, id: pend.id, op })
796
- log(`[vault] passwords: ${deviceId || '????-????'} is waiting for approval (${pend.id})`)
797
- const espera = new Promise((resolve) => {
798
- waiters.set(pend.id, resolve)
799
- // Si nadie contesta, vence solo: la promesa no se queda colgada para siempre
800
- // y el aparato recibe un no en vez de esperar sin fin.
801
- const t = setTimeout(() => {
802
- if (waiters.delete(pend.id)) {
803
- audit('passwords.expired', { device: deviceId, id: pend.id })
804
- resolve(false)
805
- }
806
- }, PENDING_TTL_MS)
807
- t.unref?.()
808
- })
809
- await notifyApprovers(pend, actaCache)
810
- return espera
811
- },
812
- audit,
813
- log,
814
- }).start()
815
- log('[vault] passwords: atendiendo peticiones de %d aparato(s)', passwordDevices().length)
816
- // El código que se pega en la extensión o en la consola web. Lleva las DOS
817
- // públicas: por la de firma enruta el proxio, a la de cifrado se le sella el
818
- // contenido. Es público: no hay nada aquí que no pueda verse.
819
- try {
820
- const codigo = Buffer.from(JSON.stringify({
821
- v: 1,
822
- sign: await client.getPublicKey(),
823
- enc: await identity.getEncryptionPubkey(),
824
- })).toString('base64url')
825
- log('[vault] passwords: enlaza un aparato con este código:\n%s', codigo)
826
- } catch (e) {
827
- log('[vault] passwords: no se pudo componer el código de enlace: %s', e.message)
766
+ /**
767
+ * EL SELLADO del gestor: la misma cripto de los sobres del ecosistema
768
+ * (`@dotrino/identity/content`), atada a la llave de cifrado de esta bóveda.
769
+ *
770
+ * Va aquí y no en el cliente del transporte porque el cliente del vault es UNO y lo
771
+ * comparte todo: el protocolo de la CA viaja en claro a propósito (un enrolamiento es
772
+ * público hasta que hay cert). Lo que se sella es lo del gestor, y por eso `isSealed`
773
+ * mira su marca y no otra.
774
+ *
775
+ * Sin esto la bóveda RECIBÍA los sobres y los tiraba —el cliente no tenía con qué
776
+ * abrirlos— y el aparato se quedaba esperando sin que nada lo dijera.
777
+ */
778
+ const passwordSealing = {
779
+ async seal (msg, peerEncPub) {
780
+ if (!peerEncPub) throw Object.assign(new Error('no encryption key'), { code: 'unsealed' })
781
+ return {
782
+ app: 'passmanager',
783
+ // Destinatarios como OBJETOS y no como llaves sueltas: `encrypt` expande cada
784
+ // uno a todos los aparatos de esa persona, y una cadena a secas se le cae por
785
+ // el `continue` sin envolver nada — el sobre salía vacío y sin error.
786
+ sealed: await identity.encrypt([{ encryptionPubkey: peerEncPub }], JSON.stringify(msg)),
787
+ from: await identity.getEncryptionPubkey()
828
788
  }
829
- }
789
+ },
790
+ // `decrypt` devuelve `{ plaintext }`, no la cadena.
791
+ async open (env) { return JSON.parse((await identity.decrypt(env.from, null, env.sealed)).plaintext) },
792
+ isSealed: (m) => !!m && m.app === 'passmanager' && !!m.sealed
793
+ }
794
+ const startPasswordDesk = async () => {
795
+ if (passwords || !passwordDevices().length) return
796
+ const { createPasswordDesk } = await import('./passwords.js')
797
+ client.updateConfig({ sealing: passwordSealing })
798
+ passwords = createPasswordDesk({
799
+ client,
800
+ store: passwordsStore,
801
+ cek: await passwordsKey(),
802
+ // UNA sola condición, y es del ACTA: el aparato tiene la capacidad `passwords`.
803
+ // Quitársela —o quitar el aparato— le corta esto en la siguiente pasada, sin
804
+ // que haya una segunda lista que acordarse de tocar.
805
+ isAllowed: (pub) => !!actaCache && Acta.memberCan(actaCache, pub, 'passwords'),
806
+ encPubOf: (pub) => (actaCache?.members || []).find((m) => m.pub === pub)?.encPub || null,
807
+ needsApproval: (pub) => needsApproval(pub),
808
+ // El teléfono: se apunta el pedido, se le avisa y esta promesa espera su firma.
809
+ // Es el mismo camino que ya recorren los cajones de secretos.
810
+ approve: async ({ pubkey, op }) => {
811
+ const deviceId = await deviceIdOf(pubkey).catch(() => null)
812
+ const label = (actaCache?.members || []).find((m) => m.pub === pubkey)?.label || ''
813
+ const pend = approvals.request({ ns: 'passwords', device: pubkey, deviceId, label, ek: '' })
814
+ audit('passwords.pending', { device: deviceId, id: pend.id, op })
815
+ log(`[vault] passwords: ${deviceId || '????-????'} is waiting for approval (${pend.id})`)
816
+ const espera = new Promise((resolve) => {
817
+ waiters.set(pend.id, resolve)
818
+ // Si nadie contesta, vence solo: la promesa no se queda colgada para siempre
819
+ // y el aparato recibe un no en vez de esperar sin fin.
820
+ const t = setTimeout(() => {
821
+ if (waiters.delete(pend.id)) {
822
+ audit('passwords.expired', { device: deviceId, id: pend.id })
823
+ resolve(false)
824
+ }
825
+ }, PENDING_TTL_MS)
826
+ t.unref?.()
827
+ })
828
+ await notifyApprovers(pend, actaCache)
829
+ return espera
830
+ },
831
+ audit,
832
+ log,
833
+ }).start()
834
+ log('[vault] passwords: serving %d device(s)', passwordDevices().length)
835
+ }
836
+ try {
837
+ // El acta ANTES de mirarla: es ella la que dice si hay a quién responder, y sin
838
+ // refrescarla primero el mostrador no se levantaba nunca en un arranque en frío.
839
+ await refreshActa()
840
+ const actaSweeper = setInterval(() => {
841
+ refreshActa().then(startPasswordDesk).catch(() => {})
842
+ }, 5000)
843
+ actaSweeper.unref?.()
844
+ if (passwordDevices().length) await startPasswordDesk()
845
+ // Ni código de enlace ni pegar nada: un gestor entra como cualquier otro aparato.
846
+ else log('[vault] passwords: no device may ask yet · pair one with `dotrino-vault pair --scope contrasenas`')
830
847
  } catch (e) {
831
848
  log('[vault] passwords: no se pudo levantar (%s); el resto sigue igual', e.message)
832
849
  }
@@ -1730,7 +1747,6 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
1730
1747
  // La bóveda de contraseñas (`passwords.js`). Aquí SÍ se lista: es donde está la
1731
1748
  // llave. Lo que no puede es listarla un aparato.
1732
1749
  passwordDevices,
1733
- setPasswordDevice,
1734
1750
  passwordsVault: () => passwords?.vault || null,
1735
1751
  listApprovals: () => approvals.list(),
1736
1752
  // Aprobar desde el PC avisa igual que aprobar a distancia: el resto de tus