From a8f68e68720629ae7ace10a92c87e215057f6803 Mon Sep 17 00:00:00 2001 From: Paul Clark Date: Sat, 5 Sep 2026 08:49:30 -0400 Subject: [PATCH] feat: PqContainerInfo container inspection and pqfe inspect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The last major product ask from both external reviews: a supported way to answer "what would decrypting this cost, and which key do I need?" without hand-parsing the frozen header. PqContainerInfo.Read/TryRead/ReadFileAsync report the declared key source (new PqKeySource enum), KDF and work factors, chunk size, recipient count, control-character-sanitized provider id, and an exact plaintext-size upper bound (the same bound MaxPlaintextBytes enforces) — with zero key derivation or decryption, so it is safe on untrusted input and is the natural primitive for application policy gates. Structural acceptance mirrors the frozen reader exactly, pinned by a conformance-corpus consistency test across all 28 vectors (reject-format vectors must fail inspection; everything else must inspect and agree on the key source). Every value documented as UNAUTHENTICATED until a decryption succeeds. pqfe inspect [--json]: human summary with the unauthenticated warning, or a tiny stable hand-built JSON schema (NativeAOT-safe, no reflection serializer) for scripts and policy gates. Freeze-safe: a new read-only API; no byte meaning or reader acceptance changes. 330x2 tests green (7 new); CLI smoke-tested on hybrid multi- recipient, Argon2id, provider, and garbage inputs. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Sym7RJ7ehNhbMXytE5rMmS --- CHANGELOG.md | 10 + samples/Pqfe.Cli/Program.cs | 92 ++++++ samples/Pqfe.Cli/README.md | 4 + .../Internal/PqContainer.cs | 2 +- .../PqContainerInfo.cs | 294 ++++++++++++++++++ .../PublicAPI.Unshipped.txt | 22 ++ .../PqContainerInfoTests.cs | 156 ++++++++++ 7 files changed, 579 insertions(+), 1 deletion(-) create mode 100644 src/PostQuantum.FileEncryption/PqContainerInfo.cs create mode 100644 tests/PostQuantum.FileEncryption.Tests/PqContainerInfoTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index da50168..f13dac1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,16 @@ and the `.pqfe` v2 container format is frozen for the entire `1.x` line. ### Added +- **`PqContainerInfo` — supported container inspection.** `Read` / `TryRead` / `ReadFileAsync` + report what a header declares (key source, KDF and its work factors, chunk size, recipient + count, sanitized provider id, and an exact plaintext-size upper bound) **without deriving + keys or decrypting anything** — the primitive for application policy ("hybrid recipients + only", "nothing over 100 MB") that both external reviews asked for, so no operator ever + hand-parses the frozen header. Structural acceptance mirrors the real reader exactly, + pinned by a conformance-corpus consistency test; every value is documented as + unauthenticated until a decryption succeeds. `pqfe inspect [--json]` exposes it from + the shipped tool (hand-built JSON keeps the CLI NativeAOT-safe). + - **`PqDecryptionLimits.MaxPlaintextBytes`** — a total-plaintext ceiling enforced *before any key derivation or decryption work* for every input whose length is known (file, bytes, atomic, seekable streams): the exact plaintext total is derived from the container length diff --git a/samples/Pqfe.Cli/Program.cs b/samples/Pqfe.Cli/Program.cs index 048e0a9..47b10b3 100644 --- a/samples/Pqfe.Cli/Program.cs +++ b/samples/Pqfe.Cli/Program.cs @@ -71,6 +71,7 @@ private static async Task Main(string[] args) "decrypt" => await DecryptAsync(rest, cts.Token).ConfigureAwait(false), "keygen" => KeyGen(rest, cts.Token), "recipient" => await RecipientAsync(rest, cts.Token).ConfigureAwait(false), + "inspect" => await InspectAsync(rest, cts.Token).ConfigureAwait(false), "sign" => await SignAsync(rest, cts.Token).ConfigureAwait(false), "verify" => await VerifyAsync(rest, cts.Token).ConfigureAwait(false), _ => Fail($"unknown command: {args[0]}", ExitUsage), @@ -520,6 +521,90 @@ private static async Task RecipientFingerprintAsync(string[] rest, Cancella return ExitOk; } + // ------------------------------------------------------------------ inspection + + private static async Task InspectAsync(string[] rest, CancellationToken cancellationToken) + { + bool json = false; + string? path = null; + foreach (string a in rest) + { + if (a == "--json") { json = true; } + else if (a.StartsWith('-') || path is not null) + { + return Fail("usage: pqfe inspect [--json]", ExitUsage); + } + else { path = a; } + } + if (path is null) return Fail("usage: pqfe inspect [--json]", ExitUsage); + + PqContainerInfo info; + try + { + info = await PqContainerInfo.ReadFileAsync(path, cancellationToken).ConfigureAwait(false); + } + catch (PqFormatException ex) + { + return Fail($"'{path}': {ex.Message}", ExitDataErr); + } + + if (json) + { + // Hand-built JSON keeps the CLI NativeAOT-safe (no reflection serializer) and the + // schema deliberately tiny and stable. + var sb = new StringBuilder(256); + sb.Append("{\n"); + sb.Append($" \"formatVersion\": {info.FormatVersion},\n"); + sb.Append($" \"keySource\": {(int)info.KeySource},\n"); + sb.Append($" \"keySourceName\": \"{info.KeySource}\",\n"); + sb.Append($" \"chunkSizeBytes\": {info.ChunkSizeBytes},\n"); + if (info.Kdf is { } kdf) + { + sb.Append($" \"kdf\": \"{kdf}\",\n"); + sb.Append($" \"saltSizeBytes\": {info.SaltSizeBytes},\n"); + } + if (info.Pbkdf2Iterations is { } iters) sb.Append($" \"pbkdf2Iterations\": {iters},\n"); + if (info.Argon2MemoryKiB is { } mem) sb.Append($" \"argon2MemoryKiB\": {mem},\n"); + if (info.Argon2Iterations is { } passes) sb.Append($" \"argon2Iterations\": {passes},\n"); + if (info.Argon2Parallelism is { } lanes) sb.Append($" \"argon2Parallelism\": {lanes},\n"); + if (info.RecipientCount is { } recipients) sb.Append($" \"recipientCount\": {recipients},\n"); + if (info.KeyProviderId is { } provider) sb.Append($" \"keyProviderId\": \"{JsonEscape(provider)}\",\n"); + if (info.PlaintextSizeUpperBoundBytes is { } bound) sb.Append($" \"plaintextSizeUpperBoundBytes\": {bound},\n"); + sb.Append(" \"authenticated\": false\n}"); + Console.WriteLine(sb.ToString()); + } + else + { + Console.WriteLine($"Format version: {info.FormatVersion} (.pqfe, frozen for 1.x)"); + Console.WriteLine($"Key source: {info.KeySource} ({(int)info.KeySource})"); + Console.WriteLine($"Chunk size: {info.ChunkSizeBytes:N0} bytes"); + if (info.Kdf == PqKdf.Pbkdf2HmacSha256) + Console.WriteLine($"KDF: PBKDF2-HMAC-SHA256, {info.Pbkdf2Iterations:N0} iterations, {info.SaltSizeBytes}-byte salt"); + if (info.Kdf == PqKdf.Argon2id) + Console.WriteLine($"KDF: Argon2id, {info.Argon2MemoryKiB:N0} KiB memory, {info.Argon2Iterations:N0} passes, {info.Argon2Parallelism} lane(s), {info.SaltSizeBytes}-byte salt"); + if (info.RecipientCount is { } count) + Console.WriteLine($"Recipients: {count}"); + if (info.KeyProviderId is { } providerId) + Console.WriteLine($"Key provider: {providerId}"); + if (info.PlaintextSizeUpperBoundBytes is { } upperBound) + Console.WriteLine($"Plaintext size: <= {upperBound:N0} bytes"); + Console.Error.WriteLine("note: header fields are UNAUTHENTICATED until a decryption succeeds — use them to"); + Console.Error.WriteLine(" refuse work or choose a key, never as trusted facts about the plaintext."); + } + return ExitOk; + } + + private static string JsonEscape(string value) + { + var sb = new StringBuilder(value.Length); + foreach (char c in value) + { + if (c is '"' or '\\') sb.Append('\\'); + sb.Append(c); + } + return sb.ToString(); + } + private static void WarnRawPrivateKey() => Console.Error.WriteLine( "note: the private key file is UNENCRYPTED (0600 on Unix). Consider --encrypt to protect it with a passphrase."); @@ -917,6 +1002,7 @@ pqfe recipient keygen [--encrypt [--passphrase-env VAR]] pqfe recipient encrypt --recipient [--recipient ...] pqfe recipient decrypt --identity [--untrusted] pqfe recipient fingerprint + pqfe inspect [--json] pqfe sign [--signature PATH] [--passphrase-env VAR] pqfe verify [--signature PATH] pqfe --version @@ -939,6 +1025,12 @@ ceilings on the KDF cost a hostile container header can sign detects an encrypted key file automatically and prompts (or reads --passphrase-env) for its passphrase. + inspect prints what a container's header declares — key source, KDF work + factors, chunk size, recipient count, provider id, and an upper bound on the + plaintext size — without deriving keys or decrypting anything, so it is safe to + run on untrusted files before deciding whether (and with which key) to decrypt. + Header fields are unauthenticated until a decryption succeeds. + recipient keygen writes an X25519 + ML-KEM-768 hybrid recipient key pair for public-key file encryption; recipient encrypt seals a file to one or more .pub keys (any listed recipient can open it) and recipient decrypt opens it with the diff --git a/samples/Pqfe.Cli/README.md b/samples/Pqfe.Cli/README.md index 4c8774d..f7f62a5 100644 --- a/samples/Pqfe.Cli/README.md +++ b/samples/Pqfe.Cli/README.md @@ -41,6 +41,10 @@ pqfe recipient keygen alice.key --encrypt # writes alice.key (PQKF) + a pqfe recipient encrypt report.pdf report.pdf.pqfe --recipient alice.key.pub --recipient bob.key.pub pqfe recipient decrypt report.pdf.pqfe report.pdf --identity alice.key --untrusted pqfe recipient fingerprint alice.key.pub # re-print the fingerprint to compare over a trusted channel + +# Inspect what a container's header declares — no key needed, nothing decrypted: +pqfe inspect report.pdf.pqfe # key source, KDF cost, chunk size, plaintext bound +pqfe inspect report.pdf.pqfe --json # machine-readable, for policy gates in scripts ``` Without `--encrypt`, `keygen` writes the raw private key bytes (`0600` on Unix). With it, the diff --git a/src/PostQuantum.FileEncryption/Internal/PqContainer.cs b/src/PostQuantum.FileEncryption/Internal/PqContainer.cs index 07fb05f..4ac9a67 100644 --- a/src/PostQuantum.FileEncryption/Internal/PqContainer.cs +++ b/src/PostQuantum.FileEncryption/Internal/PqContainer.cs @@ -237,7 +237,7 @@ private static byte[] SerializeKeyProviderParams(string providerId, byte[] wrapI /// container could otherwise inject terminal escape sequences or forged lines into whatever /// log or console the caller writes the message to. /// - private static string SanitizeForMessage(string value) + internal static string SanitizeForMessage(string value) { // Single pass, single copy of the predicate: a pre-scan fast path would duplicate the // sanitization rule, and this runs only while building an exception message anyway. diff --git a/src/PostQuantum.FileEncryption/PqContainerInfo.cs b/src/PostQuantum.FileEncryption/PqContainerInfo.cs new file mode 100644 index 0000000..e21f47f --- /dev/null +++ b/src/PostQuantum.FileEncryption/PqContainerInfo.cs @@ -0,0 +1,294 @@ +using System.Buffers.Binary; +using System.Diagnostics.CodeAnalysis; +using PostQuantum.FileEncryption.Internal; + +namespace PostQuantum.FileEncryption; + +/// The key-establishment mode a .pqfe container header declares. +public enum PqKeySource +{ + /// Passphrase-derived content key (PBKDF2-HMAC-SHA256 or Argon2id). + Passphrase = 1, + + /// Inline ML-KEM-768 recipient (deprecated mode; prefer the Hybrid package). + MlKemRecipient = 2, + + /// X25519 + ML-KEM-768 hybrid recipient (the Hybrid package). + HybridRecipient = 3, + + /// Multiple X25519 + ML-KEM-768 hybrid recipients (the Hybrid package). + HybridMultiRecipient = 4, + + /// External envelope key provider — KMS, HSM, or local KEK. + KeyProvider = 5, +} + +/// +/// Structural facts read from a .pqfe container's header — the supported way to answer +/// "what would decrypting this cost, and which key do I need?" without hand-parsing the frozen +/// format: the key source, the KDF and its declared work factors, the chunk size, the provider +/// id, and (when the container's length is known) an exact upper bound on the plaintext size. +/// Reading is cheap and performs no key derivation or decryption, so it is safe to run +/// on untrusted input before deciding whether to decrypt at all — the natural place to enforce +/// an application's own policy ("hybrid recipients only", "no KDF below our floor", "nothing +/// over 100 MB") on top of . +/// +/// +/// Everything here is unauthenticated. The header is attacker-controllable until a +/// decryption completes (it is bound as AAD, so it cannot be altered for an existing +/// container — but a hostile file can claim anything). Use these values to refuse work or +/// pick a key, never as a trusted statement about the plaintext. Structurally invalid input +/// throws , mirroring the real reader's acceptance exactly. +/// +public sealed class PqContainerInfo +{ + private PqContainerInfo(ContainerHeader header, long? totalContainerBytes) + { + FormatVersion = ContainerFormat.FormatVersion; + KeySource = (PqKeySource)header.KeySource; + ChunkSizeBytes = header.ChunkSize; + PlaintextSizeUpperBoundBytes = PqContainerEngine.DerivePlaintextTotal(totalContainerBytes, header); + + ReadOnlySpan p = header.KeyParams; + switch (header.KeySource) + { + case ContainerFormat.KeySourcePassphrase: + ParsePassphraseParams(p); + break; + case ContainerFormat.KeySourceMlKemRecipient: + // Exact layout enforced by the real reader: KemId(1) | C(2) | KemCt(C) | + // WrapNonce(12) | WrapTag(16) | WrappedKey(32), with KemId 1 ⇒ C = 1088. + if (p.Length < 3 || p[0] != 1 + || BinaryPrimitives.ReadUInt16BigEndian(p[1..]) != 1088 + || p.Length != 3 + 1088 + 12 + 16 + 32) + { + throw new PqFormatException("The recipient key parameters are malformed."); + } + RecipientCount = 1; + break; + case ContainerFormat.KeySourceHybridRecipient: + // A single hybrid wrap block: KemId(1) | C(2) | KemCt(1088) | EphX25519(32) | + // WrapNonce(12) | WrapTag(16) | WrappedKey(32) — exact length 1183. + if (p.Length != 1183) + { + throw new PqFormatException("The hybrid recipient key parameters are malformed."); + } + RecipientCount = 1; + break; + case ContainerFormat.KeySourceMultiRecipient: + RecipientCount = ParseMultiRecipientCount(p); + break; + case ContainerFormat.KeySourceKeyProvider: + KeyProviderId = ParseProviderId(p); + break; + default: + // Unreachable: ContainerHeader.Parse already rejected unknown key sources. + throw new PqFormatException("Unsupported key source."); + } + } + + /// The container format version (always 2 for the frozen 1.x format). + public int FormatVersion { get; } + + /// The declared key-establishment mode — which kind of key opens this container. + public PqKeySource KeySource { get; } + + /// The declared chunk size in bytes (bounds the decryptor's buffer per chunk). + public int ChunkSizeBytes { get; } + + /// The declared KDF, for containers. + public PqKdf? Kdf { get; private set; } + + /// The declared salt length in bytes, for passphrase containers. + public int? SaltSizeBytes { get; private set; } + + /// The declared PBKDF2 iteration count — the CPU cost decryption would pay. + public int? Pbkdf2Iterations { get; private set; } + + /// The declared Argon2id memory cost in KiB — the memory decryption would commit. + public int? Argon2MemoryKiB { get; private set; } + + /// The declared Argon2id pass count. + public int? Argon2Iterations { get; private set; } + + /// The declared Argon2id lane count. + public int? Argon2Parallelism { get; private set; } + + /// + /// The number of recipient wrap blocks the header declares (1 for the single-recipient + /// modes). Blocks past the declared count — a frozen reader leniency — are not counted. + /// + public int? RecipientCount { get; private set; } + + /// + /// The declared key-provider id, for containers, + /// with control characters replaced by ? (the raw value is attacker-controlled + /// text and must not reach a log or terminal unsanitized). + /// + public string? KeyProviderId { get; private set; } + + /// + /// An exact upper bound on the plaintext this container can decrypt to, derived from the + /// container's total length — or when the length was unknown or + /// too short to hold any frame. This is the same bound + /// enforces. + /// + public long? PlaintextSizeUpperBoundBytes { get; } + + /// + /// Reads the structural facts from a complete (or prefix of a) container. Throws + /// for input the frozen reader would reject structurally. + /// + public static PqContainerInfo Read(ReadOnlySpan container) + { + if (container.Length < ContainerFormat.FixedHeaderLength) + { + throw new PqFormatException("Input is too short to be a PostQuantum.FileEncryption container."); + } + int keyParamsLength = BinaryPrimitives.ReadUInt16BigEndian(container[ContainerFormat.OffsetKeyParamsLength..]); + int headerLength = ContainerFormat.FixedHeaderLength + keyParamsLength; + if (container.Length < headerLength) + { + throw new PqFormatException("Input ends before the declared container header is complete."); + } + ContainerHeader header = ContainerHeader.Parse(container[..headerLength].ToArray()); + return new PqContainerInfo(header, container.Length); + } + + /// + /// Like , but returns + /// instead of throwing for structurally invalid input. + /// + public static bool TryRead(ReadOnlySpan container, [NotNullWhen(true)] out PqContainerInfo? info) + { + try + { + info = Read(container); + return true; + } + catch (PqFormatException) + { + info = null; + return false; + } + } + + /// Reads the structural facts from a container file (header bytes only are read). + public static async Task ReadFileAsync(string path, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrEmpty(path); + await using var stream = FileIo.OpenRead(path); + long total = stream.Length; + ContainerHeader header = await PqContainerEngine.ReadHeaderAsync(stream, cancellationToken).ConfigureAwait(false); + return new PqContainerInfo(header, total); + } + + private void ParsePassphraseParams(ReadOnlySpan p) + { + // The same structural checks (and bounds) as the real reader's key establishment — + // pinned against it by the conformance-corpus consistency tests. + if (p.Length < 2) + { + throw new PqFormatException("Passphrase key parameters are too short."); + } + byte kdfId = p[0]; + int saltLength = p[1]; + int offset = 2; + if (saltLength < PqEncryptionOptions.MinSaltSizeBytes || p.Length < offset + saltLength) + { + throw new PqFormatException("Container declares an invalid salt."); + } + SaltSizeBytes = saltLength; + offset += saltLength; + + switch (kdfId) + { + case ContainerFormat.KdfPbkdf2HmacSha256: + { + if (p.Length < offset + 4) + { + throw new PqFormatException("PBKDF2 key parameters are truncated."); + } + long iterations = BinaryPrimitives.ReadUInt32BigEndian(p[offset..]); + if (iterations < PqEncryptionOptions.MinPbkdf2Iterations || iterations > PqEncryptionOptions.MaxPbkdf2Iterations) + { + throw new PqFormatException($"Container declares an out-of-range PBKDF2 iteration count of {iterations}."); + } + Kdf = PqKdf.Pbkdf2HmacSha256; + Pbkdf2Iterations = (int)iterations; + break; + } + case ContainerFormat.KdfArgon2id: + { + if (p.Length < offset + 9) + { + throw new PqFormatException("Argon2id key parameters are truncated."); + } + long memoryKiB = BinaryPrimitives.ReadUInt32BigEndian(p[offset..]); + long iterations = BinaryPrimitives.ReadUInt32BigEndian(p[(offset + 4)..]); + int parallelism = p[offset + 8]; + if (memoryKiB < PqEncryptionOptions.MinArgon2MemoryKiB || memoryKiB > PqEncryptionOptions.MaxArgon2MemoryKiB || + iterations < PqEncryptionOptions.MinArgon2Iterations || iterations > PqEncryptionOptions.MaxArgon2Iterations || + parallelism < 1) + { + throw new PqFormatException("Container declares out-of-range Argon2id parameters."); + } + Kdf = PqKdf.Argon2id; + Argon2MemoryKiB = (int)memoryKiB; + Argon2Iterations = (int)iterations; + Argon2Parallelism = parallelism; + break; + } + default: + throw new PqFormatException($"Unsupported KDF identifier {kdfId}."); + } + } + + private static int ParseMultiRecipientCount(ReadOnlySpan p) + { + // Count byte followed by `count` entries of Mode(1) | BlockLength(2 BE) | Block. + // Trailing bytes past the declared count are a frozen reader leniency and are ignored, + // exactly as the real reader ignores them. + if (p.Length < 1 || p[0] < 1) + { + throw new PqFormatException("The multi-recipient key parameters are malformed."); + } + int count = p[0]; + int cursor = 1; + for (int i = 0; i < count; i++) + { + if (p.Length < cursor + 3) + { + throw new PqFormatException("The multi-recipient key parameters are malformed."); + } + int blockLength = BinaryPrimitives.ReadUInt16BigEndian(p[(cursor + 1)..]); + cursor += 3; + if (p.Length < cursor + blockLength) + { + throw new PqFormatException("The multi-recipient key parameters are malformed."); + } + cursor += blockLength; + } + return count; + } + + private static string ParseProviderId(ReadOnlySpan p) + { + // ProviderIdLength(1) | ProviderId(UTF-8) | WrapInfoLength(2 BE) | WrapInfo — exact. + if (p.Length < 1) + { + throw new PqFormatException("The key-provider parameters are malformed."); + } + int idLength = p[0]; + if (idLength < 1 || p.Length < 1 + idLength + 2) + { + throw new PqFormatException("The key-provider parameters are malformed."); + } + int wrapInfoLength = BinaryPrimitives.ReadUInt16BigEndian(p[(1 + idLength)..]); + if (p.Length != 1 + idLength + 2 + wrapInfoLength) + { + throw new PqFormatException("The key-provider parameters are malformed."); + } + return PqContainer.SanitizeForMessage(System.Text.Encoding.UTF8.GetString(p.Slice(1, idLength))); + } +} diff --git a/src/PostQuantum.FileEncryption/PublicAPI.Unshipped.txt b/src/PostQuantum.FileEncryption/PublicAPI.Unshipped.txt index c330fa8..e4051c0 100644 --- a/src/PostQuantum.FileEncryption/PublicAPI.Unshipped.txt +++ b/src/PostQuantum.FileEncryption/PublicAPI.Unshipped.txt @@ -3,3 +3,25 @@ PostQuantum.FileEncryption.PqDecryptionLimits.MaxArgon2Parallelism.get -> int PostQuantum.FileEncryption.PqDecryptionLimits.MaxArgon2Parallelism.init -> void PostQuantum.FileEncryption.PqDecryptionLimits.MaxPlaintextBytes.get -> long PostQuantum.FileEncryption.PqDecryptionLimits.MaxPlaintextBytes.init -> void +PostQuantum.FileEncryption.PqContainerInfo +PostQuantum.FileEncryption.PqContainerInfo.Argon2Iterations.get -> int? +PostQuantum.FileEncryption.PqContainerInfo.Argon2MemoryKiB.get -> int? +PostQuantum.FileEncryption.PqContainerInfo.Argon2Parallelism.get -> int? +PostQuantum.FileEncryption.PqContainerInfo.ChunkSizeBytes.get -> int +PostQuantum.FileEncryption.PqContainerInfo.FormatVersion.get -> int +PostQuantum.FileEncryption.PqContainerInfo.Kdf.get -> PostQuantum.FileEncryption.PqKdf? +PostQuantum.FileEncryption.PqContainerInfo.KeyProviderId.get -> string? +PostQuantum.FileEncryption.PqContainerInfo.KeySource.get -> PostQuantum.FileEncryption.PqKeySource +PostQuantum.FileEncryption.PqContainerInfo.Pbkdf2Iterations.get -> int? +PostQuantum.FileEncryption.PqContainerInfo.PlaintextSizeUpperBoundBytes.get -> long? +PostQuantum.FileEncryption.PqContainerInfo.RecipientCount.get -> int? +PostQuantum.FileEncryption.PqContainerInfo.SaltSizeBytes.get -> int? +PostQuantum.FileEncryption.PqKeySource +PostQuantum.FileEncryption.PqKeySource.HybridMultiRecipient = 4 -> PostQuantum.FileEncryption.PqKeySource +PostQuantum.FileEncryption.PqKeySource.HybridRecipient = 3 -> PostQuantum.FileEncryption.PqKeySource +PostQuantum.FileEncryption.PqKeySource.KeyProvider = 5 -> PostQuantum.FileEncryption.PqKeySource +PostQuantum.FileEncryption.PqKeySource.MlKemRecipient = 2 -> PostQuantum.FileEncryption.PqKeySource +PostQuantum.FileEncryption.PqKeySource.Passphrase = 1 -> PostQuantum.FileEncryption.PqKeySource +static PostQuantum.FileEncryption.PqContainerInfo.Read(System.ReadOnlySpan container) -> PostQuantum.FileEncryption.PqContainerInfo! +static PostQuantum.FileEncryption.PqContainerInfo.ReadFileAsync(string! path, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +static PostQuantum.FileEncryption.PqContainerInfo.TryRead(System.ReadOnlySpan container, out PostQuantum.FileEncryption.PqContainerInfo? info) -> bool diff --git a/tests/PostQuantum.FileEncryption.Tests/PqContainerInfoTests.cs b/tests/PostQuantum.FileEncryption.Tests/PqContainerInfoTests.cs new file mode 100644 index 0000000..478a502 --- /dev/null +++ b/tests/PostQuantum.FileEncryption.Tests/PqContainerInfoTests.cs @@ -0,0 +1,156 @@ +using System.Text; +using System.Text.Json; +using PostQuantum.FileEncryption.Hybrid; +using Xunit; +using static PostQuantum.FileEncryption.Tests.TestSupport; + +namespace PostQuantum.FileEncryption.Tests; + +/// +/// must report exactly what the header declares, for every key +/// source, without any key derivation — and its structural acceptance must mirror the real +/// reader's exactly, which the conformance-corpus consistency test pins: anything the frozen +/// reader rejects structurally, Read rejects; anything it accepts (or rejects only at +/// authentication), Read inspects. +/// +public sealed class PqContainerInfoTests +{ + [Fact] + public async Task Reports_pbkdf2_passphrase_facts_and_exact_plaintext_bound() + { + byte[] plaintext = RandomBytes(4096); + byte[] container = await new PqFileEncryptor(Fast(1024)).EncryptBytesAsync(plaintext, Passphrase); + + var info = PqContainerInfo.Read(container); + + Assert.Equal(2, info.FormatVersion); + Assert.Equal(PqKeySource.Passphrase, info.KeySource); + Assert.Equal(1024, info.ChunkSizeBytes); + Assert.Equal(PqKdf.Pbkdf2HmacSha256, info.Kdf); + Assert.Equal(PqEncryptionOptions.MinPbkdf2Iterations, info.Pbkdf2Iterations); + Assert.Equal(16, info.SaltSizeBytes); + Assert.Null(info.Argon2MemoryKiB); + Assert.Null(info.RecipientCount); + Assert.Null(info.KeyProviderId); + Assert.Equal(4096, info.PlaintextSizeUpperBoundBytes); + } + + [Fact] + public async Task Reports_argon2id_work_factors() + { + var options = new PqEncryptionOptions + { + Kdf = PqKdf.Argon2id, + Argon2MemoryKiB = 8 * 1024, + Argon2Iterations = 2, + Argon2Parallelism = 3, + ChunkSizeBytes = 1024, + }; + byte[] container = await new PqFileEncryptor(options).EncryptBytesAsync(RandomBytes(100), Passphrase); + + var info = PqContainerInfo.Read(container); + + Assert.Equal(PqKdf.Argon2id, info.Kdf); + Assert.Equal(8 * 1024, info.Argon2MemoryKiB); + Assert.Equal(2, info.Argon2Iterations); + Assert.Equal(3, info.Argon2Parallelism); + Assert.Null(info.Pbkdf2Iterations); + } + + [Fact] + public async Task Reports_hybrid_recipient_counts() + { + using var alice = PqHybridKeyPair.Generate(); + using var bob = PqHybridKeyPair.Generate(); + + byte[] single = await new PqHybridEncryptor(Fast()).EncryptBytesAsync(RandomBytes(64), alice.PublicKey); + var singleInfo = PqContainerInfo.Read(single); + Assert.Equal(PqKeySource.HybridRecipient, singleInfo.KeySource); + Assert.Equal(1, singleInfo.RecipientCount); + Assert.Null(singleInfo.Kdf); + + byte[] multi = await new PqHybridEncryptor(Fast()).EncryptBytesToAsync(RandomBytes(64), [alice.PublicKey, bob.PublicKey]); + var multiInfo = PqContainerInfo.Read(multi); + Assert.Equal(PqKeySource.HybridMultiRecipient, multiInfo.KeySource); + Assert.Equal(2, multiInfo.RecipientCount); + } + + [Fact] + public async Task Reports_and_sanitizes_the_key_provider_id() + { + using var provider = LocalKekContentKeyProvider.Generate(); + byte[] container = await new PqFileEncryptor(Fast()).EncryptBytesAsync(RandomBytes(64), provider); + + var info = PqContainerInfo.Read(container); + Assert.Equal(PqKeySource.KeyProvider, info.KeySource); + Assert.Equal("local-kek", info.KeyProviderId); + + // The provider id is attacker-controlled header text: a control character must never + // pass through to logs/terminals. Patch one into the id in place (the header is AAD, + // so decryption would reject this container — inspection still reads it, by design). + byte[] hostile = (byte[])container.Clone(); + hostile[19] = 0x1B; // first provider-id byte (KeyParams start at 18: len byte, then id) + Assert.True(PqContainerInfo.TryRead(hostile, out var hostileInfo)); + Assert.Contains('?', hostileInfo!.KeyProviderId!); + Assert.DoesNotContain('\x1B', hostileInfo.KeyProviderId!); + } + + [Fact] + public async Task Read_file_reads_only_the_header() + { + string path = Path.Combine(Path.GetTempPath(), $"pqfe-info-{Guid.NewGuid():N}.pqfe"); + try + { + byte[] plaintext = RandomBytes(3000); + byte[] container = await new PqFileEncryptor(Fast(1024)).EncryptBytesAsync(plaintext, Passphrase); + await File.WriteAllBytesAsync(path, container); + + var info = await PqContainerInfo.ReadFileAsync(path); + Assert.Equal(PqKeySource.Passphrase, info.KeySource); + Assert.Equal(3000, info.PlaintextSizeUpperBoundBytes); + } + finally + { + File.Delete(path); + } + } + + [Fact] + public void Truncated_and_garbage_input_fail_closed() + { + Assert.Throws(() => PqContainerInfo.Read(new byte[4])); + Assert.False(PqContainerInfo.TryRead(RandomBytes(64), out _)); + Assert.False(PqContainerInfo.TryRead([], out _)); + } + + [Fact] + public void Structural_acceptance_mirrors_the_frozen_reader_across_the_conformance_corpus() + { + string dir = Path.Combine(ConformanceManifestTests.FindRepositoryRoot(), "test-vectors"); + using var manifest = JsonDocument.Parse(File.ReadAllText(Path.Combine(dir, "manifest.json"))); + + foreach (JsonElement v in manifest.RootElement.GetProperty("vectors").EnumerateArray()) + { + string id = v.GetProperty("id").GetString()!; + string file = v.GetProperty("file").GetString()!; + string expect = v.GetProperty("expect").GetString()!; + byte[] bytes = File.ReadAllBytes(Path.Combine(dir, file)); + + bool readable = PqContainerInfo.TryRead(bytes, out var info); + if (expect == "reject-format") + { + Assert.False(readable, $"{id}: inspection must reject what the reader rejects structurally"); + } + else + { + // accept, lenient, and reject-decryption (auth-stage) vectors all carry a + // structurally valid header, so inspection must succeed and agree on the mode. + Assert.True(readable, $"{id}: inspection must read a structurally valid header"); + if (v.TryGetProperty("keySource", out JsonElement ks)) + { + Assert.Equal(ks.GetInt32(), (int)info!.KeySource); + } + } + } + } +}