clawkeep 0.2.5 → 0.2.6

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/README.md CHANGED
@@ -109,7 +109,35 @@ Your backup target receives **encrypted chunks only**. No metadata. No history.
109
109
  |---|---|---|
110
110
  | **Local path** | ✅ Available | Any mounted folder — NAS, USB drive, network share |
111
111
  | **S3 / R2** | ✅ Available | Object storage (Cloudflare R2, AWS S3, MinIO, Backblaze B2, Wasabi) |
112
- | **ClawKeep Cloud** | 🔜 Coming soon | Managed zero-knowledge backup |
112
+ | **ClawKeep Cloud** | Available | Managed zero-knowledge backup with browser-based setup |
113
+
114
+ ### ClawKeep Cloud Setup
115
+
116
+ The easiest way to get started. Your encryption password is set in the browser — **the CLI never sees it**.
117
+
118
+ ```bash
119
+ # Connect to ClawKeep Cloud
120
+ clawkeep cloud setup
121
+
122
+ # Opens browser → log in → set your encryption password
123
+ # CLI receives only the encrypted key material, never the password
124
+ ```
125
+
126
+ ```
127
+ ✔ Authorization received
128
+ ✓ Connected to ClawKeep Cloud
129
+ Workspace ws_01abc123...
130
+
131
+ What's next:
132
+ $ clawkeep watch --sync --daemon
133
+ ```
134
+
135
+ **Security:** The encryption key is derived in your browser. Your plaintext password never leaves the browser, never hits our API, and the CLI never sees it. We store only encrypted chunks — true zero-knowledge.
136
+
137
+ ```bash
138
+ # Start auto-syncing (no password in environment needed!)
139
+ clawkeep watch --sync --daemon
140
+ ```
113
141
 
114
142
  ### S3 / R2 Setup
115
143
 
@@ -188,11 +216,25 @@ clawkeep watch --daemon
188
216
  clawkeep watch --stop
189
217
 
190
218
  # Auto-sync to backup target after each change
191
- clawkeep watch --daemon --push
219
+ clawkeep watch --sync --daemon
192
220
  ```
193
221
 
194
222
  Smart debouncing, stability detection, configurable ignore patterns. Your files are continuously protected.
195
223
 
224
+ ### 🔑 Keyless Daemon
225
+
226
+ When using ClawKeep Cloud, the watch daemon **doesn't need your password in the environment**. The encrypted key material is stored locally during `cloud setup` — the daemon uses it automatically.
227
+
228
+ ```bash
229
+ # No CLAWKEEP_PASSWORD needed!
230
+ clawkeep watch --sync --daemon
231
+
232
+ # Works with PM2, systemd, or any process manager
233
+ pm2 start "clawkeep watch --sync" --name clawkeep-watch
234
+ ```
235
+
236
+ For local/S3 targets, run `clawkeep backup set-password` once to store the encrypted key material, then the daemon works the same way.
237
+
196
238
  ## Time-Travel Restore
197
239
 
198
240
  Go back to any point in time. Your current state is preserved — nothing is ever lost.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clawkeep",
3
- "version": "0.2.5",
3
+ "version": "0.2.6",
4
4
  "description": "Private, encrypted backups with time-travel restore. Zero-knowledge protection for AI agents, configs, and everything you care about.",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -166,7 +166,8 @@ async function doSetPassword(bm, opts) {
166
166
  try {
167
167
  bm.setPassword(password);
168
168
  spinner.succeed('Encryption password set');
169
- console.log(chalk.dim(' Password hash stored (password itself is never saved)'));
169
+ console.log(chalk.dim(' Password hash + wrapped key stored (password itself is never saved)'));
170
+ console.log(chalk.dim(' Keyless sync enabled — no CLAWKEEP_PASSWORD needed for daemon'));
170
171
  } catch (err) {
171
172
  spinner.fail('Failed to set password');
172
173
  console.error(chalk.red(' ' + err.message));
@@ -179,9 +180,10 @@ async function doSync(bm, opts) {
179
180
  const needsPassword = cfg.target === 'local' || cfg.target === 's3' || cfg.target === 'cloud';
180
181
  const password = needsPassword ? getPassword(opts) : null;
181
182
 
182
- if (needsPassword && !password) {
183
+ if (needsPassword && !password && !cfg.wrappedKeySet) {
183
184
  console.error(chalk.red(' Password required for encrypted sync.'));
184
185
  console.error(chalk.dim(' Use: CLAWKEEP_PASSWORD=xxx clawkeep backup sync'));
186
+ console.error(chalk.dim(' Or run `clawkeep backup set-password` first for keyless sync.'));
185
187
  process.exit(1);
186
188
  }
187
189
 
@@ -234,10 +236,12 @@ async function doTest(bm) {
234
236
  }
235
237
 
236
238
  async function doCompact(bm, opts) {
239
+ const cfg = bm.getConfig();
237
240
  const password = getPassword(opts);
238
- if (!password) {
241
+ if (!password && !cfg.wrappedKeySet) {
239
242
  console.error(chalk.red(' Password required for compact.'));
240
243
  console.error(chalk.dim(' Use: CLAWKEEP_PASSWORD=xxx clawkeep backup compact'));
244
+ console.error(chalk.dim(' Or run `clawkeep backup set-password` first for keyless compact.'));
241
245
  process.exit(1);
242
246
  }
243
247
 
@@ -184,6 +184,15 @@ async function doBrowserSetup(dir, webUrl, apiUrl, opts) {
184
184
  const bm = new BackupManager(claw);
185
185
  await bm.setTarget('cloud', { workspace: result.workspace_id, endpoint: apiUrl });
186
186
 
187
+ // Store wrappedKey + passwordHash if browser derived them
188
+ if (result.wrapped_key && result.password_hash) {
189
+ const config = claw.loadConfig();
190
+ if (!config.backup) config.backup = {};
191
+ config.backup.passwordHash = result.password_hash;
192
+ config.backup.wrappedKey = result.wrapped_key;
193
+ claw.saveConfig(config);
194
+ }
195
+
187
196
  const password = opts.password || process.env.CLAWKEEP_PASSWORD;
188
197
  if (password && !bm.hasPassword()) {
189
198
  bm.setPassword(password);
@@ -198,7 +207,7 @@ async function doBrowserSetup(dir, webUrl, apiUrl, opts) {
198
207
  console.log(` ${chalk.dim('Workspace')} ${result.workspace_id}`);
199
208
  console.log('');
200
209
 
201
- // Show what's next
210
+ // Show what's next (skip password step if wrappedKey was received from browser)
202
211
  showNextSteps(bm, opts);
203
212
 
204
213
  // Auto-start watcher if --watch
@@ -3,7 +3,7 @@
3
3
  const path = require('path');
4
4
  const fs = require('fs');
5
5
  const crypto = require('crypto');
6
- const { hashPassword, verifyPassword } = require('./sync-crypto');
6
+ const { hashPassword, verifyPassword, deriveEncryptionKey, wrapKey, unwrapKey } = require('./sync-crypto');
7
7
  const { createTransport, LocalTransport } = require('./transport');
8
8
  const SyncManager = require('./sync');
9
9
 
@@ -43,18 +43,21 @@ class BackupManager {
43
43
  cloud: backup.cloud || null,
44
44
  s3: backup.s3 || null,
45
45
  passwordSet: !!backup.passwordHash,
46
+ wrappedKeySet: !!backup.wrappedKey,
46
47
  workspaceId: backup.workspaceId || null,
47
48
  chunkCount: backup.chunkCount || 0,
48
49
  lastSyncCommit: backup.lastSyncCommit || null,
49
50
  };
50
51
  }
51
52
 
52
- /** Set encryption password (stores hash only) */
53
+ /** Set encryption password (stores hash + wrappedKey) */
53
54
  setPassword(password) {
54
55
  if (!password) throw new Error('Password is required');
55
56
  const config = this.claw.loadConfig();
56
57
  if (!config.backup) config.backup = {};
57
58
  config.backup.passwordHash = hashPassword(password);
59
+ const encKey = deriveEncryptionKey(password);
60
+ config.backup.wrappedKey = wrapKey(encKey, config.backup.passwordHash);
58
61
  this.claw.saveConfig(config);
59
62
  }
60
63
 
@@ -72,6 +75,28 @@ class BackupManager {
72
75
  return verifyPassword(password, hash);
73
76
  }
74
77
 
78
+ /**
79
+ * Resolve encryption credential from config or explicit password.
80
+ * Returns { encryptionKey } (keyless/CK02) or { password } (legacy/CK01).
81
+ */
82
+ _resolveCredential(password) {
83
+ const config = this.claw.loadConfig();
84
+ const backup = config.backup || {};
85
+
86
+ // Priority 1: wrappedKey exists → unwrap it (keyless mode)
87
+ if (backup.wrappedKey && backup.passwordHash) {
88
+ const key = unwrapKey(backup.wrappedKey, backup.passwordHash);
89
+ return { encryptionKey: key };
90
+ }
91
+
92
+ // Priority 2: explicit password → use directly (legacy / restore)
93
+ if (password) {
94
+ return { password };
95
+ }
96
+
97
+ throw new Error('No encryption credential available. Run: clawkeep backup set-password');
98
+ }
99
+
75
100
  /** Set backup target */
76
101
  async setTarget(type, options = {}) {
77
102
  const config = this.claw.loadConfig();
@@ -131,7 +156,7 @@ class BackupManager {
131
156
  return this.getConfig();
132
157
  }
133
158
 
134
- /** Sync to backup target */
159
+ /** Sync to backup target (password optional if wrappedKey exists) */
135
160
  async sync(password) {
136
161
  const config = this.claw.loadConfig();
137
162
  const backup = config.backup || {};
@@ -143,9 +168,9 @@ class BackupManager {
143
168
  let transport;
144
169
  if (target === 'local' || target === 's3' || target === 'cloud') {
145
170
  // Encrypted incremental sync (local, S3, or cloud)
146
- if (!password) throw new Error('Password required for encrypted sync');
171
+ const credential = this._resolveCredential(password);
147
172
  transport = createTransport(backup, this.claw);
148
- const sm = new SyncManager(this.claw, transport, password);
173
+ const sm = new SyncManager(this.claw, transport, credential);
149
174
  result = await sm.sync();
150
175
  } else if (target === 'git') {
151
176
  await this.claw.push();
@@ -244,25 +269,25 @@ class BackupManager {
244
269
  return { ok: false, message: 'Unknown target: ' + target };
245
270
  }
246
271
 
247
- /** Compact chunks into single full bundle */
272
+ /** Compact chunks into single full bundle (password optional if wrappedKey exists) */
248
273
  async compact(password) {
249
274
  const config = this.claw.loadConfig();
250
275
  const backup = config.backup || {};
251
276
  if (backup.target !== 'local' && backup.target !== 's3' && backup.target !== 'cloud') {
252
277
  throw new Error('Compact only supported for local, s3, and cloud targets');
253
278
  }
254
- if (!password) throw new Error('Password required for compact');
255
279
 
280
+ const credential = this._resolveCredential(password);
256
281
  const transport = createTransport(backup, this.claw);
257
- const sm = new SyncManager(this.claw, transport, password);
282
+ const sm = new SyncManager(this.claw, transport, credential);
258
283
  return await sm.compact();
259
284
  }
260
285
 
261
- /** Get sync status (chunk count, sizes, etc.) */
286
+ /** Get sync status (chunk count, sizes, etc.) — password optional if wrappedKey exists */
262
287
  async getSyncStatus(password) {
263
288
  const config = this.claw.loadConfig();
264
289
  const backup = config.backup || {};
265
- if ((backup.target !== 'local' && backup.target !== 's3' && backup.target !== 'cloud') || !password) {
290
+ if (backup.target !== 'local' && backup.target !== 's3' && backup.target !== 'cloud') {
266
291
  return {
267
292
  synced: false,
268
293
  chunkCount: backup.chunkCount || 0,
@@ -271,8 +296,9 @@ class BackupManager {
271
296
  }
272
297
 
273
298
  try {
299
+ const credential = this._resolveCredential(password);
274
300
  const transport = createTransport(backup, this.claw);
275
- const sm = new SyncManager(this.claw, transport, password);
301
+ const sm = new SyncManager(this.claw, transport, credential);
276
302
  return await sm.getStatus();
277
303
  } catch {
278
304
  return {
@@ -299,7 +325,7 @@ class BackupManager {
299
325
  const parentDir = path.dirname(sourcePath);
300
326
  const transport = new LocalTransport(parentDir);
301
327
 
302
- return await SyncManager.restoreFrom(transport, workspaceId, password, destDir);
328
+ return await SyncManager.restoreFrom(transport, workspaceId, { password }, destDir);
303
329
  }
304
330
  }
305
331
 
@@ -2,13 +2,15 @@
2
2
 
3
3
  const crypto = require('crypto');
4
4
 
5
- const MAGIC = Buffer.from('CK01');
5
+ const MAGIC_CK01 = Buffer.from('CK01');
6
+ const MAGIC_CK02 = Buffer.from('CK02');
6
7
  const SALT_LENGTH = 32;
7
8
  const NONCE_LENGTH = 12;
8
9
  const KEY_LENGTH = 32;
9
10
  const TAG_LENGTH = 16;
10
11
  const ALGORITHM = 'aes-256-gcm';
11
- const HEADER_LENGTH = MAGIC.length + SALT_LENGTH + NONCE_LENGTH; // 4 + 32 + 12 = 48
12
+ const CK01_HEADER_LENGTH = MAGIC_CK01.length + SALT_LENGTH + NONCE_LENGTH; // 4 + 32 + 12 = 48
13
+ const CK02_HEADER_LENGTH = MAGIC_CK02.length + NONCE_LENGTH; // 4 + 12 = 16
12
14
 
13
15
  /**
14
16
  * Derive encryption key from password using scrypt.
@@ -18,8 +20,8 @@ function deriveKey(password, salt) {
18
20
  }
19
21
 
20
22
  /**
21
- * Encrypt a buffer using AES-256-GCM.
22
- * Output format: [4B magic "CK01"][32B salt][12B nonce][NB ciphertext][16B auth tag]
23
+ * Encrypt a buffer using AES-256-GCM (CK01 format — password-based).
24
+ * Output format: [4B "CK01"][32B salt][12B nonce][NB ciphertext][16B auth tag]
23
25
  */
24
26
  function encryptChunk(buffer, password) {
25
27
  const salt = crypto.randomBytes(SALT_LENGTH);
@@ -30,39 +32,127 @@ function encryptChunk(buffer, password) {
30
32
  const encrypted = Buffer.concat([cipher.update(buffer), cipher.final()]);
31
33
  const tag = cipher.getAuthTag();
32
34
 
33
- return Buffer.concat([MAGIC, salt, nonce, encrypted, tag]);
35
+ return Buffer.concat([MAGIC_CK01, salt, nonce, encrypted, tag]);
34
36
  }
35
37
 
36
38
  /**
37
- * Decrypt a CK01-format encrypted buffer.
38
- * Returns the decrypted plaintext buffer.
39
+ * Encrypt a buffer using AES-256-GCM with a pre-derived key (CK02 format).
40
+ * Output format: [4B "CK02"][12B nonce][NB ciphertext][16B auth tag]
41
+ * Much faster than CK01 — no per-chunk scrypt.
39
42
  */
40
- function decryptChunk(encBuffer, password) {
41
- if (encBuffer.length < HEADER_LENGTH + TAG_LENGTH) {
43
+ function encryptChunkWithKey(buffer, key) {
44
+ const nonce = crypto.randomBytes(NONCE_LENGTH);
45
+ const cipher = crypto.createCipheriv(ALGORITHM, key, nonce);
46
+ const encrypted = Buffer.concat([cipher.update(buffer), cipher.final()]);
47
+ const tag = cipher.getAuthTag();
48
+ return Buffer.concat([MAGIC_CK02, nonce, encrypted, tag]);
49
+ }
50
+
51
+ /**
52
+ * Decrypt an encrypted buffer. Auto-detects CK01 vs CK02 format.
53
+ * @param {Buffer} encBuffer - Encrypted chunk
54
+ * @param {string|null} password - Password for CK01 chunks (null OK if CK02 + key)
55
+ * @param {Buffer|null} key - Pre-derived key for CK02 chunks (null OK if CK01 + password)
56
+ */
57
+ function decryptChunk(encBuffer, password, key) {
58
+ if (encBuffer.length < CK02_HEADER_LENGTH + TAG_LENGTH) {
42
59
  throw new Error('Invalid chunk: too small');
43
60
  }
44
61
 
45
62
  const magic = encBuffer.subarray(0, 4);
46
- if (!magic.equals(MAGIC)) {
47
- throw new Error('Invalid chunk: bad magic (expected CK01)');
63
+
64
+ if (magic.equals(MAGIC_CK02)) {
65
+ if (!key) throw new Error('CK02 chunk requires an encryption key (not a password)');
66
+ return decryptChunkWithKey(encBuffer, key);
48
67
  }
49
68
 
50
- const salt = encBuffer.subarray(4, 4 + SALT_LENGTH);
51
- const nonce = encBuffer.subarray(4 + SALT_LENGTH, HEADER_LENGTH);
52
- const ciphertext = encBuffer.subarray(HEADER_LENGTH, encBuffer.length - TAG_LENGTH);
69
+ if (magic.equals(MAGIC_CK01)) {
70
+ if (!password) throw new Error('CK01 chunk requires a password');
71
+ if (encBuffer.length < CK01_HEADER_LENGTH + TAG_LENGTH) {
72
+ throw new Error('Invalid CK01 chunk: too small');
73
+ }
74
+ const salt = encBuffer.subarray(4, 4 + SALT_LENGTH);
75
+ const nonce = encBuffer.subarray(4 + SALT_LENGTH, CK01_HEADER_LENGTH);
76
+ const ciphertext = encBuffer.subarray(CK01_HEADER_LENGTH, encBuffer.length - TAG_LENGTH);
77
+ const tag = encBuffer.subarray(encBuffer.length - TAG_LENGTH);
78
+
79
+ const derivedKey = deriveKey(password, salt);
80
+ const decipher = crypto.createDecipheriv(ALGORITHM, derivedKey, nonce);
81
+ decipher.setAuthTag(tag);
82
+
83
+ try {
84
+ return Buffer.concat([decipher.update(ciphertext), decipher.final()]);
85
+ } catch (e) {
86
+ throw new Error('Decryption failed — wrong password or corrupted chunk');
87
+ }
88
+ }
89
+
90
+ throw new Error('Invalid chunk: unknown magic bytes');
91
+ }
92
+
93
+ /**
94
+ * Decrypt a CK02-format buffer with a pre-derived key.
95
+ */
96
+ function decryptChunkWithKey(encBuffer, key) {
97
+ const nonce = encBuffer.subarray(4, 4 + NONCE_LENGTH);
53
98
  const tag = encBuffer.subarray(encBuffer.length - TAG_LENGTH);
99
+ const ciphertext = encBuffer.subarray(CK02_HEADER_LENGTH, encBuffer.length - TAG_LENGTH);
54
100
 
55
- const key = deriveKey(password, salt);
56
101
  const decipher = crypto.createDecipheriv(ALGORITHM, key, nonce);
57
102
  decipher.setAuthTag(tag);
58
103
 
59
104
  try {
60
105
  return Buffer.concat([decipher.update(ciphertext), decipher.final()]);
61
106
  } catch (e) {
62
- throw new Error('Decryption failed — wrong password or corrupted chunk');
107
+ throw new Error('Decryption failed — wrong key or corrupted chunk');
63
108
  }
64
109
  }
65
110
 
111
+ /**
112
+ * Derive a deterministic encryption key from a password using HKDF-SHA256.
113
+ * This key is stored wrapped in config, so the daemon doesn't need the password.
114
+ */
115
+ function deriveEncryptionKey(password) {
116
+ // HKDF extract: PRK = HMAC-SHA256(salt, IKM)
117
+ const prk = crypto.createHmac('sha256', 'clawkeep').update(password).digest();
118
+ // HKDF expand: OKM = HMAC-SHA256(PRK, info || 0x01)
119
+ const info = Buffer.from('clawkeep-encryption');
120
+ const t = crypto.createHmac('sha256', prk).update(Buffer.concat([info, Buffer.from([1])])).digest();
121
+ return t.subarray(0, 32);
122
+ }
123
+
124
+ /**
125
+ * Wrap an encryption key using AES-256-GCM with the password hash as wrapping key.
126
+ * @param {Buffer} encryptionKey - The key to wrap
127
+ * @param {string} passwordHash - "$scrypt$<salt-hex>$<hash-hex>" format
128
+ * @returns {string} Base64-encoded wrapped key: [12B nonce][encrypted][16B tag]
129
+ */
130
+ function wrapKey(encryptionKey, passwordHash) {
131
+ const hashBytes = Buffer.from(passwordHash.split('$')[3], 'hex');
132
+ const nonce = crypto.randomBytes(NONCE_LENGTH);
133
+ const cipher = crypto.createCipheriv(ALGORITHM, hashBytes, nonce);
134
+ const enc = Buffer.concat([cipher.update(encryptionKey), cipher.final()]);
135
+ const tag = cipher.getAuthTag();
136
+ return Buffer.concat([nonce, enc, tag]).toString('base64');
137
+ }
138
+
139
+ /**
140
+ * Unwrap an encryption key using AES-256-GCM with the password hash.
141
+ * @param {string} wrappedKeyB64 - Base64-encoded wrapped key
142
+ * @param {string} passwordHash - "$scrypt$<salt-hex>$<hash-hex>" format
143
+ * @returns {Buffer} The unwrapped 32-byte encryption key
144
+ */
145
+ function unwrapKey(wrappedKeyB64, passwordHash) {
146
+ const hashBytes = Buffer.from(passwordHash.split('$')[3], 'hex');
147
+ const buf = Buffer.from(wrappedKeyB64, 'base64');
148
+ const nonce = buf.subarray(0, NONCE_LENGTH);
149
+ const tag = buf.subarray(buf.length - TAG_LENGTH);
150
+ const enc = buf.subarray(NONCE_LENGTH, buf.length - TAG_LENGTH);
151
+ const decipher = crypto.createDecipheriv(ALGORITHM, hashBytes, nonce);
152
+ decipher.setAuthTag(tag);
153
+ return Buffer.concat([decipher.update(enc), decipher.final()]);
154
+ }
155
+
66
156
  /**
67
157
  * Hash a password for storage (verification only, not the password itself).
68
158
  * Format: $scrypt$<salt-hex>$<hash-hex>
@@ -87,4 +177,14 @@ function verifyPassword(password, storedHash) {
87
177
  return crypto.timingEqual(derived, expected);
88
178
  }
89
179
 
90
- module.exports = { encryptChunk, decryptChunk, hashPassword, verifyPassword };
180
+ module.exports = {
181
+ encryptChunk,
182
+ encryptChunkWithKey,
183
+ decryptChunk,
184
+ decryptChunkWithKey,
185
+ deriveEncryptionKey,
186
+ wrapKey,
187
+ unwrapKey,
188
+ hashPassword,
189
+ verifyPassword,
190
+ };
package/src/core/sync.js CHANGED
@@ -4,19 +4,28 @@ const crypto = require('crypto');
4
4
  const path = require('path');
5
5
  const fs = require('fs');
6
6
  const os = require('os');
7
- const { encryptChunk, decryptChunk } = require('./sync-crypto');
7
+ const { encryptChunk, encryptChunkWithKey, decryptChunk } = require('./sync-crypto');
8
8
 
9
9
  /**
10
10
  * SyncManager — manages encrypted incremental chunk-based sync.
11
11
  *
12
12
  * Handles manifest management, git bundle creation, incremental logic,
13
13
  * restore from backup, and compaction.
14
+ *
15
+ * Accepts a credential object: { password } for CK01 or { encryptionKey } for CK02.
14
16
  */
15
17
  class SyncManager {
16
- constructor(clawGit, transport, password) {
18
+ constructor(clawGit, transport, credential) {
17
19
  this.claw = clawGit;
18
20
  this.transport = transport;
19
- this.password = password;
21
+ // credential: { password } OR { encryptionKey: Buffer }
22
+ if (credential.encryptionKey) {
23
+ this.encryptionKey = credential.encryptionKey;
24
+ this.password = null;
25
+ } else {
26
+ this.password = credential.password;
27
+ this.encryptionKey = null;
28
+ }
20
29
  }
21
30
 
22
31
  /**
@@ -44,6 +53,23 @@ class SyncManager {
44
53
  return 'chunk-' + String(num).padStart(6, '0') + '.enc';
45
54
  }
46
55
 
56
+ /**
57
+ * Encrypt a buffer using the appropriate method (CK02 if key, CK01 if password).
58
+ */
59
+ _encrypt(buffer) {
60
+ if (this.encryptionKey) {
61
+ return encryptChunkWithKey(buffer, this.encryptionKey);
62
+ }
63
+ return encryptChunk(buffer, this.password);
64
+ }
65
+
66
+ /**
67
+ * Decrypt a buffer. Handles CK01 and CK02 auto-detection.
68
+ */
69
+ _decrypt(encBuffer) {
70
+ return decryptChunk(encBuffer, this.password, this.encryptionKey);
71
+ }
72
+
47
73
  /**
48
74
  * Read and decrypt manifest from remote. Returns null if not found.
49
75
  */
@@ -53,7 +79,7 @@ class SyncManager {
53
79
  if (!exists) return null;
54
80
 
55
81
  const encData = await this.transport.readFile(manifestPath);
56
- const data = decryptChunk(encData, this.password);
82
+ const data = this._decrypt(encData);
57
83
  return JSON.parse(data.toString('utf8'));
58
84
  }
59
85
 
@@ -62,7 +88,7 @@ class SyncManager {
62
88
  */
63
89
  async _writeManifest(workspaceId, manifest) {
64
90
  const data = Buffer.from(JSON.stringify(manifest, null, 2), 'utf8');
65
- const encrypted = encryptChunk(data, this.password);
91
+ const encrypted = this._encrypt(data);
66
92
  await this.transport.writeFile(workspaceId + '/manifest.enc', encrypted);
67
93
  }
68
94
 
@@ -173,7 +199,7 @@ class SyncManager {
173
199
  // Encrypt and write chunk
174
200
  const chunkNum = manifest.chunks.length + 1;
175
201
  const chunkId = this._chunkName(chunkNum);
176
- const encrypted = encryptChunk(bundleBuffer, this.password);
202
+ const encrypted = this._encrypt(bundleBuffer);
177
203
  await this.transport.writeFile(workspaceId + '/' + chunkId, encrypted);
178
204
 
179
205
  // Count commits in this chunk
@@ -227,7 +253,7 @@ class SyncManager {
227
253
  const bundlePaths = [];
228
254
  for (const chunk of manifest.chunks) {
229
255
  const encData = await this.transport.readFile(workspaceId + '/' + chunk.id);
230
- const bundle = decryptChunk(encData, this.password);
256
+ const bundle = this._decrypt(encData);
231
257
  const bundlePath = path.join(tmpDir, chunk.id.replace('.enc', '.bundle'));
232
258
  fs.writeFileSync(bundlePath, bundle);
233
259
  bundlePaths.push(bundlePath);
@@ -262,11 +288,15 @@ class SyncManager {
262
288
  /**
263
289
  * Static restore: restore from a backup path without needing an initialized repo.
264
290
  * Used when restoring to a brand new directory.
291
+ * @param {object} credential - { password } or { encryptionKey }
265
292
  */
266
- static async restoreFrom(transport, workspaceId, password, destDir) {
293
+ static async restoreFrom(transport, workspaceId, credential, destDir) {
294
+ const password = credential.password || null;
295
+ const key = credential.encryptionKey || null;
296
+
267
297
  const manifestPath = workspaceId + '/manifest.enc';
268
298
  const encManifest = await transport.readFile(manifestPath);
269
- const manifest = JSON.parse(decryptChunk(encManifest, password).toString('utf8'));
299
+ const manifest = JSON.parse(decryptChunk(encManifest, password, key).toString('utf8'));
270
300
 
271
301
  if (!manifest.chunks.length) throw new Error('Backup has no chunks');
272
302
 
@@ -278,7 +308,7 @@ class SyncManager {
278
308
  const bundlePaths = [];
279
309
  for (const chunk of manifest.chunks) {
280
310
  const encData = await transport.readFile(workspaceId + '/' + chunk.id);
281
- const bundle = decryptChunk(encData, password);
311
+ const bundle = decryptChunk(encData, password, key);
282
312
  const bundlePath = path.join(tmpDir, chunk.id.replace('.enc', '.bundle'));
283
313
  fs.writeFileSync(bundlePath, bundle);
284
314
  bundlePaths.push(bundlePath);
@@ -324,7 +354,7 @@ class SyncManager {
324
354
  const bundlePaths = [];
325
355
  for (const chunk of manifest.chunks) {
326
356
  const encData = await this.transport.readFile(workspaceId + '/' + chunk.id);
327
- const bundle = decryptChunk(encData, this.password);
357
+ const bundle = this._decrypt(encData);
328
358
  const bundlePath = path.join(tmpDir, chunk.id.replace('.enc', '.bundle'));
329
359
  fs.writeFileSync(bundlePath, bundle);
330
360
  bundlePaths.push(bundlePath);
@@ -351,7 +381,7 @@ class SyncManager {
351
381
  }
352
382
 
353
383
  // Encrypt and write new full chunk
354
- const encrypted = encryptChunk(fullBundleBuffer, this.password);
384
+ const encrypted = this._encrypt(fullBundleBuffer);
355
385
  const newChunkId = this._chunkName(1);
356
386
  await this.transport.writeFile(workspaceId + '/' + newChunkId, encrypted);
357
387