Initial Commit
This commit is contained in:
464
packages/security-stack/crypto/crypto-rsa/README.md
Normal file
464
packages/security-stack/crypto/crypto-rsa/README.md
Normal 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
|
||||
|
||||
546
packages/security-stack/crypto/crypto-rsa/package.json
Normal file
546
packages/security-stack/crypto/crypto-rsa/package.json
Normal 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"
|
||||
]
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
@@ -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.
|
||||
@@ -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",
|
||||
};
|
||||
20
packages/security-stack/crypto/crypto-rsa/src/asn1/index.ts
Normal file
20
packages/security-stack/crypto/crypto-rsa/src/asn1/index.ts
Normal 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";
|
||||
88
packages/security-stack/crypto/crypto-rsa/src/asn1/reader.md
Normal file
88
packages/security-stack/crypto/crypto-rsa/src/asn1/reader.md
Normal 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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
285
packages/security-stack/crypto/crypto-rsa/src/asn1/reader.ts
Normal file
285
packages/security-stack/crypto/crypto-rsa/src/asn1/reader.ts
Normal 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 } }
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
87
packages/security-stack/crypto/crypto-rsa/src/asn1/writer.md
Normal file
87
packages/security-stack/crypto/crypto-rsa/src/asn1/writer.md
Normal 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.
|
||||
@@ -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
|
||||
);
|
||||
});
|
||||
});
|
||||
157
packages/security-stack/crypto/crypto-rsa/src/asn1/writer.ts
Normal file
157
packages/security-stack/crypto/crypto-rsa/src/asn1/writer.ts
Normal 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);
|
||||
}
|
||||
@@ -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).
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export type { BlindResult } from "./blinding.js";
|
||||
export {
|
||||
blindMessage,
|
||||
nextBytesViaRng,
|
||||
unblindResult,
|
||||
} from "./blinding.js";
|
||||
@@ -0,0 +1,8 @@
|
||||
export {
|
||||
type RsaDecryptOaepOptions,
|
||||
rsaDecryptOaep,
|
||||
} from "./oaep.js";
|
||||
export {
|
||||
type RsaDecryptPkcs1v15Options,
|
||||
rsaDecryptPkcs1v15,
|
||||
} from "./pkcs1v15.js";
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
148
packages/security-stack/crypto/crypto-rsa/src/decrypt/oaep.ts
Normal file
148
packages/security-stack/crypto/crypto-rsa/src/decrypt/oaep.ts
Normal 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();
|
||||
}
|
||||
@@ -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`".
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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`).
|
||||
@@ -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
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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.
|
||||
@@ -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
|
||||
);
|
||||
});
|
||||
});
|
||||
107
packages/security-stack/crypto/crypto-rsa/src/encoding/mgf1.ts
Normal file
107
packages/security-stack/crypto/crypto-rsa/src/encoding/mgf1.ts
Normal 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;
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export {
|
||||
type RsaEncryptOaepOptions,
|
||||
rsaEncryptOaep,
|
||||
} from "./oaep.js";
|
||||
export {
|
||||
type RsaEncryptPkcs1v15Options,
|
||||
rsaEncryptPkcs1v15,
|
||||
} from "./pkcs1v15.js";
|
||||
@@ -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.
|
||||
@@ -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
|
||||
);
|
||||
});
|
||||
});
|
||||
103
packages/security-stack/crypto/crypto-rsa/src/encrypt/oaep.ts
Normal file
103
packages/security-stack/crypto/crypto-rsa/src/encrypt/oaep.ts
Normal 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();
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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.
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
109
packages/security-stack/crypto/crypto-rsa/src/engine/noble.ts
Normal file
109
packages/security-stack/crypto/crypto-rsa/src/engine/noble.ts
Normal 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 } }
|
||||
);
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
269
packages/security-stack/crypto/crypto-rsa/src/engine/subtle.ts
Normal file
269
packages/security-stack/crypto/crypto-rsa/src/engine/subtle.ts
Normal 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"]
|
||||
);
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export type { CryptoRsaCode } from "./errors.js";
|
||||
export {
|
||||
CRYPTO_RSA_CODES,
|
||||
CryptoRsaError,
|
||||
} from "./errors.js";
|
||||
@@ -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.
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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(":");
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export {
|
||||
type FingerprintFormat,
|
||||
formatFingerprint,
|
||||
resetFingerprintCounterForTesting,
|
||||
} from "./format.js";
|
||||
export {
|
||||
computeSpkiFingerprint,
|
||||
type SpkiFingerprintOptions,
|
||||
} from "./spki.js";
|
||||
@@ -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`.
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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).
|
||||
@@ -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");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
240
packages/security-stack/crypto/crypto-rsa/src/index.ts
Normal file
240
packages/security-stack/crypto/crypto-rsa/src/index.ts
Normal 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";
|
||||
@@ -0,0 +1,6 @@
|
||||
export {
|
||||
type JwkThumbprintOptions,
|
||||
jwkThumbprint,
|
||||
jwkThumbprintBase64Url,
|
||||
} from "./thumbprint.js";
|
||||
export { jwkThumbprintUri } from "./thumbprint-uri.js";
|
||||
@@ -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.
|
||||
@@ -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:");
|
||||
});
|
||||
});
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
112
packages/security-stack/crypto/crypto-rsa/src/keygen/generate.md
Normal file
112
packages/security-stack/crypto/crypto-rsa/src/keygen/generate.md
Normal 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)`.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
236
packages/security-stack/crypto/crypto-rsa/src/keygen/generate.ts
Normal file
236
packages/security-stack/crypto/crypto-rsa/src/keygen/generate.ts
Normal 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();
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
43
packages/security-stack/crypto/crypto-rsa/src/keys/index.ts
Normal file
43
packages/security-stack/crypto/crypto-rsa/src/keys/index.ts
Normal 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";
|
||||
@@ -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
Reference in New Issue
Block a user