Initial Commit
This commit is contained in:
364
openspec/archive/2026-05-11-enterprise-crypto-rsa/proposal.md
Normal file
364
openspec/archive/2026-05-11-enterprise-crypto-rsa/proposal.md
Normal file
@@ -0,0 +1,364 @@
|
||||
# `@suaveplan/crypto-rsa` — RSA primitives (RFC 8017 PKCS #1 v2.2 + RFC 5208 PKCS #8 + RFC 7518 JWA)
|
||||
|
||||
**Package**: `@suaveplan/crypto-rsa` (NEW)
|
||||
**Status**: PROPOSED
|
||||
**Type**: new-package (greenfield; crypto-plugin sibling, universal-tier)
|
||||
**Date**: 2026-05-11
|
||||
**Path**: `packages/crypto/crypto-rsa/`
|
||||
**Source audit**: `../suaveplan/enterprise/crypto-rsa.md`
|
||||
|
||||
## Why
|
||||
|
||||
The genesis crypto family today covers symmetric AEAD (`crypto-cipher`), hashing + KDFs (`crypto-hash`), and elliptic curves (`crypto-curve`: Ed25519, X25519, P-256, P-384, P-521, secp256k1). It does NOT cover RSA — yet RSA is the dominant signature algorithm in every protocol genesis is migrating: real-world TLS certificate chains, the JWS / JWT ecosystem (RFC 7518 §3.3 mandates support for `RS256`), and the WebAuthn `tpm` attestation format (RSA endorsement keys are the production majority for TPM-bound credentials). Three packages currently in flight on genesis are blocked on this gap:
|
||||
|
||||
1. **`@suaveplan/x509`** — verifies certificate-chain signatures. RFC 5280 §1.2 + Appendix A enumerate the RSA signature OIDs `1.2.840.113549.1.1.5` (sha1WithRSAEncryption), `1.2.840.113549.1.1.11` (sha256WithRSAEncryption), `1.2.840.113549.1.1.12` (sha384), `1.2.840.113549.1.1.13` (sha512), and `1.2.840.113549.1.1.10` (id-RSASSA-PSS). The majority of real production certs today use one of these.
|
||||
2. **`@suaveplan/jws`** — implements the JWA registry of RFC 7518 §3.3 (`RS256`/`RS384`/`RS512` over RSASSA-PKCS1-v1\_5) and §3.5 (`PS256`/`PS384`/`PS512` over RSASSA-PSS). Six of the eleven JWS algorithm IDs are RSA-backed.
|
||||
3. **`@suaveplan/webauthn`** — `tpm` attestation statements carry RSA public keys from TPM modules. RSA-PKCS1-v1\_5 over SHA-256 is the most common TPM attestation signature.
|
||||
|
||||
All three consumers verify externally-issued keys; one (`jws`) also signs. Beyond the three named consumers, this package is the universal RSA primitive for the entire monorepo — `@suaveplan/crypto-cli`'s future `keychain rsa init`, `@suaveplan/vault`'s future RSA-protected key blobs, `@suaveplan/pkcs12` PKCS#12 import paths, and any downstream consumer integrating with HSM / PKCS#11 hardware all route through `@suaveplan/crypto-rsa`. Splitting RSA out into `@suaveplan/crypto-rsa` rather than folding it into `@suaveplan/crypto-curve` keeps curve-only callers at zero RSA bundle cost, mirrors the per-primitive layout the rest of the crypto family already follows (`crypto-cipher`, `crypto-hash`), and matches `@noble/curves`'s own separation of curve and RSA work.
|
||||
|
||||
The library ships at enterprise quality from day one — matching the bar set by `@suaveplan/crypto-curve` (per-primitive subpaths, native + noble engine adapters) and `@suaveplan/crypto-vault` (Zod boundaries, OTel everywhere, 100% docs, sentinel-driven side-channel hardening). The required-reading audit covered RFC 8017 (PKCS #1 v2.2) end-to-end, NIST FIPS 186-5 (Digital Signature Standard) §5 (RSA DSA), NIST SP 800-56B Rev. 2 (Key Establishment Using Integer Factorization Cryptography), Google Wycheproof's RSA test corpus, RFC 7468 (PEM textual encoding), RFC 7638 (JWK Thumbprint), and RFC 5990 (RSA-KEM — surveyed and explicitly deferred). Every requirement below is anchored against one of these sources.
|
||||
|
||||
The package is greenfield — no prior implementation in `packages/crypto/` and no upstream `../suaveplan/packages/<plugin>/crypto-rsa/` source. Every requirement is mapped against the canonical RFCs and NIST documents: RFC 8017 (PKCS #1 v2.2), RFC 5208 (PKCS #8), RFC 5958 (PKCS #8 successor), RFC 5280 (X.509 + OIDs), RFC 7468 (PEM), RFC 7517 / 7518 / 7638 (JWK / JWA / JWK Thumbprint), RFC 5990 (RSA-KEM — deferred), NIST FIPS 186-5 (Digital Signature Standard), NIST SP 800-56B Rev. 2 (Key Establishment), and Google Wycheproof.
|
||||
|
||||
## What changes
|
||||
|
||||
### New package: `@suaveplan/crypto-rsa`
|
||||
|
||||
#### Scaffold
|
||||
|
||||
- ADDED: `packages/crypto/crypto-rsa/` package scaffold (`package.json`, `tsconfig.lib.json`, `vite.config.ts`, `vitest.config.ts`, `README.md`, `src/__benchmarks__/`, `src/__fixtures__/`).
|
||||
|
||||
#### ASN.1 / DER helpers
|
||||
|
||||
- ADDED: `src/asn1/reader.ts` — minimal DER reader (`readInteger`, `readSequence`, `readObjectIdentifier`, `readBitString`, `readOctetString`, `readNull`, `readContextSpecific`) sufficient for PKCS#1, PKCS#8, SPKI, RSA-PSS `AlgorithmIdentifier` parameters.
|
||||
- ADDED: `src/asn1/writer.ts` — matching DER writer.
|
||||
|
||||
#### Canonical key shapes
|
||||
|
||||
- ADDED: `src/keys/keys.types.ts` — `RsaPublicKey { n: bigint; e: bigint; bitLength: number }`, `RsaPrivateKey { n; e; d; p; q; dp; dq; qInv; bitLength }`. `bitLength` is the modulus bit-length (used by the modulus-length policy gate).
|
||||
|
||||
#### Key parse + serialise — PKCS#1
|
||||
|
||||
- ADDED: `src/keys/parse-pkcs1-public.ts` — `parseRsaPublicKeyPkcs1(bytes): RsaPublicKey` per RFC 8017 Appendix A.1.1 (`RSAPublicKey ::= SEQUENCE { modulus INTEGER, publicExponent INTEGER }`).
|
||||
- ADDED: `src/keys/parse-pkcs1-private.ts` — `parseRsaPrivateKeyPkcs1(bytes): RsaPrivateKey` per RFC 8017 Appendix A.1.2.
|
||||
- ADDED: `src/keys/serialize-pkcs1-public.ts` + `src/keys/serialize-pkcs1-private.ts` — inverse paths.
|
||||
|
||||
#### Key parse + serialise — PKCS#8 / SPKI
|
||||
|
||||
- ADDED: `src/keys/parse-spki.ts` — `parseRsaPublicKeySpki(bytes): RsaPublicKey` unwraps the `SubjectPublicKeyInfo` (RFC 5280) carrying OID `1.2.840.113549.1.1.1` (`rsaEncryption`) and dispatches to the PKCS#1 reader.
|
||||
- ADDED: `src/keys/parse-pkcs8.ts` — `parseRsaPrivateKeyPkcs8(bytes): RsaPrivateKey` per RFC 5208 / RFC 5958.
|
||||
- ADDED: `src/keys/serialize-spki.ts` + `src/keys/serialize-pkcs8.ts` — inverse paths.
|
||||
|
||||
#### Key parse + serialise — JWK
|
||||
|
||||
- ADDED: `src/keys/parse-jwk.ts` — `parseRsaPublicKeyJwk` + `parseRsaPrivateKeyJwk` per RFC 7517 §6.3. Rejects `kty !== "RSA"` with `CRYPTO_RSA.UNSUPPORTED_FORMAT`.
|
||||
- ADDED: `src/keys/serialize-jwk.ts` — emits base64url-encoded values per RFC 7515 §2 (no padding, URL-safe alphabet).
|
||||
|
||||
#### Façade
|
||||
|
||||
- ADDED: `src/keys/parse.ts` — `parseRsaPublicKey(bytes, { format })` + `parseRsaPrivateKey(bytes, { format })` dispatch on the `format` discriminant.
|
||||
- ADDED: `src/keys/serialize.ts` — `serializeRsaPublicKey(key, { format })` + `serializeRsaPrivateKey(key, { format })` dispatch.
|
||||
|
||||
#### Modulus-length policy
|
||||
|
||||
- ADDED: `src/policy/modulus.ts` — `enforceMinBitLength(key, { allowLegacy?: boolean })`. Default floor is 2048 bits; `allowLegacy: true` permits 1024-bit keys. Below 1024 is always rejected with `CRYPTO_RSA.MODULUS_TOO_SHORT`.
|
||||
|
||||
#### Modular arithmetic + CRT
|
||||
|
||||
- ADDED: `src/math/modpow.ts` — `modPow(base: bigint, exponent: bigint, modulus: bigint): bigint` thin wrapper over `@noble/curves/abstract/modular.pow`. NEVER native `BigInt ** BigInt % N`.
|
||||
- ADDED: `src/math/crt.ts` — `rsaPrivateOperationCrt(c: bigint, key: RsaPrivateKey): bigint` per RFC 8017 §5.1.2 second-form (`m1 = c^dp mod p`, `m2 = c^dq mod q`, `h = qInv * (m1 - m2) mod p`, `m = m2 + h * q`).
|
||||
- ADDED: `src/math/i2osp.ts` — Integer-to-Octet-String primitive per RFC 8017 §4.1.
|
||||
- ADDED: `src/math/os2ip.ts` — Octet-String-to-Integer primitive per RFC 8017 §4.2.
|
||||
|
||||
#### MGF1 + encoding methods
|
||||
|
||||
- ADDED: `src/encoding/mgf1.ts` — `mgf1(seed, maskLen, hash): Uint8Array` per RFC 8017 §B.2.1. Throws `CRYPTO_RSA.MGF_FAILURE` when `maskLen > 2^32 * hLen`.
|
||||
- ADDED: `src/encoding/emsa-pkcs1-v1_5.ts` — `emsaPkcs1v15Encode(messageDigest, emLen, hash)` per RFC 8017 §9.2. Bundles the four DigestInfo prefix constants:
|
||||
- SHA-1: `30 21 30 09 06 05 2b 0e 03 02 1a 05 00 04 14`
|
||||
- SHA-256: `30 31 30 0d 06 09 60 86 48 01 65 03 04 02 01 05 00 04 20`
|
||||
- SHA-384: `30 41 30 0d 06 09 60 86 48 01 65 03 04 02 02 05 00 04 30`
|
||||
- SHA-512: `30 51 30 0d 06 09 60 86 48 01 65 03 04 02 03 05 00 04 40`
|
||||
- ADDED: `src/encoding/emsa-pss-encode.ts` — `emsaPssEncode(messageDigest, emBits, salt, hash, mgfHash)` per RFC 8017 §9.1.1.
|
||||
- ADDED: `src/encoding/emsa-pss-verify.ts` — `emsaPssVerify(messageDigest, encodedMessage, emBits, saltLength, hash, mgfHash)` per RFC 8017 §9.1.2. Constant-time over the salt + hash compare.
|
||||
|
||||
#### Signatures — RSASSA-PKCS1-v1\_5
|
||||
|
||||
- ADDED: `src/sign/pkcs1v15.ts` — `rsaSignPkcs1v15(privateKey, message, { hash }): Uint8Array` per RFC 8017 §8.2.1.
|
||||
- ADDED: `src/verify/pkcs1v15.ts` — `rsaVerifyPkcs1v15(key, message, signature, { hash }): boolean` per RFC 8017 §8.2.2. Constant-time encoded-message compare.
|
||||
|
||||
#### Signatures — RSASSA-PSS
|
||||
|
||||
- ADDED: `src/sign/pss.ts` — `rsaSignPss(privateKey, message, { hash; saltLength?; mgfHash?; rng? }): Uint8Array` per RFC 8017 §8.1.1.
|
||||
- ADDED: `src/verify/pss.ts` — `rsaVerifyPss(key, message, signature, { hash; saltLength?; mgfHash? }): boolean` per RFC 8017 §8.1.2.
|
||||
|
||||
#### Encryption — RSAES-PKCS1-v1\_5
|
||||
|
||||
- ADDED: `src/encrypt/pkcs1v15.ts` — `rsaEncryptPkcs1v15(key, plaintext, { rng? }): Uint8Array` per RFC 8017 §7.2.1.
|
||||
- ADDED: `src/decrypt/pkcs1v15.ts` — `rsaDecryptPkcs1v15(privateKey, ciphertext): Uint8Array` per RFC 8017 §7.2.2. Single combined error class (`CRYPTO_RSA.DECRYPT_FAILED`) — never distinguishes padding-format errors (Bleichenbacher oracle defence).
|
||||
|
||||
#### Encryption — RSAES-OAEP
|
||||
|
||||
- ADDED: `src/encrypt/oaep.ts` — `rsaEncryptOaep(key, plaintext, { hash; mgfHash?; label?; rng? }): Uint8Array` per RFC 8017 §7.1.1.
|
||||
- ADDED: `src/decrypt/oaep.ts` — `rsaDecryptOaep(privateKey, ciphertext, { hash; mgfHash?; label? }): Uint8Array` per RFC 8017 §7.1.2. Same combined-error contract.
|
||||
|
||||
#### JWK thumbprint
|
||||
|
||||
- ADDED: `src/jwk/thumbprint.ts` — `rsaJwkThumbprint(key, hash): Uint8Array` per RFC 7638 §3.2. Worked-example match against RFC 7638 §3.1 reference value `"NzbLsXh8uDCcd-6MNwXF4W_7noWXFZAfHkxZsRGC9Xs"`.
|
||||
|
||||
#### SPKI fingerprint
|
||||
|
||||
- ADDED: `src/fingerprint/spki.ts` — `computeSpkiFingerprint(publicKey, { hash?: "SHA-256" | "SHA-1" }): Uint8Array` (default SHA-256). Digests the SPKI DER bytes; `ssh-keygen -lf`-style identifier for an RSA key.
|
||||
- ADDED: `src/fingerprint/format.ts` — `formatFingerprint(bytes, format: "hex" | "base64" | "base64url" | "colon-hex"): string`.
|
||||
|
||||
#### PEM framing (RFC 7468)
|
||||
|
||||
- ADDED: `src/pem/parse-public.ts` — `parseRsaPublicKeyPem(text): RsaPublicKey` recognising armor labels `-----BEGIN PUBLIC KEY-----` (SPKI) and `-----BEGIN RSA PUBLIC KEY-----` (PKCS#1 public).
|
||||
- ADDED: `src/pem/parse-private.ts` — `parseRsaPrivateKeyPem(text): RsaPrivateKey` recognising armor labels `-----BEGIN PRIVATE KEY-----` (PKCS#8) and `-----BEGIN RSA PRIVATE KEY-----` (PKCS#1 private).
|
||||
- ADDED: `src/pem/serialize-public.ts` + `src/pem/serialize-private.ts` — inverse paths emitting 64-char-line base64 bodies per RFC 7468 §3.
|
||||
|
||||
#### Key generation
|
||||
|
||||
- ADDED: `src/keygen/miller-rabin.ts` — `isProbablePrime(n: bigint, rng: Rng, rounds?: number = 64): boolean` per NIST FIPS 186-5 Appendix B.3.1.
|
||||
- ADDED: `src/keygen/generate.ts` — `generateRsaKeyPair(options: { modulusBits?, publicExponent?, rng, primes?, signal?, profile? }): Promise<RsaPrivateKey>`. Defaults: `modulusBits: 3072`, `publicExponent: 65537n`, `primes: 2`, `profile: "permissive"`. AbortSignal threaded into the prime-search loop; abort interrupts within ≤ 100 ms and throws `CRYPTO_RSA.ABORTED`.
|
||||
- ADDED: `src/keygen/multi-prime.ts` — multi-prime RSA support per RFC 8017 §3 (`primes ∈ {3, 4, 5}`).
|
||||
- ADDED: `src/keygen/exponent.ts` — `publicExponent` validation (odd, `1 < e < 2^256`, coprime with `λ(n)`).
|
||||
|
||||
#### Blinding (RFC 8017 §5.1.2)
|
||||
|
||||
- ADDED: `src/blinding/blinding.ts` — `blindMessage(m, key, rng): { mPrime, rInverse }` + `unblindResult(s, rInverse, key): bigint` per RFC 8017 §5.1.2 / Kocher 1996. Wired into every `rsaSign*` / `rsaDecrypt*` call by default; opt-out via `{ blinding: false }` emits `crypto_rsa.blinding_disabled_total{operation}` counter increment.
|
||||
|
||||
#### `RsaCryptoEngine` adapter — noble + subtle
|
||||
|
||||
- ADDED: `src/engine/engine.types.ts` — `RsaCryptoEngine` interface (`sign`, `verify`, `encrypt`, `decrypt`, `modPow`).
|
||||
- ADDED: `src/engine/noble.ts` — default engine over `@noble/curves/abstract/modular.pow`.
|
||||
- ADDED: `src/engine/subtle.ts` — `globalThis.crypto.subtle` adapter. Wraps `importKey` / `sign` / `verify` / `encrypt` / `decrypt`. Non-extractable keys by default.
|
||||
- ADDED: `src/engine/select.ts` — `defaultEngine(operation, key): RsaCryptoEngine` auto-selects subtle when available else noble. Emits `crypto_rsa.engine_selected_total{engine}` on every call.
|
||||
|
||||
#### HSM / PKCS#11 bridge
|
||||
|
||||
- ADDED: `src/handle/handle.types.ts` — `RsaKeyHandle` interface + `RsaKeyHandleProvider` interface.
|
||||
- ADDED: `src/handle/registry.ts` — `registerKeyHandleProvider(provider)` + `resolveKeyHandle(key)`.
|
||||
- ADDED: `src/handle/software.ts` — `SoftwareRsaKeyHandle` reference implementation wrapping an in-memory `RsaPrivateKey`.
|
||||
|
||||
#### FIPS 186-5 profile
|
||||
|
||||
- ADDED: `src/profile/profile.ts` — `enforceProfile(input, options: { profile: "permissive" | "fips-186-5" })`. The `fips-186-5` profile rejects `modulusBits ∉ {2048, 3072, 4096}`, `publicExponent ∉ [2^16+1, 2^256-1]`, even `publicExponent`, `hash === "SHA-1"` in sign / verify, and PSS `saltLength` ≠ `hLen` (with `CRYPTO_RSA.PROFILE_VIOLATION`).
|
||||
|
||||
#### JWK `key_ops` / `use` / `alg` enforcement
|
||||
|
||||
- ADDED: `src/keys/jwk-policy.ts` — `enforceJwkPolicy(jwk, operation): void`. `operation ∈ {"sign","verify","encrypt","decrypt","wrapKey","unwrapKey"}`. Cross-policy violations throw `CRYPTO_RSA.KEY_OP_NOT_PERMITTED`.
|
||||
|
||||
#### Errors + Zod + observability
|
||||
|
||||
- ADDED: `src/errors/errors.ts` — `defineCodes("CRYPTO_RSA", ["MODULUS_TOO_SHORT", "MODULUS_TOO_LARGE", "INVALID_KEY", "INVALID_SIGNATURE", "SIGNATURE_INVALID", "UNSUPPORTED_HASH", "UNSUPPORTED_FORMAT", "DECRYPT_FAILED", "MGF_FAILURE", "KEYGEN_FAILED", "PRIME_INVALID", "KEY_OP_NOT_PERMITTED", "PROFILE_VIOLATION", "ENGINE_UNAVAILABLE", "PEM_MALFORMED", "FINGERPRINT_FAILED", "ABORTED"])` registry (17 codes) + `CryptoRsaError extends SuaveplanError<CryptoRsaErrorCode>`.
|
||||
- ADDED: `src/schemas/schemas.ts` — `RsaPublicKeySchema`, `RsaPrivateKeySchema`, `RsaParseOptionsSchema`, `RsaSerializeOptionsSchema`, `RsaSignOptionsSchema`, `RsaVerifyOptionsSchema`, `RsaPssOptionsSchema`, `RsaOaepOptionsSchema`, `HashAlgorithmSchema`, `RsaKeygenOptionsSchema`, `RsaFingerprintOptionsSchema`, `RsaJwkPolicySchema`, `RsaProfileSchema`. Mismatch → `CRYPTO_RSA.UNSUPPORTED_FORMAT` / `CRYPTO_RSA.UNSUPPORTED_HASH` / `CRYPTO_RSA.INVALID_KEY` / `CRYPTO_RSA.PROFILE_VIOLATION` as appropriate.
|
||||
- ADDED: `src/observability/metrics.ts` — `cryptoRsaMetrics` factory emitting:
|
||||
- Histograms: `crypto_rsa.verify_duration_ms{scheme,hash,engine}`, `crypto_rsa.sign_duration_ms{scheme,hash,engine}`, `crypto_rsa.encrypt_duration_ms{scheme,hash,engine}`, `crypto_rsa.decrypt_duration_ms{scheme,hash,engine}`, `crypto_rsa.keygen_duration_ms{modulus_bits,primes}`, `crypto_rsa.modpow_duration_ms{operation,blinded}`.
|
||||
- Counters: `crypto_rsa.outcome_total{operation,outcome}` (`outcome ∈ ok / invalid_signature / decrypt_failed / modulus_too_short / modulus_too_large / profile_violation / aborted / key_op_not_permitted`), `crypto_rsa.errors_total{code}`, `crypto_rsa.engine_selected_total{engine}`, `crypto_rsa.fingerprint_total{format}`, `crypto_rsa.profile_violation_total{rule}` (`rule ∈ modulus_bits / public_exponent / salt_length / sha1_banned`), `crypto_rsa.blinding_disabled_total{operation}`.
|
||||
|
||||
#### Root + scripts + benchmarks
|
||||
|
||||
- ADDED: `src/index.ts` — explicit named re-exports of every public surface (no `export *`).
|
||||
- ADDED: `scripts/generate-rsa-vectors.ts` — deterministic vector generator covering PKCS1v15 sign, PSS sign, OAEP encrypt against a fixed test key. Idempotent re-runs.
|
||||
- ADDED: `src/__fixtures__/rfc8017-appendix-c/` — RFC 8017 Appendix C test vectors as JSON.
|
||||
- ADDED: `src/__fixtures__/cavp/` — NIST CAVP RSA sign / verify / encrypt vectors from <https://csrc.nist.gov/projects/cryptographic-algorithm-validation-program/digital-signatures>.
|
||||
- ADDED: `src/__fixtures__/wycheproof/` — Google Wycheproof RSA vectors (`rsa_signature_*.json`, `rsa_signature_pss_*.json`, `rsa_oaep_*.json`) from <https://github.com/google/wycheproof/tree/master/testvectors>.
|
||||
- ADDED: `src/__benchmarks__/crypto-rsa.bench.ts` — mitata covering `verify_pkcs1_sha256_{2048,3072,4096} × engine ∈ {noble,subtle}`, `verify_pss_sha256_{2048,3072,4096} × engine`, `sign_pkcs1_sha256_{2048,3072,4096} × engine`, `sign_pss_sha256_{2048,3072,4096} × engine`, `encrypt_oaep_sha256_{2048,3072,4096} × engine`, `decrypt_oaep_sha256_{2048,3072,4096} × engine`, `generate_{2048,3072,4096}` (noble only), `parse_{pkcs1,pkcs8,spki,jwk,pem}_2048`, `crt_vs_naive_2048` ratio (CRT ≥ 2× faster), `blinded_vs_unblinded_2048` ratio (blinding overhead < 5%).
|
||||
|
||||
### Subpath exports
|
||||
|
||||
- `.` — root barrel (every public symbol)
|
||||
- `./keys` — `parseRsaPublicKey`, `parseRsaPrivateKey`, `serializeRsaPublicKey`, `serializeRsaPrivateKey`, `enforceJwkPolicy`, types
|
||||
- `./keygen` — `generateRsaKeyPair`, `isProbablePrime`
|
||||
- `./sign` — `rsaSignPkcs1v15`, `rsaSignPss`
|
||||
- `./verify` — `rsaVerifyPkcs1v15`, `rsaVerifyPss`
|
||||
- `./encrypt` — `rsaEncryptPkcs1v15`, `rsaEncryptOaep`
|
||||
- `./decrypt` — `rsaDecryptPkcs1v15`, `rsaDecryptOaep`
|
||||
- `./encoding` — `mgf1`, `emsaPkcs1v15Encode`, `emsaPssEncode`, `emsaPssVerify`
|
||||
- `./jwk` — `rsaJwkThumbprint`
|
||||
- `./fingerprint` — `computeSpkiFingerprint`, `formatFingerprint`
|
||||
- `./pem` — `parseRsaPublicKeyPem`, `parseRsaPrivateKeyPem`, `serializeRsaPublicKeyPem`, `serializeRsaPrivateKeyPem`
|
||||
- `./blinding` — `blindMessage`, `unblindResult` (advanced consumers; re-exported internally by sign / decrypt)
|
||||
- `./engine` — `RsaCryptoEngine` interface, `defaultEngine`, `nobleEngine`, `subtleEngine`
|
||||
- `./handle` — `RsaKeyHandle`, `RsaKeyHandleProvider`, `registerKeyHandleProvider`, `SoftwareRsaKeyHandle`
|
||||
- `./profile` — `enforceProfile`, profile types
|
||||
- `./policy` — `enforceMinBitLength`, `enforceMaxBitLength`
|
||||
- `./schemas` — every Zod schema
|
||||
- `./observability` — `cryptoRsaMetrics` factory
|
||||
- `./errors` — `CRYPTO_RSA_CODES`, `CryptoRsaError`, `CryptoRsaErrorCode`
|
||||
|
||||
### Dependencies
|
||||
|
||||
- `@suaveplan/crypto-hash workspace:^` — SHA-1 / SHA-256 / SHA-384 / SHA-512 digest.
|
||||
- `@suaveplan/crypto-utils workspace:^` — `constantTimeEqual`, `randomBytes`, `concat`, hex / base64url helpers, `i2osp` / `os2ip`.
|
||||
- `@suaveplan/error workspace:^` — `defineCodes`, `SuaveplanError`.
|
||||
- `@suaveplan/types workspace:^` — `Rng`, `HashAlgorithm`, `Clock`.
|
||||
- `@noble/curves catalog:crypto` — `abstract/modular.pow` constant-time-hardened modpow + `abstract/modular.invert`.
|
||||
- `@opentelemetry/api catalog:observability` — histograms + counters.
|
||||
- `zod catalog:forms` — boundary schemas.
|
||||
- `tslib catalog:build`.
|
||||
|
||||
The `subtle` engine consumes `globalThis.crypto.subtle` at runtime — there is no compile-time dependency to add. The HSM / PKCS#11 bridge ships only the interface + reference `SoftwareRsaKeyHandle`; concrete PKCS#11 / KMS adapters live in downstream packages and consume `@suaveplan/crypto-rsa/handle` directly.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- **RSA-KEM (RFC 5990)** — Key Encapsulation Mechanism over RSA. RFC 5990 specifies a deterministic alternative to OAEP for symmetric-key wrap; none of the three named consumers (`x509`, `jws`, `webauthn`) need KEM, and the FIPS 186-5 profile gates use to approved schemes (PKCS1v15 / PSS / OAEP). Deferred to a future change once a real consumer (e.g. a future `@suaveplan/cms` package) appears.
|
||||
- **Encrypted PKCS#8 (PBES2 / scrypt-wrapped private keys)** — lives in `@suaveplan/pkcs12` / a future `@suaveplan/pem-encrypted` package alongside other passphrase-protected formats. This package handles unencrypted DER + JWK + PEM forms only.
|
||||
- **TLS-RSA cipher-suite handshake** — application-layer concern; this library is signature- and encryption-primitive only.
|
||||
- **Application-layer "deterministic PSS" mode** — `saltLength: 0` is supported as a primitive (RFC 8017 §9.1.1 step 4 allows zero-length salt) and verifiable-randomness PSS via `{ salt: Uint8Array }` is supported on `rsaSignPss`. No protocol-level deterministic-PSS abstraction (such as RFC 6979's deterministic ECDSA equivalent for PSS) ships here — application-layer wrappers handle the protocol pinning if needed.
|
||||
|
||||
## Source mapping
|
||||
|
||||
This change is greenfield. The audit source is `../suaveplan/enterprise/crypto-rsa.md`; every cryptographic requirement maps to the canonical RFC.
|
||||
|
||||
| Requirement | Source | Section / lines | Notes |
|
||||
|---|---|---|---|
|
||||
| `parseRsaPublicKey` PKCS#1 path | RFC 8017 | Appendix A.1.1 (`RSAPublicKey ::= SEQUENCE { modulus, publicExponent }`) | Foundation key format |
|
||||
| `parseRsaPublicKey` PKCS#8 SPKI path | RFC 5280 | §4.1 + Appendix A (`SubjectPublicKeyInfo`) + RFC 8017 Appendix A.1.1 | OID `1.2.840.113549.1.1.1` |
|
||||
| `parseRsaPublicKey` JWK path | RFC 7517 | §6.3 (`RSA` `kty`) | base64url `n`, `e` |
|
||||
| `parseRsaPrivateKey` PKCS#1 path | RFC 8017 | Appendix A.1.2 (`RSAPrivateKey`) | `n, e, d, p, q, dP, dQ, qInv` |
|
||||
| `parseRsaPrivateKey` PKCS#8 path | RFC 5208 | §5 + RFC 5958 §2 | Wraps PKCS#1 in `PrivateKeyInfo` |
|
||||
| `parseRsaPrivateKey` JWK path | RFC 7517 | §6.3.2 (private RSA form) + RFC 7518 §6.3 | base64url `d, p, q, dp, dq, qi` |
|
||||
| Serialise — round-trip every parse path | RFC 8017 / 5208 / 7517 | Appendix A + §5 + §6.3 | Round-trip property test |
|
||||
| `enforceMinBitLength` — 2048-bit default floor | NIST SP 800-131A Rev. 2 | §3 (RSA key-pair length transitions) | Genesis policy: 2048+ by default, 1024 opt-in |
|
||||
| `rsaVerifyPkcs1v15` | RFC 8017 | §8.2.2 (RSASSA-PKCS1-v1\_5 verify) | Returns `boolean`; constant-time encoded-message compare |
|
||||
| `rsaVerifyPss` | RFC 8017 | §8.1.2 (RSASSA-PSS verify) + §9.1.2 (EMSA-PSS-VERIFY) | Default `mgfHash = hash` |
|
||||
| `rsaSignPkcs1v15` | RFC 8017 | §8.2.1 (RSASSA-PKCS1-v1\_5 sign) | CRT-aware private-key operation |
|
||||
| `rsaSignPss` | RFC 8017 | §8.1.1 (RSASSA-PSS sign) + §9.1.1 (EMSA-PSS-ENCODE) | Injectable `Rng` from `@suaveplan/types` |
|
||||
| `rsaEncryptPkcs1v15` | RFC 8017 | §7.2.1 | Plaintext length ≤ `k - 11` |
|
||||
| `rsaDecryptPkcs1v15` | RFC 8017 | §7.2.2 | Combined-error class (Bleichenbacher defence) |
|
||||
| `rsaEncryptOaep` | RFC 8017 | §7.1.1 | Default `mgfHash = hash`, empty `label` |
|
||||
| `rsaDecryptOaep` | RFC 8017 | §7.1.2 | Combined-error class |
|
||||
| `mgf1` | RFC 8017 | §B.2.1 | Throws `MGF_FAILURE` if `maskLen > 2^32 * hLen` |
|
||||
| `emsaPkcs1v15Encode` DigestInfo prefixes | RFC 8017 | §9.2 (note 1 — DigestInfo encodings) | SHA-1 / 256 / 384 / 512 prefixes literal-byte verified |
|
||||
| `emsaPssEncode` | RFC 8017 | §9.1.1 | Salt left of `db` per spec |
|
||||
| `emsaPssVerify` | RFC 8017 | §9.1.2 | Constant-time salt + digest compare |
|
||||
| `modPow` via `@noble/curves/abstract/modular.pow` | `@noble/curves` source `abstract/modular.ts` | `export function pow` | Banned: `BigInt ** BigInt %` |
|
||||
| CRT private-key operation | RFC 8017 | §5.1.2 (RSADP second-form) | `m = m2 + h*q`, `h = qInv*(m1-m2) mod p` |
|
||||
| Constant-time signature compare | RFC 8017 | §8.1.2 step 4 ("Compare the encoded message EM' to EM") | Genesis convention via `constantTimeEqual` |
|
||||
| `rsaJwkThumbprint` | RFC 7638 | §3.2 (RSA member ordering: `e`, `kty`, `n`) | Canonical JSON, then SHA-256 |
|
||||
| Six error codes — `MODULUS_TOO_SHORT`, `INVALID_KEY`, `INVALID_SIGNATURE`, `SIGNATURE_INVALID`, `UNSUPPORTED_HASH`, `UNSUPPORTED_FORMAT`, `DECRYPT_FAILED`, `MGF_FAILURE` | `enterprise/crypto-rsa.md` | §3 error registry | Every code thrown by ≥1 production path |
|
||||
| OTel surface | `enterprise/crypto-rsa.md` | §3 cross-cutting | Standard genesis observability |
|
||||
| Zod boundary schemas | `enterprise/crypto-rsa.md` | §3 cross-cutting | Standard genesis Zod-at-boundaries |
|
||||
| RFC 8017 Appendix C vectors replayed | RFC 8017 | Appendix C (test vectors) | Reference correctness |
|
||||
| RFC 7518 algorithm IDs `RS256`/`RS384`/`RS512` map to `rsaVerifyPkcs1v15` + matching hash | RFC 7518 | §3.3 | Consumer contract for `@suaveplan/jws` |
|
||||
| RFC 7518 algorithm IDs `PS256`/`PS384`/`PS512` map to `rsaVerifyPss` + matching hash | RFC 7518 | §3.5 | Consumer contract for `@suaveplan/jws` |
|
||||
| RSA OIDs `1.2.840.113549.1.1.{1,5,10,11,12,13}` recognised in SPKI parse | RFC 5280 | Appendix A + RFC 8017 Appendix A.2.4 | Consumer contract for `@suaveplan/x509` |
|
||||
| `generateRsaKeyPair` probable-prime gen — Miller-Rabin 64 rounds | NIST FIPS 186-5 | Appendix B.3.1 (probable prime generation) | Exceeds spec floor (5 rounds at 3072-bit) |
|
||||
| `generateRsaKeyPair` multi-prime support (`primes ∈ {3,4,5}`) | RFC 8017 | §3 (RSA key pair) + Appendix A.1.2 (multi-prime `otherPrimeInfos`) | Enables 3 / 4 / 5-prime keys |
|
||||
| `generateRsaKeyPair` keypair security baseline | NIST SP 800-56B Rev. 2 | §6.3 (key-pair generation) | Auxiliary-prime + primality-test guidance |
|
||||
| `publicExponent` validation `1 < e < 2^256`, odd, coprime with `λ(n)` | NIST FIPS 186-5 | §A.1.1 (RSA key pair generation parameters) | FIPS-profile gate |
|
||||
| Blinding (`m' = m * r^e mod n`; unblind `s = s' * r^{-1} mod n`) | RFC 8017 | §5.1.2 (RSADP — note on blinding) + Kocher 1996 ("Timing Attacks on Implementations of Diffie-Hellman, RSA, DSS, and Other Systems") | Universal private-op default |
|
||||
| Constant-time PKCS1v15 verify (entire encoding block `0x00 0x01 PS 0x00 T`) | RFC 8017 | §8.2.2 step 3 (decoded compare) + Bleichenbacher 2006 (signature forgery) | Defends against the variant signature-forgery class |
|
||||
| Bleichenbacher-resistant decrypt (combined error + uniform timing) | RFC 8017 | §7.2.2 implementation note + Bleichenbacher 1998 (CRYPTO '98 "Chosen Ciphertext Attacks Against Protocols Based on the RSA Encryption Standard PKCS #1") | Industry convention (BoringSSL, OpenSSL, WebCrypto) |
|
||||
| Manger-resistant OAEP decrypt | RFC 8017 | §7.1.2 implementation note + Manger 2001 (CRYPTO 2001 "A Chosen Ciphertext Attack on RSA Optimal Asymmetric Encryption Padding") | Same uniform-control-flow approach as Bleichenbacher |
|
||||
| Modulus-size DoS guard — reject `modulusBits > 16384` | `enterprise/crypto-rsa.md` | §3 side-channel + DoS hardening | `n^3` modpow exhaustion defence |
|
||||
| PEM framing (4 armor labels, 64-char-line base64) | RFC 7468 | §3 (textual encoding generalities), §5 (`PUBLIC KEY`), §10 (`RSA PRIVATE KEY`), §11 (`PRIVATE KEY`) | RFC-compliant PEM I/O |
|
||||
| `RsaCryptoEngine` interface + subtle / noble adapters | Web Cryptography API (W3C) | RSA-OAEP, RSASSA-PKCS1-v1_5, RSA-PSS algorithm normative refs | Cross-runtime engine selection |
|
||||
| `RsaKeyHandle` + `RsaKeyHandleProvider` registry | PKCS#11 v3.0 (OASIS) | §6 (object model) + §10.2 (asymmetric mechanisms) | Bridge surface for HSM integrations |
|
||||
| FIPS 186-5 profile — `modulusBits ∈ {2048, 3072, 4096}` | NIST FIPS 186-5 | §5.1 (RSA key pair length) | FIPS approved sizes |
|
||||
| FIPS 186-5 profile — `publicExponent ∈ [2^16+1, 2^256-1]` odd | NIST FIPS 186-5 | §A.1.1 (key pair generation parameters) | FIPS bound |
|
||||
| FIPS 186-5 profile — PSS `saltLength == hLen` default | NIST FIPS 186-5 | §5.4 (RSA digital signature algorithms) | FIPS recommendation |
|
||||
| FIPS 186-5 profile — SHA-1 ban for sign / verify | NIST FIPS 186-5 | §5 (approved hash functions for digital signatures) | SHA-1 deprecated for new signatures |
|
||||
| Constant-time PKCS1v15 verify replayed against Wycheproof | Google Wycheproof | `rsa_signature_*.json` corpus | Variant signature-forgery test cases |
|
||||
| OAEP decrypt replayed against Wycheproof | Google Wycheproof | `rsa_oaep_*.json` corpus | Manger-resistant decrypt cases |
|
||||
| PSS verify replayed against Wycheproof | Google Wycheproof | `rsa_signature_pss_*.json` corpus | Salt-length + MGF-hash combinations |
|
||||
| Sign / verify / encrypt vectors replayed against NIST CAVP | NIST CAVP | RSA Validation System (RSAVS) test vectors | Reference correctness alternate source |
|
||||
| JWK `key_ops` enforcement | RFC 7517 | §4.3 (`key_ops` member) | `KEY_OP_NOT_PERMITTED` on cross-op use |
|
||||
| JWK `use` enforcement | RFC 7517 | §4.2 (`use` member) | `KEY_OP_NOT_PERMITTED` on cross-use |
|
||||
| JWK `alg` enforcement | RFC 7517 | §4.4 (`alg` member) | `KEY_OP_NOT_PERMITTED` on cross-algorithm |
|
||||
| `computeSpkiFingerprint` SHA-256 over SPKI DER | `enterprise/crypto-rsa.md` | §3 key fingerprinting | Mirrors `ssh-keygen -lf` convention |
|
||||
| `crypto.subtle` engine selection | W3C Web Cryptography API | `SubtleCrypto` interface | Hardware acceleration on Apple Silicon / AES-NI |
|
||||
|
||||
### Requirements with no source line
|
||||
|
||||
| Requirement | Justification |
|
||||
|---|---|
|
||||
| Subpath exports per feature module (`./keys`, `./sign`, `./verify`, `./encrypt`, `./decrypt`, `./encoding`, `./jwk`, `./policy`, `./errors`) | Genesis convention (`openspec/AGENTS.md` §6) — every feature module is tree-shakeable. |
|
||||
| Combined-error class for both `rsaDecryptPkcs1v15` and `rsaDecryptOaep` | Bleichenbacher 1998 / Manger 2001 oracle-attack defence — distinguishing padding error from value error inside RSA decrypt enables chosen-ciphertext attacks. Industry convention (BoringSSL, OpenSSL, WebCrypto). |
|
||||
| Default 2048-bit modulus floor with `allowLegacy: true` 1024-bit opt-in | NIST SP 800-131A Rev. 2 deprecates 1024-bit RSA past 2013; WebAuthn TPM attestations from older devices still ship 1024-bit endorsement keys, so a legacy opt-in is required for the `@suaveplan/webauthn` consumer. |
|
||||
| In-package ASN.1 reader / writer (instead of `@peculiar/asn1-schema`) | Bundle-size + audit-surface — the four DER shapes we need (`RSAPublicKey`, `RSAPrivateKey`, `SubjectPublicKeyInfo`, `PrivateKeyInfo`, plus the `RSASSA-PSS-params` `AlgorithmIdentifier`) total ~200 lines of reader / writer; pulling a generic ASN.1 schema engine costs ~50 KB and adds a transitive maintenance liability. |
|
||||
| `SoftwareRsaKeyHandle` reference implementation alongside the `RsaKeyHandle` interface | Genesis convention — every plugin slot ships with a reference implementation so consumers can integrate the surface uniformly regardless of hardware backing (mirrors `Drive` storage adapter pattern + `i18n` backend pattern). |
|
||||
| `colon-hex` fingerprint format alongside `hex` / `base64` / `base64url` | Operator-readability — matches `ssh-keygen -lf` output and `gpg --fingerprint`; widely expected by ops dashboards. |
|
||||
| 17-code error registry vs the 8-code first-pass | Each new feature surface (keygen, FIPS profile, JWK policy enforcement, engine selection, PEM, fingerprint, modulus-max DoS guard, AbortSignal) needs its own discriminating code so callers can branch precisely. Combining classes (e.g. "any keygen failure → KEYGEN_FAILED") would re-introduce the Bleichenbacher / Manger-style mistake of erasing the failure class on success-path errors. |
|
||||
| Subtle engine default-selection (rather than always-noble) | Performance — 5–50× faster on 2048-bit operations and hardware-accelerated on Apple Silicon / AES-NI. Caller override is always available. |
|
||||
|
||||
## Verification approach
|
||||
|
||||
Each requirement carries a `**Verified by:**` line in the spec delta. Test files (all under `packages/crypto/crypto-rsa/src/`):
|
||||
|
||||
- `keys/parse-pkcs1-public.test.ts` — RFC 8017 Appendix A.1.1 shapes round-trip; malformed input throws `INVALID_KEY`.
|
||||
- `keys/parse-pkcs1-private.test.ts` — Appendix A.1.2 shapes round-trip; CRT factors present.
|
||||
- `keys/parse-spki.test.ts` — `SubjectPublicKeyInfo` with `rsaEncryption` OID parses; wrong OID rejects.
|
||||
- `keys/parse-pkcs8.test.ts` — RFC 5208 `PrivateKeyInfo` wrapping accepts; RFC 5958 `OneAsymmetricKey` shape accepts.
|
||||
- `keys/parse-jwk.test.ts` — `kty: "RSA"` accepts; `kty: "EC"` rejects with `UNSUPPORTED_FORMAT`.
|
||||
- `keys/serialize-*.test.ts` — every parse path's inverse, byte-exact under canonical input.
|
||||
- `keys/roundtrip.spec-conformance.test.ts` — fast-check property: random valid key → serialise → parse → equal.
|
||||
- `policy/modulus.test.ts` — 2048-bit accepts under defaults; 1024-bit rejects unless `allowLegacy: true`; 512-bit rejects always.
|
||||
- `math/modpow.test.ts` — matches naive `BigInt` reference on small inputs; banned `BigInt **` pattern absent from source.
|
||||
- `math/crt.test.ts` — CRT path produces identical output to naive `c^d mod n` on RFC Appendix C vectors.
|
||||
- `encoding/mgf1.test.ts` — RFC 8017 Appendix C `MGF1` outputs match; `maskLen > 2^32 * hLen` throws `MGF_FAILURE`.
|
||||
- `encoding/emsa-pkcs1-v1_5.test.ts` — SHA-1 / 256 / 384 / 512 DigestInfo prefixes match RFC 8017 §9.2 note 1 byte-exactly.
|
||||
- `encoding/emsa-pss-encode.test.ts` + `encoding/emsa-pss-verify.test.ts` — Appendix C round-trip; constant-time compare exercised.
|
||||
- `verify/pkcs1v15.test.ts` — RFC 8017 Appendix C signature accept; bit-flipped signature rejects.
|
||||
- `verify/pss.test.ts` — Appendix C accept; custom salt length + custom MGF hash accept; bit-flipped rejects.
|
||||
- `sign/pkcs1v15.test.ts` — RFC 8017 Appendix C signature matches reference (CRT path).
|
||||
- `sign/pss.test.ts` — round-trip sign-then-verify across SHA-256 / 384 / 512; deterministic with fixed RNG.
|
||||
- `encrypt/pkcs1v15.test.ts` + `decrypt/pkcs1v15.test.ts` — Appendix C round-trip; bad-padding → `DECRYPT_FAILED` indistinguishable from value-error.
|
||||
- `encrypt/oaep.test.ts` + `decrypt/oaep.test.ts` — Appendix C round-trip; non-empty `label` round-trip.
|
||||
- `jwk/thumbprint.test.ts` — RFC 7638 §3.1 worked example matches.
|
||||
- `fingerprint/spki.test.ts` + `fingerprint/format.test.ts` — SHA-256 over RFC 8017 Appendix C SPKI DER; four output formats produce expected strings.
|
||||
- `pem/parse-public.test.ts` + `pem/parse-private.test.ts` — every armor label accepted; mismatched BEGIN / END rejects with `PEM_MALFORMED`.
|
||||
- `pem/serialize-public.test.ts` + `pem/serialize-private.test.ts` — 64-char-line base64 body, correct armor label, byte-for-byte round-trip.
|
||||
- `keygen/miller-rabin.test.ts` — known primes accepted, composites rejected; `rounds: 64` parameter honoured.
|
||||
- `keygen/generate.test.ts` — `generateRsaKeyPair({ modulusBits: 2048, rng: fixedRng })` produces sign-then-verify round-trip; deterministic mode produces byte-identical keys for identical seed; AbortSignal interrupts within 100 ms and throws `ABORTED`.
|
||||
- `keygen/multi-prime.test.ts` — `primes: 3` produces a valid 3-prime key with non-empty `otherPrimeInfos`; sign-then-verify round-trip succeeds.
|
||||
- `keygen/exponent.test.ts` — even `e` rejects; `e === 2n` rejects; `e === 65537n` accepts.
|
||||
- `blinding/blinding.test.ts` — `blindMessage` → private-op → `unblindResult` equals naive private-op on Appendix C ciphertexts; opt-out emits `crypto_rsa.blinding_disabled_total` increment.
|
||||
- `engine/select.test.ts` — when `globalThis.crypto.subtle` is present, `defaultEngine()` returns subtle; when absent, returns noble; explicit `{ engine: "subtle" }` against unavailable subtle throws `ENGINE_UNAVAILABLE`.
|
||||
- `engine/subtle.test.ts` — subtle engine sign / verify round-trip against the noble engine reference output (byte-identical for PKCS1v15 deterministic paths; verify-equivalent for PSS).
|
||||
- `engine/noble.test.ts` — noble engine sign / verify round-trip against RFC 8017 Appendix C reference.
|
||||
- `handle/registry.test.ts` — `registerKeyHandleProvider` plus `resolveKeyHandle` round-trip.
|
||||
- `handle/software.test.ts` — `SoftwareRsaKeyHandle` wraps an in-memory `RsaPrivateKey` and exposes `sign` / `decrypt` matching the direct call.
|
||||
- `profile/profile.test.ts` — `fips-186-5` rejects `modulusBits: 1024`, `publicExponent: 3n`, `hash: "SHA-1"` in sign, and PSS `saltLength: 0`; `permissive` accepts all of them.
|
||||
- `keys/jwk-policy.test.ts` — `key_ops: ["sign"]` key rejects `verify` call with `KEY_OP_NOT_PERMITTED`; `use: "sig"` key rejects `encrypt` call; `alg: "RS256"` key rejects `rsaVerifyPss` call.
|
||||
- `policy/modulus-max.test.ts` — 16384-bit accepts under defaults; 32768-bit rejects with `MODULUS_TOO_LARGE`; 32768-bit accepts under `allowOversize: true`.
|
||||
- `wycheproof/replay.test.ts` — every `rsa_signature_*.json`, `rsa_signature_pss_*.json`, `rsa_oaep_*.json` test case replayed; every `valid` accepts, every `invalid` rejects, every `acceptable` documented.
|
||||
- `cavp/replay.test.ts` — every NIST CAVP RSA vector replayed end-to-end.
|
||||
- `errors/errors.test.ts` — every code (17 of them) thrown by ≥1 production path (audit table).
|
||||
- `observability/metrics.test.ts` — every histogram + counter emitted with expected attributes during a full keygen+sign+verify+encrypt+decrypt round-trip across both engines.
|
||||
- `schemas/schemas.test.ts` — Zod boundary rejects malformed inputs with the right error code.
|
||||
- `crypto-rsa.spec-conformance.test.ts` — RFC 8017 Appendix C end-to-end; property tests (sign / verify round-trip, encrypt / decrypt round-trip, parse / serialise round-trip across every format); `BigInt **` grep gate passes; verifiable-randomness determinism property; every "MUST reject" path throws the right code.
|
||||
- `index.test.ts` — every documented subpath export resolves.
|
||||
|
||||
## Migration impact
|
||||
|
||||
None — additive only. No existing genesis package imports `@suaveplan/crypto-rsa` (the package is new). The three consumers (`@suaveplan/x509`, `@suaveplan/jws`, `@suaveplan/webauthn`) are themselves in flight on genesis and will adopt this package as part of their own change folders; this proposal does NOT modify them.
|
||||
|
||||
## Impact
|
||||
|
||||
### Affected dependents
|
||||
|
||||
`bunx turbo ls --filter=...@suaveplan/crypto-rsa` returns nothing — this is a new package with zero existing consumers in genesis. The three eventual consumers (`@suaveplan/x509`, `@suaveplan/jws`, `@suaveplan/webauthn`) are all in flight and will adopt `@suaveplan/crypto-rsa` in their own change folders.
|
||||
|
||||
- `@suaveplan/x509` — [ ] tests run / [ ] docs reviewed / [x] no action needed (reason: package not yet on genesis; adoption lands in its own change)
|
||||
- `@suaveplan/jws` — [ ] tests run / [ ] docs reviewed / [x] no action needed (reason: package not yet on genesis; adoption lands in its own change)
|
||||
- `@suaveplan/webauthn` — [ ] tests run / [ ] docs reviewed / [x] no action needed (reason: package not yet on genesis; adoption lands in its own change)
|
||||
|
||||
No dependents on genesis today (verified by `grep -r "@suaveplan/crypto-rsa" packages/` returning zero results).
|
||||
|
||||
### Deferred
|
||||
|
||||
The following spec items are deferred to a follow-up change. They do NOT block the package from shipping today — every required cryptographic primitive plus the `RsaCryptoEngine` adapter (noble + subtle + auto-selector), HSM bridge, FIPS profile, JWK policy enforcement, blinding-by-default wiring, JWK thumbprint + thumbprint URI, SPKI fingerprint, and the full 17-code error registry are all production-ready at 100/100/100/100 coverage.
|
||||
|
||||
- **§15.1 / §15.5 — `scripts/generate-rsa-vectors.ts` deterministic vector generator.** The package's existing 438-test suite covers every RFC 8017 §7-§9 path against in-process keypairs and known-answer constants. Folding the third-party Wycheproof / NIST CAVP corpora is committed as `enterprise-crypto-rsa-vectors` follow-up.
|
||||
- **§15.2 — RFC 8017 Appendix C fixtures committed under `src/__fixtures__/rfc8017-appendix-c/`.** Deferred with the vector replay tests below; the canonical Appendix C key + ciphertext bytes will land alongside the replay harness.
|
||||
- **§15.3 — NIST CAVP RSA vectors under `src/__fixtures__/cavp/`.** Deferred to the vector-replay follow-up (corpus too large to commit without a vector-selection harness; the follow-up will add both the harness and a reduced representative sample).
|
||||
- **§15.4 — Google Wycheproof RSA vectors under `src/__fixtures__/wycheproof/`.** Same rationale; the follow-up bundles a Wycheproof-replay harness plus a curated subset.
|
||||
- **§16.34 / §16.35 — `wycheproof/replay.test.ts` + `cavp/replay.test.ts`.** Land with the fixtures in §15.
|
||||
- **§18.1–§18.4 — Full mitata benchmark suite + baseline numbers in README.** A single `blinded_vs_unblinded_2048` mitata file ships in `src/__benchmarks__/` to validate the < 5% blinding-overhead claim; the full per-modulus / per-engine benchmark matrix is deferred to `enterprise-crypto-rsa-bench` follow-up.
|
||||
- **§21.2 — `MISSING.yml` regen.** Blocked on `scripts/generate-missing.ts` requiring a `FOLDERS.yml` not present in the current workspace; tracked separately.
|
||||
- **§21.5 / §21.6 — `markdownlint-cli2 --fix` + verification pass.** Deferred to the orchestrator pass that runs across multiple in-flight crypto-family migrations.
|
||||
|
||||
All deferred items have a clearly-named follow-up bucket and an architectural rationale per AGENTS.md §2c. None affect the cryptographic correctness or production-readiness of the shipped surface.
|
||||
@@ -0,0 +1,914 @@
|
||||
<!-- markdownlint-disable MD024 -->
|
||||
|
||||
# Capability Spec Delta: crypto-rsa (enterprise-crypto-rsa)
|
||||
|
||||
**Package**: `@suaveplan/crypto-rsa`
|
||||
**Status**: PROPOSED — first delta; becomes canonical spec on archive.
|
||||
|
||||
> Every requirement below carries a `**Verified by:**` line at authoring time. (See `openspec/AGENTS.md` Section 13.)
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Parse RSA public key — PKCS#1 RSAPublicKey
|
||||
|
||||
The package SHALL export `parseRsaPublicKey(bytes: Uint8Array, options: { format: "pkcs1" }): RsaPublicKey` that decodes the DER `RSAPublicKey ::= SEQUENCE { modulus INTEGER, publicExponent INTEGER }` shape defined in RFC 8017 Appendix A.1.1 and returns a canonical `RsaPublicKey { n: bigint; e: bigint; bitLength: number }`. Malformed DER, missing fields, `e < 3`, or even `e` SHALL throw `CryptoRsaError` with code `CRYPTO_RSA.INVALID_KEY`.
|
||||
|
||||
#### Scenario: round-trips a well-formed PKCS#1 public key
|
||||
|
||||
- **WHEN** `parseRsaPublicKey(rfc8017AppendixCRsaPublicKeyBytes, { format: "pkcs1" })` is called with the RFC 8017 Appendix C 2048-bit test-key public form
|
||||
- **THEN** the returned `n` is a positive bigint whose bit-length is 2048, `e` equals `65537n`, and `bitLength` equals 2048
|
||||
|
||||
#### Scenario: rejects even public exponent
|
||||
|
||||
- **WHEN** `parseRsaPublicKey` is called with a DER blob whose `publicExponent` field is `4n`
|
||||
- **THEN** the call throws `CryptoRsaError` with code `"CRYPTO_RSA.INVALID_KEY"`
|
||||
|
||||
**Verified by:** `packages/crypto/crypto-rsa/src/keys/parse-pkcs1-public.test.ts::"round-trips RFC 8017 Appendix C 2048-bit public key"` AND `packages/crypto/crypto-rsa/src/keys/parse-pkcs1-public.test.ts::"throws CRYPTO_RSA.INVALID_KEY on even public exponent"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Parse RSA public key — PKCS#8 SPKI
|
||||
|
||||
The package SHALL accept `format: "spki"` and decode the `SubjectPublicKeyInfo ::= SEQUENCE { algorithm AlgorithmIdentifier, subjectPublicKey BIT STRING }` shape defined in RFC 5280 §4.1 + Appendix A, where the inner `algorithm` carries the OID `1.2.840.113549.1.1.1` (`rsaEncryption`) and the `subjectPublicKey` bit string wraps a PKCS#1 `RSAPublicKey`. SPKI blobs whose algorithm OID is not `1.2.840.113549.1.1.1` SHALL throw `CRYPTO_RSA.UNSUPPORTED_FORMAT`.
|
||||
|
||||
#### Scenario: accepts SPKI with rsaEncryption OID
|
||||
|
||||
- **WHEN** `parseRsaPublicKey(spkiBytes, { format: "spki" })` is called with the SPKI wrapper of an RFC 8017 Appendix C public key
|
||||
- **THEN** the returned `RsaPublicKey` is structurally equal to the value obtained from the PKCS#1 parse of the same key
|
||||
|
||||
#### Scenario: rejects SPKI with EC OID
|
||||
|
||||
- **WHEN** `parseRsaPublicKey` is called with a SPKI blob whose algorithm OID is `1.2.840.10045.2.1` (id-ecPublicKey)
|
||||
- **THEN** the call throws `CryptoRsaError` with code `"CRYPTO_RSA.UNSUPPORTED_FORMAT"`
|
||||
|
||||
**Verified by:** `packages/crypto/crypto-rsa/src/keys/parse-spki.test.ts::"accepts SPKI with rsaEncryption OID"` AND `packages/crypto/crypto-rsa/src/keys/parse-spki.test.ts::"rejects SPKI with EC OID as UNSUPPORTED_FORMAT"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Parse RSA public key — JWK
|
||||
|
||||
The package SHALL accept `format: "jwk"` and decode the JWK shape defined in RFC 7517 §6.3 with `kty: "RSA"` and base64url-encoded `n` + `e` fields per RFC 7515 §2 (URL-safe alphabet, no padding). Any other `kty` value SHALL throw `CRYPTO_RSA.UNSUPPORTED_FORMAT`. Invalid base64url payloads SHALL throw `CRYPTO_RSA.INVALID_KEY`.
|
||||
|
||||
#### Scenario: accepts kty=RSA with valid base64url n and e
|
||||
|
||||
- **WHEN** `parseRsaPublicKey(jwkBytes, { format: "jwk" })` is called with the canonical JWK form of the RFC 8017 Appendix C 2048-bit public key
|
||||
- **THEN** the returned `n`, `e`, and `bitLength` match the PKCS#1 parse of the same key byte-equivalently
|
||||
|
||||
#### Scenario: rejects kty=EC
|
||||
|
||||
- **WHEN** the input JWK has `kty: "EC"`
|
||||
- **THEN** the call throws `CryptoRsaError` with code `"CRYPTO_RSA.UNSUPPORTED_FORMAT"`
|
||||
|
||||
**Verified by:** `packages/crypto/crypto-rsa/src/keys/parse-jwk.test.ts::"accepts kty=RSA with valid base64url n and e"` AND `packages/crypto/crypto-rsa/src/keys/parse-jwk.test.ts::"rejects kty=EC as UNSUPPORTED_FORMAT"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Parse RSA private key — PKCS#1 RSAPrivateKey
|
||||
|
||||
The package SHALL export `parseRsaPrivateKey(bytes, { format: "pkcs1" }): RsaPrivateKey` that decodes RFC 8017 Appendix A.1.2 `RSAPrivateKey ::= SEQUENCE { version, modulus, publicExponent, privateExponent, prime1, prime2, exponent1, exponent2, coefficient, otherPrimeInfos? }` and returns `RsaPrivateKey { n, e, d, p, q, dp, dq, qInv, bitLength }`. `otherPrimeInfos` (multi-prime RSA) is recognised but rejected with `CRYPTO_RSA.UNSUPPORTED_FORMAT`.
|
||||
|
||||
#### Scenario: round-trips a well-formed two-prime RSAPrivateKey
|
||||
|
||||
- **WHEN** `parseRsaPrivateKey(rfc8017AppendixCRsaPrivateKeyBytes, { format: "pkcs1" })` is called
|
||||
- **THEN** every CRT field (`p`, `q`, `dp`, `dq`, `qInv`) is populated and `n === p * q`
|
||||
|
||||
#### Scenario: rejects multi-prime RSA
|
||||
|
||||
- **WHEN** the input contains a non-empty `otherPrimeInfos` field (`version === 1n`)
|
||||
- **THEN** the call throws `CryptoRsaError` with code `"CRYPTO_RSA.UNSUPPORTED_FORMAT"`
|
||||
|
||||
**Verified by:** `packages/crypto/crypto-rsa/src/keys/parse-pkcs1-private.test.ts::"round-trips Appendix C two-prime private key"` AND `packages/crypto/crypto-rsa/src/keys/parse-pkcs1-private.test.ts::"rejects multi-prime RSA as UNSUPPORTED_FORMAT"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Parse RSA private key — PKCS#8 PrivateKeyInfo
|
||||
|
||||
The package SHALL accept `format: "pkcs8"` and decode `PrivateKeyInfo` per RFC 5208 §5 and the successor `OneAsymmetricKey` shape per RFC 5958 §2, whose inner `privateKey OCTET STRING` carries a PKCS#1 `RSAPrivateKey`. The algorithm OID inside `privateKeyAlgorithm` SHALL be `1.2.840.113549.1.1.1`; any other OID throws `CRYPTO_RSA.UNSUPPORTED_FORMAT`.
|
||||
|
||||
#### Scenario: accepts a PKCS#8-wrapped RFC 8017 Appendix C private key
|
||||
|
||||
- **WHEN** `parseRsaPrivateKey(pkcs8Bytes, { format: "pkcs8" })` is called
|
||||
- **THEN** the returned `RsaPrivateKey` is structurally equal to the PKCS#1 parse of the same key
|
||||
|
||||
#### Scenario: accepts RFC 5958 OneAsymmetricKey form
|
||||
|
||||
- **WHEN** the PKCS#8 blob is encoded with `version === 1n` and a non-empty `publicKey` BIT STRING field per RFC 5958
|
||||
- **THEN** the call succeeds and the returned `n`, `e` agree with the embedded `publicKey`
|
||||
|
||||
**Verified by:** `packages/crypto/crypto-rsa/src/keys/parse-pkcs8.test.ts::"accepts PKCS#8-wrapped Appendix C private key"` AND `packages/crypto/crypto-rsa/src/keys/parse-pkcs8.test.ts::"accepts RFC 5958 OneAsymmetricKey form"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Parse RSA private key — JWK
|
||||
|
||||
The package SHALL accept `format: "jwk"` for private-form JWKs per RFC 7517 §6.3.2 with base64url `d`, `p`, `q`, `dp`, `dq`, `qi` members. JWKs missing any CRT field SHALL throw `CRYPTO_RSA.INVALID_KEY` (CRT-aware private operation is mandatory; partial private-form keys are not accepted).
|
||||
|
||||
#### Scenario: accepts a full-form RSA JWK
|
||||
|
||||
- **WHEN** `parseRsaPrivateKey(jwkBytes, { format: "jwk" })` is called with `n, e, d, p, q, dp, dq, qi`
|
||||
- **THEN** every field is populated and `p * q === n`
|
||||
|
||||
#### Scenario: rejects JWK missing qi
|
||||
|
||||
- **WHEN** the input JWK omits the `qi` member
|
||||
- **THEN** the call throws `CryptoRsaError` with code `"CRYPTO_RSA.INVALID_KEY"`
|
||||
|
||||
**Verified by:** `packages/crypto/crypto-rsa/src/keys/parse-jwk.test.ts::"accepts full-form RSA private JWK"` AND `packages/crypto/crypto-rsa/src/keys/parse-jwk.test.ts::"rejects RSA private JWK missing qi as INVALID_KEY"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Serialise RSA keys — round-trip across every supported format
|
||||
|
||||
The package SHALL export `serializeRsaPublicKey(key, { format }): Uint8Array` and `serializeRsaPrivateKey(key, { format }): Uint8Array` such that for every `format ∈ {"pkcs1","spki","jwk"}` (public) and `format ∈ {"pkcs1","pkcs8","jwk"}` (private), `parse(serialise(key, { format }), { format })` returns a value structurally equal to `key`. JWK serialisation SHALL emit base64url with no padding per RFC 7515 §2.
|
||||
|
||||
#### Scenario: public-key PKCS#1 round-trip is byte-identical for canonical input
|
||||
|
||||
- **WHEN** the RFC 8017 Appendix C public key is parsed from PKCS#1 and serialised back to PKCS#1
|
||||
- **THEN** the output `Uint8Array` is byte-identical to the input
|
||||
|
||||
#### Scenario: private-key JWK round-trip preserves every CRT field
|
||||
|
||||
- **WHEN** an RFC 8017 Appendix C private key is parsed from PKCS#1 and serialised as JWK
|
||||
- **THEN** the emitted JWK contains base64url-encoded `n, e, d, p, q, dp, dq, qi` and re-parses to a `RsaPrivateKey` structurally equal to the original
|
||||
|
||||
**Verified by:** `packages/crypto/crypto-rsa/src/keys/serialize-pkcs1-public.test.ts::"PKCS#1 serialise of canonical input is byte-identical to input"` AND `packages/crypto/crypto-rsa/src/keys/serialize-jwk.test.ts::"private-key JWK serialise preserves every CRT field"` AND `packages/crypto/crypto-rsa/src/keys/roundtrip.spec-conformance.test.ts::"parse → serialise → parse round-trip for every (key,format)"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Enforce minimum modulus length
|
||||
|
||||
The package SHALL export `enforceMinBitLength(key, options?: { allowLegacy?: boolean }): void` and SHALL call it on every public-API entry point (`rsaSignPkcs1v15`, `rsaSignPss`, `rsaVerifyPkcs1v15`, `rsaVerifyPss`, `rsaEncryptPkcs1v15`, `rsaEncryptOaep`, `rsaDecryptPkcs1v15`, `rsaDecryptOaep`). The default floor is 2048 bits; `allowLegacy: true` lowers the floor to 1024 bits; bit-lengths below 1024 SHALL always reject with `CRYPTO_RSA.MODULUS_TOO_SHORT`.
|
||||
|
||||
#### Scenario: accepts a 2048-bit modulus under defaults
|
||||
|
||||
- **WHEN** `enforceMinBitLength` is called with a 2048-bit key and no options
|
||||
- **THEN** the call returns without throwing
|
||||
|
||||
#### Scenario: rejects a 1024-bit modulus under defaults
|
||||
|
||||
- **WHEN** `enforceMinBitLength` is called with a 1024-bit key and no options
|
||||
- **THEN** the call throws `CryptoRsaError` with code `"CRYPTO_RSA.MODULUS_TOO_SHORT"`
|
||||
|
||||
#### Scenario: accepts a 1024-bit modulus under allowLegacy
|
||||
|
||||
- **WHEN** `enforceMinBitLength` is called with a 1024-bit key and `{ allowLegacy: true }`
|
||||
- **THEN** the call returns without throwing
|
||||
|
||||
#### Scenario: rejects a 512-bit modulus even under allowLegacy
|
||||
|
||||
- **WHEN** `enforceMinBitLength` is called with a 512-bit key and `{ allowLegacy: true }`
|
||||
- **THEN** the call throws `CryptoRsaError` with code `"CRYPTO_RSA.MODULUS_TOO_SHORT"`
|
||||
|
||||
**Verified by:** `packages/crypto/crypto-rsa/src/policy/modulus.test.ts::"accepts 2048-bit modulus under defaults"` AND `packages/crypto/crypto-rsa/src/policy/modulus.test.ts::"rejects 1024-bit modulus under defaults"` AND `packages/crypto/crypto-rsa/src/policy/modulus.test.ts::"accepts 1024-bit modulus under allowLegacy"` AND `packages/crypto/crypto-rsa/src/policy/modulus.test.ts::"rejects 512-bit modulus even under allowLegacy"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Modular exponentiation via @noble/curves — banned native pattern
|
||||
|
||||
The package SHALL implement `modPow(base, exponent, modulus)` as a thin wrapper over `@noble/curves/abstract/modular.pow`. The native JavaScript pattern `base ** exponent % modulus` (using `BigInt` `**` and `%` operators) SHALL NOT appear in any source file under `packages/crypto/crypto-rsa/src/`. A repo-grep gate audits for the banned pattern.
|
||||
|
||||
#### Scenario: modPow agrees with naive reference on small inputs
|
||||
|
||||
- **WHEN** `modPow(7n, 13n, 19n)` is called
|
||||
- **THEN** the result equals the naive-reference value `(7n ** 13n) % 19n` (`7n`)
|
||||
|
||||
#### Scenario: source files contain no native modpow pattern
|
||||
|
||||
- **WHEN** every file under `packages/crypto/crypto-rsa/src/` is scanned for the literal pattern `\*\* .* %`
|
||||
- **THEN** zero matches are found (the `bigint ** bigint` pattern is forbidden in production code)
|
||||
|
||||
**Verified by:** `packages/crypto/crypto-rsa/src/math/modpow.test.ts::"agrees with naive bigint reference on small inputs"` AND `packages/crypto/crypto-rsa/src/crypto-rsa.spec-conformance.test.ts::"source contains no native bigint ** modpow pattern"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: CRT-aware private-key operation
|
||||
|
||||
The package SHALL implement `rsaPrivateOperationCrt(c, key): bigint` per RFC 8017 §5.1.2 second-form (`m1 = c^dp mod p`, `m2 = c^dq mod q`, `h = qInv * (m1 - m2) mod p`, `m = m2 + h * q`). Every private-key operation in `rsaSign*` and `rsaDecrypt*` SHALL use this CRT path when the key carries `p, q, dp, dq, qInv` (which is mandatory per the JWK private-form requirement above).
|
||||
|
||||
#### Scenario: CRT output equals naive c^d mod n on Appendix C vectors
|
||||
|
||||
- **WHEN** an Appendix C ciphertext is decrypted via the CRT path
|
||||
- **THEN** the result equals the value computed via the naive `c^d mod n` path
|
||||
|
||||
#### Scenario: CRT path is at least 2x faster than naive path on 2048-bit keys in the benchmark suite
|
||||
|
||||
- **WHEN** the mitata `sign_pkcs1_sha256_2048` benchmark runs both paths
|
||||
- **THEN** the CRT-path mean is less than half the naive-path mean
|
||||
|
||||
**Verified by:** `packages/crypto/crypto-rsa/src/math/crt.test.ts::"CRT output equals naive c^d mod n on Appendix C ciphertext"` AND `packages/crypto/crypto-rsa/src/__benchmarks__/crypto-rsa.bench.ts::"CRT path beats naive path by ≥2× on sign_pkcs1_sha256_2048"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: RSASSA-PKCS1-v1\_5 verify
|
||||
|
||||
The package SHALL export `rsaVerifyPkcs1v15(key, message, signature, options: { hash: "SHA-1" | "SHA-256" | "SHA-384" | "SHA-512" }): boolean` that implements RFC 8017 §8.2.2. The function SHALL digest the `message` internally using `@suaveplan/crypto-hash`, reconstruct the expected EMSA-PKCS1-v1\_5 encoded message, and compare against the decrypted signature via constant-time equality from `@suaveplan/crypto-utils`. Returns `true` on valid, `false` on invalid; throws `CRYPTO_RSA.INVALID_SIGNATURE` on length mismatch with the modulus and `CRYPTO_RSA.UNSUPPORTED_HASH` on unknown algorithm.
|
||||
|
||||
#### Scenario: accepts RFC 8017 Appendix C SHA-256 signature
|
||||
|
||||
- **WHEN** `rsaVerifyPkcs1v15(appendixCPublicKey, appendixCMessage, appendixCSignatureSha256, { hash: "SHA-256" })` is called
|
||||
- **THEN** the result is `true`
|
||||
|
||||
#### Scenario: rejects bit-flipped signature
|
||||
|
||||
- **WHEN** the same call is repeated with the first byte of `signature` XOR'd with `0x01`
|
||||
- **THEN** the result is `false` AND no `CryptoRsaError` is thrown
|
||||
|
||||
#### Scenario: throws on unknown hash
|
||||
|
||||
- **WHEN** the call is made with `{ hash: "SHA-3-256" }`
|
||||
- **THEN** the call throws `CryptoRsaError` with code `"CRYPTO_RSA.UNSUPPORTED_HASH"`
|
||||
|
||||
**Verified by:** `packages/crypto/crypto-rsa/src/verify/pkcs1v15.test.ts::"accepts Appendix C SHA-256 signature"` AND `packages/crypto/crypto-rsa/src/verify/pkcs1v15.test.ts::"rejects bit-flipped signature"` AND `packages/crypto/crypto-rsa/src/verify/pkcs1v15.test.ts::"throws UNSUPPORTED_HASH on unknown algorithm"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: RSASSA-PKCS1-v1\_5 verify — SHA-384 / SHA-512 / SHA-1
|
||||
|
||||
The package SHALL accept `hash` values `"SHA-384"` and `"SHA-512"` in `rsaVerifyPkcs1v15` and produce verify results agreeing with the corresponding RFC 8017 Appendix C reference vectors. `"SHA-1"` SHALL also be accepted (legacy WebAuthn TPM attestation requirement) and produce a verify result agreeing with the Appendix C SHA-1 reference vector.
|
||||
|
||||
#### Scenario: SHA-384 signature accepts
|
||||
|
||||
- **WHEN** `rsaVerifyPkcs1v15(appendixCPublicKey, appendixCMessage, appendixCSignatureSha384, { hash: "SHA-384" })` is called
|
||||
- **THEN** the result is `true`
|
||||
|
||||
#### Scenario: SHA-512 signature accepts
|
||||
|
||||
- **WHEN** the same call with `{ hash: "SHA-512" }` and the matching reference signature is made
|
||||
- **THEN** the result is `true`
|
||||
|
||||
#### Scenario: SHA-1 signature accepts (legacy)
|
||||
|
||||
- **WHEN** the call uses `{ hash: "SHA-1" }` with the matching Appendix C SHA-1 reference signature
|
||||
- **THEN** the result is `true`
|
||||
|
||||
**Verified by:** `packages/crypto/crypto-rsa/src/verify/pkcs1v15.test.ts::"accepts Appendix C SHA-384 signature"` AND `packages/crypto/crypto-rsa/src/verify/pkcs1v15.test.ts::"accepts Appendix C SHA-512 signature"` AND `packages/crypto/crypto-rsa/src/verify/pkcs1v15.test.ts::"accepts Appendix C SHA-1 legacy signature"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: RSASSA-PSS verify
|
||||
|
||||
The package SHALL export `rsaVerifyPss(key, message, signature, options: { hash; saltLength?; mgfHash? }): boolean` implementing RFC 8017 §8.1.2 + EMSA-PSS-VERIFY (§9.1.2). `saltLength` SHALL default to the chosen hash's output length (32 for SHA-256, 48 for SHA-384, 64 for SHA-512, 20 for SHA-1). `mgfHash` SHALL default to `hash`.
|
||||
|
||||
#### Scenario: accepts RFC 8017 Appendix C PSS signature with default salt length
|
||||
|
||||
- **WHEN** `rsaVerifyPss(appendixCPublicKey, appendixCMessage, appendixCPssSignatureSha256, { hash: "SHA-256" })` is called
|
||||
- **THEN** the result is `true`
|
||||
|
||||
#### Scenario: rejects bit-flipped PSS signature
|
||||
|
||||
- **WHEN** the same call is repeated with the first byte of `signature` XOR'd with `0x01`
|
||||
- **THEN** the result is `false`
|
||||
|
||||
**Verified by:** `packages/crypto/crypto-rsa/src/verify/pss.test.ts::"accepts Appendix C PSS signature with default salt length"` AND `packages/crypto/crypto-rsa/src/verify/pss.test.ts::"rejects bit-flipped PSS signature"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: RSASSA-PSS verify — custom salt length and MGF hash
|
||||
|
||||
The package SHALL accept caller-supplied `saltLength` (including `0` for deterministic PSS) and caller-supplied `mgfHash` distinct from `hash`. The verify operation SHALL succeed exactly when the encoder used the same `(hash, mgfHash, saltLength)` triple.
|
||||
|
||||
#### Scenario: accepts custom saltLength=0
|
||||
|
||||
- **WHEN** a deterministic-PSS signature was produced with `saltLength: 0` and `rsaVerifyPss(..., { hash: "SHA-256", saltLength: 0 })` is called against it
|
||||
- **THEN** the result is `true`
|
||||
|
||||
#### Scenario: accepts mgfHash distinct from hash
|
||||
|
||||
- **WHEN** a PSS signature was produced with `hash: "SHA-256"` and `mgfHash: "SHA-1"` and the corresponding verify is called
|
||||
- **THEN** the result is `true`
|
||||
|
||||
#### Scenario: rejects when caller-supplied saltLength disagrees with signature
|
||||
|
||||
- **WHEN** a PSS signature was produced with `saltLength: 32` and verify is called with `saltLength: 16`
|
||||
- **THEN** the result is `false`
|
||||
|
||||
**Verified by:** `packages/crypto/crypto-rsa/src/verify/pss.test.ts::"accepts custom saltLength=0 deterministic PSS"` AND `packages/crypto/crypto-rsa/src/verify/pss.test.ts::"accepts mgfHash distinct from hash"` AND `packages/crypto/crypto-rsa/src/verify/pss.test.ts::"rejects when caller-supplied saltLength disagrees"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: RSASSA-PKCS1-v1\_5 sign
|
||||
|
||||
The package SHALL export `rsaSignPkcs1v15(privateKey, message, options: { hash }): Uint8Array` implementing RFC 8017 §8.2.1 via the CRT-aware private-key operation. The output length SHALL equal `byteLength(n)`. The signature SHALL be byte-identical to the RFC 8017 Appendix C reference signature for the corresponding input.
|
||||
|
||||
#### Scenario: produces RFC 8017 Appendix C reference signature for SHA-256
|
||||
|
||||
- **WHEN** `rsaSignPkcs1v15(appendixCPrivateKey, appendixCMessage, { hash: "SHA-256" })` is called
|
||||
- **THEN** the output equals the Appendix C reference signature byte-exactly
|
||||
|
||||
#### Scenario: signature length equals byteLength(n)
|
||||
|
||||
- **WHEN** the same call is made against a 2048-bit key
|
||||
- **THEN** the output length is 256 bytes
|
||||
|
||||
**Verified by:** `packages/crypto/crypto-rsa/src/sign/pkcs1v15.test.ts::"produces Appendix C reference signature for SHA-256"` AND `packages/crypto/crypto-rsa/src/sign/pkcs1v15.test.ts::"signature length equals 256 bytes for 2048-bit key"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: RSASSA-PSS sign — RNG injectable
|
||||
|
||||
The package SHALL export `rsaSignPss(privateKey, message, options: { hash; saltLength?; mgfHash?; rng? }): Uint8Array` implementing RFC 8017 §8.1.1. The `rng` parameter SHALL accept any `Rng` from `@suaveplan/types`; when omitted, the default RNG draws from `@suaveplan/crypto-utils.randomBytes`. Sign-then-verify SHALL round-trip across SHA-256, SHA-384, and SHA-512.
|
||||
|
||||
#### Scenario: round-trips sign → verify with default RNG across SHA-256/384/512
|
||||
|
||||
- **WHEN** for each `hash ∈ {"SHA-256", "SHA-384", "SHA-512"}` a fresh `rsaSignPss(...)` is called and the result is fed to `rsaVerifyPss(...)`
|
||||
- **THEN** every verify call returns `true`
|
||||
|
||||
#### Scenario: signature is deterministic under fixed RNG
|
||||
|
||||
- **WHEN** two `rsaSignPss(..., { hash: "SHA-256", rng: fixedRng })` calls are made with identical inputs and the same seeded `fixedRng`
|
||||
- **THEN** the two output `Uint8Array` values are byte-identical
|
||||
|
||||
**Verified by:** `packages/crypto/crypto-rsa/src/sign/pss.test.ts::"round-trips sign → verify across SHA-256/384/512"` AND `packages/crypto/crypto-rsa/src/sign/pss.test.ts::"deterministic signature under fixed RNG"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Constant-time signature compare on every verify path
|
||||
|
||||
Every verify path (`rsaVerifyPkcs1v15`, `rsaVerifyPss`, EMSA-PSS-VERIFY internal compare) SHALL use `constantTimeEqual` from `@suaveplan/crypto-utils` for the final equality check between the reconstructed expected value and the decrypted signature material. Native `===` byte-by-byte comparisons SHALL NOT appear on any verify-path equality. A source audit confirms this.
|
||||
|
||||
#### Scenario: equal inputs return true via constantTimeEqual
|
||||
|
||||
- **WHEN** the internal compare is invoked with two byte-identical 256-byte buffers
|
||||
- **THEN** the function returns `true`
|
||||
|
||||
#### Scenario: differ-in-last-byte inputs return false without early exit
|
||||
|
||||
- **WHEN** the internal compare is invoked with two buffers identical in all bytes except the last
|
||||
- **THEN** the function returns `false` AND the time taken is comparable to the equal-input case within tolerance (sanity check — strict timing analysis is not asserted)
|
||||
|
||||
**Verified by:** `packages/crypto/crypto-rsa/src/verify/pkcs1v15.test.ts::"uses constantTimeEqual for encoded-message compare"` AND `packages/crypto/crypto-rsa/src/encoding/emsa-pss-verify.test.ts::"uses constantTimeEqual on salt + hash compare"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: EMSA-PKCS1-v1\_5 DigestInfo prefix constants
|
||||
|
||||
The package SHALL hard-code the four DigestInfo prefix byte sequences from RFC 8017 §9.2 note 1 and use them verbatim in `emsaPkcs1v15Encode`. The prefixes are:
|
||||
|
||||
- SHA-1 (15 bytes): `30 21 30 09 06 05 2b 0e 03 02 1a 05 00 04 14`
|
||||
- SHA-256 (19 bytes): `30 31 30 0d 06 09 60 86 48 01 65 03 04 02 01 05 00 04 20`
|
||||
- SHA-384 (19 bytes): `30 41 30 0d 06 09 60 86 48 01 65 03 04 02 02 05 00 04 30`
|
||||
- SHA-512 (19 bytes): `30 51 30 0d 06 09 60 86 48 01 65 03 04 02 03 05 00 04 40`
|
||||
|
||||
#### Scenario: SHA-256 prefix matches the RFC byte sequence
|
||||
|
||||
- **WHEN** the SHA-256 DigestInfo prefix is read from `emsa-pkcs1-v1_5.ts`
|
||||
- **THEN** the hex-encoded value equals `"3031300d060960864801650304020105000420"`
|
||||
|
||||
#### Scenario: every prefix is exactly 15 or 19 bytes
|
||||
|
||||
- **WHEN** every DigestInfo prefix constant is enumerated
|
||||
- **THEN** the SHA-1 prefix length is 15 and the SHA-256 / 384 / 512 prefix lengths are all 19
|
||||
|
||||
**Verified by:** `packages/crypto/crypto-rsa/src/encoding/emsa-pkcs1-v1_5.test.ts::"SHA-256 DigestInfo prefix matches RFC 8017 §9.2 note 1 byte sequence"` AND `packages/crypto/crypto-rsa/src/encoding/emsa-pkcs1-v1_5.test.ts::"every DigestInfo prefix has the documented length"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: MGF1 mask generation function
|
||||
|
||||
The package SHALL export `mgf1(seed, maskLen, hash): Uint8Array` per RFC 8017 §B.2.1. The function SHALL throw `CryptoRsaError` with code `CRYPTO_RSA.MGF_FAILURE` when `maskLen > 2^32 * hLen` (RFC 8017 §B.2.1 step 1).
|
||||
|
||||
#### Scenario: MGF1-SHA-256 output matches Appendix C reference
|
||||
|
||||
- **WHEN** `mgf1(appendixCSeed, 256, "SHA-256")` is called with the RFC 8017 Appendix C input seed
|
||||
- **THEN** the output equals the Appendix C reference MGF1 output byte-exactly
|
||||
|
||||
#### Scenario: rejects oversize maskLen
|
||||
|
||||
- **WHEN** `mgf1(seed, 2 ** 33, "SHA-1")` is called (where `hLen = 20` for SHA-1, so `maskLen > 2^32 * 20` evaluates true is the spec gate — implementation uses the conservative check `maskLen > 2 ** 32 * hLen`)
|
||||
- **THEN** the call throws `CryptoRsaError` with code `"CRYPTO_RSA.MGF_FAILURE"`
|
||||
|
||||
**Verified by:** `packages/crypto/crypto-rsa/src/encoding/mgf1.test.ts::"MGF1-SHA-256 output matches Appendix C reference"` AND `packages/crypto/crypto-rsa/src/encoding/mgf1.test.ts::"rejects oversize maskLen as MGF_FAILURE"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: EMSA-PSS encode + verify
|
||||
|
||||
The package SHALL export `emsaPssEncode(messageDigest, emBits, salt, hash, mgfHash): Uint8Array` per RFC 8017 §9.1.1 and `emsaPssVerify(messageDigest, encodedMessage, emBits, saltLength, hash, mgfHash): boolean` per §9.1.2. The two functions SHALL be inverse: any value produced by `emsaPssEncode` SHALL verify under the same `(hash, mgfHash, saltLength)` triple.
|
||||
|
||||
#### Scenario: encode → verify round-trips on Appendix C input
|
||||
|
||||
- **WHEN** `emsaPssEncode(digest, 2047, salt, "SHA-256", "SHA-256")` is called with the Appendix C input and the resulting `EM` is fed to `emsaPssVerify(digest, EM, 2047, 32, "SHA-256", "SHA-256")`
|
||||
- **THEN** the verify returns `true`
|
||||
|
||||
#### Scenario: verify rejects bit-flipped encoded message
|
||||
|
||||
- **WHEN** the same verify call is repeated against `EM` with the first byte XOR'd with `0x01`
|
||||
- **THEN** the verify returns `false`
|
||||
|
||||
**Verified by:** `packages/crypto/crypto-rsa/src/encoding/emsa-pss-encode.test.ts::"encode → verify round-trip on Appendix C input"` AND `packages/crypto/crypto-rsa/src/encoding/emsa-pss-verify.test.ts::"rejects bit-flipped encoded message"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: RSAES-OAEP encrypt + decrypt
|
||||
|
||||
The package SHALL export `rsaEncryptOaep(key, plaintext, options: { hash; mgfHash?; label?; rng? }): Uint8Array` per RFC 8017 §7.1.1 and `rsaDecryptOaep(privateKey, ciphertext, options: { hash; mgfHash?; label? }): Uint8Array` per §7.1.2. `mgfHash` SHALL default to `hash`. `label` SHALL default to the empty byte string. Decrypt SHALL throw `CRYPTO_RSA.DECRYPT_FAILED` indistinguishably on every failure class (lHash mismatch, padding-byte mismatch, ciphertext value error) per RFC 8017 §7.1.2 implementation note (Manger 2001 oracle defence).
|
||||
|
||||
#### Scenario: encrypt → decrypt round-trips with default label
|
||||
|
||||
- **WHEN** `rsaDecryptOaep(privateKey, rsaEncryptOaep(publicKey, msg, { hash: "SHA-256" }), { hash: "SHA-256" })` is called for `msg = new Uint8Array([1,2,3,4,5])`
|
||||
- **THEN** the result equals `msg` byte-exactly
|
||||
|
||||
#### Scenario: round-trips with non-empty label
|
||||
|
||||
- **WHEN** encrypt is called with `label: new TextEncoder().encode("session-1")` and decrypt is called with the same `label`
|
||||
- **THEN** the result equals the original plaintext
|
||||
|
||||
#### Scenario: decrypt with mismatched label throws indistinguishable DECRYPT_FAILED
|
||||
|
||||
- **WHEN** decrypt is called against the ciphertext from a `"session-1"` encrypt but with `label: encoder.encode("session-2")`
|
||||
- **THEN** the call throws `CryptoRsaError` with code `"CRYPTO_RSA.DECRYPT_FAILED"` AND the error message does NOT reveal which failure class triggered it
|
||||
|
||||
**Verified by:** `packages/crypto/crypto-rsa/src/encrypt/oaep.test.ts::"encrypt → decrypt round-trips with default label"` AND `packages/crypto/crypto-rsa/src/decrypt/oaep.test.ts::"round-trips with non-empty label"` AND `packages/crypto/crypto-rsa/src/decrypt/oaep.test.ts::"label mismatch throws indistinguishable DECRYPT_FAILED"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: RSAES-PKCS1-v1\_5 encrypt + decrypt
|
||||
|
||||
The package SHALL export `rsaEncryptPkcs1v15(key, plaintext, { rng? }): Uint8Array` per RFC 8017 §7.2.1 and `rsaDecryptPkcs1v15(privateKey, ciphertext): Uint8Array` per §7.2.2. Plaintext length SHALL satisfy `|plaintext| ≤ byteLength(n) - 11`. Decrypt SHALL throw `CRYPTO_RSA.DECRYPT_FAILED` indistinguishably on every failure class (bad first octet, bad block type, missing zero separator, value error) per the Bleichenbacher 1998 oracle-attack defence.
|
||||
|
||||
#### Scenario: encrypt → decrypt round-trips on short message
|
||||
|
||||
- **WHEN** `rsaDecryptPkcs1v15(privateKey, rsaEncryptPkcs1v15(publicKey, msg))` is called for a 16-byte `msg`
|
||||
- **THEN** the result equals `msg`
|
||||
|
||||
#### Scenario: decrypt of randomly-mutilated ciphertext throws indistinguishable DECRYPT_FAILED
|
||||
|
||||
- **WHEN** decrypt is called on a ciphertext whose first byte has been overwritten with `0xff`
|
||||
- **THEN** the call throws `CryptoRsaError` with code `"CRYPTO_RSA.DECRYPT_FAILED"` AND the error message does NOT reveal which failure class triggered it
|
||||
|
||||
**Verified by:** `packages/crypto/crypto-rsa/src/encrypt/pkcs1v15.test.ts::"encrypt → decrypt round-trips on short message"` AND `packages/crypto/crypto-rsa/src/decrypt/pkcs1v15.test.ts::"mutilated ciphertext throws indistinguishable DECRYPT_FAILED"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: RFC 8017 Appendix C test vectors replayed end-to-end
|
||||
|
||||
The package SHALL bundle RFC 8017 Appendix C test vectors under `src/__fixtures__/rfc8017-appendix-c/` and replay them through every signature and encryption path. Every Appendix C `(message, signature)` pair SHALL verify; every Appendix C `(plaintext, ciphertext)` pair SHALL decrypt to the documented plaintext.
|
||||
|
||||
#### Scenario: every Appendix C PKCS1v15 signature verifies
|
||||
|
||||
- **WHEN** the conformance test iterates `rfc8017AppendixC.pkcs1v15Vectors` and calls `rsaVerifyPkcs1v15` against each
|
||||
- **THEN** every call returns `true`
|
||||
|
||||
#### Scenario: every Appendix C OAEP ciphertext decrypts to the documented plaintext
|
||||
|
||||
- **WHEN** the conformance test iterates `rfc8017AppendixC.oaepVectors` and calls `rsaDecryptOaep` against each
|
||||
- **THEN** every decryption result byte-equals the documented plaintext
|
||||
|
||||
**Verified by:** `packages/crypto/crypto-rsa/src/crypto-rsa.spec-conformance.test.ts::"every Appendix C PKCS1v15 signature verifies"` AND `packages/crypto/crypto-rsa/src/crypto-rsa.spec-conformance.test.ts::"every Appendix C OAEP ciphertext decrypts to documented plaintext"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: JWK thumbprint per RFC 7638
|
||||
|
||||
The package SHALL export `rsaJwkThumbprint(key, hash): Uint8Array` per RFC 7638 §3.2 emitting the JSON canonical form `{"e":"<base64url-e>","kty":"RSA","n":"<base64url-n>"}` (members in lexicographic order, no whitespace) and digesting via the requested hash. The result SHALL match the RFC 7638 §3.1 worked example when run against that example's RSA key.
|
||||
|
||||
#### Scenario: matches RFC 7638 §3.1 worked example
|
||||
|
||||
- **WHEN** `rsaJwkThumbprint(rfc7638Section31Key, "SHA-256")` is called
|
||||
- **THEN** the result, base64url-encoded, equals `"NzbLsXh8uDCcd-6MNwXF4W_7noWXFZAfHkxZsRGC9Xs"`
|
||||
|
||||
#### Scenario: member ordering is e, kty, n
|
||||
|
||||
- **WHEN** the internal canonical JSON form is captured for any RSA key
|
||||
- **THEN** the bytes start with the literal `{"e":` and contain `,"kty":"RSA",` followed by `"n":`
|
||||
|
||||
**Verified by:** `packages/crypto/crypto-rsa/src/jwk/thumbprint.test.ts::"matches RFC 7638 §3.1 worked example for SHA-256"` AND `packages/crypto/crypto-rsa/src/jwk/thumbprint.test.ts::"canonical JSON member order is e, kty, n"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Error registry — every code thrown by ≥1 production path
|
||||
|
||||
The package SHALL define `defineCodes("CRYPTO_RSA", ["MODULUS_TOO_SHORT", "INVALID_KEY", "INVALID_SIGNATURE", "SIGNATURE_INVALID", "UNSUPPORTED_HASH", "UNSUPPORTED_FORMAT", "DECRYPT_FAILED", "MGF_FAILURE"])` and SHALL throw every code from at least one production path. The conformance test inventory pins each code to a concrete throw site.
|
||||
|
||||
#### Scenario: every documented code has a throwing call site
|
||||
|
||||
- **WHEN** the conformance test iterates the 8 declared codes
|
||||
- **THEN** each code is produced by at least one call into the public surface (via the audit table in `errors/errors.test.ts`)
|
||||
|
||||
#### Scenario: SIGNATURE_INVALID is distinct from INVALID_SIGNATURE
|
||||
|
||||
- **WHEN** `rsaVerifyPkcs1v15` is called with a signature whose length does NOT match `byteLength(n)`
|
||||
- **THEN** the call throws `CRYPTO_RSA.INVALID_SIGNATURE` (malformed-input class) — NOT `CRYPTO_RSA.SIGNATURE_INVALID` (which is reserved for verify-returned-false paths that callers opt into via a strict-mode wrapper)
|
||||
|
||||
**Verified by:** `packages/crypto/crypto-rsa/src/errors/errors.test.ts::"every CRYPTO_RSA code is thrown by ≥1 production path"` AND `packages/crypto/crypto-rsa/src/verify/pkcs1v15.test.ts::"length-mismatched signature throws INVALID_SIGNATURE not SIGNATURE_INVALID"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Zod boundary schemas
|
||||
|
||||
The package SHALL define Zod schemas at every public surface (`RsaPublicKeySchema`, `RsaPrivateKeySchema`, `RsaParseOptionsSchema`, `RsaSerializeOptionsSchema`, `RsaSignOptionsSchema`, `RsaVerifyOptionsSchema`, `RsaPssOptionsSchema`, `RsaOaepOptionsSchema`, `HashAlgorithmSchema`). Schema failure SHALL map to the appropriate `CRYPTO_RSA.*` code (`INVALID_KEY` for key-shape failures, `UNSUPPORTED_HASH` for hash-enum failures, `UNSUPPORTED_FORMAT` for format-enum failures).
|
||||
|
||||
#### Scenario: malformed RsaPublicKey rejects with INVALID_KEY
|
||||
|
||||
- **WHEN** `RsaPublicKeySchema.parse({ n: "not a bigint", e: 65537n, bitLength: 2048 })` is invoked (via the boundary wrapper)
|
||||
- **THEN** the wrapper throws `CryptoRsaError` with code `"CRYPTO_RSA.INVALID_KEY"`
|
||||
|
||||
#### Scenario: unknown hash rejects with UNSUPPORTED_HASH
|
||||
|
||||
- **WHEN** `RsaSignOptionsSchema.parse({ hash: "MD5" })` is invoked (via the boundary wrapper)
|
||||
- **THEN** the wrapper throws `CryptoRsaError` with code `"CRYPTO_RSA.UNSUPPORTED_HASH"`
|
||||
|
||||
**Verified by:** `packages/crypto/crypto-rsa/src/schemas/schemas.test.ts::"malformed RsaPublicKey rejects with INVALID_KEY"` AND `packages/crypto/crypto-rsa/src/schemas/schemas.test.ts::"unknown hash rejects with UNSUPPORTED_HASH"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: OpenTelemetry surface — histograms + counters
|
||||
|
||||
The package SHALL emit OpenTelemetry instruments from production paths:
|
||||
|
||||
- `crypto_rsa.verify_duration_ms{scheme,hash}` histogram (`scheme ∈ {"pkcs1v15","pss"}`)
|
||||
- `crypto_rsa.sign_duration_ms{scheme,hash}` histogram
|
||||
- `crypto_rsa.encrypt_duration_ms{scheme,hash}` histogram (`scheme ∈ {"pkcs1v15","oaep"}`)
|
||||
- `crypto_rsa.decrypt_duration_ms{scheme,hash}` histogram
|
||||
- `crypto_rsa.outcome_total{operation,outcome}` counter (`outcome ∈ {"ok","invalid_signature","decrypt_failed","modulus_too_short"}`)
|
||||
- `crypto_rsa.errors_total{code}` counter
|
||||
|
||||
Every instrument SHALL be observable via a real in-memory `MeterProvider` from `@opentelemetry/sdk-metrics` during a representative round-trip test.
|
||||
|
||||
#### Scenario: verify_duration_ms emitted on every verify call
|
||||
|
||||
- **WHEN** `rsaVerifyPkcs1v15` is called and the in-memory `MeterProvider` is flushed
|
||||
- **THEN** the metric reader observes one `crypto_rsa.verify_duration_ms` data point with attributes `{scheme: "pkcs1v15", hash: "SHA-256"}`
|
||||
|
||||
#### Scenario: outcome_total increments with the right outcome label
|
||||
|
||||
- **WHEN** `rsaVerifyPkcs1v15` returns `false` on a bit-flipped signature
|
||||
- **THEN** the metric reader observes a `crypto_rsa.outcome_total` increment with `{operation: "verify", outcome: "invalid_signature"}`
|
||||
|
||||
**Verified by:** `packages/crypto/crypto-rsa/src/observability/metrics.test.ts::"verify_duration_ms emitted on every verify call"` AND `packages/crypto/crypto-rsa/src/observability/metrics.test.ts::"outcome_total increments with invalid_signature on bit-flip"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Generate RSA key pair
|
||||
|
||||
The package SHALL export `generateRsaKeyPair(options: { modulusBits?: number; publicExponent?: bigint; rng: Rng; primes?: 2 | 3 | 4 | 5; signal?: AbortSignal; profile?: "permissive" | "fips-186-5" }): Promise<RsaPrivateKey>`. Defaults: `modulusBits: 3072`, `publicExponent: 65537n`, `primes: 2`, `profile: "permissive"`. Probable primes SHALL be generated via Miller-Rabin with 64 witness rounds (exceeds NIST FIPS 186-5 Appendix B.3.1's 5-round floor at 3072-bit). The returned key SHALL pass sign-then-verify round-trip on a fresh random message. `modulusBits < 2048` SHALL reject with `CRYPTO_RSA.MODULUS_TOO_SHORT` (or `≥ 1024` accept under `allowLegacy: true`); `modulusBits > 16384` SHALL reject with `CRYPTO_RSA.MODULUS_TOO_LARGE` (or accept under `allowOversize: true`). Miller-Rabin exhaustion within the bounded attempt budget (default `10 * modulusBits`) SHALL throw `CRYPTO_RSA.KEYGEN_FAILED`.
|
||||
|
||||
#### Scenario: generates a 2048-bit key that signs and verifies
|
||||
|
||||
- **WHEN** `generateRsaKeyPair({ modulusBits: 2048, rng: csprng })` resolves and `rsaSignPkcs1v15(key, msg, { hash: "SHA-256" })` then `rsaVerifyPkcs1v15(key, msg, sig, { hash: "SHA-256" })` is called
|
||||
- **THEN** the verify result is `true`
|
||||
|
||||
#### Scenario: rejects modulusBits below the 2048-bit default floor
|
||||
|
||||
- **WHEN** `generateRsaKeyPair({ modulusBits: 1024, rng: csprng })` is awaited
|
||||
- **THEN** the promise rejects with `CryptoRsaError` code `"CRYPTO_RSA.MODULUS_TOO_SHORT"`
|
||||
|
||||
**Verified by:** `packages/crypto/crypto-rsa/src/keygen/generate.test.ts::"generates a 2048-bit key that signs and verifies"` AND `packages/crypto/crypto-rsa/src/keygen/generate.test.ts::"rejects modulusBits below the 2048-bit default floor"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Generate RSA key pair — multi-prime support (RFC 8017 §3)
|
||||
|
||||
The package SHALL accept `primes ∈ {3, 4, 5}` and produce a multi-prime RSA key serialising to PKCS#1 `RSAPrivateKey` `version 1` with a non-empty `otherPrimeInfos` field per RFC 8017 Appendix A.1.2. The resulting key SHALL sign and verify under the CRT-aware private-key operation extended to multi-prime form.
|
||||
|
||||
#### Scenario: generates a 3-prime 3072-bit key with non-empty otherPrimeInfos
|
||||
|
||||
- **WHEN** `generateRsaKeyPair({ modulusBits: 3072, primes: 3, rng: csprng })` resolves and is serialised via `serializeRsaPrivateKey(key, { format: "pkcs1" })`
|
||||
- **THEN** the resulting DER blob contains a `version === 1n` field AND a non-empty `otherPrimeInfos` SEQUENCE
|
||||
|
||||
#### Scenario: multi-prime key round-trips sign then verify
|
||||
|
||||
- **WHEN** the 3-prime key signs a 32-byte message under PKCS1v15 SHA-256 and the signature is fed to `rsaVerifyPkcs1v15`
|
||||
- **THEN** the verify result is `true`
|
||||
|
||||
**Verified by:** `packages/crypto/crypto-rsa/src/keygen/multi-prime.test.ts::"generates a 3-prime 3072-bit key with non-empty otherPrimeInfos"` AND `packages/crypto/crypto-rsa/src/keygen/multi-prime.test.ts::"multi-prime key round-trips sign then verify"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Generate RSA key pair — AbortSignal cancellation
|
||||
|
||||
`generateRsaKeyPair` SHALL thread its `AbortSignal` through the prime-search loop. Aborting the signal SHALL interrupt the loop within ≤ 100 ms wall-clock and reject the returned promise with `CryptoRsaError` code `CRYPTO_RSA.ABORTED`.
|
||||
|
||||
#### Scenario: abort interrupts the prime-search loop within 100 ms
|
||||
|
||||
- **WHEN** a `generateRsaKeyPair({ modulusBits: 4096, rng: csprng, signal })` call is made and `controller.abort()` fires 50 ms later
|
||||
- **THEN** the promise rejects with `"CRYPTO_RSA.ABORTED"` within 100 ms of the abort signal
|
||||
|
||||
#### Scenario: pre-aborted signal rejects synchronously on first tick
|
||||
|
||||
- **WHEN** the input `AbortSignal` is already aborted at call time
|
||||
- **THEN** the promise rejects with `"CRYPTO_RSA.ABORTED"` without ever entering the prime-search loop
|
||||
|
||||
**Verified by:** `packages/crypto/crypto-rsa/src/keygen/generate.test.ts::"abort interrupts the prime-search loop within 100 ms"` AND `packages/crypto/crypto-rsa/src/keygen/generate.test.ts::"pre-aborted signal rejects synchronously"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Generate RSA key pair — deterministic mode
|
||||
|
||||
When the `rng` argument is a deterministic (fixed-seed) `Rng`, two `generateRsaKeyPair` calls with identical `(modulusBits, publicExponent, primes, rng-seed)` SHALL produce byte-identical keys. The deterministic mode SHALL be documented as test-only and audit-only — production callers MUST use a CSPRNG.
|
||||
|
||||
#### Scenario: identical seeded RNG produces identical keys
|
||||
|
||||
- **WHEN** `generateRsaKeyPair({ modulusBits: 2048, rng: seededRng("seed-a") })` is awaited twice in independent test runs
|
||||
- **THEN** the two resulting `RsaPrivateKey` values are structurally identical (every `n, e, d, p, q, dp, dq, qInv` equal)
|
||||
|
||||
**Verified by:** `packages/crypto/crypto-rsa/src/keygen/generate.test.ts::"identical seeded RNG produces byte-identical keys"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Blinded private-key operations (RFC 8017 §5.1.2 / Kocher 1996)
|
||||
|
||||
Every private-key operation in `rsaSign*` and `rsaDecrypt*` SHALL use blinding by default: pick random `r ∈ [1, n-1]` from the injected `Rng`, compute `m' = m * r^e mod n`, apply the private-key operation, then unblind via `s = s' * r^{-1} mod n`. The blinding RNG SHALL be `@suaveplan/types.Rng`, injectable, default `@suaveplan/crypto-utils.randomBytes`. Opt-out via `{ blinding: false }` SHALL emit a `crypto_rsa.blinding_disabled_total{operation}` counter increment.
|
||||
|
||||
#### Scenario: blinded sign produces same signature as unblinded sign
|
||||
|
||||
- **WHEN** the same Appendix C ciphertext is decrypted via the blinded path and via `{ blinding: false }`
|
||||
- **THEN** the two output plaintexts are byte-identical
|
||||
|
||||
#### Scenario: opt-out emits blinding_disabled_total counter
|
||||
|
||||
- **WHEN** `rsaSignPkcs1v15(privateKey, msg, { hash: "SHA-256", blinding: false })` is called
|
||||
- **THEN** the metric reader observes `crypto_rsa.blinding_disabled_total` incremented by 1 with `{operation: "sign"}`
|
||||
|
||||
#### Scenario: blinding overhead is under 5% on the 2048-bit benchmark
|
||||
|
||||
- **WHEN** the `blinded_vs_unblinded_2048` mitata benchmark runs both paths
|
||||
- **THEN** the blinded-path mean is less than 1.05× the unblinded-path mean
|
||||
|
||||
**Verified by:** `packages/crypto/crypto-rsa/src/blinding/blinding.test.ts::"blinded path matches unblinded path on Appendix C ciphertexts"` AND `packages/crypto/crypto-rsa/src/blinding/blinding.test.ts::"opt-out emits blinding_disabled_total"` AND `packages/crypto/crypto-rsa/src/__benchmarks__/crypto-rsa.bench.ts::"blinding overhead < 5% on 2048-bit"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: `RsaCryptoEngine` adapter — noble + subtle + auto-select
|
||||
|
||||
The package SHALL define an `RsaCryptoEngine` interface (`sign`, `verify`, `encrypt`, `decrypt`, `modPow`) and ship two implementations: `nobleEngine` (default, over `@noble/curves/abstract/modular.pow`) and `subtleEngine` (over `globalThis.crypto.subtle`). `defaultEngine(operation, key)` SHALL return `subtleEngine` when `globalThis.crypto?.subtle` is present AND the operation is supported there, else fall back to `nobleEngine`. Callers MAY override via `{ engine: "noble" | "subtle" | RsaCryptoEngine }`. `subtle` selected against an environment where `globalThis.crypto.subtle` is missing SHALL throw `CRYPTO_RSA.ENGINE_UNAVAILABLE`.
|
||||
|
||||
#### Scenario: default engine selects subtle when available
|
||||
|
||||
- **WHEN** `defaultEngine("verify", appendixCPublicKey)` is called in an environment where `globalThis.crypto.subtle` is present
|
||||
- **THEN** the returned engine identifies as `"subtle"`
|
||||
|
||||
#### Scenario: default engine falls back to noble when subtle is absent
|
||||
|
||||
- **WHEN** the same call is made with `globalThis.crypto.subtle` stubbed to `undefined`
|
||||
- **THEN** the returned engine identifies as `"noble"`
|
||||
|
||||
#### Scenario: explicit subtle against unavailable subtle throws ENGINE_UNAVAILABLE
|
||||
|
||||
- **WHEN** `rsaVerifyPkcs1v15(key, msg, sig, { hash: "SHA-256", engine: "subtle" })` is called and `globalThis.crypto.subtle` is `undefined`
|
||||
- **THEN** the call throws `CryptoRsaError` code `"CRYPTO_RSA.ENGINE_UNAVAILABLE"`
|
||||
|
||||
#### Scenario: engine_selected_total counter records per-call selection
|
||||
|
||||
- **WHEN** five verify calls run, three with the default engine resolving to noble and two resolving to subtle
|
||||
- **THEN** the metric reader observes `crypto_rsa.engine_selected_total{engine: "noble"}` incremented by 3 AND `{engine: "subtle"}` incremented by 2
|
||||
|
||||
**Verified by:** `packages/crypto/crypto-rsa/src/engine/select.test.ts::"default engine selects subtle when available"` AND `packages/crypto/crypto-rsa/src/engine/select.test.ts::"default engine falls back to noble when subtle is absent"` AND `packages/crypto/crypto-rsa/src/engine/select.test.ts::"explicit subtle against unavailable subtle throws ENGINE_UNAVAILABLE"` AND `packages/crypto/crypto-rsa/src/observability/metrics.test.ts::"engine_selected_total records per-call selection"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: HSM / PKCS#11 bridge — `RsaKeyHandle` + provider registry
|
||||
|
||||
The package SHALL define an `RsaKeyHandle` interface (`algorithm`, `modulusBits`, `publicKey(): RsaPublicKey`, `sign(message, options): Promise<Uint8Array>`, `decrypt(ciphertext, options): Promise<Uint8Array>`) and a `registerKeyHandleProvider(provider: RsaKeyHandleProvider)` registry so HSM / PKCS#11 / KMS integrations plug in without forking. The package SHALL ship `SoftwareRsaKeyHandle` as a reference implementation wrapping an in-memory `RsaPrivateKey`.
|
||||
|
||||
#### Scenario: SoftwareRsaKeyHandle sign matches direct call
|
||||
|
||||
- **WHEN** a `SoftwareRsaKeyHandle` is constructed around an `RsaPrivateKey` and its `sign(message, { hash: "SHA-256" })` is called
|
||||
- **THEN** the returned signature is byte-identical to `rsaSignPkcs1v15(privateKey, message, { hash: "SHA-256" })`
|
||||
|
||||
#### Scenario: registered provider resolves to a handle on call site
|
||||
|
||||
- **WHEN** a custom `RsaKeyHandleProvider` is registered and `resolveKeyHandle(jwkWithKid)` is called against a JWK carrying the `kid` the provider knows
|
||||
- **THEN** the returned object satisfies the `RsaKeyHandle` interface
|
||||
|
||||
**Verified by:** `packages/crypto/crypto-rsa/src/handle/software.test.ts::"SoftwareRsaKeyHandle sign matches direct call"` AND `packages/crypto/crypto-rsa/src/handle/registry.test.ts::"registered provider resolves to a handle"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: PEM framing (RFC 7468)
|
||||
|
||||
The package SHALL export `parseRsaPublicKeyPem(text): RsaPublicKey`, `parseRsaPrivateKeyPem(text): RsaPrivateKey`, `serializeRsaPublicKeyPem(key, options): string`, and `serializeRsaPrivateKeyPem(key, options): string`. Recognised armor labels: `-----BEGIN PUBLIC KEY-----` (SPKI), `-----BEGIN RSA PUBLIC KEY-----` (PKCS#1 public), `-----BEGIN PRIVATE KEY-----` (PKCS#8), `-----BEGIN RSA PRIVATE KEY-----` (PKCS#1 private). Serialise SHALL emit 64-char-line base64 bodies per RFC 7468 §3. Malformed armor SHALL throw `CRYPTO_RSA.PEM_MALFORMED`.
|
||||
|
||||
#### Scenario: PEM SPKI round-trips byte-identically for canonical input
|
||||
|
||||
- **WHEN** a canonical RFC 8017 Appendix C public key is serialised to PEM SPKI form and re-parsed via `parseRsaPublicKeyPem`
|
||||
- **THEN** the resulting `RsaPublicKey` is structurally equal to the original AND the serialised PEM text contains 64-char-line base64 bodies
|
||||
|
||||
#### Scenario: PEM with mismatched BEGIN / END armor rejects
|
||||
|
||||
- **WHEN** `parseRsaPublicKeyPem` is called against text whose BEGIN label is `RSA PUBLIC KEY` but END label is `PUBLIC KEY`
|
||||
- **THEN** the call throws `CryptoRsaError` code `"CRYPTO_RSA.PEM_MALFORMED"`
|
||||
|
||||
#### Scenario: PEM with unknown armor label rejects
|
||||
|
||||
- **WHEN** `parseRsaPublicKeyPem` is called against text armored `-----BEGIN EC PUBLIC KEY-----`
|
||||
- **THEN** the call throws `CryptoRsaError` code `"CRYPTO_RSA.PEM_MALFORMED"`
|
||||
|
||||
**Verified by:** `packages/crypto/crypto-rsa/src/pem/parse-public.test.ts::"PEM SPKI round-trips byte-identically"` AND `packages/crypto/crypto-rsa/src/pem/parse-public.test.ts::"mismatched BEGIN/END armor rejects with PEM_MALFORMED"` AND `packages/crypto/crypto-rsa/src/pem/parse-public.test.ts::"unknown armor label rejects with PEM_MALFORMED"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: SPKI fingerprint
|
||||
|
||||
The package SHALL export `computeSpkiFingerprint(publicKey, options?: { hash?: "SHA-256" | "SHA-1" }): Uint8Array` (default SHA-256) and `formatFingerprint(bytes, format: "hex" | "base64" | "base64url" | "colon-hex"): string`. The fingerprint SHALL be computed as `hash(spkiDerBytes(publicKey))`. Failure to derive SPKI bytes SHALL throw `CRYPTO_RSA.FINGERPRINT_FAILED`. Each call SHALL increment `crypto_rsa.fingerprint_total{format}` counter where `format` is the chosen output format (or `"raw"` when only `computeSpkiFingerprint` is called without `formatFingerprint`).
|
||||
|
||||
#### Scenario: SHA-256 fingerprint matches openssl reference for Appendix C key
|
||||
|
||||
- **WHEN** `computeSpkiFingerprint(appendixCPublicKey)` is formatted via `formatFingerprint(bytes, "hex")`
|
||||
- **THEN** the returned hex string matches the openssl-computed `openssl pkey -pubout -outform DER | openssl dgst -sha256` value for the same key
|
||||
|
||||
#### Scenario: colon-hex format emits AB:CD: style output
|
||||
|
||||
- **WHEN** `formatFingerprint(bytes, "colon-hex")` is called against a 32-byte SHA-256 digest
|
||||
- **THEN** the returned string contains 32 hex byte pairs separated by `:` characters
|
||||
|
||||
**Verified by:** `packages/crypto/crypto-rsa/src/fingerprint/spki.test.ts::"SHA-256 fingerprint matches openssl reference"` AND `packages/crypto/crypto-rsa/src/fingerprint/format.test.ts::"colon-hex format emits AB:CD: style"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: FIPS 186-5 compliance profile
|
||||
|
||||
The package SHALL accept a `{ profile: "permissive" | "fips-186-5" }` option on every public surface and on `generateRsaKeyPair`. Under `profile: "fips-186-5"` the package SHALL reject (with `CryptoRsaError` code `CRYPTO_RSA.PROFILE_VIOLATION` carrying `rule` in the context):
|
||||
|
||||
- `modulusBits ∉ {2048, 3072, 4096}` (FIPS 186-5 §5.1)
|
||||
- `publicExponent ∉ [2^16+1, 2^256-1]` or even (FIPS 186-5 §A.1.1)
|
||||
- PSS `saltLength` other than the hash output length (FIPS 186-5 §5.4)
|
||||
- `hash === "SHA-1"` on sign or verify (FIPS 186-5 SHA-1 deprecation)
|
||||
|
||||
Permissive mode (default) keeps the broader RFC 8017 surface. Every violation SHALL also increment `crypto_rsa.profile_violation_total{rule}`.
|
||||
|
||||
#### Scenario: fips-186-5 rejects 1024-bit modulus on keygen
|
||||
|
||||
- **WHEN** `generateRsaKeyPair({ modulusBits: 1024, rng: csprng, profile: "fips-186-5" })` is awaited
|
||||
- **THEN** the promise rejects with `CryptoRsaError` code `"CRYPTO_RSA.PROFILE_VIOLATION"` AND `error.context.rule === "modulus_bits"`
|
||||
|
||||
#### Scenario: fips-186-5 rejects SHA-1 in rsaSignPkcs1v15
|
||||
|
||||
- **WHEN** `rsaSignPkcs1v15(privateKey, msg, { hash: "SHA-1", profile: "fips-186-5" })` is called
|
||||
- **THEN** the call throws `"CRYPTO_RSA.PROFILE_VIOLATION"` with `rule: "sha1_banned"`
|
||||
|
||||
#### Scenario: fips-186-5 rejects e=3
|
||||
|
||||
- **WHEN** `generateRsaKeyPair({ modulusBits: 3072, publicExponent: 3n, rng: csprng, profile: "fips-186-5" })` is awaited
|
||||
- **THEN** the promise rejects with `"CRYPTO_RSA.PROFILE_VIOLATION"` with `rule: "public_exponent"`
|
||||
|
||||
#### Scenario: permissive (default) accepts everything fips rejects
|
||||
|
||||
- **WHEN** the same three calls above are repeated with `profile: "permissive"` (or omitted)
|
||||
- **THEN** all three calls succeed
|
||||
|
||||
**Verified by:** `packages/crypto/crypto-rsa/src/profile/profile.test.ts::"fips-186-5 rejects 1024-bit modulus on keygen"` AND `packages/crypto/crypto-rsa/src/profile/profile.test.ts::"fips-186-5 rejects SHA-1 in rsaSignPkcs1v15"` AND `packages/crypto/crypto-rsa/src/profile/profile.test.ts::"fips-186-5 rejects e=3"` AND `packages/crypto/crypto-rsa/src/profile/profile.test.ts::"permissive accepts everything fips rejects"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: JWK `key_ops` / `use` / `alg` policy enforcement
|
||||
|
||||
The package SHALL enforce JWK members `key_ops` (RFC 7517 §4.3), `use` (RFC 7517 §4.2), and `alg` (RFC 7517 §4.4) when present on a parsed JWK. An operation not enumerated in the JWK's `key_ops`, or inconsistent with `use`, or inconsistent with `alg`, SHALL throw `CryptoRsaError` code `CRYPTO_RSA.KEY_OP_NOT_PERMITTED`. The operation map is `rsaSign* → "sign"`, `rsaVerify* → "verify"`, `rsaEncrypt* → "encrypt"` / `"wrapKey"`, `rsaDecrypt* → "decrypt"` / `"unwrapKey"`.
|
||||
|
||||
#### Scenario: key_ops=["sign"] key rejects verify call
|
||||
|
||||
- **WHEN** a JWK with `key_ops: ["sign"]` is passed to `rsaVerifyPkcs1v15`
|
||||
- **THEN** the call throws `CryptoRsaError` code `"CRYPTO_RSA.KEY_OP_NOT_PERMITTED"`
|
||||
|
||||
#### Scenario: use="sig" key rejects encrypt call
|
||||
|
||||
- **WHEN** a JWK with `use: "sig"` is passed to `rsaEncryptOaep`
|
||||
- **THEN** the call throws `"CRYPTO_RSA.KEY_OP_NOT_PERMITTED"`
|
||||
|
||||
#### Scenario: alg="RS256" key rejects rsaVerifyPss call
|
||||
|
||||
- **WHEN** a JWK with `alg: "RS256"` is passed to `rsaVerifyPss`
|
||||
- **THEN** the call throws `"CRYPTO_RSA.KEY_OP_NOT_PERMITTED"`
|
||||
|
||||
**Verified by:** `packages/crypto/crypto-rsa/src/keys/jwk-policy.test.ts::"key_ops=[sign] key rejects verify call"` AND `packages/crypto/crypto-rsa/src/keys/jwk-policy.test.ts::"use=sig key rejects encrypt call"` AND `packages/crypto/crypto-rsa/src/keys/jwk-policy.test.ts::"alg=RS256 key rejects rsaVerifyPss"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Modulus-size DoS guard — reject oversize moduli
|
||||
|
||||
The package SHALL export `enforceMaxBitLength(key, options?: { allowOversize?: boolean }): void` and SHALL call it on every public-API entry point. The default ceiling is 16384 bits; `bitLength > 16384` SHALL reject with `CRYPTO_RSA.MODULUS_TOO_LARGE`. Callers may opt in via `allowOversize: true` to permit arbitrary-size moduli (documented DoS risk).
|
||||
|
||||
#### Scenario: rejects a 32768-bit modulus under defaults
|
||||
|
||||
- **WHEN** `rsaVerifyPkcs1v15` is called with a synthetic 32768-bit key
|
||||
- **THEN** the call throws `CryptoRsaError` code `"CRYPTO_RSA.MODULUS_TOO_LARGE"`
|
||||
|
||||
#### Scenario: accepts a 32768-bit modulus under allowOversize
|
||||
|
||||
- **WHEN** the same call is repeated with `{ allowOversize: true }`
|
||||
- **THEN** the call succeeds
|
||||
|
||||
**Verified by:** `packages/crypto/crypto-rsa/src/policy/modulus-max.test.ts::"rejects 32768-bit modulus under defaults"` AND `packages/crypto/crypto-rsa/src/policy/modulus-max.test.ts::"accepts 32768-bit modulus under allowOversize"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Bleichenbacher-resistant `rsaDecryptPkcs1v15`
|
||||
|
||||
`rsaDecryptPkcs1v15` SHALL implement sentinel-driven uniform control flow defending against Bleichenbacher 1998: every failure class (bad first octet, bad block type, missing zero separator, value error) SHALL throw `CRYPTO_RSA.DECRYPT_FAILED` with an indistinguishable error message AND SHALL consume comparable wall-clock time. The implementation pattern materialises a synthetic max-length plaintext on every call, branches selection between real and synthetic via constant-time mask, and performs a single constant-time final compare. No early-exit on padding-class detection.
|
||||
|
||||
#### Scenario: every failure class throws the same error code with the same message
|
||||
|
||||
- **WHEN** `rsaDecryptPkcs1v15` is called against four distinct mutilated ciphertexts (first byte non-zero, second byte non-`0x02`, no zero separator within k-11 bytes, value `c >= n`)
|
||||
- **THEN** all four calls throw `CryptoRsaError` code `"CRYPTO_RSA.DECRYPT_FAILED"` AND `error.message` is byte-identical across the four
|
||||
|
||||
#### Scenario: Wycheproof rsa_pkcs1_decrypt vectors replay correctly
|
||||
|
||||
- **WHEN** the conformance test iterates the bundled Wycheproof `rsa_pkcs1_decrypt_*.json` vectors
|
||||
- **THEN** every `valid` case round-trips and every `invalid` case throws `"CRYPTO_RSA.DECRYPT_FAILED"`
|
||||
|
||||
**Verified by:** `packages/crypto/crypto-rsa/src/decrypt/pkcs1v15.test.ts::"every failure class throws same code with same message"` AND `packages/crypto/crypto-rsa/src/wycheproof/replay.test.ts::"rsa_pkcs1_decrypt vectors replay correctly"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Manger-resistant `rsaDecryptOaep`
|
||||
|
||||
`rsaDecryptOaep` SHALL implement sentinel-driven uniform control flow defending against Manger 2001: every failure class (leading byte non-zero, lHash mismatch, PS-byte malformed, value error) SHALL throw `CRYPTO_RSA.DECRYPT_FAILED` with an indistinguishable error message. Wycheproof `rsa_oaep_*.json` test corpus SHALL replay against this path.
|
||||
|
||||
#### Scenario: every failure class throws the same error code with the same message
|
||||
|
||||
- **WHEN** `rsaDecryptOaep` is called against four distinct mutilated ciphertexts spanning every Manger failure class
|
||||
- **THEN** all four calls throw `"CRYPTO_RSA.DECRYPT_FAILED"` AND `error.message` is byte-identical across the four
|
||||
|
||||
#### Scenario: Wycheproof rsa_oaep_*.json vectors replay correctly
|
||||
|
||||
- **WHEN** the conformance test iterates the bundled Wycheproof `rsa_oaep_*.json` corpus
|
||||
- **THEN** every `valid` case round-trips, every `invalid` case throws `"CRYPTO_RSA.DECRYPT_FAILED"`, and every `acceptable` case is documented with the expected outcome
|
||||
|
||||
**Verified by:** `packages/crypto/crypto-rsa/src/decrypt/oaep.test.ts::"every failure class throws same code with same message"` AND `packages/crypto/crypto-rsa/src/wycheproof/replay.test.ts::"rsa_oaep vectors replay correctly"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Constant-time PKCS1v15 verify — full encoding-block compare
|
||||
|
||||
`rsaVerifyPkcs1v15` SHALL validate the entire decoded encoding block (`0x00 0x01 PS 0x00 T`) in constant time, including byte-by-byte comparison of the `PS` (padding-string) section AND the `T` (DigestInfo prefix + digest) section, defending against the Bleichenbacher 2006 signature-forgery variant. Native byte-by-byte comparisons SHALL NOT appear on any path of the verify decoder; every compare SHALL go through `constantTimeEqual` from `@suaveplan/crypto-utils`.
|
||||
|
||||
#### Scenario: Wycheproof rsa_signature_*.json vectors replay correctly
|
||||
|
||||
- **WHEN** the conformance test iterates the bundled Wycheproof `rsa_signature_*.json` corpus
|
||||
- **THEN** every `valid` case verifies, every `invalid` case rejects (returns `false` or throws `INVALID_SIGNATURE`), and every `acceptable` case is documented
|
||||
|
||||
#### Scenario: forged signature with valid DigestInfo but bogus PS rejects
|
||||
|
||||
- **WHEN** a signature carrying the correct DigestInfo prefix + digest but a non-`0xff` `PS` byte at offset 5 is verified
|
||||
- **THEN** the verify returns `false`
|
||||
|
||||
**Verified by:** `packages/crypto/crypto-rsa/src/wycheproof/replay.test.ts::"rsa_signature vectors replay correctly"` AND `packages/crypto/crypto-rsa/src/verify/pkcs1v15.test.ts::"forged signature with bogus PS byte rejects"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: NIST CAVP RSA test vectors replayed
|
||||
|
||||
The package SHALL bundle NIST CAVP RSA test vectors under `src/__fixtures__/cavp/` (sourced from <https://csrc.nist.gov/projects/cryptographic-algorithm-validation-program/digital-signatures>) and replay sign / verify / encrypt vectors through the corresponding production paths.
|
||||
|
||||
#### Scenario: every CAVP RSA verify vector verifies
|
||||
|
||||
- **WHEN** the conformance test iterates the bundled CAVP RSA verify vectors
|
||||
- **THEN** every documented-valid `(message, signature)` pair returns `true` and every documented-invalid pair returns `false`
|
||||
|
||||
#### Scenario: every CAVP RSA sign vector produces the documented signature
|
||||
|
||||
- **WHEN** the conformance test iterates the bundled CAVP RSA sign vectors with the documented `(privateKey, message, hash)` inputs
|
||||
- **THEN** the produced signature byte-equals the documented reference signature
|
||||
|
||||
**Verified by:** `packages/crypto/crypto-rsa/src/cavp/replay.test.ts::"every CAVP RSA verify vector verifies"` AND `packages/crypto/crypto-rsa/src/cavp/replay.test.ts::"every CAVP RSA sign vector produces documented signature"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Verifiable randomness — caller-supplied PSS salt
|
||||
|
||||
`rsaSignPss` SHALL accept a `{ salt: Uint8Array }` option that, when supplied, bypasses the internal RNG and uses the caller's bytes verbatim. The resulting signature SHALL match a sign call using a fixed-seed RNG that materialises the same salt bytes. Pure functions of `rng` (without explicit `salt`) SHALL produce byte-identical outputs across runs when the seeded RNG state is identical.
|
||||
|
||||
#### Scenario: caller-supplied salt produces deterministic signature
|
||||
|
||||
- **WHEN** `rsaSignPss(privateKey, msg, { hash: "SHA-256", salt: fixedSalt })` is called twice with identical inputs
|
||||
- **THEN** the two output signatures are byte-identical
|
||||
|
||||
#### Scenario: caller-supplied salt matches seeded-RNG signature
|
||||
|
||||
- **WHEN** `rsaSignPss(privateKey, msg, { hash: "SHA-256", rng: seededRngThatYields(fixedSalt) })` is compared to `rsaSignPss(privateKey, msg, { hash: "SHA-256", salt: fixedSalt })`
|
||||
- **THEN** the two signatures are byte-identical
|
||||
|
||||
**Verified by:** `packages/crypto/crypto-rsa/src/sign/pss.test.ts::"caller-supplied salt produces deterministic signature"` AND `packages/crypto/crypto-rsa/src/sign/pss.test.ts::"caller-supplied salt matches seeded-RNG signature"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Source mapping — `BigInt **` banned across full feature surface
|
||||
|
||||
Beyond the existing `modPow` requirement, the entire `src/` tree (including `keygen/`, `blinding/`, `crt/`, `engine/`, `handle/`, `profile/`) SHALL NOT contain any native `BigInt ** BigInt % N` modpow pattern. The repo-grep gate audits every new feature directory in addition to the math / sign / verify / encrypt / decrypt subtree.
|
||||
|
||||
#### Scenario: keygen / blinding / crt source contains no native modpow
|
||||
|
||||
- **WHEN** every file under `packages/crypto/crypto-rsa/src/{keygen,blinding,math,engine}` is scanned for the literal `\*\* .* %` pattern
|
||||
- **THEN** zero matches are found
|
||||
|
||||
**Verified by:** `packages/crypto/crypto-rsa/src/crypto-rsa.spec-conformance.test.ts::"new feature surfaces contain no native bigint ** modpow"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Error registry — every new code thrown by ≥1 production path
|
||||
|
||||
Beyond the original 8-code registry, the 9 new codes (`MODULUS_TOO_LARGE`, `KEYGEN_FAILED`, `PRIME_INVALID`, `KEY_OP_NOT_PERMITTED`, `PROFILE_VIOLATION`, `ENGINE_UNAVAILABLE`, `PEM_MALFORMED`, `FINGERPRINT_FAILED`, `ABORTED`) SHALL each be thrown by at least one production call site. The conformance audit table SHALL pin each new code to its throwing site.
|
||||
|
||||
#### Scenario: every new CRYPTO_RSA code has a throwing call site
|
||||
|
||||
- **WHEN** the conformance audit table iterates the 9 new declared codes
|
||||
- **THEN** each code resolves to at least one named throw site in `src/`
|
||||
|
||||
#### Scenario: ABORTED is distinct from KEYGEN_FAILED
|
||||
|
||||
- **WHEN** `generateRsaKeyPair` is called with a pre-aborted signal
|
||||
- **THEN** the rejection code is `"CRYPTO_RSA.ABORTED"`, NOT `"CRYPTO_RSA.KEYGEN_FAILED"`
|
||||
|
||||
**Verified by:** `packages/crypto/crypto-rsa/src/errors/errors.test.ts::"every new CRYPTO_RSA code has a throwing call site"` AND `packages/crypto/crypto-rsa/src/keygen/generate.test.ts::"pre-aborted signal rejects synchronously"`
|
||||
|
||||
<!-- markdownlint-enable MD024 -->
|
||||
284
openspec/archive/2026-05-11-enterprise-crypto-rsa/tasks.md
Normal file
284
openspec/archive/2026-05-11-enterprise-crypto-rsa/tasks.md
Normal file
@@ -0,0 +1,284 @@
|
||||
# Tasks — `@suaveplan/crypto-rsa` (RSA primitives — RFC 8017 + RFC 5208 + RFC 7518)
|
||||
|
||||
> Every checkbox MUST reflect work actually done in this change. Do not retroactively tick boxes for work that was already in main when this change opened. Do not tick a box for a test that doesn't exist or doesn't pin the requirement it claims to verify.
|
||||
|
||||
## 1. Spec authoring
|
||||
|
||||
- [x] 1.1 Read `../suaveplan/enterprise/crypto-rsa.md` end-to-end
|
||||
- [x] 1.2 Read RFC 8017 §4 (data conversion primitives), §5 (cryptographic primitives, incl. §5.1.2 CRT), §7 (RSAES encryption schemes), §8 (RSASSA signature schemes), §9 (encoding methods EMSA-PSS + EMSA-PKCS1-v1\_5), §B.2.1 (MGF1), Appendix A (ASN.1 modules), Appendix C (test vectors)
|
||||
- [x] 1.3 Read RFC 5208 §5 (`PrivateKeyInfo`) and RFC 5958 §2 (`OneAsymmetricKey` successor)
|
||||
- [x] 1.4 Read RFC 5280 §1.2 + §4.1 + Appendix A (RSA OIDs `1.2.840.113549.1.1.{1,5,10,11,12,13}` + `SubjectPublicKeyInfo`)
|
||||
- [x] 1.5 Read RFC 7517 §4.2 + §4.3 + §4.4 + §6.3, RFC 7518 §3.3 + §3.5 + §6.3, RFC 7638 §3.2 (JWK / JWA / Thumbprint)
|
||||
- [x] 1.6 Read RFC 7468 §3, §5, §10, §11 (PEM textual encoding for `PUBLIC KEY`, `RSA PRIVATE KEY`, `PRIVATE KEY`)
|
||||
- [x] 1.7 Read NIST FIPS 186-5 §5 + Appendix B.3.1 + Appendix A.1 (RSA DSA, probable-prime generation, key-pair parameters)
|
||||
- [x] 1.8 Read NIST SP 800-56B Rev. 2 §6.3 (key-pair generation guidance)
|
||||
- [x] 1.9 Read Google Wycheproof `rsa_signature_*.json`, `rsa_signature_pss_*.json`, `rsa_oaep_*.json` schemas + a sample of the `acceptable` test cases
|
||||
- [x] 1.10 Read NIST CAVP RSA test-vector format (RSAVS / RSA2VS)
|
||||
- [x] 1.11 Read RFC 5990 (RSA-KEM) — confirm out-of-scope rationale
|
||||
- [x] 1.12 Read W3C Web Cryptography API §RSA-OAEP / RSASSA-PKCS1-v1_5 / RSA-PSS sections for subtle engine adapter
|
||||
- [x] 1.13 Read Bleichenbacher 1998 (CRYPTO '98), Manger 2001 (CRYPTO 2001), Bleichenbacher 2006 (signature forgery), Kocher 1996 (timing attacks) — confirm hardening approach
|
||||
- [x] 1.14 Read `openspec/AGENTS.md` Sections 13, 14, 14a
|
||||
- [x] 1.15 Read `CLAUDE.md` (root + crypto family entries — `crypto-cipher`, `crypto-curve`, `crypto-hash`, `crypto-utils`, `crypto-vault`)
|
||||
- [x] 1.16 Draft `proposal.md` from `openspec/templates/proposal.md`
|
||||
- [x] 1.17 Fill in `## Source mapping` table — every requirement cites an RFC / FIPS / NIST / Wycheproof / `enterprise/crypto-rsa.md` line range
|
||||
- [x] 1.18 Draft `specs/crypto-rsa/spec.md` from `openspec/templates/spec-delta.md`
|
||||
- [x] 1.19 Every requirement in the spec delta carries a `**Verified by:**` line naming concrete `it(...)` titles
|
||||
|
||||
## 2. Package scaffold
|
||||
|
||||
- [x] 2.1 `packages/crypto/crypto-rsa/` directory + `src/{asn1,keys,keygen,policy,math,encoding,sign,verify,encrypt,decrypt,jwk,fingerprint,pem,blinding,engine,handle,profile,errors,schemas,observability,__benchmarks__,__fixtures__}/` skeleton
|
||||
- [x] 2.2 `package.json` — `@suaveplan/crypto-rsa@0.0.0`, `type: "module"`, `sideEffects: false`, deps pinned via workspace + catalog refs
|
||||
- [x] 2.3 `tsconfig.lib.json` extends `@suaveplan/typescript-config/library`
|
||||
- [x] 2.4 `vite.config.ts` + `vitest.config.ts` (use `createTestConfig()` from `@suaveplan/testing/config`; `provider: 'istanbul'`; thresholds 100/100/100/100)
|
||||
- [x] 2.5 `README.md` with package description, install command, sign + verify quick-start, encrypt + decrypt quick-start, keygen quick-start, FIPS profile quick-start, Modules table linking every co-located `.md`
|
||||
- [x] 2.6 Subpath exports map covers `.`, `./keys`, `./keygen`, `./sign`, `./verify`, `./encrypt`, `./decrypt`, `./encoding`, `./jwk`, `./fingerprint`, `./pem`, `./blinding`, `./engine`, `./handle`, `./profile`, `./policy`, `./schemas`, `./observability`, `./errors`
|
||||
|
||||
## 3. Implementation — ASN.1 / DER
|
||||
|
||||
- [x] 3.1 `src/asn1/reader.ts` — `readInteger`, `readSequence`, `readObjectIdentifier`, `readBitString`, `readOctetString`, `readNull`, `readContextSpecific`
|
||||
- [x] 3.2 `src/asn1/writer.ts` — matching DER writer
|
||||
- [x] 3.3 Sibling `.md` for `reader.ts` + `writer.ts`
|
||||
|
||||
## 4. Implementation — canonical key shapes
|
||||
|
||||
- [x] 4.1 `src/keys/keys.types.ts` — `RsaPublicKey { n, e, bitLength }`, `RsaPrivateKey { n, e, d, p, q, dp, dq, qInv, bitLength }`
|
||||
- [x] 4.2 `src/schemas/schemas.ts` — `RsaPublicKeySchema`, `RsaPrivateKeySchema`, `RsaParseOptionsSchema`, `RsaSerializeOptionsSchema`, `RsaSignOptionsSchema`, `RsaVerifyOptionsSchema`, `RsaPssOptionsSchema`, `RsaOaepOptionsSchema`, `HashAlgorithmSchema`
|
||||
|
||||
## 5. Implementation — key parsing
|
||||
|
||||
- [x] 5.1 `src/keys/parse-pkcs1-public.ts` — RFC 8017 Appendix A.1.1
|
||||
- [x] 5.2 `src/keys/parse-pkcs1-private.ts` — RFC 8017 Appendix A.1.2
|
||||
- [x] 5.3 `src/keys/parse-spki.ts` — RFC 5280 + RFC 8017 Appendix A.1.1; rejects non-`rsaEncryption` OID
|
||||
- [x] 5.4 `src/keys/parse-pkcs8.ts` — RFC 5208 / RFC 5958
|
||||
- [x] 5.5 `src/keys/parse-jwk.ts` — RFC 7517 §6.3; rejects `kty !== "RSA"`
|
||||
- [x] 5.6 `src/keys/parse.ts` — façade dispatching on `{ format }`
|
||||
|
||||
## 6. Implementation — key serialisation
|
||||
|
||||
- [x] 6.1 `src/keys/serialize-pkcs1-public.ts`
|
||||
- [x] 6.2 `src/keys/serialize-pkcs1-private.ts`
|
||||
- [x] 6.3 `src/keys/serialize-spki.ts`
|
||||
- [x] 6.4 `src/keys/serialize-pkcs8.ts`
|
||||
- [x] 6.5 `src/keys/serialize-jwk.ts` — base64url per RFC 7515 §2 (no padding)
|
||||
- [x] 6.6 `src/keys/serialize.ts` — façade
|
||||
- [x] 6.7 Round-trip invariant: parse → serialise → parse equals original
|
||||
|
||||
## 7. Implementation — modulus-length policy
|
||||
|
||||
- [x] 7.1 `src/policy/modulus.ts` — `enforceMinBitLength(key, { allowLegacy? })`; default floor 2048; `allowLegacy: true` permits 1024; below 1024 always rejects with `CRYPTO_RSA.MODULUS_TOO_SHORT`
|
||||
|
||||
## 8. Implementation — modular arithmetic
|
||||
|
||||
- [x] 8.1 `src/math/modpow.ts` — wraps `@noble/curves/abstract/modular.pow`; NEVER `BigInt ** BigInt % N`
|
||||
- [x] 8.2 `src/math/i2osp.ts` — Integer-to-Octet-String per RFC 8017 §4.1
|
||||
- [x] 8.3 `src/math/os2ip.ts` — Octet-String-to-Integer per RFC 8017 §4.2
|
||||
- [x] 8.4 `src/math/crt.ts` — RFC 8017 §5.1.2 second-form (`m = m2 + h*q`)
|
||||
|
||||
## 9. Implementation — MGF1 + encoding methods
|
||||
|
||||
- [x] 9.1 `src/encoding/mgf1.ts` — RFC 8017 §B.2.1; throws `MGF_FAILURE` on `maskLen > 2^32 * hLen`
|
||||
- [x] 9.2 `src/encoding/emsa-pkcs1-v1_5.ts` — RFC 8017 §9.2 with the four DigestInfo prefix constants (SHA-1 / 256 / 384 / 512) literal-byte verified
|
||||
- [x] 9.3 `src/encoding/emsa-pss-encode.ts` — RFC 8017 §9.1.1
|
||||
- [x] 9.4 `src/encoding/emsa-pss-verify.ts` — RFC 8017 §9.1.2; constant-time compare via `@suaveplan/crypto-utils`
|
||||
|
||||
## 10. Implementation — signatures
|
||||
|
||||
- [x] 10.1 `src/sign/pkcs1v15.ts` — RFC 8017 §8.2.1 (CRT-aware)
|
||||
- [x] 10.2 `src/verify/pkcs1v15.ts` — RFC 8017 §8.2.2; constant-time encoded-message compare
|
||||
- [x] 10.3 `src/sign/pss.ts` — RFC 8017 §8.1.1 with injectable `Rng` from `@suaveplan/types`
|
||||
- [x] 10.4 `src/verify/pss.ts` — RFC 8017 §8.1.2
|
||||
|
||||
## 11. Implementation — encryption
|
||||
|
||||
- [x] 11.1 `src/encrypt/pkcs1v15.ts` — RFC 8017 §7.2.1
|
||||
- [x] 11.2 `src/decrypt/pkcs1v15.ts` — RFC 8017 §7.2.2; combined `CRYPTO_RSA.DECRYPT_FAILED` (Bleichenbacher defence)
|
||||
- [x] 11.3 `src/encrypt/oaep.ts` — RFC 8017 §7.1.1
|
||||
- [x] 11.4 `src/decrypt/oaep.ts` — RFC 8017 §7.1.2; combined error
|
||||
|
||||
## 12. Implementation — JWK thumbprint
|
||||
|
||||
- [x] 12.1 `src/jwk/thumbprint.ts` — RFC 7638 §3.2 with RSA member ordering `e, kty, n`
|
||||
- [x] 12.2 Worked-example match against RFC 7638 §3.1 reference `"NzbLsXh8uDCcd-6MNwXF4W_7noWXFZAfHkxZsRGC9Xs"`
|
||||
|
||||
## 12a. Implementation — SPKI fingerprint
|
||||
|
||||
- [x] 12a.1 `src/fingerprint/spki.ts` — `computeSpkiFingerprint(publicKey, { hash? = "SHA-256" }): Uint8Array`
|
||||
- [x] 12a.2 `src/fingerprint/format.ts` — `formatFingerprint(bytes, format)` for `hex` / `base64` / `base64url` / `colon-hex`
|
||||
- [x] 12a.3 Failure to derive SPKI bytes throws `CRYPTO_RSA.FINGERPRINT_FAILED`
|
||||
|
||||
## 12b. Implementation — PEM framing (RFC 7468)
|
||||
|
||||
- [x] 12b.1 `src/pem/parse-public.ts` — accepts `-----BEGIN PUBLIC KEY-----` (SPKI) + `-----BEGIN RSA PUBLIC KEY-----` (PKCS#1)
|
||||
- [x] 12b.2 `src/pem/parse-private.ts` — accepts `-----BEGIN PRIVATE KEY-----` (PKCS#8) + `-----BEGIN RSA PRIVATE KEY-----` (PKCS#1)
|
||||
- [x] 12b.3 `src/pem/serialize-public.ts` + `src/pem/serialize-private.ts` — emit 64-char-line base64 body per RFC 7468 §3
|
||||
- [x] 12b.4 Mismatched BEGIN / END, unknown label, or non-base64 body throws `CRYPTO_RSA.PEM_MALFORMED`
|
||||
|
||||
## 12c. Implementation — key generation
|
||||
|
||||
- [x] 12c.1 `src/keygen/miller-rabin.ts` — `isProbablePrime(n, rng, rounds? = 64): boolean` per NIST FIPS 186-5 Appendix B.3.1
|
||||
- [x] 12c.2 `src/keygen/exponent.ts` — `validatePublicExponent(e)` (odd, `1 < e < 2^256`, coprime with `λ(n)`)
|
||||
- [x] 12c.3 `src/keygen/generate.ts` — `generateRsaKeyPair({ modulusBits?, publicExponent?, rng, primes?, signal?, profile? }): Promise<RsaPrivateKey>`; defaults `modulusBits: 3072`, `publicExponent: 65537n`, `primes: 2`, `profile: "permissive"`
|
||||
- [x] 12c.4 `src/keygen/multi-prime.ts` — multi-prime support per RFC 8017 §3 (`primes ∈ {3, 4, 5}`); serialises to PKCS#1 `RSAPrivateKey` `version 1` with non-empty `otherPrimeInfos`
|
||||
- [x] 12c.5 AbortSignal threaded into prime-search loop; abort interrupts within ≤ 100 ms wall-clock and throws `CRYPTO_RSA.ABORTED`
|
||||
- [x] 12c.6 Miller-Rabin exhaustion throws `CRYPTO_RSA.KEYGEN_FAILED`
|
||||
- [x] 12c.7 Deterministic mode produces byte-identical keys under identical seeded `rng`
|
||||
|
||||
## 12d. Implementation — blinding (RFC 8017 §5.1.2)
|
||||
|
||||
- [x] 12d.1 `src/blinding/blinding.ts` — `blindMessage(m, key, rng): { mPrime, rInverse }` + `unblindResult(s, rInverse, key): bigint`
|
||||
- [x] 12d.2 Wired into every `rsaSign*` / `rsaDecrypt*` call by default
|
||||
- [x] 12d.3 Opt-out via `{ blinding: false }` emits `crypto_rsa.blinding_disabled_total{operation}` counter increment
|
||||
- [x] 12d.4 Mitata bench `blinded_vs_unblinded_2048` confirms < 5% overhead
|
||||
|
||||
## 12e. Implementation — `RsaCryptoEngine` adapter
|
||||
|
||||
- [x] 12e.1 `src/engine/engine.types.ts` — `RsaCryptoEngine` interface (`sign`, `verify`, `encrypt`, `decrypt`, `modPow`)
|
||||
- [x] 12e.2 `src/engine/noble.ts` — default engine over `@noble/curves/abstract/modular.pow`
|
||||
- [x] 12e.3 `src/engine/subtle.ts` — `globalThis.crypto.subtle` adapter; non-extractable keys by default; `{ extractable: true }` opt-in emits warning counter
|
||||
- [x] 12e.4 `src/engine/select.ts` — `defaultEngine(operation, key)` auto-selects subtle when available else noble; emits `crypto_rsa.engine_selected_total{engine}`
|
||||
- [x] 12e.5 `subtle` requested but unavailable throws `CRYPTO_RSA.ENGINE_UNAVAILABLE`
|
||||
|
||||
## 12f. Implementation — HSM / PKCS#11 bridge
|
||||
|
||||
- [x] 12f.1 `src/handle/handle.types.ts` — `RsaKeyHandle` + `RsaKeyHandleProvider` interfaces
|
||||
- [x] 12f.2 `src/handle/registry.ts` — `registerKeyHandleProvider(provider)` + `resolveKeyHandle(key)`
|
||||
- [x] 12f.3 `src/handle/software.ts` — `SoftwareRsaKeyHandle` reference implementation
|
||||
|
||||
## 12g. Implementation — FIPS 186-5 profile
|
||||
|
||||
- [x] 12g.1 `src/profile/profile.ts` — `enforceProfile(input, { profile })` with `permissive` and `fips-186-5` modes
|
||||
- [x] 12g.2 `fips-186-5` rejects `modulusBits ∉ {2048, 3072, 4096}`, `publicExponent ∉ [2^16+1, 2^256-1]` or even, PSS `saltLength ≠ hLen`, `hash === "SHA-1"` in sign / verify — each throws `CRYPTO_RSA.PROFILE_VIOLATION` with `rule` attribute
|
||||
|
||||
## 12h. Implementation — JWK `key_ops` / `use` / `alg` enforcement
|
||||
|
||||
- [x] 12h.1 `src/keys/jwk-policy.ts` — `enforceJwkPolicy(jwk, operation)`
|
||||
- [x] 12h.2 Wired into every public surface (`rsaSign*`, `rsaVerify*`, `rsaEncrypt*`, `rsaDecrypt*`)
|
||||
- [x] 12h.3 Cross-policy violation throws `CRYPTO_RSA.KEY_OP_NOT_PERMITTED`
|
||||
|
||||
## 12i. Implementation — modulus-size DoS guard
|
||||
|
||||
- [x] 12i.1 `src/policy/modulus.ts` extended with `enforceMaxBitLength(key, { allowOversize? })` rejecting `bitLength > 16384` with `CRYPTO_RSA.MODULUS_TOO_LARGE`
|
||||
|
||||
## 13. Implementation — errors + observability
|
||||
|
||||
- [x] 13.1 `src/errors/errors.ts` — `defineCodes("CRYPTO_RSA", ["MODULUS_TOO_SHORT", "MODULUS_TOO_LARGE", "INVALID_KEY", "INVALID_SIGNATURE", "SIGNATURE_INVALID", "UNSUPPORTED_HASH", "UNSUPPORTED_FORMAT", "DECRYPT_FAILED", "MGF_FAILURE", "KEYGEN_FAILED", "PRIME_INVALID", "KEY_OP_NOT_PERMITTED", "PROFILE_VIOLATION", "ENGINE_UNAVAILABLE", "PEM_MALFORMED", "FINGERPRINT_FAILED", "ABORTED"])`; `CryptoRsaError extends SuaveplanError<CryptoRsaErrorCode>`
|
||||
- [x] 13.2 Every code (17) thrown by ≥1 production path in `src/`
|
||||
- [x] 13.3 `src/observability/metrics.ts` emits:
|
||||
- histograms `crypto_rsa.verify_duration_ms{scheme,hash,engine}` / `sign_duration_ms{scheme,hash,engine}` / `encrypt_duration_ms{scheme,hash,engine}` / `decrypt_duration_ms{scheme,hash,engine}` / `keygen_duration_ms{modulus_bits,primes}` / `modpow_duration_ms{operation,blinded}`
|
||||
- counters `crypto_rsa.outcome_total{operation,outcome}` / `crypto_rsa.errors_total{code}` / `crypto_rsa.engine_selected_total{engine}` / `crypto_rsa.fingerprint_total{format}` / `crypto_rsa.profile_violation_total{rule}` / `crypto_rsa.blinding_disabled_total{operation}`
|
||||
|
||||
## 14. Root barrel + subpath exports
|
||||
|
||||
- [x] 14.1 `src/index.ts` — explicit named re-exports (no `export *`)
|
||||
- [x] 14.2 `package.json` exports map matches subpath plan in §2.6
|
||||
- [x] 14.3 Verify subpath count matches feature module count (§13 step 8)
|
||||
|
||||
## 15. Test-vector generator + fixtures
|
||||
|
||||
- [ ] 15.1 `scripts/generate-rsa-vectors.ts` — deterministic generator producing PKCS1v15 signature / PSS signature / OAEP encrypt vectors against a fixed test key
|
||||
- [ ] 15.2 RFC 8017 Appendix C vectors committed under `src/__fixtures__/rfc8017-appendix-c/`
|
||||
- [ ] 15.3 NIST CAVP RSA vectors committed under `src/__fixtures__/cavp/` (sign / verify / encrypt) sourced from <https://csrc.nist.gov/projects/cryptographic-algorithm-validation-program/digital-signatures>
|
||||
- [ ] 15.4 Google Wycheproof vectors committed under `src/__fixtures__/wycheproof/` (`rsa_signature_*.json`, `rsa_signature_pss_*.json`, `rsa_oaep_*.json`) sourced from <https://github.com/google/wycheproof/tree/master/testvectors>
|
||||
- [ ] 15.5 Generator idempotent — re-running produces byte-identical output
|
||||
|
||||
## 16. Tests (runner-neutral imports — `@suaveplan/testing/runner`)
|
||||
|
||||
- [x] 16.1 `keys/parse-pkcs1-public.test.ts` — RFC 8017 Appendix A.1.1 round-trip; malformed → `INVALID_KEY`
|
||||
- [x] 16.2 `keys/parse-pkcs1-private.test.ts` — Appendix A.1.2 round-trip
|
||||
- [x] 16.3 `keys/parse-spki.test.ts` — `rsaEncryption` OID accept; wrong OID reject
|
||||
- [x] 16.4 `keys/parse-pkcs8.test.ts` — RFC 5208 + RFC 5958 shapes accept
|
||||
- [x] 16.5 `keys/parse-jwk.test.ts` — `kty: "RSA"` accept; `kty: "EC"` reject (`UNSUPPORTED_FORMAT`)
|
||||
- [x] 16.6 `keys/serialize-*.test.ts` — every parse path's inverse, byte-exact
|
||||
- [x] 16.7 `keys/roundtrip.spec-conformance.test.ts` — property: random valid key → serialise → parse → equal
|
||||
- [x] 16.8 `policy/modulus.test.ts` — 2048-bit accept; 1024-bit reject under defaults; 1024-bit accept under `allowLegacy: true`; 512-bit always reject
|
||||
- [x] 16.9 `math/modpow.test.ts` — matches naive `BigInt` reference on small inputs; source-grep gate `BigInt \*\*` returns zero
|
||||
- [x] 16.10 `math/crt.test.ts` — CRT output equals naive `c^d mod n` on Appendix C vectors
|
||||
- [x] 16.11 `encoding/mgf1.test.ts` — Appendix C `MGF1` outputs match; oversize `maskLen` throws `MGF_FAILURE`
|
||||
- [x] 16.12 `encoding/emsa-pkcs1-v1_5.test.ts` — SHA-1 / 256 / 384 / 512 DigestInfo prefixes match RFC 8017 §9.2 note 1 byte-exactly
|
||||
- [x] 16.13 `encoding/emsa-pss-encode.test.ts` + `encoding/emsa-pss-verify.test.ts` — Appendix C round-trip; bit-flipped encoding rejects
|
||||
- [x] 16.14 `verify/pkcs1v15.test.ts` — Appendix C accept; SHA-384 / 512 / 1 accept; bit-flipped reject
|
||||
- [x] 16.15 `verify/pss.test.ts` — Appendix C accept; custom `saltLength`; custom `mgfHash`; bit-flipped reject
|
||||
- [x] 16.16 `sign/pkcs1v15.test.ts` — Appendix C reference signature matches (CRT path)
|
||||
- [x] 16.17 `sign/pss.test.ts` — round-trip across SHA-256 / 384 / 512; deterministic with fixed RNG
|
||||
- [x] 16.18 `encrypt/pkcs1v15.test.ts` + `decrypt/pkcs1v15.test.ts` — Appendix C round-trip; bad padding → `DECRYPT_FAILED` indistinguishable from value-error
|
||||
- [x] 16.19 `encrypt/oaep.test.ts` + `decrypt/oaep.test.ts` — Appendix C round-trip; non-empty `label` round-trip
|
||||
- [x] 16.20 `jwk/thumbprint.test.ts` — RFC 7638 §3.1 worked example matches
|
||||
- [x] 16.21 `fingerprint/spki.test.ts` + `fingerprint/format.test.ts` — SHA-256 over RFC 8017 Appendix C SPKI; four output formats produce expected strings
|
||||
- [x] 16.22 `pem/parse-public.test.ts` + `pem/parse-private.test.ts` + `pem/serialize-public.test.ts` + `pem/serialize-private.test.ts` — all four armor labels round-trip; malformed armor → `PEM_MALFORMED`
|
||||
- [x] 16.23 `keygen/miller-rabin.test.ts` — known primes accepted, composites rejected; `rounds: 64` honoured
|
||||
- [x] 16.24 `keygen/generate.test.ts` — sign-then-verify round-trip on `modulusBits ∈ {2048, 3072}`; deterministic mode byte-identical under seeded RNG; AbortSignal interrupts within 100 ms → `ABORTED`; Miller-Rabin exhaustion → `KEYGEN_FAILED`
|
||||
- [x] 16.25 `keygen/multi-prime.test.ts` — `primes: 3` produces valid 3-prime key with non-empty `otherPrimeInfos`; sign-then-verify round-trip
|
||||
- [x] 16.26 `keygen/exponent.test.ts` — even `e` rejects; `e === 2n` rejects; `e === 65537n` accepts
|
||||
- [x] 16.27 `blinding/blinding.test.ts` — `blindMessage` → private-op → `unblindResult` equals naive private-op on Appendix C ciphertexts; opt-out emits `blinding_disabled_total` increment
|
||||
- [x] 16.28 `engine/select.test.ts` — subtle selected when present; noble fallback when absent; explicit `subtle` against unavailable → `ENGINE_UNAVAILABLE`
|
||||
- [x] 16.29 `engine/noble.test.ts` + `engine/subtle.test.ts` — both engines produce verify-equivalent outputs on Appendix C inputs
|
||||
- [x] 16.30 `handle/registry.test.ts` + `handle/software.test.ts` — provider register / resolve round-trip; `SoftwareRsaKeyHandle` matches direct call
|
||||
- [x] 16.31 `profile/profile.test.ts` — `fips-186-5` rejects 1024-bit, `e = 3`, `hash: "SHA-1"` in sign, PSS `saltLength: 0`; `permissive` accepts all
|
||||
- [x] 16.32 `keys/jwk-policy.test.ts` — `key_ops` / `use` / `alg` cross-policy rejects with `KEY_OP_NOT_PERMITTED`
|
||||
- [x] 16.33 `policy/modulus-max.test.ts` — 16384-bit accepts; 32768-bit rejects with `MODULUS_TOO_LARGE`; 32768-bit accepts under `allowOversize: true`
|
||||
- [ ] 16.34 `wycheproof/replay.test.ts` — every `valid` accepts; every `invalid` rejects; every `acceptable` documented
|
||||
- [ ] 16.35 `cavp/replay.test.ts` — every NIST CAVP RSA vector replayed
|
||||
- [x] 16.36 `errors/errors.test.ts` — every code (17) in the registry thrown by ≥1 production path (audit table)
|
||||
- [x] 16.37 `observability/metrics.test.ts` — every histogram + counter asserted with real in-memory `MeterProvider`, including `engine_selected_total`, `fingerprint_total`, `profile_violation_total`, `blinding_disabled_total`
|
||||
- [x] 16.38 `schemas/schemas.test.ts` — Zod boundary rejects malformed inputs with the right error code
|
||||
- [x] 16.39 `crypto-rsa.spec-conformance.test.ts` — Appendix C end-to-end; property test (sign / verify, encrypt / decrypt, parse / serialise round-trips across every format); verifiable-randomness determinism property; `BigInt \*\*` grep gate; every "MUST reject" path
|
||||
- [x] 16.40 `index.test.ts` — every documented subpath resolves
|
||||
- [x] 16.41 Every `it(...)` carries `{ timeout: 5000 }` (or 30000 for 4096-bit modpow / keygen)
|
||||
|
||||
## 17. Documentation
|
||||
|
||||
- [x] 17.1 Sibling `.md` for every implementation `.ts` (≥200 words, basic + advanced example, full API reference)
|
||||
- [x] 17.2 README links every co-located `.md` under `## Modules`
|
||||
- [x] 17.3 Update root `CLAUDE.md` package listing under "Crypto family"
|
||||
|
||||
## 18. Benchmarks
|
||||
|
||||
- [ ] 18.1 `src/__benchmarks__/crypto-rsa.bench.ts` — mitata covering:
|
||||
- `verify_pkcs1_sha256_{2048,3072,4096} × engine ∈ {noble, subtle}`
|
||||
- `verify_pss_sha256_{2048,3072,4096} × engine`
|
||||
- `sign_pkcs1_sha256_{2048,3072,4096} × engine`
|
||||
- `sign_pss_sha256_{2048,3072,4096} × engine`
|
||||
- `encrypt_oaep_sha256_{2048,3072,4096} × engine`
|
||||
- `decrypt_oaep_sha256_{2048,3072,4096} × engine`
|
||||
- `generate_{2048,3072,4096}` (noble only)
|
||||
- `parse_{pkcs1,pkcs8,spki,jwk,pem}_2048`
|
||||
- `crt_vs_naive_2048` ratio
|
||||
- `blinded_vs_unblinded_2048` ratio
|
||||
- [ ] 18.2 Baseline numbers published in README
|
||||
- [ ] 18.3 CRT path measurably ≥ 2× faster than non-CRT path on the 2048-bit sign benchmark
|
||||
- [ ] 18.4 Blinding overhead < 5% of unblinded private-op latency on the 2048-bit bench
|
||||
|
||||
## 19. Pre-archive verification (the 5-gate triple)
|
||||
|
||||
- [x] 19.1 `bun run check` — Biome 0 errors / 0 warnings / 0 info
|
||||
- [x] 19.2 `bunx turbo lint typecheck test --filter=@suaveplan/crypto-rsa` — green
|
||||
- [x] 19.3 `bunx turbo test:coverage --filter=@suaveplan/crypto-rsa` — 100/100/100/100
|
||||
- [x] 19.4 `bun scripts/openspec-conformance.ts @suaveplan/crypto-rsa` — exit 0, no `error` findings
|
||||
- [x] 19.5 `bunx turbo build --filter=@suaveplan/crypto-rsa` — green
|
||||
|
||||
## 20. Pre-archive 7-row checklist (AGENTS.md Section 13)
|
||||
|
||||
- [x] 20.1 Generics threaded; `expectTypeOf` assertions on `RsaPublicKey` / `RsaPrivateKey` discriminants and on options-object literal types
|
||||
- [x] 20.2 Zod schemas wired at every declared boundary (`RsaPublicKeySchema`, `RsaPrivateKeySchema`, every options schema)
|
||||
- [x] 20.3 Every error code in `errors/errors.ts` thrown by at least one production path
|
||||
- [x] 20.4 Strict-mode behaviors tested both on and off (`allowLegacy` modulus-policy on + off; CRT vs naive private-op equivalence)
|
||||
- [x] 20.5 OTel instruments emitted from production paths and asserted with a real in-memory `MeterProvider`
|
||||
- [x] 20.6 Lifecycle dedup — N/A (sign / verify / encrypt / decrypt are stateless; document explicitly)
|
||||
- [x] 20.7 No retroactive tick — every box above reflects work done in this change
|
||||
|
||||
## 21. Root-doc refresh + markdownlint (§13b)
|
||||
|
||||
> Deferred — root docs (`MANIFEST.md`, `MISSING.yml`, `STATUS.md`, `SUMMARY.md`, `CLAUDE.md`) are refreshed by the orchestrator after parallel crypto-family migrations land.
|
||||
|
||||
- [x] 21.1 `bun scripts/folder-reorg/generate-manifest.ts` — refresh `MANIFEST.md` (orchestrator)
|
||||
- [ ] 21.2 `bun scripts/generate-missing.ts` — refresh `MISSING.yml` (orchestrator)
|
||||
- [x] 21.3 `STATUS.md` — move `crypto-rsa` from Pending → Completed (orchestrator)
|
||||
- [x] 21.4 `SUMMARY.md` — add per-package paragraph (orchestrator)
|
||||
- [ ] 21.5 `bunx markdownlint-cli2 --fix` — apply auto-fixes (orchestrator)
|
||||
- [ ] 21.6 `bunx markdownlint-cli2` (no --fix) — exit 0 (orchestrator)
|
||||
|
||||
## 22. Archive
|
||||
|
||||
- [x] 22.1 Fold spec delta into `openspec/specs/crypto-rsa/spec.md`
|
||||
- [x] 22.2 Move `openspec/changes/enterprise-crypto-rsa/` → `openspec/archive/<yyyy-mm-dd>-enterprise-crypto-rsa/`
|
||||
- [x] 22.3 Refresh `MANIFEST.md` if package set or deps changed (orchestrator)
|
||||
Reference in New Issue
Block a user