@glance-apps/sync 0.2.0 → 0.3.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 +2 -1
- package/src/crypto.js +362 -1
- package/src/index.js +12 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@glance-apps/sync",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
"url": "git+https://github.com/glance-apps/glance-sync.git"
|
|
21
21
|
},
|
|
22
22
|
"devDependencies": {
|
|
23
|
+
"fake-indexeddb": "^6.2.5",
|
|
23
24
|
"vitest": "^3.1.4"
|
|
24
25
|
}
|
|
25
26
|
}
|
package/src/crypto.js
CHANGED
|
@@ -1 +1,362 @@
|
|
|
1
|
-
//
|
|
1
|
+
// Client-side encryption for cloud sync and remote backup files.
|
|
2
|
+
// Uses Web Crypto API exclusively — no external dependencies.
|
|
3
|
+
// Architecture: passphrase → PBKDF2 → AES-256-GCM key → encrypts JSON blob.
|
|
4
|
+
//
|
|
5
|
+
// Parameterizations vs. dayGLANCE original:
|
|
6
|
+
// - KEY_DB_NAME is now passed as `cryptoDBName` (no hardcoded app name)
|
|
7
|
+
// - Android Keystore detection uses injected `nativeGetSyncKey` function
|
|
8
|
+
// instead of probing window globals by name
|
|
9
|
+
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
// Session state (in-memory only, never persisted)
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
let _sessionPassphrase = null; // string | null
|
|
14
|
+
let _sessionKey = null; // CryptoKey | null
|
|
15
|
+
let _sessionSalt = null; // Uint8Array | null — the salt used to derive _sessionKey
|
|
16
|
+
|
|
17
|
+
export function setSyncPassphrase(p) { _sessionPassphrase = p; }
|
|
18
|
+
export function getSyncPassphrase() { return _sessionPassphrase; }
|
|
19
|
+
export function hasEncryptionReady() { return _sessionKey !== null; }
|
|
20
|
+
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
// IndexedDB key store
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
const KEY_DB_VERSION = 1;
|
|
25
|
+
const KEY_STORE = 'keys';
|
|
26
|
+
const SYNC_KEY_ID = 'sync-key';
|
|
27
|
+
|
|
28
|
+
function openKeyDB(cryptoDBName) {
|
|
29
|
+
return new Promise((resolve, reject) => {
|
|
30
|
+
const req = indexedDB.open(cryptoDBName, KEY_DB_VERSION);
|
|
31
|
+
req.onupgradeneeded = (e) => {
|
|
32
|
+
const db = e.target.result;
|
|
33
|
+
if (!db.objectStoreNames.contains(KEY_STORE)) {
|
|
34
|
+
db.createObjectStore(KEY_STORE, { keyPath: 'id' });
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
req.onsuccess = () => resolve(req.result);
|
|
38
|
+
req.onerror = () => reject(req.error);
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function saveKeyToIndexedDB(cryptoDBName, cryptoKey, salt) {
|
|
43
|
+
const db = await openKeyDB(cryptoDBName);
|
|
44
|
+
const rawKey = await crypto.subtle.exportKey('raw', cryptoKey);
|
|
45
|
+
return new Promise((resolve, reject) => {
|
|
46
|
+
const tx = db.transaction(KEY_STORE, 'readwrite');
|
|
47
|
+
tx.objectStore(KEY_STORE).put({ id: SYNC_KEY_ID, rawKey, salt: Array.from(salt) });
|
|
48
|
+
tx.oncomplete = () => resolve();
|
|
49
|
+
tx.onerror = () => reject(tx.error);
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function loadKeyFromIndexedDB(cryptoDBName) {
|
|
54
|
+
try {
|
|
55
|
+
const db = await openKeyDB(cryptoDBName);
|
|
56
|
+
const record = await new Promise((resolve, reject) => {
|
|
57
|
+
const tx = db.transaction(KEY_STORE, 'readonly');
|
|
58
|
+
const req = tx.objectStore(KEY_STORE).get(SYNC_KEY_ID);
|
|
59
|
+
req.onsuccess = () => resolve(req.result);
|
|
60
|
+
req.onerror = () => reject(req.error);
|
|
61
|
+
});
|
|
62
|
+
if (!record) return null;
|
|
63
|
+
const key = await crypto.subtle.importKey(
|
|
64
|
+
'raw', record.rawKey, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']
|
|
65
|
+
);
|
|
66
|
+
return { key, salt: new Uint8Array(record.salt) };
|
|
67
|
+
} catch {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function clearKeyFromIndexedDB(cryptoDBName) {
|
|
73
|
+
try {
|
|
74
|
+
const db = await openKeyDB(cryptoDBName);
|
|
75
|
+
await new Promise((resolve, reject) => {
|
|
76
|
+
const tx = db.transaction(KEY_STORE, 'readwrite');
|
|
77
|
+
tx.objectStore(KEY_STORE).delete(SYNC_KEY_ID);
|
|
78
|
+
tx.oncomplete = () => resolve();
|
|
79
|
+
tx.onerror = () => reject(tx.error);
|
|
80
|
+
});
|
|
81
|
+
} catch { /* ignore */ }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ---------------------------------------------------------------------------
|
|
85
|
+
// Android Keystore bridge
|
|
86
|
+
// ---------------------------------------------------------------------------
|
|
87
|
+
// The native bridge is injected as a function reference rather than probing
|
|
88
|
+
// window globals by name — no app-specific global detection here.
|
|
89
|
+
|
|
90
|
+
async function loadKeyFromNative(nativeGetSyncKey) {
|
|
91
|
+
try {
|
|
92
|
+
const b64 = await nativeGetSyncKey();
|
|
93
|
+
if (!b64) return null;
|
|
94
|
+
const record = JSON.parse(atob(b64));
|
|
95
|
+
const key = await crypto.subtle.importKey(
|
|
96
|
+
'raw', new Uint8Array(record.rawKey), { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']
|
|
97
|
+
);
|
|
98
|
+
return { key, salt: new Uint8Array(record.salt) };
|
|
99
|
+
} catch {
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async function saveKeyToNative(nativeStoreSyncKey, cryptoKey, salt) {
|
|
105
|
+
if (!nativeStoreSyncKey) return;
|
|
106
|
+
const rawKey = await crypto.subtle.exportKey('raw', cryptoKey);
|
|
107
|
+
const record = { rawKey: Array.from(new Uint8Array(rawKey)), salt: Array.from(salt) };
|
|
108
|
+
nativeStoreSyncKey(btoa(JSON.stringify(record)));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function clearKeyFromNative(nativeStoreSyncKey) {
|
|
112
|
+
try {
|
|
113
|
+
if (nativeStoreSyncKey) {
|
|
114
|
+
nativeStoreSyncKey(null);
|
|
115
|
+
}
|
|
116
|
+
} catch { /* ignore */ }
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ---------------------------------------------------------------------------
|
|
120
|
+
// PBKDF2 key derivation
|
|
121
|
+
// ---------------------------------------------------------------------------
|
|
122
|
+
async function deriveKey(passphrase, salt) {
|
|
123
|
+
const enc = new TextEncoder();
|
|
124
|
+
const keyMaterial = await crypto.subtle.importKey(
|
|
125
|
+
'raw', enc.encode(passphrase), 'PBKDF2', false, ['deriveKey']
|
|
126
|
+
);
|
|
127
|
+
return crypto.subtle.deriveKey(
|
|
128
|
+
{ name: 'PBKDF2', salt, iterations: 310_000, hash: 'SHA-256' },
|
|
129
|
+
keyMaterial,
|
|
130
|
+
{ name: 'AES-GCM', length: 256 },
|
|
131
|
+
true,
|
|
132
|
+
['encrypt', 'decrypt']
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ---------------------------------------------------------------------------
|
|
137
|
+
// Public lifecycle API
|
|
138
|
+
// ---------------------------------------------------------------------------
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Called on app load. Attempts to restore the session key from device storage
|
|
142
|
+
* (IndexedDB on browser, native bridge on mobile) without requiring the user
|
|
143
|
+
* to re-enter their passphrase.
|
|
144
|
+
*
|
|
145
|
+
* @param {object} config
|
|
146
|
+
* @param {string} config.cryptoDBName - IndexedDB database name for key storage
|
|
147
|
+
* @param {Function|null} [config.nativeGetSyncKey] - native bridge getter (Android Keystore)
|
|
148
|
+
* @returns {Promise<boolean>} true if key was restored, false if passphrase required
|
|
149
|
+
*/
|
|
150
|
+
export async function initSessionKey({ cryptoDBName, nativeGetSyncKey = null } = {}) {
|
|
151
|
+
try {
|
|
152
|
+
let result;
|
|
153
|
+
if (nativeGetSyncKey != null) {
|
|
154
|
+
result = await loadKeyFromNative(nativeGetSyncKey);
|
|
155
|
+
} else {
|
|
156
|
+
result = await loadKeyFromIndexedDB(cryptoDBName);
|
|
157
|
+
}
|
|
158
|
+
if (result) {
|
|
159
|
+
_sessionKey = result.key;
|
|
160
|
+
_sessionSalt = result.salt;
|
|
161
|
+
return true;
|
|
162
|
+
}
|
|
163
|
+
} catch { /* storage unavailable */ }
|
|
164
|
+
return false;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* First-time setup on a fresh device: derives a key from the passphrase with a
|
|
169
|
+
* freshly generated salt and caches it in device storage.
|
|
170
|
+
* Call this when the user configures encryption for the first time.
|
|
171
|
+
*
|
|
172
|
+
* @param {string} passphrase
|
|
173
|
+
* @param {object} config
|
|
174
|
+
* @param {string} config.cryptoDBName
|
|
175
|
+
* @param {Function|null} [config.nativeGetSyncKey]
|
|
176
|
+
* @param {Function|null} [config.nativeStoreSyncKey]
|
|
177
|
+
*/
|
|
178
|
+
export async function setupEncryptionKey(passphrase, { cryptoDBName, nativeGetSyncKey = null, nativeStoreSyncKey = null } = {}) {
|
|
179
|
+
const salt = crypto.getRandomValues(new Uint8Array(16));
|
|
180
|
+
const key = await deriveKey(passphrase, salt);
|
|
181
|
+
_sessionPassphrase = passphrase;
|
|
182
|
+
_sessionSalt = salt;
|
|
183
|
+
if (nativeGetSyncKey != null) {
|
|
184
|
+
await saveKeyToNative(nativeStoreSyncKey, key, salt);
|
|
185
|
+
} else {
|
|
186
|
+
await saveKeyToIndexedDB(cryptoDBName, key, salt);
|
|
187
|
+
}
|
|
188
|
+
// Re-import as non-extractable so the live in-memory key cannot be exported via Web Crypto API.
|
|
189
|
+
const rawKey = await crypto.subtle.exportKey('raw', key);
|
|
190
|
+
_sessionKey = await crypto.subtle.importKey(
|
|
191
|
+
'raw', rawKey, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Clears the cached key from device storage and session memory.
|
|
197
|
+
* Call this when the user disables encryption.
|
|
198
|
+
*
|
|
199
|
+
* @param {object} config
|
|
200
|
+
* @param {string} config.cryptoDBName
|
|
201
|
+
* @param {Function|null} [config.nativeGetSyncKey]
|
|
202
|
+
* @param {Function|null} [config.nativeStoreSyncKey]
|
|
203
|
+
*/
|
|
204
|
+
export async function clearEncryptionKey({ cryptoDBName, nativeGetSyncKey = null, nativeStoreSyncKey = null } = {}) {
|
|
205
|
+
_sessionPassphrase = null;
|
|
206
|
+
_sessionKey = null;
|
|
207
|
+
_sessionSalt = null;
|
|
208
|
+
if (nativeGetSyncKey != null) {
|
|
209
|
+
await clearKeyFromNative(nativeStoreSyncKey);
|
|
210
|
+
} else {
|
|
211
|
+
await clearKeyFromIndexedDB(cryptoDBName);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// ---------------------------------------------------------------------------
|
|
216
|
+
// Helpers
|
|
217
|
+
// ---------------------------------------------------------------------------
|
|
218
|
+
function packBytes(...arrays) {
|
|
219
|
+
const total = arrays.reduce((n, a) => n + a.length, 0);
|
|
220
|
+
const out = new Uint8Array(total);
|
|
221
|
+
let offset = 0;
|
|
222
|
+
for (const a of arrays) { out.set(a, offset); offset += a.length; }
|
|
223
|
+
return out;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function toBase64(bytes) {
|
|
227
|
+
// Chunk to avoid "Maximum call stack size exceeded" for large payloads —
|
|
228
|
+
// spreading a large Uint8Array as function arguments hits the engine's
|
|
229
|
+
// argument-count limit (~65 K in V8).
|
|
230
|
+
let binary = '';
|
|
231
|
+
const chunk = 8192;
|
|
232
|
+
for (let i = 0; i < bytes.length; i += chunk) {
|
|
233
|
+
binary += String.fromCharCode(...bytes.subarray(i, i + chunk));
|
|
234
|
+
}
|
|
235
|
+
return btoa(binary);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function fromBase64(b64) {
|
|
239
|
+
return Uint8Array.from(atob(b64), c => c.charCodeAt(0));
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// ---------------------------------------------------------------------------
|
|
243
|
+
// Encrypt / decrypt
|
|
244
|
+
// ---------------------------------------------------------------------------
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Encrypts a plain JS object and returns an envelope object ready for JSON.stringify.
|
|
248
|
+
*
|
|
249
|
+
* Envelope format:
|
|
250
|
+
* { v: 1, enc: "AES-GCM-256", data: "<base64>" }
|
|
251
|
+
*
|
|
252
|
+
* The base64 blob encodes:
|
|
253
|
+
* [ 16 bytes: PBKDF2 salt ] [ 12 bytes: AES-GCM IV ] [ N bytes: ciphertext ]
|
|
254
|
+
*
|
|
255
|
+
* @param {object} data - plain JS object to encrypt
|
|
256
|
+
* @param {object} config
|
|
257
|
+
* @param {string} config.cryptoDBName
|
|
258
|
+
* @param {Function|null} [config.nativeGetSyncKey]
|
|
259
|
+
* @param {Function|null} [config.nativeStoreSyncKey]
|
|
260
|
+
*/
|
|
261
|
+
export async function encryptData(data, config = {}) {
|
|
262
|
+
// First use: lazily set up the key so the caller doesn't need to distinguish
|
|
263
|
+
// between "first device" and "subsequent upload" scenarios.
|
|
264
|
+
if (!_sessionKey) {
|
|
265
|
+
if (_sessionPassphrase) {
|
|
266
|
+
await setupEncryptionKey(_sessionPassphrase, config);
|
|
267
|
+
} else {
|
|
268
|
+
throw new Error('Encryption key not available. Please enter your sync passphrase.');
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const plaintext = new TextEncoder().encode(JSON.stringify(data));
|
|
273
|
+
const iv = crypto.getRandomValues(new Uint8Array(12));
|
|
274
|
+
const ciphertext = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, _sessionKey, plaintext);
|
|
275
|
+
|
|
276
|
+
const packed = packBytes(_sessionSalt, iv, new Uint8Array(ciphertext));
|
|
277
|
+
return { v: 1, enc: 'AES-GCM-256', data: toBase64(packed) };
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Decrypts an envelope object produced by encryptData.
|
|
282
|
+
* Throws on wrong passphrase, tampered data, or missing key.
|
|
283
|
+
*
|
|
284
|
+
* Cross-device recovery path: if no cached key but _sessionPassphrase is set,
|
|
285
|
+
* derives the key from the passphrase + salt embedded in the ciphertext, then
|
|
286
|
+
* caches it in device storage for future sessions.
|
|
287
|
+
*
|
|
288
|
+
* @param {object} envelope - encrypted envelope from encryptData
|
|
289
|
+
* @param {object} config
|
|
290
|
+
* @param {string} config.cryptoDBName
|
|
291
|
+
* @param {Function|null} [config.nativeGetSyncKey]
|
|
292
|
+
* @param {Function|null} [config.nativeStoreSyncKey]
|
|
293
|
+
*/
|
|
294
|
+
export async function decryptData(envelope, config = {}) {
|
|
295
|
+
if (!envelope || !envelope.data) throw new Error('Invalid encrypted envelope');
|
|
296
|
+
if (envelope.v !== 1) throw new Error(`Unknown encryption version: ${envelope.v}`);
|
|
297
|
+
|
|
298
|
+
const packed = fromBase64(envelope.data);
|
|
299
|
+
const salt = packed.slice(0, 16);
|
|
300
|
+
const iv = packed.slice(16, 28);
|
|
301
|
+
const ciphertext = packed.slice(28);
|
|
302
|
+
|
|
303
|
+
let key = _sessionKey;
|
|
304
|
+
|
|
305
|
+
// If the cached key was derived from a different salt (e.g. user re-configured
|
|
306
|
+
// encryption, generating a new salt), it cannot decrypt this file. Force
|
|
307
|
+
// re-derivation so the file's embedded salt always dictates which key is used.
|
|
308
|
+
if (key && _sessionSalt) {
|
|
309
|
+
const saltMatch = salt.length === _sessionSalt.length &&
|
|
310
|
+
salt.every((b, i) => b === _sessionSalt[i]);
|
|
311
|
+
if (!saltMatch) key = null;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
let keyWasDerived = false;
|
|
315
|
+
if (!key) {
|
|
316
|
+
if (_sessionPassphrase) {
|
|
317
|
+
// Cross-device recovery or salt mismatch: derive key from passphrase + salt from file.
|
|
318
|
+
// Do NOT cache yet — only persist after we confirm the key actually decrypts the file.
|
|
319
|
+
key = await deriveKey(_sessionPassphrase, salt);
|
|
320
|
+
keyWasDerived = true;
|
|
321
|
+
} else {
|
|
322
|
+
const err = new Error('Encryption key not available. Please enter your sync passphrase.');
|
|
323
|
+
err.code = 'PASSPHRASE_REQUIRED';
|
|
324
|
+
throw err;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
try {
|
|
329
|
+
const plaintext = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, ciphertext);
|
|
330
|
+
// Decryption succeeded — safe to promote and cache the derived key.
|
|
331
|
+
if (keyWasDerived) {
|
|
332
|
+
_sessionKey = key;
|
|
333
|
+
_sessionSalt = salt;
|
|
334
|
+
const { nativeGetSyncKey = null, nativeStoreSyncKey = null, cryptoDBName } = config;
|
|
335
|
+
if (nativeGetSyncKey != null) {
|
|
336
|
+
await saveKeyToNative(nativeStoreSyncKey, key, salt);
|
|
337
|
+
} else {
|
|
338
|
+
await saveKeyToIndexedDB(cryptoDBName, key, salt);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
return JSON.parse(new TextDecoder().decode(plaintext));
|
|
342
|
+
} catch (err) {
|
|
343
|
+
if (err.name === 'OperationError') {
|
|
344
|
+
throw new Error('Decryption failed — wrong passphrase or corrupted data.');
|
|
345
|
+
}
|
|
346
|
+
throw err;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Returns true if the value looks like an encrypted envelope produced by
|
|
352
|
+
* encryptData. Used to decide whether to call decryptData on downloaded files.
|
|
353
|
+
*/
|
|
354
|
+
export function isEncryptedEnvelope(value) {
|
|
355
|
+
return (
|
|
356
|
+
value !== null &&
|
|
357
|
+
typeof value === 'object' &&
|
|
358
|
+
value.v === 1 &&
|
|
359
|
+
value.enc === 'AES-GCM-256' &&
|
|
360
|
+
typeof value.data === 'string'
|
|
361
|
+
);
|
|
362
|
+
}
|
package/src/index.js
CHANGED
|
@@ -7,3 +7,15 @@ export {
|
|
|
7
7
|
mergeSyncData,
|
|
8
8
|
pruneTombstones,
|
|
9
9
|
} from './merge.js';
|
|
10
|
+
|
|
11
|
+
export {
|
|
12
|
+
encryptData,
|
|
13
|
+
decryptData,
|
|
14
|
+
setupEncryptionKey,
|
|
15
|
+
setSyncPassphrase,
|
|
16
|
+
getSyncPassphrase,
|
|
17
|
+
clearEncryptionKey,
|
|
18
|
+
hasEncryptionReady,
|
|
19
|
+
isEncryptedEnvelope,
|
|
20
|
+
initSessionKey,
|
|
21
|
+
} from './crypto.js';
|