mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-05-16 23:12:12 +00:00
feat: profile PIN cryptography for offline usage
This commit is contained in:
parent
161e5d81bb
commit
d9fbcdb9fb
11 changed files with 225 additions and 2 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
package com.nuvio.app.features.profiles
|
||||
|
||||
internal expect object ProfilePinCrypto {
|
||||
fun sha256Hex(value: String): String
|
||||
}
|
||||
|
|
@ -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<PinVerifyResult>()
|
||||
result.decodeSingle<PinVerifyResult>().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<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() {
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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')
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue