@dotrino/vault 0.1.2 → 0.2.1

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/package.json CHANGED
@@ -1,13 +1,22 @@
1
1
  {
2
2
  "name": "@dotrino/vault",
3
- "version": "0.1.2",
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. Contraparte browser del daemon dotrino-vault, reutilizable por cualquier app.",
3
+ "version": "0.2.1",
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 servicio del ecosistema se enrola como dispositivo y obtiene sus secretos del vault en vez del .env.",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
7
7
  "module": "src/index.js",
8
8
  "exports": {
9
9
  ".": {
10
10
  "import": "./src/index.js"
11
+ },
12
+ "./service": {
13
+ "import": "./src/service.js"
14
+ },
15
+ "./sealed": {
16
+ "import": "./src/sealed.js"
17
+ },
18
+ "./protocol": {
19
+ "import": "./src/protocol.js"
11
20
  }
12
21
  },
13
22
  "files": [
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Protocolo de mensajes entre un dispositivo y el vault (viajan por el proxy,
3
+ * direccionados por pubkey con `sendByPubkey`). El cuerpo va JSON-serializado en
4
+ * el campo `message` del sobre del proxy; el cliente lo entrega ya parseado.
5
+ *
6
+ * Emparejamiento ENDURECIDO (ver dotrino-vault/docs/pairing-protocol.md):
7
+ * 1. dispositivo → vault ENROLL { data:{op,dpub,token,sn,label,ts}, signature }
8
+ * (la firma es del dispositivo con su llave D = PRUEBA DE POSESION; un token
9
+ * robado ya NO basta para enrolar).
10
+ * 2. vault → dispositivo ENROLL_CHALLENGE { deviceId, sas } (aun NO firma cert)
11
+ * 3. el dueño compara el SAS (pantalla del dispositivo ↔ del PC) y APRUEBA en el PC
12
+ * 4. vault → dispositivo ENROLLED { cert, iss, sas } (recien aqui firma el cert)
13
+ * 5. el dispositivo VALIDA la cadena: cert.iss === el iss que vio, cert.sub === D.
14
+ *
15
+ * Revocacion (robo): el vault envia REVOKED { body, signature } FIRMADO por la
16
+ * maestra → el dispositivo se autoborra SOLO si la firma valida contra la maestra
17
+ * pineada (cierra el wipe-DoS; un ERROR plano jamas borra).
18
+ */
19
+ export const MSG = Object.freeze({
20
+ ENROLL: 'vault.enroll', // dispositivo → vault: { data, signature }
21
+ ENROLL_CHALLENGE: 'vault.enroll.challenge', // vault → dispositivo: { deviceId, sas }
22
+ ENROLLED: 'vault.enrolled', // vault → dispositivo (tras aprobar): { cert, iss, sas }
23
+ REVOKED: 'vault.revoked', // vault → dispositivo: { body:{op,sub,nonce,iat,exp}, signature }
24
+ SIGN: 'vault.sign', // dispositivo → vault: { data, signature, cert }
25
+ SIGNED: 'vault.signed', // vault → dispositivo: { signature, publickey, device }
26
+ GET: 'vault.get', // dispositivo → vault: { data, signature, cert }
27
+ DATA: 'vault.data', // vault → dispositivo: { id, node }
28
+ STORE: 'vault.store', // dispositivo → vault: { data:{method,args,publickey,ts}, signature, cert }
29
+ STORE_RESULT: 'vault.store.result', // vault → dispositivo: { method, result }
30
+ DEVICES: 'vault.devices', // dispositivo → vault: { data:{publickey,ts}, signature, cert }
31
+ DEVICES_RESULT: 'vault.devices.result', // vault → dispositivo: { devices, revoked }
32
+ RENEW: 'vault.renew', // dispositivo → vault: { data:{op,publickey,ts}, signature, cert }
33
+ RENEWED: 'vault.renewed', // vault → dispositivo: { cert } (cert fresco, misma sub-clave/scope)
34
+ SECRETS: 'vault.secrets', // servicio → vault: { data:{op,ns,ek,publickey,ts}, signature, cert }
35
+ SECRETS_RESULT: 'vault.secrets.result', // vault → servicio: { body:{op,ns,enc,ts}, signature } (enc SELLADO a ek; body firmado por la maestra)
36
+ ERROR: 'vault.error' // vault → dispositivo: { error }
37
+ })
38
+
39
+ /** Capacidades que puede llevar un `cert` (scope). Mínimo por defecto. */
40
+ export const SCOPE = Object.freeze({
41
+ SIGN: 'vault:sign', // pedir a la maestra que firme datos (identidad)
42
+ READ: 'vault:read', // leer nodos del árbol de contenidos
43
+ STORE: 'vault:store' // leer/escribir el store de hilos + aperturas del usuario
44
+ })
45
+
46
+ /**
47
+ * Scope de SECRETOS por namespace de servicio: un cert con `vault:secrets:proxy`
48
+ * solo puede leer los secretos del ns `proxy` — un VPS comprometido no puede
49
+ * pedir los de otro servicio. ns válido: [a-z0-9-]{1,32}.
50
+ */
51
+ export const SECRETS_SCOPE_PREFIX = 'vault:secrets:'
52
+ export const secretsScope = (ns) => SECRETS_SCOPE_PREFIX + ns
53
+ export const isValidSecretsNs = (ns) => typeof ns === 'string' && /^[a-z0-9-]{1,32}$/.test(ns)
package/src/sealed.js ADDED
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Sobres SELLADOS para respuestas del vault con contenido sensible (secretos).
3
+ *
4
+ * El proxy transporta pero NUNCA debe ver el contenido. Como la llave de
5
+ * dispositivo `D` es ECDSA (solo firma), el que PIDE genera una llave ECDH
6
+ * EFÍMERA por petición (`ek`) y la manda dentro del sobre firmado por `D`
7
+ * (→ ek queda autenticada por la cadena D←maestra). El vault sella la
8
+ * respuesta a esa ek: ECDH efímero propio + AES-256-GCM. Reproducir una
9
+ * respuesta vieja es inerte: cada petición usa una ek nueva y el replay no
10
+ * se puede descifrar.
11
+ *
12
+ * La AUTENTICIDAD de la respuesta no la da este módulo sino la firma de la
13
+ * maestra sobre el cuerpo (el dispositivo la verifica contra la `iss` pineada).
14
+ *
15
+ * WebCrypto puro (browser y Node ≥20). Cripto alineada al ecosistema:
16
+ * P-256 + AES-GCM, llaves como JWK string.
17
+ */
18
+
19
+ const subtle = globalThis.crypto.subtle
20
+
21
+ function b64 (buf) {
22
+ const bytes = new Uint8Array(buf)
23
+ let s = ''
24
+ for (let i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i])
25
+ return btoa(s)
26
+ }
27
+
28
+ function fromB64 (str) {
29
+ const bin = atob(str)
30
+ const out = new Uint8Array(bin.length)
31
+ for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i)
32
+ return out
33
+ }
34
+
35
+ async function deriveAesKey (privateKey, publicJwkStr) {
36
+ const pub = await subtle.importKey(
37
+ 'jwk', JSON.parse(publicJwkStr),
38
+ { name: 'ECDH', namedCurve: 'P-256' }, false, []
39
+ )
40
+ const bits = await subtle.deriveBits({ name: 'ECDH', public: pub }, privateKey, 256)
41
+ return subtle.importKey('raw', bits, { name: 'AES-GCM' }, false, ['encrypt', 'decrypt'])
42
+ }
43
+
44
+ /**
45
+ * Genera la llave ECDH EFÍMERA del solicitante (una por petición).
46
+ * `ek` (JWK string pública) viaja en el sobre firmado; `privateKey` se queda
47
+ * en memoria para abrir la respuesta.
48
+ */
49
+ export async function makeEphemeralKey () {
50
+ const kp = await subtle.generateKey({ name: 'ECDH', namedCurve: 'P-256' }, false, ['deriveBits'])
51
+ const jwk = await subtle.exportKey('jwk', kp.publicKey)
52
+ return { ek: JSON.stringify({ kty: jwk.kty, crv: jwk.crv, x: jwk.x, y: jwk.y }), privateKey: kp.privateKey }
53
+ }
54
+
55
+ /**
56
+ * Sella `payload` (cualquier JSON) hacia la ek del solicitante.
57
+ * @returns {Promise<{epk:string, iv:string, ct:string}>}
58
+ */
59
+ export async function seal ({ ek, payload }) {
60
+ const eph = await subtle.generateKey({ name: 'ECDH', namedCurve: 'P-256' }, false, ['deriveBits'])
61
+ const key = await deriveAesKey(eph.privateKey, ek)
62
+ const iv = globalThis.crypto.getRandomValues(new Uint8Array(12))
63
+ const pt = new TextEncoder().encode(JSON.stringify(payload))
64
+ const ct = await subtle.encrypt({ name: 'AES-GCM', iv }, key, pt)
65
+ const epkJwk = await subtle.exportKey('jwk', eph.publicKey)
66
+ return {
67
+ epk: JSON.stringify({ kty: epkJwk.kty, crv: epkJwk.crv, x: epkJwk.x, y: epkJwk.y }),
68
+ iv: b64(iv),
69
+ ct: b64(ct)
70
+ }
71
+ }
72
+
73
+ /**
74
+ * Abre un sobre sellado con la privada efímera de `makeEphemeralKey()`.
75
+ * Lanza si el sobre no es para esa llave (AES-GCM autentica el contenido).
76
+ */
77
+ export async function openSealed ({ privateKey, enc }) {
78
+ if (!enc || typeof enc.epk !== 'string' || typeof enc.iv !== 'string' || typeof enc.ct !== 'string') {
79
+ throw new Error('sobre sellado inválido')
80
+ }
81
+ const key = await deriveAesKey(privateKey, enc.epk)
82
+ const pt = await subtle.decrypt({ name: 'AES-GCM', iv: fromB64(enc.iv) }, key, fromB64(enc.ct))
83
+ return JSON.parse(new TextDecoder().decode(pt))
84
+ }
package/src/service.js ADDED
@@ -0,0 +1,252 @@
1
+ /**
2
+ * Cliente de SERVICIO del vault (Node ≥22). Para que un servicio del ecosistema
3
+ * (proxy, geo, bots…) sea un CLIENTE IDENTIFICADO más y obtenga sus secretos
4
+ * del vault en vez de llevarlos en el `.env`:
5
+ *
6
+ * 1. En el vault: `dotrino-vault pair --service proxy` (QR/código con scope
7
+ * SOLO `vault:secrets:proxy`) y `dotrino-vault secret set proxy TURN_KEY_ID …`
8
+ * 2. En el servicio (una vez): `enrollService({ qr, ns, dir })` — genera la
9
+ * llave del servicio, muestra el código de aprobación y persiste
10
+ * `service-identity.json` (device + cert + iss + proxy).
11
+ * 3. En cada arranque: `waitForSecrets({ dir, ns })` — pide los secretos y,
12
+ * si el vault no está, REINTENTA para siempre (regla del ecosistema: sin
13
+ * vault, el servicio espera; no arranca con secretos viejos ni vacíos).
14
+ *
15
+ * Seguridad: la petición va firmada por la llave del servicio + cert (cadena
16
+ * D←maestra, scope `vault:secrets:<ns>`); la respuesta viene SELLADA (ECDH
17
+ * efímero + AES-GCM → el proxy no ve los valores) y FIRMADA por la maestra
18
+ * (verificada contra la `iss` pineada en el enrolamiento → un relay no puede
19
+ * inyectar secretos falsos).
20
+ */
21
+ import fs from 'node:fs'
22
+ import path from 'node:path'
23
+ import {
24
+ makeDeviceKey, signWithDevice, verifyDelegation, verifyDeviceSig,
25
+ makePairingCode, pubkeyId
26
+ } from '@dotrino/identity/capabilities'
27
+ import { MSG, secretsScope, isValidSecretsNs } from './protocol.js'
28
+ import { makeEphemeralKey, openSealed } from './sealed.js'
29
+
30
+ const IDENTITY_FILE = 'service-identity.json'
31
+ const FRESH_WINDOW_MS = 5 * 60 * 1000
32
+ const RENEW_BEFORE_MS = 7 * 24 * 60 * 60 * 1000 // renovar el cert si vence en <7 días
33
+
34
+ let _globalsInstalled = false
35
+ function installNodeGlobals () {
36
+ if (_globalsInstalled) return
37
+ _globalsInstalled = true
38
+ // localStorage en memoria: @dotrino/proxy-client lo usa solo para su keypair
39
+ // de canales (que un servicio no necesita persistir). Se define SIN leer el
40
+ // getter nativo: en Node ≥22 acceder a `globalThis.localStorage` sin
41
+ // `--localstorage-file` es no-funcional y además emite un warning.
42
+ const desc = Object.getOwnPropertyDescriptor(globalThis, 'localStorage')
43
+ const isUsable = desc && 'value' in desc && typeof desc.value?.getItem === 'function'
44
+ if (!isUsable) {
45
+ const mem = new Map()
46
+ Object.defineProperty(globalThis, 'localStorage', {
47
+ configurable: true,
48
+ value: {
49
+ getItem: (k) => (mem.has(k) ? mem.get(k) : null),
50
+ setItem: (k, v) => mem.set(k, String(v)),
51
+ removeItem: (k) => mem.delete(k),
52
+ clear: () => mem.clear(),
53
+ key: (i) => [...mem.keys()][i] ?? null,
54
+ get length () { return mem.size }
55
+ }
56
+ })
57
+ }
58
+ if (typeof globalThis.WebSocket === 'undefined') {
59
+ throw new Error('este entorno no tiene WebSocket global: usa Node ≥22')
60
+ }
61
+ }
62
+
63
+ async function freshClient (proxyUrl, connectTimeoutMs = 20000) {
64
+ installNodeGlobals()
65
+ const { WebSocketProxyClient } = await import('@dotrino/proxy-client')
66
+ const client = new WebSocketProxyClient({ url: proxyUrl, enableWebRTC: false, autoReconnect: false })
67
+ // connect() del cliente solo se resuelve con 'connected' y solo rechaza en el
68
+ // evento 'error' de transporte: si el socket cierra LIMPIO antes de 'connected'
69
+ // (p.ej. el proxy banea la IP y envía close 1008), la promesa quedaría colgada
70
+ // para siempre y waitForSecrets no reintentaría. Le ponemos un timeout propio.
71
+ let timer
72
+ const timeout = new Promise((_, reject) => {
73
+ timer = setTimeout(() => reject(new Error('timeout conectando al proxy')), connectTimeoutMs)
74
+ })
75
+ try {
76
+ await Promise.race([client.connect(), timeout])
77
+ } catch (e) {
78
+ try { client.close() } catch (_) {}
79
+ throw e
80
+ } finally {
81
+ clearTimeout(timer)
82
+ }
83
+ return client
84
+ }
85
+
86
+ /** Identifica la conexión bajo la pubkey del servicio (para ser direccionable). */
87
+ async function identifyAsService (client, device) {
88
+ const data = { op: 'identify', publickey: device.publickey, token: client.token, ts: Date.now() }
89
+ const { signature } = await signWithDevice({ privateJwk: device.privateJwk, data })
90
+ await client.identify({ data, signature })
91
+ }
92
+
93
+ function waitForMsg (client, predicate, timeoutMs = 30000) {
94
+ return new Promise((resolve, reject) => {
95
+ const off = client.on('message', (_from, payload) => {
96
+ if (payload && typeof payload === 'object' && predicate(payload)) { cleanup(); resolve(payload) }
97
+ })
98
+ const t = setTimeout(() => { cleanup(); reject(new Error('timeout esperando respuesta del vault')) }, timeoutMs)
99
+ const cleanup = () => { off(); clearTimeout(t) }
100
+ })
101
+ }
102
+
103
+ const identityFileOf = (dir) => path.join(dir, IDENTITY_FILE)
104
+
105
+ /** Lee la identidad persistida del servicio ({device, cert, iss, proxy, ns}) o null. */
106
+ export function readServiceIdentity (dir) {
107
+ try { return JSON.parse(fs.readFileSync(identityFileOf(dir), 'utf8')) } catch (_) { return null }
108
+ }
109
+
110
+ function writeServiceIdentity (dir, obj) {
111
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 })
112
+ const f = identityFileOf(dir)
113
+ fs.writeFileSync(f, JSON.stringify(obj, null, 2), { mode: 0o600 })
114
+ }
115
+
116
+ /**
117
+ * Enrola ESTE servicio contra el vault (una sola vez; persiste la identidad).
118
+ * En el vault se corre antes `dotrino-vault pair --service <ns>`; el QR/payload
119
+ * de ese comando es el `qr` de aquí. Muestra un código por `onCode` (o stdout):
120
+ * el dueño lo tipea en el vault (`dotrino-vault approve <código>`).
121
+ *
122
+ * @param {Object} opts
123
+ * @param {{v:number, iss:string, proxy:string, token:string, sn:string}|string} opts.qr QR v2 (objeto o JSON string).
124
+ * @param {string} opts.ns Namespace de secretos del servicio (el mismo del pair).
125
+ * @param {string} opts.dir Dónde persistir `service-identity.json`.
126
+ * @param {string} [opts.label]
127
+ * @param {(c:{deviceId:string, code:string})=>void} [opts.onCode]
128
+ * @returns {Promise<{device, cert, iss:string}>}
129
+ */
130
+ export async function enrollService ({ qr, ns, dir, label, onCode, approveTimeoutMs = 180000 } = {}) {
131
+ if (typeof qr === 'string') { try { qr = JSON.parse(qr) } catch (_) { throw new Error('qr inválido: no es JSON') } }
132
+ if (!qr?.iss || !qr?.proxy || !qr?.token || !qr?.sn) throw new Error('qr inválido (v2): faltan iss/proxy/token/sn')
133
+ if (!isValidSecretsNs(ns)) throw new Error('ns inválido (usa [a-z0-9-]{1,32}, p.ej. "proxy")')
134
+ if (!dir) throw new Error('falta dir (dónde persistir la identidad del servicio)')
135
+ label = label || 'servicio:' + ns
136
+
137
+ const client = await freshClient(qr.proxy)
138
+ try {
139
+ const device = await makeDeviceKey({ label })
140
+ const deviceId = (await pubkeyId(device.publickey)).slice(0, 8).toUpperCase().replace(/(.{4})(.{4})/, '$1-$2')
141
+ // Código ALEATORIO generado AQUÍ: el vault no lo conoce; solo puede echarlo
142
+ // de vuelta si el dueño lo tipeó (= tiene esta pantalla a la vista).
143
+ const code = makePairingCode()
144
+ const data = { op: 'enroll', dpub: device.publickey, token: qr.token, sn: qr.sn, label, ts: Date.now() }
145
+ const { signature } = await signWithDevice({ privateJwk: device.privateJwk, data })
146
+
147
+ const enrolled = new Promise((resolve, reject) => {
148
+ const off = client.on('message', (_from, p) => {
149
+ if (!p || typeof p !== 'object') return
150
+ if (p.type === MSG.ENROLL_CHALLENGE) {
151
+ const show = onCode || (({ deviceId, code }) => console.log(`[vault-service] dispositivo ${deviceId} · aprueba en el vault: dotrino-vault approve ${code}`))
152
+ show({ deviceId, code })
153
+ } else if (p.type === MSG.ENROLLED) { cleanup(); resolve(p) } else if (p.type === MSG.ERROR) { cleanup(); reject(new Error(p.error)) }
154
+ })
155
+ const t = setTimeout(() => { cleanup(); reject(new Error('timeout esperando la aprobación en el vault')) }, approveTimeoutMs)
156
+ const cleanup = () => { off(); clearTimeout(t) }
157
+ })
158
+ client.sendByPubkey(qr.iss, { type: MSG.ENROLL, data, signature })
159
+ const res = await enrolled
160
+
161
+ // Validación estricta (igual que un dispositivo): cert de la maestra VISTA,
162
+ // para ESTA llave, y el código echado debe ser el nuestro (anti vault falso).
163
+ if (res.code !== code) throw new Error('el vault devolvió un código distinto al mostrado (posible relay malicioso)')
164
+ const v = await verifyDelegation({ cert: res.cert, expectedSub: device.publickey, expectedScope: secretsScope(ns) })
165
+ if (!v.ok) throw new Error('cert inválido: ' + v.reason)
166
+ if (res.cert.iss !== qr.iss) throw new Error('cert firmado por una maestra distinta a la del QR')
167
+
168
+ writeServiceIdentity(dir, { v: 1, ns, iss: qr.iss, proxy: qr.proxy, device, cert: res.cert, enrolledAt: Date.now() })
169
+ return { device, cert: res.cert, iss: qr.iss }
170
+ } finally { client.close() }
171
+ }
172
+
173
+ /**
174
+ * Pide los secretos del ns al vault (una petición puntual; lanza si falla).
175
+ * Usa la identidad persistida por `enrollService` salvo que se pase explícita.
176
+ * Renueva el cert automáticamente si está por vencer (best-effort).
177
+ * @returns {Promise<Record<string,string>>} secretos KEY→valor
178
+ */
179
+ export async function fetchSecrets ({ dir, ns, proxyUrl, masterPubkey, device, cert, timeoutMs = 30000 } = {}) {
180
+ let saved = null
181
+ if (dir) saved = readServiceIdentity(dir)
182
+ ns = ns || saved?.ns
183
+ proxyUrl = proxyUrl || saved?.proxy
184
+ masterPubkey = masterPubkey || saved?.iss
185
+ device = device || saved?.device
186
+ cert = cert || saved?.cert
187
+ if (!isValidSecretsNs(ns)) throw new Error('ns inválido')
188
+ if (!proxyUrl || !masterPubkey || !device || !cert) {
189
+ throw new Error('servicio sin enrolar: corre primero enrollService() (falta service-identity.json)')
190
+ }
191
+
192
+ const client = await freshClient(proxyUrl)
193
+ try {
194
+ await identifyAsService(client, device)
195
+
196
+ // Renovación de cert best-effort si vence pronto (mientras siga vigente).
197
+ if (typeof cert.exp === 'number' && cert.exp - Date.now() < RENEW_BEFORE_MS && cert.exp > Date.now()) {
198
+ try {
199
+ const data = { op: 'renew', publickey: device.publickey, ts: Date.now() }
200
+ const { signature } = await signWithDevice({ privateJwk: device.privateJwk, data })
201
+ const pending = waitForMsg(client, (p) => p.type === MSG.RENEWED || p.type === MSG.ERROR, 15000)
202
+ client.sendByPubkey(masterPubkey, { type: MSG.RENEW, data, signature, cert })
203
+ const res = await pending
204
+ if (res.type === MSG.RENEWED && res.cert?.sub === device.publickey) {
205
+ const v = await verifyDelegation({ cert: res.cert, expectedSub: device.publickey, expectedScope: secretsScope(ns) })
206
+ if (v.ok) { cert = res.cert; if (dir && saved) writeServiceIdentity(dir, { ...saved, cert }) }
207
+ }
208
+ } catch (_) { /* la renovación no bloquea el fetch */ }
209
+ }
210
+
211
+ const eph = await makeEphemeralKey()
212
+ const data = { op: 'secrets', ns, ek: eph.ek, publickey: device.publickey, ts: Date.now() }
213
+ const { signature } = await signWithDevice({ privateJwk: device.privateJwk, data })
214
+ const pending = waitForMsg(client, (p) => p.type === MSG.SECRETS_RESULT || p.type === MSG.ERROR, timeoutMs)
215
+ client.sendByPubkey(masterPubkey, { type: MSG.SECRETS, data, signature, cert })
216
+ const res = await pending
217
+ if (res.type === MSG.ERROR) throw new Error(res.error)
218
+
219
+ // Autenticidad: el cuerpo viene firmado por la MAESTRA pineada.
220
+ const body = res.body
221
+ if (!body || body.op !== 'secrets.result' || body.ns !== ns) throw new Error('respuesta de secretos malformada')
222
+ if (typeof body.ts !== 'number' || Math.abs(Date.now() - body.ts) > FRESH_WINDOW_MS) throw new Error('respuesta de secretos vencida')
223
+ const ok = await verifyDeviceSig({ publickey: masterPubkey, data: body, signature: res.signature })
224
+ if (!ok) throw new Error('firma de la maestra inválida en la respuesta de secretos')
225
+
226
+ const payload = await openSealed({ privateKey: eph.privateKey, enc: body.enc })
227
+ if (!payload || typeof payload.secrets !== 'object') throw new Error('sobre de secretos malformado')
228
+ return payload.secrets
229
+ } finally { client.close() }
230
+ }
231
+
232
+ /**
233
+ * Bucle de arranque de un servicio: pide los secretos y, si el vault no está
234
+ * disponible, REINTENTA para siempre (con backoff hasta `maxRetryMs`). El
235
+ * servicio no opera hasta que esto resuelva — esa es la regla.
236
+ * @returns {Promise<Record<string,string>>}
237
+ */
238
+ export async function waitForSecrets ({ dir, ns, proxyUrl, masterPubkey, device, cert, retryMs = 5000, maxRetryMs = 60000, onRetry } = {}) {
239
+ let delay = retryMs
240
+ for (;;) {
241
+ try {
242
+ return await fetchSecrets({ dir, ns, proxyUrl, masterPubkey, device, cert })
243
+ } catch (e) {
244
+ // Lo NO transitorio no se arregla reintentando: falta de enrolamiento,
245
+ // cert revocado/vencido o scope equivocado exigen re-emparejar → se corta.
246
+ if (/sin enrolar|ns inválido|no autorizado: (revoked|expired|scope|untrusted-issuer|cert-device-mismatch)/.test(e.message)) throw e
247
+ try { onRetry?.(e, delay) } catch (_) {}
248
+ await new Promise((r) => setTimeout(r, delay))
249
+ delay = Math.min(maxRetryMs, Math.round(delay * 1.6))
250
+ }
251
+ }
252
+ }