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

60 KiB
Raw Permalink Blame History

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"