@dotrino/identity 0.21.0 → 0.22.1
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 +1 -1
- package/package.json +1 -1
- package/src/index.d.ts +21 -1
- package/src/index.js +27 -0
- package/vault/index.html +5 -1
- package/vault/vault.js +145 -1
- package/vault/vendor/vault/VERSION.txt +5 -0
- package/vault/vendor/vault/index.js +251 -0
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @dotrino/identity
|
|
2
2
|
|
|
3
|
-
> **Parte del ecosistema [Dotrino](https://dotrino.com).**
|
|
3
|
+
> **Parte del ecosistema [Dotrino](https://dotrino.com).** Dotrino es un ecosistema de aplicaciones centradas en la privacidad de los datos: tu información es tuya, y las decisiones sobre ella también — qué compartes, con quién, cuándo y por qué. Sin anuncios, sin cookies, sin rastreo de datos, sin vender tu identidad a nadie.
|
|
4
4
|
|
|
5
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`.
|
|
6
6
|
|
package/package.json
CHANGED
package/src/index.d.ts
CHANGED
|
@@ -155,7 +155,27 @@ export class Identity {
|
|
|
155
155
|
syncStatus (): Promise<SyncStatus>
|
|
156
156
|
syncNow (): Promise<SyncStatus>
|
|
157
157
|
onSync (handler: (event: SyncEvent) => void): () => void
|
|
158
|
-
|
|
158
|
+
// vault (este dispositivo es cliente de un vault externo)
|
|
159
|
+
enrollDevice (qr: string): Promise<any>
|
|
160
|
+
vaultStatus (): Promise<any>
|
|
161
|
+
vaultUnpair (): Promise<any>
|
|
162
|
+
vaultSign (payload: any): Promise<{ signature: string; publickey: string }>
|
|
163
|
+
vaultStore (method: string, args?: any): Promise<any>
|
|
164
|
+
listVaultDevices (): Promise<{ devices: any[]; revoked: any[] }>
|
|
165
|
+
getVaultCert (): Promise<any>
|
|
166
|
+
onVault (handler: (payload: any) => void): () => void
|
|
167
|
+
// self-vault (este dispositivo ES el vault, daemon dentro del iframe)
|
|
168
|
+
selfVaultStatus (): Promise<{ enabled: boolean; running: boolean }>
|
|
169
|
+
setSelfVault (enabled: boolean): Promise<{ ok: true; enabled: boolean }>
|
|
170
|
+
selfVaultPairing (opts?: any): Promise<any>
|
|
171
|
+
selfVaultPending (): Promise<any[]>
|
|
172
|
+
selfVaultMachines (): Promise<any[]>
|
|
173
|
+
selfVaultApprove (deviceId: string, code: string): Promise<any>
|
|
174
|
+
selfVaultReject (deviceId: string): Promise<{ ok: true }>
|
|
175
|
+
selfVaultRevoke (nonce: string): Promise<any>
|
|
176
|
+
selfVaultProbe (pubkeys: string[]): Promise<{ online: string[] }>
|
|
177
|
+
onSelfVault (handler: (payload: any) => void): () => void
|
|
178
|
+
on (event: 'peer_updated' | 'me_updated' | 'sync' | 'vault' | 'selfVault', handler: (payload: any) => void): () => void
|
|
159
179
|
}
|
|
160
180
|
|
|
161
181
|
export interface SyncStatus {
|
package/src/index.js
CHANGED
|
@@ -318,6 +318,33 @@ export class Identity {
|
|
|
318
318
|
return this.on('vault', handler)
|
|
319
319
|
}
|
|
320
320
|
|
|
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.
|
|
327
|
+
/** { enabled, running }: si el modo self está activado y si esta pestaña sostiene el daemon. */
|
|
328
|
+
async selfVaultStatus () { return this._call('selfVaultStatus') }
|
|
329
|
+
/** Activa/desactiva el modo self-vault en este dispositivo. */
|
|
330
|
+
async setSelfVault (enabled) { return this._call('setSelfVault', { enabled }) }
|
|
331
|
+
/** Genera un código de emparejamiento + QR para enlazar otra máquina. Sólo sirve desde la pestaña activa. */
|
|
332
|
+
async selfVaultPairing (opts) { return this._call('selfVaultPairing', opts || {}, 60000) }
|
|
333
|
+
/** Lista de solicitudes de emparejamiento pendientes de aprobar. */
|
|
334
|
+
async selfVaultPending () { return this._call('selfVaultPending') }
|
|
335
|
+
/** Máquinas/agentes enrolados (delegaciones vigentes con scope vault:sign). */
|
|
336
|
+
async selfVaultMachines () { return this._call('selfVaultMachines') }
|
|
337
|
+
/** Aprueba una solicitud de emparejamiento comparando el código SAS. */
|
|
338
|
+
async selfVaultApprove (deviceId, code) { return this._call('selfVaultApprove', { deviceId, code }) }
|
|
339
|
+
/** Rechaza una solicitud de emparejamiento pendiente. */
|
|
340
|
+
async selfVaultReject (deviceId) { return this._call('selfVaultReject', { deviceId }) }
|
|
341
|
+
/** Revoca una máquina/agente enrolado por nonce de delegación. */
|
|
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) }
|
|
345
|
+
/** Suscribe a eventos del self-vault ('selfVault'): { running?, pending?, error? }. */
|
|
346
|
+
onSelfVault (handler) { return this.on('selfVault', handler) }
|
|
347
|
+
|
|
321
348
|
// ----- multi-perfil por dispositivo -----
|
|
322
349
|
// Podés tener varios perfiles (identidades) en el mismo navegador, cada uno conectado o no
|
|
323
350
|
// a su propio vault. Crear/cambiar setea el perfil activo; la app RECARGA la página y toma
|
package/vault/index.html
CHANGED
|
@@ -22,7 +22,11 @@
|
|
|
22
22
|
<!-- Transporte self-hosted (vendorizado): el emparejamiento de dispositivos importa
|
|
23
23
|
@dotrino/proxy-client de forma perezosa; este import map lo resuelve sin CDN. -->
|
|
24
24
|
<script type="importmap">
|
|
25
|
-
{ "imports": {
|
|
25
|
+
{ "imports": {
|
|
26
|
+
"@dotrino/proxy-client": "./vendor/proxy-client/index.js",
|
|
27
|
+
"@dotrino/vault": "./vendor/vault/index.js",
|
|
28
|
+
"@dotrino/identity/capabilities": "./capabilities.js"
|
|
29
|
+
} }
|
|
26
30
|
</script>
|
|
27
31
|
<script type="module" src="./vault.js"></script>
|
|
28
32
|
</body>
|
package/vault/vault.js
CHANGED
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
initPeerStorage, loadPeers, savePeers, setPeersDirect, upsertPeer, onDirty
|
|
14
14
|
} from './peerStore.js'
|
|
15
15
|
import { createIdentityCore } from './core.js'
|
|
16
|
+
import { pubkeyId } from './capabilities.js'
|
|
16
17
|
|
|
17
18
|
;(async () => {
|
|
18
19
|
// kv estilo localStorage (síncrono) para me, nonces, delegaciones, certs.
|
|
@@ -97,6 +98,147 @@ import { createIdentityCore } from './core.js'
|
|
|
97
98
|
core.onSyncStatus((p) => broadcast('sync', p))
|
|
98
99
|
core.onVaultEvent((p) => broadcast('vault', p))
|
|
99
100
|
|
|
101
|
+
// ---- Modo SELF: este navegador actúa como bóveda (daemon device-vault) ----
|
|
102
|
+
// startDeviceVault convierte la identidad P en CA: atiende enrolamientos y consultas
|
|
103
|
+
// de revocación por el proxy. Solo UN iframe por origin es el daemon activo
|
|
104
|
+
// (navigator.locks): la pestaña VISIBLE sostiene el lock; al pasar a background lo
|
|
105
|
+
// libera y otra pestaña visible lo toma. Así varias apps abiertas no compiten.
|
|
106
|
+
const SELF_FLAG = 'dotrino.self-vault.enabled' // persistido en localStorage (kv)
|
|
107
|
+
const SELF_LOCK = 'dotrino-self-vault'
|
|
108
|
+
let daemon = null // handle de startDeviceVault cuando ESTE iframe es el activo
|
|
109
|
+
let _lockResolver = null // resolver del callback del lock (libera al resolverlo)
|
|
110
|
+
|
|
111
|
+
// Adaptador: startDeviceVault exige identity.{me.publickey, signData, signDelegation,
|
|
112
|
+
// listDelegations, revokeDelegation}; el core los expone vía handlers + getter me.
|
|
113
|
+
const selfIdentity = {
|
|
114
|
+
get me () { return core.me },
|
|
115
|
+
signData: (data) => handlers.signData({ data }),
|
|
116
|
+
signDelegation: (sub, scope, opts) => handlers.signDelegation({ sub, scope, ...(opts || {}) }),
|
|
117
|
+
listDelegations: () => handlers.listDelegations({}),
|
|
118
|
+
revokeDelegation: (nonce) => handlers.revokeDelegation({ nonce })
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function startSelfDaemon () {
|
|
122
|
+
if (daemon) return
|
|
123
|
+
try {
|
|
124
|
+
// Import dinámico: aísla fallos del vendor del arranque del vault (cargado por
|
|
125
|
+
// todas las apps). El import map de index.html resuelve @dotrino/vault.
|
|
126
|
+
const { startDeviceVault } = await import('@dotrino/vault')
|
|
127
|
+
daemon = await startDeviceVault(selfIdentity)
|
|
128
|
+
daemon.onPendingChange(() => broadcast('selfVault', { pending: daemon.listPending() }))
|
|
129
|
+
broadcast('selfVault', { running: true })
|
|
130
|
+
} catch (e) { daemon = null; broadcast('selfVault', { error: e?.message || String(e) }) }
|
|
131
|
+
}
|
|
132
|
+
function stopSelfDaemon () {
|
|
133
|
+
if (!daemon) return
|
|
134
|
+
try { daemon.close() } catch {}
|
|
135
|
+
daemon = null
|
|
136
|
+
broadcast('selfVault', { running: false })
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Adquiere el lock solo si el modo self está activado Y la pestaña es visible.
|
|
140
|
+
function holdSelfLock () {
|
|
141
|
+
if (!navigator.locks) return
|
|
142
|
+
if (kv.getItem(SELF_FLAG) !== '1' || document.visibilityState !== 'visible') return
|
|
143
|
+
navigator.locks.request(SELF_LOCK, { mode: 'exclusive', ifAvailable: true }, async (lock) => {
|
|
144
|
+
if (!lock) return // otra pestaña visible lo tiene
|
|
145
|
+
await startSelfDaemon()
|
|
146
|
+
await new Promise((resolve) => { _lockResolver = resolve }) // mantener el lock
|
|
147
|
+
stopSelfDaemon()
|
|
148
|
+
}).catch(() => {})
|
|
149
|
+
}
|
|
150
|
+
function releaseSelfLock () { if (_lockResolver) { _lockResolver(); _lockResolver = null } }
|
|
151
|
+
|
|
152
|
+
document.addEventListener('visibilitychange', () => {
|
|
153
|
+
if (document.visibilityState === 'visible') holdSelfLock()
|
|
154
|
+
else releaseSelfLock()
|
|
155
|
+
})
|
|
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
|
+
|
|
185
|
+
// Handlers de UI (emparejamiento/gestión) expuestos por postMessage. Las ACCIONES
|
|
186
|
+
// (pairing/approve) requieren que ESTE iframe sea el daemon activo (la pestaña visible);
|
|
187
|
+
// la lectura (máquinas/pending) siempre funciona (lee delegaciones persistidas).
|
|
188
|
+
const selfHandlers = {
|
|
189
|
+
// En modo self, listar máquinas enroladas lee LOCAL (listDelegations) en vez de
|
|
190
|
+
// hacer RPC al daemon del PC: somos nuestra propia maestra. Así ia/terminal listan
|
|
191
|
+
// agentes siempre, sin depender de qué pestaña sostenga el lock del daemon.
|
|
192
|
+
listVaultDevices: async () => {
|
|
193
|
+
if (kv.getItem(SELF_FLAG) !== '1') return handlers.listVaultDevices({})
|
|
194
|
+
const { issued, revoked } = await handlers.listDelegations({})
|
|
195
|
+
const now = Date.now()
|
|
196
|
+
const bySub = new Map()
|
|
197
|
+
for (const x of (issued || [])) {
|
|
198
|
+
if (!x.sub || x.revokedAt || (x.exp && x.exp <= now)) continue
|
|
199
|
+
if (!Array.isArray(x.scope) || !x.scope.includes('vault:sign')) continue
|
|
200
|
+
if (!bySub.has(x.sub) || (x.exp || 0) > (bySub.get(x.sub).exp || 0)) bySub.set(x.sub, x)
|
|
201
|
+
}
|
|
202
|
+
const devices = await Promise.all([...bySub.values()].map(async (x) => ({
|
|
203
|
+
deviceId: (await pubkeyId(x.sub)).slice(0, 8).toUpperCase().replace(/(.{4})(.{4})/, '$1-$2'),
|
|
204
|
+
sub: x.sub, label: x.label || '', scope: x.scope, exp: x.exp, nonce: x.nonce
|
|
205
|
+
})))
|
|
206
|
+
return { devices, revoked: (revoked || []).map((r) => r.nonce || r) }
|
|
207
|
+
},
|
|
208
|
+
selfVaultStatus: async () => ({ enabled: kv.getItem(SELF_FLAG) === '1', running: !!daemon }),
|
|
209
|
+
setSelfVault: async ({ enabled }) => {
|
|
210
|
+
kv.setItem(SELF_FLAG, enabled ? '1' : '0')
|
|
211
|
+
if (enabled) holdSelfLock(); else releaseSelfLock()
|
|
212
|
+
return { ok: true, enabled: !!enabled }
|
|
213
|
+
},
|
|
214
|
+
selfVaultPairing: async (opts) => {
|
|
215
|
+
if (!daemon) throw new Error('esta pestaña no es la bóveda activa; ábrela como pestaña visible')
|
|
216
|
+
return daemon.startPairing(opts)
|
|
217
|
+
},
|
|
218
|
+
selfVaultPending: async () => (daemon ? daemon.listPending() : []),
|
|
219
|
+
selfVaultMachines: async () => {
|
|
220
|
+
if (daemon) return daemon.listMachines()
|
|
221
|
+
const { issued } = await handlers.listDelegations({})
|
|
222
|
+
return issued || []
|
|
223
|
+
},
|
|
224
|
+
selfVaultApprove: async ({ deviceId, code }) => {
|
|
225
|
+
if (!daemon) throw new Error('esta pestaña no es la bóveda activa')
|
|
226
|
+
return daemon.approve(deviceId, code)
|
|
227
|
+
},
|
|
228
|
+
selfVaultReject: async ({ deviceId }) => {
|
|
229
|
+
if (!daemon) throw new Error('esta pestaña no es la bóveda activa')
|
|
230
|
+
daemon.reject(deviceId)
|
|
231
|
+
return { ok: true }
|
|
232
|
+
},
|
|
233
|
+
selfVaultRevoke: async ({ nonce }) => {
|
|
234
|
+
if (daemon) return daemon.revoke(nonce)
|
|
235
|
+
return handlers.revokeDelegation({ nonce })
|
|
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 || []))] })
|
|
240
|
+
}
|
|
241
|
+
|
|
100
242
|
window.addEventListener('message', async (event) => {
|
|
101
243
|
const msg = event.data
|
|
102
244
|
if (!msg || msg._cci !== true || msg.type !== 'request') return
|
|
@@ -107,7 +249,7 @@ import { createIdentityCore } from './core.js'
|
|
|
107
249
|
{ _cci: true, type: 'response', id, ...payload },
|
|
108
250
|
event.origin
|
|
109
251
|
)
|
|
110
|
-
const handler = handlers[method]
|
|
252
|
+
const handler = selfHandlers[method] || handlers[method]
|
|
111
253
|
if (!handler) return reply({ error: `Unknown method: ${method}` })
|
|
112
254
|
try {
|
|
113
255
|
const result = await handler(params || {})
|
|
@@ -135,4 +277,6 @@ import { createIdentityCore } from './core.js'
|
|
|
135
277
|
window.parent.postMessage({ _cci: true, type: 'ready' }, '*')
|
|
136
278
|
}
|
|
137
279
|
}
|
|
280
|
+
// Si el modo self-vault ya estaba activado, intentar tomar el lock (pestaña visible).
|
|
281
|
+
holdSelfLock()
|
|
138
282
|
})()
|
|
@@ -0,0 +1,5 @@
|
|
|
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.
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @dotrino/vault — "este dispositivo es una bóveda" (lado SERVIDOR, browser+node).
|
|
3
|
+
*
|
|
4
|
+
* Convierte la identidad de ESTE dispositivo (`@dotrino/identity`, la clave P) en una
|
|
5
|
+
* bóveda/CA: atiende el MISMO protocolo de enrolamiento endurecido que el daemon
|
|
6
|
+
* `dotrino-vault` (`vault.enroll` → `vault.enroll.challenge` → `vault.enrolled`) por el
|
|
7
|
+
* proxy del ecosistema, firma certificados de delegación `D ← P` al aprobar, y responde
|
|
8
|
+
* consultas de revocación (`vault.devices`). Así CUALQUIER app (no solo la terminal)
|
|
9
|
+
* puede dejar que el usuario use su dispositivo como bóveda, sin un PC con el daemon.
|
|
10
|
+
*
|
|
11
|
+
* Modelo de aprobación SEGURO (idéntico al daemon `dotrino-vault#approveDevice`):
|
|
12
|
+
* - El DISPOSITIVO que se enrola (p. ej. `@dotrino/identity#enrollDevice`) genera un
|
|
13
|
+
* código ALEATORIO (`makePairingCode`) y lo MUESTRA; NO lo envía por la red.
|
|
14
|
+
* - Esta bóveda NO conoce el código: un humano lo LEE del dispositivo y lo TIPEA aquí.
|
|
15
|
+
* - Al aprobar, la bóveda firma el cert y ECHA el código tipeado de vuelta.
|
|
16
|
+
* - El dispositivo acepta el cert SOLO si el código echado coincide con el que generó.
|
|
17
|
+
* → una bóveda falsa (que nunca vio el código) no puede enrolar el dispositivo, y
|
|
18
|
+
* aprobar "a ciegas" (sin ir a leer el código del dispositivo) tampoco enrola nada.
|
|
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.
|
|
23
|
+
*/
|
|
24
|
+
import { verifyDeviceSig, verifyChain, pubkeyId } from '@dotrino/identity/capabilities'
|
|
25
|
+
|
|
26
|
+
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
|
+
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
|
+
|
|
32
|
+
const MSG = {
|
|
33
|
+
ENROLL: 'vault.enroll',
|
|
34
|
+
ENROLL_CHALLENGE: 'vault.enroll.challenge',
|
|
35
|
+
ENROLLED: 'vault.enrolled',
|
|
36
|
+
DEVICES: 'vault.devices',
|
|
37
|
+
DEVICES_RESULT: 'vault.devices.result',
|
|
38
|
+
REVOKED: 'vault.revoked',
|
|
39
|
+
ERROR: 'vault.error'
|
|
40
|
+
}
|
|
41
|
+
|
|
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
|
+
/** 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
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Levanta la bóveda de este dispositivo: se conecta al proxy identificado como P y
|
|
54
|
+
* atiende enrolamientos + consultas de revocación de los dispositivos que se enrolan.
|
|
55
|
+
*
|
|
56
|
+
* @param {object} identity instancia de `@dotrino/identity` (P): expone
|
|
57
|
+
* `me.publickey`, `signData`, `signDelegation`, `listDelegations`, `revokeDelegation`.
|
|
58
|
+
* @param {object} [opts]
|
|
59
|
+
* @param {string} [opts.proxyUrl='wss://proxy.dotrino.com']
|
|
60
|
+
* @returns {Promise<object>} handle: { iss, proxy, client, startPairing, approve, reject,
|
|
61
|
+
* listPending, listMachines, revoke, getSelfCert, onPendingChange, close }
|
|
62
|
+
*/
|
|
63
|
+
export async function startDeviceVault (identity, { proxyUrl } = {}) {
|
|
64
|
+
const iss = identity.me?.publickey
|
|
65
|
+
if (!iss) throw new Error('sin identidad: crea/desbloquea tu identidad antes de usar el dispositivo como bóveda')
|
|
66
|
+
const proxy = proxyUrl || 'wss://proxy.dotrino.com'
|
|
67
|
+
|
|
68
|
+
// ----- self-cert P ← P (para que este dispositivo pueda además actuar de cliente
|
|
69
|
+
// de sus propias máquinas: lo firma la propia P y verifyChain lo acepta) -----
|
|
70
|
+
let _selfCert = null
|
|
71
|
+
const getSelfCert = async () => {
|
|
72
|
+
if (_selfCert && _selfCert.exp > Date.now() + 60_000) return _selfCert
|
|
73
|
+
const { cert } = await identity.signDelegation(iss, SIGN_SCOPE, { ttlMs: SELFCERT_TTL_MS })
|
|
74
|
+
_selfCert = cert
|
|
75
|
+
return cert
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const { WebSocketProxyClient } = await import('@dotrino/proxy-client')
|
|
79
|
+
const client = new WebSocketProxyClient({
|
|
80
|
+
url: proxy, enableWebRTC: false, autoReconnect: true,
|
|
81
|
+
maxReconnectAttempts: 100000, reconnectDelay: 4000
|
|
82
|
+
})
|
|
83
|
+
await client.connect()
|
|
84
|
+
|
|
85
|
+
const selfCert = await getSelfCert()
|
|
86
|
+
const identify = async () => {
|
|
87
|
+
if (!client.token) return
|
|
88
|
+
const data = { op: 'identify', publickey: iss, token: client.token, ts: Date.now() }
|
|
89
|
+
const { signature } = await identity.signData(data)
|
|
90
|
+
await client.identify({ data, signature, cert: selfCert })
|
|
91
|
+
}
|
|
92
|
+
await identify()
|
|
93
|
+
client.on('token', () => identify().catch(() => {}))
|
|
94
|
+
|
|
95
|
+
const send = (to, obj) => { try { client.send(to, obj) } catch (_) {} }
|
|
96
|
+
|
|
97
|
+
// token -> { exp, sn, scope, ttlMs, label, state, dpub?, deviceId?, from? }
|
|
98
|
+
const pending = new Map()
|
|
99
|
+
let _onPendingChange = () => {}
|
|
100
|
+
|
|
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
|
+
}
|
|
140
|
+
|
|
141
|
+
// Consulta de revocaciones (igual que `vault.devices` del daemon): responde la lista
|
|
142
|
+
// de dispositivos enrolados + revocados para que el dispositivo refresque su set. Y si
|
|
143
|
+
// QUIEN consulta es una máquina ya revocada (reapareció), le re-emite el REVOKED firmado.
|
|
144
|
+
async function handleDevices (from, p) {
|
|
145
|
+
const d = p?.data
|
|
146
|
+
if (!d || !p.signature || !p.cert) return send(from, { type: MSG.ERROR, error: 'petición inválida' })
|
|
147
|
+
if (typeof d.ts !== 'number' || Math.abs(Date.now() - d.ts) > FRESH_WINDOW_MS) return
|
|
148
|
+
const chk = await verifyChain({ data: d, signature: p.signature, cert: p.cert, trustedIssuer: iss })
|
|
149
|
+
if (!chk.ok) return send(from, { type: MSG.ERROR, error: 'no autorizado: ' + chk.reason })
|
|
150
|
+
const { issued, revoked } = await identity.listDelegations()
|
|
151
|
+
const devices = await Promise.all((issued || []).map(async (x) => ({
|
|
152
|
+
deviceId: x.sub ? await deviceIdOf(x.sub) : null, sub: x.sub || null,
|
|
153
|
+
label: x.label || '', scope: x.scope, exp: x.exp, nonce: x.nonce
|
|
154
|
+
})))
|
|
155
|
+
send(from, { type: MSG.DEVICES_RESULT, devices, revoked: (revoked || []).map((r) => r.nonce || r) })
|
|
156
|
+
// ¿el que consulta es una máquina revocada que reapareció? → re-emite el REVOKED firmado.
|
|
157
|
+
const mine = (issued || []).find((x) => x.sub === chk.device && x.revokedAt)
|
|
158
|
+
if (mine) emitRevoke(chk.device, mine.nonce)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
client.on('message', (_from, p) => {
|
|
162
|
+
if (!p || typeof p !== 'object') return
|
|
163
|
+
if (p.type === MSG.ENROLL) handleEnroll(_from, p).catch(() => {})
|
|
164
|
+
else if (p.type === MSG.DEVICES) handleDevices(_from, p).catch(() => {})
|
|
165
|
+
})
|
|
166
|
+
|
|
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
|
+
/**
|
|
215
|
+
* Máquinas enroladas bajo esta identidad (P), vigentes, con scope de firma y label
|
|
216
|
+
* propio (excluye navegadores enrolados con label 'cli', que no atienden peticiones).
|
|
217
|
+
*/
|
|
218
|
+
async function listMachines () {
|
|
219
|
+
const { issued } = await identity.listDelegations()
|
|
220
|
+
const now = Date.now()
|
|
221
|
+
const bySub = new Map()
|
|
222
|
+
for (const x of (issued || [])) {
|
|
223
|
+
if (!x.sub || x.revokedAt || (x.exp && x.exp <= now)) continue // revocada = fuera de la lista
|
|
224
|
+
if (!Array.isArray(x.scope) || !x.scope.includes(SIGN_SCOPE)) continue
|
|
225
|
+
if (!x.label || x.label === 'cli') continue
|
|
226
|
+
if (!bySub.has(x.sub) || (x.exp || 0) > (bySub.get(x.sub).exp || 0)) bySub.set(x.sub, x)
|
|
227
|
+
}
|
|
228
|
+
return Promise.all([...bySub.values()].map(async (x) => ({ ...x, deviceId: await deviceIdOf(x.sub) })))
|
|
229
|
+
}
|
|
230
|
+
|
|
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
|
+
return {
|
|
243
|
+
iss, proxy, client,
|
|
244
|
+
startPairing, approve, reject, listPending, listMachines, revoke,
|
|
245
|
+
getSelfCert,
|
|
246
|
+
onPendingChange (fn) { _onPendingChange = fn || (() => {}) },
|
|
247
|
+
close () { try { client.close() } catch (_) {} }
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
export default { startDeviceVault, deviceIdOf }
|