Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 81 additions & 0 deletions Age.Tests/UnitTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -552,6 +552,87 @@ public void Reject_Empty_Body_Lines()
Assert.Throws<AgeArmorException>(() => { 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', AgeLimits.MaxArmorLineBytes + 1000) + "\n";
using var stream = new MemoryStream(Encoding.ASCII.GetBytes(text));
var ex = Assert.Throws<AgeArmorException>(() => { 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', AgeLimits.MaxArmorLineBytes + 1000);
using var stream = new MemoryStream(Encoding.ASCII.GetBytes(text));
Assert.Throws<AgeArmorException>(() => 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<AgeHeaderException>(() => AgeHeader.Parse(stream));
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<AgeHeaderException>(() =>
{
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()
{
Expand Down
42 changes: 42 additions & 0 deletions Age/AgeLimits.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
namespace Age;

/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// The age specification (C2SP <c>age.md</c>) defines no maximum header size,
/// stanza-argument length, or recipient count — a stanza argument is
/// <c>1*VCHAR</c> 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 <c>enc</c> argument) is
/// ~1.5 KiB, and <see cref="MaxHeaderBytes"/> 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.
/// </remarks>
public static class AgeLimits
{
/// <summary>
/// Maximum length, in bytes, of a single header line — the version line, a
/// stanza argument line, or a stanza body line. Default: 64 KiB.
/// </summary>
public const int MaxHeaderLineBytes = 64 * 1024;

/// <summary>
/// Maximum total length, in bytes, of the header — every line up to and
/// including the <c>--- &lt;mac&gt;</c> line. Bounds a header padded out with
/// a huge number of small stanzas. Default: 16 MiB.
/// </summary>
public const int MaxHeaderBytes = 16 * 1024 * 1024;

/// <summary>
/// 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.
/// </summary>
public const int MaxArmorLineBytes = 64 * 1024;
}
5 changes: 4 additions & 1 deletion Age/Format/AsciiArmor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,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, AgeLimits.MaxArmorLineBytes);
var reader = new StreamReader(bounded, Encoding.ASCII, detectEncodingFromByteOrderMarks: false,
bufferSize: 4096, leaveOpen: false);

// Skip leading whitespace (allowed per spec).
Expand Down
9 changes: 9 additions & 0 deletions Age/Format/HeaderReader.cs
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,10 @@ public void PushBack(string line)
break;

ValidateByte(b);

if (lineBytes.Count >= AgeLimits.MaxHeaderLineBytes)
throw new AgeHeaderException($"header line exceeds {AgeLimits.MaxHeaderLineBytes} bytes");

lineBytes.Add((byte)b);
}

Expand All @@ -69,7 +73,12 @@ private int ReadAndTrackByte()
var b = stream.ReadByte();

if (b >= 0)
{
if (_rawBytes.Length >= AgeLimits.MaxHeaderBytes)
throw new AgeHeaderException($"header exceeds {AgeLimits.MaxHeaderBytes} bytes");

_rawBytes.WriteByte((byte)b);
}

return b;
}
Expand Down
84 changes: 84 additions & 0 deletions Age/Format/NewlineBoundedStream.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
namespace Age.Format;

/// <summary>
/// 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 <see cref="StreamReader.ReadLine"/> 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.
/// </summary>
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<byte> buffer)
{
var n = inner.Read(buffer);
Scan(buffer[..n]);
return n;
}

private void Scan(ReadOnlySpan<byte> 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');
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;
return;
}

_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();
}
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading