SyncEntrySanitizer.kt

1.9 KB · raw

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}