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