@dotrino/vaultd 0.123.0 → 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.
@@ -34,7 +34,7 @@
34
34
  * Si algo de eso falla, se PARA con un `code` propio. No hay repliegue: entrar «a medias»
35
35
  * en una cuenta es peor que no entrar.
36
36
  */
37
- import { client as opaque } from '@dotrino/opaque'
37
+ import { client as opaquePorDefecto } from '@dotrino/opaque'
38
38
  import { pubkeyId, verifyDeviceSig, signWithDevice } from '@dotrino/identity/capabilities'
39
39
  import { checkVaultReply } from '@dotrino/identity/acta'
40
40
  import { MSG } from './protocol.js'
@@ -82,11 +82,16 @@ const parseJson = (s) => { try { return JSON.parse(s) } catch (_) { return null
82
82
  * @param {string} opts.password
83
83
  * @param {string} [opts.label] de dónde se entra («el cyber de la esquina»): es lo que
84
84
  * el dueño va a leer en su consola para decidir si cerrarlo.
85
+ * @param {object} [opts.opaque] el OPAQUE a usar. Por defecto el de casa, que va en este
86
+ * mismo hilo. Se inyecta porque en una EXTENSIÓN el WASM vive en una página sandbox, al
87
+ * otro lado de un `postMessage`, y entonces sus métodos devuelven promesas — igual que en
88
+ * `createLoginDesk`, y por el mismo motivo. Aquí se espera siempre: esperar a algo que no
89
+ * es una promesa no cuesta nada, y dos firmas serían dos clientes.
85
90
  * @returns {Promise<{user:string,address:string,code:string,sid:string,cert:object,
86
91
  * iss:string,acta:object,vaultToken:string,publickey:string,encPublickey:(string|null),
87
92
  * keys:{sign:object,enc:(object|null)}}>}
88
93
  */
89
- export async function loginWithPassword ({ transport, address, password, label = '', timeoutMs = REPLY_TIMEOUT_MS } = {}) {
94
+ export async function loginWithPassword ({ transport, address, password, label = '', timeoutMs = REPLY_TIMEOUT_MS, opaque = opaquePorDefecto } = {}) {
90
95
  if (!transport || typeof transport.on !== 'function' || typeof transport.send !== 'function') {
91
96
  throw err('no-transport', 'loginWithPassword: a connected transport is required')
92
97
  }
@@ -104,7 +109,7 @@ export async function loginWithPassword ({ transport, address, password, label =
104
109
  let ultimo = null
105
110
  for (const token of tokens) {
106
111
  try {
107
- return await unIntento({ transport, token, user, code, dir, password, label, timeoutMs })
112
+ return await unIntento({ transport, token, user, code, dir, password, label, timeoutMs, opaque })
108
113
  } catch (e) {
109
114
  // Se para: son cosas del que entra, y probar con otra bóveda no las arregla.
110
115
  if (e?.code === 'login-failed' || e?.code === 'too-many-tries' || e?.code === 'wrong-account' ||
@@ -115,15 +120,15 @@ export async function loginWithPassword ({ transport, address, password, label =
115
120
  throw ultimo || err('no-vault', 'none of the vaults on that address could let you in')
116
121
  }
117
122
 
118
- async function unIntento ({ transport, token, user, code, dir, password, label, timeoutMs }) {
119
- const start = opaque.loginStart({ password })
123
+ async function unIntento ({ transport, token, user, code, dir, password, label, timeoutMs, opaque }) {
124
+ const start = await opaque.loginStart({ password })
120
125
  transport.send(token, { type: MSG.LOGIN_START, user, request: start.request })
121
126
  const r1 = await waitFor(transport, token, (p) => p.type === MSG.LOGIN_RESPONSE || p.type === MSG.ERROR, timeoutMs)
122
127
  if (r1.type === MSG.ERROR) throw deVuelta(r1)
123
128
 
124
129
  let fin
125
130
  try {
126
- fin = opaque.loginFinish({ state: start.state, response: r1.response, password })
131
+ fin = await opaque.loginFinish({ state: start.state, response: r1.response, password })
127
132
  } catch (_) {
128
133
  // OPAQUE comprueba a las dos partes: aquí se sabe que el otro lado NO conoce el
129
134
  // registro de este usuario. Contraseña equivocada, usuario que no existe o una bóveda
@@ -198,11 +203,26 @@ function deVuelta (p) {
198
203
  * quedarse bloqueado esperándola. La sesión sigue abierta allí hasta que se cierre desde la
199
204
  * consola, y eso ya está dicho en el diseño (§3.2).
200
205
  */
201
- export async function closeLogin ({ transport, token, user, sid, publickey, privateJwk, privateKey, timeoutMs = REPLY_TIMEOUT_MS } = {}) {
206
+ export async function closeLogin ({ transport, token, user, sid, publickey, privateJwk, privateKey, sign, timeoutMs = REPLY_TIMEOUT_MS } = {}) {
202
207
  if (!transport || typeof transport.send !== 'function') throw err('no-transport', 'closeLogin: transport required')
203
208
  if (!token || !user || !sid || !publickey) throw err('bad-input', 'closeLogin: token, user, sid and publickey are required')
204
209
  const data = { op: 'login.close', publickey, user, sid, ts: Date.now() }
205
- const { signature } = await signWithDevice({ privateJwk, privateKey, publickey, data })
210
+ // CON LA LLAVE O CON QUIEN LA TIENE. `sign(data)` es lo normal en el ecosistema —el
211
+ // transporte se identifica con `identity.signData` y no con una llave suelta— y aquí hace
212
+ // falta de verdad: en una extensión la llave vive en el service worker y quien tiene el
213
+ // socket es la página, así que o se firma a distancia o la llave privada tendría que
214
+ // cruzar para nada. Sin ninguna de las dos no se firma, y salir no se puede fingir.
215
+ //
216
+ // Se comprueba ANTES de firmar: sin nada con qué hacerlo, WebCrypto revienta con un error
217
+ // suyo que no dice qué falta («code: 0»), y el de arriba es el que se puede buscar.
218
+ if (typeof sign !== 'function' && !privateJwk && !privateKey) {
219
+ throw err('no-signature', 'closeLogin: give it the device key (privateJwk/privateKey) or a sign(data)')
220
+ }
221
+ const firmado = typeof sign === 'function'
222
+ ? await sign(data)
223
+ : await signWithDevice({ privateJwk, privateKey, publickey, data })
224
+ const signature = typeof firmado === 'string' ? firmado : firmado?.signature
225
+ if (typeof signature !== 'string') throw err('no-signature', 'closeLogin: sign(data) returned no signature')
206
226
  transport.send(token, { type: MSG.LOGIN_CLOSE, data, signature })
207
227
  try {
208
228
  const r = await waitFor(transport, token, (p) => p.type === MSG.LOGIN_CLOSED || p.type === MSG.ERROR, timeoutMs)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotrino/vaultd",
3
- "version": "0.123.0",
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/tui/app.js CHANGED
@@ -42,6 +42,12 @@ import { DEVICE_CAPS } from '@dotrino/identity/acta'
42
42
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
43
43
 
44
44
  // Regex de validación (mismas que el store de secretos, protocol.js).
45
+ // El mínimo de la contraseña de un inicio de sesión. No es manía: quien tenga una COPIA
46
+ // DEL DISCO de la bóveda puede probar contraseñas contra el registro de OPAQUE sin límite y
47
+ // sin que nadie se entere (`temporary-access.md` §4). Contra eso lo único que aguanta es la
48
+ // contraseña. Mismo número que la CLI, que es la misma regla.
49
+ const MIN_LOGIN_PASSWORD = 12
50
+
45
51
  const NS_RE = /^[a-z0-9-]{1,32}$/
46
52
  const KEY_RE = /^[A-Z0-9_]{1,64}$/
47
53
 
@@ -343,6 +349,42 @@ function deviceRows (st, t) {
343
349
  return rows
344
350
  }
345
351
 
352
+ /**
353
+ * ENTRAR CON USUARIO Y CONTRASEÑA (`docs/temporary-access.md`).
354
+ *
355
+ * Esto estaba SOLO en la línea de comandos, y es lo que la regla de las tres versiones no
356
+ * admite: lo que hace el binario tiene que poder hacerse en su pantalla. El canal es el
357
+ * MISMO que usa `dotrino-vault logins` —una pieza, no dos—, así que no se pueden
358
+ * desincronizar.
359
+ *
360
+ * Lo que se ve de cada uno es lo que hace falta para decidir: su DIRECCIÓN (que es lo que
361
+ * se teclea en el equipo prestado), cuántas sesiones tiene abiertas, y si está esperando
362
+ * por intentos fallidos.
363
+ */
364
+ function loginRows (st, t) {
365
+ const i = L(st)
366
+ const rows = []
367
+ const list = st.logins || []
368
+ if (!list.length) {
369
+ rows.push({ text: t.muted(i.noLogins), sel: false })
370
+ rows.push({ text: '', sel: false })
371
+ rows.push({ text: t.muted(i.loginsHint), sel: false })
372
+ return rows
373
+ }
374
+ for (const l of list) {
375
+ const espera = l.blockedUntil > Date.now() ? Math.ceil((l.blockedUntil - Date.now()) / 1000) : 0
376
+ const abiertas = (l.sessions || []).length
377
+ const extra = [
378
+ abiertas ? t.warn(' ' + i.loginOpen(abiertas)) : t.muted(' ' + i.loginOpen(0)),
379
+ l.label ? t.muted(' «' + l.label + '»') : '',
380
+ espera ? t.danger(' ' + i.loginWait(espera)) : '',
381
+ l.createdAt ? t.muted(' ' + i.loginCreated(fmtExp(l.createdAt))) : ''
382
+ ].join('')
383
+ rows.push({ text: ` ${t.bold(l.address || l.user)} ${t.muted(l.deviceId || '????-????')}${extra}`, sel: true, meta: l })
384
+ }
385
+ return rows
386
+ }
387
+
346
388
  /**
347
389
  * LA PREGUNTA DEL EMPAREJAMIENTO. La decisión es del vault (es quien lo inicia) y
348
390
  * este daemon puede tener varias cuentas: antes de mostrar el QR hay que decir a
@@ -753,6 +795,17 @@ async function refreshMembers (term, st) {
753
795
  const r = await guard(term, st, L(st).loadingMembers, () => vc.listMembers(activeId(st)))
754
796
  if (r.ok) st.members = r.v
755
797
  }
798
+ /**
799
+ * Los inicios de sesión de la bóveda activa. Es una petición aparte del volcado normal a
800
+ * propósito: no hace falta para nada más, y pedirla siempre sería sacar del cifrado algo
801
+ * que nadie está mirando.
802
+ */
803
+ async function refreshLogins (term, st, api = vc) {
804
+ const r = await guard(term, st, L(st).refreshing, () => api.listLogins(activeId(st)))
805
+ if (r.ok) st.logins = r.v
806
+ else st.logins = []
807
+ }
808
+
756
809
  async function refreshMe (term, st) {
757
810
  const r = await guard(term, st, L(st).loadingProfile, () => vc.getMe(activeId(st)))
758
811
  st.me = r.ok ? r.v : null
@@ -1047,6 +1100,105 @@ async function onKeyDevices (term, st, key) {
1047
1100
  return true
1048
1101
  }
1049
1102
 
1103
+ /**
1104
+ * ENTRAR CON USUARIO Y CONTRASEÑA: las mismas acciones que el CLI, con las mismas teclas
1105
+ * que el resto de la TUI (`n` nuevo, `c` contraseña, `v` quitar, `x` cerrar, `u` desbloquear).
1106
+ *
1107
+ * `x` es CERRAR SESIONES y no «quitar», porque aquí «quitar» ya es `v` —revocar, como en
1108
+ * Dispositivos— y una tecla significa lo mismo en todas las pantallas.
1109
+ */
1110
+ async function onKeyLogins (term, st, key, api = vc) {
1111
+ const i = L(st)
1112
+ const list = st.logins || []
1113
+ moveSel(st, key, 'logins', list.length)
1114
+ const cur = list[Math.min(st.sel.logins || 0, Math.max(0, list.length - 1))]
1115
+ const ch = key.name === 'char' ? key.ch.toLowerCase() : null
1116
+
1117
+ if (ch === 'n') {
1118
+ // Tres preguntas encadenadas: usuario → de dónde entra → contraseña (dos veces). Van
1119
+ // en cadena y no en un formulario porque la TUI pregunta de a una, como en el resto.
1120
+ setInput(st, {
1121
+ label: i.loginUserLabel,
1122
+ hint: i.loginUserHint,
1123
+ onSubmit: async (raw) => {
1124
+ const user = String(raw || '').trim().toLowerCase()
1125
+ if (!user) return
1126
+ setInput(st, {
1127
+ label: i.loginLabelLabel(user),
1128
+ hint: i.loginLabelHint,
1129
+ value: 'equipo prestado',
1130
+ onSubmit: async (rawLabel) => {
1131
+ const label = String(rawLabel || '').trim() || 'equipo prestado'
1132
+ setInput(st, {
1133
+ label: i.loginPassLabel(user),
1134
+ hint: i.loginPassHint,
1135
+ mask: true,
1136
+ onSubmit: async (pw1) => {
1137
+ if (String(pw1 || '').length < MIN_LOGIN_PASSWORD) { flash(st, i.loginPassShort, 'warn'); return }
1138
+ setInput(st, {
1139
+ label: i.loginPassAgain,
1140
+ hint: i.loginPassHint,
1141
+ mask: true,
1142
+ onSubmit: async (pw2) => {
1143
+ if (pw1 !== pw2) { flash(st, i.loginPassMismatch, 'warn'); return }
1144
+ const r = await guard(term, st, i.loginCreating,
1145
+ () => api.addLogin({ user, password: pw1, label }))
1146
+ if (r.ok) {
1147
+ await refreshLogins(term, st, api)
1148
+ const nuevo = (st.logins || []).find((l) => l.user === user)
1149
+ flash(st, i.loginCreatedOk(nuevo?.address || user))
1150
+ }
1151
+ }
1152
+ })
1153
+ }
1154
+ })
1155
+ }
1156
+ })
1157
+ }
1158
+ })
1159
+ } else if (ch === 'c' && cur) {
1160
+ setInput(st, {
1161
+ label: i.loginOldPass(cur.user),
1162
+ hint: i.loginPassHint,
1163
+ mask: true,
1164
+ onSubmit: async (vieja) => {
1165
+ setInput(st, {
1166
+ label: i.loginNewPass,
1167
+ hint: i.loginPassHint,
1168
+ mask: true,
1169
+ onSubmit: async (nueva) => {
1170
+ if (String(nueva || '').length < MIN_LOGIN_PASSWORD) { flash(st, i.loginPassShort, 'warn'); return }
1171
+ const r = await guard(term, st, i.loginChanging,
1172
+ () => api.passwdLogin({ user: cur.user, oldPassword: vieja, newPassword: nueva }))
1173
+ if (r.ok) { flash(st, i.loginChanged); await refreshLogins(term, st, api) }
1174
+ }
1175
+ })
1176
+ }
1177
+ })
1178
+ } else if (ch === 'x' && cur) {
1179
+ if (!(cur.sessions || []).length) { flash(st, i.loginNoSessions, 'warn'); return true }
1180
+ const r = await guard(term, st, i.loginClosing, () => api.closeLoginSession(cur.user))
1181
+ if (r.ok) { flash(st, i.loginClosed(r.v?.closed ?? (cur.sessions || []).length)); await refreshLogins(term, st, api) }
1182
+ } else if (ch === 'u' && cur) {
1183
+ if (!(cur.blockedUntil > Date.now())) { flash(st, i.loginNotBlocked, 'warn'); return true }
1184
+ const r = await guard(term, st, i.loginUnblocking, () => api.unblockLogin(cur.user))
1185
+ if (r.ok) { flash(st, i.loginUnblocked); await refreshLogins(term, st, api) }
1186
+ } else if ((ch === 'v' || key.name === 'delete') && cur) {
1187
+ setConfirm(st, {
1188
+ text: i.loginRemoveConfirm(cur.user),
1189
+ onYes: async () => {
1190
+ st.confirm = null
1191
+ const r = await guard(term, st, i.loginRemoving, () => api.removeLogin(cur.user))
1192
+ if (r.ok) { flash(st, i.loginRemoved(cur.user)); st.sel.logins = 0; await refreshLogins(term, st, api) }
1193
+ },
1194
+ onNo: () => { st.confirm = null }
1195
+ })
1196
+ } else if (key.name === 'f5') {
1197
+ await refreshLogins(term, st, api)
1198
+ }
1199
+ return true
1200
+ }
1201
+
1050
1202
  /**
1051
1203
  * Tira la cuenta que NACIÓ para un emparejamiento que no llegó a término y vuelve a la que
1052
1204
  * estabas usando. Es una cuenta recién creada y vacía —el aparato nunca entró—, así que no
@@ -1794,8 +1946,8 @@ async function onConfirmKey (st, key) {
1794
1946
 
1795
1947
  // Pestañas INTERNAS de una bóveda ya elegida: se cambian con ←→. La lista de
1796
1948
  // bóvedas (profiles) es el nivel de arriba (se entra con Enter, no es una pestaña).
1797
- const INNER_TABS = ['devices', 'secrets', 'me']
1798
- const tabLabel = (i, k) => ({ devices: i.tabDevices, secrets: i.tabSecrets, me: i.tabMe })[k]
1949
+ const INNER_TABS = ['devices', 'secrets', 'logins', 'me']
1950
+ const tabLabel = (i, k) => ({ devices: i.tabDevices, secrets: i.tabSecrets, logins: i.tabLogins, me: i.tabMe })[k]
1799
1951
 
1800
1952
  /**
1801
1953
  * Las teclas que se pueden usar AHORA, no el catálogo entero. Aprobar/rechazar sin nadie
@@ -1807,6 +1959,7 @@ const helpSegs = (i, screen, st = {}) => {
1807
1959
  profiles: i.helpProfiles,
1808
1960
  devices: i.helpDevices,
1809
1961
  secrets: i.helpSecrets,
1962
+ logins: i.helpLogins,
1810
1963
  pairing: i.helpPairing,
1811
1964
  pairmode: i.helpPairMode,
1812
1965
  join: i.helpJoin,
@@ -1819,12 +1972,18 @@ const helpSegs = (i, screen, st = {}) => {
1819
1972
  // el único que las lee), y las filas seleccionables de la lista son justo los aparatos.
1820
1973
  const devs = mergeMembersAndCerts(st.members, st.devices?.issued || [])
1821
1974
  const cur = devs[Math.min(st.sel?.devices || 0, devs.length - 1)]
1975
+ // El inicio de sesión señalado: `x cerrar` solo tiene sentido si tiene alguna abierta, y
1976
+ // `u quitar espera` solo si está esperando.
1977
+ const lg = (st.logins || [])[Math.min(st.sel?.logins || 0, Math.max(0, (st.logins || []).length - 1))]
1822
1978
  return segs({
1823
1979
  pending: !!st.pending,
1824
1980
  hasDevices: (st.devices?.issued || []).length > 0,
1825
1981
  isService: !!cur?.cn,
1826
1982
  hasSecrets: Object.keys(st.secrets?.ns || {}).length > 0,
1827
- hasVars: devVarsOf(st, st.varsFor?.pub).length > 0
1983
+ hasVars: devVarsOf(st, st.varsFor?.pub).length > 0,
1984
+ hasLogins: (st.logins || []).length > 0,
1985
+ hasSessions: ((lg?.sessions) || []).length > 0,
1986
+ blocked: (lg?.blockedUntil || 0) > Date.now()
1828
1987
  })
1829
1988
  }
1830
1989
 
@@ -1843,7 +2002,9 @@ function renderTabs (st, t) {
1843
2002
  return INNER_TABS.map((k) => {
1844
2003
  const active = st.screen === k
1845
2004
  return active ? t.bold(t.accent('▐ ' + tabLabel(i, k) + ' ▌')) : t.muted(' ' + tabLabel(i, k) + ' ')
1846
- }).join(' ') + t.muted(i.tabsHint)
2005
+ // UN espacio entre pestañas, no tres: con cuatro ya no cabía el «(←→ cambiar)» en 80
2006
+ // columnas, y lo que se perdía era justamente la pista de que se puede cambiar.
2007
+ }).join(' ') + t.muted(i.tabsHint)
1847
2008
  }
1848
2009
 
1849
2010
  function pairingBody (st, t, cols, height) {
@@ -1943,6 +2104,7 @@ function render (term, st) {
1943
2104
  if (st.screen === 'profiles') body = renderList(profileRows(st, t), st.sel.profiles, contentH, cols, t, scrollRef)
1944
2105
  else if (st.screen === 'devices') body = renderList(deviceRows(st, t), st.sel.devices, contentH, cols, t, scrollRef)
1945
2106
  else if (st.screen === 'secrets') body = renderList(secretRows(st, t), st.sel.secrets, contentH, cols, t, scrollRef)
2107
+ else if (st.screen === 'logins') body = renderList(loginRows(st, t), st.sel.logins || 0, contentH, cols, t, scrollRef)
1946
2108
  else if (st.screen === 'me') body = renderList(meRows(st, t), -1, contentH, cols, t, scrollRef)
1947
2109
  else if (st.screen === 'caps') body = renderList(capsRows(st, t), st.sel.caps || 0, contentH, cols, t, scrollRef)
1948
2110
  else if (st.screen === 'devvars') body = renderList(devVarRows(st, t), st.sel.devvars || 0, contentH, cols, t, scrollRef)
@@ -2029,7 +2191,7 @@ export async function runTui () {
2029
2191
  const st = {
2030
2192
  screen: 'profiles', // se arranca en la lista de bóvedas: hay que ENTRAR a una
2031
2193
  lang: loadLang(), // es/en — se conmuta con `l` y se recuerda en prefs.json
2032
- sel: { profiles: 0, devices: 0, secrets: 0, pairmode: 0, devvars: 0 },
2194
+ sel: { profiles: 0, devices: 0, secrets: 0, pairmode: 0, devvars: 0, logins: 0 },
2033
2195
  // Las bóvedas que ha abierto ESTA sesión, para volver a cerrarlas al salir.
2034
2196
  unlockedHere: new Set(),
2035
2197
  // Su contraseña, SOLO en memoria y SOLO mientras la TUI esté abierta: sirve para
@@ -2040,6 +2202,8 @@ export async function runTui () {
2040
2202
  profiles: null,
2041
2203
  devices: null,
2042
2204
  secrets: null,
2205
+ // Los inicios de sesión se piden al ENTRAR en su pestaña, no al arrancar.
2206
+ logins: null,
2043
2207
  pending: null,
2044
2208
  pairing: null,
2045
2209
  state: null,
@@ -2107,6 +2271,7 @@ export async function runTui () {
2107
2271
  // El perfil se pide al ENTRAR en su pestaña, no al arrancar: es contenido del
2108
2272
  // usuario y no hay por qué sacarlo del cifrado si nadie lo está mirando.
2109
2273
  if (st.screen === 'me' && st.me === undefined) await refreshMe(term, st)
2274
+ if (st.screen === 'logins' && st.logins === null) await refreshLogins(term, st)
2110
2275
  continue
2111
2276
  }
2112
2277
  // Esc/'b' desde una pestaña vuelve a la lista de bóvedas (salir de la bóveda
@@ -2118,6 +2283,7 @@ export async function runTui () {
2118
2283
  if (st.screen === 'profiles') running = await onKeyProfiles(term, st, key)
2119
2284
  else if (st.screen === 'devices') running = await onKeyDevices(term, st, key)
2120
2285
  else if (st.screen === 'secrets') running = await onKeySecrets(term, st, key)
2286
+ else if (st.screen === 'logins') running = await onKeyLogins(term, st, key)
2121
2287
  else if (st.screen === 'me') running = await onKeyMe(term, st, key)
2122
2288
  else if (st.screen === 'caps') running = await onKeyCaps(term, st, key)
2123
2289
  else if (st.screen === 'devvars') running = await onKeyDevVars(term, st, key)
@@ -2138,4 +2304,4 @@ export async function runTui () {
2138
2304
  }
2139
2305
 
2140
2306
  // Solo para pruebas headless (render sin terminal real). No usar en runtime.
2141
- export const __test = { render, onKeySecrets, activeLocked, autoLockedIds, autoLockWakeIn, forgetAutoLocked, autoLockMin, refreshAll, ensureUnlocked, profileRows, deviceRows, secretRows, devVarRows, meRows, capsRows, onKeyCaps, pairModeRows, pairingBody, scrollBody, fitHelp, wrapHelp, wrapWords, joinBody, onKeyJoin, promptJoin, onKeyProfiles, onInputKey, toggleLang, mergeMembersAndCerts, seguirAqui, resetToque: () => { ultimoToque = 0 } }
2307
+ export const __test = { render, onKeySecrets, loginRows, onKeyLogins, refreshLogins, activeLocked, autoLockedIds, autoLockWakeIn, forgetAutoLocked, autoLockMin, refreshAll, ensureUnlocked, profileRows, deviceRows, secretRows, devVarRows, meRows, capsRows, onKeyCaps, pairModeRows, pairingBody, scrollBody, fitHelp, wrapHelp, wrapWords, joinBody, onKeyJoin, promptJoin, onKeyProfiles, onInputKey, toggleLang, mergeMembersAndCerts, seguirAqui, resetToque: () => { ultimoToque = 0 } }
package/src/tui/i18n.js CHANGED
@@ -33,6 +33,44 @@ const es = {
33
33
  tooSmallHint: (cols, rows) => `Agranda a ≥ 24×9 (hay ${cols}×${rows}).`,
34
34
 
35
35
  // pestañas y títulos
36
+ // ----- ENTRAR CON USUARIO Y CONTRASEÑA (`docs/temporary-access.md`) -----
37
+ tabLogins: 'Entrar',
38
+ noLogins: 'Todavía no hay ninguno. Con «n» creas uno.',
39
+ loginsHint: 'Un aparato de esta cuenta que se abre escribiendo una dirección y una contraseña, sin conectar nada. Sirve para entrar desde un equipo prestado.',
40
+ loginOpen: (n) => `${n} abierto${n === 1 ? '' : 's'}`,
41
+ loginWait: (s) => `espera ${s} s`,
42
+ loginCreated: (d) => `creado ${d}`,
43
+ helpLogins: ({ hasLogins, hasSessions, blocked } = {}) => [
44
+ '←→ pestaña', '↑↓', 'n nuevo',
45
+ ...(hasSessions ? ['x cerrar sesiones'] : []),
46
+ ...(blocked ? ['u quitar espera'] : []),
47
+ ...(hasLogins ? ['c contraseña', 'v quitar'] : []),
48
+ 'F5 refrescar', 'Esc bóvedas', 'l English', 'q salir'
49
+ ],
50
+ loginUserLabel: '¿Qué nombre de usuario?',
51
+ loginUserHint: 'letras y números, en minúscula (Esc cancela)',
52
+ loginLabelLabel: (u) => `¿De dónde va a entrar «${u}»?`,
53
+ loginLabelHint: 'lo que vas a leer aquí para decidir si cerrarlo (Esc cancela)',
54
+ loginPassLabel: (u) => `Contraseña para «${u}»`,
55
+ loginPassAgain: 'Escríbela otra vez',
56
+ loginPassHint: 'mínimo 12 caracteres; no se puede recuperar (Esc cancela)',
57
+ loginPassShort: 'La contraseña tiene que tener al menos 12 caracteres.',
58
+ loginPassMismatch: 'No coinciden. Inténtalo otra vez.',
59
+ loginOldPass: (u) => `Contraseña actual de «${u}»`,
60
+ loginNewPass: 'Contraseña nueva',
61
+ loginCreating: 'Creando el inicio de sesión…',
62
+ loginCreatedOk: (a) => `Listo. La dirección es ${a}`,
63
+ loginChanging: 'Cambiando la contraseña…',
64
+ loginChanged: 'Contraseña cambiada.',
65
+ loginClosing: 'Cerrando las sesiones…',
66
+ loginClosed: (n) => `${n} sesión(es) cerrada(s).`,
67
+ loginUnblocking: 'Quitando la espera…',
68
+ loginUnblocked: 'Ya puede volver a intentarlo.',
69
+ loginRemoving: 'Quitándolo…',
70
+ loginRemoved: (u) => `«${u}» se fue, y su llave salió del acta.`,
71
+ loginRemoveConfirm: (u) => `¿Quitar «${u}»? Su llave sale del acta y ese equipo deja de entrar.`,
72
+ loginNoSessions: 'Ese no tiene ninguna sesión abierta.',
73
+ loginNotBlocked: 'Ese no está esperando.',
36
74
  tabDevices: 'Dispositivos',
37
75
  tabSecrets: 'Scopes y variables',
38
76
  tabMe: 'Perfil',
@@ -403,6 +441,43 @@ const en = {
403
441
  tooSmall: 'Terminal too small',
404
442
  tooSmallHint: (cols, rows) => `Resize to ≥ 24×9 (now ${cols}×${rows}).`,
405
443
 
444
+ tabLogins: 'Sign in',
445
+ noLogins: 'None yet. Press “n” to create one.',
446
+ loginsHint: 'A device of this account that opens by typing an address and a password, with nothing to plug in. It is how you sign in from a borrowed computer.',
447
+ loginOpen: (n) => `${n} open`,
448
+ loginWait: (s) => `wait ${s} s`,
449
+ loginCreated: (d) => `created ${d}`,
450
+ helpLogins: ({ hasLogins, hasSessions, blocked } = {}) => [
451
+ '←→ tab', '↑↓', 'n new',
452
+ ...(hasSessions ? ['x close sessions'] : []),
453
+ ...(blocked ? ['u clear wait'] : []),
454
+ ...(hasLogins ? ['c password', 'v remove'] : []),
455
+ 'F5 refresh', 'Esc vaults', 'l español', 'q quit'
456
+ ],
457
+ loginUserLabel: 'Which username?',
458
+ loginUserHint: 'lowercase letters and numbers (Esc cancels)',
459
+ loginLabelLabel: (u) => `Where will “${u}” sign in from?`,
460
+ loginLabelHint: 'what you will read here to decide whether to close it (Esc cancels)',
461
+ loginPassLabel: (u) => `Password for “${u}”`,
462
+ loginPassAgain: 'Type it again',
463
+ loginPassHint: 'at least 12 characters; it cannot be recovered (Esc cancels)',
464
+ loginPassShort: 'The password must be at least 12 characters.',
465
+ loginPassMismatch: 'They do not match. Try again.',
466
+ loginOldPass: (u) => `Current password for “${u}”`,
467
+ loginNewPass: 'New password',
468
+ loginCreating: 'Creating the sign-in…',
469
+ loginCreatedOk: (a) => `Done. The address is ${a}`,
470
+ loginChanging: 'Changing the password…',
471
+ loginChanged: 'Password changed.',
472
+ loginClosing: 'Closing the sessions…',
473
+ loginClosed: (n) => `${n} session(s) closed.`,
474
+ loginUnblocking: 'Clearing the wait…',
475
+ loginUnblocked: 'It can try again now.',
476
+ loginRemoving: 'Removing it…',
477
+ loginRemoved: (u) => `“${u}” is gone, and its key left the account record.`,
478
+ loginRemoveConfirm: (u) => `Remove “${u}”? Its key leaves the account record and that computer stops getting in.`,
479
+ loginNoSessions: 'That one has no open sessions.',
480
+ loginNotBlocked: 'That one is not waiting.',
406
481
  tabDevices: 'Devices',
407
482
  tabSecrets: 'Scopes & variables',
408
483
  tabMe: 'Profile',
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,
@@ -49,7 +49,11 @@ const F = {
49
49
  secretReq: 'secret-request.json',
50
50
  profileReq: 'profile-request.json',
51
51
  meReq: 'me-request.json',
52
- dumpReq: 'dump-request.json'
52
+ dumpReq: 'dump-request.json',
53
+ // ENTRAR CON USUARIO Y CONTRASEÑA. La respuesta NO se llama `logins.json`: ese nombre ya
54
+ // es el almacén del escritorio dentro del dir del perfil, y pisarlo los borraría.
55
+ loginsReq: 'logins-request.json',
56
+ loginsList: 'logins-list.json'
53
57
  }
54
58
 
55
59
  const p = (name) => path.join(dir, name)
@@ -242,6 +246,96 @@ export const setProfilePassword = (profile, password, current) => profileOp('pas
242
246
  // Quitarla también la pide: se re-sella a la llave de la máquina antes de que se vaya.
243
247
  export const removeProfilePassword = (profile, password) => profileOp('password-rm', { profile, password })
244
248
 
249
+ // ---------------------------------------------------------------------------
250
+ // Inicios de sesión con contraseña (`docs/temporary-access.md`)
251
+ // ---------------------------------------------------------------------------
252
+
253
+ /**
254
+ * Un viaje al mostrador de inicios de sesión del daemon. Es el MISMO canal que usa el CLI
255
+ * (`dotrino-vault logins`) —una pieza, no dos— y por eso la pantalla de la TUI y el
256
+ * comando no se pueden desincronizar.
257
+ *
258
+ * La respuesta se BORRA al leerla: puede traer el paquete de llaves de un aparato.
259
+ */
260
+ export async function loginsOp (op, extra = {}) {
261
+ requireAlive()
262
+ rm(F.loginsList)
263
+ const since = Date.now()
264
+ const id = await writeReq(F.loginsReq, { op, ...extra })
265
+ signalOrCleanup('SIGUSR2', [F.loginsReq])
266
+ const d = await waitFor(F.loginsList, { req: id, since })
267
+ rm(F.loginsList)
268
+ if (!d) throw coded('the daemon did not reply', 'NO_REPLY')
269
+ if (d.ok === false) {
270
+ // `too-many-tries` trae cuánto falta, y `PROFILE_LOCKED` se arregla abriendo la bóveda:
271
+ // los dos cambian lo que hay que hacer, así que viajan con su dato.
272
+ throw Object.assign(coded(d.error || 'logins: the vault refused', d.code), { waitMs: d.waitMs })
273
+ }
274
+ return d
275
+ }
276
+
277
+ /** Los inicios de sesión de este perfil, con la dirección ya montada. */
278
+ export async function listLogins (profile) {
279
+ const { logins = [], fingerprint } = await loginsOp('list', profile ? { profile } : {})
280
+ const { loginAddress } = await import('../lib/src/passwordLogins.js')
281
+ return logins.map((l) => ({ ...l, address: fingerprint ? loginAddress(l.user, fingerprint) : '' }))
282
+ }
283
+
284
+ /**
285
+ * ALTA. Las llaves del aparato NACEN aquí, en esta terminal, y salen ya cerradas con lo
286
+ * que deriva de la contraseña: lo que la bóveda guarda es un paquete que no puede abrir.
287
+ *
288
+ * Se pueden tener VARIOS por perfil, cada uno con sus permisos: son miembros del acta como
289
+ * cualquier otro aparato.
290
+ */
291
+ export async function addLogin ({ user, password, label = 'equipo prestado', caps = ['sign', 'read', 'store'] } = {}) {
292
+ const { client: opaque } = await import('@dotrino/opaque')
293
+ const { makeDeviceKey, makeDeviceEncKey } = await import('@dotrino/identity/capabilities')
294
+ const { capScope } = await import('@dotrino/identity/acta')
295
+ const { sealDeviceKeys } = await import('../lib/src/passwordLogins.js')
296
+ const start = opaque.registrationStart({ password })
297
+ const begun = await loginsOp('register-begin', { user, request: start.request })
298
+ const fin = opaque.registrationFinish({ state: start.state, response: begun.response, password })
299
+ const device = await makeDeviceKey({ label })
300
+ const enc = await makeDeviceEncKey()
301
+ const blob = await sealDeviceKeys(fin.exportKey, { sign: device.privateJwk, enc: enc.privateJwk })
302
+ // El acta habla de PERMISOS y el papel de SCOPES: los traduce el pilar, no esta pantalla.
303
+ // `unattended` no es un scope —es «no me pidas aprobación»— y por eso viaja aparte.
304
+ return loginsOp('register-finish', {
305
+ user, upload: fin.upload, pub: device.publickey, encPub: enc.publickey, label, blob,
306
+ scope: caps.filter((c) => c !== 'unattended').map((c) => capScope(c)).filter(Boolean),
307
+ unattended: caps.includes('unattended')
308
+ })
309
+ }
310
+
311
+ /**
312
+ * Cambiar la contraseña es ABRIR y volver a cerrar, así que hace falta la vieja: el
313
+ * paquete de llaves está cerrado con lo que salía de ella, y sin abrirlo no hay qué
314
+ * volver a cerrar. Perderla no se arregla desde aquí — se quita el aparato y se crea otro.
315
+ */
316
+ export async function passwdLogin ({ user, oldPassword, newPassword } = {}) {
317
+ const { client: opaque } = await import('@dotrino/opaque')
318
+ const { sealDeviceKeys, openDeviceKeys } = await import('../lib/src/passwordLogins.js')
319
+ const start = opaque.loginStart({ password: oldPassword })
320
+ const begun = await loginsOp('login-begin', { user, request: start.request })
321
+ let fin
322
+ try { fin = opaque.loginFinish({ state: start.state, response: begun.response, password: oldPassword }) }
323
+ catch (_) { throw coded('wrong password', 'login-failed') }
324
+ const entrada = await loginsOp('login-end', { lid: begun.lid, finalization: fin.finalization, label: 'la consola' })
325
+ const keys = await openDeviceKeys(fin.exportKey, entrada.blob)
326
+ const reg = opaque.registrationStart({ password: newPassword })
327
+ const { response } = await loginsOp('register-begin', { user, request: reg.request, replace: true })
328
+ const nueva = opaque.registrationFinish({ state: reg.state, response, password: newPassword })
329
+ return loginsOp('register-finish', {
330
+ user, upload: nueva.upload, blob: await sealDeviceKeys(nueva.exportKey, keys), replace: true
331
+ })
332
+ }
333
+
334
+ export const closeLoginSession = (user, sid = null) => loginsOp('close', { user, ...(sid ? { sid } : {}) })
335
+ export const unblockLogin = (user) => loginsOp('unblock', { user })
336
+ /** Quitarlo lo saca del mostrador **y su llave del acta**: las dos cosas, o ninguna. */
337
+ export const removeLogin = (user) => loginsOp('rm', { user })
338
+
245
339
  // ---------------------------------------------------------------------------
246
340
  // Volcado de dispositivos + secretos de un perfil
247
341
  // ---------------------------------------------------------------------------