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); + } + } + } + } +}