봄날은 갔다. 이제 그 정신으로 공부하자

단말에 저장된 내 메모를 보호하는 앱 만들기 - 3편 본문

앱 만들기

단말에 저장된 내 메모를 보호하는 앱 만들기 - 3편

길재의 그 정신으로 공부하자 2022. 5. 13. 11:11

서론

지난번 글에서 앱을 어설프게 기획해보았습니다.

이번 글에서는 그렇게 기획된 앱을 만들어 보겠습니다.

앱은 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

 

GitHub - lee-kil-jae/MyEncNote: 나의 암호화 메모장

나의 암호화 메모장. Contribute to lee-kil-jae/MyEncNote development by creating an account on GitHub.

github.com

 

Comments