@dotrino/identity 0.107.2 → 0.108.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 +19 -0
- package/package.json +1 -1
- package/src/index.d.ts +5 -0
- package/src/index.js +5 -0
- package/src/node.js +1 -0
- package/vault/capabilities.js +9 -37
- package/vault/core.js +28 -20
- package/vault/icon-192.png +0 -0
- package/vault/index.html +161 -15
- package/vault/og.jpg +0 -0
- package/vault/robots.txt +3 -0
- package/vault/sitemap.xml +4 -0
- package/vault/vendor/proxy-client/VERSION.txt +1 -1
- package/vault/vendor/proxy-client/client.js +115 -5
- package/vault/vendor/proxy-client/index.js +2 -0
- package/vault/vendor/proxy-client/stats.js +144 -0
- package/vault/vendor/proxy-client/webrtc.js +30 -0
package/README.md
CHANGED
|
@@ -19,6 +19,24 @@ Identidad de usuario y rating de peers compartidos entre las apps de Dotrino. Fu
|
|
|
19
19
|
|
|
20
20
|
Como todas las apps cargan el vault desde el mismo origin, comparten el mismo `localStorage` aunque ellas estén en orígenes distintos. **Las claves privadas nunca salen del vault** — las apps reciben firmas (de la ECDSA) y plaintext descifrado (de la ECDH) pero nunca las llaves.
|
|
21
21
|
|
|
22
|
+
## Dónde vive la identidad: web, Android, iOS
|
|
23
|
+
|
|
24
|
+
Uso para el usuario: [El navegador y las apps instaladas](https://wiki.dotrino.com/empezar/navegador-y-apps/) (wiki). Portada: [id.dotrino.com](https://id.dotrino.com/).
|
|
25
|
+
|
|
26
|
+
«Identidad Dotrino» es un **concepto** con tres implementaciones. Solo en Android es una app:
|
|
27
|
+
|
|
28
|
+
| Plataforma | Dónde están las llaves y los perfiles | Quién los comparte |
|
|
29
|
+
|---|---|---|
|
|
30
|
+
| **Navegador** | este paquete: el iframe `id.dotrino.com` (localStorage + IndexedDB de ese navegador, llaves WebCrypto no extraíbles) | todas las apps abiertas **en ese navegador** |
|
|
31
|
+
| **Android nativo** | la app **`com.dotrino.identity`** (Keystore), servicio con permiso de nivel firma | las apps nativas firmadas con la misma llave de Play |
|
|
32
|
+
| **iOS nativo** | **no hay app**: llavero `P7G853375S.com.dotrino.shared` + App Group `group.com.dotrino`, dentro de cada app | las apps del equipo `P7G853375S` |
|
|
33
|
+
|
|
34
|
+
**El navegador y lo nativo NO se ven, en ninguna dirección.** Chrome no puede enlazar el servicio de `com.dotrino.identity` (exige la firma de Play) y Safari no entra en los grupos del equipo; ninguna app nativa puede leer el almacén de un navegador. El WebView de una app nativa tampoco es el navegador: su iframe `id.dotrino.com` guarda en lo nativo por el puente (`window.DotrinoIdentityKeys`, `nativeStore.js`), no en Chrome ni en Safari.
|
|
35
|
+
|
|
36
|
+
Para la cuenta son **dos aparatos distintos del acta** (el navegador y el conjunto de apps nativas del teléfono). Se unen como cualquier par de aparatos: emparejando con la bóveda («Adoptar un perfil», `vault.dotrino.com/d`) o entrando con dirección y contraseña. No existe ni se construye un puente para copiar perfiles de un lado a otro. Detalle nativo en `dotrino-native/docs/DISENO.md` §2.
|
|
37
|
+
|
|
38
|
+
`id.dotrino.com/` tiene dos papeles: dentro de un iframe es la bóveda (carga `vault.js`); abierta directo es la portada (no carga `vault.js`, para no tomar el candado del modo «este dispositivo es bóveda»).
|
|
39
|
+
|
|
22
40
|
## Instalación
|
|
23
41
|
|
|
24
42
|
```bash
|
|
@@ -84,6 +102,7 @@ Inicializa el iframe y resuelve cuando el vault está listo.
|
|
|
84
102
|
|
|
85
103
|
- `id.getPeer(publickey)`
|
|
86
104
|
- `id.setNickname(publickey, nickname)`
|
|
105
|
+
- `id.setBlocked(publickey, blocked)` — **bloquear** (0.108): privado, no se firma ni se publica; viaja con tus contactos (`peer.blocked`) a tu bóveda y tus aparatos. Cada app que recibe mensajes lo respeta.
|
|
87
106
|
- `id.setRating(publickey, rating, notes?)` — produce un envelope firmado y lo guarda como `peer.myRating` (rating 0–5).
|
|
88
107
|
- `id.mergeEndorsements(subject, [signedRatings], askerPubkey?)` — para web-of-trust: valida firmas, dedupea por `(ratedBy, subject)`, cap 50.
|
|
89
108
|
- `id.getRatingsForSubject(subject)` → `{ mine, endorsements }` para responder a un `RATING_QUERY`.
|
package/package.json
CHANGED
package/src/index.d.ts
CHANGED
|
@@ -65,6 +65,9 @@ export interface PeerInfo {
|
|
|
65
65
|
queryStats?: QueryStats
|
|
66
66
|
firstSeen?: number
|
|
67
67
|
lastSeen?: number
|
|
68
|
+
/** You blocked them (private, never published). */
|
|
69
|
+
blocked?: boolean
|
|
70
|
+
changedAt?: number
|
|
68
71
|
}
|
|
69
72
|
|
|
70
73
|
export interface Challenge {
|
|
@@ -106,6 +109,8 @@ export class Identity {
|
|
|
106
109
|
verifyResponse (response: ChallengeResponse): Promise<VerifyResult>
|
|
107
110
|
getPeer (publickey: string): Promise<PeerInfo | null>
|
|
108
111
|
setNickname (publickey: string, nickname: string): Promise<PeerInfo>
|
|
112
|
+
/** Private block: not signed, not published; travels with your contacts. */
|
|
113
|
+
setBlocked (publickey: string, blocked: boolean): Promise<PeerInfo>
|
|
109
114
|
setRating (publickey: string, rating: number, notes?: string): Promise<PeerInfo>
|
|
110
115
|
listPeers (): Promise<PeerInfo[]>
|
|
111
116
|
forgetPeer (publickey: string): Promise<void>
|
package/src/index.js
CHANGED
|
@@ -209,6 +209,11 @@ export class Identity {
|
|
|
209
209
|
return this._call('getPeer', { publickey })
|
|
210
210
|
}
|
|
211
211
|
|
|
212
|
+
/** Bloquear (o desbloquear) a alguien: privado, viaja con tus contactos y no se publica. */
|
|
213
|
+
async setBlocked (publickey, blocked) {
|
|
214
|
+
return this._call('setBlocked', { publickey, blocked })
|
|
215
|
+
}
|
|
216
|
+
|
|
212
217
|
async setNickname (publickey, nickname) {
|
|
213
218
|
return this._call('setNickname', { publickey, nickname })
|
|
214
219
|
}
|
package/src/node.js
CHANGED
|
@@ -166,6 +166,7 @@ export class Identity {
|
|
|
166
166
|
}
|
|
167
167
|
getPeer (publickey) { return this._h('getPeer', { publickey }) }
|
|
168
168
|
setNickname (publickey, nickname) { return this._h('setNickname', { publickey, nickname }) }
|
|
169
|
+
setBlocked (publickey, blocked) { return this._h('setBlocked', { publickey, blocked }) }
|
|
169
170
|
setRating (publickey, rating, notes) { return this._h('setRating', { publickey, rating, notes }) }
|
|
170
171
|
listPeers () { return this._h('listPeers') }
|
|
171
172
|
forgetPeer (publickey) { return this._h('forgetPeer', { publickey }) }
|
package/vault/capabilities.js
CHANGED
|
@@ -27,27 +27,12 @@ const ECDSA = { name: 'ECDSA', namedCurve: 'P-256' }
|
|
|
27
27
|
const ECDH = { name: 'ECDH', namedCurve: 'P-256' }
|
|
28
28
|
const SIGN = { name: 'ECDSA', hash: { name: 'SHA-256' } }
|
|
29
29
|
|
|
30
|
-
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
* de volver: el mostrador de renovación los rechazaba por lo mismo. Diez máquinas —los dos
|
|
36
|
-
* proxios, los nodes de contenido, el bot— habrían necesitado re-enrolarse a mano, con TTY
|
|
37
|
-
* en los dos lados.
|
|
38
|
-
*
|
|
39
|
-
* Así que se aceptan, con dos frenos y ninguna concesión de fondo:
|
|
40
|
-
* · hasta su PROPIO vencimiento, que sigue valiendo (el más lejano es 2026-09-30);
|
|
41
|
-
* · y en ningún caso pasado este tope.
|
|
42
|
-
* Además el mostrador los cruza con el acta igual que a los nuevos, así que un papel viejo
|
|
43
|
-
* no autoriza nada que el acta de hoy no autorice.
|
|
44
|
-
*
|
|
45
|
-
* Se apaga solo por partida doble: ninguna bóveda 0.73+ puede EMITIR uno de estos
|
|
46
|
-
* (`signDelegationWith` solo escribe `seq`), así que cuando el último caduque esta rama es
|
|
47
|
-
* inalcanzable. **Y aun así se borra en la fecha**, porque un repliegue sin fecha no es una
|
|
48
|
-
* migración: es un agujero (`CLAUDE.md`).
|
|
30
|
+
/*
|
|
31
|
+
* EL MODELO VIEJO YA NO ENTRA. Los papeles de antes de 0.73 llevaban `exp` y no `seq`; se
|
|
32
|
+
* aceptaron hasta el 2026-10-01 por un repliegue de migración declarado y con fecha, y esa
|
|
33
|
+
* fecha pasó: se quitó (2026-10-05). Un papel sin `seq` se rechaza con `legacy-cert-retirado`,
|
|
34
|
+
* el mismo código que daba el repliegue al caducar, así que quien lo comprueba no cambia.
|
|
49
35
|
*/
|
|
50
|
-
export const LEGACY_CERTS_UNTIL = Date.parse('2026-10-01T00:00:00Z')
|
|
51
36
|
|
|
52
37
|
const enc = (s) => new TextEncoder().encode(s)
|
|
53
38
|
|
|
@@ -252,9 +237,8 @@ export async function verifyDelegation ({ cert, expectedScope, expectedSub, acta
|
|
|
252
237
|
const { v, iss, sub, scope, iat, seq, nonce, sig } = cert
|
|
253
238
|
if (v !== 1 || typeof iss !== 'string' || typeof sub !== 'string' || typeof sig !== 'string') return { ok: false, reason: 'shape' }
|
|
254
239
|
if (typeof iat !== 'number' || (typeof scope !== 'string' && !Array.isArray(scope))) return { ok: false, reason: 'shape' }
|
|
255
|
-
//
|
|
256
|
-
|
|
257
|
-
if (legado && typeof cert.exp !== 'number') return { ok: false, reason: 'shape' }
|
|
240
|
+
// UN PAPEL DEL MODELO VIEJO (con `exp`, sin `seq`) ya no vale: el repliegue se retiró.
|
|
241
|
+
if (typeof seq !== 'number') return { ok: false, reason: typeof cert.exp === 'number' ? 'legacy-cert-retirado' : 'shape' }
|
|
258
242
|
if (!(await rawVerify(iss, enc(canonicalStringify(delegationBody(cert))), sig))) return { ok: false, reason: 'bad-signature' }
|
|
259
243
|
// EL ACTA MANDA, Y NO HAY RELOJ (dueño, 2026-08-31). El papel no vence: dice «una
|
|
260
244
|
// selladora de este perfil, mirando el acta nº `seq`, avaló esta llave». Lo que puede
|
|
@@ -280,19 +264,7 @@ export async function verifyDelegation ({ cert, expectedScope, expectedSub, acta
|
|
|
280
264
|
// estaba apagado: vuelve, su papel ya no vale, y renovarlo exige una selladora ABIERTA.
|
|
281
265
|
// O sea que un cambio de acta te obligaría a abrir la bóveda para que tus aparatos
|
|
282
266
|
// volvieran — justo lo que se acaba de quitar de en medio.
|
|
283
|
-
if (
|
|
284
|
-
// REPLIEGUE DE MIGRACIÓN, declarado y con fecha (ver `LEGACY_CERTS_UNTIL`). Un papel del
|
|
285
|
-
// modelo viejo se sigue aceptando, pero SOLO hasta su propio vencimiento: no se le regala
|
|
286
|
-
// ni un día. No hay nada que decidir sobre el acta que nombra, porque no nombra ninguna.
|
|
287
|
-
//
|
|
288
|
-
// Lo que NO se relaja, y es lo que hace que esto no sea un agujero: quien lo emitió tiene
|
|
289
|
-
// que poder sellar (abajo), y el mostrador cruza igual con el acta (`memberCanScope`). Un
|
|
290
|
-
// papel viejo no autoriza nada que el acta de hoy no autorice; lo único que compra es
|
|
291
|
-
// seguir hablando mientras su máquina se actualiza.
|
|
292
|
-
if (legado) {
|
|
293
|
-
if (Date.now() > cert.exp) return { ok: false, reason: 'expired' }
|
|
294
|
-
if (Date.now() > LEGACY_CERTS_UNTIL) return { ok: false, reason: 'legacy-cert-retirado' }
|
|
295
|
-
}
|
|
267
|
+
if (seq > actaSeq) return { ok: false, reason: 'acta-vieja' }
|
|
296
268
|
// Y QUIEN LO EMITIÓ TIENE QUE PODER SELLAR. No se compara contra «la maestra»: cualquiera
|
|
297
269
|
// que el acta nombre sellador emite papeles válidos — si no, la segunda bóveda podría
|
|
298
270
|
// invalidar todos los certificados al sellar y luego no poder dar los nuevos.
|
|
@@ -304,7 +276,7 @@ export async function verifyDelegation ({ cert, expectedScope, expectedSub, acta
|
|
|
304
276
|
: (revoked instanceof Set ? revoked.has(nonce) : !!revoked[nonce])
|
|
305
277
|
if (isRev) return { ok: false, reason: 'revoked' }
|
|
306
278
|
}
|
|
307
|
-
return { ok: true, iss, sub, scope, iat, seq
|
|
279
|
+
return { ok: true, iss, sub, scope, iat, seq, nonce }
|
|
308
280
|
}
|
|
309
281
|
|
|
310
282
|
/**
|
package/vault/core.js
CHANGED
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
* vault, compartida por todos los runtimes.
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
|
-
import { signDelegationWith
|
|
21
|
+
import { signDelegationWith } from './capabilities.js'
|
|
22
22
|
import * as Acta from './acta.js'
|
|
23
23
|
import * as Content from './content.js'
|
|
24
24
|
import { syncPeers, stampOf } from './peerSync.js'
|
|
@@ -1015,14 +1015,13 @@ export async function createIdentityCore ({ kv: hostKv, peers, makeSync = null,
|
|
|
1015
1015
|
* el vault y hay un aparato expirado debe quitarlo del acta en una nueva acta»*).
|
|
1016
1016
|
*
|
|
1017
1017
|
* Muerto es un aparato cuyo papel no tiene vuelta atrás: todos los certificados que ESTA
|
|
1018
|
-
* bóveda le dio son del modelo viejo (sin `seq`)
|
|
1019
|
-
*
|
|
1018
|
+
* bóveda le dio son del modelo viejo (sin `seq`), que ya no vale desde que se retiró su
|
|
1019
|
+
* repliegue (2026-10-01). Renovar tampoco lo salva: la renovación
|
|
1020
1020
|
* viaja firmada con ese mismo papel, y la bóveda lo rechaza (`unauthorized: expired`).
|
|
1021
1021
|
* Hasta ahora se quedaban en el acta para siempre, como miembros que nadie podía usar.
|
|
1022
1022
|
*
|
|
1023
1023
|
* Lo que NO se toca, porque no hay datos para juzgarlo:
|
|
1024
1024
|
* · un miembro sin certificados de esta bóveda (se los pudo dar otra);
|
|
1025
|
-
* · un papel viejo sin `exp` antes del corte;
|
|
1026
1025
|
* · esta misma llave.
|
|
1027
1026
|
*
|
|
1028
1027
|
* Todo sale en UNA acta: las bajas y la clave de contenido nueva van en el mismo sello,
|
|
@@ -1041,8 +1040,7 @@ export async function createIdentityCore ({ kv: hostKv, peers, makeSync = null,
|
|
|
1041
1040
|
if (!porSub.has(d.sub)) porSub.set(d.sub, [])
|
|
1042
1041
|
porSub.get(d.sub).push(d)
|
|
1043
1042
|
}
|
|
1044
|
-
const muerto = (d) => typeof d.seq !== 'number'
|
|
1045
|
-
(now > LEGACY_CERTS_UNTIL || (typeof d.exp === 'number' && now > d.exp))
|
|
1043
|
+
const muerto = (d) => typeof d.seq !== 'number'
|
|
1046
1044
|
const muertos = [...porSub]
|
|
1047
1045
|
.filter(([sub, ds]) => sub !== publickeyJwkStr && ds.every(muerto))
|
|
1048
1046
|
.map(([sub, ds]) => ({ pub: sub, label: ds[0]?.label || '' }))
|
|
@@ -1383,13 +1381,7 @@ export async function createIdentityCore ({ kv: hostKv, peers, makeSync = null,
|
|
|
1383
1381
|
// él se va la última razón por la que la maestra tenía que estar disponible sin nadie
|
|
1384
1382
|
// delante. Renovar pasa a ocurrir justo cuando ya hay una selladora abierta, porque
|
|
1385
1383
|
// cambiar el acta ES tenerla abierta.
|
|
1386
|
-
|
|
1387
|
-
// uno nuevo. Sin esto moría en su fecha aunque el aparato se usara a diario, y ya no
|
|
1388
|
-
// tenía arreglo — el teléfono que aprueba se quedó así el 2026-09-22. Caduca sola: a
|
|
1389
|
-
// partir de `LEGACY_CERTS_UNTIL` no queda ningún papel viejo que valga.
|
|
1390
|
-
const legadoVivo = typeof v.cert.seq !== 'number' && typeof v.cert.exp === 'number' &&
|
|
1391
|
-
now < v.cert.exp && now < LEGACY_CERTS_UNTIL
|
|
1392
|
-
if (!legadoVivo && !certDesfasadoDelActa()) return
|
|
1384
|
+
if (!certDesfasadoDelActa()) return
|
|
1393
1385
|
if (now - renewLastTry < RENEW_RETRY_MS) return
|
|
1394
1386
|
renovarCert().catch(() => {}) // best-effort: el cert vigente sigue sirviendo mientras tanto
|
|
1395
1387
|
} catch (_) {}
|
|
@@ -1461,6 +1453,7 @@ export async function createIdentityCore ({ kv: hostKv, peers, makeSync = null,
|
|
|
1461
1453
|
if (b.contactNotes !== undefined) merged.contactNotes = b.contactNotes
|
|
1462
1454
|
if (b.encryptionPubkey) merged.encryptionPubkey = b.encryptionPubkey
|
|
1463
1455
|
if (typeof b.rating === 'number') merged.rating = b.rating
|
|
1456
|
+
if (typeof b.blocked === 'boolean') merged.blocked = b.blocked
|
|
1464
1457
|
}
|
|
1465
1458
|
merged.firstSeen = Math.min(a.firstSeen || aSeen || Date.now(), b.firstSeen || bSeen || Date.now())
|
|
1466
1459
|
merged.lastSeen = Math.max(aSeen, bSeen)
|
|
@@ -1984,6 +1977,18 @@ export async function createIdentityCore ({ kv: hostKv, peers, makeSync = null,
|
|
|
1984
1977
|
return p[publickey] || null
|
|
1985
1978
|
},
|
|
1986
1979
|
|
|
1980
|
+
/**
|
|
1981
|
+
* BLOQUEAR a alguien (dueño, 2026-10-05): un indicador PRIVADO, aparte de la calificación.
|
|
1982
|
+
* No se firma ni se publica —no es una opinión sobre esa persona, es tu decisión de no
|
|
1983
|
+
* recibirla—, viaja con el libro de contactos a tu bóveda y a tus aparatos, y lo respeta
|
|
1984
|
+
* cada app que recibe mensajes (el messenger, el primero). Desbloquear deja `false` y no
|
|
1985
|
+
* borra el campo: así el cambio también llega a los demás aparatos.
|
|
1986
|
+
*/
|
|
1987
|
+
async setBlocked ({ publickey, blocked }) {
|
|
1988
|
+
if (!publickey) throw new Error('publickey required')
|
|
1989
|
+
return upsertPeer(publickey, { blocked: !!blocked, changedAt: Date.now() })
|
|
1990
|
+
},
|
|
1991
|
+
|
|
1987
1992
|
async setNickname ({ publickey, nickname }) {
|
|
1988
1993
|
return upsertPeer(publickey, { nickname: String(nickname || '').slice(0, 40) })
|
|
1989
1994
|
},
|
|
@@ -2396,14 +2401,17 @@ export async function createIdentityCore ({ kv: hostKv, peers, makeSync = null,
|
|
|
2396
2401
|
if (e.id === currentPid) { me = { ...(me || {}), nickname: e.name }; saveMe(me) }
|
|
2397
2402
|
return { id: e.id, name: e.name }
|
|
2398
2403
|
},
|
|
2404
|
+
/**
|
|
2405
|
+
* BORRAR UNA CUENTA, también la activa y también la única (dueño, 2026-10-05): borrar tu
|
|
2406
|
+
* cuenta desde la app es un derecho del usuario, y la App Store lo exige (norma 5.1.1(v)).
|
|
2407
|
+
* La alerta es de la INTERFAZ, que pregunta antes. Si era la activa, `current` dice cuál
|
|
2408
|
+
* queda, y la app tiene que RECARGAR; si no queda ninguna, el arranque estrena una nueva
|
|
2409
|
+
* y vacía (ver `purgeProfile`).
|
|
2410
|
+
*/
|
|
2399
2411
|
async deleteProfile ({ id } = {}) {
|
|
2400
|
-
|
|
2401
|
-
|
|
2402
|
-
|
|
2403
|
-
// ahí sí se va la última, porque no es un descuido sino que te echaron.
|
|
2404
|
-
if (list.length <= 1) throw new Error('cannot delete the only profile')
|
|
2405
|
-
if (!list.find((p) => p.id === id)) throw new Error('profile does not exist')
|
|
2406
|
-
return purgeProfile(id)
|
|
2412
|
+
if (!loadProfiles().find((p) => p.id === id)) throw new Error('profile does not exist')
|
|
2413
|
+
const wasCurrent = id === currentPid
|
|
2414
|
+
return { ...(await purgeProfile(id)), wasCurrent }
|
|
2407
2415
|
},
|
|
2408
2416
|
|
|
2409
2417
|
// ----- ENTRAR CON USUARIO Y CONTRASEÑA -----
|
|
Binary file
|
package/vault/index.html
CHANGED
|
@@ -1,24 +1,155 @@
|
|
|
1
1
|
<!DOCTYPE html>
|
|
2
|
-
<html lang="
|
|
2
|
+
<html lang="es">
|
|
3
3
|
<head>
|
|
4
4
|
<meta charset="UTF-8">
|
|
5
|
-
<
|
|
6
|
-
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
|
6
|
+
<!-- Esta página tiene DOS papeles según cómo se abra:
|
|
7
|
+
· en un iframe (lo normal): es la bóveda de identidad de todas las apps. Carga
|
|
8
|
+
vault.js y no enseña nada.
|
|
9
|
+
· abierta directo: es la portada que explica qué es Identidad Dotrino. NO carga
|
|
10
|
+
vault.js (una pestaña de portada no debe tomar el candado del modo «este
|
|
11
|
+
dispositivo es bóveda» ni atender a nadie), y carga el topbar y GoatCounter, que
|
|
12
|
+
dentro del iframe sobrarían (contarían cada arranque de cada app). -->
|
|
13
|
+
<script>if (window.top !== window) document.documentElement.classList.add('embedded')</script>
|
|
14
|
+
<title>Identidad Dotrino — tu llave y tus perfiles, en tu aparato</title>
|
|
15
|
+
<meta name="description" content="La identidad de Dotrino: una llave y tus perfiles que viven en tu aparato y comparten todas las apps de Dotrino. Cómo funciona en el navegador, en Android y en iPhone.">
|
|
16
|
+
<link rel="canonical" href="https://id.dotrino.com/">
|
|
17
|
+
<link rel="icon" type="image/png" href="icon-192.png">
|
|
18
|
+
<link rel="apple-touch-icon" href="icon-192.png">
|
|
19
|
+
<meta name="theme-color" content="#0a3570">
|
|
20
|
+
<meta property="og:type" content="website">
|
|
21
|
+
<meta property="og:locale" content="es_ES">
|
|
22
|
+
<meta property="og:locale:alternate" content="en_US">
|
|
23
|
+
<meta property="og:site_name" content="Dotrino">
|
|
24
|
+
<meta property="og:title" content="Identidad Dotrino">
|
|
25
|
+
<meta property="og:description" content="Tu llave y tus perfiles viven en tu aparato y los comparten todas las apps de Dotrino. En el navegador, en Android y en iPhone.">
|
|
26
|
+
<meta property="og:url" content="https://id.dotrino.com/">
|
|
27
|
+
<meta property="og:image" content="https://id.dotrino.com/og.jpg">
|
|
28
|
+
<meta property="og:image:width" content="1200">
|
|
29
|
+
<meta property="og:image:height" content="630">
|
|
30
|
+
<meta name="twitter:card" content="summary_large_image">
|
|
31
|
+
<meta name="twitter:title" content="Identidad Dotrino">
|
|
32
|
+
<meta name="twitter:description" content="Tu llave y tus perfiles viven en tu aparato y los comparten todas las apps de Dotrino.">
|
|
33
|
+
<meta name="twitter:image" content="https://id.dotrino.com/og.jpg">
|
|
34
|
+
<script type="application/ld+json">
|
|
35
|
+
{
|
|
36
|
+
"@context": "https://schema.org",
|
|
37
|
+
"@type": "WebApplication",
|
|
38
|
+
"name": "Identidad Dotrino",
|
|
39
|
+
"url": "https://id.dotrino.com/",
|
|
40
|
+
"description": "La identidad compartida de las apps de Dotrino: una llave y tus perfiles que viven en tu aparato.",
|
|
41
|
+
"applicationCategory": "SecurityApplication",
|
|
42
|
+
"operatingSystem": "Web, Android, iOS",
|
|
43
|
+
"offers": { "@type": "Offer", "price": "0", "priceCurrency": "USD" },
|
|
44
|
+
"publisher": { "@type": "Organization", "name": "Dotrino", "url": "https://dotrino.com/" }
|
|
45
|
+
}
|
|
46
|
+
</script>
|
|
7
47
|
<style>
|
|
8
|
-
|
|
9
|
-
|
|
48
|
+
:root {
|
|
49
|
+
--brand: #0a3570;
|
|
50
|
+
--accent: #00658c;
|
|
51
|
+
--bg: #f6f8fb;
|
|
52
|
+
--panel: #ffffff;
|
|
53
|
+
--ink: #14213d;
|
|
54
|
+
--muted: #5a6478;
|
|
55
|
+
--line: #dfe5ee;
|
|
56
|
+
--warn-bg: #fff7e6;
|
|
57
|
+
--warn-line: #f0c36d;
|
|
58
|
+
--dotrino-topbar-bg: rgba(255, 255, 255, .92);
|
|
59
|
+
--dotrino-topbar-border: var(--line);
|
|
60
|
+
--dotrino-topbar-text: var(--ink);
|
|
61
|
+
--dotrino-topbar-muted: var(--muted);
|
|
62
|
+
--dotrino-topbar-accent: var(--accent);
|
|
63
|
+
}
|
|
64
|
+
* { box-sizing: border-box; }
|
|
65
|
+
html, body { margin: 0; padding: 0; }
|
|
66
|
+
body {
|
|
67
|
+
background: var(--bg); color: var(--ink);
|
|
68
|
+
font: 16px/1.6 system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
|
|
69
|
+
-webkit-text-size-adjust: 100%;
|
|
70
|
+
}
|
|
71
|
+
html.embedded body { background: transparent; }
|
|
72
|
+
html.embedded #home { display: none; }
|
|
73
|
+
a { color: var(--accent); }
|
|
74
|
+
.wrap { max-width: 860px; margin: 0 auto; padding: 28px 20px 80px; }
|
|
75
|
+
h1 { color: var(--brand); font-size: clamp(30px, 6vw, 42px); line-height: 1.15; margin: 8px 0 10px; }
|
|
76
|
+
h2 { color: var(--brand); font-size: 22px; margin: 40px 0 12px; }
|
|
77
|
+
h3 { margin: 0 0 6px; font-size: 18px; }
|
|
78
|
+
.lead { font-size: 19px; color: var(--muted); max-width: 640px; }
|
|
79
|
+
.cards { display: grid; gap: 14px; grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); }
|
|
80
|
+
.card { background: var(--panel); border: 1px solid var(--line); border-radius: 14px; padding: 18px; }
|
|
81
|
+
.card p { margin: 6px 0; }
|
|
82
|
+
.tag { display: inline-block; font-size: 12px; font-weight: 600; color: var(--accent); text-transform: uppercase; letter-spacing: .04em; }
|
|
83
|
+
.warn { background: var(--warn-bg); border: 1px solid var(--warn-line); border-radius: 14px; padding: 18px 20px; }
|
|
84
|
+
.warn p { margin: 8px 0; }
|
|
85
|
+
ul { padding-left: 20px; }
|
|
86
|
+
footer { margin-top: 48px; color: var(--muted); font-size: 14px; }
|
|
87
|
+
html[lang="en"] [data-lang="es"] { display: none; }
|
|
88
|
+
html:not([lang="en"]) [data-lang="en"] { display: none; }
|
|
10
89
|
</style>
|
|
11
90
|
</head>
|
|
12
91
|
<body>
|
|
13
|
-
<
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
92
|
+
<div id="home">
|
|
93
|
+
<dotrino-topbar profile
|
|
94
|
+
brand="Identidad"
|
|
95
|
+
icon="icon-192.png"
|
|
96
|
+
no-back
|
|
97
|
+
support-repo="imdotrino/dotrino-identity"
|
|
98
|
+
support-discord="https://discord.gg/D648uq7cth"></dotrino-topbar>
|
|
99
|
+
|
|
100
|
+
<main class="wrap">
|
|
101
|
+
<h1>Identidad Dotrino</h1>
|
|
102
|
+
<p class="lead" data-lang="es">Tu llave y tus perfiles. Viven en tu aparato, no se registran en ningún lado y los comparten todas las apps de Dotrino.</p>
|
|
103
|
+
<p class="lead" data-lang="en">Your key and your profiles. They live on your device, aren't registered anywhere, and every Dotrino app shares them.</p>
|
|
104
|
+
|
|
105
|
+
<h2 data-lang="es">Dónde vive, según dónde uses Dotrino</h2>
|
|
106
|
+
<h2 data-lang="en">Where it lives, depending on where you use Dotrino</h2>
|
|
107
|
+
<div class="cards">
|
|
108
|
+
<section class="card">
|
|
109
|
+
<span class="tag" data-lang="es">Navegador</span><span class="tag" data-lang="en">Browser</span>
|
|
110
|
+
<h3 data-lang="es">Se crea sola</h3><h3 data-lang="en">It creates itself</h3>
|
|
111
|
+
<p data-lang="es">La primera vez que abres una app de Dotrino, tu navegador crea tu llave. Todas las apps que abras <b>en ese navegador</b> la comparten.</p>
|
|
112
|
+
<p data-lang="en">The first time you open a Dotrino app, your browser creates your key. Every app you open <b>in that browser</b> shares it.</p>
|
|
113
|
+
</section>
|
|
114
|
+
<section class="card">
|
|
115
|
+
<span class="tag">Android</span>
|
|
116
|
+
<h3 data-lang="es">Una app: Identidad Dotrino</h3><h3 data-lang="en">An app: Dotrino Identity</h3>
|
|
117
|
+
<p data-lang="es">Las apps de Dotrino instaladas desde Google Play guardan tus llaves y perfiles en la app <b>Identidad Dotrino</b>. No se abre ni hace nada sola: está ahí para que las demás la compartan. Las apps que la necesitan te avisan si falta.</p>
|
|
118
|
+
<p data-lang="en">Dotrino apps installed from Google Play keep your keys and profiles in the <b>Dotrino Identity</b> app. You don't open it and it does nothing on its own: it's there so the others can share it. The apps that need it tell you if it's missing.</p>
|
|
119
|
+
<p><a href="https://play.google.com/apps/internaltest/4699800053552003281" rel="noopener"><span data-lang="es">Identidad Dotrino en Google Play (beta)</span><span data-lang="en">Dotrino Identity on Google Play (beta)</span></a></p>
|
|
120
|
+
</section>
|
|
121
|
+
<section class="card">
|
|
122
|
+
<span class="tag" data-lang="es">iPhone y iPad</span><span class="tag" data-lang="en">iPhone and iPad</span>
|
|
123
|
+
<h3 data-lang="es">No es una app, pero existe</h3><h3 data-lang="en">Not an app, but it's there</h3>
|
|
124
|
+
<p data-lang="es">En iPhone <b>no hay app de Identidad Dotrino, ni hace falta</b>. Apple deja que las apps de un mismo fabricante compartan un espacio guardado, y ahí cada app de Dotrino guarda tus perfiles para las demás. No hay nada que instalar.</p>
|
|
125
|
+
<p data-lang="en">On iPhone <b>there is no Dotrino Identity app, and none is needed</b>. Apple lets apps from the same maker share a saved space, and each Dotrino app keeps your profiles there for the others. There's nothing to install.</p>
|
|
126
|
+
</section>
|
|
127
|
+
</div>
|
|
128
|
+
|
|
129
|
+
<h2 data-lang="es">El navegador y las apps instaladas no se ven</h2>
|
|
130
|
+
<h2 data-lang="en">The browser and installed apps don't see each other</h2>
|
|
131
|
+
<div class="warn">
|
|
132
|
+
<p data-lang="es"><b>Los perfiles de tu navegador no aparecen en las apps instaladas desde la tienda, y los de las apps instaladas no aparecen en el navegador.</b> El teléfono no deja que uno mire dentro del otro. Pasa igual en Android y en iPhone.</p>
|
|
133
|
+
<p data-lang="en"><b>Your browser's profiles don't show up in apps installed from the store, and the installed apps' profiles don't show up in the browser.</b> The phone doesn't let one look inside the other. It's the same on Android and iPhone.</p>
|
|
134
|
+
<p data-lang="es">Para usar el mismo perfil en los dos, se conectan a tu bóveda como dos aparatos de tu cuenta, o se inicia sesión con tu dirección y tu contraseña. Si no haces nada, cada lado sigue con su propia cuenta y todo funciona igual.</p>
|
|
135
|
+
<p data-lang="en">To use the same profile in both, connect them to your vault as two devices of your account, or sign in with your address and password. If you do nothing, each side keeps its own account and everything works the same.</p>
|
|
136
|
+
<p><a data-lang="es" href="https://wiki.dotrino.com/empezar/navegador-y-apps/">Cómo usar el mismo perfil en los dos</a><a data-lang="en" href="https://wiki.dotrino.com/en/empezar/navegador-y-apps/">How to use the same profile in both</a></p>
|
|
137
|
+
</div>
|
|
138
|
+
|
|
139
|
+
<h2 data-lang="es">Más</h2>
|
|
140
|
+
<h2 data-lang="en">More</h2>
|
|
141
|
+
<ul>
|
|
142
|
+
<li><a href="https://profile.dotrino.com/"><span data-lang="es">Administrar tus perfiles</span><span data-lang="en">Manage your profiles</span></a></li>
|
|
143
|
+
<li><a data-lang="es" href="https://wiki.dotrino.com/empezar/identidad/">Tu identidad y tus perfiles</a><a data-lang="en" href="https://wiki.dotrino.com/en/empezar/identidad/">Your identity and your profiles</a></li>
|
|
144
|
+
<li><a data-lang="es" href="https://wiki.dotrino.com/empezar/otros-dispositivos/">Usar tu cuenta en otros dispositivos</a><a data-lang="en" href="https://wiki.dotrino.com/en/empezar/otros-dispositivos/">Use your account on other devices</a></li>
|
|
145
|
+
</ul>
|
|
146
|
+
|
|
147
|
+
<footer>
|
|
148
|
+
<p>MIT · <a href="https://github.com/imdotrino/dotrino-identity">imdotrino/dotrino-identity</a> · <span data-lang="es">parte de</span><span data-lang="en">part of</span> <a href="https://dotrino.com/">Dotrino</a></p>
|
|
149
|
+
</footer>
|
|
150
|
+
</main>
|
|
151
|
+
</div>
|
|
152
|
+
|
|
22
153
|
<!-- Transporte self-hosted (vendorizado): el emparejamiento de dispositivos importa
|
|
23
154
|
@dotrino/proxy-client de forma perezosa; este import map lo resuelve sin CDN. -->
|
|
24
155
|
<script type="importmap">
|
|
@@ -33,6 +164,21 @@
|
|
|
33
164
|
"@dotrino/identity/content": "./content.js"
|
|
34
165
|
} }
|
|
35
166
|
</script>
|
|
36
|
-
<script type="module"
|
|
167
|
+
<script type="module">
|
|
168
|
+
if (window.top !== window) {
|
|
169
|
+
import('./vault.js')
|
|
170
|
+
} else {
|
|
171
|
+
// Portada. Analítica cookieless autohosteada (GoatCounter); no cuenta en localhost/LAN.
|
|
172
|
+
window.goatcounter = { path: (p) => location.hostname + p }
|
|
173
|
+
const goat = document.createElement('script')
|
|
174
|
+
goat.async = true
|
|
175
|
+
goat.dataset.goatcounter = 'https://goat.dotrino.com/count'
|
|
176
|
+
goat.src = 'https://goat.dotrino.com/count.js'
|
|
177
|
+
document.head.appendChild(goat)
|
|
178
|
+
// Por CDN el topbar va SIEMPRE con `+esm` (CONVENCIONES §5). Pone el idioma en
|
|
179
|
+
// <html lang>, que es de lo que cuelgan los bloques [data-lang].
|
|
180
|
+
import('https://cdn.jsdelivr.net/npm/@dotrino/topbar@0.12/+esm')
|
|
181
|
+
}
|
|
182
|
+
</script>
|
|
37
183
|
</body>
|
|
38
184
|
</html>
|
package/vault/og.jpg
ADDED
|
Binary file
|
package/vault/robots.txt
ADDED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
Copia vendorizada de @dotrino/proxy-client@0.
|
|
1
|
+
Copia vendorizada de @dotrino/proxy-client@0.28.0 (dotrino-proxy-client/src/{index,client,signature,canonical,sealing,encpub,webrtc,stats}.js).
|
|
2
2
|
NO se edita a mano: la escribe `node vendor.mjs` y la vigila test/vendor-up-to-date.test.mjs.
|
|
3
3
|
sealing.js resuelve @dotrino/identity/content de forma PEREZOSA (= ../../content.js
|
|
4
4
|
por el import map): solo se carga si de verdad se sella algo.
|
|
@@ -2,6 +2,7 @@ import { buildSignedChannel, getPublicKeyJwk, signData, samePubkey } from './sig
|
|
|
2
2
|
import { seal, open, isSealed } from './sealing.js'
|
|
3
3
|
import { buildEncPubStatement, readEncPubStatement, isEncPub } from './encpub.js'
|
|
4
4
|
import { WebRTCManager, RTC_TAG, DEFAULT_ICE_SERVERS, loadNodePeerConnection, resolvePeerConnection } from './webrtc.js'
|
|
5
|
+
import { TrafficStats, utf8Length, registerTransport, unregisterTransport } from './stats.js'
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* Error con un `code` estable.
|
|
@@ -46,6 +47,20 @@ function errorCon (mensaje, code) {
|
|
|
46
47
|
* - 'reconnecting' (attempt, max)
|
|
47
48
|
* - 'reconnect_failed' (attempts)
|
|
48
49
|
*/
|
|
50
|
+
const APP_RE = /^[a-z0-9][a-z0-9-]{0,31}$/
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Valida un nombre de app (`messenger`, `vault`…). Lo que no tiene forma LANZA: un nombre mal
|
|
54
|
+
* escrito haría que el timbre no sonara nunca en esa app, y eso no se ve.
|
|
55
|
+
*/
|
|
56
|
+
function checkApp (app) {
|
|
57
|
+
if (app == null) return null
|
|
58
|
+
if (typeof app !== 'string' || !APP_RE.test(app)) {
|
|
59
|
+
throw Object.assign(new Error(`app: "${app}" is not a valid app name (a-z, 0-9, -; up to 32)`), { code: 'bad-app' })
|
|
60
|
+
}
|
|
61
|
+
return app
|
|
62
|
+
}
|
|
63
|
+
|
|
49
64
|
export class WebSocketProxyClient {
|
|
50
65
|
constructor (options = {}) {
|
|
51
66
|
this.url = options.url || 'wss://proxy.dotrino.com'
|
|
@@ -69,6 +84,18 @@ export class WebSocketProxyClient {
|
|
|
69
84
|
this.requireSealed = options.requireSealed === true
|
|
70
85
|
this.myEncPrivateKey = options.myEncPrivateKey || null
|
|
71
86
|
|
|
87
|
+
/**
|
|
88
|
+
* QUÉ APP ES ESTA (websocket-proxy ≥ 1.4.0). En un teléfono varias apps hablan con la
|
|
89
|
+
* MISMA llave (el perfil del teléfono), y el proxio necesita saber a cuál despertar y a
|
|
90
|
+
* cuál entregarle la cola: sin esto, la última en suscribirse se llevaba todos los
|
|
91
|
+
* timbres y la primera en conectarse, los mensajes de todas. Es ruteo, no contenido.
|
|
92
|
+
*
|
|
93
|
+
* Se dice al identificarse (solo baja lo suyo de la cola), al suscribirse al timbre (solo
|
|
94
|
+
* suena con lo suyo) y quien le escribe lo marca con `sendByPubkey(…, { app })`.
|
|
95
|
+
* Sin `app`, como antes: recibe todo.
|
|
96
|
+
*/
|
|
97
|
+
this.app = checkApp(options.app)
|
|
98
|
+
|
|
72
99
|
/**
|
|
73
100
|
* MI llave de cifrado, la pública. Con ella puesta, `identify` anuncia al proxio
|
|
74
101
|
* —firmado— que esta identidad se abre por aquí, y cualquiera que sepa mi pubkey puede
|
|
@@ -154,6 +181,7 @@ export class WebSocketProxyClient {
|
|
|
154
181
|
this._pending = new Map() // messageId -> { resolve, reject, timer }
|
|
155
182
|
this._nextId = 1
|
|
156
183
|
this._tokenWatch = new Map() // messageId -> { token, sobre, peerPubkey, timer }
|
|
184
|
+
this._traffic = new TrafficStats()
|
|
157
185
|
|
|
158
186
|
this._rtc = this.enableWebRTC ? new WebRTCManager({
|
|
159
187
|
getSelfToken: () => this.token,
|
|
@@ -166,6 +194,8 @@ export class WebSocketProxyClient {
|
|
|
166
194
|
if (event === 'webrtc_close') this._rtcTried?.delete(args[0])
|
|
167
195
|
this._emit(event, ...args)
|
|
168
196
|
},
|
|
197
|
+
count: (dir, token, bytes, route) =>
|
|
198
|
+
this._traffic.peer(dir, route, { token, pubkey: this._tokenPubkeys.get(token) || null }, bytes),
|
|
169
199
|
config: this.iceServers ? { iceServers: this.iceServers } : null
|
|
170
200
|
}) : null
|
|
171
201
|
// QUIÉN PUEDE HACERTE NEGOCIAR UN CANAL DIRECTO. Sin política, cualquiera que sepa
|
|
@@ -203,6 +233,7 @@ export class WebSocketProxyClient {
|
|
|
203
233
|
|
|
204
234
|
close () {
|
|
205
235
|
this.autoReconnect = false
|
|
236
|
+
unregisterTransport(this)
|
|
206
237
|
if (this._reconnectTimer) {
|
|
207
238
|
clearTimeout(this._reconnectTimer)
|
|
208
239
|
this._reconnectTimer = null
|
|
@@ -257,6 +288,17 @@ export class WebSocketProxyClient {
|
|
|
257
288
|
// cualquier otro módulo de la app la desactivara sin querer, y no hay ningún
|
|
258
289
|
// motivo legítimo para hacerlo a mitad de una sesión.
|
|
259
290
|
if (options.requireSealed === true) this.requireSealed = true
|
|
291
|
+
|
|
292
|
+
// La APP también, por la misma razón (el singleton): si se perdiera, esta app ni sonaría
|
|
293
|
+
// con lo suyo ni se llevaría solo su cola. Se fija una vez; otra distinta después es que
|
|
294
|
+
// dos apps comparten el cliente de la página, y eso es un error, no algo que resolver aquí.
|
|
295
|
+
if (options.app != null) {
|
|
296
|
+
const app = checkApp(options.app)
|
|
297
|
+
if (this.app && this.app !== app) {
|
|
298
|
+
throw Object.assign(new Error(`app: this client is already "${this.app}", not "${app}"`), { code: 'app-conflict' })
|
|
299
|
+
}
|
|
300
|
+
this.app = app
|
|
301
|
+
}
|
|
260
302
|
}
|
|
261
303
|
|
|
262
304
|
on (event, handler) {
|
|
@@ -765,9 +807,15 @@ export class WebSocketProxyClient {
|
|
|
765
807
|
}
|
|
766
808
|
if (opts.ephemeral) msg.ephemeral = true
|
|
767
809
|
if (opts.quiet) msg.quiet = true
|
|
810
|
+
// A QUÉ APP va (ruteo): el proxio timbra y entrega solo a esa app de ese aparato.
|
|
811
|
+
const app = checkApp(opts.app)
|
|
812
|
+
if (app) msg.app = app
|
|
768
813
|
this._sendRaw(msg)
|
|
769
814
|
}
|
|
770
815
|
|
|
816
|
+
/** `{ app }` para lo firmado (suscribirse al timbre), o nada si esta conexión no dijo cuál. */
|
|
817
|
+
_appField () { return this.app ? { app: this.app } : {} }
|
|
818
|
+
|
|
771
819
|
/**
|
|
772
820
|
* Pedir una CITA: el código corto que una persona lee, dicta o escanea para
|
|
773
821
|
* emparejarse con esta conexión.
|
|
@@ -858,6 +906,7 @@ export class WebSocketProxyClient {
|
|
|
858
906
|
identify ({ data, signature, cert, acta, sign }) {
|
|
859
907
|
if (!data || !signature) throw new Error('identify requires {data, signature}')
|
|
860
908
|
const msg = { type: 'identify', data, signature }
|
|
909
|
+
if (this.app) msg.app = this.app
|
|
861
910
|
if (cert) msg.cert = cert // "una identidad": el proxy bindea este token también bajo tu maestra M
|
|
862
911
|
// Acta de perfil: el proxy la verifica (va firmada) y bindea también el `profileId`, así
|
|
863
912
|
// escribirle a la PERSONA llega a cualquiera de sus dispositivos. Ver acta-de-perfil.md.
|
|
@@ -1019,7 +1068,7 @@ export class WebSocketProxyClient {
|
|
|
1019
1068
|
})
|
|
1020
1069
|
}
|
|
1021
1070
|
const subJson = typeof sub.toJSON === 'function' ? sub.toJSON() : sub
|
|
1022
|
-
const data = { op: 'push-subscribe', publickey: publicKey, subscription: JSON.stringify(subJson), ts: Date.now() }
|
|
1071
|
+
const data = { op: 'push-subscribe', publickey: publicKey, subscription: JSON.stringify(subJson), ts: Date.now(), ...this._appField() }
|
|
1023
1072
|
const signature = await normalizeSignature(sign, data)
|
|
1024
1073
|
await this._request({ type: 'push-subscribe', data, signature }, 'push-subscribed')
|
|
1025
1074
|
return sub
|
|
@@ -1033,7 +1082,7 @@ export class WebSocketProxyClient {
|
|
|
1033
1082
|
*/
|
|
1034
1083
|
async registerPushToken ({ publicKey, sign, token, kind = 'fcm' } = {}) {
|
|
1035
1084
|
if (!publicKey || typeof sign !== 'function' || !token) throw new Error('registerPushToken requires { publicKey, sign, token }')
|
|
1036
|
-
const data = { op: 'push-subscribe', publickey: publicKey, subscription: JSON.stringify({ kind, token }), ts: Date.now() }
|
|
1085
|
+
const data = { op: 'push-subscribe', publickey: publicKey, subscription: JSON.stringify({ kind, token }), ts: Date.now(), ...this._appField() }
|
|
1037
1086
|
const signature = await normalizeSignature(sign, data)
|
|
1038
1087
|
await this._request({ type: 'push-subscribe', data, signature }, 'push-subscribed')
|
|
1039
1088
|
return { kind, token }
|
|
@@ -1058,7 +1107,7 @@ export class WebSocketProxyClient {
|
|
|
1058
1107
|
} catch (_) { /* best-effort local */ }
|
|
1059
1108
|
}
|
|
1060
1109
|
if (publicKey && typeof sign === 'function') {
|
|
1061
|
-
const data = { op: 'push-unsubscribe', publickey: publicKey, ts: Date.now() }
|
|
1110
|
+
const data = { op: 'push-unsubscribe', publickey: publicKey, ts: Date.now(), ...this._appField() }
|
|
1062
1111
|
const signature = await normalizeSignature(sign, data)
|
|
1063
1112
|
await this._request({ type: 'push-unsubscribe', data, signature }, 'push-unsubscribed')
|
|
1064
1113
|
}
|
|
@@ -1145,6 +1194,7 @@ export class WebSocketProxyClient {
|
|
|
1145
1194
|
// ---------- internals ----------
|
|
1146
1195
|
|
|
1147
1196
|
_open () {
|
|
1197
|
+
registerTransport(this)
|
|
1148
1198
|
const ws = new WebSocket(this.url)
|
|
1149
1199
|
this.ws = ws
|
|
1150
1200
|
// `ws !== this.ws` ⇒ es un socket que ya abandonamos (p.ej. por heartbeat
|
|
@@ -1159,6 +1209,7 @@ export class WebSocketProxyClient {
|
|
|
1159
1209
|
ws.addEventListener('message', (ev) => {
|
|
1160
1210
|
if (ws !== this.ws) return
|
|
1161
1211
|
this._noteActivity() // cualquier frame entrante prueba que está vivo
|
|
1212
|
+
this._traffic.frame('in', utf8Length(ev.data))
|
|
1162
1213
|
this._handleFrame(ev.data)
|
|
1163
1214
|
})
|
|
1164
1215
|
ws.addEventListener('error', (err) => {
|
|
@@ -1224,7 +1275,7 @@ export class WebSocketProxyClient {
|
|
|
1224
1275
|
_heartbeatTick () {
|
|
1225
1276
|
if (!this._connected || !this.ws) return
|
|
1226
1277
|
if (this._hbDeadTimer) return // ya hay un ping en vuelo esperando respuesta
|
|
1227
|
-
try {
|
|
1278
|
+
try { const ping = JSON.stringify({ type: 'ping' }); this.ws.send(ping); this._traffic.frame('out', ping.length) }
|
|
1228
1279
|
catch (_) { this._onHeartbeatDead(); return }
|
|
1229
1280
|
this._hbDeadTimer = setTimeout(() => this._onHeartbeatDead(), this.heartbeatTimeout)
|
|
1230
1281
|
}
|
|
@@ -1301,6 +1352,7 @@ export class WebSocketProxyClient {
|
|
|
1301
1352
|
if (typeof message === 'string') {
|
|
1302
1353
|
try { parsed = JSON.parse(message) } catch (_) { parsed = null }
|
|
1303
1354
|
}
|
|
1355
|
+
this._traffic.peer('in', 'proxy', { token: from || null, pubkey: from_publickey || this._tokenPubkeys.get(from) || null }, utf8Length(message))
|
|
1304
1356
|
if (this._rtc && parsed && parsed.t === RTC_TAG) {
|
|
1305
1357
|
this._rtc.handleIncoming(from, parsed)
|
|
1306
1358
|
break
|
|
@@ -1395,7 +1447,65 @@ export class WebSocketProxyClient {
|
|
|
1395
1447
|
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
|
1396
1448
|
throw errorCon('WebSocket not connected', 'NOT_CONNECTED')
|
|
1397
1449
|
}
|
|
1398
|
-
|
|
1450
|
+
const texto = JSON.stringify(frame)
|
|
1451
|
+
this.ws.send(texto)
|
|
1452
|
+
this._countOut(frame, texto)
|
|
1453
|
+
}
|
|
1454
|
+
|
|
1455
|
+
/** Cuenta un frame que sale por el proxio, y a quién iba. */
|
|
1456
|
+
_countOut (frame, texto) {
|
|
1457
|
+
this._traffic.frame('out', utf8Length(texto))
|
|
1458
|
+
if (typeof frame.message !== 'string') return
|
|
1459
|
+
const bytes = utf8Length(frame.message)
|
|
1460
|
+
for (const token of Array.isArray(frame.to) ? frame.to : []) {
|
|
1461
|
+
this._traffic.peer('out', 'proxy', { token, pubkey: this._tokenPubkeys.get(token) || null }, bytes)
|
|
1462
|
+
}
|
|
1463
|
+
for (const pubkey of Array.isArray(frame.to_publickey) ? frame.to_publickey : []) {
|
|
1464
|
+
this._traffic.peer('out', 'proxy', { pubkey }, bytes)
|
|
1465
|
+
}
|
|
1466
|
+
}
|
|
1467
|
+
|
|
1468
|
+
/**
|
|
1469
|
+
* ESTADÍSTICAS DE RED: cuánto entró y salió, por conexión, y por qué camino.
|
|
1470
|
+
*
|
|
1471
|
+
* `route` es por dónde va AHORA cada conexión: `proxy`, `connecting` (por el proxio
|
|
1472
|
+
* mientras se negocia WebRTC), `direct` o `turn` (WebRTC, sin o con relevo), `webrtc`
|
|
1473
|
+
* (canal abierto sin poder saber cuál) o `failed` (WebRTC no salió: sigue por el proxio).
|
|
1474
|
+
* Los bytes van separados por el camino por el que pasaron de verdad.
|
|
1475
|
+
*
|
|
1476
|
+
* Los bytes son de payload (UTF-8), no del cable: las cabeceras de TLS/DTLS no se ven
|
|
1477
|
+
* desde aquí.
|
|
1478
|
+
*/
|
|
1479
|
+
async stats () {
|
|
1480
|
+
const t = this._traffic
|
|
1481
|
+
const routes = this._rtc ? await this._rtc.describe() : new Map()
|
|
1482
|
+
const peers = [...t.peers.values()].map((p) => {
|
|
1483
|
+
const pubkey = p.pubkey || (p.token && this._tokenPubkeys.get(p.token)) || null
|
|
1484
|
+
const rtc = p.token ? routes.get(p.token) : null
|
|
1485
|
+
return {
|
|
1486
|
+
token: p.token,
|
|
1487
|
+
pubkey,
|
|
1488
|
+
route: rtc && rtc !== 'failed' && rtc !== 'connecting' ? rtc : (rtc || 'proxy'),
|
|
1489
|
+
bytesIn: { ...p.bytesIn },
|
|
1490
|
+
bytesOut: { ...p.bytesOut },
|
|
1491
|
+
msgsIn: p.msgsIn,
|
|
1492
|
+
msgsOut: p.msgsOut,
|
|
1493
|
+
firstAt: p.firstAt,
|
|
1494
|
+
lastAt: p.lastAt
|
|
1495
|
+
}
|
|
1496
|
+
}).sort((a, b) => b.lastAt - a.lastAt)
|
|
1497
|
+
return {
|
|
1498
|
+
url: this.url,
|
|
1499
|
+
app: this.app,
|
|
1500
|
+
node: this.node || null,
|
|
1501
|
+
token: this.token,
|
|
1502
|
+
publickey: this.myPublickey,
|
|
1503
|
+
connected: this._connected,
|
|
1504
|
+
webrtc: !!this._rtc,
|
|
1505
|
+
since: t.since,
|
|
1506
|
+
proxy: { ...t.proxy },
|
|
1507
|
+
peers
|
|
1508
|
+
}
|
|
1399
1509
|
}
|
|
1400
1510
|
|
|
1401
1511
|
_request (frame, expectedType, channelKey) {
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CUÁNTO TRÁFICO Y POR DÓNDE. Contadores del transporte, por conexión.
|
|
3
|
+
*
|
|
4
|
+
* Existe para poder VER la regla del camino más directo (CLAUDE.md, «el transporte SIEMPRE
|
|
5
|
+
* prefiere el camino más directo»): sin números, «el segundo mensaje ya no toca el proxio»
|
|
6
|
+
* solo se podía comprobar con una prueba en contenedores. Con esto lo enseña cualquier app
|
|
7
|
+
* (el topbar tiene un modal que lo pinta).
|
|
8
|
+
*
|
|
9
|
+
* Se cuentan los BYTES DEL PAYLOAD que pasa por este cliente (UTF-8), no los del cable:
|
|
10
|
+
* las cabeceras de TCP, TLS, DTLS o SCTP no las ve JavaScript. Para el proxio se cuenta el
|
|
11
|
+
* frame entero; por conexión, lo que iba dirigido a ella.
|
|
12
|
+
*
|
|
13
|
+
* Los caminos:
|
|
14
|
+
* proxy por el proxio (incluye la señalización de WebRTC y el saludo)
|
|
15
|
+
* direct WebRTC sin relevo
|
|
16
|
+
* turn WebRTC por un relevo TURN
|
|
17
|
+
* webrtc WebRTC sin saber todavía cuál de los dos (getStats no contestó)
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/** Longitud en bytes UTF-8 de un string, sin reservar un buffer por mensaje. */
|
|
21
|
+
export function utf8Length (s) {
|
|
22
|
+
if (typeof s !== 'string') {
|
|
23
|
+
if (s && typeof s.byteLength === 'number') return s.byteLength
|
|
24
|
+
if (s && typeof s.size === 'number') return s.size
|
|
25
|
+
return 0
|
|
26
|
+
}
|
|
27
|
+
let n = 0
|
|
28
|
+
for (let i = 0; i < s.length; i++) {
|
|
29
|
+
const c = s.charCodeAt(i)
|
|
30
|
+
if (c < 0x80) n += 1
|
|
31
|
+
else if (c < 0x800) n += 2
|
|
32
|
+
else if (c >= 0xd800 && c <= 0xdbff && i + 1 < s.length) { n += 4; i++ } // par sustituto
|
|
33
|
+
else n += 3
|
|
34
|
+
}
|
|
35
|
+
return n
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export const PATHS = ['proxy', 'direct', 'turn', 'webrtc']
|
|
39
|
+
|
|
40
|
+
const ceros = () => ({ proxy: 0, direct: 0, turn: 0, webrtc: 0 })
|
|
41
|
+
|
|
42
|
+
export class TrafficStats {
|
|
43
|
+
constructor () {
|
|
44
|
+
this.since = Date.now()
|
|
45
|
+
this.proxy = { bytesIn: 0, bytesOut: 0, framesIn: 0, framesOut: 0 }
|
|
46
|
+
/** clave (`t:<token>` | `k:<pubkey>`) → contadores de esa conexión */
|
|
47
|
+
this.peers = new Map()
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Un frame entero del WebSocket del proxio. */
|
|
51
|
+
frame (dir, bytes) {
|
|
52
|
+
if (dir === 'in') { this.proxy.bytesIn += bytes; this.proxy.framesIn++ } else { this.proxy.bytesOut += bytes; this.proxy.framesOut++ }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Un mensaje dirigido a (o venido de) una conexión.
|
|
57
|
+
* @param {'in'|'out'} dir
|
|
58
|
+
* @param {'proxy'|'direct'|'turn'|'webrtc'} path
|
|
59
|
+
* @param {{ token?: string|null, pubkey?: string|null }} who
|
|
60
|
+
* @param {number} bytes
|
|
61
|
+
*/
|
|
62
|
+
peer (dir, path, who, bytes) {
|
|
63
|
+
const key = who.token ? `t:${who.token}` : who.pubkey ? `k:${who.pubkey}` : null
|
|
64
|
+
if (!key) return
|
|
65
|
+
let p = this.peers.get(key)
|
|
66
|
+
if (!p) {
|
|
67
|
+
p = { token: who.token || null, pubkey: who.pubkey || null, bytesIn: ceros(), bytesOut: ceros(), msgsIn: 0, msgsOut: 0, firstAt: Date.now(), lastAt: 0 }
|
|
68
|
+
this.peers.set(key, p)
|
|
69
|
+
}
|
|
70
|
+
if (who.pubkey && !p.pubkey) p.pubkey = who.pubkey
|
|
71
|
+
if (dir === 'in') { p.bytesIn[path] += bytes; p.msgsIn++ } else { p.bytesOut[path] += bytes; p.msgsOut++ }
|
|
72
|
+
p.lastAt = Date.now()
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* POR DÓNDE VA UN CANAL ABIERTO: `direct` o `turn`, según el par de candidatos que ICE
|
|
78
|
+
* eligió. Si cualquiera de las dos puntas es `relay`, hay un TURN en medio.
|
|
79
|
+
*
|
|
80
|
+
* `null` si no se puede saber (la implementación no tiene getStats, o aún no hay par
|
|
81
|
+
* elegido). No se adivina: «no lo sé» se enseña como tal.
|
|
82
|
+
*
|
|
83
|
+
* @param {any} pc RTCPeerConnection
|
|
84
|
+
* @returns {Promise<'direct'|'turn'|null>}
|
|
85
|
+
*/
|
|
86
|
+
export async function routeOf (pc) {
|
|
87
|
+
if (!pc || typeof pc.getStats !== 'function') return null
|
|
88
|
+
let report
|
|
89
|
+
try { report = await pc.getStats() } catch (_) { return null }
|
|
90
|
+
if (!report || typeof report.forEach !== 'function') return null
|
|
91
|
+
const byId = new Map()
|
|
92
|
+
report.forEach((s) => { if (s && s.id) byId.set(s.id, s) })
|
|
93
|
+
let pair = null
|
|
94
|
+
// El par vigente lo dice el transporte; si no, el que está nominado y funcionando.
|
|
95
|
+
for (const s of byId.values()) {
|
|
96
|
+
if (s.type === 'transport' && s.selectedCandidatePairId) { pair = byId.get(s.selectedCandidatePairId) || null; break }
|
|
97
|
+
}
|
|
98
|
+
if (!pair) {
|
|
99
|
+
for (const s of byId.values()) {
|
|
100
|
+
if (s.type === 'candidate-pair' && (s.selected || (s.nominated && s.state === 'succeeded'))) { pair = s; break }
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
if (!pair) return null
|
|
104
|
+
const local = byId.get(pair.localCandidateId)
|
|
105
|
+
const remote = byId.get(pair.remoteCandidateId)
|
|
106
|
+
if (!local && !remote) return null
|
|
107
|
+
const tipo = (c) => c && (c.candidateType || c.type)
|
|
108
|
+
return tipo(local) === 'relay' || tipo(remote) === 'relay' ? 'turn' : 'direct'
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* LOS CLIENTES VIVOS DE ESTA PÁGINA, para quien quiera enseñarlos sin que la app lo
|
|
113
|
+
* cablee (el topbar). Va en `globalThis` con un Symbol.for, no en una variable del módulo:
|
|
114
|
+
* una app puede llevar dos copias de este paquete (la suya y la de otro pilar), y las dos
|
|
115
|
+
* tienen que verse.
|
|
116
|
+
*/
|
|
117
|
+
const REGISTRY = Symbol.for('dotrino.transports')
|
|
118
|
+
|
|
119
|
+
function registry () {
|
|
120
|
+
if (!globalThis[REGISTRY]) globalThis[REGISTRY] = new Set()
|
|
121
|
+
return globalThis[REGISTRY]
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function avisar () {
|
|
125
|
+
if (typeof globalThis.dispatchEvent === 'function' && typeof Event === 'function') {
|
|
126
|
+
try { globalThis.dispatchEvent(new Event('dotrino-transports')) } catch (_) {}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function registerTransport (client) {
|
|
131
|
+
const r = registry()
|
|
132
|
+
if (r.has(client)) return
|
|
133
|
+
r.add(client)
|
|
134
|
+
avisar()
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function unregisterTransport (client) {
|
|
138
|
+
if (registry().delete(client)) avisar()
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Los clientes registrados en esta página (o proceso). */
|
|
142
|
+
export function listTransports () {
|
|
143
|
+
return [...registry()]
|
|
144
|
+
}
|
|
@@ -12,6 +12,8 @@
|
|
|
12
12
|
* the "polite" one (rolls back on collision).
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
+
import { routeOf, utf8Length } from './stats.js'
|
|
16
|
+
|
|
15
17
|
export const DEFAULT_ICE_SERVERS = [
|
|
16
18
|
{ urls: 'stun:stun.l.google.com:19302' },
|
|
17
19
|
{ urls: 'stun:stun1.l.google.com:19302' },
|
|
@@ -82,6 +84,8 @@ export class WebRTCManager {
|
|
|
82
84
|
* @param {(from: string, parsed: any, meta: any) => void} opts.deliverMessage forwards
|
|
83
85
|
* an incoming P2P payload as if it had arrived via the proxy
|
|
84
86
|
* @param {(event: string, ...args: any[]) => void} opts.emit
|
|
87
|
+
* @param {(dir: 'in'|'out', token: string, bytes: number, route: 'direct'|'turn'|'webrtc') => void} [opts.count]
|
|
88
|
+
* cuenta el tráfico que va por el canal (ver stats.js)
|
|
85
89
|
* @param {{iceServers?: any[]}} [opts.config]
|
|
86
90
|
*/
|
|
87
91
|
constructor (opts) {
|
|
@@ -90,6 +94,7 @@ export class WebRTCManager {
|
|
|
90
94
|
this.signalSend = opts.signalSend
|
|
91
95
|
this.deliverMessage = opts.deliverMessage
|
|
92
96
|
this.emit = opts.emit
|
|
97
|
+
this.count = typeof opts.count === 'function' ? opts.count : null
|
|
93
98
|
this.iceServers = (opts.config && opts.config.iceServers) || DEFAULT_ICE_SERVERS
|
|
94
99
|
this.peers = new Map() // remoteToken -> PeerState
|
|
95
100
|
}
|
|
@@ -128,6 +133,7 @@ export class WebRTCManager {
|
|
|
128
133
|
if (peer.dc && peer.dc.readyState === 'open') {
|
|
129
134
|
try {
|
|
130
135
|
peer.dc.send(payloadString)
|
|
136
|
+
this.count?.('out', to, utf8Length(payloadString), peer.route || 'webrtc')
|
|
131
137
|
return true
|
|
132
138
|
} catch (_) {
|
|
133
139
|
return false
|
|
@@ -168,6 +174,27 @@ export class WebRTCManager {
|
|
|
168
174
|
if (Array.isArray(list) && list.length) this.iceServers = list
|
|
169
175
|
}
|
|
170
176
|
|
|
177
|
+
/** Pregunta a ICE por dónde va el canal y lo apunta en `peer.route`. */
|
|
178
|
+
async probeRoute (peer) {
|
|
179
|
+
const r = await routeOf(peer.pc)
|
|
180
|
+
if (r) peer.route = r
|
|
181
|
+
return peer.route || null
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* El estado de cada canal, para las estadísticas: `connecting` mientras se negocia,
|
|
186
|
+
* `direct`/`turn`/`webrtc` con el canal abierto, `failed` si no salió.
|
|
187
|
+
*/
|
|
188
|
+
async describe () {
|
|
189
|
+
const out = new Map()
|
|
190
|
+
for (const peer of this.peers.values()) {
|
|
191
|
+
const open = !!(peer.dc && peer.dc.readyState === 'open')
|
|
192
|
+
if (open) { try { await this.probeRoute(peer) } catch (_) {} }
|
|
193
|
+
out.set(peer.remote, open ? (peer.route || 'webrtc') : peer.failed ? 'failed' : peer.negotiating ? 'connecting' : null)
|
|
194
|
+
}
|
|
195
|
+
return out
|
|
196
|
+
}
|
|
197
|
+
|
|
171
198
|
isOpen (to) {
|
|
172
199
|
const p = this.peers.get(to)
|
|
173
200
|
return !!(p && p.dc && p.dc.readyState === 'open')
|
|
@@ -253,6 +280,8 @@ export class WebRTCManager {
|
|
|
253
280
|
_attachDC (peer, dc) {
|
|
254
281
|
peer.dc = dc
|
|
255
282
|
dc.onopen = () => {
|
|
283
|
+
// Por dónde salió: directo o por TURN. Por detrás, sin retrasar a nadie.
|
|
284
|
+
this.probeRoute(peer).catch(() => {})
|
|
256
285
|
this.emit('webrtc_open', peer.remote)
|
|
257
286
|
const waiters = peer.openWaiters
|
|
258
287
|
peer.openWaiters = []
|
|
@@ -267,6 +296,7 @@ export class WebRTCManager {
|
|
|
267
296
|
dc.onmessage = (ev) => {
|
|
268
297
|
let parsed = null
|
|
269
298
|
const raw = ev.data
|
|
299
|
+
this.count?.('in', peer.remote, utf8Length(raw), peer.route || 'webrtc')
|
|
270
300
|
if (typeof raw === 'string') {
|
|
271
301
|
try { parsed = JSON.parse(raw) } catch (_) { parsed = null }
|
|
272
302
|
}
|