Skip to content
Open
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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ APK output: `app/build/outputs/apk/debug/app-debug.apk`

- Kotlin 2.1.0, Jetpack Compose, Material 3
- Hilt (DI), Retrofit2 + OkHttp (networking)
- WorkManager (background sync), EncryptedSharedPreferences (security)
- WorkManager (background sync), DataStore + KeyStore value encryption (security)
- KSP, kotlinx.serialization

## Acknowledgments
Expand Down
3 changes: 0 additions & 3 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -107,9 +107,6 @@ dependencies {
// WorkManager
implementation(libs.work.runtime.ktx)

// Security
implementation(libs.security.crypto)

// Accompanist
implementation(libs.accompanist.permissions)

Expand Down
Original file line number Diff line number Diff line change
@@ -1,78 +1,119 @@
package com.codexbar.android.core.security

import android.content.Context
import android.content.SharedPreferences
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKeys
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.longPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import com.codexbar.android.core.domain.model.AiService
import com.codexbar.android.core.domain.model.Credential
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import java.time.Instant
import java.util.concurrent.atomic.AtomicReference
import javax.inject.Inject
import javax.inject.Singleton

private val Context.secureDataStore: DataStore<Preferences> by preferencesDataStore(
name = "codexbar_secure_prefs"
)

@Singleton
class EncryptedPrefsManager @Inject constructor(
@ApplicationContext private val context: Context
@ApplicationContext private val context: Context,
private val valueEncryption: ValueEncryption
) {
private val masterKeyAlias = MasterKeys.getOrCreate(MasterKeys.AES256_GCM_SPEC)

private val prefs: SharedPreferences by lazy {
EncryptedSharedPreferences.create(
"codexbar_secure_prefs",
masterKeyAlias,
context,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
private val dataStore = context.secureDataStore
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)

private data class Cache(
val credentialServices: Set<AiService> = emptySet(),
val refreshIntervalMinutes: Long = 30L,
val notificationsEnabled: Boolean = true
)

private val cache = AtomicReference<Cache?>(null)

private suspend fun ensureCache() {
if (cache.get() != null) return
val prefs = dataStore.data.first()
val services = AiService.entries.filter { service ->
prefs[stringPreferencesKey("${service.name}_access_token")] != null
}.toSet()
val interval = prefs[longPreferencesKey("refresh_interval_minutes")] ?: 30L
val notifications = prefs[booleanPreferencesKey("notifications_enabled")] ?: true
cache.set(Cache(services, interval, notifications))
}

fun saveCredential(service: AiService, credential: Credential) {
val editor = prefs.edit()
val prefix = service.name
private fun getEncrypted(key: String, prefs: Preferences): String? {
val raw = prefs[stringPreferencesKey(key)] ?: return null
return valueEncryption.decrypt(raw) ?: raw
}

editor.putString("${prefix}_access_token", credential.accessToken)
editor.putString("${prefix}_refresh_token", credential.refreshToken)
suspend fun saveCredential(service: AiService, credential: Credential) {
dataStore.edit { prefs ->
val prefix = service.name
prefs[stringPreferencesKey("${prefix}_access_token")] =
valueEncryption.encrypt(credential.accessToken)
credential.refreshToken?.let {
prefs[stringPreferencesKey("${prefix}_refresh_token")] = valueEncryption.encrypt(it)
}

when (credential) {
is Credential.ClaudeCredential -> {
credential.expiresAt?.let {
editor.putLong("${prefix}_expires_at", it.epochSecond)
when (credential) {
is Credential.ClaudeCredential -> {
credential.expiresAt?.let {
prefs[longPreferencesKey("${prefix}_expires_at")] = it.epochSecond
}
credential.scopes?.let {
prefs[stringPreferencesKey("${prefix}_scopes")] = valueEncryption.encrypt(it)
}
credential.rateLimitTier?.let {
prefs[stringPreferencesKey("${prefix}_rate_limit_tier")] =
valueEncryption.encrypt(it)
}
}
credential.scopes?.let {
editor.putString("${prefix}_scopes", it)
is Credential.CodexCredential -> {
credential.accountId?.let {
prefs[stringPreferencesKey("${prefix}_account_id")] =
valueEncryption.encrypt(it)
}
}
credential.rateLimitTier?.let {
editor.putString("${prefix}_rate_limit_tier", it)
is Credential.GeminiCredential -> {
prefs[longPreferencesKey("${prefix}_expires_at_ms")] = credential.expiresAtMs
prefs[stringPreferencesKey("${prefix}_oauth_client_id")] =
valueEncryption.encrypt(credential.oauthClientId)
prefs[stringPreferencesKey("${prefix}_oauth_client_secret")] =
valueEncryption.encrypt(credential.oauthClientSecret)
}
}
is Credential.CodexCredential -> {
credential.accountId?.let {
editor.putString("${prefix}_account_id", it)
}
}
is Credential.GeminiCredential -> {
editor.putLong("${prefix}_expires_at_ms", credential.expiresAtMs)
editor.putString("${prefix}_oauth_client_id", credential.oauthClientId)
editor.putString("${prefix}_oauth_client_secret", credential.oauthClientSecret)
}
}

editor.apply() // atomic write via SharedPreferences commit semantics
cache.set(
cache.get()?.copy(credentialServices = cache.get()!!.credentialServices + service)
?: Cache(credentialServices = setOf(service))
)
}

fun loadCredential(service: AiService): Credential? {
suspend fun loadCredential(service: AiService): Credential? {
ensureCache()
val prefs = dataStore.data.first()
val prefix = service.name
val accessToken = prefs.getString("${prefix}_access_token", null) ?: return null
val accessToken = getEncrypted("${prefix}_access_token", prefs) ?: return null

return when (service) {
AiService.CLAUDE -> {
val refreshToken = prefs.getString("${prefix}_refresh_token", null)
val expiresAt = prefs.getLong("${prefix}_expires_at", -1L)
.takeIf { it > 0 }
val refreshToken = getEncrypted("${prefix}_refresh_token", prefs)
val expiresAt = prefs[longPreferencesKey("${prefix}_expires_at")]
?.takeIf { it > 0 }
?.let { Instant.ofEpochSecond(it) }
val scopes = prefs.getString("${prefix}_scopes", null)
val rateLimitTier = prefs.getString("${prefix}_rate_limit_tier", null)
val scopes = getEncrypted("${prefix}_scopes", prefs)
val rateLimitTier = getEncrypted("${prefix}_rate_limit_tier", prefs)
Credential.ClaudeCredential(
accessToken = accessToken,
refreshToken = refreshToken,
Expand All @@ -82,20 +123,20 @@ class EncryptedPrefsManager @Inject constructor(
)
}
AiService.CODEX -> {
val refreshToken = prefs.getString("${prefix}_refresh_token", null) ?: return null
val accountId = prefs.getString("${prefix}_account_id", null)
val refreshToken = getEncrypted("${prefix}_refresh_token", prefs) ?: return null
val accountId = getEncrypted("${prefix}_account_id", prefs)
Credential.CodexCredential(
accessToken = accessToken,
refreshToken = refreshToken,
accountId = accountId
)
}
AiService.GEMINI -> {
val refreshToken = prefs.getString("${prefix}_refresh_token", null) ?: return null
val expiresAtMs = prefs.getLong("${prefix}_expires_at_ms", -1L)
.takeIf { it > 0 } ?: return null
val clientId = prefs.getString("${prefix}_oauth_client_id", null) ?: return null
val clientSecret = prefs.getString("${prefix}_oauth_client_secret", null) ?: return null
val refreshToken = getEncrypted("${prefix}_refresh_token", prefs) ?: return null
val expiresAtMs = prefs[longPreferencesKey("${prefix}_expires_at_ms")]
?.takeIf { it > 0 } ?: return null
val clientId = getEncrypted("${prefix}_oauth_client_id", prefs) ?: return null
val clientSecret = getEncrypted("${prefix}_oauth_client_secret", prefs) ?: return null
Credential.GeminiCredential(
accessToken = accessToken,
refreshToken = refreshToken,
Expand All @@ -107,60 +148,78 @@ class EncryptedPrefsManager @Inject constructor(
}
}

fun deleteCredential(service: AiService) {
val prefix = service.name
val editor = prefs.edit()

val keys = prefs.all.keys.filter { it.startsWith(prefix) }
keys.forEach { editor.remove(it) }

editor.apply()
suspend fun deleteCredential(service: AiService) {
dataStore.edit { prefs ->
val toRemove = prefs.asMap().keys.filter { it.name.startsWith(service.name) }
toRemove.forEach { prefs.remove(it) }
}
cache.set(
cache.get()?.copy(credentialServices = cache.get()!!.credentialServices - service)
?: Cache()
)
}

fun deleteAllCredentials() {
prefs.edit().clear().apply()
suspend fun deleteAllCredentials() {
dataStore.edit { it.clear() }
cache.set(Cache())
}

fun hasCredential(service: AiService): Boolean {
return prefs.getString("${service.name}_access_token", null) != null
val c = cache.get()
if (c != null) return service in c.credentialServices
scope.launch { ensureCache() }
return false
}

fun getRefreshInterval(): Long {
return prefs.getLong("refresh_interval_minutes", 30L)
val c = cache.get()
if (c != null) return c.refreshIntervalMinutes
scope.launch { ensureCache() }
return 30L
}

fun setRefreshInterval(minutes: Long) {
prefs.edit().putLong("refresh_interval_minutes", minutes).apply()
suspend fun setRefreshInterval(minutes: Long) {
dataStore.edit {
it[longPreferencesKey("refresh_interval_minutes")] = minutes
}
cache.set(cache.get()?.copy(refreshIntervalMinutes = minutes) ?: Cache(refreshIntervalMinutes = minutes))
}

fun isNotificationsEnabled(): Boolean {
return prefs.getBoolean("notifications_enabled", true)
val c = cache.get()
if (c != null) return c.notificationsEnabled
scope.launch { ensureCache() }
return true
}

fun setNotificationsEnabled(enabled: Boolean) {
prefs.edit().putBoolean("notifications_enabled", enabled).apply()
suspend fun setNotificationsEnabled(enabled: Boolean) {
dataStore.edit {
it[booleanPreferencesKey("notifications_enabled")] = enabled
}
cache.set(cache.get()?.copy(notificationsEnabled = enabled) ?: Cache(notificationsEnabled = enabled))
}

fun saveResetTimes(service: AiService, windows: List<Pair<String, Instant?>>) {
val editor = prefs.edit()
windows.forEach { (label, resetsAt) ->
val key = "${service.name}_${label}_resets_at"
if (resetsAt != null) {
editor.putLong(key, resetsAt.epochSecond)
} else {
editor.remove(key)
suspend fun saveResetTimes(service: AiService, windows: List<Pair<String, Instant?>>) {
dataStore.edit { prefs ->
windows.forEach { (label, resetsAt) ->
val key = longPreferencesKey("${service.name}_${label}_resets_at")
if (resetsAt != null) {
prefs[key] = resetsAt.epochSecond
} else {
prefs.remove(key)
}
}
}
editor.apply()
}

fun loadResetTimes(service: AiService): Map<String, Instant> {
suspend fun loadResetTimes(service: AiService): Map<String, Instant> {
val prefs = dataStore.data.first()
val prefix = "${service.name}_"
val suffix = "_resets_at"
return prefs.all
.filter { it.key.startsWith(prefix) && it.key.endsWith(suffix) }
return prefs.asMap()
.filter { (key, _) -> key.name.startsWith(prefix) && key.name.endsWith(suffix) }
.mapNotNull { (key, value) ->
val label = key.removePrefix(prefix).removeSuffix(suffix)
val label = key.name.removePrefix(prefix).removeSuffix(suffix)
val epochSecond = (value as? Long)?.takeIf { it > 0 } ?: return@mapNotNull null
label to Instant.ofEpochSecond(epochSecond)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
package com.codexbar.android.core.security

import android.util.Base64
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import java.security.KeyStore
import javax.crypto.Cipher
import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
import javax.crypto.spec.GCMParameterSpec
import javax.inject.Inject
import javax.inject.Singleton

/**
* KeyStore 기반 AES-256-GCM 값 암복호화.
* DataStore에 민감한 값을 저장할 때 사용합니다.
*/
@Singleton
class ValueEncryption @Inject constructor() {
private val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
private val keyAlias = "codexbar_datastore_aes_key"
private val gcmTagLength = 128
private val gcmIvLength = 12

private val secretKey: SecretKey by lazy {
if (keyStore.containsAlias(keyAlias)) {
(keyStore.getKey(keyAlias, null) as SecretKey)
} else {
KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore").run {
init(
KeyGenParameterSpec.Builder(
keyAlias,
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
)
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.setKeySize(256)
.build()
)
generateKey()
}
}
}

fun encrypt(plain: String): String {
if (plain.isEmpty()) return ""
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
cipher.init(Cipher.ENCRYPT_MODE, secretKey)
val iv = cipher.iv
val encrypted = cipher.doFinal(plain.toByteArray(Charsets.UTF_8))
val combined = ByteArray(iv.size + encrypted.size).apply {
System.arraycopy(iv, 0, this, 0, iv.size)
System.arraycopy(encrypted, 0, this, iv.size, encrypted.size)
}
return Base64.encodeToString(combined, Base64.NO_WRAP)
}

fun decrypt(encoded: String): String? {
if (encoded.isEmpty()) return ""
return try {
val combined = Base64.decode(encoded, Base64.NO_WRAP)
if (combined.size <= gcmIvLength) return null
val iv = combined.copyOfRange(0, gcmIvLength)
val encrypted = combined.copyOfRange(gcmIvLength, combined.size)
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
cipher.init(Cipher.DECRYPT_MODE, secretKey, GCMParameterSpec(gcmTagLength, iv))
String(cipher.doFinal(encrypted), Charsets.UTF_8)
} catch (_: Exception) {
null
}
}
}
Loading