Skip to content

Commit de4e133

Browse files
rbenzingclaude
andcommitted
Production-ready sprint: security hardening, persistence, and structured errors
Stories delivered (520 tests, all passing): - STORY-001: Auto-start mailbox listener in StartListeningAsync - STORY-002: SenderIdentityKey field on EncryptedMessage for O(1) session routing - STORY-003: Prekey bundle API (IKeyBundleTransport, fetch/upload/cache with 72h TTL) - STORY-004: OPK consumption tracking and auto-replenishment - STORY-005: Group replay protection (per-sender message ID deduplication) - STORY-006: Post-removal forward secrecy (chain key rotation on member removal) - STORY-007: Chat session replay protection (500-entry sliding window) - STORY-008: Encrypted device list persistence (AES-GCM, atomic writes, lazy load) - STORY-009: SendToDeviceAsync for multi-device message routing - STORY-010: Explicit CreateChatSessionAsync overloads (identity key vs full bundle) - STORY-011: IAsyncDisposable on LibEmiddleClient - STORY-012: LibEmiddleException with LibEmiddleErrorCode enum (12 error codes) Security improvements: replay attack prevention, encrypted device persistence, structured transport exceptions, post-removal group forward secrecy. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent bc0df83 commit de4e133

39 files changed

Lines changed: 6278 additions & 108 deletions

CHANGELOG.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1212
- **Password KDF Overload**: New `CryptoProvider.DeriveKeyFromPassword(string password, byte[] salt)` overload accepting a caller-supplied random salt for non-deterministic key derivation
1313
- **Shared Key Conversion Helper**: New `LibEmiddle.Core.KeyConversion.ConvertEd25519PublicKeyToX25519()` consolidating duplicate Ed25519→X25519 conversion logic previously spread across `DeviceManager` and `DeviceLinkingService`
1414
- **Unit Tests**: Added 60+ new unit tests covering `MessageSigning`, `AES` detached encryption, `Nonce` thread-safety, `EnhancedFileStorageProvider`, `PostQuantumCrypto`, stub infrastructure visibility, and `SessionManager` bundle/group session paths
15+
- **Group Replay Protection**: `GroupSession` now detects and rejects duplicate messages using a per-sender `_seenMessageIds` set (capped at 1 000 entries) checked before decryption; replay attempts return `null` without exposing ciphertext errors
16+
- **Group Member-Removal Key Rotation**: `GroupSession.RemoveMemberAsync()` automatically rotates the chain key via `RotateKeyInternalAsync()` after each successful removal, ensuring forward secrecy for the remaining group; attempting to remove a non-member is a no-op and does not rotate the key
17+
- **Chat Session Replay Protection**: `ChatSession` tracks processed message IDs in a 500-entry FIFO ring buffer (`_processedMessageIds`); duplicate messages return `null` by default or throw `InvalidOperationException` when `throwOnReplay: true` is passed to `DecryptAsync`
18+
- **Device List Persistence**: New `DeviceStorage` class persists the linked-device list to an AES-GCM–encrypted file (fresh key and nonce per write, atomic temp-then-rename) so devices survive process restarts; `DeviceManager` accepts an optional `storagePath` and lazily loads the list on first access via double-checked locking
19+
- **`LibEmiddleException`**: New domain exception type (`LibEmiddle.Domain.Exceptions.LibEmiddleException`) inheriting from `Exception`, carrying a typed `ErrorCode` property (`LibEmiddleErrorCode` enum with 12 values: `Unknown`, `InvalidBundle`, `ReplayDetected`, `DecryptionFailed`, `TransportError`, `KeyNotFound`, `SessionNotFound`, `DeviceNotFound`, `OPKExhausted`, `InvalidKey`, `InvalidMessage`, `PermissionDenied`)
20+
- **Mailbox Listener Auto-Start**: `LibEmiddleClient.StartListeningAsync()` now automatically calls `_mailboxManager.Start()` so incoming-message polling begins without a separate call
21+
- **Sender Identity Routing**: `EncryptedMessage` now carries a `SenderIdentityKey` field; `ProcessChatMessageAsync` uses it for O(1) session lookup by sender key rather than iterating all sessions
22+
- **OPK Consumption Tracking**: `OPKManager.TryConsume()` marks one-time prekeys as consumed and triggers automatic replenishment when the available count falls below the configured threshold
23+
- **`IAsyncDisposable` on `LibEmiddleClient`**: `LibEmiddleClient` now implements `IAsyncDisposable` in addition to `IDisposable`, using the canonical `DisposeAsync → Dispose(true) → GC.SuppressFinalize` pattern for safe `await using` cleanup
1524

1625
### Changed
1726
- **Password Key Derivation**: Migrated from HKDF with a fixed static salt to **Argon2id** (via libsodium's `crypto_pwhash`) for memory-hard password-based key derivation (64 MB, 2 passes). The deterministic overload uses a fixed application-specific salt; the new overload accepts a random salt
@@ -20,6 +29,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2029
- **Batch Serialization**: Replaced reflection-based `MakeGenericMethod` in `BatchAsync` with the BCL's `JsonSerializer.Serialize(object?, Type, JsonSerializerOptions?)` overload, eliminating runtime reflection
2130
- **Disposal Guards**: Changed all `bool _disposed` fields to `volatile bool _disposed` across all `IDisposable` classes for correct thread-safe disposal checks
2231
- **Timer Callback**: `EnhancedFileStorageProvider.CleanupExpiredItems` refactored to `async void` + inner `DoCleanupExpiredItemsAsync()` pattern, eliminating the `.Wait()` call that could deadlock on synchronization contexts
32+
- **`SecureWebSocketClient` Exceptions**: Transport-layer failures now throw `LibEmiddleException` with `LibEmiddleErrorCode.TransportError` (wrapping the original exception as `InnerException`) instead of generic `Exception`, giving callers structured error handling
2333

2434
### Fixed
2535
- **AES-GCM Detached Decryption**: Corrected the P/Invoke signature for `crypto_aead_aes256gcm_decrypt_detached_afternm` in `Sodium.cs` — removed the erroneous `out ulong mlen_p` second parameter that does not exist in this libsodium entry point, which was causing all AES-GCM detached decryption to fail
@@ -28,6 +38,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2838

2939
### Security
3040
- **Argon2id Password Hardening**: The Argon2id KDF (64 MB memory, 2 iterations) is orders of magnitude more resistant to GPU/ASIC brute-force attacks than the previous HKDF-with-fixed-salt approach
41+
- **Group Replay Attack Prevention**: Each `GroupSession` maintains per-sender message ID sets; replayed or duplicated ciphertexts are silently dropped before decryption is attempted
42+
- **Post-Removal Forward Secrecy**: Removing a group member immediately generates a new cryptographically independent chain key; the removed member's previously held key material cannot decrypt any subsequent message
43+
- **Chat Replay Attack Prevention**: `ChatSession` rejects message-ID duplicates within a 500-message sliding window, preventing replay of captured ciphertexts
44+
- **Encrypted Device Persistence**: Linked-device lists are stored with AES-GCM authenticated encryption; any tampered or corrupted file is rejected at load time
3145

3246
## [2.5.1] - 2025-12-22
3347

LibEmiddle.Abstractions/IMailboxTransport.cs

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,32 @@
22

33
namespace LibEmiddle.Abstractions;
44

5+
/// <summary>
6+
/// Optional capability interface for transports that support publishing and fetching
7+
/// X3DH key bundles. Implement this interface alongside <see cref="IMailboxTransport"/>
8+
/// to enable <c>UploadKeyBundleAsync</c> / <c>FetchRecipientKeyBundleAsync</c> on the client.
9+
/// </summary>
10+
public interface IKeyBundleTransport
11+
{
12+
/// <summary>
13+
/// Uploads the caller's public key bundle to the transport server so that
14+
/// other parties can initiate X3DH sessions without a pre-cached bundle.
15+
/// </summary>
16+
/// <param name="bundle">The public bundle to publish.</param>
17+
/// <returns>A task that completes when the bundle has been uploaded.</returns>
18+
Task UploadKeyBundleAsync(X3DHPublicBundle bundle);
19+
20+
/// <summary>
21+
/// Fetches the public key bundle for the specified recipient identity key.
22+
/// </summary>
23+
/// <param name="recipientIdentityKey">The Ed25519 identity public key of the recipient.</param>
24+
/// <returns>
25+
/// The recipient's <see cref="X3DHPublicBundle"/>, or <c>null</c> if no bundle is
26+
/// registered for that identity key. Callers must check for <c>null</c> before use.
27+
/// </returns>
28+
Task<X3DHPublicBundle> FetchKeyBundleAsync(byte[] recipientIdentityKey);
29+
}
30+
531
/// <summary>
632
/// Defines the contract for mailbox transport implementations that handle
733
/// sending, receiving, and managing encrypted messages through various transport mechanisms.

LibEmiddle.Domain/EncryptedMessage.cs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,14 @@ public class EncryptedMessage
1919
/// </summary>
2020
public string SessionId { get; set; } = string.Empty;
2121

22+
/// <summary>
23+
/// Gets or sets the sender's long-term identity public key (Ed25519, 32 bytes).
24+
/// Used for O(1) session routing on the receiver side.
25+
/// Nullable for backwards compatibility with messages created before this field was added.
26+
/// </summary>
27+
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
28+
public byte[]? SenderIdentityKey { get; set; }
29+
2230
/// <summary>
2331
/// Gets or sets the sender's DH ratchet public key.
2432
/// Used for the Double Ratchet protocol.
@@ -63,6 +71,7 @@ public EncryptedMessage Clone()
6371
{
6472
MessageId = MessageId,
6573
SessionId = SessionId,
74+
SenderIdentityKey = SenderIdentityKey?.ToArray(),
6675
SenderDHKey = SenderDHKey?.ToArray(),
6776
SenderMessageNumber = SenderMessageNumber,
6877
Ciphertext = Ciphertext?.ToArray(),
@@ -114,6 +123,7 @@ public int GetEstimatedSize()
114123
size += SessionId.Length * sizeof(char);
115124

116125
// Byte arrays
126+
size += SenderIdentityKey?.Length ?? 0;
117127
size += SenderDHKey?.Length ?? 0;
118128
size += Ciphertext?.Length ?? 0;
119129
size += Nonce?.Length ?? 0;
@@ -239,6 +249,12 @@ public static EncryptedMessage FromDictionary(Dictionary<string, object>? dictio
239249
message.SessionId = sessionId;
240250
}
241251

252+
// Optional: SenderIdentityKey (new field, nullable for backwards compatibility)
253+
if (dictionary.TryGetValue("SenderIdentityKey", out var senderIdentityKeyObj) && senderIdentityKeyObj is string senderIdentityKeyBase64)
254+
{
255+
message.SenderIdentityKey = Convert.FromBase64String(senderIdentityKeyBase64);
256+
}
257+
242258
return message;
243259
}
244260
catch (FormatException)
@@ -334,6 +350,11 @@ public Dictionary<string, object> ToDictionary()
334350
["Timestamp"] = Timestamp
335351
};
336352

353+
if (SenderIdentityKey != null && SenderIdentityKey.Length > 0)
354+
{
355+
dictionary["SenderIdentityKey"] = Convert.ToBase64String(SenderIdentityKey);
356+
}
357+
337358
if (!string.IsNullOrEmpty(MessageId))
338359
{
339360
dictionary["MessageId"] = MessageId;
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
namespace LibEmiddle.Domain.Exceptions
2+
{
3+
/// <summary>
4+
/// Error codes for <see cref="LibEmiddleException"/>, allowing callers to
5+
/// distinguish transient failures from permanent ones and to route errors
6+
/// to the appropriate recovery path.
7+
/// </summary>
8+
public enum LibEmiddleErrorCode
9+
{
10+
/// <summary>An unclassified or unexpected error occurred.</summary>
11+
Unknown = 0,
12+
13+
/// <summary>The supplied key bundle is missing required fields or is structurally invalid.</summary>
14+
InvalidBundle,
15+
16+
/// <summary>A replayed message was detected and rejected.</summary>
17+
ReplayDetected,
18+
19+
/// <summary>Decryption of a message or payload failed.</summary>
20+
DecryptionFailed,
21+
22+
/// <summary>A transport-level error occurred (e.g. WebSocket send/receive failure).</summary>
23+
TransportError,
24+
25+
/// <summary>The requested cryptographic key could not be found.</summary>
26+
KeyNotFound,
27+
28+
/// <summary>The requested session could not be found.</summary>
29+
SessionNotFound,
30+
31+
/// <summary>The requested device could not be found.</summary>
32+
DeviceNotFound,
33+
34+
/// <summary>All one-time pre-keys (OPKs) have been exhausted.</summary>
35+
OPKExhausted,
36+
37+
/// <summary>A supplied key is invalid (wrong length, wrong curve, corrupt data, etc.).</summary>
38+
InvalidKey,
39+
40+
/// <summary>A supplied message is invalid (malformed, missing fields, etc.).</summary>
41+
InvalidMessage,
42+
43+
/// <summary>The operation is not permitted given the caller's current privileges.</summary>
44+
PermissionDenied,
45+
}
46+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
namespace LibEmiddle.Domain.Exceptions
2+
{
3+
/// <summary>
4+
/// The base exception type for LibEmiddle. Carries a <see cref="LibEmiddleErrorCode"/>
5+
/// so callers can distinguish transient failures (e.g. <see cref="LibEmiddleErrorCode.TransportError"/>)
6+
/// from permanent ones (e.g. <see cref="LibEmiddleErrorCode.InvalidKey"/>).
7+
/// </summary>
8+
public class LibEmiddleException : Exception
9+
{
10+
/// <summary>Gets the structured error code that describes the failure.</summary>
11+
public LibEmiddleErrorCode ErrorCode { get; }
12+
13+
/// <summary>
14+
/// Initialises a new instance of <see cref="LibEmiddleException"/>.
15+
/// </summary>
16+
/// <param name="message">A human-readable description of the error.</param>
17+
/// <param name="code">The structured error code.</param>
18+
/// <param name="innerException">An optional inner exception that caused this error.</param>
19+
public LibEmiddleException(
20+
string message,
21+
LibEmiddleErrorCode code,
22+
Exception? innerException = null)
23+
: base(message, innerException)
24+
{
25+
ErrorCode = code;
26+
}
27+
}
28+
}

0 commit comments

Comments
 (0)