RecoveryWords.kt

6.3 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 = try {17        MessageDigest.getInstance("SHA-256").digest(entropy16)18    } finally {19        VaultCrypto.zero(entropy16)20    }21    // Mask to a byte before the unsigned shift: a Byte >= 0x80 sign-extends to a22    // negative Int, so `toInt() ushr 4` would yield a 28-bit value, not the 4-bit23    // checksum. The browser reads a Uint8 (`recovery-words.ts:36`). Without the24    // mask, ~50% of root keys (SHA[0] >= 0x80) produce non-browser-parity words.25    try {26        val checksum = (entropyHash[0].toInt() and 0xff) ushr 42728        var bitsBig = BigInteger.ZERO29        for (i in 0 until 16) {30            bitsBig = bitsBig.shiftLeft(8).or(BigInteger.valueOf((rootKeyBytes[i].toInt() and 0xff).toLong()))31        }32        bitsBig = bitsBig.shiftLeft(4).or(BigInteger.valueOf(checksum.toLong()))3334        val words = ArrayList<String>(TOTAL_WORDS)35        for (i in 0 until TOTAL_WORDS) {36            val shift = (TOTAL_WORDS - 1 - i) * WORD_BITS37            val index = bitsBig.shiftRight(shift).and(BigInteger.valueOf(0x7ffL)).toInt()38            words += WORD_LIST[index]39        }40        return words41    } finally {42        VaultCrypto.zero(entropyHash)43    }44}4546fun wordsToRootKeyMaterial(words: List<String>): ByteArray {47    require(words.size == TOTAL_WORDS) { "exactly $TOTAL_WORDS words required" }4849    val indices = IntArray(words.size)50    for (i in words.indices) {51        val word = words[i].trim().lowercase()52        val index = WORD_LIST.indexOf(word)53        require(index >= 0) { "unknown word at position ${i + 1}: \"${words[i]}\"" }54        indices[i] = index55    }5657    var bitsBig = BigInteger.ZERO58    for (index in indices) {59        bitsBig = bitsBig.shiftLeft(WORD_BITS).or(BigInteger.valueOf(index.toLong()))60    }6162    val entropy = ByteArray(16)63    var entropyHash: ByteArray? = null64    return try {65        for (i in 0 until 16) {66            val byteShift = 124 - i * 867            entropy[i] = bitsBig.shiftRight(byteShift).and(BigInteger.valueOf(0xffL)).toByte()68        }6970        val storedChecksum = bitsBig.and(BigInteger.valueOf(0xFL)).toInt()71        entropyHash = MessageDigest.getInstance("SHA-256").digest(entropy)72        // See rootKeyToWords: mask to a byte before the unsigned shift (browser parity).73        val computedChecksum = (entropyHash!![0].toInt() and 0xff) ushr 474        require(storedChecksum == computedChecksum) { "invalid recovery words — checksum mismatch" }7576        ByteArray(32).also { System.arraycopy(entropy, 0, it, 0, entropy.size) }77    } finally {78        entropyHash?.let(VaultCrypto::zero)79        VaultCrypto.zero(entropy)80    }81}8283/**84 * Generate a random 32-byte root key and prove its words round-trip.85 *86 * The retry loop this used to carry existed only because the shipped word list87 * duplicated "abandon" at index 1, which made ~0.6% of keys undecodable. The88 * list is now the canonical 2048, so every key round-trips; the check stays as89 * an assertion because shipping a key whose words cannot be typed back is the90 * one failure this code must never allow.91 */92fun generateRecoverableRootKey(): ByteArray {93    val candidate = ByteArray(32).also(java.security.SecureRandom()::nextBytes)94    var roundTrip: ByteArray? = null95    return try {96        roundTrip = runCatching { wordsToRootKeyMaterial(rootKeyToWords(candidate)) }.getOrNull()97        check(roundTrip != null) { "generated a root key whose recovery words do not round-trip" }98        candidate99    } catch (e: Throwable) {100        VaultCrypto.zero(candidate)101        throw e102    } finally {103        roundTrip?.let(VaultCrypto::zero)104    }105}106107/** Lowercase, unseparated hex of [bytes] — the exact ASCII the browser wraps in the escrow. */108fun rootKeyToHex(bytes: ByteArray): String = bytes.joinToString("") { "%02x".format(it) }109110/** Inverse of [rootKeyToHex]; tolerant of upper/lower case. */111fun hexToRootKey(hex: String): ByteArray {112    require(hex.length % 2 == 0) { "odd-length hex" }113    return ByteArray(hex.length / 2) { i -> hex.substring(i * 2, i * 2 + 2).toInt(16).toByte() }114}115116fun deriveRecoveryKey(wordMaterial: ByteArray, salt: ByteArray): SecretKeySpec {117    val generator = HKDFBytesGenerator(SHA256Digest())118    generator.init(HKDFParameters(wordMaterial, salt, "peach-recovery-v1".encodeToByteArray()))119    val out = ByteArray(32)120    return try {121        generator.generateBytes(out, 0, out.size)122        SecretKeySpec(out, "AES")123    } finally {124        // SecretKeySpec copies its constructor input; the HKDF workspace is125        // never returned to the caller and can be scrubbed immediately.126        VaultCrypto.zero(out)127    }128}129130fun findInvalidWords(words: List<String>): List<Pair<Int, String>> {131    val invalid = ArrayList<Pair<Int, String>>()132    for (i in words.indices) {133        val w = words[i].trim().lowercase()134        if (w.isBlank() || WORD_LIST.indexOf(w) == -1) {135            invalid += i to words[i]136        }137    }138    return invalid139}140141fun getWordSuggestions(prefix: String, maxResults: Int = 5): List<String> {142    val p = prefix.trim().lowercase()143    if (p.length < 2) return emptyList()144    return WORD_LIST.filter { it.startsWith(p) }.take(maxResults)145}146147/**148 * BLAKE3 hash (hex) of the space-joined, trimmed, lowercased recovery words.149 * Stored in vault metadata so recovery-word entry can be verified without150 * persisting the words. Mirrors the browser's recovery-hash derivation.151 */152fun recoveryWordsHash(words: List<String>): String {153    val canonical = words.joinToString(" ") { it.trim().lowercase() }.encodeToByteArray()154    val out = ByteArray(32)155    return try {156        val digest = org.bouncycastle.crypto.digests.Blake3Digest(256)157        digest.update(canonical, 0, canonical.size)158        digest.doFinal(out, 0)159        out.joinToString("") { "%02x".format(it) }160    } finally {161        VaultCrypto.zero(out)162        VaultCrypto.zero(canonical)163    }164}