Move docs/ package mirror into document-navigator plugin
Relocate the ~6k-file docs tree from the repo root into plugins/document-navigator/docs/ so the plugin is self-contained and portable. docs/superpowers/ (specs and plans) stays at the repo root. Moved via cp+rm rather than git mv because directory rename() fails with EPERM on this WSL /mnt DrvFs mount; git rename-detection pairs the identical-content files at commit time. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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`.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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`.
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user