@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 +4 -2
- package/package.json +4 -1
- package/src/index.js +56 -3
- package/src/node.js +10 -1
- package/vault/capabilities.js +24 -0
- package/vault/core.js +60 -1
- package/vault/index.html +5 -0
- package/vault/remote.js +128 -0
- package/vault/vault.js +5 -7
- package/vault/vendor/proxy-client/VERSION.txt +4 -0
- package/vault/vendor/proxy-client/canonical.js +15 -0
- package/vault/vendor/proxy-client/client.js +673 -0
- package/vault/vendor/proxy-client/index.js +16 -0
- package/vault/vendor/proxy-client/signature.js +92 -0
- package/vault/vendor/proxy-client/webrtc.js +246 -0
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.
|
|
11
|
-
│ qrshare, chess…) │ │ .
|
|
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.
|
|
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
|
-
},
|
|
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'
|
package/vault/capabilities.js
CHANGED
|
@@ -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>
|
package/vault/remote.js
ADDED
|
@@ -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
|
|
34
|
-
const
|
|
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:
|
|
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(
|
|
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
|
+
}
|