1package com.peachpasswords.ui.vault234567891011121314151617181920212223data class ParsedEntry(24 val title: String,25 val username: String? = null,26 val password: String? = null,27 val url: String? = null,28 val notes: String? = null,29 val totp: String? = null,30 val passkeys: List<ParsedPasskey> = emptyList(),31)323334data class ParsedPasskey(35 val credentialId: String,36 val rpId: String,37 val rpName: String? = null,38 val privateKeyJwk: String,39 val algorithm: Int,40 val userHandle: String? = null,41 val userName: String? = null,42 val userDisplayName: String? = null,43 val signCount: Int = 0,44)454647sealed interface ImportParseResult {48 data class Success(val provider: String, val entries: List<ParsedEntry>) : ImportParseResult49 data class Failure(val reason: Reason) : ImportParseResult5051 enum class Reason { UNRECOGNISED, CORRUPTED, NO_ENTRIES, TOO_LARGE }52}5354object CsvImport {5556 575859606162 const val MAX_IMPORT_BYTES = 50 * 1024 * 102463 const val MAX_IMPORT_ENTRIES = 10_00064 const val MAX_FIELD_CHARS = 50 * 10246566 6768697071 fun readBoundedUtf8(input: java.io.InputStream, max: Int = MAX_IMPORT_BYTES): String? {72 val out = java.io.ByteArrayOutputStream()73 val buf = ByteArray(64 * 1024)74 while (true) {75 val n = input.read(buf)76 if (n == -1) break77 if (out.size().toLong() + n > max) return null78 out.write(buf, 0, n)79 }80 return out.toString("UTF-8")81 }8283 84 const val BITWARDEN_CSV = "Bitwarden CSV"85 const val BITWARDEN_JSON = "Bitwarden JSON"86 const val CXF_JSON = "Credential Exchange JSON"87 const val PROTONPASS_JSON = "Proton Pass JSON"88 const val ONEPASSWORD_CSV = "1Password CSV"89 const val CHROME_CSV = "Chrome CSV"90 const val EDGE_CSV = "Edge CSV"91 const val SAFARI_CSV = "Safari CSV"92 const val GENERIC_CSV = "CSV"9394 95969798 fun detectProvider(content: String): String? {99 val trimmed = content.trim()100 if (trimmed.isEmpty()) return null101102 103 104 105 106 if (trimmed.startsWith("{") && Regex("\"accounts\"\\s*:\\s*\\[").containsMatchIn(trimmed)) {107 return CXF_JSON108 }109 110 if (trimmed.startsWith("{") && Regex("\"vaults\"\\s*:\\s*\\{").containsMatchIn(trimmed)) {111 return PROTONPASS_JSON112 }113 114 115 if (trimmed.startsWith("{") && Regex("\"items\"\\s*:\\s*\\[").containsMatchIn(trimmed)) {116 return BITWARDEN_JSON117 }118 119 if (trimmed.startsWith("{") || trimmed.startsWith("[")) return null120121 val header = firstNonBlankLine(trimmed) ?: return null122 val cols = splitCsvLine(header).map { it.trim().lowercase() }123 val set = cols.toSet()124125 return when {126 set.containsAll(listOf("name", "login_uri", "login_username", "login_password")) -> BITWARDEN_CSV127 128 129 130 cols == listOf("name", "url", "username", "password") -> CHROME_CSV131 set.containsAll(listOf("title", "url", "username", "password")) &&132 (set.contains("otpauth") || set.contains("notes")) -> SAFARI_CSV133 set.containsAll(listOf("title", "url", "username", "password")) -> ONEPASSWORD_CSV134 135 (set.contains("name") || set.contains("title")) &&136 set.any { it.contains("password") } -> GENERIC_CSV137 else -> null138 }139 }140141 142143144145146147 fun parse(content: String, provider: String): List<ParsedEntry> = when (provider) {148 PROTONPASS_JSON -> parseProtonPassJson(content)149 BITWARDEN_JSON -> parseBitwardenJson(content)150 CXF_JSON -> parseCxfJson(content)151 BITWARDEN_CSV -> parseCsvByHeader(content, BitwardenCsvMap)152 CHROME_CSV, EDGE_CSV -> parseCsvByHeader(content, ChromeCsvMap)153 SAFARI_CSV -> parseCsvByHeader(content, SafariCsvMap)154 ONEPASSWORD_CSV -> parseCsvByHeader(content, OnePasswordCsvMap)155 else -> parseCsvByHeader(content, GenericCsvMap)156 }157158 159 fun detectAndParse(content: String): ImportParseResult {160 try {161 val provider = detectProvider(content)162 ?: return ImportParseResult.Failure(ImportParseResult.Reason.UNRECOGNISED)163 val entries = parse(content, provider)164 return when {165 entries.isEmpty() -> ImportParseResult.Failure(ImportParseResult.Reason.NO_ENTRIES)166 167 168 entries.size > MAX_IMPORT_ENTRIES || entries.any { it.hasOversizeField() } ->169 ImportParseResult.Failure(ImportParseResult.Reason.TOO_LARGE)170 else -> ImportParseResult.Success(provider, entries)171 }172 } catch (_: ImportTooLargeException) {173 return ImportParseResult.Failure(ImportParseResult.Reason.TOO_LARGE)174 }175 }176177 private class ImportTooLargeException : RuntimeException()178179 private fun checkImportEntryCount(count: Int) {180 if (count > MAX_IMPORT_ENTRIES) throw ImportTooLargeException()181 }182183 private fun ParsedEntry.hasOversizeField(): Boolean =184 listOf(title, username, password, url, notes, totp).any { (it?.length ?: 0) > MAX_FIELD_CHARS }185186 187188 189190191192193 private class CsvColumnMap(194 val title: List<String>,195 val username: List<String>,196 val password: List<String>,197 val url: List<String>,198 val notes: List<String>,199 val totp: List<String>,200 )201202 private val BitwardenCsvMap = CsvColumnMap(203 title = listOf("name"),204 username = listOf("login_username"),205 password = listOf("login_password"),206 url = listOf("login_uri"),207 notes = listOf("notes"),208 totp = listOf("login_totp"),209 )210211 private val ChromeCsvMap = CsvColumnMap(212 title = listOf("name"),213 username = listOf("username"),214 password = listOf("password"),215 url = listOf("url"),216 notes = listOf("note", "notes"),217 totp = emptyList(),218 )219220 private val SafariCsvMap = CsvColumnMap(221 title = listOf("title"),222 username = listOf("username"),223 password = listOf("password"),224 url = listOf("url"),225 notes = listOf("notes"),226 totp = listOf("otpauth"),227 )228229 private val OnePasswordCsvMap = CsvColumnMap(230 title = listOf("title"),231 username = listOf("username"),232 password = listOf("password"),233 url = listOf("url", "website"),234 notes = listOf("notes"),235 totp = listOf("otpauth", "one-time password", "totp"),236 )237238 private val GenericCsvMap = CsvColumnMap(239 title = listOf("name", "title"),240 username = listOf("username", "user", "login", "email"),241 password = listOf("password", "login_password", "pass"),242 url = listOf("url", "login_uri", "website", "uri"),243 notes = listOf("notes", "note"),244 totp = listOf("totp", "login_totp", "otpauth"),245 )246247 private fun parseCsvByHeader(content: String, map: CsvColumnMap): List<ParsedEntry> {248 val rows = parseCsv(content)249 if (rows.size < 2) return emptyList()250 val header = rows.first().map { it.trim().lowercase() }251 fun col(names: List<String>): Int = names.firstNotNullOfOrNull { n ->252 header.indexOf(n).takeIf { it >= 0 }253 } ?: -1254255 val ti = col(map.title)256 val ui = col(map.username)257 val pi = col(map.password)258 val urli = col(map.url)259 val ni = col(map.notes)260 val toi = col(map.totp)261262 fun cell(row: List<String>, idx: Int): String? =263 if (idx in row.indices) row[idx].trim().ifEmpty { null } else null264265 return rows.drop(1).mapNotNull { row ->266 if (row.all { it.isBlank() }) return@mapNotNull null267 val title = cell(row, ti)268 ?: cell(row, urli)269 ?: cell(row, ui)270 ?: return@mapNotNull null271 ParsedEntry(272 title = title,273 username = cell(row, ui),274 password = cell(row, pi),275 url = cell(row, urli),276 notes = cell(row, ni),277 totp = cell(row, toi),278 )279 }280 }281282 283284 private fun parseProtonPassJson(content: String): List<ParsedEntry> {285 val vaults = extractObject(content, "vaults") ?: return emptyList()286 val entries = mutableListOf<ParsedEntry>()287 for (vault in splitTopLevelObjects(vaults.removePrefix("{").removeSuffix("}"))) {288 if (jsonString(vault, "name").equals("recycle bin", ignoreCase = true)) continue289 val itemsArray = extractArray(vault, "items") ?: continue290 for (item in splitTopLevelObjects(itemsArray)) {291 val itemData = extractObject(item, "data") ?: item292 val itemType = jsonString(itemData, "type") ?: jsonString(item, "type")293 if (itemType != "login") continue294 val body = extractObject(itemData, "content") ?: ""295 val metadata = extractObject(itemData, "metadata") ?: ""296 val password = firstNonBlank(jsonString(body, "password"), jsonString(body, "itemPassword"))297 ?: continue298 entries += ParsedEntry(299 title = firstNonBlank(300 jsonString(metadata, "name"),301 jsonString(item, "name"),302 jsonString(item, "title"),303 jsonString(body, "itemName"),304 ) ?: "Untitled",305 username = firstNonBlank(306 jsonString(body, "username"),307 jsonString(body, "itemUsername"),308 jsonString(body, "email"),309 jsonString(body, "itemEmail"),310 jsonString(item, "aliasEmail"),311 ),312 password = password,313 url = firstNonBlank(extractFirstStringFromArray(body, "urls"), jsonString(body, "url")),314 notes = firstNonBlank(jsonString(metadata, "note"), jsonString(metadata, "notes")),315 totp = firstNonBlank(jsonString(body, "totpUri"), jsonString(body, "itemTotpUri")),316 )317 }318 }319 return entries320 }321322 323324 325326327328329 private fun parseBitwardenJson(content: String): List<ParsedEntry> {330 val itemsArray = extractItemsArray(content) ?: return emptyList()331 val objects = splitTopLevelObjects(itemsArray)332 return objects.mapNotNull { obj ->333 val type = jsonNumber(obj, "type")334 335 if (type != null && type != 1) return@mapNotNull null336 val name = jsonString(obj, "name") ?: return@mapNotNull null337 val loginBlock = extractObject(obj, "login") ?: ""338 ParsedEntry(339 title = name,340 username = jsonString(loginBlock, "username"),341 password = jsonString(loginBlock, "password"),342 url = extractFirstUri(loginBlock),343 notes = jsonString(obj, "notes"),344 totp = jsonString(loginBlock, "totp"),345 passkeys = parseBitwardenFido2(loginBlock),346 )347 }348 }349350 private fun parseBitwardenFido2(loginBlock: String): List<ParsedPasskey> {351 val credsArray = extractArray(loginBlock, "fido2Credentials") ?: return emptyList()352 val creds = splitTopLevelObjects(credsArray)353 return creds.mapNotNull { cred ->354 val credentialId = jsonString(cred, "credentialId") ?: return@mapNotNull null355 val rpId = jsonString(cred, "rpId") ?: return@mapNotNull null356 val keyValue = jsonString(cred, "keyValue") ?: return@mapNotNull null357 val keyAlgorithm = jsonString(cred, "keyAlgorithm") ?: "ECDSA"358 val keyCurve = jsonString(cred, "keyCurve") ?: "P-256"359 val algorithm = when {360 keyAlgorithm.equals("ECDSA", ignoreCase = true) && keyCurve == "P-256" -> -7361 keyAlgorithm.equals("EdDSA", ignoreCase = true) || keyCurve == "Ed25519" -> -8362 else -> return@mapNotNull null363 }364 val jwk = buildBitwardenJwk(keyValue, algorithm)365 ParsedPasskey(366 credentialId = credentialId,367 rpId = rpId,368 rpName = jsonString(cred, "rpName"),369 privateKeyJwk = jwk,370 algorithm = algorithm,371 userHandle = jsonString(cred, "userHandle"),372 userName = jsonString(cred, "userName"),373 userDisplayName = jsonString(cred, "userDisplayName"),374 signCount = jsonNumber(cred, "counter") ?: 0,375 )376 }377 }378379 private fun buildBitwardenJwk(keyValue: String, algorithm: Int): String = when (algorithm) {380 -7 -> "{\"kty\":\"EC\",\"crv\":\"P-256\",\"d\":\"" + keyValue + "\"}"381 -8 -> "{\"kty\":\"OKP\",\"crv\":\"Ed25519\",\"d\":\"" + keyValue + "\"}"382 else -> "{}"383 }384385 386387 388389390391392393394395 private fun parseCxfJson(content: String): List<ParsedEntry> {396 val accounts = extractArray(content, "accounts") ?: return emptyList()397 val entries = mutableListOf<ParsedEntry>()398 for (account in splitTopLevelObjects(accounts)) {399 val items = extractArray(account, "items") ?: continue400 for (item in splitTopLevelObjects(items)) {401 val creds = extractArray(item, "credentials") ?: continue402 val itemTitle = jsonString(item, "title")403 val passkeys = mutableListOf<ParsedPasskey>()404 var firstRpId: String? = null405 for (cred in splitTopLevelObjects(creds)) {406 if (!jsonString(cred, "type").equals("passkey", ignoreCase = true)) continue407 val credentialId = jsonString(cred, "credentialId") ?: continue408 val rpId = jsonString(cred, "rpId") ?: continue409 val userName = jsonString(cred, "username") ?: continue410 val userDisplayName = jsonString(cred, "userDisplayName") ?: continue411 val userHandle = jsonString(cred, "userHandle") ?: continue412 val key = jsonString(cred, "key") ?: continue413 val (jwk, algorithm) = cxfKeyToJwk(key) ?: continue414 if (firstRpId == null) firstRpId = rpId415 passkeys += ParsedPasskey(416 credentialId = credentialId,417 rpId = rpId,418 rpName = itemTitle ?: rpId,419 privateKeyJwk = jwk,420 algorithm = algorithm,421 userHandle = userHandle,422 userName = userName,423 userDisplayName = userDisplayName,424 signCount = 0,425 )426 }427 if (passkeys.isEmpty()) continue428 429 430 entries += ParsedEntry(431 title = itemTitle ?: firstRpId ?: "Untitled",432 url = firstRpId?.let { "https://$it" },433 passkeys = passkeys,434 )435 }436 }437 return entries438 }439440 441442443444445446 private fun cxfKeyToJwk(keyB64url: String): Pair<String, Int>? = runCatching {447 val der = java.util.Base64.getUrlDecoder().decode(keyB64url)448 val r = DerReader(der)449 r.expect(0x30); r.readLength() 450 r.expect(0x02); r.skip(r.readLength()) 451 r.expect(0x30) 452 val algLen = r.readLength()453 val algEnd = r.pos + algLen 454 r.expect(0x06); val oid = r.readValue(r.readLength())455 r.pos = algEnd456 r.expect(0x04); val privKey = r.readValue(r.readLength()) 457 when {458 oid.contentEquals(ED25519_OID) -> {459 460 val inner = DerReader(privKey)461 inner.expect(0x04)462 val d = inner.readValue(inner.readLength())463 "{\"kty\":\"OKP\",\"crv\":\"Ed25519\",\"d\":\"" + b64Url(d) + "\"}" to -8464 }465 oid.contentEquals(EC_PUBLIC_KEY_OID) -> {466 467 val inner = DerReader(privKey)468 inner.expect(0x30); inner.readLength()469 inner.expect(0x02); inner.skip(inner.readLength()) 470 inner.expect(0x04)471 val d = inner.readValue(inner.readLength())472 "{\"kty\":\"EC\",\"crv\":\"P-256\",\"d\":\"" + b64Url(d) + "\"}" to -7473 }474 else -> null475 }476 }.getOrNull()477478 private fun b64Url(bytes: ByteArray): String =479 java.util.Base64.getUrlEncoder().withoutPadding().encodeToString(bytes)480481 482 private val ED25519_OID = byteArrayOf(0x2B, 0x65, 0x70) 483 private val EC_PUBLIC_KEY_OID = byteArrayOf(0x2A, 0x86.toByte(), 0x48, 0xCE.toByte(), 0x3D, 0x02, 0x01) 484485 486 private class DerReader(private val bytes: ByteArray) {487 var pos = 0488 fun expect(tag: Int) {489 if (pos >= bytes.size || (bytes[pos].toInt() and 0xFF) != tag) {490 throw IllegalArgumentException("unexpected DER tag at $pos")491 }492 pos++493 }494 fun readLength(): Int {495 val first = bytes[pos++].toInt() and 0xFF496 if (first < 0x80) return first497 var len = 0498 repeat(first and 0x7F) { len = (len shl 8) or (bytes[pos++].toInt() and 0xFF) }499 return len500 }501 fun readValue(len: Int): ByteArray = bytes.copyOfRange(pos, pos + len).also { pos += len }502 fun skip(len: Int) { pos += len }503 }504505 private fun extractItemsArray(content: String): String? {506 return extractArray(content, "items")507 }508509 private fun extractArray(content: String, keyName: String): String? {510 val key = Regex("\"${Regex.escape(keyName)}\"\\s*:\\s*\\[").find(content) ?: return null511 val start = key.range.last 512 var depth = 0513 var i = start514 var inStr = false515 var esc = false516 while (i < content.length) {517 val c = content[i]518 if (inStr) {519 when {520 esc -> esc = false521 c == '\\' -> esc = true522 c == '"' -> inStr = false523 }524 } else when (c) {525 '"' -> inStr = true526 '[' -> depth++527 ']' -> { depth--; if (depth == 0) return content.substring(start + 1, i) }528 }529 i++530 }531 return null532 }533534 535 private fun splitTopLevelObjects(arrayBody: String): List<String> {536 val out = mutableListOf<String>()537 var depth = 0538 var startIdx = -1539 var inStr = false540 var esc = false541 for (i in arrayBody.indices) {542 val c = arrayBody[i]543 if (inStr) {544 when {545 esc -> esc = false546 c == '\\' -> esc = true547 c == '"' -> inStr = false548 }549 continue550 }551 when (c) {552 '"' -> inStr = true553 '{' -> { if (depth == 0) startIdx = i; depth++ }554 '}' -> {555 depth--556 if (depth == 0 && startIdx >= 0) {557 out.add(arrayBody.substring(startIdx, i + 1))558 checkImportEntryCount(out.size)559 startIdx = -1560 }561 }562 }563 }564 return out565 }566567 568 private fun extractObject(obj: String, key: String): String? {569 val m = Regex("\"${Regex.escape(key)}\"\\s*:\\s*\\{").find(obj) ?: return null570 var depth = 0571 var inStr = false572 var esc = false573 var i = m.range.last 574 val start = i575 while (i < obj.length) {576 val c = obj[i]577 if (inStr) {578 when {579 esc -> esc = false580 c == '\\' -> esc = true581 c == '"' -> inStr = false582 }583 } else when (c) {584 '"' -> inStr = true585 '{' -> depth++586 '}' -> { depth--; if (depth == 0) return obj.substring(start, i + 1) }587 }588 i++589 }590 return null591 }592593 private fun extractFirstUri(loginBlock: String): String? {594 595 val uriInArray = Regex("\"uris\"\\s*:\\s*\\[(.*?)]", RegexOption.DOT_MATCHES_ALL)596 .find(loginBlock)?.groupValues?.get(1)597 if (uriInArray != null) {598 jsonString(uriInArray, "uri")?.let { return it }599 }600 return jsonString(loginBlock, "uri")601 }602603 private fun extractFirstStringFromArray(scope: String, key: String): String? {604 val body = extractArray(scope, key) ?: return null605 return Regex("\"((?:[^\"\\\\]|\\\\.)*)\"").find(body)606 ?.groupValues607 ?.get(1)608 ?.let(::unescapeJson)609 ?.ifEmpty { null }610 }611612 private fun jsonString(scope: String, key: String): String? {613 val m = Regex("\"${Regex.escape(key)}\"\\s*:\\s*\"((?:[^\"\\\\]|\\\\.)*)\"").find(scope)614 ?: return null615 return unescapeJson(m.groupValues[1]).ifEmpty { null }616 }617618 private fun jsonNumber(scope: String, key: String): Int? {619 val m = Regex("\"${Regex.escape(key)}\"\\s*:\\s*(-?\\d+)").find(scope) ?: return null620 return m.groupValues[1].toIntOrNull()621 }622623 private fun firstNonBlank(vararg values: String?): String? =624 values.firstOrNull { !it.isNullOrBlank() }?.trim()625626 private fun unescapeJson(s: String): String {627 val sb = StringBuilder(s.length)628 var i = 0629 while (i < s.length) {630 val c = s[i]631 if (c == '\\' && i + 1 < s.length) {632 when (val n = s[i + 1]) {633 'n' -> sb.append('\n')634 't' -> sb.append('\t')635 'r' -> sb.append('\r')636 '"' -> sb.append('"')637 '\\' -> sb.append('\\')638 '/' -> sb.append('/')639 'u' -> {640 if (i + 5 < s.length) {641 val code = s.substring(i + 2, i + 6).toIntOrNull(16)642 if (code != null) { sb.append(code.toChar()); i += 6; continue }643 }644 sb.append(n)645 }646 else -> sb.append(n)647 }648 i += 2649 } else {650 sb.append(c)651 i++652 }653 }654 return sb.toString()655 }656657 658659 660661662663664 fun parseCsv(content: String): List<List<String>> {665 val rows = mutableListOf<List<String>>()666 var field = StringBuilder()667 var row = mutableListOf<String>()668 var inQuotes = false669 var i = 0670 val n = content.length671672 fun endField() { row.add(field.toString()); field = StringBuilder() }673 fun endRow() {674 endField()675 checkImportEntryCount(rows.size + 1)676 rows.add(row)677 row = mutableListOf()678 }679680 while (i < n) {681 val c = content[i]682 if (inQuotes) {683 when {684 c == '"' && i + 1 < n && content[i + 1] == '"' -> { field.append('"'); i++ }685 c == '"' -> inQuotes = false686 else -> field.append(c)687 }688 } else {689 when (c) {690 '"' -> inQuotes = true691 ',' -> endField()692 '\r' -> { }693 '\n' -> endRow()694 else -> field.append(c)695 }696 }697 i++698 }699 700 701 if (field.isNotEmpty() || row.isNotEmpty()) endRow()702 703 return rows.filterNot { it.size == 1 && it[0].isEmpty() }704 }705706 707 fun splitCsvLine(line: String): List<String> =708 parseCsv(line).firstOrNull() ?: emptyList()709710 private fun firstNonBlankLine(s: String): String? =711 s.lineSequence().firstOrNull { it.isNotBlank() }712}