@syncvault/sdk 1.3.0 → 1.4.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 +7 -3
- package/src/crypto.js +69 -0
- package/src/index.js +153 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@syncvault/sdk",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.1",
|
|
4
4
|
"description": "SyncVault SDK - Zero-knowledge encrypted sync for Node.js and browsers",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
|
@@ -18,7 +18,8 @@
|
|
|
18
18
|
"src"
|
|
19
19
|
],
|
|
20
20
|
"scripts": {
|
|
21
|
-
"test": "node test/test.js"
|
|
21
|
+
"test": "node test/test.js",
|
|
22
|
+
"build": "esbuild src/index.js --bundle --outfile=dist/index.js --format=esm --minify --sourcemap"
|
|
22
23
|
},
|
|
23
24
|
"keywords": [
|
|
24
25
|
"syncvault",
|
|
@@ -45,5 +46,8 @@
|
|
|
45
46
|
"engines": {
|
|
46
47
|
"node": ">=18.0.0"
|
|
47
48
|
},
|
|
48
|
-
"sideEffects": false
|
|
49
|
+
"sideEffects": false,
|
|
50
|
+
"devDependencies": {
|
|
51
|
+
"esbuild": "^0.27.2"
|
|
52
|
+
}
|
|
49
53
|
}
|
package/src/crypto.js
CHANGED
|
@@ -137,3 +137,72 @@ function base64ToBuffer(base64) {
|
|
|
137
137
|
}
|
|
138
138
|
return bytes;
|
|
139
139
|
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Decrypt data that was encrypted by an app server using hybrid encryption.
|
|
143
|
+
* The server encrypts an AES key with RSA-OAEP, then encrypts the data with AES-GCM.
|
|
144
|
+
*
|
|
145
|
+
* Format: encryptedAESKey (256 bytes) + iv (12 bytes) + authTag (16 bytes) + ciphertext
|
|
146
|
+
*
|
|
147
|
+
* @param {string} encryptedBase64 - Base64 encoded encrypted package
|
|
148
|
+
* @param {string} privateKeyBase64 - User's RSA private key in base64 (PKCS8 format)
|
|
149
|
+
*/
|
|
150
|
+
export async function decryptFromServer(encryptedBase64, privateKeyBase64) {
|
|
151
|
+
try {
|
|
152
|
+
const combined = base64ToBuffer(encryptedBase64);
|
|
153
|
+
|
|
154
|
+
const RSA_KEY_SIZE = 256;
|
|
155
|
+
const AES_IV_LENGTH = 12;
|
|
156
|
+
const AUTH_TAG_LENGTH = 16;
|
|
157
|
+
|
|
158
|
+
const minLength = RSA_KEY_SIZE + AES_IV_LENGTH + AUTH_TAG_LENGTH;
|
|
159
|
+
if (combined.length < minLength) {
|
|
160
|
+
throw new Error(`Invalid encrypted data: too short (${combined.length} bytes, need ${minLength})`);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const encryptedAESKey = combined.slice(0, RSA_KEY_SIZE);
|
|
164
|
+
const iv = combined.slice(RSA_KEY_SIZE, RSA_KEY_SIZE + AES_IV_LENGTH);
|
|
165
|
+
const authTag = combined.slice(RSA_KEY_SIZE + AES_IV_LENGTH, RSA_KEY_SIZE + AES_IV_LENGTH + AUTH_TAG_LENGTH);
|
|
166
|
+
const ciphertext = combined.slice(RSA_KEY_SIZE + AES_IV_LENGTH + AUTH_TAG_LENGTH);
|
|
167
|
+
|
|
168
|
+
const privateKeyBuffer = base64ToBuffer(privateKeyBase64);
|
|
169
|
+
|
|
170
|
+
const privateKey = await crypto.subtle.importKey(
|
|
171
|
+
'pkcs8',
|
|
172
|
+
privateKeyBuffer,
|
|
173
|
+
{ name: 'RSA-OAEP', hash: 'SHA-256' },
|
|
174
|
+
false,
|
|
175
|
+
['decrypt']
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
const rawAESKey = await crypto.subtle.decrypt(
|
|
179
|
+
{ name: 'RSA-OAEP' },
|
|
180
|
+
privateKey,
|
|
181
|
+
encryptedAESKey
|
|
182
|
+
);
|
|
183
|
+
|
|
184
|
+
const aesKey = await crypto.subtle.importKey(
|
|
185
|
+
'raw',
|
|
186
|
+
rawAESKey,
|
|
187
|
+
{ name: 'AES-GCM' },
|
|
188
|
+
false,
|
|
189
|
+
['decrypt']
|
|
190
|
+
);
|
|
191
|
+
|
|
192
|
+
const ciphertextWithTag = new Uint8Array(ciphertext.length + AUTH_TAG_LENGTH);
|
|
193
|
+
ciphertextWithTag.set(ciphertext, 0);
|
|
194
|
+
ciphertextWithTag.set(authTag, ciphertext.length);
|
|
195
|
+
|
|
196
|
+
const decrypted = await crypto.subtle.decrypt(
|
|
197
|
+
{ name: 'AES-GCM', iv },
|
|
198
|
+
aesKey,
|
|
199
|
+
ciphertextWithTag
|
|
200
|
+
);
|
|
201
|
+
|
|
202
|
+
const decoder = new TextDecoder();
|
|
203
|
+
return JSON.parse(decoder.decode(decrypted));
|
|
204
|
+
} catch (err) {
|
|
205
|
+
console.error('[SDK decryptFromServer] Error:', err.message);
|
|
206
|
+
throw err;
|
|
207
|
+
}
|
|
208
|
+
}
|
package/src/index.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import { encrypt, decrypt, prepareAuthPassword } from './crypto.js';
|
|
1
|
+
import { encrypt, decrypt, prepareAuthPassword, decryptFromServer } from './crypto.js';
|
|
2
|
+
|
|
3
|
+
export { encrypt, decrypt, decryptFromServer };
|
|
2
4
|
|
|
3
5
|
const DEFAULT_SERVER = 'https://api.syncvault.dev';
|
|
4
6
|
|
|
@@ -345,5 +347,154 @@ export class SyncVault {
|
|
|
345
347
|
}
|
|
346
348
|
}
|
|
347
349
|
|
|
348
|
-
|
|
350
|
+
/**
|
|
351
|
+
* Server-side client for app backends to write data on behalf of users.
|
|
352
|
+
* Requires server_write OAuth scope to be granted by users.
|
|
353
|
+
*
|
|
354
|
+
* Data is encrypted with the user's public key (RSA-OAEP),
|
|
355
|
+
* so only the user can decrypt it with their private key.
|
|
356
|
+
*/
|
|
357
|
+
export class SyncVaultServer {
|
|
358
|
+
constructor(options = {}) {
|
|
359
|
+
if (!options.appToken) {
|
|
360
|
+
throw new Error('appToken is required');
|
|
361
|
+
}
|
|
362
|
+
if (!options.secretToken) {
|
|
363
|
+
throw new Error('secretToken is required for server-side operations');
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
this.appToken = options.appToken;
|
|
367
|
+
this.secretToken = options.secretToken;
|
|
368
|
+
this.serverUrl = options.serverUrl || DEFAULT_SERVER;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Get user's public key for encryption
|
|
373
|
+
*/
|
|
374
|
+
async getUserPublicKey(userId) {
|
|
375
|
+
return this._request(`/api/server/user/${userId}/public-key`);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* Encrypt data with user's public key (RSA-OAEP + AES-GCM hybrid)
|
|
380
|
+
*/
|
|
381
|
+
async encryptForUser(data, publicKeyPem) {
|
|
382
|
+
// Parse PEM to get raw key bytes
|
|
383
|
+
const pemBody = publicKeyPem
|
|
384
|
+
.replace(/-----BEGIN PUBLIC KEY-----/, '')
|
|
385
|
+
.replace(/-----END PUBLIC KEY-----/, '')
|
|
386
|
+
.replace(/\s+/g, '');
|
|
387
|
+
const publicKeyBuffer = Uint8Array.from(atob(pemBody), c => c.charCodeAt(0));
|
|
388
|
+
|
|
389
|
+
const publicKey = await crypto.subtle.importKey(
|
|
390
|
+
'spki',
|
|
391
|
+
publicKeyBuffer,
|
|
392
|
+
{ name: 'RSA-OAEP', hash: 'SHA-256' },
|
|
393
|
+
false,
|
|
394
|
+
['encrypt']
|
|
395
|
+
);
|
|
396
|
+
|
|
397
|
+
const encoder = new TextEncoder();
|
|
398
|
+
const dataBuffer = encoder.encode(JSON.stringify(data));
|
|
399
|
+
|
|
400
|
+
// Generate random AES key
|
|
401
|
+
const aesKey = await crypto.subtle.generateKey(
|
|
402
|
+
{ name: 'AES-GCM', length: 256 },
|
|
403
|
+
true,
|
|
404
|
+
['encrypt']
|
|
405
|
+
);
|
|
406
|
+
|
|
407
|
+
const iv = crypto.getRandomValues(new Uint8Array(12));
|
|
408
|
+
const encryptedData = await crypto.subtle.encrypt(
|
|
409
|
+
{ name: 'AES-GCM', iv },
|
|
410
|
+
aesKey,
|
|
411
|
+
dataBuffer
|
|
412
|
+
);
|
|
413
|
+
|
|
414
|
+
// Web Crypto returns ciphertext with authTag appended
|
|
415
|
+
// We need to separate them for our format
|
|
416
|
+
const encryptedArray = new Uint8Array(encryptedData);
|
|
417
|
+
const authTag = encryptedArray.slice(-16);
|
|
418
|
+
const ciphertext = encryptedArray.slice(0, -16);
|
|
419
|
+
|
|
420
|
+
const rawAesKey = await crypto.subtle.exportKey('raw', aesKey);
|
|
421
|
+
const encryptedAesKey = await crypto.subtle.encrypt(
|
|
422
|
+
{ name: 'RSA-OAEP' },
|
|
423
|
+
publicKey,
|
|
424
|
+
rawAesKey
|
|
425
|
+
);
|
|
426
|
+
|
|
427
|
+
// Pack: encryptedAesKey (256 bytes) + iv (12 bytes) + authTag (16 bytes) + ciphertext
|
|
428
|
+
const result = new Uint8Array(
|
|
429
|
+
encryptedAesKey.byteLength + iv.byteLength + authTag.byteLength + ciphertext.byteLength
|
|
430
|
+
);
|
|
431
|
+
let offset = 0;
|
|
432
|
+
result.set(new Uint8Array(encryptedAesKey), offset);
|
|
433
|
+
offset += encryptedAesKey.byteLength;
|
|
434
|
+
result.set(iv, offset);
|
|
435
|
+
offset += iv.byteLength;
|
|
436
|
+
result.set(authTag, offset);
|
|
437
|
+
offset += authTag.byteLength;
|
|
438
|
+
result.set(ciphertext, offset);
|
|
439
|
+
|
|
440
|
+
return btoa(String.fromCharCode(...result));
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* Write pre-encrypted data to user's storage
|
|
445
|
+
*/
|
|
446
|
+
async putForUser(userId, path, encryptedData) {
|
|
447
|
+
return this._request(`/api/server/user/${userId}/put`, {
|
|
448
|
+
method: 'POST',
|
|
449
|
+
body: JSON.stringify({ path, data: encryptedData })
|
|
450
|
+
});
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* Encrypt and store data for user in one call
|
|
455
|
+
*/
|
|
456
|
+
async writeForUser(userId, path, data) {
|
|
457
|
+
const { publicKey } = await this.getUserPublicKey(userId);
|
|
458
|
+
const encrypted = await this.encryptForUser(data, publicKey);
|
|
459
|
+
return this.putForUser(userId, path, encrypted);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* List files in user's storage for this app
|
|
464
|
+
*/
|
|
465
|
+
async listForUser(userId) {
|
|
466
|
+
return this._request(`/api/server/user/${userId}/list`);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
async _request(path, options = {}) {
|
|
470
|
+
const url = `${this.serverUrl}${path}`;
|
|
471
|
+
|
|
472
|
+
const headers = {
|
|
473
|
+
'Content-Type': 'application/json',
|
|
474
|
+
'X-App-Token': this.appToken,
|
|
475
|
+
'X-Secret-Token': this.secretToken
|
|
476
|
+
};
|
|
477
|
+
|
|
478
|
+
const response = await fetch(url, {
|
|
479
|
+
...options,
|
|
480
|
+
headers: {
|
|
481
|
+
...headers,
|
|
482
|
+
...options.headers
|
|
483
|
+
}
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
const responseData = await response.json();
|
|
487
|
+
|
|
488
|
+
if (!response.ok) {
|
|
489
|
+
throw new SyncVaultError(
|
|
490
|
+
responseData.error || 'Request failed',
|
|
491
|
+
response.status,
|
|
492
|
+
responseData
|
|
493
|
+
);
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
return responseData;
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
349
500
|
export { OfflineSyncVault, OfflineStore, createOfflineClient } from './offline.js';
|