]> git.basschouten.com Git - openhab-addons.git/blob
73aba6be9dd0b72c11ae4871c96340ed1ac5e482
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2023 Contributors to the openHAB project
3  *
4  * See the NOTICE file(s) distributed with this work for additional
5  * information.
6  *
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
10  *
11  * SPDX-License-Identifier: EPL-2.0
12  */
13 package org.openhab.binding.dlinksmarthome.internal;
14
15 import java.io.ByteArrayInputStream;
16 import java.io.ByteArrayOutputStream;
17 import java.io.IOException;
18 import java.io.StringWriter;
19 import java.net.URI;
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;
27
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;
46
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;
56
57 /**
58  * The {@link DLinkHNAPCommunication} is responsible for communicating with D-Link
59  * Smart Home devices using the HNAP interface.
60  *
61  * This abstract class handles login and authentication which is common between devices.
62  *
63  * Reverse engineered from Login.html and soapclient.js retrieved from the device.
64  *
65  * @author Mike Major - Initial contribution
66  */
67 public abstract class DLinkHNAPCommunication {
68
69     // SOAP actions
70     private static final String LOGIN_ACTION = "\"http://purenetworks.com/HNAP1/LOGIN\"";
71
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";
81
82     /**
83      * HNAP XMLNS
84      */
85     protected static final String HNAP_XMLNS = "http://purenetworks.com/HNAP1";
86     /**
87      * The SOAP action HTML header
88      */
89     protected static final String SOAPACTION = "SOAPAction";
90     /**
91      * OK represents a successful action
92      */
93     protected static final String OK = "OK";
94
95     /**
96      * Use to log connection issues
97      */
98     private final Logger logger = LoggerFactory.getLogger(DLinkHNAPCommunication.class);
99
100     private URI uri;
101     private final HttpClient httpClient;
102     private final String pin;
103     private String privateKey;
104
105     private DocumentBuilder parser;
106     private SOAPMessage requestAction;
107     private SOAPMessage loginAction;
108
109     private HNAPStatus status = HNAPStatus.INITIALISED;
110
111     /**
112      * Indicates the status of the HNAP interface
113      *
114      */
115     protected enum HNAPStatus {
116         /**
117          * Ready to start communication with device
118          */
119         INITIALISED,
120         /**
121          * Successfully logged in to device
122          */
123         LOGGED_IN,
124         /**
125          * Problem communicating with device
126          */
127         COMMUNICATION_ERROR,
128         /**
129          * Internal error
130          */
131         INTERNAL_ERROR,
132         /**
133          * Error due to unsupported firmware
134          */
135         UNSUPPORTED_FIRMWARE,
136         /**
137          * Error due to invalid pin code
138          */
139         INVALID_PIN
140     }
141
142     /**
143      * Use {@link #getHNAPStatus()} to determine the status of the HNAP connection
144      * after construction.
145      *
146      * @param ipAddress
147      * @param pin
148      */
149     public DLinkHNAPCommunication(final String ipAddress, final String pin) {
150         this.pin = pin;
151
152         httpClient = new HttpClient();
153
154         try {
155             uri = new URI("http://" + ipAddress + "/HNAP1");
156             httpClient.start();
157
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();
166
167             final MessageFactory messageFactory = MessageFactory.newInstance();
168             requestAction = messageFactory.createMessage();
169             loginAction = messageFactory.createMessage();
170
171             buildRequestAction();
172             buildLoginAction();
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;
186         }
187     }
188
189     /**
190      * Stop communicating with the device
191      */
192     public void dispose() {
193         try {
194             httpClient.stop();
195         } catch (final Exception e) {
196             // Ignored
197         }
198     }
199
200     /**
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.
203      *
204      * @throws SOAPException
205      */
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);
214
215         final MimeHeaders headers = requestAction.getMimeHeaders();
216         headers.addHeader(SOAPACTION, LOGIN_ACTION);
217
218         requestAction.saveChanges();
219     }
220
221     /**
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.
224      *
225      * @throws SOAPException
226      */
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);
235
236         final MimeHeaders headers = loginAction.getMimeHeaders();
237         headers.addHeader(SOAPACTION, LOGIN_ACTION);
238     }
239
240     /**
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.
243      *
244      * @param challenge
245      * @param cookie
246      * @param publicKey
247      * @throws SOAPException
248      * @throws InvalidKeyException
249      * @throws NoSuchAlgorithmException
250      */
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);
255
256         privateKey = hash(challenge, publicKey + pin);
257
258         final String password = hash(challenge, privateKey);
259
260         loginAction.getSOAPBody().getElementsByTagName(LOGINPASSWORD).item(0).setTextContent(password);
261         loginAction.saveChanges();
262     }
263
264     /**
265      * Used to hash the authentication data such as the login password and the authentication header
266      * for the detection message.
267      *
268      * @param data
269      * @param key
270      * @return The hashed data
271      * @throws NoSuchAlgorithmException
272      * @throws InvalidKeyException
273      */
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");
277
278         mac.init(sKey);
279         final byte[] bytes = mac.doFinal(data.getBytes());
280
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) {
285                 hashBuf.append('0');
286             }
287             hashBuf.append(hex);
288         }
289
290         return hashBuf.toString();
291     }
292
293     /**
294      * Output unexpected responses to the debug log and sets the FIRMWARE error.
295      *
296      * @param message
297      * @param soapResponse
298      */
299     private void unexpectedResult(final String message, final Document soapResponse) {
300         logUnexpectedResult(message, soapResponse);
301
302         // Best guess when receiving unexpected responses
303         status = HNAPStatus.UNSUPPORTED_FIRMWARE;
304     }
305
306     /**
307      * Get the status of the HNAP interface
308      *
309      * @return the HNAP status
310      */
311     protected HNAPStatus getHNAPStatus() {
312         return status;
313     }
314
315     /**
316      * Sends the two login messages and stores the private key used to generate the
317      * authentication header required for actions.
318      *
319      * Use {@link #getHNAPStatus()} to determine the status of the HNAP connection
320      * after calling this method.
321      *
322      * @param timeout - Connection timeout in milliseconds
323      */
324     protected void login(final int timeout) {
325         if (status != HNAPStatus.INTERNAL_ERROR) {
326             try {
327                 Document soapResponse = sendReceive(requestAction, timeout);
328
329                 Node result = soapResponse.getElementsByTagName(LOGINRESULT).item(0);
330
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);
335
336                     if (challengeNode != null && cookieNode != null && publicKeyNode != null) {
337                         setAuthenticationData(challengeNode.getTextContent(), cookieNode.getTextContent(),
338                                 publicKeyNode.getTextContent());
339
340                         soapResponse = sendReceive(loginAction, timeout);
341                         result = soapResponse.getElementsByTagName(LOGINRESULT).item(0);
342
343                         if (result != null) {
344                             if ("success".equals(result.getTextContent())) {
345                                 status = HNAPStatus.LOGGED_IN;
346                             } else {
347                                 logger.debug("login - Check pin is correct");
348                                 // Assume pin code problem rather than a firmware change
349                                 status = HNAPStatus.INVALID_PIN;
350                             }
351                         } else {
352                             unexpectedResult("login - Unexpected login response", soapResponse);
353                         }
354                     } else {
355                         unexpectedResult("login - Unexpected request response", soapResponse);
356                     }
357                 } else {
358                     unexpectedResult("login - Unexpected request response", soapResponse);
359                 }
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;
374                 }
375             }
376         }
377     }
378
379     /**
380      * Sets the authentication headers for the action message. This should only be called
381      * after a successful login.
382      *
383      * Use {@link #getHNAPStatus()} to determine the status of the HNAP connection
384      * after calling this method.
385      *
386      * @param action - SOAP Action to add headers
387      */
388     protected void setAuthenticationHeaders(final SOAPMessage action) {
389         if (status == HNAPStatus.LOGGED_IN) {
390             try {
391                 final MimeHeaders loginHeaders = loginAction.getMimeHeaders();
392                 final MimeHeaders actionHeaders = action.getMimeHeaders();
393
394                 actionHeaders.setHeader(COOKIE, loginHeaders.getHeader(COOKIE)[0]);
395
396                 final String timeStamp = String.valueOf(System.currentTimeMillis() / 1000);
397                 final String auth = hash(timeStamp + actionHeaders.getHeader(SOAPACTION)[0], privateKey) + " "
398                         + timeStamp;
399                 actionHeaders.setHeader("HNAP_AUTH", auth);
400
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;
412             }
413         }
414     }
415
416     /**
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.
419      *
420      * @param action - SOAP Action to send
421      * @param timeout - Connection timeout in milliseconds
422      * @return The result
423      * @throws IOException
424      * @throws SOAPException
425      * @throws SAXException
426      * @throws ExecutionException
427      * @throws TimeoutException
428      * @throws InterruptedException
429      */
430     protected Document sendReceive(final SOAPMessage action, final int timeout) throws IOException, SOAPException,
431             SAXException, InterruptedException, TimeoutException, ExecutionException {
432         Document result;
433
434         final Request request = httpClient.POST(uri);
435         request.timeout(timeout, TimeUnit.MILLISECONDS);
436
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());
441         }
442
443         try (final ByteArrayOutputStream os = new ByteArrayOutputStream()) {
444             action.writeTo(os);
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);
449             }
450         }
451
452         return result;
453     }
454
455     /**
456      * Output unexpected responses to the debug log.
457      *
458      * @param message
459      * @param soapResponse
460      */
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()) {
464             try {
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);
473             }
474         }
475     }
476 }