package com.peachpasswords.vault /** * MN-3 (2026-06-09 audit, reopened 2026-08-03): content sanitization for * entries arriving from a sync peer. The typed Kotlin model already rejects * missing REQUIRED fields at decode time; this covers what decode cannot: * blank-but-present values, credential-less login husks, and unbounded * passwordHistory. Mirrors the browser's `sanitizeRestoredEntry` * (vault.ts) — with one Android-specific allowance: passkey fields may * arrive FLAT on the entry (the browser's legacy shape, see VaultEntry's * MN-5 note), which `consolidatePasskeyEntries` merges on unlock. * * Returns null when the entry must be dropped entirely. */ object SyncEntrySanitizer { fun sanitize(entry: VaultEntry, historyMax: Int): VaultEntry? { val title = entry.title.trim() if (title.isEmpty()) return null // A nested passkey missing its identifying fields is a credential-less // husk — refuse the whole entry rather than import it (browser parity: // "entry claimed a passkey but the shape didn't validate"). entry.passkey?.let { pk -> if (pk.credentialId.isBlank() || pk.publicKeyCose.isBlank() || pk.rpId.isBlank() || pk.userHandle.isBlank() || pk.userName.isBlank() ) { return null } } // Login entries must carry a password OR a passkey (nested or the // browser's flat shape, identified by rpId + credentialId). val hasFlatPasskey = !entry.rpId.isNullOrBlank() && !entry.credentialId.isNullOrBlank() if (entry.isLogin() && entry.passkey == null && entry.password == null && !hasFlatPasskey) { return null } val history = entry.passwordHistory?.takeIf { it.isNotEmpty() }?.take(historyMax) return entry.copy(title = title, passwordHistory = history) } }