일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | 4 | |||
5 | 6 | 7 | 8 | 9 | 10 | 11 |
12 | 13 | 14 | 15 | 16 | 17 | 18 |
19 | 20 | 21 | 22 | 23 | 24 | 25 |
26 | 27 | 28 | 29 | 30 | 31 |
- 인앱결제
- MediaSession
- MotionLayout
- Android 13
- mvvm
- SwiftUI Tutorial
- Animation
- databinding
- Koin
- paging
- GCP
- mysql
- junit
- Android
- RxKotlin
- node
- 동영상
- Reactive
- PagingLib
- 테스트 자동화
- php
- node.js
- google play
- Kotlin
- SWIFTUI
- Observable
- list
- android13
- MediaPlayer
- rx
- Today
- Total
봄날은 갔다. 이제 그 정신으로 공부하자
단말에 저장된 내 메모를 보호하는 앱 만들기 - 3편 본문
서론
지난번 글에서 앱을 어설프게 기획해보았습니다.
이번 글에서는 그렇게 기획된 앱을 만들어 보겠습니다.
앱은 android 앱으로만 만들도록 하겠습니다.
아무래도 제가 사용하는 핸드폰이 “삼성 갤럭시”이다보니 android 앱을 많이 만들게 되네요.
다음 폰은 아이폰을 사야겠어요.
어떤 라이브러리를 사용하나요?
1. koin
MVVM 패턴 개발을 지원해주는 DI 라이브러리
2. coroutines
간단한 비동기 처리를 지원해주는 라이브러리로 비동기 처리를 위해 사용
3. paging lib
DB에 저장된 메모 데이터를 읽어와 보여주기 위해 사용한 페이징 라이브러리
4. BCrypt
비대칭 암호화 라이브러리로 비밀번호에 사용
5. greenrobot EventBus
화면 갱신을 위해 사용한 이벤트 버스
6. ROOM
암호화된 메모를 저장하는데 사용한 DB
우선 DB부터
경로: model/database
ROOM을 사용했고 아래와 같은 데이터 구조를 가집니다.
data class MemoData(@ColumnInfo(name = "hasEnc") var hasEnc: Boolean = true,
@ColumnInfo(name = "hint") var hint: String = "",
@ColumnInfo(name = "editedAt") var editedAt: Long = 0L,
@ColumnInfo(name = "backgroundId") var backgroundId: Int = 0,
@ColumnInfo(name = "openData") var openData: String = "",
@ColumnInfo(name = "encData") var encData: ByteArray?
)
저장된 메모 접근을 위해 아래와 같은 함수들을 지원합니다.
@Dao
interface MemoDao {
@Query("SELECT * FROM ${MemoData.MEMO_TABLE_NAME} ORDER BY `editedAt` DESC")
fun getAll(): MutableList<MemoData>
@Query("SELECT COUNT('index') FROM ${MemoData.MEMO_TABLE_NAME}")
fun getCount(): Int
@Query("SELECT * FROM ${MemoData.MEMO_TABLE_NAME} WHERE `editedAt` < :offset ORDER BY `editedAt` DESC LIMIT :limit")
fun getMemoDataList(offset: Long, limit: Int): MutableList<MemoData>
@Query("SELECT * FROM ${MemoData.MEMO_TABLE_NAME} WHERE `index` = :index ORDER BY `editedAt` DESC")
fun getMemoData(index: Int): MemoData
@Query("DELETE FROM ${MemoData.MEMO_TABLE_NAME} WHERE `index` = :index")
fun deleteMemo(index: Int)
@Insert
fun insertMemoData(data: MemoData)
@Update
fun updateMemoData(data: MemoData)
@Delete
fun deleteMemoData(data: MemoData)
}
메모 암호화 & 복호화
경로: common/Utils
메모를 암호화 복호화 하는 기능
// 메모 암호화 함수
@JvmStatic
fun encData(encKey: String, data: String): ByteArray {
var iv = ByteArray(16)
val keySpec = SecretKeySpec(hashSHA256(encKey), "AES")
val cipher_enc = Cipher.getInstance("AES/CBC/PKCS7Padding")
cipher_enc.init(Cipher.ENCRYPT_MODE, keySpec, IvParameterSpec(iv))
return cipher_enc.doFinal(data.toByteArray())
}
// 메모 복호화 함수
@JvmStatic
fun decData(encKey: String, encData: ByteArray): String {
var iv = ByteArray(16)
val keySpec = SecretKeySpec(hashSHA256(encKey), "AES")
val cipher_dec = Cipher.getInstance("AES/CBC/PKCS7Padding")
cipher_dec.init(Cipher.DECRYPT_MODE, keySpec, IvParameterSpec(iv))
val byteDecryptedText = cipher_dec.doFinal(encData)
return String(byteDecryptedText)
}
// 사용자의 패스워드를 32bit 해시키로 만들어주는 함수
@JvmStatic
private fun hashSHA256(msg: String): ByteArray {
val hash: ByteArray
try {
val md = MessageDigest.getInstance("SHA-256")
md.update(msg.toByteArray())
hash = md.digest()
} catch (e: CloneNotSupportedException) {
throw DigestException("couldn't make digest of partial content")
}
return hash
}
메모 저장
경로: viewmodel/AddMemoViewModel
메모를 저장하는 기능으로 DB 저장을 위해 coroutines을 사용하고 사용자의 선택에 따라 암호화 또는 평문으로 저장하는 기능을 지원하고
메모 저장 후 EventBus를 사용해 메모가 갱신되었음을 알림.
private fun saveMemo(hasEncrypt: Boolean, hint: String, data: String, password: String) {
GlobalScope.launch(Dispatchers.IO) {
if (hasEncrypt) {
val memo = MemoData(hasEnc.value,
hint,
System.currentTimeMillis(),
Utils.getRandomResourceId(),
"",
Utils.encData(password, data))
db.insertMemoData(memo)
} else {
val memo = MemoData(
hasEnc.value,
hint,
System.currentTimeMillis(),
Utils.getRandomResourceId(),
data,
null)
db.insertMemoData(memo)
}
EventBus.getDefault().post(EventMemoUpdate(true))
toastMessage.postValue(R.string.save_memo)
back.postValue(true)
}
}
메모 불러오기
경로: viewmodel/ViewMemoViewModel
DB에서 메모를 불어와 보여줌.
fun load() {
GlobalScope.launch(Dispatchers.IO) {
val memo = db.getMemoData(index)
if (memo == null) {
back.postValue(true)
} else {
hasEnc.postValue(memo.hasEnc)
hintString.postValue(memo.hint)
if (memo.hasEnc) {
memo.encData?.let {
dataString.postValue(Utils.decData(password, it))
}
} else {
dataString.postValue(memo.openData)
}
}
}
}
비밀번호 확인 팝업
경로: view/dialog/DialogCheckPassword
빌더 패턴을 사용해 구현하였고 사용자가 비밀번호를 틀리는 경우, 완전히 잠길때까지 몇번 남았는지와 5회 이상 비밀번호 실패 시 일시적으로 잠겼을 때 OK 버튼을 막는 기능 구현함.
일시 잠금 기능 구현을 위해 TimerTask와 coroutines 사용함.
class DialogCheckPassword {
data class Builder(
var context: Context? = null,
var messageId: Int = -1,
var noId: Int = -1,
var yesId: Int = -1,
var onFinished: (()->Unit)? = null,
var onClickNo: ((AlertDialog)->Unit)? = null,
var onClickYes: ((AlertDialog, String)->Unit)? = null
){
lateinit var dialog: AlertDialog
fun context(context: Context) = apply { this.context = context }
fun setMessage(stringId: Int) = apply { this.messageId = stringId }
fun setOnFinished(onFinished: (() -> Unit)) = apply { this.onFinished = onFinished }
fun setOnClickNo(stringId: Int, onClickNo: ((AlertDialog) -> Unit)) = apply {
this.noId = stringId
this.onClickNo = onClickNo
}
fun setOnClickYes(stringId: Int, onClickYes: ((AlertDialog, String) -> Unit)) = apply {
this.yesId = stringId
this.onClickYes = onClickYes
}
fun build(): AlertDialog {
context?.run {
val inflater = getSystemService(Context.LAYOUT_INFLATER_SERVICE) as LayoutInflater
val dialogView = inflater.inflate(R.layout.dialog_check_password, null)
dialog = AlertDialog.Builder(this)
.setView(dialogView)
.create()
dialog?.window?.let {
val windowLayoutParam = it.attributes
windowLayoutParam.gravity = Gravity.CENTER_VERTICAL or Gravity.CENTER_HORIZONTAL
it.attributes = windowLayoutParam
it.setBackgroundDrawableResource(R.drawable.bg_round10_white)
}
if(messageId <= 0){
dialogView.txtDesc.visibility = View.GONE
}else {
dialogView.txtDesc.visibility = View.VISIBLE
dialogView.txtDesc.text = getString(messageId)
}
if(noId <= 0){
dialogView.btnNo.visibility = View.GONE
}else {
dialogView.btnNo.visibility = View.VISIBLE
dialogView.btnNo.text = getString(noId)
dialogView.btnNo.setOnClickListener {
onClickNo?.invoke(dialog)
}
}
if(yesId <= 0){
dialogView.btnYes.visibility = View.GONE
}else {
dialogView.btnYes.visibility = View.VISIBLE
dialogView.btnYes.text = getString(yesId)
dialogView.btnYes.setOnClickListener {
MyApplication.prefs.lastRetryTime = System.currentTimeMillis()
if (hasCorrectPassword(dialogView)) {
MyApplication.prefs.retryCount = 0
Locker.showRemainLockCount(dialogView.txtRemainInfinityLockCount)
val password = dialogView.edtPassword.text.toString().trim()
InstancePassword.setPassword(password)
onClickYes?.invoke(dialog, password)
} else {
Locker.showRemainLockCount(dialogView.txtRemainInfinityLockCount)
MyApplication.prefs.retryCount += 1
}
}
}
showLockTimer(dialogView.txtRemainInfinityLockCount, dialogView.txtRemainTime, dialogView.btnYes)
dialog?.setOnDismissListener {
onFinished?.invoke()
}
}
return dialog
}
private fun showLockTimer(lockCount: TextView, view: TextView, btnYes: Button) {
Timer().schedule(object : TimerTask(){
override fun run(){
GlobalScope.launch(Dispatchers.Main) {
Locker.showRemainLockCount(lockCount)
if (Locker.remainLockSec() <= 0) {
view.visibility = View.GONE
btnYes.visibility = View.VISIBLE
} else {
view.text = String.format(
view.context.getString(R.string.remain_time),
Locker.remainLockSec()
)
view.visibility = View.VISIBLE
btnYes.visibility = View.INVISIBLE
}
}
}
},0, 500)
}
private fun hasCorrectPassword(dialogView: View): Boolean {
val password = dialogView.edtPassword.text.toString().trim()
if (password.isNullOrEmpty() || password.length < 4) {
Toast.makeText(dialogView.context, R.string.correct_password1, Toast.LENGTH_SHORT).show()
return false
}
if (!BCrypt.checkpw(password, MyApplication.prefs.hashKey)){
Toast.makeText(dialogView.context, R.string.correct_password3, Toast.LENGTH_SHORT).show()
return false
}
return true
}
}
}
해커로부터 앱을 보호하는 기술
경로: common/SecureUtils
기술에 대한 상세 내용은 아래 글 참고 부탁 드리겠습니다.
- 시큐어 코딩 & 난독화: https://als2019.tistory.com/83
- 루팅 & 디버깅 감지: https://als2019.tistory.com/84
- 앱 위변조 방지: https://als2019.tistory.com/85
아래 함수를 Activity & Fragment onCreate() & onResume() 함수에 추가해서 앱이 악의적인 환경에 노출 된 경우 앱이 종료되도록 처리합니다.
@JvmStatic
fun checkSecure(activity: Activity) {
if(isCrack(activity)){
Toast.makeText(activity, R.string.secure_exception_crack, Toast.LENGTH_SHORT).show()
finishApp(activity)
return
}
if(isRooting()){
Toast.makeText(activity, R.string.secure_exception_rooting, Toast.LENGTH_SHORT).show()
finishApp(activity)
return
}
if(isDebugEnable(activity) && isUsbConnected(activity)) {
Toast.makeText(activity, R.string.secure_exception_debugging, Toast.LENGTH_SHORT).show()
finishApp(activity)
return
}
}
앱을 종료하는 함수
@JvmStatic
fun finishApp(activity: Activity) {
if (Build.VERSION.SDK_INT >= 21) {
activity.finishAndRemoveTask()
} else {
activity.finish()
}
System.exit(0)
}
정리
앱은 아래 경로에 오픈되어 있습니다.
https://github.com/lee-kil-jae/MyEncNote
'앱 만들기' 카테고리의 다른 글
단말에 저장된 내 메모를 보호하는 앱 만들기 - 2편 (0) | 2022.05.05 |
---|---|
단말에 저장된 내 메모를 보호하는 앱 만들기 - 1편 (0) | 2022.04.29 |