systemslibrarian/postquantum-jwt
GitHub: systemslibrarian/postquantum-jwt
一个基于 .NET 10 的后量子混合 JWT 库,使用 ML-DSA-65 签名与 X-Wing 混合加密,为受控系统提供抗量子计算的令牌签发与验证能力。
Stars: 0 | Forks: 0
# PostQuantum.Jwt
[](https://www.nuget.org/packages/PostQuantum.Jwt)
[](https://www.nuget.org/packages/PostQuantum.Jwt)
[](https://github.com/systemslibrarian/postquantum-jwt/actions/workflows/ci.yml)
[](https://dotnet.microsoft.com/)
[](LICENSE)
**Hybrid confidentiality, post-quantum signatures — JOSE-style tokens for
.NET 10.** PostQuantum.Jwt is a production-oriented preview library for
controlled .NET issuer/verifier systems that need JOSE-style post-quantum
tokens. It provides ML-DSA-65 signed tokens, optional hybrid X-Wing-style
confidentiality (X25519 + ML-KEM-768 with AES-256-GCM), strict algorithm
handling, fail-closed validation, replay-protection support, key-rotation
patterns, and hardened usage guidance. Built on the native .NET BCL
post-quantum primitives. It is **not independently audited** and is **not a
drop-in replacement for OAuth/OIDC/JWT middleware**.
## Table of contents
- [Why](#why)
- [What's new in 1.0.0-preview.10](#whats-new-in-100-preview10)
- [What's new in 1.0.0-preview.9](#whats-new-in-100-preview9)
- [What's new in 1.0.0-preview.8](#whats-new-in-100-preview8)
- [What's new in 1.0.0-preview.7](#whats-new-in-100-preview7)
- [What's new in 1.0.0-preview.6](#whats-new-in-100-preview6)
- [Install](#install)
- [60-second tour](#60-second-tour)
- [Usage](#usage)
- [Sign and validate](#sign-and-validate)
- [Sign *and* encrypt](#sign-and-encrypt)
- [Key rotation and replay protection](#key-rotation-and-replay-protection)
- [ASP.NET Core integration](#aspnet-core-integration)
- [Samples](#samples)
- [Editor tooling](#editor-tooling)
- [Token format](#token-format)
- [Public API at a glance](#public-api-at-a-glance)
- [Compared to System.IdentityModel.Tokens.Jwt](#compared-to-systemidentitymodeltokensjwt)
- [Standards and interoperability status](#standards-and-interoperability-status)
- [Operational tradeoffs](#operational-tradeoffs)
- [Observability](#observability)
- [Security posture](#security-posture)
- [Compatibility](#compatibility)
- [Building from source](#building-from-source)
- [Contributing](#contributing)
- [License](#license)
## Why
A cryptographically relevant quantum computer would break the elliptic-curve
math behind today's JWT signatures (EdDSA, ECDSA, RSA) and key agreement. This
library splits the response: **post-quantum signatures**, and **hybrid
confidentiality** for the optional encryption path.
- **Signatures — ML-DSA-65 (post-quantum, not hybrid).** NIST-standardized
lattice signature, FIPS 204, security category 3. Signing is ML-DSA-65 only —
there is no classical co-signature, so this is *not* a composite
classical + PQ signature.
- **Key agreement — X-Wing (hybrid).** The IETF hybrid KEM combining the
well-studied **X25519** with **ML-KEM-768** (FIPS 203), bound together by a
SHA3-256 combiner. An attacker must break *both* to recover the key.
For the encrypted form, if either half of the key agreement stands, your token's
confidentiality stands. That hedge is the whole point of the hybrid KEM.
### What this package is — and is not
**It is** a production-oriented preview for *controlled* systems: ML-DSA-65
signed tokens, optional hybrid X-Wing-style confidentiality, strict algorithm
handling, fail-closed validation, replay-protection support, and key-rotation
patterns — with the security posture documented honestly.
**It is not** independently audited, and **not** a drop-in replacement for
OAuth/OIDC/JWT middleware or a generic JWT/JWE library.
| Intended use cases | Non-use cases |
|---|---|
| Controlled issuer/verifier services (same team owns both ends) | Public OAuth/OpenID Connect provider replacement |
| Internal/service-to-service APIs | Drop-in replacement for `Microsoft.AspNetCore.Authentication.JwtBearer` |
| Post-quantum migration experiments | Unaudited high-risk production deployments |
| Research prototypes & educational security engineering | Consumer-facing auth without careful, independent review |
| Systems behind an interop-translating gateway | Anywhere generic JWT/JWE interoperability is required |
## What's new in 1.0.0-preview.10
Two targeted fixes on top of preview.9's concurrency hardening — one interop
bug in the ASP.NET Core bearer handler, and one regression in the
`VerifierDemo` sample that shipped in preview.9.
- **`PqJwtBearerHandler` now accepts case-insensitive `Authorization`
scheme.** `bearer …` and `BEARER …` work alongside `Bearer …`. The
previous case-sensitive prefix check violated RFC 9110 §11.1
("auth-scheme tokens are case-insensitive") and rejected
standards-compliant requests with non-canonical casing. New theory test
covers `bearer` / `BEARER` / `BeArEr` against the live handler.
- **`samples/VerifierDemo` regression fixed.** The preview.9
`HttpPqJwtKeyRing` `IHostedService` refactor changed `Resolve` to a pure
in-memory lookup, but the `VerifierDemo` sample still constructed the
ring as a local variable and wired `Resolve(kid)` into the bearer options
without registering the hosted service. The cache stayed empty and
`/verify` failed closed with `UnknownKeyId` on every request. The sample
now uses the canonical pattern: `AddSingleton(...)` +
`AddHostedService(...)`, then `IPqJwtKeyRing` resolved from DI inside the
bearer options `Configure` callback. Also adds `SocketsHttpHandler` with
`PooledConnectionLifetime = 2 minutes` so DNS TTLs are honoured in
container environments.
- **`samples/PqJwtPlayground` now caches its validator** — was building a
new `PqJwtValidator` on every "Validate" click, exactly the pattern the
`PQJWT002` analyzer flags as wasteful. The validator is now a field
rebuilt only when `RegenerateKeys()` swaps the underlying keys.
- **`PqJwtBearerHandler` dead validator cache simplified** — the previous
per-instance reference-equality short-circuit never fired because ASP.NET
Core registers auth handlers as transient (one fresh handler instance per
request). Removed the dead fields; the property is now a one-liner.
## What's new in 1.0.0-preview.9
A concurrency / lifecycle hardening pass on `InMemoryReplayCache` and
`HttpPqJwtKeyRing`. **One behaviour change in `HttpPqJwtKeyRing`** — `Resolve`
is now a pure in-memory lookup and the type implements `IHostedService`;
consumers must register it as a hosted service to keep the cache refreshed.
Calling `Resolve` no longer drives a synchronous HTTP fetch.
- **Replay-cache `Prune` race fixed** — `InMemoryReplayCache.Prune` previously
removed entries by key alone; a concurrent `TryRegister` that replaced a
just-expired entry with a fresh `expiresAt` between the enumerator's read
and the `TryRemove` would have its **live entry deleted**, and the next
presentation of the same `jti` could slip past replay defense. Now uses the
atomic compare-and-remove facade — the entry is only pruned if both the key
AND the value still match.
- **`HttpPqJwtKeyRing` is now a background-refresh `IHostedService`.** The
sync-over-async fetch on the auth request path is gone: `Resolve` returns
whatever is in the cache and never blocks on HTTP. The cache is refreshed
by a `PeriodicTimer`-driven background loop started by `StartAsync`. **Action
required for consumers:**
services.AddSingleton(sp => new HttpPqJwtKeyRing(...));
services.AddHostedService(sp => sp.GetRequiredService());
Consumers that only registered the singleton will see an empty cache and
`UnknownKeyId` for every kid until they add the hosted-service registration
or call `PreloadAsync` themselves. This is intentional — the old "Resolve
auto-fetches" semantics were the source of the thread-pool-starvation risk.
- **Native handle race + leak in `HttpPqJwtKeyRing` fixed.** Old `MLDsa`
handles were disposed inline during cache replacement (racing concurrent
`Resolve` callers mid-`VerifyData`) and rotated-out keys were left
un-disposed (relying on GC finalizer). Both paths now route through a 30-
second deferred-disposal quarantine queue — deferred-but-deterministic
disposal that honours the "dispose anything holding key handles" rule
without re-introducing the inline-dispose race.
- **`HttpPqJwtKeyRing` skips the ML-DSA reimport when a kid's published
base64 hasn't changed** — steady-state native-handle churn drops from
one-per-poll to zero when the JWKS is unchanged.
## What's new in 1.0.0-preview.8
A focused **security** fix continuing the preview.6/preview.7 fail-closed
hardening pass, plus a substantial assurance and transparency layer. No API
change, no wire-format change; tokens minted by the builder are unaffected.
- **Security: `exp + skew` / `nbf - skew` arithmetic is now overflow-safe.**
A token whose `exp` claim sat exactly at `UnixSecondsMax`
(`DateTimeOffset.MaxValue.ToUnixTimeSeconds()`) parsed successfully but
escaped `Validate` as a raw `ArgumentOutOfRangeException` from
`exp + skew` — a fail-closed totality violation. Symmetric underflow
existed for `nbf - skew` at `UnixSecondsMin`. Fix: clamp the comparison
(`exp` past `DateTimeOffset.MaxValue - skew` is effectively infinite, the
check passes by definition; `nbf` below `DateTimeOffset.MinValue + skew` is
symmetric). Surfaced by **the very first end-to-end Stryker.NET mutation
run** — writing the boundary test for the surviving equality mutant at the
parser bound surfaced the overflow one stack frame deeper.
Regression-locked by 11 new `BoundaryTests.cs` cases.
- **Mutation testing (Stryker.NET 4.x) scoped to the parser/validator path.**
Latest: **66.31% raw**, **~87% on behaviorally-meaningful mutations** after
filtering exception-message `String`-mutator survivors (the
`PqJwtFailureReason` taxonomy intentionally doesn't assert on text — tests
pin the enum value). `stryker-config.json` + `docs/TESTING.md` methodology.
- **Named red-team scenario suite** (`RedTeamScenarios.cs`) — structural
attacks named so reviewers can find them: header `jku`/`jwk`/`x5u`/`x5c`
ignored for key selection, tampered inner signature in encrypted envelope,
header-swap AEAD AAD binding, `kid` collision.
- **New reviewer-facing docs:**
[`docs/TESTING.md`](docs/TESTING.md) (per-layer test pyramid + commands),
[`docs/SUPPLY-CHAIN.md`](docs/SUPPLY-CHAIN.md) (how to verify a release —
provenance, SBOM, `SHA256SUMS`, SourceLink, deterministic build), and
[`docs/ROADMAP-TO-1.0.md`](docs/ROADMAP-TO-1.0.md) (explicit answer to
"when does the `preview` suffix come off?").
## What's new in 1.0.0-preview.7
A focused **security** fix continuing the preview.6 fail-closed hardening pass.
No API change, no wire-format change; the public surface is identical.
- **Security: a JOSE header with duplicate JSON property names is now rejected
as `PqJwtValidationException(MalformedJson)`.** `System.Text.Json`'s
`JsonNode.Parse` defers building the underlying name→node dictionary until
the first property access, so a header like
`{"alg":"ML-DSA-65","typ":"JWT","typ":"JWT"}` slipped past the parser's
`JsonException` catch and threw `ArgumentException` at the first indexer
read — escaping `Validate` as an unsealed exception type. RFC 8259 §4
declares duplicate JSON keys non-interoperable and RFC 7515 §4 requires
unique JOSE header parameter names, so rejecting these is conformant.
Pinned by a regression test (`Header_with_duplicate_keys_reports_MalformedJson`).
Builder-minted tokens are unaffected.
- **Tier 2 coverage-guided fuzz target is now operational.** The
`fuzz/PostQuantum.Jwt.Fuzz/` SharpFuzz + libFuzzer target was scaffolded in
preview.6 but not yet run; in preview.7 it ran for hours and surfaced the
duplicate-key bug above on its first session. A small scaffold fix
(defer the instrumented validator construction into the `Fuzzer.LibFuzzer.Run`
callback, narrow instrumentation to the parser path) ships with this release.
The random-input `PqJwtFuzzTests` from preview.6 are unchanged.
## What's new in 1.0.0-preview.6
A **security and assurance** update. Two encrypted-path hardening fixes — both
surfaced by a new adversarial fuzz suite — plus a deeper, executable assurance
layer. The signed-token path is unchanged on the wire.
- **Security: AES-GCM tag length is pinned to the profile.** The validator no
longer derives the authentication-tag length from the token; it requires the
full 16-byte (128-bit) tag and a 12-byte nonce. Previously an attacker could
truncate the tag (e.g. to 120 bits) and have it still authenticate against its
prefix — an authentication-strength downgrade and token malleability. Tokens
from this library's builder are unaffected.
- **Security: strict canonical base64url decoding** (RFC 7515 §2). Embedded
whitespace and non-zero "slack" bits — which would let a *different* string
decode to identical bytes and still verify/decrypt — are now rejected. Token
strings are non-malleable.
- **Adversarial fuzzing + executable security invariants.** New `PqJwtFuzzTests`
(fail-closed totality + no spurious acceptance) and `SecurityInvariantsTests`
(signature-before-claims ordering, header-never-selects-algorithm, no profile
downgrade) lock the orchestration guarantees. A **TLA+ model** of the validator
(`docs/formal/`) is model-checked with TLC.
- **BenchmarkDotNet suite** (`benchmarks/`): throughput, serverless cold-start,
and measured token sizes vs. a classical ES256 baseline.
- **Docs:** corrected encrypted-token size (~7.8 KB, was understated), a new
`SECURITY.md` "Parser & protocol robustness" section, and a reconciled
validation-ordering contract across `SPEC.md`, the audit prompt, and the docs.
## What's new in 1.0.0-preview.5
A **documentation and hardening** update — the library binary is **identical** to
`preview.3` (no code change; supersedes the unpublished `preview.4` tag):
- **Reworded the API-stability language.** The public API and wire format are
held stable across the `1.0.0-preview.*` series; the `preview` suffix reflects
the pending independent audit, not API churn. (Also: a free-for-OSS
code-signing path via the SignPath Foundation is now noted in `KNOWN-GAPS.md`.)
- **Clarified JOSE/IANA standards status.** ML-DSA-65 (RFC 9964) and A256GCM
(RFC 7518) are registered JOSE identifiers; the X-Wing key-management profile
is not a standardized JOSE/JWE profile. New
[Standards and interoperability status](#standards-and-interoperability-status)
section and table.
- **Normative token profile** — [`docs/SPEC.md`](docs/SPEC.md) defines the v1
profile (headers, claims, fail-closed validation order, rejection rules).
- **Expanded security model** ([`SECURITY.md`](SECURITY.md)) and a
**production-readiness checklist** ([`samples/HARDENING-CHECKLIST.md`](samples/HARDENING-CHECKLIST.md)).
- **Precise hybrid language** throughout: hybrid confidentiality, ML-DSA-65
(post-quantum, not hybrid) signatures.
- **Aligned positioning** across all packages: *production-oriented preview for
controlled issuer/verifier systems; not independently audited; not a drop-in
OAuth/OIDC/JWT replacement.*
- **Supply-chain:** added a CodeQL workflow, Dependabot, and `CONTRIBUTING.md`.
## What's new in 1.0.0-preview.3
A **docs and packaging** refresh — the library binary is **identical** to
`preview.2` (no code change). It exists so the package pages point where they should:
- **Live playground — https://pqjwt.systemslibrarian.dev** — build, validate, and
*break* a post-quantum token in your browser, with a
[how-to guide](https://github.com/systemslibrarian/postquantum-jwt/blob/main/samples/PqJwtPlayground/USING.md).
- **Sample links are now absolute URLs**, so they resolve on the NuGet package
page, not only on GitHub.
- **Sample Dockerfiles fixed** to bring OpenSSL 3.5 (the Azure Linux base ships
only 3.3.5 — too old for ML-DSA/ML-KEM, so the containers had started but
failed closed on every token op).
## What's new in 1.0.0-preview.2
An **additive** release — the crypto core, public algorithm surface, and
fail-closed behavior are **unchanged** (no new suite, no algorithm agility). It
adds observability and a typed failure taxonomy, plus the runnable samples,
templates, and compile-time analyzers that grow the ecosystem.
- **Validation metrics.** The validator emits a `pqjwt.validations` counter on a
`System.Diagnostics.Metrics` meter named `PostQuantum.Jwt`, tagged
`outcome=success|failure` and — on failure — a coarse, bounded, **non-sensitive**
`reason`. Opt in with OpenTelemetry or any meter listener; no new dependency,
no token/claim/key material ever emitted. See [Observability](#observability).
- **Typed failure reasons.** A new public `PqJwtFailureReason` enum and
`PqJwtValidationException.Reason` let callers (and the metric) categorize a
rejection from a typed value instead of parsing the message. The fail-closed
control flow is byte-for-byte unchanged.
- **Runnable samples** (`samples/`) and a **`dotnet new` template package**
(`PostQuantum.Jwt.Templates` — `pqjwt-webapi`, `pqjwt-console`). See
[Samples](#samples).
- **Expanded hardening guidance** — `samples/SECURE-USAGE.md` and
`samples/HARDENING-CHECKLIST.md` now map common JWT attacks to the library's
defenses and the metric `reason` that surfaces each.
- **Compile-time analyzers** — a new opt-in `PostQuantum.Jwt.Analyzers` package
enforces the architecture in your IDE/build: **PQJWT001** forbids inspecting a
token's header fields and **PQJWT002** flags per-call validator construction.
Plus an AI semantic-audit prompt. See
[`docs/SECURITY-AUDIT-TOOLS.md`](docs/SECURITY-AUDIT-TOOLS.md).
## What's new in 1.0.0-preview.1
A **maturity-tier bump** from `0.3.0-preview.1`. The crypto core and public
algorithm surface are unchanged — ML-DSA-65 + X-Wing + AES-256-GCM with
sign-then-encrypt and RFC 7516 AAD binding remain the only path, no algorithm
agility, no new suites. What 1.0 brings is a sharper safety posture, a tighter
exception contract, and the test seam needed to KAT the parts of X-Wing that
*can* be made deterministic. The `preview.N` suffix carries the maturity
caveat, not the leading `1.0`: the construction has **not** been independently
audited and the non-standardized X-Wing key-management profile means tokens
still do not interop with generic JWT tooling. Changes are stacked newest-first.
**New in v1.0.0-preview.1**
- **Opt-in fail-closed replay protection.**
`PqJwtValidationParameters.RequireReplayProtection`, when `true`, makes the
`PqJwtValidator` constructor throw if no `ReplayCache` is wired. Default is
`false` (no behavior change for existing callers), but operators who turn it
on catch a missing cache at startup rather than as a silent missing defense
at runtime.
- **Parser-level failures now surface as `PqJwtValidationException`.**
`Validate` wraps `FormatException` / `JsonException` /
`CryptographicException` (raised by Base64Url decode, JSON header/payload
parse, and crypto-material import) in `PqJwtValidationException` with the
original kept as `InnerException`. Consumers that catch only
`PqJwtException` no longer leak a 500 on adversarial input.
- **`IXWingDeterministicCoins` internal test seam** (visible via
`InternalsVisibleTo` only — production code has no parameter for it). Lets
the suite KAT the X-Wing combiner direction and the X25519 ephemeral half
against the official IETF vectors. The BCL `MLKem.Encapsulate` step is
still not KAT-able and is now covered by an N=64 statistical sanity test
asserting all 64 ciphertexts *and* all 64 shared secrets are distinct while
every round-trip recovers the secret correctly.
- **Production X25519 ephemeral entropy now flows through
`RandomNumberGenerator`** instead of BouncyCastle's `SecureRandom`. Both
are CSPRNGs and the wire output is bit-identical, but the production
entropy source is now the .NET BCL and the ephemeral key is zeroed in a
`finally` (the BC path did not).
- **Pinned end-to-end roundtrip corpus**
(`tests/PostQuantum.Jwt.Tests/TestVectors/jwt-roundtrip-vectors.json`):
signed-with-`kid`/`jti`/`aud`/custom-claim, signed-minimal, and
signed-then-encrypted-minimal. Each vector pins the deterministic parts
(compact JSON of protected header + payload) and asserts successful
end-to-end validation; non-deterministic parts (ML-DSA signature, X-Wing
ciphertext, AES-GCM nonce/ciphertext/tag) are not pinned and the file
documents why.
- **"Read this first" interoperability disclosure** at the very top of the
README, naming the `ML-DSA-65` / `X-Wing` / `A256GCM` identifiers, the
non-standardized X-Wing key-management profile, and the standard JWT
libraries that will reject these tokens. Reinforces — does not replace — the
existing mid-page `System.IdentityModel.Tokens.Jwt` comparison.
- **`PostQuantum.Jwt.AspNetCore` is marked superseded by
[`PostQuantum.AspNetCore`](https://github.com/systemslibrarian/postquantum-aspnetcore)**
(cleaner naming, event-hook surface, hosted-service warmup, SignalR
support, 40-test integration suite). Tokens minted by either validate in
the other. The legacy companion receives critical fixes only — no new
features — through 1.0.
- **Test count: 79/79 passing** on the full PQ lane (was 68 at v0.3).
**Previously, in v0.3.0-preview.1**
- **New companion package `PostQuantum.Jwt.AspNetCore`.**
- `services.AddAuthentication().AddPqJwtBearer(...)` — mirrors the shape
of `AddJwtBearer` from `Microsoft.AspNetCore.Authentication.JwtBearer`,
so post-quantum tokens slot into the standard auth pipeline.
- `PqJwtBearerHandler` — fail-closed `AuthenticationHandler` that
delegates to `PqJwtValidator`. Bypasses `Microsoft.IdentityModel`, which
doesn't know `ML-DSA-65`.
- `IPqJwtKeyRing` + `HttpPqJwtKeyRing` — JWKS-equivalent: fetch a key
directory from a trusted HTTPS endpoint with configurable refresh,
in-memory cache, AOT-safe (source-gen JSON), single-suite enforcement.
- **AOT/trim-safe API path.** New `WithClaim(name, value, JsonTypeInfo)`
overload alongside the existing reflection-based `WithClaim(name, object?)`.
The reflection overload carries `[RequiresUnreferencedCode]` and
`[RequiresDynamicCode]` so AOT publishers see one targeted warning;
primitive setters (`WithIssuer`, `WithSubject`, etc.) bypass reflection
internally and stay trim-safe. Both packages declare `IsAotCompatible=true`.
- **CycloneDX SBOM packed inside the `.nupkg`.** `bom.json` lives at the
root of the package so consumers can inspect the dependency graph
directly from nuget.org.
- **Property-based tests** via FsCheck.Xunit (Base64Url involutive
round-trip, signature-tamper invariance, etc.). Total: **68 tests**,
zero skips on PQ-capable hosts.
- **Linux PQ-required CI lane.** New `linux-pq-required` job installs
OpenSSL 3.5+ via `conda-forge` and fails the run on any skipped test —
joining the Windows lane in proving the ML-KEM / ML-DSA / X-Wing paths
actually executed on every push, on both platforms.
- **Release workflow author-signing hook.** Optional
`NUGET_SIGNING_CERT` + `NUGET_SIGNING_CERT_PASSWORD` secrets on the
`nuget-publish` GitHub Environment trigger `dotnet nuget sign` with a
DigiCert timestamp before push. Absent secrets log a notice and skip
signing — the package still ships under nuget.org's repository signature.
- **API baseline infrastructure.** `PackageValidationBaselineVersion=0.2.0-preview.3`
is wired in conditionally — pass `-p:EnableBaselineValidation=true`
once the baseline is published to nuget.org and future versions are
checked for accidental API breaks against it.
**New in v0.2.0-preview.3** (the previous release line, kept for reference)
- **Fail-fast misconfiguration.** `PqJwtValidator`'s constructor now throws
`ArgumentException` if neither `SignatureVerificationKey` nor
`SignatureKeyResolver` is configured — a security validator without a way
to obtain a verification key is misconfigured by definition, and that
should surface before the first token arrives, not after.
- **Eager X-Wing public-key validation.** `XWingPublicKey.Import` now parses
the embedded ML-KEM-768 encapsulation key at ingestion. A length-correct
but structurally invalid key fails with `PqJwtException` on import rather
than later inside `XWing.Encapsulate`. Consumers handling untrusted key
input see a single exception boundary.
- **SBOM (CycloneDX).** Every release now emits a `bom.json` covering the
project's dependency graph, includes it in `SHA256SUMS.txt`, and issues a
separate GitHub build-provenance attestation for it. The SBOM travels
with the GitHub release artifacts rather than packed inside the `.nupkg`.
**The 0.1 → 0.2 delta, cumulative through `preview.2`**
- **Test coverage more than doubled** (27 → 57 tests, zero skips on PQ-capable
hosts). New fail-closed locks for `nbf` in the future, clock-skew tolerance
bounds, multi-audience tokens, `alg` confusion (`"none"` substitution),
missing `alg`, malformed JSON header, array-shaped payload, wrong
content-encryption (`A128GCM` instead of `A256GCM`), missing/wrong `cty` on
encrypted tokens, tampered ciphertext, decryption with the wrong private
key, replay protection across encrypted tokens, custom-claim round-trips,
claim removal via `WithClaim(name, null)`, `XWingPrivateKey` dispose
semantics, length-correct-but-malformed X-Wing public keys, negative
`ClockSkew` configuration, validator-without-key configuration, and
concurrent registration in `InMemoryReplayCache`.
- **Validator hardening.** Encrypted tokens now require `cty: JWT` on the
outer header. The validator constructor refuses negative `ClockSkew`
values *and* validators with no verification key. The decrypted plaintext
buffer is zeroed alongside the shared secret. Malformed X-Wing public
keys surface as `PqJwtException` rather than leaking `CryptographicException`
from the BCL.
- **Release transparency.** `scripts/check-version-sync.sh` asserts the
version is identical across `.csproj`, README, and CHANGELOG, and runs in
CI on every push. The release workflow writes a `SHA256SUMS.txt` covering
the `.nupkg`, `.snupkg`, and `bom.json`, and emits GitHub build-provenance
attestations for both the `.nupkg` and the SBOM — any consumer can run
`gh attestation verify --repo systemslibrarian/postquantum-jwt` to
confirm an artifact came from this repo's release workflow. Release steps
and trust signals are documented in [`docs/RELEASE.md`](docs/RELEASE.md).
- **Windows CI is now the PQ-required lane.** It fails the run if any test
reports skipped, so the ML-KEM / ML-DSA / X-Wing paths are *proven* to run
in CI on every push, rather than relying on local verification alone.
Linux remains the portability lane.
- **Documentation overhaul.** Rewritten README with a 60-second tour, a direct
comparison vs. `System.IdentityModel.Tokens.Jwt`, and a clearer security
posture. [`SECURITY.md`](SECURITY.md) and [`KNOWN-GAPS.md`](KNOWN-GAPS.md)
refreshed to match the current state.
- **Build hygiene.** Build is **zero warnings** (was one CA1859 hint in 0.1).
`EnablePackageValidation` is on. `LICENSE` and `CHANGELOG.md` are packed
alongside the README so consumers see them in the package details on
nuget.org.
- **CI hardening.** Workflows now run on actions versions that support
Node.js 24, and the release pipeline is split into `pack` + `publish` with a
GitHub Environment gate (`nuget-publish`) so publishing requires explicit
manual approval.
- **Docs fix.** Corrected the X-Wing combiner formula in `SECURITY.md` — the
label is concatenated **last**, matching the code and
`draft-connolly-cfrg-xwing-kem`.
Full notes in [`CHANGELOG.md`](CHANGELOG.md).
## Install
dotnet add package PostQuantum.Jwt --version 1.0.0-preview.10
Or in a `.csproj`:
**Runtime requirement.** Native ML-KEM / ML-DSA primitives come from the OS
crypto stack. PostQuantum.Jwt fails closed with a clear error when they are
unavailable — a misconfigured host throws on the first token operation,
never silently downgrades.
| Host | Status |
|---|---|
| Recent Windows (CNG with PQC support) | Works |
| Recent macOS | Works |
| Linux + OpenSSL ≥ 3.5 | Works |
| Linux + OpenSSL 3.0–3.4 (every LTS today) | **Will not run** |
**Linux deployment is non-trivial today.** No current LTS distro ships
OpenSSL 3.5+ in its default packages — Ubuntu 22.04 / 24.04, RHEL 9,
Debian 12, Fedora 40, and Alpine 3.20 are all on 3.0.x–3.3.x. Workable
production paths:
- **Container with a newer OpenSSL** — see the Dockerfiles under
[`samples/ProductionDeploymentDemo/`](samples/ProductionDeploymentDemo/),
which install OpenSSL 3.5 during build.
- **Conda-forge overlay** — `LD_LIBRARY_PATH=/opt/conda/lib` over
conda-forge's OpenSSL 3.5+. This is what the project's own CI runs.
- **Self-built OpenSSL** — workable, but you now own the patch cadence
for a security-critical dependency.
This is the single most common deployment surprise. Plan a path before
you commit.
## 60-second tour
using System.Security.Cryptography;
using PostQuantum.Jwt;
using var signingKey = MLDsa.GenerateKey(MLDsaAlgorithm.MLDsa65);
using var verificationKey = MLDsa.ImportMLDsaPublicKey(
MLDsaAlgorithm.MLDsa65, signingKey.ExportMLDsaPublicKey());
string token = new PqJwtBuilder()
.WithSubject("user-123")
.WithLifetime(TimeSpan.FromMinutes(30))
.SignWith(signingKey)
.Build();
var result = new PqJwtValidator(new PqJwtValidationParameters
{
SignatureVerificationKey = verificationKey,
}).Validate(token);
Console.WriteLine(result.Subject); // user-123
That's it: sign, validate. Anything wrong with the token — bad signature,
tampering, expiry, claim mismatch — throws `PqJwtValidationException`. There is
no "best-effort" result.
## Usage
### Sign and validate
using System.Security.Cryptography;
using PostQuantum.Jwt;
using var signingKey = MLDsa.GenerateKey(MLDsaAlgorithm.MLDsa65);
string token = new PqJwtBuilder()
.WithIssuer("https://issuer.example")
.WithSubject("user-123")
.WithAudience("https://api.example")
.WithLifetime(TimeSpan.FromMinutes(30))
.WithClaim("role", "admin")
.SignWith(signingKey)
.Build();
using var verificationKey = MLDsa.ImportMLDsaPublicKey(
MLDsaAlgorithm.MLDsa65, signingKey.ExportMLDsaPublicKey());
var validator = new PqJwtValidator(new PqJwtValidationParameters
{
SignatureVerificationKey = verificationKey,
ValidIssuer = "https://issuer.example",
ValidAudience = "https://api.example",
});
PqJwtValidationResult result = validator.Validate(token);
Console.WriteLine(result.Subject); // user-123
Console.WriteLine(result.GetString("role")); // admin
### Sign *and* encrypt
When the payload is confidential, hand the builder a recipient's X-Wing public
key. The token is signed first, then encrypted ("sign-then-encrypt").
using PostQuantum.Jwt.Cryptography;
// Recipient generates a key pair and publishes the public half.
using var recipient = XWingPrivateKey.Generate();
byte[] recipientPublic = recipient.PublicKey.Export(); // share this
string token = new PqJwtBuilder()
.WithSubject("confidential-subject")
.WithLifetime(TimeSpan.FromMinutes(5))
.SignWith(signingKey)
.EncryptFor(XWingPublicKey.Import(recipientPublic))
.Build();
var validator = new PqJwtValidator(new PqJwtValidationParameters
{
SignatureVerificationKey = verificationKey,
DecryptionKey = recipient, // required for encrypted tokens
});
PqJwtValidationResult result = validator.Validate(token);
Console.WriteLine(result.WasEncrypted); // True
### Key rotation and replay protection
Tag a signature with a `kid` and resolve it at validation time, and reject
replayed tokens with a `jti` cache:
string token = new PqJwtBuilder()
.WithKeyId("signing-key-2026")
.WithJwtId(Guid.NewGuid().ToString("N"))
.WithLifetime(TimeSpan.FromMinutes(5))
.SignWith(signingKey)
.Build();
var validator = new PqJwtValidator(new PqJwtValidationParameters
{
// Pick a verification key from the token's kid (key rotation).
SignatureKeyResolver = kid => keyRing.TryGetValue(kid, out var k) ? k : null,
// Reject any jti seen before. InMemoryReplayCache is single-process;
// implement IPqJwtReplayCache over a shared store for multi-node setups.
ReplayCache = new InMemoryReplayCache(),
});
An unknown `kid`, a missing `jti`, or a replayed `jti` all fail closed.
### ASP.NET Core integration
The legacy companion's shape, for reference: install the companion
package and call `AddPqJwtBearer(...)` on the standard
`AuthenticationBuilder` — the same shape as `AddJwtBearer` from
`Microsoft.AspNetCore.Authentication.JwtBearer`, but routing through
`PqJwtValidator` instead of the IdentityModel handler that can't speak
`ML-DSA-65`.
dotnet add package PostQuantum.Jwt.AspNetCore --version 1.0.0-preview.10
using System.Security.Cryptography;
using PostQuantum.Jwt;
using PostQuantum.Jwt.AspNetCore;
var builder = WebApplication.CreateBuilder(args);
builder.Services
.AddAuthentication(PqJwtBearerDefaults.AuthenticationScheme)
.AddPqJwtBearer(options =>
{
var keyBytes = Convert.FromBase64String(
builder.Configuration["Auth:VerificationKey"]
?? throw new InvalidOperationException("Missing Auth:VerificationKey"));
options.ValidationParameters = new PqJwtValidationParameters
{
SignatureVerificationKey = MLDsa.ImportMLDsaPublicKey(
MLDsaAlgorithm.MLDsa65, keyBytes),
ValidIssuer = builder.Configuration["Auth:Issuer"],
ValidAudience = builder.Configuration["Auth:Audience"],
// Single-process replay defense. Swap to a Redis-backed
// IPqJwtReplayCache for a horizontally scaled deployment.
ReplayCache = new InMemoryReplayCache(),
};
});
builder.Services.AddAuthorization();
var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
app.MapGet("/me", (HttpContext ctx) => new
{
sub = ctx.User.FindFirst("sub")?.Value,
role = ctx.User.FindFirst("role")?.Value,
}).RequireAuthorization();
app.Run();
That's the whole integration. The handler is fail-closed by construction
(tampered / expired / wrong-issuer tokens produce
`AuthenticateResult.Fail`), `RequireAuthorization()` returns 401 to
unauthenticated callers, and standard `[Authorize(Roles = "...")]`
attributes work against the `"role"` claim by default.
**Key rotation across services.** Use `HttpPqJwtKeyRing` to fetch
verification keys from a trusted HTTPS endpoint (the post-quantum
analogue of JWKS):
builder.Services.AddHttpClient();
builder.Services.AddSingleton(sp =>
{
var http = sp.GetRequiredService().CreateClient(nameof(HttpPqJwtKeyRing));
return new HttpPqJwtKeyRing(http, new Uri(builder.Configuration["Auth:KeysEndpoint"]!));
});
builder.Services
.AddAuthentication(PqJwtBearerDefaults.AuthenticationScheme)
.AddPqJwtBearer(options =>
{
options.ValidationParameters = new PqJwtValidationParameters
{
// Resolved per token from the token's `kid` header.
SignatureKeyResolver = kid =>
builder.Services.BuildServiceProvider()
.GetRequiredService()
.Resolve(kid),
ValidIssuer = builder.Configuration["Auth:Issuer"],
ValidAudience = builder.Configuration["Auth:Audience"],
};
});
The expected key-directory document is JSON:
`{ "keys": [ { "kid": "...", "alg": "ML-DSA-65", "key": "" }, ... ] }`.
Entries with any other `alg` are ignored — the single-suite policy holds
across services.
**Don't `AddJwtBearer` alongside this.** The standard handler will try to
parse the token's `alg` and fail. Either use `AddPqJwtBearer` as your
only bearer auth, or restrict each scheme to specific routes with
`[Authorize(AuthenticationSchemes = ...)]`.
## Samples
Ten runnable samples live in
[`samples/`](https://github.com/systemslibrarian/postquantum-jwt/tree/main/samples)
— a menu-driven console tour, a real ASP.NET Core service, an interactive Blazor
playground, refresh-token rotation, a distributed replay cache, and more. Each
references the library by project reference, so they always build against the
current source (CI builds the whole sample solution on every push).
| Sample | Shows |
| --- | --- |
| [`ConsoleDemo`](https://github.com/systemslibrarian/postquantum-jwt/tree/main/samples/ConsoleDemo) | Every feature, fast — a Spectre.Console menu |
| [`WebApiDemo`](https://github.com/systemslibrarian/postquantum-jwt/tree/main/samples/WebApiDemo) | Real ASP.NET Core integration via `AddPqJwtBearer` |
| [`VerifierDemo`](https://github.com/systemslibrarian/postquantum-jwt/tree/main/samples/VerifierDemo) | Cross-service key rotation against an issuer's key directory |
| [`PqJwtPlayground`](https://github.com/systemslibrarian/postquantum-jwt/tree/main/samples/PqJwtPlayground) | Interactive Blazor UI — the [live demo](https://pqjwt.systemslibrarian.dev) above |
| [`RefreshTokenDemo`](https://github.com/systemslibrarian/postquantum-jwt/tree/main/samples/RefreshTokenDemo) | Access/refresh split, rotation, reuse detection |
| [`DistributedReplayCache`](https://github.com/systemslibrarian/postquantum-jwt/tree/main/samples/DistributedReplayCache) | `IPqJwtReplayCache` over Redis / `IDistributedCache` |
| [`ProductionDeploymentDemo`](https://github.com/systemslibrarian/postquantum-jwt/tree/main/samples/ProductionDeploymentDemo) | Full issuer/verifier deployment runbook — scripted encrypted-token, tamper, replay, audience, expiry, and key-rotation checks. [**Live at https://demo.pqjwt.systemslibrarian.dev**](https://demo.pqjwt.systemslibrarian.dev/) — 8-step interactive browser tour with typed `PqJwtFailureReason` on the wire. |
| [`SpecByExample`](https://github.com/systemslibrarian/postquantum-jwt/tree/main/samples/SpecByExample) | xUnit tests whose names are the lessons |
| [`TestingSupport`](https://github.com/systemslibrarian/postquantum-jwt/tree/main/samples/TestingSupport) | A no-crypto test auth handler for your own `[Authorize]` endpoints |
# clone, then build every sample
dotnet build samples/PostQuantum.Jwt.Samples.slnx
# or run one
dotnet run --project samples/ConsoleDemo
See [`samples/README.md`](https://github.com/systemslibrarian/postquantum-jwt/blob/main/samples/README.md) for the full guide,
[`samples/SECURE-USAGE.md`](https://github.com/systemslibrarian/postquantum-jwt/blob/main/samples/SECURE-USAGE.md) for the decisions *around* the
token, and [`samples/HARDENING-CHECKLIST.md`](https://github.com/systemslibrarian/postquantum-jwt/blob/main/samples/HARDENING-CHECKLIST.md) for
how each attack is blocked. To host your own playground, see
[`samples/PqJwtPlayground/DEPLOY.md`](https://github.com/systemslibrarian/postquantum-jwt/blob/main/samples/PqJwtPlayground/DEPLOY.md).
## Editor tooling
A companion **[VS Code extension](https://marketplace.visualstudio.com/items?itemName=systemslibrarian.postquantum-jwt)**
(`code --install-extension systemslibrarian.postquantum-jwt`) adds C# snippets, a structure-only
token decoder, and quick links for working with PostQuantum.Jwt. It does **no
cryptography** — it helps you write and read the code, and points at the
[live playground](https://pqjwt.systemslibrarian.dev) for anything that actually
signs or validates. Source lives in [`tools/vscode`](tools/vscode).
## Token format
PostQuantum.Jwt uses JOSE-style compact serialization:
| Form | Segments | Header `alg` / `enc` |
|-----------|----------|-----------------------------------|
| Signed | 3 | `ML-DSA-65` |
| Encrypted | 5 | `X-Wing` / `A256GCM` (nested JWT) |
`ML-DSA-65` and `A256GCM` are registered JOSE identifiers; the `X-Wing`
key-management profile that ties them together here is not a standardized
JOSE/JWE profile, so these tokens are not interoperable with generic JWT
tooling — see [Standards and interoperability status](#standards-and-interoperability-status).
The normative token profile (headers, claims, validation order, rejection rules)
is in [`docs/SPEC.md`](docs/SPEC.md) — including a **token-format diagram** and a
**fail-closed validation-flow diagram** that mirrors the model-checked TLA+ spec;
full wire-format and combiner details are in [`docs/design.md`](docs/design.md). For measured token sizes, verification cost,
replay-protection posture, and an ASP.NET Core migration path, see
[**Post-quantum JWTs in .NET 10: cost and migration**](docs/PQ-JWT-COST-AND-MIGRATION.md).
## Public API at a glance
| Type | Purpose |
|---------------------------------|--------------------------------------------------------------------------|
| `PqJwtBuilder` | Fluent builder for signed (3-part) or signed-then-encrypted (5-part) tokens. |
| `PqJwtValidator` | Fail-closed validator. Thread-safe and reusable. |
| `PqJwtValidationParameters` | Validation configuration: keys, issuer/audience, lifetime, replay cache. |
| `PqJwtValidationResult` | The validated claims; only returned when every check passed. |
| `PqJwtAlgorithms` | Canonical `alg`/`enc` identifiers (e.g. `ML-DSA-65`, `X-Wing`, `A256GCM`). |
| `PqJwtException` | Misconfiguration / usage error. |
| `PqJwtValidationException` | Token failed validation (subclass of `PqJwtException`). |
| `IPqJwtReplayCache` | Optional `jti` replay-detection hook. |
| `InMemoryReplayCache` | Default single-process replay cache (use a distributed store in clusters). |
| `XWingPrivateKey` / `…PublicKey` | X-Wing hybrid KEM keys; `Generate()`, `Import()`, `Export()`. |
## Compared to `System.IdentityModel.Tokens.Jwt`
`System.IdentityModel.Tokens.Jwt` (and the wider `Microsoft.IdentityModel.*`
family) is the right choice for the vast majority of JWT work today: it speaks
the IANA JOSE algorithms, interops with the entire OAuth / OpenID Connect
ecosystem, and has been hardened over a decade of production use. **Use it
unless you have a specific reason not to.**
PostQuantum.Jwt is a focused, deliberately *non-interoperable* tool for one
problem: JOSE-style post-quantum tokens (ML-DSA-65 signatures, optional hybrid
confidentiality) for controlled systems. The trade-offs:
| Concern | `System.IdentityModel.Tokens.Jwt` | `PostQuantum.Jwt` |
|---|---|---|
| **Algorithms** | RS256/384/512, PS256/384/512, ES256/384/512, EdDSA, HS256/384/512, etc. | **One suite only:** ML-DSA-65 for signatures, X-Wing + AES-256-GCM for encryption. |
| **Quantum resistance** | None of the standard algorithms are quantum-resistant. | Post-quantum signatures (ML-DSA-65); hybrid confidentiality (X25519 *and* ML-KEM-768 must both fall). |
| **Algorithm agility** | Yes (and historically the source of `alg: none`, RS/HS confusion, and downgrade attacks). | **No, by design.** The validator does not trust the token's `alg` to choose a path; it accepts exactly one. See [`docs/adr/0001-algorithm-agility.md`](docs/adr/0001-algorithm-agility.md). |
| **Standards interop** | Fully IANA-registered identifiers; tokens validate in every JWT library. | `ML-DSA-65` and `A256GCM` are registered JOSE identifiers, but the `X-Wing` key-management profile that combines them here is **not** a standardized JOSE/JWE profile. Tokens **will not** validate in generic JWT tooling. |
| **`alg: none`** | Historically supported (and disastrous); now disabled by default. | **Impossible.** No unsigned path exists in the code. |
| **Default `exp` enforcement** | Configurable; default depends on the consumer (`TokenValidationParameters`). | Required by default. A token without an `exp` claim is rejected. |
| **Encryption** | JWE with many supported `alg`/`enc` combos. | Sign-then-encrypt only; `X-Wing` (X25519 + ML-KEM-768) → AES-256-GCM. One recipient per token. |
| **Replay defense** | Not built-in. | Built-in `IPqJwtReplayCache` + `InMemoryReplayCache`, opt-in via configuration. |
| **OAuth / OIDC integration** | First-class (`Microsoft.AspNetCore.Authentication.JwtBearer`, JWKS, etc.). | None. You wire the validator into your pipeline yourself. |
| **External audit** | Yes — widely deployed and reviewed. | **No.** Preview, not audited. |
| **Dependencies** | A family of `Microsoft.IdentityModel.*` packages. | Native .NET BCL + **one** package (`BouncyCastle.Cryptography`) for X25519 + SHA3-256. |
| **Target framework** | Multi-target (netstandard2.0 through net10). | `net10.0` only. |
**Use `System.IdentityModel.Tokens.Jwt` if** you need OAuth/OIDC interop, JWKS,
multi-algorithm agility, or any standards-conformant JWT.
**Use `PostQuantum.Jwt` if** you specifically want post-quantum signatures (and
optional hybrid confidentiality) *now*, you control both the issuer and the
verifier, and you accept that your tokens won't validate in another ecosystem
until a standards-track JOSE/JWE profile for hybrid post-quantum key management
exists and generic libraries adopt it.
## Standards and interoperability status
The primitives this library uses are mostly standardized; the *profile* that
ties them together is not. That distinction is the whole reason these tokens are
for controlled systems rather than generic interop.
| Component | Status |
|---|---|
| ML-DSA-65 signatures | Registered JOSE algorithm ([RFC 9964](https://www.rfc-editor.org/info/rfc9964/)); experimental use in this package |
| AES-256-GCM / A256GCM | Registered JOSE content-encryption algorithm ([RFC 7518](https://www.rfc-editor.org/info/rfc7518/)) |
| X-Wing / ML-KEM key management | Not currently a standardized JOSE/JWE profile |
| Generic JWT library interoperability | Not guaranteed |
| OAuth/OIDC production replacement | No |
| Controlled issuer/verifier systems | Intended use case |
| Independent cryptographic audit | Not yet completed |
**What this means in practice:**
- **Works best in closed systems** where the same team controls token issuing
and token validation. Generic JWT/JWE libraries may not validate or decrypt
these tokens, because the X-Wing key-management construction has no
standardized profile for them to implement.
- **Header algorithm selection stays fail-closed.** The validator never trusts
the token's `alg`/`enc` to choose a verification or decryption path — it
accepts exactly one configured suite. See
[`docs/adr/0001-algorithm-agility.md`](docs/adr/0001-algorithm-agility.md).
- **No unsafe algorithm fallback.** There is no `alg: none`, no unsigned path,
and no silent downgrade; every validation or decryption failure throws.
## Operational tradeoffs
Honest, decision-useful notes for the moment you're deciding whether to wire
this in.
**Token size.** A classical ES256 JWT with the same claims is **315 bytes**
(measured). A signed PostQuantum.Jwt token is **~4.6 KB** — about **15×** larger;
the sign-then-encrypt form is **~7.8 KB**, about **25×**. ML-DSA-65 signatures
are 3,309 bytes (vs. ~64 for ES256), and that's after base64url encoding. The
encrypted token is bigger than "signed + the X-Wing ciphertext" alone because
the *entire* signed token becomes the AES-GCM plaintext and is then base64url-
encoded a second time (a ~33% inflation of the 4.6 KB inner token), on top of
the ~1.5 KB X-Wing KEM ciphertext (1,120 bytes) plus a 12-byte nonce and 16-byte
GCM tag. Plan for **~4.6 KB signed, ~7.8 KB encrypted**. This matters if you put
tokens in cookies, query strings, or constrained headers — for most
`Authorization: Bearer` flows it's fine, for cookies it likely is not. Reproduce
these numbers with
`dotnet run -c Release --project benchmarks/PostQuantum.Jwt.Benchmarks -- --sizes`.
**Performance.** Sign/verify cost is dominated almost entirely by the native BCL
lattice operation (ML-DSA-65), and encryption by ML-KEM-768; the library's own
work — base64url, JSON, header assembly — is negligible beside a 3.3 KB
signature. Two practical consequences: per-token CPU is higher than HMAC/EdDSA
but fits comfortably in a `Bearer` validation path, and **first-call latency**
carries a one-time native-init + JIT cost that matters on serverless cold
starts. The [`benchmarks/PostQuantum.Jwt.Benchmarks`](benchmarks/PostQuantum.Jwt.Benchmarks)
project measures warm throughput, cold-start "time to first verified token", and
token size with BenchmarkDotNet — run it on your target hardware rather than
trusting a quoted figure.
**When to reach for encryption.** The `sign-then-encrypt` form is the right
choice only when the claims themselves are confidential (PII, account IDs you
don't want a leaked log holding). For the more common case — opaque session
references, role/scope strings — a signed-only token is correct: the signature
already prevents forgery, encryption just trades cost for secrecy you may not
need.
**Replay protection in a cluster.** `InMemoryReplayCache` works for a single
process and is fine for a development server or a single-instance worker. The
moment you scale horizontally, `jti`-based replay defense requires a shared
store — implement `IPqJwtReplayCache` over Redis, a database table, or
whichever cache the rest of your stack already uses. Until then a token
"replayed" on a different node is **not** detected.
**Key rotation.** `SignatureKeyResolver` selects a verification key from the
token's `kid` header. It does *not* fetch keys — there is no JWKS endpoint or
remote-discovery story. Your application is responsible for the key ring; this
library is just disciplined about asking for the right key when validating.
**"Production-oriented preview" — what that means operationally.** The leading
`1.0` signals the public API and wire format have stopped moving in
back-incompatible ways across preview revisions; the `preview.N` suffix
carries the maturity caveat (no independent audit, and a non-standardized
X-Wing key-management profile). A future `preview.N+1` may still adjust the
surface if a security review demands it. For an internal service you control
end-to-end this is
manageable. For a public API where third parties hold issued tokens, treat
the unaudited construction as the gating concern, not the wire format.
## Observability
`PqJwtValidator` emits a single counter so you can watch validation outcomes
without bolting on logging — and without ever logging anything sensitive.
- **Meter:** `PostQuantum.Jwt` (the name is stable API).
- **Counter:** `pqjwt.validations`, tagged `outcome` = `success` | `failure`, and
on failure a `reason` drawn from the typed
[`PqJwtFailureReason`](#public-api-at-a-glance) — a closed, bounded-cardinality
set (e.g. `signature_mismatch`, `expired`, `replay_detected`,
`algorithm_not_accepted`, `unknown_kid`, `audience_mismatch`). The `reason`
**never** contains the token, claim values, `jti`, issuer/audience values, or
key material — only the category.
It's emitted via `System.Diagnostics.Metrics`, so there's no telemetry
dependency in the package — opt in with OpenTelemetry or any meter listener:
builder.Services.AddOpenTelemetry().WithMetrics(m => m
.AddMeter("PostQuantum.Jwt")
.AddPrometheusExporter()); // or OTLP, console, etc.
A spike in `pqjwt.validations{outcome="failure",reason="signature_mismatch"}` is
a live forgery signal. Because post-quantum signature verification costs more
than classical, this is also your DoS canary — see
[`samples/HARDENING-CHECKLIST.md`](samples/HARDENING-CHECKLIST.md). The same
typed `PqJwtFailureReason` is available on `PqJwtValidationException.Reason` for
callers that want to branch on the failure category directly.
## Security posture
We aim to be honest about exactly what this library does and does not give you.
**What you get**
- **Hybrid confidentiality, post-quantum signatures.** Encryption stays secure
unless *both* X25519 and ML-KEM-768 fall; signatures are ML-DSA-65 only (not a
hybrid classical + post-quantum signature).
- **Native post-quantum primitives.** ML-KEM-768 and ML-DSA-65 are the .NET
BCL implementations, not a re-implementation.
- **Fail-closed validation.** Bad signature, tampered ciphertext, expired or
not-yet-valid token, wrong issuer/audience, missing `exp`, missing `alg`, or
an `alg` we don't expect — all throw. There is no `alg: none`, no unsigned
path, and no silent downgrade.
- **Strict, small-surface defaults.** Expiration is required, clock skew is a
modest 60 seconds, and only the exact post-quantum algorithms are accepted.
**What you must know**
- **One dependency — BouncyCastle — and why.** The .NET BCL does not ship
X25519, the classical half of X-Wing. Rather than hand-roll elliptic-curve
code, we use BouncyCastle's vetted X25519 (and its SHA3-256 for the X-Wing
combiner). ML-KEM-768 and ML-DSA-65 remain on the native BCL. This trade-off
is deliberate: we will not roll our own curve arithmetic.
- **Not audited.** No third party has reviewed this construction. X-Wing key
generation and the decapsulation/combiner path **are** validated against the
official IETF known-answer vectors; the encapsulation path is not (the native
ML-KEM API is randomized). See [`KNOWN-GAPS.md`](KNOWN-GAPS.md).
- **Non-standardized profile.** `ML-DSA-65` and `A256GCM` are registered JOSE
identifiers, but the `X-Wing` key-management profile that ties them together
here is not a standardized JOSE/JWE profile. These tokens are therefore
intentionally **not** interoperable with generic JWT tooling — see
[Standards and interoperability status](#standards-and-interoperability-status).
- **Preview.** The public API and wire format are held stable across the `1.0.0-preview.*` series; the `preview` suffix reflects the pending independent audit, not expected API churn. No breaking changes are planned before the final `1.0.0`, though a security review could still force one.
Full detail lives in [`SECURITY.md`](SECURITY.md),
[`KNOWN-GAPS.md`](KNOWN-GAPS.md), the test pyramid in
[`docs/TESTING.md`](docs/TESTING.md), the install-verification recipe in
[`docs/SUPPLY-CHAIN.md`](docs/SUPPLY-CHAIN.md), and — for "when is 1.0?" —
[`docs/ROADMAP-TO-1.0.md`](docs/ROADMAP-TO-1.0.md). To report a vulnerability,
see `SECURITY.md`.
## Compatibility
| Surface | Supported |
|---|---|
| Target framework | `net10.0` |
| Languages | C# 13 (any CLS-consuming language; the assembly is `[CLSCompliant(false)]` because the public surface exposes raw `byte[]` key material). |
| Operating system | Windows, Linux, macOS — anywhere .NET 10 + an OpenSSL build that exposes ML-KEM / ML-DSA runs. On Linux that's **OpenSSL 3.5 or later**. |
| AOT / trimming | Supported. Both `PostQuantum.Jwt` and `PostQuantum.Jwt.AspNetCore` declare `IsAotCompatible=true`. Use `WithClaim(name, value, JsonTypeInfo)` from a source-gen context for custom claims; the reflection-based `WithClaim(name, object?)` overload is annotated `[RequiresUnreferencedCode]` / `[RequiresDynamicCode]` so AOT publishers see one targeted warning. |
## Building from source
dotnet build
dotnet test
Tests that exercise the native post-quantum primitives **skip themselves** (with
a clear reason) on hosts that lack ML-KEM / ML-DSA support, and run fully where
OpenSSL 3.5+ is present.
If you're on a Linux box whose system OpenSSL predates 3.5, point the runtime
at a newer one:
LD_LIBRARY_PATH=/path/to/openssl-3.5/lib dotnet test
The full library suite is **119 tests, zero skips** on a host with native
ML-KEM and ML-DSA support (plus 11 analyzer tests). Both the Windows and Linux
CI lanes fail the run if any test skips.
## Contributing
Issues and pull requests are welcome. See [`CONTRIBUTING.md`](CONTRIBUTING.md)
for the full guide. Before opening a PR:
1. Run `dotnet build` and `dotnet test` — both must be green, with **zero
warnings** (the build treats compiler warnings as errors).
2. Keep the discipline in [`CLAUDE.md`](CLAUDE.md): honesty over polish,
fail-closed always, no rolled-your-own crypto, native BCL first.
3. Security-sensitive changes should land alongside a test that locks in the
fail-closed behavior.
4. Update [`docs/SPEC.md`](docs/SPEC.md) if you change the token profile.
**Cutting a release** is documented in [`docs/RELEASE.md`](docs/RELEASE.md).
It enumerates exactly what CI enforces, what humans review, and what
provenance signals each release carries — and is honest about what is still
missing (author code signing, SBOM).
**Reporting a vulnerability:** please **do not** open a public issue. Use
GitHub's *Report a vulnerability* button on the repository, or follow the
process in [`SECURITY.md`](SECURITY.md).
## License
[MIT](LICENSE).
*To God be the glory — 1 Corinthians 10:31.*
标签:JWT, 加密库, 后量子密码学