1package com.peachpasswords.vault23import kotlinx.serialization.SerialName4import kotlinx.serialization.Serializable5import kotlinx.serialization.json.Json6import java.util.Base6478910111213141516171819202122232425262728293031323334@Serializable35data class BrowserSlotA(36 val version: Int = 1,37 val salt: String,38 val nonce: String,39 val ciphertext: String,40 val tag: String,41 @SerialName("recovery_salt") val recoverySalt: String? = null,42 @SerialName("recovery_wrapped_root_key") val recoveryWrappedRootKey: String? = null,43 @SerialName("recovery_nonce") val recoveryNonce: String? = null,44 @SerialName("recovery_tag") val recoveryTag: String? = null,45 @SerialName("pw_root_ciphertext") val pwRootCiphertext: String? = null,46 @SerialName("pw_root_nonce") val pwRootNonce: String? = null,47 @SerialName("pw_root_tag") val pwRootTag: String? = null,48)4950object BrowserSlot {51 private val json = Json { ignoreUnknownKeys = true; isLenient = true }5253 54 fun parse(s: String): BrowserSlotA? =55 runCatching { json.decodeFromString<BrowserSlotA>(s) }.getOrNull()5657 58596061626364 fun toAndroidSlot(browser: BrowserSlotA, password: String): StoredVault? {65 val salt = unhex(browser.salt)66 val key = VaultCrypto.deriveKey(password, salt)67 try {68 val sealed = unhex(browser.nonce) + unhex(browser.ciphertext) + unhex(browser.tag)69 val payloadJson = try {70 VaultCrypto.decrypt(sealed, key, VaultCrypto.AAD_SLOT_A)71 } catch (_: Exception) {72 return null73 }74 75 76 77 78 79 80 81 82 83 84 val plain = payloadJson.toString(Charsets.UTF_8).trimBucketPadding()85 val payload = VaultCodec.decode(plain.toByteArray(Charsets.UTF_8))8687 val rootKey = unwrapRootKey(browser, key)88 val migrated = payload.copy(89 rootKeyB64 = rootKey?.let { b64(it) } ?: payload.rootKeyB64,90 )91 rootKey?.let(VaultCrypto::zero)9293 return StoredVault(94 version = 1,95 salt = b64(salt),96 blob = b64(VaultCrypto.encrypt(VaultCodec.encode(migrated), key, VaultCrypto.AAD_SLOT_A)),97 rootKeyEscrow = browser.toRootKeyEscrow(),98 )99 } finally {100 VaultCrypto.zero(key)101 }102 }103104 105 private fun unwrapRootKey(browser: BrowserSlotA, derivedKey: ByteArray): ByteArray? {106 val ct = browser.pwRootCiphertext ?: return null107 val nonce = browser.pwRootNonce ?: return null108 val tag = browser.pwRootTag ?: return null109 return runCatching {110 val hexBytes = VaultCrypto.decrypt(111 unhex(nonce) + unhex(ct) + unhex(tag),112 derivedKey,113 VaultCrypto.AAD_ROOT_KEY,114 )115 unhex(hexBytes.toString(Charsets.UTF_8).trimBucketPadding())116 }.getOrNull()117 }118119 private fun BrowserSlotA.toRootKeyEscrow(): RootKeyEscrow? {120 val s = recoverySalt ?: return null121 val w = recoveryWrappedRootKey ?: return null122 val n = recoveryNonce ?: return null123 val t = recoveryTag ?: return null124 return RootKeyEscrow(125 salt = b64(unhex(s)),126 wrappedRootKey = b64(unhex(w)),127 nonce = b64(unhex(n)),128 tag = b64(unhex(t)),129 )130 }131132 private fun unhex(s: String): ByteArray {133 require(s.length % 2 == 0) { "odd-length hex" }134 return ByteArray(s.length / 2) { i ->135 ((hexDigit(s[i * 2]) shl 4) or hexDigit(s[i * 2 + 1])).toByte()136 }137 }138139 private fun hexDigit(c: Char): Int = when (c) {140 in '0'..'9' -> c - '0'141 in 'a'..'f' -> c - 'a' + 10142 in 'A'..'F' -> c - 'A' + 10143 else -> throw IllegalArgumentException("bad hex digit")144 }145146 private fun b64(b: ByteArray): String = Base64.getEncoder().encodeToString(b)147}148149150151152153154private fun String.trimBucketPadding(): String = trim { it == '\u0000' || it.isWhitespace() }