SyncEntrySanitizer.kt
1package com.peachpasswords.vault23/**4 * MN-3 (2026-06-09 audit, reopened 2026-08-03): content sanitization for5 * entries arriving from a sync peer. The typed Kotlin model already rejects6 * missing REQUIRED fields at decode time; this covers what decode cannot:7 * blank-but-present values, credential-less login husks, and unbounded8 * passwordHistory. Mirrors the browser's `sanitizeRestoredEntry`9 * (vault.ts) — with one Android-specific allowance: passkey fields may10 * arrive FLAT on the entry (the browser's legacy shape, see VaultEntry's11 * MN-5 note), which `consolidatePasskeyEntries` merges on unlock.12 *13 * Returns null when the entry must be dropped entirely.14 */15object SyncEntrySanitizer {1617 fun sanitize(entry: VaultEntry, historyMax: Int): VaultEntry? {18 val title = entry.title.trim()19 if (title.isEmpty()) return null2021 // A nested passkey missing its identifying fields is a credential-less22 // husk — refuse the whole entry rather than import it (browser parity:23 // "entry claimed a passkey but the shape didn't validate").24 entry.passkey?.let { pk ->25 if (pk.credentialId.isBlank() || pk.publicKeyCose.isBlank() ||26 pk.rpId.isBlank() || pk.userHandle.isBlank() || pk.userName.isBlank()27 ) {28 return null29 }30 }3132 // Login entries must carry a password OR a passkey (nested or the33 // browser's flat shape, identified by rpId + credentialId).34 val hasFlatPasskey = !entry.rpId.isNullOrBlank() && !entry.credentialId.isNullOrBlank()35 if (entry.isLogin() && entry.passkey == null && entry.password == null && !hasFlatPasskey) {36 return null37 }3839 val history = entry.passwordHistory?.takeIf { it.isNotEmpty() }?.take(historyMax)40 return entry.copy(title = title, passwordHistory = history)41 }42}