quilltap 4.9.0-dev → 4.9.0-dev.102
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 +5 -1
- package/bin/quilltap.js +42 -18
- package/lib/__tests__/completion-behavior.test.js +162 -0
- package/lib/__tests__/completion-coverage.test.js +95 -0
- package/lib/__tests__/dbkey-restore.test.js +162 -0
- package/lib/completion/bash.template +118 -26
- package/lib/completion/fish.template +18 -2
- package/lib/completion/zsh.template +340 -100
- package/lib/db-helpers.js +9 -32
- package/lib/dbkey-restore.js +347 -0
- package/lib/dbkey.js +188 -0
- package/lib/instances-commands.js +48 -0
- package/lib/instances.js +6 -30
- package/lib/lock-helpers.js +2 -4
- package/package.json +1 -1
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* `quilltap instances restore-key` — rebuild a lost `.dbkey`, or re-wrap an
|
|
5
|
+
* existing one under a different passphrase, from the pepper itself.
|
|
6
|
+
*
|
|
7
|
+
* The pepper IS the database key; the `.dbkey` file is only a wrapper around
|
|
8
|
+
* it. So an operator who kept the pepper printed at first-run setup (or who
|
|
9
|
+
* runs with `ENCRYPTION_MASTER_PEPPER` in the environment) can always rebuild
|
|
10
|
+
* the wrapper — including under a new passphrase, when the old one is gone.
|
|
11
|
+
* The server can already do this for the *lost-file* half through
|
|
12
|
+
* `/api/v1/system/unlock?action=store`, but only while it is running and only
|
|
13
|
+
* from the env var; a forgotten passphrase leaves it stuck in locked mode with
|
|
14
|
+
* no way in. This is the offline twin, and it also covers the re-wrap.
|
|
15
|
+
*
|
|
16
|
+
* The load-bearing safety property: a `.dbkey` holding the WRONG pepper is
|
|
17
|
+
* worse than no `.dbkey` at all — the server unwraps it, hands SQLCipher a key
|
|
18
|
+
* that decrypts nothing, and reports what looks like a corrupt database (or,
|
|
19
|
+
* with the env var also set, exits fatally on the hash mismatch). So the
|
|
20
|
+
* candidate pepper is proved against the encrypted databases on disk BEFORE
|
|
21
|
+
* anything is written, and that proof cannot be waived while an encrypted
|
|
22
|
+
* database exists to check.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
const fs = require('fs');
|
|
26
|
+
const path = require('path');
|
|
27
|
+
const readline = require('readline');
|
|
28
|
+
|
|
29
|
+
const {
|
|
30
|
+
INTERNAL_PASSPHRASE,
|
|
31
|
+
getDbKeyPath,
|
|
32
|
+
hashPepper,
|
|
33
|
+
readDbKeyFile,
|
|
34
|
+
tryDecryptDbKey,
|
|
35
|
+
encryptDbKey,
|
|
36
|
+
preserveExtraFields,
|
|
37
|
+
writeDbKeyFile,
|
|
38
|
+
} = require('./dbkey');
|
|
39
|
+
const { promptPassphrase, openEncryptedDb } = require('./db-helpers');
|
|
40
|
+
const { acquireWriteLock, releaseWriteLock } = require('./lock-helpers');
|
|
41
|
+
const { resolveInstance, expandPath, setInstancePassphrase, readInstances } = require('./instances');
|
|
42
|
+
|
|
43
|
+
/** Mirror of lib/startup/db-encryption-state.ts. */
|
|
44
|
+
const SQLITE_MAGIC = 'SQLite format 3\0';
|
|
45
|
+
|
|
46
|
+
/** The three databases the one pepper opens, main first — it is the authority. */
|
|
47
|
+
const DATABASES = [
|
|
48
|
+
{ filename: 'quilltap.db', label: 'main database' },
|
|
49
|
+
{ filename: 'quilltap-llm-logs.db', label: 'LLM logs database' },
|
|
50
|
+
{ filename: 'quilltap-mount-index.db', label: 'mount index database' },
|
|
51
|
+
];
|
|
52
|
+
|
|
53
|
+
function promptLine(prompt) {
|
|
54
|
+
return new Promise((resolve) => {
|
|
55
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
56
|
+
let answered = false;
|
|
57
|
+
// A closed stdin (piped, /dev/null, CI) never fires `question` — treat the
|
|
58
|
+
// EOF as an empty answer so the caller's default (no) still applies.
|
|
59
|
+
rl.on('close', () => {
|
|
60
|
+
if (!answered) {
|
|
61
|
+
answered = true;
|
|
62
|
+
resolve('');
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
rl.question(prompt, (answer) => {
|
|
66
|
+
answered = true;
|
|
67
|
+
rl.close();
|
|
68
|
+
resolve(answer);
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function confirm(question, assumeYes) {
|
|
74
|
+
if (assumeYes) return true;
|
|
75
|
+
const answer = (await promptLine(`${question} [y/N] `)).trim().toLowerCase();
|
|
76
|
+
return answer === 'y' || answer === 'yes';
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* 'encrypted' | 'plaintext' | 'absent' — by file header, exactly as the server
|
|
81
|
+
* decides whether a database still needs converting.
|
|
82
|
+
*/
|
|
83
|
+
function databaseState(dbPath) {
|
|
84
|
+
if (!fs.existsSync(dbPath)) return 'absent';
|
|
85
|
+
const fd = fs.openSync(dbPath, 'r');
|
|
86
|
+
try {
|
|
87
|
+
const header = Buffer.alloc(16);
|
|
88
|
+
// Read into the Buffer itself — `new Uint8Array(buf)` would COPY it and
|
|
89
|
+
// the read would land in the copy, leaving `header` all zeroes (which
|
|
90
|
+
// reads as "encrypted" for every file on disk).
|
|
91
|
+
const read = fs.readSync(fd, header, 0, 16, 0);
|
|
92
|
+
if (read < 16) return 'plaintext';
|
|
93
|
+
return header.toString('utf8') === SQLITE_MAGIC ? 'plaintext' : 'encrypted';
|
|
94
|
+
} finally {
|
|
95
|
+
fs.closeSync(fd);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Prove the candidate pepper against every encrypted database in `dataDir`.
|
|
101
|
+
*
|
|
102
|
+
* Returns `{ proved, results }` — `proved` is true only when at least one
|
|
103
|
+
* encrypted database opened AND none that was encrypted failed. Opening is
|
|
104
|
+
* read-only and reads the schema page, which is what actually exercises the
|
|
105
|
+
* key.
|
|
106
|
+
*/
|
|
107
|
+
function provePepper(dataDir, pepper) {
|
|
108
|
+
const results = [];
|
|
109
|
+
for (const { filename, label } of DATABASES) {
|
|
110
|
+
const dbPath = path.join(dataDir, filename);
|
|
111
|
+
const state = databaseState(dbPath);
|
|
112
|
+
if (state !== 'encrypted') {
|
|
113
|
+
results.push({ label, filename, state, ok: null });
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
let db;
|
|
117
|
+
try {
|
|
118
|
+
db = openEncryptedDb(dbPath, pepper, { readonly: true, friendlyName: label });
|
|
119
|
+
db.prepare('SELECT count(*) AS n FROM sqlite_master').get();
|
|
120
|
+
results.push({ label, filename, state, ok: true });
|
|
121
|
+
} catch (err) {
|
|
122
|
+
results.push({ label, filename, state, ok: false, error: err.message });
|
|
123
|
+
} finally {
|
|
124
|
+
if (db) {
|
|
125
|
+
try { db.close(); } catch { /* best effort */ }
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
const checked = results.filter((r) => r.ok !== null);
|
|
130
|
+
const proved = checked.length > 0 && checked.every((r) => r.ok === true);
|
|
131
|
+
return { proved, results };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Back an existing key file up beside itself, returning the backup path. */
|
|
135
|
+
function backupDbKey(dataDir) {
|
|
136
|
+
const dbkeyPath = getDbKeyPath(dataDir);
|
|
137
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
138
|
+
const backupPath = `${dbkeyPath}.bak-${stamp}`;
|
|
139
|
+
fs.copyFileSync(dbkeyPath, backupPath);
|
|
140
|
+
try { fs.chmodSync(backupPath, 0o600); } catch { /* best effort */ }
|
|
141
|
+
return backupPath;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Resolve the target data directory from either a registered instance name or
|
|
146
|
+
* an explicit instance root, returning the registry name when there is one so
|
|
147
|
+
* a stored passphrase can be kept honest afterwards.
|
|
148
|
+
*/
|
|
149
|
+
function resolveTarget(name, dataDirFlag) {
|
|
150
|
+
if (name && dataDirFlag) {
|
|
151
|
+
throw new Error('Specify either an instance name or --data-dir, not both.');
|
|
152
|
+
}
|
|
153
|
+
if (name) {
|
|
154
|
+
const inst = resolveInstance(name);
|
|
155
|
+
return { dataDir: path.join(inst.path, 'data'), instanceName: inst.name, root: inst.path };
|
|
156
|
+
}
|
|
157
|
+
if (!dataDirFlag) {
|
|
158
|
+
throw new Error('Usage: quilltap instances restore-key <name> | --data-dir <instance-root>');
|
|
159
|
+
}
|
|
160
|
+
const root = expandPath(dataDirFlag);
|
|
161
|
+
// Accept either the instance root or the data directory itself.
|
|
162
|
+
const dataDir = path.basename(root) === 'data' ? root : path.join(root, 'data');
|
|
163
|
+
const registry = readInstances();
|
|
164
|
+
const match = Object.entries(registry.instances || {}).find(
|
|
165
|
+
([, entry]) => expandPath(entry.path) === expandPath(path.dirname(dataDir)),
|
|
166
|
+
);
|
|
167
|
+
return { dataDir, instanceName: match ? match[0] : null, root: path.dirname(dataDir) };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Rebuild `<dataDir>/quilltap.dbkey` from the pepper.
|
|
172
|
+
*
|
|
173
|
+
* @param {object} opts
|
|
174
|
+
* @param {string} opts.name Registered instance name (optional)
|
|
175
|
+
* @param {string} opts.dataDir Instance root or data dir (optional)
|
|
176
|
+
* @param {string} opts.passphrase Passphrase for the new file (non-interactive)
|
|
177
|
+
* @param {boolean} opts.noPassphrase Write with no user passphrase, don't prompt
|
|
178
|
+
* @param {boolean} opts.force Proceed when no encrypted database exists to prove against
|
|
179
|
+
* @param {boolean} opts.yes Skip confirmation prompts
|
|
180
|
+
*/
|
|
181
|
+
async function restoreKey(opts) {
|
|
182
|
+
const target = resolveTarget(opts.name, opts.dataDir);
|
|
183
|
+
const { dataDir } = target;
|
|
184
|
+
|
|
185
|
+
if (!fs.existsSync(dataDir)) {
|
|
186
|
+
throw new Error(`Data directory does not exist: ${dataDir}`);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
console.log(`Instance: ${target.instanceName || '(unregistered)'}`);
|
|
190
|
+
console.log(`Data dir: ${dataDir}`);
|
|
191
|
+
|
|
192
|
+
// The server keeps the pepper and the effective passphrase in memory; a file
|
|
193
|
+
// rewritten underneath it would leave both stale (archive encryption reads
|
|
194
|
+
// the cached passphrase). Recovery happens with the instance down.
|
|
195
|
+
acquireWriteLock(dataDir);
|
|
196
|
+
|
|
197
|
+
try {
|
|
198
|
+
// ---- 1. The pepper. Never a flag: a command line lands in shell history
|
|
199
|
+
// and in `ps`. Environment or hidden prompt only.
|
|
200
|
+
let pepper = (process.env.ENCRYPTION_MASTER_PEPPER || '').trim();
|
|
201
|
+
if (pepper) {
|
|
202
|
+
console.log('Pepper: from ENCRYPTION_MASTER_PEPPER');
|
|
203
|
+
} else {
|
|
204
|
+
pepper = (await promptPassphrase('Encryption master pepper (hidden): ')).trim();
|
|
205
|
+
if (!pepper) {
|
|
206
|
+
throw new Error('No pepper provided. Set ENCRYPTION_MASTER_PEPPER or paste it at the prompt.');
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
if (Buffer.from(pepper, 'base64').length !== 32) {
|
|
210
|
+
console.log('Warning: that does not look like a Quilltap pepper (44-char base64 of 32 bytes).');
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// ---- 2. Prove it against the databases before writing anything.
|
|
214
|
+
const { proved, results } = provePepper(dataDir, pepper);
|
|
215
|
+
console.log('');
|
|
216
|
+
for (const r of results) {
|
|
217
|
+
if (r.state === 'absent') {
|
|
218
|
+
console.log(` ${r.filename.padEnd(28)} absent`);
|
|
219
|
+
} else if (r.state === 'plaintext') {
|
|
220
|
+
console.log(` ${r.filename.padEnd(28)} unencrypted (nothing to check against)`);
|
|
221
|
+
} else if (r.ok) {
|
|
222
|
+
console.log(` ${r.filename.padEnd(28)} opens with this pepper ✓`);
|
|
223
|
+
} else {
|
|
224
|
+
console.log(` ${r.filename.padEnd(28)} DOES NOT OPEN — ${r.error.split('\n')[0]}`);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
console.log('');
|
|
228
|
+
|
|
229
|
+
const failures = results.filter((r) => r.ok === false);
|
|
230
|
+
if (failures.length > 0) {
|
|
231
|
+
throw new Error(
|
|
232
|
+
'This pepper does not open the databases on disk. Refusing to write a .dbkey that\n' +
|
|
233
|
+
'would make an intact instance look corrupt. If the files are cloud-evicted rather\n' +
|
|
234
|
+
'than wrongly keyed, run `quilltap file-verify` first and try again.',
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
if (!proved) {
|
|
238
|
+
console.log('No encrypted database exists here, so the pepper cannot be proved.');
|
|
239
|
+
if (results.some((r) => r.state === 'plaintext')) {
|
|
240
|
+
console.log('The databases are still unencrypted — the server will encrypt them with');
|
|
241
|
+
console.log('this pepper on its next start, whether or not it is the original one.');
|
|
242
|
+
}
|
|
243
|
+
if (!opts.force && !(await confirm('Write the .dbkey anyway?', opts.yes))) {
|
|
244
|
+
throw new Error('Aborted.');
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// ---- 3. What is already on disk.
|
|
249
|
+
const existing = readDbKeyFile(dataDir);
|
|
250
|
+
let replacingDifferentPepper = false;
|
|
251
|
+
if (existing) {
|
|
252
|
+
if (existing.pepperHash === hashPepper(pepper)) {
|
|
253
|
+
console.log('An existing .dbkey already holds this pepper — rewrapping it.');
|
|
254
|
+
} else {
|
|
255
|
+
replacingDifferentPepper = true;
|
|
256
|
+
console.log('WARNING: the existing .dbkey holds a DIFFERENT pepper than the one given.');
|
|
257
|
+
if (proved) {
|
|
258
|
+
console.log('The databases opened with the new one, so the file on disk is the stale part.');
|
|
259
|
+
}
|
|
260
|
+
if (!(await confirm('Replace it? (a timestamped backup is kept)', opts.yes))) {
|
|
261
|
+
throw new Error('Aborted.');
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// ---- 4. The passphrase for the rebuilt file.
|
|
267
|
+
let newPassphrase;
|
|
268
|
+
if (opts.noPassphrase) {
|
|
269
|
+
newPassphrase = '';
|
|
270
|
+
} else if (opts.passphrase !== undefined && opts.passphrase !== '') {
|
|
271
|
+
newPassphrase = opts.passphrase;
|
|
272
|
+
} else if (opts.yes) {
|
|
273
|
+
newPassphrase = '';
|
|
274
|
+
} else {
|
|
275
|
+
newPassphrase = await promptPassphrase('Passphrase for the rebuilt .dbkey (blank for none): ');
|
|
276
|
+
if (newPassphrase) {
|
|
277
|
+
const again = await promptPassphrase('Confirm passphrase: ');
|
|
278
|
+
if (again !== newPassphrase) {
|
|
279
|
+
throw new Error('Passphrases did not match.');
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const hadUserPassphrase = existing
|
|
285
|
+
? tryDecryptDbKey(existing, INTERNAL_PASSPHRASE) === null
|
|
286
|
+
: false;
|
|
287
|
+
const effective = newPassphrase.length > 0 ? newPassphrase : INTERNAL_PASSPHRASE;
|
|
288
|
+
|
|
289
|
+
// ---- 5. Write, then read back and prove the round trip.
|
|
290
|
+
let backupPath = null;
|
|
291
|
+
if (existing) {
|
|
292
|
+
backupPath = backupDbKey(dataDir);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// `minServerVersion` (the Electron shell's version floor) rides along in
|
|
296
|
+
// the same file without belonging to the wrapping — carry it, and anything
|
|
297
|
+
// like it, across the rebuild.
|
|
298
|
+
writeDbKeyFile(dataDir, preserveExtraFields(existing, encryptDbKey(pepper, effective)));
|
|
299
|
+
|
|
300
|
+
const written = readDbKeyFile(dataDir);
|
|
301
|
+
const roundTrip = written && tryDecryptDbKey(written, effective);
|
|
302
|
+
if (roundTrip !== pepper) {
|
|
303
|
+
if (backupPath) {
|
|
304
|
+
fs.copyFileSync(backupPath, getDbKeyPath(dataDir));
|
|
305
|
+
}
|
|
306
|
+
throw new Error('Wrote the .dbkey but could not read the pepper back — restored the previous file.');
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
console.log('');
|
|
310
|
+
console.log(`Wrote ${getDbKeyPath(dataDir)} (mode 0600).`);
|
|
311
|
+
if (backupPath) {
|
|
312
|
+
console.log(`Previous file kept at ${path.basename(backupPath)}.`);
|
|
313
|
+
}
|
|
314
|
+
console.log(
|
|
315
|
+
newPassphrase
|
|
316
|
+
? 'The instance now unlocks with the passphrase you just set.'
|
|
317
|
+
: 'The instance now opens with no passphrase.',
|
|
318
|
+
);
|
|
319
|
+
|
|
320
|
+
// ---- 6. Keep the registry's stored passphrase honest.
|
|
321
|
+
if (target.instanceName) {
|
|
322
|
+
setInstancePassphrase(target.instanceName, newPassphrase);
|
|
323
|
+
console.log(
|
|
324
|
+
newPassphrase
|
|
325
|
+
? `Updated the stored passphrase for "${target.instanceName}".`
|
|
326
|
+
: `Cleared the stored passphrase for "${target.instanceName}".`,
|
|
327
|
+
);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// ---- 7. The one thing a re-wrap does NOT carry with it.
|
|
331
|
+
const passphraseChanged =
|
|
332
|
+
replacingDifferentPepper ||
|
|
333
|
+
hadUserPassphrase !== (newPassphrase.length > 0) ||
|
|
334
|
+
(hadUserPassphrase && newPassphrase.length > 0);
|
|
335
|
+
if (existing && passphraseChanged) {
|
|
336
|
+
console.log('');
|
|
337
|
+
console.log('Note: character ARCHIVE bundles in files/ are encrypted with the passphrase,');
|
|
338
|
+
console.log('not the pepper. This command does not rewrite them — bundles made under the');
|
|
339
|
+
console.log('old passphrase still want the old one. The server\'s Change Passphrase card');
|
|
340
|
+
console.log('re-encrypts them; this offline path cannot.');
|
|
341
|
+
}
|
|
342
|
+
} finally {
|
|
343
|
+
releaseWriteLock(dataDir);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
module.exports = { restoreKey, provePepper, databaseState };
|
package/lib/dbkey.js
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* `.dbkey` file primitives for the CLI.
|
|
5
|
+
*
|
|
6
|
+
* The instance's one and only `quilltap.dbkey` wraps the pepper — the actual
|
|
7
|
+
* SQLCipher key for all three databases — in AES-256-GCM under a PBKDF2 key
|
|
8
|
+
* derived from the operator's passphrase (or an internal sentinel when no
|
|
9
|
+
* passphrase is set). The CLI is plain Node and cannot import the TypeScript
|
|
10
|
+
* source of truth at `lib/startup/dbkey.ts` + `lib/startup/pepper-crypto.ts`,
|
|
11
|
+
* so the format and constants below MIRROR it. Keep them in sync.
|
|
12
|
+
*
|
|
13
|
+
* Three call sites needed this unwrap — `db-helpers.loadDbKey`,
|
|
14
|
+
* `instances.verifyPassphrase`, and the `instances restore-key` rebuild — so it
|
|
15
|
+
* lives here once rather than three times.
|
|
16
|
+
*
|
|
17
|
+
* Reading params off the file (rather than the constants) is deliberate: it is
|
|
18
|
+
* what keeps older `.dbkey` files decryptable after a parameter upgrade. The
|
|
19
|
+
* constants are used only when writing a fresh file.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const crypto = require('crypto');
|
|
23
|
+
const fs = require('fs');
|
|
24
|
+
const path = require('path');
|
|
25
|
+
|
|
26
|
+
/** The instance's single key file. One pepper per instance. */
|
|
27
|
+
const DBKEY_FILENAME = 'quilltap.dbkey';
|
|
28
|
+
|
|
29
|
+
/** Sentinel used to wrap the pepper when the operator sets no passphrase. */
|
|
30
|
+
const INTERNAL_PASSPHRASE = '__quilltap_no_passphrase__';
|
|
31
|
+
|
|
32
|
+
// Mirror of the write-side constants in lib/startup/dbkey.ts.
|
|
33
|
+
const ALGORITHM = 'aes-256-gcm';
|
|
34
|
+
const KEY_LENGTH = 32;
|
|
35
|
+
const IV_LENGTH = 16;
|
|
36
|
+
const SALT_LENGTH = 32;
|
|
37
|
+
const PBKDF2_ITERATIONS = 600000;
|
|
38
|
+
const PBKDF2_DIGEST = 'sha256';
|
|
39
|
+
|
|
40
|
+
function getDbKeyPath(dataDir) {
|
|
41
|
+
return path.join(dataDir, DBKEY_FILENAME);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** SHA-256 of the plaintext pepper, hex — the file's `pepperHash` field. */
|
|
45
|
+
function hashPepper(pepper) {
|
|
46
|
+
return crypto.createHash('sha256').update(pepper).digest('hex');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Read and parse `<dataDir>/quilltap.dbkey`, returning null when absent.
|
|
51
|
+
*
|
|
52
|
+
* Strips the legacy `hasPassphrase` flag in passing (it leaked whether a user
|
|
53
|
+
* passphrase was set), rewriting the file the same way the server does.
|
|
54
|
+
*/
|
|
55
|
+
function readDbKeyFile(dataDir) {
|
|
56
|
+
const dbkeyPath = getDbKeyPath(dataDir);
|
|
57
|
+
if (!fs.existsSync(dbkeyPath)) return null;
|
|
58
|
+
const data = JSON.parse(fs.readFileSync(dbkeyPath, 'utf8'));
|
|
59
|
+
if ('hasPassphrase' in data) {
|
|
60
|
+
delete data.hasPassphrase;
|
|
61
|
+
fs.writeFileSync(dbkeyPath, JSON.stringify(data, null, 2), { mode: 0o600 });
|
|
62
|
+
}
|
|
63
|
+
return data;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Unwrap the pepper from parsed `.dbkey` contents.
|
|
68
|
+
*
|
|
69
|
+
* Throws on a wrong passphrase, a tampered file, or a pepper whose hash does
|
|
70
|
+
* not match the one recorded alongside it.
|
|
71
|
+
*/
|
|
72
|
+
function decryptDbKey(data, passphrase) {
|
|
73
|
+
const salt = Buffer.from(data.salt, 'hex');
|
|
74
|
+
const key = crypto.pbkdf2Sync(
|
|
75
|
+
passphrase,
|
|
76
|
+
new Uint8Array(salt),
|
|
77
|
+
data.kdfIterations,
|
|
78
|
+
KEY_LENGTH,
|
|
79
|
+
data.kdfDigest,
|
|
80
|
+
);
|
|
81
|
+
const iv = Buffer.from(data.iv, 'hex');
|
|
82
|
+
const decipher = crypto.createDecipheriv(data.algorithm, new Uint8Array(key), new Uint8Array(iv));
|
|
83
|
+
decipher.setAuthTag(new Uint8Array(Buffer.from(data.authTag, 'hex')));
|
|
84
|
+
let plaintext = decipher.update(data.ciphertext, 'hex', 'utf8');
|
|
85
|
+
plaintext += decipher.final('utf8');
|
|
86
|
+
if (hashPepper(plaintext) !== data.pepperHash) {
|
|
87
|
+
throw new Error('Pepper hash mismatch');
|
|
88
|
+
}
|
|
89
|
+
return plaintext;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Unwrap, returning null instead of throwing. */
|
|
93
|
+
function tryDecryptDbKey(data, passphrase) {
|
|
94
|
+
try {
|
|
95
|
+
return decryptDbKey(data, passphrase);
|
|
96
|
+
} catch {
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Wrap a pepper for storage, generating a fresh salt and IV. Mirrors
|
|
103
|
+
* `encryptPepper` in lib/startup/dbkey.ts, field for field.
|
|
104
|
+
*/
|
|
105
|
+
function encryptDbKey(pepper, passphrase) {
|
|
106
|
+
const salt = crypto.randomBytes(SALT_LENGTH);
|
|
107
|
+
const key = crypto.pbkdf2Sync(
|
|
108
|
+
passphrase,
|
|
109
|
+
new Uint8Array(salt),
|
|
110
|
+
PBKDF2_ITERATIONS,
|
|
111
|
+
KEY_LENGTH,
|
|
112
|
+
PBKDF2_DIGEST,
|
|
113
|
+
);
|
|
114
|
+
const iv = crypto.randomBytes(IV_LENGTH);
|
|
115
|
+
const cipher = crypto.createCipheriv(ALGORITHM, new Uint8Array(key), new Uint8Array(iv));
|
|
116
|
+
let ciphertext = cipher.update(pepper, 'utf8', 'hex');
|
|
117
|
+
ciphertext += cipher.final('hex');
|
|
118
|
+
return {
|
|
119
|
+
version: 1,
|
|
120
|
+
algorithm: ALGORITHM,
|
|
121
|
+
kdf: 'pbkdf2',
|
|
122
|
+
kdfIterations: PBKDF2_ITERATIONS,
|
|
123
|
+
kdfDigest: PBKDF2_DIGEST,
|
|
124
|
+
salt: salt.toString('hex'),
|
|
125
|
+
iv: iv.toString('hex'),
|
|
126
|
+
ciphertext,
|
|
127
|
+
authTag: cipher.getAuthTag().toString('hex'),
|
|
128
|
+
pepperHash: hashPepper(pepper),
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* The fields that make up the wrapped key itself. Anything else in the file
|
|
134
|
+
* was put there by another subsystem — `minServerVersion`, written by
|
|
135
|
+
* `lib/startup/version-guard.ts` for the Electron shell's pre-launch check —
|
|
136
|
+
* and belongs to the instance rather than to this wrapping, so a rewrap must
|
|
137
|
+
* carry it across.
|
|
138
|
+
*/
|
|
139
|
+
const KEY_WRAPPER_FIELDS = new Set([
|
|
140
|
+
'version',
|
|
141
|
+
'algorithm',
|
|
142
|
+
'kdf',
|
|
143
|
+
'kdfIterations',
|
|
144
|
+
'kdfDigest',
|
|
145
|
+
'salt',
|
|
146
|
+
'iv',
|
|
147
|
+
'ciphertext',
|
|
148
|
+
'authTag',
|
|
149
|
+
'pepperHash',
|
|
150
|
+
]);
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Copy every field the wrapping does not own from an old key file onto a
|
|
154
|
+
* freshly wrapped one, so rebuilding a `.dbkey` never quietly drops a field
|
|
155
|
+
* some other part of Quilltap depends on.
|
|
156
|
+
*/
|
|
157
|
+
function preserveExtraFields(existing, fresh) {
|
|
158
|
+
if (!existing) return fresh;
|
|
159
|
+
for (const [key, value] of Object.entries(existing)) {
|
|
160
|
+
if (!KEY_WRAPPER_FIELDS.has(key)) {
|
|
161
|
+
fresh[key] = value;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return fresh;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Write the key file at mode 0600, creating the data directory if needed. */
|
|
168
|
+
function writeDbKeyFile(dataDir, data) {
|
|
169
|
+
if (!fs.existsSync(dataDir)) {
|
|
170
|
+
fs.mkdirSync(dataDir, { recursive: true });
|
|
171
|
+
}
|
|
172
|
+
fs.writeFileSync(getDbKeyPath(dataDir), JSON.stringify(data, null, 2), { mode: 0o600 });
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
module.exports = {
|
|
176
|
+
DBKEY_FILENAME,
|
|
177
|
+
INTERNAL_PASSPHRASE,
|
|
178
|
+
KEY_WRAPPER_FIELDS,
|
|
179
|
+
preserveExtraFields,
|
|
180
|
+
PBKDF2_ITERATIONS,
|
|
181
|
+
getDbKeyPath,
|
|
182
|
+
hashPepper,
|
|
183
|
+
readDbKeyFile,
|
|
184
|
+
decryptDbKey,
|
|
185
|
+
tryDecryptDbKey,
|
|
186
|
+
encryptDbKey,
|
|
187
|
+
writeDbKeyFile,
|
|
188
|
+
};
|
|
@@ -38,6 +38,7 @@ Verbs:
|
|
|
38
38
|
set-passphrase <name> Change or clear the stored passphrase
|
|
39
39
|
default [<name>] Set/show/clear default instance
|
|
40
40
|
rename <old> <new> Rename an instance (preserves passphrase)
|
|
41
|
+
restore-key <name> Rebuild a lost/locked .dbkey from the pepper
|
|
41
42
|
-h, --help This help
|
|
42
43
|
|
|
43
44
|
Storage: ~/Library/Application Support/Quilltap/instances.json on macOS,
|
|
@@ -61,6 +62,22 @@ Default Instance:
|
|
|
61
62
|
quilltap instances default --clear # clear the default
|
|
62
63
|
quilltap instances default # show the current default
|
|
63
64
|
|
|
65
|
+
Rebuilding a .dbkey:
|
|
66
|
+
The .dbkey file only WRAPS the pepper — the pepper itself is the database
|
|
67
|
+
key. So if the file is lost, or its passphrase is forgotten, an operator who
|
|
68
|
+
still has the pepper can rebuild it:
|
|
69
|
+
|
|
70
|
+
quilltap instances restore-key Friday # pepper from the environment,
|
|
71
|
+
# or a hidden prompt
|
|
72
|
+
quilltap instances restore-key Friday --no-passphrase --yes
|
|
73
|
+
|
|
74
|
+
The pepper is read from ENCRYPTION_MASTER_PEPPER or prompted for, never
|
|
75
|
+
passed as a flag (a command line lands in shell history and in ps output). It is
|
|
76
|
+
proved against the encrypted databases on disk before anything is written,
|
|
77
|
+
and the command refuses while the instance lock is held, so run it with the
|
|
78
|
+
server down. Flags: --passphrase <pass>, --no-passphrase, --data-dir <path>,
|
|
79
|
+
--force (no encrypted database to prove against), --yes.
|
|
80
|
+
|
|
64
81
|
Examples:
|
|
65
82
|
quilltap instances add Friday ~/iCloud/Quilltap/Friday
|
|
66
83
|
quilltap instances add Ignite ~/iCloud/Quilltap/Ignite # prompts for passphrase
|
|
@@ -280,6 +297,33 @@ function cmdRename(args) {
|
|
|
280
297
|
console.log(`Renamed instance "${oldKey}" → "${newKey}".`);
|
|
281
298
|
}
|
|
282
299
|
|
|
300
|
+
// Rebuild the instance's .dbkey from the pepper — see lib/dbkey-restore.js
|
|
301
|
+
// for why the pepper never arrives as a flag and why the write is proved first.
|
|
302
|
+
async function cmdRestoreKey(args) {
|
|
303
|
+
const opts = { name: '', dataDir: '', passphrase: undefined, noPassphrase: false, force: false, yes: false };
|
|
304
|
+
for (let i = 0; i < args.length; i++) {
|
|
305
|
+
const a = args[i];
|
|
306
|
+
switch (a) {
|
|
307
|
+
case '-d': case '--data-dir': opts.dataDir = args[++i]; break;
|
|
308
|
+
case '--passphrase': opts.passphrase = args[++i]; break;
|
|
309
|
+
case '--no-passphrase': opts.noPassphrase = true; break;
|
|
310
|
+
case '--force': opts.force = true; break;
|
|
311
|
+
case '-y': case '--yes': opts.yes = true; break;
|
|
312
|
+
default:
|
|
313
|
+
if (a.startsWith('-')) throw new Error(`Unknown flag: ${a}`);
|
|
314
|
+
if (opts.name) throw new Error('Specify one instance.');
|
|
315
|
+
opts.name = a;
|
|
316
|
+
break;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
if (!opts.name && !opts.dataDir) {
|
|
320
|
+
console.error('Usage: quilltap instances restore-key <name> | --data-dir <instance-root>');
|
|
321
|
+
process.exit(1);
|
|
322
|
+
}
|
|
323
|
+
const { restoreKey } = require('./dbkey-restore');
|
|
324
|
+
await restoreKey(opts);
|
|
325
|
+
}
|
|
326
|
+
|
|
283
327
|
async function instancesCommand(args) {
|
|
284
328
|
if (args.length === 0) {
|
|
285
329
|
cmdList();
|
|
@@ -328,6 +372,10 @@ async function instancesCommand(args) {
|
|
|
328
372
|
case 'rename':
|
|
329
373
|
cmdRename(rest);
|
|
330
374
|
return;
|
|
375
|
+
case 'restore-key':
|
|
376
|
+
case 'rebuild-key':
|
|
377
|
+
await cmdRestoreKey(rest);
|
|
378
|
+
return;
|
|
331
379
|
default:
|
|
332
380
|
console.error(`Unknown instances verb: ${verb}`);
|
|
333
381
|
console.error('Run "quilltap instances --help" for usage.');
|
package/lib/instances.js
CHANGED
|
@@ -277,41 +277,17 @@ function renameInstance(oldName, newName) {
|
|
|
277
277
|
// 'no-dbkey' — no .dbkey on disk yet (first-run instance)
|
|
278
278
|
// 'no-encryption'— dbkey is unlocked by the internal passphrase, no user one needed
|
|
279
279
|
async function verifyPassphrase(instanceRoot, passphrase) {
|
|
280
|
-
const
|
|
280
|
+
const { INTERNAL_PASSPHRASE, readDbKeyFile, tryDecryptDbKey } = require('./dbkey');
|
|
281
281
|
const dataDir = path.join(expandPath(instanceRoot), 'data');
|
|
282
|
-
const dbkeyPath = path.join(dataDir, 'quilltap.dbkey');
|
|
283
|
-
if (!fs.existsSync(dbkeyPath)) {
|
|
284
|
-
return 'no-dbkey';
|
|
285
|
-
}
|
|
286
|
-
const data = JSON.parse(fs.readFileSync(dbkeyPath, 'utf8'));
|
|
287
|
-
const INTERNAL = '__quilltap_no_passphrase__';
|
|
288
282
|
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
const iv = Buffer.from(data.iv, 'hex');
|
|
293
|
-
const decipher = crypto.createDecipheriv(data.algorithm, new Uint8Array(key), new Uint8Array(iv));
|
|
294
|
-
decipher.setAuthTag(new Uint8Array(Buffer.from(data.authTag, 'hex')));
|
|
295
|
-
let plaintext = decipher.update(data.ciphertext, 'hex', 'utf8');
|
|
296
|
-
plaintext += decipher.final('utf8');
|
|
297
|
-
const hash = crypto.createHash('sha256').update(plaintext).digest('hex');
|
|
298
|
-
if (hash !== data.pepperHash) throw new Error('pepperHash mismatch');
|
|
299
|
-
return plaintext;
|
|
283
|
+
const data = readDbKeyFile(dataDir);
|
|
284
|
+
if (!data) {
|
|
285
|
+
return 'no-dbkey';
|
|
300
286
|
}
|
|
301
|
-
|
|
302
|
-
try {
|
|
303
|
-
tryDecrypt(INTERNAL);
|
|
287
|
+
if (tryDecryptDbKey(data, INTERNAL_PASSPHRASE) !== null) {
|
|
304
288
|
return 'no-encryption';
|
|
305
|
-
} catch {
|
|
306
|
-
// Falls through — dbkey needs a user passphrase.
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
try {
|
|
310
|
-
tryDecrypt(passphrase);
|
|
311
|
-
return 'valid';
|
|
312
|
-
} catch {
|
|
313
|
-
return 'wrong';
|
|
314
289
|
}
|
|
290
|
+
return tryDecryptDbKey(data, passphrase) !== null ? 'valid' : 'wrong';
|
|
315
291
|
}
|
|
316
292
|
|
|
317
293
|
module.exports = {
|
package/lib/lock-helpers.js
CHANGED
|
@@ -6,7 +6,7 @@ const path = require('path');
|
|
|
6
6
|
const { execSync } = require('child_process');
|
|
7
7
|
|
|
8
8
|
const HEARTBEAT_FRESH_MS = 5 * 60 * 1000;
|
|
9
|
-
const VM_ENVIRONMENTS = new Set(['docker'
|
|
9
|
+
const VM_ENVIRONMENTS = new Set(['docker']);
|
|
10
10
|
|
|
11
11
|
function isPidAlive(pid) {
|
|
12
12
|
try {
|
|
@@ -136,14 +136,12 @@ let exitHandlersRegistered = false;
|
|
|
136
136
|
|
|
137
137
|
/**
|
|
138
138
|
* Detect the runtime environment for lock metadata. JS port of the server's
|
|
139
|
-
* detectEnvironmentType() so a CLI run inside Docker
|
|
139
|
+
* detectEnvironmentType() so a CLI run inside Docker writes the right
|
|
140
140
|
* environment and the cross-host heartbeat semantics keep working.
|
|
141
141
|
*/
|
|
142
142
|
function detectEnvironmentType() {
|
|
143
143
|
if (process.versions && process.versions.electron) return 'electron';
|
|
144
144
|
if (process.env.ELECTRON_DEV) return 'electron';
|
|
145
|
-
if (process.env.LIMA_CONTAINER === 'true') return 'lima'; // before Docker — Lima rootfs has Docker markers
|
|
146
|
-
if (process.env.WSL_DISTRO_NAME) return 'wsl2';
|
|
147
145
|
if (process.env.DOCKER_CONTAINER === 'true') return 'docker';
|
|
148
146
|
try {
|
|
149
147
|
if (fs.existsSync('/.dockerenv')) return 'docker';
|