ldrouter 1.12.0 → 1.13.1
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/CHANGELOG.md +12 -0
- package/dist/server/auth/backup-crypto.js +31 -0
- package/dist/server/auth/crypto.js +3 -0
- package/dist/server/routes/admin/backup.js +38 -4
- package/dist/web/assets/index-CBMHkVXC.js +330 -0
- package/dist/web/assets/index-Dswaxg_c.css +1 -0
- package/dist/web/index.html +2 -2
- package/package.json +1 -3
- package/dist/web/assets/index-CqVMNQ5F.css +0 -1
- package/dist/web/assets/index-DhNkAaqo.js +0 -330
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,18 @@ All notable changes to this project are documented here. The format follows
|
|
|
4
4
|
[Keep a Changelog](https://keepachangelog.com/) and the project adheres to
|
|
5
5
|
[Semantic Versioning](https://semver.org/).
|
|
6
6
|
|
|
7
|
+
## [1.13.1] - 2026-09-11
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- **Update notification**: force a fresh npm registry check when the admin UI loads, so the update button is not hidden by the 15-minute cache.
|
|
12
|
+
|
|
13
|
+
## [1.13.0] - 2026-09-11
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
|
|
17
|
+
- **Passphrase-protected full backups**: backups now include administrator state and a master-key envelope protected by a user-entered six-digit passphrase; `/setup` can import them without recreating the admin account.
|
|
18
|
+
|
|
7
19
|
## [1.12.0] - 2026-09-05
|
|
8
20
|
|
|
9
21
|
### Added
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
function passphraseKey(passphrase, salt) {
|
|
3
|
+
if (!/^\d{6}$/.test(passphrase))
|
|
4
|
+
throw new Error('Backup passphrase must contain exactly six digits');
|
|
5
|
+
return crypto.scryptSync(passphrase, salt, 32, { N: 16_384, r: 8, p: 1 });
|
|
6
|
+
}
|
|
7
|
+
export function encryptBackupMasterKey(masterKey, passphrase) {
|
|
8
|
+
const salt = crypto.randomBytes(16);
|
|
9
|
+
const nonce = crypto.randomBytes(12);
|
|
10
|
+
const cipher = crypto.createCipheriv('aes-256-gcm', passphraseKey(passphrase, salt), nonce);
|
|
11
|
+
const ciphertext = Buffer.concat([cipher.update(masterKey), cipher.final()]);
|
|
12
|
+
return {
|
|
13
|
+
algorithm: 'scrypt-aes-256-gcm',
|
|
14
|
+
salt: salt.toString('base64'),
|
|
15
|
+
nonce: nonce.toString('base64'),
|
|
16
|
+
ciphertext: ciphertext.toString('base64'),
|
|
17
|
+
tag: cipher.getAuthTag().toString('base64'),
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
export function decryptBackupMasterKey(envelope, passphrase) {
|
|
21
|
+
if (envelope.algorithm !== 'scrypt-aes-256-gcm')
|
|
22
|
+
throw new Error('Unsupported backup key encryption');
|
|
23
|
+
try {
|
|
24
|
+
const decipher = crypto.createDecipheriv('aes-256-gcm', passphraseKey(passphrase, Buffer.from(envelope.salt, 'base64')), Buffer.from(envelope.nonce, 'base64'));
|
|
25
|
+
decipher.setAuthTag(Buffer.from(envelope.tag, 'base64'));
|
|
26
|
+
return Buffer.concat([decipher.update(Buffer.from(envelope.ciphertext, 'base64')), decipher.final()]);
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
throw new Error('Invalid backup passphrase');
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -40,6 +40,9 @@ export function getMasterKey() {
|
|
|
40
40
|
export function isMasterKeyConfigured() {
|
|
41
41
|
return Boolean(loadConfig().masterKey);
|
|
42
42
|
}
|
|
43
|
+
export function resetMasterKeyCache() {
|
|
44
|
+
cachedKey = null;
|
|
45
|
+
}
|
|
43
46
|
export function masterKeyVersion() {
|
|
44
47
|
return cachedKeyVersion;
|
|
45
48
|
}
|
|
@@ -7,11 +7,13 @@ import { getDb, closeDb, openDb, schema } from '../../db/index.js';
|
|
|
7
7
|
import { requireAdminAuth } from '../../auth/middleware.js';
|
|
8
8
|
import { sha256Hex } from '../../auth/ids.js';
|
|
9
9
|
import { recordAudit } from '../../db/repositories/audit.js';
|
|
10
|
-
import { loadConfig } from '../../config/index.js';
|
|
10
|
+
import { loadConfig, setConfigMasterKey } from '../../config/index.js';
|
|
11
11
|
import { getSettings } from '../../db/repositories/settings.js';
|
|
12
12
|
import { GatewayError } from '../../errors.js';
|
|
13
13
|
import { getAppVersion } from '../../version.js';
|
|
14
14
|
import { eq, sql } from 'drizzle-orm';
|
|
15
|
+
import { decryptBackupMasterKey, encryptBackupMasterKey } from '../../auth/backup-crypto.js';
|
|
16
|
+
import { parseMasterKey, resetMasterKeyCache } from '../../auth/crypto.js';
|
|
15
17
|
const BACKUP_VERSION = 1;
|
|
16
18
|
/** Reopen the in-process SQLite connection on the (possibly just-replaced)
|
|
17
19
|
* database file. The old connection must already be closed: a hot restore
|
|
@@ -52,8 +54,16 @@ function assertDatabaseUsable(expectedSchemaVersion) {
|
|
|
52
54
|
}
|
|
53
55
|
}
|
|
54
56
|
export async function registerBackupRoutes(app) {
|
|
55
|
-
app.addHook('preHandler',
|
|
57
|
+
app.addHook('preHandler', async (req, reply) => {
|
|
58
|
+
if (req.url === '/api/admin/backup/restore' && !getSettings().setupComplete)
|
|
59
|
+
return;
|
|
60
|
+
return requireAdminAuth(req, reply);
|
|
61
|
+
});
|
|
56
62
|
app.post('/api/admin/backup/create', async (req, reply) => {
|
|
63
|
+
const passphrase = req.body?.passphrase ?? '';
|
|
64
|
+
if (!/^\d{6}$/.test(passphrase)) {
|
|
65
|
+
throw new GatewayError('invalid_request_error', 'Backup passphrase must contain exactly six digits', { status: 400 });
|
|
66
|
+
}
|
|
57
67
|
const cfg = loadConfig();
|
|
58
68
|
const temp = path.join(cfg.dataDir, `.backup-${Date.now()}.sqlite`);
|
|
59
69
|
const Database = (await import('better-sqlite3')).default;
|
|
@@ -69,6 +79,7 @@ export async function registerBackupRoutes(app) {
|
|
|
69
79
|
const compressed = zlib.gzipSync(buf, { level: 6 });
|
|
70
80
|
const checksum = crypto.createHash('sha256').update(compressed).digest('hex');
|
|
71
81
|
const settings = getSettings();
|
|
82
|
+
const masterKey = parseMasterKey(cfg.masterKey ?? fs.readFileSync(path.join(cfg.dataDir, 'master.key'), 'utf8').trim());
|
|
72
83
|
const envelope = {
|
|
73
84
|
format: 'latedev-backup',
|
|
74
85
|
version: BACKUP_VERSION,
|
|
@@ -78,6 +89,7 @@ export async function registerBackupRoutes(app) {
|
|
|
78
89
|
createdAt: new Date().toISOString(),
|
|
79
90
|
payload: compressed.toString('base64'),
|
|
80
91
|
checksum,
|
|
92
|
+
keyEnvelope: encryptBackupMasterKey(masterKey, passphrase),
|
|
81
93
|
};
|
|
82
94
|
const envBuf = Buffer.from(JSON.stringify(envelope), 'utf8');
|
|
83
95
|
const fileName = `latedev-backup-${new Date().toISOString().replace(/[:.]/g, '-')}.ldb.json`;
|
|
@@ -88,12 +100,17 @@ export async function registerBackupRoutes(app) {
|
|
|
88
100
|
});
|
|
89
101
|
app.post('/api/admin/backup/restore', async (req, reply) => {
|
|
90
102
|
const cfg = loadConfig();
|
|
91
|
-
|
|
103
|
+
const incoming = req.body;
|
|
104
|
+
const wrapped = Boolean(incoming && 'backup' in incoming);
|
|
105
|
+
const wrappedBody = incoming;
|
|
106
|
+
const passphrase = wrapped ? wrappedBody.passphrase : undefined;
|
|
92
107
|
let envelope;
|
|
93
108
|
try {
|
|
94
|
-
envelope =
|
|
109
|
+
envelope = (wrapped ? wrappedBody.backup : incoming);
|
|
95
110
|
if (!envelope || envelope.format !== 'latedev-backup')
|
|
96
111
|
throw new Error('not a backup envelope');
|
|
112
|
+
if (envelope.keyEnvelope && !/^\d{6}$/.test(passphrase ?? ''))
|
|
113
|
+
throw new Error('backup passphrase required');
|
|
97
114
|
}
|
|
98
115
|
catch {
|
|
99
116
|
recordAudit({ action: 'db.restore', success: false, ip: req.ip, metadata: { reason: 'invalid_envelope' } });
|
|
@@ -122,6 +139,17 @@ export async function registerBackupRoutes(app) {
|
|
|
122
139
|
recordAudit({ action: 'db.restore', success: false, ip: req.ip, metadata: { reason: 'not_sqlite' } });
|
|
123
140
|
throw new GatewayError('invalid_request_error', 'Backup does not contain a valid SQLite database', { status: 400 });
|
|
124
141
|
}
|
|
142
|
+
let restoredMasterKey;
|
|
143
|
+
if (envelope.keyEnvelope) {
|
|
144
|
+
try {
|
|
145
|
+
restoredMasterKey = decryptBackupMasterKey(envelope.keyEnvelope, passphrase);
|
|
146
|
+
parseMasterKey(restoredMasterKey.toString('base64'));
|
|
147
|
+
}
|
|
148
|
+
catch {
|
|
149
|
+
recordAudit({ action: 'db.restore', success: false, ip: req.ip, metadata: { reason: 'invalid_backup_passphrase' } });
|
|
150
|
+
throw new GatewayError('invalid_request_error', 'Invalid backup passphrase', { status: 400 });
|
|
151
|
+
}
|
|
152
|
+
}
|
|
125
153
|
const liveDb = cfg.dbFile;
|
|
126
154
|
// Snapshot current DB before restore (kept for manual rollback).
|
|
127
155
|
const snapshot = path.join(cfg.dataDir, `pre-restore-${Date.now()}.sqlite`);
|
|
@@ -184,6 +212,12 @@ export async function registerBackupRoutes(app) {
|
|
|
184
212
|
recordAudit({ action: 'db.restore', success: false, ip: req.ip, metadata: { reason: 'validation_failed', err: e.message } });
|
|
185
213
|
throw new GatewayError('gateway_error', `Restore failed: ${e.message}`, { status: 500 });
|
|
186
214
|
}
|
|
215
|
+
if (restoredMasterKey) {
|
|
216
|
+
const restoredKey = restoredMasterKey.toString('base64');
|
|
217
|
+
fs.writeFileSync(path.join(cfg.dataDir, 'master.key'), restoredKey, { mode: 0o600, encoding: 'utf8' });
|
|
218
|
+
setConfigMasterKey(restoredKey);
|
|
219
|
+
resetMasterKeyCache();
|
|
220
|
+
}
|
|
187
221
|
// The swap invalidates the previous admin session (its row lived in the
|
|
188
222
|
// old database). Re-create the current session in the restored database so
|
|
189
223
|
// the admin stays logged in across the hot restore.
|