From 737236fd6e6c868d3f5894e2333fe2fbcb21d8e2 Mon Sep 17 00:00:00 2001 From: Jordan Hrycaj Date: Wed, 6 Apr 2022 15:11:13 +0100 Subject: [PATCH] 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 --- nimbus/config.nim | 9 + nimbus/nimbus.nim | 49 ++-- nimbus/rpc/jwt_auth.nim | 337 ++++++++++++++++++++++++++++ tests/all_tests.nim | 1 + tests/test_custom_network.nim | 10 +- tests/test_jwt_auth.nim | 297 ++++++++++++++++++++++++ tests/test_jwt_auth/jwtsecret.txt | 1 + tests/test_jwt_auth/jwtstripped.txt | 1 + vendor/nim-json-rpc | 2 +- 9 files changed, 687 insertions(+), 20 deletions(-) create mode 100644 nimbus/rpc/jwt_auth.nim create mode 100644 tests/test_jwt_auth.nim create mode 100644 tests/test_jwt_auth/jwtsecret.txt create mode 100644 tests/test_jwt_auth/jwtstripped.txt diff --git a/nimbus/config.nim b/nimbus/config.nim index b84dd7675..db8f6aab3 100644 --- a/nimbus/config.nim +++ b/nimbus/config.nim @@ -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 diff --git a/nimbus/nimbus.nim b/nimbus/nimbus.nim index c0604813e..432f01191 100644 --- a/nimbus/nimbus.nim +++ b/nimbus/nimbus.nim @@ -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() diff --git a/nimbus/rpc/jwt_auth.nim b/nimbus/rpc/jwt_auth.nim new file mode 100644 index 000000000..b13468500 --- /dev/null +++ b/nimbus/rpc/jwt_auth.nim @@ -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 +# ------------------------------------------------------------------------------ diff --git a/tests/all_tests.nim b/tests/all_tests.nim index d3abe11ff..102467512 100644 --- a/tests/all_tests.nim +++ b/tests/all_tests.nim @@ -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, diff --git a/tests/test_custom_network.nim b/tests/test_custom_network.nim index aee6681e6..ae70edf83 100644 --- a/tests/test_custom_network.nim +++ b/tests/test_custom_network.nim @@ -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 diff --git a/tests/test_jwt_auth.nim b/tests/test_jwt_auth.nim new file mode 100644 index 000000000..e699900c9 --- /dev/null +++ b/tests/test_jwt_auth.nim @@ -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 +# ------------------------------------------------------------------------------ diff --git a/tests/test_jwt_auth/jwtsecret.txt b/tests/test_jwt_auth/jwtsecret.txt new file mode 100644 index 000000000..cc8d220d5 --- /dev/null +++ b/tests/test_jwt_auth/jwtsecret.txt @@ -0,0 +1 @@ +0x0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef diff --git a/tests/test_jwt_auth/jwtstripped.txt b/tests/test_jwt_auth/jwtstripped.txt new file mode 100644 index 000000000..eef916177 --- /dev/null +++ b/tests/test_jwt_auth/jwtstripped.txt @@ -0,0 +1 @@ +0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef diff --git a/vendor/nim-json-rpc b/vendor/nim-json-rpc index b4bab89ab..d4ae2328d 160000 --- a/vendor/nim-json-rpc +++ b/vendor/nim-json-rpc @@ -1 +1 @@ -Subproject commit b4bab89abdde3653939abe36ab9f6cae4aa1cbd1 +Subproject commit d4ae2328d4247c59cefd8d5e0fbc3f178a0eb4ef