From f616a8fb4a0fa0fac7b1db6c67437c86179a2e94 Mon Sep 17 00:00:00 2001
From: jihyunniiii <103172971+jihyunniiii@users.noreply.github.com>
Date: Fri, 22 Nov 2024 14:37:29 +0900
Subject: [PATCH] =?UTF-8?q?[Feture/#845]=20=EB=A1=9C=EC=BB=AC=20=EC=A0=80?=
=?UTF-8?q?=EC=9E=A5=EC=86=8C=20=EC=95=94=ED=98=B8=ED=99=94=EB=A5=BC=20?=
=?UTF-8?q?=EC=9C=84=ED=95=9C=20=EC=95=94=ED=98=B8=ED=99=94=20=EB=AA=A8?=
=?UTF-8?q?=EB=93=88=20=EB=A7=8C=EB=93=A4=EA=B8=B0=20(#927)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* [feature/#845] security module
* [feature/#845] SecurityExt
* [feature/#845] cryptoManager 적용
* [feature/#845] spotlessApply
* [feature/#845] EncryptedContent data class
* [feature/#845] SharedPreference get, set 함수 확장함수화 및 DEBUG 모드에서만 암호화 적용
* [feature/#845] getOrCreateSecretKey -> getSecretKey
* [feature/#845] cipher 객체 지역변수화
* [feature/#845] key값 수정
* [feature/#845] KEY_ALIAS 변경
* [feature/#845] KEY_ALIAS 변경으로 인한 merge builder, PR builder 수정
* [feature/#845] backup_rules.xml (sharedpref backup에서 제외)
* [feature/#845] CryptoManager 예외 처리
* [feature/#845] CryptoManager object화
* [feature/#845] hilt 재적용
* [feature/#845] DataStore가 CryptoManager를 알지 못하도록 수정
* [feature/#845] CryptoManagerTest
* [feature/#845] SoptDataStore 기본 값 수정
* [feature/#845] CryproManagerTest AndroidTest로 변경
* [feature/#845] testCryptoSuccess 추가 및 로직 수정
* [feature/#845] spotlessApply
---
.github/workflows/develop_PR_builder.yml | 10 ++
.github/workflows/develop_merge_builder.yml | 10 ++
app/build.gradle.kts | 1 +
app/src/main/res/xml/backup_rules.xml | 5 +-
.../org/sopt/official/plugin/CommonConfigs.kt | 11 ++
core/common/build.gradle.kts | 1 +
.../sopt/official/common/util/SecurityExt.kt | 37 +++++++
.../network/persistence/SoptDataStore.kt | 35 +++---
core/security/.gitignore | 1 +
core/security/build.gradle.kts | 32 ++++++
.../official/security/CryptoManagerTest.kt | 82 ++++++++++++++
.../sopt/official/security/CryptoManager.kt | 100 ++++++++++++++++++
.../security/model/EncryptedContent.kt | 46 ++++++++
.../sopt/official/security/util/CryptoExt.kt | 53 ++++++++++
settings.gradle.kts | 1 +
15 files changed, 407 insertions(+), 18 deletions(-)
create mode 100644 core/common/src/main/java/org/sopt/official/common/util/SecurityExt.kt
create mode 100644 core/security/.gitignore
create mode 100644 core/security/build.gradle.kts
create mode 100644 core/security/src/androidTest/java/org/sopt/official/security/CryptoManagerTest.kt
create mode 100644 core/security/src/main/java/org/sopt/official/security/CryptoManager.kt
create mode 100644 core/security/src/main/java/org/sopt/official/security/model/EncryptedContent.kt
create mode 100644 core/security/src/main/java/org/sopt/official/security/util/CryptoExt.kt
diff --git a/.github/workflows/develop_PR_builder.yml b/.github/workflows/develop_PR_builder.yml
index a5b60070c..16e420082 100644
--- a/.github/workflows/develop_PR_builder.yml
+++ b/.github/workflows/develop_PR_builder.yml
@@ -50,6 +50,11 @@ jobs:
DEV_AMPLITUDE_KEY: ${{ secrets.SENTRY_DSN }}
AMPLITUDE_KEY: ${{ secrets.SENTRY_DSN }}
POKE_DATA_STORE_KEY: ${{ secrets.SENTRY_DSN }}
+ ACCESS_TOKEN_KEY_ALIAS: ${{ secrets.SENTRY_DSN }}
+ REFRESH_TOKEN_KEY_ALIAS: ${{ secrets.SENTRY_DSN }}
+ PLAYGROUND_TOKEN_KEY_ALIAS: ${{ secrets.SENTRY_DSN }}
+ USER_STATUS_KEY_ALIAS: ${{ secrets.SENTRY_DSN }}
+ PUSH_TOKEN_KEY_ALIAS: ${{ secrets.SENTRY_DSN }}
run: |
echo apiKey=\"$API_KEY\" >> ./local.properties
echo dataStoreKey=\"$DATA_STORE_KEY\" >> ./local.properties
@@ -63,6 +68,11 @@ jobs:
echo devAmplitudeKey=\"$DEV_AMPLITUDE_KEY\" >> ./local.properties
echo amplitudeKey=\"$AMPLITUDE_KEY\" >> ./local.properties
echo pokeDataStoreKey=\"$POKE_DATA_STORE_KEY\" >> ./local.properties
+ echo accessTokenKeyAlias=\"ACCESS_TOKEN_KEY_ALIAS\" >> ./local.properties
+ echo refreshTokenKeyAlias=\"REFRESH_TOKEN_KEY_ALIAS\" >> ./local.properties
+ echo playgroundTokenKeyAlias=\"PLAYGROUND_TOKEN_KEY_ALIAS\" >> ./local.properties
+ echo userStatusKeyAlias=\"USER_STATUS_KEY_ALIAS\" >> ./local.properties
+ echo pushTokenKeyAlias=\"PUSH_TOKEN_KEY_ALIAS\" >> ./local.properties
- name: Access Firebase Service
run: echo '${{ secrets.GOOGLE_SERVICES_JSON }}' > ./app/google-services.json
diff --git a/.github/workflows/develop_merge_builder.yml b/.github/workflows/develop_merge_builder.yml
index e77f74692..242666d08 100644
--- a/.github/workflows/develop_merge_builder.yml
+++ b/.github/workflows/develop_merge_builder.yml
@@ -50,6 +50,11 @@ jobs:
DEV_AMPLITUDE_KEY: ${{ secrets.SENTRY_DSN }}
AMPLITUDE_KEY: ${{ secrets.SENTRY_DSN }}
POKE_DATA_STORE_KEY: ${{ secrets.SENTRY_DSN }}
+ ACCESS_TOKEN_KEY_ALIAS: ${{ secrets.SENTRY_DSN }}
+ REFRESH_TOKEN_KEY_ALIAS: ${{ secrets.SENTRY_DSN }}
+ PLAYGROUND_TOKEN_KEY_ALIAS: ${{ secrets.SENTRY_DSN }}
+ USER_STATUS_KEY_ALIAS: ${{ secrets.SENTRY_DSN }}
+ PUSH_TOKEN_KEY_ALIAS: ${{ secrets.SENTRY_DSN }}
run: |
echo apiKey=\"$API_KEY\" >> ./local.properties
echo dataStoreKey=\"$DATA_STORE_KEY\" >> ./local.properties
@@ -63,6 +68,11 @@ jobs:
echo devAmplitudeKey=\"$DEV_AMPLITUDE_KEY\" >> ./local.properties
echo amplitudeKey=\"$AMPLITUDE_KEY\" >> ./local.properties
echo pokeDataStoreKey=\"$POKE_DATA_STORE_KEY\" >> ./local.properties
+ echo accessTokenKeyAlias=\"ACCESS_TOKEN_KEY_ALIAS\" >> ./local.properties
+ echo refreshTokenKeyAlias=\"REFRESH_TOKEN_KEY_ALIAS\" >> ./local.properties
+ echo playgroundTokenKeyAlias=\"PLAYGROUND_TOKEN_KEY_ALIAS\" >> ./local.properties
+ echo userStatusKeyAlias=\"USER_STATUS_KEY_ALIAS\" >> ./local.properties
+ echo pushTokenKeyAlias=\"PUSH_TOKEN_KEY_ALIAS\" >> ./local.properties
- name: Access Firebase Service
run: echo '${{ secrets.GOOGLE_SERVICES_JSON }}' > ./app/google-services.json
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 321a4bd46..247e18374 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -136,6 +136,7 @@ dependencies {
implementation(projects.core.network)
implementation(projects.core.auth)
implementation(projects.core.authimpl)
+ implementation(projects.core.security)
implementation(projects.core.webview)
implementation(projects.feature.auth)
implementation(projects.feature.mypage)
diff --git a/app/src/main/res/xml/backup_rules.xml b/app/src/main/res/xml/backup_rules.xml
index ba91ca3bb..886d724d5 100644
--- a/app/src/main/res/xml/backup_rules.xml
+++ b/app/src/main/res/xml/backup_rules.xml
@@ -23,8 +23,5 @@
SOFTWARE.
-->
-
+
\ No newline at end of file
diff --git a/build-logic/convention/src/main/kotlin/org/sopt/official/plugin/CommonConfigs.kt b/build-logic/convention/src/main/kotlin/org/sopt/official/plugin/CommonConfigs.kt
index 0e5d3325a..eb4d98e1e 100644
--- a/build-logic/convention/src/main/kotlin/org/sopt/official/plugin/CommonConfigs.kt
+++ b/build-logic/convention/src/main/kotlin/org/sopt/official/plugin/CommonConfigs.kt
@@ -32,6 +32,11 @@ internal fun Project.configureAndroidCommonPlugin() {
val operationUrl = properties["operationApi"] as? String ?: ""
val devAmplitudeKey = properties["devAmplitudeKey"] as? String ?: ""
val amplitudeKey = properties["amplitudeKey"] as? String ?: ""
+ val accessTokenKeyAlias = properties["accessTokenKeyAlias"] as? String ?: ""
+ val refreshTokenKeyAlias = properties["refreshTokenKeyAlias"] as? String ?: ""
+ val playgroundTokenKeyAlias = properties["playgroundTokenKeyAlias"] as? String ?: ""
+ val userStatusKeyAlias = properties["userStatusKeyAlias"] as? String ?: ""
+ val pushTokenKeyAlias = properties["pushTokenKeyAlias"] as? String ?: ""
buildConfigField("String", "SOPTAMP_API_KEY", apiKey)
buildConfigField("String", "SOPTAMP_DATA_STORE_KEY", dataStoreKey)
buildConfigField("String", "POKE_DATA_STORE_KEY", pokeDataStoreKey)
@@ -41,6 +46,12 @@ internal fun Project.configureAndroidCommonPlugin() {
buildConfigField("String", "SOPT_OPERATION_BASE_URL", operationUrl)
buildConfigField("String", "DEV_AMPLITUDE_KEY", devAmplitudeKey)
buildConfigField("String", "AMPLITUDE_KEY", amplitudeKey)
+ buildConfigField("String", "ACCESS_TOKEN_KEY_ALIAS", accessTokenKeyAlias)
+ buildConfigField("String", "REFRESH_TOKEN_KEY_ALIAS", refreshTokenKeyAlias)
+ buildConfigField("String", "PLAYGROUND_TOKEN_KEY_ALIAS", playgroundTokenKeyAlias)
+ buildConfigField("String", "USER_STATUS_KEY_ALIAS", userStatusKeyAlias)
+ buildConfigField("String", "PUSH_TOKEN_KEY_ALIAS", pushTokenKeyAlias)
+
}
buildFeatures.apply {
viewBinding = true
diff --git a/core/common/build.gradle.kts b/core/common/build.gradle.kts
index 1c2d00481..be41b561e 100644
--- a/core/common/build.gradle.kts
+++ b/core/common/build.gradle.kts
@@ -33,6 +33,7 @@ android {
dependencies {
implementation(projects.core.auth)
+ implementation(projects.core.security)
implementation(platform(libs.okhttp.bom))
implementation(libs.okhttp)
implementation(libs.exifinterface)
diff --git a/core/common/src/main/java/org/sopt/official/common/util/SecurityExt.kt b/core/common/src/main/java/org/sopt/official/common/util/SecurityExt.kt
new file mode 100644
index 000000000..1b412ba8d
--- /dev/null
+++ b/core/common/src/main/java/org/sopt/official/common/util/SecurityExt.kt
@@ -0,0 +1,37 @@
+/*
+ * MIT License
+ * Copyright 2024 SOPT - Shout Our Passion Together
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+package org.sopt.official.common.util
+
+import org.sopt.official.common.BuildConfig
+import org.sopt.official.security.util.getDecryptedDataOrDefault
+import org.sopt.official.security.util.getEncryptedDataOrDefault
+
+fun String.encryptInReleaseMode(keyAlias: String) = if (BuildConfig.DEBUG) this else this.getEncryptedDataOrDefault(keyAlias = keyAlias)
+
+fun String.decryptInReleaseMode(keyAlias: String, initializationVectorSize: Int = 12) =
+ if (BuildConfig.DEBUG) this else this.getDecryptedDataOrDefault(
+ keyAlias = keyAlias,
+ initializationVectorSize = initializationVectorSize
+ )
diff --git a/core/network/src/main/java/org/sopt/official/network/persistence/SoptDataStore.kt b/core/network/src/main/java/org/sopt/official/network/persistence/SoptDataStore.kt
index 298ce07b0..01ffe1992 100644
--- a/core/network/src/main/java/org/sopt/official/network/persistence/SoptDataStore.kt
+++ b/core/network/src/main/java/org/sopt/official/network/persistence/SoptDataStore.kt
@@ -32,13 +32,20 @@ import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import org.sopt.official.common.di.LocalStore
import org.sopt.official.common.file.createSharedPreference
+import org.sopt.official.common.util.decryptInReleaseMode
+import org.sopt.official.common.util.encryptInReleaseMode
+import org.sopt.official.network.BuildConfig.ACCESS_TOKEN_KEY_ALIAS
+import org.sopt.official.network.BuildConfig.PLAYGROUND_TOKEN_KEY_ALIAS
+import org.sopt.official.network.BuildConfig.PUSH_TOKEN_KEY_ALIAS
+import org.sopt.official.network.BuildConfig.REFRESH_TOKEN_KEY_ALIAS
+import org.sopt.official.network.BuildConfig.USER_STATUS_KEY_ALIAS
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class SoptDataStore @Inject constructor(
@ApplicationContext private val context: Context,
- @LocalStore private val fileName: String,
+ @LocalStore private val fileName: String
) {
private val store = createSharedPreference(fileName, context)
@@ -49,35 +56,35 @@ class SoptDataStore @Inject constructor(
}
var accessToken: String
- set(value) = store.edit { putString(ACCESS_TOKEN, value) }
- get() = store.getString(ACCESS_TOKEN, "") ?: ""
+ set(value) = store.edit { putString(ACCESS_TOKEN, value.encryptInReleaseMode(keyAlias = ACCESS_TOKEN_KEY_ALIAS)) }
+ get() = store.getString(ACCESS_TOKEN, null)?.decryptInReleaseMode(keyAlias = ACCESS_TOKEN_KEY_ALIAS) ?: DEFAULT_VALUE
var refreshToken: String
- set(value) = store.edit { putString(REFRESH_TOKEN, value) }
- get() = store.getString(REFRESH_TOKEN, "") ?: ""
+ set(value) = store.edit { putString(REFRESH_TOKEN, value.encryptInReleaseMode(keyAlias = REFRESH_TOKEN_KEY_ALIAS)) }
+ get() = store.getString(REFRESH_TOKEN, null)?.decryptInReleaseMode(keyAlias = REFRESH_TOKEN_KEY_ALIAS)
+ ?: DEFAULT_VALUE
var playgroundToken: String
- set(value) = store.edit { putString(PLAYGROUND_TOKEN, value) }
- get() = store.getString(PLAYGROUND_TOKEN, "") ?: ""
+ set(value) = store.edit { putString(PLAYGROUND_TOKEN, value.encryptInReleaseMode(keyAlias = PLAYGROUND_TOKEN_KEY_ALIAS)) }
+ get() = store.getString(PLAYGROUND_TOKEN, null)?.decryptInReleaseMode(keyAlias = PLAYGROUND_TOKEN_KEY_ALIAS)
+ ?: DEFAULT_VALUE
var userStatus: String
- set(value) = store.edit { putString(USER_STATUS, value) }
- get() = store.getString(USER_STATUS, UNAUTHENTICATED) ?: UNAUTHENTICATED
+ set(value) = store.edit { putString(USER_STATUS, value.encryptInReleaseMode(keyAlias = USER_STATUS_KEY_ALIAS)) }
+ get() = store.getString(USER_STATUS, null)?.decryptInReleaseMode(keyAlias = USER_STATUS_KEY_ALIAS) ?: UNAUTHENTICATED
var pushToken: String
- set(value) = store.edit { putString(PUSH_TOKEN, value) }
- get() = store.getString(PUSH_TOKEN, "") ?: ""
+ set(value) = store.edit { putString(PUSH_TOKEN, value.encryptInReleaseMode(keyAlias = PUSH_TOKEN_KEY_ALIAS)) }
+ get() = store.getString(PUSH_TOKEN, null)?.decryptInReleaseMode(keyAlias = PUSH_TOKEN_KEY_ALIAS) ?: DEFAULT_VALUE
companion object {
- const val DEBUG_FILE_NAME = "sopt_debug"
private const val ACCESS_TOKEN = "access_token"
private const val REFRESH_TOKEN = "refresh_token"
private const val PLAYGROUND_TOKEN = "pg_token"
private const val USER_STATUS = "user_status"
- private const val KEY_ALIAS_AUTH = "alias.preferences.auth_token"
- private const val ANDROID_KEY_STORE = "AndroidKeyStore"
private const val PUSH_TOKEN = "push_token"
private const val UNAUTHENTICATED = "UNAUTHENTICATED"
+ private const val DEFAULT_VALUE = ""
}
}
diff --git a/core/security/.gitignore b/core/security/.gitignore
new file mode 100644
index 000000000..42afabfd2
--- /dev/null
+++ b/core/security/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/core/security/build.gradle.kts b/core/security/build.gradle.kts
new file mode 100644
index 000000000..ee8e4f168
--- /dev/null
+++ b/core/security/build.gradle.kts
@@ -0,0 +1,32 @@
+/*
+ * MIT License
+ * Copyright 2024 SOPT - Shout Our Passion Together
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+plugins {
+ sopt("feature")
+ sopt("test")
+}
+
+android {
+ namespace = "org.sopt.official.security"
+}
\ No newline at end of file
diff --git a/core/security/src/androidTest/java/org/sopt/official/security/CryptoManagerTest.kt b/core/security/src/androidTest/java/org/sopt/official/security/CryptoManagerTest.kt
new file mode 100644
index 000000000..06bc03a93
--- /dev/null
+++ b/core/security/src/androidTest/java/org/sopt/official/security/CryptoManagerTest.kt
@@ -0,0 +1,82 @@
+/*
+ * MIT License
+ * Copyright 2024 SOPT - Shout Our Passion Together
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+package org.sopt.official.security
+
+import com.google.common.truth.Truth.assertThat
+import org.junit.jupiter.api.Assertions.assertTrue
+import org.junit.jupiter.api.DisplayName
+import org.junit.jupiter.params.ParameterizedTest
+import org.junit.jupiter.params.provider.Arguments
+import org.junit.jupiter.params.provider.MethodSource
+import org.sopt.official.security.util.getDecryptedDataOrDefault
+import org.sopt.official.security.util.getEncryptedDataOrDefault
+
+class CryptoManagerTest {
+
+ @ParameterizedTest
+ @MethodSource("generateEncryptedTestData")
+ @DisplayName("keyAlias와 암호화 할 데이터를 입력하면 암호화를 진행한다")
+ fun testEncryptSuccess(keyAlias: String, bytes: ByteArray) {
+ // when
+ val encryptedResult = CryptoManager.encrypt(keyAlias = keyAlias, bytes = bytes)
+
+ // then
+ assertTrue(encryptedResult.isSuccess, "암호화가 성공적으로 진행되었습니다.")
+ }
+
+ @ParameterizedTest
+ @MethodSource("generateCryptoTestData")
+ @DisplayName("keyAlias와 데이터를 입력하면 암호화, 복호화를 순차적으로 진행해 기존의 데이터를 반환한다")
+ fun testCryptoSuccess(keyAlias: String, data: String) {
+ // given
+ val encryptedData = data.getEncryptedDataOrDefault(keyAlias = keyAlias)
+
+ // when
+ val decryptedData = encryptedData.getDecryptedDataOrDefault(keyAlias = keyAlias)
+
+ // then
+ assertThat(decryptedData).isEqualTo(data)
+ }
+
+ companion object {
+ @JvmStatic
+ fun generateEncryptedTestData() = listOf(
+ Arguments.of("ACCESS_TOKEN", "accessToken".toByteArray()),
+ Arguments.of("REFRESH_TOKEN", "refreshToken".toByteArray()),
+ Arguments.of("PLAYGROUND_TOKEN", "playgroundToken".toByteArray()),
+ Arguments.of("USER_STATUS", "userStatus".toByteArray()),
+ Arguments.of("PUSH_TOKEN", "pushToken".toByteArray())
+ )
+
+ @JvmStatic
+ fun generateCryptoTestData() = listOf(
+ Arguments.of("ACCESS_TOKEN", "accessToken"),
+ Arguments.of("REFRESH_TOKEN", "refreshToken"),
+ Arguments.of("PLAYGROUND_TOKEN", "playgroundToken"),
+ Arguments.of("USER_STATUS", "userStatus"),
+ Arguments.of("PUSH_TOKEN", "pushToken")
+ )
+ }
+}
diff --git a/core/security/src/main/java/org/sopt/official/security/CryptoManager.kt b/core/security/src/main/java/org/sopt/official/security/CryptoManager.kt
new file mode 100644
index 000000000..ff07d35c6
--- /dev/null
+++ b/core/security/src/main/java/org/sopt/official/security/CryptoManager.kt
@@ -0,0 +1,100 @@
+/*
+ * MIT License
+ * Copyright 2024 SOPT - Shout Our Passion Together
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+package org.sopt.official.security
+
+import android.security.keystore.KeyGenParameterSpec
+import android.security.keystore.KeyProperties
+import org.sopt.official.security.model.EncryptedContent
+import java.security.KeyStore
+import javax.crypto.Cipher
+import javax.crypto.KeyGenerator
+import javax.crypto.SecretKey
+import javax.crypto.spec.GCMParameterSpec
+
+object CryptoManager {
+ private const val KEY_STORE_TYPE = "AndroidKeyStore"
+ private const val KEY_ALGORITHM = KeyProperties.KEY_ALGORITHM_AES
+ private const val BLOCK_MODE = KeyProperties.BLOCK_MODE_GCM
+ private const val PADDING = "NoPadding"
+ private const val TRANSFORMATION = "$KEY_ALGORITHM/$BLOCK_MODE/$PADDING"
+ private const val T_LEN = 128
+
+ private val keyStore = KeyStore.getInstance(KEY_STORE_TYPE).apply { load(null) }
+
+ private val keyGenerator by lazy { KeyGenerator.getInstance(KEY_ALGORITHM, KEY_STORE_TYPE) }
+
+ private fun getEncryptCipher(keyAlias: String): Cipher =
+ Cipher.getInstance(TRANSFORMATION)
+ .apply { init(Cipher.ENCRYPT_MODE, getSecretKey(keyAlias = keyAlias)) }
+
+ private fun getDecryptCipherForInitializationVector(
+ keyAlias: String,
+ initializationVector: ByteArray
+ ): Cipher =
+ Cipher.getInstance(TRANSFORMATION)
+ .apply {
+ init(
+ Cipher.DECRYPT_MODE,
+ getSecretKey(keyAlias = keyAlias),
+ GCMParameterSpec(T_LEN, initializationVector)
+ )
+ }
+
+ private fun getSecretKey(keyAlias: String): SecretKey =
+ (keyStore.getEntry(keyAlias, null) as? KeyStore.SecretKeyEntry)?.secretKey
+ ?: createSecretKey(keyAlias = keyAlias)
+
+ private fun createSecretKey(keyAlias: String): SecretKey = keyGenerator.apply {
+ init(
+ KeyGenParameterSpec.Builder(
+ keyAlias,
+ KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
+ )
+ .setBlockModes(BLOCK_MODE)
+ .setEncryptionPaddings(PADDING)
+ .build()
+ )
+ }.generateKey()
+
+ fun encrypt(keyAlias: String, bytes: ByteArray): Result =
+ runCatching {
+ getEncryptCipher(keyAlias = keyAlias).let { encryptCipher ->
+ EncryptedContent(
+ initializationVector = encryptCipher.iv,
+ data = encryptCipher.doFinal(bytes)
+ )
+ }
+ }
+
+ fun decrypt(keyAlias: String, encryptedContent: EncryptedContent): Result =
+ runCatching {
+ getDecryptCipherForInitializationVector(
+ keyAlias = keyAlias,
+ initializationVector = encryptedContent.initializationVector
+ ).doFinal(
+ encryptedContent.data
+ )
+ }
+}
diff --git a/core/security/src/main/java/org/sopt/official/security/model/EncryptedContent.kt b/core/security/src/main/java/org/sopt/official/security/model/EncryptedContent.kt
new file mode 100644
index 000000000..7688f4fde
--- /dev/null
+++ b/core/security/src/main/java/org/sopt/official/security/model/EncryptedContent.kt
@@ -0,0 +1,46 @@
+/*
+ * MIT License
+ * Copyright 2024 SOPT - Shout Our Passion Together
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+package org.sopt.official.security.model
+
+data class EncryptedContent(
+ val initializationVector: ByteArray,
+ val data: ByteArray
+) {
+ override fun equals(other: Any?): Boolean = when {
+ this === other -> true
+ other !is EncryptedContent -> false
+ !initializationVector.contentEquals(other.initializationVector) -> false
+ !data.contentEquals(other.data) -> false
+ else -> true
+ }
+
+ override fun hashCode(): Int = HASH_PRIME * initializationVector.contentHashCode() + data.contentHashCode()
+
+ fun concatenate() = initializationVector + data
+
+ companion object {
+ private const val HASH_PRIME = 31
+ }
+}
diff --git a/core/security/src/main/java/org/sopt/official/security/util/CryptoExt.kt b/core/security/src/main/java/org/sopt/official/security/util/CryptoExt.kt
new file mode 100644
index 000000000..70e914987
--- /dev/null
+++ b/core/security/src/main/java/org/sopt/official/security/util/CryptoExt.kt
@@ -0,0 +1,53 @@
+/*
+ * MIT License
+ * Copyright 2024 SOPT - Shout Our Passion Together
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+package org.sopt.official.security.util
+
+import android.util.Base64
+import org.sopt.official.security.CryptoManager
+import org.sopt.official.security.model.EncryptedContent
+
+fun ByteArray.toEncryptedContent(initializationVectorSize: Int): EncryptedContent =
+ EncryptedContent(
+ initializationVector = this.copyOfRange(0, initializationVectorSize),
+ data = this.copyOfRange(initializationVectorSize, this.size)
+ )
+
+fun ByteArray.toBase64(): String = Base64.encodeToString(this, Base64.DEFAULT)
+
+fun String.toByteArray(): ByteArray = Base64.decode(this, Base64.DEFAULT)
+
+fun String.getEncryptedDataOrDefault(keyAlias: String) = CryptoManager.encrypt(
+ keyAlias = keyAlias,
+ bytes = this.toByteArray(Charsets.UTF_8)
+).mapCatching { encryptedContent ->
+ encryptedContent.concatenate().toBase64()
+}.getOrDefault(this).toString()
+
+fun String.getDecryptedDataOrDefault(keyAlias: String, initializationVectorSize: Int = 12) = CryptoManager.decrypt(
+ keyAlias = keyAlias,
+ encryptedContent = this.toByteArray().toEncryptedContent(initializationVectorSize = initializationVectorSize)
+).mapCatching { decryptedContent ->
+ decryptedContent.toString(Charsets.UTF_8)
+}.getOrDefault(this).toString()
diff --git a/settings.gradle.kts b/settings.gradle.kts
index 0340a6ea4..877f4f36f 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -25,6 +25,7 @@ include(
":core:authimpl",
":core:common",
":core:designsystem",
+ ":core:security",
":core:webview",
":data:mypage",
":data:notification",