diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 6dc2cbc..74536d6 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -64,12 +64,16 @@ jobs:
- name: Test
run: dotnet test --no-build --collect:"XPlat Code Coverage"
- # Coverage is identical across platforms; upload once from Linux to avoid duplicate reports
+ # Upload from every platform and let Codecov merge them. This used to run on Linux alone,
+ # on the assumption that coverage is identical everywhere — which stopped being true once
+ # PluginLocator gained genuinely platform-specific code (PATHEXT expansion and the Windows
+ # branch of the executable check simply cannot execute on Linux). A Linux-only report shows
+ # those lines as untested when they are in fact covered by the Windows job.
- name: Upload coverage to Codecov
- if: matrix.os == 'ubuntu-latest'
uses: codecov/codecov-action@v5
with:
token: ${{ secrets.CODECOV_TOKEN }}
+ flags: ${{ matrix.os }}
# Runs the whole suite + CCTV vectors through the managed BouncyCastle AEAD backend —
# the same path Blazor/WASM takes. Managed code is OS-independent, so one OS is enough.
diff --git a/.gitignore b/.gitignore
index 2e00ea1..245eeea 100644
--- a/.gitignore
+++ b/.gitignore
@@ -26,3 +26,7 @@ BenchmarkDotNet.Artifacts/
.DS_Store
Thumbs.db
dist/
+
+## Reference implementations, cloned locally to read while working.
+## Nested git repos — tracking them would create broken gitlinks.
+/references/
diff --git a/Age.Benchmarks/KeyGenBenchmarks.cs b/Age.Benchmarks/KeyGenBenchmarks.cs
index 306bc8f..9a37471 100644
--- a/Age.Benchmarks/KeyGenBenchmarks.cs
+++ b/Age.Benchmarks/KeyGenBenchmarks.cs
@@ -3,6 +3,17 @@
namespace Age.Benchmarks;
+///
+/// Key generation, measured two ways because the two types put the work in different places.
+///
+///
+/// X25519 does its keygen in Generate and derives the recipient from an already-computed
+/// public key. ML-KEM-768-X25519 is the reverse: Generate only fills a 32-byte seed, and the
+/// ML-KEM keygen runs on first access to Recipient (cached thereafter). Comparing the two
+/// Generate calls alone therefore reports post-quantum keygen as the faster of the two,
+/// which is backwards — it has merely not happened yet. The …ToRecipient pair is the comparable
+/// number: from nothing to a usable public key.
+///
[MemoryDiagnoser]
public class KeyGenBenchmarks
{
@@ -11,4 +22,10 @@ public class KeyGenBenchmarks
[Benchmark]
public MlKem768X25519Identity MlKem768X25519Generate() => MlKem768X25519Identity.Generate();
+
+ [Benchmark]
+ public X25519Recipient X25519ToRecipient() => X25519Identity.Generate().Recipient;
+
+ [Benchmark]
+ public MlKem768X25519Recipient MlKem768X25519ToRecipient() => MlKem768X25519Identity.Generate().Recipient;
}
diff --git a/Age.Tests/AgreementGuardTests.cs b/Age.Tests/AgreementGuardTests.cs
new file mode 100644
index 0000000..8b93e0b
--- /dev/null
+++ b/Age.Tests/AgreementGuardTests.cs
@@ -0,0 +1,128 @@
+using System.Text;
+using Age.Crypto;
+using Age.Recipients;
+using Org.BouncyCastle.Crypto.Parameters;
+using Org.BouncyCastle.Security;
+using Xunit;
+
+namespace Age.Tests;
+
+///
+/// C8 / C9 / C10 — all eight X25519 agreement sites now go through one guarded helper. This is
+/// defence in depth plus a consistent exception type, not the closing of an exploitable hole:
+/// BouncyCastle already refuses low-order points, so no zero shared secret was ever used. What
+/// was broken is the exception contract — five sites let a raw InvalidOperationException
+/// escape, so a caller catching to handle hostile files crashed, and
+/// the CLI reported a merely malformed input file as "This is a bug".
+///
+public class AgreementGuardTests
+{
+ // Canonical low-order and identity points for Curve25519.
+ public static TheoryData LowOrderPoints() => new()
+ {
+ { "all zeroes (identity)", new string('0', 64) },
+ { "u = 1", "0100000000000000000000000000000000000000000000000000000000000000" },
+ { "order 8", "e0eb7a7c3b41b8ae1656e3faf19fc46ada098deb9c32b1fd866205165f49b800" },
+ { "order 4", "5f9c95bca3508c24b1d0b1559c83ef5b04445cc4581c8e86d8224eddd09f1157" },
+ { "p - 1", "ecffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f" },
+ };
+
+ [Theory]
+ [MemberData(nameof(LowOrderPoints))]
+ public void X25519Agree_RejectsLowOrderPoints(string name, string pointHex)
+ {
+ Assert.NotEmpty(name);
+
+ var privateKey = new X25519PrivateKeyParameters(new SecureRandom());
+ var point = new X25519PublicKeyParameters(Convert.FromHexString(pointHex));
+ var sharedSecret = new byte[CryptoHelper.X25519SharedSecretSize];
+
+ var ex = Assert.Throws(
+ () => CryptoHelper.X25519Agree(privateKey, point, sharedSecret));
+
+ Assert.Contains("all-zero", ex.Message, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void X25519Agree_AcceptsAnHonestPeer()
+ {
+ var a = new X25519PrivateKeyParameters(new SecureRandom());
+ var b = new X25519PrivateKeyParameters(new SecureRandom());
+
+ var ab = new byte[CryptoHelper.X25519SharedSecretSize];
+ var ba = new byte[CryptoHelper.X25519SharedSecretSize];
+
+ CryptoHelper.X25519Agree(a, b.GeneratePublicKey(), ab);
+ CryptoHelper.X25519Agree(b, a.GeneratePublicKey(), ba);
+
+ Assert.Equal(ab, ba);
+ Assert.Contains(ab, x => x != 0);
+ }
+
+ // End to end through the public API: a tampered ephemeral share must surface as an
+ // AgeException, which is what a caller is documented to catch.
+ [Theory]
+ [MemberData(nameof(LowOrderPoints))]
+ public void TamperedEphemeralShare_IsCatchableAsAgeException(string name, string pointHex)
+ {
+ Assert.NotEmpty(name);
+
+ using var identity = X25519Identity.Generate();
+
+ using var input = new MemoryStream("guarded"u8.ToArray());
+ using var encrypted = new MemoryStream();
+ AgeEncrypt.Encrypt(input, encrypted, identity.Recipient);
+
+ var tampered = ReplaceFirstStanzaArg(encrypted.ToArray(), "X25519",
+ Base64Unpadded.Encode(Convert.FromHexString(pointHex)));
+
+ var ex = Record.Exception(() =>
+ {
+ using var source = new MemoryStream(tampered);
+ using var output = new MemoryStream();
+ AgeEncrypt.Decrypt(source, output, identity);
+ });
+
+ Assert.NotNull(ex);
+ Assert.IsAssignableFrom(ex);
+ }
+
+ // A recipient can carry a low-order point too, so the encrypt side is guarded as well.
+ [Theory]
+ [MemberData(nameof(LowOrderPoints))]
+ public void RecipientCarryingALowOrderPoint_IsCatchableAsAgeException(string name, string pointHex)
+ {
+ Assert.NotEmpty(name);
+
+ var recipient = X25519Recipient.Parse(Bech32.Encode("age", Convert.FromHexString(pointHex)));
+
+ var ex = Record.Exception(() =>
+ {
+ using var input = new MemoryStream("guarded"u8.ToArray());
+ using var output = new MemoryStream();
+ AgeEncrypt.Encrypt(input, output, recipient);
+ });
+
+ Assert.NotNull(ex);
+ Assert.IsAssignableFrom(ex);
+ }
+
+ private static byte[] ReplaceFirstStanzaArg(byte[] file, string stanzaType, string replacement)
+ {
+ var lines = Encoding.ASCII.GetString(file).Split('\n');
+
+ for (var i = 0; i < lines.Length; i++)
+ {
+ if (!lines[i].StartsWith($"-> {stanzaType} ", StringComparison.Ordinal))
+ continue;
+
+ var parts = lines[i].Split(' ');
+ parts[2] = replacement;
+ lines[i] = string.Join(' ', parts);
+
+ return Encoding.ASCII.GetBytes(string.Join('\n', lines));
+ }
+
+ throw new InvalidOperationException($"no {stanzaType} stanza found");
+ }
+}
diff --git a/Age.Tests/ArmorHardeningTests.cs b/Age.Tests/ArmorHardeningTests.cs
new file mode 100644
index 0000000..f90435d
--- /dev/null
+++ b/Age.Tests/ArmorHardeningTests.cs
@@ -0,0 +1,89 @@
+using System.Text;
+using Age.Recipients;
+using Xunit;
+
+namespace Age.Tests;
+
+///
+/// H9 / H11 — armor input hardening, bringing main in line with both reference implementations.
+///
+public class ArmorHardeningTests
+{
+ private static byte[] Armored(X25519Recipient recipient, byte[] plaintext)
+ {
+ using var input = new MemoryStream(plaintext);
+ using var output = new MemoryStream();
+ AgeEncrypt.Encrypt(input, output, armor: true, recipient);
+ return output.ToArray();
+ }
+
+ private static byte[] Decrypt(byte[] file, IIdentity identity)
+ {
+ using var input = new MemoryStream(file);
+ using var output = new MemoryStream();
+ AgeEncrypt.Decrypt(input, output, identity);
+ return output.ToArray();
+ }
+
+ // H9. There is no explicit CR guard and deliberately so — StreamReader.ReadLine splits on a
+ // lone CR, so a returned line can never contain one and any check would be dead code. The CR
+ // is still rejected, because the fragments it creates fail the line-width rules. This test
+ // pins that outcome rather than proving a fix.
+ [Fact]
+ public void BareCarriageReturnInArmorBody_IsRejected()
+ {
+ using var identity = X25519Identity.Generate();
+ var armored = Encoding.ASCII.GetString(Armored(identity.Recipient, "hello armor"u8.ToArray()));
+
+ // Split one body line with a bare CR rather than a newline.
+ var lines = armored.Split('\n');
+ var bodyIndex = Array.FindIndex(lines, l => l.Length > 8 && !l.StartsWith("-----", StringComparison.Ordinal));
+ lines[bodyIndex] = lines[bodyIndex][..4] + "\r" + lines[bodyIndex][4..];
+
+ var tampered = Encoding.ASCII.GetBytes(string.Join('\n', lines));
+
+ Assert.Throws(() => Decrypt(tampered, identity));
+ }
+
+ // CRLF is legitimate and must keep working — StreamReader consumes it as one terminator, so
+ // no CR survives into the line.
+ [Fact]
+ public void CrlfLineEndings_StillDecrypt()
+ {
+ using var identity = X25519Identity.Generate();
+ var plaintext = "hello armor"u8.ToArray();
+
+ var armored = Encoding.ASCII.GetString(Armored(identity.Recipient, plaintext));
+ var crlf = Encoding.ASCII.GetBytes(armored.Replace("\n", "\r\n"));
+
+ Assert.Equal(plaintext, Decrypt(crlf, identity));
+ }
+
+ [Fact]
+ public void ModestLeadingWhitespace_IsStillAccepted()
+ {
+ using var identity = X25519Identity.Generate();
+ var plaintext = "hello armor"u8.ToArray();
+
+ var padded = (byte[]) [.. Encoding.ASCII.GetBytes(new string('\n', 8)), .. Armored(identity.Recipient, plaintext)];
+
+ Assert.Equal(plaintext, Decrypt(padded, identity));
+ }
+
+ // Without a bound, a file that is nothing but newlines is read to its end before the header
+ // is even looked for.
+ [Fact]
+ public void UnboundedLeadingWhitespace_IsRejected()
+ {
+ using var identity = X25519Identity.Generate();
+
+ var flood = (byte[])
+ [
+ .. Encoding.ASCII.GetBytes(new string('\n', 64 * 1024)),
+ .. Armored(identity.Recipient, "hello armor"u8.ToArray()),
+ ];
+
+ var ex = Assert.Throws(() => Decrypt(flood, identity));
+ Assert.Contains("whitespace", ex.Message, StringComparison.Ordinal);
+ }
+}
diff --git a/Age.Tests/ArmoredOnAPipeTests.cs b/Age.Tests/ArmoredOnAPipeTests.cs
new file mode 100644
index 0000000..8af85d0
--- /dev/null
+++ b/Age.Tests/ArmoredOnAPipeTests.cs
@@ -0,0 +1,87 @@
+using Age.Recipients;
+using Xunit;
+
+namespace Age.Tests;
+
+///
+/// I1 (message half) — armor is auto-detected only on a seekable stream, so armored input from a
+/// pipe reaches the binary header parser intact and its BEGIN marker was reported as an
+/// "unsupported version". Supporting non-seekable armor outright widens what a patch release
+/// accepts, against the documented behaviour, so only the diagnosis is fixed here.
+///
+public class ArmoredOnAPipeTests
+{
+ // A stream that refuses to seek, like a pipe or a network socket.
+ private sealed class NonSeekableStream(byte[] data) : Stream
+ {
+ private readonly MemoryStream _inner = new(data);
+
+ 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 int Read(byte[] buffer, int offset, int count) => _inner.Read(buffer, offset, count);
+ 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 ArmoredInputOnANonSeekableStream_SaysSoInsteadOfBlamingTheVersion()
+ {
+ using var identity = X25519Identity.Generate();
+
+ using var input = new MemoryStream("hello"u8.ToArray());
+ using var armored = new MemoryStream();
+ AgeEncrypt.Encrypt(input, armored, armor: true, identity.Recipient);
+
+ using var pipe = new NonSeekableStream(armored.ToArray());
+ using var output = new MemoryStream();
+
+ var ex = Assert.Throws(() => AgeEncrypt.Decrypt(pipe, output, identity));
+
+ Assert.Contains("ASCII-armored", ex.Message, StringComparison.Ordinal);
+ Assert.DoesNotContain("unsupported version", ex.Message, StringComparison.Ordinal);
+ }
+
+ // A genuinely wrong version line must still report a version problem.
+ [Fact]
+ public void AnActuallyUnsupportedVersion_StillReportsTheVersion()
+ {
+ using var identity = X25519Identity.Generate();
+ var file = System.Text.Encoding.ASCII.GetBytes("age-encryption.org/v2\n--- AAAA\n");
+
+ using var source = new MemoryStream(file);
+ using var output = new MemoryStream();
+
+ var ex = Assert.Throws(() => AgeEncrypt.Decrypt(source, output, identity));
+
+ Assert.Contains("unsupported version", ex.Message, StringComparison.Ordinal);
+ }
+
+ // Armored input on a seekable stream is detected and decrypts normally — unchanged.
+ [Fact]
+ public void ArmoredInputOnASeekableStream_StillDecrypts()
+ {
+ using var identity = X25519Identity.Generate();
+ var plaintext = "hello"u8.ToArray();
+
+ using var input = new MemoryStream(plaintext);
+ using var armored = new MemoryStream();
+ AgeEncrypt.Encrypt(input, armored, armor: true, identity.Recipient);
+
+ using var source = new MemoryStream(armored.ToArray());
+ using var output = new MemoryStream();
+ AgeEncrypt.Decrypt(source, output, identity);
+
+ Assert.Equal(plaintext, output.ToArray());
+ }
+}
diff --git a/Age.Tests/BackportEdgeCaseTests.cs b/Age.Tests/BackportEdgeCaseTests.cs
new file mode 100644
index 0000000..d19e498
--- /dev/null
+++ b/Age.Tests/BackportEdgeCaseTests.cs
@@ -0,0 +1,274 @@
+using Age.Crypto;
+using Age.Format;
+using Age.Recipients;
+using Xunit;
+
+namespace Age.Tests;
+
+///
+/// Guards on the backported paths that the ordinary round-trip tests never reach: a custom
+/// identity returning the wrong-sized file key, random access over a truncated payload, and the
+/// encode overload's own bounds check.
+///
+public class BackportEdgeCaseTests
+{
+ // A custom or plugin identity is caller-supplied code and can hand back anything. Accepting a
+ // wrong-sized key would derive garbage rather than fail, so the guard runs before the header
+ // MAC is verified and must clear the key on the way out (S9).
+ private sealed class WrongSizedKeyIdentity : IIdentity
+ {
+ public byte[]? Unwrap(Stanza stanza) => new byte[15];
+ }
+
+ // age-plugin.md:227 requires the exact same label set across every stanza wrapping one file
+ // key. Wrap throws, so these also prove the check runs before any wrapping happens.
+ private sealed class LabelledRecipient(string? label) : IRecipient
+ {
+ public string? Label => label;
+
+ public Stanza Wrap(ReadOnlySpan fileKey) =>
+ throw new NotSupportedException("label check should have rejected this first");
+ }
+
+ [Theory]
+ [InlineData("postquantum", null)]
+ [InlineData(null, "postquantum")]
+ [InlineData("postquantum", "acme-internal")]
+ public void MixingPostQuantumWithAnythingElse_NamesWhatIsLost(string? a, string? b)
+ {
+ var ex = Assert.Throws(() => Encrypt(a, b));
+
+ Assert.Contains("post-quantum and classical", ex.Message, StringComparison.Ordinal);
+ Assert.Contains("quantum computer", ex.Message, StringComparison.Ordinal);
+ }
+
+ [Theory]
+ [InlineData("acme-internal", null, "\"acme-internal\"", "none")]
+ [InlineData("acme-internal", "globex", "\"acme-internal\"", "\"globex\"")]
+ public void MixingOtherLabels_NamesBothSides(string? a, string? b, string expectedA, string expectedB)
+ {
+ var ex = Assert.Throws(() => Encrypt(a, b));
+
+ Assert.Contains(expectedA, ex.Message, StringComparison.Ordinal);
+ Assert.Contains(expectedB, ex.Message, StringComparison.Ordinal);
+ Assert.DoesNotContain("quantum", ex.Message, StringComparison.Ordinal);
+ }
+
+ private static void Encrypt(string? a, string? b)
+ {
+ using var input = new MemoryStream("x"u8.ToArray());
+ using var output = new MemoryStream();
+
+ AgeEncrypt.Encrypt(input, output, new LabelledRecipient(a), new LabelledRecipient(b));
+ }
+
+ [Fact]
+ public void FileKey_ZeroesOnDispose()
+ {
+ var key = FileKey.Fresh();
+ var live = key.Bytes.ToArray();
+
+ Assert.Contains(live, b => b != 0);
+
+ key.Dispose();
+
+ Assert.Throws(() => key.Bytes.ToArray());
+ key.Dispose(); // idempotent
+ }
+
+ [Fact]
+ public void FileKey_AdoptZeroesWhatItRejects()
+ {
+ var wrongSized = new byte[15];
+ Array.Fill(wrongSized, (byte)0xAB);
+
+ Assert.Throws(() => FileKey.Adopt(wrongSized));
+
+ // The rejected array came from caller-supplied identity code and still held key
+ // material; refusing it must not mean abandoning it.
+ Assert.DoesNotContain(wrongSized, b => b != 0);
+ }
+
+ [Fact]
+ public void IdentityReturningAWrongSizedFileKey_IsRejected()
+ {
+ using var real = X25519Identity.Generate();
+
+ using var input = new MemoryStream("edge"u8.ToArray());
+ using var encrypted = new MemoryStream();
+ AgeEncrypt.Encrypt(input, encrypted, real.Recipient);
+
+ using var source = new MemoryStream(encrypted.ToArray());
+ using var output = new MemoryStream();
+
+ var ex = Assert.Throws(
+ () => AgeEncrypt.Decrypt(source, output, new WrongSizedKeyIdentity()));
+
+ Assert.Contains("file key must be", ex.Message, StringComparison.Ordinal);
+ }
+
+ // S9 — a recipient that throws mid-wrap is routine: a plugin binary missing, a user declining
+ // a touch prompt. The file key is already generated by then, so every one of those paths has
+ // to clear it. Captured here by handing the recipient the key it is asked to wrap and then
+ // throwing, so the test can inspect what the library left behind.
+ private sealed class ThrowingRecipient : IRecipient
+ {
+ public byte[]? SeenFileKey { get; private set; }
+
+ public string? Label => null;
+
+ public Stanza Wrap(ReadOnlySpan fileKey)
+ {
+ SeenFileKey = fileKey.ToArray();
+ throw new AgePluginException("recipient declined");
+ }
+ }
+
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public void WhenARecipientThrows_TheFileKeyIsCleared(bool detached)
+ {
+ var recipient = new ThrowingRecipient();
+
+ Assert.Throws(() =>
+ {
+ using var input = new MemoryStream("x"u8.ToArray());
+ using var a = new MemoryStream();
+ using var b = new MemoryStream();
+
+ if (detached)
+ AgeEncrypt.EncryptDetached(input, a, b, recipient);
+ else
+ AgeEncrypt.Encrypt(input, a, recipient);
+ });
+
+ // The recipient saw a real, non-zero key...
+ Assert.NotNull(recipient.SeenFileKey);
+ Assert.Contains(recipient.SeenFileKey!, b => b != 0);
+
+ // ...and the library's own copy must not still be live. Verified by wrapping again and
+ // confirming a fresh key each time, so a retained buffer would be visible as a repeat.
+ var second = new ThrowingRecipient();
+
+ Assert.Throws(() =>
+ {
+ using var input = new MemoryStream("x"u8.ToArray());
+ using var a = new MemoryStream();
+ AgeEncrypt.Encrypt(input, a, second);
+ });
+
+ Assert.NotEqual(recipient.SeenFileKey, second.SeenFileKey);
+ }
+
+ // --- Random access over damaged payloads: the same rejections the forward-only path makes ---
+
+ private static byte[] Encrypted(X25519Identity identity, int size)
+ {
+ var plaintext = new byte[size];
+ for (var i = 0; i < size; i++) plaintext[i] = (byte)(i * 31 % 251);
+
+ using var input = new MemoryStream(plaintext);
+ using var output = new MemoryStream();
+ AgeEncrypt.Encrypt(input, output, identity.Recipient);
+
+ return output.ToArray();
+ }
+
+ [Fact]
+ public void RandomAccess_OverAPayloadWithNoChunks_IsRejected()
+ {
+ using var identity = X25519Identity.Generate();
+ var file = Encrypted(identity, 32);
+
+ // Keep the header and the 16-byte payload nonce, drop every chunk.
+ var headerEnd = FindPayloadStart(file);
+ var truncated = file[..(headerEnd + 16)];
+
+ Assert.Throws(() =>
+ {
+ using var source = new MemoryStream(truncated);
+ using var reader = new AgeRandomAccess(source, identity);
+ });
+ }
+
+ [Fact]
+ public void RandomAccess_OverAChunkTooSmallForItsTag_IsRejected()
+ {
+ using var identity = X25519Identity.Generate();
+ var file = Encrypted(identity, 32);
+
+ // A payload of fewer than 16 bytes cannot even hold an authentication tag.
+ var headerEnd = FindPayloadStart(file);
+ var truncated = file[..(headerEnd + 16 + 8)];
+
+ Assert.Throws(() =>
+ {
+ using var source = new MemoryStream(truncated);
+ using var reader = new AgeRandomAccess(source, identity);
+ });
+ }
+
+ // The payload begins immediately after the header's MAC line.
+ private static int FindPayloadStart(byte[] file)
+ {
+ var text = System.Text.Encoding.ASCII.GetString(file);
+ var macLine = text.IndexOf("--- ", StringComparison.Ordinal);
+
+ return text.IndexOf('\n', macLine) + 1;
+ }
+
+ // H6 — deriving the post-quantum recipient runs a full ML-KEM-768 key generation, and it was
+ // re-run on every access, so decrypting an N-stanza header cost N keygens. Caching makes that
+ // one. A bounded constant factor rather than a denial-of-service vector, but free to fix.
+ [Fact]
+ public void PostQuantumRecipient_IsDerivedOnceAndReused()
+ {
+ using var identity = MlKem768X25519Identity.Generate();
+
+ var first = identity.Recipient;
+ var second = identity.Recipient;
+
+ Assert.Same(first, second);
+ Assert.Equal(first.ToString(), second.ToString());
+ }
+
+ [Fact]
+ public void PostQuantumRecipient_IsStillGuardedAfterDispose()
+ {
+ var identity = MlKem768X25519Identity.Generate();
+ _ = identity.Recipient; // populate the cache first
+
+ identity.Dispose();
+
+ // The cache must not become a way to reach a disposed identity's derived key.
+ Assert.Throws(() => identity.Recipient);
+ }
+
+ // --- The span-filling encoder's own guard ---
+
+ [Fact]
+ public void EncodeIntoTooSmallABuffer_Throws()
+ {
+ var data = new byte[32];
+
+ Assert.Throws(() => Base64Unpadded.Encode(data, new char[4]));
+ }
+
+ [Fact]
+ public void EncodeIntoAnExactlySizedBuffer_Works()
+ {
+ var data = new byte[16];
+ var destination = new char[Base64Unpadded.MaxEncodedLength(data.Length)];
+
+ var written = Base64Unpadded.Encode(data, destination);
+
+ Assert.Equal(Base64Unpadded.Encode(data), new string(destination, 0, written));
+ }
+
+ [Fact]
+ public void EncodeOfNothingWritesNothing()
+ {
+ Assert.Equal(0, Base64Unpadded.Encode([], new char[4]));
+ }
+}
diff --git a/Age.Tests/EmptyArgumentTests.cs b/Age.Tests/EmptyArgumentTests.cs
new file mode 100644
index 0000000..95dd7e5
--- /dev/null
+++ b/Age.Tests/EmptyArgumentTests.cs
@@ -0,0 +1,139 @@
+using Age.Recipients;
+using Xunit;
+
+namespace Age.Tests;
+
+///
+/// Encrypting to nobody, or decrypting with nothing, is a caller mistake rather than a format
+/// error: it can only ever fail. Every public entry point rejects it at the door, naming the
+/// argument, instead of letting it surface later as a header with no stanzas or as
+/// . Six entry points enforced this and none was tested.
+///
+public class EmptyArgumentTests
+{
+ private static MemoryStream Empty() => new();
+
+ private static byte[] SomeCiphertext(X25519Identity identity)
+ {
+ using var input = new MemoryStream("x"u8.ToArray());
+ using var output = new MemoryStream();
+ AgeEncrypt.Encrypt(input, output, identity.Recipient);
+ return output.ToArray();
+ }
+
+ [Fact]
+ public void Encrypt_WithNoRecipients_Throws()
+ {
+ var ex = Assert.Throws(() =>
+ AgeEncrypt.Encrypt(Empty(), Empty(), ReadOnlySpan.Empty));
+
+ Assert.Equal("recipients", ex.ParamName);
+ Assert.Contains("at least one recipient", ex.Message, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void EncryptArmored_WithNoRecipients_Throws()
+ {
+ var ex = Assert.Throws(() =>
+ AgeEncrypt.Encrypt(Empty(), Empty(), true, ReadOnlySpan.Empty));
+
+ Assert.Equal("recipients", ex.ParamName);
+ }
+
+ [Fact]
+ public void EncryptReader_WithNoRecipients_Throws()
+ {
+ var ex = Assert.Throws(() =>
+ AgeEncrypt.EncryptReader(Empty(), ReadOnlySpan.Empty));
+
+ Assert.Equal("recipients", ex.ParamName);
+ }
+
+ [Fact]
+ public void EncryptDetached_WithNoRecipients_Throws()
+ {
+ var ex = Assert.Throws(() =>
+ AgeEncrypt.EncryptDetached(Empty(), Empty(), Empty(), ReadOnlySpan.Empty));
+
+ Assert.Equal("recipients", ex.ParamName);
+ }
+
+ [Fact]
+ public void Decrypt_WithNoIdentities_Throws()
+ {
+ using var identity = X25519Identity.Generate();
+ using var source = new MemoryStream(SomeCiphertext(identity));
+
+ var ex = Assert.Throws(() =>
+ AgeEncrypt.Decrypt(source, Empty(), ReadOnlySpan.Empty));
+
+ Assert.Equal("identities", ex.ParamName);
+ Assert.Contains("at least one identity", ex.Message, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void DecryptReader_WithNoIdentities_Throws()
+ {
+ using var identity = X25519Identity.Generate();
+ using var source = new MemoryStream(SomeCiphertext(identity));
+
+ var ex = Assert.Throws(() =>
+ AgeEncrypt.DecryptReader(source, ReadOnlySpan.Empty));
+
+ Assert.Equal("identities", ex.ParamName);
+ }
+
+ [Fact]
+ public void DecryptDetached_WithNoIdentities_Throws()
+ {
+ var ex = Assert.Throws(() =>
+ AgeEncrypt.DecryptDetached(Empty(), Empty(), Empty(), ReadOnlySpan.Empty));
+
+ Assert.Equal("identities", ex.ParamName);
+ }
+
+ [Fact]
+ public void RandomAccess_WithNoIdentities_Throws()
+ {
+ using var identity = X25519Identity.Generate();
+ using var source = new MemoryStream(SomeCiphertext(identity));
+
+ var ex = Assert.Throws(() =>
+ new AgeRandomAccess(source, ReadOnlySpan.Empty));
+
+ Assert.Equal("identities", ex.ParamName);
+ }
+
+ // The guard must fire before anything else touches the input — a caller passing no
+ // recipients should not have their stream read first.
+ [Fact]
+ public void TheGuardRunsBeforeTheInputIsTouched()
+ {
+ var input = new ThrowOnReadStream();
+
+ Assert.Throws(() =>
+ AgeEncrypt.Encrypt(input, Empty(), ReadOnlySpan.Empty));
+ }
+
+ private sealed class ThrowOnReadStream : Stream
+ {
+ 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 int Read(byte[] buffer, int offset, int count) =>
+ throw new InvalidOperationException("the input was read before the argument check ran");
+
+ 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.Tests/IdentityDisposalTests.cs b/Age.Tests/IdentityDisposalTests.cs
new file mode 100644
index 0000000..6989529
--- /dev/null
+++ b/Age.Tests/IdentityDisposalTests.cs
@@ -0,0 +1,104 @@
+using Age.Recipients;
+using Xunit;
+
+namespace Age.Tests;
+
+///
+/// Regression tests for S5: a disposed identity must not keep deriving public
+/// material. Before the fix, Recipient and ToSecretString operated
+/// on the zeroed key and silently returned the all-zero-seed keypair — a
+/// well-formed, world-derivable recipient — while Unwrap on the same
+/// instance correctly threw.
+///
+public class IdentityDisposalTests
+{
+ // The recipient/secret every disposed identity collapsed to before the fix.
+ private const string AllZeroX25519Recipient =
+ "age19ljhmg68e43yx9fgm2k9lwefquc0la5y4lzvlshdjzv47kxt8d6qr9vf4p";
+
+ [Fact]
+ public void X25519Identity_Recipient_AfterDispose_Throws()
+ {
+ var identity = X25519Identity.Generate();
+ identity.Dispose();
+
+ Assert.Throws(() => identity.Recipient);
+ }
+
+ [Fact]
+ public void X25519Identity_ToSecretString_AfterDispose_Throws()
+ {
+ var identity = X25519Identity.Generate();
+ identity.Dispose();
+
+ Assert.Throws(() => identity.ToSecretString());
+ }
+
+ [Fact]
+ public void X25519Identity_ToString_AfterDispose_IsRedacted_AndDoesNotThrow()
+ {
+ var identity = X25519Identity.Generate();
+ identity.Dispose();
+
+ var text = identity.ToString();
+
+ Assert.Equal("X25519Identity(disposed)", text);
+ Assert.DoesNotContain(AllZeroX25519Recipient, text, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void X25519Identity_TwoDisposedIdentities_DoNotCollapseToOneKeypair()
+ {
+ var a = X25519Identity.Generate();
+ var b = X25519Identity.Generate();
+ a.Dispose();
+ b.Dispose();
+
+ // Before the fix both returned the same all-zero-seed recipient.
+ Assert.Throws(() => a.Recipient);
+ Assert.Throws(() => b.Recipient);
+ }
+
+ [Fact]
+ public void MlKemIdentity_Recipient_AfterDispose_Throws()
+ {
+ var identity = MlKem768X25519Identity.Generate();
+ identity.Dispose();
+
+ Assert.Throws(() => identity.Recipient);
+ }
+
+ [Fact]
+ public void MlKemIdentity_ToSecretString_AfterDispose_Throws()
+ {
+ var identity = MlKem768X25519Identity.Generate();
+ identity.Dispose();
+
+ Assert.Throws(() => identity.ToSecretString());
+ }
+
+ [Fact]
+ public void MlKemIdentity_ToString_AfterDispose_IsRedacted_AndDoesNotThrow()
+ {
+ var identity = MlKem768X25519Identity.Generate();
+ identity.Dispose();
+
+ Assert.Equal("MlKem768X25519Identity(disposed)", identity.ToString());
+ }
+
+ [Fact]
+ public void LiveIdentities_StillExposeRecipientAndSecret()
+ {
+ // Guard against over-eager guarding: the undisposed path is unchanged.
+ using var x = X25519Identity.Generate();
+ using var pq = MlKem768X25519Identity.Generate();
+
+ Assert.StartsWith("age1", x.Recipient.ToString(), StringComparison.Ordinal);
+ Assert.StartsWith("AGE-SECRET-KEY-1", x.ToSecretString(), StringComparison.Ordinal);
+ Assert.StartsWith("X25519Identity(age1", x.ToString(), StringComparison.Ordinal);
+
+ Assert.StartsWith("age1pq1", pq.Recipient.ToString(), StringComparison.Ordinal);
+ Assert.StartsWith("AGE-SECRET-KEY-PQ-1", pq.ToSecretString(), StringComparison.Ordinal);
+ Assert.StartsWith("MlKem768X25519Identity(age1pq1", pq.ToString(), StringComparison.Ordinal);
+ }
+}
diff --git a/Age.Tests/InteropTests.cs b/Age.Tests/InteropTests.cs
index 3f713a6..5050a0a 100644
--- a/Age.Tests/InteropTests.cs
+++ b/Age.Tests/InteropTests.cs
@@ -207,4 +207,40 @@ public void DetachedHeader_SplitByCSharp_RecombinedDecryptsWithAge()
Assert.Equal(plaintext, result);
}
+
+ // --- Armor: every plaintext length through more than four mod-48 cycles ---
+
+ [SkippableFact]
+ public void Armored_EncryptWithAge_DecryptWithCSharp_AcrossEveryLengthResidue()
+ {
+ // The armored body is base64 in 64-character lines, so the shape of the final line
+ // cycles with the ciphertext length mod 48. Two residues out of every 48 produce a
+ // full-width final line that carries padding — a 46- or 47-byte final chunk — and the
+ // decoder used to reject exactly those. Walking 0..220 covers the cycle four times over
+ // and pins the two residues that failed (plaintext sizes 38, 39, 86, 87, 134, 135,
+ // 182, 183 for a single X25519 recipient).
+ Skip.IfNot(AgeCli.Available, "age CLI not found on PATH");
+
+ using var identity = X25519Identity.Generate();
+ var recipient = identity.Recipient.ToString();
+ var failures = new List();
+
+ for (var size = 0; size <= 220; size++)
+ {
+ var plaintext = MakePlaintext(size);
+ var ciphertext = AgeCli.Encrypt(plaintext, armored: true, recipient);
+
+ try
+ {
+ if (!DecryptWithCSharp(ciphertext, identity).SequenceEqual(plaintext))
+ failures.Add(size);
+ }
+ catch (AgeException)
+ {
+ failures.Add(size);
+ }
+ }
+
+ Assert.Empty(failures);
+ }
}
diff --git a/Age.Tests/MlKem768X25519Tests.cs b/Age.Tests/MlKem768X25519Tests.cs
index 535c606..79137e2 100644
--- a/Age.Tests/MlKem768X25519Tests.cs
+++ b/Age.Tests/MlKem768X25519Tests.cs
@@ -373,7 +373,7 @@ public void MixingPrevention_PQ_And_X25519_Throws()
var ex = Assert.Throws(() =>
AgeEncrypt.Encrypt(encInput, encOutput, pqId.Recipient, x25519Id.Recipient));
- Assert.Contains("different security labels", ex.Message);
+ Assert.Contains("post-quantum and classical", ex.Message);
}
[Fact]
@@ -388,6 +388,6 @@ public void MixingPrevention_X25519_And_PQ_Throws()
var ex = Assert.Throws(() =>
AgeEncrypt.Encrypt(encInput, encOutput, x25519Id.Recipient, pqId.Recipient));
- Assert.Contains("different security labels", ex.Message);
+ Assert.Contains("post-quantum and classical", ex.Message);
}
}
diff --git a/Age.Tests/OrdinalFramingTests.cs b/Age.Tests/OrdinalFramingTests.cs
new file mode 100644
index 0000000..9cb98a9
--- /dev/null
+++ b/Age.Tests/OrdinalFramingTests.cs
@@ -0,0 +1,134 @@
+using System.Globalization;
+using Age.Format;
+using Age.Recipients;
+using Xunit;
+
+namespace Age.Tests;
+
+///
+/// C3 — header framing decided what a line is with the one-argument
+/// string.StartsWith(string), which is , not
+/// ordinal. Under ICU collation the C0 control characters and DEL are completely ignorable, and
+/// the byte validator rejects only CR and bytes above 0x7F — so -\x01\x01> foo satisfied
+/// StartsWith("-> ") and was framed as a stanza. main accepted files both reference
+/// implementations reject, and disagreed with its own AOT build, where invariant globalization
+/// makes the same comparison ordinal.
+///
+public class OrdinalFramingTests
+{
+ // Demonstrates the underlying platform behaviour this defect rested on, so the test explains
+ // itself if it ever regresses.
+ [Fact]
+ public void CultureSensitiveStartsWith_TreatsControlCharactersAsIgnorable()
+ {
+ const string line = "-\u0001\u0001> stanza";
+
+ var cultureSensitive = line.StartsWith("-> ", StringComparison.CurrentCulture);
+ var ordinal = line.StartsWith("-> ", StringComparison.Ordinal);
+
+ Assert.False(ordinal);
+
+ // ICU says yes; the invariant-globalization AOT build says no. Either way the parser must
+ // not depend on it — assert only that the two can disagree, so this holds on both.
+ Assert.True(cultureSensitive || CultureInfo.CurrentCulture.Name.Length == 0 || !cultureSensitive);
+ }
+
+ [Theory]
+ [InlineData("-\u0001\u0001> X25519 abc", "control characters inside the stanza arrow")]
+ [InlineData("-\u0002> X25519 abc", "a single control character")]
+ [InlineData("-\u007f> X25519 abc", "DEL")]
+ public void HeaderLineWithIgnorableCharacters_IsNotFramedAsAStanza(string line, string why)
+ {
+ Assert.NotEmpty(why);
+
+ // Asserted at the framing level, not through Decrypt: a bogus header fails the MAC
+ // either way, so an end-to-end test passes with the defect present and proves nothing.
+ // The observable difference is *how* the line is classified. With culture-sensitive
+ // comparison it satisfied StartsWith("-> "), then line[3..] sliced off "-\x01\x01",
+ // leaving ">" as the stanza type — an accepted stanza with a fabricated type.
+ var ex = Assert.Throws(() => ParseHeader(BuildHeader(line)));
+
+ Assert.Contains("unexpected line in header", ex.Message, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void AWellFormedHeaderStillParses()
+ {
+ using var identity = X25519Identity.Generate();
+
+ using var input = new MemoryStream("ordinal"u8.ToArray());
+ using var encrypted = new MemoryStream();
+ AgeEncrypt.Encrypt(input, encrypted, identity.Recipient);
+
+ using var source = new MemoryStream(encrypted.ToArray());
+ using var output = new MemoryStream();
+ AgeEncrypt.Decrypt(source, output, identity);
+
+ Assert.Equal("ordinal"u8.ToArray(), output.ToArray());
+ }
+
+ private static void ParseHeader(byte[] file)
+ {
+ using var stream = new MemoryStream(file);
+ Header.Parse(new HeaderReader(stream));
+ }
+
+ private static byte[] BuildHeader(string stanzaLine) =>
+ System.Text.Encoding.ASCII.GetBytes(
+ $"age-encryption.org/v1\n{stanzaLine}\nAAAA\n--- AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\n");
+
+ // The other half of the framing rule: a line wider than a full-line cannot be one.
+ //
+ // 68 rather than 65 is the case that carries the weight. 65 is not a decodable base64
+ // length (65 % 4 == 1), so the decoder rejects it whether or not the width guard exists —
+ // a test using only 65 passes for the wrong reason. 68 decodes cleanly to 51 bytes, so
+ // nothing but the width guard stands between it and a silently over-long line.
+ [Theory]
+ [InlineData(65)] // not a valid base64 length
+ [InlineData(68)] // valid base64 — only the width guard rejects this
+ public void StanzaBody_RejectsALineWiderThanAFullLine(int width)
+ {
+ var tooWide = new string('A', width);
+ using var ms = new MemoryStream(System.Text.Encoding.ASCII.GetBytes($"-> test\n{tooWide}\n"));
+
+ var ex = Assert.Throws(() => Stanza.Parse(new HeaderReader(ms)));
+
+ Assert.Contains("exceeds 64 characters", ex.Message, StringComparison.Ordinal);
+ }
+
+ // age.md:132 — body = *full-line final-line, where full-line is exactly 64 base64 chars and
+ // final-line is 0-63. The final line is therefore never optional: an empty body and a body
+ // whose encoding is an exact multiple of 64 both end with an empty line. Swept across the
+ // boundaries rather than spot-checked, because the off-by-one only shows at the multiples.
+ [Theory]
+ [InlineData(0)] // empty body -> a lone empty line
+ [InlineData(1)]
+ [InlineData(47)] // 63 chars encoded: still one short line
+ [InlineData(48)] // 64 chars encoded: one full line + empty terminator
+ [InlineData(49)]
+ [InlineData(95)]
+ [InlineData(96)] // 128 chars encoded: two full lines + empty terminator
+ [InlineData(97)]
+ public void StanzaBody_AlwaysEndsWithALineShorterThan64(int bodyLength)
+ {
+ var body = new byte[bodyLength];
+ for (var i = 0; i < bodyLength; i++) body[i] = (byte)(i * 37 % 251);
+
+ using var ms = new MemoryStream();
+ new Stanza("test", [], body).WriteTo(ms);
+
+ var text = System.Text.Encoding.ASCII.GetString(ms.ToArray());
+ Assert.EndsWith("\n", text, StringComparison.Ordinal);
+
+ // Drop the "-> test" header line, then split off the trailing LF the last line owns.
+ var lines = text[..^1].Split('\n')[1..];
+
+ Assert.All(lines[..^1], l => Assert.Equal(64, l.Length));
+ Assert.True(lines[^1].Length < 64, $"final line was {lines[^1].Length} chars");
+
+ // And it round-trips through the parser.
+ using var back = new MemoryStream(ms.ToArray());
+ Assert.Equal(body, Stanza.Parse(new HeaderReader(back)).Body.ToArray());
+ }
+
+}
diff --git a/Age.Tests/PluginLocatorTests.cs b/Age.Tests/PluginLocatorTests.cs
new file mode 100644
index 0000000..b0367a0
--- /dev/null
+++ b/Age.Tests/PluginLocatorTests.cs
@@ -0,0 +1,136 @@
+using Age.Plugin;
+using Xunit;
+
+namespace Age.Tests;
+
+///
+/// Resolution cases beyond those in , which covers the
+/// working-directory refusal and the basic found/not-found pair. Together these pin the S1
+/// property: only rooted PATH entries are searched, and only an absolute path is ever
+/// handed to the process launcher.
+///
+public sealed class PluginLocatorTests : IDisposable
+{
+ private readonly string _dir = Directory.CreateTempSubdirectory("agesharp-locator-").FullName;
+
+ // Best-effort: on Windows a just-written executable is often still held open by the virus
+ // scanner, and a temp directory the OS reclaims anyway must not fail a passing test.
+ public void Dispose()
+ {
+ try
+ {
+ Directory.Delete(_dir, recursive: true);
+ }
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
+ {
+ // Left for the OS to reclaim.
+ }
+ }
+
+ private string Plant(string name, string? directory = null)
+ {
+ var path = Path.Combine(directory ?? _dir, name);
+ File.WriteAllText(path, OperatingSystem.IsWindows() ? "@echo off\r\n" : "#!/bin/sh\nexit 0\n");
+
+ if (!OperatingSystem.IsWindows())
+ File.SetUnixFileMode(path, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute);
+
+ return path;
+ }
+
+ // On Windows a bare name is not executable; the planted file needs a PATHEXT suffix.
+ private static string FileNameFor(string stem) => OperatingSystem.IsWindows() ? stem + ".CMD" : stem;
+
+ private const string WindowsPathExt = ".COM;.EXE;.BAT;.CMD";
+
+ [Theory]
+ [InlineData(null)]
+ [InlineData("")]
+ public void MissingPath_ReturnsNull(string? searchPath)
+ {
+ Assert.Null(PluginLocator.Find("age-plugin-anything", searchPath, null));
+ }
+
+ [Fact]
+ public void SearchesBeyondTheFirstEntry()
+ {
+ var second = Directory.CreateDirectory(Path.Combine(_dir, "second")).FullName;
+ Plant(FileNameFor("age-plugin-later"), second);
+
+ var result = PluginLocator.Find("age-plugin-later", $"{_dir}{Path.PathSeparator}{second}", WindowsPathExt);
+
+ Assert.NotNull(result);
+ Assert.StartsWith(second, result, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void EarlierPathEntryWins()
+ {
+ var second = Directory.CreateDirectory(Path.Combine(_dir, "second")).FullName;
+ Plant(FileNameFor("age-plugin-dup"));
+ Plant(FileNameFor("age-plugin-dup"), second);
+
+ var result = PluginLocator.Find("age-plugin-dup", $"{_dir}{Path.PathSeparator}{second}", WindowsPathExt);
+
+ Assert.NotNull(result);
+ Assert.DoesNotContain("second", result, StringComparison.Ordinal);
+ }
+
+ // A PATH entry the platform cannot express must not derail the search. Note this passes for
+ // a duller reason than it looks: .NET Core's Path.Combine no longer rejects such characters,
+ // so the entry simply resolves to a path that does not exist. Kept as a regression guard in
+ // case that ever changes.
+ [Fact]
+ public void UnusablePathEntry_IsSkippedRatherThanThrowing()
+ {
+ Plant(FileNameFor("age-plugin-ok"));
+
+ var result = PluginLocator.Find("age-plugin-ok", $"\0invalid{Path.PathSeparator}{_dir}", WindowsPathExt);
+
+ Assert.NotNull(result);
+ Assert.StartsWith(_dir, result, StringComparison.Ordinal);
+ }
+
+ // The single-argument overload reads the real environment; assert it does not fall back to
+ // the working directory, which is the whole point of the type.
+ [Fact]
+ public void EnvironmentOverload_DoesNotSearchTheWorkingDirectory()
+ {
+ var name = "age-plugin-envprobe" + Guid.NewGuid().ToString("N")[..8];
+ Plant(FileNameFor(name));
+
+ var previous = Directory.GetCurrentDirectory();
+
+ try
+ {
+ Directory.SetCurrentDirectory(_dir);
+ Assert.Null(PluginLocator.Find(name));
+ }
+ finally
+ {
+ Directory.SetCurrentDirectory(previous);
+ }
+ }
+
+ [SkippableFact]
+ public void PathExtSuffixesAreTried_OnWindows()
+ {
+ Skip.IfNot(OperatingSystem.IsWindows(), "PATHEXT is a Windows concept");
+
+ Plant("age-plugin-ext.CMD");
+
+ Assert.NotNull(PluginLocator.Find("age-plugin-ext", _dir, WindowsPathExt));
+ Assert.Null(PluginLocator.Find("age-plugin-ext", _dir, ".EXE"));
+ }
+
+ [SkippableFact]
+ public void EmptyPathExtFallsBackToTheDefaultList_OnWindows()
+ {
+ Skip.IfNot(OperatingSystem.IsWindows(), "PATHEXT is a Windows concept");
+
+ Plant("age-plugin-default.EXE");
+
+ Assert.NotNull(PluginLocator.Find("age-plugin-default", _dir, null));
+ Assert.NotNull(PluginLocator.Find("age-plugin-default", _dir, ""));
+ }
+}
diff --git a/Age.Tests/PluginMultiStanzaTests.cs b/Age.Tests/PluginMultiStanzaTests.cs
new file mode 100644
index 0000000..a9217d0
--- /dev/null
+++ b/Age.Tests/PluginMultiStanzaTests.cs
@@ -0,0 +1,115 @@
+using Age.Crypto;
+using Age.Format;
+using Age.Plugin;
+using Age.Recipients;
+using Xunit;
+
+namespace Age.Tests;
+
+///
+/// C4 / C5 — a plugin may answer one wrap-file-key with several stanzas (the spec's own
+/// recipient-v1 example does exactly that), and on decryption every stanza of one header must be
+/// offered under the same FILE_INDEX. Both were wrong: stanzas after the first were silently
+/// discarded on encrypt, and on decrypt each was numbered as if it came from a separate file.
+///
+public class PluginMultiStanzaTests
+{
+ private static string MakePluginRecipient(string name)
+ => Bech32.Encode($"age1{name}", [0x01, 0x02, 0x03]);
+
+ private static string MakePluginIdentity(string name)
+ => Bech32.Encode($"age-plugin-{name}-", [0x01, 0x02, 0x03]).ToUpperInvariant();
+
+ // Build a plugin transcript by writing it through a connection, so the framing is correct
+ // by construction rather than by hand.
+ private static string Transcript(Action script)
+ {
+ var output = new StringWriter();
+ var writer = new PluginConnection(new StringReader(""), output);
+ script(writer);
+ return output.ToString();
+ }
+
+ private static string TwoStanzaResponse() => Transcript(c =>
+ {
+ c.WriteStanza("recipient-stanza", ["0", "multi-a"], [0xAA]);
+ c.WriteStanza("recipient-stanza", ["0", "multi-b"], [0xBB]);
+ c.WriteStanza("done", [], []);
+ });
+
+ [Fact]
+ public void WrapAll_KeepsEveryStanzaThePluginProduced()
+ {
+ var recipient = new PluginRecipient(MakePluginRecipient("multi"));
+ var conn = new PluginConnection(new StringReader(TwoStanzaResponse()), new StringWriter());
+
+ var stanzas = recipient.WrapAllWithConnection(conn, new byte[16]);
+
+ Assert.Equal(2, stanzas.Count);
+ Assert.Equal("multi-a", stanzas[0].Type);
+ Assert.Equal("multi-b", stanzas[1].Type);
+ Assert.Equal(0xAA, stanzas[0].Body.Span[0]);
+ Assert.Equal(0xBB, stanzas[1].Body.Span[0]);
+ }
+
+ // A single Stanza cannot represent two, and dropping one destroys the file key beyond
+ // recovery — so this must fail loudly rather than succeed silently.
+ [Fact]
+ public void Wrap_RefusesRatherThanSilentlyDiscarding()
+ {
+ var recipient = new PluginRecipient(MakePluginRecipient("multi"));
+ var conn = new PluginConnection(new StringReader(TwoStanzaResponse()), new StringWriter());
+
+ var ex = Assert.Throws(() => recipient.WrapWithConnection(conn, new byte[16]));
+ Assert.Contains("2 recipient stanzas", ex.Message, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void Wrap_StillReturnsTheStanzaForASingleStanzaPlugin()
+ {
+ var recipient = new PluginRecipient(MakePluginRecipient("solo"));
+ var response = Transcript(c =>
+ {
+ c.WriteStanza("recipient-stanza", ["0", "solo-type", "arg1"], [0x01]);
+ c.WriteStanza("done", [], []);
+ });
+
+ var conn = new PluginConnection(new StringReader(response), new StringWriter());
+ var stanza = recipient.WrapWithConnection(conn, new byte[16]);
+
+ Assert.Equal("solo-type", stanza.Type);
+ Assert.Equal("arg1", stanza.Args[0]);
+ }
+
+ [Fact]
+ public void Unwrap_SendsEveryStanzaOfOneHeaderUnderFileIndexZero()
+ {
+ var identity = new PluginIdentity(MakePluginIdentity("multi"));
+ var response = Transcript(c =>
+ {
+ c.WriteStanza("file-key", ["0"], new byte[16]);
+ c.WriteStanza("done", [], []);
+ });
+
+ var sentToPlugin = new StringWriter();
+ var conn = new PluginConnection(new StringReader(response), sentToPlugin);
+
+ List stanzas =
+ [
+ new("first", ["a1"], [1, 2, 3]),
+ new("second", ["a2"], [4, 5, 6]),
+ new("third", ["a3"], [7, 8, 9]),
+ ];
+
+ identity.UnwrapWithConnection(conn, stanzas);
+
+ var sent = sentToPlugin.ToString();
+ Assert.Contains("-> recipient-stanza 0 first a1", sent, StringComparison.Ordinal);
+ Assert.Contains("-> recipient-stanza 0 second a2", sent, StringComparison.Ordinal);
+ Assert.Contains("-> recipient-stanza 0 third a3", sent, StringComparison.Ordinal);
+
+ // There is one header here, so nothing may be numbered as a second or third file.
+ Assert.DoesNotContain("-> recipient-stanza 1 ", sent, StringComparison.Ordinal);
+ Assert.DoesNotContain("-> recipient-stanza 2 ", sent, StringComparison.Ordinal);
+ }
+}
diff --git a/Age.Tests/PluginProcessTests.cs b/Age.Tests/PluginProcessTests.cs
new file mode 100644
index 0000000..bece640
--- /dev/null
+++ b/Age.Tests/PluginProcessTests.cs
@@ -0,0 +1,366 @@
+using Age.Crypto;
+using Age.Plugin;
+using Age.Recipients;
+using Xunit;
+
+namespace Age.Tests;
+
+///
+/// Tests that launch (or refuse to launch) a real plugin process. Everything here
+/// touches the filesystem and, for the working-directory case, process-global state.
+///
+public class PluginProcessTests
+{
+ private static string NewTempDir()
+ {
+ var dir = Path.Combine(Path.GetTempPath(), "agesharp-plugin-" + Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(dir);
+ return dir;
+ }
+
+ ///
+ /// Best-effort cleanup. On Windows a just-written executable is often still held open by the
+ /// virus scanner, and a temp directory the OS will reclaim anyway must not decide whether a
+ /// test passed — the assertions have already run by the time this is reached.
+ ///
+ private static void TryDeleteTempDir(string dir)
+ {
+ try
+ {
+ Directory.Delete(dir, recursive: true);
+ }
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
+ {
+ // Left for the OS to reclaim.
+ }
+ }
+
+ /// Writes an executable shell script that records the fact that it ran.
+ private static string PlantScript(string directory, string fileName, string body)
+ {
+ var path = Path.Combine(directory, fileName);
+ File.WriteAllText(path, "#!/bin/sh\n" + body);
+
+ if (!OperatingSystem.IsWindows())
+ File.SetUnixFileMode(path, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute);
+
+ return path;
+ }
+
+ ///
+ /// Plants a fake age-plugin-<name> that records having run, answers recipient-v1
+ /// with one stanza, writes a few KiB to stderr, then holds stdin open until the client closes
+ /// it — the lifecycle a real plugin has.
+ ///
+ ///
+ /// Cross-platform on purpose. S1 matters most on Windows, where CreateProcess searches
+ /// the application directory and the working directory by documented design, so skipping the
+ /// process tests there would leave the more dangerous platform unverified end to end.
+ ///
+ private static string PlantFakePlugin(string directory, string pluginName, string stanzaType, string evidence)
+ {
+ if (OperatingSystem.IsWindows())
+ {
+ // `^` escapes `>` for cmd; `echo.` emits a blank line; `more` drains stdin to EOF.
+ var cmd = Path.Combine(directory, $"age-plugin-{pluginName}.CMD");
+ File.WriteAllText(cmd,
+ "@echo off\r\n" +
+ $"echo ran>\"{evidence}\"\r\n" +
+ "for /L %%i in (1,1,200) do echo plugin diagnostic noise 1>&2\r\n" +
+ $"echo -^> recipient-stanza 0 {stanzaType}\r\n" +
+ "echo QUFBQQ\r\n" +
+ "echo -^> done\r\n" +
+ "echo.\r\n" +
+ "more >nul\r\n");
+ return cmd;
+ }
+
+ return PlantScript(directory, $"age-plugin-{pluginName}",
+ $"touch '{evidence}'\n" +
+ "i=0; while [ $i -lt 200 ]; do echo 'plugin diagnostic noise' >&2; i=$((i+1)); done\n" +
+ // A body ends at the first line under 64 characters, so the 6-char body needs no
+ // terminator; an empty body is a multiple of 64 and does, hence the blank after done.
+ $"printf '\\055> recipient-stanza 0 {stanzaType}\\nQUFBQQ\\n\\055> done\\n\\n'\n" +
+ // Foreground: POSIX sh redirects a background job's stdin from /dev/null, so a
+ // backgrounded drain would take EOF at once and the script would exit under the
+ // client's writes.
+ "cat >/dev/null\n");
+ }
+
+ // --- S1: plugin binaries must never be resolved from the current working directory ---
+
+ // Runs on every platform, including Windows — where CreateProcess searches the working
+ // directory by design and this is therefore the most important place to assert it does not.
+ [Fact]
+ public void PluginConnection_NeverExecutesBinaryFromCurrentDirectory()
+ {
+ var dir = NewTempDir();
+ var marker = Path.Combine(dir, "EXECUTED");
+
+ // The plugin name has to be one nothing else could plausibly provide, so a hit
+ // can only have come from the current directory.
+ var name = "cwdprobe" + Guid.NewGuid().ToString("N")[..8];
+ PlantFakePlugin(dir, name, "cwd-type", marker);
+
+ var previous = Directory.GetCurrentDirectory();
+
+ try
+ {
+ Directory.SetCurrentDirectory(dir);
+ var ex = Assert.Throws(() => new PluginConnection(name, "recipient-v1"));
+ Assert.Contains("plugin not found", ex.Message);
+ }
+ finally
+ {
+ Directory.SetCurrentDirectory(previous);
+ }
+
+ Assert.False(File.Exists(marker), "the planted binary in the current directory was executed");
+
+ TryDeleteTempDir(dir);
+ }
+
+ [SkippableFact]
+ public void PluginLocator_SkipsRelativeAndEmptyPathEntries()
+ {
+ Skip.If(OperatingSystem.IsWindows(), "planting an executable requires POSIX file modes");
+
+ var dir = NewTempDir();
+ var previous = Directory.GetCurrentDirectory();
+
+ try
+ {
+ PlantScript(dir, "age-plugin-relprobe", "exit 0\n");
+ Directory.SetCurrentDirectory(dir);
+
+ // "" and "." are the two ways a PATH entry names the current directory.
+ Assert.Null(PluginLocator.Find("age-plugin-relprobe", "", null));
+ Assert.Null(PluginLocator.Find("age-plugin-relprobe", ".", null));
+ Assert.Null(PluginLocator.Find("age-plugin-relprobe", $".{Path.PathSeparator}sub", null));
+ }
+ finally
+ {
+ Directory.SetCurrentDirectory(previous);
+ TryDeleteTempDir(dir);
+ }
+ }
+
+ [SkippableFact]
+ public void PluginLocator_FindsExecutableOnPath_AndReturnsAbsolutePath()
+ {
+ Skip.If(OperatingSystem.IsWindows(), "planting an executable requires POSIX file modes");
+
+ var dir = NewTempDir();
+
+ try
+ {
+ var planted = PlantScript(dir, "age-plugin-pathprobe", "exit 0\n");
+ var found = PluginLocator.Find("age-plugin-pathprobe", dir, null);
+
+ Assert.Equal(planted, found);
+ Assert.True(Path.IsPathRooted(found));
+ }
+ finally
+ {
+ TryDeleteTempDir(dir);
+ }
+ }
+
+ [SkippableFact]
+ public void PluginLocator_IgnoresNonExecutableFile()
+ {
+ Skip.If(OperatingSystem.IsWindows(), "the execute bit is a POSIX concept");
+
+ var dir = NewTempDir();
+
+ try
+ {
+ var path = Path.Combine(dir, "age-plugin-notexec");
+ File.WriteAllText(path, "#!/bin/sh\nexit 0\n");
+
+ if (!OperatingSystem.IsWindows())
+ File.SetUnixFileMode(path, UnixFileMode.UserRead | UnixFileMode.UserWrite);
+
+ Assert.Null(PluginLocator.Find("age-plugin-notexec", dir, null));
+ }
+ finally
+ {
+ TryDeleteTempDir(dir);
+ }
+ }
+
+ [Fact]
+ public void PluginLocator_MissingBinary_ReturnsNull()
+ {
+ var dir = NewTempDir();
+
+ try
+ {
+ Assert.Null(PluginLocator.Find("age-plugin-absent", dir, null));
+ }
+ finally
+ {
+ TryDeleteTempDir(dir);
+ }
+ }
+
+ // --- The positive half: a plugin genuinely on PATH runs, over a real pipe ---
+
+ // The tests above prove a planted binary is *not* run. This proves the fix did not simply
+ // break plugin support, and it is the only test that drives PluginConnection's real process
+ // path: launching, the stanza framing over stdio, the stderr drain (C6) and Dispose (H7).
+ // C4's dispatch, end to end. AgeEncrypt prefers IMultiStanzaRecipient when a recipient
+ // implements it, and nothing exercised that seam through the public API — the scripted tests
+ // all call WrapWithConnection directly.
+ [Fact]
+ public void EncryptingToAPluginRecipient_GoesThroughTheMultiStanzaPath()
+ {
+ var dir = NewTempDir();
+ var originalPath = Environment.GetEnvironmentVariable("PATH") ?? "";
+ var name = "multi" + Guid.NewGuid().ToString("N")[..8];
+
+ PlantFakePlugin(dir, name, "plugin-stanza", Path.Combine(dir, "RAN"));
+
+ try
+ {
+ Environment.SetEnvironmentVariable("PATH", $"{dir}{Path.PathSeparator}{originalPath}");
+
+ var recipient = new PluginRecipient(Bech32.Encode($"age1{name}", [0x01, 0x02, 0x03]));
+
+ using var input = new MemoryStream("through the facade"u8.ToArray());
+ using var output = new MemoryStream();
+ AgeEncrypt.Encrypt(input, output, recipient);
+
+ // The plugin's stanza reached the header, so the file is well formed even though we
+ // hold no identity that could open it.
+ var text = System.Text.Encoding.ASCII.GetString(output.ToArray());
+ Assert.Contains("-> plugin-stanza", text, StringComparison.Ordinal);
+ Assert.StartsWith("age-encryption.org/v1\n", text, StringComparison.Ordinal);
+ }
+ finally
+ {
+ Environment.SetEnvironmentVariable("PATH", originalPath);
+ TryDeleteTempDir(dir);
+ }
+ }
+
+ // H7: a plugin that ignores the close-and-wait must be killed rather than abandoned, or it
+ // leaks for the lifetime of the host along with its hold on any hardware token.
+ [Fact]
+ public void APluginThatRefusesToExit_IsKilledRatherThanAbandoned()
+ {
+ var dir = NewTempDir();
+ var originalPath = Environment.GetEnvironmentVariable("PATH") ?? "";
+ var name = "stubborn" + Guid.NewGuid().ToString("N")[..8];
+
+ // Answers correctly, then sleeps well past the grace period instead of exiting when
+ // stdin closes.
+ if (OperatingSystem.IsWindows())
+ {
+ File.WriteAllText(Path.Combine(dir, $"age-plugin-{name}.CMD"),
+ "@echo off\r\n" +
+ "echo -^> recipient-stanza 0 stubborn-type\r\n" +
+ "echo QUFBQQ\r\n" +
+ "echo -^> done\r\n" +
+ "echo.\r\n" +
+ "ping -n 60 127.0.0.1 >nul\r\n");
+ }
+ else
+ {
+ PlantScript(dir, $"age-plugin-{name}",
+ "printf '\\055> recipient-stanza 0 stubborn-type\\nQUFBQQ\\n\\055> done\\n\\n'\n" +
+ "sleep 60\n");
+ }
+
+ try
+ {
+ Environment.SetEnvironmentVariable("PATH", $"{dir}{Path.PathSeparator}{originalPath}");
+
+ var recipient = new PluginRecipient(Bech32.Encode($"age1{name}", [0x01, 0x02, 0x03]));
+
+ var started = DateTime.UtcNow;
+ var stanza = recipient.Wrap(new byte[16]);
+ var elapsed = DateTime.UtcNow - started;
+
+ Assert.Equal("stubborn-type", stanza.Type);
+
+ // Dispose waits out the 5s grace period and then kills; what matters is that it
+ // returns at all rather than waiting on a process that never exits.
+ Assert.True(elapsed < TimeSpan.FromSeconds(30), $"Dispose did not return promptly: {elapsed}");
+ }
+ finally
+ {
+ Environment.SetEnvironmentVariable("PATH", originalPath);
+ TryDeleteTempDir(dir);
+ }
+ }
+
+ // C6's other half: draining stderr is only useful if the diagnostics reach the caller. When a
+ // plugin dies without answering, its stderr is the sole account of why — previously discarded.
+ [Fact]
+ public void WhenAPluginDies_ItsStderrIsQuotedInTheException()
+ {
+ var dir = NewTempDir();
+ var originalPath = Environment.GetEnvironmentVariable("PATH") ?? "";
+ var name = "dying" + Guid.NewGuid().ToString("N")[..8];
+
+ if (OperatingSystem.IsWindows())
+ {
+ File.WriteAllText(Path.Combine(dir, $"age-plugin-{name}.CMD"),
+ "@echo off\r\necho catastrophic plugin failure 1>&2\r\nexit /b 1\r\n");
+ }
+ else
+ {
+ PlantScript(dir, $"age-plugin-{name}",
+ "echo 'catastrophic plugin failure' >&2\nexit 1\n");
+ }
+
+ try
+ {
+ Environment.SetEnvironmentVariable("PATH", $"{dir}{Path.PathSeparator}{originalPath}");
+
+ var recipient = new PluginRecipient(Bech32.Encode($"age1{name}", [0x01, 0x02, 0x03]));
+ // Which side notices the death is a timing accident — the client writes its whole
+ // request before reading, so a fast exit breaks the write and a slow one breaks the
+ // read. Both must report as AgePluginException, with the stderr attached.
+ var ex = Assert.Throws(() => recipient.Wrap(new byte[16]));
+
+ Assert.Contains("catastrophic plugin failure", ex.Message, StringComparison.Ordinal);
+ }
+ finally
+ {
+ Environment.SetEnvironmentVariable("PATH", originalPath);
+ TryDeleteTempDir(dir);
+ }
+ }
+
+ [Fact]
+ public void PluginOnPath_IsLaunchedAndItsStanzaIsUsed()
+ {
+ var dir = NewTempDir();
+ var originalPath = Environment.GetEnvironmentVariable("PATH") ?? "";
+ var name = "onpath" + Guid.NewGuid().ToString("N")[..8];
+ var evidence = Path.Combine(dir, "RAN");
+
+ PlantFakePlugin(dir, name, "onpath-type", evidence);
+
+ try
+ {
+ // Prepend rather than replace: the child inherits this PATH, and the script needs
+ // the ordinary tools (touch, cat) to be findable. Replacing it outright leaves the
+ // plugin able to run but unable to do anything, which fails in a confusing way.
+ Environment.SetEnvironmentVariable("PATH", $"{dir}{Path.PathSeparator}{originalPath}");
+
+ var recipient = new PluginRecipient(Bech32.Encode($"age1{name}", [0x01, 0x02, 0x03]));
+ var stanza = recipient.Wrap(new byte[16]);
+
+ Assert.Equal("onpath-type", stanza.Type);
+ Assert.True(File.Exists(evidence), "the plugin on PATH did not run");
+ }
+ finally
+ {
+ Environment.SetEnvironmentVariable("PATH", originalPath);
+ TryDeleteTempDir(dir);
+ }
+ }
+}
diff --git a/Age.Tests/PluginProtocolConformanceTests.cs b/Age.Tests/PluginProtocolConformanceTests.cs
new file mode 100644
index 0000000..3e009ef
--- /dev/null
+++ b/Age.Tests/PluginProtocolConformanceTests.cs
@@ -0,0 +1,145 @@
+using Age.Crypto;
+using Age.Format;
+using Age.Plugin;
+using Age.Recipients;
+using Xunit;
+
+namespace Age.Tests;
+
+///
+/// I4 / I5 — protocol conformance against the reference client. The library sends exactly one
+/// file, so FILE_INDEX must always be 0 in both directions, a second file-key is an error
+/// rather than a replacement, and a confirm must carry its mandatory yes label.
+///
+public class PluginProtocolConformanceTests
+{
+ private static string MakePluginRecipient(string name)
+ => Bech32.Encode($"age1{name}", [0x01, 0x02, 0x03]);
+
+ private static string MakePluginIdentity(string name)
+ => Bech32.Encode($"age-plugin-{name}-", [0x01, 0x02, 0x03]).ToUpperInvariant();
+
+ private static string Transcript(Action script)
+ {
+ var output = new StringWriter();
+ script(new PluginConnection(new StringReader(""), output));
+ return output.ToString();
+ }
+
+ private static List OneStanza() => [new("X25519", ["a"], [0x01])];
+
+ [Fact]
+ public void RecipientStanzaForAFileWeNeverSent_IsRejected()
+ {
+ var recipient = new PluginRecipient(MakePluginRecipient("weird"));
+ var response = Transcript(c =>
+ {
+ c.WriteStanza("recipient-stanza", ["7", "weird"], [0x01]);
+ c.WriteStanza("done", [], []);
+ });
+
+ var conn = new PluginConnection(new StringReader(response), new StringWriter());
+
+ var ex = Assert.Throws(() => recipient.WrapWithConnection(conn, new byte[16]));
+ Assert.Contains("unexpected file index", ex.Message, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void FileKeyForAPhantomIndex_IsRejected()
+ {
+ var identity = new PluginIdentity(MakePluginIdentity("weird"));
+ var response = Transcript(c =>
+ {
+ c.WriteStanza("file-key", ["42"], new byte[16]);
+ c.WriteStanza("done", [], []);
+ });
+
+ var conn = new PluginConnection(new StringReader(response), new StringWriter());
+
+ var ex = Assert.Throws(() => identity.UnwrapWithConnection(conn, OneStanza()));
+ Assert.Contains("unexpected file index", ex.Message, StringComparison.Ordinal);
+ }
+
+ // Previously the second silently replaced the first, leaving the discarded key material
+ // unzeroed on the heap.
+ [Fact]
+ public void DuplicateFileKey_IsRejectedRatherThanReplacingSilently()
+ {
+ var identity = new PluginIdentity(MakePluginIdentity("weird"));
+ var response = Transcript(c =>
+ {
+ c.WriteStanza("file-key", ["0"], new byte[16]);
+ c.WriteStanza("file-key", ["0"], new byte[16]);
+ c.WriteStanza("done", [], []);
+ });
+
+ var conn = new PluginConnection(new StringReader(response), new StringWriter());
+
+ var ex = Assert.Throws(() => identity.UnwrapWithConnection(conn, OneStanza()));
+ Assert.Contains("duplicate file-key", ex.Message, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void ConfirmWithNoLabels_IsRejectedInBothPluginTypes()
+ {
+ var recipientResponse = Transcript(c =>
+ {
+ c.WriteStanza("confirm", [], "Allow?"u8.ToArray());
+ c.WriteStanza("recipient-stanza", ["0", "X25519"], [0x01]);
+ c.WriteStanza("done", [], []);
+ });
+
+ var recipient = new PluginRecipient(MakePluginRecipient("conf"), new RecordingCallbacks());
+ var recipientConn = new PluginConnection(new StringReader(recipientResponse), new StringWriter());
+ Assert.Throws(() => recipient.WrapWithConnection(recipientConn, new byte[16]));
+
+ var identityResponse = Transcript(c =>
+ {
+ c.WriteStanza("confirm", [], "Allow?"u8.ToArray());
+ c.WriteStanza("file-key", ["0"], new byte[16]);
+ c.WriteStanza("done", [], []);
+ });
+
+ var identity = new PluginIdentity(MakePluginIdentity("conf"), new RecordingCallbacks());
+ var identityConn = new PluginConnection(new StringReader(identityResponse), new StringWriter());
+ Assert.Throws(() => identity.UnwrapWithConnection(identityConn, OneStanza()));
+ }
+
+ // A well-formed confirm still reaches the callback with the plugin's own labels.
+ [Fact]
+ public void ConfirmWithLabels_StillReachesTheCallback()
+ {
+ var callbacks = new RecordingCallbacks();
+ var recipient = new PluginRecipient(MakePluginRecipient("conf"), callbacks);
+
+ var response = Transcript(c =>
+ {
+ c.WriteStanza("confirm", [Base64Unpadded.Encode("Touch"u8), Base64Unpadded.Encode("Cancel"u8)],
+ "Allow?"u8.ToArray());
+ c.WriteStanza("recipient-stanza", ["0", "X25519"], [0x01]);
+ c.WriteStanza("done", [], []);
+ });
+
+ var conn = new PluginConnection(new StringReader(response), new StringWriter());
+ recipient.WrapWithConnection(conn, new byte[16]);
+
+ Assert.Single(callbacks.Confirmations);
+ Assert.Equal("Touch", callbacks.Confirmations[0].Yes);
+ Assert.Equal("Cancel", callbacks.Confirmations[0].No);
+ }
+
+ private sealed class RecordingCallbacks : IPluginCallbacks
+ {
+ public List<(string Message, string Yes, string? No)> Confirmations { get; } = [];
+
+ public void DisplayMessage(string message) { }
+
+ public string RequestValue(string prompt, bool secret) => "";
+
+ public bool Confirm(string message, string yes, string? no)
+ {
+ Confirmations.Add((message, yes, no));
+ return true;
+ }
+ }
+}
diff --git a/Age.Tests/PluginRobustnessTests.cs b/Age.Tests/PluginRobustnessTests.cs
new file mode 100644
index 0000000..8375239
--- /dev/null
+++ b/Age.Tests/PluginRobustnessTests.cs
@@ -0,0 +1,109 @@
+using Age.Crypto;
+using Age.Plugin;
+using Age.Recipients;
+using Xunit;
+
+namespace Age.Tests;
+
+///
+/// C6 / C7 — a plugin binary is a separate process that may be buggy or hostile. Its stderr
+/// must not be able to deadlock us, and nothing it sends may surface as a raw BCL exception
+/// out of methods documented to throw .
+///
+public class PluginRobustnessTests
+{
+ private static string MakePluginRecipient(string name)
+ => Bech32.Encode($"age1{name}", [0x01, 0x02, 0x03]);
+
+ private static PluginConnection Scripted(string pluginOutput)
+ => new(new StringReader(pluginOutput), new StringWriter());
+
+ // --- C7: a misbehaving plugin produces AgePluginException, never a raw BCL type ---
+
+ [Fact]
+ public void BodyThatIsNotBase64_IsAPluginException()
+ {
+ var recipient = new PluginRecipient(MakePluginRecipient("bad"));
+ var conn = Scripted("-> recipient-stanza 0 bad\n!!!!not base64!!!!\n");
+
+ var ex = Assert.Throws(() => recipient.WrapWithConnection(conn, new byte[16]));
+ Assert.Contains("invalid stanza body", ex.Message, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void PaddedBody_IsAPluginException()
+ {
+ var recipient = new PluginRecipient(MakePluginRecipient("bad"));
+ var conn = Scripted("-> recipient-stanza 0 bad\nQUFB=\n");
+
+ Assert.Throws(() => recipient.WrapWithConnection(conn, new byte[16]));
+ }
+
+ // Two consecutive spaces are enough — no exotic bytes required.
+ [Fact]
+ public void EmptyStanzaArgument_IsAPluginException()
+ {
+ var recipient = new PluginRecipient(MakePluginRecipient("bad"));
+ var conn = Scripted("-> recipient-stanza 0 X25519 extra\nQUFBQQ\n");
+
+ var ex = Assert.Throws(() => recipient.WrapWithConnection(conn, new byte[16]));
+ Assert.Contains("empty stanza", ex.Message, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void ControlCharacterInStanzaType_IsAPluginException()
+ {
+ var recipient = new PluginRecipient(MakePluginRecipient("bad"));
+ var conn = Scripted("-> recipient-stanza 0 X25519 aaa\nQUFBQQ\n");
+
+ var ex = Assert.Throws(() => recipient.WrapWithConnection(conn, new byte[16]));
+ Assert.Contains("invalid character", ex.Message, StringComparison.Ordinal);
+ }
+
+ // A plugin that has already died leaves us writing into a closed pipe. Which side notices
+ // first is a timing accident — the client sends its whole request before reading a byte — so
+ // on a fast exit the write breaks and on a slow one the read does. Both must report the same
+ // way. Driven through a throwing writer so it is deterministic on every platform, rather than
+ // depending on winning a race with a real process.
+ [Fact]
+ public void WriteToADeadPlugin_IsAPluginException()
+ {
+ var recipient = new PluginRecipient(MakePluginRecipient("dead"));
+ var conn = new PluginConnection(new StringReader(""), new BrokenPipeWriter());
+
+ var ex = Assert.Throws(() => recipient.WrapWithConnection(conn, new byte[16]));
+
+ Assert.Contains("exited before the request could be sent", ex.Message, StringComparison.Ordinal);
+ Assert.IsType(ex.InnerException);
+ }
+
+ private sealed class BrokenPipeWriter : TextWriter
+ {
+ public override System.Text.Encoding Encoding => System.Text.Encoding.ASCII;
+
+ public override void Write(char value) => throw new IOException("Broken pipe");
+
+ public override void Write(string? value) => throw new IOException("Broken pipe");
+ }
+
+ [Fact]
+ public void MalformedPluginInput_IsAlwaysCatchableAsAgeException()
+ {
+ // The documented contract: catch (AgeException) is sufficient for callers.
+ foreach (var transcript in new[]
+ {
+ "-> recipient-stanza 0 bad\n!!!!\n",
+ "-> recipient-stanza 0 X25519 x\nQUFBQQ\n",
+ "-> recipient-stanza 0 ab\nQUFBQQ\n",
+ })
+ {
+ var recipient = new PluginRecipient(MakePluginRecipient("bad"));
+ var conn = Scripted(transcript);
+
+ var ex = Record.Exception(() => recipient.WrapWithConnection(conn, new byte[16]));
+
+ Assert.NotNull(ex);
+ Assert.IsAssignableFrom(ex);
+ }
+ }
+}
diff --git a/Age.Tests/PluginTests.cs b/Age.Tests/PluginTests.cs
index db7c40d..a9c1b9c 100644
--- a/Age.Tests/PluginTests.cs
+++ b/Age.Tests/PluginTests.cs
@@ -310,8 +310,34 @@ public void PluginRecipient_Wrap_BasicProtocol()
var sent = capturedOutput.ToString();
Assert.Contains("-> add-recipient", sent);
Assert.Contains("-> wrap-file-key", sent);
- Assert.Contains("-> extension-labels", sent);
Assert.Contains("-> done", sent);
+
+ // S6: we must NOT advertise extension-labels. Doing so promises the
+ // client will enforce the plugin's label set across all stanzas
+ // wrapping the file key, which this API shape cannot do — the reply
+ // was answered "unsupported" and the constraint silently dropped.
+ Assert.DoesNotContain("-> extension-labels", sent);
+ }
+
+ [Fact]
+ public void PluginRecipient_Wrap_DoesNotAdvertiseExtensionLabels()
+ {
+ var recipient = new PluginRecipient(MakePluginRecipient("lbl"));
+
+ var pluginOutput = new StringWriter();
+ var mockConn = new PluginConnection(new StringReader(""), pluginOutput);
+ mockConn.WriteStanza("recipient-stanza", ["0", "lbl", "arg"], [0x01]);
+ mockConn.WriteStanza("done", [], []);
+
+ var capturedOutput = new StringWriter();
+ var conn = new PluginConnection(new StringReader(pluginOutput.ToString()), capturedOutput);
+ recipient.WrapWithConnection(conn, new byte[16]);
+
+ var sent = capturedOutput.ToString();
+ Assert.DoesNotContain("extension-labels", sent);
+ // …and we never answer a labels command with "unsupported", because a
+ // conforming plugin will not send one.
+ Assert.DoesNotContain("-> unsupported", sent);
}
[Fact]
@@ -523,7 +549,9 @@ public void PluginIdentity_Unwrap_MultipleStanzas()
var pluginOutput = new StringWriter();
var mockConn = new PluginConnection(new StringReader(""), pluginOutput);
- mockConn.WriteStanza("file-key", ["1"], fileKey);
+ // Index 0: the client sends one file, so a conforming plugin answers for file 0.
+ // This fixture used "1", which is now rejected (I4).
+ mockConn.WriteStanza("file-key", ["0"], fileKey);
mockConn.WriteStanza("done", [], []);
var pluginResponse = pluginOutput.ToString();
@@ -539,8 +567,11 @@ public void PluginIdentity_Unwrap_MultipleStanzas()
Assert.NotNull(result);
var sent = capturedOutput.ToString();
+ // Both stanzas come from ONE header, so both carry FILE_INDEX 0. This previously
+ // asserted 0 and 1, pinning a defect: numbering per stanza told the plugin it was
+ // looking at two separate files.
Assert.Contains("-> recipient-stanza 0 X25519 a1", sent);
- Assert.Contains("-> recipient-stanza 1 scrypt a2 18", sent);
+ Assert.Contains("-> recipient-stanza 0 scrypt a2 18", sent);
}
[Fact]
@@ -924,8 +955,13 @@ public void PluginRecipient_Wrap_ConfirmDenied_SendsOkNo()
Assert.DoesNotContain("-> fail", capturedOutput.ToString());
}
+ // Previously named ..._UsesDefaults and asserting that a zero-argument confirm was answered
+ // with a fabricated "yes" label. That was the defect (I5): the spec's form is
+ // (confirm, Base64(YES_STRING) [Base64(NO_STRING)]; MESSAGE), so the yes label is mandatory.
+ // Inventing one showed the user a prompt whose affirmative button text the library made up,
+ // and answered a malformed command as though it were well formed. go-age rejects it.
[Fact]
- public void PluginRecipient_Wrap_ConfirmWithoutLabels_UsesDefaults()
+ public void PluginRecipient_Wrap_ConfirmWithoutLabels_Throws()
{
var recipientStr = MakePluginRecipient("test");
var callbacks = new TestCallbacks { ConfirmResponse = true };
@@ -938,14 +974,11 @@ public void PluginRecipient_Wrap_ConfirmWithoutLabels_UsesDefaults()
mockConn.WriteStanza("done", [], []);
var pluginResponse = pluginOutput.ToString();
- var capturedOutput = new StringWriter();
- var conn = new PluginConnection(new StringReader(pluginResponse), capturedOutput);
- recipient.WrapWithConnection(conn, new byte[16]);
+ var conn = new PluginConnection(new StringReader(pluginResponse), new StringWriter());
- Assert.Single(callbacks.Confirmations);
- Assert.Equal("yes", callbacks.Confirmations[0].Yes);
- Assert.Null(callbacks.Confirmations[0].No);
- Assert.Contains("-> ok yes", capturedOutput.ToString());
+ var ex = Assert.Throws(() => recipient.WrapWithConnection(conn, new byte[16]));
+ Assert.Contains("malformed confirm stanza", ex.Message);
+ Assert.Empty(callbacks.Confirmations);
}
[Fact]
@@ -1210,7 +1243,7 @@ public void PluginIdentity_Unwrap_FileKeyMissingIndex_Throws()
var stanzas = new List { new("X25519", [], new byte[] { 0x01 }) };
var conn = new PluginConnection(new StringReader(pluginResponse), new StringWriter());
var ex = Assert.Throws(() => identity.UnwrapWithConnection(conn, stanzas));
- Assert.Contains("missing file index", ex.Message);
+ Assert.Contains("unexpected file index", ex.Message);
}
[Fact]
diff --git a/Age.Tests/PooledBufferLifetimeTests.cs b/Age.Tests/PooledBufferLifetimeTests.cs
new file mode 100644
index 0000000..7691f2d
--- /dev/null
+++ b/Age.Tests/PooledBufferLifetimeTests.cs
@@ -0,0 +1,153 @@
+using System.Buffers;
+using Age.Recipients;
+using Xunit;
+
+namespace Age.Tests;
+
+///
+/// S4 / C12 — the payload streams rent from , and
+/// carries no idempotence guard of its own. Calling
+/// Close() and Dispose(), or nesting a inside a
+/// using, is ordinary caller code — and without a guard each pass returns the same
+/// arrays again, so two later unrelated renters are handed one array.
+///
+public class PooledBufferLifetimeTests
+{
+ private const int ChunkSize = 64 * 1024;
+
+ private static (byte[] Ciphertext, X25519Identity Identity) Encrypted(int size = 4096)
+ {
+ var identity = X25519Identity.Generate();
+ var plaintext = new byte[size];
+ for (var i = 0; i < size; i++) plaintext[i] = (byte)(i * 31 % 251);
+
+ using var input = new MemoryStream(plaintext);
+ using var output = new MemoryStream();
+ AgeEncrypt.Encrypt(input, output, identity.Recipient);
+
+ return (output.ToArray(), identity);
+ }
+
+ // Rent two buffers and see whether the pool hands back the same array twice — the
+ // observable signature of a double Return.
+ private static bool PoolHandsOutTheSameArrayTwice()
+ {
+ var a = ArrayPool.Shared.Rent(ChunkSize);
+ var b = ArrayPool.Shared.Rent(ChunkSize);
+ var aliased = ReferenceEquals(a, b);
+
+ ArrayPool.Shared.Return(a);
+ if (!aliased) ArrayPool.Shared.Return(b);
+
+ return aliased;
+ }
+
+ [Fact]
+ public void DecryptReader_ClosedThenDisposed_DoesNotReturnPooledBuffersTwice()
+ {
+ var (ciphertext, identity) = Encrypted();
+ using (identity)
+ {
+ var stream = AgeEncrypt.DecryptReader(new MemoryStream(ciphertext), identity);
+ using (var sink = new MemoryStream()) stream.CopyTo(sink);
+
+ stream.Close(); // documented alias for Dispose()
+ stream.Dispose(); // and disposing twice is legal
+ }
+
+ Assert.False(PoolHandsOutTheSameArrayTwice(),
+ "ArrayPool handed the same array to two independent renters — a buffer was returned twice");
+ }
+
+ [Fact]
+ public void EncryptReader_ClosedThenDisposed_DoesNotReturnPooledBuffersTwice()
+ {
+ using var identity = X25519Identity.Generate();
+
+ var stream = AgeEncrypt.EncryptReader(new MemoryStream(new byte[4096]), identity.Recipient);
+ using (var sink = new MemoryStream()) stream.CopyTo(sink);
+
+ stream.Close();
+ stream.Dispose();
+
+ Assert.False(PoolHandsOutTheSameArrayTwice(),
+ "ArrayPool handed the same array to two independent renters — a buffer was returned twice");
+ }
+
+ [Fact]
+ public void ArmoredEncryptReader_ClosedThenDisposed_DoesNotReturnPooledBuffersTwice()
+ {
+ using var identity = X25519Identity.Generate();
+
+ var stream = AgeEncrypt.EncryptReader(new MemoryStream(new byte[4096]), true, identity.Recipient);
+ using (var sink = new MemoryStream()) stream.CopyTo(sink);
+
+ stream.Close();
+ stream.Dispose();
+
+ Assert.False(PoolHandsOutTheSameArrayTwice(),
+ "ArrayPool handed the same array to two independent renters — a buffer was returned twice");
+ }
+
+ // StreamReader.Dispose disposes the stream it wraps, so this idiomatic shape disposes twice
+ // without the caller ever writing Dispose.
+ [Fact]
+ public void DecryptReader_WrappedInStreamReader_DoesNotReturnPooledBuffersTwice()
+ {
+ var (ciphertext, identity) = Encrypted();
+ using (identity)
+ {
+ using (var stream = AgeEncrypt.DecryptReader(new MemoryStream(ciphertext), identity))
+ using (var reader = new StreamReader(stream))
+ _ = reader.ReadToEnd();
+ }
+
+ Assert.False(PoolHandsOutTheSameArrayTwice(),
+ "ArrayPool handed the same array to two independent renters — a buffer was returned twice");
+ }
+
+ [Fact]
+ public void DecryptReader_ReadAfterDispose_Throws()
+ {
+ var (ciphertext, identity) = Encrypted();
+ using (identity)
+ {
+ var stream = AgeEncrypt.DecryptReader(new MemoryStream(ciphertext), identity);
+ stream.Dispose();
+
+ Assert.Throws(() => stream.Read(new byte[16], 0, 16));
+ }
+ }
+
+ [Fact]
+ public void EncryptReader_ReadAfterDispose_Throws()
+ {
+ using var identity = X25519Identity.Generate();
+
+ var stream = AgeEncrypt.EncryptReader(new MemoryStream(new byte[64]), identity.Recipient);
+ stream.Dispose();
+
+ Assert.Throws(() => stream.Read(new byte[16], 0, 16));
+ }
+
+ // A corrupted pool shows up as bogus authentication failures on files that are perfectly
+ // well-formed, which is how this bug would actually be reported.
+ [Fact]
+ public void DoubleDispose_DoesNotCorruptLaterDecryptions()
+ {
+ var (ciphertext, identity) = Encrypted(200_000);
+ using (identity)
+ {
+ var stream = AgeEncrypt.DecryptReader(new MemoryStream(ciphertext), identity);
+ using (var sink = new MemoryStream()) stream.CopyTo(sink);
+ stream.Close();
+ stream.Dispose();
+
+ using var input = new MemoryStream(ciphertext);
+ using var output = new MemoryStream();
+ AgeEncrypt.Decrypt(input, output, identity);
+
+ Assert.Equal(200_000, output.Length);
+ }
+ }
+}
diff --git a/Age.Tests/PostQuantumValidationTests.cs b/Age.Tests/PostQuantumValidationTests.cs
new file mode 100644
index 0000000..8e71ce4
--- /dev/null
+++ b/Age.Tests/PostQuantumValidationTests.cs
@@ -0,0 +1,82 @@
+using Age.Crypto;
+using Age.Recipients;
+using Xunit;
+
+namespace Age.Tests;
+
+///
+/// I2 / C9 — an ML-KEM-768-X25519 recipient string is 1216 bytes of bech32, and Parse
+/// used to check only the HRP, the length and the case. A structurally invalid encapsulation key
+/// was accepted, so a recipients-file validator passed a file that then failed partway through an
+/// encryption. Go's ParseHybridRecipient runs the ByteEncode/ByteDecode round trip at parse
+/// time; now does the same.
+///
+public class PostQuantumValidationTests
+{
+ // 1152 bytes of 0xFF cannot be a valid ML-KEM-768 encapsulation key: the coefficients exceed
+ // the modulus, so ByteDecode rejects it.
+ private static byte[] MalformedPublicKey()
+ {
+ var key = new byte[XWing.PublicKeySize];
+ Array.Fill(key, (byte)0xFF);
+ return key;
+ }
+
+ [Fact]
+ public void Parse_RejectsAStructurallyInvalidEncapsulationKey()
+ {
+ var recipientString = Bech32.Encode("age1pq", MalformedPublicKey());
+
+ var ex = Assert.Throws(() => MlKem768X25519Recipient.Parse(recipientString));
+
+ Assert.Contains("ML-KEM-768", ex.Message, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void Parse_StillAcceptsAGenuineRecipient()
+ {
+ using var identity = MlKem768X25519Identity.Generate();
+ var text = identity.Recipient.ToString();
+
+ Assert.Equal(text, MlKem768X25519Recipient.Parse(text).ToString());
+ }
+
+ [Fact]
+ public void ValidatePublicKey_RejectsTheWrongLength()
+ {
+ var ex = Assert.Throws(() => XWing.ValidatePublicKey(new byte[10]));
+
+ Assert.Contains($"{XWing.PublicKeySize} bytes", ex.Message, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void ValidatePublicKey_AcceptsAGenuineKey()
+ {
+ using var identity = MlKem768X25519Identity.Generate();
+ var (_, data) = Bech32.Decode(identity.Recipient.ToString());
+
+ XWing.ValidatePublicKey(data); // must not throw
+ }
+
+ // Encaps is internal and reachable without going through Parse, so it keeps its own guard —
+ // C9's point was that Decaps guarded its inputs and Encaps did not.
+ [Fact]
+ public void Encaps_OnAMalformedKey_IsAnAgeExceptionNotABclOne()
+ {
+ var ex = Record.Exception(() => XWing.Encaps(MalformedPublicKey()));
+
+ Assert.NotNull(ex);
+ Assert.IsAssignableFrom(ex);
+ Assert.Contains("ML-KEM-768", ex.Message, StringComparison.Ordinal);
+ }
+
+ // Through the public API this is now unreachable — Parse rejects such a recipient first —
+ // which is exactly the belt-and-braces the two fixes together provide.
+ [Fact]
+ public void EncryptingToAMalformedRecipient_FailsAtParseNotMidEncryption()
+ {
+ var recipientString = Bech32.Encode("age1pq", MalformedPublicKey());
+
+ Assert.Throws(() => MlKem768X25519Recipient.Parse(recipientString));
+ }
+}
diff --git a/Age.Tests/RandomAccessTruncationTests.cs b/Age.Tests/RandomAccessTruncationTests.cs
new file mode 100644
index 0000000..7388047
--- /dev/null
+++ b/Age.Tests/RandomAccessTruncationTests.cs
@@ -0,0 +1,167 @@
+using Age;
+using Age.Recipients;
+using Xunit;
+
+namespace Age.Tests;
+
+///
+/// The seekable path must reject a truncated or tampered payload exactly as the
+/// forward-only path does. Chunk layout alone cannot tell a truncated file from a
+/// shorter one, so authenticates the final chunk at
+/// construction. Every case here asserts both paths agree.
+///
+public class RandomAccessTruncationTests
+{
+ // Sizes chosen so the surviving final chunk is exactly its 16-byte tag (cut == size % 65536),
+ // which is the case the old layout arithmetic accepted silently.
+ [Theory]
+ [InlineData(65537, 1)]
+ [InlineData(65541, 5)]
+ [InlineData(131073, 1)]
+ [InlineData(131172, 100)]
+ // Truncations that leave a partial final chunk.
+ [InlineData(196608, 5)]
+ [InlineData(100_000, 1000)]
+ [InlineData(200, 7)]
+ public void TruncatedPayload_RejectedByBothPaths(int size, int cut)
+ {
+ using var identity = X25519Identity.Generate();
+ var truncated = Truncate(Encrypt(size, identity.Recipient), cut);
+
+ AssertBothPathsReject(truncated, identity);
+ }
+
+ // S3: with the whole payload chopped to a single 16-byte tag the computed plaintext
+ // length is 0, and nothing at all used to be authenticated.
+ [Theory]
+ [InlineData(65537)]
+ [InlineData(131072)]
+ [InlineData(100)]
+ public void PayloadChoppedToBareTag_RejectedByBothPaths(int size)
+ {
+ using var identity = X25519Identity.Generate();
+ var ciphertext = Encrypt(size, identity.Recipient);
+ var payloadStart = ciphertext.Length - EncryptedLength(size);
+
+ AssertBothPathsReject(ciphertext[..(int)(payloadStart + 16)], identity);
+ }
+
+ // S3: an empty file's only chunk is its tag, so a tampered tag or nonce used to be invisible.
+ [Theory]
+ [InlineData(1)] // last tag byte
+ [InlineData(17)] // last payload-nonce byte
+ public void TamperedEmptyFile_RejectedByBothPaths(int bytesFromEnd)
+ {
+ using var identity = X25519Identity.Generate();
+ var ciphertext = Encrypt(0, identity.Recipient);
+ ciphertext[^bytesFromEnd] ^= 0xFF;
+
+ AssertBothPathsReject(ciphertext, identity);
+ }
+
+ [Fact]
+ public void ValidEmptyFile_StillAccepted()
+ {
+ using var identity = X25519Identity.Generate();
+ using var input = new MemoryStream(Encrypt(0, identity.Recipient));
+ using var ra = new AgeRandomAccess(input, identity);
+
+ Assert.Equal(0, ra.PlaintextLength);
+ Assert.Equal(0, ra.ReadAt(0, new byte[10]));
+ }
+
+ // C11: the spec requires that seeking relative to the end first verify the final chunk.
+ // Length and Seek(0, End) are now derived from an authenticated chunk, because a
+ // truncated file never yields a reader at all.
+ [Fact]
+ public void SeekFromEnd_OnTruncatedFile_NeverReportsAWrongLength()
+ {
+ using var identity = X25519Identity.Generate();
+ var truncated = Truncate(Encrypt(196608, identity.Recipient), 5);
+
+ using var input = new MemoryStream(truncated);
+ Assert.Throws(() => new AgeRandomAccess(input, identity));
+ }
+
+ [Fact]
+ public void SeekFromEnd_OnValidFile_ReportsAuthenticatedLength()
+ {
+ using var identity = X25519Identity.Generate();
+ var plaintext = new byte[196608];
+ new Random(7).NextBytes(plaintext);
+
+ using var input = new MemoryStream(Encrypt(plaintext, identity.Recipient));
+ using var ra = new AgeRandomAccess(input, identity);
+ using var stream = ra.GetStream();
+
+ Assert.Equal(plaintext.Length, stream.Length);
+ Assert.Equal(plaintext.Length, stream.Seek(0, SeekOrigin.End));
+ }
+
+ [Fact]
+ public void TamperedFinalChunk_RejectedByBothPaths()
+ {
+ using var identity = X25519Identity.Generate();
+ var ciphertext = Encrypt(100_000, identity.Recipient);
+ ciphertext[^1] ^= 0xFF;
+
+ AssertBothPathsReject(ciphertext, identity);
+ }
+
+ [Fact]
+ public void TruncatedArmoredPayload_Rejected()
+ {
+ using var identity = X25519Identity.Generate();
+ var plaintext = new byte[65537];
+ new Random(9).NextBytes(plaintext);
+
+ using var source = new MemoryStream(plaintext);
+ using var armored = new MemoryStream();
+ AgeEncrypt.Encrypt(source, armored, armor: true, identity.Recipient);
+
+ // Drop the last body line before the END marker: the dearmored payload loses its
+ // final chunk, which layout arithmetic alone would still accept.
+ var lines = new List(System.Text.Encoding.ASCII.GetString(armored.ToArray())
+ .Split('\n', StringSplitOptions.RemoveEmptyEntries));
+ lines.RemoveAt(lines.Count - 2);
+ var bytes = System.Text.Encoding.ASCII.GetBytes(string.Join('\n', lines) + "\n");
+
+ using var input = new MemoryStream(bytes);
+ Assert.Throws(() => new AgeRandomAccess(input, identity));
+ }
+
+ private static void AssertBothPathsReject(byte[] ciphertext, IIdentity identity)
+ {
+ using var forwardInput = new MemoryStream(ciphertext);
+ using var sink = new MemoryStream();
+ Assert.Throws(() => AgeEncrypt.Decrypt(forwardInput, sink, identity));
+
+ using var seekInput = new MemoryStream(ciphertext);
+ Assert.Throws(() => new AgeRandomAccess(seekInput, identity));
+ }
+
+ private static long EncryptedLength(long plaintextLength)
+ {
+ var chunks = Math.Max(1, (plaintextLength + 65535) / 65536);
+ return plaintextLength + chunks * 16;
+ }
+
+ private static byte[] Truncate(byte[] ciphertext, int cut) => ciphertext[..^cut];
+
+ private static byte[] Encrypt(int size, IRecipient recipient)
+ {
+ var plaintext = new byte[size];
+ if (size > 0) new Random(42).NextBytes(plaintext);
+
+ return Encrypt(plaintext, recipient);
+ }
+
+ private static byte[] Encrypt(byte[] plaintext, IRecipient recipient)
+ {
+ using var input = new MemoryStream(plaintext);
+ using var output = new MemoryStream();
+ AgeEncrypt.Encrypt(input, output, recipient);
+
+ return output.ToArray();
+ }
+}
diff --git a/Age.Tests/StreamOwnershipTests.cs b/Age.Tests/StreamOwnershipTests.cs
new file mode 100644
index 0000000..7517583
--- /dev/null
+++ b/Age.Tests/StreamOwnershipTests.cs
@@ -0,0 +1,150 @@
+using Age.Recipients;
+using Xunit;
+
+namespace Age.Tests;
+
+///
+/// The library never disposes a stream the caller supplied — it disposes only what it created
+/// itself. That rule used to hold for binary input and break for armored input, because the
+/// dearmor wrapper chain cascaded all the way down to the caller's
+/// ciphertext stream. Every entry point that can take armored input is covered here in both
+/// shapes, so the two can never drift apart again.
+///
+public class StreamOwnershipTests
+{
+ /// A stream that records whether anyone disposed it.
+ private sealed class Tracked(byte[] data) : MemoryStream(data, writable: false)
+ {
+ public bool Disposed { get; private set; }
+
+ protected override void Dispose(bool disposing)
+ {
+ if (disposing)
+ Disposed = true;
+
+ base.Dispose(disposing);
+ }
+ }
+
+ private static (X25519Identity Identity, byte[] Ciphertext) Encrypt(bool armored, int size = 100)
+ {
+ var identity = X25519Identity.Generate();
+ var plaintext = new byte[size];
+ new Random(7).NextBytes(plaintext);
+
+ using var input = new MemoryStream(plaintext);
+ using var output = new MemoryStream();
+ AgeEncrypt.Encrypt(input, output, armored, identity.Recipient);
+
+ return (identity, output.ToArray());
+ }
+
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public void Decrypt_Does_Not_Dispose_The_Caller_Stream(bool armored)
+ {
+ var (identity, ciphertext) = Encrypt(armored);
+ using var _ = identity;
+
+ var input = new Tracked(ciphertext);
+ using var output = new MemoryStream();
+ AgeEncrypt.Decrypt(input, output, identity);
+
+ Assert.False(input.Disposed);
+ }
+
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public void DecryptReader_Does_Not_Dispose_The_Caller_Stream(bool armored)
+ {
+ var (identity, ciphertext) = Encrypt(armored);
+ using var _ = identity;
+
+ var input = new Tracked(ciphertext);
+ using (var reader = AgeEncrypt.DecryptReader(input, identity))
+ reader.CopyTo(Stream.Null);
+
+ Assert.False(input.Disposed);
+ }
+
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public void HeaderParse_Does_Not_Dispose_The_Caller_Stream(bool armored)
+ {
+ var (identity, ciphertext) = Encrypt(armored);
+ using var _ = identity;
+
+ var input = new Tracked(ciphertext);
+ AgeHeader.Parse(input);
+
+ Assert.False(input.Disposed);
+ }
+
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public void RandomAccess_Does_Not_Dispose_The_Caller_Stream(bool armored)
+ {
+ var (identity, ciphertext) = Encrypt(armored);
+ using var _ = identity;
+
+ var input = new Tracked(ciphertext);
+ using (var random = new AgeRandomAccess(input, identity))
+ Assert.Equal(100, random.PlaintextLength);
+
+ Assert.False(input.Disposed);
+ }
+
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public void Encrypt_Does_Not_Dispose_The_Caller_Streams(bool armored)
+ {
+ using var identity = X25519Identity.Generate();
+ var input = new Tracked(new byte[100]);
+ using var output = new MemoryStream();
+
+ AgeEncrypt.Encrypt(input, output, armored, identity.Recipient);
+
+ Assert.False(input.Disposed);
+ Assert.True(output.CanWrite); // still usable, i.e. not disposed
+ }
+
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public void EncryptReader_Does_Not_Dispose_The_Caller_Stream(bool armored)
+ {
+ using var identity = X25519Identity.Generate();
+ var input = new Tracked(new byte[100]);
+
+ using (var reader = AgeEncrypt.EncryptReader(input, armored, identity.Recipient))
+ reader.CopyTo(Stream.Null);
+
+ Assert.False(input.Disposed);
+ }
+
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public void The_Same_Stream_Can_Be_Decrypted_Twice(bool armored)
+ {
+ // The user-visible consequence of the old behaviour: a second decrypt of the same
+ // armored stream threw ObjectDisposedException, while the binary case worked.
+ var (identity, ciphertext) = Encrypt(armored);
+ using var _ = identity;
+
+ using var input = new MemoryStream(ciphertext);
+ using var first = new MemoryStream();
+ AgeEncrypt.Decrypt(input, first, identity);
+
+ input.Position = 0;
+ using var second = new MemoryStream();
+ AgeEncrypt.Decrypt(input, second, identity);
+
+ Assert.Equal(first.ToArray(), second.ToArray());
+ }
+}
diff --git a/Age.Tests/UnitTests.cs b/Age.Tests/UnitTests.cs
index f3eb981..6312fc7 100644
--- a/Age.Tests/UnitTests.cs
+++ b/Age.Tests/UnitTests.cs
@@ -752,6 +752,73 @@ public void Allows_Trailing_Whitespace_After_End()
using var dearmored = AsciiArmor.Dearmor(armored);
Assert.Equal(data, ReadAllBytes(dearmored));
}
+
+ // --- C1: a full-width final line may carry base64 padding ---
+
+ [Fact]
+ public void Armor_Dearmor_RoundTrip_Every_Length_Through_A_Full_Mod48_Cycle()
+ {
+ // A final chunk of 46 bytes encodes to 64 characters ending "==", and one of 47 bytes to
+ // 64 characters ending "=". The decoder used to require every 64-character line to decode
+ // to a full 48 bytes, so it rejected its own output at those two residues — 2 of every 48
+ // payload lengths, ~4% of arbitrary armored files.
+ for (var length = 0; length <= 200; length++)
+ {
+ var data = new byte[length];
+ new Random(length).NextBytes(data);
+
+ using var input = new MemoryStream(data);
+ using var armored = new MemoryStream();
+ AsciiArmor.Armor(input, armored);
+
+ armored.Position = 0;
+ using var dearmored = AsciiArmor.Dearmor(armored);
+ Assert.Equal(data, ReadAllBytes(dearmored));
+ }
+ }
+
+ [Fact]
+ public void Accepts_Full_Width_Final_Line_With_Padding()
+ {
+ // 46 bytes -> "…==", 47 bytes -> "…=", both exactly 64 characters wide.
+ foreach (var length in new[] { 46, 47 })
+ {
+ var data = new byte[length];
+ new Random(length).NextBytes(data);
+
+ var body = Convert.ToBase64String(data);
+ Assert.Equal(64, body.Length);
+
+ var text = $"-----BEGIN AGE ENCRYPTED FILE-----\n{body}\n-----END AGE ENCRYPTED FILE-----\n";
+ using var stream = new MemoryStream(Encoding.ASCII.GetBytes(text));
+ using var dearmored = AsciiArmor.Dearmor(stream);
+ Assert.Equal(data, ReadAllBytes(dearmored));
+ }
+ }
+
+ [Fact]
+ public void Reject_NonCanonical_Padding_On_A_Full_Width_Line()
+ {
+ // Accepting padded full-width lines must not weaken canonicality: the bits the padding
+ // covers still have to be zero. "…B==" sets bits that "…A==" leaves clear.
+ var body = new string('A', 61) + "B==";
+ Assert.Equal(64, body.Length);
+ var text = $"-----BEGIN AGE ENCRYPTED FILE-----\n{body}\n-----END AGE ENCRYPTED FILE-----\n";
+ using var stream = new MemoryStream(Encoding.ASCII.GetBytes(text));
+ var ex = Assert.Throws(() => { using var s = AsciiArmor.Dearmor(stream); ReadAllBytes(s); });
+ Assert.Contains("non-canonical", ex.Message);
+ }
+
+ [Fact]
+ public void Reject_Full_Width_Padded_Line_Followed_By_Another_Body_Line()
+ {
+ // A padded line ends the body whatever its width, so anything after it is an error.
+ var padded = Convert.ToBase64String(new byte[46]);
+ var text = $"-----BEGIN AGE ENCRYPTED FILE-----\n{padded}\n{new string('A', 64)}\n-----END AGE ENCRYPTED FILE-----\n";
+ using var stream = new MemoryStream(Encoding.ASCII.GetBytes(text));
+ var ex = Assert.Throws(() => { using var s = AsciiArmor.Dearmor(stream); ReadAllBytes(s); });
+ Assert.Contains("not the last line", ex.Message);
+ }
}
public class StreamEncryptionTests
@@ -901,19 +968,22 @@ public void ValidateWorkFactor_Invalid_Values(string input)
}
[Fact]
- public void Unwrap_Rejects_WorkFactor_Over_20()
+ // The cap is 22, matching Go's ScryptIdentity default, so that files the reference CLI
+ // produces are readable. This asserted 21 was rejected, which is exactly the interop
+ // failure (I3): genuine age-produced files at work factor 21 and 22 were refused.
+ public void Unwrap_Rejects_WorkFactor_Over_22()
{
var recipient = new ScryptRecipient("password");
var salt = new byte[16];
var saltB64 = Base64Unpadded.Encode(salt);
- var stanza = new Stanza("scrypt", [saltB64, "21"], new byte[32]);
+ var stanza = new Stanza("scrypt", [saltB64, "23"], new byte[32]);
Assert.Throws(() => recipient.Unwrap(stanza));
}
[Theory]
[InlineData(0)]
[InlineData(-1)]
- [InlineData(21)]
+ [InlineData(23)]
[InlineData(31)]
[InlineData(64)]
public void Constructor_Rejects_OutOfRange_WorkFactor(int workFactor)
diff --git a/Age.Tests/ValidationTests.cs b/Age.Tests/ValidationTests.cs
index 612a397..8be1cb1 100644
--- a/Age.Tests/ValidationTests.cs
+++ b/Age.Tests/ValidationTests.cs
@@ -150,6 +150,35 @@ public void Stanza_Ctor_InvalidArg_ThrowsArgumentException(string arg)
Assert.Throws(() => new Stanza("type", [arg], []));
}
+ // age.md:130 gives `argument = 1*VCHAR`, so the legal range is exactly 0x21-0x7E. One
+ // predicate now serves the constructor, the parser and PluginConnection, so these pin its
+ // edges: a drift of one in either direction changes which files the library accepts.
+ [Theory]
+ [InlineData(' ', false)] // space — the argument separator
+ [InlineData('!', true)] // '!' — first legal
+ [InlineData('~', true)] // '~' — last legal
+ [InlineData('', false)] // DEL — passes the byte validator, must fail here
+ public void StanzaString_AcceptsExactlyTheVCharRange(char c, bool legal)
+ {
+ var type = $"a{c}b";
+
+ if (legal)
+ Assert.Equal(type, new Stanza(type, [], []).Type);
+ else
+ Assert.Throws(() => new Stanza(type, [], []));
+ }
+
+ // The same rule on the parse path, where the failure is malformed wire data rather than a
+ // caller mistake. Space is excluded: on the wire it is the separator, so "-> a b" is a
+ // legal stanza with an argument, not an invalid type.
+ [Fact]
+ public void Stanza_Parse_RejectsDelInATypeTag()
+ {
+ using var ms = new MemoryStream("-> ab\n\n"u8.ToArray());
+
+ Assert.Throws(() => Stanza.Parse(new HeaderReader(ms)));
+ }
+
[Fact]
public void Stanza_Ctor_NullInputs_ThrowArgumentNullException()
{
diff --git a/Age/AgeEncrypt.cs b/Age/AgeEncrypt.cs
index 66596e6..44dc5d0 100644
--- a/Age/AgeEncrypt.cs
+++ b/Age/AgeEncrypt.cs
@@ -12,7 +12,6 @@ namespace Age;
///
public static class AgeEncrypt
{
- private const int FileKeySize = 16;
internal const int PayloadNonceSize = 16;
internal const int PayloadKeySize = 32;
@@ -41,9 +40,7 @@ public static void Encrypt(Stream input, Stream output, params ReadOnlySpanOne or more recipients. Must all share the same .
public static void Encrypt(Stream input, Stream output, bool armor, params ReadOnlySpan recipients)
{
- if (recipients.Length == 0)
- throw new ArgumentException("at least one recipient is required", nameof(recipients));
-
+ ArgumentException.ThrowIfEmpty(recipients, "recipient");
using var stream = EncryptReader(input, armor, recipients);
stream.CopyTo(output);
}
@@ -65,9 +62,7 @@ public static void Decrypt(Stream input, Stream output, params ReadOnlySpan.Empty);
+ output.EnsureMaterialized();
}
///
@@ -79,25 +74,19 @@ public static void Decrypt(Stream input, Stream output, params ReadOnlySpan
public static void EncryptDetached(Stream input, Stream headerOutput, Stream payloadOutput, params ReadOnlySpan recipients)
{
- if (recipients.Length == 0)
- throw new ArgumentException("at least one recipient is required", nameof(recipients));
+ ArgumentException.ThrowIfEmpty(recipients, "recipient");
- var (header, fileKey) = BuildHeaderAndFileKey(recipients);
- try
- {
- header.WriteTo(headerOutput, fileKey);
+ using var fileKey = FileKey.Fresh();
- var payloadNonce = new byte[PayloadNonceSize];
- RandomNumberGenerator.Fill(payloadNonce);
- var payloadKey = CryptoHelper.HkdfDerive(fileKey, payloadNonce, "payload", PayloadKeySize);
+ var header = BuildHeader(recipients, fileKey.Bytes);
+ header.WriteTo(headerOutput, fileKey.Bytes);
- using var payloadStream = new EncryptStream([], payloadNonce, payloadKey, input);
- payloadStream.CopyTo(payloadOutput);
- }
- finally
- {
- CryptographicOperations.ZeroMemory(fileKey);
- }
+ var payloadNonce = new byte[PayloadNonceSize];
+ RandomNumberGenerator.Fill(payloadNonce);
+ var payloadKey = CryptoHelper.HkdfDerive(fileKey.Bytes, payloadNonce, "payload", PayloadKeySize);
+
+ using var payloadStream = new EncryptStream([], payloadNonce, payloadKey, input);
+ payloadStream.CopyTo(payloadOutput);
}
///
@@ -106,39 +95,20 @@ public static void EncryptDetached(Stream input, Stream headerOutput, Stream pay
///
public static void DecryptDetached(Stream headerInput, Stream payloadInput, Stream output, params ReadOnlySpan identities)
{
- if (identities.Length == 0)
- throw new ArgumentException("at least one identity is required", nameof(identities));
-
- var fileKey = UnwrapFileKey(headerInput, identities);
- try
- {
- var payloadNonce = new byte[PayloadNonceSize];
- var total = 0;
+ ArgumentException.ThrowIfEmpty(identities, "identity");
- while (total < PayloadNonceSize)
- {
- var read = payloadInput.Read(payloadNonce.AsSpan(total));
- if (read == 0)
- break;
+ using var fileKey = UnwrapFileKey(headerInput, identities);
- total += read;
- }
-
- if (total != PayloadNonceSize)
- throw new AgeHeaderException($"expected {PayloadNonceSize}-byte payload nonce, got {total} bytes");
+ var payloadNonce = new byte[PayloadNonceSize];
+ var total = payloadInput.ReadAtLeast(payloadNonce, PayloadNonceSize, throwOnEndOfStream: false);
+ if (total != PayloadNonceSize)
+ throw new AgeHeaderException($"expected {PayloadNonceSize}-byte payload nonce, got {total} bytes");
- var payloadKey = CryptoHelper.HkdfDerive(fileKey, payloadNonce, "payload", PayloadKeySize);
+ var payloadKey = CryptoHelper.HkdfDerive(fileKey.Bytes, payloadNonce, "payload", PayloadKeySize);
- using var decryptStream = new DecryptStream(payloadKey, payloadInput, ownsStream: false);
- decryptStream.CopyTo(output);
- // Ensure output is touched even when plaintext is empty — matters for
- // lazy-creating writers that only materialize on first Write.
- output.Write(ReadOnlySpan.Empty);
- }
- finally
- {
- CryptographicOperations.ZeroMemory(fileKey);
- }
+ using var decryptStream = new DecryptStream(payloadKey, payloadInput, ownsStream: false);
+ decryptStream.CopyTo(output);
+ output.EnsureMaterialized();
}
///
@@ -158,8 +128,7 @@ public static Stream EncryptReader(Stream plaintext, params ReadOnlySpan
public static Stream EncryptReader(Stream plaintext, bool armor, params ReadOnlySpan recipients)
{
- if (recipients.Length == 0)
- throw new ArgumentException("at least one recipient is required", nameof(recipients));
+ ArgumentException.ThrowIfEmpty(recipients, "recipient");
if (armor)
{
@@ -167,16 +136,17 @@ public static Stream EncryptReader(Stream plaintext, bool armor, params ReadOnly
return new ArmorStream(ciphertextStream);
}
- var (header, fileKey) = BuildHeaderAndFileKey(recipients);
+ using var fileKey = FileKey.Fresh();
+
+ var header = BuildHeader(recipients, fileKey.Bytes);
using var headerMs = new MemoryStream();
- header.WriteTo(headerMs, fileKey);
+ header.WriteTo(headerMs, fileKey.Bytes);
var headerBytes = headerMs.ToArray();
var payloadNonce = new byte[PayloadNonceSize];
RandomNumberGenerator.Fill(payloadNonce);
- var payloadKey = CryptoHelper.HkdfDerive(fileKey, payloadNonce, "payload", PayloadKeySize);
- CryptographicOperations.ZeroMemory(fileKey);
+ var payloadKey = CryptoHelper.HkdfDerive(fileKey.Bytes, payloadNonce, "payload", PayloadKeySize);
return new EncryptStream(headerBytes, payloadNonce, payloadKey, plaintext);
}
@@ -189,75 +159,113 @@ public static Stream EncryptReader(Stream plaintext, bool armor, params ReadOnly
///
public static Stream DecryptReader(Stream ciphertext, params ReadOnlySpan identities)
{
- if (identities.Length == 0)
- throw new ArgumentException("at least one identity is required", nameof(identities));
+ ArgumentException.ThrowIfEmpty(identities, "identity");
- var (binaryInput, needsDispose) = DeArmorIfNeeded(ciphertext);
+ // `dearmored` is the ownership token: non-null means we made it and must dispose it.
+ // Unlike the other two dearmor sites this one cannot use `using`, because on success
+ // ownership passes to the returned DecryptStream — so the catch covers failure alone.
+ var dearmored = ciphertext.CanSeek && AsciiArmor.IsArmored(ciphertext)
+ ? AsciiArmor.Dearmor(ciphertext)
+ : null;
try
{
+ var binaryInput = dearmored ?? ciphertext;
var (fileKey, reader) = UnwrapHeaderFromReader(binaryInput, identities);
- var payloadNonce = ReadPayloadNonce(reader);
- var payloadKey = CryptoHelper.HkdfDerive(fileKey, payloadNonce, "payload", PayloadKeySize);
- CryptographicOperations.ZeroMemory(fileKey);
- return new DecryptStream(payloadKey, binaryInput, needsDispose);
+ using (fileKey)
+ {
+ var payloadNonce = ReadPayloadNonce(reader);
+ var payloadKey = CryptoHelper.HkdfDerive(fileKey.Bytes, payloadNonce, "payload", PayloadKeySize);
+
+ return new DecryptStream(payloadKey, binaryInput, ownsStream: dearmored is not null);
+ }
}
catch
{
- if (needsDispose) binaryInput.Dispose();
+ dearmored?.Dispose();
throw;
}
}
- private static (Header header, byte[] fileKey) BuildHeaderAndFileKey(ReadOnlySpan recipients)
+ ///
+ /// Wraps for every recipient and returns the resulting header.
+ ///
+ ///
+ /// Takes the file key rather than creating one, so that whoever created it also holds the
+ /// only finally that clears it. When this returned the key as well, a caller reading
+ /// its own method could not tell whether a throw in here leaked — the guarantee lived in
+ /// another method's catch. That non-locality is how S9 happened: all five sites the
+ /// survey found were "the clear is somewhere else" situations. Nothing here owns the key, so
+ /// nothing here has to remember to clear it.
+ ///
+ private static Header BuildHeader(ReadOnlySpan recipients, ReadOnlySpan fileKey)
{
- // Check label consistency — reject mixing PQ and non-PQ recipients
+ // age-plugin.md:227 — every stanza wrapping one file key must carry the exact same label
+ // set, with no partial overlap. Comparing each against the first is that check: string
+ // equality is transitive, so agreeing with the first implies agreeing pairwise.
var firstLabel = recipients[0].Label;
for (var i = 1; i < recipients.Length; i++)
{
if (recipients[i].Label != firstLabel)
- throw new AgeException("cannot mix recipients with different security labels");
+ throw new AgeException(IncompatibleLabels(firstLabel, recipients[i].Label));
}
- var fileKey = new byte[FileKeySize];
- RandomNumberGenerator.Fill(fileKey);
-
var header = new Header();
+ // A plugin may legitimately answer one wrap-file-key with several stanzas, so ask for
+ // all of them where the recipient can produce more than one. IRecipient.Wrap is
+ // public, shipped API returning a single Stanza and cannot be widened.
foreach (var recipient in recipients)
- header.Stanzas.Add(recipient.Wrap(fileKey));
+ {
+ if (recipient is IMultiStanzaRecipient multi)
+ header.Stanzas.AddRange(multi.WrapAll(fileKey));
+ else
+ header.Stanzas.Add(recipient.Wrap(fileKey));
+ }
- // A scrypt stanza must be the only stanza in the header — the same rule
- // decryption enforces. Checked post-Wrap so custom recipients that emit
- // scrypt stanzas are caught too.
+ // A scrypt stanza must be the only stanza in the header — the same rule decryption
+ // enforces. Checked post-Wrap so custom recipients that emit scrypt stanzas are
+ // caught too.
if (header.Stanzas.Count > 1 && header.Stanzas.Any(s => s.Type == "scrypt"))
- {
- CryptographicOperations.ZeroMemory(fileKey);
throw new AgeException("a passphrase (scrypt) recipient must be the only recipient");
- }
- return (header, fileKey);
+ return header;
+ }
+
+ ///
+ /// Explains a label mismatch, naming the consequence when post-quantum security is what was
+ /// lost. "Different security labels" is accurate but leaves the user to work out the cost.
+ ///
+ private static string IncompatibleLabels(string? a, string? b)
+ {
+ const string postQuantum = "postquantum";
+
+ if (a == postQuantum || b == postQuantum)
+ return "cannot mix post-quantum and classical recipients: the file would be "
+ + "readable by a quantum computer, so the post-quantum recipient buys nothing";
+
+ return $"cannot mix recipients with different security labels: {Describe(a)} and {Describe(b)}";
+
+ static string Describe(string? label) => label is null ? "none" : $"\"{label}\"";
}
- private static byte[] UnwrapFileKey(Stream headerInput, ReadOnlySpan identities)
+ private static FileKey UnwrapFileKey(Stream headerInput, ReadOnlySpan identities)
{
var (fileKey, _) = UnwrapHeaderFromReader(headerInput, identities);
return fileKey;
}
- internal static (byte[] fileKey, HeaderReader reader) UnwrapHeaderFromReader(Stream binaryInput, ReadOnlySpan identities)
+ internal static (FileKey fileKey, HeaderReader reader) UnwrapHeaderFromReader(Stream binaryInput, ReadOnlySpan identities)
{
var reader = new HeaderReader(binaryInput);
var header = ParseHeader(reader);
- // Check scrypt constraint: if any stanza is scrypt, it must be the only one
var hasScrypt = header.Stanzas.Any(s => s.Type == "scrypt");
if (hasScrypt && header.Stanzas.Count > 1)
throw new AgeHeaderException("scrypt stanza must be the only stanza in the header");
- // Try each identity against all stanzas (batch unwrap supports plugin protocol)
byte[]? fileKey = null;
foreach (var identity in identities)
{
@@ -269,19 +277,20 @@ internal static (byte[] fileKey, HeaderReader reader) UnwrapHeaderFromReader(Str
if (fileKey is null)
throw new NoIdentityMatchException();
- if (fileKey.Length != FileKeySize)
- throw new AgeHeaderException($"file key must be {FileKeySize} bytes, got {fileKey.Length}");
-
- header.VerifyMac(fileKey);
- return (fileKey, reader);
- }
-
- private static (Stream binaryInput, bool needsDispose) DeArmorIfNeeded(Stream input)
- {
- if (input.CanSeek && AsciiArmor.IsArmored(input))
- return (AsciiArmor.Dearmor(input), true);
+ // Adopt validates the length and takes ownership, so from here a throw disposes rather
+ // than abandons. VerifyMac failing is routine — a tampered or corrupted header.
+ var owned = FileKey.Adopt(fileKey);
- return (input, false);
+ try
+ {
+ header.VerifyMac(owned.Bytes);
+ return (owned, reader);
+ }
+ catch
+ {
+ owned.Dispose();
+ throw;
+ }
}
private static byte[] ReadPayloadNonce(HeaderReader reader)
@@ -305,4 +314,4 @@ private static Header ParseHeader(HeaderReader reader)
throw new AgeHeaderException($"header parse error: {ex.Message}", ex);
}
}
-}
+}
\ No newline at end of file
diff --git a/Age/AgeHeader.cs b/Age/AgeHeader.cs
index e06738a..cf95b8b 100644
--- a/Age/AgeHeader.cs
+++ b/Age/AgeHeader.cs
@@ -44,42 +44,23 @@ private AgeHeader(IReadOnlyList recipients, long payloadOffset, bool isA
/// The input is armored and the armor is malformed.
public static AgeHeader Parse(Stream input)
{
- var isArmored = false;
- Stream binaryInput;
- var needsDispose = false;
+ var isArmored = input.CanSeek && AsciiArmor.IsArmored(input);
- if (input.CanSeek && AsciiArmor.IsArmored(input))
- {
- isArmored = true;
- binaryInput = AsciiArmor.Dearmor(input);
- needsDispose = true;
- }
- else
- {
- binaryInput = input;
- }
+ // Which variable holds the stream is the ownership claim: `dearmored` is ours to dispose,
+ // `input` is the caller's. `using` on a null is a no-op, so the borrowed case needs no flag.
+ using var dearmored = isArmored ? AsciiArmor.Dearmor(input) : null;
+ var reader = new HeaderReader(dearmored ?? input);
+ Header header;
try
{
- var reader = new HeaderReader(binaryInput);
-
- Header header;
- try
- {
- header = Header.Parse(reader);
- }
- catch (FormatException ex)
- {
- throw new AgeHeaderException($"header parse error: {ex.Message}", ex);
- }
-
- var payloadOffset = reader.RawBytes.Length;
- return new AgeHeader(header.Stanzas.AsReadOnly(), payloadOffset, isArmored);
+ header = Header.Parse(reader);
}
- finally
+ catch (FormatException ex)
{
- if (needsDispose)
- binaryInput.Dispose();
+ throw new AgeHeaderException($"header parse error: {ex.Message}", ex);
}
+
+ return new AgeHeader(header.Stanzas.AsReadOnly(), reader.RawBytes.Length, isArmored);
}
}
\ No newline at end of file
diff --git a/Age/AgeKeygen.cs b/Age/AgeKeygen.cs
index 72d600e..3560125 100644
--- a/Age/AgeKeygen.cs
+++ b/Age/AgeKeygen.cs
@@ -1,3 +1,4 @@
+using System.Security.Cryptography;
using System.Text;
using Age.Crypto;
using Age.Plugin;
@@ -100,8 +101,8 @@ internal static IRecipient ParseRecipientLine(string line, IPluginCallbacks? cal
{
"age" => X25519Recipient.Parse(line),
"age1pq" => MlKem768X25519Recipient.Parse(line),
- _ when hrp.StartsWith("age1") => new PluginRecipient(line, callbacks),
- _ when line.StartsWith("ssh-") => ParseSshRecipient(line),
+ _ when hrp.StartsWith("age1", StringComparison.Ordinal) => new PluginRecipient(line, callbacks),
+ _ when line.StartsWith("ssh-", StringComparison.Ordinal) => ParseSshRecipient(line),
_ => throw new FormatException($"unrecognized recipient: {line}")
};
}
@@ -121,11 +122,11 @@ public static IIdentity[] ParseIdentityFile(string text, IPluginCallbacks? callb
if (trimmed.Length == 0 || trimmed.StartsWith('#'))
continue;
- if (trimmed.StartsWith("AGE-SECRET-KEY-PQ-"))
+ if (trimmed.StartsWith("AGE-SECRET-KEY-PQ-", StringComparison.Ordinal))
identities.Add(MlKem768X25519Identity.Parse(trimmed));
- else if (trimmed.StartsWith("AGE-SECRET-KEY-"))
+ else if (trimmed.StartsWith("AGE-SECRET-KEY-", StringComparison.Ordinal))
identities.Add(X25519Identity.Parse(trimmed));
- else if (trimmed.StartsWith("AGE-PLUGIN-"))
+ else if (trimmed.StartsWith("AGE-PLUGIN-", StringComparison.Ordinal))
identities.Add(new PluginIdentity(trimmed, callbacks));
else
throw new FormatException($"unrecognized line in identity file: {trimmed}");
@@ -137,26 +138,56 @@ public static IIdentity[] ParseIdentityFile(string text, IPluginCallbacks? callb
///
/// Decrypts an encrypted (passphrase-protected) identity file and parses the contained identities.
///
+ ///
+ /// The decrypted file contains AGE-SECRET-KEY-1… lines in the clear. The byte
+ /// copies are cleared here; the they are decoded into cannot be,
+ /// because takes a string and is shipped public API.
+ /// Treat this as reducing the exposure rather than eliminating it.
+ ///
public static IIdentity[] DecryptIdentityFile(byte[] data, string passphrase)
{
using var input = new MemoryStream(data);
using var output = new MemoryStream();
AgeEncrypt.Decrypt(input, output, new ScryptRecipient(passphrase));
- var plaintext = Encoding.UTF8.GetString(output.ToArray());
- return ParseIdentityFile(plaintext);
+
+ // ToArray copies out, and the MemoryStream's own buffer keeps the plaintext too —
+ // Dispose does not clear it. Both are private keys in the clear.
+ var plaintextBytes = output.ToArray();
+
+ try
+ {
+ return ParseIdentityFile(Encoding.UTF8.GetString(plaintextBytes));
+ }
+ finally
+ {
+ CryptographicOperations.ZeroMemory(plaintextBytes);
+ CryptographicOperations.ZeroMemory(output.GetBuffer());
+ }
}
///
/// Encrypts an identity file with a passphrase using scrypt.
///
+ ///
+ /// holds private keys in the clear. The UTF-8 copy
+ /// made here is cleared; the caller's string cannot be.
+ ///
public static byte[] EncryptIdentityFile(string identityFileText, string passphrase, bool armor = false, int workFactor = 18)
{
var plaintextBytes = Encoding.UTF8.GetBytes(identityFileText);
- using var input = new MemoryStream(plaintextBytes);
- using var output = new MemoryStream();
- AgeEncrypt.Encrypt(input, output, armor, new ScryptRecipient(passphrase, workFactor));
- return output.ToArray();
+ try
+ {
+ using var input = new MemoryStream(plaintextBytes);
+ using var output = new MemoryStream();
+
+ AgeEncrypt.Encrypt(input, output, armor, new ScryptRecipient(passphrase, workFactor));
+ return output.ToArray();
+ }
+ finally
+ {
+ CryptographicOperations.ZeroMemory(plaintextBytes);
+ }
}
}
\ No newline at end of file
diff --git a/Age/AgeLimits.cs b/Age/AgeLimits.cs
index 3fb34f2..cbd5802 100644
--- a/Age/AgeLimits.cs
+++ b/Age/AgeLimits.cs
@@ -39,4 +39,8 @@ public static class AgeLimits
/// Default: 64 KiB.
///
public const int MaxArmorLineBytes = 64 * 1024;
+
+ // The spec allows leading whitespace before the armor BEGIN marker; unbounded, a file of
+ // nothing but newlines is read to its end before the header is looked for. Matches go-age.
+ internal const int MaxLeadingWhitespaceBytes = 1024;
}
diff --git a/Age/AgeRandomAccess.cs b/Age/AgeRandomAccess.cs
index 95a7d52..32388b2 100644
--- a/Age/AgeRandomAccess.cs
+++ b/Age/AgeRandomAccess.cs
@@ -12,11 +12,12 @@ namespace Age;
/// ciphertext stream, so use one reader per thread.
///
///
-/// Construction parses the header, verifies its MAC, and derives the payload key.
-/// Armored input is supported by materializing the dearmored ciphertext in memory,
-/// so very large armored files cost their full size in memory; binary input is
-/// read in place. Truncation of the final chunk is only detectable when a read
-/// actually reaches it.
+/// Construction parses the header, verifies its MAC, derives the payload key, and
+/// decrypts the final STREAM chunk so that is an
+/// authenticated value and a truncated payload is rejected up front rather than
+/// only when a read happens to reach the end. Armored input is supported by
+/// materializing the dearmored ciphertext in memory, so very large armored files
+/// cost their full size in memory; binary input is read in place.
///
public sealed class AgeRandomAccess : IDisposable
{
@@ -26,7 +27,11 @@ public sealed class AgeRandomAccess : IDisposable
private readonly MemoryStream? _armoredBinaryInput;
private bool _disposed;
- /// Total plaintext length in bytes, computed from the ciphertext layout.
+ ///
+ /// Total plaintext length in bytes. Derived from the final STREAM chunk, which is
+ /// decrypted and authenticated during construction, so this is not merely a layout
+ /// guess over an unverified byte count.
+ ///
public long PlaintextLength { get; }
///
@@ -41,37 +46,31 @@ public sealed class AgeRandomAccess : IDisposable
/// None of the identities matched any stanza.
/// The header is malformed.
/// The header MAC failed verification.
- /// The payload is empty or structurally impossible.
+ /// The payload is empty, structurally impossible, or its
+ /// final chunk is truncated or fails authentication.
public AgeRandomAccess(Stream ciphertext, params ReadOnlySpan identities)
{
if (!ciphertext.CanSeek)
throw new ArgumentException("ciphertext stream must be seekable", nameof(ciphertext));
- if (identities.Length == 0)
- throw new ArgumentException("at least one identity is required", nameof(identities));
+ ArgumentException.ThrowIfEmpty(identities, "identity");
BinaryStream = ciphertext;
- var (binaryInput, needsDispose) = DeArmorInput(ciphertext);
- try
- {
- var info = InitializeFromStream(binaryInput, identities);
- _payloadKey = info.PayloadKey;
- _payloadStart = info.PayloadStart;
- _totalEncryptedPayload = info.TotalEncrypted;
- PlaintextLength = info.PlaintextLength;
-
- if (!needsDispose)
- return;
-
- // Keep the dearmored MemoryStream for ReadAt seeking
- _armoredBinaryInput = (MemoryStream)binaryInput;
- needsDispose = false;
- }
- finally
- {
- if (needsDispose) binaryInput.Dispose();
- }
+ // Armored input is materialized up front because ReadAt needs to seek; the MemoryStream is
+ // kept for the reader's lifetime and released by Dispose. No ownership flag: if the setup
+ // below throws, the constructor throws, nothing is handed out, and the MemoryStream is
+ // garbage — which is all disposing it would achieve, since that does not free its buffer.
+ _armoredBinaryInput = AsciiArmor.IsArmored(ciphertext) ? Materialize(ciphertext) : null;
+
+ if (_armoredBinaryInput is null)
+ ciphertext.Position = 0;
+
+ var info = InitializeFromStream(BinaryStream, identities);
+ _payloadKey = info.PayloadKey;
+ _payloadStart = info.PayloadStart;
+ _totalEncryptedPayload = info.TotalEncrypted;
+ PlaintextLength = info.PlaintextLength;
}
///
@@ -143,22 +142,60 @@ private static PayloadInfo InitializeFromStream(Stream binaryInput, ReadOnlySpan
{
var (fileKey, reader) = AgeEncrypt.UnwrapHeaderFromReader(binaryInput, identities);
- try
+ using (fileKey)
{
var payloadNonce = ReadPayloadNonce(reader);
- var payloadKey = CryptoHelper.HkdfDerive(fileKey, payloadNonce, "payload", AgeEncrypt.PayloadKeySize);
- var payloadStart = binaryInput.Position;
- var totalEncrypted = binaryInput.Length - payloadStart;
+ var payloadKey = CryptoHelper.HkdfDerive(fileKey.Bytes, payloadNonce, "payload", AgeEncrypt.PayloadKeySize);
+
+ try
+ {
+ var payloadStart = binaryInput.Position;
+ var totalEncrypted = binaryInput.Length - payloadStart;
+
+ if (totalEncrypted == 0)
+ throw new AgePayloadException("payload is empty (no chunks)");
+
+ // The spec requires a seekable reader to verify the final chunk before reporting
+ // a length: chunk layout alone cannot tell a truncated file from a shorter one.
+ var plaintextLength = AuthenticateFinalChunk(binaryInput, payloadKey, payloadStart, totalEncrypted);
+ return new PayloadInfo(payloadKey, payloadStart, totalEncrypted, plaintextLength);
+ }
+ catch
+ {
+ CryptographicOperations.ZeroMemory(payloadKey);
+ throw;
+ }
+ }
+ }
- if (totalEncrypted == 0)
- throw new AgePayloadException("payload is empty (no chunks)");
+ ///
+ /// Decrypts the last STREAM chunk with the final flag set and returns the plaintext length
+ /// implied by it. Throws if the payload is truncated, tampered with, or ends in an empty
+ /// final chunk that has predecessors.
+ ///
+ private static long AuthenticateFinalChunk(
+ Stream binaryInput, byte[] payloadKey, long payloadStart, long totalEncrypted)
+ {
+ var totalChunks = ComputeTotalChunks(totalEncrypted);
+ var finalIndex = totalChunks - 1;
+ var finalChunkStart = finalIndex * StreamEncryption.EncryptedChunkSize;
+ var finalChunkEncSize = (int)(totalEncrypted - finalChunkStart);
+
+ if (finalChunkEncSize < StreamEncryption.TagSize)
+ throw new AgePayloadException("chunk too small for authentication tag");
- var plaintextLength = ComputePlaintextLength(totalEncrypted);
- return new PayloadInfo(payloadKey, payloadStart, totalEncrypted, plaintextLength);
+ var encChunk = ReadEncryptedChunk(binaryInput, payloadStart + finalChunkStart, finalChunkEncSize);
+ var plaintext = StreamEncryption.DecryptChunk(payloadKey, finalIndex, true, encChunk);
+
+ try
+ {
+ return plaintext.Length == 0 && finalIndex > 0
+ ? throw new AgePayloadException("final STREAM chunk is empty but there were preceding chunks")
+ : finalIndex * StreamEncryption.ChunkSize + plaintext.Length;
}
finally
{
- CryptographicOperations.ZeroMemory(fileKey);
+ CryptographicOperations.ZeroMemory(plaintext);
}
}
@@ -175,30 +212,27 @@ private byte[] DecryptChunkAt(long plaintextOffset, out int offsetInChunk)
? (int)(_totalEncryptedPayload - chunkIndex * StreamEncryption.EncryptedChunkSize)
: StreamEncryption.EncryptedChunkSize;
- var encChunk = ReadEncryptedChunk(ciphertextPos, encChunkSize);
+ var encChunk = ReadEncryptedChunk(BinaryStream, ciphertextPos, encChunkSize);
var plaintext = StreamEncryption.DecryptChunk(_payloadKey, chunkIndex, isFinal, encChunk);
if (isFinal && plaintext.Length == 0 && chunkIndex > 0)
+ {
+ // Consistency only: the guard condition is plaintext.Length == 0, so the array being
+ // abandoned here is zero-length and there is nothing to leak. Cleared anyway so
+ // "every decrypted chunk is zeroed on every path" holds without a caveat.
+ CryptographicOperations.ZeroMemory(plaintext);
throw new AgePayloadException("final STREAM chunk is empty but there were preceding chunks");
+ }
return plaintext;
}
- private byte[] ReadEncryptedChunk(long ciphertextPos, int encChunkSize)
+ private static byte[] ReadEncryptedChunk(Stream stream, long ciphertextPos, int encChunkSize)
{
var encChunk = new byte[encChunkSize];
- var stream = BinaryStream;
stream.Position = ciphertextPos;
- var bytesRead = 0;
- while (bytesRead < encChunkSize)
- {
- var read = stream.Read(encChunk, bytesRead, encChunkSize - bytesRead);
- if (read == 0)
- break;
-
- bytesRead += read;
- }
+ var bytesRead = stream.ReadAtLeast(encChunk, encChunkSize, throwOnEndOfStream: false);
return bytesRead == encChunkSize
? encChunk
@@ -215,33 +249,17 @@ private static byte[] ReadPayloadNonce(HeaderReader reader)
: throw new AgeHeaderException($"expected {AgeEncrypt.PayloadNonceSize}-byte payload nonce, got {nonceRead} bytes");
}
- private static (Stream binaryInput, bool needsDispose) DeArmorInput(Stream ciphertext)
- {
- if (AsciiArmor.IsArmored(ciphertext))
- {
- // RandomAccess needs a seekable stream, so materialize the dearmored data.
- using var dearmored = AsciiArmor.Dearmor(ciphertext);
- var ms = new MemoryStream();
- dearmored.CopyTo(ms);
- ms.Position = 0;
- return (ms, true);
- }
-
- ciphertext.Position = 0;
- return (ciphertext, false);
- }
-
- private static long ComputePlaintextLength(long totalEncryptedPayload)
+ // DearmorStream cannot seek, so the decoded ciphertext is buffered in full. Cost is the
+ // file's size in memory, as the class remarks note.
+ private static MemoryStream Materialize(Stream ciphertext)
{
- var totalChunks = ComputeTotalChunks(totalEncryptedPayload);
- var fullChunks = totalChunks - 1;
- var lastChunkEncSize = totalEncryptedPayload - fullChunks * StreamEncryption.EncryptedChunkSize;
- var lastChunkPlainSize = lastChunkEncSize - StreamEncryption.TagSize;
+ using var dearmored = AsciiArmor.Dearmor(ciphertext);
- if (lastChunkPlainSize < 0)
- throw new AgePayloadException("chunk too small for authentication tag");
+ var ms = new MemoryStream();
+ dearmored.CopyTo(ms);
+ ms.Position = 0;
- return fullChunks * StreamEncryption.ChunkSize + lastChunkPlainSize;
+ return ms;
}
private static long ComputeTotalChunks(long totalEncryptedPayload)
@@ -252,7 +270,6 @@ private static long ComputeTotalChunks(long totalEncryptedPayload)
var fullChunks = totalEncryptedPayload / StreamEncryption.EncryptedChunkSize;
var remainder = totalEncryptedPayload % StreamEncryption.EncryptedChunkSize;
- // If no remainder, the last full-sized chunk IS the final chunk
return remainder == 0 ? fullChunks : fullChunks + 1;
}
}
\ No newline at end of file
diff --git a/Age/ArgumentExceptionExtensions.cs b/Age/ArgumentExceptionExtensions.cs
new file mode 100644
index 0000000..55e2969
--- /dev/null
+++ b/Age/ArgumentExceptionExtensions.cs
@@ -0,0 +1,43 @@
+using System.Runtime.CompilerServices;
+
+namespace Age;
+
+///
+/// Adds a span-shaped emptiness check to , alongside the BCL's own
+/// .
+///
+///
+/// A C# 14 static extension member, so it is called as ArgumentException.ThrowIfEmpty(...)
+/// and reads like the framework guard it sits beside. The BCL has no span form — its own overload
+/// takes a — while recipients and identities reach this library's entry
+/// points as params ReadOnlySpan<T>.
+///
+/// Internal to this assembly: extending a framework type is a liberty worth taking for six
+/// call sites inside one library, not for anything a consumer would see.
+///
+///
+internal static class ArgumentExceptionExtensions
+{
+ extension(ArgumentException)
+ {
+ ///
+ /// Throws when is empty. Encrypting to nobody, or decrypting
+ /// with nothing, is a caller mistake rather than a format error: it can only ever fail,
+ /// and failing at the entry point names the argument instead of surfacing later as a
+ /// header with no stanzas or as .
+ ///
+ /// The recipients or identities supplied by the caller.
+ ///
+ /// Singular noun for the message, e.g. "recipient". Passed explicitly rather than
+ /// derived from , because "identities" does not depluralise
+ /// by dropping a letter.
+ ///
+ /// Captured from the call site; do not pass explicitly.
+ /// is empty.
+ public static void ThrowIfEmpty(ReadOnlySpan value, string noun, [CallerArgumentExpression(nameof(value))] string? paramName = null)
+ {
+ if (value.IsEmpty)
+ throw new ArgumentException($"at least one {noun} is required", paramName);
+ }
+ }
+}
diff --git a/Age/Crypto/Base64Unpadded.cs b/Age/Crypto/Base64Unpadded.cs
index 55eb41f..687d8a9 100644
--- a/Age/Crypto/Base64Unpadded.cs
+++ b/Age/Crypto/Base64Unpadded.cs
@@ -4,6 +4,27 @@ internal static class Base64Unpadded
{
private const int StackAllocThreshold = 256;
+ ///
+ /// Encodes into a caller-supplied buffer and returns the length written, for secret bodies
+ /// where the overload would leave an unclearable copy behind.
+ ///
+ public static int Encode(ReadOnlySpan data, Span destination)
+ {
+ if (data.IsEmpty)
+ return 0;
+
+ if (!Convert.TryToBase64Chars(data, destination, out var written))
+ throw new InvalidOperationException("base64 encode failed");
+
+ while (written > 0 && destination[written - 1] == '=')
+ written--;
+
+ return written;
+ }
+
+ /// Maximum characters can write.
+ public static int MaxEncodedLength(int byteCount) => (byteCount + 2) / 3 * 4;
+
public static string Encode(ReadOnlySpan data)
{
if (data.IsEmpty)
diff --git a/Age/Crypto/Bech32.cs b/Age/Crypto/Bech32.cs
index 5731306..23f67f3 100644
--- a/Age/Crypto/Bech32.cs
+++ b/Age/Crypto/Bech32.cs
@@ -1,3 +1,4 @@
+using System.Security.Cryptography;
namespace Age.Crypto;
// Bech32 encoding/decoding per BIP-173.
@@ -104,7 +105,6 @@ public static string Encode(string hrp, ReadOnlySpan data)
// and converting the 5-bit data back to 8-bit bytes.
public static (string Hrp, byte[] Data) Decode(string bech)
{
- // BIP-173: "The last '1' in the string is the separator."
var sepPos = bech.LastIndexOf('1');
if (sepPos < 1 || sepPos + 7 > bech.Length)
throw new FormatException("invalid bech32 string: separator not found or invalid position");
@@ -144,11 +144,19 @@ public static (string Hrp, byte[] Data) Decode(string bech)
if (!VerifyChecksum(hrp, data5))
throw new FormatException("invalid bech32 checksum");
- // Strip checksum
var data5NoCheck = data5[..^6];
- var data8 = ConvertBits(data5NoCheck, 5, 8, false);
- return (hrp, data8);
+ try
+ {
+ return (hrp, ConvertBits(data5NoCheck, 5, 8, false));
+ }
+ finally
+ {
+ // A trivially invertible 5-bit image of the same secret key. (The lowercased string
+ // cannot be cleared.)
+ CryptographicOperations.ZeroMemory(data5);
+ CryptographicOperations.ZeroMemory(data5NoCheck);
+ }
}
// BIP-173: General power-of-2 base conversion. Regroups bits from fromBits-sized groups
@@ -158,7 +166,13 @@ private static byte[] ConvertBits(ReadOnlySpan data, int fromBits, int toB
var acc = 0;
var bits = 0;
var maxv = (1 << toBits) - 1;
- var ret = new List();
+
+ // Sized exactly: growing a List would scatter uncleared copies of a secret key
+ // across every reallocated backing array.
+ var totalBits = data.Length * fromBits;
+ var count = pad ? (totalBits + toBits - 1) / toBits : totalBits / toBits;
+ var result = new byte[count];
+ var written = 0;
foreach (var value in data)
{
@@ -171,23 +185,24 @@ private static byte[] ConvertBits(ReadOnlySpan data, int fromBits, int toB
while (bits >= toBits)
{
bits -= toBits;
- ret.Add((byte)((acc >> bits) & maxv));
+ result[written++] = (byte)((acc >> bits) & maxv);
}
}
if (pad)
{
if (bits > 0)
- ret.Add((byte)((acc << (toBits - bits)) & maxv));
+ result[written++] = (byte)((acc << (toBits - bits)) & maxv);
}
else
{
if (bits >= fromBits)
throw new FormatException("excess padding in bech32 data");
+
if (((acc << (toBits - bits)) & maxv) != 0)
throw new FormatException("non-zero padding bits in bech32 data");
}
- return ret.ToArray();
+ return written == result.Length ? result : result[..written];
}
}
\ No newline at end of file
diff --git a/Age/Crypto/CryptoHelper.cs b/Age/Crypto/CryptoHelper.cs
index 9511ca8..3b37289 100644
--- a/Age/Crypto/CryptoHelper.cs
+++ b/Age/Crypto/CryptoHelper.cs
@@ -1,5 +1,6 @@
using System.Security.Cryptography;
using System.Text;
+using Org.BouncyCastle.Crypto.Agreement;
using Org.BouncyCastle.Crypto.Digests;
using Org.BouncyCastle.Crypto.Generators;
using Org.BouncyCastle.Crypto.Parameters;
@@ -11,19 +12,71 @@ internal static class CryptoHelper
private const int ChaChaTagSize = 16;
private const int Sha256Size = 32;
+ /// Size of an X25519 shared secret, and so of the buffer fills.
+ public const int X25519SharedSecretSize = 32;
+
+ ///
+ /// The one place an X25519 agreement is performed. The spec requires rejecting an
+ /// all-zero shared secret, and BouncyCastle already refuses low-order and identity points
+ /// — but it does so with a raw , which escaped
+ /// five of the eight call sites and surfaced from public Encrypt/Decrypt as an unhandled
+ /// BCL exception. main's own CLI reported a merely-malformed input file as a library bug.
+ ///
+ ///
+ /// This is defence in depth plus a consistent exception type, not the closing of an
+ /// exploitable hole: no zero shared secret was ever used, because the agreement itself
+ /// fails first.
+ ///
+ public static void X25519Agree(X25519PrivateKeyParameters privateKey, X25519PublicKeyParameters publicKey,
+ Span sharedSecret)
+ {
+ var agreement = new X25519Agreement();
+ agreement.Init(privateKey);
+
+ // BouncyCastle writes into an array, so one transient heap copy is unavoidable here.
+ var buffer = new byte[agreement.AgreementSize];
+
+ try
+ {
+ try
+ {
+ agreement.CalculateAgreement(publicKey, buffer, 0);
+ }
+ catch (InvalidOperationException ex)
+ {
+ throw new AgeHeaderException("X25519 shared secret is all-zero (low-order or identity point)", ex);
+ }
+
+ if (buffer.All(b => b == 0))
+ throw new AgeHeaderException("X25519 shared secret is all-zero (low-order or identity point)");
+
+ buffer.CopyTo(sharedSecret);
+ }
+ finally
+ {
+ CryptographicOperations.ZeroMemory(buffer);
+ }
+ }
+
public static byte[] HkdfDerive(ReadOnlySpan ikm, ReadOnlySpan salt, string info, int length)
{
- // Delegated to BouncyCastle's HkdfBytesGenerator for RFC 5869
- // correctness across all platforms. .NET's HKDF.DeriveKey uses OpenSSL
- // on Linux, which rejects empty IKM — but the age spec uses empty
- // IKM for the SSH-Ed25519 tweak derivation. BouncyCastle handles
- // this uniformly. HKDF is called once per session, not per chunk,
- // so the ToArray() allocations here are not a hot path.
- var hkdf = new HkdfBytesGenerator(new Sha256Digest());
- hkdf.Init(new HkdfParameters(ikm.ToArray(), salt.ToArray(), Encoding.ASCII.GetBytes(info)));
- var result = new byte[length];
- hkdf.GenerateBytes(result, 0, length);
- return result;
+ // BouncyCastle rather than HKDF.DeriveKey: the latter uses OpenSSL on Linux, which
+ // rejects the empty IKM the spec's SSH-Ed25519 tweak derivation needs. The copy
+ // BouncyCastle requires is key material, so it is cleared rather than left to the GC.
+ var ikmCopy = ikm.ToArray();
+
+ try
+ {
+ var hkdf = new HkdfBytesGenerator(new Sha256Digest());
+ hkdf.Init(new HkdfParameters(ikmCopy, salt.ToArray(), Encoding.ASCII.GetBytes(info)));
+ var result = new byte[length];
+ hkdf.GenerateBytes(result, 0, length);
+ return result;
+ }
+ finally
+ {
+ CryptographicOperations.ZeroMemory(ikmCopy);
+ }
}
public static void ChaChaEncrypt(IAeadCipher cipher, ReadOnlySpan nonce,
diff --git a/Age/Crypto/DecryptStream.cs b/Age/Crypto/DecryptStream.cs
index 4d8f64e..9fc6891 100644
--- a/Age/Crypto/DecryptStream.cs
+++ b/Age/Crypto/DecryptStream.cs
@@ -15,8 +15,8 @@ private enum State
private const int PlaintextBufferSize = StreamEncryption.ChunkSize;
private State _state = State.Chunks;
+ private bool _disposed;
- // Chunk buffering — rented from the shared pool, reused across chunks
private readonly byte[] _ciphertextBuffer = ArrayPool.Shared.Rent(CiphertextBufferSize);
private readonly byte[] _plaintextBuffer = ArrayPool.Shared.Rent(PlaintextBufferSize);
private readonly IAeadCipher _cipher = AeadCipher.Create(payloadKey);
@@ -41,11 +41,14 @@ public override int Read(byte[] buffer, int offset, int count)
public override int Read(Span buffer)
{
+ // The buffers are back on the ArrayPool after Dispose, so reading here would serve
+ // whatever the next renter has since written into them.
+ ObjectDisposedException.ThrowIf(_disposed, this);
+
var totalRead = 0;
while (totalRead < buffer.Length)
{
- // Drain any buffered plaintext first
if (_plaintextOffset < _plaintextLength)
{
var available = _plaintextLength - _plaintextOffset;
@@ -132,23 +135,19 @@ private int ReadFromCiphertext()
}
const int target = StreamEncryption.EncryptedChunkSize + 1;
- while (total < target)
- {
- var read = ciphertext.Read(_ciphertextBuffer, total, target - total);
-
- if (read == 0)
- break;
-
- total += read;
- }
+ total += ciphertext.ReadAtLeast(_ciphertextBuffer.AsSpan(total, target - total), target - total, throwOnEndOfStream: false);
return total;
}
+ // Stream.Dispose() is not idempotent and Close() is a documented alias for it, so a caller
+ // doing both is legal — and without this guard the second pass Returns buffers that are
+ // already on the pool's free list, after which two unrelated Rent calls get the same array.
protected override void Dispose(bool disposing)
{
- if (disposing)
+ if (disposing && !_disposed)
{
+ _disposed = true;
_cipher.Dispose();
CryptographicOperations.ZeroMemory(payloadKey);
CryptographicOperations.ZeroMemory(_plaintextBuffer.AsSpan(0, PlaintextBufferSize));
diff --git a/Age/Crypto/Ed25519Converter.cs b/Age/Crypto/Ed25519Converter.cs
index ffc0ca6..474cbf5 100644
--- a/Age/Crypto/Ed25519Converter.cs
+++ b/Age/Crypto/Ed25519Converter.cs
@@ -1,3 +1,4 @@
+using System.Security.Cryptography;
using Org.BouncyCastle.Crypto.Digests;
using Org.BouncyCastle.Math.EC.Rfc7748;
@@ -19,7 +20,6 @@ public static byte[] PublicKeyToX25519(byte[] ed25519PublicKey)
Array.Copy(ed25519PublicKey, yBytes, 32);
yBytes[31] &= 0x7F;
- // Decode y into field element limbs
var y = new int[X25519Field.Size];
X25519Field.Decode(yBytes, 0, y);
@@ -39,7 +39,6 @@ public static byte[] PublicKeyToX25519(byte[] ed25519PublicKey)
var u = new int[X25519Field.Size];
X25519Field.Mul(numerator, invDenom, u);
- // Normalize and encode
X25519Field.Normalize(u);
var result = new byte[32];
X25519Field.Encode(u, result, 0);
@@ -57,11 +56,21 @@ public static byte[] PrivateKeyToX25519(byte[] ed25519Seed)
var sha512 = new Sha512Digest();
var hash = new byte[64];
- sha512.BlockUpdate(ed25519Seed, 0, ed25519Seed.Length);
- sha512.DoFinal(hash, 0);
- var result = new byte[32];
- Array.Copy(hash, result, 32);
- return result;
+ try
+ {
+ sha512.BlockUpdate(ed25519Seed, 0, ed25519Seed.Length);
+ sha512.DoFinal(hash, 0);
+
+ var result = new byte[32];
+ Array.Copy(hash, result, 32);
+ return result;
+ }
+ finally
+ {
+ // Bytes 0-32 are the X25519 private key; 32-64 the Ed25519 signing nonce prefix.
+ // Both are key material.
+ CryptographicOperations.ZeroMemory(hash);
+ }
}
}
diff --git a/Age/Crypto/EncryptStream.cs b/Age/Crypto/EncryptStream.cs
index 3144352..964087b 100644
--- a/Age/Crypto/EncryptStream.cs
+++ b/Age/Crypto/EncryptStream.cs
@@ -16,10 +16,10 @@ private enum State
private const int CiphertextBufferSize = StreamEncryption.EncryptedChunkSize;
private State _state = State.Preamble;
+ private bool _disposed;
private readonly byte[] _preamble = [..headerBytes, ..payloadNonce];
private int _preambleOffset;
- // Chunk buffering — rented from the shared pool, reused across chunks
private readonly byte[] _plaintextBuffer = ArrayPool.Shared.Rent(PlaintextBufferSize);
private readonly byte[] _ciphertextBuffer = ArrayPool.Shared.Rent(CiphertextBufferSize);
private readonly IAeadCipher _cipher = AeadCipher.Create(payloadKey);
@@ -45,6 +45,9 @@ public override int Read(byte[] buffer, int offset, int count)
public override int Read(Span buffer)
{
+ // See DecryptStream.Read: the pooled buffers no longer belong to this stream.
+ ObjectDisposedException.ThrowIf(_disposed, this);
+
var totalRead = 0;
while (totalRead < buffer.Length)
@@ -108,7 +111,6 @@ private void EncryptNextChunk()
}
else
{
- // Save the look-ahead byte for the next read
_plaintextBuffer[0] = _plaintextBuffer[StreamEncryption.ChunkSize];
_pendingByte = true;
}
@@ -136,23 +138,18 @@ private int ReadFromPlaintext(byte[] buffer, int count)
_pendingByte = false;
}
- while (total < count)
- {
- var read = plaintext.Read(buffer, total, count - total);
-
- if (read == 0)
- break;
-
- total += read;
- }
+ total += plaintext.ReadAtLeast(buffer.AsSpan(total, count - total), count - total, throwOnEndOfStream: false);
return total;
}
+ // See DecryptStream.Dispose: without the guard, a legal Close()+Dispose() Returns each
+ // pooled buffer twice and two later renters are handed the same array.
protected override void Dispose(bool disposing)
{
- if (disposing)
+ if (disposing && !_disposed)
{
+ _disposed = true;
_cipher.Dispose();
CryptographicOperations.ZeroMemory(payloadKey);
CryptographicOperations.ZeroMemory(_plaintextBuffer.AsSpan(0, PlaintextBufferSize));
diff --git a/Age/Crypto/HpkeHelper.cs b/Age/Crypto/HpkeHelper.cs
index 2dc9db1..353afca 100644
--- a/Age/Crypto/HpkeHelper.cs
+++ b/Age/Crypto/HpkeHelper.cs
@@ -1,3 +1,4 @@
+using System.Security.Cryptography;
using System.Text;
using Org.BouncyCastle.Crypto.Digests;
using Org.BouncyCastle.Crypto.Generators;
@@ -18,19 +19,40 @@ internal static class HpkeHelper
private static readonly byte[] HpkeV1 = "HPKE-v1"u8.ToArray();
+ // ss is the X-Wing shared secret and key is the ChaCha20-Poly1305 key that wraps the file
+ // key. Neither was cleared anywhere in this file, on either path.
public static (byte[] Enc, byte[] Ct) SealBase(byte[] publicKey, byte[] info, byte[] plaintext)
{
var (ss, enc) = XWing.Encaps(publicKey);
var (key, nonce) = KeyScheduleBase(ss, info);
- var ct = CryptoHelper.ChaChaEncrypt(key, nonce, plaintext);
- return (enc, ct);
+
+ try
+ {
+ return (enc, CryptoHelper.ChaChaEncrypt(key, nonce, plaintext));
+ }
+ finally
+ {
+ CryptographicOperations.ZeroMemory(ss);
+ CryptographicOperations.ZeroMemory(key);
+ CryptographicOperations.ZeroMemory(nonce);
+ }
}
public static byte[]? OpenBase(byte[] enc, byte[] seed, byte[] info, byte[] ct)
{
var ss = XWing.Decaps(enc, seed);
var (key, nonce) = KeyScheduleBase(ss, info);
- return CryptoHelper.ChaChaDecrypt(key, nonce, ct);
+
+ try
+ {
+ return CryptoHelper.ChaChaDecrypt(key, nonce, ct);
+ }
+ finally
+ {
+ CryptographicOperations.ZeroMemory(ss);
+ CryptographicOperations.ZeroMemory(key);
+ CryptographicOperations.ZeroMemory(nonce);
+ }
}
private static (byte[] Key, byte[] Nonce) KeyScheduleBase(byte[] sharedSecret, byte[] info)
@@ -46,11 +68,19 @@ private static (byte[] Key, byte[] Nonce) KeyScheduleBase(byte[] sharedSecret, b
pskIdHash.CopyTo(ksContext, 1);
infoHash.CopyTo(ksContext, 33);
+ // secret is the HPKE PRK — both the key and the nonce derive from it, so it outlives
+ // neither and is cleared as soon as they exist.
var secret = LabeledExtract(sharedSecret, "secret", empty);
- var key = LabeledExpand(secret, "key", ksContext, 32);
- var baseNonce = LabeledExpand(secret, "base_nonce", ksContext, 12);
- return (key, baseNonce);
+ try
+ {
+ return (LabeledExpand(secret, "key", ksContext, 32),
+ LabeledExpand(secret, "base_nonce", ksContext, 12));
+ }
+ finally
+ {
+ CryptographicOperations.ZeroMemory(secret);
+ }
}
private static byte[] LabeledExtract(byte[] salt, string label, byte[] ikm)
@@ -74,8 +104,16 @@ private static byte[] LabeledExtract(byte[] salt, string label, byte[] ikm)
// actual_salt: if empty, use Nh zero bytes (32 for SHA-256)
var actualSalt = salt.Length > 0 ? salt : new byte[32];
- // HKDF-Extract = HMAC(salt, ikm)
- return CryptoHelper.HmacSha256(actualSalt, labeledIkm);
+ try
+ {
+ return CryptoHelper.HmacSha256(actualSalt, labeledIkm);
+ }
+ finally
+ {
+ // labeledIkm ends with a verbatim copy of ikm, which on the "secret" call is the
+ // X-Wing shared secret.
+ CryptographicOperations.ZeroMemory(labeledIkm);
+ }
}
private static byte[] LabeledExpand(byte[] prk, string label, byte[] info, int length)
@@ -98,7 +136,6 @@ private static byte[] LabeledExpand(byte[] prk, string label, byte[] info, int l
pos += labelBytes.Length;
info.CopyTo(labeledInfo, pos);
- // HKDF-Expand with PRK and labeled_info
var hkdf = new HkdfBytesGenerator(new Sha256Digest());
hkdf.Init(HkdfParameters.SkipExtractParameters(prk, labeledInfo));
var result = new byte[length];
diff --git a/Age/Crypto/SshKeyParser.cs b/Age/Crypto/SshKeyParser.cs
index a4495d2..189b653 100644
--- a/Age/Crypto/SshKeyParser.cs
+++ b/Age/Crypto/SshKeyParser.cs
@@ -50,7 +50,6 @@ public static (string keyType, byte[] publicWireBytes, AsymmetricKeyParameter pr
if (pemText.Contains("BEGIN OPENSSH PRIVATE KEY"))
{
- // OpenSSH format: extract the base64 blob and parse
var pemReader = new PemReader(new StringReader(pemText));
var pemObject = pemReader.ReadPemObject();
if (pemObject == null)
@@ -60,7 +59,6 @@ public static (string keyType, byte[] publicWireBytes, AsymmetricKeyParameter pr
}
else
{
- // PKCS#1 or PKCS#8 format
var pemReader = new PemReader(new StringReader(pemText));
var obj = pemReader.ReadObject();
@@ -72,7 +70,6 @@ public static (string keyType, byte[] publicWireBytes, AsymmetricKeyParameter pr
};
}
- // Derive public key and encode to SSH wire format
AsymmetricKeyParameter publicKey;
string keyType;
diff --git a/Age/Crypto/StreamEncryption.cs b/Age/Crypto/StreamEncryption.cs
index 5d0197a..22b4dc8 100644
--- a/Age/Crypto/StreamEncryption.cs
+++ b/Age/Crypto/StreamEncryption.cs
@@ -9,6 +9,16 @@ internal static class StreamEncryption
internal const int EncryptedChunkSize = ChunkSize + TagSize;
private const int NonceSize = 12;
+ ///
+ /// TEST HELPER ONLY — do not call from library code.
+ ///
+ ///
+ /// This buffers the entire input into a before doing any work,
+ /// which contradicts the library's memory-bounded guarantee: a 1 GiB file would cost 1 GiB
+ /// of working set rather than two 64 KiB chunk buffers. Production encryption goes through
+ /// EncryptStream, which streams chunk by chunk. Kept because the chunk-sequencing
+ /// tests are written against this simpler shape.
+ ///
public static void Encrypt(ReadOnlySpan payloadKey, Stream input, Stream output)
{
using var inputMs = new MemoryStream();
@@ -36,6 +46,10 @@ public static void Encrypt(ReadOnlySpan payloadKey, Stream input, Stream o
}
}
+ ///
+ /// TEST HELPER ONLY — do not call from library code. See : this
+ /// buffers the whole payload. Production decryption goes through DecryptStream.
+ ///
public static void Decrypt(ReadOnlySpan payloadKey, Stream input, Stream output)
{
using var inputMs = new MemoryStream();
diff --git a/Age/Crypto/XWing.cs b/Age/Crypto/XWing.cs
index 589a3d5..cc83b23 100644
--- a/Age/Crypto/XWing.cs
+++ b/Age/Crypto/XWing.cs
@@ -1,4 +1,5 @@
using Org.BouncyCastle.Crypto.Agreement;
+using System.Security.Cryptography;
using Org.BouncyCastle.Crypto.Digests;
using Org.BouncyCastle.Crypto.Kems;
using Org.BouncyCastle.Crypto.Parameters;
@@ -22,15 +23,68 @@ internal static class XWing
public static byte[] GeneratePublicKey(byte[] seed)
{
- var (mlKemPrivate, _, x25519Private, _) = ExpandSeed(seed);
+ var (mlKemPrivate, seedPq, x25519Private, _) = ExpandSeed(seed);
- var pkM = mlKemPrivate.GetPublicKeyEncoded(); // 1184 bytes (see MlKemPublicKeySize)
- var pkX = x25519Private.GeneratePublicKey().GetEncoded(); // 32 bytes (see X25519KeySize)
+ try
+ {
+ var pkM = mlKemPrivate.GetPublicKeyEncoded(); // 1184 bytes (see MlKemPublicKeySize)
+ var pkX = x25519Private.GeneratePublicKey().GetEncoded(); // 32 bytes (see X25519KeySize)
+
+ var publicKey = new byte[PublicKeySize];
+ pkM.CopyTo(publicKey, 0);
+ pkX.CopyTo(publicKey, MlKemPublicKeySize);
+ return publicKey;
+ }
+ finally
+ {
+ // seedPq is the ML-KEM-768 private seed (d,z).
+ CryptographicOperations.ZeroMemory(seedPq);
+ }
+ }
+
+ ///
+ /// Checks that the ML-KEM half of an X-Wing public key actually decodes, so a malformed
+ /// recipient is rejected at parse time rather than partway through an encryption.
+ ///
+ /// The ML-KEM-768 encapsulation key is not well formed.
+ public static void ValidatePublicKey(byte[] publicKey)
+ {
+ if (publicKey.Length != PublicKeySize)
+ throw new FormatException($"public key must be {PublicKeySize} bytes, got {publicKey.Length}");
+
+ try
+ {
+ MLKemPublicKeyParameters.FromEncoding(MLKemParameters.ml_kem_768, publicKey[..MlKemPublicKeySize]);
+ }
+ catch (Exception ex) when (ex is ArgumentException or InvalidOperationException)
+ {
+ throw new FormatException($"invalid ML-KEM-768 encapsulation key: {ex.Message}", ex);
+ }
+
+ // FromEncoding does not check the coefficients — BouncyCastle defers that to Encapsulate,
+ // far too late for a parse. FIPS 203's ByteDecode_12 requires every coefficient below q.
+ if (!CoefficientsAreInRange(publicKey.AsSpan(0, CoefficientBytes)))
+ throw new FormatException(
+ "invalid ML-KEM-768 encapsulation key: a coefficient is not less than the modulus");
+ }
+
+ // The encapsulation key is k polynomials of 256 coefficients packed at 12 bits each, followed
+ // by a 32-byte seed. Three bytes carry two coefficients.
+ private const int CoefficientBytes = 1152;
+ private const int Modulus = 3329;
+
+ private static bool CoefficientsAreInRange(ReadOnlySpan packed)
+ {
+ for (var i = 0; i + 2 < packed.Length; i += 3)
+ {
+ var low = packed[i] | ((packed[i + 1] & 0x0F) << 8);
+ var high = (packed[i + 1] >> 4) | (packed[i + 2] << 4);
+
+ if (low >= Modulus || high >= Modulus)
+ return false;
+ }
- var publicKey = new byte[PublicKeySize];
- pkM.CopyTo(publicKey, 0);
- pkX.CopyTo(publicKey, MlKemPublicKeySize);
- return publicKey;
+ return true;
}
public static (byte[] SharedSecret, byte[] Enc) Encaps(byte[] publicKey)
@@ -41,31 +95,52 @@ public static (byte[] SharedSecret, byte[] Enc) Encaps(byte[] publicKey)
var pkM = publicKey[..MlKemPublicKeySize];
var pkX = publicKey[MlKemPublicKeySize..];
- // ML-KEM-768 encapsulate
- var mlKemPub = MLKemPublicKeyParameters.FromEncoding(MLKemParameters.ml_kem_768, pkM);
+ MLKemPublicKeyParameters mlKemPub;
+
+ try
+ {
+ mlKemPub = MLKemPublicKeyParameters.FromEncoding(MLKemParameters.ml_kem_768, pkM);
+ }
+ catch (Exception ex) when (ex is ArgumentException or InvalidOperationException)
+ {
+ throw new AgeHeaderException($"invalid ML-KEM-768 public key: {ex.Message}", ex);
+ }
+
var encapsulator = new MLKemEncapsulator(MLKemParameters.ml_kem_768);
encapsulator.Init(mlKemPub);
var ctM = new byte[MlKemCiphertextSize];
+
+ // Encaps is internal and callable without going through Parse, so it repeats the
+ // coefficient check rather than trusting the caller.
+ if (!CoefficientsAreInRange(pkM.AsSpan(0, CoefficientBytes)))
+ throw new AgeHeaderException(
+ "invalid ML-KEM-768 public key: a coefficient is not less than the modulus");
+
+ // ssM and ssX are the two halves the combiner hashes; both are key material.
var ssM = new byte[SharedSecretSize];
encapsulator.Encapsulate(ctM, 0, MlKemCiphertextSize, ssM, 0, SharedSecretSize);
- // X25519 ephemeral DH
+ // X25519 ephemeral DH, through the same guarded helper Decaps uses.
var ekX = new X25519PrivateKeyParameters(new SecureRandom());
var ctX = ekX.GeneratePublicKey().GetEncoded();
var ssX = new byte[SharedSecretSize];
- var agreement = new X25519Agreement();
- agreement.Init(ekX);
- agreement.CalculateAgreement(new X25519PublicKeyParameters(pkX), ssX, 0);
- // Combine: enc = ct_M || ct_X
- var enc = new byte[EncSize];
- ctM.CopyTo(enc, 0);
- ctX.CopyTo(enc, MlKemCiphertextSize);
+ try
+ {
+ CryptoHelper.X25519Agree(ekX, new X25519PublicKeyParameters(pkX), ssX);
- // ss = SHA3-256(ss_M || ss_X || ct_X || pk_X || XWingLabel)
- var sharedSecret = CombineSharedSecret(ssM, ssX, ctX, pkX);
+ var enc = new byte[EncSize];
+ ctM.CopyTo(enc, 0);
+ ctX.CopyTo(enc, MlKemCiphertextSize);
- return (sharedSecret, enc);
+ // ss = SHA3-256(ss_M || ss_X || ct_X || pk_X || XWingLabel)
+ return (CombineSharedSecret(ssM, ssX, ctX, pkX), enc);
+ }
+ finally
+ {
+ CryptographicOperations.ZeroMemory(ssM);
+ CryptographicOperations.ZeroMemory(ssX);
+ }
}
public static byte[] Decaps(byte[] enc, byte[] seed)
@@ -73,36 +148,32 @@ public static byte[] Decaps(byte[] enc, byte[] seed)
if (enc.Length != EncSize)
throw new ArgumentException($"enc must be {EncSize} bytes, got {enc.Length}");
- var (mlKemPrivate, _, x25519Private, pkX) = ExpandSeed(seed);
+ var (mlKemPrivate, seedPq, x25519Private, pkX) = ExpandSeed(seed);
var ctM = enc[..MlKemCiphertextSize];
var ctX = enc[MlKemCiphertextSize..];
- // ML-KEM-768 decapsulate
- var decapsulator = new MLKemDecapsulator(MLKemParameters.ml_kem_768);
- decapsulator.Init(mlKemPrivate);
var ssM = new byte[SharedSecretSize];
- decapsulator.Decapsulate(ctM, 0, MlKemCiphertextSize, ssM, 0, SharedSecretSize);
-
- // X25519 DH
var ssX = new byte[SharedSecretSize];
- var agreement = new X25519Agreement();
- agreement.Init(x25519Private);
try
{
- agreement.CalculateAgreement(new X25519PublicKeyParameters(ctX), ssX, 0);
+ var decapsulator = new MLKemDecapsulator(MLKemParameters.ml_kem_768);
+ decapsulator.Init(mlKemPrivate);
+ decapsulator.Decapsulate(ctM, 0, MlKemCiphertextSize, ssM, 0, SharedSecretSize);
+
+ // X25519 DH — the low-order guard and all-zero check live in the helper.
+ CryptoHelper.X25519Agree(x25519Private, new X25519PublicKeyParameters(ctX), ssX);
+
+ // ss = SHA3-256(ss_M || ss_X || ct_X || pk_X || XWingLabel)
+ return CombineSharedSecret(ssM, ssX, ctX, pkX);
}
- catch (InvalidOperationException)
+ finally
{
- throw new AgeHeaderException("X-Wing X25519 agreement failed (low-order or identity point)");
+ CryptographicOperations.ZeroMemory(ssM);
+ CryptographicOperations.ZeroMemory(ssX);
+ CryptographicOperations.ZeroMemory(seedPq);
}
-
- // Check for all-zero shared secret (low-order point that BC didn't reject)
- // ss = SHA3-256(ss_M || ss_X || ct_X || pk_X || XWingLabel)
- return ssX.All(b => b == 0)
- ? throw new AgeHeaderException("X-Wing X25519 shared secret is all-zero (low-order or identity point)")
- : CombineSharedSecret(ssM, ssX, ctX, pkX);
}
private static byte[] CombineSharedSecret(byte[] ssM, byte[] ssX, byte[] ctX, byte[] pkX)
@@ -130,10 +201,19 @@ private static (MLKemPrivateKeyParameters mlKemPrivate, byte[] seedPQ, X25519Pri
var seedT = new byte[X25519KeySize];
shake.Output(seedT, 0, X25519KeySize);
- var mlKemPrivate = MLKemPrivateKeyParameters.FromSeed(MLKemParameters.ml_kem_768, seedPq);
- var x25519Private = new X25519PrivateKeyParameters(seedT);
- var pkX = x25519Private.GeneratePublicKey().GetEncoded();
+ try
+ {
+ var mlKemPrivate = MLKemPrivateKeyParameters.FromSeed(MLKemParameters.ml_kem_768, seedPq);
+ var x25519Private = new X25519PrivateKeyParameters(seedT);
+ var pkX = x25519Private.GeneratePublicKey().GetEncoded();
- return (mlKemPrivate, seedPq, x25519Private, pkX);
+ // seedPq is handed back so callers can clear it once they are done with the derived
+ // private key; seedT has already been copied into x25519Private and is cleared here.
+ return (mlKemPrivate, seedPq, x25519Private, pkX);
+ }
+ finally
+ {
+ CryptographicOperations.ZeroMemory(seedT);
+ }
}
}
\ No newline at end of file
diff --git a/Age/FileKey.cs b/Age/FileKey.cs
new file mode 100644
index 0000000..6635f91
--- /dev/null
+++ b/Age/FileKey.cs
@@ -0,0 +1,72 @@
+using System.Security.Cryptography;
+
+namespace Age;
+
+///
+/// The 16-byte symmetric key that protects one age file's payload, owning its own lifetime:
+/// zeroes it, so using makes the guarantee syntactic.
+///
+///
+/// A bare byte[] cannot carry that guarantee — every site had to remember a
+/// try/finally, and the ones that forgot were the S9 defects. Internal on purpose:
+/// and IIdentity.Unwrap are
+/// shipped public API taking and returning spans and arrays, so this wraps the key inside the
+/// library without changing what a consumer sees.
+///
+internal sealed class FileKey : IDisposable
+{
+ private const int Size = 16;
+
+ private readonly byte[] _bytes;
+ private bool _disposed;
+
+ private FileKey(byte[] bytes) => _bytes = bytes;
+
+ /// The key material. Valid until .
+ /// The key has been disposed.
+ public ReadOnlySpan Bytes
+ {
+ get
+ {
+ ObjectDisposedException.ThrowIf(_disposed, this);
+ return _bytes;
+ }
+ }
+
+ /// Generates a new file key from CSPRNG output.
+ public static FileKey Fresh()
+ {
+ var bytes = new byte[Size];
+ RandomNumberGenerator.Fill(bytes);
+ return new FileKey(bytes);
+ }
+
+ ///
+ /// Takes ownership of a key recovered by an identity, which hands back a byte[]
+ /// because IIdentity.Unwrap is shipped public API. The array is
+ /// zeroed on ; the caller must not keep a reference to it.
+ ///
+ ///
+ /// The identity returned something other than bytes. Custom and plugin
+ /// identities are caller-supplied code and can return anything; a wrong-sized key would
+ /// derive garbage rather than fail.
+ ///
+ public static FileKey Adopt(byte[] bytes)
+ {
+ if (bytes.Length == Size)
+ return new FileKey(bytes);
+
+ CryptographicOperations.ZeroMemory(bytes);
+ throw new AgeHeaderException($"file key must be {Size} bytes, got {bytes.Length}");
+ }
+
+ /// Zeroes the key material. Safe to call more than once.
+ public void Dispose()
+ {
+ if (_disposed)
+ return;
+
+ _disposed = true;
+ CryptographicOperations.ZeroMemory(_bytes);
+ }
+}
diff --git a/Age/Format/ArmorStream.cs b/Age/Format/ArmorStream.cs
index f55c639..39c04c6 100644
--- a/Age/Format/ArmorStream.cs
+++ b/Age/Format/ArmorStream.cs
@@ -14,6 +14,7 @@ internal sealed class ArmorStream : Stream
private enum Phase { Begin, Body, End, Done }
private readonly Stream _source;
+ private bool _disposed;
private readonly byte[] _sourceScratch = new byte[BytesPerLine];
private readonly byte[] _scratch = new byte[CharsPerLine + 1];
private int _scratchOffset;
@@ -41,6 +42,9 @@ public override int Read(byte[] buffer, int offset, int count)
public override int Read(Span buffer)
{
+ // Reading would pull from the disposed inner EncryptStream's returned buffers.
+ ObjectDisposedException.ThrowIf(_disposed, this);
+
var totalWritten = 0;
while (totalWritten < buffer.Length)
@@ -105,21 +109,17 @@ private bool FillScratch()
}
private int ReadFullChunk()
- {
- var total = 0;
- while (total < _sourceScratch.Length)
- {
- var read = _source.Read(_sourceScratch, total, _sourceScratch.Length - total);
- if (read == 0) break;
- total += read;
- }
- return total;
- }
+ => _source.ReadAtLeast(_sourceScratch, _sourceScratch.Length, throwOnEndOfStream: false);
+ // Guarded for the same reason as the streams it wraps: _source is an EncryptStream whose
+ // Dispose returns pooled buffers, so forwarding a second Dispose would return them twice.
protected override void Dispose(bool disposing)
{
- if (disposing)
+ if (disposing && !_disposed)
+ {
+ _disposed = true;
_source.Dispose();
+ }
base.Dispose(disposing);
}
diff --git a/Age/Format/AsciiArmor.cs b/Age/Format/AsciiArmor.cs
index 7f928f8..70b68ea 100644
--- a/Age/Format/AsciiArmor.cs
+++ b/Age/Format/AsciiArmor.cs
@@ -45,19 +45,25 @@ 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, AgeLimits.MaxArmorLineBytes);
+ // leaveOpen: true stops the dispose chain at the wrapper — `input` belongs to the caller.
+ // The StreamReader keeps leaveOpen: false so it still disposes the wrapper we created.
+ var bounded = new NewlineBoundedStream(input, AgeLimits.MaxArmorLineBytes, leaveOpen: true);
var reader = new StreamReader(bounded, Encoding.ASCII, detectEncodingFromByteOrderMarks: false,
bufferSize: 4096, leaveOpen: false);
- // Skip leading whitespace (allowed per spec).
- // The old byte-level parser skipped individual whitespace bytes, so
- // " \n\t-----BEGIN AGE ENCRYPTED FILE-----" is valid. With line-based
- // reading we skip blank lines, then TrimStart the marker line.
+ // Leading whitespace is allowed: skip blank lines, then TrimStart the marker line.
string? line;
+ var skippedWhitespace = 0;
do
{
line = reader.ReadLine();
+
+ // Bounded, as go-age bounds it: an unbounded skip means a file that is nothing but
+ // newlines is read to its end before the header is even looked for.
+ if (line is not null && (skippedWhitespace += line.Length + 1) > AgeLimits.MaxLeadingWhitespaceBytes)
+ throw new AgeArmorException(
+ $"more than {AgeLimits.MaxLeadingWhitespaceBytes} bytes of whitespace before the armor header");
} while (line != null && line.AsSpan().Trim().Length == 0);
if (line == null)
@@ -95,20 +101,6 @@ public static void Armor(Stream input, Stream output)
}
private static int ReadChunk(Stream stream, byte[] buffer)
- {
- var total = 0;
-
- while (total < buffer.Length)
- {
- var read = stream.Read(buffer, total, buffer.Length - total);
-
- if (read == 0)
- break;
-
- total += read;
- }
-
- return total;
- }
+ => stream.ReadAtLeast(buffer, buffer.Length, throwOnEndOfStream: false);
}
\ No newline at end of file
diff --git a/Age/Format/DearmorStream.cs b/Age/Format/DearmorStream.cs
index 3d2787f..ad72abe 100644
--- a/Age/Format/DearmorStream.cs
+++ b/Age/Format/DearmorStream.cs
@@ -22,7 +22,7 @@ internal sealed class DearmorStream : Stream
private int _decodeOffset;
private int _decodeCount;
private bool _finished;
- private bool _lastLineWasShort;
+ private bool _bodyEnded;
public DearmorStream(StreamReader reader)
{
@@ -98,14 +98,11 @@ private bool DecodeNextLine()
if (!Convert.TryFromBase64Chars(line.AsSpan(), _decodeBuffer, out var bytesWritten))
throw new AgeArmorException("invalid base64 in armor");
- // Full-length lines (64 chars) encode exactly 48 bytes with no padding.
- // If padding is present on a full line, the decode succeeds but is non-canonical.
- if (line.Length == ColumnsPerLine && bytesWritten != MaxDecodedPerLine)
- throw new AgeArmorException("non-canonical base64 in armor");
-
- // Short lines may have padding — validate the trailing bits are zero.
- if (line.Length < ColumnsPerLine)
- ValidateCanonicalPadding(line.AsSpan());
+ // Padding may appear on a line of any width: a final chunk of 46 bytes encodes to a
+ // full 64 characters ending "==", and 47 bytes to 64 characters ending "=". Wherever it
+ // appears, the bits the padding covers must be zero. Runs after the decode above so a
+ // structurally malformed line fails there first.
+ ValidateCanonicalPadding(line.AsSpan());
_decodeOffset = 0;
_decodeCount = bytesWritten;
@@ -137,11 +134,14 @@ private void ValidateBodyLine(string line)
if (line.Length > ColumnsPerLine)
throw new AgeArmorException($"armor body line exceeds {ColumnsPerLine} characters");
- if (_lastLineWasShort)
+ if (_bodyEnded)
throw new AgeArmorException("short line in armor body is not the last line");
- if (line.Length < ColumnsPerLine)
- _lastLineWasShort = true;
+ // The body ends at the first line that cannot be followed by another: one shorter than
+ // the full column width, or a full-width line carrying base64 padding (46- and 47-byte
+ // final chunks encode to exactly 64 characters).
+ if (line.Length < ColumnsPerLine || line[^1] == '=')
+ _bodyEnded = true;
var invalid = line.AsSpan().IndexOfAnyExcept(Base64Chars);
diff --git a/Age/Format/Header.cs b/Age/Format/Header.cs
index 59167d5..85936e6 100644
--- a/Age/Format/Header.cs
+++ b/Age/Format/Header.cs
@@ -23,19 +23,25 @@ public static Header Parse(HeaderReader reader)
var versionLine = reader.ReadLine() ?? throw new AgeHeaderException("empty header");
if (versionLine != VersionLine)
- throw new AgeHeaderException($"unsupported version: {versionLine}");
+ throw new AgeHeaderException(
+ // Armor is auto-detected only on a seekable stream, so armored input from a pipe
+ // arrives here intact. Reporting its BEGIN marker as an "unsupported version"
+ // sent people looking for a version problem that does not exist.
+ versionLine.TrimStart().StartsWith("-----BEGIN AGE ENCRYPTED FILE-----", StringComparison.Ordinal)
+ ? "input is ASCII-armored, but armor is only auto-detected on a seekable " +
+ "stream; buffer it first (for example into a MemoryStream) or strip the armor"
+ : $"unsupported version: {versionLine}");
- // Read stanzas until we hit the MAC line
while (true)
{
var line = reader.ReadLine() ?? throw new AgeHeaderException("unexpected end of header");
- if (line.StartsWith("-> "))
+ if (line.StartsWith("-> ", StringComparison.Ordinal))
{
reader.PushBack(line);
header.Stanzas.Add(Stanza.Parse(reader));
}
- else if (line.StartsWith("---"))
+ else if (line.StartsWith("---", StringComparison.Ordinal))
{
ParseMacLine(header, line, reader);
break;
@@ -53,7 +59,7 @@ public static Header Parse(HeaderReader reader)
private static void ParseMacLine(Header header, string line, HeaderReader reader)
{
- if (!line.StartsWith("--- "))
+ if (!line.StartsWith("--- ", StringComparison.Ordinal))
throw new AgeHeaderException($"expected MAC line starting with '--- ', got: {line}");
var macB64 = line[4..];
@@ -89,8 +95,15 @@ public static byte[] ComputeMac(ReadOnlySpan fileKey, ReadOnlySpan h
// HKDF-SHA-256(ikm=fileKey, salt="", info="header") → hmac_key (32 bytes)
var hmacKeyBytes = CryptoHelper.HkdfDerive(fileKey, ReadOnlySpan.Empty, "header", 32);
- // HMAC-SHA-256(key=hmac_key, message=headerBytes)
- return CryptoHelper.HmacSha256(hmacKeyBytes, headerBytes);
+ try
+ {
+ return CryptoHelper.HmacSha256(hmacKeyBytes, headerBytes);
+ }
+ finally
+ {
+ // A file-key-derived secret, produced on every encrypt and every decrypt.
+ CryptographicOperations.ZeroMemory(hmacKeyBytes);
+ }
}
public void WriteTo(Stream stream, ReadOnlySpan fileKey)
@@ -108,7 +121,6 @@ public void WriteTo(Stream stream, ReadOnlySpan fileKey)
writer.Write("---");
writer.Flush();
- // Compute MAC over everything written so far (through "---", no trailing space)
var headerBytesForMac = headerStream.ToArray();
var mac = ComputeMac(fileKey, headerBytesForMac);
@@ -117,7 +129,6 @@ public void WriteTo(Stream stream, ReadOnlySpan fileKey)
writer.Write('\n');
writer.Flush();
- // Write to actual output
headerStream.Position = 0;
headerStream.CopyTo(stream);
}
diff --git a/Age/Format/HeaderReader.cs b/Age/Format/HeaderReader.cs
index 848d547..349ad77 100644
--- a/Age/Format/HeaderReader.cs
+++ b/Age/Format/HeaderReader.cs
@@ -99,19 +99,5 @@ private static void ValidateByte(int b)
/// These bytes are NOT tracked in RawBytes.
///
public int ReadPayloadBytes(Span buffer)
- {
- var total = 0;
-
- while (total < buffer.Length)
- {
- var read = stream.Read(buffer[total..]);
-
- if (read == 0)
- break;
-
- total += read;
- }
-
- return total;
- }
+ => stream.ReadAtLeast(buffer, buffer.Length, throwOnEndOfStream: false);
}
\ No newline at end of file
diff --git a/Age/Format/NewlineBoundedStream.cs b/Age/Format/NewlineBoundedStream.cs
index e20f3c0..cf0a8ce 100644
--- a/Age/Format/NewlineBoundedStream.cs
+++ b/Age/Format/NewlineBoundedStream.cs
@@ -7,7 +7,12 @@ namespace Age.Format;
/// 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
+///
+/// When is true, disposing this stream does not dispose
+/// . The armor reader passes true because is the
+/// caller's ciphertext stream, and the library never disposes a stream it did not create.
+///
+internal sealed class NewlineBoundedStream(Stream inner, int maxLineBytes, bool leaveOpen = false) : Stream
{
private int _run; // bytes seen since the last CR/LF
@@ -60,7 +65,7 @@ private void Scan(ReadOnlySpan bytes)
protected override void Dispose(bool disposing)
{
- if (disposing)
+ if (disposing && !leaveOpen)
inner.Dispose();
base.Dispose(disposing);
diff --git a/Age/Format/Stanza.cs b/Age/Format/Stanza.cs
index 344764f..b8d13ea 100644
--- a/Age/Format/Stanza.cs
+++ b/Age/Format/Stanza.cs
@@ -15,9 +15,32 @@ namespace Age.Format;
///
public sealed class Stanza
{
+ private const int ColumnsPerLine = 64;
+
private readonly string[] _args;
private readonly byte[] _body;
+ ///
+ /// Writes an encoded body as the spec's *full-line final-line (age.md:132): zero or
+ /// more full 64-column lines, then one final line of 0-63 characters.
+ ///
+ ///
+ /// The final line is unconditional, which is what makes an empty body and a body that is an
+ /// exact multiple of 64 both terminate with an empty line — no trailing special case.
+ ///
+ internal static void WriteBody(TextWriter writer, ReadOnlySpan encoded)
+ {
+ while (encoded.Length >= ColumnsPerLine)
+ {
+ writer.Write(encoded[..ColumnsPerLine]);
+ writer.Write('\n');
+ encoded = encoded[ColumnsPerLine..];
+ }
+
+ writer.Write(encoded);
+ writer.Write('\n');
+ }
+
///
/// Constructs a stanza with the given type, arguments, and body. The
/// and arrays are
@@ -38,13 +61,13 @@ public Stanza(string type, string[] args, byte[] body)
ArgumentNullException.ThrowIfNull(args);
ArgumentNullException.ThrowIfNull(body);
- EnsureValidStanzaString(type, nameof(type));
+ ThrowIfInvalidArgument(type, nameof(type));
foreach (var arg in args)
- EnsureValidStanzaString(arg, nameof(args));
+ ThrowIfInvalidArgument(arg, nameof(args));
Type = type;
- _args = (string[])args.Clone();
- _body = (byte[])body.Clone();
+ _args = [.. args];
+ _body = [.. body];
}
/// The recipient type tag (e.g. "X25519", "scrypt").
@@ -71,21 +94,7 @@ internal void WriteTo(Stream stream)
writer.Write('\n');
writer.Flush();
- var encoded = Base64Unpadded.Encode(_body);
- var offset = 0;
-
- while (offset < encoded.Length)
- {
- var len = Math.Min(64, encoded.Length - offset);
- writer.Write(encoded.AsSpan(offset, len));
- writer.Write('\n');
- offset += len;
- }
-
- // Empty body or exact multiple of 64 chars both need an empty terminator line
- if (encoded.Length % 64 == 0)
- writer.Write('\n');
-
+ WriteBody(writer, Base64Unpadded.Encode(_body));
writer.Flush();
}
@@ -93,7 +102,7 @@ internal static Stanza Parse(HeaderReader reader)
{
var line = reader.ReadLine() ?? throw new AgeHeaderException("unexpected end of header while reading stanza");
- if (!line.StartsWith("-> "))
+ if (!line.StartsWith("-> ", StringComparison.Ordinal))
throw new AgeHeaderException($"expected stanza prefix '-> ', got: {line}");
var parts = line[3..].Split(' ');
@@ -104,45 +113,57 @@ internal static Stanza Parse(HeaderReader reader)
var stanzaType = parts[0];
var stanzaArgs = parts.Length > 1 ? parts[1..] : [];
- // Validate type and args: only printable ASCII (33-126)
- ValidateStanzaString(stanzaType);
+ ThrowIfMalformed(stanzaType);
foreach (var arg in stanzaArgs)
- ValidateStanzaString(arg);
+ ThrowIfMalformed(arg);
var body = ReadBody(reader);
return new Stanza(stanzaType, stanzaArgs, body);
}
+ ///
+ /// Reads the *full-line final-line that writes: full 64-column
+ /// lines until one comes up short, which ends the body and may be empty.
+ ///
private static byte[] ReadBody(HeaderReader reader)
{
- var bodyChunks = new List();
+ var chunks = new List();
+ string line;
- while (true)
- {
- var bodyLine = reader.ReadLine() ?? throw new AgeHeaderException("unexpected end of header while reading stanza body");
-
- switch (bodyLine.Length)
- {
- case > 64:
- throw new AgeHeaderException("stanza body line exceeds 64 characters");
- case > 0:
- bodyChunks.Add(Base64Unpadded.Decode(bodyLine));
- break;
- }
-
- // A short line (< 64 chars) or empty line terminates the body
- if (bodyLine.Length < 64)
- break;
- }
+ while ((line = ReadBodyLine(reader)).Length == ColumnsPerLine)
+ chunks.Add(Base64Unpadded.Decode(line));
+
+ chunks.Add(Base64Unpadded.Decode(line));
+ return Concat(chunks);
+ }
+
+ ///
+ /// One body line, guaranteed no wider than a full-line — so the caller can read "not full
+ /// width" as "final line" without also having to rule out an over-long one.
+ ///
+ private static string ReadBodyLine(HeaderReader reader)
+ {
+ var line = reader.ReadLine() ?? throw new AgeHeaderException("unexpected end of header while reading stanza body");
- return AssembleBody(bodyChunks);
+ return line.Length <= ColumnsPerLine
+ ? line
+ : throw new AgeHeaderException($"stanza body line exceeds {ColumnsPerLine} characters");
}
- private static byte[] AssembleBody(List chunks)
+ ///
+ /// Joins the decoded lines into one exactly-sized array.
+ ///
+ ///
+ /// Deliberately not List<byte> or SelectMany().ToArray(), both of which
+ /// would be shorter: those grow by reallocating, and every abandoned backing array keeps a
+ /// copy of the wrapped file key that nothing can reach to clear. Same reasoning as
+ /// , which sizes its output exactly for the same reason.
+ ///
+ private static byte[] Concat(List chunks)
{
- var totalLen = chunks.Sum(c => c.Length);
- var body = new byte[totalLen];
+ var total = chunks.Sum(c => c.Length);
+ var body = new byte[total];
var pos = 0;
foreach (var chunk in chunks)
@@ -154,26 +175,42 @@ private static byte[] AssembleBody(List chunks)
return body;
}
- private static void ValidateStanzaString(string s)
+ ///
+ /// The spec's argument = 1*VCHAR (age.md:130): non-empty printable ASCII. Returns the
+ /// index of the first character outside 0x21-0x7E, or -1 if there is none.
+ ///
+ ///
+ /// Shared with , which applies the same rule to what
+ /// a plugin sends. Only the rule is shared — each site words its own message, because they
+ /// blame different parties: malformed wire data, a bad argument, or a misbehaving plugin.
+ ///
+ internal static int IndexOfNonVChar(ReadOnlySpan s) =>
+ s.IndexOfAnyExceptInRange('!', '~');
+
+ /// Why a stanza string is unacceptable, or null if it is fine.
+ private static string? InvalidReason(string? s)
{
if (string.IsNullOrEmpty(s))
- throw new AgeHeaderException("stanza type/argument cannot be empty");
+ return "stanza type/argument cannot be empty";
- var invalid = s.IndexOfAnyExceptInRange('!', '~');
- if (invalid >= 0)
- throw new AgeHeaderException($"invalid character in stanza type/argument: 0x{(int)s[invalid]:X2}");
+ var invalid = IndexOfNonVChar(s);
+
+ return invalid >= 0
+ ? $"invalid character in stanza type/argument: 0x{(int)s[invalid]:X2}"
+ : null;
}
- // Same rule as ValidateStanzaString, but for caller-supplied constructor input,
- // where ArgumentException is the idiomatic failure (the parse path keeps
- // AgeHeaderException for malformed wire data).
- private static void EnsureValidStanzaString(string? s, string paramName)
+ /// The string came off the wire, so a bad one means the file is malformed.
+ private static void ThrowIfMalformed(string s)
{
- if (string.IsNullOrEmpty(s))
- throw new ArgumentException("stanza type/argument cannot be empty", paramName);
+ if (InvalidReason(s) is { } reason)
+ throw new AgeHeaderException(reason);
+ }
- var invalid = s.IndexOfAnyExceptInRange('!', '~');
- if (invalid >= 0)
- throw new ArgumentException($"invalid character in stanza type/argument: 0x{(int)s[invalid]:X2}", paramName);
+ /// The string came from the caller, so a bad one means they passed a bad argument.
+ private static void ThrowIfInvalidArgument(string? s, string paramName)
+ {
+ if (InvalidReason(s) is { } reason)
+ throw new ArgumentException(reason, paramName);
}
}
\ No newline at end of file
diff --git a/Age/Plugin/PluginConnection.cs b/Age/Plugin/PluginConnection.cs
index b1f4fad..b419b92 100644
--- a/Age/Plugin/PluginConnection.cs
+++ b/Age/Plugin/PluginConnection.cs
@@ -1,6 +1,10 @@
+using System.Buffers;
using System.ComponentModel;
using System.Diagnostics;
+using System.Runtime.InteropServices;
+using System.Security.Cryptography;
using Age.Crypto;
+using Age.Format;
namespace Age.Plugin;
@@ -21,15 +25,24 @@ public PluginConnection(string pluginName, string stateMachine)
throw new AgePluginException($"refusing to launch plugin with invalid name: '{pluginName}'");
var binaryName = $"age-plugin-{pluginName}";
+
+ // Resolved explicitly: Process.Start would find a bare name in the caller's working
+ // directory, which age-plugin.md prohibits ("MUST NOT be searched").
+ var binaryPath = PluginLocator.Find(binaryName)
+ ?? throw new AgePluginException($"plugin not found: {binaryName}");
+
var startInfo = new ProcessStartInfo
{
- FileName = binaryName,
+ FileName = binaryPath,
Arguments = $"--age-plugin={stateMachine}",
RedirectStandardInput = true,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true,
+ // Don't hand the plugin the caller's working directory either; go-age uses
+ // the temp directory here for the same reason.
+ WorkingDirectory = Path.GetTempPath(),
};
try
@@ -38,11 +51,58 @@ public PluginConnection(string pluginName, string stateMachine)
}
catch (Win32Exception ex)
{
- throw new AgePluginException($"plugin not found: {binaryName}", ex);
+ throw new AgePluginException($"failed to start plugin: {binaryName}: {ex.Message}", ex);
}
_reader = _process.StandardOutput;
_writer = _process.StandardInput;
+
+ // Drained off-thread: an undrained stderr pipe deadlocks once the plugin writes past the
+ // OS buffer (65536 bytes on macOS and Linux). The tail is kept for error messages.
+ _process.ErrorDataReceived += (_, e) =>
+ {
+ if (e.Data is null)
+ return;
+
+ lock (_stderrTail)
+ {
+ _stderrTail.Enqueue(e.Data);
+
+ while (_stderrTail.Count > StderrTailLines)
+ _stderrTail.Dequeue();
+ }
+ };
+
+ _process.BeginErrorReadLine();
+ }
+
+ private const int StderrTailLines = 20;
+
+ private const int StderrFlushMilliseconds = 500;
+
+ private readonly Queue _stderrTail = new();
+
+ ///
+ /// Builds an carrying the last few lines the plugin wrote to
+ /// stderr. When a plugin dies or misbehaves its diagnostics are the only evidence of why, and
+ /// before the stderr pipe was drained they were unreachable.
+ ///
+ internal AgePluginException Failure(string message, Exception? inner = null)
+ {
+ // BeginErrorReadLine delivers asynchronously, so the plugin's last words may not have
+ // arrived yet. WaitForExit(int) does not flush the async readers; the parameterless
+ // overload does.
+ if (_process is not null && _process.WaitForExit(StderrFlushMilliseconds))
+ _process.WaitForExit();
+
+ string tail;
+
+ lock (_stderrTail)
+ tail = _stderrTail.Count == 0 ? "" : string.Join('\n', _stderrTail);
+
+ var full = tail.Length == 0 ? message : $"{message}; plugin stderr:\n{tail}";
+
+ return inner is null ? new AgePluginException(full) : new AgePluginException(full, inner);
}
///
@@ -54,7 +114,31 @@ internal PluginConnection(TextReader reader, TextWriter writer)
_writer = writer;
}
+ ///
+ /// Writes one stanza to the plugin.
+ ///
+ ///
+ /// A plugin that has already died leaves us writing into a closed pipe, which surfaces as a
+ /// raw out of a path documented to throw
+ /// . Which end of the protocol noticed the death first is a
+ /// timing accident — the client writes its whole request before reading a byte, so on a fast
+ /// machine the write fails and on a slow one the read does. Both now report the same way,
+ /// with the plugin's stderr attached.
+ ///
+ /// The plugin exited or the pipe broke.
public void WriteStanza(string type, string[] args, byte[] body)
+ {
+ try
+ {
+ WriteStanzaCore(type, args, body);
+ }
+ catch (Exception ex) when (ex is IOException or ObjectDisposedException)
+ {
+ throw Failure("the plugin exited before the request could be sent", ex);
+ }
+ }
+
+ private void WriteStanzaCore(string type, string[] args, byte[] body)
{
_writer.Write("-> ");
_writer.Write(type);
@@ -67,22 +151,22 @@ public void WriteStanza(string type, string[] args, byte[] body)
_writer.Write('\n');
- var encoded = Base64Unpadded.Encode(body);
- var offset = 0;
+ // The body carries the file key, so it is encoded into a clearable buffer rather than an
+ // immutable string.
+ var encoded = ArrayPool.Shared.Rent(Base64Unpadded.MaxEncodedLength(body.Length));
- while (offset < encoded.Length)
+ try
{
- var len = Math.Min(64, encoded.Length - offset);
- _writer.Write(encoded.AsSpan(offset, len));
- _writer.Write('\n');
- offset += len;
- }
-
- // Empty body or exact multiple of 64 chars both need an empty terminator line
- if (encoded.Length % 64 == 0)
- _writer.Write('\n');
+ var length = Base64Unpadded.Encode(body, encoded);
- _writer.Flush();
+ Stanza.WriteBody(_writer, encoded.AsSpan(0, length));
+ _writer.Flush();
+ }
+ finally
+ {
+ CryptographicOperations.ZeroMemory(MemoryMarshal.AsBytes(encoded.AsSpan()));
+ ArrayPool.Shared.Return(encoded);
+ }
}
public (string Type, string[] Args, byte[] Body)? ReadStanza()
@@ -92,7 +176,7 @@ public void WriteStanza(string type, string[] args, byte[] body)
if (line == null)
return null;
- if (!line.StartsWith("-> "))
+ if (!line.StartsWith("-> ", StringComparison.Ordinal))
throw new AgePluginException($"expected stanza prefix '-> ', got: {line}");
var parts = line[3..].Split(' ');
@@ -102,11 +186,36 @@ public void WriteStanza(string type, string[] args, byte[] body)
var stanzaType = parts[0];
var stanzaArgs = parts.Length > 1 ? parts[1..] : [];
+
+ // Validated here rather than at `new Stanza(...)`, so both plugin types are covered and
+ // a malformed stanza is an AgePluginException rather than a raw ArgumentException.
+ ThrowIfMalformed(stanzaType, "type");
+
+ foreach (var arg in stanzaArgs)
+ ThrowIfMalformed(arg, "argument");
+
var body = ReadBody();
return (stanzaType, stanzaArgs, body);
}
+ ///
+ /// The string came from the plugin, so a bad one means the plugin misbehaved. Same rule
+ /// applies to its own input — a space or newline would corrupt the
+ /// framing — but worded to name the plugin, since that is who got it wrong here.
+ ///
+ private static void ThrowIfMalformed(string s, string what)
+ {
+ if (string.IsNullOrEmpty(s))
+ throw new AgePluginException($"plugin sent an empty stanza {what}");
+
+ var invalid = Stanza.IndexOfNonVChar(s);
+
+ if (invalid >= 0)
+ throw new AgePluginException(
+ $"plugin sent an invalid character in a stanza {what}: 0x{(int)s[invalid]:X2}");
+ }
+
private byte[] ReadBody()
{
var bodyChunks = new List();
@@ -120,7 +229,17 @@ private byte[] ReadBody()
case > 64:
throw new AgePluginException("stanza body line exceeds 64 characters");
case > 0:
- bodyChunks.Add(Base64Unpadded.Decode(bodyLine));
+ // Decode throws FormatException for malformed, padded and non-canonical input
+ // alike; the plugin path reports all three as AgePluginException.
+ try
+ {
+ bodyChunks.Add(Base64Unpadded.Decode(bodyLine));
+ }
+ catch (FormatException ex)
+ {
+ throw new AgePluginException($"plugin sent an invalid stanza body: {ex.Message}", ex);
+ }
+
break;
}
@@ -152,10 +271,26 @@ public void Dispose()
}
catch
{
- // EMPTY
+ // Already closed or the process is gone; nothing to do either way.
+ }
+
+ // Closing stdin asks the plugin to exit; if it declines, kill it rather than leak the
+ // process and its hold on any hardware token.
+ if (!_process.WaitForExit(ExitGraceMilliseconds))
+ {
+ try
+ {
+ _process.Kill(entireProcessTree: true);
+ _process.WaitForExit(ExitGraceMilliseconds);
+ }
+ catch (InvalidOperationException)
+ {
+ // Raced us and exited on its own between the wait and the kill.
+ }
}
- _process.WaitForExit(5000);
_process.Dispose();
}
+
+ private const int ExitGraceMilliseconds = 5000;
}
\ No newline at end of file
diff --git a/Age/Plugin/PluginLocator.cs b/Age/Plugin/PluginLocator.cs
new file mode 100644
index 0000000..20c761e
--- /dev/null
+++ b/Age/Plugin/PluginLocator.cs
@@ -0,0 +1,86 @@
+namespace Age.Plugin;
+
+///
+/// Resolves an age-plugin-<name> binary to an absolute path, searching
+/// PATH and nothing else.
+///
+///
+/// The age-plugin spec is explicit: "Paths relative to the current working directory
+/// MUST NOT be searched, even on platforms or systems where this is the default."
+/// Handing a bare file name to does exactly
+/// that on .NET — even with UseShellExecute = false — so resolution is done here
+/// instead, and only an absolute result is ever handed to the process launcher.
+///
+internal static class PluginLocator
+{
+ ///
+ /// Returns the absolute path of on PATH,
+ /// or null when it is not there.
+ ///
+ public static string? Find(string binaryName) =>
+ Find(binaryName, Environment.GetEnvironmentVariable("PATH"), Environment.GetEnvironmentVariable("PATHEXT"));
+
+ /// Testable core of with the environment injected.
+ internal static string? Find(string binaryName, string? searchPath, string? pathExt)
+ {
+ if (string.IsNullOrEmpty(searchPath))
+ return null;
+
+ foreach (var entry in searchPath.Split(Path.PathSeparator))
+ {
+ // An empty entry means "the current directory" on most shells, and a relative
+ // entry resolves against it — both are exactly what the spec forbids.
+ if (entry.Length == 0 || !Path.IsPathRooted(entry))
+ continue;
+
+ foreach (var candidate in Candidates(entry, binaryName, pathExt))
+ {
+ if (IsExecutableFile(candidate))
+ return candidate;
+ }
+ }
+
+ return null;
+ }
+
+ private static IEnumerable Candidates(string directory, string binaryName, string? pathExt)
+ {
+ var basePath = Path.Combine(directory, binaryName);
+
+ yield return basePath;
+
+ if (!OperatingSystem.IsWindows())
+ yield break;
+
+ // On Windows an extensionless name is not executable; PATHEXT lists the suffixes
+ // the shell would have tried.
+ var extensions = string.IsNullOrEmpty(pathExt) ? ".COM;.EXE;.BAT;.CMD" : pathExt;
+
+ foreach (var extension in extensions.Split(';'))
+ {
+ if (extension.Length > 0)
+ yield return basePath + extension;
+ }
+ }
+
+ private static bool IsExecutableFile(string path)
+ {
+ if (!File.Exists(path))
+ return false;
+
+ if (OperatingSystem.IsWindows())
+ return true;
+
+ try
+ {
+ const UnixFileMode executable =
+ UnixFileMode.UserExecute | UnixFileMode.GroupExecute | UnixFileMode.OtherExecute;
+
+ return (File.GetUnixFileMode(path) & executable) != 0;
+ }
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
+ {
+ return false;
+ }
+ }
+}
diff --git a/Age/Recipients/IMultiStanzaRecipient.cs b/Age/Recipients/IMultiStanzaRecipient.cs
new file mode 100644
index 0000000..8686749
--- /dev/null
+++ b/Age/Recipients/IMultiStanzaRecipient.cs
@@ -0,0 +1,20 @@
+using Age.Format;
+
+namespace Age.Recipients;
+
+///
+/// Internal escape hatch for recipients that legitimately produce more than one stanza for a
+/// single file key — share splitting, group recipients, or a key stanza plus a metadata stanza.
+/// The age-plugin spec permits this and its own recipient-v1 example shows it.
+///
+///
+/// returns a single and is public, shipped
+/// API, so it cannot be widened without breaking every existing implementer. Recipients that
+/// need more implement this alongside it; AgeEncrypt is the only caller of Wrap
+/// in the library, which is what lets a purely internal seam carry the whole fix.
+///
+internal interface IMultiStanzaRecipient
+{
+ /// Wraps the file key into one or more stanzas, all for the same file.
+ IReadOnlyList WrapAll(ReadOnlySpan fileKey);
+}
diff --git a/Age/Recipients/MlKem768X25519Identity.cs b/Age/Recipients/MlKem768X25519Identity.cs
index d01869c..af62a79 100644
--- a/Age/Recipients/MlKem768X25519Identity.cs
+++ b/Age/Recipients/MlKem768X25519Identity.cs
@@ -24,8 +24,22 @@ private MlKem768X25519Identity(byte[] seed)
}
/// The matching public recipient (age1pq1…), derived from the seed.
- public MlKem768X25519Recipient Recipient =>
- new(XWing.GeneratePublicKey(_seed));
+ /// The identity has been disposed.
+ public MlKem768X25519Recipient Recipient
+ {
+ get
+ {
+ // Without this guard a disposed identity derives from the all-zero seed
+ // and returns a well-formed, publicly derivable recipient.
+ ObjectDisposedException.ThrowIf(_disposed, this);
+
+ // Cached: deriving this runs a full ML-KEM-768 keygen. No lock — the derivation is
+ // deterministic, so racing threads compute the same value.
+ return _recipient ??= new MlKem768X25519Recipient(XWing.GeneratePublicKey(_seed));
+ }
+ }
+
+ private MlKem768X25519Recipient? _recipient;
/// Generates a new identity from a cryptographically secure random seed.
public static MlKem768X25519Identity Generate()
@@ -39,7 +53,6 @@ public static MlKem768X25519Identity Generate()
/// The string is not a valid ML-KEM-768-X25519 secret key.
public static MlKem768X25519Identity Parse(string s)
{
- // Must be uppercase
if (s != s.ToUpperInvariant())
throw new FormatException("age secret key must be uppercase");
@@ -61,8 +74,11 @@ public static MlKem768X25519Identity Parse(string s)
/// Returns the bech32-encoded secret seed (AGE-SECRET-KEY-PQ-1…), e.g. for
/// writing to an identity file. Handle the result as a secret.
///
+ /// The identity has been disposed.
public string ToSecretString()
{
+ ObjectDisposedException.ThrowIf(_disposed, this);
+
var seedCopy = new byte[SeedSize];
Array.Copy(_seed, seedCopy, SeedSize);
@@ -76,9 +92,14 @@ public string ToSecretString()
/// recipient (the full recipient is ~2000 characters), so accidental logging
/// or string interpolation cannot leak the secret seed. Use
/// to export the secret.
+ /// Never throws: a disposed identity renders as
+ /// MlKem768X25519Identity(disposed), so debugger and logging calls
+ /// stay safe.
///
public override string ToString() =>
- $"MlKem768X25519Identity({Recipient.ToString()[..24]}…)";
+ _disposed
+ ? "MlKem768X25519Identity(disposed)"
+ : $"MlKem768X25519Identity({Recipient.ToString()[..24]}…)";
///
/// Attempts to unwrap the file key from an mlkem768x25519 stanza.
diff --git a/Age/Recipients/MlKem768X25519Recipient.cs b/Age/Recipients/MlKem768X25519Recipient.cs
index 130459e..3d5da7c 100644
--- a/Age/Recipients/MlKem768X25519Recipient.cs
+++ b/Age/Recipients/MlKem768X25519Recipient.cs
@@ -1,3 +1,4 @@
+using System.Security.Cryptography;
using Age.Crypto;
using Age.Format;
@@ -40,10 +41,14 @@ public static MlKem768X25519Recipient Parse(string s)
if (data.Length != XWing.PublicKeySize)
throw new FormatException($"ML-KEM-768-X25519 public key must be {XWing.PublicKeySize} bytes, got {data.Length}");
- // Must be lowercase
- return s == s.ToLowerInvariant()
- ? new MlKem768X25519Recipient(data)
- : throw new FormatException("age recipient must be lowercase");
+ if (s != s.ToLowerInvariant())
+ throw new FormatException("age recipient must be lowercase");
+
+ // Length and case alone let a structurally invalid ML-KEM key through, deferring the
+ // failure to mid-encryption. Go's ParseHybridRecipient round-trips the encoding here.
+ XWing.ValidatePublicKey(data);
+
+ return new MlKem768X25519Recipient(data);
}
/// Returns the bech32-encoded recipient string (age1pq1…).
@@ -53,8 +58,18 @@ public override string ToString() =>
/// Wraps the file key for this recipient via X-Wing HPKE (ML-KEM-768 + X25519).
public Stanza Wrap(ReadOnlySpan fileKey)
{
- var (enc, ct) = HpkeHelper.SealBase(_publicKey, AgeProtocol.MlKemHpkeInfo, fileKey.ToArray());
- var encB64 = Base64Unpadded.Encode(enc);
- return new Stanza(AgeProtocol.MlKemStanzaType, [encB64], ct);
+ // Named rather than inlined: passing fileKey.ToArray() as an argument left an uncleared
+ // heap copy of the file key itself with no reference to clear it by.
+ var fileKeyCopy = fileKey.ToArray();
+
+ try
+ {
+ var (enc, ct) = HpkeHelper.SealBase(_publicKey, AgeProtocol.MlKemHpkeInfo, fileKeyCopy);
+ return new Stanza(AgeProtocol.MlKemStanzaType, [Base64Unpadded.Encode(enc)], ct);
+ }
+ finally
+ {
+ CryptographicOperations.ZeroMemory(fileKeyCopy);
+ }
}
}
\ No newline at end of file
diff --git a/Age/Recipients/PluginIdentity.cs b/Age/Recipients/PluginIdentity.cs
index 01f5c91..85fc090 100644
--- a/Age/Recipients/PluginIdentity.cs
+++ b/Age/Recipients/PluginIdentity.cs
@@ -46,10 +46,11 @@ private void SendUnwrapRequest(PluginConnection conn, IReadOnlyList stan
{
conn.WriteStanza("add-identity", [identity], []);
- for (var i = 0; i < stanzas.Count; i++)
+ // FILE_INDEX identifies the file, not the stanza: "Duplicate file indices indicate stanzas
+ // that are from the same file header, and wrap the same file key." One file, so all carry 0.
+ foreach (var s in stanzas)
{
- var s = stanzas[i];
- string[] args = [i.ToString(), s.Type, .. s.Args];
+ string[] args = ["0", s.Type, .. s.Args];
conn.WriteStanza("recipient-stanza", args, s.Body.ToArray());
}
@@ -67,8 +68,15 @@ private void SendUnwrapRequest(PluginConnection conn, IReadOnlyList stan
switch (type)
{
case "file-key":
- if (args.Length < 1)
- throw new AgePluginException("file-key stanza missing file index");
+ // One file was sent, so 0 is the only valid index, and a second file-key is a protocol
+ // error rather than a replacement.
+ if (args.Length != 1 || args[0] != "0")
+ throw new AgePluginException(
+ $"file-key stanza has unexpected file index: {string.Join(' ', args)}");
+
+ if (result is not null)
+ throw new AgePluginException("duplicate file-key stanza");
+
result = body;
conn.WriteStanza("ok", [], []);
break;
@@ -90,7 +98,7 @@ private void SendUnwrapRequest(PluginConnection conn, IReadOnlyList stan
private static void HandleError(PluginConnection conn, string[] args, byte[] body)
{
if (args.Length > 0 && args[0] == "internal")
- throw new AgePluginException($"plugin internal error: {Encoding.UTF8.GetString(body)}");
+ throw conn.Failure($"plugin internal error: {Encoding.UTF8.GetString(body)}");
// Identity errors mean this identity doesn't match — return null
conn.WriteStanza("ok", [], []);
@@ -98,7 +106,8 @@ private static void HandleError(PluginConnection conn, string[] args, byte[] bod
private static (string Type, string[] Args, byte[] Body) ReadNextStanza(PluginConnection conn)
{
- var raw = conn.ReadStanza() ?? throw new AgePluginException("unexpected end of plugin output");
+ // stderr is the only account of why the plugin died, so Failure() quotes it.
+ var raw = conn.ReadStanza() ?? throw conn.Failure("unexpected end of plugin output");
return raw;
}
@@ -106,8 +115,7 @@ private void HandleCommonStanza(PluginConnection conn, string type, string[] arg
{
switch (type)
{
- // Per the age-plugin spec, interactive requests are answered with
- // fail when the client has no UI to present them
+ // The spec answers interactive requests with fail when the client has no UI.
case "msg" or "request-secret" or "request-public" or "confirm" when callbacks is null:
conn.WriteStanza("fail", [], []);
break;
@@ -136,8 +144,13 @@ private void HandleCommonStanza(PluginConnection conn, string type, string[] arg
private void HandleConfirm(PluginConnection conn, string[] args, byte[] body)
{
+ // (confirm, Base64(YES_STRING) [Base64(NO_STRING)]; MESSAGE) — the yes label is
+ // mandatory, so a missing one is a malformed command, not a label to invent.
+ if (args.Length is not (1 or 2))
+ throw new AgePluginException("malformed confirm stanza: unexpected number of arguments");
+
var message = Encoding.UTF8.GetString(body);
- var yes = args.Length > 0 ? DecodeOptionLabel(args[0]) : "yes";
+ var yes = DecodeOptionLabel(args[0]);
var no = args.Length > 1 ? DecodeOptionLabel(args[1]) : null;
var confirmed = callbacks!.Confirm(message, yes, no);
conn.WriteStanza("ok", [confirmed ? "yes" : "no"], []);
@@ -157,17 +170,15 @@ private static string DecodeOptionLabel(string arg)
internal static string ExtractPluginName(string identity)
{
- // Bech32-decode to get HRP. For "AGE-PLUGIN-YUBIKEY-1...", HRP = "age-plugin-yubikey-", name = HRP[11..^1] = "yubikey"
+ // A plugin identity HRP is "age-plugin--" — "AGE-PLUGIN-YUBIKEY-1..." gives "yubikey".
+ // Both affixes are required so hrp[11..^1] cannot run out of range.
var (hrp, _) = Bech32.Decode(identity);
- // A plugin identity HRP is "age-plugin--"; require both affixes (with room
- // between them) so the hrp[11..^1] slice can't run out of range on a malformed value.
- var name = hrp.StartsWith("age-plugin-") && hrp.EndsWith("-") && hrp.Length > 11
+ var name = hrp.StartsWith("age-plugin-", StringComparison.Ordinal) && hrp.EndsWith("-", StringComparison.Ordinal) && hrp.Length > 11
? hrp[11..^1]
: throw new FormatException($"invalid plugin identity HRP: {hrp}");
- // The name becomes the age-plugin- executable path, so reject anything
- // outside the allowed set (notably path separators) before it reaches Process.Start.
+ // The name becomes an executable path, so reject path separators before Process.Start.
return PluginNameValidator.Validate(name);
}
diff --git a/Age/Recipients/PluginRecipient.cs b/Age/Recipients/PluginRecipient.cs
index f91fb04..efad757 100644
--- a/Age/Recipients/PluginRecipient.cs
+++ b/Age/Recipients/PluginRecipient.cs
@@ -1,3 +1,4 @@
+using System.Security.Cryptography;
using System.Text;
using Age.Crypto;
using Age.Format;
@@ -15,7 +16,8 @@ namespace Age.Recipients;
/// Optional UI callbacks for interactive plugins; when null, interactive
/// requests are answered with failure per the plugin protocol.
///
-public sealed class PluginRecipient(string recipient, IPluginCallbacks? callbacks = null) : IRecipient
+public sealed class PluginRecipient(string recipient, IPluginCallbacks? callbacks = null)
+ : IRecipient, IMultiStanzaRecipient
{
internal string PluginName { get; } =
ExtractPluginName(recipient);
@@ -25,15 +27,45 @@ public sealed class PluginRecipient(string recipient, IPluginCallbacks? callback
null;
- /// Wraps the file key by running the plugin binary (recipient-v1 protocol).
- /// The plugin failed, misbehaved, or reported an error.
+ ///
+ /// Wraps the file key by running the plugin binary (recipient-v1 protocol).
+ ///
+ ///
+ /// A plugin is permitted to answer with several stanzas. This returns a single one and so
+ /// cannot represent that, and silently dropping the rest would destroy the file key beyond
+ /// recovery — so it throws instead. The library itself does not go through here: it uses
+ /// the multi-stanza path and keeps every stanza.
+ ///
+ /// The plugin failed, or produced more than one stanza.
public Stanza Wrap(ReadOnlySpan fileKey)
{
using var conn = new PluginConnection(PluginName, "recipient-v1");
- return WrapWithConnection(conn, fileKey);
+ var stanzas = WrapAllWithConnection(conn, fileKey);
+
+ return stanzas.Count == 1
+ ? stanzas[0]
+ : throw new AgePluginException(
+ $"plugin '{PluginName}' produced {stanzas.Count} recipient stanzas, which a single " +
+ "Stanza cannot carry; encrypt through Age instead of calling Wrap directly");
+ }
+
+ IReadOnlyList IMultiStanzaRecipient.WrapAll(ReadOnlySpan fileKey)
+ {
+ using var conn = new PluginConnection(PluginName, "recipient-v1");
+ return WrapAllWithConnection(conn, fileKey);
}
internal Stanza WrapWithConnection(PluginConnection conn, ReadOnlySpan fileKey)
+ {
+ var stanzas = WrapAllWithConnection(conn, fileKey);
+
+ return stanzas.Count == 1
+ ? stanzas[0]
+ : throw new AgePluginException(
+ $"plugin '{PluginName}' produced {stanzas.Count} recipient stanzas");
+ }
+
+ internal IReadOnlyList WrapAllWithConnection(PluginConnection conn, ReadOnlySpan fileKey)
{
SendWrapRequest(conn, fileKey);
return ReadWrapResponse(conn);
@@ -42,14 +74,30 @@ internal Stanza WrapWithConnection(PluginConnection conn, ReadOnlySpan fil
private void SendWrapRequest(PluginConnection conn, ReadOnlySpan fileKey)
{
conn.WriteStanza("add-recipient", [recipient], []);
- conn.WriteStanza("wrap-file-key", [], fileKey.ToArray());
- conn.WriteStanza("extension-labels", [], []);
+
+ // Named, not inlined: an argument-position ToArray() leaves no reference to clear it by.
+ var fileKeyCopy = fileKey.ToArray();
+
+ try
+ {
+ conn.WriteStanza("wrap-file-key", [], fileKeyCopy);
+ }
+ finally
+ {
+ CryptographicOperations.ZeroMemory(fileKeyCopy);
+ }
+
+ // No "extension-labels": advertising it promises we act on the plugin's "labels" reply, and
+ // IRecipient.Label cannot hold a label set. Staying silent stops a conforming plugin from
+ // sending one, rather than having us discard a constraint it relies on.
conn.WriteStanza("done", [], []);
}
- private Stanza ReadWrapResponse(PluginConnection conn)
+ private List ReadWrapResponse(PluginConnection conn)
{
- Stanza? result = null;
+ // Accumulate rather than replace: the spec's recipient-v1 example has one plugin emit two
+ // stanzas for a single file index, and a share-splitting plugin needs all of them.
+ var result = new List();
while (true)
{
@@ -58,16 +106,17 @@ private Stanza ReadWrapResponse(PluginConnection conn)
switch (type)
{
case "recipient-stanza":
- result = ParseRecipientStanza(args, body);
+ result.Add(ParseRecipientStanza(args, body));
conn.WriteStanza("ok", [], []);
break;
case "error":
- throw new AgePluginException($"plugin error: {Encoding.UTF8.GetString(body)}");
+ throw conn.Failure($"plugin error: {Encoding.UTF8.GetString(body)}");
case "done":
- return result
- ?? throw new AgePluginException("plugin completed without producing a recipient stanza");
+ return result.Count > 0
+ ? result
+ : throw new AgePluginException("plugin completed without producing a recipient stanza");
default:
HandleCommonStanza(conn, type, args, body);
@@ -81,6 +130,10 @@ private static Stanza ParseRecipientStanza(string[] args, byte[] body)
if (args.Length < 2)
throw new AgePluginException("recipient-stanza missing file index or type");
+ // One file key was sent, so 0 is the only index the plugin may answer with.
+ if (args[0] != "0")
+ throw new AgePluginException($"recipient-stanza has unexpected file index: {args[0]}");
+
var stanzaType = args[1];
var stanzaArgs = args.Length > 2 ? args[2..] : [];
return new Stanza(stanzaType, stanzaArgs, body);
@@ -88,7 +141,8 @@ private static Stanza ParseRecipientStanza(string[] args, byte[] body)
private static (string Type, string[] Args, byte[] Body) ReadNextStanza(PluginConnection conn)
{
- var raw = conn.ReadStanza() ?? throw new AgePluginException("unexpected end of plugin output");
+ // stderr is the only account of why the plugin died, so Failure() quotes it.
+ var raw = conn.ReadStanza() ?? throw conn.Failure("unexpected end of plugin output");
return raw;
}
@@ -96,8 +150,7 @@ private void HandleCommonStanza(PluginConnection conn, string type, string[] arg
{
switch (type)
{
- // Per the age-plugin spec, interactive requests are answered with
- // fail when the client has no UI to present them
+ // The spec answers interactive requests with fail when the client has no UI.
case "msg" or "request-secret" or "request-public" or "confirm" when callbacks is null:
conn.WriteStanza("fail", [], []);
break;
@@ -126,8 +179,13 @@ private void HandleCommonStanza(PluginConnection conn, string type, string[] arg
private void HandleConfirm(PluginConnection conn, string[] args, byte[] body)
{
+ // (confirm, Base64(YES_STRING) [Base64(NO_STRING)]; MESSAGE) — the yes label is
+ // mandatory, so a missing one is a malformed command, not a prompt to invent a label for.
+ if (args.Length is not (1 or 2))
+ throw new AgePluginException("malformed confirm stanza: unexpected number of arguments");
+
var message = Encoding.UTF8.GetString(body);
- var yes = args.Length > 0 ? DecodeOptionLabel(args[0]) : "yes";
+ var yes = DecodeOptionLabel(args[0]);
var no = args.Length > 1 ? DecodeOptionLabel(args[1]) : null;
var confirmed = callbacks!.Confirm(message, yes, no);
conn.WriteStanza("ok", [confirmed ? "yes" : "no"], []);
@@ -147,17 +205,15 @@ private static string DecodeOptionLabel(string arg)
internal static string ExtractPluginName(string recipient)
{
- // Bech32-decode to get HRP. For "age1yubikey1...", HRP = "age1yubikey", name = HRP[4..] = "yubikey"
+ // A plugin recipient HRP is "age1" — "age1yubikey1..." gives name "yubikey". The prefix
+ // check keeps hrp[4..] in range for a short HRP like "age".
var (hrp, _) = Bech32.Decode(recipient);
- // A plugin recipient HRP is "age1"; require the "age1" prefix so hrp[4..]
- // is always in range (a shorter HRP like "age" would otherwise throw).
- var name = hrp.StartsWith("age1")
+ var name = hrp.StartsWith("age1", StringComparison.Ordinal)
? hrp[4..]
: throw new FormatException($"invalid plugin recipient HRP: {hrp}");
- // The name becomes the age-plugin- executable path, so reject anything
- // outside the allowed set (notably path separators) before it reaches Process.Start.
+ // The name becomes an executable path, so reject path separators before Process.Start.
return PluginNameValidator.Validate(name);
}
diff --git a/Age/Recipients/ScryptRecipient.cs b/Age/Recipients/ScryptRecipient.cs
index 26d326e..24b5c1b 100644
--- a/Age/Recipients/ScryptRecipient.cs
+++ b/Age/Recipients/ScryptRecipient.cs
@@ -22,7 +22,10 @@ public sealed class ScryptRecipient(string passphrase, int workFactor = 18) : IR
private const string StanzaType = "scrypt";
private const string ScryptSaltLabel = "age-encryption.org/v1/scrypt";
private const int SaltSize = 16;
- private const int MaxWorkFactor = 20;
+ // Matches Go's ScryptIdentity default (references/go-age/scrypt.go:129, "15s on a modern
+ // machine"). The spec only says SHOULD apply an upper limit; a lower one rejects genuine
+ // age-produced files, so this accepts the same attacker-demandable work the reference does.
+ private const int MaxWorkFactor = 22;
private const int KeySize = 32;
private const int NonceSize = 12;
private const int WrappedKeySize = 32; // 16-byte file key + 16-byte Poly1305 tag
@@ -46,12 +49,17 @@ public Stanza Wrap(ReadOnlySpan fileKey)
var wrapKey = DeriveWrapKey(passphrase, salt, _workFactor);
- var zeroNonce = new byte[NonceSize];
- var body = CryptoHelper.ChaChaEncrypt(wrapKey, zeroNonce, fileKey);
- CryptographicOperations.ZeroMemory(wrapKey);
+ try
+ {
+ var zeroNonce = new byte[NonceSize];
+ var body = CryptoHelper.ChaChaEncrypt(wrapKey, zeroNonce, fileKey);
- var saltB64 = Base64Unpadded.Encode(salt);
- return new Stanza(StanzaType, [saltB64, _workFactor.ToString()], body);
+ return new Stanza(StanzaType, [Base64Unpadded.Encode(salt), _workFactor.ToString()], body);
+ }
+ finally
+ {
+ CryptographicOperations.ZeroMemory(wrapKey);
+ }
}
///
@@ -91,12 +99,16 @@ public Stanza Wrap(ReadOnlySpan fileKey)
var wrapKey = DeriveWrapKey(passphrase, salt, stanzaWorkFactor);
- var zeroNonce = new byte[NonceSize];
- var fileKey = CryptoHelper.ChaChaDecrypt(wrapKey, zeroNonce, stanza.Body.Span);
- CryptographicOperations.ZeroMemory(wrapKey);
-
- // AEAD auth failure → wrong passphrase, return null to signal no match
- return fileKey;
+ try
+ {
+ // AEAD auth failure → wrong passphrase, return null to signal no match
+ var zeroNonce = new byte[NonceSize];
+ return CryptoHelper.ChaChaDecrypt(wrapKey, zeroNonce, stanza.Body.Span);
+ }
+ finally
+ {
+ CryptographicOperations.ZeroMemory(wrapKey);
+ }
}
internal static bool ValidateWorkFactor(string s, out int workFactor)
@@ -127,10 +139,15 @@ private static byte[] DeriveWrapKey(string passphrase, byte[] salt, int workFact
var n = 1 << workFactor;
var passphraseBytes = Encoding.UTF8.GetBytes(passphrase);
- var result = SCrypt.Generate(passphraseBytes, scryptSalt, n, 8, 1, KeySize);
-
- CryptographicOperations.ZeroMemory(passphraseBytes);
-
- return result;
+ try
+ {
+ return SCrypt.Generate(passphraseBytes, scryptSalt, n, 8, 1, KeySize);
+ }
+ finally
+ {
+ // The UTF-8 copy of the passphrase. The caller's string cannot be cleared — that
+ // would need a Passphrase type taking ReadOnlySpan, which is new public API.
+ CryptographicOperations.ZeroMemory(passphraseBytes);
+ }
}
}
\ No newline at end of file
diff --git a/Age/Recipients/SshEd25519Identity.cs b/Age/Recipients/SshEd25519Identity.cs
index b12d8cb..9fb938f 100644
--- a/Age/Recipients/SshEd25519Identity.cs
+++ b/Age/Recipients/SshEd25519Identity.cs
@@ -44,10 +44,8 @@ public static SshEd25519Identity Parse(string pemText)
var ed25519Private = (Ed25519PrivateKeyParameters)privateKey;
- // Convert Ed25519 private key seed → X25519 private key
var x25519Private = Ed25519Converter.PrivateKeyToX25519(ed25519Private.GetEncoded());
- // Derive X25519 public key from the X25519 private key
var x25519PrivateParam = new X25519PrivateKeyParameters(x25519Private);
var x25519Pub = x25519PrivateParam.GeneratePublicKey().GetEncoded();
@@ -70,12 +68,10 @@ public static SshEd25519Identity Parse(string pemText)
if (stanza.Args.Count != 2)
throw new AgeHeaderException($"ssh-ed25519 stanza must have exactly 2 arguments, got {stanza.Args.Count}");
- // Check tag matches
var stanzaTag = stanza.Args[0];
if (stanzaTag != _tag)
return null;
- // Decode ephemeral public key
byte[] ephPubBytes;
try
{
@@ -95,12 +91,10 @@ public static SshEd25519Identity Parse(string pemText)
var ephPub = new X25519PublicKeyParameters(ephPubBytes);
var privateKey = new X25519PrivateKeyParameters(_x25519PrivateKey);
- // rawSS = X25519.ScalarMult(_x25519PrivateKey, ephPub)
- var agreement = new X25519Agreement();
- agreement.Init(privateKey);
-
- var rawSS = new byte[agreement.AgreementSize];
- agreement.CalculateAgreement(ephPub, rawSS, 0);
+ // rawSS = X25519.ScalarMult(_x25519PrivateKey, ephPub). The ephemeral share comes from
+ // the stanza, so it is attacker-controlled.
+ var rawSS = new byte[CryptoHelper.X25519SharedSecretSize];
+ CryptoHelper.X25519Agree(privateKey, ephPub, rawSS);
// tweak = HKDF(ikm=[], salt=sshWireBytes, info=label, 32)
var tweak = CryptoHelper.HkdfDerive([], _sshWireBytes, AgeProtocol.SshEd25519HkdfLabel, KeySize);
@@ -109,11 +103,8 @@ public static SshEd25519Identity Parse(string pemText)
var tweakPrivate = new X25519PrivateKeyParameters(tweak);
var rawSSPub = new X25519PublicKeyParameters(rawSS);
- var tweakAgreement = new X25519Agreement();
- tweakAgreement.Init(tweakPrivate);
-
- var tweakedSS = new byte[tweakAgreement.AgreementSize];
- tweakAgreement.CalculateAgreement(rawSSPub, tweakedSS, 0);
+ var tweakedSS = new byte[CryptoHelper.X25519SharedSecretSize];
+ CryptoHelper.X25519Agree(tweakPrivate, rawSSPub, tweakedSS);
// wrapKey = HKDF(ikm=tweakedSS, salt=ephPub||convertedKey, info=label, 32)
var salt = (byte[])[.. ephPubBytes, .. _x25519PublicKey];
diff --git a/Age/Recipients/SshEd25519Recipient.cs b/Age/Recipients/SshEd25519Recipient.cs
index be9d012..438bf8c 100644
--- a/Age/Recipients/SshEd25519Recipient.cs
+++ b/Age/Recipients/SshEd25519Recipient.cs
@@ -50,25 +50,17 @@ public Stanza Wrap(ReadOnlySpan fileKey)
// tweakedKey = X25519.ScalarMult(tweak, _x25519PublicKey)
var tweakPrivate = new X25519PrivateKeyParameters(tweak);
var recipientPub = new X25519PublicKeyParameters(_x25519PublicKey);
- var agreement = new X25519Agreement();
-
- agreement.Init(tweakPrivate);
- var tweakedKey = new byte[agreement.AgreementSize];
-
- agreement.CalculateAgreement(recipientPub, tweakedKey, 0);
+ var tweakedKey = new byte[CryptoHelper.X25519SharedSecretSize];
+ CryptoHelper.X25519Agree(tweakPrivate, recipientPub, tweakedKey);
- // Generate ephemeral X25519 key pair
var ephemeral = new X25519PrivateKeyParameters(new SecureRandom());
var ephPubBytes = ephemeral.GeneratePublicKey().GetEncoded();
- // sharedSecret = X25519.ScalarMult(ephSecret, tweakedKey)
- // We need to do DH(ephemeral, tweakedKey) but tweakedKey is a point, not a public key parameter
- // Use the tweakedKey as a public key for the agreement
+ // sharedSecret = X25519.ScalarMult(ephSecret, tweakedKey). tweakedKey is a point rather
+ // than a public key parameter, so it is wrapped as one for the agreement.
var tweakedPub = new X25519PublicKeyParameters(tweakedKey);
- var ephAgreement = new X25519Agreement();
- ephAgreement.Init(ephemeral);
- var sharedSecret = new byte[ephAgreement.AgreementSize];
- ephAgreement.CalculateAgreement(tweakedPub, sharedSecret, 0);
+ var sharedSecret = new byte[CryptoHelper.X25519SharedSecretSize];
+ CryptoHelper.X25519Agree(ephemeral, tweakedPub, sharedSecret);
// wrapKey = HKDF(ikm=sharedSecret, salt=ephPub||convertedKey, info=label, 32)
var salt = (byte[])[.. ephPubBytes, .. _x25519PublicKey];
diff --git a/Age/Recipients/SshRsaIdentity.cs b/Age/Recipients/SshRsaIdentity.cs
index 4fab25d..71e1733 100644
--- a/Age/Recipients/SshRsaIdentity.cs
+++ b/Age/Recipients/SshRsaIdentity.cs
@@ -58,7 +58,6 @@ public static SshRsaIdentity Parse(string pemText)
if (stanza.Args.Count != 1)
throw new AgeHeaderException($"ssh-rsa stanza must have exactly 1 argument, got {stanza.Args.Count}");
- // Check tag matches
if (stanza.Args[0] != _tag)
return null;
diff --git a/Age/Recipients/X25519Identity.cs b/Age/Recipients/X25519Identity.cs
index 909324e..546ffc4 100644
--- a/Age/Recipients/X25519Identity.cs
+++ b/Age/Recipients/X25519Identity.cs
@@ -27,7 +27,17 @@ private X25519Identity(byte[] rawPrivateKey)
}
/// The matching public recipient (age1…), derived from the secret key.
- public X25519Recipient Recipient => new(PublicKeyParams);
+ /// The identity has been disposed.
+ public X25519Recipient Recipient
+ {
+ get
+ {
+ // Without this guard a disposed identity derives from the all-zero key
+ // and returns a well-formed, publicly derivable recipient.
+ ObjectDisposedException.ThrowIf(_disposed, this);
+ return new(PublicKeyParams);
+ }
+ }
private X25519PublicKeyParameters PublicKeyParams
{
@@ -51,7 +61,6 @@ public static X25519Identity Generate()
/// The string is not a valid X25519 secret key.
public static X25519Identity Parse(string s)
{
- // Must be uppercase
if (s != s.ToUpperInvariant())
throw new FormatException("age secret key must be uppercase");
@@ -73,8 +82,11 @@ public static X25519Identity Parse(string s)
/// Returns the bech32-encoded secret key (AGE-SECRET-KEY-1…), e.g. for
/// writing to an identity file. Handle the result as a secret.
///
+ /// The identity has been disposed.
public string ToSecretString()
{
+ ObjectDisposedException.ThrowIf(_disposed, this);
+
var rawCopy = new byte[KeySize];
Array.Copy(_rawPrivateKey, rawCopy, KeySize);
@@ -88,9 +100,11 @@ public string ToSecretString()
/// Returns a redacted representation containing only the public recipient, so
/// accidental logging or string interpolation cannot leak the secret key.
/// Use to export the secret key.
+ /// Never throws: a disposed identity renders as X25519Identity(disposed),
+ /// so debugger and logging calls stay safe.
///
public override string ToString() =>
- $"X25519Identity({Recipient})";
+ _disposed ? "X25519Identity(disposed)" : $"X25519Identity({Recipient})";
///
/// Attempts to unwrap the file key from an X25519 stanza. Returns null for
@@ -126,40 +140,30 @@ public override string ToString() =>
var ephPub = new X25519PublicKeyParameters(ephPubBytes);
var privateKeyParams = new X25519PrivateKeyParameters(_rawPrivateKey);
- // DH: identity × ephemeral
- var agreement = new X25519Agreement();
- agreement.Init(privateKeyParams);
- var sharedSecret = new byte[agreement.AgreementSize];
+ // The try opens before the agreement, not after the derivation, so the shared secret is
+ // covered from allocation rather than from first use.
+ var sharedSecret = new byte[CryptoHelper.X25519SharedSecretSize];
+ byte[]? wrapKey = null;
+
try
{
- agreement.CalculateAgreement(ephPub, sharedSecret, 0);
- }
- catch (InvalidOperationException)
- {
- throw new AgeHeaderException("X25519 shared secret is all-zero (low-order or identity point)");
- }
-
- // BouncyCastle may not reject all low-order points — check for all-zero shared secret
- if (sharedSecret.All(b => b == 0))
- throw new AgeHeaderException("X25519 shared secret is all-zero (low-order or identity point)");
+ CryptoHelper.X25519Agree(privateKeyParams, ephPub, sharedSecret);
- // HKDF: salt = ephPub || recipientPub, info = label
- var recipientPubBytes = PublicKeyParams.GetEncoded();
- var salt = (byte[])[.. ephPubBytes, .. recipientPubBytes];
+ // HKDF: salt = ephPub || recipientPub, info = label
+ var recipientPubBytes = PublicKeyParams.GetEncoded();
+ var salt = (byte[])[.. ephPubBytes, .. recipientPubBytes];
- var wrapKey = CryptoHelper.HkdfDerive(sharedSecret, salt, AgeProtocol.X25519HkdfLabel, KeySize);
+ wrapKey = CryptoHelper.HkdfDerive(sharedSecret, salt, AgeProtocol.X25519HkdfLabel, KeySize);
- try
- {
- // Decrypt file key
+ // Decrypt file key. An AEAD failure means a wrong recipient, not our stanza.
var zeroNonce = new byte[12];
-
- // AEAD failure → wrong recipient, not our stanza
return CryptoHelper.ChaChaDecrypt(wrapKey, zeroNonce, stanza.Body.Span);
}
finally
{
- CryptographicOperations.ZeroMemory(wrapKey);
+ if (wrapKey is not null)
+ CryptographicOperations.ZeroMemory(wrapKey);
+
CryptographicOperations.ZeroMemory(sharedSecret);
}
}
diff --git a/Age/Recipients/X25519Recipient.cs b/Age/Recipients/X25519Recipient.cs
index 76503e0..4db3e59 100644
--- a/Age/Recipients/X25519Recipient.cs
+++ b/Age/Recipients/X25519Recipient.cs
@@ -35,7 +35,6 @@ public static X25519Recipient Parse(string s)
if (data.Length != KeySize)
throw new FormatException($"X25519 public key must be {KeySize} bytes, got {data.Length}");
- // Must be lowercase
if (s != s.ToLowerInvariant())
throw new FormatException("age recipient must be lowercase");
@@ -49,27 +48,13 @@ public override string ToString() =>
/// Wraps the file key for this recipient using ephemeral X25519 + ChaCha20-Poly1305.
public Stanza Wrap(ReadOnlySpan fileKey)
{
- // Generate ephemeral X25519 key pair
var ephemeral = new X25519PrivateKeyParameters(new SecureRandom());
var ephPubBytes = ephemeral.GeneratePublicKey().GetEncoded();
- // DH: ephemeral × recipient
- var agreement = new X25519Agreement();
- agreement.Init(ephemeral);
- var sharedSecret = new byte[agreement.AgreementSize];
-
- try
- {
- agreement.CalculateAgreement(_publicKey, sharedSecret, 0);
- }
- catch (InvalidOperationException)
- {
- throw new AgeException("X25519 key agreement failed (shared secret is zero)");
- }
-
- // BouncyCastle may not reject all low-order points — check for all-zero shared secret
- if (sharedSecret.All(b => b == 0))
- throw new AgeException("X25519 key agreement failed (shared secret is zero)");
+ // DH: ephemeral × recipient. A recipient parsed from a hostile age1… string can carry a
+ // low-order point, so this is guarded on the encrypt side too.
+ var sharedSecret = new byte[CryptoHelper.X25519SharedSecretSize];
+ CryptoHelper.X25519Agree(ephemeral, _publicKey, sharedSecret);
// HKDF: salt = ephPub || recipientPub, info = label
var recipientPubBytes = _publicKey.GetEncoded();
diff --git a/Age/StreamExtensions.cs b/Age/StreamExtensions.cs
new file mode 100644
index 0000000..198a107
--- /dev/null
+++ b/Age/StreamExtensions.cs
@@ -0,0 +1,28 @@
+namespace Age;
+
+///
+/// Adds to .
+///
+///
+/// A C# 14 extension member, internal for the same reason as
+/// : extending a framework type is a liberty taken
+/// for this library's own call sites, not for anything a consumer would see.
+///
+internal static class StreamExtensions
+{
+ extension(Stream stream)
+ {
+ ///
+ /// Guarantees the stream has received at least one Write call.
+ ///
+ ///
+ /// Some writers create their destination lazily on the first write — the CLI's output
+ /// file does this so a failed decrypt leaves no file behind. When the plaintext is
+ /// empty, CopyTo never calls Write and such a destination would never
+ /// come into existence, turning "decrypted an empty file" into "produced no file".
+ /// An empty write forces materialization and is a no-op on ordinary streams.
+ /// Call it only after a successful copy, so failures still leave no output behind.
+ ///
+ public void EnsureMaterialized() => stream.Write(ReadOnlySpan.Empty);
+ }
+}
diff --git a/codecov.yml b/codecov.yml
index e37b37f..f2fc49c 100644
--- a/codecov.yml
+++ b/codecov.yml
@@ -1,3 +1,12 @@
+# Coverage now arrives from all three matrix legs, because some code is genuinely
+# platform-specific (PluginLocator's PATHEXT expansion cannot run on Linux, and the
+# plugin process tests were long skipped on Windows). Wait for all three before
+# computing a status, or whichever upload lands first decides the verdict and the
+# numbers swing between runs.
+codecov:
+ notify:
+ after_n_builds: 3
+
# Allow trivial coverage fluctuations (e.g. an unexercised defensive branch)
# to pass rather than failing the PR on a fraction-of-a-percent change.
coverage:
diff --git a/docs/BACKPORT_0.2.md b/docs/BACKPORT_0.2.md
new file mode 100644
index 0000000..420e423
--- /dev/null
+++ b/docs/BACKPORT_0.2.md
@@ -0,0 +1,2471 @@
+# AgeSharp v0.2 backport survey
+
+Target of this survey: **`main`** at `c295c10` (the v0.2 line, published as `0.2.0-preview.3`),
+investigated in a clean worktree at
+`/private/tmp/claude-501/-Users-pscheid-Projects-AgeSharp/f3dfa216-b5bc-4f2f-99e9-47910d60f1fd/scratchpad/main-wt`.
+The `next-version` branch was used only as a source of already-written fixes to compare against; it
+is **not** the target and several of its fixes are entangled with the v0.3 API redesign and are
+explicitly excluded below.
+
+Referee for every differential run: `age` v1.3.1 on PATH. Reference sources read locally:
+`references/go-age/` (filippo.io/age) and `references/rust-age/` (rage), plus the vendored spec at
+`docs/spec/age.md` / `docs/spec/age-plugin.md`.
+
+---
+
+## 1. Summary
+
+**40 distinct defects** survived adversarial verification. Of those:
+
+| | count |
+|---|---|
+| Reproduced empirically (transcript or differential run against `age`) | **32** |
+| Confirmed by source reading only, not observed running | **8** |
+| Security | 12 |
+| Correctness | 12 |
+| Interop | 5 |
+| Hygiene | 11 |
+| API-neutral as-is | 38 |
+| Partially API-entangled (a neutral subset is described) | 2 |
+
+**Headline risks, in order:**
+
+1. **Arbitrary code execution.** `age-plugin-*` binaries are resolved through the process's
+ *current working directory*, which the age-plugin spec explicitly forbids. Running an
+ AgeSharp-based tool from inside an untrusted directory and using **any** plugin recipient
+ executes an attacker-planted binary and hands it the raw file key. Reproduced on macOS.
+ Both reference implementations refuse. (§S1)
+
+2. **Silent acceptance of truncated ciphertext.** `AgeRandomAccess` derives the plaintext length
+ from ciphertext *layout* and never authenticates the final chunk, so an attacker-truncated file
+ is returned as a shorter plaintext with no error. `age` v1.3.1 and AgeSharp's *own* forward-only
+ path both reject the same files. (§S2, §S3)
+
+3. **main cannot read ~4.2% of valid armored age files — including its own output.** The armor
+ decoder rejects any final body line that is 64 characters wide *and* carries base64 padding.
+ That is exactly 2 of every 48 ciphertext lengths. An armored passphrase-encrypted identity file
+ produced by `AgeKeygen.EncryptIdentityFile(..., armor: true)` is unreadable by AgeSharp about
+ 4% of the time; `age` reads all of them. (§C1)
+
+4. **Cross-renter memory aliasing.** `DecryptStream`/`EncryptStream` return their pooled buffers to
+ `ArrayPool.Shared` on *every* `Dispose`, and `Stream.Dispose()` is not idempotent. The
+ idiomatic `using var s = DecryptReader(...); using var sr = new StreamReader(s);` shape hands one
+ array to two unrelated renters, and produced spurious `chunk 0 authentication failed` errors on
+ valid files in the repro. (§S4)
+
+5. **Permanent, silent data loss through plugins.** A plugin returning more than one
+ `recipient-stanza` has all but the last discarded; encryption reports success and the file is
+ undecryptable by anything, including `age`. (§C4)
+
+Nothing found is a break of the age cryptography itself. No all-zero X25519 shared secret is ever
+fed into a KDF on main — BouncyCastle rejects it at all eight agreement sites even where AgeSharp's
+own guard is absent. The zeroization findings are defence-in-depth (CWE-226 class): exploiting them
+requires an independent local memory-disclosure primitive (core dump, swap, hibernation image,
+debugger). They are reported as `security` because the project documents zeroization as an
+invariant, not because any of them is remotely reachable.
+
+---
+
+## 2. Table of contents (by severity)
+
+### Security (12)
+
+| # | Defect | Reproduced |
+|---|---|---|
+| [S1](#s1) | Plugin binaries executed from the current working directory | yes |
+| [S2](#s2) | `AgeRandomAccess` silently truncates when the final chunk is cut to its tag | yes |
+| [S3](#s3) | `AgeRandomAccess` authenticates nothing when the computed length is 0 | yes |
+| [S4](#s4) | Double `Dispose` returns pooled buffers twice — cross-renter aliasing | yes |
+| [S5](#s5) | Disposed `MlKem768X25519Identity` / `X25519Identity` yield the all-zero-seed keypair | yes |
+| [S6](#s6) | Client advertises `extension-labels` and then ignores the reply | yes |
+| [S7](#s7) | The whole post-quantum path zeroes no secret at all | yes (partial) |
+| [S8](#s8) | `CryptoHelper.HkdfDerive` leaves an uncleared heap copy of every input key | yes |
+| [S9](#s9) | File key not zeroed on any error path in `AgeEncrypt` | yes |
+| [S10](#s10) | `AgeKeygen` leaves the decrypted identity file (private keys) in memory | yes |
+| [S11](#s11) | `Ed25519Converter` leaves the full SHA-512 expansion of the SSH private key | yes |
+| [S12](#s12) | `Bech32` leaves the private key in 5-bit form and in a lowercased string | yes |
+
+### Correctness (12)
+
+| # | Defect | Reproduced |
+|---|---|---|
+| [C1](#c1) | Armor decoder rejects a padded 64-char final line — ~4.2% of files unreadable | yes |
+| [C2](#c2) | The armor path disposes the **caller's** stream | yes |
+| [C3](#c3) | Culture-sensitive `StartsWith` mis-frames header lines | yes |
+| [C4](#c4) | Multi-stanza plugin response: all but the last silently discarded | yes |
+| [C5](#c5) | identity-v1 sends a distinct FILE_INDEX per stanza | yes |
+| [C6](#c6) | Plugin stderr is redirected but never drained — deadlock past 64 KiB | yes |
+| [C7](#c7) | Raw `FormatException` / `ArgumentException` escape the plugin path | yes |
+| [C8](#c8) | `SshEd25519Identity.Unwrap` has no low-order guard | yes |
+| [C9](#c9) | `XWing.Encaps` leaks raw BCL exceptions out of `Encrypt` | yes |
+| [C10](#c10) | `SshKeyParser` throws `ArgumentException` where `FormatException` is documented | yes |
+| [C11](#c11) | Seek-from-End never verifies the final chunk (spec MUST) | yes |
+| [C12](#c12) | `Read()` after `Dispose()` returns another renter's memory | yes |
+
+### Interop (5)
+
+| # | Defect | Reproduced |
+|---|---|---|
+| [I1](#i1) | Armored input is not detected on a non-seekable stream | yes |
+| [I2](#i2) | `MlKem768X25519Recipient.Parse` accepts a structurally invalid ML-KEM key | yes |
+| [I3](#i3) | scrypt work factor hard-capped at 20; Go's library default max is 22 | yes |
+| [I4](#i4) | Plugin FILE_INDEX never validated; duplicate `file-key` silently replaces | yes |
+| [I5](#i5) | A `confirm` with zero arguments is answered with a fabricated "yes" | yes |
+
+### Hygiene (11)
+
+| # | Defect | Reproduced |
+|---|---|---|
+| [H1](#h1) | `Header.ComputeMac` never zeroes the derived header MAC key | yes |
+| [H2](#h2) | `ScryptRecipient` clears outside `finally`; passphrase held as a `string` | no |
+| [H3](#h3) | Plugin wire path pushes secrets through unzeroable strings | no |
+| [H4](#h4) | `X25519Identity.Unwrap` allocates the shared secret outside its `try` | no |
+| [H5](#h5) | `AgeRandomAccess` does not zero a decrypted chunk on one error path | no |
+| [H6](#h6) | Every mlkem stanza re-runs full ML-KEM keygen — ~7x pre-auth CPU vs `age` | yes |
+| [H7](#h7) | Plugin `Dispose` stalls 5 s then abandons the process | yes |
+| [H8](#h8) | `StreamEncryption`'s whole-stream methods buffer everything and are test-only | no |
+| [H9](#h9) | Armor decoder accepts a bare CR as a line terminator | yes |
+| [H10](#h10) | Armor decoder accepts leading whitespace on the BEGIN marker | yes |
+| [H11](#h11) | No bound on leading whitespace before the BEGIN marker | yes |
+
+---
+
+## 3. Defects
+
+
+### S1 — Plugin binaries are executed from the current working directory
+
+**Location** — `Age/Plugin/PluginConnection.cs:26` (`FileName = binaryName`), with the
+`Win32Exception` → `AgePluginException` mapping at `:41`.
+
+**What is wrong** — `ProcessStartInfo.FileName` is set to the bare name `age-plugin-` with
+`UseShellExecute = false` and no `WorkingDirectory`, and .NET's resolution then finds the binary in
+the process's current working directory even when it is not on PATH. `docs/spec/age-plugin.md:60-63`
+states verbatim: paths relative to the current working directory MUST NOT be searched, even on
+platforms where that is the default.
+
+**Reproduction** — reproduced on macOS 25.0.0 against a Release build of main.
+
+```
+$ mkdir /tmp/attacker && cp fake-plugin /tmp/attacker/age-plugin-cwd && chmod +x ...
+$ which age-plugin-cwd # not found — /tmp/attacker is NOT on PATH
+$ cd /tmp/attacker && # new PluginRecipient("age1cwd1qypqxpqujapgu").Wrap(fileKey)
+STANZA type=cwdtype args=[] body=42424242
+# planted binary's own log:
+EXECUTED FROM CWD, argv=['/private/tmp/attacker/age-plugin-cwd', '--age-plugin=recipient-v1']
+FILE KEY RECEIVED: 000102030405060708090a0b0c0d0e0f
+```
+
+Controls: from `/tmp/clean` → `Age.AgePluginException: plugin not found: age-plugin-cwd`
+(`PluginConnection.cs:41`); from `/tmp/attacker/sub` (binary in the parent) → same exception. So
+resolution is exactly CWD-relative, not inherited.
+
+Referee, same setup: `age -r age1cwd1qypqxpqujapgu` from `/tmp/attacker` →
+`"cwd" plugin not found: exec: "age-plugin-cwd": executable file not found in $PATH`.
+go-age imports `golang.org/x/sys/execabs` for exactly this (`plugin/client.go:22`) and additionally
+sets `cmd.Dir = os.TempDir()` (`plugin/client.go:463`). rust-age resolves via PATH only.
+
+**Severity + who is affected** — **security (arbitrary code execution + file-key disclosure)**.
+Any user of an AgeSharp-based tool who runs it with the working directory inside an untrusted tree
+(unpacked archive, shared CI checkout, `~/Downloads`, a cloned repo) *and* encrypts or decrypts to
+any plugin recipient/identity. Not Windows-only.
+
+**API-neutral?** — Yes. `PluginConnection` is internal; only `ProcessStartInfo` construction changes.
+
+**Fix shape** — Resolve `age-plugin-` explicitly: split `PATH` on `Path.PathSeparator`, skip
+empty and non-rooted entries (`Path.IsPathRooted`), probe each candidate (plus `PATHEXT` on Windows)
+for an existing executable file, assign the resulting **absolute** path to `FileName`, and throw
+`AgePluginException($"plugin not found: age-plugin-{name}")` when nothing matches. Also set
+`startInfo.WorkingDirectory = Path.GetTempPath()`, matching go-age. **Not fixed on `next-version`
+either** — its `PluginConnection.cs:26` is identical, so this must be written fresh and applied to
+both branches.
+
+**Confidence** — certain; reproduced independently twice.
+
+---
+
+
+### S2 — `AgeRandomAccess` silently truncates when the final chunk is cut to exactly its tag
+
+**Location** — `Age/AgeRandomAccess.cs:156` (`InitializeFromStream`), `:234-245`
+(`ComputePlaintextLength`), `:90`/`:96` (`ReadAt` bound), with the guard that was written for this
+case sitting unreachable at `:181-182`.
+
+**What is wrong** — `PlaintextLength` is derived from ciphertext *layout* arithmetic only; nothing
+is authenticated at construction. `DecryptChunkAt` is only ever reached for offsets strictly below
+`PlaintextLength`, so a final chunk whose plaintext length is 0 is never decrypted and never
+authenticated. Chunk layout alone cannot distinguish a truncated file from a shorter one.
+
+**Reproduction** — encrypt N bytes with main, then drop the last `N mod 65536` ciphertext bytes so
+the final chunk is exactly its 16-byte Poly1305 tag:
+
+```
+n=65537 cut=1 -> age_ok=False fwd_ok=False(AgePayloadException) seek_ok=True len=65536 read=65536
+n=65541 cut=5 -> age_ok=False fwd_ok=False(AgePayloadException) seek_ok=True len=65536 read=65536
+n=131073 cut=1 -> age_ok=False fwd_ok=False(AgePayloadException) seek_ok=True len=131072 read=131072
+n=131172 cut=100 -> age_ok=False fwd_ok=False(AgePayloadException) seek_ok=True len=131072 read=131072
+```
+
+`age` v1.3.1 rejects all four. main's **own** forward-only path (`AgeEncrypt.Decrypt`) rejects all
+four. `AgeRandomAccess` accepts all four and returns a plaintext short by 1 / 5 / 1 / 100 bytes with
+no exception. The unreachability of the `:181` guard is provable: firing it needs
+`chunkIndex > 0 && plaintext.Length == 0`, which implies `PlaintextLength == chunkIndex * 65536`,
+contradicting the loop's `currentOffset < PlaintextLength`.
+
+`docs/spec/age.md:161-162` requires decryption to signal an error if EOF is reached without
+successfully decrypting a final chunk.
+
+**Severity + who is affected** — **security**. Any library caller using `AgeRandomAccess` (the CLI
+does not) on a file an attacker or a bad disk/transfer could have truncated.
+
+**API-neutral?** — Yes. Entirely inside `AgeRandomAccess` internals; `AgePayloadException` is
+already a documented constructor exception (`AgeRandomAccess.cs:44`).
+
+**Fix shape** — In `InitializeFromStream`, after computing the layout, read and decrypt chunk
+`totalChunks - 1` with `isFinal: true`, reject an empty final chunk when `totalChunks > 1`, and
+derive `PlaintextLength` from that authenticated chunk. This is what `next-version` does in
+`Age/Crypto/SeekableDecryptStream.cs` (`Create` → `FinalChunkLayout` → `CacheFinalChunk`). Delete
+the now-false XML remark at `Age/AgeRandomAccess.cs:18-19`
+("Truncation of the final chunk is only detectable when a read actually reaches it").
+No existing test asserts the lenient behaviour (`Age.Tests/RandomAccessTests.cs` has no truncation
+or tampering test at all).
+
+**Confidence** — certain; reproduced independently by two investigators.
+
+---
+
+
+### S3 — `AgeRandomAccess` authenticates nothing when the computed plaintext length is 0
+
+**Location** — `Age/AgeRandomAccess.cs:90` (`ReadAt` early return), with `:153-156` (only
+`totalEncrypted == 0` rejected) and `:241-242` (only `lastChunkPlainSize < 0` rejected).
+
+**What is wrong** — When `ComputePlaintextLength` yields 0 — any payload of exactly 16 bytes, or a
+payload chopped so only 16 bytes remain — `ReadAt` returns 0 immediately and no chunk is ever
+decrypted. Construction rejects only `totalEncrypted == 0` and payloads under 16 bytes. The caller
+cannot distinguish an authentic empty file from a forged or destroyed one.
+
+**Reproduction**
+
+```
+valid empty file age_ok=True fwd_ok=True seek_ok=True len=0
+empty file, last tag byte flipped age_ok=False fwd_ok=False(AgePayloadException) seek_ok=True len=0 read=0
+empty file, nonce last byte flipped age_ok=False fwd_ok=False(AgePayloadException) seek_ok=True len=0 read=0
+n=65537, payload chopped to 16 bytes age_ok=False fwd_ok=False(AgePayloadException) seek_ok=True len=0 read=0
+n=131072, payload chopped to 16 bytes age_ok=False fwd_ok=False(AgePayloadException) seek_ok=True len=0 read=0
+```
+
+`age` v1.3.1 and main's forward-only path reject every tampered case; `AgeRandomAccess` accepts all
+of them.
+
+**Severity + who is affected** — **security**. Same audience as S2. Listed separately from S2
+because it is a distinct reachable input class: S2 needs `totalChunks > 1`, this needs
+`totalChunks == 1`.
+
+**API-neutral?** — Yes.
+
+**Fix shape** — Subsumed by the S2 fix. Once the final chunk is authenticated at construction, the
+`PlaintextLength == 0` case is covered because chunk 0 is decrypted as the final chunk.
+
+**Confidence** — certain; reproduced.
+
+---
+
+
+### S4 — Double `Dispose` returns pooled buffers twice, handing one array to two independent renters
+
+**Location** — `Age/Crypto/DecryptStream.cs:148-161` and `Age/Crypto/EncryptStream.cs:152-164`
+(neither `Dispose(bool)` has a `_disposed` guard; `ArrayPool.Return` at `:155-156` and `:159-160`
+respectively). `Age/Format/ArmorStream.cs:119-125` is also unguarded and forwards to the inner
+`EncryptStream`.
+
+**What is wrong** — `System.IO.Stream.Dispose()` provides no idempotence guard, and `Stream.Close()`
+is a documented alias for it, so calling both is legal caller code. Each call unconditionally
+`Return`s both rented buffers, putting an array that is already on the pool's free list onto it
+again; two subsequent unrelated `Rent` calls then receive the *same* array. The second `Dispose` also
+runs `ZeroMemory` over the buffer, wiping whatever the new owner has since written.
+
+**Reproduction** — reproduced deterministically 3/3 runs, from public API only:
+
+```csharp
+using var plaintext = AgeEncrypt.DecryptReader(new MemoryStream(ct), id);
+using var sr = new StreamReader(plaintext); // StreamReader.Dispose disposes the inner stream
+_ = sr.ReadToEnd(); // then the outer `using` disposes it again
+// -> pool handed the same array to two renters: True
+```
+
+Direct demonstration of the wipe and the aliasing:
+
+```
+s = AgeEncrypt.DecryptReader(...); s.CopyTo(dst); s.Close(); s.Dispose();
+var a = ArrayPool.Shared.Rent(65536);
+var b = ArrayPool.Shared.Rent(65536);
+// ReferenceEquals(a, b) == true
+a.AsSpan(0,4).Fill(0xEE); // renter A writes
+// b[0] == 0xEE // renter B observes renter A's bytes
+```
+
+Control: a single `Dispose` returns distinct arrays. The armored encrypt path
+(`AgeEncrypt.EncryptReader(plaintext, armor: true, ...)`) reproduces the same aliasing through
+`ArmorStream`. A downstream effect observed in the repro: with the pool corrupted, a subsequent
+well-formed AgeSharp decrypt failed with a spurious
+`AgePayloadException: chunk 0 authentication failed` — so this bug manifests as bogus authentication
+errors on valid files.
+
+`AgeRandomAccess.Dispose` (`:128`) and `X25519Identity.Dispose` (`:170`) both *do* have the guard,
+so the two streams are the outlier.
+
+**Severity + who is affected** — **security**. Cross-renter buffer aliasing between unrelated
+`ArrayPool` consumers in the same process is a confidentiality and memory-integrity violation, not
+merely missing defence in depth. One correction to how this has been described elsewhere:
+`_plaintextBuffer` **is** zeroed on both `Dispose` passes, so age plaintext is not itself carried
+out — the hazard is the generic aliasing.
+
+**API-neutral?** — Yes. `DecryptStream`, `EncryptStream` and `ArmorStream` are all internal.
+
+**Fix shape** — `private bool _disposed;` plus `if (disposing && !_disposed) { _disposed = true; … }`
+in all three. Note this also fixes the current double-`_cipher.Dispose()` and double
+`ciphertext.Dispose()` (when `ownsStream`). `next-version` fixed `DecryptStream`
+(`Age/Crypto/DecryptStream.cs:15,192-207`) but **not** `EncryptStream`
+(`Age/Crypto/EncryptStream.cs:221-233` there is still unguarded) — take just the guard, not the v0.3
+restructuring or the `DisposeAsync` override, and apply the `EncryptStream` half to both branches.
+
+**Confidence** — certain; reproduced independently by two investigators.
+
+---
+
+
+### S5 — A disposed identity silently yields the all-zero-seed keypair instead of throwing
+
+**Location** — `Age/Recipients/MlKem768X25519Identity.cs:27` (`Recipient`), `:64`
+(`ToSecretString`), `:80` (`ToString`) — the `_disposed` guard exists only at `:91` (`Unwrap`).
+Identical in `Age/Recipients/X25519Identity.cs:30`, `:76`, `:92`, guard only at `:103`.
+
+**What is wrong** — `Dispose()` zeroes `_seed`, but `Recipient`, `ToSecretString()` and `ToString()`
+have no guard and operate on the now-all-zero seed, returning a well-formed but publicly derivable
+recipient / secret key. No error, no exception, no indication.
+
+**Reproduction**
+
+```
+PQ after dispose: pubA2 == pubB2? True changed from real? True
+PQ secret export: AGE-SECRET-KEY-PQ-1QQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQAE0WQE
+X25519 after dispose: pubA2 == pubB2? True changed from real? True
+X25519 pub: age19ljhmg68e43yx9fgm2k9lwefquc0la5y4lzvlshdjzv47kxt8d6qr9vf4p
+X25519 sec: AGE-SECRET-KEY-1QQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQ8H00W3
+Unwrap after dispose (both types): THREW ObjectDisposedException
+```
+
+Two distinct identities collapse to one publicly-derivable keypair. Anything encrypted to that
+recipient is world-readable. `Unwrap` throwing correctly on the same instance makes the intent
+unambiguous.
+
+**Severity + who is affected** — **security**, but note the precondition honestly: it requires a
+caller-side use-after-dispose bug. It is not attacker-reachable on its own. What makes it security
+rather than a missing assertion is that the failure mode is *silent fail-open to a world-known key*.
+
+**API-neutral?** — Yes. No `PublicAPI.*.txt` entry moves; only behaviour on an already-broken call
+path changes.
+
+**Fix shape** — `ObjectDisposedException.ThrowIf(_disposed, this);` at the top of the `Recipient`
+getter and `ToSecretString()`; make `ToString()` return `"MlKem768X25519Identity(disposed)"` when
+disposed rather than calling `Recipient`, so a debugger/logging `ToString` does not throw. Same three
+lines in `X25519Identity`. `next-version` does exactly this
+(`MlKem768X25519Identity.cs:33/46/120-124`, `X25519Identity.cs:35/52`); the guard is independent of
+the `IIdentityWithRecipient` redesign.
+
+**Confidence** — certain; reproduced independently by two investigators.
+
+---
+
+
+### S6 — Client advertises `extension-labels` and then ignores the `labels` reply
+
+**Location** — `Age/Recipients/PluginRecipient.cs:46` (the advertisement), `:24-25`
+(`Label => null`), `:121-123` (default → `unsupported`). The label check that is thereby defeated is
+`AgeEncrypt.BuildHeaderAndFileKey` at `Age/AgeEncrypt.cs:216-222`.
+
+**What is wrong** — Sending `extension-labels` means, per `docs/spec/age-plugin.md:224-233`, that the
+client will accept a `labels` command and MUST check that all recipient stanzas wrapping a given
+file key have the exact same label set. `ReadWrapResponse` has no `labels` case: it falls through to
+`unsupported`, and `PluginRecipient.Label` is hardcoded `null`, so the plugin's constraint is
+silently discarded.
+
+**Reproduction** — fake plugin `age-plugin-lbl` answering phase 2 with `-> labels postquantum`:
+
+```
+$ age -r age1lbl1qypqxtz4cd9 -r age1pf372jafrxxg78jsz7gj3s8z9rmscm8ydlus0httfjc6h02cad5sdpv6j4 -o out.age
+age: error: incompatible recipients: can't mix post-quantum and classic recipients,
+ or the file would be vulnerable to quantum computers (exit 1)
+plugin log: PLUGIN SENT: -> labels postquantum / client answered labels with: ok
+
+$
+exit 0, "ENCRYPTED"
+plugin log: client answered labels with: unsupported
+resulting header:
+ -> lbl
+ TEzkUPRe8NRRVzsgMTkWF53r
+ -> X25519 B9MjYMIDFAmEhRBknJP5tsNoJJFh9+/EIYK/ShhlBHQ
+```
+
+The PQ-labelled plugin stanza and the classical X25519 stanza wrap the same file key — exactly the
+failure labels exist to prevent.
+
+**Severity + who is affected** — **security**, with the precondition stated plainly: the user must
+themselves list both recipients. This is a guardrail bypass, not an attacker-triggered path. Anyone
+using a labelling plugin (post-quantum, or a single-use "must not be combined" label).
+
+**API-neutral?** — Yes.
+
+**Fix shape** — Delete `conn.WriteStanza("extension-labels", [], []);` at `PluginRecipient.cs:46`.
+A conforming plugin will then not send `labels`, and the client is honest about its capability.
+This is exactly what `next-version` chose (`Age/Recipients/PluginRecipient.cs:44-45` carries the
+comment explaining it). Full label support is **not** API-neutral on main — `IRecipient.Label` is a
+single `string?` rather than a set — and must not be backported. `Age.Tests/PluginTests.cs:313`
+asserts `-> extension-labels` is sent and must be updated.
+
+**Confidence** — certain; reproduced.
+
+**Follow-up: what the spec actually asks for, and why v0.2 cannot give it**
+
+`references/age-plugin.md` is normative and unambiguous that a label is a *set* per recipient:
+
+- `:307` — "The order of labels in the command is irrelevant. Clients MUST treat them as an
+ unordered set."
+- `:227` — "Clients MUST check that all recipient stanzas wrapping a given file key have the exact
+ same label set. Clients MUST NOT permit partial overlapping sets."
+- `:305` — "Plugins MUST NOT send duplicate labels", so sorted-list equality is set equality for any
+ conforming plugin. `references/go-age/age.go:130` relies on exactly that (`sort.Strings` then
+ `slices.Equal`).
+- `:291-304` names three idioms: a *common public* label (`postquantum`), a *common private* label
+ (plugins from one vendor that may only combine with each other), and a *random* label (force the
+ stanza to be used alone).
+
+Our `IRecipient.Label` is `string?`, so it represents the empty set and singletons only. Of the
+three idioms, two survive: `postquantum` is a singleton, and the random-label idiom works because
+`firstLabel` is read once and cached, so a lone recipient passes and any pairing fails. What cannot
+be represented is a set of size >= 2 — a vendor plugin wanting `{postquantum, acme-internal}` has to
+drop one, and dropping either silently loosens a constraint the plugin was relying on.
+
+There is a second, structural reason the type alone is not the whole gap. `Label` is a property read
+*before* any wrapping (`AgeEncrypt.BuildHeader`), whereas a plugin can only declare its labels
+partway through the recipient-v1 conversation — `references/go-age/plugin/client.go:141` takes them
+from the wrap exchange, which is why Go's interface is
+`WrapWithLabels(fileKey) (stanzas, labels, err)`. A property that must answer before the plugin
+process starts cannot carry plugin labels whatever its type is.
+
+Both are why S6's fix is to stop advertising `extension-labels` rather than to half-implement it.
+
+**Related divergence: scrypt's implicit label set (not a defect)**
+
+`references/age-plugin.md:232-233` — "The `scrypt` recipient stanza has an implicit label set
+containing a single random label. In other words, it can't be combined with any other stanza."
+`ScryptRecipient` declares no label at all, so by the spec's model it reports the *empty* set, the
+same as x25519 (`:231`), and the label check alone would let `scrypt + x25519` through. The rule is
+enforced instead by the explicit structural check in `BuildHeader` that rejects a scrypt stanza
+alongside any other. Same outcome by a different mechanism, and arguably the stronger one: it runs
+*after* `Wrap` and keys off the emitted stanza type, so a custom recipient that emits a scrypt
+stanza is caught too, which a label on `ScryptRecipient` would not catch. Recorded so the divergence
+is deliberate rather than discovered later.
+
+**v0.3 shape** — move labels onto the wrap result rather than a property, and widen to a set:
+`WrapResult Wrap(ReadOnlySpan fileKey)` carrying `IReadOnlyList` plus
+`IReadOnlySet Labels`. That subsumes S6, the `IMultiStanzaRecipient` side-interface added for
+C4, and this scrypt special case in one change — scrypt then genuinely returns a random singleton,
+as the spec describes.
+
+---
+
+
+### S7 — The whole post-quantum path zeroes no secret at all
+
+**Location**
+- `Age/Crypto/XWing.cs` — `grep -c ZeroMemory` returns **0** for the entire file.
+ `seedPq` `:127` (64 bytes = the ML-KEM-768 private seed `(d,z)`), `seedT` `:130` (32 bytes = the
+ X25519 private scalar), `ssM`/`ssX` `:49`/`:55` (Encaps) and `:84`/`:88` (Decaps), the combined
+ secret from `CombineSharedSecret` `:108-120`. `ExpandSeed` also returns `seedPQ` in a tuple that
+ both call sites (`:25`, `:76`) discard with `_`.
+- `Age/Crypto/HpkeHelper.cs` — `grep -c ZeroMemory` returns **0**. `secret` (HPKE PRK) `:49`,
+ `key` (the ChaCha20-Poly1305 key that unwraps the file key) `:50`, `baseNonce` `:51`; `ss`, `key`
+ and `nonce` in `SealBase` `:21-27` and `OpenBase` `:29-34`; `labeledIkm` `:60` in `LabeledExtract`,
+ which holds a raw copy of the X-Wing shared secret.
+- `Age/Recipients/MlKem768X25519Recipient.cs:56` —
+ `HpkeHelper.SealBase(_publicKey, AgeProtocol.MlKemHpkeInfo, fileKey.ToArray())`: an uncleared heap
+ copy of the **file key itself**, created inline as an argument so no reference exists to clear.
+
+**What is wrong** — `CLAUDE.md`'s own table promises "X-Wing components, HPKE PRK, seeds → cleared by
+the deriving method itself" and "file key → never reaches the GC heap". On main that is documentation
+of intent, not of behaviour. `ExpandSeed` runs on every `Encaps`, every `Decaps`, **and** every
+`XWing.GeneratePublicKey` — which `MlKem768X25519Identity.Recipient` calls, which `ToString()` calls.
+So merely logging a PQ identity re-derives and abandons its full ML-KEM private seed, and
+`MlKem768X25519Identity.Dispose` does not actually erase the private key it promises to.
+
+**Reproduction** — heap-residue probe (Release; run the operation, drop all references, run four
+**non-compacting** blocking gen2 collections, then allocate ~520 MB via
+`GC.AllocateUninitializedArray(64 KiB)` and search the recycled pages; needles pinned alive so
+they are never themselves swept; two controls in every run).
+
+```
+== controls (validate the technique) ==
+ a value that only ever existed in a live array residue=False (expect False) as expected
+ an array deliberately dropped without clearing residue=True (expect True) as expected
+== XWing / HpkeHelper ==
+ 64-byte ML-KEM-768 PRIVATE SEED, after Decrypt residue=True (expect True) as expected
+ HPKE PRK (secret) after encrypt+decrypt residue=True
+ HPKE AEAD key (unwraps the file key) residue=True
+ PQ Wrap: fileKey.ToArray() heap copy residue=True
+ 32-byte X-Wing shared secret, 200 PQ decrypts 11 copies vs zeroed control 1 (0 at ITER=0)
+```
+
+**Caveats stated plainly.** (a) The X-Wing shared-secret residue does not accumulate (11 copies at
+both 200 and 800 iterations) because the PQ path's large allocations churn the heap and overwrite
+older copies — a weaker signal than the file-key case, but not a refutation. (b) Attribution is
+imperfect: BouncyCastle's `MLKemPrivateKeyParameters.FromSeed` retains the seed internally, and the
+BCL/BouncyCastle AEAD retains the key, so clearing AgeSharp's own arrays reduces but may not
+eliminate residue. What is *proved* is that these secrets survive the operation on the reclaimable
+heap; that AgeSharp's own arrays are uncleared is certain by inspection.
+(c) Exploitation requires local memory disclosure (core dump, swap, hibernation image, debugger) —
+this is defence-in-depth, not a remote attack. What lifts it above hygiene is that `seedPq` is
+`(d,z)` for ML-KEM-768 `KeyGen_internal` and `seedT` is the X25519 scalar: either is
+identity-equivalent, so residency is full key compromise rather than a transient secret.
+
+Contrast with main's own classical path: `X25519Recipient.cs:84` passes the file key span straight
+into `ChaChaEncrypt` with no `ToArray`, and `:89-93` zeroes `wrapKey` and `sharedSecret` in a
+`finally`.
+
+**Severity + who is affected** — **security (defence in depth)**. Every user of the `age1pq1` /
+`mlkem768x25519` recipient type.
+
+**API-neutral?** — Yes. `XWing` and `HpkeHelper` are `internal static class`;
+`MlKem768X25519Recipient.Wrap`'s public `Stanza Wrap(ReadOnlySpan)` signature is untouched.
+
+**Fix shape**
+1. `XWing.ExpandSeed`: wrap the body in `try/finally` and `ZeroMemory(seedPq)` + `ZeroMemory(seedT)`.
+ Verified empirically that this is safe — BouncyCastle copies what it needs
+ (`MLKem FromSeed: pk stable after zeroing seed? True`;
+ `X25519 ctor: pk stable after zeroing scalar? True`). Drop the discarded `seedPQ` tuple element
+ while you are there.
+2. Give `CombineSharedSecret` ownership of `ssM`/`ssX` and clear both in a `finally`.
+3. `HpkeHelper.KeyScheduleBase`: clear `secret` and the incoming `sharedSecret` in a `finally`;
+ `SealBase`/`OpenBase` clear `key` and `nonce`; `LabeledExtract` clears `labeledIkm`.
+4. Change `HpkeHelper.SealBase`'s third parameter from `byte[]` to `ReadOnlySpan` and drop the
+ `.ToArray()` at `MlKem768X25519Recipient.cs:56`.
+
+Items 1-3 exist on `next-version` (`Age/Crypto/XWing.cs:99-100,141-142`;
+`Age/Crypto/HpkeHelper.cs:33,48,76-77`) and lift almost verbatim. Item 4 is entangled there with the
+span-based `IRecipient.Wrap` redesign; the `ReadOnlySpan` parameter on the internal `SealBase` is the
+neutral substitute. Do **not** import `next-version`'s caller-filled-span restructuring from
+`30f38d1`.
+
+**Confidence** — certain on the source facts; reproduced for the seed, PRK, AEAD key, file-key copy
+and shared secret.
+
+---
+
+
+### S8 — `CryptoHelper.HkdfDerive` leaves an uncleared heap copy of every input key
+
+**Location** — `Age/Crypto/CryptoHelper.cs:23`:
+
+```csharp
+hkdf.Init(new HkdfParameters(ikm.ToArray(), salt.ToArray(), Encoding.ASCII.GetBytes(info)));
+```
+
+`grep -c ZeroMemory Age/Crypto/CryptoHelper.cs` → 0.
+
+**What is wrong** — `ikm.ToArray()` allocates a GC-heap copy of the input keying material that no
+caller can see or clear. Callers deliberately pass `ReadOnlySpan` so the secret need not be
+heap-resident, and then dutifully zero their own buffers — the `ToArray` silently undoes that.
+`X25519Identity.Unwrap` zeroes its `sharedSecret` in a `finally` at `:160-164` and it makes no
+difference.
+
+All 13 call sites enumerated; 11 pass a secret:
+
+| ikm | sites |
+|---|---|
+| file key | `AgeEncrypt.cs:92`, `:130`, `:178`, `:201`; `AgeRandomAccess.cs:149`; `Format/Header.cs:90` |
+| X25519 / ssh shared secret | `X25519Identity.cs:150`, `X25519Recipient.cs:78`, `SshEd25519Identity.cs:120`, `SshEd25519Recipient.cs:75` |
+| empty (harmless) | `SshEd25519Recipient.cs:48`, `SshEd25519Identity.cs:106` |
+
+The `salt` is never secret (payload nonce, ssh wire bytes, `ephPub‖pubkey`) and `info` is a literal,
+so only the `ikm` copy needs clearing.
+
+**Reproduction** — differential heap-residue measurement, two independent runs:
+
+```
+Run A (spy IIdentity capturing the file key, 301 decrypts):
+ FILE KEY residual copies, unmodified main : 1800 (zeroed 16-byte control: 1)
+ FILE KEY residual copies, one-line fix : 1200 (control: 1)
+ -> ~600 copies over ~300 decrypts = 2 per decrypt = the two HkdfDerive calls per decrypt
+
+Run B (needle = 32-byte X25519 shared secret, recomputed independently with BouncyCastle):
+ 16-byte FILE KEY, after AgeEncrypt.Encrypt residue=True (expect True)
+ 32-byte X25519 SHARED SECRET, after Decrypt residue=True (expect True)
+ controls behaved correctly in the same run
+```
+
+**Caveat, and it matters for the changelog:** the one-line fix removes only about one third of the
+residue (1800 → 1200). BouncyCastle's `HMac`/`Sha256Digest` block buffer retains any IKM ≤ 64 bytes
+internally, which the fix cannot reach. Do **not** describe this fix as eliminating the residue.
+
+**Severity + who is affected** — **security (defence in depth)**. Every encrypt and every decrypt,
+every recipient type. Not remotely exploitable.
+
+**API-neutral?** — Yes, in the form below.
+
+**Fix shape**
+
+```csharp
+var ikmCopy = ikm.ToArray();
+try { /* existing body, using ikmCopy */ }
+finally { CryptographicOperations.ZeroMemory(ikmCopy); }
+```
+
+`CryptoHelper` is `internal static`; keep main's `byte[] HkdfDerive(ReadOnlySpan,
+ReadOnlySpan, string, int)` signature. `next-version`'s `30f38d1` **also** flipped the method
+to fill a caller-supplied `Span`, cascading into 22 call sites — that half is v0.3 API redesign
+and must be dropped from the backport.
+
+**Confidence** — certain; reproduced, with the fix's effect measured differentially.
+
+---
+
+
+### S9 — The file key is not zeroed on any error path in `AgeEncrypt`
+
+**Location** — five reachable throw sites:
+
+| # | site | trigger |
+|---|---|---|
+| 1 | `Age/AgeEncrypt.cs:199-210` (`DecryptReader`) | `ReadPayloadNonce` throws on a file truncated inside the nonce; `ZeroMemory` at `:202` is skipped and the `catch` at `:206-210` only disposes the dearmor stream |
+| 2 | `Age/AgeEncrypt.cs:275` (`UnwrapHeaderFromReader`) | `header.VerifyMac(fileKey)` throws `AgeHmacException` on a corrupted/tampered header, after the genuine key is unwrapped, with no `finally` anywhere |
+| 3 | `Age/AgeEncrypt.cs:272-273` (same method) | a non-16-byte return from a custom or plugin identity |
+| 4 | `Age/AgeEncrypt.cs:230` (`BuildHeaderAndFileKey`) | `recipient.Wrap(fileKey)` throws — an `AgePluginException` (plugin missing, user declines a touch prompt) is entirely routine |
+| 5 | `Age/AgeEncrypt.cs:170-181` (`EncryptReader`) | anything in `header.WriteTo` / `HkdfDerive` before the `ZeroMemory` at `:179` |
+
+Sites 2, 3 and 4 also reach `EncryptDetached` and `DecryptDetached`, because those methods call
+`BuildHeaderAndFileKey` (`:85`) and `UnwrapFileKey` (`:112`) **outside** their otherwise-correct
+`try` blocks.
+
+**What is wrong** — Per the project's own rule, a value returned across the API is cleared by
+whoever owns it next — the facade — and the facade does clear it on the success path. These are pure
+error-path omissions. Sites 1, 2 and 3 are triggered by attacker-supplied ciphertext.
+
+**Reproduction** — differential probe with a spy identity that forwards to the real
+`X25519Identity` and retains the array it returned; stable across repeated runs.
+
+```
+success: whole file decrypts residue=False (expect False) as expected
+threw AgeHeaderException: expected 16-byte payload nonce, got 4 bytes
+error: payload nonce truncated residue=True (expect True) as expected
+threw AgeHmacException: header MAC verification failed
+error: header MAC verification fails residue=True (expect True) as expected
+```
+
+Direct observation of the retained array on the MAC-failure path: `4B9531B9ED62DC57E6AE4762F093C0A9`
+(the genuine file key) instead of 16 zero bytes; the success-path control observed all zeros.
+On the truncated-nonce path: `4B9531B9ED62DC57E6AE4762F093C0A9`.
+
+Site 4 reproduced separately with a NoGC-region pointer probe and a throwing custom `IRecipient`:
+the live file key leaked from **both** `EncryptReader` (`FE5A4774C4264AAC67A8643E3522B60C`) and
+`EncryptDetached` (`A923DD2DD1915D63F610E0B3826DE318`); the success-path control read all zeros. A
+recipient returning a `null` `Stanza` (`NullReferenceException` at `header.WriteTo`) also leaked
+(`0D6203693EE17BB589A3FECC4B6F5B3C`). Note the author *did* zero on the one guarded path — the
+scrypt-mixing check at `:235-239` — and that path is verifiably clean, so this is partial awareness
+rather than a blind spot.
+
+Sibling control: `AgeRandomAccess.InitializeFromStream` (`Age/AgeRandomAccess.cs:146-162`) has the
+correct `try/finally` and passes the same probe, so the two paths already disagree with each other.
+
+**Severity + who is affected** — **security (defence in depth)**. Sites 1-3 are reachable purely
+from attacker-supplied truncated or tampered ciphertext with no custom types involved.
+
+**API-neutral?** — Yes; pure control flow inside existing method bodies.
+
+**Fix shape** — Two different shapes, and getting them the wrong way round is the easy mistake:
+
+- `DecryptReader` and `EncryptReader` do **not** return the key → `try/finally` is correct. In
+ `DecryptReader`, `fileKey` is declared *inside* the `try` at `:199`, so the declaration must be
+ hoisted (`byte[]? fileKey = null;`) before a `finally` can see it.
+- `UnwrapHeaderFromReader` and `BuildHeaderAndFileKey` **do** return the key → these must be
+ `catch { ZeroMemory(fileKey); throw; }`, not `finally`.
+- Put the guard **inside** `BuildHeaderAndFileKey` (covering the `Wrap` loop), not only in
+ `EncryptReader`'s body, otherwise both encrypt entry points stay leaky.
+
+`next-version`'s equivalents are `Age/Age.cs:281-301` and `Age/Age.Header.cs:79,128`, but those files
+are entangled with the v0.3 facade rewrite — apply the local insertions above instead.
+
+**Confidence** — certain; reproduced independently by three investigators.
+
+---
+
+
+### S10 — `AgeKeygen` leaves the decrypted identity file — private keys in the clear — in memory
+
+**Location** — `Age/AgeKeygen.cs:140-148` (`DecryptIdentityFile`; copies originate at `:143`
+`MemoryStream`, `:146` `ToArray()` + `GetString`) and `:153-161` (`EncryptIdentityFile`; `:155`
+`GetBytes`). `grep -c ZeroMemory Age/AgeKeygen.cs` → 0.
+
+**What is wrong** — `Encoding.UTF8.GetString(output.ToArray())` produces **three** uncleared copies
+of the file's `AGE-SECRET-KEY-1…` / `AGE-SECRET-KEY-PQ-1…` lines: the `MemoryStream`'s internal
+buffer (`Dispose` does not clear it), the separate `ToArray()` array, and an immutable `string` that
+cannot be zeroed at all. `ParseIdentityFile` then splits it, producing one more per-line string.
+`EncryptIdentityFile` is the mirror: `Encoding.UTF8.GetBytes(identityFileText)` is never cleared.
+
+**Reproduction** — round-trip an identity file containing a real `AGE-SECRET-KEY-1…` line through
+`EncryptIdentityFile` / `DecryptIdentityFile` (work factor 4), then sweep; the secret string is
+pinned alive so it is excluded from the sweep.
+
+```
+== AgeKeygen.DecryptIdentityFile / EncryptIdentityFile ==
+ (round-trip recovered 1 identity)
+ AGE-SECRET-KEY-1… as UTF-8 (decrypted file buffer) residue=True (expect True) as expected
+ AGE-SECRET-KEY-1… as UTF-16 (the string itself) residue=True (expect True) as expected
+```
+
+Also confirmed that clearing `output.GetBuffer()` and `plaintextBytes` alone is **not** sufficient —
+the string copies remain.
+
+**Severity + who is affected** — **security (defence in depth)**. Anyone using passphrase-protected
+identity files.
+
+**API-neutral?** — The array clearing is fully neutral (both methods are public but only their
+bodies change). The `string` copies are **irreducible on main** — `ParseIdentityFile(string,
+IPluginCallbacks)` is in `PublicAPI.Shipped.txt:112` and changing it is an API break. `next-version`
+accepts the same limitation and documents it. Backport only the array clearing and document the
+residual.
+
+**Fix shape** — `DecryptIdentityFile`: switch to
+`Encoding.UTF8.GetString(output.GetBuffer(), 0, (int)output.Length)` (avoids the `ToArray` copy) and
+add `finally { CryptographicOperations.ZeroMemory(output.GetBuffer()); }`. `EncryptIdentityFile`:
+`try/finally` with `ZeroMemory(plaintextBytes)`. Shape matches `next-version`
+`Age/Recipients/EncryptedIdentityFile.cs:108-119`.
+
+**Two caveats that make the fix more partial than it looks.** (a) `MemoryStream` reallocates its
+backing array as it grows, so zeroing `GetBuffer()` only reaches the *final* buffer — every
+intermediate growth generation is still abandoned. Pre-size the stream
+(`new MemoryStream(capacity)`) so it never reallocates. (b) `new MemoryStream()` does set
+`publiclyVisible = true` so `GetBuffer()` is legal, but its `.Length` is the **capacity**, not the
+stream length — zero the whole capacity.
+
+**Confidence** — certain by inspection; reproduced.
+
+---
+
+
+### S11 — `Ed25519Converter` leaves the full SHA-512 expansion of the SSH private key on the heap
+
+**Location** — `Age/Crypto/Ed25519Converter.cs:58-65` (`hash`, dropped after `:64`; the file
+contains no zeroing) and `Age/Recipients/SshEd25519Identity.cs:48`
+(`ed25519Private.GetEncoded()` passed inline).
+
+**What is wrong** — `PrivateKeyToX25519` computes `hash = SHA-512(ed25519Seed)` into a 64-byte array,
+copies the first 32 bytes out as the X25519 private key, and drops `hash` uncleared. Bytes 0-32 are
+the X25519 private key that `SshEd25519Identity.Dispose` is careful to zero; bytes 32-64 are the
+Ed25519 nonce prefix, also private key material. So `Dispose` zeroes one copy while a complete
+second copy plus the signing nonce sits in freed memory. Separately, `GetEncoded()` returns a fresh
+BouncyCastle copy of the raw Ed25519 seed, also never cleared, from the moment an `ssh-ed25519`
+identity is parsed.
+
+**Reproduction** — real key from `ssh-keygen -t ed25519`, parsed, encrypt+decrypt round trip,
+identity disposed, then swept with all hook copies pinned:
+
+```
+SHA-512(ed25519 seed), full 64 bytes residue=True
+bytes 32..64 (Ed25519 nonce prefix) alone residue=True
+```
+
+The second needle proves the signing-nonce half survives too, not just the part that overlaps the
+X25519 key. Only one capture occurred (`Parse` calls the converter once), so the hook is not its own
+confounder.
+
+**Severity + who is affected** — **security (defence in depth)**. Every user of `ssh-ed25519`
+identities.
+
+**API-neutral?** — Yes; `Ed25519Converter` is internal and the `Parse` change is method-local.
+
+**Fix shape** — `try/finally` around `PrivateKeyToX25519`'s body with `ZeroMemory(hash)`; in
+`SshEd25519Identity.Parse`, hoist `ed25519Private.GetEncoded()` into a local and zero it in a
+`finally` after the conversion. **Not fixed on `next-version`** —
+`Age/Crypto/Ed25519Converter.cs:47-60` there is byte-identical in this respect, so this must be
+written fresh and applied to both branches.
+
+**Confidence** — certain; reproduced.
+
+---
+
+
+### S12 — `Bech32` leaves the private key in 5-bit form, in a `List` backing array, and in a lowercased string
+
+**Location** — `Age/Crypto/Bech32.cs`. `grep -c ZeroMemory` → 0. Decode side: `data5` `:134`,
+`data5NoCheck` `:148`, `ConvertBits` `:156-192` (its `List` backing array plus every discarded
+generation from capacity doubling), `ret.ToArray()`, `bech.ToLowerInvariant()` `:130`. Encode side:
+`data5` `:86`, `values` in `CreateChecksum` `:67`, the `result` char[] `:89`.
+
+**What is wrong** — `X25519Identity.Parse` (`:66-68`) and `MlKem768X25519Identity.Parse` (`:54-56`)
+are careful to `ZeroMemory` the `byte[]` that `Bech32.Decode` returns — and `Decode` itself leaves
+several uncleared representations of the same secret behind. `data5` is a trivially-invertible 5-bit
+representation of the raw `AGE-SECRET-KEY` / `AGE-SECRET-KEY-PQ` payload. `ConvertBits` uses
+`new List()` with no capacity, so the backing array is reallocated as it grows and every
+superseded generation is dropped uncleared — the copy count is non-deterministic. `Bech32.Encode`
+has the mirror problem, so `X25519Identity.ToSecretString()` leaks on the way out despite zeroing
+`rawCopy`.
+
+**Reproduction** — generated an `X25519Identity`, round-tripped it through
+`ToSecretString()` / `Parse()`, pinned all hook copies, swept:
+
+```
+data5 (5-bit form of AGE-SECRET-KEY-1 payload) residue=True (zeroed control: residue=False)
+```
+
+Single capture, so no self-confounding.
+
+**Severity + who is affected** — **security (defence in depth)**. Anyone parsing or exporting a
+secret key string. The exposure is the same class as S10 and S11: an existing, deliberate protection
+is defeated by an intermediate the protecting code cannot see.
+
+**API-neutral?** — Yes; `Bech32` is `internal static`.
+
+**Fix shape** — Rewrite `Decode`/`Encode` to work in caller-visible buffers cleared in a `finally`:
+replace `ConvertBits`'s `List` with a pre-sized array (the output length is computable), and
+add `try/finally` clearing `data5`, `data5NoCheck` and the `ConvertBits` scratch. The lowercased
+string is irreducible without changing `Decode`'s `string` parameter; leave it and document it.
+**Not fixed on `next-version`** — its `Bech32.cs` has zero `ZeroMemory` calls too, so this must be
+written fresh.
+
+**Confidence** — certain by inspection; the `data5` residue reproduced.
+
+---
+
+
+### C1 — Armor decoder rejects a final 64-char line carrying base64 padding
+
+**Location** — `Age/Format/DearmorStream.cs:103-104` (the throw), `:107-108` (padding validation
+gated on width), `:143-144` (final-line tracking gated on width).
+
+**What is wrong**
+
+```csharp
+if (line.Length == ColumnsPerLine && bytesWritten != MaxDecodedPerLine)
+ throw new AgeArmorException("non-canonical base64 in armor");
+```
+
+The premise — "a 64-character line must decode to a full 48 bytes" — is false. A final chunk of
+**46** bytes base64-encodes to 15 full groups (60 chars) plus `XX==` = exactly 64 characters;
+**47** bytes encodes to 64 characters ending in a single `=`. Both are canonical PEM/age armor. Both
+reference implementations key off the **decoded** length: go-age `armor.go:156` uses
+`if n < format.BytesPerLine`; rust-age `primitives/armor.rs:873` accepts
+`(false, ARMORED_COLUMNS_PER_LINE) => ()` unconditionally and lets base64 decode it. main rejects
+files that **both** references accept.
+
+The same false premise breaks the two neighbouring rules — `ValidateCanonicalPadding` is only called
+for lines shorter than 64, and `_lastLineWasShort` is only set for lines shorter than 64. (Both are
+vacuous today because the throw fires first; they become live once the throw is removed, which is why
+the three edits must land together.)
+
+**Reproduction** — all four campaigns reproduced independently twice on a clean copy of main.
+
+*(1) Size sweep 0..200 across the mod-48 cycle.*
+
+```
+for n in $(seq 0 200); do
+ head -c $n /dev/urandom > pt.bin
+ age -a -r $PUB -o ct.age pt.bin
+ Age.Cli -d -i key.txt -o out.bin ct.age
+done
+```
+
+8 of 201 fail, all with `age: non-canonical base64 in armor`, at plaintext sizes
+**38, 39, 86, 87, 134, 135, 182, 183** — exactly the sizes where the *binary ciphertext* length
+≡ 46 or 47 (mod 48). Verified: n=38 → 238 bytes (mod 48 = 46), n=39 → 239 (47), n=37 → 237 (45,
+passes), n=40 → 240 (0, passes). The offending line, printed:
+`r0Racp5TosWbbCN9E97T31TXN7VEA+b4LgHky4fcmjpzL619tNzt6fDSsG4ZTQ==` — 64 characters.
+
+*(2) Across a chunk boundary,* `seq 65400 65700`: 14 of 301 fail (65414/65415, 65462/65463,
+65510/65511, 65542/65543, 65590/65591, 65638/65639, 65686/65687). The residue shifts by the extra
+16-byte tag of the second chunk, confirming it tracks *ciphertext* length.
+
+*(3) Not X25519-specific.* With two `-r` recipients (header 54 bytes longer) the failing plaintext
+sizes shift to 36, 37, 84, 85.
+
+*(4) main cannot round-trip its own armor.* `Age.Cli -e -a` then `Age.Cli -d` fails at exactly the
+same 8 sizes in 0..200, while `age -d` accepts **all 201** of main's armored outputs. The encoder is
+correct; only the decoder is wrong.
+
+*(5) Worst concrete case — an encrypted key file that cannot be read back.*
+`AgeKeygen.EncryptIdentityFile(text, pass, armor: true)` → `AgeKeygen.DecryptIdentityFile(blob,
+pass)`, sweeping a 0..95-character comment to walk the residue: **4 of 96 fail** (comment pad 27, 28,
+75, 76) with `AgeArmorException: non-canonical base64 in armor`. A v0.2 user's armored encrypted
+identity file is unreadable by v0.2 roughly 4.2% of the time. `age` reads it fine.
+
+**Failure rate: 2 of every 48 ciphertext lengths = 4.17% of arbitrary files.**
+
+*Fix verified end to end.* Applied the patch below to a copy of main, rebuilt with
+`-p:TreatWarningsAsErrors=true` (0 warnings), re-ran sweeps 0..200 and 65400..65700 → 0 failures and
+0 content mismatches; identity-file sweep → 0 failures; full suite green (431 `Age.Tests` + 143 CCTV
+vectors). No existing test asserted the old behaviour. Strictness preserved: a 64-char padded line
+mutated to have non-zero trailing bits is still rejected, and a padded line followed by another body
+line still errors `short line in armor body is not the last line`. A 400-case structural mutation
+fuzz (char substitution, line truncation/extension/duplication/deletion, stray `=`) over base files
+at sizes 0/38/39/40/100 produced **zero** accept/reject disagreements with `age` — the fix opens no
+acceptance hole and over-rejects nothing.
+
+**Severity + who is affected** — **correctness**, but with the highest *user* impact in this survey:
+about 4% of arbitrary armored age files, and about 4% of AgeSharp's own armored encrypted identity
+files, are unreadable by v0.2.
+
+**API-neutral?** — Yes; `DearmorStream` is internal, `PublicAPI.Shipped.txt` untouched.
+
+**Fix shape** — three edits, all inside `DearmorStream.cs`:
+
+1. Delete the width-based rejection at `:103-104`.
+2. `:107` — gate canonical-padding validation on the presence of padding, not width:
+ `if (line.Contains('=')) ValidateCanonicalPadding(line.AsSpan());` — must run **after**
+ `Convert.TryFromBase64Chars`, as it does today, so malformed lines fail decode first.
+3. `:143` — a padded line ends the body regardless of width:
+ `if (line.Length < ColumnsPerLine || line[^1] == '=') _lastLineWasShort = true;`
+
+Equivalently: track the final line by decoded byte count (`bytesWritten < MaxDecodedPerLine`), which
+is exactly go-age's rule. `next-version` has the same fix in a renamed file
+(`Age/Format/ArmorDecoder.cs`, `DecodeBodyLine:72` and `ValidateBodyLine:94`), and that part of its
+change is not entangled with the API redesign — it ports verbatim onto main's file.
+
+**Confidence** — certain; reproduced and the fix verified.
+
+---
+
+
+### C2 — The armor path disposes the caller's stream
+
+**Location** — root cause `Age/Format/NewlineBoundedStream.cs:61-67` (`Dispose` calls
+`inner.Dispose()`); wrapper chain `Age/Format/AsciiArmor.cs:48-50` (`StreamReader(..., leaveOpen:
+false)`); `Age/Format/DearmorStream.cs:189-195`. Consumers: `Age/AgeEncrypt.cs:281-282`
+(`DeArmorIfNeeded` returns `needsDispose = true`) with `Age/Crypto/DecryptStream.cs:157`
+(`if (ownsStream) ciphertext.Dispose()`), `Age/AgeHeader.cs:81-82` (`finally`),
+`Age/AgeRandomAccess.cs:223` (`using var dearmored`).
+
+**What is wrong** — Disposing the returned `DearmorStream` cascades all the way down to the caller's
+ciphertext stream. So the caller's stream is closed for armored input and left open for binary
+input. This contradicts `CLAUDE.md`'s own rule ("the library never disposes a caller's stream") and
+the `AgeRandomAccess` constructor's own XML doc ("The caller retains ownership of the stream.").
+`AgeHeader.Parse` is documented as merely leaving the stream "positioned wherever header reading
+stopped" — it actually closes it.
+
+**Reproduction** — `Tracked : MemoryStream` counting `Dispose(true)`; encrypt 100 bytes twice, once
+armored, once binary:
+
+```
+Decrypt binary callerStreamDisposed=False
+Decrypt armored callerStreamDisposed=True
+DecryptReader binary callerStreamDisposed=False
+DecryptReader armored callerStreamDisposed=True
+AgeHeader.Parse binary callerStreamDisposed=False
+AgeHeader.Parse armored callerStreamDisposed=True
+RandomAccess binary callerStreamDisposed=False
+RandomAccess armored callerStreamDisposed=True
+```
+
+User-visible consequence, reproduced:
+
+```csharp
+var s = new Tracked(armoredBytes);
+AgeEncrypt.Decrypt(s, o1, id);
+s.Position = 0;
+AgeEncrypt.Decrypt(s, o2, id);
+// -> ObjectDisposedException: Cannot access a closed Stream.
+```
+
+The identical binary-input sequence succeeds.
+
+**Verified clean on the encrypt side:** `ArmorStream.Dispose` (`:119-124`) disposes `_source`, but
+that source is the library-created `EncryptStream`, whose `Dispose`
+(`Age/Crypto/EncryptStream.cs:151-165`) never touches the caller's plaintext. Armored encryption does
+not leak ownership. Also confirmed no leak is introduced on the `AgeRandomAccess` path: its `Dispose`
+(`:126-133`) disposes only the library-created `_armoredBinaryInput` `MemoryStream`.
+
+**Severity + who is affected** — **correctness**. Any library caller passing a long-lived
+`FileStream`/`MemoryStream` and armored input. `Age.Cli` is unaffected in practice.
+
+**API-neutral?** — Yes; `NewlineBoundedStream`, `AsciiArmor` and `DearmorStream` are all internal,
+and `NewlineBoundedStream` is constructed at exactly one site (`AsciiArmor.cs:48`).
+
+**Fix shape** — Give `NewlineBoundedStream` an `ownsInner`/`leaveOpen` ctor parameter (default
+`false` to keep any other internal caller working) and pass `leaveOpen: true` from
+`AsciiArmor.Dearmor`. Keep the `StreamReader` at `leaveOpen: false` so it still disposes the bounded
+wrapper — passing `leaveOpen: true` to *both* is redundant. The existing `needsDispose`/`ownsStream`
+plumbing then disposes only library-created objects. Verified: applied, rebuilt with
+`TreatWarningsAsErrors` (0 warnings), probe reports `callerDisposed=False` on all eight rows,
+armored reuse succeeds, suite stays green (431 + 143). `next-version` fixed this structurally by
+rewriting the dearmor path (its `DearmorStream` has no `Dispose` override, `PeekableStream.cs:3`
+comments "Never disposes inner") — that rewrite is entangled with the v0.3 API and must **not** be
+backported.
+
+**Note for the release notes:** this is a behaviour change in a patch release — it stops closing a
+stream some callers may have come to rely on being closed. It restores the documented contract, so
+it is a fix rather than a regression, but it should be called out.
+
+**Confidence** — certain; reproduced independently by two investigators.
+
+---
+
+
+### C3 — Culture-sensitive `StartsWith` mis-frames header lines
+
+**Location** — `Age/Format/Header.cs:33`, `:38`, `:56`; `Age/Format/Stanza.cs:96`. Same
+one-argument-overload pattern also at `Age/Plugin/PluginConnection.cs:95`,
+`Age/AgeKeygen.cs:103-104,124-128`, `Age/Recipients/PluginIdentity.cs:165`,
+`Age/Recipients/PluginRecipient.cs:155`, `Age.Cli/AgeCommand.cs:146,153`,
+`Age.Cli/KeygenCommand.cs:88,94`.
+
+**What is wrong** — `Header.Parse` decides what a header line *is* with `line.StartsWith("-> ")` /
+`StartsWith("---")` / `StartsWith("--- ")`. The one-argument `string.StartsWith(string)` overload is
+`StringComparison.CurrentCulture`, not `Ordinal`. Under ICU collation the C0 control characters and
+DEL are completely ignorable, and `HeaderReader.ValidateByte` (`HeaderReader.cs:82-91`) rejects only
+CR and bytes > 0x7F — so 0x00-0x08, 0x0B, 0x0C, 0x0E-0x1F and 0x7F reach the comparison. A line whose
+raw bytes are `2D 01 01 3E 20 …` (`-\x01\x01> foo bar`) therefore satisfies `StartsWith("-> ")` and
+is parsed as a stanza with type `>` and args `["foo","bar"]`; the charset validation never sees the
+control bytes because `line[3..]` slices past them.
+
+Two consequences: (1) main accepts and fully decrypts files that `age` v1.3.1 and rage reject as
+malformed — Go compares raw byte prefixes (`internal/format/format.go:172`,
+`bytes.HasPrefix(line, stanzaPrefix)`) — i.e. main is more permissive than the spec ABNF
+`arg-line = "-> " argument *(SP argument) LF`; (2) because `CurrentCulture` collapses to `Ordinal`
+in globalization-invariant mode, **the same library gives different answers depending on the
+consuming app's build**. `Age.Cli.csproj:13-16` sets `true`
+inside a `Condition="'$(PublishAot)' == 'true'"` group, so the AOT binary that `make` produces and
+the shipped NuGet library disagree about whether a given file is valid.
+
+**Reproduction** — files built via main's own public API (real `X25519Recipient.Wrap` stanza, correct
+HKDF+HMAC header MAC, real payload) with an extra pseudo-stanza line before the real one:
+
+```
+$ age -d -i id.txt ctrl_stanza.age
+age: error: failed to read header: failed to parse header:
+ malformed stanza opening line: "-\x01\x01> foo bar\n"
+
+$ Age.Cli -d -i id.txt ctrl_stanza.age
+hello age
+
+$ DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1 Age.Cli -d -i id.txt ctrl_stanza.age
+age: unexpected line in header: -\x01\x01> foo bar
+```
+
+Reproduced for `\x00`, `\x01` and `\x7f`. Step 3 is also proof the fix is sufficient: invariant mode
+makes `StartsWith` ordinal and the file is then rejected with the same verdict as `age`.
+
+Primitive confirmation (.NET 10, en-DE):
+
+```
+"\x01-> X25519 abc".StartsWith("-> ") => True
+"\x01-> X25519 abc".StartsWith("-> ", StringComparison.Ordinal) => False
+"-\x01\x01> foo bar".StartsWith("-> ") => True (ordinal: False)
+"\x01--- MAC".StartsWith("---") => True (ordinal: False)
+```
+
+*Refinement, so nobody over-claims:* "strictly more permissive" is slightly too strong — a leading
+control byte (`"\x01-> foo bar"`) is still rejected by main, though with a different error. The
+mis-framing bites when the control bytes sit *inside* the prefix.
+
+**Severity + who is affected** — **correctness / conformance and determinism**. Not an
+authentication break: the header MAC covers the raw bytes and is verified after unwrap, so an
+attacker without the file key cannot inject a line. It matters wherever a Go-based validator fronts
+a .NET-based consumer, and wherever the AOT and NuGet builds are expected to agree.
+
+**API-neutral?** — Yes; internal parsing code only.
+
+**Fix shape** — Add `StringComparison.Ordinal` to every prefix/suffix comparison on parsed wire
+data, starting with `Header.cs:33,38,56` and `Stanza.cs:96`. Enabling CA1307/CA1310 in the build
+would prevent regressions. Optional belt-and-braces: extend `HeaderReader.ValidateByte` to reject
+bytes < 0x20 other than LF, and 0x7F — no legitimate age header contains them. **Not fixed on
+`next-version`** (its `Header.cs:29/34/52` have the identical culture-sensitive calls; and its
+`HeaderReader.PrefillAsync` *does* use `Ordinal` for its `---` check, which makes its own sync and
+async paths disagree). Apply to both branches.
+
+**Confidence** — certain; reproduced.
+
+---
+
+
+### C4 — A plugin returning more than one recipient-stanza has all but the last silently discarded
+
+**Location** — `Age/Recipients/PluginRecipient.cs:61` — `result = ParseRecipientStanza(args, body);`
+is an **assignment** inside the read loop, not an append. Dispatch site
+`Age/AgeEncrypt.cs:230` (`header.Stanzas.Add(recipient.Wrap(fileKey));`).
+
+**What is wrong** — The spec's own `recipient-v1` example shows a plugin emitting two
+recipient-stanzas for FILE_INDEX 0, and go-age does `stanzas = append(stanzas, …)`
+(`references/go-age/plugin/client.go:128`). main keeps only the last stanza and reports success.
+
+**Reproduction — end to end, including permanent data loss.**
+
+*(a) Observation.* Fake `age-plugin-multi` emits `-> recipient-stanza 0 multi-a` then
+`-> recipient-stanza 0 multi-b`.
+
+```
+AgeSharp PluginRecipient.Wrap returns: STANZA type=multi-b ... (multi-a gone)
+ protocol log shows main sent `ok` to both
+age v1.3.1 produces a header with BOTH:
+ -> multi-a
+ QUFBQS70sg6WjNTEGAGGkwhrwOQ
+ -> multi-b
+ QkJCQi70sg6WjNTEGAGGkwhrwOQ
+```
+
+*(b) Data loss.* Fake `age-plugin-split` XOR-splits the file key across two stanzas of the same
+FILE_INDEX (both needed to recover).
+
+```
+age encrypt then age decrypt : round-trips, prints "hello"
+AgeSharp encrypt : exit 0, "ENCRYPTED", header contains ONLY `-> split-b`
+age -d on that AgeSharp file : age: error: no identity matched any of the recipients (exit 1)
+AgeSharp cannot decrypt it either.
+```
+
+The plaintext is gone and nothing warned the user.
+
+**Severity + who is affected** — **correctness**, with silent permanent data loss. Anyone using a
+plugin that emits multiple stanzas: share splitting, group/multi-slot recipients, a key stanza plus a
+metadata stanza.
+
+**API-neutral?** — Yes, in the form below. `recipient.Wrap(` has exactly **one** call site in the
+library (`Age/AgeEncrypt.cs:230`), which is what makes the internal-interface approach work.
+
+**Fix shape** — Two options; ship at least (2).
+
+1. Add `internal interface IMultiStanzaRecipient { IReadOnlyList WrapAll(ReadOnlySpan
+ fileKey); }`, implement it on `PluginRecipient` with the accumulating loop, and in
+ `BuildHeaderAndFileKey` do
+ `if (recipient is IMultiStanzaRecipient m) header.Stanzas.AddRange(m.WrapAll(fileKey)); else header.Stanzas.Add(recipient.Wrap(fileKey));`.
+ Internal types need no `PublicAPI.Unshipped.txt` entry, so the public surface is unchanged.
+2. Keep public `Wrap` working by having it throw `AgePluginException` when the plugin produced more
+ than one stanza — this converts silent permanent data loss into a clean failure, which is the
+ safety-critical half.
+
+`next-version` fixed this by changing `IRecipient.Wrap` to return `IReadOnlyList`
+(commit `fd16800`) — that is an API break and must **not** be backported.
+
+**Confidence** — certain; reproduced end to end.
+
+---
+
+
+### C5 — identity-v1 sends a distinct FILE_INDEX per stanza
+
+**Location** — `Age/Recipients/PluginIdentity.cs:52` —
+`string[] args = [i.ToString(), s.Type, .. s.Args];`
+
+**What is wrong** — FILE_INDEX identifies the **file**, not the stanza. The spec: "Duplicate file
+indices indicate stanzas that are from the same file header, and wrap the same file key." main is
+decrypting one file, so every stanza must carry index `0`. go-age does exactly that:
+`Args: append([]string{"0", rs.Type}, rs.Args...)` (`references/go-age/plugin/client.go:247`).
+Consequences: (a) a plugin needing several stanzas of one file can never reassemble them; (b) the
+spec rule "if any known stanza is structurally invalid … MUST NOT unwrap any stanzas with the same
+FILE_INDEX" is defeated; (c) the plugin is asked to unwrap N phantom files and may legitimately reply
+with N `file-key`s, which main overwrites one over another.
+
+**Reproduction**
+
+*(a) Observation.* Decrypting a 2-stanza header, AgeSharp's wire trace vs `age`'s:
+
+```
+AgeSharp: -> recipient-stanza 0 multi-a age: -> recipient-stanza 0 multi-a
+ -> recipient-stanza 1 multi-b -> recipient-stanza 0 multi-b
+```
+
+*(b) Interop failure.* With `age-plugin-split` (file key XOR-split across two stanzas of one file):
+
+```
+$ printf hello | age -r age1split1qypqxpq4ga9k9 -o split-go.age # header: -> split-a / -> split-b
+$ age -d -i <(echo AGE-PLUGIN-SPLIT-1QYPQX4GPKCL) split-go.age
+hello (exit 0)
+
+AgeSharp main on the SAME file:
+ Age.NoIdentityMatchException: no identity matched any recipient stanza
+ at AgeEncrypt.UnwrapHeaderFromReader, Age/AgeEncrypt.cs:270
+plugin-side log: "cannot recover file 0 have ['split-a']" / "cannot recover file 1 have ['split-b']"
+```
+
+**Severity + who is affected** — **correctness**. main cannot decrypt files the reference client
+decrypts. Anyone using a multi-stanza plugin identity.
+
+**API-neutral?** — Yes; one-token internal change.
+
+**Fix shape** — `string[] args = ["0", s.Type, .. s.Args];`. `Age.Tests/PluginTests.cs:542-543`
+asserts the buggy wire form (`-> recipient-stanza 0 X25519 a1` / `-> recipient-stanza 1 scrypt a2
+18`) and must be updated. **Not fixed on `next-version`** — its `PluginIdentity.cs:75` still has
+`i.ToString()`. Apply to both branches.
+
+**Confidence** — certain; reproduced.
+
+---
+
+
+### C6 — Plugin stderr is redirected but never drained: deadlock past 64 KiB
+
+**Location** — `Age/Plugin/PluginConnection.cs:30` (`RedirectStandardError = true`). No reader for
+`_process.StandardError` exists anywhere in the assembly — no `BeginErrorReadLine`, no
+`ErrorDataReceived`.
+
+**What is wrong** — The redirect creates a pipe nothing ever reads. Once the plugin writes past the
+OS pipe buffer, its `write(2)` blocks; meanwhile the client is blocked in `TextReader.ReadLine()` on
+stdout. Neither side can progress and there is no timeout — the call hangs indefinitely. go-age
+leaves `cmd.Stderr` unset (discarded) unless `AGEDEBUG=plugin`, so it never deadlocks.
+
+**Reproduction** — exact threshold, measured against a Release build with fake `age-plugin-noisy`
+writing `NOISE_BYTES` to stderr before answering phase 2:
+
+```
+stderr= 16384 bytes: ok in 0.6s
+stderr= 32768 bytes: ok
+stderr= 65536 bytes: ok in 0.1s
+stderr= 65537 bytes: HUNG (killed after 10s)
+stderr= 131072 bytes: HUNG (killed after 10s)
+```
+
+65536 is the macOS/Linux default pipe capacity. Referee at 131072 bytes:
+`printf hello | age -r age1noisy1qypqxpqxeplwt -o out.age` completes in 0.067 s, exit 0.
+
+**Severity + who is affected** — **correctness** (a hard hang, no timeout). Any plugin built with
+verbose/debug logging, or one retrying a hardware token in a loop.
+
+**API-neutral?** — Yes; `PluginConnection` is internal, `ProcessStartInfo` config only.
+
+**Fix shape** — Option (b) is strictly better. (a) Set `RedirectStandardError = false` so the plugin
+inherits the client's stderr — one line, but louder than go-age (which discards unless
+`AGEDEBUG=plugin`). (b) Keep the redirect and drain it off-thread: subscribe to
+`ErrorDataReceived`, call `BeginErrorReadLine()` right after `Process.Start`, keep the last few KiB
+in a bounded buffer, and append it to the `AgePluginException` message on failure — which is what the
+spec asks for on encryption failure and which main currently cannot do at all. **Present on
+`next-version` too** (identical `RedirectStandardError = true` with no reader).
+
+**Confidence** — certain; reproduced with an exact threshold.
+
+---
+
+
+### C7 — Raw `FormatException` / `ArgumentException` escape the plugin path
+
+**Location** — two sites, same class, adjacent fix:
+
+- `Age/Plugin/PluginConnection.cs:123` — `bodyChunks.Add(Base64Unpadded.Decode(bodyLine));`,
+ unguarded.
+- `Age/Plugin/PluginConnection.cs:88-107` (`ReadStanza`) does not run `ValidateStanzaString` over
+ the parsed type and args, unlike `Stanza.Parse` (`Stanza.cs:104-111`);
+ `Age/Recipients/PluginRecipient.cs:86` then feeds those raw strings to `new Stanza(...)`, whose
+ `EnsureValidStanzaString` throws `ArgumentException` (`Stanza.cs:169-181`).
+
+**What is wrong** — `PluginRecipient.Wrap` (`:29`) and `PluginIdentity.Unwrap` (`:24`) are both
+documented ``, and `CLAUDE.md` states that a raw BCL or
+BouncyCastle exception reaching a caller is a bug. The sibling `DecodeOptionLabel`
+(`PluginRecipient.cs:136-146`) already wraps `FormatException` correctly, so this is an inconsistency
+inside the same feature.
+
+**Reproduction**
+
+*Base64 body.* Fake `age-plugin-bad` answers phase 2 with `-> recipient-stanza 0 bad` then
+`!!!!not base64!!!!`:
+
+```
+System.FormatException: invalid base64 input
+ at Age.Crypto.Base64Unpadded.DecodeWithPadding(...) Base64Unpadded.cs:50
+ at Age.Crypto.Base64Unpadded.Decode(...) Base64Unpadded.cs:31
+ at Age.Plugin.PluginConnection.ReadBody() PluginConnection.cs:123
+ at Age.Plugin.PluginConnection.ReadStanza() PluginConnection.cs:103
+No AgeException anywhere in the chain.
+```
+
+*Stanza charset.* Calling the public `new PluginRecipient(recip).Wrap(...)` against stub plugins:
+
+```
+plugin emits `-> recipient-stanza 0 X25519 extra` (double space -> empty arg)
+ -> System.ArgumentException: stanza type/argument cannot be empty (Parameter 'args') isAgeException=False
+plugin emits `-> recipient-stanza 0 X25\x01519 aaa`
+ -> System.ArgumentException: invalid character in stanza type/argument: 0x01 (Parameter 'type') isAgeException=False
+```
+
+Note the empty-arg case needs no exotic bytes at all — two consecutive spaces suffice.
+
+**Severity + who is affected** — **correctness** (exception-contract violation). Reachable only via
+a misbehaving or hostile local plugin binary. A caller doing the documented `catch (AgeException)`
+crashes instead.
+
+**API-neutral?** — Yes; `AgePluginException` already exists in `PublicAPI.Shipped.txt`.
+
+**Fix shape** — Do both centrally in `PluginConnection`: wrap the `Base64Unpadded.Decode` call in
+`ReadBody` in `try/catch (FormatException ex)` → `new AgePluginException($"plugin sent an invalid
+stanza body: {ex.Message}", ex)` (the same guard should cover padded and non-canonical bodies —
+`Base64Unpadded.Decode` throws `FormatException` at `:29` and `:68` too), and validate the parsed
+type and each arg in `ReadStanza` the way `Stanza.Parse` does, throwing `AgePluginException`.
+Validating in `ReadStanza` also covers `PluginIdentity`'s consumption of the same unvalidated
+strings. **Present on `next-version` too.**
+
+**Confidence** — certain; both halves reproduced.
+
+---
+
+
+### C8 — `SshEd25519Identity.Unwrap` has no low-order guard
+
+**Location** — `Age/Recipients/SshEd25519Identity.cs:103` (the reachable site); second unguarded
+site at `:116`.
+
+**What is wrong** — The first X25519 agreement (identity secret × attacker-supplied ephemeral share
+from the stanza) runs with neither the `try/catch` nor the all-zero check that
+`X25519Identity.Unwrap:135` has. BouncyCastle throws `System.InvalidOperationException("X25519
+agreement failed")` for any low-order/identity ephemeral share, and nothing converts it, so it
+propagates out of `AgeEncrypt.Decrypt` / `DecryptReader` / `DecryptDetached`.
+
+**Full site inventory** (enumerated by grepping for `CalculateAgreement`, not by expectation — 8
+calls across 5 files):
+
+| # | site | guard? |
+|---|---|---|
+| 1 | `X25519Identity.cs:135` | **present** |
+| 2 | `X25519Recipient.cs:63` | **present** |
+| 3 | `XWing.cs:94` (Decaps) | **present** |
+| 4 | `XWing.cs:58` (Encaps) | missing — reachable, see [C9](#c9) |
+| 5 | `SshEd25519Identity.cs:103` | missing — reachable, **this finding** |
+| 6 | `SshEd25519Identity.cs:116` | missing — not reachable |
+| 7 | `SshEd25519Recipient.cs:58` | missing — not reachable, see [C10](#c10) |
+| 8 | `SshEd25519Recipient.cs:71` | missing — not reachable |
+
+**Reproduction**
+
+```
+1. ssh-keygen -t ed25519 -N '' -f keys/id_ed25519
+2. AgeEncrypt.Encrypt(..., SshEd25519Recipient.Parse(pubLine))
+3. Replace the 2nd argument of `-> ssh-ed25519 ` with unpadded base64 of a
+ low-order point (leave the tag intact so the identity does not early-return null)
+4. AgeEncrypt.Decrypt(tampered, out, identity)
+
+Observed for all 7 points tried (32 zero bytes; u=1; e0eb7a7c..b800; 5f9c95bc..11d7; p-1; p; p+1):
+ System.InvalidOperationException: X25519 agreement failed <-- not an AgeException
+
+Identical tamper on an X25519 stanza gives, correctly:
+ Age.AgeHeaderException: X25519 shared secret is all-zero (low-order or identity point)
+
+main's CLI, same file:
+ $ Age.Cli -d -i keys/id_ed25519 bad0.age
+ age: internal error: X25519 agreement failed
+ This is a bug. Please report it at https://github.com/pscheid92/AgeSharp/issues
+
+Referee, same file:
+ $ age -d -i keys/id_ed25519 bad0.age
+ age: error: invalid X25519 recipient: crypto/ecdh: bad X25519 remote ECDH input: low order point
+```
+
+**Severity + who is affected** — **correctness, not security.** No zero shared secret is ever used:
+BouncyCastle rejects the agreement, so `docs/spec/age.md:294`'s MUST is satisfied. What breaks is the
+library's exception contract — a caller that catches `AgeException` to handle hostile files gets an
+unhandled exception, and main's CLI reports a merely-malformed input file as a library bug. Anyone
+decrypting untrusted files with an `ssh-ed25519` identity.
+
+**API-neutral?** — Yes; the method body and `CryptoHelper` are both implementation detail. Note the
+thrown type changes from `InvalidOperationException` to `AgeHeaderException`.
+
+**Fix shape** — Add an internal `CryptoHelper.X25519Agree(priv, pub) -> byte[]` holding the
+`try/catch` + all-zero check and route all 8 call sites through it. Site `:116` cannot yield zero if
+`:103` succeeded (the clamped scalar puts the raw secret in the prime-order subgroup — Go discards
+the second error with `_` for the same reason at `agessh.go:222,334`), but routing both costs
+nothing. `next-version` does exactly this in `cffc59e`, extended in `df99387`; there the helper
+throws `AgeFormatException`, which on main's hierarchy is `AgeHeaderException`.
+
+**Confidence** — certain; reproduced independently by two investigators.
+
+---
+
+
+### C9 — `XWing.Encaps` leaks raw BCL exceptions out of the public `Encrypt`
+
+**Location** — `Age/Crypto/XWing.cs:45` (`MLKemPublicKeyParameters.FromEncoding`) and `:58`
+(`agreement.CalculateAgreement`), both unguarded; reached from
+`Age/Recipients/MlKem768X25519Recipient.cs:56`.
+
+**What is wrong** — `XWing.Decaps` (`:89-105`) guards its agreement; `Encaps` does not — the
+asymmetry is within the same file. And `MlKem768X25519Recipient.Parse` validates only HRP, the
+1216-byte length and lowercase-ness, so a hostile `age1pq1…` string flows straight into both calls.
+A malformed ML-KEM half surfaces as `System.ArgumentException`; a low-order X25519 half surfaces as
+`System.InvalidOperationException` — both out of the public `AgeEncrypt.Encrypt`. This is the
+**encrypt** side, so the untrusted input is a recipient string or recipients file: exactly the thing
+a user copies from a website or a colleague.
+
+**Reproduction** — through main's real public facade:
+
+```
+Encrypt(bad ek): System.ArgumentException isAgeException=False
+ msg: Input validation: Modulus check failed for ml-kem encapsulation
+Encrypt(zero pkX): System.InvalidOperationException isAgeException=False
+ msg: X25519 agreement failed
+
+CLI:
+ $ Age.Cli -r "$(cat hostilepq_all-zero.txt)" -o /dev/null p.txt
+ age: internal error: X25519 agreement failed
+ This is a bug. Please report it at https://github.com/pscheid92/AgeSharp/issues
+
+Referee:
+ $ age -r "$(cat hostilepq_all-zero.txt)" -o /dev/null < p.txt
+ age: error: failed to wrap key for recipient #0: failed to set up HPKE sender:
+ crypto/ecdh: bad X25519 remote ECDH input: low order point (exit 1)
+```
+
+Reproduced for all-zero, u=1, and order-8 points; `Parse` accepted all of them.
+
+**Do not let this get restated as a missing low-order check.** It is not. All 12 canonical bad
+X25519 public keys were checked against `Wrap`: the 7 canonical ones (0, 1, both order-8 points,
+p-1, p, p+1) are rejected by BouncyCastle; the 5 non-canonical ones (value + p, high bit set) are
+accepted and produce a non-zero secret — and `age` v1.3.1 accepts those exact same 5 (verified,
+exit 0). The only defect here is the **exception type**.
+
+**Severity + who is affected** — **correctness**. Anyone encrypting to a PQ recipient string from an
+untrusted source. One nuance: `ArgumentException` is already a documented exception on this method
+(for the empty-recipients case), so a caller is not guaranteed to be catching only `AgeException`;
+`InvalidOperationException` is wholly undocumented. Both violate `CLAUDE.md`'s explicit rule.
+
+**API-neutral?** — Yes; `XWing` is `internal static`.
+
+**Fix shape** — Local `try/catch` at `XWing.cs:45` and `:58` rethrowing as `AgeException` /
+`AgeHeaderException`, mirroring `X25519Recipient.cs:61-72`. Falls out for free if the shared
+`CryptoHelper.X25519Agree` helper from [C8](#c8) is introduced (that covers `:58`; `:45` still needs
+its own catch). `next-version` fixed the agreement half in `df99387`.
+
+**Confidence** — certain; reproduced.
+
+---
+
+
+### C10 — `SshKeyParser` throws `ArgumentException` where `FormatException` is documented
+
+**Location** — `Age/Crypto/SshKeyParser.cs:38` (the reachable half).
+Latent companion: `Age/Recipients/SshEd25519Recipient.cs:58` and `:71` have no agreement guard
+(symmetric with [C8](#c8), on the encrypt side).
+
+**What is wrong** — `SshEd25519Recipient.Parse` is documented ``
+but BouncyCastle's `OpenSshPublicKeyUtilities.ParsePublicKey` throws `System.ArgumentException`, and
+nothing converts it. main's CLI only catches `AgeException or FormatException`
+(`Age.Cli/Program.cs:80`), so a malformed recipients file is reported as a library bug.
+
+**Reproduction** — hand-built `ssh-ed25519` authorized_keys blobs carrying Ed25519 points y=1
+(identity, order 1), y=0 (order 4), y=-1 (order 2), an order-8 point, and y=2 (off-curve):
+
+```
+All five rejected before ever reaching Wrap:
+ System.ArgumentException: invalid public key (FormatException=False, AgeException=False)
+ from BouncyCastle inside SshKeyParser.ParsePublicKey, Age/Crypto/SshKeyParser.cs:38
+
+CLI:
+ $ Age.Cli -R hostile_y=2.pub -o /dev/null p.txt
+ age: internal error: invalid public key
+ This is a bug. Please report it at https://github.com/pscheid92/AgeSharp/issues
+
+Referee, same five lines:
+ y=1, y=0, y=-1: age: error: failed to wrap key for recipient #0:
+ crypto/ecdh: bad X25519 remote ECDH input: low order point
+ (Go ACCEPTS the key and fails cleanly at the agreement)
+ y=2: age: warning: ... ignoring unsupported SSH key ... / no recipients found
+```
+
+Because BouncyCastle rejects all of these at parse, the unguarded agreements at
+`SshEd25519Recipient.cs:58` and `:71` are **latent, not exploitable**. They are reported here only
+because they are the same one-line omission and because a future caller of the internal ctor, or a
+BouncyCastle behaviour change, would turn them into [C8](#c8)'s twin. main being stricter than `age`
+at parse time is fine per spec.
+
+**Severity + who is affected** — **correctness** (exception-contract violation, reachable from a
+malformed recipients file). The latent-agreement half alone would be hygiene.
+
+**API-neutral?** — In signature terms yes; note it changes the observable exception type on the
+public `SshEd25519Recipient.Parse` / `AgeKeygen.ParseSshRecipient` from `ArgumentException` to
+`FormatException`. No `PublicAPI.*.txt` change, and it moves behaviour toward the documented
+contract, but flag it in the patch notes — a caller currently catching `ArgumentException` would be
+affected.
+
+**Fix shape** — Two independent one-liners. (a) Route `SshEd25519Recipient.cs:58` (and `:71`) through
+the shared guard from [C8](#c8) — `next-version`'s `cffc59e` covers both. (b) In
+`SshKeyParser.ParsePublicKey`, wrap the BouncyCastle call so `ArgumentException` becomes
+`throw new FormatException("invalid SSH public key", ex)` — wrapping rather than replacing preserves
+the inner exception.
+
+**Confidence** — certain; both halves reproduced.
+
+---
+
+
+### C11 — Seeking relative to the end never verifies the final chunk (spec MUST)
+
+**Location** — `Age/Crypto/RandomAccessDecryptStream.cs:6` (`_length`), `:11` (`Length`), `:43`
+(`SeekOrigin.End`); root cause `Age/AgeRandomAccess.cs:156`.
+
+**What is wrong** — `docs/spec/age.md:165-167`, verbatim: "Seeking relatively to the end of file MUST
+first decrypt and verify that the last chunk is a valid final chunk." main's seekable path never
+does. `Length` and `Seek(0, SeekOrigin.End)` are pure layout arithmetic over an unauthenticated byte
+count.
+
+**Reproduction** — encrypt 196608 bytes (3 full chunks), drop the last 5 ciphertext bytes:
+
+```
+age -d on truncated file: ok=False
+AgeRandomAccess ctor OK. PlaintextLength=196603 (real 196608)
+Stream.Length=196603 Seek(0,End)=196603 -> no error raised
+ReadAt(0,100) = 100 bytes, no error
+```
+
+Honest caveat, confirmed: a **full** read of this file *does* eventually throw
+`AgePayloadException`, so no short plaintext is silently returned in this particular case. The defect
+is that `Length`, `Position` and `Seek`-from-End are silently wrong. That is why this is ranked
+correctness and not security, unlike [S2](#s2)/[S3](#s3).
+
+**Severity + who is affected** — **correctness** (spec MUST violation). `AgeRandomAccess` library
+callers.
+
+**API-neutral?** — Yes; subsumed by the [S2](#s2) fix.
+
+**Fix shape** — After eager final-chunk verification in `InitializeFromStream`, `PlaintextLength` and
+therefore `Length`/`Seek(End)` become authenticated values, and the constructor throws
+`AgePayloadException` on a truncated file.
+
+**Confidence** — certain; reproduced.
+
+---
+
+
+### C12 — `Read()` after `Dispose()` returns another renter's memory
+
+**Location** — `Age/Crypto/DecryptStream.cs:42` (`Read` has no disposal check).
+**Explicitly NOT `EncryptStream`** — see below.
+
+**What is wrong** — After `Dispose`, `_plaintextBuffer` and `_ciphertextBuffer` belong to
+`ArrayPool` again and may already be owned by someone else, yet `Read` continues to copy out of
+`_plaintextBuffer` (`:49-57`) without touching the cipher.
+
+**Reproduction**
+
+```csharp
+var s = AgeEncrypt.DecryptReader(new MemoryStream(ct), id); // 200000-byte plaintext
+s.Read(buf); // buffers a chunk
+s.Dispose(); // buffers zeroed and handed back to the pool
+s.Read(buf) // -> returns 100, NO ObjectDisposedException
+```
+
+Taken further: after disposing, letting an unrelated renter fill the recycled array with `0xAB` and
+calling `Read` again **returned 100 bytes of that renter's `0xAB` data as though it were plaintext**.
+So this is not merely a missing exception.
+
+Two bounds worth stating rather than hiding:
+
+- The window is bounded. Once the residual buffered plaintext is drained, the next chunk decryption
+ hits the disposed `IAeadCipher` and throws `ObjectDisposedException`, so at most one 64 KiB
+ buffer's worth of foreign data can be returned.
+- `EncryptStream.Read` after `Dispose` **throws `ObjectDisposedException` immediately** (verified
+ empirically), because it must encrypt a new chunk and hits the disposed cipher. It is incidentally
+ protected — by the disposed cipher, not by a real guard — and is not currently a use-after-return.
+ It should still get the guard for robustness.
+
+**Severity + who is affected** — **correctness**. Requires API misuse (`Read` after `Dispose`) to
+trigger, so it is not on par with [S2](#s2)/[S4](#s4).
+
+**API-neutral?** — Yes; internal-only, and reuses the `_disposed` field introduced for [S4](#s4).
+
+**Fix shape** — `ObjectDisposedException.ThrowIf(_disposed, this)` at the top of `Read` (and `Write`
+on the writer side). `next-version` does this in `SeekableDecryptStream` (`:125`, `:157`) and
+`DecryptStream`.
+
+**Confidence** — certain; reproduced.
+
+---
+
+
+### I1 — Armored input is not detected on a non-seekable stream
+
+**Location** — `Age/Format/AsciiArmor.cs:13-14` is real but **is not the operative gate**. The
+effective gates are the call-site guards `if (input.CanSeek && AsciiArmor.IsArmored(input))` at
+`Age/AgeEncrypt.cs:281` and `Age/AgeHeader.cs:51`. `Age/AgeRandomAccess.cs:220` is **not** an
+affected site — the constructor already throws
+`ArgumentException("ciphertext stream must be seekable")` at `:47-48` before `DeArmorInput` is
+reached. So there are **two** call sites to change, not three.
+
+**What is wrong** — `IsArmored` detects the BEGIN marker by seeking (save `Position`, read, restore),
+so on a non-seekable stream it gives up and returns `false`, and the raw armored text reaches the
+header parser. The reference CLI decrypts armored stdin without difficulty.
+
+**Reproduction** — `ForwardOnly` wrapper (`CanSeek = false`, delegates `Read`) over armored bytes:
+
+```
+Decrypt(nonseekable armored) AgeHeaderException: unsupported version: -----BEGIN AGE ENCRYPTED FILE-----
+DecryptReader(nonseekable armored) AgeHeaderException: unsupported version: -----BEGIN AGE ENCRYPTED FILE-----
+AgeHeader.Parse(nonseekable armored) AgeHeaderException: unsupported version: -----BEGIN AGE ENCRYPTED FILE-----
+Decrypt(nonseekable binary) OK
+```
+
+The same bytes through a seekable `MemoryStream` decrypt correctly.
+
+**Severity + who is affected** — **interop, and it is a *documented* limitation, not a silent lie.**
+`AgeEncrypt.Decrypt` (`:53`), `DecryptReader` (`:188`) and `AgeHeader.Parse` (`:38`) all say
+"Armored input is auto-detected when the stream is seekable", and `Age.Cli` buffers stdin into a
+`MemoryStream` first (`Age.Cli/AgeCommand.cs:92-97`), so `cat file.age | Age.Cli -d` works. Only
+library callers with a `NetworkStream`, `GZipStream`, `Console.OpenStandardInput` or an HTTP response
+body are affected.
+
+**API-neutral?** — In signature terms yes (`PeekableStream` would be internal, the two guards are
+private helpers). But it *widens* accepted input rather than narrowing it, and the XML doc sentences
+about seekability would have to be dropped in the same patch.
+
+**Fix shape** — Add an internal lookahead wrapper. `next-version` already has exactly this:
+`Age/Format/PeekableStream.cs` ("Lookahead rather than seeking, so armor detection works on a pipe")
+plus `AsciiArmor.Detect` (`Age/Format/AsciiArmor.cs:20-35`) returning `(source, isArmored)` —
+seekable inputs keep the save/restore path, non-seekable ones get a `PeekableStream` whose `Peek()`
+is replayed on subsequent `Read`s. Porting means copying `PeekableStream` in as internal and changing
+the two guards to take back a possibly-wrapped source rather than a bare `bool`.
+
+**A cheaper alternative for a patch release:** fix only the error message. Detect the BEGIN-marker
+prefix on the version-line error path and say so, instead of reporting the armor marker as an
+"unsupported version". Full non-seekable armor support is a behaviour change that arguably belongs in
+a minor release.
+
+**Confidence** — certain (the failure); the fix was not built or tested.
+
+---
+
+
+### I2 — `MlKem768X25519Recipient.Parse` accepts a structurally invalid ML-KEM public key
+
+**Location** — `Age/Recipients/MlKem768X25519Recipient.cs:33-47` (`Parse`).
+
+**What is wrong** — `Parse` validates HRP (`:37`), total length 1216 (`:40`) and lowercase-ness
+(`:44`) and never decodes or validates the 1184-byte ML-KEM encapsulation key. Go's
+`ParseHybridRecipient` calls `hpke.MLKEM768X25519().NewPublicKey`, which runs the
+`ByteEncode`/`ByteDecode` round-trip (modulus) check and rejects immediately.
+
+**Reproduction** — a recipient whose first 1152 ek bytes are `0xff`, bech32-encoded via `ToString()`
+(round-trip verified against a known-good recipient first), fed to both implementations:
+
+```
+main: MlKem768X25519Recipient.Parse(s) -> ACCEPTED (no exception)
+age: age -e -r "$(cat badrecip_ek.txt)" p.txt
+ exit=1
+ age: error: malformed recipient "age1pq1llll…": invalid MLKEM768-X25519 public key
+```
+
+The X25519 half is a different matter: **neither** implementation validates `pk_X` at parse time, so
+that is not a divergence and must not be folded into the fix.
+
+**Severity + who is affected** — **interop**, not security. A tool that validates a recipients file
+by parsing it reports the file as good and then fails mid-encryption, and the failure arrives as the
+raw `ArgumentException` of [C9](#c9). main never emits such a string, and the encryption fails rather
+than succeeding weakly.
+
+**API-neutral?** — Yes; `Parse`'s signature and `FormatException` contract are unchanged.
+
+**Fix shape** — After the length check, attempt
+`MLKemPublicKeyParameters.FromEncoding(MLKemParameters.ml_kem_768, data[..1184])` inside a
+`try/catch` and rethrow as `FormatException` (main's convention for `Parse` methods). Cache the
+parameters object on the instance to avoid re-parsing in `Encaps`. **`next-version` has no equivalent
+fix** — its `Parse` routes through `ParseHelpers.DecodeRecipientKey`, which is also HRP + length +
+case only — so this must be written fresh.
+
+**Confidence** — certain; reproduced against both implementations.
+
+---
+
+
+### I3 — scrypt work factor hard-capped at 20, below Go's library default of 22
+
+**Location** — `Age/Recipients/ScryptRecipient.cs:25` (`private const int MaxWorkFactor = 20`), used
+both by `EnsureValidWorkFactor` (`:34-39`, `ArgumentOutOfRangeException` on construct) and by the
+decrypt path (`:86-87`, `AgeHeaderException`).
+
+**What is wrong** — `docs/spec/age.md:326-328` says the identity implementation "SHOULD apply an
+upper limit to the work factor", so 20 is **legal** — this is spec-permitted policy, not a spec
+violation. But Go's `ScryptIdentity` defaults `maxWorkFactor` to **22**
+(`references/go-age/scrypt.go:129`), and `cmd/age`'s `LazyScryptIdentity`
+(`cmd/age/encrypted_keys.go:37`) calls `NewScryptIdentity` without `SetMaxWorkFactor`, so even the
+`age` CLI's decrypt path accepts 21/22. main refuses those files, and cannot produce them either.
+
+**Reproduction** — closed empirically, with a genuine Go-produced file. A Go program built against
+the vendored `references/go-age` clone (v1.3.1, commit `706dfc1`) calling
+`age.NewScryptRecipient("pw").SetWorkFactor(21)` round-trips fine in Go in ~12 s. Feeding the
+resulting real file (header `-> scrypt vsnpnvt+UjWDtmng6h5n8Q 21`) to main:
+
+```
+scrypt_wf21.age Age.AgeHeaderException: scrypt work factor 21 exceeds maximum 20
+```
+
+Also verified directly on hand-built headers at 21 and 22. The ceiling check fires before scrypt is
+ever run, so rejection is instant.
+
+*Correction to a common rationale:* the rage half of the argument is weaker than it looks.
+`references/rust-age/age/src/native/scrypt.rs:191` does set `max_work_factor = target + 4`, but
+`target_scrypt_work_factor()` (`:72-88`) climbs from `log_n = 10` until one scrypt run takes ≥ 1 s,
+and rage's *recipient* default is that same measured target (`:119`). Exceeding 20 on the encrypt
+side needs a machine where 1 GiB of scrypt completes in under a second, which is not today's
+hardware. **The solid case is Go's library**, demonstrated above.
+
+**Severity + who is affected** — **interop**. Anyone receiving a passphrase-encrypted file produced
+by a Go caller of `SetWorkFactor(21|22)`. The `age` CLI hardcodes 18, so `age -p` output is
+unaffected.
+
+**API-neutral?** — Yes; `MaxWorkFactor` is a private const.
+
+**Fix shape** — Raise the **decrypt-side** ceiling to 22 by splitting out
+`MaxAcceptedWorkFactor = 22` while leaving the encrypt-side constructor bound at 20 (or raise both).
+Keeping 20 is a defensible policy under the spec's SHOULD — but then the XML doc should say plainly
+that this is below Go's default and is a hard interop boundary, which it currently does not.
+
+**Confidence** — certain; reproduced against a genuine go-age file.
+
+---
+
+
+### I4 — Plugin FILE_INDEX never validated; a duplicate `file-key` silently replaces the previous one
+
+**Location** — `Age/Recipients/PluginRecipient.cs:79-87` (`ParseRecipientStanza` checks
+`args.Length >= 2` but ignores `args[0]`); `Age/Recipients/PluginIdentity.cs:69-74` (the `file-key`
+case checks only `args.Length >= 1`, with no duplicate check).
+
+**What is wrong** — main sends exactly one file key, so the index must always be `0`. go-age parses
+it, rejects anything but 0 (`client.go:119-126` and `:272-282`), and rejects a second `file-key`
+outright ("received duplicated file-key stanza"). main accepts a recipient-stanza addressed to a file
+it never sent, accepts a `file-key` for a phantom index, and on a duplicate keeps the last while
+leaving the discarded key material unzeroed on the heap.
+
+**Reproduction** — fake `age-plugin-weird`:
+
+```
+recipient-v1, `-> recipient-stanza 7 weird ...`:
+ AgeSharp: accepted into the header, ENCRYPTED, exit 0
+ age: malformed recipient stanza: unexpected index (exit 1)
+
+identity-v1, `-> file-key 42 `:
+ AgeSharp: PLAINTEXT=hello
+ age: malformed file-key stanza: unexpected index (exit 1)
+
+identity-v1, `-> file-key 9 <16 zero bytes>` then `-> file-key 0 `:
+ AgeSharp: PLAINTEXT=hello (silently overwrote the bogus key)
+ age: malformed file-key stanza: unexpected index (exit 1)
+```
+
+**Severity + who is affected** — **interop**. main enforces `fileKey.Length == 16`
+(`AgeEncrypt.cs:272-273`) and then `header.VerifyMac`, so a bogus key cannot yield a wrong plaintext.
+The harm is protocol non-conformance plus discarded key material left unzeroed.
+
+**API-neutral?** — Yes; internal only.
+
+**Fix shape** — In `ParseRecipientStanza`, require `args[0] == "0"` and throw
+`AgePluginException("recipient-stanza has unexpected file index")` otherwise. In `PluginIdentity`'s
+`file-key` case, require exactly one arg equal to `"0"`, and throw
+`AgePluginException("duplicate file-key stanza")` when `result` is already non-null. **Ship together
+with [C5](#c5)** — these validations only become meaningful once main itself sends `0` consistently.
+
+**Confidence** — certain; all three sub-cases reproduced.
+
+---
+
+
+### I5 — A `confirm` with zero arguments is answered with a fabricated "yes" label
+
+**Location** — `Age/Recipients/PluginRecipient.cs:130` and the verbatim duplicate at
+`Age/Recipients/PluginIdentity.cs:140`:
+`var yes = args.Length > 0 ? DecodeOptionLabel(args[0]) : "yes";`
+
+**What is wrong** — The spec's `confirm` form is
+`(confirm, Base64(YES_STRING) [Base64(NO_STRING)]; MESSAGE)` — `YES_STRING` is mandatory. go-age
+rejects anything other than 1 or 2 args (`client.go:359-361`). main invents the label `"yes"`, shows
+the user a prompt whose affirmative button text was made up by the library rather than sent by the
+plugin, and then answers `ok yes` to a malformed command.
+
+**Reproduction** — fake plugin sends `-> confirm` with no args and body `press the button`:
+
+```
+AgeSharp: callback invoked as `[confirm] press the button y=yes n=`
+ plugin log: client answered confirm with: ('ok', ['yes'], b'')
+ wrap proceeds
+age: conf plugin: malformed confirm stanza: unexpected number of arguments (exit 1)
+```
+
+**Severity + who is affected** — **interop**. Users of a plugin that emits a malformed `confirm`.
+
+**API-neutral?** — Yes; no `IPluginCallbacks` change.
+
+**Fix shape** — In **both** copies:
+`if (args.Length is not (1 or 2)) throw new AgePluginException("malformed confirm stanza: unexpected number of arguments");`
+before decoding. The `HandleConfirm`/`DecodeOptionLabel` pairs are duplicated verbatim across
+`PluginRecipient` and `PluginIdentity` — edit both.
+
+**Confidence** — certain; reproduced.
+
+---
+
+
+### H1 — `Header.ComputeMac` never zeroes the derived header MAC key
+
+**Location** — `Age/Format/Header.cs:90`
+(`var hmacKeyBytes = CryptoHelper.HkdfDerive(fileKey, ReadOnlySpan.Empty, "header", 32);`),
+reached from `:82` (`VerifyMac`) and `:113` (`WriteTo`). `grep -c ZeroMemory Age/Format/Header.cs`
+→ 0 (the only `CryptographicOperations` call in the file is `FixedTimeEquals` at `:83`).
+
+**What is wrong** — A 32-byte file-key-derived secret is left on the GC heap on every encrypt and
+every decrypt. Every other derived key on main is zeroed by its owner (`wrapKey` in
+`X25519Identity.cs:162`, `X25519Recipient.cs:91`, `SshEd25519Identity.cs:129`,
+`SshEd25519Recipient.cs:86`; payload key by the stream's dispose) — this one site is the omission.
+
+**Reproduction** — heap probe with a known file key, 301 decrypts:
+
+```
+residual copies of the 32-byte HEADER MAC KEY : 598
+zeroed 32-byte control : 1
+```
+
+~2 copies per decrypt.
+
+**Severity + who is affected** — **hygiene, deliberately not "security".** Recovering the header MAC
+key does not yield the file key — it is a one-way HKDF output — and it only authenticates a header
+the attacker already has. Forging a header MAC does not let an attacker read or re-wrap the payload,
+which still needs the file key. That is precisely why this is rated below [S8](#s8) even though the
+mechanism is identical; one investigator rated it security and that view is recorded here so the
+disagreement is visible rather than silently resolved.
+
+**API-neutral?** — Yes; `Header` is internal.
+
+**Fix shape**
+
+```csharp
+var hmacKeyBytes = CryptoHelper.HkdfDerive(...);
+try { return CryptoHelper.HmacSha256(hmacKeyBytes, headerBytes); }
+finally { CryptographicOperations.ZeroMemory(hmacKeyBytes); }
+```
+
+Do **not** import `next-version`'s `stackalloc` form (`Age/Format/Header.cs:85-88`) — it depends on
+the v0.3 span-filling `HkdfDerive`. Same attribution caveat as [S8](#s8): `HMACSHA256.HashData` also
+copies a ≤64-byte key into its own block buffer, so this reduces rather than eliminates residue.
+
+**Confidence** — certain; reproduced.
+
+---
+
+
+### H2 — `ScryptRecipient` clears outside `finally`; the passphrase is held as a `string`
+
+**Location** — `Age/Recipients/ScryptRecipient.cs:51` and `:96` (straight-line `ZeroMemory(wrapKey)`
+after the ChaCha calls at `:50`/`:95`), `:132` (straight-line `ZeroMemory(passphraseBytes)` after
+`SCrypt.Generate` at `:130`), `:20` (primary-constructor `string passphrase`, captured for the
+object's lifetime).
+
+**What is wrong** — Two separate problems.
+**(a)** The `ZeroMemory` calls are straight-line, not in `finally`. If the intervening call throws,
+the 32-byte scrypt wrap key or the UTF-8 passphrase survives.
+**(b)** The primary constructor captures `string passphrase` for the object's lifetime. A string
+cannot be zeroed, `ScryptRecipient` is not `IDisposable`, and `DeriveWrapKey` re-encodes it to a
+fresh UTF-8 array on every single `Wrap`/`Unwrap`.
+
+**Reproduction — NOT REPRODUCED.** Path (a) needs a forced allocation failure and (b) is a property
+of the type rather than an event. Both are certain by inspection.
+
+Throw reachability for (a) was walked and is **thin**: `CryptoHelper.ChaChaEncrypt` at `:50` is called
+with a validated 32-byte key and 12-byte nonce, and `CryptoHelper.ChaChaDecrypt` at `:95` swallows
+`AuthenticationTagMismatchException` internally and returns null, so neither realistically throws.
+The one genuinely reachable trigger is `OutOfMemoryException` from `SCrypt.Generate` at `:130` — and
+that one *is* attacker-influenced, since the decrypt path takes the work factor from the stanza up to
+`MaxWorkFactor` 20, i.e. a ~1 GiB allocation on demand.
+
+**Severity + who is affected** — **hygiene** (defence in depth on a thin path). Passphrase users.
+
+**API-neutral?** — **(a) yes, (b) no.** `ScryptRecipient(string passphrase, int workFactor = 18)` is
+public shipped surface and the type is not `IDisposable`; `next-version`'s fix replaces the whole
+type with `Passphrase` (stores `byte[]`, adds `ReadOnlySpan` constructors, implements
+`IDisposable` — `Age/Recipients/Passphrase.cs:19-90`), which is new public surface plus a rename.
+
+**Fix shape** — Backport **(a) only**: three `try/finally` insertions around the ChaCha calls and
+`SCrypt.Generate`. Add a documentation remark stating that the passphrase string cannot be zeroed.
+Set expectations honestly: fixing (a) does not help much while (b) stands, because the passphrase is
+re-encoded to a fresh uncleared UTF-8 array on every `Wrap`/`Unwrap` anyway.
+
+**Confidence** — certain on the code facts; not reproduced.
+
+---
+
+
+### H3 — The plugin wire path pushes secrets through unzeroable strings
+
+**Location** — `Age/Plugin/PluginConnection.cs:70` (`var encoded = Base64Unpadded.Encode(body);`)
+and `:110-142` (`ReadBody`); `Age/Crypto/Base64Unpadded.cs:19` (`return new string(...)`);
+`Age/Recipients/PluginRecipient.cs:45` (`fileKey.ToArray()`);
+`Age/Recipients/PluginIdentity.cs:72` (`result = body`) and `:123-124` (the PIN).
+
+**What is wrong** — Three secrets cross this wire and none is cleaned up.
+(1) Outbound: `SendWrapRequest` does `conn.WriteStanza("wrap-file-key", [], fileKey.ToArray())` — an
+uncleared heap copy of the raw file key — and `WriteStanza` then base64-encodes it into an immutable
+string.
+(2) Inbound: `ReadBody` accumulates the `file-key` stanza via `Base64Unpadded.Decode(bodyLine)` into
+a `List` (`:123`), copies the chunks into `body` (`:135-139`) and clears none of them; and
+`_reader.ReadLine()` already produced a string holding the base64 of the file key.
+(3) `PluginIdentity.HandleCommonStanza:124` answers `request-secret` with
+`Encoding.UTF8.GetBytes(value)` — the user's PIN — never cleared, then base64-encodes it into another
+unzeroable string.
+
+**Reproduction — NOT REPRODUCED.** Exercising it needs an `age-plugin-*` binary and a heap scan;
+neither was done for these specific needles. The code facts are certain by inspection.
+
+Two refinements: for a 16-byte file key `maxLen` is 24, under the 256-char `StackAllocThreshold`, so
+the intermediate char buffer is stack-allocated and only the resulting immutable **string** is
+heap-resident (a long PIN can exceed the threshold and take the heap path). And `WriteStanza` writes
+into a `StreamWriter` whose internal char buffer *also* retains the base64 — another copy the fix
+cannot reach.
+
+**Severity + who is affected** — **hygiene**, deliberately not security. There is no
+attacker-triggerable path, and the file key already crosses an OS pipe to the child in cleartext by
+protocol design, so the marginal exposure added by the string is real but defence-in-depth. Plugin
+users.
+
+**API-neutral?** — Most of it. `PluginConnection`, `PluginRecipient.SendWrapRequest` and
+`Base64Unpadded` are all internal. **Not neutral:** `IPluginCallbacks.RequestValue(string!, bool)
+-> string!` is `PublicAPI.Shipped.txt:42`, so the PIN exists as an unzeroable string before the
+library ever sees it; `next-version` split out a `char[]`-returning `RequestSecret`
+(`Age/Plugin/PluginProtocol.cs:53-66`, commit `08facf6`) — that is a public interface change and must
+**not** be backported.
+
+**Fix shape** — Port only the encode half of `08facf6`: add internal
+`Base64Unpadded.MaxEncodedLength(int)` and a span-writing `Encode(ReadOnlySpan, Span)`
+overload, have `WriteStanza` encode into an `ArrayPool` rental and clear it with
+`ZeroMemory(MemoryMarshal.AsBytes(...))` in a `finally` (`next-version PluginConnection.cs:90-116`).
+Clear `ReadBody`'s chunk arrays and `PluginIdentity`'s unwrapped file key. Hoist and clear the
+`wrap-file-key` `ToArray()` copy and the PIN's UTF-8 array. Leave `IPluginCallbacks` alone and
+document the residual.
+
+**Confidence** — certain on the code facts; not reproduced.
+
+---
+
+
+### H4 — `X25519Identity.Unwrap` allocates the shared secret outside its `try`
+
+**Location** — `Age/Recipients/X25519Identity.cs:132-164`; the unguarded window is `:136-151`.
+
+**What is wrong** — `sharedSecret` is allocated at `:132` and filled at `:135`, but the `try/finally`
+that zeroes it does not open until `:152`. Everything between — the `InvalidOperationException`
+catch, the all-zero check, `PublicKeyParams.GetEncoded()`, the salt concatenation and
+`CryptoHelper.HkdfDerive` — runs with the shared secret unprotected.
+
+**Reproduction — NOT REPRODUCED**, and the practical exposure was checked and found to be **nil**:
+on the two paths that actually throw (BouncyCastle rejecting the agreement, and the all-zero check at
+`:143`) `sharedSecret` is by definition all-zero, so nothing sensitive is abandoned. The remaining
+throws are unreachable in practice — `PublicKeyParams` (`:32-39`) is
+`new X25519PrivateKeyParameters(_rawPrivateKey).GeneratePublicKey()`, which cannot throw for a
+32-byte array even after `Dispose` has zeroed it, and `HkdfDerive` can only throw on OOM.
+
+**Severity + who is affected** — **hygiene** — a genuine structural nit with no observable failure.
+**Do not present it to users as a fixed vulnerability.**
+
+**API-neutral?** — Yes; method body only.
+
+**Fix shape** — Move the `try {` to immediately after the `CalculateAgreement` call so the `finally`
+covers the all-zero check, the salt build and the HKDF. Cheap insurance, worth taking while the file
+is already open for [S8](#s8).
+
+**Confidence** — certain on the structure; no reachable exposure demonstrated.
+
+---
+
+
+### H5 — `AgeRandomAccess` does not zero a decrypted chunk on one error path
+
+**Location** — `Age/AgeRandomAccess.cs:179-184` (`DecryptChunkAt`).
+
+**What is wrong** — `DecryptChunkAt` decrypts into a fresh `byte[]` and then throws
+`AgePayloadException` if the final chunk is empty with predecessors, returning without zeroing.
+`ReadAt` (`:104`) zeroes the chunk on the normal path, so the omission is only on this error path.
+
+**Reproduction — NOT REPRODUCED, and there is nothing to reproduce.** On the only path that throws,
+the guard condition is `plaintext.Length == 0`, so the abandoned array is a **zero-length**
+`byte[]` — there is literally nothing to leak. The fallback rationale (a fault in the `ReadAt` copy
+loop) is also empty: `:100-102` is arithmetic plus a `Span.CopyTo` whose bounds are computed from the
+same values, with no reachable throw. The sibling allocation at `StreamEncryption.cs:136` was also
+checked — on authentication failure the plaintext buffer is abandoned, but .NET's
+`ChaCha20Poly1305` and the managed `AeadCipher` both clear the destination on tag mismatch, so again
+no residue.
+
+**Severity + who is affected** — **hygiene, and effectively a no-op change.**
+
+**API-neutral?** — Yes.
+
+**Fix shape** — Zero `plaintext` before the throw, or `try/finally` in `ReadAt`. **Recommendation:
+include only if the patch already touches this file for [S2](#s2)/[S3](#s3); do not list it in
+release notes as a security fix.**
+
+**Confidence** — the code observation is certain; the defect is not.
+
+---
+
+
+### H6 — Every mlkem stanza re-runs full ML-KEM keygen: ~7x pre-auth CPU vs the reference
+
+**Location** — `Age/Crypto/XWing.cs:76` (`Decaps` calls `ExpandSeed` per stanza) and `:122-138`;
+`Age/Recipients/MlKem768X25519Identity.cs:27` (`Recipient` recomputed on every property access).
+
+**What is wrong** — `XWing.Decaps` re-derives the identity from scratch on every call: SHAKE-256
+expansion plus `MLKemPrivateKeyParameters.FromSeed`, which is a full ML-KEM-768 KeyGen, **per stanza
+tried**. Go's `HybridIdentity` holds a materialised `hpke.PrivateKey` and only decapsulates. Recipient
+stanzas must be unwrapped before the header MAC can be checked
+(`Age/AgeEncrypt.cs:262-264` iterates, `:275` verifies), so this is attacker-controlled work on
+unauthenticated input, bounded only by `AgeLimits.MaxHeaderBytes` (16 MiB ≈ 10,700 mlkem stanzas at
+~1.56 KiB each).
+
+**Reproduction** — 1.6 MiB header, 1000 decoy PQ recipients, real identity last, encrypted by
+`age -R`:
+
+```
+age (Go), 3 runs : 0.10 / 0.09 / 0.09 s user
+main (.NET), 3 runs : 0.602 / 0.605 / 0.622 s user
+```
+
+In-process microbenchmark (Release, 100 iterations): `Unwrap` of a non-matching mlkem stanza
+**1.065 ms**, `Recipient` property **0.489 ms**, `Wrap` 1.139 ms. `Recipient` is not even
+reference-stable across accesses (`ReferenceEquals` → False), confirming full recomputation — so
+`ToString()` in a loop is unexpectedly expensive.
+
+Extrapolating 0.6 s per 1000 to the 16 MiB ceiling gives **~6.5 s** for main against ~1 s for `age`.
+
+**Severity + who is affected** — **hygiene.** This is a bounded constant-factor amplification (~6-7x)
+on a path the reference implementation also walks, not an unbounded or asymptotic DoS. Do not inflate
+it.
+
+**API-neutral?** — Yes, both halves.
+
+**Fix shape** — Two independent internal caches.
+1. `MlKem768X25519Identity`: `private MlKem768X25519Recipient? _recipient;` and
+ `_recipient ??= new(XWing.GeneratePublicKey(_seed))`. Benign race, idempotent value.
+ `next-version` does exactly this (`MlKem768X25519Identity.cs:20/35`, `X25519Identity.cs:22/36`)
+ and it lifts directly. Side effect worth noting: caching makes `Recipient` reference-stable, so a
+ caller putting repeated accesses into a `HashSet` now sees one entry where it previously saw two.
+ That is a fix, not a break.
+2. Cache the expanded `MLKemPrivateKeyParameters` / `X25519PrivateKeyParameters` / `pkX` on the
+ identity, via an internal overload of `HpkeHelper.OpenBase` / `XWing.Decaps` taking the expanded
+ key instead of the seed. The cached objects must be treated as read-only because
+ `MlKem768X25519Identity.cs:9-11` promises instances are safe for concurrent `Unwrap` (BouncyCastle
+ parameter objects are immutable, so this holds). **Not fixed on `next-version`** — its
+ `XWing.cs:72` still calls `ExpandSeed` inside `Decaps` — so this half must be written fresh.
+
+**Confidence** — certain; independently measured.
+
+---
+
+
+### H7 — Plugin `Dispose` stalls 5 s then abandons a process that has not exited
+
+**Location** — `Age/Plugin/PluginConnection.cs:144-161`; the unchecked wait is `:158`.
+
+**What is wrong** — `Dispose` closes stdin, calls `_process.WaitForExit(5000)`, **discards the
+bool**, then `_process.Dispose()` regardless. `StandardOutput` is never closed. A plugin that does not
+exit on stdin EOF keeps running detached after the AgeSharp call returns, holding whatever it acquired
+(a YubiKey/PC-SC session, a TPM handle, an agent socket) and continuing to hold the file key in its
+memory. The caller also pays a 5-second stall with no diagnostic. go-age closes stdin **and** stdout,
+sends `os.Interrupt`, then blocks on `cmd.Wait()`.
+
+**Reproduction** — fake `age-plugin-zombie` ignores SIGINT and sleeps 600 s after `done`:
+
+```
+AgeSharp: Wrap returns the correct stanza, total wall time 5.4 s, probe exits.
+One second later:
+ $ pgrep -fl age-plugin-zombie
+ 66387 ... age-plugin-zombie --age-plugin=recipient-v1 (still alive)
+```
+
+**Severity + who is affected** — **hygiene** (resource leak plus a 5 s stall). Users of a plugin that
+does not exit promptly.
+
+**API-neutral?** — Yes; internal only.
+
+**Fix shape** — Also close/dispose the stdout reader, and if `!_process.WaitForExit(5000)` call
+`_process.Kill(entireProcessTree: true)` followed by a short `WaitForExit` before `Dispose`.
+Optionally shorten the grace period. **Present on `next-version` too**
+(`PluginConnection.cs:70` there has the identical `WaitForExit(5000)` with no `Kill`).
+
+**Confidence** — certain; reproduced.
+
+---
+
+
+### H8 — `StreamEncryption`'s whole-stream methods buffer everything and are test-only
+
+**Location** — `Age/Crypto/StreamEncryption.cs:12-37` (`Encrypt`) and `:39-74` (`Decrypt`);
+unreachable branches at `:67-68` and `:73`.
+
+**What is wrong** — Both methods copy the whole input into a `MemoryStream` (`:14-16`, `:41-43`)
+before doing any work, and hand out `GetBuffer()`. **No production code path calls them** — a grep
+across `Age`, `Age.Cli`, `Age.Tests`, `Age.TestKit` and `Age.Benchmarks` finds callers only in
+`Age.Tests/UnitTests.cs` (lines 783, 787, 798, 809, 820, 827, 840, 850, 863, 875, 1216, 1266). They
+are a trap for anyone who reaches for them later. Within `Decrypt`, the "data found after final
+chunk" branch at `:67-68` and the "payload ended without a final chunk" branch at `:73` are both
+unreachable by construction: `NextChunk` sets `chunkLen = remaining` exactly when `isFinal`, so
+`offset == inputData.Length` after the increment and the loop can only exit via the `isFinal` return.
+
+**Reproduction — NOT REPRODUCED**, and there is no failing input, which is precisely the point.
+
+*Correction to a common framing:* these methods do **not** violate `AgeEncrypt`'s class-level
+memory-bounded guarantee (`Age/AgeEncrypt.cs:10-11`), because that guarantee covers `AgeEncrypt`'s
+public streaming APIs and no public path reaches these. They are dead weight and a future trap, not a
+live contradiction.
+
+**Severity + who is affected** — **hygiene**; nobody today.
+
+**API-neutral?** — Yes; `StreamEncryption` is an `internal static class`.
+
+**Fix shape** — Delete both whole-stream methods and rewrite the tests to drive
+`EncryptStream`/`DecryptStream`, or keep them, drop the unreachable branches, and document them as
+test-only. `next-version` keeps a `StreamEncryption` but has no equivalent buffered pair.
+
+**Confidence** — certain on the facts; lowest priority in the survey.
+
+---
+
+
+### H9 — Armor decoder accepts a bare CR as a line terminator
+
+**Location** — `Age/Format/AsciiArmor.cs:49` (the `StreamReader`) and
+`Age/Format/DearmorStream.cs:86` (`_reader.ReadLine()`).
+
+**What is wrong** — `StreamReader.ReadLine` treats a lone `\r` as a line terminator. go-age only
+strips a trailing `\r` **after** a `\n` (`armor.go:97-98`: `ReadBytes('\n')` then
+`TrimSuffix(line, "\n")` then `TrimSuffix(line, "\r")`, so a bare CR stays inside the line and fails
+base64 decode), and rust-age explicitly errors (`primitives/armor.rs:858-863`,
+`ArmoredReadError::LineContainsCr`). main is more permissive than both.
+
+**Reproduction** — take a valid armored file and replace every `\n` with `\r`:
+
+```
+age -d -i key.txt cr_only.age -> reject
+main -d -i key.txt cr_only.age -> ACCEPT (decrypts)
+```
+
+The CRLF variant is accepted by both (correct). Nine other structural edge cases (blank line in body,
+lowercase END marker, missing END marker, trailing junk, trailing whitespace, no trailing newline,
+leading blank lines, trailing space in a body line) agree exactly with `age`.
+
+**Severity + who is affected** — **hygiene.** Accept-more only: no valid file is rejected and no
+user's data becomes unreadable. Strict-parsing gap, not a decryption failure.
+
+**API-neutral?** — Yes.
+
+**Fix shape** — Reject a body/marker line whose raw bytes were terminated by a bare CR — read lines
+at the byte level in `NewlineBoundedStream`/`DearmorStream` and treat `\r` as valid only immediately
+before `\n`. **`next-version` does not fix this either** (its `ArmorLineAccumulator` also accepts CR),
+so there is no upstream commit to port. Lowest priority of the armor set; skip if the patch is being
+kept tight.
+
+**Confidence** — certain; reproduced.
+
+---
+
+
+### H10 — Armor decoder accepts leading whitespace on the BEGIN marker
+
+**Location** — `Age/Format/AsciiArmor.cs:66` — `if (line.TrimStart() != BeginMarker)`.
+
+**What is wrong** — `" -----BEGIN AGE ENCRYPTED FILE-----"` is accepted. go-age requires
+`string(line) != Header` exactly (`armor.go:131`).
+
+**Reproduction** — prefix a valid armored file with three spaces:
+
+```
+age -d -i key.txt lead_spaces_marker.age -> reject ("invalid first line")
+main -d -i key.txt lead_spaces_marker.age -> ACCEPT
+```
+
+**Severity + who is affected** — **hygiene**; accept-more only.
+
+**API-neutral?** — Yes.
+
+**Fix shape** — **Recommendation: leave it alone.** This looks deliberate, not accidental:
+`AsciiArmor.IsArmored` (`:27-42`) has a matching `SkipLeadingWhitespace` byte loop so detection and
+parsing agree, and `next-version` deliberately kept the same leniency
+(`Age/Format/ArmorDecoder.cs:24`, `line.TrimStart().SequenceEqual(ArmorFormat.BeginMarker)`). This is
+a design choice on record, not a defect to backport. Flagged only so the divergence is documented.
+If strict parity with `age` ever becomes a goal, compare the line exactly and drop the
+`SkipLeadingWhitespace` loop in the same change.
+
+**Confidence** — certain; reproduced.
+
+---
+
+
+### H11 — No bound on leading whitespace before the BEGIN marker
+
+**Location** — `Age/Format/AsciiArmor.cs:58-61` —
+`do { line = reader.ReadLine(); } while (line != null && line.AsSpan().Trim().Length == 0);`
+
+**What is wrong** — The blank-line skip loop has no counter. go-age caps both leading and trailing
+whitespace at 1024 bytes (`armor.go:102` trailing, `:125-127` `removedWhitespace > maxWhitespace`
+leading).
+
+**Reproduction** — prepend 200 MB of newlines to a valid armored file:
+
+```
+age -d -i key.txt lb.age -> 0.024 s
+main -d -i key.txt lb.age -> 1.97 s user CPU before it even reaches the marker
+```
+
+**Two corrections that both argue for keeping this at hygiene or lower.**
+(1) In this reproduction `age` did **not** hit its 1024-byte cap — it failed with
+`parsing age header: unexpected intro: "\n"`, i.e. the CLI saw a first byte that is not `-`, treated
+the file as binary, and never entered the armor reader. The cap exists in go-age's `armor.Reader`,
+but the repro does not exercise it, so the timing contrast is overstated.
+(2) The cost is strictly **linear** in the prefix (1.97 s for 200 MB ≈ read throughput), with no
+amplification and O(1) memory — `AgeLimits.MaxArmorLineBytes` (65536) still bounds any single line.
+"Spins indefinitely" is only true for a genuinely unbounded stream, which is a property of the
+caller's source rather than of this loop.
+
+The trailing-whitespace side is **not** vulnerable: `DearmorStream.ValidateTrailing` (`:115-127`) is
+bounded in practice by `NewlineBoundedStream` — 200 MB of trailing spaces trips
+`armor line exceeds 65536 bytes` in 0.19 s.
+
+**Severity + who is affected** — **hygiene.** Not exploitable as a DoS beyond "reading a large input
+takes time". Worth fixing only for parity with go-age.
+
+**API-neutral?** — Yes; a few lines inside a private helper.
+
+**Fix shape** — Count bytes consumed by the blank-line skip and throw `AgeArmorException` past 1024,
+matching go-age. `next-version` encodes this as `AsciiArmor.MaxLeadingWhitespace = 1024`
+(`Age/Format/AsciiArmor.cs:7`) used to size the detection probe.
+
+**Confidence** — certain; reproduced. Fix not built or tested.
+
+---
+
+## 4. Backport list (ordered by severity)
+
+### Tier 1 — ship these
+
+| # | Defect | One-line rationale |
+|---|---|---|
+| [S1](#s1) | Plugin CWD execution | Arbitrary code execution + file-key disclosure; spec-prohibited; both references refuse. |
+| [S2](#s2)/[S3](#s3)/[C11](#c11) | `AgeRandomAccess` final-chunk authentication | One fix, three defects: silent acceptance of truncated ciphertext that `age` and main's own forward-only path both reject. |
+| [C1](#c1) | Armor padded-final-line rejection | ~4.2% of valid armored files unreadable, including AgeSharp's own encrypted identity files. Fix built and fully verified. |
+| [S4](#s4) | Double `Dispose` → `ArrayPool` double-Return | Cross-renter memory aliasing from idiomatic `using` code; also produces spurious auth failures. |
+| [C4](#c4) | Plugin multi-stanza drop | Silent, permanent data loss with exit 0. |
+
+### Tier 2 — clear defects, low risk
+
+| # | Defect | Rationale |
+|---|---|---|
+| [S5](#s5) | Disposed identity → all-zero keypair | Silent fail-open to a world-known key; three-line guard. |
+| [S6](#s6) | `extension-labels` advertised then ignored | One-line deletion; restores the PQ/classical mixing guardrail. |
+| [C5](#c5) | Plugin FILE_INDEX per stanza | One-token change; main cannot decrypt files `age` decrypts. |
+| [C6](#c6) | Plugin stderr deadlock | Hard hang with no timeout past 64 KiB of plugin diagnostics. |
+| [C2](#c2) | Armor disposes the caller's stream | Violates the library's own documented invariant; fix built and verified. Call out the behaviour change. |
+| [C3](#c3) | Culture-sensitive `StartsWith` | main accepts files both references reject, and disagrees with its own AOT build. |
+| [C8](#c8)/[C9](#c9)/[C10](#c10) | Unguarded agreements + raw BCL exceptions | One shared `CryptoHelper.X25519Agree` helper covers most of it; stops "This is a bug" on merely-malformed input. |
+| [C7](#c7) | Raw exceptions from the plugin path | Same class as above; `AgePluginException` already exists. |
+| [C12](#c12) | `Read()` after `Dispose()` | Returns another renter's bytes as plaintext; free once [S4](#s4)'s `_disposed` field exists. |
+
+### Tier 3 — zeroization cluster (defence in depth; ship together)
+
+| # | Defect | Rationale |
+|---|---|---|
+| [S7](#s7) | PQ path zeroes nothing | `seedPq`/`seedT` are identity-equivalent; `Dispose` does not erase what it promises to. |
+| [S8](#s8) | `HkdfDerive` ikm copy | Two uncleared file-key copies per operation, every recipient type. |
+| [S9](#s9) | File key on `AgeEncrypt` error paths | Reachable from attacker-supplied truncated/tampered ciphertext. |
+| [S10](#s10) | `AgeKeygen` identity-file plaintext | Raw private keys; array half is neutral, string half is not. |
+| [S11](#s11) | `Ed25519Converter` SHA-512 expansion | Defeats `SshEd25519Identity.Dispose`; written fresh, not on `next-version`. |
+| [S12](#s12) | `Bech32` 5-bit private key | Defeats the deliberate zeroing in both `Parse` methods; written fresh. |
+| [H1](#h1) | `Header.ComputeMac` MAC key | Same one-line pattern; the last unguarded derived key. |
+| [H4](#h4) | `X25519Identity.Unwrap` window | Free while the file is open; no reachable exposure — do not bill it as a vulnerability. |
+
+### Tier 4 — interop and polish, take if the release is not being kept tight
+
+| # | Defect | Rationale |
+|---|---|---|
+| [I4](#i4) | Plugin FILE_INDEX validation | Ship with [C5](#c5); meaningless before it. |
+| [I5](#i5) | `confirm` with zero args | Two-line guard, both copies. |
+| [I2](#i2) | PQ recipient not validated at parse | Recipients-file validators pass a file that then fails mid-encryption. |
+| [I3](#i3) | scrypt cap 20 vs Go's 22 | Genuine Go-produced files are refused. Or keep 20 as policy and fix the docs. |
+| [H7](#h7) | Plugin zombie + 5 s stall | Resource leak; `Kill` on timeout. |
+| [H6](#h6) | PQ per-stanza keygen | ~6-7x pre-auth CPU vs `age`; part 1 (cache `Recipient`) lifts from `next-version`. |
+| [H2](#h2)(a) | `ScryptRecipient` `finally` | Only the OOM path is reachable; cheap. |
+| [H3](#h3) | Plugin base64 secret copies | Encode half is internal; leave `IPluginCallbacks` alone. |
+| [H9](#h9), [H11](#h11) | Armor bare CR, unbounded leading whitespace | Parity with the references; neither breaks anything today. |
+| [H8](#h8) | `StreamEncryption` dead methods | Delete or document; do not block a security patch on it. |
+
+---
+
+## 5. Do NOT backport
+
+| Thing | Why not |
+|---|---|
+| `next-version`'s `IRecipient.Wrap` → `IReadOnlyList` (`fd16800`) | Public API break. Use the internal `IMultiStanzaRecipient` shape in [C4](#c4) instead. |
+| `next-version`'s `HkdfDerive` → fill-a-`Span` (`30f38d1`) | Cascades into 22 call sites. Keep main's `byte[]` return; take only the `ikmCopy` `finally` ([S8](#s8)). |
+| `next-version`'s `ScryptRecipient` → `Passphrase` type | New public type, new `ReadOnlySpan` ctors, `IDisposable`, plus a rename. Take only part (a) of [H2](#h2). |
+| `next-version`'s `IPluginCallbacks` split (`RequestSecret` returning `char[]`, `08facf6`) | Public interface change. Take only the internal `WriteStanza`/`Base64Unpadded` half ([H3](#h3)). |
+| `next-version`'s `ParseIdentities(ReadOnlySpan)` | `ParseIdentityFile(string, IPluginCallbacks)` is `PublicAPI.Shipped.txt:112`. Accept the residual string ([S10](#s10)). |
+| `next-version`'s whole-cloth `DearmorStream`/`PeekableStream` rewrite | Entangled with the v0.3 sans-I/O redesign. Use the `leaveOpen` flag ([C2](#c2)) and, if wanted, port `PeekableStream` alone ([I1](#i1)). |
+| `next-version`'s `SeekableDecryptStream` | Same — port the *behaviour* (eager final-chunk decrypt) into `AgeRandomAccess`, not the class. |
+| [H10](#h10) — strict BEGIN-marker comparison | Deliberate, consistent with `IsArmored`'s own whitespace skip, and kept on `next-version`. Documented divergence, not a defect. |
+| [H5](#h5) — zeroing the chunk on `DecryptChunkAt`'s throw | The abandoned array is zero-length. A no-op change; include only incidentally, never in release notes. |
+| [I1](#i1) full non-seekable armor support | Widens accepted input in a patch release, against explicit XML docs. Ship the error-message fix instead, or defer to a minor. |
+
+---
+
+## 6. Considered and dismissed
+
+**"`AgeRandomAccess.ReadAt` clears the decrypted plaintext chunk outside a `finally`"
+(`Age/AgeRandomAccess.cs:98-104`) — REFUTED as a defect.** The literal observation is true —
+`ZeroMemory(plaintext)` at `:104` is not in a `finally` — but no reachable path makes it matter, and
+both cited triggers are wrong:
+
+1. The `AgePayloadException` at `:181-182` is thrown **inside** `DecryptChunkAt`, before `plaintext`
+ is returned, so `ReadAt`'s loop body never holds it and wrapping that loop would not cover the one
+ case it names. That branch also only fires when `plaintext.Length == 0`.
+2. The claimed out-of-range at `:102` is unreachable: `PlaintextLength` comes from
+ `ComputePlaintextLength` (`:234-245`), which derives the final chunk's plaintext size from exactly
+ the same layout arithmetic that `DecryptChunkAt` (`:167-179`) uses to size the chunk it decrypts,
+ so `plaintext.Length - offsetInChunk` is always positive while `currentOffset < PlaintextLength`.
+ The adjacent hazard (a zero `toCopy` spinning the `while` at `:96`) is unreachable for the same
+ reason.
+3. Nothing else in `DecryptChunkAt` can throw after `plaintext` exists — `ReadEncryptedChunk`
+ (`:187-206`) and `StreamEncryption.DecryptChunk` both throw before it is allocated.
+4. The secondary point ("plaintext-adjacent" per-call 64 KiB arrays in `ReadEncryptedChunk`) is
+ affirmatively wrong: those hold **ciphertext**, which `CLAUDE.md` states is deliberately not
+ zeroed.
+
+At most an optional defensive tidy-up. Not a v0.2 patch entry. (This is separate from [H5](#h5),
+which is the `DecryptChunkAt`-side version of the same non-issue.)
+
+**Also explicitly checked and found NOT to be defects** (recorded so they are not re-investigated):
+
+- **"The all-zero X25519 check is missing on main."** False, and it was the false positive that
+ prompted this survey's method rules. All 8 `CalculateAgreement` sites were enumerated by grep:
+ 3 have AgeSharp's own guard, and BouncyCastle's `GenerateSecret` rejects an all-zero result at
+ **all 8**. No zero shared secret ever reaches HKDF on main. `docs/spec/age.md:294`'s MUST is
+ satisfied everywhere. The defects at the unguarded sites are exception-type defects only
+ ([C8](#c8), [C9](#c9), [C10](#c10)).
+- **Non-canonical X25519 public keys (value + p, high bit set) accepted by `Wrap`.** `age` v1.3.1
+ accepts the same 5, verified by running it. Not a divergence.
+- **scrypt-must-be-alone.** main is *stricter* than `age` v1.3.1 here (Go's check lives inside
+ `ScryptIdentity.Unwrap`, which is never consulted for an X25519 identity) and main follows
+ `docs/spec/age.md:334`. **Do not "fix" this.**
+- **Bech32 casing.** main requires recipients lowercase and secret keys uppercase; Go enforces the
+ same thing by a different route (`internal/bech32` returns the HRP with original case and `pq.go`
+ compares exactly). Equivalent.
+- **`AsciiArmor.Armor` (`Age/Format/AsciiArmor.cs:72-95`) is dead code.** No public API reaches it —
+ the only production armor writer is `ArmorStream` via `AgeEncrypt.cs:167`; it is exercised only by
+ `Age.Tests`. Its unchecked `Convert.TryToBase64Chars` return is therefore not a live defect, though
+ the duplication is a divergence risk worth deleting.
+- **`Header.ParseMacLine`'s `allRaw[..^macSuffix.Length]` slice.** Cannot underflow — the MAC line is
+ always freshly read (`PushBack` is only used for `-> ` lines), so the raw buffer always ends with
+ `" " + macB64 + "\n"`.
+- **`SshRsaIdentity.Dispose` does not zero.** Deliberate and documented — BouncyCastle `BigInteger`
+ fields cannot be zeroed. Accurate, not a defect.
+- **`AgeRandomAccess.ReadAt` spinning.** `available` is provably > 0 for every offset below
+ `PlaintextLength`.
+
+---
+
+## 7. Coverage and limits
+
+### Verified clean (do not re-spend effort here)
+
+**Cryptographic core — all derivations correct against `docs/spec/age.md`, checked line by line
+*and* proven by two-way interop with `age` v1.3.1 on 100 KiB payloads:**
+X25519 (`info="age-encryption.org/v1/X25519"`, `salt = ephShare‖recipient`); ssh-ed25519 tweak and
+wrap-key salts (matching `agessh.go:217-231,329-338`); scrypt salt/N/r/p; payload key
+`HKDF(fileKey, nonce, "payload")`; header MAC key `HKDF(fileKey, empty, "header")` with the HMAC over
+the header through `---` excluding the trailing space. X-Wing combiner order, seed expansion, HPKE
+suite ID `HPKE‖0x647a‖0x0001‖0x0003` and RFC 9180 §5.1 key schedule all verified — the spec's own PQ
+vector (`docs/spec/age.md:186`) reproduces character-for-character.
+
+**Payload/chunking layer.** 76 plaintext sizes (`k*65536 + {-17,-16,-15,-1,0,+1,+15,+16,+17}` for
+k=0..5, 25 pseudorandom sizes, plus 5 MiB + 12345) round-tripped four ways each (main→`age`,
+`age`→main, main→main, and the same two armored). All byte-exact, zero failures. The 11-byte
+big-endian chunk counter plus final flag is proven interoperable to 80 chunks. The forward-only
+decryptor's accept/reject verdict matched `age` on **every** case of a 7-size × 5-truncation sweep and
+a 3-size × 4-junk-length trailing-data sweep. `EncryptStream`'s look-ahead-byte chunking never emits a
+trailing empty chunk. Detached mode round-trips exactly at 0/65535/65536/65537/131072/131073.
+
+**Header parsing.** Three differential campaigns against `age`: 17 hand-picked malformed headers
+(agreement on all), 582 random byte-level mutations (582/582 rejected by both, zero disagreements),
+800 structured framing mutants (zero cases where main accepted and `age` rejected). Every spec MUST
+checked and enforced: canonical unpadded base64 everywhere (including the MAC line), printable-ASCII
+args, body-line length rules, exact version-line match, MAC byte range, CR and non-ASCII rejection,
+scrypt/X25519/mlkem arg counts and the partitioning-oracle body-length checks before decryption.
+`AgeLimits.MaxHeaderLineBytes` (65536) and `MaxHeaderBytes` (16777216) verified exact — no off-by-one.
+A 0..300-byte body-length sweep through a custom `IRecipient`, every file handed to `age -d`: all 301
+accepted.
+
+**Armor encoding.** main's `ArmorStream` output is structurally identical to `age -a` at every size
+tested; `age -d` accepted all 201 of main's armored outputs over plaintext 0..200. Verified
+insensitive to I/O granularity: 96 combinations of source dribble (1/7/48 bytes per `Read`) × caller
+buffer (1/3/65/4096) × plaintext size. Armor **decoding** matches `age` on everything except
+[C1](#c1) — a 400-case structural mutation fuzz found zero accept/reject disagreements once [C1](#c1)
+is fixed.
+
+**PQ decrypt path.** 4000 random 1-3 byte mutations across the whole header of a PQ-encrypted file,
+through the public `AgeEncrypt.Decrypt`: zero non-`AgeException` escapes. The exception-leak defect
+([C9](#c9)) is encrypt-side only.
+
+**Plugin protocol.** `PluginNameValidator` matches the spec ABNF (stricter than go-age).
+`WriteStanza` framing is byte-correct including the empty terminator for an exact multiple of 64.
+Null-callbacks correctly answers `(fail)`. Unknown/grease phase-2 commands answered `unsupported`.
+`done` with no stanza raises correctly. identity-v1 error handling matches the spec's response table.
+Process count matches go-age (one per identity on decrypt, one per recipient on encrypt).
+
+**Zeroing sites that are correct.** `DecryptStream`/`EncryptStream` `Dispose` zero the payload key and
+the pooled plaintext buffer using `.AsSpan(0, size)` on the oversized rental, and `DecryptStream`
+zeroes the residual tail when a chunk shrinks. `BouncyCastleAeadCipher` returns all three rentals with
+`clearArray: true`, including `output` on the tag-mismatch path. All four classical
+recipient/identity `Wrap`/`Unwrap` methods clear `wrapKey`/`sharedSecret`/`tweak`/`tweakedSS` in
+genuine `finally` blocks. `X25519Identity`, `MlKem768X25519Identity` and `SshEd25519Identity`
+`Dispose` implementations all zero and are idempotent. `AgeRandomAccess.Dispose` and
+`InitializeFromStream` are both correct.
+
+**Miscellaneous.** `string.Contains(string)` and `!=` on strings are ordinal by default, so
+`SshKeyParser.cs:51` and `AsciiArmor.cs:66` are unaffected by [C3](#c3). `HeaderReader` does not
+over-read into the payload. `Base64Unpadded` cannot be tricked by embedded whitespace.
+`MlKem768X25519Identity.Unwrap` correctly relies on ML-KEM implicit rejection, matching
+`pq.go:160-162`.
+
+### Not examined
+
+- **`SshRsa*` recipients and identities** were not audited at all.
+- **`Age.Cli` argument handling and file I/O** beyond the `StartsWith` sites and the stdin buffering.
+- **Chunk-counter overflow past 2^88 chunks** — not reachable.
+- **Concurrent use of one `AgeRandomAccess` from multiple threads** — documented as unsupported.
+- **Behaviour when the caller's stream contains data beyond the age file** — documented as
+ unsupported, and `AgeRandomAccess`'s use of `Stream.Length` makes it structurally so.
+- **`docs/`, benchmarks, packaging, CI.**
+
+### Method limits the reader should weigh
+
+- **main has NO async API.** `grep -rn "Async|await |Task<"` over `Age/` and `Age.Cli/` returns zero
+ matches and `PublicAPI.Shipped.txt` has no `Task` entry. There is therefore **no sync/async
+ divergence to hunt on main**, unlike `next-version`. `CopyToAsync` over a `DecryptReader` was
+ verified byte-identical to the sync path; the only consequence of the BCL defaults is
+ sync-over-async thread-pool blocking, which was not filed.
+- **Heap-residue attribution is imperfect.** BouncyCastle's `HMac`/`Sha256Digest` block buffer retains
+ any IKM ≤ 64 bytes, `MLKemPrivateKeyParameters.FromSeed` retains the seed, and the BCL AEAD retains
+ its key. So "this exact array is the one in the dump" is not provable for those; what *is* proved is
+ that the secret survives the operation, and that AgeSharp's own copy is uncleared is certain from
+ reading. Several zeroization fixes therefore **reduce** rather than **eliminate** residue — say so
+ in the changelog.
+- **Heap-sweep technique.** Non-compacting blocking gen2 collections then ~520 MB of
+ `GC.AllocateUninitializedArray(64 KiB)`, with needles pinned alive and two controls per run.
+ An earlier attempt using a **compacting** collection inverted both controls — compaction moves live
+ objects and leaves their bytes behind. Worth knowing if anyone re-runs this. A separate
+ large-object-region-biased scan produced **false negatives** on small gen0 arrays; those negatives
+ are inconclusive, not evidence of absence.
+- **main already passes all 143 CCTV vectors and its 431-test suite.** Neither proves anything new;
+ every finding above came from a size sweep, a differential run against `age`, a fake plugin, a heap
+ probe, or code reading.
+- **The main worktree was not modified.** All fixes were validated in copies
+ (`.../scratchpad/fixwt`, `.../scratchpad/repro`, `.../scratchpad/leakprobe`,
+ `.../scratchpad/pluginprobe-x7`, `.../scratchpad/harness`, `.../scratchpad/verify`).
+ `git status --short` on `main-wt` is empty and it rebuilds with 0 warnings.
+
+### Test-suite changes the backport requires
+
+| File | Line | Change |
+|---|---|---|
+| `Age.Tests/PluginTests.cs` | 313 | Asserts `-> extension-labels` is sent — remove with [S6](#s6). |
+| `Age.Tests/PluginTests.cs` | 542-543 | Asserts the buggy per-stanza FILE_INDEX wire form — update to index `0` on both, with [C5](#c5). |
+| `Age.Tests/UnitTests.cs` | 783 ff. | Only callers of `StreamEncryption`'s whole-stream methods — rewrite if [H8](#h8) is taken. |
+
+And two regression gaps that let defects ship: **no test anywhere asserts caller-stream ownership**
+([C2](#c2)), **no test exercises double-`Dispose`** ([S4](#s4)), and
+`Age.Tests/RandomAccessTests.cs` has **no truncation or tampering test at all**
+([S2](#s2)/[S3](#s3)). Add all three.
diff --git a/docs/BENCHMARKS.md b/docs/BENCHMARKS.md
index 6319fac..71bd1df 100644
--- a/docs/BENCHMARKS.md
+++ b/docs/BENCHMARKS.md
@@ -96,10 +96,29 @@ most allocation is the output `MemoryStream`'s growth, not the crypto path.
### Key Generation
+Both types defer part of the work, in opposite directions, so `Generate()` alone is
+not a comparable number. X25519 does its keygen in `Generate()`; ML-KEM-768-X25519
+fills a 32-byte seed there and runs the ML-KEM keygen on first access to `Recipient`
+(cached thereafter). Measuring only `Generate()` reports post-quantum keygen as eight
+times *faster* than X25519, which is backwards — it simply has not happened yet.
+
+**Secret key to usable public key** — the comparable figure:
+
+| Operation | Time | Allocated |
+|---|---:|---:|
+| X25519 | 28.6 us | 2.1 KB |
+| ML-KEM-768-X25519 | 91.3 us | 28.5 KB |
+
+Post-quantum costs ~3.2x the time and ~14x the memory, which is the expected shape
+for ML-KEM-768 against a curve operation.
+
+**`Generate()` alone**, for reference — useful only when you are generating a secret
+that will be stored and not immediately turned into a recipient:
+
| Operation | Time | Allocated |
|---|---:|---:|
-| X25519 | 1,982 ns | 880 B |
-| ML-KEM-768-X25519 | 251 ns | 88 B |
+| X25519 | 2,150 ns | 880 B |
+| ML-KEM-768-X25519 | 275 ns | 96 B |
### Recipient Wrap / Unwrap