477 lines
20 KiB
C#
477 lines
20 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using System.Net;
|
|
using System.Net.Security;
|
|
using System.Security.Cryptography;
|
|
using System.Security.Cryptography.X509Certificates;
|
|
using System.Text;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using Npgsql.BackendMessages;
|
|
using Npgsql.Util;
|
|
using static Npgsql.Util.Statics;
|
|
|
|
namespace Npgsql.Internal;
|
|
|
|
partial class NpgsqlConnector
|
|
{
|
|
async Task Authenticate(string username, NpgsqlTimeout timeout, bool async, CancellationToken cancellationToken)
|
|
{
|
|
Log.Trace("Authenticating...", Id);
|
|
|
|
timeout.CheckAndApply(this);
|
|
var msg = Expect<AuthenticationRequestMessage>(await ReadMessage(async), this);
|
|
switch (msg.AuthRequestType)
|
|
{
|
|
case AuthenticationRequestType.AuthenticationOk:
|
|
return;
|
|
|
|
case AuthenticationRequestType.AuthenticationCleartextPassword:
|
|
await AuthenticateCleartext(username, async, cancellationToken);
|
|
return;
|
|
|
|
case AuthenticationRequestType.AuthenticationMD5Password:
|
|
await AuthenticateMD5(username, ((AuthenticationMD5PasswordMessage)msg).Salt, async, cancellationToken);
|
|
return;
|
|
|
|
case AuthenticationRequestType.AuthenticationSASL:
|
|
await AuthenticateSASL(((AuthenticationSASLMessage)msg).Mechanisms, username, async, cancellationToken);
|
|
return;
|
|
|
|
case AuthenticationRequestType.AuthenticationGSS:
|
|
case AuthenticationRequestType.AuthenticationSSPI:
|
|
await AuthenticateGSS(async);
|
|
return;
|
|
|
|
case AuthenticationRequestType.AuthenticationGSSContinue:
|
|
throw new NpgsqlException("Can't start auth cycle with AuthenticationGSSContinue");
|
|
|
|
default:
|
|
throw new NotSupportedException($"Authentication method not supported (Received: {msg.AuthRequestType})");
|
|
}
|
|
}
|
|
|
|
async Task AuthenticateCleartext(string username, bool async, CancellationToken cancellationToken = default)
|
|
{
|
|
var passwd = GetPassword(username);
|
|
if (passwd == null)
|
|
throw new NpgsqlException("No password has been provided but the backend requires one (in cleartext)");
|
|
|
|
var encoded = new byte[Encoding.UTF8.GetByteCount(passwd) + 1];
|
|
Encoding.UTF8.GetBytes(passwd, 0, passwd.Length, encoded, 0);
|
|
|
|
await WritePassword(encoded, async, cancellationToken);
|
|
await Flush(async, cancellationToken);
|
|
Expect<AuthenticationRequestMessage>(await ReadMessage(async), this);
|
|
}
|
|
|
|
async Task AuthenticateSASL(List<string> mechanisms, string username, bool async, CancellationToken cancellationToken = default)
|
|
{
|
|
// At the time of writing PostgreSQL only supports SCRAM-SHA-256 and SCRAM-SHA-256-PLUS
|
|
var supportsSha256 = mechanisms.Contains("SCRAM-SHA-256");
|
|
var supportsSha256Plus = mechanisms.Contains("SCRAM-SHA-256-PLUS");
|
|
if (!supportsSha256 && !supportsSha256Plus)
|
|
throw new NpgsqlException("No supported SASL mechanism found (only SCRAM-SHA-256 and SCRAM-SHA-256-PLUS are supported for now). " +
|
|
"Mechanisms received from server: " + string.Join(", ", mechanisms));
|
|
|
|
var mechanism = string.Empty;
|
|
var cbindFlag = string.Empty;
|
|
var cbind = string.Empty;
|
|
var successfulBind = false;
|
|
|
|
if (supportsSha256Plus)
|
|
{
|
|
var sslStream = (SslStream)_stream;
|
|
if (sslStream.RemoteCertificate is null)
|
|
{
|
|
Log.Warn("Remote certificate null, falling back to SCRAM-SHA-256");
|
|
}
|
|
else
|
|
{
|
|
using var remoteCertificate = new X509Certificate2(sslStream.RemoteCertificate);
|
|
// Checking for hashing algorithms
|
|
HashAlgorithm? hashAlgorithm = null;
|
|
var algorithmName = remoteCertificate.SignatureAlgorithm.FriendlyName;
|
|
if (algorithmName is null)
|
|
{
|
|
Log.Warn("Signature algorithm was null, falling back to SCRAM-SHA-256");
|
|
}
|
|
else if (algorithmName.StartsWith("sha1", StringComparison.OrdinalIgnoreCase) ||
|
|
algorithmName.StartsWith("md5", StringComparison.OrdinalIgnoreCase) ||
|
|
algorithmName.StartsWith("sha256", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
hashAlgorithm = SHA256.Create();
|
|
}
|
|
else if (algorithmName.StartsWith("sha384", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
hashAlgorithm = SHA384.Create();
|
|
}
|
|
else if (algorithmName.StartsWith("sha512", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
hashAlgorithm = SHA512.Create();
|
|
}
|
|
else
|
|
{
|
|
Log.Warn($"Support for signature algorithm {algorithmName} is not yet implemented, falling back to SCRAM-SHA-256");
|
|
}
|
|
|
|
if (hashAlgorithm != null)
|
|
{
|
|
using var _ = hashAlgorithm;
|
|
|
|
// RFC 5929
|
|
mechanism = "SCRAM-SHA-256-PLUS";
|
|
// PostgreSQL only supports tls-server-end-point binding
|
|
cbindFlag = "p=tls-server-end-point";
|
|
// SCRAM-SHA-256-PLUS depends on using ssl stream, so it's fine
|
|
var cbindFlagBytes = Encoding.UTF8.GetBytes($"{cbindFlag},,");
|
|
|
|
var certificateHash = hashAlgorithm.ComputeHash(remoteCertificate.GetRawCertData());
|
|
var cbindBytes = cbindFlagBytes.Concat(certificateHash).ToArray();
|
|
cbind = Convert.ToBase64String(cbindBytes);
|
|
successfulBind = true;
|
|
IsScramPlus = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!successfulBind && supportsSha256)
|
|
{
|
|
mechanism = "SCRAM-SHA-256";
|
|
// We can get here if PostgreSQL supports only SCRAM-SHA-256 or there was an error while binding to SCRAM-SHA-256-PLUS
|
|
// So, we set 'n' (client does not support binding) if there was an error while binding
|
|
// or 'y' (client supports but server doesn't) in other case
|
|
cbindFlag = supportsSha256Plus ? "n" : "y";
|
|
cbind = supportsSha256Plus ? "biws" : "eSws";
|
|
successfulBind = true;
|
|
IsScram = true;
|
|
}
|
|
|
|
if (!successfulBind)
|
|
{
|
|
// We can get here if PostgreSQL supports only SCRAM-SHA-256-PLUS but there was an error while binding to it
|
|
throw new NpgsqlException("Unable to bind to SCRAM-SHA-256-PLUS, check logs for more information");
|
|
}
|
|
|
|
var passwd = GetPassword(username) ??
|
|
throw new NpgsqlException($"No password has been provided but the backend requires one (in SASL/{mechanism})");
|
|
|
|
// Assumption: the write buffer is big enough to contain all our outgoing messages
|
|
var clientNonce = GetNonce();
|
|
|
|
await WriteSASLInitialResponse(mechanism, PGUtil.UTF8Encoding.GetBytes($"{cbindFlag},,n=*,r={clientNonce}"), async, cancellationToken);
|
|
await Flush(async, cancellationToken);
|
|
|
|
var saslContinueMsg = Expect<AuthenticationSASLContinueMessage>(await ReadMessage(async), this);
|
|
if (saslContinueMsg.AuthRequestType != AuthenticationRequestType.AuthenticationSASLContinue)
|
|
throw new NpgsqlException("[SASL] AuthenticationSASLContinue message expected");
|
|
var firstServerMsg = AuthenticationSCRAMServerFirstMessage.Load(saslContinueMsg.Payload);
|
|
if (!firstServerMsg.Nonce.StartsWith(clientNonce, StringComparison.Ordinal))
|
|
throw new NpgsqlException("[SCRAM] Malformed SCRAMServerFirst message: server nonce doesn't start with client nonce");
|
|
|
|
var saltBytes = Convert.FromBase64String(firstServerMsg.Salt);
|
|
var saltedPassword = Hi(passwd.Normalize(NormalizationForm.FormKC), saltBytes, firstServerMsg.Iteration);
|
|
|
|
var clientKey = HMAC(saltedPassword, "Client Key");
|
|
byte[] storedKey;
|
|
using (var sha256 = SHA256.Create())
|
|
storedKey = sha256.ComputeHash(clientKey);
|
|
|
|
var clientFirstMessageBare = $"n=*,r={clientNonce}";
|
|
var serverFirstMessage = $"r={firstServerMsg.Nonce},s={firstServerMsg.Salt},i={firstServerMsg.Iteration}";
|
|
var clientFinalMessageWithoutProof = $"c={cbind},r={firstServerMsg.Nonce}";
|
|
|
|
var authMessage = $"{clientFirstMessageBare},{serverFirstMessage},{clientFinalMessageWithoutProof}";
|
|
|
|
var clientSignature = HMAC(storedKey, authMessage);
|
|
var clientProofBytes = Xor(clientKey, clientSignature);
|
|
var clientProof = Convert.ToBase64String(clientProofBytes);
|
|
|
|
var serverKey = HMAC(saltedPassword, "Server Key");
|
|
var serverSignature = HMAC(serverKey, authMessage);
|
|
|
|
var messageStr = $"{clientFinalMessageWithoutProof},p={clientProof}";
|
|
|
|
await WriteSASLResponse(Encoding.UTF8.GetBytes(messageStr), async, cancellationToken);
|
|
await Flush(async, cancellationToken);
|
|
|
|
var saslFinalServerMsg = Expect<AuthenticationSASLFinalMessage>(await ReadMessage(async), this);
|
|
if (saslFinalServerMsg.AuthRequestType != AuthenticationRequestType.AuthenticationSASLFinal)
|
|
throw new NpgsqlException("[SASL] AuthenticationSASLFinal message expected");
|
|
|
|
var scramFinalServerMsg = AuthenticationSCRAMServerFinalMessage.Load(saslFinalServerMsg.Payload);
|
|
if (scramFinalServerMsg.ServerSignature != Convert.ToBase64String(serverSignature))
|
|
throw new NpgsqlException("[SCRAM] Unable to verify server signature");
|
|
|
|
var okMsg = Expect<AuthenticationRequestMessage>(await ReadMessage(async), this);
|
|
if (okMsg.AuthRequestType != AuthenticationRequestType.AuthenticationOk)
|
|
throw new NpgsqlException("[SASL] Expected AuthenticationOK message");
|
|
|
|
static string GetNonce()
|
|
{
|
|
using var rncProvider = RandomNumberGenerator.Create();
|
|
var nonceBytes = new byte[18];
|
|
|
|
rncProvider.GetBytes(nonceBytes);
|
|
return Convert.ToBase64String(nonceBytes);
|
|
}
|
|
|
|
static byte[] Hi(string str, byte[] salt, int count)
|
|
{
|
|
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(str));
|
|
var salt1 = new byte[salt.Length + 4];
|
|
byte[] hi, u1;
|
|
|
|
Buffer.BlockCopy(salt, 0, salt1, 0, salt.Length);
|
|
salt1[salt1.Length - 1] = 1;
|
|
|
|
hi = u1 = hmac.ComputeHash(salt1);
|
|
|
|
for (var i = 1; i < count; i++)
|
|
{
|
|
var u2 = hmac.ComputeHash(u1);
|
|
Xor(hi, u2);
|
|
u1 = u2;
|
|
}
|
|
|
|
return hi;
|
|
}
|
|
|
|
static byte[] Xor(byte[] buffer1, byte[] buffer2)
|
|
{
|
|
for (var i = 0; i < buffer1.Length; i++)
|
|
buffer1[i] ^= buffer2[i];
|
|
return buffer1;
|
|
}
|
|
|
|
static byte[] HMAC(byte[] data, string key)
|
|
{
|
|
using var hmacsha256 = new HMACSHA256(data);
|
|
return hmacsha256.ComputeHash(Encoding.UTF8.GetBytes(key));
|
|
}
|
|
}
|
|
|
|
async Task AuthenticateMD5(string username, byte[] salt, bool async, CancellationToken cancellationToken = default)
|
|
{
|
|
var passwd = GetPassword(username);
|
|
if (passwd == null)
|
|
throw new NpgsqlException("No password has been provided but the backend requires one (in MD5)");
|
|
|
|
byte[] result;
|
|
using (var md5 = MD5.Create())
|
|
{
|
|
// First phase
|
|
var passwordBytes = PGUtil.UTF8Encoding.GetBytes(passwd);
|
|
var usernameBytes = PGUtil.UTF8Encoding.GetBytes(username);
|
|
var cryptBuf = new byte[passwordBytes.Length + usernameBytes.Length];
|
|
passwordBytes.CopyTo(cryptBuf, 0);
|
|
usernameBytes.CopyTo(cryptBuf, passwordBytes.Length);
|
|
|
|
var sb = new StringBuilder();
|
|
var hashResult = md5.ComputeHash(cryptBuf);
|
|
foreach (var b in hashResult)
|
|
sb.Append(b.ToString("x2"));
|
|
|
|
var prehash = sb.ToString();
|
|
|
|
var prehashbytes = PGUtil.UTF8Encoding.GetBytes(prehash);
|
|
cryptBuf = new byte[prehashbytes.Length + 4];
|
|
|
|
Array.Copy(salt, 0, cryptBuf, prehashbytes.Length, 4);
|
|
|
|
// 2.
|
|
prehashbytes.CopyTo(cryptBuf, 0);
|
|
|
|
sb = new StringBuilder("md5");
|
|
hashResult = md5.ComputeHash(cryptBuf);
|
|
foreach (var b in hashResult)
|
|
sb.Append(b.ToString("x2"));
|
|
|
|
var resultString = sb.ToString();
|
|
result = new byte[Encoding.UTF8.GetByteCount(resultString) + 1];
|
|
Encoding.UTF8.GetBytes(resultString, 0, resultString.Length, result, 0);
|
|
result[result.Length - 1] = 0;
|
|
}
|
|
|
|
await WritePassword(result, async, cancellationToken);
|
|
await Flush(async, cancellationToken);
|
|
Expect<AuthenticationRequestMessage>(await ReadMessage(async), this);
|
|
}
|
|
|
|
async Task AuthenticateGSS(bool async)
|
|
{
|
|
if (!IntegratedSecurity)
|
|
throw new NpgsqlException("GSS/SSPI authentication but IntegratedSecurity not enabled");
|
|
|
|
using var negotiateStream = new NegotiateStream(new GSSPasswordMessageStream(this), true);
|
|
try
|
|
{
|
|
var targetName = $"{KerberosServiceName}/{Host}";
|
|
if (async)
|
|
await negotiateStream.AuthenticateAsClientAsync(CredentialCache.DefaultNetworkCredentials, targetName);
|
|
else
|
|
negotiateStream.AuthenticateAsClient(CredentialCache.DefaultNetworkCredentials, targetName);
|
|
}
|
|
catch (AuthenticationCompleteException)
|
|
{
|
|
return;
|
|
}
|
|
catch (IOException e) when (e.InnerException is AuthenticationCompleteException)
|
|
{
|
|
return;
|
|
}
|
|
catch (IOException e) when (e.InnerException is PostgresException)
|
|
{
|
|
throw e.InnerException;
|
|
}
|
|
|
|
throw new NpgsqlException("NegotiateStream.AuthenticateAsClient completed unexpectedly without signaling success");
|
|
}
|
|
|
|
/// <summary>
|
|
/// This Stream is placed between NegotiateStream and the socket's NetworkStream (or SSLStream). It intercepts
|
|
/// traffic and performs the following operations:
|
|
/// * Outgoing messages are framed in PostgreSQL's PasswordMessage, and incoming are stripped of it.
|
|
/// * NegotiateStream frames payloads with a 5-byte header, which PostgreSQL doesn't understand. This header is
|
|
/// stripped from outgoing messages and added to incoming ones.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// See https://referencesource.microsoft.com/#System/net/System/Net/_StreamFramer.cs,16417e735f0e9530,references
|
|
/// </remarks>
|
|
class GSSPasswordMessageStream : Stream
|
|
{
|
|
readonly NpgsqlConnector _connector;
|
|
int _leftToWrite;
|
|
int _leftToRead, _readPos;
|
|
byte[]? _readBuf;
|
|
|
|
internal GSSPasswordMessageStream(NpgsqlConnector connector)
|
|
=> _connector = connector;
|
|
|
|
public override Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken = default)
|
|
=> Write(buffer, offset, count, true, cancellationToken);
|
|
|
|
public override void Write(byte[] buffer, int offset, int count)
|
|
=> Write(buffer, offset, count, false).GetAwaiter().GetResult();
|
|
|
|
async Task Write(byte[] buffer, int offset, int count, bool async, CancellationToken cancellationToken = default)
|
|
{
|
|
if (_leftToWrite == 0)
|
|
{
|
|
// We're writing the frame header, which contains the payload size.
|
|
_leftToWrite = (buffer[3] << 8) | buffer[4];
|
|
|
|
buffer[0] = 22;
|
|
if (buffer[1] != 1)
|
|
throw new NotSupportedException($"Received frame header major v {buffer[1]} (different from 1)");
|
|
if (buffer[2] != 0)
|
|
throw new NotSupportedException($"Received frame header minor v {buffer[2]} (different from 0)");
|
|
|
|
// In case of payload data in the same buffer just after the frame header
|
|
if (count == 5)
|
|
return;
|
|
count -= 5;
|
|
offset += 5;
|
|
}
|
|
|
|
if (count > _leftToWrite)
|
|
throw new NpgsqlException($"NegotiateStream trying to write {count} bytes but according to frame header we only have {_leftToWrite} left!");
|
|
await _connector.WritePassword(buffer, offset, count, async, cancellationToken);
|
|
await _connector.Flush(async, cancellationToken);
|
|
_leftToWrite -= count;
|
|
}
|
|
|
|
public override Task<int> ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken = default)
|
|
=> Read(buffer, offset, count, true, cancellationToken);
|
|
|
|
public override int Read(byte[] buffer, int offset, int count)
|
|
=> Read(buffer, offset, count, false).GetAwaiter().GetResult();
|
|
|
|
async Task<int> Read(byte[] buffer, int offset, int count, bool async, CancellationToken cancellationToken = default)
|
|
{
|
|
if (_leftToRead == 0)
|
|
{
|
|
var response = Expect<AuthenticationRequestMessage>(await _connector.ReadMessage(async), _connector);
|
|
if (response.AuthRequestType == AuthenticationRequestType.AuthenticationOk)
|
|
throw new AuthenticationCompleteException();
|
|
var gssMsg = response as AuthenticationGSSContinueMessage;
|
|
if (gssMsg == null)
|
|
throw new NpgsqlException($"Received unexpected authentication request message {response.AuthRequestType}");
|
|
_readBuf = gssMsg.AuthenticationData;
|
|
_leftToRead = gssMsg.AuthenticationData.Length;
|
|
_readPos = 0;
|
|
buffer[0] = 22;
|
|
buffer[1] = 1;
|
|
buffer[2] = 0;
|
|
buffer[3] = (byte)((_leftToRead >> 8) & 0xFF);
|
|
buffer[4] = (byte)(_leftToRead & 0xFF);
|
|
return 5;
|
|
}
|
|
|
|
if (count > _leftToRead)
|
|
throw new NpgsqlException($"NegotiateStream trying to read {count} bytes but according to frame header we only have {_leftToRead} left!");
|
|
count = Math.Min(count, _leftToRead);
|
|
Array.Copy(_readBuf!, _readPos, buffer, offset, count);
|
|
_leftToRead -= count;
|
|
return count;
|
|
}
|
|
|
|
public override void Flush() { }
|
|
|
|
public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();
|
|
public override void SetLength(long value) => throw new NotSupportedException();
|
|
|
|
public override bool CanRead => true;
|
|
public override bool CanWrite => true;
|
|
public override bool CanSeek => false;
|
|
public override long Length => throw new NotSupportedException();
|
|
|
|
public override long Position
|
|
{
|
|
get => throw new NotSupportedException();
|
|
set => throw new NotSupportedException();
|
|
}
|
|
}
|
|
|
|
class AuthenticationCompleteException : Exception { }
|
|
|
|
string? GetPassword(string username)
|
|
{
|
|
var password = Settings.Password;
|
|
if (password != null)
|
|
return password;
|
|
|
|
if (ProvidePasswordCallback is { } passwordCallback)
|
|
try
|
|
{
|
|
Log.Trace($"Taking password from {nameof(ProvidePasswordCallback)} delegate");
|
|
password = passwordCallback(Host, Port, Settings.Database!, username);
|
|
}
|
|
catch (Exception e)
|
|
{
|
|
throw new NpgsqlException($"Obtaining password using {nameof(NpgsqlConnection)}.{nameof(ProvidePasswordCallback)} delegate failed", e);
|
|
}
|
|
|
|
if (password is null)
|
|
password = PostgresEnvironment.Password;
|
|
|
|
if (password != null)
|
|
return password;
|
|
|
|
var passFile = Settings.Passfile ?? PostgresEnvironment.PassFile ?? PostgresEnvironment.PassFileDefault;
|
|
if (passFile != null)
|
|
{
|
|
var matchingEntry = new PgPassFile(passFile!)
|
|
.GetFirstMatchingEntry(Host, Port, Settings.Database!, username);
|
|
if (matchingEntry != null)
|
|
{
|
|
Log.Trace("Taking password from pgpass file");
|
|
password = matchingEntry.Password;
|
|
}
|
|
}
|
|
|
|
return password;
|
|
}
|
|
} |