quilltap 4.9.0-dev.92 → 4.9.0-dev.95
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/bin/quilltap.js +4 -4
- package/lib/__tests__/dbkey-restore.test.js +162 -0
- package/lib/completion/bash.template +3 -3
- package/lib/completion/fish.template +10 -1
- package/lib/completion/zsh.template +8 -1
- 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
package/bin/quilltap.js
CHANGED
|
@@ -529,8 +529,8 @@ function handleLockCommand(dataDir, opts) {
|
|
|
529
529
|
} else if (alive && !isNode) {
|
|
530
530
|
status = '\x1b[33mSUSPECT\x1b[0m (PID alive but does not look like Quilltap — possible PID reuse)';
|
|
531
531
|
} else if (!sameHost) {
|
|
532
|
-
// Different hostname — could be a
|
|
533
|
-
const isVMOrContainer =
|
|
532
|
+
// Different hostname — could be a container on this machine
|
|
533
|
+
const isVMOrContainer = lock.environment === 'docker';
|
|
534
534
|
const heartbeatAgeMs = lock.lastHeartbeat
|
|
535
535
|
? Date.now() - new Date(lock.lastHeartbeat).getTime()
|
|
536
536
|
: Infinity;
|
|
@@ -613,8 +613,8 @@ function handleLockCommand(dataDir, opts) {
|
|
|
613
613
|
console.log('This is likely a stale lock with a reused PID. Removing.');
|
|
614
614
|
}
|
|
615
615
|
} else if (!sameHost) {
|
|
616
|
-
// Different hostname — check if it's a
|
|
617
|
-
const isVMOrContainer =
|
|
616
|
+
// Different hostname — check if it's a container with a recent heartbeat
|
|
617
|
+
const isVMOrContainer = lock.environment === 'docker';
|
|
618
618
|
const heartbeatAgeMs = lock.lastHeartbeat
|
|
619
619
|
? Date.now() - new Date(lock.lastHeartbeat).getTime()
|
|
620
620
|
: Infinity;
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `quilltap instances restore-key` — the guards that stand between an operator
|
|
3
|
+
* with a pepper and a `.dbkey` that would brick an instance.
|
|
4
|
+
*
|
|
5
|
+
* The load-bearing one is the pepper proof: a key file holding the WRONG
|
|
6
|
+
* pepper is worse than none at all, because the server unwraps it happily and
|
|
7
|
+
* then reports an intact database as corrupt. So the proof runs against real
|
|
8
|
+
* SQLCipher files, not the suite's `better-sqlite3` mock — the mock would open
|
|
9
|
+
* anything.
|
|
10
|
+
*
|
|
11
|
+
* @jest-environment node
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
'use strict';
|
|
15
|
+
|
|
16
|
+
const fs = require('fs');
|
|
17
|
+
const os = require('os');
|
|
18
|
+
const path = require('path');
|
|
19
|
+
const crypto = require('crypto');
|
|
20
|
+
|
|
21
|
+
const PKG_ROOT = path.join(__dirname, '..', '..');
|
|
22
|
+
|
|
23
|
+
// The root jest config maps both driver names onto __mocks__/better-sqlite3.ts,
|
|
24
|
+
// which accepts any key. Point them back at the real binding by absolute path
|
|
25
|
+
// so a wrong pepper actually fails to decrypt.
|
|
26
|
+
//
|
|
27
|
+
// Two copies can back this: the per-package install used in local development,
|
|
28
|
+
// and — in CI, where only the root `npm ci` runs — the root alias, where
|
|
29
|
+
// package.json installs better-sqlite3-multiple-ciphers under the name
|
|
30
|
+
// `better-sqlite3`. Same fallback chain as the __tests__/unit/packages/quilltap
|
|
31
|
+
// integration suites.
|
|
32
|
+
jest.mock('better-sqlite3-multiple-ciphers', () => {
|
|
33
|
+
const join = require('path').join;
|
|
34
|
+
try {
|
|
35
|
+
return require(join(PKG_ROOT, 'node_modules', 'better-sqlite3-multiple-ciphers'));
|
|
36
|
+
} catch {
|
|
37
|
+
return require(join(PKG_ROOT, '..', '..', 'node_modules', 'better-sqlite3'));
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
const Database = require('better-sqlite3-multiple-ciphers');
|
|
42
|
+
const { provePepper, databaseState } = require('../dbkey-restore');
|
|
43
|
+
const {
|
|
44
|
+
INTERNAL_PASSPHRASE,
|
|
45
|
+
encryptDbKey,
|
|
46
|
+
decryptDbKey,
|
|
47
|
+
tryDecryptDbKey,
|
|
48
|
+
preserveExtraFields,
|
|
49
|
+
readDbKeyFile,
|
|
50
|
+
writeDbKeyFile,
|
|
51
|
+
} = require('../dbkey');
|
|
52
|
+
|
|
53
|
+
const PEPPER = crypto.randomBytes(32).toString('base64');
|
|
54
|
+
const OTHER_PEPPER = crypto.randomBytes(32).toString('base64');
|
|
55
|
+
|
|
56
|
+
let dataDir;
|
|
57
|
+
|
|
58
|
+
function seedEncryptedDb(filename, pepper) {
|
|
59
|
+
const db = new Database(path.join(dataDir, filename));
|
|
60
|
+
db.pragma(`key = "x'${Buffer.from(pepper, 'base64').toString('hex')}'"`);
|
|
61
|
+
db.exec("CREATE TABLE t (a TEXT); INSERT INTO t VALUES ('hello');");
|
|
62
|
+
db.close();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function seedPlaintextDb(filename) {
|
|
66
|
+
const db = new Database(path.join(dataDir, filename));
|
|
67
|
+
db.exec('CREATE TABLE t (a TEXT);');
|
|
68
|
+
db.close();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
beforeEach(() => {
|
|
72
|
+
dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'qtap-restore-'));
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
afterEach(() => {
|
|
76
|
+
fs.rmSync(dataDir, { recursive: true, force: true });
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe('databaseState', () => {
|
|
80
|
+
it('reads the SQLite magic to tell plaintext from SQLCipher', () => {
|
|
81
|
+
seedPlaintextDb('plain.db');
|
|
82
|
+
seedEncryptedDb('enc.db', PEPPER);
|
|
83
|
+
|
|
84
|
+
expect(databaseState(path.join(dataDir, 'plain.db'))).toBe('plaintext');
|
|
85
|
+
expect(databaseState(path.join(dataDir, 'enc.db'))).toBe('encrypted');
|
|
86
|
+
expect(databaseState(path.join(dataDir, 'nope.db'))).toBe('absent');
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
describe('provePepper', () => {
|
|
91
|
+
it('proves the right pepper against every encrypted database', () => {
|
|
92
|
+
seedEncryptedDb('quilltap.db', PEPPER);
|
|
93
|
+
seedEncryptedDb('quilltap-llm-logs.db', PEPPER);
|
|
94
|
+
seedEncryptedDb('quilltap-mount-index.db', PEPPER);
|
|
95
|
+
|
|
96
|
+
const { proved, results } = provePepper(dataDir, PEPPER);
|
|
97
|
+
expect(proved).toBe(true);
|
|
98
|
+
expect(results.every((r) => r.ok === true)).toBe(true);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('refuses a pepper that does not open the databases', () => {
|
|
102
|
+
seedEncryptedDb('quilltap.db', PEPPER);
|
|
103
|
+
|
|
104
|
+
const { proved, results } = provePepper(dataDir, OTHER_PEPPER);
|
|
105
|
+
expect(proved).toBe(false);
|
|
106
|
+
expect(results.find((r) => r.filename === 'quilltap.db').ok).toBe(false);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('reports one bad database among good ones rather than averaging it away', () => {
|
|
110
|
+
seedEncryptedDb('quilltap.db', PEPPER);
|
|
111
|
+
seedEncryptedDb('quilltap-llm-logs.db', OTHER_PEPPER);
|
|
112
|
+
|
|
113
|
+
const { proved, results } = provePepper(dataDir, PEPPER);
|
|
114
|
+
expect(proved).toBe(false);
|
|
115
|
+
expect(results.find((r) => r.filename === 'quilltap.db').ok).toBe(true);
|
|
116
|
+
expect(results.find((r) => r.filename === 'quilltap-llm-logs.db').ok).toBe(false);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('cannot prove anything when the databases are absent or still plaintext', () => {
|
|
120
|
+
expect(provePepper(dataDir, PEPPER).proved).toBe(false);
|
|
121
|
+
|
|
122
|
+
seedPlaintextDb('quilltap.db');
|
|
123
|
+
const { proved, results } = provePepper(dataDir, PEPPER);
|
|
124
|
+
expect(proved).toBe(false);
|
|
125
|
+
expect(results.find((r) => r.filename === 'quilltap.db')).toMatchObject({
|
|
126
|
+
state: 'plaintext',
|
|
127
|
+
ok: null,
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
describe('rewrapping a key file', () => {
|
|
133
|
+
it('round-trips the pepper under a new passphrase', () => {
|
|
134
|
+
writeDbKeyFile(dataDir, encryptDbKey(PEPPER, INTERNAL_PASSPHRASE));
|
|
135
|
+
expect(decryptDbKey(readDbKeyFile(dataDir), INTERNAL_PASSPHRASE)).toBe(PEPPER);
|
|
136
|
+
|
|
137
|
+
writeDbKeyFile(dataDir, encryptDbKey(PEPPER, 'the lamplighter'));
|
|
138
|
+
const rewrapped = readDbKeyFile(dataDir);
|
|
139
|
+
expect(tryDecryptDbKey(rewrapped, INTERNAL_PASSPHRASE)).toBeNull();
|
|
140
|
+
expect(decryptDbKey(rewrapped, 'the lamplighter')).toBe(PEPPER);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('carries fields the wrapping does not own across the rebuild', () => {
|
|
144
|
+
// `minServerVersion` is written by lib/startup/version-guard.ts for the
|
|
145
|
+
// Electron shell's pre-launch check. Dropping it on a rewrap would take
|
|
146
|
+
// the version floor with it.
|
|
147
|
+
const existing = encryptDbKey(PEPPER, INTERNAL_PASSPHRASE);
|
|
148
|
+
existing.minServerVersion = '4.9.0-dev.91';
|
|
149
|
+
|
|
150
|
+
const fresh = preserveExtraFields(existing, encryptDbKey(PEPPER, 'the lamplighter'));
|
|
151
|
+
|
|
152
|
+
expect(fresh.minServerVersion).toBe('4.9.0-dev.91');
|
|
153
|
+
expect(fresh.salt).not.toBe(existing.salt);
|
|
154
|
+
expect(decryptDbKey(fresh, 'the lamplighter')).toBe(PEPPER);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('writes the key file owner-only', () => {
|
|
158
|
+
writeDbKeyFile(dataDir, encryptDbKey(PEPPER, INTERNAL_PASSPHRASE));
|
|
159
|
+
const mode = fs.statSync(path.join(dataDir, 'quilltap.dbkey')).mode & 0o777;
|
|
160
|
+
expect(mode).toBe(0o600);
|
|
161
|
+
});
|
|
162
|
+
});
|
|
@@ -263,8 +263,8 @@ _quilltap_complete() {
|
|
|
263
263
|
fi
|
|
264
264
|
;;
|
|
265
265
|
instances)
|
|
266
|
-
local inst_verbs="list ls show path where add create remove rm delete set-passphrase passphrase default rename"
|
|
267
|
-
local inst_flags="--names-only --json --clear --help"
|
|
266
|
+
local inst_verbs="list ls show path where add create remove rm delete set-passphrase passphrase default rename restore-key rebuild-key"
|
|
267
|
+
local inst_flags="--names-only --json --clear --passphrase --no-passphrase --data-dir --force --yes --help"
|
|
268
268
|
if [[ -z "$subverb" ]]; then
|
|
269
269
|
if [[ "$cur" == -* ]]; then
|
|
270
270
|
COMPREPLY=($(compgen -W "$inst_flags" -- "$cur"))
|
|
@@ -274,7 +274,7 @@ _quilltap_complete() {
|
|
|
274
274
|
else
|
|
275
275
|
# Most instances verbs take a name as positional; offer registered names
|
|
276
276
|
case "$subverb" in
|
|
277
|
-
show|remove|rm|delete|set-passphrase|passphrase|default|rename)
|
|
277
|
+
show|remove|rm|delete|set-passphrase|passphrase|default|rename|restore-key|rebuild-key)
|
|
278
278
|
local instances=$(command quilltap instances list --names-only 2>/dev/null)
|
|
279
279
|
if [[ "$cur" == -* ]]; then
|
|
280
280
|
COMPREPLY=($(compgen -W "$inst_flags" -- "$cur"))
|
|
@@ -225,14 +225,23 @@ complete -c quilltap -n '__quilltap_using_subcommand instances' -f -a 'set-passp
|
|
|
225
225
|
complete -c quilltap -n '__quilltap_using_subcommand instances' -f -a 'passphrase' -d 'Set passphrase'
|
|
226
226
|
complete -c quilltap -n '__quilltap_using_subcommand instances' -f -a 'default' -d 'Set/show/clear default instance'
|
|
227
227
|
complete -c quilltap -n '__quilltap_using_subcommand instances' -f -a 'rename' -d 'Rename instance'
|
|
228
|
+
complete -c quilltap -n '__quilltap_using_subcommand instances' -f -a 'restore-key' -d 'Rebuild .dbkey from the pepper'
|
|
229
|
+
complete -c quilltap -n '__quilltap_using_subcommand instances' -f -a 'rebuild-key' -d 'Rebuild .dbkey from the pepper'
|
|
228
230
|
|
|
229
231
|
# Instance names as positional for verbs that target one
|
|
230
|
-
for verb in show remove rm delete set-passphrase passphrase default rename
|
|
232
|
+
for verb in show remove rm delete set-passphrase passphrase default rename restore-key rebuild-key
|
|
231
233
|
complete -c quilltap -n "__quilltap_using_subverb instances $verb" -f -a '(__quilltap_instance_names)'
|
|
232
234
|
end
|
|
233
235
|
|
|
234
236
|
complete -c quilltap -n '__quilltap_using_subcommand instances' -l 'names-only' -d 'Print one name per line'
|
|
235
237
|
complete -c quilltap -n '__quilltap_using_subverb instances default' -l 'clear' -d 'Clear default instance'
|
|
238
|
+
for verb in restore-key rebuild-key
|
|
239
|
+
complete -c quilltap -n "__quilltap_using_subverb instances $verb" -l 'passphrase' -d 'Passphrase for the rebuilt .dbkey'
|
|
240
|
+
complete -c quilltap -n "__quilltap_using_subverb instances $verb" -l 'no-passphrase' -d 'Rebuild with no passphrase'
|
|
241
|
+
complete -c quilltap -n "__quilltap_using_subverb instances $verb" -s 'd' -l 'data-dir' -r -d 'Instance root to rebuild in'
|
|
242
|
+
complete -c quilltap -n "__quilltap_using_subverb instances $verb" -l 'force' -d 'Write with no database to prove against'
|
|
243
|
+
complete -c quilltap -n "__quilltap_using_subverb instances $verb" -s 'y' -l 'yes' -d 'Skip confirmation prompts'
|
|
244
|
+
end
|
|
236
245
|
|
|
237
246
|
# ---------- memories verbs ----------
|
|
238
247
|
complete -c quilltap -n '__quilltap_using_subcommand memories' -f -a 'ls' -d 'List memories'
|
|
@@ -450,12 +450,19 @@ _quilltap_instances() {
|
|
|
450
450
|
'passphrase:Set instance passphrase'
|
|
451
451
|
'default:Set/show/clear the default instance'
|
|
452
452
|
'rename:Rename an instance (preserves passphrase)'
|
|
453
|
+
'restore-key:Rebuild the .dbkey from the pepper'
|
|
454
|
+
'rebuild-key:Rebuild the .dbkey from the pepper'
|
|
453
455
|
)
|
|
454
456
|
|
|
455
457
|
inst_opts=(
|
|
456
458
|
'--names-only[Print one name per line (for completion)]'
|
|
457
459
|
'--json[JSON output]'
|
|
458
460
|
'--clear[Clear value (for default)]'
|
|
461
|
+
'--passphrase[Passphrase for the rebuilt .dbkey]:passphrase:'
|
|
462
|
+
'--no-passphrase[Rebuild the .dbkey with no passphrase]'
|
|
463
|
+
'(-d --data-dir)'{-d,--data-dir}'[Instance root to rebuild in]:directory:_files -/'
|
|
464
|
+
'--force[Write even with no encrypted database to prove the pepper against]'
|
|
465
|
+
'(-y --yes)'{-y,--yes}'[Skip confirmation prompts]'
|
|
459
466
|
'(-h --help)'{-h,--help}'[Show help]'
|
|
460
467
|
)
|
|
461
468
|
|
|
@@ -470,7 +477,7 @@ _quilltap_instances() {
|
|
|
470
477
|
;;
|
|
471
478
|
name)
|
|
472
479
|
case "$line[1]" in
|
|
473
|
-
show|remove|rm|delete|set-passphrase|passphrase|default|rename)
|
|
480
|
+
show|remove|rm|delete|set-passphrase|passphrase|default|rename|restore-key|rebuild-key)
|
|
474
481
|
_quilltap_instance_names
|
|
475
482
|
;;
|
|
476
483
|
esac
|
package/lib/db-helpers.js
CHANGED
|
@@ -148,42 +148,19 @@ function promptPassphrase(prompt) {
|
|
|
148
148
|
}
|
|
149
149
|
|
|
150
150
|
async function loadDbKey(dataDir, passphrase) {
|
|
151
|
-
const
|
|
152
|
-
const dbkeyPath = path.join(dataDir, 'quilltap.dbkey');
|
|
153
|
-
if (!fs.existsSync(dbkeyPath)) {
|
|
154
|
-
return null;
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
const data = JSON.parse(fs.readFileSync(dbkeyPath, 'utf8'));
|
|
158
|
-
const INTERNAL_PASSPHRASE = '__quilltap_no_passphrase__';
|
|
159
|
-
|
|
160
|
-
if ('hasPassphrase' in data) {
|
|
161
|
-
delete data.hasPassphrase;
|
|
162
|
-
fs.writeFileSync(dbkeyPath, JSON.stringify(data, null, 2), { mode: 0o600 });
|
|
163
|
-
}
|
|
151
|
+
const { INTERNAL_PASSPHRASE, readDbKeyFile, decryptDbKey, tryDecryptDbKey } = require('./dbkey');
|
|
164
152
|
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
const iv = Buffer.from(data.iv, 'hex');
|
|
169
|
-
const decipher = crypto.createDecipheriv(data.algorithm, new Uint8Array(key), new Uint8Array(iv));
|
|
170
|
-
decipher.setAuthTag(new Uint8Array(Buffer.from(data.authTag, 'hex')));
|
|
171
|
-
let plaintext = decipher.update(data.ciphertext, 'hex', 'utf8');
|
|
172
|
-
plaintext += decipher.final('utf8');
|
|
173
|
-
|
|
174
|
-
const hash = crypto.createHash('sha256').update(plaintext).digest('hex');
|
|
175
|
-
if (hash !== data.pepperHash) {
|
|
176
|
-
throw new Error('Pepper hash mismatch');
|
|
177
|
-
}
|
|
178
|
-
return plaintext;
|
|
153
|
+
const data = readDbKeyFile(dataDir);
|
|
154
|
+
if (!data) {
|
|
155
|
+
return null;
|
|
179
156
|
}
|
|
180
157
|
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
// Internal passphrase failed — need user passphrase
|
|
158
|
+
const internal = tryDecryptDbKey(data, INTERNAL_PASSPHRASE);
|
|
159
|
+
if (internal !== null) {
|
|
160
|
+
return internal;
|
|
185
161
|
}
|
|
186
162
|
|
|
163
|
+
// Internal passphrase failed — need a user passphrase.
|
|
187
164
|
if (!passphrase && process.env.QUILLTAP_DB_PASSPHRASE) {
|
|
188
165
|
passphrase = process.env.QUILLTAP_DB_PASSPHRASE;
|
|
189
166
|
}
|
|
@@ -195,7 +172,7 @@ async function loadDbKey(dataDir, passphrase) {
|
|
|
195
172
|
}
|
|
196
173
|
}
|
|
197
174
|
|
|
198
|
-
return
|
|
175
|
+
return decryptDbKey(data, passphrase);
|
|
199
176
|
}
|
|
200
177
|
|
|
201
178
|
function openEncryptedDb(dbPath, pepper, { readonly = true, friendlyName = 'database' } = {}) {
|
|
@@ -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';
|