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:
parent
d6cb311066
commit
cbbc4e689b
2 changed files with 82 additions and 20 deletions
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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', '');
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue