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

169 lines
21 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# `@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).