Files
skills/docs/security-stack/crypto/crypto-rsa/src/sign/pss.md
2026-05-29 08:20:56 +01:00

4.5 KiB
Raw Blame History

sign/pss

Purpose

RSASSA-PSS signature generation per RFC 8017 §8.1.1. Hashes the message, draws a random salt of the configured length, runs the EMSA-PSS encoder, then converts the result through the CRT private-key operation. The output is a signature of length exactly byteLength(n).

PSS is a probabilistic scheme — distinct calls with the same inputs produce different signatures unless the salt length is zero.

Features

  • RFC 8017 §8.1.1 conformant
  • Configurable hash, mgfHash, saltLength
  • saltLength defaults to hashOutputLength(hash)
  • mgfHash defaults to hash
  • Injectable Rng from @suaveplan/types for deterministic-replay test scenarios
  • Default RNG: randomBytes from @suaveplan/crypto-utils (CSPRNG-backed)
  • CRT-aware private operation (~3-4× faster than naive m^d mod n on 2048-bit keys)
  • 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"}
  • Records crypto_rsa.sign_duration_ms{scheme="pss",hash,engine} and outcome counter on every call

Usage: Basic Example

import { rsaSignPss } from "@suaveplan/crypto-rsa/sign/pss";

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

Usage: Advanced Example

The rng option accepts any Rng shape from @suaveplan/types. A seeded RNG produces deterministic signatures, useful for golden-file testing or reproducible audit trails. The saltLength: 0 shortcut also produces deterministic PSS without needing a custom RNG.

import { rsaSignPss } from "@suaveplan/crypto-rsa/sign/pss";
import type { Rng } from "@suaveplan/types";

// Deterministic PSS via empty salt.
const detSig = rsaSignPss(privateKey, message, {
    hash: "SHA-256",
    saltLength: 0,
});

// Deterministic PSS via seeded RNG (reproducible for fuzz cases).
const seeded: Rng = makeMyMulberry32Rng(0xdeadbeef);
const reproducible = rsaSignPss(privateKey, message, {
    hash: "SHA-256",
    rng: seeded,
});

// Cross-hash PSS — message hashed with SHA-256, MGF1 driven by SHA-1.
const xHash = rsaSignPss(privateKey, message, {
    hash: "SHA-256",
    mgfHash: "SHA-1",
});

API Reference

rsaSignPss(privateKey, message, options): Uint8Array

  • privateKey: RsaPrivateKey
  • message: Uint8Array
  • options.hash: RsaHashAlgorithm
  • options.saltLength?: number — defaults to hashOutputLength(hash); must be non-negative integer
  • options.mgfHash?: RsaHashAlgorithm — defaults to options.hash
  • options.rng?: Rng — defaults to randomBytes from @suaveplan/crypto-utils
  • options.allowLegacy?: boolean
  • options.allowOversize?: boolean
  • options.blinding?: boolean — defaults to true; opting out emits crypto_rsa.blinding_disabled_total{operation="sign"}
  • returns Uint8Array of length byteLength(n)
  • throws CryptoRsaError(CRYPTO_RSA.MODULUS_TOO_SHORT) / MODULUS_TOO_LARGE / UNSUPPORTED_HASH / INVALID_KEY / INVALID_SIGNATURE (on bad RNG output or bad salt length)

Implementation Notes

  • emBits = modulusBits - 1 per RFC 8017 §8.1.1 step 2. The encoder takes emBits (not emLen bytes) so the leftmost-bit clearance can be enforced correctly.
  • The signature byte length is always byteLength(n) = ceil(modulusBits/8). When modulusBits is a multiple of 8 (the common case), this equals emLen + 1 because emBits = modulusBits - 1 rounds down by one byte. The i2osp(s, Math.ceil(modulusBits / 8)) call pads the signature to the modulus byte length on the wire.
  • The rng.nextBytes path is used when available (CSPRNGs typically expose it directly). When only nextUint32 is implemented, the salt is drawn 4 bytes at a time. Both paths produce the same statistical uniformity assuming the underlying RNG is uniform.
  • Salt length of 0 short-circuits the RNG entirely — useful for deterministic PSS validation.
  • Blinding — when enabled (default) the encoded message integer is multiplied by r^e mod n for a freshly sampled r ∈ Z*_n, the CRT private operation runs against that blinded value, and the unblinding step s = s' * r^{-1} mod n is applied before i2osp. The signature output is bit-identical to the unblinded path. PSS verifiers do not observe the difference because verification only depends on the public key. Disable only for benchmarks; the < 5% overhead does not justify the timing-side-channel exposure in production.