diff --git a/README.md b/README.md index f3f927d..5507aef 100644 --- a/README.md +++ b/README.md @@ -139,11 +139,23 @@ CodeVerifier verifier = new DefaultCodeVerifier(codeGenerator, timeProvider); // secret = the shared secret for the user // code = the code submitted by the user -boolean successful = verifier.isValidCode(secret, code) +VerifyResult result = verifier.verifyCode(secret, code); +boolean isValid = result.isValid(); +int timePeriodDifference = result.getTimePeriodDifference(); ``` This same process is used when verifying the submitted code every time the user needs to in the future. +#### Verifying multiple consecutive codes + +```java +// secret = the shared secret for the user +// code = the code submitted by the user +VerifyResult result = verifier.verifyConsecutiveCodes(secret, firstCode, secondCode, thirdCode...); +boolean isValid = result.isValid(); +int timePeriodDifference = result.getTimePeriodDifference(); +``` + #### Using different hashing algorithms By default, the `DefaultCodeGenerator` uses the SHA1 algorithm to generate/verify codes, but SHA256 and SHA512 are also supported. To use a different algorithm, pass in the desired `HashingAlgorithm` into the constructor: @@ -262,6 +274,23 @@ To run the tests for the library with Maven, run `mvn test`. +## Changelog + +All notable changes to the project will be documented here. + +### v1.8 - 2020-04-24 +#### Added + +- New method to verify multiple consecutive codes. +- New method to set the time period discrepancy by supplying a time duration object. +- Abilty to get the time drift between user & server for valid codes. +- Changelog section to README. + +#### Changed + +- Deprecated `isValidCode` method in favour of new `verifyCode` method. + + ## License diff --git a/totp-spring-boot-starter/README.md b/totp-spring-boot-starter/README.md index a1c024f..a5beee3 100644 --- a/totp-spring-boot-starter/README.md +++ b/totp-spring-boot-starter/README.md @@ -83,7 +83,7 @@ public class MfaVerifyController { public String verify(@RequestParam String code) { // secret is fetched from some storage - if (verifier.isValidCode(secret, code)) { + if (verifier.verifyCode(secret, code).isValid()) { return "CORRECT CODE"; } diff --git a/totp-spring-boot-starter/src/main/java/dev/samstevens/totp/spring/autoconfigure/TotpAutoConfiguration.java b/totp-spring-boot-starter/src/main/java/dev/samstevens/totp/spring/autoconfigure/TotpAutoConfiguration.java index daf8d19..0018d4e 100644 --- a/totp-spring-boot-starter/src/main/java/dev/samstevens/totp/spring/autoconfigure/TotpAutoConfiguration.java +++ b/totp-spring-boot-starter/src/main/java/dev/samstevens/totp/spring/autoconfigure/TotpAutoConfiguration.java @@ -10,12 +10,15 @@ import dev.samstevens.totp.secret.SecretGenerator; import dev.samstevens.totp.time.SystemTimeProvider; import dev.samstevens.totp.time.TimeProvider; +import dev.samstevens.totp.verify.CodeVerifier; +import dev.samstevens.totp.verify.DefaultCodeVerifier; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import java.time.Duration; @Configuration @ConditionalOnClass(TotpInfo.class) @@ -51,7 +54,7 @@ public HashingAlgorithm hashingAlgorithm() { @Bean @ConditionalOnMissingBean public QrDataFactory qrDataFactory(HashingAlgorithm hashingAlgorithm) { - return new QrDataFactory(hashingAlgorithm, getCodeLength(), getTimePeriod()); + return new QrDataFactory(hashingAlgorithm, getCodeLength(), getCodeValidityDuration()); } @Bean @@ -63,17 +66,13 @@ public QrGenerator qrGenerator() { @Bean @ConditionalOnMissingBean public CodeGenerator codeGenerator(HashingAlgorithm algorithm) { - return new DefaultCodeGenerator(algorithm, getCodeLength()); + return new DefaultCodeGenerator(algorithm, getCodeLength(), getCodeValidityDuration()); } @Bean @ConditionalOnMissingBean public CodeVerifier codeVerifier(CodeGenerator codeGenerator, TimeProvider timeProvider) { - DefaultCodeVerifier verifier = new DefaultCodeVerifier(codeGenerator, timeProvider); - verifier.setTimePeriod(getTimePeriod()); - verifier.setAllowedTimePeriodDiscrepancy(props.getTime().getDiscrepancy()); - - return verifier; + return new DefaultCodeVerifier(codeGenerator, timeProvider); } @Bean @@ -86,7 +85,7 @@ private int getCodeLength() { return props.getCode().getLength(); } - private int getTimePeriod() { - return props.getTime().getPeriod(); + private Duration getCodeValidityDuration() { + return Duration.ofSeconds(props.getCode().getValiditySeconds()); } } diff --git a/totp-spring-boot-starter/src/main/java/dev/samstevens/totp/spring/autoconfigure/TotpProperties.java b/totp-spring-boot-starter/src/main/java/dev/samstevens/totp/spring/autoconfigure/TotpProperties.java index 3d1ea3a..8f14032 100644 --- a/totp-spring-boot-starter/src/main/java/dev/samstevens/totp/spring/autoconfigure/TotpProperties.java +++ b/totp-spring-boot-starter/src/main/java/dev/samstevens/totp/spring/autoconfigure/TotpProperties.java @@ -7,12 +7,10 @@ public class TotpProperties { private static final int DEFAULT_SECRET_LENGTH = 32; private static final int DEFAULT_CODE_LENGTH = 6; - private static final int DEFAULT_TIME_PERIOD = 30; - private static final int DEFAULT_TIME_DISCREPANCY = 1; + private static final int DEFAULT_CODE_VALIDITY_SECONDS = 30; private final Secret secret = new Secret(); private final Code code = new Code(); - private final Time time = new Time(); public Secret getSecret() { return secret; @@ -22,10 +20,6 @@ public Code getCode() { return code; } - public Time getTime() { - return time; - } - public static class Secret { private int length = DEFAULT_SECRET_LENGTH; @@ -40,6 +34,7 @@ public void setLength(int length) { public static class Code { private int length = DEFAULT_CODE_LENGTH; + private int validitySeconds = DEFAULT_CODE_VALIDITY_SECONDS; public int getLength() { return length; @@ -48,26 +43,13 @@ public int getLength() { public void setLength(int length) { this.length = length; } - } - - public static class Time { - private int period = DEFAULT_TIME_PERIOD; - private int discrepancy = DEFAULT_TIME_DISCREPANCY; - - public int getPeriod() { - return period; - } - - public void setPeriod(int period) { - this.period = period; - } - public int getDiscrepancy() { - return discrepancy; + public int getValiditySeconds() { + return validitySeconds; } - public void setDiscrepancy(int discrepancy) { - this.discrepancy = discrepancy; + public void setValiditySeconds(int validitySeconds) { + this.validitySeconds = validitySeconds; } } } diff --git a/totp/src/main/java/dev/samstevens/totp/TotpInfo.java b/totp/src/main/java/dev/samstevens/totp/TotpInfo.java index c493b8b..fbd658d 100644 --- a/totp/src/main/java/dev/samstevens/totp/TotpInfo.java +++ b/totp/src/main/java/dev/samstevens/totp/TotpInfo.java @@ -1,5 +1,5 @@ package dev.samstevens.totp; public class TotpInfo { - public static String VERSION = "1.7"; + public static String VERSION = "2.0.0"; } diff --git a/totp/src/main/java/dev/samstevens/totp/code/CodeGenerator.java b/totp/src/main/java/dev/samstevens/totp/code/CodeGenerator.java index bf68424..7bd70d4 100644 --- a/totp/src/main/java/dev/samstevens/totp/code/CodeGenerator.java +++ b/totp/src/main/java/dev/samstevens/totp/code/CodeGenerator.java @@ -1,13 +1,30 @@ package dev.samstevens.totp.code; import dev.samstevens.totp.exceptions.CodeGenerationException; +import java.time.Instant; +import java.util.List; public interface CodeGenerator { + + /** + * Returns a list of GeneratedCode objects representing the code for a given secret and time, + * and the N previous and next codes. + * + * @param secret The shared secret/key to generate the code with. + * @param atTime An Instant object representing the time to generate the code for. + * @return The list of GeneratedCode objects. + * @throws CodeGenerationException Thrown if the code generation fails for any reason. + */ + List generate(String secret, Instant atTime, int beforeAndAfter) throws CodeGenerationException; + /** + * Returns a list of GeneratedCode objects that are valid for a given secret between a start and end time. + * * @param secret The shared secret/key to generate the code with. - * @param counter The current time bucket number. Number of seconds since epoch / bucket period. - * @return The n-digit code for the secret/counter. + * @param startTime An Instant object representing the time to start generating codes. + * @param endTime An Instant object representing the time to stop generating codes. + * @return The list of GeneratedCode objects. * @throws CodeGenerationException Thrown if the code generation fails for any reason. */ - String generate(String secret, long counter) throws CodeGenerationException; + List generateBetween(String secret, Instant startTime, Instant endTime) throws CodeGenerationException; } diff --git a/totp/src/main/java/dev/samstevens/totp/code/CodeVerifier.java b/totp/src/main/java/dev/samstevens/totp/code/CodeVerifier.java deleted file mode 100644 index 711bc03..0000000 --- a/totp/src/main/java/dev/samstevens/totp/code/CodeVerifier.java +++ /dev/null @@ -1,10 +0,0 @@ -package dev.samstevens.totp.code; - -public interface CodeVerifier { - /** - * @param secret The shared secret/key to check the code against. - * @param code The n-digit code given by the end user to check. - * @return If the code is valid or not. - */ - boolean isValidCode(String secret, String code); -} diff --git a/totp/src/main/java/dev/samstevens/totp/code/DefaultCodeGenerator.java b/totp/src/main/java/dev/samstevens/totp/code/DefaultCodeGenerator.java index e87a69e..c18a524 100644 --- a/totp/src/main/java/dev/samstevens/totp/code/DefaultCodeGenerator.java +++ b/totp/src/main/java/dev/samstevens/totp/code/DefaultCodeGenerator.java @@ -7,37 +7,104 @@ import java.security.InvalidKeyException; import java.security.InvalidParameterException; import java.security.NoSuchAlgorithmException; +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; public class DefaultCodeGenerator implements CodeGenerator { + private final static int DEFAULT_DIGITS_LENGTH = 6; + private final static Duration DEFAULT_VALIDITY_DURATION = Duration.ofSeconds(30); + private final HashingAlgorithm algorithm; private final int digits; + private final long validitySeconds; public DefaultCodeGenerator() { - this(HashingAlgorithm.SHA1, 6); + this(HashingAlgorithm.SHA1, DEFAULT_DIGITS_LENGTH, DEFAULT_VALIDITY_DURATION); } public DefaultCodeGenerator(HashingAlgorithm algorithm) { - this(algorithm, 6); + this(algorithm, DEFAULT_DIGITS_LENGTH, DEFAULT_VALIDITY_DURATION); } public DefaultCodeGenerator(HashingAlgorithm algorithm, int digits) { + this(algorithm, digits, DEFAULT_VALIDITY_DURATION); + } + + public DefaultCodeGenerator(HashingAlgorithm algorithm, int digits, Duration codeValidityDuration) { if (algorithm == null) { throw new InvalidParameterException("HashingAlgorithm must not be null."); } + if (digits < 1) { throw new InvalidParameterException("Number of digits must be higher than 0."); } + if (codeValidityDuration.getSeconds() < 1) { + throw new InvalidParameterException("Number of seconds codes are valid for must be at least 1."); + } + this.algorithm = algorithm; this.digits = digits; + this.validitySeconds = codeValidityDuration.getSeconds(); + } + + @Override + public List generate(String key, Instant atTime, int howManyBeforeAndAfter) throws CodeGenerationException { + if (howManyBeforeAndAfter < 0) { + throw new InvalidParameterException("Number of codes before and after to generate must be greater or equal to zero."); + } + + long counter = getCounterForTime(atTime); + long startCounter = counter - howManyBeforeAndAfter; + long endCounter = counter + howManyBeforeAndAfter; + + return generateCodesForCounterRange(key, startCounter, endCounter); } @Override - public String generate(String key, long counter) throws CodeGenerationException { + public List generateBetween(String key, Instant startTime, Instant endTime) throws CodeGenerationException { + if (endTime.isBefore(startTime)) { + throw new InvalidParameterException("End time must be after start time."); + } + + long startCounter = getCounterForTime(startTime); + long endCounter = getCounterForTime(endTime); + + return generateCodesForCounterRange(key, startCounter, endCounter); + } + + /** + * Get the counter value used to generate the hash for the given time. + */ + private long getCounterForTime(Instant time) { + return Math.floorDiv(time.getEpochSecond(), validitySeconds); + } + + /** + * Create the list of GeneratedCode objects for a given key & start/end counters. + */ + private List generateCodesForCounterRange(String key, long startCounter, long endCounter) throws CodeGenerationException { + List codes = new ArrayList<>(); + for (long i = startCounter; i <= endCounter; i++) { + codes.add(generateForCounter(key, i)); + } + + return codes; + } + + /** + * Create the GeneratedCode object for a given key & counter. + */ + private GeneratedCode generateForCounter(String key, long counter) throws CodeGenerationException { try { byte[] hash = generateHash(key, counter); - return getDigitsFromHash(hash); + String digits = getDigitsFromHash(hash); + ValidityPeriod validity = getValidityPeriodFromTime(counter); + + return new GeneratedCode(digits, validity); } catch (Exception e) { throw new CodeGenerationException("Failed to generate code. See nested exception.", e); } @@ -83,4 +150,14 @@ private String getDigitsFromHash(byte[] hash) { // Left pad with 0s for a n-digit code return String.format("%0" + digits + "d", truncatedHash); } + + /** + * Get the period of time (start and end Instants) that the code for a given counter is valid for. + */ + private ValidityPeriod getValidityPeriodFromTime(long counter) { + long startTimeSeconds = counter * validitySeconds; + long endTimeSeconds = startTimeSeconds + validitySeconds - 1; + + return new ValidityPeriod(Instant.ofEpochSecond(startTimeSeconds), Instant.ofEpochSecond(endTimeSeconds)); + } } diff --git a/totp/src/main/java/dev/samstevens/totp/code/DefaultCodeVerifier.java b/totp/src/main/java/dev/samstevens/totp/code/DefaultCodeVerifier.java deleted file mode 100644 index 7fdfd33..0000000 --- a/totp/src/main/java/dev/samstevens/totp/code/DefaultCodeVerifier.java +++ /dev/null @@ -1,72 +0,0 @@ -package dev.samstevens.totp.code; - -import dev.samstevens.totp.exceptions.CodeGenerationException; -import dev.samstevens.totp.time.TimeProvider; - -public class DefaultCodeVerifier implements CodeVerifier { - - private final CodeGenerator codeGenerator; - private final TimeProvider timeProvider; - private int timePeriod = 30; - private int allowedTimePeriodDiscrepancy = 1; - - public DefaultCodeVerifier(CodeGenerator codeGenerator, TimeProvider timeProvider) { - this.codeGenerator = codeGenerator; - this.timeProvider = timeProvider; - } - - public void setTimePeriod(int timePeriod) { - this.timePeriod = timePeriod; - } - - public void setAllowedTimePeriodDiscrepancy(int allowedTimePeriodDiscrepancy) { - this.allowedTimePeriodDiscrepancy = allowedTimePeriodDiscrepancy; - } - - @Override - public boolean isValidCode(String secret, String code) { - // Get the current number of seconds since the epoch and - // calculate the number of time periods passed. - long currentBucket = Math.floorDiv(timeProvider.getTime(), timePeriod); - - // Calculate and compare the codes for all the "valid" time periods, - // even if we get an early match, to avoid timing attacks - boolean success = false; - for (int i = -allowedTimePeriodDiscrepancy; i <= allowedTimePeriodDiscrepancy; i++) { - success = checkCode(secret, currentBucket + i, code) || success; - } - - return success; - } - - /** - * Check if a code matches for a given secret and counter. - */ - private boolean checkCode(String secret, long counter, String code) { - try { - String actualCode = codeGenerator.generate(secret, counter); - return timeSafeStringComparison(actualCode, code); - } catch (CodeGenerationException e) { - return false; - } - } - - /** - * Compare two strings for equality without leaking timing information. - */ - private boolean timeSafeStringComparison(String a, String b) { - byte[] aBytes = a.getBytes(); - byte[] bBytes = b.getBytes(); - - if (aBytes.length != bBytes.length) { - return false; - } - - int result = 0; - for (int i = 0; i < aBytes.length; i++) { - result |= aBytes[i] ^ bBytes[i]; - } - - return result == 0; - } -} \ No newline at end of file diff --git a/totp/src/main/java/dev/samstevens/totp/code/GeneratedCode.java b/totp/src/main/java/dev/samstevens/totp/code/GeneratedCode.java new file mode 100644 index 0000000..f33e972 --- /dev/null +++ b/totp/src/main/java/dev/samstevens/totp/code/GeneratedCode.java @@ -0,0 +1,35 @@ +package dev.samstevens.totp.code; + +import java.util.Objects; + +public class GeneratedCode { + private final String digits; + private final ValidityPeriod validityPeriod; + + public GeneratedCode(String digits, ValidityPeriod validity) { + this.digits = digits; + this.validityPeriod = validity; + } + + public String getDigits() { + return digits; + } + + public ValidityPeriod getValidityPeriod() { + return validityPeriod; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + GeneratedCode that = (GeneratedCode) o; + + return Objects.equals(digits, that.digits) && Objects.equals(validityPeriod, that.validityPeriod); + } + + @Override + public int hashCode() { + return Objects.hash(digits, validityPeriod); + } +} diff --git a/totp/src/main/java/dev/samstevens/totp/code/ValidityPeriod.java b/totp/src/main/java/dev/samstevens/totp/code/ValidityPeriod.java new file mode 100644 index 0000000..9f5897c --- /dev/null +++ b/totp/src/main/java/dev/samstevens/totp/code/ValidityPeriod.java @@ -0,0 +1,36 @@ +package dev.samstevens.totp.code; + +import java.time.Instant; +import java.util.Objects; + +public class ValidityPeriod { + private final Instant start; + private final Instant end; + + public ValidityPeriod(Instant start, Instant end) { + this.start = start; + this.end = end; + } + + public Instant getStart() { + return start; + } + + public Instant getEnd() { + return end; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + ValidityPeriod that = (ValidityPeriod) o; + + return Objects.equals(start, that.start) && Objects.equals(end, that.end); + } + + @Override + public int hashCode() { + return Objects.hash(start, end); + } +} diff --git a/totp/src/main/java/dev/samstevens/totp/qr/QrData.java b/totp/src/main/java/dev/samstevens/totp/qr/QrData.java index 7086e2a..3a27878 100644 --- a/totp/src/main/java/dev/samstevens/totp/qr/QrData.java +++ b/totp/src/main/java/dev/samstevens/totp/qr/QrData.java @@ -5,6 +5,7 @@ import java.io.UnsupportedEncodingException; import java.net.URLEncoder; import java.nio.charset.StandardCharsets; +import java.time.Duration; @SuppressWarnings("WeakerAccess") public class QrData { @@ -15,12 +16,12 @@ public class QrData { private final String issuer; private final String algorithm; private final int digits; - private final int period; + private final Duration period; /** * Force use of builder to create instances. */ - private QrData(String type, String label, String secret, String issuer, String algorithm, int digits, int period) { + private QrData(String type, String label, String secret, String issuer, String algorithm, int digits, Duration period) { this.type = type; this.label = label; this.secret = secret; @@ -54,7 +55,7 @@ public int getDigits() { return digits; } - public int getPeriod() { + public Duration getPeriod() { return period; } @@ -70,7 +71,7 @@ public String getUri() { "&issuer=" + uriEncode(issuer) + "&algorithm=" + uriEncode(algorithm) + "&digits=" + digits + - "&period=" + period; + "&period=" + period.getSeconds(); } private String uriEncode(String text) { @@ -93,7 +94,7 @@ public static class Builder { private String issuer; private HashingAlgorithm algorithm = HashingAlgorithm.SHA1; private int digits = 6; - private int period = 30; + private Duration period = Duration.ofSeconds(30); public Builder label(String label) { this.label = label; @@ -120,7 +121,7 @@ public Builder digits(int digits) { return this; } - public Builder period(int period) { + public Builder period(Duration period) { this.period = period; return this; } diff --git a/totp/src/main/java/dev/samstevens/totp/qr/QrDataFactory.java b/totp/src/main/java/dev/samstevens/totp/qr/QrDataFactory.java index 2932ae5..fb6336b 100644 --- a/totp/src/main/java/dev/samstevens/totp/qr/QrDataFactory.java +++ b/totp/src/main/java/dev/samstevens/totp/qr/QrDataFactory.java @@ -1,14 +1,15 @@ package dev.samstevens.totp.qr; import dev.samstevens.totp.code.HashingAlgorithm; +import java.time.Duration; public class QrDataFactory { private HashingAlgorithm defaultAlgorithm; private int defaultDigits; - private int defaultTimePeriod; + private Duration defaultTimePeriod; - public QrDataFactory(HashingAlgorithm defaultAlgorithm, int defaultDigits, int defaultTimePeriod) { + public QrDataFactory(HashingAlgorithm defaultAlgorithm, int defaultDigits, Duration defaultTimePeriod) { this.defaultAlgorithm = defaultAlgorithm; this.defaultDigits = defaultDigits; this.defaultTimePeriod = defaultTimePeriod; diff --git a/totp/src/main/java/dev/samstevens/totp/recovery/RecoveryCodeGenerator.java b/totp/src/main/java/dev/samstevens/totp/recovery/RecoveryCodeGenerator.java index 3100dff..4617314 100644 --- a/totp/src/main/java/dev/samstevens/totp/recovery/RecoveryCodeGenerator.java +++ b/totp/src/main/java/dev/samstevens/totp/recovery/RecoveryCodeGenerator.java @@ -5,16 +5,18 @@ import java.util.Arrays; import java.util.Random; +/** + * Recovery codes must reach a minimum entropy to be secure. + * code entropy = log( {characters-count} ^ {code-length} ) / log(2) + * the settings used below allows the code to reach an entropy of 82 bits: + * log(36^16) / log(2) == 82.7... + * + * Recovery code must be simple to read and enter by end user when needed: + * - generate a code composed of numbers and lower case characters from latin alphabet (36 possible characters) + * - split code in groups separated with dash for better readability, for example 4ckn-xspn-et8t-xgr0 + */ public class RecoveryCodeGenerator { - // Recovery code must reach a minimum entropy to be secured - // code entropy = log( {characters-count} ^ {code-length} ) / log(2) - // the settings used below allows the code to reach an entropy of 82 bits : - // log(36^16) / log(2) == 82.7... - - // Recovery code must be simple to read and enter by end user when needed : - // - generate a code composed of numbers and lower case characters from latin alphabet (36 possible characters) - // - split code in groups separated with dash for better readability, for example 4ckn-xspn-et8t-xgr0 private static final char[] CHARACTERS = "abcdefghijklmnopqrstuvwxyz0123456789".toCharArray(); private static final int CODE_LENGTH = 16; private static final int GROUPS_NBR = 4; @@ -49,5 +51,4 @@ private String generateCode() { return code.toString(); } - } \ No newline at end of file diff --git a/totp/src/main/java/dev/samstevens/totp/time/NtpTimeProvider.java b/totp/src/main/java/dev/samstevens/totp/time/NtpTimeProvider.java index 078b21e..434d1bb 100644 --- a/totp/src/main/java/dev/samstevens/totp/time/NtpTimeProvider.java +++ b/totp/src/main/java/dev/samstevens/totp/time/NtpTimeProvider.java @@ -5,42 +5,43 @@ import org.apache.commons.net.ntp.TimeInfo; import java.net.InetAddress; import java.net.UnknownHostException; +import java.time.Duration; +import java.time.Instant; public class NtpTimeProvider implements TimeProvider { + private static final Duration DEFAULT_TIMEOUT = Duration.ofSeconds(3); private final NTPUDPClient client; private final InetAddress ntpHost; public NtpTimeProvider(String ntpHostname) throws UnknownHostException { - // default timeout of 3 seconds - this(ntpHostname, 3000); + this(ntpHostname, DEFAULT_TIMEOUT); } - public NtpTimeProvider(String ntpHostname, int timeout) throws UnknownHostException { + public NtpTimeProvider(String ntpHostname, Duration timeout) throws UnknownHostException { this(ntpHostname, timeout, "org.apache.commons.net.ntp.NTPUDPClient"); } // Package-private, for tests only NtpTimeProvider(String ntpHostname, String dependentClass) throws UnknownHostException { - // default timeout of 3 seconds - this(ntpHostname, 3000, dependentClass); + this(ntpHostname, DEFAULT_TIMEOUT, dependentClass); } - private NtpTimeProvider(String ntpHostname, int timeout, String dependentClass) throws UnknownHostException { + private NtpTimeProvider(String ntpHostname, Duration timeout, String dependentClass) throws UnknownHostException { // Check the optional commons-net dependency is on the classpath checkHasDependency(dependentClass); client = new NTPUDPClient(); - client.setDefaultTimeout(timeout); + client.setDefaultTimeout((int) timeout.toMillis()); ntpHost = InetAddress.getByName(ntpHostname); } @Override - public long getTime() throws TimeProviderException { + public Instant getTime() throws TimeProviderException { try { TimeInfo timeInfo = client.getTime(ntpHost); - return (long) Math.floor(timeInfo.getReturnTime() / 1000L); + return Instant.ofEpochSecond((long) Math.floor(timeInfo.getReturnTime() / 1000L)); } catch (Exception e) { throw new TimeProviderException("Failed to provide time from NTP server. See nested exception.", e); } @@ -48,7 +49,7 @@ public long getTime() throws TimeProviderException { private void checkHasDependency(String dependentClass) { try { - Class ntpClientClass = Class.forName(dependentClass); + Class.forName(dependentClass); } catch (ClassNotFoundException e) { throw new RuntimeException("The Apache Commons Net library must be on the classpath to use the NtpTimeProvider."); } diff --git a/totp/src/main/java/dev/samstevens/totp/time/SystemTimeProvider.java b/totp/src/main/java/dev/samstevens/totp/time/SystemTimeProvider.java index 3d01f78..b06b4bd 100644 --- a/totp/src/main/java/dev/samstevens/totp/time/SystemTimeProvider.java +++ b/totp/src/main/java/dev/samstevens/totp/time/SystemTimeProvider.java @@ -5,7 +5,7 @@ public class SystemTimeProvider implements TimeProvider { @Override - public long getTime() throws TimeProviderException { - return Instant.now().getEpochSecond(); + public Instant getTime() throws TimeProviderException { + return Instant.now(); } } diff --git a/totp/src/main/java/dev/samstevens/totp/time/TimeProvider.java b/totp/src/main/java/dev/samstevens/totp/time/TimeProvider.java index 4cd0c93..4cdcab0 100644 --- a/totp/src/main/java/dev/samstevens/totp/time/TimeProvider.java +++ b/totp/src/main/java/dev/samstevens/totp/time/TimeProvider.java @@ -1,10 +1,11 @@ package dev.samstevens.totp.time; import dev.samstevens.totp.exceptions.TimeProviderException; +import java.time.Instant; public interface TimeProvider { /** - * @return The number of seconds since Jan 1st 1970, 00:00:00 UTC. + * @return The current time represented as an Instant object. */ - long getTime() throws TimeProviderException; + Instant getTime() throws TimeProviderException; } diff --git a/totp/src/main/java/dev/samstevens/totp/verify/CodeVerifier.java b/totp/src/main/java/dev/samstevens/totp/verify/CodeVerifier.java new file mode 100644 index 0000000..c3d751e --- /dev/null +++ b/totp/src/main/java/dev/samstevens/totp/verify/CodeVerifier.java @@ -0,0 +1,15 @@ +package dev.samstevens.totp.verify; + +import java.time.temporal.TemporalAmount; +import java.util.List; + +public interface CodeVerifier { + + VerifyResult verifyCode(String secret, String code); + + VerifyResult verifyCode(String secret, String code, int allowedNumberOfCodesBeforeAndAfter); + + VerifyResult verifyCode(String secret, String code, TemporalAmount acceptableTimeDrift); + + VerifyResult verifyConsecutiveCodes(String secret, List codes, TemporalAmount acceptableTimeDrift); +} diff --git a/totp/src/main/java/dev/samstevens/totp/verify/DefaultCodeVerifier.java b/totp/src/main/java/dev/samstevens/totp/verify/DefaultCodeVerifier.java new file mode 100644 index 0000000..7e72ab3 --- /dev/null +++ b/totp/src/main/java/dev/samstevens/totp/verify/DefaultCodeVerifier.java @@ -0,0 +1,84 @@ +package dev.samstevens.totp.verify; + +import dev.samstevens.totp.code.CodeGenerator; +import dev.samstevens.totp.code.GeneratedCode; +import dev.samstevens.totp.exceptions.CodeGenerationException; +import dev.samstevens.totp.time.TimeProvider; +import java.time.Duration; +import java.time.Instant; +import java.time.temporal.TemporalAmount; +import java.util.Collections; +import java.util.List; +import java.util.stream.Collectors; + +public class DefaultCodeVerifier implements CodeVerifier { + + private final CodeGenerator codeGenerator; + private final TimeProvider timeProvider; + + public DefaultCodeVerifier(CodeGenerator codeGenerator, TimeProvider timeProvider) { + this.codeGenerator = codeGenerator; + this.timeProvider = timeProvider; + } + + @Override + public VerifyResult verifyCode(String secret, String code) { + return verifyCode(secret, code, 0); + } + + @Override + public VerifyResult verifyCode(String secret, String code, int allowedNumberOfCodesBeforeAndAfter) { + Instant currentTime = timeProvider.getTime(); + + // Generate all the valid codes + List generatedCodes; + try { + generatedCodes = codeGenerator.generate(secret, currentTime, allowedNumberOfCodesBeforeAndAfter); + } catch (CodeGenerationException e) { + return new VerifyResult(false, null); + } + + // Check the code against the valid codes + return verifyConsecutiveCodesAreValid(Collections.singletonList(code), generatedCodes, currentTime); + } + + @Override + public VerifyResult verifyCode(String secret, String code, TemporalAmount acceptableTimeDrift) { + return verifyConsecutiveCodes(secret, Collections.singletonList(code), acceptableTimeDrift); + } + + @Override + public VerifyResult verifyConsecutiveCodes(String secret, List codes, TemporalAmount acceptableTimeDrift) { + Instant currentTime = timeProvider.getTime(); + Instant startTime = currentTime.minus(acceptableTimeDrift); + Instant endTime = currentTime.plus(acceptableTimeDrift); + + List generatedCodes; + try { + generatedCodes = codeGenerator.generateBetween(secret, startTime, endTime); + } catch (CodeGenerationException e) { + return new VerifyResult(false, null); + } + + return verifyConsecutiveCodesAreValid(codes, generatedCodes, currentTime); + } + + private VerifyResult verifyConsecutiveCodesAreValid(List codes, List generatedCodes, Instant currentTime) { + // Transform the list of GeneratedCodes into a list of just the digits + List generatedCodesStrings = generatedCodes.stream().map(GeneratedCode::getDigits).collect(Collectors.toList()); + + // Check if the given codes exist consecutively in the generated codes + int firstValidCodeIndex = Collections.indexOfSubList(generatedCodesStrings, codes); + + // Given codes do not consecutively appear in valid codes + if (firstValidCodeIndex < 0) { + return new VerifyResult(false, null); + } + + // Codes do appear consecutively, get the duration between now and the first code being valid + GeneratedCode firstCode = generatedCodes.get(firstValidCodeIndex); + Duration timeDrift = Duration.between(currentTime, firstCode.getValidityPeriod().getStart()); + + return new VerifyResult(true, timeDrift); + } +} \ No newline at end of file diff --git a/totp/src/main/java/dev/samstevens/totp/verify/VerifyResult.java b/totp/src/main/java/dev/samstevens/totp/verify/VerifyResult.java new file mode 100644 index 0000000..149f99f --- /dev/null +++ b/totp/src/main/java/dev/samstevens/totp/verify/VerifyResult.java @@ -0,0 +1,22 @@ +package dev.samstevens.totp.verify; + +import java.time.Duration; + +public class VerifyResult { + private final boolean isValid; + private final Duration timeDrift; + + public VerifyResult(boolean isValid, Duration timeDrift) { + this.isValid = isValid; + this.timeDrift = timeDrift; + } + + public boolean isValid() { + return isValid; + } + + public Duration getTimeDrift() + { + return timeDrift; + } +} diff --git a/totp/src/test/java/dev/samstevens/totp/code/DefaultCodeGeneratorTest.java b/totp/src/test/java/dev/samstevens/totp/code/DefaultCodeGeneratorTest.java index d37bb54..f1d93cf 100644 --- a/totp/src/test/java/dev/samstevens/totp/code/DefaultCodeGeneratorTest.java +++ b/totp/src/test/java/dev/samstevens/totp/code/DefaultCodeGeneratorTest.java @@ -6,20 +6,134 @@ import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.MethodSource; import java.security.InvalidParameterException; +import java.time.Duration; +import java.time.Instant; +import java.util.Arrays; +import java.util.List; import java.util.stream.Stream; import static org.junit.jupiter.api.Assertions.*; import static org.junit.jupiter.params.provider.Arguments.arguments; public class DefaultCodeGeneratorTest { + @Test + public void testInvalidHashingAlgorithmThrowsException() { + assertThrows(InvalidParameterException.class, () -> { + new DefaultCodeGenerator(null); + }); + } + + @Test + public void testInvalidDigitLengthThrowsException() { + assertThrows(InvalidParameterException.class, () -> { + new DefaultCodeGenerator(HashingAlgorithm.SHA1, 0); + }); + } + + @Test + public void testInvalidValidityDurationThrowsException() { + assertThrows(InvalidParameterException.class, () -> { + new DefaultCodeGenerator(HashingAlgorithm.SHA1, 6, Duration.ofSeconds(0)); + }); + } + + @Test + public void testGeneratingWithNegativeBeforeAndAfterThrowsException() + { + InvalidParameterException e = assertThrows(InvalidParameterException.class, () -> { + CodeGenerator generator = new DefaultCodeGenerator(HashingAlgorithm.SHA1, 6); + generator.generate("W3C5B3WKR4AUKFVWYU2WNMYB756OAKWY", Instant.now(), - 1); + }); + + assertEquals("Number of codes before and after to generate must be greater or equal to zero.", e.getMessage()); + } + + + @Test + public void testGeneratingBetweenWithBadTimesThrowsException() + { + InvalidParameterException e = assertThrows(InvalidParameterException.class, () -> { + CodeGenerator generator = new DefaultCodeGenerator(HashingAlgorithm.SHA1, 6); + generator.generateBetween("W3C5B3WKR4AUKFVWYU2WNMYB756OAKWY", Instant.now(), Instant.now().minusSeconds(60)); + }); + + assertEquals("End time must be after start time.", e.getMessage()); + } + @ParameterizedTest - @MethodSource("expectedCodesProvider") - public void testCodeIsGenerated(String secret, int time, HashingAlgorithm algorithm, String expectedCode) throws CodeGenerationException { - String code = generateCode(algorithm, secret, time); + @MethodSource("testGeneratingCodesProvider") + public void testGeneratingCodes(HashingAlgorithm algo, String secret, Instant timeToRunAt, int disc, List expectedCodes) throws CodeGenerationException { + CodeGenerator generator = new DefaultCodeGenerator(algo); + List generatedCodes = generator.generate(secret, timeToRunAt, disc); + assertEquals(generatedCodes, expectedCodes); + } + + static Stream testGeneratingCodesProvider() { + return Stream.of( + arguments( + // Hashing algorithm, + HashingAlgorithm.SHA1, + // Secret + "W3C5B3WKR4AUKFVWYU2WNMYB756OAKWY", + // Time to run for + Instant.ofEpochSecond(1587772860), + // Number of codes to generate before and after + 2, + // Expected codes + Arrays.asList( + createGeneratedCode("897990", 1587772800, 1587772829), + createGeneratedCode("987260", 1587772830, 1587772859), + createGeneratedCode("594169", 1587772860, 1587772889), + createGeneratedCode("896541", 1587772890, 1587772919), + createGeneratedCode("733574", 1587772920, 1587772949) + ) + ), + + arguments( + // Hashing algorithm, + HashingAlgorithm.SHA256, + // Secret + "makrzl2hict4ojeji2iah4kndmq6sgka", + // Time to run for + Instant.ofEpochSecond(1593607513), + // Number of codes to generate before and after + 1, + // Expected codes + Arrays.asList( + createGeneratedCode("773412", 1593607470, 1593607499), + createGeneratedCode("250378", 1593607500, 1593607529), + createGeneratedCode("967103", 1593607530, 1593607559) + ) + ), - assertEquals(expectedCode, code); + arguments( + // Hashing algorithm, + HashingAlgorithm.SHA512, + // Secret + "EX47GINFPBK5GNLYLILGD2H6ZLGJNNWB", + // Time to run for + Instant.ofEpochSecond(1608940785), + // Number of codes to generate before and after + 3, + // Expected codes + Arrays.asList( + createGeneratedCode("381260", 1608940680, 1608940709), + createGeneratedCode("546994", 1608940710, 1608940739), + createGeneratedCode("492946", 1608940740, 1608940769), + + createGeneratedCode("977130", 1608940770, 1608940799), + + createGeneratedCode("416247", 1608940800, 1608940829), + createGeneratedCode("610646", 1608940830, 1608940859), + createGeneratedCode("418802", 1608940860, 1608940889) + ) + ) + ); } + + + static Stream expectedCodesProvider() { return Stream.of( arguments("W3C5B3WKR4AUKFVWYU2WNMYB756OAKWY", 1567631536, HashingAlgorithm.SHA1, "082371"), @@ -32,47 +146,71 @@ static Stream expectedCodesProvider() { ); } + @Test - public void testDigitLength() throws CodeGenerationException { - DefaultCodeGenerator g = new DefaultCodeGenerator(HashingAlgorithm.SHA1); - String code = g.generate("W3C5B3WKR4AUKFVWYU2WNMYB756OAKWY", 1567631536); - assertEquals(6, code.length()); + public void testGeneratingMultipleCodesBetweenStartAndEndTimes() throws CodeGenerationException { - g = new DefaultCodeGenerator(HashingAlgorithm.SHA1, 8); - code = g.generate("W3C5B3WKR4AUKFVWYU2WNMYB756OAKWY", 1567631536); - assertEquals(8, code.length()); + String secret = "W3C5B3WKR4AUKFVWYU2WNMYB756OAKWY"; + // 2020-04-25 00:00:00 UTC + Instant startTime = Instant.ofEpochSecond(1587772800); + // 2020-04-25 00:01:30 UTC + Instant endTime = Instant.ofEpochSecond(1587772890); - g = new DefaultCodeGenerator(HashingAlgorithm.SHA1, 4); - code = g.generate("W3C5B3WKR4AUKFVWYU2WNMYB756OAKWY", 1567631536); - assertEquals(4, code.length()); - } + DefaultCodeGenerator generator = new DefaultCodeGenerator(HashingAlgorithm.SHA1); - @Test - public void testInvalidHashingAlgorithmThrowsException() { - assertThrows(InvalidParameterException.class, () -> { - new DefaultCodeGenerator(null, 6); - }); + List expectedCodes = Arrays.asList("897990", "987260", "594169", "896541"); + List expectedStartTimes = Arrays.asList( + Instant.ofEpochSecond(1587772800), + Instant.ofEpochSecond(1587772830), + Instant.ofEpochSecond(1587772860), + Instant.ofEpochSecond(1587772890) + ); + List expectedEndTimes = Arrays.asList( + Instant.ofEpochSecond(1587772829), + Instant.ofEpochSecond(1587772859), + Instant.ofEpochSecond(1587772889), + Instant.ofEpochSecond(1587772919) + ); + + List codes = generator.generateBetween(secret, startTime, endTime); + + for (int i = 0; i < codes.size(); i++) { + assertEquals(expectedCodes.get(i), codes.get(i).getDigits()); + assertEquals(expectedStartTimes.get(i), codes.get(i).getValidityPeriod().getStart()); + assertEquals(expectedEndTimes.get(i), codes.get(i).getValidityPeriod().getEnd()); + } } - + @Test - public void testInvalidDigitLengthThrowsException() { - assertThrows(InvalidParameterException.class, () -> { - new DefaultCodeGenerator(HashingAlgorithm.SHA1, 0); - }); + public void testDigitLength() throws CodeGenerationException { +// DefaultCodeGenerator g = new DefaultCodeGenerator(HashingAlgorithm.SHA1); +// GeneratedCode code = g.generate("W3C5B3WKR4AUKFVWYU2WNMYB756OAKWY", Instant.ofEpochSecond(1567631536)); +// assertEquals(6, code.getDigits().length()); +// +// g = new DefaultCodeGenerator(HashingAlgorithm.SHA1, 8); +// code = g.generate("W3C5B3WKR4AUKFVWYU2WNMYB756OAKWY", Instant.ofEpochSecond(1567631536)); +// assertEquals(8, code.getDigits().length()); +// +// g = new DefaultCodeGenerator(HashingAlgorithm.SHA1, 4); +// code = g.generate("W3C5B3WKR4AUKFVWYU2WNMYB756OAKWY", Instant.ofEpochSecond(1567631536)); +// assertEquals(4, code.getDigits().length()); } + + @Test - public void testInvalidKeyThrowsCodeGenerationException() throws CodeGenerationException { + public void testInvalidKeyThrowsCodeGenerationException() { CodeGenerationException e = assertThrows(CodeGenerationException.class, () -> { DefaultCodeGenerator g = new DefaultCodeGenerator(HashingAlgorithm.SHA1, 4); - g.generate("1234", 1567631536); + g.generate("1234", Instant.ofEpochSecond(1567631536), 0); }); assertNotNull(e.getCause()); } - private String generateCode(HashingAlgorithm algorithm, String secret, int time) throws CodeGenerationException { - long currentBucket = Math.floorDiv(time, 30); - DefaultCodeGenerator g = new DefaultCodeGenerator(algorithm); - return g.generate(secret, currentBucket); + /** + * Helper method to construct a GeneratedCode object. + */ + private static GeneratedCode createGeneratedCode(String digits, long startTime, long endTime) { + return new GeneratedCode(digits, new ValidityPeriod(Instant.ofEpochSecond(startTime), Instant.ofEpochSecond(endTime))); } } diff --git a/totp/src/test/java/dev/samstevens/totp/code/DefaultCodeVerifierTest.java b/totp/src/test/java/dev/samstevens/totp/code/DefaultCodeVerifierTest.java deleted file mode 100644 index ba85438..0000000 --- a/totp/src/test/java/dev/samstevens/totp/code/DefaultCodeVerifierTest.java +++ /dev/null @@ -1,57 +0,0 @@ -package dev.samstevens.totp.code; - -import dev.samstevens.totp.exceptions.CodeGenerationException; -import dev.samstevens.totp.time.TimeProvider; -import org.junit.jupiter.api.Test; -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.Mockito.*; - -public class DefaultCodeVerifierTest { - - @Test - public void testCodeIsValid() { - String secret = "EX47GINFPBK5GNLYLILGD2H6ZLGJNNWB"; - long timeToRunAt = 1567975936; - String correctCode = "862707"; - int timePeriod = 30; - - // allow for a -/+ ~30 second discrepancy - assertTrue(isValidCode(secret, correctCode, timeToRunAt - timePeriod, timePeriod)); - assertTrue(isValidCode(secret, correctCode, timeToRunAt, timePeriod)); - assertTrue(isValidCode(secret, correctCode, timeToRunAt + timePeriod, timePeriod)); - - // but no more - assertFalse(isValidCode(secret, correctCode, timeToRunAt + timePeriod + 15, timePeriod)); - - // test wrong code fails - assertFalse(isValidCode(secret, "123", timeToRunAt, timePeriod)); - } - - @Test - public void testCodeGenerationFailureReturnsFalse() throws CodeGenerationException { - - String secret = "EX47GINFPBK5GNLYLILGD2H6ZLGJNNWB"; - - TimeProvider timeProvider = mock(TimeProvider.class); - when(timeProvider.getTime()).thenReturn(1567975936L); - - CodeGenerator codeGenerator = mock(CodeGenerator.class); - when(codeGenerator.generate(anyString(), anyLong())).thenThrow(new CodeGenerationException("Test", new RuntimeException())); - - DefaultCodeVerifier verifier = new DefaultCodeVerifier(codeGenerator, timeProvider); - verifier.setAllowedTimePeriodDiscrepancy(1); - - - assertEquals(false, verifier.isValidCode(secret, "1234")); - } - - private boolean isValidCode(String secret, String code, long time, int timePeriod) { - TimeProvider timeProvider = mock(TimeProvider.class); - when(timeProvider.getTime()).thenReturn(time); - - DefaultCodeVerifier verifier = new DefaultCodeVerifier(new DefaultCodeGenerator(), timeProvider); - verifier.setTimePeriod(timePeriod); - - return verifier.isValidCode(secret, code); - } -} diff --git a/totp/src/test/java/dev/samstevens/totp/code/GeneratedCodeTest.java b/totp/src/test/java/dev/samstevens/totp/code/GeneratedCodeTest.java new file mode 100644 index 0000000..623ace7 --- /dev/null +++ b/totp/src/test/java/dev/samstevens/totp/code/GeneratedCodeTest.java @@ -0,0 +1,36 @@ +package dev.samstevens.totp.code; + +import org.junit.jupiter.api.Test; +import java.time.Instant; +import static org.junit.jupiter.api.Assertions.*; + +public class GeneratedCodeTest { + + @Test + public void testEquality() { + GeneratedCode code1 = createGeneratedCode("123456", 1567631536, 1567631596); + GeneratedCode code2 = createGeneratedCode("123456", 1567631536, 1567631596); + GeneratedCode code3 = createGeneratedCode("123457", 1567631536, 1567631596); + GeneratedCode code4 = createGeneratedCode("123456", 1567631536, 1567631597); + GeneratedCode code6 = new GeneratedCode("123457", null); + + assertTrue(code1.equals(code1)); + assertTrue(code1.equals(code2)); + assertTrue(code2.equals(code1)); + + assertFalse(code1.equals(code3)); + assertFalse(code3.equals(code1)); + assertFalse(code2.equals(code4)); + assertFalse(code3.equals(code6)); + + assertFalse(code1.equals(new Object())); + assertFalse(code1.equals(null)); + } + + /** + * Helper method to construct a GeneratedCode object. + */ + private static GeneratedCode createGeneratedCode(String digits, long startTime, long endTime) { + return new GeneratedCode(digits, new ValidityPeriod(Instant.ofEpochSecond(startTime), Instant.ofEpochSecond(endTime))); + } +} diff --git a/totp/src/test/java/dev/samstevens/totp/code/ValidityPeriodTest.java b/totp/src/test/java/dev/samstevens/totp/code/ValidityPeriodTest.java new file mode 100644 index 0000000..3bacb53 --- /dev/null +++ b/totp/src/test/java/dev/samstevens/totp/code/ValidityPeriodTest.java @@ -0,0 +1,25 @@ +package dev.samstevens.totp.code; + +import org.junit.jupiter.api.Test; +import java.time.Instant; +import static org.junit.jupiter.api.Assertions.*; + +public class ValidityPeriodTest { + + @Test + public void testEquality() { + ValidityPeriod period1 = new ValidityPeriod(Instant.ofEpochSecond(1567631536), Instant.ofEpochSecond(1567631596)); + ValidityPeriod period2 = new ValidityPeriod(Instant.ofEpochSecond(1567631536), Instant.ofEpochSecond(1567631596)); + ValidityPeriod period3 = new ValidityPeriod(Instant.ofEpochSecond(1567642536), Instant.ofEpochSecond(1568642536)); + + assertTrue(period1.equals(period1)); + assertTrue(period1.equals(period2)); + assertTrue(period2.equals(period1)); + + assertFalse(period1.equals(period3)); + assertFalse(period3.equals(period1)); + + assertFalse(period1.equals(new Object())); + assertFalse(period1.equals(null)); + } +} diff --git a/totp/src/test/java/dev/samstevens/totp/qr/QrDataFactoryTest.java b/totp/src/test/java/dev/samstevens/totp/qr/QrDataFactoryTest.java index ccc6d64..e7e9f4e 100644 --- a/totp/src/test/java/dev/samstevens/totp/qr/QrDataFactoryTest.java +++ b/totp/src/test/java/dev/samstevens/totp/qr/QrDataFactoryTest.java @@ -2,6 +2,7 @@ import dev.samstevens.totp.code.HashingAlgorithm; import org.junit.jupiter.api.Test; +import java.time.Duration; import static org.junit.jupiter.api.Assertions.*; public class QrDataFactoryTest { @@ -9,11 +10,11 @@ public class QrDataFactoryTest { @Test public void testFactorySetsDefaultsOnBuilder() { - QrDataFactory qrDataFactory = new QrDataFactory(HashingAlgorithm.SHA256, 6, 30); + QrDataFactory qrDataFactory = new QrDataFactory(HashingAlgorithm.SHA256, 6, Duration.ofSeconds(30)); QrData data = qrDataFactory.newBuilder().build(); assertEquals(HashingAlgorithm.SHA256.getFriendlyName(), data.getAlgorithm()); assertEquals(6, data.getDigits()); - assertEquals(30, data.getPeriod()); + assertEquals(Duration.ofSeconds(30), data.getPeriod()); } } diff --git a/totp/src/test/java/dev/samstevens/totp/qr/QrDataTest.java b/totp/src/test/java/dev/samstevens/totp/qr/QrDataTest.java index 6da428b..8c860d0 100644 --- a/totp/src/test/java/dev/samstevens/totp/qr/QrDataTest.java +++ b/totp/src/test/java/dev/samstevens/totp/qr/QrDataTest.java @@ -2,6 +2,7 @@ import dev.samstevens.totp.code.HashingAlgorithm; import org.junit.jupiter.api.Test; +import java.time.Duration; import static org.junit.jupiter.api.Assertions.*; public class QrDataTest { @@ -14,7 +15,7 @@ public void testUriGeneration() { .issuer("AppName AppCorp") .algorithm(HashingAlgorithm.SHA256) .digits(6) - .period(30) + .period(Duration.ofSeconds(30)) .build(); assertEquals( @@ -31,7 +32,7 @@ public void testNullFieldUriGeneration() { .issuer("AppName AppCorp") .algorithm(HashingAlgorithm.SHA256) .digits(6) - .period(30) + .period(Duration.ofSeconds(30)) .build(); assertEquals( diff --git a/totp/src/test/java/dev/samstevens/totp/qr/ZxingPngQrGeneratorTest.java b/totp/src/test/java/dev/samstevens/totp/qr/ZxingPngQrGeneratorTest.java index 88dc5d0..2b2450d 100644 --- a/totp/src/test/java/dev/samstevens/totp/qr/ZxingPngQrGeneratorTest.java +++ b/totp/src/test/java/dev/samstevens/totp/qr/ZxingPngQrGeneratorTest.java @@ -8,6 +8,7 @@ import java.awt.image.BufferedImage; import java.io.File; import java.io.IOException; +import java.time.Duration; import static dev.samstevens.totp.IOUtils.*; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.*; @@ -25,7 +26,7 @@ public void testSomething() throws QrGenerationException, IOException { .secret("EX47GINFPBK5GNLYLILGD2H6ZLGJNNWB") .issuer("AppName") .digits(6) - .period(30) + .period(Duration.ofSeconds(30)) .build(); writeFile(generator.generate(data), "./test_qr.png"); @@ -78,7 +79,7 @@ private QrData getData() { .secret("EX47GINFPBK5GNLYLILGD2H6ZLGJNNWB") .issuer("AppName") .digits(6) - .period(30) + .period(Duration.ofSeconds(30)) .build(); } } diff --git a/totp/src/test/java/dev/samstevens/totp/time/NtpTimeProviderTest.java b/totp/src/test/java/dev/samstevens/totp/time/NtpTimeProviderTest.java index 730f8d9..daa978a 100644 --- a/totp/src/test/java/dev/samstevens/totp/time/NtpTimeProviderTest.java +++ b/totp/src/test/java/dev/samstevens/totp/time/NtpTimeProviderTest.java @@ -10,7 +10,7 @@ public class NtpTimeProviderTest { @Test public void testProvidesTime() throws UnknownHostException { TimeProvider time = new NtpTimeProvider("pool.ntp.org"); - long currentTime = time.getTime(); + long currentTime = time.getTime().getEpochSecond(); // epoch should be 10 digits for the foreseeable future... assertEquals(10, String.valueOf(currentTime).length()); diff --git a/totp/src/test/java/dev/samstevens/totp/time/SystemTimeProviderTest.java b/totp/src/test/java/dev/samstevens/totp/time/SystemTimeProviderTest.java index 397a935..63a436f 100644 --- a/totp/src/test/java/dev/samstevens/totp/time/SystemTimeProviderTest.java +++ b/totp/src/test/java/dev/samstevens/totp/time/SystemTimeProviderTest.java @@ -1,6 +1,7 @@ package dev.samstevens.totp.time; import org.junit.jupiter.api.Test; +import java.time.Duration; import java.time.Instant; import static org.junit.jupiter.api.Assertions.*; @@ -9,15 +10,16 @@ public class SystemTimeProviderTest { @Test public void testProvidesTime() { - long currentTime = Instant.now().getEpochSecond(); + Instant currentTime = Instant.now(); + TimeProvider time = new SystemTimeProvider(); - long providedTime = time.getTime(); + Instant providedTime = time.getTime(); - // allow +=5 second discrepancy for test environments - assertTrue(currentTime - 5 <= providedTime); - assertTrue(providedTime <= currentTime + 5); + // allow +/-3 second discrepancy for test environments + Duration difference = Duration.between(currentTime, providedTime); + assertTrue(Math.abs(difference.getSeconds()) < 3); // epoch should be 10 digits for the foreseeable future... - assertEquals(10, String.valueOf(currentTime).length()); + assertEquals(10, String.valueOf(providedTime.getEpochSecond()).length()); } } diff --git a/totp/src/test/java/dev/samstevens/totp/verify/DefaultCodeVerifierTest.java b/totp/src/test/java/dev/samstevens/totp/verify/DefaultCodeVerifierTest.java new file mode 100644 index 0000000..d8fb708 --- /dev/null +++ b/totp/src/test/java/dev/samstevens/totp/verify/DefaultCodeVerifierTest.java @@ -0,0 +1,130 @@ +package dev.samstevens.totp.code; + +import dev.samstevens.totp.exceptions.CodeGenerationException; +import dev.samstevens.totp.time.TimeProvider; +import dev.samstevens.totp.verify.DefaultCodeVerifier; +import dev.samstevens.totp.verify.VerifyResult; +import org.junit.jupiter.api.Test; +import java.time.Duration; +import java.time.Instant; +import java.util.Arrays; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +public class DefaultCodeVerifierTest { + + @Test + public void testCodeIsValid() { + String secret = "EX47GINFPBK5GNLYLILGD2H6ZLGJNNWB"; + long timeToRunAt = 1567975936; + String correctCode = "862707"; + Duration timePeriod = Duration.ofSeconds(30); + + // allow for a -/+ ~30 second discrepancy + assertTrue(isValidCode(secret, correctCode, timeToRunAt - timePeriod.getSeconds(), timePeriod)); + assertTrue(isValidCode(secret, correctCode, timeToRunAt, timePeriod)); + assertTrue(isValidCode(secret, correctCode, timeToRunAt + timePeriod.getSeconds(), timePeriod)); + + // but no more + assertFalse(isValidCode(secret, correctCode, timeToRunAt + timePeriod.getSeconds() + 15, timePeriod)); + + // test wrong code fails + assertFalse(isValidCode(secret, "123", timeToRunAt, timePeriod)); + } + + @Test + public void testCodeGenerationFailureReturnsFalse() throws CodeGenerationException { + + String secret = "EX47GINFPBK5GNLYLILGD2H6ZLGJNNWB"; + + TimeProvider timeProvider = mock(TimeProvider.class); + when(timeProvider.getTime()).thenReturn(Instant.ofEpochSecond(1567975936L)); + + CodeGenerator codeGenerator = mock(CodeGenerator.class); + when(codeGenerator.generate(anyString(), any(Instant.class), anyInt())) + .thenThrow(new CodeGenerationException("Test", new RuntimeException())); + + DefaultCodeVerifier verifier = new DefaultCodeVerifier(codeGenerator, timeProvider); + VerifyResult result = verifier.verifyCode(secret, "1234"); + + assertEquals(false, result.isValid()); + assertNull(result.getTimeDrift()); + } + + ///////////////////// + + @Test + public void testVerifySingleCode() { + String secret = "EX47GINFPBK5GNLYLILGD2H6ZLGJNNWB"; + + // Ran at 2020-05-08 22:35:45 UTC + TimeProvider timeProvider = mock(TimeProvider.class); + when(timeProvider.getTime()).thenReturn(Instant.ofEpochSecond(1588977345)); + + DefaultCodeVerifier verifier = new DefaultCodeVerifier(new DefaultCodeGenerator(), timeProvider); + + VerifyResult r = verifier.verifyCode(secret, "237357"); + assertTrue(r.isValid()); + assertEquals(-15, r.getTimeDrift().getSeconds()); + } + + @Test + public void testVerifySingleCodeWithCodeDrifts() { + String secret = "EX47GINFPBK5GNLYLILGD2H6ZLGJNNWB"; + + // Ran at 2020-05-08 22:35:45 UTC + TimeProvider timeProvider = mock(TimeProvider.class); + when(timeProvider.getTime()).thenReturn(Instant.ofEpochSecond(1588977345)); + + DefaultCodeVerifier verifier = new DefaultCodeVerifier(new DefaultCodeGenerator(), timeProvider); + + VerifyResult r = verifier.verifyCode(secret, "631654", 1); + assertTrue(r.isValid()); + assertEquals(-45, r.getTimeDrift().getSeconds()); + } + + @Test + public void testVerifySingleCodeWithTimeDrifts() { + String secret = "EX47GINFPBK5GNLYLILGD2H6ZLGJNNWB"; + + // Ran at 2020-05-08 22:35:45 UTC + TimeProvider timeProvider = mock(TimeProvider.class); + when(timeProvider.getTime()).thenReturn(Instant.ofEpochSecond(1588977345)); + + DefaultCodeVerifier verifier = new DefaultCodeVerifier(new DefaultCodeGenerator(), timeProvider); + + VerifyResult r = verifier.verifyCode(secret, "877212", Duration.ofMinutes(3)); + assertTrue(r.isValid()); + assertEquals(-165, r.getTimeDrift().getSeconds()); + } + + @Test + public void testConsecutiveCodesAreValid() { + String secret = "EX47GINFPBK5GNLYLILGD2H6ZLGJNNWB"; + String firstCode = "092231"; + String secondCode = "517872"; + String thirdCode = "398601"; + + TimeProvider timeProvider = mock(TimeProvider.class); + when(timeProvider.getTime()).thenReturn(Instant.ofEpochSecond(1567975936L)); + + DefaultCodeVerifier verifier = new DefaultCodeVerifier(new DefaultCodeGenerator(), timeProvider); + + List codes = Arrays.asList(firstCode, secondCode, thirdCode); + VerifyResult r = verifier.verifyConsecutiveCodes(secret, codes, Duration.ofHours(24)); + assertTrue(r.isValid()); + assertEquals(-76500, r.getTimeDrift().getSeconds()); + } + + private boolean isValidCode(String secret, String code, long time, Duration timePeriod) { + TimeProvider timeProvider = mock(TimeProvider.class); + when(timeProvider.getTime()).thenReturn(Instant.ofEpochSecond(time)); + +// DefaultCodeVerifier verifier = new DefaultCodeVerifier(new DefaultCodeGenerator(), timeProvider, timePeriod); +// +// return verifier.isValidCode(secret, code); + return false; + } +}