Android2022. 8. 2. 11:49

최근 구글 등록중에 아래와 같은 오류 메시지를 받았다.

 

잠시 당황??? 하고 .. 알아보았는데..

 

AES암호화 시 키유출에 대한 안드로이드의 잠재적 위험성이 이미 유명해져서 더이상 방관하지 않겠다는 구글의 의지를 확인하게 되었다.

 

해킹기술도 많이 알려져서 쉽게 가능한것도 문제의 원인인데 

  1. 안드로이드 디컴파일을 통한 코드 확인 가능 : 해커들은 난독화를 거쳤더라도 어떻게는 패턴을 찾아 키를 알아내려고하고 생각보다 쉽다.
  2. jni로 작성하더라도 objdump를 통해 .data, .text섹션을 뒤지다 보면 발견도 가능
  3. SharedPreference 로 작성된 xml파일을 내부저장소에 저장 하더라도 추출 가능

 

위 방법으로 또 알아낼수도 있다. ECB가 아니라 CBC알고리즘을 사용하여 IV가 추가로 필요하더라도 IV도 동일한 방법으로 유추가 가능.

 

이러저러한 이유로 아뭏든 못 믿겠다고 구글은 판단하고 KeyStore를 사용하란다.

 

이제 KeyStore에 대해 알아보겠다.

 

문서(https://developer.android.com/training/articles/keystore)에 따르면 “Android KeyStore”시스템을 사용하면 암호화키를 컨테이너안에 저장하여 기기에서 키를 추출하기 어렵게 할수 있고, 키저장소에 키가 저장되면 키자료(key material)은 유출이 안되면서 암호화작업에 사용이 가능하다. 시스템에서 키사용시가나 방법을 제한할수도 있다. 즉 키사용을 위해 사용자 인증을 요구하거나 특정암호화모드에서만 해당 키를 사용하도록 제한할수도 있다”

 

키저장소라는 이름에서 알수 있듯이 내가 사용하고자 하는 키를 안전하게 저장해주고 내가 사용하고 싶은때 꺼내서 사용할수 있다는것이다. 이제 키의 안정성에 대해 나는 완전히 안전하게 사용하고 있다고 당당하게 말할 수 있게 해줬다는 얘기이다.

 

아래 링크에 완전히 대체 가능한 코드샘플이 있다.

키를 RSA로 키쌍으로 생성하고 개인키로 암호화하고 공개키로 복호화하는 방식을 사용하였다.

 

https://gist.github.com/FrancescoJo/b8280cff14f1254f2185a9c2e927565e

 

import android.os.Build
import android.security.KeyPairGeneratorSpec
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import android.util.Base64
import timber.log.Timber
import java.math.BigInteger
import java.security.GeneralSecurityException
import java.security.KeyPairGenerator
import java.security.KeyStore
import java.security.Security
import java.security.spec.RSAKeyGenParameterSpec
import java.security.spec.RSAKeyGenParameterSpec.F4
import java.util.*
import javax.crypto.Cipher
import javax.security.auth.x500.X500Principal

/**
 * String encryption/decryption helper with system generated key.
 *
 * Results generated by this class are very difficult but not impossible to break. Since Android is
 * easy to decompile and attacker knows how the key generation and usage is implemented. It means
 * replay attack is still possible so attackers have a reliable chance better than brute force.
 *
 * Therefore, do not plant any values on this class which maybe used as attack vectors - such as
 * unique device identifiers(MAC address, OS version, etc.), nano timestamp, constants not related
 * to cipher specifications, etc.
 *
 * @author Francesco Jo(nimbusob@gmail.com)
 * @since 25 - May - 2018
 */
object AndroidRsaCipherHelper {
    /** All inputs are must be shorter than 2048 bits(256 bytes) */
    private const val KEY_LENGTH_BIT = 2048

    // Let's think about this problem in 2043
    private const val VALIDITY_YEARS = 25

    private const val KEY_PROVIDER_NAME = "AndroidKeyStore"
    private const val CIPHER_ALGORITHM =
            "${KeyProperties.KEY_ALGORITHM_RSA}/" +
            "${KeyProperties.BLOCK_MODE_ECB}/" +
            KeyProperties.ENCRYPTION_PADDING_RSA_PKCS1

    private lateinit var keyEntry: KeyStore.Entry

    // Private only backing field
    @Suppress("ObjectPropertyName")
    private var _isSupported = false

    val isSupported: Boolean
        get() = _isSupported
    
    private lateinit var appContext: Context

    internal fun init(applicationContext: Context) {
        if (isSupported) {
            Timber.w("Already initialised - Do not attempt to initialise this twice")
            return
        }

        this.appContext = applicationContext
        val alias = "${appContext.packageName}.rsakeypairs"
        val keyStore = KeyStore.getInstance("AndroidKeyStore").apply({
            load(null)
        })

        val result: Boolean
        result = if (keyStore.containsAlias(alias)) {
            true
        } else {
            Timber.v("No keypair for %s, creating a new one", alias)

            if (Build.VERSION.SDK_INT > Build.VERSION_CODES.LOLLIPOP_MR1 && initAndroidM(alias)) {
                true
            } else {
                initAndroidL(alias)
            }
        }

        this.keyEntry = keyStore.getEntry(alias, null)
        _isSupported = result
    }

    private fun initAndroidM(alias: String): Boolean {
        try {
            with(KeyPairGenerator.getInstance(KeyProperties.KEY_ALGORITHM_RSA, KEY_PROVIDER_NAME), {
                val spec = KeyGenParameterSpec.Builder(alias,
                        KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT)
                        .setAlgorithmParameterSpec(RSAKeyGenParameterSpec(KEY_LENGTH_BIT, F4))
                        .setBlockModes(KeyProperties.BLOCK_MODE_CBC)
                        .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_RSA_PKCS1)
                        .setDigests(KeyProperties.DIGEST_SHA512,
                                KeyProperties.DIGEST_SHA384,
                                KeyProperties.DIGEST_SHA256)
                        /*
                         * Setting true only permit the private key to be used if the user authenticated
                         * within the last five minutes.
                         */
                        .setUserAuthenticationRequired(false)
                        .build()
                initialize(spec)
                generateKeyPair()
            })
            Timber.i("Random keypair with %s/%s/%s is created.", KeyProperties.KEY_ALGORITHM_RSA,
                    KeyProperties.BLOCK_MODE_CBC, KeyProperties.ENCRYPTION_PADDING_RSA_PKCS1)

            return true
        } catch (e: GeneralSecurityException) {
            /*
             * Nonsense, but some devices manufactured by developing countries have actual problem
             * Consider using JCE substitutes like Spongy castle(Bouncy castle for android)
             */
            Timber.w(e, "It seems that this device does not support RSA algorithm!!")

            return false
        }
    }

    /**
     * Tested and verified working on Nexus 5s API Level 21, it is not guaranteed that this logic is valid on
     * all Android L devices.
     */
    private fun initAndroidL(alias: String): Boolean {
        try {
            with(KeyPairGenerator.getInstance("RSA", KEY_PROVIDER_NAME), {
                val start = Calendar.getInstance(Locale.ENGLISH)
                val end = Calendar.getInstance(Locale.ENGLISH).apply { add(Calendar.YEAR, VALIDITY_YEARS) }
                val spec = KeyPairGeneratorSpec.Builder(appContext)
                        .setKeySize(KEY_LENGTH_BIT)
                        .setAlias(alias)
                        .setSubject(X500Principal("CN=francescojo.github.com, OU=Android dev, O=Francesco Jo, L=Chiyoda, ST=Tokyo, C=JP"))
                        .setSerialNumber(BigInteger.ONE)
                        .setStartDate(start.time)
                        .setEndDate(end.time)
                        .build()
                initialize(spec)
                generateKeyPair()
            })
            Timber.i("Random RSA algorithm keypair is created.")

            return true
        } catch (e: GeneralSecurityException) {
            Timber.w(e, "It seems that this device does not support encryption!!")

            return false
        }
    }

    /**
     * Beware that input must be shorter than 256 bytes. The length limit of plainText could be dramatically
     * shorter than 256 letters in certain character encoding, such as UTF-8.
     */
    fun encrypt(plainText: String): String {
        if (!_isSupported) {
            return plainText
        }

        val cipher = Cipher.getInstance(CIPHER_ALGORITHM).apply({
            init(Cipher.ENCRYPT_MODE, (keyEntry as KeyStore.PrivateKeyEntry).certificate.publicKey)
        })
        val bytes = plainText.toByteArray(Charsets.UTF_8)
        val encryptedBytes = cipher.doFinal(bytes)
        val base64EncryptedBytes = Base64.encode(encryptedBytes, Base64.DEFAULT)

        return String(base64EncryptedBytes)
    }

    fun decrypt(base64EncryptedCipherText: String): String {
        if (!_isSupported) {
            return base64EncryptedCipherText
        }

        val cipher = Cipher.getInstance(CIPHER_ALGORITHM).apply({
            init(Cipher.DECRYPT_MODE, (keyEntry as KeyStore.PrivateKeyEntry).privateKey)
        })
        val base64EncryptedBytes = base64EncryptedCipherText.toByteArray(Charsets.UTF_8)
        val encryptedBytes = Base64.decode(base64EncryptedBytes, Base64.DEFAULT)
        val decryptedBytes = cipher.doFinal(encryptedBytes)

        return String(decryptedBytes)
    }
}

 

앱은 재등록하였고 이번엔 통과되었다. ^^

 

 

Posted by 삼스
iOS2022. 7. 29. 10:38

iOS에서 mac과 xcode가 없이도 로그를 확인할 수 있도록 작성한 로거앱을 소개하겠다.

이 앱은 안드로이드와 달리 별도의 앱을 폰에 설치하여 로그를 확인할 수 있는 방법이 iOS에서는 제공되지 않는것인지 내가 못찾는것인지 찾을수가 없어서 그냥 내가 필요해서 만든 앱이다.

 

https://github.com/samse/SwiftSocketLogger

 

GitHub - samse/SwiftSocketLogger: Swift socket logger

Swift socket logger. Contribute to samse/SwiftSocketLogger development by creating an account on GitHub.

github.com

github에서 소스를 다운받아서 빌드하여 앱을 설치해야 한다. 스토어에서 설치 가능하도록 스토어게시를 시도해볼 생각이다.

위 소스로 앱을 설치하고 실행하면 아래 처럼 로그를 확인할 수 있다.

 

 

소켓으로 로그를 수신받아 로깅을 하는 방식이며 백그라운드에서 로그를 수집할 수 있어야 하기 때문에 백그라운드앱으로 작성되었다. 백그라운드 동작 방식은 무음 mp3를 재생하는 단순하고 쉬운 방식으로 작성하였다. 이겄 때문에 스토어게시가 안될수도 있겠단 생각이 들긴 한데 리젝되면 다른 방식으로 수정해볼 예정이다.

 

이제 로거 서버에 해당하는 로거앱을 설치하였으니 로그를 발생시키기 위해서 필요한 클라이언트앱에서는 어떻게 해야 하는지 설명하겠다.

github 페이지에서도 설명하였지만 일단 로거앱의 소스중에 swiftsocket 폴더의 소스를 클라이언트 앱으로 모두 가져와야 한다.

그리고 다음의 클라이언트용 로거 클래스를 추가한다.

 

class SocketLogger : NSObject {
    enum LoggerStatus: Int { case disconnected=0, connecting, connected }
    var status: LoggerStatus = .disconnected

    func sendLogs(_ logStr: String, completion: (() -> ())?) {
        if status == .connected {
            return
        }
        status = .connecting
        print("Logger sendLogs : \(logStr)")
        let client = TCPClient(address: "127.0.0.1", port: 2532)
        switch client.connect(timeout: 10) {
          case .success:
            status = .connected
            let data = logStr.data(using: .utf8)
            let _ = client.send(data: data!)
            DispatchQueue.global().asyncAfter(deadline: .now() + 0.2) {
                self.status = .disconnected
                if let completion = completion { completion() }
            }
          case .failure(let error):
            print("failure: \(error)")
            status = .disconnected
        }
    }
}

 

그리고 소켓 로거클래스를 사용하는 로거도 추가한다.

 

class Logger {
    static func debug(_ message: String?) {
        printLog(level: "🐛 DEBUG", message: message)
    }
    
    /// 경고 로그
    static func warning(_ message: String?) {
        printLog(level: "⚠️ WARNING", message: message)
    }
    
    /// 오류 로그
    static func error(_ message: String?) {
        printLog(level: "🚫 ERROR", message: message)
    }
    
    /// 정보 로그
    static func info(_ message: String?) {
        printLog(level: "ℹ️ INFO", message: message)
    }

    func printLog(level: String, message: String) {
        let logStr = "[\(level)] \(message ?? "")"
        self.appendLog(logStr)
    }
    
    static var logQueue = [String]()
    static let lock = NSLock()
    private static func appendLog(_ logStr: String) {
        print("\(logStr)")
        lock.lock(); defer { lock.unlock() }
        logQueue.append(logStr)
        triggerLogging()
    }
    private static func getLog() -> String? {
        if logQueue.count > 0 {
            return logQueue.first
        }
        return nil
    }
    private static func removeLog() {
        if logQueue.count > 0 {
            logQueue.removeFirst()
        }
    }
    
    static var socketLogger: SocketLogger = SocketLogger()
    private static func triggerLogging() {
        if let logStr = getLog() {
            socketLogger.sendLogs(logStr) {
                removeLog()
                triggerLogging()
            }
        }
    }
}

 

이제 앱코드내에서 다음과 같이 로거를 호출하면 SwiftSocketLogger앱에 해당 로그가 나오게 된다.

Logger.debug("my log" )
Logger.info("my info log")

아래 그림에 보는것과 같이 우상단의 디버그레벨 문구를 누르면 디버그레벨을 변경이 가능하며 좌측의 텍스트필드에 값을 입력하면 해당 값으로 로그를 필터링해서 볼수 있다.

 

 

단말만으로 디버깅을 해야 하는 상황에서 유용하게 사용할 만한 방법이라고 생각되며 잘 사용하고 있는 앱이다 다른 개발자들도 일부 불편함을 해소하는데 사용되었으면 좋겠다.

 

Posted by 삼스
iOS2022. 7. 28. 16:41

가볍고 단순한 안드로이드와 iOS를 위한 하이브리드 라이브러리를 소개하겠다.

 

iOS용

https://github.com/samse/WKBridgeKit

 

GitHub - samse/WKBridgeKit

Contribute to samse/WKBridgeKit development by creating an account on GitHub.

github.com

 

안드로이드용

https://github.com/samse/NBridgeKit

 

GitHub - samse/NBridgeKit

Contribute to samse/NBridgeKit development by creating an account on GitHub.

github.com

 

아주 가볍게 하이브리드앱을 개발할 수 있도록 해준다.

하이브리드앱은 웹에서 javascript로 native기능을 사용할 수 있게 해주는 플러그인을 제공해주어야 하는데 이 때 사용되어야 하는 javascript core는 아래 위치에서 찾을 수 있다.

 

https://github.com/samse/WKBridgeKit/tree/main/Sample/Sample/www

 

위 위치에서 sample.js를 열어보면 어떻게 플러그인을 사용가능한지 확인 할 수 있다.

 

앱정보를 읽어오는 javascript code샘플은 다음과 같다.

    nbridge.app.appInfo().then(function(result) {
        alert('app version : ' + result.version + "/" + result.build + "\napp name: " + result.name);
    }, function(error) {
        alert(error);
    })

애플 앱스토어에 샘플앱이 게시되어 있으니 한번씩 설치해서 확인해 보는것이 도움이 될것 같다.

 

https://apps.apple.com/kr/app/nbridge/id1628984500

 

‎nBridge

‎Swift기반으로 WKWebView와 Native를 연동할 수 있는 기본기능을 제공합니다. 아래 사이트에서 자세한 정보를 얻을 수 있으며 이를 기반으로 아주 가벼운 Hybrid앱을 작성할 수 있습니다. https://github.c

apps.apple.com

 

 

하이브리드 기술이 일반화가 많이 되었긴 하지만 가볍게 사용가능한 라이브러리가 있으면 좋겠다는 생각에 정리하여 배포하였다. 다른 이들에게 도움이 조금이라도 되면 보람이 될것 같다.

소스를 보면 플러그인을 추가하는 방법도 알수 있겠지만 쉽게 설명하는 글을 한번 더 정리해볼 예정이다.

Posted by 삼스