@dotrino/identity 0.10.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,5 +1,7 @@
1
1
  # @dotrino/identity
2
2
 
3
+ > **Parte del ecosistema [Dotrino](https://dotrino.com).** Misión: aplicaciones que resuelven problemas comunes, respetando tu privacidad — sin anuncios, sin cookies, sin rastreo de datos, sin vender tu identidad a nadie.
4
+
3
5
  Identidad de usuario y rating de peers compartidos entre las apps de Dotrino. Funciona aunque las apps vivan en orígenes distintos: usa un **vault iframe** alojado en un origin estable que guarda la información en su propio `localStorage` y expone una API por `postMessage`.
4
6
 
5
7
  ## Cómo funciona
@@ -7,8 +9,8 @@ Identidad de usuario y rating de peers compartidos entre las apps de Dotrino. Fu
7
9
  ```
8
10
  ┌────────────────────┐ postMessage ┌────────────────────────┐
9
11
  │ app (cualquier │ ◀───────────────▶ │ vault iframe │
10
- │ origin: chat, │ │ origin: id.closer
11
- │ qrshare, chess…) │ │ .click
12
+ │ origin: chat, │ │ origin: id.dotrino
13
+ │ qrshare, chess…) │ │ .com
12
14
  │ │ │ - keypair ECDSA P-256 │
13
15
  │ import {Identity} │ │ - keypair ECDH P-256 │
14
16
  └────────────────────┘ │ - peers + ratings │
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotrino/identity",
3
- "version": "0.10.0",
3
+ "version": "0.11.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",
@@ -41,6 +41,9 @@
41
41
  "type": "git",
42
42
  "url": "git+https://github.com/imdotrino/dotrino-identity.git"
43
43
  },
44
+ "dependencies": {
45
+ "@dotrino/proxy-client": "0.6.3"
46
+ },
44
47
  "devDependencies": {
45
48
  "fake-indexeddb": "^6.2.5"
46
49
  }
package/src/index.js CHANGED
@@ -197,6 +197,59 @@ export class Identity {
197
197
  return this._call('listDelegations')
198
198
  }
199
199
 
200
+ // ----- Emparejar ESTE navegador/dispositivo con el vault del usuario (Fase 1) -----
201
+
202
+ /**
203
+ * Empareja este dispositivo con el vault del usuario a partir del QR (v2) que
204
+ * muestra `dotrino-vault pair`. Genera la sub-clave D DENTRO del iframe (su privada
205
+ * nunca sale), hace el emparejamiento endurecido por el proxy y guarda el cert.
206
+ * Emite un evento 'vault' { phase:'challenge', deviceId, sas } para que muestres el
207
+ * código a comparar; resuelve cuando el dueño aprueba en su PC (espera hasta 3 min).
208
+ * @returns {Promise<{ ok:boolean, deviceId:string, master:string, exp:number, scope:string[] }>}
209
+ */
210
+ async enrollDevice (qr) {
211
+ return this._call('vaultPair', { qr }, 200000)
212
+ }
213
+
214
+ /** Estado de emparejamiento: { paired, deviceId?, master?, scope?, exp?, pairedAt? }. */
215
+ async vaultStatus () {
216
+ return this._call('vaultStatus')
217
+ }
218
+
219
+ /** Desvincula este dispositivo del vault (borra la sub-clave + el cert locales). */
220
+ async unpairDevice () {
221
+ return this._call('vaultUnpair')
222
+ }
223
+
224
+ /**
225
+ * Firma DELEGADA: pide a la maestra del vault (tu PC) que firme `payload`, usando
226
+ * el cert de este dispositivo. Aditivo y explícito — NO cambia `signData` (local).
227
+ * Requiere estar emparejado y el vault encendido. Devuelve { signature, publickey }
228
+ * donde publickey es tu identidad MAESTRA.
229
+ */
230
+ async vaultSign (payload) {
231
+ return this._call('vaultSign', { payload }, 20000)
232
+ }
233
+
234
+ /**
235
+ * Store DELEGADO: lee/escribe el store de hilos+aperturas (appendMessage,
236
+ * listThread, recordOpen, getOpens, getStats, …) EN tu vault, usando el cert de
237
+ * este dispositivo. Reusa el mismo emparejamiento. Requiere el vault encendido.
238
+ */
239
+ async vaultStore (method, args) {
240
+ return this._call('vaultStore', { method, args }, 20000)
241
+ }
242
+
243
+ /** Lista (solo lectura) los dispositivos enrolados en tu vault: { devices, revoked }. */
244
+ async listVaultDevices () {
245
+ return this._call('listVaultDevices', {}, 20000)
246
+ }
247
+
248
+ /** Suscribe a eventos de emparejamiento ('vault'): { phase:'challenge'|'paired'|'unpaired', ... }. */
249
+ onVault (handler) {
250
+ return this.on('vault', handler)
251
+ }
252
+
200
253
  /**
201
254
  * Merge endorsements (signed ratings from third parties) about a subject
202
255
  * into the local peer book. Returns { merged, total }.
@@ -334,7 +387,7 @@ export class Identity {
334
387
  }
335
388
  }
336
389
 
337
- _call (method, params = {}) {
390
+ _call (method, params = {}, timeoutMs = this.timeoutMs) {
338
391
  return new Promise((resolve, reject) => {
339
392
  if (!this._iframe?.contentWindow) {
340
393
  return reject(new Error('Vault not ready'))
@@ -343,7 +396,7 @@ export class Identity {
343
396
  const timer = setTimeout(() => {
344
397
  this._pending.delete(id)
345
398
  reject(new Error(`Vault timeout for ${method}`))
346
- }, this.timeoutMs)
399
+ }, timeoutMs)
347
400
  this._pending.set(id, { resolve, reject, timer })
348
401
 
349
402
  // Usamos targetOrigin='*' por compatibilidad: en algunos navegadores el
@@ -364,4 +417,4 @@ export class Identity {
364
417
 
365
418
  // Helpers de capacidad SIN clave maestra (lado dispositivo + verificación), reutilizables
366
419
  // por apps/bridges sin cargar el iframe del vault.
367
- export { makeDeviceKey, signWithDevice, verifyDelegation, verifyChain, pubkeyId, MAX_DELEGATION_MS, DEFAULT_DELEGATION_MS } from '../vault/capabilities.js'
420
+ export { makeDeviceKey, signWithDevice, verifyDelegation, verifyChain, pubkeyId, deriveSAS, verifyDeviceSig, MAX_DELEGATION_MS, DEFAULT_DELEGATION_MS } from '../vault/capabilities.js'
package/src/node.js CHANGED
@@ -99,6 +99,7 @@ export class Identity {
99
99
  makeSync: null
100
100
  })
101
101
  this._core.onSyncStatus((payload) => this._emit('sync', payload))
102
+ this._core.onVaultEvent((payload) => this._emit('vault', payload))
102
103
  return this
103
104
  }
104
105
 
@@ -134,6 +135,14 @@ export class Identity {
134
135
  signDelegation (sub, scope, opts = {}) { return this._h('signDelegation', { sub, scope, ...opts }) }
135
136
  revokeDelegation (nonce) { return this._h('revokeDelegation', { nonce }) }
136
137
  listDelegations () { return this._h('listDelegations') }
138
+ // Emparejar ESTE dispositivo con el vault del usuario (Fase 1)
139
+ enrollDevice (qr) { return this._h('vaultPair', { qr }) }
140
+ vaultStatus () { return this._h('vaultStatus') }
141
+ unpairDevice () { return this._h('vaultUnpair') }
142
+ vaultSign (payload) { return this._h('vaultSign', { payload }) }
143
+ vaultStore (method, args) { return this._h('vaultStore', { method, args }) }
144
+ listVaultDevices () { return this._h('listVaultDevices') }
145
+ onVault (handler) { return this.on('vault', handler) }
137
146
  mergeEndorsements (subject, endorsements, askerPubkey) {
138
147
  return this._h('mergeEndorsements', { subject, endorsements, askerPubkey })
139
148
  }
@@ -176,4 +185,4 @@ export default Identity
176
185
 
177
186
  // Helpers de capacidad SIN clave maestra (lado dispositivo + verificación), para que
178
187
  // un bridge/bot Node pueda crear su clave, firmar acciones y verificar cadenas D←P.
179
- export { makeDeviceKey, signWithDevice, verifyDelegation, verifyChain, pubkeyId, MAX_DELEGATION_MS, DEFAULT_DELEGATION_MS } from '../vault/capabilities.js'
188
+ export { makeDeviceKey, signWithDevice, verifyDelegation, verifyChain, pubkeyId, deriveSAS, verifyDeviceSig, MAX_DELEGATION_MS, DEFAULT_DELEGATION_MS } from '../vault/capabilities.js'
@@ -53,6 +53,30 @@ export async function pubkeyId (publicJwkStr) {
53
53
  return [...new Uint8Array(h)].map(b => b.toString(16).padStart(2, '0')).join('')
54
54
  }
55
55
 
56
+ /**
57
+ * Verifica que `signature` (base64) sobre `data` fue hecha por la privada de
58
+ * `publickey` (JWK string). Prueba de POSESION de una sub-clave de dispositivo (no
59
+ * es cadena de delegacion): la usa el vault para confirmar que quien pide enrolar
60
+ * `dpub` realmente tiene su privada (un token robado ya no alcanza para enrolar).
61
+ */
62
+ export async function verifyDeviceSig ({ publickey, data, signature }) {
63
+ if (typeof publickey !== 'string' || typeof signature !== 'string') return false
64
+ return rawVerify(publickey, enc(canonicalStringify(data)), signature)
65
+ }
66
+
67
+ /**
68
+ * Short Authentication String: 6 digitos deterministas derivados de (maestra,
69
+ * dispositivo, nonce de sesion). NO es un secreto: su valor esta en COMPARARLO
70
+ * visualmente entre las dos pantallas (PC del vault y dispositivo) al emparejar —
71
+ * eso mata el relay/phishing (un atacante remoto no puede mostrar el SAS correcto
72
+ * en el dispositivo fisico de la victima).
73
+ */
74
+ export async function deriveSAS (master, dpub, sn) {
75
+ const h = new Uint8Array(await crypto.subtle.digest('SHA-256', enc(canonicalStringify({ iss: master, sub: dpub, sn }))))
76
+ const n = ((h[0] << 24) | (h[1] << 16) | (h[2] << 8) | h[3]) >>> 0
77
+ return String(n % 1000000).padStart(6, '0')
78
+ }
79
+
56
80
  /** Cuerpo canónico del certificado (lo que se firma): el cert SIN la firma. */
57
81
  export function delegationBody (cert) {
58
82
  return { v: cert.v, iss: cert.iss, sub: cert.sub, scope: cert.scope, iat: cert.iat, exp: cert.exp, nonce: cert.nonce }
package/vault/core.js CHANGED
@@ -19,6 +19,7 @@
19
19
  */
20
20
 
21
21
  import { signDelegationWith, MAX_DELEGATION_MS, DEFAULT_DELEGATION_MS } from './capabilities.js'
22
+ import { enrollDevice as remoteEnroll, requestSign as remoteSign, requestStore as remoteStore, requestDevices as remoteDevices } from './remote.js'
22
23
 
23
24
  export const KEY_STORAGE = 'dotrino.identity.keypair'
24
25
  export const ENC_KEY_STORAGE = 'dotrino.identity.enc-keypair'
@@ -26,6 +27,8 @@ export const ME_STORAGE = 'dotrino.identity.me'
26
27
  export const NONCE_STORAGE = 'dotrino.identity.nonces' // replay window
27
28
  export const DELEGATIONS_STORAGE = 'dotrino.identity.delegations' // caps emitidas
28
29
  export const REVOCATIONS_STORAGE = 'dotrino.identity.revocations' // nonces revocados
30
+ export const VAULT_DEVICE_STORAGE = 'dotrino.identity.vault.device' // sub-clave D de ESTE dispositivo (custodia en el iframe)
31
+ export const VAULT_CERT_STORAGE = 'dotrino.identity.vault.cert' // { cert, master, proxy, deviceId, pairedAt }
29
32
 
30
33
  const NONCE_TTL_MS = 5 * 60 * 1000
31
34
 
@@ -168,6 +171,13 @@ export async function createIdentityCore ({ kv, peers, makeSync = null }) {
168
171
  const loadRevocations = () => loadJson(REVOCATIONS_STORAGE)
169
172
  const saveRevocations = (o) => kv.setItem(REVOCATIONS_STORAGE, JSON.stringify(o))
170
173
 
174
+ // ----- emparejamiento con el vault del usuario (este dispositivo enrolado) -----
175
+ // Canal de eventos 'vault' (p.ej. el SAS a comparar durante el emparejamiento).
176
+ const vaultListeners = new Set()
177
+ const emitVault = (p) => { for (const fn of vaultListeners) { try { fn(p) } catch (_) {} } }
178
+ const loadVaultCert = () => { try { return JSON.parse(kv.getItem(VAULT_CERT_STORAGE) || 'null') } catch (_) { return null } }
179
+ const loadVaultDevice = () => { try { return JSON.parse(kv.getItem(VAULT_DEVICE_STORAGE) || 'null') } catch (_) { return null } }
180
+
171
181
  // ----- me (kv-backed) -----
172
182
 
173
183
  function loadMe () {
@@ -501,6 +511,54 @@ export async function createIdentityCore ({ kv, peers, makeSync = null }) {
501
511
  }
502
512
  },
503
513
 
514
+ // ----- emparejar ESTE dispositivo con el vault del usuario (Fase 1) -----
515
+ // Genera D aquí dentro (su privada NUNCA sale de la identidad), hace el enroll
516
+ // endurecido por el proxy y guarda el cert. NO cambia signData todavía (Fase 2).
517
+ async vaultPair ({ qr }) {
518
+ const res = await remoteEnroll({ qr, onChallenge: (c) => emitVault({ phase: 'challenge', deviceId: c.deviceId, sas: c.sas }) })
519
+ kv.setItem(VAULT_DEVICE_STORAGE, JSON.stringify(res.device))
520
+ kv.setItem(VAULT_CERT_STORAGE, JSON.stringify({ cert: res.cert, master: res.master, proxy: res.proxy, deviceId: res.deviceId, pairedAt: Date.now() }))
521
+ emitVault({ phase: 'paired', deviceId: res.deviceId, master: res.master })
522
+ return { ok: true, deviceId: res.deviceId, master: res.master, exp: res.cert.exp, scope: res.cert.scope }
523
+ },
524
+
525
+ async vaultStatus () {
526
+ const v = loadVaultCert()
527
+ if (!v?.cert) return { paired: false }
528
+ return { paired: true, deviceId: v.deviceId, master: v.master, proxy: v.proxy, scope: v.cert.scope, exp: v.cert.exp, pairedAt: v.pairedAt }
529
+ },
530
+
531
+ async vaultUnpair () {
532
+ kv.removeItem(VAULT_DEVICE_STORAGE)
533
+ kv.removeItem(VAULT_CERT_STORAGE)
534
+ emitVault({ phase: 'unpaired' })
535
+ return { ok: true }
536
+ },
537
+
538
+ // Firma DELEGADA: pide a la maestra del vault que firme `payload` (con el cert de
539
+ // este dispositivo). Aditivo y explícito — NO cambia `signData` (que sigue local),
540
+ // así nada se rompe si no estás emparejado o si el vault está apagado.
541
+ async vaultSign ({ payload }) {
542
+ const v = loadVaultCert(); const device = loadVaultDevice()
543
+ if (!v?.cert || !device) throw new Error('este dispositivo no está emparejado con un vault')
544
+ return remoteSign({ master: v.master, proxy: v.proxy, device, cert: v.cert, payload })
545
+ },
546
+
547
+ // Store DELEGADO: lee/escribe el store de hilos+aperturas EN tu vault (con el cert).
548
+ // Reusa el MISMO emparejamiento (no hay un pairing aparte para el store).
549
+ async vaultStore ({ method, args }) {
550
+ const v = loadVaultCert(); const device = loadVaultDevice()
551
+ if (!v?.cert || !device) throw new Error('este dispositivo no está emparejado con un vault')
552
+ return remoteStore({ master: v.master, proxy: v.proxy, device, cert: v.cert, method, args })
553
+ },
554
+
555
+ // Lista (solo lectura) de dispositivos enrolados en tu vault.
556
+ async listVaultDevices () {
557
+ const v = loadVaultCert(); const device = loadVaultDevice()
558
+ if (!v?.cert || !device) throw new Error('este dispositivo no está emparejado con un vault')
559
+ return remoteDevices({ master: v.master, proxy: v.proxy, device, cert: v.cert })
560
+ },
561
+
504
562
  async listContacts () {
505
563
  return Object.values(loadPeers()).filter(p => p && p.isContact).sort((a, b) => (b.lastSeen || 0) - (a.lastSeen || 0))
506
564
  },
@@ -636,6 +694,7 @@ export async function createIdentityCore ({ kv, peers, makeSync = null }) {
636
694
  handlers,
637
695
  get me () { return me },
638
696
  sync,
639
- onSyncStatus (fn) { if (sync) sync.onStatus(fn) }
697
+ onSyncStatus (fn) { if (sync) sync.onStatus(fn) },
698
+ onVaultEvent (fn) { vaultListeners.add(fn); return () => vaultListeners.delete(fn) }
640
699
  }
641
700
  }
package/vault/index.html CHANGED
@@ -19,6 +19,11 @@
19
19
  <li>Nicknames and ratings of peers you've met.</li>
20
20
  </ul>
21
21
  <p>Source: <a href="https://github.com/imdotrino/dotrino-identity" target="_blank" rel="noopener">github.com/imdotrino/dotrino-identity</a></p>
22
+ <!-- Transporte self-hosted (vendorizado): el emparejamiento de dispositivos importa
23
+ @dotrino/proxy-client de forma perezosa; este import map lo resuelve sin CDN. -->
24
+ <script type="importmap">
25
+ { "imports": { "@dotrino/proxy-client": "./vendor/proxy-client/index.js" } }
26
+ </script>
22
27
  <script type="module" src="./vault.js"></script>
23
28
  </body>
24
29
  </html>
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Enrolamiento de ESTE dispositivo contra el vault del usuario (lado dispositivo).
3
+ *
4
+ * Corre dentro del iframe de identidad (o headless en Node): genera la sub-clave `D`
5
+ * —cuya privada NUNCA sale de la identidad—, hace el emparejamiento ENDURECIDO por el
6
+ * proxy (ver dotrino-vault/docs/pairing-protocol.md) y devuelve el cert ya validado.
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).
11
+ *
12
+ * No reimplementa cripto: usa `@dotrino/identity/capabilities`. Transporte:
13
+ * `@dotrino/proxy-client` (importado perezosamente; solo se carga al emparejar).
14
+ */
15
+ import { makeDeviceKey, signWithDevice, verifyDelegation, deriveSAS, pubkeyId } from './capabilities.js'
16
+
17
+ const MSG = {
18
+ ENROLL: 'vault.enroll',
19
+ ENROLL_CHALLENGE: 'vault.enroll.challenge',
20
+ ENROLLED: 'vault.enrolled',
21
+ ERROR: 'vault.error'
22
+ }
23
+
24
+ /**
25
+ * @param {Object} opts
26
+ * @param {{v:number, iss:string, proxy:string, token:string, sn:string}} opts.qr QR v2 del vault.
27
+ * @param {(c:{deviceId:string, sas:string})=>void} [opts.onChallenge] Para mostrar el SAS a comparar.
28
+ * @param {string} [opts.label]
29
+ * @param {number} [opts.approveTimeoutMs] Espera de la aprobación humana (def 3 min).
30
+ * @returns {Promise<{device, cert, master:string, proxy:string, deviceId:string}>}
31
+ */
32
+ export async function enrollDevice ({ qr, onChallenge, label = '', approveTimeoutMs = 180000 } = {}) {
33
+ if (!qr?.iss || !qr?.proxy || !qr?.token || !qr?.sn) throw new Error('qr inválido (v2): faltan iss/proxy/token/sn')
34
+ const { WebSocketProxyClient } = await import('@dotrino/proxy-client')
35
+ const client = new WebSocketProxyClient({ url: qr.proxy, enableWebRTC: false, autoReconnect: false })
36
+ await client.connect()
37
+ try {
38
+ const device = await makeDeviceKey({ label })
39
+ const deviceId = (await pubkeyId(device.publickey)).slice(0, 8).toUpperCase().replace(/(.{4})(.{4})/, '$1-$2')
40
+ const sas = await deriveSAS(qr.iss, device.publickey, qr.sn)
41
+ const data = { op: 'enroll', dpub: device.publickey, token: qr.token, sn: qr.sn, label, ts: Date.now() }
42
+ const { signature } = await signWithDevice({ privateJwk: device.privateJwk, data })
43
+
44
+ const enrolled = new Promise((resolve, reject) => {
45
+ const off = client.on('message', (_from, p) => {
46
+ if (!p || typeof p !== 'object') return
47
+ if (p.type === MSG.ENROLL_CHALLENGE) { try { onChallenge?.({ deviceId, sas }) } catch (_) {} }
48
+ else if (p.type === MSG.ENROLLED) { cleanup(); resolve(p) }
49
+ else if (p.type === MSG.ERROR) { cleanup(); reject(new Error(p.error)) }
50
+ })
51
+ const t = setTimeout(() => { cleanup(); reject(new Error('timeout esperando la aprobación en el vault')) }, approveTimeoutMs)
52
+ const cleanup = () => { off(); clearTimeout(t) }
53
+ })
54
+ client.sendByPubkey(qr.iss, { type: MSG.ENROLL, data, signature })
55
+ const res = await enrolled
56
+
57
+ // Validación estricta antes de guardar (cierra inyección de cert / sustitución de maestra).
58
+ const v = await verifyDelegation({ cert: res.cert, expectedSub: device.publickey })
59
+ if (!v.ok) throw new Error('cert inválido: ' + v.reason)
60
+ if (res.cert.iss !== qr.iss) throw new Error('cert firmado por una maestra distinta a la que viste')
61
+ if (res.cert.sub !== device.publickey) throw new Error('cert emitido para otro dispositivo')
62
+ return { device, cert: res.cert, master: qr.iss, proxy: qr.proxy, deviceId }
63
+ } finally { try { client.close() } catch (_) {} }
64
+ }
65
+
66
+ /**
67
+ * Pide a la MAESTRA (en el vault del PC) que firme `payload`, adjuntando el cert de
68
+ * delegación de este dispositivo. La maestra nunca sale del vault: vuelve solo la
69
+ * firma. Requiere que el vault esté online.
70
+ * @returns {Promise<{ signature:string, publickey:string }>} publickey = la maestra.
71
+ */
72
+ export async function requestSign ({ master, proxy, device, cert, payload, timeoutMs = 15000 } = {}) {
73
+ if (!master || !proxy || !device?.privateJwk || !cert) throw new Error('faltan datos de emparejamiento')
74
+ const { WebSocketProxyClient } = await import('@dotrino/proxy-client')
75
+ const client = new WebSocketProxyClient({ url: proxy, enableWebRTC: false, autoReconnect: false })
76
+ await client.connect()
77
+ try {
78
+ const data = { op: 'sign', payload, publickey: device.publickey, ts: Date.now() }
79
+ const { signature } = await signWithDevice({ privateJwk: device.privateJwk, data })
80
+ const pending = new Promise((resolve, reject) => {
81
+ const off = client.on('message', (_f, p) => {
82
+ if (!p || typeof p !== 'object') return
83
+ if (p.type === 'vault.signed') { cleanup(); resolve(p) }
84
+ else if (p.type === 'vault.error') { cleanup(); reject(new Error(p.error)) }
85
+ })
86
+ const t = setTimeout(() => { cleanup(); reject(new Error('el vault no respondió (¿está encendido?)')) }, timeoutMs)
87
+ const cleanup = () => { off(); clearTimeout(t) }
88
+ })
89
+ client.sendByPubkey(master, { type: 'vault.sign', data, signature, cert })
90
+ const res = await pending
91
+ return { signature: res.signature, publickey: res.publickey }
92
+ } finally { try { client.close() } catch (_) {} }
93
+ }
94
+
95
+ /** Helper genérico: una RPC al vault firmada por D + cert, esperando `okType`. */
96
+ async function vaultRpc ({ master, proxy, device, cert, sendType, okType, data, timeoutMs = 15000 }) {
97
+ if (!master || !proxy || !device?.privateJwk || !cert) throw new Error('faltan datos de emparejamiento')
98
+ const { WebSocketProxyClient } = await import('@dotrino/proxy-client')
99
+ const client = new WebSocketProxyClient({ url: proxy, enableWebRTC: false, autoReconnect: false })
100
+ await client.connect()
101
+ try {
102
+ const signed = { ...data, publickey: device.publickey, ts: Date.now() }
103
+ const { signature } = await signWithDevice({ privateJwk: device.privateJwk, data: signed })
104
+ const pending = new Promise((resolve, reject) => {
105
+ const off = client.on('message', (_f, p) => {
106
+ if (!p || typeof p !== 'object') return
107
+ if (p.type === okType) { cleanup(); resolve(p) }
108
+ else if (p.type === 'vault.error') { cleanup(); reject(new Error(p.error)) }
109
+ })
110
+ const t = setTimeout(() => { cleanup(); reject(new Error('el vault no respondió (¿está encendido?)')) }, timeoutMs)
111
+ const cleanup = () => { off(); clearTimeout(t) }
112
+ })
113
+ client.sendByPubkey(master, { type: sendType, data: signed, signature, cert })
114
+ return await pending
115
+ } finally { try { client.close() } catch (_) {} }
116
+ }
117
+
118
+ /** Lee/escribe el store de hilos+aperturas EN el vault (con el cert del dispositivo). */
119
+ export async function requestStore ({ master, proxy, device, cert, method, args } = {}) {
120
+ const res = await vaultRpc({ master, proxy, device, cert, sendType: 'vault.store', okType: 'vault.store.result', data: { op: 'store', method, args: args || {} } })
121
+ return res.result
122
+ }
123
+
124
+ /** Lista (solo lectura) los dispositivos enrolados en tu vault. */
125
+ export async function requestDevices ({ master, proxy, device, cert } = {}) {
126
+ const res = await vaultRpc({ master, proxy, device, cert, sendType: 'vault.devices', okType: 'vault.devices.result', data: { op: 'devices' } })
127
+ return { devices: res.devices || [], revoked: res.revoked || [] }
128
+ }
package/vault/vault.js CHANGED
@@ -30,17 +30,15 @@ import { createIdentityCore } from './core.js'
30
30
 
31
31
  const { handlers } = core
32
32
 
33
- // Broadcast de estado del sync a todos los embebedores.
34
- const broadcastStatus = (payload) => {
33
+ // Broadcast de eventos del vault (sync + emparejamiento) a todos los embebedores.
34
+ const broadcast = (eventName, payload) => {
35
35
  for (const w of [window.parent, ...Array.from(document.querySelectorAll('iframe')).map(f => f.contentWindow)]) {
36
36
  if (!w || w === window) continue
37
- try { w.postMessage({ _cci: true, type: 'event', event: 'sync', payload }, '*') } catch {}
38
- }
39
- if (window.parent && window.parent !== window) {
40
- try { window.parent.postMessage({ _cci: true, type: 'event', event: 'sync', payload }, '*') } catch {}
37
+ try { w.postMessage({ _cci: true, type: 'event', event: eventName, payload }, '*') } catch {}
41
38
  }
42
39
  }
43
- core.onSyncStatus(broadcastStatus)
40
+ core.onSyncStatus((p) => broadcast('sync', p))
41
+ core.onVaultEvent((p) => broadcast('vault', p))
44
42
 
45
43
  window.addEventListener('message', async (event) => {
46
44
  const msg = event.data
@@ -0,0 +1,4 @@
1
+ Copia vendorizada de @dotrino/proxy-client@0.6.3 (src/, sin dependencias).
2
+ El iframe id.dotrino.com se sirve estático (sin bundler), así que el transporte
3
+ va self-hosted aquí (no por CDN) para no depender de terceros en runtime.
4
+ Actualizar: re-copiar node_modules/@dotrino/proxy-client/src/*.js y bumpear esta nota.
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Canonical JSON serialization (sorted keys recursively).
3
+ * Necessary so that signatures match across implementations.
4
+ */
5
+ export function canonicalStringify (value) {
6
+ if (value === null || typeof value !== 'object') {
7
+ return JSON.stringify(value)
8
+ }
9
+ if (Array.isArray(value)) {
10
+ return '[' + value.map(canonicalStringify).join(',') + ']'
11
+ }
12
+ const keys = Object.keys(value).sort()
13
+ const parts = keys.map(k => JSON.stringify(k) + ':' + canonicalStringify(value[k]))
14
+ return '{' + parts.join(',') + '}'
15
+ }