M app/src/main/java/com/github/nacabaro/vbhelper/source/ApkSecretsImporter.kt => app/src/main/java/com/github/nacabaro/vbhelper/source/ApkSecretsImporter.kt +3 -2
@@ 3,13 3,14 @@ package com.github.nacabaro.vbhelper.source
import java.io.InputStream
import java.util.zip.ZipInputStream
-class ApkSecretsImporter(private val dexFileSecretsImporter: DexFileSecretsImporter = DexFileSecretsImporter()) {
+class ApkSecretsImporter(private val dexFileSecretsImporter: SecretsImporter = DexFileSecretsImporter()): SecretsImporter {
companion object {
const val DEX_FILE = "classes.dex"
}
- fun importSecrets(inputStream: InputStream): Map<UShort, Secrets> {
+ // 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) {
M app/src/main/java/com/github/nacabaro/vbhelper/source/DexFileSecretsImporter.kt => app/src/main/java/com/github/nacabaro/vbhelper/source/DexFileSecretsImporter.kt +48 -4
@@ 5,9 5,10 @@ import java.io.InputStream
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.nio.charset.StandardCharsets
+import java.security.InvalidKeyException
-class DexFileSecretsImporter {
+class DexFileSecretsImporter: SecretsImporter {
companion object {
const val VBDM_SUBSTITUTION_CIPHER_IDX = 1080145
@@ 20,20 21,31 @@ class DexFileSecretsImporter {
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
}
- fun importSecrets(inputStream: InputStream): Map<UShort, Secrets> {
+ 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 cryptographicTransformerByDevices = mapOf(
+ 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 cryptographicTransformerByDevices
+ return secretsByDevices
}
private fun buildSecrets(dexFile: ByteArray, hmacKeyIdx1: Int, hmacKeyIdx2: Int, aesKey: String, substitutionCipher: IntArray): Secrets {
@@ 41,6 53,38 @@ class DexFileSecretsImporter {
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 {
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
M app/src/test/java/com/github/nacabaro/vbhelper/source/ApkSecretsImporterTest.kt => app/src/test/java/com/github/nacabaro/vbhelper/source/ApkSecretsImporterTest.kt +53 -14
@@ 3,14 3,63 @@ 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 {
- @OptIn(ExperimentalStdlibApi::class)
@Test
- fun importSecretsTest() {
- val apkSecretsImporter = ApkSecretsImporter()
+ 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("""
@@ 19,16 68,6 @@ class ApkSecretsImporterTest {
should never be checked in and should be on the .gitignore.
""".trimIndent(), false)
}
- val file = File(url.path)
- val testTagId = byteArrayOf(0x04, 0x40, 0xaf.toByte(), 0xa2.toByte(), 0xee.toByte(), 0x0f, 0x90.toByte())
- file.inputStream().use {
- val deviceIdToCryptographicTransformer = apkSecretsImporter.importSecrets(it)
- var password = deviceIdToCryptographicTransformer[DeviceType.VitalBraceletBEDeviceType]!!.toCryptographicTransformer().createNfcPassword(testTagId)
- Assert.assertEquals("5651b1c8", password.toHexString())
- password = deviceIdToCryptographicTransformer[DeviceType.VitalSeriesDeviceType]!!.toCryptographicTransformer().createNfcPassword(testTagId)
- Assert.assertEquals("dd2ceb84", password.toHexString())
- password = deviceIdToCryptographicTransformer[DeviceType.VitalCharactersDeviceType]!!.toCryptographicTransformer().createNfcPassword(testTagId)
- Assert.assertEquals("515e0c12", password.toHexString())
- }
+ return url!!
}
}=
\ No newline at end of file
M app/src/test/java/com/github/nacabaro/vbhelper/source/DexFileSecretsImporterTest.kt => app/src/test/java/com/github/nacabaro/vbhelper/source/DexFileSecretsImporterTest.kt +52 -12
@@ 3,14 3,44 @@ 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 {
- @OptIn(ExperimentalStdlibApi::class)
+
@Test
- fun importSecretsTest() {
+ 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("""
@@ 19,16 49,26 @@ class DexFileSecretsImporterTest {
checked in and should be on the .gitignore.
""".trimIndent(), false)
}
- val file = File(url.path)
- val testTagId = byteArrayOf(0x04, 0x40, 0xaf.toByte(), 0xa2.toByte(), 0xee.toByte(), 0x0f, 0x90.toByte())
- file.inputStream().use {
- val deviceIdToCryptographicTransformer = dexFileSecretsImporter.importSecrets(it)
- var password = deviceIdToCryptographicTransformer[DeviceType.VitalBraceletBEDeviceType]!!.toCryptographicTransformer().createNfcPassword(testTagId)
- Assert.assertEquals("5651b1c8", password.toHexString())
- password = deviceIdToCryptographicTransformer[DeviceType.VitalSeriesDeviceType]!!.toCryptographicTransformer().createNfcPassword(testTagId)
- Assert.assertEquals("dd2ceb84", password.toHexString())
- password = deviceIdToCryptographicTransformer[DeviceType.VitalCharactersDeviceType]!!.toCryptographicTransformer().createNfcPassword(testTagId)
- Assert.assertEquals("515e0c12", password.toHexString())
+ 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