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.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)
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
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() {
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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