@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/lib/src/index.js +2 -2
- package/lib/src/loginClient.js +28 -8
- package/lib/src/passwordLogins.js +34 -19
- package/package.json +2 -2
- package/src/ctl.js +42 -24
- package/src/daemon.js +9 -0
- package/src/passwords.js +113 -55
- package/src/replica.js +38 -3
- package/src/secretsStore.js +14 -0
- package/src/tui/app.js +173 -7
- package/src/tui/i18n.js +81 -0
- package/src/vault.js +107 -15
- package/src/vaultControl.js +95 -1
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
|
|
70
|
-
|
|
71
|
-
|
|
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. */
|
package/src/secretsStore.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
*
|
|
253
|
-
*
|
|
254
|
-
*
|
|
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
|
|
279
|
+
async function llaveViejaDeContrasenas () {
|
|
257
280
|
const d = readPasswordsFile()
|
|
258
|
-
if (d?.cek)
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
const
|
|
264
|
-
|
|
265
|
-
|
|
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
|
-
|
|
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(
|