pqfile 4.3.1

Quantum-resistant file encryption: ML-KEM (512/768/1024), hybrid X25519+ML-KEM-768, ML-DSA-65 and SLH-DSA signing, multi-recipient, Shamir sharing
Documentation

A quantum-resistant file encryption tool with a CLI and a cross-platform GUI. It combines post-quantum key encapsulation (ML-KEM, NIST FIPS 203) with ChaCha20-Poly1305 authenticated encryption. Four key types are supported: ML-KEM-512, ML-KEM-768, ML-KEM-1024, and a hybrid X25519+ML-KEM-768 mode. A file can be encrypted to multiple recipients in a single pass, optionally hiding key types and even recipient count from an observer.

Digital signatures use ML-DSA-65 (NIST FIPS 204), with hash-based SLH-DSA-SHAKE-192f (NIST FIPS 205) as an option for long-lived signatures.

docs/QUICKSTART.md: build, install, common CLI commands, GUI overview, deploying.

Service status: status.nappi.work/status/pqfile


Background

Classical public-key algorithms such as RSA and ECDH are vulnerable to attacks from sufficiently large quantum computers. ML-KEM (Module-Lattice Key Encapsulation Mechanism), standardized by NIST as FIPS 203, is believed to be secure against both classical and quantum adversaries.

pqfile uses a hybrid approach:

  1. ML-KEM encapsulates a fresh random session key. Only the holder of the matching private decapsulation key can recover it.
  2. ChaCha20-Poly1305 encrypts the file contents under that session key using the STREAM construction (64 KiB chunks by default). Each chunk is independently authenticated and position-bound so truncation and reordering attacks are detected.

The optional hybrid mode (--hybrid) adds X25519 Diffie-Hellman to the key exchange so the encryption is secure under either classical or quantum assumptions, whichever holds in the future.


Cryptographic standards

Component Standard / Specification
Key encapsulation (standard) ML-KEM-512, ML-KEM-768, or ML-KEM-1024, NIST FIPS 203
Key encapsulation (hybrid) X25519 + ML-KEM-768, key combined via HKDF-SHA256
Symmetric cipher ChaCha20-Poly1305, RFC 8439
Session key wrapping (v4/v7/v8/v9) AES-256-GCM
Randomness OS CSPRNG via getrandom
Key derivation (passphrase) Argon2id (m=64 MiB, t=3, p=4)
Key wrapping (passphrase) AES-256-GCM
Digital signatures ML-DSA-65 (FIPS 204); optional SLH-DSA-SHAKE-192f (FIPS 205)
Key fingerprints SHA3-256 (first 8 bytes, colon-separated hex)
Threshold key splitting Shamir's Secret Sharing over GF(256)
Hardware-backed key storage OS credential store (Windows Credential Manager, macOS Keychain, Linux Secret Service)

Project structure

PQ-File-Encryption/
├── Cargo.toml                  Workspace manifest
├── deny.toml                   cargo-deny supply-chain policy (licenses, advisories, bans)
├── supply-chain/               cargo-vet audit configuration
├── fuzz/                       cargo-fuzz targets (excluded from the main workspace)
│   └── fuzz_targets/
│       ├── fuzz_header_read.rs     Fuzzes PqfHeader::read on arbitrary bytes
│       ├── fuzz_decrypt_bytes.rs   Fuzzes decrypt_bytes on arbitrary ciphertext
│       └── fuzz_pem_parsing.rs     Fuzzes PEM parsing and fingerprinting
├── oss-fuzz/                   OSS-Fuzz integration (Dockerfile, build.sh, project.yaml)
├── scripts/                    Release tooling (bump-version.ps1, test-local.ps1)
├── pqfile/                     Core crypto library
│   ├── src/
│   │   ├── lib.rs                Public library re-exports
│   │   ├── keygen.rs             Key pair generation and PEM serialization
│   │   ├── keys.rs               Typed key wrappers (PqfPublicKey, PqfPrivateKey, ...)
│   │   ├── encrypt.rs            Encryption pipeline (v2-v9 formats, mmap, pipelined, parallel)
│   │   ├── decrypt.rs            Decryption pipeline (v2-v9 auto-detect, parallel)
│   │   ├── format.rs             .pqf binary file format definitions
│   │   ├── reader.rs             PqfReader<R: Read> streaming decryptor
│   │   ├── writer.rs             PqfWriter<W: Write> streaming encryptor
│   │   ├── async_io.rs           Async encrypt/decrypt/AsyncPqfWriter (tokio, feature "async")
│   │   ├── archive.rs            Encrypted multi-file archive (PQFA format)
│   │   ├── rekey.rs              Re-encryption without payload decryption
│   │   ├── add_recipient.rs      Add a recipient to a multi-recipient file in place
│   │   ├── revoke.rs             Key revocation sidecar (.revoked) support
│   │   ├── shamir.rs             Shamir secret sharing for M-of-N key splitting
│   │   ├── sign.rs               ML-DSA-65 / SLH-DSA-SHAKE-192f signing and verification
│   │   ├── signcrypt.rs          Combined sign-then-encrypt and signdecrypt
│   │   ├── sealed_sender.rs      Deniable sender authentication (identity keys, seal/unseal)
│   │   ├── passphrase.rs         Argon2id wrapping for passphrase-protected keys
│   │   ├── repassphrase.rs       Change or upgrade a private key's passphrase
│   │   ├── inspect.rs            Header inspection without decryption
│   │   ├── shred.rs              Secure file shredding (overwrite then delete)
│   │   ├── progress.rs           Progress-callback reader/writer wrappers
│   │   ├── fsutil.rs             Internal: restrictive-permission private-key file writes
│   │   ├── hardware/             OS credential store-backed private keys
│   │   │   ├── mod.rs, credential_store.rs, stub.rs
│   │   └── error.rs              PqfileError enum
│   ├── tests/                    Compat matrix, property tests, format vectors, WASM smoke tests
│   ├── examples/                 ct_shamir.rs (dudect constant-time benchmark), vector generators
│   └── benches/
│       └── crypto.rs             Criterion benchmarks (encrypt/decrypt at 1 KB/1 MB/100 MB)
├── pqfile-cli/                  CLI binary
│   ├── src/main.rs                CLI entry point (clap subcommands, stdin/stdout support)
│   ├── packaging/                 .deb / .rpm packaging assets (pqfile.spec)
│   └── tests/roundtrip.rs         End-to-end CLI integration tests
├── pqfile-gui/                  Shared GUI logic + WASM web app
│   ├── icon.png                   App icon (512x512)
│   ├── index.html                 Canvas page for trunk/WASM builds
│   └── src/
│       ├── lib.rs                  Entry point, WASM start fn, tests
│       ├── app.rs                  PqfileApp struct, tab routing, sidebar, in-app help text
│       ├── colors.rs, theme.rs     Catppuccin palette + egui theme
│       ├── types.rs                Shared types (Tab, FileInput, Settings, ...)
│       ├── widgets.rs              UI helper functions
│       ├── fido2.rs                CTAP2 enroll/derive (native, feature "fido2")
│       └── tabs/                   Keys, Keygen, Encrypt, Decrypt, Sign, Signcrypt, Sealed Sender, Archive, Shamir, Inspect, Clipboard, Settings, fido2_ui
└── pqfile-desktop/               Native desktop binary
    ├── build.rs                   Embeds icon/metadata via winres (Windows)
    ├── packaging/                 Inno Setup installer script, .ico asset
    └── src/main.rs                Native entry point

The pqfile crate is a library. The pqfile-cli crate provides the CLI binary. The pqfile-gui crate compiles to a cdylib for WASM and an rlib for the native binary. pqfile-desktop is the thin native entry point. This follows the official eframe template pattern.


CLI usage

Running bare pqfile with no arguments starts an interactive guided mode that walks through encrypting, decrypting, or generating a key pair with prompts instead of flags. It calls the same code paths as the flag-driven commands below, so behavior and defaults are identical. Any argument (including --help) takes the normal CLI path.

Key generation

# ML-KEM-768 (default, 128-bit post-quantum security)
pqfile keygen --out ./keys

# ML-KEM-512 (category 1, smaller keys and ciphertexts)
pqfile keygen --out ./keys --level 512

# ML-KEM-1024 (192-bit post-quantum security)
pqfile keygen --out ./keys --level 1024

# Hybrid X25519 + ML-KEM-768 (secure under classical OR quantum assumptions)
pqfile keygen --out ./keys --hybrid

# Any of the above with a passphrase-protected private key
pqfile keygen --out ./keys --passphrase

# Store the private key seed in the OS credential store instead of on disk
pqfile keygen --out ./keys --hardware --label my-pqfile-key

# Also render the pqf1... recipient string as a terminal QR code for scanning
pqfile keygen --out ./keys --qr

Key files written: pubkey.pem (share freely) and privkey.pem (keep secret; written with owner-only permissions on Unix). The fingerprint (SHA3-256, first 8 bytes) is printed at generation time, along with a compact pqf1… Bech32m recipient string that can be passed directly to -r without distributing a PEM file. --hardware stores the seed in Windows Credential Manager / macOS Keychain / Linux Secret Service instead of writing it to privkey.pem, and is mutually exclusive with --passphrase and --expiry. The same --hardware --label <LABEL> flags work on sign-keygen.

# Print fingerprint and recipient string for an existing key (accepts PEM path or pqf1… string)
pqfile fingerprint pubkey.pem

# Same, plus a scannable terminal QR code of the recipient string
pqfile fingerprint pubkey.pem --qr

# Use a recipient string directly - no PEM file needed
pqfile encrypt -r pqf1qyxhkc... secret.txt

Import an existing key

# Derive an ML-KEM-768 key pair from an existing unencrypted OpenSSH ed25519 private key
pqfile import-key --from ~/.ssh/id_ed25519 --out ./keys

This is a one-way HKDF derivation with no mathematical relationship back to the original SSH key, and the result is not interoperable with SSH. Decrypt password-protected SSH keys first: ssh-keygen -p -f <key> -N "".

Encryption

# Single recipient
pqfile encrypt -r pubkey.pem secret.txt
# Output: secret.txt.pqf

# Custom output path
pqfile encrypt -r pubkey.pem secret.txt -o encrypted.pqf

# Multiple recipients (any one can decrypt)
pqfile encrypt -r alice/pubkey.pem -r bob/pubkey.pem secret.txt

# Recursive directory encryption
pqfile encrypt -r pubkey.pem --recursive /path/to/dir/
# Each file produces <file>.pqf alongside the original; existing .pqf files are skipped

# Compress before encrypting (single recipient only, not available on WASM)
pqfile encrypt -r pubkey.pem --compress --compress-level 3 secret.txt

# Encrypt chunks in parallel using rayon (single recipient, incompatible with --compress)
pqfile encrypt -r pubkey.pem --parallel large_file.bin

# Cap Rayon worker threads for --parallel (global flag; default 0 = all cores)
pqfile --threads 4 encrypt -r pubkey.pem --parallel large_file.bin

# Custom chunk size in bytes (single recipient, produces v5 format)
# Omit --chunk-size to auto-tune: 16 KiB for small files, 64 KiB default, 256 KiB for large files
pqfile encrypt -r pubkey.pem --chunk-size 131072 large_file.bin

# Hide key types (multiple recipients, produces v8 format)
pqfile encrypt -r alice/pubkey.pem -r bob/pubkey.pem --anonymous-recipients secret.txt

# Hide key types AND recipient count (pads slots to next power of two, produces v9 format)
pqfile encrypt -r alice/pubkey.pem -r bob/pubkey.pem --pad-recipients secret.txt

# Overlap I/O and AEAD for large files on spinning disk or network storage
pqfile encrypt -r pubkey.pem --pipeline large_file.bin

# Memory-mapped zero-copy encrypt (native only, best for files >= 100 MiB)
pqfile encrypt -r pubkey.pem --mmap huge_file.bin

# Read from stdin, write to stdout
cat secret.txt | pqfile encrypt -r pubkey.pem - > secret.txt.pqf

# Passphrase-only encryption (v10 format, no key pair required)
pqfile encrypt --passphrase secret.txt

# Stronger Argon2id parameters for v10 (see `pqfile doctor --calibrate` for a
# machine-tuned recommendation; decryptors above the default need --max-kdf-mem)
pqfile encrypt --passphrase --kdf-mem 189440 --kdf-time 3 secret.txt

# Keyfile second factor for v10: decryption requires the passphrase AND this file
pqfile encrypt --passphrase --keyfile usb/token.bin secret.txt

# FIDO2 hardware token second factor for v10 (requires the `fido2` cargo
# feature; see fido2-enroll below). Mutually exclusive with --keyfile.
pqfile encrypt --passphrase --fido2 fido2-enrollment.txt secret.txt

# Pad the plaintext to a coarser length bucket (Padme, <= ~12% overhead) so the
# ciphertext length hides the exact file size; decryption strips it automatically
pqfile encrypt -r pubkey.pem --pad secret.txt

# Stealth mode: output carries no magic bytes, version, or key-type field, so it
# is not identifiable as pqfile ciphertext (single recipient; see FORMAT.md 5.10)
pqfile encrypt -r pubkey.pem --stealth secret.txt

# The two compose: unidentifiable ciphertext with a bucketed length
pqfile encrypt -r pubkey.pem --stealth --pad secret.txt

# Time-locked encryption (v11 format, requires the `tlock` cargo feature):
# nobody, including the sender, can decrypt before the target drand beacon
# round is public. Resolve a human time expression to a round number first.
pqfile tlock round "24h"
pqfile encrypt --tlock-round 30438443 secret.txt

Multiple -r flags produce a v4 multi-recipient file. Each recipient gets their own encapsulated session key; the file payload is encrypted once. --anonymous-recipients upgrades to v8 format, dropping the per-slot KEM variant field so key types are hidden. --pad-recipients upgrades to v9 format, which additionally pads the slot count to the next power of two with random dummy entries. --recursive requires exactly one recipient. --compress-level accepts 1 (fastest) to 22 (best ratio), default 3. --parallel uses rayon for concurrent chunk processing and requires a single recipient.

--pad requires a known, non-zero input size, so it is incompatible with stdin input, empty files, --mmap, --pipeline, and --compress (compression would shrink the padding back down); it composes with --parallel and --stealth. --stealth supports a single recipient only and is incompatible with --passphrase, --recursive, and --mmap; because the output has no header, the recipient must already know the file is in stealth mode and pass --stealth at decrypt time.

Decryption

# Decrypt to default path (strips .pqf extension)
pqfile decrypt -k privkey.pem secret.txt.pqf

# Custom output path
pqfile decrypt -k privkey.pem secret.txt.pqf -o recovered.txt

# Decrypt chunks in parallel (only effective for v3/v5 chunked-format files)
pqfile decrypt -k privkey.pem large_file.bin.pqf --parallel

# Stdin/stdout pipeline
cat secret.txt.pqf | pqfile decrypt -k privkey.pem - -o -

# Passphrase-only decryption (v10 format)
pqfile decrypt --passphrase secret.txt.pqf

# Cap Argon2 resource usage when decrypting untrusted v10 files
pqfile decrypt --passphrase --max-kdf-mem 32 --max-kdf-time 2 secret.txt.pqf

# v10 file encrypted with a keyfile second factor
pqfile decrypt --passphrase --keyfile usb/token.bin secret.txt.pqf

# v10 file encrypted with a FIDO2 hardware token second factor
pqfile decrypt --passphrase --fido2 fido2-enrollment.txt secret.txt.pqf

# File written with `encrypt --stealth` (magic-free; cannot be auto-detected)
pqfile decrypt -k privkey.pem --stealth secret.txt.pqf

# File written with `encrypt --tlock-round`; fetches the beacon signature over
# the network and fails with a clear error if the round hasn't fired yet
pqfile decrypt --tlock secret.txt.pqf

Time-locked encryption (drand beacon)

Not a post-quantum guarantee: the underlying tlock scheme is BLS12-381 pairing-based (classical), layered on top of pqfile's usual authenticated, chunked payload. Requires building with the tlock cargo feature (off by default: pulls in the only network-capable dependencies in the workspace).

# Resolve a human time expression to a drand round number (fetches only the
# chain's public parameters, never a round's own beacon - safe for future rounds)
pqfile tlock round "24h"        # relative duration
pqfile tlock round "7d"
pqfile tlock round "2026-08-01T00:00:00Z"   # RFC 3339 datetime

# Encrypt: fully offline given a round number. No recipient key or passphrase.
pqfile encrypt --tlock-round 30438443 secret.txt

# Decrypt: fetches the target round's beacon signature (the only network call
# in the pqfile library). Fails cleanly if the round hasn't fired yet.
pqfile decrypt --tlock secret.txt.pqf
pqfile check --tlock secret.txt.pqf

Uses the League of Entropy mainnet quicknet chain by default; --tlock-url overrides the relay for decrypt/check. See docs/FORMAT.md §5.12 for the wire format.

FIDO2 hardware token second factor

CLI: requires building pqfile-cli with the fido2 cargo feature (cargo install pqfile-cli --features fido2, or cargo build --features fido2 from source); off by default so a normal build never needs USB HID system libraries (libudev-dev on Linux).

# Enroll a security key once: creates a non-resident CTAP2 credential
# requesting the hmac-secret extension and writes an enrollment file. Not
# sensitive on its own - reproducing the derived secret requires physically
# touching the same token - so it can be backed up like ordinary configuration.
pqfile fido2-enroll -o fido2-enrollment.txt

# If the token requires a PIN for this operation:
pqfile fido2-enroll -o fido2-enrollment.txt --pin

Desktop GUI: pqfile-desktop ships with this built in (no separate feature flag to pass). On the Encrypt or Decrypt tab, switch to Passphrase mode, then pick "FIDO2 token" under Second Factor and click "Enroll New Token…" the first time. Not available in the web (WASM) GUI - there is no browser-compatible USB HID backend, only the native hidapi-based one pqfile-desktop uses. See "WebAuthn PRF second factor" below for the web build's own equivalent.

Pass the enrollment file to encrypt --fido2 / decrypt --fido2 / check --fido2 in place of --keyfile; the two are mutually exclusive. Each operation touches the token (and prompts for its PIN, if enrolled with one).

If the private key is passphrase-protected, the passphrase is prompted interactively. Works with v2 through v10 files (all single-recipient variants) and v4/v7/v8/v9 (multi-recipient).

WebAuthn PRF second factor (web GUI only, currently disabled in the UI)

The browser-native equivalent of the FIDO2 second factor above, for the web build of pqfile-gui (no CTAP2/USB HID access exists in a browser). Uses the WebAuthn prf extension (navigator.credentials with a passkey) instead of raw CTAP2. No CLI surface (there is no browser in a terminal) and no cargo feature to build with - it's just part of the wasm32 build.

Status: implemented but disabled in the Second Factor selector ("Passkey" is greyed out with an explanatory tooltip). The implementation is complete and spec-correct - it matches the reference flow in Yubico's own PRF developer guide exactly, and the v10 format/library side is fully tested - but real-world browser/OS support for evaluating PRF (not just reporting it as present) turned out too inconsistent to enable as of mid-2026, confirmed by hands-on testing rather than assumed from documentation: Bitwarden's browser extension doesn't implement PRF for third-party sites at all (registration succeeds, prf.enabled correctly reports false); Windows Hello via Edge registers and reports prf.enabled: true but the follow-up secret derivation returns no output; Firefox fails at registration itself with a generic platform error. Revisit once this settles down - the second-factor selector's disabled branch is a one-line flip back to ui.selectable_value(...) once it does.

Local testing note: WebAuthn's relying-party ID must be a valid domain string - a raw IP address like 127.0.0.1 doesn't qualify (unlike for the separate secure-context check, where an IP is fine over plain HTTP on the same machine). If trunk serve prints both http://127.0.0.1:8080/ and http://localhost:8080/, use the localhost one - 127.0.0.1 fails passkey registration with an "invalid domain" error.

Check (verify a backup without writing plaintext)

# Run the full decrypt path into a null sink: every chunk's AEAD tag is verified,
# but no plaintext is written anywhere
pqfile check -k privkey.pem backup.tar.pqf
# OK: backup.tar.pqf authenticated (1048576 plaintext bytes)

# v10 passphrase-only files
pqfile check --passphrase secret.txt.pqf

# Files written with `encrypt --stealth` (requires -k)
pqfile check -k privkey.pem --stealth secret.txt.pqf

# Scripting: exit code 0/1 plus structured JSON
pqfile --json check -k privkey.pem backup.tar.pqf
# {"status":"ok","input":"backup.tar.pqf","plaintext_bytes":1048576}

Useful for validating backups and testing that a key still decrypts a file, without producing a cleartext copy on disk.

Rekey

# Re-wrap the session key under a new recipient key without decrypting the payload
pqfile rekey -k old_privkey.pem -r new_pubkey.pem -o new.pqf old.pqf

Decapsulates the session key with the old private key, re-encapsulates it under the new public key, and rewrites only the header. Payload bytes are not decrypted. Useful for key rotation.

Revoke

# Create a revocation sidecar for a public key
pqfile revoke --key pubkey.pem --reason "Key compromised"
# Output: pubkey.pem.revoked

# pqfile encrypt will refuse to use a key that has a .revoked sidecar

Change a key's passphrase

# Re-encrypt a private key under a new passphrase (prompts for old and new)
pqfile repassphrase -k privkey.pem

# Migrate a key created before pqfile 4.0 (legacy Argon2id p=1) to the current p=4 parameters
pqfile repassphrase -k privkey.pem --from-legacy

The key file is only overwritten after re-encryption succeeds; on error the original is untouched.

Inspect

pqfile inspect secret.txt.pqf

For a single-recipient file (v2/v3):

Magic:              PQFL
Version:            0x03
KEM variant:        768 (ML-KEM-768)
Nonce:              3a7b...
Original file size: 2048 bytes

For a multi-recipient file (v4):

Magic:              PQFL
Version:            0x04 (multi-recipient)
Recipients:         2
  Recipient 0:      768 (ML-KEM-768)
  Recipient 1:      1024 (ML-KEM-1024)
Nonce:              8c2f...
Original file size: 2048 bytes

Files written by the current version set the authenticated-header bit (bit 7) on the version byte, so inspect shows 0x83 instead of 0x03, and reports an extra Auth. header: yes/no line (header_authenticated in --json output). See FORMAT.md section 4.4 for what the bit authenticates. Stealth-mode files have no header and cannot be inspected.

Digital signatures

# Generate a signing key pair (separate from encryption keys)
pqfile sign-keygen --out ./keys
# Writes: sign_pubkey.pem (1952 bytes), sign_privkey.pem (32-byte seed)

# Sign a file (produces a detached .sig file)
pqfile sign -k sign_privkey.pem document.pdf
# Output: document.pdf.sig

# Custom signature output path
pqfile sign -k sign_privkey.pem document.pdf -o document.sig

# Verify a signature
pqfile verify -k sign_pubkey.pem -s document.pdf.sig document.pdf

# Hash-based signatures for long-lived signing (FIPS 205)
pqfile sign-keygen --out ./keys --algorithm slh-dsa-shake-192f

Signatures default to ML-DSA-65 (NIST FIPS 204), 3309 bytes, stored in PEM format. The verifying key (1952 bytes) can be distributed alongside the signed content. sign-keygen also accepts --passphrase and --hardware --label <LABEL>, same as keygen.

--algorithm slh-dsa-shake-192f selects SLH-DSA-SHAKE-192f (NIST FIPS 205) instead: hash-based signatures resting on much more conservative security assumptions than lattices, at the cost of slower signing and 35664-byte signatures. Same NIST security category (3) as ML-DSA-65; best for very long-lived signatures such as archival or release signing. sign, verify, signcrypt, and signdecrypt detect the algorithm from the key automatically - no extra flags.

Certificates

A minimal PKI layer over signing keys: a CA signing key attests to a subject public/verifying key's label, validity window, and permitted uses, so the certificate can be used in place of a raw key wherever pqfile accepts a recipient or verifying key.

# Issue a certificate: the CA vouches for a subject key
pqfile issue-cert --ca-key ca_sign_privkey.pem --subject pubkey.pem \
  --label "alice's laptop" --allow-encrypt --valid-days 365 -o alice.cert

# Verify a certificate and print its contents
pqfile verify-cert --ca-key ca_sign_pubkey.pem alice.cert

# Use a certificate directly as an encrypt recipient - no need to extract the key first
pqfile encrypt -r alice.cert --ca-key ca_sign_pubkey.pem document.pdf

# Certificates also work in place of a raw key for verify/signcrypt/signdecrypt
pqfile verify -k signer.cert --ca-key ca_sign_pubkey.pem -s document.pdf.sig document.pdf

Certificates do not chain and have no revocation mechanism beyond their own validity window, unless you also maintain a revocation list:

# Revoke a certificate before its validity window naturally expires
pqfile revoke-cert --ca-key ca_sign_privkey.pem alice.cert --reason "laptop lost" -o revocations.pem

# Revoking a second certificate appends to the same list and re-signs it
pqfile revoke-cert --ca-key ca_sign_privkey.pem bob.cert --existing revocations.pem \
  --reason "key rotated" -o revocations.pem

# Any command that accepts --ca-key for a certificate can also check --revocations
pqfile encrypt -r alice.cert --ca-key ca_sign_pubkey.pem --revocations revocations.pem document.pdf
pqfile verify-cert --ca-key ca_sign_pubkey.pem --revocations revocations.pem alice.cert

--revocations is optional everywhere it appears (encrypt, verify, signcrypt, signdecrypt, seal, verify-cert) - a certificate is accepted even without a matching entry when it is omitted, the same way .revoked sidecar checking for raw keys only happens when the sidecar file exists. There is no way to un-revoke a certificate; issue a new one instead. Full validity-window enforcement, allowed_use checks, and revocation list verification all happen before any recipient key is trusted or any signature is accepted.

Signcrypt

# Sign and encrypt in one step; the signature is embedded inside the ciphertext
pqfile signcrypt -k sign_privkey.pem -r pubkey.pem document.pdf
# Output: document.pdf.pqf

# Custom output path
pqfile signcrypt -k sign_privkey.pem -r pubkey.pem document.pdf -o signed.pqf

# Decrypt and verify the embedded signature in one step
pqfile signdecrypt -k privkey.pem -v sign_pubkey.pem document.pdf.pqf

Unlike pqfile sign followed by pqfile encrypt, the signature lives inside the AEAD-authenticated payload and cannot be stripped or substituted after encryption. A recipient cannot re-encrypt the plaintext to a third party while preserving the sender's signature. Stdin is not supported as input because two passes over the file are required (one to hash, one to encrypt).

Sealed sender

# One-time setup: each party generates a separate identity key pair
# (distinct from their encryption and signing keys)
pqfile identity-keygen --out ./alice-identity
pqfile identity-keygen --out ./bob-identity

# Alice seals a file for Bob: proves to Bob she sent it, without leaving
# evidence anyone else could check
pqfile seal -k alice-identity/identity_privkey.pem \
  --recipient-identity bob-identity/identity_pubkey.pem \
  -r bob-pubkey.pem document.pdf
# Output: document.pdf.pqf

# Bob unseals it: decrypts and verifies the sender in one step
pqfile unseal -k bob-privkey.pem \
  --identity-key bob-identity/identity_privkey.pem \
  -s alice-identity/identity_pubkey.pem document.pdf.pqf

Unlike signcrypt, which embeds a non-repudiable ML-DSA/SLH-DSA signature, sealed sender proves the sender's identity only to the specific intended recipient. The authentication tag is derived from a static X25519 Diffie-Hellman between the sender's and recipient's identity keys, so computing it requires only one party's private key plus the other's public key - the recipient could have forged an identical tag themselves, and no third party can ever confirm who really sent the file. Useful when the existence of a communication relationship is itself sensitive. unseal buffers the plaintext internally and only releases it once the tag verifies; stdin is not supported for seal (two passes are required, as with signcrypt).

Archive and extract

# Pack multiple files into a single encrypted archive
pqfile archive -r pubkey.pem file1.txt file2.txt report.pdf -o bundle.pqf

# Strip a directory prefix so entries use relative paths
pqfile archive -r pubkey.pem --base ./project/ ./project/src/main.rs ./project/README.md

# Pack an entire directory tree (entry names keep the directory prefix, like tar)
pqfile archive -r pubkey.pem --recursive ./project -o project.pqf

# List archive contents without extracting
pqfile extract bundle.pqf -k privkey.pem --list

# Extract to a directory (default: current directory)
pqfile extract bundle.pqf -k privkey.pem -o recovered/

Archives use the PQFA format: a streaming authenticated payload where the plaintext is a structured entry sequence. Each entry stores the original relative path and file data. All AEAD authentication is verified before any file is written to disk. Path traversal attempts (entries containing ..) are rejected. --recursive rejects symlinks and special files (devices, FIFOs, sockets) during the walk, and entry names that collide — including case-insensitively, which would overwrite each other when extracted on Windows or macOS — are rejected at pack time.

Threshold key splitting (Shamir)

# Split a private key into 3 shares, any 2 of which can reconstruct it
pqfile split-key --threshold 2 --shares 3 privkey.pem --out ./shares/
# Writes: shares/share_1.pem, shares/share_2.pem, shares/share_3.pem

# Reconstruct the private key from any 2 of the 3 shares
pqfile reconstruct-key shares/share_1.pem shares/share_3.pem --out ./recovered/
# Writes: recovered/privkey.pem, recovered/pubkey.pem

Uses GF(256) Shamir secret sharing over the 64-byte private key seed. Any threshold shares reconstruct the key; fewer than threshold shares reveal nothing about the seed. Useful for key escrow, disaster recovery, or organizational workflows requiring multi-party approval to access protected data.

Diagnostics

# Inspect a private key file (passphrase status, hardware, legacy p=1, revocation)
pqfile doctor privkey.pem

# Inspect a .pqf file (version, KEM info, header sanity, no decryption needed)
pqfile doctor secret.txt.pqf

# JSON output for scripting
pqfile --json doctor privkey.pem

# Benchmark Argon2id on this machine and recommend --kdf-mem / --kdf-time values
# hitting a target wall-clock time (default 250 ms) for v10 passphrase encryption
pqfile doctor --calibrate
pqfile doctor --calibrate --target-ms 500

Calibration scales the memory cost first (64 MiB floor — the compiled-in default — up to a 1 GiB ceiling), then the time cost. It never recommends parameters weaker than the defaults: a fast machine gets stronger parameters, a slow machine simply gets the defaults back.

Config file

Routine commands can drop the -r/-k flags by setting defaults in a config file — ~/.config/pqfile/config.toml ($XDG_CONFIG_HOME respected) or %APPDATA%\pqfile\config.toml on Windows:

# Default recipient for `encrypt`: a pqf1… string or a pubkey.pem path
recipient = "pqf1abc..."
# Default private key for `decrypt` and `check`
key = "/home/me/.keys/privkey.pem"

With this in place, pqfile encrypt notes.txt and pqfile decrypt notes.txt.pqf just work. Explicit flags always override the config. Pass the global --no-config flag to ignore the file entirely (recommended in scripts). A malformed config is a hard error, never silently ignored. Only key = "value" pairs, # comments, and \\/\" escapes are accepted.

Shell completions

pqfile completions bash   >> ~/.bash_completion
pqfile completions zsh    > ~/.zfunc/_pqfile
pqfile completions fish   > ~/.config/fish/completions/pqfile.fish
pqfile completions powershell >> $PROFILE

JSON output

Every command accepts a global --json flag for machine-readable output:

pqfile --json keygen --out ./keys
# {"status":"ok","pubkey_path":"./keys/pubkey.pem","privkey_path":"./keys/privkey.pem","fingerprint":"21:f3:b4:..."}

pqfile --json inspect file.pqf
pqfile --json encrypt -r pubkey.pem file.txt
pqfile --json decrypt -k privkey.pem file.txt.pqf
pqfile --json sign -k sign_privkey.pem file.txt
pqfile --json verify -k sign_pubkey.pem -s file.txt.sig file.txt

Errors go to stderr as {"status":"error","code":N,"message":"..."}. The numeric code field maps to PqfileError variants; see docs/ERROR_CODES.md for the stable code table. Exit code is always 1 on error.


GUI

The desktop GUI (pqfile-desktop) and web app (pqfile-gui) share the same egui code and expose nearly everything the CLI does, each tab with built-in "?" help text:

  • 🗝 Keys: a persistent registry of key pairs with fingerprints and quick-load buttons for the Encrypt/Decrypt tabs, plus collapsible "Change Passphrase" and "Revoke Key" sections (native only), and an "Issue / Verify / Revoke Certificate" panel for the CA certificate workflow
  • 🔑 Keygen: generates ML-KEM-512/768/1024, hybrid X25519+ML-KEM-768, ML-DSA-65, or SLH-DSA-SHAKE-192f signing key pairs, with optional passphrase or hardware-backed (OS credential store) protection, key expiry dates, and OpenSSH ed25519 key import (native only)
  • 🔒 Encrypt: multi-file batch encryption to one or more recipients; 2+ recipients automatically use the anonymous v8 format (toggle "pad recipient count" for v9); optional zstd compression for single-recipient files; checkboxes for Padme length padding and magic-free stealth mode (single recipient); a folder watcher that auto-encrypts new files (native only); drag-and-drop
  • 🔓 Decrypt: loads any v2-v10 .pqf file, prompting for a passphrase only when the key requires one; a "Stealth mode" checkbox for files encrypted without a header; includes a Rekey sub-tab to re-wrap a file for a new recipient without decrypting the payload
  • ✏ Sign / 🔏 Signcrypt: sign and verify with ML-DSA-65 or SLH-DSA-SHAKE-192f (algorithm detected from the loaded key, shown inline), plus combined sign-then-encrypt and decrypt-then-verify
  • 🕶 Sealed Sender: generate a separate X25519 identity key pair, then seal (encrypt with deniable sender authentication) and unseal files - proves the sender to the specific recipient without producing evidence a third party could ever check
  • 📦 Archive: pack multiple files into one encrypted .pqf container and extract with path-traversal protection
  • 🔀 Shamir: split a private key into M-of-N shares (with QR code export for air-gapped transfer) and reconstruct from shares
  • 🔍 Inspect: header metadata for any .pqf file, or a key-file health check (passphrase/hardware status, expiry, legacy Argon2 detection, revocation sidecar), without decrypting
  • 📋 Clipboard: encrypt/decrypt short text snippets without writing to disk, with an optional auto-clear timer
  • ⚙ Settings: theme, default output directory, confirm-before-overwrite, and clipboard auto-clear preferences

Hardware-backed keys, the folder watcher, SSH key import, passphrase change, and revocation are native-only (not available in the WASM build, which cannot access the OS credential store or filesystem watch APIs).


The .pqf file format

There are ten format versions (v2 through v11, the last gated behind the tlock cargo feature). The version byte at offset 4 selects the layout. Files written by the current version additionally set bit 7 of the version byte (VERSION_AUTH_BIT, so 0x83 = v3 layout with an authenticated header): the chunk-0 key commitment then also binds the header fields that were previously malleable (chunk size, compression algorithm, v10 Argon2id parameters, v11 chain hash/round). Older pqfile versions reject bit-carrying files with UnsupportedVersion; files written by pqfile 4.2.4 and earlier remain fully readable. Two features layer on top of these versions without a version bump: stealth mode (no magic, version, or KEM variant field at all) and Padme plaintext-length padding. Byte-level details for all of this live in docs/FORMAT.md (sections 4.4, 5.10, 5.11, and 5.12).

v2: single-recipient, whole-file AEAD

Offset   Length    Field
------   ------    -----
0        4         Magic: "PQFL"
4        1         Version: 0x02
5        2         KEM variant (u16 little-endian): 768, 1024, or 0x0301 (hybrid)
7        CT_LEN    KEM ciphertext (encapsulated session key)
7+CT     12        ChaCha20-Poly1305 nonce
7+CT+12  8         Original plaintext size (u64 little-endian)
────     N+16      Encrypted payload; header used as AEAD additional data

v3: single-recipient, chunked STREAM

Same header as v2 with version = 0x03. The payload is split into 64 KiB chunks. Each chunk's nonce is base_nonce[8] || counter[4] and its AAD is "pqfile" || counter[4] || is_last[1]. The last-chunk flag prevents truncation; the counter prevents reordering.

v4: multi-recipient, chunked STREAM

Offset   Length    Field
------   ------    -----
0        4         Magic: "PQFL"
4        1         Version: 0x04
5        2         Recipient count N (u16 little-endian)
─── Per recipient (repeated N times) ───────────────────────────────
         2         KEM variant (u16 little-endian)
         CT_LEN    KEM ciphertext for this recipient
         48        AES-256-GCM wrapped session key (32-byte key + 16-byte tag)
─── Shared tail ────────────────────────────────────────────────────
         12        Base nonce (8 random bytes || 4 zero bytes)
         8         Original plaintext size (u64 little-endian)
─── Payload ────────────────────────────────────────────────────────
         …         Chunked STREAM identical to v3, keyed by the session key

A random 32-byte session key K encrypts the payload. Each recipient's ss (from their KEM encapsulation) wraps K under AES-256-GCM(key=ss, nonce=zero). The zero nonce is safe because each ss is unique per encapsulation. Mixed KEM variants within one file are supported.

v5: single-recipient, configurable chunk size

Same header as v3 with version = 0x05, extended by four bytes immediately after the original-size field:

Offset   Length    Field
------   ------    -----
0        4         Magic: "PQFL"
4        1         Version: 0x05
5        2         KEM variant (u16 little-endian)
7        CT_LEN    KEM ciphertext
7+CT     12        ChaCha20-Poly1305 nonce
7+CT+12  8         Original plaintext size (u64 little-endian)
7+CT+20  4         Chunk size (u32 little-endian, 1-268435456 bytes)

Produced when --chunk-size is passed to override the default 64 KiB (or the adaptive default). The chunk size is stored in the header so the decryptor reads it automatically without any extra flag.

v6: single-recipient, compress-then-encrypt

Same header as v5 with version = 0x06, extended by one byte after the chunk-size field:

Offset   Length    Field
------   ------    -----
0        ...       Same as v5 through chunk size
7+CT+24  1         Compression algorithm (0x00 = none, 0x01 = zstd)

Produced when --compress is passed. The plaintext is compressed with zstd before encryption. Decompression is automatic on decrypt after AEAD verification, with output capped at the declared original size to prevent decompression-bomb memory exhaustion. Only supported with a single recipient.

v7: anonymous multi-recipient

Like v4 but all KEM ciphertext slots are padded to 1568 bytes (the ML-KEM-1024 ciphertext length) and recipient entries are written in randomized order.

Offset   Length    Field
------   ------    -----
0        4         Magic: "PQFL"
4        1         Version: 0x07
5        2         Recipient count N (u16 little-endian)
--- Per recipient (repeated N times) ----------------------------
         2         KEM variant (u16 little-endian)
         1568      KEM ciphertext padded to 1568 bytes (trailing bytes are zero)
         48        AES-256-GCM wrapped session key (32-byte key + 16-byte tag)
--- Shared tail -------------------------------------------------
         12        Base nonce (8 random bytes || 4 zero bytes)
         8         Original plaintext size (u64 little-endian)
--- Payload -----------------------------------------------------
         ...       Chunked STREAM identical to v4, keyed by the session key

The decryptor reads 1568 bytes per entry and truncates to the actual ciphertext length for the declared variant before decapsulation. Entries are shuffled before writing so an observer cannot determine recipient count, order, or key types in use.

v8: variant-blind anonymous multi-recipient

Like v7 but the per-slot KEM variant field is removed entirely. All entries are a uniform 1616 bytes (1568 KEM ciphertext + 48 wrapped session key). An observer cannot infer the key type from the ciphertext length. This is the format the GUI's Encrypt tab uses automatically once 2 or more recipients are added.

Offset   Length    Field
------   ------    -----
0        4         Magic: "PQFL"
4        1         Version: 0x08
5        2         Recipient count N (u16 little-endian)
--- Per recipient (repeated N times) ----------------------------
         1568      KEM ciphertext padded to 1568 bytes (no variant field)
         48        AES-256-GCM wrapped session key
--- Shared tail -------------------------------------------------
         12        Base nonce
         8         Original plaintext size (u64 little-endian)
--- Payload -----------------------------------------------------
         ...       Chunked STREAM identical to v4

v9: padded anonymous multi-recipient

Like v8 but the slot count is rounded up to the next power of two (1, 2, 4, 8, ...) by appending random dummy entries. The decryptor tries each slot and skips failures silently. An observer learns only that there are a power-of-two number of slots.

Offset   Length    Field
------   ------    -----
0        4         Magic: "PQFL"
4        1         Version: 0x09
5        2         Padded slot count N (u16 little-endian, next power of two)
--- Per slot (repeated N times; some are random dummy entries) ---
         1568      KEM ciphertext or random bytes
         48        Wrapped session key or random bytes
--- Shared tail -------------------------------------------------
         12        Base nonce
         8         Original plaintext size (u64 little-endian)
--- Payload -----------------------------------------------------
         ...       Chunked STREAM identical to v8

v10: passphrase-only

No KEM step. The 32-byte session key is derived from a passphrase via Argon2id. Parameters are stored in the header (unlike private-key wrapping, which uses fixed parameters) because the recipient did not choose them.

Offset   Length    Field
------   ------    -----
0        4         Magic: "PQFL"
4        1         Version: 0x0A
5        16        Salt (random, for Argon2id)
21       4         M_KIB (u32 little-endian; Argon2id memory in kibibytes)
25       4         T_COST (u32 little-endian; Argon2id time cost)
29       4         P_COST (u32 little-endian; Argon2id parallelism)
33       12        Base nonce (bytes 8-11 are 0x00)
45       8         Original plaintext size (u64 little-endian, informational)
--- Payload -----------------------------------------------------
53       ...       Chunked STREAM identical to v3, keyed by Argon2id output

Security note: M_KIB, T_COST, and P_COST are attacker-controlled fields. Decryptors must cap these before deriving. decrypt_stream_passphrase enforces a default ceiling (64 MiB / t=3); decrypt_stream_passphrase_with_limits accepts caller-specified ceilings. Exceeding the ceiling returns PqfileError::KdfLimitExceeded. The CLI exposes --max-kdf-mem and --max-kdf-time.

v11: time-locked (tlock cargo feature)

No KEM step and no recipient key pair. The session key is derived from a random 16-byte seed that is itself locked via tlock identity-based encryption against a target drand beacon round - not a post-quantum guarantee (the underlying scheme is BLS12-381 pairing-based), but layered on top of the same authenticated, chunked payload as every other format.

Offset   Length    Field
------   ------    -----
0        4         Magic: "PQFL"
4        1         Version: 0x8B (always carries the authenticated-header bit)
5        32        drand chain hash
37       8         Target beacon round (u64 big-endian)
45       4         tlock ciphertext length (u32 little-endian)
49       var       tlock IBE ciphertext (locks the 16-byte seed to the round)
--- Shared tail -------------------------------------------------
         12        Base nonce (bytes 8-11 are 0x00)
         8         Original plaintext size (u64 little-endian, informational)
--- Payload -----------------------------------------------------
         ...       Chunked STREAM identical to v3, keyed by HKDF-SHA256(seed)

Defaults to the League of Entropy mainnet quicknet chain. Off by default: pulls in the only network-capable dependencies in the workspace (tlock, drand_core), so a normal build never gains an HTTP stack. See docs/FORMAT.md §5.12.

KEM variant field

Value Algorithm CT bytes EK bytes
512 ML-KEM-512 768 800
768 ML-KEM-768 1088 1184
1024 ML-KEM-1024 1568 1568
0x0301 Hybrid X25519+ML-KEM-768 1120 1216

See docs/FORMAT.md for the byte-level specification (also exercised as executable documentation by pqfile/tests/vectors.rs).


PEM key formats

ML-KEM-512

-----BEGIN ML-KEM-512 PUBLIC KEY-----          (800 bytes raw)
-----BEGIN ML-KEM-512 PRIVATE KEY-----         (64-byte seed)
-----BEGIN ML-KEM-512 ENCRYPTED PRIVATE KEY--- (16-byte salt || 12-byte nonce || 80-byte AES ciphertext)

ML-KEM-768

-----BEGIN ML-KEM-768 PUBLIC KEY-----          (1184 bytes raw)
-----BEGIN ML-KEM-768 PRIVATE KEY-----         (64-byte seed)
-----BEGIN ML-KEM-768 ENCRYPTED PRIVATE KEY--- (16-byte salt || 12-byte nonce || 80-byte AES ciphertext)

ML-KEM-1024

-----BEGIN ML-KEM-1024 PUBLIC KEY-----         (1568 bytes raw)
-----BEGIN ML-KEM-1024 PRIVATE KEY-----        (64-byte seed)
-----BEGIN ML-KEM-1024 ENCRYPTED PRIVATE KEY-- (same layout as above)

Hybrid X25519+ML-KEM-768

-----BEGIN X25519+ML-KEM-768 PUBLIC KEY-----         (X25519 pubkey 32 || ML-KEM EK 1184 = 1216 bytes)
-----BEGIN X25519+ML-KEM-768 PRIVATE KEY-----        (X25519 scalar 32 || ML-KEM seed 64 = 96 bytes)
-----BEGIN X25519+ML-KEM-768 ENCRYPTED PRIVATE KEY-- (16-byte salt || 12-byte nonce || 112-byte AES ciphertext)

ML-DSA-65 / SLH-DSA-SHAKE-192f (signing only)

-----BEGIN ML-DSA-65 VERIFYING KEY-----             (1952 bytes raw)
-----BEGIN ML-DSA-65 SIGNING KEY-----               (32-byte seed)
-----BEGIN ML-DSA-65 SIGNATURE-----                 (3309 bytes raw)
-----BEGIN SLH-DSA-SHAKE-192F VERIFYING KEY-----    (48 bytes: PK.seed || PK.root)
-----BEGIN SLH-DSA-SHAKE-192F SIGNING KEY-----      (72-byte seed triple: SK.seed || SK.prf || PK.seed)
-----BEGIN SLH-DSA-SHAKE-192F SIGNATURE-----        (35664 bytes raw)

Signing keys can optionally be passphrase-protected (pqfile sign-keygen --passphrase) or hardware-backed (--hardware --label <LABEL>), the same as encryption keys. Without either, protect sign_privkey.pem with filesystem permissions (written 0600 on Unix by default) or disk encryption.

Passphrase-protected private keys derive their AES-256-GCM wrapping key via Argon2id (m=64 MiB, t=3, p=4, 16-byte random salt). The private key stores only the seed (64 bytes for ML-KEM, 96 bytes for hybrid, 32 bytes for ML-DSA-65, 72 bytes for SLH-DSA); the full key is re-derived on load. Keys encrypted with older p=1 parameters (pre-4.0) can be migrated with pqfile repassphrase --from-legacy.

Certificate and revocation list

-----BEGIN PQFILE CERTIFICATE-----                     (self-delimiting body + CA signature)
-----BEGIN PQFILE CERTIFICATE REVOCATION LIST-----      (self-delimiting body + CA signature)

Produced by pqfile issue-cert / pqfile revoke-cert respectively (see Certificates above). Both wrap an ML-DSA-65 or SLH-DSA-SHAKE-192f signature (whichever algorithm the CA signing key uses) over a self-delimiting body - a certificate's body embeds the subject key's own PEM tag and bytes verbatim, so a verified certificate hands back a ready-to-use PEM without the caller needing to know the key type in advance. A revocation list's body is a sequence of (cert_id, revoked_at, reason) entries, where cert_id is SHA3-256 of a certificate's signed body - two certificates issued with byte-identical fields share an identifier.

X25519 sealed-sender identity key

-----BEGIN X25519 IDENTITY PUBLIC KEY-----             (32 bytes raw)
-----BEGIN X25519 IDENTITY PRIVATE KEY-----            (32-byte scalar)
-----BEGIN X25519 IDENTITY ENCRYPTED PRIVATE KEY-----  (16-byte salt || 12-byte nonce || 48-byte AES ciphertext)

Generated by pqfile identity-keygen; consumed only by pqfile seal/pqfile unseal (see Sealed sender above). A separate key pair from every other key type on this page - never used for encryption or signing.

Hardware key reference (stub)

-----BEGIN ML-KEM-768 HARDWARE KEY REFERENCE-----   (version byte || backend ID byte || label, UTF-8)

A small PEM stub identifying which OS credential store backend holds the actual seed and the label used to look it up. The seed itself never touches disk. One reference tag per key kind (ML-KEM-512/768/1024, hybrid, ML-DSA-65 signing, and SLH-DSA-SHAKE-192f signing).

Shamir key share

-----BEGIN ML-KEM-768 KEY SHARE-----   (version || KEM variant || threshold || total || index || 16-byte pubkey fingerprint || share bytes)

Produced by split-key; reconstruct-key consumes threshold or more of these to recover the original private key.


Error handling

All errors are reported to stderr with a descriptive message; exit code is 1. The GUI shows errors inline in red.

Error variant Meaning
Io File system or I/O failure
InvalidMagic File does not start with "PQFL"
UnsupportedVersion Version byte is not a supported value (0x02-0x0A)
UnsupportedKem KEM variant field is not a recognised value
KemVariantMismatch Private key KEM variant does not match the variant in the file header
EncryptionFailure AEAD encryption or nonce generation failed
DecryptionFailure Authentication tag mismatch (file tampered or wrong key)
InvalidPem PEM file could not be parsed or has an unrecognised tag
InvalidKeyLength Decoded key bytes are the wrong length
OutputExists Key file already exists and --force was not passed
WrongPassphrase Passphrase decryption of private key seed failed
PassphraseRequired Encrypted private key loaded but no passphrase supplied
PassphraseMismatch New passphrase and confirmation do not match
InvalidSignature Signature bytes are malformed
SignatureVerificationFailed Signature (ML-DSA-65 or SLH-DSA) does not match the file
NoMatchingRecipient Multi-recipient file: no recipient entry matched the provided private key
KeyRevoked Key has an active .revoked sidecar and was refused for encryption
CompressionNotSupported Compressed (v6) file decrypted by a build without the zstd feature
LegacyKeyFormat Key was encrypted with Argon2id p=1 (pre-4.0); run repassphrase --from-legacy
ShareVerificationFailed Reconstructed Shamir key fingerprint does not match the share fingerprint
Truncated Stream ended without a final authenticated chunk; file was truncated
KdfLimitExceeded v10 file's Argon2 parameters exceed the configured ceiling (memory or time cost)

The #[non_exhaustive] enum may gain new variants in minor releases; match with a wildcard arm. See docs/ERROR_CODES.md for the stable numeric code each variant maps to in --json output.


Testing

cargo test --workspace --all-features

636 tests across all crates. Run benchmarks with:

cargo bench -p pqfile

The async feature's tests and the pqfile/tests/wasm_smoke.rs WASM smoke tests are excluded from a plain cargo test --workspace; use --all-features for the former and wasm-pack test --node pqfile --test wasm_smoke for the latter.

Key integration tests in pqfile/tests/:

Test file What it covers
compat.rs Decrypts golden ciphertext files committed per format version against a known-good plaintext, catching any decryptor regression immediately
property.rs proptest-based property tests for invariants that must hold across all inputs, not just hand-picked cases
static_vectors.rs Pre-generated key/ciphertext constants proving round-trip decoding for every format version and KEM variant
vectors.rs Encrypts a known plaintext and parses the output byte-by-byte against docs/FORMAT.md, acting as executable format documentation
wasm_smoke.rs Encrypt/decrypt roundtrip, keygen, and wrong-key rejection compiled and run on the wasm32 target via wasm-pack test --node

Key integration tests in pqfile-cli/tests/roundtrip.rs:

Test group What it covers
Basic roundtrip keygen → encrypt → decrypt → byte-for-byte match
Custom paths -o flag on encrypt and decrypt
Stdin/stdout full pipe with -
Force overwrite --force behaviour
Inspect header fields displayed correctly, v3 version byte, invalid file
JSON output all commands emit valid JSON; errors go to stderr
Recursive directory encryption, skip .pqf, non-directory error
1024-bit ML-KEM-1024 encrypt/decrypt roundtrip and inspect
ML-DSA sign-keygen, sign, verify, tamper detection, JSON output
SLH-DSA keygen tags/lengths, sign/verify roundtrip, tampering, passphrase, signcrypt, repassphrase, CLI roundtrip
Hybrid X25519+ML-KEM-768 roundtrip, passphrase, inspect, mismatch error
Multi-recipient 2-key v4 roundtrip, 3-key v4, mixed variants, wrong key rejected
Doctor key-file and .pqf-file health checks
Truncation / corruption truncated and bit-flipped ciphertext both correctly rejected
Shell completions bash/zsh/fish/powershell generation, unknown-shell error, subcommand coverage

Every push and pull request also runs cargo clippy, cargo fmt --check, cargo deny check, cargo vet check, and gitleaks. A cargo-mutants mutation-testing job and a cargo fuzz run execute weekly on a schedule (and on manual dispatch) rather than per-push, since both are CPU-intensive (see .github/workflows/).


Dependencies

pqfile (core library)

Crate Version Purpose
ml-kem 0.3 ML-KEM-512/768/1024 key encapsulation (FIPS 203)
ml-dsa 0.1 ML-DSA-65 digital signatures (FIPS 204)
slh-dsa 0.2.0-rc SLH-DSA-SHAKE-192f hash-based signatures (FIPS 205)
chacha20poly1305 0.11 ChaCha20-Poly1305 authenticated encryption
aes-gcm 0.11 AES-256-GCM (passphrase key wrapping, multi-recipient session key wrapping)
x25519-dalek 2 X25519 Diffie-Hellman (hybrid mode)
hkdf 0.13 HKDF-SHA256 key derivation (hybrid mode)
sha2 0.11 SHA-256 (HKDF input)
sha3 0.12 SHA3-256 (FIPS 202) for key fingerprints and key commitment
getrandom 0.4 OS CSPRNG for nonces and key generation
zeroize 1 Overwrite secret bytes on drop
argon2 0.5 Argon2id KDF for passphrase-protected keys
pem 3 PEM encoding/decoding for key files
bech32 0.12 Bech32m encoding/decoding for compact recipient strings (pqf1…)
rayon 1 Parallel chunk processing (--parallel)
thiserror 2 Custom error type derivation
zstd 0.13 Compression for v6 format (--compress)
memmap2 0.9 Memory-mapped I/O for zero-copy encrypt (--mmap, native only)
keyring-core 1 Cross-platform OS credential store abstraction (hardware keys, native only)
windows-native-keyring-store 1 Windows Credential Manager backend (Windows only)
apple-native-keyring-store 1 macOS Keychain backend (macOS only)
linux-keyutils-keyring-store 1 Linux Secret Service backend (Linux only)
tokio 1 Async runtime backing async_io (optional, feature "async")

pqfile-cli (CLI binary, additional to the above)

Crate Version Purpose
clap 4 CLI argument parsing
clap_complete 4 Shell completion script generation
rpassword 7 Secure passphrase prompting

pqfile-gui / pqfile-desktop (shared GUI logic, WASM lib, and native shell)

Crate Version Purpose
eframe 0.35 egui app framework (native rlib + WASM cdylib)
rfd 0.17 Native sync and WASM async file dialogs
image 0.25 PNG decoding for icons and QR codes
qrcode 0.14 QR code generation (Shamir share air-gapped transfer)
notify 8 Filesystem watcher for the Encrypt-tab folder watcher (native only)
winres 0.1 Embeds the app icon/metadata into the Windows binary (pqfile-desktop build-dependency)
wasm-bindgen 0.2 Rust/WASM bindings (WASM only)
wasm-bindgen-futures 0.4 Async bridge for WASM (WASM only)
web-sys 0.3 Browser DOM APIs for file download (WASM only)
js-sys 0.3 JavaScript types for WASM (WASM only)
getrandom 0.4 JS entropy source for WASM crypto (WASM only)
console_error_panic_hook 0.1 Routes Rust panics to the browser console (WASM only)

All dependency versions and licenses are audited via cargo deny check and cargo vet check; see deny.toml and supply-chain/.


Packaging

Every release (vX.Y.Z tag) automatically produces .deb, .rpm, and AppImage packages for Linux, an unsigned .app/DMG for macOS, and a Windows installer - see docs/RELEASING.md. None of these are code-signed or notarized yet: Windows SmartScreen and macOS Gatekeeper will both warn on first launch (macOS: right-click the app → Open once to bypass Gatekeeper). To build any of them locally instead of waiting for a release:

Debian / Ubuntu

cargo install cargo-deb
cargo deb -p pqfile-cli

Produces a .deb package installing the binary to /usr/bin/pqfile.

Fedora / RHEL

cargo install cargo-generate-rpm
cargo build --release -p pqfile-cli
cargo generate-rpm -p pqfile-cli

Pure Rust, no rpmbuild/rpm-build package needed. A traditional spec-file build also works if you prefer it: cp target/release/pqfile ~/rpmbuild/BUILD/ && rpmbuild -bb pqfile-cli/packaging/pqfile.spec (uses pqfile-cli/packaging/pqfile.spec, kept in sync with the crate version by scripts/bump-version.ps1).

Linux AppImage

Requires linuxdeploy and its appimage plugin on PATH, plus squashfs-tools:

cargo build --release -p pqfile-desktop
linuxdeploy --appdir AppDir \
  --executable target/release/pqfile-desktop \
  --desktop-file pqfile-desktop/packaging/pqfile-desktop.desktop \
  --icon-file pqfile-desktop/assets/icon.png \
  --output appimage

macOS

cargo build --release -p pqfile-desktop
mkdir -p pqfile.app/Contents/MacOS pqfile.app/Contents/Resources
cp target/release/pqfile-desktop pqfile.app/Contents/MacOS/
sed "s/@VERSION@/$(grep -m1 -oP '(?<=^version = ")[^"]+' pqfile-desktop/Cargo.toml)/" \
  pqfile-desktop/packaging/Info.plist.template > pqfile.app/Contents/Info.plist
# icon.icns: see the release workflow's "Build .app bundle and DMG" step for
# the sips/iconutil commands that generate one from pqfile-desktop/assets/icon.png
brew install create-dmg
create-dmg --no-code-sign pqfile.dmg pqfile.app/

Windows

Requires Inno Setup and iscc on PATH:

cargo build --release -p pqfile-desktop
iscc pqfile-desktop\packaging\setup.iss

Security considerations

  • Private keys must be kept confidential. Anyone with privkey.pem (or access to a hardware-backed key's OS credential store entry) can decrypt any file encrypted to the corresponding public key.
  • Public keys can be shared freely.
  • Each encryption is independent. A fresh KEM ciphertext, fresh ephemeral X25519 scalar (hybrid mode), and fresh nonce are generated per file using the OS CSPRNG. Nonce reuse under the same symmetric key is structurally impossible.
  • The entire file is authenticated. For v2, the full header is passed as AEAD additional data, so any header or payload modification fails decryption. For chunked formats (v3 onward), each chunk carries its own AEAD tag plus a position-binding counter and last-chunk flag, so truncation, reordering, and payload swapping are all detected.
  • Whole-file and async decrypt/encrypt paths are memory-bounded. The v2 (whole-file) decrypt path and the optional async feature's encrypt/decrypt functions cap their internal buffering so a stream with an unbounded or oversized tail cannot force unbounded memory allocation.
  • Secret material is zeroized on drop. The decapsulation key seed, shared secrets, session keys, Shamir shares, and passphrase-derived keys are wrapped in Zeroizing<T> from the zeroize crate. x25519-dalek, ml-kem, and ml-dsa are compiled with their zeroize features enabled.
  • Private key and Shamir share files are written with owner-only permissions (0600) on Unix. Hardware-backed keys never touch disk at all; the seed lives only in the OS credential store, accessed via its byte-native secret API.
  • Multi-recipient security. In v4/v7/v8/v9 formats, the file payload is encrypted with a single random 32-byte session key. Each recipient's copy of that key is wrapped under their KEM shared secret using AES-256-GCM (zero nonce; safe because the KEM shared secret is fresh and unique per encapsulation). A recipient with a non-matching key cannot distinguish a file addressed to them from one addressed to others; v9 additionally hides the true recipient count behind power-of-two padding.
  • Hybrid mode security. The combined session key is HKDF-SHA256(X25519_ss || ML-KEM_ss, info="pqfile-hybrid-v1"). Security holds if either X25519 or ML-KEM is unbroken, not both.
  • Signing keys can optionally be passphrase-protected or hardware-backed, the same as encryption keys. Without either, protect sign_privkey.pem with filesystem permissions (0600 by default) or disk encryption. Compromise of the signing key allows forged signatures but does not affect encryption key confidentiality.
  • The web GUI operates entirely in WebAssembly inside the browser. No file data or key material is transmitted over the network, in either the CLI, the desktop GUI, or the WASM web build.
  • Fingerprints are informational. SHA3-256(pubkey)[0:8] gives 64 bits. Suitable for display and manual comparison; not a cryptographic commitment. Always verify keys through a trusted channel.

See docs/SECURITY.md for the full threat model, security invariants, and the responsible-disclosure process for reporting a vulnerability.