CrossPlatformRecoveryWordsTest.kt

2.8 KB · raw

1package com.peachpasswords.vault23import org.junit.Assert.assertEquals4import org.junit.Assert.assertTrue5import org.junit.Test67/**8 * Recovery words are only a human-readable encoding of the root key's first 169 * bytes, so the two platforms MUST agree on the word list byte for byte. They10 * did not: Android shipped a list with "abandon" duplicated at index 1, making11 * it the canonical list shifted by one from index 1 on. The same vault printed12 * different words on each platform, so a phrase written down on the phone13 * restored nothing in the extension.14 *15 * The vector below is computed from the browser's canonical list16 * (`src/shared/recovery-word-list.ts`) and is pinned identically in17 * `src/shared/recovery-words.cross-platform.test.ts`.18 *19 * There is deliberately no fallback to the old ordering: a decoder that20 * accepts a phrase under two different lists is ambiguity in the one path that21 * must never be ambiguous. Phrases written down from a build at or before22 * v2.4.12 are not accepted and must be re-recorded.23 */24class CrossPlatformRecoveryWordsTest {2526    private val vectorRootKey =27        "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff"2829    private val vectorWords = listOf(30        "abandon", "math", "mimic", "master", "filter", "design",31        "carbon", "crystal", "rookie", "group", "knife", "young",32    )3334    @Test fun word_list_is_the_canonical_2048_with_no_duplicates() {35        assertEquals(2048, WORD_LIST.size)36        assertEquals(2048, WORD_LIST.toSet().size)37        assertEquals("abandon", WORD_LIST.first())38        assertEquals("ability", WORD_LIST[1])39        assertEquals("zoo", WORD_LIST.last())40    }4142    @Test fun words_match_the_browser_for_the_shared_vector() {43        assertEquals(vectorWords, rootKeyToWords(hexToRootKey(vectorRootKey)))44    }4546    @Test fun every_reachable_index_round_trips() {47        // The old list failed exactly here: index 1 encoded to "abandon" and48        // decoded back to 0. An 11-bit index reaches 0..2047, so all of it must.49        for (index in 0 until 2048) {50            assertEquals(51                "index $index does not round-trip",52                index,53                WORD_LIST.indexOf(WORD_LIST[index]),54            )55        }56    }5758    @Test fun random_root_keys_always_round_trip() {59        val rng = java.util.Random(20260726)60        repeat(20_000) {61            val key = ByteArray(32).also(rng::nextBytes)62            val material = wordsToRootKeyMaterial(rootKeyToWords(key))63            assertTrue(material.copyOfRange(0, 16).contentEquals(key.copyOfRange(0, 16)))64        }65    }6667    @Test fun the_shared_vector_decodes_to_its_root_key_entropy() {68        val expected = hexToRootKey(vectorRootKey).copyOfRange(0, 16)69        assertTrue(70            wordsToRootKeyMaterial(vectorWords).copyOfRange(0, 16).contentEquals(expected),71        )72    }73}