Audience: anyone implementing a
.pqfereader, writer, or both. This document is the contract another implementation must meet to claim byte-compatibility with the reference .NET library and the reference Rust → WebAssembly implementation (samples/pqfe-wasm/).
.pqfe v2 is frozen for 1.x. The wire layout, the algorithm choices, and the rejection
behavior described here will not change in any 1.x release. New KeySource values may be
added additively in later 1.x minors; existing readers MUST treat unknown KeySource values
as a format error and refuse the file.
The normative wire description is FILE-FORMAT.md. This document supplements it with the conformance obligations an implementer must satisfy and the test-vector checklist that proves they did.
A conforming writer:
- Emits a
Magicof exactly0x50 0x51 0x46 0x45("PQFE") at offset 0. - Emits
FormatVersion = 2. - Uses one of the algorithm IDs defined in FILE-FORMAT.md. At v2
that is
AeadId = 1(AES-256-GCM) only. - Generates
NoncePrefix(4 bytes) and any salt material from a cryptographically secure random source. MUST never reuse aNoncePrefixacross two files encrypted under the same key. - Chooses
ChunkSizein the range[1024, 16 * 1024 * 1024]bytes. The sameChunkSizeapplies to every non-final frame in the file. The final frame may be shorter. - For each plaintext chunk i (0-indexed), constructs the 12-byte AEAD nonce as
NoncePrefix || BE_UInt64(i). - Computes the AAD per frame as defined in FILE-FORMAT.md — it MUST bind the frame's ordinal index and a final-chunk marker. Tampering with either value MUST cause the frame to fail authentication.
- Marks exactly one frame as the final frame. Producing a file with no final-marked frame is non-conforming.
- Writes the
KeyParamsblock exactly as specified for the chosenKeySource. The block's own length is captured inKeyParamsLength(uint16, big-endian). - Writes no trailing bytes after the final frame.
A writer MAY:
- Use any conforming
KeySourcethat the platform supports. - Choose Argon2id memory/iteration parameters within the published bounds; those parameters
travel in the
KeyParamsblock so decryption does not require them out-of-band.
Reader conformance has two layers. Section 2.1 is the set of security-critical checks
every conforming reader MUST perform — the ideal, strict reader. Section 2.2 is the
frozen v2 reference-reader compatibility profile: a handful of places where the reference
.NET and Rust readers are deliberately lenient, documented so that a strict implementation
knows exactly where it may differ without breaking byte-compatibility. These leniencies are
frozen for the 1.x line; a 2.0 reader profile may tighten them (see
KNOWN-GAPS.md format-v3 candidates), but a 1.x-conformant reader MUST
NOT reject a file solely because the reference reader accepts it — doing so desyncs the
implementations.
A conforming reader:
- Rejects any input whose first four bytes are not
PQFEwith a format error. - Rejects any input with
FormatVersion != 2. - Rejects any
AeadIdorKeySourcevalue not defined in this spec at the time of the read. - Rejects
ChunkSizeoutside[1024, 16 * 1024 * 1024]bytes. - Range-checks every cost parameter read from
KeyParams(PBKDF2 iteration count, Argon2id memory/iterations/parallelism) against the published bounds and rejects out-of-range values before doing any KDF work. This prevents a hostile header from forcing unbounded memory or CPU. - Reconstructs each per-frame nonce as
NoncePrefix || BE_UInt64(i)and verifies each frame's authentication tag. A single failed tag MUST abort the operation, and the operation as a whole MUST fail closed — no plaintext recovered from a container that fails authentication anywhere may be presented as a successful decryption.- The all-or-nothing (file / in-memory / atomic) APIs guarantee that no plaintext is published on any failure: the reference implementation stages output and only publishes after the final frame authenticates.
- Stream APIs authenticate each chunk before emitting it, but a stream that has already been written cannot be un-written; if truncation is only discovered at the missing final frame, earlier — authentic — chunks may already have reached the sink. Such a reader still MUST fail the operation closed and MUST NOT emit any unauthenticated byte. Callers needing strict atomicity over a stream must buffer. See KNOWN-GAPS.md.
- Detects truncation: a container that ends without a final-marked frame MUST be rejected as a decryption error, not a format error. (The error type SHOULD be consistent across wrong-key / tampered / truncated outcomes, so the library does not become a decryption oracle.)
- Detects splicing: frame i used in position j ≠ i MUST fail authentication because the ordinal is in the AAD.
- Detects header tampering: any byte of the header is in the per-frame AAD; a single bit flip in the header MUST cause every frame to fail authentication.
A reader MAY:
- Refuse
FormatVersion = 1containers. The reference implementation has done so since0.2.0.
The reference readers tolerate the following. Each is harmless under v2 because the entire
header is bound as per-frame AAD, so none of these bytes can be modified after encryption
without breaking authentication — but each means a non-conforming writer's container can
still decrypt. They are frozen for 1.x; tightening any would change what working files are
accepted (and desync the Rust/WASM reader), so all are recorded as format-v3 candidates in
KNOWN-GAPS.md, not 1.x fixes.
- Reserved
Flagsbyte. Offset 7 is specified "must be 0", but the reference reader does not reject a nonzero value. It is bound into the AAD, so it cannot be altered after encryption. A strict reader MAY reject nonzeroFlags; a1.x-conformant reader MUST NOT reject a container solely for this reason. - Trailing bytes after the final frame. Decryption stops at the authenticated final frame
(
FILE-FORMAT.md, rule 5); bytes appended past it are ignored, not rejected. Everything inside the envelope remains fully authenticated. - Trailing bytes inside passphrase
KeyParams. The passphrase (KeySource = 1) KeyParams parser tolerates extra trailing bytes, where the inline recipient parser (KeySource = 2) and the single-recipient hybrid block (KeySource = 3) enforce exact length. - Trailing content in the multi-recipient body. The
KeySource = 4parser consumes exactly its declared recipient-block count and does not verify the body ends there, so extra blocks or trailing bytes past the count are ignored.
To claim conformance, an implementation MUST decrypt every vector listed in
TEST-VECTORS.md and recover the documented plaintext byte-for-byte. The
reference implementations are cross-checked: the .NET test suite includes
CrossImplementationTests.cs which decrypts a Rust-produced container, and the Rust suite
decrypts the .NET-produced known-answer vectors. Adding a third implementation requires
running both directions.
The positive, negative, and lenient cases are also committed as a machine-readable corpus at
test-vectors/manifest.json: every vector with its SHA-256 and
the required outcome (accept / reject-format / reject-decryption), including one accept
vector for each frozen leniency in §2.2 above. The reference implementations run it as
ConformanceManifestTests (.NET) and tests/conformance.rs (Rust); a new implementation proves
conformance by producing the same outcome for every entry.
Coverage table:
KeySource |
Path | Vector location |
|---|---|---|
1 |
Passphrase (PBKDF2-HMAC-SHA256) | tests/.../KnownAnswerVectorTests.cs |
1 |
Passphrase (Argon2id) | tests/.../KnownAnswerVectorTests.cs |
2 |
ML-KEM-768 recipient (experimental) | tests/.../RecipientTests.cs (platform-gated) |
3 |
X25519 + ML-KEM-768 hybrid combiner | tests/.../HybridTests.cs |
4 |
Multiple recipients | tests/.../HybridTests.cs |
5 |
Envelope key provider (LocalKekContentKeyProvider) |
tests/.../KeyProviderTests.cs |
Negative vectors (every implementation MUST reject these the same way). Most are committed as
machine-readable corpus vectors (test-vectors/manifest.json), run by both implementations:
- Wrong passphrase — corpus
neg-wrong-passphrase - Wrong recipient private key — per-implementation unit tests (
HybridTests; randomized keys, so not corpus-pinnable) - Wrong content-key provider — .NET unit tests (
KeyProviderTests; the Rust core does not implementKeySource = 5) - A single bit flip in the header — corpus
neg-header-tamper - A single bit flip in any frame's ciphertext or tag — corpus
neg-ciphertext-tamper - Truncation — corpus
neg-tag-truncated,neg-prefix-truncated,neg-truncated-at-frame-boundary(clean cut, zero frames), andneg-final-frame-dropped(clean cut after an authentic non-final frame) - Splicing: swap frame i with frame j — corpus
neg-frame-swap; transplanting an authentic frame from a different container — corpusneg-cross-container-transplant - Out-of-range KDF parameters in the header — corpus
neg-pbkdf2-iterations-out-of-range,neg-argon2-memory-out-of-range,neg-argon2-iterations-out-of-range,neg-argon2-parallelism-zero, andneg-salt-too-short - Unknown
AeadId,KeySource, orFormatVersion— corpusneg-unknown-aead,neg-unknown-keysource,neg-bad-version(andneg-bad-magic,neg-not-a-container,neg-chunksize-zero)
The FormatVersion byte gives readers a discriminator. The library's policy:
- A change that breaks byte-compatibility for existing readers requires a new
FormatVersion(3,4, …) and a new major library version (2.0,3.0, …). - Adding a new
KeySourcewith newKeyParamsis non-breaking at the format level if existing readers continue to reject unknownKeySourcevalues (which they MUST). It is shipped as a minor library version (1.x). - Renaming or renumbering an existing
KeySourceis a breaking change.
To God be the glory — 1 Corinthians 10:31.