@dotrino/vaultd 0.123.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/loginClient.js +28 -8
- package/package.json +1 -1
- package/src/tui/app.js +172 -6
- package/src/tui/i18n.js +75 -0
- package/src/vaultControl.js +95 -1
package/lib/src/loginClient.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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.
|
|
3
|
+
"version": "0.123.1",
|
|
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": {
|
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
|
-
|
|
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/vaultControl.js
CHANGED
|
@@ -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
|
// ---------------------------------------------------------------------------
|