Technical Article

Encoding RSASSA-PSS-params per RFC 4055 in PDFium Delphi

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:

FieldTag (DER byte)DEFAULTETSI TS 119 312 §7 valueEncoded?
hashAlgorithm[0] (A0)sha1SHA-256Yes
maskGenAlgorithm[1] (A1)mgf1SHA1MGF1 with SHA-256Yes
saltLength[2] (A2)2032 (the digest length)Yes
trailerField[3] (A3)trailerFieldBC (1)1No, 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 neighbours the encoder left out

PDFium Component diagram of RSASSA-PSS-params per RFC 4055: hashAlgorithm A0, maskGenAlgorithm A1 and saltLength A2 carry explicit context tags with DEFAULT values, the ETSI profile emits SHA-256, MGF1 with SHA-256 and salt 32, and trailerField A3 equals trailerFieldBC so DER omits it entirely
Every field is tagged precisely because every field is optional through its default, so the tag number identifies the field no matter which neighbours the encoder leaves 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

Diagram of the RSASSA-PSS-params tagging trap: after A0 and A1 a mis-tagged block meets a bare 02 01 20 where the A2 explicit tag belongs, a strict decoder stops and a lenient one falls back to the default 20-byte salt, while the correct encoding wraps the 32-byte salt in A2
RSASSA-PSS-params has no untagged INTEGER member, so stray bytes there are either a parse failure or a silent fallback to the default salt length, and neither path reaches the signer's 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

PDFium Component diagram of the X.690 11.5 rule in the PSS parameters: trailerField equal to its DEFAULT trailerFieldBC must stay absent because a tagged A3 wrapper is the encoding strict DER rejects, while saltLength 32 differs from the default 20 and must be present as A2
DER exists so that one value has exactly one encoding, and a component equal to its default already has the shortest encoding there is, namely not appearing at all

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