đź’ˇ Full working example available on GitHub: digital-signing-certificate-validity-dotnet

The Compliance Problem Nobody Sees Until an Auditor Does

A signing service runs for three years without an error. Documents go out, recipients accept them, nothing in the logs suggests a problem. Then a counterparty’s validator flags a batch as invalid, and the investigation turns up two causes: the signatures were written with SHA-1, and for the last four months the certificate had been expired.

Both failures were silent at the point of signing. That is the thing GroupDocs.Signature 26.9 changes.

Certificate validity enforcement is the new default behaviour for .NET digital signing: a certificate outside its validity window is rejected rather than used. It arrives with two companions - SHA-256 as the default PDF digest, and a LogLevel that finally filters - and together they move three classes of failure from the recipient back to the sender, where they can still be fixed.

Why Silent Success Is the Expensive Outcome

Signing is unusual in that the party who makes the mistake is not the party who discovers it. A malformed invoice fails in your own system; an invalid signature fails in someone else’s, weeks later, with no diagnostic you can read.

That asymmetry is why “the API returned success” is not a useful guarantee here. The old defaults optimised for not interrupting the caller, and the cost landed on the recipient and, eventually, on whoever had to re-sign and re-send several hundred documents.

Change 1: Expired Certificates Are Rejected

The headline change. Sign now throws GroupDocsSignatureException when the certificate’s validity has ended or has not yet started, and nothing is written to disk.

try
{
    signature.Sign(outputPath, options);
    return true;
}
catch (GroupDocsSignatureException ex)
{
    Console.WriteLine($"   Rejected: {ex.Message}");
    return false;
}

The message names the certificate and the property that would allow it, so an operator reading a log line can act on it without opening the documentation. For a pipeline that upgrades to 26.9 and starts failing, this is almost always the reason - and the correct response is renewal, not suppression.

When you genuinely need the old behaviour, it is one property:

var options = new DigitalSignOptions(certificate)
{
    Password = certificatePassword,
    AllowExpired = true
};

The document is signed and a warning goes to the logger. Validators still reject the result, because AllowExpired governs what the library permits rather than what the certificate is worth. The companion flag AllowNotYetValid covers the other end of the window and is deliberately independent: allowing an expired certificate does not quietly allow a future-dated one.

Change 2: SHA-256 by Default

PDF digital signatures are now written with SHA-256 in the adbe.pkcs7.detached format that current validators expect. Earlier versions wrote SHA-1.

var options = new DigitalSignOptions(certificate)
{
    Password = certificatePassword,
    HashAlgorithm = HashAlgorithm.Sha256,
    Reason = "Approved",
    Location = "Head office"
};

Setting the property explicitly is only necessary to go further - Sha384 or Sha512 when a policy requires them - or to stay on Sha1 for a validator that cannot handle anything else. A time stamp added to the signature uses the same digest.

Verification changed in the same release and in the same direction: DigitalVerifyOptions with no criteria used to be a near no-op, and now performs a full cryptographic check, so a document altered after signing is reported invalid.

Change 3: LogLevel Actually Filters

SignatureSettings has accepted a logger for a long time. Before 26.9 the level was ignored, so every message arrived regardless and most services turned logging off rather than drown in traces.

The sample makes the difference measurable by signing the same document three times with a counting logger:

var levels = new Dictionary<string, LogLevel>
{
    ["None"] = LogLevel.None,
    ["Warning | Error"] = LogLevel.Warning | LogLevel.Error,
    ["All"] = LogLevel.All
};

None produces zero messages, Warning | Error keeps the single warning raised by the allowed expired certificate, and All adds a trace per step. The counting logger itself is the integration point for your own stack:

public void Warning(string message)
{
    Warnings++;
    WarningMessages.Add(message);
}

Implement those three methods against Serilog, NLog or Application Insights and the library’s diagnostics land wherever the rest of your service logs.

Does the log level change which exceptions I get?

No, and it is worth being explicit because the two look related. LogLevel filters what reaches the ILogger. Exceptions are thrown to your code regardless: an expired certificate without AllowExpired still throws at LogLevel.None, and your catch block behaves identically. Diagnostics and control flow are separate channels, which is what makes it safe to run production at Warning | Error.

The Rejection Is Cheaper Than It Looks

The objection to a hard stop is operational: a nightly batch that used to finish now fails at 02:00 and somebody gets paged. That is a real cost, and it is still the smaller one. A rejected batch is one alert, one renewal and one re-run, all inside your own systems. A batch signed with an expired certificate is discovered by a recipient, which means a support thread, a re-issue of every affected document, and an awkward conversation about how long it had been happening.

The sample makes the failure concrete rather than theoretical: it signs with an expired certificate on purpose, catches the exception, and prints the message, so you can see exactly what your logs will contain before the upgrade reaches production. I would run that one method against your own certificate store before scheduling the version bump.

What To Do Before Upgrading

Three checks, in order of how likely they are to bite.

Look at certificate expiry across every signing path, including the ones that run monthly or quarterly - those are where an expired certificate hides longest. Then grep for HashAlgorithm: if nothing sets it, your digests change from SHA-1 to SHA-256 on upgrade, which is an improvement that still belongs in a release note. Finally, decide a log level deliberately. The honest default for a service is Warning | Error; All is for reproducing a specific problem, and None means giving up the one signal that tells you a signature was made under a waiver.

Verification Changed in the Same Direction

It is easy to miss, because nothing in the calling code has to change. DigitalVerifyOptions with no criteria set used to be close to a no-op: it compared the criteria it was given, and given none, it had little to say. From 26.9 the same call performs a full cryptographic check of every PDF digital signature.

For a service that verifies incoming documents, that is a silent upgrade from “there is a signature here” to “this signature matches this content”. Worth knowing before you see a document start failing verification that passed last month: the document probably was altered, and the older check simply did not look.

The Certificates in the Sample

One detail worth copying rather than the code: the sample ships no private key. TestCertificates.cs builds three self-signed PFXs in memory at run time - valid, expired last year, valid from next year - so the demonstration works whatever today’s date is and there is nothing sensitive in the repository.

That pattern is worth adopting in your own test suites. A committed test certificate expires eventually, and when it does the failure looks exactly like the bug this release was built to surface.

Conclusion

Three changes, one direction: failures that used to appear at the recipient now appear at the sender. Renew the certificate rather than reaching for AllowExpired, let SHA-256 be the default, verify incoming documents cryptographically, and pick a log level before you need it. The sample runs all six behaviours in one pass, including the rejection, so the upgrade can be rehearsed in a couple of minutes.

Additional Resources