Technical Article

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

PDFium Component version 3.114.20 fixes the RSASSA-PSS-params encoding in all three PAdES signing backends, Windows CNG, macOS Keychain and PKCS#11. RFC 4055 §3.1 gives every field of RSASSA-PSS-params an explicit context-specific tag, [0] through [3], and the backends were emitting saltLength as a bare universal INTEGER while writing out a trailerField equal to its default. The signature bytes were correct the whole time. The AlgorithmIdentifier describing them was not, and that alone is enough for a verifier to reject the signature

The frustrating part is where the bug hides. A CMS signature has two halves, the cryptographic operation and the ASN.1 that tells the verifier how the operation was performed. Get the first right and the second wrong, and the result is a document no specification-following tool can distinguish from a forgery. This article is only about that second half: how RSASSA-PSS-params must be tagged, how three backends got it wrong in the same way, and what the corrected DER looks like in TDerWriter terms

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 recover the parameters from the signature itself. PKCS#1 v1.5 padding is fully determined by the OID sha256WithRSAEncryption, so its parameters are a bare NULL and there is nothing to get wrong. PSS is parameterized by a hash function, a mask generation function with its own hash, and a salt length, and RFC 8017 §A.2.3 leaves all three open. The signer picks them, the AlgorithmIdentifier carries them, and the verifier has to reproduce them exactly before EMSA-PSS-VERIFY can even begin

So when PDFium Component signs with SHA-256, MGF1 over SHA-256 and a 32-byte salt, those three facts have to survive DER encoding here and DER decoding on a different implementation. A parameters block the verifier cannot parse ends verification before any modular exponentiation happens. A block it parses differently is worse, because RFC 4055 §3.1 gives saltLength a default of 20. A decoder that skips a field it does not recognize lands on that default, runs EMSA-PSS-VERIFY with a 20-byte salt against a signature computed with 32, and reports a bad signature with no hint that the problem is metadata rather than the key. Both outcomes are what the 3.114.19 encoding produced, depending on how strict the verifier was, and neither points at the AlgorithmIdentifier

What RFC 4055 §3.1 actually requires of RSASSA-PSS-params

RFC 4055 §3.1 defines RSASSA-PSS-params as a SEQUENCE of four fields, each carrying an explicit context-specific tag and a DEFAULT value:

// RSASSA-PSS-params ::= SEQUENCE {
//   hashAlgorithm      [0] HashAlgorithm      DEFAULT sha1,
//   maskGenAlgorithm   [1] MaskGenAlgorithm   DEFAULT mgf1SHA1,
//   saltLength         [2] INTEGER            DEFAULT 20,
//   trailerField       [3] TrailerField       DEFAULT trailerFieldBC
// }

Explicit tagging in DER means each field is wrapped in a constructed context-specific TLV, A0 for [0], A1 for [1], A2 for [2] and A3 for [3], with the universal encoding of the value nested inside. Every field is tagged precisely because every field is optional through its default. Without tags a decoder could not tell whether a SEQUENCE holding a single AlgorithmIdentifier was carrying hashAlgorithm or maskGenAlgorithm, since both are SEQUENCE types; with them, the tag number identifies the field regardless of which neighbors are present. The values PDFium Component emits follow the profile in ETSI TS 119 312 §7, SHA-256, MGF1 with SHA-256 and a salt equal to the digest length, and they mirror exactly what each platform signing call is told: a BCRYPT_PSS_PADDING_INFO with cbSalt 32 for NCryptSignHash, a CK_RSA_PKCS_PSS_PARAMS with sLen 32 for the PKCS#11 mechanism, and the SHA-256 digest-signing PSS algorithm in the Security framework

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 neighbors the encoder leaves out

How three backends made the same mistake

The 3.114.19 encoding tagged the first two fields and left the last two bare, identically in TWinCmsSigner, TKeychainCmsSigner and TPkcs11CmsSigner. That symmetry is no coincidence: all three implement the ICmsSigner interface from FPdfCms.pas, and their GetSignatureAlgorithmParams bodies were written to one template. The template read like this:

// Before 3.114.20: [0] and [1] tagged, [2] and [3] not
Result := W.Sequence(Concat4(
  W.ContextSpecific(0, W.AlgId(OID_SHA256), True),
  W.ContextSpecific(1, W.Sequence(ConcatBytes(
    W.OID(OID_MGF1), W.AlgId(OID_SHA256))), True),
  W.IntegerOf(32),      // bare INTEGER where [2] EXPLICIT was required
  W.IntegerOf(1)));     // trailerField, equal to DEFAULT, must be absent

A decoder walking that SEQUENCE sees A0, reads the hash algorithm, sees A1, reads the mask generation function, and then meets 02 01 20. That is a universal INTEGER, and RSASSA-PSS-params has no untagged INTEGER member anywhere. A strict decoder stops there. A lenient one skips the unrecognized element, never finds an A2, assigns saltLength its default of 20, then hits a second stray INTEGER, 02 01 01, and has the same problem again. Neither path arrives at a 32-byte salt. A shared template is efficient when it is right and an equally efficient way to be wrong three times when it is not, which is why the fix landed in all three units in one commit and why the three method bodies remain structurally identical afterwards. A future backend should copy the block from one of these rather than re-derive it, because the derivation is exactly where the mistake was made

PDFium Component diagram of the 3.114.19 DER bug: after A0 and A1 the params met a bare 02 01 20 where the A2 explicit tag belongs, a strict decoder stopped and a lenient one signed with the default 20-byte salt, while 3.114.20 wraps the 32-byte salt in A2
RSASSA-PSS-params has no untagged INTEGER member, so the stray bytes were either a parse failure or a silent fall back to the default salt length, and neither path reached the signer's 32

Why is trailerField omitted rather than tagged as [3]?

Because X.690 §11.5 says a DER encoder shall not encode a component whose value equals its DEFAULT, and trailerField has DEFAULT trailerFieldBC, which is the integer 1. The obvious correction to the old code, replacing the bare W.IntegerOf(1) with W.ContextSpecific(3, W.IntegerOf(1), True), yields a block a permissive BER decoder accepts and a strict DER decoder is entitled to reject. The value is not wrong. Its presence is. The same rule is why the other three fields are present: SHA-256 is not the default sha1, MGF1 with SHA-256 is not the default mgf1SHA1, and 32 is not the default 20. Had the backend been signing with SHA-1 and a 20-byte salt, RFC 4055 §3.1 would collapse the parameters to an empty SEQUENCE, 30 00, and that empty SEQUENCE rather than NULL is what a verifier expects. PDFium Component never emits that shape because it never signs with those values, but it is the case that catches anyone assuming "no parameters" always spells 05 00

This is the distinction between DER and BER that matters for signatures specifically. BER lets an encoder 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 is a signature that can be argued about. Everything inside CMS signedAttrs is DER for that reason, and the parameters block travels inside signedAttrs through the cmsAlgorithmProtection attribute as well as in the outer signatureAlgorithm, so it gets no exemption

PDFium Component diagram of the X.690 11.5 rule in the PSS fix: 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

The corrected TDerWriter encoding

PDFium Component now builds the parameters with three calls to TDerWriter.ContextSpecific from FPdfAsn1.pas, one per non-default field, each with the Constructed argument set to True to produce the explicit-tag wrapper, and no line at all for the trailer field. This is the body of TWinCmsSigner.GetSignatureAlgorithmParams with the OIDs written out; the Keychain and PKCS#11 units spell the same values as OID_SHA256, OID_MGF1 and OID_RSASSA_PSS:

function TWinCmsSigner.GetSignatureAlgorithmParams: TBytes;
var
  W: TDerWriter;
begin
  if FPaddingScheme = psRsaPss then
  begin
    W := TDerWriter.Create;
    try
      // RFC 4055 3.1 tags all four fields. saltLength is [2]; a bare
      // INTEGER here is read as the start of another field. trailerField
      // is [3] with DEFAULT 1, and X.690 11.5 forbids encoding a value
      // equal to the default, so it is omitted entirely
      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
  else
    Result := nil;   // PKCS#1 v1.5 and ECDSA: AlgIdWithParams writes NULL
end;

Two details of the surrounding machinery matter. TDerWriter.AlgId produces an AlgorithmIdentifier with NULL parameters, which is what RFC 4055 §2.1 tells encoders to generate for the nested hashAlgorithm and for the MGF1 inner hash. And the CMS builder in FPdfCms.pas pairs the signature OID with these bytes through TDerWriter.AlgIdWithParams, which substitutes NULL when the params are nil; that is why psRsaPkcs1v15 and psEcdsa simply return nil and were never affected, and why 1.2.840.113549.1.1.10, id-RSASSA-PSS, is the only signature OID of the three carrying a real parameters block. The resulting bytes for the SHA-256 profile are fixed and short enough to check by eye: an outer 30 34 SEQUENCE holding A0 0F around the 15-byte SHA-256 AlgorithmIdentifier, A1 1C around the 28-byte MGF1 AlgorithmIdentifier whose own parameters are that same SHA-256 AlgorithmIdentifier, and A2 03 02 01 20 for the salt. If a dump of your signatureAlgorithm shows 02 01 20 at the top level of the params SEQUENCE rather than inside an A2, you are looking at the 3.114.19 encoding

Why did the test suite not catch a malformed AlgorithmIdentifier?

Because the PAdES tests drive the CMS builder through a fake signer that reports sha256WithRSAEncryption and returns nil from GetSignatureAlgorithmParams, so the PSS parameters block was never constructed in a test at all. That is a reasonable design for tests that must run without a certificate store, a Keychain or a token, and it has a blind spot with a precise shape: anything only a real backend produces is only exercised by a real backend. The second layer is more interesting. PDFium Component also places the signature AlgorithmIdentifier, parameters included, inside the cmsAlgorithmProtection signed attribute from RFC 6211, and a verifier compares that copy against the outer signatureAlgorithm. Both copies came from the same call, so they matched perfectly and every internal consistency check passed. The encoding was self-consistent and wrong, the category of bug no amount of comparing a structure against itself can reveal, and the same lesson with a different structure is told in CMS signedAttrs and DER SET OF sorting, where a SET hashed in one order and emitted in another looked fine until a foreign verifier recomputed the hash

What does catch this class of bug is a decoder not written by the encoder's author, run against the actual output of the actual backend. The Windows verification path in PDFium Component goes through CryptoAPI rather than the library's own reader, and a rejected PSS signature there is what led back to the parameters. Any ASN.1 an implementation emits for other implementations to read deserves at least one round trip through a decoder it does not control, and the more defaults and tags the structure has, the more that round trip is worth

Where this fits with the rest of the PSS story

This fix is independent of the two other places PSS can go wrong in a PAdES signature, and keeping them apart shortens debugging. The macOS backend can find that a particular key or an older system refuses PSS and downgrade to PKCS#1 v1.5, and the AlgorithmIdentifier has to follow the downgrade; that is a capability question, covered in signing PAdES with a macOS Keychain identity. The PKCS#11 backend can hand the token a CK_RSA_PKCS_PSS_PARAMS whose layout the token reads differently because of an integer width mismatch; that is an ABI question, covered in CK_ULONG and the PKCS#11 packing trap. This article is about the third failure, where the key was willing, the token computed the right bytes, and the DER describing the result did not conform to RFC 4055 §3.1

A signer that declares PSS takes on an obligation the v1.5 signer never had: to describe its own parameters in a form another implementation decodes to the same three values. RFC 4055 §3.1 fixes the tags, X.690 §11.5 fixes which fields may appear, and ETSI TS 119 312 §7 fixes the values worth choosing. All three backends of the PDFium Delphi component ship as source, so the GetSignatureAlgorithmParams body above is the one you can read, dump and compare against your own verifier rather than take on trust