feat: profile PIN cryptography for offline usage

This commit is contained in:
tapframe 2026-04-18 23:07:19 +05:30
parent 161e5d81bb
commit d9fbcdb9fb
11 changed files with 225 additions and 2 deletions

View file

@ -26,6 +26,7 @@ import com.nuvio.app.features.player.PlayerSettingsStorage
import com.nuvio.app.features.player.PlayerPictureInPictureManager import com.nuvio.app.features.player.PlayerPictureInPictureManager
import com.nuvio.app.features.plugins.PluginStorage import com.nuvio.app.features.plugins.PluginStorage
import com.nuvio.app.features.profiles.AvatarStorage 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.profiles.ProfileStorage
import com.nuvio.app.features.details.SeasonViewModeStorage import com.nuvio.app.features.details.SeasonViewModeStorage
import com.nuvio.app.features.search.SearchHistoryStorage import com.nuvio.app.features.search.SearchHistoryStorage
@ -61,6 +62,7 @@ class MainActivity : ComponentActivity() {
PlayerSettingsStorage.initialize(applicationContext) PlayerSettingsStorage.initialize(applicationContext)
ProfileStorage.initialize(applicationContext) ProfileStorage.initialize(applicationContext)
AvatarStorage.initialize(applicationContext) AvatarStorage.initialize(applicationContext)
ProfilePinCacheStorage.initialize(applicationContext)
SearchHistoryStorage.initialize(applicationContext) SearchHistoryStorage.initialize(applicationContext)
SeasonViewModeStorage.initialize(applicationContext) SeasonViewModeStorage.initialize(applicationContext)
ThemeSettingsStorage.initialize(applicationContext) ThemeSettingsStorage.initialize(applicationContext)

View file

@ -10,6 +10,7 @@ internal actual object PlatformLocalAccountDataCleaner {
"nuvio_player_settings", "nuvio_player_settings",
"nuvio_profile_cache", "nuvio_profile_cache",
"nuvio_avatar_cache", "nuvio_avatar_cache",
"nuvio_profile_pin_cache",
"nuvio_theme_settings", "nuvio_theme_settings",
"nuvio_poster_card_style", "nuvio_poster_card_style",
"nuvio_mdblist_settings", "nuvio_mdblist_settings",

View file

@ -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"
}

View file

@ -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')
}
}
}

View file

@ -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")

View file

@ -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)
}

View file

@ -0,0 +1,5 @@
package com.nuvio.app.features.profiles
internal expect object ProfilePinCrypto {
fun sha256Hex(value: String): String
}

View file

@ -228,6 +228,7 @@ object ProfileRepository {
suspend fun deleteProfile(profileIndex: Int) { suspend fun deleteProfile(profileIndex: Int) {
if (AuthRepository.state.value.isAnonymous) { if (AuthRepository.state.value.isAnonymous) {
val remaining = _state.value.profiles.filter { it.profileIndex != profileIndex } val remaining = _state.value.profiles.filter { it.profileIndex != profileIndex }
ProfilePinCacheStorage.removePayload(profileIndex)
_state.value = _state.value.copy( _state.value = _state.value.copy(
profiles = remaining, profiles = remaining,
activeProfile = if (_state.value.activeProfile?.profileIndex == profileIndex) remaining.firstOrNull() else _state.value.activeProfile, 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 { suspend fun verifyPin(profileIndex: Int, pin: String): PinVerifyResult {
if (AuthRepository.state.value !is AuthState.Authenticated) {
return verifyPinLocally(profileIndex, pin)
}
return runCatching { return runCatching {
val params = buildJsonObject { val params = buildJsonObject {
put("p_profile_id", profileIndex) put("p_profile_id", profileIndex)
put("p_pin", pin) put("p_pin", pin)
} }
val result = SupabaseProvider.client.postgrest.rpc("verify_profile_pin", params) val result = SupabaseProvider.client.postgrest.rpc("verify_profile_pin", params)
result.decodeSingle<PinVerifyResult>() result.decodeSingle<PinVerifyResult>().also { verifyResult ->
if (verifyResult.unlocked) {
rememberVerifiedPin(profileIndex = profileIndex, pin = pin)
}
}
}.getOrElse { e -> }.getOrElse { e ->
log.e(e) { "Failed to verify pin" } 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 { 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 { return runCatching {
val params = buildJsonObject { val params = buildJsonObject {
put("p_profile_id", profileIndex) put("p_profile_id", profileIndex)
@ -270,6 +283,7 @@ object ProfileRepository {
} }
SupabaseProvider.client.postgrest.rpc("set_profile_pin", params) SupabaseProvider.client.postgrest.rpc("set_profile_pin", params)
pullProfiles() pullProfiles()
rememberVerifiedPin(profileIndex = profileIndex, pin = pin)
PinVerifyResult(unlocked = true) PinVerifyResult(unlocked = true)
}.onFailure { e -> }.onFailure { e ->
log.e(e) { "Failed to set pin" } log.e(e) { "Failed to set pin" }
@ -279,6 +293,10 @@ object ProfileRepository {
} }
suspend fun clearPin(profileIndex: Int, currentPin: String? = null): PinVerifyResult { 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 { return runCatching {
val params = buildJsonObject { val params = buildJsonObject {
put("p_profile_id", profileIndex) put("p_profile_id", profileIndex)
@ -286,6 +304,7 @@ object ProfileRepository {
} }
SupabaseProvider.client.postgrest.rpc("clear_profile_pin", params) SupabaseProvider.client.postgrest.rpc("clear_profile_pin", params)
pullProfiles() pullProfiles()
ProfilePinCacheStorage.removePayload(profileIndex)
PinVerifyResult(unlocked = true) PinVerifyResult(unlocked = true)
}.onFailure { e -> }.onFailure { e ->
log.e(e) { "Failed to clear pin" } log.e(e) { "Failed to clear pin" }
@ -302,6 +321,7 @@ object ProfileRepository {
} }
SupabaseProvider.client.postgrest.rpc("clear_profile_pin_with_account_password", params) SupabaseProvider.client.postgrest.rpc("clear_profile_pin_with_account_password", params)
pullProfiles() pullProfiles()
ProfilePinCacheStorage.removePayload(profileIndex)
}.onFailure { e -> }.onFailure { e ->
log.e(e) { "Failed to clear pin with password" } log.e(e) { "Failed to clear pin with password" }
} }
@ -339,6 +359,7 @@ object ProfileRepository {
if (_state.value.activeProfile != null) { if (_state.value.activeProfile != null) {
activeProfileIndex = _state.value.activeProfile!!.profileIndex activeProfileIndex = _state.value.activeProfile!!.profileIndex
} }
syncPinCache(profiles)
persist() persist()
} }
@ -360,6 +381,88 @@ object ProfileRepository {
isLoaded = profiles.isNotEmpty(), isLoaded = profiles.isNotEmpty(),
) )
_state.value.activeProfile?.let { activeProfileIndex = it.profileIndex } _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<CachedProfilePinPayload>(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<NuvioProfile>) {
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<CachedProfilePinPayload>(raw)
}.getOrNull() ?: run {
ProfilePinCacheStorage.removePayload(profileIndex)
continue
}
if (
cached.profileUpdatedAt.isNotBlank() &&
profile.updatedAt.isNotBlank() &&
cached.profileUpdatedAt != profile.updatedAt
) {
ProfilePinCacheStorage.removePayload(profileIndex)
}
}
} }
private fun persist() { private fun persist() {

View file

@ -7,6 +7,7 @@ internal actual object PlatformLocalAccountDataCleaner {
"profile_payload", "profile_payload",
"avatar_catalog_payload", "avatar_catalog_payload",
) )
private val profilePinCachePrefixes = listOf("profile_pin_cache_")
private val profileIndexedPrefixes = listOf( private val profileIndexedPrefixes = listOf(
"installed_manifest_urls_", "installed_manifest_urls_",
"plugins_state_", "plugins_state_",
@ -55,6 +56,9 @@ internal actual object PlatformLocalAccountDataCleaner {
profileIndexedPrefixes.forEach { prefix -> profileIndexedPrefixes.forEach { prefix ->
defaults.removeObjectForKey("$prefix$profileId") defaults.removeObjectForKey("$prefix$profileId")
} }
profilePinCachePrefixes.forEach { prefix ->
defaults.removeObjectForKey("$prefix$profileId")
}
profileScopedBaseKeys.forEach { baseKey -> profileScopedBaseKeys.forEach { baseKey ->
defaults.removeObjectForKey("${baseKey}_$profileId") defaults.removeObjectForKey("${baseKey}_$profileId")
} }

View file

@ -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"
}

View file

@ -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')
}
}
}