@dotrino/vault 0.62.1 → 0.64.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 +23 -0
- package/bin/dotrino-env.js +19 -3
- package/package.json +2 -2
- package/src/proc.js +192 -0
- package/src/service.js +34 -15
package/README.md
CHANGED
|
@@ -127,6 +127,29 @@ Para procesos que no son Node, el CLI los inyecta en el entorno de un hijo:
|
|
|
127
127
|
dotrino-env run --ns miapp -- ./mi-binario
|
|
128
128
|
```
|
|
129
129
|
|
|
130
|
+
### El pedido dice QUÉ COMANDO pide, y desde dónde
|
|
131
|
+
|
|
132
|
+
Cuando el cajón exige aprobación, el pedido que te llega al teléfono lleva **el comando
|
|
133
|
+
entero y la carpeta** desde la que corre — `node server.js --port 8080` en `/srv/mi-app`—,
|
|
134
|
+
no solo qué aparato pide qué cajón. En `dotrino-env run -- node server.js` el comando de
|
|
135
|
+
destino va dentro de la propia línea de `dotrino-env`, así que sale medido y no hace falta
|
|
136
|
+
creerle nada aparte.
|
|
137
|
+
|
|
138
|
+
Dos límites que se dicen y no se esconden:
|
|
139
|
+
|
|
140
|
+
- **Solo viaja por el mostrador local.** El pedido de secretos va firmado pero en claro
|
|
141
|
+
(CONVENCIONES §4.1), así que mandar el `argv` y el `cwd` por el proxio le enseñaría a quien
|
|
142
|
+
lo opere las rutas de tu disco. Desde otra máquina el pedido no lleva comando — y sin
|
|
143
|
+
comando no hay concesión: ese pide aprobación cada vez.
|
|
144
|
+
- **No es una credencial.** La bóveda lo comprueba contra el proceso de verdad cuando está en
|
|
145
|
+
su misma máquina, pero cualquier proceso de tu mismo usuario puede leer el
|
|
146
|
+
`service-identity.json` y pedir con esa llave. Sirve para decidir mirando lo que pasa y
|
|
147
|
+
para dejar rastro, no para parar a quien ya tiene la llave.
|
|
148
|
+
|
|
149
|
+
**Aprobar vale una hora y se renueva con cada uso**, así que un servicio que sigue pidiendo
|
|
150
|
+
lo mismo no vuelve a timbrarte. Cambiar un argumento o la carpeta es otro comando: pregunta
|
|
151
|
+
otra vez.
|
|
152
|
+
|
|
130
153
|
### Un agente tiene UNA identidad, y se la da el vault
|
|
131
154
|
|
|
132
155
|
Un **aparato** puede llevar varios perfiles, y hasta meter su propia cuenta al vault
|
package/bin/dotrino-env.js
CHANGED
|
@@ -23,10 +23,26 @@ import { loadEnv, serviceDir, serviceRoot, listEnrolled, resolveNs, resolveVault
|
|
|
23
23
|
// llegó a funcionar, y por eso el único servicio enrolado del ecosistema (el
|
|
24
24
|
// proxy) lo hizo con su propio `enroll-vault.js` en vez de con esta CLI.
|
|
25
25
|
import { parseInvite as sharedParseInvite } from '../src/invite.js'
|
|
26
|
+
import { selfContext, commandLine } from '../src/proc.js'
|
|
26
27
|
|
|
27
28
|
const argv = process.argv.slice(2)
|
|
28
29
|
const flag = (name) => { const i = argv.indexOf('--' + name); return i >= 0 ? argv[i + 1] : undefined }
|
|
29
30
|
|
|
31
|
+
/**
|
|
32
|
+
* QUÉ VA A VER EN EL TELÉFONO, dicho aquí también.
|
|
33
|
+
*
|
|
34
|
+
* El pedido que llega al aparato que aprueba enseña el comando y la carpeta; si por aquí solo
|
|
35
|
+
* se imprime «waiting for approval», no hay forma de emparejar lo que uno acaba de lanzar con
|
|
36
|
+
* lo que suena en el bolsillo — y con dos ventanas abiertas eso es exactamente lo que hay que
|
|
37
|
+
* distinguir antes de decir que sí.
|
|
38
|
+
*/
|
|
39
|
+
function esperandoAprobacion () {
|
|
40
|
+
const ctx = selfContext()
|
|
41
|
+
console.error('[dotrino-env] the vault asks for approval — approve it on a device with «aprueba» (up to 5 min)')
|
|
42
|
+
console.error('[dotrino-env] %s', commandLine(ctx, 160))
|
|
43
|
+
console.error('[dotrino-env] from %s', ctx.cwd || '?')
|
|
44
|
+
}
|
|
45
|
+
|
|
30
46
|
function help () {
|
|
31
47
|
console.log(`dotrino-env — credenciales del vault en vez del .env
|
|
32
48
|
|
|
@@ -223,7 +239,7 @@ async function cmdCheck () {
|
|
|
223
239
|
// bóveda contestó, que lo que falta es un permiso, ni cuánto va a esperar. `run` sí lo
|
|
224
240
|
// decía; `check`, que es el comando de DIAGNÓSTICO, era el único que no. Le pasó al dueño
|
|
225
241
|
// el 2026-09-05 con un cajón cuyo servicio no tenía a nadie que pudiera aprobarle.
|
|
226
|
-
const onPending = () =>
|
|
242
|
+
const onPending = () => esperandoAprobacion()
|
|
227
243
|
const secrets = await fetchSecrets({ dir: serviceDir(etiquetaBoveda(), ns), ns, publicOnly, onPending })
|
|
228
244
|
const keys = Object.keys(secrets)
|
|
229
245
|
console.log('ns "%s": %d %s%s', ns, keys.length,
|
|
@@ -240,7 +256,7 @@ async function cmdRun () {
|
|
|
240
256
|
const sep = argv.indexOf('--')
|
|
241
257
|
const cmd = sep >= 0 ? argv.slice(sep + 1) : []
|
|
242
258
|
if (!cmd.length) { console.error('uso: dotrino-env run [--vault <bóveda>] [--ns <ns>] [--public] -- <cmd> [args…]'); process.exit(2) }
|
|
243
|
-
const onPending = () =>
|
|
259
|
+
const onPending = () => esperandoAprobacion()
|
|
244
260
|
// POR QUÉ SE REINTENTA, SIEMPRE. Sin esto la espera es muda: la bóveda contestaba, la
|
|
245
261
|
// respuesta se caía por un motivo concreto —un aparato sin sobre en su cajón, por
|
|
246
262
|
// ejemplo— y por fuera solo se veía «waiting for approval» una y otra vez, aunque el
|
|
@@ -280,7 +296,7 @@ async function cmdSshAgent () {
|
|
|
280
296
|
const dir = flag('dir') || serviceDir(etiquetaBoveda(), ns)
|
|
281
297
|
const socketPath = flag('socket') || defaultSocketPath(dir)
|
|
282
298
|
console.error('[dotrino-env] asking the vault for the keys of «%s»…', ns)
|
|
283
|
-
const secrets = await waitForSecrets({ dir, ns, onPending: () =>
|
|
299
|
+
const secrets = await waitForSecrets({ dir, ns, onPending: () => esperandoAprobacion() })
|
|
284
300
|
const keys = []
|
|
285
301
|
for (const [name, value] of Object.entries(secrets)) {
|
|
286
302
|
if (!/^SSH_KEY_[A-Z0-9_]*$/.test(name)) continue
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dotrino/vault",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.64.0",
|
|
4
4
|
"description": "Usa ESTE dispositivo (navegador) como bóveda/CA del ecosistema Dotrino: atiende enrolamientos por el proxy y firma certificados de delegación a tus máquinas. Incluye el cliente de SERVICIO (Node): un proyecto se enrola una vez y jala sus credenciales del vault en vez del .env (`import '@dotrino/vault/config'`).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
|
@@ -50,7 +50,7 @@
|
|
|
50
50
|
"pairing"
|
|
51
51
|
],
|
|
52
52
|
"peerDependencies": {
|
|
53
|
-
"@dotrino/identity": ">=0.
|
|
53
|
+
"@dotrino/identity": ">=0.92.0",
|
|
54
54
|
"@dotrino/proxy-client": ">=0.18.0"
|
|
55
55
|
},
|
|
56
56
|
"license": "MIT",
|
package/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/src/service.js
CHANGED
|
@@ -22,16 +22,17 @@ import fs from 'node:fs'
|
|
|
22
22
|
import path from 'node:path'
|
|
23
23
|
import { createHash } from 'node:crypto'
|
|
24
24
|
import {
|
|
25
|
-
makeDeviceKey, makeDeviceEncKey, importDeviceEncKey, signWithDevice,
|
|
25
|
+
makeDeviceKey, makeDeviceEncKey, importDeviceEncKey, signWithDevice,
|
|
26
26
|
verifyDeviceSig, makePairingCode, commitCode, pubkeyId } from '@dotrino/identity/capabilities'
|
|
27
27
|
import { openWrap, wrapForMember, decryptWithCek } from '@dotrino/identity/content'
|
|
28
|
-
import { verifyActa, sealKeyAt,
|
|
28
|
+
import { verifyActa, sealKeyAt, memberCan, checkVaultReply } from '@dotrino/identity/acta'
|
|
29
29
|
import { MSG, secretsScope, isValidSecretsNs, VAULT_PROTOCOL, VAULT_SPEAKS } from './protocol.js'
|
|
30
30
|
import { PKG_VERSION } from './version.js'
|
|
31
31
|
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
|
|
@@ -399,18 +400,15 @@ export async function enrollWithVault ({ qr, label = 'agent', expectedScope = nu
|
|
|
399
400
|
// por reloj, dice el `seq` del acta con el que se emitió, y quien lo emitió tiene que
|
|
400
401
|
// ser SELLADORA de ese acta. Sin acta no se puede comprobar nada de eso, y entrar a
|
|
401
402
|
// ciegas en un perfil es justo lo que no se hace.
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
//
|
|
405
|
-
//
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
cert: res.cert, expectedSub: device.publickey,
|
|
410
|
-
actaSeq: acta.seq, sealers: sealersOf(acta),
|
|
411
|
-
...(expectedScope ? { expectedScope } : {})
|
|
403
|
+
//
|
|
404
|
+
// Se juzga contra la llave de LA BÓVEDA del QR, con la regla del pilar. Antes se exigía
|
|
405
|
+
// `acta.profileId === qr.iss`, que solo es verdad en una cuenta que nació en esa bóveda:
|
|
406
|
+
// un servicio no podía emparejarse con una segunda bóveda ni con una que adoptó la cuenta.
|
|
407
|
+
const chk = await checkVaultReply({
|
|
408
|
+
acta: res.acta, cert: res.cert, vault: qr.iss, sub: device.publickey,
|
|
409
|
+
scope: expectedScope || null, justSealed: true
|
|
412
410
|
})
|
|
413
|
-
if (!
|
|
411
|
+
if (!chk.ok) throw new Error('the vault reply does not check out: ' + chk.reason)
|
|
414
412
|
|
|
415
413
|
return { device, enc: enc ? { publickey: enc.encPublickey, privateJwk: enc.encPrivateJwk } : null, cert: res.cert, iss: qr.iss, proxy: qr.proxy || 'wss://proxy.dotrino.com' }
|
|
416
414
|
} finally { client.close() }
|
|
@@ -598,7 +596,13 @@ export async function fetchSecrets ({ dir, ns, proxyUrl, masterPubkey, device, c
|
|
|
598
596
|
const pending = waitForMsg(client, (p) => p.type === MSG.RENEWED || p.type === MSG.ERROR, 15000)
|
|
599
597
|
client.sendByPubkey(masterPubkey, { type: MSG.RENEW, data, signature, cert })
|
|
600
598
|
const res = await pending
|
|
601
|
-
|
|
599
|
+
// SE VERIFICA ANTES DE GUARDARLO. Aquí solo se miraba que fuera para esta llave: un
|
|
600
|
+
// papel cualquiera, sin firma que valga, sustituía al bueno y el servicio se quedaba
|
|
601
|
+
// fuera hasta el siguiente arranque. Misma regla que al enrolarse, sin `justSealed`:
|
|
602
|
+
// la última acta pudo sellarla otra selladora de la cuenta.
|
|
603
|
+
const ok = res.type === MSG.RENEWED &&
|
|
604
|
+
(await checkVaultReply({ acta: res.acta, cert: res.cert, vault: masterPubkey, sub: device.publickey })).ok
|
|
605
|
+
if (ok) {
|
|
602
606
|
cert = res.cert
|
|
603
607
|
recordar({ cert })
|
|
604
608
|
// QUIEN NO PASA `dir` GUARDA ÉL. Un servicio que lleva su identidad en su propio
|
|
@@ -611,9 +615,24 @@ export async function fetchSecrets ({ dir, ns, proxyUrl, masterPubkey, device, c
|
|
|
611
615
|
}
|
|
612
616
|
|
|
613
617
|
const eph = await makeEphemeralKey()
|
|
618
|
+
/**
|
|
619
|
+
* QUÉ COMANDO Y DESDE QUÉ CARPETA — pero SOLO por el mostrador local.
|
|
620
|
+
*
|
|
621
|
+
* Quien aprueba necesita saber qué está pidiendo las claves: «el aparato 904C-1002 pide
|
|
622
|
+
* el cajón proxy» no alcanza para decidir nada (dueño, 2026-09-11). Así que el pedido
|
|
623
|
+
* lleva el comando y el path, y la bóveda los comprueba contra `/proc`.
|
|
624
|
+
*
|
|
625
|
+
* POR EL PROXIO NO VA, y no es un olvido: el pedido de secretos viaja firmado pero EN
|
|
626
|
+
* CLARO (CONVENCIONES §4.1 — el transporte no cifra), así que meter ahí el `argv` y el
|
|
627
|
+
* `cwd` le enseñaría al proxio las rutas de tu disco y los argumentos de tus comandos,
|
|
628
|
+
* que es justo lo que este ecosistema promete que no pasa. Sin contexto no hay huella,
|
|
629
|
+
* y sin huella no hay ventana: un servicio que pide desde otra máquina sigue pidiendo
|
|
630
|
+
* aprobación cada vez. Se abrirá cuando el pedido vaya sellado (la deuda del §4.1).
|
|
631
|
+
*/
|
|
632
|
+
const ctx = client.local ? selfContext() : null
|
|
614
633
|
// `publicOnly` VIAJA FIRMADO (va dentro de `data`), así que nadie lo puede quitar ni
|
|
615
634
|
// 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 } : {}) }
|
|
635
|
+
const data = { op: 'secrets', ns, ek: eph.ek, publickey: device.publickey, ts: Date.now(), v: MY_VERSION, ...(ctx ? { ctx } : {}), ...(publicOnly ? { publicOnly: true } : {}) }
|
|
617
636
|
const { signature } = await signWithDevice({ privateJwk: device.privateJwk, data })
|
|
618
637
|
// A QUIÉN SE LE PREGUNTA: primero a la bóveda, y si no contesta, a los REPLICADORES
|
|
619
638
|
// que el acta nombraba la última vez que la vimos (`docs/replicas.md` §8: «el servicio
|