@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 +6 -2
- package/src/index.d.ts +19 -0
- package/src/index.js +64 -3
- package/vault/avatar.js +65 -0
- package/vault/capabilities.js +14 -45
- package/vault/core.js +293 -51
- package/vault/remote.js +16 -5
- package/vault/vault.js +81 -8
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dotrino/identity",
|
|
3
|
-
"version": "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
|
-
"./
|
|
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).
|
|
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 })
|
package/vault/avatar.js
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @dotrino/identity/avatar — el AVATAR identicon, solo.
|
|
3
|
+
*
|
|
4
|
+
* Vive aparte de `capabilities.js` a propósito: es una función pura de ~30
|
|
5
|
+
* líneas, sin dependencias, sin crypto y sin vault, pero `capabilities.js`
|
|
6
|
+
* importa `core.js` (55 KB) y `core.js` importa de vuelta `capabilities.js`
|
|
7
|
+
* (dependencia circular). O sea que quien solo quería el identicon —el topbar,
|
|
8
|
+
* la tarjeta de perfil— se arrastraba el vault entero, o dependía de que el
|
|
9
|
+
* bundler podara `core.js` de milagro (ningún paquete declara `sideEffects`).
|
|
10
|
+
*
|
|
11
|
+
* Este subpath da la garantía: importar el avatar cuesta el avatar.
|
|
12
|
+
*
|
|
13
|
+
* NO copies estas funciones dentro de tu app. El identicon es DETERMINISTA a
|
|
14
|
+
* partir del pubkey: si cada app llevara su copia, el mismo usuario podría
|
|
15
|
+
* derivar un avatar distinto según dónde lo miren.
|
|
16
|
+
*
|
|
17
|
+
* `capabilities.js` re-exporta de aquí, así que los importadores viejos siguen
|
|
18
|
+
* funcionando igual.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/** Hash FNV-1a + xorshift. Sin necesidades de seguridad: es decorativo. */
|
|
22
|
+
function _hashSeed (s) {
|
|
23
|
+
let h = 2166136261 >>> 0
|
|
24
|
+
for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 16777619) }
|
|
25
|
+
const bytes = []
|
|
26
|
+
let x = (h ^ 0x9e3779b9) >>> 0
|
|
27
|
+
for (let i = 0; i < 16; i++) { x ^= x << 13; x ^= x >>> 17; x ^= x << 5; x >>>= 0; bytes.push(x & 0xff) }
|
|
28
|
+
return { h: h >>> 0, bytes }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Identicon determinista de una semilla (normalmente el pubkey): así cada perfil
|
|
33
|
+
* nace con imagen sin que el usuario tenga que subir nada. Síncrono → usable
|
|
34
|
+
* directo en plantillas. Rejilla 5×5 simétrica sobre una "moneda" redondeada,
|
|
35
|
+
* con color derivado del hash.
|
|
36
|
+
*/
|
|
37
|
+
export function avatarSvg (seed, { size = 80 } = {}) {
|
|
38
|
+
const { h, bytes } = _hashSeed(String(seed || 'dotrino'))
|
|
39
|
+
const hue = h % 360
|
|
40
|
+
const hue2 = (hue + 40) % 360
|
|
41
|
+
const fg = `hsl(${hue} 62% 46%)`
|
|
42
|
+
const bg1 = `hsl(${hue} 48% 95%)`
|
|
43
|
+
const bg2 = `hsl(${hue2} 48% 90%)`
|
|
44
|
+
const cells = 5
|
|
45
|
+
const unit = size / cells
|
|
46
|
+
let rects = ''
|
|
47
|
+
for (let col = 0; col < 3; col++) {
|
|
48
|
+
for (let row = 0; row < cells; row++) {
|
|
49
|
+
if (!(bytes[col * cells + row] & 1)) continue
|
|
50
|
+
for (const c of (col === 2 ? [2] : [col, cells - 1 - col])) {
|
|
51
|
+
rects += `<rect x="${(c * unit).toFixed(2)}" y="${(row * unit).toFixed(2)}" width="${unit.toFixed(2)}" height="${unit.toFixed(2)}"/>`
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
const id = 'g' + (h % 100000)
|
|
56
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${size} ${size}" width="${size}" height="${size}">` +
|
|
57
|
+
`<defs><linearGradient id="${id}" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="${bg1}"/><stop offset="1" stop-color="${bg2}"/></linearGradient></defs>` +
|
|
58
|
+
`<rect width="${size}" height="${size}" rx="${(size * 0.5).toFixed(2)}" fill="url(#${id})"/>` +
|
|
59
|
+
`<g fill="${fg}" transform="translate(${(size * 0.12).toFixed(2)} ${(size * 0.12).toFixed(2)}) scale(0.76)">${rects}</g></svg>`
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** El avatar como data-URI listo para `<img src>` o `background-image`. */
|
|
63
|
+
export function avatarDataUri (seed, opts) {
|
|
64
|
+
return 'data:image/svg+xml,' + encodeURIComponent(avatarSvg(seed, opts))
|
|
65
|
+
}
|
package/vault/capabilities.js
CHANGED
|
@@ -99,49 +99,11 @@ export async function commitCode ({ code, dpub, sn }) {
|
|
|
99
99
|
return [...new Uint8Array(h)].map((x) => x.toString(16).padStart(2, '0')).join('')
|
|
100
100
|
}
|
|
101
101
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
* sobre una "moneda" redondeada, con color derivado del hash.
|
|
108
|
-
*/
|
|
109
|
-
function _hashSeed (s) {
|
|
110
|
-
let h = 2166136261 >>> 0
|
|
111
|
-
for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 16777619) }
|
|
112
|
-
const bytes = []
|
|
113
|
-
let x = (h ^ 0x9e3779b9) >>> 0
|
|
114
|
-
for (let i = 0; i < 16; i++) { x ^= x << 13; x ^= x >>> 17; x ^= x << 5; x >>>= 0; bytes.push(x & 0xff) }
|
|
115
|
-
return { h: h >>> 0, bytes }
|
|
116
|
-
}
|
|
117
|
-
export function avatarSvg (seed, { size = 80 } = {}) {
|
|
118
|
-
const { h, bytes } = _hashSeed(String(seed || 'dotrino'))
|
|
119
|
-
const hue = h % 360
|
|
120
|
-
const hue2 = (hue + 40) % 360
|
|
121
|
-
const fg = `hsl(${hue} 62% 46%)`
|
|
122
|
-
const bg1 = `hsl(${hue} 48% 95%)`
|
|
123
|
-
const bg2 = `hsl(${hue2} 48% 90%)`
|
|
124
|
-
const cells = 5
|
|
125
|
-
const unit = size / cells
|
|
126
|
-
let rects = ''
|
|
127
|
-
for (let col = 0; col < 3; col++) {
|
|
128
|
-
for (let row = 0; row < cells; row++) {
|
|
129
|
-
if (!(bytes[col * cells + row] & 1)) continue
|
|
130
|
-
for (const c of (col === 2 ? [2] : [col, cells - 1 - col])) {
|
|
131
|
-
rects += `<rect x="${(c * unit).toFixed(2)}" y="${(row * unit).toFixed(2)}" width="${unit.toFixed(2)}" height="${unit.toFixed(2)}"/>`
|
|
132
|
-
}
|
|
133
|
-
}
|
|
134
|
-
}
|
|
135
|
-
const id = 'g' + (h % 100000)
|
|
136
|
-
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${size} ${size}" width="${size}" height="${size}">` +
|
|
137
|
-
`<defs><linearGradient id="${id}" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="${bg1}"/><stop offset="1" stop-color="${bg2}"/></linearGradient></defs>` +
|
|
138
|
-
`<rect width="${size}" height="${size}" rx="${(size * 0.5).toFixed(2)}" fill="url(#${id})"/>` +
|
|
139
|
-
`<g fill="${fg}" transform="translate(${(size * 0.12).toFixed(2)} ${(size * 0.12).toFixed(2)}) scale(0.76)">${rects}</g></svg>`
|
|
140
|
-
}
|
|
141
|
-
/** El avatar como data-URI listo para `<img src>` o `background-image`. */
|
|
142
|
-
export function avatarDataUri (seed, opts) {
|
|
143
|
-
return 'data:image/svg+xml,' + encodeURIComponent(avatarSvg(seed, opts))
|
|
144
|
-
}
|
|
102
|
+
// El identicon vive en ./avatar.js (función pura, sin dependencias) y se
|
|
103
|
+
// re-exporta aquí por compatibilidad: importarlo desde este módulo arrastra
|
|
104
|
+
// core.js (55 KB), así que si SOLO quieres el avatar usa el subpath
|
|
105
|
+
// '@dotrino/identity/avatar'.
|
|
106
|
+
export { avatarSvg, avatarDataUri } from './avatar.js'
|
|
145
107
|
|
|
146
108
|
/** Cuerpo canónico del certificado (lo que se firma): el cert SIN la firma. */
|
|
147
109
|
export function delegationBody (cert) {
|
|
@@ -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
|
-
//
|
|
112
|
-
//
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
|
179
|
-
const
|
|
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,
|
|
184
|
-
|
|
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(
|
|
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(
|
|
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 = () => {
|
|
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
|
-
|
|
323
|
-
|
|
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:
|
|
328
|
-
publicJwk:
|
|
329
|
-
encPrivateJwk:
|
|
330
|
-
encPublicJwk:
|
|
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
|
-
|
|
425
|
+
await adoptJwkPair('sign', KEY_STORAGE, merged.privateJwk, merged.publicJwk)
|
|
340
426
|
if (merged.encPrivateJwk && merged.encPublicJwk) {
|
|
341
|
-
|
|
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
|
-
|
|
640
|
-
|
|
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(
|
|
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)
|
|
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
|
-
|
|
1002
|
+
await adoptJwkPair('sign', KEY_STORAGE, privateJwk, publicJwk)
|
|
787
1003
|
if (encPrivateJwk && encPublicJwk) {
|
|
788
|
-
await
|
|
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
|
|
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
|
-
//
|
|
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
|
|
36
|
-
|
|
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
|
|
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
|
-
|
|
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
|
})()
|