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 22 23 24 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 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}8283848586878889909192fun 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}106107108fun rootKeyToHex(bytes: ByteArray): String = bytes.joinToString("") { "%02x".format(it) }109110111fun 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 125 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}146147148149150151152fun 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}