diff --git a/src/main/java/com/eatthepath/otp/HmacOneTimePasswordGenerator.java b/src/main/java/com/eatthepath/otp/HmacOneTimePasswordGenerator.java index b5039c3..557a61f 100644 --- a/src/main/java/com/eatthepath/otp/HmacOneTimePasswordGenerator.java +++ b/src/main/java/com/eatthepath/otp/HmacOneTimePasswordGenerator.java @@ -72,9 +72,9 @@ public HmacOneTimePasswordGenerator(final int passwordLength) { } /** - *

Creates a new HMAC-based one-time password generator using the given password length and algorithm. Note that + * Creates a new HMAC-based one-time password generator using the given password length and algorithm. Note that * RFC 4226 specifies that HOTP must always use HMAC-SHA1 as - * an algorithm, but derived one-time password systems like TOTP may allow for other algorithms.

+ * an algorithm, but derived one-time password systems like TOTP may allow for other algorithms. * * @param passwordLength the length, in decimal digits, of the one-time passwords to be generated; must be between * 6 and 8, inclusive @@ -199,6 +199,68 @@ public String generateOneTimePasswordString(final Key key, final long counter, f return this.formatOneTimePassword(generateOneTimePassword(key, counter), locale); } + /** + * Checks whether a given one-time password matches the one-time password generated for the given key and counter + * value. Note that this method simply checks equality of two one-time passwords; incrementing expected counter + * values, throttling/rate-limiting, counter resynchronization, and so one are all beyond the scope of this method. + * + * @param key the key to be used to generate the password + * @param counter the counter value for which to generate the password + * @param oneTimePassword the user-provided one-time password to check against the generated one-time password + * + * @return {@code true} if and only if the given one-time password matches the one-time password generated for the + * given key and counter value; one-time password strings match if they have the correct number of digits (see + * {@link #getPasswordLength()}), can be parsed as an integer, and that integer matches the one-time password + * generated for the given key and counter value + * + * @throws InvalidKeyException if the given key is inappropriate for initializing the {@link Mac} for this generator + * @throws NullPointerException if the given one-time password is {@code null} + * + * @see HOTP: An HMAC-Based One-Time Password Algorithm (RFC 4226) - Security Requirements + */ + public boolean validateOneTimePassword(final Key key, final long counter, final String oneTimePassword) throws InvalidKeyException { + if (oneTimePassword == null) { + throw new NullPointerException("One-time password must not be null"); + } + + // We COULD return early if the length doesn't match, but that could allow an attacker to learn the expected + // passowrd length by observing execution time. Arguably, the expected password length isn't a secret, but we + // can avoid revealing it here and choose to do so. + final boolean lengthMatches = oneTimePassword.length() == this.passwordLength; + + try { + final boolean passwordMatches = validateOneTimePassword(key, counter, Integer.parseInt(oneTimePassword)); + + // Again, this construction may seem a little odd, but the goal is to make sure this check happens in + // constant time relative to any secret data or internal state. `&` is a constant-time operation while `&&` + // can short-circuit. This construction means we evaluate both criteria and don't return early if the length + // of the given one-time password was incorrect. + return lengthMatches & passwordMatches; + } catch (final NumberFormatException e) { + return false; + } + } + + /** + * Checks whether a given one-time password matches the one-time password generated for the given key and counter + * value. Note that this method simply checks equality of two one-time passwords; incrementing expected counter + * values, throttling/rate-limiting, counter resynchronization, and so one are all beyond the scope of this method. + * + * @param key the key to be used to generate the password + * @param counter the counter value for which to generate the password + * @param oneTimePassword the user-provided one-time password to check against the generated one-time password + * + * @return {@code true} if and only if the given one-time password matches the one-time password generated for the + * given key and counter value + * + * @throws InvalidKeyException if the given key is inappropriate for initializing the {@link Mac} for this generator + * + * @see HOTP: An HMAC-Based One-Time Password Algorithm (RFC 4226) - Security Requirements + */ + public boolean validateOneTimePassword(final Key key, final long counter, final int oneTimePassword) throws InvalidKeyException { + return generateOneTimePassword(key, counter) == oneTimePassword; + } + /** * Formats a one-time password as a fixed-length string using the given locale. * diff --git a/src/main/java/com/eatthepath/otp/TimeBasedOneTimePasswordGenerator.java b/src/main/java/com/eatthepath/otp/TimeBasedOneTimePasswordGenerator.java index df6ce84..07e3a1a 100644 --- a/src/main/java/com/eatthepath/otp/TimeBasedOneTimePasswordGenerator.java +++ b/src/main/java/com/eatthepath/otp/TimeBasedOneTimePasswordGenerator.java @@ -139,7 +139,7 @@ public TimeBasedOneTimePasswordGenerator(final Duration timeStep, final int pass * @throws InvalidKeyException if the given key is inappropriate for initializing the {@link Mac} for this generator */ public int generateOneTimePassword(final Key key, final Instant timestamp) throws InvalidKeyException { - return this.hotp.generateOneTimePassword(key, timestamp.toEpochMilli() / this.timeStep.toMillis()); + return this.hotp.generateOneTimePassword(key, getCounterValue(timestamp)); } /** @@ -174,6 +174,50 @@ public String generateOneTimePasswordString(final Key key, final Instant timesta return this.hotp.formatOneTimePassword(this.generateOneTimePassword(key, timestamp), locale); } + /** + * Checks whether a given one-time password matches the one-time password generated for the given key and timestamp. + * Note that this method simply checks equality of two one-time passwords; compensating for clock drift, + * throttling/rate-limiting, clock resynchronization, and so one are all beyond the scope of this method. + * + * @param key the key to be used to generate the password + * @param timestamp the timestamp for which to generate the password + * @param oneTimePassword the user-provided one-time password to check against the generated one-time password + * + * @return {@code true} if and only if the given one-time password matches the one-time password generated for the + * given key and timestamp; one-time password strings match if they have the correct number of digits (see + * {@link #getPasswordLength()}), can be parsed as an integer, and that integer matches the one-time password + * generated for the given key and timestamp + * + * @throws InvalidKeyException if the given key is inappropriate for initializing the {@link Mac} for this generator + * @throws NullPointerException if the given timestamp or one-time password is {@code null} + * + * @see TOTP: Time-Based One-Time Password Algorithm (RFC 6238) - Security Considerations + */ + public boolean validateOneTimePassword(final Key key, final Instant timestamp, final String oneTimePassword) throws InvalidKeyException { + return hotp.validateOneTimePassword(key, getCounterValue(timestamp), oneTimePassword); + } + + /** + * Checks whether a given one-time password matches the one-time password generated for the given key and timestamp. + * Note that this method simply checks equality of two one-time passwords; compensating for clock drift, + * throttling/rate-limiting, clock resynchronization, and so one are all beyond the scope of this method. + * + * @param key the key to be used to generate the password + * @param timestamp the timestamp for which to generate the password + * @param oneTimePassword the user-provided one-time password to check against the generated one-time password + * + * @return {@code true} if and only if the given one-time password matches the one-time password generated for the + * given key and timestamp + * + * @throws InvalidKeyException if the given key is inappropriate for initializing the {@link Mac} for this generator + * @throws NullPointerException if the given timestamp is {@code null} + * + * @see TOTP: Time-Based One-Time Password Algorithm (RFC 6238) - Security Considerations + */ + public boolean validateOneTimePassword(final Key key, final Instant timestamp, final int oneTimePassword) throws InvalidKeyException { + return hotp.validateOneTimePassword(key, getCounterValue(timestamp), oneTimePassword); + } + /** * Returns the time step used by this generator. * @@ -200,4 +244,12 @@ public int getPasswordLength() { public String getAlgorithm() { return this.hotp.getAlgorithm(); } + + private long getCounterValue(final Instant timestamp) { + if (timestamp == null) { + throw new NullPointerException("Timestamp must not be null"); + } + + return timestamp.toEpochMilli() / this.timeStep.toMillis(); + } } diff --git a/src/test/java/com/eatthepath/otp/HmacOneTimePasswordGeneratorTest.java b/src/test/java/com/eatthepath/otp/HmacOneTimePasswordGeneratorTest.java index 9c1e40c..e1c0cb6 100644 --- a/src/test/java/com/eatthepath/otp/HmacOneTimePasswordGeneratorTest.java +++ b/src/test/java/com/eatthepath/otp/HmacOneTimePasswordGeneratorTest.java @@ -29,6 +29,7 @@ import java.nio.charset.StandardCharsets; import java.security.InvalidKeyException; import java.security.Key; +import java.time.Instant; import java.util.Arrays; import java.util.Locale; import java.util.concurrent.*; @@ -147,6 +148,39 @@ private static Stream generateOneTimePasswordStringLocale() { ); } + @Test + void validateOneTimePasswordInt() throws InvalidKeyException { + final HmacOneTimePasswordGenerator hotp = new HmacOneTimePasswordGenerator(); + final long counter = ThreadLocalRandom.current().nextLong(); + + assertTrue(hotp.validateOneTimePassword(HOTP_KEY, counter, hotp.generateOneTimePassword(HOTP_KEY, counter))); + assertFalse(hotp.validateOneTimePassword(HOTP_KEY, counter, hotp.generateOneTimePassword(HOTP_KEY, counter + 1))); + } + + @Test + void validateOneTimePasswordString() throws InvalidKeyException { + final HmacOneTimePasswordGenerator hotp = new HmacOneTimePasswordGenerator(); + + // A counter value of 36 with HOTP produces a one-time password of "003784" (or "००३७८४" in the hi-IN-u-nu-Deva + // locale). The leading zeros are an important edge case for string-based tests. + final long counter = 36; + + assertTrue(hotp.validateOneTimePassword(HOTP_KEY, counter, "003784")); + assertFalse(hotp.validateOneTimePassword(HOTP_KEY, counter, "3784")); + assertFalse(hotp.validateOneTimePassword(HOTP_KEY, counter, "0003784")); + assertFalse(hotp.validateOneTimePassword(HOTP_KEY, counter, "0037840")); + + assertTrue(hotp.validateOneTimePassword(HOTP_KEY, counter, "००३७८४")); + assertFalse(hotp.validateOneTimePassword(HOTP_KEY, counter, "३७८४")); + assertFalse(hotp.validateOneTimePassword(HOTP_KEY, counter, "०००३७८४")); + assertFalse(hotp.validateOneTimePassword(HOTP_KEY, counter, "००३७८४०")); + + assertFalse(hotp.validateOneTimePassword(HOTP_KEY, counter, "cursed")); + + assertThrows(NullPointerException.class, () -> + hotp.validateOneTimePassword(HOTP_KEY, counter, null)); + } + @Test void generateOneTimePasswordConcurrent() throws InterruptedException { final int iterations = 10_000; diff --git a/src/test/java/com/eatthepath/otp/TimeBasedOneTimePasswordGeneratorTest.java b/src/test/java/com/eatthepath/otp/TimeBasedOneTimePasswordGeneratorTest.java index e1913cc..845b83f 100644 --- a/src/test/java/com/eatthepath/otp/TimeBasedOneTimePasswordGeneratorTest.java +++ b/src/test/java/com/eatthepath/otp/TimeBasedOneTimePasswordGeneratorTest.java @@ -28,6 +28,7 @@ import javax.crypto.Mac; import javax.crypto.spec.SecretKeySpec; import java.nio.charset.StandardCharsets; +import java.security.InvalidKeyException; import java.security.Key; import java.security.NoSuchAlgorithmException; import java.time.Duration; @@ -35,8 +36,7 @@ import java.util.Locale; import java.util.stream.Stream; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.*; import static org.junit.jupiter.api.Assumptions.assumeTrue; import static org.junit.jupiter.params.provider.Arguments.arguments; @@ -200,6 +200,50 @@ private static Stream generateOneTimePasswordStringLocale() { ); } + @Test + void validateOneTimePasswordInt() throws InvalidKeyException { + final TimeBasedOneTimePasswordGenerator totp = new TimeBasedOneTimePasswordGenerator(); + final Instant timestamp = Instant.now(); + final Key key = + new SecretKeySpec(HMAC_SHA1_KEY_BYTES, TimeBasedOneTimePasswordGenerator.TOTP_ALGORITHM_HMAC_SHA1); + + assertTrue(totp.validateOneTimePassword(key, timestamp, totp.generateOneTimePassword(key, timestamp))); + assertFalse(totp.validateOneTimePassword(key, timestamp, totp.generateOneTimePassword(key, timestamp.plus(totp.getTimeStep())))); + + assertThrows(NullPointerException.class, () -> + totp.validateOneTimePassword(key, null, totp.generateOneTimePassword(key, timestamp))); + } + + @Test + void validateOneTimePasswordString() throws InvalidKeyException { + final TimeBasedOneTimePasswordGenerator totp = new TimeBasedOneTimePasswordGenerator(); + final Key key = + new SecretKeySpec(HMAC_SHA1_KEY_BYTES, TimeBasedOneTimePasswordGenerator.TOTP_ALGORITHM_HMAC_SHA1); + + // A timestamp of 1970-01-01T00:18:00Z with a default TOTP generator produces a one-time password of "003784" + // (or "००३७८४" in the hi-IN-u-nu-Deva locale). The leading zeros are an important edge case for string-based + // tests. + final Instant timestamp = Instant.parse("1970-01-01T00:18:00Z"); + + assertTrue(totp.validateOneTimePassword(key, timestamp, "003784")); + assertFalse(totp.validateOneTimePassword(key, timestamp, "3784")); + assertFalse(totp.validateOneTimePassword(key, timestamp, "0003784")); + assertFalse(totp.validateOneTimePassword(key, timestamp, "0037840")); + + assertTrue(totp.validateOneTimePassword(key, timestamp, "००३७८४")); + assertFalse(totp.validateOneTimePassword(key, timestamp, "३७८४")); + assertFalse(totp.validateOneTimePassword(key, timestamp, "०००३७८४")); + assertFalse(totp.validateOneTimePassword(key, timestamp, "००३७८४०")); + + assertFalse(totp.validateOneTimePassword(key, timestamp, "cursed")); + + assertThrows(NullPointerException.class, () -> + totp.validateOneTimePassword(key, timestamp, null)); + + assertThrows(NullPointerException.class, () -> + totp.validateOneTimePassword(key, null, "003784")); + } + private static void assumeAlgorithmSupported(final String algorithm) { boolean algorithmSupported = true;