nimbus-eth1/nimbus/rpc/jwt_auth.nim
Jordan Hrycaj df1217b7ca
Silence compiler gossip after nim upgrade cont3 (#1466)
* Removed some Windows specific unit test annoyances

details:
+ Short put()/get() cycles on persistent database have a race condition
  with vendor rocksdb. On a specific (and slow) qemu/win7 a 50ms `sleep()`
  in between will mostly do the job (i.e. unless heavy CPU load.) This
  issue was not observed on github/ci.
+ Removed annoyances when qemu/Win7 keeps the rocksdb database files
  locked even after closing the db. The problem is solved by strictly
  using fresh names for each test. No assumption made to be able to
  properly clean up. This issue was not observed on github/ci.

* Silence some compiler gossip -- part 7, misc/non(sync or graphql)

details:
  Adding some missing exception annotation
2023-02-14 20:27:17 +00:00

310 lines
11 KiB
Nim

# 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
{.push raises: [].}
import
std/[base64, json, options, os, strutils, times],
bearssl/rand,
chronicles,
chronos,
chronos/apps/http/[httptable, httpserver],
json_rpc/rpcserver,
httputils,
websock/websock as ws,
nimcrypto/[hmac, utils],
stew/[byteutils, objects, results],
../config
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
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\
## `HmacDrbgContext`.
proc(): JwtSharedKey {.gcsafe, raises: [CatchableError].}
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
# ------------------------------------------------------------------------------
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: [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 CatchableError as e:
debug "JWT token decoding error",
protectedHeader = p[0],
payload = p[1],
msg = e.msg,
error
return err(error)
except Exception as e:
{.warning: "Kludge(BareExcept): `parseJson()` in vendor package needs to be updated".}
raiseAssert "Ooops verifyTokenHS256(): name=" & $e.name & " msg=" & e.msg
# 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
try:
let secret = utils.fromHex(src)
if secret.len < jwtMinSecretLen:
return err(jwtKeyTooSmall)
key = toArray(JwtSharedKeyRaw.len, secret).JwtSharedKey
ok()
except ValueError:
err(jwtKeyInvalidHexString)
proc jwtGenSecret*(rng: ref rand.HmacDrbgContext): 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[].generate(data)
data.JwtSharedKey
proc jwtSharedSecret*(
rndSecret: JwtGenSecret;
config: NimbusConf;
): Result[JwtSharedKey, JwtError]
{.gcsafe, raises: [CatchableError].}=
## 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
discard e
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)
except IOError:
return err(jwtKeyFileCannotOpen)
except ValueError:
return err(jwtKeyInvalidHexString)
proc jwtSharedSecret*(rng: ref rand.HmacDrbgContext; config: NimbusConf):
Result[JwtSharedKey, JwtError]
{.gcsafe, raises: [CatchableError].} =
## Variant of `jwtSharedSecret()` with explicit random generator argument.
rng.jwtGenSecret.jwtSharedSecret(config)
proc httpJwtAuth*(key: JwtSharedKey): HttpAuthHook =
proc handler(req: HttpRequestRef): Future[HttpResponseRef] {.async.} =
let auth = req.headers.getString("Authorization", "?")
if auth.len < 9 or auth[0..6].cmpIgnoreCase("Bearer ") != 0:
return await req.respond(Http403, "Missing authorization token")
let rc = auth[7..^1].strip.verifyTokenHS256(key)
if rc.isOk:
return HttpResponseRef(nil)
debug "Could not authenticate",
error = rc.error
case rc.error:
of jwtTokenValidationError, jwtMethodUnsupported:
return await req.respond(Http401, "Unauthorized access")
else:
return await req.respond(Http403, "Malformed token")
result = HttpAuthHook(handler)
proc wsJwtAuth*(key: JwtSharedKey): WsAuthHook =
proc handler(req: ws.HttpRequest): Future[bool] {.async.} =
let auth = req.headers.getString("Authorization", "?")
if auth.len < 9 or auth[0..6].cmpIgnoreCase("Bearer ") != 0:
await req.sendResponse(code = Http403, data = "Missing authorization token")
return false
let rc = auth[7..^1].strip.verifyTokenHS256(key)
if rc.isOk:
return true
debug "Could not authenticate",
error = rc.error
case rc.error:
of jwtTokenValidationError, jwtMethodUnsupported:
await req.sendResponse(code = Http403, data = "Unauthorized access")
else:
await req.sendResponse(code = Http403, data = "Malformed token")
return false
result = WsAuthHook(handler)
# ------------------------------------------------------------------------------
# End
# ------------------------------------------------------------------------------