Skip to content

Commit 2146875

Browse files
committed
Avoid synchronization by cloning a prototype Mac and reallocating buffers
1 parent f2a5ecd commit 2146875

File tree

3 files changed

+115
-51
lines changed

3 files changed

+115
-51
lines changed

README.md

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -65,9 +65,7 @@ Future password: 046148
6565

6666
## Performance and best practices
6767

68-
One-time password generators are thread-safe and reusable. Generally, applications should treat one-time password generator instances as long-lived resources.
69-
70-
The password-generating methods in a one-time password generator are `synchronized`. In multi-threaded applications that make heavy use of a shared one-time password generator instance, synchronization may become a performance bottleneck. In that case, callers may benefit from using one instance per thread (for example, by using a [`ThreadLocal`](https://docs.oracle.com/javase/8/docs/api/java/lang/ThreadLocal.html)).
68+
One-time password generators are thread-safe and reusable. Generally, applications should treat one-time password generator instances as long-lived resources (as opposed to creating new generators for each password-generation call).
7169

7270
## License and copyright
7371

src/main/java/com/eatthepath/otp/HmacOneTimePasswordGenerator.java

Lines changed: 32 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -29,22 +29,16 @@
2929
import java.util.Locale;
3030

3131
/**
32-
* <p>Generates HMAC-based one-time passwords (HOTP) as specified in
33-
* <a href="https://tools.ietf.org/html/rfc4226">RFC&nbsp;4226</a>.</p>
34-
*
35-
* <p>{@code HmacOneTimePasswordGenerator} instances are thread-safe and may be shared between threads. Note that the
36-
* {@link #generateOneTimePassword(Key, long)} method (and its relatives) are {@code synchronized}; in multi-threaded
37-
* applications that make heavy use of a shared {@code HmacOneTimePasswordGenerator} instance, synchronization may
38-
* become a performance bottleneck. In that case, callers may benefit from using one
39-
* {@code HmacOneTimePasswordGenerator} instance per thread (for example, with a {@link ThreadLocal}).</p>
32+
* Generates HMAC-based one-time passwords (HOTP) as specified in
33+
* <a href="https://tools.ietf.org/html/rfc4226">RFC&nbsp;4226</a>. {@code HmacOneTimePasswordGenerator} instances are
34+
* thread-safe and may be shared between threads.
4035
*
4136
* @author <a href="https://github.com/jchambers">Jon Chambers</a>
4237
*/
4338
public class HmacOneTimePasswordGenerator {
44-
private final Mac mac;
39+
private final Mac prototypeMac;
4540
private final int passwordLength;
4641

47-
private final ByteBuffer buffer;
4842
private final int modDivisor;
4943

5044
private final String formatString;
@@ -92,7 +86,7 @@ public HmacOneTimePasswordGenerator(final int passwordLength) {
9286
*/
9387
HmacOneTimePasswordGenerator(final int passwordLength, final String algorithm) throws UncheckedNoSuchAlgorithmException {
9488
try {
95-
this.mac = Mac.getInstance(algorithm);
89+
this.prototypeMac = Mac.getInstance(algorithm);
9690
} catch (final NoSuchAlgorithmException e) {
9791
throw new UncheckedNoSuchAlgorithmException(e);
9892
}
@@ -122,7 +116,6 @@ public HmacOneTimePasswordGenerator(final int passwordLength) {
122116
}
123117

124118
this.passwordLength = passwordLength;
125-
this.buffer = ByteBuffer.allocate(this.mac.getMacLength());
126119
}
127120

128121
/**
@@ -136,24 +129,41 @@ public HmacOneTimePasswordGenerator(final int passwordLength) {
136129
*
137130
* @throws InvalidKeyException if the given key is inappropriate for initializing the {@link Mac} for this generator
138131
*/
139-
public synchronized int generateOneTimePassword(final Key key, final long counter) throws InvalidKeyException {
140-
this.buffer.clear();
141-
this.buffer.putLong(0, counter);
132+
public int generateOneTimePassword(final Key key, final long counter) throws InvalidKeyException {
133+
final Mac mac = getMac();
134+
final ByteBuffer buffer = ByteBuffer.allocate(mac.getMacLength());
135+
136+
buffer.putLong(0, counter);
142137

143138
try {
144-
final byte[] array = this.buffer.array();
139+
final byte[] array = buffer.array();
145140

146-
this.mac.init(key);
147-
this.mac.update(array, 0, 8);
148-
this.mac.doFinal(array, 0);
141+
mac.init(key);
142+
mac.update(array, 0, 8);
143+
mac.doFinal(array, 0);
149144
} catch (final ShortBufferException e) {
150145
// We allocated the buffer to (at least) match the size of the MAC length at construction time, so this
151146
// should never happen.
152147
throw new RuntimeException(e);
153148
}
154149

155-
final int offset = this.buffer.get(this.buffer.capacity() - 1) & 0x0f;
156-
return (this.buffer.getInt(offset) & 0x7fffffff) % this.modDivisor;
150+
final int offset = buffer.get(buffer.capacity() - 1) & 0x0f;
151+
return (buffer.getInt(offset) & 0x7fffffff) % this.modDivisor;
152+
}
153+
154+
private Mac getMac() {
155+
try {
156+
// Cloning is generally cheaper than `Mac.getInstance`, but isn't GUARANTEED to be supported.
157+
return (Mac) this.prototypeMac.clone();
158+
} catch (CloneNotSupportedException e) {
159+
try {
160+
return Mac.getInstance(this.prototypeMac.getAlgorithm());
161+
} catch (final NoSuchAlgorithmException ex) {
162+
// This should be impossible; we're getting the algorithm from a Mac that already exists, and so the
163+
// algorithm must be supported.
164+
throw new RuntimeException(ex);
165+
}
166+
}
157167
}
158168

159169
/**
@@ -216,6 +226,6 @@ public int getPasswordLength() {
216226
* @return the name of the HMAC algorithm used by this generator
217227
*/
218228
public String getAlgorithm() {
219-
return this.mac.getAlgorithm();
229+
return this.prototypeMac.getAlgorithm();
220230
}
221231
}

src/test/java/com/eatthepath/otp/HmacOneTimePasswordGeneratorTest.java

Lines changed: 82 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,11 @@
2727

2828
import javax.crypto.spec.SecretKeySpec;
2929
import java.nio.charset.StandardCharsets;
30+
import java.security.InvalidKeyException;
3031
import java.security.Key;
3132
import java.util.Locale;
33+
import java.util.Random;
34+
import java.util.concurrent.*;
3235
import java.util.stream.Stream;
3336

3437
import static org.junit.jupiter.api.Assertions.*;
@@ -40,6 +43,19 @@ public class HmacOneTimePasswordGeneratorTest {
4043
new SecretKeySpec("12345678901234567890".getBytes(StandardCharsets.US_ASCII),
4144
HmacOneTimePasswordGenerator.HOTP_HMAC_ALGORITHM);
4245

46+
private static final int[] TEST_VECTORS = new int[] {
47+
755224,
48+
287082,
49+
359152,
50+
969429,
51+
338314,
52+
254676,
53+
287922,
54+
162583,
55+
399871,
56+
520489
57+
};
58+
4359
@Test
4460
void testHmacOneTimePasswordGeneratorWithShortPasswordLength() {
4561
assertThrows(IllegalArgumentException.class, () -> new HmacOneTimePasswordGenerator(5));
@@ -84,23 +100,19 @@ void testGenerateOneTimePassword(final int counter, final int expectedOneTimePas
84100
void testGenerateOneTimePasswordRepeated() throws Exception {
85101
final HmacOneTimePasswordGenerator hotpGenerator = new HmacOneTimePasswordGenerator();
86102

87-
assertEquals(755224, hotpGenerator.generateOneTimePassword(HOTP_KEY, 0));
88-
assertEquals(287082, hotpGenerator.generateOneTimePassword(HOTP_KEY, 1));
103+
for (int counter = 0; counter < TEST_VECTORS.length; counter++) {
104+
assertEquals(TEST_VECTORS[counter], hotpGenerator.generateOneTimePassword(HOTP_KEY, counter));
105+
}
89106
}
90107

91108
private static Stream<Arguments> argumentsForTestGenerateOneTimePasswordHotp() {
92-
return Stream.of(
93-
arguments(0, 755224),
94-
arguments(1, 287082),
95-
arguments(2, 359152),
96-
arguments(3, 969429),
97-
arguments(4, 338314),
98-
arguments(5, 254676),
99-
arguments(6, 287922),
100-
arguments(7, 162583),
101-
arguments(8, 399871),
102-
arguments(9, 520489)
103-
);
109+
final Stream.Builder<Arguments> streamBuilder = Stream.builder();
110+
111+
for (int counter = 0; counter < TEST_VECTORS.length; counter++) {
112+
streamBuilder.add(Arguments.of(counter, TEST_VECTORS[counter]));
113+
}
114+
115+
return streamBuilder.build();
104116
}
105117

106118
@ParameterizedTest
@@ -111,18 +123,13 @@ void testGenerateOneTimePasswordString(final int counter, final String expectedO
111123
}
112124

113125
private static Stream<Arguments> argumentsForTestGenerateOneTimePasswordStringHotp() {
114-
return Stream.of(
115-
arguments(0, "755224"),
116-
arguments(1, "287082"),
117-
arguments(2, "359152"),
118-
arguments(3, "969429"),
119-
arguments(4, "338314"),
120-
arguments(5, "254676"),
121-
arguments(6, "287922"),
122-
arguments(7, "162583"),
123-
arguments(8, "399871"),
124-
arguments(9, "520489")
125-
);
126+
final Stream.Builder<Arguments> streamBuilder = Stream.builder();
127+
128+
for (int counter = 0; counter < TEST_VECTORS.length; counter++) {
129+
streamBuilder.add(Arguments.of(counter, String.valueOf(TEST_VECTORS[counter])));
130+
}
131+
132+
return streamBuilder.build();
126133
}
127134

128135
@ParameterizedTest
@@ -148,4 +155,53 @@ private static Stream<Arguments> argumentsForTestGenerateOneTimePasswordStringLo
148155
arguments(9, locale, "५२०४८९")
149156
);
150157
}
158+
159+
@Test
160+
void testConcurrent() throws InterruptedException {
161+
final int iterations = 10_000;
162+
final int threadCount = Runtime.getRuntime().availableProcessors() * 4;
163+
final CountDownLatch threadStartLatch = new CountDownLatch(threadCount);
164+
final ExecutorService executorService = Executors.newFixedThreadPool(threadCount);
165+
@SuppressWarnings("unchecked") final CompletableFuture<Boolean>[] futures = new CompletableFuture[threadCount];
166+
167+
final HmacOneTimePasswordGenerator hotpGenerator = new HmacOneTimePasswordGenerator();
168+
169+
for (int thread = 0; thread < threadCount; thread++) {
170+
futures[thread] = CompletableFuture.supplyAsync(() -> {
171+
final Random random = new Random();
172+
boolean allMatched = true;
173+
174+
threadStartLatch.countDown();
175+
176+
try {
177+
threadStartLatch.await();
178+
} catch (final InterruptedException e) {
179+
throw new RuntimeException(e);
180+
}
181+
182+
for (int i = 0; i < iterations; i++) {
183+
final int counter = random.nextInt(TEST_VECTORS.length);
184+
185+
try {
186+
if (hotpGenerator.generateOneTimePassword(HOTP_KEY, counter) != TEST_VECTORS[counter]) {
187+
allMatched = false;
188+
}
189+
} catch (final InvalidKeyException e) {
190+
throw new RuntimeException(e);
191+
}
192+
}
193+
194+
return allMatched;
195+
}, executorService);
196+
}
197+
198+
CompletableFuture.allOf(futures).join();
199+
200+
for (final CompletableFuture<Boolean> future : futures) {
201+
assertTrue(future.join());
202+
}
203+
204+
executorService.shutdown();
205+
assertTrue(executorService.awaitTermination(1, TimeUnit.SECONDS));
206+
}
151207
}

0 commit comments

Comments
 (0)