mirror of
https://github.com/status-im/nimbus-eth1.git
synced 2025-01-12 05:14:14 +00:00
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
|
defaultValueDesc: $RpcFlag.Eth
|
||||||
name: "ws-api" }: seq[string]
|
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* {.
|
graphqlEnabled* {.
|
||||||
desc: "Enable the GraphQL HTTP server"
|
desc: "Enable the GraphQL HTTP server"
|
||||||
defaultValue: false
|
defaultValue: false
|
||||||
|
@ -11,19 +11,26 @@ import
|
|||||||
../nimbus/vm_compile_info
|
../nimbus/vm_compile_info
|
||||||
|
|
||||||
import
|
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,
|
stew/shims/net as stewNet,
|
||||||
eth/keys, db/[storage_types, db_chain, select_backend],
|
websock/types as ws,
|
||||||
eth/common as eth_common, eth/p2p as eth_p2p,
|
"."/[conf_utils, config, constants, context, genesis, sealer, utils, version],
|
||||||
chronos, json_rpc/rpcserver, chronicles,
|
./db/[storage_types, db_chain, select_backend],
|
||||||
eth/p2p/rlpx_protocols/les_protocol,
|
./graphql/ethapi,
|
||||||
./sync/protocol_ethxx,
|
./p2p/[chain, blockchain_sync],
|
||||||
./p2p/blockchain_sync, eth/net/nat, eth/p2p/peer_pool,
|
|
||||||
./p2p/clique/[clique_desc, clique_sealer],
|
./p2p/clique/[clique_desc, clique_sealer],
|
||||||
config, genesis, rpc/[common, p2p, debug, engine_api], p2p/chain,
|
./rpc/[common, debug, engine_api, jwt_auth, p2p],
|
||||||
eth/trie/db, metrics, metrics/[chronos_httpserver, chronicles_support],
|
./sync/protocol_ethxx,
|
||||||
graphql/ethapi, context, utils/tx_pool,
|
./utils/tx_pool
|
||||||
"."/[conf_utils, sealer, constants, utils, version]
|
|
||||||
|
|
||||||
when defined(evmc_enabled):
|
when defined(evmc_enabled):
|
||||||
import transaction/evmc_dynamic_loader
|
import transaction/evmc_dynamic_loader
|
||||||
@ -154,7 +161,6 @@ proc localServices(nimbus: NimbusNode, conf: NimbusConf,
|
|||||||
info "metrics", registry
|
info "metrics", registry
|
||||||
discard setTimer(Moment.fromNow(conf.logMetricsInterval.seconds), logMetrics)
|
discard setTimer(Moment.fromNow(conf.logMetricsInterval.seconds), logMetrics)
|
||||||
discard setTimer(Moment.fromNow(conf.logMetricsInterval.seconds), logMetrics)
|
discard setTimer(Moment.fromNow(conf.logMetricsInterval.seconds), logMetrics)
|
||||||
|
|
||||||
# Creating RPC Server
|
# Creating RPC Server
|
||||||
if conf.rpcEnabled:
|
if conf.rpcEnabled:
|
||||||
nimbus.rpcServer = newRpcHttpServer([initTAddress(conf.rpcAddress, conf.rpcPort)])
|
nimbus.rpcServer = newRpcHttpServer([initTAddress(conf.rpcAddress, conf.rpcPort)])
|
||||||
@ -175,9 +181,23 @@ proc localServices(nimbus: NimbusNode, conf: NimbusConf,
|
|||||||
|
|
||||||
nimbus.rpcServer.start()
|
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
|
# Creating Websocket RPC Server
|
||||||
if conf.wsEnabled:
|
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)
|
setupCommonRpc(nimbus.ethNode, conf, nimbus.wsRpcServer)
|
||||||
|
|
||||||
# Enable Websocket RPC APIs based on RPC flags and protocol flags
|
# 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.engineApiWsEnabled:
|
||||||
if conf.engineApiWsPort != conf.wsPort:
|
if conf.engineApiWsPort != conf.wsPort:
|
||||||
nimbus.engineApiWsServer = newRpcWebSocketServer(
|
nimbus.engineApiWsServer = newRpcWebSocketServer(
|
||||||
initTAddress(conf.engineApiWsAddress, conf.engineApiWsPort))
|
initTAddress(conf.engineApiWsAddress, conf.engineApiWsPort),
|
||||||
|
authHandler = jwtHook)
|
||||||
setupEngineAPI(nimbus.sealingEngine, nimbus.engineApiWsServer)
|
setupEngineAPI(nimbus.sealingEngine, nimbus.engineApiWsServer)
|
||||||
setupEthRpc(nimbus.ethNode, nimbus.ctx, chainDB, nimbus.txPool, nimbus.engineApiWsServer)
|
setupEthRpc(nimbus.ethNode, nimbus.ctx, chainDB, nimbus.txPool, nimbus.engineApiWsServer)
|
||||||
nimbus.engineApiWsServer.start()
|
nimbus.engineApiWsServer.start()
|
||||||
|
337
nimbus/rpc/jwt_auth.nim
Normal file
337
nimbus/rpc/jwt_auth.nim
Normal file
@ -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,
|
import ./test_code_stream,
|
||||||
./test_accounts_cache,
|
./test_accounts_cache,
|
||||||
./test_custom_network,
|
./test_custom_network,
|
||||||
|
./test_jwt_auth,
|
||||||
./test_gas_meter,
|
./test_gas_meter,
|
||||||
./test_memory,
|
./test_memory,
|
||||||
./test_stack,
|
./test_stack,
|
||||||
|
@ -128,13 +128,13 @@ const
|
|||||||
# Helpers
|
# Helpers
|
||||||
# ------------------------------------------------------------------------------
|
# ------------------------------------------------------------------------------
|
||||||
|
|
||||||
proc findFilePath(file: string): string =
|
proc findFilePath(file: string): Result[string,void] =
|
||||||
result = "?unknown?" / file
|
|
||||||
for dir in baseDir:
|
for dir in baseDir:
|
||||||
for repo in repoDir:
|
for repo in repoDir:
|
||||||
let path = dir / repo / file
|
let path = dir / repo / file
|
||||||
if path.fileExists:
|
if path.fileExists:
|
||||||
return path
|
return ok(path)
|
||||||
|
err()
|
||||||
|
|
||||||
proc flushDbDir(s: string) =
|
proc flushDbDir(s: string) =
|
||||||
if s != "":
|
if s != "":
|
||||||
@ -211,7 +211,7 @@ proc genesisLoadRunner(noisy = true;
|
|||||||
|
|
||||||
let
|
let
|
||||||
gFileInfo = sSpcs.genesisFile.splitFile.name.split(".")[0]
|
gFileInfo = sSpcs.genesisFile.splitFile.name.split(".")[0]
|
||||||
gFilePath = sSpcs.genesisFile.findFilePath
|
gFilePath = sSpcs.genesisFile.findFilePath.value
|
||||||
|
|
||||||
tmpDir = if disablePersistentDB: "*notused*"
|
tmpDir = if disablePersistentDB: "*notused*"
|
||||||
else: gFilePath.splitFile.dir / "tmp"
|
else: gFilePath.splitFile.dir / "tmp"
|
||||||
@ -288,7 +288,7 @@ proc testnetChainRunner(noisy = true;
|
|||||||
stopAfterBlock = 999999999) =
|
stopAfterBlock = 999999999) =
|
||||||
let
|
let
|
||||||
cFileInfo = sSpcs.captures[0].splitFile.name.split(".")[0]
|
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"
|
dbInfo = if memoryDB: "in-memory" else: "persistent"
|
||||||
|
|
||||||
pivotBlockNumber = sSpcs.failBlockAt.u256
|
pivotBlockNumber = sSpcs.failBlockAt.u256
|
||||||
|
297
tests/test_jwt_auth.nim
Normal file
297
tests/test_jwt_auth.nim
Normal file
@ -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
|
||||||
|
# ------------------------------------------------------------------------------
|
1
tests/test_jwt_auth/jwtsecret.txt
Normal file
1
tests/test_jwt_auth/jwtsecret.txt
Normal file
@ -0,0 +1 @@
|
|||||||
|
0x0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef
|
1
tests/test_jwt_auth/jwtstripped.txt
Normal file
1
tests/test_jwt_auth/jwtstripped.txt
Normal file
@ -0,0 +1 @@
|
|||||||
|
0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef
|
2
vendor/nim-json-rpc
vendored
2
vendor/nim-json-rpc
vendored
@ -1 +1 @@
|
|||||||
Subproject commit b4bab89abdde3653939abe36ab9f6cae4aa1cbd1
|
Subproject commit d4ae2328d4247c59cefd8d5e0fbc3f178a0eb4ef
|
Loading…
x
Reference in New Issue
Block a user