@dotrino/identity 0.10.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 seyacat
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,166 @@
1
+ # @dotrino/identity
2
+
3
+ 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`.
4
+
5
+ ## Cómo funciona
6
+
7
+ ```
8
+ ┌────────────────────┐ postMessage ┌────────────────────────┐
9
+ │ app (cualquier │ ◀───────────────▶ │ vault iframe │
10
+ │ origin: chat, │ │ origin: id.closer │
11
+ │ qrshare, chess…) │ │ .click │
12
+ │ │ │ - keypair ECDSA P-256 │
13
+ │ import {Identity} │ │ - keypair ECDH P-256 │
14
+ └────────────────────┘ │ - peers + ratings │
15
+ └────────────────────────┘
16
+ ```
17
+
18
+ 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.
19
+
20
+ ## Instalación
21
+
22
+ ```bash
23
+ npm install @dotrino/identity
24
+ ```
25
+
26
+ ## Uso
27
+
28
+ ```js
29
+ import { Identity } from '@dotrino/identity'
30
+
31
+ const id = await Identity.connect({
32
+ vaultUrl: 'https://id.dotrino.com/' // por defecto
33
+ })
34
+
35
+ console.log('my publickey JWK:', id.me.publickey)
36
+
37
+ // Identificar a un peer (handshake challenge/response)
38
+ const { nonce } = await id.makeChallenge()
39
+ // envía nonce al peer por el canal que sea (proxy, postMessage, etc.)
40
+ // el peer ejecuta await id.signChallenge(nonce) y te devuelve { nonce, publickey, signature }
41
+ const verification = await id.verifyResponse(response)
42
+ if (verification.ok) {
43
+ console.log('peer verificado:', verification.publickey)
44
+ await id.setNickname(verification.publickey, 'Bob de chess')
45
+ await id.setRating(verification.publickey, 4, 'buen rival')
46
+ }
47
+
48
+ const peers = await id.listPeers()
49
+ ```
50
+
51
+ ## Hosting del vault
52
+
53
+ Para que distintas apps compartan datos, todas deben apuntar al **mismo `vaultUrl`**. La carpeta `vault/` de este paquete es un sitio estático (HTML + JS) listo para subir a:
54
+
55
+ - Un dominio dedicado: `id.dotrino.com`
56
+ - O temporalmente: `https://id.dotrino.com/vault/`
57
+
58
+ Importante: subir vía HTTPS y configurar `Content-Security-Policy: frame-ancestors *` (o lista de orígenes permitidos) para permitir que las apps lo embeban.
59
+
60
+ ## API
61
+
62
+ ### `Identity.connect(options?)`
63
+ Inicializa el iframe y resuelve cuando el vault está listo.
64
+
65
+ | opción | tipo | default |
66
+ |--------------|----------|-------------------------------|
67
+ | `vaultUrl` | string | `https://id.dotrino.com/` |
68
+ | `timeoutMs` | number | `5000` |
69
+
70
+ ### Identidad propia
71
+
72
+ - `id.me` → `{ publickey, nickname? }`
73
+ - `id.setMyNickname(nickname)`
74
+
75
+ ### Handshake
76
+
77
+ - `id.makeChallenge()` → `{ nonce }`
78
+ - `id.signChallenge(nonce)` → `{ nonce, publickey, encryptionPubkey, signature }`
79
+ - `id.verifyResponse(response)` → `{ ok, publickey?, encryptionPubkey?, peer? }`
80
+
81
+ ### Peer book
82
+
83
+ - `id.getPeer(publickey)`
84
+ - `id.setNickname(publickey, nickname)`
85
+ - `id.setRating(publickey, rating, notes?)` — produce un envelope firmado y lo guarda como `peer.myRating` (rating 0–5).
86
+ - `id.mergeEndorsements(subject, [signedRatings], askerPubkey?)` — para web-of-trust: valida firmas, dedupea por `(ratedBy, subject)`, cap 50.
87
+ - `id.getRatingsForSubject(subject)` → `{ mine, endorsements }` para responder a un `RATING_QUERY`.
88
+ - `id.recordQuery(askerPubkey, subject?)` — contabiliza consultas para el suspicion modifier.
89
+ - `id.listPeers()`, `id.forgetPeer(publickey)`
90
+
91
+ ### Contactos compartidos (0.6.0+)
92
+
93
+ Mismo registro de `peers` que arriba, pero filtrado por flag `isContact: true`. Cualquier app del ecosistema (chat, chess, messenger, extensión) puede añadir/leer contactos del mismo address book.
94
+
95
+ - `id.addContact({ publickey, nickname?, encryptionPubkey?, lastToken?, notes? })`
96
+ - `id.updateContact(publickey, patch)` — patch limitado a `nickname`, `encryptionPubkey`, `lastToken`, `contactNotes`.
97
+ - `id.removeContact(publickey)` — quita el flag `isContact` pero conserva ratings/endorsements.
98
+ - `id.listContacts()` → array filtrado, ordenado por `lastSeen` desc.
99
+
100
+ ### Encripción E2E (0.5.0+)
101
+
102
+ - `id.getEncryptionPubkey()` → JWK string del propio peer.
103
+ - `id.encrypt(recipients, plaintext)` → `{v:1, iv, ct, wrap}` envelope. `recipients` = `[{token, encryptionPubkey}]`. AES-256-GCM con clave efímera por mensaje, envuelta para cada destinatario vía ECDH(P-256). El campo `token` puede ser cualquier identificador estable (en messenger se usa la pubkey del destinatario para sobrevivir cambios de token del proxy).
104
+ - `id.decrypt(senderEncryptionPubkey, myToken, envelope)` → `{ plaintext }`. Forward-secrecy por mensaje (clave simétrica nueva cada vez).
105
+
106
+ ### Firma genérica (0.7.0+)
107
+
108
+ - `id.signData(data)` → `{ signature, publickey }` con encoding canonical-JSON. Lo usa el messenger para construir sobres `identify` que el proxy verifica con su `verifySignatureWithJWK`.
109
+
110
+ ### Backup / migración
111
+
112
+ - `id.exportIdentity()` → blob JSON con `privateJwk` (ECDSA), `encPrivateJwk` (ECDH), `me`, `peers`. **Sensible** — el host app es responsable de guardarlo de manera segura.
113
+ - `id.importIdentity(blob)` → reemplaza la identidad local. Soporta blobs v1 (sin ECDH) y v2 (con ambas keys).
114
+
115
+ ### Auto-sync con Google Drive (0.8.0+)
116
+
117
+ Backup automático y sincronización multi-dispositivo de la identidad (claves + contactos + ratings + endorsements) usando Google Drive como almacén opaco. **Google nunca ve tus datos en claro**: el blob se cifra en el navegador con AES-256-GCM y una clave derivada por PBKDF2 (600 000 iteraciones) de una passphrase elegida por el usuario.
118
+
119
+ Topología:
120
+ - El blob se guarda en la carpeta especial `appDataFolder` de Drive (oculta para el usuario, no contamina su Drive).
121
+ - Scope OAuth requerido: solo `https://www.googleapis.com/auth/drive.appdata`.
122
+ - El cliente OAuth (Google Cloud Console → Web application) debe tener `https://id.dotrino.com` (y/o `http://localhost:5173` para dev) como Authorized JavaScript Origin.
123
+ - El sync corre **dentro del iframe del vault**: las claves privadas nunca cruzan el postMessage boundary.
124
+
125
+ ```js
126
+ // 1. Conectar Google (popup OAuth, una vez por origen)
127
+ await id.syncConnect('123456789-abc...apps.googleusercontent.com')
128
+
129
+ // 2. Desbloquear con passphrase (≥12 chars). Se guarda en sessionStorage del vault
130
+ // (per-tab) y se borra al cerrar la pestaña.
131
+ await id.syncUnlock('mi-passphrase-larga-y-secreta')
132
+
133
+ // 3. A partir de aquí: pull-on-unlock + push debounced (5s) + pull periódico (2 min).
134
+ // Cualquier mutación local (setRating, addContact, mergeEndorsements...) marca
135
+ // dirty automáticamente.
136
+
137
+ // Eventos de estado
138
+ id.onSync(({ status, error }) => console.log('sync:', status, error || ''))
139
+ // status: connected | unlocked | syncing | synced | conflict | offline | error | locked | disconnected
140
+
141
+ // Forzar pull+push inmediato
142
+ await id.syncNow()
143
+
144
+ // Bloquear (limpia la passphrase de memoria)
145
+ await id.syncLock()
146
+ ```
147
+
148
+ **Estrategia de merge** (al hacer pull, si remoto tiene cambios):
149
+ - Keypairs ECDSA/ECDH: **nunca se sobreescriben**. Solo se adopta el remoto si local está vacío (primer setup en device nuevo).
150
+ - Contact metadata (nickname, notes, encryptionPubkey, rating): last-writer-wins por `lastSeen`.
151
+ - `myRating`: el envelope firmado con mayor `issuedAt` gana.
152
+ - `endorsements`: unión dedup por `ratedBy`; firmas re-verificadas antes de aceptar.
153
+ - Concurrencia: optimistic-lock con `If-Match: <etag>` en Drive. Si 412, pull → merge → push (3 reintentos).
154
+
155
+ **Trade-off**: si el usuario pierde la passphrase, el blob es irrecuperable. Es coherente con E2E — ni Google ni el desarrollador pueden recuperarlo.
156
+
157
+ ## Diseño
158
+
159
+ - **Una sola identidad por navegador**, persistente entre apps que apuntan al mismo vault.
160
+ - **Sin servidor**: todo en `localStorage` del vault.
161
+ - **Replay protection**: cada `makeChallenge` registra el nonce; `verifyResponse` exige que el nonce sea reciente (≤ 5 min).
162
+ - **Privacidad**: la clave privada vive solo en el vault, las apps nunca la ven.
163
+
164
+ ## Licencia
165
+
166
+ MIT
package/package.json ADDED
@@ -0,0 +1,47 @@
1
+ {
2
+ "name": "@dotrino/identity",
3
+ "version": "0.10.0",
4
+ "description": "Identidad y rating de usuarios compartidos entre apps de Dotrino (vault iframe + postMessage)",
5
+ "type": "module",
6
+ "main": "src/index.js",
7
+ "module": "src/index.js",
8
+ "types": "src/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./src/index.d.ts",
12
+ "import": "./src/index.js"
13
+ },
14
+ "./node": {
15
+ "import": "./src/node.js"
16
+ },
17
+ "./capabilities": {
18
+ "import": "./vault/capabilities.js"
19
+ },
20
+ "./vault/core.js": "./vault/core.js"
21
+ },
22
+ "files": [
23
+ "src",
24
+ "vault",
25
+ "README.md",
26
+ "LICENSE"
27
+ ],
28
+ "scripts": {
29
+ "test": "node --test test/*.test.js"
30
+ },
31
+ "keywords": [
32
+ "identity",
33
+ "rating",
34
+ "dotrino",
35
+ "vault",
36
+ "cross-origin"
37
+ ],
38
+ "author": "seyacat",
39
+ "license": "MIT",
40
+ "repository": {
41
+ "type": "git",
42
+ "url": "git+https://github.com/imdotrino/dotrino-identity.git"
43
+ },
44
+ "devDependencies": {
45
+ "fake-indexeddb": "^6.2.5"
46
+ }
47
+ }
package/src/index.d.ts ADDED
@@ -0,0 +1,187 @@
1
+ export interface IdentityOptions {
2
+ vaultUrl?: string
3
+ timeoutMs?: number
4
+ }
5
+
6
+ export interface Me {
7
+ publickey: string
8
+ encryptionPubkey?: string
9
+ nickname?: string
10
+ }
11
+
12
+ export interface EnvelopeV1 {
13
+ v: 1
14
+ iv: string
15
+ ct: string
16
+ wrap: Record<string, { iv: string; ct: string }>
17
+ }
18
+
19
+ export interface EncryptRecipient {
20
+ token: string
21
+ encryptionPubkey: string
22
+ }
23
+
24
+ export interface SignedRating {
25
+ subject: string
26
+ rating: number
27
+ notes: string
28
+ ratedBy: string
29
+ issuedAt: number
30
+ signature: string
31
+ }
32
+
33
+ export interface QueryStats {
34
+ queriesMade: number
35
+ queriesKnown: number
36
+ }
37
+
38
+ export interface PeerInfo {
39
+ publickey: string
40
+ encryptionPubkey?: string
41
+ nickname?: string
42
+ rating?: number
43
+ notes?: string
44
+ myRating?: SignedRating | null
45
+ endorsements?: SignedRating[]
46
+ queryStats?: QueryStats
47
+ firstSeen?: number
48
+ lastSeen?: number
49
+ }
50
+
51
+ export interface Challenge {
52
+ nonce: string
53
+ }
54
+
55
+ export interface ChallengeResponse {
56
+ nonce: string
57
+ publickey: string
58
+ encryptionPubkey?: string
59
+ signature: string
60
+ }
61
+
62
+ export interface VerifyResult {
63
+ ok: boolean
64
+ publickey?: string
65
+ encryptionPubkey?: string | null
66
+ peer?: PeerInfo
67
+ }
68
+
69
+ export interface IdentityExport {
70
+ version: number
71
+ privateJwk: Record<string, any>
72
+ publicJwk: Record<string, any>
73
+ me: Me | null
74
+ peers: Record<string, PeerInfo>
75
+ exportedAt: string
76
+ }
77
+
78
+ export class Identity {
79
+ static connect (options?: IdentityOptions): Promise<Identity>
80
+ static current (): Identity | null
81
+ constructor (options?: IdentityOptions)
82
+ ready (): Promise<Identity>
83
+ destroy (): void
84
+ readonly me: Me | null
85
+ makeChallenge (): Promise<Challenge>
86
+ signChallenge (nonce: string): Promise<ChallengeResponse>
87
+ verifyResponse (response: ChallengeResponse): Promise<VerifyResult>
88
+ getPeer (publickey: string): Promise<PeerInfo | null>
89
+ setNickname (publickey: string, nickname: string): Promise<PeerInfo>
90
+ setRating (publickey: string, rating: number, notes?: string): Promise<PeerInfo>
91
+ listPeers (): Promise<PeerInfo[]>
92
+ forgetPeer (publickey: string): Promise<void>
93
+ addContact (input: {
94
+ publickey: string
95
+ nickname?: string
96
+ encryptionPubkey?: string
97
+ lastToken?: string
98
+ notes?: string
99
+ }): Promise<PeerInfo>
100
+ updateContact (
101
+ publickey: string,
102
+ patch: Partial<{ nickname: string; encryptionPubkey: string; lastToken: string; contactNotes: string }>
103
+ ): Promise<PeerInfo | null>
104
+ removeContact (publickey: string): Promise<PeerInfo | null>
105
+ listContacts (): Promise<PeerInfo[]>
106
+ signData (data: any): Promise<{ signature: string; publickey: string }>
107
+ setMyNickname (nickname: string): Promise<{ me: Me }>
108
+ getEncryptionPubkey (): Promise<string>
109
+ encrypt (recipients: EncryptRecipient[], plaintext: string): Promise<EnvelopeV1>
110
+ decrypt (
111
+ senderEncryptionPubkey: string,
112
+ myToken: string,
113
+ envelope: EnvelopeV1
114
+ ): Promise<{ plaintext: string }>
115
+ mergeEndorsements (
116
+ subject: string,
117
+ endorsements: SignedRating[],
118
+ askerPubkey?: string
119
+ ): Promise<{ merged: number; total: number }>
120
+ getRatingsForSubject (
121
+ subject: string
122
+ ): Promise<{ mine: SignedRating | null; endorsements: SignedRating[] }>
123
+ recordQuery (askerPubkey: string, subject?: string): Promise<PeerInfo | null>
124
+ /** Firma un certificado de delegación de capacidad para una sub-clave de dispositivo. */
125
+ signDelegation (sub: string, scope: string | string[], opts?: DelegationOpts): Promise<{ cert: CapabilityCert }>
126
+ /** Revoca una delegación por su nonce. */
127
+ revokeDelegation (nonce: string): Promise<{ ok: true; revokedAt: number }>
128
+ /** Lista las delegaciones emitidas + la lista de revocación. */
129
+ listDelegations (): Promise<{ issued: IssuedDelegation[]; revoked: { nonce: string; revokedAt: number }[] }>
130
+ exportIdentity (): Promise<IdentityExport>
131
+ importIdentity (blob: IdentityExport | Record<string, any>): Promise<{ me: Me }>
132
+ syncConnect (clientId: string): Promise<{ accessToken: string; expiresAt: number }>
133
+ syncDisconnect (): Promise<void>
134
+ syncUnlock (passphrase: string): Promise<{ ok: boolean }>
135
+ syncLock (): Promise<void>
136
+ syncStatus (): Promise<SyncStatus>
137
+ syncNow (): Promise<SyncStatus>
138
+ onSync (handler: (event: SyncEvent) => void): () => void
139
+ on (event: 'peer_updated' | 'me_updated' | 'sync', handler: (payload: any) => void): () => void
140
+ }
141
+
142
+ export interface SyncStatus {
143
+ kind?: 'identity' | 'store'
144
+ connected: boolean
145
+ unlocked: boolean
146
+ dirty: boolean
147
+ lastError?: string | null
148
+ }
149
+
150
+ export interface SyncEvent {
151
+ kind: 'identity' | 'store'
152
+ status: 'connected' | 'disconnected' | 'unlocked' | 'locked' | 'syncing' | 'synced' | 'conflict' | 'offline' | 'error'
153
+ error?: string
154
+ ts: number
155
+ }
156
+
157
+ // ----- delegación de capacidad (sub-clave de dispositivo con scope/exp/revocación) -----
158
+
159
+ /** Certificado de delegación firmado por la maestra (`iss`) para una sub-clave (`sub`). */
160
+ export interface CapabilityCert {
161
+ v: 1
162
+ iss: string // pubkey JWK string de la identidad maestra (emisor)
163
+ sub: string // pubkey JWK string de la clave de dispositivo (sujeto)
164
+ scope: string | string[]
165
+ iat: number // ms epoch
166
+ exp: number // ms epoch (tope MAX_DELEGATION_MS)
167
+ nonce: string // mango de revocación
168
+ sig: string // base64 de la firma cruda ECDSA de la maestra sobre el cuerpo canónico
169
+ }
170
+ export interface DelegationOpts { ttlMs?: number; exp?: number; nonce?: string; label?: string }
171
+ export interface IssuedDelegation { nonce: string; sub: string; scope: string | string[]; iat: number; exp: number; label?: string; revokedAt?: number }
172
+ /** Sub-clave de dispositivo generada localmente (la privada nunca va a la maestra). */
173
+ export interface DeviceKey { publickey: string; privateJwk: JsonWebKey; publicJwk: JsonWebKey; label: string; createdAt: number; deviceId: string }
174
+
175
+ export const MAX_DELEGATION_MS: number
176
+ export const DEFAULT_DELEGATION_MS: number
177
+ /** Genera una sub-clave de dispositivo `D` (corre EN el dispositivo). */
178
+ export function makeDeviceKey (opts?: { label?: string }): Promise<DeviceKey>
179
+ /** id corto y estable de un pubkey (sha-256 hex de los campos canónicos del JWK). */
180
+ export function pubkeyId (publicJwkStr: string): Promise<string>
181
+ /** Firma datos con una clave de dispositivo (formato byte-idéntico a signData). */
182
+ export function signWithDevice (args: { privateJwk: JsonWebKey; data: any }): Promise<{ signature: string; publickey: string }>
183
+ /** Verifica un certificado de delegación (firma de la maestra + exp + scope + sub + revocación). */
184
+ export function verifyDelegation (args: { cert: CapabilityCert; expectedScope?: string; expectedSub?: string; now?: number; revoked?: ((nonce: string) => boolean) | Set<string> | Record<string, any> }): Promise<{ ok: boolean; reason?: string; iss?: string; sub?: string; scope?: string | string[]; iat?: number; exp?: number; nonce?: string }>
185
+ /** Verifica la cadena de una acción/pin delegado: D firmó + cert prueba D←P + scope/exp/revocación. */
186
+ export function verifyChain (args: { data: any; signature: string; cert: CapabilityCert; expectedScope?: string; trustedIssuer?: string; now?: number; revoked?: ((nonce: string) => boolean) | Set<string> | Record<string, any> }): Promise<{ ok: boolean; reason?: string; issuer?: string; device?: string }>
187
+