Skip to content

Commit df1ed3c

Browse files
committed
minor bugfixes
1 parent e8c0f94 commit df1ed3c

10 files changed

Lines changed: 481 additions & 10 deletions

File tree

README.md

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,59 @@ To expose the wallet RPC port to the host, uncomment the `ports` block for `wall
189189

190190
---
191191

192+
## Credential recovery
193+
194+
### Change password (you know the current one)
195+
196+
Go to **Settings → Change password** (`/management/settings`) while logged in.
197+
198+
### Reset password (locked out)
199+
200+
If you are locked out and cannot log in, generate a new PBKDF2 hash and write it directly to the prefs database:
201+
202+
```bash
203+
# 1. Generate a hash for your new password
204+
NEW_HASH=$(docker run --rm node:22-alpine node -e "
205+
const crypto = require('crypto');
206+
const salt = crypto.randomBytes(16).toString('hex');
207+
crypto.pbkdf2('YOUR_NEW_PASSWORD', salt, 100000, 64, 'sha512', (_, key) => {
208+
process.stdout.write('pbkdf2:sha512:100000:' + salt + ':' + key.toString('hex'));
209+
});
210+
")
211+
212+
# 2. Write it to the database
213+
docker run --rm \
214+
-v "$(pwd)/mintlayer-data/prefs:/prefs" \
215+
alpine sh -c "apk add -q sqlite && sqlite3 /prefs/mintlayer_prefs.sqlite \
216+
\"INSERT OR REPLACE INTO prefs VALUES ('auth.password_hash', '\\\"${NEW_HASH}\\\"');\""
217+
```
218+
219+
### Reset TOTP 2FA (lost authenticator)
220+
221+
Use the bundled `update-totp` script — it generates a new secret, shows a scannable QR code, and only saves after you confirm with a valid code:
222+
223+
```bash
224+
# Inside a running container
225+
docker compose exec web-gui node scripts/update-totp.mjs
226+
227+
# One-shot (stack does not need to be running)
228+
docker run --rm -it \
229+
-v "$(pwd)/mintlayer-data/prefs:/app/prefs" \
230+
<web-gui-image> node scripts/update-totp.mjs
231+
232+
# On the host (if Node is available)
233+
PREFS_DB_PATH=./mintlayer-data/prefs/mintlayer_prefs.sqlite \
234+
node app/scripts/update-totp.mjs
235+
```
236+
237+
After saving, restart the web-gui container so the new secret takes effect:
238+
239+
```bash
240+
docker compose restart web-gui
241+
```
242+
243+
---
244+
192245
## Security
193246

194247
- Run `./init.sh` or set strong passwords in `.env` before exposing this to any network.

app/Dockerfile

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ WORKDIR /app
3939
COPY --from=builder /app/dist ./dist
4040
COPY --from=builder /app/wasm-wrappers ./wasm-wrappers
4141
COPY --from=builder /app/package.json ./package.json
42+
COPY --from=builder /app/scripts ./scripts
4243
COPY --from=prod-deps /app/node_modules ./node_modules
4344

4445
ENV HOST=0.0.0.0

app/package-lock.json

Lines changed: 3 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

app/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
"@types/qrcode": "^1.5.6",
2323
"astro": "^6.1.6",
2424
"better-sqlite3": "^12.9.0",
25+
"es-module-lexer": "^1.7.0",
2526
"clsx": "^2.1.1",
2627
"qrcode": "^1.5.4",
2728
"qrcode.react": "^4.2.0",

app/scripts/update-totp.mjs

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
#!/usr/bin/env node
2+
import crypto from 'node:crypto';
3+
import readline from 'node:readline';
4+
import { existsSync } from 'node:fs';
5+
import qrcode from 'qrcode';
6+
import Database from 'better-sqlite3';
7+
8+
const RESET = '\x1b[0m';
9+
const BOLD = '\x1b[1m';
10+
const CYAN = '\x1b[36m';
11+
const GREEN = '\x1b[32m';
12+
const RED = '\x1b[31m';
13+
const DIM = '\x1b[2m';
14+
const YELLOW = '\x1b[33m';
15+
16+
const DB_PATH = process.env.PREFS_DB_PATH ?? '/app/prefs/mintlayer_prefs.sqlite';
17+
18+
// ── TOTP helpers (mirrors app/src/lib/auth.ts) ──────────────────────────────
19+
20+
function generateTotpSecret() {
21+
const bytes = crypto.randomBytes(20);
22+
const alpha = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
23+
let result = '', bits = 0, value = 0;
24+
for (let i = 0; i < bytes.length; i++) {
25+
value = (value << 8) | bytes[i];
26+
bits += 8;
27+
while (bits >= 5) {
28+
result += alpha[(value >>> (bits - 5)) & 31];
29+
bits -= 5;
30+
}
31+
}
32+
if (bits > 0) result += alpha[(value << (5 - bits)) & 31];
33+
return result;
34+
}
35+
36+
function decodeBase32(input) {
37+
const alpha = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
38+
const str = input.toUpperCase().replace(/=+$/, '').replace(/\s/g, '');
39+
let bits = 0, value = 0;
40+
const out = [];
41+
for (const ch of str) {
42+
const idx = alpha.indexOf(ch);
43+
if (idx === -1) continue;
44+
value = (value << 5) | idx;
45+
bits += 5;
46+
if (bits >= 8) { out.push((value >>> (bits - 8)) & 0xff); bits -= 8; }
47+
}
48+
return Buffer.from(out);
49+
}
50+
51+
function hotpCode(key, counter) {
52+
const buf = Buffer.alloc(8);
53+
buf.writeBigUInt64BE(counter);
54+
const hmac = crypto.createHmac('sha1', key).update(buf).digest();
55+
const offset = hmac[hmac.length - 1] & 0x0f;
56+
const code =
57+
(((hmac[offset] & 0x7f) << 24) |
58+
((hmac[offset + 1] & 0xff) << 16) |
59+
((hmac[offset + 2] & 0xff) << 8) |
60+
(hmac[offset + 3] & 0xff)) % 1_000_000;
61+
return code.toString().padStart(6, '0');
62+
}
63+
64+
function verifyTOTP(code, secret) {
65+
if (!code || code.length !== 6 || !/^\d{6}$/.test(code)) return false;
66+
const key = decodeBase32(secret);
67+
const T = BigInt(Math.floor(Date.now() / 1000 / 30));
68+
for (const delta of [-1n, 0n, 1n]) {
69+
const candidate = hotpCode(key, T + delta);
70+
if (crypto.timingSafeEqual(Buffer.from(candidate), Buffer.from(code))) return true;
71+
}
72+
return false;
73+
}
74+
75+
// ── Readline helper ──────────────────────────────────────────────────────────
76+
77+
function prompt(rl, question) {
78+
return new Promise(resolve => rl.question(question, resolve));
79+
}
80+
81+
// ── Main ─────────────────────────────────────────────────────────────────────
82+
83+
async function main() {
84+
console.log(`\n${BOLD}${CYAN}┌─────────────────────────────────────────┐${RESET}`);
85+
console.log(`${BOLD}${CYAN}│ Mintlayer GUI — Update TOTP Secret │${RESET}`);
86+
console.log(`${BOLD}${CYAN}└─────────────────────────────────────────┘${RESET}\n`);
87+
88+
if (!existsSync(DB_PATH)) {
89+
console.error(`${RED}Error: database not found at ${DB_PATH}${RESET}`);
90+
console.error(`${DIM}Set PREFS_DB_PATH env var to point to mintlayer_prefs.sqlite${RESET}`);
91+
process.exit(1);
92+
}
93+
94+
const secret = generateTotpSecret();
95+
const issuer = 'Mintlayer';
96+
const label = encodeURIComponent('Mintlayer GUI');
97+
const uri = `otpauth://totp/${label}?secret=${secret}&issuer=${issuer}`;
98+
99+
// Render QR code
100+
const qr = await qrcode.toString(uri, { type: 'utf8', errorCorrectionLevel: 'M' });
101+
console.log(qr);
102+
103+
console.log(`${BOLD}TOTP Secret (manual entry):${RESET}`);
104+
console.log(` ${YELLOW}${BOLD}${secret}${RESET}\n`);
105+
console.log(`${DIM}otpauth URI:${RESET}`);
106+
console.log(` ${DIM}${uri}${RESET}\n`);
107+
108+
console.log(`${YELLOW}Scan the QR code with your authenticator app before continuing.${RESET}`);
109+
console.log(`${YELLOW}The current secret will be overwritten and cannot be recovered.${RESET}\n`);
110+
111+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
112+
113+
let confirmed = false;
114+
for (let attempt = 1; attempt <= 3; attempt++) {
115+
const code = (await prompt(rl, `${BOLD}Enter the 6-digit code from your authenticator: ${RESET}`)).trim();
116+
if (verifyTOTP(code, secret)) {
117+
confirmed = true;
118+
break;
119+
}
120+
console.log(`${RED}Invalid code.${attempt < 3 ? ` ${3 - attempt} attempt(s) remaining.` : ''}${RESET}`);
121+
}
122+
123+
rl.close();
124+
125+
if (!confirmed) {
126+
console.log(`\n${RED}Aborted — TOTP secret was NOT saved.${RESET}\n`);
127+
process.exit(1);
128+
}
129+
130+
// Write to SQLite
131+
const db = new Database(DB_PATH);
132+
db.prepare("INSERT OR REPLACE INTO prefs (key, value) VALUES ('auth.totp_secret', ?)").run(JSON.stringify(secret));
133+
db.close();
134+
135+
console.log(`\n${GREEN}${BOLD}✓ TOTP secret updated successfully.${RESET}`);
136+
console.log(`${DIM}Restart the web-gui container if it is currently running.${RESET}\n`);
137+
}
138+
139+
main().catch(err => {
140+
console.error(`${RED}Fatal: ${err.message}${RESET}`);
141+
process.exit(1);
142+
});

app/src/lib/wallet-rpc.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,25 @@ export async function createWallet(
194194
});
195195
}
196196

197+
/**
198+
* Recover an existing wallet from a mnemonic by scanning the full blockchain.
199+
* Blocks until the scan completes — can take many minutes on mainnet.
200+
*/
201+
export async function recoverWallet(
202+
path: string,
203+
mnemonic: string,
204+
storeSeedPhrase: boolean = true,
205+
passphrase?: string,
206+
): Promise<CreateWalletResult> {
207+
return rpcCall<CreateWalletResult>('wallet_recover', {
208+
path,
209+
store_seed_phrase: storeSeedPhrase,
210+
mnemonic,
211+
passphrase: passphrase ?? null,
212+
hardware_wallet: null,
213+
});
214+
}
215+
197216
// ── Sync / node info ──────────────────────────────────────────────────────────
198217

199218
/** Wallet's view of the best block (reflects wallet sync state). */
@@ -522,6 +541,10 @@ export async function walletUnlockPrivateKeys(password: string): Promise<void> {
522541
return rpcCall('wallet_unlock_private_keys', { password });
523542
}
524543

544+
export async function walletClose(): Promise<void> {
545+
return rpcCall('wallet_close', {});
546+
}
547+
525548
export async function walletSetLookaheadSize(lookaheadSize: number): Promise<void> {
526549
return rpcCall('wallet_set_lookahead_size', {
527550
lookahead_size: lookaheadSize,

app/src/pages/api/wallet-download.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type { APIRoute } from 'astro';
22

33
const WALLET_FILENAME = 'mintlayer.wallet';
4-
// ./mintlayer-data/ on host is mounted read-only at /app/mintlayer-data/ in the web-gui container
4+
// ./mintlayer-data/ on host is mounted at /app/mintlayer-data/ in the web-gui container
55
const LOCAL_PATH = `/app/mintlayer-data/${WALLET_FILENAME}`;
66

77
export const GET: APIRoute = async () => {

0 commit comments

Comments
 (0)