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

512
openspec/specs/jws/spec.md Normal file
View File

@@ -0,0 +1,512 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec: jws
**Package**: `@suaveplan/jws`
**Status**: CANONICAL — archived 2026-05-11.
> Every requirement below carries a `**Verified by:**` line at authoring time. (See `openspec/AGENTS.md` Section 13.)
## 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/crypto/jws/src/serialization/compact.test.ts::"parses a well-formed three-segment token"` AND `packages/crypto/jws/src/serialization/compact.test.ts::"rejects a two-segment token with JWS.MALFORMED"` AND `packages/crypto/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/crypto/jws/src/verify/verify-pipeline.test.ts::"verifies token whose alg is on the allowlist"` AND `packages/crypto/jws/src/verify/verify-pipeline.test.ts::"rejects token with disallowed alg before any crypto runs"` AND `packages/crypto/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/crypto/jws/src/algorithms/none.test.ts::"rejects none by default"` AND `packages/crypto/jws/src/algorithms/none.test.ts::"rejects none when only algorithms includes it"` AND `packages/crypto/jws/src/algorithms/none.test.ts::"rejects none when only acceptNone is set"` AND `packages/crypto/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/crypto/jws/src/algorithms/hmac.test.ts::"verifies the RFC 7515 Appendix A.1 HS256 vector"` AND `packages/crypto/jws/src/algorithms/hmac.test.ts::"round-trips an HS512 token"` AND `packages/crypto/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/crypto/jws/src/algorithms/rsa.test.ts::"verifies the RFC 7515 Appendix A.2 RS256 vector"` AND `packages/crypto/jws/src/algorithms/rsa.test.ts::"round-trips an RS384 token"` AND `packages/crypto/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/crypto/jws/src/algorithms/rsa-pss.test.ts::"round-trips a PS256 token with 32-byte salt"` AND `packages/crypto/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/crypto/jws/src/algorithms/ecdsa.test.ts::"verifies the RFC 7515 Appendix A.3 ES256 vector"` AND `packages/crypto/jws/src/algorithms/ecdsa.test.ts::"round-trips an ES384 token"` AND `packages/crypto/jws/src/algorithms/ecdsa.test.ts::"round-trips an ES512 token"` AND `packages/crypto/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/crypto/jws/src/algorithms/eddsa.test.ts::"verifies the RFC 8037 Appendix A.4 vector"` AND `packages/crypto/jws/src/algorithms/eddsa.test.ts::"round-trips an EdDSA token"` AND `packages/crypto/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/crypto/jws/src/keys/jwk.test.ts::"verifies a token whose header.jwk matches the declared alg"` AND `packages/crypto/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/crypto/jws/src/keys/x5c.test.ts::"verifies a token with a valid x5c chain"` AND `packages/crypto/jws/src/keys/x5c.test.ts::"rejects a token whose x5c root is not in the trust anchors"` AND `packages/crypto/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/crypto/jws/src/keys/resolver.test.ts::"awaits resolver and verifies with returned key"` AND `packages/crypto/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/crypto/jws/src/header/crit.test.ts::"accepts a known critical header on the allowlist"` AND `packages/crypto/jws/src/header/crit.test.ts::"rejects an unknown critical header with CRIT_UNRECOGNIZED"` AND `packages/crypto/jws/src/header/crit.test.ts::"rejects an empty crit array with CRIT_MALFORMED"` AND `packages/crypto/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/crypto/jws/src/serialization/flat.test.ts::"verifies the RFC 7515 Appendix A.7 JSON Flattened vector"` AND `packages/crypto/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/crypto/jws/src/serialization/general.test.ts::"verifies the RFC 7515 Appendix A.6 multi-sig vector"` AND `packages/crypto/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/crypto/jws/src/sign/sign-pipeline.test.ts::"HS256 round-trips through compactSign and compactVerify"` AND `packages/crypto/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/crypto/jws/src/detached/detached.test.ts::"compactSign with detached: true emits empty middle segment"` AND `packages/crypto/jws/src/detached/detached.test.ts::"compactVerify accepts detachedPayload out-of-band"` AND `packages/crypto/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/crypto/jws/src/header/b64.test.ts::"rejects b64 false without crit listing"` AND `packages/crypto/jws/src/header/b64.test.ts::"round-trips a b64 false token with raw payload"` AND `packages/crypto/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/crypto/jws/src/errors/error-codes.test.ts::"every registered JWS code is thrown by at least one production path"` AND `packages/crypto/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/crypto/jws/src/schemas/schemas.test.ts::"rejects verify options missing the algorithms field"` AND `packages/crypto/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/crypto/jws/src/observability/metrics.test.ts::"emits verify histogram and success counter on successful verify"` AND `packages/crypto/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/crypto/jws/src/verify/abort.test.ts::"throws JWS.ABORTED on pre-aborted signal"` AND `packages/crypto/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/crypto/jws/src/serialization/compact.spec-conformance.test.ts::"verifies the RFC 7515 Appendix A.1 HS256 vector"` AND `packages/crypto/jws/src/serialization/compact.spec-conformance.test.ts::"verifies the RFC 7515 Appendix A.2 RS256 vector"` AND `packages/crypto/jws/src/serialization/compact.spec-conformance.test.ts::"verifies the RFC 7515 Appendix A.3 ES256 vector"` AND `packages/crypto/jws/src/serialization/compact.spec-conformance.test.ts::"verifies the RFC 7515 Appendix A.4 ES512 vector"` AND `packages/crypto/jws/src/detached/detached.test.ts::"verifies the RFC 7515 Appendix A.5 detached vector"` AND `packages/crypto/jws/src/serialization/general.test.ts::"verifies the RFC 7515 Appendix A.6 multi-sig vector"` AND `packages/crypto/jws/src/serialization/flat.test.ts::"verifies the RFC 7515 Appendix A.7 JSON Flattened vector"`
<!-- markdownlint-enable MD024 -->