From 2ae73aa4341f4547c14eb5c97b8ea6eec442c7f2 Mon Sep 17 00:00:00 2001 From: Patrick Scheid Date: Fri, 12 Jun 2026 04:43:37 +0200 Subject: [PATCH 1/8] Validate plugin names before spawning plugin processes A crafted recipient/identity string could smuggle path separators through the bech32 HRP into the age-plugin-{name} executable path, redirecting Process.Start to an attacker-chosen binary. Restrict plugin names to [a-z0-9-] both at parse time and at the spawn site. --- Age.Tests/PluginTests.cs | 30 ++++++++++++++++++++++++++++++ Age/Plugin/PluginConnection.cs | 24 ++++++++++++++++++++++++ Age/Recipients/PluginIdentity.cs | 9 ++++++--- Age/Recipients/PluginRecipient.cs | 9 ++++++--- 4 files changed, 66 insertions(+), 6 deletions(-) diff --git a/Age.Tests/PluginTests.cs b/Age.Tests/PluginTests.cs index 6ca38a8..1bc572b 100644 --- a/Age.Tests/PluginTests.cs +++ b/Age.Tests/PluginTests.cs @@ -844,6 +844,36 @@ public void PluginRecipient_ExtractPluginName_InvalidHrp_Throws() Assert.Throws(() => PluginRecipient.ExtractPluginName(badStr)); } + [Theory] + [InlineData("../../evil")] + [InlineData("../evil")] + [InlineData("foo/bar")] + [InlineData(".")] + public void PluginRecipient_ExtractPluginName_PathTraversal_Throws(string maliciousName) + { + // A crafted recipient whose bech32 HRP smuggles path characters must be + // rejected before it can ever be turned into an executable path. + var malicious = Bech32.Encode($"age1{maliciousName}", new byte[] { 0x01 }); + var ex = Assert.Throws(() => PluginRecipient.ExtractPluginName(malicious)); + Assert.Contains("plugin name", ex.Message); + } + + [Fact] + public void PluginIdentity_ExtractPluginName_PathTraversal_Throws() + { + var malicious = Bech32.Encode("age-plugin-../../evil-", new byte[] { 0x01 }).ToUpperInvariant(); + var ex = Assert.Throws(() => PluginIdentity.ExtractPluginName(malicious)); + Assert.Contains("plugin name", ex.Message); + } + + [Fact] + public void PluginName_HyphenatedNamesAreAllowed() + { + // Real plugins use hyphens (e.g. age-plugin-fido2-hmac); these must still parse. + Assert.Equal("fido2-hmac", PluginRecipient.ExtractPluginName(MakePluginRecipient("fido2-hmac"))); + Assert.Equal("fido2-hmac", PluginIdentity.ExtractPluginName(MakePluginIdentity("fido2-hmac"))); + } + // --- Additional coverage: PluginIdentity edge cases --- [Fact] diff --git a/Age/Plugin/PluginConnection.cs b/Age/Plugin/PluginConnection.cs index 6ed6545..cb36d6f 100644 --- a/Age/Plugin/PluginConnection.cs +++ b/Age/Plugin/PluginConnection.cs @@ -15,6 +15,10 @@ internal sealed class PluginConnection : IDisposable /// public PluginConnection(string pluginName, string stateMachine) { + // Defense-in-depth: never spawn a process from an unvalidated name. + // The name becomes part of the executable path, so a value containing + // path separators or "." could redirect execution to an arbitrary binary. + ValidatePluginName(pluginName); var binaryName = $"age-plugin-{pluginName}"; var startInfo = new ProcessStartInfo { @@ -49,6 +53,26 @@ internal PluginConnection(TextReader reader, TextWriter writer) _writer = writer; } + /// + /// Validates a plugin name before it is used to build an executable path. + /// Per the age-plugin spec a name is a non-empty sequence of lowercase + /// letters, digits, and hyphens. Rejecting anything else prevents a crafted + /// recipient/identity string from steering process execution (e.g. a name + /// containing "/" or ".." resolving to an attacker-chosen binary). + /// + public static void ValidatePluginName(string pluginName) + { + if (string.IsNullOrEmpty(pluginName)) + throw new FormatException("plugin name must not be empty"); + + foreach (var c in pluginName) + { + var ok = c is (>= 'a' and <= 'z') or (>= '0' and <= '9') or '-'; + if (!ok) + throw new FormatException($"invalid character in plugin name '{pluginName}': 0x{(int)c:X2}"); + } + } + public void WriteStanza(string type, string[] args, byte[] body) { _writer.Write("-> "); diff --git a/Age/Recipients/PluginIdentity.cs b/Age/Recipients/PluginIdentity.cs index 46e0656..f0a35cb 100644 --- a/Age/Recipients/PluginIdentity.cs +++ b/Age/Recipients/PluginIdentity.cs @@ -128,9 +128,12 @@ internal static string ExtractPluginName(string identity) var (hrp, _) = Bech32.Decode(identity); // skip "age-plugin-" prefix and trailing "-" - return hrp.StartsWith("age-plugin-") - ? hrp[11..^1] - : throw new FormatException($"invalid plugin identity HRP: {hrp}"); + if (!hrp.StartsWith("age-plugin-")) + throw new FormatException($"invalid plugin identity HRP: {hrp}"); + + var name = hrp[11..^1]; + PluginConnection.ValidatePluginName(name); + return name; } public override string ToString() => diff --git a/Age/Recipients/PluginRecipient.cs b/Age/Recipients/PluginRecipient.cs index ba0c84b..56ceeb0 100644 --- a/Age/Recipients/PluginRecipient.cs +++ b/Age/Recipients/PluginRecipient.cs @@ -122,9 +122,12 @@ internal static string ExtractPluginName(string recipient) var (hrp, _) = Bech32.Decode(recipient); // skip "age" + the "1" separator character encoded in hrp after "age" - return hrp.StartsWith("age") - ? hrp[4..] - : throw new FormatException($"invalid plugin recipient HRP: {hrp}"); + if (!hrp.StartsWith("age")) + throw new FormatException($"invalid plugin recipient HRP: {hrp}"); + + var name = hrp[4..]; + PluginConnection.ValidatePluginName(name); + return name; } public override string ToString() => From cacd850989bb5c029d30458346f21b4fc72b8c80 Mon Sep 17 00:00:00 2001 From: Patrick Scheid Date: Fri, 12 Jun 2026 04:43:44 +0200 Subject: [PATCH 2/8] Use a cryptographic RNG for auto-generated passphrases System.Random is predictable; the generated passphrase is the sole secret protecting the encrypted file. --- Age.Cli/AgeCommand.cs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/Age.Cli/AgeCommand.cs b/Age.Cli/AgeCommand.cs index 27acb67..84041d8 100644 --- a/Age.Cli/AgeCommand.cs +++ b/Age.Cli/AgeCommand.cs @@ -1,3 +1,4 @@ +using System.Security.Cryptography; using System.Text; using Age.Format; using Age.Plugin; @@ -201,14 +202,15 @@ private static string ReadPassphrase(string prompt) private static string GeneratePassphrase() { - var rng = new Random(); + // This passphrase is the sole secret protecting the encrypted file, so it + // must come from a cryptographically secure RNG — never System.Random. var parts = new string[10]; for (var i = 0; i < 10; i++) { var chars = new char[6]; for (var j = 0; j < 6; j++) - chars[j] = (char)('a' + rng.Next(26)); + chars[j] = (char)('a' + RandomNumberGenerator.GetInt32(26)); parts[i] = new string(chars); } From 2f6c30fd416609d79455aa9fbbf9873b1f178459 Mon Sep 17 00:00:00 2001 From: Patrick Scheid Date: Fri, 12 Jun 2026 04:43:53 +0200 Subject: [PATCH 3/8] Surface a clear error when decrypting a passphrase file with -i MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The RejectScryptIdentity sentinel was only inserted when an identity file yielded a ScryptRecipient, which never happens — so the friendly "use -p instead" error was dead code and users got a generic "no identity matched". Always try the sentinel first; it also runs before any plugin identity would be spawned. --- Age.Cli/AgeCommand.cs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/Age.Cli/AgeCommand.cs b/Age.Cli/AgeCommand.cs index 84041d8..4100407 100644 --- a/Age.Cli/AgeCommand.cs +++ b/Age.Cli/AgeCommand.cs @@ -118,7 +118,13 @@ private static List CollectDecryptIdentities(bool passphrase, string[ if (identityFiles.Length == 0) throw new AgeException("missing identity (-i required for decryption, or use -p for passphrase)"); - identities.AddRange(from file in identityFiles from id in LoadIdentities(file, callbacks) select id is ScryptRecipient ? new RejectScryptIdentity() : id); + // A scrypt (passphrase) file can't be opened with -i. Try this sentinel + // first so we surface a clear "use -p" error instead of a generic + // "no identity matched" — and before any plugin identity is spawned. + identities.Add(new RejectScryptIdentity()); + + foreach (var file in identityFiles) + identities.AddRange(LoadIdentities(file, callbacks)); } return identities; From 959137564707e2021b7306798337ba450731deb8 Mon Sep 17 00:00:00 2001 From: Patrick Scheid Date: Fri, 12 Jun 2026 04:44:02 +0200 Subject: [PATCH 4/8] Bound header and armor line lengths against pre-auth OOM A hostile stream with an unterminated multi-gigabyte line was buffered in full before any length or authenticity check could reject it. Cap header lines at 64 KiB and total header size at 16 MiB, and bound armor lines via a pass-through stream so StreamReader.ReadLine keeps its fast path. The armor bound costs one vectorized scan per buffered read; armored decrypt benchmarks stay within noise of the unbounded code. --- Age.Tests/UnitTests.cs | 30 +++++++++++ Age/Format/AsciiArmor.cs | 13 ++++- Age/Format/HeaderReader.cs | 16 ++++++ Age/Format/NewlineBoundedStream.cs | 85 ++++++++++++++++++++++++++++++ 4 files changed, 143 insertions(+), 1 deletion(-) create mode 100644 Age/Format/NewlineBoundedStream.cs diff --git a/Age.Tests/UnitTests.cs b/Age.Tests/UnitTests.cs index dd69e71..b876d92 100644 --- a/Age.Tests/UnitTests.cs +++ b/Age.Tests/UnitTests.cs @@ -552,6 +552,36 @@ public void Reject_Empty_Body_Lines() Assert.Throws(() => { using var s = AsciiArmor.Dearmor(stream); ReadAllBytes(s); }); } + [Fact] + public void Reject_Oversized_Armor_Line() + { + // A single body line far longer than any legal armor line must be rejected + // without buffering the whole (potentially unbounded) line into memory. + var text = "-----BEGIN AGE ENCRYPTED FILE-----\n" + new string('A', AsciiArmor.MaxLineBytes + 1000) + "\n"; + using var stream = new MemoryStream(Encoding.ASCII.GetBytes(text)); + var ex = Assert.Throws(() => { using var s = AsciiArmor.Dearmor(stream); ReadAllBytes(s); }); + Assert.Contains("exceeds", ex.Message); + } + + [Fact] + public void Reject_Oversized_Line_Before_Begin_Marker() + { + // The bound must also protect the marker search before the armor body. + var text = new string('x', AsciiArmor.MaxLineBytes + 1000); + using var stream = new MemoryStream(Encoding.ASCII.GetBytes(text)); + Assert.Throws(() => AsciiArmor.Dearmor(stream)); + } + + [Fact] + public void Reject_Oversized_Header_Line() + { + // A header line with no newline must be bounded before authentication. + var text = "age-encryption.org/v1\n" + new string('a', 100_000) + "\n"; + using var stream = new MemoryStream(Encoding.ASCII.GetBytes(text)); + var ex = Assert.Throws(() => AgeHeader.Parse(stream)); + Assert.Contains("exceeds", ex.Message); + } + [Fact] public void Dearmor_Skips_Leading_Whitespace() { diff --git a/Age/Format/AsciiArmor.cs b/Age/Format/AsciiArmor.cs index 5df4b6c..4001138 100644 --- a/Age/Format/AsciiArmor.cs +++ b/Age/Format/AsciiArmor.cs @@ -8,6 +8,14 @@ internal static class AsciiArmor private const string EndMarker = "-----END AGE ENCRYPTED FILE-----"; private const int ColumnsPerLine = 64; + // Valid armor lines are at most 64 chars; the markers are ~40. The bound + // exists only to stop a hostile stream with an unterminated multi-gigabyte + // line from exhausting memory, so it is set generously high (matching the + // header reader's per-line cap). Keeping it well above the StreamReader + // buffer size also keeps NewlineBoundedStream on its one-scan-per-read + // fast path for legitimate input. + internal const int MaxLineBytes = 64 * 1024; + public static bool IsArmored(Stream stream) { if (!stream.CanSeek) @@ -43,7 +51,10 @@ private static void SkipLeadingWhitespace(Stream stream) public static Stream Dearmor(Stream input) { - var reader = new StreamReader(input, Encoding.ASCII, detectEncodingFromByteOrderMarks: false, + // Bound the line length at the byte level so the reader below can keep + // using the fast ReadLine path without risking an unbounded allocation. + var bounded = new NewlineBoundedStream(input, MaxLineBytes); + var reader = new StreamReader(bounded, Encoding.ASCII, detectEncodingFromByteOrderMarks: false, bufferSize: 4096, leaveOpen: false); // Skip leading whitespace (allowed per spec). diff --git a/Age/Format/HeaderReader.cs b/Age/Format/HeaderReader.cs index 07034ae..5a52038 100644 --- a/Age/Format/HeaderReader.cs +++ b/Age/Format/HeaderReader.cs @@ -9,6 +9,13 @@ namespace Age.Format; /// internal sealed class HeaderReader(Stream stream) { + // Defensive bounds so a malformed/hostile header can't exhaust memory before + // any authentication happens. Both are far above any legitimate age header: + // the largest built-in stanza line (an ML-KEM enc argument) is ~1.5 KiB, and + // real headers carry a handful of recipients. + private const int MaxLineLength = 64 * 1024; // 64 KiB per line + private const int MaxHeaderLength = 16 * 1024 * 1024; // 16 MiB total + private readonly MemoryStream _rawBytes = new(); private string? _pushedBack; @@ -58,6 +65,10 @@ public void PushBack(string line) break; ValidateByte(b); + + if (lineBytes.Count >= MaxLineLength) + throw new AgeHeaderException($"header line exceeds {MaxLineLength} bytes"); + lineBytes.Add((byte)b); } @@ -69,7 +80,12 @@ private int ReadAndTrackByte() var b = stream.ReadByte(); if (b >= 0) + { + if (_rawBytes.Length >= MaxHeaderLength) + throw new AgeHeaderException($"header exceeds {MaxHeaderLength} bytes"); + _rawBytes.WriteByte((byte)b); + } return b; } diff --git a/Age/Format/NewlineBoundedStream.cs b/Age/Format/NewlineBoundedStream.cs new file mode 100644 index 0000000..adba8c5 --- /dev/null +++ b/Age/Format/NewlineBoundedStream.cs @@ -0,0 +1,85 @@ +namespace Age.Format; + +/// +/// A read-only pass-through stream that caps the number of bytes that may pass +/// without a line terminator (CR or LF). This lets the armor reader keep using +/// the fast path while still bounding memory: +/// a hostile stream with a multi-gigabyte line cannot be buffered, because the +/// limit trips during the underlying read instead. +/// +internal sealed class NewlineBoundedStream(Stream inner, int maxLineBytes) : Stream +{ + private int _run; // bytes seen since the last CR/LF + + public override int Read(byte[] buffer, int offset, int count) + { + var n = inner.Read(buffer, offset, count); + Scan(buffer.AsSpan(offset, n)); + return n; + } + + public override int Read(Span buffer) + { + var n = inner.Read(buffer); + Scan(buffer[..n]); + return n; + } + + private void Scan(ReadOnlySpan bytes) + { + // Fast path: when the carried run plus this whole chunk fits the limit, + // no line inside the chunk can violate it. One backward vectorized scan + // updates the carried run, so the bound costs ~one SIMD pass per read. + if (_run + bytes.Length <= maxLineBytes) + { + var lastNl = bytes.LastIndexOfAny((byte)'\n', (byte)'\r'); + _run = lastNl < 0 ? _run + bytes.Length : bytes.Length - 1 - lastNl; + return; + } + + // Slow path (a line has accumulated near the limit — hostile input): + // walk newline-to-newline to find where the run is broken or exceeded. + while (!bytes.IsEmpty) + { + var nl = bytes.IndexOfAny((byte)'\n', (byte)'\r'); + + if (nl < 0) + { + _run += bytes.Length; + if (_run > maxLineBytes) + throw new AgeArmorException($"armor line exceeds {maxLineBytes} bytes"); + return; + } + + if (_run + nl > maxLineBytes) + throw new AgeArmorException($"armor line exceeds {maxLineBytes} bytes"); + + _run = 0; + bytes = bytes[(nl + 1)..]; + } + } + + protected override void Dispose(bool disposing) + { + if (disposing) + inner.Dispose(); + + base.Dispose(disposing); + } + + public override bool CanRead => true; + public override bool CanSeek => false; + public override bool CanWrite => false; + public override long Length => throw new NotSupportedException(); + + public override long Position + { + get => throw new NotSupportedException(); + set => throw new NotSupportedException(); + } + + public override void Flush() { } + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + public override void SetLength(long value) => throw new NotSupportedException(); + public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); +} From 33a3040225dd7bd512e6c36596b26940ed2afbbc Mon Sep 17 00:00:00 2001 From: Patrick Scheid Date: Fri, 12 Jun 2026 04:44:11 +0200 Subject: [PATCH 5/8] Validate scrypt work factor at construction The encrypt path accepted any work factor: values >= 31 overflowed 1 << workFactor, and anything above 20 produced files this library's own decrypt cap refuses to read back. Enforce [1, 20] eagerly, matching the existing decrypt-side maximum. --- Age.Tests/UnitTests.cs | 22 ++++++++++++++++++++++ Age/Recipients/ScryptRecipient.cs | 15 +++++++++++++-- 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/Age.Tests/UnitTests.cs b/Age.Tests/UnitTests.cs index b876d92..bd50567 100644 --- a/Age.Tests/UnitTests.cs +++ b/Age.Tests/UnitTests.cs @@ -859,6 +859,28 @@ public void Unwrap_Rejects_WorkFactor_Over_20() Assert.Throws(() => recipient.Unwrap(stanza)); } + [Theory] + [InlineData(0)] + [InlineData(-1)] + [InlineData(21)] + [InlineData(31)] + [InlineData(64)] + public void Constructor_Rejects_OutOfRange_WorkFactor(int workFactor) + { + // Out-of-range work factors must fail fast (the high end would otherwise + // overflow `1 << workFactor` or produce a file this library can't read). + Assert.Throws(() => new ScryptRecipient("password", workFactor)); + } + + [Theory] + [InlineData(1)] + [InlineData(18)] + [InlineData(20)] + public void Constructor_Accepts_InRange_WorkFactor(int workFactor) + { + _ = new ScryptRecipient("password", workFactor); + } + [Fact] public void Unwrap_Rejects_Wrong_Salt_Size() { diff --git a/Age/Recipients/ScryptRecipient.cs b/Age/Recipients/ScryptRecipient.cs index 12db0a8..f044ef7 100644 --- a/Age/Recipients/ScryptRecipient.cs +++ b/Age/Recipients/ScryptRecipient.cs @@ -16,19 +16,30 @@ public sealed class ScryptRecipient(string passphrase, int workFactor = 18) : IR private const int NonceSize = 12; private const int WrappedKeySize = 32; // 16-byte file key + 16-byte Poly1305 tag + // Validate eagerly so an out-of-range work factor fails at construction + // rather than overflowing `1 << workFactor` or producing a stanza this + // library (which caps decryption at MaxWorkFactor) could never read back. + private readonly int _workFactor = EnsureValidWorkFactor(workFactor); + + private static int EnsureValidWorkFactor(int workFactor) => + workFactor is >= 1 and <= MaxWorkFactor + ? workFactor + : throw new ArgumentOutOfRangeException(nameof(workFactor), workFactor, + $"scrypt work factor must be between 1 and {MaxWorkFactor}"); + public Stanza Wrap(ReadOnlySpan fileKey) { var salt = new byte[SaltSize]; RandomNumberGenerator.Fill(salt); - var wrapKey = DeriveWrapKey(passphrase, salt, workFactor); + var wrapKey = DeriveWrapKey(passphrase, salt, _workFactor); var zeroNonce = new byte[NonceSize]; var body = CryptoHelper.ChaChaEncrypt(wrapKey, zeroNonce, fileKey); CryptographicOperations.ZeroMemory(wrapKey); var saltB64 = Base64Unpadded.Encode(salt); - return new Stanza(StanzaType, [saltB64, workFactor.ToString()], body); + return new Stanza(StanzaType, [saltB64, _workFactor.ToString()], body); } public byte[]? Unwrap(Stanza stanza) From 8e841233ca407c09f2095d84cad81807146eab55 Mon Sep 17 00:00:00 2001 From: Patrick Scheid Date: Fri, 12 Jun 2026 04:44:20 +0200 Subject: [PATCH 6/8] Route static StreamEncryption helpers through the streaming path The whole-stream Encrypt/Decrypt helpers buffered the entire input in a MemoryStream and truncated lengths above 2 GiB via an int cast. They now delegate to EncryptStream/DecryptStream, which are byte-for-byte identical and memory-bounded. Also drop the unused InspectCommand.Error. --- Age.Cli/InspectCommand.cs | 3 -- Age/Crypto/StreamEncryption.cs | 77 ++++------------------------------ 2 files changed, 9 insertions(+), 71 deletions(-) diff --git a/Age.Cli/InspectCommand.cs b/Age.Cli/InspectCommand.cs index 92233a7..21a1eab 100644 --- a/Age.Cli/InspectCommand.cs +++ b/Age.Cli/InspectCommand.cs @@ -114,9 +114,6 @@ private static long ComputeOverhead(long encryptedPayload) var totalChunks = fullChunks + (remainder > 0 ? 1 : 0); return PayloadNonceSize + totalChunks * TagSize; } - - private static void Error(string msg) => - Console.Error.WriteLine($"age-inspect: {msg}"); } [JsonSerializable(typeof(InspectOutput))] diff --git a/Age/Crypto/StreamEncryption.cs b/Age/Crypto/StreamEncryption.cs index 31f2b69..0b87ad1 100644 --- a/Age/Crypto/StreamEncryption.cs +++ b/Age/Crypto/StreamEncryption.cs @@ -10,80 +10,21 @@ internal static class StreamEncryption internal const int EncryptedChunkSize = ChunkSize + TagSize; private const int NonceSize = 12; + // Whole-stream convenience wrappers over the chunk format. These route + // through EncryptStream/DecryptStream so they share the memory-bounded + // production path (no full-input buffering) and stay byte-for-byte + // identical to it. The input here carries no header/nonce preamble — just + // the raw STREAM chunks — so an empty preamble is passed. public static void Encrypt(ReadOnlySpan payloadKey, Stream input, Stream output) { - using var inputMs = new MemoryStream(); - input.CopyTo(inputMs); - var inputData = inputMs.GetBuffer().AsSpan(0, (int)inputMs.Length); - - var counter = 0L; - var offset = 0; - - while (true) - { - var remaining = inputData.Length - offset; - var chunkLen = Math.Min(ChunkSize, remaining); - var isFinal = offset + chunkLen >= inputData.Length; - - var plaintext = inputData.Slice(offset, chunkLen); - var ciphertext = EncryptChunk(payloadKey, counter, isFinal, plaintext); - output.Write(ciphertext); - - offset += chunkLen; - counter++; - - if (isFinal) - break; - } + using var stream = new EncryptStream([], [], payloadKey.ToArray(), input); + stream.CopyTo(output); } public static void Decrypt(ReadOnlySpan payloadKey, Stream input, Stream output) { - using var inputMs = new MemoryStream(); - input.CopyTo(inputMs); - var inputData = inputMs.GetBuffer().AsSpan(0, (int)inputMs.Length); - - if (inputData.Length == 0) - throw new AgePayloadException("payload is empty (no chunks)"); - - long counter = 0; - var offset = 0; - - while (offset < inputData.Length) - { - var (chunkLen, isFinal) = NextChunk(inputData, offset); - var ciphertext = inputData.Slice(offset, chunkLen); - var plaintext = DecryptChunk(payloadKey, counter, isFinal, ciphertext); - - if (isFinal && plaintext.Length == 0 && counter > 0) - throw new AgePayloadException("final STREAM chunk is empty but there were preceding chunks"); - - output.Write(plaintext); - offset += chunkLen; - counter++; - - if (!isFinal) - continue; - - if (offset < inputData.Length) - throw new AgePayloadException("data found after final chunk"); - - return; - } - - throw new AgePayloadException("payload ended without a final chunk"); - } - - private static (int ChunkLen, bool IsFinal) NextChunk(ReadOnlySpan data, int offset) - { - var remaining = data.Length - offset; - - var isFinal = remaining <= EncryptedChunkSize; - var chunkLen = isFinal ? remaining : EncryptedChunkSize; - - return chunkLen >= TagSize - ? (chunkLen, isFinal) - : throw new AgePayloadException("chunk too small for authentication tag"); + using var stream = new DecryptStream(payloadKey.ToArray(), input, ownsStream: false); + stream.CopyTo(output); } internal static void EncryptChunk(ChaCha20Poly1305 cipher, long counter, bool isFinal, From 67a2386d7a1f45661886edef2139cf01693f3e44 Mon Sep 17 00:00:00 2001 From: Patrick Scheid Date: Fri, 12 Jun 2026 04:44:20 +0200 Subject: [PATCH 7/8] Refresh benchmarks after security hardening Re-measured microbenchmarks and the CLI comparison on Apple M2. Armored decrypt stays within noise of the pre-hardening baseline (verified via stashed A/B runs with matched conditions). Also corrects allocation figures that no longer matched the unmodified baseline on the current .NET 10 runtime. --- docs/BENCHMARKS.md | 82 +++++++++++++++++++++++++--------------------- 1 file changed, 44 insertions(+), 38 deletions(-) diff --git a/docs/BENCHMARKS.md b/docs/BENCHMARKS.md index 1fba0ae..34aae8b 100644 --- a/docs/BENCHMARKS.md +++ b/docs/BENCHMARKS.md @@ -1,5 +1,7 @@ # Benchmarks +Last updated: 2026-06-12 (Apple M2, .NET 10). + ## CLI Comparison: age (Go) vs rage (Rust) vs age-sharp (C#/.NET) Wall-clock time for encrypt and decrypt at various file sizes, averaged @@ -12,47 +14,51 @@ All times in milliseconds (lower is better). | Size | Op | age (Go) | rage (Rust) | age-sharp (C#) | |---|---|---:|---:|---:| -| 1 KB | enc | 21 ms | 20 ms | 22 ms | -| 1 KB | dec | 21 ms | 20 ms | 21 ms | +| 1 KB | enc | 24 ms | 21 ms | 22 ms | +| 1 KB | dec | 21 ms | 21 ms | 22 ms | | 64 KB | enc | 21 ms | 21 ms | 22 ms | | 64 KB | dec | 21 ms | 21 ms | 22 ms | -| 1 MB | enc | 22 ms | 23 ms | 24 ms | -| 1 MB | dec | 22 ms | 23 ms | 25 ms | -| 10 MB | enc | 34 ms | 47 ms | 41 ms | -| 10 MB | dec | 34 ms | 51 ms | 46 ms | -| 100 MB | enc | 155 ms | 285 ms | 211 ms | -| 100 MB | dec | 144 ms | 324 ms | 242 ms | +| 1 MB | enc | 23 ms | 25 ms | 25 ms | +| 1 MB | dec | 23 ms | 24 ms | 25 ms | +| 10 MB | enc | 36 ms | 48 ms | 42 ms | +| 10 MB | dec | 34 ms | 51 ms | 47 ms | +| 100 MB | enc | 162 ms | 279 ms | 213 ms | +| 100 MB | dec | 144 ms | 316 ms | 246 ms | ### ASCII Armor (-a) | Size | Op | age (Go) | rage (Rust) | age-sharp (C#) | |---|---|---:|---:|---:| -| 1 KB | enc | 21 ms | 20 ms | 22 ms | -| 1 KB | dec | 21 ms | 20 ms | 21 ms | +| 1 KB | enc | 21 ms | 21 ms | 22 ms | +| 1 KB | dec | 21 ms | 21 ms | 22 ms | | 64 KB | enc | 21 ms | 21 ms | 22 ms | -| 64 KB | dec | 21 ms | 22 ms | 22 ms | -| 1 MB | enc | 25 ms | 24 ms | 24 ms | +| 64 KB | dec | 21 ms | 21 ms | 22 ms | +| 1 MB | enc | 26 ms | 24 ms | 24 ms | | 1 MB | dec | 24 ms | 25 ms | 26 ms | -| 10 MB | enc | 67 ms | 55 ms | 45 ms | -| 10 MB | dec | 51 ms | 64 ms | 59 ms | -| 100 MB | enc | 436 ms | 359 ms | 251 ms | -| 100 MB | dec | 319 ms | 450 ms | 383 ms | +| 10 MB | enc | 69 ms | 55 ms | 45 ms | +| 10 MB | dec | 52 ms | 63 ms | 58 ms | +| 100 MB | enc | 460 ms | 357 ms | 251 ms | +| 100 MB | dec | 327 ms | 454 ms | 393 ms | ### Key Takeaways - **Up to 1 MB**: All three implementations are within noise of each other (~20-26 ms), dominated by process startup overhead. -- **Binary 100 MB**: Go still leads at 144-155 ms thanks to assembly-optimized - ChaCha20-Poly1305. AgeSharp (211-242 ms) beats rage (285-324 ms) after +- **Binary 100 MB**: Go still leads at 144-162 ms thanks to assembly-optimized + ChaCha20-Poly1305. AgeSharp (213-246 ms) beats rage (279-316 ms) after switching to .NET's hardware-accelerated `ChaCha20Poly1305`. -- **Armored 100 MB encrypt**: AgeSharp (251 ms) now beats both rage (359 ms) - and Go (436 ms) after routing the push-encrypt path through `EncryptStream` +- **Armored 100 MB encrypt**: AgeSharp (251 ms) beats both rage (357 ms) + and Go (460 ms) after routing the push-encrypt path through `EncryptStream` + `ArmorStream` without any intermediate buffer. - **Bounded memory**: all public push APIs (`Encrypt`, `Decrypt`, `EncryptDetached`, `DecryptDetached`) stream chunk-by-chunk with pooled, fixed-size scratch buffers — no per-chunk heap allocations. Memory stays O(1) regardless of input size; a 1 GiB file uses the same working set as a 1 MB file. +- **Hostile-input bounds are free**: header and armor parsing cap line and + header sizes to prevent pre-authentication memory exhaustion. The armor + bound is enforced with one vectorized scan per buffered read, so armored + decrypt stays within measurement noise of the unbounded implementation. - **Startup**: The AOT-compiled AgeSharp binary starts in ~21 ms, comparable to native Go and Rust binaries. @@ -73,15 +79,15 @@ process startup overhead. | Operation | 1 KB | 64 KB | 1 MB | |---|---:|---:|---:| -| Encrypt | 98 us | 195 us | 2,038 us | -| Decrypt | 95 us | 206 us | 2,088 us | -| Encrypt (armored) | 99 us | 223 us | 2,349 us | -| Decrypt (armored) | 99 us | 281 us | 3,309 us | +| Encrypt | 102 us | 200 us | 2,058 us | +| Decrypt | 99 us | 213 us | 2,080 us | +| Encrypt (armored) | 100 us | 229 us | 2,357 us | +| Decrypt (armored) | 102 us | 284 us | 3,265 us | -Throughput at 1 MB: ~500 MB/s encrypt, ~490 MB/s decrypt. +Throughput at 1 MB: ~510 MB/s encrypt, ~500 MB/s decrypt. Armored adds ~15-60% overhead due to Base64 encoding/decoding. -Allocations at 1 KB are ~12 KB (Encrypt) / ~9 KB (Decrypt) — chunk +Allocations at 1 KB are ~20 KB (Encrypt) / ~17 KB (Decrypt) — chunk scratch buffers are rented from `ArrayPool.Shared` and reused across operations. At 1 MB, most allocation is the output `MemoryStream`'s growth, not the crypto path. @@ -90,21 +96,21 @@ across operations. At 1 MB, most allocation is the output | Operation | Time | Allocated | |---|---:|---:| -| X25519 | 1,919 ns | 880 B | -| ML-KEM-768-X25519 | 246 ns | 88 B | +| X25519 | 1,917 ns | 880 B | +| ML-KEM-768-X25519 | 251 ns | 88 B | ### Recipient Wrap / Unwrap | Operation | Time | Allocated | |---|---:|---:| -| X25519 Wrap | 87 us | 3.6 KB | -| X25519 Unwrap | 84 us | 2.9 KB | -| ML-KEM-768-X25519 Wrap | 132 us | 46.7 KB | -| ML-KEM-768-X25519 Unwrap | 174 us | 69.9 KB | -| Scrypt Wrap | 1,785 us | 1,035 KB | -| Scrypt Unwrap | 1,778 us | 1,035 KB | - -X25519 is the fastest at ~85 us. ML-KEM hybrid adds ~60-100% overhead +| X25519 Wrap | 87 us | 6.5 KB | +| X25519 Unwrap | 86 us | 5.7 KB | +| ML-KEM-768-X25519 Wrap | 133 us | 46.5 KB | +| ML-KEM-768-X25519 Unwrap | 178 us | 70.3 KB | +| Scrypt Wrap | 1,816 us | 1,035 KB | +| Scrypt Unwrap | 1,785 us | 1,035 KB | + +X25519 is the fastest at ~86-87 us. ML-KEM hybrid adds ~50-105% overhead (still sub-200 us). Scrypt is intentionally slow (~1.8 ms) due to the password-hashing work factor. @@ -112,8 +118,8 @@ password-hashing work factor. | Operation | Time | Allocated | |---|---:|---:| -| Sequential Read | 29.7 ms | 32.0 MB | -| Random Read | 31.5 ms | 34.2 MB | +| Sequential Read | 29.2 ms | 32.0 MB | +| Random Read | 30.9 ms | 34.2 MB | Random reads are only ~6% slower than sequential thanks to the chunk-based design. From fe617e46089e2cc55cd2e2bd59b94ee0f67b41fe Mon Sep 17 00:00:00 2001 From: Patrick Scheid Date: Fri, 12 Jun 2026 04:53:12 +0200 Subject: [PATCH 8/8] Collapse duplicate bound check in NewlineBoundedStream slow path Both branches of the newline walk threw the same exception; computing the line length once lets a single check cover them. --- Age/Format/NewlineBoundedStream.cs | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/Age/Format/NewlineBoundedStream.cs b/Age/Format/NewlineBoundedStream.cs index adba8c5..e20f3c0 100644 --- a/Age/Format/NewlineBoundedStream.cs +++ b/Age/Format/NewlineBoundedStream.cs @@ -42,18 +42,17 @@ private void Scan(ReadOnlySpan bytes) while (!bytes.IsEmpty) { var nl = bytes.IndexOfAny((byte)'\n', (byte)'\r'); + var lineLen = nl < 0 ? bytes.Length : nl; + + if (_run + lineLen > maxLineBytes) + throw new AgeArmorException($"armor line exceeds {maxLineBytes} bytes"); if (nl < 0) { _run += bytes.Length; - if (_run > maxLineBytes) - throw new AgeArmorException($"armor line exceeds {maxLineBytes} bytes"); return; } - if (_run + nl > maxLineBytes) - throw new AgeArmorException($"armor line exceeds {maxLineBytes} bytes"); - _run = 0; bytes = bytes[(nl + 1)..]; }