# pqfile - Post-Quantum File Encryption
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. Three key types are supported: 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.
Digital signatures use ML-DSA-65 (NIST FIPS 204).
**[docs/QUICKSTART.md](docs/QUICKSTART.md)**: build, install, common CLI commands, GUI overview, deploying.
---
## 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). 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
| 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) | AES-256-GCM |
| Randomness | OS CSPRNG via `getrandom` |
| Key derivation (passphrase) | Argon2id (m=64 MiB, t=3, p=1) |
| Key wrapping (passphrase) | AES-256-GCM |
| Digital signatures | ML-DSA-65, NIST FIPS 204 |
| Key fingerprints | SHA3-256 (first 8 bytes, colon-separated hex) |
---
## Project structure
```
PQ-File-Encryption/
├── Cargo.toml Workspace manifest
├── fuzz/ cargo-fuzz targets (excluded from 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
├── pqfile/ Core crypto library
│ ├── src/
│ │ ├── lib.rs Public library re-exports
│ │ ├── keygen.rs Key pair generation and PEM serialization
│ │ ├── encrypt.rs Hybrid encryption pipeline (v2 through v7 formats)
│ │ ├── decrypt.rs Hybrid decryption pipeline (v2 through v7 auto-detect)
│ │ ├── format.rs .pqf binary file format definitions
│ │ ├── passphrase.rs Argon2id wrapping for passphrase-protected keys
│ │ ├── sign.rs ML-DSA-65 signing and verification
│ │ ├── signcrypt.rs Combined sign-then-encrypt and signdecrypt
│ │ ├── reader.rs PqfReader<R: Read> streaming decryptor type
│ │ ├── archive.rs Encrypted multi-file archive (PQFA format)
│ │ ├── rekey.rs Re-encryption without payload decryption
│ │ ├── revoke.rs Key revocation sidecar (.revoked) support
│ │ ├── shamir.rs Shamir secret sharing for M-of-N key splitting
│ │ └── error.rs PqfileError enum
│ └── 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)
│ └── tests/
│ └── roundtrip.rs End-to-end CLI integration tests
├── pqfile-gui/ Shared GUI logic + WASM web app
│ ├── index.html Canvas page for trunk/WASM builds
│ └── src/
│ ├── lib.rs Entry point, WASM start fn, tests
│ ├── app.rs PqfileApp struct and frame impl
│ ├── colors.rs Catppuccin palette constants
│ ├── theme.rs egui theme application
│ ├── types.rs Shared types (Tab, FileInput, Settings…)
│ ├── widgets.rs UI helper functions
│ └── tabs/
│ ├── keygen.rs, encrypt.rs, decrypt.rs, inspect.rs, settings.rs, keys.rs
└── pqfile-desktop/ Native desktop binary
└── src/
└── main.rs Native entry point (~18 lines)
```
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
### Key generation
```bash
# 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
```
Key files written: `pubkey.pem` (share freely) and `privkey.pem` (keep secret). The fingerprint (SHA3-256, first 8 bytes) is printed at generation time.
### Encryption
```bash
# 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
# Custom chunk size in bytes (single recipient, produces v5 format)
pqfile encrypt -r pubkey.pem --chunk-size 131072 large_file.bin
# Hide recipient count and key types (multiple recipients required, produces v7 format)
pqfile encrypt -r alice/pubkey.pem -r bob/pubkey.pem --anonymous-recipients secret.txt
# Read from stdin, write to stdout
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 v7 format, padding all ciphertexts to a uniform size and randomizing their order. `--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.
### Decryption
```bash
# 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
# Stdin/stdout pipeline
If the private key is passphrase-protected, the passphrase is prompted interactively. Works with v2 through v7 files (all single-recipient variants) and v4/v7 (multi-recipient).
### Rekey
```bash
# 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
```bash
# 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
```
### Inspect
```bash
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
```
### Digital signatures
```bash
# 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
```
Signatures are ML-DSA-65 (NIST FIPS 204), 3309 bytes, stored in PEM format. The verifying key (1952 bytes) can be distributed alongside the signed content.
### Signcrypt
```bash
# 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).
### Archive and extract
```bash
# 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
# 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.
### Threshold key splitting (Shamir)
```bash
# 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.
### Shell completions
```bash
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:
```bash
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","message":"..."}`. Exit code is always 1 on error.
---
## GUI
The desktop GUI (`pqfile-desktop`) and web app (`pqfile-gui`) share the same egui code and support:
- **Keygen tab**: generates key pairs (ML-KEM-768, ML-KEM-1024, or Hybrid), with optional passphrase protection
- **Encrypt tab**: multi-recipient list, multi-file batch encrypt with per-file status and progress bar
- **Decrypt tab**: loads any v2 through v7 `.pqf` file; shows passphrase field only when needed
- **Inspect tab**: displays header metadata for v2/v3/v4 `.pqf` files without decrypting
- **Keys tab**: persistent key-pair registry with fingerprints and quick-load buttons
- **Settings tab**: theme, auto-clear, confirm-overwrite preferences
> The GUI keygen supports ML-KEM-512, ML-KEM-768, ML-KEM-1024, and Hybrid. Use the CLI for multi-recipient encryption.
---
## The .pqf file format
There are four format versions. The version byte at offset 4 selects the layout.
### 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. 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. 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.
### KEM variant field
| `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 |
---
## PEM key formats
### ML-KEM-512
```
-----BEGIN ML-KEM-512 PUBLIC KEY----- (800 bytes raw)
-----BEGIN ML-KEM-512 PRIVATE KEY----- (64-byte seed)
### ML-KEM-768
```
-----BEGIN ML-KEM-768 PUBLIC KEY----- (1184 bytes raw)
-----BEGIN ML-KEM-768 PRIVATE KEY----- (64-byte seed)
### 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 ENCRYPTED PRIVATE KEY-- (16-byte salt || 12-byte nonce || 112-byte AES ciphertext)
```
### ML-DSA-65 (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)
```
Signing keys are not passphrase-protected. Protect `sign_privkey.pem` with filesystem permissions or store it on encrypted storage.
Passphrase-protected private keys derive their AES-256-GCM wrapping key via Argon2id (m=64 MiB, t=3, p=1, 16-byte random salt). The private key stores only the seed (64 bytes for ML-KEM, 96 bytes for hybrid); the full decapsulation key is re-derived on load.
---
## Error handling
All errors are reported to stderr with a descriptive message; exit code is 1. The GUI shows errors inline in red.
| `Io` | File system or I/O failure |
| `InvalidMagic` | File does not start with "PQFL" |
| `UnsupportedVersion` | Version byte is not a supported value (0x02-0x07) |
| `UnsupportedKem` | KEM variant field is not a recognised value |
| `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` | ML-DSA-65 signature does not match the file |
| `NoMatchingRecipient` | v4 file: no recipient entry matched the provided private key |
---
## Testing
```
cargo test --workspace
```
216 tests across all crates (149 unit + 45 integration + 22 GUI). Run benchmarks with:
```
cargo bench -p pqfile
```
Key integration tests in `pqfile/tests/roundtrip.rs`:
| 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 |
| Hybrid | X25519+ML-KEM-768 roundtrip, passphrase, inspect, mismatch error |
| Multi-recipient | 2-key v4 roundtrip, 3-key v4, mixed variants, wrong key rejected |
---
## Dependencies
### pqfile (CLI and library)
| ml-kem | 0.3 | ML-KEM-768/1024 key encapsulation (FIPS 203) |
| ml-dsa | 0.1 | ML-DSA-65 digital signatures (FIPS 204) |
| chacha20poly1305 | 0.10 | ChaCha20-Poly1305 authenticated encryption |
| aes-gcm | 0.10 | AES-256-GCM (passphrase key wrapping, v4 session key wrapping)|
| x25519-dalek | 2 | X25519 Diffie-Hellman (hybrid mode) |
| hkdf | 0.12 | HKDF-SHA256 key derivation (hybrid mode) |
| sha2 | 0.10 | SHA-256 (HKDF input) |
| 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 |
| sha3 | 0.12 | SHA3-256 (FIPS 202) for key fingerprints |
| clap | 4 | CLI argument parsing |
| clap_complete | 4 | Shell completion script generation |
| thiserror | 2 | Custom error type derivation |
| rpassword | 7 | Secure passphrase prompting |
### pqfile-gui (shared GUI logic and WASM lib)
| eframe | 0.34 | egui app framework (native rlib + WASM cdylib) |
| rfd | 0.17 | Native sync and WASM async file dialogs |
| 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)|
---
## Packaging
### Debian / Ubuntu
```
cargo install cargo-deb
cargo deb -p pqfile
```
Produces a `.deb` package installing the binary to `/usr/bin/pqfile`.
### Fedora / RHEL
```
cargo build --release -p pqfile
cp target/release/pqfile ~/rpmbuild/BUILD/
rpmbuild -bb pqfile/packaging/pqfile.spec
```
---
## Security considerations
- **Private keys must be kept confidential.** Anyone with `privkey.pem` 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. Nonce reuse under the same symmetric key is structurally impossible.
- **The entire file is authenticated.** For v2, the 1115-byte header is AEAD additional data so any header or payload modification fails decryption. For v3/v4, each 64 KiB chunk carries its own AEAD tag plus a position-binding counter so truncation, reordering, and payload swapping are all detected.
- **Secret material is zeroized on drop.** The decapsulation key seed, shared secrets, session keys, and passphrase-derived keys are wrapped in `Zeroizing<T>` from the `zeroize` crate. `x25519-dalek` and `ml-kem` are compiled with their `zeroize` features enabled.
- **Multi-recipient security.** In v4 format, 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.
- **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 are not passphrase-protected.** Protect `sign_privkey.pem` with filesystem permissions 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.
- **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.