package com.peachpasswords.vault import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Test /** * Recovery words are only a human-readable encoding of the root key's first 16 * bytes, so the two platforms MUST agree on the word list byte for byte. They * did not: Android shipped a list with "abandon" duplicated at index 1, making * it the canonical list shifted by one from index 1 on. The same vault printed * different words on each platform, so a phrase written down on the phone * restored nothing in the extension. * * The vector below is computed from the browser's canonical list * (`src/shared/recovery-word-list.ts`) and is pinned identically in * `src/shared/recovery-words.cross-platform.test.ts`. * * There is deliberately no fallback to the old ordering: a decoder that * accepts a phrase under two different lists is ambiguity in the one path that * must never be ambiguous. Phrases written down from a build at or before * v2.4.12 are not accepted and must be re-recorded. */ class CrossPlatformRecoveryWordsTest { private val vectorRootKey = "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff" private val vectorWords = listOf( "abandon", "math", "mimic", "master", "filter", "design", "carbon", "crystal", "rookie", "group", "knife", "young", ) @Test fun word_list_is_the_canonical_2048_with_no_duplicates() { assertEquals(2048, WORD_LIST.size) assertEquals(2048, WORD_LIST.toSet().size) assertEquals("abandon", WORD_LIST.first()) assertEquals("ability", WORD_LIST[1]) assertEquals("zoo", WORD_LIST.last()) } @Test fun words_match_the_browser_for_the_shared_vector() { assertEquals(vectorWords, rootKeyToWords(hexToRootKey(vectorRootKey))) } @Test fun every_reachable_index_round_trips() { // The old list failed exactly here: index 1 encoded to "abandon" and // decoded back to 0. An 11-bit index reaches 0..2047, so all of it must. for (index in 0 until 2048) { assertEquals( "index $index does not round-trip", index, WORD_LIST.indexOf(WORD_LIST[index]), ) } } @Test fun random_root_keys_always_round_trip() { val rng = java.util.Random(20260726) repeat(20_000) { val key = ByteArray(32).also(rng::nextBytes) val material = wordsToRootKeyMaterial(rootKeyToWords(key)) assertTrue(material.copyOfRange(0, 16).contentEquals(key.copyOfRange(0, 16))) } } @Test fun the_shared_vector_decodes_to_its_root_key_entropy() { val expected = hexToRootKey(vectorRootKey).copyOfRange(0, 16) assertTrue( wordsToRootKeyMaterial(vectorWords).copyOfRange(0, 16).contentEquals(expected), ) } }