Verify Secret correctness as part of loading.

Improve tests
This commit is contained in:
Christopher O'Grady 2025-01-08 22:12:03 -05:00
parent f4974c8705
commit 9871f0420f
5 changed files with 163 additions and 32 deletions

View File

@ -3,13 +3,14 @@ package com.github.nacabaro.vbhelper.source
import java.io.InputStream import java.io.InputStream
import java.util.zip.ZipInputStream import java.util.zip.ZipInputStream
class ApkSecretsImporter(private val dexFileSecretsImporter: DexFileSecretsImporter = DexFileSecretsImporter()) { class ApkSecretsImporter(private val dexFileSecretsImporter: SecretsImporter = DexFileSecretsImporter()): SecretsImporter {
companion object { companion object {
const val DEX_FILE = "classes.dex" 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 -> ZipInputStream(inputStream).use { zip ->
var zipEntry = zip.nextEntry var zipEntry = zip.nextEntry
while(zipEntry != null) { while(zipEntry != null) {

View File

@ -5,9 +5,10 @@ import java.io.InputStream
import java.nio.ByteBuffer import java.nio.ByteBuffer
import java.nio.ByteOrder import java.nio.ByteOrder
import java.nio.charset.StandardCharsets import java.nio.charset.StandardCharsets
import java.security.InvalidKeyException
class DexFileSecretsImporter { class DexFileSecretsImporter: SecretsImporter {
companion object { companion object {
const val VBDM_SUBSTITUTION_CIPHER_IDX = 1080145 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_1_IDX = 1580157
const val BE_HMAC_KEY_2_IDX = 1593759 const val BE_HMAC_KEY_2_IDX = 1593759
const val AES_KEY_IDX = 1277527 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"
} }
fun importSecrets(inputStream: InputStream): Map<UShort, Secrets> { 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 dexFile = inputStream.readBytes()
val byteOrder = ByteOrder.BIG_ENDIAN val byteOrder = ByteOrder.BIG_ENDIAN
val vbdmSubstitutionCipher = dexFile.sliceArray(VBDM_SUBSTITUTION_CIPHER_IDX until VBDM_SUBSTITUTION_CIPHER_IDX+(16*4)).toIntArray(byteOrder) 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 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 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.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.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)), 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 { 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) val hmacKey2 = dexFile.sliceArray(hmacKeyIdx2 until hmacKeyIdx2+24).toString(StandardCharsets.UTF_8)
return Secrets(hmacKey1, hmacKey2, aesKey, substitutionCipher) 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 { fun ByteArray.toIntArray(byteOrder: ByteOrder): IntArray {

View File

@ -0,0 +1,7 @@
package com.github.nacabaro.vbhelper.source
import java.io.InputStream
fun interface SecretsImporter {
fun importSecrets(inputStream: InputStream): Map<UShort, Secrets>
}

View File

@ -3,14 +3,63 @@ package com.github.nacabaro.vbhelper.source
import com.github.cfogrady.vbnfc.data.DeviceType import com.github.cfogrady.vbnfc.data.DeviceType
import org.junit.Assert import org.junit.Assert
import org.junit.Test import org.junit.Test
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.io.File 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 { class ApkSecretsImporterTest {
@OptIn(ExperimentalStdlibApi::class)
@Test @Test
fun importSecretsTest() { fun testThatRealImportSecretsHasAllDeviceTypes() {
val apkSecretsImporter = ApkSecretsImporter() 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") val url = javaClass.getResource("com.bandai.vitalbraceletarena.apk")
if(url == null) { if(url == null) {
Assert.assertTrue(""" Assert.assertTrue("""
@ -19,16 +68,6 @@ class ApkSecretsImporterTest {
should never be checked in and should be on the .gitignore. should never be checked in and should be on the .gitignore.
""".trimIndent(), false) """.trimIndent(), false)
} }
val file = File(url.path) return url!!
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())
}
} }
} }

View File

@ -3,14 +3,44 @@ package com.github.nacabaro.vbhelper.source
import com.github.cfogrady.vbnfc.data.DeviceType import com.github.cfogrady.vbnfc.data.DeviceType
import org.junit.Assert import org.junit.Assert
import org.junit.Test import org.junit.Test
import java.io.ByteArrayInputStream
import java.io.File import java.io.File
import java.net.URL
import java.nio.ByteOrder
import java.security.InvalidKeyException
class DexFileSecretsImporterTest { class DexFileSecretsImporterTest {
@OptIn(ExperimentalStdlibApi::class)
@Test @Test
fun importSecretsTest() { fun testThatImportSecretsHasAllDeviceTypes() {
val dexFileSecretsImporter = DexFileSecretsImporter() 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") val url = javaClass.getResource("classes.dex")
if(url == null) { if(url == null) {
Assert.assertTrue(""" Assert.assertTrue("""
@ -19,16 +49,26 @@ class DexFileSecretsImporterTest {
checked in and should be on the .gitignore. checked in and should be on the .gitignore.
""".trimIndent(), false) """.trimIndent(), false)
} }
val file = File(url.path) return url!!
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) fun IntArray.toByteArray(byteOrder: ByteOrder = ByteOrder.nativeOrder()): ByteArray {
Assert.assertEquals("5651b1c8", password.toHexString()) val byteArray = ByteArray(this.size*4)
password = deviceIdToCryptographicTransformer[DeviceType.VitalSeriesDeviceType]!!.toCryptographicTransformer().createNfcPassword(testTagId) for(i in this.indices) {
Assert.assertEquals("dd2ceb84", password.toHexString()) val byteArrayIndex = i*4
password = deviceIdToCryptographicTransformer[DeviceType.VitalCharactersDeviceType]!!.toCryptographicTransformer().createNfcPassword(testTagId) this[i].toByteArray(byteArray, byteArrayIndex, byteOrder)
Assert.assertEquals("515e0c12", password.toHexString()) }
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()
} }
} }
} }