first commit

This commit is contained in:
2026-05-29 08:20:56 +01:00
commit 949299f58d
6368 changed files with 527176 additions and 0 deletions

View File

@@ -0,0 +1,147 @@
# @suaveplan/jws - RFC 7515 JSON Web Signature (verify + sign). Compact, JSON-flattened, and JSON-general (multi-signature) serializations; HS/RS/PS/ES/EdDSA algorithms; JWK + x5c + kid key resolution; crit allowlist enforcement; detached payload (§A.5); RFC 7797 b64 header support.
## Summary
RFC 7515 **JSON Web Signature** (verify + sign). Pure TypeScript over `Uint8Array` + `DataView` plus the workspace crypto primitives (`@suaveplan/crypto-rsa`, `@suaveplan/crypto-curve`, `@suaveplan/crypto-hash`, `@suaveplan/crypto-utils`) and `@suaveplan/x509` for `x5c` chain validation. Zero browser globals, zero Node built-ins, zero external JOSE/JWT/jws-npm dependencies.
<!-- begin:auto-docs -->
## Documentation tree
- [builtin](./src/algorithms/builtin.md) — Seeds the default `AlgorithmRegistry` with the 13 built-in algorithm implementations required by RFC 7518 §3 and RFC 80…
- [ecdsa](./src/algorithms/ecdsa.md) — RFC 7518 §3.4 ECDSA signing and verification over NIST P-256, P-384, and P-521. The signing input is hashed with the cu…
- [eddsa](./src/algorithms/eddsa.md) — RFC 8037 §3.1 EdDSA over Ed25519. The signing input is signed directly by Ed25519 (no caller-side pre-hash — Ed25519 ha…
- [hmac](./src/algorithms/hmac.md) — RFC 7518 §3.2 HMAC-with-SHA-2 signing and verification. The signing input is HMAC'd with the caller-supplied symmetric…
- [none](./src/algorithms/none.md) — RFC 7518 §3.6 `"none"` algorithm. The `none` value declares "no integrity protection," which is virtually never what a…
- [registry](./src/algorithms/registry.md) — In-package mapping from `alg` identifier to the concrete `AlgorithmImpl` that knows how to verify and (optionally) sign…
- [rsa-pss](./src/algorithms/rsa-pss.md) — RFC 7518 §3.5 RSASSA-PSS signing and verification. The signing input is hashed, EMSA-PSS-encoded with a random salt, an…
- [rsa](./src/algorithms/rsa.md) — RFC 7518 §3.3 RSASSA-PKCS1-v1_5 signing and verification. The signing input is hashed with SHA-256/SHA-384/SHA-512 resp…
- [base64url](./src/base64url/base64url.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [base64url](./src/base64url/base64url.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [detached](./src/detached/detached.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [detached](./src/detached/detached.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [errors](./src/errors/error-codes.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [error-codes](./src/errors/error-codes.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [header](./src/header/header.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [b64](./src/header/b64.md) — RFC 7797 §3 implements the `b64` header parameter, allowing JWS producers to embed unencoded payloads directly into the…
- [crit](./src/header/crit.md) — RFC 7515 §4.1.11 enforcement: every entry in `header.crit` MUST be in the caller's `acceptableCriticalHeaders` allowlis…
- [header](./src/header/header.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [jwk](./src/keys/jwk.md) — Parse an RFC 7517 §4 / RFC 7518 §6 JSON Web Key into the typed key shape expected by the JWS algorithm impl for the dec…
- [resolver](./src/keys/resolver.md) — Caller-supplied key-resolution function consulted when `header.kid` is present and no key has been supplied via `opts.k…
- [x5c](./src/keys/x5c.md) — Decode an RFC 7515 §4.1.6 `x5c` certificate chain, delegate trust-path validation to `@suaveplan/x509.validateChain`, a…
- [observability](./src/observability/metrics.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [metrics](./src/observability/metrics.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [schemas](./src/schemas/schemas.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [schemas](./src/schemas/schemas.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [compact](./src/serialization/compact/compact.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [compact](./src/serialization/compact/compact.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [flat](./src/serialization/flat/flat.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [flat](./src/serialization/flat/flat.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [general](./src/serialization/general/general.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [general](./src/serialization/general/general.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [sign](./src/sign/sign-pipeline.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [sign-pipeline](./src/sign/sign-pipeline.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [verify](./src/verify/verify-pipeline.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [verify-pipeline](./src/verify/verify-pipeline.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
<!-- end:auto-docs --> - [base64url](./src/base64url/base64url.md) — <!-- begin:auto-docs --> _(no submodules)_- [detached](./src/detached/detached.md) — <!-- begin:auto-docs --> _(no submodules)_- [detached](./src/detached/detached.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/error-codes.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/errors/error-codes.md) — <!-- begin:auto-docs --> _(no submodules)_- [header](./src/header/header.md) — <!-- begin:auto-docs --> _(no submodules)_- [b64](./src/header/b64.md) — RFC 7797 §3 implements the `b64` header parameter, allowing JWS producers to embed unencoded payloads directly into the…
- [crit](./src/header/crit.md) — RFC 7515 §4.1.11 enforcement: every entry in `header.crit` MUST be in the caller's `acceptableCriticalHeaders` allowlis…
- [header](./src/header/header.md) — <!-- begin:auto-docs --> _(no submodules)_- [jwk](./src/keys/jwk.md) — Parse an RFC 7517 §4 / RFC 7518 §6 JSON Web Key into the typed key shape expected by the JWS algorithm impl for the dec…
- [resolver](./src/keys/resolver.md) — Caller-supplied key-resolution function consulted when `header.kid` is present and no key has been supplied via `opts.k…
- [x5c](./src/keys/x5c.md) — Decode an RFC 7515 §4.1.6 `x5c` certificate chain, delegate trust-path validation to `@suaveplan/x509.validateChain`, a…
- [observability](./src/observability/metrics.md) — <!-- begin:auto-docs --> _(no submodules)_- [metrics](./src/observability/metrics.md) — <!-- begin:auto-docs --> _(no submodules)_- [schemas](./src/schemas/schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [schemas](./src/schemas/schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [compact](./src/serialization/compact/compact.md) — <!-- begin:auto-docs --> _(no submodules)_- [compact](./src/serialization/compact/compact.md) — <!-- begin:auto-docs --> _(no submodules)_- [flat](./src/serialization/flat/flat.md) — <!-- begin:auto-docs --> _(no submodules)_- [flat](./src/serialization/flat/flat.md) — <!-- begin:auto-docs --> _(no submodules)_- [general](./src/serialization/general/general.md) — <!-- begin:auto-docs --> _(no submodules)_- [general](./src/serialization/general/general.md) — <!-- begin:auto-docs --> _(no submodules)_- [sign](./src/sign/sign-pipeline.md) — <!-- begin:auto-docs --> _(no submodules)_- [sign-pipeline](./src/sign/sign-pipeline.md) — <!-- begin:auto-docs --> _(no submodules)_- [verify](./src/verify/verify-pipeline.md) — <!-- begin:auto-docs --> _(no submodules)_- [verify-pipeline](./src/verify/verify-pipeline.md) — <!-- begin:auto-docs --> _(no submodules)_- [base64url](./src/base64url/base64url.md) — <!-- begin:auto-docs --> _(no submodules)_- [detached](./src/detached/detached.md) — <!-- begin:auto-docs --> _(no submodules)_- [detached](./src/detached/detached.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/error-codes.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/errors/error-codes.md) — <!-- begin:auto-docs --> _(no submodules)_- [header](./src/header/header.md) — <!-- begin:auto-docs --> _(no submodules)_- [b64](./src/header/b64.md) — RFC 7797 §3 implements the `b64` header parameter, allowing JWS producers to embed unencoded payloads directly into the…
- [crit](./src/header/crit.md) — RFC 7515 §4.1.11 enforcement: every entry in `header.crit` MUST be in the caller's `acceptableCriticalHeaders` allowlis…
- [header](./src/header/header.md) — <!-- begin:auto-docs --> _(no submodules)_- [jwk](./src/keys/jwk.md) — Parse an RFC 7517 §4 / RFC 7518 §6 JSON Web Key into the typed key shape expected by the JWS algorithm impl for the dec…
- [resolver](./src/keys/resolver.md) — Caller-supplied key-resolution function consulted when `header.kid` is present and no key has been supplied via `opts.k…
- [x5c](./src/keys/x5c.md) — Decode an RFC 7515 §4.1.6 `x5c` certificate chain, delegate trust-path validation to `@suaveplan/x509.validateChain`, a…
- [observability](./src/observability/metrics.md) — <!-- begin:auto-docs --> _(no submodules)_- [metrics](./src/observability/metrics.md) — <!-- begin:auto-docs --> _(no submodules)_- [schemas](./src/schemas/schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [schemas](./src/schemas/schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [compact](./src/serialization/compact/compact.md) — <!-- begin:auto-docs --> _(no submodules)_- [compact](./src/serialization/compact/compact.md) — <!-- begin:auto-docs --> _(no submodules)_- [flat](./src/serialization/flat/flat.md) — <!-- begin:auto-docs --> _(no submodules)_- [flat](./src/serialization/flat/flat.md) — <!-- begin:auto-docs --> _(no submodules)_- [general](./src/serialization/general/general.md) — <!-- begin:auto-docs --> _(no submodules)_- [general](./src/serialization/general/general.md) — <!-- begin:auto-docs --> _(no submodules)_- [sign](./src/sign/sign-pipeline.md) — <!-- begin:auto-docs --> _(no submodules)_- [sign-pipeline](./src/sign/sign-pipeline.md) — <!-- begin:auto-docs --> _(no submodules)_- [verify](./src/verify/verify-pipeline.md) — <!-- begin:auto-docs --> _(no submodules)_- [verify-pipeline](./src/verify/verify-pipeline.md) — <!-- begin:auto-docs --> _(no submodules)_- [base64url](./src/base64url/base64url.md) — <!-- begin:auto-docs --> _(no submodules)_- [detached](./src/detached/detached.md) — <!-- begin:auto-docs --> _(no submodules)_- [detached](./src/detached/detached.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/error-codes.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/errors/error-codes.md) — <!-- begin:auto-docs --> _(no submodules)_- [header](./src/header/header.md) — <!-- begin:auto-docs --> _(no submodules)_- [b64](./src/header/b64.md) — RFC 7797 §3 implements the `b64` header parameter, allowing JWS producers to embed unencoded payloads directly into the…
- [crit](./src/header/crit.md) — RFC 7515 §4.1.11 enforcement: every entry in `header.crit` MUST be in the caller's `acceptableCriticalHeaders` allowlis…
- [header](./src/header/header.md) — <!-- begin:auto-docs --> _(no submodules)_- [jwk](./src/keys/jwk.md) — Parse an RFC 7517 §4 / RFC 7518 §6 JSON Web Key into the typed key shape expected by the JWS algorithm impl for the dec…
- [resolver](./src/keys/resolver.md) — Caller-supplied key-resolution function consulted when `header.kid` is present and no key has been supplied via `opts.k…
- [x5c](./src/keys/x5c.md) — Decode an RFC 7515 §4.1.6 `x5c` certificate chain, delegate trust-path validation to `@suaveplan/x509.validateChain`, a…
- [observability](./src/observability/metrics.md) — <!-- begin:auto-docs --> _(no submodules)_- [metrics](./src/observability/metrics.md) — <!-- begin:auto-docs --> _(no submodules)_- [schemas](./src/schemas/schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [schemas](./src/schemas/schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [compact](./src/serialization/compact/compact.md) — <!-- begin:auto-docs --> _(no submodules)_- [compact](./src/serialization/compact/compact.md) — <!-- begin:auto-docs --> _(no submodules)_- [flat](./src/serialization/flat/flat.md) — <!-- begin:auto-docs --> _(no submodules)_- [flat](./src/serialization/flat/flat.md) — <!-- begin:auto-docs --> _(no submodules)_- [general](./src/serialization/general/general.md) — <!-- begin:auto-docs --> _(no submodules)_- [general](./src/serialization/general/general.md) — <!-- begin:auto-docs --> _(no submodules)_- [sign](./src/sign/sign-pipeline.md) — <!-- begin:auto-docs --> _(no submodules)_- [sign-pipeline](./src/sign/sign-pipeline.md) — <!-- begin:auto-docs --> _(no submodules)_- [verify](./src/verify/verify-pipeline.md) — <!-- begin:auto-docs --> _(no submodules)_- [verify-pipeline](./src/verify/verify-pipeline.md) — <!-- begin:auto-docs --> _(no submodules)_- [base64url](./src/base64url/base64url.md) — <!-- begin:auto-docs --> _(no submodules)_- [detached](./src/detached/detached.md) — <!-- begin:auto-docs --> _(no submodules)_ - [detached](./src/detached/detached.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/error-codes.md) — <!-- begin:auto-docs --> _(no submodules)_ - [error-codes](./src/errors/error-codes.md) — <!-- begin:auto-docs --> _(no submodules)_- [header](./src/header/header.md) — <!-- begin:auto-docs --> _(no submodules)_ - [b64](./src/header/b64.md) — RFC 7797 §3 implements the `b64` header parameter, allowing JWS producers to embed unencoded payloads directly into the…
- [crit](./src/header/crit.md) — RFC 7515 §4.1.11 enforcement: every entry in `header.crit` MUST be in the caller's `acceptableCriticalHeaders` allowlis…
- [header](./src/header/header.md) — <!-- begin:auto-docs --> _(no submodules)_- [jwk](./src/keys/jwk.md) — Parse an RFC 7517 §4 / RFC 7518 §6 JSON Web Key into the typed key shape expected by the JWS algorithm impl for the dec…
- [resolver](./src/keys/resolver.md) — Caller-supplied key-resolution function consulted when `header.kid` is present and no key has been supplied via `opts.k…
- [x5c](./src/keys/x5c.md) — Decode an RFC 7515 §4.1.6 `x5c` certificate chain, delegate trust-path validation to `@suaveplan/x509.validateChain`, a…
- [observability](./src/observability/metrics.md) — <!-- begin:auto-docs --> _(no submodules)_ - [metrics](./src/observability/metrics.md) — <!-- begin:auto-docs --> _(no submodules)_- [schemas](./src/schemas/schemas.md) — <!-- begin:auto-docs --> _(no submodules)_ - [schemas](./src/schemas/schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [compact](./src/serialization/compact/compact.md) — <!-- begin:auto-docs --> _(no submodules)_ - [compact](./src/serialization/compact/compact.md) — <!-- begin:auto-docs --> _(no submodules)_- [flat](./src/serialization/flat/flat.md) — <!-- begin:auto-docs --> _(no submodules)_ - [flat](./src/serialization/flat/flat.md) — <!-- begin:auto-docs --> _(no submodules)_- [general](./src/serialization/general/general.md) — <!-- begin:auto-docs --> _(no submodules)_ - [general](./src/serialization/general/general.md) — <!-- begin:auto-docs --> _(no submodules)_- [sign](./src/sign/sign-pipeline.md) — <!-- begin:auto-docs --> _(no submodules)_ - [sign-pipeline](./src/sign/sign-pipeline.md) — <!-- begin:auto-docs --> _(no submodules)_- [verify](./src/verify/verify-pipeline.md) — <!-- begin:auto-docs --> _(no submodules)_ - [verify-pipeline](./src/verify/verify-pipeline.md) — <!-- begin:auto-docs --> _(no submodules)_
## Install
```sh
bun add @suaveplan/jws
```
## Quick start
```ts
import { compactSign, compactVerify } from "@suaveplan/jws";
const secret = new TextEncoder().encode("a-shared-32-byte-secret-pls-rotate");
const token = await compactSign("hello", secret, "HS256");
const result = await compactVerify(token, secret, { algorithms: ["HS256"] });
// result.payloadString === "hello"
```
## Capabilities
| Feature | Status |
| -------------------------------- | ----------------------------------- |
| RFC 7515 §3.1 compact form | ✓ |
| RFC 7515 §7.2.2 JSON-flattened | ✓ |
| RFC 7515 §7.2.1 JSON-general | ✓ (multi-signature) |
| RFC 7515 §A.5 detached payload | ✓ |
| RFC 7797 `b64: false` header | ✓ |
| HS256 / HS384 / HS512 | ✓ (constant-time tag compare) |
| RS256 / RS384 / RS512 | ✓ (`@suaveplan/crypto-rsa`) |
| PS256 / PS384 / PS512 | ✓ (hash-length salt) |
| ES256 / ES384 / ES512 | ✓ (`@suaveplan/crypto-curve`; DER rejected) |
| EdDSA (Ed25519) | ✓ (RFC 8037) |
| `alg: "none"` | ✓ (dual-flag opt-in only) |
| Embedded `jwk` header | ✓ (`allowEmbeddedJwk: true`) |
| Embedded `x5c` chain | ✓ (`@suaveplan/x509.validateChain`) |
| `kid` resolver | ✓ (caller-supplied async fn) |
| `crit` allowlist | ✓ |
| AbortSignal end-to-end | ✓ |
| Zod boundary at every entry | ✓ |
| OpenTelemetry histograms+counters| ✓ |
## Security defaults
- **Algorithm allowlist is mandatory at verify time.** There is no default — callers MUST pass `opts.algorithms`. Tokens whose `alg` is not on the list are rejected before any cryptography runs (defence against the JWT "alg confusion" CVE class).
- **`alg: "none"` requires dual-flag opt-in.** Both `algorithms: ["none"]` AND `acceptNone: true` are required.
- **`crit` parameters must be allowlisted.** Per RFC 7515 §4.1.11.
- **ECDSA signatures must be fixed-length `R||S`.** DER-encoded signatures in the JOSE position throw `JWS.SIG_FORMAT_INVALID`.
- **HMAC tag comparison is constant-time.** Via `@suaveplan/crypto-utils.constantTimeEqual`.
- **PSS salt length is fixed at the hash output length.** Per RFC 7518 §3.5.
## Modules
| Module | Doc |
| --- | --- |
| `base64url` | [`src/base64url/base64url.md`](src/base64url/base64url.md) |
| Compact serialization | [`src/serialization/compact/compact.md`](src/serialization/compact/compact.md) |
| Flat serialization | [`src/serialization/flat/flat.md`](src/serialization/flat/flat.md) |
| General serialization | [`src/serialization/general/general.md`](src/serialization/general/general.md) |
| Detached payload | [`src/detached/detached.md`](src/detached/detached.md) |
| Header parse / serialize | [`src/header/header.md`](src/header/header.md) |
| `crit` enforcement | [`src/header/crit.md`](src/header/crit.md) |
| RFC 7797 `b64` | [`src/header/b64.md`](src/header/b64.md) |
| Algorithm registry | [`src/algorithms/registry.md`](src/algorithms/registry.md) |
| Built-in seeding | [`src/algorithms/builtin.md`](src/algorithms/builtin.md) |
| HMAC (HS256/384/512) | [`src/algorithms/hmac.md`](src/algorithms/hmac.md) |
| RSA-PKCS1-v1\_5 (RS\*) | [`src/algorithms/rsa.md`](src/algorithms/rsa.md) |
| RSA-PSS (PS\*) | [`src/algorithms/rsa-pss.md`](src/algorithms/rsa-pss.md) |
| ECDSA (ES\*) | [`src/algorithms/ecdsa.md`](src/algorithms/ecdsa.md) |
| EdDSA (Ed25519) | [`src/algorithms/eddsa.md`](src/algorithms/eddsa.md) |
| `alg: "none"` | [`src/algorithms/none.md`](src/algorithms/none.md) |
| JWK → key | [`src/keys/jwk.md`](src/keys/jwk.md) |
| `x5c` chain | [`src/keys/x5c.md`](src/keys/x5c.md) |
| `KeyResolver` | [`src/keys/resolver.md`](src/keys/resolver.md) |
| Verify pipeline | [`src/verify/verify-pipeline.md`](src/verify/verify-pipeline.md) |
| Sign pipeline | [`src/sign/sign-pipeline.md`](src/sign/sign-pipeline.md) |
| Error registry | [`src/errors/error-codes.md`](src/errors/error-codes.md) |
| Schemas | [`src/schemas/schemas.md`](src/schemas/schemas.md) |
| Metrics | [`src/observability/metrics.md`](src/observability/metrics.md) |
## License
MIT.

View File

@@ -0,0 +1,46 @@
# Built-in algorithm seeding
## Purpose
Seeds the default `AlgorithmRegistry` with the 13 built-in algorithm implementations required by RFC 7518 §3 and RFC 8037: `HS256`/`HS384`/`HS512`, `RS256`/`RS384`/`RS512`, `PS256`/`PS384`/`PS512`, `ES256`/`ES384`/`ES512`, `EdDSA`, plus the dual-flag-opt-in `none`. The seeding runs once at module load via a side-effect import from both `algorithms/index.ts` and the package root `index.ts`, so the registry is always populated before any verify or sign call. Idempotent via a module-scoped boolean — safe to call multiple times if a test reload cycles the module graph.
## Features
- Seeds 14 algorithms total (13 RFC-mandated + `none`).
- Idempotent: `seedBuiltinAlgorithms()` is a no-op after the first call.
- Side-effect import from `algorithms/index.ts` and `src/index.ts`.
## Usage: Basic Example
```ts
import { defaultRegistry, seedBuiltinAlgorithms } from "@suaveplan/jws";
// Already seeded by the side-effect import — these are introspection-only:
defaultRegistry.algorithms();
// ["HS256", "HS384", "HS512", "RS256", "RS384", "RS512",
// "PS256", "PS384", "PS512", "ES256", "ES384", "ES512", "EdDSA", "none"]
```
## Usage: Advanced Example
```ts
import { AlgorithmRegistry, seedBuiltinAlgorithms } from "@suaveplan/jws";
// Build an isolated registry for an integration test, seeded identically to
// the default. (Useful when you want to register additional experimental
// algorithms without polluting the global registry.)
const isolated = new AlgorithmRegistry();
// Manually re-register the built-ins:
// (Note: `seedBuiltinAlgorithms` seeds the *default* registry, not an arbitrary
// one; tests needing isolation use `defaultRegistry` directly or build the
// isolated registry up from named imports of `hs256` / `rs256` / etc.)
seedBuiltinAlgorithms(); // idempotent no-op if already seeded
```
## API Reference
- `seedBuiltinAlgorithms(): void` — idempotent; seeds the package-shared `defaultRegistry`.
## Implementation Notes
The side-effect import `import "./builtin.js"` at the top of `algorithms/index.ts` AND `src/index.ts` is load-bearing — without it, the first `compactVerify` call would throw `JWS.ALG_UNSUPPORTED` because the registry would be empty. The dual-import is defence in depth: any consumer importing from either the package root or the `./algorithms` subpath triggers seeding. The implementation guards against double-registration via the idempotency flag rather than via the registry's own duplicate-rejection logic — that way a `seedBuiltinAlgorithms()` call from a test after a registry reset doesn't throw.

View File

@@ -0,0 +1,60 @@
# ECDSA algorithms (`ES256` / `ES384` / `ES512`)
## Purpose
RFC 7518 §3.4 ECDSA signing and verification over NIST P-256, P-384, and P-521. The signing input is hashed with the curve-matched SHA-2 variant, signed via `@suaveplan/crypto-curve`'s curve primitives, and serialised as the fixed-length concatenation `R || S` of the two coordinates. DER-encoded signatures in the JOSE position are rejected with `JWS.SIG_FORMAT_INVALID` — silently parsing them would hide protocol drift between producers, which is the source of multiple JOSE interop CVEs.
## Features
- `ES256` → P-256 + SHA-256, 64-byte `R||S`.
- `ES384` → P-384 + SHA-384, 96-byte `R||S`.
- `ES512` → P-521 + SHA-512, 132-byte `R||S` (R and S are 66 bytes each).
- DER-encoded signature in JOSE position → `JWS.SIG_FORMAT_INVALID`.
- Key shape: raw uncompressed SEC1 public-key bytes (`0x04 || X || Y`) for verify; raw scalar for sign.
## Usage: Basic Example
```ts
import { p256 } from "@suaveplan/crypto-curve";
import { compactSign, compactVerify } from "@suaveplan/jws";
const priv = p256.randomPrivateKey();
const pub = p256.getPublicKey(priv);
const token = await compactSign("hello", priv, "ES256");
await compactVerify(token, pub, { algorithms: ["ES256"] });
```
## Usage: Advanced Example
```ts
import { es256, JwsError } from "@suaveplan/jws";
// Reject DER-encoded signature in JOSE position:
const derSig = new Uint8Array([0x30, 0x46, /* ... */]);
try {
await es256.verify(signingInput, derSig, pub);
} catch (err) {
if (err instanceof JwsError && err.code === "JWS.SIG_FORMAT_INVALID") {
// DER detected by 0x30 lead byte + length mismatch.
}
}
// Wrong-length raw R||S also rejected:
try {
await es256.verify(signingInput, new Uint8Array(63), pub);
} catch (err) {
if (err instanceof JwsError && err.code === "JWS.SIG_FORMAT_INVALID") {
// Generic length mismatch.
}
}
```
## API Reference
- `es256: AlgorithmImpl<Uint8Array>` — verify `(input, sig: 64 bytes, pub)`, sign `(input, priv: 32 bytes)`.
- `es384: AlgorithmImpl<Uint8Array>` — sig 96 bytes, priv 48 bytes.
- `es512: AlgorithmImpl<Uint8Array>` — sig 132 bytes, priv 66 bytes.
## Implementation Notes
Public keys MUST be uncompressed SEC1 (`0x04 || X || Y` of the relevant coordinate length). The verifier delegates to `@suaveplan/crypto-curve`'s `p256.verify` / `p384.verify` / `p521.verify`, which use the Noble curves library with constant-time scalar multiplication. Signing uses RFC 6979 deterministic `k` (no per-signature randomness required), which makes ECDSA JWS production deterministic for a given `(privateKey, payload)` pair — this is a feature for reproducible-build test vectors but means consumers should NOT reuse `k` across messages outside this library. The 132-byte length for ES512 surprises producers expecting 128: P-521 coordinates are 66 bytes each, not 64.

View File

@@ -0,0 +1,52 @@
# EdDSA algorithm
## Purpose
RFC 8037 §3.1 EdDSA over Ed25519. The signing input is signed directly by Ed25519 (no caller-side pre-hash — Ed25519 hashes internally via SHA-512), producing a 64-byte signature. EdDSA is the preferred modern algorithm for greenfield JWS deployments: fast, no `k`-randomness footgun, no salt-length confusion, no signature-format dispute (always raw 64 bytes).
## Features
- `EdDSA` → Ed25519, 64-byte signature.
- Key shape: 32-byte raw `Uint8Array` (priv or pub).
- Ed448 not supported — `@suaveplan/crypto-curve` ships only Ed25519; tokens with `crv: "Ed448"` are rejected at JWK parsing with `JWS.KEY_ALG_MISMATCH`.
## Usage: Basic Example
```ts
import { ed25519 } from "@suaveplan/crypto-curve";
import { compactSign, compactVerify } from "@suaveplan/jws";
const priv = ed25519.randomPrivateKey();
const pub = ed25519.getPublicKey(priv);
const token = await compactSign("hello", priv, "EdDSA");
await compactVerify(token, pub, { algorithms: ["EdDSA"] });
```
## Usage: Advanced Example
```ts
import { eddsa, JwsError } from "@suaveplan/jws";
// Direct impl call:
const sig = await eddsa.sign!(new TextEncoder().encode("hdr.payload"), priv);
const ok = await eddsa.verify(new TextEncoder().encode("hdr.payload"), sig, pub);
// Wrong key type:
try {
await eddsa.verify(input, sig, { kty: "OKP" } as never);
} catch (err) {
if (err instanceof JwsError && err.code === "JWS.KEY_ALG_MISMATCH") {
// EdDSA expects raw 32-byte Uint8Array.
}
}
```
## API Reference
- `eddsa: AlgorithmImpl<Uint8Array>` — verify `(input, sig: 64 bytes, pub: 32 bytes)`, sign `(input, priv: 32 bytes)`.
`verify` returns `Promise<boolean>` (`false` for any malformed signature — never throws); `sign` returns `Promise<Uint8Array>` of length 64.
## Implementation Notes
Ed25519 verification is exception-safe: `@suaveplan/crypto-curve.ed25519.verify` wraps Noble's verify in a `try/catch` that returns `false` on any signature-decoding error, so a malformed 64-byte input surfaces as `JWS.SIG_INVALID` at the pipeline level rather than a low-level decoding exception. Per RFC 8037 §3.1 the `alg: "EdDSA"` parameter is overloaded for both Ed25519 and Ed448 — disambiguation requires inspecting the `header.jwk.crv` value (or, for `x5c`, the leaf SPKI algorithm OID). This package currently only implements Ed25519; an Ed448 token routes the same `EdDSA` alg through the JWK layer, which catches the `crv: "Ed448"` and throws `KEY_ALG_MISMATCH` — extending to Ed448 only requires adding an Ed448 primitive to `@suaveplan/crypto-curve` and branching this impl on `header.jwk.crv`.

View File

@@ -0,0 +1,54 @@
# HMAC algorithms (`HS256` / `HS384` / `HS512`)
## Purpose
RFC 7518 §3.2 HMAC-with-SHA-2 signing and verification. The signing input is HMAC'd with the caller-supplied symmetric key; the verifier recomputes the tag and compares in constant time. Constant-time compare via `@suaveplan/crypto-utils.constantTimeEqual` is mandatory — `Buffer.compare`, `===`, and any short-circuiting comparison leak the tag's prefix to a timing adversary, and that's been the root cause of multiple practical key-recovery attacks against permissive JOSE libraries.
## Features
- `HS256` → HMAC-SHA-256, 32-byte tag.
- `HS384` → HMAC-SHA-384, 48-byte tag.
- `HS512` → HMAC-SHA-512, 64-byte tag.
- Constant-time tag comparison.
- Key shape: `Uint8Array` (the raw HMAC key); mismatch → `JWS.KEY_ALG_MISMATCH`.
## Usage: Basic Example
```ts
import { compactSign, compactVerify } from "@suaveplan/jws";
const secret = new TextEncoder().encode("a-shared-secret-of-at-least-32-bytes");
const token = await compactSign("hello", secret, "HS256");
const r = await compactVerify(token, secret, { algorithms: ["HS256"] });
// r.payloadString === "hello"
```
## Usage: Advanced Example
```ts
import { compactVerify, JwsError, hs256 } from "@suaveplan/jws";
// Direct algorithm impl access (advanced — verify pipelines should be your
// default).
const signingInput = new TextEncoder().encode("hdr.payload");
const sig = await hs256.sign!(signingInput, secret);
const ok = await hs256.verify(signingInput, sig, secret);
// ok === true
// Tampered tag:
sig[sig.length - 1] ^= 0x01;
const stillOk = await hs256.verify(signingInput, sig, secret);
// stillOk === false (constant-time compare returned false)
```
## API Reference
- `hs256: AlgorithmImpl<Uint8Array>` — `{ alg: "HS256", verify, sign }`
- `hs384: AlgorithmImpl<Uint8Array>` — same shape.
- `hs512: AlgorithmImpl<Uint8Array>` — same shape.
Each `verify(signingInput, signature, key)` returns `Promise<boolean>`; each `sign(signingInput, key)` returns `Promise<Uint8Array>`. Non-`Uint8Array` keys throw `JwsError(JWS.KEY_ALG_MISMATCH)` synchronously.
## Implementation Notes
HMAC is implemented over `@suaveplan/crypto-hash.hmac`, which routes through the package's primitive-selector layer (Noble JS by default; native via the build-time `crypto.config.ts` switch). HMAC keys are required to be `Uint8Array`; passing the raw shared-secret string is a common footgun (its UTF-8 encoding is the actual key, not the string itself), and surfacing a typed `KEY_ALG_MISMATCH` is friendlier than silently HMAC-ing the wrong bytes. Per RFC 7518 §3.2 production deployments SHOULD use keys ≥ the hash output length (32/48/64 bytes); this package does not enforce a minimum because some test vectors use shorter keys, but consumers SHOULD layer that check at their boundary.

View File

@@ -0,0 +1,59 @@
# `none` algorithm
## Purpose
RFC 7518 §3.6 `"none"` algorithm. The `none` value declares "no integrity protection," which is virtually never what a verifier wants but is occasionally needed for non-security-critical contexts (signed-by-policy local development, deliberately-unsigned debugging payloads). This implementation exists only so the registry is complete; the verify pipeline rejects `none` by default and requires BOTH `algorithms: ["none"]` AND `acceptNone: true` to opt in, neutralising the classic "alg: none" downgrade attack.
## Features
- `verify` returns `true` if-and-only-if the signature segment is empty (zero bytes after base64url-decode).
- `sign` always emits empty signature bytes.
- Default rejection: pipeline throws `JWS.ALG_NONE_DISABLED` unless both flags are set.
## Usage: Basic Example
```ts
import { compactSign, compactVerify } from "@suaveplan/jws";
const token = await compactSign("hello", undefined, "none");
// token has the form "<header>.<payload>." — empty signature segment.
await compactVerify(token, undefined, {
algorithms: ["none"],
acceptNone: true,
});
```
## Usage: Advanced Example
```ts
import { compactVerify, JwsError } from "@suaveplan/jws";
const noneToken = "eyJhbGciOiJub25lIn0.eyJzdWIiOjF9.";
// Default: rejected.
try {
await compactVerify(noneToken, undefined, { algorithms: ["HS256"] });
} catch (err) {
if (err instanceof JwsError && err.code === "JWS.ALG_NONE_DISABLED") {
// CVE-class defence in depth.
}
}
// Allowlisted but no acceptNone — also rejected.
try {
await compactVerify(noneToken, undefined, { algorithms: ["none"] });
} catch (err) {
if (err instanceof JwsError && err.code === "JWS.ALG_NONE_DISABLED") {
// Two flags required.
}
}
```
## API Reference
- `none: AlgorithmImpl<undefined>` — verify `(input, sig)` returns `Promise<boolean>` (`true` iff `sig.length === 0`); sign `(input, _)` returns `Promise<Uint8Array>` of length 0.
## Implementation Notes
The two-flag opt-in design is a deliberate defence against the historic JWT-CVE class where a caller passes `algorithms: ["HS256"]` AND a public key, then an attacker mints a token with `alg: "none"` and the library accepts it because the allowlist is parsed permissively. Here, even a deliberate `algorithms: ["none"]` requires the second flag `acceptNone: true` — the second flag is intentionally separate so a single typo or merged configuration cannot accidentally accept unsigned tokens. The implementation also requires the signature segment to be empty, not "not present" — a token like `"<hdr>.<payload>.deadbeef"` with `alg: "none"` is rejected because the signature bytes are non-zero.

View File

@@ -0,0 +1,59 @@
# `AlgorithmRegistry`
## Purpose
In-package mapping from `alg` identifier to the concrete `AlgorithmImpl` that knows how to verify and (optionally) sign. The registry is seeded at module load with the 13 built-in algorithms required by RFC 7518 §3 and RFC 8037 (`HS256/384/512`, `RS256/384/512`, `PS256/384/512`, `ES256/384/512`, `EdDSA`, `none`). Downstream packages register experimental algorithms (e.g. ML-DSA, secp256k1) via `AlgorithmRegistry.register` without forking this package.
## Features
- `defaultRegistry` — the package-wide seeded registry consumed by the verify/sign pipelines.
- `register(alg, impl)` — add an algorithm; duplicate registration throws `JWS.ALG_UNSUPPORTED`.
- `lookup(alg)` — returns the impl or `undefined`.
- `lookupOrThrow(alg)` — returns the impl or throws `JWS.ALG_UNSUPPORTED`.
- `has(alg)` / `algorithms()` — introspection helpers.
## Usage: Basic Example
```ts
import { defaultRegistry } from "@suaveplan/jws";
defaultRegistry.has("RS256"); // true
defaultRegistry.has("ML-DSA-44"); // false
```
## Usage: Advanced Example
```ts
import {
AlgorithmRegistry,
type AlgorithmImpl,
defaultRegistry,
} from "@suaveplan/jws";
const mlDsa44: AlgorithmImpl<Uint8Array> = {
alg: "ML-DSA-44",
verify: async (signingInput, sig, pub) => myMlDsaImpl.verify(pub, sig, signingInput),
sign: async (signingInput, priv) => myMlDsaImpl.sign(priv, signingInput),
};
defaultRegistry.register("ML-DSA-44", mlDsa44);
// Or build an isolated registry for an integration test:
const isolated = new AlgorithmRegistry();
isolated.register("HS256", myHsImpl);
```
## API Reference
- `class AlgorithmRegistry`
- `register<K>(alg: string, impl: AlgorithmImpl<K>): void`
- `lookup(alg: string): AlgorithmImpl<unknown> | undefined`
- `lookupOrThrow(alg: string): AlgorithmImpl<unknown>`
- `has(alg: string): boolean`
- `algorithms(): readonly string[]`
- `const defaultRegistry: AlgorithmRegistry`
- `interface AlgorithmImpl<K = unknown> { alg: string; verify(signingInput, signature, key: K): Promise<boolean>; sign?(signingInput, key: K): Promise<Uint8Array> }`
- `seedBuiltinAlgorithms(): void` — idempotent seeding (already invoked at module load).
## Implementation Notes
Built-ins are seeded by the side-effect import `import "./builtin.js"` at the root of `algorithms/index.ts` AND `src/index.ts`. Idempotent via a module-scoped boolean so the registry survives test reload cycles. Registering an algorithm twice throws rather than silently overwriting, because silent overwrite would be an algorithm-confusion vector — a malicious downstream package could redefine `RS256` to delegate to an attacker-controlled impl. The registry is mutable but only via `register`; there is no `unregister` for the same reason.

View File

@@ -0,0 +1,57 @@
# RSASSA-PSS algorithms (`PS256` / `PS384` / `PS512`)
## Purpose
RFC 7518 §3.5 RSASSA-PSS signing and verification. The signing input is hashed, EMSA-PSS-encoded with a random salt, and signed via `@suaveplan/crypto-rsa.rsaSignPss`. The salt length is fixed at the hash output length per RFC 7518 §3.5 ("Salt Length shall be equal to the size of the hash output") — externally-produced signatures with non-matching salt lengths simply fail verify rather than being silently accepted, which catches producers that defaulted to a different PSS profile.
## Features
- `PS256` → RSASSA-PSS + SHA-256 + 32-byte salt.
- `PS384` → RSASSA-PSS + SHA-384 + 48-byte salt.
- `PS512` → RSASSA-PSS + SHA-512 + 64-byte salt.
- MGF1 hash matches the message hash (RFC 7518 §3.5 default).
- Delegates to `@suaveplan/crypto-rsa.rsaSignPss` / `rsaVerifyPss`.
## Usage: Basic Example
```ts
import { generateRsaKeyPair } from "@suaveplan/crypto-rsa";
import { compactSign, compactVerify } from "@suaveplan/jws";
const { publicKey, privateKey } = await generateRsaKeyPair({ modulusLength: 2048 });
const token = await compactSign("hello", privateKey, "PS256");
await compactVerify(token, publicKey, { algorithms: ["PS256"] });
```
## Usage: Advanced Example
```ts
import { ps256 } from "@suaveplan/jws";
// Signature produced with a 16-byte salt instead of 32:
const externalSig = produceFromBadProducer(message, priv, /*saltLen*/ 16);
const ok = await ps256.verify(new TextEncoder().encode("hdr.payload"), externalSig, pub);
// ok === false — salt length mismatch caught by EMSA-PSS-Verify.
// Algorithm-confusion defence: passing an HMAC byte-key throws.
import { JwsError } from "@suaveplan/jws";
try {
await ps256.verify(input, sig, new Uint8Array(32));
} catch (err) {
if (err instanceof JwsError && err.code === "JWS.KEY_ALG_MISMATCH") {
// PSS verify expects an RsaPublicKey, not a raw byte buffer.
}
}
```
## API Reference
- `ps256: AlgorithmImpl<RsaPublicKey | RsaPrivateKey>`
- `ps384: AlgorithmImpl<RsaPublicKey | RsaPrivateKey>`
- `ps512: AlgorithmImpl<RsaPublicKey | RsaPrivateKey>`
`verify` returns `Promise<boolean>`; `sign` returns `Promise<Uint8Array>` whose length matches the modulus (256 bytes for 2048-bit keys).
## Implementation Notes
PSS salt is drawn by `@suaveplan/crypto-rsa.rsaSignPss` via either the caller's optional `Rng` injection or the default RNG (`crypto.getRandomValues`). This means PSS signatures are probabilistic — the same payload signed twice produces two different tokens; tests that need determinism inject a deterministic `Rng`. Verifying with a fixed salt length and rejecting mismatched-salt signatures is a deliberate departure from libraries that auto-detect salt length from the signature length: that auto-detection is the source of many JWT-PSS interop bugs because the auto-detect heuristic disagrees on edge-case modulus sizes.

View File

@@ -0,0 +1,53 @@
# RSASSA-PKCS1-v1_5 algorithms (`RS256` / `RS384` / `RS512`)
## Purpose
RFC 7518 §3.3 RSASSA-PKCS1-v1_5 signing and verification. The signing input is hashed with SHA-256/SHA-384/SHA-512 respectively, EMSA-PKCS1-v1_5-encoded to the modulus length, and signed via the modular-exponentiation primitive from `@suaveplan/crypto-rsa`. This is the dominant JWS algorithm in production (OAuth/OIDC JWT signing, SafetyNet, Apple App Attest, GitHub workflow tokens) and the algorithm `webauthn` is unblocked on.
## Features
- `RS256` → RSASSA-PKCS1-v1_5 + SHA-256.
- `RS384` → RSASSA-PKCS1-v1_5 + SHA-384.
- `RS512` → RSASSA-PKCS1-v1_5 + SHA-512.
- Delegates to `@suaveplan/crypto-rsa`'s `rsaVerifyPkcs1v15` / `rsaSignPkcs1v15`.
- Key shape: `RsaPublicKey` (verify) / `RsaPrivateKey` (sign) — both from `@suaveplan/crypto-rsa`.
## Usage: Basic Example
```ts
import { generateRsaKeyPair, parseRsaPublicKeyJwk } from "@suaveplan/crypto-rsa";
import { compactSign, compactVerify } from "@suaveplan/jws";
const { publicKey, privateKey } = await generateRsaKeyPair({ modulusLength: 2048 });
const token = await compactSign("hello", privateKey, "RS256");
const r = await compactVerify(token, publicKey, { algorithms: ["RS256"] });
```
## Usage: Advanced Example
```ts
import { parseRsaPublicKey } from "@suaveplan/crypto-rsa";
import { compactVerify, rs256 } from "@suaveplan/jws";
// Verify a JWS whose key arrived as a DER-encoded SPKI from a registry:
const spkiDer = await fetch("/.well-known/jwks-spki.der").then((r) => r.bytes());
const pub = parseRsaPublicKey(spkiDer, { format: "spki" });
const result = await compactVerify(token, pub, {
algorithms: ["RS256", "RS384"],
});
// Direct-impl access (e.g. signing custom binary payloads):
const sig = await rs256.sign!(signingInput, priv);
```
## API Reference
- `rs256: AlgorithmImpl<RsaPublicKey | RsaPrivateKey>`
- `rs384: AlgorithmImpl<RsaPublicKey | RsaPrivateKey>`
- `rs512: AlgorithmImpl<RsaPublicKey | RsaPrivateKey>`
Each `verify` returns `Promise<boolean>`; each `sign` returns `Promise<Uint8Array>` of length `Math.ceil(bitLength/8)`. Non-RSA-shaped keys throw `JwsError(JWS.KEY_ALG_MISMATCH)`.
## Implementation Notes
This module is a thin façade over `@suaveplan/crypto-rsa` — it never reimplements PKCS#1 v2.2 or modular arithmetic. Verification swallows any low-level `CryptoRsaError` and returns `false` so structural malformation surfaces as `JWS.SIG_INVALID` at the pipeline level rather than leaking RSA-package-internal codes; signing propagates because a sign-side error means the caller's key is structurally wrong (modulus too short, missing CRT parameters, etc.) and they need the precise diagnostic. RSA private operations in `@suaveplan/crypto-rsa` run through CRT and apply RSA blinding (Kocher 1996 timing-attack defence) by default.

View File

@@ -0,0 +1,59 @@
# `base64url`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
RFC 7515 Appendix C base64url codec. Every JWS segment (protected header, payload, signature) is base64url-encoded with no padding and the URL-safe alphabet (`-`/`_` in place of `+`/`/`); this module owns the byte-level encode/decode contract that every other module in the package builds on. The codec is strict — padding (`=`), standard-alphabet bytes, whitespace, and any byte outside the URL alphabet all surface as `JwsError(JWS.BASE64URL_INVALID)` rather than degrading into a silent shift in the decoded output. Strictness is load-bearing because lenient codecs in popular JOSE libraries have historically masked malformed-token vulnerabilities by quietly accepting tokens that violate the framing rules.
## Features
- `base64urlEncode(bytes)` — encode `Uint8Array` to URL-safe base64.
- `base64urlDecode(input)` — strict decode that rejects padding, std-alphabet, whitespace, and partial-byte length residues.
- `base64urlEncodeString(value)` — encode a UTF-8 string in one call.
- `base64urlDecodeJson(input)` — decode + `JSON.parse` + object-shape check for header parsing.
- Tail-bit validation: trailing bits in the partial last group must be zero.
## Usage: Basic Example
```ts
import { base64urlDecode, base64urlEncode } from "@suaveplan/jws";
const enc = base64urlEncode(new Uint8Array([0xde, 0xad, 0xbe, 0xef]));
// "3q2-7w"
const dec = base64urlDecode("3q2-7w");
// Uint8Array [0xde, 0xad, 0xbe, 0xef]
```
## Usage: Advanced Example
```ts
import { base64urlDecodeJson, JwsError } from "@suaveplan/jws";
try {
const header = base64urlDecodeJson("eyJhbGciOiJIUzI1NiJ9");
// { alg: "HS256" }
} catch (err) {
if (err instanceof JwsError && err.code === "JWS.BASE64URL_INVALID") {
// Decoder rejected padding / std-alphabet / non-URL-alphabet character.
}
if (err instanceof JwsError && err.code === "JWS.HEADER_MALFORMED") {
// Decoded bytes were not a JSON object.
}
}
```
## API Reference
- `base64urlEncode(bytes: Uint8Array): string`
- `base64urlDecode(input: string): Uint8Array` — throws `JwsError(JWS.BASE64URL_INVALID)`.
- `base64urlEncodeString(value: string): string`
- `base64urlDecodeJson(input: string): Record<string, unknown>` — throws `JwsError(JWS.HEADER_MALFORMED)` when bytes are not a JSON object.
- `BASE64URL_ALPHABET: string`
- `BASE64_STD_ALPHABET: string`
## Implementation Notes
The decoder builds an `Int8Array` lookup table at module init so per-byte dispatch is a single array index. The first pass over the input rejects every invalid byte before allocating the output buffer; this means malformed input fails fast with bounded work rather than producing garbage bytes that the caller has to validate downstream. Tail-bit checks (`(c1 & 0x0f) !== 0` for 2-byte groups, `(c2 & 0x03) !== 0` for 3-byte groups) catch base64 encoders that emit non-canonical encodings — these are equivalent to padding errors and would break byte-identical round-trips against the RFC 7515 Appendix A vectors if accepted.

View File

@@ -0,0 +1,65 @@
# Detached payload (RFC 7515 §A.5)
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
RFC 7515 §A.5 / Appendix F detached form. The payload travels separately from the JWS — the compact-form token has an empty middle segment (`<header>..<sig>`) and the verifier supplies the payload bytes out-of-band via `opts.detachedPayload`. This is the canonical pattern when the payload is large (avoid double-encoding) or already lives somewhere addressable (file body, HTTP body, blob storage).
## Features
- `detachedSign(payload, key, alg, opts?)` — emits a compact token with empty middle segment.
- `detachedVerify(token, key, opts)` — sugar over `compactVerify` with `opts.detachedPayload`.
- Missing `detachedPayload` at verify time → `JWS.INVALID_OPTIONS`.
- Compatible with `b64: false` — raw-byte payloads also work in detached form.
## Usage: Basic Example
```ts
import { detachedSign, detachedVerify } from "@suaveplan/jws";
const token = await detachedSign("hello", secret, "HS256");
// token === "<header>..<sig>"
const result = await detachedVerify(token, secret, {
algorithms: ["HS256"],
detachedPayload: "hello",
});
```
## Usage: Advanced Example
```ts
import { compactVerify, detachedSign, JwsError } from "@suaveplan/jws";
// Sign a large blob without embedding it in the token:
const blob = await fetch("/big.bin").then((r) => r.bytes());
const token = await detachedSign(blob, key, "ES256");
// Later, given the token + blob (e.g. as separate Content-* headers):
const verified = await compactVerify(token, pub, {
algorithms: ["ES256"],
detachedPayload: blob, // Uint8Array also accepted
});
// Forgot to pass detachedPayload?
try {
await compactVerify(token, pub, { algorithms: ["ES256"] });
} catch (err) {
if (err instanceof JwsError && err.code === "JWS.INVALID_OPTIONS") {
// Empty middle segment without out-of-band payload supplied.
}
}
```
## API Reference
- `detachedSign(payload, key, alg, opts?: Omit<SignOptions, "detached">): Promise<string>`
- `detachedVerify(token, key, opts: VerifyOptions): Promise<VerifyResult>`
`detachedSign` is sugar for `compactSign(..., { ...opts, detached: true })`. `detachedVerify` is identity-forward to `compactVerify` — the actual logic lives in the verify pipeline.
## Implementation Notes
The verify pipeline detects detached tokens by an empty middle segment + `opts.detachedPayload !== undefined`. When detected, the payload is re-base64url-encoded (or stringified for `b64: false`) before computing the signing input, so the on-wire signing input bytes match what the signer would have produced. This means the same `compactVerify` entry point handles attached AND detached tokens — `detachedVerify` is purely an ergonomic alias.

View File

@@ -0,0 +1,71 @@
# `error-codes`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Frozen registry of the 18 typed error codes raised by `@suaveplan/jws`. Each code maps to a distinct RFC 7515 §4.1 / §5.2 / §7 failure class (or to one of the boundary refusals layered on top by the suaveplan conventions: Zod option validation, AbortSignal honouring, alg-confusion defence). The registry is the single source of truth — `JwsErrorCode` is pinned to the registry literal via `expectTypeOf`, and every code is thrown by at least one production code path so callers can branch on `error.code` without worrying about dead codes leaking into the public type.
## Features
- `JWS_CODES` — the 18-entry frozen registry from `@suaveplan/error.defineCodes`.
- `JwsErrorCode` — literal-union derived from `JWS_CODES`.
- `JwsError` — concrete `SuaveplanError<JwsErrorCode>` subclass; every public-API error thrown by this package is a `JwsError`.
## Usage: Basic Example
```ts
import { compactVerify, JwsError } from "@suaveplan/jws";
try {
await compactVerify(token, key, { algorithms: ["RS256"] });
} catch (err) {
if (err instanceof JwsError && err.code === "JWS.ALG_NOT_ALLOWED") {
// Token presented an alg outside the caller's allowlist.
}
}
```
## Usage: Advanced Example
```ts
import { JWS_CODES, JwsError, type JwsErrorCode } from "@suaveplan/jws";
function classify(err: unknown): "client" | "server" | "neutral" {
if (!(err instanceof JwsError)) return "neutral";
const code: JwsErrorCode = err.code;
switch (code) {
case JWS_CODES.MALFORMED:
case JWS_CODES.HEADER_MALFORMED:
case JWS_CODES.BASE64URL_INVALID:
case JWS_CODES.SIG_INVALID:
case JWS_CODES.SIG_FORMAT_INVALID:
case JWS_CODES.JWK_INVALID:
case JWS_CODES.X5C_CHAIN_INVALID:
case JWS_CODES.CRIT_UNRECOGNIZED:
case JWS_CODES.CRIT_MALFORMED:
case JWS_CODES.B64_INVALID:
case JWS_CODES.MULTI_SIG_FAILED:
return "client";
case JWS_CODES.ALG_UNSUPPORTED:
case JWS_CODES.ALG_NOT_ALLOWED:
case JWS_CODES.ALG_NONE_DISABLED:
case JWS_CODES.KEY_NOT_FOUND:
case JWS_CODES.KEY_ALG_MISMATCH:
case JWS_CODES.INVALID_OPTIONS:
case JWS_CODES.ABORTED:
return "server";
}
}
```
## API Reference
- `JWS_CODES: { readonly [Key in NamedKey]:`JWS.${Key}`}` — the registry.
- `JwsErrorCode = (typeof JWS_CODES)[keyof typeof JWS_CODES]` — `"JWS.MALFORMED" | "JWS.HEADER_MALFORMED" | ...`.
- `class JwsError extends SuaveplanError<JwsErrorCode>` — `new JwsError(code, messageKey, options?)`.
## Implementation Notes
The registry uses `defineCodes("JWS", [...] as const)` from `@suaveplan/error`, which prefixes each entry with `JWS.` so codes never collide with sibling packages (`X509.*`, `CRYPTO_RSA.*`, etc.). The `as const` is load-bearing: it pins the literal-string type so `JwsErrorCode` is a precise union and not a bare `string`. Adding a new code requires updating the registry, the `expectTypeOf` assertion in the test suite, AND a production code path that throws it — the conformance gate fails otherwise, preventing "dead" codes from accreting in the registry.

View File

@@ -0,0 +1,59 @@
# `b64` (RFC 7797) header
## Purpose
RFC 7797 §3 implements the `b64` header parameter, allowing JWS producers to embed unencoded payloads directly into the signing input. When `b64: false`, the second compact-form segment is the raw payload bytes themselves (not base64url-encoded) and the signing input becomes `BASE64URL(protected) || "." || rawPayloadBytes`. The RFC's MUST clauses require that any `b64: false` header also list `"b64"` in `crit` so verifiers ignorant of RFC 7797 refuse the token rather than misinterpret it.
## Features
- `resolveB64(header)` — returns the effective boolean (`true` when absent).
- `b64: false` without `"b64"` in `crit` → `JWS.B64_INVALID`.
- Non-boolean `b64` value → `JWS.B64_INVALID`.
- Absent `b64` defaults to base64url encoding (RFC 7515 §3.1 default).
## Usage: Basic Example
```ts
import { resolveB64 } from "@suaveplan/jws";
resolveB64({ alg: "HS256" }); // true (default)
resolveB64({ alg: "HS256", b64: true }); // true
```
## Usage: Advanced Example
```ts
import { compactSign, compactVerify, resolveB64, JwsError } from "@suaveplan/jws";
// Produce a b64: false token (raw-payload signing input).
const token = await compactSign("hello", key, "HS256", {
protectedHeader: { b64: false, crit: ["b64"] },
b64: false,
});
const result = await compactVerify(token, key, {
algorithms: ["HS256"],
acceptableCriticalHeaders: ["b64"],
});
// result.payloadString === "hello" — raw, not base64url-decoded
// Missing crit listing — verifier rejects.
try {
resolveB64({ alg: "HS256", b64: false });
} catch (err) {
if (err instanceof JwsError && err.code === "JWS.B64_INVALID") {
// RFC 7797 §3 MUST violation.
}
}
```
## API Reference
- `resolveB64(header: JoseHeader): boolean`
- Returns `true` when `b64` is absent or `b64: true`.
- Returns `false` only when `b64: false` AND `crit` includes `"b64"`.
- Throws `JwsError(JWS.B64_INVALID)` for non-boolean values or `false` without `crit`.
## Implementation Notes
The RFC 7797 contract is asymmetric: producers may emit `b64: true` without `crit` (default behaviour), but `b64: false` MUST be marked critical. This module enforces only the verifier side; the sign pipeline emits the `crit: ["b64"]` companion automatically when callers pass `b64: false` so producers do not have to remember the dual-write. Raw-payload signing input requires special handling — the payload bytes cannot pass through `TextEncoder` when the producer's payload contains the `.` byte (0x2e), since that's also the segment separator; the compact-form parser handles this by treating the segment as bytes once `b64: false` is resolved, not as a UTF-8 string.

View File

@@ -0,0 +1,57 @@
# `crit` enforcement
## Purpose
RFC 7515 §4.1.11 enforcement: every entry in `header.crit` MUST be in the caller's `acceptableCriticalHeaders` allowlist. The RFC's intent is that callers refuse to verify any token whose protected header carries critical parameters they do not understand — otherwise the producer could downgrade security guarantees (e.g. an `exp` claim becoming critical against a verifier that ignores it). This module owns that gate.
## Features
- Empty `crit` array rejected.
- Non-array `crit` rejected.
- Non-string entry rejected.
- `crit` referencing itself rejected (RFC 7515 §4.1.11 MUST).
- Entry not in caller's allowlist → `JWS.CRIT_UNRECOGNIZED`.
- Entry in allowlist accepted.
## Usage: Basic Example
```ts
import { enforceCrit, parseHeader } from "@suaveplan/jws";
const header = parseHeader("eyJhbGciOiJIUzI1NiIsImNyaXQiOlsiZXhwIl0sImV4cCI6MTYwfQ");
enforceCrit(header, ["exp"]); // does not throw
```
## Usage: Advanced Example
```ts
import { enforceCrit, JwsError } from "@suaveplan/jws";
try {
enforceCrit({ alg: "HS256", crit: ["exp", "tenant"] }, ["exp"]);
} catch (err) {
if (err instanceof JwsError && err.code === "JWS.CRIT_UNRECOGNIZED") {
// Reject the token: a critical parameter is outside the allowlist.
}
}
// Self-reference rejected:
try {
enforceCrit({ alg: "HS256", crit: ["crit"] }, ["crit"]);
} catch (err) {
if (err instanceof JwsError && err.code === "JWS.CRIT_MALFORMED") {
// RFC 7515 §4.1.11 MUST.
}
}
```
## API Reference
- `enforceCrit(header: JoseHeader, acceptableCriticalHeaders: readonly string[] | undefined): void`
- No-op when `header.crit` is absent.
- Throws `JwsError(JWS.CRIT_MALFORMED)` for shape failures (non-array, empty, non-string entry, self-reference).
- Throws `JwsError(JWS.CRIT_UNRECOGNIZED)` for entries not on the allowlist.
## Implementation Notes
The allowlist is supplied at every verify call rather than configured globally; this is deliberate because the allowlist is fundamentally a property of the consumer's threat model, not the JWS library. Callers integrating an RFC 7797 `b64: false` token MUST add `"b64"` to their `acceptableCriticalHeaders` even though this package implements the RFC 7797 spec internally — the gate is "do you know what to do with this parameter," not "does the library implement it." This makes it impossible to accidentally accept a `b64: false` token through a verifier that wasn't expecting one.

View File

@@ -0,0 +1,57 @@
# `header`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Parse and serialise RFC 7515 §4.1 JOSE protected headers. Every registered parameter (`alg`, `jku`, `jwk`, `kid`, `x5u`, `x5c`, `x5t`, `x5t#S256`, `typ`, `cty`, `crit`, plus RFC 7797 `b64`) is shape-validated through the Zod schema; non-registered parameters survive round-trip via the schema's catch-all. The `alg` field is the only mandatory parameter; everything else is optional and the decoder preserves bytes verbatim so callers can re-emit the header without canonicalisation surprises.
## Features
- `parseHeader(base64urlString | object)` — base64url-decode + JSON-parse + Zod-shape-check; returns a typed `JoseHeader`.
- `serializeHeader(header)` — `JSON.stringify` + base64url-encode in one call.
- `JoseHeader` — strongly-typed shape (every registered parameter).
- Unknown parameters survive round-trip.
## Usage: Basic Example
```ts
import { parseHeader, serializeHeader } from "@suaveplan/jws";
const decoded = parseHeader("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9");
// { alg: "HS256", typ: "JWT" }
const reencoded = serializeHeader(decoded);
```
## Usage: Advanced Example
```ts
import { JwsError, parseHeader } from "@suaveplan/jws";
try {
parseHeader("eyJhbGciOjQyfQ"); // alg as number
} catch (err) {
if (err instanceof JwsError && err.code === "JWS.HEADER_MALFORMED") {
// The Zod boundary refused a non-string alg.
}
}
// Custom parameter survives round-trip:
const h = parseHeader({ alg: "ES256", custom: { tenant: "acme" } });
console.log(h["custom"]); // { tenant: "acme" }
```
## API Reference
- `parseHeader(input: string | Record<string, unknown>): JoseHeader`
- Decodes a base64url-encoded JSON object header, OR validates an already-parsed object.
- Throws `JwsError(JWS.HEADER_MALFORMED)` on shape failure.
- Throws `JwsError(JWS.BASE64URL_INVALID)` on base64url failure.
- `serializeHeader(header: JoseHeader | Record<string, unknown>): string`
- `interface JoseHeader` — every RFC 7515 §4.1 registered parameter, all readonly.
## Implementation Notes
The schema is `.passthrough()` so consumers retain non-registered parameters — required for downstream RFCs (RFC 7519 JWT, RFC 9202 EAT-JWT, custom tenant claims) that layer additional parameters into the same header structure. Encoding always uses `JSON.stringify` with no replacer, which means insertion order is preserved; callers needing canonical-order headers (e.g. for byte-identical reproduction of an upstream signer's output) should sort their object keys before passing in. The decoder uses a strict UTF-8 `TextDecoder` so malformed UTF-8 sequences fail rather than silently inserting replacement characters into the parsed JSON — a deliberate strictness that catches producers that emit windows-1252 or latin-1 bytes through a misconfigured pipeline.

View File

@@ -0,0 +1,66 @@
# `jwkToCryptoKey`
## Purpose
Parse an RFC 7517 §4 / RFC 7518 §6 JSON Web Key into the typed key shape expected by the JWS algorithm impl for the declared `alg`. `kty` / `crv` / coordinate lengths are validated against the declared algorithm; mismatch throws `JWS.JWK_INVALID` for shape issues or `JWS.KEY_ALG_MISMATCH` for valid-shape-but-wrong-algorithm cases.
## Features
- RSA — `kty: "RSA"` → `RsaPublicKey` via `@suaveplan/crypto-rsa.parseRsaPublicKeyJwk`.
- EC — `kty: "EC"` with `crv: "P-256" | "P-384" | "P-521"` → uncompressed SEC1 bytes for the JWS ECDSA impl.
- OKP — `kty: "OKP"` with `crv: "Ed25519"` → 32-byte raw key for `EdDSA`.
- Symmetric — `kty: "oct"` with `k` → `Uint8Array` for `HS256/384/512`.
- Coordinate length validation per curve.
- Ed448 explicitly rejected with `KEY_ALG_MISMATCH`.
## Usage: Basic Example
```ts
import { jwkToCryptoKey } from "@suaveplan/jws";
const parsed = jwkToCryptoKey(
{
kty: "EC",
crv: "P-256",
x: "f83OJ3D2xF1Bg8vub9tLe1gHMzV76e8Tus9uPHvRVEU",
y: "x_FEzRu9m36HLN_tue659LNpXW6pCyStikYjKIWI5a0",
},
"ES256"
);
// parsed.kind === "ec"; parsed.key is uncompressed SEC1.
```
## Usage: Advanced Example
```ts
import { JwsError, jwkToCryptoKey } from "@suaveplan/jws";
// Algorithm-confusion defence: RSA JWK declared for an ES256 token rejected.
try {
jwkToCryptoKey({ kty: "RSA", n: "...", e: "AQAB" }, "ES256");
} catch (err) {
if (err instanceof JwsError && err.code === "JWS.JWK_INVALID") {
// kty: RSA vs alg: ES256 mismatch.
}
}
// Ed448 rejected:
try {
jwkToCryptoKey({ kty: "OKP", crv: "Ed448", x: "..." }, "EdDSA");
} catch (err) {
if (err instanceof JwsError && err.code === "JWS.KEY_ALG_MISMATCH") {
// Only Ed25519 supported today.
}
}
```
## API Reference
- `jwkToCryptoKey(jwk: Record<string, unknown>, alg: string): ParsedKey`
- `ParsedKey = { kind: "rsa", key: RsaPublicKey } | { kind: "ec", key: Uint8Array } | { kind: "okp", key: Uint8Array }`
- Throws `JwsError(JWS.JWK_INVALID)` for shape issues.
- Throws `JwsError(JWS.KEY_ALG_MISMATCH)` for valid-shape-but-wrong-curve cases.
## Implementation Notes
Coordinate lengths (32/48/66 bytes for P-256/P-384/P-521) are strictly enforced. The P-521 curve uses 66-byte coordinates because the curve order is 521 bits (66 bytes), not the often-assumed 64; producers that left-pad to 64 fail validation here, which is correct — those tokens are malformed per RFC 7518 §6.2.1.2 even though many lenient libraries accept them. RSA keys route through `@suaveplan/crypto-rsa.parseRsaPublicKeyJwk` so the same key-policy checks (min/max modulus length, validated public exponent, malformed-base64url rejection) apply uniformly across the project.

View File

@@ -0,0 +1,59 @@
# `KeyResolver`
## Purpose
Caller-supplied key-resolution function consulted when `header.kid` is present and no key has been supplied via `opts.key`, `header.jwk`, or `header.x5c`. The resolver receives the parsed header so dispatch can branch on additional parameters (`iss`, custom tenant headers, environment-specific kid namespaces). Returning `undefined` surfaces as `JwsError(JWS.KEY_NOT_FOUND)`; throwing propagates verbatim so callers can map their own error taxonomy onto JWS verification.
## Features
- Async or sync — both signatures are accepted.
- Receives `(kid, header)`; the header is the parsed `JoseHeader`, including unknown parameters.
- `undefined` / `null` return → typed `JWS.KEY_NOT_FOUND` thrown by the pipeline.
## Usage: Basic Example
```ts
import { compactVerify, type KeyResolver } from "@suaveplan/jws";
const keyResolver: KeyResolver = async (kid) => keyStore.get(kid);
await compactVerify(token, undefined, {
algorithms: ["RS256"],
keyResolver,
});
```
## Usage: Advanced Example
```ts
import { compactVerify, JwsError, type KeyResolver } from "@suaveplan/jws";
// Dispatch on a custom tenant header:
const keyResolver: KeyResolver = async (kid, header) => {
const tenant = header["tenant"] as string | undefined;
if (tenant === "acme") return acmeKeyStore.get(kid);
if (tenant === "globex") return globexKeyStore.get(kid);
return undefined; // → KEY_NOT_FOUND
};
try {
await compactVerify(token, undefined, {
algorithms: ["RS256"],
keyResolver,
});
} catch (err) {
if (err instanceof JwsError && err.code === "JWS.KEY_NOT_FOUND") {
// Inspect err.context.kid for the unresolved id.
}
}
```
## API Reference
- `type KeyResolver = (kid: string, header: JoseHeader) => Promise<unknown> | unknown`
Returning anything other than `undefined` / `null` is treated as the verification key and passed to the algorithm impl unchanged. The pipeline performs no type-check on the returned value — it relies on the algorithm impl to throw `JWS.KEY_ALG_MISMATCH` if the resolved key doesn't match the declared `alg`.
## Implementation Notes
Resolution priority in the verify pipeline is `opts.key → header.jwk → header.x5c → opts.keyResolver(header.kid)`. The resolver only runs when none of the earlier sources resolved a key; this means a token carrying both `kid` AND `jwk` will route through `jwk` first (assuming `allowEmbeddedJwk: true`). That ordering is deliberate: when a producer self-describes a key in the token, the consumer's stored key for the same `kid` should be re-verified against the embedded one — but doing that match is the consumer's responsibility, not this library's. The pipeline simply uses whichever source the caller has opted in to first.

View File

@@ -0,0 +1,61 @@
# `extractX5cLeafKey`
## Purpose
Decode an RFC 7515 §4.1.6 `x5c` certificate chain, delegate trust-path validation to `@suaveplan/x509.validateChain`, and extract the leaf `SubjectPublicKeyInfo` shaped to match the declared JOSE `alg`. This is the entry point that `@suaveplan/webauthn` will call for SafetyNet attestation verification: SafetyNet tokens carry their attestation cert chain in `x5c`, and the verifier needs to anchor it against Google's pinned attestation roots before trusting the embedded RSA public key.
## Features
- Accepts either standard-base64 (RFC's preference) or base64url-encoded chain entries.
- Delegates chain validation entirely to `@suaveplan/x509.validateChain`.
- Leaf-public-key algorithm matched against declared JOSE `alg`.
- Chain failure → `JWS.X5C_CHAIN_INVALID` with the underlying x509 error chained as `cause`.
- Leaf-alg mismatch → `JWS.KEY_ALG_MISMATCH`.
## Usage: Basic Example
```ts
import { extractX5cLeafKey } from "@suaveplan/jws";
import { parseCertificate } from "@suaveplan/x509";
const trustAnchors = [parseCertificate(googleAttestationRootDer)];
const leaf = await extractX5cLeafKey(token.protected.x5c, "RS256", {
trustAnchors,
});
// leaf.kind === "rsa"; leaf.key is the SafetyNet leaf cert's RSA public key.
```
## Usage: Advanced Example
```ts
import { compactVerify, extractX5cLeafKey, JwsError } from "@suaveplan/jws";
// Use through compactVerify's automatic x5c resolution:
const result = await compactVerify(safetynetToken, undefined, {
algorithms: ["RS256"],
allowX5c: true,
x5cTrustAnchors: trustAnchors,
});
// Direct call with a deadline:
try {
const ac = new AbortController();
setTimeout(() => ac.abort(), 1_000);
await extractX5cLeafKey(chain, "RS256", { trustAnchors, signal: ac.signal });
} catch (err) {
if (err instanceof JwsError && err.code === "JWS.X5C_CHAIN_INVALID") {
// err.cause is the underlying X509Error — inspect cause.code for
// CERT_EXPIRED / NAME_CHAIN_BROKEN / etc.
}
}
```
## API Reference
- `extractX5cLeafKey(x5c: readonly string[], alg: string, options: ExtractX5cOptions): Promise<ParsedKey>`
- `interface ExtractX5cOptions { trustAnchors: readonly X509Certificate[]; clock?: Clock; signal?: AbortSignal }`
- Returns the same `ParsedKey` union as `jwkToCryptoKey`.
## Implementation Notes
`x5c` array entries are base64-encoded DER certificates per RFC 7515 §4.1.6. The decoder accepts both alphabets because real-world producers vary; the strict choice is base64 standard alphabet with optional padding, but base64url is also surfaced from some Google attestation chains. The leaf-alg check is intentionally separate from chain validation: a malicious producer could submit a valid Google-rooted chain whose leaf is an ECDSA cert and claim `alg: "RS256"` — chain validation would pass (Google did sign that cert), but the JOSE-level alg doesn't match the leaf SPKI, and the verifier MUST reject. `validateChain` runs the full RFC 5280 §6.1 algorithm (expiry, name chaining, key usage, EKU, name constraints, optional OCSP), so this module owns only the JOSE-layer "does the leaf match `alg`" check.

View File

@@ -0,0 +1,57 @@
# `metrics`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
OpenTelemetry instrumentation surface for the JWS verify and sign hot paths. Every production code path records a histogram for wall-time latency and increments outcome-tagged counters; the error registry's `recordError` also fires once per typed throw so dashboards can pivot on `code` cardinality. The `outcome` attribute is bounded to a 4-value enum (`success | sig_invalid | alg_not_allowed | header_invalid`) so production observability backends do not blow up under attacker-controlled token streams.
## Features
- `recordVerify(alg, outcome, durationMs)` — histogram + outcome counter.
- `recordSign(alg, "success" | "error", durationMs)` — sign-side mirror.
- `recordError(code)` — typed-error counter pivoted by code.
- `resetMetricsForTesting()` — rebinds the cached instrument bag after a test installs a fresh `MeterProvider`.
- `getInstrumentsForTesting()` — exposes the bag for assertion-time introspection.
## Usage: Basic Example
```ts
import { metrics } from "@opentelemetry/api";
import { resetMetricsForTesting, compactVerify } from "@suaveplan/jws";
metrics.setGlobalMeterProvider(myInMemoryProvider);
resetMetricsForTesting();
await compactVerify(token, key, { algorithms: ["HS256"] });
// `jws.verify_duration_ms{alg=HS256, outcome=success}` has 1 sample.
```
## Usage: Advanced Example
```ts
import { getInstrumentsForTesting, recordVerify, recordError, JWS_CODES } from "@suaveplan/jws";
const bag = getInstrumentsForTesting();
// bag.verifyDurationMs / bag.signDurationMs (Histogram)
// bag.verifyTotal / bag.signTotal (Counter)
// bag.errorTotal (Counter)
// Bypass the pipeline for an ad-hoc observation:
recordVerify("RS256", "sig_invalid", 4.2);
recordError(JWS_CODES.SIG_INVALID);
```
## API Reference
- `recordVerify(alg: string, outcome: JwsVerifyOutcome, durationMs: number): void`
- `recordSign(alg: string, outcome: "success" | "error", durationMs: number): void`
- `recordError(code: string): void`
- `nowMs(): number`
- `resetMetricsForTesting(): void`
- `getInstrumentsForTesting(): InstrumentBag`
- `type JwsVerifyOutcome = "success" | "sig_invalid" | "alg_not_allowed" | "header_invalid"`
## Implementation Notes
The instrument bag is cached in module scope so repeated invocations do not call `metrics.getMeter` on every request — that call has non-trivial overhead under the OTel SDK because it walks the provider's registry. Tests that install a fresh `MeterProvider` MUST call `resetMetricsForTesting()` in `beforeEach`; otherwise the cached bag stays bound to the original (no-op) provider and the test asserts against an empty histogram. The `outcome` enum's bounded cardinality is deliberate: an unbounded enum would let an adversarial token stream blow up the metrics backend's series count, and most OTel sinks bill by series.

View File

@@ -0,0 +1,59 @@
# Schemas
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Zod boundary schemas validated at every public entry point. The `@suaveplan` convention is that public options never silently degrade into malformed tokens; every shape mismatch surfaces as `JWS.INVALID_OPTIONS` (caller misuse) or `JWS.HEADER_MALFORMED` (parsed token shape failure) before any cryptographic work runs. These schemas are exported so downstream consumers (`@suaveplan/webauthn`, future `@suaveplan/jwt`) can compose their own validators on top of the same shapes.
## Features
- `JoseHeaderSchema` — RFC 7515 §4.1 registered parameters; unknown parameters survive via `.passthrough()`.
- `JsonWebKeySchema` — RFC 7517 §4 JWK structural skeleton.
- `VerifyOptionsSchema` — every verify entry point's caller options.
- `SignOptionsSchema` — every sign entry point's caller options.
- `FlatJwsSchema` / `JwsSignatureSchema` / `GeneralJwsSchema` — wire shapes.
## Usage: Basic Example
```ts
import { VerifyOptionsSchema, JwsError } from "@suaveplan/jws";
const result = VerifyOptionsSchema.safeParse({ algorithms: ["RS256"] });
if (!result.success) {
throw new JwsError("JWS.INVALID_OPTIONS", "...", { context: { issues: result.error.issues } });
}
```
## Usage: Advanced Example
```ts
import { GeneralJwsSchema, JoseHeaderSchema } from "@suaveplan/jws";
import z from "zod";
// Compose a stricter header schema for a JWT consumer:
const StrictJwtHeader = JoseHeaderSchema.extend({
typ: z.literal("JWT"),
alg: z.union([z.literal("RS256"), z.literal("ES256")]),
});
// Validate a general JWS payload at an HTTP boundary:
const parsed = GeneralJwsSchema.parse(await request.json());
```
## API Reference
- `JoseHeaderSchema: z.ZodSchema`
- `JsonWebKeySchema: z.ZodSchema`
- `VerifyOptionsSchema: z.ZodSchema`
- `SignOptionsSchema: z.ZodSchema`
- `FlatJwsSchema: z.ZodSchema`
- `JwsSignatureSchema: z.ZodSchema`
- `GeneralJwsSchema: z.ZodSchema`
All schemas use `.passthrough()` where unknown fields are permitted (header) and `.strict()` where they aren't (wire-shape objects).
## Implementation Notes
The verify-options schema declares `algorithms` as a required array — the Zod boundary catches the most common misuse (forgetting to pass `algorithms`, or passing `{} as never`) and surfaces it as `JWS.INVALID_OPTIONS` before the verify pipeline even sees the call. The general-JWS schema enforces `signatures.length >= 1` because an empty signatures array is structurally meaningless (no one signed anything) and lenient libraries that accept it silently produce false-positive verifies under `requireAll: false`.

View File

@@ -0,0 +1,65 @@
# Compact serialization
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
RFC 7515 §3.1 + §7.1 compact-form JWS — the three-segment `<protected>.<payload>.<signature>` string format that is the default representation for JWS-as-JWT and the only form most downstream consumers will ever encounter. This module owns `compactDecode` (parse), `compactVerify` (RFC §5.2 verify), and `compactSign` (RFC §5.1 sign).
## Features
- Strict structural parser: exactly two `.` separators, URL-alphabet bytes only.
- Empty middle segment accepted (RFC 7515 §A.5 detached form).
- Mandatory `algorithms` allowlist at verify time — algorithm-confusion-CVE defence.
- `compactSign` returns the joined string; consumers can split on `.` to inspect segments.
## Usage: Basic Example
```ts
import { compactDecode, compactSign, compactVerify } from "@suaveplan/jws";
const token = await compactSign("hello", secret, "HS256");
const decoded = compactDecode(token);
// decoded.protected / decoded.payload / decoded.signature
const result = await compactVerify(token, secret, { algorithms: ["HS256"] });
// result.verified === true
```
## Usage: Advanced Example
```ts
import { compactVerify, type KeyResolver, JwsError } from "@suaveplan/jws";
const keyResolver: KeyResolver = async (kid) => keyStore.get(kid);
const verify = async (token: string) => {
try {
return await compactVerify(token, undefined, {
algorithms: ["RS256", "ES256", "EdDSA"],
allowEmbeddedJwk: false,
allowX5c: false,
keyResolver,
acceptableCriticalHeaders: ["exp", "nbf"],
});
} catch (err) {
if (err instanceof JwsError) {
if (err.code === "JWS.ALG_NOT_ALLOWED") return null;
if (err.code === "JWS.SIG_INVALID") return null;
if (err.code === "JWS.KEY_NOT_FOUND") return null;
}
throw err;
}
};
```
## API Reference
- `compactDecode(token: string): CompactJws` — throws `JWS.MALFORMED`.
- `compactVerify(token: string, key: unknown, opts: VerifyOptions): Promise<VerifyResult>`
- `compactSign(payload: string | Uint8Array, key: unknown, alg: string, opts?: SignOptions): Promise<string>`
## Implementation Notes
Verification enforces the algorithm allowlist BEFORE any cryptographic work — `opts.algorithms.includes(header.alg)` is checked immediately after parsing the header, so a token presenting `alg: "HS256"` against an RSA public key cannot even reach the HMAC impl, neutralising the entire alg-confusion class. The pipeline also enforces `crit` allowlist before alg dispatch, so a token whose `crit` parameter is unknown to the verifier never runs cryptography either; this matters because cryptographic verification side-channels can leak information about the verifier's state (cache state, branch prediction) even when the verifier rejects the token.

View File

@@ -0,0 +1,54 @@
# JSON Flattened serialization
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
RFC 7515 §3.2 + §7.2.2 JSON-flattened JWS — `{ protected?, header?, payload, signature }`. The flat form is the single-signature variant of the general form; producers use it when they need to ship an unprotected header alongside the protected one (e.g. for non-signed metadata like `kid` rotation hints) without going through the compact form's protected-header-only constraint.
## Features
- `flatDecode` — Zod-validated decode; at least one of `protected` / `header` must be present.
- `flatVerify` — verify against the recomputed `protected || "." || payload` signing input.
- `flatSign` — sign and emit the flat JSON.
- Unprotected header preserved on the verify result via `.unprotectedHeader`.
## Usage: Basic Example
```ts
import { flatSign, flatVerify } from "@suaveplan/jws";
const flat = await flatSign("hello", secret, "HS256", {
unprotectedHeader: { kid: "k1" },
});
// { protected: "...", header: { kid: "k1" }, payload: "...", signature: "..." }
const result = await flatVerify(flat, secret, { algorithms: ["HS256"] });
// result.unprotectedHeader === { kid: "k1" }
```
## Usage: Advanced Example
```ts
import { flatVerify, JwsError } from "@suaveplan/jws";
const verified = await flatVerify(flat, undefined, {
algorithms: ["RS256"],
allowEmbeddedJwk: false,
keyResolver: async (kid) => keyStore.get(kid),
acceptableCriticalHeaders: ["exp"],
});
// verified.header === protected header (signed)
// verified.unprotectedHeader === unprotected header (advisory only)
```
## API Reference
- `flatDecode(json: unknown): FlatJws`
- `flatVerify(json: unknown, key: unknown, opts: VerifyOptions): Promise<VerifyResult & { unprotectedHeader?: Record<string, unknown> }>`
- `flatSign(payload: string | Uint8Array, key: unknown, alg: string, opts?: SignOptions): Promise<FlatJws>`
## Implementation Notes
The unprotected header is informational — it is NOT covered by the signature, so consumers MUST NOT rely on it for security decisions. The library surfaces it on the verify result so callers can use it for non-security routing (rotation hints, message-id correlation) without re-decoding the wire shape. Verification enforces a header-key collision check between protected and unprotected headers per RFC 7515 §5.2 step 4: any duplicate key throws `JWS.HEADER_MALFORMED` because the RFC explicitly disallows it (an attacker could otherwise plant a different `alg` in the unprotected header and confuse a careless verifier).

View File

@@ -0,0 +1,69 @@
# JSON General serialization (multi-signature)
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
RFC 7515 §3.2 + §7.2.1 JSON-general JWS — `{ payload, signatures: JwsSignature[] }`. Each entry in `signatures` carries its own `protected` / `header` / `signature` so the same payload can be co-signed by N parties. Common use cases: cross-tenant message attestation, multi-party authorisation, signed installer manifests where multiple maintainers' signatures must all attach to the same artifact.
## Features
- `generalDecode` — Zod-validated decode; `signatures` must be non-empty.
- `generalVerify` — verifies every signer via a per-signer key resolver; returns `{ verifiedIndices, failedIndices }`.
- `generalSign` — signs the same payload under N signers; rejects mixed-`b64` signers.
- `requireAll: true` causes the first failing index to throw `JWS.MULTI_SIG_FAILED` with `error.context.failedIndex`.
## Usage: Basic Example
```ts
import { generalSign, generalVerify } from "@suaveplan/jws";
const general = await generalSign("hello", [
{ key: secret, alg: "HS256" },
{ key: rsaPriv, alg: "RS256" },
]);
const result = await generalVerify(
general,
async (i) => (i === 0 ? secret : rsaPub),
{ algorithms: ["HS256", "RS256"] }
);
// result.verifiedIndices === [0, 1]
```
## Usage: Advanced Example
```ts
import { generalVerify, JwsError } from "@suaveplan/jws";
try {
await generalVerify(
general,
async (i, protectedHdr) => keyStoreByIssuer.get(protectedHdr["iss"] as string),
{
algorithms: ["HS256", "RS256", "ES256"],
requireAll: true,
}
);
} catch (err) {
if (err instanceof JwsError && err.code === "JWS.MULTI_SIG_FAILED") {
const failedIndex = (err.context as { failedIndex: number }).failedIndex;
// err.cause === underlying JwsError for that signer.
}
}
```
## API Reference
- `generalDecode(json: unknown): GeneralJws`
- `generalVerify(json: unknown, keyResolver, opts: GeneralVerifyOptions): Promise<GeneralVerifyResult>`
- `keyResolver: (signerIndex, protectedHeader, unprotectedHeader?) => Promise<unknown>`
- `GeneralVerifyOptions extends VerifyOptions { requireAll?: boolean }`
- `GeneralVerifyResult = { verifiedIndices, failedIndices, payloadString }`
- `generalSign(payload, signers: GeneralSigner[]): Promise<GeneralJws>`
- `GeneralSigner = { key, alg, protectedHeader?, unprotectedHeader? }`
## Implementation Notes
The default `requireAll` value is `false` — more flexible for use cases like "any one of N trust roots verifies." Callers needing the stricter "every signer must verify" semantics opt in via `requireAll: true`. Mixed `b64` settings across signers are NOT supported because each signer's signing input would differ in interpretation and the receiver-side verifier cannot know which interpretation to apply — `generalSign` rejects mixed-`b64` arrangements with `JWS.MALFORMED`. The payload segment is computed from the first signer's b64 setting and then asserted byte-identical across the rest.

View File

@@ -0,0 +1,49 @@
# Sign pipeline
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Central orchestrator for compact + flat + general sign paths. Validates options through Zod, assembles the protected header (auto-injecting `alg`), computes the signing input per RFC 7515 §5.1, and dispatches to the algorithm impl's `sign`. RFC 7797 `b64: false` is handled by routing the raw payload bytes through the signing input instead of base64url-encoding.
## Features
- Auto-injects `alg` into the protected header (callers can't accidentally sign with a header `alg` that doesn't match the algorithm argument).
- RFC 7797: setting `opts.b64 = false` automatically marks `b64` critical and lists it in `crit` when callers forget.
- AbortSignal honoured at pipeline entry.
- OTel sign-histogram + counter emitted.
## Usage: Basic Example
```ts
import { compactSign } from "@suaveplan/jws";
const token = await compactSign("hello", secret, "HS256");
```
## Usage: Advanced Example
```ts
import { runSignPipeline, validateSignOptions } from "@suaveplan/jws";
const validated = validateSignOptions({
protectedHeader: { kid: "k1", typ: "JWT" },
});
const segs = await runSignPipeline("hello", priv, "ES256", validated);
// segs.protectedB64 / segs.payloadSegment / segs.signatureB64 / segs.alg
// Compose your own wire form:
const token = `${segs.protectedB64}.${segs.payloadSegment}.${segs.signatureB64}`;
```
## API Reference
- `runSignPipeline(payload, key, alg, opts): Promise<SignedSegments>`
- `validateSignOptions(opts?: unknown): SignOptions` — Zod-validated.
- `interface SignOptions { protectedHeader?, unprotectedHeader?, detached?, b64?, signal? }`
- `interface SignedSegments { protectedB64, payloadSegment, signatureB64, alg }`
## Implementation Notes
When `opts.b64 === false`, the pipeline asserts that the protected header carries `crit: ["b64"]` (or a `crit` array containing `"b64"`); callers that omit this contract violate RFC 7797 §3 and the pipeline throws `JWS.B64_INVALID`. The pipeline does NOT auto-add `crit` because doing so silently would defeat the purpose — the `crit` listing is a verifier-side opt-in signal, and producers that forget it have a bug their verifiers will reject. Detached mode is signalled by `opts.detached = true`, which causes the returned `payloadSegment` to be the empty string while the signing input continues to incorporate the (non-empty) payload bytes — so the resulting compact token reads `<hdr>..<sig>` but is verifiable when the receiver supplies the payload out-of-band.

View File

@@ -0,0 +1,65 @@
# Verify pipeline
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Central orchestrator for every verify path (compact, flat, general). The pipeline runs the RFC 7515 §5.2 verification algorithm in a fixed order so consumers of every serialization see identical security guarantees:
1. Validate caller options via Zod.
2. Honour `AbortSignal` before any work.
3. Parse header.
4. Enforce `crit` allowlist (RFC §4.1.11).
5. Enforce algorithm allowlist (RFC §5.2 step 6) BEFORE crypto.
6. Reject `alg: "none"` unless dual-flag opt-in.
7. Resolve `b64` (RFC 7797).
8. Decode payload (or splice in `detachedPayload`).
9. Recompute signing input.
10. Resolve key (`opts.key` → `header.jwk` → `header.x5c` → `opts.keyResolver`).
11. Honour `AbortSignal` again.
12. Dispatch to the algorithm impl.
## Features
- Ordering matters: every gate runs before the next stage's work.
- AbortSignal honoured at three checkpoints (entry, mid-pipeline, pre-dispatch).
- Algorithm allowlist enforced before key resolution.
- OTel histogram + counter emitted with bounded outcome enum.
## Usage: Basic Example
```ts
import { compactVerify } from "@suaveplan/jws";
const result = await compactVerify(token, key, { algorithms: ["RS256"] });
```
## Usage: Advanced Example
```ts
import { runVerifyPipeline, validateVerifyOptions, compactDecode } from "@suaveplan/jws";
// Bypass compactDecode if you already have the segments split:
const validated = validateVerifyOptions({ algorithms: ["HS256"] });
const decoded = compactDecode(token);
const result = await runVerifyPipeline(
decoded.protected,
decoded.payload,
decoded.signature,
key,
validated,
undefined, // no detachedPayload
);
```
## API Reference
- `runVerifyPipeline(protectedB64, payloadSegment, signatureB64, key, opts, detachedPayload?, extraHeader?): Promise<VerifyResult>`
- `validateVerifyOptions(opts: unknown): VerifyOptions` — Zod-validated.
- `interface VerifyOptions` — caller options (see `verify.types`).
- `interface VerifyResult<H = JoseHeader>` — `{ verified: true, header, payload, payloadString }`.
## Implementation Notes
The "allowlist before crypto" ordering is enforced by parameter order — `opts.algorithms.includes(header.alg)` is checked immediately after `parseHeader`, before any key resolution or signing-input formation. This is load-bearing because key resolution can be expensive (network fetch for `x5c` chain validation, async `keyResolver` callbacks) and an attacker could otherwise DoS by spamming tokens with bogus algorithm identifiers. The error counter pivots on a bounded outcome enum (`success | sig_invalid | alg_not_allowed | header_invalid`) so adversarial token streams cannot inflate the metrics backend's series count.