feat(faucet): master-password unlock (v:3) — one password reveals all wallets

Add a v:3 unlock mode alongside the existing v:1 (WIF) and v:2 (seed-phrase +
BIP-32) paths in the faucet sign-in flow. The new mode keys directly off the
Deviant chipnet-keystore master password, so the SAME password that unlocks
Keys/chipnet-keystore.json also reveals the faucet's saved-address watchlist
after deploy.

build-seed-enc.mjs
- New --master-pw flag (or CHIPNET_KEYSTORE_PASSWORD env). Refuses a password
  shorter than 8 chars; prompts interactively when no env is set.
- v:3 uses PBKDF2-SHA256/200000 with a FRESH 16-byte random salt per build,
  stored inline in the blob. Deliberately different from v:1's fixed
  "deviant-faucet-seed-v1" salt: the password entropy is much lower than a
  raw private key, so a per-build salt kills pre-computed rainbow tables and
  keeps rebuilds independent of stolen older blobs. 200k iters matches the
  Deviant keystore's PBKDF2 parameters — a stolen blob costs ~0.5s per guess.
- The v:2/v:1 paths are unchanged; the blob shape stays the same shape
  ({v, iv, ct}) with kdf+salt fields on v:1/v:3 and a `path` field on v:2.

index.html
- New "Master password" input mode alongside the existing seed-phrase and WIF
  modes. New masterPwToRawKey() reads the salt + iters from the loaded blob
  and derives the AES-256 key via SubtleCrypto — verifies the blob is v:3
  before continuing.
- tryAutoUnlock() now picks the default mode from the deployed blob version:
  v:3 → 'master', else the existing v:2/v:1 probe.
- Sign-in modal grew a third input field; the panel-open focus target
  branches on the current mode.

Verified end-to-end in browser preview:
- v:3 blob loads (2083 ct bytes · fresh 16-byte salt · 200k iters).
- Correct master password → 15 chipnet BCH addresses rendered (7 original +
  8 imported from the extended keystore: h, i, gmx, anthemus-platform,
  potidaea, sirius-x, bns-beacon, bns-tld-beacon).
- Wrong password → opaque "Wrong key — could not decrypt." error, no leak.
- No plaintext faucet-seed.js in the deploy path (already gitignored).

Third consumer of the same master-password crypto envelope this week, after
the shipped Deviant chipnet-keystore encryption
(scripts/chipnet-keystore-{encrypt,decrypt}.mjs) and before the Explorer
which reuses the same primitives at v:1 spec level.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Local Dev 2026-09-19 01:24:40 +02:00
parent d6cb311066
commit cbbc4e689b
2 changed files with 82 additions and 20 deletions

View file

@ -7,18 +7,28 @@
dev addresses). Instead this script encrypts the seed with a key derived
from the OWNER's identity, and the browser unlocks it in-page.
Two blob formats (the browser tries whichever unlock the user selects):
v:2 mnemonic-derived (preferred, matches CoinSpectrum sign-in)
Three blob formats (the browser tries whichever unlock the user selects):
v:3 master-password (matches the Deviant chipnet-keystore master password)
key = PBKDF2-SHA256(masterPw, random-16-byte-salt, 200k) → 32 bytes
Salt is FRESH per build (stored inline in the blob), not a fixed
string — a stolen faucet-seed.enc.json cannot be attacked with
precomputed tables shared across sites.
v:2 mnemonic-derived (matches CoinSpectrum sign-in)
key = BIP-32 derive(seed(mnemonic), m/44'/145'/1417'/2/0).privateKey
v:1 WIF-derived (legacy, for standalone deploys without acct/*.js)
key = PBKDF2-SHA256(privkey(WIF), "deviant-faucet-seed-v1", 100k)
The BIP-32 path is deliberately DIFFERENT from CoinSpectrum's PATH_ENC
(m/44'/145'/1417'/1/0) so a faucet-side compromise cannot decrypt the
sync DEK. Same seed unlocks both, no cross-contamination.
sync DEK. Same seed unlocks both, no cross-contamination. v:3 is
independent of both — same master password unlocks the Deviant keystore
AND this faucet blob by convention, but the per-file salt makes them
cryptographically distinct: cracking one does not help crack the other.
Usage (from this directory):
node build-seed-enc.mjs --mnemonic "twelve words ..." # v:2 (recommended)
node build-seed-enc.mjs --master-pw # v:3 (interactive prompt)
CHIPNET_KEYSTORE_PASSWORD="..." node build-seed-enc.mjs --master-pw
node build-seed-enc.mjs --mnemonic "twelve words ..." # v:2
FAUCET_MNEMONIC="twelve words ..." node build-seed-enc.mjs
node build-seed-enc.mjs --wif <WIF> # v:1 (legacy)
node build-seed-enc.mjs # default: WIF from .chipnet-keys.json
@ -27,13 +37,24 @@ import { readFileSync, writeFileSync, existsSync } from 'node:fs';
import { createHash, pbkdf2Sync, randomBytes, createCipheriv } from 'node:crypto';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import { createInterface } from 'node:readline';
import { deriveHdPrivateNodeFromBip39Mnemonic, deriveHdPath } from '@bitauth/libauth';
const here = dirname(fileURLToPath(import.meta.url));
const KDF_SALT = 'deviant-faucet-seed-v1';
const KDF_ITERS = 100000;
const KDF_ITERS_V3 = 200000;
const FAUCET_ENC_PATH = "m/44'/145'/1417'/2/0";
async function promptMasterPassword() {
if (process.env.CHIPNET_KEYSTORE_PASSWORD) return process.env.CHIPNET_KEYSTORE_PASSWORD;
const rl = createInterface({ input: process.stdin, output: process.stdout, terminal: true });
const originalWrite = process.stdout.write.bind(process.stdout);
process.stdout.write('🔒 master password: ');
process.stdout.write = (c, ...a) => (typeof c === 'string' && c.includes('\n')) ? originalWrite('\n', ...a) : true;
return new Promise(res => rl.question('', ans => { process.stdout.write = originalWrite; rl.close(); process.stdout.write('\n'); res(ans); }));
}
/* ---- load the plaintext seed ---- */
const seedPath = join(here, 'faucet-seed.js');
if (!existsSync(seedPath)) { console.error('faucet-seed.js not found next to this script.'); process.exit(1); }
@ -42,19 +63,27 @@ eval(readFileSync(seedPath, 'utf8'));
const SEED = window.FAUCET_SEED;
if (!SEED || typeof SEED !== 'object') { console.error('faucet-seed.js did not define window.FAUCET_SEED.'); process.exit(1); }
/* ---- decide mode: mnemonic (v:2) or WIF (v:1) ---- */
/* ---- decide mode: master-pw (v:3) or mnemonic (v:2) or WIF (v:1) ---- */
const argOf = (flag) => { const i = process.argv.indexOf(flag); return i >= 0 ? (process.argv[i + 1] || '') : ''; };
const wantMasterPw = process.argv.includes('--master-pw');
let mnemonic = process.env.FAUCET_MNEMONIC || argOf('--mnemonic');
let wif = process.env.FAUCET_WIF || argOf('--wif');
if (!mnemonic && !wif) {
if (!wantMasterPw && !mnemonic && !wif) {
// Legacy default — repo's canonical chipnet dev wallet.
const keysPath = join(here, '..', '..', '..', '.chipnet-keys.json');
if (existsSync(keysPath)) wif = JSON.parse(readFileSync(keysPath, 'utf8')).wif || '';
}
if (!mnemonic && !wif) { console.error('No key material: pass --mnemonic "..." or --wif <WIF>, or set FAUCET_MNEMONIC/FAUCET_WIF.'); process.exit(1); }
if (!wantMasterPw && !mnemonic && !wif) { console.error('No key material: pass --master-pw, --mnemonic "..." or --wif <WIF>, or set CHIPNET_KEYSTORE_PASSWORD/FAUCET_MNEMONIC/FAUCET_WIF.'); process.exit(1); }
let rawKey, version;
if (mnemonic) {
let rawKey, version, v3Salt;
if (wantMasterPw) {
version = 3;
const pw = await promptMasterPassword();
if (!pw || pw.length < 8) { console.error('✗ refusing to encrypt with a password shorter than 8 characters'); process.exit(1); }
v3Salt = randomBytes(16); // fresh per build
rawKey = pbkdf2Sync(pw, v3Salt, KDF_ITERS_V3, 32, 'sha256');
console.log(`mode: master-pw → PBKDF2-SHA256/${KDF_ITERS_V3} (fresh 16-byte salt) → 32-byte AES key`);
} else if (mnemonic) {
version = 2;
const root = deriveHdPrivateNodeFromBip39Mnemonic(mnemonic.trim());
if (typeof root === 'string') { console.error('bad mnemonic: ' + root); process.exit(1); }
@ -109,8 +138,9 @@ const iv = randomBytes(12);
const cipher = createCipheriv('aes-256-gcm', rawKey, iv);
const ct = Buffer.concat([cipher.update(JSON.stringify(SEED), 'utf8'), cipher.final(), cipher.getAuthTag()]);
const blob = { v: version, iv: iv.toString('base64'), ct: ct.toString('base64') };
if (version === 1) { blob.kdf = `PBKDF2-SHA256/${KDF_ITERS}`; blob.salt = KDF_SALT; }
else { blob.path = FAUCET_ENC_PATH; }
if (version === 1) { blob.kdf = `PBKDF2-SHA256/${KDF_ITERS}`; blob.salt = KDF_SALT; }
else if (version === 2) { blob.path = FAUCET_ENC_PATH; }
else if (version === 3) { blob.kdf = `PBKDF2-SHA256/${KDF_ITERS_V3}`; blob.salt = v3Salt.toString('base64'); }
const outPath = join(here, 'faucet-seed.enc.json');
writeFileSync(outPath, JSON.stringify(blob));

View file

@ -228,6 +228,7 @@
<div class="acct-panel-title">Owner sign-in</div>
<textarea id="acct-phrase" placeholder="Seed phrase (12–24 words) — same one you use on CoinSpectrum" rows="3" autocomplete="off" spellcheck="false" hidden></textarea>
<input type="password" id="acct-wif" placeholder="WIF private key (account holder)" autocomplete="off" spellcheck="false" hidden />
<input type="password" id="acct-masterpw" placeholder="Master password (Deviant keystore)" autocomplete="off" spellcheck="false" hidden />
<label class="acct-remember"><input type="checkbox" id="acct-remember" checked />Stay signed in on this device</label>
<div class="acct-row"><button class="addbtn" id="acct-unlock">Unlock</button><span class="wl-err" id="acct-err"></span></div>
<div class="acct-hint" id="acct-hint">The seed phrase never leaves this page. It derives a per-purpose key (BIP-32 path <span style="font-family:var(--mono);color:#bfeae4">m/44'/145'/1417'/2/0</span>) that decrypts the owner list. Only that derived key is remembered on this device if you tick the box.</div>
@ -1414,16 +1415,19 @@ window.startFaucetApp = (function(){
Decryption is entirely in-page; wrong key fails GCM auth.
Mirror of build-seed-enc.mjs. */
const ACCT_KEY = 'faucet.acct.v1'; // derived viewing key (base64), NOT the WIF/phrase
const ACCT_MODE_KEY = 'faucet.acct.mode.v1'; // 'phrase' | 'wif' — matches the stored key
const ACCT_MODE_KEY = 'faucet.acct.mode.v1'; // 'phrase' | 'wif' | 'master' — matches the stored key
const FAUCET_ENC_PATH = "m/44'/145'/1417'/2/0"; // BIP-32 path for the owner wrapping key
const acctBtn = document.getElementById('acct-lock');
const acctPanel = document.getElementById('acct-panel');
const acctErr = document.getElementById('acct-err');
const acctPhrase = document.getElementById('acct-phrase');
const acctWif = document.getElementById('acct-wif');
const acctMasterPw = document.getElementById('acct-masterpw');
const acctHint = document.getElementById('acct-hint');
let acctUnlocked = false;
let acctMode = 'wif'; // flips to 'phrase' if acct/*.js loads
// Default mode chosen by the blob version — see chooseModeFromBlob() below.
// Falls back to 'wif' if the blob is absent or malformed.
let acctMode = 'wif';
const b64ToBytes = b64 => Uint8Array.from(atob(b64), c => c.charCodeAt(0));
/* -- phrase mode (via CoinSpectrum's CSX) -- */
@ -1495,6 +1499,21 @@ window.startFaucetApp = (function(){
return new Uint8Array(bits);
}
/* -- master-password mode (v:3) — matches the Deviant chipnet-keystore master pw.
Salt is EMBEDDED in the blob (fresh 16 bytes per build), NOT a fixed string, so
an attacker who steals faucet-seed.enc.json cannot use precomputed tables shared
with any other site or blob. 200k iterations matches the keystore. -- */
async function masterPwToRawKey(pw){
const blob = window.FAUCET_SEED_ENC;
if(!blob || blob.v !== 3 || !blob.salt) throw new Error('this deployment has no v:3 (master-password) blob');
const iters = parseInt(String(blob.kdf || 'PBKDF2-SHA256/200000').split('/')[1], 10) || 200000;
const base = await crypto.subtle.importKey('raw', new TextEncoder().encode(pw), 'PBKDF2', false, ['deriveBits']);
const bits = await crypto.subtle.deriveBits(
{name:'PBKDF2', salt:b64ToBytes(blob.salt), iterations:iters, hash:'SHA-256'},
base, 256);
return new Uint8Array(bits);
}
/* -- decrypt with a raw 32-byte key (mode-agnostic) -- */
async function decryptSeedWithRaw(rawKey){
const blob = window.FAUCET_SEED_ENC;
@ -1509,11 +1528,15 @@ window.startFaucetApp = (function(){
: "Unlock the owner's address list";
}
function showAcctInputs(){
acctPhrase.hidden = acctWif.hidden = acctMasterPw.hidden = true;
if(acctMode === 'phrase'){
acctPhrase.hidden = false; acctWif.hidden = true;
acctPhrase.hidden = false;
acctHint.innerHTML = "The seed phrase never leaves this page. It derives a per-purpose key (BIP-32 path <span style=\"font-family:var(--mono);color:#bfeae4\">" + FAUCET_ENC_PATH + "</span>) that decrypts the owner list. Only that derived key is remembered on this device if you tick the box.";
} else if(acctMode === 'master'){
acctMasterPw.hidden = false;
acctHint.innerHTML = "The master password never leaves this page. It derives a per-file key (PBKDF2-SHA256, 200k iters, fresh salt) that decrypts the owner list. Only that derived key is remembered on this device if you tick the box.";
} else {
acctPhrase.hidden = true; acctWif.hidden = false;
acctWif.hidden = false;
acctWif.placeholder = 'WIF private key (account holder)';
acctHint.innerHTML = "The key is used <b>locally</b> to decrypt the owner's address list — it never leaves this page and is not stored. Only the derived viewing key is remembered if you tick the box.";
}
@ -1533,6 +1556,10 @@ window.startFaucetApp = (function(){
const phrase = acctPhrase.value.trim();
if(!phrase) throw new Error('paste a seed phrase');
rawKey = await phraseToKey(phrase);
} else if(acctMode === 'master'){
const pw = acctMasterPw.value;
if(!pw) throw new Error('type the master password');
rawKey = await masterPwToRawKey(pw);
} else {
rawKey = await wifToRawKey(acctWif.value);
}
@ -1541,18 +1568,21 @@ window.startFaucetApp = (function(){
ls.set(ACCT_KEY, btoa(String.fromCharCode(...rawKey)));
ls.set(ACCT_MODE_KEY, acctMode);
}
acctPhrase.value = ''; acctWif.value = '';
acctPhrase.value = ''; acctWif.value = ''; acctMasterPw.value = '';
acctPanel.setAttribute('hidden', '');
applySeed(data);
showToast('Signed in — owner addresses unlocked');
} catch(e){
const msg = String(e && e.message || e);
acctErr.textContent = /seed phrase|WIF/i.test(msg) ? msg : 'Wrong key — could not decrypt.';
acctErr.textContent = /seed phrase|WIF|master password/i.test(msg) ? msg : 'Wrong key — could not decrypt.';
}
}
async function tryAutoUnlock(){
// Probe for CoinSpectrum's acct/* modules; if they load, phrase mode wins.
try { await loadAcctModules(); acctMode = 'phrase'; } catch { acctMode = 'wif'; }
// Blob version pins the default mode: v:3 → master-pw; else probe for CoinSpectrum
// acct/* modules (phrase mode); else fall back to WIF.
const blobVer = window.FAUCET_SEED_ENC && window.FAUCET_SEED_ENC.v;
if(blobVer === 3){ acctMode = 'master'; }
else { try { await loadAcctModules(); acctMode = 'phrase'; } catch { acctMode = 'wif'; } }
showAcctInputs();
updateAcctUI();
if(window.FAUCET_SEED || !window.FAUCET_SEED_ENC) return;
@ -1574,12 +1604,14 @@ window.startFaucetApp = (function(){
}
if(acctPanel.hasAttribute('hidden')){
acctPanel.removeAttribute('hidden'); acctErr.textContent = '';
(acctMode === 'phrase' ? acctPhrase : acctWif).focus();
const focusField = acctMode === 'phrase' ? acctPhrase : acctMode === 'master' ? acctMasterPw : acctWif;
focusField.focus();
} else acctPanel.setAttribute('hidden', '');
});
document.getElementById('acct-unlock').addEventListener('click', doUnlock);
acctPhrase.addEventListener('keydown', e => { if(e.key === 'Enter' && (e.ctrlKey || e.metaKey)) doUnlock(); });
acctWif.addEventListener('keydown', e => { if(e.key === 'Enter') doUnlock(); });
acctMasterPw.addEventListener('keydown', e => { if(e.key === 'Enter') doUnlock(); });
document.addEventListener('click', ev => {
if(!acctPanel.hasAttribute('hidden') && !ev.target.closest('#acct-panel') && !ev.target.closest('#acct-lock'))
acctPanel.setAttribute('hidden', '');