~cytrogen/vbhelper

4c0e2fafd026253940d6b1457c6b710cddba65ae — nacabaro 1 year, 3 months ago 5d6e374 + 9871f04
Merge pull request #10 from cfogrady/APK_Import

APK Import
M .gitignore => .gitignore +4 -0
@@ 6,3 6,7 @@
local.properties

app/src/main/res/values/keys.xml

app/src/test/resources/com/github/nacabaro/vbhelper/source/com.bandai.vitalbraceletarena.apk

app/src/test/resources/com/github/nacabaro/vbhelper/source/classes.dex

A app/src/main/java/com/github/nacabaro/vbhelper/source/ApkSecretsImporter.kt => app/src/main/java/com/github/nacabaro/vbhelper/source/ApkSecretsImporter.kt +26 -0
@@ 0,0 1,26 @@
package com.github.nacabaro.vbhelper.source

import java.io.InputStream
import java.util.zip.ZipInputStream

class ApkSecretsImporter(private val dexFileSecretsImporter: SecretsImporter = DexFileSecretsImporter()): SecretsImporter {

    companion object {
        const val DEX_FILE = "classes.dex"
    }

    // importSecrets imports the secrets from the apk input stream, and validates them.
    override fun importSecrets(inputStream: InputStream): Map<UShort, Secrets> {
        ZipInputStream(inputStream).use { zip ->
            var zipEntry = zip.nextEntry
            while(zipEntry != null) {
                println("Zip Entry: ${zipEntry.name}")
                if(zipEntry.name == DEX_FILE) {
                    return dexFileSecretsImporter.importSecrets(zip)
                }
                zipEntry = zip.nextEntry
            }
            throw IllegalArgumentException("File `$DEX_FILE` is missing from apk!")
        }
    }
}

A app/src/main/java/com/github/nacabaro/vbhelper/source/DexFileSecretsImporter.kt => app/src/main/java/com/github/nacabaro/vbhelper/source/DexFileSecretsImporter.kt +95 -0
@@ 0,0 1,95 @@
package com.github.nacabaro.vbhelper.source

import com.github.cfogrady.vbnfc.data.DeviceType
import java.io.InputStream
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.nio.charset.StandardCharsets
import java.security.InvalidKeyException


class DexFileSecretsImporter: SecretsImporter {
    companion object {

        const val VBDM_SUBSTITUTION_CIPHER_IDX = 1080145
        const val BE_SUBSTITUTION_CIPHER_IDX = 1080217

        const val VBDM_HMAC_KEY_2_IDX = 1249063
        const val VBDM_HMAC_KEY_1_IDX = 1494074
        const val VBC_HMAC_KEY_1_IDX = 1241640
        const val VBC_HMAC_KEY_2_IDX = 1466955
        const val BE_HMAC_KEY_1_IDX = 1580157
        const val BE_HMAC_KEY_2_IDX = 1593759
        const val AES_KEY_IDX = 1277527

        val TEST_TAG = byteArrayOf(0x34, 0x01, 0x10, 0xff.toByte(), 0xf5.toByte(), 0x00, 0xa2.toByte())
        const val BE_TEST_TAG_PASSWORD = "be29a87e"
        const val VBDM_TEST_TAG_PASSWORD = "6ea33673"
        const val VBC_TEST_TAG_PASSWORD = "a71dfb22"
    }

    override fun importSecrets(inputStream: InputStream): Map<UShort, Secrets> {
        val deviceToSecrets = readSecrets(inputStream)
        verifySecretCorrectness(deviceToSecrets)
        return deviceToSecrets
    }

    private fun readSecrets(inputStream: InputStream): Map<UShort, Secrets> {
        val dexFile = inputStream.readBytes()
        val byteOrder = ByteOrder.BIG_ENDIAN
        val vbdmSubstitutionCipher = dexFile.sliceArray(VBDM_SUBSTITUTION_CIPHER_IDX until VBDM_SUBSTITUTION_CIPHER_IDX+(16*4)).toIntArray(byteOrder)
        val beSubstitutionCipher = dexFile.sliceArray(BE_SUBSTITUTION_CIPHER_IDX until BE_SUBSTITUTION_CIPHER_IDX+(16*4)).toIntArray(byteOrder)
        val aesKey = dexFile.sliceArray(AES_KEY_IDX until AES_KEY_IDX+24).toString(StandardCharsets.UTF_8)
        val secretsByDevices = mapOf(
            Pair(DeviceType.VitalSeriesDeviceType, buildSecrets(dexFile, VBDM_HMAC_KEY_1_IDX, VBDM_HMAC_KEY_2_IDX, aesKey, vbdmSubstitutionCipher)),
            Pair(DeviceType.VitalBraceletBEDeviceType, buildSecrets(dexFile, BE_HMAC_KEY_1_IDX, BE_HMAC_KEY_2_IDX, aesKey, beSubstitutionCipher)),
            Pair(DeviceType.VitalCharactersDeviceType, buildSecrets(dexFile, VBC_HMAC_KEY_1_IDX, VBC_HMAC_KEY_2_IDX, aesKey, vbdmSubstitutionCipher)),
        )
        return secretsByDevices
    }

    private fun buildSecrets(dexFile: ByteArray, hmacKeyIdx1: Int, hmacKeyIdx2: Int, aesKey: String, substitutionCipher: IntArray): Secrets {
        val hmacKey1 = dexFile.sliceArray(hmacKeyIdx1 until hmacKeyIdx1+24).toString(StandardCharsets.UTF_8)
        val hmacKey2 = dexFile.sliceArray(hmacKeyIdx2 until hmacKeyIdx2+24).toString(StandardCharsets.UTF_8)
        return Secrets(hmacKey1, hmacKey2, aesKey, substitutionCipher)
    }

    @OptIn(ExperimentalStdlibApi::class)
    private fun verifySecretCorrectness(deviceToSecrets: Map<UShort, Secrets>) {
        for (keyValue in deviceToSecrets) {
            when(keyValue.key) {
                DeviceType.VitalBraceletBEDeviceType -> {
                    val result = keyValue.value.toCryptographicTransformer().createNfcPassword(
                        TEST_TAG
                    )
                    if( result.toHexString() != BE_TEST_TAG_PASSWORD) {
                        throw InvalidKeyException("Secrets were loaded, but were unsuccessful at generating the test password: ${result.toHexString()}")
                    }
                }
                DeviceType.VitalCharactersDeviceType -> {
                    val result = keyValue.value.toCryptographicTransformer().createNfcPassword(
                        TEST_TAG
                    )
                    if( result.toHexString() != VBC_TEST_TAG_PASSWORD) {
                        throw InvalidKeyException("Secrets were loaded, but were unsuccessful at generating the test password: ${result.toHexString()}")
                    }
                }
                DeviceType.VitalSeriesDeviceType -> {
                    val result = keyValue.value.toCryptographicTransformer().createNfcPassword(
                        TEST_TAG
                    )
                    if( result.toHexString() != VBDM_TEST_TAG_PASSWORD) {
                        throw InvalidKeyException("Secrets were loaded, but were unsuccessful at generating the test password: ${result.toHexString()}")
                    }
                }
            }
        }
    }
}

fun ByteArray.toIntArray(byteOrder: ByteOrder): IntArray {
    require(this.size % 4 == 0) { "Number of bytes must be multiple of 4 to convert into 32-bit words" }
    val values = IntArray(this.size / 4)
    ByteBuffer.wrap(this).order(byteOrder).asIntBuffer()[values]
    return values
}
\ No newline at end of file

A app/src/main/java/com/github/nacabaro/vbhelper/source/Secrets.kt => app/src/main/java/com/github/nacabaro/vbhelper/source/Secrets.kt +9 -0
@@ 0,0 1,9 @@
package com.github.nacabaro.vbhelper.source

import com.github.cfogrady.vbnfc.CryptographicTransformer

data class Secrets(val hmacKey1: String, val hmacKey2: String, val aesKey: String, val substitutionCipher: IntArray) {
    fun toCryptographicTransformer(): CryptographicTransformer {
        return CryptographicTransformer(hmacKey1, hmacKey2, aesKey, substitutionCipher)
    }
}

A app/src/main/java/com/github/nacabaro/vbhelper/source/SecretsImporter.kt => app/src/main/java/com/github/nacabaro/vbhelper/source/SecretsImporter.kt +7 -0
@@ 0,0 1,7 @@
package com.github.nacabaro.vbhelper.source

import java.io.InputStream

fun interface SecretsImporter {
    fun importSecrets(inputStream: InputStream): Map<UShort, Secrets>
}
\ No newline at end of file

A app/src/test/java/com/github/nacabaro/vbhelper/source/ApkSecretsImporterTest.kt => app/src/test/java/com/github/nacabaro/vbhelper/source/ApkSecretsImporterTest.kt +73 -0
@@ 0,0 1,73 @@
package com.github.nacabaro.vbhelper.source

import com.github.cfogrady.vbnfc.data.DeviceType
import org.junit.Assert
import org.junit.Test
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.io.File
import java.net.URL
import java.nio.charset.StandardCharsets
import java.util.zip.ZipEntry
import java.util.zip.ZipOutputStream

class ApkSecretsImporterTest {

    @Test
    fun testThatRealImportSecretsHasAllDeviceTypes() {
        val apkFileSecretsImporter = ApkSecretsImporter()
        val url = getAndAssertApkFile()
        val file = File(url.path)
        file.inputStream().use {
            val deviceIdToSecrets = apkFileSecretsImporter.importSecrets(it)
            Assert.assertNotNull("BE Device Type", deviceIdToSecrets[DeviceType.VitalBraceletBEDeviceType])
            Assert.assertNotNull("VBDM Device Type", deviceIdToSecrets[DeviceType.VitalSeriesDeviceType])
            Assert.assertNotNull("VBC Device Type", deviceIdToSecrets[DeviceType.VitalCharactersDeviceType])
        }
    }

    @Test
    fun testThatApkSecretsImporterCallsDexSecretImporterOnDexFile() {
        val expectedDexContents = byteArrayOf(0, 1, 2, 3, 4, 5, 6, 7, 6, 5, 4, 3, 2, 1)
        var foundFile = false
        val apkFileSecretsImporter = ApkSecretsImporter {
            val inputStreamContents = it.readAllBytes()
            Assert.assertTrue("Unexpected file contents received by DexSecretsImporter", inputStreamContents.contentEquals(expectedDexContents))
            foundFile = true
            emptyMap()
        }
        val apkBytes = constructTestApk(expectedDexContents)
        ByteArrayInputStream(apkBytes).use {
            apkFileSecretsImporter.importSecrets(it)
        }
        Assert.assertTrue("${ApkSecretsImporter.DEX_FILE} not found", foundFile)
    }

    fun constructTestApk(dexContents: ByteArray): ByteArray {
        val byteArrayOutputStream = ByteArrayOutputStream()
        ZipOutputStream(byteArrayOutputStream).use { zipOutputStream ->
            zipOutputStream.putNextEntry(ZipEntry("dummy.txt"))
            zipOutputStream.write("This is a text file".toByteArray(StandardCharsets.UTF_8))
            zipOutputStream.putNextEntry(ZipEntry("AndroidManifest.xml"))
            zipOutputStream.write("Malformed xml!".toByteArray(StandardCharsets.UTF_8))
            zipOutputStream.putNextEntry(ZipEntry("assets/"))
            zipOutputStream.putNextEntry(ZipEntry("assets/bad.assets"))
            zipOutputStream.write("Malformed asset!".toByteArray(StandardCharsets.UTF_8))
            zipOutputStream.putNextEntry(ZipEntry(ApkSecretsImporter.DEX_FILE))
            zipOutputStream.write(dexContents)
        }
        return byteArrayOutputStream.toByteArray()
    }

    private fun getAndAssertApkFile(): URL {
        val url = javaClass.getResource("com.bandai.vitalbraceletarena.apk")
        if(url == null) {
            Assert.assertTrue("""
                Create `resources\com\github\nacabaro\vbhelper\source` within the src/test directory.
                Add com.bandai.vitalbraceletarena.apk (the official apk) in the above directory. It
                should never be checked in and should be on the .gitignore.
            """.trimIndent(), false)
        }
        return url!!
    }
}
\ No newline at end of file

A app/src/test/java/com/github/nacabaro/vbhelper/source/DexFileSecretsImporterTest.kt => app/src/test/java/com/github/nacabaro/vbhelper/source/DexFileSecretsImporterTest.kt +74 -0
@@ 0,0 1,74 @@
package com.github.nacabaro.vbhelper.source

import com.github.cfogrady.vbnfc.data.DeviceType
import org.junit.Assert
import org.junit.Test
import java.io.ByteArrayInputStream
import java.io.File
import java.net.URL
import java.nio.ByteOrder
import java.security.InvalidKeyException


class DexFileSecretsImporterTest {

    @Test
    fun testThatImportSecretsHasAllDeviceTypes() {
        val dexFileSecretsImporter = DexFileSecretsImporter()
        val url = getAndAssertClassesDexFile()
        val file = File(url.path)
        file.inputStream().use {
            val deviceIdToSecrets = dexFileSecretsImporter.importSecrets(it)
            Assert.assertNotNull("BE Device Type", deviceIdToSecrets[DeviceType.VitalBraceletBEDeviceType])
            Assert.assertNotNull("VBDM Device Type", deviceIdToSecrets[DeviceType.VitalSeriesDeviceType])
            Assert.assertNotNull("VBC Device Type", deviceIdToSecrets[DeviceType.VitalCharactersDeviceType])
        }
    }

    @Test
    fun testThatImportWrongSecretsThrows() {
        val dexFileSecretsImporter = DexFileSecretsImporter()
        val url = getAndAssertClassesDexFile()
        val file = File(url.path)
        val content = file.readBytes()
        val badCipher = intArrayOf(0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15).toByteArray(ByteOrder.BIG_ENDIAN)
        badCipher.copyInto(content, DexFileSecretsImporter.BE_SUBSTITUTION_CIPHER_IDX)
        ByteArrayInputStream(content).use {
            Assert.assertThrows("Secrets are validated", InvalidKeyException::class.java) {
                dexFileSecretsImporter.importSecrets(it)
            }
        }
    }

    private fun getAndAssertClassesDexFile(): URL {
        val url = javaClass.getResource("classes.dex")
        if(url == null) {
            Assert.assertTrue("""
                Create `resources\com\github\nacabaro\vbhelper\source` within the src/test directory.
                Add classes.dex from the official apk in the above directory. It should never be
                checked in and should be on the .gitignore.
            """.trimIndent(), false)
        }
        return url!!
    }
}

fun IntArray.toByteArray(byteOrder: ByteOrder = ByteOrder.nativeOrder()): ByteArray {
    val byteArray = ByteArray(this.size*4)
    for(i in this.indices) {
        val byteArrayIndex = i*4
        this[i].toByteArray(byteArray, byteArrayIndex, byteOrder)
    }
    return byteArray
}

fun Int.toByteArray(bytes: ByteArray, dstIndex: Int, byteOrder: ByteOrder = ByteOrder.nativeOrder()) {
    val asUInt = this.toUInt()
    for(i in 0 until 4) {
        if(byteOrder == ByteOrder.LITTLE_ENDIAN) {
            bytes[i+dstIndex] = ((asUInt shr 8*i) and 255u).toByte()
        } else {
            bytes[(3-i) + dstIndex] = ((asUInt shr 8*i) and 255u).toByte()
        }
    }
}
\ No newline at end of file