Skip to content

Automatic BouncyCastle AEAD fallback for Blazor WASM (#2)#15

Merged
pscheid92 merged 3 commits into
mainfrom
feat/wasm-aead-backend
Jul 23, 2026
Merged

Automatic BouncyCastle AEAD fallback for Blazor WASM (#2)#15
pscheid92 merged 3 commits into
mainfrom
feat/wasm-aead-backend

Conversation

@pscheid92

@pscheid92 pscheid92 commented Jul 23, 2026

Copy link
Copy Markdown
Owner

Makes AgeSharp work in Blazor WebAssembly. System.Security.Cryptography.ChaCha20Poly1305 is [UnsupportedOSPlatform("browser")] — its constructor throws on mono-wasm, and it's the only browser-incompatible primitive (X25519, scrypt, HKDF, ML-KEM, SSH RSA/Ed25519 are already BouncyCastle; SHA/HMAC/RNG are browser-safe). Fixes #2.

Approach

No new public API. Backend selection is fully internal and automatic: the native platform cipher where ChaCha20Poly1305.IsSupported, the managed BouncyCastle cipher otherwise. A browser just works; server/desktop keep the fast, zero-allocation path. (A user-facing override + broader options story is deferred as a separate, deliberate decision.)

Review in two commits

1 — IAeadCipher seam (no behavior change). An internal interface whose method shape mirrors the BCL cipher exactly, a pass-through BclAeadCipher, and an AeadCipher factory. The 4 crypto files change only the cipher parameter/field type and the two construction sites — every method body is untouched, so this commit is behaviorally identical (still 100% native). Safe checkpoint.

2 — the BouncyCastle fallback. The actual feature.

The one file to scrutinize: BouncyCastleAeadCipher

  • MAC size is 128 (bits), not 16 — a wrong value passes local round-trips but breaks interop. Pinned as a constant.
  • Marshals BouncyCastle's contiguous ciphertext‖tag to/from our separate ct+tag spans via a pooled scratch buffer.
  • Translates InvalidCipherTextExceptionAuthenticationTagMismatchException, kept local so it never intercepts BouncyCastle's RSA-OAEP failures (SshRsaIdentity).

Verification

  • Byte-exact cross-compatibility (AeadCipherTests): the two backends produce identical ct‖tag across a size matrix {0,1,63,64,65,100,65536} and each decrypts the other's output — proving the marshaling and the MAC constant. Plus tamper detection and reuse.
  • Whole suite + 143 CCTV wire vectors + live age CLI interop through BouncyCastle via the new test-portable CI job (-p:ForcePortableAead=true, a compile switch in the library — no runtime config, no mutable state). Managed code is OS-independent, so one Linux job suffices.
  • CA1416: verified a browser-targeting consumer compiles clean against Age (the analyzer doesn't descend into our precompiled internal cipher use), so no platform guard is needed.
  • Native default: unchanged, zero warnings. A benchmark (ChaChaImplBenchmarks) quantifies the managed-backend tradeoff (~2× slower, ~336 B/chunk) — which is why native stays the default.

Not covered

An actual in-browser runtime smoke test (no wasm workload in CI yet). The portable-path suite + CCTV + interop is the agreed proxy; a headless-browser lane is a follow-up.

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.
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.
@codecov

codecov Bot commented Jul 23, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 97.87234% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 91.97%. Comparing base (46d06f4) to head (97fe01a).

Files with missing lines Patch % Lines
Age/Crypto/AeadCipher.cs 66.66% 0 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main      #15      +/-   ##
==========================================
+ Coverage   91.87%   91.97%   +0.10%     
==========================================
  Files          38       41       +3     
  Lines        2338     2381      +43     
  Branches      310      312       +2     
==========================================
+ Hits         2148     2190      +42     
  Misses        133      133              
- Partials       57       58       +1     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

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.
@pscheid92
pscheid92 merged commit 60b302c into main Jul 23, 2026
6 checks passed
@pscheid92
pscheid92 deleted the feat/wasm-aead-backend branch July 23, 2026 12:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add WebAssembly (Blazor WASM) support — System.Security.Cryptography.ChaCha20Poly1305 is [UnsupportedOSPlatform("browser")]

1 participant