From f78035bcfabe66b42bd4e693e5030b0ed3da7543 Mon Sep 17 00:00:00 2001 From: Patrick Scheid Date: Thu, 23 Jul 2026 12:49:44 +0200 Subject: [PATCH 1/3] Introduce IAeadCipher seam over ChaCha20-Poly1305 (no behavior change) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Route all ChaCha20-Poly1305 use through an internal IAeadCipher interface whose method shape mirrors System.Security.Cryptography.ChaCha20Poly1305 exactly, plus a BclAeadCipher pass-through and an AeadCipher factory that (for now) always returns the native backend. This is a pure refactor with zero behavioral change — the only edits to CryptoHelper/StreamEncryption/EncryptStream/DecryptStream are the cipher parameter/field type and the two construction sites. It sets up the seam so a managed (BouncyCastle) backend can be substituted on browser/ WASM, where the platform cipher is unavailable. Phase A of #2. --- Age/Crypto/AeadCipher.cs | 10 ++++++++++ Age/Crypto/BclAeadCipher.cs | 24 ++++++++++++++++++++++++ Age/Crypto/CryptoHelper.cs | 8 ++++---- Age/Crypto/DecryptStream.cs | 2 +- Age/Crypto/EncryptStream.cs | 2 +- Age/Crypto/IAeadCipher.cs | 26 ++++++++++++++++++++++++++ Age/Crypto/StreamEncryption.cs | 5 ++--- 7 files changed, 68 insertions(+), 9 deletions(-) create mode 100644 Age/Crypto/AeadCipher.cs create mode 100644 Age/Crypto/BclAeadCipher.cs create mode 100644 Age/Crypto/IAeadCipher.cs diff --git a/Age/Crypto/AeadCipher.cs b/Age/Crypto/AeadCipher.cs new file mode 100644 index 0000000..f0a946a --- /dev/null +++ b/Age/Crypto/AeadCipher.cs @@ -0,0 +1,10 @@ +namespace Age.Crypto; + +/// +/// Factory for instances. Phase A always returns the native +/// backend; the backend selection (native vs. portable) is added in Phase B. +/// +internal static class AeadCipher +{ + public static IAeadCipher Create(ReadOnlySpan key) => new BclAeadCipher(key); +} diff --git a/Age/Crypto/BclAeadCipher.cs b/Age/Crypto/BclAeadCipher.cs new file mode 100644 index 0000000..ee7ef8c --- /dev/null +++ b/Age/Crypto/BclAeadCipher.cs @@ -0,0 +1,24 @@ +using System.Security.Cryptography; + +namespace Age.Crypto; + +/// +/// backed by the platform — +/// native, hardware-optimized, and zero per-call allocation. Unavailable on browser/WASM +/// (the constructor throws there); callers select it only when +/// is true. +/// +internal sealed class BclAeadCipher : IAeadCipher +{ + private readonly ChaCha20Poly1305 _cipher; + + public BclAeadCipher(ReadOnlySpan key) => _cipher = new ChaCha20Poly1305(key); + + public void Encrypt(ReadOnlySpan nonce, ReadOnlySpan plaintext, Span ciphertext, Span tag) => + _cipher.Encrypt(nonce, plaintext, ciphertext, tag); + + public void Decrypt(ReadOnlySpan nonce, ReadOnlySpan ciphertext, ReadOnlySpan tag, Span plaintext) => + _cipher.Decrypt(nonce, ciphertext, tag, plaintext); + + public void Dispose() => _cipher.Dispose(); +} diff --git a/Age/Crypto/CryptoHelper.cs b/Age/Crypto/CryptoHelper.cs index 93b6ed3..9511ca8 100644 --- a/Age/Crypto/CryptoHelper.cs +++ b/Age/Crypto/CryptoHelper.cs @@ -26,7 +26,7 @@ public static byte[] HkdfDerive(ReadOnlySpan ikm, ReadOnlySpan salt, return result; } - public static void ChaChaEncrypt(ChaCha20Poly1305 cipher, ReadOnlySpan nonce, + public static void ChaChaEncrypt(IAeadCipher cipher, ReadOnlySpan nonce, ReadOnlySpan plaintext, Span ciphertextWithTag) { cipher.Encrypt(nonce, plaintext, @@ -37,7 +37,7 @@ public static void ChaChaEncrypt(ChaCha20Poly1305 cipher, ReadOnlySpan non public static void ChaChaEncrypt(ReadOnlySpan key, ReadOnlySpan nonce, ReadOnlySpan plaintext, Span ciphertextWithTag) { - using var cipher = new ChaCha20Poly1305(key); + using var cipher = AeadCipher.Create(key); ChaChaEncrypt(cipher, nonce, plaintext, ciphertextWithTag); } @@ -48,7 +48,7 @@ public static byte[] ChaChaEncrypt(ReadOnlySpan key, ReadOnlySpan no return output; } - public static bool ChaChaDecrypt(ChaCha20Poly1305 cipher, ReadOnlySpan nonce, + public static bool ChaChaDecrypt(IAeadCipher cipher, ReadOnlySpan nonce, ReadOnlySpan ciphertextWithTag, Span plaintext) { if (ciphertextWithTag.Length < ChaChaTagSize) @@ -74,7 +74,7 @@ public static bool ChaChaDecrypt(ChaCha20Poly1305 cipher, ReadOnlySpan non public static bool ChaChaDecrypt(ReadOnlySpan key, ReadOnlySpan nonce, ReadOnlySpan ciphertextWithTag, Span plaintext) { - using var cipher = new ChaCha20Poly1305(key); + using var cipher = AeadCipher.Create(key); return ChaChaDecrypt(cipher, nonce, ciphertextWithTag, plaintext); } diff --git a/Age/Crypto/DecryptStream.cs b/Age/Crypto/DecryptStream.cs index f7f4c7d..4d8f64e 100644 --- a/Age/Crypto/DecryptStream.cs +++ b/Age/Crypto/DecryptStream.cs @@ -19,7 +19,7 @@ private enum State // 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 ChaCha20Poly1305 _cipher = new(payloadKey); + private readonly IAeadCipher _cipher = AeadCipher.Create(payloadKey); private int _plaintextLength; private int _plaintextOffset; private long _counter; diff --git a/Age/Crypto/EncryptStream.cs b/Age/Crypto/EncryptStream.cs index d1485a4..3144352 100644 --- a/Age/Crypto/EncryptStream.cs +++ b/Age/Crypto/EncryptStream.cs @@ -22,7 +22,7 @@ private enum State // 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 ChaCha20Poly1305 _cipher = new(payloadKey); + private readonly IAeadCipher _cipher = AeadCipher.Create(payloadKey); private int _chunkLength; private int _chunkOffset; private long _counter; diff --git a/Age/Crypto/IAeadCipher.cs b/Age/Crypto/IAeadCipher.cs new file mode 100644 index 0000000..f9863f5 --- /dev/null +++ b/Age/Crypto/IAeadCipher.cs @@ -0,0 +1,26 @@ +namespace Age.Crypto; + +/// +/// Minimal ChaCha20-Poly1305 AEAD abstraction. The method shapes mirror +/// exactly, so the native +/// wrapper is a pure pass-through and a managed implementation can be substituted on +/// platforms — notably browser/WebAssembly — where the platform cipher is unavailable. +/// Instances are not thread-safe: a single instance is used sequentially, like the +/// platform cipher. +/// +internal interface IAeadCipher : IDisposable +{ + /// + /// Encrypts into and writes + /// the 16-byte authentication tag to . + /// + void Encrypt(ReadOnlySpan nonce, ReadOnlySpan plaintext, Span ciphertext, Span tag); + + /// + /// Decrypts into , verifying + /// . Throws + /// when + /// authentication fails. + /// + void Decrypt(ReadOnlySpan nonce, ReadOnlySpan ciphertext, ReadOnlySpan tag, Span plaintext); +} diff --git a/Age/Crypto/StreamEncryption.cs b/Age/Crypto/StreamEncryption.cs index 31f2b69..5d0197a 100644 --- a/Age/Crypto/StreamEncryption.cs +++ b/Age/Crypto/StreamEncryption.cs @@ -1,5 +1,4 @@ using System.Buffers.Binary; -using System.Security.Cryptography; namespace Age.Crypto; @@ -86,7 +85,7 @@ private static (int ChunkLen, bool IsFinal) NextChunk(ReadOnlySpan data, i : throw new AgePayloadException("chunk too small for authentication tag"); } - internal static void EncryptChunk(ChaCha20Poly1305 cipher, long counter, bool isFinal, + internal static void EncryptChunk(IAeadCipher cipher, long counter, bool isFinal, ReadOnlySpan plaintext, Span ciphertextWithTag) { Span nonce = stackalloc byte[NonceSize]; @@ -109,7 +108,7 @@ internal static byte[] EncryptChunk(ReadOnlySpan payloadKey, long counter, return output; } - internal static void DecryptChunk(ChaCha20Poly1305 cipher, long counter, bool isFinal, + internal static void DecryptChunk(IAeadCipher cipher, long counter, bool isFinal, ReadOnlySpan ciphertext, Span plaintext) { Span nonce = stackalloc byte[NonceSize]; From 54e194f46d8d0d3d0cd02d690d130cad39b128b8 Mon Sep 17 00:00:00 2001 From: Patrick Scheid Date: Thu, 23 Jul 2026 13:00:48 +0200 Subject: [PATCH 2/3] Add automatic BouncyCastle AEAD fallback for Blazor WASM (#2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit System.Security.Cryptography.ChaCha20Poly1305 is [UnsupportedOSPlatform("browser")] — its constructor throws on mono-wasm, which is the only thing that made AgeSharp unusable in Blazor WebAssembly (every other primitive is already BouncyCastle or browser-safe). - Add BouncyCastleAeadCipher (managed ChaCha20-Poly1305) behind the IAeadCipher seam, marshaling BC's contiguous ct||tag to/from the split ct+tag spans and translating InvalidCipherTextException to AuthenticationTagMismatchException (kept local so it never intercepts BC's RSA-OAEP failures). - AeadCipher.Create picks the native cipher where ChaCha20Poly1305.IsSupported and the managed one otherwise — so the browser works with no configuration and server/desktop keep the fast, zero-allocation path. The backend selection is fully internal: no public configuration surface is added. A user-facing override is deferred as a separate, deliberate decision. - Tests: byte-exact cross-compatibility between the two backends across a size matrix (proves the marshaling and the 128-bit MAC constant), tag/ciphertext tamper detection, reuse, and both backends round-trip. A compile-time FORCE_PORTABLE_AEAD switch (-p:ForcePortableAead=true) builds the library with the managed backend as default so the whole suite + 143 CCTV vectors run through BouncyCastle, wired as a test-portable CI job. A benchmark quantifies the tradeoff (~2x slower, ~336 B/chunk). Phase B of #2. Fixes #2. --- .github/workflows/test.yml | 24 ++++ Age.Benchmarks/ChaChaImplBenchmarks.cs | 86 ++++++++++++++ Age.Tests/AeadCipherTests.cs | 151 +++++++++++++++++++++++++ Age/Age.csproj | 7 ++ Age/Crypto/AeadBackend.cs | 20 ++++ Age/Crypto/AeadCipher.cs | 27 ++++- Age/Crypto/BouncyCastleAeadCipher.cs | 83 ++++++++++++++ README.md | 3 + docs/BENCHMARKS.md | 4 +- 9 files changed, 401 insertions(+), 4 deletions(-) create mode 100644 Age.Benchmarks/ChaChaImplBenchmarks.cs create mode 100644 Age.Tests/AeadCipherTests.cs create mode 100644 Age/Crypto/AeadBackend.cs create mode 100644 Age/Crypto/BouncyCastleAeadCipher.cs diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e3c1f20..7d56e81 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -65,3 +65,27 @@ jobs: uses: codecov/codecov-action@v5 with: token: ${{ secrets.CODECOV_TOKEN }} + + # 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. + test-portable: + runs-on: ubuntu-latest + + env: + AGE_VERSION: "1.3.1" + + steps: + - uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 10.0.x + + - name: Install age CLI + run: | + curl -sSL "https://github.com/FiloSottile/age/releases/download/v${AGE_VERSION}/age-v${AGE_VERSION}-linux-amd64.tar.gz" | tar -xz -C "$RUNNER_TEMP" + echo "$RUNNER_TEMP/age" >> "$GITHUB_PATH" + + - name: Test (portable / BouncyCastle backend) + run: dotnet test -c Release -p:ForcePortableAead=true diff --git a/Age.Benchmarks/ChaChaImplBenchmarks.cs b/Age.Benchmarks/ChaChaImplBenchmarks.cs new file mode 100644 index 0000000..fed79a1 --- /dev/null +++ b/Age.Benchmarks/ChaChaImplBenchmarks.cs @@ -0,0 +1,86 @@ +using System.Buffers.Binary; +using System.Security.Cryptography; +using BenchmarkDotNet.Attributes; +using Org.BouncyCastle.Crypto.Parameters; +using BclChaCha = System.Security.Cryptography.ChaCha20Poly1305; +using BcChaCha = Org.BouncyCastle.Crypto.Modes.ChaCha20Poly1305; + +namespace Age.Benchmarks; + +// Compares the platform (BCL) ChaCha20-Poly1305 against BouncyCastle's managed one +// in AgeSharp's streaming hot-path pattern: construct once, encrypt per 64 KiB chunk. +// The question this answers: does moving the payload AEAD to BouncyCastle (to gain +// browser support) cost meaningful throughput and/or reintroduce per-chunk allocations? +[ShortRunJob] +[MemoryDiagnoser] +public class ChaChaImplBenchmarks +{ + private const int ChunkSize = 64 * 1024; // age STREAM chunk size + private const int TagSize = 16; + private const int NonceSize = 12; + + [Params(65_536, 1_048_576, 16_777_216)] // 64 KiB, 1 MiB, 16 MiB payloads + public int PayloadSize; + + private byte[] _key = null!; + private byte[] _plaintext = null!; + private byte[] _output = null!; + private byte[] _nonce = null!; + + private BclChaCha _bcl = null!; + private BcChaCha _bc = null!; + private KeyParameter _bcKey = null!; + + [GlobalSetup] + public void Setup() + { + _key = new byte[32]; + RandomNumberGenerator.Fill(_key); + _plaintext = new byte[PayloadSize]; + RandomNumberGenerator.Fill(_plaintext); + _output = new byte[ChunkSize + TagSize]; + _nonce = new byte[NonceSize]; + + _bcl = new BclChaCha(_key); + _bc = new BcChaCha(); + _bcKey = new KeyParameter(_key); + } + + [GlobalCleanup] + public void Cleanup() => _bcl.Dispose(); + + // Platform ChaCha20-Poly1305: span-based one-shot per chunk, no per-chunk allocation. + [Benchmark(Baseline = true)] + public void Bcl() + { + long counter = 0; + for (var offset = 0; offset < _plaintext.Length; offset += ChunkSize, counter++) + { + var len = Math.Min(ChunkSize, _plaintext.Length - offset); + WriteNonce(_nonce, counter); + _bcl.Encrypt(_nonce, _plaintext.AsSpan(offset, len), + _output.AsSpan(0, len), _output.AsSpan(len, TagSize)); + } + } + + // BouncyCastle: the idiomatic AEAD flow, Init with fresh AeadParameters per chunk. + [Benchmark] + public void BouncyCastle() + { + long counter = 0; + for (var offset = 0; offset < _plaintext.Length; offset += ChunkSize, counter++) + { + var len = Math.Min(ChunkSize, _plaintext.Length - offset); + WriteNonce(_nonce, counter); + _bc.Init(true, new AeadParameters(_bcKey, TagSize * 8, _nonce)); + var written = _bc.ProcessBytes(_plaintext, offset, len, _output, 0); + _bc.DoFinal(_output, written); + } + } + + private static void WriteNonce(byte[] nonce, long counter) + { + Array.Clear(nonce); + BinaryPrimitives.WriteInt64BigEndian(nonce.AsSpan(NonceSize - 8), counter); + } +} diff --git a/Age.Tests/AeadCipherTests.cs b/Age.Tests/AeadCipherTests.cs new file mode 100644 index 0000000..4d9eaf1 --- /dev/null +++ b/Age.Tests/AeadCipherTests.cs @@ -0,0 +1,151 @@ +using System.Security.Cryptography; +using Age.Crypto; +using Xunit; + +namespace Age.Tests; + +public class AeadCipherTests +{ + // Empty final chunk, sub-block, ChaCha block boundary, and full age chunk size. + private static readonly int[] Sizes = [0, 1, 63, 64, 65, 100, 65536]; + + private const int TagSize = 16; + + // AeadBackend is internal, so tests parametrize by a public bool and map inside the body. + private static IAeadCipher Create(bool portable, ReadOnlySpan key) => + AeadCipher.Create(key, portable ? AeadBackend.Portable : AeadBackend.Native); + + private static (byte[] key, byte[] nonce, byte[] plaintext) Material(int size) + { + var key = new byte[32]; + var nonce = new byte[12]; + var plaintext = new byte[size]; + RandomNumberGenerator.Fill(key); + RandomNumberGenerator.Fill(nonce); + RandomNumberGenerator.Fill(plaintext); + return (key, nonce, plaintext); + } + + [Theory] + [InlineData(false)] // native + [InlineData(true)] // portable + public void RoundTrip_AllSizes(bool portable) + { + foreach (var size in Sizes) + { + var (key, nonce, plaintext) = Material(size); + var ciphertext = new byte[size]; + var tag = new byte[TagSize]; + + using (var enc = Create(portable, key)) + enc.Encrypt(nonce, plaintext, ciphertext, tag); + + var decrypted = new byte[size]; + using (var dec = Create(portable, key)) + dec.Decrypt(nonce, ciphertext, tag, decrypted); + + Assert.Equal(plaintext, decrypted); + } + } + + // The highest-value test: the two backends must be wire-identical. If this passes, every + // code path that works with the native cipher works with BouncyCastle. It also pins the + // MAC-size-in-bits constant (a wrong value produces a different tag). + [Fact] + public void Backends_ProduceByteIdenticalOutput_AndCrossDecrypt() + { + foreach (var size in Sizes) + { + var (key, nonce, plaintext) = Material(size); + + var bclCt = new byte[size]; + var bclTag = new byte[TagSize]; + using (var bcl = new BclAeadCipher(key)) + bcl.Encrypt(nonce, plaintext, bclCt, bclTag); + + var bcCt = new byte[size]; + var bcTag = new byte[TagSize]; + using (var bc = new BouncyCastleAeadCipher(key)) + bc.Encrypt(nonce, plaintext, bcCt, bcTag); + + Assert.Equal(bclCt, bcCt); + Assert.Equal(bclTag, bcTag); + + // Each backend decrypts the other's output. + var viaBc = new byte[size]; + using (var bc = new BouncyCastleAeadCipher(key)) + bc.Decrypt(nonce, bclCt, bclTag, viaBc); + Assert.Equal(plaintext, viaBc); + + var viaBcl = new byte[size]; + using (var bcl = new BclAeadCipher(key)) + bcl.Decrypt(nonce, bcCt, bcTag, viaBcl); + Assert.Equal(plaintext, viaBcl); + } + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void TamperedTag_Throws(bool portable) + { + var (key, nonce, plaintext) = Material(100); + var ciphertext = new byte[plaintext.Length]; + var tag = new byte[TagSize]; + using (var enc = Create(portable, key)) + enc.Encrypt(nonce, plaintext, ciphertext, tag); + + tag[0] ^= 0xFF; + + using var dec = Create(portable, key); + Assert.Throws( + () => dec.Decrypt(nonce, ciphertext, tag, new byte[plaintext.Length])); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void TamperedCiphertext_Throws(bool portable) + { + var (key, nonce, plaintext) = Material(100); + var ciphertext = new byte[plaintext.Length]; + var tag = new byte[TagSize]; + using (var enc = Create(portable, key)) + enc.Encrypt(nonce, plaintext, ciphertext, tag); + + ciphertext[0] ^= 0xFF; + + using var dec = Create(portable, key); + Assert.Throws( + () => dec.Decrypt(nonce, ciphertext, tag, new byte[plaintext.Length])); + } + + // Mirrors the streaming path: one cipher instance reused for many chunks with distinct nonces. + [Theory] + [InlineData(false)] + [InlineData(true)] + public void ReuseAcrossChunks(bool portable) + { + var key = new byte[32]; + RandomNumberGenerator.Fill(key); + + using var enc = Create(portable, key); + using var dec = Create(portable, key); + + for (var i = 0; i < 5; i++) + { + var nonce = new byte[12]; + nonce[0] = (byte)i; + var plaintext = new byte[1000 + i]; + RandomNumberGenerator.Fill(plaintext); + + var ciphertext = new byte[plaintext.Length]; + var tag = new byte[TagSize]; + enc.Encrypt(nonce, plaintext, ciphertext, tag); + + var decrypted = new byte[plaintext.Length]; + dec.Decrypt(nonce, ciphertext, tag, decrypted); + Assert.Equal(plaintext, decrypted); + } + } +} diff --git a/Age/Age.csproj b/Age/Age.csproj index fcd952c..6d94443 100644 --- a/Age/Age.csproj +++ b/Age/Age.csproj @@ -16,6 +16,13 @@ age;encryption;cryptography + + + $(DefineConstants);FORCE_PORTABLE_AEAD + + diff --git a/Age/Crypto/AeadBackend.cs b/Age/Crypto/AeadBackend.cs new file mode 100644 index 0000000..e742928 --- /dev/null +++ b/Age/Crypto/AeadBackend.cs @@ -0,0 +1,20 @@ +namespace Age.Crypto; + +/// +/// Selects a ChaCha20-Poly1305 implementation. Internal: the backend is chosen automatically +/// per platform; there is no public configuration surface. +/// +internal enum AeadBackend +{ + /// + /// The platform — native, fast, + /// zero per-chunk allocation. Unavailable on browser/WebAssembly. + /// + Native, + + /// + /// The managed BouncyCastle ChaCha20-Poly1305. Works on every platform including the browser, + /// at lower throughput and with a small per-chunk allocation. + /// + Portable, +} diff --git a/Age/Crypto/AeadCipher.cs b/Age/Crypto/AeadCipher.cs index f0a946a..5cb4047 100644 --- a/Age/Crypto/AeadCipher.cs +++ b/Age/Crypto/AeadCipher.cs @@ -1,10 +1,31 @@ +using System.Security.Cryptography; + namespace Age.Crypto; /// -/// Factory for instances. Phase A always returns the native -/// backend; the backend selection (native vs. portable) is added in Phase B. +/// Factory for instances. Uses the native platform cipher where it is +/// supported and the managed BouncyCastle cipher otherwise — so browser/WebAssembly works with +/// no configuration and server/desktop keep the fast, zero-allocation path. /// internal static class AeadCipher { - public static IAeadCipher Create(ReadOnlySpan key) => new BclAeadCipher(key); + /// Creates a cipher using the default backend for the current platform. + public static IAeadCipher Create(ReadOnlySpan key) => Create(key, DefaultBackend()); + + /// + /// Creates a cipher using an explicit backend. Used by tests to exercise a specific + /// implementation regardless of the platform default. + /// + internal static IAeadCipher Create(ReadOnlySpan key, AeadBackend backend) => + backend == AeadBackend.Portable ? new BouncyCastleAeadCipher(key) : new BclAeadCipher(key); + + private static AeadBackend DefaultBackend() => +#if FORCE_PORTABLE_AEAD + // Test hook (`dotnet test -p:ForcePortableAead=true`): force the managed backend so the + // whole suite and the CCTV vectors exercise the browser/WASM path. Never defined in a + // normal or packaged build. + AeadBackend.Portable; +#else + ChaCha20Poly1305.IsSupported ? AeadBackend.Native : AeadBackend.Portable; +#endif } diff --git a/Age/Crypto/BouncyCastleAeadCipher.cs b/Age/Crypto/BouncyCastleAeadCipher.cs new file mode 100644 index 0000000..7580e69 --- /dev/null +++ b/Age/Crypto/BouncyCastleAeadCipher.cs @@ -0,0 +1,83 @@ +using System.Buffers; +using System.Security.Cryptography; +using Org.BouncyCastle.Crypto; +using Org.BouncyCastle.Crypto.Parameters; +using BcChaCha20Poly1305 = Org.BouncyCastle.Crypto.Modes.ChaCha20Poly1305; + +namespace Age.Crypto; + +/// +/// backed by BouncyCastle's managed ChaCha20-Poly1305. Works on +/// every platform including browser/WebAssembly, at the cost of ~2x lower throughput and a +/// small per-call allocation versus the native cipher. Selected automatically when the +/// platform cipher is unavailable, or explicitly via . +/// +/// +/// BouncyCastle's holds an internal copy of the key that has no +/// public zeroing path, so — unlike the native cipher — the key material lingers until GC. +/// This is inherent to BouncyCastle (see the same limitation noted for RSA in +/// SshRsaIdentity) and only applies to the opt-in portable backend. +/// +internal sealed class BouncyCastleAeadCipher : IAeadCipher +{ + private const int TagSize = 16; + private const int MacSizeBits = TagSize * 8; // BouncyCastle expects the MAC size in BITS (128), not bytes + + private readonly BcChaCha20Poly1305 _cipher = new(); + private readonly KeyParameter _key; + + public BouncyCastleAeadCipher(ReadOnlySpan key) => _key = new KeyParameter(key.ToArray()); + + public void Encrypt(ReadOnlySpan nonce, ReadOnlySpan plaintext, Span ciphertext, Span tag) + { + _cipher.Init(true, new AeadParameters(_key, MacSizeBits, nonce.ToArray())); + + // BouncyCastle emits ciphertext||tag contiguously; use a rented scratch and split. + var scratch = ArrayPool.Shared.Rent(plaintext.Length + TagSize); + try + { + var written = _cipher.ProcessBytes(plaintext, scratch); + written += _cipher.DoFinal(scratch.AsSpan(written)); + scratch.AsSpan(0, plaintext.Length).CopyTo(ciphertext); + scratch.AsSpan(plaintext.Length, TagSize).CopyTo(tag); + } + finally + { + ArrayPool.Shared.Return(scratch, clearArray: true); + } + } + + public void Decrypt(ReadOnlySpan nonce, ReadOnlySpan ciphertext, ReadOnlySpan tag, Span plaintext) + { + _cipher.Init(false, new AeadParameters(_key, MacSizeBits, nonce.ToArray())); + + // BouncyCastle expects ciphertext||tag contiguously as input. + var input = ArrayPool.Shared.Rent(ciphertext.Length + TagSize); + var output = ArrayPool.Shared.Rent(ciphertext.Length); + try + { + ciphertext.CopyTo(input); + tag.CopyTo(input.AsSpan(ciphertext.Length)); + + var written = _cipher.ProcessBytes(input.AsSpan(0, ciphertext.Length + TagSize), output); + written += _cipher.DoFinal(output.AsSpan(written)); // throws InvalidCipherTextException on tag mismatch + output.AsSpan(0, plaintext.Length).CopyTo(plaintext); + } + catch (InvalidCipherTextException) + { + // Translate to the BCL contract so CryptoHelper's existing catch continues to work. + // Kept local to this method so it never intercepts BouncyCastle's RSA-OAEP failures. + throw new AuthenticationTagMismatchException(); + } + finally + { + ArrayPool.Shared.Return(input, clearArray: true); + ArrayPool.Shared.Return(output, clearArray: true); + } + } + + public void Dispose() + { + // No native/unmanaged state to release. The KeyParameter key copy cannot be zeroed (see remarks). + } +} diff --git a/README.md b/README.md index eee7b92..e1b6216 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,9 @@ and targets .NET 10. - Encrypted identity files (passphrase-protected) - Recipients file parsing (`-R` style files with comments) - Fully interoperable — files produced by AgeSharp decrypt with `age`, `rage`, and vice versa +- **Runs in Blazor WebAssembly** — automatically uses a managed + ChaCha20-Poly1305 backend in the browser, where the platform cipher is + unavailable ## Installation diff --git a/docs/BENCHMARKS.md b/docs/BENCHMARKS.md index 1fba0ae..887a8ff 100644 --- a/docs/BENCHMARKS.md +++ b/docs/BENCHMARKS.md @@ -52,7 +52,9 @@ All times in milliseconds (lower is better). `EncryptDetached`, `DecryptDetached`) stream chunk-by-chunk with pooled, fixed-size scratch buffers — no per-chunk heap allocations. Memory stays O(1) regardless of input size; a 1 GiB file uses the same working set - as a 1 MB file. + as a 1 MB file. This applies to the native ChaCha20-Poly1305 backend; the + managed BouncyCastle backend used automatically in the browser is ~2× + slower and allocates a small, constant amount per chunk. - **Startup**: The AOT-compiled AgeSharp binary starts in ~21 ms, comparable to native Go and Rust binaries. From 97fe01a4282c2c1b9f7f0bc210914673e6090091 Mon Sep 17 00:00:00 2001 From: Patrick Scheid Date: Thu, 23 Jul 2026 14:00:32 +0200 Subject: [PATCH 3/3] Assert the portable test pass actually selects BouncyCastle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The test-portable job runs the whole suite with the compile switch that flips the default AEAD backend to BouncyCastle — but because the two backends are wire-identical, every test passes on native too. So if the switch ever silently failed to engage, the job would run on native and still go green: a false 'portable' pass. Add DefaultBackend_MatchesBuildConfiguration, which asserts AeadCipher's default is BouncyCastleAeadCipher under FORCE_PORTABLE_AEAD and the native cipher otherwise. The same test asserting different types in the two builds makes the switch self-verifying: a broken switch now fails the job loudly. --- Age.Tests/AeadCipherTests.cs | 14 ++++++++++++++ Age.Tests/Age.Tests.csproj | 6 ++++++ 2 files changed, 20 insertions(+) diff --git a/Age.Tests/AeadCipherTests.cs b/Age.Tests/AeadCipherTests.cs index 4d9eaf1..3d692de 100644 --- a/Age.Tests/AeadCipherTests.cs +++ b/Age.Tests/AeadCipherTests.cs @@ -48,6 +48,20 @@ public void RoundTrip_AllSizes(bool portable) } } + // Guards the portable CI pass: proves the compile switch actually flips the *default* + // backend, so a whole-suite "portable" run can never silently execute on native and pass + // anyway. Without this, that job trusts the build machinery with nothing asserting it. + [Fact] + public void DefaultBackend_MatchesBuildConfiguration() + { + using var cipher = AeadCipher.Create(new byte[32]); +#if FORCE_PORTABLE_AEAD + Assert.IsType(cipher); +#else + Assert.IsType(ChaCha20Poly1305.IsSupported ? typeof(BclAeadCipher) : typeof(BouncyCastleAeadCipher), cipher); +#endif + } + // The highest-value test: the two backends must be wire-identical. If this passes, every // code path that works with the native cipher works with BouncyCastle. It also pins the // MAC-size-in-bits constant (a wrong value produces a different tag). diff --git a/Age.Tests/Age.Tests.csproj b/Age.Tests/Age.Tests.csproj index 07eae54..bac226b 100644 --- a/Age.Tests/Age.Tests.csproj +++ b/Age.Tests/Age.Tests.csproj @@ -9,6 +9,12 @@ true + + + $(DefineConstants);FORCE_PORTABLE_AEAD + + runtime; build; native; contentfiles; analyzers; buildtransitive