@dotrino/identity 0.17.0 → 0.21.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotrino/identity",
3
- "version": "0.17.0",
3
+ "version": "0.21.0",
4
4
  "description": "Identidad y rating de usuarios compartidos entre apps de Dotrino (vault iframe + postMessage)",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -17,7 +17,11 @@
17
17
  "./capabilities": {
18
18
  "import": "./vault/capabilities.js"
19
19
  },
20
- "./vault/core.js": "./vault/core.js"
20
+ "./avatar": {
21
+ "import": "./vault/avatar.js"
22
+ },
23
+ "./vault/core.js": "./vault/core.js",
24
+ "./vault/remote.js": "./vault/remote.js"
21
25
  },
22
26
  "files": [
23
27
  "src",
package/src/index.d.ts CHANGED
@@ -3,10 +3,29 @@ export interface IdentityOptions {
3
3
  timeoutMs?: number
4
4
  }
5
5
 
6
+ export interface ProfileLink { id: string; type: string; value: string; visible?: boolean }
7
+ export interface ProfileField { id: string; label: string; value: string; visible?: boolean }
8
+
6
9
  export interface Me {
7
10
  publickey: string
8
11
  encryptionPubkey?: string
9
12
  nickname?: string
13
+ avatar?: string | null
14
+ avatarVisible?: boolean
15
+ links?: ProfileLink[]
16
+ fields?: ProfileField[]
17
+ /** Campos personales estándar (fijos). */
18
+ nombres?: string
19
+ apellidos?: string
20
+ email?: string
21
+ telefono?: string
22
+ direccion?: string
23
+ /** Visibilidad por campo estándar. telefono/direccion ocultos por defecto. */
24
+ nombresVisible?: boolean
25
+ apellidosVisible?: boolean
26
+ emailVisible?: boolean
27
+ telefonoVisible?: boolean
28
+ direccionVisible?: boolean
10
29
  }
11
30
 
12
31
  export interface EnvelopeV1 {
package/src/index.js CHANGED
@@ -32,9 +32,67 @@ export class Identity {
32
32
  // ready() es idempotente (devuelve la misma promesa), así que esto es
33
33
  // seguro de llamar en cada connect().
34
34
  await singleton.ready()
35
+ // Perfil protegido con contraseña/PIN (candado LOCAL del dispositivo): pedirla
36
+ // aquí, una vez por PESTAÑA (el iframe recuerda el desbloqueo en sessionStorage
37
+ // → no re-pide al refrescar). Las apps no tienen que hacer nada.
38
+ if (singleton._locked && typeof document !== 'undefined' && options.promptUnlock !== false) {
39
+ await singleton._promptUnlock()
40
+ }
35
41
  return singleton
36
42
  }
37
43
 
44
+ /** Overlay mínimo de desbloqueo (PIN/contraseña). Resuelve al desbloquear. */
45
+ async _promptUnlock () {
46
+ const es = !(navigator.language || 'es').startsWith('en')
47
+ const T = es
48
+ ? { t: 'Perfil protegido', p: 'PIN o contraseña', b: 'Desbloquear', e: 'Contraseña incorrecta' }
49
+ : { t: 'Protected profile', p: 'PIN or password', b: 'Unlock', e: 'Wrong password' }
50
+ return new Promise((resolve) => {
51
+ const back = document.createElement('div')
52
+ back.style.cssText = 'position:fixed;inset:0;background:rgba(10,8,20,.8);z-index:2147483000;display:flex;align-items:center;justify-content:center;font-family:system-ui,sans-serif'
53
+ back.innerHTML = `<form style="background:#171331;border:1px solid #2a2350;border-radius:16px;padding:22px;min-width:260px;max-width:90vw;color:#e7e3ff">
54
+ <div style="font-weight:700;margin-bottom:10px">🔒 ${T.t}</div>
55
+ <input type="password" inputmode="numeric" autocomplete="current-password" placeholder="${T.p}"
56
+ style="width:100%;box-sizing:border-box;padding:10px 12px;border-radius:10px;border:1px solid #2a2350;background:#0b0820;color:inherit;font:inherit" />
57
+ <div data-err style="color:#e5484d;font-size:13px;min-height:18px;margin:6px 0 8px"></div>
58
+ <button type="submit" style="width:100%;padding:10px;border-radius:10px;border:0;background:#7c3aed;color:#fff;font:inherit;font-weight:600;cursor:pointer">${T.b}</button>
59
+ </form>`
60
+ const form = back.firstElementChild
61
+ const input = form.querySelector('input')
62
+ const err = form.querySelector('[data-err]')
63
+ form.addEventListener('submit', async (e) => {
64
+ e.preventDefault()
65
+ err.textContent = ''
66
+ try {
67
+ await this._call('unlockProfile', { password: input.value })
68
+ this._locked = false
69
+ try { this._me = await this._call('getMe') } catch (_) {}
70
+ back.remove()
71
+ resolve(this)
72
+ } catch (ex) {
73
+ err.textContent = /incorrecta/.test(ex.message) ? T.e : ex.message
74
+ input.select()
75
+ }
76
+ })
77
+ document.body.appendChild(back)
78
+ input.focus()
79
+ })
80
+ }
81
+
82
+ /** Estado del candado del perfil activo: { protected, locked }. */
83
+ async profileLockStatus () { return this._call('profileLockStatus') }
84
+ /** Desbloquea el perfil (la prueba queda en sessionStorage: por pestaña). */
85
+ async unlockProfile (password) {
86
+ const r = await this._call('unlockProfile', { password })
87
+ this._locked = false
88
+ try { this._me = await this._call('getMe') } catch (_) {}
89
+ return r
90
+ }
91
+ /** Protege el perfil ACTIVO con contraseña/PIN — LOCAL de este dispositivo. */
92
+ async setProfilePassword (password) { return this._call('setProfilePassword', { password }) }
93
+ /** Quita la protección (requiere estar desbloqueado). */
94
+ async removeProfilePassword () { return this._call('removeProfilePassword') }
95
+
38
96
  static current () {
39
97
  return singleton
40
98
  }
@@ -64,7 +122,8 @@ export class Identity {
64
122
 
65
123
  if (msg.type === 'ready') {
66
124
  clearTimeout(timeout)
67
- this._me = msg.me
125
+ this._me = msg.me || null
126
+ this._locked = !!msg.locked
68
127
  this._readyResolve(this)
69
128
  return
70
129
  }
@@ -307,9 +366,11 @@ export class Identity {
307
366
  }
308
367
 
309
368
  /**
310
- * Actualiza tu PERFIL (merge): `{ nickname?, avatar?, avatarVisible?, links?, fields? }`.
369
+ * Actualiza tu PERFIL (merge): `{ nickname?, avatar?, avatarVisible?, links?, fields?,
370
+ * nombres?, apellidos?, email?, telefono?, direccion? }` (+ sus flags `<campo>Visible`).
311
371
  * `avatar` = data-URI 250×250 (o null para quitarla); `links`/`fields` = arrays con `visible`
312
- * por ítem (oculto = no se comparte). No pisa lo que no mandes.
372
+ * por ítem (oculto = no se comparte). `telefono`/`direccion` son sensibles: ocultos por
373
+ * defecto (solo se comparten si su flag === true). No pisa lo que no mandes.
313
374
  */
314
375
  async updateMe (patch) {
315
376
  const result = await this._call('updateMe', { patch })
@@ -0,0 +1,65 @@
1
+ /**
2
+ * @dotrino/identity/avatar — el AVATAR identicon, solo.
3
+ *
4
+ * Vive aparte de `capabilities.js` a propósito: es una función pura de ~30
5
+ * líneas, sin dependencias, sin crypto y sin vault, pero `capabilities.js`
6
+ * importa `core.js` (55 KB) y `core.js` importa de vuelta `capabilities.js`
7
+ * (dependencia circular). O sea que quien solo quería el identicon —el topbar,
8
+ * la tarjeta de perfil— se arrastraba el vault entero, o dependía de que el
9
+ * bundler podara `core.js` de milagro (ningún paquete declara `sideEffects`).
10
+ *
11
+ * Este subpath da la garantía: importar el avatar cuesta el avatar.
12
+ *
13
+ * NO copies estas funciones dentro de tu app. El identicon es DETERMINISTA a
14
+ * partir del pubkey: si cada app llevara su copia, el mismo usuario podría
15
+ * derivar un avatar distinto según dónde lo miren.
16
+ *
17
+ * `capabilities.js` re-exporta de aquí, así que los importadores viejos siguen
18
+ * funcionando igual.
19
+ */
20
+
21
+ /** Hash FNV-1a + xorshift. Sin necesidades de seguridad: es decorativo. */
22
+ function _hashSeed (s) {
23
+ let h = 2166136261 >>> 0
24
+ for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 16777619) }
25
+ const bytes = []
26
+ let x = (h ^ 0x9e3779b9) >>> 0
27
+ for (let i = 0; i < 16; i++) { x ^= x << 13; x ^= x >>> 17; x ^= x << 5; x >>>= 0; bytes.push(x & 0xff) }
28
+ return { h: h >>> 0, bytes }
29
+ }
30
+
31
+ /**
32
+ * Identicon determinista de una semilla (normalmente el pubkey): así cada perfil
33
+ * nace con imagen sin que el usuario tenga que subir nada. Síncrono → usable
34
+ * directo en plantillas. Rejilla 5×5 simétrica sobre una "moneda" redondeada,
35
+ * con color derivado del hash.
36
+ */
37
+ export function avatarSvg (seed, { size = 80 } = {}) {
38
+ const { h, bytes } = _hashSeed(String(seed || 'dotrino'))
39
+ const hue = h % 360
40
+ const hue2 = (hue + 40) % 360
41
+ const fg = `hsl(${hue} 62% 46%)`
42
+ const bg1 = `hsl(${hue} 48% 95%)`
43
+ const bg2 = `hsl(${hue2} 48% 90%)`
44
+ const cells = 5
45
+ const unit = size / cells
46
+ let rects = ''
47
+ for (let col = 0; col < 3; col++) {
48
+ for (let row = 0; row < cells; row++) {
49
+ if (!(bytes[col * cells + row] & 1)) continue
50
+ for (const c of (col === 2 ? [2] : [col, cells - 1 - col])) {
51
+ rects += `<rect x="${(c * unit).toFixed(2)}" y="${(row * unit).toFixed(2)}" width="${unit.toFixed(2)}" height="${unit.toFixed(2)}"/>`
52
+ }
53
+ }
54
+ }
55
+ const id = 'g' + (h % 100000)
56
+ return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${size} ${size}" width="${size}" height="${size}">` +
57
+ `<defs><linearGradient id="${id}" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="${bg1}"/><stop offset="1" stop-color="${bg2}"/></linearGradient></defs>` +
58
+ `<rect width="${size}" height="${size}" rx="${(size * 0.5).toFixed(2)}" fill="url(#${id})"/>` +
59
+ `<g fill="${fg}" transform="translate(${(size * 0.12).toFixed(2)} ${(size * 0.12).toFixed(2)}) scale(0.76)">${rects}</g></svg>`
60
+ }
61
+
62
+ /** El avatar como data-URI listo para `<img src>` o `background-image`. */
63
+ export function avatarDataUri (seed, opts) {
64
+ return 'data:image/svg+xml,' + encodeURIComponent(avatarSvg(seed, opts))
65
+ }
@@ -99,49 +99,11 @@ export async function commitCode ({ code, dpub, sn }) {
99
99
  return [...new Uint8Array(h)].map((x) => x.toString(16).padStart(2, '0')).join('')
100
100
  }
101
101
 
102
- /**
103
- * Avatar generado (identicon de Dotrino): SVG DETERMINISTA a partir de una semilla
104
- * (típicamente el pubkey del perfil) cada perfil/identidad nace con imagen, sin que el
105
- * usuario tenga que subir nada. Síncrono (hash FNV-1a + xorshift, sin necesidades de
106
- * seguridad: es solo decorativo) → usable directo en plantillas. Rejilla 5×5 simétrica
107
- * sobre una "moneda" redondeada, con color derivado del hash.
108
- */
109
- function _hashSeed (s) {
110
- let h = 2166136261 >>> 0
111
- for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 16777619) }
112
- const bytes = []
113
- let x = (h ^ 0x9e3779b9) >>> 0
114
- for (let i = 0; i < 16; i++) { x ^= x << 13; x ^= x >>> 17; x ^= x << 5; x >>>= 0; bytes.push(x & 0xff) }
115
- return { h: h >>> 0, bytes }
116
- }
117
- export function avatarSvg (seed, { size = 80 } = {}) {
118
- const { h, bytes } = _hashSeed(String(seed || 'dotrino'))
119
- const hue = h % 360
120
- const hue2 = (hue + 40) % 360
121
- const fg = `hsl(${hue} 62% 46%)`
122
- const bg1 = `hsl(${hue} 48% 95%)`
123
- const bg2 = `hsl(${hue2} 48% 90%)`
124
- const cells = 5
125
- const unit = size / cells
126
- let rects = ''
127
- for (let col = 0; col < 3; col++) {
128
- for (let row = 0; row < cells; row++) {
129
- if (!(bytes[col * cells + row] & 1)) continue
130
- for (const c of (col === 2 ? [2] : [col, cells - 1 - col])) {
131
- rects += `<rect x="${(c * unit).toFixed(2)}" y="${(row * unit).toFixed(2)}" width="${unit.toFixed(2)}" height="${unit.toFixed(2)}"/>`
132
- }
133
- }
134
- }
135
- const id = 'g' + (h % 100000)
136
- return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${size} ${size}" width="${size}" height="${size}">` +
137
- `<defs><linearGradient id="${id}" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="${bg1}"/><stop offset="1" stop-color="${bg2}"/></linearGradient></defs>` +
138
- `<rect width="${size}" height="${size}" rx="${(size * 0.5).toFixed(2)}" fill="url(#${id})"/>` +
139
- `<g fill="${fg}" transform="translate(${(size * 0.12).toFixed(2)} ${(size * 0.12).toFixed(2)}) scale(0.76)">${rects}</g></svg>`
140
- }
141
- /** El avatar como data-URI listo para `<img src>` o `background-image`. */
142
- export function avatarDataUri (seed, opts) {
143
- return 'data:image/svg+xml,' + encodeURIComponent(avatarSvg(seed, opts))
144
- }
102
+ // El identicon vive en ./avatar.js (función pura, sin dependencias) y se
103
+ // re-exporta aquí por compatibilidad: importarlo desde este módulo arrastra
104
+ // core.js (55 KB), así que si SOLO quieres el avatar usa el subpath
105
+ // '@dotrino/identity/avatar'.
106
+ export { avatarSvg, avatarDataUri } from './avatar.js'
145
107
 
146
108
  /** Cuerpo canónico del certificado (lo que se firma): el cert SIN la firma. */
147
109
  export function delegationBody (cert) {
@@ -175,10 +137,17 @@ export async function signDelegationWith (privateKey, iss, { sub, scope, iat, ex
175
137
  * Firma datos con la clave de DISPOSITIVO (formato byte-idéntico a `signData` del
176
138
  * vault → lo que el dispositivo/bridge usa para firmar cada pin/acción).
177
139
  */
178
- export async function signWithDevice ({ privateJwk, data }) {
140
+ export async function signWithDevice ({ privateJwk, privateKey, publickey, data }) {
141
+ // `privateKey` (CryptoKey, posiblemente NO extractable) tiene prioridad: firma
142
+ // sin tocar bytes de la privada. Con CryptoKey es obligatorio pasar `publickey`.
143
+ if (privateKey) {
144
+ if (!publickey) throw new Error('signWithDevice: con privateKey (CryptoKey) se requiere publickey')
145
+ const signature = await rawSign(privateKey, enc(canonicalStringify(data)))
146
+ return { signature, publickey }
147
+ }
179
148
  const priv = await crypto.subtle.importKey('jwk', privateJwk, ECDSA, true, ['sign'])
180
149
  const signature = await rawSign(priv, enc(canonicalStringify(data)))
181
- return { signature, publickey: JSON.stringify(publicOf(privateJwk)) }
150
+ return { signature, publickey: publickey || JSON.stringify(publicOf(privateJwk)) }
182
151
  }
183
152
 
184
153
  /**
package/vault/core.js CHANGED
@@ -19,7 +19,7 @@
19
19
  */
20
20
 
21
21
  import { signDelegationWith, MAX_DELEGATION_MS, DEFAULT_DELEGATION_MS } from './capabilities.js'
22
- import { enrollDevice as remoteEnroll, requestSign as remoteSign, requestStore as remoteStore, requestDevices as remoteDevices } from './remote.js'
22
+ import { enrollDevice as remoteEnroll, requestSign as remoteSign, requestStore as remoteStore, requestDevices as remoteDevices, requestRenew as remoteRenew } from './remote.js'
23
23
 
24
24
  export const KEY_STORAGE = 'dotrino.identity.keypair'
25
25
  export const ENC_KEY_STORAGE = 'dotrino.identity.enc-keypair'
@@ -108,21 +108,37 @@ async function verifyBytes (publicJwkStr, bytes, signatureBase64) {
108
108
  * @returns {Promise<{ handlers:Object, get me():Object, sync:Object|null,
109
109
  * onSyncStatus(fn):void }>}
110
110
  */
111
- // Sanea un patch de perfil (avatar/links/fields/nickname). Cada link/field lleva `visible`
112
- // (oculto = no se comparte). Caps de tamaño para no inflar el `me`. Los ids los pone la UI.
111
+ // Campos personales estándar del perfil (escalares fijos), con su cap de longitud.
112
+ // Cada uno tiene un flag `<campo>Visible` (booleano) para mostrar/ocultar al compartir.
113
+ const STD_FIELD_CAPS = [
114
+ ['nombres', 60], ['apellidos', 60], ['email', 120], ['telefono', 40], ['direccion', 200]
115
+ ]
116
+ // Datos sensibles: OCULTOS por defecto. `publicMe` solo los incluye si su flag === true
117
+ // (los demás campos estándar se comparten salvo que su flag sea false).
118
+ const STD_FIELDS_SENSITIVE = new Set(['telefono', 'direccion'])
119
+
120
+ // Sanea un patch de perfil (avatar/links/fields/nickname + campos estándar). Cada link/field
121
+ // lleva `visible` (oculto = no se comparte). Caps de tamaño para no inflar el `me`. Los ids los pone la UI.
113
122
  function sanitizeProfilePatch (patch = {}) {
114
123
  const out = {}
115
124
  if (typeof patch.nickname === 'string') out.nickname = patch.nickname.slice(0, 40)
125
+ // Campos personales estándar (Nombres/Apellidos/Correo/Teléfono/Dirección) + su visibilidad.
126
+ for (const [k, cap] of STD_FIELD_CAPS) {
127
+ if (typeof patch[k] === 'string') out[k] = patch[k].slice(0, cap)
128
+ const vk = k + 'Visible'
129
+ if (typeof patch[vk] === 'boolean') out[vk] = patch[vk]
130
+ }
116
131
  if (patch.avatar === null) out.avatar = null
117
132
  else if (typeof patch.avatar === 'string') out.avatar = patch.avatar.slice(0, 120000) // ~90KB: data-URI 250x250
118
133
  if (typeof patch.avatarVisible === 'boolean') out.avatarVisible = patch.avatarVisible
119
134
  if (Array.isArray(patch.links)) {
120
- out.links = patch.links.slice(0, 12).map((l) => ({
135
+ // Filtra vacíos ANTES del tope: un draft vacío no debe consumir cupo ni desplazar un enlace real.
136
+ out.links = patch.links.slice(0, 100).map((l) => ({
121
137
  id: String(l?.id || '').slice(0, 24),
122
138
  type: String(l?.type || 'web').slice(0, 16),
123
139
  value: String(l?.value || '').slice(0, 200),
124
140
  visible: l?.visible !== false
125
- })).filter((l) => l.value)
141
+ })).filter((l) => l.value).slice(0, 30)
126
142
  }
127
143
  if (Array.isArray(patch.fields)) {
128
144
  out.fields = patch.fields.slice(0, 20).map((f) => ({
@@ -135,7 +151,7 @@ function sanitizeProfilePatch (patch = {}) {
135
151
  return out
136
152
  }
137
153
 
138
- export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null }) {
154
+ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, keyStore = null, sessionKv = null }) {
139
155
  const {
140
156
  initPeerStorage, loadPeers, savePeers, setPeersDirect, upsertPeer, onDirty
141
157
  } = peers
@@ -156,42 +172,62 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null })
156
172
  const loadProfiles = () => { try { return JSON.parse(rawKv.getItem(PROFILES_STORAGE) || '[]') || [] } catch { return [] } }
157
173
  const saveProfiles = (list) => rawKv.setItem(PROFILES_STORAGE, JSON.stringify(list))
158
174
 
159
- // ----- keypair loaders (kv-backed) -----
175
+ // ----- keypair loaders -----
176
+ // Con `keyStore` (IndexedDB del navegador): la privada vive como CryptoKey
177
+ // NO EXTRACTABLE — puede FIRMAR/DERIVAR pero nadie (ni este código, ni un XSS)
178
+ // puede leer sus bytes. Migración transparente: si existe el JWK plano viejo en
179
+ // kv, se importa como no extractable y se BORRA el plano. Sin keyStore
180
+ // (Node/tests) se conserva el comportamiento kv anterior.
160
181
 
161
- async function loadOrCreateKeypair () {
162
- const raw = kv.getItem(KEY_STORAGE)
163
- if (raw) {
164
- try {
165
- const { privateJwk, publicJwk } = JSON.parse(raw)
166
- const privateKey = await crypto.subtle.importKey('jwk', privateJwk, { name: 'ECDSA', namedCurve: 'P-256' }, true, ['sign'])
167
- const publicKey = await crypto.subtle.importKey('jwk', publicJwk, { name: 'ECDSA', namedCurve: 'P-256' }, true, ['verify'])
168
- return { privateKey, publicKey, publicJwk }
169
- } catch (_) {}
170
- }
171
- const pair = await crypto.subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, true, ['sign', 'verify'])
172
- const privateJwk = await crypto.subtle.exportKey('jwk', pair.privateKey)
173
- const publicJwk = await crypto.subtle.exportKey('jwk', pair.publicKey)
174
- kv.setItem(KEY_STORAGE, JSON.stringify({ privateJwk, publicJwk }))
175
- return { privateKey: pair.privateKey, publicKey: pair.publicKey, publicJwk }
182
+ const ALGO_OF = {
183
+ sign: { algo: { name: 'ECDSA', namedCurve: 'P-256' }, privUses: ['sign'], pubUses: ['verify'], pairUses: ['sign', 'verify'] },
184
+ enc: { algo: { name: 'ECDH', namedCurve: 'P-256' }, privUses: ['deriveBits', 'deriveKey'], pubUses: [], pairUses: ['deriveBits', 'deriveKey'] }
176
185
  }
177
186
 
178
- async function loadOrCreateEncKeypair () {
179
- const raw = kv.getItem(ENC_KEY_STORAGE)
187
+ async function loadOrCreatePair (kind, storageKey) {
188
+ const { algo, privUses, pubUses, pairUses } = ALGO_OF[kind]
189
+ const importPub = (jwk) => crypto.subtle.importKey('jwk', jwk, algo, true, pubUses)
190
+ if (keyStore) {
191
+ const name = _scoped(storageKey)
192
+ const stored = await keyStore.get(name).catch(() => null)
193
+ if (stored?.privateKey && stored?.publicJwk) {
194
+ return { privateKey: stored.privateKey, publicKey: await importPub(stored.publicJwk), publicJwk: stored.publicJwk }
195
+ }
196
+ // migrar el JWK plano viejo (si hay) → no extractable + borrar el plano
197
+ const raw = kv.getItem(storageKey)
198
+ if (raw) {
199
+ try {
200
+ const { privateJwk, publicJwk } = JSON.parse(raw)
201
+ const privateKey = await crypto.subtle.importKey('jwk', privateJwk, algo, false, privUses)
202
+ await keyStore.set(name, { privateKey, publicJwk })
203
+ kv.removeItem(storageKey)
204
+ return { privateKey, publicKey: await importPub(publicJwk), publicJwk }
205
+ } catch (_) {}
206
+ }
207
+ const pair = await crypto.subtle.generateKey(algo, false, pairUses) // privada NO extractable
208
+ const publicJwk = await crypto.subtle.exportKey('jwk', pair.publicKey)
209
+ await keyStore.set(name, { privateKey: pair.privateKey, publicJwk })
210
+ return { privateKey: pair.privateKey, publicKey: pair.publicKey, publicJwk }
211
+ }
212
+ // ---- camino legado (sin keyStore): JWK en kv, extractable ----
213
+ const raw = kv.getItem(storageKey)
180
214
  if (raw) {
181
215
  try {
182
216
  const { privateJwk, publicJwk } = JSON.parse(raw)
183
- const privateKey = await crypto.subtle.importKey('jwk', privateJwk, { name: 'ECDH', namedCurve: 'P-256' }, true, ['deriveBits', 'deriveKey'])
184
- const publicKey = await crypto.subtle.importKey('jwk', publicJwk, { name: 'ECDH', namedCurve: 'P-256' }, true, [])
185
- return { privateKey, publicKey, publicJwk }
217
+ const privateKey = await crypto.subtle.importKey('jwk', privateJwk, algo, true, privUses)
218
+ return { privateKey, publicKey: await importPub(publicJwk), publicJwk }
186
219
  } catch (_) {}
187
220
  }
188
- const pair = await crypto.subtle.generateKey({ name: 'ECDH', namedCurve: 'P-256' }, true, ['deriveBits', 'deriveKey'])
221
+ const pair = await crypto.subtle.generateKey(algo, true, pairUses)
189
222
  const privateJwk = await crypto.subtle.exportKey('jwk', pair.privateKey)
190
223
  const publicJwk = await crypto.subtle.exportKey('jwk', pair.publicKey)
191
- kv.setItem(ENC_KEY_STORAGE, JSON.stringify({ privateJwk, publicJwk }))
224
+ kv.setItem(storageKey, JSON.stringify({ privateJwk, publicJwk }))
192
225
  return { privateKey: pair.privateKey, publicKey: pair.publicKey, publicJwk }
193
226
  }
194
227
 
228
+ const loadOrCreateKeypair = () => loadOrCreatePair('sign', KEY_STORAGE)
229
+ const loadOrCreateEncKeypair = () => loadOrCreatePair('enc', ENC_KEY_STORAGE)
230
+
195
231
  // ----- nonce replay protection (kv-backed) -----
196
232
 
197
233
  function loadNonces () {
@@ -233,7 +269,48 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null })
233
269
  throw e
234
270
  }
235
271
  const loadVaultCert = () => { try { return JSON.parse(kv.getItem(VAULT_CERT_STORAGE) || 'null') } catch (_) { return null } }
236
- const loadVaultDevice = () => { try { return JSON.parse(kv.getItem(VAULT_DEVICE_STORAGE) || 'null') } catch (_) { return null } }
272
+ const loadVaultDevice = () => {
273
+ try {
274
+ const d = JSON.parse(kv.getItem(VAULT_DEVICE_STORAGE) || 'null')
275
+ if (!d) return null
276
+ // Marcador nuevo (o JWK legado que ES la llave del perfil): usar la CryptoKey
277
+ // no extractable del perfil para firmar; nada de privadas en claro.
278
+ if (d.useIdentityKey || (d.publickey === publickeyJwkStr && !d.privateJwk)) {
279
+ return { publickey: publickeyJwkStr, privateKey: keypair.privateKey }
280
+ }
281
+ // MIGRACIÓN: el emparejamiento viejo persistía la privada del perfil en
282
+ // claro aquí. Si es la misma llave del perfil, reemplazar por el marcador
283
+ // (borra el último JWK plano) y firmar con la CryptoKey.
284
+ if (d.privateJwk && d.publickey === publickeyJwkStr) {
285
+ kv.setItem(VAULT_DEVICE_STORAGE, JSON.stringify({ useIdentityKey: true, publickey: publickeyJwkStr }))
286
+ return { publickey: publickeyJwkStr, privateKey: keypair.privateKey }
287
+ }
288
+ return d // legado real (dispositivo con llave propia distinta)
289
+ } catch (_) { return null }
290
+ }
291
+
292
+ // ----- renovación AUTOMÁTICA del cert (sin QR ni aprobación) -----
293
+ // Con el cert aún vigente y quedando <15 días, cualquier uso del vault dispara en
294
+ // segundo plano un `vault.renew`: el vault firma un cert fresco (30 días) para la
295
+ // misma sub-clave y scope. Mientras uses el ecosistema ~1 vez al mes, nunca vence.
296
+ // Un cert YA vencido o revocado no puede renovarse (ahí sí, re-emparejar).
297
+ const RENEW_WINDOW_MS = 15 * 24 * 60 * 60 * 1000
298
+ const RENEW_RETRY_MS = 60 * 60 * 1000 // si falla (vault apagado), no insistir >1 vez/hora
299
+ let renewLastTry = 0
300
+ function maybeRenewVaultCert () {
301
+ try {
302
+ const v = loadVaultCert(); const device = loadVaultDevice()
303
+ if (!v?.cert || !device) return
304
+ const now = Date.now()
305
+ if (v.cert.exp <= now || v.cert.exp - now > RENEW_WINDOW_MS) return
306
+ if (now - renewLastTry < RENEW_RETRY_MS) return
307
+ renewLastTry = now
308
+ remoteRenew({ master: v.master, proxy: v.proxy, device, cert: v.cert }).then(({ cert }) => {
309
+ kv.setItem(VAULT_CERT_STORAGE, JSON.stringify({ ...v, cert, renewedAt: Date.now() }))
310
+ emitVault({ phase: 'renewed', exp: cert.exp })
311
+ }).catch(() => {}) // best-effort: el cert vigente sigue sirviendo mientras tanto
312
+ } catch (_) {}
313
+ }
237
314
 
238
315
  // ----- me (kv-backed) -----
239
316
 
@@ -319,26 +396,35 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null })
319
396
  }
320
397
 
321
398
  async function exportLocalForSync () {
322
- const raw = kv.getItem(KEY_STORAGE)
323
- const keys = raw ? JSON.parse(raw) : null
324
- const encRaw = kv.getItem(ENC_KEY_STORAGE)
325
- const encKeys = encRaw ? JSON.parse(encRaw) : null
399
+ // Las llaves privadas NO viajan al sync (no extractables): Drive respalda
400
+ // perfil+contactos; la identidad se recupera ENROLANDO el navegador al vault.
326
401
  return {
327
- privateJwk: keys?.privateJwk || null,
328
- publicJwk: keys?.publicJwk || null,
329
- encPrivateJwk: encKeys?.privateJwk || null,
330
- encPublicJwk: encKeys?.publicJwk || null,
402
+ privateJwk: null,
403
+ publicJwk: keypair?.publicJwk || null,
404
+ encPrivateJwk: null,
405
+ encPublicJwk: encKeypair?.publicJwk || null,
331
406
  me: loadMe(),
332
407
  peers: loadPeers()
333
408
  }
334
409
  }
335
410
 
411
+ async function adoptJwkPair (kind, storageKey, privateJwk, publicJwk) {
412
+ const { algo, privUses } = ALGO_OF[kind]
413
+ if (keyStore) {
414
+ const privateKey = await crypto.subtle.importKey('jwk', privateJwk, algo, false, privUses)
415
+ await keyStore.set(_scoped(storageKey), { privateKey, publicJwk })
416
+ kv.removeItem(storageKey)
417
+ } else {
418
+ kv.setItem(storageKey, JSON.stringify({ privateJwk, publicJwk }))
419
+ }
420
+ }
421
+
336
422
  async function applyMergedFromSync (merged) {
337
- const localKeys = kv.getItem(KEY_STORAGE)
423
+ const localKeys = kv.getItem(KEY_STORAGE) || (keyStore && (await keyStore.get(_scoped(KEY_STORAGE)).catch(() => null)))
338
424
  if (!localKeys && merged.privateJwk && merged.publicJwk) {
339
- kv.setItem(KEY_STORAGE, JSON.stringify({ privateJwk: merged.privateJwk, publicJwk: merged.publicJwk }))
425
+ await adoptJwkPair('sign', KEY_STORAGE, merged.privateJwk, merged.publicJwk)
340
426
  if (merged.encPrivateJwk && merged.encPublicJwk) {
341
- kv.setItem(ENC_KEY_STORAGE, JSON.stringify({ privateJwk: merged.encPrivateJwk, publicJwk: merged.encPublicJwk }))
427
+ await adoptJwkPair('enc', ENC_KEY_STORAGE, merged.encPrivateJwk, merged.encPublicJwk)
342
428
  }
343
429
  keypair = await loadOrCreateKeypair()
344
430
  publickeyJwkStr = JSON.stringify(keypair.publicJwk)
@@ -385,17 +471,127 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null })
385
471
  // meta del perfil (para el switcher). Devuelve el `me` resultante.
386
472
  function applyMeUpdate (patch) {
387
473
  const clean = sanitizeProfilePatch(patch || {})
388
- me = { ...(me || {}), ...clean, publickey: publickeyJwkStr, encryptionPubkey: encPublickeyJwkStr }
474
+ me = { ...(me || {}), ...clean, publickey: publickeyJwkStr, encryptionPubkey: encPublickeyJwkStr, updatedAt: Date.now() }
389
475
  if (clean.avatar === null) delete me.avatar
390
476
  saveMe(me)
391
477
  if (typeof clean.nickname === 'string') {
392
478
  const list = loadProfiles(); const e = list.find((p) => p.id === currentPid)
393
479
  if (e && e.name !== clean.nickname) { e.name = clean.nickname; saveProfiles(list) }
394
480
  }
481
+ pushProfileToVault() // best-effort: mismo perfil en todos los dispositivos
395
482
  return me
396
483
  }
397
484
 
485
+ // ----- PERFIL COMPARTIDO entre dispositivos (vía el vault) -----
486
+ // El vault guarda la copia autoritativa del perfil (profileSet/profileGet en su
487
+ // store). Al editar aquí se EMPUJA; al arrancar se JALA y gana el más nuevo
488
+ // (updatedAt). Las llaves (publickey/encryptionPubkey) son POR dispositivo y
489
+ // nunca se sincronizan. Todo best-effort: sin vault encendido no molesta.
490
+ let profilePushTimer = null
491
+ function pushProfileToVault () {
492
+ const v = loadVaultCert(); const device = loadVaultDevice()
493
+ if (!v?.cert || !device || v.cert.exp <= Date.now()) return
494
+ clearTimeout(profilePushTimer)
495
+ profilePushTimer = setTimeout(() => {
496
+ const { publickey, encryptionPubkey, ...content } = me || {}
497
+ remoteStore({ master: v.master, proxy: v.proxy, device, cert: v.cert, method: 'profileSet', args: { me: content } })
498
+ .catch(() => {}) // el vault puede estar apagado; se reintenta en la próxima edición
499
+ }, 800) // debounce: ediciones seguidas = un solo push
500
+ }
501
+ async function pullProfileFromVault () {
502
+ try {
503
+ const v = loadVaultCert(); const device = loadVaultDevice()
504
+ if (!v?.cert || !device || v.cert.exp <= Date.now()) return
505
+ const res = await remoteStore({ master: v.master, proxy: v.proxy, device, cert: v.cert, method: 'profileGet', args: {} })
506
+ const remoteMe = res?.me
507
+ if (!remoteMe) {
508
+ // el vault aún no tiene perfil: sembrar con el local (si tiene contenido)
509
+ if (me?.nickname || me?.avatar) pushProfileToVault()
510
+ return
511
+ }
512
+ if ((remoteMe.updatedAt || 0) > (me?.updatedAt || 0)) {
513
+ const { publickey, encryptionPubkey, ...content } = remoteMe
514
+ me = { ...(me || {}), ...content, publickey: publickeyJwkStr, encryptionPubkey: encPublickeyJwkStr }
515
+ saveMe(me)
516
+ if (typeof content.nickname === 'string') {
517
+ const list = loadProfiles(); const e = list.find((p) => p.id === currentPid)
518
+ if (e && e.name !== content.nickname) { e.name = content.nickname; saveProfiles(list) }
519
+ }
520
+ emitVault({ phase: 'profile-sync', updatedAt: remoteMe.updatedAt })
521
+ }
522
+ } catch (_) { /* vault apagado: el perfil local sigue mandando */ }
523
+ }
524
+
525
+ // ----- CANDADO por contraseña (OPCIONAL, POR PERFIL, LOCAL de este dispositivo) -----
526
+ // El hash (PBKDF2) vive solo en el kv de ESTE navegador: no viaja al vault ni a
527
+ // otros dispositivos (cada uno decide si protege su acceso y con qué contraseña).
528
+ // Al desbloquear, la prueba va a sessionStorage (por PESTAÑA): sobrevive al
529
+ // refresco y muere al cerrar la pestaña. No cifra datos: es un gate de acceso.
530
+ const PWD_STORAGE = 'dotrino.identity.pwd'
531
+ const PWD_SESSION = 'dotrino.identity.pwd.proof'
532
+ const PWD_ITER = 300000
533
+ let locked = false
534
+ const b64 = (buf) => btoa(String.fromCharCode(...new Uint8Array(buf)))
535
+ async function derivePwd (password, saltB64, iter) {
536
+ const salt = Uint8Array.from(atob(saltB64), (c) => c.charCodeAt(0))
537
+ const km = await crypto.subtle.importKey('raw', new TextEncoder().encode(String(password)), 'PBKDF2', false, ['deriveBits'])
538
+ const bits = await crypto.subtle.deriveBits({ name: 'PBKDF2', hash: 'SHA-256', salt, iterations: iter }, km, 256)
539
+ return b64(bits)
540
+ }
541
+ const loadPwd = () => { try { return JSON.parse(kv.getItem(PWD_STORAGE) || 'null') } catch (_) { return null } }
542
+ const sessionProof = () => { try { return sessionKv?.getItem(_scoped(PWD_SESSION)) || null } catch (_) { return null } }
543
+ function refreshLockState () {
544
+ const pwd = loadPwd()
545
+ locked = !!pwd && sessionProof() !== pwd.verifier
546
+ }
547
+ // Métodos disponibles AUN bloqueado (gestionar perfiles y el propio candado;
548
+ // nada que lea datos o firme).
549
+ const LOCK_EXEMPT = new Set([
550
+ 'profileLockStatus', 'unlockProfile', 'listProfiles', 'currentProfile',
551
+ 'switchProfile', 'createProfile'
552
+ ])
553
+
398
554
  const handlers = {
555
+ async profileLockStatus () {
556
+ refreshLockState()
557
+ return { protected: !!loadPwd(), locked }
558
+ },
559
+ async unlockProfile ({ password }) {
560
+ const pwd = loadPwd()
561
+ if (!pwd) { locked = false; return { ok: true, locked: false } }
562
+ // Freno de fuerza bruta (un PIN de 4 dígitos se adivina probando): tras 5
563
+ // fallos, espera exponencial (2^n s, tope 5 min) persistida en el kv.
564
+ const tries = (() => { try { return JSON.parse(kv.getItem('dotrino.identity.pwd.tries') || 'null') } catch (_) { return null } })() || { n: 0, at: 0 }
565
+ const waitMs = tries.n >= 5 ? Math.min(2 ** (tries.n - 4) * 1000, 5 * 60 * 1000) : 0
566
+ const left = tries.at + waitMs - Date.now()
567
+ if (left > 0) throw new Error(`demasiados intentos: espera ${Math.ceil(left / 1000)} s`)
568
+ const proof = await derivePwd(password, pwd.salt, pwd.iter)
569
+ if (proof !== pwd.verifier) {
570
+ kv.setItem('dotrino.identity.pwd.tries', JSON.stringify({ n: tries.n + 1, at: Date.now() }))
571
+ throw new Error('contraseña incorrecta')
572
+ }
573
+ kv.removeItem('dotrino.identity.pwd.tries')
574
+ try { sessionKv?.setItem(_scoped(PWD_SESSION), proof) } catch (_) {}
575
+ locked = false
576
+ return { ok: true, locked: false }
577
+ },
578
+ // Poner/cambiar contraseña (requiere estar desbloqueado; cambiar exige la actual vía unlock previo).
579
+ async setProfilePassword ({ password }) {
580
+ if (locked) throw new Error('perfil bloqueado')
581
+ if (!password || String(password).length < 4) throw new Error('la contraseña debe tener al menos 4 caracteres')
582
+ const salt = b64(crypto.getRandomValues(new Uint8Array(16)))
583
+ const verifier = await derivePwd(password, salt, PWD_ITER)
584
+ kv.setItem(PWD_STORAGE, JSON.stringify({ v: 1, salt, iter: PWD_ITER, verifier }))
585
+ try { sessionKv?.setItem(_scoped(PWD_SESSION), verifier) } catch (_) {}
586
+ return { ok: true }
587
+ },
588
+ async removeProfilePassword () {
589
+ if (locked) throw new Error('perfil bloqueado')
590
+ kv.removeItem(PWD_STORAGE)
591
+ try { sessionKv?.removeItem(_scoped(PWD_SESSION)) } catch (_) {}
592
+ return { ok: true }
593
+ },
594
+
399
595
  async makeChallenge () {
400
596
  const nonce = crypto.randomUUID()
401
597
  rememberNonce(nonce)
@@ -626,6 +822,12 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null })
626
822
  for (const s of ['keypair', 'enc-keypair', 'me', 'nonces', 'delegations', 'revocations', 'vault.device', 'vault.cert']) {
627
823
  rawKv.removeItem(`dotrino.identity.p.${id}.${s}`)
628
824
  }
825
+ // …y sus CryptoKeys no extractables del keyStore (IndexedDB).
826
+ if (keyStore) {
827
+ for (const s of ['keypair', 'enc-keypair']) {
828
+ try { await keyStore.remove(`dotrino.identity.p.${id}.${s}`) } catch (_) {}
829
+ }
830
+ }
629
831
  if (currentPid === id) { currentPid = list[0].id; rawKv.setItem(CURRENT_STORAGE, currentPid) }
630
832
  return { ok: true, current: currentPid }
631
833
  },
@@ -636,18 +838,21 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null })
636
838
  async vaultPair ({ qr }) {
637
839
  // Usa la PROPIA llave de identidad de este navegador como dispositivo: el cert delega
638
840
  // TU identidad (P) desde la maestra M → una sola identidad (signData/identify/cert = P).
639
- let device
640
- try { const k = JSON.parse(kv.getItem(KEY_STORAGE)); device = { publickey: JSON.stringify(k.publicJwk), privateJwk: k.privateJwk } } catch (_) { device = undefined }
841
+ // La privada es la CryptoKey del perfil (no extractable): se pasa como `privateKey`
842
+ // y NO se persiste ningún JWK del dispositivo (marcador useIdentityKey).
843
+ const device = { publickey: publickeyJwkStr, privateKey: keypair.privateKey }
641
844
  const res = await remoteEnroll({ qr, device, onChallenge: (c) => emitVault({ phase: 'challenge', deviceId: c.deviceId, code: c.code }) })
642
- kv.setItem(VAULT_DEVICE_STORAGE, JSON.stringify(res.device))
845
+ kv.setItem(VAULT_DEVICE_STORAGE, JSON.stringify({ useIdentityKey: true, publickey: publickeyJwkStr }))
643
846
  kv.setItem(VAULT_CERT_STORAGE, JSON.stringify({ cert: res.cert, master: res.master, proxy: res.proxy, deviceId: res.deviceId, pairedAt: Date.now() }))
644
847
  emitVault({ phase: 'paired', deviceId: res.deviceId, master: res.master })
848
+ pullProfileFromVault() // adoptar el perfil que ya viva en el vault (si hay)
645
849
  return { ok: true, deviceId: res.deviceId, master: res.master, exp: res.cert.exp, scope: res.cert.scope }
646
850
  },
647
851
 
648
852
  async vaultStatus () {
649
853
  const v = loadVaultCert()
650
854
  if (!v?.cert) return { paired: false }
855
+ maybeRenewVaultCert()
651
856
  return { paired: true, deviceId: v.deviceId, master: v.master, proxy: v.proxy, scope: v.cert.scope, exp: v.cert.exp, pairedAt: v.pairedAt }
652
857
  },
653
858
 
@@ -664,6 +869,7 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null })
664
869
  async vaultSign ({ payload }) {
665
870
  const v = loadVaultCert(); const device = loadVaultDevice()
666
871
  if (!v?.cert || !device) throw new Error('este dispositivo no está emparejado con un vault')
872
+ maybeRenewVaultCert()
667
873
  try { return await remoteSign({ master: v.master, proxy: v.proxy, device, cert: v.cert, payload }) }
668
874
  catch (e) { return handleVaultError(e) }
669
875
  },
@@ -673,6 +879,7 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null })
673
879
  async vaultStore ({ method, args }) {
674
880
  const v = loadVaultCert(); const device = loadVaultDevice()
675
881
  if (!v?.cert || !device) throw new Error('este dispositivo no está emparejado con un vault')
882
+ maybeRenewVaultCert()
676
883
  try { return await remoteStore({ master: v.master, proxy: v.proxy, device, cert: v.cert, method, args }) }
677
884
  catch (e) { return handleVaultError(e) }
678
885
  },
@@ -681,6 +888,7 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null })
681
888
  async listVaultDevices () {
682
889
  const v = loadVaultCert(); const device = loadVaultDevice()
683
890
  if (!v?.cert || !device) throw new Error('este dispositivo no está emparejado con un vault')
891
+ maybeRenewVaultCert()
684
892
  try { return await remoteDevices({ master: v.master, proxy: v.proxy, device, cert: v.cert }) }
685
893
  catch (e) { return handleVaultError(e) }
686
894
  },
@@ -712,6 +920,12 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null })
712
920
  const out = { publickey: m.publickey, encryptionPubkey: m.encryptionPubkey }
713
921
  if (m.nickname) out.nickname = m.nickname
714
922
  if (m.avatar && m.avatarVisible !== false) out.avatar = m.avatar
923
+ // Campos estándar: sensibles (telefono/direccion) solo si su flag === true; el resto salvo flag === false.
924
+ for (const [k] of STD_FIELD_CAPS) {
925
+ if (!m[k]) continue
926
+ const shown = STD_FIELDS_SENSITIVE.has(k) ? (m[k + 'Visible'] === true) : (m[k + 'Visible'] !== false)
927
+ if (shown) out[k] = m[k]
928
+ }
715
929
  if (Array.isArray(m.links)) { const v = m.links.filter((l) => l.visible !== false).map(({ visible, ...r }) => r); if (v.length) out.links = v }
716
930
  if (Array.isArray(m.fields)) { const v = m.fields.filter((f) => f.visible !== false).map(({ visible, ...r }) => r); if (v.length) out.fields = v }
717
931
  return out
@@ -756,7 +970,10 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null })
756
970
 
757
971
  async exportIdentity () {
758
972
  const raw = kv.getItem(KEY_STORAGE)
759
- if (!raw) throw new Error('No keypair to export')
973
+ if (!raw) {
974
+ throw new Error('Este perfil guarda su llave de forma NO exportable (protección contra robo). ' +
975
+ 'Para usar tu identidad en otro navegador, conecta ese navegador a tu bóveda (vault) desde profile.dotrino.com.')
976
+ }
760
977
  const keys = JSON.parse(raw)
761
978
  const encRaw = kv.getItem(ENC_KEY_STORAGE)
762
979
  const encKeys = encRaw ? JSON.parse(encRaw) : null
@@ -781,13 +998,10 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null })
781
998
 
782
999
  async importIdentity ({ privateJwk, publicJwk, encPrivateJwk, encPublicJwk, me: meIn, peers: peersIn }) {
783
1000
  if (!privateJwk || !publicJwk) throw new Error('privateJwk and publicJwk required')
784
- await crypto.subtle.importKey('jwk', privateJwk, { name: 'ECDSA', namedCurve: 'P-256' }, true, ['sign'])
785
1001
  await crypto.subtle.importKey('jwk', publicJwk, { name: 'ECDSA', namedCurve: 'P-256' }, true, ['verify'])
786
- kv.setItem(KEY_STORAGE, JSON.stringify({ privateJwk, publicJwk }))
1002
+ await adoptJwkPair('sign', KEY_STORAGE, privateJwk, publicJwk)
787
1003
  if (encPrivateJwk && encPublicJwk) {
788
- await crypto.subtle.importKey('jwk', encPrivateJwk, { name: 'ECDH', namedCurve: 'P-256' }, true, ['deriveBits', 'deriveKey'])
789
- await crypto.subtle.importKey('jwk', encPublicJwk, { name: 'ECDH', namedCurve: 'P-256' }, true, [])
790
- kv.setItem(ENC_KEY_STORAGE, JSON.stringify({ privateJwk: encPrivateJwk, publicJwk: encPublicJwk }))
1004
+ await adoptJwkPair('enc', ENC_KEY_STORAGE, encPrivateJwk, encPublicJwk)
791
1005
  } else {
792
1006
  kv.removeItem(ENC_KEY_STORAGE)
793
1007
  }
@@ -841,6 +1055,20 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null })
841
1055
  encKeypair = await loadOrCreateEncKeypair()
842
1056
  encPublickeyJwkStr = JSON.stringify(encKeypair.publicJwk)
843
1057
 
1058
+ // Purga del JWK legado SIN namespace (pre-multi-perfil): la migración a
1059
+ // perfiles lo COPIABA sin borrarlo. Con keyStore (llaves no extractables) no
1060
+ // puede quedar ninguna privada en claro: si la llave activa ya vive en el
1061
+ // keyStore y coincide con la legada, se elimina el plano.
1062
+ if (keyStore) {
1063
+ try {
1064
+ const legacy = JSON.parse(rawKv.getItem(KEY_STORAGE) || 'null')
1065
+ if (legacy && JSON.stringify(legacy.publicJwk) === publickeyJwkStr) {
1066
+ rawKv.removeItem(KEY_STORAGE)
1067
+ rawKv.removeItem(ENC_KEY_STORAGE)
1068
+ }
1069
+ } catch (_) {}
1070
+ }
1071
+
844
1072
  await initPeerStorage()
845
1073
 
846
1074
  const persistedMe = loadMe()
@@ -854,6 +1082,8 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null })
854
1082
  me = { publickey: publickeyJwkStr, encryptionPubkey: encPublickeyJwkStr }
855
1083
  kv.setItem(ME_STORAGE, JSON.stringify(me))
856
1084
  }
1085
+ // Perfil compartido: jalar del vault en background (gana el más nuevo).
1086
+ pullProfileFromVault()
857
1087
 
858
1088
  // Registrar el pubkey (y nombre) del perfil activo en su meta → para avatar/listado sin abrir cada perfil.
859
1089
  {
@@ -874,6 +1104,18 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null })
874
1104
  onDirty(() => { if (sync) sync.markDirty() })
875
1105
  }
876
1106
 
1107
+ // ----- gate del candado: TODO handler no exento exige perfil desbloqueado -----
1108
+ refreshLockState()
1109
+ for (const name of Object.keys(handlers)) {
1110
+ if (LOCK_EXEMPT.has(name)) continue
1111
+ const fn = handlers[name]
1112
+ handlers[name] = async (params) => {
1113
+ if (locked) refreshLockState() // otra pestaña pudo desbloquear… no: session es por pestaña; re-chequea por si se quitó el pwd
1114
+ if (locked) throw new Error('perfil bloqueado: desbloquéalo con tu contraseña (unlockProfile)')
1115
+ return fn(params)
1116
+ }
1117
+ }
1118
+
877
1119
  return {
878
1120
  handlers,
879
1121
  get me () { return me },
package/vault/remote.js CHANGED
@@ -46,7 +46,7 @@ export async function enrollDevice ({ qr, device, onChallenge, label = '', appro
46
46
  // NO se manda el código ni un compromiso: el vault lo aprende SOLO cuando lo tipeás, y al
47
47
  // ECHARLO de vuelta el dispositivo confía. Un vault falso no conoce el código → no empareja.
48
48
  const data = { op: 'enroll', dpub: dev.publickey, token: qr.token, sn: qr.sn, label, ts: Date.now() }
49
- const { signature } = await signWithDevice({ privateJwk: dev.privateJwk, data })
49
+ const { signature } = await signWithDevice({ privateJwk: dev.privateJwk, privateKey: dev.privateKey, publickey: dev.publickey, data })
50
50
 
51
51
  const enrolled = new Promise((resolve, reject) => {
52
52
  const off = client.on('message', (_from, p) => {
@@ -79,13 +79,13 @@ export async function enrollDevice ({ qr, device, onChallenge, label = '', appro
79
79
  * @returns {Promise<{ signature:string, publickey:string }>} publickey = la maestra.
80
80
  */
81
81
  export async function requestSign ({ master, proxy, device, cert, payload, timeoutMs = 15000 } = {}) {
82
- if (!master || !proxy || !device?.privateJwk || !cert) throw new Error('faltan datos de emparejamiento')
82
+ if (!master || !proxy || !(device?.privateJwk || device?.privateKey) || !cert) throw new Error('faltan datos de emparejamiento')
83
83
  const { WebSocketProxyClient } = await import('@dotrino/proxy-client')
84
84
  const client = new WebSocketProxyClient({ url: proxy, enableWebRTC: false, autoReconnect: false })
85
85
  await client.connect()
86
86
  try {
87
87
  const data = { op: 'sign', payload, publickey: device.publickey, ts: Date.now() }
88
- const { signature } = await signWithDevice({ privateJwk: device.privateJwk, data })
88
+ const { signature } = await signWithDevice({ privateJwk: device.privateJwk, privateKey: device.privateKey, publickey: device.publickey, data })
89
89
  const pending = new Promise((resolve, reject) => {
90
90
  const off = client.on('message', (_f, p) => {
91
91
  if (!p || typeof p !== 'object') return
@@ -103,13 +103,13 @@ export async function requestSign ({ master, proxy, device, cert, payload, timeo
103
103
 
104
104
  /** Helper genérico: una RPC al vault firmada por D + cert, esperando `okType`. */
105
105
  async function vaultRpc ({ master, proxy, device, cert, sendType, okType, data, timeoutMs = 15000 }) {
106
- if (!master || !proxy || !device?.privateJwk || !cert) throw new Error('faltan datos de emparejamiento')
106
+ if (!master || !proxy || !(device?.privateJwk || device?.privateKey) || !cert) throw new Error('faltan datos de emparejamiento')
107
107
  const { WebSocketProxyClient } = await import('@dotrino/proxy-client')
108
108
  const client = new WebSocketProxyClient({ url: proxy, enableWebRTC: false, autoReconnect: false })
109
109
  await client.connect()
110
110
  try {
111
111
  const signed = { ...data, publickey: device.publickey, ts: Date.now() }
112
- const { signature } = await signWithDevice({ privateJwk: device.privateJwk, data: signed })
112
+ const { signature } = await signWithDevice({ privateJwk: device.privateJwk, privateKey: device.privateKey, publickey: device.publickey, data: signed })
113
113
  const pending = new Promise((resolve, reject) => {
114
114
  const off = client.on('message', (_f, p) => {
115
115
  if (!p || typeof p !== 'object') return
@@ -135,3 +135,14 @@ export async function requestDevices ({ master, proxy, device, cert } = {}) {
135
135
  const res = await vaultRpc({ master, proxy, device, cert, sendType: 'vault.devices', okType: 'vault.devices.result', data: { op: 'devices' } })
136
136
  return { devices: res.devices || [], revoked: res.revoked || [] }
137
137
  }
138
+
139
+ /**
140
+ * RENUEVA el cert de este dispositivo (requiere el cert aún VIGENTE y no revocado):
141
+ * el vault firma uno fresco para la misma sub-clave y scope, sin QR ni aprobación.
142
+ * @returns {Promise<{ cert: object }>}
143
+ */
144
+ export async function requestRenew ({ master, proxy, device, cert } = {}) {
145
+ const res = await vaultRpc({ master, proxy, device, cert, sendType: 'vault.renew', okType: 'vault.renewed', data: { op: 'renew' } })
146
+ if (!res.cert || res.cert.sub !== device.publickey || res.cert.iss !== master) throw new Error('cert renovado inválido')
147
+ return { cert: res.cert }
148
+ }
package/vault/vault.js CHANGED
@@ -15,26 +15,83 @@ import {
15
15
  import { createIdentityCore } from './core.js'
16
16
 
17
17
  ;(async () => {
18
- // kv estilo localStorage (síncrono) para keypairs, me y nonces.
18
+ // kv estilo localStorage (síncrono) para me, nonces, delegaciones, certs.
19
19
  const kv = {
20
20
  getItem: (k) => localStorage.getItem(k),
21
21
  setItem: (k, v) => localStorage.setItem(k, v),
22
22
  removeItem: (k) => localStorage.removeItem(k)
23
23
  }
24
24
 
25
+ // keyStore: las llaves PRIVADAS viven como CryptoKey NO EXTRACTABLES en
26
+ // IndexedDB (clonado estructurado). Nadie —ni este código, ni un XSS en este
27
+ // origen— puede leer sus bytes; solo firmar/derivar con ellas. Las llaves
28
+ // planas (JWK) viejas de localStorage se migran y se borran (core.js).
29
+ const keyStore = await (() => new Promise((resolve) => {
30
+ const req = indexedDB.open('dotrino-identity-keys', 1)
31
+ req.onupgradeneeded = () => req.result.createObjectStore('keys')
32
+ req.onsuccess = () => {
33
+ const db = req.result
34
+ const op = (mode, fn) => new Promise((res, rej) => {
35
+ const tx = db.transaction('keys', mode)
36
+ const r = fn(tx.objectStore('keys'))
37
+ r.onsuccess = () => res(r.result ?? null)
38
+ r.onerror = () => rej(r.error)
39
+ })
40
+ resolve({
41
+ get: (name) => op('readonly', (st) => st.get(name)),
42
+ set: (name, pair) => op('readwrite', (st) => st.put(pair, name)),
43
+ remove: (name) => op('readwrite', (st) => st.delete(name))
44
+ })
45
+ }
46
+ req.onerror = () => resolve(null) // sin IDB (raro): cae al modo kv legado
47
+ }))()
48
+
49
+ // sessionKv: la prueba de desbloqueo del candado por contraseña vive en
50
+ // sessionStorage — POR PESTAÑA: sobrevive al refresco, muere al cerrarla.
51
+ const sessionKv = {
52
+ getItem: (k) => sessionStorage.getItem(k),
53
+ setItem: (k, v) => sessionStorage.setItem(k, v),
54
+ removeItem: (k) => sessionStorage.removeItem(k)
55
+ }
56
+
25
57
  const core = await createIdentityCore({
26
58
  kv,
27
59
  peers: { initPeerStorage, loadPeers, savePeers, setPeersDirect, upsertPeer, onDirty },
28
- makeSync: createSync
60
+ makeSync: createSync,
61
+ keyStore,
62
+ sessionKv
29
63
  })
30
64
 
31
65
  const { handlers } = core
32
66
 
33
- // Broadcast de eventos del vault (sync + emparejamiento) a todos los embebedores.
67
+ // ---- Control de ORIGEN (crítico): la identidad solo habla con el ecosistema. ----
68
+ // Sin esto, CUALQUIER web podía embeber este iframe y llamar `exportIdentity`
69
+ // (llave privada cruda), `signData` (suplantación) o leer tu perfil/contactos.
70
+ // Permitidos: *.dotrino.com (y apex), el mirror de la org en GitHub Pages, y
71
+ // orígenes de desarrollo (localhost / 127.0.0.1 / IPs de LAN privada).
72
+ const ALLOWED_ORIGIN = new RegExp(
73
+ '^(' +
74
+ 'https://([a-z0-9-]+\\.)*dotrino\\.com' + '|' +
75
+ 'https://imdotrino\\.github\\.io' + '|' +
76
+ 'https?://localhost(:\\d+)?' + '|' +
77
+ 'https?://127\\.0\\.0\\.1(:\\d+)?' + '|' +
78
+ 'https?://192\\.168\\.\\d{1,3}\\.\\d{1,3}(:\\d+)?' + '|' +
79
+ 'https?://10\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}(:\\d+)?' + '|' +
80
+ 'https?://172\\.(1[6-9]|2\\d|3[01])\\.\\d{1,3}\\.\\d{1,3}(:\\d+)?' +
81
+ ')$'
82
+ )
83
+ const isAllowed = (origin) => typeof origin === 'string' && ALLOWED_ORIGIN.test(origin)
84
+
85
+ // Broadcast de eventos del vault (sync + emparejamiento) SOLO a embebedores que
86
+ // ya hicieron una petición válida (ventana+origen verificados), nunca con '*'.
87
+ const embedders = [] // [{ win, origin }]
88
+ const rememberEmbedder = (win, origin) => {
89
+ if (!win || win === window) return
90
+ if (!embedders.some((e) => e.win === win)) embedders.push({ win, origin })
91
+ }
34
92
  const broadcast = (eventName, payload) => {
35
- for (const w of [window.parent, ...Array.from(document.querySelectorAll('iframe')).map(f => f.contentWindow)]) {
36
- if (!w || w === window) continue
37
- try { w.postMessage({ _cci: true, type: 'event', event: eventName, payload }, '*') } catch {}
93
+ for (const { win, origin } of embedders) {
94
+ try { win.postMessage({ _cci: true, type: 'event', event: eventName, payload }, origin) } catch {}
38
95
  }
39
96
  }
40
97
  core.onSyncStatus((p) => broadcast('sync', p))
@@ -43,6 +100,8 @@ import { createIdentityCore } from './core.js'
43
100
  window.addEventListener('message', async (event) => {
44
101
  const msg = event.data
45
102
  if (!msg || msg._cci !== true || msg.type !== 'request') return
103
+ if (!isAllowed(event.origin)) return // origen ajeno: silencio total
104
+ rememberEmbedder(event.source, event.origin)
46
105
  const { id, method, params } = msg
47
106
  const reply = (payload) => event.source?.postMessage(
48
107
  { _cci: true, type: 'response', id, ...payload },
@@ -58,8 +117,22 @@ import { createIdentityCore } from './core.js'
58
117
  }
59
118
  })
60
119
 
61
- // Avisar a todo padre que el vault está listo.
120
+ // Avisar al padre que el vault está listo — solo si su origen (referrer) es del
121
+ // ecosistema; a una página ajena no se le revela NADA (ni pubkey ni apodo).
62
122
  if (window.parent && window.parent !== window) {
63
- window.parent.postMessage({ _cci: true, type: 'ready', me: core.me }, '*')
123
+ let parentOrigin = null
124
+ try { parentOrigin = new URL(document.referrer).origin } catch {}
125
+ if (parentOrigin && isAllowed(parentOrigin)) {
126
+ rememberEmbedder(window.parent, parentOrigin)
127
+ // Perfil BLOQUEADO por contraseña → ready sin datos (ni apodo ni pubkey):
128
+ // la app debe desbloquear (unlockProfile) y refrescar con getMe.
129
+ const lock = await handlers.profileLockStatus().catch(() => ({ locked: false }))
130
+ window.parent.postMessage({ _cci: true, type: 'ready', ...(lock.locked ? { locked: true } : { me: core.me }) }, parentOrigin)
131
+ } else {
132
+ // Sin referrer (política estricta del padre) no podemos verificar el origen:
133
+ // señalamos ready SIN datos (no revela nada; y las peticiones de orígenes
134
+ // ajenos se ignoran igual). Las apps del ecosistema refrescan `me` por RPC.
135
+ window.parent.postMessage({ _cci: true, type: 'ready' }, '*')
136
+ }
64
137
  }
65
138
  })()