mirror of
https://github.com/esiur/esiur-dotnet.git
synced 2026-07-31 01:40:42 +00:00
PPAP integration
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
namespace Esiur.Security.Authority;
|
||||
|
||||
/// <summary>
|
||||
/// Result returned by a post-encryption authentication key-rotation step.
|
||||
/// </summary>
|
||||
public sealed class AuthenticationKeyRotationResult
|
||||
{
|
||||
public AuthenticationKeyRotationRuling Ruling { get; }
|
||||
public object Data { get; }
|
||||
public string Error { get; }
|
||||
|
||||
public AuthenticationKeyRotationResult(
|
||||
AuthenticationKeyRotationRuling ruling,
|
||||
object data = null,
|
||||
string error = null)
|
||||
{
|
||||
Ruling = ruling;
|
||||
Data = data;
|
||||
Error = error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace Esiur.Security.Authority;
|
||||
|
||||
/// <summary>
|
||||
/// Describes the current state of a post-encryption authentication key rotation.
|
||||
/// </summary>
|
||||
public enum AuthenticationKeyRotationRuling
|
||||
{
|
||||
Failed,
|
||||
InProgress,
|
||||
Succeeded,
|
||||
}
|
||||
@@ -6,7 +6,11 @@ namespace Esiur.Security.Authority
|
||||
{
|
||||
public enum AuthenticationProtocol
|
||||
{
|
||||
Hash = 0,
|
||||
Password = 0,
|
||||
|
||||
[Obsolete("Use Password instead.")]
|
||||
Hash = Password,
|
||||
|
||||
PPAP = 1,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,7 +26,17 @@ namespace Esiur.Security.Authority
|
||||
LocalIdentity = localIdentity;
|
||||
RemoteIdentity = remoteIdentity;
|
||||
AuthenticationData = authenticationData;
|
||||
SessionKey = sessionKey;
|
||||
// AuthenticationResult owns its key buffer. This lets transports erase the
|
||||
// short-lived handoff copy after adopting their own session key without
|
||||
// mutating provider-wide or cached key material.
|
||||
SessionKey = sessionKey == null ? null : (byte[])sessionKey.Clone();
|
||||
}
|
||||
|
||||
internal void ClearSessionKey()
|
||||
{
|
||||
if (SessionKey != null)
|
||||
Array.Clear(SessionKey, 0, SessionKey.Length);
|
||||
SessionKey = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
namespace Esiur.Security.Authority;
|
||||
|
||||
/// <summary>
|
||||
/// Optional authentication-handler capability for rotating authentication keys after
|
||||
/// the initial session key has enabled authenticated encrypted transport protection.
|
||||
/// Transport implementations MUST NOT call either exchange method or transmit their
|
||||
/// data before that protection is active; rotation payloads can contain fresh verifier
|
||||
/// material that must never appear on a plaintext channel.
|
||||
/// </summary>
|
||||
public interface IAuthenticationKeyRotationHandler
|
||||
{
|
||||
bool RequiresKeyRotation { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Starts key rotation on the connection initiator. This is called only after the
|
||||
/// encrypted Established event has been received.
|
||||
/// </summary>
|
||||
AuthenticationKeyRotationResult BeginKeyRotation();
|
||||
|
||||
/// <summary>
|
||||
/// Processes one encrypted key-rotation message from the peer.
|
||||
/// </summary>
|
||||
AuthenticationKeyRotationResult ProcessKeyRotation(object data);
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
using Esiur.Core;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text;
|
||||
|
||||
namespace Esiur.Security.Authority
|
||||
{
|
||||
internal class PpapAuthenticationProvider : IAuthenticationProvider
|
||||
{
|
||||
public string DefaultName => "PPAP";
|
||||
|
||||
public IAuthenticationHandler CreateAuthenticationHandler(AuthenticationContext context)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
public AsyncReply<bool> Login(Session session)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
public AsyncReply<bool> Logout(Session session)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,8 @@ using Esiur.Data.Types;
|
||||
namespace Esiur.Security.Authority.Providers
|
||||
{
|
||||
/// <summary>
|
||||
/// Implements the "hash" authentication protocol: a SHA3 nonce/challenge-response
|
||||
/// Implements the "password-sha3-v1" authentication protocol: a SHA3
|
||||
/// nonce/challenge-response
|
||||
/// handshake that mutually proves knowledge of a salted password hash without sending
|
||||
/// the password, and derives a 512-bit session key. Supports initiator-only, responder-only
|
||||
/// and dual identity modes. All challenge comparisons are constant-time and remote material
|
||||
@@ -18,7 +19,7 @@ namespace Esiur.Security.Authority.Providers
|
||||
/// </summary>
|
||||
public class PasswordAuthenticationHandler : IAuthenticationHandler
|
||||
{
|
||||
public string Protocol => "hash";
|
||||
public string Protocol => PasswordAuthenticationProvider.ProtocolName;
|
||||
|
||||
// Length, in bytes, of the random nonces exchanged during the handshake.
|
||||
// Remote nonces are validated against this to reject malformed or weak input.
|
||||
|
||||
@@ -7,7 +7,19 @@ namespace Esiur.Security.Authority.Providers
|
||||
{
|
||||
public class PasswordAuthenticationProvider : IAuthenticationProvider
|
||||
{
|
||||
public string DefaultName => "hash";
|
||||
/// <summary>
|
||||
/// Canonical protocol name used for registration and EP negotiation.
|
||||
/// </summary>
|
||||
public const string ProtocolName = "password-sha3-v1";
|
||||
|
||||
/// <summary>
|
||||
/// Previous protocol name, retained only to make explicit migration aliases possible.
|
||||
/// New connections should use <see cref="ProtocolName"/>.
|
||||
/// </summary>
|
||||
[Obsolete("Use ProtocolName (`password-sha3-v1`) for new connections.")]
|
||||
public const string LegacyProtocolName = "hash";
|
||||
|
||||
public string DefaultName => ProtocolName;
|
||||
|
||||
public IAuthenticationHandler CreateAuthenticationHandler(AuthenticationContext context)
|
||||
{
|
||||
|
||||
+453
@@ -0,0 +1,453 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
|
||||
namespace Esiur.Security.Authority.Providers.Ppap;
|
||||
|
||||
public sealed partial class PpapAuthenticationHandler
|
||||
{
|
||||
enum RotationState
|
||||
{
|
||||
NotStarted,
|
||||
SubjectAwaitChallenge,
|
||||
SubjectAwaitCommit,
|
||||
VerifierAwaitProof,
|
||||
ResponderAwaitNext,
|
||||
InitiatorAwaitResponderOffer,
|
||||
InitiatorAwaitResponderCommitAck,
|
||||
ResponderAwaitDone,
|
||||
InitiatorDoneSent,
|
||||
Complete,
|
||||
Failed,
|
||||
}
|
||||
|
||||
RotationState _rotationState;
|
||||
PpapIdentityRole _rotationRole;
|
||||
PpapRegistrationRecord _pendingRotation;
|
||||
byte[] _rotationPrivateKey;
|
||||
byte[] _rotationChallengeSecret;
|
||||
byte[] _rotationOffer;
|
||||
byte[] _rotationChallenge;
|
||||
|
||||
/// <summary>
|
||||
/// True for every live PPAP handler because protocol completion requires an
|
||||
/// encrypted post-authentication phase. Static identities use a no-op Done exchange.
|
||||
/// </summary>
|
||||
public bool RequiresKeyRotation
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
// PPAP always requires the encrypted post-authentication phase. It is
|
||||
// a no-op Done exchange when every authenticated identity is static.
|
||||
return _state != HandshakeState.Failed
|
||||
&& _state != HandshakeState.Disposed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public AuthenticationKeyRotationResult BeginKeyRotation()
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (_direction != AuthenticationDirection.Initiator
|
||||
|| _state != HandshakeState.Complete
|
||||
|| _rotationState != RotationState.NotStarted
|
||||
|| !RequiresKeyRotation)
|
||||
throw new InvalidOperationException("PPAP key rotation cannot be started in the current state.");
|
||||
|
||||
if (RoleRequiresRotation(PpapIdentityRole.Initiator))
|
||||
return CreateRotationOffer(PpapIdentityRole.Initiator);
|
||||
|
||||
if (!RoleRequiresRotation(PpapIdentityRole.Responder))
|
||||
{
|
||||
_rotationState = RotationState.InitiatorDoneSent;
|
||||
var done = PpapWire.EncodeRotationDone();
|
||||
ClearCompletedRotationSecrets();
|
||||
return RotationSucceeded(done);
|
||||
}
|
||||
|
||||
_rotationState = RotationState.InitiatorAwaitResponderOffer;
|
||||
return RotationInProgress(PpapWire.EncodeRotationStart(
|
||||
PpapIdentityRole.Responder));
|
||||
}
|
||||
catch
|
||||
{
|
||||
_rotationState = RotationState.Failed;
|
||||
ClearCompletedRotationSecrets();
|
||||
return RotationFailed();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public AuthenticationKeyRotationResult ProcessKeyRotation(object data)
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (_state != HandshakeState.Complete || !RequiresKeyRotation)
|
||||
throw new InvalidOperationException("PPAP key rotation is not available.");
|
||||
|
||||
if (_direction == AuthenticationDirection.Initiator)
|
||||
return ProcessInitiatorRotation(data);
|
||||
return ProcessResponderRotation(data);
|
||||
}
|
||||
catch
|
||||
{
|
||||
_rotationState = RotationState.Failed;
|
||||
ClearCompletedRotationSecrets();
|
||||
return RotationFailed();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
AuthenticationKeyRotationResult ProcessInitiatorRotation(object data)
|
||||
{
|
||||
if (_rotationState == RotationState.SubjectAwaitChallenge)
|
||||
return ProcessRotationChallenge(data, PpapIdentityRole.Initiator);
|
||||
|
||||
if (_rotationState == RotationState.SubjectAwaitCommit)
|
||||
{
|
||||
var committedVersion = PpapWire.DecodeRotationCommit(data,
|
||||
PpapIdentityRole.Initiator);
|
||||
CommitSubjectRotation(PpapIdentityRole.Initiator, committedVersion);
|
||||
|
||||
if (RoleRequiresRotation(PpapIdentityRole.Responder))
|
||||
{
|
||||
_rotationState = RotationState.InitiatorAwaitResponderOffer;
|
||||
return RotationInProgress(PpapWire.EncodeRotationStart(
|
||||
PpapIdentityRole.Responder));
|
||||
}
|
||||
|
||||
_rotationState = RotationState.InitiatorDoneSent;
|
||||
var done = PpapWire.EncodeRotationDone();
|
||||
ClearCompletedRotationSecrets();
|
||||
return RotationSucceeded(done);
|
||||
}
|
||||
|
||||
if (_rotationState == RotationState.InitiatorAwaitResponderOffer)
|
||||
return ProcessRotationOffer(data, PpapIdentityRole.Responder);
|
||||
|
||||
if (_rotationState == RotationState.VerifierAwaitProof)
|
||||
return ProcessRotationProof(data, PpapIdentityRole.Responder);
|
||||
|
||||
if (_rotationState == RotationState.InitiatorAwaitResponderCommitAck)
|
||||
{
|
||||
var version = PpapWire.DecodeRotationCommitAck(data,
|
||||
PpapIdentityRole.Responder);
|
||||
if (_responderRegistration == null
|
||||
|| version != _responderRegistration.Version)
|
||||
throw new InvalidDataException("Unexpected responder rotation version.");
|
||||
_rotationState = RotationState.InitiatorDoneSent;
|
||||
var done = PpapWire.EncodeRotationDone();
|
||||
ClearCompletedRotationSecrets();
|
||||
return RotationSucceeded(done);
|
||||
}
|
||||
|
||||
throw new InvalidDataException("Unexpected PPAP initiator rotation message.");
|
||||
}
|
||||
|
||||
AuthenticationKeyRotationResult ProcessResponderRotation(object data)
|
||||
{
|
||||
if (_rotationState == RotationState.NotStarted)
|
||||
{
|
||||
var type = PpapWire.PeekMessageType(data);
|
||||
if (type == PpapMessageType.RotationDone
|
||||
&& !RoleRequiresRotation(PpapIdentityRole.Initiator)
|
||||
&& !RoleRequiresRotation(PpapIdentityRole.Responder))
|
||||
return ProcessRotationDone(data);
|
||||
if (type == PpapMessageType.RotationOffer
|
||||
&& RoleRequiresRotation(PpapIdentityRole.Initiator))
|
||||
return ProcessRotationOffer(data, PpapIdentityRole.Initiator);
|
||||
if (type == PpapMessageType.RotationStart
|
||||
&& !RoleRequiresRotation(PpapIdentityRole.Initiator)
|
||||
&& RoleRequiresRotation(PpapIdentityRole.Responder))
|
||||
{
|
||||
var role = PpapWire.DecodeRotationStart(data);
|
||||
if (role != PpapIdentityRole.Responder)
|
||||
throw new InvalidDataException("Unexpected rotation role.");
|
||||
return CreateRotationOffer(PpapIdentityRole.Responder);
|
||||
}
|
||||
throw new InvalidDataException("Unexpected initial PPAP rotation message.");
|
||||
}
|
||||
|
||||
if (_rotationState == RotationState.VerifierAwaitProof)
|
||||
return ProcessRotationProof(data, PpapIdentityRole.Initiator);
|
||||
|
||||
if (_rotationState == RotationState.ResponderAwaitNext)
|
||||
{
|
||||
var type = PpapWire.PeekMessageType(data);
|
||||
if (type == PpapMessageType.RotationStart
|
||||
&& RoleRequiresRotation(PpapIdentityRole.Responder))
|
||||
{
|
||||
var role = PpapWire.DecodeRotationStart(data);
|
||||
if (role != PpapIdentityRole.Responder)
|
||||
throw new InvalidDataException("Unexpected rotation role.");
|
||||
return CreateRotationOffer(PpapIdentityRole.Responder);
|
||||
}
|
||||
if (type == PpapMessageType.RotationDone
|
||||
&& !RoleRequiresRotation(PpapIdentityRole.Responder))
|
||||
return ProcessRotationDone(data);
|
||||
throw new InvalidDataException("Unexpected PPAP rotation continuation.");
|
||||
}
|
||||
|
||||
if (_rotationState == RotationState.SubjectAwaitChallenge)
|
||||
return ProcessRotationChallenge(data, PpapIdentityRole.Responder);
|
||||
|
||||
if (_rotationState == RotationState.SubjectAwaitCommit)
|
||||
{
|
||||
var committedVersion = PpapWire.DecodeRotationCommit(data,
|
||||
PpapIdentityRole.Responder);
|
||||
CommitSubjectRotation(PpapIdentityRole.Responder, committedVersion);
|
||||
_rotationState = RotationState.ResponderAwaitDone;
|
||||
return RotationInProgress(PpapWire.EncodeRotationCommitAck(
|
||||
PpapIdentityRole.Responder, committedVersion));
|
||||
}
|
||||
|
||||
if (_rotationState == RotationState.ResponderAwaitDone)
|
||||
return ProcessRotationDone(data);
|
||||
|
||||
throw new InvalidDataException("Unexpected PPAP responder rotation message.");
|
||||
}
|
||||
|
||||
AuthenticationKeyRotationResult CreateRotationOffer(PpapIdentityRole role)
|
||||
{
|
||||
if (!LocalIsSubject(role) || !RoleRequiresRotation(role)
|
||||
|| _localIdentity == null
|
||||
|| _localIdentity.Kind != PpapIdentityKind.PasswordDerived)
|
||||
throw new InvalidOperationException("The local endpoint cannot rotate this identity role.");
|
||||
|
||||
var current = GetRoleRegistration(role);
|
||||
var nonce = PpapCryptography.RandomBytes(PpapProtocol.RegistrationNonceLength);
|
||||
byte[] publicKey = null;
|
||||
try
|
||||
{
|
||||
_rotationPrivateKey = _provider.DerivePrivateKey(_localIdentity,
|
||||
_domain, nonce, _localIdentity.KdfProfile,
|
||||
postAuthentication: true);
|
||||
publicKey = PpapCryptography.GetPublicKey(_rotationPrivateKey);
|
||||
_pendingRotation = new PpapRegistrationRecord(current.Version + 1,
|
||||
current.Identity, PpapIdentityKind.PasswordDerived, nonce,
|
||||
publicKey, _localIdentity.KdfProfile);
|
||||
var offer = PpapWire.EncodeRotationOffer(role, current.Identity,
|
||||
current.Version, nonce, publicKey, _localIdentity.KdfProfile);
|
||||
ReplaceRotationBytes(ref _rotationOffer, offer);
|
||||
_rotationRole = role;
|
||||
_rotationState = RotationState.SubjectAwaitChallenge;
|
||||
return RotationInProgress(offer);
|
||||
}
|
||||
finally
|
||||
{
|
||||
PpapCryptography.Clear(publicKey);
|
||||
PpapCryptography.Clear(nonce);
|
||||
}
|
||||
}
|
||||
|
||||
AuthenticationKeyRotationResult ProcessRotationOffer(object data,
|
||||
PpapIdentityRole expectedRole)
|
||||
{
|
||||
if (LocalIsSubject(expectedRole))
|
||||
throw new InvalidOperationException("A subject cannot verify its own rotation.");
|
||||
var raw = RequireBytes(data);
|
||||
var offer = PpapWire.DecodeRotationOffer(raw);
|
||||
if (offer.Role != expectedRole)
|
||||
throw new InvalidDataException("Unexpected rotation role.");
|
||||
|
||||
var current = GetRoleRegistration(expectedRole);
|
||||
if (current == null
|
||||
|| current.Kind != PpapIdentityKind.PasswordDerived
|
||||
|| !string.Equals(current.Identity, offer.Identity, StringComparison.Ordinal)
|
||||
|| current.Version != offer.ExpectedVersion
|
||||
|| !current.KdfProfile.Equals(offer.KdfProfile))
|
||||
throw new InvalidDataException("The rotation offer does not match the authenticated registration.");
|
||||
|
||||
_pendingRotation = new PpapRegistrationRecord(current.Version + 1,
|
||||
current.Identity, PpapIdentityKind.PasswordDerived, offer.Nonce,
|
||||
offer.EncapsulationKey, offer.KdfProfile);
|
||||
PpapCryptography.Encapsulate(offer.EncapsulationKey,
|
||||
out var ciphertext, out _rotationChallengeSecret);
|
||||
try
|
||||
{
|
||||
var challenge = PpapWire.EncodeRotationChallenge(expectedRole,
|
||||
ciphertext);
|
||||
ReplaceRotationBytes(ref _rotationOffer, raw);
|
||||
ReplaceRotationBytes(ref _rotationChallenge, challenge);
|
||||
_rotationRole = expectedRole;
|
||||
_rotationState = RotationState.VerifierAwaitProof;
|
||||
return RotationInProgress(challenge);
|
||||
}
|
||||
finally
|
||||
{
|
||||
PpapCryptography.Clear(ciphertext);
|
||||
PpapCryptography.Clear(offer.Nonce);
|
||||
PpapCryptography.Clear(offer.EncapsulationKey);
|
||||
}
|
||||
}
|
||||
|
||||
AuthenticationKeyRotationResult ProcessRotationChallenge(object data,
|
||||
PpapIdentityRole expectedRole)
|
||||
{
|
||||
if (!LocalIsSubject(expectedRole) || _rotationRole != expectedRole
|
||||
|| _rotationPrivateKey == null || _pendingRotation == null)
|
||||
throw new InvalidOperationException("Unexpected rotation challenge.");
|
||||
var raw = RequireBytes(data);
|
||||
var challenge = PpapWire.DecodeRotationChallenge(raw);
|
||||
if (challenge.Role != expectedRole)
|
||||
throw new InvalidDataException("Unexpected rotation challenge role.");
|
||||
|
||||
var secret = PpapCryptography.Decapsulate(_rotationPrivateKey,
|
||||
challenge.Ciphertext);
|
||||
try
|
||||
{
|
||||
ReplaceRotationBytes(ref _rotationChallenge, raw);
|
||||
var proof = PpapCryptography.ComputeRotationProof(secret,
|
||||
_sessionKey, _rotationOffer, _rotationChallenge);
|
||||
try
|
||||
{
|
||||
_rotationState = RotationState.SubjectAwaitCommit;
|
||||
return RotationInProgress(PpapWire.EncodeRotationProof(
|
||||
expectedRole, proof));
|
||||
}
|
||||
finally
|
||||
{
|
||||
PpapCryptography.Clear(proof);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
PpapCryptography.Clear(secret);
|
||||
PpapCryptography.Clear(challenge.Ciphertext);
|
||||
PpapCryptography.Clear(_rotationPrivateKey);
|
||||
_rotationPrivateKey = null;
|
||||
}
|
||||
}
|
||||
|
||||
AuthenticationKeyRotationResult ProcessRotationProof(object data,
|
||||
PpapIdentityRole expectedRole)
|
||||
{
|
||||
if (LocalIsSubject(expectedRole) || _rotationRole != expectedRole
|
||||
|| _rotationChallengeSecret == null || _pendingRotation == null)
|
||||
throw new InvalidOperationException("Unexpected rotation proof.");
|
||||
var proof = PpapWire.DecodeRotationProof(data);
|
||||
if (proof.Role != expectedRole)
|
||||
throw new InvalidDataException("Unexpected rotation proof role.");
|
||||
var expected = PpapCryptography.ComputeRotationProof(
|
||||
_rotationChallengeSecret, _sessionKey, _rotationOffer,
|
||||
_rotationChallenge);
|
||||
var verified = PpapCryptography.FixedTimeEquals(expected, proof.Proof);
|
||||
PpapCryptography.Clear(expected);
|
||||
PpapCryptography.Clear(proof.Proof);
|
||||
if (!verified)
|
||||
throw new InvalidDataException("Rotation proof verification failed.");
|
||||
|
||||
var current = GetRoleRegistration(expectedRole);
|
||||
if (!_provider.Registrations.TryRotate(_domain, current.Identity,
|
||||
current.Version, _pendingRotation))
|
||||
throw new InvalidOperationException("The registration changed concurrently.");
|
||||
|
||||
SetRoleRegistration(expectedRole, _pendingRotation);
|
||||
var committedVersion = _pendingRotation.Version;
|
||||
_pendingRotation = null;
|
||||
ClearRotationExchangeSecrets();
|
||||
|
||||
if (expectedRole == PpapIdentityRole.Initiator)
|
||||
_rotationState = RotationState.ResponderAwaitNext;
|
||||
else
|
||||
_rotationState = RotationState.InitiatorAwaitResponderCommitAck;
|
||||
|
||||
return RotationInProgress(PpapWire.EncodeRotationCommit(expectedRole,
|
||||
committedVersion));
|
||||
}
|
||||
|
||||
void CommitSubjectRotation(PpapIdentityRole role, long committedVersion)
|
||||
{
|
||||
if (!LocalIsSubject(role) || _rotationRole != role
|
||||
|| _pendingRotation == null
|
||||
|| _pendingRotation.Version != committedVersion)
|
||||
throw new InvalidDataException("Unexpected rotation commit.");
|
||||
SetRoleRegistration(role, _pendingRotation);
|
||||
_pendingRotation = null;
|
||||
ClearRotationExchangeSecrets();
|
||||
}
|
||||
|
||||
AuthenticationKeyRotationResult ProcessRotationDone(object data)
|
||||
{
|
||||
PpapWire.DecodeRotationDone(data);
|
||||
_rotationState = RotationState.Complete;
|
||||
ClearCompletedRotationSecrets();
|
||||
return RotationSucceeded(null);
|
||||
}
|
||||
|
||||
bool RoleRequiresRotation(PpapIdentityRole role)
|
||||
{
|
||||
var registration = GetRoleRegistration(role);
|
||||
return registration != null
|
||||
&& registration.Kind == PpapIdentityKind.PasswordDerived;
|
||||
}
|
||||
|
||||
bool LocalIsSubject(PpapIdentityRole role)
|
||||
=> (_direction == AuthenticationDirection.Initiator
|
||||
&& role == PpapIdentityRole.Initiator)
|
||||
|| (_direction == AuthenticationDirection.Responder
|
||||
&& role == PpapIdentityRole.Responder);
|
||||
|
||||
PpapRegistrationRecord GetRoleRegistration(PpapIdentityRole role)
|
||||
=> role == PpapIdentityRole.Initiator
|
||||
? _initiatorRegistration : _responderRegistration;
|
||||
|
||||
void SetRoleRegistration(PpapIdentityRole role,
|
||||
PpapRegistrationRecord registration)
|
||||
{
|
||||
if (role == PpapIdentityRole.Initiator)
|
||||
_initiatorRegistration = registration;
|
||||
else
|
||||
_responderRegistration = registration;
|
||||
}
|
||||
|
||||
static AuthenticationKeyRotationResult RotationInProgress(byte[] data)
|
||||
=> new AuthenticationKeyRotationResult(
|
||||
AuthenticationKeyRotationRuling.InProgress, data);
|
||||
|
||||
static AuthenticationKeyRotationResult RotationSucceeded(byte[] data)
|
||||
=> new AuthenticationKeyRotationResult(
|
||||
AuthenticationKeyRotationRuling.Succeeded, data);
|
||||
|
||||
static AuthenticationKeyRotationResult RotationFailed()
|
||||
=> new AuthenticationKeyRotationResult(
|
||||
AuthenticationKeyRotationRuling.Failed, null,
|
||||
"PPAP authentication key rotation failed.");
|
||||
|
||||
static void ReplaceRotationBytes(ref byte[] target, byte[] value)
|
||||
{
|
||||
PpapCryptography.Clear(target);
|
||||
target = value == null ? null : (byte[])value.Clone();
|
||||
}
|
||||
|
||||
void ClearRotationExchangeSecrets()
|
||||
{
|
||||
PpapCryptography.Clear(_rotationPrivateKey);
|
||||
PpapCryptography.Clear(_rotationChallengeSecret);
|
||||
PpapCryptography.Clear(_rotationOffer);
|
||||
PpapCryptography.Clear(_rotationChallenge);
|
||||
_rotationPrivateKey = null;
|
||||
_rotationChallengeSecret = null;
|
||||
_rotationOffer = null;
|
||||
_rotationChallenge = null;
|
||||
}
|
||||
|
||||
void ClearRotationSecrets()
|
||||
{
|
||||
ClearRotationExchangeSecrets();
|
||||
_pendingRotation = null;
|
||||
}
|
||||
|
||||
void ClearCompletedRotationSecrets()
|
||||
{
|
||||
ClearRotationSecrets();
|
||||
PpapCryptography.Clear(_sessionKey);
|
||||
_sessionKey = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,547 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
|
||||
namespace Esiur.Security.Authority.Providers.Ppap;
|
||||
|
||||
/// <summary>
|
||||
/// Per-connection PPAP ML-KEM state. A handler instance must not be reused.
|
||||
/// </summary>
|
||||
public sealed partial class PpapAuthenticationHandler : IAuthenticationHandler,
|
||||
IAuthenticationKeyRotationHandler, IDisposable
|
||||
{
|
||||
enum HandshakeState
|
||||
{
|
||||
InitiatorStart,
|
||||
InitiatorAwaitServerHello,
|
||||
InitiatorAwaitResponderProof,
|
||||
ResponderAwaitClientHello,
|
||||
ResponderAwaitInitiatorProof,
|
||||
ResponderAwaitInitiatorFinished,
|
||||
Complete,
|
||||
Failed,
|
||||
Disposed,
|
||||
}
|
||||
|
||||
readonly object _sync = new();
|
||||
readonly PpapAuthenticationProvider _provider;
|
||||
readonly PpapLocalIdentity _localIdentity;
|
||||
readonly AuthenticationDirection _direction;
|
||||
readonly AuthenticationMode _mode;
|
||||
readonly string _domain;
|
||||
readonly string _expectedInitiatorIdentity;
|
||||
readonly string _expectedResponderIdentity;
|
||||
readonly bool _authenticateInitiator;
|
||||
readonly bool _authenticateResponder;
|
||||
readonly List<byte[]> _transcript = new();
|
||||
|
||||
HandshakeState _state;
|
||||
string _initiatorIdentity;
|
||||
string _responderIdentity;
|
||||
PpapRegistrationRecord _initiatorRegistration;
|
||||
PpapRegistrationRecord _responderRegistration;
|
||||
byte[] _initiatorMask;
|
||||
byte[] _responderMask;
|
||||
byte[] _ephemeralPrivateKey;
|
||||
byte[] _ephemeralSecret;
|
||||
byte[] _initiatorIdentitySecret;
|
||||
byte[] _responderIdentitySecret;
|
||||
byte[] _sessionKey;
|
||||
byte[] _initiatorFinishedKey;
|
||||
byte[] _responderFinishedKey;
|
||||
byte[] _transcriptHash;
|
||||
byte[] _authenticationContext;
|
||||
|
||||
public IAuthenticationProvider Provider => _provider;
|
||||
public string Protocol => PpapProtocol.Name;
|
||||
|
||||
internal PpapAuthenticationHandler(PpapAuthenticationProvider provider,
|
||||
AuthenticationContext context)
|
||||
{
|
||||
_provider = provider ?? throw new ArgumentNullException(nameof(provider));
|
||||
_localIdentity = provider.ResolveLocalIdentity(context);
|
||||
_direction = context.Direction;
|
||||
_mode = context.Mode;
|
||||
_domain = PpapCryptography.NormalizeDomain(context.Domain);
|
||||
_expectedInitiatorIdentity = NormalizeOptionalIdentity(context.InitiatorIdentity);
|
||||
_expectedResponderIdentity = NormalizeOptionalIdentity(context.ResponderIdentity);
|
||||
|
||||
if (_direction != AuthenticationDirection.Initiator
|
||||
&& _direction != AuthenticationDirection.Responder)
|
||||
throw new ArgumentOutOfRangeException(nameof(context.Direction));
|
||||
if (_mode != AuthenticationMode.InitializerIdentity
|
||||
&& _mode != AuthenticationMode.ResponderIdentity
|
||||
&& _mode != AuthenticationMode.DualIdentity)
|
||||
throw new ArgumentOutOfRangeException(nameof(context.Mode));
|
||||
|
||||
_authenticateInitiator = _mode == AuthenticationMode.InitializerIdentity
|
||||
|| _mode == AuthenticationMode.DualIdentity;
|
||||
_authenticateResponder = _mode == AuthenticationMode.ResponderIdentity
|
||||
|| _mode == AuthenticationMode.DualIdentity;
|
||||
|
||||
var localIsAuthenticated = _direction == AuthenticationDirection.Initiator
|
||||
? _authenticateInitiator
|
||||
: _authenticateResponder;
|
||||
if (localIsAuthenticated && _localIdentity == null)
|
||||
throw new InvalidOperationException(
|
||||
"This PPAP mode requires a provider-configured local identity.");
|
||||
|
||||
if (_localIdentity != null)
|
||||
{
|
||||
var expectedLocal = _direction == AuthenticationDirection.Initiator
|
||||
? _expectedInitiatorIdentity
|
||||
: _expectedResponderIdentity;
|
||||
if (expectedLocal != null && !string.Equals(expectedLocal,
|
||||
_localIdentity.Identity, StringComparison.Ordinal))
|
||||
throw new InvalidOperationException(
|
||||
"The connection identity does not match the PPAP provider identity.");
|
||||
}
|
||||
|
||||
_state = _direction == AuthenticationDirection.Initiator
|
||||
? HandshakeState.InitiatorStart
|
||||
: HandshakeState.ResponderAwaitClientHello;
|
||||
}
|
||||
|
||||
public AuthenticationResult Process(object authData)
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
if (_state == HandshakeState.Disposed)
|
||||
return Failed();
|
||||
|
||||
try
|
||||
{
|
||||
if (_direction == AuthenticationDirection.Initiator)
|
||||
return ProcessInitiator(authData);
|
||||
return ProcessResponder(authData);
|
||||
}
|
||||
catch
|
||||
{
|
||||
_state = HandshakeState.Failed;
|
||||
ClearHandshakeSecrets(clearSessionKey: true);
|
||||
ClearRotationSecrets();
|
||||
return Failed();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
AuthenticationResult ProcessInitiator(object authData)
|
||||
{
|
||||
if (_state == HandshakeState.InitiatorStart)
|
||||
{
|
||||
if (authData != null)
|
||||
throw new InvalidDataException("Unexpected initial PPAP data.");
|
||||
|
||||
if (_authenticateInitiator)
|
||||
_initiatorIdentity = _localIdentity.Identity;
|
||||
|
||||
PpapCryptography.GenerateKeyPair(out _ephemeralPrivateKey,
|
||||
out var ephemeralPublicKey);
|
||||
_initiatorMask = PpapCryptography.RandomBytes(PpapProtocol.IdentityMaskLength);
|
||||
byte[] message = null;
|
||||
try
|
||||
{
|
||||
message = PpapWire.EncodeClientHello(_mode, _domain,
|
||||
ephemeralPublicKey, _initiatorMask);
|
||||
AddTranscript(message);
|
||||
_state = HandshakeState.InitiatorAwaitServerHello;
|
||||
return InProgress(message);
|
||||
}
|
||||
finally
|
||||
{
|
||||
PpapCryptography.Clear(ephemeralPublicKey);
|
||||
}
|
||||
}
|
||||
|
||||
if (_state == HandshakeState.InitiatorAwaitServerHello)
|
||||
{
|
||||
var raw = RequireBytes(authData);
|
||||
var hello = PpapWire.DecodeServerHello(raw, _authenticateResponder);
|
||||
AddTranscript(raw);
|
||||
|
||||
_ephemeralSecret = PpapCryptography.Decapsulate(
|
||||
_ephemeralPrivateKey, hello.EphemeralCiphertext);
|
||||
PpapCryptography.Clear(_ephemeralPrivateKey);
|
||||
_ephemeralPrivateKey = null;
|
||||
_responderMask = hello.ResponderMask;
|
||||
|
||||
byte[] maskedInitiator = null;
|
||||
byte[] responderCiphertext = null;
|
||||
PpapRegistrationDescriptor responderDescriptor = null;
|
||||
|
||||
if (_authenticateInitiator)
|
||||
maskedInitiator = PpapCryptography.MaskIdentity(_domain,
|
||||
_initiatorIdentity, _responderMask, _ephemeralSecret);
|
||||
|
||||
if (_authenticateResponder)
|
||||
{
|
||||
_responderRegistration = _provider.Registrations.ResolveMasked(
|
||||
_domain, _initiatorMask, _ephemeralSecret,
|
||||
hello.MaskedResponderIdentity);
|
||||
ValidateRemoteRegistration(_responderRegistration,
|
||||
_expectedResponderIdentity);
|
||||
_responderIdentity = _responderRegistration.Identity;
|
||||
PpapCryptography.Encapsulate(
|
||||
_responderRegistration.EncapsulationKeyBytes,
|
||||
out responderCiphertext, out _responderIdentitySecret);
|
||||
responderDescriptor = PpapRegistrationDescriptor.FromRecord(
|
||||
_responderRegistration);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var message = PpapWire.EncodeInitiatorProof(maskedInitiator,
|
||||
responderCiphertext, responderDescriptor, _domain,
|
||||
_ephemeralSecret);
|
||||
AddTranscript(message);
|
||||
_state = HandshakeState.InitiatorAwaitResponderProof;
|
||||
return InProgress(message);
|
||||
}
|
||||
finally
|
||||
{
|
||||
PpapCryptography.Clear(maskedInitiator);
|
||||
PpapCryptography.Clear(responderCiphertext);
|
||||
}
|
||||
}
|
||||
|
||||
if (_state == HandshakeState.InitiatorAwaitResponderProof)
|
||||
{
|
||||
var proof = PpapWire.DecodeResponderProof(authData,
|
||||
_authenticateInitiator, _domain, _ephemeralSecret);
|
||||
AddTranscript(proof.TranscriptCore);
|
||||
|
||||
if (_authenticateInitiator)
|
||||
{
|
||||
ValidateLocalDescriptor(proof.InitiatorRegistration,
|
||||
_localIdentity);
|
||||
byte[] privateKey = null;
|
||||
byte[] publicKey = null;
|
||||
try
|
||||
{
|
||||
privateKey = _provider.DerivePrivateKey(_localIdentity, _domain,
|
||||
proof.InitiatorRegistration.Nonce,
|
||||
proof.InitiatorRegistration.KdfProfile);
|
||||
publicKey = PpapCryptography.GetPublicKey(privateKey);
|
||||
_initiatorIdentitySecret = PpapCryptography.Decapsulate(
|
||||
privateKey, proof.InitiatorCiphertext);
|
||||
_initiatorRegistration = new PpapRegistrationRecord(
|
||||
proof.InitiatorRegistration.Version, _initiatorIdentity,
|
||||
proof.InitiatorRegistration.Kind,
|
||||
proof.InitiatorRegistration.Nonce, publicKey,
|
||||
proof.InitiatorRegistration.KdfProfile);
|
||||
}
|
||||
finally
|
||||
{
|
||||
PpapCryptography.Clear(privateKey);
|
||||
PpapCryptography.Clear(publicKey);
|
||||
}
|
||||
}
|
||||
|
||||
DeriveHandshakeKeys();
|
||||
var expected = PpapCryptography.ComputeFinished(false,
|
||||
_responderFinishedKey, _transcriptHash);
|
||||
var verified = PpapCryptography.FixedTimeEquals(expected, proof.Finished);
|
||||
PpapCryptography.Clear(expected);
|
||||
PpapCryptography.Clear(proof.Finished);
|
||||
PpapCryptography.Clear(proof.TranscriptCore);
|
||||
if (!verified)
|
||||
throw new InvalidDataException("Responder Finished verification failed.");
|
||||
|
||||
var initiatorFinished = PpapCryptography.ComputeFinished(true,
|
||||
_initiatorFinishedKey, _transcriptHash);
|
||||
try
|
||||
{
|
||||
var message = PpapWire.EncodeInitiatorFinished(initiatorFinished);
|
||||
_state = HandshakeState.Complete;
|
||||
ClearPostConfirmationSecrets();
|
||||
return Succeeded(message);
|
||||
}
|
||||
finally
|
||||
{
|
||||
PpapCryptography.Clear(initiatorFinished);
|
||||
}
|
||||
}
|
||||
|
||||
throw new InvalidDataException("Invalid PPAP initiator state.");
|
||||
}
|
||||
|
||||
AuthenticationResult ProcessResponder(object authData)
|
||||
{
|
||||
if (_state == HandshakeState.ResponderAwaitClientHello)
|
||||
{
|
||||
var raw = RequireBytes(authData);
|
||||
var hello = PpapWire.DecodeClientHello(raw);
|
||||
if (hello.Mode != _mode
|
||||
|| !string.Equals(hello.Domain, _domain, StringComparison.Ordinal))
|
||||
throw new InvalidDataException("PPAP context mismatch.");
|
||||
AddTranscript(raw);
|
||||
_initiatorMask = hello.InitiatorMask;
|
||||
|
||||
if (_authenticateResponder)
|
||||
_responderIdentity = _localIdentity.Identity;
|
||||
|
||||
PpapCryptography.Encapsulate(hello.EphemeralKey,
|
||||
out var ephemeralCiphertext, out _ephemeralSecret);
|
||||
_responderMask = PpapCryptography.RandomBytes(
|
||||
PpapProtocol.IdentityMaskLength);
|
||||
byte[] maskedResponder = null;
|
||||
if (_authenticateResponder)
|
||||
maskedResponder = PpapCryptography.MaskIdentity(_domain,
|
||||
_responderIdentity, _initiatorMask, _ephemeralSecret);
|
||||
|
||||
try
|
||||
{
|
||||
var message = PpapWire.EncodeServerHello(ephemeralCiphertext,
|
||||
_responderMask, maskedResponder);
|
||||
AddTranscript(message);
|
||||
_state = HandshakeState.ResponderAwaitInitiatorProof;
|
||||
return InProgress(message);
|
||||
}
|
||||
finally
|
||||
{
|
||||
PpapCryptography.Clear(ephemeralCiphertext);
|
||||
PpapCryptography.Clear(maskedResponder);
|
||||
PpapCryptography.Clear(hello.EphemeralKey);
|
||||
}
|
||||
}
|
||||
|
||||
if (_state == HandshakeState.ResponderAwaitInitiatorProof)
|
||||
{
|
||||
var raw = RequireBytes(authData);
|
||||
var proof = PpapWire.DecodeInitiatorProof(raw,
|
||||
_authenticateInitiator, _authenticateResponder, _domain,
|
||||
_ephemeralSecret);
|
||||
AddTranscript(raw);
|
||||
|
||||
if (_authenticateResponder)
|
||||
{
|
||||
ValidateLocalDescriptor(proof.ResponderRegistration,
|
||||
_localIdentity);
|
||||
byte[] privateKey = null;
|
||||
byte[] publicKey = null;
|
||||
try
|
||||
{
|
||||
privateKey = _provider.DerivePrivateKey(_localIdentity, _domain,
|
||||
proof.ResponderRegistration.Nonce,
|
||||
proof.ResponderRegistration.KdfProfile);
|
||||
publicKey = PpapCryptography.GetPublicKey(privateKey);
|
||||
_responderIdentitySecret = PpapCryptography.Decapsulate(
|
||||
privateKey, proof.ResponderCiphertext);
|
||||
_responderRegistration = new PpapRegistrationRecord(
|
||||
proof.ResponderRegistration.Version, _responderIdentity,
|
||||
proof.ResponderRegistration.Kind,
|
||||
proof.ResponderRegistration.Nonce, publicKey,
|
||||
proof.ResponderRegistration.KdfProfile);
|
||||
}
|
||||
finally
|
||||
{
|
||||
PpapCryptography.Clear(privateKey);
|
||||
PpapCryptography.Clear(publicKey);
|
||||
}
|
||||
}
|
||||
|
||||
byte[] initiatorCiphertext = null;
|
||||
PpapRegistrationDescriptor initiatorDescriptor = null;
|
||||
if (_authenticateInitiator)
|
||||
{
|
||||
_initiatorRegistration = _provider.Registrations.ResolveMasked(
|
||||
_domain, _responderMask, _ephemeralSecret,
|
||||
proof.MaskedInitiatorIdentity);
|
||||
ValidateRemoteRegistration(_initiatorRegistration,
|
||||
_expectedInitiatorIdentity);
|
||||
_initiatorIdentity = _initiatorRegistration.Identity;
|
||||
PpapCryptography.Encapsulate(
|
||||
_initiatorRegistration.EncapsulationKeyBytes,
|
||||
out initiatorCiphertext, out _initiatorIdentitySecret);
|
||||
initiatorDescriptor = PpapRegistrationDescriptor.FromRecord(
|
||||
_initiatorRegistration);
|
||||
}
|
||||
|
||||
byte[] core = null;
|
||||
byte[] protectedInitiatorDescriptor = null;
|
||||
byte[] responderFinished = null;
|
||||
try
|
||||
{
|
||||
core = PpapWire.EncodeResponderProofCore(initiatorCiphertext,
|
||||
initiatorDescriptor, _domain, _ephemeralSecret,
|
||||
out protectedInitiatorDescriptor);
|
||||
AddTranscript(core);
|
||||
DeriveHandshakeKeys();
|
||||
responderFinished = PpapCryptography.ComputeFinished(false,
|
||||
_responderFinishedKey, _transcriptHash);
|
||||
var message = PpapWire.EncodeResponderProof(initiatorCiphertext,
|
||||
protectedInitiatorDescriptor, responderFinished);
|
||||
_state = HandshakeState.ResponderAwaitInitiatorFinished;
|
||||
return InProgress(message);
|
||||
}
|
||||
finally
|
||||
{
|
||||
PpapCryptography.Clear(core);
|
||||
PpapCryptography.Clear(protectedInitiatorDescriptor);
|
||||
PpapCryptography.Clear(initiatorCiphertext);
|
||||
PpapCryptography.Clear(responderFinished);
|
||||
}
|
||||
}
|
||||
|
||||
if (_state == HandshakeState.ResponderAwaitInitiatorFinished)
|
||||
{
|
||||
var remoteFinished = PpapWire.DecodeInitiatorFinished(authData);
|
||||
var expected = PpapCryptography.ComputeFinished(true,
|
||||
_initiatorFinishedKey, _transcriptHash);
|
||||
var verified = PpapCryptography.FixedTimeEquals(expected, remoteFinished);
|
||||
PpapCryptography.Clear(expected);
|
||||
PpapCryptography.Clear(remoteFinished);
|
||||
if (!verified)
|
||||
throw new InvalidDataException("Initiator Finished verification failed.");
|
||||
|
||||
_state = HandshakeState.Complete;
|
||||
ClearPostConfirmationSecrets();
|
||||
return Succeeded(null);
|
||||
}
|
||||
|
||||
throw new InvalidDataException("Invalid PPAP responder state.");
|
||||
}
|
||||
|
||||
void DeriveHandshakeKeys()
|
||||
{
|
||||
_authenticationContext = PpapWire.EncodeAuthenticationContext(_mode,
|
||||
_domain, _initiatorIdentity,
|
||||
_authenticateInitiator ? (PpapIdentityKind?)GetInitiatorKind() : null,
|
||||
_responderIdentity,
|
||||
_authenticateResponder ? (PpapIdentityKind?)GetResponderKind() : null);
|
||||
_transcriptHash = PpapCryptography.ComputeTranscriptHash(
|
||||
_transcript, _authenticationContext);
|
||||
PpapCryptography.DeriveHandshakeKeys(_ephemeralSecret,
|
||||
_initiatorIdentitySecret, _responderIdentitySecret,
|
||||
_transcriptHash, _authenticationContext,
|
||||
out _sessionKey, out _initiatorFinishedKey,
|
||||
out _responderFinishedKey);
|
||||
|
||||
PpapCryptography.Clear(_ephemeralSecret);
|
||||
PpapCryptography.Clear(_initiatorIdentitySecret);
|
||||
PpapCryptography.Clear(_responderIdentitySecret);
|
||||
_ephemeralSecret = null;
|
||||
_initiatorIdentitySecret = null;
|
||||
_responderIdentitySecret = null;
|
||||
}
|
||||
|
||||
PpapIdentityKind GetInitiatorKind()
|
||||
=> _initiatorRegistration?.Kind ?? _localIdentity.Kind;
|
||||
|
||||
PpapIdentityKind GetResponderKind()
|
||||
=> _responderRegistration?.Kind ?? _localIdentity.Kind;
|
||||
|
||||
void ValidateRemoteRegistration(PpapRegistrationRecord record,
|
||||
string expectedIdentity)
|
||||
{
|
||||
if (record == null)
|
||||
throw new InvalidDataException("The masked identity could not be resolved.");
|
||||
if (expectedIdentity != null && !string.Equals(expectedIdentity,
|
||||
record.Identity, StringComparison.Ordinal))
|
||||
throw new InvalidDataException("The resolved identity was not expected.");
|
||||
}
|
||||
|
||||
static void ValidateLocalDescriptor(PpapRegistrationDescriptor descriptor,
|
||||
PpapLocalIdentity identity)
|
||||
{
|
||||
if (descriptor == null || identity == null || descriptor.Kind != identity.Kind)
|
||||
throw new InvalidDataException("The local registration descriptor is invalid.");
|
||||
if (identity.Kind == PpapIdentityKind.PasswordDerived
|
||||
&& !identity.KdfProfile.Equals(descriptor.KdfProfile))
|
||||
throw new InvalidDataException("The local registration KDF profile is not accepted.");
|
||||
}
|
||||
|
||||
void AddTranscript(byte[] message)
|
||||
{
|
||||
if (message == null || message.Length == 0
|
||||
|| message.Length > PpapProtocol.MaximumWireMessageBytes)
|
||||
throw new InvalidDataException("Invalid transcript message.");
|
||||
_transcript.Add((byte[])message.Clone());
|
||||
}
|
||||
|
||||
static byte[] RequireBytes(object data)
|
||||
{
|
||||
if (!(data is byte[] value))
|
||||
throw new InvalidDataException("PPAP data must be a byte array.");
|
||||
return value;
|
||||
}
|
||||
|
||||
AuthenticationResult InProgress(byte[] data)
|
||||
=> new AuthenticationResult(AuthenticationRuling.InProgress, data);
|
||||
|
||||
AuthenticationResult Succeeded(byte[] data)
|
||||
{
|
||||
var local = _direction == AuthenticationDirection.Initiator
|
||||
? _initiatorIdentity : _responderIdentity;
|
||||
var remote = _direction == AuthenticationDirection.Initiator
|
||||
? _responderIdentity : _initiatorIdentity;
|
||||
return new AuthenticationResult(AuthenticationRuling.Succeeded, data,
|
||||
local, remote, _sessionKey);
|
||||
}
|
||||
|
||||
static AuthenticationResult Failed()
|
||||
=> new AuthenticationResult(AuthenticationRuling.Failed, null);
|
||||
|
||||
static string NormalizeOptionalIdentity(string identity)
|
||||
=> identity == null ? null : PpapCryptography.NormalizeIdentity(identity);
|
||||
|
||||
void ClearPostConfirmationSecrets()
|
||||
{
|
||||
PpapCryptography.Clear(_initiatorFinishedKey);
|
||||
PpapCryptography.Clear(_responderFinishedKey);
|
||||
PpapCryptography.Clear(_transcriptHash);
|
||||
PpapCryptography.Clear(_authenticationContext);
|
||||
PpapCryptography.Clear(_initiatorMask);
|
||||
PpapCryptography.Clear(_responderMask);
|
||||
_initiatorFinishedKey = null;
|
||||
_responderFinishedKey = null;
|
||||
_transcriptHash = null;
|
||||
_authenticationContext = null;
|
||||
_initiatorMask = null;
|
||||
_responderMask = null;
|
||||
foreach (var message in _transcript)
|
||||
PpapCryptography.Clear(message);
|
||||
_transcript.Clear();
|
||||
}
|
||||
|
||||
void ClearHandshakeSecrets(bool clearSessionKey)
|
||||
{
|
||||
PpapCryptography.Clear(_ephemeralPrivateKey);
|
||||
PpapCryptography.Clear(_ephemeralSecret);
|
||||
PpapCryptography.Clear(_initiatorIdentitySecret);
|
||||
PpapCryptography.Clear(_responderIdentitySecret);
|
||||
PpapCryptography.Clear(_initiatorFinishedKey);
|
||||
PpapCryptography.Clear(_responderFinishedKey);
|
||||
PpapCryptography.Clear(_transcriptHash);
|
||||
PpapCryptography.Clear(_authenticationContext);
|
||||
if (clearSessionKey)
|
||||
PpapCryptography.Clear(_sessionKey);
|
||||
_ephemeralPrivateKey = null;
|
||||
_ephemeralSecret = null;
|
||||
_initiatorIdentitySecret = null;
|
||||
_responderIdentitySecret = null;
|
||||
_initiatorFinishedKey = null;
|
||||
_responderFinishedKey = null;
|
||||
if (clearSessionKey)
|
||||
_sessionKey = null;
|
||||
foreach (var message in _transcript)
|
||||
PpapCryptography.Clear(message);
|
||||
_transcript.Clear();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
if (_state == HandshakeState.Disposed)
|
||||
return;
|
||||
ClearHandshakeSecrets(clearSessionKey: true);
|
||||
PpapCryptography.Clear(_initiatorMask);
|
||||
PpapCryptography.Clear(_responderMask);
|
||||
PpapCryptography.Clear(_transcriptHash);
|
||||
PpapCryptography.Clear(_authenticationContext);
|
||||
ClearRotationSecrets();
|
||||
_state = HandshakeState.Disposed;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
using Esiur.Core;
|
||||
using System;
|
||||
|
||||
namespace Esiur.Security.Authority.Providers.Ppap;
|
||||
|
||||
public delegate PpapLocalIdentity PpapLocalIdentityResolver(
|
||||
AuthenticationContext context);
|
||||
|
||||
/// <summary>
|
||||
/// Fixed-suite PPAP ML-KEM-768 authentication provider.
|
||||
/// </summary>
|
||||
public sealed class PpapAuthenticationProvider : IAuthenticationProvider
|
||||
{
|
||||
public string DefaultName => PpapProtocol.Name;
|
||||
public PpapLocalIdentity LocalIdentity { get; }
|
||||
public IPpapRegistrationStore Registrations { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Admission control for password-based Argon2id work performed during live
|
||||
/// authentication and protected rotation exchanges.
|
||||
/// </summary>
|
||||
public PpapPasswordDerivationLimiter PasswordDerivationLimiter { get; }
|
||||
readonly PpapLocalIdentityResolver _localIdentityResolver;
|
||||
|
||||
public PpapAuthenticationProvider(PpapLocalIdentity localIdentity = null,
|
||||
IPpapRegistrationStore registrations = null,
|
||||
PpapPasswordDerivationLimiter passwordDerivationLimiter = null)
|
||||
{
|
||||
LocalIdentity = localIdentity;
|
||||
Registrations = registrations ?? new InMemoryPpapRegistrationStore();
|
||||
PasswordDerivationLimiter = passwordDerivationLimiter
|
||||
?? PpapPasswordDerivationLimiter.Shared;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Advanced constructor for hosts serving multiple domains or local identities.
|
||||
/// The selected identity is resolved and snapshotted when a handler is created.
|
||||
/// </summary>
|
||||
public PpapAuthenticationProvider(IPpapRegistrationStore registrations,
|
||||
PpapLocalIdentityResolver localIdentityResolver,
|
||||
PpapPasswordDerivationLimiter passwordDerivationLimiter = null)
|
||||
{
|
||||
Registrations = registrations ?? throw new ArgumentNullException(nameof(registrations));
|
||||
_localIdentityResolver = localIdentityResolver
|
||||
?? throw new ArgumentNullException(nameof(localIdentityResolver));
|
||||
PasswordDerivationLimiter = passwordDerivationLimiter
|
||||
?? PpapPasswordDerivationLimiter.Shared;
|
||||
}
|
||||
|
||||
internal PpapLocalIdentity ResolveLocalIdentity(AuthenticationContext context)
|
||||
=> _localIdentityResolver == null
|
||||
? LocalIdentity
|
||||
: _localIdentityResolver(context);
|
||||
|
||||
internal byte[] DerivePrivateKey(PpapLocalIdentity identity, string domain,
|
||||
byte[] nonce, PpapKdfProfile profile,
|
||||
bool postAuthentication = false)
|
||||
{
|
||||
if (identity == null)
|
||||
throw new ArgumentNullException(nameof(identity));
|
||||
|
||||
if (identity.Kind != PpapIdentityKind.PasswordDerived)
|
||||
return identity.DerivePrivateKey(domain, nonce, profile);
|
||||
|
||||
using (var lease = PasswordDerivationLimiter.TryAcquire(postAuthentication))
|
||||
{
|
||||
if (lease == null)
|
||||
throw new InvalidOperationException(
|
||||
"PPAP password-derivation capacity is temporarily exhausted.");
|
||||
|
||||
return identity.DerivePrivateKey(domain, nonce, profile);
|
||||
}
|
||||
}
|
||||
|
||||
public IAuthenticationHandler CreateAuthenticationHandler(AuthenticationContext context)
|
||||
{
|
||||
if (context == null)
|
||||
throw new ArgumentNullException(nameof(context));
|
||||
return new PpapAuthenticationHandler(this, context);
|
||||
}
|
||||
|
||||
public AsyncReply<bool> Login(Session session) => new AsyncReply<bool>(true);
|
||||
|
||||
public AsyncReply<bool> Logout(Session session) => new AsyncReply<bool>(true);
|
||||
}
|
||||
@@ -0,0 +1,664 @@
|
||||
using Org.BouncyCastle.Crypto;
|
||||
using Org.BouncyCastle.Crypto.Digests;
|
||||
using Org.BouncyCastle.Crypto.Generators;
|
||||
using Org.BouncyCastle.Crypto.Kems;
|
||||
using Org.BouncyCastle.Crypto.Macs;
|
||||
using Org.BouncyCastle.Crypto.Modes;
|
||||
using Org.BouncyCastle.Crypto.Parameters;
|
||||
using Org.BouncyCastle.Utilities;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace Esiur.Security.Authority.Providers.Ppap;
|
||||
|
||||
public static class PpapProtocol
|
||||
{
|
||||
public const string Name = "ppap-mlkem768-v1";
|
||||
public const int WireVersion = 1;
|
||||
public const int PublicKeyLength = 1184;
|
||||
public const int PrivateKeyLength = 2400;
|
||||
public const int CiphertextLength = 1088;
|
||||
public const int KemSecretLength = 32;
|
||||
public const int SeedLength = 64;
|
||||
public const int HashLength = 32;
|
||||
public const int FinishedTagLength = 32;
|
||||
public const int IdentityMaskLength = 32;
|
||||
public const int RegistrationNonceLength = 32;
|
||||
public const int SessionKeyLength = 32;
|
||||
public const int DescriptorPlaintextLength = 128;
|
||||
public const int DescriptorKeyLength = 32;
|
||||
public const int DescriptorNonceLength = 12;
|
||||
public const int DescriptorTagLength = 16;
|
||||
public const int ProtectedDescriptorLength =
|
||||
DescriptorPlaintextLength + DescriptorTagLength;
|
||||
public const int MaximumIdentityBytes = 512;
|
||||
public const int MaximumDomainBytes = 512;
|
||||
public const int MaximumPasswordBytes = 4096;
|
||||
public const int MaximumWireMessageBytes = 8192;
|
||||
}
|
||||
|
||||
internal static class PpapCryptography
|
||||
{
|
||||
static readonly MLKemParameters KemParameters = MLKemParameters.ml_kem_768;
|
||||
static readonly UTF8Encoding StrictUtf8 = new UTF8Encoding(false, true);
|
||||
static readonly byte[] PasswordSeedLabel = Encoding.ASCII.GetBytes(
|
||||
"esiur/ppap-mlkem768-v1/password-seed");
|
||||
static readonly byte[] IdentityMaskLabel = Encoding.ASCII.GetBytes(
|
||||
"esiur/ppap-mlkem768-v1/masked-identity");
|
||||
static readonly byte[] DescriptorSaltLabel = Encoding.ASCII.GetBytes(
|
||||
"esiur/ppap-mlkem768-v1/descriptor-salt");
|
||||
static readonly byte[] DescriptorKeyLabel = Encoding.ASCII.GetBytes(
|
||||
"esiur/ppap-mlkem768-v1/descriptor-key");
|
||||
static readonly byte[] DescriptorNonceLabel = Encoding.ASCII.GetBytes(
|
||||
"esiur/ppap-mlkem768-v1/descriptor-nonce");
|
||||
static readonly byte[] DescriptorAadLabel = Encoding.ASCII.GetBytes(
|
||||
"esiur/ppap-mlkem768-v1/descriptor-aad");
|
||||
static readonly byte[] InitiatorDescriptorLabel = Encoding.ASCII.GetBytes(
|
||||
"esiur/ppap-mlkem768-v1/initiator-registration");
|
||||
static readonly byte[] ResponderDescriptorLabel = Encoding.ASCII.GetBytes(
|
||||
"esiur/ppap-mlkem768-v1/responder-registration");
|
||||
static readonly byte[] TranscriptLabel = Encoding.ASCII.GetBytes(
|
||||
"esiur/ppap-mlkem768-v1/transcript");
|
||||
static readonly byte[] KeyScheduleLabel = Encoding.ASCII.GetBytes(
|
||||
"esiur/ppap-mlkem768-v1/key-schedule");
|
||||
static readonly byte[] SessionKeyLabel = Encoding.ASCII.GetBytes(
|
||||
"esiur/ppap-mlkem768-v1/session-key");
|
||||
static readonly byte[] InitiatorFinishedKeyLabel = Encoding.ASCII.GetBytes(
|
||||
"esiur/ppap-mlkem768-v1/initiator-finished-key");
|
||||
static readonly byte[] ResponderFinishedKeyLabel = Encoding.ASCII.GetBytes(
|
||||
"esiur/ppap-mlkem768-v1/responder-finished-key");
|
||||
static readonly byte[] InitiatorFinishedLabel = Encoding.ASCII.GetBytes(
|
||||
"esiur/ppap-mlkem768-v1/initiator-finished");
|
||||
static readonly byte[] ResponderFinishedLabel = Encoding.ASCII.GetBytes(
|
||||
"esiur/ppap-mlkem768-v1/responder-finished");
|
||||
static readonly byte[] RotationProofLabel = Encoding.ASCII.GetBytes(
|
||||
"esiur/ppap-mlkem768-v1/rotation-proof");
|
||||
|
||||
internal static string NormalizeIdentity(string identity)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(identity))
|
||||
throw new ArgumentException("An identity is required.", nameof(identity));
|
||||
var normalized = identity.Normalize(NormalizationForm.FormC);
|
||||
var encoded = EncodeUtf8(normalized, PpapProtocol.MaximumIdentityBytes, nameof(identity));
|
||||
Clear(encoded);
|
||||
return normalized;
|
||||
}
|
||||
|
||||
internal static string NormalizeDomain(string domain)
|
||||
{
|
||||
var normalized = (domain ?? string.Empty).Normalize(NormalizationForm.FormC);
|
||||
var encoded = EncodeUtf8(normalized, PpapProtocol.MaximumDomainBytes, nameof(domain));
|
||||
Clear(encoded);
|
||||
return normalized;
|
||||
}
|
||||
|
||||
internal static byte[] EncodeUtf8(string value, int maximumBytes, string parameterName)
|
||||
{
|
||||
try
|
||||
{
|
||||
var encoded = StrictUtf8.GetBytes(value ?? string.Empty);
|
||||
if (encoded.Length > maximumBytes)
|
||||
{
|
||||
Clear(encoded);
|
||||
throw new ArgumentException("The UTF-8 value is too long.", parameterName);
|
||||
}
|
||||
return encoded;
|
||||
}
|
||||
catch (EncoderFallbackException ex)
|
||||
{
|
||||
throw new ArgumentException("The value contains invalid Unicode.", parameterName, ex);
|
||||
}
|
||||
}
|
||||
|
||||
internal static string DecodeUtf8(byte[] value, int maximumBytes, string parameterName)
|
||||
{
|
||||
if (value == null || value.Length > maximumBytes)
|
||||
throw new ArgumentException("The UTF-8 value has an invalid length.", parameterName);
|
||||
try
|
||||
{
|
||||
return StrictUtf8.GetString(value).Normalize(NormalizationForm.FormC);
|
||||
}
|
||||
catch (DecoderFallbackException ex)
|
||||
{
|
||||
throw new ArgumentException("The value is not valid UTF-8.", parameterName, ex);
|
||||
}
|
||||
}
|
||||
|
||||
internal static byte[] RandomBytes(int length)
|
||||
{
|
||||
if (length <= 0)
|
||||
throw new ArgumentOutOfRangeException(nameof(length));
|
||||
var value = new byte[length];
|
||||
using (var random = RandomNumberGenerator.Create())
|
||||
random.GetBytes(value);
|
||||
return value;
|
||||
}
|
||||
|
||||
internal static byte[] Hash(params byte[][] values)
|
||||
{
|
||||
var digest = new Sha3Digest(256);
|
||||
foreach (var value in values)
|
||||
{
|
||||
if (value != null && value.Length != 0)
|
||||
digest.BlockUpdate(value, 0, value.Length);
|
||||
}
|
||||
var output = new byte[PpapProtocol.HashLength];
|
||||
digest.DoFinal(output, 0);
|
||||
return output;
|
||||
}
|
||||
|
||||
internal static byte[] HashFramed(params byte[][] values)
|
||||
{
|
||||
var encoded = EncodeFrames(values);
|
||||
try
|
||||
{
|
||||
return Hash(encoded);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Clear(encoded);
|
||||
}
|
||||
}
|
||||
|
||||
internal static byte[] MaskIdentity(string domain, string identity, byte[] mask,
|
||||
byte[] maskKey)
|
||||
{
|
||||
if (mask == null || mask.Length != PpapProtocol.IdentityMaskLength)
|
||||
throw new ArgumentException("The identity mask has an invalid length.", nameof(mask));
|
||||
ValidateKemSecret(maskKey, nameof(maskKey));
|
||||
|
||||
var domainBytes = EncodeUtf8(NormalizeDomain(domain),
|
||||
PpapProtocol.MaximumDomainBytes, nameof(domain));
|
||||
var identityBytes = EncodeUtf8(NormalizeIdentity(identity),
|
||||
PpapProtocol.MaximumIdentityBytes, nameof(identity));
|
||||
try
|
||||
{
|
||||
var input = EncodeFrames(IdentityMaskLabel, domainBytes, identityBytes, mask);
|
||||
try
|
||||
{
|
||||
return ComputeMac(maskKey, input);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Clear(input);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
Clear(domainBytes);
|
||||
Clear(identityBytes);
|
||||
}
|
||||
}
|
||||
|
||||
// Protected descriptor v1 is:
|
||||
// uint32be descriptorLength || canonicalDescriptor || zeroPadding
|
||||
// padded to DescriptorPlaintextLength, followed by a 128-bit GCM tag.
|
||||
// HKDF-SHA3 derives a role/domain/message-specific AES-256 key and 96-bit
|
||||
// nonce from the fresh ephemeral ML-KEM secret; the nonce is not sent.
|
||||
// Protocol implementations MUST encrypt at most one descriptor for each
|
||||
// role under a given ephemeral secret. ResponderProof therefore reuses its
|
||||
// single protected field when constructing the transcript core and packet.
|
||||
internal static byte[] ProtectRegistrationDescriptor(string domain,
|
||||
PpapIdentityRole role, byte[] ephemeralSecret, byte[] descriptor)
|
||||
{
|
||||
ValidateKemSecret(ephemeralSecret, nameof(ephemeralSecret));
|
||||
if (descriptor == null || descriptor.Length == 0
|
||||
|| descriptor.Length > PpapProtocol.DescriptorPlaintextLength - 4)
|
||||
throw new ArgumentException("The registration descriptor has an invalid length.",
|
||||
nameof(descriptor));
|
||||
|
||||
var framed = EncodeFrames(descriptor);
|
||||
var plaintext = new byte[PpapProtocol.DescriptorPlaintextLength];
|
||||
var output = new byte[PpapProtocol.ProtectedDescriptorLength];
|
||||
byte[] key = null;
|
||||
byte[] nonce = null;
|
||||
byte[] aad = null;
|
||||
try
|
||||
{
|
||||
Buffer.BlockCopy(framed, 0, plaintext, 0, framed.Length);
|
||||
DeriveDescriptorProtection(domain, role, ephemeralSecret,
|
||||
out key, out nonce, out aad);
|
||||
|
||||
var cipher = new GcmBlockCipher(AesUtilities.CreateEngine());
|
||||
cipher.Init(true, new AeadParameters(new KeyParameter(key),
|
||||
PpapProtocol.DescriptorTagLength * 8, nonce, aad));
|
||||
var written = cipher.ProcessBytes(plaintext, 0, plaintext.Length,
|
||||
output, 0);
|
||||
written += cipher.DoFinal(output, written);
|
||||
if (written != output.Length)
|
||||
throw new CryptographicException(
|
||||
"AES-GCM produced an invalid protected descriptor length.");
|
||||
return output;
|
||||
}
|
||||
catch (CryptoException ex)
|
||||
{
|
||||
Clear(output);
|
||||
throw new CryptographicException(
|
||||
"Registration descriptor protection failed.", ex);
|
||||
}
|
||||
catch
|
||||
{
|
||||
Clear(output);
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
Clear(framed);
|
||||
Clear(plaintext);
|
||||
Clear(key);
|
||||
Clear(nonce);
|
||||
Clear(aad);
|
||||
}
|
||||
}
|
||||
|
||||
internal static byte[] UnprotectRegistrationDescriptor(string domain,
|
||||
PpapIdentityRole role, byte[] ephemeralSecret, byte[] protectedDescriptor)
|
||||
{
|
||||
ValidateKemSecret(ephemeralSecret, nameof(ephemeralSecret));
|
||||
if (protectedDescriptor == null
|
||||
|| protectedDescriptor.Length != PpapProtocol.ProtectedDescriptorLength)
|
||||
throw new InvalidDataException(
|
||||
"The protected registration descriptor has an invalid length.");
|
||||
|
||||
var plaintext = new byte[PpapProtocol.DescriptorPlaintextLength];
|
||||
byte[] key = null;
|
||||
byte[] nonce = null;
|
||||
byte[] aad = null;
|
||||
try
|
||||
{
|
||||
DeriveDescriptorProtection(domain, role, ephemeralSecret,
|
||||
out key, out nonce, out aad);
|
||||
var cipher = new GcmBlockCipher(AesUtilities.CreateEngine());
|
||||
cipher.Init(false, new AeadParameters(new KeyParameter(key),
|
||||
PpapProtocol.DescriptorTagLength * 8, nonce, aad));
|
||||
var written = cipher.ProcessBytes(protectedDescriptor, 0,
|
||||
protectedDescriptor.Length, plaintext, 0);
|
||||
written += cipher.DoFinal(plaintext, written);
|
||||
if (written != plaintext.Length)
|
||||
throw new InvalidDataException(
|
||||
"AES-GCM produced an invalid registration descriptor length.");
|
||||
|
||||
var offset = 0;
|
||||
var descriptorLength = ReadInt32(plaintext, ref offset);
|
||||
if (descriptorLength == 0
|
||||
|| descriptorLength > PpapProtocol.DescriptorPlaintextLength - offset)
|
||||
throw new InvalidDataException(
|
||||
"The protected registration descriptor is malformed.");
|
||||
var descriptor = new byte[descriptorLength];
|
||||
Buffer.BlockCopy(plaintext, offset, descriptor, 0, descriptorLength);
|
||||
offset += descriptorLength;
|
||||
for (var i = offset; i < plaintext.Length; i++)
|
||||
{
|
||||
if (plaintext[i] != 0)
|
||||
{
|
||||
Clear(descriptor);
|
||||
throw new InvalidDataException(
|
||||
"The protected registration descriptor is not canonical.");
|
||||
}
|
||||
}
|
||||
return descriptor;
|
||||
}
|
||||
catch (CryptoException ex)
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
"Protected registration descriptor authentication failed.", ex);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Clear(plaintext);
|
||||
Clear(key);
|
||||
Clear(nonce);
|
||||
Clear(aad);
|
||||
}
|
||||
}
|
||||
|
||||
static void DeriveDescriptorProtection(string domain, PpapIdentityRole role,
|
||||
byte[] ephemeralSecret, out byte[] key, out byte[] nonce, out byte[] aad)
|
||||
{
|
||||
var roleLabel = GetDescriptorRoleLabel(role, out var messageType);
|
||||
var domainBytes = EncodeUtf8(NormalizeDomain(domain),
|
||||
PpapProtocol.MaximumDomainBytes, nameof(domain));
|
||||
var binding = new[]
|
||||
{
|
||||
(byte)PpapProtocol.WireVersion,
|
||||
(byte)role,
|
||||
(byte)messageType,
|
||||
};
|
||||
var salt = HashFramed(DescriptorSaltLabel, roleLabel, domainBytes, binding);
|
||||
key = null;
|
||||
nonce = null;
|
||||
aad = null;
|
||||
try
|
||||
{
|
||||
key = Expand(ephemeralSecret, salt,
|
||||
EncodeFrames(DescriptorKeyLabel, roleLabel, domainBytes, binding),
|
||||
PpapProtocol.DescriptorKeyLength);
|
||||
nonce = Expand(ephemeralSecret, salt,
|
||||
EncodeFrames(DescriptorNonceLabel, roleLabel, domainBytes, binding),
|
||||
PpapProtocol.DescriptorNonceLength);
|
||||
aad = EncodeFrames(DescriptorAadLabel, roleLabel, domainBytes, binding);
|
||||
}
|
||||
catch
|
||||
{
|
||||
Clear(key);
|
||||
Clear(nonce);
|
||||
Clear(aad);
|
||||
key = null;
|
||||
nonce = null;
|
||||
aad = null;
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
Clear(domainBytes);
|
||||
Clear(binding);
|
||||
Clear(salt);
|
||||
}
|
||||
}
|
||||
|
||||
static byte[] GetDescriptorRoleLabel(PpapIdentityRole role,
|
||||
out PpapMessageType messageType)
|
||||
{
|
||||
if (role == PpapIdentityRole.Initiator)
|
||||
{
|
||||
messageType = PpapMessageType.ResponderProof;
|
||||
return InitiatorDescriptorLabel;
|
||||
}
|
||||
if (role == PpapIdentityRole.Responder)
|
||||
{
|
||||
messageType = PpapMessageType.InitiatorProof;
|
||||
return ResponderDescriptorLabel;
|
||||
}
|
||||
throw new ArgumentOutOfRangeException(nameof(role));
|
||||
}
|
||||
|
||||
internal static byte[] DerivePasswordPrivateKey(string domain, string identity,
|
||||
byte[] password, byte[] nonce, PpapKdfProfile profile)
|
||||
{
|
||||
if (password == null || password.Length == 0
|
||||
|| password.Length > PpapProtocol.MaximumPasswordBytes)
|
||||
throw new ArgumentException("The password has an invalid length.", nameof(password));
|
||||
if (nonce == null || nonce.Length != PpapProtocol.RegistrationNonceLength)
|
||||
throw new ArgumentException("The registration nonce has an invalid length.", nameof(nonce));
|
||||
if (profile == null)
|
||||
throw new ArgumentNullException(nameof(profile));
|
||||
|
||||
var identityBytes = EncodeUtf8(NormalizeIdentity(identity),
|
||||
PpapProtocol.MaximumIdentityBytes, nameof(identity));
|
||||
var domainBytes = EncodeUtf8(NormalizeDomain(domain),
|
||||
PpapProtocol.MaximumDomainBytes, nameof(domain));
|
||||
var input = EncodeFrames(PasswordSeedLabel, domainBytes, identityBytes,
|
||||
password, nonce);
|
||||
var seed = new byte[PpapProtocol.SeedLength];
|
||||
try
|
||||
{
|
||||
var parameters = new Argon2Parameters.Builder(Argon2Parameters.Argon2id)
|
||||
.WithVersion(profile.Version)
|
||||
.WithSalt((byte[])nonce.Clone())
|
||||
.WithMemoryAsKB(profile.MemoryKiB)
|
||||
.WithIterations(profile.Iterations)
|
||||
.WithParallelism(profile.Parallelism)
|
||||
.Build();
|
||||
var argon2 = new Argon2BytesGenerator();
|
||||
argon2.Init(parameters);
|
||||
argon2.GenerateBytes(input, seed);
|
||||
|
||||
var privateKey = MLKemPrivateKeyParameters.FromSeed(KemParameters, seed);
|
||||
return privateKey.GetEncoded();
|
||||
}
|
||||
finally
|
||||
{
|
||||
Clear(identityBytes);
|
||||
Clear(domainBytes);
|
||||
Clear(input);
|
||||
Clear(seed);
|
||||
}
|
||||
}
|
||||
|
||||
internal static void GenerateKeyPair(out byte[] privateKey, out byte[] publicKey)
|
||||
{
|
||||
var seed = RandomBytes(PpapProtocol.SeedLength);
|
||||
try
|
||||
{
|
||||
var parameters = MLKemPrivateKeyParameters.FromSeed(KemParameters, seed);
|
||||
privateKey = parameters.GetEncoded();
|
||||
publicKey = parameters.GetPublicKeyEncoded();
|
||||
}
|
||||
finally
|
||||
{
|
||||
Clear(seed);
|
||||
}
|
||||
}
|
||||
|
||||
internal static byte[] GetPublicKey(byte[] privateKey)
|
||||
{
|
||||
ValidatePrivateKey(privateKey);
|
||||
return MLKemPrivateKeyParameters.FromEncoding(KemParameters, privateKey)
|
||||
.GetPublicKeyEncoded();
|
||||
}
|
||||
|
||||
internal static void ValidatePrivateKey(byte[] privateKey)
|
||||
{
|
||||
if (privateKey == null || privateKey.Length != PpapProtocol.PrivateKeyLength)
|
||||
throw new ArgumentException("The ML-KEM-768 private key has an invalid length.", nameof(privateKey));
|
||||
MLKemPrivateKeyParameters.FromEncoding(KemParameters, privateKey);
|
||||
}
|
||||
|
||||
internal static void ValidatePublicKey(byte[] publicKey)
|
||||
{
|
||||
if (publicKey == null || publicKey.Length != PpapProtocol.PublicKeyLength)
|
||||
throw new ArgumentException("The ML-KEM-768 public key has an invalid length.", nameof(publicKey));
|
||||
MLKemPublicKeyParameters.FromEncoding(KemParameters, publicKey);
|
||||
}
|
||||
|
||||
internal static void Encapsulate(byte[] publicKey, out byte[] ciphertext,
|
||||
out byte[] secret)
|
||||
{
|
||||
ValidatePublicKey(publicKey);
|
||||
var encapsulator = new MLKemEncapsulator(KemParameters);
|
||||
encapsulator.Init(MLKemPublicKeyParameters.FromEncoding(KemParameters, publicKey));
|
||||
ciphertext = new byte[encapsulator.EncapsulationLength];
|
||||
secret = new byte[encapsulator.SecretLength];
|
||||
try
|
||||
{
|
||||
encapsulator.Encapsulate(ciphertext, 0, ciphertext.Length,
|
||||
secret, 0, secret.Length);
|
||||
}
|
||||
catch
|
||||
{
|
||||
Clear(ciphertext);
|
||||
Clear(secret);
|
||||
ciphertext = null;
|
||||
secret = null;
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
internal static byte[] Decapsulate(byte[] privateKey, byte[] ciphertext)
|
||||
{
|
||||
ValidatePrivateKey(privateKey);
|
||||
if (ciphertext == null || ciphertext.Length != PpapProtocol.CiphertextLength)
|
||||
throw new ArgumentException("The ML-KEM-768 ciphertext has an invalid length.", nameof(ciphertext));
|
||||
var decapsulator = new MLKemDecapsulator(KemParameters);
|
||||
decapsulator.Init(MLKemPrivateKeyParameters.FromEncoding(KemParameters, privateKey));
|
||||
var secret = new byte[decapsulator.SecretLength];
|
||||
decapsulator.Decapsulate(ciphertext, 0, ciphertext.Length,
|
||||
secret, 0, secret.Length);
|
||||
return secret;
|
||||
}
|
||||
|
||||
internal static byte[] ComputeTranscriptHash(IList<byte[]> messages,
|
||||
byte[] authenticationContext)
|
||||
{
|
||||
if (messages == null)
|
||||
throw new ArgumentNullException(nameof(messages));
|
||||
var frames = new byte[messages.Count + 2][];
|
||||
frames[0] = TranscriptLabel;
|
||||
frames[1] = authenticationContext ?? Array.Empty<byte>();
|
||||
for (var i = 0; i < messages.Count; i++)
|
||||
frames[i + 2] = messages[i];
|
||||
return HashFramed(frames);
|
||||
}
|
||||
|
||||
internal static void DeriveHandshakeKeys(byte[] ephemeralSecret,
|
||||
byte[] initiatorIdentitySecret, byte[] responderIdentitySecret,
|
||||
byte[] transcriptHash, byte[] authenticationContext,
|
||||
out byte[] sessionKey, out byte[] initiatorFinishedKey,
|
||||
out byte[] responderFinishedKey)
|
||||
{
|
||||
ValidateKemSecret(ephemeralSecret, nameof(ephemeralSecret));
|
||||
ValidateOptionalKemSecret(initiatorIdentitySecret, nameof(initiatorIdentitySecret));
|
||||
ValidateOptionalKemSecret(responderIdentitySecret, nameof(responderIdentitySecret));
|
||||
if (transcriptHash == null || transcriptHash.Length != PpapProtocol.HashLength)
|
||||
throw new ArgumentException("The transcript hash has an invalid length.", nameof(transcriptHash));
|
||||
|
||||
var input = EncodeFrames(KeyScheduleLabel, ephemeralSecret,
|
||||
initiatorIdentitySecret ?? Array.Empty<byte>(),
|
||||
responderIdentitySecret ?? Array.Empty<byte>());
|
||||
try
|
||||
{
|
||||
sessionKey = Expand(input, transcriptHash,
|
||||
EncodeFrames(SessionKeyLabel, authenticationContext),
|
||||
PpapProtocol.SessionKeyLength);
|
||||
initiatorFinishedKey = Expand(input, transcriptHash,
|
||||
EncodeFrames(InitiatorFinishedKeyLabel, authenticationContext),
|
||||
PpapProtocol.HashLength);
|
||||
responderFinishedKey = Expand(input, transcriptHash,
|
||||
EncodeFrames(ResponderFinishedKeyLabel, authenticationContext),
|
||||
PpapProtocol.HashLength);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Clear(input);
|
||||
}
|
||||
}
|
||||
|
||||
static byte[] Expand(byte[] input, byte[] salt, byte[] info, int length)
|
||||
{
|
||||
try
|
||||
{
|
||||
var hkdf = new HkdfBytesGenerator(new Sha3Digest(256));
|
||||
hkdf.Init(new HkdfParameters(input, salt, info));
|
||||
var output = new byte[length];
|
||||
hkdf.GenerateBytes(output, 0, output.Length);
|
||||
return output;
|
||||
}
|
||||
finally
|
||||
{
|
||||
Clear(info);
|
||||
}
|
||||
}
|
||||
|
||||
internal static byte[] ComputeFinished(bool initiator, byte[] finishedKey,
|
||||
byte[] transcriptHash)
|
||||
{
|
||||
if (finishedKey == null || finishedKey.Length != PpapProtocol.HashLength)
|
||||
throw new ArgumentException("The Finished key has an invalid length.", nameof(finishedKey));
|
||||
if (transcriptHash == null || transcriptHash.Length != PpapProtocol.HashLength)
|
||||
throw new ArgumentException("The transcript hash has an invalid length.", nameof(transcriptHash));
|
||||
var input = EncodeFrames(initiator ? InitiatorFinishedLabel : ResponderFinishedLabel,
|
||||
transcriptHash);
|
||||
try
|
||||
{
|
||||
return ComputeMac(finishedKey, input);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Clear(input);
|
||||
}
|
||||
}
|
||||
|
||||
internal static byte[] ComputeMac(byte[] key, byte[] input)
|
||||
{
|
||||
if (key == null || key.Length == 0)
|
||||
throw new ArgumentException("A MAC key is required.", nameof(key));
|
||||
var hmac = new HMac(new Sha3Digest(256));
|
||||
hmac.Init(new KeyParameter(key));
|
||||
hmac.BlockUpdate(input, 0, input.Length);
|
||||
var output = new byte[hmac.GetMacSize()];
|
||||
hmac.DoFinal(output, 0);
|
||||
return output;
|
||||
}
|
||||
|
||||
internal static byte[] ComputeRotationProof(byte[] challengeSecret,
|
||||
byte[] sessionKey, byte[] offer, byte[] challenge)
|
||||
{
|
||||
ValidateKemSecret(challengeSecret, nameof(challengeSecret));
|
||||
if (sessionKey == null || sessionKey.Length != PpapProtocol.SessionKeyLength)
|
||||
throw new ArgumentException("The session key has an invalid length.", nameof(sessionKey));
|
||||
var context = HashFramed(RotationProofLabel, sessionKey, offer, challenge);
|
||||
try
|
||||
{
|
||||
return ComputeMac(challengeSecret, context);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Clear(context);
|
||||
}
|
||||
}
|
||||
|
||||
internal static bool FixedTimeEquals(byte[] left, byte[] right)
|
||||
{
|
||||
return left != null && right != null && left.Length == right.Length
|
||||
&& Arrays.FixedTimeEquals(left, right);
|
||||
}
|
||||
|
||||
internal static byte[] EncodeFrames(params byte[][] values)
|
||||
{
|
||||
using (var output = new MemoryStream())
|
||||
{
|
||||
foreach (var value in values)
|
||||
{
|
||||
var field = value ?? Array.Empty<byte>();
|
||||
WriteInt32(output, field.Length);
|
||||
output.Write(field, 0, field.Length);
|
||||
}
|
||||
return output.ToArray();
|
||||
}
|
||||
}
|
||||
|
||||
internal static void WriteInt32(Stream output, int value)
|
||||
{
|
||||
if (value < 0)
|
||||
throw new ArgumentOutOfRangeException(nameof(value));
|
||||
output.WriteByte((byte)(value >> 24));
|
||||
output.WriteByte((byte)(value >> 16));
|
||||
output.WriteByte((byte)(value >> 8));
|
||||
output.WriteByte((byte)value);
|
||||
}
|
||||
|
||||
internal static int ReadInt32(byte[] input, ref int offset)
|
||||
{
|
||||
if (input == null || offset < 0 || input.Length - offset < 4)
|
||||
throw new InvalidDataException("Truncated PPAP integer.");
|
||||
var value = (input[offset] << 24)
|
||||
| (input[offset + 1] << 16)
|
||||
| (input[offset + 2] << 8)
|
||||
| input[offset + 3];
|
||||
offset += 4;
|
||||
if (value < 0)
|
||||
throw new InvalidDataException("Negative PPAP length.");
|
||||
return value;
|
||||
}
|
||||
|
||||
static void ValidateKemSecret(byte[] value, string parameterName)
|
||||
{
|
||||
if (value == null || value.Length != PpapProtocol.KemSecretLength)
|
||||
throw new ArgumentException("The KEM secret has an invalid length.", parameterName);
|
||||
}
|
||||
|
||||
static void ValidateOptionalKemSecret(byte[] value, string parameterName)
|
||||
{
|
||||
if (value != null && value.Length != PpapProtocol.KemSecretLength)
|
||||
throw new ArgumentException("The KEM secret has an invalid length.", parameterName);
|
||||
}
|
||||
|
||||
internal static void Clear(byte[] value)
|
||||
{
|
||||
if (value != null)
|
||||
Array.Clear(value, 0, value.Length);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,453 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Text;
|
||||
|
||||
namespace Esiur.Security.Authority.Providers.Ppap;
|
||||
|
||||
/// <summary>
|
||||
/// The kind of long-lived identity authenticated by PPAP ML-KEM.
|
||||
/// </summary>
|
||||
public enum PpapIdentityKind : byte
|
||||
{
|
||||
PasswordDerived = 1,
|
||||
StaticMlKem = 2,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Immutable Argon2id parameters used to turn a password into an ML-KEM seed.
|
||||
/// </summary>
|
||||
public sealed class PpapKdfProfile : IEquatable<PpapKdfProfile>
|
||||
{
|
||||
public const int Argon2Version13 = 0x13;
|
||||
|
||||
public static PpapKdfProfile Default { get; } =
|
||||
new PpapKdfProfile(Argon2Version13, 32 * 1024, 3, 1);
|
||||
|
||||
public int Version { get; }
|
||||
public int MemoryKiB { get; }
|
||||
public int Iterations { get; }
|
||||
public int Parallelism { get; }
|
||||
|
||||
public PpapKdfProfile(int version, int memoryKiB, int iterations, int parallelism)
|
||||
{
|
||||
if (version != Argon2Version13)
|
||||
throw new ArgumentOutOfRangeException(nameof(version), "Only Argon2 version 1.3 is supported.");
|
||||
if (memoryKiB < 8 * 1024 || memoryKiB > 256 * 1024)
|
||||
throw new ArgumentOutOfRangeException(nameof(memoryKiB));
|
||||
if (iterations < 1 || iterations > 10)
|
||||
throw new ArgumentOutOfRangeException(nameof(iterations));
|
||||
if (parallelism < 1 || parallelism > 16)
|
||||
throw new ArgumentOutOfRangeException(nameof(parallelism));
|
||||
if (memoryKiB < parallelism * 8)
|
||||
throw new ArgumentOutOfRangeException(nameof(memoryKiB));
|
||||
|
||||
Version = version;
|
||||
MemoryKiB = memoryKiB;
|
||||
Iterations = iterations;
|
||||
Parallelism = parallelism;
|
||||
}
|
||||
|
||||
public bool Equals(PpapKdfProfile other)
|
||||
=> other != null
|
||||
&& Version == other.Version
|
||||
&& MemoryKiB == other.MemoryKiB
|
||||
&& Iterations == other.Iterations
|
||||
&& Parallelism == other.Parallelism;
|
||||
|
||||
public override bool Equals(object obj) => Equals(obj as PpapKdfProfile);
|
||||
|
||||
public override int GetHashCode()
|
||||
{
|
||||
unchecked
|
||||
{
|
||||
var hash = Version;
|
||||
hash = (hash * 397) ^ MemoryKiB;
|
||||
hash = (hash * 397) ^ Iterations;
|
||||
return (hash * 397) ^ Parallelism;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Local PPAP identity. Secret material is cloned and is never exposed by a property.
|
||||
/// </summary>
|
||||
public sealed class PpapLocalIdentity : IDisposable
|
||||
{
|
||||
readonly byte[] _secret;
|
||||
bool _disposed;
|
||||
|
||||
public string Identity { get; }
|
||||
public PpapIdentityKind Kind { get; }
|
||||
public PpapKdfProfile KdfProfile { get; }
|
||||
|
||||
PpapLocalIdentity(string identity, PpapIdentityKind kind, byte[] secret,
|
||||
PpapKdfProfile kdfProfile)
|
||||
{
|
||||
Identity = PpapCryptography.NormalizeIdentity(identity);
|
||||
Kind = kind;
|
||||
KdfProfile = kdfProfile;
|
||||
_secret = (byte[])secret.Clone();
|
||||
}
|
||||
|
||||
public static PpapLocalIdentity FromPassword(string identity, string password,
|
||||
PpapKdfProfile kdfProfile = null)
|
||||
{
|
||||
if (password == null)
|
||||
throw new ArgumentNullException(nameof(password));
|
||||
|
||||
var bytes = Encoding.UTF8.GetBytes(password.Normalize(NormalizationForm.FormC));
|
||||
try
|
||||
{
|
||||
return FromPassword(identity, bytes, kdfProfile);
|
||||
}
|
||||
finally
|
||||
{
|
||||
PpapCryptography.Clear(bytes);
|
||||
}
|
||||
}
|
||||
|
||||
public static PpapLocalIdentity FromPassword(string identity, byte[] password,
|
||||
PpapKdfProfile kdfProfile = null)
|
||||
{
|
||||
if (password == null || password.Length == 0)
|
||||
throw new ArgumentException("A password is required.", nameof(password));
|
||||
if (password.Length > PpapProtocol.MaximumPasswordBytes)
|
||||
throw new ArgumentException("The password is too long.", nameof(password));
|
||||
|
||||
return new PpapLocalIdentity(identity, PpapIdentityKind.PasswordDerived,
|
||||
password, kdfProfile ?? PpapKdfProfile.Default);
|
||||
}
|
||||
|
||||
public static PpapLocalIdentity FromStaticKey(string identity, byte[] privateKey)
|
||||
{
|
||||
PpapCryptography.ValidatePrivateKey(privateKey);
|
||||
return new PpapLocalIdentity(identity, PpapIdentityKind.StaticMlKem,
|
||||
privateKey, null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new static ML-KEM-768 identity with a cryptographically random key.
|
||||
/// </summary>
|
||||
public static PpapLocalIdentity CreateStatic(string identity)
|
||||
{
|
||||
PpapCryptography.GenerateKeyPair(out var privateKey, out var publicKey);
|
||||
try
|
||||
{
|
||||
return FromStaticKey(identity, privateKey);
|
||||
}
|
||||
finally
|
||||
{
|
||||
PpapCryptography.Clear(privateKey);
|
||||
PpapCryptography.Clear(publicKey);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Exports a copy of a static ML-KEM private key for durable secure storage.
|
||||
/// The caller owns the returned buffer and should erase it after persisting it.
|
||||
/// Password-derived identities cannot be exported.
|
||||
/// </summary>
|
||||
public byte[] ExportStaticPrivateKey()
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
if (Kind != PpapIdentityKind.StaticMlKem)
|
||||
throw new InvalidOperationException(
|
||||
"Password-derived PPAP identities cannot export private key material.");
|
||||
return (byte[])_secret.Clone();
|
||||
}
|
||||
|
||||
internal byte[] DerivePrivateKey(string domain, byte[] nonce,
|
||||
PpapKdfProfile profile)
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
|
||||
if (Kind == PpapIdentityKind.StaticMlKem)
|
||||
{
|
||||
if (nonce != null && nonce.Length != 0)
|
||||
throw new InvalidOperationException("A static identity cannot have a KDF nonce.");
|
||||
return (byte[])_secret.Clone();
|
||||
}
|
||||
|
||||
if (profile == null || !KdfProfile.Equals(profile))
|
||||
throw new InvalidOperationException("The registration KDF profile is not accepted by the local identity.");
|
||||
|
||||
return PpapCryptography.DerivePasswordPrivateKey(domain, Identity,
|
||||
_secret, nonce, profile);
|
||||
}
|
||||
|
||||
internal byte[] GetStaticPublicKey()
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
if (Kind != PpapIdentityKind.StaticMlKem)
|
||||
throw new InvalidOperationException("The identity is not backed by a static key.");
|
||||
return PpapCryptography.GetPublicKey(_secret);
|
||||
}
|
||||
|
||||
void ThrowIfDisposed()
|
||||
{
|
||||
if (_disposed)
|
||||
throw new ObjectDisposedException(nameof(PpapLocalIdentity));
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
return;
|
||||
_disposed = true;
|
||||
PpapCryptography.Clear(_secret);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Immutable verifier-side registration data for one PPAP identity.
|
||||
/// </summary>
|
||||
public sealed class PpapRegistrationRecord
|
||||
{
|
||||
readonly byte[] _nonce;
|
||||
readonly byte[] _encapsulationKey;
|
||||
|
||||
public long Version { get; }
|
||||
public string Identity { get; }
|
||||
public PpapIdentityKind Kind { get; }
|
||||
public PpapKdfProfile KdfProfile { get; }
|
||||
public byte[] Nonce => (byte[])_nonce.Clone();
|
||||
public byte[] EncapsulationKey => (byte[])_encapsulationKey.Clone();
|
||||
|
||||
internal byte[] NonceBytes => (byte[])_nonce.Clone();
|
||||
internal byte[] EncapsulationKeyBytes => (byte[])_encapsulationKey.Clone();
|
||||
|
||||
public PpapRegistrationRecord(long version, string identity, PpapIdentityKind kind,
|
||||
byte[] nonce, byte[] encapsulationKey, PpapKdfProfile kdfProfile = null)
|
||||
{
|
||||
if (version < 1)
|
||||
throw new ArgumentOutOfRangeException(nameof(version));
|
||||
if (!Enum.IsDefined(typeof(PpapIdentityKind), kind))
|
||||
throw new ArgumentOutOfRangeException(nameof(kind));
|
||||
|
||||
Identity = PpapCryptography.NormalizeIdentity(identity);
|
||||
Version = version;
|
||||
Kind = kind;
|
||||
PpapCryptography.ValidatePublicKey(encapsulationKey);
|
||||
|
||||
if (kind == PpapIdentityKind.PasswordDerived)
|
||||
{
|
||||
if (nonce == null || nonce.Length != PpapProtocol.RegistrationNonceLength)
|
||||
throw new ArgumentException("A password registration requires a 32-byte nonce.", nameof(nonce));
|
||||
KdfProfile = kdfProfile ?? throw new ArgumentNullException(nameof(kdfProfile));
|
||||
_nonce = (byte[])nonce.Clone();
|
||||
}
|
||||
else
|
||||
{
|
||||
if (nonce != null && nonce.Length != 0)
|
||||
throw new ArgumentException("A static-key registration cannot contain a KDF nonce.", nameof(nonce));
|
||||
if (kdfProfile != null)
|
||||
throw new ArgumentException("A static-key registration cannot contain a KDF profile.", nameof(kdfProfile));
|
||||
_nonce = Array.Empty<byte>();
|
||||
}
|
||||
|
||||
_encapsulationKey = (byte[])encapsulationKey.Clone();
|
||||
}
|
||||
|
||||
public static PpapRegistrationRecord FromPassword(string domain, string identity,
|
||||
string password,
|
||||
long version = 1, byte[] nonce = null, PpapKdfProfile kdfProfile = null)
|
||||
{
|
||||
using (var local = PpapLocalIdentity.FromPassword(identity, password, kdfProfile))
|
||||
return FromLocalIdentity(domain, local, version, nonce);
|
||||
}
|
||||
|
||||
public static PpapRegistrationRecord FromLocalIdentity(string domain,
|
||||
PpapLocalIdentity identity,
|
||||
long version = 1, byte[] nonce = null)
|
||||
{
|
||||
if (identity == null)
|
||||
throw new ArgumentNullException(nameof(identity));
|
||||
|
||||
if (identity.Kind == PpapIdentityKind.StaticMlKem)
|
||||
return new PpapRegistrationRecord(version, identity.Identity, identity.Kind,
|
||||
Array.Empty<byte>(), identity.GetStaticPublicKey());
|
||||
|
||||
var registrationNonce = nonce == null
|
||||
? PpapCryptography.RandomBytes(PpapProtocol.RegistrationNonceLength)
|
||||
: (byte[])nonce.Clone();
|
||||
byte[] privateKey = null;
|
||||
byte[] publicKey = null;
|
||||
try
|
||||
{
|
||||
privateKey = identity.DerivePrivateKey(domain, registrationNonce,
|
||||
identity.KdfProfile);
|
||||
publicKey = PpapCryptography.GetPublicKey(privateKey);
|
||||
return new PpapRegistrationRecord(version, identity.Identity, identity.Kind,
|
||||
registrationNonce, publicKey, identity.KdfProfile);
|
||||
}
|
||||
finally
|
||||
{
|
||||
PpapCryptography.Clear(privateKey);
|
||||
PpapCryptography.Clear(publicKey);
|
||||
PpapCryptography.Clear(registrationNonce);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public interface IPpapRegistrationStore
|
||||
{
|
||||
PpapRegistrationRecord Get(string domain, string identity);
|
||||
|
||||
/// <summary>
|
||||
/// Resolves a per-handshake, keyed identity mask. Implementations must not
|
||||
/// persist <paramref name="maskKey"/> or use it as a stable lookup token.
|
||||
/// </summary>
|
||||
PpapRegistrationRecord ResolveMasked(string domain, byte[] mask,
|
||||
byte[] maskKey, byte[] maskedIdentity);
|
||||
|
||||
/// <summary>
|
||||
/// Atomically replaces exactly the registration identified by
|
||||
/// <paramref name="domain"/>, <paramref name="identity"/>, and
|
||||
/// <paramref name="expectedVersion"/>. Implementations must perform a
|
||||
/// linearizable compare-and-swap, preserve identity, kind, and KDF policy,
|
||||
/// reject reused nonce or encapsulation-key material, and return false
|
||||
/// without mutation when any precondition fails. A database implementation
|
||||
/// must use a conditional update/transaction, not a separate read then write.
|
||||
/// </summary>
|
||||
bool TryRotate(string domain, string identity, long expectedVersion,
|
||||
PpapRegistrationRecord replacement);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Thread-safe in-memory registration store intended for shortcuts, tests, and small deployments.
|
||||
/// Masked resolution deliberately scans all records in a domain.
|
||||
/// </summary>
|
||||
public sealed class InMemoryPpapRegistrationStore : IPpapRegistrationStore
|
||||
{
|
||||
struct RegistrationKey : IEquatable<RegistrationKey>
|
||||
{
|
||||
public readonly string Domain;
|
||||
public readonly string Identity;
|
||||
|
||||
public RegistrationKey(string domain, string identity)
|
||||
{
|
||||
Domain = domain;
|
||||
Identity = identity;
|
||||
}
|
||||
|
||||
public bool Equals(RegistrationKey other)
|
||||
=> string.Equals(Domain, other.Domain, StringComparison.Ordinal)
|
||||
&& string.Equals(Identity, other.Identity, StringComparison.Ordinal);
|
||||
|
||||
public override bool Equals(object obj)
|
||||
=> obj is RegistrationKey && Equals((RegistrationKey)obj);
|
||||
|
||||
public override int GetHashCode()
|
||||
{
|
||||
unchecked
|
||||
{
|
||||
return ((Domain?.GetHashCode() ?? 0) * 397)
|
||||
^ (Identity?.GetHashCode() ?? 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
readonly ConcurrentDictionary<RegistrationKey, PpapRegistrationRecord> _records = new();
|
||||
|
||||
static RegistrationKey MakeKey(string domain, string identity)
|
||||
=> new RegistrationKey(PpapCryptography.NormalizeDomain(domain),
|
||||
PpapCryptography.NormalizeIdentity(identity));
|
||||
|
||||
public bool TryAdd(string domain, PpapRegistrationRecord record)
|
||||
{
|
||||
if (record == null)
|
||||
throw new ArgumentNullException(nameof(record));
|
||||
return _records.TryAdd(MakeKey(domain, record.Identity), record);
|
||||
}
|
||||
|
||||
public void AddOrUpdate(string domain, PpapRegistrationRecord record)
|
||||
{
|
||||
if (record == null)
|
||||
throw new ArgumentNullException(nameof(record));
|
||||
var key = MakeKey(domain, record.Identity);
|
||||
_records.AddOrUpdate(key, record, (_, current) =>
|
||||
{
|
||||
if (RecordsEqual(current, record))
|
||||
return current;
|
||||
throw new InvalidOperationException(
|
||||
"An existing PPAP registration can only be changed through TryRotate.");
|
||||
});
|
||||
}
|
||||
|
||||
public PpapRegistrationRecord Get(string domain, string identity)
|
||||
{
|
||||
_records.TryGetValue(MakeKey(domain, identity), out var record);
|
||||
return record;
|
||||
}
|
||||
|
||||
public PpapRegistrationRecord ResolveMasked(string domain, byte[] mask,
|
||||
byte[] maskKey, byte[] maskedIdentity)
|
||||
{
|
||||
if (mask == null || mask.Length != PpapProtocol.IdentityMaskLength)
|
||||
return null;
|
||||
if (maskedIdentity == null || maskedIdentity.Length != PpapProtocol.HashLength)
|
||||
return null;
|
||||
if (maskKey == null || maskKey.Length != PpapProtocol.KemSecretLength)
|
||||
return null;
|
||||
|
||||
var normalizedDomain = PpapCryptography.NormalizeDomain(domain);
|
||||
PpapRegistrationRecord match = null;
|
||||
var ambiguous = false;
|
||||
|
||||
foreach (var pair in _records)
|
||||
{
|
||||
if (!string.Equals(pair.Key.Domain, normalizedDomain, StringComparison.Ordinal))
|
||||
continue;
|
||||
|
||||
var candidate = PpapCryptography.MaskIdentity(
|
||||
normalizedDomain, pair.Value.Identity, mask, maskKey);
|
||||
var equal = PpapCryptography.FixedTimeEquals(candidate, maskedIdentity);
|
||||
PpapCryptography.Clear(candidate);
|
||||
|
||||
if (!equal)
|
||||
continue;
|
||||
if (match != null)
|
||||
ambiguous = true;
|
||||
match = pair.Value;
|
||||
}
|
||||
|
||||
return ambiguous ? null : match;
|
||||
}
|
||||
|
||||
public bool TryRotate(string domain, string identity, long expectedVersion,
|
||||
PpapRegistrationRecord replacement)
|
||||
{
|
||||
if (replacement == null)
|
||||
throw new ArgumentNullException(nameof(replacement));
|
||||
var key = MakeKey(domain, identity);
|
||||
|
||||
if (!string.Equals(key.Identity, replacement.Identity, StringComparison.Ordinal)
|
||||
|| replacement.Version != expectedVersion + 1)
|
||||
return false;
|
||||
|
||||
if (!_records.TryGetValue(key, out var current)
|
||||
|| current.Version != expectedVersion
|
||||
|| current.Kind != PpapIdentityKind.PasswordDerived
|
||||
|| replacement.Kind != PpapIdentityKind.PasswordDerived)
|
||||
return false;
|
||||
|
||||
if (!current.KdfProfile.Equals(replacement.KdfProfile)
|
||||
|| PpapCryptography.FixedTimeEquals(current.NonceBytes,
|
||||
replacement.NonceBytes)
|
||||
|| PpapCryptography.FixedTimeEquals(current.EncapsulationKeyBytes,
|
||||
replacement.EncapsulationKeyBytes))
|
||||
return false;
|
||||
|
||||
return _records.TryUpdate(key, replacement, current);
|
||||
}
|
||||
|
||||
static bool RecordsEqual(PpapRegistrationRecord left,
|
||||
PpapRegistrationRecord right)
|
||||
=> left.Version == right.Version
|
||||
&& left.Kind == right.Kind
|
||||
&& string.Equals(left.Identity, right.Identity, StringComparison.Ordinal)
|
||||
&& Equals(left.KdfProfile, right.KdfProfile)
|
||||
&& PpapCryptography.FixedTimeEquals(left.NonceBytes, right.NonceBytes)
|
||||
&& PpapCryptography.FixedTimeEquals(left.EncapsulationKeyBytes,
|
||||
right.EncapsulationKeyBytes);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
|
||||
namespace Esiur.Security.Authority.Providers.Ppap;
|
||||
|
||||
/// <summary>
|
||||
/// Bounds concurrent Argon2id work performed by PPAP authentication handlers.
|
||||
/// Saturated limiters reject new derivations instead of queuing socket threads.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This bounds authentication-handler concurrency, not registration provisioning.
|
||||
/// Operators must also choose KDF memory profiles appropriate for the host because
|
||||
/// slots are not weighted by <see cref="PpapKdfProfile.MemoryKiB"/>.
|
||||
/// </remarks>
|
||||
public sealed class PpapPasswordDerivationLimiter
|
||||
{
|
||||
sealed class Lease : IDisposable
|
||||
{
|
||||
PpapPasswordDerivationLimiter _owner;
|
||||
|
||||
public Lease(PpapPasswordDerivationLimiter owner)
|
||||
{
|
||||
_owner = owner;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Interlocked.Exchange(ref _owner, null)?.Release();
|
||||
}
|
||||
}
|
||||
|
||||
readonly object _sync = new object();
|
||||
int _active;
|
||||
|
||||
/// <summary>
|
||||
/// Process-wide default shared by PPAP providers. Its upper bound limits the
|
||||
/// default Argon2id working set to four simultaneous 32-MiB derivations.
|
||||
/// </summary>
|
||||
public static PpapPasswordDerivationLimiter Shared { get; } =
|
||||
new PpapPasswordDerivationLimiter(
|
||||
Math.Max(1, Math.Min(Environment.ProcessorCount, 4)),
|
||||
Environment.ProcessorCount > 1 ? 1 : 0);
|
||||
|
||||
public int MaximumConcurrency { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Slots unavailable to unauthenticated handshakes but available to the encrypted
|
||||
/// post-authentication rotation phase, preventing pre-authentication starvation.
|
||||
/// </summary>
|
||||
public int ReservedPostAuthenticationSlots { get; }
|
||||
|
||||
public PpapPasswordDerivationLimiter(
|
||||
int maximumConcurrency,
|
||||
int reservedPostAuthenticationSlots = 0)
|
||||
{
|
||||
if (maximumConcurrency < 1)
|
||||
throw new ArgumentOutOfRangeException(nameof(maximumConcurrency));
|
||||
if (reservedPostAuthenticationSlots < 0
|
||||
|| reservedPostAuthenticationSlots >= maximumConcurrency)
|
||||
throw new ArgumentOutOfRangeException(
|
||||
nameof(reservedPostAuthenticationSlots));
|
||||
|
||||
MaximumConcurrency = maximumConcurrency;
|
||||
ReservedPostAuthenticationSlots = reservedPostAuthenticationSlots;
|
||||
}
|
||||
|
||||
internal IDisposable TryAcquire(bool postAuthentication)
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
var limit = postAuthentication
|
||||
? MaximumConcurrency
|
||||
: MaximumConcurrency - ReservedPostAuthenticationSlots;
|
||||
if (_active >= limit)
|
||||
return null;
|
||||
|
||||
_active++;
|
||||
return new Lease(this);
|
||||
}
|
||||
}
|
||||
|
||||
void Release()
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
if (_active < 1)
|
||||
throw new InvalidOperationException("PPAP derivation limiter underflow.");
|
||||
_active--;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
|
||||
namespace Esiur.Security.Authority.Providers.Ppap;
|
||||
|
||||
internal static partial class PpapWire
|
||||
{
|
||||
internal sealed class RotationOffer
|
||||
{
|
||||
public PpapIdentityRole Role;
|
||||
public string Identity;
|
||||
public long ExpectedVersion;
|
||||
public byte[] Nonce;
|
||||
public byte[] EncapsulationKey;
|
||||
public PpapKdfProfile KdfProfile;
|
||||
}
|
||||
|
||||
internal sealed class RotationChallenge
|
||||
{
|
||||
public PpapIdentityRole Role;
|
||||
public byte[] Ciphertext;
|
||||
}
|
||||
|
||||
internal sealed class RotationProof
|
||||
{
|
||||
public PpapIdentityRole Role;
|
||||
public byte[] Proof;
|
||||
}
|
||||
|
||||
internal static byte[] EncodeRotationStart(PpapIdentityRole role)
|
||||
=> Encode(PpapMessageType.RotationStart,
|
||||
payload => payload.WriteByte(ValidateRole(role)));
|
||||
|
||||
internal static PpapIdentityRole DecodeRotationStart(object data)
|
||||
=> DecodeRoleOnly(data, PpapMessageType.RotationStart);
|
||||
|
||||
internal static byte[] EncodeRotationOffer(PpapIdentityRole role, string identity,
|
||||
long expectedVersion, byte[] nonce, byte[] encapsulationKey,
|
||||
PpapKdfProfile kdfProfile)
|
||||
{
|
||||
ValidateRole(role);
|
||||
if (expectedVersion < 1 || expectedVersion == long.MaxValue)
|
||||
throw new InvalidDataException("Invalid rotation version.");
|
||||
ValidateExact(nonce, PpapProtocol.RegistrationNonceLength, nameof(nonce));
|
||||
PpapCryptography.ValidatePublicKey(encapsulationKey);
|
||||
if (kdfProfile == null)
|
||||
throw new ArgumentNullException(nameof(kdfProfile));
|
||||
var identityBytes = PpapCryptography.EncodeUtf8(
|
||||
PpapCryptography.NormalizeIdentity(identity),
|
||||
PpapProtocol.MaximumIdentityBytes, nameof(identity));
|
||||
var descriptor = EncodeDescriptor(new PpapRegistrationDescriptor(
|
||||
PpapIdentityKind.PasswordDerived, expectedVersion + 1, nonce, kdfProfile));
|
||||
try
|
||||
{
|
||||
return Encode(PpapMessageType.RotationOffer, payload =>
|
||||
{
|
||||
payload.WriteByte((byte)role);
|
||||
WriteField(payload, identityBytes);
|
||||
WriteInt64(payload, expectedVersion);
|
||||
WriteField(payload, encapsulationKey);
|
||||
WriteField(payload, descriptor);
|
||||
});
|
||||
}
|
||||
finally
|
||||
{
|
||||
PpapCryptography.Clear(identityBytes);
|
||||
PpapCryptography.Clear(descriptor);
|
||||
}
|
||||
}
|
||||
|
||||
internal static RotationOffer DecodeRotationOffer(object data)
|
||||
{
|
||||
var payload = DecodeEnvelope(RequireWireBytes(data), PpapMessageType.RotationOffer);
|
||||
var offset = 0;
|
||||
var role = ReadRole(payload, ref offset);
|
||||
var identityBytes = ReadField(payload, ref offset,
|
||||
PpapProtocol.MaximumIdentityBytes);
|
||||
var expectedVersion = ReadInt64(payload, ref offset);
|
||||
var key = ReadField(payload, ref offset, PpapProtocol.PublicKeyLength);
|
||||
var descriptorBytes = ReadField(payload, ref offset, 128);
|
||||
RequireEnd(payload, offset);
|
||||
if (expectedVersion < 1 || expectedVersion == long.MaxValue)
|
||||
throw new InvalidDataException("Invalid rotation version.");
|
||||
PpapCryptography.ValidatePublicKey(key);
|
||||
var identity = PpapCryptography.NormalizeIdentity(
|
||||
PpapCryptography.DecodeUtf8(identityBytes,
|
||||
PpapProtocol.MaximumIdentityBytes, "identity"));
|
||||
var descriptor = DecodeDescriptor(descriptorBytes);
|
||||
if (descriptor.Kind != PpapIdentityKind.PasswordDerived
|
||||
|| descriptor.Version != expectedVersion + 1)
|
||||
throw new InvalidDataException("Invalid rotated registration descriptor.");
|
||||
PpapCryptography.Clear(identityBytes);
|
||||
PpapCryptography.Clear(descriptorBytes);
|
||||
return new RotationOffer
|
||||
{
|
||||
Role = role,
|
||||
Identity = identity,
|
||||
ExpectedVersion = expectedVersion,
|
||||
Nonce = descriptor.Nonce,
|
||||
EncapsulationKey = key,
|
||||
KdfProfile = descriptor.KdfProfile,
|
||||
};
|
||||
}
|
||||
|
||||
internal static byte[] EncodeRotationChallenge(PpapIdentityRole role,
|
||||
byte[] ciphertext)
|
||||
{
|
||||
ValidateRole(role);
|
||||
ValidateExact(ciphertext, PpapProtocol.CiphertextLength, nameof(ciphertext));
|
||||
return Encode(PpapMessageType.RotationChallenge, payload =>
|
||||
{
|
||||
payload.WriteByte((byte)role);
|
||||
WriteField(payload, ciphertext);
|
||||
});
|
||||
}
|
||||
|
||||
internal static RotationChallenge DecodeRotationChallenge(object data)
|
||||
{
|
||||
var payload = DecodeEnvelope(RequireWireBytes(data),
|
||||
PpapMessageType.RotationChallenge);
|
||||
var offset = 0;
|
||||
var role = ReadRole(payload, ref offset);
|
||||
var ciphertext = ReadField(payload, ref offset,
|
||||
PpapProtocol.CiphertextLength);
|
||||
RequireEnd(payload, offset);
|
||||
ValidateExact(ciphertext, PpapProtocol.CiphertextLength,
|
||||
nameof(ciphertext));
|
||||
return new RotationChallenge { Role = role, Ciphertext = ciphertext };
|
||||
}
|
||||
|
||||
internal static byte[] EncodeRotationProof(PpapIdentityRole role, byte[] proof)
|
||||
{
|
||||
ValidateRole(role);
|
||||
ValidateExact(proof, PpapProtocol.HashLength, nameof(proof));
|
||||
return Encode(PpapMessageType.RotationProof, payload =>
|
||||
{
|
||||
payload.WriteByte((byte)role);
|
||||
WriteField(payload, proof);
|
||||
});
|
||||
}
|
||||
|
||||
internal static RotationProof DecodeRotationProof(object data)
|
||||
{
|
||||
var payload = DecodeEnvelope(RequireWireBytes(data), PpapMessageType.RotationProof);
|
||||
var offset = 0;
|
||||
var role = ReadRole(payload, ref offset);
|
||||
var proof = ReadField(payload, ref offset, PpapProtocol.HashLength);
|
||||
RequireEnd(payload, offset);
|
||||
ValidateExact(proof, PpapProtocol.HashLength, nameof(proof));
|
||||
return new RotationProof { Role = role, Proof = proof };
|
||||
}
|
||||
|
||||
internal static byte[] EncodeRotationCommit(PpapIdentityRole role, long version)
|
||||
=> EncodeRoleVersion(PpapMessageType.RotationCommit, role, version);
|
||||
|
||||
internal static long DecodeRotationCommit(object data,
|
||||
PpapIdentityRole expectedRole)
|
||||
=> DecodeRoleVersion(data, PpapMessageType.RotationCommit, expectedRole);
|
||||
|
||||
internal static byte[] EncodeRotationCommitAck(PpapIdentityRole role, long version)
|
||||
=> EncodeRoleVersion(PpapMessageType.RotationCommitAck, role, version);
|
||||
|
||||
internal static long DecodeRotationCommitAck(object data,
|
||||
PpapIdentityRole expectedRole)
|
||||
=> DecodeRoleVersion(data, PpapMessageType.RotationCommitAck, expectedRole);
|
||||
|
||||
internal static byte[] EncodeRotationDone()
|
||||
=> Encode(PpapMessageType.RotationDone, _ => { });
|
||||
|
||||
internal static void DecodeRotationDone(object data)
|
||||
{
|
||||
var payload = DecodeEnvelope(RequireWireBytes(data), PpapMessageType.RotationDone);
|
||||
RequireEnd(payload, 0);
|
||||
}
|
||||
|
||||
internal static PpapMessageType PeekMessageType(object data)
|
||||
{
|
||||
var input = RequireWireBytes(data);
|
||||
if (input.Length < 10)
|
||||
throw new InvalidDataException("Invalid PPAP message length.");
|
||||
return (PpapMessageType)input[5];
|
||||
}
|
||||
|
||||
static byte[] EncodeRoleVersion(PpapMessageType type, PpapIdentityRole role,
|
||||
long version)
|
||||
{
|
||||
ValidateRole(role);
|
||||
if (version < 2)
|
||||
throw new InvalidDataException("Invalid committed registration version.");
|
||||
return Encode(type, payload =>
|
||||
{
|
||||
payload.WriteByte((byte)role);
|
||||
WriteInt64(payload, version);
|
||||
});
|
||||
}
|
||||
|
||||
static long DecodeRoleVersion(object data, PpapMessageType type,
|
||||
PpapIdentityRole expectedRole)
|
||||
{
|
||||
var payload = DecodeEnvelope(RequireWireBytes(data), type);
|
||||
var offset = 0;
|
||||
var role = ReadRole(payload, ref offset);
|
||||
var version = ReadInt64(payload, ref offset);
|
||||
RequireEnd(payload, offset);
|
||||
if (role != expectedRole || version < 2)
|
||||
throw new InvalidDataException("Unexpected rotation commit.");
|
||||
return version;
|
||||
}
|
||||
|
||||
static PpapIdentityRole DecodeRoleOnly(object data, PpapMessageType type)
|
||||
{
|
||||
var payload = DecodeEnvelope(RequireWireBytes(data), type);
|
||||
var offset = 0;
|
||||
var role = ReadRole(payload, ref offset);
|
||||
RequireEnd(payload, offset);
|
||||
return role;
|
||||
}
|
||||
|
||||
static PpapIdentityRole ReadRole(byte[] payload, ref int offset)
|
||||
{
|
||||
if (offset >= payload.Length)
|
||||
throw new InvalidDataException("Missing PPAP identity role.");
|
||||
var role = (PpapIdentityRole)payload[offset++];
|
||||
ValidateRole(role);
|
||||
return role;
|
||||
}
|
||||
|
||||
static byte ValidateRole(PpapIdentityRole role)
|
||||
{
|
||||
if (role != PpapIdentityRole.Initiator
|
||||
&& role != PpapIdentityRole.Responder)
|
||||
throw new InvalidDataException("Invalid PPAP identity role.");
|
||||
return (byte)role;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,626 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
|
||||
namespace Esiur.Security.Authority.Providers.Ppap;
|
||||
|
||||
/// <summary>
|
||||
/// Stable message codes for the ppap-mlkem768-v1 authentication and rotation protocols.
|
||||
/// Authentication payloads are canonical byte arrays, not runtime-specific object graphs.
|
||||
/// </summary>
|
||||
public enum PpapMessageType : byte
|
||||
{
|
||||
ClientHello = 1,
|
||||
ServerHello = 2,
|
||||
InitiatorProof = 3,
|
||||
ResponderProof = 4,
|
||||
InitiatorFinished = 5,
|
||||
|
||||
RotationStart = 16,
|
||||
RotationOffer = 17,
|
||||
RotationChallenge = 18,
|
||||
RotationProof = 19,
|
||||
RotationCommit = 20,
|
||||
RotationCommitAck = 21,
|
||||
RotationDone = 22,
|
||||
}
|
||||
|
||||
public enum PpapIdentityRole : byte
|
||||
{
|
||||
Initiator = 1,
|
||||
Responder = 2,
|
||||
}
|
||||
|
||||
internal sealed class PpapRegistrationDescriptor
|
||||
{
|
||||
public PpapIdentityKind Kind { get; }
|
||||
public long Version { get; }
|
||||
public byte[] Nonce { get; }
|
||||
public PpapKdfProfile KdfProfile { get; }
|
||||
|
||||
public PpapRegistrationDescriptor(PpapIdentityKind kind, long version,
|
||||
byte[] nonce, PpapKdfProfile kdfProfile)
|
||||
{
|
||||
if (version < 1)
|
||||
throw new InvalidDataException("Invalid registration version.");
|
||||
Kind = kind;
|
||||
Version = version;
|
||||
KdfProfile = kdfProfile;
|
||||
|
||||
if (kind == PpapIdentityKind.PasswordDerived)
|
||||
{
|
||||
if (nonce == null || nonce.Length != PpapProtocol.RegistrationNonceLength
|
||||
|| kdfProfile == null)
|
||||
throw new InvalidDataException("Invalid password registration descriptor.");
|
||||
Nonce = (byte[])nonce.Clone();
|
||||
}
|
||||
else if (kind == PpapIdentityKind.StaticMlKem)
|
||||
{
|
||||
if ((nonce != null && nonce.Length != 0) || kdfProfile != null)
|
||||
throw new InvalidDataException("Invalid static-key registration descriptor.");
|
||||
Nonce = Array.Empty<byte>();
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new InvalidDataException("Unknown PPAP identity kind.");
|
||||
}
|
||||
}
|
||||
|
||||
public static PpapRegistrationDescriptor FromRecord(PpapRegistrationRecord record)
|
||||
{
|
||||
if (record == null)
|
||||
throw new ArgumentNullException(nameof(record));
|
||||
return new PpapRegistrationDescriptor(record.Kind, record.Version,
|
||||
record.NonceBytes, record.KdfProfile);
|
||||
}
|
||||
}
|
||||
|
||||
internal static partial class PpapWire
|
||||
{
|
||||
static readonly byte[] Magic = { (byte)'P', (byte)'P', (byte)'A', (byte)'P' };
|
||||
|
||||
internal sealed class ClientHello
|
||||
{
|
||||
public AuthenticationMode Mode;
|
||||
public string Domain;
|
||||
public byte[] EphemeralKey;
|
||||
public byte[] InitiatorMask;
|
||||
}
|
||||
|
||||
internal sealed class ServerHello
|
||||
{
|
||||
public byte[] EphemeralCiphertext;
|
||||
public byte[] ResponderMask;
|
||||
public byte[] MaskedResponderIdentity;
|
||||
}
|
||||
|
||||
internal sealed class InitiatorProof
|
||||
{
|
||||
public byte[] MaskedInitiatorIdentity;
|
||||
public byte[] ResponderCiphertext;
|
||||
public PpapRegistrationDescriptor ResponderRegistration;
|
||||
}
|
||||
|
||||
internal sealed class ResponderProof
|
||||
{
|
||||
public byte[] InitiatorCiphertext;
|
||||
public PpapRegistrationDescriptor InitiatorRegistration;
|
||||
public byte[] Finished;
|
||||
public byte[] TranscriptCore;
|
||||
}
|
||||
|
||||
internal static byte[] EncodeClientHello(AuthenticationMode mode, string domain,
|
||||
byte[] ephemeralKey, byte[] initiatorMask)
|
||||
{
|
||||
ValidateMode(mode);
|
||||
PpapCryptography.ValidatePublicKey(ephemeralKey);
|
||||
ValidateExact(initiatorMask, PpapProtocol.IdentityMaskLength, nameof(initiatorMask));
|
||||
var domainBytes = PpapCryptography.EncodeUtf8(
|
||||
PpapCryptography.NormalizeDomain(domain), PpapProtocol.MaximumDomainBytes,
|
||||
nameof(domain));
|
||||
try
|
||||
{
|
||||
return Encode(PpapMessageType.ClientHello, payload =>
|
||||
{
|
||||
payload.WriteByte((byte)mode);
|
||||
WriteField(payload, domainBytes);
|
||||
WriteField(payload, ephemeralKey);
|
||||
WriteField(payload, initiatorMask);
|
||||
});
|
||||
}
|
||||
finally
|
||||
{
|
||||
PpapCryptography.Clear(domainBytes);
|
||||
}
|
||||
}
|
||||
|
||||
internal static ClientHello DecodeClientHello(object data)
|
||||
{
|
||||
var input = RequireWireBytes(data);
|
||||
var payload = DecodeEnvelope(input, PpapMessageType.ClientHello);
|
||||
var offset = 0;
|
||||
if (payload.Length == 0)
|
||||
throw new InvalidDataException("Missing authentication mode.");
|
||||
var mode = (AuthenticationMode)payload[offset++];
|
||||
ValidateMode(mode);
|
||||
var domainBytes = ReadField(payload, ref offset, PpapProtocol.MaximumDomainBytes);
|
||||
var key = ReadField(payload, ref offset, PpapProtocol.PublicKeyLength);
|
||||
var mask = ReadField(payload, ref offset, PpapProtocol.IdentityMaskLength);
|
||||
RequireEnd(payload, offset);
|
||||
PpapCryptography.ValidatePublicKey(key);
|
||||
ValidateExact(mask, PpapProtocol.IdentityMaskLength, nameof(mask));
|
||||
var domain = PpapCryptography.NormalizeDomain(PpapCryptography.DecodeUtf8(
|
||||
domainBytes, PpapProtocol.MaximumDomainBytes, "domain"));
|
||||
PpapCryptography.Clear(domainBytes);
|
||||
return new ClientHello
|
||||
{
|
||||
Mode = mode,
|
||||
Domain = domain,
|
||||
EphemeralKey = key,
|
||||
InitiatorMask = mask,
|
||||
};
|
||||
}
|
||||
|
||||
internal static byte[] EncodeServerHello(byte[] ephemeralCiphertext,
|
||||
byte[] responderMask, byte[] maskedResponderIdentity)
|
||||
{
|
||||
ValidateExact(ephemeralCiphertext, PpapProtocol.CiphertextLength,
|
||||
nameof(ephemeralCiphertext));
|
||||
ValidateExact(responderMask, PpapProtocol.IdentityMaskLength,
|
||||
nameof(responderMask));
|
||||
ValidateOptional(maskedResponderIdentity, PpapProtocol.HashLength,
|
||||
nameof(maskedResponderIdentity));
|
||||
return Encode(PpapMessageType.ServerHello, payload =>
|
||||
{
|
||||
WriteField(payload, ephemeralCiphertext);
|
||||
WriteField(payload, responderMask);
|
||||
WriteField(payload, maskedResponderIdentity);
|
||||
});
|
||||
}
|
||||
|
||||
internal static ServerHello DecodeServerHello(object data, bool responderAuthenticated)
|
||||
{
|
||||
var payload = DecodeEnvelope(RequireWireBytes(data), PpapMessageType.ServerHello);
|
||||
var offset = 0;
|
||||
var ciphertext = ReadField(payload, ref offset, PpapProtocol.CiphertextLength);
|
||||
var mask = ReadField(payload, ref offset, PpapProtocol.IdentityMaskLength);
|
||||
var maskedIdentity = ReadField(payload, ref offset, PpapProtocol.HashLength);
|
||||
RequireEnd(payload, offset);
|
||||
ValidateExact(ciphertext, PpapProtocol.CiphertextLength, nameof(ciphertext));
|
||||
ValidateExact(mask, PpapProtocol.IdentityMaskLength, nameof(mask));
|
||||
ValidatePresence(maskedIdentity, responderAuthenticated, PpapProtocol.HashLength,
|
||||
nameof(maskedIdentity));
|
||||
return new ServerHello
|
||||
{
|
||||
EphemeralCiphertext = ciphertext,
|
||||
ResponderMask = mask,
|
||||
MaskedResponderIdentity = maskedIdentity,
|
||||
};
|
||||
}
|
||||
|
||||
internal static byte[] EncodeInitiatorProof(byte[] maskedInitiatorIdentity,
|
||||
byte[] responderCiphertext, PpapRegistrationDescriptor responderRegistration,
|
||||
string domain, byte[] ephemeralSecret)
|
||||
{
|
||||
ValidateOptional(maskedInitiatorIdentity, PpapProtocol.HashLength,
|
||||
nameof(maskedInitiatorIdentity));
|
||||
ValidateOptional(responderCiphertext, PpapProtocol.CiphertextLength,
|
||||
nameof(responderCiphertext));
|
||||
var descriptor = ProtectDescriptor(responderRegistration,
|
||||
PpapIdentityRole.Responder, domain, ephemeralSecret);
|
||||
try
|
||||
{
|
||||
return Encode(PpapMessageType.InitiatorProof, payload =>
|
||||
{
|
||||
WriteField(payload, maskedInitiatorIdentity);
|
||||
WriteField(payload, responderCiphertext);
|
||||
WriteField(payload, descriptor);
|
||||
});
|
||||
}
|
||||
finally
|
||||
{
|
||||
PpapCryptography.Clear(descriptor);
|
||||
}
|
||||
}
|
||||
|
||||
internal static InitiatorProof DecodeInitiatorProof(object data,
|
||||
bool initiatorAuthenticated, bool responderAuthenticated,
|
||||
string domain, byte[] ephemeralSecret)
|
||||
{
|
||||
var payload = DecodeEnvelope(RequireWireBytes(data), PpapMessageType.InitiatorProof);
|
||||
var offset = 0;
|
||||
var maskedIdentity = ReadField(payload, ref offset, PpapProtocol.HashLength);
|
||||
var ciphertext = ReadField(payload, ref offset, PpapProtocol.CiphertextLength);
|
||||
var descriptorBytes = ReadField(payload, ref offset,
|
||||
PpapProtocol.ProtectedDescriptorLength);
|
||||
RequireEnd(payload, offset);
|
||||
ValidatePresence(maskedIdentity, initiatorAuthenticated, PpapProtocol.HashLength,
|
||||
nameof(maskedIdentity));
|
||||
ValidatePresence(ciphertext, responderAuthenticated, PpapProtocol.CiphertextLength,
|
||||
nameof(ciphertext));
|
||||
ValidatePresence(descriptorBytes, responderAuthenticated,
|
||||
PpapProtocol.ProtectedDescriptorLength, nameof(descriptorBytes));
|
||||
try
|
||||
{
|
||||
var descriptor = responderAuthenticated
|
||||
? UnprotectDescriptor(descriptorBytes, PpapIdentityRole.Responder,
|
||||
domain, ephemeralSecret)
|
||||
: null;
|
||||
return new InitiatorProof
|
||||
{
|
||||
MaskedInitiatorIdentity = maskedIdentity,
|
||||
ResponderCiphertext = ciphertext,
|
||||
ResponderRegistration = descriptor,
|
||||
};
|
||||
}
|
||||
finally
|
||||
{
|
||||
PpapCryptography.Clear(descriptorBytes);
|
||||
}
|
||||
}
|
||||
|
||||
internal static byte[] EncodeResponderProofCore(byte[] initiatorCiphertext,
|
||||
PpapRegistrationDescriptor initiatorRegistration, string domain,
|
||||
byte[] ephemeralSecret, out byte[] protectedDescriptor)
|
||||
{
|
||||
ValidateOptional(initiatorCiphertext, PpapProtocol.CiphertextLength,
|
||||
nameof(initiatorCiphertext));
|
||||
protectedDescriptor = ProtectDescriptor(initiatorRegistration,
|
||||
PpapIdentityRole.Initiator, domain, ephemeralSecret);
|
||||
try
|
||||
{
|
||||
return EncodeResponderProofCoreProtected(initiatorCiphertext,
|
||||
protectedDescriptor);
|
||||
}
|
||||
catch
|
||||
{
|
||||
PpapCryptography.Clear(protectedDescriptor);
|
||||
protectedDescriptor = null;
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
internal static byte[] EncodeResponderProof(byte[] initiatorCiphertext,
|
||||
byte[] protectedInitiatorDescriptor, byte[] finished)
|
||||
{
|
||||
ValidateOptional(initiatorCiphertext, PpapProtocol.CiphertextLength,
|
||||
nameof(initiatorCiphertext));
|
||||
ValidateOptional(protectedInitiatorDescriptor,
|
||||
PpapProtocol.ProtectedDescriptorLength,
|
||||
nameof(protectedInitiatorDescriptor));
|
||||
ValidateExact(finished, PpapProtocol.FinishedTagLength, nameof(finished));
|
||||
return Encode(PpapMessageType.ResponderProof, payload =>
|
||||
{
|
||||
WriteField(payload, initiatorCiphertext);
|
||||
WriteField(payload, protectedInitiatorDescriptor);
|
||||
WriteField(payload, finished);
|
||||
});
|
||||
}
|
||||
|
||||
internal static ResponderProof DecodeResponderProof(object data,
|
||||
bool initiatorAuthenticated, string domain, byte[] ephemeralSecret)
|
||||
{
|
||||
var payload = DecodeEnvelope(RequireWireBytes(data), PpapMessageType.ResponderProof);
|
||||
var offset = 0;
|
||||
var ciphertext = ReadField(payload, ref offset, PpapProtocol.CiphertextLength);
|
||||
var descriptorBytes = ReadField(payload, ref offset,
|
||||
PpapProtocol.ProtectedDescriptorLength);
|
||||
var finished = ReadField(payload, ref offset, PpapProtocol.FinishedTagLength);
|
||||
RequireEnd(payload, offset);
|
||||
ValidatePresence(ciphertext, initiatorAuthenticated, PpapProtocol.CiphertextLength,
|
||||
nameof(ciphertext));
|
||||
ValidatePresence(descriptorBytes, initiatorAuthenticated,
|
||||
PpapProtocol.ProtectedDescriptorLength, nameof(descriptorBytes));
|
||||
ValidateExact(finished, PpapProtocol.FinishedTagLength, nameof(finished));
|
||||
try
|
||||
{
|
||||
var descriptor = initiatorAuthenticated
|
||||
? UnprotectDescriptor(descriptorBytes, PpapIdentityRole.Initiator,
|
||||
domain, ephemeralSecret)
|
||||
: null;
|
||||
var core = EncodeResponderProofCoreProtected(ciphertext,
|
||||
descriptorBytes);
|
||||
return new ResponderProof
|
||||
{
|
||||
InitiatorCiphertext = ciphertext,
|
||||
InitiatorRegistration = descriptor,
|
||||
Finished = finished,
|
||||
TranscriptCore = core,
|
||||
};
|
||||
}
|
||||
finally
|
||||
{
|
||||
PpapCryptography.Clear(descriptorBytes);
|
||||
}
|
||||
}
|
||||
|
||||
static byte[] EncodeResponderProofCoreProtected(byte[] initiatorCiphertext,
|
||||
byte[] protectedInitiatorDescriptor)
|
||||
{
|
||||
ValidateOptional(initiatorCiphertext, PpapProtocol.CiphertextLength,
|
||||
nameof(initiatorCiphertext));
|
||||
ValidateOptional(protectedInitiatorDescriptor,
|
||||
PpapProtocol.ProtectedDescriptorLength,
|
||||
nameof(protectedInitiatorDescriptor));
|
||||
return Encode(PpapMessageType.ResponderProof, payload =>
|
||||
{
|
||||
WriteField(payload, initiatorCiphertext);
|
||||
WriteField(payload, protectedInitiatorDescriptor);
|
||||
});
|
||||
}
|
||||
|
||||
internal static byte[] EncodeInitiatorFinished(byte[] finished)
|
||||
{
|
||||
ValidateExact(finished, PpapProtocol.FinishedTagLength, nameof(finished));
|
||||
return Encode(PpapMessageType.InitiatorFinished,
|
||||
payload => WriteField(payload, finished));
|
||||
}
|
||||
|
||||
internal static byte[] DecodeInitiatorFinished(object data)
|
||||
{
|
||||
var payload = DecodeEnvelope(RequireWireBytes(data), PpapMessageType.InitiatorFinished);
|
||||
var offset = 0;
|
||||
var finished = ReadField(payload, ref offset, PpapProtocol.FinishedTagLength);
|
||||
RequireEnd(payload, offset);
|
||||
ValidateExact(finished, PpapProtocol.FinishedTagLength, nameof(finished));
|
||||
return finished;
|
||||
}
|
||||
|
||||
internal static byte[] EncodeAuthenticationContext(AuthenticationMode mode,
|
||||
string domain, string initiatorIdentity, PpapIdentityKind? initiatorKind,
|
||||
string responderIdentity, PpapIdentityKind? responderKind)
|
||||
{
|
||||
ValidateMode(mode);
|
||||
var needsInitiator = mode == AuthenticationMode.InitializerIdentity
|
||||
|| mode == AuthenticationMode.DualIdentity;
|
||||
var needsResponder = mode == AuthenticationMode.ResponderIdentity
|
||||
|| mode == AuthenticationMode.DualIdentity;
|
||||
if (needsInitiator != initiatorKind.HasValue
|
||||
|| needsInitiator != (initiatorIdentity != null)
|
||||
|| needsResponder != responderKind.HasValue
|
||||
|| needsResponder != (responderIdentity != null))
|
||||
throw new InvalidDataException("The PPAP authentication context is incomplete.");
|
||||
if (initiatorKind.HasValue
|
||||
&& !Enum.IsDefined(typeof(PpapIdentityKind), initiatorKind.Value))
|
||||
throw new InvalidDataException("Invalid initiator identity kind.");
|
||||
if (responderKind.HasValue
|
||||
&& !Enum.IsDefined(typeof(PpapIdentityKind), responderKind.Value))
|
||||
throw new InvalidDataException("Invalid responder identity kind.");
|
||||
|
||||
var protocol = Encoding.ASCII.GetBytes(PpapProtocol.Name);
|
||||
var modeBytes = new[] { (byte)mode };
|
||||
var domainBytes = PpapCryptography.EncodeUtf8(
|
||||
PpapCryptography.NormalizeDomain(domain), PpapProtocol.MaximumDomainBytes,
|
||||
nameof(domain));
|
||||
var initiatorBytes = PpapCryptography.EncodeUtf8(
|
||||
initiatorIdentity == null ? string.Empty
|
||||
: PpapCryptography.NormalizeIdentity(initiatorIdentity),
|
||||
PpapProtocol.MaximumIdentityBytes, nameof(initiatorIdentity));
|
||||
var responderBytes = PpapCryptography.EncodeUtf8(
|
||||
responderIdentity == null ? string.Empty
|
||||
: PpapCryptography.NormalizeIdentity(responderIdentity),
|
||||
PpapProtocol.MaximumIdentityBytes, nameof(responderIdentity));
|
||||
var kinds = new[]
|
||||
{
|
||||
initiatorKind.HasValue ? (byte)initiatorKind.Value : (byte)0,
|
||||
responderKind.HasValue ? (byte)responderKind.Value : (byte)0,
|
||||
};
|
||||
try
|
||||
{
|
||||
return PpapCryptography.EncodeFrames(protocol, modeBytes, domainBytes,
|
||||
initiatorBytes, responderBytes, kinds);
|
||||
}
|
||||
finally
|
||||
{
|
||||
PpapCryptography.Clear(protocol);
|
||||
PpapCryptography.Clear(domainBytes);
|
||||
PpapCryptography.Clear(initiatorBytes);
|
||||
PpapCryptography.Clear(responderBytes);
|
||||
PpapCryptography.Clear(kinds);
|
||||
}
|
||||
}
|
||||
|
||||
static byte[] ProtectDescriptor(PpapRegistrationDescriptor descriptor,
|
||||
PpapIdentityRole role, string domain, byte[] ephemeralSecret)
|
||||
{
|
||||
var plaintext = EncodeDescriptor(descriptor);
|
||||
if (plaintext.Length == 0)
|
||||
return plaintext;
|
||||
try
|
||||
{
|
||||
return PpapCryptography.ProtectRegistrationDescriptor(domain, role,
|
||||
ephemeralSecret, plaintext);
|
||||
}
|
||||
finally
|
||||
{
|
||||
PpapCryptography.Clear(plaintext);
|
||||
}
|
||||
}
|
||||
|
||||
static PpapRegistrationDescriptor UnprotectDescriptor(byte[] descriptor,
|
||||
PpapIdentityRole role, string domain, byte[] ephemeralSecret)
|
||||
{
|
||||
var plaintext = PpapCryptography.UnprotectRegistrationDescriptor(domain,
|
||||
role, ephemeralSecret, descriptor);
|
||||
try
|
||||
{
|
||||
return DecodeDescriptor(plaintext);
|
||||
}
|
||||
finally
|
||||
{
|
||||
PpapCryptography.Clear(plaintext);
|
||||
}
|
||||
}
|
||||
|
||||
static byte[] EncodeDescriptor(PpapRegistrationDescriptor descriptor)
|
||||
{
|
||||
if (descriptor == null)
|
||||
return Array.Empty<byte>();
|
||||
using (var output = new MemoryStream())
|
||||
{
|
||||
output.WriteByte((byte)descriptor.Kind);
|
||||
WriteInt64(output, descriptor.Version);
|
||||
WriteField(output, descriptor.Nonce);
|
||||
if (descriptor.Kind == PpapIdentityKind.PasswordDerived)
|
||||
{
|
||||
output.WriteByte(1);
|
||||
PpapCryptography.WriteInt32(output, descriptor.KdfProfile.Version);
|
||||
PpapCryptography.WriteInt32(output, descriptor.KdfProfile.MemoryKiB);
|
||||
PpapCryptography.WriteInt32(output, descriptor.KdfProfile.Iterations);
|
||||
PpapCryptography.WriteInt32(output, descriptor.KdfProfile.Parallelism);
|
||||
}
|
||||
else
|
||||
{
|
||||
output.WriteByte(0);
|
||||
}
|
||||
return output.ToArray();
|
||||
}
|
||||
}
|
||||
|
||||
static PpapRegistrationDescriptor DecodeDescriptor(byte[] input)
|
||||
{
|
||||
if (input == null || input.Length < 10 || input.Length > 128)
|
||||
throw new InvalidDataException("Invalid registration descriptor.");
|
||||
var offset = 0;
|
||||
var kind = (PpapIdentityKind)input[offset++];
|
||||
var version = ReadInt64(input, ref offset);
|
||||
var nonce = ReadField(input, ref offset, PpapProtocol.RegistrationNonceLength);
|
||||
if (offset >= input.Length)
|
||||
throw new InvalidDataException("Truncated registration descriptor.");
|
||||
var hasProfile = input[offset++];
|
||||
PpapKdfProfile profile = null;
|
||||
if (hasProfile == 1)
|
||||
{
|
||||
var profileVersion = PpapCryptography.ReadInt32(input, ref offset);
|
||||
var memory = PpapCryptography.ReadInt32(input, ref offset);
|
||||
var iterations = PpapCryptography.ReadInt32(input, ref offset);
|
||||
var parallelism = PpapCryptography.ReadInt32(input, ref offset);
|
||||
profile = new PpapKdfProfile(profileVersion, memory, iterations, parallelism);
|
||||
}
|
||||
else if (hasProfile != 0)
|
||||
{
|
||||
throw new InvalidDataException("Invalid KDF profile marker.");
|
||||
}
|
||||
RequireEnd(input, offset);
|
||||
return new PpapRegistrationDescriptor(kind, version, nonce, profile);
|
||||
}
|
||||
|
||||
static byte[] Encode(PpapMessageType type, Action<MemoryStream> writePayload)
|
||||
{
|
||||
using (var payload = new MemoryStream())
|
||||
{
|
||||
writePayload(payload);
|
||||
if (payload.Length > PpapProtocol.MaximumWireMessageBytes - 10)
|
||||
throw new InvalidDataException("PPAP payload is too large.");
|
||||
using (var message = new MemoryStream())
|
||||
{
|
||||
message.Write(Magic, 0, Magic.Length);
|
||||
message.WriteByte(PpapProtocol.WireVersion);
|
||||
message.WriteByte((byte)type);
|
||||
PpapCryptography.WriteInt32(message, (int)payload.Length);
|
||||
payload.Position = 0;
|
||||
payload.CopyTo(message);
|
||||
return message.ToArray();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static byte[] DecodeEnvelope(byte[] input, PpapMessageType expectedType)
|
||||
{
|
||||
if (input.Length < 10 || input.Length > PpapProtocol.MaximumWireMessageBytes)
|
||||
throw new InvalidDataException("Invalid PPAP message length.");
|
||||
for (var i = 0; i < Magic.Length; i++)
|
||||
if (input[i] != Magic[i])
|
||||
throw new InvalidDataException("Invalid PPAP message magic.");
|
||||
if (input[4] != PpapProtocol.WireVersion)
|
||||
throw new InvalidDataException("Unsupported PPAP wire version.");
|
||||
if (input[5] != (byte)expectedType)
|
||||
throw new InvalidDataException("Unexpected PPAP message type.");
|
||||
var offset = 6;
|
||||
var length = PpapCryptography.ReadInt32(input, ref offset);
|
||||
if (length != input.Length - offset)
|
||||
throw new InvalidDataException("Invalid PPAP payload length.");
|
||||
var payload = new byte[length];
|
||||
Buffer.BlockCopy(input, offset, payload, 0, length);
|
||||
return payload;
|
||||
}
|
||||
|
||||
static byte[] RequireWireBytes(object data)
|
||||
{
|
||||
if (!(data is byte[] input))
|
||||
throw new InvalidDataException("PPAP authentication data must be a byte array.");
|
||||
return input;
|
||||
}
|
||||
|
||||
static void WriteField(Stream output, byte[] value)
|
||||
{
|
||||
var field = value ?? Array.Empty<byte>();
|
||||
PpapCryptography.WriteInt32(output, field.Length);
|
||||
output.Write(field, 0, field.Length);
|
||||
}
|
||||
|
||||
static byte[] ReadField(byte[] input, ref int offset, int maximumLength)
|
||||
{
|
||||
var length = PpapCryptography.ReadInt32(input, ref offset);
|
||||
if (length > maximumLength || input.Length - offset < length)
|
||||
throw new InvalidDataException("Invalid PPAP field length.");
|
||||
var field = new byte[length];
|
||||
Buffer.BlockCopy(input, offset, field, 0, length);
|
||||
offset += length;
|
||||
return field;
|
||||
}
|
||||
|
||||
static void WriteInt64(Stream output, long value)
|
||||
{
|
||||
if (value < 0)
|
||||
throw new ArgumentOutOfRangeException(nameof(value));
|
||||
for (var shift = 56; shift >= 0; shift -= 8)
|
||||
output.WriteByte((byte)(value >> shift));
|
||||
}
|
||||
|
||||
static long ReadInt64(byte[] input, ref int offset)
|
||||
{
|
||||
if (input.Length - offset < 8)
|
||||
throw new InvalidDataException("Truncated PPAP integer.");
|
||||
long value = 0;
|
||||
for (var i = 0; i < 8; i++)
|
||||
value = (value << 8) | input[offset++];
|
||||
if (value < 0)
|
||||
throw new InvalidDataException("Negative PPAP integer.");
|
||||
return value;
|
||||
}
|
||||
|
||||
static void ValidateMode(AuthenticationMode mode)
|
||||
{
|
||||
if (mode != AuthenticationMode.InitializerIdentity
|
||||
&& mode != AuthenticationMode.ResponderIdentity
|
||||
&& mode != AuthenticationMode.DualIdentity)
|
||||
throw new InvalidDataException("Unsupported PPAP authentication mode.");
|
||||
}
|
||||
|
||||
static void ValidateExact(byte[] value, int length, string name)
|
||||
{
|
||||
if (value == null || value.Length != length)
|
||||
throw new InvalidDataException($"{name} has an invalid length.");
|
||||
}
|
||||
|
||||
static void ValidateOptional(byte[] value, int length, string name)
|
||||
{
|
||||
if (value != null && value.Length != 0 && value.Length != length)
|
||||
throw new InvalidDataException($"{name} has an invalid length.");
|
||||
}
|
||||
|
||||
static void ValidatePresence(byte[] value, bool required, int length, string name)
|
||||
{
|
||||
if (required)
|
||||
ValidateExact(value, length, name);
|
||||
else if (value == null || value.Length != 0)
|
||||
throw new InvalidDataException($"{name} must be absent.");
|
||||
}
|
||||
|
||||
static void RequireEnd(byte[] input, int offset)
|
||||
{
|
||||
if (offset != input.Length)
|
||||
throw new InvalidDataException("Trailing PPAP message data.");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user