]> git.basschouten.com Git - openhab-addons.git/blob
b9e54ec633d5748f80bf85af00097e674b35ea66
[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         String hashAlg;
90     }
91
92     /**
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.
95      *
96      * @author Pawel Pieczul - initial contribution
97      *
98      */
99     private class LxResponseToken {
100         String token;
101         Integer validUntil;
102         Boolean unsecurePass;
103         @SuppressWarnings("unused")
104         String key;
105         @SuppressWarnings("unused")
106         Integer tokenRights;
107     }
108
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;
114
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
124
125     // AES encryption random initialization vector length
126     private static final int IV_LENGTH_BYTES = 16;
127
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/";
136
137     private static final String SETTINGS_TOKEN = "authToken";
138     private static final String SETTINGS_PASSWORD = "password";
139
140     private SecretKey aesKey;
141     private Cipher aesEncryptCipher;
142     private Cipher aesDecryptCipher;
143     private SecureRandom secureRandom;
144     private String salt;
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;
153
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());
158
159     /**
160      * Create a token-based authentication instance.
161      *
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
167      */
168     LxWsSecurityToken(int debugId, LxServerHandlerApi thingHandler, LxWebSocket socket, String user, String password) {
169         super(debugId, thingHandler, socket, user, password);
170     }
171
172     @Override
173     boolean execute() {
174         logger.debug("[{}] Starting token-based authentication.", debugId);
175         if (!initialize()) {
176             return false;
177         }
178         if ((token == null || token.isEmpty()) && (password == null || password.isEmpty())) {
179             return setError(LxErrorCode.USER_UNAUTHORIZED, "Enter password to acquire token.");
180         }
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);
184         if (resp == null) {
185             return setError(LxErrorCode.COMMUNICATION_ERROR, "Get public key failed - null response.");
186         }
187         // RSA cipher to encrypt our AES-256 key using Miniserver's public key
188         Cipher rsaCipher = getRsaCipher(resp.getValueAsString());
189         if (rsaCipher == null) {
190             return false;
191         }
192         // Generate session key
193         byte[] sessionKey = generateSessionKey(rsaCipher);
194         if (sessionKey == null) {
195             return false;
196         }
197         // Exchange keys
198         resp = socket.sendCmdWithResp(CMD_KEY_EXCHANGE + Base64.getEncoder().encodeToString(sessionKey), true, false);
199         if (!checkResponse(resp)) {
200             return setError(null, "Key exchange failed.");
201         }
202         logger.debug("[{}] Keys exchanged.", debugId);
203         encryptionReady = true;
204
205         if (token == null || token.isEmpty()) {
206             if (!acquireToken()) {
207                 return false;
208             }
209             logger.debug("[{}] Authenticated - acquired new token.", debugId);
210         } else {
211             if (!useToken()) {
212                 return false;
213             }
214             logger.debug("[{}] Authenticated - used stored token.", debugId);
215         }
216
217         return true;
218     }
219
220     @Override
221     public String encrypt(String command) {
222         if (!encryptionReady) {
223             return command;
224         }
225         String str;
226         if (salt != null && newSaltNeeded()) {
227             String prevSalt = salt;
228             salt = generateSalt();
229             str = "nextSalt/" + prevSalt + "/" + salt + "/" + command + "\0";
230         } else {
231             if (salt == null) {
232                 salt = generateSalt();
233             }
234             str = "salt/" + salt + "/" + command + "\0";
235         }
236
237         logger.debug("[{}] Command for encryption: {}", debugId, str);
238         try {
239             String encrypted = Base64.getEncoder()
240                     .encodeToString(aesEncryptCipher.doFinal(str.getBytes(StandardCharsets.UTF_8)));
241             try {
242                 encrypted = URLEncoder.encode(encrypted, "UTF-8");
243             } catch (UnsupportedEncodingException e) {
244                 logger.warn("[{}] Unsupported encoding for encrypted command conversion to URL.", debugId);
245             }
246             return CMD_ENCRYPT_CMD + encrypted;
247         } catch (IllegalBlockSizeException | BadPaddingException e) {
248             logger.warn("[{}] Command encryption failed: {}", debugId, e.getMessage());
249             return command;
250         }
251     }
252
253     @Override
254     public String decryptControl(String control) {
255         String string = control;
256         if (!encryptionReady || !string.startsWith(CMD_ENCRYPT_CMD)) {
257             return string;
258         }
259         string = string.substring(CMD_ENCRYPT_CMD.length());
260         try {
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/[^/]*/[^/]*/", "");
267             return string;
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);
274         }
275         return string;
276     }
277
278     @Override
279     public void cancel() {
280         super.cancel();
281         tokenRefreshLock.lock();
282         try {
283             if (tokenRefreshTimer != null) {
284                 logger.debug("[{}] Cancelling token refresh.", debugId);
285                 tokenRefreshTimer.cancel(true);
286             }
287         } finally {
288             tokenRefreshLock.unlock();
289         }
290     }
291
292     private boolean initialize() {
293         try {
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).");
299             }
300             // generate a random key for the session
301             KeyGenerator aesKeyGen = KeyGenerator.getInstance("AES");
302             aesKeyGen.init(256);
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.");
323         }
324         return true;
325     }
326
327     private Cipher getRsaCipher(String key) {
328         try {
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);
338             return cipher;
339         } catch (InvalidKeyException | NoSuchAlgorithmException | NoSuchPaddingException | InvalidKeySpecException e) {
340             setError(LxErrorCode.INTERNAL_ERROR, "Exception enabling RSA cipher: " + e.getMessage());
341             return null;
342         }
343     }
344
345     private byte[] generateSessionKey(Cipher rsaCipher) {
346         String key = HexUtils.bytesToHex(aesKey.getEncoded()) + ":" + HexUtils.bytesToHex(initVector);
347         try {
348             byte[] sessionKey = rsaCipher.doFinal(key.getBytes());
349             logger.debug("[{}] Generated session key: {}", debugId, HexUtils.bytesToHex(sessionKey));
350             return sessionKey;
351         } catch (IllegalBlockSizeException | BadPaddingException e) {
352             setError(LxErrorCode.INTERNAL_ERROR, "Exception encrypting session key: " + e.getMessage());
353             return null;
354         }
355     }
356
357     private String hashCredentials(LxResponseKeySalt keySalt, boolean sha256) {
358         try {
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());
367             return null;
368         }
369     }
370
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.");
376         }
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());
380         }
381         if ("SHA256".equals(keySalt.hashAlg)) {
382             sha256 = true;
383         }
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);
387         if (hash == null) {
388             return false;
389         }
390         // Request token
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.");
396         }
397
398         try {
399             LxResponseToken tokenResponse = parseTokenResponse(resp);
400             if (tokenResponse == null) {
401                 return false;
402             }
403             token = tokenResponse.token;
404             if (token == null) {
405                 return setError(LxErrorCode.INTERNAL_ERROR, "Received null token.");
406             }
407         } catch (JsonParseException e) {
408             return setError(LxErrorCode.INTERNAL_ERROR, "Error parsing token response: " + e.getMessage());
409         }
410
411         persistToken();
412         logger.debug("[{}] Token acquired.", debugId);
413         return true;
414     }
415
416     private boolean useToken() {
417         String hash = hashToken();
418         if (hash == null) {
419             return false;
420         }
421         LxResponse resp = socket.sendCmdWithResp(CMD_AUTH_WITH_TOKEN + hash + "/" + user, true, true);
422         if (!checkResponse(resp)) {
423             if (reason == LxErrorCode.USER_UNAUTHORIZED) {
424                 token = null;
425                 persistToken();
426                 return setError(null, "Enter password to generate a new token.");
427             }
428             return setError(null, "Token-based authentication failed.");
429         }
430         parseTokenResponse(resp);
431         return true;
432     }
433
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.");
438             return null;
439         }
440         try {
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);
444             if (hash == null) {
445                 setError(null, "Error hashing token.");
446             }
447             return hash;
448         } catch (ClassCastException | IllegalStateException e) {
449             setError(LxErrorCode.INTERNAL_ERROR, "Error parsing Miniserver key.");
450             return null;
451         }
452     }
453
454     private void persistToken() {
455         Map<String, String> properties = new HashMap<>();
456         properties.put(SETTINGS_TOKEN, token);
457         if (token != null) {
458             properties.put(SETTINGS_PASSWORD, null);
459         }
460         thingHandler.setSettings(properties);
461     }
462
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.");
467             return null;
468         }
469         Boolean unsecurePass = tokenResponse.unsecurePass;
470         if (unsecurePass != null && unsecurePass) {
471             logger.warn("[{}] Unsecure user password on Miniserver.", debugId);
472         }
473         long secondsToExpiry;
474         Integer validUntil = tokenResponse.validUntil;
475         if (validUntil == null) {
476             secondsToExpiry = TOKEN_REFRESH_DEFAULT_SECONDS;
477         } else {
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()) {
486                 try {
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));
492                 }
493             }
494             if (secondsToExpiry <= 0) {
495                 logger.warn("[{}] Time to token expiry is negative or zero: {}", debugId, secondsToExpiry);
496                 secondsToExpiry = TOKEN_REFRESH_DEFAULT_SECONDS;
497             } else {
498                 int correction = TOKEN_REFRESH_SECONDS_BEFORE_EXPIRY;
499                 while (secondsToExpiry - correction < 0) {
500                     correction /= 2;
501                 }
502                 secondsToExpiry -= correction;
503             }
504         }
505         scheduleTokenRefresh(secondsToExpiry);
506         return tokenResponse;
507     }
508
509     private void refreshToken() {
510         tokenRefreshLock.lock();
511         try {
512             tokenRefreshTimer = null;
513             String hash = hashToken();
514             if (hash != null) {
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);
519                     return;
520                 }
521             }
522             logger.debug("[{}] Token refresh failed, retrying (retry={}).", debugId, tokenRefreshRetryCount);
523             if (tokenRefreshRetryCount-- > 0) {
524                 scheduleTokenRefresh(TOKEN_REFRESH_RETRY_DELAY_SECONDS);
525             } else {
526                 logger.warn("[{}] All token refresh attempts failed.", debugId);
527             }
528         } finally {
529             tokenRefreshLock.unlock();
530         }
531     }
532
533     private void scheduleTokenRefresh(long delay) {
534         logger.debug("[{}] Setting token refresh in {} days.", debugId, TimeUnit.SECONDS.toDays(delay));
535         tokenRefreshLock.lock();
536         try {
537             tokenRefreshTimer = SCHEDULER.schedule(this::refreshToken, delay, TimeUnit.SECONDS);
538         } finally {
539             tokenRefreshLock.unlock();
540         }
541     }
542
543     private String generateSalt() {
544         byte[] bytes = new byte[SALT_BYTES];
545         secureRandom.nextBytes(bytes);
546         String salt = HexUtils.bytesToHex(bytes);
547         try {
548             salt = URLEncoder.encode(salt, "UTF-8");
549         } catch (UnsupportedEncodingException e) {
550             logger.warn("[{}] Unsupported encoding for salt conversion to URL.", debugId);
551         }
552         saltTimeStamp = timeElapsedInSeconds();
553         saltUseCount = 0;
554         logger.debug("[{}] Generated salt: {}", debugId, salt);
555         return salt;
556     }
557
558     private boolean newSaltNeeded() {
559         return (++saltUseCount > SALT_MAX_USE_COUNT || timeElapsedInSeconds() - saltTimeStamp > SALT_MAX_AGE_SECONDS);
560     }
561
562     private long timeElapsedInSeconds() {
563         return TimeUnit.NANOSECONDS.toSeconds(System.nanoTime());
564     }
565 }