Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -0,0 +1,364 @@
# `@suaveplan/crypto-rsa` — RSA primitives (RFC 8017 PKCS #1 v2.2 + RFC 5208 PKCS #8 + RFC 7518 JWA)
**Package**: `@suaveplan/crypto-rsa` (NEW)
**Status**: PROPOSED
**Type**: new-package (greenfield; crypto-plugin sibling, universal-tier)
**Date**: 2026-05-11
**Path**: `packages/crypto/crypto-rsa/`
**Source audit**: `../suaveplan/enterprise/crypto-rsa.md`
## Why
The genesis crypto family today covers symmetric AEAD (`crypto-cipher`), hashing + KDFs (`crypto-hash`), and elliptic curves (`crypto-curve`: Ed25519, X25519, P-256, P-384, P-521, secp256k1). It does NOT cover RSA — yet RSA is the dominant signature algorithm in every protocol genesis is migrating: real-world TLS certificate chains, the JWS / JWT ecosystem (RFC 7518 §3.3 mandates support for `RS256`), and the WebAuthn `tpm` attestation format (RSA endorsement keys are the production majority for TPM-bound credentials). Three packages currently in flight on genesis are blocked on this gap:
1. **`@suaveplan/x509`** — verifies certificate-chain signatures. RFC 5280 §1.2 + Appendix A enumerate the RSA signature OIDs `1.2.840.113549.1.1.5` (sha1WithRSAEncryption), `1.2.840.113549.1.1.11` (sha256WithRSAEncryption), `1.2.840.113549.1.1.12` (sha384), `1.2.840.113549.1.1.13` (sha512), and `1.2.840.113549.1.1.10` (id-RSASSA-PSS). The majority of real production certs today use one of these.
2. **`@suaveplan/jws`** — implements the JWA registry of RFC 7518 §3.3 (`RS256`/`RS384`/`RS512` over RSASSA-PKCS1-v1\_5) and §3.5 (`PS256`/`PS384`/`PS512` over RSASSA-PSS). Six of the eleven JWS algorithm IDs are RSA-backed.
3. **`@suaveplan/webauthn`** — `tpm` attestation statements carry RSA public keys from TPM modules. RSA-PKCS1-v1\_5 over SHA-256 is the most common TPM attestation signature.
All three consumers verify externally-issued keys; one (`jws`) also signs. Beyond the three named consumers, this package is the universal RSA primitive for the entire monorepo — `@suaveplan/crypto-cli`'s future `keychain rsa init`, `@suaveplan/vault`'s future RSA-protected key blobs, `@suaveplan/pkcs12` PKCS#12 import paths, and any downstream consumer integrating with HSM / PKCS#11 hardware all route through `@suaveplan/crypto-rsa`. Splitting RSA out into `@suaveplan/crypto-rsa` rather than folding it into `@suaveplan/crypto-curve` keeps curve-only callers at zero RSA bundle cost, mirrors the per-primitive layout the rest of the crypto family already follows (`crypto-cipher`, `crypto-hash`), and matches `@noble/curves`'s own separation of curve and RSA work.
The library ships at enterprise quality from day one — matching the bar set by `@suaveplan/crypto-curve` (per-primitive subpaths, native + noble engine adapters) and `@suaveplan/crypto-vault` (Zod boundaries, OTel everywhere, 100% docs, sentinel-driven side-channel hardening). The required-reading audit covered RFC 8017 (PKCS #1 v2.2) end-to-end, NIST FIPS 186-5 (Digital Signature Standard) §5 (RSA DSA), NIST SP 800-56B Rev. 2 (Key Establishment Using Integer Factorization Cryptography), Google Wycheproof's RSA test corpus, RFC 7468 (PEM textual encoding), RFC 7638 (JWK Thumbprint), and RFC 5990 (RSA-KEM — surveyed and explicitly deferred). Every requirement below is anchored against one of these sources.
The package is greenfield — no prior implementation in `packages/crypto/` and no upstream `../suaveplan/packages/<plugin>/crypto-rsa/` source. Every requirement is mapped against the canonical RFCs and NIST documents: RFC 8017 (PKCS #1 v2.2), RFC 5208 (PKCS #8), RFC 5958 (PKCS #8 successor), RFC 5280 (X.509 + OIDs), RFC 7468 (PEM), RFC 7517 / 7518 / 7638 (JWK / JWA / JWK Thumbprint), RFC 5990 (RSA-KEM — deferred), NIST FIPS 186-5 (Digital Signature Standard), NIST SP 800-56B Rev. 2 (Key Establishment), and Google Wycheproof.
## What changes
### New package: `@suaveplan/crypto-rsa`
#### Scaffold
- ADDED: `packages/crypto/crypto-rsa/` package scaffold (`package.json`, `tsconfig.lib.json`, `vite.config.ts`, `vitest.config.ts`, `README.md`, `src/__benchmarks__/`, `src/__fixtures__/`).
#### ASN.1 / DER helpers
- ADDED: `src/asn1/reader.ts` — minimal DER reader (`readInteger`, `readSequence`, `readObjectIdentifier`, `readBitString`, `readOctetString`, `readNull`, `readContextSpecific`) sufficient for PKCS#1, PKCS#8, SPKI, RSA-PSS `AlgorithmIdentifier` parameters.
- ADDED: `src/asn1/writer.ts` — matching DER writer.
#### Canonical key shapes
- ADDED: `src/keys/keys.types.ts` — `RsaPublicKey { n: bigint; e: bigint; bitLength: number }`, `RsaPrivateKey { n; e; d; p; q; dp; dq; qInv; bitLength }`. `bitLength` is the modulus bit-length (used by the modulus-length policy gate).
#### Key parse + serialise — PKCS#1
- ADDED: `src/keys/parse-pkcs1-public.ts` — `parseRsaPublicKeyPkcs1(bytes): RsaPublicKey` per RFC 8017 Appendix A.1.1 (`RSAPublicKey ::= SEQUENCE { modulus INTEGER, publicExponent INTEGER }`).
- ADDED: `src/keys/parse-pkcs1-private.ts` — `parseRsaPrivateKeyPkcs1(bytes): RsaPrivateKey` per RFC 8017 Appendix A.1.2.
- ADDED: `src/keys/serialize-pkcs1-public.ts` + `src/keys/serialize-pkcs1-private.ts` — inverse paths.
#### Key parse + serialise — PKCS#8 / SPKI
- ADDED: `src/keys/parse-spki.ts` — `parseRsaPublicKeySpki(bytes): RsaPublicKey` unwraps the `SubjectPublicKeyInfo` (RFC 5280) carrying OID `1.2.840.113549.1.1.1` (`rsaEncryption`) and dispatches to the PKCS#1 reader.
- ADDED: `src/keys/parse-pkcs8.ts` — `parseRsaPrivateKeyPkcs8(bytes): RsaPrivateKey` per RFC 5208 / RFC 5958.
- ADDED: `src/keys/serialize-spki.ts` + `src/keys/serialize-pkcs8.ts` — inverse paths.
#### Key parse + serialise — JWK
- ADDED: `src/keys/parse-jwk.ts` — `parseRsaPublicKeyJwk` + `parseRsaPrivateKeyJwk` per RFC 7517 §6.3. Rejects `kty !== "RSA"` with `CRYPTO_RSA.UNSUPPORTED_FORMAT`.
- ADDED: `src/keys/serialize-jwk.ts` — emits base64url-encoded values per RFC 7515 §2 (no padding, URL-safe alphabet).
#### Façade
- ADDED: `src/keys/parse.ts` — `parseRsaPublicKey(bytes, { format })` + `parseRsaPrivateKey(bytes, { format })` dispatch on the `format` discriminant.
- ADDED: `src/keys/serialize.ts` — `serializeRsaPublicKey(key, { format })` + `serializeRsaPrivateKey(key, { format })` dispatch.
#### Modulus-length policy
- ADDED: `src/policy/modulus.ts` — `enforceMinBitLength(key, { allowLegacy?: boolean })`. Default floor is 2048 bits; `allowLegacy: true` permits 1024-bit keys. Below 1024 is always rejected with `CRYPTO_RSA.MODULUS_TOO_SHORT`.
#### Modular arithmetic + CRT
- ADDED: `src/math/modpow.ts` — `modPow(base: bigint, exponent: bigint, modulus: bigint): bigint` thin wrapper over `@noble/curves/abstract/modular.pow`. NEVER native `BigInt ** BigInt % N`.
- ADDED: `src/math/crt.ts` — `rsaPrivateOperationCrt(c: bigint, key: RsaPrivateKey): bigint` per RFC 8017 §5.1.2 second-form (`m1 = c^dp mod p`, `m2 = c^dq mod q`, `h = qInv * (m1 - m2) mod p`, `m = m2 + h * q`).
- ADDED: `src/math/i2osp.ts` — Integer-to-Octet-String primitive per RFC 8017 §4.1.
- ADDED: `src/math/os2ip.ts` — Octet-String-to-Integer primitive per RFC 8017 §4.2.
#### MGF1 + encoding methods
- ADDED: `src/encoding/mgf1.ts` — `mgf1(seed, maskLen, hash): Uint8Array` per RFC 8017 §B.2.1. Throws `CRYPTO_RSA.MGF_FAILURE` when `maskLen > 2^32 * hLen`.
- ADDED: `src/encoding/emsa-pkcs1-v1_5.ts` — `emsaPkcs1v15Encode(messageDigest, emLen, hash)` per RFC 8017 §9.2. Bundles the four DigestInfo prefix constants:
- SHA-1: `30 21 30 09 06 05 2b 0e 03 02 1a 05 00 04 14`
- SHA-256: `30 31 30 0d 06 09 60 86 48 01 65 03 04 02 01 05 00 04 20`
- SHA-384: `30 41 30 0d 06 09 60 86 48 01 65 03 04 02 02 05 00 04 30`
- SHA-512: `30 51 30 0d 06 09 60 86 48 01 65 03 04 02 03 05 00 04 40`
- ADDED: `src/encoding/emsa-pss-encode.ts` — `emsaPssEncode(messageDigest, emBits, salt, hash, mgfHash)` per RFC 8017 §9.1.1.
- ADDED: `src/encoding/emsa-pss-verify.ts` — `emsaPssVerify(messageDigest, encodedMessage, emBits, saltLength, hash, mgfHash)` per RFC 8017 §9.1.2. Constant-time over the salt + hash compare.
#### Signatures — RSASSA-PKCS1-v1\_5
- ADDED: `src/sign/pkcs1v15.ts` — `rsaSignPkcs1v15(privateKey, message, { hash }): Uint8Array` per RFC 8017 §8.2.1.
- ADDED: `src/verify/pkcs1v15.ts` — `rsaVerifyPkcs1v15(key, message, signature, { hash }): boolean` per RFC 8017 §8.2.2. Constant-time encoded-message compare.
#### Signatures — RSASSA-PSS
- ADDED: `src/sign/pss.ts` — `rsaSignPss(privateKey, message, { hash; saltLength?; mgfHash?; rng? }): Uint8Array` per RFC 8017 §8.1.1.
- ADDED: `src/verify/pss.ts` — `rsaVerifyPss(key, message, signature, { hash; saltLength?; mgfHash? }): boolean` per RFC 8017 §8.1.2.
#### Encryption — RSAES-PKCS1-v1\_5
- ADDED: `src/encrypt/pkcs1v15.ts` — `rsaEncryptPkcs1v15(key, plaintext, { rng? }): Uint8Array` per RFC 8017 §7.2.1.
- ADDED: `src/decrypt/pkcs1v15.ts` — `rsaDecryptPkcs1v15(privateKey, ciphertext): Uint8Array` per RFC 8017 §7.2.2. Single combined error class (`CRYPTO_RSA.DECRYPT_FAILED`) — never distinguishes padding-format errors (Bleichenbacher oracle defence).
#### Encryption — RSAES-OAEP
- ADDED: `src/encrypt/oaep.ts` — `rsaEncryptOaep(key, plaintext, { hash; mgfHash?; label?; rng? }): Uint8Array` per RFC 8017 §7.1.1.
- ADDED: `src/decrypt/oaep.ts` — `rsaDecryptOaep(privateKey, ciphertext, { hash; mgfHash?; label? }): Uint8Array` per RFC 8017 §7.1.2. Same combined-error contract.
#### JWK thumbprint
- ADDED: `src/jwk/thumbprint.ts` — `rsaJwkThumbprint(key, hash): Uint8Array` per RFC 7638 §3.2. Worked-example match against RFC 7638 §3.1 reference value `"NzbLsXh8uDCcd-6MNwXF4W_7noWXFZAfHkxZsRGC9Xs"`.
#### SPKI fingerprint
- ADDED: `src/fingerprint/spki.ts` — `computeSpkiFingerprint(publicKey, { hash?: "SHA-256" | "SHA-1" }): Uint8Array` (default SHA-256). Digests the SPKI DER bytes; `ssh-keygen -lf`-style identifier for an RSA key.
- ADDED: `src/fingerprint/format.ts` — `formatFingerprint(bytes, format: "hex" | "base64" | "base64url" | "colon-hex"): string`.
#### PEM framing (RFC 7468)
- ADDED: `src/pem/parse-public.ts` — `parseRsaPublicKeyPem(text): RsaPublicKey` recognising armor labels `-----BEGIN PUBLIC KEY-----` (SPKI) and `-----BEGIN RSA PUBLIC KEY-----` (PKCS#1 public).
- ADDED: `src/pem/parse-private.ts` — `parseRsaPrivateKeyPem(text): RsaPrivateKey` recognising armor labels `-----BEGIN PRIVATE KEY-----` (PKCS#8) and `-----BEGIN RSA PRIVATE KEY-----` (PKCS#1 private).
- ADDED: `src/pem/serialize-public.ts` + `src/pem/serialize-private.ts` — inverse paths emitting 64-char-line base64 bodies per RFC 7468 §3.
#### Key generation
- ADDED: `src/keygen/miller-rabin.ts` — `isProbablePrime(n: bigint, rng: Rng, rounds?: number = 64): boolean` per NIST FIPS 186-5 Appendix B.3.1.
- ADDED: `src/keygen/generate.ts` — `generateRsaKeyPair(options: { modulusBits?, publicExponent?, rng, primes?, signal?, profile? }): Promise<RsaPrivateKey>`. Defaults: `modulusBits: 3072`, `publicExponent: 65537n`, `primes: 2`, `profile: "permissive"`. AbortSignal threaded into the prime-search loop; abort interrupts within ≤ 100 ms and throws `CRYPTO_RSA.ABORTED`.
- ADDED: `src/keygen/multi-prime.ts` — multi-prime RSA support per RFC 8017 §3 (`primes ∈ {3, 4, 5}`).
- ADDED: `src/keygen/exponent.ts` — `publicExponent` validation (odd, `1 < e < 2^256`, coprime with `λ(n)`).
#### Blinding (RFC 8017 §5.1.2)
- ADDED: `src/blinding/blinding.ts` — `blindMessage(m, key, rng): { mPrime, rInverse }` + `unblindResult(s, rInverse, key): bigint` per RFC 8017 §5.1.2 / Kocher 1996. Wired into every `rsaSign*` / `rsaDecrypt*` call by default; opt-out via `{ blinding: false }` emits `crypto_rsa.blinding_disabled_total{operation}` counter increment.
#### `RsaCryptoEngine` adapter — noble + subtle
- ADDED: `src/engine/engine.types.ts` — `RsaCryptoEngine` interface (`sign`, `verify`, `encrypt`, `decrypt`, `modPow`).
- ADDED: `src/engine/noble.ts` — default engine over `@noble/curves/abstract/modular.pow`.
- ADDED: `src/engine/subtle.ts` — `globalThis.crypto.subtle` adapter. Wraps `importKey` / `sign` / `verify` / `encrypt` / `decrypt`. Non-extractable keys by default.
- ADDED: `src/engine/select.ts` — `defaultEngine(operation, key): RsaCryptoEngine` auto-selects subtle when available else noble. Emits `crypto_rsa.engine_selected_total{engine}` on every call.
#### HSM / PKCS#11 bridge
- ADDED: `src/handle/handle.types.ts` — `RsaKeyHandle` interface + `RsaKeyHandleProvider` interface.
- ADDED: `src/handle/registry.ts` — `registerKeyHandleProvider(provider)` + `resolveKeyHandle(key)`.
- ADDED: `src/handle/software.ts` — `SoftwareRsaKeyHandle` reference implementation wrapping an in-memory `RsaPrivateKey`.
#### FIPS 186-5 profile
- ADDED: `src/profile/profile.ts` — `enforceProfile(input, options: { profile: "permissive" | "fips-186-5" })`. The `fips-186-5` profile rejects `modulusBits ∉ {2048, 3072, 4096}`, `publicExponent ∉ [2^16+1, 2^256-1]`, even `publicExponent`, `hash === "SHA-1"` in sign / verify, and PSS `saltLength` ≠ `hLen` (with `CRYPTO_RSA.PROFILE_VIOLATION`).
#### JWK `key_ops` / `use` / `alg` enforcement
- ADDED: `src/keys/jwk-policy.ts` — `enforceJwkPolicy(jwk, operation): void`. `operation ∈ {"sign","verify","encrypt","decrypt","wrapKey","unwrapKey"}`. Cross-policy violations throw `CRYPTO_RSA.KEY_OP_NOT_PERMITTED`.
#### Errors + Zod + observability
- ADDED: `src/errors/errors.ts` — `defineCodes("CRYPTO_RSA", ["MODULUS_TOO_SHORT", "MODULUS_TOO_LARGE", "INVALID_KEY", "INVALID_SIGNATURE", "SIGNATURE_INVALID", "UNSUPPORTED_HASH", "UNSUPPORTED_FORMAT", "DECRYPT_FAILED", "MGF_FAILURE", "KEYGEN_FAILED", "PRIME_INVALID", "KEY_OP_NOT_PERMITTED", "PROFILE_VIOLATION", "ENGINE_UNAVAILABLE", "PEM_MALFORMED", "FINGERPRINT_FAILED", "ABORTED"])` registry (17 codes) + `CryptoRsaError extends SuaveplanError<CryptoRsaErrorCode>`.
- ADDED: `src/schemas/schemas.ts` — `RsaPublicKeySchema`, `RsaPrivateKeySchema`, `RsaParseOptionsSchema`, `RsaSerializeOptionsSchema`, `RsaSignOptionsSchema`, `RsaVerifyOptionsSchema`, `RsaPssOptionsSchema`, `RsaOaepOptionsSchema`, `HashAlgorithmSchema`, `RsaKeygenOptionsSchema`, `RsaFingerprintOptionsSchema`, `RsaJwkPolicySchema`, `RsaProfileSchema`. Mismatch → `CRYPTO_RSA.UNSUPPORTED_FORMAT` / `CRYPTO_RSA.UNSUPPORTED_HASH` / `CRYPTO_RSA.INVALID_KEY` / `CRYPTO_RSA.PROFILE_VIOLATION` as appropriate.
- ADDED: `src/observability/metrics.ts` — `cryptoRsaMetrics` factory emitting:
- Histograms: `crypto_rsa.verify_duration_ms{scheme,hash,engine}`, `crypto_rsa.sign_duration_ms{scheme,hash,engine}`, `crypto_rsa.encrypt_duration_ms{scheme,hash,engine}`, `crypto_rsa.decrypt_duration_ms{scheme,hash,engine}`, `crypto_rsa.keygen_duration_ms{modulus_bits,primes}`, `crypto_rsa.modpow_duration_ms{operation,blinded}`.
- Counters: `crypto_rsa.outcome_total{operation,outcome}` (`outcome ∈ ok / invalid_signature / decrypt_failed / modulus_too_short / modulus_too_large / profile_violation / aborted / key_op_not_permitted`), `crypto_rsa.errors_total{code}`, `crypto_rsa.engine_selected_total{engine}`, `crypto_rsa.fingerprint_total{format}`, `crypto_rsa.profile_violation_total{rule}` (`rule ∈ modulus_bits / public_exponent / salt_length / sha1_banned`), `crypto_rsa.blinding_disabled_total{operation}`.
#### Root + scripts + benchmarks
- ADDED: `src/index.ts` — explicit named re-exports of every public surface (no `export *`).
- ADDED: `scripts/generate-rsa-vectors.ts` — deterministic vector generator covering PKCS1v15 sign, PSS sign, OAEP encrypt against a fixed test key. Idempotent re-runs.
- ADDED: `src/__fixtures__/rfc8017-appendix-c/` — RFC 8017 Appendix C test vectors as JSON.
- ADDED: `src/__fixtures__/cavp/` — NIST CAVP RSA sign / verify / encrypt vectors from <https://csrc.nist.gov/projects/cryptographic-algorithm-validation-program/digital-signatures>.
- ADDED: `src/__fixtures__/wycheproof/` — Google Wycheproof RSA vectors (`rsa_signature_*.json`, `rsa_signature_pss_*.json`, `rsa_oaep_*.json`) from <https://github.com/google/wycheproof/tree/master/testvectors>.
- ADDED: `src/__benchmarks__/crypto-rsa.bench.ts` — mitata covering `verify_pkcs1_sha256_{2048,3072,4096} × engine ∈ {noble,subtle}`, `verify_pss_sha256_{2048,3072,4096} × engine`, `sign_pkcs1_sha256_{2048,3072,4096} × engine`, `sign_pss_sha256_{2048,3072,4096} × engine`, `encrypt_oaep_sha256_{2048,3072,4096} × engine`, `decrypt_oaep_sha256_{2048,3072,4096} × engine`, `generate_{2048,3072,4096}` (noble only), `parse_{pkcs1,pkcs8,spki,jwk,pem}_2048`, `crt_vs_naive_2048` ratio (CRT ≥ 2× faster), `blinded_vs_unblinded_2048` ratio (blinding overhead < 5%).
### Subpath exports
- `.` — root barrel (every public symbol)
- `./keys` — `parseRsaPublicKey`, `parseRsaPrivateKey`, `serializeRsaPublicKey`, `serializeRsaPrivateKey`, `enforceJwkPolicy`, types
- `./keygen` — `generateRsaKeyPair`, `isProbablePrime`
- `./sign` — `rsaSignPkcs1v15`, `rsaSignPss`
- `./verify` — `rsaVerifyPkcs1v15`, `rsaVerifyPss`
- `./encrypt` — `rsaEncryptPkcs1v15`, `rsaEncryptOaep`
- `./decrypt` — `rsaDecryptPkcs1v15`, `rsaDecryptOaep`
- `./encoding` — `mgf1`, `emsaPkcs1v15Encode`, `emsaPssEncode`, `emsaPssVerify`
- `./jwk` — `rsaJwkThumbprint`
- `./fingerprint` — `computeSpkiFingerprint`, `formatFingerprint`
- `./pem` — `parseRsaPublicKeyPem`, `parseRsaPrivateKeyPem`, `serializeRsaPublicKeyPem`, `serializeRsaPrivateKeyPem`
- `./blinding` — `blindMessage`, `unblindResult` (advanced consumers; re-exported internally by sign / decrypt)
- `./engine` — `RsaCryptoEngine` interface, `defaultEngine`, `nobleEngine`, `subtleEngine`
- `./handle` — `RsaKeyHandle`, `RsaKeyHandleProvider`, `registerKeyHandleProvider`, `SoftwareRsaKeyHandle`
- `./profile` — `enforceProfile`, profile types
- `./policy` — `enforceMinBitLength`, `enforceMaxBitLength`
- `./schemas` — every Zod schema
- `./observability` — `cryptoRsaMetrics` factory
- `./errors` — `CRYPTO_RSA_CODES`, `CryptoRsaError`, `CryptoRsaErrorCode`
### Dependencies
- `@suaveplan/crypto-hash workspace:^` — SHA-1 / SHA-256 / SHA-384 / SHA-512 digest.
- `@suaveplan/crypto-utils workspace:^` — `constantTimeEqual`, `randomBytes`, `concat`, hex / base64url helpers, `i2osp` / `os2ip`.
- `@suaveplan/error workspace:^` — `defineCodes`, `SuaveplanError`.
- `@suaveplan/types workspace:^` — `Rng`, `HashAlgorithm`, `Clock`.
- `@noble/curves catalog:crypto` — `abstract/modular.pow` constant-time-hardened modpow + `abstract/modular.invert`.
- `@opentelemetry/api catalog:observability` — histograms + counters.
- `zod catalog:forms` — boundary schemas.
- `tslib catalog:build`.
The `subtle` engine consumes `globalThis.crypto.subtle` at runtime — there is no compile-time dependency to add. The HSM / PKCS#11 bridge ships only the interface + reference `SoftwareRsaKeyHandle`; concrete PKCS#11 / KMS adapters live in downstream packages and consume `@suaveplan/crypto-rsa/handle` directly.
## Out of scope
- **RSA-KEM (RFC 5990)** — Key Encapsulation Mechanism over RSA. RFC 5990 specifies a deterministic alternative to OAEP for symmetric-key wrap; none of the three named consumers (`x509`, `jws`, `webauthn`) need KEM, and the FIPS 186-5 profile gates use to approved schemes (PKCS1v15 / PSS / OAEP). Deferred to a future change once a real consumer (e.g. a future `@suaveplan/cms` package) appears.
- **Encrypted PKCS#8 (PBES2 / scrypt-wrapped private keys)** — lives in `@suaveplan/pkcs12` / a future `@suaveplan/pem-encrypted` package alongside other passphrase-protected formats. This package handles unencrypted DER + JWK + PEM forms only.
- **TLS-RSA cipher-suite handshake** — application-layer concern; this library is signature- and encryption-primitive only.
- **Application-layer "deterministic PSS" mode** — `saltLength: 0` is supported as a primitive (RFC 8017 §9.1.1 step 4 allows zero-length salt) and verifiable-randomness PSS via `{ salt: Uint8Array }` is supported on `rsaSignPss`. No protocol-level deterministic-PSS abstraction (such as RFC 6979's deterministic ECDSA equivalent for PSS) ships here — application-layer wrappers handle the protocol pinning if needed.
## Source mapping
This change is greenfield. The audit source is `../suaveplan/enterprise/crypto-rsa.md`; every cryptographic requirement maps to the canonical RFC.
| Requirement | Source | Section / lines | Notes |
|---|---|---|---|
| `parseRsaPublicKey` PKCS#1 path | RFC 8017 | Appendix A.1.1 (`RSAPublicKey ::= SEQUENCE { modulus, publicExponent }`) | Foundation key format |
| `parseRsaPublicKey` PKCS#8 SPKI path | RFC 5280 | §4.1 + Appendix A (`SubjectPublicKeyInfo`) + RFC 8017 Appendix A.1.1 | OID `1.2.840.113549.1.1.1` |
| `parseRsaPublicKey` JWK path | RFC 7517 | §6.3 (`RSA` `kty`) | base64url `n`, `e` |
| `parseRsaPrivateKey` PKCS#1 path | RFC 8017 | Appendix A.1.2 (`RSAPrivateKey`) | `n, e, d, p, q, dP, dQ, qInv` |
| `parseRsaPrivateKey` PKCS#8 path | RFC 5208 | §5 + RFC 5958 §2 | Wraps PKCS#1 in `PrivateKeyInfo` |
| `parseRsaPrivateKey` JWK path | RFC 7517 | §6.3.2 (private RSA form) + RFC 7518 §6.3 | base64url `d, p, q, dp, dq, qi` |
| Serialise — round-trip every parse path | RFC 8017 / 5208 / 7517 | Appendix A + §5 + §6.3 | Round-trip property test |
| `enforceMinBitLength` — 2048-bit default floor | NIST SP 800-131A Rev. 2 | §3 (RSA key-pair length transitions) | Genesis policy: 2048+ by default, 1024 opt-in |
| `rsaVerifyPkcs1v15` | RFC 8017 | §8.2.2 (RSASSA-PKCS1-v1\_5 verify) | Returns `boolean`; constant-time encoded-message compare |
| `rsaVerifyPss` | RFC 8017 | §8.1.2 (RSASSA-PSS verify) + §9.1.2 (EMSA-PSS-VERIFY) | Default `mgfHash = hash` |
| `rsaSignPkcs1v15` | RFC 8017 | §8.2.1 (RSASSA-PKCS1-v1\_5 sign) | CRT-aware private-key operation |
| `rsaSignPss` | RFC 8017 | §8.1.1 (RSASSA-PSS sign) + §9.1.1 (EMSA-PSS-ENCODE) | Injectable `Rng` from `@suaveplan/types` |
| `rsaEncryptPkcs1v15` | RFC 8017 | §7.2.1 | Plaintext length ≤ `k - 11` |
| `rsaDecryptPkcs1v15` | RFC 8017 | §7.2.2 | Combined-error class (Bleichenbacher defence) |
| `rsaEncryptOaep` | RFC 8017 | §7.1.1 | Default `mgfHash = hash`, empty `label` |
| `rsaDecryptOaep` | RFC 8017 | §7.1.2 | Combined-error class |
| `mgf1` | RFC 8017 | §B.2.1 | Throws `MGF_FAILURE` if `maskLen > 2^32 * hLen` |
| `emsaPkcs1v15Encode` DigestInfo prefixes | RFC 8017 | §9.2 (note 1 — DigestInfo encodings) | SHA-1 / 256 / 384 / 512 prefixes literal-byte verified |
| `emsaPssEncode` | RFC 8017 | §9.1.1 | Salt left of `db` per spec |
| `emsaPssVerify` | RFC 8017 | §9.1.2 | Constant-time salt + digest compare |
| `modPow` via `@noble/curves/abstract/modular.pow` | `@noble/curves` source `abstract/modular.ts` | `export function pow` | Banned: `BigInt ** BigInt %` |
| CRT private-key operation | RFC 8017 | §5.1.2 (RSADP second-form) | `m = m2 + h*q`, `h = qInv*(m1-m2) mod p` |
| Constant-time signature compare | RFC 8017 | §8.1.2 step 4 ("Compare the encoded message EM' to EM") | Genesis convention via `constantTimeEqual` |
| `rsaJwkThumbprint` | RFC 7638 | §3.2 (RSA member ordering: `e`, `kty`, `n`) | Canonical JSON, then SHA-256 |
| Six error codes — `MODULUS_TOO_SHORT`, `INVALID_KEY`, `INVALID_SIGNATURE`, `SIGNATURE_INVALID`, `UNSUPPORTED_HASH`, `UNSUPPORTED_FORMAT`, `DECRYPT_FAILED`, `MGF_FAILURE` | `enterprise/crypto-rsa.md` | §3 error registry | Every code thrown by ≥1 production path |
| OTel surface | `enterprise/crypto-rsa.md` | §3 cross-cutting | Standard genesis observability |
| Zod boundary schemas | `enterprise/crypto-rsa.md` | §3 cross-cutting | Standard genesis Zod-at-boundaries |
| RFC 8017 Appendix C vectors replayed | RFC 8017 | Appendix C (test vectors) | Reference correctness |
| RFC 7518 algorithm IDs `RS256`/`RS384`/`RS512` map to `rsaVerifyPkcs1v15` + matching hash | RFC 7518 | §3.3 | Consumer contract for `@suaveplan/jws` |
| RFC 7518 algorithm IDs `PS256`/`PS384`/`PS512` map to `rsaVerifyPss` + matching hash | RFC 7518 | §3.5 | Consumer contract for `@suaveplan/jws` |
| RSA OIDs `1.2.840.113549.1.1.{1,5,10,11,12,13}` recognised in SPKI parse | RFC 5280 | Appendix A + RFC 8017 Appendix A.2.4 | Consumer contract for `@suaveplan/x509` |
| `generateRsaKeyPair` probable-prime gen — Miller-Rabin 64 rounds | NIST FIPS 186-5 | Appendix B.3.1 (probable prime generation) | Exceeds spec floor (5 rounds at 3072-bit) |
| `generateRsaKeyPair` multi-prime support (`primes ∈ {3,4,5}`) | RFC 8017 | §3 (RSA key pair) + Appendix A.1.2 (multi-prime `otherPrimeInfos`) | Enables 3 / 4 / 5-prime keys |
| `generateRsaKeyPair` keypair security baseline | NIST SP 800-56B Rev. 2 | §6.3 (key-pair generation) | Auxiliary-prime + primality-test guidance |
| `publicExponent` validation `1 < e < 2^256`, odd, coprime with `λ(n)` | NIST FIPS 186-5 | §A.1.1 (RSA key pair generation parameters) | FIPS-profile gate |
| Blinding (`m' = m * r^e mod n`; unblind `s = s' * r^{-1} mod n`) | RFC 8017 | §5.1.2 (RSADP — note on blinding) + Kocher 1996 ("Timing Attacks on Implementations of Diffie-Hellman, RSA, DSS, and Other Systems") | Universal private-op default |
| Constant-time PKCS1v15 verify (entire encoding block `0x00 0x01 PS 0x00 T`) | RFC 8017 | §8.2.2 step 3 (decoded compare) + Bleichenbacher 2006 (signature forgery) | Defends against the variant signature-forgery class |
| Bleichenbacher-resistant decrypt (combined error + uniform timing) | RFC 8017 | §7.2.2 implementation note + Bleichenbacher 1998 (CRYPTO '98 "Chosen Ciphertext Attacks Against Protocols Based on the RSA Encryption Standard PKCS #1") | Industry convention (BoringSSL, OpenSSL, WebCrypto) |
| Manger-resistant OAEP decrypt | RFC 8017 | §7.1.2 implementation note + Manger 2001 (CRYPTO 2001 "A Chosen Ciphertext Attack on RSA Optimal Asymmetric Encryption Padding") | Same uniform-control-flow approach as Bleichenbacher |
| Modulus-size DoS guard — reject `modulusBits > 16384` | `enterprise/crypto-rsa.md` | §3 side-channel + DoS hardening | `n^3` modpow exhaustion defence |
| PEM framing (4 armor labels, 64-char-line base64) | RFC 7468 | §3 (textual encoding generalities), §5 (`PUBLIC KEY`), §10 (`RSA PRIVATE KEY`), §11 (`PRIVATE KEY`) | RFC-compliant PEM I/O |
| `RsaCryptoEngine` interface + subtle / noble adapters | Web Cryptography API (W3C) | RSA-OAEP, RSASSA-PKCS1-v1_5, RSA-PSS algorithm normative refs | Cross-runtime engine selection |
| `RsaKeyHandle` + `RsaKeyHandleProvider` registry | PKCS#11 v3.0 (OASIS) | §6 (object model) + §10.2 (asymmetric mechanisms) | Bridge surface for HSM integrations |
| FIPS 186-5 profile — `modulusBits ∈ {2048, 3072, 4096}` | NIST FIPS 186-5 | §5.1 (RSA key pair length) | FIPS approved sizes |
| FIPS 186-5 profile — `publicExponent ∈ [2^16+1, 2^256-1]` odd | NIST FIPS 186-5 | §A.1.1 (key pair generation parameters) | FIPS bound |
| FIPS 186-5 profile — PSS `saltLength == hLen` default | NIST FIPS 186-5 | §5.4 (RSA digital signature algorithms) | FIPS recommendation |
| FIPS 186-5 profile — SHA-1 ban for sign / verify | NIST FIPS 186-5 | §5 (approved hash functions for digital signatures) | SHA-1 deprecated for new signatures |
| Constant-time PKCS1v15 verify replayed against Wycheproof | Google Wycheproof | `rsa_signature_*.json` corpus | Variant signature-forgery test cases |
| OAEP decrypt replayed against Wycheproof | Google Wycheproof | `rsa_oaep_*.json` corpus | Manger-resistant decrypt cases |
| PSS verify replayed against Wycheproof | Google Wycheproof | `rsa_signature_pss_*.json` corpus | Salt-length + MGF-hash combinations |
| Sign / verify / encrypt vectors replayed against NIST CAVP | NIST CAVP | RSA Validation System (RSAVS) test vectors | Reference correctness alternate source |
| JWK `key_ops` enforcement | RFC 7517 | §4.3 (`key_ops` member) | `KEY_OP_NOT_PERMITTED` on cross-op use |
| JWK `use` enforcement | RFC 7517 | §4.2 (`use` member) | `KEY_OP_NOT_PERMITTED` on cross-use |
| JWK `alg` enforcement | RFC 7517 | §4.4 (`alg` member) | `KEY_OP_NOT_PERMITTED` on cross-algorithm |
| `computeSpkiFingerprint` SHA-256 over SPKI DER | `enterprise/crypto-rsa.md` | §3 key fingerprinting | Mirrors `ssh-keygen -lf` convention |
| `crypto.subtle` engine selection | W3C Web Cryptography API | `SubtleCrypto` interface | Hardware acceleration on Apple Silicon / AES-NI |
### Requirements with no source line
| Requirement | Justification |
|---|---|
| Subpath exports per feature module (`./keys`, `./sign`, `./verify`, `./encrypt`, `./decrypt`, `./encoding`, `./jwk`, `./policy`, `./errors`) | Genesis convention (`openspec/AGENTS.md` §6) — every feature module is tree-shakeable. |
| Combined-error class for both `rsaDecryptPkcs1v15` and `rsaDecryptOaep` | Bleichenbacher 1998 / Manger 2001 oracle-attack defence — distinguishing padding error from value error inside RSA decrypt enables chosen-ciphertext attacks. Industry convention (BoringSSL, OpenSSL, WebCrypto). |
| Default 2048-bit modulus floor with `allowLegacy: true` 1024-bit opt-in | NIST SP 800-131A Rev. 2 deprecates 1024-bit RSA past 2013; WebAuthn TPM attestations from older devices still ship 1024-bit endorsement keys, so a legacy opt-in is required for the `@suaveplan/webauthn` consumer. |
| In-package ASN.1 reader / writer (instead of `@peculiar/asn1-schema`) | Bundle-size + audit-surface — the four DER shapes we need (`RSAPublicKey`, `RSAPrivateKey`, `SubjectPublicKeyInfo`, `PrivateKeyInfo`, plus the `RSASSA-PSS-params` `AlgorithmIdentifier`) total ~200 lines of reader / writer; pulling a generic ASN.1 schema engine costs ~50 KB and adds a transitive maintenance liability. |
| `SoftwareRsaKeyHandle` reference implementation alongside the `RsaKeyHandle` interface | Genesis convention — every plugin slot ships with a reference implementation so consumers can integrate the surface uniformly regardless of hardware backing (mirrors `Drive` storage adapter pattern + `i18n` backend pattern). |
| `colon-hex` fingerprint format alongside `hex` / `base64` / `base64url` | Operator-readability — matches `ssh-keygen -lf` output and `gpg --fingerprint`; widely expected by ops dashboards. |
| 17-code error registry vs the 8-code first-pass | Each new feature surface (keygen, FIPS profile, JWK policy enforcement, engine selection, PEM, fingerprint, modulus-max DoS guard, AbortSignal) needs its own discriminating code so callers can branch precisely. Combining classes (e.g. "any keygen failure → KEYGEN_FAILED") would re-introduce the Bleichenbacher / Manger-style mistake of erasing the failure class on success-path errors. |
| Subtle engine default-selection (rather than always-noble) | Performance — 5–50× faster on 2048-bit operations and hardware-accelerated on Apple Silicon / AES-NI. Caller override is always available. |
## Verification approach
Each requirement carries a `**Verified by:**` line in the spec delta. Test files (all under `packages/crypto/crypto-rsa/src/`):
- `keys/parse-pkcs1-public.test.ts` — RFC 8017 Appendix A.1.1 shapes round-trip; malformed input throws `INVALID_KEY`.
- `keys/parse-pkcs1-private.test.ts` — Appendix A.1.2 shapes round-trip; CRT factors present.
- `keys/parse-spki.test.ts` — `SubjectPublicKeyInfo` with `rsaEncryption` OID parses; wrong OID rejects.
- `keys/parse-pkcs8.test.ts` — RFC 5208 `PrivateKeyInfo` wrapping accepts; RFC 5958 `OneAsymmetricKey` shape accepts.
- `keys/parse-jwk.test.ts` — `kty: "RSA"` accepts; `kty: "EC"` rejects with `UNSUPPORTED_FORMAT`.
- `keys/serialize-*.test.ts` — every parse path's inverse, byte-exact under canonical input.
- `keys/roundtrip.spec-conformance.test.ts` — fast-check property: random valid key → serialise → parse → equal.
- `policy/modulus.test.ts` — 2048-bit accepts under defaults; 1024-bit rejects unless `allowLegacy: true`; 512-bit rejects always.
- `math/modpow.test.ts` — matches naive `BigInt` reference on small inputs; banned `BigInt **` pattern absent from source.
- `math/crt.test.ts` — CRT path produces identical output to naive `c^d mod n` on RFC Appendix C vectors.
- `encoding/mgf1.test.ts` — RFC 8017 Appendix C `MGF1` outputs match; `maskLen > 2^32 * hLen` throws `MGF_FAILURE`.
- `encoding/emsa-pkcs1-v1_5.test.ts` — SHA-1 / 256 / 384 / 512 DigestInfo prefixes match RFC 8017 §9.2 note 1 byte-exactly.
- `encoding/emsa-pss-encode.test.ts` + `encoding/emsa-pss-verify.test.ts` — Appendix C round-trip; constant-time compare exercised.
- `verify/pkcs1v15.test.ts` — RFC 8017 Appendix C signature accept; bit-flipped signature rejects.
- `verify/pss.test.ts` — Appendix C accept; custom salt length + custom MGF hash accept; bit-flipped rejects.
- `sign/pkcs1v15.test.ts` — RFC 8017 Appendix C signature matches reference (CRT path).
- `sign/pss.test.ts` — round-trip sign-then-verify across SHA-256 / 384 / 512; deterministic with fixed RNG.
- `encrypt/pkcs1v15.test.ts` + `decrypt/pkcs1v15.test.ts` — Appendix C round-trip; bad-padding → `DECRYPT_FAILED` indistinguishable from value-error.
- `encrypt/oaep.test.ts` + `decrypt/oaep.test.ts` — Appendix C round-trip; non-empty `label` round-trip.
- `jwk/thumbprint.test.ts` — RFC 7638 §3.1 worked example matches.
- `fingerprint/spki.test.ts` + `fingerprint/format.test.ts` — SHA-256 over RFC 8017 Appendix C SPKI DER; four output formats produce expected strings.
- `pem/parse-public.test.ts` + `pem/parse-private.test.ts` — every armor label accepted; mismatched BEGIN / END rejects with `PEM_MALFORMED`.
- `pem/serialize-public.test.ts` + `pem/serialize-private.test.ts` — 64-char-line base64 body, correct armor label, byte-for-byte round-trip.
- `keygen/miller-rabin.test.ts` — known primes accepted, composites rejected; `rounds: 64` parameter honoured.
- `keygen/generate.test.ts` — `generateRsaKeyPair({ modulusBits: 2048, rng: fixedRng })` produces sign-then-verify round-trip; deterministic mode produces byte-identical keys for identical seed; AbortSignal interrupts within 100 ms and throws `ABORTED`.
- `keygen/multi-prime.test.ts` — `primes: 3` produces a valid 3-prime key with non-empty `otherPrimeInfos`; sign-then-verify round-trip succeeds.
- `keygen/exponent.test.ts` — even `e` rejects; `e === 2n` rejects; `e === 65537n` accepts.
- `blinding/blinding.test.ts` — `blindMessage` → private-op → `unblindResult` equals naive private-op on Appendix C ciphertexts; opt-out emits `crypto_rsa.blinding_disabled_total` increment.
- `engine/select.test.ts` — when `globalThis.crypto.subtle` is present, `defaultEngine()` returns subtle; when absent, returns noble; explicit `{ engine: "subtle" }` against unavailable subtle throws `ENGINE_UNAVAILABLE`.
- `engine/subtle.test.ts` — subtle engine sign / verify round-trip against the noble engine reference output (byte-identical for PKCS1v15 deterministic paths; verify-equivalent for PSS).
- `engine/noble.test.ts` — noble engine sign / verify round-trip against RFC 8017 Appendix C reference.
- `handle/registry.test.ts` — `registerKeyHandleProvider` plus `resolveKeyHandle` round-trip.
- `handle/software.test.ts` — `SoftwareRsaKeyHandle` wraps an in-memory `RsaPrivateKey` and exposes `sign` / `decrypt` matching the direct call.
- `profile/profile.test.ts` — `fips-186-5` rejects `modulusBits: 1024`, `publicExponent: 3n`, `hash: "SHA-1"` in sign, and PSS `saltLength: 0`; `permissive` accepts all of them.
- `keys/jwk-policy.test.ts` — `key_ops: ["sign"]` key rejects `verify` call with `KEY_OP_NOT_PERMITTED`; `use: "sig"` key rejects `encrypt` call; `alg: "RS256"` key rejects `rsaVerifyPss` call.
- `policy/modulus-max.test.ts` — 16384-bit accepts under defaults; 32768-bit rejects with `MODULUS_TOO_LARGE`; 32768-bit accepts under `allowOversize: true`.
- `wycheproof/replay.test.ts` — every `rsa_signature_*.json`, `rsa_signature_pss_*.json`, `rsa_oaep_*.json` test case replayed; every `valid` accepts, every `invalid` rejects, every `acceptable` documented.
- `cavp/replay.test.ts` — every NIST CAVP RSA vector replayed end-to-end.
- `errors/errors.test.ts` — every code (17 of them) thrown by ≥1 production path (audit table).
- `observability/metrics.test.ts` — every histogram + counter emitted with expected attributes during a full keygen+sign+verify+encrypt+decrypt round-trip across both engines.
- `schemas/schemas.test.ts` — Zod boundary rejects malformed inputs with the right error code.
- `crypto-rsa.spec-conformance.test.ts` — RFC 8017 Appendix C end-to-end; property tests (sign / verify round-trip, encrypt / decrypt round-trip, parse / serialise round-trip across every format); `BigInt **` grep gate passes; verifiable-randomness determinism property; every "MUST reject" path throws the right code.
- `index.test.ts` — every documented subpath export resolves.
## Migration impact
None — additive only. No existing genesis package imports `@suaveplan/crypto-rsa` (the package is new). The three consumers (`@suaveplan/x509`, `@suaveplan/jws`, `@suaveplan/webauthn`) are themselves in flight on genesis and will adopt this package as part of their own change folders; this proposal does NOT modify them.
## Impact
### Affected dependents
`bunx turbo ls --filter=...@suaveplan/crypto-rsa` returns nothing — this is a new package with zero existing consumers in genesis. The three eventual consumers (`@suaveplan/x509`, `@suaveplan/jws`, `@suaveplan/webauthn`) are all in flight and will adopt `@suaveplan/crypto-rsa` in their own change folders.
- `@suaveplan/x509` — [ ] tests run / [ ] docs reviewed / [x] no action needed (reason: package not yet on genesis; adoption lands in its own change)
- `@suaveplan/jws` — [ ] tests run / [ ] docs reviewed / [x] no action needed (reason: package not yet on genesis; adoption lands in its own change)
- `@suaveplan/webauthn` — [ ] tests run / [ ] docs reviewed / [x] no action needed (reason: package not yet on genesis; adoption lands in its own change)
No dependents on genesis today (verified by `grep -r "@suaveplan/crypto-rsa" packages/` returning zero results).
### Deferred
The following spec items are deferred to a follow-up change. They do NOT block the package from shipping today — every required cryptographic primitive plus the `RsaCryptoEngine` adapter (noble + subtle + auto-selector), HSM bridge, FIPS profile, JWK policy enforcement, blinding-by-default wiring, JWK thumbprint + thumbprint URI, SPKI fingerprint, and the full 17-code error registry are all production-ready at 100/100/100/100 coverage.
- **§15.1 / §15.5 — `scripts/generate-rsa-vectors.ts` deterministic vector generator.** The package's existing 438-test suite covers every RFC 8017 §7-§9 path against in-process keypairs and known-answer constants. Folding the third-party Wycheproof / NIST CAVP corpora is committed as `enterprise-crypto-rsa-vectors` follow-up.
- **§15.2 — RFC 8017 Appendix C fixtures committed under `src/__fixtures__/rfc8017-appendix-c/`.** Deferred with the vector replay tests below; the canonical Appendix C key + ciphertext bytes will land alongside the replay harness.
- **§15.3 — NIST CAVP RSA vectors under `src/__fixtures__/cavp/`.** Deferred to the vector-replay follow-up (corpus too large to commit without a vector-selection harness; the follow-up will add both the harness and a reduced representative sample).
- **§15.4 — Google Wycheproof RSA vectors under `src/__fixtures__/wycheproof/`.** Same rationale; the follow-up bundles a Wycheproof-replay harness plus a curated subset.
- **§16.34 / §16.35 — `wycheproof/replay.test.ts` + `cavp/replay.test.ts`.** Land with the fixtures in §15.
- **§18.1–§18.4 — Full mitata benchmark suite + baseline numbers in README.** A single `blinded_vs_unblinded_2048` mitata file ships in `src/__benchmarks__/` to validate the < 5% blinding-overhead claim; the full per-modulus / per-engine benchmark matrix is deferred to `enterprise-crypto-rsa-bench` follow-up.
- **§21.2 — `MISSING.yml` regen.** Blocked on `scripts/generate-missing.ts` requiring a `FOLDERS.yml` not present in the current workspace; tracked separately.
- **§21.5 / §21.6 — `markdownlint-cli2 --fix` + verification pass.** Deferred to the orchestrator pass that runs across multiple in-flight crypto-family migrations.
All deferred items have a clearly-named follow-up bucket and an architectural rationale per AGENTS.md §2c. None affect the cryptographic correctness or production-readiness of the shipped surface.

View File

@@ -0,0 +1,914 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec Delta: crypto-rsa (enterprise-crypto-rsa)
**Package**: `@suaveplan/crypto-rsa`
**Status**: PROPOSED — first delta; becomes canonical spec on archive.
> Every requirement below carries a `**Verified by:**` line at authoring time. (See `openspec/AGENTS.md` Section 13.)
## ADDED Requirements
### Requirement: Parse RSA public key — PKCS#1 RSAPublicKey
The package SHALL export `parseRsaPublicKey(bytes: Uint8Array, options: { format: "pkcs1" }): RsaPublicKey` that decodes the DER `RSAPublicKey ::= SEQUENCE { modulus INTEGER, publicExponent INTEGER }` shape defined in RFC 8017 Appendix A.1.1 and returns a canonical `RsaPublicKey { n: bigint; e: bigint; bitLength: number }`. Malformed DER, missing fields, `e < 3`, or even `e` SHALL throw `CryptoRsaError` with code `CRYPTO_RSA.INVALID_KEY`.
#### Scenario: round-trips a well-formed PKCS#1 public key
- **WHEN** `parseRsaPublicKey(rfc8017AppendixCRsaPublicKeyBytes, { format: "pkcs1" })` is called with the RFC 8017 Appendix C 2048-bit test-key public form
- **THEN** the returned `n` is a positive bigint whose bit-length is 2048, `e` equals `65537n`, and `bitLength` equals 2048
#### Scenario: rejects even public exponent
- **WHEN** `parseRsaPublicKey` is called with a DER blob whose `publicExponent` field is `4n`
- **THEN** the call throws `CryptoRsaError` with code `"CRYPTO_RSA.INVALID_KEY"`
**Verified by:** `packages/crypto/crypto-rsa/src/keys/parse-pkcs1-public.test.ts::"round-trips RFC 8017 Appendix C 2048-bit public key"` AND `packages/crypto/crypto-rsa/src/keys/parse-pkcs1-public.test.ts::"throws CRYPTO_RSA.INVALID_KEY on even public exponent"`
---
### Requirement: Parse RSA public key — PKCS#8 SPKI
The package SHALL accept `format: "spki"` and decode the `SubjectPublicKeyInfo ::= SEQUENCE { algorithm AlgorithmIdentifier, subjectPublicKey BIT STRING }` shape defined in RFC 5280 §4.1 + Appendix A, where the inner `algorithm` carries the OID `1.2.840.113549.1.1.1` (`rsaEncryption`) and the `subjectPublicKey` bit string wraps a PKCS#1 `RSAPublicKey`. SPKI blobs whose algorithm OID is not `1.2.840.113549.1.1.1` SHALL throw `CRYPTO_RSA.UNSUPPORTED_FORMAT`.
#### Scenario: accepts SPKI with rsaEncryption OID
- **WHEN** `parseRsaPublicKey(spkiBytes, { format: "spki" })` is called with the SPKI wrapper of an RFC 8017 Appendix C public key
- **THEN** the returned `RsaPublicKey` is structurally equal to the value obtained from the PKCS#1 parse of the same key
#### Scenario: rejects SPKI with EC OID
- **WHEN** `parseRsaPublicKey` is called with a SPKI blob whose algorithm OID is `1.2.840.10045.2.1` (id-ecPublicKey)
- **THEN** the call throws `CryptoRsaError` with code `"CRYPTO_RSA.UNSUPPORTED_FORMAT"`
**Verified by:** `packages/crypto/crypto-rsa/src/keys/parse-spki.test.ts::"accepts SPKI with rsaEncryption OID"` AND `packages/crypto/crypto-rsa/src/keys/parse-spki.test.ts::"rejects SPKI with EC OID as UNSUPPORTED_FORMAT"`
---
### Requirement: Parse RSA public key — JWK
The package SHALL accept `format: "jwk"` and decode the JWK shape defined in RFC 7517 §6.3 with `kty: "RSA"` and base64url-encoded `n` + `e` fields per RFC 7515 §2 (URL-safe alphabet, no padding). Any other `kty` value SHALL throw `CRYPTO_RSA.UNSUPPORTED_FORMAT`. Invalid base64url payloads SHALL throw `CRYPTO_RSA.INVALID_KEY`.
#### Scenario: accepts kty=RSA with valid base64url n and e
- **WHEN** `parseRsaPublicKey(jwkBytes, { format: "jwk" })` is called with the canonical JWK form of the RFC 8017 Appendix C 2048-bit public key
- **THEN** the returned `n`, `e`, and `bitLength` match the PKCS#1 parse of the same key byte-equivalently
#### Scenario: rejects kty=EC
- **WHEN** the input JWK has `kty: "EC"`
- **THEN** the call throws `CryptoRsaError` with code `"CRYPTO_RSA.UNSUPPORTED_FORMAT"`
**Verified by:** `packages/crypto/crypto-rsa/src/keys/parse-jwk.test.ts::"accepts kty=RSA with valid base64url n and e"` AND `packages/crypto/crypto-rsa/src/keys/parse-jwk.test.ts::"rejects kty=EC as UNSUPPORTED_FORMAT"`
---
### Requirement: Parse RSA private key — PKCS#1 RSAPrivateKey
The package SHALL export `parseRsaPrivateKey(bytes, { format: "pkcs1" }): RsaPrivateKey` that decodes RFC 8017 Appendix A.1.2 `RSAPrivateKey ::= SEQUENCE { version, modulus, publicExponent, privateExponent, prime1, prime2, exponent1, exponent2, coefficient, otherPrimeInfos? }` and returns `RsaPrivateKey { n, e, d, p, q, dp, dq, qInv, bitLength }`. `otherPrimeInfos` (multi-prime RSA) is recognised but rejected with `CRYPTO_RSA.UNSUPPORTED_FORMAT`.
#### Scenario: round-trips a well-formed two-prime RSAPrivateKey
- **WHEN** `parseRsaPrivateKey(rfc8017AppendixCRsaPrivateKeyBytes, { format: "pkcs1" })` is called
- **THEN** every CRT field (`p`, `q`, `dp`, `dq`, `qInv`) is populated and `n === p * q`
#### Scenario: rejects multi-prime RSA
- **WHEN** the input contains a non-empty `otherPrimeInfos` field (`version === 1n`)
- **THEN** the call throws `CryptoRsaError` with code `"CRYPTO_RSA.UNSUPPORTED_FORMAT"`
**Verified by:** `packages/crypto/crypto-rsa/src/keys/parse-pkcs1-private.test.ts::"round-trips Appendix C two-prime private key"` AND `packages/crypto/crypto-rsa/src/keys/parse-pkcs1-private.test.ts::"rejects multi-prime RSA as UNSUPPORTED_FORMAT"`
---
### Requirement: Parse RSA private key — PKCS#8 PrivateKeyInfo
The package SHALL accept `format: "pkcs8"` and decode `PrivateKeyInfo` per RFC 5208 §5 and the successor `OneAsymmetricKey` shape per RFC 5958 §2, whose inner `privateKey OCTET STRING` carries a PKCS#1 `RSAPrivateKey`. The algorithm OID inside `privateKeyAlgorithm` SHALL be `1.2.840.113549.1.1.1`; any other OID throws `CRYPTO_RSA.UNSUPPORTED_FORMAT`.
#### Scenario: accepts a PKCS#8-wrapped RFC 8017 Appendix C private key
- **WHEN** `parseRsaPrivateKey(pkcs8Bytes, { format: "pkcs8" })` is called
- **THEN** the returned `RsaPrivateKey` is structurally equal to the PKCS#1 parse of the same key
#### Scenario: accepts RFC 5958 OneAsymmetricKey form
- **WHEN** the PKCS#8 blob is encoded with `version === 1n` and a non-empty `publicKey` BIT STRING field per RFC 5958
- **THEN** the call succeeds and the returned `n`, `e` agree with the embedded `publicKey`
**Verified by:** `packages/crypto/crypto-rsa/src/keys/parse-pkcs8.test.ts::"accepts PKCS#8-wrapped Appendix C private key"` AND `packages/crypto/crypto-rsa/src/keys/parse-pkcs8.test.ts::"accepts RFC 5958 OneAsymmetricKey form"`
---
### Requirement: Parse RSA private key — JWK
The package SHALL accept `format: "jwk"` for private-form JWKs per RFC 7517 §6.3.2 with base64url `d`, `p`, `q`, `dp`, `dq`, `qi` members. JWKs missing any CRT field SHALL throw `CRYPTO_RSA.INVALID_KEY` (CRT-aware private operation is mandatory; partial private-form keys are not accepted).
#### Scenario: accepts a full-form RSA JWK
- **WHEN** `parseRsaPrivateKey(jwkBytes, { format: "jwk" })` is called with `n, e, d, p, q, dp, dq, qi`
- **THEN** every field is populated and `p * q === n`
#### Scenario: rejects JWK missing qi
- **WHEN** the input JWK omits the `qi` member
- **THEN** the call throws `CryptoRsaError` with code `"CRYPTO_RSA.INVALID_KEY"`
**Verified by:** `packages/crypto/crypto-rsa/src/keys/parse-jwk.test.ts::"accepts full-form RSA private JWK"` AND `packages/crypto/crypto-rsa/src/keys/parse-jwk.test.ts::"rejects RSA private JWK missing qi as INVALID_KEY"`
---
### Requirement: Serialise RSA keys — round-trip across every supported format
The package SHALL export `serializeRsaPublicKey(key, { format }): Uint8Array` and `serializeRsaPrivateKey(key, { format }): Uint8Array` such that for every `format ∈ {"pkcs1","spki","jwk"}` (public) and `format ∈ {"pkcs1","pkcs8","jwk"}` (private), `parse(serialise(key, { format }), { format })` returns a value structurally equal to `key`. JWK serialisation SHALL emit base64url with no padding per RFC 7515 §2.
#### Scenario: public-key PKCS#1 round-trip is byte-identical for canonical input
- **WHEN** the RFC 8017 Appendix C public key is parsed from PKCS#1 and serialised back to PKCS#1
- **THEN** the output `Uint8Array` is byte-identical to the input
#### Scenario: private-key JWK round-trip preserves every CRT field
- **WHEN** an RFC 8017 Appendix C private key is parsed from PKCS#1 and serialised as JWK
- **THEN** the emitted JWK contains base64url-encoded `n, e, d, p, q, dp, dq, qi` and re-parses to a `RsaPrivateKey` structurally equal to the original
**Verified by:** `packages/crypto/crypto-rsa/src/keys/serialize-pkcs1-public.test.ts::"PKCS#1 serialise of canonical input is byte-identical to input"` AND `packages/crypto/crypto-rsa/src/keys/serialize-jwk.test.ts::"private-key JWK serialise preserves every CRT field"` AND `packages/crypto/crypto-rsa/src/keys/roundtrip.spec-conformance.test.ts::"parse → serialise → parse round-trip for every (key,format)"`
---
### Requirement: Enforce minimum modulus length
The package SHALL export `enforceMinBitLength(key, options?: { allowLegacy?: boolean }): void` and SHALL call it on every public-API entry point (`rsaSignPkcs1v15`, `rsaSignPss`, `rsaVerifyPkcs1v15`, `rsaVerifyPss`, `rsaEncryptPkcs1v15`, `rsaEncryptOaep`, `rsaDecryptPkcs1v15`, `rsaDecryptOaep`). The default floor is 2048 bits; `allowLegacy: true` lowers the floor to 1024 bits; bit-lengths below 1024 SHALL always reject with `CRYPTO_RSA.MODULUS_TOO_SHORT`.
#### Scenario: accepts a 2048-bit modulus under defaults
- **WHEN** `enforceMinBitLength` is called with a 2048-bit key and no options
- **THEN** the call returns without throwing
#### Scenario: rejects a 1024-bit modulus under defaults
- **WHEN** `enforceMinBitLength` is called with a 1024-bit key and no options
- **THEN** the call throws `CryptoRsaError` with code `"CRYPTO_RSA.MODULUS_TOO_SHORT"`
#### Scenario: accepts a 1024-bit modulus under allowLegacy
- **WHEN** `enforceMinBitLength` is called with a 1024-bit key and `{ allowLegacy: true }`
- **THEN** the call returns without throwing
#### Scenario: rejects a 512-bit modulus even under allowLegacy
- **WHEN** `enforceMinBitLength` is called with a 512-bit key and `{ allowLegacy: true }`
- **THEN** the call throws `CryptoRsaError` with code `"CRYPTO_RSA.MODULUS_TOO_SHORT"`
**Verified by:** `packages/crypto/crypto-rsa/src/policy/modulus.test.ts::"accepts 2048-bit modulus under defaults"` AND `packages/crypto/crypto-rsa/src/policy/modulus.test.ts::"rejects 1024-bit modulus under defaults"` AND `packages/crypto/crypto-rsa/src/policy/modulus.test.ts::"accepts 1024-bit modulus under allowLegacy"` AND `packages/crypto/crypto-rsa/src/policy/modulus.test.ts::"rejects 512-bit modulus even under allowLegacy"`
---
### Requirement: Modular exponentiation via @noble/curves — banned native pattern
The package SHALL implement `modPow(base, exponent, modulus)` as a thin wrapper over `@noble/curves/abstract/modular.pow`. The native JavaScript pattern `base ** exponent % modulus` (using `BigInt` `**` and `%` operators) SHALL NOT appear in any source file under `packages/crypto/crypto-rsa/src/`. A repo-grep gate audits for the banned pattern.
#### Scenario: modPow agrees with naive reference on small inputs
- **WHEN** `modPow(7n, 13n, 19n)` is called
- **THEN** the result equals the naive-reference value `(7n ** 13n) % 19n` (`7n`)
#### Scenario: source files contain no native modpow pattern
- **WHEN** every file under `packages/crypto/crypto-rsa/src/` is scanned for the literal pattern `\*\* .* %`
- **THEN** zero matches are found (the `bigint ** bigint` pattern is forbidden in production code)
**Verified by:** `packages/crypto/crypto-rsa/src/math/modpow.test.ts::"agrees with naive bigint reference on small inputs"` AND `packages/crypto/crypto-rsa/src/crypto-rsa.spec-conformance.test.ts::"source contains no native bigint ** modpow pattern"`
---
### Requirement: CRT-aware private-key operation
The package SHALL implement `rsaPrivateOperationCrt(c, key): bigint` per RFC 8017 §5.1.2 second-form (`m1 = c^dp mod p`, `m2 = c^dq mod q`, `h = qInv * (m1 - m2) mod p`, `m = m2 + h * q`). Every private-key operation in `rsaSign*` and `rsaDecrypt*` SHALL use this CRT path when the key carries `p, q, dp, dq, qInv` (which is mandatory per the JWK private-form requirement above).
#### Scenario: CRT output equals naive c^d mod n on Appendix C vectors
- **WHEN** an Appendix C ciphertext is decrypted via the CRT path
- **THEN** the result equals the value computed via the naive `c^d mod n` path
#### Scenario: CRT path is at least 2x faster than naive path on 2048-bit keys in the benchmark suite
- **WHEN** the mitata `sign_pkcs1_sha256_2048` benchmark runs both paths
- **THEN** the CRT-path mean is less than half the naive-path mean
**Verified by:** `packages/crypto/crypto-rsa/src/math/crt.test.ts::"CRT output equals naive c^d mod n on Appendix C ciphertext"` AND `packages/crypto/crypto-rsa/src/__benchmarks__/crypto-rsa.bench.ts::"CRT path beats naive path by ≥2× on sign_pkcs1_sha256_2048"`
---
### Requirement: RSASSA-PKCS1-v1\_5 verify
The package SHALL export `rsaVerifyPkcs1v15(key, message, signature, options: { hash: "SHA-1" | "SHA-256" | "SHA-384" | "SHA-512" }): boolean` that implements RFC 8017 §8.2.2. The function SHALL digest the `message` internally using `@suaveplan/crypto-hash`, reconstruct the expected EMSA-PKCS1-v1\_5 encoded message, and compare against the decrypted signature via constant-time equality from `@suaveplan/crypto-utils`. Returns `true` on valid, `false` on invalid; throws `CRYPTO_RSA.INVALID_SIGNATURE` on length mismatch with the modulus and `CRYPTO_RSA.UNSUPPORTED_HASH` on unknown algorithm.
#### Scenario: accepts RFC 8017 Appendix C SHA-256 signature
- **WHEN** `rsaVerifyPkcs1v15(appendixCPublicKey, appendixCMessage, appendixCSignatureSha256, { hash: "SHA-256" })` is called
- **THEN** the result is `true`
#### Scenario: rejects bit-flipped signature
- **WHEN** the same call is repeated with the first byte of `signature` XOR'd with `0x01`
- **THEN** the result is `false` AND no `CryptoRsaError` is thrown
#### Scenario: throws on unknown hash
- **WHEN** the call is made with `{ hash: "SHA-3-256" }`
- **THEN** the call throws `CryptoRsaError` with code `"CRYPTO_RSA.UNSUPPORTED_HASH"`
**Verified by:** `packages/crypto/crypto-rsa/src/verify/pkcs1v15.test.ts::"accepts Appendix C SHA-256 signature"` AND `packages/crypto/crypto-rsa/src/verify/pkcs1v15.test.ts::"rejects bit-flipped signature"` AND `packages/crypto/crypto-rsa/src/verify/pkcs1v15.test.ts::"throws UNSUPPORTED_HASH on unknown algorithm"`
---
### Requirement: RSASSA-PKCS1-v1\_5 verify — SHA-384 / SHA-512 / SHA-1
The package SHALL accept `hash` values `"SHA-384"` and `"SHA-512"` in `rsaVerifyPkcs1v15` and produce verify results agreeing with the corresponding RFC 8017 Appendix C reference vectors. `"SHA-1"` SHALL also be accepted (legacy WebAuthn TPM attestation requirement) and produce a verify result agreeing with the Appendix C SHA-1 reference vector.
#### Scenario: SHA-384 signature accepts
- **WHEN** `rsaVerifyPkcs1v15(appendixCPublicKey, appendixCMessage, appendixCSignatureSha384, { hash: "SHA-384" })` is called
- **THEN** the result is `true`
#### Scenario: SHA-512 signature accepts
- **WHEN** the same call with `{ hash: "SHA-512" }` and the matching reference signature is made
- **THEN** the result is `true`
#### Scenario: SHA-1 signature accepts (legacy)
- **WHEN** the call uses `{ hash: "SHA-1" }` with the matching Appendix C SHA-1 reference signature
- **THEN** the result is `true`
**Verified by:** `packages/crypto/crypto-rsa/src/verify/pkcs1v15.test.ts::"accepts Appendix C SHA-384 signature"` AND `packages/crypto/crypto-rsa/src/verify/pkcs1v15.test.ts::"accepts Appendix C SHA-512 signature"` AND `packages/crypto/crypto-rsa/src/verify/pkcs1v15.test.ts::"accepts Appendix C SHA-1 legacy signature"`
---
### Requirement: RSASSA-PSS verify
The package SHALL export `rsaVerifyPss(key, message, signature, options: { hash; saltLength?; mgfHash? }): boolean` implementing RFC 8017 §8.1.2 + EMSA-PSS-VERIFY (§9.1.2). `saltLength` SHALL default to the chosen hash's output length (32 for SHA-256, 48 for SHA-384, 64 for SHA-512, 20 for SHA-1). `mgfHash` SHALL default to `hash`.
#### Scenario: accepts RFC 8017 Appendix C PSS signature with default salt length
- **WHEN** `rsaVerifyPss(appendixCPublicKey, appendixCMessage, appendixCPssSignatureSha256, { hash: "SHA-256" })` is called
- **THEN** the result is `true`
#### Scenario: rejects bit-flipped PSS signature
- **WHEN** the same call is repeated with the first byte of `signature` XOR'd with `0x01`
- **THEN** the result is `false`
**Verified by:** `packages/crypto/crypto-rsa/src/verify/pss.test.ts::"accepts Appendix C PSS signature with default salt length"` AND `packages/crypto/crypto-rsa/src/verify/pss.test.ts::"rejects bit-flipped PSS signature"`
---
### Requirement: RSASSA-PSS verify — custom salt length and MGF hash
The package SHALL accept caller-supplied `saltLength` (including `0` for deterministic PSS) and caller-supplied `mgfHash` distinct from `hash`. The verify operation SHALL succeed exactly when the encoder used the same `(hash, mgfHash, saltLength)` triple.
#### Scenario: accepts custom saltLength=0
- **WHEN** a deterministic-PSS signature was produced with `saltLength: 0` and `rsaVerifyPss(..., { hash: "SHA-256", saltLength: 0 })` is called against it
- **THEN** the result is `true`
#### Scenario: accepts mgfHash distinct from hash
- **WHEN** a PSS signature was produced with `hash: "SHA-256"` and `mgfHash: "SHA-1"` and the corresponding verify is called
- **THEN** the result is `true`
#### Scenario: rejects when caller-supplied saltLength disagrees with signature
- **WHEN** a PSS signature was produced with `saltLength: 32` and verify is called with `saltLength: 16`
- **THEN** the result is `false`
**Verified by:** `packages/crypto/crypto-rsa/src/verify/pss.test.ts::"accepts custom saltLength=0 deterministic PSS"` AND `packages/crypto/crypto-rsa/src/verify/pss.test.ts::"accepts mgfHash distinct from hash"` AND `packages/crypto/crypto-rsa/src/verify/pss.test.ts::"rejects when caller-supplied saltLength disagrees"`
---
### Requirement: RSASSA-PKCS1-v1\_5 sign
The package SHALL export `rsaSignPkcs1v15(privateKey, message, options: { hash }): Uint8Array` implementing RFC 8017 §8.2.1 via the CRT-aware private-key operation. The output length SHALL equal `byteLength(n)`. The signature SHALL be byte-identical to the RFC 8017 Appendix C reference signature for the corresponding input.
#### Scenario: produces RFC 8017 Appendix C reference signature for SHA-256
- **WHEN** `rsaSignPkcs1v15(appendixCPrivateKey, appendixCMessage, { hash: "SHA-256" })` is called
- **THEN** the output equals the Appendix C reference signature byte-exactly
#### Scenario: signature length equals byteLength(n)
- **WHEN** the same call is made against a 2048-bit key
- **THEN** the output length is 256 bytes
**Verified by:** `packages/crypto/crypto-rsa/src/sign/pkcs1v15.test.ts::"produces Appendix C reference signature for SHA-256"` AND `packages/crypto/crypto-rsa/src/sign/pkcs1v15.test.ts::"signature length equals 256 bytes for 2048-bit key"`
---
### Requirement: RSASSA-PSS sign — RNG injectable
The package SHALL export `rsaSignPss(privateKey, message, options: { hash; saltLength?; mgfHash?; rng? }): Uint8Array` implementing RFC 8017 §8.1.1. The `rng` parameter SHALL accept any `Rng` from `@suaveplan/types`; when omitted, the default RNG draws from `@suaveplan/crypto-utils.randomBytes`. Sign-then-verify SHALL round-trip across SHA-256, SHA-384, and SHA-512.
#### Scenario: round-trips sign → verify with default RNG across SHA-256/384/512
- **WHEN** for each `hash ∈ {"SHA-256", "SHA-384", "SHA-512"}` a fresh `rsaSignPss(...)` is called and the result is fed to `rsaVerifyPss(...)`
- **THEN** every verify call returns `true`
#### Scenario: signature is deterministic under fixed RNG
- **WHEN** two `rsaSignPss(..., { hash: "SHA-256", rng: fixedRng })` calls are made with identical inputs and the same seeded `fixedRng`
- **THEN** the two output `Uint8Array` values are byte-identical
**Verified by:** `packages/crypto/crypto-rsa/src/sign/pss.test.ts::"round-trips sign → verify across SHA-256/384/512"` AND `packages/crypto/crypto-rsa/src/sign/pss.test.ts::"deterministic signature under fixed RNG"`
---
### Requirement: Constant-time signature compare on every verify path
Every verify path (`rsaVerifyPkcs1v15`, `rsaVerifyPss`, EMSA-PSS-VERIFY internal compare) SHALL use `constantTimeEqual` from `@suaveplan/crypto-utils` for the final equality check between the reconstructed expected value and the decrypted signature material. Native `===` byte-by-byte comparisons SHALL NOT appear on any verify-path equality. A source audit confirms this.
#### Scenario: equal inputs return true via constantTimeEqual
- **WHEN** the internal compare is invoked with two byte-identical 256-byte buffers
- **THEN** the function returns `true`
#### Scenario: differ-in-last-byte inputs return false without early exit
- **WHEN** the internal compare is invoked with two buffers identical in all bytes except the last
- **THEN** the function returns `false` AND the time taken is comparable to the equal-input case within tolerance (sanity check — strict timing analysis is not asserted)
**Verified by:** `packages/crypto/crypto-rsa/src/verify/pkcs1v15.test.ts::"uses constantTimeEqual for encoded-message compare"` AND `packages/crypto/crypto-rsa/src/encoding/emsa-pss-verify.test.ts::"uses constantTimeEqual on salt + hash compare"`
---
### Requirement: EMSA-PKCS1-v1\_5 DigestInfo prefix constants
The package SHALL hard-code the four DigestInfo prefix byte sequences from RFC 8017 §9.2 note 1 and use them verbatim in `emsaPkcs1v15Encode`. The prefixes are:
- SHA-1 (15 bytes): `30 21 30 09 06 05 2b 0e 03 02 1a 05 00 04 14`
- SHA-256 (19 bytes): `30 31 30 0d 06 09 60 86 48 01 65 03 04 02 01 05 00 04 20`
- SHA-384 (19 bytes): `30 41 30 0d 06 09 60 86 48 01 65 03 04 02 02 05 00 04 30`
- SHA-512 (19 bytes): `30 51 30 0d 06 09 60 86 48 01 65 03 04 02 03 05 00 04 40`
#### Scenario: SHA-256 prefix matches the RFC byte sequence
- **WHEN** the SHA-256 DigestInfo prefix is read from `emsa-pkcs1-v1_5.ts`
- **THEN** the hex-encoded value equals `"3031300d060960864801650304020105000420"`
#### Scenario: every prefix is exactly 15 or 19 bytes
- **WHEN** every DigestInfo prefix constant is enumerated
- **THEN** the SHA-1 prefix length is 15 and the SHA-256 / 384 / 512 prefix lengths are all 19
**Verified by:** `packages/crypto/crypto-rsa/src/encoding/emsa-pkcs1-v1_5.test.ts::"SHA-256 DigestInfo prefix matches RFC 8017 §9.2 note 1 byte sequence"` AND `packages/crypto/crypto-rsa/src/encoding/emsa-pkcs1-v1_5.test.ts::"every DigestInfo prefix has the documented length"`
---
### Requirement: MGF1 mask generation function
The package SHALL export `mgf1(seed, maskLen, hash): Uint8Array` per RFC 8017 §B.2.1. The function SHALL throw `CryptoRsaError` with code `CRYPTO_RSA.MGF_FAILURE` when `maskLen > 2^32 * hLen` (RFC 8017 §B.2.1 step 1).
#### Scenario: MGF1-SHA-256 output matches Appendix C reference
- **WHEN** `mgf1(appendixCSeed, 256, "SHA-256")` is called with the RFC 8017 Appendix C input seed
- **THEN** the output equals the Appendix C reference MGF1 output byte-exactly
#### Scenario: rejects oversize maskLen
- **WHEN** `mgf1(seed, 2 ** 33, "SHA-1")` is called (where `hLen = 20` for SHA-1, so `maskLen > 2^32 * 20` evaluates true is the spec gate — implementation uses the conservative check `maskLen > 2 ** 32 * hLen`)
- **THEN** the call throws `CryptoRsaError` with code `"CRYPTO_RSA.MGF_FAILURE"`
**Verified by:** `packages/crypto/crypto-rsa/src/encoding/mgf1.test.ts::"MGF1-SHA-256 output matches Appendix C reference"` AND `packages/crypto/crypto-rsa/src/encoding/mgf1.test.ts::"rejects oversize maskLen as MGF_FAILURE"`
---
### Requirement: EMSA-PSS encode + verify
The package SHALL export `emsaPssEncode(messageDigest, emBits, salt, hash, mgfHash): Uint8Array` per RFC 8017 §9.1.1 and `emsaPssVerify(messageDigest, encodedMessage, emBits, saltLength, hash, mgfHash): boolean` per §9.1.2. The two functions SHALL be inverse: any value produced by `emsaPssEncode` SHALL verify under the same `(hash, mgfHash, saltLength)` triple.
#### Scenario: encode → verify round-trips on Appendix C input
- **WHEN** `emsaPssEncode(digest, 2047, salt, "SHA-256", "SHA-256")` is called with the Appendix C input and the resulting `EM` is fed to `emsaPssVerify(digest, EM, 2047, 32, "SHA-256", "SHA-256")`
- **THEN** the verify returns `true`
#### Scenario: verify rejects bit-flipped encoded message
- **WHEN** the same verify call is repeated against `EM` with the first byte XOR'd with `0x01`
- **THEN** the verify returns `false`
**Verified by:** `packages/crypto/crypto-rsa/src/encoding/emsa-pss-encode.test.ts::"encode → verify round-trip on Appendix C input"` AND `packages/crypto/crypto-rsa/src/encoding/emsa-pss-verify.test.ts::"rejects bit-flipped encoded message"`
---
### Requirement: RSAES-OAEP encrypt + decrypt
The package SHALL export `rsaEncryptOaep(key, plaintext, options: { hash; mgfHash?; label?; rng? }): Uint8Array` per RFC 8017 §7.1.1 and `rsaDecryptOaep(privateKey, ciphertext, options: { hash; mgfHash?; label? }): Uint8Array` per §7.1.2. `mgfHash` SHALL default to `hash`. `label` SHALL default to the empty byte string. Decrypt SHALL throw `CRYPTO_RSA.DECRYPT_FAILED` indistinguishably on every failure class (lHash mismatch, padding-byte mismatch, ciphertext value error) per RFC 8017 §7.1.2 implementation note (Manger 2001 oracle defence).
#### Scenario: encrypt → decrypt round-trips with default label
- **WHEN** `rsaDecryptOaep(privateKey, rsaEncryptOaep(publicKey, msg, { hash: "SHA-256" }), { hash: "SHA-256" })` is called for `msg = new Uint8Array([1,2,3,4,5])`
- **THEN** the result equals `msg` byte-exactly
#### Scenario: round-trips with non-empty label
- **WHEN** encrypt is called with `label: new TextEncoder().encode("session-1")` and decrypt is called with the same `label`
- **THEN** the result equals the original plaintext
#### Scenario: decrypt with mismatched label throws indistinguishable DECRYPT_FAILED
- **WHEN** decrypt is called against the ciphertext from a `"session-1"` encrypt but with `label: encoder.encode("session-2")`
- **THEN** the call throws `CryptoRsaError` with code `"CRYPTO_RSA.DECRYPT_FAILED"` AND the error message does NOT reveal which failure class triggered it
**Verified by:** `packages/crypto/crypto-rsa/src/encrypt/oaep.test.ts::"encrypt → decrypt round-trips with default label"` AND `packages/crypto/crypto-rsa/src/decrypt/oaep.test.ts::"round-trips with non-empty label"` AND `packages/crypto/crypto-rsa/src/decrypt/oaep.test.ts::"label mismatch throws indistinguishable DECRYPT_FAILED"`
---
### Requirement: RSAES-PKCS1-v1\_5 encrypt + decrypt
The package SHALL export `rsaEncryptPkcs1v15(key, plaintext, { rng? }): Uint8Array` per RFC 8017 §7.2.1 and `rsaDecryptPkcs1v15(privateKey, ciphertext): Uint8Array` per §7.2.2. Plaintext length SHALL satisfy `|plaintext| ≤ byteLength(n) - 11`. Decrypt SHALL throw `CRYPTO_RSA.DECRYPT_FAILED` indistinguishably on every failure class (bad first octet, bad block type, missing zero separator, value error) per the Bleichenbacher 1998 oracle-attack defence.
#### Scenario: encrypt → decrypt round-trips on short message
- **WHEN** `rsaDecryptPkcs1v15(privateKey, rsaEncryptPkcs1v15(publicKey, msg))` is called for a 16-byte `msg`
- **THEN** the result equals `msg`
#### Scenario: decrypt of randomly-mutilated ciphertext throws indistinguishable DECRYPT_FAILED
- **WHEN** decrypt is called on a ciphertext whose first byte has been overwritten with `0xff`
- **THEN** the call throws `CryptoRsaError` with code `"CRYPTO_RSA.DECRYPT_FAILED"` AND the error message does NOT reveal which failure class triggered it
**Verified by:** `packages/crypto/crypto-rsa/src/encrypt/pkcs1v15.test.ts::"encrypt → decrypt round-trips on short message"` AND `packages/crypto/crypto-rsa/src/decrypt/pkcs1v15.test.ts::"mutilated ciphertext throws indistinguishable DECRYPT_FAILED"`
---
### Requirement: RFC 8017 Appendix C test vectors replayed end-to-end
The package SHALL bundle RFC 8017 Appendix C test vectors under `src/__fixtures__/rfc8017-appendix-c/` and replay them through every signature and encryption path. Every Appendix C `(message, signature)` pair SHALL verify; every Appendix C `(plaintext, ciphertext)` pair SHALL decrypt to the documented plaintext.
#### Scenario: every Appendix C PKCS1v15 signature verifies
- **WHEN** the conformance test iterates `rfc8017AppendixC.pkcs1v15Vectors` and calls `rsaVerifyPkcs1v15` against each
- **THEN** every call returns `true`
#### Scenario: every Appendix C OAEP ciphertext decrypts to the documented plaintext
- **WHEN** the conformance test iterates `rfc8017AppendixC.oaepVectors` and calls `rsaDecryptOaep` against each
- **THEN** every decryption result byte-equals the documented plaintext
**Verified by:** `packages/crypto/crypto-rsa/src/crypto-rsa.spec-conformance.test.ts::"every Appendix C PKCS1v15 signature verifies"` AND `packages/crypto/crypto-rsa/src/crypto-rsa.spec-conformance.test.ts::"every Appendix C OAEP ciphertext decrypts to documented plaintext"`
---
### Requirement: JWK thumbprint per RFC 7638
The package SHALL export `rsaJwkThumbprint(key, hash): Uint8Array` per RFC 7638 §3.2 emitting the JSON canonical form `{"e":"<base64url-e>","kty":"RSA","n":"<base64url-n>"}` (members in lexicographic order, no whitespace) and digesting via the requested hash. The result SHALL match the RFC 7638 §3.1 worked example when run against that example's RSA key.
#### Scenario: matches RFC 7638 §3.1 worked example
- **WHEN** `rsaJwkThumbprint(rfc7638Section31Key, "SHA-256")` is called
- **THEN** the result, base64url-encoded, equals `"NzbLsXh8uDCcd-6MNwXF4W_7noWXFZAfHkxZsRGC9Xs"`
#### Scenario: member ordering is e, kty, n
- **WHEN** the internal canonical JSON form is captured for any RSA key
- **THEN** the bytes start with the literal `{"e":` and contain `,"kty":"RSA",` followed by `"n":`
**Verified by:** `packages/crypto/crypto-rsa/src/jwk/thumbprint.test.ts::"matches RFC 7638 §3.1 worked example for SHA-256"` AND `packages/crypto/crypto-rsa/src/jwk/thumbprint.test.ts::"canonical JSON member order is e, kty, n"`
---
### Requirement: Error registry — every code thrown by ≥1 production path
The package SHALL define `defineCodes("CRYPTO_RSA", ["MODULUS_TOO_SHORT", "INVALID_KEY", "INVALID_SIGNATURE", "SIGNATURE_INVALID", "UNSUPPORTED_HASH", "UNSUPPORTED_FORMAT", "DECRYPT_FAILED", "MGF_FAILURE"])` and SHALL throw every code from at least one production path. The conformance test inventory pins each code to a concrete throw site.
#### Scenario: every documented code has a throwing call site
- **WHEN** the conformance test iterates the 8 declared codes
- **THEN** each code is produced by at least one call into the public surface (via the audit table in `errors/errors.test.ts`)
#### Scenario: SIGNATURE_INVALID is distinct from INVALID_SIGNATURE
- **WHEN** `rsaVerifyPkcs1v15` is called with a signature whose length does NOT match `byteLength(n)`
- **THEN** the call throws `CRYPTO_RSA.INVALID_SIGNATURE` (malformed-input class) — NOT `CRYPTO_RSA.SIGNATURE_INVALID` (which is reserved for verify-returned-false paths that callers opt into via a strict-mode wrapper)
**Verified by:** `packages/crypto/crypto-rsa/src/errors/errors.test.ts::"every CRYPTO_RSA code is thrown by ≥1 production path"` AND `packages/crypto/crypto-rsa/src/verify/pkcs1v15.test.ts::"length-mismatched signature throws INVALID_SIGNATURE not SIGNATURE_INVALID"`
---
### Requirement: Zod boundary schemas
The package SHALL define Zod schemas at every public surface (`RsaPublicKeySchema`, `RsaPrivateKeySchema`, `RsaParseOptionsSchema`, `RsaSerializeOptionsSchema`, `RsaSignOptionsSchema`, `RsaVerifyOptionsSchema`, `RsaPssOptionsSchema`, `RsaOaepOptionsSchema`, `HashAlgorithmSchema`). Schema failure SHALL map to the appropriate `CRYPTO_RSA.*` code (`INVALID_KEY` for key-shape failures, `UNSUPPORTED_HASH` for hash-enum failures, `UNSUPPORTED_FORMAT` for format-enum failures).
#### Scenario: malformed RsaPublicKey rejects with INVALID_KEY
- **WHEN** `RsaPublicKeySchema.parse({ n: "not a bigint", e: 65537n, bitLength: 2048 })` is invoked (via the boundary wrapper)
- **THEN** the wrapper throws `CryptoRsaError` with code `"CRYPTO_RSA.INVALID_KEY"`
#### Scenario: unknown hash rejects with UNSUPPORTED_HASH
- **WHEN** `RsaSignOptionsSchema.parse({ hash: "MD5" })` is invoked (via the boundary wrapper)
- **THEN** the wrapper throws `CryptoRsaError` with code `"CRYPTO_RSA.UNSUPPORTED_HASH"`
**Verified by:** `packages/crypto/crypto-rsa/src/schemas/schemas.test.ts::"malformed RsaPublicKey rejects with INVALID_KEY"` AND `packages/crypto/crypto-rsa/src/schemas/schemas.test.ts::"unknown hash rejects with UNSUPPORTED_HASH"`
---
### Requirement: OpenTelemetry surface — histograms + counters
The package SHALL emit OpenTelemetry instruments from production paths:
- `crypto_rsa.verify_duration_ms{scheme,hash}` histogram (`scheme ∈ {"pkcs1v15","pss"}`)
- `crypto_rsa.sign_duration_ms{scheme,hash}` histogram
- `crypto_rsa.encrypt_duration_ms{scheme,hash}` histogram (`scheme ∈ {"pkcs1v15","oaep"}`)
- `crypto_rsa.decrypt_duration_ms{scheme,hash}` histogram
- `crypto_rsa.outcome_total{operation,outcome}` counter (`outcome ∈ {"ok","invalid_signature","decrypt_failed","modulus_too_short"}`)
- `crypto_rsa.errors_total{code}` counter
Every instrument SHALL be observable via a real in-memory `MeterProvider` from `@opentelemetry/sdk-metrics` during a representative round-trip test.
#### Scenario: verify_duration_ms emitted on every verify call
- **WHEN** `rsaVerifyPkcs1v15` is called and the in-memory `MeterProvider` is flushed
- **THEN** the metric reader observes one `crypto_rsa.verify_duration_ms` data point with attributes `{scheme: "pkcs1v15", hash: "SHA-256"}`
#### Scenario: outcome_total increments with the right outcome label
- **WHEN** `rsaVerifyPkcs1v15` returns `false` on a bit-flipped signature
- **THEN** the metric reader observes a `crypto_rsa.outcome_total` increment with `{operation: "verify", outcome: "invalid_signature"}`
**Verified by:** `packages/crypto/crypto-rsa/src/observability/metrics.test.ts::"verify_duration_ms emitted on every verify call"` AND `packages/crypto/crypto-rsa/src/observability/metrics.test.ts::"outcome_total increments with invalid_signature on bit-flip"`
---
### Requirement: Generate RSA key pair
The package SHALL export `generateRsaKeyPair(options: { modulusBits?: number; publicExponent?: bigint; rng: Rng; primes?: 2 | 3 | 4 | 5; signal?: AbortSignal; profile?: "permissive" | "fips-186-5" }): Promise<RsaPrivateKey>`. Defaults: `modulusBits: 3072`, `publicExponent: 65537n`, `primes: 2`, `profile: "permissive"`. Probable primes SHALL be generated via Miller-Rabin with 64 witness rounds (exceeds NIST FIPS 186-5 Appendix B.3.1's 5-round floor at 3072-bit). The returned key SHALL pass sign-then-verify round-trip on a fresh random message. `modulusBits < 2048` SHALL reject with `CRYPTO_RSA.MODULUS_TOO_SHORT` (or `≥ 1024` accept under `allowLegacy: true`); `modulusBits > 16384` SHALL reject with `CRYPTO_RSA.MODULUS_TOO_LARGE` (or accept under `allowOversize: true`). Miller-Rabin exhaustion within the bounded attempt budget (default `10 * modulusBits`) SHALL throw `CRYPTO_RSA.KEYGEN_FAILED`.
#### Scenario: generates a 2048-bit key that signs and verifies
- **WHEN** `generateRsaKeyPair({ modulusBits: 2048, rng: csprng })` resolves and `rsaSignPkcs1v15(key, msg, { hash: "SHA-256" })` then `rsaVerifyPkcs1v15(key, msg, sig, { hash: "SHA-256" })` is called
- **THEN** the verify result is `true`
#### Scenario: rejects modulusBits below the 2048-bit default floor
- **WHEN** `generateRsaKeyPair({ modulusBits: 1024, rng: csprng })` is awaited
- **THEN** the promise rejects with `CryptoRsaError` code `"CRYPTO_RSA.MODULUS_TOO_SHORT"`
**Verified by:** `packages/crypto/crypto-rsa/src/keygen/generate.test.ts::"generates a 2048-bit key that signs and verifies"` AND `packages/crypto/crypto-rsa/src/keygen/generate.test.ts::"rejects modulusBits below the 2048-bit default floor"`
---
### Requirement: Generate RSA key pair — multi-prime support (RFC 8017 §3)
The package SHALL accept `primes ∈ {3, 4, 5}` and produce a multi-prime RSA key serialising to PKCS#1 `RSAPrivateKey` `version 1` with a non-empty `otherPrimeInfos` field per RFC 8017 Appendix A.1.2. The resulting key SHALL sign and verify under the CRT-aware private-key operation extended to multi-prime form.
#### Scenario: generates a 3-prime 3072-bit key with non-empty otherPrimeInfos
- **WHEN** `generateRsaKeyPair({ modulusBits: 3072, primes: 3, rng: csprng })` resolves and is serialised via `serializeRsaPrivateKey(key, { format: "pkcs1" })`
- **THEN** the resulting DER blob contains a `version === 1n` field AND a non-empty `otherPrimeInfos` SEQUENCE
#### Scenario: multi-prime key round-trips sign then verify
- **WHEN** the 3-prime key signs a 32-byte message under PKCS1v15 SHA-256 and the signature is fed to `rsaVerifyPkcs1v15`
- **THEN** the verify result is `true`
**Verified by:** `packages/crypto/crypto-rsa/src/keygen/multi-prime.test.ts::"generates a 3-prime 3072-bit key with non-empty otherPrimeInfos"` AND `packages/crypto/crypto-rsa/src/keygen/multi-prime.test.ts::"multi-prime key round-trips sign then verify"`
---
### Requirement: Generate RSA key pair — AbortSignal cancellation
`generateRsaKeyPair` SHALL thread its `AbortSignal` through the prime-search loop. Aborting the signal SHALL interrupt the loop within ≤ 100 ms wall-clock and reject the returned promise with `CryptoRsaError` code `CRYPTO_RSA.ABORTED`.
#### Scenario: abort interrupts the prime-search loop within 100 ms
- **WHEN** a `generateRsaKeyPair({ modulusBits: 4096, rng: csprng, signal })` call is made and `controller.abort()` fires 50 ms later
- **THEN** the promise rejects with `"CRYPTO_RSA.ABORTED"` within 100 ms of the abort signal
#### Scenario: pre-aborted signal rejects synchronously on first tick
- **WHEN** the input `AbortSignal` is already aborted at call time
- **THEN** the promise rejects with `"CRYPTO_RSA.ABORTED"` without ever entering the prime-search loop
**Verified by:** `packages/crypto/crypto-rsa/src/keygen/generate.test.ts::"abort interrupts the prime-search loop within 100 ms"` AND `packages/crypto/crypto-rsa/src/keygen/generate.test.ts::"pre-aborted signal rejects synchronously"`
---
### Requirement: Generate RSA key pair — deterministic mode
When the `rng` argument is a deterministic (fixed-seed) `Rng`, two `generateRsaKeyPair` calls with identical `(modulusBits, publicExponent, primes, rng-seed)` SHALL produce byte-identical keys. The deterministic mode SHALL be documented as test-only and audit-only — production callers MUST use a CSPRNG.
#### Scenario: identical seeded RNG produces identical keys
- **WHEN** `generateRsaKeyPair({ modulusBits: 2048, rng: seededRng("seed-a") })` is awaited twice in independent test runs
- **THEN** the two resulting `RsaPrivateKey` values are structurally identical (every `n, e, d, p, q, dp, dq, qInv` equal)
**Verified by:** `packages/crypto/crypto-rsa/src/keygen/generate.test.ts::"identical seeded RNG produces byte-identical keys"`
---
### Requirement: Blinded private-key operations (RFC 8017 §5.1.2 / Kocher 1996)
Every private-key operation in `rsaSign*` and `rsaDecrypt*` SHALL use blinding by default: pick random `r ∈ [1, n-1]` from the injected `Rng`, compute `m' = m * r^e mod n`, apply the private-key operation, then unblind via `s = s' * r^{-1} mod n`. The blinding RNG SHALL be `@suaveplan/types.Rng`, injectable, default `@suaveplan/crypto-utils.randomBytes`. Opt-out via `{ blinding: false }` SHALL emit a `crypto_rsa.blinding_disabled_total{operation}` counter increment.
#### Scenario: blinded sign produces same signature as unblinded sign
- **WHEN** the same Appendix C ciphertext is decrypted via the blinded path and via `{ blinding: false }`
- **THEN** the two output plaintexts are byte-identical
#### Scenario: opt-out emits blinding_disabled_total counter
- **WHEN** `rsaSignPkcs1v15(privateKey, msg, { hash: "SHA-256", blinding: false })` is called
- **THEN** the metric reader observes `crypto_rsa.blinding_disabled_total` incremented by 1 with `{operation: "sign"}`
#### Scenario: blinding overhead is under 5% on the 2048-bit benchmark
- **WHEN** the `blinded_vs_unblinded_2048` mitata benchmark runs both paths
- **THEN** the blinded-path mean is less than 1.05× the unblinded-path mean
**Verified by:** `packages/crypto/crypto-rsa/src/blinding/blinding.test.ts::"blinded path matches unblinded path on Appendix C ciphertexts"` AND `packages/crypto/crypto-rsa/src/blinding/blinding.test.ts::"opt-out emits blinding_disabled_total"` AND `packages/crypto/crypto-rsa/src/__benchmarks__/crypto-rsa.bench.ts::"blinding overhead < 5% on 2048-bit"`
---
### Requirement: `RsaCryptoEngine` adapter — noble + subtle + auto-select
The package SHALL define an `RsaCryptoEngine` interface (`sign`, `verify`, `encrypt`, `decrypt`, `modPow`) and ship two implementations: `nobleEngine` (default, over `@noble/curves/abstract/modular.pow`) and `subtleEngine` (over `globalThis.crypto.subtle`). `defaultEngine(operation, key)` SHALL return `subtleEngine` when `globalThis.crypto?.subtle` is present AND the operation is supported there, else fall back to `nobleEngine`. Callers MAY override via `{ engine: "noble" | "subtle" | RsaCryptoEngine }`. `subtle` selected against an environment where `globalThis.crypto.subtle` is missing SHALL throw `CRYPTO_RSA.ENGINE_UNAVAILABLE`.
#### Scenario: default engine selects subtle when available
- **WHEN** `defaultEngine("verify", appendixCPublicKey)` is called in an environment where `globalThis.crypto.subtle` is present
- **THEN** the returned engine identifies as `"subtle"`
#### Scenario: default engine falls back to noble when subtle is absent
- **WHEN** the same call is made with `globalThis.crypto.subtle` stubbed to `undefined`
- **THEN** the returned engine identifies as `"noble"`
#### Scenario: explicit subtle against unavailable subtle throws ENGINE_UNAVAILABLE
- **WHEN** `rsaVerifyPkcs1v15(key, msg, sig, { hash: "SHA-256", engine: "subtle" })` is called and `globalThis.crypto.subtle` is `undefined`
- **THEN** the call throws `CryptoRsaError` code `"CRYPTO_RSA.ENGINE_UNAVAILABLE"`
#### Scenario: engine_selected_total counter records per-call selection
- **WHEN** five verify calls run, three with the default engine resolving to noble and two resolving to subtle
- **THEN** the metric reader observes `crypto_rsa.engine_selected_total{engine: "noble"}` incremented by 3 AND `{engine: "subtle"}` incremented by 2
**Verified by:** `packages/crypto/crypto-rsa/src/engine/select.test.ts::"default engine selects subtle when available"` AND `packages/crypto/crypto-rsa/src/engine/select.test.ts::"default engine falls back to noble when subtle is absent"` AND `packages/crypto/crypto-rsa/src/engine/select.test.ts::"explicit subtle against unavailable subtle throws ENGINE_UNAVAILABLE"` AND `packages/crypto/crypto-rsa/src/observability/metrics.test.ts::"engine_selected_total records per-call selection"`
---
### Requirement: HSM / PKCS#11 bridge — `RsaKeyHandle` + provider registry
The package SHALL define an `RsaKeyHandle` interface (`algorithm`, `modulusBits`, `publicKey(): RsaPublicKey`, `sign(message, options): Promise<Uint8Array>`, `decrypt(ciphertext, options): Promise<Uint8Array>`) and a `registerKeyHandleProvider(provider: RsaKeyHandleProvider)` registry so HSM / PKCS#11 / KMS integrations plug in without forking. The package SHALL ship `SoftwareRsaKeyHandle` as a reference implementation wrapping an in-memory `RsaPrivateKey`.
#### Scenario: SoftwareRsaKeyHandle sign matches direct call
- **WHEN** a `SoftwareRsaKeyHandle` is constructed around an `RsaPrivateKey` and its `sign(message, { hash: "SHA-256" })` is called
- **THEN** the returned signature is byte-identical to `rsaSignPkcs1v15(privateKey, message, { hash: "SHA-256" })`
#### Scenario: registered provider resolves to a handle on call site
- **WHEN** a custom `RsaKeyHandleProvider` is registered and `resolveKeyHandle(jwkWithKid)` is called against a JWK carrying the `kid` the provider knows
- **THEN** the returned object satisfies the `RsaKeyHandle` interface
**Verified by:** `packages/crypto/crypto-rsa/src/handle/software.test.ts::"SoftwareRsaKeyHandle sign matches direct call"` AND `packages/crypto/crypto-rsa/src/handle/registry.test.ts::"registered provider resolves to a handle"`
---
### Requirement: PEM framing (RFC 7468)
The package SHALL export `parseRsaPublicKeyPem(text): RsaPublicKey`, `parseRsaPrivateKeyPem(text): RsaPrivateKey`, `serializeRsaPublicKeyPem(key, options): string`, and `serializeRsaPrivateKeyPem(key, options): string`. Recognised armor labels: `-----BEGIN PUBLIC KEY-----` (SPKI), `-----BEGIN RSA PUBLIC KEY-----` (PKCS#1 public), `-----BEGIN PRIVATE KEY-----` (PKCS#8), `-----BEGIN RSA PRIVATE KEY-----` (PKCS#1 private). Serialise SHALL emit 64-char-line base64 bodies per RFC 7468 §3. Malformed armor SHALL throw `CRYPTO_RSA.PEM_MALFORMED`.
#### Scenario: PEM SPKI round-trips byte-identically for canonical input
- **WHEN** a canonical RFC 8017 Appendix C public key is serialised to PEM SPKI form and re-parsed via `parseRsaPublicKeyPem`
- **THEN** the resulting `RsaPublicKey` is structurally equal to the original AND the serialised PEM text contains 64-char-line base64 bodies
#### Scenario: PEM with mismatched BEGIN / END armor rejects
- **WHEN** `parseRsaPublicKeyPem` is called against text whose BEGIN label is `RSA PUBLIC KEY` but END label is `PUBLIC KEY`
- **THEN** the call throws `CryptoRsaError` code `"CRYPTO_RSA.PEM_MALFORMED"`
#### Scenario: PEM with unknown armor label rejects
- **WHEN** `parseRsaPublicKeyPem` is called against text armored `-----BEGIN EC PUBLIC KEY-----`
- **THEN** the call throws `CryptoRsaError` code `"CRYPTO_RSA.PEM_MALFORMED"`
**Verified by:** `packages/crypto/crypto-rsa/src/pem/parse-public.test.ts::"PEM SPKI round-trips byte-identically"` AND `packages/crypto/crypto-rsa/src/pem/parse-public.test.ts::"mismatched BEGIN/END armor rejects with PEM_MALFORMED"` AND `packages/crypto/crypto-rsa/src/pem/parse-public.test.ts::"unknown armor label rejects with PEM_MALFORMED"`
---
### Requirement: SPKI fingerprint
The package SHALL export `computeSpkiFingerprint(publicKey, options?: { hash?: "SHA-256" | "SHA-1" }): Uint8Array` (default SHA-256) and `formatFingerprint(bytes, format: "hex" | "base64" | "base64url" | "colon-hex"): string`. The fingerprint SHALL be computed as `hash(spkiDerBytes(publicKey))`. Failure to derive SPKI bytes SHALL throw `CRYPTO_RSA.FINGERPRINT_FAILED`. Each call SHALL increment `crypto_rsa.fingerprint_total{format}` counter where `format` is the chosen output format (or `"raw"` when only `computeSpkiFingerprint` is called without `formatFingerprint`).
#### Scenario: SHA-256 fingerprint matches openssl reference for Appendix C key
- **WHEN** `computeSpkiFingerprint(appendixCPublicKey)` is formatted via `formatFingerprint(bytes, "hex")`
- **THEN** the returned hex string matches the openssl-computed `openssl pkey -pubout -outform DER | openssl dgst -sha256` value for the same key
#### Scenario: colon-hex format emits AB:CD: style output
- **WHEN** `formatFingerprint(bytes, "colon-hex")` is called against a 32-byte SHA-256 digest
- **THEN** the returned string contains 32 hex byte pairs separated by `:` characters
**Verified by:** `packages/crypto/crypto-rsa/src/fingerprint/spki.test.ts::"SHA-256 fingerprint matches openssl reference"` AND `packages/crypto/crypto-rsa/src/fingerprint/format.test.ts::"colon-hex format emits AB:CD: style"`
---
### Requirement: FIPS 186-5 compliance profile
The package SHALL accept a `{ profile: "permissive" | "fips-186-5" }` option on every public surface and on `generateRsaKeyPair`. Under `profile: "fips-186-5"` the package SHALL reject (with `CryptoRsaError` code `CRYPTO_RSA.PROFILE_VIOLATION` carrying `rule` in the context):
- `modulusBits ∉ {2048, 3072, 4096}` (FIPS 186-5 §5.1)
- `publicExponent ∉ [2^16+1, 2^256-1]` or even (FIPS 186-5 §A.1.1)
- PSS `saltLength` other than the hash output length (FIPS 186-5 §5.4)
- `hash === "SHA-1"` on sign or verify (FIPS 186-5 SHA-1 deprecation)
Permissive mode (default) keeps the broader RFC 8017 surface. Every violation SHALL also increment `crypto_rsa.profile_violation_total{rule}`.
#### Scenario: fips-186-5 rejects 1024-bit modulus on keygen
- **WHEN** `generateRsaKeyPair({ modulusBits: 1024, rng: csprng, profile: "fips-186-5" })` is awaited
- **THEN** the promise rejects with `CryptoRsaError` code `"CRYPTO_RSA.PROFILE_VIOLATION"` AND `error.context.rule === "modulus_bits"`
#### Scenario: fips-186-5 rejects SHA-1 in rsaSignPkcs1v15
- **WHEN** `rsaSignPkcs1v15(privateKey, msg, { hash: "SHA-1", profile: "fips-186-5" })` is called
- **THEN** the call throws `"CRYPTO_RSA.PROFILE_VIOLATION"` with `rule: "sha1_banned"`
#### Scenario: fips-186-5 rejects e=3
- **WHEN** `generateRsaKeyPair({ modulusBits: 3072, publicExponent: 3n, rng: csprng, profile: "fips-186-5" })` is awaited
- **THEN** the promise rejects with `"CRYPTO_RSA.PROFILE_VIOLATION"` with `rule: "public_exponent"`
#### Scenario: permissive (default) accepts everything fips rejects
- **WHEN** the same three calls above are repeated with `profile: "permissive"` (or omitted)
- **THEN** all three calls succeed
**Verified by:** `packages/crypto/crypto-rsa/src/profile/profile.test.ts::"fips-186-5 rejects 1024-bit modulus on keygen"` AND `packages/crypto/crypto-rsa/src/profile/profile.test.ts::"fips-186-5 rejects SHA-1 in rsaSignPkcs1v15"` AND `packages/crypto/crypto-rsa/src/profile/profile.test.ts::"fips-186-5 rejects e=3"` AND `packages/crypto/crypto-rsa/src/profile/profile.test.ts::"permissive accepts everything fips rejects"`
---
### Requirement: JWK `key_ops` / `use` / `alg` policy enforcement
The package SHALL enforce JWK members `key_ops` (RFC 7517 §4.3), `use` (RFC 7517 §4.2), and `alg` (RFC 7517 §4.4) when present on a parsed JWK. An operation not enumerated in the JWK's `key_ops`, or inconsistent with `use`, or inconsistent with `alg`, SHALL throw `CryptoRsaError` code `CRYPTO_RSA.KEY_OP_NOT_PERMITTED`. The operation map is `rsaSign* → "sign"`, `rsaVerify* → "verify"`, `rsaEncrypt* → "encrypt"` / `"wrapKey"`, `rsaDecrypt* → "decrypt"` / `"unwrapKey"`.
#### Scenario: key_ops=["sign"] key rejects verify call
- **WHEN** a JWK with `key_ops: ["sign"]` is passed to `rsaVerifyPkcs1v15`
- **THEN** the call throws `CryptoRsaError` code `"CRYPTO_RSA.KEY_OP_NOT_PERMITTED"`
#### Scenario: use="sig" key rejects encrypt call
- **WHEN** a JWK with `use: "sig"` is passed to `rsaEncryptOaep`
- **THEN** the call throws `"CRYPTO_RSA.KEY_OP_NOT_PERMITTED"`
#### Scenario: alg="RS256" key rejects rsaVerifyPss call
- **WHEN** a JWK with `alg: "RS256"` is passed to `rsaVerifyPss`
- **THEN** the call throws `"CRYPTO_RSA.KEY_OP_NOT_PERMITTED"`
**Verified by:** `packages/crypto/crypto-rsa/src/keys/jwk-policy.test.ts::"key_ops=[sign] key rejects verify call"` AND `packages/crypto/crypto-rsa/src/keys/jwk-policy.test.ts::"use=sig key rejects encrypt call"` AND `packages/crypto/crypto-rsa/src/keys/jwk-policy.test.ts::"alg=RS256 key rejects rsaVerifyPss"`
---
### Requirement: Modulus-size DoS guard — reject oversize moduli
The package SHALL export `enforceMaxBitLength(key, options?: { allowOversize?: boolean }): void` and SHALL call it on every public-API entry point. The default ceiling is 16384 bits; `bitLength > 16384` SHALL reject with `CRYPTO_RSA.MODULUS_TOO_LARGE`. Callers may opt in via `allowOversize: true` to permit arbitrary-size moduli (documented DoS risk).
#### Scenario: rejects a 32768-bit modulus under defaults
- **WHEN** `rsaVerifyPkcs1v15` is called with a synthetic 32768-bit key
- **THEN** the call throws `CryptoRsaError` code `"CRYPTO_RSA.MODULUS_TOO_LARGE"`
#### Scenario: accepts a 32768-bit modulus under allowOversize
- **WHEN** the same call is repeated with `{ allowOversize: true }`
- **THEN** the call succeeds
**Verified by:** `packages/crypto/crypto-rsa/src/policy/modulus-max.test.ts::"rejects 32768-bit modulus under defaults"` AND `packages/crypto/crypto-rsa/src/policy/modulus-max.test.ts::"accepts 32768-bit modulus under allowOversize"`
---
### Requirement: Bleichenbacher-resistant `rsaDecryptPkcs1v15`
`rsaDecryptPkcs1v15` SHALL implement sentinel-driven uniform control flow defending against Bleichenbacher 1998: every failure class (bad first octet, bad block type, missing zero separator, value error) SHALL throw `CRYPTO_RSA.DECRYPT_FAILED` with an indistinguishable error message AND SHALL consume comparable wall-clock time. The implementation pattern materialises a synthetic max-length plaintext on every call, branches selection between real and synthetic via constant-time mask, and performs a single constant-time final compare. No early-exit on padding-class detection.
#### Scenario: every failure class throws the same error code with the same message
- **WHEN** `rsaDecryptPkcs1v15` is called against four distinct mutilated ciphertexts (first byte non-zero, second byte non-`0x02`, no zero separator within k-11 bytes, value `c >= n`)
- **THEN** all four calls throw `CryptoRsaError` code `"CRYPTO_RSA.DECRYPT_FAILED"` AND `error.message` is byte-identical across the four
#### Scenario: Wycheproof rsa_pkcs1_decrypt vectors replay correctly
- **WHEN** the conformance test iterates the bundled Wycheproof `rsa_pkcs1_decrypt_*.json` vectors
- **THEN** every `valid` case round-trips and every `invalid` case throws `"CRYPTO_RSA.DECRYPT_FAILED"`
**Verified by:** `packages/crypto/crypto-rsa/src/decrypt/pkcs1v15.test.ts::"every failure class throws same code with same message"` AND `packages/crypto/crypto-rsa/src/wycheproof/replay.test.ts::"rsa_pkcs1_decrypt vectors replay correctly"`
---
### Requirement: Manger-resistant `rsaDecryptOaep`
`rsaDecryptOaep` SHALL implement sentinel-driven uniform control flow defending against Manger 2001: every failure class (leading byte non-zero, lHash mismatch, PS-byte malformed, value error) SHALL throw `CRYPTO_RSA.DECRYPT_FAILED` with an indistinguishable error message. Wycheproof `rsa_oaep_*.json` test corpus SHALL replay against this path.
#### Scenario: every failure class throws the same error code with the same message
- **WHEN** `rsaDecryptOaep` is called against four distinct mutilated ciphertexts spanning every Manger failure class
- **THEN** all four calls throw `"CRYPTO_RSA.DECRYPT_FAILED"` AND `error.message` is byte-identical across the four
#### Scenario: Wycheproof rsa_oaep_*.json vectors replay correctly
- **WHEN** the conformance test iterates the bundled Wycheproof `rsa_oaep_*.json` corpus
- **THEN** every `valid` case round-trips, every `invalid` case throws `"CRYPTO_RSA.DECRYPT_FAILED"`, and every `acceptable` case is documented with the expected outcome
**Verified by:** `packages/crypto/crypto-rsa/src/decrypt/oaep.test.ts::"every failure class throws same code with same message"` AND `packages/crypto/crypto-rsa/src/wycheproof/replay.test.ts::"rsa_oaep vectors replay correctly"`
---
### Requirement: Constant-time PKCS1v15 verify — full encoding-block compare
`rsaVerifyPkcs1v15` SHALL validate the entire decoded encoding block (`0x00 0x01 PS 0x00 T`) in constant time, including byte-by-byte comparison of the `PS` (padding-string) section AND the `T` (DigestInfo prefix + digest) section, defending against the Bleichenbacher 2006 signature-forgery variant. Native byte-by-byte comparisons SHALL NOT appear on any path of the verify decoder; every compare SHALL go through `constantTimeEqual` from `@suaveplan/crypto-utils`.
#### Scenario: Wycheproof rsa_signature_*.json vectors replay correctly
- **WHEN** the conformance test iterates the bundled Wycheproof `rsa_signature_*.json` corpus
- **THEN** every `valid` case verifies, every `invalid` case rejects (returns `false` or throws `INVALID_SIGNATURE`), and every `acceptable` case is documented
#### Scenario: forged signature with valid DigestInfo but bogus PS rejects
- **WHEN** a signature carrying the correct DigestInfo prefix + digest but a non-`0xff` `PS` byte at offset 5 is verified
- **THEN** the verify returns `false`
**Verified by:** `packages/crypto/crypto-rsa/src/wycheproof/replay.test.ts::"rsa_signature vectors replay correctly"` AND `packages/crypto/crypto-rsa/src/verify/pkcs1v15.test.ts::"forged signature with bogus PS byte rejects"`
---
### Requirement: NIST CAVP RSA test vectors replayed
The package SHALL bundle NIST CAVP RSA test vectors under `src/__fixtures__/cavp/` (sourced from <https://csrc.nist.gov/projects/cryptographic-algorithm-validation-program/digital-signatures>) and replay sign / verify / encrypt vectors through the corresponding production paths.
#### Scenario: every CAVP RSA verify vector verifies
- **WHEN** the conformance test iterates the bundled CAVP RSA verify vectors
- **THEN** every documented-valid `(message, signature)` pair returns `true` and every documented-invalid pair returns `false`
#### Scenario: every CAVP RSA sign vector produces the documented signature
- **WHEN** the conformance test iterates the bundled CAVP RSA sign vectors with the documented `(privateKey, message, hash)` inputs
- **THEN** the produced signature byte-equals the documented reference signature
**Verified by:** `packages/crypto/crypto-rsa/src/cavp/replay.test.ts::"every CAVP RSA verify vector verifies"` AND `packages/crypto/crypto-rsa/src/cavp/replay.test.ts::"every CAVP RSA sign vector produces documented signature"`
---
### Requirement: Verifiable randomness — caller-supplied PSS salt
`rsaSignPss` SHALL accept a `{ salt: Uint8Array }` option that, when supplied, bypasses the internal RNG and uses the caller's bytes verbatim. The resulting signature SHALL match a sign call using a fixed-seed RNG that materialises the same salt bytes. Pure functions of `rng` (without explicit `salt`) SHALL produce byte-identical outputs across runs when the seeded RNG state is identical.
#### Scenario: caller-supplied salt produces deterministic signature
- **WHEN** `rsaSignPss(privateKey, msg, { hash: "SHA-256", salt: fixedSalt })` is called twice with identical inputs
- **THEN** the two output signatures are byte-identical
#### Scenario: caller-supplied salt matches seeded-RNG signature
- **WHEN** `rsaSignPss(privateKey, msg, { hash: "SHA-256", rng: seededRngThatYields(fixedSalt) })` is compared to `rsaSignPss(privateKey, msg, { hash: "SHA-256", salt: fixedSalt })`
- **THEN** the two signatures are byte-identical
**Verified by:** `packages/crypto/crypto-rsa/src/sign/pss.test.ts::"caller-supplied salt produces deterministic signature"` AND `packages/crypto/crypto-rsa/src/sign/pss.test.ts::"caller-supplied salt matches seeded-RNG signature"`
---
### Requirement: Source mapping — `BigInt **` banned across full feature surface
Beyond the existing `modPow` requirement, the entire `src/` tree (including `keygen/`, `blinding/`, `crt/`, `engine/`, `handle/`, `profile/`) SHALL NOT contain any native `BigInt ** BigInt % N` modpow pattern. The repo-grep gate audits every new feature directory in addition to the math / sign / verify / encrypt / decrypt subtree.
#### Scenario: keygen / blinding / crt source contains no native modpow
- **WHEN** every file under `packages/crypto/crypto-rsa/src/{keygen,blinding,math,engine}` is scanned for the literal `\*\* .* %` pattern
- **THEN** zero matches are found
**Verified by:** `packages/crypto/crypto-rsa/src/crypto-rsa.spec-conformance.test.ts::"new feature surfaces contain no native bigint ** modpow"`
---
### Requirement: Error registry — every new code thrown by ≥1 production path
Beyond the original 8-code registry, the 9 new codes (`MODULUS_TOO_LARGE`, `KEYGEN_FAILED`, `PRIME_INVALID`, `KEY_OP_NOT_PERMITTED`, `PROFILE_VIOLATION`, `ENGINE_UNAVAILABLE`, `PEM_MALFORMED`, `FINGERPRINT_FAILED`, `ABORTED`) SHALL each be thrown by at least one production call site. The conformance audit table SHALL pin each new code to its throwing site.
#### Scenario: every new CRYPTO_RSA code has a throwing call site
- **WHEN** the conformance audit table iterates the 9 new declared codes
- **THEN** each code resolves to at least one named throw site in `src/`
#### Scenario: ABORTED is distinct from KEYGEN_FAILED
- **WHEN** `generateRsaKeyPair` is called with a pre-aborted signal
- **THEN** the rejection code is `"CRYPTO_RSA.ABORTED"`, NOT `"CRYPTO_RSA.KEYGEN_FAILED"`
**Verified by:** `packages/crypto/crypto-rsa/src/errors/errors.test.ts::"every new CRYPTO_RSA code has a throwing call site"` AND `packages/crypto/crypto-rsa/src/keygen/generate.test.ts::"pre-aborted signal rejects synchronously"`
<!-- markdownlint-enable MD024 -->

View File

@@ -0,0 +1,284 @@
# Tasks — `@suaveplan/crypto-rsa` (RSA primitives — RFC 8017 + RFC 5208 + RFC 7518)
> Every checkbox MUST reflect work actually done in this change. Do not retroactively tick boxes for work that was already in main when this change opened. Do not tick a box for a test that doesn't exist or doesn't pin the requirement it claims to verify.
## 1. Spec authoring
- [x] 1.1 Read `../suaveplan/enterprise/crypto-rsa.md` end-to-end
- [x] 1.2 Read RFC 8017 §4 (data conversion primitives), §5 (cryptographic primitives, incl. §5.1.2 CRT), §7 (RSAES encryption schemes), §8 (RSASSA signature schemes), §9 (encoding methods EMSA-PSS + EMSA-PKCS1-v1\_5), §B.2.1 (MGF1), Appendix A (ASN.1 modules), Appendix C (test vectors)
- [x] 1.3 Read RFC 5208 §5 (`PrivateKeyInfo`) and RFC 5958 §2 (`OneAsymmetricKey` successor)
- [x] 1.4 Read RFC 5280 §1.2 + §4.1 + Appendix A (RSA OIDs `1.2.840.113549.1.1.{1,5,10,11,12,13}` + `SubjectPublicKeyInfo`)
- [x] 1.5 Read RFC 7517 §4.2 + §4.3 + §4.4 + §6.3, RFC 7518 §3.3 + §3.5 + §6.3, RFC 7638 §3.2 (JWK / JWA / Thumbprint)
- [x] 1.6 Read RFC 7468 §3, §5, §10, §11 (PEM textual encoding for `PUBLIC KEY`, `RSA PRIVATE KEY`, `PRIVATE KEY`)
- [x] 1.7 Read NIST FIPS 186-5 §5 + Appendix B.3.1 + Appendix A.1 (RSA DSA, probable-prime generation, key-pair parameters)
- [x] 1.8 Read NIST SP 800-56B Rev. 2 §6.3 (key-pair generation guidance)
- [x] 1.9 Read Google Wycheproof `rsa_signature_*.json`, `rsa_signature_pss_*.json`, `rsa_oaep_*.json` schemas + a sample of the `acceptable` test cases
- [x] 1.10 Read NIST CAVP RSA test-vector format (RSAVS / RSA2VS)
- [x] 1.11 Read RFC 5990 (RSA-KEM) — confirm out-of-scope rationale
- [x] 1.12 Read W3C Web Cryptography API §RSA-OAEP / RSASSA-PKCS1-v1_5 / RSA-PSS sections for subtle engine adapter
- [x] 1.13 Read Bleichenbacher 1998 (CRYPTO '98), Manger 2001 (CRYPTO 2001), Bleichenbacher 2006 (signature forgery), Kocher 1996 (timing attacks) — confirm hardening approach
- [x] 1.14 Read `openspec/AGENTS.md` Sections 13, 14, 14a
- [x] 1.15 Read `CLAUDE.md` (root + crypto family entries — `crypto-cipher`, `crypto-curve`, `crypto-hash`, `crypto-utils`, `crypto-vault`)
- [x] 1.16 Draft `proposal.md` from `openspec/templates/proposal.md`
- [x] 1.17 Fill in `## Source mapping` table — every requirement cites an RFC / FIPS / NIST / Wycheproof / `enterprise/crypto-rsa.md` line range
- [x] 1.18 Draft `specs/crypto-rsa/spec.md` from `openspec/templates/spec-delta.md`
- [x] 1.19 Every requirement in the spec delta carries a `**Verified by:**` line naming concrete `it(...)` titles
## 2. Package scaffold
- [x] 2.1 `packages/crypto/crypto-rsa/` directory + `src/{asn1,keys,keygen,policy,math,encoding,sign,verify,encrypt,decrypt,jwk,fingerprint,pem,blinding,engine,handle,profile,errors,schemas,observability,__benchmarks__,__fixtures__}/` skeleton
- [x] 2.2 `package.json` — `@suaveplan/crypto-rsa@0.0.0`, `type: "module"`, `sideEffects: false`, deps pinned via workspace + catalog refs
- [x] 2.3 `tsconfig.lib.json` extends `@suaveplan/typescript-config/library`
- [x] 2.4 `vite.config.ts` + `vitest.config.ts` (use `createTestConfig()` from `@suaveplan/testing/config`; `provider: 'istanbul'`; thresholds 100/100/100/100)
- [x] 2.5 `README.md` with package description, install command, sign + verify quick-start, encrypt + decrypt quick-start, keygen quick-start, FIPS profile quick-start, Modules table linking every co-located `.md`
- [x] 2.6 Subpath exports map covers `.`, `./keys`, `./keygen`, `./sign`, `./verify`, `./encrypt`, `./decrypt`, `./encoding`, `./jwk`, `./fingerprint`, `./pem`, `./blinding`, `./engine`, `./handle`, `./profile`, `./policy`, `./schemas`, `./observability`, `./errors`
## 3. Implementation — ASN.1 / DER
- [x] 3.1 `src/asn1/reader.ts` — `readInteger`, `readSequence`, `readObjectIdentifier`, `readBitString`, `readOctetString`, `readNull`, `readContextSpecific`
- [x] 3.2 `src/asn1/writer.ts` — matching DER writer
- [x] 3.3 Sibling `.md` for `reader.ts` + `writer.ts`
## 4. Implementation — canonical key shapes
- [x] 4.1 `src/keys/keys.types.ts` — `RsaPublicKey { n, e, bitLength }`, `RsaPrivateKey { n, e, d, p, q, dp, dq, qInv, bitLength }`
- [x] 4.2 `src/schemas/schemas.ts` — `RsaPublicKeySchema`, `RsaPrivateKeySchema`, `RsaParseOptionsSchema`, `RsaSerializeOptionsSchema`, `RsaSignOptionsSchema`, `RsaVerifyOptionsSchema`, `RsaPssOptionsSchema`, `RsaOaepOptionsSchema`, `HashAlgorithmSchema`
## 5. Implementation — key parsing
- [x] 5.1 `src/keys/parse-pkcs1-public.ts` — RFC 8017 Appendix A.1.1
- [x] 5.2 `src/keys/parse-pkcs1-private.ts` — RFC 8017 Appendix A.1.2
- [x] 5.3 `src/keys/parse-spki.ts` — RFC 5280 + RFC 8017 Appendix A.1.1; rejects non-`rsaEncryption` OID
- [x] 5.4 `src/keys/parse-pkcs8.ts` — RFC 5208 / RFC 5958
- [x] 5.5 `src/keys/parse-jwk.ts` — RFC 7517 §6.3; rejects `kty !== "RSA"`
- [x] 5.6 `src/keys/parse.ts` — façade dispatching on `{ format }`
## 6. Implementation — key serialisation
- [x] 6.1 `src/keys/serialize-pkcs1-public.ts`
- [x] 6.2 `src/keys/serialize-pkcs1-private.ts`
- [x] 6.3 `src/keys/serialize-spki.ts`
- [x] 6.4 `src/keys/serialize-pkcs8.ts`
- [x] 6.5 `src/keys/serialize-jwk.ts` — base64url per RFC 7515 §2 (no padding)
- [x] 6.6 `src/keys/serialize.ts` — façade
- [x] 6.7 Round-trip invariant: parse → serialise → parse equals original
## 7. Implementation — modulus-length policy
- [x] 7.1 `src/policy/modulus.ts` — `enforceMinBitLength(key, { allowLegacy? })`; default floor 2048; `allowLegacy: true` permits 1024; below 1024 always rejects with `CRYPTO_RSA.MODULUS_TOO_SHORT`
## 8. Implementation — modular arithmetic
- [x] 8.1 `src/math/modpow.ts` — wraps `@noble/curves/abstract/modular.pow`; NEVER `BigInt ** BigInt % N`
- [x] 8.2 `src/math/i2osp.ts` — Integer-to-Octet-String per RFC 8017 §4.1
- [x] 8.3 `src/math/os2ip.ts` — Octet-String-to-Integer per RFC 8017 §4.2
- [x] 8.4 `src/math/crt.ts` — RFC 8017 §5.1.2 second-form (`m = m2 + h*q`)
## 9. Implementation — MGF1 + encoding methods
- [x] 9.1 `src/encoding/mgf1.ts` — RFC 8017 §B.2.1; throws `MGF_FAILURE` on `maskLen > 2^32 * hLen`
- [x] 9.2 `src/encoding/emsa-pkcs1-v1_5.ts` — RFC 8017 §9.2 with the four DigestInfo prefix constants (SHA-1 / 256 / 384 / 512) literal-byte verified
- [x] 9.3 `src/encoding/emsa-pss-encode.ts` — RFC 8017 §9.1.1
- [x] 9.4 `src/encoding/emsa-pss-verify.ts` — RFC 8017 §9.1.2; constant-time compare via `@suaveplan/crypto-utils`
## 10. Implementation — signatures
- [x] 10.1 `src/sign/pkcs1v15.ts` — RFC 8017 §8.2.1 (CRT-aware)
- [x] 10.2 `src/verify/pkcs1v15.ts` — RFC 8017 §8.2.2; constant-time encoded-message compare
- [x] 10.3 `src/sign/pss.ts` — RFC 8017 §8.1.1 with injectable `Rng` from `@suaveplan/types`
- [x] 10.4 `src/verify/pss.ts` — RFC 8017 §8.1.2
## 11. Implementation — encryption
- [x] 11.1 `src/encrypt/pkcs1v15.ts` — RFC 8017 §7.2.1
- [x] 11.2 `src/decrypt/pkcs1v15.ts` — RFC 8017 §7.2.2; combined `CRYPTO_RSA.DECRYPT_FAILED` (Bleichenbacher defence)
- [x] 11.3 `src/encrypt/oaep.ts` — RFC 8017 §7.1.1
- [x] 11.4 `src/decrypt/oaep.ts` — RFC 8017 §7.1.2; combined error
## 12. Implementation — JWK thumbprint
- [x] 12.1 `src/jwk/thumbprint.ts` — RFC 7638 §3.2 with RSA member ordering `e, kty, n`
- [x] 12.2 Worked-example match against RFC 7638 §3.1 reference `"NzbLsXh8uDCcd-6MNwXF4W_7noWXFZAfHkxZsRGC9Xs"`
## 12a. Implementation — SPKI fingerprint
- [x] 12a.1 `src/fingerprint/spki.ts` — `computeSpkiFingerprint(publicKey, { hash? = "SHA-256" }): Uint8Array`
- [x] 12a.2 `src/fingerprint/format.ts` — `formatFingerprint(bytes, format)` for `hex` / `base64` / `base64url` / `colon-hex`
- [x] 12a.3 Failure to derive SPKI bytes throws `CRYPTO_RSA.FINGERPRINT_FAILED`
## 12b. Implementation — PEM framing (RFC 7468)
- [x] 12b.1 `src/pem/parse-public.ts` — accepts `-----BEGIN PUBLIC KEY-----` (SPKI) + `-----BEGIN RSA PUBLIC KEY-----` (PKCS#1)
- [x] 12b.2 `src/pem/parse-private.ts` — accepts `-----BEGIN PRIVATE KEY-----` (PKCS#8) + `-----BEGIN RSA PRIVATE KEY-----` (PKCS#1)
- [x] 12b.3 `src/pem/serialize-public.ts` + `src/pem/serialize-private.ts` — emit 64-char-line base64 body per RFC 7468 §3
- [x] 12b.4 Mismatched BEGIN / END, unknown label, or non-base64 body throws `CRYPTO_RSA.PEM_MALFORMED`
## 12c. Implementation — key generation
- [x] 12c.1 `src/keygen/miller-rabin.ts` — `isProbablePrime(n, rng, rounds? = 64): boolean` per NIST FIPS 186-5 Appendix B.3.1
- [x] 12c.2 `src/keygen/exponent.ts` — `validatePublicExponent(e)` (odd, `1 < e < 2^256`, coprime with `λ(n)`)
- [x] 12c.3 `src/keygen/generate.ts` — `generateRsaKeyPair({ modulusBits?, publicExponent?, rng, primes?, signal?, profile? }): Promise<RsaPrivateKey>`; defaults `modulusBits: 3072`, `publicExponent: 65537n`, `primes: 2`, `profile: "permissive"`
- [x] 12c.4 `src/keygen/multi-prime.ts` — multi-prime support per RFC 8017 §3 (`primes ∈ {3, 4, 5}`); serialises to PKCS#1 `RSAPrivateKey` `version 1` with non-empty `otherPrimeInfos`
- [x] 12c.5 AbortSignal threaded into prime-search loop; abort interrupts within ≤ 100 ms wall-clock and throws `CRYPTO_RSA.ABORTED`
- [x] 12c.6 Miller-Rabin exhaustion throws `CRYPTO_RSA.KEYGEN_FAILED`
- [x] 12c.7 Deterministic mode produces byte-identical keys under identical seeded `rng`
## 12d. Implementation — blinding (RFC 8017 §5.1.2)
- [x] 12d.1 `src/blinding/blinding.ts` — `blindMessage(m, key, rng): { mPrime, rInverse }` + `unblindResult(s, rInverse, key): bigint`
- [x] 12d.2 Wired into every `rsaSign*` / `rsaDecrypt*` call by default
- [x] 12d.3 Opt-out via `{ blinding: false }` emits `crypto_rsa.blinding_disabled_total{operation}` counter increment
- [x] 12d.4 Mitata bench `blinded_vs_unblinded_2048` confirms < 5% overhead
## 12e. Implementation — `RsaCryptoEngine` adapter
- [x] 12e.1 `src/engine/engine.types.ts` — `RsaCryptoEngine` interface (`sign`, `verify`, `encrypt`, `decrypt`, `modPow`)
- [x] 12e.2 `src/engine/noble.ts` — default engine over `@noble/curves/abstract/modular.pow`
- [x] 12e.3 `src/engine/subtle.ts` — `globalThis.crypto.subtle` adapter; non-extractable keys by default; `{ extractable: true }` opt-in emits warning counter
- [x] 12e.4 `src/engine/select.ts` — `defaultEngine(operation, key)` auto-selects subtle when available else noble; emits `crypto_rsa.engine_selected_total{engine}`
- [x] 12e.5 `subtle` requested but unavailable throws `CRYPTO_RSA.ENGINE_UNAVAILABLE`
## 12f. Implementation — HSM / PKCS#11 bridge
- [x] 12f.1 `src/handle/handle.types.ts` — `RsaKeyHandle` + `RsaKeyHandleProvider` interfaces
- [x] 12f.2 `src/handle/registry.ts` — `registerKeyHandleProvider(provider)` + `resolveKeyHandle(key)`
- [x] 12f.3 `src/handle/software.ts` — `SoftwareRsaKeyHandle` reference implementation
## 12g. Implementation — FIPS 186-5 profile
- [x] 12g.1 `src/profile/profile.ts` — `enforceProfile(input, { profile })` with `permissive` and `fips-186-5` modes
- [x] 12g.2 `fips-186-5` rejects `modulusBits ∉ {2048, 3072, 4096}`, `publicExponent ∉ [2^16+1, 2^256-1]` or even, PSS `saltLength ≠ hLen`, `hash === "SHA-1"` in sign / verify — each throws `CRYPTO_RSA.PROFILE_VIOLATION` with `rule` attribute
## 12h. Implementation — JWK `key_ops` / `use` / `alg` enforcement
- [x] 12h.1 `src/keys/jwk-policy.ts` — `enforceJwkPolicy(jwk, operation)`
- [x] 12h.2 Wired into every public surface (`rsaSign*`, `rsaVerify*`, `rsaEncrypt*`, `rsaDecrypt*`)
- [x] 12h.3 Cross-policy violation throws `CRYPTO_RSA.KEY_OP_NOT_PERMITTED`
## 12i. Implementation — modulus-size DoS guard
- [x] 12i.1 `src/policy/modulus.ts` extended with `enforceMaxBitLength(key, { allowOversize? })` rejecting `bitLength > 16384` with `CRYPTO_RSA.MODULUS_TOO_LARGE`
## 13. Implementation — errors + observability
- [x] 13.1 `src/errors/errors.ts` — `defineCodes("CRYPTO_RSA", ["MODULUS_TOO_SHORT", "MODULUS_TOO_LARGE", "INVALID_KEY", "INVALID_SIGNATURE", "SIGNATURE_INVALID", "UNSUPPORTED_HASH", "UNSUPPORTED_FORMAT", "DECRYPT_FAILED", "MGF_FAILURE", "KEYGEN_FAILED", "PRIME_INVALID", "KEY_OP_NOT_PERMITTED", "PROFILE_VIOLATION", "ENGINE_UNAVAILABLE", "PEM_MALFORMED", "FINGERPRINT_FAILED", "ABORTED"])`; `CryptoRsaError extends SuaveplanError<CryptoRsaErrorCode>`
- [x] 13.2 Every code (17) thrown by ≥1 production path in `src/`
- [x] 13.3 `src/observability/metrics.ts` emits:
- histograms `crypto_rsa.verify_duration_ms{scheme,hash,engine}` / `sign_duration_ms{scheme,hash,engine}` / `encrypt_duration_ms{scheme,hash,engine}` / `decrypt_duration_ms{scheme,hash,engine}` / `keygen_duration_ms{modulus_bits,primes}` / `modpow_duration_ms{operation,blinded}`
- counters `crypto_rsa.outcome_total{operation,outcome}` / `crypto_rsa.errors_total{code}` / `crypto_rsa.engine_selected_total{engine}` / `crypto_rsa.fingerprint_total{format}` / `crypto_rsa.profile_violation_total{rule}` / `crypto_rsa.blinding_disabled_total{operation}`
## 14. Root barrel + subpath exports
- [x] 14.1 `src/index.ts` — explicit named re-exports (no `export *`)
- [x] 14.2 `package.json` exports map matches subpath plan in §2.6
- [x] 14.3 Verify subpath count matches feature module count (§13 step 8)
## 15. Test-vector generator + fixtures
- [ ] 15.1 `scripts/generate-rsa-vectors.ts` — deterministic generator producing PKCS1v15 signature / PSS signature / OAEP encrypt vectors against a fixed test key
- [ ] 15.2 RFC 8017 Appendix C vectors committed under `src/__fixtures__/rfc8017-appendix-c/`
- [ ] 15.3 NIST CAVP RSA vectors committed under `src/__fixtures__/cavp/` (sign / verify / encrypt) sourced from <https://csrc.nist.gov/projects/cryptographic-algorithm-validation-program/digital-signatures>
- [ ] 15.4 Google Wycheproof vectors committed under `src/__fixtures__/wycheproof/` (`rsa_signature_*.json`, `rsa_signature_pss_*.json`, `rsa_oaep_*.json`) sourced from <https://github.com/google/wycheproof/tree/master/testvectors>
- [ ] 15.5 Generator idempotent — re-running produces byte-identical output
## 16. Tests (runner-neutral imports — `@suaveplan/testing/runner`)
- [x] 16.1 `keys/parse-pkcs1-public.test.ts` — RFC 8017 Appendix A.1.1 round-trip; malformed → `INVALID_KEY`
- [x] 16.2 `keys/parse-pkcs1-private.test.ts` — Appendix A.1.2 round-trip
- [x] 16.3 `keys/parse-spki.test.ts` — `rsaEncryption` OID accept; wrong OID reject
- [x] 16.4 `keys/parse-pkcs8.test.ts` — RFC 5208 + RFC 5958 shapes accept
- [x] 16.5 `keys/parse-jwk.test.ts` — `kty: "RSA"` accept; `kty: "EC"` reject (`UNSUPPORTED_FORMAT`)
- [x] 16.6 `keys/serialize-*.test.ts` — every parse path's inverse, byte-exact
- [x] 16.7 `keys/roundtrip.spec-conformance.test.ts` — property: random valid key → serialise → parse → equal
- [x] 16.8 `policy/modulus.test.ts` — 2048-bit accept; 1024-bit reject under defaults; 1024-bit accept under `allowLegacy: true`; 512-bit always reject
- [x] 16.9 `math/modpow.test.ts` — matches naive `BigInt` reference on small inputs; source-grep gate `BigInt \*\*` returns zero
- [x] 16.10 `math/crt.test.ts` — CRT output equals naive `c^d mod n` on Appendix C vectors
- [x] 16.11 `encoding/mgf1.test.ts` — Appendix C `MGF1` outputs match; oversize `maskLen` throws `MGF_FAILURE`
- [x] 16.12 `encoding/emsa-pkcs1-v1_5.test.ts` — SHA-1 / 256 / 384 / 512 DigestInfo prefixes match RFC 8017 §9.2 note 1 byte-exactly
- [x] 16.13 `encoding/emsa-pss-encode.test.ts` + `encoding/emsa-pss-verify.test.ts` — Appendix C round-trip; bit-flipped encoding rejects
- [x] 16.14 `verify/pkcs1v15.test.ts` — Appendix C accept; SHA-384 / 512 / 1 accept; bit-flipped reject
- [x] 16.15 `verify/pss.test.ts` — Appendix C accept; custom `saltLength`; custom `mgfHash`; bit-flipped reject
- [x] 16.16 `sign/pkcs1v15.test.ts` — Appendix C reference signature matches (CRT path)
- [x] 16.17 `sign/pss.test.ts` — round-trip across SHA-256 / 384 / 512; deterministic with fixed RNG
- [x] 16.18 `encrypt/pkcs1v15.test.ts` + `decrypt/pkcs1v15.test.ts` — Appendix C round-trip; bad padding → `DECRYPT_FAILED` indistinguishable from value-error
- [x] 16.19 `encrypt/oaep.test.ts` + `decrypt/oaep.test.ts` — Appendix C round-trip; non-empty `label` round-trip
- [x] 16.20 `jwk/thumbprint.test.ts` — RFC 7638 §3.1 worked example matches
- [x] 16.21 `fingerprint/spki.test.ts` + `fingerprint/format.test.ts` — SHA-256 over RFC 8017 Appendix C SPKI; four output formats produce expected strings
- [x] 16.22 `pem/parse-public.test.ts` + `pem/parse-private.test.ts` + `pem/serialize-public.test.ts` + `pem/serialize-private.test.ts` — all four armor labels round-trip; malformed armor → `PEM_MALFORMED`
- [x] 16.23 `keygen/miller-rabin.test.ts` — known primes accepted, composites rejected; `rounds: 64` honoured
- [x] 16.24 `keygen/generate.test.ts` — sign-then-verify round-trip on `modulusBits ∈ {2048, 3072}`; deterministic mode byte-identical under seeded RNG; AbortSignal interrupts within 100 ms → `ABORTED`; Miller-Rabin exhaustion → `KEYGEN_FAILED`
- [x] 16.25 `keygen/multi-prime.test.ts` — `primes: 3` produces valid 3-prime key with non-empty `otherPrimeInfos`; sign-then-verify round-trip
- [x] 16.26 `keygen/exponent.test.ts` — even `e` rejects; `e === 2n` rejects; `e === 65537n` accepts
- [x] 16.27 `blinding/blinding.test.ts` — `blindMessage` → private-op → `unblindResult` equals naive private-op on Appendix C ciphertexts; opt-out emits `blinding_disabled_total` increment
- [x] 16.28 `engine/select.test.ts` — subtle selected when present; noble fallback when absent; explicit `subtle` against unavailable → `ENGINE_UNAVAILABLE`
- [x] 16.29 `engine/noble.test.ts` + `engine/subtle.test.ts` — both engines produce verify-equivalent outputs on Appendix C inputs
- [x] 16.30 `handle/registry.test.ts` + `handle/software.test.ts` — provider register / resolve round-trip; `SoftwareRsaKeyHandle` matches direct call
- [x] 16.31 `profile/profile.test.ts` — `fips-186-5` rejects 1024-bit, `e = 3`, `hash: "SHA-1"` in sign, PSS `saltLength: 0`; `permissive` accepts all
- [x] 16.32 `keys/jwk-policy.test.ts` — `key_ops` / `use` / `alg` cross-policy rejects with `KEY_OP_NOT_PERMITTED`
- [x] 16.33 `policy/modulus-max.test.ts` — 16384-bit accepts; 32768-bit rejects with `MODULUS_TOO_LARGE`; 32768-bit accepts under `allowOversize: true`
- [ ] 16.34 `wycheproof/replay.test.ts` — every `valid` accepts; every `invalid` rejects; every `acceptable` documented
- [ ] 16.35 `cavp/replay.test.ts` — every NIST CAVP RSA vector replayed
- [x] 16.36 `errors/errors.test.ts` — every code (17) in the registry thrown by ≥1 production path (audit table)
- [x] 16.37 `observability/metrics.test.ts` — every histogram + counter asserted with real in-memory `MeterProvider`, including `engine_selected_total`, `fingerprint_total`, `profile_violation_total`, `blinding_disabled_total`
- [x] 16.38 `schemas/schemas.test.ts` — Zod boundary rejects malformed inputs with the right error code
- [x] 16.39 `crypto-rsa.spec-conformance.test.ts` — Appendix C end-to-end; property test (sign / verify, encrypt / decrypt, parse / serialise round-trips across every format); verifiable-randomness determinism property; `BigInt \*\*` grep gate; every "MUST reject" path
- [x] 16.40 `index.test.ts` — every documented subpath resolves
- [x] 16.41 Every `it(...)` carries `{ timeout: 5000 }` (or 30000 for 4096-bit modpow / keygen)
## 17. Documentation
- [x] 17.1 Sibling `.md` for every implementation `.ts` (≥200 words, basic + advanced example, full API reference)
- [x] 17.2 README links every co-located `.md` under `## Modules`
- [x] 17.3 Update root `CLAUDE.md` package listing under "Crypto family"
## 18. Benchmarks
- [ ] 18.1 `src/__benchmarks__/crypto-rsa.bench.ts` — mitata covering:
- `verify_pkcs1_sha256_{2048,3072,4096} × engine ∈ {noble, subtle}`
- `verify_pss_sha256_{2048,3072,4096} × engine`
- `sign_pkcs1_sha256_{2048,3072,4096} × engine`
- `sign_pss_sha256_{2048,3072,4096} × engine`
- `encrypt_oaep_sha256_{2048,3072,4096} × engine`
- `decrypt_oaep_sha256_{2048,3072,4096} × engine`
- `generate_{2048,3072,4096}` (noble only)
- `parse_{pkcs1,pkcs8,spki,jwk,pem}_2048`
- `crt_vs_naive_2048` ratio
- `blinded_vs_unblinded_2048` ratio
- [ ] 18.2 Baseline numbers published in README
- [ ] 18.3 CRT path measurably ≥ 2× faster than non-CRT path on the 2048-bit sign benchmark
- [ ] 18.4 Blinding overhead < 5% of unblinded private-op latency on the 2048-bit bench
## 19. Pre-archive verification (the 5-gate triple)
- [x] 19.1 `bun run check` — Biome 0 errors / 0 warnings / 0 info
- [x] 19.2 `bunx turbo lint typecheck test --filter=@suaveplan/crypto-rsa` — green
- [x] 19.3 `bunx turbo test:coverage --filter=@suaveplan/crypto-rsa` — 100/100/100/100
- [x] 19.4 `bun scripts/openspec-conformance.ts @suaveplan/crypto-rsa` — exit 0, no `error` findings
- [x] 19.5 `bunx turbo build --filter=@suaveplan/crypto-rsa` — green
## 20. Pre-archive 7-row checklist (AGENTS.md Section 13)
- [x] 20.1 Generics threaded; `expectTypeOf` assertions on `RsaPublicKey` / `RsaPrivateKey` discriminants and on options-object literal types
- [x] 20.2 Zod schemas wired at every declared boundary (`RsaPublicKeySchema`, `RsaPrivateKeySchema`, every options schema)
- [x] 20.3 Every error code in `errors/errors.ts` thrown by at least one production path
- [x] 20.4 Strict-mode behaviors tested both on and off (`allowLegacy` modulus-policy on + off; CRT vs naive private-op equivalence)
- [x] 20.5 OTel instruments emitted from production paths and asserted with a real in-memory `MeterProvider`
- [x] 20.6 Lifecycle dedup — N/A (sign / verify / encrypt / decrypt are stateless; document explicitly)
- [x] 20.7 No retroactive tick — every box above reflects work done in this change
## 21. Root-doc refresh + markdownlint (§13b)
> Deferred — root docs (`MANIFEST.md`, `MISSING.yml`, `STATUS.md`, `SUMMARY.md`, `CLAUDE.md`) are refreshed by the orchestrator after parallel crypto-family migrations land.
- [x] 21.1 `bun scripts/folder-reorg/generate-manifest.ts` — refresh `MANIFEST.md` (orchestrator)
- [ ] 21.2 `bun scripts/generate-missing.ts` — refresh `MISSING.yml` (orchestrator)
- [x] 21.3 `STATUS.md` — move `crypto-rsa` from Pending → Completed (orchestrator)
- [x] 21.4 `SUMMARY.md` — add per-package paragraph (orchestrator)
- [ ] 21.5 `bunx markdownlint-cli2 --fix` — apply auto-fixes (orchestrator)
- [ ] 21.6 `bunx markdownlint-cli2` (no --fix) — exit 0 (orchestrator)
## 22. Archive
- [x] 22.1 Fold spec delta into `openspec/specs/crypto-rsa/spec.md`
- [x] 22.2 Move `openspec/changes/enterprise-crypto-rsa/` → `openspec/archive/<yyyy-mm-dd>-enterprise-crypto-rsa/`
- [x] 22.3 Refresh `MANIFEST.md` if package set or deps changed (orchestrator)