@dotrino/vaultd 0.77.0 → 0.79.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/admin.js +16 -3
- package/lib/src/env.js +58 -11
- package/package.json +2 -2
- package/src/ctl.js +35 -32
- package/src/daemon.js +0 -7
- package/src/passwords.js +7 -2
- package/src/transport.js +10 -1
- package/src/tui/app.js +6 -1
- package/src/tui/i18n.js +8 -4
- package/src/vault.js +54 -21
package/lib/src/admin.js
CHANGED
|
@@ -60,7 +60,20 @@ export const ADMIN_OPS = Object.freeze([
|
|
|
60
60
|
])
|
|
61
61
|
|
|
62
62
|
/**
|
|
63
|
-
*
|
|
63
|
+
* QUÉ SE ATIENDE CON EL CANDADO ECHADO.
|
|
64
|
+
*
|
|
65
|
+
* No es «solo mirar», y confundirlo fue un error: **guardar una variable NO necesita la
|
|
66
|
+
* maestra**. Sellar un valor usa las llaves PÚBLICAS de quien va a leerlo, así que la
|
|
67
|
+
* bóveda puede hacerlo sin ningún secreto suyo (`docs/secretos-sellados.md` §8.1) — es
|
|
68
|
+
* justo lo que hace barata la rotación. Meterlo en el mismo saco que revocar dejaba la
|
|
69
|
+
* consola sin poder configurar nada con el perfil cerrado, que es su trabajo diario.
|
|
70
|
+
*
|
|
71
|
+
* Lo que SÍ se queda fuera es `revoke`: reescribe el acta, y sellar el acta es de la
|
|
72
|
+
* maestra. Cerrada no hay con qué.
|
|
73
|
+
*
|
|
74
|
+
* (Si el cajón tiene su llave sellada con la frase, repartirla a los miembros puede quedar
|
|
75
|
+
* a deber hasta que alguien abra: eso ya lo dice la bóveda y se salda solo. Guardar el
|
|
76
|
+
* valor, que es lo que se pidió, se hace igual.)
|
|
64
77
|
*
|
|
65
78
|
* El candado ES de la consola —los aparatos ya emparejados siguen leyendo, guardando y
|
|
66
79
|
* firmando con SU llave— así que cerrar el perfil tiene que cerrar exactamente esto. No
|
|
@@ -72,7 +85,7 @@ export const ADMIN_OPS = Object.freeze([
|
|
|
72
85
|
* Se dejan las de LEER a propósito: si se cortaran todas, la consola no podría ni
|
|
73
86
|
* enterarse de por qué no funciona y parecería rota. Así puede decir «está cerrada».
|
|
74
87
|
*/
|
|
75
|
-
export const ADMIN_OPS_WHILE_LOCKED = Object.freeze(['audit', 'vars'])
|
|
88
|
+
export const ADMIN_OPS_WHILE_LOCKED = Object.freeze(['audit', 'vars', 'var.set', 'var.setMany'])
|
|
76
89
|
|
|
77
90
|
/** Cuánto se recuerda un nonce ya usado (el doble de la ventana de frescura). */
|
|
78
91
|
export const ADMIN_NONCE_TTL_MS = 10 * 60 * 1000
|
|
@@ -136,7 +149,7 @@ export function createAdminDesk ({
|
|
|
136
149
|
}
|
|
137
150
|
const by = await deviceIdOf(chk.device).catch(() => null)
|
|
138
151
|
|
|
139
|
-
// CERRADA NO SE
|
|
152
|
+
// CERRADA NO SE SELLA EL ACTA. Va después de autorizar (el candado es estado del perfil,
|
|
140
153
|
// y a un desconocido no se le cuenta) y ANTES de quemar el nonce: quien reintente
|
|
141
154
|
// tras abrir la bóveda no tiene por qué generar otro.
|
|
142
155
|
//
|
package/lib/src/env.js
CHANGED
|
@@ -29,18 +29,63 @@ export function serviceRoot () {
|
|
|
29
29
|
return process.env.DOTRINO_ENV_HOME || path.join(os.homedir(), '.dotrino', 'service')
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
-
/**
|
|
33
|
-
|
|
32
|
+
/**
|
|
33
|
+
* DE QUÉ BÓVEDA VINO ESTO: `~/.dotrino/service/<bóveda>/<ns>`.
|
|
34
|
+
*
|
|
35
|
+
* Es una ETIQUETA TUYA, de esta máquina: nadie la manda por la red, no entra en el acta y
|
|
36
|
+
* no se compara con nada — puedes llamarla como quieras. Sirve solo para que dos bóvedas
|
|
37
|
+
* puedan tener un cajón que se llame igual.
|
|
38
|
+
*
|
|
39
|
+
* Se llama `--vault` y no `--profile` a propósito (dueño): `dotrino-vault --profile` ya
|
|
40
|
+
* significa OTRA cosa —el perfil de la bóveda— y los dos comandos se usan seguidos. Dos
|
|
41
|
+
* banderas con el mismo nombre y distinto significado se confunden el día que hay prisa.
|
|
42
|
+
*
|
|
43
|
+
* Antes la ruta era `<raíz>/<ns>` y el nombre del cajón era una CLAVE GLOBAL del usuario:
|
|
44
|
+
* si dos bóvedas usaban `aws`, el segundo enrolamiento pisaba al primero y lo dejaba
|
|
45
|
+
* inservible —avisaba, pero el papel del primero seguía existiendo en su bóveda sin
|
|
46
|
+
* identidad en disco que lo usara—. Obligar a nombrar el perfil quita esa colisión de
|
|
47
|
+
* raíz en vez de pedirle al usuario que se acuerde de no repetir nombres.
|
|
48
|
+
*
|
|
49
|
+
* Mismo alfabeto que el `ns` porque es lo mismo: un segmento de ruta.
|
|
50
|
+
*/
|
|
51
|
+
export const isValidVaultLabel = (p) => typeof p === 'string' && /^[a-z0-9-]{1,32}$/.test(p)
|
|
52
|
+
|
|
53
|
+
/** Directorio de la identidad del servicio `ns` bajo la etiqueta de bóveda `vault`. */
|
|
54
|
+
export function serviceDir (vault, ns) {
|
|
34
55
|
if (process.env.DOTRINO_ENV_DIR) return process.env.DOTRINO_ENV_DIR
|
|
56
|
+
if (!isValidVaultLabel(vault)) {
|
|
57
|
+
throw new Error('missing vault label: pass --vault <nombre> (use [a-z0-9-]{1,32}). It is a label of YOUR machine: it says which vault this drawer came from')
|
|
58
|
+
}
|
|
35
59
|
if (!isValidSecretsNs(ns)) throw new Error('invalid ns (use [a-z0-9-]{1,32}, e.g. "myapp")')
|
|
36
|
-
return path.join(serviceRoot(), ns)
|
|
60
|
+
return path.join(serviceRoot(), vault, ns)
|
|
37
61
|
}
|
|
38
62
|
|
|
39
|
-
/**
|
|
63
|
+
/** La etiqueta de bóveda: `--vault`, o `DOTRINO_ENV_VAULT`. */
|
|
64
|
+
export const resolveVaultLabel = (v) => v || process.env.DOTRINO_ENV_VAULT || null
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Lo enrolado en esta máquina: `[{ vault, ns }]`. Recorre DOS niveles.
|
|
68
|
+
*
|
|
69
|
+
* Y avisa de lo que quedó en el nivel de antes (`<raíz>/<ns>`): no se lee ni se usa —
|
|
70
|
+
* repescarlo en silencio sería fingir que la migración no hace falta— pero callarlo
|
|
71
|
+
* dejaría al dueño con un servicio que «desapareció» sin explicación.
|
|
72
|
+
*/
|
|
40
73
|
export function listEnrolled () {
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
74
|
+
const out = []
|
|
75
|
+
let dirs = []
|
|
76
|
+
try { dirs = fs.readdirSync(serviceRoot(), { withFileTypes: true }) } catch (_) { return out }
|
|
77
|
+
for (const d of dirs) {
|
|
78
|
+
if (!d.isDirectory() || !isValidVaultLabel(d.name)) continue
|
|
79
|
+
// Un enrolamiento del formato viejo es un directorio con `service-identity.json`
|
|
80
|
+
// DENTRO; uno del nuevo tiene subdirectorios. Así se distinguen sin adivinar.
|
|
81
|
+
if (readServiceIdentity(path.join(serviceRoot(), d.name))) { out.push({ legacy: true, ns: d.name }); continue }
|
|
82
|
+
let hijos = []
|
|
83
|
+
try { hijos = fs.readdirSync(path.join(serviceRoot(), d.name)) } catch (_) { continue }
|
|
84
|
+
for (const ns of hijos) {
|
|
85
|
+
if (isValidSecretsNs(ns) && readServiceIdentity(path.join(serviceRoot(), d.name, ns))) out.push({ vault: d.name, ns })
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return out
|
|
44
89
|
}
|
|
45
90
|
|
|
46
91
|
/**
|
|
@@ -98,10 +143,12 @@ let lastApplied = null
|
|
|
98
143
|
* `overridden` = las que YA tenían otro valor en el entorno y el vault pisó. Es
|
|
99
144
|
* el dato que delata un `.env` rancio, así que se reporta en vez de callarse.
|
|
100
145
|
*/
|
|
101
|
-
export async function loadEnv ({ ns, dir, override, wait = true, required = [], onRetry, onPending } = {}) {
|
|
146
|
+
export async function loadEnv ({ ns, vault, dir, override, wait = true, required = [], onRetry, onPending } = {}) {
|
|
102
147
|
if (override === undefined) override = overrideByDefault()
|
|
103
148
|
ns = resolveNs(ns)
|
|
104
|
-
|
|
149
|
+
// La ETIQUETA DE BÓVEDA es obligatoria salvo que se diga el directorio a pelo: es lo que
|
|
150
|
+
// hace que dos bóvedas puedan tener un cajón con el mismo nombre sin pisarse.
|
|
151
|
+
dir = dir || serviceDir(resolveVaultLabel(vault), ns)
|
|
105
152
|
const load = wait ? waitForSecrets : fetchSecrets
|
|
106
153
|
const secrets = await load({ dir, ns, onRetry, onPending })
|
|
107
154
|
|
|
@@ -199,9 +246,9 @@ export function applyEnv (secrets, override = overrideByDefault()) {
|
|
|
199
246
|
* también. Se elige 0 porque salir a propósito no es un fallo.
|
|
200
247
|
* @returns {Promise<{stop:()=>void}>}
|
|
201
248
|
*/
|
|
202
|
-
export async function watchEnv ({ ns, dir, applied = lastApplied ?? undefined, onUpdate, exitCode = 0, quiet = false, ...rest } = {}) {
|
|
249
|
+
export async function watchEnv ({ ns, vault, dir, applied = lastApplied ?? undefined, onUpdate, exitCode = 0, quiet = false, ...rest } = {}) {
|
|
203
250
|
ns = resolveNs(ns)
|
|
204
|
-
dir = dir || serviceDir(ns)
|
|
251
|
+
dir = dir || serviceDir(resolveVaultLabel(vault), ns)
|
|
205
252
|
const say = (m) => { if (!quiet) console.error(m) }
|
|
206
253
|
|
|
207
254
|
const exitNow = (reason) => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dotrino/vaultd",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.79.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.75.0",
|
|
24
24
|
"@dotrino/passmanager": "^0.9.0",
|
|
25
25
|
"@dotrino/proxy-client": "^0.13.1",
|
|
26
26
|
"ws": "^8.18.0"
|
package/src/ctl.js
CHANGED
|
@@ -202,9 +202,10 @@ async function cmdPair (args = []) {
|
|
|
202
202
|
// `secrets:<ns>`, y los dos se combinan (`--service eco --scope sign` = un bot que
|
|
203
203
|
// firma como aparato del acta y lee solo su cajón). `admin` no se empareja: se
|
|
204
204
|
// concede desde el PC (`caps <ID> +administra`).
|
|
205
|
-
//
|
|
206
|
-
//
|
|
207
|
-
|
|
205
|
+
// (Aquí vivía `--approval`. Ya no hace falta: PEDIR APROBACIÓN ES EL DEFECTO, y lo
|
|
206
|
+
// contrario —`unattended`— no se empareja, por el mismo motivo que `admin`: un QR que
|
|
207
|
+
// circula no puede conceder «llévate mis claves privadas sin preguntar». Se concede
|
|
208
|
+
// después y a mano con `caps <ID> +desatendido`.)
|
|
208
209
|
// --name <n>: CÓMO SE VA A LLAMAR el aparato que entre, decidido aquí y antes de nada.
|
|
209
210
|
// Sin esto el nombre lo ponía el propio aparato, que por defecto usa el apodo del
|
|
210
211
|
// PERFIL: acababas con varios dispositivos llamados igual que tú, y para distinguirlos
|
|
@@ -311,7 +312,7 @@ async function cmdPair (args = []) {
|
|
|
311
312
|
|
|
312
313
|
// La petición se escribe SIEMPRE (aunque no haya --service): lleva a qué perfil
|
|
313
314
|
// se empareja el dispositivo.
|
|
314
|
-
writeReq('pair-request.json', { ...(service ? { service } : {}), ...(scope ? { scope } : {}), ...(adopt ? { mode: 'adopt' } : {}), ...(
|
|
315
|
+
writeReq('pair-request.json', { ...(service ? { service } : {}), ...(scope ? { scope } : {}), ...(adopt ? { mode: 'adopt' } : {}), ...(admin ? { admin: true } : {}), ...(label ? { label } : {}) })
|
|
315
316
|
sendSignal(s.pid, 'SIGUSR1')
|
|
316
317
|
|
|
317
318
|
let pair = null
|
|
@@ -575,7 +576,7 @@ async function cmdMembers () {
|
|
|
575
576
|
// se mira quién es quién — y en la lista de variables ya seria tarde.
|
|
576
577
|
if (m.cn && !m.canSeal) console.log(' %ssin llave de cifrado: NO puede leer sus variables%s', R, Z)
|
|
577
578
|
}
|
|
578
|
-
console.log('\n Cambiar permisos: dotrino-vault caps <ID> +firma | -firma | +guarda | -guarda | +lee | -lee | +administra | +aprueba | +contrasenas | +sella | +
|
|
579
|
+
console.log('\n Cambiar permisos: dotrino-vault caps <ID> +firma | -firma | +guarda | -guarda | +lee | -lee | +administra | +aprueba | +contrasenas | +sella | +desatendido')
|
|
579
580
|
console.log(' «Permiso»: ese aparato solo recibe claves privadas cuando lo apruebas desde un aparato con «aprueba» (en cada arranque).')
|
|
580
581
|
console.log(' «Administra» deja conectar y quitar dispositivos desde ese aparato, sin venir aquí.')
|
|
581
582
|
console.log(' No deja cambiar permisos ni traspasar el mando: eso solo se hace en esta máquina.')
|
|
@@ -617,28 +618,22 @@ async function findMember (id) {
|
|
|
617
618
|
return m
|
|
618
619
|
}
|
|
619
620
|
|
|
620
|
-
|
|
621
|
-
* `dotrino-vault approval <ID> on|off
|
|
622
|
-
*
|
|
623
|
-
*
|
|
624
|
-
*
|
|
621
|
+
/*
|
|
622
|
+
* Aquí vivía `dotrino-vault approval <ID> on|off`. Ya no existe: pedir aprobación para
|
|
623
|
+
* recibir claves privadas dejó de ser una marca local de esta bóveda y pasó a ser un
|
|
624
|
+
* PERMISO DEL ACTA (`unattended`, dueño 2026-09-01). Se concede y se quita como cualquier
|
|
625
|
+
* otro, y por lo tanto viaja, se ve en la pantalla de permisos y lo respeta cualquier
|
|
626
|
+
* bóveda de la cuenta:
|
|
627
|
+
*
|
|
628
|
+
* dotrino-vault caps <ID> +desatendido ← se lleva las claves solo
|
|
629
|
+
* dotrino-vault caps <ID> -desatendido ← vuelve a pedir permiso (el defecto)
|
|
625
630
|
*/
|
|
626
|
-
async function cmdApproval (args = []) {
|
|
627
|
-
const [id, val] = args
|
|
628
|
-
if (!id || !['on', 'off'].includes(val)) { console.error('uso: dotrino-vault approval <ID> on|off'); process.exit(2) }
|
|
629
|
-
const m = await findMember(id)
|
|
630
|
-
writeReq('secret-request.json', { op: 'approval', pub: m.pub, id: m.id, approval: val === 'on' })
|
|
631
|
-
sendSignal(requireDaemon().pid, 'SIGUSR2')
|
|
632
|
-
console.log(val === 'on'
|
|
633
|
-
? `Listo: ${m.id} solo recibirá claves privadas cuando un aparato con caps <ID> +aprueba lo apruebe (en cada arranque).`
|
|
634
|
-
: `Listo: ${m.id} vuelve a recibir sus claves sin aprobación.`)
|
|
635
|
-
}
|
|
636
631
|
|
|
637
632
|
/** `dotrino-vault caps <ID> ±permiso` — cambia lo que puede hacer un dispositivo. */
|
|
638
633
|
async function cmdCaps (args = []) {
|
|
639
634
|
const [id, ...changes] = args
|
|
640
635
|
if (!id || !changes.length) {
|
|
641
|
-
console.error('uso: dotrino-vault caps <ID> +firma|-firma|+guarda|-guarda|+lee|-lee|+administra|-administra|+aprueba|-aprueba|+contrasenas|-contrasenas|+sella|-sella|+
|
|
636
|
+
console.error('uso: dotrino-vault caps <ID> +firma|-firma|+guarda|-guarda|+lee|-lee|+administra|-administra|+aprueba|-aprueba|+contrasenas|-contrasenas|+sella|-sella|+desatendido|-desatendido')
|
|
642
637
|
process.exit(2)
|
|
643
638
|
}
|
|
644
639
|
const CAP_BY_WORD = {
|
|
@@ -650,25 +645,34 @@ async function cmdCaps (args = []) {
|
|
|
650
645
|
// `contraseñas`: el gestor (la extensión, la app del teléfono) puede PEDIR
|
|
651
646
|
// credenciales de a una. Se acepta con y sin tilde: nadie escribe la ñ en una CLI.
|
|
652
647
|
contrasenas: 'passwords', 'contraseñas': 'passwords',
|
|
648
|
+
// `desatendido`: RECIBE CLAVES PRIVADAS SIN QUE NADIE APRUEBE. Sin él, la bóveda no
|
|
649
|
+
// entrega nada hasta que un aparato con `aprueba` lo firme (una vez por arranque).
|
|
650
|
+
//
|
|
651
|
+
// No se llama `permiso` —como la marca local de antes— a propósito: aquella significaba
|
|
652
|
+
// «este aparato PIDE permiso» y esta significa lo contrario. Reusar la palabra con el
|
|
653
|
+
// sentido invertido es la clase de trampa que se paga una madrugada.
|
|
654
|
+
desatendido: 'unattended', unattended: 'unattended',
|
|
653
655
|
sign: 'sign', store: 'store', read: 'read', admin: 'admin', approve: 'approve',
|
|
654
656
|
passwords: 'passwords'
|
|
655
657
|
}
|
|
656
658
|
const s = requireDaemon()
|
|
657
659
|
const m = await findMember(id)
|
|
658
660
|
|
|
659
|
-
//
|
|
660
|
-
//
|
|
661
|
-
|
|
661
|
+
// El desvío de `+permiso` a una marca local de la bóveda se fue: ahora TODO es un
|
|
662
|
+
// permiso del acta y sigue el mismo camino, incluido `desatendido`.
|
|
663
|
+
//
|
|
664
|
+
// Y `permiso` se rechaza en voz alta en vez de ignorarse: significaba lo CONTRARIO
|
|
665
|
+
// («este aparato pide aprobación»), así que quien lo teclee de memoria estaría pidiendo
|
|
666
|
+
// justo lo opuesto a lo que cree.
|
|
662
667
|
for (const c of changes) {
|
|
663
668
|
const w = c.slice(1).toLowerCase()
|
|
664
|
-
if (
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
} else rest.push(c)
|
|
669
|
+
if (w === 'permiso' || w === 'approval') {
|
|
670
|
+
console.error('`%s` ya no existe. Pedir aprobación es el DEFECTO; lo que se concede es lo contrario:', w)
|
|
671
|
+
console.error(' dotrino-vault caps %s +desatendido ← se lleva las claves privadas sin aprobación', m.id)
|
|
672
|
+
process.exit(2)
|
|
673
|
+
}
|
|
670
674
|
}
|
|
671
|
-
|
|
675
|
+
const rest = changes
|
|
672
676
|
const caps = new Set(m.caps)
|
|
673
677
|
for (const c of rest) {
|
|
674
678
|
const sign = c[0]
|
|
@@ -1495,7 +1499,6 @@ function help () {
|
|
|
1495
1499
|
pair --name <nombre> cómo se llamará el aparato que entre. Sin esto el nombre lo pone
|
|
1496
1500
|
ÉL, y por defecto usa el apodo del perfil: acabas con varios
|
|
1497
1501
|
dispositivos llamados igual que tú
|
|
1498
|
-
pair --approval el aparato que entre pedirá tu aprobación (teléfono) al recibir claves
|
|
1499
1502
|
pair --admin el aparato que entre podrá ADMINISTRAR (es lo que es una consola).
|
|
1500
1503
|
El QR no lleva nada: el permiso se aplica al aprobar el código aquí
|
|
1501
1504
|
pair --quiet escupe SOLO la invitación (una línea) y termina: sin QR y sin
|
package/src/daemon.js
CHANGED
|
@@ -143,7 +143,6 @@ export async function runDaemon () {
|
|
|
143
143
|
|
|
144
144
|
// --- SIGUSR1: iniciar emparejamiento ---
|
|
145
145
|
const pairFile = path.join(dir, 'pair.json')
|
|
146
|
-
let pendingApproval = false // lo pidió `pair --approval`; se aplica al aprobar
|
|
147
146
|
/**
|
|
148
147
|
* Lo pidió `pair --admin`: el aparato que entre por ESTA invitación podrá administrar.
|
|
149
148
|
*
|
|
@@ -199,9 +198,6 @@ export async function runDaemon () {
|
|
|
199
198
|
// La etiqueta por defecto de un servicio es su ns a secas: la lista ya marca [servicio «ns»],
|
|
200
199
|
// y el prefijo «service:» confundía (el cajón se llama claude, no service:claude).
|
|
201
200
|
const label = pairReq?.label || (isService ? pairReq.service : 'cli')
|
|
202
|
-
// `pair --approval`: el aparato que entre por esta invitación pedirá el visto bueno del
|
|
203
|
-
// teléfono cada vez que reciba claves privadas (se aplica al aprobarlo, abajo).
|
|
204
|
-
pendingApproval = !!pairReq?.approval
|
|
205
201
|
pendingAdmin = !!pairReq?.admin
|
|
206
202
|
// `profile`/`profileName`: la CUENTA del vault a la que entra el dispositivo.
|
|
207
203
|
// Con varias bóvedas en el mismo daemon, el QR sale de UNA y quien empareja
|
|
@@ -392,8 +388,6 @@ export async function runDaemon () {
|
|
|
392
388
|
const vault = targetOf(appr)
|
|
393
389
|
if (!vault) throw Object.assign(new Error('profile locked'), { code: 'PROFILE_LOCKED' })
|
|
394
390
|
const r = await vault.approveDevice(appr.code); rm(pendingEnrollFile); rm(pairFile)
|
|
395
|
-
if (pendingApproval && r?.cert?.sub) { try { await vault.setApproval(r.cert.sub, true); console.log('[vault] the new device will need approval on every key request') } catch (e) { console.error('[vault] could not flag approval: %s', e.message) } }
|
|
396
|
-
pendingApproval = false
|
|
397
391
|
// `pair --admin`: se le SUMA `admin` a lo que ya tiene, no se le reescriben los
|
|
398
392
|
// permisos — el aparato acaba de entrar con el scope que pidió la invitación.
|
|
399
393
|
if (pendingAdmin && r?.deviceId) {
|
|
@@ -542,7 +536,6 @@ export async function runDaemon () {
|
|
|
542
536
|
console.log('[vault] %d secret(s) applied in one go: %s', changed.length, sec.pub ? 'device' : sec.ns)
|
|
543
537
|
} 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) }
|
|
544
538
|
else if (sec.op === 'rm') { await vault.deleteSecret(sec.ns, sec.key); console.log('[vault] secret deleted: %s/%s', sec.ns, sec.key) }
|
|
545
|
-
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') }
|
|
546
539
|
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) }
|
|
547
540
|
else if (sec.op === 'dev-rm') { await vault.deleteDeviceSecret(sec.pub, sec.key); console.log('[vault] device secret deleted: %s', sec.key) }
|
|
548
541
|
// Saldar lo que quedó a deber: heredarle a un aparato nuevo lo ya guardado y
|
package/src/passwords.js
CHANGED
|
@@ -27,7 +27,8 @@ export const PASSWORDS_CAP = 'passwords'
|
|
|
27
27
|
* `cek` la llave de la bóveda de contraseñas (CryptoKey AES-GCM)
|
|
28
28
|
* `isAllowed(pubkey)` qué aparatos pueden pedir — lo decide el acta, no esto
|
|
29
29
|
* `encPubOf(pubkey)` su llave de cifrado, para sellarle la respuesta
|
|
30
|
-
* `needsApproval(pubkey)` si ese aparato
|
|
30
|
+
* `needsApproval(pubkey)` (async) si ese aparato tiene que pedir aprobación según el
|
|
31
|
+
* ACTA (`unattended`). Se compone con el
|
|
31
32
|
* criterio del propio protocolo: solo lo PRIVADO pregunta
|
|
32
33
|
* `approve({ pubkey, op })` pide el visto bueno (el teléfono) y espera
|
|
33
34
|
* `devices()` / `unlink(pubkey)` administración, opcional
|
|
@@ -66,7 +67,11 @@ export function createPasswordDesk (opts = {}) {
|
|
|
66
67
|
// reescribirlo aquí: si cada bóveda tuviera su idea de qué es privado, serían tres
|
|
67
68
|
// bóvedas distintas otra vez.
|
|
68
69
|
needsApproval: async (op, payload, pubkey) =>
|
|
69
|
-
needsApproval
|
|
70
|
+
// `await` OBLIGATORIO: `needsApproval` pasó a ser asíncrona (lee el acta en vivo), y
|
|
71
|
+
// una promesa SIEMPRE es verdadera. Sin el await esto decía «sí, pide aprobación»
|
|
72
|
+
// para todo el mundo, y en el otro sentido —si algún día devuelve false— diría que
|
|
73
|
+
// no hace falta. Un `&&` sobre una promesa es una comprobación que no comprueba.
|
|
74
|
+
await needsApproval(pubkey) && await responder.wantsPrivate(op, payload),
|
|
70
75
|
approve,
|
|
71
76
|
admin: devices && unlink ? { devices, unlink } : null,
|
|
72
77
|
onRequest: (r) => {
|
package/src/transport.js
CHANGED
|
@@ -66,7 +66,16 @@ export async function createTransport ({ identity, dir, url = DEFAULT_PROXY, com
|
|
|
66
66
|
if (comm && esMiembro) {
|
|
67
67
|
const data = { op: 'identify', publickey: comm, token: client.token, ts: Date.now() }
|
|
68
68
|
const signature = await commKey.sign(data)
|
|
69
|
-
if (signature)
|
|
69
|
+
if (signature) {
|
|
70
|
+
await client.identify({ data, signature, acta: record })
|
|
71
|
+
// SE DICE QUE SE IDENTIFICÓ, y con qué llave. Este camino era MUDO: al pasar de la
|
|
72
|
+
// maestra a la llave de comunicación, el único rastro de «estoy alcanzable» que
|
|
73
|
+
// quedaba en el log era el del repliegue. Así que cuando la consola decía «no
|
|
74
|
+
// contesta», no había forma de saber desde aquí si la bóveda estaba atada al proxio
|
|
75
|
+
// o no. Una línea por reconexión es barata; no tenerla cuesta una hora de sondeos.
|
|
76
|
+
log('[vault] identified on the proxy with the communication key · record #%s', record?.seq ?? '?')
|
|
77
|
+
return
|
|
78
|
+
}
|
|
70
79
|
}
|
|
71
80
|
|
|
72
81
|
// REPLIEGUE, y solo para migrar: una bóveda que todavía no ha metido su llave de
|
package/src/tui/app.js
CHANGED
|
@@ -332,8 +332,13 @@ function pairModeRows (st, t) {
|
|
|
332
332
|
* `sealer` es justo el que hay que entender para el multivault. Se ven siempre, aunque en
|
|
333
333
|
* un teléfono normal `sealer` no lo vaya a usar nadie: verlo apagado explica el modelo
|
|
334
334
|
* (dueño, 2026-08-31).
|
|
335
|
+
*
|
|
336
|
+
* `unattended` entró por lo mismo (dueño, 2026-09-01): era una marca local de la bóveda
|
|
337
|
+
* —invisible aquí y solo tocable por la CLI— y encima con el sentido invertido. Es EL
|
|
338
|
+
* permiso que hay que entender para un servidor: dice si esa máquina se lleva tus claves
|
|
339
|
+
* privadas sola o si te lo pregunta. Verlo apagado es la mitad del mensaje.
|
|
335
340
|
*/
|
|
336
|
-
const CAPS_ORDER = ['sign', 'store', 'read', 'admin', 'approve', 'passwords', 'sealer']
|
|
341
|
+
const CAPS_ORDER = ['sign', 'store', 'read', 'admin', 'approve', 'passwords', 'sealer', 'unattended']
|
|
337
342
|
|
|
338
343
|
function capsRows (st, t) {
|
|
339
344
|
const i = L(st)
|
package/src/tui/i18n.js
CHANGED
|
@@ -48,7 +48,8 @@ const es = {
|
|
|
48
48
|
admin: 'Administrar el perfil',
|
|
49
49
|
passwords: 'Pedir tus contraseñas',
|
|
50
50
|
approve: 'Aprobar pedidos',
|
|
51
|
-
sealer: 'Sellar el acta'
|
|
51
|
+
sealer: 'Sellar el acta',
|
|
52
|
+
unattended: 'Recibir claves sin aprobación'
|
|
52
53
|
},
|
|
53
54
|
capHint: {
|
|
54
55
|
sign: 'usar tu identidad en las apps del ecosistema',
|
|
@@ -61,7 +62,8 @@ const es = {
|
|
|
61
62
|
// daemon, o un navegador que se puso de bóveda). En un teléfono normal se concede y no
|
|
62
63
|
// lo usa nadie. Se enseña igual, y se dice: verlo desactivado explica el modelo;
|
|
63
64
|
// esconderlo hacía creer que no existe (dueño, 2026-08-31).
|
|
64
|
-
sealer: 'admitir aparatos y cambiar permisos él solo, si esta bóveda no está. Solo sirve en otra BÓVEDA'
|
|
65
|
+
sealer: 'admitir aparatos y cambiar permisos él solo, si esta bóveda no está. Solo sirve en otra BÓVEDA',
|
|
66
|
+
unattended: 'llevarse claves privadas SIN preguntarte. Apagado, cada vez que arranque tienes que aprobarlo desde un aparato con «Aprobar»'
|
|
65
67
|
},
|
|
66
68
|
confirmAdmin: (id) => `¿Dejar que ${id} conecte y quite dispositivos sin venir aquí?`,
|
|
67
69
|
capGiven: (n) => `Concedido: ${n}`,
|
|
@@ -358,7 +360,8 @@ const en = {
|
|
|
358
360
|
admin: 'Manage the profile',
|
|
359
361
|
passwords: 'Ask for your passwords',
|
|
360
362
|
approve: 'Approve requests',
|
|
361
|
-
sealer: 'Seal the record'
|
|
363
|
+
sealer: 'Seal the record',
|
|
364
|
+
unattended: 'Take keys without approval'
|
|
362
365
|
},
|
|
363
366
|
capHint: {
|
|
364
367
|
sign: 'use your identity across the ecosystem apps',
|
|
@@ -367,7 +370,8 @@ const en = {
|
|
|
367
370
|
admin: 'connect and remove devices from that device, without coming here',
|
|
368
371
|
passwords: 'the manager: ask for one credential per site, never the whole list',
|
|
369
372
|
approve: 'give the go-ahead when another device asks for private keys',
|
|
370
|
-
sealer: 'admit devices and change permissions on its own if this vault is gone. Only useful on another VAULT'
|
|
373
|
+
sealer: 'admit devices and change permissions on its own if this vault is gone. Only useful on another VAULT',
|
|
374
|
+
unattended: 'take private keys WITHOUT asking you. Off, you must approve it from a device with «Approve» every time it starts'
|
|
371
375
|
},
|
|
372
376
|
confirmAdmin: (id) => `Let ${id} connect and remove devices without coming here?`,
|
|
373
377
|
capGiven: (n) => `Granted: ${n}`,
|
package/src/vault.js
CHANGED
|
@@ -113,18 +113,25 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
113
113
|
* dos clases de espera.
|
|
114
114
|
*/
|
|
115
115
|
const waiters = new Map()
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
116
|
+
/**
|
|
117
|
+
* ¿ESTE APARATO TIENE QUE PEDIR PERMISO PARA RECIBIR CLAVES PRIVADAS?
|
|
118
|
+
*
|
|
119
|
+
* Lo dice el ACTA, con el permiso `unattended`: quien lo tiene se las lleva solo; quien
|
|
120
|
+
* no, espera a que un aparato con `approve` lo firme.
|
|
121
|
+
*
|
|
122
|
+
* Antes era al revés y vivía aquí: una lista local de «estos SÍ piden permiso», así que
|
|
123
|
+
* un aparato nuevo nacía pudiendo llevarse las claves y nadie elegía eso — se elegía por
|
|
124
|
+
* omisión. Ahora hay que conceder a propósito quién puede llevárselas solo, y **si falta
|
|
125
|
+
* el dato se pide permiso**, que es lo que hay que hacer cuando no se sabe (dueño,
|
|
126
|
+
* 2026-09-01).
|
|
127
|
+
*
|
|
128
|
+
* Y al estar en el acta lo respeta cualquier bóveda de la cuenta, se ve en la pantalla de
|
|
129
|
+
* permisos como los demás, y se quita quitándolo — sin acordarse de un registro escondido
|
|
130
|
+
* en una máquina.
|
|
131
|
+
*/
|
|
132
|
+
function needsApproval (pub, record) {
|
|
133
|
+
if (!record) return true // sin acta no se decide que sí: se pide permiso
|
|
134
|
+
return !Acta.memberCan(record, pub, 'unattended')
|
|
128
135
|
}
|
|
129
136
|
// LA BÓVEDA DE CONTRASEÑAS: entradas y su llave, en el dir del perfil y cifradas en
|
|
130
137
|
// reposo como todo lo demás. QUIÉN PUEDE PEDIR LO DICE EL ACTA (capacidad
|
|
@@ -285,8 +292,26 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
285
292
|
return new Set(revoked.map((r) => r.nonce))
|
|
286
293
|
}
|
|
287
294
|
|
|
295
|
+
/**
|
|
296
|
+
* CONTESTAR SIN MATAR LA CONEXIÓN.
|
|
297
|
+
*
|
|
298
|
+
* El proxio corta los frames a 1 MB (`maxPayload`), y `ws` no «descarta» el que se pasa:
|
|
299
|
+
* CIERRA EL SOCKET con un 1009. La bóveda se queda muda, sin un solo error en su log, y
|
|
300
|
+
* desde fuera se ve igual que si estuviera apagada. Eso ya pasó y duró tres días.
|
|
301
|
+
*
|
|
302
|
+
* Así que una respuesta que no quepa no se manda: se sustituye por un error, que sí cabe.
|
|
303
|
+
* Una bóveda que dice «no cabe» se arregla; una muda no se puede ni diagnosticar.
|
|
304
|
+
*/
|
|
288
305
|
const reply = (to, obj) => {
|
|
289
|
-
try {
|
|
306
|
+
try {
|
|
307
|
+
const bytes = Buffer.byteLength(JSON.stringify(obj))
|
|
308
|
+
if (bytes > MAX_REPLY_BYTES) {
|
|
309
|
+
log(`[vault] the reply to ${obj?.type} does not fit (${bytes} bytes > ${MAX_REPLY_BYTES}): sending an error instead of killing the connection`)
|
|
310
|
+
audit('reply-too-big', { type: obj?.type || null, bytes })
|
|
311
|
+
return client.send(to, { type: MSG.ERROR, error: `reply too big (${bytes} bytes): ask for less at a time` })
|
|
312
|
+
}
|
|
313
|
+
client.send(to, obj)
|
|
314
|
+
} catch (e) { log('[vault] could not reply:', e.message) }
|
|
290
315
|
}
|
|
291
316
|
|
|
292
317
|
// FRESCURA anti-replay: toda petición firmada debe traer `data.ts` dentro de una
|
|
@@ -680,7 +705,9 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
680
705
|
if (!msg.chain?.length) msg.chain = null
|
|
681
706
|
const left = msg.chain?.length || 0
|
|
682
707
|
log(`[vault] record chain trimmed to fit the transport: ${left}/${full} link(s), ${size(msg)} bytes`)
|
|
683
|
-
|
|
708
|
+
// Si tras podar la cadena SIGUE sin caber, no se avisa y se manda igual —eso mataba la
|
|
709
|
+
// conexión—: se devuelve tal cual y `reply` lo sustituye por un error. El aviso de aquí
|
|
710
|
+
// sobra porque el de `reply` dice lo mismo y además dice qué hizo.
|
|
684
711
|
return msg
|
|
685
712
|
}
|
|
686
713
|
|
|
@@ -815,7 +842,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
815
842
|
// claves en memoria y no vuelve a pedir. El pedido se apunta, se avisa a quien aprueba y se
|
|
816
843
|
// contesta «pendiente»; la respuesta de verdad sale cuando el teléfono firme
|
|
817
844
|
// (`handleApproval`), sellada a la misma `ek`.
|
|
818
|
-
if (needsApproval(chk.device)) {
|
|
845
|
+
if (needsApproval(chk.device, record)) {
|
|
819
846
|
const deviceId = await deviceIdOf(chk.device).catch(() => null)
|
|
820
847
|
const label = (record?.members || []).find((m) => m.pub === chk.device)?.label || ''
|
|
821
848
|
const pend = approvals.request({ ns, device: chk.device, deviceId, label, ek: p.data.ek })
|
|
@@ -1037,7 +1064,11 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
1037
1064
|
// que haya una segunda lista que acordarse de tocar.
|
|
1038
1065
|
isAllowed: (pub) => !!actaCache && Acta.memberCan(actaCache, pub, 'passwords'),
|
|
1039
1066
|
encPubOf: (pub) => (actaCache?.members || []).find((m) => m.pub === pub)?.encPub || null,
|
|
1040
|
-
|
|
1067
|
+
// EN VIVO, no del caché: conceder o quitar `unattended` tiene que surtir efecto en
|
|
1068
|
+
// el acto. Con el caché, la mesa de contraseñas seguía decidiendo con la foto del
|
|
1069
|
+
// último refresco — y en el sentido peligroso: un aparato al que le acabas de quitar
|
|
1070
|
+
// el permiso se seguía sirviendo solo.
|
|
1071
|
+
needsApproval: async (pub) => needsApproval(pub, await refreshActa()),
|
|
1041
1072
|
// El teléfono: se apunta el pedido, se le avisa y esta promesa espera su firma.
|
|
1042
1073
|
// Es el mismo camino que ya recorren los cajones de secretos.
|
|
1043
1074
|
approve: async ({ pubkey, op }) => {
|
|
@@ -2031,6 +2062,9 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
2031
2062
|
|
|
2032
2063
|
return {
|
|
2033
2064
|
identity, client, store, threads, secrets, master, fingerprint: fp, dir,
|
|
2065
|
+
// Se expone para poder PROBAR que una respuesta que no cabe no mata la conexión: es el
|
|
2066
|
+
// único punto por el que sale todo, y el fallo que cierra vive justo ahí.
|
|
2067
|
+
reply,
|
|
2034
2068
|
/**
|
|
2035
2069
|
* SOLTAR LA MAESTRA. Cerrar el perfil tiene que sacarla de la memoria, no solo dejar de
|
|
2036
2070
|
* cargarla en el siguiente arranque: si no, el candado seguiría siendo una bandera al
|
|
@@ -2053,11 +2087,10 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
2053
2087
|
startPairing: desk.startPairing,
|
|
2054
2088
|
stopPairing: desk.stopPairing,
|
|
2055
2089
|
listPending: desk.listPending,
|
|
2056
|
-
//
|
|
2057
|
-
//
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
supervised: () => readSupervised(),
|
|
2090
|
+
// ¿Pide permiso este aparato para recibir claves privadas? Lo dice el ACTA
|
|
2091
|
+
// (`unattended`), no una lista de esta máquina — por eso ya no hay `setApproval` ni
|
|
2092
|
+
// `supervised`: se concede y se quita como cualquier otro permiso, con `caps`.
|
|
2093
|
+
needsApproval: async (pub) => needsApproval(pub, await refreshActa()),
|
|
2061
2094
|
// La bóveda de contraseñas (`passwords.js`). Aquí SÍ se lista: es donde está la
|
|
2062
2095
|
// llave. Lo que no puede es listarla un aparato.
|
|
2063
2096
|
passwordDevices,
|