Repository: wultra/powerauth-crypto (java)

Issue:
Add support for Time-Based One-Time Password (TOTP)
https://www.rfc-editor.org/rfc/rfc6238

Pull request:
Fix #471: Add support for Time-Based One-Time Password (TOTP)

Patch:
diff --git a/pom.xml b/pom.xml
index f6643bdf..6f01d258 100644
--- a/pom.xml
+++ b/pom.xml
@@ -79,7 +79,7 @@
         <maven-source-plugin.version>3.2.1</maven-source-plugin.version>
         <guava.version>31.1-jre</guava.version>
         <slf4j.version>2.0.7</slf4j.version>
-        <junit.version>5.9.1</junit.version>
+        <junit.version>5.9.2</junit.version>
     </properties>
 
     <dependencyManagement>
@@ -107,6 +107,12 @@
                 <artifactId>junit-jupiter-engine</artifactId>
                 <version>${junit.version}</version>
             </dependency>
+
+            <dependency>
+                <groupId>org.junit.jupiter</groupId>
+                <artifactId>junit-jupiter-params</artifactId>
+                <version>${junit.version}</version>
+            </dependency>
         </dependencies>
     </dependencyManagement>
 
diff --git a/powerauth-java-crypto/pom.xml b/powerauth-java-crypto/pom.xml
index d174af0b..f5a2897d 100644
--- a/powerauth-java-crypto/pom.xml
+++ b/powerauth-java-crypto/pom.xml
@@ -43,6 +43,11 @@
 			<artifactId>junit-jupiter-engine</artifactId>
 			<scope>test</scope>
 		</dependency>
+		<dependency>
+			<groupId>org.junit.jupiter</groupId>
+			<artifactId>junit-jupiter-params</artifactId>
+			<scope>test</scope>
+		</dependency>
 		<dependency>
 			<groupId>com.fasterxml.jackson.core</groupId>
 			<artifactId>jackson-databind</artifactId>
diff --git a/powerauth-java-crypto/src/main/java/io/getlime/security/powerauth/crypto/lib/totp/Totp.java b/powerauth-java-crypto/src/main/java/io/getlime/security/powerauth/crypto/lib/totp/Totp.java
new file mode 100644
index 00000000..420da575
--- /dev/null
+++ b/powerauth-java-crypto/src/main/java/io/getlime/security/powerauth/crypto/lib/totp/Totp.java
@@ -0,0 +1,271 @@
+/*
+ * PowerAuth Crypto Library
+ * Copyright 2023 Wultra s.r.o.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package io.getlime.security.powerauth.crypto.lib.totp;
+
+import com.google.common.base.Strings;
+import io.getlime.security.powerauth.crypto.lib.model.exception.CryptoProviderException;
+import org.bouncycastle.util.Arrays;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import javax.crypto.Mac;
+import javax.crypto.spec.SecretKeySpec;
+import java.security.GeneralSecurityException;
+import java.time.LocalDateTime;
+import java.time.ZoneOffset;
+import java.util.HexFormat;
+
+/**
+ * TOTP: Time-Based One-Time Password Algorithm according to <a href="https://www.rfc-editor.org/rfc/rfc6238">RFC 6238</a>.
+ *
+ * @author Lubos Racansky, lubos.racansky@wultra.com
+ */
+public final class Totp {
+
+    private static final Logger logger = LoggerFactory.getLogger(Totp.class);
+
+    /**
+     * Default time-step size of 30 seconds recommended by RFC. The value is selected as a balance between security and usability.
+     */
+    private static final int TIME_STEP_X = 30;
+
+    private Totp() {
+        throw new IllegalStateException("Should not be instantiated");
+    }
+
+    private static final int[] DIGITS_POWER
+            // 0  1   2    3      4       5        6          7           8
+            = {1, 10, 100, 1_000, 10_000, 100_000, 1_000_000, 10_000_000, 100_000_000};
+
+    /**
+     * Generates a TOTP value for the given set of parameters using HmacSHA256 algorithm.
+     *
+     * @param key           the shared secret
+     * @param localDateTime date time
+     * @param digitsNumber  number of digits to return
+     * @return a numeric String in base 10 that includes truncation digits
+     * @throws CryptoProviderException in case of any crypto error
+     */
+    public static byte[] generateTotpSha256(final byte[] key, final LocalDateTime localDateTime, final int digitsNumber) throws CryptoProviderException {
+        return generateTotp(key, countTimeSteps(localDateTime), digitsNumber, Algorithm.HMAC_SHA256.code);
+    }
+
+    /**
+     * Generates a TOTP value for the given set of parameters using HmacSHA512 algorithm.
+     *
+     * @param key           the shared secret
+     * @param localDateTime date time
+     * @param digitsNumber  number of digits to return
+     * @return a numeric String in base 10 that includes truncation digits
+     * @throws CryptoProviderException in case of any crypto error
+     */
+    public static byte[] generateTotpSha512(final byte[] key, final LocalDateTime localDateTime, final int digitsNumber) throws CryptoProviderException {
+        return generateTotp(key, countTimeSteps(localDateTime), digitsNumber, Algorithm.HMAC_SHA512.code);
+    }
+
+    /**
+     * Validate a TOTP value for the given set of parameters using HmacSHA256 algorithm. Validates one time step backward.
+     *
+     * @param otp           TOTP to validate
+     * @param key           the shared secret
+     * @param localDateTime date time
+     * @param digitsNumber  expected length of the TOTP
+     * @return true if OTP is valid
+     * @throws CryptoProviderException in case of any crypto error
+     * @see #validateTotpSha256(byte[], byte[], LocalDateTime, int)
+     */
+    public static boolean validateTotpSha256(final byte[] otp, final byte[] key, final LocalDateTime localDateTime, final int digitsNumber) throws CryptoProviderException {
+        return validateTotpSha256(otp, key, localDateTime, digitsNumber, 1);
+    }
+
+    /**
+     * Validate a TOTP value for the given set of parameters using HmacSHA256 algorithm.
+     *
+     * @param otp           TOTP to validate
+     * @param key           the shared secret
+     * @param localDateTime date time
+     * @param digitsNumber  expected length of the TOTP
+     * @param steps         number of backward time steps allowed to validate
+     * @return true if OTP is valid
+     * @throws CryptoProviderException in case of any crypto error
+     */
+    public static boolean validateTotpSha256(final byte[] otp, final byte[] key, final LocalDateTime localDateTime, final int digitsNumber, final int steps) throws CryptoProviderException {
+        return validateTotp(otp, key, localDateTime, digitsNumber, steps, Algorithm.HMAC_SHA256.code);
+    }
+
+    /**
+     * Validate a TOTP value for the given set of parameters using HmacSHA512 algorithm. Validates one time step backward.
+     *
+     * @param otp           TOTP to validate
+     * @param key           the shared secret
+     * @param localDateTime date time
+     * @param digitsNumber  expected length of the TOTP
+     * @return true if OTP is valid
+     * @throws CryptoProviderException in case of any crypto error
+     * @see #validateTotpSha512(byte[], byte[], LocalDateTime, int, int)
+     */
+    public static boolean validateTotpSha512(final byte[] otp, final byte[] key, final LocalDateTime localDateTime, final int digitsNumber) throws CryptoProviderException {
+        return validateTotpSha512(otp, key, localDateTime, digitsNumber, 1);
+    }
+
+    /**
+     * Validate a TOTP value for the given set of parameters using HmacSHA512 algorithm.
+     *
+     * @param otp           TOTP to validate
+     * @param key           the shared secret
+     * @param localDateTime date time
+     * @param digitsNumber  expected length of the TOTP
+     * @param steps         number of backward time steps allowed to validate
+     * @return true if OTP is valid
+     * @throws CryptoProviderException in case of any crypto error
+     */
+    public static boolean validateTotpSha512(final byte[] otp, final byte[] key, final LocalDateTime localDateTime, final int digitsNumber, final int steps) throws CryptoProviderException {
+        return validateTotp(otp, key, localDateTime, digitsNumber, steps, Algorithm.HMAC_SHA512.code);
+    }
+
+    /**
+     * Validate a TOTP value for the given set of parameters.
+     *
+     * @param otp           TOTP to validate
+     * @param key           the shared secret
+     * @param localDateTime date time
+     * @param digitsNumber  expected length of the TOTP
+     * @param backwardSteps number of backward time steps allowed to validate
+     * @param algorithm     the algorithm to use
+     * @return true if OTP is valid
+     * @throws CryptoProviderException in case of any crypto error
+     */
+    private static boolean validateTotp(final byte[] otp, final byte[] key, final LocalDateTime localDateTime, final int digitsNumber, final int backwardSteps, final String algorithm) throws CryptoProviderException {
+        logger.debug("Validating TOTP for localDateTime={}, algorithm={}, steps={}", localDateTime, algorithm, backwardSteps);
+
+        if (otp == null) {
+            throw new CryptoProviderException("Otp is mandatory");
+        }
+
+        if (otp.length != digitsNumber) {
+            throw new CryptoProviderException("Otp length %d is different from expected %d".formatted(otp.length, digitsNumber));
+        }
+
+        if (backwardSteps < 0) {
+            throw new CryptoProviderException("Steps must not be negative number");
+        }
+
+        final long currentTimeStep = countTimeSteps(localDateTime);
+        for (int i = 0; i <= backwardSteps; i++) {
+            logger.debug("Validating TOTP for localDateTime={}, algorithm={}, step={} out of allowed backward steps={}", localDateTime, algorithm, i, backwardSteps);
+            final long step = currentTimeStep - i;
+            final byte[] expectedOtp = generateTotp(key, step, otp.length, algorithm);
+            if (Arrays.constantTimeAreEqual(expectedOtp, otp)) {
+                return true;
+            }
+        }
+
+        return false;
+    }
+
+    /**
+     * Generates a TOTP value for the given set of parameters.
+     *
+     * @param key          the shared secret
+     * @param timeStep     number of time step
+     * @param digitsNumber number of digits to return
+     * @param algorithm    the algorithm to use
+     * @return a numeric String in base 10 that includes truncation digits
+     * @throws CryptoProviderException in case of any crypto error
+     */
+    private static byte[] generateTotp(final byte[] key, final long timeStep, final int digitsNumber, final String algorithm) throws CryptoProviderException {
+        logger.debug("Generating TOTP for timeStep={}, algorithm={}", timeStep, algorithm);
+
+        if (key == null) {
+            throw new CryptoProviderException("Key is mandatory");
+        }
+
+        if (algorithm == null) {
+            throw new CryptoProviderException("Algorithm is mandatory");
+        }
+
+        if (digitsNumber <= 0 || digitsNumber >= DIGITS_POWER.length) {
+            throw new CryptoProviderException("DigitsNumber must be positive number and smaller than " + DIGITS_POWER.length);
+        }
+
+        // Using the counter
+        // First 8 bytes are for the movingFactor
+        // Compliant with base RFC4226 (HOTP)
+        final String hexPaddedTimeStep = padWithZeros(Long.toHexString(timeStep), 16);
+        
+        final byte[] data = HexFormat.of().parseHex(hexPaddedTimeStep);
+        final byte[] hash = computeHash(algorithm, key, data);
+
+        // Last four bits of the hash is offset (last byte masked by 0xf)
+        final int offset = hash[hash.length - 1] & 0xf;
+
+        // The reason for masking the most significant bit (0x7f) is to avoid confusion about signed vs. unsigned modulo computations.
+        // Different processors perform these operations differently, and masking out the signed bit removes all ambiguity.
+        final int binaryCode = ((hash[offset] & 0x7f) << 24) |
+                ((hash[offset + 1] & 0xff) << 16) |
+                ((hash[offset + 2] & 0xff) << 8) |
+                (hash[offset + 3] & 0xff);
+
+        final int otp = binaryCode % DIGITS_POWER[digitsNumber];
+
+        return padWithZeros(Integer.toString(otp), digitsNumber).getBytes();
+    }
+
+    private static long countTimeSteps(final LocalDateTime localDateTime) throws CryptoProviderException {
+        if (localDateTime == null) {
+            throw new CryptoProviderException("LocalDateTime is mandatory");
+        }
+
+        return localDateTime.toEpochSecond(ZoneOffset.UTC) / TIME_STEP_X;
+    }
+
+    private static String padWithZeros(final String source, final int length) {
+        return Strings.padStart(source, length, '0');
+    }
+
+    /**
+     * Computes a Hashed Message Authentication Code with the give hash algorithm as a parameter.
+     *
+     * @param algorithm the algorithm
+     * @param keyBytes  the bytes to use for the HMAC key
+     * @param data      data to be hashed
+     * @throws CryptoProviderException in case of any crypto error
+     */
+    @SuppressWarnings("java:S2139") // NOSONAR We need to be sure that the exception is logged, better twice than never
+    private static byte[] computeHash(final String algorithm, final byte[] keyBytes, final byte[] data) throws CryptoProviderException {
+        try {
+            final Mac hmac = Mac.getInstance(algorithm);
+            final SecretKeySpec macKey = new SecretKeySpec(keyBytes, "RAW");
+            hmac.init(macKey);
+            return hmac.doFinal(data);
+        } catch (GeneralSecurityException e) {
+            logger.error("Problem to compute hash for algorithm={}", algorithm, e);
+            throw new CryptoProviderException("Problem to compute hash for algorithm=" + algorithm, e);
+        }
+    }
+
+    private enum Algorithm {
+        HMAC_SHA256("HmacSHA256"),
+        HMAC_SHA512("HmacSHA512");
+
+        private final String code;
+
+        Algorithm(String code) {
+            this.code = code;
+        }
+    }
+}


Test patch:
diff --git a/powerauth-java-crypto/src/test/java/io/getlime/security/powerauth/crypto/lib/totp/TotpTest.java b/powerauth-java-crypto/src/test/java/io/getlime/security/powerauth/crypto/lib/totp/TotpTest.java
new file mode 100644
index 00000000..fd76a0d5
--- /dev/null
+++ b/powerauth-java-crypto/src/test/java/io/getlime/security/powerauth/crypto/lib/totp/TotpTest.java
@@ -0,0 +1,124 @@
+/*
+ * PowerAuth Crypto Library
+ * Copyright 2023 Wultra s.r.o.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package io.getlime.security.powerauth.crypto.lib.totp;
+
+import io.getlime.security.powerauth.crypto.lib.model.exception.CryptoProviderException;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.converter.ArgumentConversionException;
+import org.junit.jupiter.params.converter.ConvertWith;
+import org.junit.jupiter.params.converter.TypedArgumentConverter;
+import org.junit.jupiter.params.provider.CsvFileSource;
+import org.opentest4j.AssertionFailedError;
+
+import java.time.LocalDateTime;
+import java.util.HexFormat;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+/**
+ * Test for {@link Totp}.
+ *
+ * @author Lubos Racansky, lubos.racansky@wultra.com
+ *
+ */
+class TotpTest {
+
+    private static final int DIGITS_NUMBER = 8;
+
+    @ParameterizedTest
+    @CsvFileSource(resources = "/io/getlime/security/powerauth/crypto/lib/totp/data.csv", useHeadersInDisplayName=true)
+    void testGenerateTotp(final long seconds, final @ConvertWith(DateTimeConverter.class) LocalDateTime localDateTime, final String step, final String otp, final String algorithm, final String seed) throws Exception {
+        final byte[] result = switch (algorithm) {
+            case "HmacSHA256" -> Totp.generateTotpSha256(fromHex(seed), localDateTime, DIGITS_NUMBER);
+            case "HmacSHA512" -> Totp.generateTotpSha512(fromHex(seed), localDateTime, DIGITS_NUMBER);
+            default -> throw new AssertionFailedError("Not supported algorithm " + algorithm);
+        };
+        assertEquals(otp, new String(result));
+    }
+
+    @ParameterizedTest
+    @CsvFileSource(resources = "/io/getlime/security/powerauth/crypto/lib/totp/data.csv", useHeadersInDisplayName=true)
+    void testValidateTotpCurrentStep(final long seconds, final @ConvertWith(DateTimeConverter.class) LocalDateTime localDateTime, final String step, final String otp, final String algorithm, final String seed) throws Exception {
+        final boolean result = switch (algorithm) {
+            case "HmacSHA256" -> Totp.validateTotpSha256(otp.getBytes(), fromHex(seed), localDateTime, DIGITS_NUMBER);
+            case "HmacSHA512" -> Totp.validateTotpSha512(otp.getBytes(), fromHex(seed), localDateTime, DIGITS_NUMBER);
+            default -> throw new AssertionFailedError("Not supported algorithm " + algorithm);
+        };
+        assertTrue(result);
+    }
+
+    @ParameterizedTest
+    @CsvFileSource(resources = "/io/getlime/security/powerauth/crypto/lib/totp/data.csv", useHeadersInDisplayName=true)
+    void testValidateTotpOneStepBack(final long seconds, final @ConvertWith(DateTimeConverter.class) LocalDateTime localDateTime, final String step, final String otp, final String algorithm, final String seed) throws Exception {
+        final LocalDateTime movedLocalDateTime = localDateTime.plusSeconds(30);
+
+        final boolean result = switch (algorithm) {
+            case "HmacSHA256" -> Totp.validateTotpSha256(otp.getBytes(), fromHex(seed), movedLocalDateTime, DIGITS_NUMBER);
+            case "HmacSHA512" -> Totp.validateTotpSha512(otp.getBytes(), fromHex(seed), movedLocalDateTime, DIGITS_NUMBER);
+            default -> throw new AssertionFailedError("Not supported algorithm " + algorithm);
+        };
+        assertTrue(result);
+    }
+
+    @ParameterizedTest
+    @CsvFileSource(resources = "/io/getlime/security/powerauth/crypto/lib/totp/data.csv", useHeadersInDisplayName=true)
+    void testValidateTotpTwoStepsBack(final long seconds, final @ConvertWith(DateTimeConverter.class) LocalDateTime localDateTime, final String step, final String otp, final String algorithm, final String seed) throws Exception {
+        final LocalDateTime movedLocalDateTime = localDateTime.plusSeconds(60);
+
+        final boolean result = switch (algorithm) {
+            case "HmacSHA256" -> Totp.validateTotpSha256(otp.getBytes(), fromHex(seed), movedLocalDateTime, DIGITS_NUMBER);
+            case "HmacSHA512" -> Totp.validateTotpSha512(otp.getBytes(), fromHex(seed), movedLocalDateTime, DIGITS_NUMBER);
+            default -> throw new AssertionFailedError("Not supported algorithm " + algorithm);
+        };
+        assertFalse(result);
+    }
+
+    @Test
+    void testGenerateTotpLeftPaddedWithZero() throws Exception {
+        final LocalDateTime localDateTime = LocalDateTime.parse("2023-04-27T01:26:29");
+        final String result = new String(Totp.generateTotpSha256("12345678901234567890".getBytes(), localDateTime, DIGITS_NUMBER));
+        assertEquals("01760428", result);
+    }
+
+    @Test
+    void testValidateTotpInvalidLength() {
+        var exception = Assertions.assertThrows(CryptoProviderException.class, () ->
+            Totp.validateTotpSha256("1".getBytes(), "12345678901234567890".getBytes(), LocalDateTime.now(), DIGITS_NUMBER));
+        assertEquals("Otp length 1 is different from expected 8", exception.getMessage());
+    }
+
+    private static byte[] fromHex(final String source) {
+        return HexFormat.of().parseHex(source);
+    }
+
+    static class DateTimeConverter extends TypedArgumentConverter<String, LocalDateTime> {
+        protected DateTimeConverter() {
+            super(String.class, LocalDateTime.class);
+        }
+
+        @Override
+        protected LocalDateTime convert(final String source) throws ArgumentConversionException {
+            try {
+                return LocalDateTime.parse(source);
+            } catch (Exception e) {
+                throw new IllegalArgumentException("Failed to convert", e);
+            }
+        }
+    }
+}
diff --git a/powerauth-java-crypto/src/test/resources/io/getlime/security/powerauth/crypto/lib/totp/data.csv b/powerauth-java-crypto/src/test/resources/io/getlime/security/powerauth/crypto/lib/totp/data.csv
new file mode 100644
index 00000000..82797303
--- /dev/null
+++ b/powerauth-java-crypto/src/test/resources/io/getlime/security/powerauth/crypto/lib/totp/data.csv
@@ -0,0 +1,13 @@
+ Time (sec) ,  UTC Time           , Value of T (hex) ,  TOTP    , Algorithm  , Seed
+         59 , 1970-01-01T00:00:59 , 0000000000000001 , 46119246 , HmacSHA256 , 3132333435363738393031323334353637383930313233343536373839303132
+         59 , 1970-01-01T00:00:59 , 0000000000000001 , 90693936 , HmacSHA512 , 31323334353637383930313233343536373839303132333435363738393031323334353637383930313233343536373839303132333435363738393031323334
+ 1111111109 , 2005-03-18T01:58:29 , 00000000023523EC , 68084774 , HmacSHA256 , 3132333435363738393031323334353637383930313233343536373839303132
+ 1111111109 , 2005-03-18T01:58:29 , 00000000023523EC , 25091201 , HmacSHA512 , 31323334353637383930313233343536373839303132333435363738393031323334353637383930313233343536373839303132333435363738393031323334
+ 1111111111 , 2005-03-18T01:58:31 , 00000000023523ED , 67062674 , HmacSHA256 , 3132333435363738393031323334353637383930313233343536373839303132
+ 1111111111 , 2005-03-18T01:58:31 , 00000000023523ED , 99943326 , HmacSHA512 , 31323334353637383930313233343536373839303132333435363738393031323334353637383930313233343536373839303132333435363738393031323334
+ 1234567890 , 2009-02-13T23:31:30 , 000000000273EF07 , 91819424 , HmacSHA256 , 3132333435363738393031323334353637383930313233343536373839303132
+ 1234567890 , 2009-02-13T23:31:30 , 000000000273EF07 , 93441116 , HmacSHA512 , 31323334353637383930313233343536373839303132333435363738393031323334353637383930313233343536373839303132333435363738393031323334
+ 2000000000 , 2033-05-18T03:33:20 , 0000000003F940AA , 90698825 , HmacSHA256 , 3132333435363738393031323334353637383930313233343536373839303132
+ 2000000000 , 2033-05-18T03:33:20 , 0000000003F940AA , 38618901 , HmacSHA512 , 31323334353637383930313233343536373839303132333435363738393031323334353637383930313233343536373839303132333435363738393031323334
+20000000000 , 2603-10-11T11:33:20 , 0000000027BC86AA , 77737706 , HmacSHA256 , 3132333435363738393031323334353637383930313233343536373839303132
+20000000000 , 2603-10-11T11:33:20 , 0000000027BC86AA , 47863826 , HmacSHA512 , 31323334353637383930313233343536373839303132333435363738393031323334353637383930313233343536373839303132333435363738393031323334
\ No newline at end of file
diff --git a/powerauth-java-crypto/src/test/resources/simplelogger.properties b/powerauth-java-crypto/src/test/resources/simplelogger.properties
new file mode 100644
index 00000000..3a33d130
--- /dev/null
+++ b/powerauth-java-crypto/src/test/resources/simplelogger.properties
@@ -0,0 +1,1 @@
+org.slf4j.simpleLogger.log.io.getlime.security.powerauth.crypto=DEBUG