@dotrino/identity 0.22.0 → 0.23.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/identity",
3
- "version": "0.22.0",
3
+ "version": "0.23.0",
4
4
  "description": "Identidad y rating de usuarios compartidos entre apps de Dotrino (vault iframe + postMessage)",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
package/src/index.d.ts CHANGED
@@ -173,6 +173,7 @@ export class Identity {
173
173
  selfVaultApprove (deviceId: string, code: string): Promise<any>
174
174
  selfVaultReject (deviceId: string): Promise<{ ok: true }>
175
175
  selfVaultRevoke (nonce: string): Promise<any>
176
+ selfVaultProbe (pubkeys: string[]): Promise<{ online: string[] }>
176
177
  onSelfVault (handler: (payload: any) => void): () => void
177
178
  on (event: 'peer_updated' | 'me_updated' | 'sync' | 'vault' | 'selfVault', handler: (payload: any) => void): () => void
178
179
  }
package/src/index.js CHANGED
@@ -319,11 +319,11 @@ export class Identity {
319
319
  }
320
320
 
321
321
  // ----- Self-vault: ESTE dispositivo actúa como su propia bóveda/CA -----
322
- // El daemon device-vault vive dentro del iframe (no requiere el binario del PC ni
323
- // vault.dotrino.com/pair). Cualquier app puede activarlo, generar códigos de
324
- // emparejamiento, aprobar SAS y revocar máquinas — todo por RPC al iframe.
325
- // El daemon sólo corre en una pestaña visible a la vez (navigator.locks), pero los
326
- // getters (status/pending/machines) y revoke sirven desde cualquier pestaña.
322
+ // El daemon device-vault vive dentro del iframe (no requiere el binario del PC).
323
+ // Se gestiona desde profile.dotrino.com/#myvault. Cualquier app puede activarlo,
324
+ // generar códigos de emparejamiento, aprobar SAS y revocar máquinas — todo por RPC
325
+ // al iframe. El daemon sólo corre en una pestaña visible a la vez (navigator.locks),
326
+ // pero los getters (status/pending/machines) y revoke sirven desde cualquier pestaña.
327
327
  /** { enabled, running }: si el modo self está activado y si esta pestaña sostiene el daemon. */
328
328
  async selfVaultStatus () { return this._call('selfVaultStatus') }
329
329
  /** Activa/desactiva el modo self-vault en este dispositivo. */
@@ -340,6 +340,8 @@ export class Identity {
340
340
  async selfVaultReject (deviceId) { return this._call('selfVaultReject', { deviceId }) }
341
341
  /** Revoca una máquina/agente enrolado por nonce de delegación. */
342
342
  async selfVaultRevoke (nonce) { return this._call('selfVaultRevoke', { nonce }) }
343
+ /** Presencia online (ping/pong) de las máquinas enroladas. Devuelve { online: [pubkeys] }. */
344
+ async selfVaultProbe (pubkeys) { return this._call('selfVaultProbe', { pubkeys }, 10000) }
343
345
  /** Suscribe a eventos del self-vault ('selfVault'): { running?, pending?, error? }. */
344
346
  onSelfVault (handler) { return this.on('selfVault', handler) }
345
347
 
package/vault/core.js CHANGED
@@ -249,23 +249,69 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
249
249
  // ----- delegaciones de capacidad emitidas + revocaciones (kv-backed) -----
250
250
 
251
251
  function loadJson (key) { try { return JSON.parse(kv.getItem(key) || '{}') || {} } catch (_) { return {} } }
252
- const loadDelegations = () => loadJson(DELEGATIONS_STORAGE)
252
+
253
+ // PODA (los dos registros crecían para siempre): la renovación automática firma un cert
254
+ // nuevo cada 30 días, así que sin podar cada dispositivo dejaba 12 entradas muertas al año.
255
+ // Se tira lo que YA NO PUEDE SERVIR, nunca lo vivo:
256
+ // · delegación → cuando su `exp` ya pasó (un cert vencido no autoriza nada).
257
+ // OJO: no se poda «la anterior del mismo dispositivo» al renovar, porque el cert
258
+ // viejo SIGUE VIGENTE hasta su exp y hay que poder revocarlo si te roban el aparato.
259
+ // · revocación → 30 días después de revocar: para entonces el cert al que apunta está
260
+ // vencido seguro (el tope duro de vida es `MAX_DELEGATION_MS`, y exp ≤ iat + 30 días
261
+ // ≤ revokedAt + 30 días), y un cert vencido ya falla por `expired` sin mirar la lista.
262
+ const DELEGATION_MAX_LIFE_MS = 30 * 24 * 60 * 60 * 1000 // espejo de MAX_DELEGATION_MS (capabilities.js)
263
+
264
+ function loadDelegations () {
265
+ const o = loadJson(DELEGATIONS_STORAGE)
266
+ const now = Date.now()
267
+ let changed = false
268
+ for (const k of Object.keys(o)) {
269
+ const exp = o[k]?.exp
270
+ if (typeof exp === 'number' && exp < now) { delete o[k]; changed = true }
271
+ }
272
+ if (changed) kv.setItem(DELEGATIONS_STORAGE, JSON.stringify(o))
273
+ return o
274
+ }
253
275
  const saveDelegations = (o) => kv.setItem(DELEGATIONS_STORAGE, JSON.stringify(o))
254
- const loadRevocations = () => loadJson(REVOCATIONS_STORAGE)
276
+
277
+ function loadRevocations () {
278
+ const o = loadJson(REVOCATIONS_STORAGE)
279
+ const now = Date.now()
280
+ let changed = false
281
+ for (const k of Object.keys(o)) {
282
+ const at = o[k]
283
+ if (typeof at === 'number' && now - at > DELEGATION_MAX_LIFE_MS) { delete o[k]; changed = true }
284
+ }
285
+ if (changed) kv.setItem(REVOCATIONS_STORAGE, JSON.stringify(o))
286
+ return o
287
+ }
255
288
  const saveRevocations = (o) => kv.setItem(REVOCATIONS_STORAGE, JSON.stringify(o))
256
289
 
257
290
  // ----- emparejamiento con el vault del usuario (este dispositivo enrolado) -----
258
- // Canal de eventos 'vault' (p.ej. el SAS a comparar durante el emparejamiento).
291
+ // Canal de eventos 'vault' (p.ej. el código a tipear durante el emparejamiento).
259
292
  const vaultListeners = new Set()
260
293
  const emitVault = (p) => { for (const fn of vaultListeners) { try { fn(p) } catch (_) {} } }
261
- // Si el vault RECHAZA por cert revocado, este dispositivo perdió el acceso: limpiamos el
262
- // cert local (ya no sirve) y emitimos 'revoked' → @dotrino/store borra SOLO el store de
263
- // ESTE perfil (los demás perfiles quedan intactos). Cualquier otro error se propaga igual.
294
+
295
+ /**
296
+ * BORRADO por revocación. Solo lo dispara un `vault.revoked` FIRMADO por la maestra
297
+ * pineada (lo verifica `remote.js` antes de llamar aquí). Emite 'revoked' →
298
+ * `@dotrino/store` borra el store de ESTE perfil (los demás quedan intactos).
299
+ */
300
+ const wipeVaultLink = () => {
301
+ try { kv.removeItem(VAULT_CERT_STORAGE); kv.removeItem(VAULT_DEVICE_STORAGE) } catch (_) {}
302
+ emitVault({ phase: 'revoked' })
303
+ }
304
+
305
+ /**
306
+ * El vault RECHAZÓ una petición diciendo «revocado». Ese mensaje NO va firmado: lo
307
+ * puede falsificar cualquiera que conozca la pubkey de este dispositivo, así que
308
+ * **jamás borra nada** (sería un wipe-DoS: destruir datos ajenos con un mensaje suelto,
309
+ * prohibido por `dotrino-vault/docs/pairing-protocol.md §2.3`). Lo único que hacemos es
310
+ * DEGRADAR: avisar a la app de que la bóveda nos está rechazando, y que sea el usuario
311
+ * quien decida. El borrado real llega por `vault.revoked` firmado (ver `wipeVaultLink`).
312
+ */
264
313
  const handleVaultError = (e) => {
265
- if (e && /\brevoked\b/.test(e.message || '')) {
266
- try { kv.removeItem(VAULT_CERT_STORAGE); kv.removeItem(VAULT_DEVICE_STORAGE) } catch (_) {}
267
- emitVault({ phase: 'revoked' })
268
- }
314
+ if (e && /\brevoked\b/.test(e.message || '')) emitVault({ phase: 'rejected', reason: e.message })
269
315
  throw e
270
316
  }
271
317
  const loadVaultCert = () => { try { return JSON.parse(kv.getItem(VAULT_CERT_STORAGE) || 'null') } catch (_) { return null } }
@@ -305,7 +351,7 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
305
351
  if (v.cert.exp <= now || v.cert.exp - now > RENEW_WINDOW_MS) return
306
352
  if (now - renewLastTry < RENEW_RETRY_MS) return
307
353
  renewLastTry = now
308
- remoteRenew({ master: v.master, proxy: v.proxy, device, cert: v.cert }).then(({ cert }) => {
354
+ remoteRenew({ master: v.master, proxy: v.proxy, device, cert: v.cert, onRevoked: wipeVaultLink }).then(({ cert }) => {
309
355
  kv.setItem(VAULT_CERT_STORAGE, JSON.stringify({ ...v, cert, renewedAt: Date.now() }))
310
356
  emitVault({ phase: 'renewed', exp: cert.exp })
311
357
  }).catch(() => {}) // best-effort: el cert vigente sigue sirviendo mientras tanto
@@ -870,7 +916,7 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
870
916
  const v = loadVaultCert(); const device = loadVaultDevice()
871
917
  if (!v?.cert || !device) throw new Error('este dispositivo no está emparejado con un vault')
872
918
  maybeRenewVaultCert()
873
- try { return await remoteSign({ master: v.master, proxy: v.proxy, device, cert: v.cert, payload }) }
919
+ try { return await remoteSign({ master: v.master, proxy: v.proxy, device, cert: v.cert, payload, onRevoked: wipeVaultLink }) }
874
920
  catch (e) { return handleVaultError(e) }
875
921
  },
876
922
 
@@ -880,7 +926,7 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
880
926
  const v = loadVaultCert(); const device = loadVaultDevice()
881
927
  if (!v?.cert || !device) throw new Error('este dispositivo no está emparejado con un vault')
882
928
  maybeRenewVaultCert()
883
- try { return await remoteStore({ master: v.master, proxy: v.proxy, device, cert: v.cert, method, args }) }
929
+ try { return await remoteStore({ master: v.master, proxy: v.proxy, device, cert: v.cert, method, args, onRevoked: wipeVaultLink }) }
884
930
  catch (e) { return handleVaultError(e) }
885
931
  },
886
932
 
@@ -889,7 +935,7 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
889
935
  const v = loadVaultCert(); const device = loadVaultDevice()
890
936
  if (!v?.cert || !device) throw new Error('este dispositivo no está emparejado con un vault')
891
937
  maybeRenewVaultCert()
892
- try { return await remoteDevices({ master: v.master, proxy: v.proxy, device, cert: v.cert }) }
938
+ try { return await remoteDevices({ master: v.master, proxy: v.proxy, device, cert: v.cert, onRevoked: wipeVaultLink }) }
893
939
  catch (e) { return handleVaultError(e) }
894
940
  },
895
941
 
package/vault/remote.js CHANGED
@@ -5,22 +5,49 @@
5
5
  * —cuya privada NUNCA sale de la identidad—, hace el emparejamiento ENDURECIDO por el
6
6
  * proxy (ver dotrino-vault/docs/pairing-protocol.md) y devuelve el cert ya validado.
7
7
  *
8
- * Flujo: firma el ENROLL con D (prueba de posesión) recibe el reto y computa SU
9
- * propio SAS (que el usuario compara con el del PC) al ser aprobado en el PC, recibe
10
- * el cert y lo valida (firmado por la maestra que vio en el QR, y para SU clave).
8
+ * Flujo: genera un código de 6 dígitos, lo MUESTRA y manda solo su COMPROMISO dentro del
9
+ * ENROLL firmado con D (prueba de posesión) el dueño tipea el código en la bóveda, que
10
+ * lo comprueba contra el compromiso y solo entonces firma el dispositivo acepta el cert
11
+ * si le ECHAN su código y lo valida (firmado por la maestra que vio en el QR, y para SU clave).
11
12
  *
12
13
  * No reimplementa cripto: usa `@dotrino/identity/capabilities`. Transporte:
13
14
  * `@dotrino/proxy-client` (importado perezosamente; solo se carga al emparejar).
14
15
  */
15
- import { makeDeviceKey, signWithDevice, verifyDelegation, makePairingCode, pubkeyId } from './capabilities.js'
16
+ import { makeDeviceKey, signWithDevice, verifyDelegation, verifyDeviceSig, makePairingCode, commitCode, pubkeyId } from './capabilities.js'
16
17
 
17
18
  const MSG = {
18
19
  ENROLL: 'vault.enroll',
19
20
  ENROLL_CHALLENGE: 'vault.enroll.challenge',
20
21
  ENROLLED: 'vault.enrolled',
22
+ REVOKED: 'vault.revoked',
21
23
  ERROR: 'vault.error'
22
24
  }
23
25
 
26
+ /**
27
+ * ¿Es AUTÉNTICO este `vault.revoked`? Solo lo es si va firmado por la maestra PINEADA al
28
+ * emparejar, es para ESTE dispositivo y no ha caducado. Es la única puerta al autoborrado:
29
+ * un `vault.error` con la palabra «revocado» no borra nada (cierra el wipe-DoS, ver
30
+ * `dotrino-vault/docs/pairing-protocol.md §2.3`).
31
+ */
32
+ export async function isAuthenticRevoke ({ body, signature, master, devicePubkey }) {
33
+ if (!body || body.op !== 'revoke' || typeof signature !== 'string') return false
34
+ if (body.sub !== devicePubkey) return false
35
+ if (typeof body.exp === 'number' && Date.now() > body.exp) return false
36
+ return verifyDeviceSig({ publickey: master, data: body, signature })
37
+ }
38
+
39
+ /**
40
+ * Identifica la conexión bajo la pubkey de este dispositivo. Además de hacerlo
41
+ * direccionable, es lo que hace que el proxy le entregue lo que tenía ENCOLADO (24 h) —
42
+ * entre otras cosas, un `vault.revoked` emitido mientras estaba apagado.
43
+ */
44
+ async function identifyAsDevice (client, device) {
45
+ if (!client.token) return
46
+ const data = { op: 'identify', publickey: device.publickey, token: client.token, ts: Date.now() }
47
+ const { signature } = await signWithDevice({ privateJwk: device.privateJwk, privateKey: device.privateKey, publickey: device.publickey, data })
48
+ await client.identify({ data, signature })
49
+ }
50
+
24
51
  /**
25
52
  * @param {Object} opts
26
53
  * @param {{v:number, iss:string, proxy:string, token:string, sn:string}} opts.qr QR v2 del vault.
@@ -43,9 +70,12 @@ export async function enrollDevice ({ qr, device, onChallenge, label = '', appro
43
70
  // El DISPOSITIVO genera el código y manda solo su COMPROMISO (no el código). El vault
44
71
  // aprende el código únicamente cuando vos lo tipeás en el PC → aprobar exige tener el dispositivo.
45
72
  const code = makePairingCode()
46
- // NO se manda el código ni un compromiso: el vault lo aprende SOLO cuando lo tipeás, y al
47
- // ECHARLO de vuelta el dispositivo confía. Un vault falso no conoce el códigono empareja.
48
- const data = { op: 'enroll', dpub: dev.publickey, token: qr.token, sn: qr.sn, label, ts: Date.now() }
73
+ // Se manda el COMPROMISO del código, nunca el código. El vault lo aprende solo cuando lo
74
+ // tipeas, recompone el compromiso y únicamente entonces firma el certaprobar exige
75
+ // haber leído el código de ESTA pantalla. Y al ECHARLO de vuelta, el dispositivo confía:
76
+ // una bóveda falsa no conoce el código y no puede enrolarlo.
77
+ const commit = await commitCode({ code, dpub: dev.publickey, sn: qr.sn })
78
+ const data = { op: 'enroll', dpub: dev.publickey, token: qr.token, sn: qr.sn, commit, label, ts: Date.now() }
49
79
  const { signature } = await signWithDevice({ privateJwk: dev.privateJwk, privateKey: dev.privateKey, publickey: dev.publickey, data })
50
80
 
51
81
  const enrolled = new Promise((resolve, reject) => {
@@ -78,41 +108,39 @@ export async function enrollDevice ({ qr, device, onChallenge, label = '', appro
78
108
  * firma. Requiere que el vault esté online.
79
109
  * @returns {Promise<{ signature:string, publickey:string }>} publickey = la maestra.
80
110
  */
81
- export async function requestSign ({ master, proxy, device, cert, payload, timeoutMs = 15000 } = {}) {
82
- if (!master || !proxy || !(device?.privateJwk || device?.privateKey) || !cert) throw new Error('faltan datos de emparejamiento')
83
- const { WebSocketProxyClient } = await import('@dotrino/proxy-client')
84
- const client = new WebSocketProxyClient({ url: proxy, enableWebRTC: false, autoReconnect: false })
85
- await client.connect()
86
- try {
87
- const data = { op: 'sign', payload, publickey: device.publickey, ts: Date.now() }
88
- const { signature } = await signWithDevice({ privateJwk: device.privateJwk, privateKey: device.privateKey, publickey: device.publickey, data })
89
- const pending = new Promise((resolve, reject) => {
90
- const off = client.on('message', (_f, p) => {
91
- if (!p || typeof p !== 'object') return
92
- if (p.type === 'vault.signed') { cleanup(); resolve(p) }
93
- else if (p.type === 'vault.error') { cleanup(); reject(new Error(p.error)) }
94
- })
95
- const t = setTimeout(() => { cleanup(); reject(new Error('el vault no respondió (¿está encendido?)')) }, timeoutMs)
96
- const cleanup = () => { off(); clearTimeout(t) }
97
- })
98
- client.sendByPubkey(master, { type: 'vault.sign', data, signature, cert })
99
- const res = await pending
100
- return { signature: res.signature, publickey: res.publickey }
101
- } finally { try { client.close() } catch (_) {} }
111
+ export async function requestSign ({ master, proxy, device, cert, payload, onRevoked, timeoutMs = 15000 } = {}) {
112
+ const res = await vaultRpc({
113
+ master, proxy, device, cert, onRevoked, timeoutMs,
114
+ sendType: 'vault.sign', okType: 'vault.signed', data: { op: 'sign', payload }
115
+ })
116
+ return { signature: res.signature, publickey: res.publickey }
102
117
  }
103
118
 
104
- /** Helper genérico: una RPC al vault firmada por D + cert, esperando `okType`. */
105
- async function vaultRpc ({ master, proxy, device, cert, sendType, okType, data, timeoutMs = 15000 }) {
119
+ /**
120
+ * Helper genérico: una RPC al vault firmada por D + cert, esperando `okType`.
121
+ *
122
+ * Se identifica al conectar para que el proxy entregue lo ENCOLADO: si mientras el
123
+ * dispositivo estaba apagado la bóveda emitió un `vault.revoked` firmado, llega aquí y se
124
+ * ejecuta el autoborrado (`onRevoked`) tras verificar la firma contra la maestra pineada.
125
+ */
126
+ async function vaultRpc ({ master, proxy, device, cert, sendType, okType, data, onRevoked, timeoutMs = 15000 }) {
106
127
  if (!master || !proxy || !(device?.privateJwk || device?.privateKey) || !cert) throw new Error('faltan datos de emparejamiento')
107
128
  const { WebSocketProxyClient } = await import('@dotrino/proxy-client')
108
129
  const client = new WebSocketProxyClient({ url: proxy, enableWebRTC: false, autoReconnect: false })
109
130
  await client.connect()
110
131
  try {
132
+ try { await identifyAsDevice(client, device) } catch (_) { /* sin identify seguimos: solo perdemos la cola */ }
111
133
  const signed = { ...data, publickey: device.publickey, ts: Date.now() }
112
134
  const { signature } = await signWithDevice({ privateJwk: device.privateJwk, privateKey: device.privateKey, publickey: device.publickey, data: signed })
113
135
  const pending = new Promise((resolve, reject) => {
114
136
  const off = client.on('message', (_f, p) => {
115
137
  if (!p || typeof p !== 'object') return
138
+ if (p.type === MSG.REVOKED) {
139
+ isAuthenticRevoke({ body: p.body, signature: p.signature, master, devicePubkey: device.publickey })
140
+ .then((ok) => { if (ok) { try { onRevoked?.() } catch (_) {} } })
141
+ .catch(() => {})
142
+ return
143
+ }
116
144
  if (p.type === okType) { cleanup(); resolve(p) }
117
145
  else if (p.type === 'vault.error') { cleanup(); reject(new Error(p.error)) }
118
146
  })
@@ -125,14 +153,14 @@ async function vaultRpc ({ master, proxy, device, cert, sendType, okType, data,
125
153
  }
126
154
 
127
155
  /** Lee/escribe el store de hilos+aperturas EN el vault (con el cert del dispositivo). */
128
- export async function requestStore ({ master, proxy, device, cert, method, args } = {}) {
129
- const res = await vaultRpc({ master, proxy, device, cert, sendType: 'vault.store', okType: 'vault.store.result', data: { op: 'store', method, args: args || {} } })
156
+ export async function requestStore ({ master, proxy, device, cert, method, args, onRevoked } = {}) {
157
+ const res = await vaultRpc({ master, proxy, device, cert, onRevoked, sendType: 'vault.store', okType: 'vault.store.result', data: { op: 'store', method, args: args || {} } })
130
158
  return res.result
131
159
  }
132
160
 
133
161
  /** Lista (solo lectura) los dispositivos enrolados en tu vault. */
134
- export async function requestDevices ({ master, proxy, device, cert } = {}) {
135
- const res = await vaultRpc({ master, proxy, device, cert, sendType: 'vault.devices', okType: 'vault.devices.result', data: { op: 'devices' } })
162
+ export async function requestDevices ({ master, proxy, device, cert, onRevoked } = {}) {
163
+ const res = await vaultRpc({ master, proxy, device, cert, onRevoked, sendType: 'vault.devices', okType: 'vault.devices.result', data: { op: 'devices' } })
136
164
  return { devices: res.devices || [], revoked: res.revoked || [] }
137
165
  }
138
166
 
@@ -141,8 +169,8 @@ export async function requestDevices ({ master, proxy, device, cert } = {}) {
141
169
  * el vault firma uno fresco para la misma sub-clave y scope, sin QR ni aprobación.
142
170
  * @returns {Promise<{ cert: object }>}
143
171
  */
144
- export async function requestRenew ({ master, proxy, device, cert } = {}) {
145
- const res = await vaultRpc({ master, proxy, device, cert, sendType: 'vault.renew', okType: 'vault.renewed', data: { op: 'renew' } })
172
+ export async function requestRenew ({ master, proxy, device, cert, onRevoked } = {}) {
173
+ const res = await vaultRpc({ master, proxy, device, cert, onRevoked, sendType: 'vault.renew', okType: 'vault.renewed', data: { op: 'renew' } })
146
174
  if (!res.cert || res.cert.sub !== device.publickey || res.cert.iss !== master) throw new Error('cert renovado inválido')
147
175
  return { cert: res.cert }
148
176
  }
package/vault/vault.js CHANGED
@@ -154,6 +154,34 @@ import { pubkeyId } from './capabilities.js'
154
154
  else releaseSelfLock()
155
155
  })
156
156
 
157
+ // Sonda de presencia (ping/pong por el proxy del daemon). Mandamos AMBOS tipos
158
+ // (ra.ping para agentes @dotrino/remote-agent —ia—; terminal.ping para terminal
159
+ // pre-migración) y consideramos online si responde cualquiera. Reusa el cliente
160
+ // del proxy del daemon activo en ESTE iframe; si no hay daemon, devuelve vacío.
161
+ function probeOnline (pubkeys, { timeoutMs = 4000 } = {}) {
162
+ return new Promise((resolve) => {
163
+ const online = new Set()
164
+ const client = daemon?.client
165
+ if (!client?.sendByPubkey || !pubkeys.length) return resolve(online)
166
+ let rest = pubkeys.length
167
+ const byNonce = new Map()
168
+ const off = client.on('message', (_f, p) => {
169
+ if (!p || typeof p !== 'object') return
170
+ if (p.type === 'ra.pong' || p.type === 'terminal.pong') {
171
+ const pk = byNonce.get(p.n)
172
+ if (pk) { online.add(pk); byNonce.delete(p.n); settle() }
173
+ }
174
+ })
175
+ function settle () { if (--rest <= 0) { off(); resolve(online) } }
176
+ for (const pk of pubkeys) {
177
+ const n = pk.slice(0, 6) + Math.random().toString(36).slice(2, 8)
178
+ byNonce.set(n, pk)
179
+ try { client.sendByPubkey(pk, { type: 'ra.ping', n }); client.sendByPubkey(pk, { type: 'terminal.ping', n }) } catch {}
180
+ setTimeout(settle, timeoutMs)
181
+ }
182
+ })
183
+ }
184
+
157
185
  // Handlers de UI (emparejamiento/gestión) expuestos por postMessage. Las ACCIONES
158
186
  // (pairing/approve) requieren que ESTE iframe sea el daemon activo (la pestaña visible);
159
187
  // la lectura (máquinas/pending) siempre funciona (lee delegaciones persistidas).
@@ -205,7 +233,10 @@ import { pubkeyId } from './capabilities.js'
205
233
  selfVaultRevoke: async ({ nonce }) => {
206
234
  if (daemon) return daemon.revoke(nonce)
207
235
  return handlers.revokeDelegation({ nonce })
208
- }
236
+ },
237
+ // Presencia online (ping/pong) de las máquinas enroladas. Requiere que ESTE
238
+ // iframe sea el daemon activo (tiene el cliente del proxy); si no, devuelve [].
239
+ selfVaultProbe: async ({ pubkeys }) => ({ online: [...(await probeOnline(pubkeys || []))] })
209
240
  }
210
241
 
211
242
  window.addEventListener('message', async (event) => {
@@ -1,5 +1,6 @@
1
- Copia vendorizada de @dotrino/vault@0.3.0 (lib/src/index.js, sin dependencias).
2
- El iframe de identity se sirve estático (vanilla, sin build); así startDeviceVault
3
- resuelve en el navegador sin bundler. Importa @dotrino/identity/capabilities
4
- (=../../capabilities.js) y @dotrino/proxy-client (=../proxy-client/), ambos vía
5
- el import map de index.html. Re-vendorizar al subir @dotrino/vault.
1
+ Copia vendorizada de @dotrino/vault@0.3.0 (lib/src/{index,enroll}.js, sin dependencias).
2
+ El iframe de identity se sirve estatico (vanilla, sin build); asi startDeviceVault
3
+ resuelve en el navegador sin bundler. index.js importa ./enroll.js (relativo, se
4
+ vendoriza tambien) y @dotrino/identity/capabilities (=../../capabilities.js) y
5
+ @dotrino/proxy-client (=../proxy-client/), ambos via el import map de index.html.
6
+ Re-vendorizar AMBOS archivos al subir @dotrino/vault.
@@ -0,0 +1,250 @@
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
+ /** Token aleatorio de 128 bits en hex. */
48
+ export function randToken () {
49
+ const b = crypto.getRandomValues(new Uint8Array(16))
50
+ return [...b].map((x) => x.toString(16).padStart(2, '0')).join('')
51
+ }
52
+
53
+ /** deviceId legible (p. ej. `C440-AC0E`) a partir de una pubkey JWK. */
54
+ export async function deviceIdOf (pub) {
55
+ const id = (await pubkeyId(pub)).slice(0, 8).toUpperCase()
56
+ return id.slice(0, 4) + '-' + id.slice(4, 8)
57
+ }
58
+
59
+ /**
60
+ * Crea el «mostrador» de emparejamiento de una bóveda.
61
+ *
62
+ * @param {Object} opts
63
+ * @param {Object} opts.identity firma: `signData`, `signDelegation`, `listDelegations`, `revokeDelegation`.
64
+ * @param {string} opts.iss pubkey de la maestra de ESTA bóveda (va en el QR).
65
+ * @param {string} opts.proxy URL del proxy (va en el QR).
66
+ * @param {(to:string, obj:object)=>void} opts.send responder por el token de la conexión.
67
+ * @param {(pub:string, obj:object)=>void} opts.sendByPubkey dirigir por pubkey (cola offline 24 h).
68
+ * @param {(op:string, info?:object)=>void} [opts.audit]
69
+ * @param {(...a:any[])=>void} [opts.log]
70
+ * @param {(c:{deviceId:string, scope:any, label:string})=>void} [opts.onChallenge] un dispositivo espera aprobación.
71
+ * @param {()=>void} [opts.onPendingChange]
72
+ * @param {string[]} [opts.defaultScope]
73
+ * @param {number} [opts.defaultTtlMs]
74
+ */
75
+ export function createEnrollDesk ({
76
+ identity, iss, proxy, send, sendByPubkey,
77
+ audit = () => {}, log = () => {},
78
+ onChallenge = () => {}, onPendingChange = () => {},
79
+ defaultScope = ['vault:sign'], defaultTtlMs = DEVICE_TTL_MS
80
+ } = {}) {
81
+ if (!identity) throw new Error('createEnrollDesk: falta identity')
82
+ if (!iss) throw new Error('createEnrollDesk: falta iss (pubkey de la maestra)')
83
+
84
+ // token -> { token, exp, scope, ttlMs, label, sn, state, dpub?, deviceId?, commit?, from? }
85
+ // state: 'AWAITING_ENROLL' -> 'PENDING_CONFIRM'
86
+ const pending = new Map()
87
+
88
+ const fire = (fn, arg) => { try { fn(arg) } catch (_) {} }
89
+ const reply = (to, obj) => { try { send(to, obj) } catch (e) { log('[vault] no se pudo responder:', e.message) } }
90
+ const isFresh = (d) => typeof d?.ts === 'number' && Math.abs(Date.now() - d.ts) <= FRESH_WINDOW_MS
91
+
92
+ /** Inicia un emparejamiento: token + nonce de sesión. NO firma nada todavía. */
93
+ function startPairing ({ scope = defaultScope, ttlMs = defaultTtlMs, label = '' } = {}) {
94
+ pending.clear() // uno a la vez: una sesión nueva supersede a la anterior
95
+ const token = randToken()
96
+ const sn = randToken()
97
+ pending.set(token, { token, exp: Date.now() + PAIRING_TTL_MS, scope, ttlMs, label, sn, state: 'AWAITING_ENROLL' })
98
+ return { token, qr: { v: 2, iss, proxy, token, sn }, expiresInMs: PAIRING_TTL_MS }
99
+ }
100
+
101
+ function stopPairing (token) { pending.delete(token) }
102
+
103
+ function listPending () {
104
+ return [...pending.values()]
105
+ .filter((p) => p.state === 'PENDING_CONFIRM')
106
+ .map((p) => ({ deviceId: p.deviceId, label: p.label || '', scope: p.scope }))
107
+ }
108
+
109
+ function findPending (deviceId) {
110
+ for (const p of pending.values()) {
111
+ if (p.state === 'PENDING_CONFIRM' && p.deviceId === deviceId) return p
112
+ }
113
+ return null
114
+ }
115
+
116
+ /**
117
+ * ENROLL: el dispositivo prueba posesión de `D` firmando el sobre y deja el
118
+ * COMPROMISO de su código. Todavía NO se firma ningún cert.
119
+ */
120
+ async function handleEnroll (from, p) {
121
+ const d = p?.data
122
+ if (!d || typeof d.dpub !== 'string' || typeof p.signature !== 'string') {
123
+ return reply(from, { type: MSG_ERROR, error: 'enroll inválido' })
124
+ }
125
+ const pend = pending.get(d.token)
126
+ if (!pend || pend.state === 'DONE' || Date.now() > pend.exp) {
127
+ return reply(from, { type: MSG_ERROR, error: 'token de emparejamiento inválido o expirado' })
128
+ }
129
+ if (d.sn !== pend.sn) return reply(from, { type: MSG_ERROR, error: 'sesión inválida' })
130
+ if (!isFresh(d)) {
131
+ audit('rejected', { what: 'enroll', reason: 'stale' })
132
+ 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)' })
133
+ }
134
+ // PRUEBA DE POSESIÓN: la firma de `data` debe verificar contra `dpub`.
135
+ if (!(await verifyDeviceSig({ publickey: d.dpub, data: d, signature: p.signature }))) {
136
+ audit('rejected', { what: 'enroll', reason: 'bad-device-signature' })
137
+ return reply(from, { type: MSG_ERROR, error: 'firma de dispositivo inválida' })
138
+ }
139
+ // El COMPROMISO del código es obligatorio: sin él no se puede comprobar al aprobar
140
+ // y volveríamos a emitir certs a ciegas. Un cliente viejo cae acá con un mensaje claro.
141
+ if (typeof d.commit !== 'string' || !/^[0-9a-f]{64}$/.test(d.commit)) {
142
+ audit('rejected', { what: 'enroll', reason: 'no-commit' })
143
+ 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.' })
144
+ }
145
+ // Un solo dispositivo a la vez esperando su código (así aprobar no es ambiguo).
146
+ if (pend.state === 'PENDING_CONFIRM' && pend.dpub && pend.dpub !== d.dpub) {
147
+ return reply(from, { type: MSG_ERROR, error: 'ya hay un dispositivo usando este emparejamiento' })
148
+ }
149
+
150
+ const deviceId = await deviceIdOf(d.dpub)
151
+ pend.state = 'PENDING_CONFIRM'
152
+ pend.dpub = d.dpub
153
+ pend.deviceId = deviceId
154
+ pend.commit = d.commit
155
+ pend.from = from // la bóveda NO conoce el código: lo aprende cuando lo tipeas
156
+ if (d.label) pend.label = String(d.label).slice(0, 60)
157
+
158
+ reply(from, { type: MSG_ENROLL_CHALLENGE, deviceId })
159
+ fire(onChallenge, { deviceId, scope: pend.scope, label: pend.label || '' })
160
+ fire(onPendingChange)
161
+ return { deviceId }
162
+ }
163
+
164
+ /**
165
+ * Aprueba TIPEANDO el código que muestra el dispositivo. Recompone el compromiso
166
+ * `SHA-256(code‖dpub‖sn)` y solo firma el cert si coincide con el que llegó en el
167
+ * ENROLL — es decir, solo si de verdad fuiste a leer el código del dispositivo.
168
+ *
169
+ * @param {string} code
170
+ * @param {{deviceId?: string}} [opts] cuál aprobar cuando hay varios pendientes.
171
+ */
172
+ async function approve (code, { deviceId } = {}) {
173
+ code = String(code || '').trim()
174
+ if (!code) throw new Error('falta el código (los dígitos que muestra el dispositivo)')
175
+
176
+ let pend
177
+ if (deviceId) {
178
+ pend = findPending(deviceId)
179
+ if (!pend) throw new Error('no hay ninguna máquina esperando aprobación con ese identificador')
180
+ } else {
181
+ const waiting = [...pending.values()].filter((p) => p.state === 'PENDING_CONFIRM' && p.dpub)
182
+ if (waiting.length === 0) throw new Error('no hay ningún dispositivo esperando aprobación')
183
+ if (waiting.length > 1) throw new Error('hay más de un emparejamiento en curso; reinícialo con dotrino-vault pair')
184
+ pend = waiting[0]
185
+ }
186
+
187
+ // COMPROBACIÓN DEL CÓDIGO — antes de firmar nada.
188
+ const expected = await commitCode({ code, dpub: pend.dpub, sn: pend.sn })
189
+ if (expected !== pend.commit) {
190
+ audit('rejected', { what: 'approve', device: pend.deviceId, reason: 'bad-code' })
191
+ log('[vault] código incorrecto para %s: no se emitió ningún certificado', pend.deviceId)
192
+ 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.')
193
+ }
194
+
195
+ const { cert } = await identity.signDelegation(pend.dpub, pend.scope, { ttlMs: pend.ttlMs, label: pend.label })
196
+ audit('enroll', { device: pend.deviceId, label: pend.label || '', scope: pend.scope })
197
+ // Echamos el código tipeado junto al cert: el DISPOSITIVO acepta solo si coincide
198
+ // con el que generó → una bóveda falsa (que no lo conoce) no puede enrolarlo.
199
+ reply(pend.from, { type: MSG_ENROLLED, code, cert, iss })
200
+ pend.state = 'DONE'
201
+ pending.delete(pend.token)
202
+ fire(onPendingChange)
203
+ log('[vault] dispositivo aprobado: %s', pend.deviceId)
204
+ return { ok: true, deviceId: pend.deviceId, cert }
205
+ }
206
+
207
+ /** Rechaza un enrolamiento pendiente. */
208
+ function reject (deviceId) {
209
+ const pend = deviceId
210
+ ? findPending(deviceId)
211
+ : [...pending.values()].find((p) => p.state === 'PENDING_CONFIRM')
212
+ if (!pend) return { ok: false }
213
+ reply(pend.from, { type: MSG_ERROR, error: 'emparejamiento rechazado' })
214
+ pending.delete(pend.token)
215
+ audit('reject', { device: pend.deviceId })
216
+ fire(onPendingChange)
217
+ log('[vault] dispositivo rechazado: %s', pend.deviceId)
218
+ return { ok: true, deviceId: pend.deviceId }
219
+ }
220
+
221
+ /**
222
+ * Emite un REVOKED FIRMADO por la maestra para que el dispositivo se autoborre. El
223
+ * borrado remoto SOLO se dispara con esta firma (nunca con un error cualquiera →
224
+ * cierra el wipe-DoS). Va por `sendByPubkey`: si está apagado, el proxy lo encola 24 h.
225
+ */
226
+ async function emitRevoke (dpub, nonce) {
227
+ const body = { op: 'revoke', sub: dpub, nonce, iat: Date.now(), exp: Date.now() + DEVICE_TTL_MS }
228
+ const { signature } = await identity.signData(body)
229
+ try { sendByPubkey(dpub, { type: MSG_REVOKED, body, signature }) }
230
+ catch (e) { log('[vault] no se pudo emitir revoke:', e.message) }
231
+ }
232
+
233
+ /** Revoca una delegación por `nonce` y avisa al dispositivo para que se autoborre. */
234
+ async function revoke (nonce) {
235
+ audit('revoke', { nonce })
236
+ const { issued } = await identity.listDelegations()
237
+ const dele = (issued || []).find((d) => d.nonce === nonce)
238
+ const res = await identity.revokeDelegation(nonce)
239
+ if (dele?.sub) await emitRevoke(dele.sub, nonce)
240
+ return res
241
+ }
242
+
243
+ return {
244
+ startPairing, stopPairing, handleEnroll, approve, reject,
245
+ listPending, findPending, emitRevoke, revoke,
246
+ get pendingCount () { return pending.size }
247
+ }
248
+ }
249
+
250
+ export default { createEnrollDesk, deviceIdOf, randToken }
@@ -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 (_) {} }