@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@syncvault/sdk",
3
- "version": "1.3.0",
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
- export { encrypt, decrypt } from './crypto.js';
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';