From 7475f8ebe965ea2e097141da3e758fc09a7511e5 Mon Sep 17 00:00:00 2001
From: Patrick Scheid
Date: Fri, 12 Jun 2026 04:44:02 +0200
Subject: [PATCH 1/3] 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 17ece00..bd50567 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 0c049b895b1ea5084b106612758e589def90847b Mon Sep 17 00:00:00 2001
From: Patrick Scheid
Date: Fri, 12 Jun 2026 04:53:12 +0200
Subject: [PATCH 2/3] 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)..];
}
From 8094842417dd35f7c907883eb699ad64172fdb20 Mon Sep 17 00:00:00 2001
From: Patrick Scheid
Date: Thu, 23 Jul 2026 15:27:04 +0200
Subject: [PATCH 3/3] Surface pre-auth parser limits as public AgeLimits
constants
The header and armor bounds were private constants split across two
parsers. Expose them as AgeLimits.MaxHeaderLineBytes / MaxHeaderBytes /
MaxArmorLineBytes so callers can see the limits and reference them, and
so a configurable override has an obvious home later. Document them in
the README and add direct coverage for the total-header cap.
---
Age.Tests/UnitTests.cs | 55 ++++++++++++++++++++++++++++++++++++--
Age/AgeLimits.cs | 42 +++++++++++++++++++++++++++++
Age/Format/AsciiArmor.cs | 10 +------
Age/Format/HeaderReader.cs | 15 +++--------
README.md | 19 +++++++++++++
5 files changed, 119 insertions(+), 22 deletions(-)
create mode 100644 Age/AgeLimits.cs
diff --git a/Age.Tests/UnitTests.cs b/Age.Tests/UnitTests.cs
index bd50567..7039aa6 100644
--- a/Age.Tests/UnitTests.cs
+++ b/Age.Tests/UnitTests.cs
@@ -557,7 +557,7 @@ 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";
+ var text = "-----BEGIN AGE ENCRYPTED FILE-----\n" + new string('A', AgeLimits.MaxArmorLineBytes + 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);
@@ -567,7 +567,7 @@ public void Reject_Oversized_Armor_Line()
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);
+ var text = new string('x', AgeLimits.MaxArmorLineBytes + 1000);
using var stream = new MemoryStream(Encoding.ASCII.GetBytes(text));
Assert.Throws(() => AsciiArmor.Dearmor(stream));
}
@@ -582,6 +582,57 @@ public void Reject_Oversized_Header_Line()
Assert.Contains("exceeds", ex.Message);
}
+ [Fact]
+ public void Reject_Oversized_Header_Total()
+ {
+ // Many individually-short lines — none tripping the per-line cap — must
+ // still be bounded in total, so a header padded with a huge number of
+ // small (structurally valid) stanzas can't exhaust memory before the MAC
+ // is checked. Drive HeaderReader directly with an endless run of short
+ // lines: the total-bytes cap must stop it well before it reads forever.
+ using var stream = new RepeatingLineStream(lineLength: 64);
+ var reader = new HeaderReader(stream);
+ var ex = Assert.Throws(() =>
+ {
+ while (reader.ReadLine() is not null) { }
+ });
+ Assert.Contains("exceeds", ex.Message);
+ }
+
+ // Emits an unbounded sequence of identical short lines ("xxx…\n"), each below
+ // the per-line cap, so only the total-header cap can terminate a read loop.
+ private sealed class RepeatingLineStream(int lineLength) : Stream
+ {
+ private readonly byte[] _line = BuildLine(lineLength);
+ private long _pos;
+
+ private static byte[] BuildLine(int length)
+ {
+ var line = new byte[length];
+ Array.Fill(line, (byte)'x');
+ line[length - 1] = (byte)'\n';
+ return line;
+ }
+
+ public override int Read(byte[] buffer, int offset, int count)
+ {
+ for (var i = 0; i < count; i++)
+ buffer[offset + i] = _line[(_pos + i) % _line.Length];
+ _pos += count;
+ return count;
+ }
+
+ 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 => _pos; 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();
+ }
+
[Fact]
public void Dearmor_Skips_Leading_Whitespace()
{
diff --git a/Age/AgeLimits.cs b/Age/AgeLimits.cs
new file mode 100644
index 0000000..3fb34f2
--- /dev/null
+++ b/Age/AgeLimits.cs
@@ -0,0 +1,42 @@
+namespace Age;
+
+///
+/// Resource limits applied while reading an age file's header and ASCII armor,
+/// before any bytes are authenticated. They exist only to stop a hostile or
+/// malformed stream from exhausting memory: the header must be buffered whole to
+/// verify its MAC, so without a ceiling an unterminated or endlessly repeated
+/// line could be read until the process runs out of memory.
+///
+///
+/// The age specification (C2SP age.md) defines no maximum header size,
+/// stanza-argument length, or recipient count — a stanza argument is
+/// 1*VCHAR with no upper bound — so these are AgeSharp's own defensive
+/// limits, not a spec requirement. They are set far above any real age file:
+/// the largest built-in stanza line (an ML-KEM-768 enc argument) is
+/// ~1.5 KiB, and still permits well over a hundred
+/// thousand recipients. If a legitimate file ever trips one of these, that is
+/// the signal to make them configurable rather than to remove them.
+///
+public static class AgeLimits
+{
+ ///
+ /// Maximum length, in bytes, of a single header line — the version line, a
+ /// stanza argument line, or a stanza body line. Default: 64 KiB.
+ ///
+ public const int MaxHeaderLineBytes = 64 * 1024;
+
+ ///
+ /// Maximum total length, in bytes, of the header — every line up to and
+ /// including the --- <mac> line. Bounds a header padded out with
+ /// a huge number of small stanzas. Default: 16 MiB.
+ ///
+ public const int MaxHeaderBytes = 16 * 1024 * 1024;
+
+ ///
+ /// Maximum length, in bytes, of a single ASCII-armor line. A spec-compliant
+ /// armor line is at most 64 characters; the ceiling is set high so it only
+ /// ever rejects a hostile unterminated line, never legitimate input.
+ /// Default: 64 KiB.
+ ///
+ public const int MaxArmorLineBytes = 64 * 1024;
+}
diff --git a/Age/Format/AsciiArmor.cs b/Age/Format/AsciiArmor.cs
index 4001138..7f928f8 100644
--- a/Age/Format/AsciiArmor.cs
+++ b/Age/Format/AsciiArmor.cs
@@ -8,14 +8,6 @@ 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)
@@ -53,7 +45,7 @@ public static Stream Dearmor(Stream input)
{
// 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 bounded = new NewlineBoundedStream(input, AgeLimits.MaxArmorLineBytes);
var reader = new StreamReader(bounded, Encoding.ASCII, detectEncodingFromByteOrderMarks: false,
bufferSize: 4096, leaveOpen: false);
diff --git a/Age/Format/HeaderReader.cs b/Age/Format/HeaderReader.cs
index 5a52038..848d547 100644
--- a/Age/Format/HeaderReader.cs
+++ b/Age/Format/HeaderReader.cs
@@ -9,13 +9,6 @@ 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;
@@ -66,8 +59,8 @@ public void PushBack(string line)
ValidateByte(b);
- if (lineBytes.Count >= MaxLineLength)
- throw new AgeHeaderException($"header line exceeds {MaxLineLength} bytes");
+ if (lineBytes.Count >= AgeLimits.MaxHeaderLineBytes)
+ throw new AgeHeaderException($"header line exceeds {AgeLimits.MaxHeaderLineBytes} bytes");
lineBytes.Add((byte)b);
}
@@ -81,8 +74,8 @@ private int ReadAndTrackByte()
if (b >= 0)
{
- if (_rawBytes.Length >= MaxHeaderLength)
- throw new AgeHeaderException($"header exceeds {MaxHeaderLength} bytes");
+ if (_rawBytes.Length >= AgeLimits.MaxHeaderBytes)
+ throw new AgeHeaderException($"header exceeds {AgeLimits.MaxHeaderBytes} bytes");
_rawBytes.WriteByte((byte)b);
}
diff --git a/README.md b/README.md
index e1b6216..7a7c056 100644
--- a/README.md
+++ b/README.md
@@ -218,6 +218,25 @@ public class MyIdentity : IIdentity
}
```
+### Parsing limits
+
+An age header must be buffered in full before its MAC can be verified, so
+AgeSharp caps how much it will read before authentication — otherwise a hostile
+or truncated stream with an unterminated (or endlessly repeated) line could
+exhaust memory. The limits are exposed as constants on `AgeLimits`:
+
+| Constant | Default | Bounds |
+| --- | --- | --- |
+| `AgeLimits.MaxHeaderLineBytes` | 64 KiB | A single header line |
+| `AgeLimits.MaxHeaderBytes` | 16 MiB | The whole header (all stanzas) |
+| `AgeLimits.MaxArmorLineBytes` | 64 KiB | A single ASCII-armor line |
+
+Exceeding a limit throws `AgeHeaderException` / `AgeArmorException`. The age
+[specification](https://github.com/C2SP/C2SP/blob/main/age.md) sets no such
+bounds, so these are AgeSharp's own defense; they sit far above any real file
+(the largest built-in stanza line is ~1.5 KiB, and 16 MiB still allows well over
+100,000 recipients), so legitimate input never trips them.
+
## CLI
`AgeSharp` ships a CLI compatible with the `age` command.