@dotrino/vaultd 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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
+ }
@@ -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)