{"id":50486816,"url":"https://github.com/systemslibrarian/postquantum-jwt","last_synced_at":"2026-06-01T23:02:32.578Z","repository":{"id":361434312,"uuid":"1254426907","full_name":"systemslibrarian/postquantum-jwt","owner":"systemslibrarian","description":"PostQuantum.Jwt — Modern post-quantum hybrid JWT library for .NET 10 using X-Wing (X25519 + ML-KEM-768) and ML-DSA signatures.","archived":false,"fork":false,"pushed_at":"2026-05-30T17:02:26.000Z","size":112,"stargazers_count":0,"open_issues_count":0,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-05-30T17:17:03.749Z","etag":null,"topics":["cryptography","csharp","dotnet","hybrid-cryptography","jwt","ml-dsa","ml-kem","post-quantum","pqc","security","tokens","x-wing"],"latest_commit_sha":null,"homepage":"","language":"C#","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/systemslibrarian.png","metadata":{"files":{"readme":"README.md","changelog":"CHANGELOG.md","contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":"SECURITY.md","support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-05-30T14:52:09.000Z","updated_at":"2026-05-30T17:02:13.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/systemslibrarian/postquantum-jwt","commit_stats":null,"previous_names":["systemslibrarian/postquantum-jwt"],"tags_count":4,"template":false,"template_full_name":null,"purl":"pkg:github/systemslibrarian/postquantum-jwt","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/systemslibrarian%2Fpostquantum-jwt","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/systemslibrarian%2Fpostquantum-jwt/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/systemslibrarian%2Fpostquantum-jwt/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/systemslibrarian%2Fpostquantum-jwt/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/systemslibrarian","download_url":"https://codeload.github.com/systemslibrarian/postquantum-jwt/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/systemslibrarian%2Fpostquantum-jwt/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":33703067,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"online","status_checked_at":"2026-05-30T02:00:06.278Z","response_time":92,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["cryptography","csharp","dotnet","hybrid-cryptography","jwt","ml-dsa","ml-kem","post-quantum","pqc","security","tokens","x-wing"],"created_at":"2026-06-01T23:02:32.003Z","updated_at":"2026-06-01T23:02:32.572Z","avatar_url":"https://github.com/systemslibrarian.png","language":"C#","funding_links":[],"categories":[],"sub_categories":[],"readme":"# PostQuantum.Jwt\n\n[![NuGet](https://img.shields.io/nuget/vpre/PostQuantum.Jwt?label=nuget\u0026color=blue)](https://www.nuget.org/packages/PostQuantum.Jwt)\n[![Downloads](https://img.shields.io/nuget/dt/PostQuantum.Jwt?color=blue)](https://www.nuget.org/packages/PostQuantum.Jwt)\n[![CI](https://github.com/systemslibrarian/postquantum-jwt/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/systemslibrarian/postquantum-jwt/actions/workflows/ci.yml)\n[![.NET](https://img.shields.io/badge/.NET-10.0-512BD4)](https://dotnet.microsoft.com/)\n[![License](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE)\n\n**Post-quantum hybrid JWTs for .NET 10.** Signs with ML-DSA-65 (FIPS 204).\nOptionally encrypts with X-Wing (X25519 + ML-KEM-768) and AES-256-GCM. Built\non the native .NET BCL post-quantum primitives. Fail-closed by design,\nsmall-surface, and honest about what it is.\n\n\u003e **Status — `0.3.0-preview.1`. Preview software. Not for production use.**\n\u003e The API may change before 1.0. The cryptographic construction has **not** been\n\u003e independently audited. Read [`KNOWN-GAPS.md`](KNOWN-GAPS.md) before depending\n\u003e on this for anything that matters.\n\n---\n\n## Table of contents\n\n- [Why](#why)\n- [What's new in 0.3.0-preview.1](#whats-new-in-020-preview1)\n- [Install](#install)\n- [60-second tour](#60-second-tour)\n- [Usage](#usage)\n  - [Sign and validate](#sign-and-validate)\n  - [Sign *and* encrypt](#sign-and-encrypt)\n  - [Key rotation and replay protection](#key-rotation-and-replay-protection)\n  - [ASP.NET Core integration](#aspnet-core-integration)\n- [Token format](#token-format)\n- [Public API at a glance](#public-api-at-a-glance)\n- [Compared to System.IdentityModel.Tokens.Jwt](#compared-to-systemidentitymodeltokensjwt)\n- [Operational tradeoffs](#operational-tradeoffs)\n- [Security posture](#security-posture)\n- [Compatibility](#compatibility)\n- [Building from source](#building-from-source)\n- [Contributing](#contributing)\n- [License](#license)\n\n---\n\n## Why\n\nA cryptographically relevant quantum computer would break the elliptic-curve\nmath behind today's JWT signatures (EdDSA, ECDSA, RSA). Pure post-quantum\nschemes are new and comparatively under-attacked. **Hybrid** hedges both at\nonce:\n\n- **Signatures — ML-DSA-65.** NIST-standardized lattice signature, FIPS 204,\n  security category 3.\n- **Key agreement — X-Wing.** The IETF hybrid KEM combining the battle-tested\n  **X25519** with **ML-KEM-768** (FIPS 203), bound together by a SHA3-256\n  combiner. An attacker must break *both* to recover the key.\n\nIf either half stands, your token stands. That is the whole point.\n\n---\n\n## What's new in 0.3.0-preview.1\n\nA **real-world adoption** release. v0.2 made the existing surface trustworthy;\nv0.3 makes it pleasant to wire into a real ASP.NET Core 10 app, makes it\nAOT-friendly, and adds the supply-chain signals a production-grade crypto\npackage needs. Changes are stacked newest-first.\n\n**New in v0.3.0-preview.1**\n\n- **New companion package `PostQuantum.Jwt.AspNetCore`.**\n  - `services.AddAuthentication().AddPqJwtBearer(...)` — mirrors the shape\n    of `AddJwtBearer` from `Microsoft.AspNetCore.Authentication.JwtBearer`,\n    so post-quantum tokens slot into the standard auth pipeline.\n  - `PqJwtBearerHandler` — fail-closed `AuthenticationHandler` that\n    delegates to `PqJwtValidator`. Bypasses `Microsoft.IdentityModel`, which\n    doesn't know `ML-DSA-65`.\n  - `IPqJwtKeyRing` + `HttpPqJwtKeyRing` — JWKS-equivalent: fetch a key\n    directory from a trusted HTTPS endpoint with configurable refresh,\n    in-memory cache, AOT-safe (source-gen JSON), single-suite enforcement.\n- **AOT/trim-safe API path.** New `WithClaim\u003cT\u003e(name, value, JsonTypeInfo\u003cT\u003e)`\n  overload alongside the existing reflection-based `WithClaim(name, object?)`.\n  The reflection overload carries `[RequiresUnreferencedCode]` and\n  `[RequiresDynamicCode]` so AOT publishers see one targeted warning;\n  primitive setters (`WithIssuer`, `WithSubject`, etc.) bypass reflection\n  internally and stay trim-safe. Both packages declare `IsAotCompatible=true`.\n- **CycloneDX SBOM packed inside the `.nupkg`.** `bom.json` lives at the\n  root of the package so consumers can inspect the dependency graph\n  directly from nuget.org.\n- **Property-based tests** via FsCheck.Xunit (Base64Url involutive\n  round-trip, signature-tamper invariance, etc.). Total: **68 tests**,\n  zero skips on PQ-capable hosts.\n- **Linux PQ-required CI lane.** New `linux-pq-required` job installs\n  OpenSSL 3.5+ via `conda-forge` and fails the run on any skipped test —\n  joining the Windows lane in proving the ML-KEM / ML-DSA / X-Wing paths\n  actually executed on every push, on both platforms.\n- **Release workflow author-signing hook.** Optional\n  `NUGET_SIGNING_CERT` + `NUGET_SIGNING_CERT_PASSWORD` secrets on the\n  `nuget-publish` GitHub Environment trigger `dotnet nuget sign` with a\n  DigiCert timestamp before push. Absent secrets log a notice and skip\n  signing — the package still ships under nuget.org's repository signature.\n- **API baseline infrastructure.** `PackageValidationBaselineVersion=0.2.0-preview.3`\n  is wired in conditionally — pass `-p:EnableBaselineValidation=true`\n  once the baseline is published to nuget.org and future versions are\n  checked for accidental API breaks against it.\n\n**New in v0.2.0-preview.3** (the previous release line, kept for reference)\n\n- **Fail-fast misconfiguration.** `PqJwtValidator`'s constructor now throws\n  `ArgumentException` if neither `SignatureVerificationKey` nor\n  `SignatureKeyResolver` is configured — a security validator without a way\n  to obtain a verification key is misconfigured by definition, and that\n  should surface before the first token arrives, not after.\n- **Eager X-Wing public-key validation.** `XWingPublicKey.Import` now parses\n  the embedded ML-KEM-768 encapsulation key at ingestion. A length-correct\n  but structurally invalid key fails with `PqJwtException` on import rather\n  than later inside `XWing.Encapsulate`. Consumers handling untrusted key\n  input see a single exception boundary.\n- **SBOM (CycloneDX).** Every release now emits a `bom.json` covering the\n  project's dependency graph, includes it in `SHA256SUMS.txt`, and issues a\n  separate GitHub build-provenance attestation for it. The SBOM travels\n  with the GitHub release artifacts rather than packed inside the `.nupkg`.\n\n**The 0.1 → 0.2 delta, cumulative through `preview.2`**\n\n- **Test coverage more than doubled** (27 → 57 tests, zero skips on PQ-capable\n  hosts). New fail-closed locks for `nbf` in the future, clock-skew tolerance\n  bounds, multi-audience tokens, `alg` confusion (`\"none\"` substitution),\n  missing `alg`, malformed JSON header, array-shaped payload, wrong\n  content-encryption (`A128GCM` instead of `A256GCM`), missing/wrong `cty` on\n  encrypted tokens, tampered ciphertext, decryption with the wrong private\n  key, replay protection across encrypted tokens, custom-claim round-trips,\n  claim removal via `WithClaim(name, null)`, `XWingPrivateKey` dispose\n  semantics, length-correct-but-malformed X-Wing public keys, negative\n  `ClockSkew` configuration, validator-without-key configuration, and\n  concurrent registration in `InMemoryReplayCache`.\n\n- **Validator hardening.** Encrypted tokens now require `cty: JWT` on the\n  outer header. The validator constructor refuses negative `ClockSkew`\n  values *and* validators with no verification key. The decrypted plaintext\n  buffer is zeroed alongside the shared secret. Malformed X-Wing public\n  keys surface as `PqJwtException` rather than leaking `CryptographicException`\n  from the BCL.\n\n- **Release transparency.** `scripts/check-version-sync.sh` asserts the\n  version is identical across `.csproj`, README, and CHANGELOG, and runs in\n  CI on every push. The release workflow writes a `SHA256SUMS.txt` covering\n  the `.nupkg`, `.snupkg`, and `bom.json`, and emits GitHub build-provenance\n  attestations for both the `.nupkg` and the SBOM — any consumer can run\n  `gh attestation verify \u003cfile\u003e --repo systemslibrarian/postquantum-jwt` to\n  confirm an artifact came from this repo's release workflow. Release steps\n  and trust signals are documented in [`docs/RELEASE.md`](docs/RELEASE.md).\n\n- **Windows CI is now the PQ-required lane.** It fails the run if any test\n  reports skipped, so the ML-KEM / ML-DSA / X-Wing paths are *proven* to run\n  in CI on every push, rather than relying on local verification alone.\n  Linux remains the portability lane.\n- **Documentation overhaul.** Rewritten README with a 60-second tour, a direct\n  comparison vs. `System.IdentityModel.Tokens.Jwt`, and a clearer security\n  posture. [`SECURITY.md`](SECURITY.md) and [`KNOWN-GAPS.md`](KNOWN-GAPS.md)\n  refreshed to match the current state.\n- **Build hygiene.** Build is **zero warnings** (was one CA1859 hint in 0.1).\n  `EnablePackageValidation` is on. `LICENSE` and `CHANGELOG.md` are packed\n  alongside the README so consumers see them in the package details on\n  nuget.org.\n- **CI hardening.** Workflows now run on actions versions that support\n  Node.js 24, and the release pipeline is split into `pack` + `publish` with a\n  GitHub Environment gate (`nuget-publish`) so publishing requires explicit\n  manual approval.\n- **Docs fix.** Corrected the X-Wing combiner formula in `SECURITY.md` — the\n  label is concatenated **last**, matching the code and\n  `draft-connolly-cfrg-xwing-kem`.\n\nFull notes in [`CHANGELOG.md`](CHANGELOG.md).\n\n---\n\n## Install\n\n```bash\ndotnet add package PostQuantum.Jwt --version 0.3.0-preview.1\n```\n\nOr in a `.csproj`:\n\n```xml\n\u003cPackageReference Include=\"PostQuantum.Jwt\" Version=\"0.3.0-preview.1\" /\u003e\n```\n\n**Runtime requirement:** the native ML-KEM / ML-DSA primitives need an OpenSSL\nbuild that exposes them — **OpenSSL 3.5 or later** on Linux, or a recent\nWindows. PostQuantum.Jwt fails closed with a clear error where they are\nunavailable rather than silently falling back to weaker crypto.\n\n---\n\n## 60-second tour\n\n```csharp\nusing System.Security.Cryptography;\nusing PostQuantum.Jwt;\n\nusing var signingKey      = MLDsa.GenerateKey(MLDsaAlgorithm.MLDsa65);\nusing var verificationKey = MLDsa.ImportMLDsaPublicKey(\n    MLDsaAlgorithm.MLDsa65, signingKey.ExportMLDsaPublicKey());\n\nstring token = new PqJwtBuilder()\n    .WithSubject(\"user-123\")\n    .WithLifetime(TimeSpan.FromMinutes(30))\n    .SignWith(signingKey)\n    .Build();\n\nvar result = new PqJwtValidator(new PqJwtValidationParameters\n{\n    SignatureVerificationKey = verificationKey,\n}).Validate(token);\n\nConsole.WriteLine(result.Subject); // user-123\n```\n\nThat's it: sign, validate. Anything wrong with the token — bad signature,\ntampering, expiry, claim mismatch — throws `PqJwtValidationException`. There is\nno \"best-effort\" result.\n\n---\n\n## Usage\n\n### Sign and validate\n\n```csharp\nusing System.Security.Cryptography;\nusing PostQuantum.Jwt;\n\nusing var signingKey = MLDsa.GenerateKey(MLDsaAlgorithm.MLDsa65);\n\nstring token = new PqJwtBuilder()\n    .WithIssuer(\"https://issuer.example\")\n    .WithSubject(\"user-123\")\n    .WithAudience(\"https://api.example\")\n    .WithLifetime(TimeSpan.FromMinutes(30))\n    .WithClaim(\"role\", \"admin\")\n    .SignWith(signingKey)\n    .Build();\n\nusing var verificationKey = MLDsa.ImportMLDsaPublicKey(\n    MLDsaAlgorithm.MLDsa65, signingKey.ExportMLDsaPublicKey());\n\nvar validator = new PqJwtValidator(new PqJwtValidationParameters\n{\n    SignatureVerificationKey = verificationKey,\n    ValidIssuer   = \"https://issuer.example\",\n    ValidAudience = \"https://api.example\",\n});\n\nPqJwtValidationResult result = validator.Validate(token);\nConsole.WriteLine(result.Subject);            // user-123\nConsole.WriteLine(result.GetString(\"role\"));  // admin\n```\n\n### Sign *and* encrypt\n\nWhen the payload is confidential, hand the builder a recipient's X-Wing public\nkey. The token is signed first, then encrypted (\"sign-then-encrypt\").\n\n```csharp\nusing PostQuantum.Jwt.Cryptography;\n\n// Recipient generates a key pair and publishes the public half.\nusing var recipient = XWingPrivateKey.Generate();\nbyte[] recipientPublic = recipient.PublicKey.Export();   // share this\n\nstring token = new PqJwtBuilder()\n    .WithSubject(\"confidential-subject\")\n    .WithLifetime(TimeSpan.FromMinutes(5))\n    .SignWith(signingKey)\n    .EncryptFor(XWingPublicKey.Import(recipientPublic))\n    .Build();\n\nvar validator = new PqJwtValidator(new PqJwtValidationParameters\n{\n    SignatureVerificationKey = verificationKey,\n    DecryptionKey            = recipient,   // required for encrypted tokens\n});\n\nPqJwtValidationResult result = validator.Validate(token);\nConsole.WriteLine(result.WasEncrypted);  // True\n```\n\n### Key rotation and replay protection\n\nTag a signature with a `kid` and resolve it at validation time, and reject\nreplayed tokens with a `jti` cache:\n\n```csharp\nstring token = new PqJwtBuilder()\n    .WithKeyId(\"signing-key-2026\")\n    .WithJwtId(Guid.NewGuid().ToString(\"N\"))\n    .WithLifetime(TimeSpan.FromMinutes(5))\n    .SignWith(signingKey)\n    .Build();\n\nvar validator = new PqJwtValidator(new PqJwtValidationParameters\n{\n    // Pick a verification key from the token's kid (key rotation).\n    SignatureKeyResolver = kid =\u003e keyRing.TryGetValue(kid, out var k) ? k : null,\n    // Reject any jti seen before. InMemoryReplayCache is single-process;\n    // implement IPqJwtReplayCache over a shared store for multi-node setups.\n    ReplayCache = new InMemoryReplayCache(),\n});\n```\n\nAn unknown `kid`, a missing `jti`, or a replayed `jti` all fail closed.\n\n### ASP.NET Core integration\n\n\u003e **`PostQuantum.Jwt.AspNetCore` is superseded by\n\u003e [`PostQuantum.AspNetCore`](https://github.com/systemslibrarian/postquantum-aspnetcore).**\n\u003e Same engine (this library), cleaner naming, dedicated release cadence,\n\u003e richer event-hook surface (`OnMessageReceived` / `OnTokenValidated` /\n\u003e `OnAuthenticationFailed` / `OnChallenge`), hosted-service key-ring\n\u003e warmup, full SignalR support, and a 40-test integration suite. Tokens\n\u003e minted by either package validate in the other. New consumers should\n\u003e adopt `PostQuantum.AspNetCore` directly. The old companion will\n\u003e continue to receive critical fixes through 1.0 but no new features.\n\u003e\n\u003e **Migration guide:**\n\u003e [`postquantum-aspnetcore/docs/MIGRATION.md`](https://github.com/systemslibrarian/postquantum-aspnetcore/blob/main/docs/MIGRATION.md)\n\u003e — diff-style, mostly an `AddPqJwtBearer` → `AddPostQuantumJwtBearer`\n\u003e rename plus a scheme-name change.\n\nThe legacy companion's shape, for reference: install the companion\npackage and call `AddPqJwtBearer(...)` on the standard\n`AuthenticationBuilder` — the same shape as `AddJwtBearer` from\n`Microsoft.AspNetCore.Authentication.JwtBearer`, but routing through\n`PqJwtValidator` instead of the IdentityModel handler that can't speak\n`ML-DSA-65`.\n\n```bash\ndotnet add package PostQuantum.Jwt.AspNetCore --version 0.3.0-preview.1\n```\n\n```csharp\nusing System.Security.Cryptography;\nusing PostQuantum.Jwt;\nusing PostQuantum.Jwt.AspNetCore;\n\nvar builder = WebApplication.CreateBuilder(args);\n\nbuilder.Services\n    .AddAuthentication(PqJwtBearerDefaults.AuthenticationScheme)\n    .AddPqJwtBearer(options =\u003e\n    {\n        var keyBytes = Convert.FromBase64String(\n            builder.Configuration[\"Auth:VerificationKey\"]\n                ?? throw new InvalidOperationException(\"Missing Auth:VerificationKey\"));\n        options.ValidationParameters = new PqJwtValidationParameters\n        {\n            SignatureVerificationKey = MLDsa.ImportMLDsaPublicKey(\n                MLDsaAlgorithm.MLDsa65, keyBytes),\n            ValidIssuer   = builder.Configuration[\"Auth:Issuer\"],\n            ValidAudience = builder.Configuration[\"Auth:Audience\"],\n            // Single-process replay defense. Swap to a Redis-backed\n            // IPqJwtReplayCache for a horizontally scaled deployment.\n            ReplayCache   = new InMemoryReplayCache(),\n        };\n    });\nbuilder.Services.AddAuthorization();\n\nvar app = builder.Build();\napp.UseAuthentication();\napp.UseAuthorization();\n\napp.MapGet(\"/me\", (HttpContext ctx) =\u003e new\n{\n    sub  = ctx.User.FindFirst(\"sub\")?.Value,\n    role = ctx.User.FindFirst(\"role\")?.Value,\n}).RequireAuthorization();\n\napp.Run();\n```\n\nThat's the whole integration. The handler is fail-closed by construction\n(tampered / expired / wrong-issuer tokens produce\n`AuthenticateResult.Fail`), `RequireAuthorization()` returns 401 to\nunauthenticated callers, and standard `[Authorize(Roles = \"...\")]`\nattributes work against the `\"role\"` claim by default.\n\n**Key rotation across services.** Use `HttpPqJwtKeyRing` to fetch\nverification keys from a trusted HTTPS endpoint (the post-quantum\nanalogue of JWKS):\n\n```csharp\nbuilder.Services.AddHttpClient\u003cHttpPqJwtKeyRing\u003e();\nbuilder.Services.AddSingleton(sp =\u003e\n{\n    var http = sp.GetRequiredService\u003cIHttpClientFactory\u003e().CreateClient(nameof(HttpPqJwtKeyRing));\n    return new HttpPqJwtKeyRing(http, new Uri(builder.Configuration[\"Auth:KeysEndpoint\"]!));\n});\n\nbuilder.Services\n    .AddAuthentication(PqJwtBearerDefaults.AuthenticationScheme)\n    .AddPqJwtBearer(options =\u003e\n    {\n        options.ValidationParameters = new PqJwtValidationParameters\n        {\n            // Resolved per token from the token's `kid` header.\n            SignatureKeyResolver = kid =\u003e\n                builder.Services.BuildServiceProvider()\n                    .GetRequiredService\u003cHttpPqJwtKeyRing\u003e()\n                    .Resolve(kid),\n            ValidIssuer   = builder.Configuration[\"Auth:Issuer\"],\n            ValidAudience = builder.Configuration[\"Auth:Audience\"],\n        };\n    });\n```\n\nThe expected key-directory document is JSON:\n`{ \"keys\": [ { \"kid\": \"...\", \"alg\": \"ML-DSA-65\", \"key\": \"\u003cbase64\u003e\" }, ... ] }`.\nEntries with any other `alg` are ignored — the single-suite policy holds\nacross services.\n\n**Don't `AddJwtBearer` alongside this.** The standard handler will try to\nparse the token's `alg` and fail. Either use `AddPqJwtBearer` as your\nonly bearer auth, or restrict each scheme to specific routes with\n`[Authorize(AuthenticationSchemes = ...)]`.\n\n---\n\n## Token format\n\nPostQuantum.Jwt uses JOSE-style compact serialization:\n\n| Form      | Segments | Header `alg` / `enc`              |\n|-----------|----------|-----------------------------------|\n| Signed    | 3        | `ML-DSA-65`                       |\n| Encrypted | 5        | `X-Wing` / `A256GCM` (nested JWT) |\n\nThese algorithm identifiers are **not** registered with IANA — see\n[Security posture](#security-posture).\n\nFull wire-format and combiner details are in [`docs/design.md`](docs/design.md).\n\n---\n\n## Public API at a glance\n\n| Type                            | Purpose                                                                  |\n|---------------------------------|--------------------------------------------------------------------------|\n| `PqJwtBuilder`                  | Fluent builder for signed (3-part) or signed-then-encrypted (5-part) tokens. |\n| `PqJwtValidator`                | Fail-closed validator. Thread-safe and reusable.                         |\n| `PqJwtValidationParameters`     | Validation configuration: keys, issuer/audience, lifetime, replay cache. |\n| `PqJwtValidationResult`         | The validated claims; only returned when every check passed.             |\n| `PqJwtAlgorithms`               | Canonical `alg`/`enc` identifiers (e.g. `ML-DSA-65`, `X-Wing`, `A256GCM`). |\n| `PqJwtException`                | Misconfiguration / usage error.                                          |\n| `PqJwtValidationException`      | Token failed validation (subclass of `PqJwtException`).                  |\n| `IPqJwtReplayCache`             | Optional `jti` replay-detection hook.                                    |\n| `InMemoryReplayCache`           | Default single-process replay cache (use a distributed store in clusters). |\n| `XWingPrivateKey` / `…PublicKey` | X-Wing hybrid KEM keys; `Generate()`, `Import()`, `Export()`.            |\n\n---\n\n## Compared to `System.IdentityModel.Tokens.Jwt`\n\n`System.IdentityModel.Tokens.Jwt` (and the wider `Microsoft.IdentityModel.*`\nfamily) is the right choice for the vast majority of JWT work today: it speaks\nthe IANA JOSE algorithms, interops with the entire OAuth / OpenID Connect\necosystem, and has been hardened over a decade of production use. **Use it\nunless you have a specific reason not to.**\n\nPostQuantum.Jwt is a focused, deliberately *non-interoperable* tool for one\nproblem: hybrid post-quantum JWTs. The trade-offs:\n\n| Concern | `System.IdentityModel.Tokens.Jwt` | `PostQuantum.Jwt` |\n|---|---|---|\n| **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. |\n| **Quantum resistance** | None of the standard algorithms are quantum-resistant. | Hybrid: classical *and* post-quantum, both must fall. |\n| **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). |\n| **Standards interop** | Fully IANA-registered identifiers; tokens validate in every JWT library. | Identifiers (`ML-DSA-65`, `X-Wing`) are not IANA-registered. Tokens **will not** validate in generic JWT tooling. |\n| **`alg: none`** | Historically supported (and disastrous); now disabled by default. | **Impossible.** No unsigned path exists in the code. |\n| **Default `exp` enforcement** | Configurable; default depends on the consumer (`TokenValidationParameters`). | Required by default. A token without an `exp` claim is rejected. |\n| **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. |\n| **Replay defense** | Not built-in. | Built-in `IPqJwtReplayCache` + `InMemoryReplayCache`, opt-in via configuration. |\n| **OAuth / OIDC integration** | First-class (`Microsoft.AspNetCore.Authentication.JwtBearer`, JWKS, etc.). | None. You wire the validator into your pipeline yourself. |\n| **External audit** | Yes — widely deployed and reviewed. | **No.** Preview, not audited. |\n| **Dependencies** | A family of `Microsoft.IdentityModel.*` packages. | Native .NET BCL + **one** package (`BouncyCastle.Cryptography`) for X25519 + SHA3-256. |\n| **Target framework** | Multi-target (netstandard2.0 through net10). | `net10.0` only. |\n\n**Use `System.IdentityModel.Tokens.Jwt` if** you need OAuth/OIDC interop, JWKS,\nmulti-algorithm agility, or any standards-conformant JWT.\n\n**Use `PostQuantum.Jwt` if** you specifically want hybrid post-quantum tokens\n*now*, you control both the issuer and the verifier, and you accept that your\ntokens won't validate in any other ecosystem until IANA registers these\nidentifiers and standard libraries catch up.\n\n---\n\n## Operational tradeoffs\n\nHonest, decision-useful notes for the moment you're deciding whether to wire\nthis in.\n\n**Token size.** A plain HS256 JWT is ~200 bytes. A signed PostQuantum.Jwt token\nis **~4.5 KB**: ML-DSA-65 signatures are 3,309 bytes (vs. 32–64 for HMAC/EdDSA),\nand that's after base64url encoding. A sign-then-encrypt token adds another\n**~1.5 KB** (1,120-byte X-Wing ciphertext + 12-byte nonce + 16-byte AES-GCM\ntag, base64url-encoded). Plan for ~5 KB signed, ~6.5 KB encrypted. This matters\nif you put tokens in cookies, query strings, or constrained headers — for most\n`Authorization: Bearer` flows it's fine, for cookies it likely is not.\n\n**When to reach for encryption.** The `sign-then-encrypt` form is the right\nchoice only when the claims themselves are confidential (PII, account IDs you\ndon't want a leaked log holding). For the more common case — opaque session\nreferences, role/scope strings — a signed-only token is correct: the signature\nalready prevents forgery, encryption just trades cost for secrecy you may not\nneed.\n\n**Replay protection in a cluster.** `InMemoryReplayCache` works for a single\nprocess and is fine for a development server or a single-instance worker. The\nmoment you scale horizontally, `jti`-based replay defense requires a shared\nstore — implement `IPqJwtReplayCache` over Redis, a database table, or\nwhichever cache the rest of your stack already uses. Until then a token\n\"replayed\" on a different node is **not** detected.\n\n**Key rotation.** `SignatureKeyResolver` selects a verification key from the\ntoken's `kid` header. It does *not* fetch keys — there is no JWKS endpoint or\nremote-discovery story. Your application is responsible for the key ring; this\nlibrary is just disciplined about asking for the right key when validating.\n\n**\"Preview, not for production\" — what that means operationally.** The wire\nformat and public API are not stable yet. If you ship 0.3.0-preview.1 in a\nservice and a future release bumps to 0.3.0 with a wire-format change, you\nwill be re-signing every active token (and possibly running a flag-day\nmigration). For an internal service you control end-to-end this is\nmanageable. For a public API where third parties hold issued tokens, treat\nthis as a blocker until 1.0.\n\n---\n\n## Security posture\n\nWe aim to be honest about exactly what this library does and does not give you.\n\n**What you get**\n\n- **Hybrid by construction.** Encryption stays secure unless *both* X25519 and\n  ML-KEM-768 fall; signatures rest on ML-DSA-65.\n- **Native post-quantum primitives.** ML-KEM-768 and ML-DSA-65 are the .NET\n  BCL implementations, not a re-implementation.\n- **Fail-closed validation.** Bad signature, tampered ciphertext, expired or\n  not-yet-valid token, wrong issuer/audience, missing `exp`, missing `alg`, or\n  an `alg` we don't expect — all throw. There is no `alg: none`, no unsigned\n  path, and no silent downgrade.\n- **Strict, small-surface defaults.** Expiration is required, clock skew is a\n  modest 60 seconds, and only the exact post-quantum algorithms are accepted.\n\n**What you must know**\n\n- **One dependency — BouncyCastle — and why.** The .NET BCL does not ship\n  X25519, the classical half of X-Wing. Rather than hand-roll elliptic-curve\n  code, we use BouncyCastle's vetted X25519 (and its SHA3-256 for the X-Wing\n  combiner). ML-KEM-768 and ML-DSA-65 remain on the native BCL. This trade-off\n  is deliberate: we will not roll our own curve arithmetic.\n- **Not audited.** No third party has reviewed this construction. X-Wing key\n  generation and the decapsulation/combiner path **are** validated against the\n  official IETF known-answer vectors; the encapsulation path is not (the native\n  ML-KEM API is randomized). See [`KNOWN-GAPS.md`](KNOWN-GAPS.md).\n- **Non-standard identifiers.** The `alg`/`enc` values describe a scheme the\n  IANA JOSE registry does not cover, so these tokens are intentionally **not**\n  interoperable with generic JWT tooling.\n- **Preview.** Treat the API and wire format as unstable until 1.0.\n\nFull detail lives in [`SECURITY.md`](SECURITY.md) and\n[`KNOWN-GAPS.md`](KNOWN-GAPS.md). To report a vulnerability, see `SECURITY.md`.\n\n---\n\n## Compatibility\n\n| Surface | Supported |\n|---|---|\n| Target framework | `net10.0` |\n| Languages | C# 13 (any CLS-consuming language; the assembly is `[CLSCompliant(false)]` because the public surface exposes raw `byte[]` key material). |\n| 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**. |\n| AOT / trimming | Not yet validated. The library uses `System.Text.Json` reflection paths; expect to need source-generated contexts before publishing AOT. |\n\n---\n\n## Building from source\n\n```bash\ndotnet build\ndotnet test\n```\n\nTests that exercise the native post-quantum primitives **skip themselves** (with\na clear reason) on hosts that lack ML-KEM / ML-DSA support, and run fully where\nOpenSSL 3.5+ is present.\n\nIf you're on a Linux box whose system OpenSSL predates 3.5, point the runtime\nat a newer one:\n\n```bash\nLD_LIBRARY_PATH=/path/to/openssl-3.5/lib dotnet test\n```\n\nThe full suite is **68 tests, zero skips** on a Windows 11 / .NET 10 host\nwith native ML-KEM and ML-DSA support. Both the Windows and Linux CI lanes\nfail the run if any test skips.\n\n---\n\n## Contributing\n\nIssues and pull requests are welcome. Before opening a PR:\n\n1. Run `dotnet build` and `dotnet test` — both must be green, with **zero\n   warnings** (the build treats compiler warnings as errors).\n2. Keep the discipline in [`CLAUDE.md`](CLAUDE.md): honesty over polish,\n   fail-closed always, no rolled-your-own crypto, native BCL first.\n3. Security-sensitive changes should land alongside a test that locks in the\n   fail-closed behavior.\n\n**Cutting a release** is documented in [`docs/RELEASE.md`](docs/RELEASE.md).\nIt enumerates exactly what CI enforces, what humans review, and what\nprovenance signals each release carries — and is honest about what is still\nmissing (author code signing, SBOM).\n\n**Reporting a vulnerability:** please **do not** open a public issue. Use\nGitHub's *Report a vulnerability* button on the repository, or follow the\nprocess in [`SECURITY.md`](SECURITY.md).\n\n---\n\n## License\n\n[MIT](LICENSE).\n\n---\n\n*To God be the glory — 1 Corinthians 10:31.*\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fsystemslibrarian%2Fpostquantum-jwt","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fsystemslibrarian%2Fpostquantum-jwt","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fsystemslibrarian%2Fpostquantum-jwt/lists"}