2 * Copyright (c) 2010-2023 Contributors to the openHAB project
4 * See the NOTICE file(s) distributed with this work for additional
7 * This program and the accompanying materials are made available under the
8 * terms of the Eclipse Public License 2.0 which is available at
9 * http://www.eclipse.org/legal/epl-2.0
11 * SPDX-License-Identifier: EPL-2.0
13 package org.openhab.binding.dlinksmarthome.internal;
15 import java.io.ByteArrayInputStream;
16 import java.io.ByteArrayOutputStream;
17 import java.io.IOException;
18 import java.io.StringWriter;
20 import java.net.URISyntaxException;
21 import java.security.InvalidKeyException;
22 import java.security.NoSuchAlgorithmException;
23 import java.util.Iterator;
24 import java.util.concurrent.ExecutionException;
25 import java.util.concurrent.TimeUnit;
26 import java.util.concurrent.TimeoutException;
28 import javax.crypto.Mac;
29 import javax.crypto.spec.SecretKeySpec;
30 import javax.xml.parsers.DocumentBuilder;
31 import javax.xml.parsers.DocumentBuilderFactory;
32 import javax.xml.parsers.ParserConfigurationException;
33 import javax.xml.soap.MessageFactory;
34 import javax.xml.soap.MimeHeader;
35 import javax.xml.soap.MimeHeaders;
36 import javax.xml.soap.SOAPBody;
37 import javax.xml.soap.SOAPElement;
38 import javax.xml.soap.SOAPException;
39 import javax.xml.soap.SOAPMessage;
40 import javax.xml.transform.OutputKeys;
41 import javax.xml.transform.Transformer;
42 import javax.xml.transform.TransformerException;
43 import javax.xml.transform.TransformerFactory;
44 import javax.xml.transform.dom.DOMSource;
45 import javax.xml.transform.stream.StreamResult;
47 import org.eclipse.jetty.client.HttpClient;
48 import org.eclipse.jetty.client.api.ContentResponse;
49 import org.eclipse.jetty.client.api.Request;
50 import org.eclipse.jetty.client.util.BytesContentProvider;
51 import org.slf4j.Logger;
52 import org.slf4j.LoggerFactory;
53 import org.w3c.dom.Document;
54 import org.w3c.dom.Node;
55 import org.xml.sax.SAXException;
58 * The {@link DLinkHNAPCommunication} is responsible for communicating with D-Link
59 * Smart Home devices using the HNAP interface.
61 * This abstract class handles login and authentication which is common between devices.
63 * Reverse engineered from Login.html and soapclient.js retrieved from the device.
65 * @author Mike Major - Initial contribution
67 public abstract class DLinkHNAPCommunication {
70 private static final String LOGIN_ACTION = "\"http://purenetworks.com/HNAP1/LOGIN\"";
72 // Strings used more than once
73 private static final String LOGIN = "LOGIN";
74 private static final String ACTION = "Action";
75 private static final String USERNAME = "Username";
76 private static final String LOGINPASSWORD = "LoginPassword";
77 private static final String CAPTCHA = "Captcha";
78 private static final String ADMIN = "Admin";
79 private static final String LOGINRESULT = "LOGINResult";
80 private static final String COOKIE = "Cookie";
85 protected static final String HNAP_XMLNS = "http://purenetworks.com/HNAP1";
87 * The SOAP action HTML header
89 protected static final String SOAPACTION = "SOAPAction";
91 * OK represents a successful action
93 protected static final String OK = "OK";
96 * Use to log connection issues
98 private final Logger logger = LoggerFactory.getLogger(DLinkHNAPCommunication.class);
101 private final HttpClient httpClient;
102 private final String pin;
103 private String privateKey;
105 private DocumentBuilder parser;
106 private SOAPMessage requestAction;
107 private SOAPMessage loginAction;
109 private HNAPStatus status = HNAPStatus.INITIALISED;
112 * Indicates the status of the HNAP interface
115 protected enum HNAPStatus {
117 * Ready to start communication with device
121 * Successfully logged in to device
125 * Problem communicating with device
133 * Error due to unsupported firmware
135 UNSUPPORTED_FIRMWARE,
137 * Error due to invalid pin code
143 * Use {@link #getHNAPStatus()} to determine the status of the HNAP connection
144 * after construction.
149 public DLinkHNAPCommunication(final String ipAddress, final String pin) {
152 httpClient = new HttpClient();
155 uri = new URI("http://" + ipAddress + "/HNAP1");
158 DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance();
159 // see https://cheatsheetseries.owasp.org/cheatsheets/XML_External_Entity_Prevention_Cheat_Sheet.html
160 dbf.setFeature("http://xml.org/sax/features/external-general-entities", false);
161 dbf.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
162 dbf.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false);
163 dbf.setXIncludeAware(false);
164 dbf.setExpandEntityReferences(false);
165 parser = dbf.newDocumentBuilder();
167 final MessageFactory messageFactory = MessageFactory.newInstance();
168 requestAction = messageFactory.createMessage();
169 loginAction = messageFactory.createMessage();
171 buildRequestAction();
173 } catch (final SOAPException e) {
174 logger.debug("DLinkHNAPCommunication - Internal error", e);
175 status = HNAPStatus.INTERNAL_ERROR;
176 } catch (final URISyntaxException e) {
177 logger.debug("DLinkHNAPCommunication - Internal error", e);
178 status = HNAPStatus.INTERNAL_ERROR;
179 } catch (final ParserConfigurationException e) {
180 logger.debug("DLinkHNAPCommunication - Internal error", e);
181 status = HNAPStatus.INTERNAL_ERROR;
182 } catch (final Exception e) {
183 // Thrown by httpClient.start()
184 logger.debug("DLinkHNAPCommunication - Internal error", e);
185 status = HNAPStatus.INTERNAL_ERROR;
190 * Stop communicating with the device
192 public void dispose() {
195 } catch (final Exception e) {
201 * This is the first SOAP message used in the login process and is used to retrieve
202 * the cookie, challenge and public key used for authentication.
204 * @throws SOAPException
206 private void buildRequestAction() throws SOAPException {
207 requestAction.getSOAPHeader().detachNode();
208 final SOAPBody soapBody = requestAction.getSOAPBody();
209 final SOAPElement soapBodyElem = soapBody.addChildElement(LOGIN, "", HNAP_XMLNS);
210 soapBodyElem.addChildElement(ACTION).addTextNode("request");
211 soapBodyElem.addChildElement(USERNAME).addTextNode(ADMIN);
212 soapBodyElem.addChildElement(LOGINPASSWORD);
213 soapBodyElem.addChildElement(CAPTCHA);
215 final MimeHeaders headers = requestAction.getMimeHeaders();
216 headers.addHeader(SOAPACTION, LOGIN_ACTION);
218 requestAction.saveChanges();
222 * This is the second SOAP message used in the login process and uses a password derived
223 * from the challenge, public key and the device's pin code.
225 * @throws SOAPException
227 private void buildLoginAction() throws SOAPException {
228 loginAction.getSOAPHeader().detachNode();
229 final SOAPBody soapBody = loginAction.getSOAPBody();
230 final SOAPElement soapBodyElem = soapBody.addChildElement(LOGIN, "", HNAP_XMLNS);
231 soapBodyElem.addChildElement(ACTION).addTextNode("login");
232 soapBodyElem.addChildElement(USERNAME).addTextNode(ADMIN);
233 soapBodyElem.addChildElement(LOGINPASSWORD);
234 soapBodyElem.addChildElement(CAPTCHA);
236 final MimeHeaders headers = loginAction.getMimeHeaders();
237 headers.addHeader(SOAPACTION, LOGIN_ACTION);
241 * Sets the password for the second login message based on the data received from the
242 * first login message. Also sets the private key used to generate the authentication header.
247 * @throws SOAPException
248 * @throws InvalidKeyException
249 * @throws NoSuchAlgorithmException
251 private void setAuthenticationData(final String challenge, final String cookie, final String publicKey)
252 throws SOAPException, InvalidKeyException, NoSuchAlgorithmException {
253 final MimeHeaders loginHeaders = loginAction.getMimeHeaders();
254 loginHeaders.setHeader(COOKIE, "uid=" + cookie);
256 privateKey = hash(challenge, publicKey + pin);
258 final String password = hash(challenge, privateKey);
260 loginAction.getSOAPBody().getElementsByTagName(LOGINPASSWORD).item(0).setTextContent(password);
261 loginAction.saveChanges();
265 * Used to hash the authentication data such as the login password and the authentication header
266 * for the detection message.
270 * @return The hashed data
271 * @throws NoSuchAlgorithmException
272 * @throws InvalidKeyException
274 private String hash(final String data, final String key) throws NoSuchAlgorithmException, InvalidKeyException {
275 final Mac mac = Mac.getInstance("HMACMD5");
276 final SecretKeySpec sKey = new SecretKeySpec(key.getBytes(), "ASCII");
279 final byte[] bytes = mac.doFinal(data.getBytes());
281 final StringBuilder hashBuf = new StringBuilder();
282 for (int i = 0; i < bytes.length; i++) {
283 final String hex = Integer.toHexString(0xFF & bytes[i]).toUpperCase();
284 if (hex.length() == 1) {
290 return hashBuf.toString();
294 * Output unexpected responses to the debug log and sets the FIRMWARE error.
297 * @param soapResponse
299 private void unexpectedResult(final String message, final Document soapResponse) {
300 logUnexpectedResult(message, soapResponse);
302 // Best guess when receiving unexpected responses
303 status = HNAPStatus.UNSUPPORTED_FIRMWARE;
307 * Get the status of the HNAP interface
309 * @return the HNAP status
311 protected HNAPStatus getHNAPStatus() {
316 * Sends the two login messages and stores the private key used to generate the
317 * authentication header required for actions.
319 * Use {@link #getHNAPStatus()} to determine the status of the HNAP connection
320 * after calling this method.
322 * @param timeout - Connection timeout in milliseconds
324 protected void login(final int timeout) {
325 if (status != HNAPStatus.INTERNAL_ERROR) {
327 Document soapResponse = sendReceive(requestAction, timeout);
329 Node result = soapResponse.getElementsByTagName(LOGINRESULT).item(0);
331 if (result != null && OK.equals(result.getTextContent())) {
332 final Node challengeNode = soapResponse.getElementsByTagName("Challenge").item(0);
333 final Node cookieNode = soapResponse.getElementsByTagName(COOKIE).item(0);
334 final Node publicKeyNode = soapResponse.getElementsByTagName("PublicKey").item(0);
336 if (challengeNode != null && cookieNode != null && publicKeyNode != null) {
337 setAuthenticationData(challengeNode.getTextContent(), cookieNode.getTextContent(),
338 publicKeyNode.getTextContent());
340 soapResponse = sendReceive(loginAction, timeout);
341 result = soapResponse.getElementsByTagName(LOGINRESULT).item(0);
343 if (result != null) {
344 if ("success".equals(result.getTextContent())) {
345 status = HNAPStatus.LOGGED_IN;
347 logger.debug("login - Check pin is correct");
348 // Assume pin code problem rather than a firmware change
349 status = HNAPStatus.INVALID_PIN;
352 unexpectedResult("login - Unexpected login response", soapResponse);
355 unexpectedResult("login - Unexpected request response", soapResponse);
358 unexpectedResult("login - Unexpected request response", soapResponse);
360 } catch (final InvalidKeyException e) {
361 logger.debug("login - Internal error", e);
362 status = HNAPStatus.INTERNAL_ERROR;
363 } catch (final NoSuchAlgorithmException e) {
364 logger.debug("login - Internal error", e);
365 status = HNAPStatus.INTERNAL_ERROR;
366 } catch (final InterruptedException e) {
367 status = HNAPStatus.COMMUNICATION_ERROR;
368 Thread.currentThread().interrupt();
369 } catch (final Exception e) {
370 // Assume there has been some problem trying to send one of the messages
371 if (status != HNAPStatus.COMMUNICATION_ERROR) {
372 logger.debug("login - Communication error", e);
373 status = HNAPStatus.COMMUNICATION_ERROR;
380 * Sets the authentication headers for the action message. This should only be called
381 * after a successful login.
383 * Use {@link #getHNAPStatus()} to determine the status of the HNAP connection
384 * after calling this method.
386 * @param action - SOAP Action to add headers
388 protected void setAuthenticationHeaders(final SOAPMessage action) {
389 if (status == HNAPStatus.LOGGED_IN) {
391 final MimeHeaders loginHeaders = loginAction.getMimeHeaders();
392 final MimeHeaders actionHeaders = action.getMimeHeaders();
394 actionHeaders.setHeader(COOKIE, loginHeaders.getHeader(COOKIE)[0]);
396 final String timeStamp = String.valueOf(System.currentTimeMillis() / 1000);
397 final String auth = hash(timeStamp + actionHeaders.getHeader(SOAPACTION)[0], privateKey) + " "
399 actionHeaders.setHeader("HNAP_AUTH", auth);
401 action.saveChanges();
402 } catch (final InvalidKeyException e) {
403 logger.debug("setAuthenticationHeaders - Internal error", e);
404 status = HNAPStatus.INTERNAL_ERROR;
405 } catch (final NoSuchAlgorithmException e) {
406 logger.debug("setAuthenticationHeaders - Internal error", e);
407 status = HNAPStatus.INTERNAL_ERROR;
408 } catch (final SOAPException e) {
409 // No communication happening so assume system error
410 logger.debug("setAuthenticationHeaders - Internal error", e);
411 status = HNAPStatus.INTERNAL_ERROR;
417 * Send the SOAP message using Jetty HTTP client. Jetty is used in preference to
418 * HttpURLConnection which can result in the HNAP interface becoming unresponsive.
420 * @param action - SOAP Action to send
421 * @param timeout - Connection timeout in milliseconds
423 * @throws IOException
424 * @throws SOAPException
425 * @throws SAXException
426 * @throws ExecutionException
427 * @throws TimeoutException
428 * @throws InterruptedException
430 protected Document sendReceive(final SOAPMessage action, final int timeout) throws IOException, SOAPException,
431 SAXException, InterruptedException, TimeoutException, ExecutionException {
434 final Request request = httpClient.POST(uri);
435 request.timeout(timeout, TimeUnit.MILLISECONDS);
437 final Iterator<?> it = action.getMimeHeaders().getAllHeaders();
438 while (it.hasNext()) {
439 final MimeHeader header = (MimeHeader) it.next();
440 request.header(header.getName(), header.getValue());
443 try (final ByteArrayOutputStream os = new ByteArrayOutputStream()) {
445 request.content(new BytesContentProvider(os.toByteArray()));
446 final ContentResponse response = request.send();
447 try (final ByteArrayInputStream is = new ByteArrayInputStream(response.getContent())) {
448 result = parser.parse(is);
456 * Output unexpected responses to the debug log.
459 * @param soapResponse
461 protected void logUnexpectedResult(final String message, final Document soapResponse) {
462 // No point formatting for output if debug logging is not enabled
463 if (logger.isDebugEnabled()) {
465 final TransformerFactory transFactory = TransformerFactory.newInstance();
466 final Transformer transformer = transFactory.newTransformer();
467 final StringWriter buffer = new StringWriter();
468 transformer.setOutputProperty(OutputKeys.OMIT_XML_DECLARATION, "yes");
469 transformer.transform(new DOMSource(soapResponse), new StreamResult(buffer));
470 logger.debug("{} : {}", message, buffer);
471 } catch (final TransformerException e) {
472 logger.debug("{}", message);