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