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