From d037c84e8c8c338b1026d0a513dd7dfbdfb6eb08 Mon Sep 17 00:00:00 2001 From: Jonathan Bernard Date: Tue, 22 Sep 2026 10:22:23 -0500 Subject: [PATCH] Give JWK independent value semantics for thread-safe copies --- CHANGELOG.md | 21 +++++++++ README.md | 24 ++++++++++ jwt_full.nimble | 5 +- src/main/jwt_full/jwk.nim | 30 +++++++++--- src/test/unit/runner.nim | 1 + src/test/unit/tjoseheader.nim | 14 ++++++ src/test/unit/tjwk.nim | 87 ++++++++++++++++++++++++++++++++++- src/test/unit/tjwkthreads.nim | 47 +++++++++++++++++++ 8 files changed, 220 insertions(+), 9 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 src/test/unit/tjwkthreads.nim diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..cbbbadf --- /dev/null +++ b/CHANGELOG.md @@ -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. diff --git a/README.md b/README.md index 11b4f52..68ba9b6 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/jwt_full.nimble b/jwt_full.nimble index b17239c..eafcbc0 100644 --- a/jwt_full.nimble +++ b/jwt_full.nimble @@ -1,6 +1,6 @@ # Package -version = "0.4.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" diff --git a/src/main/jwt_full/jwk.nim b/src/main/jwt_full/jwk.nim index f570a70..c6a7e01 100644 --- a/src/main/jwt_full/jwk.nim +++ b/src/main/jwt_full/jwk.nim @@ -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") diff --git a/src/test/unit/runner.nim b/src/test/unit/runner.nim index 2cb5d53..faac9a9 100644 --- a/src/test/unit/runner.nim +++ b/src/test/unit/runner.nim @@ -4,5 +4,6 @@ import ./tclaims import ./tencoding import ./tjoseheader import ./tjwk +import ./tjwkthreads import ./tjws import ./tjwt diff --git a/src/test/unit/tjoseheader.nim b/src/test/unit/tjoseheader.nim index 92525ef..ff4e86d 100644 --- a/src/test/unit/tjoseheader.nim +++ b/src/test/unit/tjoseheader.nim @@ -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"] diff --git a/src/test/unit/tjwk.nim b/src/test/unit/tjwk.nim index e0695df..ccce857 100644 --- a/src/test/unit/tjwk.nim +++ b/src/test/unit/tjwk.nim @@ -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" diff --git a/src/test/unit/tjwkthreads.nim b/src/test/unit/tjwkthreads.nim new file mode 100644 index 0000000..5284fb3 --- /dev/null +++ b/src/test/unit/tjwkthreads.nim @@ -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