@dotrino/identity 0.10.0 → 0.12.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 +65 -3
- package/src/node.js +11 -1
- package/vault/capabilities.js +24 -0
- package/vault/core.js +71 -1
- package/vault/index.html +5 -0
- package/vault/remote.js +131 -0
- package/vault/vault.js +5 -7
- package/vault/vendor/proxy-client/VERSION.txt +2 -0
- package/vault/vendor/proxy-client/canonical.js +15 -0
- package/vault/vendor/proxy-client/client.js +675 -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.12.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,68 @@ 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
|
+
/**
|
|
249
|
+
* El cert de delegación de este dispositivo (o null si no está emparejado). El
|
|
250
|
+
* transporte lo presenta al proxy en `identify` → "una identidad": el proxy enruta
|
|
251
|
+
* los mensajes dirigidos a tu maestra M también a este dispositivo. No tiene secretos.
|
|
252
|
+
*/
|
|
253
|
+
async getVaultCert () {
|
|
254
|
+
return this._call('getVaultCert')
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** Suscribe a eventos de emparejamiento ('vault'): { phase:'challenge'|'paired'|'unpaired', ... }. */
|
|
258
|
+
onVault (handler) {
|
|
259
|
+
return this.on('vault', handler)
|
|
260
|
+
}
|
|
261
|
+
|
|
200
262
|
/**
|
|
201
263
|
* Merge endorsements (signed ratings from third parties) about a subject
|
|
202
264
|
* into the local peer book. Returns { merged, total }.
|
|
@@ -334,7 +396,7 @@ export class Identity {
|
|
|
334
396
|
}
|
|
335
397
|
}
|
|
336
398
|
|
|
337
|
-
_call (method, params = {}) {
|
|
399
|
+
_call (method, params = {}, timeoutMs = this.timeoutMs) {
|
|
338
400
|
return new Promise((resolve, reject) => {
|
|
339
401
|
if (!this._iframe?.contentWindow) {
|
|
340
402
|
return reject(new Error('Vault not ready'))
|
|
@@ -343,7 +405,7 @@ export class Identity {
|
|
|
343
405
|
const timer = setTimeout(() => {
|
|
344
406
|
this._pending.delete(id)
|
|
345
407
|
reject(new Error(`Vault timeout for ${method}`))
|
|
346
|
-
},
|
|
408
|
+
}, timeoutMs)
|
|
347
409
|
this._pending.set(id, { resolve, reject, timer })
|
|
348
410
|
|
|
349
411
|
// Usamos targetOrigin='*' por compatibilidad: en algunos navegadores el
|
|
@@ -364,4 +426,4 @@ export class Identity {
|
|
|
364
426
|
|
|
365
427
|
// Helpers de capacidad SIN clave maestra (lado dispositivo + verificación), reutilizables
|
|
366
428
|
// 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'
|
|
429
|
+
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,15 @@ 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
|
+
getVaultCert () { return this._h('getVaultCert') }
|
|
146
|
+
onVault (handler) { return this.on('vault', handler) }
|
|
137
147
|
mergeEndorsements (subject, endorsements, askerPubkey) {
|
|
138
148
|
return this._h('mergeEndorsements', { subject, endorsements, askerPubkey })
|
|
139
149
|
}
|
|
@@ -176,4 +186,4 @@ export default Identity
|
|
|
176
186
|
|
|
177
187
|
// Helpers de capacidad SIN clave maestra (lado dispositivo + verificación), para que
|
|
178
188
|
// 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'
|
|
189
|
+
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,65 @@ 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
|
+
// Usa la PROPIA llave de identidad de este navegador como dispositivo: el cert delega
|
|
519
|
+
// TU identidad (P) desde la maestra M → una sola identidad (signData/identify/cert = P).
|
|
520
|
+
let device
|
|
521
|
+
try { const k = JSON.parse(kv.getItem(KEY_STORAGE)); device = { publickey: JSON.stringify(k.publicJwk), privateJwk: k.privateJwk } } catch (_) { device = undefined }
|
|
522
|
+
const res = await remoteEnroll({ qr, device, onChallenge: (c) => emitVault({ phase: 'challenge', deviceId: c.deviceId, sas: c.sas }) })
|
|
523
|
+
kv.setItem(VAULT_DEVICE_STORAGE, JSON.stringify(res.device))
|
|
524
|
+
kv.setItem(VAULT_CERT_STORAGE, JSON.stringify({ cert: res.cert, master: res.master, proxy: res.proxy, deviceId: res.deviceId, pairedAt: Date.now() }))
|
|
525
|
+
emitVault({ phase: 'paired', deviceId: res.deviceId, master: res.master })
|
|
526
|
+
return { ok: true, deviceId: res.deviceId, master: res.master, exp: res.cert.exp, scope: res.cert.scope }
|
|
527
|
+
},
|
|
528
|
+
|
|
529
|
+
async vaultStatus () {
|
|
530
|
+
const v = loadVaultCert()
|
|
531
|
+
if (!v?.cert) return { paired: false }
|
|
532
|
+
return { paired: true, deviceId: v.deviceId, master: v.master, proxy: v.proxy, scope: v.cert.scope, exp: v.cert.exp, pairedAt: v.pairedAt }
|
|
533
|
+
},
|
|
534
|
+
|
|
535
|
+
async vaultUnpair () {
|
|
536
|
+
kv.removeItem(VAULT_DEVICE_STORAGE)
|
|
537
|
+
kv.removeItem(VAULT_CERT_STORAGE)
|
|
538
|
+
emitVault({ phase: 'unpaired' })
|
|
539
|
+
return { ok: true }
|
|
540
|
+
},
|
|
541
|
+
|
|
542
|
+
// Firma DELEGADA: pide a la maestra del vault que firme `payload` (con el cert de
|
|
543
|
+
// este dispositivo). Aditivo y explícito — NO cambia `signData` (que sigue local),
|
|
544
|
+
// así nada se rompe si no estás emparejado o si el vault está apagado.
|
|
545
|
+
async vaultSign ({ payload }) {
|
|
546
|
+
const v = loadVaultCert(); const device = loadVaultDevice()
|
|
547
|
+
if (!v?.cert || !device) throw new Error('este dispositivo no está emparejado con un vault')
|
|
548
|
+
return remoteSign({ master: v.master, proxy: v.proxy, device, cert: v.cert, payload })
|
|
549
|
+
},
|
|
550
|
+
|
|
551
|
+
// Store DELEGADO: lee/escribe el store de hilos+aperturas EN tu vault (con el cert).
|
|
552
|
+
// Reusa el MISMO emparejamiento (no hay un pairing aparte para el store).
|
|
553
|
+
async vaultStore ({ method, args }) {
|
|
554
|
+
const v = loadVaultCert(); const device = loadVaultDevice()
|
|
555
|
+
if (!v?.cert || !device) throw new Error('este dispositivo no está emparejado con un vault')
|
|
556
|
+
return remoteStore({ master: v.master, proxy: v.proxy, device, cert: v.cert, method, args })
|
|
557
|
+
},
|
|
558
|
+
|
|
559
|
+
// Lista (solo lectura) de dispositivos enrolados en tu vault.
|
|
560
|
+
async listVaultDevices () {
|
|
561
|
+
const v = loadVaultCert(); const device = loadVaultDevice()
|
|
562
|
+
if (!v?.cert || !device) throw new Error('este dispositivo no está emparejado con un vault')
|
|
563
|
+
return remoteDevices({ master: v.master, proxy: v.proxy, device, cert: v.cert })
|
|
564
|
+
},
|
|
565
|
+
|
|
566
|
+
// El cert de delegación de este dispositivo (para presentarlo al proxy en `identify`
|
|
567
|
+
// → "una identidad": el proxy bindea tu pubkey también bajo tu maestra M). Sin secretos.
|
|
568
|
+
async getVaultCert () {
|
|
569
|
+
const v = loadVaultCert()
|
|
570
|
+
return v?.cert ? { cert: v.cert, master: v.master } : null
|
|
571
|
+
},
|
|
572
|
+
|
|
504
573
|
async listContacts () {
|
|
505
574
|
return Object.values(loadPeers()).filter(p => p && p.isContact).sort((a, b) => (b.lastSeen || 0) - (a.lastSeen || 0))
|
|
506
575
|
},
|
|
@@ -636,6 +705,7 @@ export async function createIdentityCore ({ kv, peers, makeSync = null }) {
|
|
|
636
705
|
handlers,
|
|
637
706
|
get me () { return me },
|
|
638
707
|
sync,
|
|
639
|
-
onSyncStatus (fn) { if (sync) sync.onStatus(fn) }
|
|
708
|
+
onSyncStatus (fn) { if (sync) sync.onStatus(fn) },
|
|
709
|
+
onVaultEvent (fn) { vaultListeners.add(fn); return () => vaultListeners.delete(fn) }
|
|
640
710
|
}
|
|
641
711
|
}
|
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,131 @@
|
|
|
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, device, 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
|
+
// Por defecto genera una sub-clave nueva; pero el iframe pasa SU PROPIA llave de
|
|
39
|
+
// identidad (P) como `device` → el cert delega tu identidad y hay UNA sola (signData/
|
|
40
|
+
// identify/cert son la misma P).
|
|
41
|
+
const dev = device || await makeDeviceKey({ label })
|
|
42
|
+
const deviceId = (await pubkeyId(dev.publickey)).slice(0, 8).toUpperCase().replace(/(.{4})(.{4})/, '$1-$2')
|
|
43
|
+
const sas = await deriveSAS(qr.iss, dev.publickey, qr.sn)
|
|
44
|
+
const data = { op: 'enroll', dpub: dev.publickey, token: qr.token, sn: qr.sn, label, ts: Date.now() }
|
|
45
|
+
const { signature } = await signWithDevice({ privateJwk: dev.privateJwk, data })
|
|
46
|
+
|
|
47
|
+
const enrolled = new Promise((resolve, reject) => {
|
|
48
|
+
const off = client.on('message', (_from, p) => {
|
|
49
|
+
if (!p || typeof p !== 'object') return
|
|
50
|
+
if (p.type === MSG.ENROLL_CHALLENGE) { try { onChallenge?.({ deviceId, sas }) } catch (_) {} }
|
|
51
|
+
else if (p.type === MSG.ENROLLED) { cleanup(); resolve(p) }
|
|
52
|
+
else if (p.type === MSG.ERROR) { cleanup(); reject(new Error(p.error)) }
|
|
53
|
+
})
|
|
54
|
+
const t = setTimeout(() => { cleanup(); reject(new Error('timeout esperando la aprobación en el vault')) }, approveTimeoutMs)
|
|
55
|
+
const cleanup = () => { off(); clearTimeout(t) }
|
|
56
|
+
})
|
|
57
|
+
client.sendByPubkey(qr.iss, { type: MSG.ENROLL, data, signature })
|
|
58
|
+
const res = await enrolled
|
|
59
|
+
|
|
60
|
+
// Validación estricta antes de guardar (cierra inyección de cert / sustitución de maestra).
|
|
61
|
+
const v = await verifyDelegation({ cert: res.cert, expectedSub: dev.publickey })
|
|
62
|
+
if (!v.ok) throw new Error('cert inválido: ' + v.reason)
|
|
63
|
+
if (res.cert.iss !== qr.iss) throw new Error('cert firmado por una maestra distinta a la que viste')
|
|
64
|
+
if (res.cert.sub !== dev.publickey) throw new Error('cert emitido para otro dispositivo')
|
|
65
|
+
return { device: dev, cert: res.cert, master: qr.iss, proxy: qr.proxy, deviceId }
|
|
66
|
+
} finally { try { client.close() } catch (_) {} }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Pide a la MAESTRA (en el vault del PC) que firme `payload`, adjuntando el cert de
|
|
71
|
+
* delegación de este dispositivo. La maestra nunca sale del vault: vuelve solo la
|
|
72
|
+
* firma. Requiere que el vault esté online.
|
|
73
|
+
* @returns {Promise<{ signature:string, publickey:string }>} publickey = la maestra.
|
|
74
|
+
*/
|
|
75
|
+
export async function requestSign ({ master, proxy, device, cert, payload, timeoutMs = 15000 } = {}) {
|
|
76
|
+
if (!master || !proxy || !device?.privateJwk || !cert) throw new Error('faltan datos de emparejamiento')
|
|
77
|
+
const { WebSocketProxyClient } = await import('@dotrino/proxy-client')
|
|
78
|
+
const client = new WebSocketProxyClient({ url: proxy, enableWebRTC: false, autoReconnect: false })
|
|
79
|
+
await client.connect()
|
|
80
|
+
try {
|
|
81
|
+
const data = { op: 'sign', payload, publickey: device.publickey, ts: Date.now() }
|
|
82
|
+
const { signature } = await signWithDevice({ privateJwk: device.privateJwk, data })
|
|
83
|
+
const pending = new Promise((resolve, reject) => {
|
|
84
|
+
const off = client.on('message', (_f, p) => {
|
|
85
|
+
if (!p || typeof p !== 'object') return
|
|
86
|
+
if (p.type === 'vault.signed') { cleanup(); resolve(p) }
|
|
87
|
+
else if (p.type === 'vault.error') { cleanup(); reject(new Error(p.error)) }
|
|
88
|
+
})
|
|
89
|
+
const t = setTimeout(() => { cleanup(); reject(new Error('el vault no respondió (¿está encendido?)')) }, timeoutMs)
|
|
90
|
+
const cleanup = () => { off(); clearTimeout(t) }
|
|
91
|
+
})
|
|
92
|
+
client.sendByPubkey(master, { type: 'vault.sign', data, signature, cert })
|
|
93
|
+
const res = await pending
|
|
94
|
+
return { signature: res.signature, publickey: res.publickey }
|
|
95
|
+
} finally { try { client.close() } catch (_) {} }
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Helper genérico: una RPC al vault firmada por D + cert, esperando `okType`. */
|
|
99
|
+
async function vaultRpc ({ master, proxy, device, cert, sendType, okType, data, timeoutMs = 15000 }) {
|
|
100
|
+
if (!master || !proxy || !device?.privateJwk || !cert) throw new Error('faltan datos de emparejamiento')
|
|
101
|
+
const { WebSocketProxyClient } = await import('@dotrino/proxy-client')
|
|
102
|
+
const client = new WebSocketProxyClient({ url: proxy, enableWebRTC: false, autoReconnect: false })
|
|
103
|
+
await client.connect()
|
|
104
|
+
try {
|
|
105
|
+
const signed = { ...data, publickey: device.publickey, ts: Date.now() }
|
|
106
|
+
const { signature } = await signWithDevice({ privateJwk: device.privateJwk, data: signed })
|
|
107
|
+
const pending = new Promise((resolve, reject) => {
|
|
108
|
+
const off = client.on('message', (_f, p) => {
|
|
109
|
+
if (!p || typeof p !== 'object') return
|
|
110
|
+
if (p.type === okType) { cleanup(); resolve(p) }
|
|
111
|
+
else if (p.type === 'vault.error') { cleanup(); reject(new Error(p.error)) }
|
|
112
|
+
})
|
|
113
|
+
const t = setTimeout(() => { cleanup(); reject(new Error('el vault no respondió (¿está encendido?)')) }, timeoutMs)
|
|
114
|
+
const cleanup = () => { off(); clearTimeout(t) }
|
|
115
|
+
})
|
|
116
|
+
client.sendByPubkey(master, { type: sendType, data: signed, signature, cert })
|
|
117
|
+
return await pending
|
|
118
|
+
} finally { try { client.close() } catch (_) {} }
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Lee/escribe el store de hilos+aperturas EN el vault (con el cert del dispositivo). */
|
|
122
|
+
export async function requestStore ({ master, proxy, device, cert, method, args } = {}) {
|
|
123
|
+
const res = await vaultRpc({ master, proxy, device, cert, sendType: 'vault.store', okType: 'vault.store.result', data: { op: 'store', method, args: args || {} } })
|
|
124
|
+
return res.result
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Lista (solo lectura) los dispositivos enrolados en tu vault. */
|
|
128
|
+
export async function requestDevices ({ master, proxy, device, cert } = {}) {
|
|
129
|
+
const res = await vaultRpc({ master, proxy, device, cert, sendType: 'vault.devices', okType: 'vault.devices.result', data: { op: 'devices' } })
|
|
130
|
+
return { devices: res.devices || [], revoked: res.revoked || [] }
|
|
131
|
+
}
|
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,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
|
+
}
|