@dotrino/identity 0.102.2 → 0.104.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/vault/capabilities.js +3 -0
- package/vault/content.js +5 -2
- package/vault/core.js +27 -0
- package/vault/externalKeys.js +129 -0
- package/vault/nativeStore.js +80 -0
- package/vault/peerStore.js +21 -0
- package/vault/vault.js +17 -2
package/package.json
CHANGED
package/vault/capabilities.js
CHANGED
|
@@ -52,6 +52,9 @@ export const LEGACY_CERTS_UNTIL = Date.parse('2026-10-01T00:00:00Z')
|
|
|
52
52
|
const enc = (s) => new TextEncoder().encode(s)
|
|
53
53
|
|
|
54
54
|
async function rawSign (privateKey, bytes) {
|
|
55
|
+
// Una llave EXTERNA (el chip del teléfono, `vault/vault.js`) firma ella misma: aquí no
|
|
56
|
+
// hay CryptoKey que pasarle a WebCrypto. Devuelve la misma firma P1363 en base64.
|
|
57
|
+
if (privateKey?.external) return privateKey.sign(bytes)
|
|
55
58
|
return bufToBase64(await crypto.subtle.sign(SIGN, privateKey, bytes))
|
|
56
59
|
}
|
|
57
60
|
async function rawVerify (publicJwkStr, bytes, sigB64) {
|
package/vault/content.js
CHANGED
|
@@ -37,8 +37,11 @@ const fromB64 = (str) => {
|
|
|
37
37
|
}
|
|
38
38
|
|
|
39
39
|
async function sharedKey (privateKey, peerPubJwkStr) {
|
|
40
|
-
|
|
41
|
-
|
|
40
|
+
// Llave EXTERNA: el acuerdo ECDH lo hace el chip y devuelve los 32 bytes del secreto
|
|
41
|
+
// (lo mismo que `deriveBits(…, 256)`); la privada no sale de allí.
|
|
42
|
+
const bits = privateKey?.external
|
|
43
|
+
? await privateKey.deriveBits(peerPubJwkStr)
|
|
44
|
+
: await subtle.deriveBits({ name: 'ECDH', public: await subtle.importKey('jwk', JSON.parse(peerPubJwkStr), ECDH, false, []) }, privateKey, 256)
|
|
42
45
|
return subtle.importKey('raw', bits, { name: 'AES-GCM' }, false, ['encrypt', 'decrypt'])
|
|
43
46
|
}
|
|
44
47
|
|
package/vault/core.js
CHANGED
|
@@ -88,6 +88,13 @@ async function importPeerEncPubkey (jwkStr) {
|
|
|
88
88
|
}
|
|
89
89
|
|
|
90
90
|
async function deriveSharedAesKey (myPriv, peerPub) {
|
|
91
|
+
if (myPriv?.external) {
|
|
92
|
+
// Llave EXTERNA: el chip da los 32 bytes del secreto ECDH, que es exactamente lo que
|
|
93
|
+
// `deriveKey` ECDH→AES-256 usa por dentro. Mismo AES, sin que la privada salga.
|
|
94
|
+
const { kty, crv, x, y } = await crypto.subtle.exportKey('jwk', peerPub)
|
|
95
|
+
const bits = await myPriv.deriveBits(JSON.stringify({ kty, crv, x, y }))
|
|
96
|
+
return crypto.subtle.importKey('raw', bits, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt'])
|
|
97
|
+
}
|
|
91
98
|
return crypto.subtle.deriveKey(
|
|
92
99
|
{ name: 'ECDH', public: peerPub },
|
|
93
100
|
myPriv,
|
|
@@ -98,6 +105,7 @@ async function deriveSharedAesKey (myPriv, peerPub) {
|
|
|
98
105
|
}
|
|
99
106
|
|
|
100
107
|
async function signBytes (privateKey, bytes) {
|
|
108
|
+
if (privateKey?.external) return privateKey.sign(bytes) // el chip del teléfono (ver rawSign)
|
|
101
109
|
const sig = await crypto.subtle.sign({ name: 'ECDSA', hash: { name: 'SHA-256' } }, privateKey, bytes)
|
|
102
110
|
return bufToBase64(sig)
|
|
103
111
|
}
|
|
@@ -330,6 +338,13 @@ export async function createIdentityCore ({ kv: hostKv, peers, makeSync = null,
|
|
|
330
338
|
return { privateKey, publicKey: await importPub(publicJwk), publicJwk }
|
|
331
339
|
} catch (_) {}
|
|
332
340
|
}
|
|
341
|
+
// El ANFITRIÓN puede crear la llave fuera (dentro de la app de Android: en el chip del
|
|
342
|
+
// teléfono, `vault/vault.js`). Guarda él su registro y devuelve un asa externa. Sin
|
|
343
|
+
// `create` —el navegador y las ~30 apps— el camino es el de siempre.
|
|
344
|
+
if (keyStore.create) {
|
|
345
|
+
const made = await keyStore.create(kind, name)
|
|
346
|
+
return { privateKey: made.privateKey, publicKey: await importPub(made.publicJwk), publicJwk: made.publicJwk }
|
|
347
|
+
}
|
|
333
348
|
const pair = await crypto.subtle.generateKey(algo, false, pairUses) // privada NO extractable
|
|
334
349
|
const publicJwk = await crypto.subtle.exportKey('jwk', pair.publicKey)
|
|
335
350
|
await keyStore.set(name, { privateKey: pair.privateKey, publicJwk })
|
|
@@ -1282,6 +1297,18 @@ export async function createIdentityCore ({ kv: hostKv, peers, makeSync = null,
|
|
|
1282
1297
|
// Conectarse a una bóveda es ENTRAR A SU CUENTA: el acta viene con el cert.
|
|
1283
1298
|
const unido = res.acta ? await joinProfile(res.acta) : { joined: false, reason: 'sin-acta' }
|
|
1284
1299
|
emitVault({ phase: 'paired', deviceId: res.deviceId, master: res.master, join: unido })
|
|
1300
|
+
// LA LLAVE ES DEL CHIP DEL TELÉFONO (app de Android): la app guarda la cuenta para su
|
|
1301
|
+
// pantalla nativa de Pedidos, con el MISMO papel. Así el teléfono es un solo aparato en
|
|
1302
|
+
// el acta. Si la app no la guarda, se dice: el emparejamiento en sí ya está hecho.
|
|
1303
|
+
if (keypair?.privateKey?.external) {
|
|
1304
|
+
keypair.privateKey.paired({
|
|
1305
|
+
cert: res.cert, vault: res.master, proxy: res.proxy, deviceId: res.deviceId,
|
|
1306
|
+
name: res.account || res.acta?.name || '', profileId: res.acta?.profileId || null
|
|
1307
|
+
}).then(
|
|
1308
|
+
() => emitVault({ phase: 'native-account', ok: true }),
|
|
1309
|
+
(e) => { console.warn('[identity] the phone did not save the account: ' + e.message); emitVault({ phase: 'native-account', ok: false, code: e.code || 'native-error', reason: e.message }) }
|
|
1310
|
+
)
|
|
1311
|
+
}
|
|
1285
1312
|
pullProfileFromVault() // adoptar el perfil que ya viva en el vault (si hay)
|
|
1286
1313
|
return { ok: true, deviceId: res.deviceId, master: res.master, seq: res.cert.seq, scope: res.cert.scope, join: unido }
|
|
1287
1314
|
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LLAVES QUE NO VIVEN AQUÍ: el chip del teléfono, dentro de la app de Dotrino.
|
|
3
|
+
*
|
|
4
|
+
* En la app de Android el iframe corre en un WebView, y el teléfono ya tiene su llave de
|
|
5
|
+
* cada cuenta en el Keystore (la que aprueba en la pantalla nativa de Pedidos). Si el
|
|
6
|
+
* WebView se hiciera la suya, el teléfono serían DOS aparatos en el acta: uno con perfil y
|
|
7
|
+
* sin aprobar, otro aprobando y sin perfil (dueño, 2026-09-25: «es como si el perfil viviera
|
|
8
|
+
* solo en nativo y no en el webview»). Con esto hay UNA llave: el WebView firma y descifra
|
|
9
|
+
* con la del chip, y la privada nunca entra en esta página.
|
|
10
|
+
*
|
|
11
|
+
* Este módulo envuelve el `keyStore` de IndexedDB. Lo que guarda por llave es un registro
|
|
12
|
+
* sin nada secreto: `{ external: kid, kind, publicJwk }`. Al leerlo lo convierte en un ASA,
|
|
13
|
+
* que es lo que `core.js` trata como privada: `{ external: true, sign, deriveBits }`. Las
|
|
14
|
+
* cuatro funciones que usan una privada (`rawSign`, `signBytes`, `sharedKey`,
|
|
15
|
+
* `deriveSharedAesKey`) saben qué hacer con ella.
|
|
16
|
+
*
|
|
17
|
+
* `bridge.call(method, params)` habla con la app: `create`, `open`, `sign`, `deriveBits`,
|
|
18
|
+
* `save`, `remove`. La app solo le enseña el puente a `https://id.dotrino.com`.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const b64 = (buf) => {
|
|
22
|
+
const bytes = buf instanceof Uint8Array ? buf : new Uint8Array(buf)
|
|
23
|
+
let s = ''
|
|
24
|
+
for (let i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i])
|
|
25
|
+
return btoa(s)
|
|
26
|
+
}
|
|
27
|
+
const fromB64 = (str) => {
|
|
28
|
+
const bin = atob(str)
|
|
29
|
+
const out = new Uint8Array(bin.length)
|
|
30
|
+
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i)
|
|
31
|
+
return out
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const SIGN_SUFFIX = '.keypair'
|
|
35
|
+
const ENC_SUFFIX = '.enc-keypair'
|
|
36
|
+
|
|
37
|
+
/** El nombre de la OTRA mitad del mismo perfil: firma y cifrado comparten llave en el chip. */
|
|
38
|
+
function siblingName (name) {
|
|
39
|
+
if (name.endsWith(ENC_SUFFIX)) return name.slice(0, -ENC_SUFFIX.length) + SIGN_SUFFIX
|
|
40
|
+
if (name.endsWith(SIGN_SUFFIX)) return name.slice(0, -SIGN_SUFFIX.length) + ENC_SUFFIX
|
|
41
|
+
return null
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** El asa de una llave del chip. Lo que la app no pueda hacer sale con su `code`, sin plan B. */
|
|
45
|
+
export function externalHandle (bridge, kid) {
|
|
46
|
+
return {
|
|
47
|
+
external: true,
|
|
48
|
+
kid,
|
|
49
|
+
async sign (bytes) {
|
|
50
|
+
const { signature } = await bridge.call('sign', { kid, data: b64(bytes) })
|
|
51
|
+
return signature
|
|
52
|
+
},
|
|
53
|
+
async deriveBits (peerPubJwkStr) {
|
|
54
|
+
const { bits } = await bridge.call('deriveBits', { kid, peer: peerPubJwkStr })
|
|
55
|
+
return fromB64(bits).buffer
|
|
56
|
+
},
|
|
57
|
+
/** Tras emparejar: la app guarda la cuenta para su pantalla de Pedidos. */
|
|
58
|
+
async paired (account) { return bridge.call('save', { kid, ...account }) }
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Envuelve `keyStore` para que las llaves NUEVAS nazcan en el chip. Las que ya existían
|
|
64
|
+
* (CryptoKey de IndexedDB) siguen igual: una llave no extraíble no se puede mover.
|
|
65
|
+
*/
|
|
66
|
+
export function withExternalKeys (keyStore, bridge) {
|
|
67
|
+
const toHandle = (rec) => (rec && rec.external
|
|
68
|
+
? { privateKey: externalHandle(bridge, rec.external), publicJwk: rec.publicJwk }
|
|
69
|
+
: rec)
|
|
70
|
+
return {
|
|
71
|
+
async get (name) { return toHandle(await keyStore.get(name)) },
|
|
72
|
+
set: (name, pair) => keyStore.set(name, pair),
|
|
73
|
+
async remove (name) {
|
|
74
|
+
const rec = await keyStore.get(name)
|
|
75
|
+
await keyStore.remove(name)
|
|
76
|
+
// Se borra del chip cuando ya no queda ninguna de las dos mitades del perfil.
|
|
77
|
+
const sib = siblingName(name)
|
|
78
|
+
const other = sib ? await keyStore.get(sib) : null
|
|
79
|
+
if (rec?.external && !(other?.external === rec.external)) await bridge.call('remove', { kid: rec.external })
|
|
80
|
+
},
|
|
81
|
+
async create (kind, name) {
|
|
82
|
+
// Las dos mitades del perfil van bajo la MISMA llave del chip: si la otra ya nació
|
|
83
|
+
// allí, se reutiliza; si no, se crea una nueva.
|
|
84
|
+
const sib = siblingName(name)
|
|
85
|
+
const other = sib ? await keyStore.get(sib) : null
|
|
86
|
+
const k = other?.external
|
|
87
|
+
? await bridge.call('open', { kid: other.external })
|
|
88
|
+
: await bridge.call('create', {})
|
|
89
|
+
const publicJwk = kind === 'enc' ? k.encPub : k.publickey
|
|
90
|
+
if (!k?.kid || !publicJwk) throw Object.assign(new Error('the phone did not return a key'), { code: 'native-no-key' })
|
|
91
|
+
const rec = { external: k.kid, kind, publicJwk: typeof publicJwk === 'string' ? JSON.parse(publicJwk) : publicJwk }
|
|
92
|
+
await keyStore.set(name, rec)
|
|
93
|
+
return { privateKey: externalHandle(bridge, k.kid), publicJwk: rec.publicJwk }
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* El puente de la app visto desde la página: `window.DotrinoIdentityKeys` (un
|
|
100
|
+
* `addWebMessageListener` de Android). Mensajes `{ id, method, params }` → `{ id, result }`
|
|
101
|
+
* o `{ id, error, code }`.
|
|
102
|
+
*/
|
|
103
|
+
export function appBridge (port, { timeoutMs = 20000 } = {}) {
|
|
104
|
+
let seq = 0
|
|
105
|
+
const waiting = new Map()
|
|
106
|
+
port.onmessage = (ev) => {
|
|
107
|
+
let m
|
|
108
|
+
try { m = JSON.parse(ev.data) } catch (_) { return }
|
|
109
|
+
const w = waiting.get(m?.id)
|
|
110
|
+
if (!w) return
|
|
111
|
+
waiting.delete(m.id)
|
|
112
|
+
clearTimeout(w.t)
|
|
113
|
+
if (m.error) w.reject(Object.assign(new Error(m.error), { code: m.code || 'native-error' }))
|
|
114
|
+
else w.resolve(m.result)
|
|
115
|
+
}
|
|
116
|
+
return {
|
|
117
|
+
call (method, params = {}) {
|
|
118
|
+
const id = 'k' + (++seq)
|
|
119
|
+
return new Promise((resolve, reject) => {
|
|
120
|
+
const t = setTimeout(() => {
|
|
121
|
+
waiting.delete(id)
|
|
122
|
+
reject(Object.assign(new Error(`the phone did not answer ${method}`), { code: 'native-no-reply' }))
|
|
123
|
+
}, timeoutMs)
|
|
124
|
+
waiting.set(id, { resolve, reject, t })
|
|
125
|
+
port.postMessage(JSON.stringify({ id, method, params }))
|
|
126
|
+
})
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TODO LO DE LA IDENTIDAD EN LA APP, NO EN LA PÁGINA (app de iOS, 2026-09-26).
|
|
3
|
+
*
|
|
4
|
+
* WebKit guarda el localStorage y el IndexedDB de un iframe de otro origen APARTE por cada
|
|
5
|
+
* página que lo contiene. Dentro de la app de iOS eso partía la identidad en tantas como
|
|
6
|
+
* páginas: `dotrino.com`, `profile.dotrino.com` y `vault.dotrino.com` veían cada una un
|
|
7
|
+
* perfil distinto, recién inventado (dueño: «todas deberían tener el perfil/perfiles
|
|
8
|
+
* cargados nativamente»). El WebView de Android no separa así, y por eso allí no pasaba.
|
|
9
|
+
*
|
|
10
|
+
* Con esto, cuando la app lo ofrece (`bridge.storage`), las tres piezas que la identidad
|
|
11
|
+
* guarda —el `kv` síncrono, los registros de llaves y el libro de contactos— viven en UN
|
|
12
|
+
* almacén de la app, el mismo para todas las páginas. Nada secreto viaja: las llaves son del
|
|
13
|
+
* chip (el registro es `{ external: kid, kind, publicJwk }`) y el resto es lo que ya se
|
|
14
|
+
* guardaba en el disco de la página.
|
|
15
|
+
*
|
|
16
|
+
* Cómo: al arrancar se trae TODO de una vez (`storeLoad`) y se sirve desde memoria, que es
|
|
17
|
+
* lo que el `kv` necesita para seguir siendo síncrono. Cada cambio se escribe en la app al
|
|
18
|
+
* momento, en orden. Solo hay un iframe de identidad vivo a la vez (la app tiene un solo
|
|
19
|
+
* WebView), así que no hay dos memorias que se pisen.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const KV = 'kv:'
|
|
23
|
+
const KEY = 'key:'
|
|
24
|
+
const PEERS = 'peers:'
|
|
25
|
+
|
|
26
|
+
/** El asa de una llave no se puede guardar en la app: solo su registro, que no tiene nada secreto. */
|
|
27
|
+
function plainKeyRecord (rec) {
|
|
28
|
+
if (rec && typeof rec.external === 'string' && rec.publicJwk && (rec.kind === 'sign' || rec.kind === 'enc')) {
|
|
29
|
+
return { external: rec.external, kind: rec.kind, publicJwk: rec.publicJwk }
|
|
30
|
+
}
|
|
31
|
+
// Una llave de SOFTWARE (adoptar un perfil, entrar con contraseña) no cabe en el chip, y
|
|
32
|
+
// guardarla a medias sería perder la cuenta sin decirlo. Se para aquí y se dice.
|
|
33
|
+
throw Object.assign(new Error('this app keeps keys in the phone chip only: a software key cannot be stored here'), { code: 'native-no-import' })
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function nativeBackends (bridge) {
|
|
37
|
+
const { items } = await bridge.call('storeLoad', {})
|
|
38
|
+
if (!items || typeof items !== 'object') {
|
|
39
|
+
throw Object.assign(new Error('the app did not return its store'), { code: 'native-no-store' })
|
|
40
|
+
}
|
|
41
|
+
const mem = new Map(Object.entries(items))
|
|
42
|
+
|
|
43
|
+
// Escrituras EN ORDEN: un `remove` que adelantara a su `set` resucitaría el dato. Una que
|
|
44
|
+
// falla se dice en la consola con su código (sin tragársela) y no frena a las siguientes.
|
|
45
|
+
let chain = Promise.resolve()
|
|
46
|
+
const write = (method, params) => {
|
|
47
|
+
chain = chain.then(() => bridge.call(method, params)).catch((e) => {
|
|
48
|
+
console.error(`[cc-identity] native store ${method} failed (${e?.code || 'error'}): ${e?.message}`)
|
|
49
|
+
})
|
|
50
|
+
return chain
|
|
51
|
+
}
|
|
52
|
+
const set = (k, v) => { mem.set(k, v); return write('storeSet', { k, v }) }
|
|
53
|
+
const del = (k) => { mem.delete(k); return write('storeRemove', { k }) }
|
|
54
|
+
|
|
55
|
+
const kv = {
|
|
56
|
+
getItem: (k) => (mem.has(KV + k) ? mem.get(KV + k) : null),
|
|
57
|
+
setItem: (k, v) => { set(KV + k, String(v)) },
|
|
58
|
+
removeItem: (k) => { del(KV + k) }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const keyStore = {
|
|
62
|
+
async get (name) {
|
|
63
|
+
const raw = mem.get(KEY + name)
|
|
64
|
+
return raw ? JSON.parse(raw) : null
|
|
65
|
+
},
|
|
66
|
+
async set (name, rec) { await set(KEY + name, JSON.stringify(plainKeyRecord(rec))) },
|
|
67
|
+
async remove (name) { await del(KEY + name) }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// El libro de contactos por perfil (`peerStore.useBackend`).
|
|
71
|
+
const peers = {
|
|
72
|
+
async get (key) {
|
|
73
|
+
const raw = mem.get(PEERS + key)
|
|
74
|
+
return raw ? JSON.parse(raw) : null
|
|
75
|
+
},
|
|
76
|
+
async put (key, val) { await set(PEERS + key, JSON.stringify(val)) }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return { kv, keyStore, peers, flush: () => chain }
|
|
80
|
+
}
|
package/vault/peerStore.js
CHANGED
|
@@ -40,6 +40,14 @@ let _pid = null
|
|
|
40
40
|
*/
|
|
41
41
|
let _volatile = false
|
|
42
42
|
|
|
43
|
+
/**
|
|
44
|
+
* Otro almacén para el libro, en vez del IndexedDB de la página: el de la app de iOS
|
|
45
|
+
* (`nativeStore.js`), que es uno para todas las páginas. `{ get(key), put(key, val) }`.
|
|
46
|
+
* Se pone antes de `initPeerStorage`.
|
|
47
|
+
*/
|
|
48
|
+
let _backend = null
|
|
49
|
+
export function useBackend (b) { _backend = b || null }
|
|
50
|
+
|
|
43
51
|
/** Registra el callback que marca el estado como "sucio" para el sync. */
|
|
44
52
|
export function onDirty (fn) { _markDirty = fn }
|
|
45
53
|
|
|
@@ -53,6 +61,7 @@ function peersKey () { return _pid ? `peers.${_pid}.v1` : IDB_PEERS_KEY }
|
|
|
53
61
|
|
|
54
62
|
/** Migración: copia el peer book VIEJO (pre-multi-perfil, sin namespace) al perfil `pid`. */
|
|
55
63
|
export async function adoptLegacy (pid) {
|
|
64
|
+
if (_backend) return // el almacén de la app nació después: no hay libro viejo que adoptar
|
|
56
65
|
try {
|
|
57
66
|
_idb = _idb || await openIdb()
|
|
58
67
|
let legacy = await idbGet(_idb, IDB_PEERS_KEY) // 'peers.v1' (viejo)
|
|
@@ -103,6 +112,11 @@ export async function initPeerStorage () {
|
|
|
103
112
|
try { if (typeof navigator !== 'undefined' && navigator.storage?.persist) await navigator.storage.persist() }
|
|
104
113
|
catch (_) { /* best-effort */ }
|
|
105
114
|
if (_volatile) { _peers = {}; return _peers }
|
|
115
|
+
if (_backend) {
|
|
116
|
+
const stored = await _backend.get(peersKey())
|
|
117
|
+
_peers = (stored && typeof stored === 'object') ? stored : {}
|
|
118
|
+
return _peers
|
|
119
|
+
}
|
|
106
120
|
try {
|
|
107
121
|
_idb = await openIdb()
|
|
108
122
|
const stored = await idbGet(_idb, peersKey()) // peer book DEL perfil activo (namespaceado)
|
|
@@ -120,6 +134,13 @@ export async function initPeerStorage () {
|
|
|
120
134
|
function persistPeers () {
|
|
121
135
|
if (_volatile) return _writeChain // cuenta volátil: en memoria y en ningún disco
|
|
122
136
|
const key = peersKey()
|
|
137
|
+
if (_backend) {
|
|
138
|
+
const snapshot = _peers
|
|
139
|
+
_writeChain = _writeChain
|
|
140
|
+
.then(() => _backend.put(key, snapshot))
|
|
141
|
+
.catch(e => console.warn('[cc-identity] persist (app) failed:', e?.message))
|
|
142
|
+
return _writeChain
|
|
143
|
+
}
|
|
123
144
|
if (_fallback || !_idb) {
|
|
124
145
|
try { localStorage.setItem(key, JSON.stringify(_peers)) }
|
|
125
146
|
catch (e) { console.warn('[cc-identity] persist (ls) failed:', e?.message) }
|
package/vault/vault.js
CHANGED
|
@@ -14,10 +14,21 @@ import {
|
|
|
14
14
|
} from './peerStore.js'
|
|
15
15
|
import { createIdentityCore } from './core.js'
|
|
16
16
|
import { pubkeyId } from './capabilities.js'
|
|
17
|
+
import { withExternalKeys, appBridge } from './externalKeys.js'
|
|
18
|
+
import { nativeBackends } from './nativeStore.js'
|
|
19
|
+
import { useBackend as usePeerBackend } from './peerStore.js'
|
|
17
20
|
|
|
18
21
|
;(async () => {
|
|
22
|
+
// DENTRO DE LA APP, el puente con el teléfono. En un navegador no está y nada cambia.
|
|
23
|
+
const idKeys = typeof window !== 'undefined' ? window.DotrinoIdentityKeys : null
|
|
24
|
+
const bridge = idKeys ? appBridge(idKeys) : null
|
|
25
|
+
// Y si la app GUARDA la identidad (`storage`, la de iOS): todo vive allí, uno para todas
|
|
26
|
+
// las páginas, porque WebKit parte el almacén de este iframe por página (`./nativeStore.js`).
|
|
27
|
+
const native = bridge && idKeys.storage === true ? await nativeBackends(bridge) : null
|
|
28
|
+
if (native) usePeerBackend(native.peers)
|
|
29
|
+
|
|
19
30
|
// kv estilo localStorage (síncrono) para me, nonces, delegaciones, certs.
|
|
20
|
-
const kv = {
|
|
31
|
+
const kv = native ? native.kv : {
|
|
21
32
|
getItem: (k) => localStorage.getItem(k),
|
|
22
33
|
setItem: (k, v) => localStorage.setItem(k, v),
|
|
23
34
|
removeItem: (k) => localStorage.removeItem(k)
|
|
@@ -27,7 +38,7 @@ import { pubkeyId } from './capabilities.js'
|
|
|
27
38
|
// IndexedDB (clonado estructurado). Nadie —ni este código, ni un XSS en este
|
|
28
39
|
// origen— puede leer sus bytes; solo firmar/derivar con ellas. Las llaves
|
|
29
40
|
// planas (JWK) viejas de localStorage se migran y se borran (core.js).
|
|
30
|
-
const
|
|
41
|
+
const baseKeyStore = native ? native.keyStore : await (() => new Promise((resolve) => {
|
|
31
42
|
const req = indexedDB.open('dotrino-identity-keys', 1)
|
|
32
43
|
req.onupgradeneeded = () => req.result.createObjectStore('keys')
|
|
33
44
|
req.onsuccess = () => {
|
|
@@ -46,6 +57,10 @@ import { pubkeyId } from './capabilities.js'
|
|
|
46
57
|
}
|
|
47
58
|
req.onerror = () => resolve(null) // sin IDB (raro): cae al modo kv legado
|
|
48
59
|
}))()
|
|
60
|
+
// DENTRO DE LA APP (Android e iOS), las llaves nuevas nacen en el chip del teléfono: una sola
|
|
61
|
+
// llave por cuenta, la misma que aprueba en la pantalla nativa (`./externalKeys.js`). El
|
|
62
|
+
// puente solo existe en este origen; en un navegador no está y nada cambia.
|
|
63
|
+
const keyStore = bridge && baseKeyStore ? withExternalKeys(baseKeyStore, bridge) : baseKeyStore
|
|
49
64
|
|
|
50
65
|
// sessionKv: la prueba de desbloqueo del candado por contraseña vive en
|
|
51
66
|
// sessionStorage — POR PESTAÑA: sobrevive al refresco, muere al cerrarla.
|