Skip to content

Commit f0d1a26

Browse files
authored
[hue] Fix bridge certificate handling (openhab#20400)
* support v3 bridge intermediate certificate * disable tls host name check when using ip address * fix lifecycle bug when config changes - decouple ssl settings from common http client - ignore https host name before config params are set * fix resource not found when called from another thread * use trust manager for TLS * v2+ bridge default to use full certificate validation Signed-off-by: Andrew Fiddian-Green <software@whitebear.ch>
1 parent 8a11e6a commit f0d1a26

8 files changed

Lines changed: 128 additions & 66 deletions

File tree

bundles/org.openhab.binding.hue/doc/readme_v2.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,10 @@ Bridge hue:bridge-api2:1 [ ipAddress="192.168.0.64", applicationKey="qwertzuiopa
3434
| ipAddress | Network address of the Hue Bridge. **Mandatory**. |
3535
| applicationKey | A code generated by the bridge that allows to access the API. **Mandatory** |
3636
| checkMinutes | Interval in minutes between retrying the HTTP 2 and SSE connections. Default is 60. **Advanced** |
37-
| useSelfSignedCertificate | Use self-signed certificate for HTTPS connection to Hue Bridge. Default is `true`. **Advanced** |
37+
| useSelfSignedCertificate | Use self-signed certificate for HTTPS connection to Hue Bridge. Default is `false`. **Advanced** |
38+
39+
When `useSelfSignedCertificate` is true, it will trust whatever certificate is present on the Bridge for securing the HTTPS communication.
40+
Otherwise, the binding will validate the Bridge's certificate against that of the official Philips / Signify issuing Certificate Authority.
3841

3942
### Devices, Rooms, Zones, and Areas
4043

bundles/org.openhab.binding.hue/src/main/java/org/openhab/binding/hue/internal/connection/Clip2Bridge.java

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -45,9 +45,11 @@
4545
import java.util.concurrent.locks.Lock;
4646
import java.util.concurrent.locks.ReadWriteLock;
4747
import java.util.concurrent.locks.ReentrantReadWriteLock;
48+
import java.util.regex.Pattern;
4849

4950
import javax.net.ssl.HttpsURLConnection;
5051
import javax.net.ssl.SSLContext;
52+
import javax.net.ssl.TrustManager;
5153
import javax.ws.rs.core.MediaType;
5254

5355
import org.eclipse.jdt.annotation.NonNullByDefault;
@@ -532,7 +534,8 @@ public void close() {
532534

533535
/**
534536
* Static method to attempt to connect to a Hue Bridge, get its software version, and check if it is high enough to
535-
* support the CLIP 2 API.
537+
* support the CLIP 2 API. The bridge may redirect HTTP to HTTPS, but since we do not yet have the certificate
538+
* configuration parameters for a real bridge, we implement a trustAll policy and disable host name verification.
536539
*
537540
* @param hostName the bridge IP address.
538541
* @return true if bridge is online and it supports CLIP 2, or false if it is online and does not support CLIP 2.
@@ -546,10 +549,6 @@ public static boolean isClip2Supported(String hostName) throws IOException {
546549
try {
547550
URL url = new URI(String.format(FORMAT_URL_CONFIG, hostName)).toURL();
548551
httpConnection = (HttpURLConnection) url.openConnection();
549-
/*
550-
* TODO we manually check if the bridge redirects to HTTPS, and if so, since v3 bridges
551-
* currently don't provide a full certificate chain we force use of a TrustAllTrustManager
552-
*/
553552
httpConnection.setInstanceFollowRedirects(false);
554553
int status = httpConnection.getResponseCode();
555554
if (status == 301 || status == 302) {
@@ -559,6 +558,7 @@ public static boolean isClip2Supported(String hostName) throws IOException {
559558
sslContext.init(null, new TrustAllTrustManager[] { TrustAllTrustManager.getInstance() }, null);
560559
httpsConnection = (HttpsURLConnection) new URI(redirectUrl).toURL().openConnection();
561560
httpsConnection.setSSLSocketFactory(sslContext.getSocketFactory());
561+
httpsConnection.setHostnameVerifier((hostname, session) -> true); // don't verify host name
562562
try (InputStream in = httpsConnection.getInputStream()) {
563563
response = new String(in.readAllBytes(), StandardCharsets.UTF_8);
564564
}
@@ -620,20 +620,38 @@ public static boolean isClip2Supported(String hostName) throws IOException {
620620
private @Nullable Thread recreateThread;
621621
private @Nullable Future<?> checkAliveTask;
622622

623+
private static final String IPV4_PART = "(25[0-5]|2[0-4]\\d|1\\d{2}|[1-9]?\\d)";
624+
private static final String IPV4_REGEX = "^(" + IPV4_PART + "\\.){3}" + IPV4_PART + "$";
625+
private static final Pattern IPV4_PATTERN = Pattern.compile(IPV4_REGEX);
626+
623627
/**
624628
* Constructor.
625629
*
626630
* @param httpClientFactory the OH core HttpClientFactory.
627631
* @param bridgeHandler the bridge handler.
632+
* @param trustManagerProvider the Hue TLS trust manager provider
628633
* @param hostName the host name (ip address) of the Hue bridge
629634
* @param applicationKey the application key.
630635
* @throws ApiException if unable to open Jetty HTTP/2 client.
631636
*/
632-
public Clip2Bridge(HttpClientFactory httpClientFactory, Clip2BridgeHandler bridgeHandler, String hostName,
633-
String applicationKey) throws ApiException {
637+
public Clip2Bridge(HttpClientFactory httpClientFactory, Clip2BridgeHandler bridgeHandler,
638+
HueTlsTrustManagerProvider trustManagerProvider, String hostName, String applicationKey)
639+
throws ApiException {
634640
LOGGER.debug("Clip2Bridge()");
635641
httpClient = httpClientFactory.getCommonHttpClient();
636-
http2Client = httpClientFactory.createHttp2Client("hue-clip2", httpClient.getSslContextFactory());
642+
SslContextFactory sslContextFactory = new SslContextFactory.Client();
643+
try {
644+
SSLContext sslContext = SSLContext.getInstance("TLS");
645+
sslContext.init(null, new TrustManager[] { trustManagerProvider.getTrustManager() }, null);
646+
sslContextFactory.setSslContext(sslContext);
647+
} catch (NoSuchAlgorithmException | KeyManagementException e) {
648+
throw new ApiException("Could not initialize Hue SSL Context", e);
649+
}
650+
// don't verify host name when using an IP address since Hue certificates don't contain IP SANs
651+
if (IPV4_PATTERN.matcher(hostName).matches()) {
652+
sslContextFactory.setEndpointIdentificationAlgorithm("");
653+
}
654+
http2Client = httpClientFactory.createHttp2Client("hue-clip2", sslContextFactory);
637655
http2Client.setConnectTimeout(Clip2Bridge.TIMEOUT_SECONDS * 1000);
638656
http2Client.setIdleTimeout(-1);
639657
startHttp2Client();

bundles/org.openhab.binding.hue/src/main/java/org/openhab/binding/hue/internal/connection/HueTlsTrustManagerProvider.java

Lines changed: 69 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -12,37 +12,42 @@
1212
*/
1313
package org.openhab.binding.hue.internal.connection;
1414

15+
import java.io.ByteArrayInputStream;
1516
import java.io.IOException;
1617
import java.io.InputStream;
1718
import java.net.MalformedURLException;
1819
import java.net.URL;
1920
import java.nio.charset.StandardCharsets;
21+
import java.security.KeyStore;
22+
import java.security.cert.Certificate;
2023
import java.security.cert.CertificateException;
24+
import java.security.cert.CertificateFactory;
25+
import java.util.Collection;
2126

27+
import javax.net.ssl.TrustManager;
28+
import javax.net.ssl.TrustManagerFactory;
2229
import javax.net.ssl.X509ExtendedTrustManager;
2330

2431
import org.eclipse.jdt.annotation.NonNullByDefault;
2532
import org.eclipse.jdt.annotation.Nullable;
2633
import org.openhab.core.io.net.http.PEMTrustManager;
27-
import org.openhab.core.io.net.http.PEMTrustManager.CertificateInstantiationException;
2834
import org.openhab.core.io.net.http.TlsTrustManagerProvider;
2935
import org.openhab.core.io.net.http.TrustAllTrustManager;
3036
import org.slf4j.Logger;
3137
import org.slf4j.LoggerFactory;
3238

3339
/**
34-
* Provides a {@link PEMTrustManager} to allow secure connections to any Hue Bridge.
40+
* Provides a {@link X509ExtendedTrustManager} to allow secure connections to any Hue Bridge.
3541
*
3642
* @author Christoph Weitkamp - Initial Contribution
43+
* @author Andrew Fiddian-Green - Add support for intermediate certificates on V3 bridges
3744
*/
3845
@NonNullByDefault
3946
public class HueTlsTrustManagerProvider implements TlsTrustManagerProvider {
4047

41-
private static final String PEM_CACERT_V1_FILENAME = "huebridge_cacert.pem";
42-
private static final String PEM_CACERT_V2_FILENAME = "huebridge_cacert_v2.pem";
48+
private static final String PEM_CACERT_FILENAME = "huebridge_cacert.pem";
4349
private final String hostname;
4450
private final boolean useSelfSignedCertificate;
45-
private final boolean isBridgeV3orHigher;
4651

4752
private final Logger logger = LoggerFactory.getLogger(HueTlsTrustManagerProvider.class);
4853

@@ -51,21 +56,18 @@ public class HueTlsTrustManagerProvider implements TlsTrustManagerProvider {
5156
/**
5257
* Creates a new instance of {@link HueTlsTrustManagerProvider}.
5358
*
54-
* See the documentation for more details about 'Signify private CA Certificates V1 and V2 for Hue Bridges'.
59+
* See the documentation for more details about 'Signify private CA Certificates for Hue Bridges'.
5560
*
5661
* @see <a href=
5762
* "https://developers.meethue.com/develop/application-design-guidance/using-https/">https://developers.meethue.com/develop/application-design-guidance/using-https/</a>
5863
*
59-
* @param hostname the hostname of the Hue Bridge
64+
* @param hostname the host name of the Hue Bridge
6065
* @param useSelfSignedCertificate true, to use the self-signed certificate downloaded from the Hue Bridge;
61-
* false, to use the Signify private CA Certificate V1 or V2 for Hue Bridges from resources
62-
* @param isBridgeV3orHigher true, to use the 'Signify private CA Certificate V2 for Hue Bridges';
63-
* false, to use the 'Signify private CA Certificate V1 for Hue Bridges'
66+
* false, to use the Signify private CA Certificate(s) for Hue Bridges from resources
6467
*/
65-
public HueTlsTrustManagerProvider(String hostname, boolean useSelfSignedCertificate, boolean isBridgeV3orHigher) {
68+
public HueTlsTrustManagerProvider(String hostname, boolean useSelfSignedCertificate) {
6669
this.hostname = hostname;
6770
this.useSelfSignedCertificate = useSelfSignedCertificate;
68-
this.isBridgeV3orHigher = isBridgeV3orHigher;
6971
}
7072

7173
@Override
@@ -87,50 +89,80 @@ public X509ExtendedTrustManager getTrustManager() {
8789
if (localTrustManager != null) {
8890
return localTrustManager;
8991
}
90-
91-
// TODO V3 bridges currently don't provide the full certificate chain (missing intermediate certificate)
92-
if (isBridgeV3orHigher) {
93-
logger.error("Hue V3 Bridge has incomplete PEM certificate chains - defaulting to a TrustAllTrustManager");
94-
return TrustAllTrustManager.getInstance();
95-
}
96-
9792
try {
9893
if (useSelfSignedCertificate) {
9994
logger.trace("Use self-signed certificate downloaded from Hue Bridge.");
10095
// use self-signed certificate downloaded from Hue Bridge
10196
localTrustManager = PEMTrustManager.getInstanceFromServer("https://" + getHostName());
10297
} else {
103-
logger.trace("Use Signify private CA Certificate for Hue Bridges from resources.");
104-
// use Signify private CA Certificate V1 or V2 for Hue Bridges from resources
105-
localTrustManager = getInstanceFromResource(
106-
isBridgeV3orHigher ? PEM_CACERT_V2_FILENAME : PEM_CACERT_V1_FILENAME);
98+
logger.trace("Use Signify private CA Certificate(s) for Hue Bridges from resources.");
99+
// use Signify private CA Certificate(s) for Hue Bridges from resources
100+
localTrustManager = getInstanceFromResource(PEM_CACERT_FILENAME);
107101
}
108102
this.trustManager = localTrustManager;
109103
} catch (CertificateException | MalformedURLException e) {
110-
logger.debug("An unexpected exception occurred: {}", e.getMessage(), e);
104+
logger.warn("An unexpected exception occurred: {}", e.getMessage(), e);
111105
}
112106
return localTrustManager;
113107
}
114108

115109
/**
116-
* Creates a {@link PEMTrustManager} instance by reading the PEM certificate from the given file.
117-
* This is useful if you have a private CA Certificate stored in a file.
110+
* Creates a {@link X509ExtendedTrustManager} instance by reading one or more PEM certificates from the given
111+
* file. The returned trust manager will trust all certificates that are signed by any of the certificates in
112+
* the PEM file, including certificates with intermediates. This is useful if you have private CA Certificate(s)
113+
* stored in a file.
118114
*
119-
* @param fileName name to the PEM file located in the resources folder
120-
* @return a {@link PEMTrustManager} instance
121-
* @throws CertificateInstantiationException
115+
* @param fileName name of the PEM file located in the resources folder
116+
* @return a {@link X509ExtendedTrustManager} instance
117+
* @throws CertificateException
122118
*/
123-
private PEMTrustManager getInstanceFromResource(String fileName) throws CertificateException {
124-
String pemCert = readPEMCertificateStringFromResource(fileName);
125-
if (pemCert != null) {
126-
return new PEMTrustManager(pemCert);
119+
private X509ExtendedTrustManager getInstanceFromResource(String fileName) throws CertificateException {
120+
String certificatesString = readPEMCertificatesStringFromResource(fileName);
121+
if (certificatesString == null) {
122+
throw new CertificateException("Certificate resource '" + fileName + "' not found or not accessible.");
123+
}
124+
try {
125+
CertificateFactory certificateFactory = CertificateFactory.getInstance("X.509");
126+
// load all certificates from the PEM file
127+
Collection<? extends Certificate> certificates;
128+
try (InputStream input = new ByteArrayInputStream(certificatesString.getBytes(StandardCharsets.UTF_8))) {
129+
certificates = certificateFactory.generateCertificates(input);
130+
}
131+
if (certificates.isEmpty()) {
132+
throw new CertificateException("No certificates found in " + fileName);
133+
}
134+
// build a key store containing all the certificates
135+
KeyStore keyStore = KeyStore.getInstance(KeyStore.getDefaultType());
136+
keyStore.load(null, null);
137+
int index = 0;
138+
for (Certificate cert : certificates) {
139+
keyStore.setCertificateEntry("cert-" + index++, cert);
140+
}
141+
// build a trust manager from this key store
142+
TrustManagerFactory trustManagerFactory = TrustManagerFactory
143+
.getInstance(TrustManagerFactory.getDefaultAlgorithm());
144+
trustManagerFactory.init(keyStore);
145+
for (TrustManager trustManager : trustManagerFactory.getTrustManagers()) {
146+
if (trustManager instanceof X509ExtendedTrustManager x509) {
147+
return x509;
148+
}
149+
}
150+
throw new CertificateException("No X509ExtendedTrustManager available.");
151+
} catch (Exception e) {
152+
throw new CertificateException("Failed to load certificates: " + e.getMessage(), e);
127153
}
128-
throw new CertificateInstantiationException(
129-
String.format("Certificate resource '%s' not found or not accessible.", fileName));
130154
}
131155

132-
private @Nullable String readPEMCertificateStringFromResource(String fileName) {
133-
URL resource = Thread.currentThread().getContextClassLoader().getResource(fileName);
156+
/**
157+
* Reads the content of a PEM file from the resources folder and returns it as a string. It may contain multiple
158+
* certificates, e.g. a certificate chain with intermediate certificates. If the file is not found or cannot be
159+
* read, null is returned.
160+
*
161+
* @param fileName name of the PEM file located in the resources folder
162+
* @return the content of the PEM file as a string, or null if the file is not found or cannot be read
163+
*/
164+
private @Nullable String readPEMCertificatesStringFromResource(String fileName) {
165+
URL resource = HueTlsTrustManagerProvider.class.getClassLoader().getResource(fileName);
134166
if (resource != null) {
135167
try (InputStream certInputStream = resource.openStream()) {
136168
return new String(certInputStream.readAllBytes(), StandardCharsets.UTF_8);

bundles/org.openhab.binding.hue/src/main/java/org/openhab/binding/hue/internal/handler/Clip2BridgeHandler.java

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -206,7 +206,12 @@ private synchronized void checkConnection() {
206206
getClip2Bridge().testConnectionState();
207207
updateSelf(); // go online
208208
} catch (HttpUnauthorizedException unauthorizedException) {
209-
logger.debug("checkConnection() {}", unauthorizedException.getMessage(), unauthorizedException);
209+
Clip2BridgeConfig config = getConfigAs(Clip2BridgeConfig.class);
210+
if (config.applicationKey.isBlank()) {
211+
logger.debug("checkConnection() no application key configured");
212+
} else {
213+
logger.debug("checkConnection() {}", unauthorizedException.getMessage(), unauthorizedException);
214+
}
210215
if (applKeyRetriesRemaining > 0) {
211216
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
212217
"@text/offline.api2.conf-error.press-pairing-button");
@@ -491,6 +496,12 @@ public void initialize() {
491496
private void initializeAssets() {
492497
logger.debug("initializeAssets() {}", this);
493498
synchronized (this) {
499+
ServiceRegistration<?> temp = trustManagerRegistration;
500+
if (temp != null) {
501+
temp.unregister();
502+
trustManagerRegistration = null;
503+
}
504+
494505
Clip2BridgeConfig config = getConfigAs(Clip2BridgeConfig.class);
495506

496507
String ipAddress = config.ipAddress;
@@ -512,10 +523,8 @@ private void initializeAssets() {
512523
return;
513524
}
514525

515-
String modelId = thing.getProperties().get(Thing.PROPERTY_MODEL_ID);
516-
boolean useSignifyCaCertificateVersion2 = modelId != null && HueBridgeModel.getGeneration(modelId) >= 3;
517526
HueTlsTrustManagerProvider trustManagerProvider = new HueTlsTrustManagerProvider(ipAddress + ":443",
518-
config.useSelfSignedCertificate, useSignifyCaCertificateVersion2);
527+
config.useSelfSignedCertificate);
519528

520529
if (Objects.isNull(trustManagerProvider.getPEMTrustManager())) {
521530
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
@@ -530,7 +539,7 @@ private void initializeAssets() {
530539
applicationKey = Objects.nonNull(applicationKey) ? applicationKey : "";
531540

532541
try {
533-
clip2Bridge = new Clip2Bridge(httpClientFactory, this, ipAddress, applicationKey);
542+
clip2Bridge = new Clip2Bridge(httpClientFactory, this, trustManagerProvider, ipAddress, applicationKey);
534543
} catch (ApiException e) {
535544
logger.trace("initializeAssets() communication error on '{}'", ipAddress, e);
536545
setStatusOfflineWithCommunicationError(e);
@@ -751,7 +760,7 @@ private void updateSelf() {
751760
*/
752761
private void updateThingFromLegacy() {
753762
if (isInitialized()) {
754-
logger.warn("Cannot update bridge thing '{}' from legacy since handler already initialized.",
763+
logger.debug("Cannot update bridge thing '{}' from legacy since handler already initialized.",
755764
thing.getUID());
756765
return;
757766
}

bundles/org.openhab.binding.hue/src/main/java/org/openhab/binding/hue/internal/handler/HueBridgeHandler.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -698,7 +698,7 @@ public void initialize() {
698698
scheduler.submit(() -> {
699699
// register trustmanager service
700700
HueTlsTrustManagerProvider tlsTrustManagerProvider = new HueTlsTrustManagerProvider(
701-
ip + ":" + hueBridgeConfig.getPort(), hueBridgeConfig.useSelfSignedCertificate, false);
701+
ip + ":" + hueBridgeConfig.getPort(), hueBridgeConfig.useSelfSignedCertificate);
702702

703703
// Check before registering that the PEM certificate can be downloaded
704704
if (tlsTrustManagerProvider.getPEMTrustManager() == null) {

bundles/org.openhab.binding.hue/src/main/resources/OH-INF/thing/bridge.xml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@
9494
<parameter name="useSelfSignedCertificate" type="boolean">
9595
<label>Use Self-Signed Certificate</label>
9696
<description>Use self-signed certificate for HTTPS connection to Hue Bridge.</description>
97-
<default>true</default>
97+
<default>false</default>
9898
<advanced>true</advanced>
9999
</parameter>
100100
</config-description>

0 commit comments

Comments
 (0)