PGP Signature Verification for Inbound ACH Files

Confidentiality and origin are different guarantees, and conflating them is how a forged batch reaches the parser. Decrypting a PGP-encrypted ACH file proves only that it was encrypted to your public key; it says nothing about who produced the plaintext. Origin and integrity come from a separate signature check, and this page isolates that one step: verifying a detached OpenPGP signature over a downloaded NACHA file against a trusted signing key, and returning an unambiguous accept-or-reject decision before the file is parsed. It is the verification half of the resilient file transfer gate, within the broader automated file ingestion and parsing pipelines reference.

Signature verification belongs at the transport edge because it is a permanent, non-retryable gate: a file that fails the check is not a transient transport problem, it is either corrupt or not from who it claims. Getting the policy right means being strict in exactly the places it is tempting to be lenient — a verification that raises an exception must be treated as a rejection, not shrugged off; a signature from an untrusted or expired key must be rejected even if the cryptography is otherwise valid; and the file bytes verified must be the exact bytes handed downstream, or the check protects nothing.

Concept spec: detached signatures, trust, and hard-fail

A detached signature is a separate .sig (or .asc) artifact computed over the file's bytes, delivered alongside the file rather than wrapped around it. Detached signatures suit fixed-width payment files because the signed payload stays byte-for-byte identical to what the parser consumes — there is no envelope to strip that could alter record width. Verification is a single hash-and-compare pass over the file, in file length, plus a constant-time public-key operation.

Verification answers two independent questions, and both must be yes:

  1. Is the signature cryptographically valid over these exact bytes? The signature must verify against the payload with no modification. One flipped byte in the NACHA file invalidates it.
  2. Is the signing key one we trust? A valid signature from an unknown or untrusted key is worthless — an attacker can sign anything with their own key. The signing key's fingerprint must match a pre-registered, in-date key in the originator's keyring, exchanged out of band during onboarding.

The governing policy is hard-fail: reject on invalid signature, reject on untrusted key, reject on expired key, and reject on any error during verification. The last clause is the one most often gotten wrong. A verification routine that returns True on the happy path and lets an exception escape (or worse, catches it and continues) creates a fail-open gate — the one failure mode that silently admits unverified files. The function below is written so every path that is not an affirmative, trusted, in-date verification returns a rejection with a reason.

Full annotated implementation

The function uses pgpy for pure-Python OpenPGP verification. It takes the downloaded NACHA file, the detached signature, and a keyring of trusted originator public keys, and returns a typed result carrying the accept/reject decision and a machine-readable reason. Trust is enforced explicitly: the key that produced the signature must be present in the trusted keyring and not expired, checked before the decision is returned.

python
from __future__ import annotations

import logging
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path

import pgpy
from pgpy.constants import KeyFlags

logger = logging.getLogger("ach_signature_verification")


@dataclass(frozen=True)
class VerificationResult:
    """Outcome of a detached-signature check over one ACH file."""

    accepted: bool
    reason: str
    signing_fingerprint: str | None = None


def verify_detached_signature(
    file_path: Path,
    signature_path: Path,
    trusted_keyring: dict[str, pgpy.PGPKey],
) -> VerificationResult:
    """Verify a detached PGP signature over an inbound NACHA file.

    Hard-fail policy: returns accepted=False for an invalid signature, an
    untrusted or expired signing key, or ANY error raised during
    verification. Never raises to the caller — a fail-open gate is the one
    outcome that must be impossible.

    Args:
        file_path: The downloaded NACHA payload, verified byte-for-byte.
        signature_path: The detached .sig / .asc artifact for that payload.
        trusted_keyring: fingerprint -> trusted originator public key,
            populated out of band during counterparty onboarding.

    Returns:
        VerificationResult with the decision, a reason code, and the
        signing key fingerprint when one could be identified.
    """
    try:
        signature = pgpy.PGPSignature.from_file(str(signature_path))
        signer_fp = signature.signer_fingerprint  # 40-hex primary/subkey fp
    except (ValueError, OSError) as exc:
        # Malformed or unreadable signature artifact -> reject, never admit.
        logger.error("Unreadable signature for %s: %s", file_path.name, exc)
        return VerificationResult(False, "signature_unreadable")

    # 1. Trust: the signing key must be one we registered out of band.
    signing_key = trusted_keyring.get(str(signer_fp))
    if signing_key is None:
        logger.warning("Untrusted signer %s for %s", signer_fp, file_path.name)
        return VerificationResult(False, "untrusted_key", str(signer_fp))

    # 2. Freshness: a valid signature from an expired key is not acceptable.
    expires_at = signing_key.expires_at
    if expires_at is not None and expires_at <= datetime.now(timezone.utc):
        return VerificationResult(False, "expired_key", str(signer_fp))

    # 3. Capability: the key must actually be allowed to sign.
    flags = next(iter(signing_key.key_flags), set()) if signing_key.key_flags else set()
    if KeyFlags.Sign not in signing_key.usageflags:
        return VerificationResult(False, "key_not_a_signing_key", str(signer_fp))

    # 4. Cryptographic verification over the EXACT payload bytes.
    try:
        payload = file_path.read_bytes()
        verified = signing_key.verify(payload, signature)
    except pgpy.errors.PGPError as exc:
        # Treat any verification error as a rejection — fail closed.
        logger.error("Verification error for %s: %s", file_path.name, exc)
        return VerificationResult(False, "verification_error", str(signer_fp))

    if not bool(verified):
        return VerificationResult(False, "signature_invalid", str(signer_fp))

    logger.info("Verified %s signed by %s", file_path.name, signer_fp)
    return VerificationResult(True, "verified", str(signer_fp))

The structure is deliberately linear and fails closed at every branch. Note that the trust check precedes the cryptographic check: there is no reason to spend the hash pass verifying a signature from a key you would reject anyway, and short-circuiting on trust keeps an attacker's self-signed file cheap to discard. The bytes passed to verify() are read from the same file_path the pipeline will hand downstream, so there is no window in which a verified file and a parsed file could differ.

Calibration & configuration

Three policy settings need to be pinned per corridor and reviewed on a schedule:

  • Key rotation and overlap. Originators rotate signing keys. Load the trusted keyring so that during a documented rotation window both the outgoing and incoming fingerprints are present, then retire the old one after the window closes. Never accept a new signing key discovered on the wire — the keyring is populated only from out-of-band onboarding or a signed rotation notice.
  • Allowed algorithms. Reject signatures using deprecated hash algorithms (MD5, SHA-1) even when pgpy can technically verify them; a payment corridor should require SHA-256 or stronger. Inspect signature.hash_algorithm and add it as an explicit allow-list check if your counterparties are inconsistent.
  • Hard-fail is the only mode. There is no "warn and continue" tier for a payment file. The one calibration that is genuinely dangerous is any configuration flag that downgrades a rejection to a log line. If verification is temporarily failing for a legitimate reason (a keyring not yet updated for a rotation), the correct response is to quarantine and escalate, not to relax the gate.

Validation example: good file vs tampered file

Consider an inbound file ACH_20260716_0530.ach with its detached signature ACH_20260716_0530.ach.sig, signed by an originator whose key fingerprint A1B2 C3D4 … 7788 is registered in the trusted keyring.

Good file. The originator's Entry Detail records arrive intact; the last record reads:

code
6220210000210001234567        0000125000ACME PAYROLL LLC       0091000010000042

verify_detached_signature reads the 94-byte-aligned payload, looks up fingerprint A1B2…7788 (present and in-date), confirms the key carries the sign flag, and verify() returns true. Result: VerificationResult(accepted=True, reason="verified", signing_fingerprint="A1B2C3D4…7788"). The file proceeds to the parser, and the fingerprint is recorded in the audit trail.

Tampered file. A byte is altered in transit — the amount field 0000125000 becomes 0000725000, inflating the entry from 1250.00 to 7250.00:

code
6220210000210001234567        0000725000ACME PAYROLL LLC       0091000010000042

The signature was computed over the original bytes, so verify() now returns false. Result: VerificationResult(accepted=False, reason="signature_invalid", signing_fingerprint="A1B2C3D4…7788"). The file never reaches the parser; it is quarantined with the reason code, and the inflated 7250.00 entry is never posted. Crucially, the checksum and signature catch this even though the tampered record is still exactly 94 bytes and would have parsed cleanly.

Failure modes & guardrails

  1. Verifying against the wrong key. Calling verify() with the recipient's own key, or with whatever key is embedded in an attacker-supplied signature packet, verifies nothing. The signing fingerprint must be looked up in a keyring populated out of band; a signature is only as trustworthy as the provenance of the key that checks it. Never trust a key that arrived with the file.
  2. Accepting on a verification error. A try block around verify() that returns True in its except, or that omits the check for a falsy verify() result, converts the gate to fail-open. Every non-affirmative path — unreadable signature, untrusted key, expired key, raised error, falsy result — must return a rejection. Test this explicitly by feeding a corrupt signature and asserting the result is a rejection, not an exception.
  3. Expired or revoked key still in the keyring. A signature can be cryptographically valid yet signed by a key whose validity period has ended, or one the originator has revoked. Check expires_at against a UTC clock, and refresh the keyring against revocation notices so a compromised-then-revoked key cannot keep signing accepted files.

Frequently Asked Questions

Why not just rely on PGP decryption to prove the file is authentic?

Decryption and signing prove different things. Encrypting to your public key proves the message was intended to be confidential to you, but anyone holding your public key can encrypt a file — it does not identify the sender. Only a signature verified against a trusted signing key proves origin and integrity. In practice a corridor uses both: encrypt for confidentiality, sign for attribution. Verifying the signature is the step that lets you reject a forged batch that was nonetheless correctly encrypted to you.

Should verification happen before or after decryption?

For a file that is both signed and encrypted, decrypt first (the signature is usually over the plaintext), then verify the signature over the decrypted payload before parsing. What must never happen is parsing before verification. Keeping decryption, verification, and parsing as three separate stages means a confidentiality failure, an attribution failure, and a structural failure each surface at their own boundary with their own runbook.

What should the pipeline do when verification fails?

Quarantine the file with its reason code and escalate; never retry and never parse. A signature failure is permanent — retrying cannot fix a corrupt or forged file, and re-attempting risks eventually accepting a different, unverified delivery. The quarantine record retains the raw bytes, the signing fingerprint if one was identified, and the reason, so an operator or examiner can determine whether it was corruption in transit or an attempted forgery.

How do we handle a signing-key rotation without downtime?

Load both the outgoing and incoming public keys into the trusted keyring for the duration of the originator's documented rotation window, so files signed by either verify successfully. Retire the old fingerprint only after the window closes and you have confirmed the originator has fully switched. The keyring is updated from a signed rotation notice exchanged out of band, never from a new key observed on an inbound file.