implement --jwt-secret and HS256 JWT/JWS signing for engine API alpha.7 (#3440)

This commit is contained in:
tersec 2022-02-27 16:55:02 +00:00 committed by GitHub
parent 40a4c01086
commit ef9767eb7a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 226 additions and 7 deletions

View File

@ -465,6 +465,13 @@ OK: 3/3 Fail: 0/3 Skip: 0/3
+ `createValidatorFiles` with already existing dirs and any error OK
```
OK: 8/8 Fail: 0/8 Skip: 0/8
## engine API authentication
```diff
+ HS256 JWS iat token signing OK
+ HS256 JWS signing OK
+ getIatToken OK
```
OK: 3/3 Fail: 0/3 Skip: 0/3
## eth2.0-deposits-cli compatibility
```diff
+ restoring mnemonic with password OK
@ -512,4 +519,4 @@ OK: 1/1 Fail: 0/1 Skip: 0/1
OK: 1/1 Fail: 0/1 Skip: 0/1
---TOTAL---
OK: 282/286 Fail: 0/286 Skip: 4/286
OK: 285/289 Fail: 0/289 Skip: 4/289

View File

@ -388,7 +388,7 @@ type
name: "keymanager-allow-origin" }: Option[string]
keymanagerTokenFile* {.
desc: "A file specifying the authorizition token required for accessing the keymanager API"
desc: "A file specifying the authorization token required for accessing the keymanager API"
name: "keymanager-token-file" }: Option[InputFile]
inProcessValidators* {.
@ -461,6 +461,12 @@ type
defaultValue: 128
name: "safe-slots-to-import-optimistically" }: uint64
# https://github.com/ethereum/execution-apis/blob/v1.0.0-alpha.7/src/engine/authentication.md#key-distribution
jwtSecret* {.
hidden
desc: "A file containing the hex-encoded 256 bit secret key to be used for verifying/generating jwt tokens"
name: "jwt-secret" .}: Option[string]
of BNStartUpCmd.createTestnet:
testnetDepositsFile* {.
desc: "A LaunchPad deposits file for the genesis state validators"

View File

@ -107,6 +107,7 @@ type
eth1Network: Option[Eth1Network]
depositContractAddress*: Eth1Address
forcePolling: bool
jwtSecret: seq[byte]
dataProvider: Web3DataProviderRef
latestEth1Block: Option[FullBlockId]
@ -901,7 +902,8 @@ proc init*(T: type Eth1Monitor,
web3Urls: seq[string],
depositContractSnapshot: Option[DepositContractSnapshot],
eth1Network: Option[Eth1Network],
forcePolling: bool): T =
forcePolling: bool,
jwtSecret: seq[byte]): T =
doAssert web3Urls.len > 0
var web3Urls = web3Urls
for url in mitems(web3Urls):
@ -916,7 +918,8 @@ proc init*(T: type Eth1Monitor,
web3Urls: web3Urls,
eth1Network: eth1Network,
eth1Progress: newAsyncEvent(),
forcePolling: forcePolling)
forcePolling: forcePolling,
jwtSecret: jwtSecret)
proc safeCancel(fut: var Future[void]) =
if not fut.isNil and not fut.finished:

View File

@ -15,7 +15,7 @@ import
eth/keys,
./rpc/[rest_api, rpc_api, state_ttl_cache],
./spec/datatypes/[altair, bellatrix, phase0],
./spec/weak_subjectivity,
./spec/[engine_authentication, weak_subjectivity],
./validators/[keystore_management, validator_duties],
"."/[
beacon_node, deposits, interop, nimbus_binary_common, statusbar,
@ -213,6 +213,12 @@ proc init*(T: type BeaconNode,
else:
none(DepositContractSnapshot)
let jwtSecret = rng[].checkJwtSecret(string(config.dataDir), config.jwtSecret)
if jwtSecret.isErr:
fatal "Specified a JWT secret file which couldn't be loaded",
err = jwtSecret.error
quit 1
var eth1Monitor: Eth1Monitor
if not ChainDAGRef.isInitialized(db).isOk():
var
@ -240,7 +246,8 @@ proc init*(T: type BeaconNode,
config.web3Urls,
getDepositContractSnapshot(),
eth1Network,
config.web3ForcePolling)
config.web3ForcePolling,
jwtSecret.get)
eth1Monitor.loadPersistedDeposits()
@ -360,7 +367,8 @@ proc init*(T: type BeaconNode,
config.web3Urls,
getDepositContractSnapshot(),
eth1Network,
config.web3ForcePolling)
config.web3ForcePolling,
jwtSecret.get)
let rpcServer = if config.rpcEnabled:
RpcServer.init(config.rpcAddress, config.rpcPort)

View File

@ -0,0 +1,97 @@
# beacon_chain
# Copyright (c) 2022 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
std/[base64, json, options, os, strutils],
chronicles,
bearssl,
nimcrypto/[hmac, utils],
stew/[byteutils, results]
{.push raises: [Defect].}
proc base64urlEncode(x: auto): string =
# The only strings this gets are internally generated, and don't have
# encoding quirks.
base64.encode(x, safe = true).replace("=", "")
func getIatToken*(time: uint64): JsonNode =
# https://github.com/ethereum/execution-apis/blob/v1.0.0-alpha.7/src/engine/authentication.md#jwt-claims
# "Required: iat (issued-at) claim. The EL SHOULD only accept iat timestamps
# which are within +-5 seconds from the current time."
#
# https://datatracker.ietf.org/doc/html/rfc7519#section-4.1.6 describes iat
# claims.
#
# https://pyjwt.readthedocs.io/en/stable/usage.html#issued-at-claim-iat shows
# an example of an iat claim: {"iat": 1371720939}
%* {"iat": time}
proc getSignedToken*(key: openArray[byte], payload: string): string =
# https://github.com/ethereum/execution-apis/blob/v1.0.0-alpha.7/src/engine/authentication.md#jwt-specifications
# "The EL MUST support at least the following alg: HMAC + SHA256 (HS256)"
# https://datatracker.ietf.org/doc/html/rfc7515#appendix-A.1.1
const jwsProtectedHeader =
base64url_encode($ %* {"typ": "JWT", "alg": "HS256"}) & "."
# In theory, std/json might change how it encodes, and it doesn't per-se
# matter but can also simply specify the base64-encoded form directly if
# useful, since it's never checked here on its own.
static: doAssert jwsProtectedHeader == "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9."
let signingInput = jwsProtectedHeader & base64urlEncode(payload)
signingInput & "." & base64_urlencode(sha256.hmac(key, signingInput).data)
proc getSignedIatToken*(key: openArray[byte], time: uint64): string =
getSignedToken(key, $getIatToken(time))
proc checkJwtSecret*(
rng: var BrHmacDrbgContext, dataDir: string, jwtSecret: Option[string]):
Result[seq[byte], cstring] =
# If such a parameter is given, but the file cannot be read, or does not
# contain a hex-encoded key of at least 256 bits, the client should treat
# this as an error: either abort the startup, or show error and continue
# without exposing the authenticated port.
const MIN_SECRET_LEN = 32
if jwtSecret.isNone:
# If such a parameter is not given, the client SHOULD generate such a
# token, valid for the duration of the execution, and store it the
# hex-encoded secret as a jwt.hex file on the filesystem. This file can
# then be used to provision the counterpart client.
#
# https://github.com/ethereum/execution-apis/blob/v1.0.0-alpha.7/src/engine/authentication.md#key-distribution
const jwtSecretFilename = "jwt.hex"
let jwtSecretPath = dataDir / jwtSecretFilename
var newSecret: seq[byte]
newSecret.setLen(MIN_SECRET_LEN)
rng.brHmacDrbgGenerate(newSecret)
try:
writeFile(jwtSecretPath, newSecret.to0xHex())
except IOError as e:
# Allow continuing to run, though this is effectively fatal for a merge
# client using authentication. This keeps it lower-risk initially.
warn "Could not write JWT secret to data directory",
jwtSecretPath
return ok(newSecret)
try:
let lines = readLines(jwtSecret.get, 1)
if lines.len > 0 and lines[0].startswith("0x"):
let secret = utils.fromHex(lines[0])
if secret.len >= MIN_SECRET_LEN:
ok(secret)
else:
err("JWT secret not at least 256 bits")
else:
err("no 0x-prefixed hex string found")
except IOError:
err("couldn't open specified JWT secret file")
except ValueError:
err("invalid JWT hex string")

View File

@ -1,8 +1,17 @@
# beacon_chain
# Copyright (c) 2021-2022 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
stew/[bitops2, objects],
datatypes/altair,
helpers
{.push raises: [Defect].}
# https://github.com/ethereum/consensus-specs/blob/v1.1.9/specs/altair/sync-protocol.md#get_active_header
func get_active_header(update: LightClientUpdate): BeaconBlockHeader =
# The "active header" is the header that the update is trying to convince

View File

@ -7,6 +7,8 @@
import "."/[altair, bellatrix]
{.push raises: [Defect].}
type
# https://github.com/flashbots/mev-boost/blob/thegostep/docs/docs/milestone-1.md#blindedbeaconblockbody
# This is forked from bellatrix.BeaconBlockBody with execution_payload

View File

@ -16,6 +16,8 @@ import
export base, helpers, network, sets, tables
{.push raises: [Defect].}
const
SUBNET_SUBSCRIPTION_LEAD_TIME_SLOTS* = 4 ##\
## The number of slots before we're up for aggregation duty that we'll

View File

@ -1,3 +1,10 @@
# beacon_chain
# Copyright (c) 2021-2022 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
std/[options, tables],
metrics, chronicles,
@ -5,6 +12,8 @@ import
../spec/datatypes/[phase0, altair],
../beacon_clock
{.push raises: [Defect].}
logScope: topics = "val_mon"
# Validator monitoring based on the same feature in Lighthouse - using the same

View File

@ -21,6 +21,7 @@ import # Unit test
./test_block_quarantine,
./test_datatypes,
./test_discovery,
./test_engine_authentication,
./test_eth1_monitor,
./test_eth2_ssz_serialization,
./test_exit_pool,

View File

@ -0,0 +1,75 @@
# beacon_chain
# Copyright (c) 2022 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.
{.used.}
import
std/[json, options, sequtils],
unittest2,
../beacon_chain/spec/engine_authentication
suite "engine API authentication":
test "getIatToken":
check:
$getIatToken(0) == "{\"iat\":0}"
$getIatToken(1) == "{\"iat\":1}"
$getIatToken(2) == "{\"iat\":2}"
$getIatToken(14) == "{\"iat\":14}"
$getIatToken(60) == "{\"iat\":60}"
$getIatToken(95) == "{\"iat\":95}"
$getIatToken(487) == "{\"iat\":487}"
$getIatToken(529) == "{\"iat\":529}"
$getIatToken(666) == "{\"iat\":666}"
$getIatToken(2669) == "{\"iat\":2669}"
$getIatToken(6082) == "{\"iat\":6082}"
$getIatToken(6234) == "{\"iat\":6234}"
$getIatToken(230158) == "{\"iat\":230158}"
$getIatToken(675817) == "{\"iat\":675817}"
$getIatToken(695159) == "{\"iat\":695159}"
$getIatToken(19257188) == "{\"iat\":19257188}"
$getIatToken(52639657) == "{\"iat\":52639657}"
$getIatToken(71947005) == "{\"iat\":71947005}"
$getIatToken(1169144470) == "{\"iat\":1169144470}"
$getIatToken(2931679730'u64) == "{\"iat\":2931679730}"
$getIatToken(3339327695'u64) == "{\"iat\":3339327695}"
test "HS256 JWS signing":
let secret = mapIt("secret", byte(it))
check:
# https://pyjwt.readthedocs.io/en/stable/usage.html#encoding-decoding-tokens-with-hs256
# The pyjwt version I have swaps the order of the fields in the header, so creates this
# different result from their website. Both are valid, and RFC 7515 has another example
# of a slightly different ordering/whitespace combination. It just has to decode as the
# same JSON, semantically.
getSignedToken(secret, "{\"some\":\"payload\"}") == "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzb21lIjoicGF5bG9hZCJ9.Joh1R2dYzkRvDkqv3sygm5YyK8Gi4ShZqbhK2gxcs2U"
test "HS256 JWS iat token signing":
let secret = mapIt("secret", byte(it))
# https://pyjwt.readthedocs.io/en/stable/usage.html
# >>> for i in [0, 1, 2, 14, 60, 95, 487, 529, 2669, 6082, 6234, 230158, 675817, 695159, 19257188, 52639657, 71947005, 1169144470, 29316
#... print(' getSignedIatToken(secret, %d) == "%s"'%(i, jwt.encode({"iat": i}, "secret", algorithm="HS256")))
check:
getSignedIatToken(secret, 0) == "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpYXQiOjB9.BA9VRUphKugikQmUzIL-6kyi9Wa1IWeli25hY8n5w7M"
getSignedIatToken(secret, 1) == "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpYXQiOjF9.SjP9sSFaSm1pgnnVtx-M7Bq06xoenJUJldFRn1HpB5g"
getSignedIatToken(secret, 2) == "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpYXQiOjJ9.VA2Qo_m1MtT_DXiNt06UFcFTxEd90GjggsJC1H2XL2U"
getSignedIatToken(secret, 14) == "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpYXQiOjE0fQ.I7YMwh9o23qr5iK6YSgflG3nCCtzJFoSSMDTXSMJoZ4"
getSignedIatToken(secret, 60) == "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpYXQiOjYwfQ.3-5HWzbM9ICMADiXshOKdBVP2RsWKdpcaw1uK_x0B-w"
getSignedIatToken(secret, 95) == "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpYXQiOjk1fQ.mERclea8y-SjN6qAZFWoXKydrLTgnzHNgvJ87zbYc8k"
getSignedIatToken(secret, 487) == "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpYXQiOjQ4N30.Z6xuP1n4vUOqKgMdCQrDREOoBVSfvUlXcNzw5B-BA8k"
getSignedIatToken(secret, 529) == "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpYXQiOjUyOX0.VSWpcLQEp_fdGZGjAHvYFYAyfc8Pzt3V-hRZUngMf8Y"
getSignedIatToken(secret, 2669) == "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpYXQiOjI2Njl9.jeBC5FfU6amVKGmCqZxUHSqumd8AYEa-mnk0V_QNBn4"
getSignedIatToken(secret, 6082) == "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpYXQiOjYwODJ9.Ua9q9HTc1jv8S5_Lpg0w-mFV293rrrtXnS7jUhH8pxE"
getSignedIatToken(secret, 6234) == "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpYXQiOjYyMzR9.8nSB6zb4zrAcH1vW5OcOt1ru1RkuLRTFLVv1VQW8BS0"
getSignedIatToken(secret, 230158) == "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpYXQiOjIzMDE1OH0.IwjEyz0__xlp1bMitC5YmEIR0emGgqin7Bknm9pDrYM"
getSignedIatToken(secret, 675817) == "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpYXQiOjY3NTgxN30.mKhib1fJ0KQy8X8T0xPN89DZootODNlBXOIksdVnmf4"
getSignedIatToken(secret, 695159) == "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpYXQiOjY5NTE1OX0.KJClqQMaEVnFksdScc_SprEWqpxDtFUrXxZCsALqkpk"
getSignedIatToken(secret, 19257188) == "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpYXQiOjE5MjU3MTg4fQ.Q_BssigyQGRDkV9ysGcGKIzEEXMpVpv0t4Bx4pf7lr4"
getSignedIatToken(secret, 52639657) == "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpYXQiOjUyNjM5NjU3fQ.O0cI2U_kEW1MbWyXcAh146mRU2CwzMNegAQit_1-TNU"
getSignedIatToken(secret, 71947005) == "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpYXQiOjcxOTQ3MDA1fQ.pQwPWxMHzWGvTTfRfWKiGX8qEI2NcZbnB3ruh4Wcftg"
getSignedIatToken(secret, 1169144470) == "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpYXQiOjExNjkxNDQ0NzB9.5JS0pVVh1g8hxO_PDQpwCvFnh1tdRtodpALXU1xol4I"
getSignedIatToken(secret, 2931679730'u64) == "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpYXQiOjI5MzE2Nzk3MzB9.0ZR8DiVy6Y_pOleGC9Ti3M8ShtH5hyCBhceO1C2OTj0"
getSignedIatToken(secret, 3339327695'u64) == "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpYXQiOjMzMzkzMjc2OTV9.ZRYaNrsvcIzppVeNorYUgEmVXcwOOQbqPlCQcoAaO4k"