diff --git a/AllTests-mainnet.md b/AllTests-mainnet.md index 56243951e..d6aefe553 100644 --- a/AllTests-mainnet.md +++ b/AllTests-mainnet.md @@ -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 diff --git a/beacon_chain/conf.nim b/beacon_chain/conf.nim index 438919a7a..94db23f81 100644 --- a/beacon_chain/conf.nim +++ b/beacon_chain/conf.nim @@ -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" diff --git a/beacon_chain/eth1/eth1_monitor.nim b/beacon_chain/eth1/eth1_monitor.nim index 912226085..6493c9f58 100644 --- a/beacon_chain/eth1/eth1_monitor.nim +++ b/beacon_chain/eth1/eth1_monitor.nim @@ -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: diff --git a/beacon_chain/nimbus_beacon_node.nim b/beacon_chain/nimbus_beacon_node.nim index 4bc07f053..e63cbd4d4 100644 --- a/beacon_chain/nimbus_beacon_node.nim +++ b/beacon_chain/nimbus_beacon_node.nim @@ -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) diff --git a/beacon_chain/spec/engine_authentication.nim b/beacon_chain/spec/engine_authentication.nim new file mode 100644 index 000000000..515012633 --- /dev/null +++ b/beacon_chain/spec/engine_authentication.nim @@ -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") diff --git a/beacon_chain/spec/light_client_sync.nim b/beacon_chain/spec/light_client_sync.nim index 6c914d433..44e989d56 100644 --- a/beacon_chain/spec/light_client_sync.nim +++ b/beacon_chain/spec/light_client_sync.nim @@ -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 diff --git a/beacon_chain/spec/mev/bellatrix_mev.nim b/beacon_chain/spec/mev/bellatrix_mev.nim index 24a569151..b57bda273 100644 --- a/beacon_chain/spec/mev/bellatrix_mev.nim +++ b/beacon_chain/spec/mev/bellatrix_mev.nim @@ -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 diff --git a/beacon_chain/validators/action_tracker.nim b/beacon_chain/validators/action_tracker.nim index bfce70ef4..2f411bd1f 100644 --- a/beacon_chain/validators/action_tracker.nim +++ b/beacon_chain/validators/action_tracker.nim @@ -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 diff --git a/beacon_chain/validators/validator_monitor.nim b/beacon_chain/validators/validator_monitor.nim index e37ddd667..5f856e319 100644 --- a/beacon_chain/validators/validator_monitor.nim +++ b/beacon_chain/validators/validator_monitor.nim @@ -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 diff --git a/tests/all_tests.nim b/tests/all_tests.nim index 1bb547be3..e726d32d7 100644 --- a/tests/all_tests.nim +++ b/tests/all_tests.nim @@ -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, diff --git a/tests/test_engine_authentication.nim b/tests/test_engine_authentication.nim new file mode 100644 index 000000000..3b49c9885 --- /dev/null +++ b/tests/test_engine_authentication.nim @@ -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"