Skip to content

Commit 74b9e20

Browse files
auth: SCRAM-SHA-256-PLUS channel binding for POP3 (B2)
Completes the SCRAM-SHA-256-PLUS channel-binding rollout (RFC 5802 + RFC 5929 tls-server-end-point) across IMAP, SMTP and now POP3 (RFC 5034), reusing TCPConnection::GetTlsServerEndPoint and the ScramSha256 PLUS mode unchanged. POP3Connection::ProtocolAUTH_ gained a SCRAM-SHA-256-PLUS branch that requires a TLS connection (else -ERR), derives the tls-server-end-point binding and calls SetChannelBinding before driving the existing SCRAM exchange; the non-PLUS SCRAM-SHA-256 branch now calls SetServerSupportsChannelBinding on a TLS connection so a stripped-PLUS 'y' gs2 flag is rejected (RFC 5802 section 6). CAPA and the bare-AUTH mechanism list advertise SCRAM-SHA-256-PLUS only over TLS, alongside the always-offered SCRAM-SHA-256. Binds POP3 authentication to the specific TLS channel, defeating a man-in-the-middle who relays an otherwise-valid SCRAM exchange over a different TLS connection. Validated over a real TLS POP3 connection (RegressionTests.SSL.ScramPlusPop3): advertised-on-TLS-only, full channel-bound auth with a usable session afterwards, tampered-binding-rejected-with-the-correct-password (the MITM case), and refused-without-TLS. Server built 0/0 /WX; tests 0/0; SCRAM set 21/21; full POP3 suite 53/53; no errors logged.
1 parent 747e946 commit 74b9e20

5 files changed

Lines changed: 311 additions & 4 deletions

File tree

hmailserver/source/Server/POP3/POP3Connection.cpp

Lines changed: 47 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -407,7 +407,12 @@ namespace HM
407407
{
408408
capabilities+="USER\r\n";
409409
// RFC 5034: advertise the SASL mechanisms available for the AUTH command.
410-
capabilities+="SASL PLAIN SCRAM-SHA-256\r\n";
410+
// SCRAM-SHA-256-PLUS (RFC 5802 + RFC 5929 tls-server-end-point) binds the
411+
// exchange to the TLS channel, so it is only offered on a TLS connection.
412+
if (IsSSLConnection())
413+
capabilities+="SASL PLAIN SCRAM-SHA-256 SCRAM-SHA-256-PLUS\r\n";
414+
else
415+
capabilities+="SASL PLAIN SCRAM-SHA-256\r\n";
411416
}
412417

413418
if (GetConnectionSecurity() == CSSTARTTLSOptional ||
@@ -594,8 +599,12 @@ namespace HM
594599

595600
if (sParameter.IsEmpty())
596601
{
597-
// RFC 5034: list the supported SASL mechanisms.
598-
EnqueueWrite_("+OK List of SASL mechanisms follows\r\nPLAIN\r\nSCRAM-SHA-256\r\n.");
602+
// RFC 5034: list the supported SASL mechanisms. SCRAM-SHA-256-PLUS is only
603+
// offered on a TLS connection, where channel binding is meaningful.
604+
if (IsSSLConnection())
605+
EnqueueWrite_("+OK List of SASL mechanisms follows\r\nPLAIN\r\nSCRAM-SHA-256\r\nSCRAM-SHA-256-PLUS\r\n.");
606+
else
607+
EnqueueWrite_("+OK List of SASL mechanisms follows\r\nPLAIN\r\nSCRAM-SHA-256\r\n.");
599608
return ResultNormalResponse;
600609
}
601610

@@ -616,10 +625,45 @@ namespace HM
616625
return ResultNormalResponse;
617626
}
618627

628+
if (mechanism == _T("SCRAM-SHA-256-PLUS"))
629+
{
630+
// Channel binding only has meaning over TLS; the mechanism is advertised
631+
// (and accepted) only on a TLS connection.
632+
if (!IsSSLConnection())
633+
{
634+
EnqueueWrite_("-ERR SCRAM-SHA-256-PLUS requires a TLS connection.");
635+
return ResultNormalResponse;
636+
}
637+
638+
// Bind the exchange to this TLS channel via the server certificate
639+
// (RFC 5929 tls-server-end-point).
640+
std::vector<unsigned char> cbindData;
641+
if (!GetTlsServerEndPoint(cbindData))
642+
{
643+
EnqueueWrite_("-ERR Channel binding is not available on this connection.");
644+
return ResultNormalResponse;
645+
}
646+
647+
scram_session_ = std::make_shared<ScramSha256>();
648+
scram_session_->SetChannelBinding(cbindData);
649+
650+
if (hasInitialResponse)
651+
return ProcessScramClientFirst_(parts[1]);
652+
653+
EnqueueWrite_("+ ");
654+
return ResultNormalResponse;
655+
}
656+
619657
if (mechanism == _T("SCRAM-SHA-256"))
620658
{
621659
scram_session_ = std::make_shared<ScramSha256>();
622660

661+
// On a TLS connection the server also advertises SCRAM-SHA-256-PLUS, so a
662+
// non-PLUS client that sends the 'y' gs2 flag is signalling a stripped-PLUS
663+
// downgrade and is rejected (RFC 5802 section 6).
664+
if (IsSSLConnection())
665+
scram_session_->SetServerSupportsChannelBinding();
666+
623667
if (hasInitialResponse)
624668
return ProcessScramClientFirst_(parts[1]);
625669

hmailserver/test/RegressionTests/POP3/Basics.cs

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -711,6 +711,77 @@ public static string AuthenticateScram(TcpConnection con, string username, strin
711711
return con.Receive();
712712
}
713713

714+
/// <summary>
715+
/// Runs a full SCRAM-SHA-256-PLUS exchange (RFC 5802 + RFC 5929
716+
/// tls-server-end-point channel binding), no SASL-IR. Returns the final +OK
717+
/// on success, or the -ERR line if the server rejects the mechanism, the
718+
/// binding, or the proof.
719+
/// </summary>
720+
public static string AuthenticateScramPlus(TcpConnection con, string username, string password, byte[] channelBindingData)
721+
{
722+
var nonceBytes = new byte[18];
723+
using (var rng = RandomNumberGenerator.Create())
724+
rng.GetBytes(nonceBytes);
725+
string clientNonce = Convert.ToBase64String(nonceBytes);
726+
727+
string gs2Header = "p=tls-server-end-point,,";
728+
string clientFirstBare = "n=" + SaslName(username) + ",r=" + clientNonce;
729+
string clientFirst = gs2Header + clientFirstBare;
730+
731+
con.Send("AUTH SCRAM-SHA-256-PLUS\r\n");
732+
string challenge = con.Receive();
733+
if (!challenge.TrimStart().StartsWith("+"))
734+
return challenge; // mechanism rejected (e.g. -ERR requires TLS)
735+
736+
con.Send(Base64(clientFirst) + "\r\n");
737+
string serverFirstLine = con.Receive();
738+
if (!serverFirstLine.TrimStart().StartsWith("+"))
739+
return serverFirstLine; // protocol error / rejection
740+
741+
string serverFirst = DecodeContinuation(serverFirstLine);
742+
743+
string combinedNonce = Attribute(serverFirst, "r");
744+
byte[] salt = Convert.FromBase64String(Attribute(serverFirst, "s"));
745+
int iterations = int.Parse(Attribute(serverFirst, "i"));
746+
Assert.IsTrue(combinedNonce.StartsWith(clientNonce),
747+
"Server nonce must start with the client nonce. Got: " + combinedNonce);
748+
749+
byte[] saltedPassword;
750+
using (var pbkdf2 = new Rfc2898DeriveBytes(password, salt, iterations, HashAlgorithmName.SHA256))
751+
saltedPassword = pbkdf2.GetBytes(32);
752+
753+
byte[] clientKey = Hmac(saltedPassword, "Client Key");
754+
byte[] storedKey = Sha256(clientKey);
755+
756+
// c= carries base64(gs2-header || channel-binding-data) for PLUS.
757+
byte[] gs2HeaderBytes = Encoding.ASCII.GetBytes(gs2Header);
758+
byte[] cbind = new byte[gs2HeaderBytes.Length + channelBindingData.Length];
759+
Buffer.BlockCopy(gs2HeaderBytes, 0, cbind, 0, gs2HeaderBytes.Length);
760+
Buffer.BlockCopy(channelBindingData, 0, cbind, gs2HeaderBytes.Length, channelBindingData.Length);
761+
762+
string clientFinalWithoutProof = "c=" + Convert.ToBase64String(cbind) + ",r=" + combinedNonce;
763+
string authMessage = clientFirstBare + "," + serverFirst + "," + clientFinalWithoutProof;
764+
byte[] clientSignature = Hmac(storedKey, authMessage);
765+
byte[] clientProof = Xor(clientKey, clientSignature);
766+
767+
string clientFinal = clientFinalWithoutProof + ",p=" + Convert.ToBase64String(clientProof);
768+
con.Send(Base64(clientFinal) + "\r\n");
769+
770+
string afterFinal = con.Receive();
771+
if (!afterFinal.TrimStart().StartsWith("+"))
772+
return afterFinal; // rejected proof (-ERR)
773+
774+
string serverFinal = DecodeContinuation(afterFinal);
775+
byte[] serverKey = Hmac(saltedPassword, "Server Key");
776+
byte[] serverSignature = Hmac(serverKey, authMessage);
777+
Assert.AreEqual("v=" + Convert.ToBase64String(serverSignature), serverFinal,
778+
"Server signature (v=) did not verify.");
779+
780+
// Empty client response acknowledges the server-final; server completes auth.
781+
con.Send("\r\n");
782+
return con.Receive();
783+
}
784+
714785
private static string SaslName(string name)
715786
{
716787
return name.Replace("=", "=3D").Replace(",", "=2C");

hmailserver/test/RegressionTests/RegressionTests.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,7 @@
179179
<Compile Include="SSL\CertificateTypes.cs" />
180180
<Compile Include="SSL\ScramPlus.cs" />
181181
<Compile Include="SSL\ScramPlusSmtp.cs" />
182+
<Compile Include="SSL\ScramPlusPop3.cs" />
182183
<Compile Include="Stress\StabilitySanityTests.cs" />
183184
</ItemGroup>
184185
<ItemGroup>
Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
// Copyright (c) 2026 Christopher Holloway / Progressive Robot Ltd
2+
// http://www.hmailserver.com
3+
4+
using System;
5+
using System.Security.Cryptography;
6+
using System.Security.Cryptography.X509Certificates;
7+
using NUnit.Framework;
8+
using RegressionTests.POP3;
9+
using RegressionTests.Shared;
10+
11+
namespace RegressionTests.SSL
12+
{
13+
/// <summary>
14+
/// SCRAM-SHA-256-PLUS (RFC 5802 / RFC 7677) with tls-server-end-point channel
15+
/// binding (RFC 5929) over a real TLS POP3 connection (RFC 5034). These tests
16+
/// require TLS, so they live with the SSL suite and configure the SSL ports in
17+
/// each test.
18+
/// </summary>
19+
[TestFixture]
20+
public class ScramPlusPop3 : TestFixtureBase
21+
{
22+
// Ports created by SslSetup.SetupSSLPorts.
23+
private const int Pop3PlainPort = 11000; // eCSNone
24+
private const int Pop3TlsPort = 11001; // eCSTLS (implicit TLS)
25+
26+
[Test]
27+
[Description("RFC 5802/5929 (SCRAM-SHA-256-PLUS over POP3): CAPA advertises SASL " +
28+
"SCRAM-SHA-256-PLUS over TLS (where channel binding is possible) but never on a " +
29+
"plain connection.")]
30+
public void TestScramPlusAdvertisedOnTlsOnly()
31+
{
32+
SslSetup.SetupSSLPorts(_application);
33+
SingletonProvider<TestSetup>.Instance.AddAccount(_domain, "pluspop3cap@example.test", "test");
34+
35+
using (var con = new TcpConnection(true))
36+
{
37+
Assert.IsTrue(con.Connect(Pop3TlsPort), "Could not connect to TLS POP3.");
38+
con.ReadUntil("+OK"); // banner
39+
con.Send("CAPA\r\n");
40+
string caps = con.Receive();
41+
Assert.IsTrue(caps.Contains("SCRAM-SHA-256-PLUS"),
42+
"TLS CAPA must advertise SASL SCRAM-SHA-256-PLUS. " + caps);
43+
}
44+
45+
using (var con = new TcpConnection())
46+
{
47+
Assert.IsTrue(con.Connect(Pop3PlainPort), "Could not connect to plaintext POP3.");
48+
con.ReadUntil("+OK"); // banner
49+
con.Send("CAPA\r\n");
50+
string caps = con.Receive();
51+
Assert.IsFalse(caps.Contains("SCRAM-SHA-256-PLUS"),
52+
"A plain connection must not advertise SCRAM-SHA-256-PLUS. " + caps);
53+
Assert.IsTrue(caps.Contains("SCRAM-SHA-256"),
54+
"A plain connection should still advertise the non-PLUS SCRAM-SHA-256. " + caps);
55+
}
56+
}
57+
58+
[Test]
59+
[Description("RFC 5802/5929 (SCRAM-SHA-256-PLUS over POP3): a full channel-bound exchange " +
60+
"authenticates a PBKDF2 account over TLS and the server proves it knows the key.")]
61+
public void TestScramPlusAuthenticates()
62+
{
63+
SslSetup.SetupSSLPorts(_application);
64+
SingletonProvider<TestSetup>.Instance.AddAccount(_domain, "pluspop3ok@example.test", "SeC-r3t Pass!");
65+
66+
using (var con = new TcpConnection(true))
67+
{
68+
Assert.IsTrue(con.Connect(Pop3TlsPort), "Could not connect to TLS POP3.");
69+
con.ReadUntil("+OK"); // banner
70+
71+
byte[] cbind = TlsServerEndPoint(con.RemoteCertificate);
72+
string final = Pop3SaslTestClient.AuthenticateScramPlus(con, "pluspop3ok@example.test", "SeC-r3t Pass!", cbind);
73+
Assert.IsTrue(final.StartsWith("+OK"),
74+
"SCRAM-SHA-256-PLUS authentication should succeed (+OK). Got: " + final);
75+
76+
// The session must be authenticated and usable afterwards.
77+
con.Send("STAT\r\n");
78+
Assert.IsTrue(con.Receive().StartsWith("+OK"),
79+
"Session should be usable after a PLUS logon.");
80+
con.Send("QUIT\r\n");
81+
con.Disconnect();
82+
}
83+
}
84+
85+
[Test]
86+
[Description("RFC 5929 (SCRAM-SHA-256-PLUS over POP3): a channel binding that does not match the " +
87+
"server certificate is rejected even with the correct password, which is exactly the " +
88+
"man-in-the-middle case channel binding defends against.")]
89+
public void TestScramPlusWrongBindingFails()
90+
{
91+
SslSetup.SetupSSLPorts(_application);
92+
bool autoBan = _settings.AutoBanOnLogonFailure;
93+
_settings.AutoBanOnLogonFailure = false;
94+
_settings.ClearLogonFailureList();
95+
try
96+
{
97+
SingletonProvider<TestSetup>.Instance.AddAccount(_domain, "pluspop3mitm@example.test", "correct horse");
98+
99+
using (var con = new TcpConnection(true))
100+
{
101+
Assert.IsTrue(con.Connect(Pop3TlsPort), "Could not connect to TLS POP3.");
102+
con.ReadUntil("+OK"); // banner
103+
104+
byte[] cbind = TlsServerEndPoint(con.RemoteCertificate);
105+
// Simulate an attacker on a different TLS channel: the binding no longer
106+
// matches the certificate the server presents.
107+
cbind[0] ^= 0xFF;
108+
109+
string final = Pop3SaslTestClient.AuthenticateScramPlus(con, "pluspop3mitm@example.test", "correct horse", cbind);
110+
Assert.IsTrue(final.StartsWith("-ERR"),
111+
"A mismatched channel binding must be rejected even with the right password. Got: " + final);
112+
}
113+
}
114+
finally
115+
{
116+
_settings.AutoBanOnLogonFailure = autoBan;
117+
_settings.ClearLogonFailureList();
118+
}
119+
}
120+
121+
[Test]
122+
[Description("SCRAM-SHA-256-PLUS over POP3 requires a TLS channel: the mechanism is refused on a " +
123+
"plain connection where no channel binding is available.")]
124+
public void TestScramPlusRejectedWithoutTls()
125+
{
126+
SslSetup.SetupSSLPorts(_application);
127+
bool autoBan = _settings.AutoBanOnLogonFailure;
128+
_settings.AutoBanOnLogonFailure = false;
129+
_settings.ClearLogonFailureList();
130+
try
131+
{
132+
using (var con = new TcpConnection())
133+
{
134+
Assert.IsTrue(con.Connect(Pop3PlainPort), "Could not connect to plaintext POP3.");
135+
con.ReadUntil("+OK"); // banner
136+
137+
con.Send("AUTH SCRAM-SHA-256-PLUS\r\n");
138+
string resp = con.Receive();
139+
Assert.IsTrue(resp.StartsWith("-ERR"),
140+
"SCRAM-SHA-256-PLUS must be refused without TLS. Got: " + resp);
141+
}
142+
}
143+
finally
144+
{
145+
_settings.AutoBanOnLogonFailure = autoBan;
146+
_settings.ClearLogonFailureList();
147+
}
148+
}
149+
150+
/// <summary>
151+
/// Computes the RFC 5929 'tls-server-end-point' channel binding for a server
152+
/// certificate: the hash of the DER certificate using the certificate's
153+
/// signature hash, with MD5/SHA-1 substituted by SHA-256.
154+
/// </summary>
155+
private static byte[] TlsServerEndPoint(X509Certificate cert)
156+
{
157+
Assert.IsNotNull(cert, "No server certificate was negotiated.");
158+
159+
var cert2 = new X509Certificate2(cert);
160+
HashAlgorithm hash;
161+
switch (cert2.SignatureAlgorithm.Value)
162+
{
163+
case "1.2.840.113549.1.1.12": // sha384RSA
164+
case "1.2.840.10045.4.3.3": // ecdsa-with-SHA384
165+
hash = SHA384.Create();
166+
break;
167+
case "1.2.840.113549.1.1.13": // sha512RSA
168+
case "1.2.840.10045.4.3.4": // ecdsa-with-SHA512
169+
hash = SHA512.Create();
170+
break;
171+
default: // sha256RSA / ecdsa-with-SHA256 and the MD5/SHA-1 -> SHA-256 substitution
172+
hash = SHA256.Create();
173+
break;
174+
}
175+
176+
using (hash)
177+
return hash.ComputeHash(cert.GetRawCertData());
178+
}
179+
}
180+
}

planupdate.md

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -394,6 +394,17 @@ upgrading the management/admin INI password from MD5.
394394
session afterwards, tampered-binding-rejected-with-the-correct-password (the MITM case), and
395395
refused-without-TLS. SCRAM set 17/17 and the full SMTP suite 178/178 green. Follow-up: SCRAM-SHA-256-PLUS
396396
for POP3.
397+
-**SCRAM-SHA-256-PLUS channel binding (POP3) — delivered in v6.2.0.** Completed the channel-binding
398+
rollout across all three retrieval/submission protocols by extending the same mechanism to POP3 `AUTH`
399+
(RFC 5034), again reusing `TCPConnection::GetTlsServerEndPoint` and the `ScramSha256` PLUS mode unchanged.
400+
`POP3Connection::ProtocolAUTH_` gained a `SCRAM-SHA-256-PLUS` branch that requires a TLS connection (else
401+
`-ERR`), derives the tls-server-end-point binding and calls `SetChannelBinding` before driving the existing
402+
SCRAM exchange; the non-PLUS `SCRAM-SHA-256` branch now calls `SetServerSupportsChannelBinding()` on a TLS
403+
connection for stripped-PLUS downgrade protection (RFC 5802 §6). `CAPA` and the bare-`AUTH` mechanism list
404+
advertise `SCRAM-SHA-256-PLUS` only over TLS. Validated over a real TLS POP3 connection
405+
(`RegressionTests.SSL.ScramPlusPop3`): advertised-on-TLS-only, full channel-bound auth + a usable session,
406+
tampered-binding-rejected-with-the-correct-password (the MITM case), and refused-without-TLS. SCRAM set
407+
21/21 and the full POP3 suite 53/53 green.
397408
-**Argon2id KDF option — delivered in v6.2.0.** Added the OWASP-recommended memory-hard KDF as
398409
password-hash algorithm **5** (`Crypt::ETArgon2id`), implemented in `HashCreator`
399410
(`GenerateArgon2id`/`ValidateArgon2id`/`IsArgon2idHash`) over OpenSSL's `EVP_KDF` `ARGON2ID`
@@ -407,7 +418,7 @@ upgrading the management/admin INI password from MD5.
407418
self-tests (`HashCreatorTester` Argon2id round-trip/negative/salt-uniqueness/cross-scheme checks +
408419
a `Crypt` `EnCrypt``GetHashType``Validate` dispatch check for Argon2id and PBKDF2), with the
409420
full auth regression (default PBKDF2 path) green.
410-
- Remaining B2: SCRAM-SHA-256-`PLUS` for POP3 (IMAP and SMTP delivered); a hash-policy engine (min accepted type,
421+
- Remaining B2: a hash-policy engine (min accepted type,
411422
phase out MD5/SHA256) and optional pepper building on the Argon2id work; OAuth2 XOAUTH2/OAUTHBEARER;
412423
POP3/IMAP UTF8 (RFC 6856 / UTF8=ACCEPT) and full SASLprep of non-ASCII credentials.
413424
- Verify: O365/Gmail XOAUTH2 + Thunderbird SCRAM interop.

0 commit comments

Comments
 (0)