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

915 lines
60 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!-- markdownlint-disable MD024 -->
# Capability Spec: crypto-rsa
**Package**: `@suaveplan/crypto-rsa`
**Status**: SHIPPED 2026-05-11 (folded from `openspec/archive/2026-05-11-enterprise-crypto-rsa/`).
> Every requirement carries a `**Verified by:**` line. (See `openspec/AGENTS.md` Section 13.)
## 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 -->