Codex 의 도움을 받아 리팩토링을 진행하던 도중, Codex 가 현재 내 안드로이드 프로젝트의 다음 보안 문제점을 지적했다.
이를 해결하기 위한 방법으로는
EncryptedSharedPreferencesAndroidKeyStore방식 사용DataStore-tink라이브러리 활용
위의 3종류가 존재하는데, 1번인 EncryptedSharedPreferences 방식은 현재 androidx.security.crypto 계열이 Derprecated 되었기에 추천하지 않는다. (https://developer.android.com/privacy-and-security/cryptography?hl=ko#jetpack_security_crypto_library, https://developer.android.com/reference/androidx/security/crypto/EncryptedSharedPreferences 참고)
이제 남은 방식은 2가지인데, 나는 이 중에서 DataStore-tink 에 대해 게시글을 작성하려 한다.
-> DataStore-tink 는 3월에 출시된, 안정화되지 않은 단계이므로 사용을 추천하지는 않는다.
DataStore-tink 방식 구현 진행이 방식은 크게 아래 흐름으로 이해하면 된다.
TokenBundle같은 객체를Serializer가 바이트로 직렬화한다.AeadSerializer가 그 바이트를 AEAD 로 암호화한다.- 최종 저장과 갱신은
DataStore가 담당한다.
이번 프로젝트에서는 공식 1.3.0-alpha07 릴리스 노트 흐름에 맞춰 다음 구조로 구성했다.
AndroidKeysetManager.Builder()로 Tink keyset 생성withMasterKeyUri("android-keystore://...")로 AndroidKeyStore master key 연결keysetHandle.getPrimitive(...)로Aeadprimitive 생성AeadSerializer에 기존TokenBundleSerializer를 감싸서 연결DataStore.Builder<T>로 최종DataStore생성
우선 시작하기에 앞서 libs.versions.toml 에 다음 라이브러리르 추가하고 module dependencies 에 등록해주자.
androidxDataStoreTink = "1.3.0-alpha09"
androidx-datastore-tink = { module = "androidx.datastore:datastore-tink", version.ref = "androidxDataStoreTink" }
implementation(libs.androidx.datastore.tink)
DataStore-tink 구현하기 전 토큰 데이터를 담을 TokenBundle 클래스를 구현하고 넘어가겠다.
@Serializable
data class TokenBundle(
val accessToken: String,
val refreshToken: String,
val accessTokenExpiredAt: String,
val refreshTokenExpiredAt: String,
) {
companion object {
val Empty = TokenBundle(
accessToken = "",
refreshToken = "",
accessTokenExpiredAt = "",
refreshTokenExpiredAt = "",
)
}
}
우선 DataStoreTinkTokenStore 라는 커스텀 저장소를 구현하자.
class DataStoreTinkTokenStore<T>(
private val dataStore: DataStore<T>,
private val emptyValue: T,
) {
suspend fun save(value: T) {
dataStore.updateData { value }
}
suspend fun read(): T? {
return dataStore.data.first().takeUnless { it == emptyValue }
}
fun readFlow(): Flow<T> {
return dataStore.data
}
suspend fun clear() {
dataStore.updateData { emptyValue }
}
}
DataStoreTinkTokenStore 클래스를 생성할 함수를 만들어준다./** 암호화된 TokenBundle 본문이 DataStore 파일로 저장될 때 사용할 파일명 */
private const val DefaultDataStoreTinkFileName = "token_bundle_tink.pb"
/** Tink keyset 을 식별할 때 사용할 key */
private const val DefaultDataStoreTinkKeysetName = "token_bundle_tink_keyset"
/** Tink keyset 메타데이터를 저장할 SharedPreferences 파일명 */
private const val DefaultDataStoreTinkPrefsFileName = "token_bundle_tink_keyset_prefs"
/** Tink keyset 을 보호할 Android Keystore master key alias URI*/
private const val DefaultDataStoreTinkMasterKeyUri = "android-keystore://blog_datastore_tink_master_key"
fun <T> createTypedDataStoreTinkStore(
context: Context,
fileName: String,
keysetName: String,
keysetPrefsFileName: String,
masterKeyUri: String,
serializer: Serializer<T>,
emptyValue: T,
): DataStoreTinkTokenStore<T> {
// 저장소가 UI 에 묶이지 않게 ApplicationContext 사용 !
val appContext = context.applicationContext
val storageFile = appContext.dataStoreFile(fileName)
val aeadSerializer = AeadSerializer(
aead = createDataStoreTinkAead(appContext, keysetName, keysetPrefsFileName, masterKeyUri),
wrappedSerializer = serializer,
// 파일 이름을 바이트어레이로 변경
associatedData = fileName.encodeToByteArray(),
)
...
}
이제 AeadSerializer 라는 것을 구현해야하는데, 이를 위해 AeadSerializer 를 위한 Aead 생성 함수를 만들자.
private fun createDataStoreTinkAead(
context: Context,
keysetName: String,
keysetPrefsFileName: String,
masterKeyUri: String,
): Aead {
AeadConfig.register()
val keysetHandle = AndroidKeysetManager.Builder()
// SharedPreferences 위치 결정
.withSharedPref(context, keysetName, keysetPrefsFileName)
// 키 알고리즘 결정. 현재는 AES256_GCM 사용
.withKeyTemplate(KeyTemplate.createFrom(PredefinedAeadParameters.AES256_GCM))
// Android Keystore 보호키 결정
// 주소값은 android-keystore:// 이후 자유. 하지만 고유의 것으로 작성할 것
.withMasterKeyUri(masterKeyUri)
.build()
.keysetHandle
// 최종적으로 keyset 으로부터 Aead 추출
return keysetHandle.getPrimitive(
RegistryConfiguration.get(),
Aead::class.java,
)
}
참고로 위에서 AeadConfig.register() 는 하지 않는 경우 에러가 발생할 수도 있다.
안하니까 java.security.GeneralSecurityException: Cannot create a new key for parameters AesGcm Parameters 에러가 발생하였는데, 원인이 Tink 내부 registry에 AEAD 관련 구현이 먼저 등록되어야해서 이것을 먼저 해야한다고 한다.
함수 구현이 완료되었으면, createTypedDataStoreTinkStore 작성을 마무리하자.
fun <T> createTypedDataStoreTinkStore(
context: Context,
fileName: String,
keysetName: String,
keysetPrefsFileName: String,
masterKeyUri: String,
serializer: Serializer<T>,
emptyValue: T,
): DataStoreTinkTokenStore<T> {
val appContext = context.applicationContext
val storageFile = appContext.dataStoreFile(fileName)
val aeadSerializer = AeadSerializer(
aead = createDataStoreTinkAead(appContext, keysetName, keysetPrefsFileName, masterKeyUri),
wrappedSerializer = serializer,
associatedData = fileName.encodeToByteArray(),
)
val dataStore: DataStore<T> = DataStore.Builder(
FileStorage(
serializer = aeadSerializer,
produceFile = { storageFile },
),
Dispatchers.IO + SupervisorJob(),
)
// 역직렬화 실패 시 빈 값 (기본 값) 으로 갈아끼우기.
.setCorruptionHandler(ReplaceFileCorruptionHandler { emptyValue })
.build()
return DataStoreTinkTokenStore(
dataStore = dataStore,
emptyValue = emptyValue,
)
}
이제 기본적인 준비가 끝났다.
실제 코드에서 사용할 수 있는 함수를 만들어보자.
fun <T> createDataStoreTinkTokenStore(
context: Context,
json: Json,
defaultValue: T,
emptyValue: T,
serializer: KSerializer<T>,
fileName: String = DefaultDataStoreTinkFileName,
keysetName: String = DefaultDataStoreTinkKeysetName,
keysetPrefsFileName: String = DefaultDataStoreTinkPrefsFileName,
masterKeyUri: String = DefaultDataStoreTinkMasterKeyUri,
): DataStoreTinkTokenStore<T> {
return createTypedDataStoreTinkStore(
context = context,
fileName = fileName,
keysetName = keysetName,
keysetPrefsFileName = keysetPrefsFileName,
masterKeyUri = masterKeyUri,
serializer = JsonDataStoreSerializer(
json = json,
defaultValue = defaultValue,
serializer = serializer
),
emptyValue = emptyValue,
)
}
class JsonDataStoreSerializer<T>(
private val json: Json,
override val defaultValue: T,
private val serializer: KSerializer<T>,
) : Serializer<T> {
override suspend fun readFrom(input: InputStream): T {
val bytes = input.readBytes()
if (bytes.isEmpty()) return defaultValue
return json.decodeFromString(serializer, bytes.decodeToString())
}
override suspend fun writeTo(t: T, output: OutputStream) {
withContext(Dispatchers.IO) {
output.write(json.encodeToString(serializer, t).encodeToByteArray())
}
}
}
여러 곳에서 쉽게 꺼내쓸 수 있게 GenericType 으로 구현하였고, 이를 위해 KSerializer 파라미터를 받도록 하였다.
MainActivity 에 다음과 같이 코드를 놓아보자.
class MainActivity : ComponentActivity() {
private val dataStoreTinkStore by lazy {
createDataStoreTinkTokenStore(
context = applicationContext,
json = Json {
ignoreUnknownKeys = true
encodeDefaults = true
},
serializer = TokenBundle.serializer(),
emptyValue = TokenBundle.Empty,
defaultValue = TokenBundle.Empty,
)
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
MaterialTheme {
Surface(modifier = Modifier.fillMaxSize().systemBarsPadding()) {
BlogScreen(dataStoreTinkStore = dataStoreTinkStore)
}
}
}
}
}
@Composable
private fun BlogScreen(dataStoreTinkStore: DataStoreTinkTokenStore<TokenBundle>) {
val tokenBundle by dataStoreTinkStore.readFlow()
.collectAsStateWithLifecycle(TokenBundle.Empty)
val scope = rememberCoroutineScope()
Column {
Text(
text = """
액세스 토큰 : ${ tokenBundle.accessToken }
리프레쉬 토큰 : ${ tokenBundle.refreshToken }
액세스 만료 : ${ tokenBundle.accessTokenExpiredAt }
리프레쉬 만료 : ${ tokenBundle.refreshTokenExpiredAt }
""".trimIndent(),
style = MaterialTheme.typography.headlineMedium,
)
Button(
onClick = { scope.launch {
val newTokenBundle = TokenBundle(
accessToken = UUID.randomUUID().toString(),
refreshToken = UUID.randomUUID().toString(),
accessTokenExpiredAt = "2023-01-01 00:00:00",
refreshTokenExpiredAt = "2023-01-01 00:00:00",
)
dataStoreTinkStore.save(newTokenBundle)
}}
) { Text(text = "토큰 변경" )}
}
}
에뮬레이터를 실행하여 테스트 해보자.

잘되는 것을 확인할 수 있었다.