@dotrino/vaultd 0.122.0 → 0.123.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/index.js CHANGED
@@ -394,7 +394,7 @@ export async function startDeviceVault (identity, { proxyUrl, client: injectedCl
394
394
  async function handleLoginStart (from, p) {
395
395
  if (!logins) return noLogins(from)
396
396
  try {
397
- const { lid, response } = logins.loginBegin({ user: p?.user, request: p?.request })
397
+ const { lid, response } = await logins.loginBegin({ user: p?.user, request: p?.request })
398
398
  send(from, { type: MSG.LOGIN_RESPONSE, lid, response })
399
399
  } catch (e) { loginError(from, e) }
400
400
  }
@@ -402,7 +402,7 @@ export async function startDeviceVault (identity, { proxyUrl, client: injectedCl
402
402
  async function handleLoginFinish (from, p) {
403
403
  if (!logins) return noLogins(from)
404
404
  try {
405
- const r = logins.loginEnd({ lid: p?.lid, finalization: p?.finalization, label: p?.label })
405
+ const r = await logins.loginEnd({ lid: p?.lid, finalization: p?.finalization, label: p?.label })
406
406
  const acta = (await identity.profileActa?.().catch(() => null))?.acta || null
407
407
  send(from, { type: MSG.LOGIN_OK, sid: r.sid, blob: r.blob, cert: r.cert, iss: r.iss || iss, acta })
408
408
  } catch (e) { loginError(from, e) }
@@ -24,7 +24,7 @@
24
24
  * binario, `lib/src/index.js` en la pestaña—. Lo único que no es puro es `registerLogin`, al
25
25
  * final, porque hace falta la identidad que firma.
26
26
  */
27
- import { server as opaque, suiteId } from '@dotrino/opaque'
27
+ import { server as opaquePorDefecto, suiteId as suitePorDefecto } from '@dotrino/opaque'
28
28
  import { pubkeyId } from '@dotrino/identity/capabilities'
29
29
  import { deviceIdOf, scopeToCaps, scopeToCn } from './enroll.js'
30
30
  import { SCOPE } from './protocol.js'
@@ -181,10 +181,25 @@ export async function openDeviceKeys (exportKey, blob) {
181
181
  * `load()` devuelve el estado guardado (o `null` la primera vez)
182
182
  * `save(s)` lo guarda
183
183
  * `now()` el reloj, para poder probar el freno sin esperar una hora
184
+ * `opaque` la mitad de SERVIDOR de OPAQUE. Por defecto la del paquete, que es WASM y
185
+ * corre en el mismo hilo. Se puede inyectar OTRA porque en una extensión MV3 el WASM
186
+ * vive al otro lado de un `postMessage` —en una página sandbox, para no tener que
187
+ * abrirle la CSP a toda la extensión—, y desde allí solo puede contestar una promesa.
188
+ *
189
+ * **Por eso los métodos que tocan OPAQUE son ASÍNCRONOS**, también con la implementación de
190
+ * casa: una firma que cambia según quién la use son dos mostradores, y de eso va la regla
191
+ * de las tres versiones.
184
192
  */
185
- export function createLoginDesk ({ load, save, now = () => Date.now() } = {}) {
193
+ export function createLoginDesk ({ load, save, now = () => Date.now(), opaque = opaquePorDefecto } = {}) {
186
194
  if (typeof load !== 'function' || typeof save !== 'function') throw new Error('createLoginDesk: load and save are required')
187
195
 
196
+ /**
197
+ * QUÉ SUITE se está usando, dicha por quien hace el cálculo. Con el OPAQUE de casa es la
198
+ * del paquete; con uno inyectado, la suya — preguntárselo a él es lo único que no miente,
199
+ * porque el registro que queda guardado lo hizo él.
200
+ */
201
+ const suite = () => (typeof opaque.suiteId === 'function' ? opaque.suiteId() : suitePorDefecto())
202
+
188
203
  /** Intercambios a medias: viven en memoria, y por eso un reinicio los tira. */
189
204
  const exchanges = new Map()
190
205
 
@@ -195,9 +210,9 @@ export function createLoginDesk ({ load, save, now = () => Date.now() } = {}) {
195
210
  }
196
211
 
197
212
  /** La preparación del servidor nace con el primer usuario y no se toca nunca más. */
198
- function setupOf (state) {
213
+ async function setupOf (state) {
199
214
  if (state.setup) return state.setup
200
- state.setup = opaque.createSetup()
215
+ state.setup = await opaque.createSetup()
201
216
  return state.setup
202
217
  }
203
218
 
@@ -234,30 +249,30 @@ export function createLoginDesk ({ load, save, now = () => Date.now() } = {}) {
234
249
  * ALTA, primer paso. La contraseña no llega hasta aquí: lo que llega es el mensaje de
235
250
  * registro de OPAQUE, que no la lleva ni permite adivinarla.
236
251
  */
237
- registerBegin ({ user, request, replace = false } = {}) {
252
+ async registerBegin ({ user, request, replace = false } = {}) {
238
253
  const state = read()
239
254
  const existing = userOf(state, user)
240
255
  if (existing && !replace) throw err('user-exists', `there is already a login called "${user}"`)
241
256
  if (!existing && replace) throw err('no-user', `there is no login called "${user}"`)
242
257
  if (typeof request !== 'string' || !request) throw err('bad-input', 'request required')
243
- const setup = setupOf(state)
244
- const response = opaque.registrationResponse({ setup, request, credentialId: user })
258
+ const setup = await setupOf(state)
259
+ const response = await opaque.registrationResponse({ setup, request, credentialId: user })
245
260
  save(state)
246
- return { response, suite: suiteId() }
261
+ return { response, suite: await suite() }
247
262
  },
248
263
 
249
264
  /**
250
265
  * ALTA, segundo paso. `blob` son las llaves privadas del aparato, ya cerradas por quien
251
266
  * creó el aparato con la llave que sale de la contraseña: aquí no se abre nunca.
252
267
  */
253
- registerFinish ({ user, upload, pub, encPub = null, deviceId = null, label = '', blob, replace = false } = {}) {
268
+ async registerFinish ({ user, upload, pub, encPub = null, deviceId = null, label = '', blob, replace = false } = {}) {
254
269
  const state = read()
255
270
  const existing = userOf(state, user)
256
271
  if (existing && !replace) throw err('user-exists', `there is already a login called "${user}"`)
257
272
  if (!existing && replace) throw err('no-user', `there is no login called "${user}"`)
258
273
  if (typeof blob !== 'string' || !blob) throw err('bad-input', 'blob required (the device keys, sealed with the password)')
259
274
  if (!replace && (typeof pub !== 'string' || !pub)) throw err('bad-input', 'pub required')
260
- const record = opaque.registrationFinish({ upload })
275
+ const record = await opaque.registrationFinish({ upload })
261
276
  const t = now()
262
277
  state.users[user] = {
263
278
  ...(existing || {}),
@@ -267,7 +282,7 @@ export function createLoginDesk ({ load, save, now = () => Date.now() } = {}) {
267
282
  label: label || existing?.label || '',
268
283
  record,
269
284
  blob,
270
- suite: suiteId(),
285
+ suite: await suite(),
271
286
  createdAt: existing?.createdAt || t,
272
287
  passwordChangedAt: t,
273
288
  // Cambiar la contraseña cierra lo abierto: si alguien entró con la vieja, deja de
@@ -277,7 +292,7 @@ export function createLoginDesk ({ load, save, now = () => Date.now() } = {}) {
277
292
  nextTryAt: 0
278
293
  }
279
294
  save(state)
280
- return { user, suite: suiteId() }
295
+ return { user, suite: await suite() }
281
296
  },
282
297
 
283
298
  /** El certificado del aparato, que firma la bóveda al admitirlo en el acta. Es público. */
@@ -298,7 +313,7 @@ export function createLoginDesk ({ load, save, now = () => Date.now() } = {}) {
298
313
  * registro inventado: desde fuera no se puede averiguar qué usuarios hay. Y cuenta
299
314
  * contra el mismo freno, para que tampoco se pueda medir por el tiempo.
300
315
  */
301
- loginBegin ({ user, request } = {}) {
316
+ async loginBegin ({ user, request } = {}) {
302
317
  sweepExchanges()
303
318
  const state = read()
304
319
  if (typeof request !== 'string' || !request) throw err('bad-input', 'request required')
@@ -314,8 +329,8 @@ export function createLoginDesk ({ load, save, now = () => Date.now() } = {}) {
314
329
  u.fails = (u.fails || 0) + 1
315
330
  u.nextTryAt = t + backoffFor(u.fails)
316
331
  }
317
- const setup = setupOf(state)
318
- const { state: serverState, response } = opaque.loginStart({
332
+ const setup = await setupOf(state)
333
+ const { state: serverState, response } = await opaque.loginStart({
319
334
  setup,
320
335
  record: u?.record || null,
321
336
  request,
@@ -332,7 +347,7 @@ export function createLoginDesk ({ load, save, now = () => Date.now() } = {}) {
332
347
  * INICIO DE SESIÓN, segundo paso. Si la contraseña es la buena, devuelve el paquete de
333
348
  * llaves —que solo abre esa contraseña—, el certificado y el acta.
334
349
  */
335
- loginEnd ({ lid, finalization, label = '' } = {}) {
350
+ async loginEnd ({ lid, finalization, label = '' } = {}) {
336
351
  sweepExchanges()
337
352
  const x = exchanges.get(lid)
338
353
  if (!x) throw err('no-exchange', 'that login is not in flight any more: start again')
@@ -341,7 +356,7 @@ export function createLoginDesk ({ load, save, now = () => Date.now() } = {}) {
341
356
  const u = x.known ? state.users[x.user] : null
342
357
 
343
358
  try {
344
- opaque.loginFinish({ state: x.state, finalization })
359
+ await opaque.loginFinish({ state: x.state, finalization })
345
360
  } catch (e) {
346
361
  // Contraseña equivocada, usuario inexistente o mensaje alterado: el MISMO error.
347
362
  // Distinguirlos diría qué usuarios existen. El intento ya está contado desde
@@ -438,7 +453,7 @@ export async function registerLogin ({
438
453
  scope, unattended = false, replace = false
439
454
  } = {}) {
440
455
  if (replace) {
441
- logins.registerFinish({ user, upload, blob, replace: true })
456
+ await logins.registerFinish({ user, upload, blob, replace: true })
442
457
  return { ok: true, user, replaced: true }
443
458
  }
444
459
  // PERMISOS, no tipos: los del scope, más `unattended` si quien lo crea lo eligió
@@ -452,7 +467,7 @@ export async function registerLogin ({
452
467
  throw Object.assign(new Error('this vault cannot add devices to the account record: no login was created'), { code: 'admit-unavailable' })
453
468
  }
454
469
  const deviceId = await deviceIdOf(pub)
455
- logins.registerFinish({ user, upload, pub, encPub, deviceId, label, blob })
470
+ await logins.registerFinish({ user, upload, pub, encPub, deviceId, label, blob })
456
471
  try {
457
472
  const { cert } = await identity.signDelegation(pub, scopes, { label: label || `login:${user}` })
458
473
  const cn = scopeToCn(scopes)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotrino/vaultd",
3
- "version": "0.122.0",
3
+ "version": "0.123.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": {
@@ -24,7 +24,7 @@
24
24
  "@dotrino/compat": "0.4.0",
25
25
  "@dotrino/identity": "0.92.0",
26
26
  "@dotrino/opaque": "0.1.0",
27
- "@dotrino/passmanager": "^0.10.1",
27
+ "@dotrino/passmanager": "0.12.0",
28
28
  "@dotrino/proxy-client": "0.23.1",
29
29
  "@dotrino/roadmap": "0.3.3",
30
30
  "@dotrino/store": "0.11.0",
package/src/ctl.js CHANGED
@@ -676,6 +676,42 @@ async function findMember (id) {
676
676
  * dotrino-vault caps <ID> -desatendido ← vuelve a pedir permiso (el defecto)
677
677
  */
678
678
 
679
+ /**
680
+ * LAS PALABRAS DE LOS PERMISOS, en un solo sitio.
681
+ *
682
+ * Las usa `caps <ID> ±permiso` y también `logins add`, que da de alta un aparato que se abre
683
+ * con usuario y contraseña: los dos hablan de lo mismo, así que tienen que llamarlo igual.
684
+ * Tenerlo dos veces es cómo se acaba aceptando `+aprueba` en un comando y no en el otro.
685
+ */
686
+ const CAP_BY_WORD = {
687
+ firma: 'sign', guarda: 'store', lee: 'read', administra: 'admin', aprueba: 'approve',
688
+ // `sella`: SELLAR EL ACTA. Es lo que convierte a otra bóveda en respaldo de esta —
689
+ // podrá admitir aparatos y cambiar permisos si esta se pierde. No es un traspaso:
690
+ // quien manda sigue mandando. Como `administra`, no se empareja: se concede aquí.
691
+ sella: 'sealer', sealer: 'sealer',
692
+ // `replica`: reparte los sobres que esta bóveda ya firmó, cuando ella no está. No
693
+ // tiene maestra y no puede abrir nada. A diferencia de `sella`, SÍ se empareja.
694
+ replica: 'replica', replicador: 'replica',
695
+ // `contraseñas`: el gestor (la extensión, la app del teléfono) puede PEDIR
696
+ // credenciales de a una. Se acepta con y sin tilde: nadie escribe la ñ en una CLI.
697
+ contrasenas: 'passwords', 'contraseñas': 'passwords',
698
+ // `passkeys`: además de las contraseñas, puede abrir la LLAVE PRIVADA de una passkey.
699
+ // Va aparte porque una passkey copiada sirve hasta que la borres en cada sitio donde
700
+ // la registraste — de una contraseña, al menos, se puede cambiar
701
+ // (`dotrino-passmanager/docs/sealed-passwords.md` §2.8). Sin `contraseñas` no
702
+ // significa nada: la passkey vive en una entrada de contraseñas.
703
+ passkeys: 'passkeys', llaves: 'passkeys',
704
+ // `desatendido`: RECIBE CLAVES PRIVADAS SIN QUE NADIE APRUEBE. Sin él, la bóveda no
705
+ // entrega nada hasta que un aparato con `aprueba` lo firme (una vez por arranque).
706
+ //
707
+ // No se llama `permiso` —como la marca local de antes— a propósito: aquella significaba
708
+ // «este aparato PIDE permiso» y esta significa lo contrario. Reusar la palabra con el
709
+ // sentido invertido es la clase de trampa que se paga una madrugada.
710
+ desatendido: 'unattended', unattended: 'unattended',
711
+ sign: 'sign', store: 'store', read: 'read', admin: 'admin', approve: 'approve',
712
+ passwords: 'passwords'
713
+ }
714
+
679
715
  /** `dotrino-vault caps <ID> ±permiso` — cambia lo que puede hacer un dispositivo. */
680
716
  async function cmdCaps (args = []) {
681
717
  const [id, ...changes] = args
@@ -683,28 +719,6 @@ async function cmdCaps (args = []) {
683
719
  console.error('uso: dotrino-vault caps <ID> +firma|-firma|+guarda|-guarda|+lee|-lee|+administra|-administra|+aprueba|-aprueba|+contrasenas|-contrasenas|+sella|-sella|+desatendido|-desatendido')
684
720
  process.exit(2)
685
721
  }
686
- const CAP_BY_WORD = {
687
- firma: 'sign', guarda: 'store', lee: 'read', administra: 'admin', aprueba: 'approve',
688
- // `sella`: SELLAR EL ACTA. Es lo que convierte a otra bóveda en respaldo de esta —
689
- // podrá admitir aparatos y cambiar permisos si esta se pierde. No es un traspaso:
690
- // quien manda sigue mandando. Como `administra`, no se empareja: se concede aquí.
691
- sella: 'sealer', sealer: 'sealer',
692
- // `replica`: reparte los sobres que esta bóveda ya firmó, cuando ella no está. No
693
- // tiene maestra y no puede abrir nada. A diferencia de `sella`, SÍ se empareja.
694
- replica: 'replica', replicador: 'replica',
695
- // `contraseñas`: el gestor (la extensión, la app del teléfono) puede PEDIR
696
- // credenciales de a una. Se acepta con y sin tilde: nadie escribe la ñ en una CLI.
697
- contrasenas: 'passwords', 'contraseñas': 'passwords',
698
- // `desatendido`: RECIBE CLAVES PRIVADAS SIN QUE NADIE APRUEBE. Sin él, la bóveda no
699
- // entrega nada hasta que un aparato con `aprueba` lo firme (una vez por arranque).
700
- //
701
- // No se llama `permiso` —como la marca local de antes— a propósito: aquella significaba
702
- // «este aparato PIDE permiso» y esta significa lo contrario. Reusar la palabra con el
703
- // sentido invertido es la clase de trampa que se paga una madrugada.
704
- desatendido: 'unattended', unattended: 'unattended',
705
- sign: 'sign', store: 'store', read: 'read', admin: 'admin', approve: 'approve',
706
- passwords: 'passwords'
707
- }
708
722
  const s = requireDaemon()
709
723
  const m = await findMember(id)
710
724
 
@@ -1558,7 +1572,9 @@ async function loginHere (opaqueClient, user, password) {
1558
1572
  const LOGINS_USAGE = `uso: dotrino-vault logins <orden>
1559
1573
 
1560
1574
  logins [ls] los aparatos que se abren con usuario y contraseña
1561
- logins add <usuario> [nombre del equipo]
1575
+ logins add <usuario> [nombre del equipo] [±permiso …]
1576
+ permisos como en «caps» (+aprueba, -guarda, +desatendido…).
1577
+ Sin decir nada: firma, lee y guarda
1562
1578
  logins passwd <usuario> cambia la contraseña (cierra lo que estuviera abierto)
1563
1579
  logins close <usuario> [sid] cierra una sesión abierta (sin sid, todas)
1564
1580
  logins unblock <usuario> quita la espera de los intentos fallidos
@@ -1924,7 +1940,9 @@ function help () {
1924
1940
  revoke <ID|nonce> quita un dispositivo (con el ID, todos sus certificados)
1925
1941
  logins los aparatos que se abren con usuario y contraseña (para un equipo
1926
1942
  prestado, donde no puedes emparejar nada)
1927
- logins add <usuario> [nombre del equipo]
1943
+ logins add <usuario> [nombre del equipo] [±permiso …]
1944
+ permisos como en «caps» (+aprueba, -guarda, +desatendido…).
1945
+ Sin decir nada: firma, lee y guarda
1928
1946
  crea uno. La contraseña se teclea aquí y no sale de esta terminal:
1929
1947
  la bóveda guarda un paquete de llaves que solo ella abre
1930
1948
  logins passwd <usuario> cambia la contraseña (cierra lo que estuviera abierto)
package/src/daemon.js CHANGED
@@ -467,6 +467,15 @@ export async function runDaemon () {
467
467
  for (const f of r.failed) console.error('[vault] %s: could not reseal (%s)', f.owner, f.error)
468
468
  note += ` · ⚠ ${r.failed.length} cajón(es) NO se pudieron reenvolver (${r.failed.map((f) => f.owner).join(', ')}): sus aparatos no podrán leerlos`
469
469
  }
470
+ // Y LAS CONTRASEÑAS, por lo mismo y en el mismo momento: convertirlas al formato
471
+ // sellado es un acto único que necesita la frase, y repasar sus envolturas hace
472
+ // falta cada vez que cambia quién puede leer. Un aparato que entró después de
473
+ // guardada una contraseña no puede abrirla hasta que alguien se la envuelva, y
474
+ // con la bóveda cerrada nadie puede.
475
+ const p = await mgr.get(id)?.sealPasswords?.(ak)
476
+ if (p?.converted) note += ` · ${p.converted} contraseña(s) pasadas al formato sellado`
477
+ if (p?.wrapped || p?.dropped) note += ` · contraseñas: llavero al día${p.dropped ? ` (${p.dropped} envoltura(s) retirada(s))` : ''}`
478
+ if (p?.convertError) note += ` · ⚠ las contraseñas NO se pudieron convertir (${p.convertError})`
470
479
  } catch (e) {
471
480
  console.error('[vault] could not rebuild the keyring on unlock:', e.message)
472
481
  note += ` · ⚠ el llavero NO se pudo rehacer (${e.message}): los aparatos no podrán leer sus cajones`
package/src/passwords.js CHANGED
@@ -1,97 +1,155 @@
1
1
  /**
2
- * LA BÓVEDA DE CONTRASEÑAS del dueño, atendida por el vault.
2
+ * LA BÓVEDA DE CONTRASEÑAS del dueño, atendida por el vault — Y QUE EL VAULT NO PUEDE LEER.
3
3
  *
4
- * `dotrino-passmanager serve` es la versión mínima: bóveda propia, aprobación por
5
- * consola, bitácora propia. Esto es lo mismo pero dentro del vault, que ya tiene lo
6
- * que allí había que improvisar:
4
+ * Hasta 0.122 esto tenía una llave propia (`passwordsKey()`) guardada **dentro del mismo
5
+ * archivo que las entradas**, cifrada con la llave de la máquina. O sea: el demonio con el
6
+ * perfil CERRADO seguía descifrando contraseñas, y cualquiera con una copia del disco las
7
+ * leía todas. Era el mismo esquema que tuvo la maestra hasta que se selló.
7
8
  *
8
- * · el ACTA dice qué aparatos existen y qué puede cada uno
9
- * · la APROBACIÓN ya sabe avisar al teléfono y esperar su firma (`caps +aprueba`)
10
- * · la BITÁCORA ya existe y es la misma que audita firmas y enrolamientos
9
+ * Ahora la bóveda es un cartero también para las contraseñas
10
+ * (`dotrino-passmanager/docs/sealed-passwords.md`): guarda sobres dirigidos a los aparatos
11
+ * y envolturas que no puede abrir. Lo que sí hace, y es lo que sostiene el modelo:
11
12
  *
12
- * Lo que NO cambia es el protocolo: un aparato pide una credencial por dominio y
13
- * recibe esa sola. `list` sigue sin existir en remoto.
13
+ * · dice A QUIÉN hay que envolver cada llave —sale del acta, y **la bóveda nunca está en
14
+ * esa lista**: ni su maestra, ni su llave de comunicación;
15
+ * · comprueba que quien escribe sea un aparato del acta con `passwords`, por su firma;
16
+ * · filtra por destinatario al entregar, y decide si hace falta un dedo encima.
14
17
  *
15
- * Módulo aislado a propósito: recibe todo lo que necesita y no toca el núcleo. Un
16
- * fallo aquí no puede tumbar la CA.
18
+ * Lo que SIGUE igual: el protocolo es de a una credencial, `list` no existe en remoto, y la
19
+ * aprobación es la misma del resto del vault (`caps +aprueba`).
17
20
  */
18
21
 
19
- import { LocalVault, VaultResponder } from '@dotrino/passmanager'
22
+ import { SealedStore, SealedResponder } from '@dotrino/passmanager'
23
+ import { verifyDeviceSig } from '@dotrino/identity/capabilities'
20
24
 
21
25
  export const PASSWORDS_CAP = 'passwords'
26
+ export const PASSKEYS_CAP = 'passkeys'
22
27
 
23
28
  /**
24
29
  * @param {object} opts
25
30
  * `client` cliente del proxio del vault, ya identificado
26
- * `store` `{ get(k), set(k,v) }` donde viven las entradas cifradas
27
- * `cek` la llave de la bóveda de contraseñas (CryptoKey AES-GCM)
28
- * `isAllowed(pubkey)` qué aparatos pueden pedir — lo decide el acta, no esto
29
- * `encPubOf(pubkey)` su llave de cifrado, para sellarle la respuesta
30
- * `needsApproval(pubkey)` (async) si ese aparato tiene que pedir aprobación según el
31
- * ACTA (`unattended`). Se compone con el
32
- * criterio del propio protocolo: solo lo PRIVADO pregunta
31
+ * `store` `{ get(k), set(k,v) }` donde viven los sobres
32
+ * `members(kind)` → `[{ pub, encPub }]` a quién envolverle cada llave (`main`/`passkeys`),
33
+ * SIN la bóveda. Lo pone `vault.js`, que es quien lee el acta.
34
+ * `recoveryPub()` la pública de la copia de recuperación (la que abre la frase)
35
+ * `canWrite(pubkey)` ¿ese aparato puede ESCRIBIR contraseñas? (acta: `passwords`)
36
+ * `isAllowed(pubkey)` ¿puede pedir? — lo decide el acta, no esto
37
+ * `encPubOf(pubkey)` su llave de cifrado, para sellarle la respuesta
38
+ * `needsApproval(pubkey)` (async) si ese aparato tiene que pedir aprobación (`unattended`)
33
39
  * `approve({ pubkey, op })` pide el visto bueno (el teléfono) y espera
34
- * `devices()` / `unlink(pubkey)` administración, opcional
35
40
  * `audit(op, info)` la bitácora del vault
36
41
  */
37
42
  export function createPasswordDesk (opts = {}) {
38
43
  const {
39
- client, store, cek,
44
+ client, store,
45
+ members = () => [],
46
+ recoveryPub = () => null,
47
+ canWrite = () => false,
40
48
  isAllowed = () => false,
41
49
  encPubOf = () => null,
42
50
  needsApproval = () => true,
43
51
  approve = async () => false,
44
- devices = null,
45
- unlink = null,
46
52
  audit = () => {},
47
53
  log = () => {},
48
54
  } = opts
49
55
 
50
- const vault = new LocalVault(store)
51
- if (cek) vault.unlock(cek)
56
+ const sealed = new SealedStore(store, {
57
+ recipients: (kind) => members(kind),
58
+ /**
59
+ * ¿LA FIRMÓ QUIEN DICE, Y PUEDE ESCRIBIR? Es lo único que la bóveda puede comprobar
60
+ * sobre una entrada cuyo contenido no ve. Las dos mitades importan: la firma ata el
61
+ * sobre a una llave, y el acta dice si esa llave tiene algo que hacer aquí.
62
+ */
63
+ verifyAuthor: async ({ body, author }) => {
64
+ if (!canWrite(author?.pub)) return false
65
+ return verifyDeviceSig({ publickey: author.pub, data: body, signature: author.sig })
66
+ },
67
+ })
52
68
 
53
- const responder = new VaultResponder({
69
+ const responder = new SealedResponder({
54
70
  client,
55
- vault,
71
+ store: sealed,
72
+ recipients: async () => ({
73
+ recoveryPub: recoveryPub(),
74
+ main: await members('main'),
75
+ passkeys: await members('passkeys'),
76
+ }),
56
77
  isAllowed,
57
78
  encPubOf,
58
- // Dos condiciones, y las dos tienen que darse:
59
- //
60
- // · que ese APARATO esté marcado para aprobar (la política del vault: se pide una
61
- // vez y dura mientras el vault siga encendido, igual que los cajones de secretos);
62
- // · y que lo que pide sea PRIVADO — una contraseña, un código de dos pasos, o un
63
- // campo que el usuario marcó. Rellenar un nombre no es sacar un secreto, y pedir
64
- // permiso para todo enseña a decir que sí sin mirar.
65
- //
66
- // El segundo criterio se toma del propio responder (`wantsPrivate`) en vez de
67
- // reescribirlo aquí: si cada bóveda tuviera su idea de qué es privado, serían tres
68
- // bóvedas distintas otra vez.
69
- needsApproval: async (op, payload, pubkey) =>
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),
79
+ // Dos condiciones, y las dos tienen que darse: que ese APARATO esté marcado para
80
+ // aprobar, y que lo que pide sea PRIVADO. Rellenar un nombre no es sacar un secreto, y
81
+ // pedir permiso para todo enseña a decir que sí sin mirar.
82
+ needsApproval: async (pubkey) => await needsApproval(pubkey),
75
83
  approve,
76
- admin: devices && unlink ? { devices, unlink } : null,
77
84
  onRequest: (r) => {
78
- // La bitácora NO lleva qué credencial se pidió: eso es contenido del usuario y
79
- // el criterio del vault es apuntar la operación, no el payload.
85
+ // La bitácora NO lleva qué credencial se pidió: eso es contenido del usuario.
80
86
  audit('passwords', { op: r.op, outcome: r.outcome, device: (r.from || '').slice(0, 24) })
81
- if (r.outcome !== 'served') {
82
- log(`[vault] passwords: ${r.op} ${r.outcome}`)
83
- }
87
+ if (r.outcome !== 'ok') log(`[vault] passwords: ${r.op} ${r.outcome}`)
84
88
  },
85
89
  })
86
90
 
87
91
  return {
88
92
  start () { responder.start(); return this },
89
93
  stop () { responder.stop() },
90
- /** Al bloquear el perfil, la bóveda de contraseñas se cierra con él. */
91
- lock () { vault.lock() },
92
- unlock (nuevaCek) { vault.unlock(nuevaCek) },
93
- /** Para la consola local: aquí SÍ se puede listar, porque aquí está la llave. */
94
- vault,
94
+ /**
95
+ * EL CANDADO DEL PERFIL YA NO PINTA NADA AQUÍ, y eso es la noticia: esta bóveda no
96
+ * tiene ninguna llave que cerrar. Se conserva el método porque quien lo llama no tiene
97
+ * por qué saberlo, y porque decirlo en la bitácora ayuda a entender el cambio.
98
+ */
99
+ lock () { audit('passwords.lock', { sealed: true }) },
100
+ unlock () {},
101
+ /** Para la consola local y las pruebas. Aquí tampoco se abre nada. */
102
+ sealed,
95
103
  responder,
96
104
  }
97
105
  }
106
+
107
+ /**
108
+ * CONVERTIR LO QUE HAY (§2.7). Es un acto único y es **segundo trabajo de la maestra**: se
109
+ * hace al ABRIR la bóveda, que es cuando la frase está a mano y hay con qué reescribirlo
110
+ * todo.
111
+ *
112
+ * Durante este paso —y solo durante este paso— la bóveda ve los valores: los tiene que
113
+ * abrir con la llave vieja para volver a cerrarlos uno por uno, con una llave por escritura
114
+ * envuelta a los aparatos. Después ya no puede, y por eso lo último que hace es **borrar la
115
+ * llave vieja**: mientras siga ahí, el agujero sigue abierto.
116
+ *
117
+ * @param {object} o
118
+ * `store` el almacén con las entradas viejas y donde irán los sobres
119
+ * `sealed` la `SealedStore` de esta bóveda
120
+ * `cek` la llave vieja (`CryptoKey`), o `null` si ya no hay nada que convertir
121
+ * `recipients` `{ recoveryPub, main, passkeys }`
122
+ * `author` `{ publickey, sign(body) }` — la maestra, que firma lo que escribe
123
+ * `dropOldKey()` borra la llave vieja del disco. Se llama AL FINAL y solo si todo salió
124
+ */
125
+ export async function convertPasswords ({ store, sealed, cek, recipients, author, dropOldKey, log = () => {} } = {}) {
126
+ const { openEntry } = await import('@dotrino/passmanager')
127
+ const { buildSealedEntry, buildProfileKey } = await import('@dotrino/passmanager/sealed')
128
+
129
+ const viejas = (await store.get('passmanager/entries/v1')) || []
130
+ if (await sealed.sealed()) return { already: true, entries: 0 }
131
+
132
+ // 1. La llave del perfil, de la que salen el índice de sitios y los resúmenes.
133
+ const pk = await buildProfileKey({ recipients })
134
+ await sealed.setProfile({ envelope: pk.envelope, wraps: pk.wraps })
135
+ const { profileKeys } = await import('@dotrino/passmanager/sealed')
136
+ const keys = await profileKeys(pk.base)
137
+
138
+ // 2. Cada entrada, reescrita: un sobre por campo, la vista sellada, sus huellas.
139
+ let hechas = 0
140
+ for (const vieja of viejas) {
141
+ if (!cek) break
142
+ const abierta = await openEntry(cek, vieja)
143
+ const sobre = await buildSealedEntry({ plain: abierta, keys, recipients, author })
144
+ await sealed.putSealed(sobre)
145
+ hechas++
146
+ }
147
+
148
+ // 3. Y se va lo viejo: las entradas con la llave que las abría, y la llave misma.
149
+ if (hechas === viejas.length) {
150
+ await store.set('passmanager/entries/v1', [])
151
+ await dropOldKey?.()
152
+ }
153
+ log(`[vault] passwords: ${hechas} entries converted to the sealed format`)
154
+ return { already: false, entries: hechas }
155
+ }
package/src/replica.js CHANGED
@@ -66,9 +66,44 @@ export function mergeBundle (viejo, nuevo) {
66
66
  if (gb > ga) entries[k] = e
67
67
  else if (gb === ga && String(e.e?.ct ?? e.pubv ?? '') < String(a.e?.ct ?? a.pubv ?? '')) entries[k] = e
68
68
  }
69
- const porGen = new Map()
70
- for (const w of [...(viejo.wraps || []), ...(nuevo.wraps || [])]) porGen.set(w.gen, w)
71
- return { ...viejo, ...nuevo, entries, wraps: [...porGen.values()].sort((x, y) => x.gen - y.gen) }
69
+ const wraps = mergeWraps(viejo.wraps, nuevo.wraps)
70
+ // `ns`/`dev` son la envoltura VIGENTE de cada lado, y salen de la lista ya mezclada: con
71
+ // `{...viejo, ...nuevo}` se quedaba la del empujón aunque su generación fuera menor, o
72
+ // sea justo lo contrario de lo que decide la lista.
73
+ return {
74
+ ...viejo,
75
+ ...nuevo,
76
+ entries,
77
+ wraps,
78
+ ns: wraps.ns[wraps.ns.length - 1] || null,
79
+ dev: wraps.dev[wraps.dev.length - 1] || null
80
+ }
81
+ }
82
+
83
+ /**
84
+ * `wraps` es `{ ns: [...], dev: [...] }`, NO una lista.
85
+ *
86
+ * Aquí se esparcía como si fuera una lista, así que cada empujón sobre un sobre que ya
87
+ * existía moría con «(viejo.wraps || []) is not iterable» — y solo en producción: el
88
+ * primer guardado sale por `if (!viejo) return nuevo` y nunca llega aquí. El replicador de
89
+ * Cepi lleva así desde que se desplegó, guardando el primer sobre de cada cajón y
90
+ * rechazando en silencio todas las actualizaciones.
91
+ *
92
+ * La forma la fija quien lo produce (`secretsStore.js`, `sealedBundleFor`) y quien lo lee
93
+ * (`lib/src/service.js`, que recorre `wraps.ns` y `wraps.dev`). Los dos estaban de acuerdo;
94
+ * el único equivocado era esto. Y de haber funcionado habría sido peor: escribía `wraps`
95
+ * como lista y el servicio se habría quedado sin envolturas, que es no poder abrir nada.
96
+ *
97
+ * Las de cada lado se SUMAN por generación: son de aparatos distintos y ninguna estorba.
98
+ */
99
+ function mergeWraps (a, b) {
100
+ const out = {}
101
+ for (const side of ['ns', 'dev']) {
102
+ const byGen = new Map()
103
+ for (const w of [...(a?.[side] || []), ...(b?.[side] || [])]) byGen.set(w.gen, w)
104
+ out[side] = [...byGen.values()].sort((x, y) => x.gen - y.gen)
105
+ }
106
+ return out
72
107
  }
73
108
 
74
109
  /** La clave de un sobre: es POR (cajón, aparato), porque va tallado a quien lo pide. */
@@ -1145,6 +1145,20 @@ export function openSecretsStore (dir, { sealer = null, recipients = null, signe
1145
1145
  * decidir si hay que migrarla. Preguntarlo intentando reenvolver un cajón mezcla dos
1146
1146
  * cosas y deja el fallo diciendo «wrong password» sobre el cajón, que no es donde está.
1147
1147
  */
1148
+ /**
1149
+ * ABRE UNA ENVOLTURA DE RECUPERACIÓN y devuelve lo que lleva dentro.
1150
+ *
1151
+ * Es para OTROS almacenes de este mismo perfil —hoy la bóveda de contraseñas—, que
1152
+ * comparten ESTA copia de recuperación en vez de estrenar una segunda. Una segunda
1153
+ * sería otra llave que custodiar, otra que rotar y otra que se queda atrás.
1154
+ *
1155
+ * Sigue siendo la única puerta a un valor en claro, y sigue pidiendo la frase.
1156
+ */
1157
+ async openRecoveryWrap (wrap, adminKey = null) {
1158
+ const priv = await openRecovery(adminKey)
1159
+ return sealer.openWrapWith(priv, wrap)
1160
+ },
1161
+
1148
1162
  async recoveryOpensWith (key) {
1149
1163
  needSealer('check the recovery key')
1150
1164
  if (!data.recovery?.priv) throw new NeedsPassword('this store has no recovery key yet')
package/src/tui/app.js CHANGED
@@ -403,7 +403,7 @@ function pairModeRows (st, t) {
403
403
  * esté nombrado se añade al final: un permiso puede salir en mal sitio, nunca desaparecer.
404
404
  * `test/tui-permisos.test.mjs` exige además que todos tengan nombre en los dos idiomas.
405
405
  */
406
- const ORDEN = ['sign', 'store', 'read', 'admin', 'approve', 'passwords', 'sealer', 'unattended', 'replica']
406
+ const ORDEN = ['sign', 'store', 'read', 'admin', 'approve', 'passwords', 'passkeys', 'sealer', 'unattended', 'replica']
407
407
  const CAPS_ORDER = [...ORDEN.filter((c) => DEVICE_CAPS.includes(c)),
408
408
  ...DEVICE_CAPS.filter((c) => !ORDEN.includes(c))]
409
409
 
package/src/tui/i18n.js CHANGED
@@ -60,6 +60,7 @@ const es = {
60
60
  read: 'lee',
61
61
  admin: 'administra',
62
62
  passwords: 'contrasenas',
63
+ passkeys: 'passkeys',
63
64
  approve: 'aprueba',
64
65
  sealer: 'sella',
65
66
  unattended: 'desatendido',
@@ -75,6 +76,9 @@ const es = {
75
76
  read: 'ver lo que guardaste',
76
77
  admin: 'conectar y quitar aparatos desde ahí, sin venir aquí. No puede cambiar permisos',
77
78
  passwords: 'pedir una contraseña por sitio, nunca la lista entera',
79
+ // Una passkey copiada sirve hasta que la borres en cada sitio: de una contraseña, al
80
+ // menos, se puede cambiar. Por eso es una decisión aparte.
81
+ passkeys: 'abrir además las passkeys guardadas, que son llaves y no se pueden cambiar. Sin «contrasenas» no hace nada',
78
82
  approve: 'dar el visto bueno cuando otro aparato pide claves privadas',
79
83
  // Sellar solo significa algo en un aparato que HAGA de bóveda (otra máquina con el
80
84
  // daemon, o un navegador que se puso de bóveda). En un teléfono normal se concede y no
@@ -419,6 +423,7 @@ const en = {
419
423
  read: 'read',
420
424
  admin: 'admin',
421
425
  passwords: 'passwords',
426
+ passkeys: 'passkeys',
422
427
  approve: 'approve',
423
428
  sealer: 'sealer',
424
429
  unattended: 'unattended',
@@ -432,6 +437,7 @@ const en = {
432
437
  read: 'see what you saved',
433
438
  admin: 'connect and remove devices from there, without coming here. It cannot change permissions',
434
439
  passwords: 'ask for one password per site, never the whole list',
440
+ passkeys: 'also open saved passkeys, which are keys and cannot be changed. Without «passwords» it does nothing',
435
441
  approve: 'give the go-ahead when another device asks for private keys',
436
442
  sealer: 'admit devices and change permissions on its own when this vault is away. Only useful on another VAULT',
437
443
  unattended: 'take private keys without asking you. Off, it must be approved from a device with «approve» every time it starts',
package/src/vault.js CHANGED
@@ -234,6 +234,25 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
234
234
  /** Los aparatos que el acta autoriza a pedir credenciales (`caps <ID> +contraseñas`). */
235
235
  const passwordDevices = () => (actaCache?.members || []).filter((m) => Acta.memberCan(actaCache, m.pub, 'passwords'))
236
236
 
237
+ /**
238
+ * A QUIÉN SE LE ENVUELVE LA LLAVE DE UNA CONTRASEÑA — Y LA BÓVEDA NUNCA ESTÁ EN LA LISTA.
239
+ *
240
+ * Es la comprobación que sostiene todo el modelo sellado: si la maestra o la llave de
241
+ * comunicación de esta máquina recibieran envoltura, el demonio volvería a poder leer las
242
+ * contraseñas y no habríamos arreglado nada —solo lo habríamos escrito más bonito—. Por
243
+ * eso se filtran explícitamente aquí, que es donde se construye la lista, y no en quien la
244
+ * consume (`sealed-passwords.md` §2.1).
245
+ *
246
+ * `main` son los aparatos con `contraseñas`; `passkeys`, los que además pueden abrir la
247
+ * privada de una passkey (§2.8). Un miembro sin llave de cifrado no puede recibir nada: se
248
+ * queda fuera y se ve en `incompleteMembers`.
249
+ */
250
+ const noEsLaBoveda = (pub) => pub !== master && pub !== commKey.pub()
251
+ const passwordRecipients = (kind = 'main') => (actaCache?.members || [])
252
+ .filter((m) => m.encPub && noEsLaBoveda(m.pub) && Acta.memberCan(actaCache, m.pub, 'passwords'))
253
+ .filter((m) => kind !== 'passkeys' || Acta.memberCan(actaCache, m.pub, 'passkeys'))
254
+ .map((m) => ({ pub: m.pub, encPub: m.encPub }))
255
+
237
256
  /**
238
257
  * El acta vigente, en caché.
239
258
  *
@@ -249,20 +268,27 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
249
268
  }
250
269
 
251
270
  /**
252
- * La llave de la bóveda de contraseñas. Nace con el primer uso y vive cifrada en
253
- * reposo, como la identidad: aquí no hace falta envolverla a cada aparato porque
254
- * ningún aparato abre la bóveda — piden de a una y el vault responde.
271
+ * LA LLAVE VIEJA DE LA BÓVEDA DE CONTRASEÑAS, la que había que quitar.
272
+ *
273
+ * Vivía DENTRO del mismo archivo que las entradas, cifrada con la llave de la máquina y
274
+ * no con la contraseña del perfil. O sea: el demonio con el perfil cerrado seguía
275
+ * descifrando, y una copia del disco las abría todas. Esto ya no la crea — solo la LEE,
276
+ * y solo para convertir lo que quedó escrito con ella; en cuanto la conversión termina,
277
+ * `soltarLlaveVieja` la borra del archivo.
255
278
  */
256
- async function passwordsKey () {
279
+ async function llaveViejaDeContrasenas () {
257
280
  const d = readPasswordsFile()
258
- if (d?.cek) {
259
- const raw = Uint8Array.from(Buffer.from(d.cek, 'base64'))
260
- return crypto.subtle.importKey('raw', raw, { name: 'AES-GCM' }, true, ['encrypt', 'decrypt'])
261
- }
262
- const key = await crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt'])
263
- const raw = new Uint8Array(await crypto.subtle.exportKey('raw', key))
264
- writePasswordsFile({ ...(d || { v: 1, data: {} }), cek: Buffer.from(raw).toString('base64') })
265
- return key
281
+ if (!d?.cek) return null
282
+ const raw = Uint8Array.from(Buffer.from(d.cek, 'base64'))
283
+ return crypto.subtle.importKey('raw', raw, { name: 'AES-GCM' }, true, ['encrypt', 'decrypt'])
284
+ }
285
+ const soltarLlaveVieja = () => {
286
+ const d = readPasswordsFile()
287
+ if (!d?.cek) return
288
+ delete d.cek
289
+ writePasswordsFile(d)
290
+ log('[vault] passwords: the old vault key is gone from the disk')
291
+ audit('passwords.converted', {})
266
292
  }
267
293
 
268
294
  const approvalsSweeper = setInterval(() => {
@@ -1121,7 +1147,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
1121
1147
 
1122
1148
  async function handleLoginStart (from, p) {
1123
1149
  try {
1124
- const { lid, response } = logins.loginBegin({ user: p?.user, request: p?.request })
1150
+ const { lid, response } = await logins.loginBegin({ user: p?.user, request: p?.request })
1125
1151
  audit('login.start', { user: String(p?.user || '').slice(0, 32) })
1126
1152
  reply(from, { type: MSG.LOGIN_RESPONSE, lid, response })
1127
1153
  } catch (e) { loginError(from, e) }
@@ -1129,7 +1155,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
1129
1155
 
1130
1156
  async function handleLoginFinish (from, p) {
1131
1157
  try {
1132
- const r = logins.loginEnd({ lid: p?.lid, finalization: p?.finalization, label: p?.label })
1158
+ const r = await logins.loginEnd({ lid: p?.lid, finalization: p?.finalization, label: p?.label })
1133
1159
  const record = (await identity.profileActa?.().catch(() => null))?.acta || null
1134
1160
  audit('login.ok', { user: r.user, sid: r.sid.slice(0, 8) })
1135
1161
  log('[vault] login: %s entered (session %s)', r.user, r.sid.slice(0, 8))
@@ -1871,7 +1897,13 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
1871
1897
  passwords = createPasswordDesk({
1872
1898
  client,
1873
1899
  store: passwordsStore,
1874
- cek: await passwordsKey(),
1900
+ // LA BÓVEDA NO TIENE LLAVE. Lo que pone aquí es a quién envolverle las que hacen los
1901
+ // aparatos, y la pública de la copia de recuperación —la que abre la frase del perfil
1902
+ // y es la única forma de volver a repartir una entrada si un día no queda ningún
1903
+ // aparato—. Ninguna de las dos abre nada por sí sola.
1904
+ members: (kind) => passwordRecipients(kind),
1905
+ recoveryPub: () => secrets.recoveryPub(),
1906
+ canWrite: (pub) => !!actaCache && !!pub && Acta.memberCan(actaCache, pub, 'passwords'),
1875
1907
  // UNA sola condición, y es del ACTA: el aparato tiene la capacidad `passwords`.
1876
1908
  // Quitársela —o quitar el aparato— le corta esto en la siguiente pasada, sin
1877
1909
  // que haya una segunda lista que acordarse de tocar.
@@ -3092,6 +3124,64 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
3092
3124
  }
3093
3125
  }
3094
3126
 
3127
+ /**
3128
+ * LAS CONTRASEÑAS, AL FORMATO SELLADO (§2.7) Y CON SUS ENVOLTURAS AL DÍA (§2.5).
3129
+ *
3130
+ * Las dos cosas se hacen aquí, al ABRIR el perfil, porque las dos necesitan lo mismo: la
3131
+ * frase. Convertir hay que hacerlo una vez; repasar las envolturas, cada vez que cambie
3132
+ * quién puede leer — y este es el único momento en que hay con qué.
3133
+ *
3134
+ * La bóveda ve los valores SOLO mientras convierte, que es cuando tiene que reescribirlos.
3135
+ * Después no puede: lo último que hace la conversión es borrar la llave vieja.
3136
+ */
3137
+ async function sealPasswords (adminKey = null) {
3138
+ if (!passwords) return null
3139
+ const recipients = {
3140
+ recoveryPub: secrets.recoveryPub(),
3141
+ main: passwordRecipients('main'),
3142
+ passkeys: passwordRecipients('passkeys')
3143
+ }
3144
+ if (!recipients.recoveryPub) {
3145
+ log('[vault] passwords: no recovery key yet — nothing can be sealed until there is one')
3146
+ return null
3147
+ }
3148
+ const out = {}
3149
+ try {
3150
+ const { convertPasswords } = await import('./passwords.js')
3151
+ const r = await convertPasswords({
3152
+ store: passwordsStore,
3153
+ sealed: passwords.sealed,
3154
+ cek: await llaveViejaDeContrasenas(),
3155
+ recipients,
3156
+ // LA MAESTRA FIRMA LO QUE ESCRIBE, como cualquier aparato que guarda una entrada.
3157
+ // Firmar no es leer: lo que se le envuelve a esta bóveda sigue siendo nada.
3158
+ author: { publickey: master, sign: (body) => identity.signData(body) },
3159
+ dropOldKey: soltarLlaveVieja,
3160
+ log
3161
+ })
3162
+ out.converted = r?.entries || 0
3163
+ if (r?.already) out.converted = 0
3164
+ } catch (e) {
3165
+ log(`[vault] passwords: could not convert to the sealed format (${e.message})`)
3166
+ out.convertError = e.message
3167
+ }
3168
+ try {
3169
+ const r = await passwords.sealed.rewrapAll({
3170
+ openRecovery: (wrap) => secrets.openRecoveryWrap(wrap, adminKey)
3171
+ })
3172
+ out.wrapped = r.wrapped
3173
+ out.dropped = r.dropped
3174
+ if (r.wrapped || r.dropped) {
3175
+ log(`[vault] passwords: keyring rebuilt (${r.wrapped} wrap(s), ${r.dropped} stale one(s) dropped)`)
3176
+ }
3177
+ if (r.failed?.length) log(`[vault] passwords: ${r.failed.length} generation(s) could NOT be rewrapped`)
3178
+ } catch (e) {
3179
+ log(`[vault] passwords: could not rebuild the keyring (${e.message})`)
3180
+ out.rewrapError = e.message
3181
+ }
3182
+ return out
3183
+ }
3184
+
3095
3185
  async function resealAll (adminKey = null) {
3096
3186
  // ANTES de reenvolver nada: si la copia de recuperación se quedó bajo la llave de la
3097
3187
  // máquina, se pasa a la frase. Sin esto, todo lo de abajo falla con «wrong password».
@@ -3558,6 +3648,8 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
3558
3648
  * diseño dice que sí (§8.2). Lo de siempre: dos sitios decidiendo lo mismo.
3559
3649
  */
3560
3650
  resealAll,
3651
+ /** Convierte las contraseñas al formato sellado y repasa sus envolturas (§2.5, §2.7). */
3652
+ sealPasswords,
3561
3653
  delegateRewrap,
3562
3654
  incompleteMembers,
3563
3655
  migrateSecrets: (membersOf, adminKey) => secrets.migrate(