Skip to content

Obfuscator async stream methods read the source synchronously, so TryObfuscateAsync throws on ASP.NET Core request bodies, even for Hex/Base64 whose encoders are already async #172

Open

Description

@matt-edmondson

What's wrong

Every stream async member of IObfuscationProvider is still a Task.Run wrapper over the synchronous stream path (IObfuscationProvider.cs#L100-L129, #L204-L233), and no obfuscator declares its own async primitives. So TryObfuscateAsync(Stream, Stream) ends up calling Stream.Read on a pool thread.

HexObfuscationProvider and Base64ObfuscationProvider show this most clearly. They wrap HexEncodingProvider and Base64EncodingProvider, whose TryEncodeAsync and TryDecodeAsync(Stream, Stream) have been genuinely async since PR #22. The obfuscators forward only the synchronous TryEncode(Stream, Stream), so their async path never reaches the async encoder.

Failure scenario

An ASP.NET Core endpoint obfuscates the request body:

await obfuscator.TryObfuscateAsync(Request.Body, Response.Body, ct);

Kestrel disallows synchronous I/O by default, so this throws InvalidOperationException: Synchronous operations are disallowed. HexEncodingProvider.TryEncodeAsync succeeds on the same stream. With a stream that throws NotSupportedException from its synchronous members, such as the repo's own AsyncOnlyStream test double, the obfuscators catch the exception and return false without saying why.

Repro at 89fc373, using a stream whose ReadAsync works and whose Read throws as Kestrel does:

HexEncodingProvider.TryEncodeAsync: True -> '68656C6C6F'
HexObfuscationProvider.TryObfuscateAsync: threw InvalidOperationException: Synchronous operations are disallowed.
Base64ObfuscationProvider.TryObfuscateAsync: threw InvalidOperationException: Synchronous operations are disallowed.
XorObfuscationProvider.TryObfuscateAsync: threw InvalidOperationException: Synchronous operations are disallowed.

Why this is separate from #8

The async-surface spec left obfuscation out because no single generic default suits Hex, Reverse and Base64. That argument is against one shared default. It doesn't stop each provider declaring its own async primitives, which is what the encoders, compressors and AES have done since. StreamChunks already provides the chunk loop that Xor, Caesar and BitRotate share. The change adds API without breaking any, and doesn't depend on #8's remaining ValueTask decision.

Suggested fix

  1. Make the derived stream defaults on IObfuscationProvider (TryObfuscateAsync(ReadOnlyMemory<byte>, Stream), ObfuscateAsync(Stream) and their deobfuscate counterparts) compose over the async primitives, as IEncodingProvider already does. A provider that declares the two Try…Async(Stream, Stream) primitives then converts all six members.
  2. Hex and Base64 obfuscators: forward TryObfuscateAsync and TryDeobfuscateAsync(Stream, Stream, ct) to _encoder.TryEncodeAsync and _encoder.TryDecodeAsync.
  3. Xor, Caesar and BitRotate: add a StreamChunks.ForEachAsync that uses ReadAsync/WriteAsync over the same pooled buffer, and declare the async primitives with it. Xor carries its key offset across chunks, as the sync path does.
  4. Reverse and Composite: buffer with CopyToAsync and write with WriteAsync. They have to hold the whole input either way.
  5. Update README's "Async Support" line and CLAUDE.md's three-tier note to list obfuscation as genuinely async.

Acceptance criteria

  • Every obfuscator passes the AsyncOnlyStream tests in Essentials.Tests/AsyncStreamIoTests.cs in both directions.
  • Async output is byte-identical to sync output for every obfuscator, including Xor with a multi-byte key over input longer than StreamChunks.BufferSize.
  • An already-cancelled token is honoured before any read.

Activity

  1. matt-edmondson commented on Nov 2, 2026

    @matt-edmondson
    ContributorAuthor

    Triage


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions