@dotrino/vaultd 0.51.0 → 0.53.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/README.md +14 -7
- package/lib/src/enroll.js +1 -1
- package/lib/src/protocol.js +5 -1
- package/package.json +2 -2
- package/src/ctl.js +23 -41
- package/src/daemon.js +37 -13
- package/src/manager.js +8 -2
- package/src/profiles.js +66 -9
- package/src/tui/app.js +78 -3
- package/src/tui/i18n.js +10 -4
- package/src/vault.js +93 -77
package/README.md
CHANGED
|
@@ -456,18 +456,25 @@ y así un reinicio del PC nunca deja tus apps muertas esperando a que alguien te
|
|
|
456
456
|
```sh
|
|
457
457
|
dotrino-vault profile password # pone o cambia la contraseña (te la pregunta)
|
|
458
458
|
dotrino-vault profile password rm # la quita
|
|
459
|
-
dotrino-vault unlock # abre la bóveda en esta consola
|
|
460
|
-
dotrino-vault lock # vuelve a cerrarla
|
|
459
|
+
dotrino-vault unlock # abre la bóveda en esta consola (5 min sin usarse y se cierra sola)
|
|
460
|
+
dotrino-vault lock # vuelve a cerrarla ya
|
|
461
461
|
```
|
|
462
462
|
|
|
463
463
|
Lo único que se sigue viendo con el candado puesto es que **existe** y que está cerrada
|
|
464
464
|
(`status`, `profile ls`): si no, no habría forma de saber qué abrir.
|
|
465
465
|
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
466
|
+
**Se vuelve a cerrar solo a los 5 minutos sin usarse**, además de al reiniciar el
|
|
467
|
+
servicio o con `dotrino-vault lock`. El plazo se cuenta desde la última cosa que hiciste
|
|
468
|
+
en la consola, no desde que la abriste: mientras trabajas no te echa, y en cuanto te
|
|
469
|
+
levantas de la silla se cierra. Lo que piden tus aparatos por el proxy **no** cuenta como
|
|
470
|
+
uso —el candado no es suyo—, así que siguen funcionando igual mientras la consola se
|
|
471
|
+
cierra. La TUI, además, **olvida la contraseña** en ese momento: si no, la reabriría sola
|
|
472
|
+
a la siguiente tecla.
|
|
473
|
+
|
|
474
|
+
La contraseña **no se guarda**: solo un verificador con sal (scrypt), igual que el
|
|
475
|
+
candado del navegador. Tiene un mínimo de 12 caracteres —varias palabras al azar— y,
|
|
476
|
+
tras 5 intentos fallidos, cada intento nuevo espera cada vez más (hasta 5 minutos); la
|
|
477
|
+
cuenta de fallos se guarda, así que reiniciar no la borra.
|
|
471
478
|
|
|
472
479
|
Con el perfil bloqueado, la CLI **no** te pide la contraseña sobre la marcha: cualquier
|
|
473
480
|
comando que mire o toque esa bóveda falla con «perfil bloqueado» y hay que correr
|
package/lib/src/enroll.js
CHANGED
|
@@ -52,7 +52,7 @@ export const MSG_REVOKED = 'vault.revoked'
|
|
|
52
52
|
export const MSG_ERROR = 'vault.error'
|
|
53
53
|
|
|
54
54
|
/** Los scopes del cert se corresponden 1:1 con las capacidades del acta (§D7). */
|
|
55
|
-
const SCOPE_TO_CAP = { 'vault:sign': 'sign', 'vault:store': 'store', 'vault:read': 'read', 'vault:admin': 'admin' }
|
|
55
|
+
const SCOPE_TO_CAP = { 'vault:sign': 'sign', 'vault:store': 'store', 'vault:read': 'read', 'vault:admin': 'admin', 'vault:passwords': 'passwords' }
|
|
56
56
|
export const scopeToCaps = (scope) =>
|
|
57
57
|
(Array.isArray(scope) ? scope : [scope]).map((s) => SCOPE_TO_CAP[s]).filter(Boolean)
|
|
58
58
|
|
package/lib/src/protocol.js
CHANGED
|
@@ -98,7 +98,11 @@ export const SCOPE = Object.freeze({
|
|
|
98
98
|
// NO incluye cambiar permisos, traspasar el mando ni conceder `admin`: eso es el rol
|
|
99
99
|
// de master y sigue siendo local. No se empareja — se concede desde el PC.
|
|
100
100
|
ADMIN: 'vault:admin',
|
|
101
|
-
APPROVE: 'vault:approve' // aprobar pedidos de secretos (cajones con `approval`); se concede a mano, como admin
|
|
101
|
+
APPROVE: 'vault:approve', // aprobar pedidos de secretos (cajones con `approval`); se concede a mano, como admin
|
|
102
|
+
// El gestor de contraseñas: pedir credenciales de la bóveda, de a una y por dominio.
|
|
103
|
+
// Nunca lista la bóveda entera. Este SÍ se empareja (`pair --scope contrasenas`): es
|
|
104
|
+
// lo primero que hace la extensión, y no tendría sentido obligar a un segundo paso.
|
|
105
|
+
PASSWORDS: 'vault:passwords'
|
|
102
106
|
})
|
|
103
107
|
|
|
104
108
|
/**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dotrino/vaultd",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.53.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": {
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
"node": ">=20"
|
|
21
21
|
},
|
|
22
22
|
"dependencies": {
|
|
23
|
-
"@dotrino/identity": "^0.
|
|
23
|
+
"@dotrino/identity": "^0.61.0",
|
|
24
24
|
"@dotrino/passmanager": "^0.1.0",
|
|
25
25
|
"@dotrino/proxy-client": "^0.13.1",
|
|
26
26
|
"ws": "^8.18.0"
|
package/src/ctl.js
CHANGED
|
@@ -195,16 +195,19 @@ async function cmdPair (args = []) {
|
|
|
195
195
|
if (scIdx >= 0) {
|
|
196
196
|
const raw = args[scIdx + 1]
|
|
197
197
|
if (!raw || raw.startsWith('-')) { console.error('uso: dotrino-vault pair --scope sign,read,store,secrets:<ns>'); process.exit(2) }
|
|
198
|
-
const ALIAS = { firma: 'sign', lee: 'read', guarda: 'store' }
|
|
198
|
+
const ALIAS = { firma: 'sign', lee: 'read', guarda: 'store', contrasenas: 'passwords', 'contraseñas': 'passwords' }
|
|
199
199
|
scope = []
|
|
200
200
|
for (const tok of raw.split(',').map((t) => t.trim()).filter(Boolean)) {
|
|
201
201
|
const t = ALIAS[tok] || tok
|
|
202
202
|
if (t === 'admin' || t === 'administra') { console.error('`admin` no se empareja: concédelo desde el PC con dotrino-vault caps <ID> +administra'); process.exit(2) }
|
|
203
203
|
if (t === 'approve' || t === 'aprueba') { console.error('`approve` no se empareja: concédelo desde el PC con dotrino-vault caps <ID> +aprueba'); process.exit(2) }
|
|
204
204
|
if (t === 'sign' || t === 'read' || t === 'store') { scope.push('vault:' + t); continue }
|
|
205
|
+
// El gestor de contraseñas SÍ se empareja con su permiso puesto: es lo único que
|
|
206
|
+
// va a hacer ese aparato, y pedirlo en dos pasos era el paso que nadie daba.
|
|
207
|
+
if (t === 'passwords') { scope.push('vault:passwords'); continue }
|
|
205
208
|
const m = /^secrets:([a-z0-9-]{1,32})$/.exec(t)
|
|
206
209
|
if (m) { scope.push('vault:secrets:' + m[1]); continue }
|
|
207
|
-
console.error('permiso desconocido: %s (sign | read | store | secrets:<ns>)', tok); process.exit(2)
|
|
210
|
+
console.error('permiso desconocido: %s (sign | read | store | contrasenas | secrets:<ns>)', tok); process.exit(2)
|
|
208
211
|
}
|
|
209
212
|
if (service) scope.push('vault:secrets:' + service)
|
|
210
213
|
scope = [...new Set(scope)]
|
|
@@ -404,7 +407,7 @@ async function cmdMembers () {
|
|
|
404
407
|
// se mira quién es quién — y en la lista de variables ya seria tarde.
|
|
405
408
|
if (m.cn && !m.canSeal) console.log(' %ssin llave de cifrado: NO puede leer sus variables%s', R, Z)
|
|
406
409
|
}
|
|
407
|
-
console.log('\n Cambiar permisos: dotrino-vault caps <ID> +firma | -firma | +guarda | -guarda | +lee | -lee | +administra | +aprueba | +permiso')
|
|
410
|
+
console.log('\n Cambiar permisos: dotrino-vault caps <ID> +firma | -firma | +guarda | -guarda | +lee | -lee | +administra | +aprueba | +contrasenas | +permiso')
|
|
408
411
|
console.log(' «Permiso»: ese aparato solo recibe claves privadas cuando lo apruebas desde un aparato con «aprueba» (en cada arranque).')
|
|
409
412
|
console.log(' «Administra» deja conectar y quitar dispositivos desde ese aparato, sin venir aquí.')
|
|
410
413
|
console.log(' No deja cambiar permisos ni traspasar el mando: eso solo se hace en esta máquina.')
|
|
@@ -463,42 +466,20 @@ async function cmdApproval (args = []) {
|
|
|
463
466
|
: `Listo: ${m.id} vuelve a recibir sus claves sin aprobación.`)
|
|
464
467
|
}
|
|
465
468
|
|
|
466
|
-
/**
|
|
467
|
-
* `dotrino-vault passwords <ID> on|off` — deja (o no) que ese aparato pida contraseñas
|
|
468
|
-
* de tu bóveda.
|
|
469
|
-
*
|
|
470
|
-
* Es una lista de ESTA bóveda, no del acta, por lo mismo que `approval`: es ella la que
|
|
471
|
-
* entrega. El acta manda igual — si revocas el aparato allí, deja de pedir aunque siga
|
|
472
|
-
* en esta lista. Y si además le pones `approval on`, cada arranque suyo te pedirá el
|
|
473
|
-
* visto bueno en el teléfono.
|
|
474
|
-
*/
|
|
475
|
-
async function cmdPasswords (args = []) {
|
|
476
|
-
const [id, val] = args
|
|
477
|
-
if (!id || !['on', 'off'].includes(val)) {
|
|
478
|
-
console.error('uso: dotrino-vault passwords <ID> on|off')
|
|
479
|
-
process.exit(1)
|
|
480
|
-
}
|
|
481
|
-
const m = await findMember(id)
|
|
482
|
-
writeReq('secret-request.json', { op: 'passwords', pub: m.pub, id: m.id, label: m.label || '', on: val === 'on' })
|
|
483
|
-
sendSignal(requireDaemon().pid, 'SIGUSR2')
|
|
484
|
-
if (val === 'on') {
|
|
485
|
-
console.log(`Listo: ${m.id} puede pedir contraseñas de tu bóveda (de una en una; nunca la lista entera).`)
|
|
486
|
-
console.log('Reinicia el vault: al arrancar imprime el código de enlace que se pega en la extensión.')
|
|
487
|
-
} else {
|
|
488
|
-
console.log(`Listo: ${m.id} deja de poder pedir contraseñas.`)
|
|
489
|
-
}
|
|
490
|
-
}
|
|
491
|
-
|
|
492
469
|
/** `dotrino-vault caps <ID> ±permiso` — cambia lo que puede hacer un dispositivo. */
|
|
493
470
|
async function cmdCaps (args = []) {
|
|
494
471
|
const [id, ...changes] = args
|
|
495
472
|
if (!id || !changes.length) {
|
|
496
|
-
console.error('uso: dotrino-vault caps <ID> +firma|-firma|+guarda|-guarda|+lee|-lee|+administra|-administra|+aprueba|-aprueba|+permiso|-permiso')
|
|
473
|
+
console.error('uso: dotrino-vault caps <ID> +firma|-firma|+guarda|-guarda|+lee|-lee|+administra|-administra|+aprueba|-aprueba|+contrasenas|-contrasenas|+permiso|-permiso')
|
|
497
474
|
process.exit(2)
|
|
498
475
|
}
|
|
499
476
|
const CAP_BY_WORD = {
|
|
500
477
|
firma: 'sign', guarda: 'store', lee: 'read', administra: 'admin', aprueba: 'approve',
|
|
501
|
-
|
|
478
|
+
// `contraseñas`: el gestor (la extensión, la app del teléfono) puede PEDIR
|
|
479
|
+
// credenciales de a una. Se acepta con y sin tilde: nadie escribe la ñ en una CLI.
|
|
480
|
+
contrasenas: 'passwords', 'contraseñas': 'passwords',
|
|
481
|
+
sign: 'sign', store: 'store', read: 'read', admin: 'admin', approve: 'approve',
|
|
482
|
+
passwords: 'passwords'
|
|
502
483
|
}
|
|
503
484
|
const s = requireDaemon()
|
|
504
485
|
const m = await findMember(id)
|
|
@@ -1160,8 +1141,13 @@ function askText (prompt) {
|
|
|
1160
1141
|
|
|
1161
1142
|
async function cmdUnlock () {
|
|
1162
1143
|
const pwd = await askPassword('Contraseña del perfil: ')
|
|
1163
|
-
|
|
1164
|
-
|
|
1144
|
+
// El daemon devuelve cuánto aguanta abierto: se dice AQUÍ, al abrirlo, que es cuando
|
|
1145
|
+
// sirve de algo. Encontrárselo cerrado sin haberlo leído nunca parece una avería.
|
|
1146
|
+
const out = await profileRequest('unlock', { password: pwd })
|
|
1147
|
+
reportProfiles(out)
|
|
1148
|
+
const min = Math.max(1, Math.round((out?.autoLockMs || 5 * 60 * 1000) / 60000))
|
|
1149
|
+
console.log(`Ya puedes editar el perfil. Se vuelve a bloquear solo tras ${min} min sin usarse` +
|
|
1150
|
+
' (o al reiniciar el servicio, o con: dotrino-vault lock).')
|
|
1165
1151
|
}
|
|
1166
1152
|
|
|
1167
1153
|
async function cmdLock () {
|
|
@@ -1191,7 +1177,7 @@ function help () {
|
|
|
1191
1177
|
(sin la bandera entra a la cuenta activa, o a la de --profile)
|
|
1192
1178
|
pair --service <ns> empareja un SERVICIO (proxy, geo…) con acceso SOLO a sus secretos
|
|
1193
1179
|
pair --approval el aparato que entre pedirá tu aprobación (teléfono) al recibir claves
|
|
1194
|
-
pair --scope <lista> los PERMISOS del cert: sign,read,store,secrets:<ns> (sin esto: sign,read,store;
|
|
1180
|
+
pair --scope <lista> los PERMISOS del cert: sign,read,store,contrasenas,secrets:<ns> (sin esto: sign,read,store;
|
|
1195
1181
|
se combina con --service: --service eco --scope sign = bot que firma y lee su cajón)
|
|
1196
1182
|
secret set <ns> <CLAVE> <valor> variable del scope <ns>: la comparten TODOS los
|
|
1197
1183
|
aparatos del perfil que sirven ese namespace
|
|
@@ -1206,10 +1192,6 @@ function help () {
|
|
|
1206
1192
|
secret device set <ID> CLAVE=valor …
|
|
1207
1193
|
secret device import <ID> [archivo.env]
|
|
1208
1194
|
secret device rm <ID> <CLAVE> borra una variable de ese aparato
|
|
1209
|
-
passwords <ID> on|off deja que ese aparato pida CONTRASEÑAS de tu bóveda
|
|
1210
|
-
(de una en una; nunca la lista entera). Con
|
|
1211
|
-
approval on además te pedirá el visto bueno en el
|
|
1212
|
-
teléfono. Reinicia el vault tras el primer on
|
|
1213
1195
|
secret list lista los dos cajones: el valor de las públicas,
|
|
1214
1196
|
tapadas las privadas
|
|
1215
1197
|
secret show [device] <ns|ID> <CLAVE>
|
|
@@ -1234,7 +1216,7 @@ function help () {
|
|
|
1234
1216
|
me tu perfil (nombre, foto, datos) tal como lo tiene la bóveda
|
|
1235
1217
|
members el acta del perfil: quién es tuyo y qué puede hacer
|
|
1236
1218
|
label <ID> <nombre> renombra un dispositivo (el nombre con el que lo reconoces)
|
|
1237
|
-
caps <ID> ±permiso cambia permisos (+firma -guarda +administra …)
|
|
1219
|
+
caps <ID> ±permiso cambia permisos (+firma -guarda +administra +contrasenas …)
|
|
1238
1220
|
revoke <ID|nonce> quita un dispositivo (con el ID, todos sus certificados)
|
|
1239
1221
|
activity [n] bitácora de seguridad: firmas, renovaciones, enrolados, rechazos
|
|
1240
1222
|
logs últimos logs del servicio
|
|
@@ -1254,7 +1236,8 @@ dispositivos siguen firmando y leyendo aunque esté bloqueado):
|
|
|
1254
1236
|
profile password [set] pone o cambia la contraseña
|
|
1255
1237
|
profile password rm la quita
|
|
1256
1238
|
unlock desbloquea para poder editar
|
|
1257
|
-
lock vuelve a bloquear (también
|
|
1239
|
+
lock vuelve a bloquear (también solo, a los 5 min sin
|
|
1240
|
+
usarse, y al reiniciar el servicio)
|
|
1258
1241
|
|
|
1259
1242
|
Arrancar y parar, según dónde corra:
|
|
1260
1243
|
Linux (servicio) systemctl --user {start,stop,restart} dotrino-vault
|
|
@@ -1284,7 +1267,6 @@ export async function runCtl (argv) {
|
|
|
1284
1267
|
case 'revoke': return cmdRevoke(rest[0])
|
|
1285
1268
|
case 'secret': return cmdSecret(rest)
|
|
1286
1269
|
case 'approval': return cmdApproval(rest)
|
|
1287
|
-
case 'passwords': return cmdPasswords(rest)
|
|
1288
1270
|
case 'activity': return cmdActivity(Number(rest[0]) || 30)
|
|
1289
1271
|
case 'logs': return cmdLogs()
|
|
1290
1272
|
case 'version':
|
package/src/daemon.js
CHANGED
|
@@ -74,6 +74,9 @@ export async function runDaemon () {
|
|
|
74
74
|
writeJson(stateFile, {
|
|
75
75
|
v: 2, version: daemonVersion, fingerprint: cur.fingerprint || null, iss: cur.iss || null,
|
|
76
76
|
proxy: proxyUrl, pid: process.pid, startedAt: new Date().toISOString(),
|
|
77
|
+
// Cuánto aguanta abierto el candado sin usarse. Va en la foto para que la consola
|
|
78
|
+
// pueda DECIRLO en vez de llevar su propio número (que se desincronizaría).
|
|
79
|
+
autoLockMs: mgr.profiles.autoLockMs,
|
|
77
80
|
current: mgr.currentId(), profiles: mgr.summary()
|
|
78
81
|
})
|
|
79
82
|
}
|
|
@@ -105,7 +108,14 @@ export async function runDaemon () {
|
|
|
105
108
|
const resolveTarget = (req) => {
|
|
106
109
|
try {
|
|
107
110
|
const id = req?.profile ? mgr.resolve(req.profile) : mgr.currentId()
|
|
108
|
-
|
|
111
|
+
const locked = mgr.profiles.isLocked(id)
|
|
112
|
+
// ESTO CUENTA COMO USO: el candado se cierra solo a los 5 min de no usarse
|
|
113
|
+
// (`profiles.js`), y quien lo usa es esta consola. Va aquí y no en cada `case`
|
|
114
|
+
// porque toda petición de la CLI/TUI pasa por este punto, y así ninguna se olvida.
|
|
115
|
+
// Lo que un aparato pida por el proxy NO pasa por aquí, que es justo lo que se
|
|
116
|
+
// quiere: el candado no es suyo y no debe alargarlo.
|
|
117
|
+
if (!locked) mgr.profiles.touch(id)
|
|
118
|
+
return { id, vault: mgr.get(id), locked }
|
|
109
119
|
} catch (e) { console.error('[vault] invalid profile in the request:', e.message); return null }
|
|
110
120
|
}
|
|
111
121
|
/** La bóveda destino, o `null` si el perfil está bloqueado (la petición no se atiende). */
|
|
@@ -247,8 +257,24 @@ export async function runDaemon () {
|
|
|
247
257
|
if (r?.rekeyed) console.log('[vault] secrets master re-sealed (%d drawer(s))', r.drawers)
|
|
248
258
|
}
|
|
249
259
|
|
|
260
|
+
/**
|
|
261
|
+
* BORRA la llave derivada en cuanto se usó. Es lo único de la contraseña que se puede
|
|
262
|
+
* borrar de verdad: un `string` de JS no se puede pisar (lo copia y lo mueve el motor
|
|
263
|
+
* hasta que pase el recolector), pero un `Uint8Array` sí, y es el que lleva el material
|
|
264
|
+
* con el que se abre la copia maestra. Vale lo que vale — no hace milagros con un
|
|
265
|
+
* volcado de memoria tomado en el instante justo —, pero acorta la ventana de horas a
|
|
266
|
+
* milisegundos, que es la diferencia entre «estaba ahí» y «estuvo».
|
|
267
|
+
*/
|
|
268
|
+
const wipe = (k) => { try { if (k instanceof Uint8Array) k.fill(0) } catch (_) {} }
|
|
269
|
+
|
|
250
270
|
async function handleProfileRequest (req) {
|
|
251
|
-
|
|
271
|
+
// Resolver el destino de una orden de perfil CUENTA COMO USO: estira el plazo del
|
|
272
|
+
// bloqueo automático igual que cualquier otra cosa que se haga desde la consola.
|
|
273
|
+
const ref = () => {
|
|
274
|
+
const id = mgr.resolve(req.profile || mgr.currentId())
|
|
275
|
+
mgr.profiles.touch(id)
|
|
276
|
+
return id
|
|
277
|
+
}
|
|
252
278
|
switch (req.op) {
|
|
253
279
|
case 'list': return {} // el volcado de perfiles ya se hace abajo
|
|
254
280
|
// `id`: quien la crea necesita saber CUÁL quedó, no adivinar por nombre (dos
|
|
@@ -266,8 +292,9 @@ export async function runDaemon () {
|
|
|
266
292
|
const id = ref()
|
|
267
293
|
await mgr.profiles.unlock(id, req.password)
|
|
268
294
|
let note = ''
|
|
295
|
+
let ak = null
|
|
269
296
|
try {
|
|
270
|
-
|
|
297
|
+
ak = await mgr.profiles.adminKey(id, req.password)
|
|
271
298
|
// REHACER el llavero, no solo saldar: con la frase delante se puede dejar cada
|
|
272
299
|
// cajón envuelto para exactamente quien dice el acta — creando lo que falta,
|
|
273
300
|
// reemplazando lo que alguien metiera mal y quitando lo que sobre.
|
|
@@ -277,7 +304,8 @@ export async function runDaemon () {
|
|
|
277
304
|
if (r?.dropped) note = ` · llavero al día (${r.dropped} envoltura(s) de más retirada(s))`
|
|
278
305
|
else if (r?.wrapped) note = ' · llavero al día'
|
|
279
306
|
} catch (e) { console.error('[vault] could not rebuild the keyring on unlock:', e.message) }
|
|
280
|
-
|
|
307
|
+
finally { wipe(ak) }
|
|
308
|
+
return { done: 'perfil desbloqueado' + note, autoLockMs: mgr.profiles.autoLockMs }
|
|
281
309
|
}
|
|
282
310
|
case 'lock': { mgr.profiles.lock(ref()); return { done: 'perfil bloqueado' } }
|
|
283
311
|
// PONER contraseña: los secretos pasan de abrirse con la llave de la máquina a
|
|
@@ -291,7 +319,7 @@ export async function runDaemon () {
|
|
|
291
319
|
const vieja = tenia ? await mgr.profiles.adminKey(id, req.current) : null
|
|
292
320
|
await mgr.profiles.setPassword(id, req.password)
|
|
293
321
|
const nueva = await mgr.profiles.adminKey(id, req.password)
|
|
294
|
-
await rekey(id, vieja, nueva)
|
|
322
|
+
try { await rekey(id, vieja, nueva) } finally { wipe(vieja); wipe(nueva) }
|
|
295
323
|
return { done: 'contraseña guardada' }
|
|
296
324
|
}
|
|
297
325
|
// QUITARLA: al revés. Se abre la copia maestra con la frase y se vuelve a cerrar
|
|
@@ -301,7 +329,7 @@ export async function runDaemon () {
|
|
|
301
329
|
const id = ref()
|
|
302
330
|
if (!req.password) throw new Error('removing the password needs the current one: the secrets must be re-sealed before it goes')
|
|
303
331
|
const vieja = await mgr.profiles.adminKey(id, req.password)
|
|
304
|
-
await rekey(id, vieja, null)
|
|
332
|
+
try { await rekey(id, vieja, null) } finally { wipe(vieja) }
|
|
305
333
|
mgr.profiles.removePassword(id)
|
|
306
334
|
return { done: 'contraseña quitada · los secretos ahora se abren con la llave de esta máquina' }
|
|
307
335
|
}
|
|
@@ -373,6 +401,7 @@ export async function runDaemon () {
|
|
|
373
401
|
if (sec?.op) {
|
|
374
402
|
rm(secretReqFile) // puede llevar la contraseña: fuera del disco cuanto antes
|
|
375
403
|
lastSecretError = null
|
|
404
|
+
let ak // fuera del try para poder BORRARLA pase lo que pase (ver `wipe`)
|
|
376
405
|
try {
|
|
377
406
|
const vault = targetOf(sec)
|
|
378
407
|
// Carga en GRUPO (`secret set ns K=v K2=v2`, `secret import`): todas las
|
|
@@ -384,7 +413,7 @@ export async function runDaemon () {
|
|
|
384
413
|
// convertir el archivo, rotar re-cifrando—: escribir no (§8.1). Sin ella se cae
|
|
385
414
|
// a la llave de la máquina, que es la protección de antes de esto (y el vault lo
|
|
386
415
|
// avisa al arrancar).
|
|
387
|
-
|
|
416
|
+
ak = sec.password ? await mgr.profiles.adminKey(sec.profile ? mgr.resolve(sec.profile) : mgr.currentId(), sec.password) : undefined
|
|
388
417
|
if (sec.op === 'migrate') {
|
|
389
418
|
// Sin lista a mano: la pone el vault, y es la MISMA que usa cualquier
|
|
390
419
|
// escritura (servicios del cajón + aparatos que administran). Con una lista
|
|
@@ -405,11 +434,6 @@ export async function runDaemon () {
|
|
|
405
434
|
console.log('[vault] %d secret(s) applied in one go: %s', changed.length, sec.pub ? 'device' : sec.ns)
|
|
406
435
|
} else if (sec.op === 'set') { await vault.setSecret(sec.ns, sec.key, sec.value, sec.public); console.log('[vault] secret saved: %s/%s', sec.ns, sec.key) }
|
|
407
436
|
else if (sec.op === 'rm') { await vault.deleteSecret(sec.ns, sec.key); console.log('[vault] secret deleted: %s/%s', sec.ns, sec.key) }
|
|
408
|
-
else if (sec.op === 'passwords') {
|
|
409
|
-
const r = await vault.setPasswordDevice(sec.pub, { label: sec.label, on: !!sec.on })
|
|
410
|
-
console.log('[vault] passwords: device %s %s', sec.id || '?', sec.on ? 'ALLOWED' : 'removed')
|
|
411
|
-
if (sec.on) console.log('[vault] restart the vault so it starts answering password requests')
|
|
412
|
-
}
|
|
413
437
|
else if (sec.op === 'approval') { const r = await vault.setApproval(sec.pub, !!sec.approval); console.log('[vault] device %s: approval %s', sec.id || '?', r.approval ? 'REQUIRED on every key request' : 'off') }
|
|
414
438
|
else if (sec.op === 'dev-set') { await vault.setDeviceSecret(sec.pub, sec.key, sec.value, sec.public); console.log('[vault] device secret saved: %s', sec.key) }
|
|
415
439
|
else if (sec.op === 'dev-rm') { await vault.deleteDeviceSecret(sec.pub, sec.key); console.log('[vault] device secret deleted: %s', sec.key) }
|
|
@@ -445,7 +469,7 @@ export async function runDaemon () {
|
|
|
445
469
|
code: e.code || (/wrong password/i.test(e.message) ? 'WRONG_PASSWORD' : 'SECRET_FAILED')
|
|
446
470
|
}
|
|
447
471
|
console.error('[vault] secret failed:', e.message)
|
|
448
|
-
}
|
|
472
|
+
} finally { wipe(ak) }
|
|
449
473
|
}
|
|
450
474
|
// Perfiles / candado.
|
|
451
475
|
const preq = readJsonSafe(profileReqFile)
|
package/src/manager.js
CHANGED
|
@@ -35,14 +35,20 @@ export function assertCanRemove ({ isMaster, memberCount, name = '' }) {
|
|
|
35
35
|
throw e
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
-
export async function startVaultManager ({ root = dataDir(), proxyUrl, log = console.log, onEnrollChallenge } = {}) {
|
|
38
|
+
export async function startVaultManager ({ root = dataDir(), proxyUrl, log = console.log, onEnrollChallenge, autoLockMs } = {}) {
|
|
39
39
|
ensureDir(root)
|
|
40
40
|
// El keypair de transporte del proxy-client es del PROCESO, no de la identidad:
|
|
41
41
|
// se instala apuntando a la RAÍZ (no al dir de un perfil) para que los perfiles
|
|
42
42
|
// no se peleen por el archivo ni se lo lleven al borrarse.
|
|
43
43
|
installNodeGlobals(root)
|
|
44
44
|
|
|
45
|
-
const profiles = openProfiles(root
|
|
45
|
+
const profiles = openProfiles(root, {
|
|
46
|
+
...(autoLockMs === undefined ? {} : { autoLockMs }),
|
|
47
|
+
// Que se cierre solo tiene que VERSE: si no, quien vuelve y encuentra la consola
|
|
48
|
+
// pidiendo la contraseña otra vez cree que algo se rompió.
|
|
49
|
+
onAutoLock: (id) => log('[vault] profile %s locked itself after %d min idle (the console; devices keep working)',
|
|
50
|
+
id, Math.round(profiles.autoLockMs / 60000))
|
|
51
|
+
})
|
|
46
52
|
const migrated = profiles.migrate()
|
|
47
53
|
if (migrated?.migrated) log('[vault] identidad mono-perfil migrada al perfil %s', migrated.id)
|
|
48
54
|
|
package/src/profiles.js
CHANGED
|
@@ -52,6 +52,25 @@ const PWD_MIN = 12
|
|
|
52
52
|
*/
|
|
53
53
|
const TRIES_FORGET_MS = 15 * 60 * 1000
|
|
54
54
|
|
|
55
|
+
/**
|
|
56
|
+
* BLOQUEO AUTOMÁTICO. Cuánto aguanta abierto el candado sin que nadie lo use.
|
|
57
|
+
*
|
|
58
|
+
* Abrir la bóveda dejaba la consola abierta hasta que alguien la cerraba a mano o
|
|
59
|
+
* reiniciaba el servicio — y el servicio de un PC de escritorio no se reinicia en semanas.
|
|
60
|
+
* O sea que teclear la contraseña una vez un lunes dejaba la máquina administrable para
|
|
61
|
+
* quien pasara por delante el jueves. Un candado que solo se cierra reiniciando no es un
|
|
62
|
+
* candado.
|
|
63
|
+
*
|
|
64
|
+
* El plazo se cuenta desde el ÚLTIMO USO (`touch`), no desde que se abrió: quien está
|
|
65
|
+
* trabajando no se queda fuera a media faena, y quien se levanta de la silla lo encuentra
|
|
66
|
+
* cerrado. Y vence solo, sin temporizador: se mira la hora al preguntar (`isOpen`), así
|
|
67
|
+
* que no hay nada que limpiar ni un `setTimeout` que mantenga vivo al proceso.
|
|
68
|
+
*
|
|
69
|
+
* Como todo el candado, esto es de la CONSOLA: al vencer, los aparatos ya emparejados
|
|
70
|
+
* siguen firmando, leyendo y guardando. Lo que se cierra es administrar y mirar.
|
|
71
|
+
*/
|
|
72
|
+
const AUTO_LOCK_MS = 5 * 60 * 1000
|
|
73
|
+
|
|
55
74
|
/**
|
|
56
75
|
* Archivos de un perfil que en la versión mono-perfil vivían sueltos en la raíz.
|
|
57
76
|
*
|
|
@@ -91,7 +110,13 @@ async function derivePwd (password, saltB64, iter) {
|
|
|
91
110
|
const newId = () => 'p' + crypto.randomUUID().slice(0, 8)
|
|
92
111
|
const cleanName = (name) => String(name || '').slice(0, MAX_NAME)
|
|
93
112
|
|
|
94
|
-
|
|
113
|
+
/**
|
|
114
|
+
* @param {string} root dir de datos
|
|
115
|
+
* @param {{ autoLockMs?: number, onAutoLock?: (id: string) => void }} [opts]
|
|
116
|
+
* `autoLockMs`: 0 desactiva el bloqueo automático (las pruebas lo acortan).
|
|
117
|
+
* `onAutoLock`: se avisa cuando un perfil se cierra solo, para poder decirlo en el log.
|
|
118
|
+
*/
|
|
119
|
+
export function openProfiles (root = dataDir(), { autoLockMs = AUTO_LOCK_MS, onAutoLock = null } = {}) {
|
|
95
120
|
const file = path.join(root, REGISTRY)
|
|
96
121
|
// CIFRADO EN REPOSO, como todo lo demás. Era el único archivo del vault sin códec, y
|
|
97
122
|
// lleva dentro el verificador del candado. No protege de quien tenga el disco entero
|
|
@@ -107,8 +132,24 @@ export function openProfiles (root = dataDir()) {
|
|
|
107
132
|
const save = () => writeJson(file, data, atRest)
|
|
108
133
|
|
|
109
134
|
// Perfiles DESBLOQUEADOS en esta ejecución del daemon (en memoria: un reinicio
|
|
110
|
-
// vuelve a bloquear, igual que cerrar la pestaña en el navegador)
|
|
111
|
-
|
|
135
|
+
// vuelve a bloquear, igual que cerrar la pestaña en el navegador), cada uno con la
|
|
136
|
+
// hora a la que se cierra solo si nadie lo usa (ver AUTO_LOCK_MS).
|
|
137
|
+
const unlocked = new Map() // id -> vence (ms epoch)
|
|
138
|
+
|
|
139
|
+
/** ¿Sigue abierto? Vence al MIRARLO, así que no hace falta ningún temporizador. */
|
|
140
|
+
const isOpen = (id) => {
|
|
141
|
+
const until = unlocked.get(id)
|
|
142
|
+
if (until == null) return false
|
|
143
|
+
if (autoLockMs > 0 && Date.now() >= until) {
|
|
144
|
+
unlocked.delete(id)
|
|
145
|
+
// El aviso va después de borrarlo: quien lo escuche verá el perfil ya cerrado.
|
|
146
|
+
try { onAutoLock?.(id) } catch (_) {}
|
|
147
|
+
return false
|
|
148
|
+
}
|
|
149
|
+
return true
|
|
150
|
+
}
|
|
151
|
+
/** Abre (o estira) el plazo. Todo lo que abre el candado pasa por aquí. */
|
|
152
|
+
const open = (id) => { unlocked.set(id, Date.now() + (autoLockMs > 0 ? autoLockMs : Number.MAX_SAFE_INTEGER)) }
|
|
112
153
|
|
|
113
154
|
const find = (id) => data.profiles.find((p) => p.id === id) || null
|
|
114
155
|
const dirOf = (id) => path.join(root, 'p', id)
|
|
@@ -118,7 +159,10 @@ export function openProfiles (root = dataDir()) {
|
|
|
118
159
|
name: p.name || '',
|
|
119
160
|
createdAt: p.createdAt || null,
|
|
120
161
|
protected: !!p.pwd,
|
|
121
|
-
locked: !!p.pwd && !
|
|
162
|
+
locked: !!p.pwd && !isOpen(p.id),
|
|
163
|
+
// Hasta cuándo sigue abierto si nadie lo toca. Es DATO: la consola lo enseña para
|
|
164
|
+
// que el cierre no llegue por sorpresa.
|
|
165
|
+
...(p.pwd && isOpen(p.id) && autoLockMs > 0 ? { until: unlocked.get(p.id) } : {}),
|
|
122
166
|
current: p.id === data.current,
|
|
123
167
|
// Nació para adoptar la cuenta de un aparato (camino A) y todavía no lo ha hecho.
|
|
124
168
|
...(p.adopt ? { adopt: true } : {})
|
|
@@ -231,7 +275,20 @@ export function openProfiles (root = dataDir()) {
|
|
|
231
275
|
// ----- candado -----
|
|
232
276
|
|
|
233
277
|
isProtected: (id) => !!find(id)?.pwd,
|
|
234
|
-
isLocked: (id) => { const p = find(id); return !!p?.pwd && !
|
|
278
|
+
isLocked: (id) => { const p = find(id); return !!p?.pwd && !isOpen(id) },
|
|
279
|
+
/** Cuánto dura abierto el candado sin usarse (ms). 0 = no se cierra solo. */
|
|
280
|
+
get autoLockMs () { return autoLockMs },
|
|
281
|
+
/**
|
|
282
|
+
* «Se acaba de usar»: estira el plazo del bloqueo automático. Lo llama la consola
|
|
283
|
+
* al atender CADA petición suya, y solo eso cuenta como uso: que un aparato pida su
|
|
284
|
+
* configuración no abre nada ni alarga nada, porque el candado no es suyo.
|
|
285
|
+
* Devuelve si el perfil estaba abierto (a uno cerrado no hay nada que estirarle).
|
|
286
|
+
*/
|
|
287
|
+
touch (id) {
|
|
288
|
+
if (!isOpen(id)) return false
|
|
289
|
+
open(id)
|
|
290
|
+
return true
|
|
291
|
+
},
|
|
235
292
|
assertUnlocked (id) {
|
|
236
293
|
if (api.isLocked(id)) throw new Error('profile locked: unlock it with your password (dotrino-vault unlock)')
|
|
237
294
|
},
|
|
@@ -258,7 +315,7 @@ export function openProfiles (root = dataDir()) {
|
|
|
258
315
|
|
|
259
316
|
async unlock (id, password) {
|
|
260
317
|
const p = assertExists(id)
|
|
261
|
-
if (!p.pwd) {
|
|
318
|
+
if (!p.pwd) { open(id); return { ok: true, locked: false } }
|
|
262
319
|
// Freno de fuerza bruta (una contraseña corta se adivina probando): tras 5
|
|
263
320
|
// fallos, espera exponencial (2^n s, tope 5 min) persistida en el registro.
|
|
264
321
|
// Los fallos VIEJOS se olvidan (ver TRIES_FORGET_MS): si desde el último ha pasado
|
|
@@ -296,7 +353,7 @@ export function openProfiles (root = dataDir()) {
|
|
|
296
353
|
}
|
|
297
354
|
delete p.tries
|
|
298
355
|
save()
|
|
299
|
-
|
|
356
|
+
open(id)
|
|
300
357
|
return { ok: true, locked: false }
|
|
301
358
|
},
|
|
302
359
|
|
|
@@ -314,7 +371,7 @@ export function openProfiles (root = dataDir()) {
|
|
|
314
371
|
p.pwd = { v: 2, salt, verifier: deriveScryptPwd(password, salt) }
|
|
315
372
|
delete p.tries
|
|
316
373
|
save()
|
|
317
|
-
|
|
374
|
+
open(id)
|
|
318
375
|
return entry(p)
|
|
319
376
|
},
|
|
320
377
|
|
|
@@ -324,7 +381,7 @@ export function openProfiles (root = dataDir()) {
|
|
|
324
381
|
delete p.pwd
|
|
325
382
|
delete p.tries
|
|
326
383
|
save()
|
|
327
|
-
|
|
384
|
+
open(id)
|
|
328
385
|
return entry(p)
|
|
329
386
|
}
|
|
330
387
|
}
|
package/src/tui/app.js
CHANGED
|
@@ -318,7 +318,7 @@ function pairModeRows (st, t) {
|
|
|
318
318
|
* una marca de si los tiene. El de administrar va aparte y avisado: es el único que deja
|
|
319
319
|
* a ese aparato meter y sacar dispositivos sin venir aquí.
|
|
320
320
|
*/
|
|
321
|
-
const CAPS_ORDER = ['sign', 'store', 'read', 'admin']
|
|
321
|
+
const CAPS_ORDER = ['sign', 'store', 'read', 'admin', 'passwords']
|
|
322
322
|
|
|
323
323
|
function capsRows (st, t) {
|
|
324
324
|
const i = L(st)
|
|
@@ -492,6 +492,72 @@ async function guard (term, st, msg, fn) {
|
|
|
492
492
|
*/
|
|
493
493
|
const activeLocked = (st) => { const p = activeProfile(st); return !!(p?.protected && p.locked) }
|
|
494
494
|
|
|
495
|
+
/** Los minutos que el daemon aguanta abierto (viene en `state.json`; 5 por defecto). */
|
|
496
|
+
const autoLockMin = (st) => Math.max(1, Math.round((st.state?.autoLockMs || 5 * 60 * 1000) / 60000))
|
|
497
|
+
|
|
498
|
+
/**
|
|
499
|
+
* ¿Alguna de las bóvedas que abrió ESTA sesión se cerró sola?
|
|
500
|
+
*
|
|
501
|
+
* El plazo lo lleva el daemon (`profiles.js`), no la TUI: aquí solo se MIRA su foto
|
|
502
|
+
* (`state.json`, que se reescribe cada dos segundos). Así hay un único reloj y no dos
|
|
503
|
+
* que se desincronizan — y lo que decida el daemon es lo que manda, porque es quien
|
|
504
|
+
* atiende.
|
|
505
|
+
*/
|
|
506
|
+
const autoLockedIds = (st) => (st.state?.profiles || [])
|
|
507
|
+
.filter((p) => p.protected && p.locked && (st.sessionPwd?.has(p.id) || st.unlockedHere?.has(p.id)))
|
|
508
|
+
.map((p) => p.id)
|
|
509
|
+
|
|
510
|
+
/**
|
|
511
|
+
* Cuándo hay que despertar para enterarse. Sin esto la TUI se queda dormida en
|
|
512
|
+
* `readKey()` hasta la siguiente tecla y seguiría enseñando los aparatos y los nombres
|
|
513
|
+
* de las variables de una bóveda que el daemon ya cerró. Se suman ~2 s porque la foto
|
|
514
|
+
* se reescribe cada dos: despertar en el instante justo leería la anterior.
|
|
515
|
+
*/
|
|
516
|
+
function autoLockWakeIn (st, now = Date.now()) {
|
|
517
|
+
let at = 0
|
|
518
|
+
for (const p of st.state?.profiles || []) {
|
|
519
|
+
if (!p.until || !(st.sessionPwd?.has(p.id) || st.unlockedHere?.has(p.id))) continue
|
|
520
|
+
at = at ? Math.min(at, p.until) : p.until
|
|
521
|
+
}
|
|
522
|
+
if (!at) return 0
|
|
523
|
+
const left = at - now + 2200
|
|
524
|
+
// El plazo ya pasó y la foto sigue diciendo que está abierta: el daemon no la está
|
|
525
|
+
// reescribiendo (caído o colgado). Se insiste un rato y luego se deja de despertar, o
|
|
526
|
+
// la TUI se quedaría dando vueltas contra una foto que no va a cambiar.
|
|
527
|
+
if (left <= 0) return now - at > 15000 ? 0 : 1000
|
|
528
|
+
return Math.max(250, left)
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/**
|
|
532
|
+
* BLOQUEO AUTOMÁTICO. Se cerró sola: se OLVIDA su contraseña y se sale de lo que se
|
|
533
|
+
* estaba mirando (dentro se ven los aparatos, las variables y tus datos, que es justo
|
|
534
|
+
* lo que el candado tapa).
|
|
535
|
+
*
|
|
536
|
+
* Olvidarla es la mitad que importa: sin esto la TUI la seguiría teniendo y la
|
|
537
|
+
* reabriría sola en cuanto alguien pulsara Enter (ver `reunlockSilently`), así que el
|
|
538
|
+
* candado del daemon no habría cerrado nada.
|
|
539
|
+
*/
|
|
540
|
+
async function forgetAutoLocked (term, st, api = vc) {
|
|
541
|
+
const ids = autoLockedIds(st)
|
|
542
|
+
if (!ids.length) return false
|
|
543
|
+
for (const id of ids) { st.sessionPwd?.delete(id); st.unlockedHere?.delete(id) }
|
|
544
|
+
const cur = (st.state?.profiles || []).find((p) => p.current)
|
|
545
|
+
if (ids.includes(cur?.id) && st.screen !== 'profiles') {
|
|
546
|
+
// Un modal abierto (una pregunta a medias) también se cierra: quien lo dejó abierto
|
|
547
|
+
// ya no está delante.
|
|
548
|
+
st.input = null
|
|
549
|
+
st.confirm = null
|
|
550
|
+
st.screen = 'profiles'
|
|
551
|
+
}
|
|
552
|
+
// Suelta lo cargado y repinta la lista con el candado ya echado (`refreshAll` no pide
|
|
553
|
+
// el contenido de una bóveda cerrada).
|
|
554
|
+
await refreshAll(term, st, api)
|
|
555
|
+
// El aviso va DESPUÉS del refresco: si no, un tropiezo al refrescar lo pisaría con su
|
|
556
|
+
// error rojo y el candado se cerraría en silencio.
|
|
557
|
+
flash(st, L(st).autoLocked(autoLockMin(st)), 'warn')
|
|
558
|
+
return true
|
|
559
|
+
}
|
|
560
|
+
|
|
495
561
|
// `api` es `vaultControl` — se recibe para poder probar ESTA función (la que se rompió)
|
|
496
562
|
// sin un daemon detrás, que es donde vive la regla de qué se pide y en qué orden.
|
|
497
563
|
async function refreshAll (term, st, api = vc) {
|
|
@@ -1608,14 +1674,23 @@ export async function runTui () {
|
|
|
1608
1674
|
while (running) {
|
|
1609
1675
|
st.state = vc.readState()
|
|
1610
1676
|
st.daemonUp = vc.daemonAlive()
|
|
1677
|
+
// ¿Se cerró sola mientras nadie miraba? Olvida su contraseña y sal de su contenido.
|
|
1678
|
+
await forgetAutoLocked(term, st)
|
|
1611
1679
|
// caducar el flash a los ~4 s
|
|
1612
1680
|
if (st.flash && Date.now() - st.flash.at > 4000) st.flash = null
|
|
1613
1681
|
render(term, st)
|
|
1614
1682
|
|
|
1615
|
-
const
|
|
1683
|
+
const base = (st.screen === 'pairing' || (st.screen === 'devices' && !st.input && !st.confirm)) ? 800 : 0
|
|
1684
|
+
// Despertar a tiempo del bloqueo automático, además del sondeo de la pantalla.
|
|
1685
|
+
const wake = autoLockWakeIn(st)
|
|
1686
|
+
const tick = base && wake ? Math.min(base, wake) : (base || wake)
|
|
1616
1687
|
const key = await term.readKey(tick)
|
|
1617
1688
|
|
|
1618
1689
|
if (key.name === 'resize') continue
|
|
1690
|
+
// Un despertar del bloqueo automático no es una tecla: ya se atendió arriba, y las
|
|
1691
|
+
// pantallas que NO esperan `tick` (todas menos aparatos y emparejamiento) no tienen
|
|
1692
|
+
// por qué verlo.
|
|
1693
|
+
if (key.name === 'tick' && st.screen !== 'pairing' && st.screen !== 'devices') continue
|
|
1619
1694
|
// input/confirm se AWAITan: serializa las ops contra el daemon (ver onInputKey).
|
|
1620
1695
|
// Ctrl-C dentro de un modal lo CANCELA (no sale); fuera de un modal, sale.
|
|
1621
1696
|
if (st.input) { await onInputKey(st, key); continue }
|
|
@@ -1664,4 +1739,4 @@ export async function runTui () {
|
|
|
1664
1739
|
}
|
|
1665
1740
|
|
|
1666
1741
|
// Solo para pruebas headless (render sin terminal real). No usar en runtime.
|
|
1667
|
-
export const __test = { render, activeLocked, refreshAll, ensureUnlocked, profileRows, deviceRows, secretRows, devVarRows, meRows, capsRows, pairModeRows, pairingBody, scrollBody, fitHelp, toggleLang, mergeMembersAndCerts }
|
|
1742
|
+
export const __test = { render, activeLocked, autoLockedIds, autoLockWakeIn, forgetAutoLocked, autoLockMin, refreshAll, ensureUnlocked, profileRows, deviceRows, secretRows, devVarRows, meRows, capsRows, pairModeRows, pairingBody, scrollBody, fitHelp, toggleLang, mergeMembersAndCerts }
|
package/src/tui/i18n.js
CHANGED
|
@@ -45,13 +45,15 @@ const es = {
|
|
|
45
45
|
sign: 'Firmar como tú',
|
|
46
46
|
store: 'Guardar tus datos',
|
|
47
47
|
read: 'Leer tus datos',
|
|
48
|
-
admin: 'Administrar el perfil'
|
|
48
|
+
admin: 'Administrar el perfil',
|
|
49
|
+
passwords: 'Pedir tus contraseñas'
|
|
49
50
|
},
|
|
50
51
|
capHint: {
|
|
51
52
|
sign: 'usar tu identidad en las apps del ecosistema',
|
|
52
53
|
store: 'escribir en tu bóveda (perfil, contenido, datos sensibles)',
|
|
53
54
|
read: 'ver lo que guardaste',
|
|
54
|
-
admin: 'conectar y quitar dispositivos desde ese aparato, sin venir aquí'
|
|
55
|
+
admin: 'conectar y quitar dispositivos desde ese aparato, sin venir aquí',
|
|
56
|
+
passwords: 'el gestor: pedir una credencial por sitio, nunca la lista entera'
|
|
55
57
|
},
|
|
56
58
|
confirmAdmin: (id) => `¿Dejar que ${id} conecte y quite dispositivos sin venir aquí?`,
|
|
57
59
|
capGiven: (n) => `Concedido: ${n}`,
|
|
@@ -124,6 +126,7 @@ const es = {
|
|
|
124
126
|
vaultUnlocked: 'Bóveda desbloqueada',
|
|
125
127
|
lockingVault: 'Bloqueando…',
|
|
126
128
|
vaultLocked: 'Bóveda bloqueada',
|
|
129
|
+
autoLocked: (min) => `Se bloqueó sola tras ${min} min sin usarse · la contraseña se olvidó`,
|
|
127
130
|
|
|
128
131
|
// dispositivos
|
|
129
132
|
pendingDevice: (id) => ` ⧗ PENDIENTE: ${id}`,
|
|
@@ -341,13 +344,15 @@ const en = {
|
|
|
341
344
|
sign: 'Sign as you',
|
|
342
345
|
store: 'Save your data',
|
|
343
346
|
read: 'Read your data',
|
|
344
|
-
admin: 'Manage the profile'
|
|
347
|
+
admin: 'Manage the profile',
|
|
348
|
+
passwords: 'Ask for your passwords'
|
|
345
349
|
},
|
|
346
350
|
capHint: {
|
|
347
351
|
sign: 'use your identity across the ecosystem apps',
|
|
348
352
|
store: 'write to your vault (profile, content, sensitive data)',
|
|
349
353
|
read: 'see what you saved',
|
|
350
|
-
admin: 'connect and remove devices from that device, without coming here'
|
|
354
|
+
admin: 'connect and remove devices from that device, without coming here',
|
|
355
|
+
passwords: 'the manager: ask for one credential per site, never the whole list'
|
|
351
356
|
},
|
|
352
357
|
confirmAdmin: (id) => `Let ${id} connect and remove devices without coming here?`,
|
|
353
358
|
capGiven: (n) => `Granted: ${n}`,
|
|
@@ -418,6 +423,7 @@ const en = {
|
|
|
418
423
|
vaultUnlocked: 'Vault unlocked',
|
|
419
424
|
lockingVault: 'Locking…',
|
|
420
425
|
vaultLocked: 'Vault locked',
|
|
426
|
+
autoLocked: (min) => `Locked itself after ${min} min idle · the password was forgotten`,
|
|
421
427
|
|
|
422
428
|
pendingDevice: (id) => ` ⧗ PENDING: ${id}`,
|
|
423
429
|
pendingHint: ' — press A to approve, X to reject',
|
package/src/vault.js
CHANGED
|
@@ -102,9 +102,10 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
102
102
|
return { approval: !!on }
|
|
103
103
|
}
|
|
104
104
|
// LA BÓVEDA DE CONTRASEÑAS: entradas y su llave, en el dir del perfil y cifradas en
|
|
105
|
-
// reposo como todo lo demás.
|
|
106
|
-
//
|
|
107
|
-
//
|
|
105
|
+
// reposo como todo lo demás. QUIÉN PUEDE PEDIR LO DICE EL ACTA (capacidad
|
|
106
|
+
// `passwords`), como cualquier otro permiso: aquí no hay una segunda lista de
|
|
107
|
+
// aparatos. Tenerla obligaba a acordarse de dos sitios al quitar un aparato, y era
|
|
108
|
+
// además un emparejamiento paralelo al del ecosistema.
|
|
108
109
|
const passwordsFile = path.join(dir, 'passwords.json')
|
|
109
110
|
const passwordsAtRest = atRestFor(dir)
|
|
110
111
|
const readPasswordsFile = () => {
|
|
@@ -118,13 +119,14 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
118
119
|
const passwordsStore = {
|
|
119
120
|
async get (k) { return readPasswordsFile()?.data?.[k] },
|
|
120
121
|
async set (k, v) {
|
|
121
|
-
const d = readPasswordsFile() || { v: 1, data: {}
|
|
122
|
+
const d = readPasswordsFile() || { v: 1, data: {} }
|
|
122
123
|
d.data = { ...(d.data || {}), [k]: v }
|
|
123
124
|
writePasswordsFile(d)
|
|
124
125
|
},
|
|
125
126
|
}
|
|
126
127
|
|
|
127
|
-
|
|
128
|
+
/** Los aparatos que el acta autoriza a pedir credenciales (`caps <ID> +contraseñas`). */
|
|
129
|
+
const passwordDevices = () => (actaCache?.members || []).filter((m) => Acta.memberCan(actaCache, m.pub, 'passwords'))
|
|
128
130
|
|
|
129
131
|
/**
|
|
130
132
|
* El acta vigente, en caché.
|
|
@@ -153,20 +155,10 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
153
155
|
}
|
|
154
156
|
const key = await crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt'])
|
|
155
157
|
const raw = new Uint8Array(await crypto.subtle.exportKey('raw', key))
|
|
156
|
-
writePasswordsFile({ ...(d || { v: 1, data: {} }), cek: Buffer.from(raw).toString('base64')
|
|
158
|
+
writePasswordsFile({ ...(d || { v: 1, data: {} }), cek: Buffer.from(raw).toString('base64') })
|
|
157
159
|
return key
|
|
158
160
|
}
|
|
159
161
|
|
|
160
|
-
/** Autorizar/retirar un aparato para pedir contraseñas (`dotrino-vault passwords`). */
|
|
161
|
-
async function setPasswordDevice (pub, { label = '', on = true } = {}) {
|
|
162
|
-
const d = readPasswordsFile() || { v: 1, data: {}, devices: [] }
|
|
163
|
-
const resto = (d.devices || []).filter((x) => x.pubkey !== pub)
|
|
164
|
-
if (on) resto.push({ pubkey: pub, label: String(label || '').slice(0, 60), ts: Date.now() })
|
|
165
|
-
writePasswordsFile({ ...d, devices: resto })
|
|
166
|
-
audit('passwords.device', { device: await deviceIdOf(pub).catch(() => null), on: !!on })
|
|
167
|
-
return { ok: true }
|
|
168
|
-
}
|
|
169
|
-
|
|
170
162
|
const approvalsSweeper = setInterval(() => { for (const g of approvals.sweep()) audit('secrets.expired', { id: g.id, ns: g.ns, device: g.deviceId }) }, 30 * 1000); approvalsSweeper.unref?.()
|
|
171
163
|
const secrets = openSecretsStore(dir, {
|
|
172
164
|
sealer: makeSealer(),
|
|
@@ -764,69 +756,94 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
764
756
|
// ----- LA BÓVEDA DE CONTRASEÑAS -----
|
|
765
757
|
//
|
|
766
758
|
// Se monta al final y envuelto: es una pieza opcional y un fallo suyo no puede tumbar
|
|
767
|
-
// la CA. Solo se levanta
|
|
759
|
+
// la CA. Solo se levanta cuando el acta autoriza a alguien — sin eso no hay a quién
|
|
768
760
|
// responder, y crear la llave por si acaso sería crear un secreto que nadie pidió.
|
|
761
|
+
//
|
|
762
|
+
// Y se levanta EN CUANTO pasa, no en el siguiente arranque: conceder el permiso ya es
|
|
763
|
+
// un acto explícito del dueño, y pedirle además que reinicie la bóveda es de las cosas
|
|
764
|
+
// que se olvidan y luego parecen un fallo del gestor.
|
|
769
765
|
let passwords = null
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
//
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
const deviceId = await deviceIdOf(pubkey).catch(() => null)
|
|
793
|
-
const label = (actaCache?.members || []).find((m) => m.pub === pubkey)?.label || ''
|
|
794
|
-
const pend = approvals.request({ ns: 'passwords', device: pubkey, deviceId, label, ek: '' })
|
|
795
|
-
audit('passwords.pending', { device: deviceId, id: pend.id, op })
|
|
796
|
-
log(`[vault] passwords: ${deviceId || '????-????'} is waiting for approval (${pend.id})`)
|
|
797
|
-
const espera = new Promise((resolve) => {
|
|
798
|
-
waiters.set(pend.id, resolve)
|
|
799
|
-
// Si nadie contesta, vence solo: la promesa no se queda colgada para siempre
|
|
800
|
-
// y el aparato recibe un no en vez de esperar sin fin.
|
|
801
|
-
const t = setTimeout(() => {
|
|
802
|
-
if (waiters.delete(pend.id)) {
|
|
803
|
-
audit('passwords.expired', { device: deviceId, id: pend.id })
|
|
804
|
-
resolve(false)
|
|
805
|
-
}
|
|
806
|
-
}, PENDING_TTL_MS)
|
|
807
|
-
t.unref?.()
|
|
808
|
-
})
|
|
809
|
-
await notifyApprovers(pend, actaCache)
|
|
810
|
-
return espera
|
|
811
|
-
},
|
|
812
|
-
audit,
|
|
813
|
-
log,
|
|
814
|
-
}).start()
|
|
815
|
-
log('[vault] passwords: atendiendo peticiones de %d aparato(s)', passwordDevices().length)
|
|
816
|
-
// El código que se pega en la extensión o en la consola web. Lleva las DOS
|
|
817
|
-
// públicas: por la de firma enruta el proxio, a la de cifrado se le sella el
|
|
818
|
-
// contenido. Es público: no hay nada aquí que no pueda verse.
|
|
819
|
-
try {
|
|
820
|
-
const codigo = Buffer.from(JSON.stringify({
|
|
821
|
-
v: 1,
|
|
822
|
-
sign: await client.getPublicKey(),
|
|
823
|
-
enc: await identity.getEncryptionPubkey(),
|
|
824
|
-
})).toString('base64url')
|
|
825
|
-
log('[vault] passwords: enlaza un aparato con este código:\n%s', codigo)
|
|
826
|
-
} catch (e) {
|
|
827
|
-
log('[vault] passwords: no se pudo componer el código de enlace: %s', e.message)
|
|
766
|
+
/**
|
|
767
|
+
* EL SELLADO del gestor: la misma cripto de los sobres del ecosistema
|
|
768
|
+
* (`@dotrino/identity/content`), atada a la llave de cifrado de esta bóveda.
|
|
769
|
+
*
|
|
770
|
+
* Va aquí y no en el cliente del transporte porque el cliente del vault es UNO y lo
|
|
771
|
+
* comparte todo: el protocolo de la CA viaja en claro a propósito (un enrolamiento es
|
|
772
|
+
* público hasta que hay cert). Lo que se sella es lo del gestor, y por eso `isSealed`
|
|
773
|
+
* mira su marca y no otra.
|
|
774
|
+
*
|
|
775
|
+
* Sin esto la bóveda RECIBÍA los sobres y los tiraba —el cliente no tenía con qué
|
|
776
|
+
* abrirlos— y el aparato se quedaba esperando sin que nada lo dijera.
|
|
777
|
+
*/
|
|
778
|
+
const passwordSealing = {
|
|
779
|
+
async seal (msg, peerEncPub) {
|
|
780
|
+
if (!peerEncPub) throw Object.assign(new Error('no encryption key'), { code: 'unsealed' })
|
|
781
|
+
return {
|
|
782
|
+
app: 'passmanager',
|
|
783
|
+
// Destinatarios como OBJETOS y no como llaves sueltas: `encrypt` expande cada
|
|
784
|
+
// uno a todos los aparatos de esa persona, y una cadena a secas se le cae por
|
|
785
|
+
// el `continue` sin envolver nada — el sobre salía vacío y sin error.
|
|
786
|
+
sealed: await identity.encrypt([{ encryptionPubkey: peerEncPub }], JSON.stringify(msg)),
|
|
787
|
+
from: await identity.getEncryptionPubkey()
|
|
828
788
|
}
|
|
829
|
-
}
|
|
789
|
+
},
|
|
790
|
+
// `decrypt` devuelve `{ plaintext }`, no la cadena.
|
|
791
|
+
async open (env) { return JSON.parse((await identity.decrypt(env.from, null, env.sealed)).plaintext) },
|
|
792
|
+
isSealed: (m) => !!m && m.app === 'passmanager' && !!m.sealed
|
|
793
|
+
}
|
|
794
|
+
const startPasswordDesk = async () => {
|
|
795
|
+
if (passwords || !passwordDevices().length) return
|
|
796
|
+
const { createPasswordDesk } = await import('./passwords.js')
|
|
797
|
+
client.updateConfig({ sealing: passwordSealing })
|
|
798
|
+
passwords = createPasswordDesk({
|
|
799
|
+
client,
|
|
800
|
+
store: passwordsStore,
|
|
801
|
+
cek: await passwordsKey(),
|
|
802
|
+
// UNA sola condición, y es del ACTA: el aparato tiene la capacidad `passwords`.
|
|
803
|
+
// Quitársela —o quitar el aparato— le corta esto en la siguiente pasada, sin
|
|
804
|
+
// que haya una segunda lista que acordarse de tocar.
|
|
805
|
+
isAllowed: (pub) => !!actaCache && Acta.memberCan(actaCache, pub, 'passwords'),
|
|
806
|
+
encPubOf: (pub) => (actaCache?.members || []).find((m) => m.pub === pub)?.encPub || null,
|
|
807
|
+
needsApproval: (pub) => needsApproval(pub),
|
|
808
|
+
// El teléfono: se apunta el pedido, se le avisa y esta promesa espera su firma.
|
|
809
|
+
// Es el mismo camino que ya recorren los cajones de secretos.
|
|
810
|
+
approve: async ({ pubkey, op }) => {
|
|
811
|
+
const deviceId = await deviceIdOf(pubkey).catch(() => null)
|
|
812
|
+
const label = (actaCache?.members || []).find((m) => m.pub === pubkey)?.label || ''
|
|
813
|
+
const pend = approvals.request({ ns: 'passwords', device: pubkey, deviceId, label, ek: '' })
|
|
814
|
+
audit('passwords.pending', { device: deviceId, id: pend.id, op })
|
|
815
|
+
log(`[vault] passwords: ${deviceId || '????-????'} is waiting for approval (${pend.id})`)
|
|
816
|
+
const espera = new Promise((resolve) => {
|
|
817
|
+
waiters.set(pend.id, resolve)
|
|
818
|
+
// Si nadie contesta, vence solo: la promesa no se queda colgada para siempre
|
|
819
|
+
// y el aparato recibe un no en vez de esperar sin fin.
|
|
820
|
+
const t = setTimeout(() => {
|
|
821
|
+
if (waiters.delete(pend.id)) {
|
|
822
|
+
audit('passwords.expired', { device: deviceId, id: pend.id })
|
|
823
|
+
resolve(false)
|
|
824
|
+
}
|
|
825
|
+
}, PENDING_TTL_MS)
|
|
826
|
+
t.unref?.()
|
|
827
|
+
})
|
|
828
|
+
await notifyApprovers(pend, actaCache)
|
|
829
|
+
return espera
|
|
830
|
+
},
|
|
831
|
+
audit,
|
|
832
|
+
log,
|
|
833
|
+
}).start()
|
|
834
|
+
log('[vault] passwords: serving %d device(s)', passwordDevices().length)
|
|
835
|
+
}
|
|
836
|
+
try {
|
|
837
|
+
// El acta ANTES de mirarla: es ella la que dice si hay a quién responder, y sin
|
|
838
|
+
// refrescarla primero el mostrador no se levantaba nunca en un arranque en frío.
|
|
839
|
+
await refreshActa()
|
|
840
|
+
const actaSweeper = setInterval(() => {
|
|
841
|
+
refreshActa().then(startPasswordDesk).catch(() => {})
|
|
842
|
+
}, 5000)
|
|
843
|
+
actaSweeper.unref?.()
|
|
844
|
+
if (passwordDevices().length) await startPasswordDesk()
|
|
845
|
+
// Ni código de enlace ni pegar nada: un gestor entra como cualquier otro aparato.
|
|
846
|
+
else log('[vault] passwords: no device may ask yet · pair one with `dotrino-vault pair --scope contrasenas`')
|
|
830
847
|
} catch (e) {
|
|
831
848
|
log('[vault] passwords: no se pudo levantar (%s); el resto sigue igual', e.message)
|
|
832
849
|
}
|
|
@@ -1730,7 +1747,6 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
1730
1747
|
// La bóveda de contraseñas (`passwords.js`). Aquí SÍ se lista: es donde está la
|
|
1731
1748
|
// llave. Lo que no puede es listarla un aparato.
|
|
1732
1749
|
passwordDevices,
|
|
1733
|
-
setPasswordDevice,
|
|
1734
1750
|
passwordsVault: () => passwords?.vault || null,
|
|
1735
1751
|
listApprovals: () => approvals.list(),
|
|
1736
1752
|
// Aprobar desde el PC avisa igual que aprobar a distancia: el resto de tus
|