Porting a C# Project to CryptoHives.Foundation

Audience: an LLM performing a mechanical + semantic port of an existing C# codebase onto the CryptoHives.Foundation.* NuGet packages.

Goal: replace BCL async synchronization primitives with the allocation-free CryptoHives ValueTask-based primitives, replace ad-hoc buffer/stream/pool code with the Memory ArrayPool-based primitives, and replace hashing and post-quantum code with the managed, deterministic Security.Cryptography implementations — without changing observable behavior. MAC, KDF and cipher ports follow the same rules; their member lists are in the package porting guide.

Follow these instructions exactly. Do not invent APIs. If a source pattern has no clean mapping below, leave it unchanged and record it in the "Unmapped" report at the end.


0. Ground rules

  1. Behavior-preserving first. Every replacement must keep the same semantics (fairness, reentrancy expectations, exceptions, timeout/cancellation contract). When a CryptoHives type has a different semantic (e.g. AsyncLock is not reentrant), stop and flag it rather than silently swapping.
  2. One concern per commit. Do the packages/csproj wiring first, then async, then memory, then hashing. Build and run tests between each phase.
  3. ValueTask is single-consumption. Every CryptoHives async primitive returns ValueTask/ValueTask<T>. It may be awaited exactly once and must never be stored in a field, awaited twice, .Result-ed, or passed to Task.WhenAll/WhenAny. The CryptoHives.Foundation.Threading.Analyzers package enforces this — add it and treat its diagnostics as the source of truth (see §2.5).
  4. Match surrounding code style: nullable is enabled, warnings are errors in this repo; any code you add must compile clean under TreatWarningsAsErrors=true.

1. Add package references

Add the packages the port actually uses. Package IDs:

Need Package ID
Async primitives (AsyncLock, AsyncSemaphore, …) CryptoHives.Foundation.Threading
Analyzer that enforces correct ValueTask usage CryptoHives.Foundation.Threading.Analyzers
Buffers / streams / object pools CryptoHives.Foundation.Memory
Managed hashes, MAC, KDF, ciphers, KEM, signatures CryptoHives.Foundation.Security.Cryptography

In each consuming .csproj:

<ItemGroup>
  <PackageReference Include="CryptoHives.Foundation.Threading" />
  <PackageReference Include="CryptoHives.Foundation.Threading.Analyzers">
    <PrivateAssets>all</PrivateAssets>
    <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
  </PackageReference>
  <PackageReference Include="CryptoHives.Foundation.Memory" />
  <PackageReference Include="CryptoHives.Foundation.Security.Cryptography" />
</ItemGroup>

If the target project uses Central Package Management (Directory.Packages.props with <PackageVersion> entries), put versions there, not inline. Otherwise add Version="…".


2. Async primitives (CryptoHives.Foundation.Threading.Async.Pooled)

using CryptoHives.Foundation.Threading.Async.Pooled;

2.1 Mapping table

Existing (BCL / third-party) Replace with Notes
private readonly object _gate = new(); + lock (_gate) { … } guarding async work AsyncLock + using (await _lock.LockAsync()) Only when you need to await inside the critical section. Pure-sync lock blocks that never await should stay as lock.
SemaphoreSlim(1, 1) used as a mutex AsyncLock Analyzer flags this as CHT009.
SemaphoreSlim(n, …) used for concurrency limiting AsyncSemaphore(n) sem.Wait(0) / WaitAsync(0) → TryWait().
ManualResetEventSlim / TCS-based manual gate awaited async AsyncManualResetEvent mre.IsSet / Wait(0) → IsSet or the equivalent TryWait() (non-consuming).
AutoResetEvent semantics, async AsyncAutoResetEvent are.WaitOne(0) → TryWait(), which consumes the signal (unlike IsSet, which only peeks).
CountdownEvent awaited async AsyncCountdownEvent cde.Wait(0) → IsSet or the equivalent TryWait() (non-consuming).
Barrier awaited async AsyncBarrier
ReaderWriterLockSlim awaited async AsyncReaderWriterLock TryEnterReadLock(0) / TryEnterUpgradeableReadLock(0) / TryEnterWriteLock(0) → TryReaderLock / TryUpgradeableReaderLock / TryWriterLock (all out Releaser); the upgrade probe is Releaser.TryUpgradeToWriterLock.
Monitor.Wait / Monitor.Pulse awaited async AsyncConditionVariable Pairs with an AsyncLock; re-check the predicate in a while loop — signals are not stored.
Java's Exchanger<V> (no BCL equivalent) AsyncExchange<T> Two-party rendezvous, not a queue; see System.Threading.Channels if buffering is needed instead.
Dictionary<TKey, SemaphoreSlim> / a registry of one lock per key AsyncKeyedLock<TKey> Distinct keys never block each other; released keys stay cached, so repeat acquisitions allocate nothing.
AsyncKeyedLock (the third-party package), KeyedSemaphores, AsyncDuplicateLock AsyncKeyedLock<TKey> Same per-key semantics, without the striping some libraries use.
Nito.AsyncEx.AsyncLock, NeoSmart.AsyncLock, AsyncKeyedLock used without keys AsyncLock Verify the source did not rely on reentrancy.
DotNext.Threading.AsyncExclusiveLock AsyncLock DotNext releases with an explicit Release(); here the lock is released by disposing the Releaser, so wrap the critical section in using.
DotNext.Threading.AsyncReaderWriterLock AsyncReaderWriterLock Same Releaser pattern for reader and writer locks; upgradeable readers are a distinct mode here.
DotNext.Threading.AsyncAutoResetEvent / AsyncManualResetEvent / AsyncCountdownEvent / AsyncBarrier The same-named type in this package DotNext's types are net10.0-only; these run on every target down to net462.
DotNext.Threading.AsyncExchanger<T> AsyncExchange<T> Same two-party rendezvous.

2.2 AsyncLock — exact usage

private readonly AsyncLock _lock = new();

public async Task DoStuffAsync(CancellationToken ct)
{
    using (await _lock.LockAsync(ct))          // ct only observed if not acquired immediately
    {
        await SomethingAsync();
    }
}

Signatures actually present:

  • ValueTask<Releaser> LockAsync(CancellationToken cancellationToken = default)
  • ValueTask<Releaser> LockAsync(TimeSpan timeout, CancellationToken cancellationToken = default)
    • timeout throws TimeoutException if it elapses; TimeSpan.Zero throws immediately if the lock is held; Timeout.InfiniteTimeSpan waits forever.
  • bool IsTaken { get; }
  • bool TryLock(out Releaser releaser) — acquires only if the lock is free right now; never waits, never allocates, never throws. Replaces Monitor.TryEnter / SemaphoreSlim.Wait(0). On false the out releaser is default — do not dispose it (throws in DEBUG).

Rules:

  • AsyncLock is NOT recursive. If the original lock was taken re-entrantly (same call stack re-acquires), do not port it — flag it. Re-entrant acquisition will deadlock.
  • The Releaser is a readonly struct implementing IDisposable/IAsyncDisposable. Release only by disposing it via using. Do not store it in a field.
  • Do not .GetAwaiter().GetResult() the ValueTask<Releaser> (blocking) — that is CHT002.

2.3 AsyncSemaphore — exact usage

private readonly AsyncSemaphore _semaphore = new(3);   // 3 concurrent permits

public async Task AccessAsync(CancellationToken ct)
{
    await _semaphore.WaitAsync(ct);
    try
    {
        await DoWorkAsync();
    }
    finally
    {
        _semaphore.Release();
    }
}

Signatures actually present:

  • AsyncSemaphore(int initialCount, bool runContinuationAsynchronously = true, …)
  • ValueTask WaitAsync(CancellationToken cancellationToken = default)
  • ValueTask WaitAsync(TimeSpan timeout, CancellationToken cancellationToken = default) (throws TimeoutException / OperationCanceledException)
  • void Release() / void Release(int releaseCount)
  • int CurrentCount { get; }
  • bool TryWait() — takes a permit only if one is available right now; synchronous, never allocates, never throws. On true you still owe exactly one Release(). This is the direct replacement for SemaphoreSlim.Wait(0) / WaitAsync(0).

Note: there is no SemaphoreSlim-style bool-returning WaitAsync(timeout). The BCL pattern if (await sem.WaitAsync(timeout)) { … } must be rewritten to try/catch on TimeoutException, or use a CancellationToken. For a pure zero-timeout probe (WaitAsync(0)), use TryWait() instead.

2.4 AsyncKeyedLock<TKey> — exact usage

private readonly AsyncKeyedLock<string> _locks = new();

public async Task UpdateAsync(string id, CancellationToken ct)
{
    using (await _locks.LockAsync(id, ct))      // other ids proceed in parallel
    {
        await MutateAsync(id);
    }
}

Signatures actually present:

  • AsyncKeyedLock(IEqualityComparer<TKey>? comparer = null, IGetPooledManualResetValueTaskSource<Releaser>? pool = null, int maxIdleEntries = 128, int maxRetainedWaiters = 128)
  • ValueTask<Releaser> LockAsync(TKey key, CancellationToken cancellationToken = default)
  • ValueTask<Releaser> LockAsync(TKey key, TimeSpan timeout, CancellationToken cancellationToken = default)
  • bool TryLock(TKey key, out Releaser releaser) — never waits and never allocates
  • bool IsInUse(TKey key) / int Count { get; } — best-effort diagnostics, not synchronization

Rules:

  • Not reentrant, exactly like AsyncLock: re-acquiring the same key on one call stack deadlocks.
  • Hold one instance for the lifetime of the component. A new instance per call gets its own registry and serializes nothing.
  • maxIdleEntries bounds key cardinality and maxRetainedWaiters bounds simultaneous contention; past either the lock still works, it just stops being allocation-free.

2.5 Enforce correctness with the analyzer

After porting, build. The analyzer emits (full descriptions: https://cryptohives.github.io/Foundation/packages/threading.analyzers/index.html):

ID Meaning — fix
CHT001 (Error) ValueTask consumed multiple times — consume once, capture the result.
CHT002 (Warn) .GetAwaiter().GetResult() on ValueTask — make the caller async and await.
CHT003 (Warn) ValueTask stored in a field — don't; await it locally.
CHT005 (Warn) .Result on a ValueTask — await instead.
CHT007 (Info) AsTask() stored before the primitive signals — await the ValueTask directly.
CHT008 (Warn) ValueTask not awaited/consumed.
CHT009 (Info) SemaphoreSlim(1,1) — switch to AsyncLock.
CHT010 (Warn) ValueTask captured in a lambda/closure.
CHT011 (Warn) async method only forwards an awaited ValueTask — return it directly.
CHT012 (Info) async ValueTask wrapper boxes a state machine when it suspends.

Resolve every CHT0xx before considering the async phase done. Do not suppress them to make the build pass unless a human explicitly approves a specific instance.


3. Memory: buffers, streams, pools (CryptoHives.Foundation.Memory)

3.1 ArrayPoolBufferWriter<T> — pooled IBufferWriter<T>

using CryptoHives.Foundation.Memory.Buffers;

Replace: hand-rolled growable buffers, MemoryStream used only to accumulate bytes then read them back, ArrayBufferWriter<T> where you want ArrayPool backing, or manual ArrayPool<T>.Shared.Rent/Return accumulation loops.

using var writer = new ArrayPoolBufferWriter<byte>();
// standard IBufferWriter<T> surface:
Span<byte> span = writer.GetSpan(sizeHint);   // or GetMemory
// … write into span …
writer.Advance(bytesWritten);
// consume as a ReadOnlySequence (valid until next write or Dispose):
ReadOnlySequence<byte> result = writer.GetReadOnlySequence();

Critical rules:

  • It is IDisposable and rents from ArrayPool<T>.Shared. Always using it so pooled arrays are returned. Never let one escape its scope.
  • The ReadOnlySequence<T> from GetReadOnlySequence() is only valid until the next write or Dispose(). Fully consume/copy it before disposing. When the payload has to outlive the using, do not copy it — return writer.LeaseSequence() instead. The lease is a readonly struct carrying the sequence and the writer that owns it, so the caller disposes one thing and costs nothing over borrowing.
  • For sensitive data, pass clearArray: true so buffers are zeroed on return — new ArrayPoolBufferWriter<byte>(clearArray: true, defaultChunkBytes, maxChunkBytes), or better, declare it once on a provider (below) rather than at each call site.
  • Chunk sizing is in bytes, not elements: default 256, doubling to a 65,536 ceiling. The writer divides by sizeof(T) to decide how many elements fit, which is what keeps a chunk off the large object heap whatever T is.
  • The writer itself is poolable. ObjectPools.RentBufferWriter<byte>() gives a writer that returns itself on dispose, not just its buffers; an ArrayPoolBufferWriterProvider<byte> holds one immutable settings profile and rents from the shared pool, so many profiles cost no extra instances.
  • Never touch a writer after disposing it. A pooled instance does not throw ObjectDisposedException — it silently becomes whatever the next renter is doing.

3.2 ArrayPoolMemoryStream — pooled MemoryStream

Replace new MemoryStream() used as a scratch/accumulation buffer where the allocation of one large backing array is wasteful. It is a MemoryStream subclass backed by pooled ArrayPool<byte> segments, so it is a drop-in for APIs typed as MemoryStream/Stream. using it (it returns segments on Dispose).

The same two rules as the writer apply: GetReadOnlySequence() is borrowed and LeaseSequence() lets the payload leave the scope, and clearArray: true on any owning constructor zeroes the buffers on release — on truncation by SetLength as well as on disposal. It cannot hand out its internal storage as one contiguous array, so GetBuffer() throws and TryGetBuffer() returns false; reach for the sequence instead. ToArray() is overridden and works across segments — it copies rather than exposes, so use it only when an owned byte[] is genuinely required, not on a hot path.

3.3 ReadOnlySequenceMemoryStream — read-only Stream over an existing sequence

Replace code that copies a ReadOnlySequence<byte> into a byte[] just to wrap it in a new MemoryStream(bytes). Wrap the sequence directly: new ReadOnlySequenceMemoryStream(sequence).

3.4 ObjectOwner<T> + object pools

using CryptoHives.Foundation.Memory.Pools;

Replace manual ObjectPool<T>.Get()/Return() pairs and StringBuilder churn.

using var owner = ObjectPools.GetStringBuilder();
StringBuilder sb = owner.PooledObject;
// … use sb …

For your own pools — PoolFactory builds one from a factory and a reset delegate, including for types this package does not reference:

ObjectPool<MyType> pool = PoolFactory.CreatePool(
    create: () => new MyType(),
    reset: obj => { obj.Clear(); return true; });

using var owner = new ObjectOwner<MyType>(pool);
MyType obj = owner.PooledObject;

Critical rules:

  • ObjectOwner<T> is a readonly struct. Use it only with using var in a narrow scope. Never cast it to IDisposable and never box it — that defeats the purpose and can double-return. Do not copy it around.
  • Whatever you got from PooledObject must not be used after the using scope ends (it has been returned to the pool).
  • A reset delegate returning false makes the pool drop the instance, not dispose it. If T holds unmanaged or pooled resources, dispose them in reset before returning false.
  • Object pools are not trimmed under memory pressure — DefaultObjectPool<T> holds what it has for the life of the process. Bound it with maximumRetained. Rented arrays are different: ArrayPool<T>.Shared trims itself on a gen-2 GC callback.

3.5 ISegmentOwner<T> — segment ownership primitives

using CryptoHives.Foundation.Memory.Buffers;

ISegmentOwner<T> is the ArraySegment<T> counterpart to the BCL's IMemoryOwner<T>. Use it when code passes a fixed-size, directly-addressable buffer and the caller needs to control whether the backing memory comes from a pool, the GC heap, or is simply empty.

Replacement table

Existing pattern Replace with
ArrayPool<T>.Shared.Rent(n) + manual Return PooledSegment<T>.Rent(n)
new T[n] handed across an ownership boundary AllocatedSegment<T>.Create(buffer)
null or zero-length array as "no data" sentinel EmptySegment<T>.Instance
IMemoryOwner<T> where ArraySegment<T> is preferred ISegmentOwner<T>

Choosing the right implementation

Class Memory source When to use
PooledSegment<T> ArrayPool<T>.Shared Hot-path, short-lived, fixed-size buffers where you want pool reuse.
AllocatedSegment<T> new T[] (GC heap) You already have an array and just need a uniform ownership wrapper.
EmptySegment<T> Array.Empty<T>() singleton Default/uninitialized state; avoids null checks in consumers.

Patterns

using CryptoHives.Foundation.Memory.Buffers;

// PooledSegment<T> — pool-backed, Segment.Count == minimumLength
using ISegmentOwner<byte> seg = PooledSegment<byte>.Rent(256);
Span<byte> span = seg.Segment.AsSpan();
FillData(span);
// narrow the view without reallocating (returns false if out-of-range):
if (seg.TrySetSegment(offset: 16, length: 64))
    Send(seg.Segment.AsSpan());
// rented array is returned to ArrayPool on dispose

// AllocatedSegment<T> — wraps an existing GC-managed array, no pool
byte[] raw = new byte[256];
using ISegmentOwner<byte> alloc = AllocatedSegment<byte>.Create(raw);
Process(alloc);
// only the wrapper is cleared on Dispose; the array itself is unchanged

// EmptySegment<T> — null-object sentinel
ISegmentOwner<byte> none = EmptySegment<byte>.Instance;

void Process(ISegmentOwner<byte> owner)
{
    if (owner.Segment.Count == 0) return;   // handles EmptySegment cleanly
    Span<byte> data = owner.Segment.AsSpan();
    // …
}

Critical rules

  • PooledSegment<T> must be using-scoped — the rented array is returned to ArrayPool<T>.Shared on dispose. Never let one escape its scope or use the segment after disposal (Segment.Array becomes null).
  • TrySetSegment does not resize. It only repositions the view within the existing backing array. It returns false when offset + length > array.Length; the segment is unchanged. Never use it as a grow/resize operation.
  • EmptySegment<T>.Instance is a permanent singleton. Calling Dispose on it is a no-op by design; treat it as an immutable sentinel value, not as a resource to manage.
  • Indexer this[int i] is offset-aware. owner[i] accesses Segment.Array[i + Segment.Offset]. After TrySetSegment(offset, length), index 0 is element offset in the original array.
  • For sensitive data, rent with PooledSegment<T>.Rent(length, clearArray: true), which zeroes the whole backing array as it returns to the pool. Debug builds zero every returned buffer regardless, but that is a use-after-return diagnostic, not a security control — do not verify a wipe in Debug and ship a Release build that never asked for one.

4. Hashing (CryptoHives.Foundation.Security.Cryptography)

using CryptoHives.Foundation.Security.Cryptography.Hash;

These are fully managed, deterministic, cross-platform implementations that extend System.Security.Cryptography.HashAlgorithm (all but ParallelHash and IncrementalParallelHash), so they are drop-in wherever a HashAlgorithm is expected. Use them to replace BouncyCastle digests, third-party hash libs, or System.Security.Cryptography types when you want managed determinism across all target frameworks (including legacy TFMs where the BCL lacks e.g. SHA-3).

4.1 One-shot hashing (preferred — pooled, allocation-light)

Concrete classes expose static HashData / TryHashData that pool their instance:

byte[] digest = SHA256.HashData(data);                        // ReadOnlySpan<byte> or ReadOnlySequence<byte>
bool ok      = SHA256.TryHashData(data, destination, out int written);

Prefer these over ComputeHash for one-shot cases — no IDisposable bookkeeping and they accept ReadOnlySequence<byte> directly (pairs well with §3.1's GetReadOnlySequence()).

4.2 Streaming / incremental hashing

For multi-chunk input, use the instance API (same shape as the BCL base type):

using var sha = SHA256.Create();
sha.AppendData(chunk1);          // or TransformBlock for the classic pattern
sha.AppendData(chunk2);
sha.TryGetHashAndReset(dest, out int written);
// or: byte[] h = sha.ComputeHash(stream);

Every concrete type has a static Create() returning the concrete type, plus the inherited HashAlgorithm surface (ComputeHash, TransformBlock/TransformFinalBlock, etc.).

4.3 Available concrete hash types (use the exact class name)

  • SHA-2: SHA224, SHA256, SHA384, SHA512, SHA512_224, SHA512_256
  • SHA-3 (FIPS 202): SHA3_224, SHA3_256, SHA3_384, SHA3_512
  • Keccak (original padding): Keccak256, Keccak384, Keccak512
  • XOF: Shake128, Shake256, CShake128, CShake256, TurboShake128, TurboShake256, KT128, KT256
  • BLAKE: Blake2b, Blake2s, Blake3
  • Legacy (deprecated, kept for compat): SHA1, MD5, Ripemd160
  • Regional / other: SM3, Whirlpool, Kupyna, Streebog, Lsh256, Lsh512, AsconHash256, AsconXof128
  • Parallel: ParallelHash, IncrementalParallelHash

4.4 Extendable-output functions (XOF)

XOF types (Shake*, CShake*, TurboShake*, KT*, Blake3, AsconXof128) implement IExtendableOutput:

void Absorb(ReadOnlySpan<byte> input);   // absorb all input first
void Squeeze(Span<byte> output);         // then squeeze arbitrary-length output (repeatable)
void Reset();

After the first Squeeze, the state is finalized — no more Absorb. Map any "digest of arbitrary length" or SHAKE usage to this pattern.

4.5 Choosing managed vs. OS implementation

HashAlgorithm.Create(string hashName, bool osVersion = false) is available: pass osVersion: true to get the (often faster) OS implementation for algorithms the BCL supports, or the default managed CryptoHives implementation otherwise. For a straight port, prefer the concrete-class static HashData calls (§4.1) for clarity.

4.6 Secret handling — replace Array.Clear and SequenceEqual

using CryptoHives.Foundation.Security.Cryptography;

Source pattern Replace with
Array.Clear(key, 0, key.Length) on key material, a password, a PRK or plaintext CryptographicOperations.ZeroMemory(key)
SequenceEqual / == / early-exit loop comparing a MAC or tag CryptographicOperations.FixedTimeEquals(expected, actual)
System.Security.Cryptography.CryptographicOperations.* the same call from this namespace

A clear on a buffer nothing reads again is a dead store the compiler may delete; an early-exit compare leaks how many leading bytes matched. Both helpers are marked so the optimizer cannot do either. They also work on net462/net472/netstandard2.0, where the in-box type does not exist at all, and ZeroMemory has char overloads the in-box type has never offered — the only supported way to erase a password or PEM text held in char storage.

Importing both this namespace and System.Security.Cryptography gives CS0104 on the shared type name; alias one side with using Bcl = System.Security.Cryptography;.

4.7 Do NOT touch cryptographic correctness

Do not change algorithm choice, key sizes, IV/nonce handling, digest length, or padding as part of this port. Swap the implementation type only. If the source uses a broken/weak algorithm (MD5/SHA-1), keep it as-is (the equivalent CryptoHives type exists) and note it — do not "upgrade" it silently.


4.8 Post-quantum: ML-KEM, ML-DSA, SLH-DSA

MLKem (.Kem), MLDsa and SlhDsa (.Dsa) mirror the names and signatures of the .NET 10 in-box types, so the algorithm operations port as a using swap and IsSupported is always true — down to net462, with no CNG or OpenSSL dependency.

The key-format members are where a port needs attention. Three differences are deliberate, and each is a compile error rather than a silent behaviour change:

  • No password is taken as a string. ImportEncryptedPkcs8PrivateKey(string, byte[]) and the four other string-password overloads do not exist. A string cannot be overwritten, so passwords are ReadOnlySpan<char> or ReadOnlySpan<byte> and the caller owns storage it can clear. "pw" → "pw".AsSpan() compiles; holding the password in a char[] you clear in a finally is the actual port.
  • Character and byte passwords are not interchangeable, and confusing them does not throw — it derives a different key and looks like a wrong password much later. Characters are encoded by the scheme (UTF-8 for PBES2, big-endian UTF-16 for the legacy PKCS#12 reads); bytes are the KDF input verbatim.
  • PbeOptions replaces PbeParameters, and string ExportPkcs8PrivateKeyPem() is replaced by GetPkcs8PrivateKeyPemSize() + TryExportPkcs8PrivateKeyPem(Span<char>, out int).

Erase those buffers with CryptographicOperations.ZeroMemory, not Array.Clear or Span<T>.Clear() — a clear on a buffer nothing reads again is a dead store a compiler may delete. The package exposes its own (CryptoHives.Foundation.Security.Cryptography) because the in-box type does not exist below .NET Standard 2.1 and has no character overload on any .NET version.

Full member lists, the porting table and the reasoning: package porting guide and Erasable Memory.


5. Procedure & verification

Work phase by phase; build + test after each:

  1. Wire packages (§1). Restore/build.
  2. Async (§2): swap primitives, add the analyzer, drive CHT0xx to zero.
  3. Memory (§3): swap buffer/stream/pool patterns; verify every new type is using-scoped.
  4. Hashing (§4): swap hash implementations; confirm identical digests.
  5. Post-quantum (§4.8), if present: swap the algorithm types, then fix the key-format call sites the compiler flags — passwords to spans, PbeParameters to PbeOptions, the plaintext PEM export to its TryExport form.

Verification checklist:

  • [ ] Solution builds clean under TreatWarningsAsErrors=true.
  • [ ] Zero unresolved CHT001–CHT012 diagnostics.
  • [ ] All existing tests pass. For hashing, add/keep a test asserting the new digest equals a known-answer vector or the pre-port output for representative inputs.
  • [ ] No ValueTask stored in a field, awaited twice, .Result-ed, or WhenAll-ed.
  • [ ] Every ArrayPoolBufferWriter<T>, ArrayPoolMemoryStream, PooledSegment<T>, and ObjectOwner<T> is in a using scope; no pooled buffer or ReadOnlySequence escapes it.
  • [ ] No AsyncLock used re-entrantly.
  • [ ] No code uses a PooledSegment<T> segment or its backing array after Dispose.
  • [ ] TrySetSegment return value is checked; it is not used as a resize/grow operation.

6. Report

At the end, produce a short report:

  • Replaced: table of file:line → old type → new CryptoHives type.
  • Unmapped / flagged: patterns you intentionally left alone (re-entrant locks, sync-only lock blocks, bool-returning WaitAsync(timeout) usages that need human decision, weak hashes retained), each with a one-line reason.
  • Behavioral risks: anything where semantics might differ (fairness, continuation scheduling, timeout-as-exception vs. bool), for human review.