580 lines
18 KiB
Nim
580 lines
18 KiB
Nim
# beacon_chain
|
|
# Copyright (c) 2018-2020 Status Research & Development GmbH
|
|
# Licensed and distributed under either of
|
|
# * MIT license (license terms in the root directory or at https://opensource.org/licenses/MIT).
|
|
# * Apache v2 license (license terms in the root directory or at https://www.apache.org/licenses/LICENSE-2.0).
|
|
# at your option. This file may not be copied, modified, or distributed except according to those terms.
|
|
|
|
import
|
|
math, strutils, strformat, typetraits, bearssl,
|
|
stew/[results, byteutils, bitseqs, bitops2], stew/shims/macros,
|
|
eth/keyfile/uuid, blscurve, faststreams/textio, json_serialization,
|
|
nimcrypto/[sha2, rijndael, pbkdf2, bcmode, hash, utils, scrypt],
|
|
./datatypes, ./crypto, ./digest, ./signatures
|
|
|
|
export
|
|
results, burnMem, writeValue, readValue
|
|
|
|
{.push raises: [Defect].}
|
|
|
|
type
|
|
ChecksumFunctionKind* = enum
|
|
sha256Checksum = "sha256"
|
|
|
|
Sha256Params* = object
|
|
Sha256Digest* = MDigest[256]
|
|
|
|
ChecksumBytes* = distinct seq[byte]
|
|
|
|
Checksum* = object
|
|
case function*: ChecksumFunctionKind
|
|
of sha256Checksum:
|
|
params*: Sha256Params
|
|
message*: Sha256Digest
|
|
|
|
Aes128CtrIv* = distinct seq[byte]
|
|
|
|
Aes128CtrParams* = object
|
|
iv*: Aes128CtrIv
|
|
|
|
CipherFunctionKind* = enum
|
|
aes128CtrCipher = "aes-128-ctr"
|
|
|
|
CipherBytes* = distinct seq[byte]
|
|
|
|
Cipher* = object
|
|
case function*: CipherFunctionKind
|
|
of aes128ctrCipher:
|
|
params*: Aes128CtrParams
|
|
message*: CipherBytes
|
|
|
|
KdfKind* = enum
|
|
kdfPbkdf2 = "pbkdf2"
|
|
kdfScrypt = "scrypt"
|
|
|
|
ScryptSalt* = distinct seq[byte]
|
|
|
|
ScryptParams* = object
|
|
dklen: int
|
|
n, p, r: int
|
|
salt: ScryptSalt
|
|
|
|
Pbkdf2Salt* = distinct seq[byte]
|
|
|
|
PrfKind* = enum # Pseudo-random-function Kind
|
|
HmacSha256 = "hmac-sha256"
|
|
|
|
Pbkdf2Params* = object
|
|
dklen*: int
|
|
c*: int
|
|
prf*: PrfKind
|
|
salt*: Pbkdf2Salt
|
|
|
|
# https://github.com/ethereum/EIPs/blob/4494da0966afa7318ec0157948821b19c4248805/EIPS/eip-2386.md#specification
|
|
Wallet* = object
|
|
uuid*: UUID
|
|
name*: WalletName
|
|
version*: uint
|
|
walletType* {.serializedFieldName: "type"}: string
|
|
# TODO: The use of `JsonString` can be removed once we
|
|
# solve the serialization problem for `Crypto[T]`
|
|
crypto*: Crypto
|
|
nextAccount* {.serializedFieldName: "nextaccount".}: Natural
|
|
|
|
Kdf* = object
|
|
case function*: KdfKind
|
|
of kdfPbkdf2:
|
|
pbkdf2Params* {.serializedFieldName: "params".}: Pbkdf2Params
|
|
of kdfScrypt:
|
|
scryptParams* {.serializedFieldName: "params".}: ScryptParams
|
|
message*: string
|
|
|
|
Crypto* = object
|
|
kdf*: Kdf
|
|
checksum*: Checksum
|
|
cipher*: Cipher
|
|
|
|
Keystore* = object
|
|
crypto*: Crypto
|
|
description*: ref string
|
|
pubkey*: ValidatorPubKey
|
|
path*: KeyPath
|
|
uuid*: string
|
|
version*: int
|
|
|
|
KsResult*[T] = Result[T, string]
|
|
|
|
Eth2KeyKind* = enum
|
|
signingKeyKind # Also known as voting key
|
|
withdrawalKeyKind
|
|
|
|
UUID* = distinct string
|
|
WalletName* = distinct string
|
|
Mnemonic* = distinct string
|
|
KeyPath* = distinct string
|
|
KeystorePass* = distinct string
|
|
KeySeed* = distinct seq[byte]
|
|
|
|
Credentials* = object
|
|
mnemonic*: Mnemonic
|
|
keystore*: Keystore
|
|
signingKey*: ValidatorPrivKey
|
|
withdrawalKey*: ValidatorPrivKey
|
|
|
|
SensitiveData = Mnemonic|KeystorePass|KeySeed
|
|
SimpleHexEncodedTypes = ScryptSalt|ChecksumBytes|CipherBytes
|
|
|
|
const
|
|
keyLen = 32
|
|
|
|
scryptParams = ScryptParams(
|
|
dklen: keyLen,
|
|
n: 2^18,
|
|
p: 1,
|
|
r: 8
|
|
)
|
|
|
|
pbkdf2Params = Pbkdf2Params(
|
|
dklen: keyLen,
|
|
c: 2^18,
|
|
prf: HmacSha256
|
|
)
|
|
|
|
# https://eips.ethereum.org/EIPS/eip-2334
|
|
eth2KeyPurpose = 12381
|
|
eth2CoinType* = 3600
|
|
|
|
# https://github.com/bitcoin/bips/blob/master/bip-0039/bip-0039-wordlists.md
|
|
wordListLen = 2048
|
|
|
|
UUID.serializesAsBaseIn Json
|
|
KeyPath.serializesAsBaseIn Json
|
|
WalletName.serializesAsBaseIn Json
|
|
|
|
ChecksumFunctionKind.serializesAsTextInJson
|
|
CipherFunctionKind.serializesAsTextInJson
|
|
PrfKind.serializesAsTextInJson
|
|
KdfKind.serializesAsTextInJson
|
|
|
|
template `$`*(m: Mnemonic): string =
|
|
string(m)
|
|
|
|
template `==`*(lhs, rhs: WalletName): bool =
|
|
string(lhs) == string(rhs)
|
|
|
|
template `$`*(x: WalletName): string =
|
|
string(x)
|
|
|
|
template burnMem*(m: var (SensitiveData|TaintedString)) =
|
|
# TODO: `burnMem` in nimcrypto could use distinctBase
|
|
# to make its usage less error-prone.
|
|
utils.burnMem(string m)
|
|
|
|
proc getRandomBytes*(rng: var BrHmacDrbgContext, n: Natural): seq[byte]
|
|
{.raises: [Defect].} =
|
|
result = newSeq[byte](n)
|
|
brHmacDrbgGenerate(rng, result)
|
|
|
|
macro wordListArray*(filename: static string,
|
|
maxWords: static int = 0,
|
|
minWordLength: static int = 0): untyped =
|
|
result = newTree(nnkBracket)
|
|
var words = slurp(filename).split()
|
|
for word in words:
|
|
if word.len >= minWordLength:
|
|
result.add newCall("cstring", newLit(word))
|
|
if maxWords > 0 and result.len >= maxWords:
|
|
return
|
|
|
|
const
|
|
englishWords = wordListArray("english_word_list.txt",
|
|
maxWords = wordListLen)
|
|
|
|
iterator pathNodesImpl(path: string): Natural
|
|
{.raises: [ValueError].} =
|
|
for elem in path.split("/"):
|
|
if elem == "m": continue
|
|
yield parseInt(elem)
|
|
|
|
func append*(path: KeyPath, pathNode: Natural): KeyPath =
|
|
KeyPath(path.string & "/" & $pathNode)
|
|
|
|
func validateKeyPath*(path: TaintedString): KeyPath
|
|
{.raises: [ValueError].} =
|
|
for elem in pathNodesImpl(path.string): discard elem
|
|
KeyPath path
|
|
|
|
iterator pathNodes(path: KeyPath): Natural =
|
|
try:
|
|
for elem in pathNodesImpl(path.string):
|
|
yield elem
|
|
except ValueError:
|
|
doAssert false, "Make sure you've validated the key path with `validateKeyPath`"
|
|
|
|
func makeKeyPath*(validatorIdx: Natural,
|
|
keyType: Eth2KeyKind): KeyPath =
|
|
# https://eips.ethereum.org/EIPS/eip-2334
|
|
let use = case keyType
|
|
of withdrawalKeyKind: "0"
|
|
of signingKeyKind: "0/0"
|
|
|
|
try:
|
|
KeyPath &"m/{eth2KeyPurpose}/{eth2CoinType}/{validatorIdx}/{use}"
|
|
except ValueError:
|
|
raiseAssert "All values above can be converted successfully to strings"
|
|
|
|
func getSeed*(mnemonic: Mnemonic, password: KeystorePass): KeySeed =
|
|
# https://github.com/bitcoin/bips/blob/master/bip-0039.mediawiki#from-mnemonic-to-seed
|
|
let salt = "mnemonic-" & password.string
|
|
KeySeed sha512.pbkdf2(mnemonic.string, salt, 2048, 64)
|
|
|
|
proc generateMnemonic*(
|
|
rng: var BrHmacDrbgContext,
|
|
words: openarray[cstring] = englishWords,
|
|
entropyParam: openarray[byte] = @[]): Mnemonic =
|
|
# https://github.com/bitcoin/bips/blob/master/bip-0039.mediawiki#generating-the-mnemonic
|
|
doAssert words.len == wordListLen
|
|
|
|
var entropy: seq[byte]
|
|
if entropyParam.len == 0:
|
|
setLen(entropy, 32)
|
|
brHmacDrbgGenerate(rng, entropy)
|
|
else:
|
|
doAssert entropyParam.len >= 128 and
|
|
entropyParam.len <= 256 and
|
|
entropyParam.len mod 32 == 0
|
|
entropy = @entropyParam
|
|
|
|
let
|
|
checksumBits = entropy.len div 4 # ranges from 4 to 8
|
|
mnemonicWordCount = 12 + (checksumBits - 4) * 3
|
|
checksum = sha256.digest(entropy)
|
|
|
|
entropy.add byte(checksum.data.getBitsBE(0 ..< checksumBits))
|
|
|
|
var res = ""
|
|
res.add words[entropy.getBitsBE(0..10)]
|
|
|
|
for i in 1 ..< mnemonicWordCount:
|
|
let
|
|
firstBit = i*11
|
|
lastBit = firstBit + 10
|
|
res.add " "
|
|
res.add words[entropy.getBitsBE(firstBit..lastBit)]
|
|
|
|
Mnemonic res
|
|
|
|
proc deriveChildKey*(parentKey: ValidatorPrivKey,
|
|
index: Natural): ValidatorPrivKey =
|
|
let success = derive_child_secretKey(SecretKey result,
|
|
SecretKey parentKey,
|
|
uint32 index)
|
|
# TODO `derive_child_secretKey` is reporting pre-condition
|
|
# failures with return values. We should turn the checks
|
|
# into asserts inside the function.
|
|
doAssert success
|
|
|
|
proc deriveMasterKey*(seed: KeySeed): ValidatorPrivKey =
|
|
let success = derive_master_secretKey(SecretKey result,
|
|
seq[byte] seed)
|
|
# TODO `derive_master_secretKey` is reporting pre-condition
|
|
# failures with return values. We should turn the checks
|
|
# into asserts inside the function.
|
|
doAssert success
|
|
|
|
proc deriveMasterKey*(mnemonic: Mnemonic,
|
|
password: KeystorePass): ValidatorPrivKey =
|
|
deriveMasterKey(getSeed(mnemonic, password))
|
|
|
|
proc deriveChildKey*(masterKey: ValidatorPrivKey,
|
|
path: KeyPath): ValidatorPrivKey =
|
|
result = masterKey
|
|
for idx in pathNodes(path):
|
|
result = deriveChildKey(result, idx)
|
|
|
|
proc keyFromPath*(mnemonic: Mnemonic,
|
|
password: KeystorePass,
|
|
path: KeyPath): ValidatorPrivKey =
|
|
deriveChildKey(deriveMasterKey(mnemonic, password), path)
|
|
|
|
proc shaChecksum(key, cipher: openarray[byte]): Sha256Digest =
|
|
var ctx: sha256
|
|
ctx.init()
|
|
ctx.update(key)
|
|
ctx.update(cipher)
|
|
result = ctx.finish()
|
|
ctx.clear()
|
|
|
|
template hexToBytes(data, name: string): untyped =
|
|
try:
|
|
hexToSeqByte(data)
|
|
except ValueError:
|
|
return err "ks: failed to parse " & name
|
|
|
|
proc writeJsonHexString(s: OutputStream, data: openarray[byte])
|
|
{.raises: [IOError, Defect].} =
|
|
s.write '"'
|
|
s.writeHex data
|
|
s.write '"'
|
|
|
|
proc readValue*(r: var JsonReader, value: var Pbkdf2Salt)
|
|
{.raises: [SerializationError, IOError, Defect].} =
|
|
var s = r.readValue(string)
|
|
|
|
if s.len == 0 or s.len mod 16 != 0:
|
|
r.raiseUnexpectedValue(
|
|
"The Pbkdf2Salt salf must have a non-zero length divisible by 16")
|
|
|
|
try:
|
|
value = Pbkdf2Salt hexToSeqByte(s)
|
|
except ValueError:
|
|
r.raiseUnexpectedValue(
|
|
"The Pbkdf2Salt must be a valid hex string")
|
|
|
|
proc readValue*(r: var JsonReader, value: var Aes128CtrIv)
|
|
{.raises: [SerializationError, IOError, Defect].} =
|
|
var s = r.readValue(string)
|
|
|
|
if s.len != 32:
|
|
r.raiseUnexpectedValue(
|
|
"The aes-128-ctr IV must be a string of length 32")
|
|
|
|
try:
|
|
value = Aes128CtrIv hexToSeqByte(s)
|
|
except ValueError:
|
|
r.raiseUnexpectedValue(
|
|
"The aes-128-ctr IV must be a valid hex string")
|
|
|
|
proc readValue*[T: SimpleHexEncodedTypes](r: var JsonReader, value: var T)
|
|
{.raises: [SerializationError, IOError, Defect].} =
|
|
try:
|
|
value = T hexToSeqByte(r.readValue(string))
|
|
except ValueError:
|
|
r.raiseUnexpectedValue("Valid hex string expected")
|
|
|
|
proc readValue*(r: var JsonReader, value: var Kdf)
|
|
{.raises: [SerializationError, IOError, Defect].} =
|
|
var
|
|
functionSpecified = false
|
|
paramsSpecified = false
|
|
|
|
for fieldName in readObjectFields(r):
|
|
case fieldName
|
|
of "function":
|
|
value.function = r.readValue(KdfKind)
|
|
functionSpecified = true
|
|
|
|
of "params":
|
|
if functionSpecified:
|
|
case value.function
|
|
of kdfPbkdf2:
|
|
r.readValue(value.pbkdf2Params)
|
|
of kdfScrypt:
|
|
r.readValue(value.scryptParams)
|
|
else:
|
|
r.raiseUnexpectedValue(
|
|
"The 'params' field must be specified after the 'function' field")
|
|
paramsSpecified = true
|
|
|
|
of "message":
|
|
r.readValue(value.message)
|
|
|
|
else:
|
|
r.raiseUnexpectedField(fieldName, "Kdf")
|
|
|
|
if not (functionSpecified and paramsSpecified):
|
|
r.raiseUnexpectedValue(
|
|
"The Kdf value should have sub-fields named 'function' and 'params'")
|
|
|
|
template writeValue*(w: var JsonWriter,
|
|
value: Pbkdf2Salt|SimpleHexEncodedTypes|Aes128CtrIv) =
|
|
writeJsonHexString(w.stream, distinctBase value)
|
|
|
|
template bytes(value: Pbkdf2Salt|SimpleHexEncodedTypes|Aes128CtrIv): seq[byte] =
|
|
distinctBase value
|
|
|
|
func scrypt(password: openArray[char], salt: openArray[byte],
|
|
N, r, p, keyLen: static[int]): array[keyLen, byte] =
|
|
let (xyvLen, bLen) = scryptCalc(N, r, p)
|
|
var xyv = newSeq[uint32](xyvLen)
|
|
var b = newSeq[byte](bLen)
|
|
discard scrypt(password, salt, N, r, p, xyv, b, result)
|
|
|
|
proc decryptCryptoField*(crypto: Crypto, password: KeystorePass): seq[byte] =
|
|
## Returns 0 bytes if the supplied password is incorrect
|
|
|
|
let decKey = case crypto.kdf.function
|
|
of kdfPbkdf2:
|
|
template params: auto = crypto.kdf.pbkdf2Params
|
|
sha256.pbkdf2(password.string, params.salt.bytes, params.c, params.dklen)
|
|
of kdfScrypt:
|
|
template params: auto = crypto.kdf.scryptParams
|
|
if params.dklen != scryptParams.dklen or
|
|
params.n != scryptParams.n or
|
|
params.r != scryptParams.r or
|
|
params.p != scryptParams.p:
|
|
# TODO This should be reported in a better way
|
|
return
|
|
@(scrypt(password.string,
|
|
params.salt.bytes,
|
|
scryptParams.n,
|
|
scryptParams.r,
|
|
scryptParams.p,
|
|
scryptParams.dklen))
|
|
|
|
let derivedChecksum = shaChecksum(decKey.toOpenArray(16, 31),
|
|
crypto.cipher.message.bytes)
|
|
if derivedChecksum != crypto.checksum.message:
|
|
return
|
|
|
|
var
|
|
aesCipher: CTR[aes128]
|
|
secret = newSeq[byte](crypto.cipher.message.bytes.len)
|
|
|
|
aesCipher.init(decKey.toOpenArray(0, 15), crypto.cipher.params.iv.bytes)
|
|
aesCipher.decrypt(crypto.cipher.message.bytes, secret)
|
|
aesCipher.clear()
|
|
|
|
return secret
|
|
|
|
func cstringToStr(v: cstring): string = $v
|
|
|
|
proc decryptKeystore*(keystore: Keystore,
|
|
password: KeystorePass): KsResult[ValidatorPrivKey] =
|
|
let decryptedBytes = decryptCryptoField(keystore.crypto, password)
|
|
if decryptedBytes.len > 0:
|
|
return ValidatorPrivKey.fromRaw(decryptedBytes).mapErr(cstringToStr)
|
|
|
|
proc decryptKeystore*(keystore: JsonString,
|
|
password: KeystorePass): KsResult[ValidatorPrivKey] =
|
|
let keystore = try: Json.decode(keystore.string, Keystore)
|
|
except SerializationError as e:
|
|
return err e.formatMsg("<keystore>")
|
|
decryptKeystore(keystore, password)
|
|
|
|
proc createCryptoField(kdfKind: KdfKind,
|
|
rng: var BrHmacDrbgContext,
|
|
secret: openarray[byte],
|
|
password = KeystorePass "",
|
|
salt: openarray[byte] = @[],
|
|
iv: openarray[byte] = @[]): Crypto =
|
|
type AES = aes128
|
|
|
|
let kdfSalt =
|
|
if salt.len > 0:
|
|
doAssert salt.len == keyLen
|
|
@salt
|
|
else:
|
|
getRandomBytes(rng, keyLen)
|
|
|
|
let aesIv = if iv.len > 0:
|
|
doAssert iv.len == AES.sizeBlock
|
|
@iv
|
|
else:
|
|
getRandomBytes(rng, AES.sizeBlock)
|
|
|
|
var decKey: seq[byte]
|
|
let kdf = case kdfKind
|
|
of kdfPbkdf2:
|
|
decKey = sha256.pbkdf2(password.string,
|
|
kdfSalt,
|
|
pbkdf2Params.c,
|
|
pbkdf2Params.dklen)
|
|
var params = pbkdf2Params
|
|
params.salt = Pbkdf2Salt kdfSalt
|
|
Kdf(function: kdfPbkdf2, pbkdf2Params: params, message: "")
|
|
of kdfScrypt:
|
|
decKey = @(scrypt(password.string, kdfSalt,
|
|
scryptParams.n, scryptParams.r, scryptParams.p, keyLen))
|
|
var params = scryptParams
|
|
params.salt = ScryptSalt kdfSalt
|
|
Kdf(function: kdfScrypt, scryptParams: params, message: "")
|
|
|
|
var
|
|
aesCipher: CTR[AES]
|
|
cipherMsg = newSeq[byte](secret.len)
|
|
|
|
aesCipher.init(decKey.toOpenArray(0, 15), aesIv)
|
|
aesCipher.encrypt(secret, cipherMsg)
|
|
aesCipher.clear()
|
|
|
|
let sum = shaChecksum(decKey.toOpenArray(16, 31), cipherMsg)
|
|
|
|
Crypto(
|
|
kdf: kdf,
|
|
checksum: Checksum(
|
|
function: sha256Checksum,
|
|
message: sum),
|
|
cipher: Cipher(
|
|
function: aes128CtrCipher,
|
|
params: Aes128CtrParams(iv: Aes128CtrIv aesIv),
|
|
message: CipherBytes cipherMsg))
|
|
|
|
proc createKeystore*(kdfKind: KdfKind,
|
|
rng: var BrHmacDrbgContext,
|
|
privKey: ValidatorPrivkey,
|
|
password = KeystorePass "",
|
|
path = KeyPath "",
|
|
description = "",
|
|
salt: openarray[byte] = @[],
|
|
iv: openarray[byte] = @[]): Keystore =
|
|
let
|
|
secret = privKey.toRaw[^32..^1]
|
|
cryptoField = createCryptoField(kdfKind, rng, secret, password, salt, iv)
|
|
pubkey = privKey.toPubKey()
|
|
uuid = uuidGenerate().expect("Random bytes should be available")
|
|
|
|
Keystore(
|
|
crypto: cryptoField,
|
|
pubkey: pubkey,
|
|
path: path,
|
|
description: newClone(description),
|
|
uuid: $uuid,
|
|
version: 4)
|
|
|
|
proc createWallet*(kdfKind: KdfKind,
|
|
rng: var BrHmacDrbgContext,
|
|
mnemonic: Mnemonic,
|
|
name = WalletName "",
|
|
salt: openarray[byte] = @[],
|
|
iv: openarray[byte] = @[],
|
|
password = KeystorePass "",
|
|
nextAccount = none(Natural),
|
|
pretty = true): Wallet =
|
|
let
|
|
uuid = UUID $(uuidGenerate().expect("Random bytes should be available"))
|
|
# Please note that we are passing an empty password here because
|
|
# we want the wallet restoration procedure to depend only on the
|
|
# mnemonic (the user is asked to treat the mnemonic as a password).
|
|
seed = getSeed(mnemonic, KeystorePass"")
|
|
crypto = createCryptoField(kdfKind, rng, distinctBase seed,
|
|
password, salt, iv)
|
|
|
|
Wallet(
|
|
uuid: uuid,
|
|
name: if name.string.len > 0: name
|
|
else: WalletName(uuid),
|
|
version: 1,
|
|
walletType: "hierarchical deterministic",
|
|
crypto: crypto,
|
|
nextAccount: nextAccount.get(0))
|
|
|
|
# https://github.com/ethereum/eth2.0-specs/blob/v0.12.2/specs/phase0/deposit-contract.md#withdrawal-credentials
|
|
proc makeWithdrawalCredentials*(k: ValidatorPubKey): Eth2Digest =
|
|
var bytes = eth2digest(k.toRaw())
|
|
bytes.data[0] = BLS_WITHDRAWAL_PREFIX.uint8
|
|
bytes
|
|
|
|
proc prepareDeposit*(preset: RuntimePreset,
|
|
withdrawalPubKey: ValidatorPubKey,
|
|
signingKey: ValidatorPrivKey, signingPubKey: ValidatorPubKey,
|
|
amount = MAX_EFFECTIVE_BALANCE.Gwei): DepositData =
|
|
var res = DepositData(
|
|
amount: amount,
|
|
pubkey: signingPubKey,
|
|
withdrawal_credentials: makeWithdrawalCredentials(withdrawalPubKey))
|
|
|
|
res.signature = preset.get_deposit_signature(res, signingKey)
|
|
return res
|
|
|