diff --git a/bundles/org.openhab.binding.hue/src/main/java/org/openhab/binding/hue/internal/connection/Clip2Bridge.java b/bundles/org.openhab.binding.hue/src/main/java/org/openhab/binding/hue/internal/connection/Clip2Bridge.java index 2d7b52a92ed42..779fda3e37fea 100644 --- a/bundles/org.openhab.binding.hue/src/main/java/org/openhab/binding/hue/internal/connection/Clip2Bridge.java +++ b/bundles/org.openhab.binding.hue/src/main/java/org/openhab/binding/hue/internal/connection/Clip2Bridge.java @@ -16,11 +16,18 @@ import java.io.ByteArrayInputStream; import java.io.Closeable; import java.io.IOException; +import java.io.InputStream; import java.io.InputStreamReader; import java.io.Reader; +import java.net.HttpURLConnection; import java.net.InetSocketAddress; +import java.net.URI; +import java.net.URISyntaxException; +import java.net.URL; import java.nio.ByteBuffer; import java.nio.charset.StandardCharsets; +import java.security.KeyManagementException; +import java.security.NoSuchAlgorithmException; import java.time.Duration; import java.time.Instant; import java.util.ArrayList; @@ -28,7 +35,6 @@ import java.util.List; import java.util.Map; import java.util.Objects; -import java.util.Properties; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ExecutionException; @@ -40,6 +46,8 @@ import java.util.concurrent.locks.ReadWriteLock; import java.util.concurrent.locks.ReentrantReadWriteLock; +import javax.net.ssl.HttpsURLConnection; +import javax.net.ssl.SSLContext; import javax.ws.rs.core.MediaType; import org.eclipse.jdt.annotation.NonNullByDefault; @@ -81,7 +89,7 @@ import org.openhab.binding.hue.internal.exceptions.HttpUnauthorizedException; import org.openhab.binding.hue.internal.handler.Clip2BridgeHandler; import org.openhab.core.io.net.http.HttpClientFactory; -import org.openhab.core.io.net.http.HttpUtil; +import org.openhab.core.io.net.http.TrustAllTrustManager; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -532,11 +540,46 @@ public void close() { * @throws NumberFormatException if the bridge firmware version is invalid. */ public static boolean isClip2Supported(String hostName) throws IOException { - String response; - Properties headers = new Properties(); - headers.put(HttpHeader.ACCEPT, MediaType.APPLICATION_JSON); - response = HttpUtil.executeUrl("GET", String.format(FORMAT_URL_CONFIG, hostName), headers, null, null, - TIMEOUT_SECONDS * 1000); + String response = null; + HttpURLConnection httpConnection = null; + HttpsURLConnection httpsConnection = null; + try { + URL url = new URI(String.format(FORMAT_URL_CONFIG, hostName)).toURL(); + httpConnection = (HttpURLConnection) url.openConnection(); + /* + * TODO we manually check if the bridge redirects to HTTPS, and if so, since v3 bridges + * currently don't provide a full certificate chain we force use of a TrustAllTrustManager + */ + httpConnection.setInstanceFollowRedirects(false); + int status = httpConnection.getResponseCode(); + if (status == 301 || status == 302) { + String redirectUrl = httpConnection.getHeaderField("Location"); + if (redirectUrl != null && redirectUrl.startsWith("https://")) { + SSLContext sslContext = SSLContext.getInstance("TLS"); + sslContext.init(null, new TrustAllTrustManager[] { TrustAllTrustManager.getInstance() }, null); + httpsConnection = (HttpsURLConnection) new URI(redirectUrl).toURL().openConnection(); + httpsConnection.setSSLSocketFactory(sslContext.getSocketFactory()); + try (InputStream in = httpsConnection.getInputStream()) { + response = new String(in.readAllBytes(), StandardCharsets.UTF_8); + } + } + } + if (response == null) { + try (InputStream in = httpConnection.getInputStream()) { + response = new String(in.readAllBytes(), StandardCharsets.UTF_8); + } + } + } catch (NoSuchAlgorithmException | KeyManagementException | URISyntaxException e) { + throw new IOException("isClip2Supported() error connecting to bridge", e); + } finally { + if (httpConnection != null) { + httpConnection.disconnect(); + } + if (httpsConnection != null) { + httpsConnection.disconnect(); + } + } + BridgeConfig config = new Gson().fromJson(response, BridgeConfig.class); if (Objects.nonNull(config)) { String swVersion = config.swversion; diff --git a/bundles/org.openhab.binding.hue/src/main/java/org/openhab/binding/hue/internal/connection/HueTlsTrustManagerProvider.java b/bundles/org.openhab.binding.hue/src/main/java/org/openhab/binding/hue/internal/connection/HueTlsTrustManagerProvider.java index d34657c5484bd..9e98a16bf3603 100644 --- a/bundles/org.openhab.binding.hue/src/main/java/org/openhab/binding/hue/internal/connection/HueTlsTrustManagerProvider.java +++ b/bundles/org.openhab.binding.hue/src/main/java/org/openhab/binding/hue/internal/connection/HueTlsTrustManagerProvider.java @@ -38,17 +38,34 @@ @NonNullByDefault public class HueTlsTrustManagerProvider implements TlsTrustManagerProvider { - private static final String PEM_FILENAME = "huebridge_cacert.pem"; + private static final String PEM_CACERT_V1_FILENAME = "huebridge_cacert.pem"; + private static final String PEM_CACERT_V2_FILENAME = "huebridge_cacert_v2.pem"; private final String hostname; private final boolean useSelfSignedCertificate; + private final boolean isBridgeV3orHigher; private final Logger logger = LoggerFactory.getLogger(HueTlsTrustManagerProvider.class); - private @Nullable PEMTrustManager trustManager; + private @Nullable X509ExtendedTrustManager trustManager; - public HueTlsTrustManagerProvider(String hostname, boolean useSelfSignedCertificate) { + /** + * Creates a new instance of {@link HueTlsTrustManagerProvider}. + * + * See the documentation for more details about 'Signify private CA Certificates V1 and V2 for Hue Bridges'. + * + * @see https://developers.meethue.com/develop/application-design-guidance/using-https/ + * + * @param hostname the hostname of the Hue Bridge + * @param useSelfSignedCertificate true, to use the self-signed certificate downloaded from the Hue Bridge; + * false, to use the Signify private CA Certificate V1 or V2 for Hue Bridges from resources + * @param isBridgeV3orHigher true, to use the 'Signify private CA Certificate V2 for Hue Bridges'; + * false, to use the 'Signify private CA Certificate V1 for Hue Bridges' + */ + public HueTlsTrustManagerProvider(String hostname, boolean useSelfSignedCertificate, boolean isBridgeV3orHigher) { this.hostname = hostname; this.useSelfSignedCertificate = useSelfSignedCertificate; + this.isBridgeV3orHigher = isBridgeV3orHigher; } @Override @@ -58,18 +75,25 @@ public String getHostName() { @Override public X509ExtendedTrustManager getTrustManager() { - PEMTrustManager localTrustManager = getPEMTrustManager(); + X509ExtendedTrustManager localTrustManager = getPEMTrustManager(); if (localTrustManager == null) { logger.error("Cannot get the PEM certificate - returning a TrustAllTrustManager"); } return localTrustManager != null ? localTrustManager : TrustAllTrustManager.getInstance(); } - public @Nullable PEMTrustManager getPEMTrustManager() { - PEMTrustManager localTrustManager = trustManager; + public @Nullable X509ExtendedTrustManager getPEMTrustManager() { + X509ExtendedTrustManager localTrustManager = trustManager; if (localTrustManager != null) { return localTrustManager; } + + // TODO V3 bridges currently don't provide the full certificate chain (missing intermediate certificate) + if (isBridgeV3orHigher) { + logger.error("Hue V3 Bridge has incomplete PEM certificate chains - defaulting to a TrustAllTrustManager"); + return TrustAllTrustManager.getInstance(); + } + try { if (useSelfSignedCertificate) { logger.trace("Use self-signed certificate downloaded from Hue Bridge."); @@ -77,8 +101,9 @@ public X509ExtendedTrustManager getTrustManager() { localTrustManager = PEMTrustManager.getInstanceFromServer("https://" + getHostName()); } else { logger.trace("Use Signify private CA Certificate for Hue Bridges from resources."); - // use Signify private CA Certificate for Hue Bridges from resources - localTrustManager = getInstanceFromResource(PEM_FILENAME); + // use Signify private CA Certificate V1 or V2 for Hue Bridges from resources + localTrustManager = getInstanceFromResource( + isBridgeV3orHigher ? PEM_CACERT_V2_FILENAME : PEM_CACERT_V1_FILENAME); } this.trustManager = localTrustManager; } catch (CertificateException | MalformedURLException e) { diff --git a/bundles/org.openhab.binding.hue/src/main/java/org/openhab/binding/hue/internal/discovery/HueBridgeMDNSDiscoveryParticipant.java b/bundles/org.openhab.binding.hue/src/main/java/org/openhab/binding/hue/internal/discovery/HueBridgeMDNSDiscoveryParticipant.java index e22689b7c3920..3ee8b3709816b 100644 --- a/bundles/org.openhab.binding.hue/src/main/java/org/openhab/binding/hue/internal/discovery/HueBridgeMDNSDiscoveryParticipant.java +++ b/bundles/org.openhab.binding.hue/src/main/java/org/openhab/binding/hue/internal/discovery/HueBridgeMDNSDiscoveryParticipant.java @@ -19,6 +19,8 @@ import java.util.Objects; import java.util.Optional; import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; import javax.jmdns.ServiceInfo; @@ -54,6 +56,26 @@ @NonNullByDefault public class HueBridgeMDNSDiscoveryParticipant implements MDNSDiscoveryParticipant { + private static final Pattern BSB_MODEL_ID_PATTERN = Pattern.compile("^BSB(\\d{3})$"); + + /** + * Checks if the given model ID is a BSB model and if its version is 003 or above. + * + * @param modelId the model ID to check + * @return true if the model ID is a BSB model with version 003 or above, false otherwise + */ + public static boolean modelIsOrAboveBSB003(@Nullable String modelId) { + if (modelId == null) { + return false; + } + Matcher matcher = BSB_MODEL_ID_PATTERN.matcher(modelId); + if (!matcher.matches()) { + return false; + } + int version = Integer.parseInt(matcher.group(1)); + return version >= 3; + } + private static final String SERVICE_TYPE = "_hue._tcp.local."; private static final String MDNS_PROPERTY_BRIDGE_ID = "bridgeid"; private static final String MDNS_PROPERTY_MODEL_ID = "modelid"; @@ -109,6 +131,7 @@ public String getServiceType() { @Override public @Nullable DiscoveryResult createResult(ServiceInfo service) { + logger.debug("Discovered mDNS service: {}", service.getNiceTextString()); if (isAutoDiscoveryEnabled) { ThingUID uid = getThingUID(service); if (Objects.nonNull(uid)) { @@ -160,6 +183,9 @@ private Optional getLegacyBridge(String ipAddress) { String id = service.getPropertyString(MDNS_PROPERTY_BRIDGE_ID); if (id != null && !id.isBlank()) { id = id.toLowerCase(); + if (modelIsOrAboveBSB003(service.getPropertyString(MDNS_PROPERTY_MODEL_ID))) { + return new ThingUID(THING_TYPE_BRIDGE_API2, id); + } try { return Clip2Bridge.isClip2Supported(service.getHostAddresses()[0]) ? new ThingUID(THING_TYPE_BRIDGE_API2, id) diff --git a/bundles/org.openhab.binding.hue/src/main/java/org/openhab/binding/hue/internal/handler/Clip2BridgeHandler.java b/bundles/org.openhab.binding.hue/src/main/java/org/openhab/binding/hue/internal/handler/Clip2BridgeHandler.java index e8a7ccb51b799..59297d94b3c56 100644 --- a/bundles/org.openhab.binding.hue/src/main/java/org/openhab/binding/hue/internal/handler/Clip2BridgeHandler.java +++ b/bundles/org.openhab.binding.hue/src/main/java/org/openhab/binding/hue/internal/handler/Clip2BridgeHandler.java @@ -45,6 +45,7 @@ import org.openhab.binding.hue.internal.connection.Clip2Bridge; import org.openhab.binding.hue.internal.connection.HueTlsTrustManagerProvider; import org.openhab.binding.hue.internal.discovery.Clip2ThingDiscoveryService; +import org.openhab.binding.hue.internal.discovery.HueBridgeMDNSDiscoveryParticipant; import org.openhab.binding.hue.internal.exceptions.ApiException; import org.openhab.binding.hue.internal.exceptions.AssetNotLoadedException; import org.openhab.binding.hue.internal.exceptions.HttpUnauthorizedException; @@ -494,8 +495,10 @@ private void initializeAssets() { return; } + boolean useSignifyCaCertificateVersion2 = HueBridgeMDNSDiscoveryParticipant + .modelIsOrAboveBSB003(thing.getProperties().get(Thing.PROPERTY_MODEL_ID)); HueTlsTrustManagerProvider trustManagerProvider = new HueTlsTrustManagerProvider(ipAddress + ":443", - config.useSelfSignedCertificate); + config.useSelfSignedCertificate, useSignifyCaCertificateVersion2); if (Objects.isNull(trustManagerProvider.getPEMTrustManager())) { updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, diff --git a/bundles/org.openhab.binding.hue/src/main/java/org/openhab/binding/hue/internal/handler/HueBridgeHandler.java b/bundles/org.openhab.binding.hue/src/main/java/org/openhab/binding/hue/internal/handler/HueBridgeHandler.java index 64f658fefea00..2f05ea889d845 100644 --- a/bundles/org.openhab.binding.hue/src/main/java/org/openhab/binding/hue/internal/handler/HueBridgeHandler.java +++ b/bundles/org.openhab.binding.hue/src/main/java/org/openhab/binding/hue/internal/handler/HueBridgeHandler.java @@ -698,7 +698,7 @@ public void initialize() { scheduler.submit(() -> { // register trustmanager service HueTlsTrustManagerProvider tlsTrustManagerProvider = new HueTlsTrustManagerProvider( - ip + ":" + hueBridgeConfig.getPort(), hueBridgeConfig.useSelfSignedCertificate); + ip + ":" + hueBridgeConfig.getPort(), hueBridgeConfig.useSelfSignedCertificate, false); // Check before registering that the PEM certificate can be downloaded if (tlsTrustManagerProvider.getPEMTrustManager() == null) { diff --git a/bundles/org.openhab.binding.hue/src/main/resources/huebridge_cacert_v2.pem b/bundles/org.openhab.binding.hue/src/main/resources/huebridge_cacert_v2.pem new file mode 100644 index 0000000000000..081a58f82efbf --- /dev/null +++ b/bundles/org.openhab.binding.hue/src/main/resources/huebridge_cacert_v2.pem @@ -0,0 +1,12 @@ +-----BEGIN CERTIFICATE----- +MIIBzDCCAXOgAwIBAgICEAAwCgYIKoZIzj0EAwIwPDELMAkGA1UEBhMCTkwxFDAS +BgNVBAoMC1NpZ25pZnkgSHVlMRcwFQYDVQQDDA5IdWUgUm9vdCBDQSAwMTAgFw0y +NTAyMjUwMDAwMDBaGA8yMDUwMTIzMTIzNTk1OVowPDELMAkGA1UEBhMCTkwxFDAS +BgNVBAoMC1NpZ25pZnkgSHVlMRcwFQYDVQQDDA5IdWUgUm9vdCBDQSAwMTBZMBMG +ByqGSM49AgEGCCqGSM49AwEHA0IABFfOO0jfSAUXGQ9kjEDzyBrcMQ3ItyA5krE+ +cyvb1Y3xFti7KlAad8UOnAx0FBLn7HZrlmIwm1QnX0fK3LPM13mjYzBhMB0GA1Ud +DgQWBBTF1pSpsCASX/z0VHLigxU2CAaqoTAfBgNVHSMEGDAWgBTF1pSpsCASX/z0 +VHLigxU2CAaqoTAPBgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIBBjAKBggq +hkjOPQQDAgNHADBEAiAk7duT+IHbOGO4UUuGLAEpyYejGZK9Z7V9oSfnvuQ5BQIg +IYSgwwxHXm73/JgcU9lAM6c8Bmu3UE3kBIUwBs1qXFw= +-----END CERTIFICATE-----