@syncvault/sdk 1.2.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.2.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.d.ts CHANGED
@@ -35,6 +35,20 @@ export interface QuotaInfo {
35
35
  unlimited: boolean;
36
36
  }
37
37
 
38
+ export interface SharedVault {
39
+ id: string;
40
+ name: string;
41
+ ownerId: string;
42
+ ownerUsername: string;
43
+ memberCount: number;
44
+ isOwner: boolean;
45
+ createdAt: string;
46
+ }
47
+
48
+ export interface PutOptions {
49
+ updatedAt?: number | Date;
50
+ }
51
+
38
52
  export declare class SyncVault {
39
53
  constructor(options: SyncVaultOptions);
40
54
 
@@ -48,7 +62,7 @@ export declare class SyncVault {
48
62
  register(username: string, password: string): Promise<User>;
49
63
 
50
64
  // Data operations
51
- put<T = unknown>(path: string, data: T): Promise<PutResponse>;
65
+ put<T = unknown>(path: string, data: T, options?: PutOptions): Promise<PutResponse>;
52
66
  get<T = unknown>(path: string): Promise<T>;
53
67
  list(): Promise<FileInfo[]>;
54
68
  delete(path: string): Promise<DeleteResponse>;
@@ -64,6 +78,13 @@ export declare class SyncVault {
64
78
  // Quota info
65
79
  getQuota(): Promise<QuotaInfo>;
66
80
 
81
+ // Shared vaults
82
+ getSharedVaults(): Promise<SharedVault[]>;
83
+ listShared(vaultId: string): Promise<FileInfo[]>;
84
+ putShared<T = unknown>(vaultId: string, path: string, data: T, sharedPassword?: string): Promise<PutResponse>;
85
+ getShared<T = unknown>(vaultId: string, path: string, sharedPassword?: string): Promise<T>;
86
+ deleteShared(vaultId: string, path: string): Promise<DeleteResponse>;
87
+
67
88
  // State
68
89
  isAuthenticated(): boolean;
69
90
  logout(): void;
@@ -79,6 +100,7 @@ export interface PendingOperation {
79
100
  type: 'put' | 'delete';
80
101
  path: string;
81
102
  data?: string;
103
+ updatedAt?: number;
82
104
  createdAt: number;
83
105
  retries: number;
84
106
  }
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
 
@@ -107,15 +109,24 @@ export class SyncVault {
107
109
 
108
110
  /**
109
111
  * Store encrypted data
112
+ * @param {string} path - File path
113
+ * @param {any} data - Data to encrypt and store
114
+ * @param {Object} options - Optional settings
115
+ * @param {number} options.updatedAt - Timestamp for LWW conflict resolution
110
116
  */
111
- async put(path, data) {
117
+ async put(path, data, options = {}) {
112
118
  this._checkAuth();
113
119
 
114
120
  const encrypted = await encrypt(data, this.password);
121
+ const body = { path, data: encrypted };
122
+
123
+ if (options.updatedAt) {
124
+ body.updatedAt = new Date(options.updatedAt).toISOString();
125
+ }
115
126
 
116
127
  const response = await this._request('/api/sync/put', {
117
128
  method: 'POST',
118
- body: JSON.stringify({ path, data: encrypted })
129
+ body: JSON.stringify(body)
119
130
  });
120
131
 
121
132
  return response;
@@ -215,6 +226,63 @@ export class SyncVault {
215
226
  return this._request('/api/sync/quota');
216
227
  }
217
228
 
229
+ // --- Shared Vaults ---
230
+
231
+ /**
232
+ * Get all shared vaults the user has access to in this app
233
+ */
234
+ async getSharedVaults() {
235
+ this._checkAuth();
236
+
237
+ return this._request('/api/sync/shared/vaults');
238
+ }
239
+
240
+ /**
241
+ * List files in a shared vault
242
+ */
243
+ async listShared(vaultId) {
244
+ this._checkAuth();
245
+
246
+ const response = await this._request(`/api/sync/shared/${vaultId}/list`);
247
+ return response.files;
248
+ }
249
+
250
+ /**
251
+ * Store encrypted data in a shared vault
252
+ */
253
+ async putShared(vaultId, path, data, sharedPassword) {
254
+ this._checkAuth();
255
+
256
+ const encrypted = await encrypt(data, sharedPassword || this.password);
257
+
258
+ return this._request(`/api/sync/shared/${vaultId}/put`, {
259
+ method: 'POST',
260
+ body: JSON.stringify({ path, data: encrypted })
261
+ });
262
+ }
263
+
264
+ /**
265
+ * Retrieve and decrypt data from a shared vault
266
+ */
267
+ async getShared(vaultId, path, sharedPassword) {
268
+ this._checkAuth();
269
+
270
+ const response = await this._request(`/api/sync/shared/${vaultId}/get?path=${encodeURIComponent(path)}`);
271
+ return decrypt(response.data, sharedPassword || this.password);
272
+ }
273
+
274
+ /**
275
+ * Delete a file from a shared vault
276
+ */
277
+ async deleteShared(vaultId, path) {
278
+ this._checkAuth();
279
+
280
+ return this._request(`/api/sync/shared/${vaultId}/delete`, {
281
+ method: 'POST',
282
+ body: JSON.stringify({ path })
283
+ });
284
+ }
285
+
218
286
  /**
219
287
  * Check if user is authenticated
220
288
  */
@@ -279,5 +347,154 @@ export class SyncVault {
279
347
  }
280
348
  }
281
349
 
282
- 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
+
283
500
  export { OfflineSyncVault, OfflineStore, createOfflineClient } from './offline.js';
package/src/offline.js CHANGED
@@ -219,15 +219,16 @@ export class OfflineSyncVault {
219
219
  }
220
220
 
221
221
  /**
222
- * Put with offline support
222
+ * Put with offline support (LWW enabled)
223
223
  */
224
224
  async put(path, data) {
225
225
  await this.init();
226
226
 
227
+ const timestamp = Date.now();
227
228
  const encrypted = await encrypt(data, this.client.password);
228
229
 
229
230
  try {
230
- const result = await this.client.put(path, data);
231
+ const result = await this.client.put(path, data, { updatedAt: timestamp });
231
232
  await this.store.setCache(path, encrypted);
232
233
  return result;
233
234
  } catch (error) {
@@ -236,7 +237,8 @@ export class OfflineSyncVault {
236
237
  await this.store.queueOperation({
237
238
  type: 'put',
238
239
  path,
239
- data: encrypted
240
+ data: encrypted,
241
+ updatedAt: timestamp
240
242
  });
241
243
  return { queued: true, path };
242
244
  }
@@ -391,10 +393,31 @@ export class OfflineSyncVault {
391
393
  }
392
394
 
393
395
  async _syncPut(op) {
394
- await this.client._request('/api/sync/put', {
395
- method: 'POST',
396
- body: JSON.stringify({ path: op.path, data: op.data })
397
- });
396
+ const body = { path: op.path, data: op.data };
397
+ if (op.updatedAt) {
398
+ body.updatedAt = new Date(op.updatedAt).toISOString();
399
+ }
400
+
401
+ try {
402
+ await this.client._request('/api/sync/put', {
403
+ method: 'POST',
404
+ body: JSON.stringify(body)
405
+ });
406
+ } catch (error) {
407
+ // LWW conflict - server has newer data, discard local change
408
+ if (error.statusCode === 409 && error.data?.code === 'CONFLICT_STALE') {
409
+ // Fetch fresh data from server to update cache
410
+ try {
411
+ const result = await this.client.get(op.path);
412
+ const encrypted = await encrypt(result, this.client.password);
413
+ await this.store.setCache(op.path, encrypted);
414
+ } catch (cacheErr) {
415
+ console.warn('Failed to update cache after LWW conflict:', cacheErr.message);
416
+ }
417
+ return; // Consider this "synced" - we accepted server's version
418
+ }
419
+ throw error;
420
+ }
398
421
  }
399
422
 
400
423
  async _syncDelete(op) {