diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt index a110d6da..9e05571b 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt @@ -26,6 +26,7 @@ import com.nuvio.app.features.player.PlayerSettingsStorage import com.nuvio.app.features.player.PlayerPictureInPictureManager import com.nuvio.app.features.plugins.PluginStorage import com.nuvio.app.features.profiles.AvatarStorage +import com.nuvio.app.features.profiles.ProfilePinCacheStorage import com.nuvio.app.features.profiles.ProfileStorage import com.nuvio.app.features.details.SeasonViewModeStorage import com.nuvio.app.features.search.SearchHistoryStorage @@ -61,6 +62,7 @@ class MainActivity : ComponentActivity() { PlayerSettingsStorage.initialize(applicationContext) ProfileStorage.initialize(applicationContext) AvatarStorage.initialize(applicationContext) + ProfilePinCacheStorage.initialize(applicationContext) SearchHistoryStorage.initialize(applicationContext) SeasonViewModeStorage.initialize(applicationContext) ThemeSettingsStorage.initialize(applicationContext) diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.android.kt index 2f2932ee..7f970d32 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.android.kt @@ -10,6 +10,7 @@ internal actual object PlatformLocalAccountDataCleaner { "nuvio_player_settings", "nuvio_profile_cache", "nuvio_avatar_cache", + "nuvio_profile_pin_cache", "nuvio_theme_settings", "nuvio_poster_card_style", "nuvio_mdblist_settings", diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/profiles/ProfilePinCacheStorage.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/profiles/ProfilePinCacheStorage.android.kt new file mode 100644 index 00000000..1d0d049d --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/profiles/ProfilePinCacheStorage.android.kt @@ -0,0 +1,33 @@ +package com.nuvio.app.features.profiles + +import android.content.Context +import android.content.SharedPreferences + +actual object ProfilePinCacheStorage { + private const val preferencesName = "nuvio_profile_pin_cache" + + private var preferences: SharedPreferences? = null + + fun initialize(context: Context) { + preferences = context.getSharedPreferences(preferencesName, Context.MODE_PRIVATE) + } + + actual fun loadPayload(profileIndex: Int): String? = + preferences?.getString(payloadKey(profileIndex), null) + + actual fun savePayload(profileIndex: Int, payload: String) { + preferences + ?.edit() + ?.putString(payloadKey(profileIndex), payload) + ?.apply() + } + + actual fun removePayload(profileIndex: Int) { + preferences + ?.edit() + ?.remove(payloadKey(profileIndex)) + ?.apply() + } + + private fun payloadKey(profileIndex: Int): String = "profile_pin_cache_$profileIndex" +} \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/profiles/ProfilePinCrypto.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/profiles/ProfilePinCrypto.android.kt new file mode 100644 index 00000000..9a9547bc --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/profiles/ProfilePinCrypto.android.kt @@ -0,0 +1,12 @@ +package com.nuvio.app.features.profiles + +import java.security.MessageDigest + +actual object ProfilePinCrypto { + actual fun sha256Hex(value: String): String { + val digest = MessageDigest.getInstance("SHA-256").digest(value.encodeToByteArray()) + return digest.joinToString(separator = "") { byte -> + byte.toUByte().toString(16).padStart(2, '0') + } + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfilePinCache.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfilePinCache.kt new file mode 100644 index 00000000..0664d3b8 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfilePinCache.kt @@ -0,0 +1,20 @@ +package com.nuvio.app.features.profiles + +import kotlin.random.Random +import kotlinx.serialization.Serializable + +@Serializable +internal data class CachedProfilePinPayload( + val salt: String, + val digest: String, + val profileUpdatedAt: String = "", +) + +internal fun generateProfilePinSalt(): String { + val first = Random.nextLong().toULong().toString(16) + val second = Random.nextLong().toULong().toString(16) + return "$first$second" +} + +internal fun hashProfilePin(profileIndex: Int, salt: String, pin: String): String = + ProfilePinCrypto.sha256Hex("profile:$profileIndex:$salt:$pin") \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfilePinCacheStorage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfilePinCacheStorage.kt new file mode 100644 index 00000000..16b325c2 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfilePinCacheStorage.kt @@ -0,0 +1,7 @@ +package com.nuvio.app.features.profiles + +internal expect object ProfilePinCacheStorage { + fun loadPayload(profileIndex: Int): String? + fun savePayload(profileIndex: Int, payload: String) + fun removePayload(profileIndex: Int) +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfilePinCrypto.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfilePinCrypto.kt new file mode 100644 index 00000000..317985c7 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfilePinCrypto.kt @@ -0,0 +1,5 @@ +package com.nuvio.app.features.profiles + +internal expect object ProfilePinCrypto { + fun sha256Hex(value: String): String +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileRepository.kt index 0e49bd1a..51181a2b 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileRepository.kt @@ -228,6 +228,7 @@ object ProfileRepository { suspend fun deleteProfile(profileIndex: Int) { if (AuthRepository.state.value.isAnonymous) { val remaining = _state.value.profiles.filter { it.profileIndex != profileIndex } + ProfilePinCacheStorage.removePayload(profileIndex) _state.value = _state.value.copy( profiles = remaining, activeProfile = if (_state.value.activeProfile?.profileIndex == profileIndex) remaining.firstOrNull() else _state.value.activeProfile, @@ -248,20 +249,32 @@ object ProfileRepository { } suspend fun verifyPin(profileIndex: Int, pin: String): PinVerifyResult { + if (AuthRepository.state.value !is AuthState.Authenticated) { + return verifyPinLocally(profileIndex, pin) + } + return runCatching { val params = buildJsonObject { put("p_profile_id", profileIndex) put("p_pin", pin) } val result = SupabaseProvider.client.postgrest.rpc("verify_profile_pin", params) - result.decodeSingle() + result.decodeSingle().also { verifyResult -> + if (verifyResult.unlocked) { + rememberVerifiedPin(profileIndex = profileIndex, pin = pin) + } + } }.getOrElse { e -> log.e(e) { "Failed to verify pin" } - PinVerifyResult(unlocked = false, retryAfterSeconds = 0, message = "Couldn't verify PIN. Try again.") + verifyPinLocally(profileIndex, pin) } } suspend fun setPin(profileIndex: Int, pin: String, currentPin: String? = null): PinVerifyResult { + if (AuthRepository.state.value !is AuthState.Authenticated) { + return PinVerifyResult(unlocked = false, message = "Connect to the internet to set a PIN.") + } + return runCatching { val params = buildJsonObject { put("p_profile_id", profileIndex) @@ -270,6 +283,7 @@ object ProfileRepository { } SupabaseProvider.client.postgrest.rpc("set_profile_pin", params) pullProfiles() + rememberVerifiedPin(profileIndex = profileIndex, pin = pin) PinVerifyResult(unlocked = true) }.onFailure { e -> log.e(e) { "Failed to set pin" } @@ -279,6 +293,10 @@ object ProfileRepository { } suspend fun clearPin(profileIndex: Int, currentPin: String? = null): PinVerifyResult { + if (AuthRepository.state.value !is AuthState.Authenticated) { + return PinVerifyResult(unlocked = false, message = "Connect to the internet to remove the PIN lock.") + } + return runCatching { val params = buildJsonObject { put("p_profile_id", profileIndex) @@ -286,6 +304,7 @@ object ProfileRepository { } SupabaseProvider.client.postgrest.rpc("clear_profile_pin", params) pullProfiles() + ProfilePinCacheStorage.removePayload(profileIndex) PinVerifyResult(unlocked = true) }.onFailure { e -> log.e(e) { "Failed to clear pin" } @@ -302,6 +321,7 @@ object ProfileRepository { } SupabaseProvider.client.postgrest.rpc("clear_profile_pin_with_account_password", params) pullProfiles() + ProfilePinCacheStorage.removePayload(profileIndex) }.onFailure { e -> log.e(e) { "Failed to clear pin with password" } } @@ -339,6 +359,7 @@ object ProfileRepository { if (_state.value.activeProfile != null) { activeProfileIndex = _state.value.activeProfile!!.profileIndex } + syncPinCache(profiles) persist() } @@ -360,6 +381,88 @@ object ProfileRepository { isLoaded = profiles.isNotEmpty(), ) _state.value.activeProfile?.let { activeProfileIndex = it.profileIndex } + syncPinCache(profiles) + } + + private fun rememberVerifiedPin(profileIndex: Int, pin: String) { + val profile = _state.value.profiles.find { it.profileIndex == profileIndex } + val salt = generateProfilePinSalt() + val payload = CachedProfilePinPayload( + salt = salt, + digest = hashProfilePin(profileIndex = profileIndex, salt = salt, pin = pin), + profileUpdatedAt = profile?.updatedAt.orEmpty(), + ) + ProfilePinCacheStorage.savePayload(profileIndex, json.encodeToString(payload)) + } + + private fun verifyPinLocally(profileIndex: Int, pin: String): PinVerifyResult { + val profile = _state.value.profiles.find { it.profileIndex == profileIndex } + if (profile?.pinEnabled != true) { + return PinVerifyResult(unlocked = true) + } + + val payload = ProfilePinCacheStorage.loadPayload(profileIndex).orEmpty().trim() + if (payload.isEmpty()) { + return PinVerifyResult( + unlocked = false, + message = "This PIN can't be verified offline on this device yet. Connect once and unlock it online first.", + ) + } + + val cached = runCatching { + json.decodeFromString(payload) + }.getOrNull() ?: return PinVerifyResult( + unlocked = false, + message = "This PIN can't be verified offline on this device yet. Connect once and unlock it online first.", + ) + + if ( + cached.profileUpdatedAt.isNotBlank() && + profile.updatedAt.isNotBlank() && + cached.profileUpdatedAt != profile.updatedAt + ) { + ProfilePinCacheStorage.removePayload(profileIndex) + return PinVerifyResult( + unlocked = false, + message = "This profile PIN changed. Connect once to refresh the lock on this device.", + ) + } + + val digest = hashProfilePin(profileIndex = profileIndex, salt = cached.salt, pin = pin) + return if (digest == cached.digest) { + PinVerifyResult(unlocked = true) + } else { + PinVerifyResult(unlocked = false, message = "Incorrect PIN") + } + } + + private fun syncPinCache(profiles: List) { + val profilesByIndex = profiles.associateBy { it.profileIndex } + for (profileIndex in 1..4) { + val profile = profilesByIndex[profileIndex] + if (profile == null || !profile.pinEnabled) { + ProfilePinCacheStorage.removePayload(profileIndex) + continue + } + + val raw = ProfilePinCacheStorage.loadPayload(profileIndex).orEmpty().trim() + if (raw.isEmpty()) continue + + val cached = runCatching { + json.decodeFromString(raw) + }.getOrNull() ?: run { + ProfilePinCacheStorage.removePayload(profileIndex) + continue + } + + if ( + cached.profileUpdatedAt.isNotBlank() && + profile.updatedAt.isNotBlank() && + cached.profileUpdatedAt != profile.updatedAt + ) { + ProfilePinCacheStorage.removePayload(profileIndex) + } + } } private fun persist() { diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.ios.kt index 1da8d0a2..8e8a1418 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.ios.kt @@ -7,6 +7,7 @@ internal actual object PlatformLocalAccountDataCleaner { "profile_payload", "avatar_catalog_payload", ) + private val profilePinCachePrefixes = listOf("profile_pin_cache_") private val profileIndexedPrefixes = listOf( "installed_manifest_urls_", "plugins_state_", @@ -55,6 +56,9 @@ internal actual object PlatformLocalAccountDataCleaner { profileIndexedPrefixes.forEach { prefix -> defaults.removeObjectForKey("$prefix$profileId") } + profilePinCachePrefixes.forEach { prefix -> + defaults.removeObjectForKey("$prefix$profileId") + } profileScopedBaseKeys.forEach { baseKey -> defaults.removeObjectForKey("${baseKey}_$profileId") } diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/profiles/ProfilePinCacheStorage.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/profiles/ProfilePinCacheStorage.ios.kt new file mode 100644 index 00000000..dc1659d0 --- /dev/null +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/profiles/ProfilePinCacheStorage.ios.kt @@ -0,0 +1,18 @@ +package com.nuvio.app.features.profiles + +import platform.Foundation.NSUserDefaults + +actual object ProfilePinCacheStorage { + actual fun loadPayload(profileIndex: Int): String? = + NSUserDefaults.standardUserDefaults.stringForKey(payloadKey(profileIndex)) + + actual fun savePayload(profileIndex: Int, payload: String) { + NSUserDefaults.standardUserDefaults.setObject(payload, forKey = payloadKey(profileIndex)) + } + + actual fun removePayload(profileIndex: Int) { + NSUserDefaults.standardUserDefaults.removeObjectForKey(payloadKey(profileIndex)) + } + + private fun payloadKey(profileIndex: Int): String = "profile_pin_cache_$profileIndex" +} \ No newline at end of file diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/profiles/ProfilePinCrypto.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/profiles/ProfilePinCrypto.ios.kt new file mode 100644 index 00000000..9fc5a1cb --- /dev/null +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/profiles/ProfilePinCrypto.ios.kt @@ -0,0 +1,18 @@ +package com.nuvio.app.features.profiles + +import com.nuvio.app.features.plugins.cryptointerop.CC_SHA256 +import com.nuvio.app.features.plugins.cryptointerop.CC_SHA256_DIGEST_LENGTH +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.refTo + +actual object ProfilePinCrypto { + @OptIn(ExperimentalForeignApi::class) + actual fun sha256Hex(value: String): String { + val input = value.encodeToByteArray() + val output = UByteArray(CC_SHA256_DIGEST_LENGTH.toInt()) + CC_SHA256(input.refTo(0), input.size.toUInt(), output.refTo(0)) + return output.joinToString(separator = "") { byte -> + byte.toString(16).padStart(2, '0') + } + } +} \ No newline at end of file