33 KiB
Capability Spec: jws
Package: @suaveplan/jws
Status: CANONICAL — archived 2026-05-11.
Every requirement below carries a
**Verified by:**line at authoring time. (Seeopenspec/AGENTS.mdSection 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
JwsErrorwith 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
HS256token signed with keyK - 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
HS256token signed with keyK - WHEN
compactVerify(token, K, { algorithms: ["RS256"] })is called - THEN the call throws
JwsErrorwith code"JWS.ALG_NOT_ALLOWED" - AND no HMAC or RSA verification was performed (asserted via
vi.spyOnon the algorithm impls when needed)
Scenario: missing algorithms option rejected at boundary
- WHEN
compactVerify(token, K, {} as never)is called - THEN the call throws
JwsErrorwith 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
JwsErrorwith 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
JwsErrorwith 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
JwsErrorwith code"JWS.ALG_NOT_ALLOWED"
Scenario: none accepted with both flags set explicitly
- WHEN
compactVerify(token, undefined, { algorithms: ["none"], acceptNone: true })is called against anone-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 withHS512and 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
compactVerifyis called - THEN the call throws
JwsErrorwith 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 withRS384and 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
JwsErrorwith 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
compactVerifyis called - THEN the call throws
JwsErrorwith 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
ES384and 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
ES512and 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
SEQUENCEinstead of fixed-lengthR||S - WHEN
compactVerifyis called - THEN the call throws
JwsErrorwith 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
EdDSAand verified - THEN the verify call resolves with
verified: true
Scenario: EdDSA against an Ed448 OKP key rejected
- WHEN an
EdDSAtoken'sheader.jwkis an OKP key withcrv: "Ed448" - THEN
compactVerifythrowsJwsErrorwith 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"andheader.jwkcarrying 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"andheader.jwk.kty: "RSA" - WHEN
compactVerifyis called - THEN the call throws
JwsErrorwith 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.x5ccarrying 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
compactVerifyis called - THEN the call throws
JwsErrorwith code"JWS.X5C_CHAIN_INVALID"anderror.causeis the@suaveplan/x509chain error
Scenario: leaf-key alg mismatch rejected
- GIVEN a token declaring
alg: "ES256"whosex5cleaf is an RSA cert - WHEN
compactVerifyis called with a valid trust-anchor set - THEN the call throws
JwsErrorwith 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 withheader.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 withverified: 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
JwsErrorwith 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"]andopts.acceptableCriticalHeaders: ["exp"] - WHEN
compactVerifyis called - THEN the call resolves successfully
Scenario: unknown critical header rejected
- GIVEN
header.crit: ["exp", "tenant"]andopts.acceptableCriticalHeaders: ["exp"] - WHEN
compactVerifyis called - THEN the call throws
JwsErrorwith code"JWS.CRIT_UNRECOGNIZED"
Scenario: empty crit array rejected
- GIVEN
header.crit: [] - WHEN
compactVerifyis called - THEN the call throws
JwsErrorwith code"JWS.CRIT_MALFORMED"
Scenario: non-array crit rejected
- GIVEN
header.crit: "exp" - WHEN
compactVerifyis called - THEN the call throws
JwsErrorwith 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" }andunprotectedHeader: { kid: "k1" } - WHEN the payload is signed via
flatSignand verified viaflatVerify - 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
JwsErrorwith code"JWS.MULTI_SIG_FAILED"anderror.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 tocompactVerify(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
JwsErrorwith 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 nocritentry - WHEN
compactVerifyis called - THEN the call throws
JwsErrorwith code"JWS.B64_INVALID"
Scenario: b64: false with crit round-trips
- GIVEN
protected: { alg: "HS256", b64: false, crit: ["b64"] }andacceptableCriticalHeaders: ["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
compactVerifyis called - THEN the call throws
JwsErrorwith 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.tswalks the registry - THEN every code in
JWS_CODEShas at least oneit(...)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
JwsErrorwith code"JWS.INVALID_OPTIONS"
Scenario: header with non-string alg rejected
- GIVEN a base64url-encoded JSON header where
algis42 - WHEN
parseHeaderis called - THEN the call throws
JwsErrorwith 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
MeterProviderregistered as the global meter - WHEN
compactVerifyreturns successfully for anHS256token - THEN
jws.verify_duration_ms{alg=HS256,outcome=success}has 1 recorded value ANDjws.verify_total{alg=HS256,outcome=success}equals 1
Scenario: verify failure increments error counter
- WHEN
compactVerifythrowsJWS.SIG_INVALIDfor a tampered token - THEN
jws.error_total{code=JWS.SIG_INVALID}equals 1 ANDjws.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
JwsErrorwith code"JWS.ABORTED"beforeparseHeaderruns
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
compactVerifyproceeds - THEN the call throws
JwsErrorwith code"JWS.ABORTED"and the algorithmverifywas 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: trueAND 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
verifiedIndicesequals[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"