@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,293 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* enroll.js — núcleo del LADO BÓVEDA del emparejamiento endurecido.
|
|
3
|
+
*
|
|
4
|
+
* Fuente ÚNICA del flujo `vault.enroll` → `vault.enroll.challenge` → `vault.enrolled`
|
|
5
|
+
* y de la revocación firmada. Lo consumen los tres sitios que hacen de bóveda:
|
|
6
|
+
* · el daemon del PC (`dotrino-vault/src/vault.js`)
|
|
7
|
+
* · «este dispositivo es bóveda» (`lib/src/index.js#startDeviceVault`)
|
|
8
|
+
* · la copia vendorizada del iframe de identidad (`dotrino-identity/vault/vendor/vault/`)
|
|
9
|
+
*
|
|
10
|
+
* Módulo PURO: sin `node:*`, sin red, sin disco. Recibe la identidad (que firma), un
|
|
11
|
+
* transporte (`send`/`sendByPubkey`) y callbacks de log/auditoría. Así el binario Node
|
|
12
|
+
* lo embebe al compilar (SEA), el navegador lo importa y el iframe lo vendoriza sin
|
|
13
|
+
* bundler.
|
|
14
|
+
*
|
|
15
|
+
* EL CÓDIGO DE APROBACIÓN, en detalle (esto es lo que hace seguro el emparejamiento):
|
|
16
|
+
* 1. El DISPOSITIVO genera un código aleatorio de 6 dígitos, lo MUESTRA en su pantalla
|
|
17
|
+
* y manda solo su COMPROMISO `SHA-256(code‖dpub‖sn)` dentro del `data` firmado.
|
|
18
|
+
* El código en sí NUNCA viaja.
|
|
19
|
+
* 2. La bóveda no conoce el código: lo aprende cuando un humano lo TIPEA al aprobar.
|
|
20
|
+
* 3. Al aprobar, la bóveda RECOMPUTA el compromiso con el código tipeado y solo firma
|
|
21
|
+
* el cert si coincide → aprobar exige haber ido a leer el código del dispositivo.
|
|
22
|
+
* 4. La bóveda ECHA el código junto al cert; el dispositivo lo acepta solo si es el
|
|
23
|
+
* suyo → una bóveda falsa (que nunca vio el código) no puede enrolarlo.
|
|
24
|
+
*
|
|
25
|
+
* Qué cierra y qué NO (sin exagerar): cierra que se emita un cert sin que quien aprueba
|
|
26
|
+
* tenga el código del dispositivo — antes se firmaba igual y la defensa vivía solo en el
|
|
27
|
+
* cliente honesto, así que un cliente malicioso se quedaba con un cert válido. NO cierra
|
|
28
|
+
* el phishing en el que alguien le DICTA el código al dueño por otro canal: contra eso
|
|
29
|
+
* está la copy de advertencia y que el dueño reconozca el `deviceId` (residual A1/A2 de
|
|
30
|
+
* `docs/pairing-protocol.md`).
|
|
31
|
+
*/
|
|
32
|
+
import { verifyDeviceSig, pubkeyId, commitCode } from '@dotrino/identity/capabilities'
|
|
33
|
+
import { verifyContinuity } from '@dotrino/identity/acta'
|
|
34
|
+
|
|
35
|
+
/** Un token de emparejamiento vale 5 min. */
|
|
36
|
+
export const PAIRING_TTL_MS = 5 * 60 * 1000
|
|
37
|
+
/** Ventana anti-replay del ENROLL (±5 min), mismo criterio que el identify del proxy. */
|
|
38
|
+
export const FRESH_WINDOW_MS = 5 * 60 * 1000
|
|
39
|
+
/** Vida por defecto del cert de un dispositivo (tope duro de `MAX_DELEGATION_MS`). */
|
|
40
|
+
export const DEVICE_TTL_MS = 30 * 24 * 60 * 60 * 1000
|
|
41
|
+
|
|
42
|
+
export const MSG_ENROLL = 'vault.enroll'
|
|
43
|
+
export const MSG_ENROLL_CHALLENGE = 'vault.enroll.challenge'
|
|
44
|
+
export const MSG_ENROLLED = 'vault.enrolled'
|
|
45
|
+
export const MSG_REVOKED = 'vault.revoked'
|
|
46
|
+
export const MSG_ERROR = 'vault.error'
|
|
47
|
+
|
|
48
|
+
/** Los scopes del cert se corresponden 1:1 con las capacidades del acta (§D7). */
|
|
49
|
+
const SCOPE_TO_CAP = { 'vault:sign': 'sign', 'vault:store': 'store', 'vault:read': 'read' }
|
|
50
|
+
export const scopeToCaps = (scope) =>
|
|
51
|
+
(Array.isArray(scope) ? scope : [scope]).map((s) => SCOPE_TO_CAP[s]).filter(Boolean)
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* El CN de un servicio sale de su scope: `vault:secrets:proxy` ⇒ CN `proxy`. Con CN, el
|
|
55
|
+
* miembro entra al acta como SERVICIO —solo abre su propio cajón— en vez de como un
|
|
56
|
+
* dispositivo del usuario. Es la frontera, y vive en el acta para que se pueda comprobar.
|
|
57
|
+
*/
|
|
58
|
+
export function scopeToCn (scope) {
|
|
59
|
+
for (const s of (Array.isArray(scope) ? scope : [scope])) {
|
|
60
|
+
const m = /^vault:secrets:([a-z0-9-]{1,32})$/.exec(String(s || ''))
|
|
61
|
+
if (m) return m[1]
|
|
62
|
+
}
|
|
63
|
+
return null
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Token aleatorio de 128 bits en hex. */
|
|
67
|
+
export function randToken () {
|
|
68
|
+
const b = crypto.getRandomValues(new Uint8Array(16))
|
|
69
|
+
return [...b].map((x) => x.toString(16).padStart(2, '0')).join('')
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** deviceId legible (p. ej. `C440-AC0E`) a partir de una pubkey JWK. */
|
|
73
|
+
export async function deviceIdOf (pub) {
|
|
74
|
+
const id = (await pubkeyId(pub)).slice(0, 8).toUpperCase()
|
|
75
|
+
return id.slice(0, 4) + '-' + id.slice(4, 8)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Crea el «mostrador» de emparejamiento de una bóveda.
|
|
80
|
+
*
|
|
81
|
+
* @param {Object} opts
|
|
82
|
+
* @param {Object} opts.identity firma: `signData`, `signDelegation`, `listDelegations`, `revokeDelegation`.
|
|
83
|
+
* @param {string} opts.iss pubkey de la maestra de ESTA bóveda (va en el QR).
|
|
84
|
+
* @param {string} opts.proxy URL del proxy (va en el QR).
|
|
85
|
+
* @param {(to:string, obj:object)=>void} opts.send responder por el token de la conexión.
|
|
86
|
+
* @param {(pub:string, obj:object)=>void} opts.sendByPubkey dirigir por pubkey (cola offline 24 h).
|
|
87
|
+
* @param {(op:string, info?:object)=>void} [opts.audit]
|
|
88
|
+
* @param {(...a:any[])=>void} [opts.log]
|
|
89
|
+
* @param {(c:{deviceId:string, scope:any, label:string})=>void} [opts.onChallenge] un dispositivo espera aprobación.
|
|
90
|
+
* @param {()=>void} [opts.onPendingChange]
|
|
91
|
+
* @param {string[]} [opts.defaultScope]
|
|
92
|
+
* @param {number} [opts.defaultTtlMs]
|
|
93
|
+
*/
|
|
94
|
+
export function createEnrollDesk ({
|
|
95
|
+
identity, iss, proxy, send, sendByPubkey,
|
|
96
|
+
audit = () => {}, log = () => {},
|
|
97
|
+
onChallenge = () => {}, onPendingChange = () => {},
|
|
98
|
+
defaultScope = ['vault:sign'], defaultTtlMs = DEVICE_TTL_MS
|
|
99
|
+
} = {}) {
|
|
100
|
+
if (!identity) throw new Error('createEnrollDesk: falta identity')
|
|
101
|
+
if (!iss) throw new Error('createEnrollDesk: falta iss (pubkey de la maestra)')
|
|
102
|
+
|
|
103
|
+
// token -> { token, exp, scope, ttlMs, label, sn, state, dpub?, deviceId?, commit?, from? }
|
|
104
|
+
// state: 'AWAITING_ENROLL' -> 'PENDING_CONFIRM'
|
|
105
|
+
const pending = new Map()
|
|
106
|
+
|
|
107
|
+
const fire = (fn, arg) => { try { fn(arg) } catch (_) {} }
|
|
108
|
+
const reply = (to, obj) => { try { send(to, obj) } catch (e) { log('[vault] no se pudo responder:', e.message) } }
|
|
109
|
+
const isFresh = (d) => typeof d?.ts === 'number' && Math.abs(Date.now() - d.ts) <= FRESH_WINDOW_MS
|
|
110
|
+
|
|
111
|
+
/** Inicia un emparejamiento: token + nonce de sesión. NO firma nada todavía. */
|
|
112
|
+
function startPairing ({ scope = defaultScope, ttlMs = defaultTtlMs, label = '' } = {}) {
|
|
113
|
+
pending.clear() // uno a la vez: una sesión nueva supersede a la anterior
|
|
114
|
+
const token = randToken()
|
|
115
|
+
const sn = randToken()
|
|
116
|
+
pending.set(token, { token, exp: Date.now() + PAIRING_TTL_MS, scope, ttlMs, label, sn, state: 'AWAITING_ENROLL' })
|
|
117
|
+
return { token, qr: { v: 2, iss, proxy, token, sn }, expiresInMs: PAIRING_TTL_MS }
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function stopPairing (token) { pending.delete(token) }
|
|
121
|
+
|
|
122
|
+
function listPending () {
|
|
123
|
+
return [...pending.values()]
|
|
124
|
+
.filter((p) => p.state === 'PENDING_CONFIRM')
|
|
125
|
+
.map((p) => ({ deviceId: p.deviceId, label: p.label || '', scope: p.scope }))
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function findPending (deviceId) {
|
|
129
|
+
for (const p of pending.values()) {
|
|
130
|
+
if (p.state === 'PENDING_CONFIRM' && p.deviceId === deviceId) return p
|
|
131
|
+
}
|
|
132
|
+
return null
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* ENROLL: el dispositivo prueba posesión de `D` firmando el sobre y deja el
|
|
137
|
+
* COMPROMISO de su código. Todavía NO se firma ningún cert.
|
|
138
|
+
*/
|
|
139
|
+
async function handleEnroll (from, p) {
|
|
140
|
+
const d = p?.data
|
|
141
|
+
if (!d || typeof d.dpub !== 'string' || typeof p.signature !== 'string') {
|
|
142
|
+
return reply(from, { type: MSG_ERROR, error: 'enroll inválido' })
|
|
143
|
+
}
|
|
144
|
+
const pend = pending.get(d.token)
|
|
145
|
+
if (!pend || pend.state === 'DONE' || Date.now() > pend.exp) {
|
|
146
|
+
return reply(from, { type: MSG_ERROR, error: 'token de emparejamiento inválido o expirado' })
|
|
147
|
+
}
|
|
148
|
+
if (d.sn !== pend.sn) return reply(from, { type: MSG_ERROR, error: 'sesión inválida' })
|
|
149
|
+
if (!isFresh(d)) {
|
|
150
|
+
audit('rejected', { what: 'enroll', reason: 'stale' })
|
|
151
|
+
return reply(from, { type: MSG_ERROR, error: 'petición vencida: ts fuera de la ventana ±5 min (posible replay, o el reloj del dispositivo está desfasado)' })
|
|
152
|
+
}
|
|
153
|
+
// PRUEBA DE POSESIÓN: la firma de `data` debe verificar contra `dpub`.
|
|
154
|
+
if (!(await verifyDeviceSig({ publickey: d.dpub, data: d, signature: p.signature }))) {
|
|
155
|
+
audit('rejected', { what: 'enroll', reason: 'bad-device-signature' })
|
|
156
|
+
return reply(from, { type: MSG_ERROR, error: 'firma de dispositivo inválida' })
|
|
157
|
+
}
|
|
158
|
+
// El COMPROMISO del código es obligatorio: sin él no se puede comprobar al aprobar
|
|
159
|
+
// y volveríamos a emitir certs a ciegas. Un cliente viejo cae acá con un mensaje claro.
|
|
160
|
+
if (typeof d.commit !== 'string' || !/^[0-9a-f]{64}$/.test(d.commit)) {
|
|
161
|
+
audit('rejected', { what: 'enroll', reason: 'no-commit' })
|
|
162
|
+
return reply(from, { type: MSG_ERROR, error: 'este dispositivo usa una versión antigua del emparejamiento (no envía el compromiso del código). Actualízalo y vuelve a intentarlo.' })
|
|
163
|
+
}
|
|
164
|
+
// Un solo dispositivo a la vez esperando su código (así aprobar no es ambiguo).
|
|
165
|
+
if (pend.state === 'PENDING_CONFIRM' && pend.dpub && pend.dpub !== d.dpub) {
|
|
166
|
+
return reply(from, { type: MSG_ERROR, error: 'ya hay un dispositivo usando este emparejamiento' })
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const deviceId = await deviceIdOf(d.dpub)
|
|
170
|
+
pend.state = 'PENDING_CONFIRM'
|
|
171
|
+
pend.dpub = d.dpub
|
|
172
|
+
pend.deviceId = deviceId
|
|
173
|
+
pend.commit = d.commit
|
|
174
|
+
// Llave de CIFRADO del dispositivo: con ella se le envuelve la clave de contenido del
|
|
175
|
+
// perfil al admitirlo. Sin ella entra, pero no podrá leer lo que haya guardado.
|
|
176
|
+
if (typeof d.encPub === 'string') pend.encPub = d.encPub
|
|
177
|
+
// Certificado de continuidad (opcional): lo firma la identidad que se une, con su
|
|
178
|
+
// propia llave. Se comprueba aquí y se guarda con el miembro al aprobar.
|
|
179
|
+
if (d.continuity) {
|
|
180
|
+
const okC = await verifyContinuity(d.continuity)
|
|
181
|
+
pend.continuity = (okC && d.continuity.member === d.dpub) ? d.continuity : null
|
|
182
|
+
}
|
|
183
|
+
pend.from = from // la bóveda NO conoce el código: lo aprende cuando lo tipeas
|
|
184
|
+
if (d.label) pend.label = String(d.label).slice(0, 60)
|
|
185
|
+
|
|
186
|
+
reply(from, { type: MSG_ENROLL_CHALLENGE, deviceId })
|
|
187
|
+
fire(onChallenge, { deviceId, scope: pend.scope, label: pend.label || '' })
|
|
188
|
+
fire(onPendingChange)
|
|
189
|
+
return { deviceId }
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Aprueba TIPEANDO el código que muestra el dispositivo. Recompone el compromiso
|
|
194
|
+
* `SHA-256(code‖dpub‖sn)` y solo firma el cert si coincide con el que llegó en el
|
|
195
|
+
* ENROLL — es decir, solo si de verdad fuiste a leer el código del dispositivo.
|
|
196
|
+
*
|
|
197
|
+
* @param {string} code
|
|
198
|
+
* @param {{deviceId?: string}} [opts] cuál aprobar cuando hay varios pendientes.
|
|
199
|
+
*/
|
|
200
|
+
async function approve (code, { deviceId } = {}) {
|
|
201
|
+
code = String(code || '').trim()
|
|
202
|
+
if (!code) throw new Error('falta el código (los dígitos que muestra el dispositivo)')
|
|
203
|
+
|
|
204
|
+
let pend
|
|
205
|
+
if (deviceId) {
|
|
206
|
+
pend = findPending(deviceId)
|
|
207
|
+
if (!pend) throw new Error('no hay ninguna máquina esperando aprobación con ese identificador')
|
|
208
|
+
} else {
|
|
209
|
+
const waiting = [...pending.values()].filter((p) => p.state === 'PENDING_CONFIRM' && p.dpub)
|
|
210
|
+
if (waiting.length === 0) throw new Error('no hay ningún dispositivo esperando aprobación')
|
|
211
|
+
if (waiting.length > 1) throw new Error('hay más de un emparejamiento en curso; reinícialo con dotrino-vault pair')
|
|
212
|
+
pend = waiting[0]
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// COMPROBACIÓN DEL CÓDIGO — antes de firmar nada.
|
|
216
|
+
const expected = await commitCode({ code, dpub: pend.dpub, sn: pend.sn })
|
|
217
|
+
if (expected !== pend.commit) {
|
|
218
|
+
audit('rejected', { what: 'approve', device: pend.deviceId, reason: 'bad-code' })
|
|
219
|
+
log('[vault] código incorrecto para %s: no se emitió ningún certificado', pend.deviceId)
|
|
220
|
+
throw new Error('el código no coincide con el que muestra el dispositivo: no se emitió ningún certificado. Vuelve a mirarlo y prueba otra vez.')
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const { cert } = await identity.signDelegation(pend.dpub, pend.scope, { ttlMs: pend.ttlMs, label: pend.label })
|
|
224
|
+
|
|
225
|
+
// Aprobar un emparejamiento ES admitir al dispositivo en el perfil: el cert es la
|
|
226
|
+
// credencial y el acta es la política, y no tiene sentido emitir una sin la otra.
|
|
227
|
+
// Las capacidades salen del scope que se pidió al emparejar (cert ∩ acta, §2.3).
|
|
228
|
+
let acta = null
|
|
229
|
+
try {
|
|
230
|
+
if (typeof identity.admitMember === 'function') {
|
|
231
|
+
const cn = scopeToCn(pend.scope)
|
|
232
|
+
const caps = cn ? ['secrets'] : scopeToCaps(pend.scope)
|
|
233
|
+
if (caps.length) await identity.admitMember({ pub: pend.dpub, encPub: pend.encPub || null, label: pend.label || '', cn, caps, cert, continuity: pend.continuity || null })
|
|
234
|
+
}
|
|
235
|
+
acta = (await identity.profileActa?.())?.acta || null
|
|
236
|
+
} catch (e) { log('[vault] no se pudo admitir en el acta:', e.message) }
|
|
237
|
+
|
|
238
|
+
audit('enroll', { device: pend.deviceId, label: pend.label || '', scope: pend.scope })
|
|
239
|
+
// Echamos el código tipeado junto al cert: el DISPOSITIVO acepta solo si coincide
|
|
240
|
+
// con el que generó → una bóveda falsa (que no lo conoce) no puede enrolarlo.
|
|
241
|
+
// El acta viaja con el cert: el dispositivo ya sabe de quién es el perfil al que entra.
|
|
242
|
+
reply(pend.from, { type: MSG_ENROLLED, code, cert, iss, acta })
|
|
243
|
+
pend.state = 'DONE'
|
|
244
|
+
pending.delete(pend.token)
|
|
245
|
+
fire(onPendingChange)
|
|
246
|
+
log('[vault] dispositivo aprobado: %s', pend.deviceId)
|
|
247
|
+
return { ok: true, deviceId: pend.deviceId, cert }
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** Rechaza un enrolamiento pendiente. */
|
|
251
|
+
function reject (deviceId) {
|
|
252
|
+
const pend = deviceId
|
|
253
|
+
? findPending(deviceId)
|
|
254
|
+
: [...pending.values()].find((p) => p.state === 'PENDING_CONFIRM')
|
|
255
|
+
if (!pend) return { ok: false }
|
|
256
|
+
reply(pend.from, { type: MSG_ERROR, error: 'emparejamiento rechazado' })
|
|
257
|
+
pending.delete(pend.token)
|
|
258
|
+
audit('reject', { device: pend.deviceId })
|
|
259
|
+
fire(onPendingChange)
|
|
260
|
+
log('[vault] dispositivo rechazado: %s', pend.deviceId)
|
|
261
|
+
return { ok: true, deviceId: pend.deviceId }
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Emite un REVOKED FIRMADO por la maestra para que el dispositivo se autoborre. El
|
|
266
|
+
* borrado remoto SOLO se dispara con esta firma (nunca con un error cualquiera →
|
|
267
|
+
* cierra el wipe-DoS). Va por `sendByPubkey`: si está apagado, el proxy lo encola 24 h.
|
|
268
|
+
*/
|
|
269
|
+
async function emitRevoke (dpub, nonce) {
|
|
270
|
+
const body = { op: 'revoke', sub: dpub, nonce, iat: Date.now(), exp: Date.now() + DEVICE_TTL_MS }
|
|
271
|
+
const { signature } = await identity.signData(body)
|
|
272
|
+
try { sendByPubkey(dpub, { type: MSG_REVOKED, body, signature }) }
|
|
273
|
+
catch (e) { log('[vault] no se pudo emitir revoke:', e.message) }
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** Revoca una delegación por `nonce` y avisa al dispositivo para que se autoborre. */
|
|
277
|
+
async function revoke (nonce) {
|
|
278
|
+
audit('revoke', { nonce })
|
|
279
|
+
const { issued } = await identity.listDelegations()
|
|
280
|
+
const dele = (issued || []).find((d) => d.nonce === nonce)
|
|
281
|
+
const res = await identity.revokeDelegation(nonce)
|
|
282
|
+
if (dele?.sub) await emitRevoke(dele.sub, nonce)
|
|
283
|
+
return res
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
return {
|
|
287
|
+
startPairing, stopPairing, handleEnroll, approve, reject,
|
|
288
|
+
listPending, findPending, emitRevoke, revoke,
|
|
289
|
+
get pendingCount () { return pending.size }
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
export default { createEnrollDesk, deviceIdOf, randToken }
|
package/lib/src/env.js
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@dotrino/vault/env` — el "dotenv contra el vault".
|
|
3
|
+
*
|
|
4
|
+
* Un proyecto Node cualquiera obtiene sus credenciales del vault del dueño en
|
|
5
|
+
* vez de llevarlas en un `.env`:
|
|
6
|
+
*
|
|
7
|
+
* import { loadEnv } from '@dotrino/vault/env'
|
|
8
|
+
* await loadEnv({ ns: 'miapp' }) // → process.env.API_KEY, …
|
|
9
|
+
*
|
|
10
|
+
* o, con la forma clásica de dotenv (side-effect, ns por `DOTRINO_NS`):
|
|
11
|
+
*
|
|
12
|
+
* import '@dotrino/vault/config'
|
|
13
|
+
*
|
|
14
|
+
* Lo que queda en el disco del servicio NO es un secreto: es la llave del
|
|
15
|
+
* dispositivo (generada aquí, nunca sale) y un certificado con scope
|
|
16
|
+
* `vault:secrets:<ns>`. Los valores solo viven en memoria del proceso; si la
|
|
17
|
+
* máquina se compromete, se revoca el cert y no había nada que robar.
|
|
18
|
+
*
|
|
19
|
+
* Enrolar una vez: npx dotrino-env enroll --ns miapp (ver bin/dotrino-env.js)
|
|
20
|
+
*/
|
|
21
|
+
import fs from 'node:fs'
|
|
22
|
+
import os from 'node:os'
|
|
23
|
+
import path from 'node:path'
|
|
24
|
+
import { fetchSecrets, waitForSecrets, readServiceIdentity } from './service.js'
|
|
25
|
+
import { isValidSecretsNs } from './protocol.js'
|
|
26
|
+
|
|
27
|
+
/** Raíz donde viven las identidades de servicio de esta máquina/usuario. */
|
|
28
|
+
export function serviceRoot () {
|
|
29
|
+
return process.env.DOTRINO_ENV_HOME || path.join(os.homedir(), '.dotrino', 'service')
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Directorio de la identidad del servicio `ns` (`DOTRINO_ENV_DIR` lo pisa). */
|
|
33
|
+
export function serviceDir (ns) {
|
|
34
|
+
if (process.env.DOTRINO_ENV_DIR) return process.env.DOTRINO_ENV_DIR
|
|
35
|
+
if (!isValidSecretsNs(ns)) throw new Error('ns inválido (usa [a-z0-9-]{1,32}, p. ej. "miapp")')
|
|
36
|
+
return path.join(serviceRoot(), ns)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Namespaces ya enrolados en esta máquina. */
|
|
40
|
+
export function listEnrolled () {
|
|
41
|
+
let names = []
|
|
42
|
+
try { names = fs.readdirSync(serviceRoot()) } catch (_) { return [] }
|
|
43
|
+
return names.filter((ns) => isValidSecretsNs(ns) && readServiceIdentity(path.join(serviceRoot(), ns)))
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Resuelve el ns cuando no se pasa explícito: `DOTRINO_NS`, y si no, el único
|
|
48
|
+
* enrolado en esta máquina. Con varios, exige elegir (no adivinamos).
|
|
49
|
+
*/
|
|
50
|
+
export function resolveNs (ns) {
|
|
51
|
+
ns = ns || process.env.DOTRINO_NS
|
|
52
|
+
if (ns) {
|
|
53
|
+
if (!isValidSecretsNs(ns)) throw new Error('ns inválido: ' + ns)
|
|
54
|
+
return ns
|
|
55
|
+
}
|
|
56
|
+
const found = listEnrolled()
|
|
57
|
+
if (found.length === 1) return found[0]
|
|
58
|
+
if (found.length === 0) {
|
|
59
|
+
throw new Error('no hay ningún servicio enrolado en esta máquina: corre `npx dotrino-env enroll --ns <tu-app>`')
|
|
60
|
+
}
|
|
61
|
+
throw new Error(`hay varios servicios enrolados (${found.join(', ')}): elige uno con DOTRINO_NS=<ns> o loadEnv({ ns })`)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Trae los secretos del ns desde el vault y los pone en `process.env`.
|
|
66
|
+
*
|
|
67
|
+
* @param {Object} [opts]
|
|
68
|
+
* @param {string} [opts.ns] Namespace (por defecto: `DOTRINO_NS` o el único enrolado).
|
|
69
|
+
* @param {string} [opts.dir] Dónde está `service-identity.json` (por defecto: `serviceDir(ns)`).
|
|
70
|
+
* @param {boolean} [opts.override] `true` = pisa variables ya presentes en el entorno (default: no).
|
|
71
|
+
* @param {boolean} [opts.wait] `true` (default) = si el vault no está, ESPERA (reintenta) en vez de fallar.
|
|
72
|
+
* @param {string[]} [opts.required] Claves que deben venir; si falta alguna, lanza.
|
|
73
|
+
* @param {(e:Error, ms:number)=>void} [opts.onRetry]
|
|
74
|
+
* @returns {Promise<{ns:string, secrets:Record<string,string>, injected:string[], skipped:string[]}>}
|
|
75
|
+
*/
|
|
76
|
+
export async function loadEnv ({ ns, dir, override = false, wait = true, required = [], onRetry } = {}) {
|
|
77
|
+
ns = resolveNs(ns)
|
|
78
|
+
dir = dir || serviceDir(ns)
|
|
79
|
+
const load = wait ? waitForSecrets : fetchSecrets
|
|
80
|
+
const secrets = await load({ dir, ns, onRetry })
|
|
81
|
+
|
|
82
|
+
const missing = required.filter((k) => !(k in secrets))
|
|
83
|
+
if (missing.length) {
|
|
84
|
+
throw new Error(`faltan secretos en el ns "${ns}": ${missing.join(', ')} (agrégalos con \`dotrino-vault secret set ${ns} <CLAVE> <valor>\`)`)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const injected = []
|
|
88
|
+
const skipped = []
|
|
89
|
+
for (const [k, v] of Object.entries(secrets)) {
|
|
90
|
+
if (!override && k in process.env) { skipped.push(k); continue }
|
|
91
|
+
process.env[k] = String(v)
|
|
92
|
+
injected.push(k)
|
|
93
|
+
}
|
|
94
|
+
return { ns, secrets, injected, skipped }
|
|
95
|
+
}
|
package/lib/src/index.js
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @dotrino/vault — "este dispositivo es una bóveda" (lado SERVIDOR, browser+node).
|
|
3
|
+
*
|
|
4
|
+
* Convierte la identidad de ESTE dispositivo (`@dotrino/identity`, la clave P) en una
|
|
5
|
+
* bóveda/CA: atiende el MISMO protocolo de enrolamiento endurecido que el daemon
|
|
6
|
+
* `dotrino-vault` (`vault.enroll` → `vault.enroll.challenge` → `vault.enrolled`) por el
|
|
7
|
+
* proxy del ecosistema, firma certificados de delegación `D ← P` al aprobar, y responde
|
|
8
|
+
* consultas de revocación (`vault.devices`). Así CUALQUIER app (no solo la terminal)
|
|
9
|
+
* puede dejar que el usuario use su dispositivo como bóveda, sin un PC con el daemon.
|
|
10
|
+
*
|
|
11
|
+
* Modelo de aprobación SEGURO (idéntico al daemon `dotrino-vault#approveDevice`):
|
|
12
|
+
* - El DISPOSITIVO que se enrola (p. ej. `@dotrino/identity#enrollDevice`) genera un
|
|
13
|
+
* código ALEATORIO (`makePairingCode`) y lo MUESTRA; NO lo envía por la red.
|
|
14
|
+
* - Esta bóveda NO conoce el código: un humano lo LEE del dispositivo y lo TIPEA aquí.
|
|
15
|
+
* - Al aprobar, la bóveda firma el cert y ECHA el código tipeado de vuelta.
|
|
16
|
+
* - El dispositivo acepta el cert SOLO si el código echado coincide con el que generó.
|
|
17
|
+
* → una bóveda falsa (que nunca vio el código) no puede enrolar el dispositivo, y
|
|
18
|
+
* aprobar "a ciegas" (sin ir a leer el código del dispositivo) tampoco enrola nada.
|
|
19
|
+
*
|
|
20
|
+
* El flujo de enrolamiento en sí (incluida la comprobación del código antes de firmar) vive
|
|
21
|
+
* en `./enroll.js`, COMPARTIDO con el daemon del PC y con la copia vendorizada del iframe:
|
|
22
|
+
* un solo sitio donde se decide a quién se le emite un certificado.
|
|
23
|
+
*
|
|
24
|
+
* Cripto 100% de `@dotrino/identity/capabilities`; firma con la identidad P
|
|
25
|
+
* (`identity.signDelegation`). Transporte: `@dotrino/proxy-client` (import perezoso).
|
|
26
|
+
* No reimplementa nada del ecosistema.
|
|
27
|
+
*/
|
|
28
|
+
import { verifyChain } from '@dotrino/identity/capabilities'
|
|
29
|
+
import { createEnrollDesk, deviceIdOf, DEVICE_TTL_MS, FRESH_WINDOW_MS } from './enroll.js'
|
|
30
|
+
|
|
31
|
+
const SIGN_SCOPE = 'vault:sign'
|
|
32
|
+
const SELFCERT_TTL_MS = 24 * 60 * 60 * 1000 // el self-cert P←P se regenera cada 24 h
|
|
33
|
+
|
|
34
|
+
const MSG = {
|
|
35
|
+
ENROLL: 'vault.enroll',
|
|
36
|
+
DEVICES: 'vault.devices',
|
|
37
|
+
DEVICES_RESULT: 'vault.devices.result',
|
|
38
|
+
ERROR: 'vault.error'
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** deviceId legible (p. ej. `C440-AC0E`) desde una pubkey JWK. */
|
|
42
|
+
export { deviceIdOf }
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Levanta la bóveda de este dispositivo: se conecta al proxy identificado como P y
|
|
46
|
+
* atiende enrolamientos + consultas de revocación de los dispositivos que se enrolan.
|
|
47
|
+
*
|
|
48
|
+
* @param {object} identity instancia de `@dotrino/identity` (P): expone
|
|
49
|
+
* `me.publickey`, `signData`, `signDelegation`, `listDelegations`, `revokeDelegation`.
|
|
50
|
+
* @param {object} [opts]
|
|
51
|
+
* @param {string} [opts.proxyUrl='wss://proxy.dotrino.com']
|
|
52
|
+
* @returns {Promise<object>} handle: { iss, proxy, client, startPairing, approve, reject,
|
|
53
|
+
* listPending, listMachines, revoke, getSelfCert, onPendingChange, close }
|
|
54
|
+
*/
|
|
55
|
+
export async function startDeviceVault (identity, { proxyUrl } = {}) {
|
|
56
|
+
const iss = identity.me?.publickey
|
|
57
|
+
if (!iss) throw new Error('sin identidad: crea/desbloquea tu identidad antes de usar el dispositivo como bóveda')
|
|
58
|
+
const proxy = proxyUrl || 'wss://proxy.dotrino.com'
|
|
59
|
+
|
|
60
|
+
// ----- self-cert P ← P (para que este dispositivo pueda además actuar de cliente
|
|
61
|
+
// de sus propias máquinas: lo firma la propia P y verifyChain lo acepta) -----
|
|
62
|
+
let _selfCert = null
|
|
63
|
+
const getSelfCert = async () => {
|
|
64
|
+
if (_selfCert && _selfCert.exp > Date.now() + 60_000) return _selfCert
|
|
65
|
+
const { cert } = await identity.signDelegation(iss, SIGN_SCOPE, { ttlMs: SELFCERT_TTL_MS })
|
|
66
|
+
_selfCert = cert
|
|
67
|
+
return cert
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const { WebSocketProxyClient } = await import('@dotrino/proxy-client')
|
|
71
|
+
const client = new WebSocketProxyClient({
|
|
72
|
+
url: proxy, enableWebRTC: false, autoReconnect: true,
|
|
73
|
+
maxReconnectAttempts: 100000, reconnectDelay: 4000
|
|
74
|
+
})
|
|
75
|
+
await client.connect()
|
|
76
|
+
|
|
77
|
+
const selfCert = await getSelfCert()
|
|
78
|
+
const identify = async () => {
|
|
79
|
+
if (!client.token) return
|
|
80
|
+
const data = { op: 'identify', publickey: iss, token: client.token, ts: Date.now() }
|
|
81
|
+
const { signature } = await identity.signData(data)
|
|
82
|
+
await client.identify({ data, signature, cert: selfCert })
|
|
83
|
+
}
|
|
84
|
+
await identify()
|
|
85
|
+
client.on('token', () => identify().catch(() => {}))
|
|
86
|
+
|
|
87
|
+
const send = (to, obj) => { try { client.send(to, obj) } catch (_) {} }
|
|
88
|
+
|
|
89
|
+
let _onPendingChange = () => {}
|
|
90
|
+
|
|
91
|
+
// ENROLL / aprobación / revocación: núcleo COMPARTIDO con el daemon del PC y con la
|
|
92
|
+
// copia vendorizada del iframe (`lib/src/enroll.js`). Un solo sitio donde vive el
|
|
93
|
+
// flujo → y por lo tanto un solo sitio donde se comprueba el código antes de firmar.
|
|
94
|
+
const desk = createEnrollDesk({
|
|
95
|
+
identity,
|
|
96
|
+
iss,
|
|
97
|
+
proxy,
|
|
98
|
+
send,
|
|
99
|
+
sendByPubkey: (pub, obj) => { try { client.sendByPubkey(pub, obj) } catch (_) {} },
|
|
100
|
+
defaultScope: [SIGN_SCOPE],
|
|
101
|
+
defaultTtlMs: DEVICE_TTL_MS,
|
|
102
|
+
onPendingChange: () => _onPendingChange()
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
// Consulta de revocaciones (igual que `vault.devices` del daemon): responde la lista
|
|
106
|
+
// de dispositivos enrolados + revocados para que el dispositivo refresque su set. Y si
|
|
107
|
+
// QUIEN consulta es una máquina ya revocada (reapareció), le re-emite el REVOKED firmado.
|
|
108
|
+
async function handleDevices (from, p) {
|
|
109
|
+
const d = p?.data
|
|
110
|
+
if (!d || !p.signature || !p.cert) return send(from, { type: MSG.ERROR, error: 'petición inválida' })
|
|
111
|
+
if (typeof d.ts !== 'number' || Math.abs(Date.now() - d.ts) > FRESH_WINDOW_MS) return
|
|
112
|
+
const chk = await verifyChain({ data: d, signature: p.signature, cert: p.cert, trustedIssuer: iss })
|
|
113
|
+
if (!chk.ok) return send(from, { type: MSG.ERROR, error: 'no autorizado: ' + chk.reason })
|
|
114
|
+
const { issued, revoked } = await identity.listDelegations()
|
|
115
|
+
const devices = await Promise.all((issued || []).map(async (x) => ({
|
|
116
|
+
deviceId: x.sub ? await deviceIdOf(x.sub) : null, sub: x.sub || null,
|
|
117
|
+
label: x.label || '', scope: x.scope, exp: x.exp, nonce: x.nonce
|
|
118
|
+
})))
|
|
119
|
+
send(from, { type: MSG.DEVICES_RESULT, devices, revoked: (revoked || []).map((r) => r.nonce || r) })
|
|
120
|
+
// ¿el que consulta es una máquina revocada que reapareció? → re-emite el REVOKED firmado.
|
|
121
|
+
const mine = (issued || []).find((x) => x.sub === chk.device && x.revokedAt)
|
|
122
|
+
if (mine) desk.emitRevoke(chk.device, mine.nonce)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
client.on('message', (_from, p) => {
|
|
126
|
+
if (!p || typeof p !== 'object') return
|
|
127
|
+
if (p.type === MSG.ENROLL) desk.handleEnroll(_from, p).catch(() => {})
|
|
128
|
+
else if (p.type === MSG.DEVICES) handleDevices(_from, p).catch(() => {})
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Máquinas enroladas bajo esta identidad (P), vigentes, con scope de firma y label
|
|
133
|
+
* propio (excluye navegadores enrolados con label 'cli', que no atienden peticiones).
|
|
134
|
+
*/
|
|
135
|
+
async function listMachines () {
|
|
136
|
+
const { issued } = await identity.listDelegations()
|
|
137
|
+
const now = Date.now()
|
|
138
|
+
const bySub = new Map()
|
|
139
|
+
for (const x of (issued || [])) {
|
|
140
|
+
if (!x.sub || x.revokedAt || (x.exp && x.exp <= now)) continue // revocada = fuera de la lista
|
|
141
|
+
if (!Array.isArray(x.scope) || !x.scope.includes(SIGN_SCOPE)) continue
|
|
142
|
+
if (!x.label || x.label === 'cli') continue
|
|
143
|
+
if (!bySub.has(x.sub) || (x.exp || 0) > (bySub.get(x.sub).exp || 0)) bySub.set(x.sub, x)
|
|
144
|
+
}
|
|
145
|
+
return Promise.all([...bySub.values()].map(async (x) => ({ ...x, deviceId: await deviceIdOf(x.sub) })))
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return {
|
|
149
|
+
iss, proxy, client,
|
|
150
|
+
startPairing: desk.startPairing,
|
|
151
|
+
// Aprueba TIPEANDO el código que muestra la máquina: el núcleo compartido recompone
|
|
152
|
+
// el compromiso `SHA-256(code‖dpub‖sn)` y solo firma el cert si coincide.
|
|
153
|
+
approve: (deviceId, code) => desk.approve(code, { deviceId }),
|
|
154
|
+
reject: (deviceId) => desk.reject(deviceId),
|
|
155
|
+
listPending: desk.listPending,
|
|
156
|
+
listMachines,
|
|
157
|
+
// Revoca y AVISA a la máquina con un REVOKED firmado para que se auto-borre (ahora si
|
|
158
|
+
// está online, o al reaparecer vía handleDevices).
|
|
159
|
+
revoke: (nonce) => desk.revoke(nonce),
|
|
160
|
+
getSelfCert,
|
|
161
|
+
onPendingChange (fn) { _onPendingChange = fn || (() => {}) },
|
|
162
|
+
close () { try { client.close() } catch (_) {} }
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export default { startDeviceVault, deviceIdOf }
|
|
@@ -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)
|