pqfile 4.2.2

Quantum-resistant file encryption: ML-KEM (512/768/1024), hybrid X25519+ML-KEM-768, ML-DSA-65 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
# 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.

**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). 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)        | 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, 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 v9 formats)
│   │   ├── decrypt.rs      Hybrid decryption pipeline (v2 through v9 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
│   │   ├── writer.rs       PqfWriter<W: Write> streaming encryptor type
│   │   ├── async_io.rs     Async encrypt/decrypt/PqfWriter (tokio, feature "async")
│   │   ├── 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)
# 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
```

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.

### 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
cat secret.txt.pqf | pqfile decrypt -k privkey.pem - -o -
```

If the private key is passphrase-protected, the passphrase is prompted interactively. Works with v2 through v9 files (all single-recipient variants) and v4/v7/v8/v9 (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.

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

### 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 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 v9 `.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 eight format versions (v2 through v9). 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.

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

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

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

---

## 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 (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 can be passphrase-protected: `pqfile sign-keygen --passphrase`. Use `pqfile sign -k sign_privkey.pem` and the passphrase is prompted interactively. Without passphrase protection, 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=4, 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. Keys encrypted with older p=1 parameters (pre-4.0) can be migrated with `pqfile repassphrase --from-legacy`.

---

## 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-0x09)                         |
| `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              |
| `KemVariantMismatch`   | Private key KEM variant does not match the variant in the file header     |
| `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      |

---

## Testing

```
cargo test --workspace
```

365 tests across all crates (236 unit + 58 integration + 32 GUI + 19 doc-tests). Run benchmarks with:

```
cargo bench -p pqfile
```

Key integration tests in `pqfile/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 |
| 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)

| Crate            | Version | Purpose                                                       |
|------------------|---------|---------------------------------------------------------------|
| 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.13    | HKDF-SHA256 key derivation (hybrid mode)                      |
| sha2             | 0.11    | 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 and key commitment   |
| rayon            | 1       | Parallel chunk processing (`--parallel`)                      |
| memmap2          | 0.9     | Memory-mapped I/O for zero-copy encrypt (`--mmap`, native only)|
| 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)

| Crate                    | Version | Purpose                                              |
|--------------------------|---------|------------------------------------------------------|
| 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.