Skip to content

Commit 04c8232

Browse files
Merge pull request #84 from anibyl/base32-string-builder-and-docs
Add a String secret Builder constructor + Base32 docs and comments #83
2 parents 6aeafa0 + 1c45ecc commit 04c8232

File tree

5 files changed

+100
-54
lines changed

5 files changed

+100
-54
lines changed

README.md

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ Or you can download the source from the [GitHub releases page](https://github.co
5757
To create a `HOTPGenerator` instance, use the `HOTPGenerator.Builder` class as follows:
5858

5959
```java
60-
byte[] secret = "VV3KOX7UQJ4KYAKOHMZPPH3US4CJIMH6F3ZKNB5C2OOBQ6V2KIYHM27Q".getBytes();
60+
String secret = "VV3KOX7UQJ4KYAKOHMZPPH3US4CJIMH6F3ZKNB5C2OOBQ6V2KIYHM27Q";
6161
HOTPGenerator hotp = new HOTPGenerator.Builder(secret).build();
6262
```
6363
The above builder creates a HOTP instance with default values for passwordLength = 6 and algorithm = SHA1. Use the builder to change these defaults:
@@ -68,12 +68,20 @@ HOTPGenerator hotp = new HOTPGenerator.Builder(secret)
6868
.build();
6969
```
7070

71+
If you have a shared secret described in [RFC-4226](https://www.rfc-editor.org/rfc/rfc4226), you need to encode it first:
72+
73+
```java
74+
byte[] sharedSecret = getMySharedSecret();
75+
76+
byte[] secret = Base32.encode(sharedSecret);
77+
```
78+
7179
When you don't already have a secret, you can let the library generate it:
7280
```java
73-
// To generate a secret with 160 bits
81+
// To generate a Base32-encoded secret with 160 bits
7482
byte[] secret = SecretGenerator.generate();
7583

76-
// To generate a secret with a custom amount of bits
84+
// To generate a Base32-encoded secret with a custom amount of bits
7785
byte[] secret = SecretGenerator.generate(512);
7886
```
7987

@@ -131,7 +139,6 @@ TOTPGenerator totpGenerator = TOTPGenerator.fromURI(uri);
131139

132140
Get information about the generator:
133141
```java
134-
byte[] secret = totpGenerator.getSecret();
135142
int passwordLength = totpGenerator.getPasswordLength(); // 6
136143
HMACAlgorithm algorithm = totpGenerator.getAlgorithm(); // HMACAlgorithm.SHA1
137144
Duration period = totpGenerator.getPeriod(); // Duration.ofSeconds(30)

src/main/java/com/bastiaanjansen/otp/HOTPGenerator.java

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,14 @@
88
import java.net.URI;
99
import java.net.URISyntaxException;
1010
import java.nio.ByteBuffer;
11-
import java.nio.charset.StandardCharsets;
1211
import java.security.InvalidKeyException;
1312
import java.security.NoSuchAlgorithmException;
1413
import java.util.HashMap;
1514
import java.util.Map;
1615
import java.util.Optional;
1716

17+
import static java.nio.charset.StandardCharsets.UTF_8;
18+
1819
public final class HOTPGenerator {
1920

2021
private static final String URL_SCHEME = "otpauth";
@@ -38,7 +39,7 @@ public static HOTPGenerator fromURI(final URI uri) throws URISyntaxException {
3839
Map<String, String> query = URIHelper.queryItems(uri);
3940

4041
byte[] secret = Optional.ofNullable(query.get(URIHelper.SECRET))
41-
.map(String::getBytes)
42+
.map(s -> s.getBytes(UTF_8))
4243
.orElseThrow(() -> new IllegalArgumentException("Secret query parameter must be set"));
4344

4445
Builder builder = new Builder(secret);
@@ -117,7 +118,7 @@ public String generate(final long counter) throws IllegalStateException {
117118
public URI getURI(final String type, final String issuer, final String account, final Map<String, String> query) throws URISyntaxException {
118119
query.put(URIHelper.DIGITS, String.valueOf(passwordLength));
119120
query.put(URIHelper.ALGORITHM, algorithm.name());
120-
query.put(URIHelper.SECRET, new String(secret, StandardCharsets.UTF_8));
121+
query.put(URIHelper.SECRET, new String(secret, UTF_8));
121122
query.put(URIHelper.ISSUER, issuer);
122123

123124
String path = account.isEmpty() ? URIHelper.encode(issuer) : String.format("%s:%s", URIHelper.encode(issuer), URIHelper.encode(account));
@@ -194,8 +195,21 @@ public static final class Builder {
194195

195196
private HMACAlgorithm algorithm;
196197

198+
/**
199+
* Base32 encoded secret
200+
*/
197201
private final byte[] secret;
198202

203+
/**
204+
* Creates a new builder.
205+
* <p>
206+
* Use {@link SecretGenerator#generate()} to create a secret.
207+
* <p>
208+
* If you are using a shared secret from another generator, you would likely need to encode it using
209+
* {@link org.apache.commons.codec.binary.Base32#encode(byte[])}}
210+
*
211+
* @param secret Base32 encoded secret
212+
*/
199213
public Builder(final byte[] secret) {
200214
if (secret.length == 0)
201215
throw new IllegalArgumentException("Secret must not be empty");
@@ -205,6 +219,13 @@ public Builder(final byte[] secret) {
205219
this.algorithm = DEFAULT_HMAC_ALGORITHM;
206220
}
207221

222+
/**
223+
* @param secret Base32 encoded secret
224+
*/
225+
public Builder(String secret) {
226+
this(secret.getBytes(UTF_8));
227+
}
228+
208229
public Builder withPasswordLength(final int passwordLength) {
209230
if (!passwordLengthIsValid(passwordLength))
210231
throw new IllegalArgumentException("Password length must be between 6 and 8 digits");

src/main/java/com/bastiaanjansen/otp/TOTPGenerator.java

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@
1212
import java.util.concurrent.TimeUnit;
1313
import java.util.function.Consumer;
1414

15+
import static java.nio.charset.StandardCharsets.UTF_8;
16+
1517
public final class TOTPGenerator {
1618
private static final String OTP_TYPE = "totp";
1719
private static final Duration DEFAULT_PERIOD = Duration.ofSeconds(30);
@@ -32,8 +34,7 @@ private TOTPGenerator(final Builder builder) {
3234
public static TOTPGenerator fromURI(URI uri) throws URISyntaxException {
3335
Map<String, String> query = URIHelper.queryItems(uri);
3436

35-
byte[] secret = Optional.ofNullable(query.get(URIHelper.SECRET))
36-
.map(String::getBytes)
37+
String secret = Optional.ofNullable(query.get(URIHelper.SECRET))
3738
.orElseThrow(() -> new IllegalArgumentException("Secret query parameter must be set"));
3839

3940
Builder builder = new Builder(secret);
@@ -171,12 +172,29 @@ public static final class Builder {
171172

172173
private final HOTPGenerator.Builder hotpBuilder;
173174

175+
/**
176+
* Creates a new builder.
177+
* <p>
178+
* Use {@link SecretGenerator#generate()} to create a secret.
179+
* <p>
180+
* If you are using a shared secret from another generator, you would likely need to encode it using
181+
* {@link org.apache.commons.codec.binary.Base32#encode(byte[])}}
182+
*
183+
* @param secret Base32 encoded secret
184+
*/
174185
public Builder(byte[] secret) {
175186
this.period = DEFAULT_PERIOD;
176187
this.clock = DEFAULT_CLOCK;
177188
this.hotpBuilder = new HOTPGenerator.Builder(secret);
178189
}
179190

191+
/**
192+
* @param secret Base32 encoded secret
193+
*/
194+
public Builder(String secret) {
195+
this(secret.getBytes(UTF_8));
196+
}
197+
180198
public Builder withHOTPGenerator(Consumer<HOTPGenerator.Builder> builder) {
181199
builder.accept(hotpBuilder);
182200
return this;

src/test/java/com/bastiaanjansen/otp/HOTPGeneratorTest.java

Lines changed: 21 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ private static Stream<Arguments> testData() {
4141
@ParameterizedTest
4242
@MethodSource("testData")
4343
void generateWithCounter(int passwordLength, long counter, HMACAlgorithm algorithm, String otp) {
44-
HOTPGenerator generator = new HOTPGenerator.Builder(secret.getBytes())
44+
HOTPGenerator generator = new HOTPGenerator.Builder(secret)
4545
.withPasswordLength(passwordLength)
4646
.withAlgorithm(algorithm)
4747
.build();
@@ -52,30 +52,30 @@ void generateWithCounter(int passwordLength, long counter, HMACAlgorithm algorit
5252
@ParameterizedTest
5353
@ValueSource(ints = {-1, -100})
5454
void generateWithInvalidCounter_throwsIllegalArgumentException(long counter) {
55-
HOTPGenerator generator = new HOTPGenerator.Builder(secret.getBytes()).build();
55+
HOTPGenerator generator = new HOTPGenerator.Builder(secret).build();
5656

5757
assertThrows(IllegalArgumentException.class, () -> generator.generate(counter));
5858
}
5959

6060
@Test
6161
void verifyCurrentCode_true() {
62-
HOTPGenerator generator = new HOTPGenerator.Builder(secret.getBytes()).build();
62+
HOTPGenerator generator = new HOTPGenerator.Builder(secret).build();
6363
String code = generator.generate(1);
6464

6565
assertThat(generator.verify(code, 1), is(true));
6666
}
6767

6868
@Test
6969
void verifyOlderCodeWithDelayWindowIs0_false() {
70-
HOTPGenerator generator = new HOTPGenerator.Builder(secret.getBytes()).build();
70+
HOTPGenerator generator = new HOTPGenerator.Builder(secret).build();
7171
String code = generator.generate(1);
7272

7373
assertThat(generator.verify(code, 2), is(false));
7474
}
7575

7676
@Test
7777
void verifyOlderCodeWithDelayWindowIs1_true() {
78-
HOTPGenerator generator = new HOTPGenerator.Builder(secret.getBytes()).build();
78+
HOTPGenerator generator = new HOTPGenerator.Builder(secret).build();
7979
String code = generator.generate(1);
8080

8181
assertThat(generator.verify(code, 2, 1), is(true));
@@ -99,7 +99,7 @@ void withDefaultValues_passwordLength() {
9999

100100
@Test
101101
void getURIWithIssuer_doesNotThrow() {
102-
HOTPGenerator generator = new HOTPGenerator.Builder(secret.getBytes()).build();
102+
HOTPGenerator generator = new HOTPGenerator.Builder(secret).build();
103103

104104
assertDoesNotThrow(() -> {
105105
generator.getURI(10, "issuer");
@@ -108,14 +108,14 @@ void getURIWithIssuer_doesNotThrow() {
108108

109109
@Test
110110
void getURIWithIssuerWithSpace_doesNotThrow() {
111-
HOTPGenerator generator = new HOTPGenerator.Builder(secret.getBytes()).build();
111+
HOTPGenerator generator = new HOTPGenerator.Builder(secret).build();
112112

113113
assertDoesNotThrow(() -> generator.getURI(10, "issuer with space"));
114114
}
115115

116116
@Test
117117
void getURIWithIssuerWithSpace_doesEscapeIssuer() throws URISyntaxException {
118-
HOTPGenerator generator = new HOTPGenerator.Builder(secret.getBytes()).build();
118+
HOTPGenerator generator = new HOTPGenerator.Builder(secret).build();
119119

120120
String url = generator.getURI(10, "issuer with space").toString();
121121

@@ -124,15 +124,15 @@ void getURIWithIssuerWithSpace_doesEscapeIssuer() throws URISyntaxException {
124124

125125
@Test
126126
void getURIWithIssuer() throws URISyntaxException {
127-
HOTPGenerator generator = new HOTPGenerator.Builder(secret.getBytes()).build();
127+
HOTPGenerator generator = new HOTPGenerator.Builder(secret).build();
128128
URI uri = generator.getURI(10, "issuer");
129129

130130
assertThat(uri.toString(), is("otpauth://hotp/issuer?digits=6&counter=10&secret=" + secret + "&issuer=issuer&algorithm=SHA1"));
131131
}
132132

133133
@Test
134134
void getURIWithIssuerWithUrlUnsafeCharacters() throws URISyntaxException {
135-
HOTPGenerator generator = new HOTPGenerator.Builder(secret.getBytes()).build();
135+
HOTPGenerator generator = new HOTPGenerator.Builder(secret).build();
136136
URI uri = generator.getURI(10, "mac&cheese");
137137

138138
assertThat(uri.toString(), is("otpauth://hotp/mac%26cheese?digits=6&counter=10&secret=" + secret + "&issuer=mac%26cheese&algorithm=SHA1"));
@@ -141,7 +141,7 @@ void getURIWithIssuerWithUrlUnsafeCharacters() throws URISyntaxException {
141141

142142
@Test
143143
void getURIWithIssuerAndAccount_doesNotThrow() {
144-
HOTPGenerator generator = new HOTPGenerator.Builder(secret.getBytes()).build();
144+
HOTPGenerator generator = new HOTPGenerator.Builder(secret).build();
145145

146146
assertDoesNotThrow(() -> {
147147
generator.getURI(100, "issuer", "account");
@@ -150,15 +150,15 @@ void getURIWithIssuerAndAccount_doesNotThrow() {
150150

151151
@Test
152152
void getURIWithIssuerAndAccount() throws URISyntaxException {
153-
HOTPGenerator generator = new HOTPGenerator.Builder(secret.getBytes()).build();
153+
HOTPGenerator generator = new HOTPGenerator.Builder(secret).build();
154154

155155
URI uri = generator.getURI(100, "issuer", "account");
156156
assertThat(uri.toString(), is("otpauth://hotp/issuer:account?digits=6&counter=100&secret=" + secret + "&issuer=issuer&algorithm=SHA1"));
157157
}
158158

159159
@Test
160160
void getURIWithIssuerAndAccountWithUrlUnsafeCharacters() throws URISyntaxException {
161-
HOTPGenerator generator = new HOTPGenerator.Builder(secret.getBytes()).build();
161+
HOTPGenerator generator = new HOTPGenerator.Builder(secret).build();
162162

163163
URI uri = generator.getURI(100, "mac&cheese", "[email protected]");
164164

@@ -224,44 +224,44 @@ void builderWithEmptySecret_throwsIllegalArgumentException() {
224224
@Test
225225
void builderWithPasswordLengthIs5_throwsIllegalArgumentException() {
226226
assertThrows(IllegalArgumentException.class, () -> {
227-
new HOTPGenerator.Builder(secret.getBytes()).withPasswordLength(5).build();
227+
new HOTPGenerator.Builder(secret).withPasswordLength(5).build();
228228
});
229229
}
230230

231231
@Test
232232
void builderWithPasswordLengthIs9_throwsIllegalArgumentException() {
233233
assertThrows(IllegalArgumentException.class, () -> {
234-
new HOTPGenerator.Builder(secret.getBytes()).withPasswordLength(9).build();
234+
new HOTPGenerator.Builder(secret).withPasswordLength(9).build();
235235
});
236236
}
237237

238238
@Test
239239
void builderWithPasswordLengthIs6() {
240-
HOTPGenerator generator = new HOTPGenerator.Builder(secret.getBytes()).withPasswordLength(6).build();
240+
HOTPGenerator generator = new HOTPGenerator.Builder(secret).withPasswordLength(6).build();
241241
int expected = 6;
242242

243243
assertThat(generator.getPasswordLength(), Matchers.is(expected));
244244
}
245245

246246
@Test
247247
void builderWithAlgorithmSHA1() {
248-
HOTPGenerator generator = new HOTPGenerator.Builder(secret.getBytes()).withAlgorithm(HMACAlgorithm.SHA1).build();
248+
HOTPGenerator generator = new HOTPGenerator.Builder(secret).withAlgorithm(HMACAlgorithm.SHA1).build();
249249
HMACAlgorithm expected = HMACAlgorithm.SHA1;
250250

251251
assertThat(generator.getAlgorithm(), Matchers.is(expected));
252252
}
253253

254254
@Test
255255
void builderWithAlgorithmSHA256() {
256-
HOTPGenerator generator = new HOTPGenerator.Builder(secret.getBytes()).withAlgorithm(HMACAlgorithm.SHA256).build();
256+
HOTPGenerator generator = new HOTPGenerator.Builder(secret).withAlgorithm(HMACAlgorithm.SHA256).build();
257257
HMACAlgorithm expected = HMACAlgorithm.SHA256;
258258

259259
assertThat(generator.getAlgorithm(), Matchers.is(expected));
260260
}
261261

262262
@Test
263263
void builderWithAlgorithmSHA512() {
264-
HOTPGenerator generator = new HOTPGenerator.Builder(secret.getBytes()).withAlgorithm(HMACAlgorithm.SHA512).build();
264+
HOTPGenerator generator = new HOTPGenerator.Builder(secret).withAlgorithm(HMACAlgorithm.SHA512).build();
265265
HMACAlgorithm expected = HMACAlgorithm.SHA512;
266266

267267
assertThat(generator.getAlgorithm(), Matchers.is(expected));
@@ -270,12 +270,12 @@ void builderWithAlgorithmSHA512() {
270270
@ParameterizedTest
271271
@ValueSource(ints = { 1, 2, 3, 4, 5, 9, 10 })
272272
void builderWithInvalidPasswordLength_throwsIllegalArgumentException(int passwordLength) {
273-
assertThrows(IllegalArgumentException.class, () -> new HOTPGenerator.Builder(secret.getBytes()).withPasswordLength(passwordLength).build());
273+
assertThrows(IllegalArgumentException.class, () -> new HOTPGenerator.Builder(secret).withPasswordLength(passwordLength).build());
274274
}
275275

276276
@Test
277277
void builderWithoutAlgorithm_defaultAlgorithm() {
278-
HOTPGenerator generator = new HOTPGenerator.Builder(secret.getBytes()).build();
278+
HOTPGenerator generator = new HOTPGenerator.Builder(secret).build();
279279
HMACAlgorithm expected = HMACAlgorithm.SHA1;
280280

281281
assertThat(generator.getAlgorithm(), is(expected));

0 commit comments

Comments
 (0)