quilltap 4.9.0-dev.92 → 4.9.0-dev.93

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.
@@ -0,0 +1,151 @@
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
+ jest.mock('better-sqlite3-multiple-ciphers', () =>
27
+ require(path.join(PKG_ROOT, 'node_modules', 'better-sqlite3-multiple-ciphers'))
28
+ );
29
+
30
+ const Database = require('better-sqlite3-multiple-ciphers');
31
+ const { provePepper, databaseState } = require('../dbkey-restore');
32
+ const {
33
+ INTERNAL_PASSPHRASE,
34
+ encryptDbKey,
35
+ decryptDbKey,
36
+ tryDecryptDbKey,
37
+ preserveExtraFields,
38
+ readDbKeyFile,
39
+ writeDbKeyFile,
40
+ } = require('../dbkey');
41
+
42
+ const PEPPER = crypto.randomBytes(32).toString('base64');
43
+ const OTHER_PEPPER = crypto.randomBytes(32).toString('base64');
44
+
45
+ let dataDir;
46
+
47
+ function seedEncryptedDb(filename, pepper) {
48
+ const db = new Database(path.join(dataDir, filename));
49
+ db.pragma(`key = "x'${Buffer.from(pepper, 'base64').toString('hex')}'"`);
50
+ db.exec("CREATE TABLE t (a TEXT); INSERT INTO t VALUES ('hello');");
51
+ db.close();
52
+ }
53
+
54
+ function seedPlaintextDb(filename) {
55
+ const db = new Database(path.join(dataDir, filename));
56
+ db.exec('CREATE TABLE t (a TEXT);');
57
+ db.close();
58
+ }
59
+
60
+ beforeEach(() => {
61
+ dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'qtap-restore-'));
62
+ });
63
+
64
+ afterEach(() => {
65
+ fs.rmSync(dataDir, { recursive: true, force: true });
66
+ });
67
+
68
+ describe('databaseState', () => {
69
+ it('reads the SQLite magic to tell plaintext from SQLCipher', () => {
70
+ seedPlaintextDb('plain.db');
71
+ seedEncryptedDb('enc.db', PEPPER);
72
+
73
+ expect(databaseState(path.join(dataDir, 'plain.db'))).toBe('plaintext');
74
+ expect(databaseState(path.join(dataDir, 'enc.db'))).toBe('encrypted');
75
+ expect(databaseState(path.join(dataDir, 'nope.db'))).toBe('absent');
76
+ });
77
+ });
78
+
79
+ describe('provePepper', () => {
80
+ it('proves the right pepper against every encrypted database', () => {
81
+ seedEncryptedDb('quilltap.db', PEPPER);
82
+ seedEncryptedDb('quilltap-llm-logs.db', PEPPER);
83
+ seedEncryptedDb('quilltap-mount-index.db', PEPPER);
84
+
85
+ const { proved, results } = provePepper(dataDir, PEPPER);
86
+ expect(proved).toBe(true);
87
+ expect(results.every((r) => r.ok === true)).toBe(true);
88
+ });
89
+
90
+ it('refuses a pepper that does not open the databases', () => {
91
+ seedEncryptedDb('quilltap.db', PEPPER);
92
+
93
+ const { proved, results } = provePepper(dataDir, OTHER_PEPPER);
94
+ expect(proved).toBe(false);
95
+ expect(results.find((r) => r.filename === 'quilltap.db').ok).toBe(false);
96
+ });
97
+
98
+ it('reports one bad database among good ones rather than averaging it away', () => {
99
+ seedEncryptedDb('quilltap.db', PEPPER);
100
+ seedEncryptedDb('quilltap-llm-logs.db', OTHER_PEPPER);
101
+
102
+ const { proved, results } = provePepper(dataDir, PEPPER);
103
+ expect(proved).toBe(false);
104
+ expect(results.find((r) => r.filename === 'quilltap.db').ok).toBe(true);
105
+ expect(results.find((r) => r.filename === 'quilltap-llm-logs.db').ok).toBe(false);
106
+ });
107
+
108
+ it('cannot prove anything when the databases are absent or still plaintext', () => {
109
+ expect(provePepper(dataDir, PEPPER).proved).toBe(false);
110
+
111
+ seedPlaintextDb('quilltap.db');
112
+ const { proved, results } = provePepper(dataDir, PEPPER);
113
+ expect(proved).toBe(false);
114
+ expect(results.find((r) => r.filename === 'quilltap.db')).toMatchObject({
115
+ state: 'plaintext',
116
+ ok: null,
117
+ });
118
+ });
119
+ });
120
+
121
+ describe('rewrapping a key file', () => {
122
+ it('round-trips the pepper under a new passphrase', () => {
123
+ writeDbKeyFile(dataDir, encryptDbKey(PEPPER, INTERNAL_PASSPHRASE));
124
+ expect(decryptDbKey(readDbKeyFile(dataDir), INTERNAL_PASSPHRASE)).toBe(PEPPER);
125
+
126
+ writeDbKeyFile(dataDir, encryptDbKey(PEPPER, 'the lamplighter'));
127
+ const rewrapped = readDbKeyFile(dataDir);
128
+ expect(tryDecryptDbKey(rewrapped, INTERNAL_PASSPHRASE)).toBeNull();
129
+ expect(decryptDbKey(rewrapped, 'the lamplighter')).toBe(PEPPER);
130
+ });
131
+
132
+ it('carries fields the wrapping does not own across the rebuild', () => {
133
+ // `minServerVersion` is written by lib/startup/version-guard.ts for the
134
+ // Electron shell's pre-launch check. Dropping it on a rewrap would take
135
+ // the version floor with it.
136
+ const existing = encryptDbKey(PEPPER, INTERNAL_PASSPHRASE);
137
+ existing.minServerVersion = '4.9.0-dev.91';
138
+
139
+ const fresh = preserveExtraFields(existing, encryptDbKey(PEPPER, 'the lamplighter'));
140
+
141
+ expect(fresh.minServerVersion).toBe('4.9.0-dev.91');
142
+ expect(fresh.salt).not.toBe(existing.salt);
143
+ expect(decryptDbKey(fresh, 'the lamplighter')).toBe(PEPPER);
144
+ });
145
+
146
+ it('writes the key file owner-only', () => {
147
+ writeDbKeyFile(dataDir, encryptDbKey(PEPPER, INTERNAL_PASSPHRASE));
148
+ const mode = fs.statSync(path.join(dataDir, 'quilltap.dbkey')).mode & 0o777;
149
+ expect(mode).toBe(0o600);
150
+ });
151
+ });
@@ -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 crypto = require('crypto');
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
- function tryDecrypt(pass) {
166
- const salt = Buffer.from(data.salt, 'hex');
167
- const key = crypto.pbkdf2Sync(pass, new Uint8Array(salt), data.kdfIterations, 32, data.kdfDigest);
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
- try {
182
- return tryDecrypt(INTERNAL_PASSPHRASE);
183
- } catch {
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 tryDecrypt(passphrase);
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 crypto = require('crypto');
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
- function tryDecrypt(pass) {
290
- const salt = Buffer.from(data.salt, 'hex');
291
- const key = crypto.pbkdf2Sync(pass, new Uint8Array(salt), data.kdfIterations, 32, data.kdfDigest);
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "quilltap",
3
- "version": "4.9.0-dev.92",
3
+ "version": "4.9.0-dev.93",
4
4
  "description": "Self-hosted AI workspace for writers, worldbuilders, and roleplayers. Run with npx quilltap.",
5
5
  "author": {
6
6
  "name": "Charles Sebold",