From 7ebff434d92bf2decda4896637675420461be0fc Mon Sep 17 00:00:00 2001 From: Jason Aylward Date: Thu, 19 Jan 2023 09:09:09 -0500 Subject: [PATCH] Add totp utilities (#9) Provide TOTP utilities for 2FA authentication. --- lib/src/main/java/rife/tools/StringUtils.java | 48 +++++- lib/src/main/java/rife/tools/TOTPUtils.java | 158 ++++++++++++++++++ .../test/java/rife/tools/TestStringUtils.java | 11 ++ .../test/java/rife/tools/TestTOTPUtils.java | 53 ++++++ 4 files changed, 269 insertions(+), 1 deletion(-) create mode 100644 lib/src/main/java/rife/tools/TOTPUtils.java create mode 100644 lib/src/test/java/rife/tools/TestTOTPUtils.java diff --git a/lib/src/main/java/rife/tools/StringUtils.java b/lib/src/main/java/rife/tools/StringUtils.java index bb2dafcf..5d5744f5 100644 --- a/lib/src/main/java/rife/tools/StringUtils.java +++ b/lib/src/main/java/rife/tools/StringUtils.java @@ -586,6 +586,8 @@ public final class StringUtils { return UNRESERVED_URI_CHARS.get(ch); } + private static final char[] BASE32_DIGITS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567".toCharArray(); + /** * Appends the hexadecimal digit of the provided number. * @@ -1178,6 +1180,50 @@ public final class StringUtils { return out.toString(); } + /** + * Encodes byte array to Base32 String. + * + * @param bytes Bytes to encode. + * @return Encoded byte array bytes as a String. + */ + public static String encodeBase32(byte[] bytes) { + if (bytes == null) { + return null; + } + + int i = 0, index = 0, digit = 0; + int currByte, nextByte; + StringBuilder base32 = new StringBuilder((bytes.length + 7) * 8 / 5); + + while (i < bytes.length) { + currByte = (bytes[i] >= 0) ? bytes[i] : (bytes[i] + 256); // unsign + + /* Is the current digit going to span a byte boundary? */ + if (index > 3) { + if ((i + 1) < bytes.length) { + nextByte = + (bytes[i + 1] >= 0) ? bytes[i + 1] : (bytes[i + 1] + 256); + } else { + nextByte = 0; + } + + digit = currByte & (0xFF >> index); + index = (index + 5) % 8; + digit <<= index; + digit |= nextByte >> (8 - index); + i++; + } else { + digit = (currByte >> (8 - (index + 5))) & 0x1F; + index = (index + 5) % 8; + if (index == 0) + i++; + } + base32.append(BASE32_DIGITS[digit]); + } + + return base32.toString(); + } + /** * Counts the number of times a substring occures in a provided string in * a case-sensitive manner. @@ -2873,4 +2919,4 @@ public final class StringUtils { return buffer.toString(); } -} \ No newline at end of file +} diff --git a/lib/src/main/java/rife/tools/TOTPUtils.java b/lib/src/main/java/rife/tools/TOTPUtils.java new file mode 100644 index 00000000..6a04e915 --- /dev/null +++ b/lib/src/main/java/rife/tools/TOTPUtils.java @@ -0,0 +1,158 @@ +package rife.tools; + +/* + * Licensed under the Apache License, Version 2.0 (the "License") + * https://github.com/taimos/totp + * Changes from original: + * Renamed to TOTPUtils. Removed apache.commons.codec dependencies. Moved URL generation into class. All public secret handling in Base32 + */ + +import java.math.*; +import java.nio.charset.StandardCharsets; + +import javax.crypto.*; +import javax.crypto.spec.*; +import java.lang.reflect.*; +import java.security.*; + + +/** + * Utility class providing the necessary functions to build 2FA using a time-based OTP algorithm + * + * @since 1.0 + */ +public final class TOTPUtils +{ + private TOTPUtils() { + } + + /** + * Generates a random secret + * + * @returns secret as a UTF-8 encoded {@code String} + * @since 1.0 + **/ + public static String generateSecret() { + var random = new SecureRandom(); + var bytes = new byte[20]; + random.nextBytes(bytes); + return new String(bytes, StandardCharsets.UTF_8); + } + + /** + * Generates the time-based code based on the given secret + * @param secret should be the UTF-8 encoded secret + * @returns time-based code as a {@code String} to use for authentication or {@code null} if secret is null or an empty string + * @since 1.0 + **/ + public static String getCode(final String secret) { + if (secret == null || secret.isEmpty()) { + return null; + } + var hexKey = StringUtils.encodeHex(secret.getBytes(StandardCharsets.UTF_8)); + return getOTP(hexKey); + } + + /** + * Code validation with a default of 1-step back, granting a 30-60 second window + * @param secret should be the UTF-8 encoded secret + * @param inputCode should be the code input by the challenger + * + * @returns {@code true} if inputCode is valid, {@code false} if invalid or if secret or inputCode are null + * @since 1.0 + **/ + public static boolean validateCode(final String secret, final String inputCode) { + return validateCode(secret, inputCode, 1); + } + + /** + * Code validation where steps back can be customized to allow looser time-based authentication + * @param secret should be the UTF-8 encoded secret + * @param inputCode should be the code input by the challenger + * @param stepsBack number of steps (30 second increments) to look back during authentication + * + * @returns {@code true} if inputCode is valid, @{code false} if invalid or if secret or inputCode are null + * @since 1.0 + **/ + public static boolean validateCode(final String secret, final String inputCode, int stepsBack) { + if (secret == null || secret.isEmpty() || inputCode == null || inputCode.isEmpty()) { + return false; + } + long step = getStep(); + var hexKey = StringUtils.encodeHex(secret.getBytes(StandardCharsets.UTF_8)); + for (long i = 0; i <= stepsBack; i++) { + if (getOTP(step - i, hexKey).equals(inputCode)) { + return true; + } + } + return false; + } + + /** + * Generates a Google Authenticator-compatible URL + * Formatting based on the document found here: https://github.com/google/google-authenticator/wiki/Key-Uri-Format + * Can be used for QR-code image scanning + * + * @param secret should be the UTF-8 encoded secret + * @param issuer should represent the account associated with the authentication + * @param user represents the user associated with the authentication + * + * @returns the URL to be used as a {@code String} + * @since 1.0 + **/ + public static String getUrl(final String secret, final String issuer, final String user) { + if (secret == null || secret.isEmpty()) { + return null; + } + if (issuer == null || issuer.isEmpty() || issuer.contains(":") || user == null || user.isEmpty() || user.contains(":") ) { + return null; + } + var encoded = StringUtils.encodeBase32(secret.getBytes(StandardCharsets.UTF_8)); + var encoded_issuer = StringUtils.encodeUrl(issuer); + var encoded_user = StringUtils.encodeUrl(user); + var rawURL = String.format("otpauth://totp/%s:%s?secret=%s&issuer=%s", encoded_issuer, encoded_user, encoded, encoded_issuer); + return rawURL; + } + + private static String getOTP(final String key) { + return getOTP(getStep(), key); + } + + private static long getStep() { + return System.currentTimeMillis() / 30000L; + } + + private static String getOTP(final long step, final String key) { + String steps; + for (steps = Long.toHexString(step).toUpperCase(); steps.length() < 16; steps = "0" + steps) {} + final byte[] msg = hexStr2Bytes(steps); + final byte[] k = hexStr2Bytes(key); + final byte[] hash = hmac_sha1(k, msg); + final int offset = hash[hash.length - 1] & 0xF; + final int binary = (hash[offset] & 0x7F) << 24 | (hash[offset + 1] & 0xFF) << 16 | (hash[offset + 2] & 0xFF) << 8 | (hash[offset + 3] & 0xFF); + final int otp = binary % 1000000; + String result; + for (result = Integer.toString(otp); result.length() < 6; result = "0" + result) {} + return result; + } + + private static byte[] hexStr2Bytes(final String hex) { + final byte[] bArray = new BigInteger("10" + hex, 16).toByteArray(); + final byte[] ret = new byte[bArray.length - 1]; + System.arraycopy(bArray, 1, ret, 0, ret.length); + return ret; + } + + private static byte[] hmac_sha1(final byte[] keyBytes, final byte[] text) { + try { + final Mac hmac = Mac.getInstance("HmacSHA1"); + final SecretKeySpec macKey = new SecretKeySpec(keyBytes, "RAW"); + hmac.init(macKey); + return hmac.doFinal(text); + } + catch (final GeneralSecurityException gse) { + throw new UndeclaredThrowableException(gse); + } + } +} + diff --git a/lib/src/test/java/rife/tools/TestStringUtils.java b/lib/src/test/java/rife/tools/TestStringUtils.java index b9084cdf..bd742e88 100644 --- a/lib/src/test/java/rife/tools/TestStringUtils.java +++ b/lib/src/test/java/rife/tools/TestStringUtils.java @@ -147,6 +147,17 @@ public class TestStringUtils { abcd\\"\\na\\\\wx/y\\bz\\nfde\\fde\\rjk\\tos\\\\u218Foi"""); } + @Test + void testEncodeBase32() { + assertNull(StringUtils.encodeBase32(null)); + assertEquals("", StringUtils.encodeBase32("".getBytes())); + var characterStr = "2b49ec9c-969f-11ed-a1eb-0242ac120002?!@#$^^&*/\2345"; + var encoded = StringUtils.encodeBase32(characterStr.getBytes()); + for (var character : encoded.toCharArray()) { + assertTrue("ABCDEFGHIJKLMNOPQRSTUVWXYZ234567".indexOf(character) > -1); + } + } + @Test void testCountCase() { assertEquals(StringUtils.count("ONEtwooNethreefourone", "onE", false), 3); diff --git a/lib/src/test/java/rife/tools/TestTOTPUtils.java b/lib/src/test/java/rife/tools/TestTOTPUtils.java new file mode 100644 index 00000000..bb644f50 --- /dev/null +++ b/lib/src/test/java/rife/tools/TestTOTPUtils.java @@ -0,0 +1,53 @@ +package rife.tools; + +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; + +public class TestTOTPUtils { + + @Test + public void testGenerateSecret() { + var secretOne = TOTPUtils.generateSecret(); + var secretTwo = TOTPUtils.generateSecret(); + assertNotEquals(secretOne, secretTwo); + } + + @Test + public void testGetCode() { + var secret = TOTPUtils.generateSecret(); + assertNotNull(TOTPUtils.getCode(secret)); + assertEquals(6, TOTPUtils.getCode(secret).length()); + assertDoesNotThrow(() -> TOTPUtils.getCode(null)); + assertDoesNotThrow(() -> TOTPUtils.getCode("")); + assertDoesNotThrow(() -> TOTPUtils.getCode("ABCDEFGHIQPOWER!@#$%^&*()12343567890///\\\\")); + assertNull(TOTPUtils.getCode(null)); + assertNull(TOTPUtils.getCode("")); + } + + @Test + public void testGetAndValidateCode() { + var secret = TOTPUtils.generateSecret(); + var code = TOTPUtils.getCode(secret); + assertFalse(TOTPUtils.validateCode(secret, code, -1)); + assertTrue(TOTPUtils.validateCode(secret, code, 0)); + assertTrue(TOTPUtils.validateCode(secret, code, 1)); + assertTrue(TOTPUtils.validateCode(secret, code, 10)); + + assertFalse(TOTPUtils.validateCode("", code)); + assertFalse(TOTPUtils.validateCode(secret, "")); + assertFalse(TOTPUtils.validateCode(null, code)); + assertFalse(TOTPUtils.validateCode(secret, null)); + } + + + @Test + public void testGetURL() { + var secret = TOTPUtils.generateSecret(); + assertNotNull(TOTPUtils.getUrl(secret, "TestIssuer", "TestUser")); + assertNull(TOTPUtils.getUrl(secret, null, null)); + assertNull(TOTPUtils.getUrl(secret, "", "")); + assertNull(TOTPUtils.getUrl(secret, "Issuer:WithColon", "TestUser")); + assertNull(TOTPUtils.getUrl(secret, "TestIssuer", "User:WithColon")); + assertNotNull(TOTPUtils.getUrl(secret, "ABCDEFGHIQPOWER!@#$%^&*()12343567890///\\\\", "ABCDEFGHIQPOWER!@#$%^&*()12343567890///\\\\")); + } +}