4 Commits
11 changed files with 263 additions and 38 deletions

No files matched your search

+27
View File
@@ -0,0 +1,27 @@
# Changelog
## 0.5.1 - 2026-10-05
### Changed
- Require BearSSL 0.3.0 or newer and use its managed PEM decoder interface.
## 0.5.0 - 2026-09-29
### Added
- Parsed `JWK` and `JwkSet` values now own their key material and metadata
allowing copies can be safely passed to worker threads without sharing
mutable JSON state.
- Added `%jwk` serialization, which produces an independent `JsonNode` and
retains extension members.
- The unit-test task now runs under threaded ARC and ORC memory management, with
coverage for copied key caches used concurrently by workers.
### Changed
- `initJwk` now snapshots its input JSON. Mutating the source `JsonNode` after
parsing no longer changes the resulting key.
- `jwk["member"]` still returns `Option[JsonNode]`, but the node is now a fresh
copy. Mutating it no longer changes the key; construct a new key with
`initJwk` to make a modification.
+24
View File
@@ -4,6 +4,30 @@ This is a standards-compliant implementation of the JWT, JWK, JWS, and JWE
standards in Nim, using the [BearSSL](https://www.bearssl.org/) library to standards in Nim, using the [BearSSL](https://www.bearssl.org/) library to
provide the underlying cryptographic primatives. provide the underlying cryptographic primatives.
## JWK ownership and threads
`JWK` and `JwkSet` have value semantics. Parsed keys own their key material and
metadata; changing the input `JsonNode` after `initJwk` or `initJwkSet` does not
change the keys. Assigning a key, key set, or `Table[string, JwkSet]` produces an
independent value suitable for handing to a worker thread. Synchronize access
to any source value that another thread may be modifying.
The typed accessors (`kid`, `alg`, `rsaPrv`, etc.) return values. The JSON member
accessor, `key["member"]`, keeps its `Option[JsonNode]` return type, but each call
returns a fresh node. Mutating that node does not update the key; construct a
new key with `initJwk` to change it. Missing members return `none`, while an
explicit JSON null returns `some` containing a `JNull` node.
Use `%key` to serialize a complete key, including extension members, to an
independent JSON object.
Internally, arbitrary JSON members are stored as encoded strings in a value
table. Only JSON member lookups decode them. Signing and verification use the
parsed key fields directly. This ownership guarantee applies to `JWK` and
`JwkSet`; JWT claims and JOSE headers retain their existing ownership behavior.
Run `nimble unittest` to run the suite, including worker-thread tests, under
both ARC and ORC.
## Supported Algorithms (JWA) ## Supported Algorithms (JWA)
[RFC7518 (JWA)][rfc7518] registers [RFC7518 (JWA)][rfc7518] registers
+4 -3
View File
@@ -1,6 +1,6 @@
# Package # Package
version = "0.2.0" version = "0.5.1"
author = "Jonathan Bernard" author = "Jonathan Bernard"
description = "Full JWT, JWS, JWE, and JWK implementation for Nim, compliant with RFCs 7515-7519." description = "Full JWT, JWS, JWE, and JWK implementation for Nim, compliant with RFCs 7515-7519."
license = "GPL-3.0-or-later" license = "GPL-3.0-or-later"
@@ -9,8 +9,9 @@ srcDir = "src/main"
# Dependencies # Dependencies
requires "nim >= 1.4.8" requires "nim >= 1.4.8"
requires "bearssl" requires "bearssl >= 0.3.0"
requires "https://github.com/yglukhov/bearssl_pkey_decoder" requires "https://github.com/yglukhov/bearssl_pkey_decoder"
task unittest, "Run the unit test suites.": task unittest, "Run the unit test suites.":
exec "nim c -r src/test/unit/runner" exec "nim c -r --threads:on --gc:arc src/test/unit/runner"
exec "nim c -r --threads:on --gc:orc src/test/unit/runner"
+26 -16
View File
@@ -25,25 +25,29 @@ type
JwtClaims* = object JwtClaims* = object
rawB64: string rawB64: string
json: JsonNode json: JsonNode
iss: Option[string]
sub: Option[string]
aud: Option[string] aud: Option[string]
exp: Option[DateTime] exp: Option[DateTime]
nbf: Option[DateTime]
iat: Option[DateTime] iat: Option[DateTime]
iss: Option[string]
jti: Option[string] jti: Option[string]
nbf: Option[DateTime]
sid: Option[string]
sub: Option[string]
typ: Option[string]
# Public read-only accessors to JwtClaims members # Public read-only accessors to JwtClaims members
# ----------------------------------------------- # -----------------------------------------------
func rawB64*(c: JwtClaims): string = c.rawB64 func rawB64*(c: JwtClaims): string = c.rawB64
func iss*(c: JwtClaims): Option[string] = c.iss
func sub*(c: JwtClaims): Option[string] = c.sub
func aud*(c: JwtClaims): Option[string] = c.aud func aud*(c: JwtClaims): Option[string] = c.aud
func exp*(c: JwtClaims): Option[DateTime] = c.exp func exp*(c: JwtClaims): Option[DateTime] = c.exp
func nbf*(c: JwtClaims): Option[DateTime] = c.nbf
func iat*(c: JwtClaims): Option[DateTime] = c.iat func iat*(c: JwtClaims): Option[DateTime] = c.iat
func iss*(c: JwtClaims): Option[string] = c.iss
func jti*(c: JwtClaims): Option[string] = c.jti func jti*(c: JwtClaims): Option[string] = c.jti
func nbf*(c: JwtClaims): Option[DateTime] = c.nbf
func sid*(c: JwtClaims): Option[string] = c.sid
func sub*(c: JwtClaims): Option[string] = c.sub
func typ*(c: JwtClaims): Option[string] = c.typ
func `[]`*(c: JwtClaims, key: string): Option[JsonNode] = func `[]`*(c: JwtClaims, key: string): Option[JsonNode] =
## Generic accessor to claim values by name ## Generic accessor to claim values by name
@@ -61,13 +65,15 @@ proc initJwtClaims*(n: JsonNode): JwtClaims =
return JwtClaims( return JwtClaims(
rawB64: b64UrlEncode($n), rawB64: b64UrlEncode($n),
json: n, json: n,
iss: n.optStrVal("iss"),
sub: n.optStrVal("sub"),
aud: n.optStrVal("aud"), aud: n.optStrVal("aud"),
exp: n.optNumericDate("exp"), exp: n.optNumericDate("exp"),
nbf: n.optNumericDate("nbf"),
iat: n.optNumericDate("iat"), iat: n.optNumericDate("iat"),
jti: n.optStrVal("jtu")) iss: n.optStrVal("iss"),
jti: n.optStrVal("jtu"),
nbf: n.optNumericDate("nbf"),
sid: n.optStrVal("sid"),
sub: n.optStrVal("sub"),
typ: n.optStrVal("typ"))
proc initJwtClaims*(encoded: string): JwtClaims = proc initJwtClaims*(encoded: string): JwtClaims =
## Parse a Base64url-encoded set of claims into a JwtClaims object ## Parse a Base64url-encoded set of claims into a JwtClaims object
@@ -77,13 +83,15 @@ proc initJwtClaims*(encoded: string): JwtClaims =
result.rawB64 = encoded result.rawB64 = encoded
proc initJwtClaims*( proc initJwtClaims*(
iss: Option[string] = none[string](),
sub: Option[string] = none[string](),
aud: Option[string] = none[string](), aud: Option[string] = none[string](),
exp: Option[DateTime] = none[DateTime](), exp: Option[DateTime] = none[DateTime](),
nbf: Option[DateTime] = none[DateTime](),
iat: Option[DateTime] = none[DateTime](), iat: Option[DateTime] = none[DateTime](),
iss: Option[string] = none[string](),
jti: Option[string] = none[string](), jti: Option[string] = none[string](),
nbf: Option[DateTime] = none[DateTime](),
sid: Option[string] = none[string](),
sub: Option[string] = none[string](),
typ: Option[string] = none[string](),
moreClaims: seq[tuple[k, v: string]] = @[] moreClaims: seq[tuple[k, v: string]] = @[]
): JwtClaims = ): JwtClaims =
## Convenience method to initialize a new JwtClaims object by specifying ## Convenience method to initialize a new JwtClaims object by specifying
@@ -99,12 +107,14 @@ proc initJwtClaims*(
let json = newJObject() let json = newJObject()
for claim in moreClaims: json[claim.k] = %claim.v for claim in moreClaims: json[claim.k] = %claim.v
if iss.isSome: json["iss"] = %iss.get
if sub.isSome: json["sub"] = %sub.get
if aud.isSome: json["aud"] = %aud.get if aud.isSome: json["aud"] = %aud.get
if exp.isSome: json["exp"] = %toNumericDate(exp.get) if exp.isSome: json["exp"] = %toNumericDate(exp.get)
if nbf.isSome: json["nbf"] = %toNumericDate(nbf.get)
if iat.isSome: json["iat"] = %toNumericDate(iat.get) if iat.isSome: json["iat"] = %toNumericDate(iat.get)
if iss.isSome: json["iss"] = %iss.get
if jti.isSome: json["jti"] = %jti.get if jti.isSome: json["jti"] = %jti.get
if nbf.isSome: json["nbf"] = %toNumericDate(nbf.get)
if sid.isSome: json["sid"] = %sid.get
if sub.isSome: json["sub"] = %sub.get
if typ.isSome: json["typ"] = %typ.get
return initJwtClaims(json) return initJwtClaims(json)
+24 -6
View File
@@ -11,7 +11,7 @@
## ##
## Nim implementation of the data structures and constants defined in RFC 7518. ## Nim implementation of the data structures and constants defined in RFC 7518.
import std/json, std/options, std/sequtils, std/strutils import std/json, std/options, std/sequtils, std/strutils, std/tables
import ./jwa import ./jwa
import ./private/jsonutils import ./private/jsonutils
@@ -56,7 +56,12 @@ type
k*: string k*: string
JWK* = object JWK* = object
json: JsonNode ## Owns its key material and metadata with value semantics. Copies may be
## handed to separate threads; synchronize access to a source being mutated.
## JSON member access returns a fresh node, independent of the key.
jsonMembers: Table[string, string]
# Keep arbitrary JSON members as encoded values, without JsonNode refs.
# Cryptographic operations use the parsed fields below, without decoding.
kty: string kty: string
use: Option[string] use: Option[string]
key_ops: Option[string] key_ops: Option[string]
@@ -96,8 +101,18 @@ func rsaPrv*(jwk: JWK): RsaPrvKey = jwk.rsaPrv
func octKey*(jwk: JWK): OctetKey = jwk.octKey func octKey*(jwk: JWK): OctetKey = jwk.octKey
func `[]`*(jwk: JWK, key: string): Option[JsonNode] = func `[]`*(jwk: JWK, key: string): Option[JsonNode] =
if jwk.json.hasKey(key): some(jwk.json[key]) ## Return an independent JSON value for a member, or none if it is absent.
else: none[JsonNode]() if jwk.jsonMembers.hasKey(key):
# The string parser only touches a newly allocated in-memory stream, but
# its stream-based implementation is not annotated noSideEffect in std/json.
{.cast(noSideEffect).}:
result = some(parseJson(jwk.jsonMembers[key]))
func `%`*(jwk: JWK): JsonNode =
## Serialize the key and its extension members to an independent JSON object.
result = newJObject()
for name in jwk.jsonMembers.keys:
result[name] = jwk[name].get
# Public Parsing Functions # Public Parsing Functions
# ------------------------ # ------------------------
@@ -143,7 +158,9 @@ func parseOctetKey*(n: JsonNode): OctetKey =
OctetKey(k: n.reqStrVal("k")) OctetKey(k: n.reqStrVal("k"))
func initJwk*(n: JsonNode): JWK = func initJwk*(n: JsonNode): JWK =
# TODO: documentation, example, handle encrypted keys (JWEs) ## Parse an owned snapshot of a key, including its extension metadata.
## Later changes to n or to nodes returned by [] do not affect the key.
# TODO: example, handle encrypted keys (JWEs)
case n["kty"].getStr(""): case n["kty"].getStr(""):
of $ktyEC: of $ktyEC:
@@ -179,7 +196,8 @@ func initJwk*(n: JsonNode): JWK =
else: raise newException(ValueError, else: raise newException(ValueError,
"Unrecognized or missing kty: '" & n["kty"].getStr("") & "'") "Unrecognized or missing kty: '" & n["kty"].getStr("") & "'")
result.json = n for name, value in n.pairs:
result.jsonMembers[name] = $value
result.use = n.optStrVal("use") result.use = n.optStrVal("use")
result.key_ops = n.optStrVal("key_ops") result.key_ops = n.optStrVal("key_ops")
result.alg = n.optStrVal("alg") result.alg = n.optStrVal("alg")
+1 -1
View File
@@ -57,7 +57,7 @@ proc initJWT*(encoded: string): JWT =
jws: jws) jws: jws)
else: else:
# TODO # TODO
raise newException(Exception, "not yet implemented") raise newException(Exception, "partial JWT construction is not yet implemented")
proc toJWT*(encoded: string): JWT = initJWT(encoded) proc toJWT*(encoded: string): JWT = initJWT(encoded)
+9 -11
View File
@@ -3,7 +3,7 @@ import bearssl
# Taken from nim-bearssl/decls.nim # Taken from nim-bearssl/decls.nim
{.pragma: bearSslFunc, cdecl, gcsafe, noSideEffect, raises: [].} {.pragma: bearSslFunc, cdecl, gcsafe, noSideEffect, raises: [].}
type BearPemDecoderCallback* = proc(destCtx: pointer, src: pointer, dataLen: uint) {.bearSslFunc.} type BearPemDecoderCallback* = proc(destCtx: pointer, src: pointer, dataLen: csize_t) {.bearSslFunc.}
proc decodePem*( proc decodePem*(
pem: string, pem: string,
@@ -13,30 +13,28 @@ proc decodePem*(
) = ) =
var pemCtx: PemDecoderContext var pemCtx: PemDecoderContext
pemDecoderInit(pemCtx) pemCtx.init()
var offset: uint = 0 var offset = 0
var bytesRemaining: uint = uint len(pem)
var readingObj = false var readingObj = false
while bytesRemaining > 0: while offset < pem.len:
let bytesRead = pemDecoderPush(pemCtx, unsafeAddr pem[offset], uint bytesRemaining) let bytesRead = pemCtx.push(pem.toOpenArray(offset, pem.high))
offset += bytesRead offset += bytesRead
bytesRemaining -= bytesRead
case pemDecoderEvent(pemCtx): case pemCtx.lastEvent():
of PEM_BEGIN_OBJ: of PEM_BEGIN_OBJ:
if readingObj: if readingObj:
raise newException(ValueError, raise newException(ValueError,
"Invalid PEM: saw a second BEGIN before seeing END.") "Invalid PEM: saw a second BEGIN before seeing END.")
if pemDecoderName(pemCtx) != expectedObjectName: if pemCtx.banner() != expectedObjectName:
raise newException(ValueError, raise newException(ValueError,
"Invalid PEM: expected BEGIN " & expectedObjectName & "Invalid PEM: expected BEGIN " & expectedObjectName &
" but got BEGIN " & $pemDecoderName(pemCtx)) " but got BEGIN " & pemCtx.banner())
readingObj = true readingObj = true
pemDecoderSetdest(pemCtx, callback, keyCtx) pemCtx.setdest(callback, keyCtx)
of PEM_END_OBJ: of PEM_END_OBJ:
if readingObj: readingObj = false if readingObj: readingObj = false
+1
View File
@@ -4,5 +4,6 @@ import ./tclaims
import ./tencoding import ./tencoding
import ./tjoseheader import ./tjoseheader
import ./tjwk import ./tjwk
import ./tjwkthreads
import ./tjws import ./tjws
import ./tjwt import ./tjwt
+14
View File
@@ -2,6 +2,7 @@ import std/json, std/options, std/unittest
import jwt_full/jwa import jwt_full/jwa
import jwt_full/joseheader import jwt_full/joseheader
import jwt_full/jwk
import ../testdata import ../testdata
@@ -16,3 +17,16 @@ suite "jwt_full/joseheader":
h1.rawB64 == rfc7519_S31_HeaderB64 h1.rawB64 == rfc7519_S31_HeaderB64
h2.rawB64 == "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9" h2.rawB64 == "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9"
h3.rawB64 == "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9" h3.rawB64 == "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9"
test "initJoseHeader serializes an embedded JWK":
let node = %*{"kty": "oct", "k": "c2VjcmV0", "kid": "original",
"metadata": {"labels": ["original"]}}
let key = initJwk(node)
let header = initJoseHeader(jwk = some(key))
check header["jwk"].get == node
check header.jwk.get.kid.get == "original"
let decoded = initJoseHeader(header.rawB64)
check decoded["jwk"].get == node
header["jwk"].get["metadata"]["labels"][0].str = "changed"
check header.jwk.get["metadata"].get == node["metadata"]
check key["metadata"].get == node["metadata"]
+86 -1
View File
@@ -1,4 +1,4 @@
import std/json, std/options, std/unittest import std/json, std/options, std/tables, std/unittest
import jwt_full/jwa, jwt_full/jwk import jwt_full/jwa, jwt_full/jwk
@@ -215,3 +215,88 @@ suite "jwt_full/jwk":
jwkSet[0].keyKind == EcPrivate jwkSet[0].keyKind == EcPrivate
jwkSet[1].kty == $ktyRSA jwkSet[1].kty == $ktyRSA
jwkSet[1].keyKind == RsaPrivate jwkSet[1].keyKind == RsaPrivate
test "all key variants own their input JSON":
let nodes = @[
rfc7517A1ExamplePubKeysJson["keys"][0].copy,
rfc7517A1ExamplePubKeysJson["keys"][1].copy,
rfc7517A2ExamplePrvKeysJson["keys"][0].copy,
rfc7517A2ExamplePrvKeysJson["keys"][1].copy,
%*{"kty": "oct", "k": "c2VjcmV0", "kid": "octet"}]
for node in nodes:
node["metadata"] = %*{"labels": ["original"]}
let original = node.copy
let key = initJwk(node)
node["kid"].str = "changed"
node["metadata"]["labels"][0].str = "changed"
node["newMember"] = %true
for name, value in original.pairs:
check key[name].get == value
check key.kid.get == original["kid"].getStr
check key["newMember"].isNone
test "JSON member lookups return independent values":
let key = initJwk(%*{
"kty": "oct", "k": "c2VjcmV0", "kid": "original",
"metadata": {"labels": ["original"]}})
let copied = key
let metadata = copied["metadata"].get
metadata["labels"][0].str = "changed"
let kid = copied["kid"].get
kid.str = "changed"
check key["metadata"].get["labels"][0].getStr == "original"
check copied["metadata"].get["labels"][0].getStr == "original"
check key["kid"].get.getStr == "original"
check copied["kid"].get.getStr == "original"
check copied.kid.get == "original"
test "JSON serialization round trips without exposing owned state":
let node = %*{"kty": "oct", "k": "c2VjcmV0", "kid": "original",
"metadata": {"labels": ["original"]}}
let key = initJwk(node)
let serialized = %key
check serialized == node
check %initJwk(serialized) == node
serialized["metadata"]["labels"][0].str = "changed"
check %key == node
test "member lookup preserves JSON types and missing versus null":
let node = %*{
"kty": "oct", "k": "c2VjcmV0", "nullValue": nil,
"integer": 1234567890123456789'i64, "float": 1.25, "boolean": true,
"string": "quotes: \" newline: \n unicode: \u263a",
"array": [nil, false, 42, {"nested": []}], "object": {}}
let key = initJwk(node)
for name, value in node.pairs:
check key[name].get.kind == value.kind
check key[name].get == value
check key["nullValue"].isSome
check key["nullValue"].get.kind == JNull
check key["missing"].isNone
check default(JWK)["missing"].isNone
test "tables of key sets have independent copies":
let node = %*{"keys": [{"kty": "oct", "k": "c2VjcmV0", "kid": "original"}]}
let keys = initJwkSet(node)
let original = {"issuer": keys}.toTable
var copied = original
node["keys"][0]["kid"].str = "changed"
copied["issuer"][0]["kid"].get.str = "changed"
copied["issuer"].add initJwk(%*{"kty": "oct", "k": "c2VjcmV0", "kid": "added"})
check keys[0]["kid"].get.getStr == "original"
check original["issuer"].len == 1
check original["issuer"][0]["kid"].get.getStr == "original"
check copied["issuer"].len == 2
check copied["issuer"][0]["kid"].get.getStr == "original"
test "RSA accessors return independent nested value fields":
let node = rfc7517A2ExamplePrvKeysJson["keys"][1].copy
node["oth"] = %*[{"r": "Aw", "d": "BQ", "t": "Bw"}]
let key = initJwk(node)
var rsa = key.rsaPrv
rsa.n[0] = '!'
rsa.p.get[0] = '!'
rsa.oth.get[0].r[0] = '!'
check key.rsaPrv.n == node["n"].getStr
check key.rsaPrv.p.get == node["p"].getStr
check key.rsaPrv.oth.get[0].r == "Aw"
+47
View File
@@ -0,0 +1,47 @@
when compileOption("threads"):
import std/[json, options, tables, unittest]
import jwt_full
type
KeyCache = Table[string, JwkSet]
WorkerInput = tuple[keys: KeyCache, passed: ptr bool]
proc useKeys(input: WorkerInput) {.thread.} =
var keys = input.keys
try:
for i in 0 ..< 50:
let key = keys["issuer"][0]
let metadata = key["metadata"].get
metadata["labels"][0].str = "worker"
var material = key.octKey
material.k[0] = '!'
# Both metadata and parsed key material remain intact after modifying
# accessor results. Each worker also signs and verifies with its copy.
if key["metadata"].get["labels"][0].getStr != "original": return
if key.octKey.k[0] == '!': return
let token = sign(payload = "payload",
header = initJoseHeader(%*{"alg": "HS256"}), key = key)
token.validate(HS256, key)
keys["issuer"].setLen(0)
input.passed[] = true
except CatchableError:
input.passed[] = false
suite "jwt_full/jwk thread isolation":
test "workers copy key caches and use their keys independently":
let node = %*{
"kty": "oct", "alg": "HS256", "kid": "original",
"k": "MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY",
"metadata": {"labels": ["original"]}}
let original = {"issuer": @[initJwk(node)]}.toTable
var workers: array[4, Thread[WorkerInput]]
var passed: array[4, bool]
for i in 0 ..< workers.len:
createThread(workers[i], useKeys, (original, addr passed[i]))
joinThreads(workers)
check passed == [true, true, true, true]
check original["issuer"].len == 1
check original["issuer"][0]["metadata"].get == node["metadata"]
check original["issuer"][0].octKey.k == node["k"].getStr