Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -37,7 +38,6 @@ val appModule = module {
single {
AuthorizationRepository(
preferences = get(),
pinRepository = get(),
encryptionScheme = get(),
context = get(),
clock = get()
Expand All @@ -58,14 +58,15 @@ val appModule = module {
val detector = get<SecurityLevelDetector>()
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()) }

Expand All @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
) {
Expand Down Expand Up @@ -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
)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,13 +70,13 @@ class AppPreferencesDataSource(
/**
* Check if the user has completed the introduction
*/
val hasCompletedIntro: Flow<Boolean?> = context.dataStore.data
val hasCompletedIntro: Flow<Boolean?> = dataStore.data
.map { preferences ->
preferences[HAS_COMPLETED_INTRO] ?: false
}

// DELETE ME after beta migration is over
val isProdReady: Flow<Boolean?> = context.dataStore.data
val isProdReady: Flow<Boolean?> = dataStore.data
.map { preferences ->
preferences[IS_PROD_READY] ?: false
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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
}
}
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
}

Expand All @@ -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())
}

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
}
}
Original file line number Diff line number Diff line change
@@ -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
}
}
}
Loading