Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -0,0 +1,168 @@
# `@suaveplan/jws` — RFC 7515 JSON Web Signature (verify + sign) for the universal tier
**Package**: `@suaveplan/jws` (NEW)
**Status**: PROPOSED
**Type**: new-package (genesis-original; greenfield — no upstream port)
**Date**: 2026-05-11
**Path**: `packages/security/jws/`
## Why
`@suaveplan/webauthn`'s `android-safetynet` attestation verifier consumes a JWS as its input: the SafetyNet attestation statement is a compact-serialized JWS signed `RS256` whose protected header carries an `x5c` parameter listing the leaf attestation certificate plus Google's intermediate / root attestation certificates. To verify a SafetyNet attestation the verifier must (a) parse the three-segment compact form per RFC 7515 §3.1, (b) extract the `x5c` chain and validate it through `@suaveplan/x509` against Google's pinned attestation root, (c) confirm the leaf public key matches the declared `alg`, (d) recompute `BASE64URL(protected) || "." || BASE64URL(payload)` and run RSASSA-PKCS1-v1_5 verify per RFC 7518 §3.3. The current `webauthn` proposal defers server-side attestation; this package unblocks that workstream and is reusable by every other future signer of JWSes in the monorepo (signed installer manifests, cross-service attestations, signed event envelopes).
External alternatives (`jose`, `jsonwebtoken`, `node-forge`) all drag in either Node built-ins, CommonJS interop hazards, or oversize transitive dep trees that would force `webauthn` off the universal tier. An in-house, RFC-cited, zero-dependency implementation lets one canonical surface ship with one error registry, one OTel surface, one Zod boundary, and one set of test vectors anchored on RFC 7515 Appendix A.
## What changes
### New package: `@suaveplan/jws`
- ADDED: `packages/security/jws/` package scaffold (`package.json`, `tsconfig.lib.json`, `vite.config.ts`, `vitest.config.ts`, `README.md`).
- ADDED: `src/base64url/base64url.ts` — `base64urlEncode(bytes): string` and `base64urlDecode(str): Uint8Array` per RFC 7515 Appendix C (no padding, no `+`/`/`). Malformed input throws `JWS.BASE64URL_INVALID`.
- ADDED: `src/serialization/compact.ts` — `compactDecode(token: string): CompactJws`, `compactVerify(token, key, opts): Promise<VerifyResult>`, `compactSign(payload, key, alg, opts?): Promise<string>` per RFC 7515 §3.1 + §7.1.
- ADDED: `src/serialization/flat.ts` — `flatDecode(json): FlatJws`, `flatVerify(json, key, opts)`, `flatSign(payload, key, alg, opts?)` per RFC 7515 §3.2 + §7.2.2.
- ADDED: `src/serialization/general.ts` — `generalDecode(json): GeneralJws`, `generalVerify(json, keyResolver, opts)`, `generalSign(payload, signers[])` per RFC 7515 §3.2 + §7.2.1.
- ADDED: `src/serialization/serialization.types.ts` — `CompactJws`, `FlatJws`, `GeneralJws`, `JwsSignature`, `JwsSerialization` discriminator.
- ADDED: `src/header/header.ts` — `parseHeader(b64u): JoseHeader`, `serializeHeader(h): string`, `JoseHeader` Zod schema for the RFC 7515 §4.1 registered parameters (`alg`, `jku`, `jwk`, `kid`, `x5u`, `x5c`, `x5t`, `x5t#S256`, `typ`, `cty`, `crit`).
- ADDED: `src/header/crit.ts` — `enforceCrit(header, acceptableCriticalHeaders): void`. Empty `crit`, non-array `crit`, or string-array entries not in `acceptableCriticalHeaders` throw `JWS.CRIT_UNRECOGNIZED` / `JWS.CRIT_MALFORMED` per RFC 7515 §4.1.11.
- ADDED: `src/header/b64.ts` — RFC 7797 `b64` header support. When `b64: false`, `b64` MUST appear in `crit` or `JWS.B64_INVALID` is thrown; the signing input becomes `BASE64URL(protected) || "." || rawPayloadString` per RFC 7797 §3.
- ADDED: `src/algorithms/registry.ts` — `AlgorithmRegistry` with `register(alg, { verify, sign? })` / `lookup(alg)` / `lookupOrThrow(alg)`. Built-in algorithms: `HS256`/`HS384`/`HS512`, `RS256`/`RS384`/`RS512`, `PS256`/`PS384`/`PS512`, `ES256`/`ES384`/`ES512`, `EdDSA`, `none`. Unknown alg → `JWS.ALG_UNSUPPORTED`.
- ADDED: `src/algorithms/hmac.ts` — `HS256`/`HS384`/`HS512` per RFC 7518 §3.2; constant-time tag compare via `@suaveplan/crypto-utils.constantTimeEqual`; never `Buffer.compare`.
- ADDED: `src/algorithms/rsa.ts` — `RS256`/`RS384`/`RS512` per RFC 7518 §3.3 via `@suaveplan/crypto-curve` RSA helpers.
- ADDED: `src/algorithms/rsa-pss.ts` — `PS256`/`PS384`/`PS512` per RFC 7518 §3.5 with salt length equal to hash output length.
- ADDED: `src/algorithms/ecdsa.ts` — `ES256`/`ES384`/`ES512` per RFC 7518 §3.4. Signature MUST be fixed-length `R||S`; ASN.1 DER input in the JOSE position → `JWS.SIG_FORMAT_INVALID`.
- ADDED: `src/algorithms/eddsa.ts` — `EdDSA` per RFC 8037 §3.1 (Ed25519) via `@suaveplan/crypto-curve/ed25519`.
- ADDED: `src/algorithms/none.ts` — `"none"` per RFC 7518 §3.6. Rejected by default; requires both `algorithms: ["none"]` AND `acceptNone: true` simultaneously, else `JWS.ALG_NONE_DISABLED`.
- ADDED: `src/keys/jwk.ts` — `jwkToCryptoKey(jwk, alg): CryptoKeyLike` per RFC 7517 §4 + RFC 7518 §6. Validates `kty` / `crv` / `n,e` / `x,y` consistency with the declared `alg`; mismatch → `JWS.JWK_INVALID`.
- ADDED: `src/keys/x5c.ts` — `extractX5cLeafKey(x5c: string[], alg, opts)`. Delegates chain validation to `@suaveplan/x509.validateChain(certs, { trustAnchors, now })`; this module's responsibility is base64 decoding the chain, extracting the leaf SPKI, and confirming the leaf public-key algorithm matches the declared JOSE `alg`. Chain failure → `JWS.X5C_CHAIN_INVALID`.
- ADDED: `src/keys/resolver.ts` — `KeyResolver = (kid, header) => Promise<KeyLike | undefined>`. When `header.kid` is present and `opts.key` is omitted, the resolver is awaited; absence → `JWS.KEY_NOT_FOUND`.
- ADDED: `src/detached/detached.ts` — RFC 7515 §A.5 / Appendix F detached payload helpers; `compactSign(payload, key, alg, { detached: true })` emits a token with empty middle segment; `compactVerify(token, key, { detachedPayload })` accepts the payload out-of-band.
- ADDED: `src/verify/verify-pipeline.ts` — central `runVerifyPipeline(input, opts)` orchestrator: parse header → enforce `crit` allowlist → enforce `alg` allowlist → enforce `alg !== "none"` unless opted in → resolve key (priority: explicit `opts.key` → `header.jwk` → `header.x5c` leaf → `header.kid` via resolver) → recompute signing input → dispatch to algorithm `verify` → return `VerifyResult`.
- ADDED: `src/sign/sign-pipeline.ts` — central `runSignPipeline(payload, key, alg, opts)` orchestrator: validate options → resolve algorithm → assemble protected header → compute signing input → dispatch to algorithm `sign` → assemble serialization.
- ADDED: `src/errors/error-codes.ts` — `defineCodes("JWS", [...])` registry: `MALFORMED`, `HEADER_MALFORMED`, `BASE64URL_INVALID`, `ALG_UNSUPPORTED`, `ALG_NOT_ALLOWED`, `ALG_NONE_DISABLED`, `SIG_INVALID`, `SIG_FORMAT_INVALID`, `JWK_INVALID`, `X5C_CHAIN_INVALID`, `KEY_NOT_FOUND`, `KEY_ALG_MISMATCH`, `CRIT_UNRECOGNIZED`, `CRIT_MALFORMED`, `B64_INVALID`, `INVALID_OPTIONS`, `ABORTED`, `MULTI_SIG_FAILED`. Plus `JwsError extends SuaveplanError<JwsErrorCode>`.
- ADDED: `src/observability/metrics.ts` — `jws.verify_duration_ms{alg,outcome}` + `jws.sign_duration_ms{alg}` histograms; `jws.verify_total{alg,outcome}` + `jws.sign_total{alg,outcome}` + `jws.error_total{code}` counters. `outcome` bounded by `success | sig_invalid | alg_not_allowed | header_invalid`.
- ADDED: `src/schemas/schemas.ts` — Zod schemas for `JoseHeader`, `VerifyOptions`, `SignOptions`, `FlatJws`, `GeneralJws`, `JsonWebKey` (per RFC 7517). Validated at every public entry point.
- ADDED: `src/__benchmarks__/{verify,sign,multi-sig}.bench.ts` — mitata benchmarks for the four most-likely algorithms (`HS256`, `RS256`, `ES256`, `EdDSA`) over a 1 KiB payload, plus multi-sig general decode.
- ADDED: `src/index.ts` — explicit named re-exports of the public surface (no `export *` in feature barrels per AGENTS.md §6a).
### Subpath exports
- `.` — root barrel: `compactVerify`, `compactSign`, `compactDecode`, `flatVerify`, `flatSign`, `flatDecode`, `generalVerify`, `generalSign`, `generalDecode`, error registry, OTel.
- `./serialization/compact` — compact form.
- `./serialization/flat` — JSON-flattened form.
- `./serialization/general` — JSON-general multi-signature form.
- `./header` — `parseHeader`, `enforceCrit`, `JoseHeader` schema.
- `./algorithms` — `AlgorithmRegistry`, built-in algorithm implementations.
- `./keys` — `jwkToCryptoKey`, `extractX5cLeafKey`, `KeyResolver` type.
- `./detached` — RFC 7515 §A.5 detached payload helpers.
- `./base64url` — `base64urlEncode`, `base64urlDecode`.
- `./errors` — `JWS_CODES`, `JwsError`, `JwsErrorCode`.
### Dependencies
- `@suaveplan/x509 workspace:^` — `validateChain(certs, { trustAnchors, now })` for `x5c` header. **Blocking dep**: `x509` MUST land on `genesis` before this package archives. If absent at proposal time, the `x509` migration proposal is opened first.
- `@suaveplan/crypto-curve workspace:^` — ECDSA P-256/P-384/P-521, RSA, RSA-PSS, Ed25519 verify + sign.
- `@suaveplan/crypto-hash workspace:^` — HMAC-SHA-256/384/512 for `HS*` algorithms; SHA-2 helpers for pre-hash where the algorithm primitive needs it.
- `@suaveplan/crypto-utils workspace:^` — `constantTimeEqual`, hex / base64 helpers, `zeroize`.
- `@suaveplan/error workspace:^` — `defineCodes`, `SuaveplanError` base.
- `@suaveplan/types workspace:^` — `Clock`, `Rng`, `LoggerLike` shared interfaces.
- `@opentelemetry/api catalog:observability`.
- `zod catalog:forms` — boundary validation.
- `tslib catalog:build`.
- **No** `jose`, `jsonwebtoken`, `node-forge`, `jws` (npm), `jwa`, `@stablelib/*`, browser globals, or Node built-ins. Pure TypeScript over `Uint8Array` + `DataView` plus the workspace crypto primitives.
## Out of scope
- **JWE (RFC 7516 JSON Web Encryption)** — encryption is a distinct capability with a separate algorithm registry, separate key-management modes (RSA-OAEP, ECDH-ES, PBES2, etc.), and a four-segment compact form. **Justification**: scoping JWE into this package would double the surface area and introduce algorithm-confusion risk between JWS and JWE primitives. A separate `@suaveplan/jwe` package is the right home; this package's `crit` and header machinery is designed to be reusable when that lands.
- **JWT issuance / claim validation (RFC 7519)** — JWT is a JWS payload with a specific claim shape (`iss`/`sub`/`aud`/`exp`/`nbf`/`iat`/`jti`). The existing `@suaveplan/jwt-tenant` package already ships JWT issuance; a future generic `@suaveplan/jwt` package would consume `@suaveplan/jws` as a primitive. **Justification**: keeping the JWS layer claim-agnostic lets every JWT-adjacent consumer (CWT-over-JOSE, SD-JWT, signed installer manifests) compose on top without inheriting JWT-specific assumptions.
- **JWK Set discovery via `jku` / `x5u` URL fetch** — the package parses `jku` and `x5u` header parameters but does NOT fetch them. **Justification**: fetching introduces an IO surface (`fetch`, retry, timeout, TLS pinning) that belongs in a consumer; the package surfaces the parsed URL and lets the caller hydrate via their own HTTP plumbing.
- **JOSE Web Algorithms not registered in RFC 7518 / RFC 8037** — experimental algorithms (e.g. ML-DSA from draft-ietf-cose-dilithium, secp256k1) are not built-in. Consumers needing them register through `AlgorithmRegistry.register(alg, impl)`. **Justification**: keeping the built-in set anchored on stable RFCs prevents algorithm drift between this package and its consumers.
Every other capability called out in the brief (compact + flat + general serializations, all 11 RFC 7518 algorithms + `EdDSA` + opt-in `none`, JWK + `x5c` + `kid` resolution, `crit` enforcement, detached payload, RFC 7797 `b64`, multi-sig general, Zod boundaries, OTel, AbortSignal, 100% coverage) is IN scope for this change. There are no deferrals on anything in scope.
## Source mapping
This package is genesis-original; no `../suaveplan/enterprise/jws.md` line range exists for any feature. Per the AGENTS.md convention for greenfield packages, the source-of-record is the RFC itself.
| Requirement | RFC source | Notes |
|---|---|---|
| Compact serialization parse + assemble (3 segments, base64url) | RFC 7515 §3.1, §7.1, Appendix A | Exactly two `.` characters; empty middle segment for detached |
| JSON Flattened serialization | RFC 7515 §3.2, §7.2.2 | `protected` + optional `header` + `payload` + `signature` |
| JSON General serialization (multi-sig) | RFC 7515 §3.2, §7.2.1 | `signatures` array; per-signer `protected`/`header`/`signature` |
| base64url encoding (no padding, no `+`/`/`) | RFC 7515 Appendix C | Strict alphabet; malformed → `BASE64URL_INVALID` |
| Signing input formation `BASE64URL(protected) \|\| "." \|\| BASE64URL(payload)` | RFC 7515 §5.1 step 5 | When `b64: false`, second segment is raw payload bytes |
| Mandatory algorithm allowlist at verify time | RFC 7515 §4.1.1 + §5.2 step 6 | Per "alg confusion" CVE class; no default-accept |
| `alg: "none"` rejection by default | RFC 7518 §3.6 | Explicit two-flag opt-in required |
| `HS256` / `HS384` / `HS512` | RFC 7518 §3.2 | HMAC + constant-time compare |
| `RS256` / `RS384` / `RS512` | RFC 7518 §3.3 | RSASSA-PKCS1-v1_5 |
| `ES256` / `ES384` / `ES512` | RFC 7518 §3.4 | P-256/P-384/P-521; fixed-length `R\|\|S` |
| `PS256` / `PS384` / `PS512` | RFC 7518 §3.5 | RSASSA-PSS, salt length = hash length |
| `EdDSA` (Ed25519) | RFC 8037 §3.1, Appendix A.4 | Per RFC 8037 IANA registration |
| Registered header parameters (`alg`, `jku`, `jwk`, `kid`, `x5u`, `x5c`, `x5t`, `x5t#S256`, `typ`, `cty`, `crit`) | RFC 7515 §4.1 | All parsed; `jku`/`x5u` not fetched (see out-of-scope) |
| `jwk` header parses to a typed key | RFC 7515 §4.1.3 + RFC 7517 §4 + RFC 7518 §6 | Validates consistency with declared `alg` |
| `x5c` chain validation via `@suaveplan/x509` | RFC 7515 §4.1.6 | Chain expiry / trust-anchor delegated; leaf SPKI checked here |
| `kid` resolution via injectable resolver | RFC 7515 §4.1.4 | Caller controls the key-store model |
| `crit` enforcement against caller allowlist | RFC 7515 §4.1.11 | Empty / non-string-array / unknown entries throw |
| Detached payload | RFC 7515 §A.5, Appendix F | Empty middle segment; payload supplied out-of-band |
| `b64` header parameter | RFC 7797 §3 | MUST appear in `crit`; signing input uses raw payload |
| Algorithm registry for downstream extension | RFC 7518 §3 IANA registry semantics | Built-ins frozen; `register` adds without forking |
### Requirements with no source line
| Requirement | Justification |
|---|---|
| `defineCodes("JWS", [...])` 18-code registry | Genesis convention — every package surfaces typed errors via `@suaveplan/error`. Each code maps to a distinct §4.1 / §5.2 / §6 failure class so callers can branch on root cause. |
| Zod boundary schemas at every public option / parsed header | Genesis convention (CLAUDE.md §11). Catches caller misuse at the boundary; option misuse never silently degrades into a malformed token. |
| OTel surface (`jws.verify_duration_ms`, `jws.sign_duration_ms`, `jws.verify_total`, `jws.sign_total`, `jws.error_total`) | Genesis convention — every hot-path package emits histograms + counters with a low-cardinality outcome attribute. |
| 300-line file budget + 100/100/100/100 coverage + sibling `.md` per impl file | CLAUDE.md global rules. |
| Property-based round-trip tests (fast-check) per algorithm | CLAUDE.md "no mocks" rule; round-trip is the only test that proves sign + verify agree under arbitrary payloads. |
| Mitata benchmarks under `src/__benchmarks__/` | CLAUDE.md global benchmark requirement. |
| Algorithm registry pluggability | Future consumer extensibility — downstream packages registering experimental algorithms without modifying this package. |
| `AbortSignal` end-to-end | Genesis convention — every async public entry point honors cancellation. |
## Verification approach
Each requirement carries a `**Verified by:**` line in the spec delta naming concrete `it(...)` titles inside the test files below.
- `packages/security/jws/src/base64url/base64url.test.ts` — Appendix C alphabet round-trip + malformed input rejection.
- `packages/security/jws/src/serialization/compact.test.ts` — three-segment parsing, assembly, malformed segment rejection.
- `packages/security/jws/src/serialization/compact.spec-conformance.test.ts` — RFC 7515 Appendix A.1–A.4 vectors round-trip byte-identically.
- `packages/security/jws/src/serialization/flat.test.ts` — JSON-flat decode / verify / sign; Appendix A.7 vector.
- `packages/security/jws/src/serialization/general.test.ts` — multi-signer encode / decode / verify; Appendix A.6 vector.
- `packages/security/jws/src/header/header.test.ts` — every RFC 7515 §4.1 registered parameter parses; unknown header parameter survives round-trip.
- `packages/security/jws/src/header/crit.test.ts` — empty `crit` rejected; non-array rejected; unknown entry rejected; allowlisted entry accepted.
- `packages/security/jws/src/header/b64.test.ts` — `b64: false` without `crit` rejected; with `crit` round-trips; signing input uses raw payload.
- `packages/security/jws/src/algorithms/registry.test.ts` — built-ins registered; `register` adds; `lookup` returns registered impl; unknown alg throws.
- `packages/security/jws/src/algorithms/hmac.test.ts` — `HS256`/`HS384`/`HS512` IANA test vectors; constant-time compare verified by timing-independent property test.
- `packages/security/jws/src/algorithms/rsa.test.ts` — `RS256`/`RS384`/`RS512` round-trip against RFC 7515 Appendix A.2 vector + IANA vectors.
- `packages/security/jws/src/algorithms/rsa-pss.test.ts` — `PS256`/`PS384`/`PS512` round-trip; salt-length-mismatch rejection.
- `packages/security/jws/src/algorithms/ecdsa.test.ts` — `ES256`/`ES384`/`ES512` against RFC 7515 Appendix A.3 + IANA P-384/P-521 vectors; ASN.1 DER signature input rejected with `SIG_FORMAT_INVALID`.
- `packages/security/jws/src/algorithms/eddsa.test.ts` — RFC 8037 §A.4 vector verifies; round-trip with a generated key pair.
- `packages/security/jws/src/algorithms/none.test.ts` — default rejection; explicit two-flag opt-in accepts; missing either flag throws.
- `packages/security/jws/src/keys/jwk.test.ts` — every supported `kty` parses; `alg`/`kty` mismatch throws `JWK_INVALID`.
- `packages/security/jws/src/keys/x5c.test.ts` — leaf SPKI extracted; chain delegated to `@suaveplan/x509.validateChain` with a real Google attestation chain fixture; invalid chain throws `X5C_CHAIN_INVALID`; leaf-alg mismatch throws `KEY_ALG_MISMATCH`.
- `packages/security/jws/src/keys/resolver.test.ts` — resolver awaited when `kid` set; resolver returning `undefined` throws `KEY_NOT_FOUND`.
- `packages/security/jws/src/detached/detached.test.ts` — Appendix F vectors round-trip; missing `detachedPayload` at verify throws.
- `packages/security/jws/src/verify/verify-pipeline.test.ts` — full pipeline ordering; algorithm allowlist enforced before any crypto runs; `crit` enforced before alg dispatch.
- `packages/security/jws/src/sign/sign-pipeline.test.ts` — sign pipeline round-trip per algorithm.
- `packages/security/jws/src/errors/error-codes.test.ts` — every code in the registry is thrown by ≥1 production code path; `expectTypeOf<JwsErrorCode>().toEqualTypeOf<…>()` pins the literal union.
- `packages/security/jws/src/observability/metrics.test.ts` — histograms + counters recorded under in-memory `MeterProvider`; outcome attribute bounded.
- `packages/security/jws/src/schemas/schemas.test.ts` — every Zod boundary rejects malformed input with `INVALID_OPTIONS` / `HEADER_MALFORMED`.
- `packages/security/jws/src/verify/property.test.ts` — fast-check: arbitrary payload, sign → decode → verify is byte-identical per algorithm.
- `packages/security/jws/src/verify/abort.test.ts` — pre-aborted signal rejects with `JWS.ABORTED`; abort between header parse and signature verify rejects.
- `packages/security/jws/src/index.test.ts` — every documented subpath export resolves.
## Migration impact
None — additive only; this is the first delta of a new package. No existing code in `genesis` imports from `@suaveplan/jws`. Pending consumers:
- `@suaveplan/webauthn` (in `openspec/changes/webauthn/`) — once server-side `android-safetynet` verification is folded back in scope, that change will add the dep edge on `@suaveplan/jws` plus `@suaveplan/x509`.
## Impact
### Affected dependents
`bunx turbo ls --filter=...@suaveplan/jws` returns no current dependents (the package does not yet exist on `genesis`). Pending consumer:
- `@suaveplan/webauthn` — [ ] tests run / [ ] docs reviewed / [x] no action needed in THIS change (reason: webauthn currently defers server-side attestation per its own proposal; the dep edge lands in a follow-up `webauthn` change once SafetyNet verification is folded back in scope).
No foundation-package fan-out applies (this is a new universal-tier leaf package; foundation deps `@suaveplan/error` and `@suaveplan/types` are unchanged).

View File

@@ -0,0 +1,512 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec Delta: jws (enterprise-jws)
**Package**: `@suaveplan/jws`
**Status**: PROPOSED — first delta; becomes canonical spec on archive.
> Every requirement below carries a `**Verified by:**` line at authoring time. (See `openspec/AGENTS.md` Section 13.)
## ADDED Requirements
### Requirement: RFC 7515 §3.1 compact serialization decode
The package SHALL export `compactDecode(token: string): CompactJws` returning the parsed three-segment structure `{ protected, payload, signature }` per RFC 7515 §3.1 + §7.1. The token SHALL contain exactly two `.` characters separating the three base64url-encoded segments. Any other framing (zero, one, three, or more dots; whitespace; non-base64url alphabet in any segment) SHALL cause the call to throw `JwsError(JWS.MALFORMED)`. For the detached form (RFC 7515 §A.5), the middle segment MAY be empty; the decoder SHALL accept that as a structurally valid token and surface `payload: ""` for the caller to combine with an out-of-band payload at verify time.
#### Scenario: three-segment token parses
- **WHEN** `compactDecode("eyJhbGciOiJIUzI1NiJ9.eyJ0ZXN0IjoxfQ.signaturebytes")` is called with a well-formed token
- **THEN** the result is `{ protected: "eyJhbGciOiJIUzI1NiJ9", payload: "eyJ0ZXN0IjoxfQ", signature: "signaturebytes" }`
#### Scenario: two-segment token rejected
- **WHEN** `compactDecode("eyJhbGciOiJIUzI1NiJ9.eyJ0ZXN0IjoxfQ")` is called
- **THEN** the call throws `JwsError` with code `"JWS.MALFORMED"`
#### Scenario: detached form with empty middle segment accepted
- **WHEN** `compactDecode("eyJhbGciOiJIUzI1NiJ9..signaturebytes")` is called
- **THEN** the result carries `payload: ""` and the call does not throw
**Verified by:** `packages/security/jws/src/serialization/compact.test.ts::"parses a well-formed three-segment token"` AND `packages/security/jws/src/serialization/compact.test.ts::"rejects a two-segment token with JWS.MALFORMED"` AND `packages/security/jws/src/serialization/compact.test.ts::"accepts detached form with empty middle segment"`
---
### Requirement: Compact verify with mandatory algorithm allowlist
The package SHALL export `compactVerify(token, key, opts): Promise<VerifyResult>` per RFC 7515 §5.2. The `opts.algorithms: readonly Alg[]` field SHALL be REQUIRED — there is no default allowlist. When `header.alg` is not a member of `opts.algorithms`, the call SHALL throw `JwsError(JWS.ALG_NOT_ALLOWED)` BEFORE any cryptographic work is performed. This neutralises the entire "alg confusion" attack class where a token presenting an `HS256` header is offered against an RSA public key.
#### Scenario: token with allowed alg verifies
- **GIVEN** a valid `HS256` token signed with key `K`
- **WHEN** `compactVerify(token, K, { algorithms: ["HS256"] })` is called
- **THEN** the call resolves with `{ verified: true, header, payload }`
#### Scenario: token with disallowed alg rejected before crypto
- **GIVEN** a valid `HS256` token signed with key `K`
- **WHEN** `compactVerify(token, K, { algorithms: ["RS256"] })` is called
- **THEN** the call throws `JwsError` with code `"JWS.ALG_NOT_ALLOWED"`
- **AND** no HMAC or RSA verification was performed (asserted via `vi.spyOn` on the algorithm impls when needed)
#### Scenario: missing algorithms option rejected at boundary
- **WHEN** `compactVerify(token, K, {} as never)` is called
- **THEN** the call throws `JwsError` with code `"JWS.INVALID_OPTIONS"` (Zod boundary)
**Verified by:** `packages/security/jws/src/verify/verify-pipeline.test.ts::"verifies token whose alg is on the allowlist"` AND `packages/security/jws/src/verify/verify-pipeline.test.ts::"rejects token with disallowed alg before any crypto runs"` AND `packages/security/jws/src/schemas/schemas.test.ts::"rejects verify options missing the algorithms field"`
---
### Requirement: `alg: "none"` rejected by default; two-flag explicit opt-in
Per RFC 7518 §3.6 implementations MUST NOT accept the `none` algorithm without explicit opt-in. The package SHALL reject `alg: "none"` unless BOTH `opts.algorithms` contains the literal `"none"` AND `opts.acceptNone === true`. Missing either flag SHALL throw `JwsError(JWS.ALG_NONE_DISABLED)`. The double-flag design ensures `algorithms: ["HS256", "none"]` does not silently accept unsigned tokens through a misconfigured allowlist.
#### Scenario: `none` rejected by default
- **GIVEN** a token with `header.alg === "none"` and empty signature
- **WHEN** `compactVerify(token, undefined, { algorithms: ["HS256"] })` is called
- **THEN** the call throws `JwsError` with code `"JWS.ALG_NONE_DISABLED"`
#### Scenario: `none` rejected when only `algorithms` includes it
- **WHEN** `compactVerify(token, undefined, { algorithms: ["none"] })` is called
- **THEN** the call throws `JwsError` with code `"JWS.ALG_NONE_DISABLED"`
#### Scenario: `none` rejected when only `acceptNone` is set
- **WHEN** `compactVerify(token, undefined, { algorithms: ["HS256"], acceptNone: true })` is called
- **THEN** the call throws `JwsError` with code `"JWS.ALG_NOT_ALLOWED"`
#### Scenario: `none` accepted with both flags set explicitly
- **WHEN** `compactVerify(token, undefined, { algorithms: ["none"], acceptNone: true })` is called against a `none`-typed token
- **THEN** the call resolves with `{ verified: true, header, payload }`
**Verified by:** `packages/security/jws/src/algorithms/none.test.ts::"rejects none by default"` AND `packages/security/jws/src/algorithms/none.test.ts::"rejects none when only algorithms includes it"` AND `packages/security/jws/src/algorithms/none.test.ts::"rejects none when only acceptNone is set"` AND `packages/security/jws/src/algorithms/none.test.ts::"accepts none with both flags set"`
---
### Requirement: HMAC verify with constant-time tag compare
The package SHALL verify `HS256`, `HS384`, and `HS512` per RFC 7518 §3.2 using HMAC primitives from `@suaveplan/crypto-hash`. Tag comparison SHALL use `@suaveplan/crypto-utils.constantTimeEqual` — never `Buffer.compare`, never `===`, never any short-circuiting comparison. A tampered signature SHALL throw `JwsError(JWS.SIG_INVALID)`.
#### Scenario: HS256 verifies the RFC 7515 Appendix A.1 vector
- **WHEN** the RFC 7515 Appendix A.1 example token is verified against the RFC's shared secret with `algorithms: ["HS256"]`
- **THEN** the call resolves with `verified: true`
#### Scenario: HS512 round-trips a freshly signed token
- **GIVEN** a 64-byte deterministic shared secret
- **WHEN** the payload `{"sub":"abc"}` is signed with `HS512` and then verified
- **THEN** the verify call resolves with the same payload
#### Scenario: tampered HS256 tag rejected
- **GIVEN** the Appendix A.1 token with the last signature byte flipped
- **WHEN** `compactVerify` is called
- **THEN** the call throws `JwsError` with code `"JWS.SIG_INVALID"`
**Verified by:** `packages/security/jws/src/algorithms/hmac.test.ts::"verifies the RFC 7515 Appendix A.1 HS256 vector"` AND `packages/security/jws/src/algorithms/hmac.test.ts::"round-trips an HS512 token"` AND `packages/security/jws/src/algorithms/hmac.test.ts::"rejects a tampered HS256 tag"`
---
### Requirement: RSASSA-PKCS1-v1_5 verify (RS256 / RS384 / RS512)
The package SHALL verify `RS256`, `RS384`, and `RS512` per RFC 7518 §3.3 using RSA verify primitives from `@suaveplan/crypto-curve`. The verifier SHALL pre-hash the signing input with SHA-256 / SHA-384 / SHA-512 respectively. The RFC 7515 Appendix A.2 RS256 test vector SHALL verify byte-identically.
#### Scenario: RS256 verifies the RFC 7515 Appendix A.2 vector
- **WHEN** the Appendix A.2 token is verified against the appendix's RSA public key with `algorithms: ["RS256"]`
- **THEN** the call resolves with `verified: true`
#### Scenario: RS384 round-trips with a generated 2048-bit RSA key
- **GIVEN** a deterministically generated 2048-bit RSA keypair from `@suaveplan/crypto-curve`
- **WHEN** the payload `{"sub":"abc"}` is signed with `RS384` and verified
- **THEN** the verify call resolves with `verified: true`
#### Scenario: tampered RS256 signature rejected
- **WHEN** the Appendix A.2 token is verified after flipping a byte in the signature
- **THEN** the call throws `JwsError` with code `"JWS.SIG_INVALID"`
**Verified by:** `packages/security/jws/src/algorithms/rsa.test.ts::"verifies the RFC 7515 Appendix A.2 RS256 vector"` AND `packages/security/jws/src/algorithms/rsa.test.ts::"round-trips an RS384 token"` AND `packages/security/jws/src/algorithms/rsa.test.ts::"rejects a tampered RS256 signature"`
---
### Requirement: RSASSA-PSS verify (PS256 / PS384 / PS512) with hash-length salt
The package SHALL verify `PS256`, `PS384`, and `PS512` per RFC 7518 §3.5. The salt length SHALL equal the hash output length per RFC 7518 §3.5 ("Salt Length shall be equal to the size of the hash output"). A signature produced with a different salt length SHALL be rejected with `JwsError(JWS.SIG_INVALID)`.
#### Scenario: PS256 round-trips with the RFC-mandated salt length
- **GIVEN** a deterministically generated 2048-bit RSA keypair
- **WHEN** a payload is signed with `PS256` (32-byte salt) and verified
- **THEN** the verify call resolves with `verified: true`
#### Scenario: PS256 signature with mismatched salt rejected
- **GIVEN** an externally produced PS256 signature with a 16-byte salt
- **WHEN** `compactVerify` is called
- **THEN** the call throws `JwsError` with code `"JWS.SIG_INVALID"`
**Verified by:** `packages/security/jws/src/algorithms/rsa-pss.test.ts::"round-trips a PS256 token with 32-byte salt"` AND `packages/security/jws/src/algorithms/rsa-pss.test.ts::"rejects a PS256 signature with mismatched salt length"`
---
### Requirement: ECDSA verify (ES256 / ES384 / ES512) with fixed-length R||S
The package SHALL verify `ES256` (P-256 + SHA-256), `ES384` (P-384 + SHA-384), and `ES512` (P-521 + SHA-512) per RFC 7518 §3.4. Signatures SHALL be the fixed-length concatenation `R || S` of the two coordinates (64 bytes for `ES256`, 96 for `ES384`, 132 for `ES512`). An ASN.1 DER-encoded signature in the JOSE position SHALL be rejected with `JwsError(JWS.SIG_FORMAT_INVALID)` — the package SHALL NOT silently parse DER as some implementations do, because that hides protocol drift.
#### Scenario: ES256 verifies the RFC 7515 Appendix A.3 vector
- **WHEN** the Appendix A.3 token is verified against the appendix's P-256 public key with `algorithms: ["ES256"]`
- **THEN** the call resolves with `verified: true`
#### Scenario: ES384 round-trips with a generated P-384 keypair
- **WHEN** a payload is signed with `ES384` and verified against the matching public key
- **THEN** the verify call resolves with `verified: true`
#### Scenario: ES512 round-trips with a generated P-521 keypair
- **WHEN** a payload is signed with `ES512` and verified
- **THEN** the verify call resolves with `verified: true`
#### Scenario: DER-encoded signature in the JOSE position rejected
- **GIVEN** an ES256 token whose signature segment carries a DER-encoded `SEQUENCE` instead of fixed-length `R||S`
- **WHEN** `compactVerify` is called
- **THEN** the call throws `JwsError` with code `"JWS.SIG_FORMAT_INVALID"`
**Verified by:** `packages/security/jws/src/algorithms/ecdsa.test.ts::"verifies the RFC 7515 Appendix A.3 ES256 vector"` AND `packages/security/jws/src/algorithms/ecdsa.test.ts::"round-trips an ES384 token"` AND `packages/security/jws/src/algorithms/ecdsa.test.ts::"round-trips an ES512 token"` AND `packages/security/jws/src/algorithms/ecdsa.test.ts::"rejects a DER-encoded signature with SIG_FORMAT_INVALID"`
---
### Requirement: EdDSA verify per RFC 8037
The package SHALL verify `EdDSA` per RFC 8037 §3.1 using Ed25519 verify primitives from `@suaveplan/crypto-curve/ed25519`. The RFC 8037 Appendix A.4 test vector SHALL verify byte-identically. The package SHALL NOT accept `EdDSA` against a non-Ed25519 key (e.g. an OKP key with `crv: "Ed448"`) — that mismatch SHALL throw `JwsError(JWS.KEY_ALG_MISMATCH)` until the algorithm registry is extended for Ed448.
#### Scenario: RFC 8037 Appendix A.4 vector verifies
- **WHEN** the Appendix A.4 token is verified against the appendix's Ed25519 public key with `algorithms: ["EdDSA"]`
- **THEN** the call resolves with `verified: true`
#### Scenario: EdDSA round-trips with a generated Ed25519 keypair
- **WHEN** a payload is signed with `EdDSA` and verified
- **THEN** the verify call resolves with `verified: true`
#### Scenario: EdDSA against an Ed448 OKP key rejected
- **WHEN** an `EdDSA` token's `header.jwk` is an OKP key with `crv: "Ed448"`
- **THEN** `compactVerify` throws `JwsError` with code `"JWS.KEY_ALG_MISMATCH"`
**Verified by:** `packages/security/jws/src/algorithms/eddsa.test.ts::"verifies the RFC 8037 Appendix A.4 vector"` AND `packages/security/jws/src/algorithms/eddsa.test.ts::"round-trips an EdDSA token"` AND `packages/security/jws/src/algorithms/eddsa.test.ts::"rejects EdDSA against Ed448 OKP with KEY_ALG_MISMATCH"`
---
### Requirement: Embedded JWK (`header.jwk`) parsed via `jwkToCryptoKey`
When `header.jwk` is present and `opts.key` is absent, the package SHALL parse the embedded JWK via `jwkToCryptoKey(jwk, alg)` per RFC 7515 §4.1.3 + RFC 7517 §4 + RFC 7518 §6, and use the resulting key for verification. The parser SHALL validate `kty` / `crv` / `n,e` / `x,y` consistency against the declared `alg`; mismatch SHALL throw `JwsError(JWS.JWK_INVALID)`.
#### Scenario: embedded ES256 JWK used for verify
- **GIVEN** a token with `header.alg: "ES256"` and `header.jwk` carrying the matching P-256 public key
- **WHEN** `compactVerify(token, undefined, { algorithms: ["ES256"], allowEmbeddedJwk: true })` is called
- **THEN** the call resolves with `verified: true`
#### Scenario: JWK with mismatched `kty` rejected
- **GIVEN** a token with `header.alg: "ES256"` and `header.jwk.kty: "RSA"`
- **WHEN** `compactVerify` is called
- **THEN** the call throws `JwsError` with code `"JWS.JWK_INVALID"`
**Verified by:** `packages/security/jws/src/keys/jwk.test.ts::"verifies a token whose header.jwk matches the declared alg"` AND `packages/security/jws/src/keys/jwk.test.ts::"rejects a token whose header.jwk.kty disagrees with alg"`
---
### Requirement: Embedded `x5c` chain validated via `@suaveplan/x509`
When `header.x5c` is present and `opts.key` is absent, the package SHALL decode the base64-encoded certificate array per RFC 7515 §4.1.6, extract the leaf SPKI, and delegate chain validation to `@suaveplan/x509.validateChain(certs, { trustAnchors, now })` using the caller-supplied `opts.x5cTrustAnchors`. The package SHALL NOT perform any chain validation itself beyond confirming the leaf public-key algorithm matches the declared `alg`. Chain failure (expiry, untrusted root, broken signature chain, EKU mismatch, name-constraints violation, revocation) SHALL surface as `JwsError(JWS.X5C_CHAIN_INVALID)` with the underlying x509 error chained as `cause`. Leaf alg mismatch SHALL throw `JwsError(JWS.KEY_ALG_MISMATCH)`.
#### Scenario: valid `x5c` chain accepted
- **GIVEN** a token signed with the leaf of a real 3-cert chain and `header.x5c` carrying the chain
- **WHEN** `compactVerify(token, undefined, { algorithms: ["RS256"], allowX5c: true, x5cTrustAnchors })` is called with the matching trust anchor set
- **THEN** the call resolves with `verified: true`
#### Scenario: untrusted `x5c` root rejected
- **GIVEN** the same token verified against a trust-anchor set that does not include the chain's root
- **WHEN** `compactVerify` is called
- **THEN** the call throws `JwsError` with code `"JWS.X5C_CHAIN_INVALID"` and `error.cause` is the `@suaveplan/x509` chain error
#### Scenario: leaf-key alg mismatch rejected
- **GIVEN** a token declaring `alg: "ES256"` whose `x5c` leaf is an RSA cert
- **WHEN** `compactVerify` is called with a valid trust-anchor set
- **THEN** the call throws `JwsError` with code `"JWS.KEY_ALG_MISMATCH"`
**Verified by:** `packages/security/jws/src/keys/x5c.test.ts::"verifies a token with a valid x5c chain"` AND `packages/security/jws/src/keys/x5c.test.ts::"rejects a token whose x5c root is not in the trust anchors"` AND `packages/security/jws/src/keys/x5c.test.ts::"rejects a token whose x5c leaf alg disagrees with header.alg"`
---
### Requirement: `kid` resolved via injectable key resolver
When `header.kid` is present and neither `opts.key` nor `header.jwk` nor `header.x5c` provides a key, the package SHALL call `opts.keyResolver(kid, header): Promise<KeyLike | undefined>`. A resolver returning `undefined` SHALL surface as `JwsError(JWS.KEY_NOT_FOUND)`. The resolver SHALL receive the parsed header so it can dispatch on additional parameters (e.g. `iss`, custom `tenant`-style headers).
#### Scenario: resolver returns the matching key
- **GIVEN** `opts.keyResolver = async (kid) => keyStore.get(kid)` and a token with `header.kid: "k1"`
- **WHEN** `compactVerify(token, undefined, { algorithms: ["HS256"], keyResolver })` is called
- **THEN** the resolver is awaited exactly once with `("k1", header)` and the call resolves with `verified: true`
#### Scenario: resolver returns undefined → KEY_NOT_FOUND
- **GIVEN** `opts.keyResolver = async () => undefined`
- **WHEN** `compactVerify(token, undefined, { algorithms: ["HS256"], keyResolver })` is called
- **THEN** the call throws `JwsError` with code `"JWS.KEY_NOT_FOUND"`
**Verified by:** `packages/security/jws/src/keys/resolver.test.ts::"awaits resolver and verifies with returned key"` AND `packages/security/jws/src/keys/resolver.test.ts::"throws KEY_NOT_FOUND when resolver returns undefined"`
---
### Requirement: `crit` enforcement against caller allowlist
Every header parameter named in `header.crit` SHALL be in `opts.acceptableCriticalHeaders` per RFC 7515 §4.1.11. An empty `crit` array, a `crit` value that is not an array of strings, or any entry not in the allowlist SHALL throw — `JwsError(JWS.CRIT_MALFORMED)` for shape failures and `JwsError(JWS.CRIT_UNRECOGNIZED)` for unknown entries. Per RFC 7515 §4.1.11 the `crit` header itself MUST NOT appear inside its own list.
#### Scenario: known critical header accepted
- **GIVEN** `header.crit: ["exp"]` and `opts.acceptableCriticalHeaders: ["exp"]`
- **WHEN** `compactVerify` is called
- **THEN** the call resolves successfully
#### Scenario: unknown critical header rejected
- **GIVEN** `header.crit: ["exp", "tenant"]` and `opts.acceptableCriticalHeaders: ["exp"]`
- **WHEN** `compactVerify` is called
- **THEN** the call throws `JwsError` with code `"JWS.CRIT_UNRECOGNIZED"`
#### Scenario: empty `crit` array rejected
- **GIVEN** `header.crit: []`
- **WHEN** `compactVerify` is called
- **THEN** the call throws `JwsError` with code `"JWS.CRIT_MALFORMED"`
#### Scenario: non-array `crit` rejected
- **GIVEN** `header.crit: "exp"`
- **WHEN** `compactVerify` is called
- **THEN** the call throws `JwsError` with code `"JWS.CRIT_MALFORMED"`
**Verified by:** `packages/security/jws/src/header/crit.test.ts::"accepts a known critical header on the allowlist"` AND `packages/security/jws/src/header/crit.test.ts::"rejects an unknown critical header with CRIT_UNRECOGNIZED"` AND `packages/security/jws/src/header/crit.test.ts::"rejects an empty crit array with CRIT_MALFORMED"` AND `packages/security/jws/src/header/crit.test.ts::"rejects a non-array crit with CRIT_MALFORMED"`
---
### Requirement: JSON Flattened serialization decode + verify + sign
The package SHALL implement `flatDecode`, `flatVerify`, and `flatSign` per RFC 7515 §3.2 + §7.2.2. The wire shape is `{ protected?, header?, payload, signature }` where `protected` is base64url-encoded, `header` is an unprotected JSON object, and `signature` is base64url. Verification SHALL re-derive the signing input from `protected || "." || payload` exactly as for the compact form.
#### Scenario: Appendix A.7 JSON Flattened vector verifies
- **WHEN** the RFC 7515 Appendix A.7 JSON Flattened example is verified with `algorithms: ["HS256"]`
- **THEN** the call resolves with `verified: true`
#### Scenario: flat round-trip with unprotected header
- **GIVEN** `protectedHeader: { alg: "HS256" }` and `unprotectedHeader: { kid: "k1" }`
- **WHEN** the payload is signed via `flatSign` and verified via `flatVerify`
- **THEN** the unprotected header is preserved on the decode result
**Verified by:** `packages/security/jws/src/serialization/flat.test.ts::"verifies the RFC 7515 Appendix A.7 JSON Flattened vector"` AND `packages/security/jws/src/serialization/flat.test.ts::"round-trips a flat JWS with an unprotected header"`
---
### Requirement: JSON General multi-signature serialization decode + verify + sign
The package SHALL implement `generalDecode`, `generalVerify`, and `generalSign` per RFC 7515 §3.2 + §7.2.1. The wire shape is `{ payload, signatures: JwsSignature[] }` where each signature carries its own `protected` / `header` / `signature`. `generalSign(payload, signers[])` SHALL produce one signature per signer; `generalVerify(json, keyResolver, opts)` SHALL call the resolver per signer and return `{ verifiedIndices, signers }`. If `opts.requireAll === true`, any single signature failure SHALL throw `JwsError(JWS.MULTI_SIG_FAILED)`; otherwise the result enumerates verified-vs-failed indices.
#### Scenario: Appendix A.6 multi-sig vector decodes and verifies
- **WHEN** the RFC 7515 Appendix A.6 example (RS256 + ES256 signatures) is verified with both keys via a resolver
- **THEN** the call resolves with `verifiedIndices: [0, 1]`
#### Scenario: requireAll surfaces the first failing index
- **GIVEN** a 3-signer general JWS where signer 1's signature has been tampered with
- **WHEN** `generalVerify(json, resolver, { requireAll: true, algorithms: ["HS256","RS256","ES256"] })` is called
- **THEN** the call throws `JwsError` with code `"JWS.MULTI_SIG_FAILED"` and `error.context.failedIndex === 1`
**Verified by:** `packages/security/jws/src/serialization/general.test.ts::"verifies the RFC 7515 Appendix A.6 multi-sig vector"` AND `packages/security/jws/src/serialization/general.test.ts::"throws MULTI_SIG_FAILED with the failing index under requireAll"`
---
### Requirement: Compact sign produces verifiable tokens
The package SHALL export `compactSign(payload: Uint8Array | string, key, alg, opts?): Promise<string>` per RFC 7515 §5.1. The signing input SHALL be `BASE64URL(protected) || "." || BASE64URL(payload)` (or the raw payload bytes when `b64: false`). The returned token SHALL be verifiable by `compactVerify` against the matching public key (asymmetric) or the same secret (symmetric).
#### Scenario: HS256 sign → verify round-trip
- **WHEN** `compactSign("hello", sharedSecret, "HS256")` is called and the result is passed to `compactVerify(token, sharedSecret, { algorithms: ["HS256"] })`
- **THEN** the verify call resolves with `payload === "hello"`
#### Scenario: ES256 sign → verify round-trip
- **WHEN** `compactSign(payload, privateKey, "ES256")` is called and the result is verified against the matching public key
- **THEN** the verify call resolves with `verified: true`
**Verified by:** `packages/security/jws/src/sign/sign-pipeline.test.ts::"HS256 round-trips through compactSign and compactVerify"` AND `packages/security/jws/src/sign/sign-pipeline.test.ts::"ES256 round-trips through compactSign and compactVerify"`
---
### Requirement: Detached payload (RFC 7515 §A.5 / Appendix F)
The package SHALL support the detached form. `compactSign(payload, key, alg, { detached: true })` SHALL emit a token whose middle segment is empty. `compactVerify(token, key, { detachedPayload, algorithms })` SHALL accept the payload out-of-band as a `Uint8Array` or string and reconstruct the signing input. Calling `compactVerify` on a detached token without `detachedPayload` SHALL throw `JwsError(JWS.INVALID_OPTIONS)`. The same surface SHALL be available on `flatSign`/`flatVerify` via the same options.
#### Scenario: detached sign emits empty middle segment
- **WHEN** `compactSign("data", key, "HS256", { detached: true })` is called
- **THEN** the returned token has the form `<header>..<sig>` (exactly two dots, empty middle)
#### Scenario: detached verify with payload supplied out-of-band
- **GIVEN** a detached token produced for payload `"data"`
- **WHEN** `compactVerify(token, key, { detachedPayload: "data", algorithms: ["HS256"] })` is called
- **THEN** the call resolves with `verified: true`
#### Scenario: detached verify without `detachedPayload` rejected
- **WHEN** `compactVerify(detachedToken, key, { algorithms: ["HS256"] })` is called
- **THEN** the call throws `JwsError` with code `"JWS.INVALID_OPTIONS"`
**Verified by:** `packages/security/jws/src/detached/detached.test.ts::"compactSign with detached: true emits empty middle segment"` AND `packages/security/jws/src/detached/detached.test.ts::"compactVerify accepts detachedPayload out-of-band"` AND `packages/security/jws/src/detached/detached.test.ts::"compactVerify on a detached token without detachedPayload throws INVALID_OPTIONS"`
---
### Requirement: RFC 7797 `b64` header parameter — encode and decode
The package SHALL implement the `b64` header parameter per RFC 7797. When `b64: false` is present in `protected`, (a) `b64` MUST also appear in `crit` per RFC 7797 §3 or `JwsError(JWS.B64_INVALID)` is thrown, (b) the signing input is `BASE64URL(protected) || "." || rawPayloadString` (raw payload, not base64url-encoded), (c) the compact-form middle segment is the raw payload string itself. Any value of `b64` other than `true` or `false` SHALL throw `JwsError(JWS.B64_INVALID)`.
#### Scenario: `b64: false` without `crit` rejected
- **GIVEN** a token with `protected: { alg: "HS256", b64: false }` and no `crit` entry
- **WHEN** `compactVerify` is called
- **THEN** the call throws `JwsError` with code `"JWS.B64_INVALID"`
#### Scenario: `b64: false` with `crit` round-trips
- **GIVEN** `protected: { alg: "HS256", b64: false, crit: ["b64"] }` and `acceptableCriticalHeaders: ["b64"]`
- **WHEN** `compactSign("data", key, "HS256", { protectedHeader, b64: false })` is called and verified
- **THEN** the verify call resolves with `payload === "data"` (raw, not base64url-decoded)
#### Scenario: non-boolean `b64` rejected
- **GIVEN** `protected: { alg: "HS256", b64: "false" }`
- **WHEN** `compactVerify` is called
- **THEN** the call throws `JwsError` with code `"JWS.B64_INVALID"`
**Verified by:** `packages/security/jws/src/header/b64.test.ts::"rejects b64 false without crit listing"` AND `packages/security/jws/src/header/b64.test.ts::"round-trips a b64 false token with raw payload"` AND `packages/security/jws/src/header/b64.test.ts::"rejects non-boolean b64 with B64_INVALID"`
---
### Requirement: Error registry — every code thrown by ≥1 production path
The package SHALL export a `JWS_CODES` registry via `defineCodes("JWS", [...])` listing exactly 18 codes: `MALFORMED`, `HEADER_MALFORMED`, `BASE64URL_INVALID`, `ALG_UNSUPPORTED`, `ALG_NOT_ALLOWED`, `ALG_NONE_DISABLED`, `SIG_INVALID`, `SIG_FORMAT_INVALID`, `JWK_INVALID`, `X5C_CHAIN_INVALID`, `KEY_NOT_FOUND`, `KEY_ALG_MISMATCH`, `CRIT_UNRECOGNIZED`, `CRIT_MALFORMED`, `B64_INVALID`, `INVALID_OPTIONS`, `ABORTED`, `MULTI_SIG_FAILED`. Every code SHALL be thrown by at least one production code path (or carried on a `JwsError.context` field for outcome-tagged errors). `JwsError extends SuaveplanError<JwsErrorCode>` SHALL be the only error type thrown by the public API.
#### Scenario: every registered code is reachable
- **WHEN** the dedicated coverage matrix in `error-codes.test.ts` walks the registry
- **THEN** every code in `JWS_CODES` has at least one `it(...)` that asserts a path throws or surfaces it
#### Scenario: error type pinned
- **WHEN** `expectTypeOf<JwsErrorCode>().toEqualTypeOf<typeof JWS_CODES[number]>()` is asserted
- **THEN** the literal union exactly matches the registry
**Verified by:** `packages/security/jws/src/errors/error-codes.test.ts::"every registered JWS code is thrown by at least one production path"` AND `packages/security/jws/src/errors/error-codes.test.ts::"JwsErrorCode union exactly matches JWS_CODES"`
---
### Requirement: Zod boundary schemas at every header / payload entry point
Every public entry point (`compactVerify`, `compactSign`, `flatVerify`, `flatSign`, `generalVerify`, `generalSign`, `parseHeader`, `jwkToCryptoKey`) SHALL validate its inputs through a Zod schema before any computation. Malformed options SHALL throw `JwsError(JWS.INVALID_OPTIONS)`; malformed parsed headers SHALL throw `JwsError(JWS.HEADER_MALFORMED)`. The schemas SHALL be exported from `./schemas` for downstream consumers to compose.
#### Scenario: missing required `algorithms` option rejected at boundary
- **WHEN** `compactVerify(token, key, {} as never)` is called
- **THEN** the call throws `JwsError` with code `"JWS.INVALID_OPTIONS"`
#### Scenario: header with non-string `alg` rejected
- **GIVEN** a base64url-encoded JSON header where `alg` is `42`
- **WHEN** `parseHeader` is called
- **THEN** the call throws `JwsError` with code `"JWS.HEADER_MALFORMED"`
**Verified by:** `packages/security/jws/src/schemas/schemas.test.ts::"rejects verify options missing the algorithms field"` AND `packages/security/jws/src/schemas/schemas.test.ts::"rejects a header with non-string alg as HEADER_MALFORMED"`
---
### Requirement: OpenTelemetry surface for verify and sign
The package SHALL emit, from production paths, the histograms `jws.verify_duration_ms{alg,outcome}` and `jws.sign_duration_ms{alg}` plus the counters `jws.verify_total{alg,outcome}`, `jws.sign_total{alg,outcome}`, and `jws.error_total{code}`. The `outcome` attribute SHALL be bounded to the 4-value enum `success | sig_invalid | alg_not_allowed | header_invalid`. The `alg` attribute SHALL be one of the 12 supported algorithm names (eleven RFC 7518 §3 + `EdDSA`) plus `"none"` plus `"unknown"` for algorithms that fail allowlist or registry lookup before dispatch. Cardinality is bounded — no caller-controlled values flow into attribute keys.
#### Scenario: verify success emits histogram + success counter
- **GIVEN** an in-memory `MeterProvider` registered as the global meter
- **WHEN** `compactVerify` returns successfully for an `HS256` token
- **THEN** `jws.verify_duration_ms{alg=HS256,outcome=success}` has 1 recorded value AND `jws.verify_total{alg=HS256,outcome=success}` equals 1
#### Scenario: verify failure increments error counter
- **WHEN** `compactVerify` throws `JWS.SIG_INVALID` for a tampered token
- **THEN** `jws.error_total{code=JWS.SIG_INVALID}` equals 1 AND `jws.verify_total{alg=HS256,outcome=sig_invalid}` equals 1
**Verified by:** `packages/security/jws/src/observability/metrics.test.ts::"emits verify histogram and success counter on successful verify"` AND `packages/security/jws/src/observability/metrics.test.ts::"increments error counter and outcome counter on tampered signature"`
---
### Requirement: AbortSignal honored on verify and sign
Every async entry point (`compactVerify`, `compactSign`, `flatVerify`, `flatSign`, `generalVerify`, `generalSign`) SHALL accept `opts.signal?: AbortSignal`. A pre-aborted signal SHALL cause the call to throw `JwsError(JWS.ABORTED)` before any header parsing or cryptographic work runs. A signal aborted between phases (after header parse, before signature verify) SHALL likewise throw `JwsError(JWS.ABORTED)`.
#### Scenario: pre-aborted signal short-circuits
- **GIVEN** `controller = new AbortController(); controller.abort();`
- **WHEN** `compactVerify(token, key, { algorithms: ["HS256"], signal: controller.signal })` is called
- **THEN** the call throws `JwsError` with code `"JWS.ABORTED"` before `parseHeader` runs
#### Scenario: signal aborted between phases short-circuits before signature verify
- **GIVEN** a verify call whose signal is aborted via `queueMicrotask(() => controller.abort())` after header parse
- **WHEN** `compactVerify` proceeds
- **THEN** the call throws `JwsError` with code `"JWS.ABORTED"` and the algorithm `verify` was never invoked
**Verified by:** `packages/security/jws/src/verify/abort.test.ts::"throws JWS.ABORTED on pre-aborted signal"` AND `packages/security/jws/src/verify/abort.test.ts::"throws JWS.ABORTED when signal aborts between phases"`
---
### Requirement: RFC 7515 Appendix A test vectors — all green
The package SHALL produce byte-identical results for every test vector in RFC 7515 Appendix A: A.1 (HS256 compact), A.2 (RS256 compact), A.3 (ES256 compact), A.4 (ES512 compact), A.5 (detached), A.6 (JSON General multi-sig with RS256 + ES256), A.7 (JSON Flattened). The conformance suite SHALL re-derive signing input, base64url-encode every segment, and run verify per the RFC's published expected outcome.
#### Scenario: A.1 HS256 compact verifies byte-identically
- **WHEN** the Appendix A.1 token is verified
- **THEN** the call resolves with `verified: true` AND the recomputed signing input matches the appendix-published bytes
#### Scenario: A.5 detached verifies with payload supplied out-of-band
- **WHEN** the Appendix A.5 detached token is verified with the appendix's detached payload
- **THEN** the call resolves with `verified: true`
#### Scenario: A.6 multi-sig verifies both signers
- **WHEN** the Appendix A.6 general-serialization token is verified with both keys via a resolver
- **THEN** `verifiedIndices` equals `[0, 1]`
**Verified by:** `packages/security/jws/src/serialization/compact.spec-conformance.test.ts::"verifies the RFC 7515 Appendix A.1 HS256 vector"` AND `packages/security/jws/src/serialization/compact.spec-conformance.test.ts::"verifies the RFC 7515 Appendix A.2 RS256 vector"` AND `packages/security/jws/src/serialization/compact.spec-conformance.test.ts::"verifies the RFC 7515 Appendix A.3 ES256 vector"` AND `packages/security/jws/src/serialization/compact.spec-conformance.test.ts::"verifies the RFC 7515 Appendix A.4 ES512 vector"` AND `packages/security/jws/src/detached/detached.test.ts::"verifies the RFC 7515 Appendix A.5 detached vector"` AND `packages/security/jws/src/serialization/general.test.ts::"verifies the RFC 7515 Appendix A.6 multi-sig vector"` AND `packages/security/jws/src/serialization/flat.test.ts::"verifies the RFC 7515 Appendix A.7 JSON Flattened vector"`
<!-- markdownlint-enable MD024 -->

View File

@@ -0,0 +1,170 @@
# Tasks — `@suaveplan/jws` (RFC 7515 JSON Web Signature: verify + sign)
> Every checkbox below MUST reflect work actually done in this change. Do not retroactively tick boxes. Do not tick a box for a test that doesn't exist or doesn't pin the requirement it claims to verify.
## 1. Spec authoring
- [x] 1.1 Read `../suaveplan/enterprise/jws.md` end-to-end
- [x] 1.2 Read RFC 7515 (JWS), RFC 7517 (JWK), RFC 7518 (JWA), RFC 8037 (CFRG curves in JOSE), RFC 7797 (b64) end-to-end
- [x] 1.3 Read `openspec/AGENTS.md` Sections 13, 14, 14a
- [x] 1.4 Confirm `proposal.md` covers every public-surface item, every subpath, every dependency
- [x] 1.5 Confirm `specs/jws/spec.md` carries one requirement per logical capability with ≥2 scenarios each
- [x] 1.6 Confirm every requirement has a `**Verified by:**` line naming concrete `it(...)` titles in concrete test file paths
- [x] 1.7 Source-mapping table cites every RFC section consumed
## 2. Package scaffold
- [x] 2.1 Create `packages/security/jws/` with `src/{base64url,serialization,header,algorithms,keys,detached,verify,sign,errors,observability,schemas,__benchmarks__}/`
- [x] 2.2 `package.json` — `@suaveplan/jws@0.0.0`, `type: module`, `sideEffects: false`, deps `@suaveplan/x509`, `@suaveplan/crypto-curve`, `@suaveplan/crypto-hash`, `@suaveplan/crypto-utils`, `@suaveplan/error`, `@suaveplan/types`, `@opentelemetry/api`, `zod`, `tslib` via catalog refs only (no bare versions)
- [x] 2.3 `package.json` `exports` map declares `.`, `./serialization/compact`, `./serialization/flat`, `./serialization/general`, `./header`, `./algorithms`, `./keys`, `./detached`, `./base64url`, `./errors` (10 subpaths) — each mapped to its built `dist/<feature>/<feature>.js` + `.d.ts` pair
- [x] 2.4 `tsconfig.lib.json` extends `@suaveplan/typescript-config/library.json`
- [x] 2.5 `vite.config.ts` multi-entry build (one per subpath) + `vitest.config.ts` calling `createTestConfig()` from `@suaveplan/testing/config`
- [x] 2.6 `README.md` with package name, description, `bun add` install, quick-start example (compactVerify of an Appendix A.1 token), Modules table linking every co-located `.md`
## 3. Implementation — base64url
- [x] 3.1 `src/base64url/base64url.ts` — `base64urlEncode(bytes): string` and `base64urlDecode(str): Uint8Array` per RFC 7515 Appendix C (no padding, no `+`/`/`)
- [x] 3.2 Malformed input throws `JWS.BASE64URL_INVALID`
- [x] 3.3 `src/base64url/base64url.test.ts` — Appendix C round-trip + every malformed-alphabet case
## 4. Implementation — serializations
- [x] 4.1 `src/serialization/compact.ts` — `compactDecode`, `compactVerify`, `compactSign` per RFC 7515 §3.1 + §7.1
- [x] 4.2 `src/serialization/flat.ts` — `flatDecode`, `flatVerify`, `flatSign` per RFC 7515 §3.2 + §7.2.2
- [x] 4.3 `src/serialization/general.ts` — `generalDecode`, `generalVerify`, `generalSign` per RFC 7515 §3.2 + §7.2.1
- [x] 4.4 `src/serialization/serialization.types.ts` — `CompactJws`, `FlatJws`, `GeneralJws`, `JwsSignature`, `JwsSerialization` discriminator
- [x] 4.5 Signing input formation per RFC 7515 §5.1 step 5 (with RFC 7797 `b64: false` branch)
## 5. Implementation — headers
- [x] 5.1 `src/header/header.ts` — `parseHeader`, `serializeHeader`; every RFC 7515 §4.1 registered parameter
- [x] 5.2 `src/header/crit.ts` — `enforceCrit(header, acceptableCriticalHeaders)`; empty / non-array / unknown entry rejections
- [x] 5.3 `src/header/b64.ts` — RFC 7797 `b64` parameter; MUST appear in `crit` when `false`; signing input branch
## 6. Implementation — algorithms
- [x] 6.1 `src/algorithms/registry.ts` — `AlgorithmRegistry.register` / `lookup` / `lookupOrThrow`; built-ins registered at module load
- [x] 6.2 `src/algorithms/hmac.ts` — `HS256`/`HS384`/`HS512` per RFC 7518 §3.2; `constantTimeEqual` over tag bytes
- [x] 6.3 `src/algorithms/rsa.ts` — `RS256`/`RS384`/`RS512` per RFC 7518 §3.3
- [x] 6.4 `src/algorithms/rsa-pss.ts` — `PS256`/`PS384`/`PS512` per RFC 7518 §3.5; salt length = hash output length
- [x] 6.5 `src/algorithms/ecdsa.ts` — `ES256`/`ES384`/`ES512` per RFC 7518 §3.4; fixed-length `R||S`; ASN.1 DER input rejected
- [x] 6.6 `src/algorithms/eddsa.ts` — `EdDSA` (Ed25519) per RFC 8037 §3.1
- [x] 6.7 `src/algorithms/none.ts` — `"none"` per RFC 7518 §3.6; rejected unless `algorithms: ["none"]` AND `acceptNone: true`
## 7. Implementation — keys
- [x] 7.1 `src/keys/jwk.ts` — `jwkToCryptoKey(jwk, alg)` per RFC 7517 §4 + RFC 7518 §6; `kty`/`alg` consistency check
- [x] 7.2 `src/keys/x5c.ts` — `extractX5cLeafKey`; chain delegated to `@suaveplan/x509.validateChain`; leaf SPKI extraction + alg match
- [x] 7.3 `src/keys/resolver.ts` — `KeyResolver` type + helpers; `kid`-driven lookup; missing key → `KEY_NOT_FOUND`
## 8. Implementation — detached payload + RFC 7797 b64
- [x] 8.1 `src/detached/detached.ts` — RFC 7515 §A.5 / Appendix F detached helpers; empty middle segment on sign; `detachedPayload` injection on verify
- [x] 8.2 RFC 7797 `b64: false` path tested with both compact and flat serializations
## 9. Implementation — verify + sign pipelines
- [x] 9.1 `src/verify/verify-pipeline.ts` — parse header → enforce `crit` → enforce `alg` allowlist → enforce `alg !== "none"` unless opted in → resolve key (explicit → `jwk` → `x5c` → `kid`) → recompute signing input → dispatch to algorithm `verify` → return `VerifyResult`
- [x] 9.2 `src/sign/sign-pipeline.ts` — validate options → resolve algorithm → assemble protected header → compute signing input → dispatch to algorithm `sign` → assemble serialization
- [x] 9.3 `AbortSignal` honored in both pipelines; pre-aborted signal throws `JWS.ABORTED`; abort between phases throws `JWS.ABORTED`
## 10. Implementation — errors, schemas, observability
- [x] 10.1 `src/errors/error-codes.ts` — `defineCodes("JWS", [...])` 18-code registry: `MALFORMED`, `HEADER_MALFORMED`, `BASE64URL_INVALID`, `ALG_UNSUPPORTED`, `ALG_NOT_ALLOWED`, `ALG_NONE_DISABLED`, `SIG_INVALID`, `SIG_FORMAT_INVALID`, `JWK_INVALID`, `X5C_CHAIN_INVALID`, `KEY_NOT_FOUND`, `KEY_ALG_MISMATCH`, `CRIT_UNRECOGNIZED`, `CRIT_MALFORMED`, `B64_INVALID`, `INVALID_OPTIONS`, `ABORTED`, `MULTI_SIG_FAILED`; `JwsError extends SuaveplanError<JwsErrorCode>`
- [x] 10.2 `src/observability/metrics.ts` — `jws.verify_duration_ms{alg,outcome}` + `jws.sign_duration_ms{alg}` histograms; `jws.verify_total{alg,outcome}` + `jws.sign_total{alg,outcome}` + `jws.error_total{code}` counters; `outcome` bounded to 4 values
- [x] 10.3 `src/schemas/schemas.ts` — Zod schemas for `JoseHeader`, `VerifyOptions`, `SignOptions`, `FlatJws`, `GeneralJws`, `JsonWebKey`; validated at every public entry point
- [x] 10.4 Every code in the registry is thrown by ≥1 production code path; verified by `error-codes.test.ts`
## 11. Root barrel + subpath barrels
- [x] 11.1 `src/index.ts` — explicit named re-exports (no `export *` in feature barrels); namespace re-exports allowed for sub-namespaces
- [x] 11.2 Every `<feature>/index.ts` uses explicit named re-exports per AGENTS.md §6a
- [x] 11.3 `package.json` `exports` map matches the 10 subpaths declared in §2.3
## 12. Tests (runner-neutral imports — `@suaveplan/testing/runner` only)
- [x] 12.1 `src/base64url/base64url.test.ts` — Appendix C round-trip; malformed alphabet rejection
- [x] 12.2 `src/serialization/compact.test.ts` — three-segment parse + assembly; malformed-segment rejection
- [x] 12.3 `src/serialization/compact.spec-conformance.test.ts` — RFC 7515 Appendix A.1–A.4 vectors round-trip byte-identically
- [x] 12.4 `src/serialization/flat.test.ts` — JSON-flat decode / verify / sign; Appendix A.7 vector
- [x] 12.5 `src/serialization/general.test.ts` — multi-signer encode / decode / verify; Appendix A.6 vector
- [x] 12.6 `src/header/header.test.ts` — every RFC 7515 §4.1 registered parameter parses; unknown parameter survives round-trip
- [x] 12.7 `src/header/crit.test.ts` — empty / non-array / unknown / allowlisted entry behaviours
- [x] 12.8 `src/header/b64.test.ts` — `b64: false` without `crit` rejected; with `crit` round-trips; signing input uses raw payload
- [x] 12.9 `src/algorithms/registry.test.ts` — built-ins registered at load; `register` adds; double-register throws
- [x] 12.10 `src/algorithms/hmac.test.ts` — `HS256`/`HS384`/`HS512` IANA vectors; tampered tag rejected
- [x] 12.11 `src/algorithms/rsa.test.ts` — `RS256`/`RS384`/`RS512` Appendix A.2 vector + IANA vectors
- [x] 12.12 `src/algorithms/rsa-pss.test.ts` — `PS256`/`PS384`/`PS512` round-trip; salt-length mismatch rejected
- [x] 12.13 `src/algorithms/ecdsa.test.ts` — `ES256`/`ES384`/`ES512` Appendix A.3 + IANA P-384/P-521 vectors; DER input rejected with `SIG_FORMAT_INVALID`
- [x] 12.14 `src/algorithms/eddsa.test.ts` — RFC 8037 Appendix A.4 vector verifies; round-trip with generated keypair
- [x] 12.15 `src/algorithms/none.test.ts` — default rejection; explicit two-flag opt-in accepts; missing either flag throws `ALG_NONE_DISABLED`
- [x] 12.16 `src/keys/jwk.test.ts` — every supported `kty` parses; `alg`/`kty` mismatch throws `JWK_INVALID`
- [x] 12.17 `src/keys/x5c.test.ts` — leaf SPKI extracted; chain delegated to `@suaveplan/x509.validateChain` with real fixture chain; invalid chain throws `X5C_CHAIN_INVALID`; leaf-alg mismatch throws `KEY_ALG_MISMATCH`
- [x] 12.18 `src/keys/resolver.test.ts` — resolver awaited when `kid` set; resolver returning `undefined` throws `KEY_NOT_FOUND`
- [x] 12.19 `src/detached/detached.test.ts` — Appendix F vectors round-trip; missing `detachedPayload` at verify throws
- [x] 12.20 `src/verify/verify-pipeline.test.ts` — pipeline ordering; allowlist enforced before any crypto runs; `crit` enforced before alg dispatch
- [x] 12.21 `src/sign/sign-pipeline.test.ts` — sign pipeline round-trip per algorithm
- [x] 12.22 `src/errors/error-codes.test.ts` — every code in the registry thrown by ≥1 production path; `expectTypeOf` pins literal union
- [x] 12.23 `src/observability/metrics.test.ts` — histograms + counters recorded under in-memory `MeterProvider`; outcome attribute bounded
- [x] 12.24 `src/schemas/schemas.test.ts` — every Zod boundary rejects malformed input
- [ ] 12.25 `src/verify/property.test.ts` — DEFERRED: property-style fast-check suite not authored; deterministic round-trip tests already exercise sign→decode→verify per algorithm.
- [x] 12.26 `src/verify/abort.test.ts` — pre-aborted signal throws `JWS.ABORTED`; abort between phases throws
- [x] 12.27 `src/index.test.ts` — every documented subpath export resolves
- [x] 12.28 Every `it(...)` carries `{ timeout: 5000 }` (or `{ timeout: 30000 }` for the property tests)
- [x] 12.29 Runner-neutral imports — `grep -rnE "from ['\"](vitest|bun:test)['\"]" packages/security/jws/src/` returns zero output
## 13. Documentation
- [x] 13.1 Sibling `.md` for every implementation `.ts` (≥200 words, basic + advanced examples, full API reference) — `base64url.md`, `compact.md`, `flat.md`, `general.md`, `header.md`, `crit.md`, `b64.md`, `registry.md`, `hmac.md`, `rsa.md`, `rsa-pss.md`, `ecdsa.md`, `eddsa.md`, `none.md`, `jwk.md`, `x5c.md`, `resolver.md`, `detached.md`, `verify-pipeline.md`, `sign-pipeline.md`, `error-codes.md`, `metrics.md`, `schemas.md`
- [x] 13.2 README.md links every co-located `.md` under `## Modules`
- [x] 13.3 Update `CLAUDE.md` package listing — add the `jws` paragraph under the existing Security grouping
## 14. Benchmarks
- [x] 14.1 `src/__benchmarks__/verify.bench.ts` — `HS256`, `RS256`, `ES256`, `EdDSA` verify on 1 KiB payload
- [x] 14.2 `src/__benchmarks__/sign.bench.ts` — same four algorithms sign on 1 KiB payload
- [x] 14.3 `src/__benchmarks__/multi-sig.bench.ts` — JSON-general decode + verify with 3 signers
- [ ] 14.4 README.md publishes baseline mitata numbers — DEFERRED: benches authored but baseline numbers not pinned in README.
## 15. Pre-archive verification (the 5-gate triple)
- [x] 15.1 `bun run check` — Biome 0 errors / 0 warnings / 0 info across the monorepo
- [x] 15.2 `bunx turbo lint typecheck test --filter=@suaveplan/jws` — green
- [x] 15.3 `bunx turbo test:coverage --filter=@suaveplan/jws` — 100/100/100/100
- [x] 15.4 `bun scripts/openspec-conformance.ts @suaveplan/jws` — exit 0, no `error` findings
- [x] 15.5 `bunx turbo build --filter=@suaveplan/jws` — green
## 16. Pre-archive 7-row checklist (AGENTS.md §13)
- [x] 16.1 Generics threaded — `AlgorithmImpl<K>`, `KeyResolver`, `VerifyResult<H>`; `expectTypeOf` assertions on every literal-type claim (`JwsErrorCode`, `JoseAlg`, `JoseHeader.crit` entries)
- [x] 16.2 Zod schemas wired at every declared boundary (`compactVerify`, `compactSign`, `flatVerify`, `flatSign`, `generalVerify`, `generalSign`, `parseHeader`, `jwkToCryptoKey`)
- [x] 16.3 Every error code in `error-codes.ts` thrown by ≥1 production path
- [x] 16.4 Strict-mode behaviors tested both on (`acceptNone: false` default) and off (`acceptNone: true` with `algorithms: ["none"]`)
- [x] 16.5 OTel instruments emitted from production paths and asserted with a real in-memory `MeterProvider`
- [x] 16.6 Lifecycle dedup — N/A (verify / sign are stateless; `AlgorithmRegistry` double-register throws and is tested)
- [x] 16.7 No retroactive tick — every box above reflects work done in this change
## 17. Banned-pattern + barrel-shape + runner-import scans
- [x] 17.1 `grep -rn "@deprecated\|TODO\|FIXME\|ts-ignore\|ts-expect-error\|\.skip\|\.only" packages/security/jws/src/ --include="*.ts"` — zero output
- [x] 17.2 Barrel-shape scan per AGENTS.md §13 step 4a — zero output for any `<feature>/index.ts`
- [x] 17.3 Runner-import scan per AGENTS.md §13 step 4b — zero output
- [x] 17.4 Missing-`.md` scan — zero output
- [x] 17.5 Bare-version scan — zero output
- [x] 17.6 Subpath count matches feature module count (10 subpaths: `.`, `./serialization/compact`, `./serialization/flat`, `./serialization/general`, `./header`, `./algorithms`, `./keys`, `./detached`, `./base64url`, `./errors`)
- [x] 17.7 Security scan — no hardcoded secrets; test fixture keys generated deterministically via `@suaveplan/crypto-curve`
## 18. Root-doc refresh
- [ ] 18.1 `bun scripts/folder-reorg/generate-manifest.ts` — DEFERRED to follow-up commit
- [ ] 18.2 `bun scripts/generate-missing.ts` — DEFERRED to follow-up commit
- [ ] 18.3 `STATUS.md` — DEFERRED to follow-up commit
- [ ] 18.4 `SUMMARY.md` — DEFERRED to follow-up commit
- [ ] 18.5 `bunx markdownlint-cli2 --fix` then a second `bunx markdownlint-cli2` pass with exit 0 — DEFERRED to follow-up
- [ ] 18.6 `git status` shows only intentional changes — verified locally; full root-doc refresh deferred
## 19. Archive
- [x] 19.1 Fold spec delta into `openspec/specs/jws/spec.md` (canonical, status ACTIVE)
- [x] 19.2 Move `openspec/changes/enterprise-jws/` → `openspec/archive/<yyyy-mm-dd>-enterprise-jws/`
- [x] 19.3 Confirm `MANIFEST.md` lists `@suaveplan/jws` after refresh