@dotrino/vaultd 0.123.0 → 0.124.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/src/loginClient.js +28 -8
- package/package.json +3 -2
- package/src/approvals.js +25 -3
- package/src/ctl.js +80 -0
- package/src/daemon.js +51 -0
- package/src/passwords.js +10 -40
- package/src/secretsStore.js +27 -5
- package/src/tui/app.js +172 -6
- package/src/tui/i18n.js +75 -0
- package/src/update.js +39 -0
- package/src/vault.js +36 -20
- 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.
|
|
3
|
+
"version": "0.124.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Certificador personal de Dotrino: daemon headless que custodia la clave maestra y delega capacidades a tus dispositivos por el proxy. Tu CA propia.",
|
|
6
6
|
"bin": {
|
|
@@ -24,10 +24,11 @@
|
|
|
24
24
|
"@dotrino/compat": "0.4.0",
|
|
25
25
|
"@dotrino/identity": "0.92.0",
|
|
26
26
|
"@dotrino/opaque": "0.1.0",
|
|
27
|
-
"@dotrino/passmanager": "0.
|
|
27
|
+
"@dotrino/passmanager": "0.16.0",
|
|
28
28
|
"@dotrino/proxy-client": "0.23.1",
|
|
29
29
|
"@dotrino/roadmap": "0.3.3",
|
|
30
30
|
"@dotrino/store": "0.11.0",
|
|
31
|
+
"@dotrino/update": "0.1.1",
|
|
31
32
|
"ws": "^8.18.0"
|
|
32
33
|
},
|
|
33
34
|
"license": "MIT",
|
package/src/approvals.js
CHANGED
|
@@ -38,7 +38,27 @@ const rnd = () => [...crypto.getRandomValues(new Uint8Array(8))].map((b) => b.to
|
|
|
38
38
|
/** Separador de la llave compuesta. Es un byte que no puede aparecer en un cajón ni en una pubkey. */
|
|
39
39
|
const SEP = String.fromCharCode(0)
|
|
40
40
|
|
|
41
|
+
/**
|
|
42
|
+
* LO QUE DURA UN PEDIDO SIN CONTESTAR, y por qué son dos números y no uno.
|
|
43
|
+
*
|
|
44
|
+
* CINCO MINUTOS es lo correcto para un COMANDO: hay un proceso bloqueado esperando, y
|
|
45
|
+
* aprobar media hora más tarde entregaría claves a algo cuyo contexto ya cambió — la
|
|
46
|
+
* carpeta, el comando o la máquina pueden ser otros. Que caduque rápido es la protección.
|
|
47
|
+
*
|
|
48
|
+
* UNA ACTUALIZACIÓN no se parece en nada (dueño, 2026-09-20): no hay nadie esperando, y
|
|
49
|
+
* caducar no protege de nada — solo significa que mañana se vuelve a pedir y se vuelve a
|
|
50
|
+
* perder, porque mirar el teléfono puede tardar horas. Un pedido que nunca llega a tiempo
|
|
51
|
+
* es una función que no existe.
|
|
52
|
+
*
|
|
53
|
+
* Por eso el plazo es DEL PEDIDO y no de la mesa. Quien pide dice cuánto aguanta lo suyo.
|
|
54
|
+
*/
|
|
41
55
|
export const PENDING_TTL_MS = 5 * 60 * 1000
|
|
56
|
+
/**
|
|
57
|
+
* Y el de una actualización: un día. Cuadra con que la bóveda mire una vez al día — pedir
|
|
58
|
+
* otra vez reemplaza el pedido anterior del mismo cajón y aparato, así que siempre hay
|
|
59
|
+
* exactamente uno vivo, en vez de una cola de avisos caducados.
|
|
60
|
+
*/
|
|
61
|
+
export const UPDATE_TTL_MS = 24 * 60 * 60 * 1000
|
|
42
62
|
/** Lo que dura una concesión SIN USARSE. Cada uso la vuelve a poner en una hora. */
|
|
43
63
|
export const GRANT_TTL_MS = 60 * 60 * 1000
|
|
44
64
|
|
|
@@ -62,10 +82,12 @@ export function createApprovals ({ now = Date.now, pendingTtlMs = PENDING_TTL_MS
|
|
|
62
82
|
* apruebe— y tiene que volver por el mismo sitio: desde que la bóveda atiende también
|
|
63
83
|
* por un socket local, mandarla siempre por el proxio la dejaba en el vacío.
|
|
64
84
|
*/
|
|
65
|
-
request ({ ns, device, deviceId, label = '', ek, ctx = null, from = null }) {
|
|
85
|
+
request ({ ns, device, deviceId, label = '', ek, ctx = null, from = null, ttlMs = null }) {
|
|
66
86
|
for (const [id, p] of pending) if (p.ns === ns && p.device === device) pending.delete(id)
|
|
67
87
|
const ts = now()
|
|
68
|
-
|
|
88
|
+
// `ttlMs` lo pone quien pide: una actualización aguanta un día y un comando cinco
|
|
89
|
+
// minutos, y la mesa no tiene por qué saber cuál es cuál (ver `PENDING_TTL_MS`).
|
|
90
|
+
const p = { id: rnd(), ns, device, deviceId, label, ek, ctx, ts, exp: ts + (ttlMs || pendingTtlMs), from }
|
|
69
91
|
pending.set(p.id, p)
|
|
70
92
|
return publicOf(p)
|
|
71
93
|
},
|
|
@@ -158,4 +180,4 @@ export function createGrants ({ now = Date.now, ttlMs = GRANT_TTL_MS } = {}) {
|
|
|
158
180
|
return api
|
|
159
181
|
}
|
|
160
182
|
|
|
161
|
-
export default { createApprovals, createGrants, PENDING_TTL_MS, GRANT_TTL_MS }
|
|
183
|
+
export default { createApprovals, createGrants, PENDING_TTL_MS, UPDATE_TTL_MS, GRANT_TTL_MS }
|
package/src/ctl.js
CHANGED
|
@@ -37,6 +37,7 @@ import { qrToString } from './qr.js'
|
|
|
37
37
|
import { encodeInvite, inviteUrl, parseInvite } from '../lib/src/invite.js'
|
|
38
38
|
import { atRestFor, readConfig as readKekConfig, probe as probeKek, rekeyDir, encryptedFilesIn, CONFIG_FILE as KEK_CONFIG_FILE } from '../lib/src/atrest.js'
|
|
39
39
|
import { VERSION } from './version.js'
|
|
40
|
+
import { isNewer } from './update.js'
|
|
40
41
|
|
|
41
42
|
const dir = dataDir()
|
|
42
43
|
const stateFile = path.join(dir, 'state.json')
|
|
@@ -144,6 +145,13 @@ function cmdStatus () {
|
|
|
144
145
|
console.log(' proxy : %s', s.proxy)
|
|
145
146
|
console.log(' pid : %s%s', s.pid, up ? '' : ' (no responde)')
|
|
146
147
|
console.log(' datos : %s', dir)
|
|
148
|
+
// HAY VERSIÓN NUEVA. Lo mira el daemon una vez al día y lo deja en `state.json`; aquí
|
|
149
|
+
// solo se lee, para que `status` conteste al instante y también sin conexión. Es lo que
|
|
150
|
+
// faltaba el 2026-09-19: el replicador llevaba quince días atrás y nada lo decía.
|
|
151
|
+
if (s.latest?.version && isNewer(s.latest.version, VERSION)) {
|
|
152
|
+
console.log(' %s⬆ hay %s publicada%s (esta es la %s) · tráela con: dotrino-vault update',
|
|
153
|
+
B, s.latest.version, Z, VERSION)
|
|
154
|
+
}
|
|
147
155
|
const profiles = s.profiles || []
|
|
148
156
|
if (profiles.length) {
|
|
149
157
|
console.log(' perfiles : %d', profiles.length)
|
|
@@ -1689,6 +1697,77 @@ async function cmdLogins (rest) {
|
|
|
1689
1697
|
}
|
|
1690
1698
|
}
|
|
1691
1699
|
|
|
1700
|
+
/**
|
|
1701
|
+
* `dotrino-vault update` — TRAER LA VERSIÓN NUEVA, cuando lo escribe una persona.
|
|
1702
|
+
*
|
|
1703
|
+
* La mitad automática es MIRAR, y la hace el daemon una vez al día (`src/update.js`
|
|
1704
|
+
* explica por qué la otra mitad no se automatiza: sería un canal para meter código en la
|
|
1705
|
+
* máquina que guarda la maestra sin que nadie diga que sí).
|
|
1706
|
+
*
|
|
1707
|
+
* Lo que se baja SE VERIFICA contra la atestación de sigstore antes de tocar el disco, y
|
|
1708
|
+
* si no se puede verificar NO SE INSTALA. Que la URL sea la correcta no prueba nada.
|
|
1709
|
+
*
|
|
1710
|
+
* Instalar no reinicia nada a mano: el daemon vigila su propio binario y, al cambiar, se
|
|
1711
|
+
* va para que systemd lo levante con el nuevo (`src/selfupdate.js`).
|
|
1712
|
+
*/
|
|
1713
|
+
async function cmdUpdate (args = []) {
|
|
1714
|
+
const { latestRelease, assetFor, download, verifyArtifact, isNewer } = await import('./update.js')
|
|
1715
|
+
const soloMirar = args.includes('--check')
|
|
1716
|
+
|
|
1717
|
+
process.stdout.write('Mirando qué hay publicado… ')
|
|
1718
|
+
const r = await latestRelease()
|
|
1719
|
+
if (!r.ok) {
|
|
1720
|
+
console.log('')
|
|
1721
|
+
console.error('No se pudo mirar (%s). No es que estés al día: es que no se sabe.', r.reason)
|
|
1722
|
+
process.exit(1)
|
|
1723
|
+
}
|
|
1724
|
+
console.log('%s', r.version)
|
|
1725
|
+
|
|
1726
|
+
if (!isNewer(r.version, VERSION)) {
|
|
1727
|
+
console.log('Estás al día (%s).', VERSION)
|
|
1728
|
+
return
|
|
1729
|
+
}
|
|
1730
|
+
console.log('%sHay %s y esta es la %s.%s', B, r.version, VERSION, Z)
|
|
1731
|
+
if (soloMirar) { console.log('Tráela con: dotrino-vault update'); return }
|
|
1732
|
+
|
|
1733
|
+
const pick = assetFor(r.assets)
|
|
1734
|
+
if (!pick.ok) { console.error('%s', pick.reason); process.exit(1) }
|
|
1735
|
+
|
|
1736
|
+
process.stdout.write(`Bajando ${pick.asset.name}… `)
|
|
1737
|
+
let file
|
|
1738
|
+
try { file = await download(pick.asset) } catch (e) { console.log(''); console.error('%s', e.message); process.exit(1) }
|
|
1739
|
+
console.log('listo')
|
|
1740
|
+
|
|
1741
|
+
process.stdout.write('Comprobando la firma… ')
|
|
1742
|
+
const v = verifyArtifact(file)
|
|
1743
|
+
if (!v.ok) {
|
|
1744
|
+
console.log('')
|
|
1745
|
+
console.error('%sNO se instala nada.%s %s', R, Z, v.reason)
|
|
1746
|
+
console.error('El archivo quedó en %s, sin tocar el sistema.', file)
|
|
1747
|
+
process.exit(1)
|
|
1748
|
+
}
|
|
1749
|
+
console.log('firmada por el release de %s ✓', 'imdotrino/dotrino-vault')
|
|
1750
|
+
|
|
1751
|
+
if (pick.kind === 'deb') {
|
|
1752
|
+
if (typeof process.getuid === 'function' && process.getuid() === 0) {
|
|
1753
|
+
execFileSync('dpkg', ['-i', file], { stdio: 'inherit' })
|
|
1754
|
+
} else {
|
|
1755
|
+
// No se pide sudo por dentro: que la contraseña la escriba quien la escribe, en su
|
|
1756
|
+
// terminal y viendo qué comando la pide.
|
|
1757
|
+
console.log('\nYa verificada. Instálala con:')
|
|
1758
|
+
console.log(' %ssudo dpkg -i %s%s', B, file, Z)
|
|
1759
|
+
console.log('\nEl servicio se reinicia solo al cambiar el binario.')
|
|
1760
|
+
return
|
|
1761
|
+
}
|
|
1762
|
+
} else {
|
|
1763
|
+
console.log('\nYa verificada. Descomprímela y reemplaza el binario:')
|
|
1764
|
+
console.log(' %star xzf %s -C /tmp && sudo install -m0755 /tmp/dotrino-vault-%s-linux-x64/dotrino-vault{,d} /usr/bin/%s',
|
|
1765
|
+
B, file, r.version, Z)
|
|
1766
|
+
return
|
|
1767
|
+
}
|
|
1768
|
+
console.log('Listo. El servicio se reinicia solo; compruébalo con: dotrino-vault status')
|
|
1769
|
+
}
|
|
1770
|
+
|
|
1692
1771
|
function reportProfiles (d) {
|
|
1693
1772
|
if (d.error) {
|
|
1694
1773
|
// Los dos rechazos del candado se dicen con palabras y con el dato que hace falta; el
|
|
@@ -2085,6 +2164,7 @@ export async function runCtl (argv) {
|
|
|
2085
2164
|
case 'caps': return cmdCaps(rest)
|
|
2086
2165
|
case 'revoke': return cmdRevoke(rest[0])
|
|
2087
2166
|
case 'logins': return cmdLogins(rest)
|
|
2167
|
+
case 'update': return cmdUpdate(rest)
|
|
2088
2168
|
case 'secret': return cmdSecret(rest)
|
|
2089
2169
|
// `approval` se quitó el 2026-09-01 (ahora es el permiso `unattended`), pero la ruta se
|
|
2090
2170
|
// quedó apuntando a una función que ya no existía: reventaba con un ReferenceError. Se
|
package/src/daemon.js
CHANGED
|
@@ -23,6 +23,7 @@ import { dataDir } from './paths.js'
|
|
|
23
23
|
import { ipcRead, ipcWrite, migrateIpcDir } from './ipc.js'
|
|
24
24
|
import { parseInvite } from '../lib/src/invite.js'
|
|
25
25
|
import { watchBinary } from './selfupdate.js'
|
|
26
|
+
import { latestRelease, isNewer, CHECK_EVERY_MS } from './update.js'
|
|
26
27
|
import { VERSION } from './version.js'
|
|
27
28
|
import crypto from 'node:crypto'
|
|
28
29
|
|
|
@@ -87,6 +88,8 @@ export async function runDaemon () {
|
|
|
87
88
|
// --- state.json ---
|
|
88
89
|
const stateFile = path.join(dir, 'state.json')
|
|
89
90
|
const daemonVersion = VERSION
|
|
91
|
+
/** `{ version, checkedAt }` de la última consulta que salió bien, o null si aún ninguna. */
|
|
92
|
+
let ultimaPublicada = null
|
|
90
93
|
// Los campos de la raíz (fingerprint/iss) son los del perfil ACTIVO: los leen el
|
|
91
94
|
// instalador y la web, que son anteriores al multi-perfil. La lista completa va
|
|
92
95
|
// en `profiles`.
|
|
@@ -98,6 +101,10 @@ export async function runDaemon () {
|
|
|
98
101
|
// Cuánto aguanta abierto el candado sin usarse. Va en la foto para que la consola
|
|
99
102
|
// pueda DECIRLO en vez de llevar su propio número (que se desincronizaría).
|
|
100
103
|
autoLockMs: mgr.profiles.autoLockMs,
|
|
104
|
+
// LO QUE HAY PUBLICADO, si se pudo mirar. Va en la foto para que `status` lo enseñe
|
|
105
|
+
// sin salir a la red: una pantalla de estado tiene que contestar al instante y
|
|
106
|
+
// también sin conexión.
|
|
107
|
+
...(ultimaPublicada ? { latest: ultimaPublicada } : {}),
|
|
101
108
|
current: mgr.currentId(), profiles: mgr.summary()
|
|
102
109
|
})
|
|
103
110
|
}
|
|
@@ -983,6 +990,50 @@ export async function runDaemon () {
|
|
|
983
990
|
})
|
|
984
991
|
if (vigia) console.log('[vault] watching for updates: a new binary restarts the service on its own')
|
|
985
992
|
|
|
993
|
+
/**
|
|
994
|
+
* ¿SALIÓ UNA VERSIÓN NUEVA? Una lectura al día, y nada más.
|
|
995
|
+
*
|
|
996
|
+
* Esto NO descarga ni instala: sería meter código en la máquina que guarda la maestra sin
|
|
997
|
+
* que nadie diga que sí, y eso es justo el interruptor remoto que el proyecto no tiene
|
|
998
|
+
* (`src/update.js` lo explica entero). Traerla la escribe una persona: `dotrino-vault
|
|
999
|
+
* update`.
|
|
1000
|
+
*
|
|
1001
|
+
* Lo que arregla es lo que de verdad dolió el 2026-09-19: el replicador llevaba quince
|
|
1002
|
+
* días atrás y las bóvedas dos versiones, y NADA lo decía en ninguna pantalla.
|
|
1003
|
+
*
|
|
1004
|
+
* Si no se puede mirar, no se dice nada y ya está: no saber si hay versión nueva no es un
|
|
1005
|
+
* problema del usuario, y llenarle el log de fallos de red tampoco ayuda.
|
|
1006
|
+
*/
|
|
1007
|
+
const mirarRelease = async () => {
|
|
1008
|
+
const r = await latestRelease()
|
|
1009
|
+
if (!r.ok) return
|
|
1010
|
+
ultimaPublicada = { version: r.version, checkedAt: Date.now() }
|
|
1011
|
+
writeState()
|
|
1012
|
+
if (!isNewer(r.version, daemonVersion)) return
|
|
1013
|
+
|
|
1014
|
+
/**
|
|
1015
|
+
* PIDE PERMISO SOLO SI HAY A QUIÉN PEDÍRSELO (dueño, 2026-09-20).
|
|
1016
|
+
*
|
|
1017
|
+
* La bóveda es la pieza sensible del ecosistema: quien le cuele una actualización se
|
|
1018
|
+
* lleva la maestra, y eso no se rota — se pierde la cuenta. Por eso no se actualiza
|
|
1019
|
+
* sola mientras haya un aparato con `aprueba` que pueda decidirlo, igual que ya pasa
|
|
1020
|
+
* con las claves privadas.
|
|
1021
|
+
*
|
|
1022
|
+
* Y si NO hay aprobadores, esperar un sí que nadie puede dar sería condenarla a no
|
|
1023
|
+
* actualizarse nunca. Entonces se actualiza sola, que es lo que hace el resto del
|
|
1024
|
+
* ecosistema (§15).
|
|
1025
|
+
*/
|
|
1026
|
+
let quienAprueba = []
|
|
1027
|
+
try { quienAprueba = (await mgr.current()?.approvers?.()) || [] } catch (_) {}
|
|
1028
|
+
const comoLoDice = quienAprueba.length
|
|
1029
|
+
? `waiting for one of ${quienAprueba.length} approver(s) to say yes`
|
|
1030
|
+
: 'no approver in the record, so this vault may update itself'
|
|
1031
|
+
console.log(`[vault] version ${r.version} is out (this one is ${daemonVersion}) · ${comoLoDice} · dotrino-vault update`)
|
|
1032
|
+
}
|
|
1033
|
+
mirarRelease()
|
|
1034
|
+
const relojRelease = setInterval(mirarRelease, CHECK_EVERY_MS)
|
|
1035
|
+
relojRelease.unref?.()
|
|
1036
|
+
|
|
986
1037
|
console.log('[vault] servicio listo.')
|
|
987
1038
|
return mgr
|
|
988
1039
|
}
|
package/src/passwords.js
CHANGED
|
@@ -109,47 +109,17 @@ export function createPasswordDesk (opts = {}) {
|
|
|
109
109
|
* hace al ABRIR la bóveda, que es cuando la frase está a mano y hay con qué reescribirlo
|
|
110
110
|
* todo.
|
|
111
111
|
*
|
|
112
|
-
*
|
|
113
|
-
*
|
|
114
|
-
*
|
|
115
|
-
*
|
|
112
|
+
* El trabajo está en `@dotrino/passmanager/sealed` y no aquí, porque lo hacen las CUATRO
|
|
113
|
+
* bóvedas: esta, la pestaña, la de dentro de la extensión y `passmanager serve`. Cuatro
|
|
114
|
+
* copias serían cuatro formas sutilmente distintas de reescribir lo mismo, y la que se
|
|
115
|
+
* quedara atrás no fallaría al convertir — fallaría al leerlo otra bóveda, meses después.
|
|
116
116
|
*
|
|
117
|
-
*
|
|
118
|
-
* `store` el almacén con las entradas viejas y donde irán los sobres
|
|
119
|
-
* `sealed` la `SealedStore` de esta bóveda
|
|
120
|
-
* `cek` la llave vieja (`CryptoKey`), o `null` si ya no hay nada que convertir
|
|
121
|
-
* `recipients` `{ recoveryPub, main, passkeys }`
|
|
122
|
-
* `author` `{ publickey, sign(body) }` — la maestra, que firma lo que escribe
|
|
123
|
-
* `dropOldKey()` borra la llave vieja del disco. Se llama AL FINAL y solo si todo salió
|
|
117
|
+
* Lo que sí es de aquí: `dropOldKey`, porque dónde vive la llave vieja lo sabe cada una.
|
|
124
118
|
*/
|
|
125
119
|
export async function convertPasswords ({ store, sealed, cek, recipients, author, dropOldKey, log = () => {} } = {}) {
|
|
126
|
-
const {
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
// 1. La llave del perfil, de la que salen el índice de sitios y los resúmenes.
|
|
133
|
-
const pk = await buildProfileKey({ recipients })
|
|
134
|
-
await sealed.setProfile({ envelope: pk.envelope, wraps: pk.wraps })
|
|
135
|
-
const { profileKeys } = await import('@dotrino/passmanager/sealed')
|
|
136
|
-
const keys = await profileKeys(pk.base)
|
|
137
|
-
|
|
138
|
-
// 2. Cada entrada, reescrita: un sobre por campo, la vista sellada, sus huellas.
|
|
139
|
-
let hechas = 0
|
|
140
|
-
for (const vieja of viejas) {
|
|
141
|
-
if (!cek) break
|
|
142
|
-
const abierta = await openEntry(cek, vieja)
|
|
143
|
-
const sobre = await buildSealedEntry({ plain: abierta, keys, recipients, author })
|
|
144
|
-
await sealed.putSealed(sobre)
|
|
145
|
-
hechas++
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
// 3. Y se va lo viejo: las entradas con la llave que las abría, y la llave misma.
|
|
149
|
-
if (hechas === viejas.length) {
|
|
150
|
-
await store.set('passmanager/entries/v1', [])
|
|
151
|
-
await dropOldKey?.()
|
|
152
|
-
}
|
|
153
|
-
log(`[vault] passwords: ${hechas} entries converted to the sealed format`)
|
|
154
|
-
return { already: false, entries: hechas }
|
|
120
|
+
const { convertToSealed } = await import('@dotrino/passmanager/sealed')
|
|
121
|
+
return convertToSealed({
|
|
122
|
+
store, sealed, cek, recipients, author, dropOldKey,
|
|
123
|
+
log: (m) => log('[vault] ' + m)
|
|
124
|
+
})
|
|
155
125
|
}
|
package/src/secretsStore.js
CHANGED
|
@@ -253,6 +253,20 @@ export function openSecretsStore (dir, { sealer = null, recipients = null, signe
|
|
|
253
253
|
return kr.reduce((best, g) => (!best || (g.gen || 0) > (best.gen || 0) ? g : best), null)
|
|
254
254
|
}
|
|
255
255
|
|
|
256
|
+
/**
|
|
257
|
+
* LA GENERACIÓN QUE TOCA ESTRENAR. Se mira el llavero Y las entradas, no solo el llavero:
|
|
258
|
+
* un dato PÚBLICO del perfil lleva generación pero no sobre, así que no deja nada en el
|
|
259
|
+
* llavero. Contando solo el llavero, dos escrituras públicas seguidas salían con la MISMA
|
|
260
|
+
* generación — y la generación es lo que ordena un dato entre réplicas (`mergeBundle`):
|
|
261
|
+
* con empate gana el de hash menor, que puede ser el valor viejo.
|
|
262
|
+
*/
|
|
263
|
+
const nextGen = (bag, k, owner) => {
|
|
264
|
+
let top = topGen(bag, k)?.gen || 0
|
|
265
|
+
for (const e of Object.values(bag[k]?.vars || {})) if ((e.gen || 0) > top) top = e.gen
|
|
266
|
+
for (const h of data.history) if (h.owner === owner && (h.gen || 0) > top) top = h.gen
|
|
267
|
+
return top + 1
|
|
268
|
+
}
|
|
269
|
+
|
|
256
270
|
/**
|
|
257
271
|
* UN CAMBIO A LA VEZ.
|
|
258
272
|
*
|
|
@@ -445,14 +459,22 @@ export function openSecretsStore (dir, { sealer = null, recipients = null, signe
|
|
|
445
459
|
*
|
|
446
460
|
* @param {string} owner `ns:<scope>` o `dev:<pub>`
|
|
447
461
|
* @param {string} memberPub la llave de FIRMA del miembro (así se indexan las envolturas)
|
|
462
|
+
* @param {{ only?: (isPublic: boolean) => boolean }} [opts] qué variables le TOCAN, según
|
|
463
|
+
* su visibilidad: a quien administra se le envuelven las públicas de un cajón con dueño
|
|
464
|
+
* y no las privadas, así que la falta de una privada no es deuda suya.
|
|
448
465
|
* @returns {string[]} nombres de variables, vacío si puede con todas
|
|
449
466
|
*/
|
|
450
|
-
missingFor (owner, memberPub) {
|
|
467
|
+
missingFor (owner, memberPub, { only = null } = {}) {
|
|
451
468
|
if (isLegacy()) return []
|
|
452
469
|
const [kind, k] = splitOwner(owner)
|
|
453
470
|
const bag = kind === 'ns' ? data.ns : data.dev
|
|
454
471
|
const out = []
|
|
455
472
|
for (const [key, e] of Object.entries(varsOf(bag, k))) {
|
|
473
|
+
// Lo PÚBLICO DEL PERFIL no lleva sobre: va en claro y firmado, porque quien lo lee
|
|
474
|
+
// desde fuera no tiene ninguna llave tuya. No hay envoltura que pueda faltarle a
|
|
475
|
+
// nadie, y contarla como deuda era un aviso que ningún reparto podía apagar.
|
|
476
|
+
if (e.cls === 'public') continue
|
|
477
|
+
if (only && !only(!!e.pub)) continue
|
|
456
478
|
// También las públicas: desde 2026-09-02 van en sobre, así que a quien no tenga su
|
|
457
479
|
// envoltura le falta igual que con una privada.
|
|
458
480
|
const g = (bag[k]?.keyring || []).find((x) => x.gen === e.gen)
|
|
@@ -580,7 +602,7 @@ export function openSecretsStore (dir, { sealer = null, recipients = null, signe
|
|
|
580
602
|
|
|
581
603
|
// CEK NUEVA, siempre: no se puede reutilizar la de antes sin poder abrirla.
|
|
582
604
|
const cek = await sealer.newKey()
|
|
583
|
-
const gen = (
|
|
605
|
+
const gen = nextGen(bag, k, owner)
|
|
584
606
|
const { wraps, sinLlave } = await wrapAll(cek, owner, pub)
|
|
585
607
|
const e = await sealer.encrypt(cek, value, gen)
|
|
586
608
|
// La FIRMA dice que este sobre salió de esta bóveda, y con qué acta (§8.8). Si no
|
|
@@ -644,7 +666,7 @@ export function openSecretsStore (dir, { sealer = null, recipients = null, signe
|
|
|
644
666
|
|
|
645
667
|
// LA GENERACIÓN LA PONE EL ALMACÉN, no quien escribe: es la que ordena el llavero, y
|
|
646
668
|
// dejar que la eligiera el que llega permitiría pisar una anterior.
|
|
647
|
-
const gen = (
|
|
669
|
+
const gen = nextGen(bag, k, owner)
|
|
648
670
|
// La FIRMA sigue siendo de la bóveda —dice que este sobre salió de aquí y con qué
|
|
649
671
|
// acta (§8.8)— y se hace con la llave de SELLADO, que funciona con el perfil cerrado.
|
|
650
672
|
const seal = signer ? await signer({ owner, key, gen, iv: e.iv, ct: e.ct }) : null
|
|
@@ -917,7 +939,7 @@ export function openSecretsStore (dir, { sealer = null, recipients = null, signe
|
|
|
917
939
|
}
|
|
918
940
|
const vars = ensureBag(data.ns, PROFILE_NS)
|
|
919
941
|
const before = vars[key]
|
|
920
|
-
const gen = (
|
|
942
|
+
const gen = nextGen(data.ns, PROFILE_NS, PROFILE_OWNER)
|
|
921
943
|
const seal = signer ? await signer({ owner: PROFILE_OWNER, key, gen, iv: '', ct: value }) : null
|
|
922
944
|
pushHistory(PROFILE_OWNER, key, before, by)
|
|
923
945
|
vars[key] = { cls: 'public', pub: true, owner: PROFILE_OWNER, gen, pubv: value, seal, at: Date.now(), by: by || null }
|
|
@@ -963,7 +985,7 @@ export function openSecretsStore (dir, { sealer = null, recipients = null, signe
|
|
|
963
985
|
for (const [key, e] of Object.entries(vars)) {
|
|
964
986
|
if (typeof e.v !== 'string' || e.e) continue
|
|
965
987
|
const cek = await sealer.newKey()
|
|
966
|
-
const gen = (
|
|
988
|
+
const gen = nextGen(bag, k, owner)
|
|
967
989
|
const r = await sealer.wrapFor(cek, await paraVisibilidad(!!e.pub))
|
|
968
990
|
r.wraps[RECOVERY] = await sealer.wrapForKey(cek, data.recovery.pub)
|
|
969
991
|
const sobre = await sealer.encrypt(cek, e.v, gen)
|
package/src/tui/app.js
CHANGED
|
@@ -42,6 +42,12 @@ import { DEVICE_CAPS } from '@dotrino/identity/acta'
|
|
|
42
42
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
|
|
43
43
|
|
|
44
44
|
// Regex de validación (mismas que el store de secretos, protocol.js).
|
|
45
|
+
// El mínimo de la contraseña de un inicio de sesión. No es manía: quien tenga una COPIA
|
|
46
|
+
// DEL DISCO de la bóveda puede probar contraseñas contra el registro de OPAQUE sin límite y
|
|
47
|
+
// sin que nadie se entere (`temporary-access.md` §4). Contra eso lo único que aguanta es la
|
|
48
|
+
// contraseña. Mismo número que la CLI, que es la misma regla.
|
|
49
|
+
const MIN_LOGIN_PASSWORD = 12
|
|
50
|
+
|
|
45
51
|
const NS_RE = /^[a-z0-9-]{1,32}$/
|
|
46
52
|
const KEY_RE = /^[A-Z0-9_]{1,64}$/
|
|
47
53
|
|
|
@@ -343,6 +349,42 @@ function deviceRows (st, t) {
|
|
|
343
349
|
return rows
|
|
344
350
|
}
|
|
345
351
|
|
|
352
|
+
/**
|
|
353
|
+
* ENTRAR CON USUARIO Y CONTRASEÑA (`docs/temporary-access.md`).
|
|
354
|
+
*
|
|
355
|
+
* Esto estaba SOLO en la línea de comandos, y es lo que la regla de las tres versiones no
|
|
356
|
+
* admite: lo que hace el binario tiene que poder hacerse en su pantalla. El canal es el
|
|
357
|
+
* MISMO que usa `dotrino-vault logins` —una pieza, no dos—, así que no se pueden
|
|
358
|
+
* desincronizar.
|
|
359
|
+
*
|
|
360
|
+
* Lo que se ve de cada uno es lo que hace falta para decidir: su DIRECCIÓN (que es lo que
|
|
361
|
+
* se teclea en el equipo prestado), cuántas sesiones tiene abiertas, y si está esperando
|
|
362
|
+
* por intentos fallidos.
|
|
363
|
+
*/
|
|
364
|
+
function loginRows (st, t) {
|
|
365
|
+
const i = L(st)
|
|
366
|
+
const rows = []
|
|
367
|
+
const list = st.logins || []
|
|
368
|
+
if (!list.length) {
|
|
369
|
+
rows.push({ text: t.muted(i.noLogins), sel: false })
|
|
370
|
+
rows.push({ text: '', sel: false })
|
|
371
|
+
rows.push({ text: t.muted(i.loginsHint), sel: false })
|
|
372
|
+
return rows
|
|
373
|
+
}
|
|
374
|
+
for (const l of list) {
|
|
375
|
+
const espera = l.blockedUntil > Date.now() ? Math.ceil((l.blockedUntil - Date.now()) / 1000) : 0
|
|
376
|
+
const abiertas = (l.sessions || []).length
|
|
377
|
+
const extra = [
|
|
378
|
+
abiertas ? t.warn(' ' + i.loginOpen(abiertas)) : t.muted(' ' + i.loginOpen(0)),
|
|
379
|
+
l.label ? t.muted(' «' + l.label + '»') : '',
|
|
380
|
+
espera ? t.danger(' ' + i.loginWait(espera)) : '',
|
|
381
|
+
l.createdAt ? t.muted(' ' + i.loginCreated(fmtExp(l.createdAt))) : ''
|
|
382
|
+
].join('')
|
|
383
|
+
rows.push({ text: ` ${t.bold(l.address || l.user)} ${t.muted(l.deviceId || '????-????')}${extra}`, sel: true, meta: l })
|
|
384
|
+
}
|
|
385
|
+
return rows
|
|
386
|
+
}
|
|
387
|
+
|
|
346
388
|
/**
|
|
347
389
|
* LA PREGUNTA DEL EMPAREJAMIENTO. La decisión es del vault (es quien lo inicia) y
|
|
348
390
|
* este daemon puede tener varias cuentas: antes de mostrar el QR hay que decir a
|
|
@@ -753,6 +795,17 @@ async function refreshMembers (term, st) {
|
|
|
753
795
|
const r = await guard(term, st, L(st).loadingMembers, () => vc.listMembers(activeId(st)))
|
|
754
796
|
if (r.ok) st.members = r.v
|
|
755
797
|
}
|
|
798
|
+
/**
|
|
799
|
+
* Los inicios de sesión de la bóveda activa. Es una petición aparte del volcado normal a
|
|
800
|
+
* propósito: no hace falta para nada más, y pedirla siempre sería sacar del cifrado algo
|
|
801
|
+
* que nadie está mirando.
|
|
802
|
+
*/
|
|
803
|
+
async function refreshLogins (term, st, api = vc) {
|
|
804
|
+
const r = await guard(term, st, L(st).refreshing, () => api.listLogins(activeId(st)))
|
|
805
|
+
if (r.ok) st.logins = r.v
|
|
806
|
+
else st.logins = []
|
|
807
|
+
}
|
|
808
|
+
|
|
756
809
|
async function refreshMe (term, st) {
|
|
757
810
|
const r = await guard(term, st, L(st).loadingProfile, () => vc.getMe(activeId(st)))
|
|
758
811
|
st.me = r.ok ? r.v : null
|
|
@@ -1047,6 +1100,105 @@ async function onKeyDevices (term, st, key) {
|
|
|
1047
1100
|
return true
|
|
1048
1101
|
}
|
|
1049
1102
|
|
|
1103
|
+
/**
|
|
1104
|
+
* ENTRAR CON USUARIO Y CONTRASEÑA: las mismas acciones que el CLI, con las mismas teclas
|
|
1105
|
+
* que el resto de la TUI (`n` nuevo, `c` contraseña, `v` quitar, `x` cerrar, `u` desbloquear).
|
|
1106
|
+
*
|
|
1107
|
+
* `x` es CERRAR SESIONES y no «quitar», porque aquí «quitar» ya es `v` —revocar, como en
|
|
1108
|
+
* Dispositivos— y una tecla significa lo mismo en todas las pantallas.
|
|
1109
|
+
*/
|
|
1110
|
+
async function onKeyLogins (term, st, key, api = vc) {
|
|
1111
|
+
const i = L(st)
|
|
1112
|
+
const list = st.logins || []
|
|
1113
|
+
moveSel(st, key, 'logins', list.length)
|
|
1114
|
+
const cur = list[Math.min(st.sel.logins || 0, Math.max(0, list.length - 1))]
|
|
1115
|
+
const ch = key.name === 'char' ? key.ch.toLowerCase() : null
|
|
1116
|
+
|
|
1117
|
+
if (ch === 'n') {
|
|
1118
|
+
// Tres preguntas encadenadas: usuario → de dónde entra → contraseña (dos veces). Van
|
|
1119
|
+
// en cadena y no en un formulario porque la TUI pregunta de a una, como en el resto.
|
|
1120
|
+
setInput(st, {
|
|
1121
|
+
label: i.loginUserLabel,
|
|
1122
|
+
hint: i.loginUserHint,
|
|
1123
|
+
onSubmit: async (raw) => {
|
|
1124
|
+
const user = String(raw || '').trim().toLowerCase()
|
|
1125
|
+
if (!user) return
|
|
1126
|
+
setInput(st, {
|
|
1127
|
+
label: i.loginLabelLabel(user),
|
|
1128
|
+
hint: i.loginLabelHint,
|
|
1129
|
+
value: 'equipo prestado',
|
|
1130
|
+
onSubmit: async (rawLabel) => {
|
|
1131
|
+
const label = String(rawLabel || '').trim() || 'equipo prestado'
|
|
1132
|
+
setInput(st, {
|
|
1133
|
+
label: i.loginPassLabel(user),
|
|
1134
|
+
hint: i.loginPassHint,
|
|
1135
|
+
mask: true,
|
|
1136
|
+
onSubmit: async (pw1) => {
|
|
1137
|
+
if (String(pw1 || '').length < MIN_LOGIN_PASSWORD) { flash(st, i.loginPassShort, 'warn'); return }
|
|
1138
|
+
setInput(st, {
|
|
1139
|
+
label: i.loginPassAgain,
|
|
1140
|
+
hint: i.loginPassHint,
|
|
1141
|
+
mask: true,
|
|
1142
|
+
onSubmit: async (pw2) => {
|
|
1143
|
+
if (pw1 !== pw2) { flash(st, i.loginPassMismatch, 'warn'); return }
|
|
1144
|
+
const r = await guard(term, st, i.loginCreating,
|
|
1145
|
+
() => api.addLogin({ user, password: pw1, label }))
|
|
1146
|
+
if (r.ok) {
|
|
1147
|
+
await refreshLogins(term, st, api)
|
|
1148
|
+
const nuevo = (st.logins || []).find((l) => l.user === user)
|
|
1149
|
+
flash(st, i.loginCreatedOk(nuevo?.address || user))
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1152
|
+
})
|
|
1153
|
+
}
|
|
1154
|
+
})
|
|
1155
|
+
}
|
|
1156
|
+
})
|
|
1157
|
+
}
|
|
1158
|
+
})
|
|
1159
|
+
} else if (ch === 'c' && cur) {
|
|
1160
|
+
setInput(st, {
|
|
1161
|
+
label: i.loginOldPass(cur.user),
|
|
1162
|
+
hint: i.loginPassHint,
|
|
1163
|
+
mask: true,
|
|
1164
|
+
onSubmit: async (vieja) => {
|
|
1165
|
+
setInput(st, {
|
|
1166
|
+
label: i.loginNewPass,
|
|
1167
|
+
hint: i.loginPassHint,
|
|
1168
|
+
mask: true,
|
|
1169
|
+
onSubmit: async (nueva) => {
|
|
1170
|
+
if (String(nueva || '').length < MIN_LOGIN_PASSWORD) { flash(st, i.loginPassShort, 'warn'); return }
|
|
1171
|
+
const r = await guard(term, st, i.loginChanging,
|
|
1172
|
+
() => api.passwdLogin({ user: cur.user, oldPassword: vieja, newPassword: nueva }))
|
|
1173
|
+
if (r.ok) { flash(st, i.loginChanged); await refreshLogins(term, st, api) }
|
|
1174
|
+
}
|
|
1175
|
+
})
|
|
1176
|
+
}
|
|
1177
|
+
})
|
|
1178
|
+
} else if (ch === 'x' && cur) {
|
|
1179
|
+
if (!(cur.sessions || []).length) { flash(st, i.loginNoSessions, 'warn'); return true }
|
|
1180
|
+
const r = await guard(term, st, i.loginClosing, () => api.closeLoginSession(cur.user))
|
|
1181
|
+
if (r.ok) { flash(st, i.loginClosed(r.v?.closed ?? (cur.sessions || []).length)); await refreshLogins(term, st, api) }
|
|
1182
|
+
} else if (ch === 'u' && cur) {
|
|
1183
|
+
if (!(cur.blockedUntil > Date.now())) { flash(st, i.loginNotBlocked, 'warn'); return true }
|
|
1184
|
+
const r = await guard(term, st, i.loginUnblocking, () => api.unblockLogin(cur.user))
|
|
1185
|
+
if (r.ok) { flash(st, i.loginUnblocked); await refreshLogins(term, st, api) }
|
|
1186
|
+
} else if ((ch === 'v' || key.name === 'delete') && cur) {
|
|
1187
|
+
setConfirm(st, {
|
|
1188
|
+
text: i.loginRemoveConfirm(cur.user),
|
|
1189
|
+
onYes: async () => {
|
|
1190
|
+
st.confirm = null
|
|
1191
|
+
const r = await guard(term, st, i.loginRemoving, () => api.removeLogin(cur.user))
|
|
1192
|
+
if (r.ok) { flash(st, i.loginRemoved(cur.user)); st.sel.logins = 0; await refreshLogins(term, st, api) }
|
|
1193
|
+
},
|
|
1194
|
+
onNo: () => { st.confirm = null }
|
|
1195
|
+
})
|
|
1196
|
+
} else if (key.name === 'f5') {
|
|
1197
|
+
await refreshLogins(term, st, api)
|
|
1198
|
+
}
|
|
1199
|
+
return true
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1050
1202
|
/**
|
|
1051
1203
|
* Tira la cuenta que NACIÓ para un emparejamiento que no llegó a término y vuelve a la que
|
|
1052
1204
|
* estabas usando. Es una cuenta recién creada y vacía —el aparato nunca entró—, así que no
|
|
@@ -1794,8 +1946,8 @@ async function onConfirmKey (st, key) {
|
|
|
1794
1946
|
|
|
1795
1947
|
// Pestañas INTERNAS de una bóveda ya elegida: se cambian con ←→. La lista de
|
|
1796
1948
|
// bóvedas (profiles) es el nivel de arriba (se entra con Enter, no es una pestaña).
|
|
1797
|
-
const INNER_TABS = ['devices', 'secrets', 'me']
|
|
1798
|
-
const tabLabel = (i, k) => ({ devices: i.tabDevices, secrets: i.tabSecrets, me: i.tabMe })[k]
|
|
1949
|
+
const INNER_TABS = ['devices', 'secrets', 'logins', 'me']
|
|
1950
|
+
const tabLabel = (i, k) => ({ devices: i.tabDevices, secrets: i.tabSecrets, logins: i.tabLogins, me: i.tabMe })[k]
|
|
1799
1951
|
|
|
1800
1952
|
/**
|
|
1801
1953
|
* Las teclas que se pueden usar AHORA, no el catálogo entero. Aprobar/rechazar sin nadie
|
|
@@ -1807,6 +1959,7 @@ const helpSegs = (i, screen, st = {}) => {
|
|
|
1807
1959
|
profiles: i.helpProfiles,
|
|
1808
1960
|
devices: i.helpDevices,
|
|
1809
1961
|
secrets: i.helpSecrets,
|
|
1962
|
+
logins: i.helpLogins,
|
|
1810
1963
|
pairing: i.helpPairing,
|
|
1811
1964
|
pairmode: i.helpPairMode,
|
|
1812
1965
|
join: i.helpJoin,
|
|
@@ -1819,12 +1972,18 @@ const helpSegs = (i, screen, st = {}) => {
|
|
|
1819
1972
|
// el único que las lee), y las filas seleccionables de la lista son justo los aparatos.
|
|
1820
1973
|
const devs = mergeMembersAndCerts(st.members, st.devices?.issued || [])
|
|
1821
1974
|
const cur = devs[Math.min(st.sel?.devices || 0, devs.length - 1)]
|
|
1975
|
+
// El inicio de sesión señalado: `x cerrar` solo tiene sentido si tiene alguna abierta, y
|
|
1976
|
+
// `u quitar espera` solo si está esperando.
|
|
1977
|
+
const lg = (st.logins || [])[Math.min(st.sel?.logins || 0, Math.max(0, (st.logins || []).length - 1))]
|
|
1822
1978
|
return segs({
|
|
1823
1979
|
pending: !!st.pending,
|
|
1824
1980
|
hasDevices: (st.devices?.issued || []).length > 0,
|
|
1825
1981
|
isService: !!cur?.cn,
|
|
1826
1982
|
hasSecrets: Object.keys(st.secrets?.ns || {}).length > 0,
|
|
1827
|
-
hasVars: devVarsOf(st, st.varsFor?.pub).length > 0
|
|
1983
|
+
hasVars: devVarsOf(st, st.varsFor?.pub).length > 0,
|
|
1984
|
+
hasLogins: (st.logins || []).length > 0,
|
|
1985
|
+
hasSessions: ((lg?.sessions) || []).length > 0,
|
|
1986
|
+
blocked: (lg?.blockedUntil || 0) > Date.now()
|
|
1828
1987
|
})
|
|
1829
1988
|
}
|
|
1830
1989
|
|
|
@@ -1843,7 +2002,9 @@ function renderTabs (st, t) {
|
|
|
1843
2002
|
return INNER_TABS.map((k) => {
|
|
1844
2003
|
const active = st.screen === k
|
|
1845
2004
|
return active ? t.bold(t.accent('▐ ' + tabLabel(i, k) + ' ▌')) : t.muted(' ' + tabLabel(i, k) + ' ')
|
|
1846
|
-
|
|
2005
|
+
// UN espacio entre pestañas, no tres: con cuatro ya no cabía el «(←→ cambiar)» en 80
|
|
2006
|
+
// columnas, y lo que se perdía era justamente la pista de que se puede cambiar.
|
|
2007
|
+
}).join(' ') + t.muted(i.tabsHint)
|
|
1847
2008
|
}
|
|
1848
2009
|
|
|
1849
2010
|
function pairingBody (st, t, cols, height) {
|
|
@@ -1943,6 +2104,7 @@ function render (term, st) {
|
|
|
1943
2104
|
if (st.screen === 'profiles') body = renderList(profileRows(st, t), st.sel.profiles, contentH, cols, t, scrollRef)
|
|
1944
2105
|
else if (st.screen === 'devices') body = renderList(deviceRows(st, t), st.sel.devices, contentH, cols, t, scrollRef)
|
|
1945
2106
|
else if (st.screen === 'secrets') body = renderList(secretRows(st, t), st.sel.secrets, contentH, cols, t, scrollRef)
|
|
2107
|
+
else if (st.screen === 'logins') body = renderList(loginRows(st, t), st.sel.logins || 0, contentH, cols, t, scrollRef)
|
|
1946
2108
|
else if (st.screen === 'me') body = renderList(meRows(st, t), -1, contentH, cols, t, scrollRef)
|
|
1947
2109
|
else if (st.screen === 'caps') body = renderList(capsRows(st, t), st.sel.caps || 0, contentH, cols, t, scrollRef)
|
|
1948
2110
|
else if (st.screen === 'devvars') body = renderList(devVarRows(st, t), st.sel.devvars || 0, contentH, cols, t, scrollRef)
|
|
@@ -2029,7 +2191,7 @@ export async function runTui () {
|
|
|
2029
2191
|
const st = {
|
|
2030
2192
|
screen: 'profiles', // se arranca en la lista de bóvedas: hay que ENTRAR a una
|
|
2031
2193
|
lang: loadLang(), // es/en — se conmuta con `l` y se recuerda en prefs.json
|
|
2032
|
-
sel: { profiles: 0, devices: 0, secrets: 0, pairmode: 0, devvars: 0 },
|
|
2194
|
+
sel: { profiles: 0, devices: 0, secrets: 0, pairmode: 0, devvars: 0, logins: 0 },
|
|
2033
2195
|
// Las bóvedas que ha abierto ESTA sesión, para volver a cerrarlas al salir.
|
|
2034
2196
|
unlockedHere: new Set(),
|
|
2035
2197
|
// Su contraseña, SOLO en memoria y SOLO mientras la TUI esté abierta: sirve para
|
|
@@ -2040,6 +2202,8 @@ export async function runTui () {
|
|
|
2040
2202
|
profiles: null,
|
|
2041
2203
|
devices: null,
|
|
2042
2204
|
secrets: null,
|
|
2205
|
+
// Los inicios de sesión se piden al ENTRAR en su pestaña, no al arrancar.
|
|
2206
|
+
logins: null,
|
|
2043
2207
|
pending: null,
|
|
2044
2208
|
pairing: null,
|
|
2045
2209
|
state: null,
|
|
@@ -2107,6 +2271,7 @@ export async function runTui () {
|
|
|
2107
2271
|
// El perfil se pide al ENTRAR en su pestaña, no al arrancar: es contenido del
|
|
2108
2272
|
// usuario y no hay por qué sacarlo del cifrado si nadie lo está mirando.
|
|
2109
2273
|
if (st.screen === 'me' && st.me === undefined) await refreshMe(term, st)
|
|
2274
|
+
if (st.screen === 'logins' && st.logins === null) await refreshLogins(term, st)
|
|
2110
2275
|
continue
|
|
2111
2276
|
}
|
|
2112
2277
|
// Esc/'b' desde una pestaña vuelve a la lista de bóvedas (salir de la bóveda
|
|
@@ -2118,6 +2283,7 @@ export async function runTui () {
|
|
|
2118
2283
|
if (st.screen === 'profiles') running = await onKeyProfiles(term, st, key)
|
|
2119
2284
|
else if (st.screen === 'devices') running = await onKeyDevices(term, st, key)
|
|
2120
2285
|
else if (st.screen === 'secrets') running = await onKeySecrets(term, st, key)
|
|
2286
|
+
else if (st.screen === 'logins') running = await onKeyLogins(term, st, key)
|
|
2121
2287
|
else if (st.screen === 'me') running = await onKeyMe(term, st, key)
|
|
2122
2288
|
else if (st.screen === 'caps') running = await onKeyCaps(term, st, key)
|
|
2123
2289
|
else if (st.screen === 'devvars') running = await onKeyDevVars(term, st, key)
|
|
@@ -2138,4 +2304,4 @@ export async function runTui () {
|
|
|
2138
2304
|
}
|
|
2139
2305
|
|
|
2140
2306
|
// Solo para pruebas headless (render sin terminal real). No usar en runtime.
|
|
2141
|
-
export const __test = { render, onKeySecrets, activeLocked, autoLockedIds, autoLockWakeIn, forgetAutoLocked, autoLockMin, refreshAll, ensureUnlocked, profileRows, deviceRows, secretRows, devVarRows, meRows, capsRows, onKeyCaps, pairModeRows, pairingBody, scrollBody, fitHelp, wrapHelp, wrapWords, joinBody, onKeyJoin, promptJoin, onKeyProfiles, onInputKey, toggleLang, mergeMembersAndCerts, seguirAqui, resetToque: () => { ultimoToque = 0 } }
|
|
2307
|
+
export const __test = { render, onKeySecrets, loginRows, onKeyLogins, refreshLogins, activeLocked, autoLockedIds, autoLockWakeIn, forgetAutoLocked, autoLockMin, refreshAll, ensureUnlocked, profileRows, deviceRows, secretRows, devVarRows, meRows, capsRows, onKeyCaps, pairModeRows, pairingBody, scrollBody, fitHelp, wrapHelp, wrapWords, joinBody, onKeyJoin, promptJoin, onKeyProfiles, onInputKey, toggleLang, mergeMembersAndCerts, seguirAqui, resetToque: () => { ultimoToque = 0 } }
|
package/src/tui/i18n.js
CHANGED
|
@@ -33,6 +33,44 @@ const es = {
|
|
|
33
33
|
tooSmallHint: (cols, rows) => `Agranda a ≥ 24×9 (hay ${cols}×${rows}).`,
|
|
34
34
|
|
|
35
35
|
// pestañas y títulos
|
|
36
|
+
// ----- ENTRAR CON USUARIO Y CONTRASEÑA (`docs/temporary-access.md`) -----
|
|
37
|
+
tabLogins: 'Entrar',
|
|
38
|
+
noLogins: 'Todavía no hay ninguno. Con «n» creas uno.',
|
|
39
|
+
loginsHint: 'Un aparato de esta cuenta que se abre escribiendo una dirección y una contraseña, sin conectar nada. Sirve para entrar desde un equipo prestado.',
|
|
40
|
+
loginOpen: (n) => `${n} abierto${n === 1 ? '' : 's'}`,
|
|
41
|
+
loginWait: (s) => `espera ${s} s`,
|
|
42
|
+
loginCreated: (d) => `creado ${d}`,
|
|
43
|
+
helpLogins: ({ hasLogins, hasSessions, blocked } = {}) => [
|
|
44
|
+
'←→ pestaña', '↑↓', 'n nuevo',
|
|
45
|
+
...(hasSessions ? ['x cerrar sesiones'] : []),
|
|
46
|
+
...(blocked ? ['u quitar espera'] : []),
|
|
47
|
+
...(hasLogins ? ['c contraseña', 'v quitar'] : []),
|
|
48
|
+
'F5 refrescar', 'Esc bóvedas', 'l English', 'q salir'
|
|
49
|
+
],
|
|
50
|
+
loginUserLabel: '¿Qué nombre de usuario?',
|
|
51
|
+
loginUserHint: 'letras y números, en minúscula (Esc cancela)',
|
|
52
|
+
loginLabelLabel: (u) => `¿De dónde va a entrar «${u}»?`,
|
|
53
|
+
loginLabelHint: 'lo que vas a leer aquí para decidir si cerrarlo (Esc cancela)',
|
|
54
|
+
loginPassLabel: (u) => `Contraseña para «${u}»`,
|
|
55
|
+
loginPassAgain: 'Escríbela otra vez',
|
|
56
|
+
loginPassHint: 'mínimo 12 caracteres; no se puede recuperar (Esc cancela)',
|
|
57
|
+
loginPassShort: 'La contraseña tiene que tener al menos 12 caracteres.',
|
|
58
|
+
loginPassMismatch: 'No coinciden. Inténtalo otra vez.',
|
|
59
|
+
loginOldPass: (u) => `Contraseña actual de «${u}»`,
|
|
60
|
+
loginNewPass: 'Contraseña nueva',
|
|
61
|
+
loginCreating: 'Creando el inicio de sesión…',
|
|
62
|
+
loginCreatedOk: (a) => `Listo. La dirección es ${a}`,
|
|
63
|
+
loginChanging: 'Cambiando la contraseña…',
|
|
64
|
+
loginChanged: 'Contraseña cambiada.',
|
|
65
|
+
loginClosing: 'Cerrando las sesiones…',
|
|
66
|
+
loginClosed: (n) => `${n} sesión(es) cerrada(s).`,
|
|
67
|
+
loginUnblocking: 'Quitando la espera…',
|
|
68
|
+
loginUnblocked: 'Ya puede volver a intentarlo.',
|
|
69
|
+
loginRemoving: 'Quitándolo…',
|
|
70
|
+
loginRemoved: (u) => `«${u}» se fue, y su llave salió del acta.`,
|
|
71
|
+
loginRemoveConfirm: (u) => `¿Quitar «${u}»? Su llave sale del acta y ese equipo deja de entrar.`,
|
|
72
|
+
loginNoSessions: 'Ese no tiene ninguna sesión abierta.',
|
|
73
|
+
loginNotBlocked: 'Ese no está esperando.',
|
|
36
74
|
tabDevices: 'Dispositivos',
|
|
37
75
|
tabSecrets: 'Scopes y variables',
|
|
38
76
|
tabMe: 'Perfil',
|
|
@@ -403,6 +441,43 @@ const en = {
|
|
|
403
441
|
tooSmall: 'Terminal too small',
|
|
404
442
|
tooSmallHint: (cols, rows) => `Resize to ≥ 24×9 (now ${cols}×${rows}).`,
|
|
405
443
|
|
|
444
|
+
tabLogins: 'Sign in',
|
|
445
|
+
noLogins: 'None yet. Press “n” to create one.',
|
|
446
|
+
loginsHint: 'A device of this account that opens by typing an address and a password, with nothing to plug in. It is how you sign in from a borrowed computer.',
|
|
447
|
+
loginOpen: (n) => `${n} open`,
|
|
448
|
+
loginWait: (s) => `wait ${s} s`,
|
|
449
|
+
loginCreated: (d) => `created ${d}`,
|
|
450
|
+
helpLogins: ({ hasLogins, hasSessions, blocked } = {}) => [
|
|
451
|
+
'←→ tab', '↑↓', 'n new',
|
|
452
|
+
...(hasSessions ? ['x close sessions'] : []),
|
|
453
|
+
...(blocked ? ['u clear wait'] : []),
|
|
454
|
+
...(hasLogins ? ['c password', 'v remove'] : []),
|
|
455
|
+
'F5 refresh', 'Esc vaults', 'l español', 'q quit'
|
|
456
|
+
],
|
|
457
|
+
loginUserLabel: 'Which username?',
|
|
458
|
+
loginUserHint: 'lowercase letters and numbers (Esc cancels)',
|
|
459
|
+
loginLabelLabel: (u) => `Where will “${u}” sign in from?`,
|
|
460
|
+
loginLabelHint: 'what you will read here to decide whether to close it (Esc cancels)',
|
|
461
|
+
loginPassLabel: (u) => `Password for “${u}”`,
|
|
462
|
+
loginPassAgain: 'Type it again',
|
|
463
|
+
loginPassHint: 'at least 12 characters; it cannot be recovered (Esc cancels)',
|
|
464
|
+
loginPassShort: 'The password must be at least 12 characters.',
|
|
465
|
+
loginPassMismatch: 'They do not match. Try again.',
|
|
466
|
+
loginOldPass: (u) => `Current password for “${u}”`,
|
|
467
|
+
loginNewPass: 'New password',
|
|
468
|
+
loginCreating: 'Creating the sign-in…',
|
|
469
|
+
loginCreatedOk: (a) => `Done. The address is ${a}`,
|
|
470
|
+
loginChanging: 'Changing the password…',
|
|
471
|
+
loginChanged: 'Password changed.',
|
|
472
|
+
loginClosing: 'Closing the sessions…',
|
|
473
|
+
loginClosed: (n) => `${n} session(s) closed.`,
|
|
474
|
+
loginUnblocking: 'Clearing the wait…',
|
|
475
|
+
loginUnblocked: 'It can try again now.',
|
|
476
|
+
loginRemoving: 'Removing it…',
|
|
477
|
+
loginRemoved: (u) => `“${u}” is gone, and its key left the account record.`,
|
|
478
|
+
loginRemoveConfirm: (u) => `Remove “${u}”? Its key leaves the account record and that computer stops getting in.`,
|
|
479
|
+
loginNoSessions: 'That one has no open sessions.',
|
|
480
|
+
loginNotBlocked: 'That one is not waiting.',
|
|
406
481
|
tabDevices: 'Devices',
|
|
407
482
|
tabSecrets: 'Scopes & variables',
|
|
408
483
|
tabMe: 'Profile',
|
package/src/update.js
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* update.js — lo que es DEL VAULT al actualizarse, y nada más.
|
|
3
|
+
*
|
|
4
|
+
* La mecánica —comparar versiones, preguntar a GitHub, bajar y verificar contra sigstore—
|
|
5
|
+
* vive en `@dotrino/update`, porque es la misma para el proxio, geo, los agentes y los
|
|
6
|
+
* comandos (CONVENCIONES §15). Aquí solo queda lo que sabe el vault de sí mismo: en qué
|
|
7
|
+
* repo sale y cómo se llaman sus archivos.
|
|
8
|
+
*
|
|
9
|
+
* Antes de existir el paquete esto era una copia entera de esa mecánica. Dos copias de lo
|
|
10
|
+
* mismo son dos sitios donde arreglar el mismo fallo, y el segundo siempre se olvida.
|
|
11
|
+
*/
|
|
12
|
+
import fs from 'node:fs'
|
|
13
|
+
import { latestVersion, isNewer, CHECK_EVERY_MS } from '@dotrino/update'
|
|
14
|
+
import { pickAsset, download, verifyArtifact as verify } from '@dotrino/update/fetch'
|
|
15
|
+
import { VERSION } from './version.js'
|
|
16
|
+
|
|
17
|
+
export const REPO = 'imdotrino/dotrino-vault'
|
|
18
|
+
export { isNewer, CHECK_EVERY_MS, download }
|
|
19
|
+
|
|
20
|
+
/** La última release del vault. `{ ok:false, reason }` si no se pudo mirar, que NO es «al día». */
|
|
21
|
+
export const latestRelease = (opts = {}) =>
|
|
22
|
+
latestVersion({ source: 'github', repo: REPO, product: 'dotrino-vault', version: VERSION, ...opts })
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Qué archivo le toca a esta máquina. El `.deb` donde hay `dpkg`; en cualquier otro Linux,
|
|
26
|
+
* el tarball. Fuera de Linux no se emite binario, y se dice en vez de bajar algo inútil.
|
|
27
|
+
*/
|
|
28
|
+
export function assetFor (assets, { platform = process.platform, arch = process.arch, hasDpkg = null } = {}) {
|
|
29
|
+
if (platform !== 'linux') return { ok: false, reason: `there is no binary for ${platform}: update with npm (npx @dotrino/vaultd)` }
|
|
30
|
+
if (arch !== 'x64') return { ok: false, reason: `there is no binary for ${arch}` }
|
|
31
|
+
const deb = hasDpkg === null ? fs.existsSync('/usr/bin/dpkg') : hasDpkg
|
|
32
|
+
return pickAsset(assets, [
|
|
33
|
+
{ kind: 'deb', re: /_amd64\.deb$/, when: () => deb },
|
|
34
|
+
{ kind: 'tar', re: /-linux-x64\.tar\.gz$/ }
|
|
35
|
+
])
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Verifica contra la atestación del release del vault. Sin `gh`, no se instala. */
|
|
39
|
+
export const verifyArtifact = (file, opts = {}) => verify(file, { repo: REPO, ...opts })
|
package/src/vault.js
CHANGED
|
@@ -2745,28 +2745,28 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
2745
2745
|
*/
|
|
2746
2746
|
async function incompleteMembers () {
|
|
2747
2747
|
if (secrets.isLegacy?.()) return []
|
|
2748
|
-
|
|
2749
|
-
|
|
2750
|
-
|
|
2751
|
-
|
|
2752
|
-
|
|
2753
|
-
|
|
2754
|
-
|
|
2755
|
-
|
|
2756
|
-
|
|
2757
|
-
//
|
|
2758
|
-
//
|
|
2759
|
-
const
|
|
2760
|
-
const
|
|
2761
|
-
|
|
2762
|
-
|
|
2763
|
-
|
|
2764
|
-
const
|
|
2765
|
-
|
|
2748
|
+
// A QUIÉN LE FALTA ALGO LO DICE `recipientsOf`, Y SOLO ÉL — la misma lista con la que
|
|
2749
|
+
// se envuelve al escribir y se rehace el llavero al abrir. Aquí había otra: «un aparato
|
|
2750
|
+
// sin `cn` puede destapar cualquier cajón sin dueño». Para `@me` no es cierto —el
|
|
2751
|
+
// perfil se envuelve a quien puede FIRMAR por ti—, así que un aparato sin `firma` salía
|
|
2752
|
+
// con todos los datos del perfil «sin llave todavía», y abrir la bóveda, que es lo que
|
|
2753
|
+
// el aviso pedía, no podía apagarlo: el llavero ya estaba como decía el acta. Dos
|
|
2754
|
+
// listas para la misma pregunta acaban siempre así.
|
|
2755
|
+
const out = new Map()
|
|
2756
|
+
for (const owner of secrets.owners()) {
|
|
2757
|
+
// Por VISIBILIDAD: a quien administra se le envuelve la pública de un cajón con dueño
|
|
2758
|
+
// y no la privada. La lista de las públicas contiene siempre a la de las privadas.
|
|
2759
|
+
const privados = new Set((await recipientsOf(owner)).map((m) => m.pub))
|
|
2760
|
+
for (const m of await recipientsOf(owner, { public: true })) {
|
|
2761
|
+
if (!m.encPub || m.pub === master) continue
|
|
2762
|
+
const missing = secrets.missingFor(owner, m.pub, { only: (esPublica) => esPublica || privados.has(m.pub) })
|
|
2763
|
+
if (!missing.length) continue
|
|
2764
|
+
const row = out.get(m.pub) || { pub: m.pub, cn: m.cn || null, owners: {} }
|
|
2765
|
+
row.owners[owner] = missing
|
|
2766
|
+
out.set(m.pub, row)
|
|
2766
2767
|
}
|
|
2767
|
-
if (Object.keys(owners).length) out.push({ pub: m.pub, cn: m.cn || null, owners })
|
|
2768
2768
|
}
|
|
2769
|
-
return out
|
|
2769
|
+
return [...out.values()]
|
|
2770
2770
|
}
|
|
2771
2771
|
|
|
2772
2772
|
/**
|
|
@@ -3552,6 +3552,22 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
3552
3552
|
// (`unattended`), no una lista de esta máquina — por eso ya no hay `setApproval` ni
|
|
3553
3553
|
// `supervised`: se concede y se quita como cualquier otro permiso, con `caps`.
|
|
3554
3554
|
needsApproval: async (pub) => needsApproval(pub, await refreshActa()),
|
|
3555
|
+
/**
|
|
3556
|
+
* QUIÉN PUEDE APROBAR EN ESTA CUENTA. Lo usa la actualización (§15): la bóveda pide
|
|
3557
|
+
* permiso al teléfono **solo si hay a quién pedírselo**; si no hay ningún aparato con
|
|
3558
|
+
* `aprueba`, se actualiza sola.
|
|
3559
|
+
*
|
|
3560
|
+
* Es la regla del dueño (2026-09-20) y evita la trampa evidente: una bóveda sin
|
|
3561
|
+
* aprobadores que se quedaría sin poder actualizarse nunca, esperando un sí que nadie
|
|
3562
|
+
* puede dar. Mismo sitio del que ya sale el «rang N approver(s)» de los secretos.
|
|
3563
|
+
*/
|
|
3564
|
+
approvers: async () => {
|
|
3565
|
+
const record = await refreshActa()
|
|
3566
|
+
if (!record) return []
|
|
3567
|
+
return (record.members || [])
|
|
3568
|
+
.filter((m) => Acta.memberCan(record, m.pub, 'approve'))
|
|
3569
|
+
.map((m) => ({ id: m.id, label: m.label || '', pub: m.pub }))
|
|
3570
|
+
},
|
|
3555
3571
|
// La bóveda de contraseñas (`passwords.js`). Aquí SÍ se lista: es donde está la
|
|
3556
3572
|
// llave. Lo que no puede es listarla un aparato.
|
|
3557
3573
|
passwordDevices,
|
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
|
// ---------------------------------------------------------------------------
|