@dotrino/identity 0.16.1 → 0.20.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.16.1",
3
+ "version": "0.20.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",
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
  }
@@ -306,6 +365,24 @@ export class Identity {
306
365
  return result
307
366
  }
308
367
 
368
+ /**
369
+ * Actualiza tu PERFIL (merge): `{ nickname?, avatar?, avatarVisible?, links?, fields?,
370
+ * nombres?, apellidos?, email?, telefono?, direccion? }` (+ sus flags `<campo>Visible`).
371
+ * `avatar` = data-URI 250×250 (o null para quitarla); `links`/`fields` = arrays con `visible`
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.
374
+ */
375
+ async updateMe (patch) {
376
+ const result = await this._call('updateMe', { patch })
377
+ if (result?.me) this._me = result.me
378
+ return result
379
+ }
380
+
381
+ /** Tu `me` completo (incluye ocultos). */
382
+ async getMe () { return this._call('getMe') }
383
+ /** Subconjunto PÚBLICO de tu perfil (solo lo visible) — para compartir/publicar. */
384
+ async publicMe () { return this._call('publicMe') }
385
+
309
386
  /** Pubkey ECDH (JWK string) propio para encripción. */
310
387
  async getEncryptionPubkey () {
311
388
  return this._call('getEncryptionPubkey')
package/src/node.js CHANGED
@@ -175,6 +175,9 @@ export class Identity {
175
175
  const result = await this._h('setMyNickname', { nickname })
176
176
  return result
177
177
  }
178
+ async updateMe (patch) { return this._h('updateMe', { patch }) }
179
+ getMe () { return this._h('getMe') }
180
+ publicMe () { return this._h('publicMe') }
178
181
  getEncryptionPubkey () { return this._h('getEncryptionPubkey') }
179
182
  encrypt (recipients, plaintext) { return this._h('encrypt', { recipients, plaintext }) }
180
183
  decrypt (senderEncryptionPubkey, myToken, envelope) {
@@ -175,10 +175,17 @@ export async function signDelegationWith (privateKey, iss, { sub, scope, iat, ex
175
175
  * Firma datos con la clave de DISPOSITIVO (formato byte-idéntico a `signData` del
176
176
  * vault → lo que el dispositivo/bridge usa para firmar cada pin/acción).
177
177
  */
178
- export async function signWithDevice ({ privateJwk, data }) {
178
+ export async function signWithDevice ({ privateJwk, privateKey, publickey, data }) {
179
+ // `privateKey` (CryptoKey, posiblemente NO extractable) tiene prioridad: firma
180
+ // sin tocar bytes de la privada. Con CryptoKey es obligatorio pasar `publickey`.
181
+ if (privateKey) {
182
+ if (!publickey) throw new Error('signWithDevice: con privateKey (CryptoKey) se requiere publickey')
183
+ const signature = await rawSign(privateKey, enc(canonicalStringify(data)))
184
+ return { signature, publickey }
185
+ }
179
186
  const priv = await crypto.subtle.importKey('jwk', privateJwk, ECDSA, true, ['sign'])
180
187
  const signature = await rawSign(priv, enc(canonicalStringify(data)))
181
- return { signature, publickey: JSON.stringify(publicOf(privateJwk)) }
188
+ return { signature, publickey: publickey || JSON.stringify(publicOf(privateJwk)) }
182
189
  }
183
190
 
184
191
  /**
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,7 +108,50 @@ 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
- export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null }) {
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.
122
+ function sanitizeProfilePatch (patch = {}) {
123
+ const out = {}
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
+ }
131
+ if (patch.avatar === null) out.avatar = null
132
+ else if (typeof patch.avatar === 'string') out.avatar = patch.avatar.slice(0, 120000) // ~90KB: data-URI 250x250
133
+ if (typeof patch.avatarVisible === 'boolean') out.avatarVisible = patch.avatarVisible
134
+ if (Array.isArray(patch.links)) {
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) => ({
137
+ id: String(l?.id || '').slice(0, 24),
138
+ type: String(l?.type || 'web').slice(0, 16),
139
+ value: String(l?.value || '').slice(0, 200),
140
+ visible: l?.visible !== false
141
+ })).filter((l) => l.value).slice(0, 30)
142
+ }
143
+ if (Array.isArray(patch.fields)) {
144
+ out.fields = patch.fields.slice(0, 20).map((f) => ({
145
+ id: String(f?.id || '').slice(0, 24),
146
+ label: String(f?.label || '').slice(0, 40),
147
+ value: String(f?.value || '').slice(0, 280),
148
+ visible: f?.visible !== false
149
+ })).filter((f) => f.label || f.value)
150
+ }
151
+ return out
152
+ }
153
+
154
+ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, keyStore = null, sessionKv = null }) {
112
155
  const {
113
156
  initPeerStorage, loadPeers, savePeers, setPeersDirect, upsertPeer, onDirty
114
157
  } = peers
@@ -129,42 +172,62 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null })
129
172
  const loadProfiles = () => { try { return JSON.parse(rawKv.getItem(PROFILES_STORAGE) || '[]') || [] } catch { return [] } }
130
173
  const saveProfiles = (list) => rawKv.setItem(PROFILES_STORAGE, JSON.stringify(list))
131
174
 
132
- // ----- 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.
133
181
 
134
- async function loadOrCreateKeypair () {
135
- const raw = kv.getItem(KEY_STORAGE)
136
- if (raw) {
137
- try {
138
- const { privateJwk, publicJwk } = JSON.parse(raw)
139
- const privateKey = await crypto.subtle.importKey('jwk', privateJwk, { name: 'ECDSA', namedCurve: 'P-256' }, true, ['sign'])
140
- const publicKey = await crypto.subtle.importKey('jwk', publicJwk, { name: 'ECDSA', namedCurve: 'P-256' }, true, ['verify'])
141
- return { privateKey, publicKey, publicJwk }
142
- } catch (_) {}
143
- }
144
- const pair = await crypto.subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, true, ['sign', 'verify'])
145
- const privateJwk = await crypto.subtle.exportKey('jwk', pair.privateKey)
146
- const publicJwk = await crypto.subtle.exportKey('jwk', pair.publicKey)
147
- kv.setItem(KEY_STORAGE, JSON.stringify({ privateJwk, publicJwk }))
148
- 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'] }
149
185
  }
150
186
 
151
- async function loadOrCreateEncKeypair () {
152
- 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)
153
214
  if (raw) {
154
215
  try {
155
216
  const { privateJwk, publicJwk } = JSON.parse(raw)
156
- const privateKey = await crypto.subtle.importKey('jwk', privateJwk, { name: 'ECDH', namedCurve: 'P-256' }, true, ['deriveBits', 'deriveKey'])
157
- const publicKey = await crypto.subtle.importKey('jwk', publicJwk, { name: 'ECDH', namedCurve: 'P-256' }, true, [])
158
- return { privateKey, publicKey, publicJwk }
217
+ const privateKey = await crypto.subtle.importKey('jwk', privateJwk, algo, true, privUses)
218
+ return { privateKey, publicKey: await importPub(publicJwk), publicJwk }
159
219
  } catch (_) {}
160
220
  }
161
- const pair = await crypto.subtle.generateKey({ name: 'ECDH', namedCurve: 'P-256' }, true, ['deriveBits', 'deriveKey'])
221
+ const pair = await crypto.subtle.generateKey(algo, true, pairUses)
162
222
  const privateJwk = await crypto.subtle.exportKey('jwk', pair.privateKey)
163
223
  const publicJwk = await crypto.subtle.exportKey('jwk', pair.publicKey)
164
- kv.setItem(ENC_KEY_STORAGE, JSON.stringify({ privateJwk, publicJwk }))
224
+ kv.setItem(storageKey, JSON.stringify({ privateJwk, publicJwk }))
165
225
  return { privateKey: pair.privateKey, publicKey: pair.publicKey, publicJwk }
166
226
  }
167
227
 
228
+ const loadOrCreateKeypair = () => loadOrCreatePair('sign', KEY_STORAGE)
229
+ const loadOrCreateEncKeypair = () => loadOrCreatePair('enc', ENC_KEY_STORAGE)
230
+
168
231
  // ----- nonce replay protection (kv-backed) -----
169
232
 
170
233
  function loadNonces () {
@@ -206,7 +269,48 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null })
206
269
  throw e
207
270
  }
208
271
  const loadVaultCert = () => { try { return JSON.parse(kv.getItem(VAULT_CERT_STORAGE) || 'null') } catch (_) { return null } }
209
- 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
+ }
210
314
 
211
315
  // ----- me (kv-backed) -----
212
316
 
@@ -292,26 +396,35 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null })
292
396
  }
293
397
 
294
398
  async function exportLocalForSync () {
295
- const raw = kv.getItem(KEY_STORAGE)
296
- const keys = raw ? JSON.parse(raw) : null
297
- const encRaw = kv.getItem(ENC_KEY_STORAGE)
298
- 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.
299
401
  return {
300
- privateJwk: keys?.privateJwk || null,
301
- publicJwk: keys?.publicJwk || null,
302
- encPrivateJwk: encKeys?.privateJwk || null,
303
- encPublicJwk: encKeys?.publicJwk || null,
402
+ privateJwk: null,
403
+ publicJwk: keypair?.publicJwk || null,
404
+ encPrivateJwk: null,
405
+ encPublicJwk: encKeypair?.publicJwk || null,
304
406
  me: loadMe(),
305
407
  peers: loadPeers()
306
408
  }
307
409
  }
308
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
+
309
422
  async function applyMergedFromSync (merged) {
310
- const localKeys = kv.getItem(KEY_STORAGE)
423
+ const localKeys = kv.getItem(KEY_STORAGE) || (keyStore && (await keyStore.get(_scoped(KEY_STORAGE)).catch(() => null)))
311
424
  if (!localKeys && merged.privateJwk && merged.publicJwk) {
312
- kv.setItem(KEY_STORAGE, JSON.stringify({ privateJwk: merged.privateJwk, publicJwk: merged.publicJwk }))
425
+ await adoptJwkPair('sign', KEY_STORAGE, merged.privateJwk, merged.publicJwk)
313
426
  if (merged.encPrivateJwk && merged.encPublicJwk) {
314
- kv.setItem(ENC_KEY_STORAGE, JSON.stringify({ privateJwk: merged.encPrivateJwk, publicJwk: merged.encPublicJwk }))
427
+ await adoptJwkPair('enc', ENC_KEY_STORAGE, merged.encPrivateJwk, merged.encPublicJwk)
315
428
  }
316
429
  keypair = await loadOrCreateKeypair()
317
430
  publickeyJwkStr = JSON.stringify(keypair.publicJwk)
@@ -354,7 +467,131 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null })
354
467
 
355
468
  // ----- handlers (idénticos a la versión iframe) -----
356
469
 
470
+ // Merge de un patch de perfil en `me` (preserva lo demás), saneado. Refleja el nombre en la
471
+ // meta del perfil (para el switcher). Devuelve el `me` resultante.
472
+ function applyMeUpdate (patch) {
473
+ const clean = sanitizeProfilePatch(patch || {})
474
+ me = { ...(me || {}), ...clean, publickey: publickeyJwkStr, encryptionPubkey: encPublickeyJwkStr, updatedAt: Date.now() }
475
+ if (clean.avatar === null) delete me.avatar
476
+ saveMe(me)
477
+ if (typeof clean.nickname === 'string') {
478
+ const list = loadProfiles(); const e = list.find((p) => p.id === currentPid)
479
+ if (e && e.name !== clean.nickname) { e.name = clean.nickname; saveProfiles(list) }
480
+ }
481
+ pushProfileToVault() // best-effort: mismo perfil en todos los dispositivos
482
+ return me
483
+ }
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
+
357
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
+
358
595
  async makeChallenge () {
359
596
  const nonce = crypto.randomUUID()
360
597
  rememberNonce(nonce)
@@ -585,6 +822,12 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null })
585
822
  for (const s of ['keypair', 'enc-keypair', 'me', 'nonces', 'delegations', 'revocations', 'vault.device', 'vault.cert']) {
586
823
  rawKv.removeItem(`dotrino.identity.p.${id}.${s}`)
587
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
+ }
588
831
  if (currentPid === id) { currentPid = list[0].id; rawKv.setItem(CURRENT_STORAGE, currentPid) }
589
832
  return { ok: true, current: currentPid }
590
833
  },
@@ -595,18 +838,21 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null })
595
838
  async vaultPair ({ qr }) {
596
839
  // Usa la PROPIA llave de identidad de este navegador como dispositivo: el cert delega
597
840
  // TU identidad (P) desde la maestra M → una sola identidad (signData/identify/cert = P).
598
- let device
599
- 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 }
600
844
  const res = await remoteEnroll({ qr, device, onChallenge: (c) => emitVault({ phase: 'challenge', deviceId: c.deviceId, code: c.code }) })
601
- kv.setItem(VAULT_DEVICE_STORAGE, JSON.stringify(res.device))
845
+ kv.setItem(VAULT_DEVICE_STORAGE, JSON.stringify({ useIdentityKey: true, publickey: publickeyJwkStr }))
602
846
  kv.setItem(VAULT_CERT_STORAGE, JSON.stringify({ cert: res.cert, master: res.master, proxy: res.proxy, deviceId: res.deviceId, pairedAt: Date.now() }))
603
847
  emitVault({ phase: 'paired', deviceId: res.deviceId, master: res.master })
848
+ pullProfileFromVault() // adoptar el perfil que ya viva en el vault (si hay)
604
849
  return { ok: true, deviceId: res.deviceId, master: res.master, exp: res.cert.exp, scope: res.cert.scope }
605
850
  },
606
851
 
607
852
  async vaultStatus () {
608
853
  const v = loadVaultCert()
609
854
  if (!v?.cert) return { paired: false }
855
+ maybeRenewVaultCert()
610
856
  return { paired: true, deviceId: v.deviceId, master: v.master, proxy: v.proxy, scope: v.cert.scope, exp: v.cert.exp, pairedAt: v.pairedAt }
611
857
  },
612
858
 
@@ -623,6 +869,7 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null })
623
869
  async vaultSign ({ payload }) {
624
870
  const v = loadVaultCert(); const device = loadVaultDevice()
625
871
  if (!v?.cert || !device) throw new Error('este dispositivo no está emparejado con un vault')
872
+ maybeRenewVaultCert()
626
873
  try { return await remoteSign({ master: v.master, proxy: v.proxy, device, cert: v.cert, payload }) }
627
874
  catch (e) { return handleVaultError(e) }
628
875
  },
@@ -632,6 +879,7 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null })
632
879
  async vaultStore ({ method, args }) {
633
880
  const v = loadVaultCert(); const device = loadVaultDevice()
634
881
  if (!v?.cert || !device) throw new Error('este dispositivo no está emparejado con un vault')
882
+ maybeRenewVaultCert()
635
883
  try { return await remoteStore({ master: v.master, proxy: v.proxy, device, cert: v.cert, method, args }) }
636
884
  catch (e) { return handleVaultError(e) }
637
885
  },
@@ -640,6 +888,7 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null })
640
888
  async listVaultDevices () {
641
889
  const v = loadVaultCert(); const device = loadVaultDevice()
642
890
  if (!v?.cert || !device) throw new Error('este dispositivo no está emparejado con un vault')
891
+ maybeRenewVaultCert()
643
892
  try { return await remoteDevices({ master: v.master, proxy: v.proxy, device, cert: v.cert }) }
644
893
  catch (e) { return handleVaultError(e) }
645
894
  },
@@ -656,9 +905,30 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null })
656
905
  },
657
906
 
658
907
  async setMyNickname ({ nickname }) {
659
- const next = { publickey: publickeyJwkStr, encryptionPubkey: encPublickeyJwkStr, nickname: String(nickname || '').slice(0, 40) }
660
- saveMe(next)
661
- return { me: next }
908
+ return { me: applyMeUpdate({ nickname }) }
909
+ },
910
+
911
+ // Perfil completo (avatar 250x250, links de redes, datos), cada ítem con `visible`
912
+ // (oculto = no se comparte). Merge: no pisa lo que no venga en el patch.
913
+ async updateMe ({ patch } = {}) {
914
+ return { me: applyMeUpdate(patch || {}) }
915
+ },
916
+ async getMe () { return me },
917
+ // Subconjunto PÚBLICO del perfil (solo lo marcado visible) — para compartir/publicar.
918
+ async publicMe () {
919
+ const m = me || {}
920
+ const out = { publickey: m.publickey, encryptionPubkey: m.encryptionPubkey }
921
+ if (m.nickname) out.nickname = m.nickname
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
+ }
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 }
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 }
931
+ return out
662
932
  },
663
933
 
664
934
  async getEncryptionPubkey () { return encPublickeyJwkStr },
@@ -700,7 +970,10 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null })
700
970
 
701
971
  async exportIdentity () {
702
972
  const raw = kv.getItem(KEY_STORAGE)
703
- 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
+ }
704
977
  const keys = JSON.parse(raw)
705
978
  const encRaw = kv.getItem(ENC_KEY_STORAGE)
706
979
  const encKeys = encRaw ? JSON.parse(encRaw) : null
@@ -725,13 +998,10 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null })
725
998
 
726
999
  async importIdentity ({ privateJwk, publicJwk, encPrivateJwk, encPublicJwk, me: meIn, peers: peersIn }) {
727
1000
  if (!privateJwk || !publicJwk) throw new Error('privateJwk and publicJwk required')
728
- await crypto.subtle.importKey('jwk', privateJwk, { name: 'ECDSA', namedCurve: 'P-256' }, true, ['sign'])
729
1001
  await crypto.subtle.importKey('jwk', publicJwk, { name: 'ECDSA', namedCurve: 'P-256' }, true, ['verify'])
730
- kv.setItem(KEY_STORAGE, JSON.stringify({ privateJwk, publicJwk }))
1002
+ await adoptJwkPair('sign', KEY_STORAGE, privateJwk, publicJwk)
731
1003
  if (encPrivateJwk && encPublicJwk) {
732
- await crypto.subtle.importKey('jwk', encPrivateJwk, { name: 'ECDH', namedCurve: 'P-256' }, true, ['deriveBits', 'deriveKey'])
733
- await crypto.subtle.importKey('jwk', encPublicJwk, { name: 'ECDH', namedCurve: 'P-256' }, true, [])
734
- kv.setItem(ENC_KEY_STORAGE, JSON.stringify({ privateJwk: encPrivateJwk, publicJwk: encPublicJwk }))
1004
+ await adoptJwkPair('enc', ENC_KEY_STORAGE, encPrivateJwk, encPublicJwk)
735
1005
  } else {
736
1006
  kv.removeItem(ENC_KEY_STORAGE)
737
1007
  }
@@ -785,6 +1055,20 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null })
785
1055
  encKeypair = await loadOrCreateEncKeypair()
786
1056
  encPublickeyJwkStr = JSON.stringify(encKeypair.publicJwk)
787
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
+
788
1072
  await initPeerStorage()
789
1073
 
790
1074
  const persistedMe = loadMe()
@@ -798,6 +1082,8 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null })
798
1082
  me = { publickey: publickeyJwkStr, encryptionPubkey: encPublickeyJwkStr }
799
1083
  kv.setItem(ME_STORAGE, JSON.stringify(me))
800
1084
  }
1085
+ // Perfil compartido: jalar del vault en background (gana el más nuevo).
1086
+ pullProfileFromVault()
801
1087
 
802
1088
  // Registrar el pubkey (y nombre) del perfil activo en su meta → para avatar/listado sin abrir cada perfil.
803
1089
  {
@@ -818,6 +1104,18 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null })
818
1104
  onDirty(() => { if (sync) sync.markDirty() })
819
1105
  }
820
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
+
821
1119
  return {
822
1120
  handlers,
823
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
  })()