diff --git a/Age.Cli/AgeCommand.cs b/Age.Cli/AgeCommand.cs index 27acb67..4100407 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; @@ -117,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; @@ -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); } 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.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.Tests/UnitTests.cs b/Age.Tests/UnitTests.cs index dd69e71..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() { @@ -829,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/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, 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..e20f3c0 --- /dev/null +++ b/Age/Format/NewlineBoundedStream.cs @@ -0,0 +1,84 @@ +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'); + 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(); +} 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() => 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) 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.