@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 +1 -1
- package/package.json +6 -2
- package/src/index.d.ts +20 -1
- package/src/index.js +25 -0
- package/vault/avatar.js +65 -0
- package/vault/capabilities.js +5 -43
- package/vault/index.html +5 -1
- package/vault/vault.js +114 -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
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dotrino/identity",
|
|
3
|
-
"version": "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
|
-
"./
|
|
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
|
-
|
|
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
|
package/vault/avatar.js
ADDED
|
@@ -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
|
+
}
|
package/vault/capabilities.js
CHANGED
|
@@ -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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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": {
|
|
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 }
|