]> git.basschouten.com Git - openhab-addons.git/blob
1a8601aea1bf0f9ef1b6b140d2df22171a6bb6f6
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2021 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.loxone.internal.security;
14
15 import java.io.UnsupportedEncodingException;
16 import java.net.URLEncoder;
17 import java.nio.charset.StandardCharsets;
18 import java.security.InvalidAlgorithmParameterException;
19 import java.security.InvalidKeyException;
20 import java.security.InvalidParameterException;
21 import java.security.KeyFactory;
22 import java.security.MessageDigest;
23 import java.security.NoSuchAlgorithmException;
24 import java.security.PublicKey;
25 import java.security.SecureRandom;
26 import java.security.spec.InvalidKeySpecException;
27 import java.security.spec.X509EncodedKeySpec;
28 import java.text.SimpleDateFormat;
29 import java.util.Base64;
30 import java.util.Calendar;
31 import java.util.HashMap;
32 import java.util.Map;
33 import java.util.concurrent.ScheduledExecutorService;
34 import java.util.concurrent.ScheduledFuture;
35 import java.util.concurrent.TimeUnit;
36 import java.util.concurrent.locks.Lock;
37 import java.util.concurrent.locks.ReentrantLock;
38
39 import javax.crypto.BadPaddingException;
40 import javax.crypto.Cipher;
41 import javax.crypto.IllegalBlockSizeException;
42 import javax.crypto.KeyGenerator;
43 import javax.crypto.NoSuchPaddingException;
44 import javax.crypto.SecretKey;
45 import javax.crypto.spec.IvParameterSpec;
46
47 import org.openhab.binding.loxone.internal.LxServerHandlerApi;
48 import org.openhab.binding.loxone.internal.LxWebSocket;
49 import org.openhab.binding.loxone.internal.types.LxErrorCode;
50 import org.openhab.binding.loxone.internal.types.LxResponse;
51 import org.openhab.core.common.ThreadPoolManager;
52 import org.openhab.core.id.InstanceUUID;
53 import org.openhab.core.util.HexUtils;
54 import org.slf4j.Logger;
55 import org.slf4j.LoggerFactory;
56
57 import com.google.gson.JsonParseException;
58
59 /**
60  * A token-based authentication algorithm with AES-256 encryption and decryption.
61  *
62  * The encryption algorithm uses public Miniserver key to RSA-encrypt own AES-256 key and initialization vector into a
63  * session key. The encrypted session key is sent to the Miniserver. From this point on encryption (and decryption) of
64  * the communication is possible and all further commands sent to the Miniserver are encrypted. The encryption makes use
65  * of an additional salt value injected into the commands and updated frequently.
66  *
67  * To get the token, a hash key and salt values that are specific to the user are received from the Miniserver. These
68  * values are used to compute a hash over user name and password using Miniserver's salt and key values (combined SHA1
69  * and HMAC-SHA1 algorithm). This hash is sent to the Miniserver in an encrypted message to authorize the user and
70  * obtain a token.
71  *
72  * Once a token is obtained, it can be used in all future authorizations instead of hashed user name and password.
73  * When a token expires, it is refreshed.
74  *
75  * @author Pawel Pieczul - initial contribution
76  *
77  */
78 class LxWsSecurityToken extends LxWsSecurity {
79     /**
80      * A sub-response value structure that is received as a response to get key-salt request command sent to the
81      * Miniserver during authentication procedure.
82      *
83      * @author Pawel Pieczul - initial contribution
84      *
85      */
86     private class LxResponseKeySalt {
87         String key;
88         String salt;
89     }
90
91     /**
92      * A sub-response value structure that is received as a response to token request or token update command sent to
93      * the Miniserver during authentication procedure.
94      *
95      * @author Pawel Pieczul - initial contribution
96      *
97      */
98     private class LxResponseToken {
99         String token;
100         Integer validUntil;
101         Boolean unsecurePass;
102         @SuppressWarnings("unused")
103         String key;
104         @SuppressWarnings("unused")
105         Integer tokenRights;
106     }
107
108     // length of salt used for encrypting commands
109     private static final int SALT_BYTES = 16;
110     // after salt aged or reached max use count, a new salt will be generated
111     private static final int SALT_MAX_AGE_SECONDS = 60 * 60;
112     private static final int SALT_MAX_USE_COUNT = 30;
113
114     // defined by Loxone API, value 4 gives longest token expiration time
115     private static final int TOKEN_PERMISSION = 4; // 2=web, 4=app
116     // number of attempts for token refresh and delay between them
117     private static final int TOKEN_REFRESH_RETRY_COUNT = 5;
118     private static final int TOKEN_REFRESH_RETRY_DELAY_SECONDS = 10;
119     // token will be refreshed 1 day before its expiration date
120     private static final int TOKEN_REFRESH_SECONDS_BEFORE_EXPIRY = 24 * 60 * 60; // 1 day
121     // if can't determine token expiration date, it will be refreshed after 2 days
122     private static final int TOKEN_REFRESH_DEFAULT_SECONDS = 2 * 24 * 60 * 60; // 2 days
123
124     // AES encryption random initialization vector length
125     private static final int IV_LENGTH_BYTES = 16;
126
127     private static final String CMD_GET_KEY_AND_SALT = "jdev/sys/getkey2/";
128     private static final String CMD_GET_PUBLIC_KEY = "jdev/sys/getPublicKey";
129     private static final String CMD_KEY_EXCHANGE = "jdev/sys/keyexchange/";
130     private static final String CMD_REQUEST_TOKEN = "jdev/sys/gettoken/";
131     private static final String CMD_GET_KEY = "jdev/sys/getkey";
132     private static final String CMD_AUTH_WITH_TOKEN = "authwithtoken/";
133     private static final String CMD_REFRESH_TOKEN = "jdev/sys/refreshtoken/";
134     private static final String CMD_ENCRYPT_CMD = "jdev/sys/enc/";
135
136     private static final String SETTINGS_TOKEN = "authToken";
137     private static final String SETTINGS_PASSWORD = "password";
138
139     private SecretKey aesKey;
140     private Cipher aesEncryptCipher;
141     private Cipher aesDecryptCipher;
142     private SecureRandom secureRandom;
143     private String salt;
144     private int saltUseCount;
145     private long saltTimeStamp;
146     private boolean encryptionReady = false;
147     private String token;
148     private int tokenRefreshRetryCount;
149     private ScheduledFuture<?> tokenRefreshTimer;
150     private final Lock tokenRefreshLock = new ReentrantLock();
151
152     private final byte[] initVector = new byte[IV_LENGTH_BYTES];
153     private final Logger logger = LoggerFactory.getLogger(LxWsSecurityToken.class);
154     private static final ScheduledExecutorService SCHEDULER = ThreadPoolManager
155             .getScheduledPool(LxWsSecurityToken.class.getName());
156
157     /**
158      * Create a token-based authentication instance.
159      *
160      * @param debugId instance of the client used for debugging purposes only
161      * @param thingHandler API to the thing handler
162      * @param socket websocket to perform communication with Miniserver
163      * @param user user to authenticate
164      * @param password password to authenticate
165      */
166     LxWsSecurityToken(int debugId, LxServerHandlerApi thingHandler, LxWebSocket socket, String user, String password) {
167         super(debugId, thingHandler, socket, user, password);
168     }
169
170     @Override
171     boolean execute() {
172         logger.debug("[{}] Starting token-based authentication.", debugId);
173         if (!initialize()) {
174             return false;
175         }
176         if ((token == null || token.isEmpty()) && (password == null || password.isEmpty())) {
177             return setError(LxErrorCode.USER_UNAUTHORIZED, "Enter password to acquire token.");
178         }
179         // Get Miniserver's public key - must be over http, not websocket
180         String msg = socket.httpGet(CMD_GET_PUBLIC_KEY);
181         LxResponse resp = socket.getResponse(msg);
182         if (resp == null) {
183             return setError(LxErrorCode.COMMUNICATION_ERROR, "Get public key failed - null response.");
184         }
185         // RSA cipher to encrypt our AES-256 key using Miniserver's public key
186         Cipher rsaCipher = getRsaCipher(resp.getValueAsString());
187         if (rsaCipher == null) {
188             return false;
189         }
190         // Generate session key
191         byte[] sessionKey = generateSessionKey(rsaCipher);
192         if (sessionKey == null) {
193             return false;
194         }
195         // Exchange keys
196         resp = socket.sendCmdWithResp(CMD_KEY_EXCHANGE + Base64.getEncoder().encodeToString(sessionKey), true, false);
197         if (!checkResponse(resp)) {
198             return setError(null, "Key exchange failed.");
199         }
200         logger.debug("[{}] Keys exchanged.", debugId);
201         encryptionReady = true;
202
203         if (token == null || token.isEmpty()) {
204             if (!acquireToken()) {
205                 return false;
206             }
207             logger.debug("[{}] Authenticated - acquired new token.", debugId);
208         } else {
209             if (!useToken()) {
210                 return false;
211             }
212             logger.debug("[{}] Authenticated - used stored token.", debugId);
213         }
214
215         return true;
216     }
217
218     @Override
219     public String encrypt(String command) {
220         if (!encryptionReady) {
221             return command;
222         }
223         String str;
224         if (salt != null && newSaltNeeded()) {
225             String prevSalt = salt;
226             salt = generateSalt();
227             str = "nextSalt/" + prevSalt + "/" + salt + "/" + command + "\0";
228         } else {
229             if (salt == null) {
230                 salt = generateSalt();
231             }
232             str = "salt/" + salt + "/" + command + "\0";
233         }
234
235         logger.debug("[{}] Command for encryption: {}", debugId, str);
236         try {
237             String encrypted = Base64.getEncoder()
238                     .encodeToString(aesEncryptCipher.doFinal(str.getBytes(StandardCharsets.UTF_8)));
239             try {
240                 encrypted = URLEncoder.encode(encrypted, "UTF-8");
241             } catch (UnsupportedEncodingException e) {
242                 logger.warn("[{}] Unsupported encoding for encrypted command conversion to URL.", debugId);
243             }
244             return CMD_ENCRYPT_CMD + encrypted;
245         } catch (IllegalBlockSizeException | BadPaddingException e) {
246             logger.warn("[{}] Command encryption failed: {}", debugId, e.getMessage());
247             return command;
248         }
249     }
250
251     @Override
252     public String decryptControl(String control) {
253         String string = control;
254         if (!encryptionReady || !string.startsWith(CMD_ENCRYPT_CMD)) {
255             return string;
256         }
257         string = string.substring(CMD_ENCRYPT_CMD.length());
258         try {
259             byte[] bytes = Base64.getDecoder().decode(string);
260             bytes = aesDecryptCipher.doFinal(bytes);
261             string = new String(bytes, "UTF-8");
262             string = string.replaceAll("\0+.*$", "");
263             string = string.replaceFirst("^salt/[^/]*/", "");
264             string = string.replaceFirst("^nextSalt/[^/]*/[^/]*/", "");
265             return string;
266         } catch (IllegalArgumentException e) {
267             logger.debug("[{}] Failed to decode base64 string: {}", debugId, string);
268         } catch (IllegalBlockSizeException | BadPaddingException e) {
269             logger.warn("[{}] Command decryption failed: {}", debugId, e.getMessage());
270         } catch (UnsupportedEncodingException e) {
271             logger.warn("[{}] Unsupported encoding for decrypted bytes to string conversion.", debugId);
272         }
273         return string;
274     }
275
276     @Override
277     public void cancel() {
278         super.cancel();
279         tokenRefreshLock.lock();
280         try {
281             if (tokenRefreshTimer != null) {
282                 logger.debug("[{}] Cancelling token refresh.", debugId);
283                 tokenRefreshTimer.cancel(true);
284             }
285         } finally {
286             tokenRefreshLock.unlock();
287         }
288     }
289
290     private boolean initialize() {
291         try {
292             encryptionReady = false;
293             tokenRefreshRetryCount = TOKEN_REFRESH_RETRY_COUNT;
294             if (Cipher.getMaxAllowedKeyLength("AES") < 256) {
295                 return setError(LxErrorCode.INTERNAL_ERROR,
296                         "Enable Java cryptography unlimited strength (see binding doc).");
297             }
298             // generate a random key for the session
299             KeyGenerator aesKeyGen = KeyGenerator.getInstance("AES");
300             aesKeyGen.init(256);
301             aesKey = aesKeyGen.generateKey();
302             // generate an initialization vector
303             secureRandom = new SecureRandom();
304             secureRandom.nextBytes(initVector);
305             IvParameterSpec ivSpec = new IvParameterSpec(initVector);
306             // initialize aes cipher for command encryption
307             aesEncryptCipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
308             aesEncryptCipher.init(Cipher.ENCRYPT_MODE, aesKey, ivSpec);
309             // initialize aes cipher for response decryption
310             aesDecryptCipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
311             aesDecryptCipher.init(Cipher.DECRYPT_MODE, aesKey, ivSpec);
312             // get token value from configuration storage
313             token = thingHandler.getSetting(SETTINGS_TOKEN);
314             logger.debug("[{}] Retrieved token value: {}", debugId, token);
315         } catch (InvalidParameterException e) {
316             return setError(LxErrorCode.INTERNAL_ERROR, "Invalid parameter: " + e.getMessage());
317         } catch (NoSuchAlgorithmException e) {
318             return setError(LxErrorCode.INTERNAL_ERROR, "AES not supported on platform.");
319         } catch (InvalidKeyException | NoSuchPaddingException | InvalidAlgorithmParameterException e) {
320             return setError(LxErrorCode.INTERNAL_ERROR, "AES cipher initialization failed.");
321         }
322         return true;
323     }
324
325     private Cipher getRsaCipher(String key) {
326         try {
327             KeyFactory keyFactory = KeyFactory.getInstance("RSA");
328             String keyString = key.replace("-----BEGIN CERTIFICATE-----", "").replace("-----END CERTIFICATE-----", "");
329             byte[] keyData = Base64.getDecoder().decode(keyString);
330             X509EncodedKeySpec keySpec = new X509EncodedKeySpec(keyData);
331             PublicKey publicKey = keyFactory.generatePublic(keySpec);
332             logger.debug("[{}] Miniserver public key: {}", debugId, publicKey);
333             Cipher cipher = Cipher.getInstance("RSA/ECB/PKCS1Padding");
334             cipher.init(Cipher.PUBLIC_KEY, publicKey);
335             logger.debug("[{}] Initialized RSA public key cipher", debugId);
336             return cipher;
337         } catch (InvalidKeyException | NoSuchAlgorithmException | NoSuchPaddingException | InvalidKeySpecException e) {
338             setError(LxErrorCode.INTERNAL_ERROR, "Exception enabling RSA cipher: " + e.getMessage());
339             return null;
340         }
341     }
342
343     private byte[] generateSessionKey(Cipher rsaCipher) {
344         String key = HexUtils.bytesToHex(aesKey.getEncoded()) + ":" + HexUtils.bytesToHex(initVector);
345         try {
346             byte[] sessionKey = rsaCipher.doFinal(key.getBytes());
347             logger.debug("[{}] Generated session key: {}", debugId, HexUtils.bytesToHex(sessionKey));
348             return sessionKey;
349         } catch (IllegalBlockSizeException | BadPaddingException e) {
350             setError(LxErrorCode.INTERNAL_ERROR, "Exception encrypting session key: " + e.getMessage());
351             return null;
352         }
353     }
354
355     private String hashCredentials(LxResponseKeySalt keySalt) {
356         try {
357             MessageDigest msgDigest = MessageDigest.getInstance("SHA-1");
358             String pwdHashStr = password + ":" + keySalt.salt;
359             byte[] rawData = msgDigest.digest(pwdHashStr.getBytes(StandardCharsets.UTF_8));
360             String pwdHash = HexUtils.bytesToHex(rawData).toUpperCase();
361             logger.debug("[{}] PWDHASH: {}", debugId, pwdHash);
362             return hashString(user + ":" + pwdHash, keySalt.key);
363         } catch (NoSuchAlgorithmException e) {
364             logger.debug("[{}] Error hashing token credentials: {}", debugId, e.getMessage());
365             return null;
366         }
367     }
368
369     private boolean acquireToken() {
370         // Get Miniserver hash key and salt - this command should be encrypted
371         LxResponse resp = socket.sendCmdWithResp(CMD_GET_KEY_AND_SALT + user, true, true);
372         if (!checkResponse(resp)) {
373             return setError(null, "Hash key/salt get failed.");
374         }
375         LxResponseKeySalt keySalt = resp.getValueAs(thingHandler.getGson(), LxResponseKeySalt.class);
376         if (keySalt == null) {
377             return setError(null, "Error parsing hash key/salt json: " + resp.getValueAsString());
378         }
379
380         logger.debug("[{}] Hash key: {}, salt: {}", debugId, keySalt.key, keySalt.salt);
381         // Hash user name, password, key and salt
382         String hash = hashCredentials(keySalt);
383         if (hash == null) {
384             return false;
385         }
386         // Request token
387         String uuid = InstanceUUID.get();
388         resp = socket.sendCmdWithResp(CMD_REQUEST_TOKEN + hash + "/" + user + "/" + TOKEN_PERMISSION + "/"
389                 + (uuid != null ? uuid : "098802e1-02b4-603c-ffffeee000d80cfd") + "/openHAB", true, true);
390         if (!checkResponse(resp)) {
391             return setError(null, "Request token failed.");
392         }
393
394         try {
395             LxResponseToken tokenResponse = parseTokenResponse(resp);
396             if (tokenResponse == null) {
397                 return false;
398             }
399             token = tokenResponse.token;
400             if (token == null) {
401                 return setError(LxErrorCode.INTERNAL_ERROR, "Received null token.");
402             }
403         } catch (JsonParseException e) {
404             return setError(LxErrorCode.INTERNAL_ERROR, "Error parsing token response: " + e.getMessage());
405         }
406
407         persistToken();
408         logger.debug("[{}] Token acquired.", debugId);
409         return true;
410     }
411
412     private boolean useToken() {
413         String hash = hashToken();
414         if (hash == null) {
415             return false;
416         }
417         LxResponse resp = socket.sendCmdWithResp(CMD_AUTH_WITH_TOKEN + hash + "/" + user, true, true);
418         if (!checkResponse(resp)) {
419             if (reason == LxErrorCode.USER_UNAUTHORIZED) {
420                 token = null;
421                 persistToken();
422                 return setError(null, "Enter password to generate a new token.");
423             }
424             return setError(null, "Token-based authentication failed.");
425         }
426         parseTokenResponse(resp);
427         return true;
428     }
429
430     private String hashToken() {
431         LxResponse resp = socket.sendCmdWithResp(CMD_GET_KEY, true, true);
432         if (!checkResponse(resp)) {
433             setError(null, "Get key command failed.");
434             return null;
435         }
436         try {
437             String hashKey = resp.getValueAsString();
438             // here is a difference to the API spec, which says the string to hash is "user:token", but this is "token"
439             String hash = hashString(token, hashKey);
440             if (hash == null) {
441                 setError(null, "Error hashing token.");
442             }
443             return hash;
444         } catch (ClassCastException | IllegalStateException e) {
445             setError(LxErrorCode.INTERNAL_ERROR, "Error parsing Miniserver key.");
446             return null;
447         }
448     }
449
450     private void persistToken() {
451         Map<String, String> properties = new HashMap<>();
452         properties.put(SETTINGS_TOKEN, token);
453         if (token != null) {
454             properties.put(SETTINGS_PASSWORD, null);
455         }
456         thingHandler.setSettings(properties);
457     }
458
459     private LxResponseToken parseTokenResponse(LxResponse response) {
460         LxResponseToken tokenResponse = response.getValueAs(thingHandler.getGson(), LxResponseToken.class);
461         if (tokenResponse == null) {
462             setError(LxErrorCode.INTERNAL_ERROR, "Error parsing token response.");
463             return null;
464         }
465         Boolean unsecurePass = tokenResponse.unsecurePass;
466         if (unsecurePass != null && unsecurePass) {
467             logger.warn("[{}] Unsecure user password on Miniserver.", debugId);
468         }
469         long secondsToExpiry;
470         Integer validUntil = tokenResponse.validUntil;
471         if (validUntil == null) {
472             secondsToExpiry = TOKEN_REFRESH_DEFAULT_SECONDS;
473         } else {
474             // validUntil is the end of token life-span in seconds from 2009/01/01
475             Calendar loxoneCalendar = Calendar.getInstance();
476             loxoneCalendar.clear();
477             loxoneCalendar.set(2009, Calendar.JANUARY, 1);
478             loxoneCalendar.add(Calendar.SECOND, validUntil);
479             Calendar ohCalendar = Calendar.getInstance();
480             secondsToExpiry = (loxoneCalendar.getTimeInMillis() - ohCalendar.getTimeInMillis()) / 1000;
481             if (logger.isDebugEnabled()) {
482                 try {
483                     SimpleDateFormat format = new SimpleDateFormat("yyyy-MM-dd HH:mm");
484                     logger.debug("[{}] Token will expire on: {}.", debugId, format.format(loxoneCalendar.getTime()));
485                 } catch (IllegalArgumentException e) {
486                     logger.debug("[{}] Token will expire in {} days.", debugId,
487                             TimeUnit.SECONDS.toDays(secondsToExpiry));
488                 }
489             }
490             if (secondsToExpiry <= 0) {
491                 logger.warn("[{}] Time to token expiry is negative or zero: {}", debugId, secondsToExpiry);
492                 secondsToExpiry = TOKEN_REFRESH_DEFAULT_SECONDS;
493             } else {
494                 int correction = TOKEN_REFRESH_SECONDS_BEFORE_EXPIRY;
495                 while (secondsToExpiry - correction < 0) {
496                     correction /= 2;
497                 }
498                 secondsToExpiry -= correction;
499             }
500         }
501         scheduleTokenRefresh(secondsToExpiry);
502         return tokenResponse;
503     }
504
505     private void refreshToken() {
506         tokenRefreshLock.lock();
507         try {
508             tokenRefreshTimer = null;
509             String hash = hashToken();
510             if (hash != null) {
511                 LxResponse resp = socket.sendCmdWithResp(CMD_REFRESH_TOKEN + hash + "/" + user, true, true);
512                 if (checkResponse(resp)) {
513                     logger.debug("[{}] Successful token refresh.", debugId);
514                     parseTokenResponse(resp);
515                     return;
516                 }
517             }
518             logger.debug("[{}] Token refresh failed, retrying (retry={}).", debugId, tokenRefreshRetryCount);
519             if (tokenRefreshRetryCount-- > 0) {
520                 scheduleTokenRefresh(TOKEN_REFRESH_RETRY_DELAY_SECONDS);
521             } else {
522                 logger.warn("[{}] All token refresh attempts failed.", debugId);
523             }
524         } finally {
525             tokenRefreshLock.unlock();
526         }
527     }
528
529     private void scheduleTokenRefresh(long delay) {
530         logger.debug("[{}] Setting token refresh in {} days.", debugId, TimeUnit.SECONDS.toDays(delay));
531         tokenRefreshLock.lock();
532         try {
533             tokenRefreshTimer = SCHEDULER.schedule(this::refreshToken, delay, TimeUnit.SECONDS);
534         } finally {
535             tokenRefreshLock.unlock();
536         }
537     }
538
539     private String generateSalt() {
540         byte[] bytes = new byte[SALT_BYTES];
541         secureRandom.nextBytes(bytes);
542         String salt = HexUtils.bytesToHex(bytes);
543         try {
544             salt = URLEncoder.encode(salt, "UTF-8");
545         } catch (UnsupportedEncodingException e) {
546             logger.warn("[{}] Unsupported encoding for salt conversion to URL.", debugId);
547         }
548         saltTimeStamp = timeElapsedInSeconds();
549         saltUseCount = 0;
550         logger.debug("[{}] Generated salt: {}", debugId, salt);
551         return salt;
552     }
553
554     private boolean newSaltNeeded() {
555         return (++saltUseCount > SALT_MAX_USE_COUNT || timeElapsedInSeconds() - saltTimeStamp > SALT_MAX_AGE_SECONDS);
556     }
557
558     private long timeElapsedInSeconds() {
559         return TimeUnit.NANOSECONDS.toSeconds(System.nanoTime());
560     }
561 }