Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Now accepts user-defined recovery code's length. Differentiated between readable and actual code. Backwards compatible. #44

Open
wants to merge 4 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
.idea
target

#IntelliJ
.idea
*.iml

# Eclipse
.settings
.project
.classpath
bin

# NetBeans
nb-configuration.xml
38 changes: 36 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -244,16 +244,50 @@ The default implementation provided in this library generates recovery codes :

Thoses settings guarantees recovery codes security (with an entropy of 82 bits) while keeping codes simple to read and enter by end user when needed.


```java
import dev.samstevens.totp.recovery.RecoveryCodeGenerator;
...
// Generate 16 random recovery codes
RecoveryCodeGenerator recoveryCodes = new RecoveryCodeGenerator();
String[] codes = recoveryCodes.generateCodes(16);
// codes = ["tf8i-exmo-3lcb-slkm", "boyv-yq75-z99k-r308", "w045-mq6w-mg1i-q12o", ...]
```

OR with Differentiated Recovery Codes

```java
import dev.samstevens.totp.recovery.RecoveryCodeGenerator;
import dev.samstevens.totp.recovery.CodePack;
...
// Generate 16 random recovery codes
RecoveryCodeGenerator recoveryCodes = new RecoveryCodeGenerator();
CodePack[] codes = recoveryCodes.generateDifferentiatedCodes(16);
// codes = [CodePack Object1, CodePack Object2, CodePack Object3, ...]
// Object1.actualCode ="tf8iexmo3lcbslkm"
// Object1.readableCode="tf8i-exmo-3lcb-slkm"
```


The user can even define custom length for the Recovery Codes and the Grouping size for the Recovery readable code.
The minimum length of the Recovery Code is 7, therefore keeping the minimun entropy of 36 bits, hence generating a fairly secure code.


```java
import dev.samstevens.totp.recovery.RecoveryCodeGenerator;
import dev.samstevens.totp.recovery.CodePack;
...
// Generate 15 random recovery codes of length 13 and grouping size of 3 for readable code
RecoveryCodeGenerator generator =
new RecoveryCodeGenerator
.Builder()
.setCodeLength(13)
.setGroupNumber(3)
.build();
CodePack[] codes = recoveryCodes.generateDifferentiatedCodes(15);
// codes = [CodePack Object1, CodePack Object2, CodePack Object3, ...]
// Object1.actualCode ="tf8xmo3lcslkm"
// Object1.readableCode="tf8-xmo-3lc-slk-m"
```



## Running Tests
Expand Down
5 changes: 5 additions & 0 deletions totp/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -59,5 +59,10 @@
<artifactId>javase</artifactId>
<version>3.4.0</version>
</dependency>
<dependency>
<groupId>commons-codec</groupId>
<artifactId>commons-codec</artifactId>
<version>1.15</version>
</dependency>
</dependencies>
</project>
11 changes: 11 additions & 0 deletions totp/src/main/java/dev/samstevens/totp/recovery/CodePack.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package dev.samstevens.totp.recovery;

public class CodePack {
public String readableCode=null;
public String actualCode=null;

public CodePack(String readableCode, String actualCode) {
this.readableCode = readableCode;
this.actualCode = actualCode;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package dev.samstevens.totp.recovery;

import java.security.InvalidParameterException;

public class InsecureCodeException extends InvalidParameterException {
public InsecureCodeException(){}
public InsecureCodeException(String s){
super(s);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,18 +9,57 @@ 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 :
// the default settings used below allows the code to reach an entropy of 82 bits :
// log(36^16) / log(2) == 82.7...

// the minimum allowed settings used below allows the code to reach a minimum entropy of 36 bits :
// log(36^7) / log(2) == 36.18.. which is considered reasonable; fairly secure passwords

// 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;
private static int CODE_LENGTH;
private static int GROUPS_NBR;

private Random random = new SecureRandom();

public RecoveryCodeGenerator(){
CODE_LENGTH = 16;
GROUPS_NBR = 4;
}

public RecoveryCodeGenerator(int codeLength,int groupNumber){
if (groupNumber < 1) {
throw new InvalidParameterException("Group Number must be at least 1.");
}
if(codeLength < 7){
throw new InsecureCodeException("Code Length must be at least 7 to be secure");
}

CODE_LENGTH=codeLength;
GROUPS_NBR=groupNumber;
}

public static class Builder{
private int codeLength=16;
private int groupNumber=4;

public Builder setCodeLength(int codeLength) {
this.codeLength = codeLength;
return this;
}

public Builder setGroupNumber(int groupNumber) {
this.groupNumber = groupNumber;
return this;
}

public RecoveryCodeGenerator build(){
return new RecoveryCodeGenerator(codeLength,groupNumber);
}
}

public String[] generateCodes(int amount) {
// Must generate at least one code
if (amount < 1) {
Expand Down Expand Up @@ -49,5 +88,37 @@ private String generateCode() {

return code.toString();
}

public CodePack[] generateDifferentiatedCodes(int amount) {
// Must generate at least one code
if (amount < 1) {
throw new InvalidParameterException("Amount must be at least 1.");
}

// Create an array and fill with generated codes
CodePack[] codes = new CodePack[amount];
Arrays.setAll(codes, i -> generateDifferentiatedCode());

return codes;
}

private CodePack generateDifferentiatedCode() {
final StringBuilder code_readable = new StringBuilder(CODE_LENGTH + (CODE_LENGTH/GROUPS_NBR) - 1);
final StringBuilder code_actual = new StringBuilder(CODE_LENGTH);

for (int i = 0; i < CODE_LENGTH; i++) {
// Append random character from authorized ones
char c =CHARACTERS[random.nextInt(CHARACTERS.length)];
code_actual.append(c);
code_readable.append(c);

// Split code into groups for increased readability
if ((i+1) % GROUPS_NBR == 0 && (i+1) != CODE_LENGTH) {
code_readable.append("-");
}
}

return new CodePack(code_readable.toString(),code_actual.toString());
}

}
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
package dev.samstevens.totp.recovery;

import org.junit.jupiter.api.Test;

import java.security.InvalidParameterException;
import java.util.Arrays;
import java.util.HashSet;
import java.util.Set;
import java.util.stream.Collectors;

import static org.junit.jupiter.api.Assertions.*;

public class RecoveryCodeGeneratorTest {
Expand All @@ -21,6 +24,18 @@ public void testCorrectAmountGenerated() {
}
}

@Test
public void testCorrectAmountGeneratedDifferentiated() {
RecoveryCodeGenerator generator = new RecoveryCodeGenerator();
CodePack[] codes = generator.generateDifferentiatedCodes(16);

// Assert 16 non null codes generated
assertEquals(16, codes.length);
for (CodePack code : codes) {
assertNotNull(code);
}
}

@Test
public void testCodesMatchFormat() {
RecoveryCodeGenerator generator = new RecoveryCodeGenerator();
Expand All @@ -32,6 +47,28 @@ public void testCodesMatchFormat() {
}
}

@Test
public void testDifferentiatedCodesMatchReadableFormat() {
RecoveryCodeGenerator generator = new RecoveryCodeGenerator();
CodePack[] codes = generator.generateDifferentiatedCodes(16);

// Assert each one is the correct format
for (CodePack code : codes) {
assertTrue(code.readableCode.matches("[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}"), code.readableCode);
}
}

@Test
public void testDifferentiatedCodesMatchActualFormat() {
RecoveryCodeGenerator generator = new RecoveryCodeGenerator();
CodePack[] codes = generator.generateDifferentiatedCodes(16);

// Assert each one is the correct format
for (CodePack code : codes) {
assertTrue(code.actualCode.length()==16,code.actualCode);
}
}

@Test
public void testCodesAreUnique() {
RecoveryCodeGenerator generator = new RecoveryCodeGenerator();
Expand All @@ -42,6 +79,16 @@ public void testCodesAreUnique() {
assertEquals(25, uniqueCodes.size());
}

@Test
public void testDifferentiatedCodesAreUnique() {
RecoveryCodeGenerator generator = new RecoveryCodeGenerator();
CodePack[] codes = generator.generateDifferentiatedCodes(25);

Set<String> uniqueCodes = Arrays.stream(codes).map(map->map.actualCode).collect(Collectors.toSet());

assertEquals(25, uniqueCodes.size());
}

@Test
public void testInvalidNumberThrowsException() {
RecoveryCodeGenerator generator = new RecoveryCodeGenerator();
Expand All @@ -50,4 +97,44 @@ public void testInvalidNumberThrowsException() {
generator.generateCodes(-1);
});
}

@Test
public void testDifferentiatedReadableCodeGrouping(){
RecoveryCodeGenerator generator =
new RecoveryCodeGenerator
.Builder()
.setCodeLength(13)
.setGroupNumber(3)
.build();

CodePack codes[] = generator.generateDifferentiatedCodes(14);
for (CodePack code : codes) {
assertTrue(code.readableCode.matches("[a-z0-9]{3}-[a-z0-9]{3}-[a-z0-9]{3}-[a-z0-9]{3}-[a-z0-9]{1}"), code.readableCode);
}
}

@Test
public void testInvalidGroupSize(){
assertThrows(InvalidParameterException.class,
()->{
new RecoveryCodeGenerator.Builder()
.setCodeLength(12)
.setGroupNumber(0)
.build();
}
);
}

@Test
public void testInsecureCodeLength(){
assertThrows(InsecureCodeException.class,
()->{
new RecoveryCodeGenerator
.Builder()
.setGroupNumber(5)
.setCodeLength(6)
.build();
}
);
}
}