3 Commits
11 changed files with 248 additions and 27 deletions

No files matched your search

+21
View File
@@ -0,0 +1,21 @@
# Changelog
## 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
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)
[RFC7518 (JWA)][rfc7518] registers
+3 -2
View File
@@ -1,6 +1,6 @@
# Package
version = "0.2.0"
version = "0.5.0"
author = "Jonathan Bernard"
description = "Full JWT, JWS, JWE, and JWK implementation for Nim, compliant with RFCs 7515-7519."
license = "GPL-3.0-or-later"
@@ -13,4 +13,5 @@ requires "bearssl"
requires "https://github.com/yglukhov/bearssl_pkey_decoder"
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
rawB64: string
json: JsonNode
iss: Option[string]
sub: Option[string]
aud: Option[string]
exp: Option[DateTime]
nbf: Option[DateTime]
iat: Option[DateTime]
iss: Option[string]
jti: Option[string]
nbf: Option[DateTime]
sid: Option[string]
sub: Option[string]
typ: Option[string]
# Public read-only accessors to JwtClaims members
# -----------------------------------------------
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 exp*(c: JwtClaims): Option[DateTime] = c.exp
func nbf*(c: JwtClaims): Option[DateTime] = c.nbf
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 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] =
## Generic accessor to claim values by name
@@ -61,13 +65,15 @@ proc initJwtClaims*(n: JsonNode): JwtClaims =
return JwtClaims(
rawB64: b64UrlEncode($n),
json: n,
iss: n.optStrVal("iss"),
sub: n.optStrVal("sub"),
aud: n.optStrVal("aud"),
exp: n.optNumericDate("exp"),
nbf: n.optNumericDate("nbf"),
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 =
## Parse a Base64url-encoded set of claims into a JwtClaims object
@@ -77,13 +83,15 @@ proc initJwtClaims*(encoded: string): JwtClaims =
result.rawB64 = encoded
proc initJwtClaims*(
iss: Option[string] = none[string](),
sub: Option[string] = none[string](),
aud: Option[string] = none[string](),
exp: Option[DateTime] = none[DateTime](),
nbf: Option[DateTime] = none[DateTime](),
iat: Option[DateTime] = none[DateTime](),
iss: 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]] = @[]
): JwtClaims =
## Convenience method to initialize a new JwtClaims object by specifying
@@ -99,12 +107,14 @@ proc initJwtClaims*(
let json = newJObject()
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 exp.isSome: json["exp"] = %toNumericDate(exp.get)
if nbf.isSome: json["nbf"] = %toNumericDate(nbf.get)
if iat.isSome: json["iat"] = %toNumericDate(iat.get)
if iss.isSome: json["iss"] = %iss.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)
+24 -6
View File
@@ -11,7 +11,7 @@
##
## 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 ./private/jsonutils
@@ -56,7 +56,12 @@ type
k*: string
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
use: 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 `[]`*(jwk: JWK, key: string): Option[JsonNode] =
if jwk.json.hasKey(key): some(jwk.json[key])
else: none[JsonNode]()
## Return an independent JSON value for a member, or none if it is absent.
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
# ------------------------
@@ -143,7 +158,9 @@ func parseOctetKey*(n: JsonNode): OctetKey =
OctetKey(k: n.reqStrVal("k"))
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(""):
of $ktyEC:
@@ -179,7 +196,8 @@ func initJwk*(n: JsonNode): JWK =
else: raise newException(ValueError,
"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.key_ops = n.optStrVal("key_ops")
result.alg = n.optStrVal("alg")
+1 -1
View File
@@ -57,7 +57,7 @@ proc initJWT*(encoded: string): JWT =
jws: jws)
else:
# TODO
raise newException(Exception, "not yet implemented")
raise newException(Exception, "partial JWT construction is not yet implemented")
proc toJWT*(encoded: string): JWT = initJWT(encoded)
+1 -1
View File
@@ -3,7 +3,7 @@ import bearssl
# Taken from nim-bearssl/decls.nim
{.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*(
pem: string,
+1
View File
@@ -4,5 +4,6 @@ import ./tclaims
import ./tencoding
import ./tjoseheader
import ./tjwk
import ./tjwkthreads
import ./tjws
import ./tjwt
+14
View File
@@ -2,6 +2,7 @@ import std/json, std/options, std/unittest
import jwt_full/jwa
import jwt_full/joseheader
import jwt_full/jwk
import ../testdata
@@ -16,3 +17,16 @@ suite "jwt_full/joseheader":
h1.rawB64 == rfc7519_S31_HeaderB64
h2.rawB64 == "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9"
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
@@ -215,3 +215,88 @@ suite "jwt_full/jwk":
jwkSet[0].keyKind == EcPrivate
jwkSet[1].kty == $ktyRSA
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