@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 +11 -2
- package/src/index.js +24 -2
- package/src/protocol.js +53 -0
- package/src/sealed.js +84 -0
- package/src/service.js +237 -0
package/package.json
CHANGED
|
@@ -1,13 +1,22 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dotrino/vault",
|
|
3
|
-
"version": "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.
|
|
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
|
-
|
|
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 {
|
package/src/protocol.js
ADDED
|
@@ -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
|
+
}
|