DEV Community

GodfreySterling1574
GodfreySterling1574

Posted on

Debug 6 PDF Signature Verification Failures — Certificate Chain and Key Mismatch

TL;DR: Treat a failed PDF signature as six separate checks, in order: the signed byte range, the cryptographic signature, the signer certificate, the certificate path, the verification time, and the expected public key. Do not replace that sequence with one valid=false flag. For a monthly fintech report, archive the original PDF, the exact trust inputs, the verification time, and the result of every check; otherwise a later reviewer cannot distinguish document tampering from an expired certificate, an incomplete chain, or a key-selection mistake.

This order matters because two failures that look identical at the API boundary can imply opposite operational decisions. A digest or signature mismatch concerns the signed bytes. A path-building failure concerns the verifier's trust material and policy. A public-key mismatch can mean that the report was signed by a valid but unexpected identity. Collapsing them into “signature invalid” destroys the evidence needed for reconciliation.

How should you debug PDF signature verification when a certificate fails?

A PDF signature binds a cryptographic signature to specified byte ranges in the file. Verification therefore begins with the stored bytes, not with the certificate chain and not with what a PDF viewer renders on screen. The archived object must be immutable, and the verifier must read the same object that crossed the signing boundary. Re-rendering HTML, normalizing metadata, or rebuilding a monthly report produces another PDF, even when every visible glyph is unchanged.

Start by recording the object digest and size at each custody boundary: after rendering, after signing, and after archival retrieval. These are audit facts, not substitutes for PDF signature validation. If the archived-object digest differs from the post-signing digest, stop. Chain debugging cannot repair a changed document.

Stop there.

Next, ask the PDF-aware verifier to validate the signature over the document's declared signed byte ranges. ISO 32000-2 defines the PDF structures involved; use a conforming parser rather than extracting /ByteRange or signature contents with a regular expression. A parser must also decide whether later incremental updates are permitted by the signature and report what revision was actually covered. That decision belongs in a policy result, separate from the raw cryptographic result.

Only after the byte-level check passes should certificate diagnostics drive the investigation. This boundary prevents a common category error: adding an intermediate certificate may fix path construction, but it cannot make a modified byte range authentic.

Separate the chain from the expected key

Certificate path validation and expected-key matching answer different questions. Path validation asks whether the signer certificate can be linked, under the verifier's policy and chosen time, to a configured trust anchor. Expected-key matching asks whether that signer is the identity authorized for this report stream. A certificate may satisfy the first question and fail the second.

Those are separate controls.

The distinction is especially important during key rotation. Suppose the reporting service has a current signing certificate and an older certificate retained for historical validation. A trust store containing both issuing paths can validate either chain. The monthly-report control, however, may require a specific key identifier for a given signing period. Trust alone is too broad for that assertion.

Compare keys by their canonical public-key encoding, not by certificate filenames, subject display names, or PEM text. PEM wrapping can differ while representing the same DER value, and a renewed certificate may legitimately carry a familiar subject while containing a different key. The comparison should produce a stable fingerprint for diagnostics, but the authorization decision should use an approved-key record tied to the report stream and effective period.

Keep private keys out of this verifier. It needs the signed PDF, public certificates, trust policy, and expected public key; giving it signing credentials expands the blast radius without improving validation.

Build one reference fixture that proves each boundary

A useful fixture is not merely “a signed PDF that passes.” It is a small evidence package with an immutable PDF, the signer certificate, any supplied intermediates, the trust anchor selected by policy, the expected public key, and an explicit verification time. The fixture should also state the expected outcome of each stage. That makes a regression attributable.

The following Go program isolates two frequently conflated checks. It verifies a leaf certificate with explicit roots, intermediates, key usage, and time, then compares the leaf public key with an expected public key. It does not parse or validate a PDF signature; that remains the responsibility of the conforming PDF verifier. Feed this diagnostic only the signer certificate extracted by that verifier.

Its limitation is deliberate: this diagnostic cannot establish that the PDF bytes were signed, decide whether an incremental update is allowed, evaluate revocation evidence, or prove that a timestamp is trustworthy. A full verifier must supply those results. The trade-off is a narrower fixture with a much clearer failure boundary; use it to answer “is this chain valid under these inputs, and is this the expected key?”, not “is this report acceptable?” Treating the sample as a complete PDF verifier would create a false positive path, particularly when certificate validation succeeds against a document whose signature container was never checked.

package main

import (
    "bytes"
    "crypto/sha256"
    "crypto/x509"
    "encoding/hex"
    "encoding/pem"
    "errors"
    "fmt"
    "os"
    "time"
)

type Fixture struct {
    LeafPath         string
    IntermediatePath string
    RootPath         string
    ExpectedKeyPath  string
    VerifyAt         time.Time
}

func readCertificate(path string) (*x509.Certificate, error) {
    b, err := os.ReadFile(path)
    if err != nil {
        return nil, err
    }
    block, _ := pem.Decode(b)
    if block == nil || block.Type != "CERTIFICATE" {
        return nil, fmt.Errorf("%s: expected one PEM certificate", path)
    }
    return x509.ParseCertificate(block.Bytes)
}

func readPublicKey(path string) (any, error) {
    b, err := os.ReadFile(path)
    if err != nil {
        return nil, err
    }
    block, _ := pem.Decode(b)
    if block == nil || block.Type != "PUBLIC KEY" {
        return nil, fmt.Errorf("%s: expected a PKIX PUBLIC KEY", path)
    }
    return x509.ParsePKIXPublicKey(block.Bytes)
}

func encodedKey(key any) ([]byte, string, error) {
    der, err := x509.MarshalPKIXPublicKey(key)
    if err != nil {
        return nil, "", err
    }
    sum := sha256.Sum256(der)
    return der, hex.EncodeToString(sum[:]), nil
}

func verifyFixture(f Fixture) error {
    leaf, err := readCertificate(f.LeafPath)
    if err != nil {
        return fmt.Errorf("leaf: %w", err)
    }
    intermediate, err := readCertificate(f.IntermediatePath)
    if err != nil {
        return fmt.Errorf("intermediate: %w", err)
    }
    root, err := readCertificate(f.RootPath)
    if err != nil {
        return fmt.Errorf("root: %w", err)
    }

    roots := x509.NewCertPool()
    roots.AddCert(root)
    intermediates := x509.NewCertPool()
    intermediates.AddCert(intermediate)
    chains, err := leaf.Verify(x509.VerifyOptions{
        Roots:         roots,
        Intermediates: intermediates,
        CurrentTime:   f.VerifyAt,
        KeyUsages:     []x509.ExtKeyUsage{x509.ExtKeyUsageAny},
    })
    if err != nil {
        return fmt.Errorf("certificate path: %w", err)
    }
    if len(chains) == 0 {
        return errors.New("certificate path: no verified chain")
    }

    expected, err := readPublicKey(f.ExpectedKeyPath)
    if err != nil {
        return fmt.Errorf("expected key: %w", err)
    }
    actualDER, actualID, err := encodedKey(leaf.PublicKey)
    if err != nil {
        return fmt.Errorf("leaf key: %w", err)
    }
    expectedDER, expectedID, err := encodedKey(expected)
    if err != nil {
        return fmt.Errorf("expected key: %w", err)
    }
    if !bytes.Equal(actualDER, expectedDER) {
        return fmt.Errorf("key mismatch: signer=%s expected=%s", actualID, expectedID)
    }

    fmt.Printf("path=valid key=match chains=%d signer_key=%s\n", len(chains), actualID)
    return nil
}

func main() {
    verifyAt, err := time.Parse(time.RFC3339, "2026-09-01T00:00:00Z")
    if err != nil {
        panic(err)
    }
    fixture := Fixture{
        LeafPath:         "fixture/signer.pem",
        IntermediatePath: "fixture/intermediate.pem",
        RootPath:         "fixture/root.pem",
        ExpectedKeyPath:  "fixture/expected-public-key.pem",
        VerifyAt:         verifyAt,
    }
    if err := verifyFixture(fixture); err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }
}
Enter fullscreen mode Exit fullscreen mode

Make variants by changing one input at a time: alter a covered PDF byte, omit the intermediate, use a time outside the certificate's accepted interval, and replace the expected public key. The important property is isolation. A fixture that simultaneously changes the PDF and the trust bundle cannot tell you which control caught the defect.

Use the expected stage, rather than a generic pass/fail result, as the fixture assertion:

Fixture change Expected failing stage What must remain unclaimed
Change one covered byte Cryptographic signature Certificate trust
Remove the required intermediate Certificate path Document tampering
Move evaluation outside the accepted interval Certificate time Key mismatch
Replace the authorized public key Expected key Certificate path

One detail in the sample is deliberately conspicuous: the verification time is fixed. Tests that inherit the wall clock eventually change meaning, while a historical report needs a declared time policy. Production policy still has to define which trustworthy time evidence is accepted; the fixture merely ensures that the same input is evaluated consistently.

Make the failure code carry audit meaning

Do not log only a library error string. Emit a structured verification record whose fields remain useful after a dependency upgrade: report identifier, archived-object digest, signature field identifier, covered revision, signer-certificate fingerprint, expected-key identifier, chosen trust-policy version, evaluation time, and one result per stage. Store certificate material or durable references to it according to the system's evidence-retention rules.

The minimum stage vocabulary should distinguish byte_range_invalid, signature_mismatch, chain_untrusted, certificate_time_invalid, key_mismatch, and policy_rejected. These labels express control boundaries; the underlying library error can be attached as diagnostic detail. Do not turn an unexpected parsing error into signature_mismatch, because that silently asserts a cryptographic conclusion the system never reached.

Idempotency belongs here too. Give the verification attempt an idempotency key derived from the archived object identity, signature identity, trust-policy version, and verification-policy version. Repeating the same job should return or append the same logical outcome, while a policy change should create a new, linked evaluation rather than overwrite history. An audit trail is a sequence of decisions, not a mutable status column.

Never overwrite it.

Compliance limits shape the storage design. Evidence retention, access control, approved algorithms, trust anchors, and time-validation rules are policy inputs owned by the relevant legal and security functions; there is no universal period or universally acceptable signature profile that can be inferred from “PDF.” Record the policy version that made the decision. Never invent a compliance rule in application code.

Debug in a fixed order, then roll out narrowly

When production verification fails, preserve the exact PDF first. Reproduce against the same trust bundle and explicit evaluation time, then compare the signer key with the authorized key record. If the failure does not reproduce, compare inputs before comparing library versions: object digest, extracted signer certificate, intermediate set, trust-anchor set, time source, and policy version. This sequence is intentionally dull. Dull is good.

For rollout, run the new staged verifier in shadow mode on a bounded set of monthly reports and compare each stage with the existing final decision. Investigate disagreements without changing the authoritative result. Promote only after the fixture variants fail at their intended boundaries and operators can retrieve the evidence record by report identifier.

Monitor counts by stable failure code, not by raw error text. Alert on a change from the established baseline and retain enough context to reconcile every report without exposing private key material or sensitive report contents in logs. Rollback should switch decision authority back to the previous verifier while preserving all shadow evaluations; deleting inconvenient evidence defeats the point of the exercise.

The practical decision rule is compact: trust a report only when the covered bytes verify, the certificate path satisfies the recorded policy at the recorded time, and the signer key matches the identity authorized for that report stream. Everything else is diagnostic context, and all of it must remain reproducible.

Sources

Top comments (0)