@dotrino/identity 0.103.0 → 0.104.1
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/nativeStore.js +161 -0
- package/vault/peerStore.js +21 -0
- package/vault/vault.js +21 -5
package/package.json
CHANGED
|
@@ -0,0 +1,161 @@
|
|
|
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
|
+
}
|
|
81
|
+
|
|
82
|
+
const PROFILES = 'dotrino.identity.profiles'
|
|
83
|
+
const CURRENT = 'dotrino.identity.current'
|
|
84
|
+
const scopedKey = (pid, k) => `dotrino.identity.p.${pid}.${k}`
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* LOS PERFILES QUE SE QUEDARON EN LA PÁGINA pasan a la app (dueño, 2026-09-26: «todas las
|
|
88
|
+
* pestañas usan el mismo perfil, pero NO es el de Dotrino que está en Pedidos»).
|
|
89
|
+
*
|
|
90
|
+
* Antes de `nativeStore` cada página guardaba la identidad aparte, así que la cuenta que se
|
|
91
|
+
* emparejó en `vault.dotrino.com/d` quedó en el almacén de ESA página: su llave está en el
|
|
92
|
+
* chip (Pedidos la usa), pero su acta, su papel y su nombre no llegaron a la app, y la app
|
|
93
|
+
* estrenó un perfil vacío. Esto la trae.
|
|
94
|
+
*
|
|
95
|
+
* Solo se trae un perfil cuya llave VIVE en el chip (`open` contesta): uno de software no
|
|
96
|
+
* cabe, y uno cuya llave ya se borró no sirve para nada. Lo que la app ya tiene no se pisa.
|
|
97
|
+
* Y si el perfil activo de la app no está emparejado y el traído sí, pasa a ser el activo:
|
|
98
|
+
* el vacío se inventó justo porque faltaba este.
|
|
99
|
+
*
|
|
100
|
+
* `page`: lo que hay en el almacén de ESTA página — `kv` (entradas del localStorage),
|
|
101
|
+
* `keys` (registros del IndexedDB de llaves) y `peers` (libros de contactos).
|
|
102
|
+
*/
|
|
103
|
+
export async function adoptPageProfiles (native, bridge, page) {
|
|
104
|
+
const mine = JSON.parse(native.kv.getItem(PROFILES) || '[]')
|
|
105
|
+
const theirs = JSON.parse(page.kv.get(PROFILES) || '[]')
|
|
106
|
+
const adopted = []
|
|
107
|
+
for (const p of theirs) {
|
|
108
|
+
if (!p?.id || mine.some((m) => m.id === p.id)) continue
|
|
109
|
+
const sign = page.keys.get(scopedKey(p.id, 'keypair'))
|
|
110
|
+
const enc = page.keys.get(scopedKey(p.id, 'enc-keypair'))
|
|
111
|
+
if (!sign?.external) continue
|
|
112
|
+
try { await bridge.call('open', { kid: sign.external }) } catch (_) { continue }
|
|
113
|
+
const prefix = `dotrino.identity.p.${p.id}.`
|
|
114
|
+
for (const [k, v] of page.kv) if (k.startsWith(prefix)) native.kv.setItem(k, v)
|
|
115
|
+
await native.keyStore.set(scopedKey(p.id, 'keypair'), sign)
|
|
116
|
+
if (enc?.external) await native.keyStore.set(scopedKey(p.id, 'enc-keypair'), enc)
|
|
117
|
+
const book = page.peers.get(`peers.${p.id}.v1`)
|
|
118
|
+
if (book) await native.peers.put(`peers.${p.id}.v1`, book)
|
|
119
|
+
mine.push(p)
|
|
120
|
+
adopted.push(p.id)
|
|
121
|
+
}
|
|
122
|
+
if (!adopted.length) return adopted
|
|
123
|
+
native.kv.setItem(PROFILES, JSON.stringify(mine))
|
|
124
|
+
const cur = native.kv.getItem(CURRENT)
|
|
125
|
+
const paired = (pid) => !!native.kv.getItem(scopedKey(pid, 'vault.cert'))
|
|
126
|
+
if (!cur || !paired(cur)) {
|
|
127
|
+
const next = adopted.find(paired)
|
|
128
|
+
if (next) native.kv.setItem(CURRENT, next)
|
|
129
|
+
}
|
|
130
|
+
console.info(`[cc-identity] adopted ${adopted.length} profile(s) from this page's storage into the app: ${adopted.join(', ')}`)
|
|
131
|
+
return adopted
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Todo lo que la identidad guardó en el almacén de ESTA página (localStorage + sus dos IndexedDB). */
|
|
135
|
+
export async function readPageStorage () {
|
|
136
|
+
const kv = new Map()
|
|
137
|
+
for (let i = 0; i < localStorage.length; i++) {
|
|
138
|
+
const k = localStorage.key(i)
|
|
139
|
+
if (k && k.startsWith('dotrino.identity.')) kv.set(k, localStorage.getItem(k))
|
|
140
|
+
}
|
|
141
|
+
return { kv, keys: await idbAll('dotrino-identity-keys', 'keys'), peers: await idbAll('cc-identity', 'kv') }
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function idbAll (dbName, store) {
|
|
145
|
+
return new Promise((resolve, reject) => {
|
|
146
|
+
const req = indexedDB.open(dbName)
|
|
147
|
+
req.onupgradeneeded = () => { if (!req.result.objectStoreNames.contains(store)) req.result.createObjectStore(store) }
|
|
148
|
+
req.onerror = () => reject(req.error)
|
|
149
|
+
req.onsuccess = () => {
|
|
150
|
+
const db = req.result
|
|
151
|
+
if (!db.objectStoreNames.contains(store)) { db.close(); resolve(new Map()); return }
|
|
152
|
+
const out = new Map()
|
|
153
|
+
const cur = db.transaction(store, 'readonly').objectStore(store).openCursor()
|
|
154
|
+
cur.onsuccess = () => {
|
|
155
|
+
const c = cur.result
|
|
156
|
+
if (c) { out.set(String(c.key), c.value); c.continue() } else { db.close(); resolve(out) }
|
|
157
|
+
}
|
|
158
|
+
cur.onerror = () => reject(cur.error)
|
|
159
|
+
}
|
|
160
|
+
})
|
|
161
|
+
}
|
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
|
@@ -15,10 +15,27 @@ import {
|
|
|
15
15
|
import { createIdentityCore } from './core.js'
|
|
16
16
|
import { pubkeyId } from './capabilities.js'
|
|
17
17
|
import { withExternalKeys, appBridge } from './externalKeys.js'
|
|
18
|
+
import { nativeBackends, adoptPageProfiles, readPageStorage } from './nativeStore.js'
|
|
19
|
+
import { useBackend as usePeerBackend } from './peerStore.js'
|
|
18
20
|
|
|
19
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) {
|
|
29
|
+
usePeerBackend(native.peers)
|
|
30
|
+
// Lo que se emparejó cuando la identidad aún vivía en la página, a la app. Si no se puede
|
|
31
|
+
// leer el almacén de la página se dice (con su error) y se sigue: la identidad de la app
|
|
32
|
+
// funciona igual, solo que sin ese perfil.
|
|
33
|
+
try { await adoptPageProfiles(native, bridge, await readPageStorage()) }
|
|
34
|
+
catch (e) { console.error('[cc-identity] could not bring this page\'s profiles into the app:', e?.message || e) }
|
|
35
|
+
}
|
|
36
|
+
|
|
20
37
|
// kv estilo localStorage (síncrono) para me, nonces, delegaciones, certs.
|
|
21
|
-
const kv = {
|
|
38
|
+
const kv = native ? native.kv : {
|
|
22
39
|
getItem: (k) => localStorage.getItem(k),
|
|
23
40
|
setItem: (k, v) => localStorage.setItem(k, v),
|
|
24
41
|
removeItem: (k) => localStorage.removeItem(k)
|
|
@@ -28,7 +45,7 @@ import { withExternalKeys, appBridge } from './externalKeys.js'
|
|
|
28
45
|
// IndexedDB (clonado estructurado). Nadie —ni este código, ni un XSS en este
|
|
29
46
|
// origen— puede leer sus bytes; solo firmar/derivar con ellas. Las llaves
|
|
30
47
|
// planas (JWK) viejas de localStorage se migran y se borran (core.js).
|
|
31
|
-
const baseKeyStore = await (() => new Promise((resolve) => {
|
|
48
|
+
const baseKeyStore = native ? native.keyStore : await (() => new Promise((resolve) => {
|
|
32
49
|
const req = indexedDB.open('dotrino-identity-keys', 1)
|
|
33
50
|
req.onupgradeneeded = () => req.result.createObjectStore('keys')
|
|
34
51
|
req.onsuccess = () => {
|
|
@@ -47,11 +64,10 @@ import { withExternalKeys, appBridge } from './externalKeys.js'
|
|
|
47
64
|
}
|
|
48
65
|
req.onerror = () => resolve(null) // sin IDB (raro): cae al modo kv legado
|
|
49
66
|
}))()
|
|
50
|
-
// DENTRO DE LA APP
|
|
67
|
+
// DENTRO DE LA APP (Android e iOS), las llaves nuevas nacen en el chip del teléfono: una sola
|
|
51
68
|
// llave por cuenta, la misma que aprueba en la pantalla nativa (`./externalKeys.js`). El
|
|
52
69
|
// puente solo existe en este origen; en un navegador no está y nada cambia.
|
|
53
|
-
const
|
|
54
|
-
const keyStore = idKeys && baseKeyStore ? withExternalKeys(baseKeyStore, appBridge(idKeys)) : baseKeyStore
|
|
70
|
+
const keyStore = bridge && baseKeyStore ? withExternalKeys(baseKeyStore, bridge) : baseKeyStore
|
|
55
71
|
|
|
56
72
|
// sessionKv: la prueba de desbloqueo del candado por contraseña vive en
|
|
57
73
|
// sessionStorage — POR PESTAÑA: sobrevive al refresco, muere al cerrarla.
|