@dotrino/identity 0.20.0 → 0.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,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.20.0",
3
+ "version": "0.22.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",
@@ -17,7 +17,11 @@
17
17
  "./capabilities": {
18
18
  "import": "./vault/capabilities.js"
19
19
  },
20
- "./vault/core.js": "./vault/core.js"
20
+ "./avatar": {
21
+ "import": "./vault/avatar.js"
22
+ },
23
+ "./vault/core.js": "./vault/core.js",
24
+ "./vault/remote.js": "./vault/remote.js"
21
25
  },
22
26
  "files": [
23
27
  "src",
package/src/index.d.ts CHANGED
@@ -155,7 +155,26 @@ 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
+ onSelfVault (handler: (payload: any) => void): () => void
177
+ on (event: 'peer_updated' | 'me_updated' | 'sync' | 'vault' | 'selfVault', handler: (payload: any) => void): () => void
159
178
  }
160
179
 
161
180
  export interface SyncStatus {
package/src/index.js CHANGED
@@ -318,6 +318,31 @@ 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
+ /** Suscribe a eventos del self-vault ('selfVault'): { running?, pending?, error? }. */
344
+ onSelfVault (handler) { return this.on('selfVault', handler) }
345
+
321
346
  // ----- multi-perfil por dispositivo -----
322
347
  // Podés tener varios perfiles (identidades) en el mismo navegador, cada uno conectado o no
323
348
  // a su propio vault. Crear/cambiar setea el perfil activo; la app RECARGA la página y toma
@@ -0,0 +1,65 @@
1
+ /**
2
+ * @dotrino/identity/avatar — el AVATAR identicon, solo.
3
+ *
4
+ * Vive aparte de `capabilities.js` a propósito: es una función pura de ~30
5
+ * líneas, sin dependencias, sin crypto y sin vault, pero `capabilities.js`
6
+ * importa `core.js` (55 KB) y `core.js` importa de vuelta `capabilities.js`
7
+ * (dependencia circular). O sea que quien solo quería el identicon —el topbar,
8
+ * la tarjeta de perfil— se arrastraba el vault entero, o dependía de que el
9
+ * bundler podara `core.js` de milagro (ningún paquete declara `sideEffects`).
10
+ *
11
+ * Este subpath da la garantía: importar el avatar cuesta el avatar.
12
+ *
13
+ * NO copies estas funciones dentro de tu app. El identicon es DETERMINISTA a
14
+ * partir del pubkey: si cada app llevara su copia, el mismo usuario podría
15
+ * derivar un avatar distinto según dónde lo miren.
16
+ *
17
+ * `capabilities.js` re-exporta de aquí, así que los importadores viejos siguen
18
+ * funcionando igual.
19
+ */
20
+
21
+ /** Hash FNV-1a + xorshift. Sin necesidades de seguridad: es decorativo. */
22
+ function _hashSeed (s) {
23
+ let h = 2166136261 >>> 0
24
+ for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 16777619) }
25
+ const bytes = []
26
+ let x = (h ^ 0x9e3779b9) >>> 0
27
+ for (let i = 0; i < 16; i++) { x ^= x << 13; x ^= x >>> 17; x ^= x << 5; x >>>= 0; bytes.push(x & 0xff) }
28
+ return { h: h >>> 0, bytes }
29
+ }
30
+
31
+ /**
32
+ * Identicon determinista de una semilla (normalmente el pubkey): así cada perfil
33
+ * nace con imagen sin que el usuario tenga que subir nada. Síncrono → usable
34
+ * directo en plantillas. Rejilla 5×5 simétrica sobre una "moneda" redondeada,
35
+ * con color derivado del hash.
36
+ */
37
+ export function avatarSvg (seed, { size = 80 } = {}) {
38
+ const { h, bytes } = _hashSeed(String(seed || 'dotrino'))
39
+ const hue = h % 360
40
+ const hue2 = (hue + 40) % 360
41
+ const fg = `hsl(${hue} 62% 46%)`
42
+ const bg1 = `hsl(${hue} 48% 95%)`
43
+ const bg2 = `hsl(${hue2} 48% 90%)`
44
+ const cells = 5
45
+ const unit = size / cells
46
+ let rects = ''
47
+ for (let col = 0; col < 3; col++) {
48
+ for (let row = 0; row < cells; row++) {
49
+ if (!(bytes[col * cells + row] & 1)) continue
50
+ for (const c of (col === 2 ? [2] : [col, cells - 1 - col])) {
51
+ rects += `<rect x="${(c * unit).toFixed(2)}" y="${(row * unit).toFixed(2)}" width="${unit.toFixed(2)}" height="${unit.toFixed(2)}"/>`
52
+ }
53
+ }
54
+ }
55
+ const id = 'g' + (h % 100000)
56
+ return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${size} ${size}" width="${size}" height="${size}">` +
57
+ `<defs><linearGradient id="${id}" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="${bg1}"/><stop offset="1" stop-color="${bg2}"/></linearGradient></defs>` +
58
+ `<rect width="${size}" height="${size}" rx="${(size * 0.5).toFixed(2)}" fill="url(#${id})"/>` +
59
+ `<g fill="${fg}" transform="translate(${(size * 0.12).toFixed(2)} ${(size * 0.12).toFixed(2)}) scale(0.76)">${rects}</g></svg>`
60
+ }
61
+
62
+ /** El avatar como data-URI listo para `<img src>` o `background-image`. */
63
+ export function avatarDataUri (seed, opts) {
64
+ return 'data:image/svg+xml,' + encodeURIComponent(avatarSvg(seed, opts))
65
+ }
@@ -99,49 +99,11 @@ export async function commitCode ({ code, dpub, sn }) {
99
99
  return [...new Uint8Array(h)].map((x) => x.toString(16).padStart(2, '0')).join('')
100
100
  }
101
101
 
102
- /**
103
- * Avatar generado (identicon de Dotrino): SVG DETERMINISTA a partir de una semilla
104
- * (típicamente el pubkey del perfil) cada perfil/identidad nace con imagen, sin que el
105
- * usuario tenga que subir nada. Síncrono (hash FNV-1a + xorshift, sin necesidades de
106
- * seguridad: es solo decorativo) → usable directo en plantillas. Rejilla 5×5 simétrica
107
- * sobre una "moneda" redondeada, con color derivado del hash.
108
- */
109
- function _hashSeed (s) {
110
- let h = 2166136261 >>> 0
111
- for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 16777619) }
112
- const bytes = []
113
- let x = (h ^ 0x9e3779b9) >>> 0
114
- for (let i = 0; i < 16; i++) { x ^= x << 13; x ^= x >>> 17; x ^= x << 5; x >>>= 0; bytes.push(x & 0xff) }
115
- return { h: h >>> 0, bytes }
116
- }
117
- export function avatarSvg (seed, { size = 80 } = {}) {
118
- const { h, bytes } = _hashSeed(String(seed || 'dotrino'))
119
- const hue = h % 360
120
- const hue2 = (hue + 40) % 360
121
- const fg = `hsl(${hue} 62% 46%)`
122
- const bg1 = `hsl(${hue} 48% 95%)`
123
- const bg2 = `hsl(${hue2} 48% 90%)`
124
- const cells = 5
125
- const unit = size / cells
126
- let rects = ''
127
- for (let col = 0; col < 3; col++) {
128
- for (let row = 0; row < cells; row++) {
129
- if (!(bytes[col * cells + row] & 1)) continue
130
- for (const c of (col === 2 ? [2] : [col, cells - 1 - col])) {
131
- rects += `<rect x="${(c * unit).toFixed(2)}" y="${(row * unit).toFixed(2)}" width="${unit.toFixed(2)}" height="${unit.toFixed(2)}"/>`
132
- }
133
- }
134
- }
135
- const id = 'g' + (h % 100000)
136
- return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${size} ${size}" width="${size}" height="${size}">` +
137
- `<defs><linearGradient id="${id}" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="${bg1}"/><stop offset="1" stop-color="${bg2}"/></linearGradient></defs>` +
138
- `<rect width="${size}" height="${size}" rx="${(size * 0.5).toFixed(2)}" fill="url(#${id})"/>` +
139
- `<g fill="${fg}" transform="translate(${(size * 0.12).toFixed(2)} ${(size * 0.12).toFixed(2)}) scale(0.76)">${rects}</g></svg>`
140
- }
141
- /** El avatar como data-URI listo para `<img src>` o `background-image`. */
142
- export function avatarDataUri (seed, opts) {
143
- return 'data:image/svg+xml,' + encodeURIComponent(avatarSvg(seed, opts))
144
- }
102
+ // El identicon vive en ./avatar.js (función pura, sin dependencias) y se
103
+ // re-exporta aquí por compatibilidad: importarlo desde este módulo arrastra
104
+ // core.js (55 KB), así que si SOLO quieres el avatar usa el subpath
105
+ // '@dotrino/identity/avatar'.
106
+ export { avatarSvg, avatarDataUri } from './avatar.js'
145
107
 
146
108
  /** Cuerpo canónico del certificado (lo que se firma): el cert SIN la firma. */
147
109
  export function delegationBody (cert) {
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,116 @@ 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
+ // Handlers de UI (emparejamiento/gestión) expuestos por postMessage. Las ACCIONES
158
+ // (pairing/approve) requieren que ESTE iframe sea el daemon activo (la pestaña visible);
159
+ // la lectura (máquinas/pending) siempre funciona (lee delegaciones persistidas).
160
+ const selfHandlers = {
161
+ // En modo self, listar máquinas enroladas lee LOCAL (listDelegations) en vez de
162
+ // hacer RPC al daemon del PC: somos nuestra propia maestra. Así ia/terminal listan
163
+ // agentes siempre, sin depender de qué pestaña sostenga el lock del daemon.
164
+ listVaultDevices: async () => {
165
+ if (kv.getItem(SELF_FLAG) !== '1') return handlers.listVaultDevices({})
166
+ const { issued, revoked } = await handlers.listDelegations({})
167
+ const now = Date.now()
168
+ const bySub = new Map()
169
+ for (const x of (issued || [])) {
170
+ if (!x.sub || x.revokedAt || (x.exp && x.exp <= now)) continue
171
+ if (!Array.isArray(x.scope) || !x.scope.includes('vault:sign')) continue
172
+ if (!bySub.has(x.sub) || (x.exp || 0) > (bySub.get(x.sub).exp || 0)) bySub.set(x.sub, x)
173
+ }
174
+ const devices = await Promise.all([...bySub.values()].map(async (x) => ({
175
+ deviceId: (await pubkeyId(x.sub)).slice(0, 8).toUpperCase().replace(/(.{4})(.{4})/, '$1-$2'),
176
+ sub: x.sub, label: x.label || '', scope: x.scope, exp: x.exp, nonce: x.nonce
177
+ })))
178
+ return { devices, revoked: (revoked || []).map((r) => r.nonce || r) }
179
+ },
180
+ selfVaultStatus: async () => ({ enabled: kv.getItem(SELF_FLAG) === '1', running: !!daemon }),
181
+ setSelfVault: async ({ enabled }) => {
182
+ kv.setItem(SELF_FLAG, enabled ? '1' : '0')
183
+ if (enabled) holdSelfLock(); else releaseSelfLock()
184
+ return { ok: true, enabled: !!enabled }
185
+ },
186
+ selfVaultPairing: async (opts) => {
187
+ if (!daemon) throw new Error('esta pestaña no es la bóveda activa; ábrela como pestaña visible')
188
+ return daemon.startPairing(opts)
189
+ },
190
+ selfVaultPending: async () => (daemon ? daemon.listPending() : []),
191
+ selfVaultMachines: async () => {
192
+ if (daemon) return daemon.listMachines()
193
+ const { issued } = await handlers.listDelegations({})
194
+ return issued || []
195
+ },
196
+ selfVaultApprove: async ({ deviceId, code }) => {
197
+ if (!daemon) throw new Error('esta pestaña no es la bóveda activa')
198
+ return daemon.approve(deviceId, code)
199
+ },
200
+ selfVaultReject: async ({ deviceId }) => {
201
+ if (!daemon) throw new Error('esta pestaña no es la bóveda activa')
202
+ daemon.reject(deviceId)
203
+ return { ok: true }
204
+ },
205
+ selfVaultRevoke: async ({ nonce }) => {
206
+ if (daemon) return daemon.revoke(nonce)
207
+ return handlers.revokeDelegation({ nonce })
208
+ }
209
+ }
210
+
100
211
  window.addEventListener('message', async (event) => {
101
212
  const msg = event.data
102
213
  if (!msg || msg._cci !== true || msg.type !== 'request') return
@@ -107,7 +218,7 @@ import { createIdentityCore } from './core.js'
107
218
  { _cci: true, type: 'response', id, ...payload },
108
219
  event.origin
109
220
  )
110
- const handler = handlers[method]
221
+ const handler = selfHandlers[method] || handlers[method]
111
222
  if (!handler) return reply({ error: `Unknown method: ${method}` })
112
223
  try {
113
224
  const result = await handler(params || {})
@@ -135,4 +246,6 @@ import { createIdentityCore } from './core.js'
135
246
  window.parent.postMessage({ _cci: true, type: 'ready' }, '*')
136
247
  }
137
248
  }
249
+ // Si el modo self-vault ya estaba activado, intentar tomar el lock (pestaña visible).
250
+ holdSelfLock()
138
251
  })()
@@ -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 }