@dotrino/vaultd 0.112.1 → 0.114.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 +25 -4
- package/lib/src/proc.js +192 -0
- package/lib/src/service.js +17 -1
- package/package.json +1 -1
- package/src/approvals.js +111 -11
- package/src/ctl.js +7 -1
- package/src/manager.js +4 -1
- package/src/transport.js +54 -6
- package/src/vault.js +152 -8
package/README.md
CHANGED
|
@@ -778,10 +778,31 @@ dotrino-env run --ns claude -- node mi-script.js # el proceso espera el sí
|
|
|
778
778
|
```
|
|
779
779
|
|
|
780
780
|
…la bóveda apunta el pedido, avisa al teléfono (cola del proxio → aviso nativo en la app de
|
|
781
|
-
Dotrino), y solo su firma entrega las claves — **al proceso que pidió, en memoria**.
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
781
|
+
Dotrino), y solo su firma entrega las claves — **al proceso que pidió, en memoria**. Lo
|
|
782
|
+
denegado corta sin reintentos; lo que nadie atiende vence a los 5 min; todo queda en
|
|
783
|
+
`dotrino-vault activity`.
|
|
784
|
+
|
|
785
|
+
**El pedido dice QUÉ COMANDO pide y DESDE QUÉ CARPETA** (2026-09-11). «El aparato 904C-1002
|
|
786
|
+
pide el cajón claude» no alcanza para decidir: no distingue el arranque que acabas de lanzar
|
|
787
|
+
de cualquier otra cosa de esa máquina. Dos cosas que conviene tener claras:
|
|
788
|
+
|
|
789
|
+
- **Se mide, no se cree.** Si quien pide corre en la misma máquina que la bóveda, ella lee el
|
|
790
|
+
comando, la carpeta y el binario del propio proceso (`/proc/<pid>`) y los compara con lo
|
|
791
|
+
que dijo; si no cuadra, deniega (`ctx-mismatch`). Desde otra máquina no hay nada que leer y
|
|
792
|
+
la pantalla lo marca como *sin comprobar*. Y el dato **no autoriza nada**: cualquier proceso
|
|
793
|
+
del mismo usuario puede leer el `service-identity.json` y pedir con esa llave. La frontera
|
|
794
|
+
es el usuario, no el proceso; esto es para decidir mirando y para dejar rastro.
|
|
795
|
+
- **Va cifrado hasta el teléfono.** El comando y la carpeta son del dueño y el camino es el
|
|
796
|
+
proxio, que no cifra (CONVENCIONES §4.1): viajan sellados a la llave de cifrado del aparato
|
|
797
|
+
que aprueba. Por eso `dotrino-env` **solo lo manda por el mostrador local** — mandarlo por
|
|
798
|
+
el proxio enseñaría las rutas del disco a quien lo opere.
|
|
799
|
+
|
|
800
|
+
**Aprobar vale una hora, y se renueva con cada uso.** Ese mismo comando desde esa misma
|
|
801
|
+
carpeta entra sin volver a timbrar; cada petición vuelve a poner el reloj en una hora, así
|
|
802
|
+
que un servicio que sigue pidiendo la mantiene viva. Cambia un argumento o la carpeta y es
|
|
803
|
+
otro comando: pregunta otra vez. Las concesiones vivas se ven y se quitan en
|
|
804
|
+
`vault.dotrino.com/approvals`, viven **en memoria** (reiniciar la bóveda las borra) y un
|
|
805
|
+
pedido que no dijo qué ejecutaba no deja ninguna.
|
|
785
806
|
|
|
786
807
|
### La llave SSH como un secreto más (`dotrino-env ssh-agent`)
|
|
787
808
|
|
package/lib/src/proc.js
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* proc.js — QUIÉN ESTÁ PIDIENDO LOS SECRETOS: qué comando, desde qué carpeta.
|
|
3
|
+
*
|
|
4
|
+
* Hasta ahora un pedido de aprobación decía «el aparato 904C-1002 pide el cajón proxy» y
|
|
5
|
+
* nada más. Con eso no se puede decidir: el dueño no sabe si es el arranque que acaba de
|
|
6
|
+
* lanzar o cualquier otra cosa de la misma máquina. Lo que hace falta es el **comando** y
|
|
7
|
+
* el **path** (dueño, 2026-09-11).
|
|
8
|
+
*
|
|
9
|
+
* DOS FUENTES, Y NO VALEN LO MISMO:
|
|
10
|
+
*
|
|
11
|
+
* · `selfContext()` — lo que un proceso dice de sí mismo. Es lo que viaja en el pedido.
|
|
12
|
+
* · `readProcContext(pid)` — lo que el KERNEL dice de ese pid, leyendo `/proc`. Solo la
|
|
13
|
+
* puede usar quien está en la misma máquina, y es la que manda.
|
|
14
|
+
*
|
|
15
|
+
* Cuando el pedido entra por el mostrador local, la bóveda lee la segunda y la compara con
|
|
16
|
+
* la primera: si no cuadran, no es un matiz, es una mentira, y se deniega. Cuando entra por
|
|
17
|
+
* el proxio (un servicio en otra máquina) no hay nada que leer, y entonces lo declarado se
|
|
18
|
+
* marca como declarado — que es distinto de comprobado, y se enseña distinto.
|
|
19
|
+
*
|
|
20
|
+
* EL LÍMITE, DICHO EN VOZ ALTA: esto no es una credencial. Cualquier proceso del MISMO
|
|
21
|
+
* usuario puede leer `service-identity.json` y pedir los secretos con esa llave, y también
|
|
22
|
+
* puede llamarse como quiera (`exec -a`) y arrancar desde donde quiera. La frontera de
|
|
23
|
+
* verdad es el usuario, no el proceso. Esto existe para que quien aprueba decida con lo que
|
|
24
|
+
* está pasando delante, y para que quede rastro — no para parar a quien ya tiene la llave.
|
|
25
|
+
*
|
|
26
|
+
* En `dotrino-env run -- node server.js` el comando de destino ya va DENTRO de la línea de
|
|
27
|
+
* comandos del propio `dotrino-env`, así que se mide igual que todo lo demás: no hay que
|
|
28
|
+
* creerle nada aparte.
|
|
29
|
+
*/
|
|
30
|
+
import fs from 'node:fs'
|
|
31
|
+
import os from 'node:os'
|
|
32
|
+
import crypto from 'node:crypto'
|
|
33
|
+
|
|
34
|
+
/** Topes. Un `argv` puede ser enorme y esto viaja en cada pedido y se pinta en un teléfono. */
|
|
35
|
+
export const MAX_ARGV_BYTES = 4096
|
|
36
|
+
export const MAX_PATH_BYTES = 1024
|
|
37
|
+
/** Cuántos argumentos como mucho: más que esto no lo lee nadie. */
|
|
38
|
+
export const MAX_ARGV_ITEMS = 64
|
|
39
|
+
|
|
40
|
+
const clip = (s, max) => {
|
|
41
|
+
if (typeof s !== 'string') return ''
|
|
42
|
+
return Buffer.byteLength(s) <= max ? s : Buffer.from(s).subarray(0, max).toString('utf8')
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** ¿Hay `/proc`? Es lo que separa poder medir de tener que creer. */
|
|
46
|
+
export const hasProc = () => { try { return fs.statSync('/proc/self').isDirectory() } catch (_) { return false } }
|
|
47
|
+
|
|
48
|
+
/** `/proc/<pid>/cmdline` viene separado por NUL y termina en NUL. */
|
|
49
|
+
function readCmdline (pid) {
|
|
50
|
+
const raw = fs.readFileSync(`/proc/${pid}/cmdline`, 'utf8')
|
|
51
|
+
const parts = raw.split('\0')
|
|
52
|
+
if (parts.length && parts[parts.length - 1] === '') parts.pop()
|
|
53
|
+
return parts
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Recorta el `argv` a lo que se puede enseñar y transportar. Devuelve también si sobró
|
|
58
|
+
* algo: un comando cortado que no dice que está cortado se lee como otro comando.
|
|
59
|
+
*/
|
|
60
|
+
function trimArgv (argv) {
|
|
61
|
+
const list = (Array.isArray(argv) ? argv : []).map((a) => String(a))
|
|
62
|
+
let cut = list.length > MAX_ARGV_ITEMS
|
|
63
|
+
const kept = list.slice(0, MAX_ARGV_ITEMS)
|
|
64
|
+
let total = 0
|
|
65
|
+
const out = []
|
|
66
|
+
for (const a of kept) {
|
|
67
|
+
const b = Buffer.byteLength(a)
|
|
68
|
+
if (total + b > MAX_ARGV_BYTES) { cut = true; break }
|
|
69
|
+
total += b
|
|
70
|
+
out.push(a)
|
|
71
|
+
}
|
|
72
|
+
return { argv: out, truncated: cut }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* LA HORA DE ARRANQUE del proceso (campo 22 de `/proc/<pid>/stat`, en ticks). Sirve para
|
|
77
|
+
* saber que el pid no se recicló entre que pidió y que se miró: el nombre del proceso va
|
|
78
|
+
* entre paréntesis y puede llevar espacios, así que se corta por el ÚLTIMO `)`.
|
|
79
|
+
*/
|
|
80
|
+
function readStart (pid) {
|
|
81
|
+
try {
|
|
82
|
+
const stat = fs.readFileSync(`/proc/${pid}/stat`, 'utf8')
|
|
83
|
+
const campos = stat.slice(stat.lastIndexOf(')') + 2).split(' ')
|
|
84
|
+
const t = Number(campos[19])
|
|
85
|
+
return Number.isFinite(t) ? t : null
|
|
86
|
+
} catch (_) { return null }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Lo que el KERNEL dice del proceso `pid`. `null` si no hay `/proc`, si el proceso ya no
|
|
91
|
+
* está o si no se puede mirar (otro usuario). No se inventa nada: no poder medir se
|
|
92
|
+
* contesta como no poder medir.
|
|
93
|
+
*/
|
|
94
|
+
export function readProcContext (pid) {
|
|
95
|
+
if (!hasProc() || !Number.isInteger(pid) || pid <= 0) return null
|
|
96
|
+
try {
|
|
97
|
+
const st = fs.statSync(`/proc/${pid}`)
|
|
98
|
+
const { argv, truncated } = trimArgv(readCmdline(pid))
|
|
99
|
+
return {
|
|
100
|
+
pid,
|
|
101
|
+
uid: st.uid,
|
|
102
|
+
exe: clip(fs.readlinkSync(`/proc/${pid}/exe`), MAX_PATH_BYTES),
|
|
103
|
+
cwd: clip(fs.readlinkSync(`/proc/${pid}/cwd`), MAX_PATH_BYTES),
|
|
104
|
+
argv,
|
|
105
|
+
truncated,
|
|
106
|
+
start: readStart(pid)
|
|
107
|
+
}
|
|
108
|
+
} catch (_) { return null }
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Lo que ESTE proceso dice de sí mismo. En Linux sale de `/proc/self`, o sea de la misma
|
|
113
|
+
* fuente que va a leer la bóveda: así lo declarado y lo medido coinciden byte a byte y una
|
|
114
|
+
* diferencia significa lo que tiene que significar. Fuera de Linux se arma con lo que da
|
|
115
|
+
* Node, y entonces no hay nada que comprobar al otro lado.
|
|
116
|
+
*/
|
|
117
|
+
export function selfContext () {
|
|
118
|
+
const medido = readProcContext(process.pid)
|
|
119
|
+
if (medido) return { ...medido, user: userName(), host: os.hostname() }
|
|
120
|
+
const { argv, truncated } = trimArgv([process.execPath, ...process.argv.slice(1)])
|
|
121
|
+
return {
|
|
122
|
+
pid: process.pid,
|
|
123
|
+
uid: typeof process.getuid === 'function' ? process.getuid() : null,
|
|
124
|
+
exe: clip(process.execPath, MAX_PATH_BYTES),
|
|
125
|
+
cwd: clip(safeCwd(), MAX_PATH_BYTES),
|
|
126
|
+
argv,
|
|
127
|
+
truncated,
|
|
128
|
+
start: null,
|
|
129
|
+
user: userName(),
|
|
130
|
+
host: os.hostname()
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const safeCwd = () => { try { return process.cwd() } catch (_) { return '' } }
|
|
135
|
+
const userName = () => { try { return os.userInfo().username } catch (_) { return '' } }
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* LO QUE LLEGA POR LA RED NO SE CREE NI EN LA FORMA. Este dato lo manda otro proceso, se
|
|
139
|
+
* guarda en memoria y se pinta en un teléfono: se recorta a lo que cabe y se tira lo que no
|
|
140
|
+
* tiene el tipo que debería. Devuelve `null` si no queda nada que enseñar — que no es lo
|
|
141
|
+
* mismo que un contexto vacío, y por eso no se devuelve un objeto a medias.
|
|
142
|
+
*/
|
|
143
|
+
export function sanitizeContext (raw) {
|
|
144
|
+
if (!raw || typeof raw !== 'object') return null
|
|
145
|
+
const argvRaw = Array.isArray(raw.argv) ? raw.argv.filter((a) => typeof a === 'string') : []
|
|
146
|
+
const { argv, truncated } = trimArgv(argvRaw)
|
|
147
|
+
const exe = clip(typeof raw.exe === 'string' ? raw.exe : '', MAX_PATH_BYTES)
|
|
148
|
+
const cwd = clip(typeof raw.cwd === 'string' ? raw.cwd : '', MAX_PATH_BYTES)
|
|
149
|
+
if (!exe && !argv.length) return null
|
|
150
|
+
return {
|
|
151
|
+
pid: Number.isInteger(raw.pid) && raw.pid > 0 ? raw.pid : null,
|
|
152
|
+
uid: Number.isInteger(raw.uid) ? raw.uid : null,
|
|
153
|
+
exe,
|
|
154
|
+
cwd,
|
|
155
|
+
argv,
|
|
156
|
+
truncated: truncated || raw.truncated === true,
|
|
157
|
+
start: Number.isFinite(raw.start) ? raw.start : null,
|
|
158
|
+
user: clip(typeof raw.user === 'string' ? raw.user : '', 64),
|
|
159
|
+
host: clip(typeof raw.host === 'string' ? raw.host : '', 64)
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* LA HUELLA DEL COMANDO: qué binario, desde qué carpeta y con qué argumentos. Es la llave
|
|
165
|
+
* de la concesión por tiempo — aprobar `node server.js` en `/srv/app` no aprueba otra cosa
|
|
166
|
+
* ni la misma cosa en otro sitio.
|
|
167
|
+
*
|
|
168
|
+
* El pid NO entra: cambia en cada arranque y la gracia es justamente que el mismo comando
|
|
169
|
+
* se reconozca la próxima vez.
|
|
170
|
+
*/
|
|
171
|
+
export function commandFingerprint (ctx) {
|
|
172
|
+
if (!ctx || typeof ctx !== 'object') return null
|
|
173
|
+
const argv = Array.isArray(ctx.argv) ? ctx.argv : []
|
|
174
|
+
if (!ctx.exe && !argv.length) return null
|
|
175
|
+
const material = [ctx.exe || '', ctx.cwd || '', ...argv].join('\0')
|
|
176
|
+
return crypto.createHash('sha256').update(material).digest('hex')
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** ¿Son el mismo comando? Mismo binario, misma carpeta, mismos argumentos. */
|
|
180
|
+
export const sameCommand = (a, b) => {
|
|
181
|
+
const fa = commandFingerprint(a)
|
|
182
|
+
return !!fa && fa === commandFingerprint(b)
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** El comando en una línea, para un log o una pantalla estrecha. */
|
|
186
|
+
export function commandLine (ctx, max = 160) {
|
|
187
|
+
const argv = Array.isArray(ctx?.argv) ? ctx.argv : []
|
|
188
|
+
const linea = argv.length ? argv.join(' ') : (ctx?.exe || '')
|
|
189
|
+
return linea.length > max ? linea.slice(0, max - 1) + '…' : linea
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export default { selfContext, readProcContext, sanitizeContext, commandFingerprint, sameCommand, commandLine, hasProc }
|
package/lib/src/service.js
CHANGED
|
@@ -32,6 +32,7 @@ import { makeEphemeralKey, openSealed } from './sealed.js'
|
|
|
32
32
|
import { parseInvite } from './invite.js'
|
|
33
33
|
import { atRestFor } from './atrest.js'
|
|
34
34
|
import { connectLocal, hasLocalVault } from './localdesk.js'
|
|
35
|
+
import { selfContext } from './proc.js'
|
|
35
36
|
|
|
36
37
|
const IDENTITY_FILE = 'service-identity.json'
|
|
37
38
|
const FRESH_WINDOW_MS = 5 * 60 * 1000
|
|
@@ -611,9 +612,24 @@ export async function fetchSecrets ({ dir, ns, proxyUrl, masterPubkey, device, c
|
|
|
611
612
|
}
|
|
612
613
|
|
|
613
614
|
const eph = await makeEphemeralKey()
|
|
615
|
+
/**
|
|
616
|
+
* QUÉ COMANDO Y DESDE QUÉ CARPETA — pero SOLO por el mostrador local.
|
|
617
|
+
*
|
|
618
|
+
* Quien aprueba necesita saber qué está pidiendo las claves: «el aparato 904C-1002 pide
|
|
619
|
+
* el cajón proxy» no alcanza para decidir nada (dueño, 2026-09-11). Así que el pedido
|
|
620
|
+
* lleva el comando y el path, y la bóveda los comprueba contra `/proc`.
|
|
621
|
+
*
|
|
622
|
+
* POR EL PROXIO NO VA, y no es un olvido: el pedido de secretos viaja firmado pero EN
|
|
623
|
+
* CLARO (CONVENCIONES §4.1 — el transporte no cifra), así que meter ahí el `argv` y el
|
|
624
|
+
* `cwd` le enseñaría al proxio las rutas de tu disco y los argumentos de tus comandos,
|
|
625
|
+
* que es justo lo que este ecosistema promete que no pasa. Sin contexto no hay huella,
|
|
626
|
+
* y sin huella no hay ventana: un servicio que pide desde otra máquina sigue pidiendo
|
|
627
|
+
* aprobación cada vez. Se abrirá cuando el pedido vaya sellado (la deuda del §4.1).
|
|
628
|
+
*/
|
|
629
|
+
const ctx = client.local ? selfContext() : null
|
|
614
630
|
// `publicOnly` VIAJA FIRMADO (va dentro de `data`), así que nadie lo puede quitar ni
|
|
615
631
|
// poner por el camino. Quien decide qué se manda es la bóveda; esto solo es la petición.
|
|
616
|
-
const data = { op: 'secrets', ns, ek: eph.ek, publickey: device.publickey, ts: Date.now(), v: MY_VERSION, ...(publicOnly ? { publicOnly: true } : {}) }
|
|
632
|
+
const data = { op: 'secrets', ns, ek: eph.ek, publickey: device.publickey, ts: Date.now(), v: MY_VERSION, ...(ctx ? { ctx } : {}), ...(publicOnly ? { publicOnly: true } : {}) }
|
|
617
633
|
const { signature } = await signWithDevice({ privateJwk: device.privateJwk, data })
|
|
618
634
|
// A QUIÉN SE LE PREGUNTA: primero a la bóveda, y si no contesta, a los REPLICADORES
|
|
619
635
|
// que el acta nombraba la última vez que la vimos (`docs/replicas.md` §8: «el servicio
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dotrino/vaultd",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.114.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": {
|
package/src/approvals.js
CHANGED
|
@@ -1,23 +1,52 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* PEDIDOS DE APROBACIÓN (puro: sin red ni disco).
|
|
2
|
+
* PEDIDOS DE APROBACIÓN Y CONCESIONES (puro: sin red ni disco).
|
|
3
3
|
*
|
|
4
4
|
* Un aparato marcado con `approval on` no recibe claves privadas solo por tener el cert: el
|
|
5
5
|
* vault apunta el pedido, avisa a los aparatos con `approve` (el teléfono), y solo su firma
|
|
6
|
-
* libera los secretos.
|
|
7
|
-
* hecho eso es UNA por arranque (pide al iniciar, se queda las claves en memoria). Lo que
|
|
8
|
-
* nadie aprueba vence solo a los 5 min.
|
|
6
|
+
* libera los secretos. Lo que nadie aprueba vence solo a los 5 min.
|
|
9
7
|
*
|
|
10
|
-
* Lo que se guarda de cada pedido es lo justo para contestar después: el cajón, quién pide
|
|
11
|
-
*
|
|
8
|
+
* Lo que se guarda de cada pedido es lo justo para contestar después: el cajón, quién pide,
|
|
9
|
+
* su llave efímera `ek` (a la que se sella la respuesta) y **qué comando está corriendo y
|
|
10
|
+
* desde dónde**. Nunca un valor.
|
|
11
|
+
*
|
|
12
|
+
* LA CONCESIÓN POR COMANDO (dueño, 2026-09-11). Antes no había ventana: cada petición era un
|
|
13
|
+
* pedido, y para un servicio bien hecho eso era una por arranque. Dejó de alcanzar en cuanto
|
|
14
|
+
* los pedidos empezaron a decir QUÉ comando piden: si el dueño ya dijo que sí a ese comando
|
|
15
|
+
* exacto desde esa carpeta exacta, volver a timbrarle por lo mismo es ruido. Así que aprobar
|
|
16
|
+
* concede **una hora, contada desde el último uso** — o sea que un servicio que sigue
|
|
17
|
+
* pidiendo la mantiene viva indefinidamente, que es lo que se pidió. Lo que la corta: que
|
|
18
|
+
* cambie el comando o la carpeta (otra huella, otro pedido), una hora entera sin usarla,
|
|
19
|
+
* revocarla a mano, o reiniciar la bóveda (viven en memoria y no se escriben en ningún
|
|
20
|
+
* sitio: un permiso que sobrevive a un reinicio es un permiso que nadie recuerda haber dado).
|
|
21
|
+
*
|
|
22
|
+
* SIN COMANDO NO HAY CONCESIÓN. Un pedido que no dice qué está corriendo —porque vino de
|
|
23
|
+
* otra máquina por el proxio, donde mandarlo en claro filtraría las rutas del dueño— no
|
|
24
|
+
* tiene huella con la que emparejarse, y entonces se pide aprobación cada vez. No se
|
|
25
|
+
* inventa una huella «vacía» que valdría para todo: eso sería exactamente un repliegue.
|
|
12
26
|
*/
|
|
27
|
+
import { commandFingerprint } from '../lib/src/proc.js'
|
|
28
|
+
|
|
13
29
|
const rnd = () => [...crypto.getRandomValues(new Uint8Array(8))].map((b) => b.toString(16).padStart(2, '0')).join('')
|
|
14
30
|
|
|
31
|
+
/** Separador de la llave compuesta. Es un byte que no puede aparecer en un cajón ni en una pubkey. */
|
|
32
|
+
const SEP = String.fromCharCode(0)
|
|
33
|
+
|
|
15
34
|
export const PENDING_TTL_MS = 5 * 60 * 1000
|
|
35
|
+
/** Lo que dura una concesión SIN USARSE. Cada uso la vuelve a poner en una hora. */
|
|
36
|
+
export const GRANT_TTL_MS = 60 * 60 * 1000
|
|
16
37
|
|
|
17
38
|
export function createApprovals ({ now = Date.now, pendingTtlMs = PENDING_TTL_MS } = {}) {
|
|
18
|
-
/** id → { id, ns, device, deviceId, label, ek, ts, exp } */
|
|
39
|
+
/** id → { id, ns, device, deviceId, label, ek, ctx, ts, exp, from } */
|
|
19
40
|
const pending = new Map()
|
|
20
|
-
|
|
41
|
+
/**
|
|
42
|
+
* EL CONTEXTO VA EN LA LISTA, Y QUIEN LA SACA POR LA RED LO ENVUELVE.
|
|
43
|
+
*
|
|
44
|
+
* `ctx` es el comando y el path del dueño: por el mostrador local y en la consola de su
|
|
45
|
+
* propia máquina se enseña tal cual, pero al teléfono viaja por el proxio y ahí va dentro
|
|
46
|
+
* de un sobre (`handleApproval`). Si mañana aparece otro sitio que publique esta lista,
|
|
47
|
+
* esto es lo que hay que recordar.
|
|
48
|
+
*/
|
|
49
|
+
const publicOf = (p) => ({ id: p.id, ns: p.ns, deviceId: p.deviceId, label: p.label, ts: p.ts, exp: p.exp, ctx: p.ctx || null })
|
|
21
50
|
|
|
22
51
|
return {
|
|
23
52
|
/** Apunta un pedido. Uno por cajón y aparato: pedir otra vez reemplaza al anterior. */
|
|
@@ -26,10 +55,10 @@ export function createApprovals ({ now = Date.now, pendingTtlMs = PENDING_TTL_MS
|
|
|
26
55
|
* apruebe— y tiene que volver por el mismo sitio: desde que la bóveda atiende también
|
|
27
56
|
* por un socket local, mandarla siempre por el proxio la dejaba en el vacío.
|
|
28
57
|
*/
|
|
29
|
-
request ({ ns, device, deviceId, label = '', ek, from = null }) {
|
|
58
|
+
request ({ ns, device, deviceId, label = '', ek, ctx = null, from = null }) {
|
|
30
59
|
for (const [id, p] of pending) if (p.ns === ns && p.device === device) pending.delete(id)
|
|
31
60
|
const ts = now()
|
|
32
|
-
const p = { id: rnd(), ns, device, deviceId, label, ek, ts, exp: ts + pendingTtlMs, from }
|
|
61
|
+
const p = { id: rnd(), ns, device, deviceId, label, ek, ctx, ts, exp: ts + pendingTtlMs, from }
|
|
33
62
|
pending.set(p.id, p)
|
|
34
63
|
return publicOf(p)
|
|
35
64
|
},
|
|
@@ -51,4 +80,75 @@ export function createApprovals ({ now = Date.now, pendingTtlMs = PENDING_TTL_MS
|
|
|
51
80
|
}
|
|
52
81
|
}
|
|
53
82
|
|
|
54
|
-
|
|
83
|
+
/**
|
|
84
|
+
* LAS CONCESIONES VIVAS: «este comando, desde esta carpeta, ya está aprobado».
|
|
85
|
+
*
|
|
86
|
+
* La llave es cajón + aparato + huella del comando. Nada de esto se escribe en disco.
|
|
87
|
+
*/
|
|
88
|
+
export function createGrants ({ now = Date.now, ttlMs = GRANT_TTL_MS } = {}) {
|
|
89
|
+
/** key → { id, ns, device, deviceId, fp, ctx, by, grantedAt, lastAt, exp, uses } */
|
|
90
|
+
const grants = new Map()
|
|
91
|
+
const keyOf = (ns, device, fp) => ns + SEP + device + SEP + fp
|
|
92
|
+
const publicOf = (g) => ({
|
|
93
|
+
id: g.id, ns: g.ns, deviceId: g.deviceId, fp: g.fp, ctx: g.ctx,
|
|
94
|
+
by: g.by, grantedAt: g.grantedAt, lastAt: g.lastAt, exp: g.exp, uses: g.uses
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
const api = {
|
|
98
|
+
/**
|
|
99
|
+
* Anota que este comando quedó aprobado. Devuelve la concesión, o `null` si el pedido no
|
|
100
|
+
* traía comando: sin huella no hay nada que recordar, y el próximo pedido volverá a
|
|
101
|
+
* timbrar. Que eso se diga en el log es cosa de quien llama.
|
|
102
|
+
*/
|
|
103
|
+
grant ({ ns, device, deviceId = null, ctx = null, by = null }) {
|
|
104
|
+
const fp = commandFingerprint(ctx)
|
|
105
|
+
if (!fp) return null
|
|
106
|
+
const t = now()
|
|
107
|
+
const g = { id: rnd(), ns, device, deviceId, fp, ctx, by, grantedAt: t, lastAt: t, exp: t + ttlMs, uses: 0 }
|
|
108
|
+
grants.set(keyOf(ns, device, fp), g)
|
|
109
|
+
return publicOf(g)
|
|
110
|
+
},
|
|
111
|
+
/**
|
|
112
|
+
* ¿Está este comando ya aprobado? Si lo está, **la concesión se estira**: la hora cuenta
|
|
113
|
+
* desde el último uso, no desde que se dio. Devuelve la concesión (ya refrescada) o `null`.
|
|
114
|
+
*/
|
|
115
|
+
allows ({ ns, device, ctx }) {
|
|
116
|
+
api.sweep()
|
|
117
|
+
const fp = commandFingerprint(ctx)
|
|
118
|
+
if (!fp) return null
|
|
119
|
+
const g = grants.get(keyOf(ns, device, fp))
|
|
120
|
+
if (!g) return null
|
|
121
|
+
const t = now()
|
|
122
|
+
g.lastAt = t
|
|
123
|
+
g.exp = t + ttlMs
|
|
124
|
+
g.uses += 1
|
|
125
|
+
return publicOf(g)
|
|
126
|
+
},
|
|
127
|
+
/** Las concesiones vivas. */
|
|
128
|
+
list () { api.sweep(); return [...grants.values()].map(publicOf) },
|
|
129
|
+
/** Corta una por su id. `true` si existía. */
|
|
130
|
+
revoke (id) {
|
|
131
|
+
for (const [k, g] of grants) if (g.id === id) { grants.delete(k); return true }
|
|
132
|
+
return false
|
|
133
|
+
},
|
|
134
|
+
/** Corta todas, o las de un cajón / un aparato. Devuelve cuántas se fueron. */
|
|
135
|
+
revokeAll ({ ns = null, device = null } = {}) {
|
|
136
|
+
let n = 0
|
|
137
|
+
for (const [k, g] of grants) {
|
|
138
|
+
if (ns && g.ns !== ns) continue
|
|
139
|
+
if (device && g.device !== device) continue
|
|
140
|
+
grants.delete(k); n++
|
|
141
|
+
}
|
|
142
|
+
return n
|
|
143
|
+
},
|
|
144
|
+
/** Tira lo vencido; devuelve lo que se fue, para anotarlo. */
|
|
145
|
+
sweep () {
|
|
146
|
+
const t = now(); const gone = []
|
|
147
|
+
for (const [k, g] of grants) if (g.exp <= t) { grants.delete(k); gone.push(publicOf(g)) }
|
|
148
|
+
return gone
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return api
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export default { createApprovals, createGrants, PENDING_TTL_MS, GRANT_TTL_MS }
|
package/src/ctl.js
CHANGED
|
@@ -155,7 +155,13 @@ function cmdStatus () {
|
|
|
155
155
|
/** Una línea por perfil: nombre, id, huella y estado del candado. */
|
|
156
156
|
function describeProfile (p) {
|
|
157
157
|
const lock = !p.protected ? 'sin contraseña' : (p.locked ? `${B}🔒 bloqueado${Z}` : '🔓 desbloqueado')
|
|
158
|
-
|
|
158
|
+
// ALCANZABLE, Y SE DICE AQUÍ. Una bóveda puede estar corriendo y fuera de la red —si el
|
|
159
|
+
// proxio le rechazó el `identify`, nadie la alcanza por su pubkey— y desde fuera eso se ve
|
|
160
|
+
// igual que «no hay nada que aprobar»: el teléfono no timbra y no hay nada que mirar.
|
|
161
|
+
// `online` no viaja en las fotos de un daemon anterior, así que solo se enseña cuando el
|
|
162
|
+
// dato está y dice que no.
|
|
163
|
+
const red = p.online === false ? ` ${B}⚠ sin proxio (nadie puede alcanzarla)${Z}` : ''
|
|
164
|
+
return `${B}${p.name || '(sin nombre)'}${Z} ${p.id} ${p.fingerprint || '—'} ${lock}${red}`
|
|
159
165
|
}
|
|
160
166
|
|
|
161
167
|
function showChallenge (pe) {
|
package/src/manager.js
CHANGED
|
@@ -195,7 +195,10 @@ export async function startVaultManager ({ root = dataDir(), proxyUrl, log = con
|
|
|
195
195
|
/** Resumen para state.json / `profile ls`: identidad + candado de cada perfil. */
|
|
196
196
|
const summary = () => profiles.list().map((p) => {
|
|
197
197
|
const v = running.get(p.id)
|
|
198
|
-
|
|
198
|
+
// `online` es ALCANZABLE, no «corriendo»: la bóveda puede estar abierta y con el socket
|
|
199
|
+
// en pie y aun así fuera de la red, si el proxio le rechazó el `identify` (un reloj
|
|
200
|
+
// atrasado al arrancar, p. ej.). Sin esto, eso solo se veía en una línea del log.
|
|
201
|
+
return { ...p, fingerprint: v?.fingerprint || null, iss: v?.master || null, online: v ? !!v.isIdentified?.() : false }
|
|
199
202
|
})
|
|
200
203
|
|
|
201
204
|
return {
|
package/src/transport.js
CHANGED
|
@@ -36,7 +36,7 @@ export async function masterPubkeyOf (identity) {
|
|
|
36
36
|
* @param {string} [opts.url] URL del proxy (default wss://proxy.dotrino.com).
|
|
37
37
|
* @returns {Promise<{ client, token:string, identify():Promise<void> }>}
|
|
38
38
|
*/
|
|
39
|
-
export async function createTransport ({ identity, dir, url = DEFAULT_PROXY, commKey = null, log = () => {} }) {
|
|
39
|
+
export async function createTransport ({ identity, dir, url = DEFAULT_PROXY, commKey = null, log = () => {}, makeClient = null }) {
|
|
40
40
|
installNodeGlobals(dir)
|
|
41
41
|
// Import dinámico DESPUÉS de instalar los globals que el paquete usa.
|
|
42
42
|
// `WebSocketProxyClient` (la clase) y NO el helper `getWebSocketProxyClient`:
|
|
@@ -44,7 +44,11 @@ export async function createTransport ({ identity, dir, url = DEFAULT_PROXY, com
|
|
|
44
44
|
// conexión POR PERFIL (cada maestra se identifica con su propia pubkey ante el
|
|
45
45
|
// proxy). Con el singleton, el segundo perfil reusaba el cliente del primero y
|
|
46
46
|
// su `identify` pisaba al anterior. Sigue siendo el cliente oficial del paquete.
|
|
47
|
-
|
|
47
|
+
// `makeClient` es la costura de las PRUEBAS, y existe por una razón concreta: comprobar
|
|
48
|
+
// que un `identify` rechazado no tumba el perfil y se reintenta solo. Sin ella habría que
|
|
49
|
+
// levantar un proxio de verdad para probar el caso que ya costó una tarde. En producción
|
|
50
|
+
// no se pasa nunca y esto es el cliente oficial de siempre.
|
|
51
|
+
const { WebSocketProxyClient } = makeClient ? {} : await import('@dotrino/proxy-client')
|
|
48
52
|
|
|
49
53
|
/**
|
|
50
54
|
* EL CANAL DIRECTO, ENCENDIDO Y CON LA PUERTA CERRADA.
|
|
@@ -65,13 +69,17 @@ export async function createTransport ({ identity, dir, url = DEFAULT_PROXY, com
|
|
|
65
69
|
// el acta es asíncrono. Así que se guarda una copia y se refresca donde ya se refresca
|
|
66
70
|
// todo: en `identify`, que es lo que corre cada vez que el acta cambia o se reconecta.
|
|
67
71
|
let actaVista = null
|
|
72
|
+
// ¿Está esta bóveda ATADA al proxio ahora mismo? Conectado no es identificado: el socket
|
|
73
|
+
// puede estar perfectamente abierto y el sobre rechazado, y entonces nadie la alcanza por
|
|
74
|
+
// su pubkey. Se expone para que `status` lo enseñe en vez de dejarlo a un log del arranque.
|
|
75
|
+
let identificado = false
|
|
68
76
|
const enElActa = (token) => {
|
|
69
77
|
// SIN ACTA NO SE NEGOCIA. No se sabe quién es nadie, y en la duda no se arranca a
|
|
70
78
|
// parsear red no confiable. Se sigue por el proxio, que es el escalón que siempre está.
|
|
71
79
|
if (!actaVista) return false
|
|
72
80
|
return (actaVista.members || []).some((m) => m?.pub === token)
|
|
73
81
|
}
|
|
74
|
-
const client = new WebSocketProxyClient({
|
|
82
|
+
const client = makeClient ? makeClient({ url, acceptDirectFrom: enElActa }) : new WebSocketProxyClient({
|
|
75
83
|
url, enableWebRTC: true, acceptDirectFrom: enElActa, autoReconnect: true,
|
|
76
84
|
maxReconnectAttempts: 100000, reconnectDelay: 4000
|
|
77
85
|
})
|
|
@@ -79,6 +87,7 @@ export async function createTransport ({ identity, dir, url = DEFAULT_PROXY, com
|
|
|
79
87
|
await client.connect()
|
|
80
88
|
|
|
81
89
|
const identify = async () => {
|
|
90
|
+
identificado = false
|
|
82
91
|
if (!client.token) return
|
|
83
92
|
const record = (await identity.profileActa?.().catch(() => null))?.acta || null
|
|
84
93
|
actaVista = record // la copia que mira la puerta del canal directo
|
|
@@ -90,6 +99,7 @@ export async function createTransport ({ identity, dir, url = DEFAULT_PROXY, com
|
|
|
90
99
|
// El sobre lo arma el pilar (`identifyAs`), que le pone el destinatario.
|
|
91
100
|
try {
|
|
92
101
|
await client.identifyAs({ publickey: comm, sign: (d) => commKey.sign(d), acta: record })
|
|
102
|
+
identificado = true
|
|
93
103
|
// SE DICE QUE SE IDENTIFICÓ, y con qué llave. Este camino era MUDO: al pasar de la
|
|
94
104
|
// maestra a la llave de comunicación, el único rastro de «estoy alcanzable» que
|
|
95
105
|
// quedaba en el log era el del repliegue. Así que cuando la consola decía «no
|
|
@@ -124,13 +134,51 @@ export async function createTransport ({ identity, dir, url = DEFAULT_PROXY, com
|
|
|
124
134
|
if (!publickey) return
|
|
125
135
|
log('[vault] identifying with the master key: this vault is not in its own record yet (open the profile once to fix it)')
|
|
126
136
|
await client.identifyAs({ publickey, sign: (d) => identity.signData(d), acta: record })
|
|
137
|
+
identificado = true
|
|
127
138
|
} catch (e) {
|
|
128
139
|
log(`[vault] cannot identify on the proxy yet: ${e.message} — the profile still opens; unlock it once and it gets its own communication key`)
|
|
129
140
|
}
|
|
130
141
|
}
|
|
131
|
-
|
|
142
|
+
/**
|
|
143
|
+
* IDENTIFICARSE PUEDE FALLAR, Y ESO NO PUEDE TUMBAR EL PERFIL — NI QUEDARSE ASÍ.
|
|
144
|
+
*
|
|
145
|
+
* Esto era `await identify()` a secas, así que cualquier «no» del proxio subía por
|
|
146
|
+
* `createTransport` hasta `startVault` y el perfil **no se abría en absoluto**: no es que
|
|
147
|
+
* se quedara sin voz, es que cada petición contestaba `profile is not open` y en el
|
|
148
|
+
* `status` el perfil salía sin huella. Y no se reintentaba nunca: `identify` solo vuelve a
|
|
149
|
+
* correr con el evento `token`, o sea al RECONECTAR, y aquí el socket estaba perfectamente
|
|
150
|
+
* conectado — lo que el proxio rechazó fue el sobre.
|
|
151
|
+
*
|
|
152
|
+
* El caso real (2026-09-10): esta máquina arranca con el reloj 35 minutos atrasado (el
|
|
153
|
+
* `systemd-timesyncd` no contactó el servidor de hora hasta media hora después del
|
|
154
|
+
* arranque), el proxio rechaza el `identify` con «ts fuera de la ventana ±5min» y la
|
|
155
|
+
* bóveda se quedaba fuera de la red DESDE EL ARRANQUE HASTA QUE ALGUIEN LA REINICIABA.
|
|
156
|
+
* Con ella fuera no hay a quién timbrar: el teléfono nunca recibía un pedido de
|
|
157
|
+
* aprobación, y desde fuera eso se ve igual que «no hay pedidos».
|
|
158
|
+
*
|
|
159
|
+
* Reintentar con espera creciente lo arregla solo en cuanto el reloj se corrige, y cada
|
|
160
|
+
* intento se dice en voz alta: una bóveda inalcanzable en silencio es el fallo que ya
|
|
161
|
+
* costó un día entero (`CLAUDE.md`, el apagón del 1-2 de septiembre).
|
|
162
|
+
*/
|
|
163
|
+
let reintento = null
|
|
164
|
+
let fallos = 0
|
|
165
|
+
const identifyWithRetry = async () => {
|
|
166
|
+
if (reintento) { clearTimeout(reintento); reintento = null }
|
|
167
|
+
try {
|
|
168
|
+
await identify()
|
|
169
|
+
if (fallos) log(`[vault] identified on the proxy after ${fallos + 1} attempt(s)`)
|
|
170
|
+
fallos = 0
|
|
171
|
+
} catch (e) {
|
|
172
|
+
fallos++
|
|
173
|
+
const espera = Math.min(60000, 5000 * fallos)
|
|
174
|
+
log(`[vault] could not identify on the proxy (${e.message}) — retrying in ${Math.round(espera / 1000)}s; until then this vault is unreachable and cannot ring anyone`)
|
|
175
|
+
reintento = setTimeout(() => { reintento = null; identifyWithRetry() }, espera)
|
|
176
|
+
reintento.unref?.()
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
await identifyWithRetry()
|
|
132
180
|
// Re-identificar al reconectar (el token cambia).
|
|
133
|
-
client.on('token', () => {
|
|
181
|
+
client.on('token', () => { fallos = 0; identifyWithRetry() })
|
|
134
182
|
|
|
135
|
-
return { client, token: client.token, identify }
|
|
183
|
+
return { client, token: client.token, identify: identifyWithRetry, isIdentified: () => identificado }
|
|
136
184
|
}
|
package/src/vault.js
CHANGED
|
@@ -36,7 +36,9 @@ import { loadManifest, brokenOf } from '@dotrino/roadmap'
|
|
|
36
36
|
// espera reventaba con un ReferenceError y la aprobación del mostrador de contraseñas no
|
|
37
37
|
// llegaba a existir. Solo se veía por ese camino —el único que lo usa—, y no había prueba
|
|
38
38
|
// que lo recorriera hasta que la hubo (dotrino-test, smoke:demonio, 2026-08-30).
|
|
39
|
-
import { createApprovals, PENDING_TTL_MS } from './approvals.js'
|
|
39
|
+
import { createApprovals, createGrants, PENDING_TTL_MS, GRANT_TTL_MS } from './approvals.js'
|
|
40
|
+
import { readProcContext, sanitizeContext, commandFingerprint, commandLine } from '../lib/src/proc.js'
|
|
41
|
+
import { makeContentKey, encryptWithCek, wrapForMember } from '@dotrino/identity/content'
|
|
40
42
|
import { makeSealer } from './sealer.js'
|
|
41
43
|
import { openSealKeys } from './sealKey.js'
|
|
42
44
|
import { openCommKey, COMM_CN, COMM_CAPS } from './commKey.js'
|
|
@@ -44,6 +46,7 @@ import { seal } from '../lib/src/sealed.js'
|
|
|
44
46
|
import { localSocketPath, socketDir } from '../lib/src/localdesk.js'
|
|
45
47
|
import { dataDir, ensureDir } from './paths.js'
|
|
46
48
|
import net from 'node:net'
|
|
49
|
+
import os from 'node:os'
|
|
47
50
|
import { atRestFor, kekFor, migrateFile, encryptText, decryptText } from './atrest.js'
|
|
48
51
|
import { MSG, SCOPE, secretsScope, isValidSecretsNs, VAULT_PROTOCOL, VAULT_SPEAKS } from './protocol.js'
|
|
49
52
|
|
|
@@ -113,6 +116,13 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
113
116
|
const store = openStore(dir)
|
|
114
117
|
const threads = openThreadStore(dir)
|
|
115
118
|
const approvals = createApprovals()
|
|
119
|
+
/**
|
|
120
|
+
* LO QUE YA SE APROBÓ: comando + carpeta, por una hora desde el último uso.
|
|
121
|
+
*
|
|
122
|
+
* En memoria a propósito. Reiniciar la bóveda las borra todas, que es lo correcto: un
|
|
123
|
+
* permiso que sobrevive a un reinicio es un permiso que nadie recuerda haber dado.
|
|
124
|
+
*/
|
|
125
|
+
const grants = createGrants()
|
|
116
126
|
|
|
117
127
|
/**
|
|
118
128
|
* Pedidos de aprobación que NO son de un cajón de secretos: quien espera es una
|
|
@@ -143,6 +153,47 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
143
153
|
if (!record) return true // sin acta no se decide que sí: se pide permiso
|
|
144
154
|
return !Acta.memberCan(record, pub, 'unattended')
|
|
145
155
|
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* QUÉ COMANDO ESTÁ PIDIENDO LAS CLAVES, Y DESDE QUÉ CARPETA — medido, no creído.
|
|
159
|
+
*
|
|
160
|
+
* Quien aprueba no puede decidir con «el aparato 904C-1002 pide el cajón proxy»: necesita
|
|
161
|
+
* ver qué se está ejecutando (dueño, 2026-09-11). El que pide lo cuenta en el pedido, pero
|
|
162
|
+
* contar no es probar, así que **si dice estar en esta misma máquina la bóveda lo lee del
|
|
163
|
+
* kernel** (`/proc/<pid>`) y lo compara. Si no cuadra, no es un matiz: es una mentira, y se
|
|
164
|
+
* deniega con un código que se puede buscar.
|
|
165
|
+
*
|
|
166
|
+
* SE MIRA EL PEDIDO, NUNCA EL CANAL. Sería más cómodo comprobar «vino por el socket local»,
|
|
167
|
+
* y está prohibido: el día que un handler decide algo por dónde entró el mensaje, el atajo
|
|
168
|
+
* local deja de ser un atajo y pasa a ser una puerta de atrás (`test/mostrador-local.test.mjs`).
|
|
169
|
+
* El `host` es un dato del pedido como cualquier otro, y mentirlo no consigue nada: lo que
|
|
170
|
+
* se gana es quedar marcado como no comprobado, que es exactamente lo que se quería evitar.
|
|
171
|
+
*
|
|
172
|
+
* Tres resultados, y son tres cosas distintas:
|
|
173
|
+
* · `null` — no dijo nada. Se pide aprobación como siempre y no hay concesión posible.
|
|
174
|
+
* · `verified: 'proc'` — lo leyó el kernel de esta máquina. Es lo que hay que enseñar.
|
|
175
|
+
* · `verified: 'declared'` — lo dice quien pide y no hay forma de comprobarlo (otra
|
|
176
|
+
* máquina, o un sistema sin `/proc`). Vale para decidir, pero la pantalla lo marca.
|
|
177
|
+
*
|
|
178
|
+
* Y EL LÍMITE, QUE NO SE ESCONDE: esto no autoriza nada. Cualquier proceso del mismo
|
|
179
|
+
* usuario puede leer el `service-identity.json` del servicio y pedir con esa llave, y puede
|
|
180
|
+
* llamarse como quiera. La frontera es el usuario, no el proceso: esto existe para que
|
|
181
|
+
* quien aprueba decida mirando lo que pasa, y para que quede rastro.
|
|
182
|
+
*/
|
|
183
|
+
function contextoDelPedido (declarado) {
|
|
184
|
+
const dicho = sanitizeContext(declarado)
|
|
185
|
+
if (!dicho) return null
|
|
186
|
+
// Otra máquina: aquí no hay ningún `/proc` que hable de ese proceso.
|
|
187
|
+
if (!dicho.host || dicho.host !== os.hostname()) return { ...dicho, verified: 'declared' }
|
|
188
|
+
const medido = sanitizeContext(readProcContext(dicho.pid))
|
|
189
|
+
// El proceso ya no está, o no se puede mirar: no se sabe, y no saber se dice como no
|
|
190
|
+
// saber. Inventar un «comprobado» aquí sería el repliegue de siempre.
|
|
191
|
+
if (!medido) return { ...dicho, verified: 'declared' }
|
|
192
|
+
if (commandFingerprint(medido) !== commandFingerprint(dicho)) {
|
|
193
|
+
throw new Error(`ctx — it says it is running "${commandLine(dicho, 60)}" but pid ${dicho.pid} is "${commandLine(medido, 60)}"`)
|
|
194
|
+
}
|
|
195
|
+
return { ...medido, user: dicho.user, host: dicho.host, verified: 'proc' }
|
|
196
|
+
}
|
|
146
197
|
// LA BÓVEDA DE CONTRASEÑAS: entradas y su llave, en el dir del perfil y cifradas en
|
|
147
198
|
// reposo como todo lo demás. QUIÉN PUEDE PEDIR LO DICE EL ACTA (capacidad
|
|
148
199
|
// `passwords`), como cualquier otro permiso: aquí no hay una segunda lista de
|
|
@@ -201,7 +252,12 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
201
252
|
return key
|
|
202
253
|
}
|
|
203
254
|
|
|
204
|
-
const approvalsSweeper = setInterval(() => {
|
|
255
|
+
const approvalsSweeper = setInterval(() => {
|
|
256
|
+
for (const g of approvals.sweep()) audit('secrets.expired', { id: g.id, ns: g.ns, device: g.deviceId })
|
|
257
|
+
// Las concesiones vencidas también se anotan: si un servicio vuelve a timbrar el
|
|
258
|
+
// teléfono, esta línea es la que explica por qué (estuvo una hora sin pedir).
|
|
259
|
+
for (const g of grants.sweep()) audit('secrets.grant.expired', { id: g.id, ns: g.ns, device: g.deviceId })
|
|
260
|
+
}, 30 * 1000); approvalsSweeper.unref?.()
|
|
205
261
|
const secrets = openSecretsStore(dir, {
|
|
206
262
|
sealer: makeSealer(),
|
|
207
263
|
// A QUIÉN se le envuelve la llave de cada cajón: los servicios de ese namespace (o el
|
|
@@ -306,7 +362,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
306
362
|
}
|
|
307
363
|
await ensureCommKeyInActa()
|
|
308
364
|
|
|
309
|
-
const { client, identify: reidentificar } = await createTransport({ identity, dir, url: proxyUrl, commKey, log })
|
|
365
|
+
const { client, identify: reidentificar, isIdentified } = await createTransport({ identity, dir, url: proxyUrl, commKey, log })
|
|
310
366
|
|
|
311
367
|
// El registro público de cadenas de selladores: deposita, si hay a dónde, los eslabones
|
|
312
368
|
// que le dicen a un tercero si esta cuenta sigue sellada por quien él cree. Ver
|
|
@@ -1226,7 +1282,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
1226
1282
|
// da acceso a nada por sí solo. Quien firma esta petición ya tiene la llave de firma
|
|
1227
1283
|
// del servicio y su cert, o sea que ya lee ese namespace. No hay escalada.
|
|
1228
1284
|
if (p.data?.op === 'enckey') return handleEncKey(from, p)
|
|
1229
|
-
if (['approvals', 'approve', 'deny'].includes(p.data?.op)) return handleApproval(from, p)
|
|
1285
|
+
if (['approvals', 'approve', 'deny', 'grants', 'grant-revoke'].includes(p.data?.op)) return handleApproval(from, p)
|
|
1230
1286
|
const ns = p.data?.ns
|
|
1231
1287
|
if (!isValidSecretsNs(ns)) return reply(from, { type: MSG.ERROR, error: 'secrets: invalid namespace' })
|
|
1232
1288
|
if (typeof p.data?.ek !== 'string') return reply(from, { type: MSG.ERROR, error: 'secrets: missing ek (requester ephemeral key)' })
|
|
@@ -1263,6 +1319,27 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
1263
1319
|
if (!publicOnly && needsApproval(chk.device, record)) {
|
|
1264
1320
|
const deviceId = await deviceIdOf(chk.device).catch(() => null)
|
|
1265
1321
|
const label = (record?.members || []).find((m) => m.pub === chk.device)?.label || ''
|
|
1322
|
+
// QUÉ COMANDO, DESDE QUÉ CARPETA — medido contra `/proc` si se puede.
|
|
1323
|
+
let ctx = null
|
|
1324
|
+
try { ctx = contextoDelPedido(p.data?.ctx) } catch (e) {
|
|
1325
|
+
audit('rejected', { what: 'secrets', ns, reason: 'ctx-mismatch' })
|
|
1326
|
+
log(`[vault] ${ns}: refused ${deviceId || '????-????'} — ${e.message}`)
|
|
1327
|
+
return reply(from, { type: MSG.ERROR, code: 'ctx-mismatch', error: 'unauthorized: ' + e.message })
|
|
1328
|
+
}
|
|
1329
|
+
// ¿YA DIJISTE QUE SÍ A ESTE COMANDO? Entonces no se vuelve a timbrar: la concesión
|
|
1330
|
+
// dura una hora DESDE EL ÚLTIMO USO, así que un servicio que sigue pidiendo la
|
|
1331
|
+
// mantiene viva. Cambia el comando o la carpeta y esto ya no encaja: otro pedido.
|
|
1332
|
+
const vigente = grants.allows({ ns, device: chk.device, ctx })
|
|
1333
|
+
if (vigente) {
|
|
1334
|
+
try {
|
|
1335
|
+
const res = await resultFor(ns, chk.device, p.data.ek, record, { publicOnly: false })
|
|
1336
|
+
audit('secrets', { device: deviceId, ns, grant: vigente.id })
|
|
1337
|
+
log(`[vault] ${ns}: ${deviceId || '????-????'} served without ringing (approved command, ${vigente.uses} use(s))`)
|
|
1338
|
+
return reply(from, { type: MSG.SECRETS_RESULT, ...res })
|
|
1339
|
+
} catch (e) {
|
|
1340
|
+
return reply(from, { type: MSG.ERROR, error: 'secrets: invalid ek' })
|
|
1341
|
+
}
|
|
1342
|
+
}
|
|
1266
1343
|
// NADIE PUEDE APROBAR = SE DICE AHORA, no se deja esperando cinco minutos.
|
|
1267
1344
|
//
|
|
1268
1345
|
// La bóveda ya lo sabía —lo escribía en su propio log, «rang 0 approver(s)»— y aun
|
|
@@ -1288,9 +1365,10 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
1288
1365
|
// volver POR DONDE VINO. Si la pregunta entró por el mostrador local, mandarla por el
|
|
1289
1366
|
// proxio la deja en el vacío: quien preguntó está escuchando en el socket, no ahí.
|
|
1290
1367
|
// Lo cazó el E2E de aprobación, que se quedó esperando cinco minutos.
|
|
1291
|
-
const pend = approvals.request({ ns, device: chk.device, deviceId, label, ek: p.data.ek, from })
|
|
1368
|
+
const pend = approvals.request({ ns, device: chk.device, deviceId, label, ek: p.data.ek, ctx, from })
|
|
1292
1369
|
audit('secrets.pending', { device: deviceId, ns, id: pend.id })
|
|
1293
|
-
log(`[vault] ${ns}: ${deviceId || '????-????'} is waiting for approval (${pend.id})`
|
|
1370
|
+
log(`[vault] ${ns}: ${deviceId || '????-????'} is waiting for approval (${pend.id})` +
|
|
1371
|
+
(ctx ? ` — ${commandLine(ctx, 80)} [${ctx.cwd || '?'}]` : ' — it does not say what it is running'))
|
|
1294
1372
|
const body = { op: 'secrets.pending', ns, id: pend.id, exp: pend.exp, ts: Date.now() }
|
|
1295
1373
|
// El acta viaja también aquí: es con lo que el agente sabe qué llave podía firmar
|
|
1296
1374
|
// esto. En `secrets.result` va dentro del sobre; en un «pendiente» no hay sobre.
|
|
@@ -1381,7 +1459,20 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
1381
1459
|
body = { ...body, ts: Date.now() }
|
|
1382
1460
|
reply(from, { type: MSG.SECRETS_RESULT, body, seal: await sealOrFail(body) })
|
|
1383
1461
|
}
|
|
1384
|
-
if (op === 'approvals') return answer({ op: 'approvals', items: approvals.list() })
|
|
1462
|
+
if (op === 'approvals') return answer({ op: 'approvals', items: await conContextoSellado(approvals.list(), chk.device, record) })
|
|
1463
|
+
// LO QUE YA ESTÁ APROBADO SE VE Y SE CORTA DESDE DONDE SE APROBÓ. Una concesión que se
|
|
1464
|
+
// renueva con cada uso puede durar indefinidamente: si no se puede mirar ni quitar desde
|
|
1465
|
+
// el mismo sitio donde se dio, es un permiso invisible, y eso no lo damos por bueno.
|
|
1466
|
+
if (op === 'grants') return answer({ op: 'grants', items: await conContextoSellado(grants.list(), chk.device, record), ttlMs: GRANT_TTL_MS })
|
|
1467
|
+
if (op === 'grant-revoke') {
|
|
1468
|
+
const gid = typeof p.data?.id === 'string' ? p.data.id : ''
|
|
1469
|
+
const ok = gid ? grants.revoke(gid) : false
|
|
1470
|
+
if (ok) {
|
|
1471
|
+
audit('secrets.grant.revoked', { id: gid, by })
|
|
1472
|
+
log(`[vault] grant ${gid} revoked by ${by} — that command will ring again`)
|
|
1473
|
+
}
|
|
1474
|
+
return answer({ op: 'grant-revoke.result', id: gid, ok })
|
|
1475
|
+
}
|
|
1385
1476
|
const id = typeof p.data?.id === 'string' ? p.data.id : ''
|
|
1386
1477
|
const pend = approvals.take(id)
|
|
1387
1478
|
if (!pend) return reply(from, { type: MSG.ERROR, error: 'approval: unknown or expired request' })
|
|
@@ -1413,8 +1504,16 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
1413
1504
|
try { res = await resultFor(pend.ns, pend.device, pend.ek, record) } catch (e) {
|
|
1414
1505
|
return reply(from, { type: MSG.ERROR, error: 'approval: could not seal the reply: ' + e.message })
|
|
1415
1506
|
}
|
|
1416
|
-
|
|
1507
|
+
// LA HORA EMPIEZA AQUÍ, y se estira con cada uso: decir que sí a este comando vale para
|
|
1508
|
+
// las siguientes veces que lo pida ESE MISMO comando desde ESA MISMA carpeta. Un pedido
|
|
1509
|
+
// que no dijo qué corría no deja nada anotado, y se dice por qué: si no, el dueño vería
|
|
1510
|
+
// que unos pedidos dejan de timbrar y otros no, sin ninguna explicación a mano.
|
|
1511
|
+
const grant = grants.grant({ ns: pend.ns, device: pend.device, deviceId: pend.deviceId, ctx: pend.ctx, by })
|
|
1512
|
+
audit('secrets.approved', { device: pend.deviceId, ns: pend.ns, id, by, ...(grant ? { grant: grant.id } : {}) })
|
|
1417
1513
|
log(`[vault] ${pend.ns}: request of ${pend.deviceId} approved by ${by}`)
|
|
1514
|
+
log(grant
|
|
1515
|
+
? `[vault] ${pend.ns}: "${commandLine(pend.ctx, 80)}" approved for ${Math.round(GRANT_TTL_MS / 60000)} min from its last use (grant ${grant.id})`
|
|
1516
|
+
: `[vault] ${pend.ns}: no grant — this request did not say what it is running, so the next one will ring again`)
|
|
1418
1517
|
// Va por `sendByPubkey`: si el que pedía ya no está conectado, lo recoge al volver.
|
|
1419
1518
|
// EL FALLO NO SE TRAGA. Tragarlo era lo peor de este mostrador: la bóveda apuntaba
|
|
1420
1519
|
// «aprobado», el que pedía no recibía nada, y no quedaba una sola línea que mirar —
|
|
@@ -1429,6 +1528,43 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
1429
1528
|
return answer({ op: 'approve.result', id, ok: true })
|
|
1430
1529
|
}
|
|
1431
1530
|
|
|
1531
|
+
/**
|
|
1532
|
+
* EL COMANDO Y EL PATH VIAJAN DENTRO DE UN SOBRE, no en claro.
|
|
1533
|
+
*
|
|
1534
|
+
* La lista de pedidos sale hacia el teléfono por el proxio, que no cifra (CONVENCIONES
|
|
1535
|
+
* §4.1): mandar ahí el `argv` y el `cwd` le enseñaría al que opera el proxio las rutas del
|
|
1536
|
+
* disco del dueño y los argumentos de sus comandos, que es justo lo que este ecosistema
|
|
1537
|
+
* promete que no pasa. Así que el contexto se cifra **para el aparato que está
|
|
1538
|
+
* preguntando** —con la misma cripto de los secretos sellados: una llave de una vez,
|
|
1539
|
+
* envuelta a su `encPub` del acta— y él lo abre con `openSealedValue`, con una privada que
|
|
1540
|
+
* no sale de su dispositivo.
|
|
1541
|
+
*
|
|
1542
|
+
* Un aparato sin `encPub` en el acta no recibe el contexto, y se dice (`ctxSealed: false`)
|
|
1543
|
+
* en vez de mandárselo en claro «para que al menos lo vea»: eso sería cambiar la promesa
|
|
1544
|
+
* por comodidad, y encima sin avisar a nadie.
|
|
1545
|
+
*/
|
|
1546
|
+
async function conContextoSellado (items, aprobador, record) {
|
|
1547
|
+
const encPub = (record?.members || []).find((m) => m.pub === aprobador)?.encPub || null
|
|
1548
|
+
const salida = []
|
|
1549
|
+
for (const it of items) {
|
|
1550
|
+
const { ctx, ...resto } = it
|
|
1551
|
+
if (!ctx) { salida.push({ ...resto, ctx: null }); continue }
|
|
1552
|
+
if (!encPub) { salida.push({ ...resto, ctx: null, ctxSealed: false, ctxReason: 'no-enc-key' }); continue }
|
|
1553
|
+
try {
|
|
1554
|
+
const cek = await makeContentKey()
|
|
1555
|
+
const envelope = await encryptWithCek({ cek, gen: 0, plaintext: JSON.stringify(ctx) })
|
|
1556
|
+
const wrap = await wrapForMember({ cek, memberEncPub: encPub })
|
|
1557
|
+
salida.push({ ...resto, ctx: null, ctxSealed: true, ctxWrap: wrap, ctxEnvelope: envelope })
|
|
1558
|
+
} catch (e) {
|
|
1559
|
+
// NO SE TRAGA: sin esto, un fallo al sellar se vería en el teléfono igual que un
|
|
1560
|
+
// pedido que no dice nada, y nadie sabría que hay algo que arreglar.
|
|
1561
|
+
log(`[vault] could not seal the command of ${it.id} for ${aprobador.slice(0, 24)}…: ${e.message}`)
|
|
1562
|
+
salida.push({ ...resto, ctx: null, ctxSealed: false, ctxReason: 'seal-failed' })
|
|
1563
|
+
}
|
|
1564
|
+
}
|
|
1565
|
+
return salida
|
|
1566
|
+
}
|
|
1567
|
+
|
|
1432
1568
|
/**
|
|
1433
1569
|
* Aviso a los aparatos que aprueban (cola del proxio → timbre nativo si están apagados).
|
|
1434
1570
|
*
|
|
@@ -3179,6 +3315,9 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
3179
3315
|
|
|
3180
3316
|
return {
|
|
3181
3317
|
identity, client, store, threads, secrets, master, fingerprint: fp, dir,
|
|
3318
|
+
// Alcanzable ≠ corriendo: si el proxio rechazó el `identify` esta bóveda está viva y
|
|
3319
|
+
// nadie puede hablarle. Lo mira `status` (ver `manager.js`, `summary`).
|
|
3320
|
+
isIdentified,
|
|
3182
3321
|
// Se expone para poder PROBAR que una respuesta que no cabe no mata la conexión: es el
|
|
3183
3322
|
// único punto por el que sale todo, y el fallo que cierra vive justo ahí.
|
|
3184
3323
|
reply,
|
|
@@ -3232,6 +3371,11 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
3232
3371
|
passwordDevices,
|
|
3233
3372
|
passwordsVault: () => passwords?.vault || null,
|
|
3234
3373
|
listApprovals: () => approvals.list(),
|
|
3374
|
+
// LAS CONCESIONES VIVAS, para verlas y para cortarlas. Se enseñan en la máquina del
|
|
3375
|
+
// dueño (TUI/CLI), donde el contexto ya es suyo y no hay nada que sellar.
|
|
3376
|
+
listGrants: () => grants.list(),
|
|
3377
|
+
revokeGrant: (id) => grants.revoke(id),
|
|
3378
|
+
revokeGrants: (filtro) => grants.revokeAll(filtro || {}),
|
|
3235
3379
|
// Aprobar desde el PC avisa igual que aprobar a distancia: el resto de tus
|
|
3236
3380
|
// dispositivos se entera de que entró alguien, venga de donde venga.
|
|
3237
3381
|
approveDevice: async (code, adminKey) => {
|