Files
suaveplan/openspec/archive/2026-05-11-enterprise-jws/proposal.md
2026-05-27 23:29:53 +01:00

21 KiB
Raw Blame History

@suaveplan/jws — RFC 7515 JSON Web Signature (verify + sign) for the universal tier

Package: @suaveplan/jws (NEW) Status: PROPOSED Type: new-package (genesis-original; greenfield — no upstream port) Date: 2026-05-11 Path: packages/security/jws/

Why

@suaveplan/webauthn's android-safetynet attestation verifier consumes a JWS as its input: the SafetyNet attestation statement is a compact-serialized JWS signed RS256 whose protected header carries an x5c parameter listing the leaf attestation certificate plus Google's intermediate / root attestation certificates. To verify a SafetyNet attestation the verifier must (a) parse the three-segment compact form per RFC 7515 §3.1, (b) extract the x5c chain and validate it through @suaveplan/x509 against Google's pinned attestation root, (c) confirm the leaf public key matches the declared alg, (d) recompute BASE64URL(protected) || "." || BASE64URL(payload) and run RSASSA-PKCS1-v1_5 verify per RFC 7518 §3.3. The current webauthn proposal defers server-side attestation; this package unblocks that workstream and is reusable by every other future signer of JWSes in the monorepo (signed installer manifests, cross-service attestations, signed event envelopes).

External alternatives (jose, jsonwebtoken, node-forge) all drag in either Node built-ins, CommonJS interop hazards, or oversize transitive dep trees that would force webauthn off the universal tier. An in-house, RFC-cited, zero-dependency implementation lets one canonical surface ship with one error registry, one OTel surface, one Zod boundary, and one set of test vectors anchored on RFC 7515 Appendix A.

What changes

New package: @suaveplan/jws

  • ADDED: packages/security/jws/ package scaffold (package.json, tsconfig.lib.json, vite.config.ts, vitest.config.ts, README.md).
  • ADDED: src/base64url/base64url.ts — base64urlEncode(bytes): string and base64urlDecode(str): Uint8Array per RFC 7515 Appendix C (no padding, no +//). Malformed input throws JWS.BASE64URL_INVALID.
  • ADDED: src/serialization/compact.ts — compactDecode(token: string): CompactJws, compactVerify(token, key, opts): Promise<VerifyResult>, compactSign(payload, key, alg, opts?): Promise<string> per RFC 7515 §3.1 + §7.1.
  • ADDED: src/serialization/flat.ts — flatDecode(json): FlatJws, flatVerify(json, key, opts), flatSign(payload, key, alg, opts?) per RFC 7515 §3.2 + §7.2.2.
  • ADDED: src/serialization/general.ts — generalDecode(json): GeneralJws, generalVerify(json, keyResolver, opts), generalSign(payload, signers[]) per RFC 7515 §3.2 + §7.2.1.
  • ADDED: src/serialization/serialization.types.ts — CompactJws, FlatJws, GeneralJws, JwsSignature, JwsSerialization discriminator.
  • ADDED: src/header/header.ts — parseHeader(b64u): JoseHeader, serializeHeader(h): string, JoseHeader Zod schema for the RFC 7515 §4.1 registered parameters (alg, jku, jwk, kid, x5u, x5c, x5t, x5t#S256, typ, cty, crit).
  • ADDED: src/header/crit.ts — enforceCrit(header, acceptableCriticalHeaders): void. Empty crit, non-array crit, or string-array entries not in acceptableCriticalHeaders throw JWS.CRIT_UNRECOGNIZED / JWS.CRIT_MALFORMED per RFC 7515 §4.1.11.
  • ADDED: src/header/b64.ts — RFC 7797 b64 header support. When b64: false, b64 MUST appear in crit or JWS.B64_INVALID is thrown; the signing input becomes BASE64URL(protected) || "." || rawPayloadString per RFC 7797 §3.
  • ADDED: src/algorithms/registry.ts — AlgorithmRegistry with register(alg, { verify, sign? }) / lookup(alg) / lookupOrThrow(alg). Built-in algorithms: HS256/HS384/HS512, RS256/RS384/RS512, PS256/PS384/PS512, ES256/ES384/ES512, EdDSA, none. Unknown alg → JWS.ALG_UNSUPPORTED.
  • ADDED: src/algorithms/hmac.ts — HS256/HS384/HS512 per RFC 7518 §3.2; constant-time tag compare via @suaveplan/crypto-utils.constantTimeEqual; never Buffer.compare.
  • ADDED: src/algorithms/rsa.ts — RS256/RS384/RS512 per RFC 7518 §3.3 via @suaveplan/crypto-curve RSA helpers.
  • ADDED: src/algorithms/rsa-pss.ts — PS256/PS384/PS512 per RFC 7518 §3.5 with salt length equal to hash output length.
  • ADDED: src/algorithms/ecdsa.ts — ES256/ES384/ES512 per RFC 7518 §3.4. Signature MUST be fixed-length R||S; ASN.1 DER input in the JOSE position → JWS.SIG_FORMAT_INVALID.
  • ADDED: src/algorithms/eddsa.ts — EdDSA per RFC 8037 §3.1 (Ed25519) via @suaveplan/crypto-curve/ed25519.
  • ADDED: src/algorithms/none.ts — "none" per RFC 7518 §3.6. Rejected by default; requires both algorithms: ["none"] AND acceptNone: true simultaneously, else JWS.ALG_NONE_DISABLED.
  • ADDED: src/keys/jwk.ts — jwkToCryptoKey(jwk, alg): CryptoKeyLike per RFC 7517 §4 + RFC 7518 §6. Validates kty / crv / n,e / x,y consistency with the declared alg; mismatch → JWS.JWK_INVALID.
  • ADDED: src/keys/x5c.ts — extractX5cLeafKey(x5c: string[], alg, opts). Delegates chain validation to @suaveplan/x509.validateChain(certs, { trustAnchors, now }); this module's responsibility is base64 decoding the chain, extracting the leaf SPKI, and confirming the leaf public-key algorithm matches the declared JOSE alg. Chain failure → JWS.X5C_CHAIN_INVALID.
  • ADDED: src/keys/resolver.ts — KeyResolver = (kid, header) => Promise<KeyLike | undefined>. When header.kid is present and opts.key is omitted, the resolver is awaited; absence → JWS.KEY_NOT_FOUND.
  • ADDED: src/detached/detached.ts — RFC 7515 §A.5 / Appendix F detached payload helpers; compactSign(payload, key, alg, { detached: true }) emits a token with empty middle segment; compactVerify(token, key, { detachedPayload }) accepts the payload out-of-band.
  • ADDED: src/verify/verify-pipeline.ts — central runVerifyPipeline(input, opts) orchestrator: parse header → enforce crit allowlist → enforce alg allowlist → enforce alg !== "none" unless opted in → resolve key (priority: explicit opts.key → header.jwk → header.x5c leaf → header.kid via resolver) → recompute signing input → dispatch to algorithm verify → return VerifyResult.
  • ADDED: src/sign/sign-pipeline.ts — central runSignPipeline(payload, key, alg, opts) orchestrator: validate options → resolve algorithm → assemble protected header → compute signing input → dispatch to algorithm sign → assemble serialization.
  • ADDED: src/errors/error-codes.ts — defineCodes("JWS", [...]) registry: MALFORMED, HEADER_MALFORMED, BASE64URL_INVALID, ALG_UNSUPPORTED, ALG_NOT_ALLOWED, ALG_NONE_DISABLED, SIG_INVALID, SIG_FORMAT_INVALID, JWK_INVALID, X5C_CHAIN_INVALID, KEY_NOT_FOUND, KEY_ALG_MISMATCH, CRIT_UNRECOGNIZED, CRIT_MALFORMED, B64_INVALID, INVALID_OPTIONS, ABORTED, MULTI_SIG_FAILED. Plus JwsError extends SuaveplanError<JwsErrorCode>.
  • ADDED: src/observability/metrics.ts — jws.verify_duration_ms{alg,outcome} + jws.sign_duration_ms{alg} histograms; jws.verify_total{alg,outcome} + jws.sign_total{alg,outcome} + jws.error_total{code} counters. outcome bounded by success | sig_invalid | alg_not_allowed | header_invalid.
  • ADDED: src/schemas/schemas.ts — Zod schemas for JoseHeader, VerifyOptions, SignOptions, FlatJws, GeneralJws, JsonWebKey (per RFC 7517). Validated at every public entry point.
  • ADDED: src/__benchmarks__/{verify,sign,multi-sig}.bench.ts — mitata benchmarks for the four most-likely algorithms (HS256, RS256, ES256, EdDSA) over a 1 KiB payload, plus multi-sig general decode.
  • ADDED: src/index.ts — explicit named re-exports of the public surface (no export * in feature barrels per AGENTS.md §6a).

Subpath exports

  • . — root barrel: compactVerify, compactSign, compactDecode, flatVerify, flatSign, flatDecode, generalVerify, generalSign, generalDecode, error registry, OTel.
  • ./serialization/compact — compact form.
  • ./serialization/flat — JSON-flattened form.
  • ./serialization/general — JSON-general multi-signature form.
  • ./header — parseHeader, enforceCrit, JoseHeader schema.
  • ./algorithms — AlgorithmRegistry, built-in algorithm implementations.
  • ./keys — jwkToCryptoKey, extractX5cLeafKey, KeyResolver type.
  • ./detached — RFC 7515 §A.5 detached payload helpers.
  • ./base64url — base64urlEncode, base64urlDecode.
  • ./errors — JWS_CODES, JwsError, JwsErrorCode.

Dependencies

  • @suaveplan/x509 workspace:^ — validateChain(certs, { trustAnchors, now }) for x5c header. Blocking dep: x509 MUST land on genesis before this package archives. If absent at proposal time, the x509 migration proposal is opened first.
  • @suaveplan/crypto-curve workspace:^ — ECDSA P-256/P-384/P-521, RSA, RSA-PSS, Ed25519 verify + sign.
  • @suaveplan/crypto-hash workspace:^ — HMAC-SHA-256/384/512 for HS* algorithms; SHA-2 helpers for pre-hash where the algorithm primitive needs it.
  • @suaveplan/crypto-utils workspace:^ — constantTimeEqual, hex / base64 helpers, zeroize.
  • @suaveplan/error workspace:^ — defineCodes, SuaveplanError base.
  • @suaveplan/types workspace:^ — Clock, Rng, LoggerLike shared interfaces.
  • @opentelemetry/api catalog:observability.
  • zod catalog:forms — boundary validation.
  • tslib catalog:build.
  • No jose, jsonwebtoken, node-forge, jws (npm), jwa, @stablelib/*, browser globals, or Node built-ins. Pure TypeScript over Uint8Array + DataView plus the workspace crypto primitives.

Out of scope

  • JWE (RFC 7516 JSON Web Encryption) — encryption is a distinct capability with a separate algorithm registry, separate key-management modes (RSA-OAEP, ECDH-ES, PBES2, etc.), and a four-segment compact form. Justification: scoping JWE into this package would double the surface area and introduce algorithm-confusion risk between JWS and JWE primitives. A separate @suaveplan/jwe package is the right home; this package's crit and header machinery is designed to be reusable when that lands.
  • JWT issuance / claim validation (RFC 7519) — JWT is a JWS payload with a specific claim shape (iss/sub/aud/exp/nbf/iat/jti). The existing @suaveplan/jwt-tenant package already ships JWT issuance; a future generic @suaveplan/jwt package would consume @suaveplan/jws as a primitive. Justification: keeping the JWS layer claim-agnostic lets every JWT-adjacent consumer (CWT-over-JOSE, SD-JWT, signed installer manifests) compose on top without inheriting JWT-specific assumptions.
  • JWK Set discovery via jku / x5u URL fetch — the package parses jku and x5u header parameters but does NOT fetch them. Justification: fetching introduces an IO surface (fetch, retry, timeout, TLS pinning) that belongs in a consumer; the package surfaces the parsed URL and lets the caller hydrate via their own HTTP plumbing.
  • JOSE Web Algorithms not registered in RFC 7518 / RFC 8037 — experimental algorithms (e.g. ML-DSA from draft-ietf-cose-dilithium, secp256k1) are not built-in. Consumers needing them register through AlgorithmRegistry.register(alg, impl). Justification: keeping the built-in set anchored on stable RFCs prevents algorithm drift between this package and its consumers.

Every other capability called out in the brief (compact + flat + general serializations, all 11 RFC 7518 algorithms + EdDSA + opt-in none, JWK + x5c + kid resolution, crit enforcement, detached payload, RFC 7797 b64, multi-sig general, Zod boundaries, OTel, AbortSignal, 100% coverage) is IN scope for this change. There are no deferrals on anything in scope.

Source mapping

This package is genesis-original; no ../suaveplan/enterprise/jws.md line range exists for any feature. Per the AGENTS.md convention for greenfield packages, the source-of-record is the RFC itself.

Requirement RFC source Notes
Compact serialization parse + assemble (3 segments, base64url) RFC 7515 §3.1, §7.1, Appendix A Exactly two . characters; empty middle segment for detached
JSON Flattened serialization RFC 7515 §3.2, §7.2.2 protected + optional header + payload + signature
JSON General serialization (multi-sig) RFC 7515 §3.2, §7.2.1 signatures array; per-signer protected/header/signature
base64url encoding (no padding, no +//) RFC 7515 Appendix C Strict alphabet; malformed → BASE64URL_INVALID
Signing input formation BASE64URL(protected) || "." || BASE64URL(payload) RFC 7515 §5.1 step 5 When b64: false, second segment is raw payload bytes
Mandatory algorithm allowlist at verify time RFC 7515 §4.1.1 + §5.2 step 6 Per "alg confusion" CVE class; no default-accept
alg: "none" rejection by default RFC 7518 §3.6 Explicit two-flag opt-in required
HS256 / HS384 / HS512 RFC 7518 §3.2 HMAC + constant-time compare
RS256 / RS384 / RS512 RFC 7518 §3.3 RSASSA-PKCS1-v1_5
ES256 / ES384 / ES512 RFC 7518 §3.4 P-256/P-384/P-521; fixed-length R||S
PS256 / PS384 / PS512 RFC 7518 §3.5 RSASSA-PSS, salt length = hash length
EdDSA (Ed25519) RFC 8037 §3.1, Appendix A.4 Per RFC 8037 IANA registration
Registered header parameters (alg, jku, jwk, kid, x5u, x5c, x5t, x5t#S256, typ, cty, crit) RFC 7515 §4.1 All parsed; jku/x5u not fetched (see out-of-scope)
jwk header parses to a typed key RFC 7515 §4.1.3 + RFC 7517 §4 + RFC 7518 §6 Validates consistency with declared alg
x5c chain validation via @suaveplan/x509 RFC 7515 §4.1.6 Chain expiry / trust-anchor delegated; leaf SPKI checked here
kid resolution via injectable resolver RFC 7515 §4.1.4 Caller controls the key-store model
crit enforcement against caller allowlist RFC 7515 §4.1.11 Empty / non-string-array / unknown entries throw
Detached payload RFC 7515 §A.5, Appendix F Empty middle segment; payload supplied out-of-band
b64 header parameter RFC 7797 §3 MUST appear in crit; signing input uses raw payload
Algorithm registry for downstream extension RFC 7518 §3 IANA registry semantics Built-ins frozen; register adds without forking

Requirements with no source line

Requirement Justification
defineCodes("JWS", [...]) 18-code registry Genesis convention — every package surfaces typed errors via @suaveplan/error. Each code maps to a distinct §4.1 / §5.2 / §6 failure class so callers can branch on root cause.
Zod boundary schemas at every public option / parsed header Genesis convention (CLAUDE.md §11). Catches caller misuse at the boundary; option misuse never silently degrades into a malformed token.
OTel surface (jws.verify_duration_ms, jws.sign_duration_ms, jws.verify_total, jws.sign_total, jws.error_total) Genesis convention — every hot-path package emits histograms + counters with a low-cardinality outcome attribute.
300-line file budget + 100/100/100/100 coverage + sibling .md per impl file CLAUDE.md global rules.
Property-based round-trip tests (fast-check) per algorithm CLAUDE.md "no mocks" rule; round-trip is the only test that proves sign + verify agree under arbitrary payloads.
Mitata benchmarks under src/__benchmarks__/ CLAUDE.md global benchmark requirement.
Algorithm registry pluggability Future consumer extensibility — downstream packages registering experimental algorithms without modifying this package.
AbortSignal end-to-end Genesis convention — every async public entry point honors cancellation.

Verification approach

Each requirement carries a **Verified by:** line in the spec delta naming concrete it(...) titles inside the test files below.

  • packages/security/jws/src/base64url/base64url.test.ts — Appendix C alphabet round-trip + malformed input rejection.
  • packages/security/jws/src/serialization/compact.test.ts — three-segment parsing, assembly, malformed segment rejection.
  • packages/security/jws/src/serialization/compact.spec-conformance.test.ts — RFC 7515 Appendix A.1–A.4 vectors round-trip byte-identically.
  • packages/security/jws/src/serialization/flat.test.ts — JSON-flat decode / verify / sign; Appendix A.7 vector.
  • packages/security/jws/src/serialization/general.test.ts — multi-signer encode / decode / verify; Appendix A.6 vector.
  • packages/security/jws/src/header/header.test.ts — every RFC 7515 §4.1 registered parameter parses; unknown header parameter survives round-trip.
  • packages/security/jws/src/header/crit.test.ts — empty crit rejected; non-array rejected; unknown entry rejected; allowlisted entry accepted.
  • packages/security/jws/src/header/b64.test.ts — b64: false without crit rejected; with crit round-trips; signing input uses raw payload.
  • packages/security/jws/src/algorithms/registry.test.ts — built-ins registered; register adds; lookup returns registered impl; unknown alg throws.
  • packages/security/jws/src/algorithms/hmac.test.ts — HS256/HS384/HS512 IANA test vectors; constant-time compare verified by timing-independent property test.
  • packages/security/jws/src/algorithms/rsa.test.ts — RS256/RS384/RS512 round-trip against RFC 7515 Appendix A.2 vector + IANA vectors.
  • packages/security/jws/src/algorithms/rsa-pss.test.ts — PS256/PS384/PS512 round-trip; salt-length-mismatch rejection.
  • packages/security/jws/src/algorithms/ecdsa.test.ts — ES256/ES384/ES512 against RFC 7515 Appendix A.3 + IANA P-384/P-521 vectors; ASN.1 DER signature input rejected with SIG_FORMAT_INVALID.
  • packages/security/jws/src/algorithms/eddsa.test.ts — RFC 8037 §A.4 vector verifies; round-trip with a generated key pair.
  • packages/security/jws/src/algorithms/none.test.ts — default rejection; explicit two-flag opt-in accepts; missing either flag throws.
  • packages/security/jws/src/keys/jwk.test.ts — every supported kty parses; alg/kty mismatch throws JWK_INVALID.
  • packages/security/jws/src/keys/x5c.test.ts — leaf SPKI extracted; chain delegated to @suaveplan/x509.validateChain with a real Google attestation chain fixture; invalid chain throws X5C_CHAIN_INVALID; leaf-alg mismatch throws KEY_ALG_MISMATCH.
  • packages/security/jws/src/keys/resolver.test.ts — resolver awaited when kid set; resolver returning undefined throws KEY_NOT_FOUND.
  • packages/security/jws/src/detached/detached.test.ts — Appendix F vectors round-trip; missing detachedPayload at verify throws.
  • packages/security/jws/src/verify/verify-pipeline.test.ts — full pipeline ordering; algorithm allowlist enforced before any crypto runs; crit enforced before alg dispatch.
  • packages/security/jws/src/sign/sign-pipeline.test.ts — sign pipeline round-trip per algorithm.
  • packages/security/jws/src/errors/error-codes.test.ts — every code in the registry is thrown by ≥1 production code path; expectTypeOf<JwsErrorCode>().toEqualTypeOf<…>() pins the literal union.
  • packages/security/jws/src/observability/metrics.test.ts — histograms + counters recorded under in-memory MeterProvider; outcome attribute bounded.
  • packages/security/jws/src/schemas/schemas.test.ts — every Zod boundary rejects malformed input with INVALID_OPTIONS / HEADER_MALFORMED.
  • packages/security/jws/src/verify/property.test.ts — fast-check: arbitrary payload, sign → decode → verify is byte-identical per algorithm.
  • packages/security/jws/src/verify/abort.test.ts — pre-aborted signal rejects with JWS.ABORTED; abort between header parse and signature verify rejects.
  • packages/security/jws/src/index.test.ts — every documented subpath export resolves.

Migration impact

None — additive only; this is the first delta of a new package. No existing code in genesis imports from @suaveplan/jws. Pending consumers:

  • @suaveplan/webauthn (in openspec/changes/webauthn/) — once server-side android-safetynet verification is folded back in scope, that change will add the dep edge on @suaveplan/jws plus @suaveplan/x509.

Impact

Affected dependents

bunx turbo ls --filter=...@suaveplan/jws returns no current dependents (the package does not yet exist on genesis). Pending consumer:

  • @suaveplan/webauthn — [ ] tests run / [ ] docs reviewed / [x] no action needed in THIS change (reason: webauthn currently defers server-side attestation per its own proposal; the dep edge lands in a follow-up webauthn change once SafetyNet verification is folded back in scope).

No foundation-package fan-out applies (this is a new universal-tier leaf package; foundation deps @suaveplan/error and @suaveplan/types are unchanged).