Browse Source

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

Sebastian Stenzel 5 years ago
parent
commit
d2086d100e

+ 97 - 0
main/ui/src/main/java/org/cryptomator/ui/keyrecovery/WordEncoder.java

@@ -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
+ 4096 - 0
main/ui/src/main/resources/i18n/4096words_en.txt


+ 42 - 0
main/ui/src/test/java/org/cryptomator/ui/keyrecovery/WordEncoderTest.java

@@ -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;
+		});
+	}
+
+}