Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

View File

@@ -0,0 +1,464 @@
# @suaveplan/crypto-rsa - RSA primitives — RFC 8017 PKCS #1 v2.2, RFC 5208 PKCS #8, RFC 5280 SPKI, RFC 7517/7518 JWK, RFC 7468 PEM. Universal; ships ASN.1/DER, key parse/serialise across PKCS#1/SPKI/PKCS#8/JWK/PEM, modulus-length policy, modular-arithmetic primitives, and the error/observability registry.
## Summary
RSA primitives for Suaveplan — RFC 8017 PKCS #1 v2.2, RFC 5208 / RFC 5958
PKCS #8, RFC 5280 SPKI, RFC 7517 / RFC 7518 JWK, RFC 7468 PEM. Universal package
(zero browser/React dependencies, runs anywhere ES2022 + `BigInt` + `crypto.subtle`
or a `@noble/curves`-compatible engine is available).
This package is shipped in stages. Stage 1 (this release) provides every primitive a
sign/verify/encrypt/decrypt pipeline depends on: ASN.1 / DER reader and writer, key
parsing and serialisation across all five formats, modulus-length policy enforcement,
modular-arithmetic primitives (`modPow`, `i2osp`, `os2ip`, CRT private operation),
PEM textual encoding, and the full error / observability registry. Stages 2-4 will
land the encoding methods (MGF1 + EMSA-PKCS1-v1_5 + EMSA-PSS), the sign / verify /
encrypt / decrypt / key-generation entry points, the engine adapter (`@noble/curves`
+ WebCrypto subtle), and the FIPS / JWK / blinding / fingerprint surface.
<!-- begin:auto-docs -->
## Documentation tree
- [__benchmarks__](./src/__benchmarks__/blinded_vs_unblinded_2048.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [blinded_vs_unblinded_2048](./src/__benchmarks__/blinded_vs_unblinded_2048.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [rsa-2048-key](./src/__fixtures__/rsa-2048-key.md) — Real 2048-bit RSA keypair generated once at module-load time and exposed as canonical `RsaPublicKey` / `RsaPrivateKey`…
- [test-keys](./src/__fixtures__/test-keys.md) — Tiny synthetic RSA keypair for unit tests that need an `RsaPrivateKey` / `RsaPublicKey` shape but don't need to run a r…
- [reader](./src/asn1/reader.md) — Strict-DER reader scoped to the RSA key shapes defined in RFC 8017 Appendix A (PKCS#1), RFC 5280 §4.1 + Appendix A (`Su…
- [writer](./src/asn1/writer.md) — Strict-DER encoder mirroring `asn1/reader`. Encodes the same minimal subset of ASN.1 needed to serialise RSA keys in PK…
- [blinding](./src/blinding/blinding.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [blinding](./src/blinding/blinding.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [oaep](./src/decrypt/oaep.md) — RSAES-OAEP decryption per RFC 8017 §7.1.2 — counterpart to `encrypt/oaep`. Modern default for RSA public-key encryption.
- [pkcs1v15](./src/decrypt/pkcs1v15.md) — RSAES-PKCS1-v1_5 decryption per RFC 8017 §7.2.2. Counterpart to `encrypt/pkcs1v15`. Provides legacy interop for protoco…
- [emsa-pkcs1-v1_5](./src/encoding/emsa-pkcs1-v1_5.md) — Implements EMSA-PKCS1-v1\_5-ENCODE from RFC 8017 §9.2 — the encoding method underlying RSASSA-PKCS1-v1\_5 signatures. T…
- [emsa-pss-encode](./src/encoding/emsa-pss-encode.md) — Implements the EMSA-PSS encoding operation defined in RFC 8017 §9.1.1. Given a message digest, an encoded-message bit l…
- [emsa-pss-verify](./src/encoding/emsa-pss-verify.md) — Implements the EMSA-PSS verification operation defined in RFC 8017 §9.1.2. Given the message digest, the candidate enco…
- [mgf1](./src/encoding/mgf1.md) — Implements the MGF1 mask-generation function defined in RFC 8017 §B.2.1. MGF1 is the building block used by EMSA-PSS an…
- [default-rng](./src/encrypt/default-rng.md) — Single canonical adapter that lifts the `crypto.getRandomValues` CSPRNG into the `@suaveplan/types` `Rng` interface eve…
- [oaep](./src/encrypt/oaep.md) — RSAES-OAEP encryption per RFC 8017 §7.1.1. The Optimal Asymmetric Encryption Padding (OAEP) scheme is the modern defaul…
- [pkcs1v15](./src/encrypt/pkcs1v15.md) — RSAES-PKCS1-v1_5 encryption per RFC 8017 §7.2.1 — the legacy public-key encryption padding scheme historically used by…
- [noble](./src/engine/noble.md) — Default `RsaCryptoEngine` backed by the pure-JS noble primitives this package ships natively. Provides the `name: "nobl…
- [select](./src/engine/select.md) — Resolve the default `RsaCryptoEngine` for the host runtime. When `globalThis.crypto.subtle` exposes the RSA algorithms…
- [subtle](./src/engine/subtle.md) — `RsaCryptoEngine` adapter over `globalThis.crypto.subtle`. When the host runtime exposes WebCrypto, this engine routes…
- [errors](./src/errors/errors.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [errors](./src/errors/errors.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [format](./src/fingerprint/format.md) — Render a fingerprint byte sequence as a human- and machine-readable string. Provides four output formats covering every…
- [spki](./src/fingerprint/spki.md) — Compute a deterministic fingerprint of an RSA public key by hashing its SPKI DER encoding (RFC 5280 §4.1 `SubjectPublic…
- [registry](./src/handle/registry.md) — Process-wide registry for RSA key-handle providers. Every HSM, KMS, OS keychain, or PKCS#11 bridge registers a provider…
- [software](./src/handle/software.md) — Reference `RsaKeyHandleProvider` wrapping in-memory `RsaPrivateKey` values. Implements the same interface as a real HSM…
- [thumbprint-uri](./src/jwk/thumbprint-uri.md) — Compute the JWK Thumbprint URI per RFC 9278 §3 — the canonical `urn:ietf:params:oauth:jwk-thumbprint:<hash-alg>:<base64…
- [thumbprint](./src/jwk/thumbprint.md) — Compute the JWK Thumbprint of an RSA public key per RFC 7638 §3.2. The thumbprint is a deterministic hash over the cano…
- [exponent](./src/keygen/exponent.md) — Public-exponent validation for RSA key generation. The `e`-only constraints (odd, `> 1`, `< 2^256`) live here so they c…
- [generate](./src/keygen/generate.md) — RSA key-pair generation per NIST FIPS 186-5 Appendix A.1 and SP 800-56B Rev. 2 §6.3. Two-prime by default; multi-prime…
- [miller-rabin](./src/keygen/miller-rabin.md) — Probable-prime test per NIST FIPS 186-5 Appendix B.3.1, used by `generateRsaKeyPair` to qualify candidate primes during…
- [multi-prime](./src/keygen/multi-prime.md) — Multi-prime RSA key generation per RFC 8017 §3 and PKCS #1 Appendix A.1.2 `OtherPrimeInfos` extension. Reachable via th…
- [jwk-policy](./src/keys/jwk-policy.md) — Cross-check a JWK's `key_ops`, `use`, and `alg` members against the requested operation per RFC 7517 §4.2 / §4.3 / §4.4…
- [oid](./src/keys/oid.md) — Centralised OID constants for the RSA wire formats handled by this package. Currently exports `OID_RSA_ENCRYPTION` (`1.…
- [parse-jwk](./src/keys/parse-jwk.md) — Parses RSA JWKs per RFC 7517 §6.3.1 (public) and §6.3.2 (private). Accepts the JWK as a plain JS object literal, as a J…
- [parse-pkcs1-private](./src/keys/parse-pkcs1-private.md) — Parses a DER-encoded `RSAPrivateKey` per RFC 8017 Appendix A.1.2 into the canonical `RsaPrivateKey` shape with mandator…
- [parse-pkcs1-public](./src/keys/parse-pkcs1-public.md) — Parses a DER-encoded `RSAPublicKey ::= SEQUENCE { modulus INTEGER, publicExponent INTEGER }` per RFC 8017 Appendix A.1.…
- [parse-pkcs8](./src/keys/parse-pkcs8.md) — Parses a PKCS#8 `PrivateKeyInfo` per RFC 5208 §5 — also accepts the RFC 5958 `OneAsymmetricKey` shape, which adds optio…
- [parse-spki](./src/keys/parse-spki.md) — Parses an X.509 `SubjectPublicKeyInfo` per RFC 5280 §4.1 + Appendix A and unwraps the inner PKCS#1 `RSAPublicKey`. SPKI…
- [parse](./src/keys/parse.md) — Public dispatcher entry-point unifying the four key-format parsers (PKCS#1, SPKI, PKCS#8, JWK) under one ergonomic call…
- [serialize-jwk](./src/keys/serialize-jwk.md) — Inverse of `parseRsaPublicKeyJwk` and `parseRsaPrivateKeyJwk`. Emits an `RsaJwk` with every numeric field encoded as ba…
- [serialize-pkcs1-private](./src/keys/serialize-pkcs1-private.md) — Inverse of `parseRsaPrivateKeyPkcs1`. Emits a DER `RSAPrivateKey` SEQUENCE per RFC 8017 Appendix A.1.2 from a canonical…
- [serialize-pkcs1-public](./src/keys/serialize-pkcs1-public.md) — Inverse of `parseRsaPublicKeyPkcs1`. Emits a DER `RSAPublicKey` SEQUENCE per RFC 8017 Appendix A.1.1 from a canonical `…
- [serialize-pkcs8](./src/keys/serialize-pkcs8.md) — Inverse of `parseRsaPrivateKeyPkcs8`. Wraps a serialised PKCS#1 `RSAPrivateKey` in a DER `PrivateKeyInfo` per RFC 5208…
- [serialize-spki](./src/keys/serialize-spki.md) — Inverse of `parseRsaPublicKeySpki`. Wraps a serialised PKCS#1 `RSAPublicKey` in a DER `SubjectPublicKeyInfo` per RFC 52…
- [serialize](./src/keys/serialize.md) — Public dispatcher entry-point inverse of `parse`. Selects the per-format serialiser via the same `format` discriminator…
- [crt](./src/math/crt.md) — RSA private-key operation via the Chinese Remainder Theorem (RFC 8017 §5.1.2, second-form algorithm). Computes `m = c^d…
- [i2osp](./src/math/i2osp.md) — Integer-to-Octet-String Primitive (RFC 8017 §4.1). Encodes a non-negative integer as a fixed-length big-endian byte str…
- [modpow](./src/math/modpow.md) — Single-source-of-truth modular exponentiation for the entire `@suaveplan/crypto-rsa` package. Wraps `@noble/curves/abst…
- [os2ip](./src/math/os2ip.md) — Octet-String-to-Integer Primitive (RFC 8017 §4.2). Decodes a big-endian byte string into a non-negative `bigint`. Used…
- [metrics](./src/observability/metrics.md) — OpenTelemetry meter + instrument registry for `@suaveplan/crypto-rsa`. defines the full instrument set the prop…
- [timing](./src/observability/timing.md) — Centralised high-resolution clock source used by every metric histogram in `@suaveplan/crypto-rsa`. Resolves to `perfor…
- [labels](./src/pem/labels.md) — Centralised PEM textual labels per RFC 7468 §5/§10/§11 used by the public and private PEM parsers and serialisers in th…
- [parse-private](./src/pem/parse-private.md) — Parse an RSA private key from RFC 7468 PEM textual encoding. Accepts the two canonical private-key armor labels — `"PRI…
- [parse-public](./src/pem/parse-public.md) — Parse an RSA public key from RFC 7468 PEM textual encoding. Accepts both canonical armor labels — `"PUBLIC KEY"` (SPKI…
- [pem-codec](./src/pem/pem-codec.md) — Underlying RFC 7468 textual-encoding codec used by the four PEM parse/serialise entry points in this module. Splits the…
- [serialize-private](./src/pem/serialize-private.md) — Inverse of `parseRsaPrivateKeyPem`. Emits PEM textual encoding for an RSA private key in either PKCS#8 (`"PRIVATE KEY"`…
- [serialize-public](./src/pem/serialize-public.md) — Inverse of `parseRsaPublicKeyPem`. Emits PEM textual encoding for an RSA public key in either SPKI (`"PUBLIC KEY"`, the…
- [policy](./src/policy/modulus.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [modulus](./src/policy/modulus.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [profile](./src/profile/profile.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [profile](./src/profile/profile.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [schemas](./src/schemas/schemas.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [schemas](./src/schemas/schemas.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [pkcs1v15](./src/sign/pkcs1v15.md) — RSASSA-PKCS1-v1\_5 signature generation per RFC 8017 §8.2.1. Hashes the message with the configured algorithm, applies…
- [pss](./src/sign/pss.md) — RSASSA-PSS signature generation per RFC 8017 §8.1.1. Hashes the message, draws a random salt of the configured length,…
- [pkcs1v15](./src/verify/pkcs1v15.md) — RSASSA-PKCS1-v1\_5 signature verification per RFC 8017 §8.2.2. Recovers the encoded message from `signature` via `s^e m…
- [pss](./src/verify/pss.md) — RSASSA-PSS signature verification per RFC 8017 §8.1.2. Recovers the encoded message via `s^e mod n`, then runs the EMSA…
<!-- end:auto-docs --> + [blinded_vs_unblinded_2048](./src/__benchmarks__/blinded_vs_unblinded_2048.md) — <!-- begin:auto-docs --> _(no submodules)_+ [rsa-2048-key](./src/__fixtures__/rsa-2048-key.md) — Real 2048-bit RSA keypair generated once at module-load time and exposed as canonical `RsaPublicKey` / `RsaPrivateKey`…
+ [test-keys](./src/__fixtures__/test-keys.md) — Tiny synthetic RSA keypair for unit tests that need an `RsaPrivateKey` / `RsaPublicKey` shape but don't need to run a r…
+ [reader](./src/asn1/reader.md) — Strict-DER reader scoped to the RSA key shapes defined in RFC 8017 Appendix A (PKCS#1), RFC 5280 §4.1 + Appendix A (`Su…
+ [writer](./src/asn1/writer.md) — Strict-DER encoder mirroring `asn1/reader`. Encodes the same minimal subset of ASN.1 needed to serialise RSA keys in PK…
+ [blinding](./src/blinding/blinding.md) — <!-- begin:auto-docs --> _(no submodules)_+ [blinding](./src/blinding/blinding.md) — <!-- begin:auto-docs --> _(no submodules)_+ [oaep](./src/decrypt/oaep.md) — RSAES-OAEP decryption per RFC 8017 §7.1.2 — counterpart to `encrypt/oaep`. Modern default for RSA public-key encryption.
+ [pkcs1v15](./src/decrypt/pkcs1v15.md) — RSAES-PKCS1-v1_5 decryption per RFC 8017 §7.2.2. Counterpart to `encrypt/pkcs1v15`. Provides legacy interop for protoco…
+ [emsa-pkcs1-v1_5](./src/encoding/emsa-pkcs1-v1_5.md) — Implements EMSA-PKCS1-v1\_5-ENCODE from RFC 8017 §9.2 — the encoding method underlying RSASSA-PKCS1-v1\_5 signatures. T…
+ [emsa-pss-encode](./src/encoding/emsa-pss-encode.md) — Implements the EMSA-PSS encoding operation defined in RFC 8017 §9.1.1. Given a message digest, an encoded-message bit l…
+ [emsa-pss-verify](./src/encoding/emsa-pss-verify.md) — Implements the EMSA-PSS verification operation defined in RFC 8017 §9.1.2. Given the message digest, the candidate enco…
+ [mgf1](./src/encoding/mgf1.md) — Implements the MGF1 mask-generation function defined in RFC 8017 §B.2.1. MGF1 is the building block used by EMSA-PSS an…
+ [default-rng](./src/encrypt/default-rng.md) — Single canonical adapter that lifts the `crypto.getRandomValues` CSPRNG into the `@suaveplan/types` `Rng` interface eve…
+ [oaep](./src/encrypt/oaep.md) — RSAES-OAEP encryption per RFC 8017 §7.1.1. The Optimal Asymmetric Encryption Padding (OAEP) scheme is the modern defaul…
+ [pkcs1v15](./src/encrypt/pkcs1v15.md) — RSAES-PKCS1-v1_5 encryption per RFC 8017 §7.2.1 — the legacy public-key encryption padding scheme historically used by…
+ [noble](./src/engine/noble.md) — Default `RsaCryptoEngine` backed by the pure-JS noble primitives this package ships natively. Provides the `name: "nobl…
+ [select](./src/engine/select.md) — Resolve the default `RsaCryptoEngine` for the host runtime. When `globalThis.crypto.subtle` exposes the RSA algorithms…
+ [subtle](./src/engine/subtle.md) — `RsaCryptoEngine` adapter over `globalThis.crypto.subtle`. When the host runtime exposes WebCrypto, this engine routes…
+ [errors](./src/errors/errors.md) — <!-- begin:auto-docs --> _(no submodules)_+ [errors](./src/errors/errors.md) — <!-- begin:auto-docs --> _(no submodules)_+ [format](./src/fingerprint/format.md) — Render a fingerprint byte sequence as a human- and machine-readable string. Provides four output formats covering every…
+ [spki](./src/fingerprint/spki.md) — Compute a deterministic fingerprint of an RSA public key by hashing its SPKI DER encoding (RFC 5280 §4.1 `SubjectPublic…
+ [registry](./src/handle/registry.md) — Process-wide registry for RSA key-handle providers. Every HSM, KMS, OS keychain, or PKCS#11 bridge registers a provider…
+ [software](./src/handle/software.md) — Reference `RsaKeyHandleProvider` wrapping in-memory `RsaPrivateKey` values. Implements the same interface as a real HSM…
+ [thumbprint-uri](./src/jwk/thumbprint-uri.md) — Compute the JWK Thumbprint URI per RFC 9278 §3 — the canonical `urn:ietf:params:oauth:jwk-thumbprint:<hash-alg>:<base64…
+ [thumbprint](./src/jwk/thumbprint.md) — Compute the JWK Thumbprint of an RSA public key per RFC 7638 §3.2. The thumbprint is a deterministic hash over the cano…
+ [exponent](./src/keygen/exponent.md) — Public-exponent validation for RSA key generation. The `e`-only constraints (odd, `> 1`, `< 2^256`) live here so they c…
+ [generate](./src/keygen/generate.md) — RSA key-pair generation per NIST FIPS 186-5 Appendix A.1 and SP 800-56B Rev. 2 §6.3. Two-prime by default; multi-prime…
+ [miller-rabin](./src/keygen/miller-rabin.md) — Probable-prime test per NIST FIPS 186-5 Appendix B.3.1, used by `generateRsaKeyPair` to qualify candidate primes during…
+ [multi-prime](./src/keygen/multi-prime.md) — Multi-prime RSA key generation per RFC 8017 §3 and PKCS #1 Appendix A.1.2 `OtherPrimeInfos` extension. Reachable via th…
+ [jwk-policy](./src/keys/jwk-policy.md) — Cross-check a JWK's `key_ops`, `use`, and `alg` members against the requested operation per RFC 7517 §4.2 / §4.3 / §4.4…
+ [oid](./src/keys/oid.md) — Centralised OID constants for the RSA wire formats handled by this package. Currently exports `OID_RSA_ENCRYPTION` (`1.…
+ [parse-jwk](./src/keys/parse-jwk.md) — Parses RSA JWKs per RFC 7517 §6.3.1 (public) and §6.3.2 (private). Accepts the JWK as a plain JS object literal, as a J…
+ [parse-pkcs1-private](./src/keys/parse-pkcs1-private.md) — Parses a DER-encoded `RSAPrivateKey` per RFC 8017 Appendix A.1.2 into the canonical `RsaPrivateKey` shape with mandator…
+ [parse-pkcs1-public](./src/keys/parse-pkcs1-public.md) — Parses a DER-encoded `RSAPublicKey ::= SEQUENCE { modulus INTEGER, publicExponent INTEGER }` per RFC 8017 Appendix A.1.…
+ [parse-pkcs8](./src/keys/parse-pkcs8.md) — Parses a PKCS#8 `PrivateKeyInfo` per RFC 5208 §5 — also accepts the RFC 5958 `OneAsymmetricKey` shape, which adds optio…
+ [parse-spki](./src/keys/parse-spki.md) — Parses an X.509 `SubjectPublicKeyInfo` per RFC 5280 §4.1 + Appendix A and unwraps the inner PKCS#1 `RSAPublicKey`. SPKI…
+ [parse](./src/keys/parse.md) — Public dispatcher entry-point unifying the four key-format parsers (PKCS#1, SPKI, PKCS#8, JWK) under one ergonomic call…
+ [serialize-jwk](./src/keys/serialize-jwk.md) — Inverse of `parseRsaPublicKeyJwk` and `parseRsaPrivateKeyJwk`. Emits an `RsaJwk` with every numeric field encoded as ba…
+ [serialize-pkcs1-private](./src/keys/serialize-pkcs1-private.md) — Inverse of `parseRsaPrivateKeyPkcs1`. Emits a DER `RSAPrivateKey` SEQUENCE per RFC 8017 Appendix A.1.2 from a canonical…
+ [serialize-pkcs1-public](./src/keys/serialize-pkcs1-public.md) — Inverse of `parseRsaPublicKeyPkcs1`. Emits a DER `RSAPublicKey` SEQUENCE per RFC 8017 Appendix A.1.1 from a canonical `…
+ [serialize-pkcs8](./src/keys/serialize-pkcs8.md) — Inverse of `parseRsaPrivateKeyPkcs8`. Wraps a serialised PKCS#1 `RSAPrivateKey` in a DER `PrivateKeyInfo` per RFC 5208…
+ [serialize-spki](./src/keys/serialize-spki.md) — Inverse of `parseRsaPublicKeySpki`. Wraps a serialised PKCS#1 `RSAPublicKey` in a DER `SubjectPublicKeyInfo` per RFC 52…
+ [serialize](./src/keys/serialize.md) — Public dispatcher entry-point inverse of `parse`. Selects the per-format serialiser via the same `format` discriminator…
+ [crt](./src/math/crt.md) — RSA private-key operation via the Chinese Remainder Theorem (RFC 8017 §5.1.2, second-form algorithm). Computes `m = c^d…
+ [i2osp](./src/math/i2osp.md) — Integer-to-Octet-String Primitive (RFC 8017 §4.1). Encodes a non-negative integer as a fixed-length big-endian byte str…
+ [modpow](./src/math/modpow.md) — Single-source-of-truth modular exponentiation for the entire `@suaveplan/crypto-rsa` package. Wraps `@noble/curves/abst…
+ [os2ip](./src/math/os2ip.md) — Octet-String-to-Integer Primitive (RFC 8017 §4.2). Decodes a big-endian byte string into a non-negative `bigint`. Used…
+ [metrics](./src/observability/metrics.md) — OpenTelemetry meter + instrument registry for `@suaveplan/crypto-rsa`. defines the full instrument set the prop…
+ [timing](./src/observability/timing.md) — Centralised high-resolution clock source used by every metric histogram in `@suaveplan/crypto-rsa`. Resolves to `perfor…
+ [labels](./src/pem/labels.md) — Centralised PEM textual labels per RFC 7468 §5/§10/§11 used by the public and private PEM parsers and serialisers in th…
+ [parse-private](./src/pem/parse-private.md) — Parse an RSA private key from RFC 7468 PEM textual encoding. Accepts the two canonical private-key armor labels — `"PRI…
+ [parse-public](./src/pem/parse-public.md) — Parse an RSA public key from RFC 7468 PEM textual encoding. Accepts both canonical armor labels — `"PUBLIC KEY"` (SPKI…
+ [pem-codec](./src/pem/pem-codec.md) — Underlying RFC 7468 textual-encoding codec used by the four PEM parse/serialise entry points in this module. Splits the…
+ [serialize-private](./src/pem/serialize-private.md) — Inverse of `parseRsaPrivateKeyPem`. Emits PEM textual encoding for an RSA private key in either PKCS#8 (`"PRIVATE KEY"`…
+ [serialize-public](./src/pem/serialize-public.md) — Inverse of `parseRsaPublicKeyPem`. Emits PEM textual encoding for an RSA public key in either SPKI (`"PUBLIC KEY"`, the…
+ [policy](./src/policy/modulus.md) — <!-- begin:auto-docs --> _(no submodules)_+ [modulus](./src/policy/modulus.md) — <!-- begin:auto-docs --> _(no submodules)_+ [profile](./src/profile/profile.md) — <!-- begin:auto-docs --> _(no submodules)_+ [profile](./src/profile/profile.md) — <!-- begin:auto-docs --> _(no submodules)_+ [schemas](./src/schemas/schemas.md) — <!-- begin:auto-docs --> _(no submodules)_+ [schemas](./src/schemas/schemas.md) — <!-- begin:auto-docs --> _(no submodules)_+ [pkcs1v15](./src/sign/pkcs1v15.md) — RSASSA-PKCS1-v1\_5 signature generation per RFC 8017 §8.2.1. Hashes the message with the configured algorithm, applies…
+ [pss](./src/sign/pss.md) — RSASSA-PSS signature generation per RFC 8017 §8.1.1. Hashes the message, draws a random salt of the configured length,…
+ [pkcs1v15](./src/verify/pkcs1v15.md) — RSASSA-PKCS1-v1\_5 signature verification per RFC 8017 §8.2.2. Recovers the encoded message from `signature` via `s^e m…
+ [pss](./src/verify/pss.md) — RSASSA-PSS signature verification per RFC 8017 §8.1.2. Recovers the encoded message via `s^e mod n`, then runs the EMSA…
+ [blinded_vs_unblinded_2048](./src/__benchmarks__/blinded_vs_unblinded_2048.md) — <!-- begin:auto-docs --> _(no submodules)_+ [rsa-2048-key](./src/__fixtures__/rsa-2048-key.md) — Real 2048-bit RSA keypair generated once at module-load time and exposed as canonical `RsaPublicKey` / `RsaPrivateKey`…
+ [test-keys](./src/__fixtures__/test-keys.md) — Tiny synthetic RSA keypair for unit tests that need an `RsaPrivateKey` / `RsaPublicKey` shape but don't need to run a r…
+ [reader](./src/asn1/reader.md) — Strict-DER reader scoped to the RSA key shapes defined in RFC 8017 Appendix A (PKCS#1), RFC 5280 §4.1 + Appendix A (`Su…
+ [writer](./src/asn1/writer.md) — Strict-DER encoder mirroring `asn1/reader`. Encodes the same minimal subset of ASN.1 needed to serialise RSA keys in PK…
+ [blinding](./src/blinding/blinding.md) — <!-- begin:auto-docs --> _(no submodules)_+ [blinding](./src/blinding/blinding.md) — <!-- begin:auto-docs --> _(no submodules)_+ [oaep](./src/decrypt/oaep.md) — RSAES-OAEP decryption per RFC 8017 §7.1.2 — counterpart to `encrypt/oaep`. Modern default for RSA public-key encryption.
+ [pkcs1v15](./src/decrypt/pkcs1v15.md) — RSAES-PKCS1-v1_5 decryption per RFC 8017 §7.2.2. Counterpart to `encrypt/pkcs1v15`. Provides legacy interop for protoco…
+ [emsa-pkcs1-v1_5](./src/encoding/emsa-pkcs1-v1_5.md) — Implements EMSA-PKCS1-v1\_5-ENCODE from RFC 8017 §9.2 — the encoding method underlying RSASSA-PKCS1-v1\_5 signatures. T…
+ [emsa-pss-encode](./src/encoding/emsa-pss-encode.md) — Implements the EMSA-PSS encoding operation defined in RFC 8017 §9.1.1. Given a message digest, an encoded-message bit l…
+ [emsa-pss-verify](./src/encoding/emsa-pss-verify.md) — Implements the EMSA-PSS verification operation defined in RFC 8017 §9.1.2. Given the message digest, the candidate enco…
+ [mgf1](./src/encoding/mgf1.md) — Implements the MGF1 mask-generation function defined in RFC 8017 §B.2.1. MGF1 is the building block used by EMSA-PSS an…
+ [default-rng](./src/encrypt/default-rng.md) — Single canonical adapter that lifts the `crypto.getRandomValues` CSPRNG into the `@suaveplan/types` `Rng` interface eve…
+ [oaep](./src/encrypt/oaep.md) — RSAES-OAEP encryption per RFC 8017 §7.1.1. The Optimal Asymmetric Encryption Padding (OAEP) scheme is the modern defaul…
+ [pkcs1v15](./src/encrypt/pkcs1v15.md) — RSAES-PKCS1-v1_5 encryption per RFC 8017 §7.2.1 — the legacy public-key encryption padding scheme historically used by…
+ [noble](./src/engine/noble.md) — Default `RsaCryptoEngine` backed by the pure-JS noble primitives this package ships natively. Provides the `name: "nobl…
+ [select](./src/engine/select.md) — Resolve the default `RsaCryptoEngine` for the host runtime. When `globalThis.crypto.subtle` exposes the RSA algorithms…
+ [subtle](./src/engine/subtle.md) — `RsaCryptoEngine` adapter over `globalThis.crypto.subtle`. When the host runtime exposes WebCrypto, this engine routes…
+ [errors](./src/errors/errors.md) — <!-- begin:auto-docs --> _(no submodules)_+ [errors](./src/errors/errors.md) — <!-- begin:auto-docs --> _(no submodules)_+ [format](./src/fingerprint/format.md) — Render a fingerprint byte sequence as a human- and machine-readable string. Provides four output formats covering every…
+ [spki](./src/fingerprint/spki.md) — Compute a deterministic fingerprint of an RSA public key by hashing its SPKI DER encoding (RFC 5280 §4.1 `SubjectPublic…
+ [registry](./src/handle/registry.md) — Process-wide registry for RSA key-handle providers. Every HSM, KMS, OS keychain, or PKCS#11 bridge registers a provider…
+ [software](./src/handle/software.md) — Reference `RsaKeyHandleProvider` wrapping in-memory `RsaPrivateKey` values. Implements the same interface as a real HSM…
+ [thumbprint-uri](./src/jwk/thumbprint-uri.md) — Compute the JWK Thumbprint URI per RFC 9278 §3 — the canonical `urn:ietf:params:oauth:jwk-thumbprint:<hash-alg>:<base64…
+ [thumbprint](./src/jwk/thumbprint.md) — Compute the JWK Thumbprint of an RSA public key per RFC 7638 §3.2. The thumbprint is a deterministic hash over the cano…
+ [exponent](./src/keygen/exponent.md) — Public-exponent validation for RSA key generation. The `e`-only constraints (odd, `> 1`, `< 2^256`) live here so they c…
+ [generate](./src/keygen/generate.md) — RSA key-pair generation per NIST FIPS 186-5 Appendix A.1 and SP 800-56B Rev. 2 §6.3. Two-prime by default; multi-prime…
+ [miller-rabin](./src/keygen/miller-rabin.md) — Probable-prime test per NIST FIPS 186-5 Appendix B.3.1, used by `generateRsaKeyPair` to qualify candidate primes during…
+ [multi-prime](./src/keygen/multi-prime.md) — Multi-prime RSA key generation per RFC 8017 §3 and PKCS #1 Appendix A.1.2 `OtherPrimeInfos` extension. Reachable via th…
+ [jwk-policy](./src/keys/jwk-policy.md) — Cross-check a JWK's `key_ops`, `use`, and `alg` members against the requested operation per RFC 7517 §4.2 / §4.3 / §4.4…
+ [oid](./src/keys/oid.md) — Centralised OID constants for the RSA wire formats handled by this package. Currently exports `OID_RSA_ENCRYPTION` (`1.…
+ [parse-jwk](./src/keys/parse-jwk.md) — Parses RSA JWKs per RFC 7517 §6.3.1 (public) and §6.3.2 (private). Accepts the JWK as a plain JS object literal, as a J…
+ [parse-pkcs1-private](./src/keys/parse-pkcs1-private.md) — Parses a DER-encoded `RSAPrivateKey` per RFC 8017 Appendix A.1.2 into the canonical `RsaPrivateKey` shape with mandator…
+ [parse-pkcs1-public](./src/keys/parse-pkcs1-public.md) — Parses a DER-encoded `RSAPublicKey ::= SEQUENCE { modulus INTEGER, publicExponent INTEGER }` per RFC 8017 Appendix A.1.…
+ [parse-pkcs8](./src/keys/parse-pkcs8.md) — Parses a PKCS#8 `PrivateKeyInfo` per RFC 5208 §5 — also accepts the RFC 5958 `OneAsymmetricKey` shape, which adds optio…
+ [parse-spki](./src/keys/parse-spki.md) — Parses an X.509 `SubjectPublicKeyInfo` per RFC 5280 §4.1 + Appendix A and unwraps the inner PKCS#1 `RSAPublicKey`. SPKI…
+ [parse](./src/keys/parse.md) — Public dispatcher entry-point unifying the four key-format parsers (PKCS#1, SPKI, PKCS#8, JWK) under one ergonomic call…
+ [serialize-jwk](./src/keys/serialize-jwk.md) — Inverse of `parseRsaPublicKeyJwk` and `parseRsaPrivateKeyJwk`. Emits an `RsaJwk` with every numeric field encoded as ba…
+ [serialize-pkcs1-private](./src/keys/serialize-pkcs1-private.md) — Inverse of `parseRsaPrivateKeyPkcs1`. Emits a DER `RSAPrivateKey` SEQUENCE per RFC 8017 Appendix A.1.2 from a canonical…
+ [serialize-pkcs1-public](./src/keys/serialize-pkcs1-public.md) — Inverse of `parseRsaPublicKeyPkcs1`. Emits a DER `RSAPublicKey` SEQUENCE per RFC 8017 Appendix A.1.1 from a canonical `…
+ [serialize-pkcs8](./src/keys/serialize-pkcs8.md) — Inverse of `parseRsaPrivateKeyPkcs8`. Wraps a serialised PKCS#1 `RSAPrivateKey` in a DER `PrivateKeyInfo` per RFC 5208…
+ [serialize-spki](./src/keys/serialize-spki.md) — Inverse of `parseRsaPublicKeySpki`. Wraps a serialised PKCS#1 `RSAPublicKey` in a DER `SubjectPublicKeyInfo` per RFC 52…
+ [serialize](./src/keys/serialize.md) — Public dispatcher entry-point inverse of `parse`. Selects the per-format serialiser via the same `format` discriminator…
+ [crt](./src/math/crt.md) — RSA private-key operation via the Chinese Remainder Theorem (RFC 8017 §5.1.2, second-form algorithm). Computes `m = c^d…
+ [i2osp](./src/math/i2osp.md) — Integer-to-Octet-String Primitive (RFC 8017 §4.1). Encodes a non-negative integer as a fixed-length big-endian byte str…
+ [modpow](./src/math/modpow.md) — Single-source-of-truth modular exponentiation for the entire `@suaveplan/crypto-rsa` package. Wraps `@noble/curves/abst…
+ [os2ip](./src/math/os2ip.md) — Octet-String-to-Integer Primitive (RFC 8017 §4.2). Decodes a big-endian byte string into a non-negative `bigint`. Used…
+ [metrics](./src/observability/metrics.md) — OpenTelemetry meter + instrument registry for `@suaveplan/crypto-rsa`. defines the full instrument set the prop…
+ [timing](./src/observability/timing.md) — Centralised high-resolution clock source used by every metric histogram in `@suaveplan/crypto-rsa`. Resolves to `perfor…
+ [labels](./src/pem/labels.md) — Centralised PEM textual labels per RFC 7468 §5/§10/§11 used by the public and private PEM parsers and serialisers in th…
+ [parse-private](./src/pem/parse-private.md) — Parse an RSA private key from RFC 7468 PEM textual encoding. Accepts the two canonical private-key armor labels — `"PRI…
+ [parse-public](./src/pem/parse-public.md) — Parse an RSA public key from RFC 7468 PEM textual encoding. Accepts both canonical armor labels — `"PUBLIC KEY"` (SPKI…
+ [pem-codec](./src/pem/pem-codec.md) — Underlying RFC 7468 textual-encoding codec used by the four PEM parse/serialise entry points in this module. Splits the…
+ [serialize-private](./src/pem/serialize-private.md) — Inverse of `parseRsaPrivateKeyPem`. Emits PEM textual encoding for an RSA private key in either PKCS#8 (`"PRIVATE KEY"`…
+ [serialize-public](./src/pem/serialize-public.md) — Inverse of `parseRsaPublicKeyPem`. Emits PEM textual encoding for an RSA public key in either SPKI (`"PUBLIC KEY"`, the…
+ [policy](./src/policy/modulus.md) — <!-- begin:auto-docs --> _(no submodules)_+ [modulus](./src/policy/modulus.md) — <!-- begin:auto-docs --> _(no submodules)_+ [profile](./src/profile/profile.md) — <!-- begin:auto-docs --> _(no submodules)_+ [profile](./src/profile/profile.md) — <!-- begin:auto-docs --> _(no submodules)_+ [schemas](./src/schemas/schemas.md) — <!-- begin:auto-docs --> _(no submodules)_+ [schemas](./src/schemas/schemas.md) — <!-- begin:auto-docs --> _(no submodules)_+ [pkcs1v15](./src/sign/pkcs1v15.md) — RSASSA-PKCS1-v1\_5 signature generation per RFC 8017 §8.2.1. Hashes the message with the configured algorithm, applies…
+ [pss](./src/sign/pss.md) — RSASSA-PSS signature generation per RFC 8017 §8.1.1. Hashes the message, draws a random salt of the configured length,…
+ [pkcs1v15](./src/verify/pkcs1v15.md) — RSASSA-PKCS1-v1\_5 signature verification per RFC 8017 §8.2.2. Recovers the encoded message from `signature` via `s^e m…
+ [pss](./src/verify/pss.md) — RSASSA-PSS signature verification per RFC 8017 §8.1.2. Recovers the encoded message via `s^e mod n`, then runs the EMSA…
+ [blinded_vs_unblinded_2048](./src/__benchmarks__/blinded_vs_unblinded_2048.md) — <!-- begin:auto-docs --> _(no submodules)_+ [rsa-2048-key](./src/__fixtures__/rsa-2048-key.md) — Real 2048-bit RSA keypair generated once at module-load time and exposed as canonical `RsaPublicKey` / `RsaPrivateKey`…
+ [test-keys](./src/__fixtures__/test-keys.md) — Tiny synthetic RSA keypair for unit tests that need an `RsaPrivateKey` / `RsaPublicKey` shape but don't need to run a r…
+ [reader](./src/asn1/reader.md) — Strict-DER reader scoped to the RSA key shapes defined in RFC 8017 Appendix A (PKCS#1), RFC 5280 §4.1 + Appendix A (`Su…
+ [writer](./src/asn1/writer.md) — Strict-DER encoder mirroring `asn1/reader`. Encodes the same minimal subset of ASN.1 needed to serialise RSA keys in PK…
+ [blinding](./src/blinding/blinding.md) — <!-- begin:auto-docs --> _(no submodules)_+ [blinding](./src/blinding/blinding.md) — <!-- begin:auto-docs --> _(no submodules)_+ [oaep](./src/decrypt/oaep.md) — RSAES-OAEP decryption per RFC 8017 §7.1.2 — counterpart to `encrypt/oaep`. Modern default for RSA public-key encryption.
+ [pkcs1v15](./src/decrypt/pkcs1v15.md) — RSAES-PKCS1-v1_5 decryption per RFC 8017 §7.2.2. Counterpart to `encrypt/pkcs1v15`. Provides legacy interop for protoco…
+ [emsa-pkcs1-v1_5](./src/encoding/emsa-pkcs1-v1_5.md) — Implements EMSA-PKCS1-v1\_5-ENCODE from RFC 8017 §9.2 — the encoding method underlying RSASSA-PKCS1-v1\_5 signatures. T…
+ [emsa-pss-encode](./src/encoding/emsa-pss-encode.md) — Implements the EMSA-PSS encoding operation defined in RFC 8017 §9.1.1. Given a message digest, an encoded-message bit l…
+ [emsa-pss-verify](./src/encoding/emsa-pss-verify.md) — Implements the EMSA-PSS verification operation defined in RFC 8017 §9.1.2. Given the message digest, the candidate enco…
+ [mgf1](./src/encoding/mgf1.md) — Implements the MGF1 mask-generation function defined in RFC 8017 §B.2.1. MGF1 is the building block used by EMSA-PSS an…
+ [default-rng](./src/encrypt/default-rng.md) — Single canonical adapter that lifts the `crypto.getRandomValues` CSPRNG into the `@suaveplan/types` `Rng` interface eve…
+ [oaep](./src/encrypt/oaep.md) — RSAES-OAEP encryption per RFC 8017 §7.1.1. The Optimal Asymmetric Encryption Padding (OAEP) scheme is the modern defaul…
+ [pkcs1v15](./src/encrypt/pkcs1v15.md) — RSAES-PKCS1-v1_5 encryption per RFC 8017 §7.2.1 — the legacy public-key encryption padding scheme historically used by…
+ [noble](./src/engine/noble.md) — Default `RsaCryptoEngine` backed by the pure-JS noble primitives this package ships natively. Provides the `name: "nobl…
+ [select](./src/engine/select.md) — Resolve the default `RsaCryptoEngine` for the host runtime. When `globalThis.crypto.subtle` exposes the RSA algorithms…
+ [subtle](./src/engine/subtle.md) — `RsaCryptoEngine` adapter over `globalThis.crypto.subtle`. When the host runtime exposes WebCrypto, this engine routes…
+ [errors](./src/errors/errors.md) — <!-- begin:auto-docs --> _(no submodules)_+ [errors](./src/errors/errors.md) — <!-- begin:auto-docs --> _(no submodules)_+ [format](./src/fingerprint/format.md) — Render a fingerprint byte sequence as a human- and machine-readable string. Provides four output formats covering every…
+ [spki](./src/fingerprint/spki.md) — Compute a deterministic fingerprint of an RSA public key by hashing its SPKI DER encoding (RFC 5280 §4.1 `SubjectPublic…
+ [registry](./src/handle/registry.md) — Process-wide registry for RSA key-handle providers. Every HSM, KMS, OS keychain, or PKCS#11 bridge registers a provider…
+ [software](./src/handle/software.md) — Reference `RsaKeyHandleProvider` wrapping in-memory `RsaPrivateKey` values. Implements the same interface as a real HSM…
+ [thumbprint-uri](./src/jwk/thumbprint-uri.md) — Compute the JWK Thumbprint URI per RFC 9278 §3 — the canonical `urn:ietf:params:oauth:jwk-thumbprint:<hash-alg>:<base64…
+ [thumbprint](./src/jwk/thumbprint.md) — Compute the JWK Thumbprint of an RSA public key per RFC 7638 §3.2. The thumbprint is a deterministic hash over the cano…
+ [exponent](./src/keygen/exponent.md) — Public-exponent validation for RSA key generation. The `e`-only constraints (odd, `> 1`, `< 2^256`) live here so they c…
+ [generate](./src/keygen/generate.md) — RSA key-pair generation per NIST FIPS 186-5 Appendix A.1 and SP 800-56B Rev. 2 §6.3. Two-prime by default; multi-prime…
+ [miller-rabin](./src/keygen/miller-rabin.md) — Probable-prime test per NIST FIPS 186-5 Appendix B.3.1, used by `generateRsaKeyPair` to qualify candidate primes during…
+ [multi-prime](./src/keygen/multi-prime.md) — Multi-prime RSA key generation per RFC 8017 §3 and PKCS #1 Appendix A.1.2 `OtherPrimeInfos` extension. Reachable via th…
+ [jwk-policy](./src/keys/jwk-policy.md) — Cross-check a JWK's `key_ops`, `use`, and `alg` members against the requested operation per RFC 7517 §4.2 / §4.3 / §4.4…
+ [oid](./src/keys/oid.md) — Centralised OID constants for the RSA wire formats handled by this package. Currently exports `OID_RSA_ENCRYPTION` (`1.…
+ [parse-jwk](./src/keys/parse-jwk.md) — Parses RSA JWKs per RFC 7517 §6.3.1 (public) and §6.3.2 (private). Accepts the JWK as a plain JS object literal, as a J…
+ [parse-pkcs1-private](./src/keys/parse-pkcs1-private.md) — Parses a DER-encoded `RSAPrivateKey` per RFC 8017 Appendix A.1.2 into the canonical `RsaPrivateKey` shape with mandator…
+ [parse-pkcs1-public](./src/keys/parse-pkcs1-public.md) — Parses a DER-encoded `RSAPublicKey ::= SEQUENCE { modulus INTEGER, publicExponent INTEGER }` per RFC 8017 Appendix A.1.…
+ [parse-pkcs8](./src/keys/parse-pkcs8.md) — Parses a PKCS#8 `PrivateKeyInfo` per RFC 5208 §5 — also accepts the RFC 5958 `OneAsymmetricKey` shape, which adds optio…
+ [parse-spki](./src/keys/parse-spki.md) — Parses an X.509 `SubjectPublicKeyInfo` per RFC 5280 §4.1 + Appendix A and unwraps the inner PKCS#1 `RSAPublicKey`. SPKI…
+ [parse](./src/keys/parse.md) — Public dispatcher entry-point unifying the four key-format parsers (PKCS#1, SPKI, PKCS#8, JWK) under one ergonomic call…
+ [serialize-jwk](./src/keys/serialize-jwk.md) — Inverse of `parseRsaPublicKeyJwk` and `parseRsaPrivateKeyJwk`. Emits an `RsaJwk` with every numeric field encoded as ba…
+ [serialize-pkcs1-private](./src/keys/serialize-pkcs1-private.md) — Inverse of `parseRsaPrivateKeyPkcs1`. Emits a DER `RSAPrivateKey` SEQUENCE per RFC 8017 Appendix A.1.2 from a canonical…
+ [serialize-pkcs1-public](./src/keys/serialize-pkcs1-public.md) — Inverse of `parseRsaPublicKeyPkcs1`. Emits a DER `RSAPublicKey` SEQUENCE per RFC 8017 Appendix A.1.1 from a canonical `…
+ [serialize-pkcs8](./src/keys/serialize-pkcs8.md) — Inverse of `parseRsaPrivateKeyPkcs8`. Wraps a serialised PKCS#1 `RSAPrivateKey` in a DER `PrivateKeyInfo` per RFC 5208…
+ [serialize-spki](./src/keys/serialize-spki.md) — Inverse of `parseRsaPublicKeySpki`. Wraps a serialised PKCS#1 `RSAPublicKey` in a DER `SubjectPublicKeyInfo` per RFC 52…
+ [serialize](./src/keys/serialize.md) — Public dispatcher entry-point inverse of `parse`. Selects the per-format serialiser via the same `format` discriminator…
+ [crt](./src/math/crt.md) — RSA private-key operation via the Chinese Remainder Theorem (RFC 8017 §5.1.2, second-form algorithm). Computes `m = c^d…
+ [i2osp](./src/math/i2osp.md) — Integer-to-Octet-String Primitive (RFC 8017 §4.1). Encodes a non-negative integer as a fixed-length big-endian byte str…
+ [modpow](./src/math/modpow.md) — Single-source-of-truth modular exponentiation for the entire `@suaveplan/crypto-rsa` package. Wraps `@noble/curves/abst…
+ [os2ip](./src/math/os2ip.md) — Octet-String-to-Integer Primitive (RFC 8017 §4.2). Decodes a big-endian byte string into a non-negative `bigint`. Used…
+ [metrics](./src/observability/metrics.md) — OpenTelemetry meter + instrument registry for `@suaveplan/crypto-rsa`. defines the full instrument set the prop…
+ [timing](./src/observability/timing.md) — Centralised high-resolution clock source used by every metric histogram in `@suaveplan/crypto-rsa`. Resolves to `perfor…
+ [labels](./src/pem/labels.md) — Centralised PEM textual labels per RFC 7468 §5/§10/§11 used by the public and private PEM parsers and serialisers in th…
+ [parse-private](./src/pem/parse-private.md) — Parse an RSA private key from RFC 7468 PEM textual encoding. Accepts the two canonical private-key armor labels — `"PRI…
+ [parse-public](./src/pem/parse-public.md) — Parse an RSA public key from RFC 7468 PEM textual encoding. Accepts both canonical armor labels — `"PUBLIC KEY"` (SPKI…
+ [pem-codec](./src/pem/pem-codec.md) — Underlying RFC 7468 textual-encoding codec used by the four PEM parse/serialise entry points in this module. Splits the…
+ [serialize-private](./src/pem/serialize-private.md) — Inverse of `parseRsaPrivateKeyPem`. Emits PEM textual encoding for an RSA private key in either PKCS#8 (`"PRIVATE KEY"`…
+ [serialize-public](./src/pem/serialize-public.md) — Inverse of `parseRsaPublicKeyPem`. Emits PEM textual encoding for an RSA public key in either SPKI (`"PUBLIC KEY"`, the…
+ [policy](./src/policy/modulus.md) — <!-- begin:auto-docs --> _(no submodules)_+ [modulus](./src/policy/modulus.md) — <!-- begin:auto-docs --> _(no submodules)_+ [profile](./src/profile/profile.md) — <!-- begin:auto-docs --> _(no submodules)_+ [profile](./src/profile/profile.md) — <!-- begin:auto-docs --> _(no submodules)_+ [schemas](./src/schemas/schemas.md) — <!-- begin:auto-docs --> _(no submodules)_+ [schemas](./src/schemas/schemas.md) — <!-- begin:auto-docs --> _(no submodules)_+ [pkcs1v15](./src/sign/pkcs1v15.md) — RSASSA-PKCS1-v1\_5 signature generation per RFC 8017 §8.2.1. Hashes the message with the configured algorithm, applies…
+ [pss](./src/sign/pss.md) — RSASSA-PSS signature generation per RFC 8017 §8.1.1. Hashes the message, draws a random salt of the configured length,…
+ [pkcs1v15](./src/verify/pkcs1v15.md) — RSASSA-PKCS1-v1\_5 signature verification per RFC 8017 §8.2.2. Recovers the encoded message from `signature` via `s^e m…
+ [pss](./src/verify/pss.md) — RSASSA-PSS signature verification per RFC 8017 §8.1.2. Recovers the encoded message via `s^e mod n`, then runs the EMSA…
+ [blinded_vs_unblinded_2048](./src/__benchmarks__/blinded_vs_unblinded_2048.md) — <!-- begin:auto-docs --> _(no submodules)_+ [rsa-2048-key](./src/__fixtures__/rsa-2048-key.md) — Real 2048-bit RSA keypair generated once at module-load time and exposed as canonical `RsaPublicKey` / `RsaPrivateKey`…
+ [test-keys](./src/__fixtures__/test-keys.md) — Tiny synthetic RSA keypair for unit tests that need an `RsaPrivateKey` / `RsaPublicKey` shape but don't need to run a r…
+ [reader](./src/asn1/reader.md) — Strict-DER reader scoped to the RSA key shapes defined in RFC 8017 Appendix A (PKCS#1), RFC 5280 §4.1 + Appendix A (`Su…
+ [writer](./src/asn1/writer.md) — Strict-DER encoder mirroring `asn1/reader`. Encodes the same minimal subset of ASN.1 needed to serialise RSA keys in PK…
+ [blinding](./src/blinding/blinding.md) — <!-- begin:auto-docs --> _(no submodules)_ + [blinding](./src/blinding/blinding.md) — <!-- begin:auto-docs --> _(no submodules)_+ [oaep](./src/decrypt/oaep.md) — RSAES-OAEP decryption per RFC 8017 §7.1.2 — counterpart to `encrypt/oaep`. Modern default for RSA public-key encryption.
+ [pkcs1v15](./src/decrypt/pkcs1v15.md) — RSAES-PKCS1-v1_5 decryption per RFC 8017 §7.2.2. Counterpart to `encrypt/pkcs1v15`. Provides legacy interop for protoco…
+ [emsa-pkcs1-v1_5](./src/encoding/emsa-pkcs1-v1_5.md) — Implements EMSA-PKCS1-v1\_5-ENCODE from RFC 8017 §9.2 — the encoding method underlying RSASSA-PKCS1-v1\_5 signatures. T…
+ [emsa-pss-encode](./src/encoding/emsa-pss-encode.md) — Implements the EMSA-PSS encoding operation defined in RFC 8017 §9.1.1. Given a message digest, an encoded-message bit l…
+ [emsa-pss-verify](./src/encoding/emsa-pss-verify.md) — Implements the EMSA-PSS verification operation defined in RFC 8017 §9.1.2. Given the message digest, the candidate enco…
+ [mgf1](./src/encoding/mgf1.md) — Implements the MGF1 mask-generation function defined in RFC 8017 §B.2.1. MGF1 is the building block used by EMSA-PSS an…
+ [default-rng](./src/encrypt/default-rng.md) — Single canonical adapter that lifts the `crypto.getRandomValues` CSPRNG into the `@suaveplan/types` `Rng` interface eve…
+ [oaep](./src/encrypt/oaep.md) — RSAES-OAEP encryption per RFC 8017 §7.1.1. The Optimal Asymmetric Encryption Padding (OAEP) scheme is the modern defaul…
+ [pkcs1v15](./src/encrypt/pkcs1v15.md) — RSAES-PKCS1-v1_5 encryption per RFC 8017 §7.2.1 — the legacy public-key encryption padding scheme historically used by…
+ [noble](./src/engine/noble.md) — Default `RsaCryptoEngine` backed by the pure-JS noble primitives this package ships natively. Provides the `name: "nobl…
+ [select](./src/engine/select.md) — Resolve the default `RsaCryptoEngine` for the host runtime. When `globalThis.crypto.subtle` exposes the RSA algorithms…
+ [subtle](./src/engine/subtle.md) — `RsaCryptoEngine` adapter over `globalThis.crypto.subtle`. When the host runtime exposes WebCrypto, this engine routes…
+ [errors](./src/errors/errors.md) — <!-- begin:auto-docs --> _(no submodules)_ + [errors](./src/errors/errors.md) — <!-- begin:auto-docs --> _(no submodules)_+ [format](./src/fingerprint/format.md) — Render a fingerprint byte sequence as a human- and machine-readable string. Provides four output formats covering every…
+ [spki](./src/fingerprint/spki.md) — Compute a deterministic fingerprint of an RSA public key by hashing its SPKI DER encoding (RFC 5280 §4.1 `SubjectPublic…
+ [registry](./src/handle/registry.md) — Process-wide registry for RSA key-handle providers. Every HSM, KMS, OS keychain, or PKCS#11 bridge registers a provider…
+ [software](./src/handle/software.md) — Reference `RsaKeyHandleProvider` wrapping in-memory `RsaPrivateKey` values. Implements the same interface as a real HSM…
+ [thumbprint-uri](./src/jwk/thumbprint-uri.md) — Compute the JWK Thumbprint URI per RFC 9278 §3 — the canonical `urn:ietf:params:oauth:jwk-thumbprint:<hash-alg>:<base64…
+ [thumbprint](./src/jwk/thumbprint.md) — Compute the JWK Thumbprint of an RSA public key per RFC 7638 §3.2. The thumbprint is a deterministic hash over the cano…
+ [exponent](./src/keygen/exponent.md) — Public-exponent validation for RSA key generation. The `e`-only constraints (odd, `> 1`, `< 2^256`) live here so they c…
+ [generate](./src/keygen/generate.md) — RSA key-pair generation per NIST FIPS 186-5 Appendix A.1 and SP 800-56B Rev. 2 §6.3. Two-prime by default; multi-prime…
+ [miller-rabin](./src/keygen/miller-rabin.md) — Probable-prime test per NIST FIPS 186-5 Appendix B.3.1, used by `generateRsaKeyPair` to qualify candidate primes during…
+ [multi-prime](./src/keygen/multi-prime.md) — Multi-prime RSA key generation per RFC 8017 §3 and PKCS #1 Appendix A.1.2 `OtherPrimeInfos` extension. Reachable via th…
+ [jwk-policy](./src/keys/jwk-policy.md) — Cross-check a JWK's `key_ops`, `use`, and `alg` members against the requested operation per RFC 7517 §4.2 / §4.3 / §4.4…
+ [oid](./src/keys/oid.md) — Centralised OID constants for the RSA wire formats handled by this package. Currently exports `OID_RSA_ENCRYPTION` (`1.…
+ [parse-jwk](./src/keys/parse-jwk.md) — Parses RSA JWKs per RFC 7517 §6.3.1 (public) and §6.3.2 (private). Accepts the JWK as a plain JS object literal, as a J…
+ [parse-pkcs1-private](./src/keys/parse-pkcs1-private.md) — Parses a DER-encoded `RSAPrivateKey` per RFC 8017 Appendix A.1.2 into the canonical `RsaPrivateKey` shape with mandator…
+ [parse-pkcs1-public](./src/keys/parse-pkcs1-public.md) — Parses a DER-encoded `RSAPublicKey ::= SEQUENCE { modulus INTEGER, publicExponent INTEGER }` per RFC 8017 Appendix A.1.…
+ [parse-pkcs8](./src/keys/parse-pkcs8.md) — Parses a PKCS#8 `PrivateKeyInfo` per RFC 5208 §5 — also accepts the RFC 5958 `OneAsymmetricKey` shape, which adds optio…
+ [parse-spki](./src/keys/parse-spki.md) — Parses an X.509 `SubjectPublicKeyInfo` per RFC 5280 §4.1 + Appendix A and unwraps the inner PKCS#1 `RSAPublicKey`. SPKI…
+ [parse](./src/keys/parse.md) — Public dispatcher entry-point unifying the four key-format parsers (PKCS#1, SPKI, PKCS#8, JWK) under one ergonomic call…
+ [serialize-jwk](./src/keys/serialize-jwk.md) — Inverse of `parseRsaPublicKeyJwk` and `parseRsaPrivateKeyJwk`. Emits an `RsaJwk` with every numeric field encoded as ba…
+ [serialize-pkcs1-private](./src/keys/serialize-pkcs1-private.md) — Inverse of `parseRsaPrivateKeyPkcs1`. Emits a DER `RSAPrivateKey` SEQUENCE per RFC 8017 Appendix A.1.2 from a canonical…
+ [serialize-pkcs1-public](./src/keys/serialize-pkcs1-public.md) — Inverse of `parseRsaPublicKeyPkcs1`. Emits a DER `RSAPublicKey` SEQUENCE per RFC 8017 Appendix A.1.1 from a canonical `…
+ [serialize-pkcs8](./src/keys/serialize-pkcs8.md) — Inverse of `parseRsaPrivateKeyPkcs8`. Wraps a serialised PKCS#1 `RSAPrivateKey` in a DER `PrivateKeyInfo` per RFC 5208…
+ [serialize-spki](./src/keys/serialize-spki.md) — Inverse of `parseRsaPublicKeySpki`. Wraps a serialised PKCS#1 `RSAPublicKey` in a DER `SubjectPublicKeyInfo` per RFC 52…
+ [serialize](./src/keys/serialize.md) — Public dispatcher entry-point inverse of `parse`. Selects the per-format serialiser via the same `format` discriminator…
+ [crt](./src/math/crt.md) — RSA private-key operation via the Chinese Remainder Theorem (RFC 8017 §5.1.2, second-form algorithm). Computes `m = c^d…
+ [i2osp](./src/math/i2osp.md) — Integer-to-Octet-String Primitive (RFC 8017 §4.1). Encodes a non-negative integer as a fixed-length big-endian byte str…
+ [modpow](./src/math/modpow.md) — Single-source-of-truth modular exponentiation for the entire `@suaveplan/crypto-rsa` package. Wraps `@noble/curves/abst…
+ [os2ip](./src/math/os2ip.md) — Octet-String-to-Integer Primitive (RFC 8017 §4.2). Decodes a big-endian byte string into a non-negative `bigint`. Used…
+ [metrics](./src/observability/metrics.md) — OpenTelemetry meter + instrument registry for `@suaveplan/crypto-rsa`. defines the full instrument set the prop…
+ [timing](./src/observability/timing.md) — Centralised high-resolution clock source used by every metric histogram in `@suaveplan/crypto-rsa`. Resolves to `perfor…
+ [labels](./src/pem/labels.md) — Centralised PEM textual labels per RFC 7468 §5/§10/§11 used by the public and private PEM parsers and serialisers in th…
+ [parse-private](./src/pem/parse-private.md) — Parse an RSA private key from RFC 7468 PEM textual encoding. Accepts the two canonical private-key armor labels — `"PRI…
+ [parse-public](./src/pem/parse-public.md) — Parse an RSA public key from RFC 7468 PEM textual encoding. Accepts both canonical armor labels — `"PUBLIC KEY"` (SPKI…
+ [pem-codec](./src/pem/pem-codec.md) — Underlying RFC 7468 textual-encoding codec used by the four PEM parse/serialise entry points in this module. Splits the…
+ [serialize-private](./src/pem/serialize-private.md) — Inverse of `parseRsaPrivateKeyPem`. Emits PEM textual encoding for an RSA private key in either PKCS#8 (`"PRIVATE KEY"`…
+ [serialize-public](./src/pem/serialize-public.md) — Inverse of `parseRsaPublicKeyPem`. Emits PEM textual encoding for an RSA public key in either SPKI (`"PUBLIC KEY"`, the…
+ [policy](./src/policy/modulus.md) — <!-- begin:auto-docs --> _(no submodules)_ + [modulus](./src/policy/modulus.md) — <!-- begin:auto-docs --> _(no submodules)_+ [profile](./src/profile/profile.md) — <!-- begin:auto-docs --> _(no submodules)_ + [profile](./src/profile/profile.md) — <!-- begin:auto-docs --> _(no submodules)_+ [schemas](./src/schemas/schemas.md) — <!-- begin:auto-docs --> _(no submodules)_ + [schemas](./src/schemas/schemas.md) — <!-- begin:auto-docs --> _(no submodules)_+ [pkcs1v15](./src/sign/pkcs1v15.md) — RSASSA-PKCS1-v1\_5 signature generation per RFC 8017 §8.2.1. Hashes the message with the configured algorithm, applies…
+ [pss](./src/sign/pss.md) — RSASSA-PSS signature generation per RFC 8017 §8.1.1. Hashes the message, draws a random salt of the configured length,…
+ [pkcs1v15](./src/verify/pkcs1v15.md) — RSASSA-PKCS1-v1\_5 signature verification per RFC 8017 §8.2.2. Recovers the encoded message from `signature` via `s^e m…
+ [pss](./src/verify/pss.md) — RSASSA-PSS signature verification per RFC 8017 §8.1.2. Recovers the encoded message via `s^e mod n`, then runs the EMSA…
## Install
```bash
bun add @suaveplan/crypto-rsa
```
## Quick start — parse and serialise
```ts
import {
parseRsaPublicKey,
parseRsaPublicKeyPem,
serializeRsaPublicKey,
serializeRsaPublicKeyPem,
} from "@suaveplan/crypto-rsa";
// PEM in, PEM out (round-trip).
const key = parseRsaPublicKeyPem(spkiPemString);
const back = serializeRsaPublicKeyPem(key); // -----BEGIN PUBLIC KEY-----…
// DER + JWK round-trips.
const der = serializeRsaPublicKey(key, { format: "spki" });
const jwk = serializeRsaPublicKey(key, { format: "jwk" });
const fromDer = parseRsaPublicKey(der as Uint8Array, { format: "spki" });
const fromJwk = parseRsaPublicKey(jwk, { format: "jwk" });
```
## Quick start — modulus policy
```ts
import { enforceMaxBitLength, enforceMinBitLength } from "@suaveplan/crypto-rsa/policy";
enforceMinBitLength(publicKey); // throws if < 2048 bits
enforceMinBitLength(legacyKey, { allowLegacy: true }); // accepts ≥ 1024 bits
enforceMaxBitLength(publicKey); // throws if > 16384 bits
```
## Quick start — math primitives
```ts
import {
modPow,
i2osp,
os2ip,
rsaPrivateOperationCrt,
} from "@suaveplan/crypto-rsa/math";
const c = os2ip(ciphertext);
const m = rsaPrivateOperationCrt(c, privateKey); // CRT-aware private op
const plaintext = i2osp(m, ciphertext.length);
const v = modPow(c, publicKey.e, publicKey.n); // public op
```
## Stage 1 modules
| Module | Doc |
| --- | --- |
| `asn1/reader` | [`src/asn1/reader.md`](src/asn1/reader.md) |
| `asn1/writer` | [`src/asn1/writer.md`](src/asn1/writer.md) |
| `errors/errors` | [`src/errors/errors.md`](src/errors/errors.md) |
| `keys/oid` | [`src/keys/oid.md`](src/keys/oid.md) |
| `keys/parse-pkcs1-public` | [`src/keys/parse-pkcs1-public.md`](src/keys/parse-pkcs1-public.md) |
| `keys/parse-pkcs1-private` | [`src/keys/parse-pkcs1-private.md`](src/keys/parse-pkcs1-private.md) |
| `keys/parse-spki` | [`src/keys/parse-spki.md`](src/keys/parse-spki.md) |
| `keys/parse-pkcs8` | [`src/keys/parse-pkcs8.md`](src/keys/parse-pkcs8.md) |
| `keys/parse-jwk` | [`src/keys/parse-jwk.md`](src/keys/parse-jwk.md) |
| `keys/parse` | [`src/keys/parse.md`](src/keys/parse.md) |
| `keys/serialize-pkcs1-public` | [`src/keys/serialize-pkcs1-public.md`](src/keys/serialize-pkcs1-public.md) |
| `keys/serialize-pkcs1-private` | [`src/keys/serialize-pkcs1-private.md`](src/keys/serialize-pkcs1-private.md) |
| `keys/serialize-spki` | [`src/keys/serialize-spki.md`](src/keys/serialize-spki.md) |
| `keys/serialize-pkcs8` | [`src/keys/serialize-pkcs8.md`](src/keys/serialize-pkcs8.md) |
| `keys/serialize-jwk` | [`src/keys/serialize-jwk.md`](src/keys/serialize-jwk.md) |
| `keys/serialize` | [`src/keys/serialize.md`](src/keys/serialize.md) |
| `math/modpow` | [`src/math/modpow.md`](src/math/modpow.md) |
| `math/i2osp` | [`src/math/i2osp.md`](src/math/i2osp.md) |
| `math/os2ip` | [`src/math/os2ip.md`](src/math/os2ip.md) |
| `math/crt` | [`src/math/crt.md`](src/math/crt.md) |
| `pem/labels` | [`src/pem/labels.md`](src/pem/labels.md) |
| `pem/pem-codec` | [`src/pem/pem-codec.md`](src/pem/pem-codec.md) |
| `pem/parse-public` | [`src/pem/parse-public.md`](src/pem/parse-public.md) |
| `pem/parse-private` | [`src/pem/parse-private.md`](src/pem/parse-private.md) |
| `pem/serialize-public` | [`src/pem/serialize-public.md`](src/pem/serialize-public.md) |
| `pem/serialize-private` | [`src/pem/serialize-private.md`](src/pem/serialize-private.md) |
| `policy/modulus` | [`src/policy/modulus.md`](src/policy/modulus.md) |
| `schemas/schemas` | [`src/schemas/schemas.md`](src/schemas/schemas.md) |
| `observability/metrics` | [`src/observability/metrics.md`](src/observability/metrics.md) |
## Stage 2 modules
| Module | Doc |
| --- | --- |
| `encoding/mgf1` | [`src/encoding/mgf1.md`](src/encoding/mgf1.md) |
| `encoding/emsa-pkcs1-v1_5` | [`src/encoding/emsa-pkcs1-v1_5.md`](src/encoding/emsa-pkcs1-v1_5.md) |
| `encoding/emsa-pss-encode` | [`src/encoding/emsa-pss-encode.md`](src/encoding/emsa-pss-encode.md) |
| `encoding/emsa-pss-verify` | [`src/encoding/emsa-pss-verify.md`](src/encoding/emsa-pss-verify.md) |
| `sign/pkcs1v15` | [`src/sign/pkcs1v15.md`](src/sign/pkcs1v15.md) |
| `sign/pss` | [`src/sign/pss.md`](src/sign/pss.md) |
| `verify/pkcs1v15` | [`src/verify/pkcs1v15.md`](src/verify/pkcs1v15.md) |
| `verify/pss` | [`src/verify/pss.md`](src/verify/pss.md) |
| `observability/timing` | [`src/observability/timing.md`](src/observability/timing.md) |
## Stage 3 modules
| Module | Doc |
| --- | --- |
| `blinding/blinding` | [`src/blinding/blinding.md`](src/blinding/blinding.md) |
| `encrypt/pkcs1v15` | [`src/encrypt/pkcs1v15.md`](src/encrypt/pkcs1v15.md) |
| `encrypt/oaep` | [`src/encrypt/oaep.md`](src/encrypt/oaep.md) |
| `encrypt/default-rng` | [`src/encrypt/default-rng.md`](src/encrypt/default-rng.md) |
| `decrypt/pkcs1v15` | [`src/decrypt/pkcs1v15.md`](src/decrypt/pkcs1v15.md) |
| `decrypt/oaep` | [`src/decrypt/oaep.md`](src/decrypt/oaep.md) |
| `keygen/generate` | [`src/keygen/generate.md`](src/keygen/generate.md) |
| `keygen/miller-rabin` | [`src/keygen/miller-rabin.md`](src/keygen/miller-rabin.md) |
| `keygen/exponent` | [`src/keygen/exponent.md`](src/keygen/exponent.md) |
| `keygen/multi-prime` | [`src/keygen/multi-prime.md`](src/keygen/multi-prime.md) |
## Stage 4 modules
| Module | Doc |
| --- | --- |
| `jwk/thumbprint` | [`src/jwk/thumbprint.md`](src/jwk/thumbprint.md) |
| `jwk/thumbprint-uri` | [`src/jwk/thumbprint-uri.md`](src/jwk/thumbprint-uri.md) |
| `fingerprint/spki` | [`src/fingerprint/spki.md`](src/fingerprint/spki.md) |
| `fingerprint/format` | [`src/fingerprint/format.md`](src/fingerprint/format.md) |
| `engine/noble` | [`src/engine/noble.md`](src/engine/noble.md) |
| `engine/subtle` | [`src/engine/subtle.md`](src/engine/subtle.md) |
| `engine/select` | [`src/engine/select.md`](src/engine/select.md) |
| `handle/registry` | [`src/handle/registry.md`](src/handle/registry.md) |
| `handle/software` | [`src/handle/software.md`](src/handle/software.md) |
| `profile/profile` | [`src/profile/profile.md`](src/profile/profile.md) |
| `keys/jwk-policy` | [`src/keys/jwk-policy.md`](src/keys/jwk-policy.md) |
## Stage roadmap
+ __Stage 1__: ASN.1 / DER, key parse + serialise (PKCS#1, SPKI, PKCS#8, JWK, PEM),
modulus-length policy, math primitives, full error / observability registry.
+ __Stage 2__: MGF1, EMSA-PKCS1-v1_5, EMSA-PSS, sign / verify (RSASSA-PKCS1-v1_5 + PSS).
+ __Stage 3__: encrypt / decrypt (PKCS#1 v1.5 + OAEP), key generation (Miller-Rabin,
multi-prime), blinding (RFC 8017 §5.1.2 / Kocher 1996).
+ __Stage 4__ (this release): blinding wired into sign paths, JWK thumbprint (RFC 7638)
+ thumbprint URI (RFC 9278), SPKI fingerprint with four output formats,
`RsaCryptoEngine` adapter (noble + WebCrypto subtle + auto-selector), `RsaKeyHandle`
HSM bridge with reference `SoftwareRsaKeyHandleProvider`, FIPS 186-5 profile,
JWK `key_ops` / `use` / `alg` enforcement.
## License
MIT

View File

@@ -0,0 +1,546 @@
{
"name": "@suaveplan/crypto-rsa",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "RSA primitives — RFC 8017 PKCS #1 v2.2, RFC 5208 PKCS #8, RFC 5280 SPKI, RFC 7517/7518 JWK, RFC 7468 PEM. Universal; Stage 1 ships ASN.1/DER, key parse/serialise across PKCS#1/SPKI/PKCS#8/JWK/PEM, modulus-length policy, modular-arithmetic primitives, and the error/observability registry.",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"require": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"./asn1": {
"import": {
"types": "./dist/asn1/index.d.ts",
"default": "./dist/asn1/index.js"
},
"require": {
"types": "./dist/asn1/index.d.ts",
"default": "./dist/asn1/index.js"
}
},
"./keys": {
"import": {
"types": "./dist/keys/index.d.ts",
"default": "./dist/keys/index.js"
},
"require": {
"types": "./dist/keys/index.d.ts",
"default": "./dist/keys/index.js"
}
},
"./math": {
"import": {
"types": "./dist/math/index.d.ts",
"default": "./dist/math/index.js"
},
"require": {
"types": "./dist/math/index.d.ts",
"default": "./dist/math/index.js"
}
},
"./pem": {
"import": {
"types": "./dist/pem/index.d.ts",
"default": "./dist/pem/index.js"
},
"require": {
"types": "./dist/pem/index.d.ts",
"default": "./dist/pem/index.js"
}
},
"./policy": {
"import": {
"types": "./dist/policy/index.d.ts",
"default": "./dist/policy/index.js"
},
"require": {
"types": "./dist/policy/index.d.ts",
"default": "./dist/policy/index.js"
}
},
"./schemas": {
"import": {
"types": "./dist/schemas/index.d.ts",
"default": "./dist/schemas/index.js"
},
"require": {
"types": "./dist/schemas/index.d.ts",
"default": "./dist/schemas/index.js"
}
},
"./observability": {
"import": {
"types": "./dist/observability/index.d.ts",
"default": "./dist/observability/index.js"
},
"require": {
"types": "./dist/observability/index.d.ts",
"default": "./dist/observability/index.js"
}
},
"./errors": {
"import": {
"types": "./dist/errors/index.d.ts",
"default": "./dist/errors/index.js"
},
"require": {
"types": "./dist/errors/index.d.ts",
"default": "./dist/errors/index.js"
}
},
"./encoding": {
"import": {
"types": "./dist/encoding/index.d.ts",
"default": "./dist/encoding/index.js"
},
"require": {
"types": "./dist/encoding/index.d.ts",
"default": "./dist/encoding/index.js"
}
},
"./encoding/mgf1": {
"import": {
"types": "./dist/encoding/mgf1.d.ts",
"default": "./dist/encoding/mgf1.js"
},
"require": {
"types": "./dist/encoding/mgf1.d.ts",
"default": "./dist/encoding/mgf1.js"
}
},
"./encoding/emsa-pkcs1-v1_5": {
"import": {
"types": "./dist/encoding/emsa-pkcs1-v1_5.d.ts",
"default": "./dist/encoding/emsa-pkcs1-v1_5.js"
},
"require": {
"types": "./dist/encoding/emsa-pkcs1-v1_5.d.ts",
"default": "./dist/encoding/emsa-pkcs1-v1_5.js"
}
},
"./encoding/emsa-pss-encode": {
"import": {
"types": "./dist/encoding/emsa-pss-encode.d.ts",
"default": "./dist/encoding/emsa-pss-encode.js"
},
"require": {
"types": "./dist/encoding/emsa-pss-encode.d.ts",
"default": "./dist/encoding/emsa-pss-encode.js"
}
},
"./encoding/emsa-pss-verify": {
"import": {
"types": "./dist/encoding/emsa-pss-verify.d.ts",
"default": "./dist/encoding/emsa-pss-verify.js"
},
"require": {
"types": "./dist/encoding/emsa-pss-verify.d.ts",
"default": "./dist/encoding/emsa-pss-verify.js"
}
},
"./sign": {
"import": {
"types": "./dist/sign/index.d.ts",
"default": "./dist/sign/index.js"
},
"require": {
"types": "./dist/sign/index.d.ts",
"default": "./dist/sign/index.js"
}
},
"./sign/pkcs1v15": {
"import": {
"types": "./dist/sign/pkcs1v15.d.ts",
"default": "./dist/sign/pkcs1v15.js"
},
"require": {
"types": "./dist/sign/pkcs1v15.d.ts",
"default": "./dist/sign/pkcs1v15.js"
}
},
"./sign/pss": {
"import": {
"types": "./dist/sign/pss.d.ts",
"default": "./dist/sign/pss.js"
},
"require": {
"types": "./dist/sign/pss.d.ts",
"default": "./dist/sign/pss.js"
}
},
"./verify": {
"import": {
"types": "./dist/verify/index.d.ts",
"default": "./dist/verify/index.js"
},
"require": {
"types": "./dist/verify/index.d.ts",
"default": "./dist/verify/index.js"
}
},
"./verify/pkcs1v15": {
"import": {
"types": "./dist/verify/pkcs1v15.d.ts",
"default": "./dist/verify/pkcs1v15.js"
},
"require": {
"types": "./dist/verify/pkcs1v15.d.ts",
"default": "./dist/verify/pkcs1v15.js"
}
},
"./verify/pss": {
"import": {
"types": "./dist/verify/pss.d.ts",
"default": "./dist/verify/pss.js"
},
"require": {
"types": "./dist/verify/pss.d.ts",
"default": "./dist/verify/pss.js"
}
},
"./blinding": {
"import": {
"types": "./dist/blinding/index.d.ts",
"default": "./dist/blinding/index.js"
},
"require": {
"types": "./dist/blinding/index.d.ts",
"default": "./dist/blinding/index.js"
}
},
"./blinding/blinding": {
"import": {
"types": "./dist/blinding/blinding.d.ts",
"default": "./dist/blinding/blinding.js"
},
"require": {
"types": "./dist/blinding/blinding.d.ts",
"default": "./dist/blinding/blinding.js"
}
},
"./encrypt": {
"import": {
"types": "./dist/encrypt/index.d.ts",
"default": "./dist/encrypt/index.js"
},
"require": {
"types": "./dist/encrypt/index.d.ts",
"default": "./dist/encrypt/index.js"
}
},
"./encrypt/pkcs1v15": {
"import": {
"types": "./dist/encrypt/pkcs1v15.d.ts",
"default": "./dist/encrypt/pkcs1v15.js"
},
"require": {
"types": "./dist/encrypt/pkcs1v15.d.ts",
"default": "./dist/encrypt/pkcs1v15.js"
}
},
"./encrypt/oaep": {
"import": {
"types": "./dist/encrypt/oaep.d.ts",
"default": "./dist/encrypt/oaep.js"
},
"require": {
"types": "./dist/encrypt/oaep.d.ts",
"default": "./dist/encrypt/oaep.js"
}
},
"./encrypt/default-rng": {
"import": {
"types": "./dist/encrypt/default-rng.d.ts",
"default": "./dist/encrypt/default-rng.js"
},
"require": {
"types": "./dist/encrypt/default-rng.d.ts",
"default": "./dist/encrypt/default-rng.js"
}
},
"./decrypt": {
"import": {
"types": "./dist/decrypt/index.d.ts",
"default": "./dist/decrypt/index.js"
},
"require": {
"types": "./dist/decrypt/index.d.ts",
"default": "./dist/decrypt/index.js"
}
},
"./decrypt/pkcs1v15": {
"import": {
"types": "./dist/decrypt/pkcs1v15.d.ts",
"default": "./dist/decrypt/pkcs1v15.js"
},
"require": {
"types": "./dist/decrypt/pkcs1v15.d.ts",
"default": "./dist/decrypt/pkcs1v15.js"
}
},
"./decrypt/oaep": {
"import": {
"types": "./dist/decrypt/oaep.d.ts",
"default": "./dist/decrypt/oaep.js"
},
"require": {
"types": "./dist/decrypt/oaep.d.ts",
"default": "./dist/decrypt/oaep.js"
}
},
"./keygen": {
"import": {
"types": "./dist/keygen/index.d.ts",
"default": "./dist/keygen/index.js"
},
"require": {
"types": "./dist/keygen/index.d.ts",
"default": "./dist/keygen/index.js"
}
},
"./keygen/generate": {
"import": {
"types": "./dist/keygen/generate.d.ts",
"default": "./dist/keygen/generate.js"
},
"require": {
"types": "./dist/keygen/generate.d.ts",
"default": "./dist/keygen/generate.js"
}
},
"./keygen/miller-rabin": {
"import": {
"types": "./dist/keygen/miller-rabin.d.ts",
"default": "./dist/keygen/miller-rabin.js"
},
"require": {
"types": "./dist/keygen/miller-rabin.d.ts",
"default": "./dist/keygen/miller-rabin.js"
}
},
"./keygen/exponent": {
"import": {
"types": "./dist/keygen/exponent.d.ts",
"default": "./dist/keygen/exponent.js"
},
"require": {
"types": "./dist/keygen/exponent.d.ts",
"default": "./dist/keygen/exponent.js"
}
},
"./keygen/multi-prime": {
"import": {
"types": "./dist/keygen/multi-prime.d.ts",
"default": "./dist/keygen/multi-prime.js"
},
"require": {
"types": "./dist/keygen/multi-prime.d.ts",
"default": "./dist/keygen/multi-prime.js"
}
},
"./jwk": {
"import": {
"types": "./dist/jwk/index.d.ts",
"default": "./dist/jwk/index.js"
},
"require": {
"types": "./dist/jwk/index.d.ts",
"default": "./dist/jwk/index.js"
}
},
"./jwk/thumbprint": {
"import": {
"types": "./dist/jwk/thumbprint.d.ts",
"default": "./dist/jwk/thumbprint.js"
},
"require": {
"types": "./dist/jwk/thumbprint.d.ts",
"default": "./dist/jwk/thumbprint.js"
}
},
"./jwk/thumbprint-uri": {
"import": {
"types": "./dist/jwk/thumbprint-uri.d.ts",
"default": "./dist/jwk/thumbprint-uri.js"
},
"require": {
"types": "./dist/jwk/thumbprint-uri.d.ts",
"default": "./dist/jwk/thumbprint-uri.js"
}
},
"./fingerprint": {
"import": {
"types": "./dist/fingerprint/index.d.ts",
"default": "./dist/fingerprint/index.js"
},
"require": {
"types": "./dist/fingerprint/index.d.ts",
"default": "./dist/fingerprint/index.js"
}
},
"./fingerprint/spki": {
"import": {
"types": "./dist/fingerprint/spki.d.ts",
"default": "./dist/fingerprint/spki.js"
},
"require": {
"types": "./dist/fingerprint/spki.d.ts",
"default": "./dist/fingerprint/spki.js"
}
},
"./fingerprint/format": {
"import": {
"types": "./dist/fingerprint/format.d.ts",
"default": "./dist/fingerprint/format.js"
},
"require": {
"types": "./dist/fingerprint/format.d.ts",
"default": "./dist/fingerprint/format.js"
}
},
"./engine": {
"import": {
"types": "./dist/engine/index.d.ts",
"default": "./dist/engine/index.js"
},
"require": {
"types": "./dist/engine/index.d.ts",
"default": "./dist/engine/index.js"
}
},
"./engine/noble": {
"import": {
"types": "./dist/engine/noble.d.ts",
"default": "./dist/engine/noble.js"
},
"require": {
"types": "./dist/engine/noble.d.ts",
"default": "./dist/engine/noble.js"
}
},
"./engine/subtle": {
"import": {
"types": "./dist/engine/subtle.d.ts",
"default": "./dist/engine/subtle.js"
},
"require": {
"types": "./dist/engine/subtle.d.ts",
"default": "./dist/engine/subtle.js"
}
},
"./engine/select": {
"import": {
"types": "./dist/engine/select.d.ts",
"default": "./dist/engine/select.js"
},
"require": {
"types": "./dist/engine/select.d.ts",
"default": "./dist/engine/select.js"
}
},
"./handle": {
"import": {
"types": "./dist/handle/index.d.ts",
"default": "./dist/handle/index.js"
},
"require": {
"types": "./dist/handle/index.d.ts",
"default": "./dist/handle/index.js"
}
},
"./handle/registry": {
"import": {
"types": "./dist/handle/registry.d.ts",
"default": "./dist/handle/registry.js"
},
"require": {
"types": "./dist/handle/registry.d.ts",
"default": "./dist/handle/registry.js"
}
},
"./handle/software": {
"import": {
"types": "./dist/handle/software.d.ts",
"default": "./dist/handle/software.js"
},
"require": {
"types": "./dist/handle/software.d.ts",
"default": "./dist/handle/software.js"
}
},
"./profile": {
"import": {
"types": "./dist/profile/index.d.ts",
"default": "./dist/profile/index.js"
},
"require": {
"types": "./dist/profile/index.d.ts",
"default": "./dist/profile/index.js"
}
},
"./keys/jwk-policy": {
"import": {
"types": "./dist/keys/jwk-policy.d.ts",
"default": "./dist/keys/jwk-policy.js"
},
"require": {
"types": "./dist/keys/jwk-policy.d.ts",
"default": "./dist/keys/jwk-policy.js"
}
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"lint": "bunx biome check src/",
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
"format": "bunx biome format --write --max-diagnostics 500 src/",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@noble/curves": "catalog:crypto",
"@noble/hashes": "catalog:crypto",
"@opentelemetry/api": "catalog:observability",
"@suaveplan/crypto-hash": "workspace:^",
"@suaveplan/crypto-utils": "workspace:^",
"@suaveplan/error": "workspace:^",
"@suaveplan/types": "workspace:^",
"tslib": "catalog:build",
"zod": "catalog:forms"
},
"devDependencies": {
"@suaveplan/testing": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
},
"files": [
"dist",
"src",
"README.md"
]
}

View File

@@ -0,0 +1,42 @@
# __fixtures__/rsa-2048-key
## Purpose
Real 2048-bit RSA keypair generated once at module-load time and exposed
as canonical `RsaPublicKey` / `RsaPrivateKey` shapes for Stage 2+ tests
(sign / verify / encrypt / decrypt). The 16-bit synthetic
`TINY_PRIVATE_KEY` fixture in `test-keys.ts` cannot host a SHA-256 EM
(32 bytes is wider than its 2-byte modulus), so signature tests need a
real-sized key.
## Features
- Real 2048-bit keypair via `node:crypto.generateKeyPairSync`
- Decoded into canonical bigint form via `finalisePrivateKey` /
`finalisePublicKey`
- Stable within a single test run (deterministic across all tests in the
same process); not deterministic across processes
- Re-run cost amortised across the entire Stage 2+ suite (≈ 50 ms once)
## Usage
```ts
import {
RSA_2048_PRIVATE_KEY,
RSA_2048_PUBLIC_KEY,
} from "../__fixtures__/rsa-2048-key.js";
```
## API Reference
### `RSA_2048_PUBLIC_KEY: RsaPublicKey`
### `RSA_2048_PRIVATE_KEY: RsaPrivateKey`
## Implementation Notes
- Tests that need byte-exact reference vectors must use one of the RFC
8017 Appendix C / Wycheproof / CAVP fixture files (added incrementally
).
- The fixture is not committed bytes-on-disk; regeneration on every
test process amortises down to noise once the rest of the suite runs.

View File

@@ -0,0 +1,80 @@
import { generateKeyPairSync } from "node:crypto";
import type { RsaPrivateKey, RsaPublicKey } from "../keys/keys.types.js";
import { finalisePrivateKey } from "../keys/parse-pkcs1-private.js";
import { finalisePublicKey } from "../keys/parse-pkcs1-public.js";
/**
* Generates a real 2048-bit RSA keypair once at module-load time and
* exposes it as canonical `RsaPublicKey` / `RsaPrivateKey` shapes for
* Stage 2+ tests (sign/verify/encrypt/decrypt).
*
* The 16-bit synthetic `TINY_*` fixture in `test-keys.ts` cannot host a
* SHA-256 EM (32 bytes is wider than its 2-byte modulus) so signature
* tests need a real key. Generation cost on Node is ~50 ms; amortised
* across the whole Stage-2 test suite this is negligible.
*
* The key is non-deterministic — different across processes — but stable
* within a single test run, which is all that the round-trip tests
* require. Tests that need byte-exact reference vectors must use one of
* the RFC 8017 Appendix C fixture files (added incrementally as needed).
*/
function base64UrlToBigint(b64url: string): bigint {
const b64 = b64url.replace(/-/g, "+").replace(/_/g, "/");
const padded = b64 + "=".repeat((4 - (b64.length % 4)) % 4);
const bytes = Uint8Array.from(globalThis.atob(padded), (c) =>
c.charCodeAt(0)
);
let n = 0n;
for (const b of bytes) {
n = (n << 8n) | BigInt(b);
}
return n;
}
interface JwkLike {
n: string;
e: string;
d: string;
p: string;
q: string;
dp: string;
dq: string;
qi: string;
}
function buildKeypair(bits: number): {
publicKey: RsaPublicKey;
privateKey: RsaPrivateKey;
} {
const { privateKey: nodePriv } = generateKeyPairSync("rsa", {
modulusLength: bits,
publicExponent: 65537,
});
const jwk = nodePriv.export({ format: "jwk" }) as unknown as JwkLike;
const n = base64UrlToBigint(jwk.n);
const e = base64UrlToBigint(jwk.e);
const d = base64UrlToBigint(jwk.d);
const p = base64UrlToBigint(jwk.p);
const q = base64UrlToBigint(jwk.q);
const dp = base64UrlToBigint(jwk.dp);
const dq = base64UrlToBigint(jwk.dq);
const qInv = base64UrlToBigint(jwk.qi);
return {
publicKey: finalisePublicKey(n, e),
privateKey: finalisePrivateKey({
n,
e,
d,
p,
q,
dp,
dq,
qInv,
}),
};
}
const KP_2048 = buildKeypair(2048);
export const RSA_2048_PUBLIC_KEY: RsaPublicKey = KP_2048.publicKey;
export const RSA_2048_PRIVATE_KEY: RsaPrivateKey = KP_2048.privateKey;

View File

@@ -0,0 +1,40 @@
# __fixtures__/test-keys
## Purpose
Tiny synthetic RSA keypair for unit tests that need an `RsaPrivateKey` /
`RsaPublicKey` shape but don't need to run a real cryptographic
operation. Modulus is 16 bits (`n = 251 * 241 = 60 491`) so the
modulus-length policy gate's sub-1024-bit reject path can fire without
colliding with real signing operations.
## Features
- Hand-computed CRT parameters (`d`, `p`, `q`, `dp`, `dq`, `qInv`)
- Round-trip-safe through every parser/serialiser
- 16-bit modulus exercises the `MODULUS_TOO_SHORT` rejection path
## Usage
```ts
import {
TINY_PRIVATE_KEY,
TINY_PUBLIC_KEY,
} from "../__fixtures__/test-keys.js";
```
## API Reference
### `TINY_PUBLIC_KEY: RsaPublicKey`
### `TINY_PRIVATE_KEY: RsaPrivateKey`
## Implementation Notes
- NOT a real cryptographic key. Do not use for any signature /
encryption operation that needs to interoperate with real RSA keys —
the modulus is far below the FIPS / industry minimum.
- The 16-bit modulus is chosen specifically so it cannot host a SHA-256
encoded message (the EM is 32 bytes, the modulus is 2 bytes); this
triggers the canonical `MODULUS_TOO_SHORT` failure path without
needing to construct adversarial inputs.

View File

@@ -0,0 +1,109 @@
import type { RsaPrivateKey, RsaPublicKey } from "../keys/keys.types.js";
import { finalisePrivateKey } from "../keys/parse-pkcs1-private.js";
import { finalisePublicKey } from "../keys/parse-pkcs1-public.js";
/**
* A small synthetic RSA keypair used by the unit-test suite. NOT a real
* cryptographic key — `n = p * q` for the small primes below, all CRT
* fields recomputed from `(p, q, e)` so the structure type-checks and
* round-trips through every parser/serialiser. The bit-length is 16 so
* the modulus-length policy gates can exercise the sub-1024-bit reject
* path without colliding with real signing operations.
*/
const P = 251n; // 8-bit prime
const Q = 241n; // 8-bit prime
const N = P * Q; // 60 491
const E = 65537n;
function modInv(a: bigint, m: bigint): bigint {
let [oldR, r] = [a % m, m];
let [oldS, s] = [1n, 0n];
while (r !== 0n) {
const q = oldR / r;
[oldR, r] = [r, oldR - q * r];
[oldS, s] = [s, oldS - q * s];
}
if (oldR !== 1n) {
throw new Error("not invertible");
}
return ((oldS % m) + m) % m;
}
const PHI = (P - 1n) * (Q - 1n);
const D = modInv(E, PHI);
const DP = D % (P - 1n);
const DQ = D % (Q - 1n);
const Q_INV = modInv(Q, P);
export const TINY_PUBLIC_KEY: RsaPublicKey = finalisePublicKey(N, E);
export const TINY_PRIVATE_KEY: RsaPrivateKey = finalisePrivateKey({
n: N,
e: E,
d: D,
p: P,
q: Q,
dp: DP,
dq: DQ,
qInv: Q_INV,
});
/**
* Build an `RsaPublicKey` with a custom `bitLength` for policy-gate tests
* without going through the parser pipeline.
*/
export function makePublicKeyWithBitLength(bitLength: number): RsaPublicKey {
return { n: N, e: E, bitLength };
}
/**
* Real 1024-bit RSA keypair, used by Stage 3 encrypt/decrypt/blinding tests
* that must satisfy the modulus-length policy (`enforceMinBitLength` with
* `allowLegacy: true`). The primes were factored from a published
* RFC-3447-derived `(n, e, d)` triple via the Miller (1976) probabilistic
* factorisation from `e * d - 1`. Re-deriving deterministically: see
* `.test.ts` companion of `keygen/generate.ts`.
*
* NOT a security-sensitive key — used purely as a fixture, never in
* production. Bit-length is the legacy 1024-bit floor; production key
* generation defaults to 3072-bit.
*/
const LEGACY_1024_N =
0xa56e4a0e701017589a5187dc7ea841d156f2ec0e36ad52a44dfeb1e61f7ad991d8c51056ffedb162b4c0f283a12a88a394dff526ab7291cbb307ceabfce0b1dfd5cd9508096d5b2b8b6df5d671ef6377c0921cb23c270a70e2598e6ff89d19f105acc2d3f0cb35f29280e1386b6f64c4ef22e1e1f20d0ce8cffb2249bd9a2137n;
const LEGACY_1024_E = 0x10001n;
const LEGACY_1024_D =
0x33a5042a90b27d4f5451ca9bbbd0b44771a101af884340aef9885f2a4bbe92e894a724ac3c568c8f97853ad07c0266c8c6a3ca0929f1e8f11231884429fc4d9ae55fee896a10ce707c3ed7e734e44727a39574501a532683109c2abacaba283c31b4bd2f53c3ee37e352cee34f9e503bd80c0622ad79c6dcee883547c6a3b325n;
const LEGACY_1024_P =
0xe7e8942720a877517273a356053ea2a1bc0c94aa72d55c6e86296b2dfc967948c0a72cbccca7eacb35706e09a1df55a1535bd9b3cc34160b3b6dcd3eda8e6443n;
const LEGACY_1024_Q =
0xb69dca1cf7d4d7ec81e75b90fcca874abcde123fd2700180aa90479b6e48de8d67ed24f9f19d85ba275874f542cd20dc723e6963364a1f9425452b269a6799fdn;
const LEGACY_1024_DP =
0x28fa13938655be1f8a159cbaca5a72ea190c30089e19cd274a556f36c4f6e19f554b34c077790427bbdd8dd3ede2448328f385d81b30e8e43b2fffa027861979n;
const LEGACY_1024_DQ =
0x1a8b38f398fa712049898d7fb79ee0a77668791299cdfa09efc0e507acb21ed74301ef5bfd48be455eaeb6e1678255827580a8e4e8e14151d1510a82a3f2e729n;
const LEGACY_1024_QINV =
0x27156aba4126d24a81f3a528cbfb27f56886f840a9f6e86e17a44b94fe9319584b8e22fdde1e5a2e3bd8aa5ba8d8584194eb2190acf832b847f13a3d24a79f4dn;
export const LEGACY_1024_PUBLIC_KEY: RsaPublicKey = finalisePublicKey(
LEGACY_1024_N,
LEGACY_1024_E
);
export const LEGACY_1024_PRIVATE_KEY: RsaPrivateKey = finalisePrivateKey({
n: LEGACY_1024_N,
e: LEGACY_1024_E,
d: LEGACY_1024_D,
p: LEGACY_1024_P,
q: LEGACY_1024_Q,
dp: LEGACY_1024_DP,
dq: LEGACY_1024_DQ,
qInv: LEGACY_1024_QINV,
});
/**
* RFC 7638 §3.1 worked-example JWK. Used by the JWK-parse positive case to
* pin the exact `n`/`e` of the canonical reference fixture.
*/
export const RFC7638_PUBLIC_JWK = {
kty: "RSA" as const,
n: "0vx7agoebGcQSuuPiLJXZptN9nndrQmbXEps2aiAFbWhM78LhWx4cbbfAAtVT86zwu1RK7aPFFxuhDR1L6tSoc_BJECPebWKRXjBZCiFV4n3oknjhMstn64tZ_2W-5JsGY4Hc5n9yBXArwl93lqt7_RN5w6Cf0h4QyQ5v-65YGjQR0_FDW2QvzqY368QQMicAtaSqzs8KJZgnYb9c7d0zgdAZHzu6qMQvRL5hajrn1n91CbOpbISD08qNLyrdkt-bFTWhAI4vMQFh6WeZu0fM4lFd2NcRwr3XPksINHaQ-G_xBniIqbw0Ls1jF44-csFCur-kEgU8awapJzKnqDKgw",
e: "AQAB",
};

View File

@@ -0,0 +1,20 @@
export {
DerReader,
TAG_BIT_STRING,
TAG_CONTEXT_SPECIFIC_BASE,
TAG_INTEGER,
TAG_NULL,
TAG_OCTET_STRING,
TAG_OID,
TAG_SEQUENCE,
} from "./reader.js";
export {
encodeBitString,
encodeContextSpecific,
encodeInteger,
encodeLength,
encodeNull,
encodeObjectIdentifier,
encodeOctetString,
encodeSequence,
} from "./writer.js";

View File

@@ -0,0 +1,88 @@
# `asn1/reader`
## Purpose
Strict-DER reader scoped to the RSA key shapes defined in RFC 8017 Appendix A
(PKCS#1), RFC 5280 §4.1 + Appendix A (`SubjectPublicKeyInfo`), and RFC 5208 / RFC 5958
(PKCS#8 `PrivateKeyInfo` / `OneAsymmetricKey`). The reader rejects every BER
construction that DER forbids — indefinite length, non-minimal length, non-minimal
integer encoding, non-zero unused-bits in `BIT STRING`, trailing data — by raising
`CryptoRsaError(CRYPTO_RSA.INVALID_KEY)`.
The implementation is intentionally minimal. It implements exactly the subset needed
to decode RSA keys; it is not a general-purpose ASN.1 library.
## Features
- Stateful cursor with `position`, `remaining`, `hasMore`, `peekTag`, `assertEnd`.
- `readInteger`, `readSequence`, `readObjectIdentifier`, `readBitString`,
`readOctetString`, `readNull`, `readContextSpecific(tagNumber)`.
- Strict DER length parsing — non-minimal long-form lengths are rejected.
- Strict DER integer parsing — leading-`0x00` only when MSB requires it; negative
encodings rejected (RSA INTEGERs are non-negative).
- `BIT STRING` reader copies the body so callers cannot mutate the input buffer.
- Sub-readers returned by `readSequence` / `readContextSpecific` are scoped to the
child body for ergonomic recursive descent.
## Usage: Basic Example
```ts
import { DerReader } from "@suaveplan/crypto-rsa/asn1";
const reader = new DerReader(pkcs1PublicKeyDer);
const seq = reader.readSequence();
const modulus = seq.readInteger();
const exponent = seq.readInteger();
seq.assertEnd();
reader.assertEnd();
```
## Usage: Advanced Example
```ts
import { DerReader, TAG_INTEGER } from "@suaveplan/crypto-rsa/asn1";
const reader = new DerReader(pkcs8Bytes);
const outer = reader.readSequence();
// version is OPTIONAL DEFAULT 0 in some PKCS#8 dialects
const version =
outer.peekTag() === TAG_INTEGER ? outer.readInteger() : 0n;
const algSeq = outer.readSequence();
const oid = algSeq.readObjectIdentifier();
algSeq.readNull();
const privateKeyOctet = outer.readOctetString();
const inner = new DerReader(privateKeyOctet).readSequence();
// … recurse into RSAPrivateKey body.
```
## API Reference
- `class DerReader { constructor(bytes: Uint8Array, offset?: number) }`
- `position: number` — current cursor offset (read-only).
- `remaining: number` — bytes remaining in the buffer.
- `hasMore(): boolean` — true if the cursor has not reached the end.
- `peekTag(): number | undefined` — tag byte at the cursor, or `undefined` at EOF.
- `readInteger(): bigint` — consumes a DER `INTEGER`; throws `INVALID_KEY` on bad encoding.
- `readSequence(): DerReader` — body sub-reader for the next `SEQUENCE`.
- `readObjectIdentifier(): string` — dotted-decimal OID (e.g. `"1.2.840.113549.1.1.1"`).
- `readBitString(): Uint8Array` — payload bytes; throws when unused-bits ≠ 0.
- `readOctetString(): Uint8Array` — payload bytes (defensive copy).
- `readNull(): void` — consumes a DER `NULL`; throws on non-empty body.
- `readContextSpecific(tagNumber: 0..30): DerReader` — body sub-reader for `[n]`.
- `assertEnd(): void` — throws `INVALID_KEY` if any unread bytes remain.
All failure modes throw `CryptoRsaError` with `code === CRYPTO_RSA.INVALID_KEY` and
a structured `context` payload (e.g. `{ expected, actual }`, `{ unusedBits }`).
## Implementation Notes
The reader rejects BER-only constructs even when they are syntactically unambiguous
because RSA wire formats are defined in DER and any tooling that emits BER is
non-conformant. This matters for security: silently accepting non-canonical encodings
is a known fingerprinting and signature-malleability vector.
Sub-readers returned by `readSequence` are constructed over `Uint8Array.subarray`
(a view), not a copy, so the parser remains zero-allocation on the hot path. The
single defensive copy is in `readBitString` because callers receive the payload
directly and the SPKI bit-string carries the inner PKCS#1 body which is then
re-parsed.

View File

@@ -0,0 +1,250 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { CryptoRsaError } from "../errors/errors.js";
import { DerReader } from "./reader.js";
import {
encodeBitString,
encodeContextSpecific,
encodeInteger,
encodeNull,
encodeObjectIdentifier,
encodeOctetString,
encodeSequence,
} from "./writer.js";
describe("DerReader.readInteger", () => {
it("round-trips a small positive integer", { timeout: 5000 }, () => {
const bytes = encodeInteger(42n);
const reader = new DerReader(bytes);
expect(reader.readInteger()).toBe(42n);
reader.assertEnd();
});
it("round-trips a large multi-byte integer", { timeout: 5000 }, () => {
const value = 0x010001n;
const bytes = encodeInteger(value);
expect(new DerReader(bytes).readInteger()).toBe(value);
});
it("round-trips zero", { timeout: 5000 }, () => {
const bytes = encodeInteger(0n);
expect(new DerReader(bytes).readInteger()).toBe(0n);
});
it("rejects empty integer body", { timeout: 5000 }, () => {
const bytes = new Uint8Array([0x02, 0x00]);
expect(() => new DerReader(bytes).readInteger()).toThrow(
CryptoRsaError
);
});
it("rejects non-minimal leading 0x00", { timeout: 5000 }, () => {
const bytes = new Uint8Array([0x02, 0x02, 0x00, 0x01]);
expect(() => new DerReader(bytes).readInteger()).toThrow(
CryptoRsaError
);
});
it("rejects non-minimal leading 0xff", { timeout: 5000 }, () => {
const bytes = new Uint8Array([0x02, 0x02, 0xff, 0x80]);
expect(() => new DerReader(bytes).readInteger()).toThrow(
CryptoRsaError
);
});
it("rejects negative integer (high bit set on single byte)", {
timeout: 5000,
}, () => {
const bytes = new Uint8Array([0x02, 0x01, 0x80]);
expect(() => new DerReader(bytes).readInteger()).toThrow(
CryptoRsaError
);
});
it("rejects unexpected tag", { timeout: 5000 }, () => {
const bytes = new Uint8Array([0x05, 0x00]);
expect(() => new DerReader(bytes).readInteger()).toThrow(
CryptoRsaError
);
});
it("rejects truncated input", { timeout: 5000 }, () => {
const bytes = new Uint8Array([]);
expect(() => new DerReader(bytes).readInteger()).toThrow(
CryptoRsaError
);
});
});
describe("DerReader length parsing", () => {
it("parses long-form length", { timeout: 5000 }, () => {
// SEQUENCE of 200 bytes of zero-INTEGERs would need long-form.
const child = encodeInteger(0n);
const children: Uint8Array[] = [];
for (let i = 0; i < 100; i++) children.push(child);
const seq = encodeSequence(...children);
const reader = new DerReader(seq).readSequence();
for (let i = 0; i < 100; i++) {
expect(reader.readInteger()).toBe(0n);
}
reader.assertEnd();
});
it("rejects long-form length with zero number-of-bytes", {
timeout: 5000,
}, () => {
const bytes = new Uint8Array([0x30, 0x80]);
expect(() => new DerReader(bytes).readSequence()).toThrow(
CryptoRsaError
);
});
it("rejects long-form length with > 4 number-of-bytes", {
timeout: 5000,
}, () => {
const bytes = new Uint8Array([0x30, 0x85, 0, 0, 0, 0, 0]);
expect(() => new DerReader(bytes).readSequence()).toThrow(
CryptoRsaError
);
});
it("rejects non-minimal long-form length (value < 128)", {
timeout: 5000,
}, () => {
const bytes = new Uint8Array([0x30, 0x81, 0x05, 0, 0, 0, 0, 0]);
expect(() => new DerReader(bytes).readSequence()).toThrow(
CryptoRsaError
);
});
it("rejects length that overflows the buffer", { timeout: 5000 }, () => {
const bytes = new Uint8Array([0x30, 0x05, 0x02, 0x01, 0x01]);
expect(() => new DerReader(bytes).readSequence()).toThrow(
CryptoRsaError
);
});
});
describe("DerReader.readSequence", () => {
it("exposes a sub-reader scoped to the body", { timeout: 5000 }, () => {
const seq = encodeSequence(encodeInteger(1n), encodeInteger(2n));
const reader = new DerReader(seq);
const inner = reader.readSequence();
expect(inner.readInteger()).toBe(1n);
expect(inner.readInteger()).toBe(2n);
inner.assertEnd();
reader.assertEnd();
});
});
describe("DerReader.readObjectIdentifier", () => {
it("round-trips rsaEncryption OID", { timeout: 5000 }, () => {
const oid = "1.2.840.113549.1.1.1";
const bytes = encodeObjectIdentifier(oid);
expect(new DerReader(bytes).readObjectIdentifier()).toBe(oid);
});
it("decodes multi-byte sub-identifiers", { timeout: 5000 }, () => {
const oid = "2.5.4.3";
const bytes = encodeObjectIdentifier(oid);
expect(new DerReader(bytes).readObjectIdentifier()).toBe(oid);
});
it("rejects empty OID body", { timeout: 5000 }, () => {
const bytes = new Uint8Array([0x06, 0x00]);
expect(() => new DerReader(bytes).readObjectIdentifier()).toThrow(
CryptoRsaError
);
});
});
describe("DerReader.readBitString", () => {
it("round-trips a primitive BIT STRING", { timeout: 5000 }, () => {
const payload = new Uint8Array([0xde, 0xad, 0xbe, 0xef]);
const bytes = encodeBitString(payload);
expect(Array.from(new DerReader(bytes).readBitString())).toEqual(
Array.from(payload)
);
});
it("rejects empty BIT STRING body", { timeout: 5000 }, () => {
const bytes = new Uint8Array([0x03, 0x00]);
expect(() => new DerReader(bytes).readBitString()).toThrow(
CryptoRsaError
);
});
it("rejects non-zero unused-bits", { timeout: 5000 }, () => {
const bytes = new Uint8Array([0x03, 0x02, 0x01, 0x80]);
expect(() => new DerReader(bytes).readBitString()).toThrow(
CryptoRsaError
);
});
});
describe("DerReader.readOctetString", () => {
it("round-trips and defensively copies", { timeout: 5000 }, () => {
const payload = new Uint8Array([1, 2, 3, 4]);
const bytes = encodeOctetString(payload);
const decoded = new DerReader(bytes).readOctetString();
expect(Array.from(decoded)).toEqual([1, 2, 3, 4]);
// Mutating the decoded copy MUST not corrupt the input.
decoded[0] = 99;
const again = new DerReader(bytes).readOctetString();
expect(again[0]).toBe(1);
});
});
describe("DerReader.readNull", () => {
it("consumes 0x05 0x00", { timeout: 5000 }, () => {
const reader = new DerReader(encodeNull());
expect(() => reader.readNull()).not.toThrow();
reader.assertEnd();
});
it("rejects non-empty NULL", { timeout: 5000 }, () => {
const bytes = new Uint8Array([0x05, 0x01, 0x00]);
expect(() => new DerReader(bytes).readNull()).toThrow(CryptoRsaError);
});
});
describe("DerReader.readContextSpecific", () => {
it("round-trips a context-specific [0] body", { timeout: 5000 }, () => {
const wrapped = encodeContextSpecific(0, encodeInteger(7n));
const reader = new DerReader(wrapped);
const inner = reader.readContextSpecific(0);
expect(inner.readInteger()).toBe(7n);
});
it("rejects out-of-range tag number", { timeout: 5000 }, () => {
const bytes = new Uint8Array([0xa0, 0x00]);
expect(() => new DerReader(bytes).readContextSpecific(31)).toThrow(
CryptoRsaError
);
});
});
describe("DerReader misc", () => {
it("reports position, remaining, hasMore, peekTag", {
timeout: 5000,
}, () => {
const bytes = new Uint8Array([0x02, 0x01, 0x05]);
const reader = new DerReader(bytes);
expect(reader.position).toBe(0);
expect(reader.remaining).toBe(3);
expect(reader.hasMore()).toBe(true);
expect(reader.peekTag()).toBe(0x02);
expect(reader.readInteger()).toBe(5n);
expect(reader.hasMore()).toBe(false);
expect(reader.peekTag()).toBeUndefined();
expect(reader.position).toBe(3);
});
it("assertEnd throws when there is trailing data", {
timeout: 5000,
}, () => {
const bytes = new Uint8Array([0x02, 0x01, 0x00, 0xff]);
const reader = new DerReader(bytes);
reader.readInteger();
expect(() => reader.assertEnd()).toThrow(CryptoRsaError);
});
});

View File

@@ -0,0 +1,285 @@
import { CRYPTO_RSA_CODES, CryptoRsaError } from "../errors/errors.js";
/**
* DER tag constants used inside RSA key encodings (PKCS#1, PKCS#8, SPKI).
*
* Only the tags actually referenced by RSA key shapes are listed; this is
* intentionally a minimal RSA-scoped reader, not a general ASN.1 library.
*/
export const TAG_INTEGER = 0x02;
export const TAG_BIT_STRING = 0x03;
export const TAG_OCTET_STRING = 0x04;
export const TAG_NULL = 0x05;
export const TAG_OID = 0x06;
export const TAG_SEQUENCE = 0x30;
export const TAG_CONTEXT_SPECIFIC_BASE = 0xa0;
/**
* Stateful DER reader. Maintains an internal byte cursor; every accessor
* advances the cursor by exactly the number of bytes consumed.
*
* Strict DER only — indefinite-length form (BER), non-minimal length
* encodings, and trailing data inside SEQUENCE bodies all raise
* `CRYPTO_RSA.INVALID_KEY`.
*/
export class DerReader {
private readonly bytes: Uint8Array;
private offset: number;
public constructor(bytes: Uint8Array, offset = 0) {
this.bytes = bytes;
this.offset = offset;
}
public get position(): number {
return this.offset;
}
public get remaining(): number {
return this.bytes.length - this.offset;
}
public hasMore(): boolean {
return this.offset < this.bytes.length;
}
private requireByte(): number {
if (this.offset >= this.bytes.length) {
throw new CryptoRsaError(
CRYPTO_RSA_CODES.INVALID_KEY,
"error.crypto-rsa.invalid-key.truncated"
);
}
const value = this.bytes[this.offset] as number;
this.offset += 1;
return value;
}
private readTag(expected: number): void {
const tag = this.requireByte();
if (tag !== expected) {
throw new CryptoRsaError(
CRYPTO_RSA_CODES.INVALID_KEY,
"error.crypto-rsa.invalid-key.unexpected-tag",
{ context: { expected, actual: tag } }
);
}
}
private readLength(): number {
const first = this.requireByte();
if ((first & 0x80) === 0) {
return first;
}
const numBytes = first & 0x7f;
if (numBytes === 0 || numBytes > 4) {
throw new CryptoRsaError(
CRYPTO_RSA_CODES.INVALID_KEY,
"error.crypto-rsa.invalid-key.bad-length"
);
}
let length = 0;
for (let i = 0; i < numBytes; i++) {
length = (length << 8) | this.requireByte();
}
// Strict DER: reject non-minimal length form.
if (length < 0x80) {
throw new CryptoRsaError(
CRYPTO_RSA_CODES.INVALID_KEY,
"error.crypto-rsa.invalid-key.non-minimal-length"
);
}
return length;
}
private readBytes(length: number): Uint8Array {
if (this.offset + length > this.bytes.length) {
throw new CryptoRsaError(
CRYPTO_RSA_CODES.INVALID_KEY,
"error.crypto-rsa.invalid-key.length-overflow"
);
}
const slice = this.bytes.subarray(this.offset, this.offset + length);
this.offset += length;
return slice;
}
public readInteger(): bigint {
this.readTag(TAG_INTEGER);
const length = this.readLength();
if (length === 0) {
throw new CryptoRsaError(
CRYPTO_RSA_CODES.INVALID_KEY,
"error.crypto-rsa.invalid-key.empty-integer"
);
}
const body = this.readBytes(length);
// Strict DER: leading 0x00 only when MSB of next byte is 1.
if (body.length > 1) {
const first = body[0] as number;
const second = body[1] as number;
if (first === 0x00 && (second & 0x80) === 0) {
throw new CryptoRsaError(
CRYPTO_RSA_CODES.INVALID_KEY,
"error.crypto-rsa.invalid-key.non-minimal-integer"
);
}
if (first === 0xff && (second & 0x80) !== 0) {
throw new CryptoRsaError(
CRYPTO_RSA_CODES.INVALID_KEY,
"error.crypto-rsa.invalid-key.non-minimal-integer"
);
}
}
// We treat all RSA INTEGERs as non-negative.
if ((body[0] as number) & 0x80) {
throw new CryptoRsaError(
CRYPTO_RSA_CODES.INVALID_KEY,
"error.crypto-rsa.invalid-key.negative-integer"
);
}
let value = 0n;
for (let i = 0; i < body.length; i++) {
value = (value << 8n) | BigInt(body[i] as number);
}
return value;
}
public readSequence(): DerReader {
this.readTag(TAG_SEQUENCE);
const length = this.readLength();
const body = this.readBytes(length);
return new DerReader(body);
}
public readObjectIdentifier(): string {
this.readTag(TAG_OID);
const length = this.readLength();
if (length === 0) {
throw new CryptoRsaError(
CRYPTO_RSA_CODES.INVALID_KEY,
"error.crypto-rsa.invalid-key.empty-oid"
);
}
const body = this.readBytes(length);
// Decode the first sub-identifier as a base-128 value spanning
// however many leading bytes have the high bit set. The result
// splits into the first two OID arcs per X.690 §8.19: when
// value < 80, arc1 = floor(value/40) and arc2 = value mod 40;
// otherwise arc1 = 2 and arc2 = value - 80.
let firstSubId = 0n;
let i = 0;
while (i < body.length) {
const b = body[i] as number;
firstSubId = (firstSubId << 7n) | BigInt(b & 0x7f);
i += 1;
if ((b & 0x80) === 0) {
break;
}
}
let arc1: bigint;
let arc2: bigint;
if (firstSubId < 80n) {
arc1 = firstSubId / 40n;
arc2 = firstSubId - arc1 * 40n;
} else {
arc1 = 2n;
arc2 = firstSubId - 80n;
}
const arcs: string[] = [arc1.toString(), arc2.toString()];
let value = 0n;
for (; i < body.length; i++) {
const b = body[i] as number;
value = (value << 7n) | BigInt(b & 0x7f);
if ((b & 0x80) === 0) {
arcs.push(value.toString());
value = 0n;
}
}
return arcs.join(".");
}
public readBitString(): Uint8Array {
this.readTag(TAG_BIT_STRING);
const length = this.readLength();
if (length === 0) {
throw new CryptoRsaError(
CRYPTO_RSA_CODES.INVALID_KEY,
"error.crypto-rsa.invalid-key.empty-bit-string"
);
}
const body = this.readBytes(length);
const unusedBits = body[0] as number;
if (unusedBits !== 0) {
throw new CryptoRsaError(
CRYPTO_RSA_CODES.INVALID_KEY,
"error.crypto-rsa.invalid-key.unused-bits",
{ context: { unusedBits } }
);
}
// Copy so callers cannot mutate the underlying input buffer.
return new Uint8Array(body.subarray(1));
}
public readOctetString(): Uint8Array {
this.readTag(TAG_OCTET_STRING);
const length = this.readLength();
const body = this.readBytes(length);
return new Uint8Array(body);
}
public readNull(): void {
this.readTag(TAG_NULL);
const length = this.readLength();
if (length !== 0) {
throw new CryptoRsaError(
CRYPTO_RSA_CODES.INVALID_KEY,
"error.crypto-rsa.invalid-key.non-empty-null"
);
}
}
/**
* Read an `[n] IMPLICIT` or `[n] EXPLICIT` context-specific element. The
* raw constructed/primitive bit is preserved by mask-comparing against
* `0xa0 | n`. Returns a sub-reader scoped to the body bytes.
*/
public readContextSpecific(tagNumber: number): DerReader {
if (tagNumber < 0 || tagNumber > 30) {
throw new CryptoRsaError(
CRYPTO_RSA_CODES.INVALID_KEY,
"error.crypto-rsa.invalid-key.bad-context-tag"
);
}
const expected = TAG_CONTEXT_SPECIFIC_BASE | tagNumber;
this.readTag(expected);
const length = this.readLength();
const body = this.readBytes(length);
return new DerReader(body);
}
/**
* Peek at the next tag byte without consuming it. Useful to drive
* `OPTIONAL`/`DEFAULT` field branching in PKCS#1 / PKCS#8 grammars.
*/
public peekTag(): number | undefined {
if (this.offset >= this.bytes.length) {
return undefined;
}
return this.bytes[this.offset];
}
/**
* Assert the reader has consumed every byte. Called at the end of every
* top-level structural parse so trailing data raises `INVALID_KEY`.
*/
public assertEnd(): void {
if (this.offset !== this.bytes.length) {
throw new CryptoRsaError(
CRYPTO_RSA_CODES.INVALID_KEY,
"error.crypto-rsa.invalid-key.trailing-data",
{ context: { remaining: this.bytes.length - this.offset } }
);
}
}
}

View File

@@ -0,0 +1,87 @@
# `asn1/writer`
## Purpose
Strict-DER encoder mirroring `asn1/reader`. Encodes the same minimal subset of
ASN.1 needed to serialise RSA keys in PKCS#1, SPKI, and PKCS#8 wire formats with
byte-identical canonical form, so `parse(serialise(key))` round-trips bit-for-bit.
The writer never emits BER constructions even when the underlying X.690 grammar
permits them; minimal definite-length encoding, minimal integer encoding, and
zero-unused-bit `BIT STRING` are the only forms produced.
## Features
- `encodeLength` — minimal definite-length form per X.690 §8.1.3.
- `encodeInteger(bigint)` — non-negative `bigint` with leading-`0x00` insertion
when the high bit is set (so positive interpretation is unambiguous).
- `encodeSequence(...children)` — variadic concatenation of pre-encoded child TLVs.
- `encodeObjectIdentifier(oid)` — dotted-decimal OID with base-128 sub-identifier
encoding and validation of the first two arcs.
- `encodeBitString` — primitive `BIT STRING` carrying a whole-byte payload.
- `encodeOctetString`, `encodeNull`, `encodeContextSpecific(tagNumber, body)`.
## Usage: Basic Example
```ts
import {
encodeInteger,
encodeSequence,
} from "@suaveplan/crypto-rsa/asn1";
const pkcs1Public = encodeSequence(
encodeInteger(modulus),
encodeInteger(exponent)
);
```
## Usage: Advanced Example
```ts
import {
encodeBitString,
encodeNull,
encodeObjectIdentifier,
encodeSequence,
} from "@suaveplan/crypto-rsa/asn1";
const RSA_ENCRYPTION_OID = "1.2.840.113549.1.1.1";
const algorithmIdentifier = encodeSequence(
encodeObjectIdentifier(RSA_ENCRYPTION_OID),
encodeNull()
);
const subjectPublicKeyInfo = encodeSequence(
algorithmIdentifier,
encodeBitString(pkcs1PublicKeyDer)
);
```
## API Reference
- `encodeLength(length: number): Uint8Array` — DER length octet(s).
- `encodeInteger(value: bigint): Uint8Array` — full INTEGER TLV.
- `encodeSequence(...children: Uint8Array[]): Uint8Array` — full SEQUENCE TLV.
- `encodeObjectIdentifier(oid: string): Uint8Array` — full OID TLV.
- `encodeBitString(payload: Uint8Array): Uint8Array` — full BIT STRING TLV.
- `encodeOctetString(payload: Uint8Array): Uint8Array` — full OCTET STRING TLV.
- `encodeNull(): Uint8Array` — `0x05 0x00`.
- `encodeContextSpecific(tagNumber: 0..30, body: Uint8Array): Uint8Array` — `[n]` wrapping.
All inputs are validated. Negative integers, malformed OID strings, lengths that
overflow, and out-of-range context tag numbers throw
`CryptoRsaError(CRYPTO_RSA.INVALID_KEY)`.
## Implementation Notes
The writer is fully synchronous and allocates one `Uint8Array` per TLV node plus
one final concatenation through `concatBytes` from `@suaveplan/crypto-utils`.
For the small key-shape graph (≤ 8 INTEGERs for an RSA private key) this is well
below any allocation budget.
OID encoding implements the standard base-128 sub-identifier rule: the high bit of
every byte except the last is set, so each sub-identifier reconstructs as a
big-endian variable-length integer. The first two arcs are folded into a single
byte (`arc1 * 40 + arc2`) per X.690 §8.19; arc1 is constrained to `{0, 1, 2}`,
and arc2 to `< 40` when arc1 is 0 or 1. Both constraints are enforced.

View File

@@ -0,0 +1,131 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { CryptoRsaError } from "../errors/errors.js";
import { DerReader } from "./reader.js";
import {
encodeBitString,
encodeContextSpecific,
encodeInteger,
encodeLength,
encodeNull,
encodeObjectIdentifier,
encodeOctetString,
encodeSequence,
} from "./writer.js";
describe("encodeLength", () => {
it("emits short-form for length < 0x80", { timeout: 5000 }, () => {
expect(Array.from(encodeLength(0))).toEqual([0x00]);
expect(Array.from(encodeLength(127))).toEqual([0x7f]);
});
it("emits long-form with one length byte for 0x80", {
timeout: 5000,
}, () => {
expect(Array.from(encodeLength(0x80))).toEqual([0x81, 0x80]);
});
it("emits long-form with two length bytes for 0x100", {
timeout: 5000,
}, () => {
expect(Array.from(encodeLength(0x100))).toEqual([0x82, 0x01, 0x00]);
});
it("rejects negative or non-integer length", { timeout: 5000 }, () => {
expect(() => encodeLength(-1)).toThrow(CryptoRsaError);
expect(() => encodeLength(1.5)).toThrow(CryptoRsaError);
});
});
describe("encodeInteger", () => {
it("round-trips positive integers", { timeout: 5000 }, () => {
for (const v of [1n, 0x7fn, 0x80n, 0xffn, 0x100n, 1n << 256n]) {
const bytes = encodeInteger(v);
expect(new DerReader(bytes).readInteger()).toBe(v);
}
});
it("encodes zero as 0x02 0x01 0x00", { timeout: 5000 }, () => {
expect(Array.from(encodeInteger(0n))).toEqual([0x02, 0x01, 0x00]);
});
it("rejects negative integer input", { timeout: 5000 }, () => {
expect(() => encodeInteger(-1n)).toThrow(CryptoRsaError);
});
});
describe("encodeSequence", () => {
it("round-trips through DerReader.readSequence", { timeout: 5000 }, () => {
const seq = encodeSequence(encodeInteger(1n), encodeInteger(2n));
const reader = new DerReader(seq).readSequence();
expect(reader.readInteger()).toBe(1n);
expect(reader.readInteger()).toBe(2n);
});
});
describe("encodeObjectIdentifier", () => {
it("round-trips well-known OIDs", { timeout: 5000 }, () => {
for (const oid of ["1.2.840.113549.1.1.1", "0.0", "1.39", "2.999"]) {
const bytes = encodeObjectIdentifier(oid);
expect(new DerReader(bytes).readObjectIdentifier()).toBe(oid);
}
});
it("rejects malformed arc strings", { timeout: 5000 }, () => {
expect(() => encodeObjectIdentifier("1.x.3")).toThrow(CryptoRsaError);
});
it("rejects single-arc OID", { timeout: 5000 }, () => {
expect(() => encodeObjectIdentifier("1")).toThrow(CryptoRsaError);
});
it("rejects out-of-range first arcs", { timeout: 5000 }, () => {
expect(() => encodeObjectIdentifier("3.0")).toThrow(CryptoRsaError);
expect(() => encodeObjectIdentifier("0.40")).toThrow(CryptoRsaError);
});
it("round-trips an OID with an arc whose value is exactly 0", {
timeout: 5000,
}, () => {
const bytes = encodeObjectIdentifier("1.2.0.5");
expect(new DerReader(bytes).readObjectIdentifier()).toBe("1.2.0.5");
});
});
describe("encodeBitString / encodeOctetString / encodeNull", () => {
it("BIT STRING round-trips", { timeout: 5000 }, () => {
const payload = new Uint8Array([0xab, 0xcd]);
const bytes = encodeBitString(payload);
expect(Array.from(new DerReader(bytes).readBitString())).toEqual(
Array.from(payload)
);
});
it("OCTET STRING round-trips", { timeout: 5000 }, () => {
const payload = new Uint8Array([1, 2, 3]);
const bytes = encodeOctetString(payload);
expect(Array.from(new DerReader(bytes).readOctetString())).toEqual([
1, 2, 3,
]);
});
it("NULL emits 0x05 0x00", { timeout: 5000 }, () => {
expect(Array.from(encodeNull())).toEqual([0x05, 0x00]);
});
});
describe("encodeContextSpecific", () => {
it("round-trips a context-specific element", { timeout: 5000 }, () => {
const wrapped = encodeContextSpecific(1, encodeInteger(99n));
const reader = new DerReader(wrapped).readContextSpecific(1);
expect(reader.readInteger()).toBe(99n);
});
it("rejects out-of-range tag number", { timeout: 5000 }, () => {
expect(() => encodeContextSpecific(31, new Uint8Array(0))).toThrow(
CryptoRsaError
);
expect(() => encodeContextSpecific(-1, new Uint8Array(0))).toThrow(
CryptoRsaError
);
});
});

View File

@@ -0,0 +1,157 @@
import { concatBytes } from "@suaveplan/crypto-utils";
import { CRYPTO_RSA_CODES, CryptoRsaError } from "../errors/errors.js";
import {
TAG_BIT_STRING,
TAG_CONTEXT_SPECIFIC_BASE,
TAG_INTEGER,
TAG_NULL,
TAG_OCTET_STRING,
TAG_OID,
TAG_SEQUENCE,
} from "./reader.js";
/**
* Encode a length per X.690 §8.1.3 strict DER (definite, minimal form).
*/
export function encodeLength(length: number): Uint8Array {
if (!Number.isInteger(length) || length < 0) {
throw new CryptoRsaError(
CRYPTO_RSA_CODES.INVALID_KEY,
"error.crypto-rsa.invalid-key.bad-length"
);
}
if (length < 0x80) {
return new Uint8Array([length]);
}
const bytes: number[] = [];
let v = length;
while (v > 0) {
bytes.unshift(v & 0xff);
v >>>= 8;
}
return new Uint8Array([0x80 | bytes.length, ...bytes]);
}
function encodeTLV(tag: number, body: Uint8Array): Uint8Array {
return concatBytes(new Uint8Array([tag]), encodeLength(body.length), body);
}
/**
* Encode a non-negative `bigint` as a DER `INTEGER` body, prepending `0x00`
* when the high bit of the leading byte is set so the value is unambiguously
* interpreted as positive.
*/
export function encodeInteger(value: bigint): Uint8Array {
if (value < 0n) {
throw new CryptoRsaError(
CRYPTO_RSA_CODES.INVALID_KEY,
"error.crypto-rsa.invalid-key.negative-integer"
);
}
if (value === 0n) {
return encodeTLV(TAG_INTEGER, new Uint8Array([0x00]));
}
const bytes: number[] = [];
let v = value;
while (v > 0n) {
bytes.unshift(Number(v & 0xffn));
v >>= 8n;
}
if ((bytes[0] as number) & 0x80) {
bytes.unshift(0x00);
}
return encodeTLV(TAG_INTEGER, new Uint8Array(bytes));
}
export function encodeSequence(...children: Uint8Array[]): Uint8Array {
return encodeTLV(TAG_SEQUENCE, concatBytes(...children));
}
export function encodeObjectIdentifier(oid: string): Uint8Array {
const arcs = oid.split(".").map((part) => {
if (!/^[0-9]+$/.test(part)) {
throw new CryptoRsaError(
CRYPTO_RSA_CODES.INVALID_KEY,
"error.crypto-rsa.invalid-key.bad-oid",
{ context: { part } }
);
}
return BigInt(part);
});
if (arcs.length < 2) {
throw new CryptoRsaError(
CRYPTO_RSA_CODES.INVALID_KEY,
"error.crypto-rsa.invalid-key.bad-oid",
{ context: { oid } }
);
}
const arc1 = arcs[0] as bigint;
const arc2 = arcs[1] as bigint;
if (arc1 > 2n || (arc1 < 2n && arc2 >= 40n)) {
throw new CryptoRsaError(
CRYPTO_RSA_CODES.INVALID_KEY,
"error.crypto-rsa.invalid-key.bad-oid",
{ context: { oid } }
);
}
// The first two arcs are folded into a single base-128 sub-identifier
// per X.690 §8.19. Encoding the combined value as base-128 (rather than
// a single byte) is required when arc1 = 2 and arc2 ≥ 88 so the result
// exceeds 0x7f.
const out: number[] = encodeBase128(arc1 * 40n + arc2);
for (let i = 2; i < arcs.length; i++) {
out.push(...encodeBase128(arcs[i] as bigint));
}
return encodeTLV(TAG_OID, new Uint8Array(out));
}
function encodeBase128(value: bigint): number[] {
if (value === 0n) {
return [0x00];
}
const bytes: number[] = [];
let v = value;
let first = true;
while (v > 0n) {
const chunk = Number(v & 0x7fn);
bytes.unshift(first ? chunk : chunk | 0x80);
first = false;
v >>= 7n;
}
return bytes;
}
/**
* Encode a primitive `BIT STRING` carrying a whole-byte payload (no unused
* trailing bits). All RSA SPKI/PKCS#1 use cases satisfy this constraint.
*/
export function encodeBitString(payload: Uint8Array): Uint8Array {
const body = new Uint8Array(payload.length + 1);
body[0] = 0x00;
body.set(payload, 1);
return encodeTLV(TAG_BIT_STRING, body);
}
export function encodeOctetString(payload: Uint8Array): Uint8Array {
return encodeTLV(TAG_OCTET_STRING, payload);
}
export function encodeNull(): Uint8Array {
return encodeTLV(TAG_NULL, new Uint8Array(0));
}
/**
* Encode an `[n] EXPLICIT` context-specific wrapping over the supplied body.
*/
export function encodeContextSpecific(
tagNumber: number,
body: Uint8Array
): Uint8Array {
if (tagNumber < 0 || tagNumber > 30) {
throw new CryptoRsaError(
CRYPTO_RSA_CODES.INVALID_KEY,
"error.crypto-rsa.invalid-key.bad-context-tag"
);
}
return encodeTLV(TAG_CONTEXT_SPECIFIC_BASE | tagNumber, body);
}

View File

@@ -0,0 +1,133 @@
# `blinding`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
RSA blinding (RFC 8017 §5.1.2 informational note) is the standard
defence against remote-timing attacks on RSA private operations
(decrypt and sign). Without it, the wall-clock time of a
`c^d mod n` computation correlates with the bits of the secret
exponent `d` and with the value of `c` — well-published attacks
(Brumley/Boneh 2003, Aciicmez et al. 2007) recovered RSA private
keys from a single network-attached server in minutes.
This module exposes the two halves of the defence — `blindMessage`
that randomises the input and `unblindResult` that recovers the
true output — as standalone helpers so they can be wired into
every private-operation surface in the package (`rsaDecryptPkcs1v15`
and `rsaDecryptOaep` here , `rsaSignPkcs1v15` and
`rsaSignPss` once that wave lands).
## Features
- Uniform rejection sampling of the blinding factor `r` over
`Z*_n` (drop any candidate `≥ n`, drop any candidate sharing a
prime factor with `n` so `r^-1 mod n` always exists).
- Exhaustion guard — `MAX_REJECTIONS = 1024` candidates before
surfacing `KEYGEN_FAILED`. For a well-formed RSA modulus this
never fires; the cap exists only to keep a malformed key (e.g.
`n` with very small factors) from looping forever.
- Pluggable `Rng` source — works with both `crypto.getRandomValues`
CSPRNGs (use the optional `nextBytes` fast path) and seeded
deterministic test RNGs (fall back to `nextUint32` extraction).
- Helper `nextBytesViaRng` exported for reuse by keygen and other
modules that need RNG byte extraction.
## Usage: Basic Example
```ts
import { blindMessage, unblindResult } from "@suaveplan/crypto-rsa/blinding";
import {
rsaPrivateOperationCrt,
type RsaPrivateKey,
type RsaPublicKey,
} from "@suaveplan/crypto-rsa";
import { defaultByteSource } from "@suaveplan/crypto-utils";
declare const m: bigint;
declare const publicKey: RsaPublicKey;
declare const privateKey: RsaPrivateKey;
const csprng = {
nextUint32: () =>
new DataView(defaultByteSource(4).buffer).getUint32(0),
nextFloat: () => Math.random(),
nextBytes: (n: number) => defaultByteSource(n),
};
const { mPrime, rInverse } = blindMessage(m, publicKey, csprng);
const sBlinded = rsaPrivateOperationCrt(mPrime, privateKey);
const s = unblindResult(sBlinded, rInverse, publicKey.n);
// s === m^d mod n, but the modPow ran on the random m' instead of m
```
## Usage: Advanced Example
A deterministic seeded `Rng` for tests — `blindMessage` will use
`nextUint32` extraction when `nextBytes` is omitted, so the same
seed produces the same `(mPrime, rInverse)` every run:
```ts
import { blindMessage, unblindResult } from "@suaveplan/crypto-rsa/blinding";
let state = 0x12345678 >>> 0;
const seededRng = {
nextUint32: () => {
state = (state * 1664525 + 1013904223) >>> 0;
return state;
},
nextFloat: () => state / 0xffffffff,
};
const { mPrime, rInverse } = blindMessage(0x42n, publicKey, seededRng);
// ... feed into rsaPrivateOperationCrt, then unblindResult(...)
```
## API Reference
### `blindMessage(m, key, rng): BlindResult`
- `m: bigint` — message representative in `[0, n)`. Throws
`CRYPTO_RSA.INVALID_KEY` if out of range.
- `key: RsaPublicKey` — public key carrying `n` and `e`.
- `rng: Rng` — randomness source (canonical
`@suaveplan/types` interface).
- Returns `{ mPrime, rInverse }` — the blinded message and the
modular inverse of `r` to be passed back into `unblindResult`.
- Throws `CRYPTO_RSA.KEYGEN_FAILED` after `MAX_REJECTIONS` failed
rejection-sampling attempts. Indicates a pathological modulus,
not a normal failure mode.
### `unblindResult(s, rInverse, n): bigint`
Pure modular multiplication — `(s * rInverse) % n`. Constant-time
to the extent JavaScript's bigint arithmetic allows.
### `nextBytesViaRng(rng, n): Uint8Array`
Helper that extracts `n` bytes from an arbitrary `Rng`,
preferring the optional `nextBytes` fast path when present.
## Implementation Notes
- We use noble's `invert` for `r^-1 mod n` rather than rolling our
own extended-Euclidean — noble already implements the Bezout
algorithm with bigint-correct sign handling.
- `gcd(r, n)` rejection guards against the (vanishingly rare)
case where the random `r` shares a prime factor with `n`.
Without this, `invert` would throw and we'd lose the candidate
anyway, but the gcd check is cheaper than catching the throw
in a tight loop.
- The blinding factor `r` is sampled byte-wise with a top-bit
mask matching `n`'s bit length so the rejection rate stays
below 50% per draw.
- This module is constant-time only to the extent JavaScript's
bigint arithmetic allows. Bigint multiplication and modular
reduction over 2048+ bit values are NOT constant-time in V8 or
JavaScriptCore. Blinding still meaningfully degrades the
signal-to-noise ratio of timing attacks because the secret no
longer correlates with the input — but for FIPS-grade
side-channel resistance, defer to a hardware engine
(`crypto-browser` WebCrypto, `crypto-sodium` libsodium).

View File

@@ -0,0 +1,170 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { Rng } from "@suaveplan/types";
import {
LEGACY_1024_PRIVATE_KEY,
LEGACY_1024_PUBLIC_KEY,
} from "../__fixtures__/test-keys.js";
import { CryptoRsaError } from "../errors/errors.js";
import { rsaPrivateOperationCrt } from "../math/crt.js";
import { modPow } from "../math/modpow.js";
import { blindMessage, nextBytesViaRng, unblindResult } from "./blinding.js";
function seededRng(seed = 0xdeadbeef >>> 0): Rng {
let state = seed >>> 0;
return {
nextUint32: () => {
state = (Math.imul(state, 1664525) + 1013904223) >>> 0;
return state;
},
nextFloat: () => {
state = (Math.imul(state, 1664525) + 1013904223) >>> 0;
return state / 0x1_0000_0000;
},
};
}
function bytesRng(bytes: Uint8Array): Rng {
let i = 0;
return {
nextUint32: () => 0,
nextFloat: () => 0,
nextBytes: (n: number) => {
const out = bytes.slice(i, i + n);
i += n;
return out;
},
};
}
describe("blindMessage / unblindResult", () => {
it("round-trip: unblind(blind(m)^d) === m^d mod n", {
timeout: 5000,
}, () => {
const m = 0x424242n;
const { mPrime, rInverse } = blindMessage(
m,
LEGACY_1024_PUBLIC_KEY,
seededRng()
);
expect(mPrime).not.toBe(m);
const sBlinded = rsaPrivateOperationCrt(
mPrime,
LEGACY_1024_PRIVATE_KEY
);
const s = unblindResult(sBlinded, rInverse, LEGACY_1024_PUBLIC_KEY.n);
const direct = modPow(
m,
LEGACY_1024_PRIVATE_KEY.d,
LEGACY_1024_PUBLIC_KEY.n
);
expect(s).toBe(direct);
});
it("blinded m' differs from m and changes on a fresh RNG draw", {
timeout: 5000,
}, () => {
const m = 0x1234n;
const a = blindMessage(m, LEGACY_1024_PUBLIC_KEY, seededRng(1));
const b = blindMessage(m, LEGACY_1024_PUBLIC_KEY, seededRng(2));
expect(a.mPrime).not.toBe(b.mPrime);
expect(a.rInverse).not.toBe(b.rInverse);
});
it("rejects message representative out of range", {
timeout: 5000,
}, () => {
expect(() =>
blindMessage(-1n, LEGACY_1024_PUBLIC_KEY, seededRng())
).toThrow(CryptoRsaError);
expect(() =>
blindMessage(
LEGACY_1024_PUBLIC_KEY.n,
LEGACY_1024_PUBLIC_KEY,
seededRng()
)
).toThrow(CryptoRsaError);
});
it("uses nextBytes fast path when available", { timeout: 5000 }, () => {
// Provide enough random bytes — the loop may need >1 draw if a
// candidate happens to be ≥ n. 16 draws of byteLen 128 suffices.
const buf = new Uint8Array(128 * 16);
crypto.getRandomValues(buf);
const { mPrime, rInverse } = blindMessage(
0x42n,
LEGACY_1024_PUBLIC_KEY,
bytesRng(buf)
);
expect(mPrime).toBeGreaterThan(0n);
expect(rInverse).toBeGreaterThan(0n);
});
it("nextBytesViaRng errors when nextBytes returns short buffer", {
timeout: 5000,
}, () => {
const badRng: Rng = {
nextUint32: () => 0,
nextFloat: () => 0,
nextBytes: () => new Uint8Array(0),
};
expect(() => nextBytesViaRng(badRng, 4)).toThrow(CryptoRsaError);
});
it("nextBytesViaRng falls back to nextUint32 extraction", {
timeout: 5000,
}, () => {
const out = nextBytesViaRng(seededRng(7), 13);
expect(out.length).toBe(13);
});
it("exhaustion: pathological RNG always producing r < 2 surfaces KEYGEN_FAILED", {
timeout: 5000,
}, () => {
const zeroRng: Rng = {
nextUint32: () => 0,
nextFloat: () => 0,
nextBytes: (n: number) => new Uint8Array(n),
};
try {
blindMessage(0x42n, LEGACY_1024_PUBLIC_KEY, zeroRng);
throw new Error("should have thrown");
} catch (err) {
expect(err).toBeInstanceOf(CryptoRsaError);
expect((err as CryptoRsaError).code).toBe(
"CRYPTO_RSA.KEYGEN_FAILED"
);
}
});
it("gcd(r, n) ≠ 1 reject path: first draw shares factor p with n, second succeeds", {
timeout: 5000,
}, () => {
// Encode r = p (512-bit) followed by a known coprime r' so
// the rejection-sampling loop hits the gcd-not-1 branch
// exactly once before succeeding.
const byteLen = 128;
const p = LEGACY_1024_PRIVATE_KEY.p;
// First draw: pad to 128 bytes with zeros at top.
const first = new Uint8Array(byteLen);
const pBytes: number[] = [];
let v = p;
while (v > 0n) {
pBytes.unshift(Number(v & 0xffn));
v >>= 8n;
}
first.set(pBytes, byteLen - pBytes.length);
// Subsequent draws: random bytes (likely coprime). Provide many
// backups in case any randomly-drawn r happens to be ≥ n.
const tail = new Uint8Array(byteLen * 32);
crypto.getRandomValues(tail);
const combined = new Uint8Array(byteLen + tail.length);
combined.set(first, 0);
combined.set(tail, byteLen);
const result = blindMessage(
0x99n,
LEGACY_1024_PUBLIC_KEY,
bytesRng(combined)
);
expect(result.mPrime).toBeGreaterThan(0n);
});
});

View File

@@ -0,0 +1,144 @@
import { invert } from "@noble/curves/abstract/modular";
import type { Rng } from "@suaveplan/types";
import { CRYPTO_RSA_CODES, CryptoRsaError } from "../errors/errors.js";
import type { RsaPublicKey } from "../keys/keys.types.js";
import { modPow } from "../math/modpow.js";
/**
* Output of `blindMessage`: the blinded message representative `m * r^e
* mod n` ready to feed into `rsaPrivateOperationCrt`, plus the modular
* inverse `r^-1 mod n` the caller must keep until the private operation
* returns.
*/
export interface BlindResult {
readonly mPrime: bigint;
readonly rInverse: bigint;
}
/**
* RSA blinding (RFC 8017 §5.1.2 informational note). Given a message
* representative `m`, sample a uniform `r ∈ [2, n-1]` coprime with `n`
* and return `m' = m * r^e mod n` together with `r^-1 mod n`.
*
* Wired into `rsaDecryptPkcs1v15` / `rsaDecryptOaep` (and the Stage 2
* sign paths) by default — the `{ blinding: false }` opt-out emits the
* `crypto_rsa.blinding_disabled_total{operation}` counter so production
* dashboards can flag any caller that disables the timing-side-channel
* defence.
*
* The rejection-sampling loop terminates after `MAX_REJECTIONS` attempts
* with `KEYGEN_FAILED` — for a well-formed public key with `n` coprime to
* almost every value, this never fires in practice.
*/
const MAX_REJECTIONS = 1024;
export function blindMessage(
m: bigint,
key: RsaPublicKey,
rng: Rng
): BlindResult {
const n = key.n;
if (m < 0n || m >= n) {
throw new CryptoRsaError(
CRYPTO_RSA_CODES.INVALID_KEY,
"error.crypto-rsa.blinding.message-out-of-range"
);
}
let attempts = 0;
while (attempts < MAX_REJECTIONS) {
attempts++;
const r = sampleCoprimeUniform(n, rng);
if (r === undefined) {
continue;
}
// gcd(r, n) === 1 is enforced inside sampleCoprimeUniform, so
// invert always succeeds. The defence-in-depth try/catch was
// removed: no upstream input can drive `invert` to throw here
// and a dead catch block undermines coverage as deliberate
// dead code.
const rInverse = invert(r, n);
const rE = modPow(r, key.e, n);
const mPrime = (m * rE) % n;
return { mPrime, rInverse };
}
throw new CryptoRsaError(
CRYPTO_RSA_CODES.KEYGEN_FAILED,
"error.crypto-rsa.blinding.exhausted"
);
}
/**
* Reverse the blinding step: given the unblinded private-operation
* output `s = (m')^d mod n`, return the true `m^d mod n` by multiplying
* with `r^-1` modulo `n`.
*/
export function unblindResult(s: bigint, rInverse: bigint, n: bigint): bigint {
return (s * rInverse) % n;
}
/**
* Sample a uniform random `r ∈ [2, n-1]` coprime with `n`. Returns
* `undefined` when the candidate happened to share a factor with `n` so
* the caller can retry. Uses byte-wise rejection sampling (drop any
* candidate `≥ n` AND any candidate sharing a factor) for an unbiased
* distribution across `Z*_n`.
*/
function sampleCoprimeUniform(n: bigint, rng: Rng): bigint | undefined {
const bitLen = n.toString(2).length;
const byteLen = Math.ceil(bitLen / 8);
const topMask = (1 << (bitLen - (byteLen - 1) * 8)) - 1;
const bytes = nextBytesViaRng(rng, byteLen);
bytes[0] = (bytes[0] as number) & topMask;
let r = 0n;
for (const b of bytes) {
r = (r << 8n) | BigInt(b);
}
if (r < 2n || r >= n) {
return undefined;
}
if (gcd(r, n) !== 1n) {
return undefined;
}
return r;
}
function gcd(a: bigint, b: bigint): bigint {
// Internal helper — only called with non-negative inputs from
// sampleCoprimeUniform (where r ∈ [2, n-1] and n > 0).
let x = a;
let y = b;
while (y !== 0n) {
[x, y] = [y, x % y];
}
return x;
}
/**
* Pull `n` bytes from an `Rng`. Uses the optional `nextBytes` shortcut
* when present (true for CSPRNG-backed `Rng` values) and falls back to
* `nextUint32` byte-extraction otherwise (so deterministic seeded RNGs
* used in tests work without implementing `nextBytes`).
*/
export function nextBytesViaRng(rng: Rng, n: number): Uint8Array {
if (rng.nextBytes !== undefined) {
const out = rng.nextBytes(n);
if (out.length !== n) {
throw new CryptoRsaError(
CRYPTO_RSA_CODES.KEYGEN_FAILED,
"error.crypto-rsa.blinding.rng-short-read"
);
}
return out;
}
const out = new Uint8Array(n);
let i = 0;
while (i < n) {
const u32 = rng.nextUint32() >>> 0;
out[i] = (u32 >>> 24) & 0xff;
if (i + 1 < n) out[i + 1] = (u32 >>> 16) & 0xff;
if (i + 2 < n) out[i + 2] = (u32 >>> 8) & 0xff;
if (i + 3 < n) out[i + 3] = u32 & 0xff;
i += 4;
}
return out;
}

View File

@@ -0,0 +1,6 @@
export type { BlindResult } from "./blinding.js";
export {
blindMessage,
nextBytesViaRng,
unblindResult,
} from "./blinding.js";

View File

@@ -0,0 +1,8 @@
export {
type RsaDecryptOaepOptions,
rsaDecryptOaep,
} from "./oaep.js";
export {
type RsaDecryptPkcs1v15Options,
rsaDecryptPkcs1v15,
} from "./pkcs1v15.js";

View File

@@ -0,0 +1,86 @@
# `decrypt/oaep`
## Purpose
RSAES-OAEP decryption per RFC 8017 §7.1.2 — counterpart to
`encrypt/oaep`. Modern default for RSA public-key encryption.
The defining concern of this module is the **Manger defence**:
every padding-format failure surfaces the SAME
`CRYPTO_RSA.DECRYPT_FAILED` error so a remote attacker cannot
mount the chosen-ciphertext attack Manger described against
naive OAEP implementations in 2001.
## Features
- Combined error path — leading-byte mismatch, `lHash` mismatch
(wrong label), missing `0x01` separator, non-zero byte in
`PS` before separator, integer ≥ `n`, ciphertext length ≠ `k`,
modulus too small for the chosen hash: every failure raises
the single `DECRYPT_FAILED` code.
- Constant-time `lHash` comparison via `constantTimeEqual` from
`@suaveplan/crypto-utils` (the comparison we DO have a
guarantee about — the rest of the unwrap is best-effort).
- RSA blinding wired in by default; `{ blinding: false }` opt-out
emits the `crypto_rsa.blinding_disabled_total{operation="decrypt"}`
counter.
- CRT-accelerated private operation.
- `crypto_rsa.decrypt_duration_ms{scheme="oaep",hash}` histogram.
## Usage: Basic Example
```ts
import { rsaDecryptOaep } from "@suaveplan/crypto-rsa/decrypt/oaep";
const plaintext = rsaDecryptOaep(privateKey, ciphertext, {
hash: "SHA-256",
});
```
## Usage: Advanced Example
OAEP-SHA-384 with explicit label — the matching encrypt MUST
have used the same hash, MGF hash, AND label or this throws
`DECRYPT_FAILED`:
```ts
import { rsaDecryptOaep } from "@suaveplan/crypto-rsa/decrypt/oaep";
const plaintext = rsaDecryptOaep(privateKey, ciphertext, {
hash: "SHA-384",
mgfHash: "SHA-384",
label: new TextEncoder().encode("session-2026-05-11"),
allowLegacy: false,
});
```
## API Reference
### `rsaDecryptOaep(key, ciphertext, options?): Uint8Array`
- `key: RsaPrivateKey`
- `ciphertext: Uint8Array` — must equal `k = ceil(bitLength / 8)`.
- `options.hash?: RsaHashAlgorithm` — defaults to `"SHA-256"`.
- `options.mgfHash?: RsaHashAlgorithm` — defaults to `hash`.
- `options.label?: Uint8Array` — defaults to empty.
- `options.rng?: Rng` — randomness source for blinding.
- `options.blinding?: boolean` — defaults to `true`.
- `options.allowLegacy?: boolean` — accept 1024-bit moduli.
- Returns the recovered plaintext.
- Throws `CRYPTO_RSA.DECRYPT_FAILED` on every padding failure.
## Implementation Notes
- The combined error path is enforced by routing every failure
through the private `decryptFailed()` helper — a single
callsite makes future drift detectable in code review.
- `constantTimeEqual` from `@suaveplan/crypto-utils` covers the
`lHash` byte comparison; the surrounding unwrap loop is NOT
constant-time because JavaScript bigint and `Uint8Array.slice`
are not. This is the same practical limitation as PKCS1-v1_5
decryption — for FIPS-grade side-channel resistance, defer to
`crypto-browser` (WebCrypto) or `crypto-sodium`.
- The OAEP separator scan walks `db` from index `hLen` onward
and rejects on either a non-zero, non-0x01 byte before the
separator or no separator at all. This implements RFC 8017
§7.1.2 step 3.g exactly.

View File

@@ -0,0 +1,235 @@
import { xor } from "@suaveplan/crypto-utils";
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
LEGACY_1024_PRIVATE_KEY,
LEGACY_1024_PUBLIC_KEY,
} from "../__fixtures__/test-keys.js";
import { digestWith, mgf1 } from "../encoding/mgf1.js";
import { rsaEncryptOaep } from "../encrypt/oaep.js";
import { CRYPTO_RSA_CODES, CryptoRsaError } from "../errors/errors.js";
import { rsaDecryptOaep, unwrapOaep } from "./oaep.js";
function flipBit(bytes: Uint8Array, i: number, bit = 0): Uint8Array {
const out = new Uint8Array(bytes);
out[i] = (out[i] as number) ^ (1 << bit);
return out;
}
/**
* Build an OAEP `em` value with an arbitrary `db` payload — used to drive
* the separator-scan branches inside `unwrapOaep` directly without going
* through the full encrypt path. Returns the masked `em` ready to feed
* straight into `unwrapOaep`.
*/
function buildEm(
db: Uint8Array,
seed: Uint8Array,
hash: "SHA-256"
): Uint8Array {
const dbMask = mgf1(seed, db.length, hash);
const maskedDb = xor(db, dbMask);
const seedMask = mgf1(maskedDb, seed.length, hash);
const maskedSeed = xor(seed, seedMask);
const em = new Uint8Array(1 + seed.length + db.length);
em[0] = 0x00;
em.set(maskedSeed, 1);
em.set(maskedDb, 1 + seed.length);
return em;
}
describe("rsaDecryptOaep — Manger defence", () => {
it("round-trip baseline", { timeout: 10000 }, () => {
const pt = new TextEncoder().encode("oaep");
const ct = rsaEncryptOaep(LEGACY_1024_PUBLIC_KEY, pt, {
hash: "SHA-256",
allowLegacy: true,
});
const recovered = rsaDecryptOaep(LEGACY_1024_PRIVATE_KEY, ct, {
hash: "SHA-256",
allowLegacy: true,
});
expect(Array.from(recovered)).toEqual(Array.from(pt));
});
it("bit-flipped ciphertext throws DECRYPT_FAILED", {
timeout: 10000,
}, () => {
const ct = rsaEncryptOaep(
LEGACY_1024_PUBLIC_KEY,
new Uint8Array([1, 2, 3]),
{ hash: "SHA-256", allowLegacy: true }
);
const tampered = flipBit(ct, 60, 4);
try {
rsaDecryptOaep(LEGACY_1024_PRIVATE_KEY, tampered, {
hash: "SHA-256",
allowLegacy: true,
});
throw new Error("should have thrown");
} catch (err) {
expect(err).toBeInstanceOf(CryptoRsaError);
expect((err as CryptoRsaError).code).toBe(
CRYPTO_RSA_CODES.DECRYPT_FAILED
);
}
});
it("mismatched label throws DECRYPT_FAILED (combined)", {
timeout: 10000,
}, () => {
const ct = rsaEncryptOaep(LEGACY_1024_PUBLIC_KEY, new Uint8Array([9]), {
hash: "SHA-256",
label: new TextEncoder().encode("alpha"),
allowLegacy: true,
});
try {
rsaDecryptOaep(LEGACY_1024_PRIVATE_KEY, ct, {
hash: "SHA-256",
label: new TextEncoder().encode("beta"),
allowLegacy: true,
});
throw new Error("should have thrown");
} catch (err) {
expect((err as CryptoRsaError).code).toBe(
CRYPTO_RSA_CODES.DECRYPT_FAILED
);
}
});
it("ciphertext wrong length throws DECRYPT_FAILED", {
timeout: 5000,
}, () => {
try {
rsaDecryptOaep(LEGACY_1024_PRIVATE_KEY, new Uint8Array(64), {
hash: "SHA-256",
allowLegacy: true,
});
throw new Error("should have thrown");
} catch (err) {
expect((err as CryptoRsaError).code).toBe(
CRYPTO_RSA_CODES.DECRYPT_FAILED
);
}
});
it("integer ≥ n throws DECRYPT_FAILED", { timeout: 5000 }, () => {
const big = new Uint8Array(128).fill(0xff);
try {
rsaDecryptOaep(LEGACY_1024_PRIVATE_KEY, big, {
hash: "SHA-256",
allowLegacy: true,
});
throw new Error("should have thrown");
} catch (err) {
expect((err as CryptoRsaError).code).toBe(
CRYPTO_RSA_CODES.DECRYPT_FAILED
);
}
});
it("blinding: false still round-trips", { timeout: 10000 }, () => {
const pt = new Uint8Array([42]);
const ct = rsaEncryptOaep(LEGACY_1024_PUBLIC_KEY, pt, {
hash: "SHA-256",
allowLegacy: true,
});
const recovered = rsaDecryptOaep(LEGACY_1024_PRIVATE_KEY, ct, {
hash: "SHA-256",
blinding: false,
allowLegacy: true,
});
expect(Array.from(recovered)).toEqual(Array.from(pt));
});
it("policy gate rejects sub-1024 keys", { timeout: 5000 }, () => {
const tiny = { ...LEGACY_1024_PRIVATE_KEY, bitLength: 16 };
expect(() =>
rsaDecryptOaep(tiny, new Uint8Array(2), { hash: "SHA-256" })
).toThrow(CryptoRsaError);
});
it("no-options call exercises the EMPTY_OPTS default-parameter branch", {
timeout: 5000,
}, () => {
// Synthetic 2048-bit key (bitLength field only, no real
// arithmetic) so the policy gate passes; ciphertext length
// mismatch then surfaces DECRYPT_FAILED. Exercises the
// `options ?? EMPTY_OPTS` branch when callers omit options.
const synth = {
...LEGACY_1024_PRIVATE_KEY,
bitLength: 2048,
};
expect(() => rsaDecryptOaep(synth, new Uint8Array(0))).toThrow(
CryptoRsaError
);
});
});
describe("unwrapOaep — separator-scan branch coverage", () => {
it("rejects em with leading byte ≠ 0x00", {
timeout: 5000,
}, () => {
const em = new Uint8Array(128);
em[0] = 0x01;
expect(() =>
unwrapOaep(em, 32, "SHA-256", "SHA-256", new Uint8Array(0))
).toThrow(CryptoRsaError);
});
it("rejects db with non-zero byte before separator", {
timeout: 5000,
}, () => {
const hLen = 32;
const k = 128;
const lHash = digestWith("SHA-256", new Uint8Array(0));
const db = new Uint8Array(k - hLen - 1);
db.set(lHash, 0);
// Place a non-zero byte (0x42) at index hLen — before any
// 0x01 separator. Triggers the "non-zero byte before
// separator" reject branch.
db[hLen] = 0x42;
const seed = new Uint8Array(hLen).fill(0x07);
const em = buildEm(db, seed, "SHA-256");
expect(() =>
unwrapOaep(em, hLen, "SHA-256", "SHA-256", new Uint8Array(0))
).toThrow(CryptoRsaError);
});
it("rejects db with no 0x01 separator at all", { timeout: 5000 }, () => {
const hLen = 32;
const k = 128;
const lHash = digestWith("SHA-256", new Uint8Array(0));
const db = new Uint8Array(k - hLen - 1);
db.set(lHash, 0);
// Leave the rest as zeros — separator never appears.
const seed = new Uint8Array(hLen).fill(0x09);
const em = buildEm(db, seed, "SHA-256");
expect(() =>
unwrapOaep(em, hLen, "SHA-256", "SHA-256", new Uint8Array(0))
).toThrow(CryptoRsaError);
});
it("accepts well-formed db and returns plaintext", {
timeout: 5000,
}, () => {
const hLen = 32;
const k = 128;
const lHash = digestWith("SHA-256", new Uint8Array(0));
const db = new Uint8Array(k - hLen - 1);
db.set(lHash, 0);
db[hLen + 50] = 0x01;
db[hLen + 51] = 0xab;
db[hLen + 52] = 0xcd;
const seed = new Uint8Array(hLen).fill(0x05);
const em = buildEm(db, seed, "SHA-256");
const out = unwrapOaep(
em,
hLen,
"SHA-256",
"SHA-256",
new Uint8Array(0)
);
expect(out[0]).toBe(0xab);
expect(out[1]).toBe(0xcd);
});
});

View File

@@ -0,0 +1,148 @@
import { constantTimeEqual, xor } from "@suaveplan/crypto-utils";
import type { Rng } from "@suaveplan/types";
import { blindMessage, unblindResult } from "../blinding/blinding.js";
import { digestWith, hashOutputLength, mgf1 } from "../encoding/mgf1.js";
import { defaultRng } from "../encrypt/default-rng.js";
import { CRYPTO_RSA_CODES, CryptoRsaError } from "../errors/errors.js";
import type { RsaHashAlgorithm, RsaPrivateKey } from "../keys/keys.types.js";
import { rsaPrivateOperationCrt } from "../math/crt.js";
import { i2osp } from "../math/i2osp.js";
import { os2ip } from "../math/os2ip.js";
import {
recordBlindingDisabled,
recordDecryptDuration,
recordThrown,
} from "../observability/metrics.js";
import { enforceMaxBitLength, enforceMinBitLength } from "../policy/modulus.js";
/**
* Optional input for `rsaDecryptOaep`. Defaults: `hash: "SHA-256"`,
* `mgfHash` defaults to `hash`, `label: empty`. Blinding defaults on.
*/
export interface RsaDecryptOaepOptions {
readonly hash?: RsaHashAlgorithm;
readonly mgfHash?: RsaHashAlgorithm;
readonly label?: Uint8Array;
readonly rng?: Rng;
readonly blinding?: boolean;
readonly allowLegacy?: boolean;
}
const EMPTY = new Uint8Array(0);
const EMPTY_OPTS: RsaDecryptOaepOptions = {};
/**
* RSAES-OAEP decryption per RFC 8017 §7.1.2.
*
* **Manger defence — combined error path.** Every padding-format
* failure (length mismatch, integer ≥ `n`, leading byte ≠ `0x00`,
* `lHash` mismatch, missing `0x01` separator, invalid `PS`) raises
* the SAME `CRYPTO_RSA.DECRYPT_FAILED` error and bumps the same
* `errors_total{code}` counter. Callers cannot distinguish "wrong
* label" from "wrong padding" from "tampered ciphertext".
*/
export function rsaDecryptOaep(
key: RsaPrivateKey,
ciphertext: Uint8Array,
options?: RsaDecryptOaepOptions
): Uint8Array {
const start = nowMs();
const opts = options ?? EMPTY_OPTS;
const hash = opts.hash ?? "SHA-256";
const mgfHash = opts.mgfHash ?? hash;
try {
enforceMinBitLength(
key,
opts.allowLegacy === undefined
? {}
: { allowLegacy: opts.allowLegacy }
);
enforceMaxBitLength(key);
const k = byteLength(key);
const hLen = hashOutputLength(hash);
if (ciphertext.length !== k || k < 2 * hLen + 2) {
throw decryptFailed();
}
const c = os2ip(ciphertext);
if (c >= key.n) {
throw decryptFailed();
}
const useBlinding = opts.blinding !== false;
if (!useBlinding) {
recordBlindingDisabled("decrypt");
}
const m = useBlinding
? privateOpBlinded(c, key, opts.rng ?? defaultRng())
: rsaPrivateOperationCrt(c, key);
const em = i2osp(m, k);
const out = unwrapOaep(em, hLen, hash, mgfHash, opts.label ?? EMPTY);
recordDecryptDuration("oaep", hash, nowMs() - start);
return out;
} catch (err) {
recordThrown(err);
throw err;
}
}
function privateOpBlinded(c: bigint, key: RsaPrivateKey, rng: Rng): bigint {
const { mPrime, rInverse } = blindMessage(c, key, rng);
const sBlinded = rsaPrivateOperationCrt(mPrime, key);
return unblindResult(sBlinded, rInverse, key.n);
}
/**
* Internal: OAEP unwrap step (RFC 8017 §7.1.2 step 3). Exported for
* unit-testable coverage of the `lHash` mismatch / separator-scan
* branches that real round-trip tests cannot exercise without
* synthesising a tampered `em` byte-by-byte.
*/
export function unwrapOaep(
em: Uint8Array,
hLen: number,
hash: RsaHashAlgorithm,
mgfHash: RsaHashAlgorithm,
label: Uint8Array
): Uint8Array {
if (em[0] !== 0x00) {
throw decryptFailed();
}
const maskedSeed = em.subarray(1, 1 + hLen);
const maskedDb = em.subarray(1 + hLen);
const seedMask = mgf1(maskedDb, hLen, mgfHash);
const seed = xor(maskedSeed, seedMask);
const dbMask = mgf1(seed, maskedDb.length, mgfHash);
const db = xor(maskedDb, dbMask);
const lHash = digestWith(hash, label);
if (!constantTimeEqual(db.subarray(0, hLen), lHash)) {
throw decryptFailed();
}
let separator = -1;
for (let i = hLen; i < db.length; i++) {
const v = db[i] as number;
if (v === 0x01 && separator === -1) {
separator = i;
} else if (v !== 0x00 && separator === -1) {
// Non-zero byte before the 0x01 separator.
throw decryptFailed();
}
}
if (separator === -1) {
throw decryptFailed();
}
return db.slice(separator + 1);
}
function decryptFailed(): CryptoRsaError {
return new CryptoRsaError(
CRYPTO_RSA_CODES.DECRYPT_FAILED,
"error.crypto-rsa.decrypt.failed"
);
}
function byteLength(key: RsaPrivateKey): number {
return Math.ceil(key.bitLength / 8);
}
function nowMs(): number {
return performance.now();
}

View File

@@ -0,0 +1,88 @@
# `decrypt/pkcs1v15`
## Purpose
RSAES-PKCS1-v1_5 decryption per RFC 8017 §7.2.2. Counterpart to
`encrypt/pkcs1v15`. Provides legacy interop for protocols that
predate OAEP (TLS 1.0–1.2 RSA key transport, JWE `RSA1_5` legacy
mode). Modern callers MUST prefer `decrypt/oaep`.
The defining concern of this module is the **Bleichenbacher
defence**: every padding-format failure surfaces the SAME
`CRYPTO_RSA.DECRYPT_FAILED` error so a remote attacker observing
the typed-error code (or the OTel error counter) cannot mount a
million-message chosen-ciphertext attack.
## Features
- Combined error path — wrong leading byte, wrong block-type
marker, missing `0x00` separator, `PS` shorter than 8 bytes,
ciphertext length ≠ `k`, post-decrypt integer ≥ `n`: every
failure throws the single `DECRYPT_FAILED` code.
- RSA blinding wired in by default — `{ blinding: false }` opt-out
emits the `crypto_rsa.blinding_disabled_total{operation="decrypt"}`
counter so production dashboards can flag any caller that
disables the timing-side-channel defence.
- CRT-accelerated private operation (`rsaPrivateOperationCrt`).
- Modulus-length policy gate runs first.
- `crypto_rsa.decrypt_duration_ms{scheme="pkcs1v15"}` histogram.
## Usage: Basic Example
```ts
import { rsaDecryptPkcs1v15 } from "@suaveplan/crypto-rsa/decrypt/pkcs1v15";
import type { RsaPrivateKey } from "@suaveplan/crypto-rsa";
declare const privateKey: RsaPrivateKey;
declare const ciphertext: Uint8Array;
const plaintext = rsaDecryptPkcs1v15(privateKey, ciphertext, {
allowLegacy: false,
});
```
## Usage: Advanced Example
Disable blinding (NOT recommended outside benchmarks) — the
counter increment surfaces the choice in production telemetry:
```ts
import { rsaDecryptPkcs1v15 } from "@suaveplan/crypto-rsa/decrypt/pkcs1v15";
const plaintext = rsaDecryptPkcs1v15(privateKey, ciphertext, {
blinding: false, // emits crypto_rsa.blinding_disabled_total{operation="decrypt"}
allowLegacy: true,
});
```
## API Reference
### `rsaDecryptPkcs1v15(key, ciphertext, options?): Uint8Array`
- `key: RsaPrivateKey` — modulus must satisfy the policy gate.
- `ciphertext: Uint8Array` — must be exactly `k` bytes; any
other length throws `DECRYPT_FAILED`.
- `options.rng?: Rng` — randomness source for blinding.
- `options.blinding?: boolean` — defaults to `true`. Set to
`false` only for benchmark comparisons.
- `options.allowLegacy?: boolean` — accept 1024-bit moduli.
- Returns the recovered plaintext (`em.length - separator - 1`
bytes).
- Throws `CRYPTO_RSA.DECRYPT_FAILED` on every padding failure.
## Implementation Notes
- The combined error path is enforced by routing every failure
through the private `decryptFailed()` helper — a single
callsite for `new CryptoRsaError(DECRYPT_FAILED, ...)` makes
it impossible for a future edit to accidentally introduce a
differentiated error.
- True constant-time decryption is not achievable in JavaScript
without WebCrypto (V8/JSC bigint ops are not constant-time).
We mitigate with blinding and with eager-throw bucketing all
padding failures under one error class. For FIPS-grade
side-channel resistance, route through `crypto-browser`
(WebCrypto) or `crypto-sodium`.
- The `c >= key.n` check is RFC 8017 §5.1.2's "ciphertext
representative out of range" condition — also bucketed under
`DECRYPT_FAILED` so callers cannot distinguish "padding bad"
from "ciphertext outside `Z*_n`".

View File

@@ -0,0 +1,149 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
LEGACY_1024_PRIVATE_KEY,
LEGACY_1024_PUBLIC_KEY,
} from "../__fixtures__/test-keys.js";
import { rsaEncryptPkcs1v15 } from "../encrypt/pkcs1v15.js";
import { CRYPTO_RSA_CODES, CryptoRsaError } from "../errors/errors.js";
import { rsaDecryptPkcs1v15, unwrapPadding } from "./pkcs1v15.js";
function flipBit(
bytes: Uint8Array,
byteIndex: number,
bitIndex = 0
): Uint8Array {
const out = new Uint8Array(bytes);
out[byteIndex] = (out[byteIndex] as number) ^ (1 << bitIndex);
return out;
}
describe("rsaDecryptPkcs1v15 — Bleichenbacher defence", () => {
it("round-trip of well-formed ciphertext succeeds", {
timeout: 10000,
}, () => {
const pt = new TextEncoder().encode("payload");
const ct = rsaEncryptPkcs1v15(LEGACY_1024_PUBLIC_KEY, pt, {
allowLegacy: true,
});
const recovered = rsaDecryptPkcs1v15(LEGACY_1024_PRIVATE_KEY, ct, {
allowLegacy: true,
});
expect(Array.from(recovered)).toEqual(Array.from(pt));
});
it("bit-flipped ciphertext throws DECRYPT_FAILED (combined error)", {
timeout: 10000,
}, () => {
const pt = new Uint8Array([42]);
const ct = rsaEncryptPkcs1v15(LEGACY_1024_PUBLIC_KEY, pt, {
allowLegacy: true,
});
const tampered = flipBit(ct, 5, 3);
try {
rsaDecryptPkcs1v15(LEGACY_1024_PRIVATE_KEY, tampered, {
allowLegacy: true,
});
throw new Error("should have thrown");
} catch (err) {
expect(err).toBeInstanceOf(CryptoRsaError);
expect((err as CryptoRsaError).code).toBe(
CRYPTO_RSA_CODES.DECRYPT_FAILED
);
}
});
it("ciphertext of wrong length throws DECRYPT_FAILED (not a length error)", {
timeout: 5000,
}, () => {
try {
rsaDecryptPkcs1v15(LEGACY_1024_PRIVATE_KEY, new Uint8Array(64), {
allowLegacy: true,
});
throw new Error("should have thrown");
} catch (err) {
expect((err as CryptoRsaError).code).toBe(
CRYPTO_RSA_CODES.DECRYPT_FAILED
);
}
});
it("ciphertext representative ≥ n throws DECRYPT_FAILED (combined)", {
timeout: 5000,
}, () => {
// All-0xFF 128-byte ciphertext exceeds the 1024-bit modulus.
const big = new Uint8Array(128).fill(0xff);
try {
rsaDecryptPkcs1v15(LEGACY_1024_PRIVATE_KEY, big, {
allowLegacy: true,
});
throw new Error("should have thrown");
} catch (err) {
expect((err as CryptoRsaError).code).toBe(
CRYPTO_RSA_CODES.DECRYPT_FAILED
);
}
});
it("blinding: false still round-trips correctly", {
timeout: 10000,
}, () => {
const pt = new Uint8Array([1, 2, 3]);
const ct = rsaEncryptPkcs1v15(LEGACY_1024_PUBLIC_KEY, pt, {
allowLegacy: true,
});
const recovered = rsaDecryptPkcs1v15(LEGACY_1024_PRIVATE_KEY, ct, {
allowLegacy: true,
blinding: false,
});
expect(Array.from(recovered)).toEqual(Array.from(pt));
});
it("policy gate rejects sub-1024 keys", { timeout: 5000 }, () => {
const tinyPriv = {
...LEGACY_1024_PRIVATE_KEY,
bitLength: 16,
};
expect(() => rsaDecryptPkcs1v15(tinyPriv, new Uint8Array(2))).toThrow(
CryptoRsaError
);
});
});
describe("unwrapPadding — branch coverage", () => {
it("rejects em shorter than 11 bytes", { timeout: 5000 }, () => {
expect(() => unwrapPadding(new Uint8Array(10))).toThrow(CryptoRsaError);
});
it("rejects em with separator at index < 10 (PS too short)", {
timeout: 5000,
}, () => {
// 0x00 0x02 then 0x00 separator at index 2 — PS is empty.
const em = new Uint8Array(20);
em[0] = 0x00;
em[1] = 0x02;
// em[2] is the 0x00 separator.
expect(() => unwrapPadding(em)).toThrow(CryptoRsaError);
});
it("rejects em with no separator at all", { timeout: 5000 }, () => {
const em = new Uint8Array(20);
em[0] = 0x00;
em[1] = 0x02;
for (let i = 2; i < 20; i++) em[i] = 0xff;
expect(() => unwrapPadding(em)).toThrow(CryptoRsaError);
});
it("accepts well-formed em with separator at ≥ 10", {
timeout: 5000,
}, () => {
const em = new Uint8Array(20);
em[0] = 0x00;
em[1] = 0x02;
for (let i = 2; i < 12; i++) em[i] = 0xff;
em[12] = 0x00;
em[13] = 0xab;
em[14] = 0xcd;
const out = unwrapPadding(em);
expect(out[0]).toBe(0xab);
});
});

View File

@@ -0,0 +1,124 @@
import type { Rng } from "@suaveplan/types";
import { blindMessage, unblindResult } from "../blinding/blinding.js";
import { defaultRng } from "../encrypt/default-rng.js";
import { CRYPTO_RSA_CODES, CryptoRsaError } from "../errors/errors.js";
import type { RsaPrivateKey } from "../keys/keys.types.js";
import { rsaPrivateOperationCrt } from "../math/crt.js";
import { i2osp } from "../math/i2osp.js";
import { os2ip } from "../math/os2ip.js";
import {
recordBlindingDisabled,
recordDecryptDuration,
recordThrown,
} from "../observability/metrics.js";
import { enforceMaxBitLength, enforceMinBitLength } from "../policy/modulus.js";
/**
* Optional input for `rsaDecryptPkcs1v15`. `blinding` defaults to `true`
* — disabling it emits the `crypto_rsa.blinding_disabled_total{operation="decrypt"}`
* counter so dashboards can surface any production caller that opts out.
*/
export interface RsaDecryptPkcs1v15Options {
readonly rng?: Rng;
readonly blinding?: boolean;
readonly allowLegacy?: boolean;
}
/**
* RSAES-PKCS1-v1_5 decryption per RFC 8017 §7.2.2.
*
* **Bleichenbacher defence — combined error path.** Every padding-format
* failure (wrong leading byte, wrong block-type marker, no `0x00`
* separator, `PS` shorter than 8 bytes, ciphertext length ≠ `k`) raises
* the same `CRYPTO_RSA.DECRYPT_FAILED` error. Callers cannot
* differentiate the failure mode through the typed error — and
* production telemetry buckets every padding failure under a single
* `errors_total{code="CRYPTO_RSA.DECRYPT_FAILED"}` counter so log
* scraping cannot leak the distinction either.
*/
export function rsaDecryptPkcs1v15(
key: RsaPrivateKey,
ciphertext: Uint8Array,
options: RsaDecryptPkcs1v15Options = {}
): Uint8Array {
const start = nowMs();
try {
enforceMinBitLength(
key,
options.allowLegacy === undefined
? {}
: { allowLegacy: options.allowLegacy }
);
enforceMaxBitLength(key);
const k = byteLength(key);
if (ciphertext.length !== k) {
throw decryptFailed();
}
const c = os2ip(ciphertext);
if (c >= key.n) {
throw decryptFailed();
}
const useBlinding = options.blinding !== false;
if (!useBlinding) {
recordBlindingDisabled("decrypt");
}
const m = useBlinding
? privateOpBlinded(c, key, options.rng ?? defaultRng())
: rsaPrivateOperationCrt(c, key);
const em = i2osp(m, k);
const out = unwrapPadding(em);
recordDecryptDuration("pkcs1v15", "n/a", nowMs() - start);
return out;
} catch (err) {
recordThrown(err);
throw err;
}
}
function privateOpBlinded(c: bigint, key: RsaPrivateKey, rng: Rng): bigint {
const { mPrime, rInverse } = blindMessage(c, key, rng);
const sBlinded = rsaPrivateOperationCrt(mPrime, key);
return unblindResult(sBlinded, rInverse, key.n);
}
/**
* Internal: PKCS1-v1_5 padding unwrap (RFC 8017 §7.2.2 step 3).
* Exported for unit-testable coverage of the separator-position
* checks that real round-trip tests cannot reach without
* synthesising a tampered `em` byte-by-byte.
*/
export function unwrapPadding(em: Uint8Array): Uint8Array {
if (em.length < 11) {
throw decryptFailed();
}
if (em[0] !== 0x00 || em[1] !== 0x02) {
throw decryptFailed();
}
let separator = -1;
for (let i = 2; i < em.length; i++) {
if (em[i] === 0x00) {
separator = i;
break;
}
}
if (separator === -1 || separator < 10) {
// PS minimum is 8 non-zero bytes so separator must be at index ≥ 10.
throw decryptFailed();
}
return em.slice(separator + 1);
}
function decryptFailed(): CryptoRsaError {
return new CryptoRsaError(
CRYPTO_RSA_CODES.DECRYPT_FAILED,
"error.crypto-rsa.decrypt.failed"
);
}
function byteLength(key: RsaPrivateKey): number {
return Math.ceil(key.bitLength / 8);
}
function nowMs(): number {
return performance.now();
}

View File

@@ -0,0 +1,84 @@
# emsa-pkcs1-v1\_5
## Purpose
Implements EMSA-PKCS1-v1\_5-ENCODE from RFC 8017 §9.2 — the encoding
method underlying RSASSA-PKCS1-v1\_5 signatures. This module also pins
the four DigestInfo prefix byte sequences from §9.2 note 1 as named
constants so consumers and tests can audit the bytes against the RFC.
## Features
- Hard-coded `DIGEST_INFO_PREFIX_*` constants for SHA-1, SHA-256, SHA-384, SHA-512
- `digestInfoPrefix(hash)` lookup — throws `CRYPTO_RSA.UNSUPPORTED_HASH` on unknown algorithm
- `emsaPkcs1v15Encode(message, emLen, hash)` — RFC 8017 §9.2 conformant encoder
- Detects modulus-too-short conditions (`emLen < tLen + 11`) and throws `CRYPTO_RSA.INVALID_KEY`
- Returns a fresh `Uint8Array` of length exactly `emLen`
## Usage: Basic Example
```ts
import { emsaPkcs1v15Encode } from "@suaveplan/crypto-rsa/encoding/emsa-pkcs1-v1_5";
const message = new TextEncoder().encode("hello");
const em = emsaPkcs1v15Encode(message, 256, "SHA-256");
console.log(em.length); // 256
console.log(em[0], em[1]); // 0x00, 0x01
```
## Usage: Advanced Example
The encoded message is the input to the RSA private-key operation in
`rsaSignPkcs1v15`. To verify a signature, the same encoder is run on the
candidate message and the result is constant-time compared against the
public-key recovery of the signature.
```ts
import { emsaPkcs1v15Encode, digestInfoPrefix } from "@suaveplan/crypto-rsa/encoding/emsa-pkcs1-v1_5";
import { toHex } from "@suaveplan/crypto-utils";
const prefix = digestInfoPrefix("SHA-256");
console.log(toHex(prefix as Uint8Array));
// 3031300d060960864801650304020105000420
const em1 = emsaPkcs1v15Encode(new Uint8Array([1, 2, 3]), 256, "SHA-256");
const em2 = emsaPkcs1v15Encode(new Uint8Array([1, 2, 3]), 256, "SHA-256");
// em1 and em2 are byte-identical (encoding is deterministic)
```
## API Reference
### `emsaPkcs1v15Encode(message, emLen, hash): Uint8Array`
- `message: Uint8Array` — the bytes that will be digested
- `emLen: number` — the target encoded-message length, equal to `byteLength(n)`
- `hash: RsaHashAlgorithm` — `"SHA-1" | "SHA-256" | "SHA-384" | "SHA-512"`
- **returns** `Uint8Array` of length exactly `emLen`
- **throws** `CryptoRsaError(CRYPTO_RSA.INVALID_KEY)` when `emLen < tLen + 11` (modulus too short for the chosen hash)
- **throws** `CryptoRsaError(CRYPTO_RSA.UNSUPPORTED_HASH)` on unknown algorithm
### `digestInfoPrefix(hash): Readonly<Uint8Array>`
Returns the immutable RFC 8017 §9.2 note 1 DigestInfo prefix for `hash`.
### `DIGEST_INFO_PREFIX_SHA1` / `_SHA256` / `_SHA384` / `_SHA512`
Read-only `Uint8Array` constants holding the byte-exact DigestInfo
prefix sequences from RFC 8017 §9.2 note 1.
## Implementation Notes
- The encoded message structure is `0x00 || 0x01 || PS || 0x00 || T`,
where `PS` is at least 8 bytes of `0xff` (per RFC 8017 §9.2 step 3).
The `tLen + 11` floor encodes the 2-byte header (`0x00 || 0x01`), the
1-byte separator (`0x00`), and the 8-byte minimum for `PS`.
- The DigestInfo prefix bytes are pinned as literal byte arrays — not
ASN.1 encoder calls — so a single misbyte in the RFC table would be
caught by the bytewise prefix tests against the RFC reference.
- The `emLen` parameter is supplied by the caller (= `byteLength(n)` for
RSASSA-PKCS1-v1\_5). This module does not compute `emLen` from a key;
that responsibility belongs to the higher-level signature scheme.
- Encoding is deterministic — the same `(message, emLen, hash)` triple
always produces the same `EM` bytes.
- Hash dispatch goes through `digestWith` from `mgf1.ts`, so SHA-2
flows through `@suaveplan/crypto-hash` and SHA-1 through Noble.

View File

@@ -0,0 +1,147 @@
import { toHex } from "@suaveplan/crypto-utils";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { CRYPTO_RSA_CODES, CryptoRsaError } from "../errors/errors.js";
import {
DIGEST_INFO_PREFIX_SHA1,
DIGEST_INFO_PREFIX_SHA256,
DIGEST_INFO_PREFIX_SHA384,
DIGEST_INFO_PREFIX_SHA512,
digestInfoPrefix,
emsaPkcs1v15Encode,
} from "./emsa-pkcs1-v1_5.js";
describe("DigestInfo prefix constants", () => {
it("SHA-1 prefix matches RFC 8017 §9.2 note 1 byte sequence", {
timeout: 5000,
}, () => {
expect(toHex(DIGEST_INFO_PREFIX_SHA1 as Uint8Array)).toBe(
"3021300906052b0e03021a05000414"
);
});
it("SHA-256 DigestInfo prefix matches RFC 8017 §9.2 note 1 byte sequence", {
timeout: 5000,
}, () => {
expect(toHex(DIGEST_INFO_PREFIX_SHA256 as Uint8Array)).toBe(
"3031300d060960864801650304020105000420"
);
});
it("SHA-384 prefix matches RFC 8017 §9.2 note 1 byte sequence", {
timeout: 5000,
}, () => {
expect(toHex(DIGEST_INFO_PREFIX_SHA384 as Uint8Array)).toBe(
"3041300d060960864801650304020205000430"
);
});
it("SHA-512 prefix matches RFC 8017 §9.2 note 1 byte sequence", {
timeout: 5000,
}, () => {
expect(toHex(DIGEST_INFO_PREFIX_SHA512 as Uint8Array)).toBe(
"3051300d060960864801650304020305000440"
);
});
it("every DigestInfo prefix has the documented length", {
timeout: 5000,
}, () => {
expect(DIGEST_INFO_PREFIX_SHA1.length).toBe(15);
expect(DIGEST_INFO_PREFIX_SHA256.length).toBe(19);
expect(DIGEST_INFO_PREFIX_SHA384.length).toBe(19);
expect(DIGEST_INFO_PREFIX_SHA512.length).toBe(19);
});
});
describe("digestInfoPrefix", () => {
it("returns the SHA-256 prefix for SHA-256", { timeout: 5000 }, () => {
expect(digestInfoPrefix("SHA-256")).toBe(DIGEST_INFO_PREFIX_SHA256);
});
it("throws UNSUPPORTED_HASH on unknown algorithm", {
timeout: 5000,
}, () => {
let caught: unknown;
try {
digestInfoPrefix("MD5" as never);
} catch (err) {
caught = err;
}
expect(caught).toBeInstanceOf(CryptoRsaError);
expect((caught as CryptoRsaError).code).toBe(
CRYPTO_RSA_CODES.UNSUPPORTED_HASH
);
});
});
describe("emsaPkcs1v15Encode", () => {
const MESSAGE = new TextEncoder().encode("hello, world");
it("produces output of length emLen", { timeout: 5000 }, () => {
expect(emsaPkcs1v15Encode(MESSAGE, 256, "SHA-256").length).toBe(256);
expect(emsaPkcs1v15Encode(MESSAGE, 384, "SHA-384").length).toBe(384);
expect(emsaPkcs1v15Encode(MESSAGE, 512, "SHA-512").length).toBe(512);
});
it("starts with 0x00 || 0x01 and ends with the digest", {
timeout: 5000,
}, () => {
const em = emsaPkcs1v15Encode(MESSAGE, 256, "SHA-256");
expect(em[0]).toBe(0x00);
expect(em[1]).toBe(0x01);
// tail = prefix||digest = 19 + 32 = 51 bytes; preceded by 0x00.
expect(em[256 - 51 - 1]).toBe(0x00);
});
it("PS region is all 0xff", { timeout: 5000 }, () => {
const em = emsaPkcs1v15Encode(MESSAGE, 256, "SHA-256");
const tLen = DIGEST_INFO_PREFIX_SHA256.length + 32;
const psLen = 256 - tLen - 3;
for (let i = 0; i < psLen; i++) {
expect(em[2 + i]).toBe(0xff);
}
});
it("embeds the DigestInfo prefix at the right offset", {
timeout: 5000,
}, () => {
const em = emsaPkcs1v15Encode(MESSAGE, 256, "SHA-256");
const tLen = DIGEST_INFO_PREFIX_SHA256.length + 32;
const offset = 256 - tLen;
const slice = em.subarray(
offset,
offset + DIGEST_INFO_PREFIX_SHA256.length
);
expect(toHex(slice)).toBe(
toHex(DIGEST_INFO_PREFIX_SHA256 as Uint8Array)
);
});
it("is deterministic for the same inputs", { timeout: 5000 }, () => {
const a = emsaPkcs1v15Encode(MESSAGE, 256, "SHA-256");
const b = emsaPkcs1v15Encode(MESSAGE, 256, "SHA-256");
expect(toHex(a)).toBe(toHex(b));
});
it("throws INVALID_KEY when emLen < tLen + 11", { timeout: 5000 }, () => {
// SHA-256: tLen = 19 + 32 = 51, floor = 62.
let caught: unknown;
try {
emsaPkcs1v15Encode(MESSAGE, 50, "SHA-256");
} catch (err) {
caught = err;
}
expect(caught).toBeInstanceOf(CryptoRsaError);
expect((caught as CryptoRsaError).code).toBe(
CRYPTO_RSA_CODES.INVALID_KEY
);
});
it("throws UNSUPPORTED_HASH on unknown algorithm", {
timeout: 5000,
}, () => {
expect(() => emsaPkcs1v15Encode(MESSAGE, 256, "MD5" as never)).toThrow(
CryptoRsaError
);
});
});

View File

@@ -0,0 +1,118 @@
import { CRYPTO_RSA_CODES, CryptoRsaError } from "../errors/errors.js";
import type { RsaHashAlgorithm } from "../keys/keys.types.js";
import { digestWith, hashOutputLength } from "./mgf1.js";
/**
* DigestInfo prefix for SHA-1 (RFC 8017 §9.2 note 1) — 15 bytes.
*
* Encodes `DigestInfo ::= SEQUENCE { digestAlgorithm AlgorithmIdentifier, digest OCTET STRING }`
* where `digestAlgorithm.algorithm = id-sha1 = 1.3.14.3.2.26` and the
* trailing 4-byte tag `04 14` introduces the 20-byte SHA-1 digest.
*/
export const DIGEST_INFO_PREFIX_SHA1: Readonly<Uint8Array> = new Uint8Array([
0x30, 0x21, 0x30, 0x09, 0x06, 0x05, 0x2b, 0x0e, 0x03, 0x02, 0x1a, 0x05,
0x00, 0x04, 0x14,
]);
/**
* DigestInfo prefix for SHA-256 (RFC 8017 §9.2 note 1) — 19 bytes.
*
* `digestAlgorithm.algorithm = id-sha256 = 2.16.840.1.101.3.4.2.1`.
*/
export const DIGEST_INFO_PREFIX_SHA256: Readonly<Uint8Array> = new Uint8Array([
0x30, 0x31, 0x30, 0x0d, 0x06, 0x09, 0x60, 0x86, 0x48, 0x01, 0x65, 0x03,
0x04, 0x02, 0x01, 0x05, 0x00, 0x04, 0x20,
]);
/**
* DigestInfo prefix for SHA-384 (RFC 8017 §9.2 note 1) — 19 bytes.
*
* `digestAlgorithm.algorithm = id-sha384 = 2.16.840.1.101.3.4.2.2`.
*/
export const DIGEST_INFO_PREFIX_SHA384: Readonly<Uint8Array> = new Uint8Array([
0x30, 0x41, 0x30, 0x0d, 0x06, 0x09, 0x60, 0x86, 0x48, 0x01, 0x65, 0x03,
0x04, 0x02, 0x02, 0x05, 0x00, 0x04, 0x30,
]);
/**
* DigestInfo prefix for SHA-512 (RFC 8017 §9.2 note 1) — 19 bytes.
*
* `digestAlgorithm.algorithm = id-sha512 = 2.16.840.1.101.3.4.2.3`.
*/
export const DIGEST_INFO_PREFIX_SHA512: Readonly<Uint8Array> = new Uint8Array([
0x30, 0x51, 0x30, 0x0d, 0x06, 0x09, 0x60, 0x86, 0x48, 0x01, 0x65, 0x03,
0x04, 0x02, 0x03, 0x05, 0x00, 0x04, 0x40,
]);
const PREFIX_BY_HASH: Readonly<Record<RsaHashAlgorithm, Readonly<Uint8Array>>> =
{
"SHA-1": DIGEST_INFO_PREFIX_SHA1,
"SHA-256": DIGEST_INFO_PREFIX_SHA256,
"SHA-384": DIGEST_INFO_PREFIX_SHA384,
"SHA-512": DIGEST_INFO_PREFIX_SHA512,
};
/**
* Look up the DigestInfo prefix for `hash`.
*
* @throws CryptoRsaError(CRYPTO_RSA.UNSUPPORTED_HASH) on unknown algorithm
*/
export function digestInfoPrefix(hash: RsaHashAlgorithm): Readonly<Uint8Array> {
const prefix = PREFIX_BY_HASH[hash];
if (prefix === undefined) {
throw new CryptoRsaError(
CRYPTO_RSA_CODES.UNSUPPORTED_HASH,
"error.crypto-rsa.unsupported-hash",
{ context: { hash } }
);
}
return prefix;
}
/**
* EMSA-PKCS1-v1\_5-ENCODE (RFC 8017 §9.2).
*
* Builds the encoded message `EM = 0x00 || 0x01 || PS || 0x00 || T`
* where `T = DigestInfo || H(message)` and `PS` is `emLen - tLen - 3`
* bytes of `0xff`. `emLen` MUST be at least `tLen + 11`; otherwise this
* function throws `CRYPTO_RSA.INVALID_KEY` because the modulus is too
* short to host the encoding for the chosen hash.
*
* @param message - the message to be signed (will be hashed inside)
* @param emLen - target encoded-message length in bytes (= byteLength(n))
* @param hash - hash algorithm
*/
export function emsaPkcs1v15Encode(
message: Uint8Array,
emLen: number,
hash: RsaHashAlgorithm
): Uint8Array {
const prefix = digestInfoPrefix(hash);
const hLen = hashOutputLength(hash);
const tLen = prefix.length + hLen;
if (emLen < tLen + 11) {
throw new CryptoRsaError(
CRYPTO_RSA_CODES.INVALID_KEY,
"error.crypto-rsa.invalid-key",
{
context: {
reason: "modulus-too-short-for-emsa-pkcs1-v15",
emLen,
tLen,
},
}
);
}
const digest = digestWith(hash, message);
const em = new Uint8Array(emLen);
em[0] = 0x00;
em[1] = 0x01;
const psLen = emLen - tLen - 3;
for (let i = 0; i < psLen; i++) {
em[2 + i] = 0xff;
}
em[2 + psLen] = 0x00;
em.set(prefix, 3 + psLen);
em.set(digest, 3 + psLen + prefix.length);
return em;
}

View File

@@ -0,0 +1,78 @@
# emsa-pss-encode
## Purpose
Implements the EMSA-PSS encoding operation defined in RFC 8017 §9.1.1.
Given a message digest, an encoded-message bit length, a salt, and the
two hash choices (message hash and MGF1 hash), this function produces
the byte-string `EM` that is then converted to an integer and signed
with the RSA private-key operation in `rsaSignPss`.
## Features
- RFC 8017 §9.1.1 conformant — produces `EM = maskedDB || H || 0xbc`
- Caller-supplied salt — randomness is the responsibility of the signature scheme
- Configurable MGF1 hash distinct from the message hash
- Detects modulus-too-short conditions (`emLen < hLen + sLen + 2`) and throws `CRYPTO_RSA.INVALID_KEY`
- Validates the input digest length against the hash's documented `hLen`
## Usage: Basic Example
```ts
import { emsaPssEncode } from "@suaveplan/crypto-rsa/encoding/emsa-pss-encode";
import { sha256 } from "@suaveplan/crypto-hash";
const message = new TextEncoder().encode("hello");
const mHash = sha256(message);
const salt = new Uint8Array(32); // would be random in production
const em = emsaPssEncode(mHash, 2047, salt, "SHA-256", "SHA-256");
console.log(em.length); // 256
console.log(em[em.length - 1]); // 0xbc
```
## Usage: Advanced Example
PSS allows the message hash and the MGF1 hash to differ. This is rare
but legal per RFC 8017 §9.1.1, and `rsaSignPss` exposes the choice
through its `mgfHash` option.
```ts
import { emsaPssEncode } from "@suaveplan/crypto-rsa/encoding/emsa-pss-encode";
import { sha256 } from "@suaveplan/crypto-hash";
const mHash = sha256(new TextEncoder().encode("audit-log-entry"));
const salt = crypto.getRandomValues(new Uint8Array(32));
// Mismatched hashes: SHA-256 for the message, SHA-1 for MGF1.
const em = emsaPssEncode(mHash, 2047, salt, "SHA-256", "SHA-1");
```
## API Reference
### `emsaPssEncode(mHash, emBits, salt, hash, mgfHash): Uint8Array`
- `mHash: Uint8Array` — pre-computed `Hash(message)`; length must equal `hashOutputLength(hash)`
- `emBits: number` — encoded-message bit length, typically `modulusBits - 1`
- `salt: Uint8Array` — caller-supplied salt, any non-negative length up to `emLen - hLen - 2`
- `hash: RsaHashAlgorithm` — message hash; also used to digest `M'`
- `mgfHash: RsaHashAlgorithm` — MGF1 hash for `dbMask`
- **returns** `Uint8Array` of length exactly `ceil(emBits / 8)`
- **throws** `CryptoRsaError(CRYPTO_RSA.INVALID_SIGNATURE)` when `mHash.length !== hashOutputLength(hash)`
- **throws** `CryptoRsaError(CRYPTO_RSA.INVALID_KEY)` when `emLen < hLen + sLen + 2` (modulus too short)
- **throws** `CryptoRsaError(CRYPTO_RSA.UNSUPPORTED_HASH)` on unknown hash via the `mgf1` dispatcher
## Implementation Notes
- The `M'` construction prefixes 8 zero bytes before `mHash` and the
salt — this is the RFC-mandated domain separator that prevents
cross-protocol collisions with constructions that hash the message
alone.
- The most-significant `8 * emLen - emBits` bits of `maskedDB`'s
leftmost byte are cleared per RFC 8017 §9.1.1 step 11. For a 2048-bit
modulus, `emBits = 2047` and `8 * 256 - 2047 = 1`, so the top bit of
byte 0 is cleared. The verifier MUST accept only encodings where this
bit is zero, otherwise the encoding is rejected.
- The trailing byte is always `0xbc` — the PSS trailer field. The
verifier rejects any other value.
- This function does NOT generate randomness — the salt is supplied by
the caller. `rsaSignPss` is responsible for drawing the salt from a
CSPRNG (or using a caller-provided `Rng` from `@suaveplan/types`).

View File

@@ -0,0 +1,162 @@
import { sha256, sha512 } from "@suaveplan/crypto-hash";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { CRYPTO_RSA_CODES, CryptoRsaError } from "../errors/errors.js";
import { emsaPssEncode } from "./emsa-pss-encode.js";
import { emsaPssVerify } from "./emsa-pss-verify.js";
describe("emsaPssEncode", () => {
it("produces an EM of length ceil(emBits / 8) ending in 0xbc", {
timeout: 5000,
}, () => {
const mHash = sha256(new TextEncoder().encode("hello"));
const salt = new Uint8Array(32);
const em = emsaPssEncode(mHash, 2047, salt, "SHA-256", "SHA-256");
expect(em.length).toBe(256);
expect(em[em.length - 1]).toBe(0xbc);
});
it("encode → verify round-trip on Appendix C input", {
timeout: 5000,
}, () => {
const mHash = sha256(
new TextEncoder().encode("RFC 8017 Appendix C synthetic input")
);
const salt = new Uint8Array(32);
for (let i = 0; i < salt.length; i++) {
salt[i] = i;
}
const em = emsaPssEncode(mHash, 2047, salt, "SHA-256", "SHA-256");
expect(emsaPssVerify(mHash, em, 2047, 32, "SHA-256", "SHA-256")).toBe(
true
);
});
it("round-trip with empty salt (deterministic PSS)", {
timeout: 5000,
}, () => {
const mHash = sha256(new TextEncoder().encode("deterministic"));
const salt = new Uint8Array(0);
const em = emsaPssEncode(mHash, 2047, salt, "SHA-256", "SHA-256");
expect(emsaPssVerify(mHash, em, 2047, 0, "SHA-256", "SHA-256")).toBe(
true
);
});
it("round-trip with mgfHash distinct from hash", { timeout: 5000 }, () => {
const mHash = sha256(new TextEncoder().encode("cross-hash"));
const salt = new Uint8Array(32);
const em = emsaPssEncode(mHash, 2047, salt, "SHA-256", "SHA-1");
expect(emsaPssVerify(mHash, em, 2047, 32, "SHA-256", "SHA-1")).toBe(
true
);
});
it("round-trip with SHA-512", { timeout: 5000 }, () => {
const mHash = sha512(new TextEncoder().encode("hello"));
const salt = new Uint8Array(64);
const em = emsaPssEncode(mHash, 4095, salt, "SHA-512", "SHA-512");
expect(em.length).toBe(512);
expect(emsaPssVerify(mHash, em, 4095, 64, "SHA-512", "SHA-512")).toBe(
true
);
});
it("is deterministic given the same salt", { timeout: 5000 }, () => {
const mHash = sha256(new TextEncoder().encode("hello"));
const salt = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8]);
const a = emsaPssEncode(mHash, 2047, salt, "SHA-256", "SHA-256");
const b = emsaPssEncode(mHash, 2047, salt, "SHA-256", "SHA-256");
expect(Array.from(a)).toEqual(Array.from(b));
});
it("different salts produce different EMs", { timeout: 5000 }, () => {
const mHash = sha256(new TextEncoder().encode("hello"));
const a = emsaPssEncode(
mHash,
2047,
new Uint8Array([1, 2, 3]),
"SHA-256",
"SHA-256"
);
const b = emsaPssEncode(
mHash,
2047,
new Uint8Array([4, 5, 6]),
"SHA-256",
"SHA-256"
);
expect(Array.from(a)).not.toEqual(Array.from(b));
});
it("throws INVALID_KEY when emLen too small for hLen + sLen + 2", {
timeout: 5000,
}, () => {
const mHash = sha256(new TextEncoder().encode("x"));
// emBits = 8 → emLen = 1; far too small for SHA-256.
let caught: unknown;
try {
emsaPssEncode(mHash, 8, new Uint8Array(32), "SHA-256", "SHA-256");
} catch (err) {
caught = err;
}
expect(caught).toBeInstanceOf(CryptoRsaError);
expect((caught as CryptoRsaError).code).toBe(
CRYPTO_RSA_CODES.INVALID_KEY
);
});
it("throws INVALID_SIGNATURE on mHash length mismatch", {
timeout: 5000,
}, () => {
// Pass a 16-byte digest but request SHA-256 (32-byte digest).
let caught: unknown;
try {
emsaPssEncode(
new Uint8Array(16),
2047,
new Uint8Array(32),
"SHA-256",
"SHA-256"
);
} catch (err) {
caught = err;
}
expect(caught).toBeInstanceOf(CryptoRsaError);
expect((caught as CryptoRsaError).code).toBe(
CRYPTO_RSA_CODES.INVALID_SIGNATURE
);
});
it("clears the top bits of the leftmost maskedDB byte", {
timeout: 5000,
}, () => {
// emBits = 2047, emLen = 256 → 1 leading bit must be 0.
const mHash = sha256(new TextEncoder().encode("x"));
const em = emsaPssEncode(
mHash,
2047,
new Uint8Array(32),
"SHA-256",
"SHA-256"
);
expect((em[0]! & 0x80) === 0).toBe(true);
});
it("skips leftmost-bit clearance when emBits is a multiple of 8", {
timeout: 5000,
}, () => {
// emBits = 2048 → 8*emLen - emBits = 0; clearance branch skipped.
const mHash = sha256(new TextEncoder().encode("aligned"));
const em = emsaPssEncode(
mHash,
2048,
new Uint8Array(32),
"SHA-256",
"SHA-256"
);
expect(em.length).toBe(256);
expect(emsaPssVerify(mHash, em, 2048, 32, "SHA-256", "SHA-256")).toBe(
true
);
});
});

View File

@@ -0,0 +1,83 @@
import { CRYPTO_RSA_CODES, CryptoRsaError } from "../errors/errors.js";
import type { RsaHashAlgorithm } from "../keys/keys.types.js";
import { digestWith, hashOutputLength, mgf1 } from "./mgf1.js";
/**
* EMSA-PSS-ENCODE (RFC 8017 §9.1.1).
*
* Inputs are pre-digested in the higher-level signature scheme; this
* function takes `mHash = Hash(message)` and produces the encoded
* message `EM` of byte length `ceil(emBits / 8)`.
*
* - `mHash`: the message digest (length must equal `hashOutputLength(hash)`)
* - `emBits`: the encoded-message bit length (= `modulusBits - 1` per RFC §8.1.1)
* - `salt`: the random salt (length must equal `saltLength` chosen by caller)
* - `hash`: hash function applied to the message (and to the encoded `M'`)
* - `mgfHash`: hash function used by MGF1 (often the same as `hash`)
*
* @throws CryptoRsaError(CRYPTO_RSA.INVALID_KEY) when `emLen < hLen + sLen + 2`
* (RFC 8017 §9.1.1 step 3 — modulus too small for the chosen hash + salt)
* @throws CryptoRsaError(CRYPTO_RSA.UNSUPPORTED_HASH) on unknown hash
*/
export function emsaPssEncode(
mHash: Uint8Array,
emBits: number,
salt: Uint8Array,
hash: RsaHashAlgorithm,
mgfHash: RsaHashAlgorithm
): Uint8Array {
const hLen = hashOutputLength(hash);
if (mHash.length !== hLen) {
throw new CryptoRsaError(
CRYPTO_RSA_CODES.INVALID_SIGNATURE,
"error.crypto-rsa.invalid-signature",
{
context: {
reason: "mHash-length-mismatch",
got: mHash.length,
expected: hLen,
},
}
);
}
const sLen = salt.length;
const emLen = Math.ceil(emBits / 8);
if (emLen < hLen + sLen + 2) {
throw new CryptoRsaError(
CRYPTO_RSA_CODES.INVALID_KEY,
"error.crypto-rsa.invalid-key",
{
context: {
reason: "modulus-too-short-for-emsa-pss",
emLen,
hLen,
sLen,
},
}
);
}
// M' = (0x00 * 8) || mHash || salt
const mPrime = new Uint8Array(8 + hLen + sLen);
mPrime.set(mHash, 8);
mPrime.set(salt, 8 + hLen);
const h = digestWith(hash, mPrime);
// DB = PS (emLen - sLen - hLen - 2 zero bytes) || 0x01 || salt
const db = new Uint8Array(emLen - hLen - 1);
db[emLen - sLen - hLen - 2] = 0x01;
db.set(salt, emLen - sLen - hLen - 1);
const dbMask = mgf1(h, db.length, mgfHash);
const maskedDb = new Uint8Array(db.length);
for (let i = 0; i < db.length; i++) {
maskedDb[i] = db[i]! ^ dbMask[i]!;
}
// Clear the top `8 * emLen - emBits` bits of the leftmost byte.
const leftmostBitsToClear = 8 * emLen - emBits;
if (leftmostBitsToClear > 0) {
maskedDb[0] = maskedDb[0]! & (0xff >>> leftmostBitsToClear);
}
const em = new Uint8Array(emLen);
em.set(maskedDb, 0);
em.set(h, maskedDb.length);
em[emLen - 1] = 0xbc;
return em;
}

View File

@@ -0,0 +1,89 @@
# emsa-pss-verify
## Purpose
Implements the EMSA-PSS verification operation defined in RFC 8017
§9.1.2. Given the message digest, the candidate encoded message `EM`,
the encoded-message bit length, the expected salt length, and the
hash + MGF1 hash pair, this function returns whether `EM` is a valid
PSS encoding of the digest.
## Features
- RFC 8017 §9.1.2 conformant — full structure, padding, and trailer checks
- Returns `false` on every malformed input rather than throwing — the
signature scheme decides how to escalate
- Constant-time final compare via `constantTimeEqual` from `@suaveplan/crypto-utils`
- Validates leftmost-bit clearance (RFC 8017 §9.1.2 step 6)
- Validates the `0xbc` trailer
- Validates the all-zero PS region and the `0x01` separator
## Usage: Basic Example
```ts
import { emsaPssVerify } from "@suaveplan/crypto-rsa/encoding/emsa-pss-verify";
import { emsaPssEncode } from "@suaveplan/crypto-rsa/encoding/emsa-pss-encode";
import { sha256 } from "@suaveplan/crypto-hash";
const mHash = sha256(new TextEncoder().encode("hello"));
const salt = new Uint8Array(32);
const em = emsaPssEncode(mHash, 2047, salt, "SHA-256", "SHA-256");
console.log(emsaPssVerify(mHash, em, 2047, 32, "SHA-256", "SHA-256")); // true
```
## Usage: Advanced Example
A bit-flipped EM is rejected at one of several gates: the trailer check,
the leftmost-bit check, the PS region check, the separator check, or
the final salt+hash compare. All of these gates fail closed (return
`false`) without revealing which gate caught the divergence.
```ts
import { emsaPssVerify } from "@suaveplan/crypto-rsa/encoding/emsa-pss-verify";
import { emsaPssEncode } from "@suaveplan/crypto-rsa/encoding/emsa-pss-encode";
import { sha256 } from "@suaveplan/crypto-hash";
const mHash = sha256(new TextEncoder().encode("hello"));
const em = emsaPssEncode(
mHash,
2047,
new Uint8Array(32),
"SHA-256",
"SHA-256"
);
const tampered = new Uint8Array(em);
tampered[0] = tampered[0] ^ 0x01;
console.log(emsaPssVerify(mHash, tampered, 2047, 32, "SHA-256", "SHA-256"));
// false
```
## API Reference
### `emsaPssVerify(mHash, em, emBits, saltLength, hash, mgfHash): boolean`
- `mHash: Uint8Array` — `Hash(message)`; length must equal `hashOutputLength(hash)` (otherwise returns `false`)
- `em: Uint8Array` — candidate encoded message; length must equal `ceil(emBits / 8)` (otherwise returns `false`)
- `emBits: number` — encoded-message bit length, typically `modulusBits - 1`
- `saltLength: number` — expected salt length, must match the value used by `emsaPssEncode`
- `hash: RsaHashAlgorithm` — message hash; also used to digest `M'`
- `mgfHash: RsaHashAlgorithm` — MGF1 hash for `dbMask`
- **returns** `true` when `EM` is a valid PSS encoding of `mHash` under the triple, `false` otherwise
## Implementation Notes
- The function never throws on encoding-level failures — every
malformed input returns `false`. This matches the RFC 8017 §9.1.2
contract: the caller is responsible for distinguishing between
expected verify failures and protocol errors.
- The final `H == H'` comparison is constant-time. The earlier
structural checks (trailer byte, PS region, separator byte) short-
circuit on first failure — that is acceptable because the public
inputs determining those bytes are known to the verifier; only the
hash output carries the secret-derived signal that timing attacks
would target.
- The `leftmostBitsToClear` check (RFC 8017 §9.1.2 step 6) prevents a
trivial signature-malleability attack where an attacker flips bits
in the cleared region without being detected.
- Returning `false` on a length mismatch (`em.length !== emLen` or
`mHash.length !== hLen`) preserves the same constant-effort failure
shape as a structurally-valid mismatch.

View File

@@ -0,0 +1,175 @@
import { sha256 } from "@suaveplan/crypto-hash";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { emsaPssEncode } from "./emsa-pss-encode.js";
import { emsaPssVerify } from "./emsa-pss-verify.js";
const MESSAGE = new TextEncoder().encode("hello, world");
function makeEncoded(salt: Uint8Array): Uint8Array {
const mHash = sha256(MESSAGE);
return emsaPssEncode(mHash, 2047, salt, "SHA-256", "SHA-256");
}
describe("emsaPssVerify", () => {
it("accepts a freshly-encoded EM", { timeout: 5000 }, () => {
const salt = new Uint8Array(32);
for (let i = 0; i < 32; i++) salt[i] = i + 1;
const em = makeEncoded(salt);
expect(
emsaPssVerify(sha256(MESSAGE), em, 2047, 32, "SHA-256", "SHA-256")
).toBe(true);
});
it("rejects bit-flipped encoded message", { timeout: 5000 }, () => {
const em = makeEncoded(new Uint8Array(32));
const tampered = new Uint8Array(em);
tampered[0] = tampered[0]! ^ 0x01;
expect(
emsaPssVerify(
sha256(MESSAGE),
tampered,
2047,
32,
"SHA-256",
"SHA-256"
)
).toBe(false);
});
it("rejects when trailer byte is not 0xbc", { timeout: 5000 }, () => {
const em = makeEncoded(new Uint8Array(32));
const tampered = new Uint8Array(em);
tampered[tampered.length - 1] = 0x00;
expect(
emsaPssVerify(
sha256(MESSAGE),
tampered,
2047,
32,
"SHA-256",
"SHA-256"
)
).toBe(false);
});
it("rejects when leading-bit clearance violated", { timeout: 5000 }, () => {
const em = makeEncoded(new Uint8Array(32));
const tampered = new Uint8Array(em);
tampered[0] = tampered[0]! | 0x80;
expect(
emsaPssVerify(
sha256(MESSAGE),
tampered,
2047,
32,
"SHA-256",
"SHA-256"
)
).toBe(false);
});
it("rejects when caller-supplied saltLength disagrees", {
timeout: 5000,
}, () => {
const em = makeEncoded(new Uint8Array(32));
expect(
emsaPssVerify(sha256(MESSAGE), em, 2047, 16, "SHA-256", "SHA-256")
).toBe(false);
});
it("rejects when EM length disagrees with emBits", {
timeout: 5000,
}, () => {
const em = makeEncoded(new Uint8Array(32));
const truncated = em.subarray(0, em.length - 1);
expect(
emsaPssVerify(
sha256(MESSAGE),
truncated,
2047,
32,
"SHA-256",
"SHA-256"
)
).toBe(false);
});
it("rejects mHash length mismatch", { timeout: 5000 }, () => {
const em = makeEncoded(new Uint8Array(32));
// Wrong-sized digest (16 bytes for SHA-256 expected length 32).
expect(
emsaPssVerify(
new Uint8Array(16),
em,
2047,
32,
"SHA-256",
"SHA-256"
)
).toBe(false);
});
it("rejects when emLen < hLen + saltLength + 2", { timeout: 5000 }, () => {
// emBits = 8 → emLen = 1. SHA-256 hLen = 32, saltLength = 0 → 34 > 1.
const mHash = sha256(MESSAGE);
const fakeEm = new Uint8Array(1);
expect(emsaPssVerify(mHash, fakeEm, 8, 0, "SHA-256", "SHA-256")).toBe(
false
);
});
it("uses constantTimeEqual on salt + hash compare", {
timeout: 5000,
}, async () => {
// Source-level audit: this file imports from `emsa-pss-verify.ts`
// which imports `constantTimeEqual` from `@suaveplan/crypto-utils`.
// Read the source and confirm the symbol is present.
const fs = await import("node:fs/promises");
const src = await fs.readFile(
new URL("./emsa-pss-verify.ts", import.meta.url),
"utf8"
);
expect(src.includes("constantTimeEqual")).toBe(true);
expect(src.includes('from "@suaveplan/crypto-utils"')).toBe(true);
});
it("rejects when separator byte (db[psLen]) is not 0x01", {
timeout: 5000,
}, () => {
const em = makeEncoded(new Uint8Array(32));
const tampered = new Uint8Array(em);
const emLen = 256;
const hLen = 32;
const saltLength = 32;
const psLen = emLen - saltLength - hLen - 2;
tampered[psLen] = tampered[psLen]! ^ 0x01;
expect(
emsaPssVerify(
sha256(MESSAGE),
tampered,
2047,
saltLength,
"SHA-256",
"SHA-256"
)
).toBe(false);
});
it("round-trips with mgfHash distinct from hash", { timeout: 5000 }, () => {
const mHash = sha256(MESSAGE);
const em = emsaPssEncode(
mHash,
2047,
new Uint8Array(32),
"SHA-256",
"SHA-1"
);
expect(emsaPssVerify(mHash, em, 2047, 32, "SHA-256", "SHA-1")).toBe(
true
);
// Wrong MGF hash → fails.
expect(emsaPssVerify(mHash, em, 2047, 32, "SHA-256", "SHA-256")).toBe(
false
);
});
});

View File

@@ -0,0 +1,77 @@
import { constantTimeEqual } from "@suaveplan/crypto-utils";
import type { RsaHashAlgorithm } from "../keys/keys.types.js";
import { digestWith, hashOutputLength, mgf1 } from "./mgf1.js";
/**
* EMSA-PSS-VERIFY (RFC 8017 §9.1.2).
*
* Returns `true` when `EM` is a valid PSS encoding of `mHash` under the
* triple `(hash, mgfHash, saltLength)`, and `false` otherwise. Never
* throws on a bad encoding — the verify path returns `false` so callers
* can decide whether to treat the failure as an attack or a routine
* mismatch.
*
* The final salt + hash compare uses `constantTimeEqual` from
* `@suaveplan/crypto-utils` so the timing of a verify call does not
* reveal where the encoding diverged from the expected shape.
*/
export function emsaPssVerify(
mHash: Uint8Array,
em: Uint8Array,
emBits: number,
saltLength: number,
hash: RsaHashAlgorithm,
mgfHash: RsaHashAlgorithm
): boolean {
const hLen = hashOutputLength(hash);
if (mHash.length !== hLen) {
return false;
}
const emLen = Math.ceil(emBits / 8);
if (em.length !== emLen) {
return false;
}
if (emLen < hLen + saltLength + 2) {
return false;
}
if (em[emLen - 1] !== 0xbc) {
return false;
}
const maskedDb = em.subarray(0, emLen - hLen - 1);
const h = em.subarray(emLen - hLen - 1, emLen - 1);
// Top `8 * emLen - emBits` bits of maskedDb's leftmost byte must be zero.
const leftmostBitsToClear = 8 * emLen - emBits;
if (leftmostBitsToClear > 0) {
const mask = 0xff << (8 - leftmostBitsToClear);
if ((maskedDb[0]! & (mask & 0xff)) !== 0) {
return false;
}
}
const dbMask = mgf1(h, maskedDb.length, mgfHash);
const db = new Uint8Array(maskedDb.length);
for (let i = 0; i < maskedDb.length; i++) {
db[i] = maskedDb[i]! ^ dbMask[i]!;
}
if (leftmostBitsToClear > 0) {
db[0] = db[0]! & (0xff >>> leftmostBitsToClear);
}
// Check PS = 0x00 * (emLen - sLen - hLen - 2), then 0x01.
const psLen = emLen - saltLength - hLen - 2;
for (let i = 0; i < psLen; i++) {
if (db[i] !== 0x00) {
return false;
}
}
if (db[psLen] !== 0x01) {
return false;
}
// db.subarray(psLen + 1) has length saltLength by construction
// (db.length === emLen - hLen - 1 === psLen + 1 + saltLength).
const salt = db.subarray(psLen + 1);
// M' = (0x00 * 8) || mHash || salt
const mPrime = new Uint8Array(8 + hLen + saltLength);
mPrime.set(mHash, 8);
mPrime.set(salt, 8 + hLen);
const hPrime = digestWith(hash, mPrime);
return constantTimeEqual(h, hPrime);
}

View File

@@ -0,0 +1,11 @@
export {
DIGEST_INFO_PREFIX_SHA1,
DIGEST_INFO_PREFIX_SHA256,
DIGEST_INFO_PREFIX_SHA384,
DIGEST_INFO_PREFIX_SHA512,
digestInfoPrefix,
emsaPkcs1v15Encode,
} from "./emsa-pkcs1-v1_5.js";
export { emsaPssEncode } from "./emsa-pss-encode.js";
export { emsaPssVerify } from "./emsa-pss-verify.js";
export { digestWith, hashOutputLength, mgf1 } from "./mgf1.js";

View File

@@ -0,0 +1,84 @@
# mgf1
## Purpose
Implements the MGF1 mask-generation function defined in RFC 8017 §B.2.1.
MGF1 is the building block used by EMSA-PSS and RSAES-OAEP to expand a
short seed into an arbitrary-length pseudorandom mask. The same module
also exports the hash-dispatch helper (`digestWith`) and the per-hash
output-length lookup (`hashOutputLength`) shared by every encoding and
signature function in `@suaveplan/crypto-rsa`.
## Features
- RFC 8017 §B.2.1 conformant — counter-based hash chain over `seed || I2OSP(counter, 4)`
- Supports SHA-1, SHA-256, SHA-384, SHA-512 (the four `RsaHashAlgorithm` values)
- Routes SHA-2 through `@suaveplan/crypto-hash` (engine-pluggable)
- Routes SHA-1 through `@noble/hashes/sha1` directly (crypto-hash omits SHA-1)
- Enforces the `maskLen <= 2^32 * hLen` upper bound from RFC 8017 §B.2.1 step 1
- Throws `CRYPTO_RSA.MGF_FAILURE` on negative / non-integer `maskLen` or oversize `maskLen`
- Throws `CRYPTO_RSA.UNSUPPORTED_HASH` on unknown hash names via `digestWith` / `hashOutputLength`
## Usage: Basic Example
```ts
import { mgf1 } from "@suaveplan/crypto-rsa/encoding/mgf1";
const seed = new Uint8Array([0x01, 0x02, 0x03, 0x04]);
const mask = mgf1(seed, 32, "SHA-256");
console.log(mask.length); // 32
```
## Usage: Advanced Example
The encoding modules call `mgf1` internally with a hash that is
typically — but not always — the same as the one used to digest the
message. EMSA-PSS allows distinct hash and MGF hash, OAEP does the same.
The MGF hash is independently configurable in both schemes.
```ts
import { mgf1 } from "@suaveplan/crypto-rsa/encoding/mgf1";
import { sha256 } from "@suaveplan/crypto-hash";
// Reproduce the dbMask used by EMSA-PSS-Encode for a 256-byte salted DB
const mHashSeed = sha256(new TextEncoder().encode("hello"));
const dbMask = mgf1(mHashSeed, 222, "SHA-1");
console.log(dbMask.length); // 222
```
## API Reference
### `mgf1(seed, maskLen, hash): Uint8Array`
- `seed: Uint8Array` — input seed, any non-zero length
- `maskLen: number` — desired output length in bytes; must be a non-negative integer ≤ `2^32 * hLen`
- `hash: RsaHashAlgorithm` — `"SHA-1" | "SHA-256" | "SHA-384" | "SHA-512"`
- **returns** `Uint8Array` of length exactly `maskLen`
- **throws** `CryptoRsaError(CRYPTO_RSA.MGF_FAILURE)` on out-of-range `maskLen`
- **throws** `CryptoRsaError(CRYPTO_RSA.UNSUPPORTED_HASH)` on unknown hash
### `digestWith(hash, data): Uint8Array`
Dispatches `data` to the appropriate hash implementation. Used internally
by `mgf1` and by every encoding / signature function in this package.
### `hashOutputLength(hash): number`
Returns the byte length of a digest produced by `hash`: 20, 32, 48, or 64.
## Implementation Notes
- The internal counter buffer is allocated once (`seed.length + 4` bytes)
and reused across the loop, avoiding per-iteration allocation.
- The block count is `ceil(maskLen / hLen)` and the final block is
truncated to the requested length so the result is always exactly
`maskLen` bytes.
- The `2^32 * hLen` bound is the conservative upper limit specified by
RFC 8017 (the actual MGF1 limit is `2^32`, but `2^32 * hLen` covers the
combined output bound and matches the implementation note in
RFC 8017 §B.2.1).
- SHA-1 routes through `@noble/hashes/sha1` because `@suaveplan/crypto-hash`
deliberately ships only SHA-2 in its public surface; SHA-1 is needed
here for legacy WebAuthn-TPM signature compatibility (RSASSA-PKCS1-v1\_5
with SHA-1) and for the rare PSS variant that uses SHA-1 as the MGF
hash with SHA-256 as the message hash.

View File

@@ -0,0 +1,140 @@
import { sha1 } from "@noble/hashes/sha1";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { CRYPTO_RSA_CODES, CryptoRsaError } from "../errors/errors.js";
import { digestWith, hashOutputLength, mgf1 } from "./mgf1.js";
describe("mgf1", () => {
it("produces output of the requested length", { timeout: 5000 }, () => {
const seed = new Uint8Array([1, 2, 3, 4]);
expect(mgf1(seed, 32, "SHA-256").length).toBe(32);
expect(mgf1(seed, 222, "SHA-1").length).toBe(222);
expect(mgf1(seed, 1, "SHA-512").length).toBe(1);
});
it("matches the manual MGF1 reference for SHA-1 (RFC 8017 §B.2.1 algorithm)", {
timeout: 5000,
}, () => {
// Hand-construct MGF1 by concatenating SHA-1(seed||I2OSP(0,4)) etc.
const seed = new Uint8Array([0xaa, 0xbb, 0xcc]);
const hLen = 20;
const maskLen = 50;
const blocks = Math.ceil(maskLen / hLen);
const expected = new Uint8Array(maskLen);
for (let c = 0; c < blocks; c++) {
const buf = new Uint8Array(seed.length + 4);
buf.set(seed, 0);
buf[seed.length] = (c >>> 24) & 0xff;
buf[seed.length + 1] = (c >>> 16) & 0xff;
buf[seed.length + 2] = (c >>> 8) & 0xff;
buf[seed.length + 3] = c & 0xff;
const block = sha1(buf);
const off = c * hLen;
const take = Math.min(hLen, maskLen - off);
expected.set(block.subarray(0, take), off);
}
expect(Array.from(mgf1(seed, maskLen, "SHA-1"))).toEqual(
Array.from(expected)
);
});
it("is deterministic — same seed + maskLen + hash → same bytes", {
timeout: 5000,
}, () => {
const seed = new Uint8Array([9, 8, 7, 6, 5]);
const a = mgf1(seed, 64, "SHA-256");
const b = mgf1(seed, 64, "SHA-256");
expect(Array.from(a)).toEqual(Array.from(b));
});
it("different mgf hash → different output for the same seed", {
timeout: 5000,
}, () => {
const seed = new Uint8Array([0x11, 0x22, 0x33]);
const a = mgf1(seed, 32, "SHA-256");
const b = mgf1(seed, 32, "SHA-512");
expect(Array.from(a)).not.toEqual(Array.from(b));
});
it("rejects oversize maskLen as MGF_FAILURE", { timeout: 5000 }, () => {
// RFC 8017 §B.2.1 step 1: maskLen > 2^32 * hLen → reject. Pick a
// value above the conservative 2^32 * 20 bound for SHA-1.
const seed = new Uint8Array([1]);
let caught: unknown;
try {
// Use Number.MAX_SAFE_INTEGER as the trigger (definitely > 2^32 * 20).
mgf1(seed, Number.MAX_SAFE_INTEGER, "SHA-1");
} catch (err) {
caught = err;
}
expect(caught).toBeInstanceOf(CryptoRsaError);
expect((caught as CryptoRsaError).code).toBe(
CRYPTO_RSA_CODES.MGF_FAILURE
);
});
it("rejects negative maskLen as MGF_FAILURE", { timeout: 5000 }, () => {
const seed = new Uint8Array([1]);
expect(() => mgf1(seed, -1, "SHA-256")).toThrow(CryptoRsaError);
});
it("rejects non-integer maskLen as MGF_FAILURE", { timeout: 5000 }, () => {
const seed = new Uint8Array([1]);
expect(() => mgf1(seed, 1.5, "SHA-256")).toThrow(CryptoRsaError);
});
it("supports maskLen === 0 (empty mask)", { timeout: 5000 }, () => {
const out = mgf1(new Uint8Array([0]), 0, "SHA-256");
expect(out.length).toBe(0);
});
});
describe("hashOutputLength", () => {
it("returns the documented byte lengths", { timeout: 5000 }, () => {
expect(hashOutputLength("SHA-1")).toBe(20);
expect(hashOutputLength("SHA-256")).toBe(32);
expect(hashOutputLength("SHA-384")).toBe(48);
expect(hashOutputLength("SHA-512")).toBe(64);
});
it("throws UNSUPPORTED_HASH on an unknown algorithm", {
timeout: 5000,
}, () => {
let caught: unknown;
try {
hashOutputLength("MD5" as never);
} catch (err) {
caught = err;
}
expect(caught).toBeInstanceOf(CryptoRsaError);
expect((caught as CryptoRsaError).code).toBe(
CRYPTO_RSA_CODES.UNSUPPORTED_HASH
);
});
});
describe("digestWith", () => {
it("routes each algorithm to a digest of the documented length", {
timeout: 5000,
}, () => {
const data = new Uint8Array([1, 2, 3, 4]);
expect(digestWith("SHA-1", data).length).toBe(20);
expect(digestWith("SHA-256", data).length).toBe(32);
expect(digestWith("SHA-384", data).length).toBe(48);
expect(digestWith("SHA-512", data).length).toBe(64);
});
it("throws UNSUPPORTED_HASH on an unknown algorithm", {
timeout: 5000,
}, () => {
let caught: unknown;
try {
digestWith("MD5" as never, new Uint8Array([1]));
} catch (err) {
caught = err;
}
expect(caught).toBeInstanceOf(CryptoRsaError);
expect((caught as CryptoRsaError).code).toBe(
CRYPTO_RSA_CODES.UNSUPPORTED_HASH
);
});
});

View File

@@ -0,0 +1,107 @@
import { sha1 } from "@noble/hashes/sha1";
import { sha256, sha384, sha512 } from "@suaveplan/crypto-hash";
import { CRYPTO_RSA_CODES, CryptoRsaError } from "../errors/errors.js";
import type { RsaHashAlgorithm } from "../keys/keys.types.js";
import { i2osp } from "../math/i2osp.js";
const HLEN_BY_HASH: Readonly<Record<RsaHashAlgorithm, number>> = {
"SHA-1": 20,
"SHA-256": 32,
"SHA-384": 48,
"SHA-512": 64,
};
/**
* Hash-output length in bytes for the given algorithm.
*
* Throws `CRYPTO_RSA.UNSUPPORTED_HASH` if the algorithm is not one of the
* four declared in `RsaHashAlgorithm`.
*/
export function hashOutputLength(hash: RsaHashAlgorithm): number {
const len = HLEN_BY_HASH[hash];
if (len === undefined) {
throw new CryptoRsaError(
CRYPTO_RSA_CODES.UNSUPPORTED_HASH,
"error.crypto-rsa.unsupported-hash",
{ context: { hash } }
);
}
return len;
}
/**
* Compute a digest of `data` using the named algorithm. Routes SHA-2
* through `@suaveplan/crypto-hash` (which selects between native and
* Noble engines per `crypto.config.ts`) and SHA-1 through
* `@noble/hashes/sha1` directly because crypto-hash deliberately omits
* SHA-1 from its public surface.
*/
export function digestWith(
hash: RsaHashAlgorithm,
data: Uint8Array
): Uint8Array {
if (hash === "SHA-256") {
return sha256(data);
}
if (hash === "SHA-384") {
return sha384(data);
}
if (hash === "SHA-512") {
return sha512(data);
}
if (hash === "SHA-1") {
return sha1(data);
}
throw new CryptoRsaError(
CRYPTO_RSA_CODES.UNSUPPORTED_HASH,
"error.crypto-rsa.unsupported-hash",
{ context: { hash } }
);
}
const TWO_POW_32 = 0x1_00_00_00_00;
/**
* MGF1 mask-generation function (RFC 8017 §B.2.1).
*
* Produces a `maskLen`-byte pseudorandom mask from `seed` by hashing
* `seed || I2OSP(counter, 4)` for `counter = 0, 1, 2, ...` and
* concatenating up to `maskLen` bytes.
*
* @throws CryptoRsaError(CRYPTO_RSA.MGF_FAILURE) when `maskLen > 2^32 * hLen`
*/
export function mgf1(
seed: Uint8Array,
maskLen: number,
hash: RsaHashAlgorithm
): Uint8Array {
const hLen = hashOutputLength(hash);
if (maskLen < 0 || !Number.isInteger(maskLen)) {
throw new CryptoRsaError(
CRYPTO_RSA_CODES.MGF_FAILURE,
"error.crypto-rsa.mgf-failure",
{ context: { maskLen } }
);
}
if (maskLen > TWO_POW_32 * hLen) {
throw new CryptoRsaError(
CRYPTO_RSA_CODES.MGF_FAILURE,
"error.crypto-rsa.mgf-failure",
{ context: { maskLen, hLen } }
);
}
const result = new Uint8Array(maskLen);
const blocks = Math.ceil(maskLen / hLen);
const buffer = new Uint8Array(seed.length + 4);
buffer.set(seed, 0);
for (let counter = 0; counter < blocks; counter++) {
const cBytes = i2osp(counter, 4);
buffer.set(cBytes, seed.length);
const block = digestWith(hash, buffer);
const offset = counter * hLen;
const remaining = maskLen - offset;
const take = remaining < hLen ? remaining : hLen;
result.set(block.subarray(0, take), offset);
}
return result;
}

View File

@@ -0,0 +1,64 @@
# `default-rng`
## Purpose
Single canonical adapter that lifts the `crypto.getRandomValues`
CSPRNG into the `@suaveplan/types` `Rng` interface every Stage 3
surface accepts. Centralising the conversion here keeps the
package's CSPRNG entry point auditable — exactly one callsite,
exactly one set of tests, and any future change (e.g. routing
through a `crypto-utils.defaultByteSource` override for hardware
RNGs) flows from here outward.
## Features
- Routes byte production through `defaultByteSource` from
`@suaveplan/crypto-utils` so override hooks installed at the
utils layer also affect every RSA surface.
- Implements `nextUint32`, `nextFloat`, and the optional
`nextBytes` fast path so blinding / OAEP padding always take
the cheap byte-extraction route.
- Pure factory — never caches; returns a fresh object on every
call. Cheap to allocate; safe to retain across operations.
## Usage: Basic Example
```ts
import { defaultRng } from "@suaveplan/crypto-rsa/encrypt/default-rng";
const rng = defaultRng();
console.log(rng.nextBytes?.(16));
```
## Usage: Advanced Example
Override the byte source repo-wide via `@suaveplan/crypto-utils`
without touching this module:
```ts
import { defaultRng } from "@suaveplan/crypto-rsa/encrypt/default-rng";
// Any hardware-RNG override installed at the crypto-utils layer
// also reaches every Stage 3 surface that calls defaultRng().
const rng = defaultRng();
```
## API Reference
### `defaultRng(): Rng`
Returns a fresh `Rng` instance backed by
`@suaveplan/crypto-utils.defaultByteSource`. Implements
`nextUint32`, `nextFloat`, and `nextBytes`.
## Implementation Notes
- The `nextUint32` path allocates 4 bytes per call. Callers that
need a tight loop should prefer `rng.nextBytes(n)` and slice
from there to avoid the per-call allocation overhead.
- `nextFloat` divides by `2^32` so the returned value is strictly
less than 1 even for the all-ones `nextUint32` draw — matches
the `Rng` interface contract from `@suaveplan/types`.
- Returning a fresh object per call keeps state isolated; two
callers receive independent RNG handles even though both wrap
the same global `crypto.getRandomValues` source.

View File

@@ -0,0 +1,39 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { defaultRng } from "./default-rng.js";
describe("defaultRng", () => {
it("returns a fresh Rng instance per call", { timeout: 5000 }, () => {
const a = defaultRng();
const b = defaultRng();
expect(a).not.toBe(b);
});
it("nextUint32 returns an integer in [0, 2^32)", {
timeout: 5000,
}, () => {
const rng = defaultRng();
for (let i = 0; i < 16; i++) {
const v = rng.nextUint32();
expect(Number.isInteger(v)).toBe(true);
expect(v >= 0 && v < 0x1_0000_0000).toBe(true);
}
});
it("nextFloat returns a value in [0, 1)", {
timeout: 5000,
}, () => {
const rng = defaultRng();
for (let i = 0; i < 16; i++) {
const v = rng.nextFloat();
expect(v >= 0 && v < 1).toBe(true);
}
});
it("nextBytes returns a buffer of the requested length", {
timeout: 5000,
}, () => {
const rng = defaultRng();
const out = rng.nextBytes?.(13);
expect(out?.length).toBe(13);
});
});

View File

@@ -0,0 +1,24 @@
import { defaultByteSource } from "@suaveplan/crypto-utils";
import type { Rng } from "@suaveplan/types";
/**
* Default `Rng` backed by `crypto.getRandomValues` via the
* `@suaveplan/crypto-utils` `defaultByteSource` indirection. Used by every
* encrypt / sign / keygen surface when the caller does not inject one.
*/
export function defaultRng(): Rng {
return {
nextUint32: () => {
const buf = defaultByteSource(4);
return new DataView(buf.buffer, buf.byteOffset, 4).getUint32(0);
},
nextFloat: () => {
const buf = defaultByteSource(4);
return (
new DataView(buf.buffer, buf.byteOffset, 4).getUint32(0) /
0x1_0000_0000
);
},
nextBytes: (n: number) => defaultByteSource(n),
};
}

View File

@@ -0,0 +1,8 @@
export {
type RsaEncryptOaepOptions,
rsaEncryptOaep,
} from "./oaep.js";
export {
type RsaEncryptPkcs1v15Options,
rsaEncryptPkcs1v15,
} from "./pkcs1v15.js";

View File

@@ -0,0 +1,91 @@
# `encrypt/oaep`
## Purpose
RSAES-OAEP encryption per RFC 8017 §7.1.1. The Optimal Asymmetric
Encryption Padding (OAEP) scheme is the modern default for
RSA public-key encryption — provably secure under the random-oracle
model when the underlying hash is collision-resistant, and
immune to the chosen-ciphertext attacks (Bleichenbacher, Manger)
that plague PKCS1-v1_5.
This module provides the encryption side; the matching decrypt
lives in `decrypt/oaep` and applies the Manger-safe combined
error path.
## Features
- RFC 8017 §7.1.1 reference algorithm with caller-overridable
hash (`SHA-256` default), MGF hash (defaults to the OAEP
hash), and optional label (defaults to empty).
- Routes hashing through `@suaveplan/crypto-hash` for SHA-256/384/512
and `@noble/hashes/sha1` for SHA-1; same dispatcher Stage 2's
`mgf1` uses, so OAEP and MGF1 stay in lock-step.
- Modulus-length policy gate runs first; sub-1024-bit keys
rejected outright.
- Plaintext oversize → `CRYPTO_RSA.INVALID_KEY` with `{ mLen, max }`
context for diagnosability.
- `crypto_rsa.encrypt_duration_ms{scheme="oaep",hash}` histogram.
## Usage: Basic Example
```ts
import { rsaEncryptOaep } from "@suaveplan/crypto-rsa/encrypt/oaep";
const ciphertext = rsaEncryptOaep(
publicKey,
new TextEncoder().encode("hello"),
{ hash: "SHA-256" }
);
```
## Usage: Advanced Example
OAEP with SHA-384 hashing and a non-empty label (mirrors a
common JWE `RSA-OAEP-384` deployment with a context-bound
label):
```ts
import { rsaEncryptOaep } from "@suaveplan/crypto-rsa/encrypt/oaep";
const label = new TextEncoder().encode("session-2026-05-11");
const ct = rsaEncryptOaep(publicKey, plaintext, {
hash: "SHA-384",
mgfHash: "SHA-384",
label,
allowLegacy: false,
});
```
## API Reference
### `rsaEncryptOaep(key, plaintext, options?): Uint8Array`
- `key: RsaPublicKey`
- `plaintext: Uint8Array` — at most `k - 2*hLen - 2` bytes.
- `options.hash?: RsaHashAlgorithm` — defaults to `"SHA-256"`.
- `options.mgfHash?: RsaHashAlgorithm` — defaults to `hash`.
- `options.label?: Uint8Array` — defaults to empty.
- `options.rng?: Rng` — defaults to `defaultRng()`.
- `options.allowLegacy?: boolean` — accept 1024-bit moduli.
- Throws `CRYPTO_RSA.INVALID_KEY` on plaintext oversize,
`CRYPTO_RSA.UNSUPPORTED_HASH` on unknown algorithm,
`CRYPTO_RSA.MGF_FAILURE` on absurd mask-length input,
modulus-length policy violations on bad key.
## Implementation Notes
- The seed is drawn from the injected `Rng` via the shared
`nextBytesViaRng` helper (same path as blinding) so seeded
test RNGs work transparently.
- We share the `mgf1` and `digestWith` implementations from
Stage 2's `src/encoding/mgf1.ts` rather than duplicating —
RFC 8017 §B.2.1 is short but having two copies invites drift.
- `db` is allocated once and built in place; XOR with the mask
produces a new buffer rather than mutating in place because
`xor` from `@suaveplan/crypto-utils` returns a fresh allocation
(length-mismatch guard).
- The OAEP encoding allocates `O(k)` — for 4096-bit keys this
is 512 bytes. Hot-loop callers (e.g. JWE bulk re-encryption)
should prefer keeping a single `RsaPublicKey` and amortising
the modpow cost over many ciphertexts.

View File

@@ -0,0 +1,94 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
LEGACY_1024_PRIVATE_KEY,
LEGACY_1024_PUBLIC_KEY,
} from "../__fixtures__/test-keys.js";
import { rsaDecryptOaep } from "../decrypt/oaep.js";
import { CryptoRsaError } from "../errors/errors.js";
import { rsaEncryptOaep } from "./oaep.js";
describe("rsaEncryptOaep", () => {
// SHA-512 OAEP requires modulus ≥ 1040 bits (k - 2*64 - 2 ≥ 1).
// The 1024-bit fixture leaves no room — covered by the
// "rejects oversized plaintext" case below instead.
it.each([
["SHA-256" as const, 32],
["SHA-384" as const, 48],
["SHA-1" as const, 20],
])("round-trips with hash=%s (hLen=%i)", { timeout: 15000 }, (hash) => {
const pt = new TextEncoder().encode("oaep round-trip");
const ct = rsaEncryptOaep(LEGACY_1024_PUBLIC_KEY, pt, {
hash,
allowLegacy: true,
});
expect(ct.length).toBe(128);
const recovered = rsaDecryptOaep(LEGACY_1024_PRIVATE_KEY, ct, {
hash,
allowLegacy: true,
});
expect(Array.from(recovered)).toEqual(Array.from(pt));
});
it("rejects oversized plaintext", { timeout: 5000 }, () => {
const k = 128;
const hLen = 32;
const tooBig = new Uint8Array(k - 2 * hLen - 1);
expect(() =>
rsaEncryptOaep(LEGACY_1024_PUBLIC_KEY, tooBig, {
hash: "SHA-256",
allowLegacy: true,
})
).toThrow(CryptoRsaError);
});
it("SHA-512 against 1024-bit modulus is rejected (modulus too small)", {
timeout: 5000,
}, () => {
// For SHA-512 (hLen=64), k - 2*hLen - 2 = 128 - 130 = -2,
// so even an empty plaintext fails the size gate.
expect(() =>
rsaEncryptOaep(LEGACY_1024_PUBLIC_KEY, new Uint8Array(0), {
hash: "SHA-512",
allowLegacy: true,
})
).toThrow(CryptoRsaError);
});
it("non-empty label round-trips", { timeout: 10000 }, () => {
const pt = new Uint8Array([7, 7, 7]);
const label = new TextEncoder().encode("ctx");
const ct = rsaEncryptOaep(LEGACY_1024_PUBLIC_KEY, pt, {
hash: "SHA-256",
label,
allowLegacy: true,
});
const recovered = rsaDecryptOaep(LEGACY_1024_PRIVATE_KEY, ct, {
hash: "SHA-256",
label,
allowLegacy: true,
});
expect(Array.from(recovered)).toEqual(Array.from(pt));
});
it("mgfHash overrides default to hash", { timeout: 10000 }, () => {
const pt = new Uint8Array([1]);
const ct = rsaEncryptOaep(LEGACY_1024_PUBLIC_KEY, pt, {
hash: "SHA-256",
mgfHash: "SHA-1",
allowLegacy: true,
});
const recovered = rsaDecryptOaep(LEGACY_1024_PRIVATE_KEY, ct, {
hash: "SHA-256",
mgfHash: "SHA-1",
allowLegacy: true,
});
expect(Array.from(recovered)).toEqual(Array.from(pt));
});
it("policy rejects sub-1024 keys", { timeout: 5000 }, () => {
const tinyKey = { n: 60491n, e: 65537n, bitLength: 16 };
expect(() => rsaEncryptOaep(tinyKey, new Uint8Array(1))).toThrow(
CryptoRsaError
);
});
});

View File

@@ -0,0 +1,103 @@
import { xor } from "@suaveplan/crypto-utils";
import type { Rng } from "@suaveplan/types";
import { nextBytesViaRng } from "../blinding/blinding.js";
import { digestWith, hashOutputLength, mgf1 } from "../encoding/mgf1.js";
import { CRYPTO_RSA_CODES, CryptoRsaError } from "../errors/errors.js";
import type { RsaHashAlgorithm, RsaPublicKey } from "../keys/keys.types.js";
import { i2osp } from "../math/i2osp.js";
import { modPow } from "../math/modpow.js";
import { os2ip } from "../math/os2ip.js";
import {
recordEncryptDuration,
recordThrown,
} from "../observability/metrics.js";
import { enforceMaxBitLength, enforceMinBitLength } from "../policy/modulus.js";
import { defaultRng } from "./default-rng.js";
/**
* Optional input for `rsaEncryptOaep`. Defaults: `hash: "SHA-256"`,
* `mgfHash` defaults to `hash`, `label: empty`, `rng` defaults to the
* CSPRNG-backed `defaultRng()`.
*/
export interface RsaEncryptOaepOptions {
readonly hash?: RsaHashAlgorithm;
readonly mgfHash?: RsaHashAlgorithm;
readonly label?: Uint8Array;
readonly rng?: Rng;
readonly allowLegacy?: boolean;
}
const EMPTY = new Uint8Array(0);
/**
* RSAES-OAEP encryption per RFC 8017 §7.1.1.
*
* Builds the encoded message `EM = 0x00 || maskedSeed || maskedDB`
* where `DB = lHash || PS || 0x01 || M`, masks `DB` with
* `MGF(seed, k - hLen - 1)`, and masks `seed` with
* `MGF(maskedDB, hLen)`. Modular exponentiates `m = OS2IP(EM)` to the
* public exponent and emits `I2OSP(c, k)`.
*
* Throws `CRYPTO_RSA.INVALID_KEY` when `mLen > k - 2*hLen - 2`.
*/
export function rsaEncryptOaep(
key: RsaPublicKey,
plaintext: Uint8Array,
options: RsaEncryptOaepOptions = {}
): Uint8Array {
const start = nowMs();
const hash = options.hash ?? "SHA-256";
const mgfHash = options.mgfHash ?? hash;
try {
enforceMinBitLength(
key,
options.allowLegacy === undefined
? {}
: { allowLegacy: options.allowLegacy }
);
enforceMaxBitLength(key);
const k = byteLength(key);
const hLen = hashOutputLength(hash);
const label = options.label ?? EMPTY;
if (plaintext.length > k - 2 * hLen - 2) {
throw new CryptoRsaError(
CRYPTO_RSA_CODES.INVALID_KEY,
"error.crypto-rsa.encrypt.plaintext-too-long",
{ context: { mLen: plaintext.length, max: k - 2 * hLen - 2 } }
);
}
const lHash = digestWith(hash, label);
const psLen = k - plaintext.length - 2 * hLen - 2;
const db = new Uint8Array(k - hLen - 1);
db.set(lHash, 0);
// PS is psLen zero bytes (already initialised).
db[hLen + psLen] = 0x01;
db.set(plaintext, hLen + psLen + 1);
const rng = options.rng ?? defaultRng();
const seed = nextBytesViaRng(rng, hLen);
const dbMask = mgf1(seed, k - hLen - 1, mgfHash);
const maskedDb = xor(db, dbMask);
const seedMask = mgf1(maskedDb, hLen, mgfHash);
const maskedSeed = xor(seed, seedMask);
const em = new Uint8Array(k);
em[0] = 0x00;
em.set(maskedSeed, 1);
em.set(maskedDb, 1 + hLen);
const m = os2ip(em);
const c = modPow(m, key.e, key.n);
const out = i2osp(c, k);
recordEncryptDuration("oaep", hash, nowMs() - start);
return out;
} catch (err) {
recordThrown(err);
throw err;
}
}
function byteLength(key: RsaPublicKey): number {
return Math.ceil(key.bitLength / 8);
}
function nowMs(): number {
return performance.now();
}

View File

@@ -0,0 +1,90 @@
# `encrypt/pkcs1v15`
## Purpose
RSAES-PKCS1-v1_5 encryption per RFC 8017 §7.2.1 — the legacy
public-key encryption padding scheme historically used by
TLS 1.0–1.2 ClientKeyExchange and JWE `RSA1_5` algorithm. Modern
deployments should prefer OAEP (`@suaveplan/crypto-rsa/encrypt/oaep`)
because the matching decrypt path is vulnerable to
Bleichenbacher chosen-ciphertext attacks; encryption itself is
straightforward padding.
## Features
- Block-type-2 padding: `0x00 || 0x02 || PS || 0x00 || M` where
`PS` is `k - mLen - 3` non-zero random bytes (RFC 8017 §7.2.1
step 2 mandates non-zero).
- Modulus-length policy gate runs first — sub-1024-bit keys
rejected outright, 1024-bit accepted only with
`{ allowLegacy: true }`.
- Plaintext oversize → `CRYPTO_RSA.INVALID_KEY` with
`{ mLen, max }` context for diagnosability.
- Pluggable `Rng` for tests; defaults to `crypto.getRandomValues`
via `defaultRng()`.
- Observability — `crypto_rsa.encrypt_duration_ms{scheme="pkcs1v15"}`
histogram + `outcome_total{operation="encrypt"}` counter on
success, `errors_total{code}` on every typed-error throw.
## Usage: Basic Example
```ts
import { rsaEncryptPkcs1v15 } from "@suaveplan/crypto-rsa/encrypt/pkcs1v15";
import type { RsaPublicKey } from "@suaveplan/crypto-rsa";
declare const publicKey: RsaPublicKey; // ≥ 2048-bit
const ciphertext = rsaEncryptPkcs1v15(
publicKey,
new TextEncoder().encode("hello"),
{ allowLegacy: false }
);
// ciphertext.length === Math.ceil(publicKey.bitLength / 8)
```
## Usage: Advanced Example
Deterministic round-trip in a unit test using a seeded Rng:
```ts
import { rsaEncryptPkcs1v15 } from "@suaveplan/crypto-rsa/encrypt/pkcs1v15";
import { rsaDecryptPkcs1v15 } from "@suaveplan/crypto-rsa/decrypt/pkcs1v15";
let s = 1;
const seededRng = {
nextUint32: () => (s = (s * 1664525 + 1013904223) >>> 0),
nextFloat: () => Math.random(),
};
const ct = rsaEncryptPkcs1v15(publicKey, plaintext, {
rng: seededRng,
allowLegacy: true,
});
const pt = rsaDecryptPkcs1v15(privateKey, ct, { allowLegacy: true });
// pt deepEquals plaintext
```
## API Reference
### `rsaEncryptPkcs1v15(key, plaintext, options?): Uint8Array`
- `key: RsaPublicKey` — modulus must satisfy the policy gate.
- `plaintext: Uint8Array` — at most `k - 11` bytes where
`k = ceil(bitLength / 8)`.
- `options.rng?: Rng` — randomness source for the `PS` block.
- `options.allowLegacy?: boolean` — accept 1024-bit moduli.
- Returns the `k`-byte ciphertext.
- Throws `CRYPTO_RSA.INVALID_KEY` on plaintext oversize,
`MODULUS_TOO_SHORT` / `MODULUS_TOO_LARGE` on policy violations.
## Implementation Notes
- The non-zero `PS` is generated by drawing `2 * remaining`
bytes per loop iteration; the rejection rate is `1/256` per
byte so a single draw covers almost every case.
- The whole pad-and-modpow path is non-constant-time. PKCS1-v1_5
encryption is a public operation so timing leaks have no
security impact — all the danger lives on the decryption side.
- `byteLength(key)` derives from `bitLength` so partial-byte
moduli (e.g. 2049-bit) round up to the next whole byte.
- We do not blind during encryption — only the private operation
(decrypt / sign) leaks information through timing.

View File

@@ -0,0 +1,98 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
LEGACY_1024_PRIVATE_KEY,
LEGACY_1024_PUBLIC_KEY,
} from "../__fixtures__/test-keys.js";
import { rsaDecryptPkcs1v15 } from "../decrypt/pkcs1v15.js";
import { CryptoRsaError } from "../errors/errors.js";
import { rsaEncryptPkcs1v15 } from "./pkcs1v15.js";
describe("rsaEncryptPkcs1v15", () => {
it("round-trips through rsaDecryptPkcs1v15", { timeout: 10000 }, () => {
const plaintext = new TextEncoder().encode("hello, RSA!");
const ct = rsaEncryptPkcs1v15(LEGACY_1024_PUBLIC_KEY, plaintext, {
allowLegacy: true,
});
expect(ct.length).toBe(128); // 1024 / 8
const pt = rsaDecryptPkcs1v15(LEGACY_1024_PRIVATE_KEY, ct, {
allowLegacy: true,
});
expect(Array.from(pt)).toEqual(Array.from(plaintext));
});
it("rejects oversized plaintext", { timeout: 5000 }, () => {
const k = 128;
const tooBig = new Uint8Array(k - 10); // mLen > k - 11
expect(() =>
rsaEncryptPkcs1v15(LEGACY_1024_PUBLIC_KEY, tooBig, {
allowLegacy: true,
})
).toThrow(CryptoRsaError);
});
it("ciphertext length always equals modulus byte length", {
timeout: 10000,
}, () => {
for (const len of [0, 1, 16, 64, 117]) {
const ct = rsaEncryptPkcs1v15(
LEGACY_1024_PUBLIC_KEY,
new Uint8Array(len),
{ allowLegacy: true }
);
expect(ct.length).toBe(128);
}
});
it("uses provided rng deterministically", { timeout: 10000 }, () => {
const seedRng = (seed: number) => {
let s = seed >>> 0;
return {
nextUint32: () =>
(s = (Math.imul(s, 1664525) + 1013904223) >>> 0),
nextFloat: () => 0,
nextBytes: (n: number) => {
const out = new Uint8Array(n);
for (let i = 0; i < n; i++) {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
out[i] = (s >>> 24) & 0xff;
}
return out;
},
};
};
const plaintext = new Uint8Array([1, 2, 3, 4]);
const a = rsaEncryptPkcs1v15(LEGACY_1024_PUBLIC_KEY, plaintext, {
rng: seedRng(123),
allowLegacy: true,
});
const b = rsaEncryptPkcs1v15(LEGACY_1024_PUBLIC_KEY, plaintext, {
rng: seedRng(123),
allowLegacy: true,
});
expect(Array.from(a)).toEqual(Array.from(b));
});
it("policy gate rejects sub-1024 keys", { timeout: 5000 }, () => {
const tinyKey = {
n: 60491n,
e: 65537n,
bitLength: 16,
};
expect(() => rsaEncryptPkcs1v15(tinyKey, new Uint8Array(1))).toThrow(
CryptoRsaError
);
});
it("non-CryptoRsa error surfaces unchanged via catch branch", {
timeout: 5000,
}, () => {
// n = 0n triggers a RangeError from noble's modPow — exercises
// the false branch of the catch's instanceof check.
const malformed = { n: 0n, e: 65537n, bitLength: 1024 };
expect(() =>
rsaEncryptPkcs1v15(malformed, new Uint8Array(1), {
allowLegacy: true,
})
).toThrow();
});
});

View File

@@ -0,0 +1,111 @@
import type { Rng } from "@suaveplan/types";
import { blindMessage, nextBytesViaRng } from "../blinding/blinding.js";
import { CRYPTO_RSA_CODES, CryptoRsaError } from "../errors/errors.js";
import type { RsaPublicKey } from "../keys/keys.types.js";
import { i2osp } from "../math/i2osp.js";
import { modPow } from "../math/modpow.js";
import { os2ip } from "../math/os2ip.js";
import {
recordEncryptDuration,
recordThrown,
} from "../observability/metrics.js";
import { enforceMaxBitLength, enforceMinBitLength } from "../policy/modulus.js";
import { defaultRng } from "./default-rng.js";
/**
* Optional input for `rsaEncryptPkcs1v15`. The `rng` slot lets callers
* inject deterministic randomness for tests; production should leave it
* undefined so the CSPRNG-backed default kicks in.
*/
export interface RsaEncryptPkcs1v15Options {
readonly rng?: Rng;
/** Permit 1024-bit moduli (legacy WebAuthn TPM). */
readonly allowLegacy?: boolean;
}
/**
* RSAES-PKCS1-v1_5 encryption per RFC 8017 §7.2.1.
*
* Pads the plaintext as `0x00 || 0x02 || PS || 0x00 || M` where `PS` is
* `k - mLen - 3` non-zero random bytes and `k` is the modulus byte
* length. Throws `CRYPTO_RSA.INVALID_KEY` when the plaintext exceeds
* `k - 11` bytes.
*
* Modern callers should prefer `rsaEncryptOaep` — PKCS1-v1_5 encryption
* is retained for legacy interop only (TLS pre-handshake, JWE `RSA1_5`
* legacy mode). Decryption applies the Bleichenbacher-safe combined
* error path; encryption is plain padding.
*/
export function rsaEncryptPkcs1v15(
key: RsaPublicKey,
plaintext: Uint8Array,
options: RsaEncryptPkcs1v15Options = {}
): Uint8Array {
const start = nowMs();
try {
enforceMinBitLength(
key,
options.allowLegacy === undefined
? {}
: { allowLegacy: options.allowLegacy }
);
enforceMaxBitLength(key);
const k = byteLength(key);
if (plaintext.length > k - 11) {
throw new CryptoRsaError(
CRYPTO_RSA_CODES.INVALID_KEY,
"error.crypto-rsa.encrypt.plaintext-too-long",
{
context: { mLen: plaintext.length, max: k - 11 },
}
);
}
const rng = options.rng ?? defaultRng();
const psLength = k - plaintext.length - 3;
const ps = nonZeroPadding(rng, psLength);
const em = new Uint8Array(k);
em[0] = 0x00;
em[1] = 0x02;
em.set(ps, 2);
em[2 + psLength] = 0x00;
em.set(plaintext, 3 + psLength);
const m = os2ip(em);
const c = modPow(m, key.e, key.n);
const out = i2osp(c, k);
recordEncryptDuration("pkcs1v15", "n/a", nowMs() - start);
return out;
} catch (err) {
recordThrown(err);
throw err;
}
}
function byteLength(key: RsaPublicKey): number {
return Math.ceil(key.bitLength / 8);
}
function nonZeroPadding(rng: Rng, length: number): Uint8Array {
const out = new Uint8Array(length);
let filled = 0;
while (filled < length) {
// Draw 2x what we still need so non-zero rejections rarely loop twice.
const need = (length - filled) * 2;
const draw = nextBytesViaRng(rng, Math.max(8, need));
for (const b of draw) {
if (b !== 0) {
out[filled++] = b;
if (filled === length) break;
}
}
}
return out;
}
function nowMs(): number {
return performance.now();
}
// Re-export the wired blinding helper for downstream sign-path consumers
// (Stage 2). Encryption itself does not blind — only the private operation
// (decrypt / sign) leaks via timing.
export { blindMessage };

View File

@@ -0,0 +1,72 @@
import type {
RsaHashAlgorithm,
RsaPrivateKey,
RsaPublicKey,
} from "../keys/keys.types.js";
/**
* Engine label — `"noble"` for the pure-JS implementation routed through
* `@noble/curves/abstract/modular`, `"subtle"` for the WebCrypto adapter
* over `globalThis.crypto.subtle`. Future engines (e.g. native Bun, GPU)
* extend this union and gain matching adapters.
*/
export type RsaEngineName = "noble" | "subtle";
/**
* Discriminated union of scheme options. Each variant carries the scheme
* tag plus the per-scheme parameters required to drive the engine's
* sign / verify / encrypt / decrypt paths uniformly.
*/
export type RsaSchemeOptions = RsaPkcs1v15Scheme | RsaPssScheme | RsaOaepScheme;
export interface RsaPkcs1v15Scheme {
readonly scheme: "pkcs1v15";
readonly hash: RsaHashAlgorithm;
}
export interface RsaPssScheme {
readonly scheme: "pss";
readonly hash: RsaHashAlgorithm;
readonly mgfHash?: RsaHashAlgorithm;
readonly saltLength?: number;
}
export interface RsaOaepScheme {
readonly scheme: "oaep";
readonly hash: RsaHashAlgorithm;
readonly mgfHash?: RsaHashAlgorithm;
readonly label?: Uint8Array;
}
/**
* Pluggable engine surface. Every engine implements the same five
* operations against the canonical `RsaPublicKey` / `RsaPrivateKey`
* shapes, regardless of whether the underlying primitive is a pure-JS
* `modPow`, a `crypto.subtle.sign` call, or a hardware-backed HSM
* roundtrip.
*/
export interface RsaCryptoEngine {
readonly name: RsaEngineName;
sign(
privateKey: RsaPrivateKey,
message: Uint8Array,
options: RsaSchemeOptions
): Promise<Uint8Array>;
verify(
publicKey: RsaPublicKey,
message: Uint8Array,
signature: Uint8Array,
options: RsaSchemeOptions
): Promise<boolean>;
encrypt(
publicKey: RsaPublicKey,
plaintext: Uint8Array,
options: RsaSchemeOptions
): Promise<Uint8Array>;
decrypt(
privateKey: RsaPrivateKey,
ciphertext: Uint8Array,
options: RsaSchemeOptions
): Promise<Uint8Array>;
modPow(base: bigint, exponent: bigint, modulus: bigint): bigint;
}

View File

@@ -0,0 +1,19 @@
export type {
RsaCryptoEngine,
RsaEngineName,
RsaOaepScheme,
RsaPkcs1v15Scheme,
RsaPssScheme,
RsaSchemeOptions,
} from "./engine.types.js";
export { nobleEngine } from "./noble.js";
export {
type DefaultEngineOptions,
defaultEngine,
resetEngineSelectionCounterForTesting,
} from "./select.js";
export {
resetSubtleCountersForTesting,
type SubtleEngineOptions,
subtleEngine,
} from "./subtle.js";

View File

@@ -0,0 +1,90 @@
# engine/noble
## Purpose
Default `RsaCryptoEngine` backed by the pure-JS noble primitives this
package ships natively. Provides the `name: "noble"` engine label and
delegates `sign` / `verify` / `encrypt` / `decrypt` / `modPow` to the
canonical `rsa*` functions, giving engine-aware consumers identical
semantics to direct callers.
## Features
- Implements every method on `RsaCryptoEngine`
- Routes through `rsaSignPkcs1v15`, `rsaSignPss`, `rsaVerifyPkcs1v15`,
`rsaVerifyPss`, `rsaEncryptPkcs1v15`, `rsaEncryptOaep`,
`rsaDecryptPkcs1v15`, `rsaDecryptOaep`
- Discriminated `RsaSchemeOptions` union routes per-scheme parameters
- Rejects scheme/operation mismatches (e.g. `sign({ scheme: "oaep" })`)
with `UNSUPPORTED_FORMAT`
- `modPow` exposed for advanced consumers (e.g. consumer-side
signature audit, key-pair sanity checks)
## Usage: Basic Example
```ts
import { nobleEngine } from "@suaveplan/crypto-rsa/engine";
const sig = await nobleEngine.sign(privateKey, message, {
scheme: "pkcs1v15",
hash: "SHA-256",
});
const ok = await nobleEngine.verify(publicKey, message, sig, {
scheme: "pkcs1v15",
hash: "SHA-256",
});
```
## Usage: Advanced Example
Use the engine surface to abstract over backend selection in higher-
level code:
```ts
import {
nobleEngine,
type RsaCryptoEngine,
} from "@suaveplan/crypto-rsa/engine";
async function signWithEngine(
engine: RsaCryptoEngine,
privateKey: RsaPrivateKey,
message: Uint8Array
): Promise<Uint8Array> {
return engine.sign(privateKey, message, {
scheme: "pss",
hash: "SHA-512",
saltLength: 64,
});
}
const sig = await signWithEngine(nobleEngine, key, msg);
```
## API Reference
### `nobleEngine: RsaCryptoEngine`
- `name: "noble"`
- `sign(privateKey, message, options)` — `RsaSchemeOptions` with
`scheme: "pkcs1v15" | "pss"`
- `verify(publicKey, message, signature, options)` — same scheme set
- `encrypt(publicKey, plaintext, options)` — `scheme: "pkcs1v15" | "oaep"`
- `decrypt(privateKey, ciphertext, options)` — same scheme set
- `modPow(base, exponent, modulus): bigint`
## Implementation Notes
- The engine is a stateless singleton — no construction overhead, no
per-call setup.
- Scheme/operation mismatches throw `UNSUPPORTED_FORMAT` synchronously
inside the async wrapper. Defensive by design — the discriminated
union forbids the mismatch at the type level, but runtime callers
can bypass the type system (`as never`).
- `modPow` is exposed to give advanced consumers a single uniform
modpow surface across engines. The implementation matches
`math/modpow` exactly (both delegate to
`@noble/curves/abstract/modular.pow`).
- Sign / verify route through the package's existing functions with
blinding enabled by default; the caller cannot disable blinding via
the engine surface.

View File

@@ -0,0 +1,163 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
RSA_2048_PRIVATE_KEY,
RSA_2048_PUBLIC_KEY,
} from "../__fixtures__/rsa-2048-key.js";
import { CRYPTO_RSA_CODES, type CryptoRsaError } from "../errors/errors.js";
import { nobleEngine } from "./noble.js";
const MESSAGE = new TextEncoder().encode("noble-engine");
describe("nobleEngine", () => {
it("name is 'noble'", () => {
expect(nobleEngine.name).toBe("noble");
});
it("PKCS1v15 sign + verify round-trip", { timeout: 10000 }, async () => {
const sig = await nobleEngine.sign(RSA_2048_PRIVATE_KEY, MESSAGE, {
scheme: "pkcs1v15",
hash: "SHA-256",
});
const ok = await nobleEngine.verify(RSA_2048_PUBLIC_KEY, MESSAGE, sig, {
scheme: "pkcs1v15",
hash: "SHA-256",
});
expect(ok).toBe(true);
});
it("PSS sign + verify round-trip", { timeout: 10000 }, async () => {
const sig = await nobleEngine.sign(RSA_2048_PRIVATE_KEY, MESSAGE, {
scheme: "pss",
hash: "SHA-256",
});
const ok = await nobleEngine.verify(RSA_2048_PUBLIC_KEY, MESSAGE, sig, {
scheme: "pss",
hash: "SHA-256",
});
expect(ok).toBe(true);
});
it("PSS sign + verify with mgfHash + saltLength overrides", {
timeout: 10000,
}, async () => {
const sig = await nobleEngine.sign(RSA_2048_PRIVATE_KEY, MESSAGE, {
scheme: "pss",
hash: "SHA-256",
mgfHash: "SHA-1",
saltLength: 0,
});
const ok = await nobleEngine.verify(RSA_2048_PUBLIC_KEY, MESSAGE, sig, {
scheme: "pss",
hash: "SHA-256",
mgfHash: "SHA-1",
saltLength: 0,
});
expect(ok).toBe(true);
});
it("OAEP encrypt + decrypt round-trip", { timeout: 10000 }, async () => {
const ct = await nobleEngine.encrypt(RSA_2048_PUBLIC_KEY, MESSAGE, {
scheme: "oaep",
hash: "SHA-256",
});
const pt = await nobleEngine.decrypt(RSA_2048_PRIVATE_KEY, ct, {
scheme: "oaep",
hash: "SHA-256",
});
expect(new TextDecoder().decode(pt)).toBe("noble-engine");
});
it("OAEP with mgfHash + label overrides", { timeout: 10000 }, async () => {
const label = new TextEncoder().encode("label-1");
const ct = await nobleEngine.encrypt(RSA_2048_PUBLIC_KEY, MESSAGE, {
scheme: "oaep",
hash: "SHA-256",
mgfHash: "SHA-1",
label,
});
const pt = await nobleEngine.decrypt(RSA_2048_PRIVATE_KEY, ct, {
scheme: "oaep",
hash: "SHA-256",
mgfHash: "SHA-1",
label,
});
expect(new TextDecoder().decode(pt)).toBe("noble-engine");
});
it("PKCS1v15 encrypt + decrypt round-trip", {
timeout: 10000,
}, async () => {
const ct = await nobleEngine.encrypt(RSA_2048_PUBLIC_KEY, MESSAGE, {
scheme: "pkcs1v15",
hash: "SHA-256",
});
const pt = await nobleEngine.decrypt(RSA_2048_PRIVATE_KEY, ct, {
scheme: "pkcs1v15",
hash: "SHA-256",
});
expect(new TextDecoder().decode(pt)).toBe("noble-engine");
});
it("modPow matches the standalone helper", () => {
expect(nobleEngine.modPow(2n, 10n, 1000n)).toBe(24n);
});
it("rejects sign with OAEP scheme", { timeout: 5000 }, async () => {
try {
await nobleEngine.sign(RSA_2048_PRIVATE_KEY, MESSAGE, {
scheme: "oaep",
hash: "SHA-256",
});
throw new Error("should have thrown");
} catch (err) {
expect((err as CryptoRsaError).code).toBe(
CRYPTO_RSA_CODES.UNSUPPORTED_FORMAT
);
}
});
it("rejects verify with OAEP scheme", { timeout: 5000 }, async () => {
try {
await nobleEngine.verify(
RSA_2048_PUBLIC_KEY,
MESSAGE,
new Uint8Array(256),
{ scheme: "oaep", hash: "SHA-256" }
);
throw new Error("should have thrown");
} catch (err) {
expect((err as CryptoRsaError).code).toBe(
CRYPTO_RSA_CODES.UNSUPPORTED_FORMAT
);
}
});
it("rejects encrypt with PSS scheme", { timeout: 5000 }, async () => {
try {
await nobleEngine.encrypt(RSA_2048_PUBLIC_KEY, MESSAGE, {
scheme: "pss",
hash: "SHA-256",
});
throw new Error("should have thrown");
} catch (err) {
expect((err as CryptoRsaError).code).toBe(
CRYPTO_RSA_CODES.UNSUPPORTED_FORMAT
);
}
});
it("rejects decrypt with PSS scheme", { timeout: 5000 }, async () => {
try {
await nobleEngine.decrypt(
RSA_2048_PRIVATE_KEY,
new Uint8Array(256),
{ scheme: "pss", hash: "SHA-256" }
);
throw new Error("should have thrown");
} catch (err) {
expect((err as CryptoRsaError).code).toBe(
CRYPTO_RSA_CODES.UNSUPPORTED_FORMAT
);
}
});
});

View File

@@ -0,0 +1,109 @@
import { rsaDecryptOaep } from "../decrypt/oaep.js";
import { rsaDecryptPkcs1v15 } from "../decrypt/pkcs1v15.js";
import { rsaEncryptOaep } from "../encrypt/oaep.js";
import { rsaEncryptPkcs1v15 } from "../encrypt/pkcs1v15.js";
import { CRYPTO_RSA_CODES, CryptoRsaError } from "../errors/errors.js";
import type { RsaPrivateKey } from "../keys/keys.types.js";
import { modPow } from "../math/modpow.js";
import { rsaSignPkcs1v15 } from "../sign/pkcs1v15.js";
import { rsaSignPss } from "../sign/pss.js";
import { rsaVerifyPkcs1v15 } from "../verify/pkcs1v15.js";
import { rsaVerifyPss } from "../verify/pss.js";
import type { RsaCryptoEngine, RsaSchemeOptions } from "./engine.types.js";
/**
* Default `RsaCryptoEngine` backed by the pure-JS noble primitives this
* package ships natively. Every method routes through the canonical
* `rsa*` functions exported from `./sign`, `./verify`, `./encrypt`,
* `./decrypt` so engine consumers get identical semantics to direct
* callers.
*/
export const nobleEngine: RsaCryptoEngine = {
name: "noble",
async sign(privateKey, message, options) {
if (options.scheme === "pkcs1v15") {
return rsaSignPkcs1v15(privateKey, message, {
hash: options.hash,
});
}
if (options.scheme === "pss") {
return rsaSignPss(privateKey, message, {
hash: options.hash,
...(options.mgfHash !== undefined && {
mgfHash: options.mgfHash,
}),
...(options.saltLength !== undefined && {
saltLength: options.saltLength,
}),
});
}
throw schemeMismatch("sign", options);
},
async verify(publicKey, message, signature, options) {
if (options.scheme === "pkcs1v15") {
return rsaVerifyPkcs1v15(publicKey, message, signature, {
hash: options.hash,
});
}
if (options.scheme === "pss") {
return rsaVerifyPss(publicKey, message, signature, {
hash: options.hash,
...(options.mgfHash !== undefined && {
mgfHash: options.mgfHash,
}),
...(options.saltLength !== undefined && {
saltLength: options.saltLength,
}),
});
}
throw schemeMismatch("verify", options);
},
async encrypt(publicKey, plaintext, options) {
if (options.scheme === "pkcs1v15") {
return rsaEncryptPkcs1v15(publicKey, plaintext, {});
}
if (options.scheme === "oaep") {
return rsaEncryptOaep(publicKey, plaintext, {
hash: options.hash,
...(options.mgfHash !== undefined && {
mgfHash: options.mgfHash,
}),
...(options.label !== undefined && {
label: options.label,
}),
});
}
throw schemeMismatch("encrypt", options);
},
async decrypt(privateKey: RsaPrivateKey, ciphertext, options) {
if (options.scheme === "pkcs1v15") {
return rsaDecryptPkcs1v15(privateKey, ciphertext, {});
}
if (options.scheme === "oaep") {
return rsaDecryptOaep(privateKey, ciphertext, {
hash: options.hash,
...(options.mgfHash !== undefined && {
mgfHash: options.mgfHash,
}),
...(options.label !== undefined && {
label: options.label,
}),
});
}
throw schemeMismatch("decrypt", options);
},
modPow(base, exponent, modulus) {
return modPow(base, exponent, modulus);
},
};
function schemeMismatch(
operation: string,
options: RsaSchemeOptions
): CryptoRsaError {
return new CryptoRsaError(
CRYPTO_RSA_CODES.UNSUPPORTED_FORMAT,
"error.crypto-rsa.engine.scheme-mismatch",
{ context: { operation, scheme: options.scheme } }
);
}

View File

@@ -0,0 +1,76 @@
# engine/select
## Purpose
Resolve the default `RsaCryptoEngine` for the host runtime. When
`globalThis.crypto.subtle` exposes the RSA algorithms this package
needs, `subtleEngine` is returned for hardware-accelerated performance;
otherwise the pure-JS `nobleEngine` is used. Increments
`crypto_rsa.engine_selected_total{engine}` on every call so production
dashboards can track engine distribution per deployment.
## Features
- Auto-detects `subtle` availability at runtime
- `prefer: "noble"` forces the pure-JS engine even when subtle is
available (useful for deterministic-test scenarios)
- `prefer: "subtle"` forces subtle and throws `ENGINE_UNAVAILABLE` if
absent (caller commits to subtle, surfaces failure synchronously)
- Counter increments on every call regardless of branch taken
## Usage: Basic Example
```ts
import { defaultEngine } from "@suaveplan/crypto-rsa/engine";
const engine = defaultEngine();
const sig = await engine.sign(privateKey, message, {
scheme: "pkcs1v15",
hash: "SHA-256",
});
```
## Usage: Advanced Example
Force noble in test environments to make signature outputs reproducible
(subtle's RSA-PSS internally generates the salt and is non-determinstic;
noble's PSS path accepts a custom `Rng`):
```ts
import { defaultEngine } from "@suaveplan/crypto-rsa/engine";
const engine = defaultEngine({ prefer: "noble" });
// Reproducible PSS signatures.
```
## API Reference
### `defaultEngine(options?): RsaCryptoEngine`
- `options.prefer?: "noble" | "subtle"` — forces the engine choice
- **returns** `RsaCryptoEngine`
- **throws** `CryptoRsaError(CRYPTO_RSA.ENGINE_UNAVAILABLE)` when
`prefer: "subtle"` is set and `globalThis.crypto.subtle` is missing
### `DefaultEngineOptions`
- `prefer?: RsaEngineName`
### `resetEngineSelectionCounterForTesting()`
Test-only helper.
## Implementation Notes
- The selection rule is: if `prefer` is set, use it (subtle requested
but missing → throw); otherwise prefer subtle, fall back to noble.
The counter records the actual choice made, not the request.
- Detecting subtle availability is a single `globalThis.crypto.subtle`
read. Cached lookups would be incorrect — runtimes can polyfill
subtle mid-process (e.g. test setup adding a Web Crypto polyfill).
- The counter cardinality is bounded to two label values
(`noble`/`subtle`) so dashboards aggregate cleanly.
- This selector is the recommended default entry point for engine
consumers; calling `nobleEngine` / `subtleEngine` directly is
appropriate only when the caller has committed to a specific engine
for the lifetime of the operation.

View File

@@ -0,0 +1,90 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { CRYPTO_RSA_CODES, type CryptoRsaError } from "../errors/errors.js";
import {
defaultEngine,
resetEngineSelectionCounterForTesting,
} from "./select.js";
const SUBTLE_AVAILABLE =
(globalThis as { crypto?: { subtle?: unknown } }).crypto?.subtle !==
undefined;
describe("defaultEngine", () => {
it("auto-selects subtle when present", () => {
if (!SUBTLE_AVAILABLE) {
return;
}
resetEngineSelectionCounterForTesting();
const engine = defaultEngine();
expect(engine.name).toBe("subtle");
});
it("forces noble when prefer: 'noble'", () => {
const engine = defaultEngine({ prefer: "noble" });
expect(engine.name).toBe("noble");
});
it("forces subtle when prefer: 'subtle' (and subtle is available)", () => {
if (!SUBTLE_AVAILABLE) {
return;
}
const engine = defaultEngine({ prefer: "subtle" });
expect(engine.name).toBe("subtle");
});
it("falls back to noble when subtle is unavailable and not preferred", () => {
const original = (
globalThis as unknown as { crypto?: { subtle?: unknown } }
).crypto;
const stub = { ...(original ?? {}), subtle: undefined };
Object.defineProperty(globalThis, "crypto", {
value: stub,
configurable: true,
writable: true,
});
try {
const engine = defaultEngine();
expect(engine.name).toBe("noble");
} finally {
Object.defineProperty(globalThis, "crypto", {
value: original,
configurable: true,
writable: true,
});
}
});
it("throws ENGINE_UNAVAILABLE when prefer: 'subtle' is set and subtle is missing", () => {
const original = (
globalThis as unknown as { crypto?: { subtle?: unknown } }
).crypto;
const stub = { ...(original ?? {}), subtle: undefined };
Object.defineProperty(globalThis, "crypto", {
value: stub,
configurable: true,
writable: true,
});
try {
try {
defaultEngine({ prefer: "subtle" });
throw new Error("should have thrown");
} catch (err) {
expect((err as CryptoRsaError).code).toBe(
CRYPTO_RSA_CODES.ENGINE_UNAVAILABLE
);
}
} finally {
Object.defineProperty(globalThis, "crypto", {
value: original,
configurable: true,
writable: true,
});
}
});
it("counter helper resets cleanly", () => {
resetEngineSelectionCounterForTesting();
const engine = defaultEngine({ prefer: "noble" });
expect(engine.name).toBe("noble");
});
});

View File

@@ -0,0 +1,63 @@
import { type Counter, metrics } from "@opentelemetry/api";
import type { RsaCryptoEngine, RsaEngineName } from "./engine.types.js";
import { nobleEngine } from "./noble.js";
import { subtleEngine } from "./subtle.js";
let cachedSelectedCounter: Counter | undefined;
function selectedCounter(): Counter {
if (cachedSelectedCounter !== undefined) {
return cachedSelectedCounter;
}
cachedSelectedCounter = metrics
.getMeter("@suaveplan/crypto-rsa")
.createCounter("crypto_rsa.engine_selected_total");
return cachedSelectedCounter;
}
/**
* Reset the cached engine-selection counter (test-only).
*/
export function resetEngineSelectionCounterForTesting(): void {
cachedSelectedCounter = undefined;
}
/**
* Optional input for `defaultEngine`. `prefer` lets a caller force a
* specific engine even when the alternative is available; absence of
* the field defers to runtime auto-selection (subtle if present,
* noble otherwise).
*/
export interface DefaultEngineOptions {
readonly prefer?: RsaEngineName;
}
/**
* Resolve the default `RsaCryptoEngine` for the host runtime. When
* `globalThis.crypto.subtle` exposes the WebCrypto algorithms this
* package needs, `subtleEngine` is returned; otherwise the pure-JS
* `nobleEngine` is used. Increments
* `crypto_rsa.engine_selected_total{engine}` so dashboards can
* track per-runtime engine distribution.
*
* Pass `{ prefer: "subtle" }` to force subtle (will throw
* `ENGINE_UNAVAILABLE` if absent). Pass `{ prefer: "noble" }` to
* force noble even when subtle is available.
*/
export function defaultEngine(
options: DefaultEngineOptions = {}
): RsaCryptoEngine {
const subtleAvailable =
(globalThis as { crypto?: { subtle?: unknown } }).crypto?.subtle !==
undefined;
const choice =
options.prefer === "noble"
? "noble"
: options.prefer === "subtle" || subtleAvailable
? "subtle"
: "noble";
selectedCounter().add(1, { engine: choice });
if (choice === "subtle") {
return subtleEngine();
}
return nobleEngine;
}

View File

@@ -0,0 +1,96 @@
# engine/subtle
## Purpose
`RsaCryptoEngine` adapter over `globalThis.crypto.subtle`. When the
host runtime exposes WebCrypto, this engine routes RSA operations
through the platform-native (often hardware-accelerated) implementation
— faster than pure-JS modpow on every modern runtime (browsers, Node
≥ 16, Bun, Deno, Cloudflare Workers).
## Features
- Implements every method on `RsaCryptoEngine`
- Maps each scheme to the matching subtle algorithm (`RSASSA-PKCS1-v1_5`,
`RSA-PSS`, `RSA-OAEP`)
- Imports keys via JWK so the canonical `RsaPublicKey` / `RsaPrivateKey`
shape works without manual key marshalling
- `extractable: false` by default — the imported `CryptoKey` cannot be
exported back to raw bytes
- `extractable: true` opt-in increments `crypto_rsa.subtle_extractable_total`
so production dashboards can flag the unsafe path
- Throws `ENGINE_UNAVAILABLE` synchronously when subtle is missing —
caller can fall back to noble immediately
- Rejects `encrypt({ scheme: "pkcs1v15" })` and `decrypt(...)` —
WebCrypto deliberately removed RSA-PKCS1-v1\_5 encryption from the
standard (Bleichenbacher defence at the spec level)
## Usage: Basic Example
```ts
import { subtleEngine } from "@suaveplan/crypto-rsa/engine";
const eng = subtleEngine();
const sig = await eng.sign(privateKey, message, {
scheme: "pkcs1v15",
hash: "SHA-256",
});
const ok = await eng.verify(publicKey, message, sig, {
scheme: "pkcs1v15",
hash: "SHA-256",
});
```
## Usage: Advanced Example
For workflows that need `extractable: true` (e.g. exporting keys to
non-WebCrypto runtimes mid-test):
```ts
import { subtleEngine } from "@suaveplan/crypto-rsa/engine";
const eng = subtleEngine({ extractable: true });
// Note: every call increments crypto_rsa.subtle_extractable_total —
// production dashboards flag this as unsafe.
const sig = await eng.sign(privateKey, message, {
scheme: "pss",
hash: "SHA-512",
saltLength: 64,
});
```
## API Reference
### `subtleEngine(options?): RsaCryptoEngine`
- `options.extractable?: boolean` — defaults to `false`; setting `true`
imports keys with the `extractable` bit set and emits the
`crypto_rsa.subtle_extractable_total` counter
- **returns** `RsaCryptoEngine` with `name: "subtle"`
- **throws** `CryptoRsaError(CRYPTO_RSA.ENGINE_UNAVAILABLE)` when
`globalThis.crypto.subtle` is undefined
### `SubtleEngineOptions`
- `extractable?: boolean`
### `resetSubtleCountersForTesting()`
Test-only helper.
## Implementation Notes
- WebCrypto's `RSA-PSS` requires `saltLength` at sign/verify time; this
adapter defaults to `hashOutputBytes(hash)` (the RFC 8017 §8.1
recommendation) when the caller omits it.
- `RSA-OAEP` accepts an optional `label` (`OAEP-DSP` "P" parameter);
when omitted, subtle uses the empty label automatically.
- `RSASSA-PKCS1-v1_5` for encryption is deliberately rejected — the
WebCrypto spec removed it after the Bleichenbacher 1998 / 2001
attacks. Use OAEP for encryption with subtle; pure-JS PKCS1v15
encrypt remains available via the noble engine for legacy interop.
- `modPow` falls back to `math/modpow` (which routes through noble) —
WebCrypto exposes no standalone `modPow` primitive.
- `importKey` runs every call. For very-high-frequency callers, build
a key cache outside the engine; this adapter prioritises correctness
and statelessness over import-amortisation.

View File

@@ -0,0 +1,300 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
RSA_2048_PRIVATE_KEY,
RSA_2048_PUBLIC_KEY,
} from "../__fixtures__/rsa-2048-key.js";
import { CRYPTO_RSA_CODES, type CryptoRsaError } from "../errors/errors.js";
import { resetSubtleCountersForTesting, subtleEngine } from "./subtle.js";
const SUBTLE_AVAILABLE =
(globalThis as { crypto?: { subtle?: unknown } }).crypto?.subtle !==
undefined;
const MESSAGE = new TextEncoder().encode("subtle-engine");
describe("subtleEngine — when subtle is available", () => {
it("name is 'subtle'", () => {
if (!SUBTLE_AVAILABLE) {
return;
}
expect(subtleEngine().name).toBe("subtle");
});
it("PKCS1v15 sign + verify round-trip", { timeout: 10000 }, async () => {
if (!SUBTLE_AVAILABLE) {
return;
}
const eng = subtleEngine();
const sig = await eng.sign(RSA_2048_PRIVATE_KEY, MESSAGE, {
scheme: "pkcs1v15",
hash: "SHA-256",
});
const ok = await eng.verify(RSA_2048_PUBLIC_KEY, MESSAGE, sig, {
scheme: "pkcs1v15",
hash: "SHA-256",
});
expect(ok).toBe(true);
});
it("PSS sign + verify round-trip", { timeout: 10000 }, async () => {
if (!SUBTLE_AVAILABLE) {
return;
}
const eng = subtleEngine();
const sig = await eng.sign(RSA_2048_PRIVATE_KEY, MESSAGE, {
scheme: "pss",
hash: "SHA-256",
saltLength: 32,
});
const ok = await eng.verify(RSA_2048_PUBLIC_KEY, MESSAGE, sig, {
scheme: "pss",
hash: "SHA-256",
saltLength: 32,
});
expect(ok).toBe(true);
});
it("OAEP encrypt + decrypt round-trip", { timeout: 10000 }, async () => {
if (!SUBTLE_AVAILABLE) {
return;
}
const eng = subtleEngine();
const ct = await eng.encrypt(RSA_2048_PUBLIC_KEY, MESSAGE, {
scheme: "oaep",
hash: "SHA-256",
});
const pt = await eng.decrypt(RSA_2048_PRIVATE_KEY, ct, {
scheme: "oaep",
hash: "SHA-256",
});
expect(new TextDecoder().decode(pt)).toBe("subtle-engine");
});
it("OAEP with explicit label round-trip", { timeout: 10000 }, async () => {
if (!SUBTLE_AVAILABLE) {
return;
}
const eng = subtleEngine();
const label = new TextEncoder().encode("ctx");
const ct = await eng.encrypt(RSA_2048_PUBLIC_KEY, MESSAGE, {
scheme: "oaep",
hash: "SHA-256",
label,
});
const pt = await eng.decrypt(RSA_2048_PRIVATE_KEY, ct, {
scheme: "oaep",
hash: "SHA-256",
label,
});
expect(new TextDecoder().decode(pt)).toBe("subtle-engine");
});
it("modPow falls back to noble", () => {
if (!SUBTLE_AVAILABLE) {
return;
}
const eng = subtleEngine();
expect(eng.modPow(2n, 10n, 1000n)).toBe(24n);
});
it("extractable: true increments the counter", { timeout: 5000 }, () => {
if (!SUBTLE_AVAILABLE) {
return;
}
resetSubtleCountersForTesting();
const eng = subtleEngine({ extractable: true });
expect(eng.name).toBe("subtle");
// Reset once more for clean post-test state.
resetSubtleCountersForTesting();
});
it("rejects encrypt with PKCS1v15 scheme", {
timeout: 5000,
}, async () => {
if (!SUBTLE_AVAILABLE) {
return;
}
const eng = subtleEngine();
try {
await eng.encrypt(RSA_2048_PUBLIC_KEY, MESSAGE, {
scheme: "pkcs1v15",
hash: "SHA-256",
});
throw new Error("should have thrown");
} catch (err) {
expect((err as CryptoRsaError).code).toBe(
CRYPTO_RSA_CODES.UNSUPPORTED_FORMAT
);
}
});
it("rejects decrypt with PKCS1v15 scheme", {
timeout: 5000,
}, async () => {
if (!SUBTLE_AVAILABLE) {
return;
}
const eng = subtleEngine();
try {
await eng.decrypt(RSA_2048_PRIVATE_KEY, new Uint8Array(256), {
scheme: "pkcs1v15",
hash: "SHA-256",
});
throw new Error("should have thrown");
} catch (err) {
expect((err as CryptoRsaError).code).toBe(
CRYPTO_RSA_CODES.UNSUPPORTED_FORMAT
);
}
});
it("rejects sign with OAEP scheme", { timeout: 5000 }, async () => {
if (!SUBTLE_AVAILABLE) {
return;
}
const eng = subtleEngine();
try {
await eng.sign(RSA_2048_PRIVATE_KEY, MESSAGE, {
scheme: "oaep",
hash: "SHA-256",
});
throw new Error("should have thrown");
} catch (err) {
expect((err as CryptoRsaError).code).toBe(
CRYPTO_RSA_CODES.UNSUPPORTED_FORMAT
);
}
});
it("rejects verify with OAEP scheme", { timeout: 5000 }, async () => {
if (!SUBTLE_AVAILABLE) {
return;
}
const eng = subtleEngine();
try {
await eng.verify(
RSA_2048_PUBLIC_KEY,
MESSAGE,
new Uint8Array(256),
{ scheme: "oaep", hash: "SHA-256" }
);
throw new Error("should have thrown");
} catch (err) {
expect((err as CryptoRsaError).code).toBe(
CRYPTO_RSA_CODES.UNSUPPORTED_FORMAT
);
}
});
it("PSS without explicit saltLength defaults to hLen", {
timeout: 10000,
}, async () => {
if (!SUBTLE_AVAILABLE) {
return;
}
const eng = subtleEngine();
const sig = await eng.sign(RSA_2048_PRIVATE_KEY, MESSAGE, {
scheme: "pss",
hash: "SHA-256",
});
const ok = await eng.verify(RSA_2048_PUBLIC_KEY, MESSAGE, sig, {
scheme: "pss",
hash: "SHA-256",
});
expect(ok).toBe(true);
});
it("PSS with SHA-384 default-salt round-trips", {
timeout: 10000,
}, async () => {
if (!SUBTLE_AVAILABLE) {
return;
}
const eng = subtleEngine();
const sig = await eng.sign(RSA_2048_PRIVATE_KEY, MESSAGE, {
scheme: "pss",
hash: "SHA-384",
});
const ok = await eng.verify(RSA_2048_PUBLIC_KEY, MESSAGE, sig, {
scheme: "pss",
hash: "SHA-384",
});
expect(ok).toBe(true);
});
it("PSS with SHA-512 default-salt round-trips", {
timeout: 10000,
}, async () => {
if (!SUBTLE_AVAILABLE) {
return;
}
const eng = subtleEngine();
const sig = await eng.sign(RSA_2048_PRIVATE_KEY, MESSAGE, {
scheme: "pss",
hash: "SHA-512",
});
const ok = await eng.verify(RSA_2048_PUBLIC_KEY, MESSAGE, sig, {
scheme: "pss",
hash: "SHA-512",
});
expect(ok).toBe(true);
});
it("PSS with SHA-1 default-salt round-trips (covers hashOutputBytes SHA-1 branch)", {
timeout: 10000,
}, async () => {
if (!SUBTLE_AVAILABLE) {
return;
}
const eng = subtleEngine();
const sig = await eng.sign(RSA_2048_PRIVATE_KEY, MESSAGE, {
scheme: "pss",
hash: "SHA-1",
});
const ok = await eng.verify(RSA_2048_PUBLIC_KEY, MESSAGE, sig, {
scheme: "pss",
hash: "SHA-1",
});
expect(ok).toBe(true);
});
it("extractable counter caches across calls", { timeout: 5000 }, () => {
if (!SUBTLE_AVAILABLE) {
return;
}
const e1 = subtleEngine({ extractable: true });
const e2 = subtleEngine({ extractable: true });
expect(e1.name).toBe("subtle");
expect(e2.name).toBe("subtle");
});
});
describe("subtleEngine — when subtle is unavailable", () => {
it("throws ENGINE_UNAVAILABLE", () => {
const original = (
globalThis as unknown as { crypto?: { subtle?: unknown } }
).crypto;
const stub = { ...(original ?? {}), subtle: undefined };
Object.defineProperty(globalThis, "crypto", {
value: stub,
configurable: true,
writable: true,
});
try {
try {
subtleEngine();
throw new Error("should have thrown");
} catch (err) {
expect((err as CryptoRsaError).code).toBe(
CRYPTO_RSA_CODES.ENGINE_UNAVAILABLE
);
}
} finally {
Object.defineProperty(globalThis, "crypto", {
value: original,
configurable: true,
writable: true,
});
}
});
});

View File

@@ -0,0 +1,269 @@
import { type Counter, metrics } from "@opentelemetry/api";
import { CRYPTO_RSA_CODES, CryptoRsaError } from "../errors/errors.js";
import type {
RsaHashAlgorithm,
RsaPrivateKey,
RsaPublicKey,
} from "../keys/keys.types.js";
import {
serializeRsaPrivateKeyJwk,
serializeRsaPublicKeyJwk,
} from "../keys/serialize-jwk.js";
import { modPow } from "../math/modpow.js";
import type { RsaCryptoEngine, RsaSchemeOptions } from "./engine.types.js";
/**
* Optional configuration for the subtle engine. `extractable: true`
* imports keys with the `extractable` WebCrypto bit set — useful for
* test-suite key-export round-trips, but production callers should
* leave it at `false` (default) so the runtime cannot leak the raw key
* bytes after import.
*/
export interface SubtleEngineOptions {
readonly extractable?: boolean;
}
const HASH_TO_SUBTLE_NAME: Readonly<Record<RsaHashAlgorithm, string>> = {
"SHA-1": "SHA-1",
"SHA-256": "SHA-256",
"SHA-384": "SHA-384",
"SHA-512": "SHA-512",
};
let cachedExtractableCounter: Counter | undefined;
function extractableCounter(): Counter {
if (cachedExtractableCounter !== undefined) {
return cachedExtractableCounter;
}
cachedExtractableCounter = metrics
.getMeter("@suaveplan/crypto-rsa")
.createCounter("crypto_rsa.subtle_extractable_total");
return cachedExtractableCounter;
}
/**
* Reset the cached extractable counter (test-only).
*/
export function resetSubtleCountersForTesting(): void {
cachedExtractableCounter = undefined;
}
/**
* Build an `RsaCryptoEngine` backed by `globalThis.crypto.subtle`.
* Throws `ENGINE_UNAVAILABLE` synchronously when the host runtime
* doesn't expose `subtle`. Each operation imports the key into a
* non-extractable `CryptoKey` (or extractable when explicitly opted in
* via `{ extractable: true }`, with a counter increment so dashboards
* can flag the unsafe path).
*/
export function subtleEngine(
options: SubtleEngineOptions = {}
): RsaCryptoEngine {
const subtle = (globalThis as { crypto?: { subtle?: SubtleCrypto } }).crypto
?.subtle;
if (subtle === undefined) {
throw new CryptoRsaError(
CRYPTO_RSA_CODES.ENGINE_UNAVAILABLE,
"error.crypto-rsa.engine.subtle-unavailable"
);
}
const extractable = options.extractable === true;
if (extractable) {
extractableCounter().add(1, {});
}
return {
name: "subtle",
async sign(privateKey, message, schemeOptions) {
const narrowed = narrowSignVerifyScheme(schemeOptions);
const algo = signOrVerifyAlgorithm(narrowed);
const key = await importPrivateForSign(
subtle,
privateKey,
narrowed,
extractable
);
const sig = await subtle.sign(
algo as AlgorithmIdentifier,
key,
message as BufferSource
);
return new Uint8Array(sig);
},
async verify(publicKey, message, signature, schemeOptions) {
const narrowed = narrowSignVerifyScheme(schemeOptions);
const algo = signOrVerifyAlgorithm(narrowed);
const key = await importPublicForVerify(
subtle,
publicKey,
narrowed,
extractable
);
return subtle.verify(
algo as AlgorithmIdentifier,
key,
signature as BufferSource,
message as BufferSource
);
},
async encrypt(publicKey, plaintext, schemeOptions) {
if (schemeOptions.scheme !== "oaep") {
throw new CryptoRsaError(
CRYPTO_RSA_CODES.UNSUPPORTED_FORMAT,
"error.crypto-rsa.engine.subtle-encrypt-pkcs1v15",
{ context: { scheme: schemeOptions.scheme } }
);
}
const key = await subtle.importKey(
"jwk",
serializeRsaPublicKeyJwk(publicKey) as JsonWebKey,
{
name: "RSA-OAEP",
hash: HASH_TO_SUBTLE_NAME[schemeOptions.hash],
},
extractable,
["encrypt"]
);
const ct = await subtle.encrypt(
rsaOaepAlgorithm(schemeOptions.label),
key,
plaintext as BufferSource
);
return new Uint8Array(ct);
},
async decrypt(privateKey, ciphertext, schemeOptions) {
if (schemeOptions.scheme !== "oaep") {
throw new CryptoRsaError(
CRYPTO_RSA_CODES.UNSUPPORTED_FORMAT,
"error.crypto-rsa.engine.subtle-decrypt-pkcs1v15",
{ context: { scheme: schemeOptions.scheme } }
);
}
const key = await subtle.importKey(
"jwk",
serializeRsaPrivateKeyJwk(privateKey) as JsonWebKey,
{
name: "RSA-OAEP",
hash: HASH_TO_SUBTLE_NAME[schemeOptions.hash],
},
extractable,
["decrypt"]
);
const pt = await subtle.decrypt(
rsaOaepAlgorithm(schemeOptions.label),
key,
ciphertext as BufferSource
);
return new Uint8Array(pt);
},
modPow(base, exponent, modulus) {
// Subtle has no exposed modPow — fall back to noble.
return modPow(base, exponent, modulus);
},
};
}
interface NarrowedSignVerify {
readonly scheme: "pkcs1v15" | "pss";
readonly hash: RsaHashAlgorithm;
readonly saltLength?: number;
}
function narrowSignVerifyScheme(options: RsaSchemeOptions): NarrowedSignVerify {
if (options.scheme !== "pkcs1v15" && options.scheme !== "pss") {
throw new CryptoRsaError(
CRYPTO_RSA_CODES.UNSUPPORTED_FORMAT,
"error.crypto-rsa.engine.subtle-scheme-mismatch",
{ context: { scheme: options.scheme } }
);
}
if (options.scheme === "pkcs1v15") {
return { scheme: "pkcs1v15", hash: options.hash };
}
return options.saltLength === undefined
? { scheme: "pss", hash: options.hash }
: {
scheme: "pss",
hash: options.hash,
saltLength: options.saltLength,
};
}
function signOrVerifyAlgorithm(options: NarrowedSignVerify): {
name: string;
hash?: string;
saltLength?: number;
} {
if (options.scheme === "pkcs1v15") {
return {
name: "RSASSA-PKCS1-v1_5",
hash: HASH_TO_SUBTLE_NAME[options.hash],
};
}
const saltLength = options.saltLength ?? hashOutputBytes(options.hash);
return { name: "RSA-PSS", saltLength };
}
const HLEN_BY_HASH: Readonly<Record<RsaHashAlgorithm, number>> = {
"SHA-1": 20,
"SHA-256": 32,
"SHA-384": 48,
"SHA-512": 64,
};
function hashOutputBytes(hash: RsaHashAlgorithm): number {
return HLEN_BY_HASH[hash];
}
function rsaOaepAlgorithm(label: Uint8Array | undefined): {
name: string;
label?: BufferSource;
} {
if (label === undefined) {
return { name: "RSA-OAEP" };
}
return { name: "RSA-OAEP", label: label as BufferSource };
}
// signOrVerifyAlgorithm runs first inside sign/verify, narrowing the
// scheme to "pkcs1v15" | "pss" before the import helpers run; the
// helpers therefore receive only those two schemes and don't repeat
// the rejection.
async function importPrivateForSign(
subtle: SubtleCrypto,
privateKey: RsaPrivateKey,
schemeOptions: { scheme: "pkcs1v15" | "pss"; hash: RsaHashAlgorithm },
extractable: boolean
): Promise<CryptoKey> {
const algName =
schemeOptions.scheme === "pkcs1v15" ? "RSASSA-PKCS1-v1_5" : "RSA-PSS";
return subtle.importKey(
"jwk",
serializeRsaPrivateKeyJwk(privateKey) as JsonWebKey,
{
name: algName,
hash: HASH_TO_SUBTLE_NAME[schemeOptions.hash],
} as RsaHashedImportParams,
extractable,
["sign"]
);
}
async function importPublicForVerify(
subtle: SubtleCrypto,
publicKey: RsaPublicKey,
schemeOptions: { scheme: "pkcs1v15" | "pss"; hash: RsaHashAlgorithm },
extractable: boolean
): Promise<CryptoKey> {
const algName =
schemeOptions.scheme === "pkcs1v15" ? "RSASSA-PKCS1-v1_5" : "RSA-PSS";
return subtle.importKey(
"jwk",
serializeRsaPublicKeyJwk(publicKey) as JsonWebKey,
{
name: algName,
hash: HASH_TO_SUBTLE_NAME[schemeOptions.hash],
} as RsaHashedImportParams,
extractable,
["verify"]
);
}

View File

@@ -0,0 +1,87 @@
# `errors`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines the canonical error registry and concrete `CryptoRsaError` class for the entire
`@suaveplan/crypto-rsa` package. The 17-code surface is declared up-front so consumer
switches over `error.code` are exhaustive even before later stages of the package wire
up the production paths that raise the codes.
This module is a pure registry — no runtime behaviour beyond constructing errors. Every
public-API entry-point in `@suaveplan/crypto-rsa` ultimately throws a `CryptoRsaError`,
never a bare `Error`, so consumers get stable codes and i18n message keys.
## Features
- Frozen `CRYPTO_RSA_CODES` literal-typed map produced via `defineCodes("CRYPTO_RSA", …)`.
- Exhaustive 17-member `CryptoRsaCode` discriminated union covering every code Stages
1-4 of the proposal will raise.
- `CryptoRsaError` subclass extending `SuaveplanError<CryptoRsaCode>` so `instanceof`
narrows the code union for error handlers.
- Stable `code` strings (`CRYPTO_RSA.<REASON>`) safe to log, count, and switch on.
## Usage: Basic Example
```ts
import { CRYPTO_RSA_CODES, CryptoRsaError } from "@suaveplan/crypto-rsa/errors";
throw new CryptoRsaError(
CRYPTO_RSA_CODES.INVALID_KEY,
"error.crypto-rsa.invalid-key"
);
```
## Usage: Advanced Example
```ts
import { CRYPTO_RSA_CODES, CryptoRsaError } from "@suaveplan/crypto-rsa/errors";
try {
parseRsaPublicKey(bytes, { format: "pkcs1" });
} catch (error) {
if (error instanceof CryptoRsaError) {
switch (error.code) {
case CRYPTO_RSA_CODES.INVALID_KEY:
metrics.invalidKey.add(1);
break;
case CRYPTO_RSA_CODES.UNSUPPORTED_FORMAT:
metrics.unsupportedFormat.add(1);
break;
default:
metrics.otherError.add(1, { code: error.code });
}
}
throw error;
}
```
## API Reference
### `CRYPTO_RSA_CODES`
Frozen object mapping each reason string (e.g. `"INVALID_KEY"`) to its fully-qualified
code (`"CRYPTO_RSA.INVALID_KEY"`). Indexable by reason key for type-safe access.
### `type CryptoRsaCode`
Union of every value in `CRYPTO_RSA_CODES`. Use as the discriminant on switch statements.
### `class CryptoRsaError extends SuaveplanError<CryptoRsaCode>`
Constructor accepts `(code, messageKey, options?)`. `options` follows
`SuaveplanErrorOptions` (`context`, `cause`, `traceId`, `clock`).
## Implementation Notes
The full registry is declared even though only five codes
(`MODULUS_TOO_SHORT`, `MODULUS_TOO_LARGE`, `INVALID_KEY`, `UNSUPPORTED_FORMAT`,
`PEM_MALFORMED`) are reachable from production code yet. The remaining 12 codes
(`INVALID_SIGNATURE`, `SIGNATURE_INVALID`, `UNSUPPORTED_HASH`, `DECRYPT_FAILED`,
`MGF_FAILURE`, `KEYGEN_FAILED`, `PRIME_INVALID`, `KEY_OP_NOT_PERMITTED`,
`PROFILE_VIOLATION`, `ENGINE_UNAVAILABLE`, `FINGERPRINT_FAILED`, `ABORTED`)
become reachable as Stages 2-4 land sign/verify/encrypt/decrypt/keygen/engine/profile
modules. Declaring them now keeps the public surface stable and prevents downstream
consumers from having to recompile their switch statements.

View File

@@ -0,0 +1,76 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
CRYPTO_RSA_CODES,
type CryptoRsaCode,
CryptoRsaError,
} from "./errors.js";
describe("CRYPTO_RSA_CODES", () => {
it("exposes every spec-pinned code with the CRYPTO_RSA prefix", {
timeout: 5000,
}, () => {
const expected: CryptoRsaCode[] = [
CRYPTO_RSA_CODES.MODULUS_TOO_SHORT,
CRYPTO_RSA_CODES.MODULUS_TOO_LARGE,
CRYPTO_RSA_CODES.INVALID_KEY,
CRYPTO_RSA_CODES.INVALID_SIGNATURE,
CRYPTO_RSA_CODES.SIGNATURE_INVALID,
CRYPTO_RSA_CODES.UNSUPPORTED_HASH,
CRYPTO_RSA_CODES.UNSUPPORTED_FORMAT,
CRYPTO_RSA_CODES.DECRYPT_FAILED,
CRYPTO_RSA_CODES.MGF_FAILURE,
CRYPTO_RSA_CODES.KEYGEN_FAILED,
CRYPTO_RSA_CODES.PRIME_INVALID,
CRYPTO_RSA_CODES.KEY_OP_NOT_PERMITTED,
CRYPTO_RSA_CODES.PROFILE_VIOLATION,
CRYPTO_RSA_CODES.ENGINE_UNAVAILABLE,
CRYPTO_RSA_CODES.PEM_MALFORMED,
CRYPTO_RSA_CODES.FINGERPRINT_FAILED,
CRYPTO_RSA_CODES.ABORTED,
];
expect(expected).toHaveLength(17);
for (const code of expected) {
expect(code.startsWith("CRYPTO_RSA.")).toBe(true);
}
});
it("freezes the codes object", { timeout: 5000 }, () => {
expect(Object.isFrozen(CRYPTO_RSA_CODES)).toBe(true);
});
});
describe("CryptoRsaError", () => {
it("carries the code field and instanceof narrows", {
timeout: 5000,
}, () => {
const error = new CryptoRsaError(
CRYPTO_RSA_CODES.INVALID_KEY,
"test.message-key"
);
expect(error).toBeInstanceOf(CryptoRsaError);
expect(error.code).toBe(CRYPTO_RSA_CODES.INVALID_KEY);
expect(error.name).toBe("CryptoRsaError");
});
it("passes options through to SuaveplanError", { timeout: 5000 }, () => {
const cause = new Error("inner");
const error = new CryptoRsaError(
CRYPTO_RSA_CODES.PEM_MALFORMED,
"test.message-key",
{ cause, context: { detail: "bad armor" } }
);
expect(error.cause).toBe(cause);
expect(error.context).toEqual({ detail: "bad armor" });
});
it("uses the default options bag when none are supplied", {
timeout: 5000,
}, () => {
const error = new CryptoRsaError(
CRYPTO_RSA_CODES.MODULUS_TOO_SHORT,
"test.message-key"
);
expect(error.context).toBeUndefined();
expect(error.timestamp).toBeGreaterThan(0);
});
});

View File

@@ -0,0 +1,53 @@
import type { SuaveplanErrorOptions } from "@suaveplan/error";
import { defineCodes, SuaveplanError } from "@suaveplan/error";
/**
* Frozen registry of error codes raised by `@suaveplan/crypto-rsa`.
*
* The full 17-code surface is declared up-front so consumers can switch on
* the union exhaustively. Stage 1 (the current package release) raises
* `MODULUS_TOO_SHORT`, `MODULUS_TOO_LARGE`, `INVALID_KEY`,
* `UNSUPPORTED_FORMAT`, and `PEM_MALFORMED`. The remaining codes are
* carried for the proposal-mandated future surface (sign/verify/encrypt/
* decrypt, MGF1, key-generation, profile, engine, fingerprint, blinding)
* and become reachable as Stages 2-4 land.
*/
export const CRYPTO_RSA_CODES = 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",
] as const);
export type CryptoRsaCode =
(typeof CRYPTO_RSA_CODES)[keyof typeof CRYPTO_RSA_CODES];
const DEFAULT_OPTIONS: SuaveplanErrorOptions = {};
/**
* Concrete error subclass. All thrown errors in this package are instances
* of `CryptoRsaError`; switching on `error.code` narrows to the
* `CryptoRsaCode` union.
*/
export class CryptoRsaError extends SuaveplanError<CryptoRsaCode> {
public constructor(
code: CryptoRsaCode,
messageKey: string,
options: SuaveplanErrorOptions = DEFAULT_OPTIONS
) {
super(code, messageKey, options);
}
}

View File

@@ -0,0 +1,5 @@
export type { CryptoRsaCode } from "./errors.js";
export {
CRYPTO_RSA_CODES,
CryptoRsaError,
} from "./errors.js";

View File

@@ -0,0 +1,80 @@
# fingerprint/format
## Purpose
Render a fingerprint byte sequence as a human- and machine-readable
string. Provides four output formats covering every common operator
display: `hex` (lowercase, no separators), `base64` (standard alphabet
with `=` padding), `base64url` (URL-safe, no padding), and `colon-hex`
(lowercase hex with `:` between bytes — matches `ssh-keygen -lf` legacy
MD5 output and `gpg --fingerprint`).
## Features
- Four output formats covering every common ops convention
- Increments `crypto_rsa.fingerprint_total{format}` counter on every
call so dashboards can aggregate per-format usage
- Pairs with `computeSpkiFingerprint` for end-to-end fingerprinting
- Throws `CRYPTO_RSA.FINGERPRINT_FAILED` for unknown formats (defensive
guard for callers bypassing the typed union)
## Usage: Basic Example
```ts
import { formatFingerprint } from "@suaveplan/crypto-rsa/fingerprint";
const display = formatFingerprint(digest, "colon-hex");
// "ab:cd:12:34:..."
```
## Usage: Advanced Example
Switch format dynamically based on a runtime configuration option:
```ts
import {
computeSpkiFingerprint,
formatFingerprint,
type FingerprintFormat,
} from "@suaveplan/crypto-rsa/fingerprint";
function displayKey(
key: RsaPublicKey,
format: FingerprintFormat
): string {
const digest = computeSpkiFingerprint(key);
return formatFingerprint(digest, format);
}
```
## API Reference
### `formatFingerprint(bytes, format)`
- `bytes: Uint8Array`
- `format: "hex" | "base64" | "base64url" | "colon-hex"`
- **returns** `string`
- **throws** `CryptoRsaError(CRYPTO_RSA.FINGERPRINT_FAILED)` on unknown
format (defensive — TypeScript narrows the union; only reachable via
type-system bypass)
### `FingerprintFormat`
Union type of the four supported format names.
### `resetFingerprintCounterForTesting()`
Test-only helper. Production code never calls this.
## Implementation Notes
- The counter (`crypto_rsa.fingerprint_total{format}`) is incremented
unconditionally on every call. The format attribute is bounded to
the four-element union so cardinality stays low.
- `colon-hex` is implemented as `toHex(bytes)` plus a 2-character
re-grouping with `:` separators — the same convention used by
`ssh-keygen -lf` for MD5 fingerprints (pre-OpenSSH 6.8).
- The `default` arm of the format switch is reachable only when a
caller deliberately bypasses the type system — defensive guard
rather than load-bearing. Coverage is exercised by the test suite
via `as never` type-narrowing escape.

View File

@@ -0,0 +1,46 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { CRYPTO_RSA_CODES, type CryptoRsaError } from "../errors/errors.js";
import {
type FingerprintFormat,
formatFingerprint,
resetFingerprintCounterForTesting,
} from "./format.js";
const SAMPLE = new Uint8Array([0x00, 0x01, 0xfe, 0xff]);
describe("formatFingerprint", () => {
it("renders hex (lowercase, no separators)", { timeout: 5000 }, () => {
expect(formatFingerprint(SAMPLE, "hex")).toBe("0001feff");
});
it("renders standard base64", { timeout: 5000 }, () => {
expect(formatFingerprint(SAMPLE, "base64")).toBe("AAH+/w==");
});
it("renders URL-safe base64 (no padding)", { timeout: 5000 }, () => {
expect(formatFingerprint(SAMPLE, "base64url")).toBe("AAH-_w");
});
it("renders colon-hex (ssh-keygen style)", { timeout: 5000 }, () => {
expect(formatFingerprint(SAMPLE, "colon-hex")).toBe("00:01:fe:ff");
});
it("rejects unknown format with FINGERPRINT_FAILED", {
timeout: 5000,
}, () => {
try {
formatFingerprint(SAMPLE, "uuencode" as FingerprintFormat);
throw new Error("should have thrown");
} catch (err) {
expect((err as CryptoRsaError).code).toBe(
CRYPTO_RSA_CODES.FINGERPRINT_FAILED
);
}
});
it("counter helper resets cleanly", { timeout: 5000 }, () => {
// Just exercise the reset hook so the test suite covers it.
resetFingerprintCounterForTesting();
expect(formatFingerprint(SAMPLE, "hex")).toBe("0001feff");
});
});

View File

@@ -0,0 +1,72 @@
import { type Counter, metrics } from "@opentelemetry/api";
import { toBase64, toBase64Url, toHex } from "@suaveplan/crypto-utils";
import { CRYPTO_RSA_CODES, CryptoRsaError } from "../errors/errors.js";
/**
* Output formats supported by `formatFingerprint`. `colon-hex` matches
* the `ssh-keygen -lf` legacy MD5 fingerprint convention (lowercase hex
* with `:` between every byte) — useful for ops dashboards mixing
* pre-OpenSSH-6.8 and post-6.8 displays.
*/
export type FingerprintFormat = "hex" | "base64" | "base64url" | "colon-hex";
let cachedCounter: Counter | undefined;
function fingerprintCounter(): Counter {
if (cachedCounter !== undefined) {
return cachedCounter;
}
cachedCounter = metrics
.getMeter("@suaveplan/crypto-rsa")
.createCounter("crypto_rsa.fingerprint_total");
return cachedCounter;
}
/**
* Reset the cached counter (test-only). Production code never invokes
* this; tests that re-install a `MeterProvider` do, so `fingerprintCounter`
* binds to the new provider on next call.
*/
export function resetFingerprintCounterForTesting(): void {
cachedCounter = undefined;
}
/**
* Render a fingerprint byte sequence as a human- and machine-readable
* string. Increments `crypto_rsa.fingerprint_total{format}` on every
* call so dashboards can aggregate per-format usage.
*
* @throws CryptoRsaError(CRYPTO_RSA.FINGERPRINT_FAILED) on unknown format
*/
export function formatFingerprint(
bytes: Uint8Array,
format: FingerprintFormat
): string {
fingerprintCounter().add(1, { format });
if (format === "hex") {
return toHex(bytes);
}
if (format === "base64") {
return toBase64(bytes);
}
if (format === "base64url") {
return toBase64Url(bytes);
}
if (format === "colon-hex") {
return toColonHex(bytes);
}
// Defensive — only reachable if a caller bypasses the type system.
throw new CryptoRsaError(
CRYPTO_RSA_CODES.FINGERPRINT_FAILED,
"error.crypto-rsa.fingerprint.unknown-format",
{ context: { format } }
);
}
function toColonHex(bytes: Uint8Array): string {
const hex = toHex(bytes);
const parts: string[] = [];
for (let i = 0; i < hex.length; i += 2) {
parts.push(hex.slice(i, i + 2));
}
return parts.join(":");
}

View File

@@ -0,0 +1,9 @@
export {
type FingerprintFormat,
formatFingerprint,
resetFingerprintCounterForTesting,
} from "./format.js";
export {
computeSpkiFingerprint,
type SpkiFingerprintOptions,
} from "./spki.js";

View File

@@ -0,0 +1,73 @@
# fingerprint/spki
## Purpose
Compute a deterministic fingerprint of an RSA public key by hashing
its SPKI DER encoding (RFC 5280 §4.1 `SubjectPublicKeyInfo`). The
resulting digest uniquely identifies the key independently of any PEM
armor, JWK packaging, or other transport framing — semantically
equivalent to the `ssh-keygen -lf` fingerprint of an OpenSSH-format
key.
## Features
- Hashes the canonical SPKI DER bytes (RFC 5280)
- Default SHA-256; SHA-1 / SHA-384 / SHA-512 supported
- Wraps SPKI serialiser failures as `FINGERPRINT_FAILED` so callers see
a single failure class regardless of underlying cause
- Pairs with `formatFingerprint` for human-readable output
## Usage: Basic Example
```ts
import { computeSpkiFingerprint } from "@suaveplan/crypto-rsa/fingerprint";
const digest = computeSpkiFingerprint(publicKey);
// 32 bytes (SHA-256)
```
## Usage: Advanced Example
Combine with `formatFingerprint` to render a colon-separated hex
string for ops dashboards and certificate-pinning workflows:
```ts
import {
computeSpkiFingerprint,
formatFingerprint,
} from "@suaveplan/crypto-rsa/fingerprint";
const digest = computeSpkiFingerprint(publicKey, { hash: "SHA-256" });
const display = formatFingerprint(digest, "colon-hex");
// "ab:12:cd:34:..."
```
## API Reference
### `computeSpkiFingerprint(publicKey, options?)`
- `publicKey: RsaPublicKey`
- `options.hash?: RsaHashAlgorithm` — defaults to `"SHA-256"`
- **returns** `Uint8Array` — digest bytes
- **throws** `CryptoRsaError(CRYPTO_RSA.UNSUPPORTED_HASH)` on bad hash;
`CRYPTO_RSA.FINGERPRINT_FAILED` when SPKI serialisation throws (e.g.
malformed `n` / `e`).
### `SpkiFingerprintOptions`
- `hash?: RsaHashAlgorithm`
## Implementation Notes
- The fingerprint is computed over the SPKI DER bytes — NOT the PEM
armor. PEM-armored input produces the same fingerprint as the
underlying DER.
- SHA-256 is the default to match modern OpenSSH conventions
(`SHA256:base64url(...)` since OpenSSH 6.8 / 2015).
- SHA-1 is supported for legacy interop (Ed25519 SPKI fingerprints in
some older TLS pinning configs); production code should default to
SHA-256 or higher.
- Failure to serialise the SPKI bytes (e.g. negative `n`, malformed
`bitLength`) surfaces as `FINGERPRINT_FAILED` with the underlying
error preserved as `cause` — callers needing the original failure
class can introspect `error.cause`.

View File

@@ -0,0 +1,65 @@
import { sha256 } from "@suaveplan/crypto-hash";
import { toHex } from "@suaveplan/crypto-utils";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { RSA_2048_PUBLIC_KEY } from "../__fixtures__/rsa-2048-key.js";
import { CRYPTO_RSA_CODES, type CryptoRsaError } from "../errors/errors.js";
import { serializeRsaPublicKeySpki } from "../keys/serialize-spki.js";
import { computeSpkiFingerprint } from "./spki.js";
describe("computeSpkiFingerprint", () => {
it("hashes the SPKI DER bytes with SHA-256 by default", {
timeout: 5000,
}, () => {
const fingerprint = computeSpkiFingerprint(RSA_2048_PUBLIC_KEY);
const expected = sha256(serializeRsaPublicKeySpki(RSA_2048_PUBLIC_KEY));
expect(toHex(fingerprint)).toBe(toHex(expected));
expect(fingerprint.length).toBe(32);
});
it("supports SHA-1 / SHA-384 / SHA-512 overrides", {
timeout: 5000,
}, () => {
const sha1 = computeSpkiFingerprint(RSA_2048_PUBLIC_KEY, {
hash: "SHA-1",
});
const sha384 = computeSpkiFingerprint(RSA_2048_PUBLIC_KEY, {
hash: "SHA-384",
});
const sha512 = computeSpkiFingerprint(RSA_2048_PUBLIC_KEY, {
hash: "SHA-512",
});
expect(sha1.length).toBe(20);
expect(sha384.length).toBe(48);
expect(sha512.length).toBe(64);
});
it("rejects unsupported hash with UNSUPPORTED_HASH", {
timeout: 5000,
}, () => {
try {
computeSpkiFingerprint(RSA_2048_PUBLIC_KEY, {
hash: "MD5" as never,
});
throw new Error("should have thrown");
} catch (err) {
expect((err as CryptoRsaError).code).toBe(
CRYPTO_RSA_CODES.UNSUPPORTED_HASH
);
}
});
it("wraps SPKI serialiser failure as FINGERPRINT_FAILED", {
timeout: 5000,
}, () => {
// n=0n forces the i2osp path inside SPKI serialise to throw.
const broken = { ...RSA_2048_PUBLIC_KEY, n: -1n };
try {
computeSpkiFingerprint(broken);
throw new Error("should have thrown");
} catch (err) {
expect((err as CryptoRsaError).code).toBe(
CRYPTO_RSA_CODES.FINGERPRINT_FAILED
);
}
});
});

View File

@@ -0,0 +1,42 @@
import { digestWith, hashOutputLength } from "../encoding/mgf1.js";
import { CRYPTO_RSA_CODES, CryptoRsaError } from "../errors/errors.js";
import type { RsaHashAlgorithm, RsaPublicKey } from "../keys/keys.types.js";
import { serializeRsaPublicKeySpki } from "../keys/serialize-spki.js";
/**
* Optional input for `computeSpkiFingerprint`. The hash defaults to
* `"SHA-256"` (the conventional fingerprint algorithm — matches
* `ssh-keygen -lf`'s default since OpenSSH 6.8).
*/
export interface SpkiFingerprintOptions {
readonly hash?: RsaHashAlgorithm;
}
/**
* Compute a deterministic fingerprint of an RSA public key by hashing
* its SPKI DER encoding (RFC 5280 §4.1 `SubjectPublicKeyInfo`). The
* resulting digest uniquely identifies the key independently of any PEM
* armor, JWK packaging, or other transport framing.
*
* @throws CryptoRsaError(CRYPTO_RSA.UNSUPPORTED_HASH) on unknown hash
* @throws CryptoRsaError(CRYPTO_RSA.FINGERPRINT_FAILED) when the SPKI
* serialiser fails (typically a malformed public key).
*/
export function computeSpkiFingerprint(
publicKey: RsaPublicKey,
options: SpkiFingerprintOptions = {}
): Uint8Array {
const hash = options.hash ?? "SHA-256";
void hashOutputLength(hash);
let der: Uint8Array;
try {
der = serializeRsaPublicKeySpki(publicKey);
} catch (cause) {
throw new CryptoRsaError(
CRYPTO_RSA_CODES.FINGERPRINT_FAILED,
"error.crypto-rsa.fingerprint.spki-serialize-failed",
{ cause }
);
}
return digestWith(hash, der);
}

View File

@@ -0,0 +1,44 @@
import type { RsaSchemeOptions } from "../engine/engine.types.js";
import type { RsaPrivateKey, RsaPublicKey } from "../keys/keys.types.js";
declare const HANDLE_BRAND: unique symbol;
/**
* Opaque marker for an RSA key residing outside JS memory — typically
* an HSM, KMS, OS keychain, or PKCS#11 token. The brand prevents
* accidental conflation with other handle types at the type level;
* runtime introspection is via the `provider` field.
*/
export interface RsaKeyHandle {
readonly [HANDLE_BRAND]?: never;
readonly provider: string;
readonly id: string;
}
/**
* Provider interface implemented by every HSM / KMS / keychain bridge.
* Every handle is associated with a provider via its `provider` field;
* the registry resolves the handle to the matching provider at call
* time and dispatches to the provider's `sign` / `decrypt` / `resolve`
* methods.
*/
export interface RsaKeyHandleProvider {
readonly name: string;
/**
* Resolve the handle to a canonical key. For software handles this
* returns the wrapped `RsaPrivateKey` directly; for HSM-backed
* handles this typically returns the public component only —
* private operations route through `sign` / `decrypt`.
*/
resolve(handle: RsaKeyHandle): Promise<RsaPublicKey | RsaPrivateKey>;
sign(
handle: RsaKeyHandle,
message: Uint8Array,
options: RsaSchemeOptions
): Promise<Uint8Array>;
decrypt(
handle: RsaKeyHandle,
ciphertext: Uint8Array,
options: RsaSchemeOptions
): Promise<Uint8Array>;
}

View File

@@ -0,0 +1,12 @@
export type {
RsaKeyHandle,
RsaKeyHandleProvider,
} from "./handle.types.js";
export {
decryptWithHandle,
registerKeyHandleProvider,
resolveKeyHandle,
signWithHandle,
unregisterKeyHandleProvider,
} from "./registry.js";
export { SoftwareRsaKeyHandleProvider } from "./software.js";

View File

@@ -0,0 +1,93 @@
# handle/registry
## Purpose
Process-wide registry for RSA key-handle providers. Every HSM, KMS, OS
keychain, or PKCS#11 bridge registers a provider once at startup; the
registry resolves opaque `RsaKeyHandle` values to the matching provider
at call time and dispatches `resolve` / `sign` / `decrypt` operations
through it. Decouples consumer code from any specific hardware
backend.
## Features
- Single in-process registry keyed by provider name
- Idempotent re-registration of the same provider value
- Conflicting registration of a different provider under the same name
rejects with `INVALID_KEY`
- `signWithHandle` / `decryptWithHandle` mirror the `RsaCryptoEngine`
surface so handle consumers and engine consumers share the same
scheme-options shape
- `unregisterKeyHandleProvider` exposed for test cleanup
## Usage: Basic Example
```ts
import {
registerKeyHandleProvider,
signWithHandle,
} from "@suaveplan/crypto-rsa/handle";
registerKeyHandleProvider(myHsmProvider);
const sig = await signWithHandle(
{ provider: "my-hsm", id: "key-1" },
message,
{ scheme: "pkcs1v15", hash: "SHA-256" }
);
```
## Usage: Advanced Example
For test setups that need a clean registry per test:
```ts
import {
registerKeyHandleProvider,
unregisterKeyHandleProvider,
SoftwareRsaKeyHandleProvider,
} from "@suaveplan/crypto-rsa/handle";
beforeEach(() => {
registerKeyHandleProvider(new SoftwareRsaKeyHandleProvider());
});
afterEach(() => {
unregisterKeyHandleProvider("software");
});
```
## API Reference
### `registerKeyHandleProvider(provider)`
- `provider: RsaKeyHandleProvider`
- **throws** `CryptoRsaError(CRYPTO_RSA.INVALID_KEY)` on conflicting
registration of a different provider under the same name
### `unregisterKeyHandleProvider(name)`
- `name: string`
### `resolveKeyHandle(handle): Promise<RsaPublicKey | RsaPrivateKey>`
- **throws** `CryptoRsaError(CRYPTO_RSA.INVALID_KEY)` when no provider
is registered under the handle's `provider` name
### `signWithHandle(handle, message, options): Promise<Uint8Array>`
### `decryptWithHandle(handle, ciphertext, options): Promise<Uint8Array>`
## Implementation Notes
- The registry is a module-level `Map<string, RsaKeyHandleProvider>`.
The chosen scope is process-lifetime; if cross-process or per-vault
isolation is required, callers must wrap their own dispatch layer.
- Idempotent registration of the same provider value (object identity
comparison) is a no-op so that import-once-then-register patterns
work without per-import side effects.
- Conflicting registration (two different provider objects with the
same name) raises a typed error instead of silently winning — this
is to detect copy-paste mistakes between adapters during integration.
- Handle resolution failures throw `INVALID_KEY` because from the
caller's perspective the supplied handle was not valid (the
underlying cause — provider unregistered, id unknown — is captured
in the error context).

View File

@@ -0,0 +1,130 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { CRYPTO_RSA_CODES, type CryptoRsaError } from "../errors/errors.js";
import type { RsaKeyHandle, RsaKeyHandleProvider } from "./handle.types.js";
import {
decryptWithHandle,
registerKeyHandleProvider,
resolveKeyHandle,
signWithHandle,
unregisterKeyHandleProvider,
} from "./registry.js";
function makeStubProvider(name: string): RsaKeyHandleProvider {
return {
name,
async resolve(_handle: RsaKeyHandle) {
return { n: 1n, e: 3n, bitLength: 1 };
},
async sign(_handle, _message, _options) {
return new Uint8Array([0xab]);
},
async decrypt(_handle, _ciphertext, _options) {
return new Uint8Array([0xcd]);
},
};
}
describe("registerKeyHandleProvider + resolveKeyHandle", () => {
it("registers a provider and resolves a handle", {
timeout: 5000,
}, async () => {
const provider = makeStubProvider("stub-resolve");
registerKeyHandleProvider(provider);
try {
const handle: RsaKeyHandle = {
provider: "stub-resolve",
id: "k1",
};
const resolved = await resolveKeyHandle(handle);
expect(resolved.n).toBe(1n);
} finally {
unregisterKeyHandleProvider("stub-resolve");
}
});
it("idempotent re-registration of the same provider value", {
timeout: 5000,
}, () => {
const provider = makeStubProvider("stub-idem");
registerKeyHandleProvider(provider);
registerKeyHandleProvider(provider);
unregisterKeyHandleProvider("stub-idem");
});
it("conflicting registration of a different provider rejects with INVALID_KEY", {
timeout: 5000,
}, () => {
const a = makeStubProvider("stub-conflict");
const b = makeStubProvider("stub-conflict");
registerKeyHandleProvider(a);
try {
try {
registerKeyHandleProvider(b);
throw new Error("should have thrown");
} catch (err) {
expect((err as CryptoRsaError).code).toBe(
CRYPTO_RSA_CODES.INVALID_KEY
);
}
} finally {
unregisterKeyHandleProvider("stub-conflict");
}
});
it("resolves missing-provider lookup with INVALID_KEY", {
timeout: 5000,
}, async () => {
const handle: RsaKeyHandle = {
provider: "nonexistent",
id: "x",
};
try {
await resolveKeyHandle(handle);
throw new Error("should have thrown");
} catch (err) {
expect((err as CryptoRsaError).code).toBe(
CRYPTO_RSA_CODES.INVALID_KEY
);
}
});
it("signWithHandle delegates to the provider", {
timeout: 5000,
}, async () => {
const provider = makeStubProvider("stub-sign");
registerKeyHandleProvider(provider);
try {
const handle: RsaKeyHandle = {
provider: "stub-sign",
id: "k1",
};
const sig = await signWithHandle(handle, new Uint8Array([1, 2]), {
scheme: "pkcs1v15",
hash: "SHA-256",
});
expect(sig[0]).toBe(0xab);
} finally {
unregisterKeyHandleProvider("stub-sign");
}
});
it("decryptWithHandle delegates to the provider", {
timeout: 5000,
}, async () => {
const provider = makeStubProvider("stub-decrypt");
registerKeyHandleProvider(provider);
try {
const handle: RsaKeyHandle = {
provider: "stub-decrypt",
id: "k1",
};
const pt = await decryptWithHandle(handle, new Uint8Array([1, 2]), {
scheme: "oaep",
hash: "SHA-256",
});
expect(pt[0]).toBe(0xcd);
} finally {
unregisterKeyHandleProvider("stub-decrypt");
}
});
});

View File

@@ -0,0 +1,83 @@
import { CRYPTO_RSA_CODES, CryptoRsaError } from "../errors/errors.js";
import type { RsaPrivateKey, RsaPublicKey } from "../keys/keys.types.js";
import type { RsaKeyHandle, RsaKeyHandleProvider } from "./handle.types.js";
const REGISTRY = new Map<string, RsaKeyHandleProvider>();
/**
* Register a key-handle provider. Registering the same provider value
* twice is a no-op (idempotent); registering a DIFFERENT provider
* under an already-claimed name throws `INVALID_KEY` so two HSM
* adapters can't collide silently.
*/
export function registerKeyHandleProvider(
provider: RsaKeyHandleProvider
): void {
const existing = REGISTRY.get(provider.name);
if (existing !== undefined) {
if (existing === provider) {
return;
}
throw new CryptoRsaError(
CRYPTO_RSA_CODES.INVALID_KEY,
"error.crypto-rsa.handle.provider-conflict",
{ context: { provider: provider.name } }
);
}
REGISTRY.set(provider.name, provider);
}
/**
* Unregister a provider by name. Test-only — production code never
* removes providers because the registry is process-lifetime stable.
*/
export function unregisterKeyHandleProvider(name: string): void {
REGISTRY.delete(name);
}
/**
* Resolve a key handle to its canonical RSA key value via the
* registered provider. Throws `INVALID_KEY` when no provider is
* registered under the handle's `provider` name.
*/
export async function resolveKeyHandle(
handle: RsaKeyHandle
): Promise<RsaPublicKey | RsaPrivateKey> {
return providerFor(handle).resolve(handle);
}
/**
* Sign a message via the handle's provider. Equivalent to
* `engine.sign` but routed through the HSM/KMS bridge instead of an
* in-process key.
*/
export async function signWithHandle(
handle: RsaKeyHandle,
message: Uint8Array,
options: import("../engine/engine.types.js").RsaSchemeOptions
): Promise<Uint8Array> {
return providerFor(handle).sign(handle, message, options);
}
/**
* Decrypt a ciphertext via the handle's provider.
*/
export async function decryptWithHandle(
handle: RsaKeyHandle,
ciphertext: Uint8Array,
options: import("../engine/engine.types.js").RsaSchemeOptions
): Promise<Uint8Array> {
return providerFor(handle).decrypt(handle, ciphertext, options);
}
function providerFor(handle: RsaKeyHandle): RsaKeyHandleProvider {
const provider = REGISTRY.get(handle.provider);
if (provider === undefined) {
throw new CryptoRsaError(
CRYPTO_RSA_CODES.INVALID_KEY,
"error.crypto-rsa.handle.provider-not-registered",
{ context: { provider: handle.provider } }
);
}
return provider;
}

View File

@@ -0,0 +1,78 @@
# handle/software
## Purpose
Reference `RsaKeyHandleProvider` wrapping in-memory `RsaPrivateKey`
values. Implements the same interface as a real HSM bridge but with
zero hardware dependency, so consumers can develop and test
handle-based code paths without an HSM. Production workloads typically
replace this with their own provider (PKCS#11, AWS KMS, Apple Keychain
via `@suaveplan/macos-keychain`, etc.).
## Features
- Implements every `RsaKeyHandleProvider` method
- Stores keys in a process-local `Map<string, RsaPrivateKey>` keyed by
handle id
- Sign / decrypt route through `nobleEngine` so semantics match the
rest of the package exactly
- `register(id, key)` returns the matching `RsaKeyHandle` for ergonomic
test setups
## Usage: Basic Example
```ts
import {
registerKeyHandleProvider,
signWithHandle,
} from "@suaveplan/crypto-rsa/handle";
import { SoftwareRsaKeyHandleProvider } from "@suaveplan/crypto-rsa/handle";
const provider = new SoftwareRsaKeyHandleProvider();
registerKeyHandleProvider(provider);
const handle = provider.register("test-key", privateKey);
const sig = await signWithHandle(handle, message, {
scheme: "pkcs1v15",
hash: "SHA-256",
});
```
## Usage: Advanced Example
Use the software provider to drive integration tests that exercise
the handle-based code path of a downstream consumer (e.g. a JWS signer
that accepts `RsaKeyHandle` instead of an in-memory key):
```ts
import { SoftwareRsaKeyHandleProvider } from "@suaveplan/crypto-rsa/handle";
import { registerKeyHandleProvider } from "@suaveplan/crypto-rsa/handle";
const provider = new SoftwareRsaKeyHandleProvider();
registerKeyHandleProvider(provider);
const handle = provider.register("k1", testKey);
// Downstream consumer uses the handle exactly as it would an HSM key.
const jws = await jwsSigner.sign(payload, handle);
```
## API Reference
### `class SoftwareRsaKeyHandleProvider`
- `name: "software"` (readonly)
- `register(id: string, key: RsaPrivateKey): RsaKeyHandle`
- `resolve(handle): Promise<RsaPrivateKey>`
- `sign(handle, message, options): Promise<Uint8Array>`
- `decrypt(handle, ciphertext, options): Promise<Uint8Array>`
## Implementation Notes
- The provider is class-based (vs the module-singleton style of
`nobleEngine`) so consumers can spin up multiple instances for
parallel-test isolation. Each instance has its own internal Map.
- Sign/decrypt delegate to `nobleEngine` directly — this provider is
a pure JS reference; HSM-backed providers replace these methods with
the appropriate `crypto.subtle` / PKCS#11 / KMS API calls.
- The "handle id missing" failure throws a plain `Error` (NOT a
`CryptoRsaError`) because the caller is the registry layer; the
registry maps it to a typed error before reaching application code.

View File

@@ -0,0 +1,76 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
RSA_2048_PRIVATE_KEY,
RSA_2048_PUBLIC_KEY,
} from "../__fixtures__/rsa-2048-key.js";
import { nobleEngine } from "../engine/noble.js";
import { SoftwareRsaKeyHandleProvider } from "./software.js";
const MESSAGE = new TextEncoder().encode("software-handle");
describe("SoftwareRsaKeyHandleProvider", () => {
it("name is 'software'", () => {
expect(new SoftwareRsaKeyHandleProvider().name).toBe("software");
});
it("resolve returns the wrapped private key", {
timeout: 5000,
}, async () => {
const provider = new SoftwareRsaKeyHandleProvider();
const handle = provider.register("k1", RSA_2048_PRIVATE_KEY);
const resolved = await provider.resolve(handle);
expect(resolved.n).toBe(RSA_2048_PRIVATE_KEY.n);
});
it("sign matches the direct engine call", { timeout: 10000 }, async () => {
const provider = new SoftwareRsaKeyHandleProvider();
const handle = provider.register("k-sign", RSA_2048_PRIVATE_KEY);
const handleSig = await provider.sign(handle, MESSAGE, {
scheme: "pkcs1v15",
hash: "SHA-256",
});
const directSig = await nobleEngine.sign(
RSA_2048_PRIVATE_KEY,
MESSAGE,
{ scheme: "pkcs1v15", hash: "SHA-256" }
);
// PKCS1v15 is deterministic.
expect(handleSig.length).toBe(directSig.length);
const ok = await nobleEngine.verify(
RSA_2048_PUBLIC_KEY,
MESSAGE,
handleSig,
{ scheme: "pkcs1v15", hash: "SHA-256" }
);
expect(ok).toBe(true);
});
it("decrypt round-trips an OAEP ciphertext", {
timeout: 10000,
}, async () => {
const provider = new SoftwareRsaKeyHandleProvider();
const handle = provider.register("k-dec", RSA_2048_PRIVATE_KEY);
const ct = await nobleEngine.encrypt(RSA_2048_PUBLIC_KEY, MESSAGE, {
scheme: "oaep",
hash: "SHA-256",
});
const pt = await provider.decrypt(handle, ct, {
scheme: "oaep",
hash: "SHA-256",
});
expect(new TextDecoder().decode(pt)).toBe("software-handle");
});
it("throws on unregistered handle id", { timeout: 5000 }, async () => {
const provider = new SoftwareRsaKeyHandleProvider();
try {
await provider.resolve({
provider: "software",
id: "missing",
});
throw new Error("should have thrown");
} catch (err) {
expect((err as Error).message).toContain("missing");
}
});
});

View File

@@ -0,0 +1,56 @@
import type { RsaSchemeOptions } from "../engine/engine.types.js";
import { nobleEngine } from "../engine/noble.js";
import type { RsaPrivateKey } from "../keys/keys.types.js";
import type { RsaKeyHandle, RsaKeyHandleProvider } from "./handle.types.js";
/**
* Reference `RsaKeyHandleProvider` wrapping an in-memory
* `RsaPrivateKey`. Implements the same interface as a real HSM bridge
* but with zero hardware dependency — consumers can develop and test
* handle-based code without an HSM.
*
* Backing keys are stored in a process-local Map keyed by handle id.
* Test code typically registers exactly one provider; production HSM
* adapters replace this with their own implementation.
*/
export class SoftwareRsaKeyHandleProvider implements RsaKeyHandleProvider {
public readonly name = "software";
private readonly keys = new Map<string, RsaPrivateKey>();
/**
* Register an in-memory key under the given handle id and return
* the matching `RsaKeyHandle`.
*/
public register(id: string, key: RsaPrivateKey): RsaKeyHandle {
this.keys.set(id, key);
return { provider: this.name, id };
}
public async resolve(handle: RsaKeyHandle): Promise<RsaPrivateKey> {
return this.lookup(handle);
}
public async sign(
handle: RsaKeyHandle,
message: Uint8Array,
options: RsaSchemeOptions
): Promise<Uint8Array> {
return nobleEngine.sign(this.lookup(handle), message, options);
}
public async decrypt(
handle: RsaKeyHandle,
ciphertext: Uint8Array,
options: RsaSchemeOptions
): Promise<Uint8Array> {
return nobleEngine.decrypt(this.lookup(handle), ciphertext, options);
}
private lookup(handle: RsaKeyHandle): RsaPrivateKey {
const key = this.keys.get(handle.id);
if (key === undefined) {
throw new Error(`software handle '${handle.id}' is not registered`);
}
return key;
}
}

View File

@@ -0,0 +1,240 @@
// Engine (Stage 4)
export {
type DefaultEngineOptions,
defaultEngine,
nobleEngine,
type RsaCryptoEngine,
type RsaEngineName,
type RsaOaepScheme,
type RsaPkcs1v15Scheme,
type RsaPssScheme,
type RsaSchemeOptions,
resetEngineSelectionCounterForTesting,
resetSubtleCountersForTesting,
type SubtleEngineOptions,
subtleEngine,
} from "./engine/index.js";
// Fingerprint (Stage 4)
export {
computeSpkiFingerprint,
type FingerprintFormat,
formatFingerprint,
resetFingerprintCounterForTesting,
type SpkiFingerprintOptions,
} from "./fingerprint/index.js";
// Handle (Stage 4)
export {
decryptWithHandle,
type RsaKeyHandle,
type RsaKeyHandleProvider,
registerKeyHandleProvider,
resolveKeyHandle,
SoftwareRsaKeyHandleProvider,
signWithHandle,
unregisterKeyHandleProvider,
} from "./handle/index.js";
// JWK thumbprint (Stage 4)
export {
type JwkThumbprintOptions,
jwkThumbprint,
jwkThumbprintBase64Url,
jwkThumbprintUri,
} from "./jwk/index.js";
// JWK policy (Stage 4)
export {
enforceJwkPolicy,
type JwkOperation,
} from "./keys/jwk-policy.js";
// Profile (Stage 4)
export {
type EnforceProfileOptions,
enforceProfile,
type ProfileInput,
type RsaProfile,
resetProfileCounterForTesting,
} from "./profile/index.js";
// Encoding (Stage 2)
// ASN.1 / DER
export {
DerReader,
encodeBitString,
encodeContextSpecific,
encodeInteger,
encodeLength,
encodeNull,
encodeObjectIdentifier,
encodeOctetString,
encodeSequence,
TAG_BIT_STRING,
TAG_CONTEXT_SPECIFIC_BASE,
TAG_INTEGER,
TAG_NULL,
TAG_OCTET_STRING,
TAG_OID,
TAG_SEQUENCE,
} from "./asn1/index.js";
// Blinding (Stage 3)
export {
type BlindResult,
blindMessage,
nextBytesViaRng,
unblindResult,
} from "./blinding/index.js";
// Decrypt (Stage 3)
export {
type RsaDecryptOaepOptions,
type RsaDecryptPkcs1v15Options,
rsaDecryptOaep,
rsaDecryptPkcs1v15,
} from "./decrypt/index.js";
export {
DIGEST_INFO_PREFIX_SHA1,
DIGEST_INFO_PREFIX_SHA256,
DIGEST_INFO_PREFIX_SHA384,
DIGEST_INFO_PREFIX_SHA512,
digestInfoPrefix,
digestWith,
emsaPkcs1v15Encode,
emsaPssEncode,
emsaPssVerify,
hashOutputLength,
mgf1,
} from "./encoding/index.js";
// Encrypt (Stage 3)
export {
type RsaEncryptOaepOptions,
type RsaEncryptPkcs1v15Options,
rsaEncryptOaep,
rsaEncryptPkcs1v15,
} from "./encrypt/index.js";
// Errors
export {
CRYPTO_RSA_CODES,
type CryptoRsaCode,
CryptoRsaError,
} from "./errors/index.js";
// Keygen (Stage 3)
export {
assembleMultiPrimeKey,
assembleTwoPrime,
type GenerateMultiPrimeOptions,
type GenerateRsaKeyPairOptions,
gcd,
generateMultiPrimeKey,
generateRsaKeyPair,
isProbablePrime,
SMALL_PRIMES,
samplePrime,
uniformBigint,
validatePublicExponent,
} from "./keygen/index.js";
// Keys — types + parse + serialise
export {
finalisePrivateKey,
finalisePublicKey,
OID_RSA_ENCRYPTION,
type ParseRsaPrivateKeyOptions,
type ParseRsaPublicKeyOptions,
parseRsaPrivateKey,
parseRsaPrivateKeyJwk,
parseRsaPrivateKeyPkcs1,
parseRsaPrivateKeyPkcs8,
parseRsaPublicKey,
parseRsaPublicKeyJwk,
parseRsaPublicKeyPkcs1,
parseRsaPublicKeySpki,
type RsaHashAlgorithm,
type RsaJwk,
type RsaPrivateKey,
type RsaPrivateKeyFormat,
type RsaPublicKey,
type RsaPublicKeyFormat,
type SerializeRsaPrivateKeyOptions,
type SerializeRsaPublicKeyOptions,
serializeRsaPrivateKey,
serializeRsaPrivateKeyJwk,
serializeRsaPrivateKeyPkcs1,
serializeRsaPrivateKeyPkcs8,
serializeRsaPublicKey,
serializeRsaPublicKeyJwk,
serializeRsaPublicKeyPkcs1,
serializeRsaPublicKeySpki,
} from "./keys/index.js";
// Math
export { i2osp, modPow, os2ip, rsaPrivateOperationCrt } from "./math/index.js";
// Observability
export {
getInstrumentsForTesting,
recordBlindingDisabled,
recordDecryptDuration,
recordEncryptDuration,
recordError,
recordKeygenDuration,
recordModpowDuration,
recordParseDuration,
recordSerializeDuration,
recordSignOutcome,
recordThrown,
recordVerifyOutcome,
resetMetricsForTesting,
} from "./observability/index.js";
// PEM
export {
type DecodedPem,
decodePem,
encodePem,
PEM_LABEL_PRIVATE_KEY,
PEM_LABEL_PUBLIC_KEY,
PEM_LABEL_RSA_PRIVATE_KEY,
PEM_LABEL_RSA_PUBLIC_KEY,
PEM_LINE_LENGTH,
parseRsaPrivateKeyPem,
parseRsaPublicKeyPem,
type SerializeRsaPrivateKeyPemOptions,
type SerializeRsaPublicKeyPemOptions,
serializeRsaPrivateKeyPem,
serializeRsaPublicKeyPem,
} from "./pem/index.js";
// Policy
export {
type EnforceMaxBitLengthOptions,
type EnforceMinBitLengthOptions,
enforceMaxBitLength,
enforceMinBitLength,
} from "./policy/index.js";
// Schemas
export {
HashAlgorithmSchema,
RsaOaepOptionsSchema,
RsaParseOptionsPrivateSchema,
RsaParseOptionsPublicSchema,
RsaParseOptionsSchema,
RsaPrivateKeyFormatSchema,
RsaPrivateKeySchema,
RsaPssOptionsSchema,
RsaPublicKeyFormatSchema,
RsaPublicKeySchema,
RsaSerializeOptionsPrivateSchema,
RsaSerializeOptionsPublicSchema,
RsaSerializeOptionsSchema,
RsaSignOptionsSchema,
RsaVerifyOptionsSchema,
} from "./schemas/index.js";
// Sign / Verify (Stage 2)
export {
type RsaSignPkcs1v15Options,
type RsaSignPssOptions,
rsaSignPkcs1v15,
rsaSignPss,
} from "./sign/index.js";
export {
type RsaVerifyPkcs1v15Options,
type RsaVerifyPssOptions,
rsaVerifyPkcs1v15,
rsaVerifyPss,
} from "./verify/index.js";

View File

@@ -0,0 +1,6 @@
export {
type JwkThumbprintOptions,
jwkThumbprint,
jwkThumbprintBase64Url,
} from "./thumbprint.js";
export { jwkThumbprintUri } from "./thumbprint-uri.js";

View File

@@ -0,0 +1,59 @@
# jwk/thumbprint-uri
## Purpose
Compute the JWK Thumbprint URI per RFC 9278 §3 — the canonical
`urn:ietf:params:oauth:jwk-thumbprint:<hash-alg>:<base64url(thumbprint)>`
form used by OAuth 2.0 Demonstrating Proof-of-Possession (DPoP),
draft-ietf-oauth-dpop, and other OAuth/OIDC binding protocols. Wraps
`jwkThumbprint` with the hash-algorithm label and URN prefix.
## Features
- RFC 9278 §3 conformant URN format
- Default SHA-256 hash; SHA-1 / SHA-384 / SHA-512 supported
- Hash labels match RFC 9278 §3.1 registry: `sha-1`, `sha-256`, `sha-384`,
`sha-512`
- Returns a deterministic `string` ready to embed in OAuth tokens, DPoP
proofs, or `iss`/`sub` claims
## Usage: Basic Example
```ts
import { jwkThumbprintUri } from "@suaveplan/crypto-rsa/jwk";
const uri = jwkThumbprintUri(publicKey);
// "urn:ietf:params:oauth:jwk-thumbprint:sha-256:NzbLsXh8u..."
```
## Usage: Advanced Example
For protocols that pin a non-default hash (e.g. SHA-512 for
high-assurance bindings), pass the `hash` option:
```ts
import { jwkThumbprintUri } from "@suaveplan/crypto-rsa/jwk";
const sha512Uri = jwkThumbprintUri(publicKey, { hash: "SHA-512" });
// "urn:ietf:params:oauth:jwk-thumbprint:sha-512:..."
```
## API Reference
### `jwkThumbprintUri(key, options?)`
- `key: RsaPublicKey | RsaJwk` — public key or JWK
- `options.hash?: RsaHashAlgorithm` — defaults to `"SHA-256"`
- **returns** `string` — full URN
- **throws** `CryptoRsaError(CRYPTO_RSA.UNSUPPORTED_HASH)` /
`CRYPTO_RSA.INVALID_KEY` (propagated from `jwkThumbprint`)
## Implementation Notes
- The hash-label mapping is a frozen lookup table mirroring the
IANA-registered names in RFC 9278 §3.1. Adding new RSA hash
algorithms (e.g. SHA-3) requires updating both this table and the
`RsaHashAlgorithm` union in `keys.types.ts`.
- This helper is a thin wrapper over `jwkThumbprint` + `toBase64Url`;
it adds no cryptographic complexity, only the URN framing. Tests
cover the framing for every supported hash.

View File

@@ -0,0 +1,38 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { jwkThumbprintUri } from "./thumbprint-uri.js";
const RFC_7638_JWK = {
kty: "RSA" as const,
n:
"0vx7agoebGcQSuuPiLJXZptN9nndrQmbXEps2aiAFbWhM78LhWx" +
"4cbbfAAtVT86zwu1RK7aPFFxuhDR1L6tSoc_BJECPebWKRXjBZCi" +
"FV4n3oknjhMstn64tZ_2W-5JsGY4Hc5n9yBXArwl93lqt7_RN5w6" +
"Cf0h4QyQ5v-65YGjQR0_FDW2QvzqY368QQMicAtaSqzs8KJZgnYb" +
"9c7d0zgdAZHzu6qMQvRL5hajrn1n91CbOpbISD08qNLyrdkt-bFT" +
"WhAI4vMQFh6WeZu0fM4lFd2NcRwr3XPksINHaQ-G_xBniIqbw0Ls" +
"1jF44-csFCur-kEgU8awapJzKnqDKgw",
e: "AQAB",
};
describe("jwkThumbprintUri", () => {
it("emits the RFC 9278 URN with the SHA-256 thumbprint", {
timeout: 5000,
}, () => {
const uri = jwkThumbprintUri(RFC_7638_JWK);
expect(uri).toBe(
"urn:ietf:params:oauth:jwk-thumbprint:sha-256:NzbLsXh8uDCcd-6MNwXF4W_7noWXFZAfHkxZsRGC9Xs"
);
});
it("supports SHA-384 + SHA-512 hash overrides", { timeout: 5000 }, () => {
const sha384 = jwkThumbprintUri(RFC_7638_JWK, { hash: "SHA-384" });
const sha512 = jwkThumbprintUri(RFC_7638_JWK, { hash: "SHA-512" });
expect(sha384).toContain(":sha-384:");
expect(sha512).toContain(":sha-512:");
});
it("supports SHA-1 hash override", { timeout: 5000 }, () => {
const sha1 = jwkThumbprintUri(RFC_7638_JWK, { hash: "SHA-1" });
expect(sha1).toContain(":sha-1:");
});
});

View File

@@ -0,0 +1,30 @@
import { toBase64Url } from "@suaveplan/crypto-utils";
import type {
RsaHashAlgorithm,
RsaJwk,
RsaPublicKey,
} from "../keys/keys.types.js";
import { type JwkThumbprintOptions, jwkThumbprint } from "./thumbprint.js";
const HASH_TO_URI_LABEL: Readonly<Record<RsaHashAlgorithm, string>> = {
"SHA-1": "sha-1",
"SHA-256": "sha-256",
"SHA-384": "sha-384",
"SHA-512": "sha-512",
};
/**
* Compute the JWK Thumbprint URI per RFC 9278 §3 — `urn:ietf:params:oauth:
* jwk-thumbprint:<hash-alg>:<base64url(thumbprint)>`. The hash defaults to
* SHA-256 (the canonical RFC 7638 thumbprint algorithm).
*/
export function jwkThumbprintUri(
key: RsaPublicKey | RsaJwk,
options: JwkThumbprintOptions = {}
): string {
const hash = options.hash ?? "SHA-256";
const digest = jwkThumbprint(key, options);
const label = HASH_TO_URI_LABEL[hash];
const encoded = toBase64Url(digest);
return `urn:ietf:params:oauth:jwk-thumbprint:${label}:${encoded}`;
}

View File

@@ -0,0 +1,84 @@
# jwk/thumbprint
## Purpose
Compute the JWK Thumbprint of an RSA public key per RFC 7638 §3.2. The
thumbprint is a deterministic hash over the canonical JSON form of the
public-key members (`e`, `kty`, `n` in alphabetical order with no
whitespace), giving every RSA key a stable identifier independent of
its wire format. Used downstream for `kid` derivation, key rotation
diffing, and key-pinning protocols.
## Features
- RFC 7638 §3.2 conformant — RSA member ordering `e`, `kty`, `n`
- Default SHA-256 hash; SHA-1 / SHA-384 / SHA-512 supported via `hash` option
- Accepts both canonical `RsaPublicKey` and `RsaJwk` shapes
- Hand-built canonical JSON byte string (does NOT use `JSON.stringify` —
member order is implementation-defined for that API)
- Worked-example matched against the RFC 7638 §3.1 reference value
`"NzbLsXh8uDCcd-6MNwXF4W_7noWXFZAfHkxZsRGC9Xs"`
- `jwkThumbprintBase64Url` convenience returns the URL-safe base64 encoding directly
- All thrown errors are `CryptoRsaError` with `INVALID_KEY` /
`UNSUPPORTED_HASH`
## Usage: Basic Example
```ts
import { jwkThumbprint } from "@suaveplan/crypto-rsa/jwk";
const fingerprint = jwkThumbprint(publicKey);
// 32 bytes (SHA-256 default)
```
## Usage: Advanced Example
The `kid` JWK member is conventionally set to the base64url-encoded
SHA-256 thumbprint. Combine `jwkThumbprintBase64Url` with
`serializeRsaPublicKeyJwk` to populate it on emit:
```ts
import {
jwkThumbprintBase64Url,
serializeRsaPublicKeyJwk,
} from "@suaveplan/crypto-rsa";
const baseJwk = serializeRsaPublicKeyJwk(publicKey);
const jwkWithKid = {
...baseJwk,
kid: jwkThumbprintBase64Url(publicKey),
};
```
## API Reference
### `jwkThumbprint(key, options?)`
- `key: RsaPublicKey | RsaJwk` — public key or JWK
- `options.hash?: RsaHashAlgorithm` — defaults to `"SHA-256"`
- **returns** `Uint8Array` — digest bytes
- **throws** `CryptoRsaError(CRYPTO_RSA.UNSUPPORTED_HASH)` on bad hash;
`CRYPTO_RSA.INVALID_KEY` on missing `e` or `n`
### `jwkThumbprintBase64Url(key, options?)`
Convenience: returns `toBase64Url(jwkThumbprint(key, options))`.
### `JwkThumbprintOptions`
- `hash?: RsaHashAlgorithm`
## Implementation Notes
- The canonical JSON byte string is `'{"e":"<b64u(e)>","kty":"RSA","n":"<b64u(n)>"}'`.
Members are alphabetically ordered per RFC 7638 §3.2; only the three
required members participate in the digest.
- `JSON.stringify` is NOT used because member-order in object iteration
is implementation-defined for non-integer keys; constructing the byte
string by hand is the only correctness-stable approach.
- When the input is a `RsaPublicKey` (canonical bigint form), the
implementation routes through `serializeRsaPublicKeyJwk` to obtain
the base64url members, then concatenates the canonical JSON shape.
- Hash algorithm validation runs BEFORE member-validity checks so
consumers see `UNSUPPORTED_HASH` on bad hashes regardless of key
validity — useful for fail-fast configuration validators.

View File

@@ -0,0 +1,90 @@
import { toBase64Url } from "@suaveplan/crypto-utils";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { CRYPTO_RSA_CODES, type CryptoRsaError } from "../errors/errors.js";
import { parseRsaPublicKeyJwk } from "../keys/parse-jwk.js";
import { jwkThumbprint, jwkThumbprintBase64Url } from "./thumbprint.js";
// RFC 7638 §3.1 worked example.
const RFC_7638_JWK = {
kty: "RSA" as const,
n:
"0vx7agoebGcQSuuPiLJXZptN9nndrQmbXEps2aiAFbWhM78LhWx" +
"4cbbfAAtVT86zwu1RK7aPFFxuhDR1L6tSoc_BJECPebWKRXjBZCi" +
"FV4n3oknjhMstn64tZ_2W-5JsGY4Hc5n9yBXArwl93lqt7_RN5w6" +
"Cf0h4QyQ5v-65YGjQR0_FDW2QvzqY368QQMicAtaSqzs8KJZgnYb" +
"9c7d0zgdAZHzu6qMQvRL5hajrn1n91CbOpbISD08qNLyrdkt-bFT" +
"WhAI4vMQFh6WeZu0fM4lFd2NcRwr3XPksINHaQ-G_xBniIqbw0Ls" +
"1jF44-csFCur-kEgU8awapJzKnqDKgw",
e: "AQAB",
};
const RFC_7638_EXPECTED_BASE64URL =
"NzbLsXh8uDCcd-6MNwXF4W_7noWXFZAfHkxZsRGC9Xs";
describe("jwkThumbprint", () => {
it("matches RFC 7638 §3.1 worked example", { timeout: 5000 }, () => {
const got = toBase64Url(jwkThumbprint(RFC_7638_JWK));
expect(got).toBe(RFC_7638_EXPECTED_BASE64URL);
});
it("matches the same value via the convenience helper", {
timeout: 5000,
}, () => {
expect(jwkThumbprintBase64Url(RFC_7638_JWK)).toBe(
RFC_7638_EXPECTED_BASE64URL
);
});
it("accepts an RsaPublicKey value (parsed from the JWK)", {
timeout: 5000,
}, () => {
const key = parseRsaPublicKeyJwk(RFC_7638_JWK);
const got = toBase64Url(jwkThumbprint(key));
expect(got).toBe(RFC_7638_EXPECTED_BASE64URL);
});
it("accepts the SHA-512 hash override", { timeout: 5000 }, () => {
const a = jwkThumbprint(RFC_7638_JWK);
const b = jwkThumbprint(RFC_7638_JWK, { hash: "SHA-512" });
// Different hash → different output, different length.
expect(b.length).toBe(64);
expect(a.length).toBe(32);
});
it("rejects unsupported hash algorithm", { timeout: 5000 }, () => {
try {
jwkThumbprint(RFC_7638_JWK, {
hash: "MD5" as never,
});
throw new Error("should have thrown");
} catch (err) {
expect((err as CryptoRsaError).code).toBe(
CRYPTO_RSA_CODES.UNSUPPORTED_HASH
);
}
});
it("rejects empty `e` member", { timeout: 5000 }, () => {
const broken = { ...RFC_7638_JWK, e: "" };
try {
jwkThumbprint(broken);
throw new Error("should have thrown");
} catch (err) {
expect((err as CryptoRsaError).code).toBe(
CRYPTO_RSA_CODES.INVALID_KEY
);
}
});
it("rejects empty `n` member", { timeout: 5000 }, () => {
const broken = { ...RFC_7638_JWK, n: "" };
try {
jwkThumbprint(broken);
throw new Error("should have thrown");
} catch (err) {
expect((err as CryptoRsaError).code).toBe(
CRYPTO_RSA_CODES.INVALID_KEY
);
}
});
});

View File

@@ -0,0 +1,78 @@
import { toBase64Url } from "@suaveplan/crypto-utils";
import { digestWith, hashOutputLength } from "../encoding/mgf1.js";
import { CRYPTO_RSA_CODES, CryptoRsaError } from "../errors/errors.js";
import type {
RsaHashAlgorithm,
RsaJwk,
RsaPublicKey,
} from "../keys/keys.types.js";
import { serializeRsaPublicKeyJwk } from "../keys/serialize-jwk.js";
/**
* Optional input for `jwkThumbprint`. The hash defaults to `"SHA-256"` per
* RFC 7638 §3.2 (the canonical thumbprint hash).
*/
export interface JwkThumbprintOptions {
readonly hash?: RsaHashAlgorithm;
}
/**
* Compute an RSA JWK thumbprint per RFC 7638 §3.2.
*
* The canonical input is the JSON object `{"e":"<b64u(e)>","kty":"RSA",
* "n":"<b64u(n)>"}` with members in alphabetical order — `e`, `kty`, `n`.
* `JSON.stringify` is NOT used because key insertion order is implementation
* defined; this implementation builds the canonical UTF-8 byte string by
* hand, hashes it with the configured algorithm, and returns the digest as
* a `Uint8Array`.
*
* @throws CryptoRsaError(CRYPTO_RSA.UNSUPPORTED_HASH) when `hash` is not
* one of the four declared `RsaHashAlgorithm` values.
*/
export function jwkThumbprint(
key: RsaPublicKey | RsaJwk,
options: JwkThumbprintOptions = {}
): Uint8Array {
const hash = options.hash ?? "SHA-256";
// Validate hash early so the caller gets a typed error before any
// digest work.
void hashOutputLength(hash);
const jwk: RsaJwk = isJwk(key) ? key : serializeRsaPublicKeyJwk(key);
const eMember = jwk.e;
const nMember = jwk.n;
if (typeof eMember !== "string" || eMember.length === 0) {
throw new CryptoRsaError(
CRYPTO_RSA_CODES.INVALID_KEY,
"error.crypto-rsa.jwk-thumbprint.missing-e"
);
}
if (typeof nMember !== "string" || nMember.length === 0) {
throw new CryptoRsaError(
CRYPTO_RSA_CODES.INVALID_KEY,
"error.crypto-rsa.jwk-thumbprint.missing-n"
);
}
const canonical = `{"e":"${eMember}","kty":"RSA","n":"${nMember}"}`;
const bytes = new TextEncoder().encode(canonical);
return digestWith(hash, bytes);
}
/**
* Compute an RSA JWK thumbprint and return the URL-safe base64 encoding
* directly. Convenience over `jwkThumbprint` + `toBase64Url`.
*/
export function jwkThumbprintBase64Url(
key: RsaPublicKey | RsaJwk,
options: JwkThumbprintOptions = {}
): string {
return toBase64Url(jwkThumbprint(key, options));
}
function isJwk(value: RsaPublicKey | RsaJwk): value is RsaJwk {
return (
typeof value === "object" &&
value !== null &&
"kty" in value &&
(value as { kty: unknown }).kty === "RSA"
);
}

View File

@@ -0,0 +1,68 @@
# `keygen/exponent`
## Purpose
Public-exponent validation for RSA key generation. The `e`-only
constraints (odd, `> 1`, `< 2^256`) live here so they can be
asserted at the API boundary before the caller pays for prime
sampling. Coprimality with `λ(n)` happens later in
`generate.ts` once the primes are known.
## Features
- Single boundary check that maps every violation to
`CRYPTO_RSA.INVALID_KEY` with an `e` context for diagnosis.
- Auxiliary `gcd` helper exported so the higher-level keygen
loop can verify `gcd(e, λ(n)) === 1` without duplicating the
Euclidean algorithm.
- Conforms to NIST FIPS 186-5 §5.4 (`2^16 < e < 2^256`); this
module enforces the upper bound and `e > 1`. The lower bound
`e ≥ 2^16 + 1` is profile-specific (FIPS-186-5 mode) and lives
in the policy / profile layer.
## Usage: Basic Example
```ts
import { validatePublicExponent } from "@suaveplan/crypto-rsa/keygen/exponent";
validatePublicExponent(65537n); // ok
validatePublicExponent(3n); // ok — permissive profile
validatePublicExponent(4n); // throws CRYPTO_RSA.INVALID_KEY
```
## Usage: Advanced Example
Combine with `gcd` to cull candidate primes whose `(p-1)` shares
a factor with `e`:
```ts
import { gcd, validatePublicExponent } from "@suaveplan/crypto-rsa/keygen/exponent";
validatePublicExponent(65537n);
const candidate = 0x...n; // some probable prime
if (gcd(candidate - 1n, 65537n) !== 1n) {
// discard, sample again
}
```
## API Reference
### `validatePublicExponent(e: bigint): void`
Throws `CRYPTO_RSA.INVALID_KEY` on `e ≤ 1`, even `e`, or
`e ≥ 2^256`. Returns `void` on success.
### `gcd(a: bigint, b: bigint): bigint`
Iterative Euclidean algorithm; works on any bigint pair, signs
ignored.
## Implementation Notes
- The `gcd` implementation is the standard iterative Euclidean
loop rather than a recursive one to avoid V8 stack-frame
overhead on very large bigint pairs (`λ(n)` for a 4096-bit
modulus is roughly 4096 bits).
- The `2^256` upper bound is hard-coded as a literal because it
is a bright-line spec value; any future relaxation must
modify this module deliberately, not via a config flag.

View File

@@ -0,0 +1,56 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { CRYPTO_RSA_CODES, CryptoRsaError } from "../errors/errors.js";
import { gcd, validatePublicExponent } from "./exponent.js";
describe("validatePublicExponent", () => {
it("accepts canonical exponents", { timeout: 5000 }, () => {
expect(() => validatePublicExponent(3n)).not.toThrow();
expect(() => validatePublicExponent(17n)).not.toThrow();
expect(() => validatePublicExponent(65537n)).not.toThrow();
expect(() => validatePublicExponent((1n << 16n) + 1n)).not.toThrow();
});
it("rejects e ≤ 1", { timeout: 5000 }, () => {
for (const e of [-1n, 0n, 1n]) {
try {
validatePublicExponent(e);
throw new Error("should have thrown");
} catch (err) {
expect(err).toBeInstanceOf(CryptoRsaError);
expect((err as CryptoRsaError).code).toBe(
CRYPTO_RSA_CODES.INVALID_KEY
);
}
}
});
it("rejects even e", { timeout: 5000 }, () => {
for (const e of [2n, 4n, 1024n, 65536n]) {
expect(() => validatePublicExponent(e)).toThrow(CryptoRsaError);
}
});
it("rejects e ≥ 2^256", { timeout: 5000 }, () => {
expect(() => validatePublicExponent(1n << 256n)).toThrow(
CryptoRsaError
);
expect(() => validatePublicExponent((1n << 256n) + 1n)).toThrow(
CryptoRsaError
);
});
});
describe("gcd", () => {
it("matches known values", { timeout: 5000 }, () => {
expect(gcd(0n, 5n)).toBe(5n);
expect(gcd(5n, 0n)).toBe(5n);
expect(gcd(12n, 18n)).toBe(6n);
expect(gcd(65537n, 100000000n)).toBe(1n);
});
it("ignores sign", { timeout: 5000 }, () => {
expect(gcd(-12n, 18n)).toBe(6n);
expect(gcd(12n, -18n)).toBe(6n);
expect(gcd(-12n, -18n)).toBe(6n);
});
});

View File

@@ -0,0 +1,56 @@
import { CRYPTO_RSA_CODES, CryptoRsaError } from "../errors/errors.js";
const TWO_POW_256 = 1n << 256n;
/**
* Validate that a candidate public exponent satisfies the structural
* constraints common to every RSA-key-pair generation profile in this
* package:
*
* - `e` must be odd (otherwise `gcd(e, λ(n)) > 1` for any prime `n`).
* - `e > 1` — `e === 1` makes `c === m`, no encryption.
* - `e < 2^256` — guards against exponentiation cost amplification by a
* malicious caller. NIST FIPS 186-5 §5.4 mandates the same upper bound.
*
* Coprimality with `λ(n)` is checked separately by the caller after the
* primes are sampled; this function does only the `e`-only checks.
*
* @throws CryptoRsaError(CRYPTO_RSA.INVALID_KEY) on any violation
*/
export function validatePublicExponent(e: bigint): void {
if (e <= 1n) {
throw new CryptoRsaError(
CRYPTO_RSA_CODES.INVALID_KEY,
"error.crypto-rsa.exponent.too-small",
{ context: { e: e.toString() } }
);
}
if ((e & 1n) === 0n) {
throw new CryptoRsaError(
CRYPTO_RSA_CODES.INVALID_KEY,
"error.crypto-rsa.exponent.even",
{ context: { e: e.toString() } }
);
}
if (e >= TWO_POW_256) {
throw new CryptoRsaError(
CRYPTO_RSA_CODES.INVALID_KEY,
"error.crypto-rsa.exponent.too-large",
{ context: { e: e.toString() } }
);
}
}
/**
* Greatest common divisor (Euclidean algorithm). Re-exported here because
* the keygen pipeline uses it to verify `gcd(e, λ(n)) === 1` after
* sampling a prime pair, before commiting to the candidate.
*/
export function gcd(a: bigint, b: bigint): bigint {
let x = a < 0n ? -a : a;
let y = b < 0n ? -b : b;
while (y !== 0n) {
[x, y] = [y, x % y];
}
return x;
}

View File

@@ -0,0 +1,112 @@
# `keygen/generate`
## Purpose
RSA key-pair generation per NIST FIPS 186-5 Appendix A.1 and
SP 800-56B Rev. 2 §6.3. Two-prime by default; multi-prime
(`primes ∈ {3, 4, 5}`) when callers explicitly opt in.
The algorithm samples each prime as a random odd `bits`-wide
integer with the top two bits forced to `1` (so the product
always reaches the target modulus length), checks coprimality
with `e`, runs the Miller-Rabin probable-prime test from the
sibling module, and assembles a canonical `RsaPrivateKey` with
all CRT fields populated.
## Features
- `generateRsaKeyPair(options)` returns a fully-formed
`RsaPrivateKey` ready for any encrypt / decrypt / sign / verify
surface in the package.
- `AbortSignal` plumbed through the prime-search loop — yields
via `await Promise.resolve()` every 64 candidates so an
aborted signal scheduled via `queueMicrotask` lands within
≤ 100 ms.
- Deterministic mode — identical seeded `Rng` produces
byte-identical keys. Used by tests and by reproducible-build
pipelines.
- Miller-Rabin exhaustion guard — caps each prime search at
100 000 candidates and surfaces `CRYPTO_RSA.KEYGEN_FAILED` on
hitting the cap (vanishingly rare in practice).
- Multi-prime delegation — `primes !== 2` routes to the sibling
`generateMultiPrimeKey` for the RFC 8017 §3 multi-prime path.
- `crypto_rsa.keygen_duration_ms{modulus_bits,primes}` histogram.
## Usage: Basic Example
```ts
import { generateRsaKeyPair } from "@suaveplan/crypto-rsa/keygen/generate";
const key = await generateRsaKeyPair({ modulusBits: 2048 });
// key has n, e, d, p, q, dp, dq, qInv all populated
```
## Usage: Advanced Example
Cancel a slow generation on a timeout via `AbortSignal`:
```ts
import { generateRsaKeyPair } from "@suaveplan/crypto-rsa/keygen/generate";
const ctrl = new AbortController();
setTimeout(() => ctrl.abort(), 5000);
try {
const key = await generateRsaKeyPair({
modulusBits: 4096,
signal: ctrl.signal,
});
} catch (err) {
if (err.code === "CRYPTO_RSA.ABORTED") {
// surfaced from the next yield point in the prime search
}
}
```
Deterministic seeded generation for tests:
```ts
const seedRng = makeSeededRng(0xdeadbeef);
const k1 = await generateRsaKeyPair({
modulusBits: 1024,
rng: seedRng(),
});
const k2 = await generateRsaKeyPair({
modulusBits: 1024,
rng: seedRng(), // fresh from same seed
});
// k1 and k2 byte-identical
```
## API Reference
### `generateRsaKeyPair(options?): Promise<RsaPrivateKey>`
- `options.modulusBits?: number` — defaults to 3072.
- `options.publicExponent?: bigint` — defaults to `65537n`.
- `options.primes?: number` — defaults to 2; accepts 3–5.
- `options.rng?: Rng` — defaults to `defaultRng()`.
- `options.signal?: AbortSignal` — optional cancellation.
### `samplePrime(bits, e, rng, signal): Promise<bigint>`
Building block exposed for tests and for the multi-prime path.
Returns a probable prime with the top two bits set and coprime
to `e`. Throws `CRYPTO_RSA.KEYGEN_FAILED` after 100 000
candidates without success.
## Implementation Notes
- The top-two-bits-set rule guarantees that any two `bits`-wide
primes `p, q` produce a `p * q` of exactly `2 * bits` bits.
The post-multiplication length check is a paranoia guard that
fails loudly on a pathological RNG draw.
- The `bits` split on odd `modulusBits` is `(modulusBits + 1)/2`
for `p` and `modulusBits/2` for `q` so the top prime carries
the extra bit.
- `λ(n) = lcm(p-1, q-1)` per RFC 8017's modern derivation; this
produces a strictly smaller `d` than `φ(n) = (p-1)(q-1)`
while remaining cryptographically equivalent.
- `await Promise.resolve()` between candidate batches is the
cheapest yield point in V8 — it lets the microtask queue drain
(so a `queueMicrotask`-scheduled `signal.abort()` lands)
without paying for `setTimeout(0)`.

View File

@@ -0,0 +1,330 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { Rng } from "@suaveplan/types";
import { CRYPTO_RSA_CODES, CryptoRsaError } from "../errors/errors.js";
import { modPow } from "../math/modpow.js";
import { generateRsaKeyPair } from "./generate.js";
// Speed tradeoff: 512-bit RSA generation takes ~50-300ms in V8 / WSL2
// and exercises every algorithm path that 2048-bit does (sampling,
// Miller-Rabin trial division + witness loop, lcm, invert). We run a
// 1024-bit case once to prove the larger size works end-to-end and
// keep the rest at 512-bit for fast feedback.
function seededRng(seed: number): Rng {
let state = seed >>> 0;
return {
nextUint32: () => {
state = (Math.imul(state, 1664525) + 1013904223) >>> 0;
return state;
},
nextFloat: () => 0,
nextBytes: (n: number) => {
const out = new Uint8Array(n);
for (let i = 0; i < n; i++) {
state = (Math.imul(state, 1664525) + 1013904223) >>> 0;
out[i] = (state >>> 24) & 0xff;
}
return out;
},
};
}
describe("generateRsaKeyPair", () => {
it("generates a 512-bit two-prime key with valid CRT params", {
timeout: 30000,
}, async () => {
const key = await generateRsaKeyPair({
modulusBits: 512,
publicExponent: 65537n,
});
expect(key.bitLength).toBe(512);
expect(key.n).toBe(key.p * key.q);
expect(key.e).toBe(65537n);
// Encrypt-then-decrypt sanity: m^e^d = m mod n
const m = 0x1234n;
const c = modPow(m, key.e, key.n);
const decrypted = modPow(c, key.d, key.n);
expect(decrypted).toBe(m);
});
it("generates a 1024-bit key end-to-end (slow path)", {
timeout: 60000,
}, async () => {
const key = await generateRsaKeyPair({ modulusBits: 1024 });
expect(key.bitLength).toBe(1024);
expect(key.n).toBe(key.p * key.q);
});
it("deterministic: same seed produces identical keys", {
timeout: 30000,
}, async () => {
const a = await generateRsaKeyPair({
modulusBits: 512,
rng: seededRng(0xc0ffee),
});
const b = await generateRsaKeyPair({
modulusBits: 512,
rng: seededRng(0xc0ffee),
});
expect(a.n).toBe(b.n);
expect(a.d).toBe(b.d);
});
it("AbortSignal pre-aborted throws CRYPTO_RSA.ABORTED", {
timeout: 5000,
}, async () => {
const ctrl = new AbortController();
ctrl.abort();
try {
await generateRsaKeyPair({
modulusBits: 512,
signal: ctrl.signal,
});
throw new Error("should have thrown");
} catch (err) {
expect((err as CryptoRsaError).code).toBe(CRYPTO_RSA_CODES.ABORTED);
}
});
it("AbortSignal mid-flight throws CRYPTO_RSA.ABORTED", {
timeout: 30000,
}, async () => {
const ctrl = new AbortController();
queueMicrotask(() => ctrl.abort());
try {
await generateRsaKeyPair({
modulusBits: 2048,
signal: ctrl.signal,
});
throw new Error("should have thrown");
} catch (err) {
expect((err as CryptoRsaError).code).toBe(CRYPTO_RSA_CODES.ABORTED);
}
});
it("rejects bad modulusBits", { timeout: 5000 }, async () => {
await expect(generateRsaKeyPair({ modulusBits: 100 })).rejects.toThrow(
CryptoRsaError
);
await expect(
generateRsaKeyPair({ modulusBits: 1.5 as unknown as number })
).rejects.toThrow(CryptoRsaError);
});
it("rejects bad publicExponent", { timeout: 5000 }, async () => {
await expect(
generateRsaKeyPair({
modulusBits: 512,
publicExponent: 4n,
})
).rejects.toThrow(CryptoRsaError);
});
it("rejects bad primes count", { timeout: 5000 }, async () => {
await expect(
generateRsaKeyPair({ modulusBits: 512, primes: 6 })
).rejects.toThrow(CryptoRsaError);
await expect(
generateRsaKeyPair({ modulusBits: 512, primes: 1 })
).rejects.toThrow(CryptoRsaError);
});
});
describe("generateRsaKeyPair samplePrimeOverride seam", () => {
it("drives the q === p resample branch", { timeout: 30000 }, async () => {
// First two calls return the same prime, third returns a
// different one — exercises the `while (q === p)` resample
// loop in generateTwoPrime (otherwise unreachable for
// CSPRNG-sampled 256-bit+ primes). We don't care whether
// assembly succeeds; we only need the resample branch to
// execute, which is observable through call-count tracking.
const sequence = [13n, 13n, 11n];
let i = 0;
const dup = async () => {
const v = sequence[i++] as bigint;
if (v === undefined) {
throw new Error("sampler called too many times");
}
return v;
};
try {
await generateRsaKeyPair({
modulusBits: 512,
publicExponent: 7n,
samplePrimeOverride: dup,
});
} catch {
// assembleTwoPrime will reject because 11*13 has the
// wrong bit-length; the test goal is just to execute the
// resample loop, which is proven by the call-count
// assertion below.
}
expect(i).toBeGreaterThanOrEqual(3);
});
it("falls back to DEFAULT_MODULUS_BITS when modulusBits is omitted", {
timeout: 5000,
}, async () => {
// The `options.modulusBits ?? DEFAULT_MODULUS_BITS` branch is
// covered when the caller passes options WITHOUT the
// modulusBits field. Inject a sampler that throws so we exit
// before any real 3072-bit keygen work happens.
const fast = async () => {
throw new CryptoRsaError(
CRYPTO_RSA_CODES.KEYGEN_FAILED,
"error.crypto-rsa.keygen.miller-rabin-exhausted"
);
};
try {
await generateRsaKeyPair({ samplePrimeOverride: fast });
throw new Error("should have thrown");
} catch (err) {
expect((err as CryptoRsaError).code).toBe(
CRYPTO_RSA_CODES.KEYGEN_FAILED
);
}
});
it("forwards samplePrimeOverride into multi-prime keygen", {
timeout: 30000,
}, async () => {
// Tracks that the override is threaded through the
// multi-prime branch. Sampler returns small primes that
// won't satisfy the bit-length invariant; assembleMultiPrimeKey
// returns undefined and the loop retries; eventually the
// outer-attempts cap fires KEYGEN_FAILED. The point of the
// test is the override-propagation branch coverage.
const fast = async () => 7n;
try {
await generateRsaKeyPair({
modulusBits: 512,
primes: 3,
samplePrimeOverride: fast,
});
throw new Error("should have thrown");
} catch (err) {
expect((err as CryptoRsaError).code).toBe(
CRYPTO_RSA_CODES.KEYGEN_FAILED
);
}
});
it("propagates KEYGEN_FAILED when sampler always exhausts", {
timeout: 5000,
}, async () => {
// Sampler that always throws KEYGEN_FAILED — the same code
// the production samplePrime raises after MAX_PRIME_CANDIDATES
// exhaustion. This proves the error propagates through
// generateTwoPrime + generateRsaKeyPair without being
// swallowed.
const exhausted = async () => {
throw new CryptoRsaError(
CRYPTO_RSA_CODES.KEYGEN_FAILED,
"error.crypto-rsa.keygen.miller-rabin-exhausted"
);
};
try {
await generateRsaKeyPair({
modulusBits: 512,
samplePrimeOverride: exhausted,
});
throw new Error("should have thrown");
} catch (err) {
expect((err as CryptoRsaError).code).toBe(
CRYPTO_RSA_CODES.KEYGEN_FAILED
);
}
});
});
describe("samplePrime exhaustion", () => {
it("throws KEYGEN_FAILED when maxCandidates exhausts", {
timeout: 5000,
}, async () => {
// bits=4 → candidate forced to 0b1101=13 OR 0b1111=15 etc.
// With a constant-byte Rng returning 0 every byte, the
// candidate is min=8 with all forced bits → 0b1101=13 (prime).
// Override `e=13n`: gcd(13-1, 13)=gcd(12,13)=1 (coprime), then
// isProbablePrime(13)=true → returns immediately. No good.
// Instead set candidate to 15: bytes=[0x07] masked & 0x0F=7,
// then force bit3+bit2+bit0 → 0b1111=15. 15 is composite.
// e=2n is invalid for production, but samplePrime accepts any
// bigint here. gcd(14, 2)=2≠1 → continue (gcd reject path).
// Loop never finds a prime — exhausts after maxCandidates.
const { samplePrime } = await import("./generate.js");
const constByte: Rng = {
nextUint32: () => 0x07070707,
nextFloat: () => 0,
nextBytes: (n: number) => new Uint8Array(n).fill(0x07),
};
try {
await samplePrime(4, 2n, constByte, undefined, 16);
throw new Error("should have thrown");
} catch (err) {
expect((err as CryptoRsaError).code).toBe(
CRYPTO_RSA_CODES.KEYGEN_FAILED
);
}
});
});
describe("samplePrime", () => {
it("gcd(candidate-1, e) reject path: many runs guarantee a candidate hits the reject branch", {
timeout: 60000,
}, async () => {
const { samplePrime } = await import("./generate.js");
// bits=10, e=3: ~1/3 of candidates land on the gcd-not-1
// reject branch (the candidates ≡ 1 mod 3). Run 10 samples
// so the cumulative probability of hitting the reject path
// at least once is effectively 1.
for (let i = 0; i < 10; i++) {
const p = await samplePrime(10, 3n, defaultRngLocal(), undefined);
expect(p).toBeGreaterThan(0n);
}
});
});
function defaultRngLocal() {
return {
nextUint32: () => {
const buf = new Uint8Array(4);
crypto.getRandomValues(buf);
return new DataView(buf.buffer).getUint32(0);
},
nextFloat: () => Math.random(),
nextBytes: (n: number) => {
const buf = new Uint8Array(n);
crypto.getRandomValues(buf);
return buf;
},
};
}
describe("assembleTwoPrime", () => {
it("rejects mismatched modulus bit length (defensive guard)", {
timeout: 5000,
}, async () => {
// Two small primes whose product is far from the requested
// modulusBits — drives the modulus-bits-mismatch defensive
// guard branch.
const { assembleTwoPrime } = await import("./generate.js");
try {
assembleTwoPrime(7n, 11n, 3n, 2048);
throw new Error("should have thrown");
} catch (err) {
expect((err as CryptoRsaError).code).toBe(
CRYPTO_RSA_CODES.KEYGEN_FAILED
);
}
});
it("assembles a two-prime key from explicit primes", {
timeout: 5000,
}, async () => {
const { assembleTwoPrime } = await import("./generate.js");
// Synthetic 8-bit pair: p=251, q=241, n=60491 (16 bits).
const key = assembleTwoPrime(251n, 241n, 65537n, 16);
expect(key.bitLength).toBe(16);
expect(key.n).toBe(60491n);
});
});

View File

@@ -0,0 +1,236 @@
import { invert } from "@noble/curves/abstract/modular";
import type { Rng } from "@suaveplan/types";
import { defaultRng } from "../encrypt/default-rng.js";
import { CRYPTO_RSA_CODES, CryptoRsaError } from "../errors/errors.js";
import type { RsaPrivateKey } from "../keys/keys.types.js";
import { finalisePrivateKey } from "../keys/parse-pkcs1-private.js";
import {
recordKeygenDuration,
recordThrown,
} from "../observability/metrics.js";
import { gcd, validatePublicExponent } from "./exponent.js";
import { isProbablePrime, uniformBigint } from "./miller-rabin.js";
import { generateMultiPrimeKey, type SamplePrimeFn } from "./multi-prime.js";
/**
* Optional input for `generateRsaKeyPair`. All fields default sensibly:
*
* - `modulusBits` — defaults to 3072 (NIST FIPS 186-5 recommendation
* through 2030).
* - `publicExponent` — defaults to `65537n` (Fermat-4).
* - `primes` — defaults to 2. Set to 3, 4, or 5 for multi-prime RSA.
* - `rng` — defaults to `defaultRng()` (CSPRNG-backed).
* - `signal` — optional `AbortSignal`. Aborts surface as
* `CRYPTO_RSA.ABORTED` from the next prime-search yield point.
*/
export interface GenerateRsaKeyPairOptions {
readonly modulusBits?: number;
readonly publicExponent?: bigint;
readonly primes?: number;
readonly rng?: Rng;
readonly signal?: AbortSignal;
/**
* Test-only hook: substitute the prime-sampling function. Used by the
* keygen test suite to drive the `q === p` resample branch and the
* Miller-Rabin exhaustion guard (both probability < 2^-1000 with the
* default CSPRNG-sampled flow). Production callers must NOT set this
* field; the default `samplePrime` enforces the FIPS 186-5 prime
* search.
*/
readonly samplePrimeOverride?: SamplePrimeFn;
}
const DEFAULT_MODULUS_BITS = 3072;
const DEFAULT_PUBLIC_EXPONENT = 65537n;
const MAX_PRIME_CANDIDATES = 100_000;
const YIELD_INTERVAL = 64;
/**
* Generate an RSA key pair per NIST FIPS 186-5 Appendix A.1 / SP 800-56B
* Rev. 2 §6.3 guidance. Two-prime by default; multi-prime when
* `primes ∈ {3, 4, 5}`.
*
* The prime-search loop yields control back to the event loop every
* `YIELD_INTERVAL` candidates via `await Promise.resolve()` so an
* `AbortSignal` scheduled via `queueMicrotask` lands within ≤ 100 ms.
*
* @throws CryptoRsaError(CRYPTO_RSA.INVALID_KEY) on bad option shape
* @throws CryptoRsaError(CRYPTO_RSA.KEYGEN_FAILED) on Miller-Rabin
* exhaustion (> 100 000 candidates per prime)
* @throws CryptoRsaError(CRYPTO_RSA.ABORTED) when `signal.aborted`
*/
export async function generateRsaKeyPair(
options: GenerateRsaKeyPairOptions
): Promise<RsaPrivateKey> {
const start = nowMs();
const opts = options;
const modulusBits = opts.modulusBits ?? DEFAULT_MODULUS_BITS;
const publicExponent = opts.publicExponent ?? DEFAULT_PUBLIC_EXPONENT;
const primes = opts.primes ?? 2;
const rng = opts.rng ?? defaultRng();
const signal = opts.signal;
try {
if (!Number.isInteger(modulusBits) || modulusBits < 512) {
throw new CryptoRsaError(
CRYPTO_RSA_CODES.INVALID_KEY,
"error.crypto-rsa.keygen.modulus-bits-invalid",
{ context: { modulusBits } }
);
}
validatePublicExponent(publicExponent);
if (![2, 3, 4, 5].includes(primes)) {
throw new CryptoRsaError(
CRYPTO_RSA_CODES.INVALID_KEY,
"error.crypto-rsa.keygen.primes-invalid",
{ context: { primes } }
);
}
const sampler: SamplePrimeFn = opts.samplePrimeOverride ?? samplePrime;
const key =
primes === 2
? await generateTwoPrime(
modulusBits,
publicExponent,
rng,
signal,
sampler
)
: await generateMultiPrimeKey({
modulusBits,
publicExponent,
primes,
rng,
signal,
...(opts.samplePrimeOverride !== undefined && {
samplePrimeOverride: opts.samplePrimeOverride,
}),
});
recordKeygenDuration(modulusBits, primes, nowMs() - start);
return key;
} catch (err) {
recordThrown(err);
throw err;
}
}
async function generateTwoPrime(
modulusBits: number,
e: bigint,
rng: Rng,
signal: AbortSignal | undefined,
sampler: SamplePrimeFn
): Promise<RsaPrivateKey> {
const halfBits = modulusBits >> 1;
const halfBitsHigh = modulusBits - halfBits;
const p = await sampler(halfBitsHigh, e, rng, signal);
let q = await sampler(halfBits, e, rng, signal);
while (q === p) {
// Vanishingly improbable for CSPRNG-sampled primes, but the
// resample is cheap and lets us avoid a degenerate `n = p^2`.
q = await sampler(halfBits, e, rng, signal);
}
return assembleTwoPrime(p, q, e, modulusBits);
}
/**
* Assemble a two-prime `RsaPrivateKey` from already-sampled primes.
* Exported for keygen unit tests that need to drive the assembly step
* with deterministic prime inputs (e.g. property-based testing of
* the CRT-field invariants).
*
* @throws CryptoRsaError(CRYPTO_RSA.KEYGEN_FAILED) when the resulting
* modulus differs from the requested bit length — this guards
* against a pathological RNG draw silently emitting an
* off-by-one key.
*/
export function assembleTwoPrime(
p: bigint,
q: bigint,
e: bigint,
modulusBits: number
): RsaPrivateKey {
const [pBig, qBig] = p > q ? [p, q] : [q, p];
const n = pBig * qBig;
if (n.toString(2).length !== modulusBits) {
throw new CryptoRsaError(
CRYPTO_RSA_CODES.KEYGEN_FAILED,
"error.crypto-rsa.keygen.modulus-bits-mismatch",
{
context: {
expected: modulusBits,
actual: n.toString(2).length,
},
}
);
}
const lambda = lcm(pBig - 1n, qBig - 1n);
const d = invert(e, lambda);
return finalisePrivateKey({
n,
e,
d,
p: pBig,
q: qBig,
dp: d % (pBig - 1n),
dq: d % (qBig - 1n),
qInv: invert(qBig, pBig),
});
}
/**
* Sample a probable prime of exactly `bits` bits coprime with `e`.
* Top two bits are forced to `1` so `p * q` always reaches the target
* modulus length; bottom bit forced to `1` to guarantee oddness.
*
* `maxCandidates` is a test-only escape hatch — production callers omit
* it and get the FIPS-grade default of `MAX_PRIME_CANDIDATES = 100 000`.
* The keygen test suite passes a small value to drive the exhaustion
* `KEYGEN_FAILED` path against a deliberately composite-only sampler
* within the suite's wall-clock budget.
*/
export async function samplePrime(
bits: number,
e: bigint,
rng: Rng,
signal: AbortSignal | undefined,
maxCandidates: number = MAX_PRIME_CANDIDATES
): Promise<bigint> {
const min = 1n << BigInt(bits - 1);
const max = (1n << BigInt(bits)) - 1n;
for (let i = 0; i < maxCandidates; i++) {
if ((i & (YIELD_INTERVAL - 1)) === 0) {
await Promise.resolve();
if (signal?.aborted === true) {
throw new CryptoRsaError(
CRYPTO_RSA_CODES.ABORTED,
"error.crypto-rsa.keygen.aborted"
);
}
}
let candidate = uniformBigint(min, max, rng);
// Force top two bits = 11 (so p*q has exactly the target length)
// and bottom bit = 1 (oddness).
candidate |= 1n << BigInt(bits - 1);
candidate |= 1n << BigInt(bits - 2);
candidate |= 1n;
if (gcd(candidate - 1n, e) !== 1n) {
continue;
}
if (isProbablePrime(candidate, rng)) {
return candidate;
}
}
throw new CryptoRsaError(
CRYPTO_RSA_CODES.KEYGEN_FAILED,
"error.crypto-rsa.keygen.miller-rabin-exhausted",
{ context: { bits } }
);
}
function lcm(a: bigint, b: bigint): bigint {
return (a / gcd(a, b)) * b;
}
function nowMs(): number {
return performance.now();
}

View File

@@ -0,0 +1,17 @@
export { gcd, validatePublicExponent } from "./exponent.js";
export {
assembleTwoPrime,
type GenerateRsaKeyPairOptions,
generateRsaKeyPair,
samplePrime,
} from "./generate.js";
export {
isProbablePrime,
SMALL_PRIMES,
uniformBigint,
} from "./miller-rabin.js";
export {
assembleMultiPrimeKey,
type GenerateMultiPrimeOptions,
generateMultiPrimeKey,
} from "./multi-prime.js";

View File

@@ -0,0 +1,75 @@
# `keygen/miller-rabin`
## Purpose
Probable-prime test per NIST FIPS 186-5 Appendix B.3.1, used by
`generateRsaKeyPair` to qualify candidate primes during RSA key
generation. The default 64 rounds achieve ≥ 128-bit security
against composites for randomly-sampled candidates — the
prevailing recommendation for RSA key generation in 2026.
## Features
- Trial division by the first 53 odd primes (≤ 251) before any
modular exponentiation; eliminates ~80% of uniform random
candidates on average.
- Configurable round count — default 64. Lowering to 5 (e.g. for
fast unit tests) trades probabilistic certainty for speed.
- Uniform witness sampling via the injected `Rng` — works with
both CSPRNG-backed `defaultRng()` and seeded deterministic
test RNGs.
- `uniformBigint` exported as a reusable building block for any
caller that needs unbiased bigint sampling.
## Usage: Basic Example
```ts
import { isProbablePrime } from "@suaveplan/crypto-rsa/keygen/miller-rabin";
import { defaultRng } from "@suaveplan/crypto-rsa/encrypt/default-rng";
console.log(isProbablePrime(2147483647n, defaultRng())); // true (Mersenne)
console.log(isProbablePrime(561n, defaultRng())); // false (Carmichael)
```
## Usage: Advanced Example
Lower the round count for fast tests at the cost of negligible
false-positive risk on Carmichael-like inputs:
```ts
import { isProbablePrime } from "@suaveplan/crypto-rsa/keygen/miller-rabin";
const fast = (n: bigint, rng) => isProbablePrime(n, rng, 8);
```
## API Reference
### `isProbablePrime(n, rng, rounds = 64): boolean`
- `n: bigint` — candidate to test.
- `rng: Rng` — uniform witness source.
- `rounds: number` — default 64. Each round picks a fresh witness.
- Returns `true` if probably prime, `false` if definitely composite.
### `uniformBigint(min, maxInclusive, rng): bigint`
Returns a uniform bigint in `[min, maxInclusive]` via byte-wise
rejection sampling.
### `SMALL_PRIMES: ReadonlyArray<bigint>`
The first 53 odd primes through 251.
## Implementation Notes
- The Miller-Rabin witness loop short-circuits on the first
observed composite signature (non-trivial square root of 1)
rather than running through every round.
- The fallback modular-reduction path in `uniformBigint` only
triggers after 1024 rejection attempts — for any reasonable
range with the top-bit mask applied this never fires in
practice. Documented for completeness; biased by at most one
candidate's worth in the worst case.
- `SMALL_PRIMES` is a literal array rather than a `Set` because
the ordered scan benefits from monotonic comparison and the
set is small enough to iterate twice in cache time.

View File

@@ -0,0 +1,140 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { Rng } from "@suaveplan/types";
import {
isProbablePrime,
SMALL_PRIMES,
uniformBigint,
} from "./miller-rabin.js";
function seededRng(seed = 1): Rng {
let state = seed >>> 0;
return {
nextUint32: () => {
state = (Math.imul(state, 1664525) + 1013904223) >>> 0;
return state;
},
nextFloat: () => {
state = (Math.imul(state, 1664525) + 1013904223) >>> 0;
return state / 0x1_0000_0000;
},
};
}
describe("isProbablePrime", () => {
it("accepts known small primes", { timeout: 5000 }, () => {
const rng = seededRng();
for (const p of [3n, 7n, 97n, 251n, 2147483647n]) {
expect(isProbablePrime(p, rng)).toBe(true);
}
});
it("rejects known composites including Carmichael numbers", {
timeout: 5000,
}, () => {
const rng = seededRng();
for (const c of [4n, 9n, 15n, 91n, 561n, 1105n, 1729n]) {
expect(isProbablePrime(c, rng)).toBe(false);
}
});
it("rejects values < 2 and accepts 2 / 3", { timeout: 5000 }, () => {
const rng = seededRng();
expect(isProbablePrime(0n, rng)).toBe(false);
expect(isProbablePrime(1n, rng)).toBe(false);
expect(isProbablePrime(2n, rng)).toBe(true);
expect(isProbablePrime(3n, rng)).toBe(true);
});
it("deterministic with seeded RNG", { timeout: 5000 }, () => {
const a = isProbablePrime(101n, seededRng(42), 10);
const b = isProbablePrime(101n, seededRng(42), 10);
expect(a).toBe(b);
});
it("trial-division short-circuits even composites", {
timeout: 5000,
}, () => {
// n ≡ 0 (mod 251) but odd — 251 * 9 = 2259
expect(isProbablePrime(2259n, seededRng())).toBe(false);
});
it("SMALL_PRIMES contains 251 and starts at 3", () => {
expect(SMALL_PRIMES[0]).toBe(3n);
expect(SMALL_PRIMES.at(-1)).toBe(251n);
});
it("non-trivial sqrt of 1 path: Carmichael 561 across many seeds", {
timeout: 10000,
}, () => {
// Witness a=2 against n=561 squares to 1 (non-trivial sqrt
// of 1) — covers the `if (x === 1n) return true` branch.
// Iterate many seeds so at least one round picks the right
// witness early in the sequence.
for (let seed = 1; seed <= 50; seed++) {
expect(isProbablePrime(561n, seededRng(seed), 32)).toBe(false);
}
});
it("non-trivial sqrt of 1 path: n=109969 with deterministic witness a=35", {
timeout: 5000,
}, () => {
// n=109969 = 313*317 (composite, NOT divisible by any
// SMALL_PRIMES entry up to 251 so Miller-Rabin runs in full).
// n-1=109968=2^4 * 6873 → s=4, d=6873. Witness a=35:
// 35^6873 mod 109969 = X (≠ 1, ≠ n-1)
// X^2 mod 109969 = ... eventually = 1 inside the squaring
// loop — covers `if (x === 1n) return true` (the non-trivial
// sqrt of 1 branch at line 122).
//
// uniformBigint with min=2n, max=109967n: range=109966
// (17 bits, 3 bytes). topMask = (1 << (17 - 16)) - 1 = 1.
// We need v=33 (so a = min + v = 2 + 33 = 35). Bytes BE
// [0, 0, 33] → first byte masked to 0 (0 & 1 = 0), full
// value = 33.
const witnessRng: Rng = {
nextUint32: () => 33,
nextFloat: () => 0,
nextBytes: (n: number) => {
const out = new Uint8Array(n);
if (n >= 1) out[n - 1] = 33;
return out;
},
};
expect(isProbablePrime(109969n, witnessRng, 1)).toBe(false);
});
});
describe("uniformBigint", () => {
it("returns min when range degenerate", { timeout: 5000 }, () => {
expect(uniformBigint(5n, 4n, seededRng())).toBe(5n);
});
it("returns values within [min, max] across many draws", {
timeout: 5000,
}, () => {
const rng = seededRng(99);
for (let i = 0; i < 200; i++) {
const v = uniformBigint(10n, 100n, rng);
expect(v >= 10n && v <= 100n).toBe(true);
}
});
it("covers the modular-reduction fallback path", { timeout: 5000 }, () => {
// Force the fallback: provide an Rng that always returns bytes
// with the top bit set so every rejection-sampled candidate
// exceeds the range. The fallback then folds via modular
// reduction.
let i = 0;
const overflowRng: Rng = {
nextUint32: () => 0xffffffff,
nextFloat: () => 0,
nextBytes: (n: number) => {
i++;
return new Uint8Array(n).fill(0xff);
},
};
const v = uniformBigint(0n, 100n, overflowRng);
expect(v >= 0n && v <= 100n).toBe(true);
expect(i).toBeGreaterThan(0);
});
});

View File

@@ -0,0 +1,163 @@
import type { Rng } from "@suaveplan/types";
import { nextBytesViaRng } from "../blinding/blinding.js";
import { modPow } from "../math/modpow.js";
/**
* Small primes used for trial division before Miller-Rabin. Eliminating
* candidates with any small prime factor avoids ~80% of Miller-Rabin runs
* on a uniform random sample. The set is chosen to fit in a single
* `Uint8Array` cache line (≈ first 54 odd primes ≤ 251).
*/
export const SMALL_PRIMES: ReadonlyArray<bigint> = [
3n,
5n,
7n,
11n,
13n,
17n,
19n,
23n,
29n,
31n,
37n,
41n,
43n,
47n,
53n,
59n,
61n,
67n,
71n,
73n,
79n,
83n,
89n,
97n,
101n,
103n,
107n,
109n,
113n,
127n,
131n,
137n,
139n,
149n,
151n,
157n,
163n,
167n,
173n,
179n,
181n,
191n,
193n,
197n,
199n,
211n,
223n,
227n,
229n,
233n,
239n,
241n,
251n,
];
/**
* Miller-Rabin probable-prime test (NIST FIPS 186-5 Appendix B.3.1).
*
* Default `rounds = 64` provides ≥ 128-bit security against composites
* for randomly-sampled candidates (the prevailing recommendation for
* RSA key generation in 2026). Each round picks a uniform witness
* `a ∈ [2, n-2]` from the injected `Rng`.
*
* Small-prime trial division runs first so any candidate sharing a
* factor with one of the precomputed `SMALL_PRIMES` short-circuits to
* `false` without a single modular exponentiation.
*
* @returns `true` if `n` is probably prime, `false` if definitely composite
*/
export function isProbablePrime(n: bigint, rng: Rng, rounds = 64): boolean {
if (n < 2n) return false;
if (n < 4n) return true;
if (n % 2n === 0n) return false;
for (const p of SMALL_PRIMES) {
if (n === p) return true;
if (n % p === 0n) return false;
}
// Decompose n - 1 = 2^s * d with d odd.
let d = n - 1n;
let s = 0n;
while (d % 2n === 0n) {
d >>= 1n;
s++;
}
const upperBound = n - 2n;
for (let i = 0; i < rounds; i++) {
const a = uniformBigint(2n, upperBound, rng);
if (millerRabinWitness(a, d, s, n)) {
return false;
}
}
return true;
}
function millerRabinWitness(
a: bigint,
d: bigint,
s: bigint,
n: bigint
): boolean {
let x = modPow(a, d, n);
if (x === 1n || x === n - 1n) {
return false;
}
for (let r = 1n; r < s; r++) {
x = (x * x) % n;
if (x === n - 1n) {
return false;
}
if (x === 1n) {
return true; // composite — non-trivial sqrt of 1
}
}
return true; // composite
}
/**
* Sample a uniform bigint in `[min, maxInclusive]` from `rng`. Uses
* byte-wise rejection sampling so the distribution is unbiased even when
* the range is not a power of two.
*/
export function uniformBigint(
min: bigint,
maxInclusive: bigint,
rng: Rng
): bigint {
if (maxInclusive < min) {
return min;
}
const range = maxInclusive - min + 1n;
const bitLen = range.toString(2).length;
const byteLen = Math.ceil(bitLen / 8);
const topMask = (1 << (bitLen - (byteLen - 1) * 8)) - 1;
for (let attempt = 0; attempt < 1024; attempt++) {
const bytes = nextBytesViaRng(rng, byteLen);
bytes[0] = (bytes[0] as number) & topMask;
let v = 0n;
for (const b of bytes) {
v = (v << 8n) | BigInt(b);
}
if (v < range) {
return min + v;
}
}
// Vanishingly unlikely with the top-bit mask; fall back to modular reduction.
let v = 0n;
const bytes = nextBytesViaRng(rng, byteLen);
for (const b of bytes) {
v = (v << 8n) | BigInt(b);
}
return min + (v % range);
}

View File

@@ -0,0 +1,95 @@
# `keygen/multi-prime`
## Purpose
Multi-prime RSA key generation per RFC 8017 §3 and PKCS #1
Appendix A.1.2 `OtherPrimeInfos` extension. Reachable via the
public `generateRsaKeyPair({ primes: 3 | 4 | 5 })` entry point;
this module is the algorithmic core.
Multi-prime RSA trades a small reduction in security per bit (the
modulus must be slightly larger to retain the same factoring
hardness) for a substantial speedup of the private operation —
each prime factor is smaller, and CRT-accelerated decryption
runs `primes` exponentiations of `bitLength / primes`-sized
operands instead of two exponentiations of `bitLength / 2`.
## Features
- 3-, 4-, and 5-prime modes (PKCS #1 declares no upper bound but
this module caps at 5 because beyond that the security margin
per bit becomes uneconomical).
- Reuses the two-prime `samplePrime` helper for each factor — same
Miller-Rabin gate, same coprimality check, same `AbortSignal`
threading.
- Outputs a canonical `RsaPrivateKey` with the largest two primes
in the standard `(p, q, dp, dq, qInv)` slots and the rest in
the optional `otherPrimeInfos` field.
- Round-trips through PKCS #1 (`serializeRsaPrivateKeyPkcs1` /
`parseRsaPrivateKeyPkcs1`) — the serializer emits version 1
with the full `OtherPrimeInfos` SEQUENCE OF when
`otherPrimeInfos` is present.
## Usage: Basic Example
```ts
import { generateRsaKeyPair } from "@suaveplan/crypto-rsa/keygen/generate";
const key3 = await generateRsaKeyPair({
modulusBits: 2048,
primes: 3,
});
console.log(key3.otherPrimeInfos?.length); // 1
```
## Usage: Advanced Example
Round-trip a 3-prime key through PKCS #1 DER:
```ts
import {
generateRsaKeyPair,
parseRsaPrivateKeyPkcs1,
serializeRsaPrivateKeyPkcs1,
} from "@suaveplan/crypto-rsa";
const key = await generateRsaKeyPair({ modulusBits: 1536, primes: 3 });
const der = serializeRsaPrivateKeyPkcs1(key);
const restored = parseRsaPrivateKeyPkcs1(der);
console.log(restored.otherPrimeInfos?.length); // 1
```
## API Reference
### `generateMultiPrimeKey(options): Promise<RsaPrivateKey>`
Internal entry — public callers should use `generateRsaKeyPair`.
- `options.modulusBits: number`
- `options.publicExponent: bigint`
- `options.primes: number` — must be 3, 4, or 5.
- `options.rng: Rng`
- `options.signal: AbortSignal | undefined`
Throws `CRYPTO_RSA.PRIME_INVALID` on bad `primes`,
`CRYPTO_RSA.KEYGEN_FAILED` on outer-loop exhaustion (32 attempts
without producing a coprime modulus).
## Implementation Notes
- The `(p, q)` selection picks the two largest primes so the
standard CRT path operates on the bigger half of the modulus
— this matches OpenSSL's multi-prime convention and mirrors
what most PKCS #1 consumers expect when reading version 1
keys.
- The `otherPrimeInfos[i].t` coefficient is
`(r_1 * r_2 * ... * r_(i-1))^-1 mod r_i` per RFC 8017 §3 — we
maintain `runningProduct` rather than recomputing each step.
- Outer loop bails after `MAX_OUTER_ATTEMPTS = 32` attempts;
in practice every well-formed `(modulusBits, primes)` pair
succeeds on the first attempt because the small-prime trial
division in `isProbablePrime` already culls most non-coprime
candidates upstream.
- `lcm(a, b) = (a / gcd(a, b)) * b` — the reverse `(a * b) / gcd`
formulation overflows V8's bigint scratch for `λ(n)` with
4096-bit moduli; this rearrangement avoids it.

View File

@@ -0,0 +1,148 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { CRYPTO_RSA_CODES, type CryptoRsaError } from "../errors/errors.js";
import { parseRsaPrivateKeyPkcs1 } from "../keys/parse-pkcs1-private.js";
import { serializeRsaPrivateKeyPkcs1 } from "../keys/serialize-pkcs1-private.js";
import { modPow } from "../math/modpow.js";
import { generateRsaKeyPair } from "./generate.js";
import { generateMultiPrimeKey } from "./multi-prime.js";
describe("generateMultiPrimeKey", () => {
it("primes: 3 produces a 3-prime 768-bit key with otherPrimeInfos.length === 1", {
timeout: 30000,
}, async () => {
const key = await generateRsaKeyPair({
modulusBits: 768,
primes: 3,
});
expect(key.bitLength).toBe(768);
expect(key.otherPrimeInfos?.length).toBe(1);
const r = key.otherPrimeInfos?.[0]?.r as bigint;
expect(key.n).toBe(key.p * key.q * r);
// CRT-private-op equivalence sanity check.
const m = 0xabcd1234n;
const c = modPow(m, key.e, key.n);
const d = modPow(c, key.d, key.n);
expect(d).toBe(m);
});
it("round-trips through PKCS#1 with otherPrimeInfos preserved", {
timeout: 30000,
}, async () => {
const key = await generateRsaKeyPair({
modulusBits: 768,
primes: 3,
});
const der = serializeRsaPrivateKeyPkcs1(key);
const restored = parseRsaPrivateKeyPkcs1(der);
expect(restored.n).toBe(key.n);
expect(restored.otherPrimeInfos?.length).toBe(1);
expect(restored.otherPrimeInfos?.[0]?.r).toBe(
key.otherPrimeInfos?.[0]?.r
);
expect(restored.otherPrimeInfos?.[0]?.d).toBe(
key.otherPrimeInfos?.[0]?.d
);
expect(restored.otherPrimeInfos?.[0]?.t).toBe(
key.otherPrimeInfos?.[0]?.t
);
});
it("assembleMultiPrimeKey rejects bad inputs", {
timeout: 5000,
}, async () => {
const { assembleMultiPrimeKey } = await import("./multi-prime.js");
// primes < 3
expect(assembleMultiPrimeKey([7n, 11n], 3n, 8)).toBeUndefined();
// modulus mismatch
expect(assembleMultiPrimeKey([7n, 11n, 13n], 3n, 2048)).toBeUndefined();
// gcd(e, lambda) !== 1 — pick e = 3 with primes that all leave
// (p-1) divisible by 3.
// 7-1=6, 13-1=12, 19-1=18 — all share factor 3 with e=3.
const product = 7n * 13n * 19n;
const result = assembleMultiPrimeKey(
[7n, 13n, 19n],
3n,
product.toString(2).length
);
expect(result).toBeUndefined();
});
it("outer-attempts loop: duplicate primes trigger resample then exhaustion", {
timeout: 5000,
}, async () => {
// sampler always returns the same prime — every attempt
// detects the duplicate and resamples; eventually the outer
// attempts exhaust → KEYGEN_FAILED.
const sampler = async () => 0xfn;
try {
await generateMultiPrimeKey({
modulusBits: 100,
publicExponent: 3n,
primes: 3,
rng: { nextUint32: () => 0, nextFloat: () => 0 },
signal: undefined,
samplePrimeOverride: sampler,
});
throw new Error("should have thrown");
} catch (err) {
expect((err as CryptoRsaError).code).toBe(
CRYPTO_RSA_CODES.KEYGEN_FAILED
);
}
});
it("outer-attempts loop: assemble-failure (mismatched modulus) drives exhaustion", {
timeout: 5000,
}, async () => {
// Sampler returns three distinct small primes whose product
// never reaches the requested modulusBits — assembleMultiPrimeKey
// returns undefined every iteration, exhausting the outer loop.
let counter = 0;
const primes = [7n, 11n, 13n];
const sampler = async () => {
const p = primes[counter % primes.length] as bigint;
counter++;
return p;
};
try {
await generateMultiPrimeKey({
modulusBits: 2048,
publicExponent: 65537n,
primes: 3,
rng: { nextUint32: () => 0, nextFloat: () => 0 },
signal: undefined,
samplePrimeOverride: sampler,
});
throw new Error("should have thrown");
} catch (err) {
expect((err as CryptoRsaError).code).toBe(
CRYPTO_RSA_CODES.KEYGEN_FAILED
);
}
});
it("rejects primes count < 3 or > 5", { timeout: 5000 }, async () => {
const opts = {
modulusBits: 512,
publicExponent: 65537n,
rng: { nextUint32: () => 0, nextFloat: () => 0 },
signal: undefined,
};
try {
await generateMultiPrimeKey({ ...opts, primes: 2 });
throw new Error("should have thrown");
} catch (err) {
expect((err as CryptoRsaError).code).toBe(
CRYPTO_RSA_CODES.PRIME_INVALID
);
}
try {
await generateMultiPrimeKey({ ...opts, primes: 6 });
throw new Error("should have thrown");
} catch (err) {
expect((err as CryptoRsaError).code).toBe(
CRYPTO_RSA_CODES.PRIME_INVALID
);
}
});
});

View File

@@ -0,0 +1,170 @@
import { invert } from "@noble/curves/abstract/modular";
import type { Rng } from "@suaveplan/types";
import { CRYPTO_RSA_CODES, CryptoRsaError } from "../errors/errors.js";
import type { RsaPrivateKey } from "../keys/keys.types.js";
import { finalisePrivateKey } from "../keys/parse-pkcs1-private.js";
import { gcd } from "./exponent.js";
export type SamplePrimeFn = (
bits: number,
e: bigint,
rng: Rng,
signal: AbortSignal | undefined
) => Promise<bigint>;
/**
* Internal options for multi-prime key generation. Public callers reach
* this path via `generateRsaKeyPair({ primes: 3 | 4 | 5, ... })`.
*/
export interface GenerateMultiPrimeOptions {
readonly modulusBits: number;
readonly publicExponent: bigint;
readonly primes: number;
readonly rng: Rng;
readonly signal: AbortSignal | undefined;
/**
* Test-only hook: substitute the prime-sampling function. Used by
* the keygen test suite to drive the outer-attempts loop and the
* duplicate-prime detection path with deterministic prime sequences
* that would be infeasible to reach with a CSPRNG.
*/
readonly samplePrimeOverride?: SamplePrimeFn;
}
const MAX_OUTER_ATTEMPTS = 32;
/**
* Generate a multi-prime RSA private key per RFC 8017 §3 and PKCS #1
* Appendix A.1.2 `OtherPrimeInfos` extension.
*
* Splits `modulusBits` evenly across `primes` factors (the top factor
* carries any remainder bits). Each factor passes through the same
* `samplePrime` helper as two-prime generation. Returns a canonical
* `RsaPrivateKey` with the standard `(p, q, dp, dq, qInv)` CRT triple
* for the largest two primes plus an `otherPrimeInfos` array carrying
* `(r, d, t)` per additional prime.
*/
export async function generateMultiPrimeKey(
options: GenerateMultiPrimeOptions
): Promise<RsaPrivateKey> {
if (options.primes < 3 || options.primes > 5) {
throw new CryptoRsaError(
CRYPTO_RSA_CODES.PRIME_INVALID,
"error.crypto-rsa.keygen.multi-prime-count",
{ context: { primes: options.primes } }
);
}
const samplePrime =
options.samplePrimeOverride ??
// Late import to avoid a circular module dep on generate.ts.
(await import("./generate.js")).samplePrime;
for (let attempt = 0; attempt < MAX_OUTER_ATTEMPTS; attempt++) {
const primesList = await sampleAllPrimes(options, samplePrime);
if (primesList.length !== options.primes) {
continue;
}
const assembled = assembleMultiPrimeKey(
primesList,
options.publicExponent,
options.modulusBits
);
if (assembled !== undefined) {
return assembled;
}
}
throw new CryptoRsaError(
CRYPTO_RSA_CODES.KEYGEN_FAILED,
"error.crypto-rsa.keygen.multi-prime-exhausted",
{ context: { primes: options.primes } }
);
}
async function sampleAllPrimes(
options: GenerateMultiPrimeOptions,
samplePrime: SamplePrimeFn
): Promise<bigint[]> {
const bitsPerPrime = Math.floor(options.modulusBits / options.primes);
const remainder = options.modulusBits - bitsPerPrime * options.primes;
const out: bigint[] = [];
for (let i = 0; i < options.primes; i++) {
const bits = i === 0 ? bitsPerPrime + remainder : bitsPerPrime;
const p = await samplePrime(
bits,
options.publicExponent,
options.rng,
options.signal
);
if (out.includes(p)) {
return [];
}
out.push(p);
}
return out;
}
/**
* Assemble a multi-prime `RsaPrivateKey` from already-sampled primes.
* Exported for keygen unit tests that need to drive the assembly step
* with deterministic prime inputs.
*
* Returns `undefined` when the resulting modulus differs from the
* requested bit length OR `gcd(e, λ(n)) !== 1` so callers can resample
* the prime set. Production callers route through
* `generateMultiPrimeKey` which wraps the resample loop.
*/
export function assembleMultiPrimeKey(
primesList: ReadonlyArray<bigint>,
e: bigint,
expectedBits: number
): RsaPrivateKey | undefined {
if (primesList.length < 3) {
return undefined;
}
let n = 1n;
for (const p of primesList) {
n *= p;
}
if (n.toString(2).length !== expectedBits) {
return undefined;
}
let lambda = 1n;
for (const p of primesList) {
lambda = lcm(lambda, p - 1n);
}
if (gcd(e, lambda) !== 1n) {
return undefined;
}
const d = invert(e, lambda);
// Descending sort — the largest two primes go to (p, q) for the
// standard CRT path. Distinct primes per RFC 8017 §3 so the
// equality case never applies.
const sorted = [...primesList].sort((a, b) => (a > b ? -1 : 1));
const p = sorted[0] as bigint;
const q = sorted[1] as bigint;
const rest = sorted.slice(2);
const base = finalisePrivateKey({
n,
e,
d,
p,
q,
dp: d % (p - 1n),
dq: d % (q - 1n),
qInv: invert(q, p),
});
let runningProduct = p * q;
const otherPrimeInfos = rest.map((r) => {
const entry = {
r,
d: d % (r - 1n),
t: invert(runningProduct, r),
};
runningProduct *= r;
return entry;
});
return { ...base, otherPrimeInfos };
}
function lcm(a: bigint, b: bigint): bigint {
return (a / gcd(a, b)) * b;
}

View File

@@ -0,0 +1,43 @@
export type {
RsaHashAlgorithm,
RsaJwk,
RsaPrivateKey,
RsaPrivateKeyFormat,
RsaPublicKey,
RsaPublicKeyFormat,
} from "./keys.types.js";
export { OID_RSA_ENCRYPTION } from "./oid.js";
export {
type ParseRsaPrivateKeyOptions,
type ParseRsaPublicKeyOptions,
parseRsaPrivateKey,
parseRsaPublicKey,
} from "./parse.js";
export {
parseRsaPrivateKeyJwk,
parseRsaPublicKeyJwk,
} from "./parse-jwk.js";
export {
finalisePrivateKey,
parseRsaPrivateKeyPkcs1,
} from "./parse-pkcs1-private.js";
export {
finalisePublicKey,
parseRsaPublicKeyPkcs1,
} from "./parse-pkcs1-public.js";
export { parseRsaPrivateKeyPkcs8 } from "./parse-pkcs8.js";
export { parseRsaPublicKeySpki } from "./parse-spki.js";
export {
type SerializeRsaPrivateKeyOptions,
type SerializeRsaPublicKeyOptions,
serializeRsaPrivateKey,
serializeRsaPublicKey,
} from "./serialize.js";
export {
serializeRsaPrivateKeyJwk,
serializeRsaPublicKeyJwk,
} from "./serialize-jwk.js";
export { serializeRsaPrivateKeyPkcs1 } from "./serialize-pkcs1-private.js";
export { serializeRsaPublicKeyPkcs1 } from "./serialize-pkcs1-public.js";
export { serializeRsaPrivateKeyPkcs8 } from "./serialize-pkcs8.js";
export { serializeRsaPublicKeySpki } from "./serialize-spki.js";

View File

@@ -0,0 +1,93 @@
# keys/jwk-policy
## Purpose
Cross-check a JWK's `key_ops`, `use`, and `alg` members against the
requested operation per RFC 7517 §4.2 / §4.3 / §4.4 + RFC 7518 §6.3.
Prevents the common operational mistake of using a JWK marked
`use: "sig"` for encryption (or vice-versa), which leaks both the key
purpose and the operator intent into the wrong code path.
## Features
- Validates `key_ops` ⊃ requested operation (with the standard
encrypt → wrapKey, decrypt → unwrapKey aliases)
- Validates `use === "sig"` permits only sign/verify and `use === "enc"`
permits only encrypt/decrypt
- Validates `alg` is one of the RSA-compatible JWA registry values
(`RS{256,384,512}`, `PS{256,384,512}`, `RSA-OAEP{,−256,−384,−512}`,
`RSA1_5`) AND is appropriate for the requested operation class
- Each violation throws `CRYPTO_RSA.KEY_OP_NOT_PERMITTED` with a
`reason` attribute (`"key_ops"` / `"use"` / `"alg"`) plus context
identifying the offending member
- Skips checks when the JWK omits the corresponding member (RFC 7517
semantics: absence ≠ denial)
## Usage: Basic Example
```ts
import { enforceJwkPolicy } from "@suaveplan/crypto-rsa/keys/jwk-policy";
const jwk = {
kty: "RSA",
n: "...",
e: "AQAB",
use: "sig",
alg: "RS256",
key_ops: ["sign"],
};
enforceJwkPolicy(jwk, "sign"); // no-op
enforceJwkPolicy(jwk, "encrypt"); // throws KEY_OP_NOT_PERMITTED
```
## Usage: Advanced Example
Wire policy enforcement into a higher-level signer that accepts both
canonical `RsaPrivateKey` and JWK inputs; only run the policy check on
the JWK path because canonical keys carry no policy fields:
```ts
import { enforceJwkPolicy, parseRsaPrivateKey } from "@suaveplan/crypto-rsa";
function loadSigningKey(input: RsaJwk | Uint8Array): RsaPrivateKey {
if (typeof input === "object" && "kty" in input) {
enforceJwkPolicy(input, "sign");
return parseRsaPrivateKey(input, { format: "jwk" });
}
return parseRsaPrivateKey(input, { format: "pkcs8" });
}
```
## API Reference
### `enforceJwkPolicy(jwk, operation): void`
- `jwk: RsaJwk`
- `operation: "sign" | "verify" | "encrypt" | "decrypt"`
- **throws** `CryptoRsaError(CRYPTO_RSA.KEY_OP_NOT_PERMITTED)` on any
cross-policy violation; the error context carries `reason` (which
member triggered the rejection) plus the member value
### `JwkOperation`
`"sign" | "verify" | "encrypt" | "decrypt"`.
## Implementation Notes
- The canonical `RsaPublicKey` / `RsaPrivateKey` shapes have no
`key_ops` / `use` / `alg` fields, so direct callers using the
canonical types bypass policy enforcement entirely. Wrap the policy
check at the JWK ingestion boundary if you need it; the package's
built-in sign / encrypt surfaces accept canonical keys precisely so
they don't impose JWK-specific semantics.
- The encrypt → wrapKey / decrypt → unwrapKey aliases follow RFC 7517
§4.3 (`key_ops` value definitions); a JWK marked `key_ops: ["wrapKey"]`
is permitted to encrypt because the cryptographic operation is the
same (RSA public encryption).
- When `use` and `alg` both appear they MUST be consistent (RFC 7517
§4.4); this implementation does not cross-check them against each
other because the JWA registry ties algorithm IDs to a single use,
so the per-member checks already cover the cross-product.
- `key_ops` and `use` together are RFC-permitted but discouraged
(RFC 7517 §4.3 last paragraph); both checks fire independently when
both are present.

Some files were not shown because too many files have changed in this diff Show More