@dotrino/vaultd 0.6.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.
@@ -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
+ }
@@ -0,0 +1,258 @@
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, commitCode, 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
+ // El 'error' de transporte del cliente puede llegar como un Event sin
80
+ // `message` → sin esto el operador ve una línea de error vacía.
81
+ const why = e?.message || e?.type || 'error de transporte'
82
+ throw new Error(`no se pudo conectar al proxy ${proxyUrl}: ${why}`)
83
+ } finally {
84
+ clearTimeout(timer)
85
+ }
86
+ return client
87
+ }
88
+
89
+ /** Identifica la conexión bajo la pubkey del servicio (para ser direccionable). */
90
+ async function identifyAsService (client, device) {
91
+ const data = { op: 'identify', publickey: device.publickey, token: client.token, ts: Date.now() }
92
+ const { signature } = await signWithDevice({ privateJwk: device.privateJwk, data })
93
+ await client.identify({ data, signature })
94
+ }
95
+
96
+ function waitForMsg (client, predicate, timeoutMs = 30000) {
97
+ return new Promise((resolve, reject) => {
98
+ const off = client.on('message', (_from, payload) => {
99
+ if (payload && typeof payload === 'object' && predicate(payload)) { cleanup(); resolve(payload) }
100
+ })
101
+ const t = setTimeout(() => { cleanup(); reject(new Error('timeout esperando respuesta del vault')) }, timeoutMs)
102
+ const cleanup = () => { off(); clearTimeout(t) }
103
+ })
104
+ }
105
+
106
+ const identityFileOf = (dir) => path.join(dir, IDENTITY_FILE)
107
+
108
+ /** Lee la identidad persistida del servicio ({device, cert, iss, proxy, ns}) o null. */
109
+ export function readServiceIdentity (dir) {
110
+ try { return JSON.parse(fs.readFileSync(identityFileOf(dir), 'utf8')) } catch (_) { return null }
111
+ }
112
+
113
+ function writeServiceIdentity (dir, obj) {
114
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 })
115
+ const f = identityFileOf(dir)
116
+ fs.writeFileSync(f, JSON.stringify(obj, null, 2), { mode: 0o600 })
117
+ }
118
+
119
+ /**
120
+ * Enrola ESTE servicio contra el vault (una sola vez; persiste la identidad).
121
+ * En el vault se corre antes `dotrino-vault pair --service <ns>`; el QR/payload
122
+ * de ese comando es el `qr` de aquí. Muestra un código por `onCode` (o stdout):
123
+ * el dueño lo tipea en el vault (`dotrino-vault approve <código>`).
124
+ *
125
+ * @param {Object} opts
126
+ * @param {{v:number, iss:string, proxy:string, token:string, sn:string}|string} opts.qr QR v2 (objeto o JSON string).
127
+ * @param {string} opts.ns Namespace de secretos del servicio (el mismo del pair).
128
+ * @param {string} opts.dir Dónde persistir `service-identity.json`.
129
+ * @param {string} [opts.label]
130
+ * @param {(c:{deviceId:string, code:string})=>void} [opts.onCode]
131
+ * @returns {Promise<{device, cert, iss:string}>}
132
+ */
133
+ export async function enrollService ({ qr, ns, dir, label, onCode, approveTimeoutMs = 180000 } = {}) {
134
+ if (typeof qr === 'string') { try { qr = JSON.parse(qr) } catch (_) { throw new Error('qr inválido: no es JSON') } }
135
+ if (!qr?.iss || !qr?.proxy || !qr?.token || !qr?.sn) throw new Error('qr inválido (v2): faltan iss/proxy/token/sn')
136
+ if (!isValidSecretsNs(ns)) throw new Error('ns inválido (usa [a-z0-9-]{1,32}, p.ej. "proxy")')
137
+ if (!dir) throw new Error('falta dir (dónde persistir la identidad del servicio)')
138
+ label = label || 'servicio:' + ns
139
+
140
+ const client = await freshClient(qr.proxy)
141
+ try {
142
+ const device = await makeDeviceKey({ label })
143
+ const deviceId = (await pubkeyId(device.publickey)).slice(0, 8).toUpperCase().replace(/(.{4})(.{4})/, '$1-$2')
144
+ // Código ALEATORIO generado AQUÍ: el vault no lo conoce; solo puede echarlo
145
+ // de vuelta si el dueño lo tipeó (= tiene esta pantalla a la vista).
146
+ const code = makePairingCode()
147
+ // El COMPROMISO del código (nunca el código): la bóveda lo recompone con lo que
148
+ // tipeas y solo entonces firma el cert → aprobar exige haber leído esta pantalla.
149
+ const commit = await commitCode({ code, dpub: device.publickey, sn: qr.sn })
150
+ const data = { op: 'enroll', dpub: device.publickey, token: qr.token, sn: qr.sn, commit, label, ts: Date.now() }
151
+ const { signature } = await signWithDevice({ privateJwk: device.privateJwk, data })
152
+
153
+ const enrolled = new Promise((resolve, reject) => {
154
+ const off = client.on('message', (_from, p) => {
155
+ if (!p || typeof p !== 'object') return
156
+ if (p.type === MSG.ENROLL_CHALLENGE) {
157
+ const show = onCode || (({ deviceId, code }) => console.log(`[vault-service] dispositivo ${deviceId} · aprueba en el vault: dotrino-vault approve ${code}`))
158
+ show({ deviceId, code })
159
+ } else if (p.type === MSG.ENROLLED) { cleanup(); resolve(p) } else if (p.type === MSG.ERROR) { cleanup(); reject(new Error(p.error)) }
160
+ })
161
+ const t = setTimeout(() => { cleanup(); reject(new Error('timeout esperando la aprobación en el vault')) }, approveTimeoutMs)
162
+ const cleanup = () => { off(); clearTimeout(t) }
163
+ })
164
+ client.sendByPubkey(qr.iss, { type: MSG.ENROLL, data, signature })
165
+ const res = await enrolled
166
+
167
+ // Validación estricta (igual que un dispositivo): cert de la maestra VISTA,
168
+ // para ESTA llave, y el código echado debe ser el nuestro (anti vault falso).
169
+ if (res.code !== code) throw new Error('el vault devolvió un código distinto al mostrado (posible relay malicioso)')
170
+ const v = await verifyDelegation({ cert: res.cert, expectedSub: device.publickey, expectedScope: secretsScope(ns) })
171
+ if (!v.ok) throw new Error('cert inválido: ' + v.reason)
172
+ if (res.cert.iss !== qr.iss) throw new Error('cert firmado por una maestra distinta a la del QR')
173
+
174
+ writeServiceIdentity(dir, { v: 1, ns, iss: qr.iss, proxy: qr.proxy, device, cert: res.cert, enrolledAt: Date.now() })
175
+ return { device, cert: res.cert, iss: qr.iss }
176
+ } finally { client.close() }
177
+ }
178
+
179
+ /**
180
+ * Pide los secretos del ns al vault (una petición puntual; lanza si falla).
181
+ * Usa la identidad persistida por `enrollService` salvo que se pase explícita.
182
+ * Renueva el cert automáticamente si está por vencer (best-effort).
183
+ * @returns {Promise<Record<string,string>>} secretos KEY→valor
184
+ */
185
+ export async function fetchSecrets ({ dir, ns, proxyUrl, masterPubkey, device, cert, timeoutMs = 30000 } = {}) {
186
+ let saved = null
187
+ if (dir) saved = readServiceIdentity(dir)
188
+ ns = ns || saved?.ns
189
+ proxyUrl = proxyUrl || saved?.proxy
190
+ masterPubkey = masterPubkey || saved?.iss
191
+ device = device || saved?.device
192
+ cert = cert || saved?.cert
193
+ if (!isValidSecretsNs(ns)) throw new Error('ns inválido')
194
+ if (!proxyUrl || !masterPubkey || !device || !cert) {
195
+ throw new Error('servicio sin enrolar: corre primero enrollService() (falta service-identity.json)')
196
+ }
197
+
198
+ const client = await freshClient(proxyUrl)
199
+ try {
200
+ await identifyAsService(client, device)
201
+
202
+ // Renovación de cert best-effort si vence pronto (mientras siga vigente).
203
+ if (typeof cert.exp === 'number' && cert.exp - Date.now() < RENEW_BEFORE_MS && cert.exp > Date.now()) {
204
+ try {
205
+ const data = { op: 'renew', publickey: device.publickey, ts: Date.now() }
206
+ const { signature } = await signWithDevice({ privateJwk: device.privateJwk, data })
207
+ const pending = waitForMsg(client, (p) => p.type === MSG.RENEWED || p.type === MSG.ERROR, 15000)
208
+ client.sendByPubkey(masterPubkey, { type: MSG.RENEW, data, signature, cert })
209
+ const res = await pending
210
+ if (res.type === MSG.RENEWED && res.cert?.sub === device.publickey) {
211
+ const v = await verifyDelegation({ cert: res.cert, expectedSub: device.publickey, expectedScope: secretsScope(ns) })
212
+ if (v.ok) { cert = res.cert; if (dir && saved) writeServiceIdentity(dir, { ...saved, cert }) }
213
+ }
214
+ } catch (_) { /* la renovación no bloquea el fetch */ }
215
+ }
216
+
217
+ const eph = await makeEphemeralKey()
218
+ const data = { op: 'secrets', ns, ek: eph.ek, publickey: device.publickey, ts: Date.now() }
219
+ const { signature } = await signWithDevice({ privateJwk: device.privateJwk, data })
220
+ const pending = waitForMsg(client, (p) => p.type === MSG.SECRETS_RESULT || p.type === MSG.ERROR, timeoutMs)
221
+ client.sendByPubkey(masterPubkey, { type: MSG.SECRETS, data, signature, cert })
222
+ const res = await pending
223
+ if (res.type === MSG.ERROR) throw new Error(res.error)
224
+
225
+ // Autenticidad: el cuerpo viene firmado por la MAESTRA pineada.
226
+ const body = res.body
227
+ if (!body || body.op !== 'secrets.result' || body.ns !== ns) throw new Error('respuesta de secretos malformada')
228
+ if (typeof body.ts !== 'number' || Math.abs(Date.now() - body.ts) > FRESH_WINDOW_MS) throw new Error('respuesta de secretos vencida')
229
+ const ok = await verifyDeviceSig({ publickey: masterPubkey, data: body, signature: res.signature })
230
+ if (!ok) throw new Error('firma de la maestra inválida en la respuesta de secretos')
231
+
232
+ const payload = await openSealed({ privateKey: eph.privateKey, enc: body.enc })
233
+ if (!payload || typeof payload.secrets !== 'object') throw new Error('sobre de secretos malformado')
234
+ return payload.secrets
235
+ } finally { client.close() }
236
+ }
237
+
238
+ /**
239
+ * Bucle de arranque de un servicio: pide los secretos y, si el vault no está
240
+ * disponible, REINTENTA para siempre (con backoff hasta `maxRetryMs`). El
241
+ * servicio no opera hasta que esto resuelva — esa es la regla.
242
+ * @returns {Promise<Record<string,string>>}
243
+ */
244
+ export async function waitForSecrets ({ dir, ns, proxyUrl, masterPubkey, device, cert, retryMs = 5000, maxRetryMs = 60000, onRetry } = {}) {
245
+ let delay = retryMs
246
+ for (;;) {
247
+ try {
248
+ return await fetchSecrets({ dir, ns, proxyUrl, masterPubkey, device, cert })
249
+ } catch (e) {
250
+ // Lo NO transitorio no se arregla reintentando: falta de enrolamiento,
251
+ // cert revocado/vencido o scope equivocado exigen re-emparejar → se corta.
252
+ if (/sin enrolar|ns inválido|no autorizado: (revoked|expired|scope|cn|untrusted-issuer|cert-device-mismatch)/.test(e.message)) throw e
253
+ try { onRetry?.(e, delay) } catch (_) {}
254
+ await new Promise((r) => setTimeout(r, delay))
255
+ delay = Math.min(maxRetryMs, Math.round(delay * 1.6))
256
+ }
257
+ }
258
+ }
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "@dotrino/vaultd",
3
+ "version": "0.6.0",
4
+ "type": "module",
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
+ "bin": {
7
+ "dotrino-vaultd": "bin/dotrino-vaultd.js",
8
+ "dotrino-vault-tui": "bin/dotrino-vault-tui.js",
9
+ "dotrino-vault": "bin/dotrino-vault.js"
10
+ },
11
+ "scripts": {
12
+ "start": "node bin/dotrino-vaultd.js",
13
+ "pair": "node bin/dotrino-vaultd.js --pair",
14
+ "tui": "node bin/dotrino-vault-tui.js",
15
+ "test": "node --test test/*.test.mjs"
16
+ },
17
+ "engines": {
18
+ "node": ">=20"
19
+ },
20
+ "dependencies": {
21
+ "@dotrino/identity": "^0.34.0",
22
+ "@dotrino/proxy-client": "0.8.0",
23
+ "ws": "^8.18.0"
24
+ },
25
+ "license": "MIT",
26
+ "publishConfig": {
27
+ "access": "public"
28
+ },
29
+ "repository": {
30
+ "type": "git",
31
+ "url": "git+https://github.com/imdotrino/dotrino-vault.git"
32
+ },
33
+ "homepage": "https://vault.dotrino.com/",
34
+ "files": [
35
+ "bin",
36
+ "src",
37
+ "README.md",
38
+ "vendor",
39
+ "lib/src"
40
+ ]
41
+ }
package/src/atrest.js ADDED
Binary file
package/src/client.js ADDED
@@ -0,0 +1,149 @@
1
+ /**
2
+ * Cliente de DISPOSITIVO (lado del que consulta el vault). Pensado para Node
3
+ * (CLI/tests); en un dispositivo real (navegador/app) se usa el mismo flujo con
4
+ * `@dotrino/identity/capabilities` + `@dotrino/proxy-client`.
5
+ *
6
+ * Emparejamiento ENDURECIDO (docs/pairing-protocol.md): el dispositivo genera su
7
+ * sub-clave `D`, FIRMA el ENROLL con ella (prueba de posesión) y adjunta el COMPROMISO
8
+ * de un código de 6 dígitos que MUESTRA en pantalla (el código nunca viaja). El dueño lo
9
+ * tipea en la bóveda, que recompone el compromiso y solo entonces firma el cert; al
10
+ * recibirlo, el dispositivo comprueba que le ECHAN su código y VALIDA el cert
11
+ * estrictamente (firmado por la maestra que vio en el QR, y para SU clave) antes de
12
+ * guardarlo. La maestra nunca sale del vault.
13
+ */
14
+ import { makeDeviceKey, signWithDevice, verifyDelegation, verifyDeviceSig, makePairingCode, commitCode, pubkeyId } from '@dotrino/identity/capabilities'
15
+ import { installNodeGlobals } from './node-globals.js'
16
+ import { MSG } from './protocol.js'
17
+
18
+ async function freshClient ({ proxyUrl, dir = '.dotrino-vault-device' }) {
19
+ installNodeGlobals(dir)
20
+ const { WebSocketProxyClient } = await import('@dotrino/proxy-client')
21
+ const client = new WebSocketProxyClient({ url: proxyUrl, enableWebRTC: false, autoReconnect: false })
22
+ await client.connect()
23
+ return client
24
+ }
25
+
26
+ /** Identifica la conexión bajo la pubkey de dispositivo D (para ser direccionable). */
27
+ async function identifyAsDevice (client, device) {
28
+ if (!client.token) return
29
+ const data = { op: 'identify', publickey: device.publickey, token: client.token, ts: Date.now() }
30
+ const { signature } = await signWithDevice({ privateJwk: device.privateJwk, data })
31
+ await client.identify({ data, signature })
32
+ }
33
+
34
+ function waitFor (client, predicate, timeoutMs = 30000) {
35
+ return new Promise((resolve, reject) => {
36
+ const off = client.on('message', (_from, payload) => {
37
+ if (payload && typeof payload === 'object' && predicate(payload)) { cleanup(); resolve(payload) }
38
+ })
39
+ const t = setTimeout(() => { cleanup(); reject(new Error('timeout esperando respuesta del vault')) }, timeoutMs)
40
+ const cleanup = () => { off(); clearTimeout(t) }
41
+ })
42
+ }
43
+
44
+ /**
45
+ * Enrola este dispositivo contra un vault (flujo endurecido).
46
+ * @param {Object} opts
47
+ * @param {{v:number, iss:string, proxy:string, token:string, sn:string}} opts.qr QR v2 del vault.
48
+ * @param {string} [opts.label]
49
+ * @param {(c:{deviceId:string,code:string})=>void} [opts.onChallenge] Para MOSTRAR el código que el usuario tipea en la bóveda.
50
+ * @param {number} [opts.approveTimeoutMs] Cuánto esperar la aprobación humana (def 3 min).
51
+ * @returns {Promise<{ device, cert, iss:string }>} GUARDAR `device` (incluye la privada) + `cert`. `iss` = qr.iss verificado.
52
+ */
53
+ export async function enroll ({ qr, label = '', dir, onChallenge, approveTimeoutMs = 180000 } = {}) {
54
+ if (!qr?.iss || !qr?.proxy || !qr?.token || !qr?.sn) throw new Error('qr inválido (v2): faltan iss/proxy/token/sn')
55
+ const client = await freshClient({ proxyUrl: qr.proxy, dir })
56
+ try {
57
+ const device = await makeDeviceKey({ label })
58
+ const myDeviceId = (await pubkeyId(device.publickey)).slice(0, 8).toUpperCase().replace(/(.{4})(.{4})/, '$1-$2')
59
+ // ESTE dispositivo genera el código y lo MUESTRA; solo viaja su COMPROMISO. El vault
60
+ // lo aprende cuando un humano lo tipea, y solo firma el cert si el compromiso coincide.
61
+ const code = makePairingCode()
62
+ const commit = await commitCode({ code, dpub: device.publickey, sn: qr.sn })
63
+ // ENROLL firmado con D = prueba de posesión (un token robado ya no basta).
64
+ const data = { op: 'enroll', dpub: device.publickey, token: qr.token, sn: qr.sn, commit, label, ts: Date.now() }
65
+ const { signature } = await signWithDevice({ privateJwk: device.privateJwk, data })
66
+
67
+ const enrolled = new Promise((resolve, reject) => {
68
+ const off = client.on('message', (_from, p) => {
69
+ if (!p || typeof p !== 'object') return
70
+ if (p.type === MSG.ENROLL_CHALLENGE) {
71
+ onChallenge?.({ deviceId: myDeviceId, code }) // el código lo genera y muestra ESTE dispositivo
72
+ } else if (p.type === MSG.ENROLLED) {
73
+ // Aceptamos solo si la bóveda ECHA nuestro código: una que no lo conoce no nos enrola.
74
+ if (String(p.code || '').trim() !== code) return
75
+ cleanup(); resolve(p)
76
+ } else if (p.type === MSG.ERROR) {
77
+ cleanup(); reject(new Error(p.error))
78
+ }
79
+ })
80
+ const t = setTimeout(() => { cleanup(); reject(new Error('timeout esperando la aprobación en el vault')) }, approveTimeoutMs)
81
+ const cleanup = () => { off(); clearTimeout(t) }
82
+ })
83
+ client.sendByPubkey(qr.iss, { type: MSG.ENROLL, data, signature })
84
+ const res = await enrolled
85
+
86
+ // VALIDACIÓN ESTRICTA antes de persistir (cierra inyección de cert / sustitución de maestra).
87
+ const v = await verifyDelegation({ cert: res.cert, expectedSub: device.publickey })
88
+ if (!v.ok) throw new Error('cert inválido: ' + v.reason)
89
+ if (res.cert.iss !== qr.iss) throw new Error('cert firmado por una maestra distinta a la que viste (posible proxy malicioso)')
90
+ if (res.cert.sub !== device.publickey) throw new Error('cert emitido para otro dispositivo')
91
+ // OJO: devolvemos qr.iss (la maestra que el usuario VIO), NO res.iss.
92
+ return { device, cert: res.cert, iss: qr.iss }
93
+ } finally { client.close() }
94
+ }
95
+
96
+ /**
97
+ * Verifica que un mensaje REVOKED es AUTÉNTICO (firmado por la maestra pineada y
98
+ * para este dispositivo). SOLO si esto es true se debe ejecutar el self-wipe — un
99
+ * `MSG.ERROR` plano o una firma de otra clave JAMÁS borra (cierra el wipe-DoS).
100
+ */
101
+ export async function verifyRevoke ({ body, signature, master, devicePubkey }) {
102
+ if (!body || body.op !== 'revoke' || body.sub !== devicePubkey) return false
103
+ if (typeof body.exp === 'number' && Date.now() > body.exp) return false
104
+ return verifyDeviceSig({ publickey: master, data: body, signature })
105
+ }
106
+
107
+ /** Pide a la maestra que firme `payload` (scope vault:sign). */
108
+ export async function requestSign ({ masterPubkey, proxyUrl, device, cert, payload, dir } = {}) {
109
+ const client = await freshClient({ proxyUrl, dir })
110
+ try {
111
+ const data = { op: 'sign', payload, publickey: device.publickey, ts: Date.now() }
112
+ const { signature } = await signWithDevice({ privateJwk: device.privateJwk, data })
113
+ const pending = waitFor(client, (p) => p.type === MSG.SIGNED || p.type === MSG.ERROR)
114
+ client.sendByPubkey(masterPubkey, { type: MSG.SIGN, data, signature, cert })
115
+ const res = await pending
116
+ if (res.type === MSG.ERROR) throw new Error(res.error)
117
+ return { signature: res.signature, publickey: res.publickey }
118
+ } finally { client.close() }
119
+ }
120
+
121
+ /** Lee un nodo del árbol del vault (scope vault:read). */
122
+ export async function requestGet ({ masterPubkey, proxyUrl, device, cert, id = 'root', dir } = {}) {
123
+ const client = await freshClient({ proxyUrl, dir })
124
+ try {
125
+ const data = { op: 'get', id, publickey: device.publickey, ts: Date.now() }
126
+ const { signature } = await signWithDevice({ privateJwk: device.privateJwk, data })
127
+ const pending = waitFor(client, (p) => p.type === MSG.DATA || p.type === MSG.ERROR)
128
+ client.sendByPubkey(masterPubkey, { type: MSG.GET, data, signature, cert })
129
+ const res = await pending
130
+ if (res.type === MSG.ERROR) throw new Error(res.error)
131
+ return { id: res.id, node: res.node }
132
+ } finally { client.close() }
133
+ }
134
+
135
+ /** Llama un método del store de hilos/aperturas del vault (scope vault:store). */
136
+ export async function requestStore ({ masterPubkey, proxyUrl, device, cert, method, args, dir } = {}) {
137
+ const client = await freshClient({ proxyUrl, dir })
138
+ try {
139
+ const data = { op: 'store', method, args: args || {}, publickey: device.publickey, ts: Date.now() }
140
+ const { signature } = await signWithDevice({ privateJwk: device.privateJwk, data })
141
+ const pending = waitFor(client, (p) => p.type === MSG.STORE_RESULT || p.type === MSG.ERROR)
142
+ client.sendByPubkey(masterPubkey, { type: MSG.STORE, data, signature, cert })
143
+ const res = await pending
144
+ if (res.type === MSG.ERROR) throw new Error(res.error)
145
+ return res.result
146
+ } finally { client.close() }
147
+ }
148
+
149
+ export { identifyAsDevice }