diff --git a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/AppModule.kt b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/AppModule.kt index 7399b7f..500ed8e 100644 --- a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/AppModule.kt +++ b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/AppModule.kt @@ -13,6 +13,7 @@ import com.darkrockstudios.app.securecamera.preferences.AppPreferencesDataSource import com.darkrockstudios.app.securecamera.security.DeviceInfo import com.darkrockstudios.app.securecamera.security.SecurityLevel import com.darkrockstudios.app.securecamera.security.SecurityLevelDetector +import com.darkrockstudios.app.securecamera.security.pin.PinCrypto import com.darkrockstudios.app.securecamera.security.pin.PinRepository import com.darkrockstudios.app.securecamera.security.pin.PinRepositoryHardware import com.darkrockstudios.app.securecamera.security.pin.PinRepositorySoftware @@ -37,7 +38,6 @@ val appModule = module { single { AuthorizationRepository( preferences = get(), - pinRepository = get(), encryptionScheme = get(), context = get(), clock = get() @@ -58,14 +58,15 @@ val appModule = module { val detector = get() when (detector.detectSecurityLevel()) { SecurityLevel.SOFTWARE -> - PinRepositorySoftware(get(), get()) + PinRepositorySoftware(get(), get(), get()) SecurityLevel.TEE, SecurityLevel.STRONGBOX -> { - PinRepositoryHardware(get(), get(), get()) + PinRepositoryHardware(get(), get(), get(), get()) } } } bind PinRepository::class singleOf(::SecurityLevelDetector) + singleOf(::PinCrypto) single { WorkManager.getInstance(get()) } @@ -75,11 +76,13 @@ val appModule = module { factoryOf(::SecurityResetUseCase) factoryOf(::PinStrengthCheckUseCase) factoryOf(::VerifyPinUseCase) + factoryOf(::AuthorizePinUseCase) factoryOf(::CreatePinUseCase) factoryOf(::PinSizeUseCase) factoryOf(::RemovePoisonPillIUseCase) factoryOf(::MigratePinHash) factoryOf(::InvalidateSessionUseCase) + factoryOf(::AddDecoyPhotoUseCase) viewModelOf(::ObfuscatePhotoViewModel) viewModelOf(::ViewPhotoViewModel) diff --git a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/auth/AuthorizationRepository.kt b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/auth/AuthorizationRepository.kt index 579cd83..70788cc 100644 --- a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/auth/AuthorizationRepository.kt +++ b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/auth/AuthorizationRepository.kt @@ -3,7 +3,6 @@ package com.darkrockstudios.app.securecamera.auth import android.content.Context import com.darkrockstudios.app.securecamera.preferences.AppPreferencesDataSource import com.darkrockstudios.app.securecamera.preferences.HashedPin -import com.darkrockstudios.app.securecamera.security.pin.PinRepository import com.darkrockstudios.app.securecamera.security.schemes.EncryptionScheme import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -19,7 +18,6 @@ import kotlin.time.Instant */ class AuthorizationRepository( private val preferences: AppPreferencesDataSource, - private val pinRepository: PinRepository, private val encryptionScheme: EncryptionScheme, private val context: Context, private val clock: Clock, @@ -38,10 +36,6 @@ class AuthorizationRepository( preferences.securityFailureReset() } - suspend fun activatePoisonPill() { - pinRepository.activatePoisonPill() - } - /** * Gets the current number of failed PIN attempts * @return The number of failed attempts @@ -119,29 +113,11 @@ class AuthorizationRepository( return true } - /** - * Verifies the PIN and updates the authorization state if successful. - * @param pin The PIN entered by the user - * @return True if the PIN is correct, false otherwise - */ - suspend fun verifyPin(pin: String): HashedPin? { - val hashedPin = pinRepository.getHashedPin() - val isValid = pinRepository.verifySecurityPin(pin) - return if (isValid && hashedPin != null) { - authorizeSession() - // Reset failed attempts counter on successful verification - resetFailedAttempts() - hashedPin - } else { - null - } - } - /** * Marks the current session as authorized and updates the last authentication time. * Also starts the SessionService to monitor session validity. */ - private fun authorizeSession() { + fun authorizeSession() { lastAuthTimeMs = clock.now() _isAuthorized.value = true startSessionService() diff --git a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/camera/SecureImageRepository.kt b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/camera/SecureImageRepository.kt index 577b38e..98aa852 100644 --- a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/camera/SecureImageRepository.kt +++ b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/camera/SecureImageRepository.kt @@ -10,20 +10,17 @@ import com.ashampoo.kim.common.convertToPhotoMetadata import com.ashampoo.kim.model.GpsCoordinates import com.ashampoo.kim.model.MetadataUpdate import com.ashampoo.kim.model.TiffOrientation -import com.darkrockstudios.app.securecamera.security.pin.PinRepository import com.darkrockstudios.app.securecamera.security.schemes.EncryptionScheme import timber.log.Timber import java.io.ByteArrayOutputStream import java.io.File import java.text.SimpleDateFormat -import java.util.Date -import java.util.Locale +import java.util.* import kotlin.time.toJavaInstant class SecureImageRepository( private val appContext: Context, - private val pinRepository: PinRepository, internal val thumbnailCache: ThumbnailCache, private val encryptionScheme: EncryptionScheme, ) { @@ -438,18 +435,15 @@ class SecureImageRepository( fun numDecoys(): Int = getDecoyFiles().count() - suspend fun addDecoyPhoto(photoDef: PhotoDef): Boolean { + suspend fun addDecoyPhotoWithKey(photoDef: PhotoDef, keyBytes: ByteArray): Boolean { return if (numDecoys() < MAX_DECOY_PHOTOS) { val jpgBytes = decryptJpg(photoDef) getDecoyDirectory().mkdirs() val decoyFile = getDecoyFile(photoDef) - val ppp = pinRepository.getHashedPoisonPillPin() ?: return false - val pin = pinRepository.getPlainPoisonPillPin() ?: return false - val ppk = encryptionScheme.deriveKey(plainPin = pin, hashedPin = ppp) encryptionScheme.encryptToFile( plain = jpgBytes, - keyBytes = ppk, + keyBytes = keyBytes, targetFile = decoyFile ) diff --git a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/preferences/AppPreferencesDataSource.kt b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/preferences/AppPreferencesDataSource.kt index dbaed06..8be8bd5 100644 --- a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/preferences/AppPreferencesDataSource.kt +++ b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/preferences/AppPreferencesDataSource.kt @@ -70,13 +70,13 @@ class AppPreferencesDataSource( /** * Check if the user has completed the introduction */ - val hasCompletedIntro: Flow = context.dataStore.data + val hasCompletedIntro: Flow = dataStore.data .map { preferences -> preferences[HAS_COMPLETED_INTRO] ?: false } // DELETE ME after beta migration is over - val isProdReady: Flow = context.dataStore.data + val isProdReady: Flow = dataStore.data .map { preferences -> preferences[IS_PROD_READY] ?: false } diff --git a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/security/pin/PinCrypto.kt b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/security/pin/PinCrypto.kt new file mode 100644 index 0000000..87fa667 --- /dev/null +++ b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/security/pin/PinCrypto.kt @@ -0,0 +1,49 @@ +package com.darkrockstudios.app.securecamera.security.pin + +import com.darkrockstudios.app.securecamera.preferences.HashedPin +import com.darkrockstudios.app.securecamera.preferences.base64DecodeUrlSafe +import com.darkrockstudios.app.securecamera.preferences.base64EncodeUrlSafe +import com.lambdapioneer.argon2kt.Argon2Kt +import com.lambdapioneer.argon2kt.Argon2KtResult +import com.lambdapioneer.argon2kt.Argon2Mode +import dev.whyoleg.cryptography.random.CryptographyRandom + +/** + * Pure hashing/verification helper for PINs. No I/O, KMP‑friendly. + * Binds the hash to the provided deviceId bytes by concatenating to the PIN bytes. + */ +class PinCrypto( + private val argon2: Argon2Kt = Argon2Kt(), + private val iterations: Int = DEFAULT_ITERATIONS, + private val costKiB: Int = DEFAULT_COST_KIB, +) { + fun hashPin(pin: String, deviceId: ByteArray): HashedPin { + val salt = CryptographyRandom.nextBytes(16) + val password = pin.toByteArray() + deviceId + val result: Argon2KtResult = argon2.hash( + mode = Argon2Mode.ARGON2_I, + password = password, + salt = salt, + tCostInIterations = iterations, + mCostInKibibyte = costKiB, + ) + return HashedPin( + result.encodedOutputAsString().toByteArray().base64EncodeUrlSafe(), + salt.base64EncodeUrlSafe(), + ) + } + + fun verifyPin(pin: String, stored: HashedPin, deviceId: ByteArray): Boolean { + val password = pin.toByteArray() + deviceId + return argon2.verify( + mode = Argon2Mode.ARGON2_I, + encoded = String(stored.hash.base64DecodeUrlSafe()), + password = password, + ) + } + + companion object { + const val DEFAULT_ITERATIONS = 5 + const val DEFAULT_COST_KIB = 65536 + } +} diff --git a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/security/pin/PinRepositoryHardware.kt b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/security/pin/PinRepositoryHardware.kt index 8695941..27f2242 100644 --- a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/security/pin/PinRepositoryHardware.kt +++ b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/security/pin/PinRepositoryHardware.kt @@ -1,24 +1,20 @@ package com.darkrockstudios.app.securecamera.security.pin -import com.darkrockstudios.app.securecamera.preferences.* +import com.darkrockstudios.app.securecamera.preferences.AppPreferencesDataSource +import com.darkrockstudios.app.securecamera.preferences.HashedPin +import com.darkrockstudios.app.securecamera.preferences.base64Decode +import com.darkrockstudios.app.securecamera.preferences.base64Encode import com.darkrockstudios.app.securecamera.security.DeviceInfo import com.darkrockstudios.app.securecamera.security.SchemeConfig -import com.darkrockstudios.app.securecamera.security.pin.PinRepository.Companion.ARGON_COST -import com.darkrockstudios.app.securecamera.security.pin.PinRepository.Companion.ARGON_ITERATIONS import com.darkrockstudios.app.securecamera.security.schemes.EncryptionScheme -import com.lambdapioneer.argon2kt.Argon2Kt -import com.lambdapioneer.argon2kt.Argon2KtResult -import com.lambdapioneer.argon2kt.Argon2Mode -import dev.whyoleg.cryptography.random.CryptographyRandom -import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json class PinRepositoryHardware( private val dataSource: AppPreferencesDataSource, private val encryptionScheme: EncryptionScheme, private val deviceInfo: DeviceInfo, + private val pinCrypto: PinCrypto, ) : PinRepository { - private val argon2Kt = Argon2Kt() override suspend fun setAppPin( pin: String, @@ -41,32 +37,14 @@ class PinRepositoryHardware( } override suspend fun hashPin(pin: String): HashedPin { - val salt = CryptographyRandom.nextBytes(16) - val password = pin.toByteArray() + deviceInfo.getDeviceIdentifier() - val hashResult: Argon2KtResult = argon2Kt.hash( - mode = Argon2Mode.ARGON2_I, - password = password, - salt = salt, - tCostInIterations = ARGON_ITERATIONS, - mCostInKibibyte = ARGON_COST, - ) - - return HashedPin( - hashResult.encodedOutputAsString().toByteArray().base64EncodeUrlSafe(), - salt.base64EncodeUrlSafe() - ) + return pinCrypto.hashPin(pin, deviceInfo.getDeviceIdentifier()) } override suspend fun verifyPin( inputPin: String, storedHash: HashedPin ): Boolean { - val password = inputPin.toByteArray() + deviceInfo.getDeviceIdentifier() - return argon2Kt.verify( - mode = Argon2Mode.ARGON2_I, - encoded = String(storedHash.hash.base64DecodeUrlSafe()), - password = password, - ) + return pinCrypto.verifyPin(inputPin, storedHash, deviceInfo.getDeviceIdentifier()) } override suspend fun setPoisonPillPin(pin: String) { diff --git a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/security/pin/PinRepositorySoftware.kt b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/security/pin/PinRepositorySoftware.kt index d65ce9c..5d45eb5 100644 --- a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/security/pin/PinRepositorySoftware.kt +++ b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/security/pin/PinRepositorySoftware.kt @@ -1,30 +1,25 @@ package com.darkrockstudios.app.securecamera.security.pin -import com.darkrockstudios.app.securecamera.preferences.* +import com.darkrockstudios.app.securecamera.preferences.AppPreferencesDataSource +import com.darkrockstudios.app.securecamera.preferences.HashedPin +import com.darkrockstudios.app.securecamera.preferences.XorCipher import com.darkrockstudios.app.securecamera.security.DeviceInfo import com.darkrockstudios.app.securecamera.security.SchemeConfig -import com.darkrockstudios.app.securecamera.security.pin.PinRepository.Companion.ARGON_COST -import com.darkrockstudios.app.securecamera.security.pin.PinRepository.Companion.ARGON_ITERATIONS -import com.lambdapioneer.argon2kt.Argon2Kt -import com.lambdapioneer.argon2kt.Argon2KtResult -import com.lambdapioneer.argon2kt.Argon2Mode -import dev.whyoleg.cryptography.random.CryptographyRandom -import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import kotlin.io.encoding.ExperimentalEncodingApi class PinRepositorySoftware( private val dataSource: AppPreferencesDataSource, private val deviceInfo: DeviceInfo, + private val pinCrypto: PinCrypto, ) : PinRepository { - private val argon2Kt = Argon2Kt() override suspend fun setAppPin(pin: String, schemeConfig: SchemeConfig) { val hashedPin: HashedPin = hashPin(pin) val key = dataSource.getCipherKey() - val cipheredHash = XorCipher.encrypt(Json.Default.encodeToString(hashedPin), key) - val config = Json.Default.encodeToString(schemeConfig) + val cipheredHash = XorCipher.encrypt(Json.encodeToString(hashedPin), key) + val config = Json.encodeToString(schemeConfig) dataSource.setAppPin(cipheredHash, config) } @@ -36,30 +31,12 @@ class PinRepositorySoftware( @OptIn(ExperimentalStdlibApi::class) override suspend fun hashPin(pin: String): HashedPin { - val salt = CryptographyRandom.nextBytes(16) - val password = pin.toByteArray() + deviceInfo.getDeviceIdentifier() - val hashResult: Argon2KtResult = argon2Kt.hash( - mode = Argon2Mode.ARGON2_I, - password = password, - salt = salt, - tCostInIterations = ARGON_ITERATIONS, - mCostInKibibyte = ARGON_COST, - ) - - return HashedPin( - hashResult.encodedOutputAsString().toByteArray().base64EncodeUrlSafe(), - salt.base64EncodeUrlSafe() - ) + return pinCrypto.hashPin(pin, deviceInfo.getDeviceIdentifier()) } @OptIn(ExperimentalStdlibApi::class) override suspend fun verifyPin(inputPin: String, storedHash: HashedPin): Boolean { - val password = inputPin.toByteArray() + deviceInfo.getDeviceIdentifier() - return argon2Kt.verify( - mode = Argon2Mode.ARGON2_I, - encoded = String(storedHash.hash.base64DecodeUrlSafe()), - password = password, - ) + return pinCrypto.verifyPin(inputPin, storedHash, deviceInfo.getDeviceIdentifier()) } /** diff --git a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/usecases/AddDecoyPhotoUseCase.kt b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/usecases/AddDecoyPhotoUseCase.kt new file mode 100644 index 0000000..0d22555 --- /dev/null +++ b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/usecases/AddDecoyPhotoUseCase.kt @@ -0,0 +1,19 @@ +package com.darkrockstudios.app.securecamera.usecases + +import com.darkrockstudios.app.securecamera.camera.PhotoDef +import com.darkrockstudios.app.securecamera.camera.SecureImageRepository +import com.darkrockstudios.app.securecamera.security.pin.PinRepository +import com.darkrockstudios.app.securecamera.security.schemes.EncryptionScheme + +class AddDecoyPhotoUseCase( + private val pinRepository: PinRepository, + private val encryptionScheme: EncryptionScheme, + private val imageRepository: SecureImageRepository, +) { + suspend fun addDecoyPhoto(photoDef: PhotoDef): Boolean { + val ppp = pinRepository.getHashedPoisonPillPin() ?: return false + val plain = pinRepository.getPlainPoisonPillPin() ?: return false + val keyBytes = encryptionScheme.deriveKey(plainPin = plain, hashedPin = ppp) + return imageRepository.addDecoyPhotoWithKey(photoDef, keyBytes) + } +} diff --git a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/usecases/AuthorizePinUseCase.kt b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/usecases/AuthorizePinUseCase.kt new file mode 100644 index 0000000..2a1ddf2 --- /dev/null +++ b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/usecases/AuthorizePinUseCase.kt @@ -0,0 +1,28 @@ +package com.darkrockstudios.app.securecamera.usecases + +import com.darkrockstudios.app.securecamera.auth.AuthorizationRepository +import com.darkrockstudios.app.securecamera.preferences.HashedPin +import com.darkrockstudios.app.securecamera.security.pin.PinRepository + +class AuthorizePinUseCase( + private val authManager: AuthorizationRepository, + private val pinRepository: PinRepository, +) { + /** + * Authorizes user by verifying the PIN and updates the authorization state if successful. + * @param pin The PIN entered by the user + * @return True if the PIN is correct, false otherwise + */ + suspend fun authorizePin(pin: String): HashedPin? { + val hashedPin = pinRepository.getHashedPin() + val isValid = pinRepository.verifySecurityPin(pin) + return if (isValid && hashedPin != null) { + authManager.authorizeSession() + // Reset failed attempts counter on successful verification + authManager.resetFailedAttempts() + hashedPin + } else { + null + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/usecases/CreatePinUseCase.kt b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/usecases/CreatePinUseCase.kt index 60e4d2d..b845e4c 100644 --- a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/usecases/CreatePinUseCase.kt +++ b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/usecases/CreatePinUseCase.kt @@ -11,10 +11,11 @@ class CreatePinUseCase( private val encryptionScheme: EncryptionScheme, private val pinRepository: PinRepository, private val preferencesDataSource: AppPreferencesDataSource, + private val authorizePinUseCase: AuthorizePinUseCase ) { suspend fun createPin(pin: String, schemeConfig: SchemeConfig): Boolean { pinRepository.setAppPin(pin, schemeConfig) - val hashedPin = authorizationRepository.verifyPin(pin) + val hashedPin = authorizePinUseCase.authorizePin(pin) return if (hashedPin != null) { authorizationRepository.createKey(pin, hashedPin) encryptionScheme.deriveAndCacheKey(pin, hashedPin) diff --git a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/usecases/VerifyPinUseCase.kt b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/usecases/VerifyPinUseCase.kt index bf5c154..5670ae7 100644 --- a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/usecases/VerifyPinUseCase.kt +++ b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/usecases/VerifyPinUseCase.kt @@ -11,6 +11,7 @@ class VerifyPinUseCase( private val pinRepository: PinRepository, private val encryptionScheme: EncryptionScheme, private val migratePinHash: MigratePinHash, + private val authorizePinUseCase: AuthorizePinUseCase, ) { suspend fun verifyPin(pin: String): Boolean { migratePinHash.runMigration(pin) @@ -18,10 +19,10 @@ class VerifyPinUseCase( if (pinRepository.hasPoisonPillPin() && pinRepository.verifyPoisonPillPin(pin)) { encryptionScheme.activatePoisonPill(oldPin = pinRepository.getHashedPin()) imageManager.activatePoisonPill() - authManager.activatePoisonPill() + pinRepository.activatePoisonPill() } - val hashedPin = authManager.verifyPin(pin) + val hashedPin = authorizePinUseCase.authorizePin(pin) return if (hashedPin != null) { encryptionScheme.deriveAndCacheKey(pin, hashedPin) authManager.resetFailedAttempts() diff --git a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/viewphoto/ViewPhotoViewModel.kt b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/viewphoto/ViewPhotoViewModel.kt index 289839b..e77420b 100644 --- a/app/src/main/kotlin/com/darkrockstudios/app/securecamera/viewphoto/ViewPhotoViewModel.kt +++ b/app/src/main/kotlin/com/darkrockstudios/app/securecamera/viewphoto/ViewPhotoViewModel.kt @@ -11,6 +11,7 @@ import com.darkrockstudios.app.securecamera.camera.SecureImageRepository import com.darkrockstudios.app.securecamera.preferences.AppPreferencesDataSource import com.darkrockstudios.app.securecamera.security.pin.PinRepository import com.darkrockstudios.app.securecamera.share.sharePhotoWithProvider +import com.darkrockstudios.app.securecamera.usecases.AddDecoyPhotoUseCase import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch @@ -21,6 +22,7 @@ class ViewPhotoViewModel( private val imageManager: SecureImageRepository, private val preferencesManager: AppPreferencesDataSource, private val pinRepository: PinRepository, + private val addDecoyPhotoUseCase: AddDecoyPhotoUseCase, private val initialPhotoName: String, ) : BaseViewModel() { @@ -99,7 +101,7 @@ class ViewPhotoViewModel( showMessage(appContext.getString(R.string.decoy_removed)) } } else { - val success = imageManager.addDecoyPhoto(currentPhoto) + val success = addDecoyPhotoUseCase.addDecoyPhoto(currentPhoto) withContext(Dispatchers.Main) { _uiState.update { it.copy( diff --git a/app/src/test/kotlin/FakeDataStore.kt b/app/src/test/kotlin/FakeDataStore.kt new file mode 100644 index 0000000..a14e682 --- /dev/null +++ b/app/src/test/kotlin/FakeDataStore.kt @@ -0,0 +1,40 @@ +package testutil + +import androidx.datastore.core.DataStore +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +/** + * A simple, in-memory DataStore for tests. + * - Atomic updates guarded by a Mutex + * - Hot Flow that immediately emits the current value + * - No IO, no closing needed + */ +class FakeDataStore( + initial: T +) : DataStore { + + private val state = MutableStateFlow(initial) + private val mutex = Mutex() + + override val data: Flow = state + + override suspend fun updateData(transform: suspend (t: T) -> T): T { + // Matches DataStore semantics: if transform throws, value is unchanged. + return mutex.withLock { + val newValue = transform(state.value) + state.value = newValue + newValue + } + } + + /** Directly set the value (useful to seed state in tests). */ + suspend fun set(value: T) { + mutex.withLock { state.value = value } + } + + /** Read the current value synchronously. */ + fun currentValue(): T = state.value +} diff --git a/app/src/test/kotlin/com/darkrockstudios/app/securecamera/TestClock.kt b/app/src/test/kotlin/com/darkrockstudios/app/securecamera/TestClock.kt index a2eba83..481fe5c 100644 --- a/app/src/test/kotlin/com/darkrockstudios/app/securecamera/TestClock.kt +++ b/app/src/test/kotlin/com/darkrockstudios/app/securecamera/TestClock.kt @@ -1,8 +1,13 @@ package com.darkrockstudios.app.securecamera import kotlin.time.Clock +import kotlin.time.Duration import kotlin.time.Instant class TestClock(var fixedInstant: Instant) : Clock { override fun now(): Instant = fixedInstant + + fun advanceBy(duration: Duration) { + fixedInstant += duration + } } \ No newline at end of file diff --git a/app/src/test/kotlin/com/darkrockstudios/app/securecamera/auth/AuthorizationManagerTest.kt b/app/src/test/kotlin/com/darkrockstudios/app/securecamera/auth/AuthorizationManagerTest.kt index c4c6c52..e5b21b9 100644 --- a/app/src/test/kotlin/com/darkrockstudios/app/securecamera/auth/AuthorizationManagerTest.kt +++ b/app/src/test/kotlin/com/darkrockstudios/app/securecamera/auth/AuthorizationManagerTest.kt @@ -2,31 +2,30 @@ package com.darkrockstudios.app.securecamera.auth import android.content.Context import androidx.datastore.core.DataStore -import androidx.datastore.preferences.core.PreferenceDataStoreFactory import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.emptyPreferences import com.darkrockstudios.app.securecamera.TestClock import com.darkrockstudios.app.securecamera.preferences.AppPreferencesDataSource import com.darkrockstudios.app.securecamera.preferences.HashedPin import com.darkrockstudios.app.securecamera.security.SoftwareSchemeConfig import com.darkrockstudios.app.securecamera.security.pin.PinRepository import com.darkrockstudios.app.securecamera.security.schemes.EncryptionScheme +import com.darkrockstudios.app.securecamera.usecases.AuthorizePinUseCase import io.mockk.coEvery import io.mockk.coVerify import io.mockk.mockk import io.mockk.spyk import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.first -import kotlinx.coroutines.test.TestScope -import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.runTest -import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import org.junit.Assert.* import org.junit.Before import org.junit.Test -import java.io.File +import testutil.FakeDataStore import java.util.concurrent.TimeUnit import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds import kotlin.time.Instant @ExperimentalCoroutinesApi @@ -35,68 +34,29 @@ class AuthorizationManagerTest { private lateinit var context: Context private lateinit var preferencesManager: AppPreferencesDataSource private lateinit var authManager: AuthorizationRepository + private lateinit var authorizePinUseCase: AuthorizePinUseCase + private lateinit var pinRepository: PinRepository private lateinit var dataStore: DataStore private lateinit var encryptionManager: EncryptionScheme - private lateinit var pinRepository: PinRepository private lateinit var clock: TestClock private val configJson = Json.encodeToString(SoftwareSchemeConfig) - @OptIn(ExperimentalCoroutinesApi::class) - private val testScope = TestScope(UnconfinedTestDispatcher()) - @Before fun setup() { context = mockk(relaxed = true) - dataStore = PreferenceDataStoreFactory.create( - scope = testScope, - produceFile = { File.createTempFile("prefs_test", ".preferences_pb") } - ) + dataStore = FakeDataStore(emptyPreferences()) preferencesManager = spyk(AppPreferencesDataSource(context, dataStore)) encryptionManager = mockk(relaxed = true) - pinRepository = mockk() clock = TestClock(Instant.fromEpochSeconds(1)) - // Default mocks for PinRepository methods + authManager = AuthorizationRepository(preferencesManager, encryptionManager, context, clock) + + pinRepository = mockk() coEvery { pinRepository.getHashedPin() } returns HashedPin("hashed_pin", "salt") coEvery { pinRepository.verifySecurityPin(any()) } returns true - coEvery { pinRepository.activatePoisonPill() } returns Unit - coEvery { pinRepository.verifyPoisonPillPin(any()) } returns true - coEvery { pinRepository.hasPoisonPillPin() } returns true - authManager = AuthorizationRepository(preferencesManager, pinRepository, encryptionManager, context, clock) - } - - @Test - fun `verifyPin should update authorization state when PIN is valid`() = runTest { - // Given - val pin = "1234" - preferencesManager.setAppPin(pin, configJson) - - // When - val result = authManager.verifyPin(pin) - - // Then - assertNotNull(result) - assertTrue(authManager.isAuthorized.first()) - } - - @Test - fun `verifyPin should not update authorization state when PIN is invalid`() = runTest { - // Given - val correctPin = "1234" - val incorrectPin = "5678" - preferencesManager.setAppPin(correctPin, configJson) - - // Mock verifySecurityPin to return false for incorrect PIN - coEvery { pinRepository.verifySecurityPin(incorrectPin) } returns false - - // When - val result = authManager.verifyPin(incorrectPin) - - // Then - assertNull(result) - assertFalse(authManager.isAuthorized.first()) + authorizePinUseCase = AuthorizePinUseCase(authManager, pinRepository) } @Test @@ -117,7 +77,7 @@ class AuthorizationManagerTest { // Given val pin = "1234" preferencesManager.setAppPin(pin, configJson) - authManager.verifyPin(pin) + authorizePinUseCase.authorizePin(pin) // When val result = authManager.checkSessionValidity() @@ -133,15 +93,12 @@ class AuthorizationManagerTest { val pin = "1234" preferencesManager.setAppPin(pin, configJson) - coEvery { pinRepository.getHashedPin() } returns null - // Set a very small session timeout (1 millisecond) preferencesManager.setSessionTimeout(1L) - authManager.verifyPin(pin) + authorizePinUseCase.authorizePin(pin) - // Wait for the session to expire - Thread.sleep(10) + clock.advanceBy(1.seconds) // When val result = authManager.checkSessionValidity() @@ -156,7 +113,7 @@ class AuthorizationManagerTest { // Given val pin = "1234" preferencesManager.setAppPin(pin, configJson) - authManager.verifyPin(pin) + authorizePinUseCase.authorizePin(pin) assertTrue(authManager.isAuthorized.first()) // When @@ -175,7 +132,7 @@ class AuthorizationManagerTest { preferencesManager.setSessionTimeout(customTimeout) // When - authManager.verifyPin(pin) + authorizePinUseCase.authorizePin(pin) // Then assertTrue(authManager.checkSessionValidity()) @@ -241,22 +198,6 @@ class AuthorizationManagerTest { assertEquals(0L, preferencesManager.getLastFailedAttemptTimestamp()) } - @Test - fun `verifyPin should reset failed attempts when PIN is valid`() = runTest { - // Given - val pin = "1234" - preferencesManager.setAppPin(pin, configJson) - preferencesManager.setFailedPinAttempts(3) - preferencesManager.setLastFailedAttemptTimestamp(1000L) - - // When - val result = authManager.verifyPin(pin) - - // Then - assertNotNull(result) - assertEquals(0, preferencesManager.getFailedPinAttempts()) - assertEquals(0L, preferencesManager.getLastFailedAttemptTimestamp()) - } @Test fun `getLastFailedAttemptTimestamp should return the timestamp from preferences`() = runTest { @@ -320,15 +261,14 @@ class AuthorizationManagerTest { // Given val pin = "1234" - // Create a new spy for preferencesManager for this test - val spyPreferencesManager = spyk(AppPreferencesDataSource(context, dataStore)) + // Create a new AuthorizationRepository for this test val testAuthManager = - AuthorizationRepository(spyPreferencesManager, pinRepository, encryptionManager, context, clock) + AuthorizationRepository(preferencesManager, encryptionManager, context, clock) // Set up initial state - spyPreferencesManager.setAppPin(pin, configJson) - spyPreferencesManager.setFailedPinAttempts(5) - spyPreferencesManager.setLastFailedAttemptTimestamp(1000L) + preferencesManager.setAppPin(pin, configJson) + preferencesManager.setFailedPinAttempts(5) + preferencesManager.setLastFailedAttemptTimestamp(1000L) // Mock verifySecurityPin to return true initially, then false after reset coEvery { pinRepository.verifySecurityPin(pin) } returns true andThen false @@ -338,55 +278,9 @@ class AuthorizationManagerTest { // Then // Verify that securityFailureReset was called on the preferences manager - coVerify { spyPreferencesManager.securityFailureReset() } + coVerify { preferencesManager.securityFailureReset() } } - @Test - fun `activatePoisonPill should delegate to preferencesManager`() = runTest { - // Given - val regularPin = "1234" - val poisonPillPin = "5678" - val hashedPoisonPin = "hashed" - preferencesManager.setAppPin(regularPin, configJson) - preferencesManager.setPoisonPillPin(hashedPoisonPin, poisonPillPin) - - // Mock activatePoisonPill to change behavior - coEvery { pinRepository.activatePoisonPill() } answers { - // After activatePoisonPill is called, hasPoisonPillPin should return false - // and verifySecurityPin should return true for the poison pill pin - coEvery { pinRepository.hasPoisonPillPin() } returns false - coEvery { pinRepository.verifySecurityPin(poisonPillPin) } returns true - } - - // Verify initial state - assertTrue(pinRepository.verifySecurityPin(regularPin)) - assertTrue(pinRepository.verifyPoisonPillPin(poisonPillPin)) - assertTrue(pinRepository.hasPoisonPillPin()) - - // When - authManager.activatePoisonPill() - - // Then - // Verify poison pill was activated - assertTrue(pinRepository.verifySecurityPin(poisonPillPin)) - assertFalse(pinRepository.hasPoisonPillPin()) - } - - @Test - fun `verifyPin should not update authorization state when PIN is valid but hashedPin is null`() = runTest { - // Given - val pin = "1234" - coEvery { pinRepository.verifySecurityPin(pin) } returns true - coEvery { pinRepository.getHashedPin() } returns null - - // When - val result = authManager.verifyPin(pin) - - // Then - assertNull(result) - assertFalse(authManager.isAuthorized.first()) - coVerify { pinRepository.verifySecurityPin(pin) } - } @Test fun `calculateRemainingBackoffSeconds should return 0 when elapsed time exceeds backoff time`() = runTest { @@ -420,7 +314,7 @@ class AuthorizationManagerTest { clock.fixedInstant = initialTime // Authorize the session - authManager.verifyPin(pin) + authorizePinUseCase.authorizePin(pin) assertTrue(authManager.isAuthorized.first()) // Advance time by half the session timeout diff --git a/app/src/test/kotlin/com/darkrockstudios/app/securecamera/auth/AuthorizePinUseCaseTest.kt b/app/src/test/kotlin/com/darkrockstudios/app/securecamera/auth/AuthorizePinUseCaseTest.kt new file mode 100644 index 0000000..0619cdf --- /dev/null +++ b/app/src/test/kotlin/com/darkrockstudios/app/securecamera/auth/AuthorizePinUseCaseTest.kt @@ -0,0 +1,224 @@ +package testutil.com.darkrockstudios.app.securecamera.auth + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.emptyPreferences +import com.darkrockstudios.app.securecamera.TestClock +import com.darkrockstudios.app.securecamera.auth.AuthorizationRepository +import com.darkrockstudios.app.securecamera.preferences.AppPreferencesDataSource +import com.darkrockstudios.app.securecamera.preferences.HashedPin +import com.darkrockstudios.app.securecamera.security.SoftwareSchemeConfig +import com.darkrockstudios.app.securecamera.security.pin.PinRepository +import com.darkrockstudios.app.securecamera.security.schemes.EncryptionScheme +import com.darkrockstudios.app.securecamera.usecases.AuthorizePinUseCase +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import io.mockk.spyk +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.Json +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test +import testutil.FakeDataStore +import java.util.concurrent.TimeUnit +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Instant + +class AuthorizePinUseCaseTest { + + private lateinit var context: Context + private lateinit var preferencesManager: AppPreferencesDataSource + private lateinit var authorizePinUseCase: AuthorizePinUseCase + private lateinit var dataStore: DataStore + private lateinit var encryptionManager: EncryptionScheme + private lateinit var authManager: AuthorizationRepository + private lateinit var pinRepository: PinRepository + private lateinit var clock: TestClock + + private val configJson = Json.encodeToString(SoftwareSchemeConfig) + + @Before + fun setup() { + context = mockk(relaxed = true) + dataStore = FakeDataStore(emptyPreferences()) + preferencesManager = spyk(AppPreferencesDataSource(context, dataStore)) + encryptionManager = mockk(relaxed = true) + pinRepository = mockk() + clock = TestClock(Instant.fromEpochSeconds(1)) + + // Default mocks for PinRepository methods + coEvery { pinRepository.getHashedPin() } returns HashedPin("hashed_pin", "salt") + coEvery { pinRepository.verifySecurityPin(any()) } returns true + coEvery { pinRepository.activatePoisonPill() } returns Unit + coEvery { pinRepository.verifyPoisonPillPin(any()) } returns true + coEvery { pinRepository.hasPoisonPillPin() } returns true + + authManager = AuthorizationRepository(preferencesManager, encryptionManager, context, clock) + authorizePinUseCase = AuthorizePinUseCase(authManager, pinRepository) + } + + @Test + fun `verifyPin should update authorization state when PIN is valid`() = runTest { + // Given + val pin = "1234" + preferencesManager.setAppPin(pin, configJson) + + // When + val result = authorizePinUseCase.authorizePin(pin) + + // Then + assertNotNull(result) + assertTrue(authManager.isAuthorized.first()) + } + + @Test + fun `verifyPin should not update authorization state when PIN is invalid`() = runTest { + // Given + val correctPin = "1234" + val incorrectPin = "5678" + preferencesManager.setAppPin(correctPin, configJson) + + // Mock verifySecurityPin to return false for incorrect PIN + coEvery { pinRepository.verifySecurityPin(incorrectPin) } returns false + + // When + val result = authorizePinUseCase.authorizePin(incorrectPin) + + // Then + assertNull(result) + assertFalse(authManager.isAuthorized.first()) + } + + @Test + fun `checkSessionValidity should return false when session has expired`() = runTest { + // Given + val pin = "1234" + preferencesManager.setAppPin(pin, configJson) + + coEvery { pinRepository.getHashedPin() } returns null + + // Set a very small session timeout (1 millisecond) + preferencesManager.setSessionTimeout(1L) + + authorizePinUseCase.authorizePin(pin) + + // Wait for the session to expire + Thread.sleep(10) + + // When + val result = authManager.checkSessionValidity() + + // Then + assertFalse(result) + assertFalse(authManager.isAuthorized.value) + } + + @Test + fun `revokeAuthorization should reset authorization state`() = runTest { + // Given + val pin = "1234" + preferencesManager.setAppPin(pin, configJson) + authorizePinUseCase.authorizePin(pin) + assertTrue(authManager.isAuthorized.first()) + + // When + authManager.revokeAuthorization() + + // Then + assertFalse(authManager.isAuthorized.first()) + } + + @Test + fun `setSessionTimeout should update the timeout duration`() = runTest { + // Given + val pin = "1234" + val customTimeout = TimeUnit.SECONDS.toMillis(30) + preferencesManager.setAppPin(pin, configJson) + preferencesManager.setSessionTimeout(customTimeout) + + // When + authorizePinUseCase.authorizePin(pin) + + // Then + assertTrue(authManager.checkSessionValidity()) + + // Fast-forward time but less than the timeout + Thread.sleep(10) + assertTrue(authManager.checkSessionValidity()) + } + + @Test + fun `verifyPin should reset failed attempts when PIN is valid`() = runTest { + // Given + val pin = "1234" + preferencesManager.setAppPin(pin, configJson) + preferencesManager.setFailedPinAttempts(3) + preferencesManager.setLastFailedAttemptTimestamp(1000L) + + // When + val result = authorizePinUseCase.authorizePin(pin) + + // Then + assertNotNull(result) + assertEquals(0, preferencesManager.getFailedPinAttempts()) + assertEquals(0L, preferencesManager.getLastFailedAttemptTimestamp()) + } + + @Test + fun `verifyPin should not update authorization state when PIN is valid but hashedPin is null`() = runTest { + // Given + val pin = "1234" + coEvery { pinRepository.verifySecurityPin(pin) } returns true + coEvery { pinRepository.getHashedPin() } returns null + + // When + val result = authorizePinUseCase.authorizePin(pin) + + // Then + assertNull(result) + assertFalse(authManager.isAuthorized.first()) + coVerify { pinRepository.verifySecurityPin(pin) } + } + + @Test + fun `keepAliveSession should extend session validity`() = runTest { + // Given + val pin = "1234" + preferencesManager.setAppPin(pin, configJson) + + // Set a session timeout + val sessionTimeout = 1000L // 1 second + preferencesManager.setSessionTimeout(sessionTimeout) + + // Set initial time in the test clock + val initialTime = Instant.fromEpochMilliseconds(1000) + clock.fixedInstant = initialTime + + // Authorize the session + authorizePinUseCase.authorizePin(pin) + assertTrue(authManager.isAuthorized.first()) + + // Advance time by half the session timeout + clock.fixedInstant = initialTime.plus((sessionTimeout / 2).milliseconds) + + // Verify session is still valid + assertTrue(authManager.checkSessionValidity()) + + // Keep the session alive + authManager.keepAliveSession() + + // Advance time beyond the original session timeout + // but within the timeout of the keep-alive + clock.fixedInstant = initialTime.plus((sessionTimeout + 100).milliseconds) + + // When + val result = authManager.checkSessionValidity() + + // Then + assertTrue(result) + assertTrue(authManager.isAuthorized.first()) + } +} \ No newline at end of file diff --git a/app/src/test/kotlin/com/darkrockstudios/app/securecamera/imagemanager/SecureImageRepositoryTest.kt b/app/src/test/kotlin/com/darkrockstudios/app/securecamera/imagemanager/SecureImageRepositoryTest.kt index 8a4a2a0..55616e1 100644 --- a/app/src/test/kotlin/com/darkrockstudios/app/securecamera/imagemanager/SecureImageRepositoryTest.kt +++ b/app/src/test/kotlin/com/darkrockstudios/app/securecamera/imagemanager/SecureImageRepositoryTest.kt @@ -96,7 +96,6 @@ class SecureImageRepositoryTest { // Create the SecureImageManager with real dependencies secureImageRepository = SecureImageRepository( appContext = context, - pinRepository = pinRepository, thumbnailCache = thumbnailCache, encryptionScheme = encryptionScheme, ) @@ -531,7 +530,7 @@ class SecureImageRepositoryTest { coEvery { encryptionScheme.deriveKey(any(), any()) } returns ppk // When - val result = secureImageRepository.addDecoyPhoto(photoDef) + val result = secureImageRepository.addDecoyPhotoWithKey(photoDef, ByteArray(0x00)) // Then assertTrue(result) @@ -564,7 +563,7 @@ class SecureImageRepositoryTest { } // When - val result = secureImageRepository.addDecoyPhoto(photoDef) + val result = secureImageRepository.addDecoyPhotoWithKey(photoDef, ByteArray(0x00)) // Then assertFalse(result) diff --git a/app/src/test/kotlin/com/darkrockstudios/app/securecamera/preferences/AppPreferencesDataSourceTest.kt b/app/src/test/kotlin/com/darkrockstudios/app/securecamera/preferences/AppPreferencesDataSourceTest.kt new file mode 100644 index 0000000..b3b7096 --- /dev/null +++ b/app/src/test/kotlin/com/darkrockstudios/app/securecamera/preferences/AppPreferencesDataSourceTest.kt @@ -0,0 +1,154 @@ +package com.darkrockstudios.app.securecamera.preferences + +import android.content.Context +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.emptyPreferences +import com.darkrockstudios.app.securecamera.security.SchemeConfig +import com.darkrockstudios.app.securecamera.security.SoftwareSchemeConfig +import io.mockk.mockk +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.Json +import org.junit.Assert.* +import org.junit.Test +import testutil.FakeDataStore + +class AppPreferencesDataSourceTest { + private fun newSut(): AppPreferencesDataSource { + val context = mockk(relaxed = true) + val dataStore = FakeDataStore(emptyPreferences()) + return AppPreferencesDataSource(context = context, dataStore = dataStore) + } + + @Test + fun `getCipherKey generates and persists Base64 key`() = runTest { + val sut = newSut() + val key1 = sut.getCipherKey() + assertNotNull(key1) + // 128 bytes -> Base64 length 172 + assertEquals(172, key1.length) + val key2 = sut.getCipherKey() + assertEquals(key1, key2) + } + + @Test + fun `intro completed flow defaults false and can be updated`() = runTest { + val sut = newSut() + assertEquals(false, sut.hasCompletedIntro.first()) + sut.setIntroCompleted(true) + assertEquals(true, sut.hasCompletedIntro.first()) + sut.setIntroCompleted(false) + assertEquals(false, sut.hasCompletedIntro.first()) + } + + @Test + fun `isProdReady defaults false, markProdReady true`() = runTest { + val sut = newSut() + assertEquals(false, sut.isProdReady.first()) + sut.markProdReady() + assertEquals(true, sut.isProdReady.first()) + } + + @Test + fun `sanitize flags default to true and are settable`() = runTest { + val sut = newSut() + assertEquals(true, sut.sanitizeFileName.first()) + assertEquals(true, sut.sanitizeMetadata.first()) + sut.setSanitizeFileName(false) + sut.setSanitizeMetadata(false) + assertEquals(false, sut.sanitizeFileName.first()) + assertEquals(false, sut.sanitizeMetadata.first()) + } + + @Test + fun `session timeout default and set-get`() = runTest { + val sut = newSut() + assertEquals(AppPreferencesDataSource.SESSION_TIMEOUT_DEFAULT, sut.sessionTimeout.first()) + assertEquals(AppPreferencesDataSource.SESSION_TIMEOUT_DEFAULT, sut.getSessionTimeout()) + val newTimeout = AppPreferencesDataSource.SESSION_TIMEOUT_1_MIN + sut.setSessionTimeout(newTimeout) + assertEquals(newTimeout, sut.sessionTimeout.first()) + assertEquals(newTimeout, sut.getSessionTimeout()) + } + + @Test + fun `failed pin attempts default 0 and set-get`() = runTest { + val sut = newSut() + assertEquals(0, sut.getFailedPinAttempts()) + sut.setFailedPinAttempts(3) + assertEquals(3, sut.getFailedPinAttempts()) + } + + @Test + fun `last failed timestamp default 0 and set-get`() = runTest { + val sut = newSut() + assertEquals(0L, sut.getLastFailedAttemptTimestamp()) + sut.setLastFailedAttemptTimestamp(12345L) + assertEquals(12345L, sut.getLastFailedAttemptTimestamp()) + } + + @Test + fun `setAppPin stores ciphered pin, marks ciphered, and stores scheme config`() = runTest { + val sut = newSut() + val scheme: SchemeConfig = SoftwareSchemeConfig + val schemeJson = Json.encodeToString(SchemeConfig.serializer(), scheme) + sut.setAppPin(cipheredPin = "ciphered-abc", schemeConfigJson = schemeJson) + assertEquals("ciphered-abc", sut.getCipheredPin()) + assertTrue(sut.isPinCiphered()) + val decoded = sut.getSchemeConfig() + assertTrue(decoded is SoftwareSchemeConfig) + } + + @Test + fun `poison pill set-get-activate-remove`() = runTest { + val sut = newSut() + // Initially none + assertNull(sut.getPlainPoisonPillPin()) + assertNull(sut.getHashedPoisonPillPin()) + // Set + sut.setPoisonPillPin(cipheredHashedPin = "hash-x", cipheredPlainPin = "plain-y") + assertEquals("plain-y", sut.getPlainPoisonPillPin()) + assertEquals("hash-x", sut.getHashedPoisonPillPin()) + // Activate replaces APP_PIN and removes poison values + sut.activatePoisonPill(ciphered = "activated") + assertEquals("activated", sut.getCipheredPin()) + assertNull(sut.getPlainPoisonPillPin()) + assertNull(sut.getHashedPoisonPillPin()) + // Explicit remove after setting again + sut.setPoisonPillPin(cipheredHashedPin = "h2", cipheredPlainPin = "p2") + sut.removePoisonPillPin() + assertNull(sut.getPlainPoisonPillPin()) + assertNull(sut.getHashedPoisonPillPin()) + } + + @Test + fun `securityFailureReset clears data but sets prod ready true`() = runTest { + val sut = newSut() + // Seed some values + sut.setIntroCompleted(true) + sut.setSanitizeFileName(false) + sut.setSanitizeMetadata(false) + sut.setFailedPinAttempts(7) + sut.setLastFailedAttemptTimestamp(55L) + sut.setSessionTimeout(AppPreferencesDataSource.SESSION_TIMEOUT_10_MIN) + val schemeJson = Json.encodeToString(SchemeConfig.serializer(), SoftwareSchemeConfig) + sut.setAppPin("pinX", schemeJson) + sut.setPoisonPillPin("h", "p") + + // Reset + sut.securityFailureReset() + + // Verify defaults/cleared + assertEquals(false, sut.hasCompletedIntro.first()) + assertEquals(true, sut.isProdReady.first()) + assertEquals(true, sut.sanitizeFileName.first()) + assertEquals(true, sut.sanitizeMetadata.first()) + assertEquals(0, sut.getFailedPinAttempts()) + assertEquals(0L, sut.getLastFailedAttemptTimestamp()) + assertEquals(AppPreferencesDataSource.SESSION_TIMEOUT_DEFAULT, sut.sessionTimeout.first()) + assertNull(sut.getCipheredPin()) + assertFalse(sut.isPinCiphered()) + assertNull(sut.getPlainPoisonPillPin()) + assertNull(sut.getHashedPoisonPillPin()) + } +} diff --git a/app/src/test/kotlin/com/darkrockstudios/app/securecamera/preferences/AppPreferencesManagerTest.kt b/app/src/test/kotlin/com/darkrockstudios/app/securecamera/preferences/AppPreferencesManagerTest.kt deleted file mode 100644 index 9fdf07b..0000000 --- a/app/src/test/kotlin/com/darkrockstudios/app/securecamera/preferences/AppPreferencesManagerTest.kt +++ /dev/null @@ -1,369 +0,0 @@ -package com.darkrockstudios.app.securecamera.preferences - -import android.content.Context -import androidx.datastore.core.DataStore -import androidx.datastore.preferences.core.PreferenceDataStoreFactory -import androidx.datastore.preferences.core.Preferences -import io.mockk.mockk -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.test.TestScope -import kotlinx.coroutines.test.UnconfinedTestDispatcher -import kotlinx.coroutines.test.runTest -import org.junit.Assert -import org.junit.Before -import org.junit.Test -import java.io.File - -class AppPreferencesManagerTest { - - private lateinit var context: Context - private lateinit var preferencesManager: AppPreferencesDataSource - - @OptIn(ExperimentalCoroutinesApi::class) - private val testScope = TestScope(UnconfinedTestDispatcher()) - - private lateinit var dataStore: DataStore - - @Before - fun setup() { - context = mockk(relaxed = true) - dataStore = PreferenceDataStoreFactory.create( - scope = testScope, - produceFile = { File.createTempFile("prefs_test", ".preferences_pb") } - ) - preferencesManager = AppPreferencesDataSource(context, dataStore) - } - -// @Test -// fun `hashPin generates salt and hash`() = runTest { -// // Given -// val pin = "1234" -// -// // When -// val hashedPin = preferencesManager.hashPin(pin) -// -// // Then -// Assert.assertNotNull("Salt should not be null", hashedPin.salt) -// Assert.assertNotNull("Hash should not be null", hashedPin.hash) -// Assert.assertTrue("Salt should not be empty", hashedPin.salt.isNotEmpty()) -// Assert.assertTrue("Hash should not be empty", hashedPin.hash.isNotEmpty()) -// } -// -// @Test -// fun `hashPin generates different hashes for same PIN`() = runTest { -// // Given -// val pin = "1234" -// -// // When -// val hashedPin1 = preferencesManager.hashPin(pin) -// val hashedPin2 = preferencesManager.hashPin(pin) -// -// // Then -// Assert.assertNotEquals("Salts should be different", hashedPin1.salt, hashedPin2.salt) -// Assert.assertNotEquals("Hashes should be different", hashedPin1.hash, hashedPin2.hash) -// } -// -// @Test -// fun `verifyPin returns true for correct PIN`() = runTest { -// // Given -// val pin = "1234" -// val hashedPin = preferencesManager.hashPin(pin) -// -// // When -// val result = preferencesManager.verifyPin(pin, hashedPin) -// -// // Then -// Assert.assertTrue("Verification should succeed for correct PIN", result) -// } -// -// @Test -// fun `verifyPin returns false for incorrect PIN`() = runTest { -// // Given -// val correctPin = "1234" -// val incorrectPin = "5678" -// val hashedPin = preferencesManager.hashPin(correctPin) -// -// // When -// val result = preferencesManager.verifyPin(incorrectPin, hashedPin) -// -// // Then -// Assert.assertFalse("Verification should fail for incorrect PIN", result) -// } -// -// @Test -// fun `verifyPin handles empty PIN`() = runTest { -// // Given -// val correctPin = "1234" -// val emptyPin = "" -// val hashedPin = preferencesManager.hashPin(correctPin) -// -// // When -// val result = preferencesManager.verifyPin(emptyPin, hashedPin) -// -// // Then -// Assert.assertFalse("Verification should fail for empty PIN", result) -// } -// -// @Test -// fun `verifySecurityPin returns false when no PIN is stored`() = runTest { -// // Given -// // No PIN is stored in the dataStore by default -// -// // When -// val result = preferencesManager.verifySecurityPin("1234") -// -// // Then -// Assert.assertFalse("Should return false when no PIN is stored", result) -// } -// -// @Test -// fun `verifySecurityPin returns true for correct PIN`() = runTest { -// // Given -// val pin = "1234" -// preferencesManager.setAppPin(pin, SoftwareSchemeConfig) -// -// // When -// val result = preferencesManager.verifySecurityPin(pin) -// -// // Then -// Assert.assertTrue("Should return true for correct PIN", result) -// } -// -// @Test -// fun `verifySecurityPin returns false for incorrect PIN`() = runTest { -// // Given -// val correctPin = "1234" -// val incorrectPin = "5678" -// preferencesManager.setAppPin(correctPin, SoftwareSchemeConfig) -// -// // When -// val result = preferencesManager.verifySecurityPin(incorrectPin) -// -// // Then -// Assert.assertFalse("Should return false for incorrect PIN", result) -// } -// -// @Test -// fun `verifyPoisonPillPin returns false when no poison pill PIN is stored`() = runTest { -// // Given -// // No poison pill PIN is stored in the dataStore by default -// -// // When -// val result = preferencesManager.verifyPoisonPillPin("5678") -// -// // Then -// Assert.assertFalse("Should return false when no poison pill PIN is stored", result) -// } -// -// @Test -// fun `verifyPoisonPillPin returns true for correct PIN`() = runTest { -// // Given -// val pin = "5678" -// preferencesManager.setPoisonPillPin(pin) -// -// // When -// val result = preferencesManager.verifyPoisonPillPin(pin) -// -// // Then -// Assert.assertTrue("Should return true for correct PIN", result) -// } -// -// @Test -// fun `verifyPoisonPillPin returns false for incorrect PIN`() = runTest { -// // Given -// val correctPin = "5678" -// val incorrectPin = "1234" -// preferencesManager.setPoisonPillPin(correctPin) -// -// // When -// val result = preferencesManager.verifyPoisonPillPin(incorrectPin) -// -// // Then -// Assert.assertFalse("Should return false for incorrect PIN", result) -// } -// -// @Test -// fun `hasPoisonPillPin returns false when no poison pill PIN is stored`() = runTest { -// // Given -// // No poison pill PIN is stored in the dataStore by default -// -// // When -// val result = preferencesManager.hasPoisonPillPin() -// -// // Then -// Assert.assertFalse("Should return false when no poison pill PIN is stored", result) -// } -// -// @Test -// fun `hasPoisonPillPin returns true when poison pill PIN is stored`() = runTest { -// // Given -// preferencesManager.setPoisonPillPin("5678") -// -// // When -// val result = preferencesManager.hasPoisonPillPin() -// -// // Then -// Assert.assertTrue("Should return true when poison pill PIN is stored", result) -// } -// -// @Test -// fun `activatePoisonPill does nothing when no poison pill PIN is stored`() = runTest { -// // Given -// // No poison pill PIN is stored in the dataStore by default -// -// // When -// try { -// preferencesManager.activatePoisonPill(ciphered) -// assertTrue(false, "activatePoisonPill should have thrown an exception") -// } catch (e: Exception) { -// assertTrue(true) -// } -// -// // Then -// // No exception should be thrown -// } -// -// @Test -// fun `activatePoisonPill handles when poison pill PIN is stored`() = runTest { -// // Given -// val pin = "5678" -// preferencesManager.setPoisonPillPin(pin) -// -// // Verify poison pill PIN is set -// Assert.assertTrue(preferencesManager.hasPoisonPillPin()) -// -// // When -// preferencesManager.activatePoisonPill(ciphered) -// -// // Then -// // Poison pill PIN should be removed -// Assert.assertFalse(preferencesManager.hasPoisonPillPin()) -// -// // And the regular PIN should now be set to the poison pill PIN -// Assert.assertTrue(preferencesManager.verifySecurityPin(pin)) -// } - - @Test - fun `getSessionTimeout returns default when no timeout is stored`() = runTest { - // Given - // No session timeout is stored in the dataStore by default - - // When - val result = preferencesManager.getSessionTimeout() - - // Then - Assert.assertEquals( - "Should return default when no timeout is stored", - AppPreferencesDataSource.SESSION_TIMEOUT_DEFAULT, - result - ) - } - - @Test - fun `getFailedPinAttempts returns 0 when no count is stored`() = runTest { - // Given - // No failed pin attempts count is stored in the dataStore by default - - // When - val result = preferencesManager.getFailedPinAttempts() - - // Then - Assert.assertEquals("Should return 0 when no count is stored", 0, result) - } - - @Test - fun `getFailedPinAttempts returns stored value when count is set`() = runTest { - // Given - val count = 5 - preferencesManager.setFailedPinAttempts(count) - - // When - val result = preferencesManager.getFailedPinAttempts() - - // Then - Assert.assertEquals("Should return the stored count value", count, result) - } - - @Test - fun `getLastFailedAttemptTimestamp returns 0 when no timestamp is stored`() = runTest { - // Given - // No last failed attempt timestamp is stored in the dataStore by default - - // When - val result = preferencesManager.getLastFailedAttemptTimestamp() - - // Then - Assert.assertEquals("Should return 0 when no timestamp is stored", 0L, result) - } - - @Test - fun `getLastFailedAttemptTimestamp returns stored value when timestamp is set`() = runTest { - // Given - val timestamp = 1234567890L - preferencesManager.setLastFailedAttemptTimestamp(timestamp) - - // When - val result = preferencesManager.getLastFailedAttemptTimestamp() - - // Then - Assert.assertEquals("Should return the stored timestamp value", timestamp, result) - } - -// @Test -// fun `securityFailureReset clears all preferences`() = runTest { -// // Given -// // Set some preferences -// preferencesManager.setAppPin("1234", SoftwareSchemeConfig) -// preferencesManager.setPoisonPillPin("5678") -// preferencesManager.setSessionTimeout(AppPreferencesDataSource.SESSION_TIMEOUT_10_MIN) -// preferencesManager.setFailedPinAttempts(5) -// preferencesManager.setLastFailedAttemptTimestamp(1000L) -// -// // Verify preferences are set -// Assert.assertTrue(preferencesManager.verifySecurityPin("1234")) -// Assert.assertTrue(preferencesManager.hasPoisonPillPin()) -// Assert.assertEquals(AppPreferencesDataSource.SESSION_TIMEOUT_10_MIN, preferencesManager.getSessionTimeout()) -// Assert.assertEquals(5, preferencesManager.getFailedPinAttempts()) -// Assert.assertEquals(1000L, preferencesManager.getLastFailedAttemptTimestamp()) -// -// // When -// preferencesManager.securityFailureReset() -// -// // Then -// // Verify all preferences are cleared -// Assert.assertFalse(preferencesManager.verifySecurityPin("1234")) -// Assert.assertFalse(preferencesManager.hasPoisonPillPin()) -// Assert.assertEquals(AppPreferencesDataSource.SESSION_TIMEOUT_DEFAULT, preferencesManager.getSessionTimeout()) -// Assert.assertEquals(0, preferencesManager.getFailedPinAttempts()) -// Assert.assertEquals(0L, preferencesManager.getLastFailedAttemptTimestamp()) -// } - - @Test - fun `sanitizeFileName returns default value when not set`() = runTest { - // Given - val defaultValue = preferencesManager.sanitizeFileNameDefault - - // When - val flow = preferencesManager.sanitizeFileName - - // Then - // The flow should emit the default value - // This is testing the property initialization with the default value - Assert.assertEquals(defaultValue, flow.first()) - } - - @Test - fun `sanitizeMetadata returns default value when not set`() = runTest { - // Given - val defaultValue = preferencesManager.sanitizeMetadataDefault - - // When - val flow = preferencesManager.sanitizeMetadata - - // Then - // The flow should emit the default value - // This is testing the property initialization with the default value - Assert.assertEquals(defaultValue, flow.first()) - } -} diff --git a/app/src/test/kotlin/com/darkrockstudios/app/securecamera/security/pin/PinCryptoTest.kt b/app/src/test/kotlin/com/darkrockstudios/app/securecamera/security/pin/PinCryptoTest.kt new file mode 100644 index 0000000..a63e13f --- /dev/null +++ b/app/src/test/kotlin/com/darkrockstudios/app/securecamera/security/pin/PinCryptoTest.kt @@ -0,0 +1,131 @@ +package com.darkrockstudios.app.securecamera.security.pin + +import com.darkrockstudios.app.securecamera.preferences.HashedPin +import com.darkrockstudios.app.securecamera.security.pin.PinCrypto.Companion.DEFAULT_COST_KIB +import com.darkrockstudios.app.securecamera.security.pin.PinCrypto.Companion.DEFAULT_ITERATIONS +import com.lambdapioneer.argon2kt.Argon2Kt +import com.lambdapioneer.argon2kt.Argon2KtResult +import com.lambdapioneer.argon2kt.Argon2Mode +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.Assert +import org.junit.Before +import org.junit.Test +import java.util.* + +class PinCryptoTest { + private val deviceId: ByteArray = "test-device-id-123".encodeToByteArray() + private lateinit var argon2: Argon2Kt + private lateinit var crypto: PinCrypto + + private fun ByteArray.b64(): String = Base64.getEncoder().encodeToString(this) + + @Before + fun setup() { + argon2 = mockk() + + // Stub Argon2 hash: embed password and salt into a fake encoded string so we can verify later + every { + argon2.hash( + mode = any(), + password = any(), + salt = any(), + tCostInIterations = DEFAULT_ITERATIONS, + mCostInKibibyte = DEFAULT_COST_KIB, + ) + } answers { + val password = secondArg() + val salt = thirdArg() + val encoded = listOf("mocked", password.b64(), salt.b64()).joinToString("$") + val result = mockk() + every { result.encodedOutputAsString() } returns encoded + result + } + + crypto = PinCrypto(argon2 = argon2) + } + + @Test + fun `hashPin generates salt and hash`() = runTest { + val pin = "1234" + + val hashedPin: HashedPin = crypto.hashPin(pin, deviceId) + + Assert.assertNotNull("Salt should not be null", hashedPin.salt) + Assert.assertNotNull("Hash should not be null", hashedPin.hash) + Assert.assertTrue("Salt should not be empty", hashedPin.salt.isNotEmpty()) + Assert.assertTrue("Hash should not be empty", hashedPin.hash.isNotEmpty()) + } + + @Test + fun `hashPin generates different hashes for same PIN`() = runTest { + val pin = "1234" + + val hashedPin1 = crypto.hashPin(pin, deviceId) + val hashedPin2 = crypto.hashPin(pin, deviceId) + + Assert.assertNotEquals("Salts should be different", hashedPin1.salt, hashedPin2.salt) + Assert.assertNotEquals("Hashes should be different", hashedPin1.hash, hashedPin2.hash) + } + + @Test + fun `verifyPin returns true for correct PIN`() = runTest { + val pin = "1234" + val hashedPin = crypto.hashPin(pin, deviceId) + + val want = pin.toByteArray() + deviceId + + every { + argon2.verify( + mode = Argon2Mode.ARGON2_I, + encoded = any(), + password = match { it.contentEquals(want) } + ) + } returns true + + val result = crypto.verifyPin(pin, hashedPin, deviceId) + + Assert.assertTrue("Verification should succeed for correct PIN", result) + } + + @Test + fun `verifyPin returns false for incorrect PIN`() = runTest { + val correctPin = "1234" + val incorrectPin = "5678" + + every { + argon2.verify( + mode = Argon2Mode.ARGON2_I, + encoded = any(), + password = any() + ) + } returns false + + val hashedPin = crypto.hashPin(correctPin, deviceId) + + val result = crypto.verifyPin(incorrectPin, hashedPin, deviceId) + + Assert.assertFalse("Verification should fail for incorrect PIN", result) + } + + @Test + fun `verifyPin handles empty PIN`() = runTest { + val correctPin = "1234" + val emptyPin = "" + + every { + argon2.verify( + mode = Argon2Mode.ARGON2_I, + encoded = any(), + password = any() + ) + } returns false + + val hashedPin = crypto.hashPin(correctPin, deviceId) + + val result = crypto.verifyPin(emptyPin, hashedPin, deviceId) + + Assert.assertFalse("Verification should fail for empty PIN", result) + } +} diff --git a/app/src/test/kotlin/com/darkrockstudios/app/securecamera/security/pin/PinRepositorySoftwareTest.kt b/app/src/test/kotlin/com/darkrockstudios/app/securecamera/security/pin/PinRepositorySoftwareTest.kt new file mode 100644 index 0000000..af88a3d --- /dev/null +++ b/app/src/test/kotlin/com/darkrockstudios/app/securecamera/security/pin/PinRepositorySoftwareTest.kt @@ -0,0 +1,236 @@ +package com.darkrockstudios.app.securecamera.security.pin + +import com.darkrockstudios.app.securecamera.preferences.AppPreferencesDataSource +import com.darkrockstudios.app.securecamera.preferences.HashedPin +import com.darkrockstudios.app.securecamera.preferences.XorCipher +import com.darkrockstudios.app.securecamera.security.DeviceInfo +import com.darkrockstudios.app.securecamera.security.SchemeConfig +import com.darkrockstudios.app.securecamera.security.SoftwareSchemeConfig +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.Json +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class PinRepositorySoftwareTest { + private lateinit var dataSource: AppPreferencesDataSource + private lateinit var deviceInfo: DeviceInfo + private lateinit var pinCrypto: PinCrypto + private lateinit var repo: PinRepositorySoftware + + private val deviceId = "device-id-123".toByteArray() + private val cipherKey = "c1ph3r-k3y" + + @Before + fun setup() { + dataSource = mockk(relaxed = true) + deviceInfo = mockk() + pinCrypto = mockk() + + coEvery { deviceInfo.getDeviceIdentifier() } returns deviceId + coEvery { dataSource.getCipherKey() } returns cipherKey + + repo = PinRepositorySoftware(dataSource, deviceInfo, pinCrypto) + } + + @Test + fun `setAppPin should hash and store ciphered pin and config`() = runTest { + val pin = "1234" + val hashed = HashedPin(hash = "hash123", salt = "salt123") + coEvery { pinCrypto.hashPin(pin, deviceId) } returns hashed + + val config: SchemeConfig = SoftwareSchemeConfig + val configJson = Json.encodeToString(config) + val expectedCiphered = XorCipher.encrypt(Json.encodeToString(hashed), cipherKey) + + repo.setAppPin(pin, SoftwareSchemeConfig) + + coVerify { dataSource.setAppPin(expectedCiphered, configJson) } + } + + @Test + fun `getHashedPin should decrypt and return stored pin`() = runTest { + val stored = HashedPin("h", "s") + val storedJson = Json.encodeToString(stored) + val ciphered = XorCipher.encrypt(storedJson, cipherKey) + coEvery { dataSource.getCipheredPin() } returns ciphered + + val result = repo.getHashedPin() + + assertNotNull(result) + assertEquals(stored.hash, result!!.hash) + assertEquals(stored.salt, result.salt) + } + + @Test + fun `getHashedPin returns null when none stored`() = runTest { + coEvery { dataSource.getCipheredPin() } returns null + val result = repo.getHashedPin() + assertNull(result) + } + + @Test + fun `hashPin delegates to PinCrypto with device id`() = runTest { + val pin = "9999" + val hashed = HashedPin("hh", "ss") + coEvery { pinCrypto.hashPin(pin, deviceId) } returns hashed + + val result = repo.hashPin(pin) + + assertEquals(hashed, result) + } + + @Test + fun `verifyPin delegates to PinCrypto with device id`() = runTest { + val input = "1111" + val stored = HashedPin("h1", "s1") + coEvery { pinCrypto.verifyPin(input, stored, deviceId) } returns true + + assertTrue(repo.verifyPin(input, stored)) + } + + @Test + fun `verifySecurityPin uses getHashedPin and verifyPin`() = runTest { + val input = "2222" + val stored = HashedPin("h2", "s2") + coEvery { dataSource.getCipheredPin() } returns XorCipher.encrypt(Json.encodeToString(stored), cipherKey) + coEvery { pinCrypto.verifyPin(input, stored, deviceId) } returns true + + assertTrue(repo.verifySecurityPin(input)) + } + + @Test + fun `verifySecurityPin returns false when no stored pin`() = runTest { + coEvery { dataSource.getCipheredPin() } returns null + assertFalse(repo.verifySecurityPin("0000")) + } + + @Test + fun `setPoisonPillPin stores ciphered hashed and plain`() = runTest { + val ppp = "5678" + val hashed = HashedPin("ph", "ps") + coEvery { pinCrypto.hashPin(ppp, deviceId) } returns hashed + + val expectedHashed = XorCipher.encrypt(Json.encodeToString(hashed), cipherKey) + val expectedPlain = XorCipher.encrypt(ppp, cipherKey) + + repo.setPoisonPillPin(ppp) + + coVerify { dataSource.setPoisonPillPin(expectedHashed, expectedPlain) } + } + + @Test + fun `getPlainPoisonPillPin decrypts plain PPP`() = runTest { + val ppp = "7777" + val ciphered = XorCipher.encrypt(ppp, cipherKey) + coEvery { dataSource.getPlainPoisonPillPin() } returns ciphered + + val result = repo.getPlainPoisonPillPin() + assertEquals(ppp, result) + } + + @Test + fun `getPlainPoisonPillPin returns null when not set`() = runTest { + coEvery { dataSource.getPlainPoisonPillPin() } returns null + assertNull(repo.getPlainPoisonPillPin()) + } + + @Test + fun `getHashedPoisonPillPin decrypts and decodes`() = runTest { + val stored = HashedPin("h3", "s3") + val ciphered = XorCipher.encrypt(Json.encodeToString(stored), cipherKey) + coEvery { dataSource.getHashedPoisonPillPin() } returns ciphered + + val result = repo.getHashedPoisonPillPin() + assertNotNull(result) + assertEquals(stored, result) + } + + @Test + fun `hasPoisonPillPin true only when both main and PPP exist`() = runTest { + // Main pin present + coEvery { dataSource.getCipheredPin() } returns XorCipher.encrypt( + Json.encodeToString(HashedPin("mh", "ms")), + cipherKey + ) + // PPP present + coEvery { dataSource.getHashedPoisonPillPin() } returns XorCipher.encrypt( + Json.encodeToString( + HashedPin( + "ph", + "ps" + ) + ), cipherKey + ) + + assertTrue(repo.hasPoisonPillPin()) + } + + @Test + fun `hasPoisonPillPin false when one missing`() = runTest { + // Main pin present, PPP missing + coEvery { dataSource.getCipheredPin() } returns XorCipher.encrypt( + Json.encodeToString(HashedPin("mh", "ms")), + cipherKey + ) + coEvery { dataSource.getHashedPoisonPillPin() } returns null + assertFalse(repo.hasPoisonPillPin()) + + // Main pin missing, PPP present + coEvery { dataSource.getCipheredPin() } returns null + coEvery { dataSource.getHashedPoisonPillPin() } returns XorCipher.encrypt( + Json.encodeToString( + HashedPin( + "ph", + "ps" + ) + ), cipherKey + ) + assertFalse(repo.hasPoisonPillPin()) + } + + @Test + fun `verifyPoisonPillPin delegates to verifyPin`() = runTest { + val input = "9898" + val stored = HashedPin("h9", "s9") + coEvery { dataSource.getHashedPoisonPillPin() } returns XorCipher.encrypt( + Json.encodeToString(stored), + cipherKey + ) + coEvery { pinCrypto.verifyPin(input, stored, deviceId) } returns true + + assertTrue(repo.verifyPoisonPillPin(input)) + } + + @Test + fun `activatePoisonPill moves PPP to main and removes PPP`() = runTest { + val ppp = HashedPin("ph1", "ps1") + val pppJson = Json.encodeToString(ppp) + val cipheredPPP = XorCipher.encrypt(pppJson, cipherKey) + coEvery { dataSource.getHashedPoisonPillPin() } returns cipheredPPP + + val expectedMain = XorCipher.encrypt(pppJson, cipherKey) + + repo.activatePoisonPill() + + coVerify { dataSource.activatePoisonPill(expectedMain) } + coVerify { dataSource.removePoisonPillPin() } + } + + @Test(expected = IllegalStateException::class) + fun `activatePoisonPill throws when no PPP`() = runTest { + coEvery { dataSource.getHashedPoisonPillPin() } returns null + repo.activatePoisonPill() + } + + @Test + fun `removePoisonPillPin delegates to data source`() = runTest { + repo.removePoisonPillPin() + coVerify { dataSource.removePoisonPillPin() } + } +} diff --git a/app/src/test/kotlin/com/darkrockstudios/app/securecamera/usecases/VerifyPinUseCaseTest.kt b/app/src/test/kotlin/com/darkrockstudios/app/securecamera/usecases/VerifyPinUseCaseTest.kt index 459e4b5..b668151 100644 --- a/app/src/test/kotlin/com/darkrockstudios/app/securecamera/usecases/VerifyPinUseCaseTest.kt +++ b/app/src/test/kotlin/com/darkrockstudios/app/securecamera/usecases/VerifyPinUseCaseTest.kt @@ -4,11 +4,7 @@ import com.darkrockstudios.app.securecamera.auth.AuthorizationRepository import com.darkrockstudios.app.securecamera.camera.SecureImageRepository import com.darkrockstudios.app.securecamera.security.pin.PinRepository import com.darkrockstudios.app.securecamera.security.schemes.EncryptionScheme -import io.mockk.Runs -import io.mockk.coEvery -import io.mockk.coVerify -import io.mockk.just -import io.mockk.mockk +import io.mockk.* import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.runTest import org.junit.Assert.assertFalse @@ -25,6 +21,7 @@ class VerifyPinUseCaseTest { private lateinit var verifyPinUseCase: VerifyPinUseCase private lateinit var encryptionScheme: EncryptionScheme private lateinit var migratePinHash: MigratePinHash + private lateinit var authorizePinUseCase: AuthorizePinUseCase @Before fun setup() { @@ -32,6 +29,7 @@ class VerifyPinUseCaseTest { imageManager = mockk() pinRepository = mockk() migratePinHash = mockk() + authorizePinUseCase = mockk() encryptionScheme = mockk(relaxed = true) verifyPinUseCase = VerifyPinUseCase( authManager = authManager, @@ -39,6 +37,7 @@ class VerifyPinUseCaseTest { pinRepository = pinRepository, encryptionScheme = encryptionScheme, migratePinHash = migratePinHash, + authorizePinUseCase = authorizePinUseCase, ) coEvery { migratePinHash.runMigration(any()) } just Runs @@ -49,7 +48,7 @@ class VerifyPinUseCaseTest { // Given val pin = "1234" coEvery { pinRepository.hasPoisonPillPin() } returns false - coEvery { authManager.verifyPin(pin) } returns mockk(relaxed = true) + coEvery { authorizePinUseCase.authorizePin(pin) } returns mockk(relaxed = true) coEvery { authManager.resetFailedAttempts() } just Runs // When @@ -57,8 +56,8 @@ class VerifyPinUseCaseTest { // Then assertTrue(result) - coVerify { authManager.verifyPin(pin) } - coVerify(exactly = 0) { authManager.activatePoisonPill() } + coVerify { authorizePinUseCase.authorizePin(pin) } + coVerify(exactly = 0) { pinRepository.activatePoisonPill() } coVerify(exactly = 0) { imageManager.activatePoisonPill() } } @@ -67,15 +66,15 @@ class VerifyPinUseCaseTest { // Given val pin = "1234" coEvery { pinRepository.hasPoisonPillPin() } returns false - coEvery { authManager.verifyPin(pin) } returns null + coEvery { authorizePinUseCase.authorizePin(pin) } returns null // When val result = verifyPinUseCase.verifyPin(pin) // Then assertFalse(result) - coVerify { authManager.verifyPin(pin) } - coVerify(exactly = 0) { authManager.activatePoisonPill() } + coVerify { authorizePinUseCase.authorizePin(pin) } + coVerify(exactly = 0) { pinRepository.activatePoisonPill() } coVerify(exactly = 0) { imageManager.activatePoisonPill() } } @@ -86,15 +85,15 @@ class VerifyPinUseCaseTest { coEvery { pinRepository.getHashedPin() } returns mockk() coEvery { pinRepository.hasPoisonPillPin() } returns true coEvery { pinRepository.verifyPoisonPillPin(pin) } returns true - coEvery { authManager.activatePoisonPill() } returns Unit + coEvery { pinRepository.activatePoisonPill() } returns Unit coEvery { imageManager.activatePoisonPill() } returns Unit - coEvery { authManager.verifyPin(pin) } returns null // Even if PIN verification fails, poison pill should activate + coEvery { authorizePinUseCase.authorizePin(pin) } returns null // Even if PIN verification fails, poison pill should activate // When val result = verifyPinUseCase.verifyPin(pin) // Then - assertFalse(result) // Result should match what authManager.verifyPin returns + assertFalse(result) // Result should match what authorizePinUseCase.authorizePin returns } @Test @@ -103,7 +102,7 @@ class VerifyPinUseCaseTest { val pin = "1234" coEvery { pinRepository.hasPoisonPillPin() } returns true coEvery { pinRepository.verifyPoisonPillPin(pin) } returns false - coEvery { authManager.verifyPin(pin) } returns mockk(relaxed = true) + coEvery { authorizePinUseCase.authorizePin(pin) } returns mockk(relaxed = true) coEvery { authManager.resetFailedAttempts() } just Runs // When @@ -113,9 +112,9 @@ class VerifyPinUseCaseTest { assertTrue(result) coVerify { pinRepository.hasPoisonPillPin() } coVerify { pinRepository.verifyPoisonPillPin(pin) } - coVerify(exactly = 0) { authManager.activatePoisonPill() } + coVerify(exactly = 0) { pinRepository.activatePoisonPill() } coVerify(exactly = 0) { imageManager.activatePoisonPill() } - coVerify { authManager.verifyPin(pin) } + coVerify { authorizePinUseCase.authorizePin(pin) } } @Test @@ -123,13 +122,13 @@ class VerifyPinUseCaseTest { // Given val pin = "" coEvery { pinRepository.hasPoisonPillPin() } returns false - coEvery { authManager.verifyPin(pin) } returns null + coEvery { authorizePinUseCase.authorizePin(pin) } returns null // When val result = verifyPinUseCase.verifyPin(pin) // Then assertFalse(result) - coVerify { authManager.verifyPin(pin) } + coVerify { authorizePinUseCase.authorizePin(pin) } } } \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b9b5328..11cbaab 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -28,7 +28,7 @@ coreKtx = "1.16.0" junit = "4.13.2" junitVersion = "1.3.0" activityCompose = "1.10.1" -composeBom = "2025.07.00" +composeBom = "2025.08.00" navigation3 = "1.0.0-alpha06" nav3Material = "1.0.0-SNAPSHOT" lifecycleViewmodel = "1.0.0-alpha04"