2 * Copyright (c) 2010-2021 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 parser = DocumentBuilderFactory.newInstance().newDocumentBuilder();
160 final MessageFactory messageFactory = MessageFactory.newInstance();
161 requestAction = messageFactory.createMessage();
162 loginAction = messageFactory.createMessage();
164 buildRequestAction();
166 } catch (final SOAPException e) {
167 logger.debug("DLinkHNAPCommunication - Internal error", e);
168 status = HNAPStatus.INTERNAL_ERROR;
169 } catch (final URISyntaxException e) {
170 logger.debug("DLinkHNAPCommunication - Internal error", e);
171 status = HNAPStatus.INTERNAL_ERROR;
172 } catch (final ParserConfigurationException e) {
173 logger.debug("DLinkHNAPCommunication - Internal error", e);
174 status = HNAPStatus.INTERNAL_ERROR;
175 } catch (final Exception e) {
176 // Thrown by httpClient.start()
177 logger.debug("DLinkHNAPCommunication - Internal error", e);
178 status = HNAPStatus.INTERNAL_ERROR;
183 * Stop communicating with the device
185 public void dispose() {
188 } catch (final Exception e) {
194 * This is the first SOAP message used in the login process and is used to retrieve
195 * the cookie, challenge and public key used for authentication.
197 * @throws SOAPException
199 private void buildRequestAction() throws SOAPException {
200 requestAction.getSOAPHeader().detachNode();
201 final SOAPBody soapBody = requestAction.getSOAPBody();
202 final SOAPElement soapBodyElem = soapBody.addChildElement(LOGIN, "", HNAP_XMLNS);
203 soapBodyElem.addChildElement(ACTION).addTextNode("request");
204 soapBodyElem.addChildElement(USERNAME).addTextNode(ADMIN);
205 soapBodyElem.addChildElement(LOGINPASSWORD);
206 soapBodyElem.addChildElement(CAPTCHA);
208 final MimeHeaders headers = requestAction.getMimeHeaders();
209 headers.addHeader(SOAPACTION, LOGIN_ACTION);
211 requestAction.saveChanges();
215 * This is the second SOAP message used in the login process and uses a password derived
216 * from the challenge, public key and the device's pin code.
218 * @throws SOAPException
220 private void buildLoginAction() throws SOAPException {
221 loginAction.getSOAPHeader().detachNode();
222 final SOAPBody soapBody = loginAction.getSOAPBody();
223 final SOAPElement soapBodyElem = soapBody.addChildElement(LOGIN, "", HNAP_XMLNS);
224 soapBodyElem.addChildElement(ACTION).addTextNode("login");
225 soapBodyElem.addChildElement(USERNAME).addTextNode(ADMIN);
226 soapBodyElem.addChildElement(LOGINPASSWORD);
227 soapBodyElem.addChildElement(CAPTCHA);
229 final MimeHeaders headers = loginAction.getMimeHeaders();
230 headers.addHeader(SOAPACTION, LOGIN_ACTION);
234 * Sets the password for the second login message based on the data received from the
235 * first login message. Also sets the private key used to generate the authentication header.
240 * @throws SOAPException
241 * @throws InvalidKeyException
242 * @throws NoSuchAlgorithmException
244 private void setAuthenticationData(final String challenge, final String cookie, final String publicKey)
245 throws SOAPException, InvalidKeyException, NoSuchAlgorithmException {
246 final MimeHeaders loginHeaders = loginAction.getMimeHeaders();
247 loginHeaders.setHeader(COOKIE, "uid=" + cookie);
249 privateKey = hash(challenge, publicKey + pin);
251 final String password = hash(challenge, privateKey);
253 loginAction.getSOAPBody().getElementsByTagName(LOGINPASSWORD).item(0).setTextContent(password);
254 loginAction.saveChanges();
258 * Used to hash the authentication data such as the login password and the authentication header
259 * for the detection message.
263 * @return The hashed data
264 * @throws NoSuchAlgorithmException
265 * @throws InvalidKeyException
267 private String hash(final String data, final String key) throws NoSuchAlgorithmException, InvalidKeyException {
268 final Mac mac = Mac.getInstance("HMACMD5");
269 final SecretKeySpec sKey = new SecretKeySpec(key.getBytes(), "ASCII");
272 final byte[] bytes = mac.doFinal(data.getBytes());
274 final StringBuilder hashBuf = new StringBuilder();
275 for (int i = 0; i < bytes.length; i++) {
276 final String hex = Integer.toHexString(0xFF & bytes[i]).toUpperCase();
277 if (hex.length() == 1) {
283 return hashBuf.toString();
287 * Output unexpected responses to the debug log and sets the FIRMWARE error.
290 * @param soapResponse
292 private void unexpectedResult(final String message, final Document soapResponse) {
293 logUnexpectedResult(message, soapResponse);
295 // Best guess when receiving unexpected responses
296 status = HNAPStatus.UNSUPPORTED_FIRMWARE;
300 * Get the status of the HNAP interface
302 * @return the HNAP status
304 protected HNAPStatus getHNAPStatus() {
309 * Sends the two login messages and stores the private key used to generate the
310 * authentication header required for actions.
312 * Use {@link #getHNAPStatus()} to determine the status of the HNAP connection
313 * after calling this method.
315 * @param timeout - Connection timeout in milliseconds
317 protected void login(final int timeout) {
318 if (status != HNAPStatus.INTERNAL_ERROR) {
320 Document soapResponse = sendReceive(requestAction, timeout);
322 Node result = soapResponse.getElementsByTagName(LOGINRESULT).item(0);
324 if (result != null && OK.equals(result.getTextContent())) {
325 final Node challengeNode = soapResponse.getElementsByTagName("Challenge").item(0);
326 final Node cookieNode = soapResponse.getElementsByTagName(COOKIE).item(0);
327 final Node publicKeyNode = soapResponse.getElementsByTagName("PublicKey").item(0);
329 if (challengeNode != null && cookieNode != null && publicKeyNode != null) {
330 setAuthenticationData(challengeNode.getTextContent(), cookieNode.getTextContent(),
331 publicKeyNode.getTextContent());
333 soapResponse = sendReceive(loginAction, timeout);
334 result = soapResponse.getElementsByTagName(LOGINRESULT).item(0);
336 if (result != null) {
337 if ("success".equals(result.getTextContent())) {
338 status = HNAPStatus.LOGGED_IN;
340 logger.debug("login - Check pin is correct");
341 // Assume pin code problem rather than a firmware change
342 status = HNAPStatus.INVALID_PIN;
345 unexpectedResult("login - Unexpected login response", soapResponse);
348 unexpectedResult("login - Unexpected request response", soapResponse);
351 unexpectedResult("login - Unexpected request response", soapResponse);
353 } catch (final InvalidKeyException e) {
354 logger.debug("login - Internal error", e);
355 status = HNAPStatus.INTERNAL_ERROR;
356 } catch (final NoSuchAlgorithmException e) {
357 logger.debug("login - Internal error", e);
358 status = HNAPStatus.INTERNAL_ERROR;
359 } catch (final Exception e) {
360 // Assume there has been some problem trying to send one of the messages
361 if (status != HNAPStatus.COMMUNICATION_ERROR) {
362 logger.debug("login - Communication error", e);
363 status = HNAPStatus.COMMUNICATION_ERROR;
370 * Sets the authentication headers for the action message. This should only be called
371 * after a successful login.
373 * Use {@link #getHNAPStatus()} to determine the status of the HNAP connection
374 * after calling this method.
376 * @param action - SOAP Action to add headers
378 protected void setAuthenticationHeaders(final SOAPMessage action) {
379 if (status == HNAPStatus.LOGGED_IN) {
381 final MimeHeaders loginHeaders = loginAction.getMimeHeaders();
382 final MimeHeaders actionHeaders = action.getMimeHeaders();
384 actionHeaders.setHeader(COOKIE, loginHeaders.getHeader(COOKIE)[0]);
386 final String timeStamp = String.valueOf(System.currentTimeMillis() / 1000);
387 final String auth = hash(timeStamp + actionHeaders.getHeader(SOAPACTION)[0], privateKey) + " "
389 actionHeaders.setHeader("HNAP_AUTH", auth);
391 action.saveChanges();
392 } catch (final InvalidKeyException e) {
393 logger.debug("setAuthenticationHeaders - Internal error", e);
394 status = HNAPStatus.INTERNAL_ERROR;
395 } catch (final NoSuchAlgorithmException e) {
396 logger.debug("setAuthenticationHeaders - Internal error", e);
397 status = HNAPStatus.INTERNAL_ERROR;
398 } catch (final SOAPException e) {
399 // No communication happening so assume system error
400 logger.debug("setAuthenticationHeaders - Internal error", e);
401 status = HNAPStatus.INTERNAL_ERROR;
407 * Send the SOAP message using Jetty HTTP client. Jetty is used in preference to
408 * HttpURLConnection which can result in the HNAP interface becoming unresponsive.
410 * @param action - SOAP Action to send
411 * @param timeout - Connection timeout in milliseconds
413 * @throws IOException
414 * @throws SOAPException
415 * @throws SAXException
416 * @throws ExecutionException
417 * @throws TimeoutException
418 * @throws InterruptedException
420 protected Document sendReceive(final SOAPMessage action, final int timeout) throws IOException, SOAPException,
421 SAXException, InterruptedException, TimeoutException, ExecutionException {
424 final Request request = httpClient.POST(uri);
425 request.timeout(timeout, TimeUnit.MILLISECONDS);
427 final Iterator<?> it = action.getMimeHeaders().getAllHeaders();
428 while (it.hasNext()) {
429 final MimeHeader header = (MimeHeader) it.next();
430 request.header(header.getName(), header.getValue());
433 try (final ByteArrayOutputStream os = new ByteArrayOutputStream()) {
435 request.content(new BytesContentProvider(os.toByteArray()));
436 final ContentResponse response = request.send();
437 try (final ByteArrayInputStream is = new ByteArrayInputStream(response.getContent())) {
438 result = parser.parse(is);
446 * Output unexpected responses to the debug log.
449 * @param soapResponse
451 protected void logUnexpectedResult(final String message, final Document soapResponse) {
452 // No point formatting for output if debug logging is not enabled
453 if (logger.isDebugEnabled()) {
455 final TransformerFactory transFactory = TransformerFactory.newInstance();
456 final Transformer transformer = transFactory.newTransformer();
457 final StringWriter buffer = new StringWriter();
458 transformer.setOutputProperty(OutputKeys.OMIT_XML_DECLARATION, "yes");
459 transformer.transform(new DOMSource(soapResponse), new StreamResult(buffer));
460 logger.debug("{} : {}", message, buffer);
461 } catch (final TransformerException e) {
462 logger.debug("{}", message);