2 Commits
Author SHA1 Message Date
jdb 70862cb9e4 Require BearSSL 0.3 2026-10-05 20:59:26 -05:00
jdb d037c84e8c Give JWK independent value semantics for thread-safe copies 2026-09-29 08:26:50 -05:00
9 changed files with 235 additions and 20 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
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
+4 -3
View File
@@ -1,6 +1,6 @@
# Package
version = "0.4.0"
version = "0.5.1"
author = "Jonathan Bernard"
description = "Full JWT, JWS, JWE, and JWK implementation for Nim, compliant with RFCs 7515-7519."
license = "GPL-3.0-or-later"
@@ -9,8 +9,9 @@ srcDir = "src/main"
# Dependencies
requires "nim >= 1.4.8"
requires "bearssl"
requires "bearssl >= 0.3.0"
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"
+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")
+8 -10
View File
@@ -13,30 +13,28 @@ proc decodePem*(
) =
var pemCtx: PemDecoderContext
pemDecoderInit(pemCtx)
pemCtx.init()
var offset: uint = 0
var bytesRemaining: uint = uint len(pem)
var offset = 0
var readingObj = false
while bytesRemaining > 0:
let bytesRead = pemDecoderPush(pemCtx, unsafeAddr pem[offset], uint bytesRemaining)
while offset < pem.len:
let bytesRead = pemCtx.push(pem.toOpenArray(offset, pem.high))
offset += bytesRead
bytesRemaining -= bytesRead
case pemDecoderEvent(pemCtx):
case pemCtx.lastEvent():
of PEM_BEGIN_OBJ:
if readingObj:
raise newException(ValueError,
"Invalid PEM: saw a second BEGIN before seeing END.")
if pemDecoderName(pemCtx) != expectedObjectName:
if pemCtx.banner() != expectedObjectName:
raise newException(ValueError,
"Invalid PEM: expected BEGIN " & expectedObjectName &
" but got BEGIN " & $pemDecoderName(pemCtx))
" but got BEGIN " & pemCtx.banner())
readingObj = true
pemDecoderSetdest(pemCtx, callback, keyCtx)
pemCtx.setdest(callback, keyCtx)
of PEM_END_OBJ:
if readingObj: readingObj = false
+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