@dotrino/vaultd 0.95.0 → 0.97.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.
@@ -668,7 +668,13 @@ async function openSealedBundle (sealed, ident, acta = null, masterPubkey = null
668
668
  // sería aceptar un valor en claro dentro de un paquete que se supone todo sellado, o
669
669
  // sea la puerta para colar uno.
670
670
  if (typeof e.v === 'string' && !e.e) {
671
- throw Object.assign(new Error(`${key} arrived in the clear: since 2026-09-02 every variable travels sealed`), { code: 'plaintext-var' })
671
+ // TAMPOCO SE ARREGLA ESPERANDO, igual que `no-wrapping`. El valor está guardado en
672
+ // claro en la bóveda, de antes de que todo viajara sellado, y solo vuelve a
673
+ // escribirlo quien puede abrirla. Reintentar no lo sella: solo vuelve a timbrar.
674
+ throw Object.assign(
675
+ new Error(`${key} arrived in the clear: since 2026-09-02 every variable travels sealed` +
676
+ ` — rewrite it in the vault (dotrino-vault unlock, then secret set ${ident?.ns || '<ns>'} ${key} …) or remove it`),
677
+ { code: 'plaintext-var' })
672
678
  }
673
679
  // `owner` dice de qué cajón salió, y `gen` con qué llave de ese cajón se abre.
674
680
  const cual = String(e.owner || '').startsWith('dev:') ? 'dev' : 'ns'
@@ -1026,21 +1032,52 @@ function fingerprintOf (secrets) {
1026
1032
  * Se decide por `e.code` donde lo hay: emparejar un mensaje por su texto se rompe en
1027
1033
  * silencio en cuanto alguien lo reescribe (ver `dotrino-error-strings`).
1028
1034
  */
1035
+ const FINAL_CODES = new Set([
1036
+ 'no-wrapping', // falta la envoltura, y solo la fabrica el dueño abriendo la bóveda
1037
+ 'plaintext-var' // el valor está en claro en la bóveda: hay que volver a escribirlo
1038
+ ])
1039
+
1029
1040
  export function isFinal (e) {
1030
- if (e?.code === 'no-wrapping') return true
1041
+ if (FINAL_CODES.has(e?.code)) return true
1031
1042
  return /not enrolled|invalid ns|unauthorized: (revoked|expired|scope|cn|untrusted-issuer|cert-device-mismatch|denied)/.test(e?.message || '')
1032
1043
  }
1033
1044
 
1034
- export async function waitForSecrets ({ dir, ns, proxyUrl, masterPubkey, device, cert, enc, retryMs = 5000, maxRetryMs = 60000, onRetry, onPending, onCert, publicOnly = false } = {}) {
1035
- let delay = retryMs
1045
+ /**
1046
+ * CUÁNTO ESPERAR ANTES DE VOLVER A PEDIR, y por qué no siempre es el backoff.
1047
+ *
1048
+ * Un cajón con aprobación hace sonar el teléfono en CADA petición. Con el reintento a
1049
+ * cinco segundos, un fallo posterior a la aprobación volvía a pedir enseguida y el dueño
1050
+ * veía dos avisos del mismo cajón: aprobaba el primero para nada. La regla es que un
1051
+ * reintento no llegue nunca antes de que venza el pedido que ya está sonando.
1052
+ *
1053
+ * La otra mitad importa igual: si el intento se pasó la ventana entera esperando a que
1054
+ * alguien aprobara, no se le suma otra ventana — ahí ya no hay nada sonando, y hacerle
1055
+ * esperar cinco minutos más sería un cuelgue.
1056
+ *
1057
+ * @param {number} backoffMs lo que tocaría por el backoff normal
1058
+ * @param {number} rangAt cuándo timbró este intento (0 si no timbró)
1059
+ * @param {number} approvalTimeoutMs lo que dura un pedido de aprobación
1060
+ * @param {number} now
1061
+ */
1062
+ export function retryDelay (backoffMs, rangAt, approvalTimeoutMs = APPROVAL_TIMEOUT_MS, now = Date.now()) {
1063
+ if (!rangAt) return backoffMs
1064
+ return Math.max(backoffMs, approvalTimeoutMs - (now - rangAt))
1065
+ }
1066
+
1067
+ export async function waitForSecrets ({ dir, ns, proxyUrl, masterPubkey, device, cert, enc, retryMs = 5000, maxRetryMs = 60000, approvalTimeoutMs = APPROVAL_TIMEOUT_MS, onRetry, onPending, onCert, publicOnly = false } = {}) {
1068
+ let backoff = retryMs
1069
+ let rangAt = 0 // cuándo timbró ESTE intento; 0 = no hubo aprobación de por medio
1070
+ const notePending = (info) => { rangAt = Date.now(); onPending?.(info) }
1036
1071
  for (;;) {
1037
1072
  try {
1038
- return await fetchSecrets({ dir, ns, proxyUrl, masterPubkey, device, cert, enc, onPending, onCert, publicOnly })
1073
+ return await fetchSecrets({ dir, ns, proxyUrl, masterPubkey, device, cert, enc, approvalTimeoutMs, onPending: notePending, onCert, publicOnly })
1039
1074
  } catch (e) {
1040
1075
  if (isFinal(e)) throw e
1041
- try { onRetry?.(e, delay) } catch (_) {}
1042
- await new Promise((r) => setTimeout(r, delay))
1043
- delay = Math.min(maxRetryMs, Math.round(delay * 1.6))
1076
+ const wait = retryDelay(backoff, rangAt, approvalTimeoutMs)
1077
+ rangAt = 0
1078
+ try { onRetry?.(e, wait) } catch (_) {}
1079
+ await new Promise((r) => setTimeout(r, wait))
1080
+ backoff = Math.min(maxRetryMs, Math.round(backoff * 1.6))
1044
1081
  }
1045
1082
  }
1046
1083
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotrino/vaultd",
3
- "version": "0.95.0",
3
+ "version": "0.97.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": {
@@ -21,7 +21,7 @@
21
21
  },
22
22
  "dependencies": {
23
23
  "@dotrino/identity": "0.76.0",
24
- "@dotrino/passmanager": "^0.9.0",
24
+ "@dotrino/passmanager": "^0.10.1",
25
25
  "@dotrino/proxy-client": "^0.13.1",
26
26
  "ws": "^8.18.0"
27
27
  },
package/src/daemon.js CHANGED
@@ -333,10 +333,13 @@ export async function runDaemon () {
333
333
  // cajón envuelto para exactamente quien dice el acta — creando lo que falta,
334
334
  // reemplazando lo que alguien metiera mal y quitando lo que sobre.
335
335
  const r = await mgr.get(id)?.resealAll?.(ak)
336
- if (r?.wrapped) console.log('[vault] keyring rebuilt on unlock: %d wrap(s) in %d drawer(s)%s',
337
- r.wrapped, r.drawers, r.dropped ? `, ${r.dropped} stale one(s) dropped` : '')
336
+ if (r?.wrapped) console.log('[vault] keyring rebuilt on unlock: %d wrap(s) in %d drawer(s)%s%s',
337
+ r.wrapped, r.drawers, r.dropped ? `, ${r.dropped} stale one(s) dropped` : '',
338
+ r.sealed ? `, ${r.sealed} value(s) left in the clear now sealed` : '')
338
339
  if (r?.dropped) note = ` · llavero al día (${r.dropped} envoltura(s) de más retirada(s))`
339
340
  else if (r?.wrapped) note = ' · llavero al día'
341
+ // Se SUMA, no pisa: es lo que el dueño necesita ver de esta apertura en concreto.
342
+ if (r?.sealed) note += ` · ${r.sealed} valor(es) que estaban sin cifrar ya están sellados`
340
343
  // ABIERTA PERO SIN LLAVERO NO ES «ABIERTA». Si un cajón no se pudo reenvolver, sus
341
344
  // aparatos no van a poder leer nada y hay que decirlo AQUÍ: contestar
342
345
  // «desbloqueado» a secas dejaba la bóveda pareciendo sana, y el fallo aparecía
@@ -834,11 +834,44 @@ export function openSecretsStore (dir, { sealer = null, recipients = null, signe
834
834
  if (!bag[k]) return { wrapped: 0, sinLlave: [] }
835
835
  const priv = await openRecovery(adminKey)
836
836
  let wrapped = 0
837
+ let sealed = 0
837
838
  const sinLlave = new Set()
839
+ const vars = varsOf(bag, k)
840
+ const paraVisibilidad = async (esPublica) => (typeof members === 'function' ? members(esPublica) : members)
841
+
842
+ // UN VALOR QUE SE QUEDÓ EN CLARO SE SELLA AQUÍ, que es el único sitio donde se puede.
843
+ //
844
+ // Una pública escrita antes del 2026-09-02 se guardó legible y SIN sobre, así que no
845
+ // tiene generación ni entrada en el llavero. Y el llavero es lo único que este
846
+ // método reenvuelve: por muchas veces que se abra la bóveda, a esa variable no se le
847
+ // crea una envoltura nunca. Sus aparatos la reclaman para siempre («no key yet») y la
848
+ // consola invita a abrir la bóveda, que es justo lo que no lo arregla. Peor: desde
849
+ // 2026-09-02 el agente rechaza el cajón ENTERO por ella, así que una variable de
850
+ // prueba deja sin leer el token que está al lado.
851
+ //
852
+ // Con la bóveda abierta el valor está delante, así que se cifra como si se acabara
853
+ // de escribir. No es un repliegue ni una migración a medias: es la conversión que
854
+ // faltaba, y ocurre en el único momento en que hay con qué hacerla.
855
+ for (const [key, e] of Object.entries(vars)) {
856
+ if (typeof e.v !== 'string' || e.e) continue
857
+ const cek = await sealer.newKey()
858
+ const gen = (topGen(bag, k)?.gen || 0) + 1
859
+ const r = await sealer.wrapFor(cek, await paraVisibilidad(!!e.pub))
860
+ r.wraps[RECOVERY] = await sealer.wrapForKey(cek, data.recovery.pub)
861
+ const sobre = await sealer.encrypt(cek, e.v, gen)
862
+ const seal = signer ? await signer({ owner, key, gen, iv: sobre.iv, ct: sobre.ct }) : null
863
+ // `pub` y `at` se conservan: no es un valor nuevo, es el mismo mejor guardado.
864
+ vars[key] = { pub: !!e.pub, owner, gen, e: sobre, seal, at: e.at || Date.now(), by: e.by || null }
865
+ bag[k].keyring = [...(bag[k].keyring || []), { gen, createdAt: Date.now(), wraps: r.wraps }]
866
+ for (const x of r.sinLlave) sinLlave.add(x)
867
+ sealed++
868
+ }
869
+
838
870
  // De qué variable es cada generación: es lo que dice si esa CEK es de una pública.
871
+ // Se calcula DESPUÉS de sellar, para que las generaciones recién creadas entren.
839
872
  const visibilidad = new Map()
840
- for (const e of Object.values(varsOf(bag, k))) if (e.gen != null) visibilidad.set(e.gen, !!e.pub)
841
- const paraGen = async (gen) => (typeof members === 'function' ? members(visibilidad.get(gen) === true) : members)
873
+ for (const e of Object.values(vars)) if (e.gen != null) visibilidad.set(e.gen, !!e.pub)
874
+ const paraGen = async (gen) => paraVisibilidad(visibilidad.get(gen) === true)
842
875
  for (const g of bag[k].keyring || []) {
843
876
  const w = g.wraps?.[RECOVERY]
844
877
  if (!w) continue
@@ -859,7 +892,7 @@ export function openSecretsStore (dir, { sealer = null, recipients = null, signe
859
892
  wrapped += Object.keys(r.wraps).length
860
893
  }
861
894
  save()
862
- return { wrapped, sinLlave: [...sinLlave] }
895
+ return { wrapped, sealed, sinLlave: [...sinLlave] }
863
896
  },
864
897
 
865
898
  /**
package/src/vault.js CHANGED
@@ -19,6 +19,8 @@ import * as Acta from '@dotrino/identity/acta'
19
19
  import { createEnrollDesk, deviceIdOf, DEVICE_TTL_MS } from '../lib/src/enroll.js'
20
20
  import { createAdminDesk, authorBody } from '../lib/src/admin.js'
21
21
  import { shouldNotifyRevoked } from '../lib/src/revocation.js'
22
+ // El sobre del gestor, el mismo que usan la bóveda-en-pestaña y la extensión.
23
+ import { identitySealing } from '@dotrino/passmanager'
22
24
  import { createTransport, masterPubkeyOf } from './transport.js'
23
25
  import { startSealersPublisher } from './sealers.js'
24
26
  import { assertKeyOwnsDir } from './keyowner.js'
@@ -1200,30 +1202,22 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
1200
1202
  * EL SELLADO del gestor: la misma cripto de los sobres del ecosistema
1201
1203
  * (`@dotrino/identity/content`), atada a la llave de cifrado de esta bóveda.
1202
1204
  *
1203
- * Va aquí y no en el cliente del transporte porque el cliente del vault es UNO y lo
1204
- * comparte todo: el protocolo de la CA viaja en claro a propósito (un enrolamiento es
1205
- * público hasta que hay cert). Lo que se sella es lo del gestor, y por eso `isSealed`
1206
- * mira su marca y no otra.
1205
+ * Lo arma el PILAR (`identitySealing`), que es la MISMA pieza que usan las otras dos
1206
+ * bóvedas —la de la pestaña y la de dentro de la extensión—. Estaba escrito a mano aquí,
1207
+ * y son las tres puntas del mismo sobre: tres copias son dos que se pueden quedar atrás,
1208
+ * y ese fallo no hace ruido — la petición sale, al otro lado «no es para mí», y desde
1209
+ * fuera se ve como que nadie respondió.
1210
+ *
1211
+ * NO se pone `requireSealed` en el cliente, y no es un olvido: el cliente del vault es
1212
+ * UNO y lo comparte todo, y el protocolo de la CA viaja en claro a propósito (un
1213
+ * enrolamiento es público hasta que hay cert). Lo que corta aquí es `VaultResponder`,
1214
+ * que tira lo que llegue sin la marca `sealed`. Por eso `isSealed` mira la marca del
1215
+ * gestor y no otra.
1207
1216
  *
1208
1217
  * Sin esto la bóveda RECIBÍA los sobres y los tiraba —el cliente no tenía con qué
1209
1218
  * abrirlos— y el aparato se quedaba esperando sin que nada lo dijera.
1210
1219
  */
1211
- const passwordSealing = {
1212
- async seal (msg, peerEncPub) {
1213
- if (!peerEncPub) throw Object.assign(new Error('no encryption key'), { code: 'unsealed' })
1214
- return {
1215
- app: 'passmanager',
1216
- // Destinatarios como OBJETOS y no como llaves sueltas: `encrypt` expande cada
1217
- // uno a todos los aparatos de esa persona, y una cadena a secas se le cae por
1218
- // el `continue` sin envolver nada — el sobre salía vacío y sin error.
1219
- sealed: await identity.encrypt([{ encryptionPubkey: peerEncPub }], JSON.stringify(msg)),
1220
- from: await identity.getEncryptionPubkey()
1221
- }
1222
- },
1223
- // `decrypt` devuelve `{ plaintext }`, no la cadena.
1224
- async open (env) { return JSON.parse((await identity.decrypt(env.from, null, env.sealed)).plaintext) },
1225
- isSealed: (m) => !!m && m.app === 'passmanager' && !!m.sealed
1226
- }
1220
+ const passwordSealing = identitySealing(identity)
1227
1221
  const startPasswordDesk = async () => {
1228
1222
  if (passwords || !passwordDevices().length) return
1229
1223
  const { createPasswordDesk } = await import('./passwords.js')
@@ -2297,7 +2291,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
2297
2291
  // ANTES de reenvolver nada: si la copia de recuperación se quedó bajo la llave de la
2298
2292
  // máquina, se pasa a la frase. Sin esto, todo lo de abajo falla con «wrong password».
2299
2293
  if (adminKey) await migrarRecuperacionALaFrase(adminKey)
2300
- const out = { drawers: 0, wrapped: 0, dropped: 0, sinLlave: [], failed: [] }
2294
+ const out = { drawers: 0, wrapped: 0, sealed: 0, dropped: 0, sinLlave: [], failed: [] }
2301
2295
  for (const owner of secrets.owners?.() || []) {
2302
2296
  const before = new Set(secrets.recipientsIn(owner))
2303
2297
  // POR VISIBILIDAD, no una lista fija: una pública lleva además la envoltura de quien
@@ -2309,6 +2303,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
2309
2303
  const after = new Set(secrets.recipientsIn(owner))
2310
2304
  out.drawers++
2311
2305
  out.wrapped += r?.wrapped || 0
2306
+ out.sealed += r?.sealed || 0
2312
2307
  out.dropped += [...before].filter((p) => !after.has(p)).length
2313
2308
  for (const p of r?.sinLlave || []) out.sinLlave.push(p)
2314
2309
  } catch (e) {
@@ -2316,7 +2311,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
2316
2311
  out.failed.push({ owner, error: e.message })
2317
2312
  }
2318
2313
  }
2319
- if (out.dropped) audit('secret.reseal', { drawers: out.drawers, dropped: out.dropped })
2314
+ if (out.dropped || out.sealed) audit('secret.reseal', { drawers: out.drawers, dropped: out.dropped, sealed: out.sealed })
2320
2315
  if (out.failed.length) audit('secret.reseal-failed', { drawers: out.failed.map((f) => f.owner) })
2321
2316
  return out
2322
2317
  }