Skip to content
14 changes: 11 additions & 3 deletions Age.Cli/AgeCommand.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Security.Cryptography;
using System.Text;
using Age.Format;
using Age.Plugin;
Expand Down Expand Up @@ -117,7 +118,13 @@ private static List<IIdentity> 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;
Expand Down Expand Up @@ -201,14 +208,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);
}

Expand Down
3 changes: 0 additions & 3 deletions Age.Cli/InspectCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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))]
Expand Down
30 changes: 30 additions & 0 deletions Age.Tests/PluginTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -844,6 +844,36 @@ public void PluginRecipient_ExtractPluginName_InvalidHrp_Throws()
Assert.Throws<FormatException>(() => 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<FormatException>(() => 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<FormatException>(() => 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]
Expand Down
52 changes: 52 additions & 0 deletions Age.Tests/UnitTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -552,6 +552,36 @@ 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', AsciiArmor.MaxLineBytes + 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', AsciiArmor.MaxLineBytes + 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 Dearmor_Skips_Leading_Whitespace()
{
Expand Down Expand Up @@ -829,6 +859,28 @@ public void Unwrap_Rejects_WorkFactor_Over_20()
Assert.Throws<AgeHeaderException>(() => 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<ArgumentOutOfRangeException>(() => 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()
{
Expand Down
77 changes: 9 additions & 68 deletions Age/Crypto/StreamEncryption.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<byte> 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<byte> 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<byte> 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,
Expand Down
13 changes: 12 additions & 1 deletion Age/Format/AsciiArmor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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).
Expand Down
16 changes: 16 additions & 0 deletions Age/Format/HeaderReader.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,13 @@ namespace Age.Format;
/// </summary>
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;

Expand Down Expand Up @@ -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);
}

Expand All @@ -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;
}
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();
}
Loading
Loading