mirror of
https://github.com/esiur/esiur-dotnet.git
synced 2026-07-30 17:30:40 +00:00
AES
This commit is contained in:
@@ -158,7 +158,8 @@ public class EpAuthPacket : Packet
|
||||
if (Command == EpAuthPacketCommand.Initialize)
|
||||
{
|
||||
AuthMode = (AuthenticationMode)(data[offset] >> 2 & 0x3);
|
||||
EncryptionMode = (EncryptionMode)(data[offset++] & 0x7);
|
||||
// Authentication occupies bits 2-3; encryption is confined to bits 0-1.
|
||||
EncryptionMode = (EncryptionMode)(data[offset++] & 0x3);
|
||||
}
|
||||
else if (Command == EpAuthPacketCommand.Acknowledge)
|
||||
{
|
||||
|
||||
@@ -22,6 +22,7 @@ namespace Esiur.Net.Packets
|
||||
Identity,
|
||||
AuthenticationProtocol,
|
||||
AuthenticationData,
|
||||
ErrorMessage
|
||||
ErrorMessage,
|
||||
CipherNonce
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,8 @@ namespace Esiur.Net.Sockets
|
||||
ArraySegment<byte> websocketReceiveBufferSegment;
|
||||
|
||||
object sendLock = new object();
|
||||
readonly SemaphoreSlim sendSemaphore = new SemaphoreSlim(1, 1);
|
||||
int sendFailureNotified;
|
||||
bool held;
|
||||
|
||||
public event DestroyedEvent OnDestroy;
|
||||
@@ -70,7 +72,7 @@ namespace Esiur.Net.Sockets
|
||||
|
||||
public void Send(byte[] message)
|
||||
{
|
||||
|
||||
byte[] queued = null;
|
||||
lock (sendLock)
|
||||
{
|
||||
if (held)
|
||||
@@ -79,16 +81,18 @@ namespace Esiur.Net.Sockets
|
||||
}
|
||||
else
|
||||
{
|
||||
totalSent += message.Length;
|
||||
sock.SendAsync(new ArraySegment<byte>(message), WebSocketMessageType.Binary,
|
||||
true, new System.Threading.CancellationToken());
|
||||
queued = (byte[])message.Clone();
|
||||
}
|
||||
}
|
||||
|
||||
if (queued != null)
|
||||
ObserveSend(QueueSend(queued));
|
||||
}
|
||||
|
||||
|
||||
public void Send(byte[] message, int offset, int size)
|
||||
{
|
||||
byte[] queued = null;
|
||||
lock (sendLock)
|
||||
{
|
||||
if (held)
|
||||
@@ -97,12 +101,13 @@ namespace Esiur.Net.Sockets
|
||||
}
|
||||
else
|
||||
{
|
||||
totalSent += size;
|
||||
|
||||
sock.SendAsync(new ArraySegment<byte>(message, offset, size),
|
||||
WebSocketMessageType.Binary, true, new System.Threading.CancellationToken());
|
||||
queued = new byte[size];
|
||||
Buffer.BlockCopy(message, offset, queued, 0, size);
|
||||
}
|
||||
}
|
||||
|
||||
if (queued != null)
|
||||
ObserveSend(QueueSend(queued));
|
||||
}
|
||||
|
||||
|
||||
@@ -184,41 +189,85 @@ namespace Esiur.Net.Sockets
|
||||
|
||||
public void Unhold()
|
||||
{
|
||||
byte[] message;
|
||||
lock (sendLock)
|
||||
{
|
||||
held = false;
|
||||
|
||||
var message = sendNetworkBuffer.Read();
|
||||
|
||||
if (message == null)
|
||||
return;
|
||||
|
||||
totalSent += message.Length;
|
||||
|
||||
sock.SendAsync(new ArraySegment<byte>(message), WebSocketMessageType.Binary,
|
||||
true, new System.Threading.CancellationToken());
|
||||
|
||||
message = sendNetworkBuffer.Read();
|
||||
}
|
||||
|
||||
if (message != null)
|
||||
ObserveSend(QueueSend(message));
|
||||
}
|
||||
|
||||
public async AsyncReply<bool> SendAsync(byte[] message, int offset, int length)
|
||||
{
|
||||
if (held)
|
||||
byte[] queued = null;
|
||||
lock (sendLock)
|
||||
{
|
||||
sendNetworkBuffer.Write(message, (uint)offset, (uint)length);
|
||||
}
|
||||
else
|
||||
{
|
||||
totalSent += length;
|
||||
|
||||
await sock.SendAsync(new ArraySegment<byte>(message, offset, length),
|
||||
WebSocketMessageType.Binary, true, new System.Threading.CancellationToken());
|
||||
if (held)
|
||||
{
|
||||
sendNetworkBuffer.Write(message, (uint)offset, (uint)length);
|
||||
}
|
||||
else
|
||||
{
|
||||
queued = new byte[length];
|
||||
Buffer.BlockCopy(message, offset, queued, 0, length);
|
||||
}
|
||||
}
|
||||
|
||||
if (queued != null)
|
||||
await QueueSend(queued);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async Task QueueSend(byte[] message)
|
||||
{
|
||||
await sendSemaphore.WaitAsync();
|
||||
try
|
||||
{
|
||||
var socket = sock ?? throw new InvalidOperationException("WebSocket is closed.");
|
||||
await socket.SendAsync(
|
||||
new ArraySegment<byte>(message),
|
||||
WebSocketMessageType.Binary,
|
||||
true,
|
||||
CancellationToken.None);
|
||||
Interlocked.Add(ref totalSent, message.Length);
|
||||
}
|
||||
catch
|
||||
{
|
||||
NotifySendFailure();
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
sendSemaphore.Release();
|
||||
}
|
||||
}
|
||||
|
||||
void ObserveSend(Task task)
|
||||
{
|
||||
_ = task.ContinueWith(
|
||||
completed =>
|
||||
{
|
||||
// Observe the exception; QueueSend already closed/notified the receiver.
|
||||
_ = completed.Exception;
|
||||
},
|
||||
CancellationToken.None,
|
||||
TaskContinuationOptions.OnlyOnFaulted,
|
||||
TaskScheduler.Default);
|
||||
}
|
||||
|
||||
void NotifySendFailure()
|
||||
{
|
||||
if (Interlocked.Exchange(ref sendFailureNotified, 1) == 0)
|
||||
{
|
||||
try { sock?.Abort(); } catch { }
|
||||
Receiver?.NetworkClose(this);
|
||||
}
|
||||
}
|
||||
|
||||
public ISocket Accept()
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,7 @@ using Esiur.Data;
|
||||
using Esiur.Net.Packets;
|
||||
using Esiur.Resource;
|
||||
using Esiur.Security.Authority;
|
||||
using Esiur.Security.Cryptography;
|
||||
using Esiur.Security.Membership;
|
||||
using Esiur.Security.Permissions;
|
||||
using System;
|
||||
@@ -40,6 +41,16 @@ public class EpConnectionContext : IResourceContext
|
||||
public string Identity { get; set; }
|
||||
public string AuthenticationProtocol { get; set; } = "hash";
|
||||
|
||||
/// <summary>
|
||||
/// Controls whether the authenticated session key must protect EP traffic.
|
||||
/// </summary>
|
||||
public EncryptionMode EncryptionMode { get; set; } = EncryptionMode.None;
|
||||
|
||||
/// <summary>
|
||||
/// Encryption provider protocol names offered to the responder, in preference order.
|
||||
/// </summary>
|
||||
public string[] EncryptionProviders { get; set; } = new[] { "aes-gcm" };
|
||||
|
||||
public bool AutoReconnect { get; set; } = false;
|
||||
|
||||
public uint ReconnectInterval { get; set; } = 5;
|
||||
|
||||
@@ -59,6 +59,17 @@ public class EpServer : NetworkServer<EpConnection>, IResource
|
||||
//[Attribute]
|
||||
public string[] AllowedAuthenticationProviders { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Encryption provider protocol names that incoming connections may negotiate.
|
||||
/// Providers must also be registered with the server Warehouse.
|
||||
/// </summary>
|
||||
public string[] AllowedEncryptionProviders { get; set; } = Array.Empty<string>();
|
||||
|
||||
/// <summary>
|
||||
/// Rejects incoming sessions that do not request authenticated encryption.
|
||||
/// </summary>
|
||||
public bool RequireEncryption { get; set; }
|
||||
|
||||
//[Attribute]
|
||||
public bool AllowUnauthorizedAccess { get; set; }
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
using Esiur.Security.Management;
|
||||
using System;
|
||||
|
||||
namespace Esiur.Resource;
|
||||
|
||||
/// <summary>
|
||||
/// Associates a registered auditing-manager implementation with a resource type.
|
||||
/// </summary>
|
||||
[AttributeUsage(AttributeTargets.Class, AllowMultiple = true, Inherited = true)]
|
||||
public sealed class AuditingManagerAttribute<T> : Attribute
|
||||
where T : IAuditingManager
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
using Esiur.Security.Permissions;
|
||||
using System;
|
||||
|
||||
namespace Esiur.Resource;
|
||||
|
||||
/// <summary>
|
||||
/// Associates a registered permissions-manager implementation with a resource type.
|
||||
/// </summary>
|
||||
[AttributeUsage(AttributeTargets.Class, AllowMultiple = true, Inherited = true)]
|
||||
public sealed class PermissionsManagerAttribute<T> : Attribute
|
||||
where T : IPermissionsManager
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
using Esiur.Security.Management;
|
||||
using System;
|
||||
|
||||
namespace Esiur.Resource;
|
||||
|
||||
/// <summary>
|
||||
/// Associates a registered rate-control-manager implementation with a resource type.
|
||||
/// </summary>
|
||||
[AttributeUsage(AttributeTargets.Class, AllowMultiple = true, Inherited = true)]
|
||||
public sealed class RateControlManagerAttribute<T> : Attribute
|
||||
where T : IRateControlManager
|
||||
{
|
||||
}
|
||||
@@ -30,6 +30,7 @@ using Esiur.Net.Packets;
|
||||
using Esiur.Protocol;
|
||||
using Esiur.Proxy;
|
||||
using Esiur.Security.Authority;
|
||||
using Esiur.Security.Cryptography;
|
||||
using Esiur.Security.Permissions;
|
||||
using Esiur.Security.RateLimiting;
|
||||
using Org.BouncyCastle.Asn1.Cms;
|
||||
@@ -88,6 +89,8 @@ public class Warehouse
|
||||
|
||||
|
||||
Map<string, IAuthenticationProvider> _authenticationProviders = new Map<string, IAuthenticationProvider>();
|
||||
readonly ConcurrentDictionary<string, IEncryptionProvider> _encryptionProviders
|
||||
= new ConcurrentDictionary<string, IEncryptionProvider>(StringComparer.Ordinal);
|
||||
List<IPermissionsManager> _permissionsManagers = new List<IPermissionsManager>();
|
||||
readonly ConcurrentDictionary<string, RatePolicy> _ratePolicies
|
||||
= new ConcurrentDictionary<string, RatePolicy>(StringComparer.Ordinal);
|
||||
@@ -123,6 +126,30 @@ public class Warehouse
|
||||
_authenticationProviders.Add(name, provider);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers an encryption provider using its default protocol name.
|
||||
/// </summary>
|
||||
public void RegisterEncryptionProvider(IEncryptionProvider provider)
|
||||
{
|
||||
if (provider == null)
|
||||
throw new ArgumentNullException(nameof(provider));
|
||||
|
||||
RegisterEncryptionProvider(provider.DefaultName, provider);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers an encryption provider under a protocol name used during session negotiation.
|
||||
/// </summary>
|
||||
public void RegisterEncryptionProvider(string name, IEncryptionProvider provider)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
throw new ArgumentException("An encryption provider name is required.", nameof(name));
|
||||
if (provider == null)
|
||||
throw new ArgumentNullException(nameof(provider));
|
||||
if (!_encryptionProviders.TryAdd(name, provider))
|
||||
throw new InvalidOperationException($"An encryption provider named `{name}` is already registered.");
|
||||
}
|
||||
|
||||
|
||||
public void RegisterPermissionsManager(IPermissionsManager manager)
|
||||
{
|
||||
@@ -179,6 +206,32 @@ public class Warehouse
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a registered encryption provider or throws when the protocol is unavailable.
|
||||
/// </summary>
|
||||
public IEncryptionProvider GetEncryptionProvider(string name)
|
||||
{
|
||||
if (TryGetEncryptionProvider(name) is { } provider)
|
||||
return provider;
|
||||
|
||||
throw new InvalidOperationException($"Encryption provider `{name}` was not found.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to get a registered encryption provider by protocol name.
|
||||
/// </summary>
|
||||
public IEncryptionProvider? TryGetEncryptionProvider(string name)
|
||||
=> !string.IsNullOrWhiteSpace(name)
|
||||
&& _encryptionProviders.TryGetValue(name, out var provider)
|
||||
? provider
|
||||
: null;
|
||||
|
||||
/// <summary>
|
||||
/// Returns a snapshot of encryption protocol names registered in this Warehouse.
|
||||
/// </summary>
|
||||
public string[] GetEncryptionProviderNames()
|
||||
=> _encryptionProviders.Keys.OrderBy(x => x, StringComparer.Ordinal).ToArray();
|
||||
|
||||
|
||||
public Warehouse() : this(new WarehouseConfiguration())
|
||||
{
|
||||
|
||||
@@ -11,6 +11,16 @@ public sealed class WarehouseConfiguration
|
||||
public ParserConfiguration Parser { get; set; } = new ParserConfiguration();
|
||||
public ResourceAttachmentConfiguration ResourceAttachments { get; set; } = new ResourceAttachmentConfiguration();
|
||||
public ConnectionConfiguration Connections { get; set; } = new ConnectionConfiguration();
|
||||
public EncryptionConfiguration Encryption { get; set; } = new EncryptionConfiguration();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bounds encrypted transport records before any peer-controlled allocation occurs.
|
||||
/// A value of zero disables the limit.
|
||||
/// </summary>
|
||||
public sealed class EncryptionConfiguration
|
||||
{
|
||||
public uint MaximumRecordSize { get; set; } = 8 * 1024 * 1024 + 1024;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -55,7 +55,7 @@ public sealed class SessionHeaders : IndexedStructure
|
||||
public object? SupportedHashAlgorithms { get; set; }
|
||||
|
||||
[Index((int)EpAuthPacketHeader.SupportedCiphers)]
|
||||
public object? SupportedCiphers { get; set; }
|
||||
public string[]? SupportedCiphers { get; set; }
|
||||
|
||||
[Index((int)EpAuthPacketHeader.SupportedCompression)]
|
||||
public object? SupportedCompression { get; set; }
|
||||
@@ -64,7 +64,7 @@ public sealed class SessionHeaders : IndexedStructure
|
||||
public object? SupportedMultiFactorAuthentications { get; set; }
|
||||
|
||||
[Index((int)EpAuthPacketHeader.CipherType)]
|
||||
public object? CipherType { get; set; }
|
||||
public string? CipherType { get; set; }
|
||||
|
||||
[Index((int)EpAuthPacketHeader.CipherKey)]
|
||||
public byte[]? CipherKey { get; set; }
|
||||
@@ -93,6 +93,13 @@ public sealed class SessionHeaders : IndexedStructure
|
||||
[Index((int)EpAuthPacketHeader.ErrorMessage)]
|
||||
public string? ErrorMessage { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Fresh public nonce used with the authenticated session key to derive unique
|
||||
/// per-connection encryption keys. This value is not a secret key.
|
||||
/// </summary>
|
||||
[Index((int)EpAuthPacketHeader.CipherNonce)]
|
||||
public byte[]? CipherNonce { get; set; }
|
||||
|
||||
internal SessionHeaders Copy() => (SessionHeaders)MemberwiseClone();
|
||||
}
|
||||
|
||||
@@ -107,6 +114,8 @@ public class Session
|
||||
|
||||
//public IKeyExchanger KeyExchanger { get; set; } = null;
|
||||
public ISymetricCipher SymetricCipher { get; set; } = null;
|
||||
public IEncryptionProvider EncryptionProvider { get; set; } = null;
|
||||
public bool EncryptionActive { get; internal set; }
|
||||
|
||||
|
||||
public SessionHeaders LocalHeaders { get; set; } = new SessionHeaders();
|
||||
|
||||
@@ -0,0 +1,482 @@
|
||||
using Esiur.Security.Authority;
|
||||
using Org.BouncyCastle.Crypto;
|
||||
using Org.BouncyCastle.Crypto.Digests;
|
||||
using Org.BouncyCastle.Crypto.Generators;
|
||||
using Org.BouncyCastle.Crypto.Modes;
|
||||
using Org.BouncyCastle.Crypto.Parameters;
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace Esiur.Security.Cryptography;
|
||||
|
||||
/// <summary>
|
||||
/// Creates AES-256-GCM record ciphers. Session keys and nonce prefixes are
|
||||
/// derived with HKDF-SHA256 and separated by protocol direction and purpose.
|
||||
/// </summary>
|
||||
public sealed class AesEncryptionProvider : IEncryptionProvider
|
||||
{
|
||||
public const string Name = "aes-gcm";
|
||||
|
||||
public string DefaultName => Name;
|
||||
|
||||
public uint MaximumRecordOverhead => 8 + 16;
|
||||
|
||||
public ISymetricCipher CreateCipher(EncryptionContext context)
|
||||
=> new AesGcmSymetricCipher(context);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// AES-256-GCM session record cipher.
|
||||
///
|
||||
/// A record contains an eight-byte, big-endian sequence followed by ciphertext
|
||||
/// and a sixteen-byte GCM tag. The transport's four-byte record length and the
|
||||
/// sequence are authenticated as associated data. Sequence numbers are implicit
|
||||
/// state as well as explicit record fields, so replay and reordering fail closed.
|
||||
/// </summary>
|
||||
public sealed class AesGcmSymetricCipher : ISymetricCipher, IDisposable
|
||||
{
|
||||
const int KeySize = 32;
|
||||
const int NoncePrefixSize = 4;
|
||||
const int SequenceSize = 8;
|
||||
const int TagSize = 16;
|
||||
const int RecordOverhead = SequenceSize + TagSize;
|
||||
const int AadSize = 4 + SequenceSize;
|
||||
const int MinimumSharedKeySize = 16;
|
||||
const int MaximumSharedKeySize = 1024;
|
||||
const int MinimumPeerNonceSize = 16;
|
||||
const int MaximumPeerNonceSize = 64;
|
||||
|
||||
static readonly byte[] ContextLabel = Encoding.ASCII.GetBytes("esiur/ep/aes-256-gcm/context/v3");
|
||||
static readonly byte[] InitiatorToResponderKeyLabel = Encoding.ASCII.GetBytes("esiur/ep/aes-256-gcm/v1/initiator-to-responder/key");
|
||||
static readonly byte[] ResponderToInitiatorKeyLabel = Encoding.ASCII.GetBytes("esiur/ep/aes-256-gcm/v1/responder-to-initiator/key");
|
||||
static readonly byte[] InitiatorToResponderNonceLabel = Encoding.ASCII.GetBytes("esiur/ep/aes-256-gcm/v1/initiator-to-responder/nonce");
|
||||
static readonly byte[] ResponderToInitiatorNonceLabel = Encoding.ASCII.GetBytes("esiur/ep/aes-256-gcm/v1/responder-to-initiator/nonce");
|
||||
|
||||
readonly object _sendLock = new object();
|
||||
readonly object _receiveLock = new object();
|
||||
readonly byte[] _contextSalt;
|
||||
readonly byte[] _sendKeyLabel;
|
||||
readonly byte[] _receiveKeyLabel;
|
||||
readonly byte[] _sendNonceLabel;
|
||||
readonly byte[] _receiveNonceLabel;
|
||||
|
||||
byte[] _sendKey;
|
||||
byte[] _receiveKey;
|
||||
byte[] _sendNoncePrefix;
|
||||
byte[] _receiveNoncePrefix;
|
||||
ulong _sendSequence;
|
||||
ulong _receiveSequence;
|
||||
bool _keyInitialized;
|
||||
bool _disposed;
|
||||
|
||||
internal AesGcmSymetricCipher(EncryptionContext context)
|
||||
{
|
||||
if (context == null)
|
||||
throw new ArgumentNullException(nameof(context));
|
||||
|
||||
ValidateContext(context);
|
||||
|
||||
_contextSalt = ComposeContextSalt(context);
|
||||
|
||||
if (context.Direction == AuthenticationDirection.Initiator)
|
||||
{
|
||||
_sendKeyLabel = InitiatorToResponderKeyLabel;
|
||||
_receiveKeyLabel = ResponderToInitiatorKeyLabel;
|
||||
_sendNonceLabel = InitiatorToResponderNonceLabel;
|
||||
_receiveNonceLabel = ResponderToInitiatorNonceLabel;
|
||||
}
|
||||
else
|
||||
{
|
||||
_sendKeyLabel = ResponderToInitiatorKeyLabel;
|
||||
_receiveKeyLabel = InitiatorToResponderKeyLabel;
|
||||
_sendNonceLabel = ResponderToInitiatorNonceLabel;
|
||||
_receiveNonceLabel = InitiatorToResponderNonceLabel;
|
||||
}
|
||||
|
||||
var acceptedKey = SetKey(context.Key);
|
||||
Clear(acceptedKey);
|
||||
}
|
||||
|
||||
/// <summary>Identifier retained for compatibility with <see cref="ISymetricCipher"/>.</summary>
|
||||
public ushort Identifier => (ushort)SymetricEncryptionAlgorithmType.AES;
|
||||
|
||||
public byte[] Encrypt(byte[] data)
|
||||
{
|
||||
if (data == null)
|
||||
throw new ArgumentNullException(nameof(data));
|
||||
|
||||
if (data.Length > int.MaxValue - RecordOverhead)
|
||||
throw new ArgumentOutOfRangeException(nameof(data), "The encrypted record exceeds the 32-bit record length.");
|
||||
|
||||
lock (_sendLock)
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
|
||||
if (_sendSequence == ulong.MaxValue)
|
||||
throw new CryptographicException("The AES-GCM send sequence is exhausted.");
|
||||
|
||||
var sequence = _sendSequence;
|
||||
var recordLength = data.Length + RecordOverhead;
|
||||
var nonce = ComposeNonce(_sendNoncePrefix, sequence);
|
||||
var aad = ComposeAad((uint)recordLength, sequence);
|
||||
var cipherText = new byte[data.Length + TagSize];
|
||||
|
||||
try
|
||||
{
|
||||
var cipher = new GcmBlockCipher(AesUtilities.CreateEngine());
|
||||
cipher.Init(true, new AeadParameters(new KeyParameter(_sendKey), TagSize * 8, nonce, aad));
|
||||
|
||||
var written = cipher.ProcessBytes(data, 0, data.Length, cipherText, 0);
|
||||
written += cipher.DoFinal(cipherText, written);
|
||||
|
||||
if (written != cipherText.Length)
|
||||
throw new CryptographicException("AES-GCM produced an invalid record length.");
|
||||
|
||||
var record = new byte[recordLength];
|
||||
WriteUInt64BigEndian(record, 0, sequence);
|
||||
Buffer.BlockCopy(cipherText, 0, record, SequenceSize, cipherText.Length);
|
||||
|
||||
_sendSequence++;
|
||||
return record;
|
||||
}
|
||||
catch (CryptoException ex)
|
||||
{
|
||||
throw new CryptographicException("AES-GCM record encryption failed.", ex);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Clear(nonce);
|
||||
Clear(aad);
|
||||
Clear(cipherText);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public byte[] Decrypt(byte[] data)
|
||||
{
|
||||
if (data == null)
|
||||
throw new ArgumentNullException(nameof(data));
|
||||
if (data.Length < RecordOverhead)
|
||||
throw new CryptographicException("The AES-GCM record is truncated.");
|
||||
|
||||
lock (_receiveLock)
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
|
||||
var sequence = ReadUInt64BigEndian(data, 0);
|
||||
if (sequence != _receiveSequence)
|
||||
throw new CryptographicException("The AES-GCM record sequence is invalid.");
|
||||
|
||||
if (_receiveSequence == ulong.MaxValue)
|
||||
throw new CryptographicException("The AES-GCM receive sequence is exhausted.");
|
||||
|
||||
var recordLength = checked((uint)data.Length);
|
||||
var cipherTextLength = data.Length - SequenceSize;
|
||||
var plainText = new byte[cipherTextLength - TagSize];
|
||||
var nonce = ComposeNonce(_receiveNoncePrefix, sequence);
|
||||
var aad = ComposeAad(recordLength, sequence);
|
||||
|
||||
try
|
||||
{
|
||||
var cipher = new GcmBlockCipher(AesUtilities.CreateEngine());
|
||||
cipher.Init(false, new AeadParameters(new KeyParameter(_receiveKey), TagSize * 8, nonce, aad));
|
||||
|
||||
var written = cipher.ProcessBytes(data, SequenceSize, cipherTextLength, plainText, 0);
|
||||
written += cipher.DoFinal(plainText, written);
|
||||
|
||||
if (written != plainText.Length)
|
||||
throw new CryptographicException("AES-GCM produced an invalid plaintext length.");
|
||||
|
||||
_receiveSequence++;
|
||||
return plainText;
|
||||
}
|
||||
catch (CryptoException ex)
|
||||
{
|
||||
Clear(plainText);
|
||||
throw new CryptographicException("AES-GCM record authentication failed.", ex);
|
||||
}
|
||||
catch
|
||||
{
|
||||
Clear(plainText);
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
Clear(nonce);
|
||||
Clear(aad);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes the cipher key. Session ciphers are deliberately immutable after
|
||||
/// construction because resetting a key would also reset the GCM nonce sequence.
|
||||
/// Create a new cipher with fresh peer nonces to use different key material.
|
||||
/// </summary>
|
||||
public byte[] SetKey(byte[] key)
|
||||
{
|
||||
ValidateSharedKey(key);
|
||||
|
||||
lock (_sendLock)
|
||||
{
|
||||
lock (_receiveLock)
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
|
||||
if (_keyInitialized)
|
||||
throw new InvalidOperationException(
|
||||
"AES-GCM session keys are immutable; create a new cipher with fresh peer nonces.");
|
||||
|
||||
byte[] sendKey = null;
|
||||
byte[] receiveKey = null;
|
||||
byte[] sendNoncePrefix = null;
|
||||
byte[] receiveNoncePrefix = null;
|
||||
|
||||
try
|
||||
{
|
||||
sendKey = Derive(key, _contextSalt, _sendKeyLabel, KeySize);
|
||||
receiveKey = Derive(key, _contextSalt, _receiveKeyLabel, KeySize);
|
||||
sendNoncePrefix = Derive(key, _contextSalt, _sendNonceLabel, NoncePrefixSize);
|
||||
receiveNoncePrefix = Derive(key, _contextSalt, _receiveNonceLabel, NoncePrefixSize);
|
||||
|
||||
Clear(_sendKey);
|
||||
Clear(_receiveKey);
|
||||
Clear(_sendNoncePrefix);
|
||||
Clear(_receiveNoncePrefix);
|
||||
|
||||
_sendKey = sendKey;
|
||||
_receiveKey = receiveKey;
|
||||
_sendNoncePrefix = sendNoncePrefix;
|
||||
_receiveNoncePrefix = receiveNoncePrefix;
|
||||
sendKey = null;
|
||||
receiveKey = null;
|
||||
sendNoncePrefix = null;
|
||||
receiveNoncePrefix = null;
|
||||
_sendSequence = 0;
|
||||
_receiveSequence = 0;
|
||||
_keyInitialized = true;
|
||||
|
||||
return (byte[])key.Clone();
|
||||
}
|
||||
finally
|
||||
{
|
||||
Clear(sendKey);
|
||||
Clear(receiveKey);
|
||||
Clear(sendNoncePrefix);
|
||||
Clear(receiveNoncePrefix);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
lock (_sendLock)
|
||||
{
|
||||
lock (_receiveLock)
|
||||
{
|
||||
if (_disposed)
|
||||
return;
|
||||
|
||||
_disposed = true;
|
||||
Clear(_sendKey);
|
||||
Clear(_receiveKey);
|
||||
Clear(_sendNoncePrefix);
|
||||
Clear(_receiveNoncePrefix);
|
||||
Clear(_contextSalt);
|
||||
_sendKey = null;
|
||||
_receiveKey = null;
|
||||
_sendNoncePrefix = null;
|
||||
_receiveNoncePrefix = null;
|
||||
_sendSequence = 0;
|
||||
_receiveSequence = 0;
|
||||
}
|
||||
}
|
||||
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
static void ValidateContext(EncryptionContext context)
|
||||
{
|
||||
ValidateSharedKey(context.Key);
|
||||
ValidateNonce(context.InitiatorNonce, nameof(context.InitiatorNonce));
|
||||
ValidateNonce(context.ResponderNonce, nameof(context.ResponderNonce));
|
||||
|
||||
if (context.Direction != AuthenticationDirection.Initiator &&
|
||||
context.Direction != AuthenticationDirection.Responder)
|
||||
throw new ArgumentOutOfRangeException(nameof(context.Direction));
|
||||
|
||||
if (context.Mode == EncryptionMode.None)
|
||||
throw new ArgumentException("AES-GCM requires an encrypted session mode.", nameof(context));
|
||||
if (context.Mode != EncryptionMode.EncryptWithSessionKey &&
|
||||
context.Mode != EncryptionMode.EncryptWithSessionKeyAndAddress)
|
||||
throw new ArgumentOutOfRangeException(nameof(context.Mode));
|
||||
if (string.IsNullOrWhiteSpace(context.Protocol))
|
||||
throw new ArgumentException("A negotiated encryption protocol is required.", nameof(context));
|
||||
if (context.OfferedProtocols == null || context.OfferedProtocols.Length == 0)
|
||||
throw new ArgumentException("The initiator's encryption offer is required.", nameof(context));
|
||||
if (context.OfferedProtocols.Any(string.IsNullOrWhiteSpace))
|
||||
throw new ArgumentException("Encryption offers cannot contain empty protocol names.", nameof(context));
|
||||
if (!context.OfferedProtocols.Contains(context.Protocol, StringComparer.Ordinal))
|
||||
throw new ArgumentException("The selected encryption protocol was not offered.", nameof(context));
|
||||
if (context.AuthenticationMode == AuthenticationMode.None)
|
||||
throw new ArgumentException("AES-GCM requires an authenticated session.", nameof(context));
|
||||
if (string.IsNullOrWhiteSpace(context.AuthenticationProtocol))
|
||||
throw new ArgumentException("A negotiated authentication protocol is required.", nameof(context));
|
||||
|
||||
if (context.Mode == EncryptionMode.EncryptWithSessionKeyAndAddress)
|
||||
{
|
||||
ValidateAddress(context.InitiatorAddress, nameof(context.InitiatorAddress));
|
||||
ValidateAddress(context.ResponderAddress, nameof(context.ResponderAddress));
|
||||
|
||||
if (IsUnspecifiedAddress(context.InitiatorAddress)
|
||||
|| IsUnspecifiedAddress(context.ResponderAddress))
|
||||
throw new ArgumentException(
|
||||
"Address-bound encryption requires concrete peer network addresses.",
|
||||
nameof(context));
|
||||
}
|
||||
}
|
||||
|
||||
static void ValidateSharedKey(byte[] key)
|
||||
{
|
||||
if (key == null)
|
||||
throw new ArgumentNullException(nameof(key));
|
||||
if (key.Length < MinimumSharedKeySize || key.Length > MaximumSharedKeySize)
|
||||
throw new ArgumentException(
|
||||
$"The shared key must contain between {MinimumSharedKeySize} and {MaximumSharedKeySize} bytes.",
|
||||
nameof(key));
|
||||
}
|
||||
|
||||
static void ValidateNonce(byte[] nonce, string parameterName)
|
||||
{
|
||||
if (nonce == null)
|
||||
throw new ArgumentNullException(parameterName);
|
||||
if (nonce.Length < MinimumPeerNonceSize || nonce.Length > MaximumPeerNonceSize)
|
||||
throw new ArgumentException(
|
||||
$"A cipher nonce must contain between {MinimumPeerNonceSize} and {MaximumPeerNonceSize} bytes.",
|
||||
parameterName);
|
||||
}
|
||||
|
||||
static void ValidateAddress(byte[] address, string parameterName)
|
||||
{
|
||||
if (address == null)
|
||||
throw new ArgumentNullException(parameterName);
|
||||
if (address.Length != 4 && address.Length != 16)
|
||||
throw new ArgumentException("An address must be an IPv4 or IPv6 byte sequence.", parameterName);
|
||||
}
|
||||
|
||||
static byte[] ComposeContextSalt(EncryptionContext context)
|
||||
{
|
||||
var digest = new Sha256Digest();
|
||||
digest.BlockUpdate(ContextLabel, 0, ContextLabel.Length);
|
||||
digest.Update((byte)context.AuthenticationMode);
|
||||
DigestLengthPrefixed(digest, Encoding.UTF8.GetBytes(context.AuthenticationProtocol));
|
||||
DigestLengthPrefixed(digest, Encoding.UTF8.GetBytes(context.Domain ?? string.Empty));
|
||||
digest.Update((byte)context.Mode);
|
||||
DigestUInt32(digest, checked((uint)context.OfferedProtocols.Length));
|
||||
foreach (var offeredProtocol in context.OfferedProtocols)
|
||||
DigestLengthPrefixed(digest, Encoding.UTF8.GetBytes(offeredProtocol));
|
||||
DigestLengthPrefixed(digest, Encoding.UTF8.GetBytes(context.Protocol));
|
||||
DigestLengthPrefixed(digest, context.InitiatorNonce);
|
||||
DigestLengthPrefixed(digest, context.ResponderNonce);
|
||||
|
||||
if (context.Mode == EncryptionMode.EncryptWithSessionKeyAndAddress)
|
||||
{
|
||||
DigestLengthPrefixed(digest, context.InitiatorAddress);
|
||||
DigestLengthPrefixed(digest, context.ResponderAddress);
|
||||
}
|
||||
|
||||
var salt = new byte[digest.GetDigestSize()];
|
||||
digest.DoFinal(salt, 0);
|
||||
return salt;
|
||||
}
|
||||
|
||||
static void DigestLengthPrefixed(Sha256Digest digest, byte[] value)
|
||||
{
|
||||
var length = new byte[4];
|
||||
WriteUInt32BigEndian(length, 0, checked((uint)value.Length));
|
||||
digest.BlockUpdate(length, 0, length.Length);
|
||||
digest.BlockUpdate(value, 0, value.Length);
|
||||
Clear(length);
|
||||
}
|
||||
|
||||
static void DigestUInt32(Sha256Digest digest, uint value)
|
||||
{
|
||||
var encoded = new byte[4];
|
||||
WriteUInt32BigEndian(encoded, 0, value);
|
||||
digest.BlockUpdate(encoded, 0, encoded.Length);
|
||||
Clear(encoded);
|
||||
}
|
||||
|
||||
static bool IsUnspecifiedAddress(byte[] address)
|
||||
=> address.All(value => value == 0);
|
||||
|
||||
static byte[] Derive(byte[] key, byte[] salt, byte[] label, int size)
|
||||
{
|
||||
var generator = new HkdfBytesGenerator(new Sha256Digest());
|
||||
generator.Init(new HkdfParameters(key, salt, label));
|
||||
var result = new byte[size];
|
||||
generator.GenerateBytes(result, 0, result.Length);
|
||||
return result;
|
||||
}
|
||||
|
||||
static byte[] ComposeNonce(byte[] prefix, ulong sequence)
|
||||
{
|
||||
var nonce = new byte[NoncePrefixSize + SequenceSize];
|
||||
Buffer.BlockCopy(prefix, 0, nonce, 0, NoncePrefixSize);
|
||||
WriteUInt64BigEndian(nonce, NoncePrefixSize, sequence);
|
||||
return nonce;
|
||||
}
|
||||
|
||||
static byte[] ComposeAad(uint recordLength, ulong sequence)
|
||||
{
|
||||
var aad = new byte[AadSize];
|
||||
WriteUInt32BigEndian(aad, 0, recordLength);
|
||||
WriteUInt64BigEndian(aad, 4, sequence);
|
||||
return aad;
|
||||
}
|
||||
|
||||
static void WriteUInt32BigEndian(byte[] destination, int offset, uint value)
|
||||
{
|
||||
destination[offset] = (byte)(value >> 24);
|
||||
destination[offset + 1] = (byte)(value >> 16);
|
||||
destination[offset + 2] = (byte)(value >> 8);
|
||||
destination[offset + 3] = (byte)value;
|
||||
}
|
||||
|
||||
static void WriteUInt64BigEndian(byte[] destination, int offset, ulong value)
|
||||
{
|
||||
destination[offset] = (byte)(value >> 56);
|
||||
destination[offset + 1] = (byte)(value >> 48);
|
||||
destination[offset + 2] = (byte)(value >> 40);
|
||||
destination[offset + 3] = (byte)(value >> 32);
|
||||
destination[offset + 4] = (byte)(value >> 24);
|
||||
destination[offset + 5] = (byte)(value >> 16);
|
||||
destination[offset + 6] = (byte)(value >> 8);
|
||||
destination[offset + 7] = (byte)value;
|
||||
}
|
||||
|
||||
static ulong ReadUInt64BigEndian(byte[] source, int offset)
|
||||
=> ((ulong)source[offset] << 56)
|
||||
| ((ulong)source[offset + 1] << 48)
|
||||
| ((ulong)source[offset + 2] << 40)
|
||||
| ((ulong)source[offset + 3] << 32)
|
||||
| ((ulong)source[offset + 4] << 24)
|
||||
| ((ulong)source[offset + 5] << 16)
|
||||
| ((ulong)source[offset + 6] << 8)
|
||||
| source[offset + 7];
|
||||
|
||||
static void Clear(byte[] value)
|
||||
{
|
||||
if (value != null)
|
||||
Array.Clear(value, 0, value.Length);
|
||||
}
|
||||
|
||||
void ThrowIfDisposed()
|
||||
{
|
||||
if (_disposed)
|
||||
throw new ObjectDisposedException(nameof(AesGcmSymetricCipher));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
using Esiur.Security.Authority;
|
||||
|
||||
namespace Esiur.Security.Cryptography;
|
||||
|
||||
/// <summary>
|
||||
/// Authenticated and negotiated material used to create a session cipher.
|
||||
/// Initiator and responder values always retain their protocol roles, regardless
|
||||
/// of which peer creates the cipher.
|
||||
/// </summary>
|
||||
public sealed class EncryptionContext
|
||||
{
|
||||
/// <summary>Shared secret produced by the authentication provider.</summary>
|
||||
public byte[] Key { get; set; }
|
||||
|
||||
/// <summary>Role of the peer creating the cipher.</summary>
|
||||
public AuthenticationDirection Direction { get; set; }
|
||||
|
||||
/// <summary>Negotiated encryption mode.</summary>
|
||||
public EncryptionMode Mode { get; set; }
|
||||
|
||||
/// <summary>Negotiated provider protocol name.</summary>
|
||||
public string Protocol { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Encryption protocols offered by the initiator, in their original wire order.
|
||||
/// Binding the complete offer prevents an intermediary from silently removing or
|
||||
/// reordering stronger choices during negotiation.
|
||||
/// </summary>
|
||||
public string[] OfferedProtocols { get; set; }
|
||||
|
||||
/// <summary>Authentication mode that produced the shared session key.</summary>
|
||||
public AuthenticationMode AuthenticationMode { get; set; }
|
||||
|
||||
/// <summary>Negotiated authentication protocol that produced the shared key.</summary>
|
||||
public string AuthenticationProtocol { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Authentication realm/domain requested by the initiator. This is part of the
|
||||
/// authenticated encryption transcript so credentials cannot be redirected to a
|
||||
/// different realm that happens to derive the same shared key.
|
||||
/// </summary>
|
||||
public string Domain { get; set; }
|
||||
|
||||
/// <summary>Fresh public nonce generated by the session initiator.</summary>
|
||||
public byte[] InitiatorNonce { get; set; }
|
||||
|
||||
/// <summary>Fresh public nonce generated by the session responder.</summary>
|
||||
public byte[] ResponderNonce { get; set; }
|
||||
|
||||
/// <summary>Initiator address used by address-bound encryption mode.</summary>
|
||||
public byte[] InitiatorAddress { get; set; }
|
||||
|
||||
/// <summary>Responder address used by address-bound encryption mode.</summary>
|
||||
public byte[] ResponderAddress { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
namespace Esiur.Security.Cryptography;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a per-session symmetric cipher from authenticated session material.
|
||||
/// Providers are registered and negotiated by <see cref="DefaultName"/>.
|
||||
/// </summary>
|
||||
public interface IEncryptionProvider
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the stable protocol name advertised during connection establishment.
|
||||
/// </summary>
|
||||
string DefaultName { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Maximum number of bytes added by <see cref="ISymetricCipher.Encrypt(byte[])"/>
|
||||
/// to one plaintext record. The transport uses this before encryption so a rejected
|
||||
/// oversized send cannot consume a cipher sequence number.
|
||||
/// </summary>
|
||||
uint MaximumRecordOverhead { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates an independent authenticated record cipher for one session.
|
||||
/// Implementations must derive independent directional keys/nonces while binding
|
||||
/// every negotiation field in <paramref name="context"/>, authenticate each record,
|
||||
/// reject replay/reordering, and fail closed on authentication errors. A cipher and
|
||||
/// its nonce sequence must never be reused for another connection.
|
||||
/// </summary>
|
||||
ISymetricCipher CreateCipher(EncryptionContext context);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
using Esiur.Security.Permissions;
|
||||
|
||||
namespace Esiur.Security.Management;
|
||||
|
||||
/// <summary>
|
||||
/// Audits a resource operation before it is executed. A
|
||||
/// <see cref="Ruling.Denied"/> result may veto the operation, while
|
||||
/// <see cref="Ruling.Allowed"/> and <see cref="Ruling.DontCare"/> never grant
|
||||
/// authorization or override another manager's denial.
|
||||
/// </summary>
|
||||
public interface IAuditingManager : IResourceManager
|
||||
{
|
||||
Ruling Applicable(ResourceManagerContext context);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
using Esiur.Security.Permissions;
|
||||
|
||||
namespace Esiur.Security.Management;
|
||||
|
||||
/// <summary>
|
||||
/// Evaluates whether a resource operation is admitted by rate-control policy.
|
||||
/// A manager may assign <see cref="ResourceManagerContext.Delay"/> when allowing
|
||||
/// an operation that should be queued.
|
||||
/// </summary>
|
||||
public interface IRateControlManager : IResourceManager
|
||||
{
|
||||
Ruling Applicable(ResourceManagerContext context);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace Esiur.Security.Management;
|
||||
|
||||
/// <summary>
|
||||
/// Identifies a manager that participates in resource operation processing.
|
||||
/// Category interfaces add the behavior appropriate to each manager type.
|
||||
/// </summary>
|
||||
public interface IResourceManager
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
using Esiur.Data.Types;
|
||||
using Esiur.Protocol;
|
||||
using Esiur.Resource;
|
||||
using Esiur.Security.Authority;
|
||||
using Esiur.Security.Permissions;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace Esiur.Security.Management;
|
||||
|
||||
/// <summary>
|
||||
/// Immutable metadata describing a resource operation being evaluated by managers.
|
||||
/// <see cref="Delay"/> is the sole mutable value and allows rate-control managers to
|
||||
/// request deferred execution.
|
||||
/// </summary>
|
||||
public sealed class ResourceManagerContext
|
||||
{
|
||||
readonly IReadOnlyList<Attribute> _memberPolicyAttributes;
|
||||
|
||||
public Warehouse Warehouse { get; }
|
||||
public EpConnection? Connection { get; }
|
||||
public Session? Session { get; }
|
||||
public IResource? Resource { get; }
|
||||
public MemberDef? Member { get; }
|
||||
public ActionType Action { get; }
|
||||
public object? Inquirer { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the local attributes that configure manager policy for the target member.
|
||||
/// The collection is a defensive, read-only snapshot.
|
||||
/// </summary>
|
||||
public IReadOnlyList<Attribute> MemberPolicyAttributes => _memberPolicyAttributes;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets an optional delay requested by a rate-control manager.
|
||||
/// </summary>
|
||||
public TimeSpan Delay { get; set; }
|
||||
|
||||
public ResourceManagerContext(
|
||||
Warehouse warehouse,
|
||||
EpConnection? connection,
|
||||
Session? session,
|
||||
IResource? resource,
|
||||
MemberDef? member,
|
||||
ActionType action,
|
||||
object? inquirer = null,
|
||||
IEnumerable<Attribute>? memberPolicyAttributes = null)
|
||||
{
|
||||
Warehouse = warehouse ?? throw new ArgumentNullException(nameof(warehouse));
|
||||
Connection = connection;
|
||||
Session = session;
|
||||
Resource = resource;
|
||||
Member = member;
|
||||
Action = action;
|
||||
Inquirer = inquirer;
|
||||
|
||||
var policies = memberPolicyAttributes?.ToArray() ?? Array.Empty<Attribute>();
|
||||
if (policies.Any(attribute => attribute == null))
|
||||
throw new ArgumentException(
|
||||
"Member policy attributes cannot contain null values.",
|
||||
nameof(memberPolicyAttributes));
|
||||
|
||||
_memberPolicyAttributes = Array.AsReadOnly(policies);
|
||||
}
|
||||
}
|
||||
@@ -33,10 +33,11 @@ using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using Esiur.Data.Types;
|
||||
using Esiur.Security.Management;
|
||||
|
||||
namespace Esiur.Security.Permissions;
|
||||
|
||||
public interface IPermissionsManager
|
||||
public interface IPermissionsManager : IResourceManager
|
||||
{
|
||||
/// <summary>
|
||||
/// Check for permission.
|
||||
|
||||
Reference in New Issue
Block a user