Skip to content

Commit 7a47315

Browse files
committed
feat: support channel binding configuration in PgConnectOptions
Introduce support for configuring channel binding mechanisms during the SCRAM-SHA-256-PLUS authentication handshake. This allows users to enforce, prefer, or disable channel binding securely. - Add channelBinding property and getters/setters to PgConnectOptions - Update PgConnectionUriParser to parse 'channel_binding' from connection URIs - Store channel binding preference in Netty Channel attributes during initialization - Propagate the configuration to ScramAuthentication to negotiate -PLUS variants - Fail the connection handshake if a mismatch or unsupported negotiation occurs Signed-off-by: Jorge Solorzano <jorsol@gmail.com>
1 parent 5869727 commit 7a47315

7 files changed

Lines changed: 158 additions & 27 deletions

File tree

vertx-pg-client/pom.xml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@
5151
<dependency>
5252
<groupId>com.ongres.scram</groupId>
5353
<artifactId>scram-client</artifactId>
54-
<version>3.2</version>
54+
<version>3.4</version>
5555
</dependency>
5656

5757
<!-- Testing purposes -->
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
/*
2+
* Copyright (c) 2026 Contributors to the Eclipse Foundation
3+
*
4+
* This program and the accompanying materials are made available under the
5+
* terms of the Eclipse Public License 2.0 which is available at
6+
* http://www.eclipse.org/legal/epl-2.0, or the Apache License, Version 2.0
7+
* which is available at https://www.apache.org/licenses/LICENSE-2.0.
8+
*
9+
* SPDX-License-Identifier: EPL-2.0 OR Apache-2.0
10+
*/
11+
package io.vertx.pgclient;
12+
13+
import io.vertx.codegen.annotations.VertxGen;
14+
15+
/**
16+
* The different values for the Channel Binding parameter provide different levels of
17+
* protection. Channel binding is a method for the server to authenticate itself to the client.
18+
* It is only supported over SSL connections with PostgreSQL 11 or later servers using the
19+
* SCRAM authentication method.
20+
*
21+
* @see <a href=
22+
* "https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-CHANNEL-BINDING">
23+
* libpq channel_binding</a>
24+
*/
25+
@VertxGen
26+
public enum ChannelBinding {
27+
28+
/**
29+
* Prevents the use of channel binding
30+
*/
31+
DISABLE("disable"),
32+
33+
/**
34+
* Means that the client will choose channel binding if available.
35+
*/
36+
PREFER("prefer"),
37+
38+
/**
39+
* Means that the connection must employ channel binding.
40+
*/
41+
REQUIRE("require");
42+
43+
public static final ChannelBinding[] VALUES = ChannelBinding.values();
44+
45+
public final String value;
46+
47+
ChannelBinding(String value) {
48+
this.value = value;
49+
}
50+
51+
public static ChannelBinding of(String value) {
52+
for (ChannelBinding channelBinding : VALUES) {
53+
if (channelBinding.value.equalsIgnoreCase(value)) {
54+
return channelBinding;
55+
}
56+
}
57+
58+
throw new IllegalArgumentException("Could not find an appropriate Channel Binding mode for the value [" + value + "].");
59+
}
60+
}

vertx-pg-client/src/main/java/io/vertx/pgclient/PgConnectOptions.java

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,9 @@ public static PgConnectOptions fromEnv() {
9999
if (getenv("PGSSLNEGOTIATION") != null) {
100100
pgConnectOptions.setSslNegotiation(SslNegotiation.of(getenv("PGSSLNEGOTIATION")));
101101
}
102+
if (getenv(" PGCHANNELBINDING") != null) {
103+
pgConnectOptions.setChannelBinding(ChannelBinding.of(getenv(" PGCHANNELBINDING")));
104+
}
102105
return pgConnectOptions;
103106
}
104107

@@ -110,6 +113,7 @@ public static PgConnectOptions fromEnv() {
110113
public static final int DEFAULT_PIPELINING_LIMIT = 256;
111114
public static final SslMode DEFAULT_SSLMODE = SslMode.DISABLE;
112115
public static final SslNegotiation DEFAULT_SSL_NEGOTIATION = SslNegotiation.POSTGRES;
116+
public static final ChannelBinding DEFAULT_CHANNEL_BINDING = ChannelBinding.PREFER;
113117
public static final boolean DEFAULT_USE_LAYER_7_PROXY = false;
114118
public static final Map<String, String> DEFAULT_PROPERTIES;
115119

@@ -125,6 +129,7 @@ public static PgConnectOptions fromEnv() {
125129
private int pipeliningLimit = DEFAULT_PIPELINING_LIMIT;
126130
private SslMode sslMode = DEFAULT_SSLMODE;
127131
private SslNegotiation sslNegotiation = DEFAULT_SSL_NEGOTIATION;
132+
private ChannelBinding channelBinding = DEFAULT_CHANNEL_BINDING;
128133
private boolean useLayer7Proxy = DEFAULT_USE_LAYER_7_PROXY;
129134

130135
public PgConnectOptions() {
@@ -143,6 +148,7 @@ public PgConnectOptions(SqlConnectOptions other) {
143148
pipeliningLimit = opts.pipeliningLimit;
144149
sslMode = opts.sslMode;
145150
sslNegotiation = opts.sslNegotiation;
151+
channelBinding = opts.channelBinding;
146152
}
147153
}
148154

@@ -151,6 +157,7 @@ public PgConnectOptions(PgConnectOptions other) {
151157
pipeliningLimit = other.pipeliningLimit;
152158
sslMode = other.sslMode;
153159
sslNegotiation = other.sslNegotiation;
160+
channelBinding = other.channelBinding;
154161
}
155162

156163
@Override
@@ -258,6 +265,24 @@ public PgConnectOptions setSslNegotiation(SslNegotiation sslNegotiation) {
258265
return this;
259266
}
260267

268+
/**
269+
* @return the value of current Channel Binding mode
270+
*/
271+
public ChannelBinding getChannelBinding() {
272+
return channelBinding;
273+
}
274+
275+
/**
276+
* Set {@link ChannelBinding} for the client, this option controls the client's use of channel binding.
277+
*
278+
* @param channelBinding the channel binding mode
279+
* @return a reference to this, so the API can be used fluently
280+
*/
281+
public PgConnectOptions setChannelBinding(ChannelBinding channelBinding) {
282+
this.channelBinding = channelBinding;
283+
return this;
284+
}
285+
261286
/**
262287
* @return whether the client interacts with a layer 7 proxy instead of a server
263288
*/
@@ -342,6 +367,7 @@ public boolean equals(Object o) {
342367
if (pipeliningLimit != that.pipeliningLimit) return false;
343368
if (sslMode != that.sslMode) return false;
344369
if (sslNegotiation != that.sslNegotiation) return false;
370+
if (channelBinding != that.channelBinding) return false;
345371

346372
return true;
347373
}
@@ -352,6 +378,7 @@ public int hashCode() {
352378
result = 31 * result + pipeliningLimit;
353379
result = 31 * result + sslMode.hashCode();
354380
result = 31 * result + sslNegotiation.hashCode();
381+
result = 31 * result + channelBinding.hashCode();
355382
return result;
356383
}
357384

vertx-pg-client/src/main/java/io/vertx/pgclient/impl/PgConnectionUriParser.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
package io.vertx.pgclient.impl;
1212

1313
import io.vertx.core.json.JsonObject;
14+
import io.vertx.pgclient.ChannelBinding;
1415
import io.vertx.pgclient.SslMode;
1516
import io.vertx.pgclient.SslNegotiation;
1617

@@ -183,6 +184,9 @@ private static void parseParameters(String parametersInfo, JsonObject configurat
183184
case "sslnegotiation":
184185
configuration.put("sslNegotiation", SslNegotiation.of(value));
185186
break;
187+
case "channel_binding":
188+
configuration.put("channelBinding", ChannelBinding.of(value));
189+
break;
186190
default:
187191
properties.put(key, value);
188192
break;

vertx-pg-client/src/main/java/io/vertx/pgclient/impl/PgSocketConnection.java

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919

2020
import io.netty.channel.ChannelPipeline;
2121
import io.netty.handler.codec.DecoderException;
22+
import io.netty.util.AttributeKey;
2223
import io.vertx.core.*;
2324
import io.vertx.core.buffer.Buffer;
2425
import io.vertx.core.net.ClientSSLOptions;
@@ -81,6 +82,11 @@ protected PgConnectOptions connectOptions() {
8182
@Override
8283
public void init() {
8384
codec = new PgCodec(useLayer7Proxy);
85+
86+
socket.channelHandlerContext().channel()
87+
.attr(AttributeKey.valueOf("channel_binding"))
88+
.set(connectOptions.getChannelBinding());
89+
8490
ChannelPipeline pipeline = socket.channelHandlerContext().pipeline();
8591
pipeline.addBefore("handler", "codec", codec);
8692
super.init();

vertx-pg-client/src/main/java/io/vertx/pgclient/impl/auth/scram/ScramSessionImpl.java

Lines changed: 46 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -17,27 +17,33 @@
1717

1818
package io.vertx.pgclient.impl.auth.scram;
1919

20+
import java.nio.charset.StandardCharsets;
21+
import java.security.NoSuchAlgorithmException;
22+
import java.security.cert.Certificate;
23+
import java.security.cert.CertificateEncodingException;
24+
import java.security.cert.X509Certificate;
25+
import java.util.ArrayList;
26+
import java.util.List;
27+
28+
import javax.net.ssl.SSLException;
29+
import javax.net.ssl.SSLSession;
30+
31+
import com.ongres.scram.client.ChannelBindingPolicy;
2032
import com.ongres.scram.client.ScramClient;
2133
import com.ongres.scram.common.StringPreparation;
2234
import com.ongres.scram.common.exception.ScramInvalidServerSignatureException;
2335
import com.ongres.scram.common.exception.ScramParseException;
2436
import com.ongres.scram.common.exception.ScramServerErrorException;
2537
import com.ongres.scram.common.util.TlsServerEndpoint;
38+
2639
import io.netty.buffer.ByteBuf;
2740
import io.netty.channel.ChannelHandlerContext;
2841
import io.netty.handler.ssl.SslHandler;
42+
import io.netty.util.AttributeKey;
43+
import io.vertx.pgclient.ChannelBinding;
2944
import io.vertx.pgclient.impl.codec.ScramClientInitialMessage;
3045
import io.vertx.pgclient.impl.util.Util;
3146

32-
import javax.net.ssl.SSLException;
33-
import javax.net.ssl.SSLSession;
34-
import java.nio.charset.StandardCharsets;
35-
import java.security.cert.Certificate;
36-
import java.security.cert.CertificateEncodingException;
37-
import java.security.cert.X509Certificate;
38-
import java.util.ArrayList;
39-
import java.util.List;
40-
4147
public class ScramSessionImpl implements ScramSession {
4248

4349
private final String username;
@@ -51,9 +57,10 @@ public ScramSessionImpl(String username, char[] password) {
5157

5258
/*
5359
* The client selects one of the supported mechanisms from the list,
54-
* and sends a SASLInitialResponse message to the server.
60+
* and sends a SASLInitialResponse message to the server.
61+
*
5562
* The message includes the name of the selected mechanism, and
56-
* an optional Initial Client Response, if the selected mechanism uses that.
63+
* an optional Initial Client Response, if the selected mechanism uses that.
5764
*/
5865
public ScramClientInitialMessage createInitialSaslMessage(ByteBuf in, ChannelHandlerContext ctx) {
5966
List<String> mechanisms = new ArrayList<>();
@@ -63,17 +70,13 @@ public ScramClientInitialMessage createInitialSaslMessage(ByteBuf in, ChannelHan
6370
mechanisms.add(mechanism);
6471
}
6572

66-
if (mechanisms.isEmpty()) {
67-
throw new UnsupportedOperationException("SASL Authentication : the server returned no mechanism");
68-
}
69-
70-
byte[] channelBindingData = extractChannelBindingData(ctx);
7173
this.scramClient = ScramClient.builder()
7274
.advertisedMechanisms(mechanisms)
7375
.username(username) // ignored by the server, use startup message
7476
.password(password)
7577
.stringPreparation(StringPreparation.POSTGRESQL_PREPARATION)
76-
.channelBinding(TlsServerEndpoint.TLS_SERVER_END_POINT, channelBindingData)
78+
.channelBindingPolicy(getChannelBindingPolicy(ctx))
79+
.channelBinding(TlsServerEndpoint.TLS_SERVER_END_POINT, extractChannelBindingData(ctx))
7780
.build();
7881

7982
return new ScramClientInitialMessage(scramClient.clientFirstMessage().toString(),
@@ -83,10 +86,10 @@ public ScramClientInitialMessage createInitialSaslMessage(ByteBuf in, ChannelHan
8386
/*
8487
* One or more server-challenge and client-response message will follow.
8588
* Each server-challenge is sent in an AuthenticationSASLContinue message,
86-
* followed by a response from client in an SASLResponse message.
89+
* followed by a response from client in an SASLResponse message.
8790
* The particulars of the messages are mechanism specific.
8891
*/
89-
public String receiveServerFirstMessage(ByteBuf in) {
92+
public String receiveServerFirstMessage(ByteBuf in) {
9093
String serverFirstMessage = in.readCharSequence(in.readableBytes(), StandardCharsets.UTF_8).toString();
9194

9295
try {
@@ -100,11 +103,11 @@ public String receiveServerFirstMessage(ByteBuf in) {
100103

101104
/*
102105
* Finally, when the authentication exchange is completed successfully,
103-
* the server sends an AuthenticationSASLFinal message, followed immediately by an AuthenticationOk message.
106+
* the server sends an AuthenticationSASLFinal message, followed immediately by an AuthenticationOk message.
104107
* The AuthenticationSASLFinal contains additional server-to-client data,
105-
* whose content is particular to the selected authentication mechanism.
108+
* whose content is particular to the selected authentication mechanism.
106109
* If the authentication mechanism doesn't use additional data that's sent at completion,
107-
* the AuthenticationSASLFinal message is not sent
110+
* the AuthenticationSASLFinal message is not sent
108111
*/
109112
public void checkServerFinalMessage(ByteBuf in) {
110113
String serverFinalMessage = in.readCharSequence(in.readableBytes(), StandardCharsets.UTF_8).toString();
@@ -128,15 +131,32 @@ private byte[] extractChannelBindingData(ChannelHandlerContext ctx) {
128131
Certificate peerCert = certificates[0]; // First certificate is the peer's certificate
129132
if (peerCert instanceof X509Certificate) {
130133
X509Certificate cert = (X509Certificate) peerCert;
131-
return TlsServerEndpoint.getChannelBindingData(cert);
134+
return TlsServerEndpoint.getChannelBindingHash(cert);
132135
}
133136
}
134-
} catch (CertificateEncodingException | SSLException e) {
135-
// Cannot extract X509Certificate from SSL session
136-
// handle as no channel binding available
137+
} catch (CertificateEncodingException | SSLException | NoSuchAlgorithmException e) {
138+
if (getChannelBindingPolicy(ctx) == ChannelBindingPolicy.REQUIRE) {
139+
throw new UnsupportedOperationException(e);
140+
}
137141
}
138142
}
139143
}
140144
return new byte[0]; // handle as no channel binding available
141145
}
146+
147+
private static ChannelBindingPolicy getChannelBindingPolicy(ChannelHandlerContext ctx) {
148+
ChannelBinding channelBinding = (ChannelBinding) ctx.channel()
149+
.attr(AttributeKey.valueOf("channel_binding"))
150+
.get();
151+
switch (channelBinding) {
152+
case DISABLE:
153+
return ChannelBindingPolicy.DISABLE;
154+
case PREFER:
155+
return ChannelBindingPolicy.ALLOW;
156+
case REQUIRE:
157+
return ChannelBindingPolicy.REQUIRE;
158+
default:
159+
throw new IllegalArgumentException("Invalid channel binding value");
160+
}
161+
}
142162
}

vertx-pg-client/src/test/java/io/vertx/tests/pgclient/PgConnectOptionsTest.java

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
package io.vertx.tests.pgclient;
1212

1313
import io.vertx.core.json.JsonObject;
14+
import io.vertx.pgclient.ChannelBinding;
1415
import io.vertx.pgclient.PgConnectOptions;
1516
import io.vertx.pgclient.SslMode;
1617
import io.vertx.pgclient.SslNegotiation;
@@ -167,6 +168,19 @@ public void testValidUriSslNegotiationDirect() {
167168
assertEquals(expectedConfiguration, actualConfiguration);
168169
}
169170

171+
@Test
172+
public void testValidUriChannelBindingRequire() {
173+
connectionUri = "postgresql://user@myhost?channel_binding=require";
174+
actualConfiguration = PgConnectOptions.fromUri(connectionUri);
175+
176+
expectedConfiguration = new PgConnectOptions()
177+
.setHost("myhost")
178+
.setUser("user")
179+
.setChannelBinding(ChannelBinding.REQUIRE);
180+
181+
assertEquals(expectedConfiguration, actualConfiguration);
182+
}
183+
170184
@Test
171185
public void testValidUri11() {
172186
connectionUri = "postgresql://user@myhost?application_name=myapp";

0 commit comments

Comments
 (0)