Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
86 changes: 86 additions & 0 deletions Age.Benchmarks/ChaChaImplBenchmarks.cs
Original file line number Diff line number Diff line change
@@ -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);
}
}
165 changes: 165 additions & 0 deletions Age.Tests/AeadCipherTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
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<byte> 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);
}
}

// 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<BouncyCastleAeadCipher>(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).
[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<AuthenticationTagMismatchException>(
() => 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<AuthenticationTagMismatchException>(
() => 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);
}
}
}
6 changes: 6 additions & 0 deletions Age.Tests/Age.Tests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,12 @@
<IsTestProject>true</IsTestProject>
</PropertyGroup>

<!-- Mirror the library's FORCE_PORTABLE_AEAD switch so DefaultBackend_MatchesBuildConfiguration
can assert the portable pass actually selected BouncyCastle. -->
<PropertyGroup Condition="'$(ForcePortableAead)' == 'true'">
<DefineConstants>$(DefineConstants);FORCE_PORTABLE_AEAD</DefineConstants>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="coverlet.collector" Version="8.0.0">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
Expand Down
7 changes: 7 additions & 0 deletions Age/Age.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,13 @@
<PackageTags>age;encryption;cryptography</PackageTags>
</PropertyGroup>

<!-- Test hook: `dotnet test -p:ForcePortableAead=true` builds the library with the managed
ChaCha20-Poly1305 backend as the default, so the suite exercises the browser/WASM path.
Never defined in a normal or packaged build. -->
<PropertyGroup Condition="'$(ForcePortableAead)' == 'true'">
<DefineConstants>$(DefineConstants);FORCE_PORTABLE_AEAD</DefineConstants>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="BouncyCastle.Cryptography" Version="2.6.2" />
</ItemGroup>
Expand Down
20 changes: 20 additions & 0 deletions Age/Crypto/AeadBackend.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
namespace Age.Crypto;

/// <summary>
/// Selects a ChaCha20-Poly1305 implementation. Internal: the backend is chosen automatically
/// per platform; there is no public configuration surface.
/// </summary>
internal enum AeadBackend
{
/// <summary>
/// The platform <see cref="System.Security.Cryptography.ChaCha20Poly1305"/> — native, fast,
/// zero per-chunk allocation. Unavailable on browser/WebAssembly.
/// </summary>
Native,

/// <summary>
/// The managed BouncyCastle ChaCha20-Poly1305. Works on every platform including the browser,
/// at lower throughput and with a small per-chunk allocation.
/// </summary>
Portable,
}
31 changes: 31 additions & 0 deletions Age/Crypto/AeadCipher.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
using System.Security.Cryptography;

namespace Age.Crypto;

/// <summary>
/// Factory for <see cref="IAeadCipher"/> 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.
/// </summary>
internal static class AeadCipher
{
/// <summary>Creates a cipher using the default backend for the current platform.</summary>
public static IAeadCipher Create(ReadOnlySpan<byte> key) => Create(key, DefaultBackend());

/// <summary>
/// Creates a cipher using an explicit backend. Used by tests to exercise a specific
/// implementation regardless of the platform default.
/// </summary>
internal static IAeadCipher Create(ReadOnlySpan<byte> 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
}
24 changes: 24 additions & 0 deletions Age/Crypto/BclAeadCipher.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
using System.Security.Cryptography;

namespace Age.Crypto;

/// <summary>
/// <see cref="IAeadCipher"/> backed by the platform <see cref="ChaCha20Poly1305"/> —
/// native, hardware-optimized, and zero per-call allocation. Unavailable on browser/WASM
/// (the constructor throws there); callers select it only when
/// <see cref="ChaCha20Poly1305.IsSupported"/> is true.
/// </summary>
internal sealed class BclAeadCipher : IAeadCipher
{
private readonly ChaCha20Poly1305 _cipher;

public BclAeadCipher(ReadOnlySpan<byte> key) => _cipher = new ChaCha20Poly1305(key);

public void Encrypt(ReadOnlySpan<byte> nonce, ReadOnlySpan<byte> plaintext, Span<byte> ciphertext, Span<byte> tag) =>
_cipher.Encrypt(nonce, plaintext, ciphertext, tag);

public void Decrypt(ReadOnlySpan<byte> nonce, ReadOnlySpan<byte> ciphertext, ReadOnlySpan<byte> tag, Span<byte> plaintext) =>
_cipher.Decrypt(nonce, ciphertext, tag, plaintext);

public void Dispose() => _cipher.Dispose();
}
Loading
Loading