Skip to content

Latest commit

 

History

History
189 lines (157 loc) · 11 KB

CONFORMANCE.md

File metadata and controls

189 lines (157 loc) · 11 KB

PostQuantum.FileEncryption Conformance Specification — .pqfe v2

Audience: anyone implementing a .pqfe reader, 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.


1. Producer obligations (writer)

A conforming writer:

  1. Emits a Magic of exactly 0x50 0x51 0x46 0x45 ("PQFE") at offset 0.
  2. Emits FormatVersion = 2.
  3. Uses one of the algorithm IDs defined in FILE-FORMAT.md. At v2 that is AeadId = 1 (AES-256-GCM) only.
  4. Generates NoncePrefix (4 bytes) and any salt material from a cryptographically secure random source. MUST never reuse a NoncePrefix across two files encrypted under the same key.
  5. Chooses ChunkSize in the range [1024, 16 * 1024 * 1024] bytes. The same ChunkSize applies to every non-final frame in the file. The final frame may be shorter.
  6. For each plaintext chunk i (0-indexed), constructs the 12-byte AEAD nonce as NoncePrefix || BE_UInt64(i).
  7. 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.
  8. Marks exactly one frame as the final frame. Producing a file with no final-marked frame is non-conforming.
  9. Writes the KeyParams block exactly as specified for the chosen KeySource. The block's own length is captured in KeyParamsLength (uint16, big-endian).
  10. Writes no trailing bytes after the final frame.

A writer MAY:

  • Use any conforming KeySource that the platform supports.
  • Choose Argon2id memory/iteration parameters within the published bounds; those parameters travel in the KeyParams block so decryption does not require them out-of-band.

2. Consumer obligations (reader)

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.

2.1 Strict reader requirements (MUST)

A conforming reader:

  1. Rejects any input whose first four bytes are not PQFE with a format error.
  2. Rejects any input with FormatVersion != 2.
  3. Rejects any AeadId or KeySource value not defined in this spec at the time of the read.
  4. Rejects ChunkSize outside [1024, 16 * 1024 * 1024] bytes.
  5. 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.
  6. 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.
  7. 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.)
  8. Detects splicing: frame i used in position j ≠ i MUST fail authentication because the ordinal is in the AAD.
  9. 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 = 1 containers. The reference implementation has done so since 0.2.0.

2.2 Frozen v2 reference-reader compatibility profile

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.

  1. Reserved Flags byte. 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 nonzero Flags; a 1.x-conformant reader MUST NOT reject a container solely for this reason.
  2. 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.
  3. 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.
  4. Trailing content in the multi-recipient body. The KeySource = 4 parser 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.

3. Test-vector checklist

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 implement KeySource = 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), and neg-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 — corpus neg-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, and neg-salt-too-short
  • Unknown AeadId, KeySource, or FormatVersion — corpus neg-unknown-aead, neg-unknown-keysource, neg-bad-version (and neg-bad-magic, neg-not-a-container, neg-chunksize-zero)

4. Versioning beyond v2

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 KeySource with new KeyParams is non-breaking at the format level if existing readers continue to reject unknown KeySource values (which they MUST). It is shipped as a minor library version (1.x).
  • Renaming or renumbering an existing KeySource is a breaking change.

To God be the glory — 1 Corinthians 10:31.