Skip to content

Security: PGP Decryption and Signature Verification

craft-easy-file-import supports pre-flight cryptographic operations that run before any parsing: PGP/GPG decryption of encrypted files and detached PGP signature verification. Both are configured per template via CryptoConfig.


How the transform chain works

Before a file is parsed, the runner passes it through a TransformChain. The default chain applies transforms in this order, looping until no more apply:

  1. MagicByteDetector — detects MIME type from file magic bytes
  2. PgpDecryptor — decrypts if MIME is application/pgp-encrypted
  3. GzipDecompressor
  4. Bzip2Decompressor
  5. ZipExtractor
  6. TarExtractor

The loop means nested formats are unwrapped automatically. A file named payments.csv.gz.pgp is processed as: PGP decrypt → gunzip → CSV parse — without any special configuration.

Signature verification (SignatureVerifier) runs separately, before the transform chain, because it must operate on the original encrypted/compressed bytes.


PGP/GPG decryption

Installation

The decryptor uses pgpy (pure Python). For high-throughput production deployments, pgpy works without system dependencies.

pip install pgpy

Template configuration

from craft_easy_file_import.models import ImportTemplate, CryptoConfig

template = ImportTemplate(
    id="bank-payments",
    format="csv",
    crypto=CryptoConfig(
        decryption_credential="payments_pgp_private_key",
    ),
)

decryption_credential is the name of a key in the credential store whose value is an ASCII-armored PGP private key:

-----BEGIN PGP PRIVATE KEY BLOCK-----
...
-----END PGP PRIVATE KEY BLOCK-----

Passphrase-protected keys

If the private key is protected by a passphrase, store the passphrase under a credential named {credential_name}.passphrase. For example, if decryption_credential = "payments_pgp_private_key", store the passphrase under payments_pgp_private_key.passphrase.

The decryptor fetches the passphrase credential silently and calls key.unlock(passphrase) before decrypting. If the passphrase credential does not exist, decryption is attempted without a passphrase.

Key management

Keys are fetched from the credential store at runtime on every file — no in-process key cache. This means credential rotation takes effect on the next file without restarting the runner.

The credential store is injected at startup:

from craft_easy_file_import.transforms.pgp import set_credential_store

set_credential_store(my_credential_store)

The credential store must implement async def get(name: str, tenant_id: str) -> str.

Output file name

After decryption, the .pgp, .gpg, or .asc extension is stripped from the file name so downstream parsers see the underlying format. payments.csv.pgp becomes payments.csv; report.json.gpg becomes report.json.

Decryption failures

All decryption errors raise CryptoError with a machine-readable code:

code Cause
pgp_unavailable pgpy not installed
no_decryption_credential Template has no decryption_credential
credential_fetch_failed Credential store returned an error
invalid_private_key Armored key could not be parsed
pgp_parse_error File is not a valid PGP message
pgp_decrypt_failed Wrong key or bad passphrase

When CryptoError is raised, the runner marks the run as failed, creates an ImportFileFailure, and uploads the original (encrypted) file for diagnosis.


Detached signature verification

Signature verification ensures a file was signed with a trusted private key before any processing occurs. It runs before the transform chain so that the file is verified in its original form (still encrypted/compressed if applicable).

Template configuration

template = ImportTemplate(
    id="bank-payments",
    format="csv",
    crypto=CryptoConfig(
        require_signature=True,
        signature_public_key_credential="payments_pgp_public_key",
        signature_file_pattern="{file}.sig",  # default
    ),
)

signature_public_key_credential must hold an ASCII-armored PGP public key.

Signature file pattern

The signature file must be discoverable alongside the data file. The signature_file_pattern supports two placeholders:

Placeholder Resolves to
{file} Full file name, e.g. payments.csv.gz
{file_stem} Name without final extension, e.g. payments.csv

Examples:

# Default: payments.csv.gz → looks for payments.csv.gz.sig
signature_file_pattern = "{file}.sig"

# Detached sig alongside compressed file: payments.csv.gz → payments.csv.sig
signature_file_pattern = "{file_stem}.sig"

# GPG armor format
signature_file_pattern = "{file}.asc"

The verifier looks for the signature file via the source's open() method (so it works with SFTP, S3, Azure, etc.) and falls back to the local filesystem.

Verification failures

code Cause
no_signature_credential require_signature=True but no signature_public_key_credential
signature_missing Signature file not found at the resolved path
pgp_unavailable pgpy not installed
credential_fetch_failed Credential store returned an error
invalid_public_key Armored public key could not be parsed
signature_invalid Cryptographic verification failed

Other CryptoConfig fields

Field Default Purpose
decryption_credential None PGP private key credential name
require_signature False Whether a detached signature is required
signature_public_key_credential None PGP public key credential name
signature_file_pattern {file}.sig Pattern for resolving the signature file
zip_entry_regex None If set, only ZIP entries matching this regex are extracted
max_decompression_ratio 50.0 Maximum allowed decompression ratio (zip-bomb protection)

Selective ZIP extraction

When a ZIP archive contains multiple entries, zip_entry_regex limits which entries are extracted and processed:

CryptoConfig(
    zip_entry_regex=r"payments_\d{8}\.csv$",  # only CSV files matching this pattern
)

Zip-bomb protection

max_decompression_ratio limits how much a compressed file may expand. The default of 50.0 means a 1 MB compressed file may decompress to at most 50 MB. Files that exceed this ratio raise CryptoError(code="decompression_ratio_exceeded").


Temporary file security

Decrypted content is written to a temporary file with permissions 0o600 (owner read/write only) using a cef-import- prefix. The runner tracks all temporary files per job and deletes them in a finally block regardless of success or failure.

Temp files are created in the system's default temp directory (tempfile.gettempdir()). On Linux this is typically /tmp; consider mounting a tmpfs for additional isolation in containerised deployments.