@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 +1 -1
- package/src/index.d.ts +19 -0
- package/src/index.js +78 -1
- package/src/node.js +3 -0
- package/vault/capabilities.js +9 -2
- package/vault/core.js +347 -49
- package/vault/remote.js +16 -5
- package/vault/vault.js +81 -8
package/package.json
CHANGED
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) {
|
package/vault/capabilities.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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
|
|
152
|
-
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)
|
|
153
214
|
if (raw) {
|
|
154
215
|
try {
|
|
155
216
|
const { privateJwk, publicJwk } = JSON.parse(raw)
|
|
156
|
-
const privateKey = await crypto.subtle.importKey('jwk', privateJwk,
|
|
157
|
-
|
|
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(
|
|
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(
|
|
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 = () => {
|
|
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
|
-
|
|
296
|
-
|
|
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:
|
|
301
|
-
publicJwk:
|
|
302
|
-
encPrivateJwk:
|
|
303
|
-
encPublicJwk:
|
|
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
|
-
|
|
425
|
+
await adoptJwkPair('sign', KEY_STORAGE, merged.privateJwk, merged.publicJwk)
|
|
313
426
|
if (merged.encPrivateJwk && merged.encPublicJwk) {
|
|
314
|
-
|
|
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
|
-
|
|
599
|
-
|
|
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(
|
|
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
|
-
|
|
660
|
-
|
|
661
|
-
|
|
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)
|
|
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
|
-
|
|
1002
|
+
await adoptJwkPair('sign', KEY_STORAGE, privateJwk, publicJwk)
|
|
731
1003
|
if (encPrivateJwk && encPublicJwk) {
|
|
732
|
-
await
|
|
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
|
|
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
|
})()
|