Support setting the minimum acceptable TLS version.

Change-Id: I24775461fb690abdd0a47c5b4e23776c9fe5bfe0
Bug: https://github.com/dart-lang/sdk/issues/54901
Tested: TLS traffic verified in Wireshark, exception behavior verified with a Python test server, property changes verified by unit test
CoreLibraryReviewExempt: dart:io only
Reviewed-on: https://dart-review.googlesource.com/c/sdk/+/365664
Reviewed-by: Brian Quinlan <bquinlan@google.com>
Commit-Queue: Brian Quinlan <bquinlan@google.com>
Reviewed-by: Lasse Nielsen <lrn@google.com>
diff --git a/runtime/bin/io_natives.cc b/runtime/bin/io_natives.cc
index 091b616..552d063 100644
--- a/runtime/bin/io_natives.cc
+++ b/runtime/bin/io_natives.cc
@@ -137,6 +137,8 @@
   V(SecurityContext_SetTrustedCertificatesBytes, 3)                            \
   V(SecurityContext_TrustBuiltinRoots, 1)                                      \
   V(SecurityContext_SetAllowTlsRenegotiation, 2)                               \
+  V(SecurityContext_SetMinimumProtocolVersion, 2)                              \
+  V(SecurityContext_GetMinimumProtocolVersion, 1)                              \
   V(SecurityContext_UseCertificateChainBytes, 3)                               \
   V(ServerSocket_Accept, 2)                                                    \
   V(ServerSocket_CreateBindListen, 7)                                          \
diff --git a/runtime/bin/secure_socket_unsupported.cc b/runtime/bin/secure_socket_unsupported.cc
index 5d5241db..1324d92 100644
--- a/runtime/bin/secure_socket_unsupported.cc
+++ b/runtime/bin/secure_socket_unsupported.cc
@@ -136,6 +136,18 @@
       "Secure Sockets unsupported on this platform"));
 }
 
+void FUNCTION_NAME(SecurityContext_SetMinimumProtocolVersion)(
+    Dart_NativeArguments args) {
+  Dart_ThrowException(DartUtils::NewDartArgumentError(
+      "Secure Sockets unsupported on this platform"));
+}
+
+void FUNCTION_NAME(SecurityContext_GetMinimumProtocolVersion)(
+    Dart_NativeArguments args) {
+  Dart_ThrowException(DartUtils::NewDartArgumentError(
+      "Secure Sockets unsupported on this platform"));
+}
+
 void FUNCTION_NAME(SecurityContext_UseCertificateChainBytes)(
     Dart_NativeArguments args) {
   Dart_ThrowException(DartUtils::NewDartArgumentError(
diff --git a/runtime/bin/security_context.cc b/runtime/bin/security_context.cc
index 5fc9444..6af6116 100644
--- a/runtime/bin/security_context.cc
+++ b/runtime/bin/security_context.cc
@@ -827,6 +827,8 @@
   SSL_CTX* ctx = SSL_CTX_new(TLS_method());
   SSL_CTX_set_verify(ctx, SSL_VERIFY_PEER, SSLCertContext::CertificateCallback);
   SSL_CTX_set_keylog_callback(ctx, SSLCertContext::KeyLogCallback);
+  // If we change the minimum protocol version here, then the documentation
+  // for `SecurityContext.minimumTlsProtocolVersion` must also be changed.
   SSL_CTX_set_min_proto_version(ctx, TLS1_2_VERSION);
   SSL_CTX_set_cipher_list(ctx, "HIGH:MEDIUM");
   SSLCertContext* context = new SSLCertContext(ctx);
@@ -901,6 +903,32 @@
   context->set_allow_tls_renegotiation(allow);
 }
 
+void FUNCTION_NAME(SecurityContext_SetMinimumProtocolVersion)(
+    Dart_NativeArguments args) {
+  SSLCertContext* context = SSLCertContext::GetSecurityContext(args);
+  Dart_Handle protocol_version_handle =
+      ThrowIfError(Dart_GetNativeArgument(args, 1));
+  if (!Dart_IsInteger(protocol_version_handle)) {
+    Dart_ThrowException(DartUtils::NewDartArgumentError(
+        "Non-int argument passed to SetMinimumProtocolVersion"));
+  }
+
+  int protocol_version = DartUtils::GetIntegerValue(protocol_version_handle);
+  if (SSL_CTX_set_min_proto_version(context->context(), protocol_version) ==
+      0) {
+    Dart_ThrowException(DartUtils::NewDartArgumentError(
+        "Invalid protocol version passed to SetMinimumProtocolVersion"));
+  }
+}
+
+void FUNCTION_NAME(SecurityContext_GetMinimumProtocolVersion)(
+    Dart_NativeArguments args) {
+  SSLCertContext* context = SSLCertContext::GetSecurityContext(args);
+
+  Dart_SetIntegerReturnValue(args,
+                             SSL_CTX_get_min_proto_version(context->context()));
+}
+
 void FUNCTION_NAME(X509_Der)(Dart_NativeArguments args) {
   Dart_SetReturnValue(args, X509Helper::GetDer(args));
 }
diff --git a/sdk/lib/_internal/vm/bin/secure_socket_patch.dart b/sdk/lib/_internal/vm/bin/secure_socket_patch.dart
index 67999d7..f6c2bdf 100644
--- a/sdk/lib/_internal/vm/bin/secure_socket_patch.dart
+++ b/sdk/lib/_internal/vm/bin/secure_socket_patch.dart
@@ -227,6 +227,14 @@
 
   bool get allowLegacyUnsafeRenegotiation => _allowLegacyUnsafeRenegotiation;
 
+  set minimumTlsProtocolVersion(TlsProtocolVersion version) {
+    _setMinimumProtocolVersion(version._version);
+  }
+
+  TlsProtocolVersion get minimumTlsProtocolVersion =>
+      TlsProtocolVersion._fromProtocolVersionConstant(
+          _getMinimumProtocolVersion());
+
   @pragma("vm:external-name", "SecurityContext_Allocate")
   external void _createNativeContext();
 
@@ -279,6 +287,10 @@
   external void _trustBuiltinRoots();
   @pragma("vm:external-name", "SecurityContext_SetAllowTlsRenegotiation")
   external void _setAllowTlsRenegotiation(bool allow);
+  @pragma("vm:external-name", "SecurityContext_SetMinimumProtocolVersion")
+  external void _setMinimumProtocolVersion(int version);
+  @pragma("vm:external-name", "SecurityContext_GetMinimumProtocolVersion")
+  external int _getMinimumProtocolVersion();
 }
 
 /**
diff --git a/sdk/lib/io/security_context.dart b/sdk/lib/io/security_context.dart
index 571b32c..df6cbac 100644
--- a/sdk/lib/io/security_context.dart
+++ b/sdk/lib/io/security_context.dart
@@ -4,6 +4,32 @@
 
 part of dart.io;
 
+/// A Transport Layer Security (TLS) version.
+///
+/// Only TLS versions supported by `dart:io` are included.
+class TlsProtocolVersion {
+  /// Transport Layer Security (TLS) Protocol Version 1.2.
+  ///
+  /// See RFC-5246.
+  static const tls1_2 = TlsProtocolVersion._(0x0303);
+
+  /// Transport Layer Security (TLS) Protocol Version 1.3.
+  ///
+  /// See RFC-8446.
+  static const tls1_3 = TlsProtocolVersion._(0x0304);
+
+  final int _version;
+
+  const TlsProtocolVersion._(this._version);
+
+  static TlsProtocolVersion _fromProtocolVersionConstant(int version) =>
+      switch (version) {
+        0x0303 => tls1_2,
+        0x0304 => tls1_3,
+        _ => throw ArgumentError.value(version, 'version'),
+      };
+}
+
 /// The object containing the certificates to trust when making
 /// a secure client connection, and the certificate chain and
 /// private key to serve from a secure server.
@@ -170,6 +196,18 @@
   /// where it is known to be safe.
   abstract bool allowLegacyUnsafeRenegotiation;
 
+  /// The minimum TLS version to use when establishing a secure connection.
+  ///
+  /// If the peer does not support `minimumTlsProtocolVersion` or later
+  /// then [SecureSocket.connect] will throw a [TlsException].
+  ///
+  /// If the value is changed, it will only affect new connections. Existing
+  /// connections will continue to use the protocol that was negotiated with the
+  /// peer.
+  ///
+  /// The default value is [TlsProtocolVersion.tls1_2].
+  abstract TlsProtocolVersion minimumTlsProtocolVersion;
+
   /// Encodes a set of supported protocols for ALPN/NPN usage.
   ///
   /// The [protocols] list is expected to contain protocols in descending order
diff --git a/tests/standalone/io/secure_socket_minimum_tls_protocol_version_test.dart b/tests/standalone/io/secure_socket_minimum_tls_protocol_version_test.dart
new file mode 100644
index 0000000..14d8214
--- /dev/null
+++ b/tests/standalone/io/secure_socket_minimum_tls_protocol_version_test.dart
@@ -0,0 +1,74 @@
+// Copyright (c) 2024, the Dart project authors.  Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+//
+// OtherResources=certificates/server_chain.pem
+// OtherResources=certificates/server_key.pem
+// OtherResources=certificates/trusted_certs.pem
+//
+// This test does not verify that the value set in `minimumTlsProtocolVersion`
+// appears in the supported versions extension as defined in RPC-8446 4.2.1.
+import "dart:async";
+import 'dart:convert';
+import "dart:io";
+
+import "package:async_helper/async_helper.dart";
+import "package:expect/expect.dart";
+
+late InternetAddress HOST;
+
+String localFile(path) => Platform.script.resolve(path).toFilePath();
+
+SecurityContext serverContext = new SecurityContext()
+  ..useCertificateChain(localFile('certificates/server_chain.pem'))
+  ..usePrivateKey(localFile('certificates/server_key.pem'),
+      password: 'dartdart');
+
+Future<SecureServerSocket> startEchoServer() {
+  return SecureServerSocket.bind(HOST, 0, serverContext).then((server) {
+    server.listen((SecureSocket client) {
+      client.fold<List<int>>(
+          <int>[], (message, data) => message..addAll(data)).then((message) {
+        client.add(message);
+        client.close();
+      });
+    });
+    return server;
+  });
+}
+
+testVersion(SecureServerSocket server, TlsProtocolVersion tlsVersion) async {
+  // NOTE: this test only verifies that `minimumTlsProtocolVersion` does
+  // not cause incorrect behavior when used - the server does *not* actually
+  // verify that the supported versions extension is correctly set.
+  SecurityContext clientContext = new SecurityContext()
+    ..minimumTlsProtocolVersion = tlsVersion
+    ..setTrustedCertificates(localFile('certificates/trusted_certs.pem'));
+
+  await SecureSocket.connect(HOST, server.port, context: clientContext)
+      .then((socket) async {
+    socket.write("Hello server.");
+    socket.close();
+    Expect.isTrue(await utf8.decoder.bind(socket).contains("Hello server."));
+  });
+}
+
+testProperty() {
+  SecurityContext context = new SecurityContext();
+  Expect.equals(TlsProtocolVersion.tls1_2, context.minimumTlsProtocolVersion);
+  context.minimumTlsProtocolVersion = TlsProtocolVersion.tls1_3;
+  Expect.equals(TlsProtocolVersion.tls1_3, context.minimumTlsProtocolVersion);
+}
+
+void main() async {
+  asyncStart();
+  await InternetAddress.lookup("localhost").then((hosts) => HOST = hosts.first);
+  final server = await startEchoServer();
+
+  testProperty();
+  await testVersion(server, TlsProtocolVersion.tls1_2);
+  await testVersion(server, TlsProtocolVersion.tls1_3);
+
+  await server.close();
+  asyncEnd();
+}