Enable JWT authentication for websockets (#1039)
* Enable JWT authentication for websockets details: Currently, this is optional and only enabled when the jwtsecret option is set. There is a default mechanism to generate a JWT secret if it is not explicitly stated. This mechanism is currently unused. * Make JWT authentication compulsory for websockets * Fix unit test entry point + cosmetics * Update JSON-RPC link * Improvements as suggested by Mamy
This commit is contained in:
parent
203d8e8b70
commit
737236fd6e
|
@ -403,6 +403,15 @@ type
|
|||
defaultValueDesc: $RpcFlag.Eth
|
||||
name: "ws-api" }: seq[string]
|
||||
|
||||
# github.com/ethereum/execution-apis/
|
||||
# /blob/v1.0.0-alpha.8/src/engine/authentication.md#key-distribution
|
||||
jwtSecret* {.
|
||||
desc: "Path to a file containing a 32 byte hex-encoded shared secret" &
|
||||
" needed for websocket authentication. By default, the secret key" &
|
||||
" is auto-generated."
|
||||
defaultValueDesc: "\"jwt.hex\" in the data directory (see --data-dir)"
|
||||
name: "jwt-secret" .}: Option[InputFile]
|
||||
|
||||
graphqlEnabled* {.
|
||||
desc: "Enable the GraphQL HTTP server"
|
||||
defaultValue: false
|
||||
|
|
|
@ -11,19 +11,26 @@ import
|
|||
../nimbus/vm_compile_info
|
||||
|
||||
import
|
||||
os, strutils, net, options,
|
||||
std/[os, strutils, net, options],
|
||||
chronicles,
|
||||
chronos,
|
||||
eth/[keys, net/nat, trie/db],
|
||||
eth/common as eth_common,
|
||||
eth/p2p as eth_p2p,
|
||||
eth/p2p/[peer_pool, rlpx_protocols/les_protocol],
|
||||
json_rpc/rpcserver,
|
||||
metrics,
|
||||
metrics/[chronos_httpserver, chronicles_support],
|
||||
stew/shims/net as stewNet,
|
||||
eth/keys, db/[storage_types, db_chain, select_backend],
|
||||
eth/common as eth_common, eth/p2p as eth_p2p,
|
||||
chronos, json_rpc/rpcserver, chronicles,
|
||||
eth/p2p/rlpx_protocols/les_protocol,
|
||||
./sync/protocol_ethxx,
|
||||
./p2p/blockchain_sync, eth/net/nat, eth/p2p/peer_pool,
|
||||
websock/types as ws,
|
||||
"."/[conf_utils, config, constants, context, genesis, sealer, utils, version],
|
||||
./db/[storage_types, db_chain, select_backend],
|
||||
./graphql/ethapi,
|
||||
./p2p/[chain, blockchain_sync],
|
||||
./p2p/clique/[clique_desc, clique_sealer],
|
||||
config, genesis, rpc/[common, p2p, debug, engine_api], p2p/chain,
|
||||
eth/trie/db, metrics, metrics/[chronos_httpserver, chronicles_support],
|
||||
graphql/ethapi, context, utils/tx_pool,
|
||||
"."/[conf_utils, sealer, constants, utils, version]
|
||||
./rpc/[common, debug, engine_api, jwt_auth, p2p],
|
||||
./sync/protocol_ethxx,
|
||||
./utils/tx_pool
|
||||
|
||||
when defined(evmc_enabled):
|
||||
import transaction/evmc_dynamic_loader
|
||||
|
@ -154,7 +161,6 @@ proc localServices(nimbus: NimbusNode, conf: NimbusConf,
|
|||
info "metrics", registry
|
||||
discard setTimer(Moment.fromNow(conf.logMetricsInterval.seconds), logMetrics)
|
||||
discard setTimer(Moment.fromNow(conf.logMetricsInterval.seconds), logMetrics)
|
||||
|
||||
# Creating RPC Server
|
||||
if conf.rpcEnabled:
|
||||
nimbus.rpcServer = newRpcHttpServer([initTAddress(conf.rpcAddress, conf.rpcPort)])
|
||||
|
@ -175,9 +181,23 @@ proc localServices(nimbus: NimbusNode, conf: NimbusConf,
|
|||
|
||||
nimbus.rpcServer.start()
|
||||
|
||||
# Provide JWT authentication handler for websockets
|
||||
let jwtHook = block:
|
||||
# Create or load shared secret
|
||||
let rc = nimbus.ctx.rng.jwtSharedSecret(conf)
|
||||
if rc.isErr:
|
||||
error "Failed create or load shared secret",
|
||||
msg = $(rc.unsafeError) # avoid side effects
|
||||
quit(QuitFailure)
|
||||
# Authentcation handler constructor
|
||||
@[rc.value.jwtAuthAsyHook]
|
||||
|
||||
# Creating Websocket RPC Server
|
||||
if conf.wsEnabled:
|
||||
nimbus.wsRpcServer = newRpcWebSocketServer(initTAddress(conf.wsAddress, conf.wsPort))
|
||||
# Construct server object
|
||||
nimbus.wsRpcServer = newRpcWebSocketServer(
|
||||
initTAddress(conf.wsAddress, conf.wsPort),
|
||||
authHandler = jwtHook)
|
||||
setupCommonRpc(nimbus.ethNode, conf, nimbus.wsRpcServer)
|
||||
|
||||
# Enable Websocket RPC APIs based on RPC flags and protocol flags
|
||||
|
@ -242,7 +262,8 @@ proc localServices(nimbus: NimbusNode, conf: NimbusConf,
|
|||
if conf.engineApiWsEnabled:
|
||||
if conf.engineApiWsPort != conf.wsPort:
|
||||
nimbus.engineApiWsServer = newRpcWebSocketServer(
|
||||
initTAddress(conf.engineApiWsAddress, conf.engineApiWsPort))
|
||||
initTAddress(conf.engineApiWsAddress, conf.engineApiWsPort),
|
||||
authHandler = jwtHook)
|
||||
setupEngineAPI(nimbus.sealingEngine, nimbus.engineApiWsServer)
|
||||
setupEthRpc(nimbus.ethNode, nimbus.ctx, chainDB, nimbus.txPool, nimbus.engineApiWsServer)
|
||||
nimbus.engineApiWsServer.start()
|
||||
|
|
|
@ -0,0 +1,337 @@
|
|||
# Nimbus
|
||||
# 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.
|
||||
#
|
||||
# Ackn:
|
||||
# nimbus-eth2/beacon_chain/spec/engine_authentication.nim
|
||||
# go-ethereum/node/jwt_handler.go
|
||||
|
||||
import
|
||||
std/[base64, json, options, os, strutils, times],
|
||||
bearssl,
|
||||
chronicles,
|
||||
chronos,
|
||||
chronos/apps/http/httptable,
|
||||
httputils,
|
||||
websock/types as ws,
|
||||
nimcrypto/[hmac, utils],
|
||||
stew/[byteutils, objects, results],
|
||||
../config
|
||||
|
||||
{.push raises: [Defect].}
|
||||
|
||||
logScope:
|
||||
topics = "Jwt/HS256 auth"
|
||||
|
||||
const
|
||||
jwtSecretFile* = ##\
|
||||
## A copy on the secret key in the `dataDir` directory
|
||||
"jwt.hex"
|
||||
|
||||
jwtMinSecretLen* = ##\
|
||||
## Number of bytes needed with the shared key
|
||||
32
|
||||
|
||||
type
|
||||
# -- currently unused --
|
||||
#
|
||||
#JwtAuthHandler* = ##\
|
||||
# ## JSW authenticator prototype
|
||||
# proc(req: HttpTable): Result[void,(HttpCode,string)]
|
||||
# {.gcsafe, raises: [Defect].}
|
||||
#
|
||||
|
||||
JwtAuthAsyHandler* = ##\
|
||||
## Asynchroneous JSW authenticator prototype. This is the definition
|
||||
## appicable for the `verify` entry of a `ws.Hook`.
|
||||
proc(req: HttpTable): Future[Result[void,string]]
|
||||
{.closure, gcsafe, raises: [Defect].}
|
||||
|
||||
JwtSharedKey* = ##\
|
||||
## Convenience type, needed quite often
|
||||
distinct array[jwtMinSecretLen,byte]
|
||||
|
||||
JwtSharedKeyRaw =
|
||||
array[jwtMinSecretLen,byte]
|
||||
|
||||
JwtGenSecret* = ##\
|
||||
## Random generator function producing a shared key. Typically, this\
|
||||
## will be a wrapper around a random generator type, such as\
|
||||
## `BrHmacDrbgContext`.
|
||||
proc(): JwtSharedKey {.gcsafe.}
|
||||
|
||||
JwtExcept* = object of CatchableError
|
||||
## Catch and relay exception error
|
||||
|
||||
JwtError* = enum
|
||||
jwtKeyTooSmall = "JWT secret not at least 256 bits"
|
||||
jwtKeyEmptyFile = "no 0x-prefixed hex string found"
|
||||
jwtKeyFileCannotOpen = "couldn't open specified JWT secret file"
|
||||
jwtKeyInvalidHexString = "invalid JWT hex string"
|
||||
|
||||
jwtTokenInvNumSegments = "token contains an invalid number of segments"
|
||||
jwtProtHeaderInvBase64 = "token protected header invalid base64 encoding"
|
||||
jwtProtHeaderInvJson = "token protected header invalid JSON data"
|
||||
jwtIatPayloadInvBase64 = "iat payload time invalid base64 encoding"
|
||||
jwtIatPayloadInvJson = "iat payload time invalid JSON data"
|
||||
jwtMethodUnsupported = "token protected header provides unsupported method"
|
||||
jwtTimeValidationError = "token time validation failed"
|
||||
jwtTokenValidationError = "token signature validation failed"
|
||||
|
||||
JwtHeader = object ##\
|
||||
## Template used for JSON unmarshalling
|
||||
typ, alg: string
|
||||
|
||||
JwtIatPayload = object ##\
|
||||
## Template used for JSON unmarshalling
|
||||
iat: uint64
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# Private functions
|
||||
# ------------------------------------------------------------------------------
|
||||
|
||||
template safeExecutor(info: string; code: untyped) =
|
||||
try:
|
||||
code
|
||||
except Exception as e:
|
||||
raise newException(JwtExcept, info & "(): " & $e.name & " -- " & e.msg)
|
||||
|
||||
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("=", "")
|
||||
|
||||
proc base64urlDecode(data: string): string
|
||||
{.gcsafe, raises: [Defect, CatchableError].} =
|
||||
## Decodes a JWT specific base64url, optionally encoding with stripped
|
||||
## padding.
|
||||
let l = data.len mod 4
|
||||
if 0 < l:
|
||||
return base64.decode(data & "=".repeat(4-l))
|
||||
base64.decode(data)
|
||||
|
||||
proc verifyTokenHS256(token: string; key: JwtSharedKey): Result[void,JwtError] =
|
||||
let p = token.split('.')
|
||||
if p.len != 3:
|
||||
return err(jwtTokenInvNumSegments)
|
||||
|
||||
var
|
||||
time: int64
|
||||
error: JwtError
|
||||
try:
|
||||
# Parse/verify protected header, try first the most common encoding
|
||||
# of """{"typ": "JWT", "alg": "HS256"}"""
|
||||
if p[0] != "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9":
|
||||
error = jwtProtHeaderInvBase64
|
||||
let jsonHeader = p[0].base64urlDecode
|
||||
|
||||
error = jwtProtHeaderInvJson
|
||||
let jwtHeader = jsonHeader.parseJson.to(JwtHeader)
|
||||
|
||||
# The following JSON decoded object is required
|
||||
if jwtHeader.typ != "JWT" and jwtHeader.alg != "HS256":
|
||||
return err(jwtMethodUnsupported)
|
||||
|
||||
# Get the time payload
|
||||
error = jwtIatPayloadInvBase64
|
||||
let jsonPayload = p[1].base64urlDecode
|
||||
|
||||
error = jwtIatPayloadInvJson
|
||||
let jwtPayload = jsonPayload.parseJson.to(JwtIatPayload)
|
||||
time = jwtPayload.iat.int64
|
||||
except:
|
||||
debug "JWT token decoding error",
|
||||
protectedHeader = p[0],
|
||||
payload = p[1],
|
||||
error
|
||||
return err(error)
|
||||
|
||||
# github.com/ethereum/
|
||||
# /execution-apis/blob/v1.0.0-alpha.8/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.
|
||||
let delta = getTime().toUnix - time
|
||||
if delta < -5 or 5 < delta:
|
||||
debug "Iat timestamp problem, accepted |delta| <= 5",
|
||||
delta
|
||||
return err(jwtTimeValidationError)
|
||||
|
||||
let
|
||||
keyArray = cast[array[jwtMinSecretLen,byte]](key)
|
||||
b64sig = base64urlEncode(sha256.hmac(keyArray, p[0] & "." & p[1]).data)
|
||||
if b64sig != p[2]:
|
||||
return err(jwtTokenValidationError)
|
||||
|
||||
ok()
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# Public functions
|
||||
# ------------------------------------------------------------------------------
|
||||
|
||||
proc fromHex*(key: var JwtSharedkey, src: string): Result[void,JwtError] =
|
||||
## Parse argument `src` from hex-string and fill it into the argument `key`.
|
||||
## This function is supposed to read and convert data in constant-time
|
||||
## fashion, guarding against side channel attacks.
|
||||
# utils.fromHex() does the constant-time job
|
||||
let secret = utils.fromHex(src)
|
||||
if secret.len < jwtMinSecretLen:
|
||||
return err(jwtKeyTooSmall)
|
||||
key = toArray(JwtSharedKeyRaw.len, secret).JwtSharedKey
|
||||
ok()
|
||||
|
||||
proc jwtGenSecret*(rng: ref BrHmacDrbgContext): JwtGenSecret =
|
||||
## Standard shared key random generator. If a fixed key is needed, a
|
||||
## function like
|
||||
## ::
|
||||
## proc preCompiledGenSecret(key: JwtSharedKey): JwtGenSecret =
|
||||
## result = proc: JwtSharedKey =
|
||||
## key
|
||||
##
|
||||
## might do. Not that in most cases, this function is internally used,
|
||||
## only.
|
||||
result = proc: JwtSharedKey =
|
||||
var data: array[jwtMinSecretLen,byte]
|
||||
rng[].brHmacDrbgGenerate(data)
|
||||
data.JwtSharedKey
|
||||
|
||||
proc jwtSharedSecret*(rndSecret: JwtGenSecret; config: NimbusConf):
|
||||
Result[JwtSharedKey, JwtError] =
|
||||
## Return a key for jwt authentication preferable from the argument file
|
||||
## `config.jwtSecret` (which contains at least 32 bytes hex encoded random
|
||||
## data.) Otherwise it creates a key and stores it in the `config.dataDir`.
|
||||
##
|
||||
## The resulting `JwtSharedKey` is supposed to be usewd as argument for
|
||||
## the function `jwtHandlerHS256()`, below.
|
||||
##
|
||||
## Note that this function variant is mainly used for debugging and testing.
|
||||
## For a more common interface prototype with explicit random generator
|
||||
## object see the variant below this one.
|
||||
##
|
||||
## Ackn nimbus-eth2:
|
||||
## beacon_chain/spec/engine_authentication.nim.`checkJwtSecret()`
|
||||
#
|
||||
# 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 (aka ``jwtMinSecretLen`
|
||||
# bytes.), the client should treat this as an error: either abort the
|
||||
# startup, or show error and continue without exposing the authenticated
|
||||
# port.
|
||||
#
|
||||
if config.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.
|
||||
#
|
||||
# github.com/ethereum/
|
||||
# /execution-apis/blob/v1.0.0-alpha.8/src/engine/
|
||||
# /authentication.md#key-distribution
|
||||
let
|
||||
jwtSecretPath = config.dataDir.string / jwtSecretFile
|
||||
newSecret = rndSecret()
|
||||
try:
|
||||
jwtSecretPath.writeFile(newSecret.JwtSharedKeyRaw.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 = config.jwtSecret.get.string.readLines(1)
|
||||
if lines.len == 0:
|
||||
return err(jwtKeyEmptyFile)
|
||||
var key: JwtSharedkey
|
||||
let rc = key.fromHex(lines[0])
|
||||
if rc.isErr:
|
||||
return err(rc.error)
|
||||
return ok(key.JwtSharedKey)
|
||||
except IOError:
|
||||
return err(jwtKeyFileCannotOpen)
|
||||
except ValueError:
|
||||
return err(jwtKeyInvalidHexString)
|
||||
|
||||
proc jwtSharedSecret*(rng: ref BrHmacDrbgContext; config: NimbusConf):
|
||||
Result[JwtSharedKey, JwtError]
|
||||
{.gcsafe, raises: [Defect,JwtExcept].} =
|
||||
## Variant of `jwtSharedSecret()` with explicit random generator argument.
|
||||
safeExecutor("jwtSharedSecret"):
|
||||
result = rng.jwtGenSecret.jwtSharedSecret(config)
|
||||
|
||||
|
||||
# -- currently unused --
|
||||
#
|
||||
#proc jwtAuthHandler*(key: JwtSharedKey): JwtAuthHandler =
|
||||
# ## Returns a JWT authentication handler that can be used with an HTTP header
|
||||
# ## based call back system.
|
||||
# ##
|
||||
# ## The argument `key` is captured by the session handler for JWT
|
||||
# ## authentication. The function `jwtSharedSecret()` provides such a key.
|
||||
# result = proc(req: HttpTable): Result[void,(HttpCode,string)] =
|
||||
# let auth = req.getString("Authorization","?")
|
||||
# if auth.len < 9 or auth[0..6].cmpIgnoreCase("Bearer ") != 0:
|
||||
# return err((Http403, "Missing Token"))
|
||||
#
|
||||
# let rc = auth[7..^1].strip.verifyTokenHS256(key)
|
||||
# if rc.isOk:
|
||||
# return ok()
|
||||
#
|
||||
# debug "Could not authenticate",
|
||||
# error = rc.error
|
||||
#
|
||||
# case rc.error:
|
||||
# of jwtTokenValidationError, jwtMethodUnsupported:
|
||||
# return err((Http401, "Unauthorized"))
|
||||
# else:
|
||||
# return err((Http403, "Malformed Token"))
|
||||
#
|
||||
|
||||
proc jwtAuthAsyHandler*(key: JwtSharedKey): JwtAuthAsyHandler =
|
||||
## Returns an asynchroneous JWT authentication handler that can be used with
|
||||
## an HTTP header based call back system.
|
||||
##
|
||||
## The argument `key` is captured by the session handler for JWT
|
||||
## authentication. The function `jwtSharedSecret()` provides such a key.
|
||||
result = proc(req: HttpTable): Future[Result[void,string]] {.async.} =
|
||||
let auth = req.getString("Authorization","?")
|
||||
if auth.len < 9 or auth[0..6].cmpIgnoreCase("Bearer ") != 0:
|
||||
return err("Missing Token")
|
||||
|
||||
let rc = auth[7..^1].strip.verifyTokenHS256(key)
|
||||
if rc.isOk:
|
||||
return ok()
|
||||
|
||||
debug "Could not authenticate",
|
||||
error = rc.error
|
||||
|
||||
case rc.error:
|
||||
of jwtTokenValidationError, jwtMethodUnsupported:
|
||||
return err("Unauthorized")
|
||||
else:
|
||||
return err("Malformed Token")
|
||||
|
||||
proc jwtAuthAsyHook*(key: JwtSharedKey): ws.Hook =
|
||||
## Variant of `jwtAuthHandler()` (e.g. directly suitable for Json WebSockets.)
|
||||
##
|
||||
## Note that currently there is no meaningful way to send a http 401/403 in
|
||||
## case of an authentication problem.
|
||||
let handler = key.jwtAuthAsyHandler
|
||||
ws.Hook(
|
||||
append: proc(ctx: ws.Hook, req: var HttpTable): Result[void,string] =
|
||||
ok(),
|
||||
verify: proc(ctx: ws.Hook, req: HttpTable): Future[Result[void,string]] =
|
||||
req.handler)
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# End
|
||||
# ------------------------------------------------------------------------------
|
|
@ -13,6 +13,7 @@ cliBuilder:
|
|||
import ./test_code_stream,
|
||||
./test_accounts_cache,
|
||||
./test_custom_network,
|
||||
./test_jwt_auth,
|
||||
./test_gas_meter,
|
||||
./test_memory,
|
||||
./test_stack,
|
||||
|
|
|
@ -128,13 +128,13 @@ const
|
|||
# Helpers
|
||||
# ------------------------------------------------------------------------------
|
||||
|
||||
proc findFilePath(file: string): string =
|
||||
result = "?unknown?" / file
|
||||
proc findFilePath(file: string): Result[string,void] =
|
||||
for dir in baseDir:
|
||||
for repo in repoDir:
|
||||
let path = dir / repo / file
|
||||
if path.fileExists:
|
||||
return path
|
||||
return ok(path)
|
||||
err()
|
||||
|
||||
proc flushDbDir(s: string) =
|
||||
if s != "":
|
||||
|
@ -211,7 +211,7 @@ proc genesisLoadRunner(noisy = true;
|
|||
|
||||
let
|
||||
gFileInfo = sSpcs.genesisFile.splitFile.name.split(".")[0]
|
||||
gFilePath = sSpcs.genesisFile.findFilePath
|
||||
gFilePath = sSpcs.genesisFile.findFilePath.value
|
||||
|
||||
tmpDir = if disablePersistentDB: "*notused*"
|
||||
else: gFilePath.splitFile.dir / "tmp"
|
||||
|
@ -288,7 +288,7 @@ proc testnetChainRunner(noisy = true;
|
|||
stopAfterBlock = 999999999) =
|
||||
let
|
||||
cFileInfo = sSpcs.captures[0].splitFile.name.split(".")[0]
|
||||
cFilePath = sSpcs.captures.mapIt(it.findFilePath)
|
||||
cFilePath = sSpcs.captures.mapIt(it.findFilePath.value)
|
||||
dbInfo = if memoryDB: "in-memory" else: "persistent"
|
||||
|
||||
pivotBlockNumber = sSpcs.failBlockAt.u256
|
||||
|
|
|
@ -0,0 +1,297 @@
|
|||
# Nimbus
|
||||
# Copyright (c) 2018-2019 Status Research & Development GmbH
|
||||
# Licensed under either of
|
||||
# * Apache License, version 2.0, ([LICENSE-APACHE](LICENSE-APACHE) or
|
||||
# http://www.apache.org/licenses/LICENSE-2.0)
|
||||
# * MIT license ([LICENSE-MIT](LICENSE-MIT) or
|
||||
# http://opensource.org/licenses/MIT)
|
||||
# at your option. This file may not be copied, modified, or distributed except
|
||||
# according to those terms.
|
||||
|
||||
## Test Jwt Authorisation Functionality
|
||||
## ====================================
|
||||
|
||||
import
|
||||
std/[base64, json, options, os, strutils, tables, times],
|
||||
../nimbus/config,
|
||||
../nimbus/rpc/jwt_auth,
|
||||
./replay/pp,
|
||||
confutils/defs,
|
||||
chronicles,
|
||||
chronos/apps/http/httpserver,
|
||||
eth/[common, keys, p2p],
|
||||
json_rpc/rpcserver,
|
||||
nimcrypto/[hmac, utils],
|
||||
stew/results,
|
||||
stint,
|
||||
unittest2
|
||||
|
||||
type
|
||||
UnGuardedKey =
|
||||
array[jwtMinSecretLen,byte]
|
||||
|
||||
const
|
||||
jwtKeyFile ="jwtsecret.txt" # external shared secret file
|
||||
jwtKeyStripped ="jwtstripped.txt" # without leading 0x
|
||||
jwtKeyCopy = jwtSecretFile # file containing effective data key
|
||||
|
||||
baseDir = [".", "..", ".."/"..", $DirSep]
|
||||
repoDir = [".", "tests" / "test_jwt_auth"]
|
||||
|
||||
let
|
||||
fakeKey = block:
|
||||
var rc: JwtSharedKey
|
||||
discard rc.fromHex((0..31).mapIt(15 - (it mod 16)).mapIt(it.byte).toHex)
|
||||
rc
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ------------------------------------------------------------------------------
|
||||
|
||||
proc findFilePath(file: string): Result[string,void] =
|
||||
for dir in baseDir:
|
||||
for repo in repoDir:
|
||||
let path = dir / repo / file
|
||||
if path.fileExists:
|
||||
return ok(path)
|
||||
err()
|
||||
|
||||
proc say*(noisy = false; pfx = "***"; args: varargs[string, `$`]) =
|
||||
if noisy:
|
||||
if args.len == 0:
|
||||
echo "*** ", pfx
|
||||
elif 0 < pfx.len and pfx[^1] != ' ':
|
||||
echo pfx, " ", args.toSeq.join
|
||||
else:
|
||||
echo pfx, args.toSeq.join
|
||||
|
||||
proc setTraceLevel =
|
||||
discard
|
||||
when defined(chronicles_runtime_filtering) and loggingEnabled:
|
||||
setLogLevel(LogLevel.TRACE)
|
||||
|
||||
proc setErrorLevel =
|
||||
discard
|
||||
when defined(chronicles_runtime_filtering) and loggingEnabled:
|
||||
setLogLevel(LogLevel.ERROR)
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# Private Functions
|
||||
# ------------------------------------------------------------------------------
|
||||
|
||||
proc fakeGenSecret(fake: JwtSharedKey): JwtGenSecret =
|
||||
## Key random generator, fake version
|
||||
result = proc: JwtSharedKey =
|
||||
fake
|
||||
|
||||
proc base64urlEncode(x: auto): string =
|
||||
## from nimbus-eth2, engine_authentication.nim
|
||||
base64.encode(x, safe = true).replace("=", "")
|
||||
|
||||
func getIatToken*(time: uint64): JsonNode =
|
||||
## from nimbus-eth2, engine_authentication.nim
|
||||
%* {"iat": time}
|
||||
|
||||
proc getSignedToken*(key: openArray[byte], payload: string): string =
|
||||
## from nimbus-eth2, engine_authentication.nim
|
||||
# Using hard coded string for """{"typ": "JWT", "alg": "HS256"}"""
|
||||
let sData = "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9." & base64urlEncode(payload)
|
||||
sData & "." & sha256.hmac(key, sData).data.base64UrlEncode
|
||||
|
||||
proc getSignedToken2*(key: openArray[byte], payload: string): string =
|
||||
## Variant of `getSignedToken()`: different algorithm encoding
|
||||
let
|
||||
jNode = %* {"alg": "HS256", "typ": "JWT" }
|
||||
sData = base64urlEncode($jNode) & "." & base64urlEncode(payload)
|
||||
sData & "." & sha256.hmac(key, sData).data.base64UrlEncode
|
||||
|
||||
proc getHttpAuthReqHeader(secret: JwtSharedKey; time: uint64): HttpTable =
|
||||
let bearer = secret.UnGuardedKey.getSignedToken($getIatToken(time))
|
||||
result.add("aUtHoRiZaTiOn", "Bearer " & bearer)
|
||||
|
||||
proc getHttpAuthReqHeader2(secret: JwtSharedKey; time: uint64): HttpTable =
|
||||
let bearer = secret.UnGuardedKey.getSignedToken2($getIatToken(time))
|
||||
result.add("aUtHoRiZaTiOn", "Bearer " & bearer)
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# Test Runners
|
||||
# ------------------------------------------------------------------------------
|
||||
|
||||
proc runKeyLoader(noisy = true;
|
||||
keyFile = jwtKeyFile; strippedFile = jwtKeyStripped) =
|
||||
let
|
||||
filePath = keyFile.findFilePath.value
|
||||
fileInfo = keyFile.splitFile.name.split(".")[0]
|
||||
|
||||
strippedPath = strippedFile.findFilePath.value
|
||||
strippedInfo = strippedFile.splitFile.name.split(".")[0]
|
||||
|
||||
dataDir = filePath.splitPath.head
|
||||
localKeyFile = dataDir / jwtKeyCopy
|
||||
|
||||
dataDirCmdOpt = &"--data-dir={dataDir}"
|
||||
jwtSecretCmdOpt = &"--jwt-secret={filePath}"
|
||||
jwtStrippedCmdOpt = &"--jwt-secret={strippedPath}"
|
||||
|
||||
suite "EngineAuth: Load or generate shared secrets":
|
||||
|
||||
test &"Load shared key file {fileInfo}":
|
||||
let
|
||||
config = @[dataDirCmdOpt,jwtSecretCmdOpt].makeConfig
|
||||
secret = fakeKey.fakeGenSecret.jwtSharedSecret(config)
|
||||
lines = config.jwtSecret.get.string.readLines(1)
|
||||
|
||||
check secret.isOk
|
||||
check 0 < lines.len
|
||||
|
||||
let
|
||||
hexKey = "0x" & secret.value.UnGuardedKey.toHex
|
||||
hexFake = "0x" & fakeKey.UnGuardedKey.toSeq.toHex
|
||||
hexLine = lines[0].strip
|
||||
|
||||
noisy.say "***", "key=", hexKey
|
||||
noisy.say " ", "text=", hexLine
|
||||
noisy.say " ", "fake=", hexFake
|
||||
|
||||
# Compare key against tcontents of shared key file
|
||||
check hexKey.cmpIgnoreCase(hexLine) == 0
|
||||
|
||||
# Just to make sure that there was no random generator used
|
||||
check hexKey.cmpIgnoreCase(hexFake) != 0
|
||||
|
||||
test &"Load shared key file {strippedInfo}, missing 0x prefix":
|
||||
let
|
||||
config = @[dataDirCmdOpt,jwtStrippedCmdOpt].makeConfig
|
||||
secret = fakeKey.fakeGenSecret.jwtSharedSecret(config)
|
||||
lines = config.jwtSecret.get.string.readLines(1)
|
||||
|
||||
check secret.isOk
|
||||
check 0 < lines.len
|
||||
|
||||
let
|
||||
hexKey = secret.value.UnGuardedKey.toHex
|
||||
hexFake = fakeKey.UnGuardedKey.toSeq.toHex
|
||||
hexLine = lines[0].strip
|
||||
|
||||
noisy.say "***", "key=", hexKey
|
||||
noisy.say " ", "text=", hexLine
|
||||
noisy.say " ", "fake=", hexFake
|
||||
|
||||
# Compare key against tcontents of shared key file
|
||||
check hexKey.cmpIgnoreCase(hexLine) == 0
|
||||
|
||||
# Just to make sure that there was no random generator used
|
||||
check hexKey.cmpIgnoreCase(hexFake) != 0
|
||||
|
||||
test &"Generate shared key file, store it in {jwtKeyCopy}":
|
||||
|
||||
# Clean up after file generation
|
||||
defer: localKeyFile.removeFile
|
||||
|
||||
# Maybe a stale left over
|
||||
localKeyFile.removeFile
|
||||
|
||||
let
|
||||
config = @[dataDirCmdOpt].makeConfig
|
||||
secret = fakeKey.fakeGenSecret.jwtSharedSecret(config)
|
||||
lines = localKeyFile.readLines(1)
|
||||
|
||||
check secret.isOk
|
||||
|
||||
let
|
||||
hexKey = "0x" & secret.value.UnGuardedKey.toHex
|
||||
hexLine = lines[0].strip
|
||||
|
||||
noisy.say "***", "key=", hexKey
|
||||
noisy.say " ", "text=", hexLine
|
||||
|
||||
# Compare key against tcontents of shared key file
|
||||
check hexKey.cmpIgnoreCase(hexLine) == 0
|
||||
|
||||
proc runJwtAuth(noisy = true; keyFile = jwtKeyFile) =
|
||||
let
|
||||
filePath = keyFile.findFilePath.value
|
||||
fileInfo = keyFile.splitFile.name.split(".")[0]
|
||||
|
||||
dataDir = filePath.splitPath.head
|
||||
|
||||
dataDirCmdOpt = &"--data-dir={dataDir}"
|
||||
jwtSecretCmdOpt = &"--jwt-secret={filePath}"
|
||||
config = @[dataDirCmdOpt,jwtSecretCmdOpt].makeConfig
|
||||
|
||||
# The secret is just used for extracting the key, it would otherwise
|
||||
# be hidden in the closure of the handler function
|
||||
secret = fakeKey.fakeGenSecret.jwtSharedSecret(config)
|
||||
|
||||
# The wrapper contains the handler function with the captured shared key
|
||||
asyHandler = secret.value.jwtAuthAsyHandler
|
||||
|
||||
suite "EngineAuth: Http/rpc authentication mechanics":
|
||||
|
||||
test &"JSW/HS256 authentication using shared secret file {fileInfo}":
|
||||
# Just to make sure that we made a proper choice. Typically, all
|
||||
# ingredients shoud have been tested, already in the preceeding test
|
||||
# suite.
|
||||
let
|
||||
lines = config.jwtSecret.get.string.readLines(1)
|
||||
hexKey = "0x" & secret.value.UnGuardedKey.toHex
|
||||
hexLine = lines[0].strip
|
||||
noisy.say "***", "key=", hexKey
|
||||
noisy.say " ", "text=", hexLine
|
||||
check hexKey.cmpIgnoreCase(hexLine) == 0
|
||||
|
||||
let
|
||||
time = getTime().toUnix.uint64
|
||||
req = secret.value.getHttpAuthReqHeader(time)
|
||||
noisy.say "***", "request",
|
||||
" Authorization=", req.getString("Authorization")
|
||||
|
||||
setTraceLevel()
|
||||
|
||||
# Run http authorisation request
|
||||
let htCode = waitFor req.asyHandler
|
||||
noisy.say "***", "result",
|
||||
" htCode=", htCode
|
||||
|
||||
check htCode.isOk
|
||||
setErrorLevel()
|
||||
|
||||
test &"JSW/HS256, ditto with protected header variant":
|
||||
let
|
||||
time = getTime().toUnix.uint64
|
||||
req = secret.value.getHttpAuthReqHeader2(time)
|
||||
|
||||
# Assemble request header
|
||||
noisy.say "***", "request",
|
||||
" Authorization=", req.getString("Authorization")
|
||||
|
||||
setTraceLevel()
|
||||
|
||||
# Run http authorisation request
|
||||
let htCode = waitFor req.asyHandler
|
||||
noisy.say "***", "result",
|
||||
" htCode=", htCode
|
||||
|
||||
check htCode.isOk
|
||||
setErrorLevel()
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# Main function(s)
|
||||
# ------------------------------------------------------------------------------
|
||||
|
||||
proc jwtAuthMain*(noisy = defined(debug)) =
|
||||
noisy.runKeyLoader
|
||||
noisy.runJwtAuth
|
||||
|
||||
when isMainModule:
|
||||
const
|
||||
noisy = defined(debug) or true
|
||||
|
||||
setErrorLevel()
|
||||
|
||||
noisy.runKeyLoader
|
||||
noisy.runJwtAuth
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# End
|
||||
# ------------------------------------------------------------------------------
|
|
@ -0,0 +1 @@
|
|||
0x0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef
|
|
@ -0,0 +1 @@
|
|||
0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef
|
|
@ -1 +1 @@
|
|||
Subproject commit b4bab89abdde3653939abe36ab9f6cae4aa1cbd1
|
||||
Subproject commit d4ae2328d4247c59cefd8d5e0fbc3f178a0eb4ef
|
Loading…
Reference in New Issue