@dotrino/vault 0.1.1 → 0.2.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/package.json CHANGED
@@ -1,13 +1,22 @@
1
1
  {
2
2
  "name": "@dotrino/vault",
3
- "version": "0.1.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. Contraparte browser del daemon dotrino-vault, reutilizable por cualquier app.",
3
+ "version": "0.2.0",
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": [
package/src/index.js CHANGED
@@ -35,6 +35,7 @@ const MSG = {
35
35
  ENROLLED: 'vault.enrolled',
36
36
  DEVICES: 'vault.devices',
37
37
  DEVICES_RESULT: 'vault.devices.result',
38
+ REVOKED: 'vault.revoked',
38
39
  ERROR: 'vault.error'
39
40
  }
40
41
 
@@ -127,8 +128,19 @@ export async function startDeviceVault (identity, { proxyUrl } = {}) {
127
128
  send(from, { type: MSG.ENROLL_CHALLENGE, deviceId })
128
129
  }
129
130
 
131
+ // Emite un REVOKED FIRMADO por la maestra a la máquina revocada para que se
132
+ // auto-borre. Va por `sendByPubkey` → si está offline, el proxy lo encola 24 h; y
133
+ // si reaparece más tarde, `handleDevices` lo re-emite en su siguiente consulta.
134
+ // El auto-borrado remoto SOLO se dispara con esta firma (no con un error cualquiera).
135
+ async function emitRevoke (dpub, nonce) {
136
+ const body = { op: 'revoke', sub: dpub, nonce, iat: Date.now(), exp: Date.now() + DEVICE_TTL_MS }
137
+ const { signature } = await identity.signData(body)
138
+ try { client.sendByPubkey(dpub, { type: MSG.REVOKED, body, signature }) } catch (_) {}
139
+ }
140
+
130
141
  // Consulta de revocaciones (igual que `vault.devices` del daemon): responde la lista
131
- // de dispositivos enrolados + revocados para que el dispositivo refresque su set.
142
+ // de dispositivos enrolados + revocados para que el dispositivo refresque su set. Y si
143
+ // QUIEN consulta es una máquina ya revocada (reapareció), le re-emite el REVOKED firmado.
132
144
  async function handleDevices (from, p) {
133
145
  const d = p?.data
134
146
  if (!d || !p.signature || !p.cert) return send(from, { type: MSG.ERROR, error: 'petición inválida' })
@@ -141,6 +153,9 @@ export async function startDeviceVault (identity, { proxyUrl } = {}) {
141
153
  label: x.label || '', scope: x.scope, exp: x.exp, nonce: x.nonce
142
154
  })))
143
155
  send(from, { type: MSG.DEVICES_RESULT, devices, revoked: (revoked || []).map((r) => r.nonce || r) })
156
+ // ¿el que consulta es una máquina revocada que reapareció? → re-emite el REVOKED firmado.
157
+ const mine = (issued || []).find((x) => x.sub === chk.device && x.revokedAt)
158
+ if (mine) emitRevoke(chk.device, mine.nonce)
144
159
  }
145
160
 
146
161
  client.on('message', (_from, p) => {
@@ -214,7 +229,14 @@ export async function startDeviceVault (identity, { proxyUrl } = {}) {
214
229
  }
215
230
 
216
231
  async function revoke (nonce) {
217
- return identity.revokeDelegation(nonce)
232
+ // Deja el registro persistente (revokedAt en la delegación) y AVISA a la máquina
233
+ // con un REVOKED firmado para que se auto-borre (ahora si está online, o al
234
+ // reaparecer vía handleDevices). Ver emitRevoke.
235
+ const { issued } = await identity.listDelegations()
236
+ const dele = (issued || []).find((d) => d.nonce === nonce)
237
+ const res = await identity.revokeDelegation(nonce)
238
+ if (dele?.sub) await emitRevoke(dele.sub, nonce)
239
+ return res
218
240
  }
219
241
 
220
242
  return {
@@ -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,237 @@
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) {
64
+ installNodeGlobals()
65
+ const { WebSocketProxyClient } = await import('@dotrino/proxy-client')
66
+ const client = new WebSocketProxyClient({ url: proxyUrl, enableWebRTC: false, autoReconnect: false })
67
+ await client.connect()
68
+ return client
69
+ }
70
+
71
+ /** Identifica la conexión bajo la pubkey del servicio (para ser direccionable). */
72
+ async function identifyAsService (client, device) {
73
+ const data = { op: 'identify', publickey: device.publickey, token: client.token, ts: Date.now() }
74
+ const { signature } = await signWithDevice({ privateJwk: device.privateJwk, data })
75
+ await client.identify({ data, signature })
76
+ }
77
+
78
+ function waitForMsg (client, predicate, timeoutMs = 30000) {
79
+ return new Promise((resolve, reject) => {
80
+ const off = client.on('message', (_from, payload) => {
81
+ if (payload && typeof payload === 'object' && predicate(payload)) { cleanup(); resolve(payload) }
82
+ })
83
+ const t = setTimeout(() => { cleanup(); reject(new Error('timeout esperando respuesta del vault')) }, timeoutMs)
84
+ const cleanup = () => { off(); clearTimeout(t) }
85
+ })
86
+ }
87
+
88
+ const identityFileOf = (dir) => path.join(dir, IDENTITY_FILE)
89
+
90
+ /** Lee la identidad persistida del servicio ({device, cert, iss, proxy, ns}) o null. */
91
+ export function readServiceIdentity (dir) {
92
+ try { return JSON.parse(fs.readFileSync(identityFileOf(dir), 'utf8')) } catch (_) { return null }
93
+ }
94
+
95
+ function writeServiceIdentity (dir, obj) {
96
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 })
97
+ const f = identityFileOf(dir)
98
+ fs.writeFileSync(f, JSON.stringify(obj, null, 2), { mode: 0o600 })
99
+ }
100
+
101
+ /**
102
+ * Enrola ESTE servicio contra el vault (una sola vez; persiste la identidad).
103
+ * En el vault se corre antes `dotrino-vault pair --service <ns>`; el QR/payload
104
+ * de ese comando es el `qr` de aquí. Muestra un código por `onCode` (o stdout):
105
+ * el dueño lo tipea en el vault (`dotrino-vault approve <código>`).
106
+ *
107
+ * @param {Object} opts
108
+ * @param {{v:number, iss:string, proxy:string, token:string, sn:string}|string} opts.qr QR v2 (objeto o JSON string).
109
+ * @param {string} opts.ns Namespace de secretos del servicio (el mismo del pair).
110
+ * @param {string} opts.dir Dónde persistir `service-identity.json`.
111
+ * @param {string} [opts.label]
112
+ * @param {(c:{deviceId:string, code:string})=>void} [opts.onCode]
113
+ * @returns {Promise<{device, cert, iss:string}>}
114
+ */
115
+ export async function enrollService ({ qr, ns, dir, label, onCode, approveTimeoutMs = 180000 } = {}) {
116
+ if (typeof qr === 'string') { try { qr = JSON.parse(qr) } catch (_) { throw new Error('qr inválido: no es JSON') } }
117
+ if (!qr?.iss || !qr?.proxy || !qr?.token || !qr?.sn) throw new Error('qr inválido (v2): faltan iss/proxy/token/sn')
118
+ if (!isValidSecretsNs(ns)) throw new Error('ns inválido (usa [a-z0-9-]{1,32}, p.ej. "proxy")')
119
+ if (!dir) throw new Error('falta dir (dónde persistir la identidad del servicio)')
120
+ label = label || 'servicio:' + ns
121
+
122
+ const client = await freshClient(qr.proxy)
123
+ try {
124
+ const device = await makeDeviceKey({ label })
125
+ const deviceId = (await pubkeyId(device.publickey)).slice(0, 8).toUpperCase().replace(/(.{4})(.{4})/, '$1-$2')
126
+ // Código ALEATORIO generado AQUÍ: el vault no lo conoce; solo puede echarlo
127
+ // de vuelta si el dueño lo tipeó (= tiene esta pantalla a la vista).
128
+ const code = makePairingCode()
129
+ const data = { op: 'enroll', dpub: device.publickey, token: qr.token, sn: qr.sn, label, ts: Date.now() }
130
+ const { signature } = await signWithDevice({ privateJwk: device.privateJwk, data })
131
+
132
+ const enrolled = new Promise((resolve, reject) => {
133
+ const off = client.on('message', (_from, p) => {
134
+ if (!p || typeof p !== 'object') return
135
+ if (p.type === MSG.ENROLL_CHALLENGE) {
136
+ const show = onCode || (({ deviceId, code }) => console.log(`[vault-service] dispositivo ${deviceId} · aprueba en el vault: dotrino-vault approve ${code}`))
137
+ show({ deviceId, code })
138
+ } else if (p.type === MSG.ENROLLED) { cleanup(); resolve(p) } else if (p.type === MSG.ERROR) { cleanup(); reject(new Error(p.error)) }
139
+ })
140
+ const t = setTimeout(() => { cleanup(); reject(new Error('timeout esperando la aprobación en el vault')) }, approveTimeoutMs)
141
+ const cleanup = () => { off(); clearTimeout(t) }
142
+ })
143
+ client.sendByPubkey(qr.iss, { type: MSG.ENROLL, data, signature })
144
+ const res = await enrolled
145
+
146
+ // Validación estricta (igual que un dispositivo): cert de la maestra VISTA,
147
+ // para ESTA llave, y el código echado debe ser el nuestro (anti vault falso).
148
+ if (res.code !== code) throw new Error('el vault devolvió un código distinto al mostrado (posible relay malicioso)')
149
+ const v = await verifyDelegation({ cert: res.cert, expectedSub: device.publickey, expectedScope: secretsScope(ns) })
150
+ if (!v.ok) throw new Error('cert inválido: ' + v.reason)
151
+ if (res.cert.iss !== qr.iss) throw new Error('cert firmado por una maestra distinta a la del QR')
152
+
153
+ writeServiceIdentity(dir, { v: 1, ns, iss: qr.iss, proxy: qr.proxy, device, cert: res.cert, enrolledAt: Date.now() })
154
+ return { device, cert: res.cert, iss: qr.iss }
155
+ } finally { client.close() }
156
+ }
157
+
158
+ /**
159
+ * Pide los secretos del ns al vault (una petición puntual; lanza si falla).
160
+ * Usa la identidad persistida por `enrollService` salvo que se pase explícita.
161
+ * Renueva el cert automáticamente si está por vencer (best-effort).
162
+ * @returns {Promise<Record<string,string>>} secretos KEY→valor
163
+ */
164
+ export async function fetchSecrets ({ dir, ns, proxyUrl, masterPubkey, device, cert, timeoutMs = 30000 } = {}) {
165
+ let saved = null
166
+ if (dir) saved = readServiceIdentity(dir)
167
+ ns = ns || saved?.ns
168
+ proxyUrl = proxyUrl || saved?.proxy
169
+ masterPubkey = masterPubkey || saved?.iss
170
+ device = device || saved?.device
171
+ cert = cert || saved?.cert
172
+ if (!isValidSecretsNs(ns)) throw new Error('ns inválido')
173
+ if (!proxyUrl || !masterPubkey || !device || !cert) {
174
+ throw new Error('servicio sin enrolar: corre primero enrollService() (falta service-identity.json)')
175
+ }
176
+
177
+ const client = await freshClient(proxyUrl)
178
+ try {
179
+ await identifyAsService(client, device)
180
+
181
+ // Renovación de cert best-effort si vence pronto (mientras siga vigente).
182
+ if (typeof cert.exp === 'number' && cert.exp - Date.now() < RENEW_BEFORE_MS && cert.exp > Date.now()) {
183
+ try {
184
+ const data = { op: 'renew', publickey: device.publickey, ts: Date.now() }
185
+ const { signature } = await signWithDevice({ privateJwk: device.privateJwk, data })
186
+ const pending = waitForMsg(client, (p) => p.type === MSG.RENEWED || p.type === MSG.ERROR, 15000)
187
+ client.sendByPubkey(masterPubkey, { type: MSG.RENEW, data, signature, cert })
188
+ const res = await pending
189
+ if (res.type === MSG.RENEWED && res.cert?.sub === device.publickey) {
190
+ const v = await verifyDelegation({ cert: res.cert, expectedSub: device.publickey, expectedScope: secretsScope(ns) })
191
+ if (v.ok) { cert = res.cert; if (dir && saved) writeServiceIdentity(dir, { ...saved, cert }) }
192
+ }
193
+ } catch (_) { /* la renovación no bloquea el fetch */ }
194
+ }
195
+
196
+ const eph = await makeEphemeralKey()
197
+ const data = { op: 'secrets', ns, ek: eph.ek, publickey: device.publickey, ts: Date.now() }
198
+ const { signature } = await signWithDevice({ privateJwk: device.privateJwk, data })
199
+ const pending = waitForMsg(client, (p) => p.type === MSG.SECRETS_RESULT || p.type === MSG.ERROR, timeoutMs)
200
+ client.sendByPubkey(masterPubkey, { type: MSG.SECRETS, data, signature, cert })
201
+ const res = await pending
202
+ if (res.type === MSG.ERROR) throw new Error(res.error)
203
+
204
+ // Autenticidad: el cuerpo viene firmado por la MAESTRA pineada.
205
+ const body = res.body
206
+ if (!body || body.op !== 'secrets.result' || body.ns !== ns) throw new Error('respuesta de secretos malformada')
207
+ if (typeof body.ts !== 'number' || Math.abs(Date.now() - body.ts) > FRESH_WINDOW_MS) throw new Error('respuesta de secretos vencida')
208
+ const ok = await verifyDeviceSig({ publickey: masterPubkey, data: body, signature: res.signature })
209
+ if (!ok) throw new Error('firma de la maestra inválida en la respuesta de secretos')
210
+
211
+ const payload = await openSealed({ privateKey: eph.privateKey, enc: body.enc })
212
+ if (!payload || typeof payload.secrets !== 'object') throw new Error('sobre de secretos malformado')
213
+ return payload.secrets
214
+ } finally { client.close() }
215
+ }
216
+
217
+ /**
218
+ * Bucle de arranque de un servicio: pide los secretos y, si el vault no está
219
+ * disponible, REINTENTA para siempre (con backoff hasta `maxRetryMs`). El
220
+ * servicio no opera hasta que esto resuelva — esa es la regla.
221
+ * @returns {Promise<Record<string,string>>}
222
+ */
223
+ export async function waitForSecrets ({ dir, ns, proxyUrl, masterPubkey, device, cert, retryMs = 5000, maxRetryMs = 60000, onRetry } = {}) {
224
+ let delay = retryMs
225
+ for (;;) {
226
+ try {
227
+ return await fetchSecrets({ dir, ns, proxyUrl, masterPubkey, device, cert })
228
+ } catch (e) {
229
+ // Lo NO transitorio no se arregla reintentando: falta de enrolamiento,
230
+ // cert revocado/vencido o scope equivocado exigen re-emparejar → se corta.
231
+ if (/sin enrolar|ns inválido|no autorizado: (revoked|expired|scope|untrusted-issuer|cert-device-mismatch)/.test(e.message)) throw e
232
+ try { onRetry?.(e, delay) } catch (_) {}
233
+ await new Promise((r) => setTimeout(r, delay))
234
+ delay = Math.min(maxRetryMs, Math.round(delay * 1.6))
235
+ }
236
+ }
237
+ }