@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  # @dotrino/identity
2
2
 
3
- > **Parte del ecosistema [Dotrino](https://dotrino.com).** Misión: aplicaciones que resuelven problemas comunes, respetando tu privacidadsin anuncios, sin cookies, sin rastreo de datos, sin vender tu identidad a nadie.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotrino/identity",
3
- "version": "0.21.0",
3
+ "version": "0.22.1",
4
4
  "description": "Identidad y rating de usuarios compartidos entre apps de Dotrino (vault iframe + postMessage)",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
package/src/index.d.ts CHANGED
@@ -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
- on (event: 'peer_updated' | 'me_updated' | 'sync', handler: (payload: any) => void): () => void
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": { "@dotrino/proxy-client": "./vendor/proxy-client/index.js" } }
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 }