@dotrino/vaultd 0.122.0 → 0.123.1

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/src/replica.js CHANGED
@@ -66,9 +66,44 @@ export function mergeBundle (viejo, nuevo) {
66
66
  if (gb > ga) entries[k] = e
67
67
  else if (gb === ga && String(e.e?.ct ?? e.pubv ?? '') < String(a.e?.ct ?? a.pubv ?? '')) entries[k] = e
68
68
  }
69
- const porGen = new Map()
70
- for (const w of [...(viejo.wraps || []), ...(nuevo.wraps || [])]) porGen.set(w.gen, w)
71
- return { ...viejo, ...nuevo, entries, wraps: [...porGen.values()].sort((x, y) => x.gen - y.gen) }
69
+ const wraps = mergeWraps(viejo.wraps, nuevo.wraps)
70
+ // `ns`/`dev` son la envoltura VIGENTE de cada lado, y salen de la lista ya mezclada: con
71
+ // `{...viejo, ...nuevo}` se quedaba la del empujón aunque su generación fuera menor, o
72
+ // sea justo lo contrario de lo que decide la lista.
73
+ return {
74
+ ...viejo,
75
+ ...nuevo,
76
+ entries,
77
+ wraps,
78
+ ns: wraps.ns[wraps.ns.length - 1] || null,
79
+ dev: wraps.dev[wraps.dev.length - 1] || null
80
+ }
81
+ }
82
+
83
+ /**
84
+ * `wraps` es `{ ns: [...], dev: [...] }`, NO una lista.
85
+ *
86
+ * Aquí se esparcía como si fuera una lista, así que cada empujón sobre un sobre que ya
87
+ * existía moría con «(viejo.wraps || []) is not iterable» — y solo en producción: el
88
+ * primer guardado sale por `if (!viejo) return nuevo` y nunca llega aquí. El replicador de
89
+ * Cepi lleva así desde que se desplegó, guardando el primer sobre de cada cajón y
90
+ * rechazando en silencio todas las actualizaciones.
91
+ *
92
+ * La forma la fija quien lo produce (`secretsStore.js`, `sealedBundleFor`) y quien lo lee
93
+ * (`lib/src/service.js`, que recorre `wraps.ns` y `wraps.dev`). Los dos estaban de acuerdo;
94
+ * el único equivocado era esto. Y de haber funcionado habría sido peor: escribía `wraps`
95
+ * como lista y el servicio se habría quedado sin envolturas, que es no poder abrir nada.
96
+ *
97
+ * Las de cada lado se SUMAN por generación: son de aparatos distintos y ninguna estorba.
98
+ */
99
+ function mergeWraps (a, b) {
100
+ const out = {}
101
+ for (const side of ['ns', 'dev']) {
102
+ const byGen = new Map()
103
+ for (const w of [...(a?.[side] || []), ...(b?.[side] || [])]) byGen.set(w.gen, w)
104
+ out[side] = [...byGen.values()].sort((x, y) => x.gen - y.gen)
105
+ }
106
+ return out
72
107
  }
73
108
 
74
109
  /** La clave de un sobre: es POR (cajón, aparato), porque va tallado a quien lo pide. */
@@ -1145,6 +1145,20 @@ export function openSecretsStore (dir, { sealer = null, recipients = null, signe
1145
1145
  * decidir si hay que migrarla. Preguntarlo intentando reenvolver un cajón mezcla dos
1146
1146
  * cosas y deja el fallo diciendo «wrong password» sobre el cajón, que no es donde está.
1147
1147
  */
1148
+ /**
1149
+ * ABRE UNA ENVOLTURA DE RECUPERACIÓN y devuelve lo que lleva dentro.
1150
+ *
1151
+ * Es para OTROS almacenes de este mismo perfil —hoy la bóveda de contraseñas—, que
1152
+ * comparten ESTA copia de recuperación en vez de estrenar una segunda. Una segunda
1153
+ * sería otra llave que custodiar, otra que rotar y otra que se queda atrás.
1154
+ *
1155
+ * Sigue siendo la única puerta a un valor en claro, y sigue pidiendo la frase.
1156
+ */
1157
+ async openRecoveryWrap (wrap, adminKey = null) {
1158
+ const priv = await openRecovery(adminKey)
1159
+ return sealer.openWrapWith(priv, wrap)
1160
+ },
1161
+
1148
1162
  async recoveryOpensWith (key) {
1149
1163
  needSealer('check the recovery key')
1150
1164
  if (!data.recovery?.priv) throw new NeedsPassword('this store has no recovery key yet')
package/src/tui/app.js CHANGED
@@ -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
@@ -403,7 +445,7 @@ function pairModeRows (st, t) {
403
445
  * esté nombrado se añade al final: un permiso puede salir en mal sitio, nunca desaparecer.
404
446
  * `test/tui-permisos.test.mjs` exige además que todos tengan nombre en los dos idiomas.
405
447
  */
406
- const ORDEN = ['sign', 'store', 'read', 'admin', 'approve', 'passwords', 'sealer', 'unattended', 'replica']
448
+ const ORDEN = ['sign', 'store', 'read', 'admin', 'approve', 'passwords', 'passkeys', 'sealer', 'unattended', 'replica']
407
449
  const CAPS_ORDER = [...ORDEN.filter((c) => DEVICE_CAPS.includes(c)),
408
450
  ...DEVICE_CAPS.filter((c) => !ORDEN.includes(c))]
409
451
 
@@ -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',
@@ -60,6 +98,7 @@ const es = {
60
98
  read: 'lee',
61
99
  admin: 'administra',
62
100
  passwords: 'contrasenas',
101
+ passkeys: 'passkeys',
63
102
  approve: 'aprueba',
64
103
  sealer: 'sella',
65
104
  unattended: 'desatendido',
@@ -75,6 +114,9 @@ const es = {
75
114
  read: 'ver lo que guardaste',
76
115
  admin: 'conectar y quitar aparatos desde ahí, sin venir aquí. No puede cambiar permisos',
77
116
  passwords: 'pedir una contraseña por sitio, nunca la lista entera',
117
+ // Una passkey copiada sirve hasta que la borres en cada sitio: de una contraseña, al
118
+ // menos, se puede cambiar. Por eso es una decisión aparte.
119
+ passkeys: 'abrir además las passkeys guardadas, que son llaves y no se pueden cambiar. Sin «contrasenas» no hace nada',
78
120
  approve: 'dar el visto bueno cuando otro aparato pide claves privadas',
79
121
  // Sellar solo significa algo en un aparato que HAGA de bóveda (otra máquina con el
80
122
  // daemon, o un navegador que se puso de bóveda). En un teléfono normal se concede y no
@@ -399,6 +441,43 @@ const en = {
399
441
  tooSmall: 'Terminal too small',
400
442
  tooSmallHint: (cols, rows) => `Resize to ≥ 24×9 (now ${cols}×${rows}).`,
401
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.',
402
481
  tabDevices: 'Devices',
403
482
  tabSecrets: 'Scopes & variables',
404
483
  tabMe: 'Profile',
@@ -419,6 +498,7 @@ const en = {
419
498
  read: 'read',
420
499
  admin: 'admin',
421
500
  passwords: 'passwords',
501
+ passkeys: 'passkeys',
422
502
  approve: 'approve',
423
503
  sealer: 'sealer',
424
504
  unattended: 'unattended',
@@ -432,6 +512,7 @@ const en = {
432
512
  read: 'see what you saved',
433
513
  admin: 'connect and remove devices from there, without coming here. It cannot change permissions',
434
514
  passwords: 'ask for one password per site, never the whole list',
515
+ passkeys: 'also open saved passkeys, which are keys and cannot be changed. Without «passwords» it does nothing',
435
516
  approve: 'give the go-ahead when another device asks for private keys',
436
517
  sealer: 'admit devices and change permissions on its own when this vault is away. Only useful on another VAULT',
437
518
  unattended: 'take private keys without asking you. Off, it must be approved from a device with «approve» every time it starts',
package/src/vault.js CHANGED
@@ -234,6 +234,25 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
234
234
  /** Los aparatos que el acta autoriza a pedir credenciales (`caps <ID> +contraseñas`). */
235
235
  const passwordDevices = () => (actaCache?.members || []).filter((m) => Acta.memberCan(actaCache, m.pub, 'passwords'))
236
236
 
237
+ /**
238
+ * A QUIÉN SE LE ENVUELVE LA LLAVE DE UNA CONTRASEÑA — Y LA BÓVEDA NUNCA ESTÁ EN LA LISTA.
239
+ *
240
+ * Es la comprobación que sostiene todo el modelo sellado: si la maestra o la llave de
241
+ * comunicación de esta máquina recibieran envoltura, el demonio volvería a poder leer las
242
+ * contraseñas y no habríamos arreglado nada —solo lo habríamos escrito más bonito—. Por
243
+ * eso se filtran explícitamente aquí, que es donde se construye la lista, y no en quien la
244
+ * consume (`sealed-passwords.md` §2.1).
245
+ *
246
+ * `main` son los aparatos con `contraseñas`; `passkeys`, los que además pueden abrir la
247
+ * privada de una passkey (§2.8). Un miembro sin llave de cifrado no puede recibir nada: se
248
+ * queda fuera y se ve en `incompleteMembers`.
249
+ */
250
+ const noEsLaBoveda = (pub) => pub !== master && pub !== commKey.pub()
251
+ const passwordRecipients = (kind = 'main') => (actaCache?.members || [])
252
+ .filter((m) => m.encPub && noEsLaBoveda(m.pub) && Acta.memberCan(actaCache, m.pub, 'passwords'))
253
+ .filter((m) => kind !== 'passkeys' || Acta.memberCan(actaCache, m.pub, 'passkeys'))
254
+ .map((m) => ({ pub: m.pub, encPub: m.encPub }))
255
+
237
256
  /**
238
257
  * El acta vigente, en caché.
239
258
  *
@@ -249,20 +268,27 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
249
268
  }
250
269
 
251
270
  /**
252
- * La llave de la bóveda de contraseñas. Nace con el primer uso y vive cifrada en
253
- * reposo, como la identidad: aquí no hace falta envolverla a cada aparato porque
254
- * ningún aparato abre la bóveda — piden de a una y el vault responde.
271
+ * LA LLAVE VIEJA DE LA BÓVEDA DE CONTRASEÑAS, la que había que quitar.
272
+ *
273
+ * Vivía DENTRO del mismo archivo que las entradas, cifrada con la llave de la máquina y
274
+ * no con la contraseña del perfil. O sea: el demonio con el perfil cerrado seguía
275
+ * descifrando, y una copia del disco las abría todas. Esto ya no la crea — solo la LEE,
276
+ * y solo para convertir lo que quedó escrito con ella; en cuanto la conversión termina,
277
+ * `soltarLlaveVieja` la borra del archivo.
255
278
  */
256
- async function passwordsKey () {
279
+ async function llaveViejaDeContrasenas () {
257
280
  const d = readPasswordsFile()
258
- if (d?.cek) {
259
- const raw = Uint8Array.from(Buffer.from(d.cek, 'base64'))
260
- return crypto.subtle.importKey('raw', raw, { name: 'AES-GCM' }, true, ['encrypt', 'decrypt'])
261
- }
262
- const key = await crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt'])
263
- const raw = new Uint8Array(await crypto.subtle.exportKey('raw', key))
264
- writePasswordsFile({ ...(d || { v: 1, data: {} }), cek: Buffer.from(raw).toString('base64') })
265
- return key
281
+ if (!d?.cek) return null
282
+ const raw = Uint8Array.from(Buffer.from(d.cek, 'base64'))
283
+ return crypto.subtle.importKey('raw', raw, { name: 'AES-GCM' }, true, ['encrypt', 'decrypt'])
284
+ }
285
+ const soltarLlaveVieja = () => {
286
+ const d = readPasswordsFile()
287
+ if (!d?.cek) return
288
+ delete d.cek
289
+ writePasswordsFile(d)
290
+ log('[vault] passwords: the old vault key is gone from the disk')
291
+ audit('passwords.converted', {})
266
292
  }
267
293
 
268
294
  const approvalsSweeper = setInterval(() => {
@@ -1121,7 +1147,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
1121
1147
 
1122
1148
  async function handleLoginStart (from, p) {
1123
1149
  try {
1124
- const { lid, response } = logins.loginBegin({ user: p?.user, request: p?.request })
1150
+ const { lid, response } = await logins.loginBegin({ user: p?.user, request: p?.request })
1125
1151
  audit('login.start', { user: String(p?.user || '').slice(0, 32) })
1126
1152
  reply(from, { type: MSG.LOGIN_RESPONSE, lid, response })
1127
1153
  } catch (e) { loginError(from, e) }
@@ -1129,7 +1155,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
1129
1155
 
1130
1156
  async function handleLoginFinish (from, p) {
1131
1157
  try {
1132
- const r = logins.loginEnd({ lid: p?.lid, finalization: p?.finalization, label: p?.label })
1158
+ const r = await logins.loginEnd({ lid: p?.lid, finalization: p?.finalization, label: p?.label })
1133
1159
  const record = (await identity.profileActa?.().catch(() => null))?.acta || null
1134
1160
  audit('login.ok', { user: r.user, sid: r.sid.slice(0, 8) })
1135
1161
  log('[vault] login: %s entered (session %s)', r.user, r.sid.slice(0, 8))
@@ -1871,7 +1897,13 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
1871
1897
  passwords = createPasswordDesk({
1872
1898
  client,
1873
1899
  store: passwordsStore,
1874
- cek: await passwordsKey(),
1900
+ // LA BÓVEDA NO TIENE LLAVE. Lo que pone aquí es a quién envolverle las que hacen los
1901
+ // aparatos, y la pública de la copia de recuperación —la que abre la frase del perfil
1902
+ // y es la única forma de volver a repartir una entrada si un día no queda ningún
1903
+ // aparato—. Ninguna de las dos abre nada por sí sola.
1904
+ members: (kind) => passwordRecipients(kind),
1905
+ recoveryPub: () => secrets.recoveryPub(),
1906
+ canWrite: (pub) => !!actaCache && !!pub && Acta.memberCan(actaCache, pub, 'passwords'),
1875
1907
  // UNA sola condición, y es del ACTA: el aparato tiene la capacidad `passwords`.
1876
1908
  // Quitársela —o quitar el aparato— le corta esto en la siguiente pasada, sin
1877
1909
  // que haya una segunda lista que acordarse de tocar.
@@ -3092,6 +3124,64 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
3092
3124
  }
3093
3125
  }
3094
3126
 
3127
+ /**
3128
+ * LAS CONTRASEÑAS, AL FORMATO SELLADO (§2.7) Y CON SUS ENVOLTURAS AL DÍA (§2.5).
3129
+ *
3130
+ * Las dos cosas se hacen aquí, al ABRIR el perfil, porque las dos necesitan lo mismo: la
3131
+ * frase. Convertir hay que hacerlo una vez; repasar las envolturas, cada vez que cambie
3132
+ * quién puede leer — y este es el único momento en que hay con qué.
3133
+ *
3134
+ * La bóveda ve los valores SOLO mientras convierte, que es cuando tiene que reescribirlos.
3135
+ * Después no puede: lo último que hace la conversión es borrar la llave vieja.
3136
+ */
3137
+ async function sealPasswords (adminKey = null) {
3138
+ if (!passwords) return null
3139
+ const recipients = {
3140
+ recoveryPub: secrets.recoveryPub(),
3141
+ main: passwordRecipients('main'),
3142
+ passkeys: passwordRecipients('passkeys')
3143
+ }
3144
+ if (!recipients.recoveryPub) {
3145
+ log('[vault] passwords: no recovery key yet — nothing can be sealed until there is one')
3146
+ return null
3147
+ }
3148
+ const out = {}
3149
+ try {
3150
+ const { convertPasswords } = await import('./passwords.js')
3151
+ const r = await convertPasswords({
3152
+ store: passwordsStore,
3153
+ sealed: passwords.sealed,
3154
+ cek: await llaveViejaDeContrasenas(),
3155
+ recipients,
3156
+ // LA MAESTRA FIRMA LO QUE ESCRIBE, como cualquier aparato que guarda una entrada.
3157
+ // Firmar no es leer: lo que se le envuelve a esta bóveda sigue siendo nada.
3158
+ author: { publickey: master, sign: (body) => identity.signData(body) },
3159
+ dropOldKey: soltarLlaveVieja,
3160
+ log
3161
+ })
3162
+ out.converted = r?.entries || 0
3163
+ if (r?.already) out.converted = 0
3164
+ } catch (e) {
3165
+ log(`[vault] passwords: could not convert to the sealed format (${e.message})`)
3166
+ out.convertError = e.message
3167
+ }
3168
+ try {
3169
+ const r = await passwords.sealed.rewrapAll({
3170
+ openRecovery: (wrap) => secrets.openRecoveryWrap(wrap, adminKey)
3171
+ })
3172
+ out.wrapped = r.wrapped
3173
+ out.dropped = r.dropped
3174
+ if (r.wrapped || r.dropped) {
3175
+ log(`[vault] passwords: keyring rebuilt (${r.wrapped} wrap(s), ${r.dropped} stale one(s) dropped)`)
3176
+ }
3177
+ if (r.failed?.length) log(`[vault] passwords: ${r.failed.length} generation(s) could NOT be rewrapped`)
3178
+ } catch (e) {
3179
+ log(`[vault] passwords: could not rebuild the keyring (${e.message})`)
3180
+ out.rewrapError = e.message
3181
+ }
3182
+ return out
3183
+ }
3184
+
3095
3185
  async function resealAll (adminKey = null) {
3096
3186
  // ANTES de reenvolver nada: si la copia de recuperación se quedó bajo la llave de la
3097
3187
  // máquina, se pasa a la frase. Sin esto, todo lo de abajo falla con «wrong password».
@@ -3558,6 +3648,8 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
3558
3648
  * diseño dice que sí (§8.2). Lo de siempre: dos sitios decidiendo lo mismo.
3559
3649
  */
3560
3650
  resealAll,
3651
+ /** Convierte las contraseñas al formato sellado y repasa sus envolturas (§2.5, §2.7). */
3652
+ sealPasswords,
3561
3653
  delegateRewrap,
3562
3654
  incompleteMembers,
3563
3655
  migrateSecrets: (membersOf, adminKey) => secrets.migrate(