Files
2026-05-27 23:29:53 +01:00

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. (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"