@dotrino/vault 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotrino/vault",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "Usa ESTE dispositivo (navegador) como bóveda/CA del ecosistema Dotrino: atiende enrolamientos por el proxy y firma certificados de delegación a tus máquinas. Incluye el cliente de SERVICIO (Node): un proyecto se enrola una vez y jala sus credenciales del vault en vez del .env (`import '@dotrino/vault/config'`).",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -42,7 +42,7 @@
42
42
  "pairing"
43
43
  ],
44
44
  "peerDependencies": {
45
- "@dotrino/identity": ">=0.17.0",
45
+ "@dotrino/identity": ">=0.24.0",
46
46
  "@dotrino/proxy-client": ">=0.6.0"
47
47
  },
48
48
  "license": "MIT",
package/src/enroll.js ADDED
@@ -0,0 +1,269 @@
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
+
34
+ /** Un token de emparejamiento vale 5 min. */
35
+ export const PAIRING_TTL_MS = 5 * 60 * 1000
36
+ /** Ventana anti-replay del ENROLL (±5 min), mismo criterio que el identify del proxy. */
37
+ export const FRESH_WINDOW_MS = 5 * 60 * 1000
38
+ /** Vida por defecto del cert de un dispositivo (tope duro de `MAX_DELEGATION_MS`). */
39
+ export const DEVICE_TTL_MS = 30 * 24 * 60 * 60 * 1000
40
+
41
+ export const MSG_ENROLL = 'vault.enroll'
42
+ export const MSG_ENROLL_CHALLENGE = 'vault.enroll.challenge'
43
+ export const MSG_ENROLLED = 'vault.enrolled'
44
+ export const MSG_REVOKED = 'vault.revoked'
45
+ export const MSG_ERROR = 'vault.error'
46
+
47
+ /** Los scopes del cert se corresponden 1:1 con las capacidades del acta (§D7). */
48
+ const SCOPE_TO_CAP = { 'vault:sign': 'sign', 'vault:store': 'store', 'vault:read': 'read' }
49
+ export const scopeToCaps = (scope) =>
50
+ (Array.isArray(scope) ? scope : [scope]).map((s) => SCOPE_TO_CAP[s]).filter(Boolean)
51
+
52
+ /** Token aleatorio de 128 bits en hex. */
53
+ export function randToken () {
54
+ const b = crypto.getRandomValues(new Uint8Array(16))
55
+ return [...b].map((x) => x.toString(16).padStart(2, '0')).join('')
56
+ }
57
+
58
+ /** deviceId legible (p. ej. `C440-AC0E`) a partir de una pubkey JWK. */
59
+ export async function deviceIdOf (pub) {
60
+ const id = (await pubkeyId(pub)).slice(0, 8).toUpperCase()
61
+ return id.slice(0, 4) + '-' + id.slice(4, 8)
62
+ }
63
+
64
+ /**
65
+ * Crea el «mostrador» de emparejamiento de una bóveda.
66
+ *
67
+ * @param {Object} opts
68
+ * @param {Object} opts.identity firma: `signData`, `signDelegation`, `listDelegations`, `revokeDelegation`.
69
+ * @param {string} opts.iss pubkey de la maestra de ESTA bóveda (va en el QR).
70
+ * @param {string} opts.proxy URL del proxy (va en el QR).
71
+ * @param {(to:string, obj:object)=>void} opts.send responder por el token de la conexión.
72
+ * @param {(pub:string, obj:object)=>void} opts.sendByPubkey dirigir por pubkey (cola offline 24 h).
73
+ * @param {(op:string, info?:object)=>void} [opts.audit]
74
+ * @param {(...a:any[])=>void} [opts.log]
75
+ * @param {(c:{deviceId:string, scope:any, label:string})=>void} [opts.onChallenge] un dispositivo espera aprobación.
76
+ * @param {()=>void} [opts.onPendingChange]
77
+ * @param {string[]} [opts.defaultScope]
78
+ * @param {number} [opts.defaultTtlMs]
79
+ */
80
+ export function createEnrollDesk ({
81
+ identity, iss, proxy, send, sendByPubkey,
82
+ audit = () => {}, log = () => {},
83
+ onChallenge = () => {}, onPendingChange = () => {},
84
+ defaultScope = ['vault:sign'], defaultTtlMs = DEVICE_TTL_MS
85
+ } = {}) {
86
+ if (!identity) throw new Error('createEnrollDesk: falta identity')
87
+ if (!iss) throw new Error('createEnrollDesk: falta iss (pubkey de la maestra)')
88
+
89
+ // token -> { token, exp, scope, ttlMs, label, sn, state, dpub?, deviceId?, commit?, from? }
90
+ // state: 'AWAITING_ENROLL' -> 'PENDING_CONFIRM'
91
+ const pending = new Map()
92
+
93
+ const fire = (fn, arg) => { try { fn(arg) } catch (_) {} }
94
+ const reply = (to, obj) => { try { send(to, obj) } catch (e) { log('[vault] no se pudo responder:', e.message) } }
95
+ const isFresh = (d) => typeof d?.ts === 'number' && Math.abs(Date.now() - d.ts) <= FRESH_WINDOW_MS
96
+
97
+ /** Inicia un emparejamiento: token + nonce de sesión. NO firma nada todavía. */
98
+ function startPairing ({ scope = defaultScope, ttlMs = defaultTtlMs, label = '' } = {}) {
99
+ pending.clear() // uno a la vez: una sesión nueva supersede a la anterior
100
+ const token = randToken()
101
+ const sn = randToken()
102
+ pending.set(token, { token, exp: Date.now() + PAIRING_TTL_MS, scope, ttlMs, label, sn, state: 'AWAITING_ENROLL' })
103
+ return { token, qr: { v: 2, iss, proxy, token, sn }, expiresInMs: PAIRING_TTL_MS }
104
+ }
105
+
106
+ function stopPairing (token) { pending.delete(token) }
107
+
108
+ function listPending () {
109
+ return [...pending.values()]
110
+ .filter((p) => p.state === 'PENDING_CONFIRM')
111
+ .map((p) => ({ deviceId: p.deviceId, label: p.label || '', scope: p.scope }))
112
+ }
113
+
114
+ function findPending (deviceId) {
115
+ for (const p of pending.values()) {
116
+ if (p.state === 'PENDING_CONFIRM' && p.deviceId === deviceId) return p
117
+ }
118
+ return null
119
+ }
120
+
121
+ /**
122
+ * ENROLL: el dispositivo prueba posesión de `D` firmando el sobre y deja el
123
+ * COMPROMISO de su código. Todavía NO se firma ningún cert.
124
+ */
125
+ async function handleEnroll (from, p) {
126
+ const d = p?.data
127
+ if (!d || typeof d.dpub !== 'string' || typeof p.signature !== 'string') {
128
+ return reply(from, { type: MSG_ERROR, error: 'enroll inválido' })
129
+ }
130
+ const pend = pending.get(d.token)
131
+ if (!pend || pend.state === 'DONE' || Date.now() > pend.exp) {
132
+ return reply(from, { type: MSG_ERROR, error: 'token de emparejamiento inválido o expirado' })
133
+ }
134
+ if (d.sn !== pend.sn) return reply(from, { type: MSG_ERROR, error: 'sesión inválida' })
135
+ if (!isFresh(d)) {
136
+ audit('rejected', { what: 'enroll', reason: 'stale' })
137
+ 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)' })
138
+ }
139
+ // PRUEBA DE POSESIÓN: la firma de `data` debe verificar contra `dpub`.
140
+ if (!(await verifyDeviceSig({ publickey: d.dpub, data: d, signature: p.signature }))) {
141
+ audit('rejected', { what: 'enroll', reason: 'bad-device-signature' })
142
+ return reply(from, { type: MSG_ERROR, error: 'firma de dispositivo inválida' })
143
+ }
144
+ // El COMPROMISO del código es obligatorio: sin él no se puede comprobar al aprobar
145
+ // y volveríamos a emitir certs a ciegas. Un cliente viejo cae acá con un mensaje claro.
146
+ if (typeof d.commit !== 'string' || !/^[0-9a-f]{64}$/.test(d.commit)) {
147
+ audit('rejected', { what: 'enroll', reason: 'no-commit' })
148
+ 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.' })
149
+ }
150
+ // Un solo dispositivo a la vez esperando su código (así aprobar no es ambiguo).
151
+ if (pend.state === 'PENDING_CONFIRM' && pend.dpub && pend.dpub !== d.dpub) {
152
+ return reply(from, { type: MSG_ERROR, error: 'ya hay un dispositivo usando este emparejamiento' })
153
+ }
154
+
155
+ const deviceId = await deviceIdOf(d.dpub)
156
+ pend.state = 'PENDING_CONFIRM'
157
+ pend.dpub = d.dpub
158
+ pend.deviceId = deviceId
159
+ pend.commit = d.commit
160
+ pend.from = from // la bóveda NO conoce el código: lo aprende cuando lo tipeas
161
+ if (d.label) pend.label = String(d.label).slice(0, 60)
162
+
163
+ reply(from, { type: MSG_ENROLL_CHALLENGE, deviceId })
164
+ fire(onChallenge, { deviceId, scope: pend.scope, label: pend.label || '' })
165
+ fire(onPendingChange)
166
+ return { deviceId }
167
+ }
168
+
169
+ /**
170
+ * Aprueba TIPEANDO el código que muestra el dispositivo. Recompone el compromiso
171
+ * `SHA-256(code‖dpub‖sn)` y solo firma el cert si coincide con el que llegó en el
172
+ * ENROLL — es decir, solo si de verdad fuiste a leer el código del dispositivo.
173
+ *
174
+ * @param {string} code
175
+ * @param {{deviceId?: string}} [opts] cuál aprobar cuando hay varios pendientes.
176
+ */
177
+ async function approve (code, { deviceId } = {}) {
178
+ code = String(code || '').trim()
179
+ if (!code) throw new Error('falta el código (los dígitos que muestra el dispositivo)')
180
+
181
+ let pend
182
+ if (deviceId) {
183
+ pend = findPending(deviceId)
184
+ if (!pend) throw new Error('no hay ninguna máquina esperando aprobación con ese identificador')
185
+ } else {
186
+ const waiting = [...pending.values()].filter((p) => p.state === 'PENDING_CONFIRM' && p.dpub)
187
+ if (waiting.length === 0) throw new Error('no hay ningún dispositivo esperando aprobación')
188
+ if (waiting.length > 1) throw new Error('hay más de un emparejamiento en curso; reinícialo con dotrino-vault pair')
189
+ pend = waiting[0]
190
+ }
191
+
192
+ // COMPROBACIÓN DEL CÓDIGO — antes de firmar nada.
193
+ const expected = await commitCode({ code, dpub: pend.dpub, sn: pend.sn })
194
+ if (expected !== pend.commit) {
195
+ audit('rejected', { what: 'approve', device: pend.deviceId, reason: 'bad-code' })
196
+ log('[vault] código incorrecto para %s: no se emitió ningún certificado', pend.deviceId)
197
+ 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.')
198
+ }
199
+
200
+ const { cert } = await identity.signDelegation(pend.dpub, pend.scope, { ttlMs: pend.ttlMs, label: pend.label })
201
+
202
+ // Aprobar un emparejamiento ES admitir al dispositivo en el perfil: el cert es la
203
+ // credencial y el acta es la política, y no tiene sentido emitir una sin la otra.
204
+ // Las capacidades salen del scope que se pidió al emparejar (cert ∩ acta, §2.3).
205
+ let acta = null
206
+ try {
207
+ if (typeof identity.admitMember === 'function') {
208
+ const caps = scopeToCaps(pend.scope)
209
+ if (caps.length) await identity.admitMember({ pub: pend.dpub, label: pend.label || '', caps, cert })
210
+ }
211
+ acta = (await identity.profileActa?.())?.acta || null
212
+ } catch (e) { log('[vault] no se pudo admitir en el acta:', e.message) }
213
+
214
+ audit('enroll', { device: pend.deviceId, label: pend.label || '', scope: pend.scope })
215
+ // Echamos el código tipeado junto al cert: el DISPOSITIVO acepta solo si coincide
216
+ // con el que generó → una bóveda falsa (que no lo conoce) no puede enrolarlo.
217
+ // El acta viaja con el cert: el dispositivo ya sabe de quién es el perfil al que entra.
218
+ reply(pend.from, { type: MSG_ENROLLED, code, cert, iss, acta })
219
+ pend.state = 'DONE'
220
+ pending.delete(pend.token)
221
+ fire(onPendingChange)
222
+ log('[vault] dispositivo aprobado: %s', pend.deviceId)
223
+ return { ok: true, deviceId: pend.deviceId, cert }
224
+ }
225
+
226
+ /** Rechaza un enrolamiento pendiente. */
227
+ function reject (deviceId) {
228
+ const pend = deviceId
229
+ ? findPending(deviceId)
230
+ : [...pending.values()].find((p) => p.state === 'PENDING_CONFIRM')
231
+ if (!pend) return { ok: false }
232
+ reply(pend.from, { type: MSG_ERROR, error: 'emparejamiento rechazado' })
233
+ pending.delete(pend.token)
234
+ audit('reject', { device: pend.deviceId })
235
+ fire(onPendingChange)
236
+ log('[vault] dispositivo rechazado: %s', pend.deviceId)
237
+ return { ok: true, deviceId: pend.deviceId }
238
+ }
239
+
240
+ /**
241
+ * Emite un REVOKED FIRMADO por la maestra para que el dispositivo se autoborre. El
242
+ * borrado remoto SOLO se dispara con esta firma (nunca con un error cualquiera →
243
+ * cierra el wipe-DoS). Va por `sendByPubkey`: si está apagado, el proxy lo encola 24 h.
244
+ */
245
+ async function emitRevoke (dpub, nonce) {
246
+ const body = { op: 'revoke', sub: dpub, nonce, iat: Date.now(), exp: Date.now() + DEVICE_TTL_MS }
247
+ const { signature } = await identity.signData(body)
248
+ try { sendByPubkey(dpub, { type: MSG_REVOKED, body, signature }) }
249
+ catch (e) { log('[vault] no se pudo emitir revoke:', e.message) }
250
+ }
251
+
252
+ /** Revoca una delegación por `nonce` y avisa al dispositivo para que se autoborre. */
253
+ async function revoke (nonce) {
254
+ audit('revoke', { nonce })
255
+ const { issued } = await identity.listDelegations()
256
+ const dele = (issued || []).find((d) => d.nonce === nonce)
257
+ const res = await identity.revokeDelegation(nonce)
258
+ if (dele?.sub) await emitRevoke(dele.sub, nonce)
259
+ return res
260
+ }
261
+
262
+ return {
263
+ startPairing, stopPairing, handleEnroll, approve, reject,
264
+ listPending, findPending, emitRevoke, revoke,
265
+ get pendingCount () { return pending.size }
266
+ }
267
+ }
268
+
269
+ export default { createEnrollDesk, deviceIdOf, randToken }
package/src/index.js CHANGED
@@ -17,37 +17,29 @@
17
17
  * → una bóveda falsa (que nunca vio el código) no puede enrolar el dispositivo, y
18
18
  * aprobar "a ciegas" (sin ir a leer el código del dispositivo) tampoco enrola nada.
19
19
  *
20
- * Cripto 100% de `@dotrino/identity/capabilities` (verifyDeviceSig/verifyChain/pubkeyId)
21
- * + firma con la identidad P (`identity.signDelegation`). Transporte: `@dotrino/proxy-client`
22
- * (import perezoso). No reimplementa nada del ecosistema.
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.
23
27
  */
24
- import { verifyDeviceSig, verifyChain, pubkeyId } from '@dotrino/identity/capabilities'
28
+ import { verifyChain } from '@dotrino/identity/capabilities'
29
+ import { createEnrollDesk, deviceIdOf, DEVICE_TTL_MS, FRESH_WINDOW_MS } from './enroll.js'
25
30
 
26
31
  const SIGN_SCOPE = 'vault:sign'
27
- const PAIRING_TTL_MS = 5 * 60 * 1000 // un emparejamiento (token) vale 5 min
28
- const DEVICE_TTL_MS = 30 * 24 * 60 * 60 * 1000 // vida de un cert de dispositivo (30 días)
29
32
  const SELFCERT_TTL_MS = 24 * 60 * 60 * 1000 // el self-cert P←P se regenera cada 24 h
30
- const FRESH_WINDOW_MS = 5 * 60 * 1000 // ventana anti-replay del enroll (±5 min)
31
33
 
32
34
  const MSG = {
33
35
  ENROLL: 'vault.enroll',
34
- ENROLL_CHALLENGE: 'vault.enroll.challenge',
35
- ENROLLED: 'vault.enrolled',
36
36
  DEVICES: 'vault.devices',
37
37
  DEVICES_RESULT: 'vault.devices.result',
38
- REVOKED: 'vault.revoked',
39
38
  ERROR: 'vault.error'
40
39
  }
41
40
 
42
- function randToken () {
43
- const b = crypto.getRandomValues(new Uint8Array(16))
44
- return [...b].map((x) => x.toString(16).padStart(2, '0')).join('')
45
- }
46
-
47
41
  /** deviceId legible (p. ej. `C440-AC0E`) desde una pubkey JWK. */
48
- export function deviceIdOf (pub) {
49
- return pubkeyId(pub).then((id) => id.slice(0, 8).toUpperCase().replace(/(.{4})(.{4})/, '$1-$2'))
50
- }
42
+ export { deviceIdOf }
51
43
 
52
44
  /**
53
45
  * Levanta la bóveda de este dispositivo: se conecta al proxy identificado como P y
@@ -94,49 +86,21 @@ export async function startDeviceVault (identity, { proxyUrl } = {}) {
94
86
 
95
87
  const send = (to, obj) => { try { client.send(to, obj) } catch (_) {} }
96
88
 
97
- // token -> { exp, sn, scope, ttlMs, label, state, dpub?, deviceId?, from? }
98
- const pending = new Map()
99
89
  let _onPendingChange = () => {}
100
90
 
101
- async function handleEnroll (from, p) {
102
- const d = p?.data
103
- if (!d || typeof d.dpub !== 'string' || typeof p.signature !== 'string') {
104
- return send(from, { type: MSG.ERROR, error: 'enroll inválido' })
105
- }
106
- const pend = pending.get(d.token)
107
- if (!pend || Date.now() > pend.exp) {
108
- return send(from, { type: MSG.ERROR, error: 'token de emparejamiento inválido o expirado' })
109
- }
110
- if (d.sn !== pend.sn) return send(from, { type: MSG.ERROR, error: 'sesión inválida' })
111
- if (typeof d.ts !== 'number' || Math.abs(Date.now() - d.ts) > FRESH_WINDOW_MS) {
112
- return send(from, { type: MSG.ERROR, error: 'enroll vencido (posible replay, o el reloj desfasado)' })
113
- }
114
- // PRUEBA DE POSESIÓN: la firma de `data` debe verificar contra `dpub`.
115
- const ok = await verifyDeviceSig({ publickey: d.dpub, data: d, signature: p.signature })
116
- if (!ok) return send(from, { type: MSG.ERROR, error: 'firma de dispositivo inválida' })
117
- // Un solo dispositivo a la vez esperando su código (así `approve` no es ambiguo).
118
- if (pend.state === 'PENDING_CONFIRM' && pend.dpub && pend.dpub !== d.dpub) {
119
- return send(from, { type: MSG.ERROR, error: 'ya hay un dispositivo usando este emparejamiento' })
120
- }
121
- const deviceId = await deviceIdOf(d.dpub)
122
- pend.state = 'PENDING_CONFIRM'
123
- pend.dpub = d.dpub
124
- pend.deviceId = deviceId
125
- pend.from = from // esta bóveda NO conoce el código (no viaja): el dispositivo lo MUESTRA
126
- if (d.label) pend.label = String(d.label).slice(0, 60)
127
- _onPendingChange()
128
- send(from, { type: MSG.ENROLL_CHALLENGE, deviceId })
129
- }
130
-
131
- // Emite un REVOKED FIRMADO por la maestra a la máquina revocada para que se
132
- // auto-borre. Va por `sendByPubkey` → si está offline, el proxy lo encola 24 h; y
133
- // si reaparece más tarde, `handleDevices` lo re-emite en su siguiente consulta.
134
- // El auto-borrado remoto SOLO se dispara con esta firma (no con un error cualquiera).
135
- async function emitRevoke (dpub, nonce) {
136
- const body = { op: 'revoke', sub: dpub, nonce, iat: Date.now(), exp: Date.now() + DEVICE_TTL_MS }
137
- const { signature } = await identity.signData(body)
138
- try { client.sendByPubkey(dpub, { type: MSG.REVOKED, body, signature }) } catch (_) {}
139
- }
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
+ })
140
104
 
141
105
  // Consulta de revocaciones (igual que `vault.devices` del daemon): responde la lista
142
106
  // de dispositivos enrolados + revocados para que el dispositivo refresque su set. Y si
@@ -155,62 +119,15 @@ export async function startDeviceVault (identity, { proxyUrl } = {}) {
155
119
  send(from, { type: MSG.DEVICES_RESULT, devices, revoked: (revoked || []).map((r) => r.nonce || r) })
156
120
  // ¿el que consulta es una máquina revocada que reapareció? → re-emite el REVOKED firmado.
157
121
  const mine = (issued || []).find((x) => x.sub === chk.device && x.revokedAt)
158
- if (mine) emitRevoke(chk.device, mine.nonce)
122
+ if (mine) desk.emitRevoke(chk.device, mine.nonce)
159
123
  }
160
124
 
161
125
  client.on('message', (_from, p) => {
162
126
  if (!p || typeof p !== 'object') return
163
- if (p.type === MSG.ENROLL) handleEnroll(_from, p).catch(() => {})
127
+ if (p.type === MSG.ENROLL) desk.handleEnroll(_from, p).catch(() => {})
164
128
  else if (p.type === MSG.DEVICES) handleDevices(_from, p).catch(() => {})
165
129
  })
166
130
 
167
- /**
168
- * Abre un emparejamiento: devuelve el QR/JSON v2 que el dispositivo consume para
169
- * enrolarse. `scope`/`ttlMs`/`label` fijan lo que otorgará el cert al aprobar.
170
- */
171
- function startPairing ({ scope = [SIGN_SCOPE], ttlMs = DEVICE_TTL_MS, label = '' } = {}) {
172
- pending.clear()
173
- const token = randToken()
174
- const sn = randToken()
175
- pending.set(token, { token, exp: Date.now() + PAIRING_TTL_MS, sn, scope, ttlMs, label, state: 'AWAITING_ENROLL' })
176
- return { qr: { v: 2, iss, proxy, token, sn }, expiresInMs: PAIRING_TTL_MS }
177
- }
178
-
179
- function listPending () {
180
- return [...pending.values()]
181
- .filter((p) => p.state === 'PENDING_CONFIRM')
182
- .map((p) => ({ deviceId: p.deviceId, label: p.label }))
183
- }
184
- function findPending (deviceId) {
185
- for (const [, p] of pending) if (p.state === 'PENDING_CONFIRM' && p.deviceId === deviceId) return p
186
- return null
187
- }
188
-
189
- /**
190
- * Aprueba una máquina pendiente TIPEANDO el código que ella muestra. Esta bóveda NO
191
- * conoce/valida el código: firma el cert y ECHA el código tipeado; la máquina lo acepta
192
- * solo si coincide con el que generó. (Modelo `dotrino-vault#approveDevice`.)
193
- */
194
- async function approve (deviceId, code) {
195
- const pend = findPending(deviceId)
196
- if (!pend || !pend.dpub) throw new Error('no hay ninguna máquina esperando aprobación')
197
- code = String(code || '').trim()
198
- if (!code) throw new Error('escribe el código que muestra la máquina')
199
- const { cert } = await identity.signDelegation(pend.dpub, pend.scope, { ttlMs: pend.ttlMs, label: pend.label })
200
- send(pend.from, { type: MSG.ENROLLED, code, cert, iss })
201
- pending.delete(pend.token)
202
- _onPendingChange()
203
- return { ok: true, deviceId }
204
- }
205
-
206
- function reject (deviceId) {
207
- const pend = findPending(deviceId)
208
- if (!pend) return
209
- send(pend.from, { type: MSG.ERROR, error: 'emparejamiento rechazado' })
210
- pending.delete(pend.token)
211
- _onPendingChange()
212
- }
213
-
214
131
  /**
215
132
  * Máquinas enroladas bajo esta identidad (P), vigentes, con scope de firma y label
216
133
  * propio (excluye navegadores enrolados con label 'cli', que no atienden peticiones).
@@ -228,20 +145,18 @@ export async function startDeviceVault (identity, { proxyUrl } = {}) {
228
145
  return Promise.all([...bySub.values()].map(async (x) => ({ ...x, deviceId: await deviceIdOf(x.sub) })))
229
146
  }
230
147
 
231
- async function revoke (nonce) {
232
- // Deja el registro persistente (revokedAt en la delegación) y AVISA a la máquina
233
- // con un REVOKED firmado para que se auto-borre (ahora si está online, o al
234
- // reaparecer vía handleDevices). Ver emitRevoke.
235
- const { issued } = await identity.listDelegations()
236
- const dele = (issued || []).find((d) => d.nonce === nonce)
237
- const res = await identity.revokeDelegation(nonce)
238
- if (dele?.sub) await emitRevoke(dele.sub, nonce)
239
- return res
240
- }
241
-
242
148
  return {
243
149
  iss, proxy, client,
244
- startPairing, approve, reject, listPending, listMachines, revoke,
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),
245
160
  getSelfCert,
246
161
  onPendingChange (fn) { _onPendingChange = fn || (() => {}) },
247
162
  close () { try { client.close() } catch (_) {} }
package/src/service.js CHANGED
@@ -22,7 +22,7 @@ import fs from 'node:fs'
22
22
  import path from 'node:path'
23
23
  import {
24
24
  makeDeviceKey, signWithDevice, verifyDelegation, verifyDeviceSig,
25
- makePairingCode, pubkeyId
25
+ makePairingCode, commitCode, pubkeyId
26
26
  } from '@dotrino/identity/capabilities'
27
27
  import { MSG, secretsScope, isValidSecretsNs } from './protocol.js'
28
28
  import { makeEphemeralKey, openSealed } from './sealed.js'
@@ -144,7 +144,10 @@ export async function enrollService ({ qr, ns, dir, label, onCode, approveTimeou
144
144
  // Código ALEATORIO generado AQUÍ: el vault no lo conoce; solo puede echarlo
145
145
  // de vuelta si el dueño lo tipeó (= tiene esta pantalla a la vista).
146
146
  const code = makePairingCode()
147
- const data = { op: 'enroll', dpub: device.publickey, token: qr.token, sn: qr.sn, label, ts: Date.now() }
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() }
148
151
  const { signature } = await signWithDevice({ privateJwk: device.privateJwk, data })
149
152
 
150
153
  const enrolled = new Promise((resolve, reject) => {