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
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
<p align="center">
  <img src="pqfile-gui/icon.png" alt="pqfile logo" width="112" height="112">
</p>

<h1 align="center">pqfile: Post-Quantum File Encryption</h1>

<p align="center">
  <a href="https://github.com/dangel34/PQ-File-Encryption/actions/workflows/ci.yml"><img src="https://github.com/dangel34/PQ-File-Encryption/actions/workflows/ci.yml/badge.svg" alt="CI status"></a>
  <a href="https://crates.io/crates/pqfile"><img src="https://img.shields.io/crates/v/pqfile.svg" alt="crates.io version"></a>
  <a href="https://docs.rs/pqfile"><img src="https://img.shields.io/docsrs/pqfile" alt="docs.rs"></a>
  <a href="https://crates.io/crates/pqfile"><img src="https://img.shields.io/crates/d/pqfile.svg" alt="crates.io downloads"></a>
  <a href="https://github.com/dangel34/PQ-File-Encryption/releases/latest"><img src="https://img.shields.io/github/v/release/dangel34/PQ-File-Encryption" alt="latest release"></a>
  <a href="LICENSE"><img src="https://img.shields.io/crates/l/pqfile.svg" alt="license"></a>
</p>

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](docs/QUICKSTART.md)**: build, install, common CLI commands, GUI overview, deploying.

**Service status**: [status.nappi.work/status/pqfile](https://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

```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

# 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`.

```bash
# 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

```bash
# 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

```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

# 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

```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

# 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](https://eprint.iacr.org/2023/189) 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).

```bash
# 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](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).

```bash
# 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)

```bash
# 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

```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
```

### Change a key's passphrase

```bash
# 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

```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
```

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](docs/FORMAT.md) for what the bit authenticates. Stealth-mode files have no header and cannot be inspected.

### 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

# 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.

```bash
# 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:

```bash
# 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

```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).

### Sealed sender

```bash
# 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

```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

# 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)

```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.

### Diagnostics

```bash
# 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:

```toml
# 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

```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","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](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](https://eprint.iacr.org/2023/189) 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](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](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](#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](#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](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](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](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](https://github.com/linuxdeploy/linuxdeploy) and its [appimage plugin](https://github.com/linuxdeploy/linuxdeploy-plugin-appimage) 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](https://jrsoftware.org/isinfo.php) 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](docs/SECURITY.md) for the full threat model, security invariants, and the responsible-disclosure process for reporting a vulnerability.