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