query) throws URISyntaxException {
query.put(URIHelper.DIGITS, String.valueOf(passwordLength));
query.put(URIHelper.ALGORITHM, algorithm.name());
query.put(URIHelper.SECRET, new String(secret, UTF_8));
query.put(URIHelper.ISSUER, issuer);
String path = account.isEmpty() ? URIHelper.encode(issuer) : String.format("%s:%s", URIHelper.encode(issuer), URIHelper.encode(account));
return URIHelper.createURI(URL_SCHEME, type, path, query);
}
/**
* Decode a base32 value to bytes array
*
* @param value base32 value
* @return bytes array
*/
private byte[] decodeBase32(final byte[] value) {
Base32 codec = new Base32();
return codec.decode(value);
}
private byte[] longToBytes(final long value) {
return ByteBuffer.allocate(Long.BYTES).putLong(value).array();
}
private byte[] generateHash(final byte[] secret, final byte[] data) throws InvalidKeyException, NoSuchAlgorithmException {
// Create a secret key with correct SHA algorithm
SecretKeySpec signKey = new SecretKeySpec(secret, "RAW");
// Mac is 'message authentication code' algorithm (RFC 2104)
Mac mac = Mac.getInstance(algorithm.getHMACName());
mac.init(signKey);
// Hash data with generated sign key
return mac.doFinal(data);
}
private String getCodeFromHash(final byte[] hash) {
/* Find mask to get last 4 digits:
1. Set all bits to 1: ~0 -> 11111111 -> 255 decimal -> 0xFF
2. Shift n (in this case 4, because we want the last 4 bits) bits to left with <<
3. Negate the result: 1111 1100 -> 0000 0011
*/
int mask = ~(~0 << 4);
/* Get last 4 bits of hash as offset:
Use the bitwise AND (&) operator to select last 4 bits
Mask should be 00001111 = 15 = 0xF
Last byte of hash & 0xF = last 4 bits:
Example:
Input: decimal 219 as binary: 11011011 &
Mask: decimal 15 as binary: 00001111
-----------------------------------------
Output: decimal 11 as binary: 00001011
*/
byte lastByte = hash[hash.length - 1];
int offset = lastByte & mask;
// Get 4 bytes from hash from offset to offset + 3
byte[] truncatedHashInBytes = { hash[offset], hash[offset + 1], hash[offset + 2], hash[offset + 3] };
// Wrap in ByteBuffer to convert bytes to long
ByteBuffer byteBuffer = ByteBuffer.wrap(truncatedHashInBytes);
long truncatedHash = byteBuffer.getInt();
// Mask most significant bit
truncatedHash &= 0x7FFFFFFF;
// Modulo (%) truncatedHash by 10^passwordLength
truncatedHash %= Math.pow(10, passwordLength);
// Left pad with 0s for an n-digit code
return String.format("%0" + passwordLength + "d", truncatedHash);
}
public static final class Builder {
private int passwordLength;
private HMACAlgorithm algorithm;
/**
* Base32 encoded secret
*/
private final byte[] secret;
/**
* Creates a new builder.
*
* Use {@link SecretGenerator#generate()} to create a secret.
*
* If you are using a shared secret from another generator, you would likely need to encode it using
* {@link org.apache.commons.codec.binary.Base32#encode(byte[])}}
*
* @param secret Base32 encoded secret
*/
public Builder(final byte[] secret) {
if (secret.length == 0)
throw new IllegalArgumentException("Secret must not be empty");
this.secret = secret;
this.passwordLength = DEFAULT_PASSWORD_LENGTH;
this.algorithm = DEFAULT_HMAC_ALGORITHM;
}
/**
* @param secret Base32 encoded secret
*/
public Builder(String secret) {
this(secret.getBytes(UTF_8));
}
public Builder withPasswordLength(final int passwordLength) {
if (!passwordLengthIsValid(passwordLength))
throw new IllegalArgumentException("Password length must be between 6 and 8 digits");
this.passwordLength = passwordLength;
return this;
}
public Builder withAlgorithm(final HMACAlgorithm algorithm) {
this.algorithm = algorithm;
return this;
}
public HOTPGenerator build() {
return new HOTPGenerator(this);
}
private boolean passwordLengthIsValid(final int passwordLength) {
return passwordLength >= 6 && passwordLength <= 8;
}
}
}
================================================
FILE: src/main/java/com/bastiaanjansen/otp/SecretGenerator.java
================================================
package com.bastiaanjansen.otp;
import org.apache.commons.codec.binary.Base32;
import java.security.SecureRandom;
/**
* A secret generator to generate OTP secrets
*
* @author Bastiaan Jansen
*/
public class SecretGenerator {
private SecretGenerator() {}
/**
* Default amount of bits for secret generation
*/
public static final int DEFAULT_BITS = 160;
private static final SecureRandom random = new SecureRandom();
private static final Base32 encoder = new Base32();
/**
* Generate an OTP base32 secret with default amount of bits
*
* @return generated secret
*/
public static byte[] generate() {
return generate(DEFAULT_BITS);
}
/**
* Generate an OTP base32 secret
*
* @param bits length, this should be greater than or equal to the length of the HMAC algorithm type:
* SHA1: 160 bits
* SHA256: 256 bits
* SHA512: 512 bits
* @return generated secret
*/
public static byte[] generate(final int bits) {
if (bits <= 0)
throw new IllegalArgumentException("Bits must be greater than or equal to 0");
byte[] bytes = new byte[bits / Byte.SIZE];
random.nextBytes(bytes);
return encoder.encode(bytes);
}
}
================================================
FILE: src/main/java/com/bastiaanjansen/otp/TOTPGenerator.java
================================================
package com.bastiaanjansen.otp;
import com.bastiaanjansen.otp.helpers.URIHelper;
import java.net.URI;
import java.net.URISyntaxException;
import java.time.*;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.TimeUnit;
import java.util.function.Consumer;
import static java.nio.charset.StandardCharsets.UTF_8;
public final class TOTPGenerator {
private static final String OTP_TYPE = "totp";
private static final Duration DEFAULT_PERIOD = Duration.ofSeconds(30);
private static final Clock DEFAULT_CLOCK = Clock.system(ZoneId.systemDefault());
private final Duration period;
private final Clock clock;
private final HOTPGenerator hotpGenerator;
private TOTPGenerator(final Builder builder) {
this.period = builder.period;
this.clock = builder.clock;
this.hotpGenerator = builder.hotpBuilder.build();
}
public static TOTPGenerator fromURI(URI uri) throws URISyntaxException {
Map query = URIHelper.queryItems(uri);
String secret = Optional.ofNullable(query.get(URIHelper.SECRET))
.orElseThrow(() -> new IllegalArgumentException("Secret query parameter must be set"));
Builder builder = new Builder(secret);
try {
Optional.ofNullable(query.get(URIHelper.PERIOD))
.map(Long::parseLong)
.map(Duration::ofSeconds)
.ifPresent(builder::withPeriod);
Optional.ofNullable(query.get(URIHelper.DIGITS))
.map(Integer::valueOf)
.ifPresent(builder.hotpBuilder::withPasswordLength);
Optional.ofNullable(query.get(URIHelper.ALGORITHM))
.map(String::toUpperCase)
.map(HMACAlgorithm::valueOf)
.ifPresent(builder.hotpBuilder::withAlgorithm);
} catch (Exception e) {
throw new URISyntaxException(uri.toString(), "URI could not be parsed");
}
return builder.build();
}
public static TOTPGenerator withDefaultValues(final byte[] secret) {
return new TOTPGenerator.Builder(secret).build();
}
public String now() throws IllegalStateException {
long counter = calculateCounter(clock, period);
return hotpGenerator.generate(counter);
}
public String now(Clock clock) throws IllegalStateException {
long counter = calculateCounter(clock, period);
return hotpGenerator.generate(counter);
}
public String at(final Instant instant) throws IllegalStateException {
return at(instant.getEpochSecond());
}
public String at(final Date date) throws IllegalStateException {
long secondsSince1970 = TimeUnit.MILLISECONDS.toSeconds(date.getTime());
return at(secondsSince1970);
}
public String at(final LocalDate date) throws IllegalStateException {
long secondsSince1970 = date.atStartOfDay(clock.getZone()).toEpochSecond();
return at(secondsSince1970);
}
public String at(final long secondsPast1970) throws IllegalArgumentException {
if (!validateTime(secondsPast1970))
throw new IllegalArgumentException("Time must be above zero");
long counter = calculateCounter(secondsPast1970, period);
return hotpGenerator.generate(counter);
}
public boolean verify(final String code) {
long counter = calculateCounter(clock, period);
return hotpGenerator.verify(code, counter);
}
/**
* Checks whether a code is valid for a specific counter taking a delay window into account
*
* @param code an OTP code
* @param delayWindow window in which a code can still be deemed valid
* @return a boolean, true if code is valid, otherwise false
*/
public boolean verify(final String code, final int delayWindow) {
long counter = calculateCounter(clock, period);
return hotpGenerator.verify(code, counter, delayWindow);
}
public URI getURI(final String issuer) throws URISyntaxException {
return getURI(issuer, "");
}
public URI getURI(final String issuer, final String account) throws URISyntaxException {
Map query = new HashMap<>();
query.put(URIHelper.PERIOD, String.valueOf(period.getSeconds()));
return hotpGenerator.getURI(OTP_TYPE, issuer, account, query);
}
/**
* Calculates time until next time window will be reached and a new totp should be generated
*
* @return a duration object with duration until next time window
*/
public Duration durationUntilNextTimeWindow() {
return durationUntilNextTimeWindow(clock);
}
public Duration durationUntilNextTimeWindow(Clock clock) {
long timeInterval = period.toMillis();
return Duration.ofMillis(timeInterval - clock.millis() % timeInterval);
}
public Duration getPeriod() {
return period;
}
public Clock getClock() {
return clock;
}
public HMACAlgorithm getAlgorithm() {
return hotpGenerator.getAlgorithm();
}
public int getPasswordLength() {
return hotpGenerator.getPasswordLength();
}
private long calculateCounter(final long secondsPast1970, final Duration period) {
return TimeUnit.SECONDS.toMillis(secondsPast1970) / period.toMillis();
}
private long calculateCounter(final Clock clock, final Duration period) {
return clock.millis() / period.toMillis();
}
private boolean validateTime(final long time) {
return time > 0;
}
public static final class Builder {
private Duration period;
private Clock clock;
private final HOTPGenerator.Builder hotpBuilder;
/**
* Creates a new builder.
*
* Use {@link SecretGenerator#generate()} to create a secret.
*
* If you are using a shared secret from another generator, you would likely need to encode it using
* {@link org.apache.commons.codec.binary.Base32#encode(byte[])}}
*
* @param secret Base32 encoded secret
*/
public Builder(byte[] secret) {
this.period = DEFAULT_PERIOD;
this.clock = DEFAULT_CLOCK;
this.hotpBuilder = new HOTPGenerator.Builder(secret);
}
/**
* @param secret Base32 encoded secret
*/
public Builder(String secret) {
this(secret.getBytes(UTF_8));
}
public Builder withHOTPGenerator(Consumer builder) {
builder.accept(hotpBuilder);
return this;
}
public Builder withClock(Clock clock) {
this.clock = clock;
return this;
}
public Builder withPeriod(Duration period) {
if (period.getSeconds() < 1) throw new IllegalArgumentException("Period must be at least 1 second");
this.period = period;
return this;
}
public TOTPGenerator build() {
return new TOTPGenerator(this);
}
}
}
================================================
FILE: src/main/java/com/bastiaanjansen/otp/helpers/URIHelper.java
================================================
package com.bastiaanjansen.otp.helpers;
import java.io.UnsupportedEncodingException;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.URLDecoder;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.stream.Collectors;
/**
* A URI utility class with helper methods
*
* @author Bastiaan Jansen
*/
public class URIHelper {
public static final String DIGITS = "digits";
public static final String SECRET = "secret";
public static final String ALGORITHM = "algorithm";
public static final String PERIOD = "period";
public static final String COUNTER = "counter";
public static final String ISSUER = "issuer";
private URIHelper() {}
/**
* Get a map of query items from URI
*
* @param uri to get query items from
* @return map of query items from URI
*/
public static Map queryItems(URI uri) {
Map items = new LinkedHashMap<>();
String query = uri.getQuery();
String[] pairs = query.split("&");
for (String pair: pairs) {
int index = pair.indexOf("=");
try {
items.put(
URLDecoder.decode(pair.substring(0, index), StandardCharsets.UTF_8.toString()),
URLDecoder.decode(pair.substring(index + 1), StandardCharsets.UTF_8.toString())
);
} catch (UnsupportedEncodingException e) {
throw new IllegalStateException("Encoding should be supported");
}
}
return items;
}
/**
* Create a URI based on a scheme, host, path and query items
*
* @param scheme of URI
* @param host of URI
* @param path of URI
* @param query of URI
* @return created URI
* @throws URISyntaxException when URI cannot be created
*/
public static URI createURI(String scheme, String host, String path, Map query) throws URISyntaxException {
String uriString = String.format("%s://%s/%s?", scheme, host, path);
String uri = query.keySet().stream()
.map(key -> String.format("%s=%s", key, encode(query.get(key))))
.collect(Collectors.joining("&", uriString, ""));
return new URI(uri);
}
public static String encode(String value) {
try {
return URLEncoder.encode(value, StandardCharsets.UTF_8.toString());
} catch (UnsupportedEncodingException e) {
throw new IllegalArgumentException(e);
}
}
}
================================================
FILE: src/test/java/com/bastiaanjansen/otp/ExampleApp.java
================================================
package com.bastiaanjansen.otp;
import java.time.Clock;
import java.time.Duration;
public class ExampleApp {
public static void main(String[] args) {
// Generate a secret, if you don't have one already
byte[] secret = SecretGenerator.generate();
Clock clock = Clock.systemUTC();
// Create a TOTPGenerate instance
TOTPGenerator totpGenerator = new TOTPGenerator.Builder(secret)
.withHOTPGenerator(builder -> {
builder.withAlgorithm(HMACAlgorithm.SHA1);
builder.withPasswordLength(6);
})
.withClock(clock)
.withPeriod(Duration.ofMinutes(15))
.build();
try {
String code = totpGenerator.now();
System.out.println("Generated code: " + code);
// To verify a code
totpGenerator.verify(code); // true
} catch (IllegalStateException e) {
e.printStackTrace();
}
}
}
================================================
FILE: src/test/java/com/bastiaanjansen/otp/HOTPGeneratorTest.java
================================================
package com.bastiaanjansen.otp;
import org.hamcrest.Matchers;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
import org.junit.jupiter.params.provider.ValueSource;
import java.net.URI;
import java.net.URISyntaxException;
import java.util.stream.Stream;
import static org.hamcrest.CoreMatchers.is;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertThrows;
class HOTPGeneratorTest {
private final String secret = "vv3kox7uqj4kyakohmzpph3us4cjimh6f3zknb5c2oobq6v2kiyhm27q";
private static Stream testData() {
return Stream.of(
Arguments.of(6, 1, HMACAlgorithm.SHA1, "560287"),
Arguments.of(7, 1, HMACAlgorithm.SHA1, "1560287"),
Arguments.of(8, 1, HMACAlgorithm.SHA1, "61560287"),
Arguments.of(6, 1000, HMACAlgorithm.SHA1, "401796"),
Arguments.of(6, 923892, HMACAlgorithm.SHA1, "793394"),
Arguments.of(6, 82764924, HMACAlgorithm.SHA1, "022826"),
Arguments.of(6, 1, HMACAlgorithm.SHA256, "361406"),
Arguments.of(6, 1, HMACAlgorithm.SHA512, "016738"),
Arguments.of(6, 1, HMACAlgorithm.SHA224, "422784"),
Arguments.of(6, 1, HMACAlgorithm.SHA384, "466320")
);
}
@ParameterizedTest
@MethodSource("testData")
void generateWithCounter(int passwordLength, long counter, HMACAlgorithm algorithm, String otp) {
HOTPGenerator generator = new HOTPGenerator.Builder(secret)
.withPasswordLength(passwordLength)
.withAlgorithm(algorithm)
.build();
assertThat(generator.generate(counter), is(otp));
}
@ParameterizedTest
@ValueSource(ints = {-1, -100})
void generateWithInvalidCounter_throwsIllegalArgumentException(long counter) {
HOTPGenerator generator = new HOTPGenerator.Builder(secret).build();
assertThrows(IllegalArgumentException.class, () -> generator.generate(counter));
}
@Test
void verifyCurrentCode_true() {
HOTPGenerator generator = new HOTPGenerator.Builder(secret).build();
String code = generator.generate(1);
assertThat(generator.verify(code, 1), is(true));
}
@Test
void verifyOlderCodeWithDelayWindowIs0_false() {
HOTPGenerator generator = new HOTPGenerator.Builder(secret).build();
String code = generator.generate(1);
assertThat(generator.verify(code, 2), is(false));
}
@Test
void verifyOlderCodeWithDelayWindowIs1_true() {
HOTPGenerator generator = new HOTPGenerator.Builder(secret).build();
String code = generator.generate(1);
assertThat(generator.verify(code, 2, 1), is(true));
}
@Test
void withDefaultValues_algorithm() {
HOTPGenerator generator = HOTPGenerator.withDefaultValues(secret.getBytes());
HMACAlgorithm expected = HMACAlgorithm.SHA1;
assertThat(generator.getAlgorithm(), is(expected));
}
@Test
void withDefaultValues_passwordLength() {
HOTPGenerator generator = HOTPGenerator.withDefaultValues(secret.getBytes());
int expected = 6;
assertThat(generator.getPasswordLength(), is(expected));
}
@Test
void getURIWithIssuer_doesNotThrow() {
HOTPGenerator generator = new HOTPGenerator.Builder(secret).build();
assertDoesNotThrow(() -> {
generator.getURI(10, "issuer");
});
}
@Test
void getURIWithIssuerWithSpace_doesNotThrow() {
HOTPGenerator generator = new HOTPGenerator.Builder(secret).build();
assertDoesNotThrow(() -> generator.getURI(10, "issuer with space"));
}
@Test
void getURIWithIssuerWithSpace_doesEscapeIssuer() throws URISyntaxException {
HOTPGenerator generator = new HOTPGenerator.Builder(secret).build();
String url = generator.getURI(10, "issuer with space").toString();
assertThat(url, is("otpauth://hotp/issuer+with+space?digits=6&counter=10&secret=vv3kox7uqj4kyakohmzpph3us4cjimh6f3zknb5c2oobq6v2kiyhm27q&issuer=issuer+with+space&algorithm=SHA1"));
}
@Test
void getURIWithIssuer() throws URISyntaxException {
HOTPGenerator generator = new HOTPGenerator.Builder(secret).build();
URI uri = generator.getURI(10, "issuer");
assertThat(uri.toString(), is("otpauth://hotp/issuer?digits=6&counter=10&secret=" + secret + "&issuer=issuer&algorithm=SHA1"));
}
@Test
void getURIWithIssuerWithUrlUnsafeCharacters() throws URISyntaxException {
HOTPGenerator generator = new HOTPGenerator.Builder(secret).build();
URI uri = generator.getURI(10, "mac&cheese");
assertThat(uri.toString(), is("otpauth://hotp/mac%26cheese?digits=6&counter=10&secret=" + secret + "&issuer=mac%26cheese&algorithm=SHA1"));
}
@Test
void getURIWithIssuerAndAccount_doesNotThrow() {
HOTPGenerator generator = new HOTPGenerator.Builder(secret).build();
assertDoesNotThrow(() -> {
generator.getURI(100, "issuer", "account");
});
}
@Test
void getURIWithIssuerAndAccount() throws URISyntaxException {
HOTPGenerator generator = new HOTPGenerator.Builder(secret).build();
URI uri = generator.getURI(100, "issuer", "account");
assertThat(uri.toString(), is("otpauth://hotp/issuer:account?digits=6&counter=100&secret=" + secret + "&issuer=issuer&algorithm=SHA1"));
}
@Test
void getURIWithIssuerAndAccountWithUrlUnsafeCharacters() throws URISyntaxException {
HOTPGenerator generator = new HOTPGenerator.Builder(secret).build();
URI uri = generator.getURI(100, "mac&cheese", "ac@cou.nt");
assertThat(uri.toString(), is("otpauth://hotp/mac%26cheese:ac%40cou.nt?digits=6&counter=100&secret=" + secret + "&issuer=mac%26cheese&algorithm=SHA1"));
}
@Test
void fromURIWithAlgorithmUppercase() throws URISyntaxException {
URI uri = new URI("otpauth://hotp/issuer?counter=10&algorithm=SHA256&secret=" + secret);
HOTPGenerator generator = HOTPGenerator.fromURI(uri);
HMACAlgorithm expected = HMACAlgorithm.SHA256;
assertThat(generator.getAlgorithm(), is(expected));
}
@Test
void fromURIWithAlgorithmLowercase() throws URISyntaxException {
URI uri = new URI("otpauth://hotp/issuer?counter=10&algorithm=sha256&secret=" + secret);
HOTPGenerator generator = HOTPGenerator.fromURI(uri);
HMACAlgorithm expected = HMACAlgorithm.SHA256;
assertThat(generator.getAlgorithm(), is(expected));
}
@Test
void fromURIWithDigitsIs7() throws URISyntaxException {
URI uri = new URI("otpauth://hotp/issuer?digits=7&counter=10&secret=" + secret);
HOTPGenerator generator = HOTPGenerator.fromURI(uri);
int expected = 7;
assertThat(generator.getPasswordLength(), is(expected));
}
@Test
void fromURIWithInvalidDigits_throwsURISyntaxException() throws URISyntaxException {
URI uri = new URI("otpauth://hotp/issuer?digits=invalid&counter=10&secret=" + secret);
assertThrows(URISyntaxException.class, () -> HOTPGenerator.fromURI(uri));
}
@Test
void fromURIWithInvalidAlgorithm_throwsURISyntaxException() throws URISyntaxException {
URI uri = new URI("otpauth://hotp/issuer?counter=10&algorithm=invalid&secret=" + secret);
assertThrows(URISyntaxException.class, () -> HOTPGenerator.fromURI(uri));
}
@Test
void fromURIWithInvalidSecret_throwsIllegalArgumentException() throws URISyntaxException {
URI uri = new URI("otpauth://hotp/issuer?counter=10");
assertThrows(IllegalArgumentException.class, () -> HOTPGenerator.fromURI(uri));
}
@Nested
class BuilderTest {
@Test
void builderWithEmptySecret_throwsIllegalArgumentException() {
assertThrows(IllegalArgumentException.class, () -> new HOTPGenerator.Builder(new byte[]{}).build());
}
@Test
void builderWithPasswordLengthIs5_throwsIllegalArgumentException() {
assertThrows(IllegalArgumentException.class, () -> {
new HOTPGenerator.Builder(secret).withPasswordLength(5).build();
});
}
@Test
void builderWithPasswordLengthIs9_throwsIllegalArgumentException() {
assertThrows(IllegalArgumentException.class, () -> {
new HOTPGenerator.Builder(secret).withPasswordLength(9).build();
});
}
@Test
void builderWithPasswordLengthIs6() {
HOTPGenerator generator = new HOTPGenerator.Builder(secret).withPasswordLength(6).build();
int expected = 6;
assertThat(generator.getPasswordLength(), Matchers.is(expected));
}
@Test
void builderWithAlgorithmSHA1() {
HOTPGenerator generator = new HOTPGenerator.Builder(secret).withAlgorithm(HMACAlgorithm.SHA1).build();
HMACAlgorithm expected = HMACAlgorithm.SHA1;
assertThat(generator.getAlgorithm(), Matchers.is(expected));
}
@Test
void builderWithAlgorithmSHA256() {
HOTPGenerator generator = new HOTPGenerator.Builder(secret).withAlgorithm(HMACAlgorithm.SHA256).build();
HMACAlgorithm expected = HMACAlgorithm.SHA256;
assertThat(generator.getAlgorithm(), Matchers.is(expected));
}
@Test
void builderWithAlgorithmSHA512() {
HOTPGenerator generator = new HOTPGenerator.Builder(secret).withAlgorithm(HMACAlgorithm.SHA512).build();
HMACAlgorithm expected = HMACAlgorithm.SHA512;
assertThat(generator.getAlgorithm(), Matchers.is(expected));
}
@ParameterizedTest
@ValueSource(ints = { 1, 2, 3, 4, 5, 9, 10 })
void builderWithInvalidPasswordLength_throwsIllegalArgumentException(int passwordLength) {
assertThrows(IllegalArgumentException.class, () -> new HOTPGenerator.Builder(secret).withPasswordLength(passwordLength).build());
}
@Test
void builderWithoutAlgorithm_defaultAlgorithm() {
HOTPGenerator generator = new HOTPGenerator.Builder(secret).build();
HMACAlgorithm expected = HMACAlgorithm.SHA1;
assertThat(generator.getAlgorithm(), is(expected));
}
}
}
================================================
FILE: src/test/java/com/bastiaanjansen/otp/SecretGeneratorTest.java
================================================
package com.bastiaanjansen.otp;
import org.junit.jupiter.api.Test;
import static org.hamcrest.CoreMatchers.is;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.junit.jupiter.api.Assertions.assertThrows;
class SecretGeneratorTest {
@Test
void generate_defaultLengthIs32() {
int expected = 32;
assertThat(SecretGenerator.generate().length, is(expected));
}
@Test
void generate_lengthIs56() {
int expected = 56;
assertThat(SecretGenerator.generate(256).length, is(expected));
}
@Test
void generateWithZeroBits_throwsIllegalArgument() {
assertThrows(IllegalArgumentException.class, () -> SecretGenerator.generate(0));
}
@Test
void generateWithLessThanZeroBits_throwsIllegalArgument() {
assertThrows(IllegalArgumentException.class, () -> SecretGenerator.generate(-1));
}
}
================================================
FILE: src/test/java/com/bastiaanjansen/otp/TOTPGeneratorTest.java
================================================
package com.bastiaanjansen.otp;
import org.hamcrest.Matchers;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
import org.junit.jupiter.params.provider.ValueSource;
import java.net.URI;
import java.net.URISyntaxException;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.ZoneId;
import java.util.Date;
import java.util.stream.Stream;
import static org.hamcrest.CoreMatchers.is;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertThrows;
class TOTPGeneratorTest {
private final static String secret = "vv3kox7uqj4kyakohmzpph3us4cjimh6f3zknb5c2oobq6v2kiyhm27q";
private static Stream secondsPast1970TestData() {
return Stream.of(
Arguments.of(6, 1, HMACAlgorithm.SHA1, "455216"),
Arguments.of(7, 1, HMACAlgorithm.SHA1, "7455216"),
Arguments.of(8, 1, HMACAlgorithm.SHA1, "17455216"),
Arguments.of(6, 1000, HMACAlgorithm.SHA1, "687469"),
Arguments.of(6, 923892, HMACAlgorithm.SHA1, "909546"),
Arguments.of(6, 82764924, HMACAlgorithm.SHA1, "408978"),
Arguments.of(6, 1, HMACAlgorithm.SHA256, "755370"),
Arguments.of(6, 1, HMACAlgorithm.SHA512, "303161")
);
}
private static Stream instantTestData() {
return Stream.of(
Arguments.of(6, Instant.ofEpochSecond(1), HMACAlgorithm.SHA1, "455216"),
Arguments.of(7, Instant.ofEpochSecond(1000), HMACAlgorithm.SHA1, "0687469"),
Arguments.of(8, Instant.ofEpochSecond(923892), HMACAlgorithm.SHA1, "39909546"),
Arguments.of(6, Instant.ofEpochSecond(82764924), HMACAlgorithm.SHA256, "999993"),
Arguments.of(6, Instant.ofEpochSecond(82764924), HMACAlgorithm.SHA512, "300089")
);
}
private static Stream dateTestData() {
return Stream.of(
Arguments.of(6, Date.from(Instant.ofEpochSecond(1)), HMACAlgorithm.SHA1, "455216"),
Arguments.of(7, Date.from(Instant.ofEpochSecond(100)), HMACAlgorithm.SHA1, "9650012"),
Arguments.of(8, Date.from(Instant.ofEpochSecond(723)), HMACAlgorithm.SHA1, "12251322"),
Arguments.of(6, Date.from(Instant.ofEpochSecond(123)), HMACAlgorithm.SHA256, "376047"),
Arguments.of(6, Date.from(Instant.ofEpochSecond(9802467)), HMACAlgorithm.SHA512, "040816")
);
}
private static Stream clockTestData() {
return Stream.of(
Arguments.of(6, Clock.fixed(Instant.ofEpochSecond(1), ZoneId.of("UTC")), HMACAlgorithm.SHA1, "455216"),
Arguments.of(7, Clock.fixed(Instant.ofEpochSecond(100), ZoneId.of("UTC")), HMACAlgorithm.SHA1, "9650012"),
Arguments.of(8, Clock.fixed(Instant.ofEpochSecond(723), ZoneId.of("UTC")), HMACAlgorithm.SHA1, "12251322"),
Arguments.of(6, Clock.fixed(Instant.ofEpochSecond(123), ZoneId.of("UTC")), HMACAlgorithm.SHA256, "376047"),
Arguments.of(6, Clock.fixed(Instant.ofEpochSecond(9802467), ZoneId.of("UTC")), HMACAlgorithm.SHA512, "040816")
);
}
@ParameterizedTest
@MethodSource("secondsPast1970TestData")
void generateAtSecondsPast1970(int passwordLength, int secondsPast1970, HMACAlgorithm algorithm, String otp) {
TOTPGenerator generator = new TOTPGenerator.Builder(secret).withHOTPGenerator(builder -> {
builder.withPasswordLength(passwordLength);
builder.withAlgorithm(algorithm);
}).build();
assertThat(generator.at(secondsPast1970), is(otp));
}
@ParameterizedTest
@MethodSource("instantTestData")
void generateAtInstant(int passwordLength, Instant instant, HMACAlgorithm algorithm, String otp) {
TOTPGenerator generator = new TOTPGenerator.Builder(secret).withHOTPGenerator(builder -> {
builder.withPasswordLength(passwordLength);
builder.withAlgorithm(algorithm);
}).build();
assertThat(generator.at(instant), is(otp));
}
@ParameterizedTest
@MethodSource("dateTestData")
void generateAtDate(int passwordLength, Date date, HMACAlgorithm algorithm, String otp) {
TOTPGenerator generator = new TOTPGenerator.Builder(secret).withHOTPGenerator(builder -> {
builder.withPasswordLength(passwordLength);
builder.withAlgorithm(algorithm);
}).build();
assertThat(generator.at(date), is(otp));
}
@ParameterizedTest
@MethodSource("clockTestData")
void generateAtNow(int passwordLength, Clock clock, HMACAlgorithm algorithm, String otp) {
TOTPGenerator generator = new TOTPGenerator.Builder(secret).withHOTPGenerator(builder -> {
builder.withPasswordLength(passwordLength);
builder.withAlgorithm(algorithm);
})
.withClock(clock)
.build();
assertThat(generator.now(), is(otp));
}
@ParameterizedTest
@ValueSource(ints = {0, -1})
void generateWithInvalidSecondsPast1970_throwsIllegalArgumentException(int secondsPast1970) {
TOTPGenerator generator = new TOTPGenerator.Builder(secret).build();
assertThrows(IllegalArgumentException.class, () -> generator.at(secondsPast1970));
}
@Test
void verifyCurrentCode_true() {
TOTPGenerator generator = new TOTPGenerator.Builder(secret).build();
String code = generator.now();
assertThat(generator.verify(code), is(true));
}
@Test
void verifyOlderCodeWithDelayWindowIs0_false() {
TOTPGenerator generator = new TOTPGenerator.Builder(secret).build();
String code = generator.at(Instant.now().minusSeconds(30));
assertThat(generator.verify(code), is(false));
}
@Test
void verifyOlderCodeWithDelayWindowIs1_true() {
TOTPGenerator generator = new TOTPGenerator.Builder(secret).build();
String code = generator.at(Instant.now().minusSeconds(30));
assertThat(generator.verify(code, 1), is(true));
}
@Test
void getURIWithIssuer() throws URISyntaxException {
TOTPGenerator generator = new TOTPGenerator.Builder(secret).build();
URI uri = generator.getURI("issuer");
assertThat(uri.toString(), is("otpauth://totp/issuer?period=30&digits=6&secret=" + secret + "&issuer=issuer&algorithm=SHA1"));
}
@Test
void getURIWithIssuerWithUrlUnsafeCharacters() throws URISyntaxException {
TOTPGenerator generator = new TOTPGenerator.Builder(secret).build();
URI uri = generator.getURI("mac&cheese");
assertThat(uri.toString(), is("otpauth://totp/mac%26cheese?period=30&digits=6&secret=" + secret + "&issuer=mac%26cheese&algorithm=SHA1"));
}
@Test
void getURIWithIssuerAndAccount_doesNotThrow() {
TOTPGenerator generator = new TOTPGenerator.Builder(secret).build();
assertDoesNotThrow(() -> {
generator.getURI("issuer", "account");
});
}
@Test
void getURIWithIssuerAndAccount() throws URISyntaxException {
TOTPGenerator generator = new TOTPGenerator.Builder(secret).build();
URI uri = generator.getURI("issuer", "account");
assertThat(uri.toString(), is("otpauth://totp/issuer:account?period=30&digits=6&secret=" + secret + "&issuer=issuer&algorithm=SHA1"));
}
@Test
void getURIWithIssuerAndAccountWithUrlUnsafeCharacters() throws URISyntaxException {
TOTPGenerator generator = new TOTPGenerator.Builder(secret).build();
URI uri = generator.getURI("mac&cheese", "ac@cou.nt");
assertThat(uri.toString(), is("otpauth://totp/mac%26cheese:ac%40cou.nt?period=30&digits=6&secret=" + secret + "&issuer=mac%26cheese&algorithm=SHA1"));
}
@Test
void fromURIWithPeriod() throws URISyntaxException {
URI uri = new URI("otpauth://totp/issuer:account?period=60&secret=" + secret);
TOTPGenerator generator = TOTPGenerator.fromURI(uri);
Duration expected = Duration.ofSeconds(60);
assertThat(generator.getPeriod(), is(expected));
}
@Test
void fromURIWithAlgorithmUppercase() throws URISyntaxException {
URI uri = new URI("otpauth://totp/issuer:account?algorithm=SHA1&secret=" + secret);
TOTPGenerator generator = TOTPGenerator.fromURI(uri);
HMACAlgorithm expected = HMACAlgorithm.SHA1;
assertThat(generator.getAlgorithm(), is(expected));
}
@Test
void fromURIWithAlgorithmLowercase() throws URISyntaxException {
URI uri = new URI("otpauth://totp/issuer:account?algorithm=sha1&secret=" + secret);
TOTPGenerator generator = TOTPGenerator.fromURI(uri);
HMACAlgorithm expected = HMACAlgorithm.SHA1;
assertThat(generator.getAlgorithm(), is(expected));
}
@Test
void fromURIWithPasswordLength() throws URISyntaxException {
URI uri = new URI("otpauth://totp/issuer:account?digits=6&secret=" + secret);
TOTPGenerator generator = TOTPGenerator.fromURI(uri);
int expected = 6;
assertThat(generator.getPasswordLength(), is(expected));
}
@Test
void fromURIWithInvalidPeriod_throwsURISyntaxException() throws URISyntaxException {
URI uri = new URI("otpauth://totp/issuer:account?period=invalid&secret=" + secret);
assertThrows(URISyntaxException.class, () -> TOTPGenerator.fromURI(uri));
}
@Test
void fromURIWithPasswordLengthIs5_throwsURISyntaxException() throws URISyntaxException {
URI uri = new URI("otpauth://totp/issuer:account?digits=5&secret=" + secret);
assertThrows(URISyntaxException.class, () -> TOTPGenerator.fromURI(uri));
}
@Test
void fromURIWithPasswordLengthIs9_throwsURISyntaxException() throws URISyntaxException {
URI uri = new URI("otpauth://totp/issuer:account?digits=9&secret=" + secret);
assertThrows(URISyntaxException.class, () -> TOTPGenerator.fromURI(uri));
}
@ParameterizedTest
@ValueSource(strings = {
"otpauth://totp/issuer:account?digits=6&secret=",
"otpauth://totp/issuer:account?digits=8&secret=",
"otpauth://totp/issuer:account?digits=7&secret="
})
void fromURI_doesNotThrow(String url) throws URISyntaxException {
URI uri = new URI(url + secret);
assertDoesNotThrow(() -> {
TOTPGenerator.fromURI(uri);
});
}
@Test
void fromURIWithInvalidAlgorithm_throwsURISyntaxException() throws URISyntaxException {
URI uri = new URI("otpauth://totp/issuer:account?algorithm=invalid&secret=" + secret);
assertThrows(URISyntaxException.class, () -> TOTPGenerator.fromURI(uri));
}
@Nested
class BuilderTest {
@Test
void builderWithEmptySecret_throwsIllegalArgumentException() {
assertThrows(IllegalArgumentException.class, () -> new TOTPGenerator.Builder(new byte[]{}).build());
}
@Test
void builderWithPasswordLengthIs5_throwsIllegalArgumentException() {
assertThrows(IllegalArgumentException.class, () -> {
new TOTPGenerator.Builder(secret).withHOTPGenerator(builder -> builder.withPasswordLength(5)).build();
});
}
@Test
void builderWithPasswordLengthIs9_throwsIllegalArgumentException() {
assertThrows(IllegalArgumentException.class, () -> {
new TOTPGenerator.Builder(secret).withHOTPGenerator(builder -> builder.withPasswordLength(9)).build();
});
}
@Test
void builderWithPasswordLengthIs6() {
TOTPGenerator generator = new TOTPGenerator.Builder(secret).withHOTPGenerator(builder -> builder.withPasswordLength(6)).build();
int expected = 6;
assertThat(generator.getPasswordLength(), Matchers.is(expected));
}
@Test
void builderWithAlgorithmSHA1() {
TOTPGenerator generator = new TOTPGenerator.Builder(secret).withHOTPGenerator(builder -> builder.withAlgorithm(HMACAlgorithm.SHA1)).build();
HMACAlgorithm expected = HMACAlgorithm.SHA1;
assertThat(generator.getAlgorithm(), Matchers.is(expected));
}
@Test
void builderWithAlgorithmSHA256() {
TOTPGenerator generator = new TOTPGenerator.Builder(secret).withHOTPGenerator(builder -> builder.withAlgorithm(HMACAlgorithm.SHA256)).build();
HMACAlgorithm expected = HMACAlgorithm.SHA256;
assertThat(generator.getAlgorithm(), Matchers.is(expected));
}
@Test
void builderWithAlgorithmSHA512() {
TOTPGenerator generator = new TOTPGenerator.Builder(secret).withHOTPGenerator(builder -> builder.withAlgorithm(HMACAlgorithm.SHA512)).build();
HMACAlgorithm expected = HMACAlgorithm.SHA512;
assertThat(generator.getAlgorithm(), Matchers.is(expected));
}
@ParameterizedTest
@ValueSource(ints = {1, 2, 3, 4, 5, 9, 10})
void builderWithInvalidPasswordLength_throwsIllegalArgumentException(int passwordLength) {
assertThrows(IllegalArgumentException.class, () -> new TOTPGenerator.Builder(secret).withHOTPGenerator(builder -> builder.withPasswordLength(passwordLength)).build());
}
@Test
void builderWithoutPeriod_defaultPeriod() {
TOTPGenerator generator = new TOTPGenerator.Builder(secret).build();
Duration expected = Duration.ofSeconds(30);
assertThat(generator.getPeriod(), is(expected));
}
@Test
void builderWithoutAlgorithm_defaultAlgorithm() {
TOTPGenerator generator = new TOTPGenerator.Builder(secret).build();
HMACAlgorithm expected = HMACAlgorithm.SHA1;
assertThat(generator.getAlgorithm(), is(expected));
}
}
}
================================================
FILE: src/test/java/com/bastiaanjansen/otp/helpers/URIHelperTest.java
================================================
package com.bastiaanjansen.otp.helpers;
import org.junit.jupiter.api.Test;
import java.net.URI;
import java.net.URISyntaxException;
import java.util.HashMap;
import java.util.Map;
import static org.hamcrest.CoreMatchers.is;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
class URIHelperTest {
@Test
void queryItemsWithOneQueryItem() throws URISyntaxException {
URI uri = new URI("otpauth://totp/issuer:account?algorithm=SHA1");
Map query = URIHelper.queryItems(uri);
String expected = "SHA1";
assertThat(query.get("algorithm"), is(expected));
}
@Test
void queryItemsWithTwoQueryItems() throws URISyntaxException {
URI uri = new URI("otpauth://totp/issuer:account?algorithm=SHA1&secret=ABC");
Map query = URIHelper.queryItems(uri);
String expected = "ABC";
assertThat(query.get("secret"), is(expected));
}
@Test
void createURI_doesNotThrow() {
Map query = new HashMap<>();
assertDoesNotThrow(() -> URIHelper.createURI("scheme", "host", "path", query));
}
@Test
void createURIWithOneQueryItem() throws URISyntaxException {
Map query = new HashMap<>();
query.put("test", "1");
URI uri = URIHelper.createURI("scheme", "host", "path", query);
String expected = "scheme://host/path?test=1";
assertThat(uri.toString(), is(expected));
}
@Test
void createURIWithTwoQueryItems() throws URISyntaxException {
Map query = new HashMap<>();
query.put("test", "1");
query.put("test2", "2");
URI uri = URIHelper.createURI("scheme", "host", "path", query);
String expected = "scheme://host/path?test2=2&test=1";
assertThat(uri.toString(), is(expected));
}
@Test
void createURIWithUrlUnsafeCharacters() throws URISyntaxException {
Map query = new HashMap<>();
query.put("test", "value 1");
query.put("test2", "value?&2");
URI uri = URIHelper.createURI("scheme", "host", "path", query);
String expected = "scheme://host/path?test2=value%3F%262&test=value+1";
assertThat(uri.toString(), is(expected));
}
}