RecoveryWords.kt

6.1 KB · raw

1package com.peachpasswords.vault23import org.bouncycastle.crypto.generators.HKDFBytesGenerator4import org.bouncycastle.crypto.digests.SHA256Digest5import org.bouncycastle.crypto.params.HKDFParameters6import java.math.BigInteger7import java.security.MessageDigest8import javax.crypto.spec.SecretKeySpec910private const val WORD_BITS = 1111private const val TOTAL_WORDS = 121213fun rootKeyToWords(rootKeyBytes: ByteArray): List<String> {14    require(rootKeyBytes.size == 32) { "root key must be 32 bytes" }15    val entropy16 = rootKeyBytes.copyOfRange(0, 16)16    val entropyHash = MessageDigest.getInstance("SHA-256").digest(entropy16)17    // Mask to a byte before the unsigned shift: a Byte >= 0x80 sign-extends to a18    // negative Int, so `toInt() ushr 4` would yield a 28-bit value, not the 4-bit19    // checksum. The browser reads a Uint8 (`recovery-words.ts:36`). Without the20    // mask, ~50% of root keys (SHA[0] >= 0x80) produce non-browser-parity words.21    val checksum = (entropyHash[0].toInt() and 0xff) ushr 42223    var bitsBig = BigInteger.ZERO24    for (i in 0 until 16) {25        bitsBig = bitsBig.shiftLeft(8).or(BigInteger.valueOf((rootKeyBytes[i].toInt() and 0xff).toLong()))26    }27    bitsBig = bitsBig.shiftLeft(4).or(BigInteger.valueOf(checksum.toLong()))2829    val words = ArrayList<String>(TOTAL_WORDS)30    for (i in 0 until TOTAL_WORDS) {31        val shift = (TOTAL_WORDS - 1 - i) * WORD_BITS32        val index = bitsBig.shiftRight(shift).and(BigInteger.valueOf(0x7ffL)).toInt()33        words += WORD_LIST[index]34    }35    return words36}3738/**39 * Decode a phrase to its 16 bytes of word material.40 *41 * Tries the canonical list first, then [LEGACY_WORD_LIST] so a phrase written42 * down from a build that shipped the duplicated-"abandon" ordering still43 * recovers. Both orderings are checksum-verified, and the escrow this material44 * unwraps is authenticated, so a phrase that decodes under the wrong ordering45 * cannot silently yield the wrong vault.46 */47fun wordsToRootKeyMaterial(words: List<String>): ByteArray =48    runCatching { decodeWith(words, WORD_LIST) }49        .recoverCatching { canonicalFailure ->50            runCatching { decodeWith(words, LEGACY_WORD_LIST) }51                .getOrElse { throw canonicalFailure }52        }53        .getOrThrow()5455private fun decodeWith(words: List<String>, list: List<String>): ByteArray {56    require(words.size == TOTAL_WORDS) { "exactly $TOTAL_WORDS words required" }5758    val indices = IntArray(words.size)59    for (i in words.indices) {60        val word = words[i].trim().lowercase()61        val index = list.indexOf(word)62        require(index >= 0) { "unknown word at position ${i + 1}: \"${words[i]}\"" }63        indices[i] = index64    }6566    var bitsBig = BigInteger.ZERO67    for (index in indices) {68        bitsBig = bitsBig.shiftLeft(WORD_BITS).or(BigInteger.valueOf(index.toLong()))69    }7071    val entropy = ByteArray(16)72    for (i in 0 until 16) {73        val byteShift = 124 - i * 874        entropy[i] = bitsBig.shiftRight(byteShift).and(BigInteger.valueOf(0xffL)).toByte()75    }7677    val storedChecksum = bitsBig.and(BigInteger.valueOf(0xFL)).toInt()78    val entropyHash = MessageDigest.getInstance("SHA-256").digest(entropy)79    // See rootKeyToWords: mask to a byte before the unsigned shift (browser parity).80    val computedChecksum = (entropyHash[0].toInt() and 0xff) ushr 481    require(storedChecksum == computedChecksum) { "invalid recovery words — checksum mismatch" }8283    return ByteArray(32).also { it.fill(0); System.arraycopy(entropy, 0, it, 0, entropy.size) }84}8586/**87 * Generate a random 32-byte root key and prove its words round-trip.88 *89 * The retry loop this used to carry existed only because the shipped word list90 * duplicated "abandon" at index 1, which made ~0.6% of keys undecodable. The91 * list is now the canonical 2048, so every key round-trips; the check stays as92 * an assertion because shipping a key whose words cannot be typed back is the93 * one failure this code must never allow.94 */95fun generateRecoverableRootKey(): ByteArray {96    val candidate = ByteArray(32).also(java.security.SecureRandom()::nextBytes)97    check(runCatching { wordsToRootKeyMaterial(rootKeyToWords(candidate)) }.isSuccess) {98        "generated a root key whose recovery words do not round-trip"99    }100    return candidate101}102103/** Lowercase, unseparated hex of [bytes] — the exact ASCII the browser wraps in the escrow. */104fun rootKeyToHex(bytes: ByteArray): String = bytes.joinToString("") { "%02x".format(it) }105106/** Inverse of [rootKeyToHex]; tolerant of upper/lower case. */107fun hexToRootKey(hex: String): ByteArray {108    require(hex.length % 2 == 0) { "odd-length hex" }109    return ByteArray(hex.length / 2) { i -> hex.substring(i * 2, i * 2 + 2).toInt(16).toByte() }110}111112fun deriveRecoveryKey(wordMaterial: ByteArray, salt: ByteArray): SecretKeySpec {113    val generator = HKDFBytesGenerator(SHA256Digest())114    generator.init(HKDFParameters(wordMaterial, salt, "peach-recovery-v1".encodeToByteArray()))115    val out = ByteArray(32)116    generator.generateBytes(out, 0, out.size)117    return SecretKeySpec(out, "AES")118}119120fun findInvalidWords(words: List<String>): List<Pair<Int, String>> {121    val invalid = ArrayList<Pair<Int, String>>()122    for (i in words.indices) {123        val w = words[i].trim().lowercase()124        if (w.isBlank() || WORD_LIST.indexOf(w) == -1) {125            invalid += i to words[i]126        }127    }128    return invalid129}130131fun getWordSuggestions(prefix: String, maxResults: Int = 5): List<String> {132    val p = prefix.trim().lowercase()133    if (p.length < 2) return emptyList()134    return WORD_LIST.filter { it.startsWith(p) }.take(maxResults)135}136137/**138 * BLAKE3 hash (hex) of the space-joined, trimmed, lowercased recovery words.139 * Stored in vault metadata so recovery-word entry can be verified without140 * persisting the words. Mirrors the browser's recovery-hash derivation.141 */142fun recoveryWordsHash(words: List<String>): String {143    val canonical = words.joinToString(" ") { it.trim().lowercase() }.encodeToByteArray()144    val digest = org.bouncycastle.crypto.digests.Blake3Digest(256)145    digest.update(canonical, 0, canonical.size)146    val out = ByteArray(32)147    digest.doFinal(out, 0)148    return out.joinToString("") { "%02x".format(it) }149}