← Back to the tool

Public-key · Under the hood

RSA-PSS sign & verify

What each button in the tool actually computes, step by step — with the real formulas.

Why RSA signatures need a padding scheme

Textbook RSA signing, s = m^d mod n, is multiplicative: two signatures multiply into a valid signature on the product of their messages, and anyone can pick s first and compute the 'message' it signs. Signing a hash fixes part of that, but how the hash is laid out inside the modulus still matters. PKCS #1 v1.5 uses a fixed, deterministic layout; PSS (Probabilistic Signature Scheme, Bellare and Rogaway, 1996) adds a random salt and a mask, and has a security proof (in the random-oracle model) that reduces forging to inverting RSA itself. RFC 8017 keeps PKCS #1 v1.5 signatures for compatibility with existing applications and points new ones to PSS.

Generating the key: the hash is part of the key

The modulus and exponent are the same as any RSA key. What Web Crypto adds is that the hash is fixed at key creation: an RSA-PSS CryptoKey made for SHA-256 always signs with SHA-256 and MGF1-SHA-256, so the hash isn't a parameter of sign() or verify().

The actual WebCrypto call
const keyPair = await crypto.subtle.generateKey(
  {
    name: "RSA-PSS",
    modulusLength: 2048,                          // or 3072 / 4096
    publicExponent: new Uint8Array([1, 0, 1]),    // 65537
    hash: "SHA-256",                              // message hash and MGF1 hash
  },
  true,
  ["sign", "verify"]
);

Web Crypto always uses the same hash for the message and for MGF1. RFC 8017 allows them to differ, and X.509's RSASSA-PSS-params can say so, but almost every deployment matches them.

Encoding: salt, hash, mask

sign() first hashes the message to mHash. It then draws a fresh random salt, of the length you chose, and hashes the block M′ = eight zero bytes ‖ mHash ‖ salt to get H. The data block DB is zero padding, a single 0x01, and the salt. DB is XORed with MGF1(H), a mask stretched from H by hashing it with a counter, and the encoded message is EM = maskedDB ‖ H ‖ 0xbc. EM is one bit shorter than the modulus (emBits = modBits − 1), so that it is always smaller than n.

The salt is why signing the same message twice gives two different signatures. With a salt length of 0, PSS becomes deterministic: try it, and "Sign again" gives the same bytes.

The actual WebCrypto call
const signature = await crypto.subtle.sign(
  { name: "RSA-PSS", saltLength: 32 },   // bytes; the hash length is the usual choice
  privateKey,
  new TextEncoder().encode(message)
);
// signature is exactly modulusLength / 8 bytes (256 for RSA-2048)

saltLength can go from 0 up to emLen − hLen − 2 bytes: 222 for RSA-2048 with SHA-256. FIPS 186-5 caps it at the hash length, and the hash length is the usual choice.

H=Hash(008 ∥ Hash(M) ∥ salt)H = \mathrm{Hash}(\mathtt{00}^{8} \,\|\, \mathrm{Hash}(M) \,\|\, \mathit{salt})
EM=(PS ∥ 01 ∥ salt)⊕MGF1(H);∥;H;∥;bcs=EM d mod n\mathit{EM} = \big(\mathit{PS} \,\|\, \mathtt{01} \,\|\, \mathit{salt}\big) \oplus \mathrm{MGF1}(H) ;\|; H ;\|; \mathtt{bc} \qquad s = \mathit{EM}^{\,d} \bmod n

Opening a signature with the public key

Verification undoes the encoding. Raising s to the public exponent gives back EM, so anyone with the public key can see the masked block, H, and the trailer. Unmasking DB with MGF1(H) reveals the padding, the 0x01, and the salt; the verifier then rebuilds M′ from the message it was given and checks that hashing it reproduces H.

This tool runs those steps a second time by hand and shows them: the public RSA operation with JavaScript BigInt, then MGF1 and the hashes through crypto.subtle.digest. Only the public key is involved, and the verdict in the banner is always Web Crypto's own verify(). The hand-run opening is there to show which check failed: change one character of the message and only the last check (H′ = H) fails, because EM is untouched; change the signature or the key and EM turns to noise, so the trailer and separator checks fail first.

Opening it by hand (what this tool runs alongside verify())
const m = modPow(os2ip(signature), e, n);           // BigInt
const em = i2osp(m, emLen);                          // emLen = ceil((modBits - 1) / 8)
const maskedDB = em.slice(0, emLen - hLen - 1);
const H = em.slice(emLen - hLen - 1, emLen - 1);     // em[emLen - 1] must be 0xbc
const DB = xor(maskedDB, await mgf1("SHA-256", H, maskedDB.length));
DB[0] &= 0xff >> (8 * emLen - emBits);               // clear the unused top bits
const salt = DB.slice(DB.indexOf(0x01) + 1);         // after zeros and the 0x01
const Hprime = await crypto.subtle.digest("SHA-256", concat(zeros(8), mHash, salt));

The library behind this is tested against RSA Laboratories' PKCS #1 v2.1 PSS vectors, recovering their published salts exactly, including a 1025-bit key where EM is one byte shorter than the signature.

EM=se mod n\mathit{EM} = s^{e} \bmod n

Verifying: the salt length has to match

Web Crypto's verify() takes the expected salt length and checks it exactly. A signature with a 32-byte salt doesn't verify if you pass 20, even though the opening above can still find the salt and the hash matches. Signer and verifier have to agree on the salt length, which is why X.509 certificates and CMS carry it in RSASSA-PSS-params, and why some libraries offer an 'auto' salt length that reads it from the signature the way this tool's opening does.

The actual WebCrypto call
const valid = await crypto.subtle.verify(
  { name: "RSA-PSS", saltLength: 32 },   // must equal the signer's salt length
  publicKey,                             // imported for RSA-PSS with the same hash
  signatureBytes,
  new TextEncoder().encode(message)
);   // false for any failed check, no exception

As with ECDSA and Ed25519, a bad signature resolves to false. The 'wrong salt length' case in this tool is a real verify() call with a different saltLength, not a simulated failure.

Now try it

Back to RSA-PSS sign & verify →

Sign with real RSA-PSS, choose the salt length, then open the signature with the public key to see the encoded message, the recovered salt, and which check fails when you tamper with it.