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 +44 -2
- package/package.json +1 -1
- package/src/commands/backup.js +7 -3
- package/src/commands/cloud.js +10 -1
- package/src/core/backup.js +38 -12
- package/src/core/sync-crypto.js +117 -17
- package/src/core/sync.js +42 -12
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** |
|
|
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 --
|
|
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
package/src/commands/backup.js
CHANGED
|
@@ -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
|
|
package/src/commands/cloud.js
CHANGED
|
@@ -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
|
package/src/core/backup.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
171
|
+
const credential = this._resolveCredential(password);
|
|
147
172
|
transport = createTransport(backup, this.claw);
|
|
148
|
-
const sm = new SyncManager(this.claw, transport,
|
|
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,
|
|
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 (
|
|
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,
|
|
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
|
|
package/src/core/sync-crypto.js
CHANGED
|
@@ -2,13 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
const crypto = require('crypto');
|
|
4
4
|
|
|
5
|
-
const
|
|
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
|
|
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
|
|
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([
|
|
35
|
+
return Buffer.concat([MAGIC_CK01, salt, nonce, encrypted, tag]);
|
|
34
36
|
}
|
|
35
37
|
|
|
36
38
|
/**
|
|
37
|
-
*
|
|
38
|
-
*
|
|
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
|
|
41
|
-
|
|
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
|
-
|
|
47
|
-
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
|
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 = {
|
|
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,
|
|
18
|
+
constructor(clawGit, transport, credential) {
|
|
17
19
|
this.claw = clawGit;
|
|
18
20
|
this.transport = transport;
|
|
19
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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,
|
|
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 =
|
|
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 =
|
|
384
|
+
const encrypted = this._encrypt(fullBundleBuffer);
|
|
355
385
|
const newChunkId = this._chunkName(1);
|
|
356
386
|
await this.transport.writeFile(workspaceId + '/' + newChunkId, encrypted);
|
|
357
387
|
|