Skip to content

Commit 789766e

Browse files
rbenzingclaude
andcommitted
fix: move AES-GCM unsafe pinning blocks into Sodium.cs, AES.cs now has no unsafe code
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 93294d0 commit 789766e

4 files changed

Lines changed: 302 additions & 165 deletions

File tree

.gitignore

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -398,8 +398,4 @@ FodyWeavers.xsd
398398
*.msp
399399

400400
# JetBrains Rider
401-
*.sln.iml
402-
403-
# Claude
404-
.claude/
405-
CLAUDE.md
401+
*.sln.iml

CLAUDE.md

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
# CLAUDE.md
2+
3+
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4+
5+
## Build, Test & Lint Commands
6+
7+
```bash
8+
# Restore dependencies
9+
dotnet restore
10+
11+
# Build (Debug or Release)
12+
dotnet build --configuration Release
13+
dotnet build --configuration Debug
14+
15+
# Run all tests
16+
dotnet test --configuration Release --verbosity detailed
17+
18+
# Run a single test class
19+
dotnet test --configuration Release --filter "ClassName=ChatSessionTests"
20+
21+
# Run a single test method
22+
dotnet test --configuration Release --filter "FullyQualifiedName~ChatSessionTests.SomeTestMethod"
23+
24+
# Run tests with coverage
25+
dotnet test --configuration Release --collect:"XPlat Code Coverage"
26+
27+
# Pack NuGet package
28+
dotnet pack --configuration Release --output ./packages
29+
```
30+
31+
Build settings enforce `TreatWarningsAsErrors=true` and `EnforceCodeStyleInBuild=true`, so all analyzer warnings must be resolved before a build succeeds. CS1591 (missing XML doc comments) is suppressed.
32+
33+
## Architecture Overview
34+
35+
LibEmiddle is a .NET 8 end-to-end encryption library implementing X3DH + Double Ratchet protocols via libsodium. The solution has four projects:
36+
37+
| Project | Role |
38+
|---------|------|
39+
| **LibEmiddle** | Main library — all cryptographic logic, sessions, transport |
40+
| **LibEmiddle.Abstractions** | Interface contracts only; no implementations |
41+
| **LibEmiddle.Domain** | Domain models, DTOs, enums, constants — no external dependencies |
42+
| **LibEmiddle.Tests.Unit** | MSTest test suite with Moq for mocking |
43+
44+
### Layered dependency flow
45+
```
46+
LibEmiddle → LibEmiddle.Abstractions → LibEmiddle.Domain
47+
```
48+
`LibEmiddle.Tests.Unit` references only `LibEmiddle` (internals are exposed via `InternalsVisibleTo`).
49+
50+
### Main library layout (`LibEmiddle/`)
51+
52+
- **API/**`LibEmiddleClient` is the single public entry point. `LibEmiddleClientOptions` controls all configuration. Client implements `IAsyncDisposable`.
53+
- **Protocol/**`X3DHProtocol` (initial key agreement) and `DoubleRatchetProtocol` (continuous ratcheting). `ProtocolAdapter` bridges them.
54+
- **Crypto/**`CryptoProvider` wraps libsodium via `Sodium.cs` P/Invoke bindings. `AES.cs` handles AES-256-GCM. `SecureMemory.cs` manages zeroing of sensitive data.
55+
- **KeyManagement/**`KeyManager` handles key lifecycle; `KeyStorage` persists keys encrypted with AES-GCM.
56+
- **Sessions/**`SessionManager` manages session lifecycle; `SessionPersistenceManager` handles bundle caching and recovery (Argon2id KDF for password-derived keys since v2.6.0).
57+
- **Messaging/Chat/**`ChatSession` handles 1-to-1 encrypted sessions with 500-entry replay-protection FIFO buffer.
58+
- **Messaging/Group/**`GroupSession` manages multi-party sessions with per-sender message IDs for replay protection.
59+
- **Messaging/Transport/**`MailboxManager` encrypts/decrypts; `HttpMailboxTransport` and `InMemoryMailboxTransport` implement `IMailboxTransport`. `SecureWebSocketClient` wraps TLS WebSocket.
60+
- **MultiDevice/**`DeviceManager`, `DeviceLinkingService`, and `SyncMessageValidator` implement device linking and revocation.
61+
62+
### Domain layer (`LibEmiddle.Domain/`)
63+
64+
- **Constants/ProtocolVersion.cs**`MAJOR_VERSION`/`MINOR_VERSION` constants; updated only on major releases.
65+
- **Enums/**`KeyRotationStrategy` (Aggressive/Standard/Conservative/Adaptive), `SessionState`, `MemberRole`, `MessageType`, `SessionType`.
66+
- **DTO/** — Serializable snapshots for all session types (used for persistence, never for wire format directly).
67+
- **`LibEmiddleException`** — Typed exception with `LibEmiddleErrorCode` enum; always throw this instead of raw exceptions in library code.
68+
69+
### Abstractions layer (`LibEmiddle.Abstractions/`)
70+
71+
All public-facing interfaces live here: `IChatSession`, `IGroupSession`, `IDoubleRatchetProtocol`, `IX3DHProtocol`, `IMailboxTransport`, `IKeyManager`, `ISessionManager`, `IStorageProvider`, `IDeviceManager`, `ICryptoProvider`, etc. Implement these interfaces in `LibEmiddle`; never let implementations leak into Abstractions.
72+
73+
## Patterns & Anti-Patterns
74+
75+
**Core patterns — always apply:**
76+
- Clone session state before mutating: `DeepCloneSession()` at entry of every encrypt/decrypt, return the clone, never modify the input object
77+
- Semaphore over lock: `SemaphoreSlim(1,1)` for all async-safe state mutations; never use `lock()` in async paths
78+
- Volatile + Interlocked disposal: `private volatile bool _disposed` checked via `ThrowIfDisposed()` on every public entry; set with `Interlocked.Exchange(ref _disposedFlag, 1)`
79+
- Null-before-return for owned keys: when returning key material to a caller, set the local variable to `null` before the `finally` block so the finally doesn't clear what the caller now owns
80+
- Secure-clear in finally: every method that produces intermediate key bytes wraps the work in try/finally and calls `SecureMemory.SecureClear()` on all temporaries — even on the success path
81+
- Tuple returns for paired state: protocol methods return `(UpdatedSession?, ResultData?)` — never mutate session in-place and return a side-channel result
82+
- Bounded eviction collections: replay-protection sets use a parallel Queue for FIFO eviction when the HashSet exceeds its cap; do the same for any unbounded accumulator
83+
- Fire-and-forget events: raise domain events via `Task.Run(() => handler(...))` so event handlers can't hold the session lock
84+
- Boolean validators, not throwing validators: key-validation helpers return `false` on bad input; only the caller that has context throws
85+
- Constant-time ops: use `CryptographicOperations.FixedTimeEquals()` for key comparison, `Sodium.sodium_memcmp()` for raw byte comparison — never `==` or `SequenceEqual` on secrets
86+
87+
**Anti-patterns — never do:**
88+
- Never call `Array.Clear()` on key material — use `SecureMemory.SecureClear()` which pins and calls `sodium_memzero`
89+
- Never use `System.Security.Cryptography` asymmetric primitives — all asymmetric ops go through `CryptoProvider``Sodium`
90+
- Never add unsafe blocks outside `Core/SecureMemory.cs` and `Core/Sodium.cs`
91+
- Never swallow exceptions in catch — log and rethrow with context; `(null, null)` tuple returns are the only silent failure allowed and only in protocol decrypt paths
92+
- Never share the cached key array — `GetKeyCopy()` returns a fresh copy; the cache retains the original
93+
- Never use `Task.FromResult` to wrap genuinely async work — only for operations that are provably synchronous
94+
- Never skip timestamp validation on incoming messages — reject anything negative or more than 1 hour in the future before touching crypto
95+
96+
## Key Conventions
97+
98+
- **Cryptography**: All crypto operations go through `CryptoProvider``Sodium` P/Invoke. Never use `System.Security.Cryptography` directly for asymmetric operations; libsodium is the source of truth.
99+
- **Unsafe code**: Enabled in `LibEmiddle.csproj` solely for libsodium P/Invoke and `SecureMemory` zeroing. Do not add unsafe blocks elsewhere.
100+
- **Nullable**: Nullable reference types are enabled project-wide (`Nullable=enable`). All public APIs must be null-annotated.
101+
- **Async**: All I/O and session operations are async/await. Use `IAsyncDisposable` for resources that hold crypto state.
102+
- **Events**: State changes (message received, member added, key rotated, session state changed) are surfaced via typed `EventArgs` subclasses in Domain.
103+
- **Version bumps**: Update `Directory.Build.props` (`VersionPrefix`) and for major releases also `ProtocolVersion.cs`.
104+
- **Native binaries**: libsodium DLLs live in `runtimes/win-x64/native/` and `runtimes/win-x86/native/`. Linux/macOS builds rely on the system-installed libsodium.
105+
106+
## CI/CD Notes
107+
108+
- `dotnet.yml` runs matrix builds (Debug + Release) on push/PR to `main`, `legacy-1.0`, and `experimental`. Tests must pass in both configurations.
109+
- Version tags (`v*`) trigger a GitHub Release with attached `.nupkg` files and auto-extracted changelog notes.
110+
- `release.yml` is a manual workflow for triggering versioned releases; it validates branch/version compatibility before committing the version bump and creating the tag.
111+
- Branch → version mapping: `main` = v2.x.x, `legacy-1.0` = v1.x.x, `experimental` = v3.x.x-alpha.

LibEmiddle/Core/Sodium.cs

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -352,6 +352,175 @@ internal static partial int crypto_aead_aes256gcm_decrypt_detached_afternm(
352352
ReadOnlySpan<byte> npub, // const unsigned char *npub (nonce)
353353
IntPtr ctx); // const crypto_aead_aes256gcm_state *ctx
354354

355+
// Size of the AES-GCM state for precomputation (must be 512 bytes, aligned to 16 bytes)
356+
internal const int AesGcmStateSize = 512;
357+
358+
/// <summary>
359+
/// Performs AES-GCM combined (ciphertext + tag) encryption using a stackalloc state buffer.
360+
/// Moves all unsafe/fixed pinning here so callers need no unsafe keyword.
361+
/// </summary>
362+
internal static byte[] AesGcmEncrypt(
363+
ReadOnlySpan<byte> plaintext,
364+
ReadOnlySpan<byte> key,
365+
ReadOnlySpan<byte> nonce,
366+
ReadOnlySpan<byte> additionalData,
367+
int authTagSize)
368+
{
369+
byte[] ciphertext = new byte[plaintext.Length + authTagSize];
370+
unsafe
371+
{
372+
Span<byte> stateBuffer = stackalloc byte[AesGcmStateSize];
373+
fixed (byte* pState = stateBuffer)
374+
{
375+
IntPtr state = (IntPtr)pState;
376+
377+
int result = crypto_aead_aes256gcm_beforenm(state, key);
378+
if (result != 0)
379+
throw new InvalidOperationException("Failed to initialize AES-GCM state");
380+
381+
result = crypto_aead_aes256gcm_encrypt_afternm(
382+
ciphertext, out _,
383+
plaintext, (ulong)plaintext.Length,
384+
additionalData, (ulong)additionalData.Length,
385+
default,
386+
nonce,
387+
state);
388+
389+
if (result != 0)
390+
throw new InvalidOperationException("Encryption failed");
391+
}
392+
}
393+
return ciphertext;
394+
}
395+
396+
/// <summary>
397+
/// Performs AES-GCM combined (ciphertext + tag) decryption using a stackalloc state buffer.
398+
/// Moves all unsafe/fixed pinning here so callers need no unsafe keyword.
399+
/// Returns the actual plaintext length via <paramref name="plaintextLength"/>.
400+
/// </summary>
401+
internal static byte[] AesGcmDecrypt(
402+
ReadOnlySpan<byte> ciphertextWithTag,
403+
ReadOnlySpan<byte> key,
404+
ReadOnlySpan<byte> nonce,
405+
ReadOnlySpan<byte> additionalData,
406+
int authTagSize,
407+
out ulong plaintextLength)
408+
{
409+
byte[] plaintext = new byte[ciphertextWithTag.Length - authTagSize];
410+
ulong capturedLength = 0;
411+
unsafe
412+
{
413+
Span<byte> stateBuffer = stackalloc byte[AesGcmStateSize];
414+
fixed (byte* pState = stateBuffer)
415+
{
416+
IntPtr state = (IntPtr)pState;
417+
418+
int result = crypto_aead_aes256gcm_beforenm(state, key);
419+
if (result != 0)
420+
throw new InvalidOperationException("Failed to initialize AES-GCM state");
421+
422+
result = crypto_aead_aes256gcm_decrypt_afternm(
423+
plaintext, out capturedLength,
424+
default,
425+
ciphertextWithTag, (ulong)ciphertextWithTag.Length,
426+
additionalData, (ulong)additionalData.Length,
427+
nonce,
428+
state);
429+
430+
if (result != 0)
431+
throw new System.Security.Cryptography.CryptographicException(
432+
"Authentication failed. The data may have been tampered with or the wrong key was used.");
433+
}
434+
}
435+
plaintextLength = capturedLength;
436+
return plaintext;
437+
}
438+
439+
/// <summary>
440+
/// Performs AES-GCM detached encryption (separate ciphertext and tag) using a stackalloc state buffer.
441+
/// Moves all unsafe/fixed pinning here so callers need no unsafe keyword.
442+
/// </summary>
443+
internal static byte[] AesGcmEncryptDetached(
444+
ReadOnlySpan<byte> plaintext,
445+
ReadOnlySpan<byte> key,
446+
ReadOnlySpan<byte> nonce,
447+
ReadOnlySpan<byte> additionalData,
448+
int authTagSize,
449+
out byte[] tag)
450+
{
451+
byte[] ciphertext = new byte[plaintext.Length];
452+
tag = new byte[authTagSize];
453+
unsafe
454+
{
455+
Span<byte> stateBuffer = stackalloc byte[AesGcmStateSize];
456+
fixed (byte* pState = stateBuffer)
457+
{
458+
IntPtr statePtr = (IntPtr)pState;
459+
460+
int rc = crypto_aead_aes256gcm_beforenm(statePtr, key);
461+
if (rc != 0)
462+
throw new InvalidOperationException("AES-GCM key schedule failed");
463+
464+
rc = crypto_aead_aes256gcm_encrypt_detached_afternm(
465+
ciphertext,
466+
tag,
467+
out ulong maclen,
468+
plaintext,
469+
(ulong)plaintext.Length,
470+
additionalData,
471+
(ulong)additionalData.Length,
472+
default,
473+
nonce,
474+
statePtr);
475+
476+
if (rc != 0 || maclen != (ulong)authTagSize)
477+
throw new System.Security.Cryptography.CryptographicException("AES-GCM detached encryption failed");
478+
}
479+
}
480+
return ciphertext;
481+
}
482+
483+
/// <summary>
484+
/// Performs AES-GCM detached decryption (separate ciphertext and tag) using a stackalloc state buffer.
485+
/// Moves all unsafe/fixed pinning here so callers need no unsafe keyword.
486+
/// </summary>
487+
internal static byte[] AesGcmDecryptDetached(
488+
ReadOnlySpan<byte> ciphertext,
489+
ReadOnlySpan<byte> tag,
490+
ReadOnlySpan<byte> key,
491+
ReadOnlySpan<byte> nonce,
492+
ReadOnlySpan<byte> additionalData)
493+
{
494+
byte[] plaintext = new byte[ciphertext.Length];
495+
unsafe
496+
{
497+
Span<byte> stateBuffer = stackalloc byte[AesGcmStateSize];
498+
fixed (byte* pState = stateBuffer)
499+
{
500+
IntPtr statePtr = (IntPtr)pState;
501+
502+
int rc = crypto_aead_aes256gcm_beforenm(statePtr, key);
503+
if (rc != 0)
504+
throw new InvalidOperationException("AES-GCM key schedule failed");
505+
506+
rc = crypto_aead_aes256gcm_decrypt_detached_afternm(
507+
plaintext,
508+
default,
509+
ciphertext,
510+
(ulong)ciphertext.Length,
511+
tag,
512+
additionalData,
513+
(ulong)additionalData.Length,
514+
nonce,
515+
statePtr);
516+
517+
if (rc != 0)
518+
throw new System.Security.Cryptography.CryptographicException("AES-GCM detached decryption failed");
519+
}
520+
}
521+
return plaintext;
522+
}
523+
355524
#endregion
356525

357526
#region Memory operations

0 commit comments

Comments
 (0)