@dotrino/vaultd 0.123.1 → 0.124.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotrino/vaultd",
3
- "version": "0.123.1",
3
+ "version": "0.124.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,10 +24,11 @@
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.12.0",
27
+ "@dotrino/passmanager": "0.16.0",
28
28
  "@dotrino/proxy-client": "0.23.1",
29
29
  "@dotrino/roadmap": "0.3.3",
30
30
  "@dotrino/store": "0.11.0",
31
+ "@dotrino/update": "0.1.1",
31
32
  "ws": "^8.18.0"
32
33
  },
33
34
  "license": "MIT",
package/src/approvals.js CHANGED
@@ -38,7 +38,27 @@ const rnd = () => [...crypto.getRandomValues(new Uint8Array(8))].map((b) => b.to
38
38
  /** Separador de la llave compuesta. Es un byte que no puede aparecer en un cajón ni en una pubkey. */
39
39
  const SEP = String.fromCharCode(0)
40
40
 
41
+ /**
42
+ * LO QUE DURA UN PEDIDO SIN CONTESTAR, y por qué son dos números y no uno.
43
+ *
44
+ * CINCO MINUTOS es lo correcto para un COMANDO: hay un proceso bloqueado esperando, y
45
+ * aprobar media hora más tarde entregaría claves a algo cuyo contexto ya cambió — la
46
+ * carpeta, el comando o la máquina pueden ser otros. Que caduque rápido es la protección.
47
+ *
48
+ * UNA ACTUALIZACIÓN no se parece en nada (dueño, 2026-09-20): no hay nadie esperando, y
49
+ * caducar no protege de nada — solo significa que mañana se vuelve a pedir y se vuelve a
50
+ * perder, porque mirar el teléfono puede tardar horas. Un pedido que nunca llega a tiempo
51
+ * es una función que no existe.
52
+ *
53
+ * Por eso el plazo es DEL PEDIDO y no de la mesa. Quien pide dice cuánto aguanta lo suyo.
54
+ */
41
55
  export const PENDING_TTL_MS = 5 * 60 * 1000
56
+ /**
57
+ * Y el de una actualización: un día. Cuadra con que la bóveda mire una vez al día — pedir
58
+ * otra vez reemplaza el pedido anterior del mismo cajón y aparato, así que siempre hay
59
+ * exactamente uno vivo, en vez de una cola de avisos caducados.
60
+ */
61
+ export const UPDATE_TTL_MS = 24 * 60 * 60 * 1000
42
62
  /** Lo que dura una concesión SIN USARSE. Cada uso la vuelve a poner en una hora. */
43
63
  export const GRANT_TTL_MS = 60 * 60 * 1000
44
64
 
@@ -62,10 +82,12 @@ export function createApprovals ({ now = Date.now, pendingTtlMs = PENDING_TTL_MS
62
82
  * apruebe— y tiene que volver por el mismo sitio: desde que la bóveda atiende también
63
83
  * por un socket local, mandarla siempre por el proxio la dejaba en el vacío.
64
84
  */
65
- request ({ ns, device, deviceId, label = '', ek, ctx = null, from = null }) {
85
+ request ({ ns, device, deviceId, label = '', ek, ctx = null, from = null, ttlMs = null }) {
66
86
  for (const [id, p] of pending) if (p.ns === ns && p.device === device) pending.delete(id)
67
87
  const ts = now()
68
- const p = { id: rnd(), ns, device, deviceId, label, ek, ctx, ts, exp: ts + pendingTtlMs, from }
88
+ // `ttlMs` lo pone quien pide: una actualización aguanta un día y un comando cinco
89
+ // minutos, y la mesa no tiene por qué saber cuál es cuál (ver `PENDING_TTL_MS`).
90
+ const p = { id: rnd(), ns, device, deviceId, label, ek, ctx, ts, exp: ts + (ttlMs || pendingTtlMs), from }
69
91
  pending.set(p.id, p)
70
92
  return publicOf(p)
71
93
  },
@@ -158,4 +180,4 @@ export function createGrants ({ now = Date.now, ttlMs = GRANT_TTL_MS } = {}) {
158
180
  return api
159
181
  }
160
182
 
161
- export default { createApprovals, createGrants, PENDING_TTL_MS, GRANT_TTL_MS }
183
+ export default { createApprovals, createGrants, PENDING_TTL_MS, UPDATE_TTL_MS, GRANT_TTL_MS }
package/src/ctl.js CHANGED
@@ -37,6 +37,7 @@ import { qrToString } from './qr.js'
37
37
  import { encodeInvite, inviteUrl, parseInvite } from '../lib/src/invite.js'
38
38
  import { atRestFor, readConfig as readKekConfig, probe as probeKek, rekeyDir, encryptedFilesIn, CONFIG_FILE as KEK_CONFIG_FILE } from '../lib/src/atrest.js'
39
39
  import { VERSION } from './version.js'
40
+ import { isNewer } from './update.js'
40
41
 
41
42
  const dir = dataDir()
42
43
  const stateFile = path.join(dir, 'state.json')
@@ -144,6 +145,13 @@ function cmdStatus () {
144
145
  console.log(' proxy : %s', s.proxy)
145
146
  console.log(' pid : %s%s', s.pid, up ? '' : ' (no responde)')
146
147
  console.log(' datos : %s', dir)
148
+ // HAY VERSIÓN NUEVA. Lo mira el daemon una vez al día y lo deja en `state.json`; aquí
149
+ // solo se lee, para que `status` conteste al instante y también sin conexión. Es lo que
150
+ // faltaba el 2026-09-19: el replicador llevaba quince días atrás y nada lo decía.
151
+ if (s.latest?.version && isNewer(s.latest.version, VERSION)) {
152
+ console.log(' %s⬆ hay %s publicada%s (esta es la %s) · tráela con: dotrino-vault update',
153
+ B, s.latest.version, Z, VERSION)
154
+ }
147
155
  const profiles = s.profiles || []
148
156
  if (profiles.length) {
149
157
  console.log(' perfiles : %d', profiles.length)
@@ -1689,6 +1697,77 @@ async function cmdLogins (rest) {
1689
1697
  }
1690
1698
  }
1691
1699
 
1700
+ /**
1701
+ * `dotrino-vault update` — TRAER LA VERSIÓN NUEVA, cuando lo escribe una persona.
1702
+ *
1703
+ * La mitad automática es MIRAR, y la hace el daemon una vez al día (`src/update.js`
1704
+ * explica por qué la otra mitad no se automatiza: sería un canal para meter código en la
1705
+ * máquina que guarda la maestra sin que nadie diga que sí).
1706
+ *
1707
+ * Lo que se baja SE VERIFICA contra la atestación de sigstore antes de tocar el disco, y
1708
+ * si no se puede verificar NO SE INSTALA. Que la URL sea la correcta no prueba nada.
1709
+ *
1710
+ * Instalar no reinicia nada a mano: el daemon vigila su propio binario y, al cambiar, se
1711
+ * va para que systemd lo levante con el nuevo (`src/selfupdate.js`).
1712
+ */
1713
+ async function cmdUpdate (args = []) {
1714
+ const { latestRelease, assetFor, download, verifyArtifact, isNewer } = await import('./update.js')
1715
+ const soloMirar = args.includes('--check')
1716
+
1717
+ process.stdout.write('Mirando qué hay publicado… ')
1718
+ const r = await latestRelease()
1719
+ if (!r.ok) {
1720
+ console.log('')
1721
+ console.error('No se pudo mirar (%s). No es que estés al día: es que no se sabe.', r.reason)
1722
+ process.exit(1)
1723
+ }
1724
+ console.log('%s', r.version)
1725
+
1726
+ if (!isNewer(r.version, VERSION)) {
1727
+ console.log('Estás al día (%s).', VERSION)
1728
+ return
1729
+ }
1730
+ console.log('%sHay %s y esta es la %s.%s', B, r.version, VERSION, Z)
1731
+ if (soloMirar) { console.log('Tráela con: dotrino-vault update'); return }
1732
+
1733
+ const pick = assetFor(r.assets)
1734
+ if (!pick.ok) { console.error('%s', pick.reason); process.exit(1) }
1735
+
1736
+ process.stdout.write(`Bajando ${pick.asset.name}… `)
1737
+ let file
1738
+ try { file = await download(pick.asset) } catch (e) { console.log(''); console.error('%s', e.message); process.exit(1) }
1739
+ console.log('listo')
1740
+
1741
+ process.stdout.write('Comprobando la firma… ')
1742
+ const v = verifyArtifact(file)
1743
+ if (!v.ok) {
1744
+ console.log('')
1745
+ console.error('%sNO se instala nada.%s %s', R, Z, v.reason)
1746
+ console.error('El archivo quedó en %s, sin tocar el sistema.', file)
1747
+ process.exit(1)
1748
+ }
1749
+ console.log('firmada por el release de %s ✓', 'imdotrino/dotrino-vault')
1750
+
1751
+ if (pick.kind === 'deb') {
1752
+ if (typeof process.getuid === 'function' && process.getuid() === 0) {
1753
+ execFileSync('dpkg', ['-i', file], { stdio: 'inherit' })
1754
+ } else {
1755
+ // No se pide sudo por dentro: que la contraseña la escriba quien la escribe, en su
1756
+ // terminal y viendo qué comando la pide.
1757
+ console.log('\nYa verificada. Instálala con:')
1758
+ console.log(' %ssudo dpkg -i %s%s', B, file, Z)
1759
+ console.log('\nEl servicio se reinicia solo al cambiar el binario.')
1760
+ return
1761
+ }
1762
+ } else {
1763
+ console.log('\nYa verificada. Descomprímela y reemplaza el binario:')
1764
+ console.log(' %star xzf %s -C /tmp && sudo install -m0755 /tmp/dotrino-vault-%s-linux-x64/dotrino-vault{,d} /usr/bin/%s',
1765
+ B, file, r.version, Z)
1766
+ return
1767
+ }
1768
+ console.log('Listo. El servicio se reinicia solo; compruébalo con: dotrino-vault status')
1769
+ }
1770
+
1692
1771
  function reportProfiles (d) {
1693
1772
  if (d.error) {
1694
1773
  // Los dos rechazos del candado se dicen con palabras y con el dato que hace falta; el
@@ -2085,6 +2164,7 @@ export async function runCtl (argv) {
2085
2164
  case 'caps': return cmdCaps(rest)
2086
2165
  case 'revoke': return cmdRevoke(rest[0])
2087
2166
  case 'logins': return cmdLogins(rest)
2167
+ case 'update': return cmdUpdate(rest)
2088
2168
  case 'secret': return cmdSecret(rest)
2089
2169
  // `approval` se quitó el 2026-09-01 (ahora es el permiso `unattended`), pero la ruta se
2090
2170
  // quedó apuntando a una función que ya no existía: reventaba con un ReferenceError. Se
package/src/daemon.js CHANGED
@@ -23,6 +23,7 @@ import { dataDir } from './paths.js'
23
23
  import { ipcRead, ipcWrite, migrateIpcDir } from './ipc.js'
24
24
  import { parseInvite } from '../lib/src/invite.js'
25
25
  import { watchBinary } from './selfupdate.js'
26
+ import { latestRelease, isNewer, CHECK_EVERY_MS } from './update.js'
26
27
  import { VERSION } from './version.js'
27
28
  import crypto from 'node:crypto'
28
29
 
@@ -87,6 +88,8 @@ export async function runDaemon () {
87
88
  // --- state.json ---
88
89
  const stateFile = path.join(dir, 'state.json')
89
90
  const daemonVersion = VERSION
91
+ /** `{ version, checkedAt }` de la última consulta que salió bien, o null si aún ninguna. */
92
+ let ultimaPublicada = null
90
93
  // Los campos de la raíz (fingerprint/iss) son los del perfil ACTIVO: los leen el
91
94
  // instalador y la web, que son anteriores al multi-perfil. La lista completa va
92
95
  // en `profiles`.
@@ -98,6 +101,10 @@ export async function runDaemon () {
98
101
  // Cuánto aguanta abierto el candado sin usarse. Va en la foto para que la consola
99
102
  // pueda DECIRLO en vez de llevar su propio número (que se desincronizaría).
100
103
  autoLockMs: mgr.profiles.autoLockMs,
104
+ // LO QUE HAY PUBLICADO, si se pudo mirar. Va en la foto para que `status` lo enseñe
105
+ // sin salir a la red: una pantalla de estado tiene que contestar al instante y
106
+ // también sin conexión.
107
+ ...(ultimaPublicada ? { latest: ultimaPublicada } : {}),
101
108
  current: mgr.currentId(), profiles: mgr.summary()
102
109
  })
103
110
  }
@@ -983,6 +990,50 @@ export async function runDaemon () {
983
990
  })
984
991
  if (vigia) console.log('[vault] watching for updates: a new binary restarts the service on its own')
985
992
 
993
+ /**
994
+ * ¿SALIÓ UNA VERSIÓN NUEVA? Una lectura al día, y nada más.
995
+ *
996
+ * Esto NO descarga ni instala: sería meter código en la máquina que guarda la maestra sin
997
+ * que nadie diga que sí, y eso es justo el interruptor remoto que el proyecto no tiene
998
+ * (`src/update.js` lo explica entero). Traerla la escribe una persona: `dotrino-vault
999
+ * update`.
1000
+ *
1001
+ * Lo que arregla es lo que de verdad dolió el 2026-09-19: el replicador llevaba quince
1002
+ * días atrás y las bóvedas dos versiones, y NADA lo decía en ninguna pantalla.
1003
+ *
1004
+ * Si no se puede mirar, no se dice nada y ya está: no saber si hay versión nueva no es un
1005
+ * problema del usuario, y llenarle el log de fallos de red tampoco ayuda.
1006
+ */
1007
+ const mirarRelease = async () => {
1008
+ const r = await latestRelease()
1009
+ if (!r.ok) return
1010
+ ultimaPublicada = { version: r.version, checkedAt: Date.now() }
1011
+ writeState()
1012
+ if (!isNewer(r.version, daemonVersion)) return
1013
+
1014
+ /**
1015
+ * PIDE PERMISO SOLO SI HAY A QUIÉN PEDÍRSELO (dueño, 2026-09-20).
1016
+ *
1017
+ * La bóveda es la pieza sensible del ecosistema: quien le cuele una actualización se
1018
+ * lleva la maestra, y eso no se rota — se pierde la cuenta. Por eso no se actualiza
1019
+ * sola mientras haya un aparato con `aprueba` que pueda decidirlo, igual que ya pasa
1020
+ * con las claves privadas.
1021
+ *
1022
+ * Y si NO hay aprobadores, esperar un sí que nadie puede dar sería condenarla a no
1023
+ * actualizarse nunca. Entonces se actualiza sola, que es lo que hace el resto del
1024
+ * ecosistema (§15).
1025
+ */
1026
+ let quienAprueba = []
1027
+ try { quienAprueba = (await mgr.current()?.approvers?.()) || [] } catch (_) {}
1028
+ const comoLoDice = quienAprueba.length
1029
+ ? `waiting for one of ${quienAprueba.length} approver(s) to say yes`
1030
+ : 'no approver in the record, so this vault may update itself'
1031
+ console.log(`[vault] version ${r.version} is out (this one is ${daemonVersion}) · ${comoLoDice} · dotrino-vault update`)
1032
+ }
1033
+ mirarRelease()
1034
+ const relojRelease = setInterval(mirarRelease, CHECK_EVERY_MS)
1035
+ relojRelease.unref?.()
1036
+
986
1037
  console.log('[vault] servicio listo.')
987
1038
  return mgr
988
1039
  }
package/src/passwords.js CHANGED
@@ -109,47 +109,17 @@ export function createPasswordDesk (opts = {}) {
109
109
  * hace al ABRIR la bóveda, que es cuando la frase está a mano y hay con qué reescribirlo
110
110
  * todo.
111
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.
112
+ * El trabajo está en `@dotrino/passmanager/sealed` y no aquí, porque lo hacen las CUATRO
113
+ * bóvedas: esta, la pestaña, la de dentro de la extensión y `passmanager serve`. Cuatro
114
+ * copias serían cuatro formas sutilmente distintas de reescribir lo mismo, y la que se
115
+ * quedara atrás no fallaría al convertir — fallaría al leerlo otra bóveda, meses después.
116
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ó
117
+ * Lo que sí es de aquí: `dropOldKey`, porque dónde vive la llave vieja lo sabe cada una.
124
118
  */
125
119
  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 }
120
+ const { convertToSealed } = await import('@dotrino/passmanager/sealed')
121
+ return convertToSealed({
122
+ store, sealed, cek, recipients, author, dropOldKey,
123
+ log: (m) => log('[vault] ' + m)
124
+ })
155
125
  }
@@ -253,6 +253,20 @@ export function openSecretsStore (dir, { sealer = null, recipients = null, signe
253
253
  return kr.reduce((best, g) => (!best || (g.gen || 0) > (best.gen || 0) ? g : best), null)
254
254
  }
255
255
 
256
+ /**
257
+ * LA GENERACIÓN QUE TOCA ESTRENAR. Se mira el llavero Y las entradas, no solo el llavero:
258
+ * un dato PÚBLICO del perfil lleva generación pero no sobre, así que no deja nada en el
259
+ * llavero. Contando solo el llavero, dos escrituras públicas seguidas salían con la MISMA
260
+ * generación — y la generación es lo que ordena un dato entre réplicas (`mergeBundle`):
261
+ * con empate gana el de hash menor, que puede ser el valor viejo.
262
+ */
263
+ const nextGen = (bag, k, owner) => {
264
+ let top = topGen(bag, k)?.gen || 0
265
+ for (const e of Object.values(bag[k]?.vars || {})) if ((e.gen || 0) > top) top = e.gen
266
+ for (const h of data.history) if (h.owner === owner && (h.gen || 0) > top) top = h.gen
267
+ return top + 1
268
+ }
269
+
256
270
  /**
257
271
  * UN CAMBIO A LA VEZ.
258
272
  *
@@ -445,14 +459,22 @@ export function openSecretsStore (dir, { sealer = null, recipients = null, signe
445
459
  *
446
460
  * @param {string} owner `ns:<scope>` o `dev:<pub>`
447
461
  * @param {string} memberPub la llave de FIRMA del miembro (así se indexan las envolturas)
462
+ * @param {{ only?: (isPublic: boolean) => boolean }} [opts] qué variables le TOCAN, según
463
+ * su visibilidad: a quien administra se le envuelven las públicas de un cajón con dueño
464
+ * y no las privadas, así que la falta de una privada no es deuda suya.
448
465
  * @returns {string[]} nombres de variables, vacío si puede con todas
449
466
  */
450
- missingFor (owner, memberPub) {
467
+ missingFor (owner, memberPub, { only = null } = {}) {
451
468
  if (isLegacy()) return []
452
469
  const [kind, k] = splitOwner(owner)
453
470
  const bag = kind === 'ns' ? data.ns : data.dev
454
471
  const out = []
455
472
  for (const [key, e] of Object.entries(varsOf(bag, k))) {
473
+ // Lo PÚBLICO DEL PERFIL no lleva sobre: va en claro y firmado, porque quien lo lee
474
+ // desde fuera no tiene ninguna llave tuya. No hay envoltura que pueda faltarle a
475
+ // nadie, y contarla como deuda era un aviso que ningún reparto podía apagar.
476
+ if (e.cls === 'public') continue
477
+ if (only && !only(!!e.pub)) continue
456
478
  // También las públicas: desde 2026-09-02 van en sobre, así que a quien no tenga su
457
479
  // envoltura le falta igual que con una privada.
458
480
  const g = (bag[k]?.keyring || []).find((x) => x.gen === e.gen)
@@ -580,7 +602,7 @@ export function openSecretsStore (dir, { sealer = null, recipients = null, signe
580
602
 
581
603
  // CEK NUEVA, siempre: no se puede reutilizar la de antes sin poder abrirla.
582
604
  const cek = await sealer.newKey()
583
- const gen = (topGen(bag, k)?.gen || 0) + 1
605
+ const gen = nextGen(bag, k, owner)
584
606
  const { wraps, sinLlave } = await wrapAll(cek, owner, pub)
585
607
  const e = await sealer.encrypt(cek, value, gen)
586
608
  // La FIRMA dice que este sobre salió de esta bóveda, y con qué acta (§8.8). Si no
@@ -644,7 +666,7 @@ export function openSecretsStore (dir, { sealer = null, recipients = null, signe
644
666
 
645
667
  // LA GENERACIÓN LA PONE EL ALMACÉN, no quien escribe: es la que ordena el llavero, y
646
668
  // dejar que la eligiera el que llega permitiría pisar una anterior.
647
- const gen = (topGen(bag, k)?.gen || 0) + 1
669
+ const gen = nextGen(bag, k, owner)
648
670
  // La FIRMA sigue siendo de la bóveda —dice que este sobre salió de aquí y con qué
649
671
  // acta (§8.8)— y se hace con la llave de SELLADO, que funciona con el perfil cerrado.
650
672
  const seal = signer ? await signer({ owner, key, gen, iv: e.iv, ct: e.ct }) : null
@@ -917,7 +939,7 @@ export function openSecretsStore (dir, { sealer = null, recipients = null, signe
917
939
  }
918
940
  const vars = ensureBag(data.ns, PROFILE_NS)
919
941
  const before = vars[key]
920
- const gen = (topGen(data.ns, PROFILE_NS)?.gen || 0) + 1
942
+ const gen = nextGen(data.ns, PROFILE_NS, PROFILE_OWNER)
921
943
  const seal = signer ? await signer({ owner: PROFILE_OWNER, key, gen, iv: '', ct: value }) : null
922
944
  pushHistory(PROFILE_OWNER, key, before, by)
923
945
  vars[key] = { cls: 'public', pub: true, owner: PROFILE_OWNER, gen, pubv: value, seal, at: Date.now(), by: by || null }
@@ -963,7 +985,7 @@ export function openSecretsStore (dir, { sealer = null, recipients = null, signe
963
985
  for (const [key, e] of Object.entries(vars)) {
964
986
  if (typeof e.v !== 'string' || e.e) continue
965
987
  const cek = await sealer.newKey()
966
- const gen = (topGen(bag, k)?.gen || 0) + 1
988
+ const gen = nextGen(bag, k, owner)
967
989
  const r = await sealer.wrapFor(cek, await paraVisibilidad(!!e.pub))
968
990
  r.wraps[RECOVERY] = await sealer.wrapForKey(cek, data.recovery.pub)
969
991
  const sobre = await sealer.encrypt(cek, e.v, gen)
package/src/update.js ADDED
@@ -0,0 +1,39 @@
1
+ /**
2
+ * update.js — lo que es DEL VAULT al actualizarse, y nada más.
3
+ *
4
+ * La mecánica —comparar versiones, preguntar a GitHub, bajar y verificar contra sigstore—
5
+ * vive en `@dotrino/update`, porque es la misma para el proxio, geo, los agentes y los
6
+ * comandos (CONVENCIONES §15). Aquí solo queda lo que sabe el vault de sí mismo: en qué
7
+ * repo sale y cómo se llaman sus archivos.
8
+ *
9
+ * Antes de existir el paquete esto era una copia entera de esa mecánica. Dos copias de lo
10
+ * mismo son dos sitios donde arreglar el mismo fallo, y el segundo siempre se olvida.
11
+ */
12
+ import fs from 'node:fs'
13
+ import { latestVersion, isNewer, CHECK_EVERY_MS } from '@dotrino/update'
14
+ import { pickAsset, download, verifyArtifact as verify } from '@dotrino/update/fetch'
15
+ import { VERSION } from './version.js'
16
+
17
+ export const REPO = 'imdotrino/dotrino-vault'
18
+ export { isNewer, CHECK_EVERY_MS, download }
19
+
20
+ /** La última release del vault. `{ ok:false, reason }` si no se pudo mirar, que NO es «al día». */
21
+ export const latestRelease = (opts = {}) =>
22
+ latestVersion({ source: 'github', repo: REPO, product: 'dotrino-vault', version: VERSION, ...opts })
23
+
24
+ /**
25
+ * Qué archivo le toca a esta máquina. El `.deb` donde hay `dpkg`; en cualquier otro Linux,
26
+ * el tarball. Fuera de Linux no se emite binario, y se dice en vez de bajar algo inútil.
27
+ */
28
+ export function assetFor (assets, { platform = process.platform, arch = process.arch, hasDpkg = null } = {}) {
29
+ if (platform !== 'linux') return { ok: false, reason: `there is no binary for ${platform}: update with npm (npx @dotrino/vaultd)` }
30
+ if (arch !== 'x64') return { ok: false, reason: `there is no binary for ${arch}` }
31
+ const deb = hasDpkg === null ? fs.existsSync('/usr/bin/dpkg') : hasDpkg
32
+ return pickAsset(assets, [
33
+ { kind: 'deb', re: /_amd64\.deb$/, when: () => deb },
34
+ { kind: 'tar', re: /-linux-x64\.tar\.gz$/ }
35
+ ])
36
+ }
37
+
38
+ /** Verifica contra la atestación del release del vault. Sin `gh`, no se instala. */
39
+ export const verifyArtifact = (file, opts = {}) => verify(file, { repo: REPO, ...opts })
package/src/vault.js CHANGED
@@ -2745,28 +2745,28 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
2745
2745
  */
2746
2746
  async function incompleteMembers () {
2747
2747
  if (secrets.isLegacy?.()) return []
2748
- const record = (await identity.profileActa?.().catch(() => null))?.acta
2749
- const out = []
2750
- for (const m of record?.members || []) {
2751
- if (!m.encPub || m.pub === master) continue
2752
- const owners = {}
2753
- // Un servicio lee su cajón de ns y el suyo propio; un aparato que administra
2754
- // puede DESTAPAR cualquiera, así que se le miran todos: sin envoltura, el botón
2755
- // «Ver» de la consola le fallaría sin explicar por qué.
2756
- // Un aparato que administra NO debe tener envoltura de los cajones con dueño
2757
- // (`recipientsOf`), así que no se le cuenta como falta: lo que ahí falta es a
2758
- // propósito y ponerlo en la lista sería pedir que se «arregle» lo correcto.
2759
- const ownedNs = new Set((record?.members || []).filter((x) => x.cn).map((x) => x.cn))
2760
- const drawers = m.cn
2761
- ? [`ns:${m.cn}`, `dev:${m.pub}`]
2762
- : [...Object.keys(secrets.list()).filter((ns) => !ownedNs.has(ns)).map((ns) => `ns:${ns}`), `dev:${m.pub}`]
2763
- for (const owner of drawers) {
2764
- const missing = secrets.missingFor(owner, m.pub)
2765
- if (missing.length) owners[owner] = missing
2748
+ // A QUIÉN LE FALTA ALGO LO DICE `recipientsOf`, Y SOLO ÉL — la misma lista con la que
2749
+ // se envuelve al escribir y se rehace el llavero al abrir. Aquí había otra: «un aparato
2750
+ // sin `cn` puede destapar cualquier cajón sin dueño». Para `@me` no es cierto —el
2751
+ // perfil se envuelve a quien puede FIRMAR por ti—, así que un aparato sin `firma` salía
2752
+ // con todos los datos del perfil «sin llave todavía», y abrir la bóveda, que es lo que
2753
+ // el aviso pedía, no podía apagarlo: el llavero ya estaba como decía el acta. Dos
2754
+ // listas para la misma pregunta acaban siempre así.
2755
+ const out = new Map()
2756
+ for (const owner of secrets.owners()) {
2757
+ // Por VISIBILIDAD: a quien administra se le envuelve la pública de un cajón con dueño
2758
+ // y no la privada. La lista de las públicas contiene siempre a la de las privadas.
2759
+ const privados = new Set((await recipientsOf(owner)).map((m) => m.pub))
2760
+ for (const m of await recipientsOf(owner, { public: true })) {
2761
+ if (!m.encPub || m.pub === master) continue
2762
+ const missing = secrets.missingFor(owner, m.pub, { only: (esPublica) => esPublica || privados.has(m.pub) })
2763
+ if (!missing.length) continue
2764
+ const row = out.get(m.pub) || { pub: m.pub, cn: m.cn || null, owners: {} }
2765
+ row.owners[owner] = missing
2766
+ out.set(m.pub, row)
2766
2767
  }
2767
- if (Object.keys(owners).length) out.push({ pub: m.pub, cn: m.cn || null, owners })
2768
2768
  }
2769
- return out
2769
+ return [...out.values()]
2770
2770
  }
2771
2771
 
2772
2772
  /**
@@ -3552,6 +3552,22 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
3552
3552
  // (`unattended`), no una lista de esta máquina — por eso ya no hay `setApproval` ni
3553
3553
  // `supervised`: se concede y se quita como cualquier otro permiso, con `caps`.
3554
3554
  needsApproval: async (pub) => needsApproval(pub, await refreshActa()),
3555
+ /**
3556
+ * QUIÉN PUEDE APROBAR EN ESTA CUENTA. Lo usa la actualización (§15): la bóveda pide
3557
+ * permiso al teléfono **solo si hay a quién pedírselo**; si no hay ningún aparato con
3558
+ * `aprueba`, se actualiza sola.
3559
+ *
3560
+ * Es la regla del dueño (2026-09-20) y evita la trampa evidente: una bóveda sin
3561
+ * aprobadores que se quedaría sin poder actualizarse nunca, esperando un sí que nadie
3562
+ * puede dar. Mismo sitio del que ya sale el «rang N approver(s)» de los secretos.
3563
+ */
3564
+ approvers: async () => {
3565
+ const record = await refreshActa()
3566
+ if (!record) return []
3567
+ return (record.members || [])
3568
+ .filter((m) => Acta.memberCan(record, m.pub, 'approve'))
3569
+ .map((m) => ({ id: m.id, label: m.label || '', pub: m.pub }))
3570
+ },
3555
3571
  // La bóveda de contraseñas (`passwords.js`). Aquí SÍ se lista: es donde está la
3556
3572
  // llave. Lo que no puede es listarla un aparato.
3557
3573
  passwordDevices,