Added utility to encode a recovery key to a human-friendly sequence of words

This commit is contained in:
Sebastian Stenzel
2019-09-16 17:39:10 +02:00
parent 2a33705cc6
commit d2086d100e
3 changed files with 4235 additions and 0 deletions

View File

@@ -0,0 +1,97 @@
package org.cryptomator.ui.keyrecovery;
import com.google.common.base.Preconditions;
import com.google.common.base.Splitter;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.Reader;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import java.util.stream.IntStream;
class WordEncoder {
private static final int WORD_COUNT = 4096;
private static final char DELIMITER = ' ';
private final List<String> words;
private final Map<String, Integer> indices;
public WordEncoder() {
this("/i18n/4096words_en.txt");
}
public WordEncoder(String wordFile) {
try (InputStream in = getClass().getResourceAsStream(wordFile); //
Reader reader = new InputStreamReader(in, StandardCharsets.US_ASCII.newDecoder()); //
BufferedReader bufferedReader = new BufferedReader(reader)) {
this.words = bufferedReader.lines().limit(WORD_COUNT).collect(Collectors.toUnmodifiableList());
} catch (IOException e) {
throw new IllegalArgumentException("Unreadable file: " + wordFile, e);
}
if (words.size() < WORD_COUNT) {
throw new IllegalArgumentException("Insufficient input file: " + wordFile);
}
this.indices = Map.ofEntries(IntStream.range(0, WORD_COUNT).mapToObj(i -> Map.entry(words.get(i), i)).toArray(Map.Entry[]::new));
}
/**
* Encodes the given input as a sequence of words.
* @param input A multiple of three bytes
* @return A String that can be {@link #decode(String) decoded} to the input again.
* @throws IllegalArgumentException If input is not a multiple of three bytes
*/
public String encodePadded(byte[] input) {
Preconditions.checkArgument(input.length % 3 == 0, "input needs to be padded to a multipe of three");
StringBuilder sb = new StringBuilder();
for (int i = 0; i < input.length; i+=3) {
byte b1 = input[i];
byte b2 = input[i+1];
byte b3 = input[i+2];
int firstWordIndex = (0xFF0 & (b1 << 4)) + (0x00F & (b2 >> 4)); // 0xFFF000
int secondWordIndex = (0xF00 & (b2 << 8)) + (0x0FF & b3); // 0x000FFF
assert firstWordIndex < WORD_COUNT;
assert secondWordIndex < WORD_COUNT;
sb.append(words.get(firstWordIndex)).append(DELIMITER);
sb.append(words.get(secondWordIndex)).append(DELIMITER);
}
if (sb.length() > 0) {
sb.setLength(sb.length() - 1); // remove last space
}
return sb.toString();
}
/**
* Decodes a String that has previously been {@link #encodePadded(byte[]) encoded} to a word sequence.
* @param encoded The word sequence
* @return Decoded bytes
* @throws IllegalArgumentException If the encoded string doesn't consist of a multiple of two words or one of the words is unknown to this encoder.
*/
public byte[] decode(String encoded) {
List<String> splitted = Splitter.on(DELIMITER).omitEmptyStrings().splitToList(encoded);
Preconditions.checkArgument(splitted.size() % 2 == 0, "%s needs to be a multiple of two words", encoded);
byte[] result = new byte[splitted.size() / 2 * 3];
for (int i = 0; i < splitted.size(); i+=2) {
String w1 = splitted.get(i);
String w2 = splitted.get(i+1);
int firstWordIndex = indices.getOrDefault(w1, -1);
int secondWordIndex = indices.getOrDefault(w2, -1);
Preconditions.checkArgument(firstWordIndex != -1, "%s not in dictionary", w1);
Preconditions.checkArgument(secondWordIndex != -1, "%s not in dictionary", w2);
byte b1 = (byte) (0xFF & (firstWordIndex >> 4));
byte b2 = (byte) ((0xF0 & (firstWordIndex << 4)) + (0x0F & (secondWordIndex >> 8)));
byte b3 = (byte) (0xFF & secondWordIndex);
result[i/2*3] = b1;
result[i/2*3+1] = b2;
result[i/2*3+2] = b3;
}
return result;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,42 @@
package org.cryptomator.ui.keyrecovery;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.TestInstance;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.MethodSource;
import java.util.Random;
import java.util.stream.IntStream;
import java.util.stream.Stream;
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class WordEncoderTest {
private static final Random PRNG = new Random(42l);
private WordEncoder encoder;
@BeforeAll
public void setup() {
encoder = new WordEncoder();
}
@DisplayName("decode(encode(input)) == input")
@ParameterizedTest(name = "test {index}")
@MethodSource("createRandomByteSequences")
void encodeAndDecode(byte[] input) {
String encoded = encoder.encodePadded(input);
byte[] decoded = encoder.decode(encoded);
Assertions.assertArrayEquals(input, decoded);
}
static Stream<byte[]> createRandomByteSequences() {
return IntStream.range(0, 30).mapToObj(i -> {
byte[] randomBytes = new byte[i * 3];
PRNG.nextBytes(randomBytes);
return randomBytes;
});
}
}