@dotrino/identity 0.10.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/LICENSE +21 -0
- package/README.md +166 -0
- package/package.json +47 -0
- package/src/index.d.ts +187 -0
- package/src/index.js +367 -0
- package/src/node.js +179 -0
- package/vault/CNAME +1 -0
- package/vault/capabilities.js +143 -0
- package/vault/core.js +641 -0
- package/vault/index.html +24 -0
- package/vault/peerStore.js +147 -0
- package/vault/sync.js +473 -0
- package/vault/vault.js +67 -0
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Almacén del peer book del vault (nicknames + ratings + endorsements firmados).
|
|
3
|
+
*
|
|
4
|
+
* Migrado de localStorage (~5 MB de techo, compartido) a IndexedDB (cuota grande,
|
|
5
|
+
* non-evictable con `persist()`), porque el peer book crece con los contactos y,
|
|
6
|
+
* sobre todo, con los endorsements del registro de reputación. Patrón idéntico
|
|
7
|
+
* al del store del ecosistema:
|
|
8
|
+
* - `_peers` en memoria es la fuente de verdad en runtime.
|
|
9
|
+
* - `loadPeers()` es SÍNCRONA (lee la cache) → los handlers del vault no cambian.
|
|
10
|
+
* - `initPeerStorage()` (async, en el bootstrap) la rellena desde IndexedDB,
|
|
11
|
+
* MIGRANDO una sola vez del localStorage viejo. Si IndexedDB no está (modo
|
|
12
|
+
* privado) cae a localStorage para no perder función.
|
|
13
|
+
* - `savePeers()` actualiza la cache y hace write-through async a IndexedDB.
|
|
14
|
+
*
|
|
15
|
+
* El módulo es independiente del resto del vault (testeable en aislamiento).
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
export const PEERS_STORAGE = 'dotrino.identity.peers' // clave del localStorage VIEJO (migración/fallback)
|
|
19
|
+
const IDB_NAME = 'cc-identity'
|
|
20
|
+
const IDB_STORE = 'kv'
|
|
21
|
+
const IDB_PEERS_KEY = 'peers.v1'
|
|
22
|
+
// Flag de reconciliación one-time. Distingue "IndexedDB vacío porque ya
|
|
23
|
+
// migramos y el usuario no tiene peers" de "vacío porque nunca migramos (o un
|
|
24
|
+
// bug previo escribió {} y enmascaró la migración)". Sin esto, un `{}` escrito
|
|
25
|
+
// por error quedaba como objeto truthy y la migración NO se reintentaba jamás,
|
|
26
|
+
// perdiendo contactos que SIGUEN intactos en el localStorage viejo.
|
|
27
|
+
const IDB_MIGRATED_KEY = 'peers.migrated.v1'
|
|
28
|
+
|
|
29
|
+
let _peers = {}
|
|
30
|
+
let _fallback = false
|
|
31
|
+
let _idb = null
|
|
32
|
+
let _writeChain = Promise.resolve()
|
|
33
|
+
let _markDirty = null
|
|
34
|
+
|
|
35
|
+
/** Registra el callback que marca el estado como "sucio" para el sync. */
|
|
36
|
+
export function onDirty (fn) { _markDirty = fn }
|
|
37
|
+
|
|
38
|
+
function openIdb () {
|
|
39
|
+
return new Promise((resolve, reject) => {
|
|
40
|
+
let req
|
|
41
|
+
try { req = indexedDB.open(IDB_NAME, 1) } catch (e) { reject(e); return }
|
|
42
|
+
req.onupgradeneeded = () => {
|
|
43
|
+
const db = req.result
|
|
44
|
+
if (!db.objectStoreNames.contains(IDB_STORE)) db.createObjectStore(IDB_STORE)
|
|
45
|
+
}
|
|
46
|
+
req.onsuccess = () => resolve(req.result)
|
|
47
|
+
req.onerror = () => reject(req.error)
|
|
48
|
+
})
|
|
49
|
+
}
|
|
50
|
+
function idbGet (db, key) {
|
|
51
|
+
return new Promise((resolve, reject) => {
|
|
52
|
+
const tx = db.transaction(IDB_STORE, 'readonly')
|
|
53
|
+
const r = tx.objectStore(IDB_STORE).get(key)
|
|
54
|
+
r.onsuccess = () => resolve(r.result)
|
|
55
|
+
r.onerror = () => reject(r.error)
|
|
56
|
+
})
|
|
57
|
+
}
|
|
58
|
+
function idbPut (db, key, val) {
|
|
59
|
+
return new Promise((resolve, reject) => {
|
|
60
|
+
const tx = db.transaction(IDB_STORE, 'readwrite')
|
|
61
|
+
tx.objectStore(IDB_STORE).put(val, key)
|
|
62
|
+
tx.oncomplete = () => resolve()
|
|
63
|
+
tx.onerror = () => reject(tx.error)
|
|
64
|
+
})
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function readLocalPeers () {
|
|
68
|
+
try { const raw = localStorage.getItem(PEERS_STORAGE); return raw ? (JSON.parse(raw) || {}) : {} }
|
|
69
|
+
catch (_) { return {} }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export async function initPeerStorage () {
|
|
73
|
+
try { if (typeof navigator !== 'undefined' && navigator.storage?.persist) await navigator.storage.persist() }
|
|
74
|
+
catch (_) { /* best-effort */ }
|
|
75
|
+
try {
|
|
76
|
+
_idb = await openIdb()
|
|
77
|
+
const stored = await idbGet(_idb, IDB_PEERS_KEY)
|
|
78
|
+
const storedPeers = (stored && typeof stored === 'object') ? stored : {}
|
|
79
|
+
const migratedFlag = await idbGet(_idb, IDB_MIGRATED_KEY)
|
|
80
|
+
if (migratedFlag) {
|
|
81
|
+
// Ya reconciliado: IndexedDB es la fuente de verdad (ignora el LS viejo).
|
|
82
|
+
_peers = storedPeers
|
|
83
|
+
} else {
|
|
84
|
+
// Primera corrida (o reintento tras el bug que escribía {}): unimos el
|
|
85
|
+
// peer book del localStorage viejo con lo que haya en IndexedDB —unión,
|
|
86
|
+
// IndexedDB gana en conflictos— para recuperar contactos que un bug previo
|
|
87
|
+
// pudo enmascarar. Nunca borra. Marcamos el flag para no rehacerlo (así no
|
|
88
|
+
// se "resucitan" contactos que el usuario borre más adelante).
|
|
89
|
+
const local = readLocalPeers()
|
|
90
|
+
_peers = { ...local, ...storedPeers }
|
|
91
|
+
const recovered = Object.keys(local).filter(k => !(k in storedPeers)).length
|
|
92
|
+
await idbPut(_idb, IDB_PEERS_KEY, _peers)
|
|
93
|
+
await idbPut(_idb, IDB_MIGRATED_KEY, true)
|
|
94
|
+
if (recovered) console.log(`[cc-identity] ${recovered} peer(s) recuperados del localStorage viejo → IndexedDB`)
|
|
95
|
+
}
|
|
96
|
+
} catch (e) {
|
|
97
|
+
console.warn('[cc-identity] IndexedDB no disponible, uso localStorage:', e?.message)
|
|
98
|
+
_fallback = true
|
|
99
|
+
_idb = null
|
|
100
|
+
_peers = readLocalPeers()
|
|
101
|
+
}
|
|
102
|
+
return _peers
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function persistPeers () {
|
|
106
|
+
if (_fallback || !_idb) {
|
|
107
|
+
try { localStorage.setItem(PEERS_STORAGE, JSON.stringify(_peers)) }
|
|
108
|
+
catch (e) { console.warn('[cc-identity] persist (ls) falló:', e?.message) }
|
|
109
|
+
return _writeChain
|
|
110
|
+
}
|
|
111
|
+
const snapshot = _peers
|
|
112
|
+
_writeChain = _writeChain
|
|
113
|
+
.then(() => idbPut(_idb, IDB_PEERS_KEY, snapshot))
|
|
114
|
+
.catch(e => console.warn('[cc-identity] persist (idb) falló:', e?.message))
|
|
115
|
+
return _writeChain
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Promesa que resuelve cuando se completaron los write-through pendientes (tests). */
|
|
119
|
+
export function flushPeers () { return _writeChain }
|
|
120
|
+
|
|
121
|
+
export function loadPeers () { return _peers }
|
|
122
|
+
|
|
123
|
+
export function savePeers (peers) {
|
|
124
|
+
_peers = peers
|
|
125
|
+
persistPeers()
|
|
126
|
+
if (_markDirty) _markDirty()
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Escritura directa (merge del sync): persiste sin marcar dirty. */
|
|
130
|
+
export function setPeersDirect (peers) {
|
|
131
|
+
_peers = peers
|
|
132
|
+
persistPeers()
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function upsertPeer (publickey, patch) {
|
|
136
|
+
const peers = loadPeers()
|
|
137
|
+
const existing = peers[publickey] || { publickey, firstSeen: Date.now() }
|
|
138
|
+
peers[publickey] = { ...existing, ...patch, publickey, lastSeen: Date.now() }
|
|
139
|
+
savePeers(peers)
|
|
140
|
+
return peers[publickey]
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Sólo para tests: resetea el estado del módulo (y cierra la conexión IDB).
|
|
144
|
+
export function _resetForTest () {
|
|
145
|
+
try { if (_idb && _idb.close) _idb.close() } catch (_) {}
|
|
146
|
+
_peers = {}; _fallback = false; _idb = null; _writeChain = Promise.resolve(); _markDirty = null
|
|
147
|
+
}
|
package/vault/sync.js
ADDED
|
@@ -0,0 +1,473 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dotrino — Drive sync module.
|
|
3
|
+
*
|
|
4
|
+
* Loaded by vault.js inside the identity-vault iframe. Implements:
|
|
5
|
+
* - Google OAuth (Implicit / GIS token client) for the `drive.appdata` scope
|
|
6
|
+
* - PBKDF2-derived AES-256-GCM encryption of the vault export blob
|
|
7
|
+
* - Drive REST v3 read/write of a single file in `appDataFolder`
|
|
8
|
+
* - Auto-sync scheduler: pull-on-unlock, debounced push, periodic pull,
|
|
9
|
+
* optimistic-locked push (If-Match etag) with retry-and-merge on 412
|
|
10
|
+
*
|
|
11
|
+
* The same module ships in the message-store repo with the only differences
|
|
12
|
+
* being FILE_NAME and the merge function passed in by the caller.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const DRIVE_FILES_API = 'https://www.googleapis.com/drive/v3/files'
|
|
16
|
+
const DRIVE_UPLOAD_API = 'https://www.googleapis.com/upload/drive/v3/files'
|
|
17
|
+
const GIS_SCRIPT = 'https://accounts.google.com/gsi/client'
|
|
18
|
+
const SCOPE = 'https://www.googleapis.com/auth/drive.appdata'
|
|
19
|
+
|
|
20
|
+
const PUSH_DEBOUNCE_MS = 5_000
|
|
21
|
+
const PUSH_HARD_INTERVAL_MS = 60_000
|
|
22
|
+
const PULL_INTERVAL_MS = 2 * 60_000
|
|
23
|
+
const PBKDF2_ITER = 600_000
|
|
24
|
+
|
|
25
|
+
// Local state keys (stored in localStorage of the vault origin)
|
|
26
|
+
const LS_DEVICE_ID = 'cc.sync.deviceId'
|
|
27
|
+
const LS_OAUTH_CLIENT = 'cc.sync.oauthClientId'
|
|
28
|
+
const LS_LAST_ETAG = 'cc.sync.lastEtag'
|
|
29
|
+
const LS_LAST_VERSIONS = 'cc.sync.deviceVersions'
|
|
30
|
+
// Passphrase-derived key only lives in sessionStorage (per-tab, cleared on close)
|
|
31
|
+
const SS_PASSPHRASE_KEY = 'cc.sync.kHexEphemeral'
|
|
32
|
+
|
|
33
|
+
// ---------- low-level helpers ----------
|
|
34
|
+
|
|
35
|
+
function bufToBase64 (buf) {
|
|
36
|
+
const bytes = new Uint8Array(buf)
|
|
37
|
+
let s = ''
|
|
38
|
+
for (let i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i])
|
|
39
|
+
return btoa(s)
|
|
40
|
+
}
|
|
41
|
+
function base64ToBuf (b64) {
|
|
42
|
+
const s = atob(b64)
|
|
43
|
+
const bytes = new Uint8Array(s.length)
|
|
44
|
+
for (let i = 0; i < s.length; i++) bytes[i] = s.charCodeAt(i)
|
|
45
|
+
return bytes.buffer
|
|
46
|
+
}
|
|
47
|
+
function getDeviceId () {
|
|
48
|
+
let id = localStorage.getItem(LS_DEVICE_ID)
|
|
49
|
+
if (!id) {
|
|
50
|
+
id = crypto.randomUUID()
|
|
51
|
+
localStorage.setItem(LS_DEVICE_ID, id)
|
|
52
|
+
}
|
|
53
|
+
return id
|
|
54
|
+
}
|
|
55
|
+
function getDeviceVersions () {
|
|
56
|
+
try { return JSON.parse(localStorage.getItem(LS_LAST_VERSIONS) || '{}') } catch { return {} }
|
|
57
|
+
}
|
|
58
|
+
function setDeviceVersions (v) {
|
|
59
|
+
localStorage.setItem(LS_LAST_VERSIONS, JSON.stringify(v))
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ---------- crypto: passphrase → key, AES-GCM ----------
|
|
63
|
+
|
|
64
|
+
async function deriveKey (passphrase, salt) {
|
|
65
|
+
const baseKey = await crypto.subtle.importKey(
|
|
66
|
+
'raw',
|
|
67
|
+
new TextEncoder().encode(passphrase),
|
|
68
|
+
{ name: 'PBKDF2' },
|
|
69
|
+
false,
|
|
70
|
+
['deriveKey']
|
|
71
|
+
)
|
|
72
|
+
return crypto.subtle.deriveKey(
|
|
73
|
+
{ name: 'PBKDF2', salt, iterations: PBKDF2_ITER, hash: 'SHA-256' },
|
|
74
|
+
baseKey,
|
|
75
|
+
{ name: 'AES-GCM', length: 256 },
|
|
76
|
+
true,
|
|
77
|
+
['encrypt', 'decrypt']
|
|
78
|
+
)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function encryptBlob (plaintextObj, passphrase) {
|
|
82
|
+
const salt = crypto.getRandomValues(new Uint8Array(16))
|
|
83
|
+
const iv = crypto.getRandomValues(new Uint8Array(12))
|
|
84
|
+
const key = await deriveKey(passphrase, salt)
|
|
85
|
+
const ct = await crypto.subtle.encrypt(
|
|
86
|
+
{ name: 'AES-GCM', iv },
|
|
87
|
+
key,
|
|
88
|
+
new TextEncoder().encode(JSON.stringify(plaintextObj))
|
|
89
|
+
)
|
|
90
|
+
return {
|
|
91
|
+
v: 1,
|
|
92
|
+
kdf: { alg: 'PBKDF2-SHA256', iter: PBKDF2_ITER, salt: bufToBase64(salt) },
|
|
93
|
+
enc: { alg: 'AES-256-GCM', iv: bufToBase64(iv), ct: bufToBase64(new Uint8Array(ct)) },
|
|
94
|
+
createdAt: Date.now()
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function decryptBlob (envelope, passphrase) {
|
|
99
|
+
if (!envelope || envelope.v !== 1) throw new Error('Unsupported backup version')
|
|
100
|
+
const salt = new Uint8Array(base64ToBuf(envelope.kdf.salt))
|
|
101
|
+
const iv = new Uint8Array(base64ToBuf(envelope.enc.iv))
|
|
102
|
+
const key = await deriveKey(passphrase, salt)
|
|
103
|
+
let ptBytes
|
|
104
|
+
try {
|
|
105
|
+
ptBytes = await crypto.subtle.decrypt(
|
|
106
|
+
{ name: 'AES-GCM', iv },
|
|
107
|
+
key,
|
|
108
|
+
base64ToBuf(envelope.enc.ct)
|
|
109
|
+
)
|
|
110
|
+
} catch {
|
|
111
|
+
throw new Error('Wrong passphrase or corrupted backup')
|
|
112
|
+
}
|
|
113
|
+
return JSON.parse(new TextDecoder().decode(ptBytes))
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ---------- Google OAuth (GIS Token Client) ----------
|
|
117
|
+
|
|
118
|
+
let _gisLoaded = null
|
|
119
|
+
function loadGis () {
|
|
120
|
+
if (_gisLoaded) return _gisLoaded
|
|
121
|
+
_gisLoaded = new Promise((resolve, reject) => {
|
|
122
|
+
if (window.google?.accounts?.oauth2) return resolve()
|
|
123
|
+
const s = document.createElement('script')
|
|
124
|
+
s.src = GIS_SCRIPT
|
|
125
|
+
s.async = true
|
|
126
|
+
s.defer = true
|
|
127
|
+
s.onload = () => resolve()
|
|
128
|
+
s.onerror = () => reject(new Error('Failed to load Google Identity Services'))
|
|
129
|
+
document.head.appendChild(s)
|
|
130
|
+
})
|
|
131
|
+
return _gisLoaded
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
let _accessToken = null
|
|
135
|
+
let _accessTokenExpiresAt = 0
|
|
136
|
+
let _tokenClient = null
|
|
137
|
+
|
|
138
|
+
async function ensureTokenClient (clientId) {
|
|
139
|
+
await loadGis()
|
|
140
|
+
if (_tokenClient && _tokenClient._clientId === clientId) return _tokenClient
|
|
141
|
+
_tokenClient = window.google.accounts.oauth2.initTokenClient({
|
|
142
|
+
client_id: clientId,
|
|
143
|
+
scope: SCOPE,
|
|
144
|
+
prompt: '',
|
|
145
|
+
callback: () => {} // overridden per-request
|
|
146
|
+
})
|
|
147
|
+
_tokenClient._clientId = clientId
|
|
148
|
+
return _tokenClient
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async function requestAccessToken (clientId, { interactive }) {
|
|
152
|
+
const client = await ensureTokenClient(clientId)
|
|
153
|
+
return new Promise((resolve, reject) => {
|
|
154
|
+
client.callback = (resp) => {
|
|
155
|
+
if (resp?.error) return reject(new Error(resp.error))
|
|
156
|
+
if (!resp?.access_token) return reject(new Error('No access_token in response'))
|
|
157
|
+
_accessToken = resp.access_token
|
|
158
|
+
_accessTokenExpiresAt = Date.now() + ((Number(resp.expires_in) || 3600) - 60) * 1000
|
|
159
|
+
resolve({ accessToken: _accessToken, expiresAt: _accessTokenExpiresAt })
|
|
160
|
+
}
|
|
161
|
+
try {
|
|
162
|
+
client.requestAccessToken({ prompt: interactive ? 'consent' : '' })
|
|
163
|
+
} catch (e) { reject(e) }
|
|
164
|
+
})
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async function getValidAccessToken () {
|
|
168
|
+
if (_accessToken && Date.now() < _accessTokenExpiresAt) return _accessToken
|
|
169
|
+
const clientId = localStorage.getItem(LS_OAUTH_CLIENT)
|
|
170
|
+
if (!clientId) return null
|
|
171
|
+
try {
|
|
172
|
+
const r = await requestAccessToken(clientId, { interactive: false })
|
|
173
|
+
return r.accessToken
|
|
174
|
+
} catch { return null }
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ---------- Drive REST helpers ----------
|
|
178
|
+
|
|
179
|
+
async function driveFindFile (token, fileName) {
|
|
180
|
+
const q = encodeURIComponent(`name='${fileName}' and 'appDataFolder' in parents and trashed=false`)
|
|
181
|
+
const url = `${DRIVE_FILES_API}?spaces=appDataFolder&q=${q}&fields=files(id,name,headRevisionId,modifiedTime)`
|
|
182
|
+
const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } })
|
|
183
|
+
if (!res.ok) throw new Error(`Drive list failed: ${res.status}`)
|
|
184
|
+
const data = await res.json()
|
|
185
|
+
return (data.files && data.files[0]) || null
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async function driveDownload (token, fileId) {
|
|
189
|
+
const url = `${DRIVE_FILES_API}/${fileId}?alt=media`
|
|
190
|
+
const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } })
|
|
191
|
+
if (!res.ok) throw new Error(`Drive download failed: ${res.status}`)
|
|
192
|
+
const etag = res.headers.get('ETag') || null
|
|
193
|
+
const body = await res.json()
|
|
194
|
+
return { body, etag }
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
async function driveCreate (token, fileName, contentObj) {
|
|
198
|
+
const metadata = { name: fileName, parents: ['appDataFolder'] }
|
|
199
|
+
const boundary = '-------ccsync' + Math.random().toString(36).slice(2)
|
|
200
|
+
const body =
|
|
201
|
+
`--${boundary}\r\n` +
|
|
202
|
+
'Content-Type: application/json; charset=UTF-8\r\n\r\n' +
|
|
203
|
+
JSON.stringify(metadata) + '\r\n' +
|
|
204
|
+
`--${boundary}\r\n` +
|
|
205
|
+
'Content-Type: application/json; charset=UTF-8\r\n\r\n' +
|
|
206
|
+
JSON.stringify(contentObj) + '\r\n' +
|
|
207
|
+
`--${boundary}--`
|
|
208
|
+
const res = await fetch(`${DRIVE_UPLOAD_API}?uploadType=multipart&fields=id`, {
|
|
209
|
+
method: 'POST',
|
|
210
|
+
headers: {
|
|
211
|
+
Authorization: `Bearer ${token}`,
|
|
212
|
+
'Content-Type': `multipart/related; boundary=${boundary}`
|
|
213
|
+
},
|
|
214
|
+
body
|
|
215
|
+
})
|
|
216
|
+
if (!res.ok) throw new Error(`Drive create failed: ${res.status}`)
|
|
217
|
+
const data = await res.json()
|
|
218
|
+
return data.id
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
async function driveUpdate (token, fileId, contentObj, ifMatchEtag) {
|
|
222
|
+
const headers = {
|
|
223
|
+
Authorization: `Bearer ${token}`,
|
|
224
|
+
'Content-Type': 'application/json; charset=UTF-8'
|
|
225
|
+
}
|
|
226
|
+
if (ifMatchEtag) headers['If-Match'] = ifMatchEtag
|
|
227
|
+
const url = `${DRIVE_UPLOAD_API}/${fileId}?uploadType=media`
|
|
228
|
+
const res = await fetch(url, { method: 'PATCH', headers, body: JSON.stringify(contentObj) })
|
|
229
|
+
if (res.status === 412) {
|
|
230
|
+
const err = new Error('etag mismatch')
|
|
231
|
+
err.code = 'PRECONDITION_FAILED'
|
|
232
|
+
throw err
|
|
233
|
+
}
|
|
234
|
+
if (!res.ok) throw new Error(`Drive update failed: ${res.status}`)
|
|
235
|
+
const newEtag = res.headers.get('ETag') || null
|
|
236
|
+
return { etag: newEtag }
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// ---------- public createSync(): returns a controller wired to caller’s state ----------
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Creates a sync controller for one logical state (identity OR store).
|
|
243
|
+
*
|
|
244
|
+
* @param {Object} cfg
|
|
245
|
+
* @param {string} cfg.fileName - e.g. 'dotrino-identity-backup.json'
|
|
246
|
+
* @param {() => Promise<Object>} cfg.exportLocal - returns plaintext-equivalent JSON
|
|
247
|
+
* @param {(merged: Object) => Promise<void>} cfg.applyMerged - apply merged state locally
|
|
248
|
+
* @param {(local: Object, remote: Object) => Promise<{merged: Object, changed: boolean}>} cfg.mergeFn
|
|
249
|
+
* @param {string} cfg.kind - 'identity' | 'store' (used in plaintext header)
|
|
250
|
+
*/
|
|
251
|
+
export function createSync (cfg) {
|
|
252
|
+
const deviceId = getDeviceId()
|
|
253
|
+
let _passphrase = null // kept in module memory after unlock; mirrored to sessionStorage for tab refresh
|
|
254
|
+
let _dirty = false
|
|
255
|
+
let _debounceTimer = null
|
|
256
|
+
let _hardTimer = null
|
|
257
|
+
let _periodicTimer = null
|
|
258
|
+
let _running = false
|
|
259
|
+
let _lastError = null
|
|
260
|
+
let _statusListeners = new Set()
|
|
261
|
+
|
|
262
|
+
// Restore passphrase from sessionStorage if present (per-tab unlock survives refresh)
|
|
263
|
+
try {
|
|
264
|
+
const saved = sessionStorage.getItem(SS_PASSPHRASE_KEY)
|
|
265
|
+
if (saved) _passphrase = saved
|
|
266
|
+
} catch {}
|
|
267
|
+
|
|
268
|
+
function emitStatus (status, extra = {}) {
|
|
269
|
+
const payload = { kind: cfg.kind, status, ...extra, ts: Date.now() }
|
|
270
|
+
for (const fn of _statusListeners) {
|
|
271
|
+
try { fn(payload) } catch (e) { console.error(e) }
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function isUnlocked () { return !!_passphrase }
|
|
276
|
+
|
|
277
|
+
async function unlock (passphrase) {
|
|
278
|
+
if (!passphrase || passphrase.length < 8) throw new Error('Passphrase too short')
|
|
279
|
+
_passphrase = passphrase
|
|
280
|
+
try { sessionStorage.setItem(SS_PASSPHRASE_KEY, passphrase) } catch {}
|
|
281
|
+
emitStatus('unlocked')
|
|
282
|
+
// Trigger an immediate pull on unlock
|
|
283
|
+
try { await pull() } catch (e) { _lastError = e; emitStatus('error', { error: e.message }) }
|
|
284
|
+
startTimers()
|
|
285
|
+
return { ok: true }
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function lock () {
|
|
289
|
+
_passphrase = null
|
|
290
|
+
try { sessionStorage.removeItem(SS_PASSPHRASE_KEY) } catch {}
|
|
291
|
+
stopTimers()
|
|
292
|
+
emitStatus('locked')
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function buildPlaintext (exported) {
|
|
296
|
+
const versions = getDeviceVersions()
|
|
297
|
+
versions[deviceId] = (versions[deviceId] || 0) + 1
|
|
298
|
+
setDeviceVersions(versions)
|
|
299
|
+
return {
|
|
300
|
+
version: 1,
|
|
301
|
+
kind: cfg.kind,
|
|
302
|
+
exportedAt: Date.now(),
|
|
303
|
+
deviceVersions: versions,
|
|
304
|
+
payload: exported
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function dominates (localV, remoteV) {
|
|
309
|
+
if (!remoteV) return true
|
|
310
|
+
const all = new Set([...Object.keys(localV || {}), ...Object.keys(remoteV)])
|
|
311
|
+
let strictly = false
|
|
312
|
+
for (const k of all) {
|
|
313
|
+
const a = localV[k] || 0
|
|
314
|
+
const b = remoteV[k] || 0
|
|
315
|
+
if (a < b) return false
|
|
316
|
+
if (a > b) strictly = true
|
|
317
|
+
}
|
|
318
|
+
return strictly
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
async function pull () {
|
|
322
|
+
if (!isUnlocked()) return { skipped: 'locked' }
|
|
323
|
+
if (_running) return { skipped: 'busy' }
|
|
324
|
+
_running = true
|
|
325
|
+
emitStatus('syncing')
|
|
326
|
+
try {
|
|
327
|
+
const token = await getValidAccessToken()
|
|
328
|
+
if (!token) { emitStatus('offline'); return { skipped: 'no-token' } }
|
|
329
|
+
const file = await driveFindFile(token, cfg.fileName)
|
|
330
|
+
if (!file) { emitStatus('synced'); return { remote: null } }
|
|
331
|
+
const { body: envelope, etag } = await driveDownload(token, file.id)
|
|
332
|
+
localStorage.setItem(LS_LAST_ETAG + ':' + cfg.kind, etag || '')
|
|
333
|
+
const plaintext = await decryptBlob(envelope, _passphrase)
|
|
334
|
+
const local = await cfg.exportLocal()
|
|
335
|
+
const remoteVersions = plaintext.deviceVersions || {}
|
|
336
|
+
const localVersions = getDeviceVersions()
|
|
337
|
+
if (dominates(localVersions, remoteVersions)) {
|
|
338
|
+
// Local already covers remote — nothing to apply, but mark dirty to push
|
|
339
|
+
_dirty = true
|
|
340
|
+
emitStatus('synced')
|
|
341
|
+
return { applied: false, dominant: 'local' }
|
|
342
|
+
}
|
|
343
|
+
const { merged, changed } = await cfg.mergeFn(local, plaintext.payload)
|
|
344
|
+
await cfg.applyMerged(merged)
|
|
345
|
+
// Merge versions: max per device
|
|
346
|
+
const mergedVersions = { ...remoteVersions }
|
|
347
|
+
for (const k of Object.keys(localVersions)) {
|
|
348
|
+
mergedVersions[k] = Math.max(mergedVersions[k] || 0, localVersions[k] || 0)
|
|
349
|
+
}
|
|
350
|
+
setDeviceVersions(mergedVersions)
|
|
351
|
+
if (changed) _dirty = true
|
|
352
|
+
emitStatus('synced')
|
|
353
|
+
return { applied: true, changed }
|
|
354
|
+
} catch (e) {
|
|
355
|
+
_lastError = e
|
|
356
|
+
emitStatus('error', { error: e.message })
|
|
357
|
+
throw e
|
|
358
|
+
} finally {
|
|
359
|
+
_running = false
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
async function push (retries = 3) {
|
|
364
|
+
if (!isUnlocked()) return { skipped: 'locked' }
|
|
365
|
+
if (!_dirty) return { skipped: 'clean' }
|
|
366
|
+
if (_running) return { skipped: 'busy' }
|
|
367
|
+
_running = true
|
|
368
|
+
emitStatus('syncing')
|
|
369
|
+
try {
|
|
370
|
+
const token = await getValidAccessToken()
|
|
371
|
+
if (!token) { emitStatus('offline'); return { skipped: 'no-token' } }
|
|
372
|
+
const local = await cfg.exportLocal()
|
|
373
|
+
const plaintext = buildPlaintext(local)
|
|
374
|
+
const envelope = await encryptBlob(plaintext, _passphrase)
|
|
375
|
+
const existing = await driveFindFile(token, cfg.fileName)
|
|
376
|
+
if (!existing) {
|
|
377
|
+
await driveCreate(token, cfg.fileName, envelope)
|
|
378
|
+
_dirty = false
|
|
379
|
+
emitStatus('synced')
|
|
380
|
+
return { created: true }
|
|
381
|
+
}
|
|
382
|
+
const lastEtag = localStorage.getItem(LS_LAST_ETAG + ':' + cfg.kind) || null
|
|
383
|
+
try {
|
|
384
|
+
const { etag } = await driveUpdate(token, existing.id, envelope, lastEtag)
|
|
385
|
+
localStorage.setItem(LS_LAST_ETAG + ':' + cfg.kind, etag || '')
|
|
386
|
+
_dirty = false
|
|
387
|
+
emitStatus('synced')
|
|
388
|
+
return { updated: true }
|
|
389
|
+
} catch (e) {
|
|
390
|
+
if (e.code === 'PRECONDITION_FAILED' && retries > 0) {
|
|
391
|
+
emitStatus('conflict')
|
|
392
|
+
_running = false // pull() needs the slot
|
|
393
|
+
await pull()
|
|
394
|
+
_running = true
|
|
395
|
+
return push(retries - 1)
|
|
396
|
+
}
|
|
397
|
+
throw e
|
|
398
|
+
}
|
|
399
|
+
} catch (e) {
|
|
400
|
+
_lastError = e
|
|
401
|
+
emitStatus('error', { error: e.message })
|
|
402
|
+
throw e
|
|
403
|
+
} finally {
|
|
404
|
+
_running = false
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function markDirty () {
|
|
409
|
+
_dirty = true
|
|
410
|
+
if (!isUnlocked()) return
|
|
411
|
+
if (_debounceTimer) clearTimeout(_debounceTimer)
|
|
412
|
+
_debounceTimer = setTimeout(() => {
|
|
413
|
+
push().catch(() => {})
|
|
414
|
+
}, PUSH_DEBOUNCE_MS)
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function startTimers () {
|
|
418
|
+
if (_hardTimer) clearInterval(_hardTimer)
|
|
419
|
+
if (_periodicTimer) clearInterval(_periodicTimer)
|
|
420
|
+
_hardTimer = setInterval(() => { if (_dirty) push().catch(() => {}) }, PUSH_HARD_INTERVAL_MS)
|
|
421
|
+
_periodicTimer = setInterval(() => { pull().catch(() => {}) }, PULL_INTERVAL_MS)
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function stopTimers () {
|
|
425
|
+
if (_debounceTimer) { clearTimeout(_debounceTimer); _debounceTimer = null }
|
|
426
|
+
if (_hardTimer) { clearInterval(_hardTimer); _hardTimer = null }
|
|
427
|
+
if (_periodicTimer) { clearInterval(_periodicTimer); _periodicTimer = null }
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// Push pending writes when the tab becomes visible again (mobile / bg)
|
|
431
|
+
document.addEventListener('visibilitychange', () => {
|
|
432
|
+
if (document.visibilityState === 'visible' && _dirty && isUnlocked()) {
|
|
433
|
+
push().catch(() => {})
|
|
434
|
+
}
|
|
435
|
+
})
|
|
436
|
+
window.addEventListener('online', () => {
|
|
437
|
+
if (_dirty && isUnlocked()) push().catch(() => {})
|
|
438
|
+
})
|
|
439
|
+
|
|
440
|
+
return {
|
|
441
|
+
onStatus (fn) { _statusListeners.add(fn); return () => _statusListeners.delete(fn) },
|
|
442
|
+
isUnlocked,
|
|
443
|
+
isConnected: () => !!localStorage.getItem(LS_OAUTH_CLIENT),
|
|
444
|
+
async connectGoogle (clientId) {
|
|
445
|
+
if (!clientId) throw new Error('clientId required')
|
|
446
|
+
localStorage.setItem(LS_OAUTH_CLIENT, clientId)
|
|
447
|
+
const r = await requestAccessToken(clientId, { interactive: true })
|
|
448
|
+
emitStatus('connected')
|
|
449
|
+
return r
|
|
450
|
+
},
|
|
451
|
+
async disconnectGoogle () {
|
|
452
|
+
_accessToken = null
|
|
453
|
+
_accessTokenExpiresAt = 0
|
|
454
|
+
localStorage.removeItem(LS_OAUTH_CLIENT)
|
|
455
|
+
stopTimers()
|
|
456
|
+
emitStatus('disconnected')
|
|
457
|
+
},
|
|
458
|
+
unlock,
|
|
459
|
+
lock,
|
|
460
|
+
pull,
|
|
461
|
+
push,
|
|
462
|
+
markDirty,
|
|
463
|
+
getStatus () {
|
|
464
|
+
return {
|
|
465
|
+
kind: cfg.kind,
|
|
466
|
+
connected: !!localStorage.getItem(LS_OAUTH_CLIENT),
|
|
467
|
+
unlocked: isUnlocked(),
|
|
468
|
+
dirty: _dirty,
|
|
469
|
+
lastError: _lastError ? _lastError.message : null
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
}
|
package/vault/vault.js
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dotrino Identity Vault (cáscara de navegador).
|
|
3
|
+
*
|
|
4
|
+
* Cargado dentro de un iframe oculto por las apps. La criptografía y todos los
|
|
5
|
+
* handlers viven en `./core.js` (runtime-agnóstico); este archivo sólo provee
|
|
6
|
+
* los backends del navegador —`localStorage` (kv), el peer book en IndexedDB
|
|
7
|
+
* (`./peerStore.js`) y el sync a Google Drive (`./sync.js`)— y el transporte
|
|
8
|
+
* `postMessage` con los embebedores. La clave privada nunca sale de esta página.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { createSync } from './sync.js'
|
|
12
|
+
import {
|
|
13
|
+
initPeerStorage, loadPeers, savePeers, setPeersDirect, upsertPeer, onDirty
|
|
14
|
+
} from './peerStore.js'
|
|
15
|
+
import { createIdentityCore } from './core.js'
|
|
16
|
+
|
|
17
|
+
;(async () => {
|
|
18
|
+
// kv estilo localStorage (síncrono) para keypairs, me y nonces.
|
|
19
|
+
const kv = {
|
|
20
|
+
getItem: (k) => localStorage.getItem(k),
|
|
21
|
+
setItem: (k, v) => localStorage.setItem(k, v),
|
|
22
|
+
removeItem: (k) => localStorage.removeItem(k)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const core = await createIdentityCore({
|
|
26
|
+
kv,
|
|
27
|
+
peers: { initPeerStorage, loadPeers, savePeers, setPeersDirect, upsertPeer, onDirty },
|
|
28
|
+
makeSync: createSync
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
const { handlers } = core
|
|
32
|
+
|
|
33
|
+
// Broadcast de estado del sync a todos los embebedores.
|
|
34
|
+
const broadcastStatus = (payload) => {
|
|
35
|
+
for (const w of [window.parent, ...Array.from(document.querySelectorAll('iframe')).map(f => f.contentWindow)]) {
|
|
36
|
+
if (!w || w === window) continue
|
|
37
|
+
try { w.postMessage({ _cci: true, type: 'event', event: 'sync', payload }, '*') } catch {}
|
|
38
|
+
}
|
|
39
|
+
if (window.parent && window.parent !== window) {
|
|
40
|
+
try { window.parent.postMessage({ _cci: true, type: 'event', event: 'sync', payload }, '*') } catch {}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
core.onSyncStatus(broadcastStatus)
|
|
44
|
+
|
|
45
|
+
window.addEventListener('message', async (event) => {
|
|
46
|
+
const msg = event.data
|
|
47
|
+
if (!msg || msg._cci !== true || msg.type !== 'request') return
|
|
48
|
+
const { id, method, params } = msg
|
|
49
|
+
const reply = (payload) => event.source?.postMessage(
|
|
50
|
+
{ _cci: true, type: 'response', id, ...payload },
|
|
51
|
+
event.origin
|
|
52
|
+
)
|
|
53
|
+
const handler = handlers[method]
|
|
54
|
+
if (!handler) return reply({ error: `Unknown method: ${method}` })
|
|
55
|
+
try {
|
|
56
|
+
const result = await handler(params || {})
|
|
57
|
+
reply({ result })
|
|
58
|
+
} catch (e) {
|
|
59
|
+
reply({ error: e?.message || String(e) })
|
|
60
|
+
}
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
// Avisar a todo padre que el vault está listo.
|
|
64
|
+
if (window.parent && window.parent !== window) {
|
|
65
|
+
window.parent.postMessage({ _cci: true, type: 'ready', me: core.me }, '*')
|
|
66
|
+
}
|
|
67
|
+
})()
|