Loop icon
AllowFailure icon
Decrypt icon
OutputValues icon
Fail icon
Move icon
Log icon
Set icon
Trigger icon

Decrypt and Verify PGP Files from a Partner's SFTP

Watch an SFTP inbox, decrypt OpenPGP files, verify the partner's signature, refuse replayed files and quarantine anything that fails.

Categories
DataInfrastructure

Banks, payment processors and payroll providers often deliver PGP-encrypted, signed files on an SFTP server. This blueprint accepts a file only if it decrypts with your key, passes its integrity check, carries a valid document signature from the partner key you configured, and its content was not accepted within replay_window. Every other file it picks up is moved to quarantine and the execution ends in WARNING.

How it works

  1. watch_inbox (io.kestra.plugin.fs.sftp.Trigger) polls the inbox every minute for .pgp and .gpg files (any case, ASCII names), oldest first, downloads them and moves the originals to the received directory.
  2. each_file (io.kestra.plugin.core.flow.Loop) handles one file at a time inside an AllowFailure block:
    • decrypt_and_verify (io.kestra.plugin.crypto.openpgp.Decrypt) checks decryption, integrity and a document signature by PARTNER_PGP_PUBLIC_KEY with the user ID allowed_signer.
    • reject_replay fails if the same content was accepted within replay_window (KV store), whatever the file is called.
    • archive_accepted moves the original to the accepted directory and remember_content stores the content hash. If any step fails, the errors branch moves it to quarantine instead.
  3. flag_not_accepted ends the execution in WARNING when at least one file was not accepted.

concurrency.limit: 1 keeps two executions from checking the same content at once. Decrypted content never goes back to the SFTP server.

Outputs

  • accepted: file, sha256 (of the decrypted content read as UTF-8 text, so it equals the file's SHA-256 only for UTF-8 or ASCII content), uri of the decrypted file in internal storage, encrypted_uri (the encrypted file as downloaded) and archived_as.
  • not_accepted: file, iteration (whose logs give the exact message), status, reason, encrypted_uri and quarantined_as. The status is rejected (any failure of decrypt_and_verify, including a wrong private key or passphrase), duplicate (content already accepted) or error (verified, but a later step such as the SFTP move or the KV store failed; the content is not remembered, so the file can be re-sent).
  • On the server, originals end up in the accepted or quarantine directory, prefixed with the execution ID. If the quarantine move fails too, the original stays where it was (received, or accepted if it had already been archived).

Prerequisites

  • Kestra 2.0 or later and plugin-fs 2.11 or later (tested with 2.12.0).
  • plugin-crypto 1.3.9 or later: earlier versions do not check the MDC or the final AEAD tag (before 1.3.7), decrypt only for the first recipient key (before 1.3.8) or accept signatures that are not document signatures (before 1.3.9).
  • An SFTP account for Kestra that reads the inbox, moves files out of it and writes the received, accepted and quarantine directories (create them beforehand), and a separate partner account that can only write to the inbox.
  • Your OpenPGP key pair, and the partner's public key with its fingerprint confirmed by another channel.
  • Secrets: SFTP_HOST (port 22), SFTP_USERNAME, SFTP_PASSWORD, PGP_PRIVATE_KEY and PARTNER_PGP_PUBLIC_KEY (both ASCII-armored) and PGP_PRIVATE_KEY_PASSPHRASE.
  • Variables: the four directories (absolute, with the trailing slash), allowed_signer (the full user ID or its email, matched exactly; the example value rejects every real file) and replay_window (a duration in days or hours, default P90D; P3M, P1Y or P2W are rejected, and every valid file would end as error).

Quick start

  1. Create the secrets and directories, adjust variables and save the flow.
  2. Have the partner run gpg --encrypt --sign -r data-intake@example.com -u partner@example.com orders.csv, upload orders.csv.gpg under a temporary name that does not end in .pgp or .gpg (for example .orders.csv.gpg.part) and rename it.
  3. Within a minute or two outputs.accepted lists the file; checksum mismatch in the decrypt_and_verify log means a wrong passphrase.

The Execute button processes no files: there is no trigger context.

Limits

  • A wrong private key or passphrase looks like a bad file: every file is quarantined as rejected. Test with a known-good file after any key change.
  • The signature only proves that the partner key signed the content as a document, up to line endings: Decrypt takes text mode from the unsigned one-pass header, so text whose line breaks are all CRLF also verifies with LF or CR (the replay key treats them as the same content). Decrypt does not check expiry, revocation, key flags, the hash algorithm, the signature time or the user ID certification, so trust rests on PARTNER_PGP_PUBLIC_KEY: replace it when the partner rotates a key. Only the outermost one-pass signature is verified.
  • No recipient binding: anyone holding a file the partner signed, for you or for someone else, can re-encrypt it to your key and it is accepted unless the same content was accepted within replay_window.
  • Replay detection hashes the decrypted content in memory as UTF-8 text, with line endings normalized. It suits UTF-8 or ASCII text files of moderate size: binary files can collide or end as error, text in other encodings (such as Windows-1252) can collide, and identical legitimate files are marked duplicate, so ask for a date or sequence number in the content.
  • SFTP: plugin-fs does not verify the host key, so a man in the middle could capture SFTP_PASSWORD or withhold files: use a trusted network, and prefer key-based authentication (keyfile) over a password. The trigger does not wait for uploads to finish (hence the rename) and moves files before the execution starts, replacing a same-name file left in the received directory: check that directory after an outage or while executions are queued, and ask for unique file names. Non-ASCII file names are skipped and stay in the inbox (tested with letters, digits, ., _ and -), because plugin-fs 2.12.0 garbles them when it moves them. Server copies can be changed by anyone with write access; encrypted_uri is the copy Kestra verified.
  • Internal storage keeps the decrypted files until purged (for example with a scheduled io.kestra.plugin.core.execution.PurgeExecutions), and anyone who can read this flow's executions can download them. The replay lookup also reads KV entries of parent namespaces, so any flow that can write KV here or in a parent namespace can create or change the entries this flow relies on.
  • Formats: detached signatures, .asc names, hidden recipients (gpg --throw-keyids) and messages without a one-pass signature are not handled; up to 25 files per poll. Tested on Kestra 2.0.5 with GnuPG 2.4 messages (AEAD and v1 with MDC, compressed or not, binary and text-mode signatures) and Ed25519/Cv25519 and RSA 3072 keys.

How to extend

  • Load accepted files from a second flow with an io.kestra.plugin.core.trigger.Flow trigger on this flow (states SUCCESS and WARNING) that reads each uri in trigger.outputs.accepted.
  • Add a notification to the errors branch, or copy the flow per partner with its own key, directories, allowed_signer and flow ID.

Links

See How

New to Kestra?

Use blueprints to kickstart your first workflows.