A hex dump of an RSASSA-PSS signatureAlgorithm that ends in A0 0F … A1 1C … 02 01 20 02 01 01 looks complete: SHA-256, MGF1, a 32-byte salt and a trailer of 1. A strict decoder rejects that parameters block outright, and a lenient one reads the salt length as 20. Either way, a signature whose RSA arithmetic is correct comes back invalid. RFC 4055 §3.1 explains why: every field of RSASSA-PSS-params carries an explicit context tag, so the salt belongs inside A2, and under DER a trailerField equal to its default must not appear at all
That gives a CMS signature two halves that can fail independently. One is the cryptographic operation; the other is the ASN.1 that tells the verifier how the operation was performed. Get the first right and the second wrong, and a specification-following verifier has no way to tell your document from a forgery. The ASN.1 half is the one this article covers
Why does a verifier reject an RSASSA-PSS signature whose bytes are correct?
Because RSASSA-PSS is the one RSA scheme where the verifier cannot work out the parameters from the algorithm OID. PKCS#1 v1.5 is fully pinned down by sha256WithRSAEncryption, so its parameters are a bare NULL. PSS is parameterized by a message hash, a mask generation function with its own hash, and a salt length, and RFC 8017 §A.2.3 leaves all three to the signer. The AlgorithmIdentifier carries the signer's choices, and the verifier has to reproduce them exactly before EMSA-PSS-VERIFY can run
A block the verifier cannot parse ends verification before any modular exponentiation happens. A block it parses differently is worse. RFC 4055 §3.1 gives saltLength a default of 20, so a decoder that never finds the salt field runs EMSA-PSS-VERIFY with a 20-byte salt against a signature computed with 32. The result is reported as a bad signature, with nothing pointing at the metadata. How strict the verifier is decides which of the two failures you see, and neither error message mentions the AlgorithmIdentifier
The four fields of RSASSA-PSS-params and their tags
RFC 4055 §3.1 defines RSASSA-PSS-params as a SEQUENCE of four fields, each with an explicit context-specific tag and a DEFAULT value. In DER, explicit tagging wraps each field in a constructed context TLV — A0 through A3 — with the field's own universal encoding nested inside:
| Field | Tag (DER byte) | DEFAULT | ETSI TS 119 312 §7 value | Encoded? |
|---|---|---|---|---|
hashAlgorithm | [0] (A0) | sha1 | SHA-256 | Yes |
maskGenAlgorithm | [1] (A1) | mgf1SHA1 | MGF1 with SHA-256 | Yes |
saltLength | [2] (A2) | 20 | 32 (the digest length) | Yes |
trailerField | [3] (A3) | trailerFieldBC (1) | 1 | No, it equals the default |
The tags are there because every field is optional through its default. hashAlgorithm and maskGenAlgorithm are both AlgorithmIdentifier SEQUENCEs; without a tag, a decoder looking at a parameters block that holds a single SEQUENCE could not tell which of the two it was. With tags, the tag number names the field no matter which neighbors the encoder left out
Pick a salt equal to the digest length and keep it there. Some APIs offer "maximum salt" or "salt length auto", which yields a value that depends on the key size; that is legal under RFC 8017, but the AlgorithmIdentifier then has to state a number that changes from key to key, and a 32-byte salt for SHA-256 is what ETSI TS 119 312 §7 asks for anyway. PDFium Component's built-in signing backends all fix it at 32 and pass the same number to the platform call: cbSalt in the BCRYPT_PSS_PADDING_INFO handed to NCryptSignHash on Windows, sLen in CK_RSA_PKCS_PSS_PARAMS for a PKCS#11 token, EVP_PKEY_CTX_set_rsa_pss_saltlen under OpenSSL, and the digest-length salt that kSecKeyAlgorithmRSASignatureDigestPSSSHA256 implies on macOS
Writing GetSignatureAlgorithmParams for your own ICmsSigner
In PDFium Component, every signing backend implements the ICmsSigner interface from FPdfCms.pas, and the parameters block comes from one method, GetSignatureAlgorithmParams. The CMS builder pairs its result with the OID from GetSignatureAlgorithmOid through TDerWriter.AlgIdWithParams. You meet this method when you plug in a signer of your own through ConfigurePadesSignerProvider — a network HSM, a signing service, a smart card driver the built-in backends do not cover. The encoding most people write first tags the two AlgorithmIdentifiers, because those obviously need wrapping, and leaves the two integers bare:
// A common first attempt: [0] and [1] tagged, [2] and [3] not
Result := W.Sequence(Concat4(
W.ContextSpecific(0, W.AlgId('2.16.840.1.101.3.4.2.1'), True),
W.ContextSpecific(1, W.Sequence(ConcatBytes(
W.OID('1.2.840.113549.1.1.8'),
W.AlgId('2.16.840.1.101.3.4.2.1'))), True),
W.IntegerOf(32), // bare INTEGER where [2] EXPLICIT is required
W.IntegerOf(1))); // trailerField equal to DEFAULT: must be absent
Walk it the way a decoder does. It sees A0 and reads the hash, sees A1 and reads the mask generation function, then meets 02 01 20, a universal INTEGER. RSASSA-PSS-params has no untagged INTEGER member anywhere, so a strict decoder stops. A lenient one skips the unknown element, never finds an A2, assigns the default salt of 20, and then trips over a second stray INTEGER, 02 01 01. Neither path ends at 32
The conforming version uses three calls to TDerWriter.ContextSpecific from FPdfAsn1.pas, one per non-default field, each with Constructed set to True so the result is an explicit wrapper. There is no line for the trailer field. This is the same block the Windows CNG, macOS Keychain, PKCS#11 and OpenSSL backends emit, written here as a custom signer would carry it:
uses
System.SysUtils, FPdfSha256, FPdfAsn1, FPdfCms;
type
TMyHsmSigner = class(TInterfacedObject, ICmsSigner)
private
FUsePss: Boolean;
public
function GetDigestAlgorithmOid: AnsiString;
function GetSignatureAlgorithmOid: AnsiString;
function GetSignatureAlgorithmParams: TBytes;
function GetSignerId: TCmsSignerId;
function SignSignedAttrsDigest(const Digest: TSHA256Digest): TBytes;
end;
// Your own class; the other four methods talk to your HSM and are not shown
function TMyHsmSigner.GetSignatureAlgorithmParams: TBytes;
var
W: TDerWriter;
begin
if not FUsePss then
Exit(nil); // v1.5 and ECDSA: AlgIdWithParams writes NULL for nil
W := TDerWriter.Create;
try
// RFC 4055 3.1: [0] hash, [1] MGF1 with its hash, [2] salt.
// [3] trailerField = 1 equals its DEFAULT, so X.690 11.5 omits it
Result := W.Sequence(Concat3(
W.ContextSpecific(0, W.AlgId('2.16.840.1.101.3.4.2.1'), True),
W.ContextSpecific(1, W.Sequence(ConcatBytes(
W.OID('1.2.840.113549.1.1.8'), // id-mgf1
W.AlgId('2.16.840.1.101.3.4.2.1'))), True),
W.ContextSpecific(2, W.IntegerOf(32), True)));
finally
W.Free;
end;
end;
TDerWriter.AlgId writes an AlgorithmIdentifier with NULL parameters, which is what RFC 4055 §2.1 tells encoders to generate for the nested SHA-256 identifiers. The resulting bytes are short enough to check by eye: an outer 30 34, then A0 0F around the 15-byte SHA-256 AlgorithmIdentifier, A1 1C around the 28-byte MGF1 AlgorithmIdentifier, and A2 03 02 01 20 for the salt. The salt you write in GetSignatureAlgorithmParams must match the one your HSM actually uses; if the service only exposes "PSS with SHA-256" and does not tell you the salt length, find out before shipping, because the AlgorithmIdentifier is a claim about it
Why is trailerField omitted rather than tagged as [3]?
X.690 §11.5 says a DER encoder shall not encode a component whose value equals its DEFAULT. trailerField has DEFAULT trailerFieldBC, the integer 1, and PSS as RFC 8017 defines it only uses that trailer. So the obvious repair to the first attempt — replacing W.IntegerOf(1) with W.ContextSpecific(3, W.IntegerOf(1), True) — produces A3 03 02 01 01, which a permissive BER decoder accepts and a strict DER decoder is entitled to reject. The value is right. Its presence is not
The same rule explains why the other three fields do appear: SHA-256 is not sha1, MGF1 with SHA-256 is not mgf1SHA1, and 32 is not 20. A signer using SHA-1 with a 20-byte salt would collapse the whole block to an empty SEQUENCE, 30 00. That shape trips up anyone who assumes "no parameters" is always spelled 05 00; for id-RSASSA-PSS in CMS the parameters field is required, an empty SEQUENCE means "all defaults", and NULL is not a valid RSASSA-PSS-params value
BER allows an encoder to include a default-valued component; DER forbids it, because DER exists so that one value has exactly one encoding, and a signature over a structure with two legal encodings can be argued about. The parameters block cannot escape DER here. It sits in the outer signatureAlgorithm of the SignerInfo, and it is copied into the signed attributes through the cmsAlgorithmProtection attribute from RFC 6211, where every byte is covered by the signature
Checking a CMS you did not build
That copy inside cmsAlgorithmProtection does not catch a mis-tagged block. The attribute and the outer signatureAlgorithm come from the same call, so they always match, and a comparison of the structure against itself passes. What catches the problem is a decoder that reads the parameters field by field. PDFium Component's InspectPadesSignatureAlgorithm in FPdfPades.pas does that: it accepts only A0 to A3, each at most once and in ascending order, fills in the RFC 4055 defaults for anything absent, and reports the effective values in TPadesRsaPssParameters. A bare INTEGER in the block leaves SignaturePss.Valid False, and EvaluatePadesSignatureAlgorithm then sets PolicyStatus to pcsInvalid. It also rejects a PSS hash that differs from the SignerInfo digest algorithm, which is exactly what a lenient fallback to sha1 would produce
This matters most with remote signing, where an external service returns a finished CMS and CompletePadesRemoteSignature embeds it in the prepared PDF. Check the blob before you embed it. Rejecting it at that point costs one API call; finding out from a customer's validator costs a re-signing round with whoever runs the service:
uses
System.SysUtils, FPdfPades;
// Suggested check you add yourself before CompletePadesRemoteSignature
function CheckRemotePssCms(const CmsDer: TBytes; out Why: string): Boolean;
const
OID_RSASSA_PSS = '1.2.840.113549.1.1.10';
var
Info: TPadesSignatureAlgorithmInfo;
Values: string;
begin
Result := False;
Why := '';
// nil: let the inspector locate the signer certificate inside the CMS
if not InspectPadesSignatureAlgorithm(CmsDer, nil, Info) then
begin
Why := 'CMS SignerInfo could not be parsed';
Exit;
end;
if Info.SignatureAlgorithmOid = OID_RSASSA_PSS then
begin
if not Info.SignaturePss.Valid then
begin
Why := 'RSASSA-PSS-params does not decode as RFC 4055 3.1';
Exit;
end;
// Effective values, defaults included: a missing A2 shows up as 20 here
Why := Format('hash %s, MGF hash %s, salt %d', [
string(Info.SignaturePss.HashAlgorithmOid),
string(Info.SignaturePss.MaskGenHashAlgorithmOid),
Info.SignaturePss.SaltLength]);
end;
if Info.PolicyStatus = pcsInvalid then
Exit; // Why still holds the decoded values for the log
Values := Why;
Result := VerifyCmsAlgorithmProtection(CmsDer, Why);
if Result then
Why := Values; // the protection check clears Why on success
end;
Log the Why string even on success. A salt length of 20 next to a SHA-256 hash is the fingerprint of a parameters block whose A2 went missing, whatever the service's own logs say. For a quick look without code, openssl asn1parse -inform DER -in signature.p7s prints the tree; under the id-RSASSA-PSS OID you want to see cont [ 2 ] wrapping an INTEGER of 20 hex, and no cont [ 3 ] at all. A top-level INTEGER inside that SEQUENCE is the mis-tagged encoding from the first code block
Where this fits with the rest of the PSS story
Parameter encoding is one of three separate ways PSS can go wrong in a PAdES signature, and keeping them apart shortens debugging. On macOS a particular key or an older system may not offer PSS, and the backend then signs with PKCS#1 v1.5 and declares sha256WithRSAEncryption to match; that capability question is covered in signing PAdES with a macOS Keychain identity. A PKCS#11 token can read CK_RSA_PKCS_PSS_PARAMS with a different integer width than the caller wrote, an ABI question covered in CK_ULONG and the PKCS#11 packing trap. The third is the one above: the key is willing, the token computes the right bytes, and the DER that describes them does not follow RFC 4055 §3.1
The signed-attributes SET has its own version of the self-consistency blind spot, where a SET hashed in one order and emitted in another looks fine until a foreign verifier recomputes the hash; see CMS signedAttrs and DER SET OF sorting. For the prepare, sign remotely, complete cycle that the check above plugs into, remote PAdES signing with an HSM covers the two-phase protocol and what the completion step refuses
The PDFium Delphi component ships its signing backends as source, so the GetSignatureAlgorithmParams bodies in the Windows, macOS, PKCS#11 and OpenSSL units are there to read and dump next to your own signer's output, byte for byte