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",