diff --git a/Parameters/dApp/Faucet/build-seed-enc.mjs b/Parameters/dApp/Faucet/build-seed-enc.mjs index 4ddc2ed..0786a99 100644 --- a/Parameters/dApp/Faucet/build-seed-enc.mjs +++ b/Parameters/dApp/Faucet/build-seed-enc.mjs @@ -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 # 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 , or set FAUCET_MNEMONIC/FAUCET_WIF.'); process.exit(1); } +if (!wantMasterPw && !mnemonic && !wif) { console.error('No key material: pass --master-pw, --mnemonic "..." or --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)); diff --git a/Parameters/dApp/Faucet/index.html b/Parameters/dApp/Faucet/index.html index 42d28b3..81ed5ce 100644 --- a/Parameters/dApp/Faucet/index.html +++ b/Parameters/dApp/Faucet/index.html @@ -228,6 +228,7 @@
Owner sign-in
+
The seed phrase never leaves this page. It derives a per-purpose key (BIP-32 path m/44'/145'/1417'/2/0) that decrypts the owner list. Only that derived key is remembered on this device if you tick the box.
@@ -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 " + FAUCET_ENC_PATH + ") 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 locally 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', '');