418 lines
14 KiB
Nim
418 lines
14 KiB
Nim
import
|
|
std/[os, json, strutils, terminal, wordwrap],
|
|
stew/byteutils, chronicles, chronos, web3, stint, json_serialization,
|
|
serialization, blscurve, eth/common/eth_types, eth/keys, confutils, bearssl,
|
|
spec/[datatypes, digest, crypto, keystore],
|
|
conf, ssz/merkleization, network_metadata
|
|
|
|
export
|
|
keystore
|
|
|
|
{.push raises: [Defect].}
|
|
|
|
const
|
|
keystoreFileName* = "keystore.json"
|
|
|
|
type
|
|
WalletDataForDeposits* = object
|
|
mnemonic*: Mnemonic
|
|
nextAccount*: Natural
|
|
|
|
proc loadKeyStore(conf: BeaconNodeConf|ValidatorClientConf,
|
|
validatorsDir, keyName: string): Option[ValidatorPrivKey] =
|
|
let
|
|
keystorePath = validatorsDir / keyName / keystoreFileName
|
|
keystoreContents = KeyStoreContent:
|
|
try: readFile(keystorePath)
|
|
except IOError as err:
|
|
error "Failed to read keystore", err = err.msg, path = keystorePath
|
|
return
|
|
|
|
let passphrasePath = conf.secretsDir / keyName
|
|
if fileExists(passphrasePath):
|
|
let
|
|
passphrase = KeyStorePass:
|
|
try: readFile(passphrasePath)
|
|
except IOError as err:
|
|
error "Failed to read passphrase file", err = err.msg, path = passphrasePath
|
|
return
|
|
|
|
let res = decryptKeystore(keystoreContents, passphrase)
|
|
if res.isOk:
|
|
return res.get.some
|
|
else:
|
|
error "Failed to decrypt keystore", keystorePath, passphrasePath
|
|
return
|
|
|
|
if conf.nonInteractive:
|
|
error "Unable to load validator key store. Please ensure matching passphrase exists in the secrets dir",
|
|
keyName, validatorsDir, secretsDir = conf.secretsDir
|
|
return
|
|
|
|
var remainingAttempts = 3
|
|
var prompt = "Please enter passphrase for key \"" & validatorsDir/keyName & "\"\n"
|
|
while remainingAttempts > 0:
|
|
let passphrase = KeyStorePass:
|
|
try: readPasswordFromStdin(prompt)
|
|
except IOError:
|
|
error "STDIN not readable. Cannot obtain KeyStore password"
|
|
return
|
|
|
|
let decrypted = decryptKeystore(keystoreContents, passphrase)
|
|
if decrypted.isOk:
|
|
return decrypted.get.some
|
|
else:
|
|
prompt = "Keystore decryption failed. Please try again"
|
|
dec remainingAttempts
|
|
|
|
iterator validatorKeys*(conf: BeaconNodeConf|ValidatorClientConf): ValidatorPrivKey =
|
|
for validatorKeyFile in conf.validators:
|
|
try:
|
|
yield validatorKeyFile.load
|
|
except CatchableError as err:
|
|
error "Failed to load validator private key",
|
|
file = validatorKeyFile.string, err = err.msg
|
|
quit 1
|
|
|
|
let validatorsDir = conf.validatorsDir
|
|
try:
|
|
for kind, file in walkDir(validatorsDir):
|
|
if kind == pcDir:
|
|
let keyName = splitFile(file).name
|
|
let key = loadKeyStore(conf, validatorsDir, keyName)
|
|
if key.isSome:
|
|
yield key.get
|
|
else:
|
|
quit 1
|
|
except OSError as err:
|
|
error "Validator keystores directory not accessible",
|
|
path = validatorsDir, err = err.msg
|
|
quit 1
|
|
|
|
type
|
|
GenerateDepositsError = enum
|
|
RandomSourceDepleted,
|
|
FailedToCreateValidatoDir
|
|
FailedToCreateSecretFile
|
|
FailedToCreateKeystoreFile
|
|
|
|
proc generateDeposits*(preset: RuntimePreset,
|
|
rng: var BrHmacDrbgContext,
|
|
walletData: WalletDataForDeposits,
|
|
totalValidators: int,
|
|
validatorsDir: string,
|
|
secretsDir: string): Result[seq[DepositData], GenerateDepositsError] =
|
|
var deposits: seq[DepositData]
|
|
|
|
info "Generating deposits", totalValidators, validatorsDir, secretsDir
|
|
|
|
let withdrawalKeyPath = makeKeyPath(0, withdrawalKeyKind)
|
|
# TODO: Explain why we are using an empty password
|
|
var withdrawalKey = keyFromPath(walletData.mnemonic, KeyStorePass"", withdrawalKeyPath)
|
|
defer: burnMem(withdrawalKey)
|
|
let withdrawalPubKey = withdrawalKey.toPubKey
|
|
|
|
for i in 0 ..< totalValidators:
|
|
let keyStoreIdx = walletData.nextAccount + i
|
|
let password = KeyStorePass getRandomBytes(rng, 32).toHex
|
|
|
|
let signingKeyPath = withdrawalKeyPath.append keyStoreIdx
|
|
var signingKey = deriveChildKey(withdrawalKey, keyStoreIdx)
|
|
defer: burnMem(signingKey)
|
|
let signingPubKey = signingKey.toPubKey
|
|
|
|
let keyStore = encryptKeystore(KdfPbkdf2, rng, signingKey,
|
|
password, signingKeyPath)
|
|
let
|
|
keyName = $signingPubKey
|
|
validatorDir = validatorsDir / keyName
|
|
keystoreFile = validatorDir / keystoreFileName
|
|
|
|
if existsDir(validatorDir):
|
|
continue
|
|
|
|
try: createDir validatorDir
|
|
except OSError, IOError: return err FailedToCreateValidatoDir
|
|
|
|
try: writeFile(secretsDir / keyName, password.string)
|
|
except IOError: return err FailedToCreateSecretFile
|
|
|
|
try: writeFile(keystoreFile, keyStore.string)
|
|
except IOError: return err FailedToCreateKeystoreFile
|
|
|
|
deposits.add preset.prepareDeposit(withdrawalPubKey, signingKey, signingPubKey)
|
|
|
|
ok deposits
|
|
|
|
const
|
|
minPasswordLen = 10
|
|
|
|
mostCommonPasswords = wordListArray(
|
|
currentSourcePath.parentDir /
|
|
"../vendor/nimbus-security-resources/passwords/10-million-password-list-top-100000.txt",
|
|
minWordLength = minPasswordLen)
|
|
|
|
proc saveWallet*(wallet: Wallet, outWalletPath: string): Result[void, string] =
|
|
let
|
|
uuid = wallet.uuid
|
|
walletContent = WalletContent Json.encode(wallet, pretty = true)
|
|
|
|
try: createDir splitFile(outWalletPath).dir
|
|
except OSError, IOError:
|
|
let e = getCurrentException()
|
|
return err("failure to create wallet directory: " & e.msg)
|
|
|
|
try: writeFile(outWalletPath, string walletContent)
|
|
except IOError as e:
|
|
return err("failure to write file: " & e.msg)
|
|
|
|
ok()
|
|
|
|
proc readPasswordInput(prompt: string, password: var TaintedString): bool =
|
|
try: readPasswordFromStdin(prompt, password)
|
|
except IOError: false
|
|
|
|
proc setStyleNoError(styles: set[Style]) =
|
|
when defined(windows):
|
|
try: stdout.setStyle(styles)
|
|
except: discard
|
|
else:
|
|
try: stdout.setStyle(styles)
|
|
except IOError, ValueError: discard
|
|
|
|
proc setForegroundColorNoError(color: ForegroundColor) =
|
|
when defined(windows):
|
|
try: stdout.setForegroundColor(color)
|
|
except: discard
|
|
else:
|
|
try: stdout.setForegroundColor(color)
|
|
except IOError, ValueError: discard
|
|
|
|
proc resetAttributesNoError() =
|
|
when defined(windows):
|
|
try: stdout.resetAttributes()
|
|
except: discard
|
|
else:
|
|
try: stdout.resetAttributes()
|
|
except IOError: discard
|
|
|
|
proc createWalletInteractively*(
|
|
rng: var BrHmacDrbgContext,
|
|
conf: BeaconNodeConf): Result[WalletDataForDeposits, string] =
|
|
|
|
if conf.nonInteractive:
|
|
return err "not running in interactive mode"
|
|
|
|
var mnemonic = generateMnemonic(rng)
|
|
defer: burnMem(mnemonic)
|
|
|
|
template ask(prompt: string): string =
|
|
try:
|
|
stdout.write prompt, ": "
|
|
stdin.readLine()
|
|
except IOError:
|
|
return err "failure to read data from stdin"
|
|
|
|
template echo80(msg: string) =
|
|
echo wrapWords(msg, 80)
|
|
|
|
echo80 "The generated wallet is uniquely identified by a seed phrase " &
|
|
"consisting of 24 words. In case you lose your wallet and you " &
|
|
"need to restore it on a different machine, you must use the " &
|
|
"words displayed below:"
|
|
|
|
try:
|
|
echo ""
|
|
setStyleNoError({styleBright})
|
|
setForegroundColorNoError fgCyan
|
|
echo80 $mnemonic
|
|
resetAttributesNoError()
|
|
echo ""
|
|
except IOError, ValueError:
|
|
return err "failure to write to the standard output"
|
|
|
|
echo80 "Please back up the seed phrase now to a safe location as " &
|
|
"if you are protecting a sensitive password. The seed phrase " &
|
|
"can be used to withdrawl funds from your wallet."
|
|
|
|
echo ""
|
|
echo "Did you back up your seed recovery phrase?\p" &
|
|
"(please type 'yes' to continue or press enter to quit)"
|
|
|
|
while true:
|
|
let answer = ask "Answer"
|
|
if answer == "":
|
|
return err "aborted wallet creation"
|
|
elif answer != "yes":
|
|
echo "To continue, please type 'yes' (without the quotes) or press enter to quit"
|
|
else:
|
|
break
|
|
|
|
echo ""
|
|
echo80 "When you perform operations with your wallet such as withdrawals " &
|
|
"and additional deposits, you'll be asked to enter a password. " &
|
|
"Please note that this password is local to the current Nimbus " &
|
|
"installation and can be changed at any time."
|
|
echo ""
|
|
|
|
while true:
|
|
var password, confirmedPassword: TaintedString
|
|
try:
|
|
var firstTry = true
|
|
|
|
template prompt: string =
|
|
if firstTry:
|
|
"Please enter a password:"
|
|
else:
|
|
"Please enter a new password:"
|
|
|
|
while true:
|
|
if not readPasswordInput(prompt, password):
|
|
return err "failure to read a password from stdin"
|
|
|
|
if password.len < minPasswordLen:
|
|
try:
|
|
echo "The entered password should be at least $1 characters." %
|
|
[$minPasswordLen]
|
|
except ValueError as err:
|
|
raiseAssert "The format string above is correct"
|
|
elif password in mostCommonPasswords:
|
|
echo80 "The entered password is too commonly used and it would be easy " &
|
|
"to brute-force with automated tools."
|
|
else:
|
|
break
|
|
|
|
firstTry = false
|
|
|
|
if not readPasswordInput("Please repeat the password:", confirmedPassword):
|
|
return err "failure to read a password from stdin"
|
|
|
|
if password != confirmedPassword:
|
|
echo "Passwords don't match, please try again"
|
|
continue
|
|
|
|
var name: WalletName
|
|
let outWalletName = conf.outWalletName
|
|
if outWalletName.isSome:
|
|
name = outWalletName.get
|
|
else:
|
|
echo ""
|
|
echo80 "For your convenience, the wallet can be identified with a name " &
|
|
"of your choice. Please enter a wallet name below or press ENTER " &
|
|
"to continue with a machine-generated name."
|
|
|
|
while true:
|
|
var enteredName = ask "Wallet name"
|
|
if enteredName.len > 0:
|
|
name = try: WalletName.parseCmdArg(enteredName)
|
|
except CatchableError as err:
|
|
echo err.msg & ". Please try again."
|
|
continue
|
|
break
|
|
|
|
let wallet = KdfPbkdf2.createWallet(rng, mnemonic,
|
|
name = name,
|
|
password = KeyStorePass password)
|
|
|
|
let outWalletFileFlag = conf.outWalletFile
|
|
let outWalletFile = if outWalletFileFlag.isSome:
|
|
string outWalletFileFlag.get
|
|
else:
|
|
conf.walletsDir / addFileExt(string wallet.uuid, "json")
|
|
|
|
let status = saveWallet(wallet, outWalletFile)
|
|
if status.isErr:
|
|
return err("failure to create wallet file due to " & status.error)
|
|
|
|
info "Wallet file written", path = outWalletFile
|
|
return ok WalletDataForDeposits(mnemonic: mnemonic, nextAccount: 0)
|
|
|
|
finally:
|
|
burnMem(password)
|
|
burnMem(confirmedPassword)
|
|
|
|
proc loadWallet*(fileName: string): Result[Wallet, string] =
|
|
try:
|
|
ok Json.loadFile(fileName, Wallet)
|
|
except CatchableError as e:
|
|
err e.msg
|
|
|
|
proc unlockWalletInteractively*(wallet: Wallet): Result[WalletDataForDeposits, string] =
|
|
var json: JsonNode
|
|
try:
|
|
json = parseJson wallet.crypto.string
|
|
except Exception as e: # TODO: parseJson shouldn't raise general `Exception`
|
|
if e[] of Defect: raise (ref Defect)(e)
|
|
else: return err "failure to parse crypto field"
|
|
|
|
var password: TaintedString
|
|
|
|
echo "Please enter the password for unlocking the wallet"
|
|
|
|
for i in 1..3:
|
|
try:
|
|
if not readPasswordInput("Password: ", password):
|
|
return err "failure to read password from stdin"
|
|
|
|
var status = decryptoCryptoField(json, KeyStorePass password)
|
|
if status.isOk:
|
|
defer: burnMem(status.value)
|
|
return ok WalletDataForDeposits(
|
|
mnemonic: Mnemonic string.fromBytes(status.value))
|
|
else:
|
|
echo "Unlocking of the wallet failed. Please try again."
|
|
finally:
|
|
burnMem(password)
|
|
|
|
return err "failure to unlock wallet"
|
|
|
|
proc findWallet*(config: BeaconNodeConf, name: WalletName): Result[Wallet, string] =
|
|
var walletFiles = newSeq[string]()
|
|
|
|
try:
|
|
for kind, walletFile in walkDir(config.walletsDir):
|
|
if kind != pcFile: continue
|
|
let fullPath = config.walletsDir / walletFile
|
|
if walletFile == name.string:
|
|
return loadWallet(fullPath)
|
|
walletFiles.add fullPath
|
|
except OSError:
|
|
return err "failure to list wallet directory"
|
|
|
|
for walletFile in walletFiles:
|
|
let wallet = loadWallet(walletFile)
|
|
if wallet.isOk and wallet.get.name == name:
|
|
return wallet
|
|
|
|
return err "failure to locate wallet file"
|
|
|
|
type
|
|
# This is not particularly well-standardized yet.
|
|
# Some relevant code for generating (1) and validating (2) the data can be found below:
|
|
# 1) https://github.com/ethereum/eth2.0-deposit-cli/blob/dev/eth2deposit/credentials.py
|
|
# 2) https://github.com/ethereum/eth2.0-deposit/blob/dev/src/pages/UploadValidator/validateDepositKey.ts
|
|
LaunchPadDeposit* = object
|
|
pubkey*: ValidatorPubKey
|
|
withdrawal_credentials*: Eth2Digest
|
|
amount*: Gwei
|
|
signature*: ValidatorSig
|
|
deposit_message_root*: Eth2Digest
|
|
deposit_data_root*: Eth2Digest
|
|
fork_version*: Version
|
|
|
|
func init*(T: type LaunchPadDeposit,
|
|
preset: RuntimePreset, d: DepositData): T =
|
|
T(pubkey: d.pubkey,
|
|
withdrawal_credentials: d.withdrawal_credentials,
|
|
amount: d.amount,
|
|
signature: d.signature,
|
|
deposit_message_root: hash_tree_root(d as DepositMessage),
|
|
deposit_data_root: hash_tree_root(d),
|
|
fork_version: preset.GENESIS_FORK_VERSION)
|
|
|
|
func `as`*(copied: LaunchPadDeposit, T: type DepositData): T =
|
|
T(pubkey: copied.pubkey,
|
|
withdrawal_credentials: copied.withdrawal_credentials,
|
|
amount: copied.amount,
|
|
signature: copied.signature)
|