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 18 19 20 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}3738394041424344454647fun 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 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}8586878889909192939495fun 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}102103104fun rootKeyToHex(bytes: ByteArray): String = bytes.joinToString("") { "%02x".format(it) }105106107fun 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}136137138139140141142fun 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}