@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.
- package/README.md +301 -0
- package/bin/dotrino-vault-tui.js +28 -0
- package/bin/dotrino-vault.js +23 -0
- package/bin/dotrino-vaultd.js +29 -0
- package/bin/sea-entry.js +29 -0
- package/lib/README.md +139 -0
- package/lib/src/config.js +26 -0
- package/lib/src/enroll.js +293 -0
- package/lib/src/env.js +95 -0
- package/lib/src/index.js +166 -0
- package/lib/src/protocol.js +53 -0
- package/lib/src/sealed.js +84 -0
- package/lib/src/service.js +258 -0
- package/package.json +41 -0
- package/src/atrest.js +0 -0
- package/src/client.js +149 -0
- package/src/ctl.js +597 -0
- package/src/daemon.js +217 -0
- package/src/manager.js +88 -0
- package/src/node-globals.js +37 -0
- package/src/paths.js +47 -0
- package/src/profiles.js +214 -0
- package/src/protocol.js +6 -0
- package/src/qr.js +61 -0
- package/src/secretsStore.js +61 -0
- package/src/store.js +64 -0
- package/src/threadStore.js +111 -0
- package/src/transport.js +64 -0
- package/src/tui/app.js +722 -0
- package/src/tui/term.js +278 -0
- package/src/vault.js +303 -0
- package/src/vaultControl.js +296 -0
- package/vendor/qrcode-generator.cjs +2297 -0
|
@@ -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 }
|