@dotrino/vaultd 0.113.0 → 0.115.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 +2 -1
- package/src/approvals.js +118 -11
- package/src/threadStore.js +60 -27
- package/src/vault.js +151 -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.115.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": {
|
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
"@dotrino/passmanager": "^0.10.1",
|
|
27
27
|
"@dotrino/proxy-client": "0.18.2",
|
|
28
28
|
"@dotrino/roadmap": "0.2.0",
|
|
29
|
+
"@dotrino/store": "0.11.0",
|
|
29
30
|
"ws": "^8.18.0"
|
|
30
31
|
},
|
|
31
32
|
"license": "MIT",
|
package/src/approvals.js
CHANGED
|
@@ -1,23 +1,59 @@
|
|
|
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.
|
|
26
|
+
*
|
|
27
|
+
* Y PARA EL GESTOR DE CONTRASEÑAS ESO ES LO QUE SE QUIERE, no una carencia que arreglar
|
|
28
|
+
* (dueño, 2026-09-11: «para el gestor de contraseñas es perfecto»). La extensión es un
|
|
29
|
+
* navegador hablando por el proxio, no un proceso con línea de comandos, así que nunca va a
|
|
30
|
+
* traer huella — y así cada acceso a una credencial vuelve a pasar por tu mano, que es la
|
|
31
|
+
* protección por la que esto existe. No se le busque un sustituto del comando para que
|
|
32
|
+
* entre en la ventana.
|
|
12
33
|
*/
|
|
34
|
+
import { commandFingerprint } from '../lib/src/proc.js'
|
|
35
|
+
|
|
13
36
|
const rnd = () => [...crypto.getRandomValues(new Uint8Array(8))].map((b) => b.toString(16).padStart(2, '0')).join('')
|
|
14
37
|
|
|
38
|
+
/** Separador de la llave compuesta. Es un byte que no puede aparecer en un cajón ni en una pubkey. */
|
|
39
|
+
const SEP = String.fromCharCode(0)
|
|
40
|
+
|
|
15
41
|
export const PENDING_TTL_MS = 5 * 60 * 1000
|
|
42
|
+
/** Lo que dura una concesión SIN USARSE. Cada uso la vuelve a poner en una hora. */
|
|
43
|
+
export const GRANT_TTL_MS = 60 * 60 * 1000
|
|
16
44
|
|
|
17
45
|
export function createApprovals ({ now = Date.now, pendingTtlMs = PENDING_TTL_MS } = {}) {
|
|
18
|
-
/** id → { id, ns, device, deviceId, label, ek, ts, exp } */
|
|
46
|
+
/** id → { id, ns, device, deviceId, label, ek, ctx, ts, exp, from } */
|
|
19
47
|
const pending = new Map()
|
|
20
|
-
|
|
48
|
+
/**
|
|
49
|
+
* EL CONTEXTO VA EN LA LISTA, Y QUIEN LA SACA POR LA RED LO ENVUELVE.
|
|
50
|
+
*
|
|
51
|
+
* `ctx` es el comando y el path del dueño: por el mostrador local y en la consola de su
|
|
52
|
+
* propia máquina se enseña tal cual, pero al teléfono viaja por el proxio y ahí va dentro
|
|
53
|
+
* de un sobre (`handleApproval`). Si mañana aparece otro sitio que publique esta lista,
|
|
54
|
+
* esto es lo que hay que recordar.
|
|
55
|
+
*/
|
|
56
|
+
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
57
|
|
|
22
58
|
return {
|
|
23
59
|
/** Apunta un pedido. Uno por cajón y aparato: pedir otra vez reemplaza al anterior. */
|
|
@@ -26,10 +62,10 @@ export function createApprovals ({ now = Date.now, pendingTtlMs = PENDING_TTL_MS
|
|
|
26
62
|
* apruebe— y tiene que volver por el mismo sitio: desde que la bóveda atiende también
|
|
27
63
|
* por un socket local, mandarla siempre por el proxio la dejaba en el vacío.
|
|
28
64
|
*/
|
|
29
|
-
request ({ ns, device, deviceId, label = '', ek, from = null }) {
|
|
65
|
+
request ({ ns, device, deviceId, label = '', ek, ctx = null, from = null }) {
|
|
30
66
|
for (const [id, p] of pending) if (p.ns === ns && p.device === device) pending.delete(id)
|
|
31
67
|
const ts = now()
|
|
32
|
-
const p = { id: rnd(), ns, device, deviceId, label, ek, ts, exp: ts + pendingTtlMs, from }
|
|
68
|
+
const p = { id: rnd(), ns, device, deviceId, label, ek, ctx, ts, exp: ts + pendingTtlMs, from }
|
|
33
69
|
pending.set(p.id, p)
|
|
34
70
|
return publicOf(p)
|
|
35
71
|
},
|
|
@@ -51,4 +87,75 @@ export function createApprovals ({ now = Date.now, pendingTtlMs = PENDING_TTL_MS
|
|
|
51
87
|
}
|
|
52
88
|
}
|
|
53
89
|
|
|
54
|
-
|
|
90
|
+
/**
|
|
91
|
+
* LAS CONCESIONES VIVAS: «este comando, desde esta carpeta, ya está aprobado».
|
|
92
|
+
*
|
|
93
|
+
* La llave es cajón + aparato + huella del comando. Nada de esto se escribe en disco.
|
|
94
|
+
*/
|
|
95
|
+
export function createGrants ({ now = Date.now, ttlMs = GRANT_TTL_MS } = {}) {
|
|
96
|
+
/** key → { id, ns, device, deviceId, fp, ctx, by, grantedAt, lastAt, exp, uses } */
|
|
97
|
+
const grants = new Map()
|
|
98
|
+
const keyOf = (ns, device, fp) => ns + SEP + device + SEP + fp
|
|
99
|
+
const publicOf = (g) => ({
|
|
100
|
+
id: g.id, ns: g.ns, deviceId: g.deviceId, fp: g.fp, ctx: g.ctx,
|
|
101
|
+
by: g.by, grantedAt: g.grantedAt, lastAt: g.lastAt, exp: g.exp, uses: g.uses
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
const api = {
|
|
105
|
+
/**
|
|
106
|
+
* Anota que este comando quedó aprobado. Devuelve la concesión, o `null` si el pedido no
|
|
107
|
+
* traía comando: sin huella no hay nada que recordar, y el próximo pedido volverá a
|
|
108
|
+
* timbrar. Que eso se diga en el log es cosa de quien llama.
|
|
109
|
+
*/
|
|
110
|
+
grant ({ ns, device, deviceId = null, ctx = null, by = null }) {
|
|
111
|
+
const fp = commandFingerprint(ctx)
|
|
112
|
+
if (!fp) return null
|
|
113
|
+
const t = now()
|
|
114
|
+
const g = { id: rnd(), ns, device, deviceId, fp, ctx, by, grantedAt: t, lastAt: t, exp: t + ttlMs, uses: 0 }
|
|
115
|
+
grants.set(keyOf(ns, device, fp), g)
|
|
116
|
+
return publicOf(g)
|
|
117
|
+
},
|
|
118
|
+
/**
|
|
119
|
+
* ¿Está este comando ya aprobado? Si lo está, **la concesión se estira**: la hora cuenta
|
|
120
|
+
* desde el último uso, no desde que se dio. Devuelve la concesión (ya refrescada) o `null`.
|
|
121
|
+
*/
|
|
122
|
+
allows ({ ns, device, ctx }) {
|
|
123
|
+
api.sweep()
|
|
124
|
+
const fp = commandFingerprint(ctx)
|
|
125
|
+
if (!fp) return null
|
|
126
|
+
const g = grants.get(keyOf(ns, device, fp))
|
|
127
|
+
if (!g) return null
|
|
128
|
+
const t = now()
|
|
129
|
+
g.lastAt = t
|
|
130
|
+
g.exp = t + ttlMs
|
|
131
|
+
g.uses += 1
|
|
132
|
+
return publicOf(g)
|
|
133
|
+
},
|
|
134
|
+
/** Las concesiones vivas. */
|
|
135
|
+
list () { api.sweep(); return [...grants.values()].map(publicOf) },
|
|
136
|
+
/** Corta una por su id. `true` si existía. */
|
|
137
|
+
revoke (id) {
|
|
138
|
+
for (const [k, g] of grants) if (g.id === id) { grants.delete(k); return true }
|
|
139
|
+
return false
|
|
140
|
+
},
|
|
141
|
+
/** Corta todas, o las de un cajón / un aparato. Devuelve cuántas se fueron. */
|
|
142
|
+
revokeAll ({ ns = null, device = null } = {}) {
|
|
143
|
+
let n = 0
|
|
144
|
+
for (const [k, g] of grants) {
|
|
145
|
+
if (ns && g.ns !== ns) continue
|
|
146
|
+
if (device && g.device !== device) continue
|
|
147
|
+
grants.delete(k); n++
|
|
148
|
+
}
|
|
149
|
+
return n
|
|
150
|
+
},
|
|
151
|
+
/** Tira lo vencido; devuelve lo que se fue, para anotarlo. */
|
|
152
|
+
sweep () {
|
|
153
|
+
const t = now(); const gone = []
|
|
154
|
+
for (const [k, g] of grants) if (g.exp <= t) { grants.delete(k); gone.push(publicOf(g)) }
|
|
155
|
+
return gone
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return api
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export default { createApprovals, createGrants, PENDING_TTL_MS, GRANT_TTL_MS }
|
package/src/threadStore.js
CHANGED
|
@@ -14,10 +14,14 @@
|
|
|
14
14
|
* que el ecosistema promete que no queda en claro en ningún disco.
|
|
15
15
|
*/
|
|
16
16
|
import path from 'node:path'
|
|
17
|
+
import * as core from '@dotrino/store/core'
|
|
17
18
|
import { readJson, writeJson } from './paths.js'
|
|
18
19
|
import { atRestFor } from './atrest.js'
|
|
19
20
|
|
|
20
|
-
|
|
21
|
+
// El MISMO tope que acepta la página del almacén. Era 1000: una app que guarda más en un
|
|
22
|
+
// hilo (los compradores de facturero, por ejemplo) perdía aquí lo más viejo sin decir nada,
|
|
23
|
+
// y el navegador lo volvía a subir en cada sincronización.
|
|
24
|
+
const MAX_PER_THREAD = core.MAX_PER_THREAD_LIMIT
|
|
21
25
|
|
|
22
26
|
// DATOS SENSIBLES (F4): topes para que un dispositivo con `vault:store` no pueda
|
|
23
27
|
// llenar el disco de la bóveda. Son generosos para el uso real (unas contraseñas,
|
|
@@ -33,24 +37,31 @@ export function openThreadStore (dir) {
|
|
|
33
37
|
if (!data.threads) data.threads = {}
|
|
34
38
|
if (!data.opens) data.opens = {}
|
|
35
39
|
if (!data.secure) data.secure = {}
|
|
40
|
+
// Lápidas de lo borrado (`@dotrino/store/core`): sin ellas un borrado vuelve desde el
|
|
41
|
+
// primer aparato que todavía tenga la entrada.
|
|
42
|
+
if (!data.tombs) data.tombs = {}
|
|
43
|
+
core.pruneTombs(data.tombs, Date.now())
|
|
36
44
|
const save = () => writeJson(file, data, atRest)
|
|
37
45
|
save() // reescribe al abrir: cifra lo que venía en claro
|
|
38
|
-
const
|
|
46
|
+
const budget = (n) => Math.min(Math.max(Number(n) || core.PAGE_BYTES, 1024), core.PAGE_BYTES)
|
|
47
|
+
const needKeys = (keys) => {
|
|
48
|
+
if (!Array.isArray(keys) || !keys.every((k) => typeof k === 'string')) throw new Error('keys must be a list of thread keys')
|
|
49
|
+
return keys
|
|
50
|
+
}
|
|
51
|
+
const needMap = (value, what) => {
|
|
52
|
+
if (value == null) return {}
|
|
53
|
+
if (typeof value !== 'object' || Array.isArray(value)) throw new Error(`${what} must be an object`)
|
|
54
|
+
return value
|
|
55
|
+
}
|
|
39
56
|
|
|
40
57
|
const methods = {
|
|
41
58
|
appendMessage ({ threadKey, entry }) {
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
if (!entry.id) entry.id = crypto.randomUUID()
|
|
45
|
-
if (!entry.ts) entry.ts = Date.now()
|
|
46
|
-
const arr = data.threads[threadKey] || (data.threads[threadKey] = [])
|
|
47
|
-
const i = arr.findIndex((e) => e.id === entry.id)
|
|
48
|
-
if (i >= 0) arr[i] = { ...arr[i], ...entry }; else arr.push(entry)
|
|
49
|
-
trim(arr); save(); return entry
|
|
59
|
+
core.writeEntry(data.threads, data.tombs, threadKey, entry, { max: MAX_PER_THREAD, now: Date.now(), newId: () => crypto.randomUUID() })
|
|
60
|
+
save(); return entry
|
|
50
61
|
},
|
|
51
62
|
listThread ({ threadKey, limit, before }) {
|
|
52
|
-
if (!threadKey) return []
|
|
53
|
-
let arr = data.threads[threadKey]
|
|
63
|
+
if (!core.validKey(threadKey) || !Object.hasOwn(data.threads, threadKey)) return []
|
|
64
|
+
let arr = data.threads[threadKey]
|
|
54
65
|
if (typeof before === 'number') arr = arr.filter((e) => (e.ts || 0) < before)
|
|
55
66
|
if (typeof limit === 'number' && limit > 0) arr = arr.slice(-limit)
|
|
56
67
|
return arr
|
|
@@ -62,14 +73,14 @@ export function openThreadStore (dir) {
|
|
|
62
73
|
return out
|
|
63
74
|
},
|
|
64
75
|
removeThread ({ threadKey }) {
|
|
65
|
-
const removed = data.threads
|
|
66
|
-
|
|
76
|
+
const removed = core.removeWholeThread(data.threads, data.tombs, threadKey, Date.now())
|
|
77
|
+
if (removed) save()
|
|
78
|
+
return { removed }
|
|
67
79
|
},
|
|
68
80
|
removeMessage ({ threadKey, id }) {
|
|
69
|
-
const
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
save(); return { removed: before - (data.threads[threadKey]?.length || 0) }
|
|
81
|
+
const removed = core.removeEntry(data.threads, data.tombs, threadKey, id, Date.now())
|
|
82
|
+
if (removed) save()
|
|
83
|
+
return { removed }
|
|
73
84
|
},
|
|
74
85
|
recordOpen ({ appId }) {
|
|
75
86
|
if (!appId || typeof appId !== 'string') throw new Error('appId required')
|
|
@@ -79,17 +90,38 @@ export function openThreadStore (dir) {
|
|
|
79
90
|
},
|
|
80
91
|
getOpens () { return { ...data.opens } },
|
|
81
92
|
clearOpens () { data.opens = {}; save(); return { ok: true } },
|
|
93
|
+
/** Mezcla el contador de un aparato (el mayor de cada lado) y devuelve el resultado. */
|
|
94
|
+
mergeOpens ({ opens }) {
|
|
95
|
+
if (core.mergeOpens(data.opens, needMap(opens, 'opens'))) save()
|
|
96
|
+
return { ...data.opens }
|
|
97
|
+
},
|
|
82
98
|
exportThreads () { return { threads: data.threads } },
|
|
83
|
-
|
|
99
|
+
// ----- SINCRONIZAR POR PARTES (@dotrino/store ≥ 0.11) -----
|
|
100
|
+
// `exportThreads`/`importThreads` a secas mueven TODO en un mensaje, y el proxio corta
|
|
101
|
+
// los frames en 1 MB: con unos cientos de KB de datos la sincronización dejaba de caber.
|
|
102
|
+
// Con esto el aparato pregunta qué hilos difieren (una huella por hilo), baja su índice
|
|
103
|
+
// (id y ts) y pide solo las entradas que le faltan, todo por páginas con tope de bytes.
|
|
104
|
+
// Las reglas —huella, mezcla, lápidas— son las de `@dotrino/store/core`, las mismas que
|
|
105
|
+
// aplica el navegador: si cada lado llevara las suyas, las huellas no coincidirían nunca.
|
|
106
|
+
getThreadDigests ({ keys } = {}) {
|
|
107
|
+
return core.digestsOf(data.threads, keys == null ? undefined : needKeys(keys))
|
|
108
|
+
},
|
|
109
|
+
getThreadIndexes ({ keys, cursor, maxBytes }) {
|
|
110
|
+
return core.indexPage(data.threads, data.tombs, needKeys(keys), cursor || null, budget(maxBytes))
|
|
111
|
+
},
|
|
112
|
+
getEntries ({ refs, maxBytes }) {
|
|
113
|
+
return core.entriesPage(data.threads, needMap(refs, 'refs'), budget(maxBytes))
|
|
114
|
+
},
|
|
115
|
+
importThreads ({ threads, tombs, mode = 'merge' }) {
|
|
84
116
|
if (!threads || typeof threads !== 'object') throw new Error('threads required')
|
|
85
117
|
if (mode === 'replace') { data.threads = threads; save(); return { mode, count: Object.keys(threads).length } }
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
118
|
+
if (mode !== 'merge' && mode !== 'upsert') throw new Error(`unknown import mode: ${mode}`)
|
|
119
|
+
const now = Date.now()
|
|
120
|
+
const buried = core.applyTombs(data.threads, data.tombs, needMap(tombs, 'tombs'), now)
|
|
121
|
+
const merged = core.mergeEntries(data.threads, data.tombs, threads, { mode, max: MAX_PER_THREAD })
|
|
122
|
+
core.pruneTombs(data.tombs, now)
|
|
123
|
+
if (buried.tombsChanged || buried.changed.size || merged.size) save()
|
|
124
|
+
return { mode, count: Object.keys(data.threads).length, changed: [...new Set([...buried.changed, ...merged])] }
|
|
93
125
|
},
|
|
94
126
|
// ----- PERFIL del usuario (me): el vault es la copia AUTORITATIVA -----
|
|
95
127
|
// Cada dispositivo emparejado lo empuja al editarlo y lo jala al arrancar →
|
|
@@ -182,7 +214,8 @@ export function openThreadStore (dir) {
|
|
|
182
214
|
* dispositivo al que solo le diste «leer» no lee tus contraseñas.
|
|
183
215
|
*/
|
|
184
216
|
export const STORE_READ_METHODS = new Set([
|
|
185
|
-
'listThread', 'listThreadKeys', 'getThreadSummaries', 'getOpens', 'exportThreads', 'getStats', 'profileGet'
|
|
217
|
+
'listThread', 'listThreadKeys', 'getThreadSummaries', 'getOpens', 'exportThreads', 'getStats', 'profileGet',
|
|
218
|
+
'getThreadDigests', 'getThreadIndexes', 'getEntries'
|
|
186
219
|
])
|
|
187
220
|
|
|
188
221
|
/**
|
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
|
|
@@ -880,7 +936,9 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
880
936
|
// `method: 'toString'` (o cualquier miembro heredado de Object) pasaba el filtro y
|
|
881
937
|
// se llamaba como si fuera del store.
|
|
882
938
|
if (!d || typeof d.method !== 'string' || !Object.hasOwn(threads.methods, d.method)) {
|
|
883
|
-
|
|
939
|
+
// Con `code`: un aparato más nuevo que esta bóveda pide métodos que aquí no existen, y
|
|
940
|
+
// tiene que poder decir «actualiza la bóveda» en vez de enseñar la frase.
|
|
941
|
+
return reply(from, { type: MSG.ERROR, error: 'store: invalid method', code: 'store-unknown-method' })
|
|
884
942
|
}
|
|
885
943
|
if (!isFresh(d)) return staleReply(from)
|
|
886
944
|
// EL PERFIL VA POR OTRO CAMINO, y por eso se atiende antes que nada
|
|
@@ -1226,7 +1284,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
1226
1284
|
// da acceso a nada por sí solo. Quien firma esta petición ya tiene la llave de firma
|
|
1227
1285
|
// del servicio y su cert, o sea que ya lee ese namespace. No hay escalada.
|
|
1228
1286
|
if (p.data?.op === 'enckey') return handleEncKey(from, p)
|
|
1229
|
-
if (['approvals', 'approve', 'deny'].includes(p.data?.op)) return handleApproval(from, p)
|
|
1287
|
+
if (['approvals', 'approve', 'deny', 'grants', 'grant-revoke'].includes(p.data?.op)) return handleApproval(from, p)
|
|
1230
1288
|
const ns = p.data?.ns
|
|
1231
1289
|
if (!isValidSecretsNs(ns)) return reply(from, { type: MSG.ERROR, error: 'secrets: invalid namespace' })
|
|
1232
1290
|
if (typeof p.data?.ek !== 'string') return reply(from, { type: MSG.ERROR, error: 'secrets: missing ek (requester ephemeral key)' })
|
|
@@ -1263,6 +1321,27 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
1263
1321
|
if (!publicOnly && needsApproval(chk.device, record)) {
|
|
1264
1322
|
const deviceId = await deviceIdOf(chk.device).catch(() => null)
|
|
1265
1323
|
const label = (record?.members || []).find((m) => m.pub === chk.device)?.label || ''
|
|
1324
|
+
// QUÉ COMANDO, DESDE QUÉ CARPETA — medido contra `/proc` si se puede.
|
|
1325
|
+
let ctx = null
|
|
1326
|
+
try { ctx = contextoDelPedido(p.data?.ctx) } catch (e) {
|
|
1327
|
+
audit('rejected', { what: 'secrets', ns, reason: 'ctx-mismatch' })
|
|
1328
|
+
log(`[vault] ${ns}: refused ${deviceId || '????-????'} — ${e.message}`)
|
|
1329
|
+
return reply(from, { type: MSG.ERROR, code: 'ctx-mismatch', error: 'unauthorized: ' + e.message })
|
|
1330
|
+
}
|
|
1331
|
+
// ¿YA DIJISTE QUE SÍ A ESTE COMANDO? Entonces no se vuelve a timbrar: la concesión
|
|
1332
|
+
// dura una hora DESDE EL ÚLTIMO USO, así que un servicio que sigue pidiendo la
|
|
1333
|
+
// mantiene viva. Cambia el comando o la carpeta y esto ya no encaja: otro pedido.
|
|
1334
|
+
const vigente = grants.allows({ ns, device: chk.device, ctx })
|
|
1335
|
+
if (vigente) {
|
|
1336
|
+
try {
|
|
1337
|
+
const res = await resultFor(ns, chk.device, p.data.ek, record, { publicOnly: false })
|
|
1338
|
+
audit('secrets', { device: deviceId, ns, grant: vigente.id })
|
|
1339
|
+
log(`[vault] ${ns}: ${deviceId || '????-????'} served without ringing (approved command, ${vigente.uses} use(s))`)
|
|
1340
|
+
return reply(from, { type: MSG.SECRETS_RESULT, ...res })
|
|
1341
|
+
} catch (e) {
|
|
1342
|
+
return reply(from, { type: MSG.ERROR, error: 'secrets: invalid ek' })
|
|
1343
|
+
}
|
|
1344
|
+
}
|
|
1266
1345
|
// NADIE PUEDE APROBAR = SE DICE AHORA, no se deja esperando cinco minutos.
|
|
1267
1346
|
//
|
|
1268
1347
|
// La bóveda ya lo sabía —lo escribía en su propio log, «rang 0 approver(s)»— y aun
|
|
@@ -1288,9 +1367,10 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
1288
1367
|
// volver POR DONDE VINO. Si la pregunta entró por el mostrador local, mandarla por el
|
|
1289
1368
|
// proxio la deja en el vacío: quien preguntó está escuchando en el socket, no ahí.
|
|
1290
1369
|
// 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 })
|
|
1370
|
+
const pend = approvals.request({ ns, device: chk.device, deviceId, label, ek: p.data.ek, ctx, from })
|
|
1292
1371
|
audit('secrets.pending', { device: deviceId, ns, id: pend.id })
|
|
1293
|
-
log(`[vault] ${ns}: ${deviceId || '????-????'} is waiting for approval (${pend.id})`
|
|
1372
|
+
log(`[vault] ${ns}: ${deviceId || '????-????'} is waiting for approval (${pend.id})` +
|
|
1373
|
+
(ctx ? ` — ${commandLine(ctx, 80)} [${ctx.cwd || '?'}]` : ' — it does not say what it is running'))
|
|
1294
1374
|
const body = { op: 'secrets.pending', ns, id: pend.id, exp: pend.exp, ts: Date.now() }
|
|
1295
1375
|
// El acta viaja también aquí: es con lo que el agente sabe qué llave podía firmar
|
|
1296
1376
|
// esto. En `secrets.result` va dentro del sobre; en un «pendiente» no hay sobre.
|
|
@@ -1381,7 +1461,20 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
1381
1461
|
body = { ...body, ts: Date.now() }
|
|
1382
1462
|
reply(from, { type: MSG.SECRETS_RESULT, body, seal: await sealOrFail(body) })
|
|
1383
1463
|
}
|
|
1384
|
-
if (op === 'approvals') return answer({ op: 'approvals', items: approvals.list() })
|
|
1464
|
+
if (op === 'approvals') return answer({ op: 'approvals', items: await conContextoSellado(approvals.list(), chk.device, record) })
|
|
1465
|
+
// LO QUE YA ESTÁ APROBADO SE VE Y SE CORTA DESDE DONDE SE APROBÓ. Una concesión que se
|
|
1466
|
+
// renueva con cada uso puede durar indefinidamente: si no se puede mirar ni quitar desde
|
|
1467
|
+
// el mismo sitio donde se dio, es un permiso invisible, y eso no lo damos por bueno.
|
|
1468
|
+
if (op === 'grants') return answer({ op: 'grants', items: await conContextoSellado(grants.list(), chk.device, record), ttlMs: GRANT_TTL_MS })
|
|
1469
|
+
if (op === 'grant-revoke') {
|
|
1470
|
+
const gid = typeof p.data?.id === 'string' ? p.data.id : ''
|
|
1471
|
+
const ok = gid ? grants.revoke(gid) : false
|
|
1472
|
+
if (ok) {
|
|
1473
|
+
audit('secrets.grant.revoked', { id: gid, by })
|
|
1474
|
+
log(`[vault] grant ${gid} revoked by ${by} — that command will ring again`)
|
|
1475
|
+
}
|
|
1476
|
+
return answer({ op: 'grant-revoke.result', id: gid, ok })
|
|
1477
|
+
}
|
|
1385
1478
|
const id = typeof p.data?.id === 'string' ? p.data.id : ''
|
|
1386
1479
|
const pend = approvals.take(id)
|
|
1387
1480
|
if (!pend) return reply(from, { type: MSG.ERROR, error: 'approval: unknown or expired request' })
|
|
@@ -1413,8 +1506,16 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
1413
1506
|
try { res = await resultFor(pend.ns, pend.device, pend.ek, record) } catch (e) {
|
|
1414
1507
|
return reply(from, { type: MSG.ERROR, error: 'approval: could not seal the reply: ' + e.message })
|
|
1415
1508
|
}
|
|
1416
|
-
|
|
1509
|
+
// LA HORA EMPIEZA AQUÍ, y se estira con cada uso: decir que sí a este comando vale para
|
|
1510
|
+
// las siguientes veces que lo pida ESE MISMO comando desde ESA MISMA carpeta. Un pedido
|
|
1511
|
+
// que no dijo qué corría no deja nada anotado, y se dice por qué: si no, el dueño vería
|
|
1512
|
+
// que unos pedidos dejan de timbrar y otros no, sin ninguna explicación a mano.
|
|
1513
|
+
const grant = grants.grant({ ns: pend.ns, device: pend.device, deviceId: pend.deviceId, ctx: pend.ctx, by })
|
|
1514
|
+
audit('secrets.approved', { device: pend.deviceId, ns: pend.ns, id, by, ...(grant ? { grant: grant.id } : {}) })
|
|
1417
1515
|
log(`[vault] ${pend.ns}: request of ${pend.deviceId} approved by ${by}`)
|
|
1516
|
+
log(grant
|
|
1517
|
+
? `[vault] ${pend.ns}: "${commandLine(pend.ctx, 80)}" approved for ${Math.round(GRANT_TTL_MS / 60000)} min from its last use (grant ${grant.id})`
|
|
1518
|
+
: `[vault] ${pend.ns}: no grant — this request did not say what it is running, so the next one will ring again`)
|
|
1418
1519
|
// Va por `sendByPubkey`: si el que pedía ya no está conectado, lo recoge al volver.
|
|
1419
1520
|
// EL FALLO NO SE TRAGA. Tragarlo era lo peor de este mostrador: la bóveda apuntaba
|
|
1420
1521
|
// «aprobado», el que pedía no recibía nada, y no quedaba una sola línea que mirar —
|
|
@@ -1429,6 +1530,43 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
1429
1530
|
return answer({ op: 'approve.result', id, ok: true })
|
|
1430
1531
|
}
|
|
1431
1532
|
|
|
1533
|
+
/**
|
|
1534
|
+
* EL COMANDO Y EL PATH VIAJAN DENTRO DE UN SOBRE, no en claro.
|
|
1535
|
+
*
|
|
1536
|
+
* La lista de pedidos sale hacia el teléfono por el proxio, que no cifra (CONVENCIONES
|
|
1537
|
+
* §4.1): mandar ahí el `argv` y el `cwd` le enseñaría al que opera el proxio las rutas del
|
|
1538
|
+
* disco del dueño y los argumentos de sus comandos, que es justo lo que este ecosistema
|
|
1539
|
+
* promete que no pasa. Así que el contexto se cifra **para el aparato que está
|
|
1540
|
+
* preguntando** —con la misma cripto de los secretos sellados: una llave de una vez,
|
|
1541
|
+
* envuelta a su `encPub` del acta— y él lo abre con `openSealedValue`, con una privada que
|
|
1542
|
+
* no sale de su dispositivo.
|
|
1543
|
+
*
|
|
1544
|
+
* Un aparato sin `encPub` en el acta no recibe el contexto, y se dice (`ctxSealed: false`)
|
|
1545
|
+
* en vez de mandárselo en claro «para que al menos lo vea»: eso sería cambiar la promesa
|
|
1546
|
+
* por comodidad, y encima sin avisar a nadie.
|
|
1547
|
+
*/
|
|
1548
|
+
async function conContextoSellado (items, aprobador, record) {
|
|
1549
|
+
const encPub = (record?.members || []).find((m) => m.pub === aprobador)?.encPub || null
|
|
1550
|
+
const salida = []
|
|
1551
|
+
for (const it of items) {
|
|
1552
|
+
const { ctx, ...resto } = it
|
|
1553
|
+
if (!ctx) { salida.push({ ...resto, ctx: null }); continue }
|
|
1554
|
+
if (!encPub) { salida.push({ ...resto, ctx: null, ctxSealed: false, ctxReason: 'no-enc-key' }); continue }
|
|
1555
|
+
try {
|
|
1556
|
+
const cek = await makeContentKey()
|
|
1557
|
+
const envelope = await encryptWithCek({ cek, gen: 0, plaintext: JSON.stringify(ctx) })
|
|
1558
|
+
const wrap = await wrapForMember({ cek, memberEncPub: encPub })
|
|
1559
|
+
salida.push({ ...resto, ctx: null, ctxSealed: true, ctxWrap: wrap, ctxEnvelope: envelope })
|
|
1560
|
+
} catch (e) {
|
|
1561
|
+
// NO SE TRAGA: sin esto, un fallo al sellar se vería en el teléfono igual que un
|
|
1562
|
+
// pedido que no dice nada, y nadie sabría que hay algo que arreglar.
|
|
1563
|
+
log(`[vault] could not seal the command of ${it.id} for ${aprobador.slice(0, 24)}…: ${e.message}`)
|
|
1564
|
+
salida.push({ ...resto, ctx: null, ctxSealed: false, ctxReason: 'seal-failed' })
|
|
1565
|
+
}
|
|
1566
|
+
}
|
|
1567
|
+
return salida
|
|
1568
|
+
}
|
|
1569
|
+
|
|
1432
1570
|
/**
|
|
1433
1571
|
* Aviso a los aparatos que aprueban (cola del proxio → timbre nativo si están apagados).
|
|
1434
1572
|
*
|
|
@@ -3235,6 +3373,11 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
3235
3373
|
passwordDevices,
|
|
3236
3374
|
passwordsVault: () => passwords?.vault || null,
|
|
3237
3375
|
listApprovals: () => approvals.list(),
|
|
3376
|
+
// LAS CONCESIONES VIVAS, para verlas y para cortarlas. Se enseñan en la máquina del
|
|
3377
|
+
// dueño (TUI/CLI), donde el contexto ya es suyo y no hay nada que sellar.
|
|
3378
|
+
listGrants: () => grants.list(),
|
|
3379
|
+
revokeGrant: (id) => grants.revoke(id),
|
|
3380
|
+
revokeGrants: (filtro) => grants.revokeAll(filtro || {}),
|
|
3238
3381
|
// Aprobar desde el PC avisa igual que aprobar a distancia: el resto de tus
|
|
3239
3382
|
// dispositivos se entera de que entró alguien, venga de donde venga.
|
|
3240
3383
|
approveDevice: async (code, adminKey) => {
|