60 KiB
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. (Seeopenspec/AGENTS.mdSection 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
nis a positive bigint whose bit-length is 2048,eequals65537n, andbitLengthequals 2048
Scenario: rejects even public exponent
- WHEN
parseRsaPublicKeyis called with a DER blob whosepublicExponentfield is4n - THEN the call throws
CryptoRsaErrorwith 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
RsaPublicKeyis structurally equal to the value obtained from the PKCS#1 parse of the same key
Scenario: rejects SPKI with EC OID
- WHEN
parseRsaPublicKeyis called with a SPKI blob whose algorithm OID is1.2.840.10045.2.1(id-ecPublicKey) - THEN the call throws
CryptoRsaErrorwith 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, andbitLengthmatch 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
CryptoRsaErrorwith 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 andn === p * q
Scenario: rejects multi-prime RSA
- WHEN the input contains a non-empty
otherPrimeInfosfield (version === 1n) - THEN the call throws
CryptoRsaErrorwith 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
RsaPrivateKeyis 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 === 1nand a non-emptypublicKeyBIT STRING field per RFC 5958 - THEN the call succeeds and the returned
n,eagree with the embeddedpublicKey
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 withn, 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
qimember - THEN the call throws
CryptoRsaErrorwith 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
Uint8Arrayis 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, qiand re-parses to aRsaPrivateKeystructurally 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
enforceMinBitLengthis called with a 2048-bit key and no options - THEN the call returns without throwing
Scenario: rejects a 1024-bit modulus under defaults
- WHEN
enforceMinBitLengthis called with a 1024-bit key and no options - THEN the call throws
CryptoRsaErrorwith code"CRYPTO_RSA.MODULUS_TOO_SHORT"
Scenario: accepts a 1024-bit modulus under allowLegacy
- WHEN
enforceMinBitLengthis 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
enforceMinBitLengthis called with a 512-bit key and{ allowLegacy: true } - THEN the call throws
CryptoRsaErrorwith 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 ** bigintpattern 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 npath
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_2048benchmark 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
signatureXOR'd with0x01 - THEN the result is
falseAND noCryptoRsaErroris thrown
Scenario: throws on unknown hash
- WHEN the call is made with
{ hash: "SHA-3-256" } - THEN the call throws
CryptoRsaErrorwith 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
signatureXOR'd with0x01 - 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: 0andrsaVerifyPss(..., { 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"andmgfHash: "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: 32and verify is called withsaltLength: 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 freshrsaSignPss(...)is called and the result is fed torsaVerifyPss(...) - 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 seededfixedRng - THEN the two output
Uint8Arrayvalues 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
falseAND 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 (wherehLen = 20for SHA-1, somaskLen > 2^32 * 20evaluates true is the spec gate — implementation uses the conservative checkmaskLen > 2 ** 32 * hLen) - THEN the call throws
CryptoRsaErrorwith 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 resultingEMis fed toemsaPssVerify(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
EMwith the first byte XOR'd with0x01 - 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 formsg = new Uint8Array([1,2,3,4,5]) - THEN the result equals
msgbyte-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 samelabel - 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 withlabel: encoder.encode("session-2") - THEN the call throws
CryptoRsaErrorwith 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-bytemsg - 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
CryptoRsaErrorwith 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.pkcs1v15Vectorsand callsrsaVerifyPkcs1v15against each - THEN every call returns
true
Scenario: every Appendix C OAEP ciphertext decrypts to the documented plaintext
- WHEN the conformance test iterates
rfc8017AppendixC.oaepVectorsand callsrsaDecryptOaepagainst 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
rsaVerifyPkcs1v15is called with a signature whose length does NOT matchbyteLength(n) - THEN the call throws
CRYPTO_RSA.INVALID_SIGNATURE(malformed-input class) — NOTCRYPTO_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
CryptoRsaErrorwith 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
CryptoRsaErrorwith 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}histogramcrypto_rsa.encrypt_duration_ms{scheme,hash}histogram (scheme ∈ {"pkcs1v15","oaep"})crypto_rsa.decrypt_duration_ms{scheme,hash}histogramcrypto_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
rsaVerifyPkcs1v15is called and the in-memoryMeterProvideris flushed - THEN the metric reader observes one
crypto_rsa.verify_duration_msdata point with attributes{scheme: "pkcs1v15", hash: "SHA-256"}
Scenario: outcome_total increments with the right outcome label
- WHEN
rsaVerifyPkcs1v15returnsfalseon a bit-flipped signature - THEN the metric reader observes a
crypto_rsa.outcome_totalincrement 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 andrsaSignPkcs1v15(key, msg, { hash: "SHA-256" })thenrsaVerifyPkcs1v15(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
CryptoRsaErrorcode"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 viaserializeRsaPrivateKey(key, { format: "pkcs1" }) - THEN the resulting DER blob contains a
version === 1nfield AND a non-emptyotherPrimeInfosSEQUENCE
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 andcontroller.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
AbortSignalis 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
RsaPrivateKeyvalues are structurally identical (everyn, e, d, p, q, dp, dq, qInvequal)
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_totalincremented by 1 with{operation: "sign"}
Scenario: blinding overhead is under 5% on the 2048-bit benchmark
- WHEN the
blinded_vs_unblinded_2048mitata 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 whereglobalThis.crypto.subtleis 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.subtlestubbed toundefined - 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 andglobalThis.crypto.subtleisundefined - THEN the call throws
CryptoRsaErrorcode"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
SoftwareRsaKeyHandleis constructed around anRsaPrivateKeyand itssign(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
RsaKeyHandleProvideris registered andresolveKeyHandle(jwkWithKid)is called against a JWK carrying thekidthe provider knows - THEN the returned object satisfies the
RsaKeyHandleinterface
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
RsaPublicKeyis 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
parseRsaPublicKeyPemis called against text whose BEGIN label isRSA PUBLIC KEYbut END label isPUBLIC KEY - THEN the call throws
CryptoRsaErrorcode"CRYPTO_RSA.PEM_MALFORMED"
Scenario: PEM with unknown armor label rejects
- WHEN
parseRsaPublicKeyPemis called against text armored-----BEGIN EC PUBLIC KEY----- - THEN the call throws
CryptoRsaErrorcode"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 viaformatFingerprint(bytes, "hex") - THEN the returned hex string matches the openssl-computed
openssl pkey -pubout -outform DER | openssl dgst -sha256value 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
saltLengthother 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
CryptoRsaErrorcode"CRYPTO_RSA.PROFILE_VIOLATION"ANDerror.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"withrule: "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"withrule: "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 torsaVerifyPkcs1v15 - THEN the call throws
CryptoRsaErrorcode"CRYPTO_RSA.KEY_OP_NOT_PERMITTED"
Scenario: use="sig" key rejects encrypt call
- WHEN a JWK with
use: "sig"is passed torsaEncryptOaep - 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 torsaVerifyPss - 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
rsaVerifyPkcs1v15is called with a synthetic 32768-bit key - THEN the call throws
CryptoRsaErrorcode"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
rsaDecryptPkcs1v15is called against four distinct mutilated ciphertexts (first byte non-zero, second byte non-0x02, no zero separator within k-11 bytes, valuec >= n) - THEN all four calls throw
CryptoRsaErrorcode"CRYPTO_RSA.DECRYPT_FAILED"ANDerror.messageis byte-identical across the four
Scenario: Wycheproof rsa_pkcs1_decrypt vectors replay correctly
- WHEN the conformance test iterates the bundled Wycheproof
rsa_pkcs1_decrypt_*.jsonvectors - THEN every
validcase round-trips and everyinvalidcase 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
rsaDecryptOaepis called against four distinct mutilated ciphertexts spanning every Manger failure class - THEN all four calls throw
"CRYPTO_RSA.DECRYPT_FAILED"ANDerror.messageis byte-identical across the four
Scenario: Wycheproof rsa_oaep_*.json vectors replay correctly
- WHEN the conformance test iterates the bundled Wycheproof
rsa_oaep_*.jsoncorpus - THEN every
validcase round-trips, everyinvalidcase throws"CRYPTO_RSA.DECRYPT_FAILED", and everyacceptablecase 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_*.jsoncorpus - THEN every
validcase verifies, everyinvalidcase rejects (returnsfalseor throwsINVALID_SIGNATURE), and everyacceptablecase is documented
Scenario: forged signature with valid DigestInfo but bogus PS rejects
- WHEN a signature carrying the correct DigestInfo prefix + digest but a non-
0xffPSbyte 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 returnstrueand every documented-invalid pair returnsfalse
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 torsaSignPss(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
generateRsaKeyPairis 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"