implement --jwt-secret and HS256 JWT/JWS signing for engine API alpha.7 (#3440)
This commit is contained in:
parent
40a4c01086
commit
ef9767eb7a
|
@ -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
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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")
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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"
|
Loading…
Reference in New Issue