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.loxone.internal.security;
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;
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;
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;
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;
57 import com.google.gson.JsonParseException;
60 * A token-based authentication algorithm with AES-256 encryption and decryption.
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.
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
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.
75 * @author Pawel Pieczul - initial contribution
78 class LxWsSecurityToken extends LxWsSecurity {
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.
83 * @author Pawel Pieczul - initial contribution
86 private class LxResponseKeySalt {
93 * A sub-response value structure that is received as a response to token request or token update command sent to
94 * the Miniserver during authentication procedure.
96 * @author Pawel Pieczul - initial contribution
99 private class LxResponseToken {
102 Boolean unsecurePass;
103 @SuppressWarnings("unused")
105 @SuppressWarnings("unused")
109 // length of salt used for encrypting commands
110 private static final int SALT_BYTES = 16;
111 // after salt aged or reached max use count, a new salt will be generated
112 private static final int SALT_MAX_AGE_SECONDS = 60 * 60;
113 private static final int SALT_MAX_USE_COUNT = 30;
115 // defined by Loxone API, value 4 gives longest token expiration time
116 private static final int TOKEN_PERMISSION = 4; // 2=web, 4=app
117 // number of attempts for token refresh and delay between them
118 private static final int TOKEN_REFRESH_RETRY_COUNT = 5;
119 private static final int TOKEN_REFRESH_RETRY_DELAY_SECONDS = 10;
120 // token will be refreshed 1 day before its expiration date
121 private static final int TOKEN_REFRESH_SECONDS_BEFORE_EXPIRY = 24 * 60 * 60; // 1 day
122 // if can't determine token expiration date, it will be refreshed after 2 days
123 private static final int TOKEN_REFRESH_DEFAULT_SECONDS = 2 * 24 * 60 * 60; // 2 days
125 // AES encryption random initialization vector length
126 private static final int IV_LENGTH_BYTES = 16;
128 private static final String CMD_GET_KEY_AND_SALT = "jdev/sys/getkey2/";
129 private static final String CMD_GET_PUBLIC_KEY = "jdev/sys/getPublicKey";
130 private static final String CMD_KEY_EXCHANGE = "jdev/sys/keyexchange/";
131 private static final String CMD_REQUEST_TOKEN = "jdev/sys/gettoken/";
132 private static final String CMD_GET_KEY = "jdev/sys/getkey";
133 private static final String CMD_AUTH_WITH_TOKEN = "authwithtoken/";
134 private static final String CMD_REFRESH_TOKEN = "jdev/sys/refreshtoken/";
135 private static final String CMD_ENCRYPT_CMD = "jdev/sys/enc/";
137 private static final String SETTINGS_TOKEN = "authToken";
138 private static final String SETTINGS_PASSWORD = "password";
140 private SecretKey aesKey;
141 private Cipher aesEncryptCipher;
142 private Cipher aesDecryptCipher;
143 private SecureRandom secureRandom;
145 private int saltUseCount;
146 private long saltTimeStamp;
147 private boolean encryptionReady = false;
148 private String token;
149 private int tokenRefreshRetryCount;
150 private ScheduledFuture<?> tokenRefreshTimer;
151 private final Lock tokenRefreshLock = new ReentrantLock();
152 private boolean sha256 = false;
154 private final byte[] initVector = new byte[IV_LENGTH_BYTES];
155 private final Logger logger = LoggerFactory.getLogger(LxWsSecurityToken.class);
156 private static final ScheduledExecutorService SCHEDULER = ThreadPoolManager
157 .getScheduledPool(LxWsSecurityToken.class.getName());
160 * Create a token-based authentication instance.
162 * @param debugId instance of the client used for debugging purposes only
163 * @param thingHandler API to the thing handler
164 * @param socket websocket to perform communication with Miniserver
165 * @param user user to authenticate
166 * @param password password to authenticate
168 LxWsSecurityToken(int debugId, LxServerHandlerApi thingHandler, LxWebSocket socket, String user, String password) {
169 super(debugId, thingHandler, socket, user, password);
174 logger.debug("[{}] Starting token-based authentication.", debugId);
178 if ((token == null || token.isEmpty()) && (password == null || password.isEmpty())) {
179 return setError(LxErrorCode.USER_UNAUTHORIZED, "Enter password to acquire token.");
181 // Get Miniserver's public key - must be over http, not websocket
182 String msg = socket.httpGet(CMD_GET_PUBLIC_KEY);
183 LxResponse resp = socket.getResponse(msg);
185 return setError(LxErrorCode.COMMUNICATION_ERROR, "Get public key failed - null response.");
187 // RSA cipher to encrypt our AES-256 key using Miniserver's public key
188 Cipher rsaCipher = getRsaCipher(resp.getValueAsString());
189 if (rsaCipher == null) {
192 // Generate session key
193 byte[] sessionKey = generateSessionKey(rsaCipher);
194 if (sessionKey == null) {
198 resp = socket.sendCmdWithResp(CMD_KEY_EXCHANGE + Base64.getEncoder().encodeToString(sessionKey), true, false);
199 if (!checkResponse(resp)) {
200 return setError(null, "Key exchange failed.");
202 logger.debug("[{}] Keys exchanged.", debugId);
203 encryptionReady = true;
205 if (token == null || token.isEmpty()) {
206 if (!acquireToken()) {
209 logger.debug("[{}] Authenticated - acquired new token.", debugId);
214 logger.debug("[{}] Authenticated - used stored token.", debugId);
221 public String encrypt(String command) {
222 if (!encryptionReady) {
226 if (salt != null && newSaltNeeded()) {
227 String prevSalt = salt;
228 salt = generateSalt();
229 str = "nextSalt/" + prevSalt + "/" + salt + "/" + command + "\0";
232 salt = generateSalt();
234 str = "salt/" + salt + "/" + command + "\0";
237 logger.debug("[{}] Command for encryption: {}", debugId, str);
239 String encrypted = Base64.getEncoder()
240 .encodeToString(aesEncryptCipher.doFinal(str.getBytes(StandardCharsets.UTF_8)));
242 encrypted = URLEncoder.encode(encrypted, "UTF-8");
243 } catch (UnsupportedEncodingException e) {
244 logger.warn("[{}] Unsupported encoding for encrypted command conversion to URL.", debugId);
246 return CMD_ENCRYPT_CMD + encrypted;
247 } catch (IllegalBlockSizeException | BadPaddingException e) {
248 logger.warn("[{}] Command encryption failed: {}", debugId, e.getMessage());
254 public String decryptControl(String control) {
255 String string = control;
256 if (!encryptionReady || !string.startsWith(CMD_ENCRYPT_CMD)) {
259 string = string.substring(CMD_ENCRYPT_CMD.length());
261 byte[] bytes = Base64.getDecoder().decode(string);
262 bytes = aesDecryptCipher.doFinal(bytes);
263 string = new String(bytes, "UTF-8");
264 string = string.replaceAll("\0+.*$", "");
265 string = string.replaceFirst("^salt/[^/]*/", "");
266 string = string.replaceFirst("^nextSalt/[^/]*/[^/]*/", "");
268 } catch (IllegalArgumentException e) {
269 logger.debug("[{}] Failed to decode base64 string: {}", debugId, string);
270 } catch (IllegalBlockSizeException | BadPaddingException e) {
271 logger.warn("[{}] Command decryption failed: {}", debugId, e.getMessage());
272 } catch (UnsupportedEncodingException e) {
273 logger.warn("[{}] Unsupported encoding for decrypted bytes to string conversion.", debugId);
279 public void cancel() {
281 tokenRefreshLock.lock();
283 if (tokenRefreshTimer != null) {
284 logger.debug("[{}] Cancelling token refresh.", debugId);
285 tokenRefreshTimer.cancel(true);
288 tokenRefreshLock.unlock();
292 private boolean initialize() {
294 encryptionReady = false;
295 tokenRefreshRetryCount = TOKEN_REFRESH_RETRY_COUNT;
296 if (Cipher.getMaxAllowedKeyLength("AES") < 256) {
297 return setError(LxErrorCode.INTERNAL_ERROR,
298 "Enable Java cryptography unlimited strength (see binding doc).");
300 // generate a random key for the session
301 KeyGenerator aesKeyGen = KeyGenerator.getInstance("AES");
303 aesKey = aesKeyGen.generateKey();
304 // generate an initialization vector
305 secureRandom = new SecureRandom();
306 secureRandom.nextBytes(initVector);
307 IvParameterSpec ivSpec = new IvParameterSpec(initVector);
308 // initialize aes cipher for command encryption
309 aesEncryptCipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
310 aesEncryptCipher.init(Cipher.ENCRYPT_MODE, aesKey, ivSpec);
311 // initialize aes cipher for response decryption
312 aesDecryptCipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
313 aesDecryptCipher.init(Cipher.DECRYPT_MODE, aesKey, ivSpec);
314 // get token value from configuration storage
315 token = thingHandler.getSetting(SETTINGS_TOKEN);
316 logger.debug("[{}] Retrieved token value: {}", debugId, token);
317 } catch (InvalidParameterException e) {
318 return setError(LxErrorCode.INTERNAL_ERROR, "Invalid parameter: " + e.getMessage());
319 } catch (NoSuchAlgorithmException e) {
320 return setError(LxErrorCode.INTERNAL_ERROR, "AES not supported on platform.");
321 } catch (InvalidKeyException | NoSuchPaddingException | InvalidAlgorithmParameterException e) {
322 return setError(LxErrorCode.INTERNAL_ERROR, "AES cipher initialization failed.");
327 private Cipher getRsaCipher(String key) {
329 KeyFactory keyFactory = KeyFactory.getInstance("RSA");
330 String keyString = key.replace("-----BEGIN CERTIFICATE-----", "").replace("-----END CERTIFICATE-----", "");
331 byte[] keyData = Base64.getDecoder().decode(keyString);
332 X509EncodedKeySpec keySpec = new X509EncodedKeySpec(keyData);
333 PublicKey publicKey = keyFactory.generatePublic(keySpec);
334 logger.debug("[{}] Miniserver public key: {}", debugId, publicKey);
335 Cipher cipher = Cipher.getInstance("RSA/ECB/PKCS1Padding");
336 cipher.init(Cipher.PUBLIC_KEY, publicKey);
337 logger.debug("[{}] Initialized RSA public key cipher", debugId);
339 } catch (InvalidKeyException | NoSuchAlgorithmException | NoSuchPaddingException | InvalidKeySpecException e) {
340 setError(LxErrorCode.INTERNAL_ERROR, "Exception enabling RSA cipher: " + e.getMessage());
345 private byte[] generateSessionKey(Cipher rsaCipher) {
346 String key = HexUtils.bytesToHex(aesKey.getEncoded()) + ":" + HexUtils.bytesToHex(initVector);
348 byte[] sessionKey = rsaCipher.doFinal(key.getBytes());
349 logger.debug("[{}] Generated session key: {}", debugId, HexUtils.bytesToHex(sessionKey));
351 } catch (IllegalBlockSizeException | BadPaddingException e) {
352 setError(LxErrorCode.INTERNAL_ERROR, "Exception encrypting session key: " + e.getMessage());
357 private String hashCredentials(LxResponseKeySalt keySalt, boolean sha256) {
359 MessageDigest msgDigest = MessageDigest.getInstance(sha256 ? "SHA-256" : "SHA-1");
360 String pwdHashStr = password + ":" + keySalt.salt;
361 byte[] rawData = msgDigest.digest(pwdHashStr.getBytes(StandardCharsets.UTF_8));
362 String pwdHash = HexUtils.bytesToHex(rawData).toUpperCase();
363 logger.debug("[{}] PWDHASH: {}", debugId, pwdHash);
364 return hashString(user + ":" + pwdHash, keySalt.key, sha256);
365 } catch (NoSuchAlgorithmException e) {
366 logger.debug("[{}] Error hashing token credentials: {}", debugId, e.getMessage());
371 private boolean acquireToken() {
372 // Get Miniserver hash key and salt - this command should be encrypted
373 LxResponse resp = socket.sendCmdWithResp(CMD_GET_KEY_AND_SALT + user, true, true);
374 if (!checkResponse(resp)) {
375 return setError(null, "Hash key/salt get failed.");
377 LxResponseKeySalt keySalt = resp.getValueAs(thingHandler.getGson(), LxResponseKeySalt.class);
378 if (keySalt == null) {
379 return setError(null, "Error parsing hash key/salt json: " + resp.getValueAsString());
381 if ("SHA256".equals(keySalt.hashAlg)) {
384 logger.debug("[{}] Hash key: {}, salt: {}", debugId, keySalt.key, keySalt.salt);
385 // Hash user name, password, key and salt
386 String hash = hashCredentials(keySalt, sha256);
391 String uuid = InstanceUUID.get();
392 resp = socket.sendCmdWithResp(CMD_REQUEST_TOKEN + hash + "/" + user + "/" + TOKEN_PERMISSION + "/"
393 + (uuid != null ? uuid : "098802e1-02b4-603c-ffffeee000d80cfd") + "/openHAB", true, true);
394 if (!checkResponse(resp)) {
395 return setError(null, "Request token failed.");
399 LxResponseToken tokenResponse = parseTokenResponse(resp);
400 if (tokenResponse == null) {
403 token = tokenResponse.token;
405 return setError(LxErrorCode.INTERNAL_ERROR, "Received null token.");
407 } catch (JsonParseException e) {
408 return setError(LxErrorCode.INTERNAL_ERROR, "Error parsing token response: " + e.getMessage());
412 logger.debug("[{}] Token acquired.", debugId);
416 private boolean useToken() {
417 String hash = hashToken();
421 LxResponse resp = socket.sendCmdWithResp(CMD_AUTH_WITH_TOKEN + hash + "/" + user, true, true);
422 if (!checkResponse(resp)) {
423 if (reason == LxErrorCode.USER_UNAUTHORIZED) {
426 return setError(null, "Enter password to generate a new token.");
428 return setError(null, "Token-based authentication failed.");
430 parseTokenResponse(resp);
434 private String hashToken() {
435 LxResponse resp = socket.sendCmdWithResp(CMD_GET_KEY, true, true);
436 if (!checkResponse(resp)) {
437 setError(null, "Get key command failed.");
441 String hashKey = resp.getValueAsString();
442 // here is a difference to the API spec, which says the string to hash is "user:token", but this is "token"
443 String hash = hashString(token, hashKey, sha256);
445 setError(null, "Error hashing token.");
448 } catch (ClassCastException | IllegalStateException e) {
449 setError(LxErrorCode.INTERNAL_ERROR, "Error parsing Miniserver key.");
454 private void persistToken() {
455 Map<String, String> properties = new HashMap<>();
456 properties.put(SETTINGS_TOKEN, token);
458 properties.put(SETTINGS_PASSWORD, null);
460 thingHandler.setSettings(properties);
463 private LxResponseToken parseTokenResponse(LxResponse response) {
464 LxResponseToken tokenResponse = response.getValueAs(thingHandler.getGson(), LxResponseToken.class);
465 if (tokenResponse == null) {
466 setError(LxErrorCode.INTERNAL_ERROR, "Error parsing token response.");
469 Boolean unsecurePass = tokenResponse.unsecurePass;
470 if (unsecurePass != null && unsecurePass) {
471 logger.warn("[{}] Unsecure user password on Miniserver.", debugId);
473 long secondsToExpiry;
474 Integer validUntil = tokenResponse.validUntil;
475 if (validUntil == null) {
476 secondsToExpiry = TOKEN_REFRESH_DEFAULT_SECONDS;
478 // validUntil is the end of token life-span in seconds from 2009/01/01
479 Calendar loxoneCalendar = Calendar.getInstance();
480 loxoneCalendar.clear();
481 loxoneCalendar.set(2009, Calendar.JANUARY, 1);
482 loxoneCalendar.add(Calendar.SECOND, validUntil);
483 Calendar ohCalendar = Calendar.getInstance();
484 secondsToExpiry = (loxoneCalendar.getTimeInMillis() - ohCalendar.getTimeInMillis()) / 1000;
485 if (logger.isDebugEnabled()) {
487 SimpleDateFormat format = new SimpleDateFormat("yyyy-MM-dd HH:mm");
488 logger.debug("[{}] Token will expire on: {}.", debugId, format.format(loxoneCalendar.getTime()));
489 } catch (IllegalArgumentException e) {
490 logger.debug("[{}] Token will expire in {} days.", debugId,
491 TimeUnit.SECONDS.toDays(secondsToExpiry));
494 if (secondsToExpiry <= 0) {
495 logger.warn("[{}] Time to token expiry is negative or zero: {}", debugId, secondsToExpiry);
496 secondsToExpiry = TOKEN_REFRESH_DEFAULT_SECONDS;
498 int correction = TOKEN_REFRESH_SECONDS_BEFORE_EXPIRY;
499 while (secondsToExpiry - correction < 0) {
502 secondsToExpiry -= correction;
505 scheduleTokenRefresh(secondsToExpiry);
506 return tokenResponse;
509 private void refreshToken() {
510 tokenRefreshLock.lock();
512 tokenRefreshTimer = null;
513 String hash = hashToken();
515 LxResponse resp = socket.sendCmdWithResp(CMD_REFRESH_TOKEN + hash + "/" + user, true, true);
516 if (checkResponse(resp)) {
517 logger.debug("[{}] Successful token refresh.", debugId);
518 parseTokenResponse(resp);
522 logger.debug("[{}] Token refresh failed, retrying (retry={}).", debugId, tokenRefreshRetryCount);
523 if (tokenRefreshRetryCount-- > 0) {
524 scheduleTokenRefresh(TOKEN_REFRESH_RETRY_DELAY_SECONDS);
526 logger.warn("[{}] All token refresh attempts failed.", debugId);
529 tokenRefreshLock.unlock();
533 private void scheduleTokenRefresh(long delay) {
534 logger.debug("[{}] Setting token refresh in {} days.", debugId, TimeUnit.SECONDS.toDays(delay));
535 tokenRefreshLock.lock();
537 tokenRefreshTimer = SCHEDULER.schedule(this::refreshToken, delay, TimeUnit.SECONDS);
539 tokenRefreshLock.unlock();
543 private String generateSalt() {
544 byte[] bytes = new byte[SALT_BYTES];
545 secureRandom.nextBytes(bytes);
546 String salt = HexUtils.bytesToHex(bytes);
548 salt = URLEncoder.encode(salt, "UTF-8");
549 } catch (UnsupportedEncodingException e) {
550 logger.warn("[{}] Unsupported encoding for salt conversion to URL.", debugId);
552 saltTimeStamp = timeElapsedInSeconds();
554 logger.debug("[{}] Generated salt: {}", debugId, salt);
558 private boolean newSaltNeeded() {
559 return (++saltUseCount > SALT_MAX_USE_COUNT || timeElapsedInSeconds() - saltTimeStamp > SALT_MAX_AGE_SECONDS);
562 private long timeElapsedInSeconds() {
563 return TimeUnit.NANOSECONDS.toSeconds(System.nanoTime());