Files
2026-05-27 23:47:53 +01:00

4.0 KiB
Raw Permalink Blame History

sign/pkcs1v15

Purpose

RSASSA-PKCS1-v1_5 signature generation per RFC 8017 §8.2.1. Hashes the message with the configured algorithm, applies the EMSA-PKCS1-v1_5 encoding, and runs the RSA private operation through the CRT path. The output is a signature of length exactly byteLength(n).

Features

  • RFC 8017 §8.2.1 conformant — uses the CRT private-key operation
  • Supports SHA-1, SHA-256, SHA-384, SHA-512
  • Modulus-length policy gates run on every call (MODULUS_TOO_SHORT / MODULUS_TOO_LARGE)
  • Re-uses the canonical i2osp / os2ip math primitives
  • Records crypto_rsa.sign_duration_ms{scheme,hash,engine} and crypto_rsa.outcome_total{operation,outcome} on every call
  • RSA blinding (RFC 8017 §5.1.2 / Kocher 1996) wired by default — opt-out via { blinding: false } emits crypto_rsa.blinding_disabled_total{operation="sign"}
  • All thrown errors are CryptoRsaError instances; non-typed exceptions wrap as CRYPTO_RSA.INVALID_SIGNATURE

Usage: Basic Example

import { rsaSignPkcs1v15 } from "@suaveplan/crypto-rsa/sign/pkcs1v15";

const message = new TextEncoder().encode("hello, signed world");
const signature = rsaSignPkcs1v15(privateKey, message, { hash: "SHA-256" });
console.log(signature.length); // === byteLength(n), e.g. 256 for 2048-bit

Usage: Advanced Example

The allowLegacy option lowers the modulus floor from 2048 to 1024 bits for legacy interop scenarios (e.g. WebAuthn TPM 1.2 endorsement keys). The allowOversize option lifts the ceiling beyond 16384 bits for offline tooling. Both default to false.

import { rsaSignPkcs1v15 } from "@suaveplan/crypto-rsa/sign/pkcs1v15";

const sig = rsaSignPkcs1v15(legacy1024PrivateKey, message, {
    hash: "SHA-1",
    allowLegacy: true,
});

API Reference

rsaSignPkcs1v15(privateKey, message, options): Uint8Array

  • privateKey: RsaPrivateKey — canonical private key with mandatory CRT parameters
  • message: Uint8Array — bytes to be signed; will be hashed internally
  • options.hash: RsaHashAlgorithm — one of "SHA-1", "SHA-256", "SHA-384", "SHA-512"
  • options.allowLegacy?: boolean — see policy doc
  • options.allowOversize?: boolean — see policy doc
  • options.blinding?: boolean — defaults to true; opting out emits crypto_rsa.blinding_disabled_total{operation="sign"}
  • options.rng?: Rng — randomness source for the blinding factor; defaults to defaultRng()
  • returns Uint8Array of length byteLength(n)
  • throws CryptoRsaError(CRYPTO_RSA.MODULUS_TOO_SHORT) / MODULUS_TOO_LARGE / UNSUPPORTED_HASH / INVALID_KEY

Implementation Notes

  • The output equals i2osp(rsaPrivateOperationCrt(os2ip(em), privateKey), emLen) where em = emsaPkcs1v15Encode(message, emLen, hash) and emLen = byteLength(n). This is bit-for-bit identical to the naive c = m^d mod n reference, but uses the CRT decomposition for ~3-4× the throughput on a 2048-bit modulus.
  • Encoding is deterministic — every call with the same (message, hash) pair produces the same signature. This is RFC 8017 §8.2.1 behaviour: PKCS1-v1_5 is a deterministic signature scheme, in contrast to PSS.
  • The function never short-circuits on early failure — even when an exception is raised, the duration histogram and outcome counter are recorded. The outcome attribute distinguishes "ok" from "error".
  • The engine attribute is "noble" because the underlying modular exponentiation is @noble/curves/abstract/modular.pow. The RsaCryptoEngine adapter (./engine) selects "subtle" when globalThis.crypto.subtle exposes the matching algorithm.
  • Blinding — for every call m' = m * r^e mod n is computed with a freshly sampled r ∈ Z*_n, the CRT private operation runs against m', and the result is unblinded via s = s' * r^{-1} mod n. This prevents the Kocher 1996 timing-attack class against the secret exponent. Output is bit-for-bit identical to the unblinded path; only the timing varies. The < 5% overhead is bench-verified.