@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 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**. Pide en
782
- **cada petición**, que para un servicio bien hecho es **una por arranque**: pide al iniciar,
783
- se queda las claves en memoria y no vuelve a pedir. Lo denegado corta sin reintentos; lo que
784
- nadie atiende vence a los 5 min; todo queda en `dotrino-vault activity`.
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
 
@@ -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 }
@@ -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.113.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. No hay ventana: cada petición es un pedido, y para un servicio bien
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
- * y su llave efímera `ek` (a la que se sella la respuesta). Nunca un valor.
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
- const publicOf = (p) => ({ id: p.id, ns: p.ns, deviceId: p.deviceId, label: p.label, ts: p.ts, exp: p.exp })
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
- export default { createApprovals, PENDING_TTL_MS }
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 }
@@ -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
- const MAX_PER_THREAD = 1000
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 trim = (arr) => { if (arr.length > MAX_PER_THREAD) arr.splice(0, arr.length - MAX_PER_THREAD) }
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
- if (!threadKey || typeof threadKey !== 'string') throw new Error('threadKey required')
43
- if (!entry || typeof entry !== 'object') throw new Error('entry required')
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[threadKey]?.length || 0
66
- delete data.threads[threadKey]; save(); return { removed }
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 arr = data.threads[threadKey] || []; const before = arr.length
70
- data.threads[threadKey] = arr.filter((e) => e.id !== id)
71
- if (data.threads[threadKey].length === 0) delete data.threads[threadKey]
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
- importThreads ({ threads, mode = 'merge' }) {
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
- for (const [k, arr] of Object.entries(threads)) {
87
- const cur = data.threads[k] || (data.threads[k] = [])
88
- const byId = new Map(cur.map((e) => [e.id, e]))
89
- for (const e of arr) { if (!e?.id) continue; const pr = byId.get(e.id); if (!pr || (e.ts || 0) > (pr.ts || 0)) byId.set(e.id, e) }
90
- data.threads[k] = Array.from(byId.values()).sort((a, b) => (a.ts || 0) - (b.ts || 0)); trim(data.threads[k])
91
- }
92
- save(); return { mode, count: Object.keys(data.threads).length }
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(() => { for (const g of approvals.sweep()) audit('secrets.expired', { id: g.id, ns: g.ns, device: g.deviceId }) }, 30 * 1000); approvalsSweeper.unref?.()
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
- return reply(from, { type: MSG.ERROR, error: 'store: invalid method' })
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
- audit('secrets.approved', { device: pend.deviceId, ns: pend.ns, id, by })
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) => {