From 9de74b90729621fb11ca3fae027dba932d1f1b0b Mon Sep 17 00:00:00 2001 From: sunnysingh85 Date: Tue, 14 Jan 2025 09:05:24 -0800 Subject: [PATCH] TLS PSK implementation (#1777) * TLS PSK implementation * Breaking apart the PSK creation to an interface * Added license * Store PSK info in handshake info * Addressed review comments * Added license * 1) moving read of byte buff and release to helper method in TLSPSKHandler 2) adding comment in Http2OrHttpHandler and use readBytes instead of readSlice 3) adding SslCloseCompletionEvent on close_notify alert 4) handling null value TLS_HANDSHAKE_USING_EXTERNAL_PSK * adding license header * adding back old SslHandshakeInfo constructor for backward compatibility * Update build.gradle --------- Co-authored-by: deeptiv1991 --- build.gradle | 4 +- zuul-core/build.gradle | 6 + .../netty/common/ssl/SslHandshakeInfo.java | 33 +++ .../server/http2/Http2OrHttpHandler.java | 54 +++- .../server/psk/ClientPSKIdentityInfo.java | 19 ++ .../server/psk/ExternalTlsPskProvider.java | 22 ++ .../psk/PskCreationFailureException.java | 46 ++++ .../zuul/netty/server/psk/TlsPskDecoder.java | 67 +++++ .../zuul/netty/server/psk/TlsPskHandler.java | 106 ++++++++ .../server/psk/TlsPskServerProtocol.java | 133 ++++++++++ .../zuul/netty/server/psk/TlsPskUtils.java | 35 +++ .../zuul/netty/server/psk/ZuulPskServer.java | 242 ++++++++++++++++++ .../server/ssl/SslHandshakeInfoHandler.java | 49 +++- 13 files changed, 806 insertions(+), 10 deletions(-) create mode 100644 zuul-core/src/main/java/com/netflix/zuul/netty/server/psk/ClientPSKIdentityInfo.java create mode 100644 zuul-core/src/main/java/com/netflix/zuul/netty/server/psk/ExternalTlsPskProvider.java create mode 100644 zuul-core/src/main/java/com/netflix/zuul/netty/server/psk/PskCreationFailureException.java create mode 100644 zuul-core/src/main/java/com/netflix/zuul/netty/server/psk/TlsPskDecoder.java create mode 100644 zuul-core/src/main/java/com/netflix/zuul/netty/server/psk/TlsPskHandler.java create mode 100644 zuul-core/src/main/java/com/netflix/zuul/netty/server/psk/TlsPskServerProtocol.java create mode 100644 zuul-core/src/main/java/com/netflix/zuul/netty/server/psk/TlsPskUtils.java create mode 100644 zuul-core/src/main/java/com/netflix/zuul/netty/server/psk/ZuulPskServer.java diff --git a/build.gradle b/build.gradle index a5b6f2fd22..d833b19ae0 100644 --- a/build.gradle +++ b/build.gradle @@ -240,9 +240,11 @@ subprojects { jupiterEngine: 'org.junit.jupiter:junit-jupiter-engine:5.+', jupiterMockito: 'org.mockito:mockito-junit-jupiter:5.+', mockito: 'org.mockito:mockito-core:5.+', + slf4j: "org.slf4j:slf4j-api:2.0.16", truth: 'com.google.truth:truth:1.4.4', - awaitility: 'org.awaitility:awaitility:4.2.2' + awaitility: 'org.awaitility:awaitility:4.2.2', + lombok: 'org.projectlombok:lombok:1.18.30' ] } diff --git a/zuul-core/build.gradle b/zuul-core/build.gradle index 466a8d72fd..996b6b4d64 100644 --- a/zuul-core/build.gradle +++ b/zuul-core/build.gradle @@ -3,11 +3,17 @@ apply plugin: "java-library" dependencies { + compileOnly libraries.lombok + annotationProcessor(libraries.lombok) + implementation libraries.guava // TODO(carl-mastrangelo): this can be implementation; remove Logger from public api points. api libraries.slf4j + implementation 'org.bouncycastle:bcprov-jdk18on:1.78.1' implementation 'org.bouncycastle:bcpkix-jdk18on:1.78.1' + implementation 'org.bouncycastle:bctls-jdk18on:1.78.1' + implementation 'com.fasterxml.jackson.core:jackson-core:2.16.1' api 'com.fasterxml.jackson.core:jackson-databind:2.16.1' diff --git a/zuul-core/src/main/java/com/netflix/netty/common/ssl/SslHandshakeInfo.java b/zuul-core/src/main/java/com/netflix/netty/common/ssl/SslHandshakeInfo.java index 16cfd2342e..5a6882c7a8 100644 --- a/zuul-core/src/main/java/com/netflix/netty/common/ssl/SslHandshakeInfo.java +++ b/zuul-core/src/main/java/com/netflix/netty/common/ssl/SslHandshakeInfo.java @@ -16,6 +16,7 @@ package com.netflix.netty.common.ssl; +import com.netflix.zuul.netty.server.psk.ClientPSKIdentityInfo; import io.netty.handler.ssl.ClientAuth; import java.security.cert.Certificate; import java.security.cert.X509Certificate; @@ -31,7 +32,10 @@ public class SslHandshakeInfo { private final Certificate serverCertificate; private final X509Certificate clientCertificate; private final boolean isOfIntermediary; + private final boolean usingExternalPSK; + private final ClientPSKIdentityInfo clientPSKIdentityInfo; + //for backward compatibility public SslHandshakeInfo( boolean isOfIntermediary, String protocol, @@ -45,6 +49,27 @@ public SslHandshakeInfo( this.serverCertificate = serverCertificate; this.clientCertificate = clientCertificate; this.isOfIntermediary = isOfIntermediary; + this.usingExternalPSK = false; + this.clientPSKIdentityInfo = null; + } + + public SslHandshakeInfo( + boolean isOfIntermediary, + String protocol, + String cipherSuite, + ClientAuth clientAuthRequirement, + Certificate serverCertificate, + X509Certificate clientCertificate, + boolean usingExternalPSK, + ClientPSKIdentityInfo clientPSKIdentityInfo) { + this.protocol = protocol; + this.cipherSuite = cipherSuite; + this.clientAuthRequirement = clientAuthRequirement; + this.serverCertificate = serverCertificate; + this.clientCertificate = clientCertificate; + this.isOfIntermediary = isOfIntermediary; + this.usingExternalPSK = usingExternalPSK; + this.clientPSKIdentityInfo = clientPSKIdentityInfo; } public boolean isOfIntermediary() { @@ -71,6 +96,14 @@ public X509Certificate getClientCertificate() { return clientCertificate; } + public boolean usingExternalPSK() { + return usingExternalPSK; + } + + public ClientPSKIdentityInfo geClientPSKIdentityInfo() { + return clientPSKIdentityInfo; + } + @Override public String toString() { return "SslHandshakeInfo{" + "protocol='" diff --git a/zuul-core/src/main/java/com/netflix/zuul/netty/server/http2/Http2OrHttpHandler.java b/zuul-core/src/main/java/com/netflix/zuul/netty/server/http2/Http2OrHttpHandler.java index 1e4ebe5d33..c0131bddad 100644 --- a/zuul-core/src/main/java/com/netflix/zuul/netty/server/http2/Http2OrHttpHandler.java +++ b/zuul-core/src/main/java/com/netflix/zuul/netty/server/http2/Http2OrHttpHandler.java @@ -20,6 +20,7 @@ import com.netflix.netty.common.channel.config.CommonChannelConfigKeys; import com.netflix.netty.common.http2.DynamicHttp2FrameLogger; import com.netflix.zuul.netty.server.BaseZuulChannelInitializer; +import com.netflix.zuul.netty.server.psk.TlsPskHandler; import io.netty.channel.ChannelHandler; import io.netty.channel.ChannelHandlerContext; import io.netty.channel.ChannelPipeline; @@ -33,12 +34,13 @@ import io.netty.handler.logging.LogLevel; import io.netty.handler.ssl.ApplicationProtocolNames; import io.netty.handler.ssl.ApplicationProtocolNegotiationHandler; +import io.netty.handler.ssl.SslHandshakeCompletionEvent; import io.netty.util.AttributeKey; import java.util.function.Consumer; /** * Http2 Or Http Handler - * + *

* Author: Arthur Gonigberg * Date: December 15, 2017 */ @@ -47,6 +49,8 @@ public class Http2OrHttpHandler extends ApplicationProtocolNegotiationHandler { public static final String PROTOCOL_HTTP_1_1 = "HTTP/1.1"; public static final String PROTOCOL_HTTP_2 = "HTTP/2"; + private static final String FALLBACK_APPLICATION_PROTOCOL = ApplicationProtocolNames.HTTP_1_1; + private static final DynamicHttp2FrameLogger FRAME_LOGGER = new DynamicHttp2FrameLogger(LogLevel.DEBUG, Http2FrameCodec.class); @@ -62,7 +66,7 @@ public Http2OrHttpHandler( ChannelHandler http2StreamHandler, ChannelConfig channelConfig, Consumer addHttpHandlerFn) { - super(ApplicationProtocolNames.HTTP_1_1); + super(FALLBACK_APPLICATION_PROTOCOL); this.http2StreamHandler = http2StreamHandler; this.maxConcurrentStreams = channelConfig.get(CommonChannelConfigKeys.maxConcurrentStreams); this.initialWindowSize = channelConfig.get(CommonChannelConfigKeys.initialWindowSize); @@ -72,6 +76,45 @@ public Http2OrHttpHandler( this.addHttpHandlerFn = addHttpHandlerFn; } + /** + * this method is inspired by ApplicationProtocolNegotiationHandler.userEventTriggered + */ + @Override + public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception { + if (evt instanceof SslHandshakeCompletionEvent handshakeEvent) { + if (handshakeEvent.isSuccess()) { + TlsPskHandler tlsPskHandler = ctx.channel().pipeline().get(TlsPskHandler.class); + if (tlsPskHandler != null) { + // PSK mode + try { + String tlsPskApplicationProtocol = tlsPskHandler.getApplicationProtocol(); + configurePipeline( + ctx, + tlsPskApplicationProtocol != null + ? tlsPskApplicationProtocol + : FALLBACK_APPLICATION_PROTOCOL); + } catch (Throwable cause) { + exceptionCaught(ctx, cause); + } finally { + // Handshake failures are handled in exceptionCaught(...). + if (handshakeEvent.isSuccess()) { + removeSelfIfPresent(ctx); + } + } + } else { + // non PSK mode + super.userEventTriggered(ctx, evt); + } + } else { + // handshake failures + // TODO sunnys - handle PSK handshake failures + super.userEventTriggered(ctx, evt); + } + } else { + super.userEventTriggered(ctx, evt); + } + } + @Override protected void configurePipeline(ChannelHandlerContext ctx, String protocol) throws Exception { if (ApplicationProtocolNames.HTTP_2.equals(protocol)) { @@ -120,4 +163,11 @@ private void configureHttp2(ChannelPipeline pipeline) { private void configureHttp1(ChannelPipeline pipeline) { addHttpHandlerFn.accept(pipeline); } + + private void removeSelfIfPresent(ChannelHandlerContext ctx) { + ChannelPipeline pipeline = ctx.pipeline(); + if (!ctx.isRemoved()) { + pipeline.remove(this); + } + } } diff --git a/zuul-core/src/main/java/com/netflix/zuul/netty/server/psk/ClientPSKIdentityInfo.java b/zuul-core/src/main/java/com/netflix/zuul/netty/server/psk/ClientPSKIdentityInfo.java new file mode 100644 index 0000000000..566a21f5d3 --- /dev/null +++ b/zuul-core/src/main/java/com/netflix/zuul/netty/server/psk/ClientPSKIdentityInfo.java @@ -0,0 +1,19 @@ +/* + * Copyright 2024 Netflix, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.netflix.zuul.netty.server.psk; + +public record ClientPSKIdentityInfo(byte[] clientPSKIdentity) { +} diff --git a/zuul-core/src/main/java/com/netflix/zuul/netty/server/psk/ExternalTlsPskProvider.java b/zuul-core/src/main/java/com/netflix/zuul/netty/server/psk/ExternalTlsPskProvider.java new file mode 100644 index 0000000000..03aba1265f --- /dev/null +++ b/zuul-core/src/main/java/com/netflix/zuul/netty/server/psk/ExternalTlsPskProvider.java @@ -0,0 +1,22 @@ +/* + * Copyright 2024 Netflix, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.netflix.zuul.netty.server.psk; + + +public interface ExternalTlsPskProvider { + byte[] provide(byte[] clientPskIdentity, byte[] clientRandom) throws PskCreationFailureException; +} diff --git a/zuul-core/src/main/java/com/netflix/zuul/netty/server/psk/PskCreationFailureException.java b/zuul-core/src/main/java/com/netflix/zuul/netty/server/psk/PskCreationFailureException.java new file mode 100644 index 0000000000..08b1b2966b --- /dev/null +++ b/zuul-core/src/main/java/com/netflix/zuul/netty/server/psk/PskCreationFailureException.java @@ -0,0 +1,46 @@ +/* + * Copyright 2024 Netflix, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.netflix.zuul.netty.server.psk; + +public class PskCreationFailureException extends Exception { + + public enum TlsAlertMessage { + /** + * The server does not recognize the (client) PSK identity + */ + unknown_psk_identity, + /** + * The (client) PSK identity existed but the key was incorrect + */ + decrypt_error, + } + + private final TlsAlertMessage tlsAlertMessage; + + public PskCreationFailureException(TlsAlertMessage tlsAlertMessage, String message) { + super(message); + this.tlsAlertMessage = tlsAlertMessage; + } + + public PskCreationFailureException(TlsAlertMessage tlsAlertMessage, String message, Throwable cause) { + super(message, cause); + this.tlsAlertMessage = tlsAlertMessage; + } + + public TlsAlertMessage getTlsAlertMessage() { + return tlsAlertMessage; + } +} diff --git a/zuul-core/src/main/java/com/netflix/zuul/netty/server/psk/TlsPskDecoder.java b/zuul-core/src/main/java/com/netflix/zuul/netty/server/psk/TlsPskDecoder.java new file mode 100644 index 0000000000..b7be25a51c --- /dev/null +++ b/zuul-core/src/main/java/com/netflix/zuul/netty/server/psk/TlsPskDecoder.java @@ -0,0 +1,67 @@ +/* + * Copyright 2024 Netflix, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.netflix.zuul.netty.server.psk; + +import io.netty.buffer.ByteBuf; +import io.netty.buffer.Unpooled; +import io.netty.channel.ChannelFutureListener; +import io.netty.channel.ChannelHandlerContext; +import io.netty.handler.codec.ByteToMessageDecoder; +import io.netty.handler.ssl.SslHandshakeCompletionEvent; +import org.bouncycastle.tls.TlsFatalAlert; + +import java.util.List; + +public class TlsPskDecoder extends ByteToMessageDecoder { + + private final TlsPskServerProtocol tlsPskServerProtocol; + + public TlsPskDecoder(TlsPskServerProtocol tlsPskServerProtocol) { + this.tlsPskServerProtocol = tlsPskServerProtocol; + } + + @Override + protected void decode(ChannelHandlerContext ctx, ByteBuf in, List out) throws Exception { + final byte[] bytesRead = in.hasArray() ? in.array() : TlsPskUtils.readDirect(in); + try { + tlsPskServerProtocol.offerInput(bytesRead); + } catch (TlsFatalAlert tlsFatalAlert) { + writeOutputIfAvailable(ctx); + ctx.fireUserEventTriggered(new SslHandshakeCompletionEvent(tlsFatalAlert)); + ctx.close(); + return; + } + writeOutputIfAvailable(ctx); + final int appDataAvailable = tlsPskServerProtocol.getAvailableInputBytes(); + if (appDataAvailable > 0) { + byte[] appData = new byte[appDataAvailable]; + tlsPskServerProtocol.readInput(appData, 0, appDataAvailable); + out.add(Unpooled.wrappedBuffer(appData)); + } + } + + private void writeOutputIfAvailable(ChannelHandlerContext ctx) { + final int availableOutputBytes = tlsPskServerProtocol.getAvailableOutputBytes(); + // output is available immediately (handshake not complete), pipe that back to the client right away + if (availableOutputBytes != 0) { + byte[] outputBytes = new byte[availableOutputBytes]; + tlsPskServerProtocol.readOutput(outputBytes, 0, availableOutputBytes); + ctx.writeAndFlush(Unpooled.wrappedBuffer(outputBytes)) + .addListener(ChannelFutureListener.FIRE_EXCEPTION_ON_FAILURE); + } + } +} diff --git a/zuul-core/src/main/java/com/netflix/zuul/netty/server/psk/TlsPskHandler.java b/zuul-core/src/main/java/com/netflix/zuul/netty/server/psk/TlsPskHandler.java new file mode 100644 index 0000000000..95083fb209 --- /dev/null +++ b/zuul-core/src/main/java/com/netflix/zuul/netty/server/psk/TlsPskHandler.java @@ -0,0 +1,106 @@ +/* + * Copyright 2024 Netflix, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.netflix.zuul.netty.server.psk; + +import com.netflix.spectator.api.Registry; +import io.netty.buffer.ByteBuf; +import io.netty.buffer.Unpooled; +import io.netty.channel.ChannelDuplexHandler; +import io.netty.channel.ChannelFutureListener; +import io.netty.channel.ChannelHandlerContext; +import io.netty.channel.ChannelPromise; +import io.netty.util.AttributeKey; +import io.netty.util.ReferenceCountUtil; +import java.security.SecureRandom; +import java.util.Map; +import java.util.Set; +import javax.net.ssl.SSLSession; +import org.bouncycastle.tls.CipherSuite; +import org.bouncycastle.tls.ProtocolName; +import org.bouncycastle.tls.crypto.impl.jcajce.JcaTlsCryptoProvider; + +public class TlsPskHandler extends ChannelDuplexHandler { + + public static final Map SUPPORTED_TLS_PSK_CIPHER_SUITE_MAP = Map.of( + CipherSuite.TLS_AES_128_GCM_SHA256, + "TLS_AES_128_GCM_SHA256", + CipherSuite.TLS_AES_256_GCM_SHA384, + "TLS_AES_256_GCM_SHA384"); + public static final AttributeKey CLIENT_PSK_IDENTITY_ATTRIBUTE_KEY = + AttributeKey.newInstance("_client_psk_identity_info"); + public static final SecureRandom secureRandom = new SecureRandom(); + + private final Registry registry; + private final ExternalTlsPskProvider externalTlsPskProvider; + private final Set supportedApplicationProtocols; + private final TlsPskServerProtocol tlsPskServerProtocol; + + private ZuulPskServer tlsPskServer; + + public TlsPskHandler(Registry registry, ExternalTlsPskProvider externalTlsPskProvider, Set supportedApplicationProtocols) { + super(); + this.registry = registry; + this.externalTlsPskProvider = externalTlsPskProvider; + this.supportedApplicationProtocols = supportedApplicationProtocols; + this.tlsPskServerProtocol = new TlsPskServerProtocol(); + } + + @Override + public void write(ChannelHandlerContext ctx, Object msg, ChannelPromise promise) throws Exception { + if (!(msg instanceof ByteBuf byteBufMsg)) { + ReferenceCountUtil.safeRelease(msg); + promise.setFailure(new IllegalStateException("Failed to write message on the channel. Message is not a ByteBuf")); + return; + } + byte[] appDataBytes = TlsPskUtils.getAppDataBytesAndRelease(byteBufMsg); + tlsPskServerProtocol.writeApplicationData(appDataBytes, 0, appDataBytes.length); + int availableOutputBytes = tlsPskServerProtocol.getAvailableOutputBytes(); + if (availableOutputBytes != 0) { + byte[] outputBytes = new byte[availableOutputBytes]; + tlsPskServerProtocol.readOutput(outputBytes, 0, availableOutputBytes); + ctx.writeAndFlush(Unpooled.wrappedBuffer(outputBytes), promise) + .addListener(ChannelFutureListener.FIRE_EXCEPTION_ON_FAILURE); + } + } + + @Override + public void handlerAdded(ChannelHandlerContext ctx) { + ctx.pipeline().addBefore(ctx.name(), "tls_psk_handler", new TlsPskDecoder(tlsPskServerProtocol)); + } + + @Override + public void channelRegistered(ChannelHandlerContext ctx) throws Exception { + tlsPskServer = + new ZuulPskServer(new JcaTlsCryptoProvider().create(secureRandom), registry, externalTlsPskProvider, ctx, supportedApplicationProtocols); + tlsPskServerProtocol.accept(tlsPskServer); + super.channelRegistered(ctx); + } + + /** + * Returns the name of the current application-level protocol. + * Returns: + * the protocol name or null if application-level protocol has not been negotiated + */ + public String getApplicationProtocol() { + return tlsPskServer != null ? tlsPskServer.getApplicationProtocol() : null; + } + + public SSLSession getSession() { + return tlsPskServerProtocol != null ? tlsPskServerProtocol.getSSLSession() : null; + } + +} \ No newline at end of file diff --git a/zuul-core/src/main/java/com/netflix/zuul/netty/server/psk/TlsPskServerProtocol.java b/zuul-core/src/main/java/com/netflix/zuul/netty/server/psk/TlsPskServerProtocol.java new file mode 100644 index 0000000000..3f5af46132 --- /dev/null +++ b/zuul-core/src/main/java/com/netflix/zuul/netty/server/psk/TlsPskServerProtocol.java @@ -0,0 +1,133 @@ +/* + * Copyright 2024 Netflix, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.netflix.zuul.netty.server.psk; + +import org.bouncycastle.tls.TlsServerProtocol; + +import javax.net.ssl.SSLPeerUnverifiedException; +import javax.net.ssl.SSLSession; +import javax.net.ssl.SSLSessionContext; +import javax.security.cert.X509Certificate; +import java.security.Principal; +import java.security.cert.Certificate; + +public class TlsPskServerProtocol extends TlsServerProtocol { + + public SSLSession getSSLSession() { + return new SSLSession() { + @Override + public byte[] getId() { + return tlsSession.getSessionID(); + } + + @Override + public SSLSessionContext getSessionContext() { + return null; + } + + @Override + public long getCreationTime() { + return 0; + } + + @Override + public long getLastAccessedTime() { + return 0; + } + + @Override + public void invalidate() {} + + @Override + public boolean isValid() { + return !isClosed(); + } + + @Override + public void putValue(String name, Object value) {} + + @Override + public Object getValue(String name) { + return null; + } + + @Override + public void removeValue(String name) {} + + @Override + public String[] getValueNames() { + return new String[0]; + } + + @Override + public Certificate[] getPeerCertificates() throws SSLPeerUnverifiedException { + return new Certificate[0]; + } + + @Override + public Certificate[] getLocalCertificates() { + return new Certificate[0]; + } + + @Override + public X509Certificate[] getPeerCertificateChain() throws SSLPeerUnverifiedException { + return new X509Certificate[0]; + } + + @Override + public Principal getPeerPrincipal() throws SSLPeerUnverifiedException { + return null; + } + + @Override + public Principal getLocalPrincipal() { + return null; + } + + @Override + public String getCipherSuite() { + return TlsPskHandler.SUPPORTED_TLS_PSK_CIPHER_SUITE_MAP.get( + getContext().getSecurityParameters().getCipherSuite()); + } + + @Override + public String getProtocol() { + return getContext().getServerVersion().getName(); + } + + @Override + public String getPeerHost() { + return null; + } + + @Override + public int getPeerPort() { + return 0; + } + + @Override + public int getPacketBufferSize() { + return 0; + } + + @Override + public int getApplicationBufferSize() { + return 0; + } + }; + } +} diff --git a/zuul-core/src/main/java/com/netflix/zuul/netty/server/psk/TlsPskUtils.java b/zuul-core/src/main/java/com/netflix/zuul/netty/server/psk/TlsPskUtils.java new file mode 100644 index 0000000000..455bbaa4a8 --- /dev/null +++ b/zuul-core/src/main/java/com/netflix/zuul/netty/server/psk/TlsPskUtils.java @@ -0,0 +1,35 @@ +/* + * Copyright 2024 Netflix, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.netflix.zuul.netty.server.psk; + +import io.netty.buffer.ByteBuf; +import io.netty.util.ReferenceCountUtil; + +class TlsPskUtils { + protected static byte[] readDirect(ByteBuf byteBufMsg) { + int length = byteBufMsg.readableBytes(); + byte[] dest = new byte[length]; + byteBufMsg.readBytes(dest); + return dest; + } + + protected static byte[] getAppDataBytesAndRelease(ByteBuf byteBufMsg) { + byte[] appDataBytes = byteBufMsg.hasArray() ? byteBufMsg.array() : TlsPskUtils.readDirect(byteBufMsg); + ReferenceCountUtil.safeRelease(byteBufMsg); + return appDataBytes; + } +} diff --git a/zuul-core/src/main/java/com/netflix/zuul/netty/server/psk/ZuulPskServer.java b/zuul-core/src/main/java/com/netflix/zuul/netty/server/psk/ZuulPskServer.java new file mode 100644 index 0000000000..9add76dfa9 --- /dev/null +++ b/zuul-core/src/main/java/com/netflix/zuul/netty/server/psk/ZuulPskServer.java @@ -0,0 +1,242 @@ +/* + * Copyright 2024 Netflix, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.netflix.zuul.netty.server.psk; + +import com.netflix.spectator.api.Registry; +import com.netflix.spectator.api.Timer; +import io.netty.channel.ChannelHandlerContext; +import io.netty.handler.ssl.SslCloseCompletionEvent; +import io.netty.handler.ssl.SslHandshakeCompletionEvent; +import io.netty.util.AttributeKey; +import java.io.IOException; +import java.util.Hashtable; +import java.util.Set; +import java.util.Vector; +import java.util.concurrent.TimeUnit; +import java.util.function.Consumer; +import lombok.SneakyThrows; +import org.bouncycastle.tls.AbstractTlsServer; +import org.bouncycastle.tls.AlertDescription; +import org.bouncycastle.tls.AlertLevel; +import org.bouncycastle.tls.BasicTlsPSKExternal; +import org.bouncycastle.tls.CipherSuite; +import org.bouncycastle.tls.PRFAlgorithm; +import org.bouncycastle.tls.ProtocolName; +import org.bouncycastle.tls.ProtocolVersion; +import org.bouncycastle.tls.PskIdentity; +import org.bouncycastle.tls.TlsCredentials; +import org.bouncycastle.tls.TlsFatalAlert; +import org.bouncycastle.tls.TlsPSKExternal; +import org.bouncycastle.tls.TlsUtils; +import org.bouncycastle.tls.crypto.TlsCrypto; +import org.bouncycastle.tls.crypto.TlsSecret; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class ZuulPskServer extends AbstractTlsServer { + + private static final Logger LOGGER = LoggerFactory.getLogger(ZuulPskServer.class); + + public static final AttributeKey TLS_HANDSHAKE_USING_EXTERNAL_PSK = + AttributeKey.newInstance("_tls_handshake_using_external_psk"); + + private static class PSKTimings { + private final Timer handshakeCompleteTimer; + + private Long handshakeStartTime; + + PSKTimings(Registry registry) { + handshakeCompleteTimer = registry.timer("zuul.psk.handshake.complete.time"); + } + + public void recordHandshakeStarting() { + handshakeStartTime = System.nanoTime(); + } + + public void recordHandshakeComplete() { + handshakeCompleteTimer.record(System.nanoTime() - handshakeStartTime, TimeUnit.NANOSECONDS); + } + } + + private final PSKTimings pskTimings; + + private final ExternalTlsPskProvider externalTlsPskProvider; + + private final ChannelHandlerContext ctx; + + private final Set supportedApplicationProtocols; + + public ZuulPskServer( + TlsCrypto crypto, + Registry registry, + ExternalTlsPskProvider externalTlsPskProvider, + ChannelHandlerContext ctx, + Set supportedApplicationProtocols) { + super(crypto); + this.pskTimings = new PSKTimings(registry); + this.externalTlsPskProvider = externalTlsPskProvider; + this.ctx = ctx; + this.supportedApplicationProtocols = supportedApplicationProtocols; + } + + @Override + public TlsCredentials getCredentials() { + return null; + } + + @Override + protected Vector getProtocolNames() { + Vector protocolNames = new Vector(); + if (supportedApplicationProtocols != null) { + supportedApplicationProtocols.forEach(protocolNames::addElement); + } + return protocolNames; + } + + @Override + public void notifyHandshakeBeginning() throws IOException { + pskTimings.recordHandshakeStarting(); + this.ctx.channel().attr(TLS_HANDSHAKE_USING_EXTERNAL_PSK).set(false); + // TODO: sunnys - handshake timeouts + super.notifyHandshakeBeginning(); + } + + @Override + public void notifyHandshakeComplete() throws IOException { + pskTimings.recordHandshakeComplete(); + this.ctx.channel().attr(TLS_HANDSHAKE_USING_EXTERNAL_PSK).set(true); + super.notifyHandshakeComplete(); + ctx.fireUserEventTriggered(SslHandshakeCompletionEvent.SUCCESS); + } + + @Override + protected ProtocolVersion[] getSupportedVersions() { + return ProtocolVersion.TLSv13.only(); + } + + @Override + protected int[] getSupportedCipherSuites() { + return TlsUtils.getSupportedCipherSuites( + getCrypto(), + TlsPskHandler.SUPPORTED_TLS_PSK_CIPHER_SUITE_MAP.keySet().stream() + .mapToInt(Number::intValue) + .toArray()); + } + + @Override + public ProtocolVersion getServerVersion() throws IOException { + return super.getServerVersion(); + } + + /** + * TODO: Ask BC folks to see if getExternalPSK can throw a checked exception + * https://github.com/bcgit/bc-java/issues/1673 + * We are using SneakyThrows here because getExternalPSK is an override and we cant have throws in the method signature + * and we dont want to catch and wrap in RuntimeException. + * SneakyThrows allows up to compile and it will throw the exception at runtime. + */ + @Override + @SneakyThrows + public TlsPSKExternal getExternalPSK(Vector clientPskIdentities) { + byte[] clientPskIdentity = ((PskIdentity) clientPskIdentities.get(0)).getIdentity(); + byte[] psk; + try { + this.ctx.channel().attr(TlsPskHandler.CLIENT_PSK_IDENTITY_ATTRIBUTE_KEY).set(new ClientPSKIdentityInfo(clientPskIdentity)); + psk = externalTlsPskProvider.provide(clientPskIdentity, this.context.getSecurityParametersHandshake().getClientRandom()); + } catch (PskCreationFailureException e) { + throw switch (e.getTlsAlertMessage()) { + case unknown_psk_identity -> + new TlsFatalAlert(AlertDescription.unknown_psk_identity, "Unknown or null client PSk identity"); + case decrypt_error -> + new TlsFatalAlert(AlertDescription.decrypt_error, "Invalid or expired client PSk identity"); + }; + } + TlsSecret pskTlsSecret = getCrypto().createSecret(psk); + int prfAlgorithm = getPRFAlgorithm13(getSelectedCipherSuite()); + return new BasicTlsPSKExternal(clientPskIdentity, pskTlsSecret, prfAlgorithm); + } + + @Override + public void notifyAlertRaised(short alertLevel, short alertDescription, String message, Throwable cause) { + super.notifyAlertRaised(alertLevel, alertDescription, message, cause); + Consumer loggerFunc = (alertLevel == AlertLevel.fatal) ? LOGGER::error : LOGGER::debug; + loggerFunc.accept("TLS/PSK server raised alert: " + AlertLevel.getText(alertLevel) + ", " + + AlertDescription.getText(alertDescription)); + if (message != null) { + loggerFunc.accept("> " + message); + } + if (cause != null) { + LOGGER.error("TLS/PSK alert stacktrace", cause); + } + + if (alertDescription == AlertDescription.close_notify) { + ctx.fireUserEventTriggered(SslCloseCompletionEvent.SUCCESS); + } + } + + @Override + public void notifyAlertReceived(short alertLevel, short alertDescription) { + Consumer loggerFunc = (alertLevel == AlertLevel.fatal) ? LOGGER::error : LOGGER::debug; + loggerFunc.accept("TLS 1.3 PSK server received alert: " + AlertLevel.getText(alertLevel) + ", " + + AlertDescription.getText(alertDescription)); + } + + @Override + public void processClientExtensions(Hashtable clientExtensions) throws IOException { + if (context.getSecurityParametersHandshake().getClientRandom() == null) { + throw new TlsFatalAlert(AlertDescription.internal_error); + } + super.processClientExtensions(clientExtensions); + } + + @Override + public Hashtable getServerExtensions() throws IOException { + if (context.getSecurityParametersHandshake().getServerRandom() == null) { + throw new TlsFatalAlert(AlertDescription.internal_error); + } + return super.getServerExtensions(); + } + + @Override + public void getServerExtensionsForConnection(Hashtable serverExtensions) throws IOException { + if (context.getSecurityParametersHandshake().getServerRandom() == null) { + throw new TlsFatalAlert(AlertDescription.internal_error); + } + super.getServerExtensionsForConnection(serverExtensions); + } + + public String getApplicationProtocol() { + ProtocolName protocolName = + context.getSecurityParametersConnection().getApplicationProtocol(); + if (protocolName != null) { + return protocolName.getUtf8Decoding(); + } + return null; + } + + private static int getPRFAlgorithm13(int cipherSuite) { + return switch (cipherSuite) { + case CipherSuite.TLS_AES_128_CCM_SHA256, + CipherSuite.TLS_AES_128_CCM_8_SHA256, + CipherSuite.TLS_AES_128_GCM_SHA256, + CipherSuite.TLS_CHACHA20_POLY1305_SHA256 -> PRFAlgorithm.tls13_hkdf_sha256; + case CipherSuite.TLS_AES_256_GCM_SHA384 -> PRFAlgorithm.tls13_hkdf_sha384; + case CipherSuite.TLS_SM4_CCM_SM3, CipherSuite.TLS_SM4_GCM_SM3 -> PRFAlgorithm.tls13_hkdf_sm3; + default -> -1; + }; + } +} diff --git a/zuul-core/src/main/java/com/netflix/zuul/netty/server/ssl/SslHandshakeInfoHandler.java b/zuul-core/src/main/java/com/netflix/zuul/netty/server/ssl/SslHandshakeInfoHandler.java index 79a9154434..cd24a3d7d0 100644 --- a/zuul-core/src/main/java/com/netflix/zuul/netty/server/ssl/SslHandshakeInfoHandler.java +++ b/zuul-core/src/main/java/com/netflix/zuul/netty/server/ssl/SslHandshakeInfoHandler.java @@ -23,6 +23,9 @@ import com.netflix.spectator.api.NoopRegistry; import com.netflix.spectator.api.Registry; import com.netflix.zuul.netty.ChannelUtils; +import com.netflix.zuul.netty.server.psk.ClientPSKIdentityInfo; +import com.netflix.zuul.netty.server.psk.TlsPskHandler; +import com.netflix.zuul.netty.server.psk.ZuulPskServer; import com.netflix.zuul.passport.CurrentPassport; import com.netflix.zuul.passport.PassportState; import io.netty.channel.ChannelHandlerContext; @@ -81,10 +84,13 @@ public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exc CurrentPassport.fromChannel(ctx.channel()).add(PassportState.SERVER_CH_SSL_HANDSHAKE_COMPLETE); - SslHandler sslhandler = ctx.channel().pipeline().get(SslHandler.class); - SSLSession session = sslhandler.engine().getSession(); + SSLSession session = getSSLSession(ctx); + if (session == null) { + logger.warn("Error getting the SSL handshake info. SSLSession is null"); + return; + } - ClientAuth clientAuth = whichClientAuthEnum(sslhandler); + ClientAuth clientAuth = whichClientAuthEnum(ctx); Certificate serverCert = null; X509Certificate peerCert = null; @@ -98,13 +104,24 @@ public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exc serverCert = session.getLocalCertificates()[0]; } + //if attribute is true, then true. If null or false then false + boolean tlsHandshakeUsingExternalPSK = Boolean.TRUE.equals(ctx.channel() + .attr(ZuulPskServer.TLS_HANDSHAKE_USING_EXTERNAL_PSK) + .get()); + + ClientPSKIdentityInfo clientPSKIdentityInfo = ctx.channel() + .attr(TlsPskHandler.CLIENT_PSK_IDENTITY_ATTRIBUTE_KEY) + .get(); + SslHandshakeInfo info = new SslHandshakeInfo( isSSlFromIntermediary, session.getProtocol(), session.getCipherSuite(), clientAuth, serverCert, - peerCert); + peerCert, + tlsHandshakeUsingExternalPSK, + clientPSKIdentityInfo); ctx.channel().attr(ATTR_SSL_INFO).set(info); // Metrics. @@ -121,7 +138,7 @@ public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exc CurrentPassport.fromChannel(ctx.channel()).getState(); if (cause instanceof ClosedChannelException && (PassportState.SERVER_CH_INACTIVE.equals(passportState) - || PassportState.SERVER_CH_IDLE_TIMEOUT.equals(passportState))) { + || PassportState.SERVER_CH_IDLE_TIMEOUT.equals(passportState))) { // Either client closed the connection without/before having completed a handshake, or // the connection idle timed-out before handshake. // NOTE: we were seeing a lot of these in prod and can repro by just telnetting to port and then @@ -129,7 +146,8 @@ public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exc // without sending anything. // So don't treat these as SSL handshake failures. logger.debug( - "Client closed connection or it idle timed-out without doing an ssl handshake. , client_ip = {}, channel_info = {}", + "Client closed connection or it idle timed-out without doing an ssl handshake. ," + + " client_ip = {}, channel_info = {}", clientIP, ChannelUtils.channelInfoForLogging(ctx.channel())); } else if (cause instanceof SSLException @@ -184,7 +202,24 @@ public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exc super.userEventTriggered(ctx, evt); } - private ClientAuth whichClientAuthEnum(SslHandler sslhandler) { + private SSLSession getSSLSession(ChannelHandlerContext ctx) { + SslHandler sslhandler = ctx.channel().pipeline().get(SslHandler.class); + if (sslhandler != null) { + return sslhandler.engine().getSession(); + } + TlsPskHandler tlsPskHandler = ctx.channel().pipeline().get(TlsPskHandler.class); + if (tlsPskHandler != null) { + return tlsPskHandler.getSession(); + } + return null; + } + + private ClientAuth whichClientAuthEnum(ChannelHandlerContext ctx) { + SslHandler sslhandler = ctx.channel().pipeline().get(SslHandler.class); + if (sslhandler == null) { + return ClientAuth.NONE; + } + ClientAuth clientAuth; if (sslhandler.engine().getNeedClientAuth()) { clientAuth = ClientAuth.REQUIRE;