Implement Secrets Repository

Add proto plugin and dependencies
Create Secrets Proto
Create Secrets Proto DataStore
Replace old secrets with proto secrets
Fix importers and tests to use new proto secrets.
This commit is contained in:
Christopher O'Grady 2025-01-09 15:24:10 -05:00
parent 9871f0420f
commit c456d455ef
15 changed files with 221 additions and 62 deletions

View File

@ -3,6 +3,7 @@ plugins {
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
id("com.google.devtools.ksp") version "2.0.21-1.0.27"
id("com.google.protobuf")
}
android {
@ -38,6 +39,29 @@ android {
buildFeatures {
compose = true
}
lint {
baseline = file("lint-baseline.xml")
}
}
protobuf {
protoc {
artifact = "com.google.protobuf:protoc:4.27.0"
}
// Generates the java Protobuf-lite code for the Protobufs in this project. See
// https://github.com/google/protobuf-gradle-plugin#customizing-protobuf-compilation
// for more information.
generateProtoTasks {
all().forEach { task ->
task.builtins {
create("java") {
option("lite")
}
}
}
}
}
dependencies {
@ -51,6 +75,7 @@ dependencies {
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.activity.compose)
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.datastore)
implementation(libs.androidx.ui)
implementation(libs.androidx.ui.graphics)
implementation(libs.androidx.ui.tooling.preview)
@ -64,5 +89,6 @@ dependencies {
debugImplementation(libs.androidx.ui.test.manifest)
implementation("androidx.navigation:navigation-compose:2.7.0")
implementation("com.google.android.material:material:1.2.0")
implementation(libs.protobuf.javalite)
implementation("androidx.compose.material:material")
}

View File

@ -1,7 +1,9 @@
package com.github.nacabaro.vbhelper.di
import com.github.nacabaro.vbhelper.database.AppDatabase
import com.github.nacabaro.vbhelper.source.DataStoreSecretsRepository
interface AppContainer {
val db: AppDatabase
val dataStoreSecretsRepository: DataStoreSecretsRepository
}

View File

@ -1,9 +1,22 @@
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.dataStore
import androidx.room.Room
import com.github.nacabaro.vbhelper.database.AppDatabase
import com.github.nacabaro.vbhelper.di.AppContainer
import com.github.nacabaro.vbhelper.source.DataStoreSecretsRepository
import com.github.nacabaro.vbhelper.source.SecretsSerializer
import com.github.nacabaro.vbhelper.source.proto.Secrets
private const val SECRETS_DATA_STORE_NAME = "secrets.pb"
val Context.secretsStore: DataStore<Secrets> by dataStore(
fileName = SECRETS_DATA_STORE_NAME,
serializer = SecretsSerializer
)
class DefaultAppContainer(private val context: Context) : AppContainer {
override val db: AppDatabase by lazy {
Room.databaseBuilder(
context = context,
@ -11,4 +24,8 @@ class DefaultAppContainer(private val context: Context) : AppContainer {
"internalDb"
).build()
}
override val dataStoreSecretsRepository = DataStoreSecretsRepository(context.secretsStore)
}

View File

@ -1,5 +1,6 @@
package com.github.nacabaro.vbhelper.source
import com.github.nacabaro.vbhelper.source.proto.Secrets
import java.io.InputStream
import java.util.zip.ZipInputStream
@ -10,7 +11,7 @@ class ApkSecretsImporter(private val dexFileSecretsImporter: SecretsImporter = D
}
// importSecrets imports the secrets from the apk input stream, and validates them.
override fun importSecrets(inputStream: InputStream): Map<UShort, Secrets> {
override fun importSecrets(inputStream: InputStream): Secrets {
ZipInputStream(inputStream).use { zip ->
var zipEntry = zip.nextEntry
while(zipEntry != null) {

View File

@ -0,0 +1,46 @@
package com.github.nacabaro.vbhelper.source
import androidx.datastore.core.DataStore
import com.github.cfogrady.vbnfc.CryptographicTransformer
import com.github.cfogrady.vbnfc.data.DeviceType
import com.github.nacabaro.vbhelper.source.proto.Secrets
import com.github.nacabaro.vbhelper.source.proto.Secrets.HmacKeys
import kotlinx.coroutines.flow.single
class DataStoreSecretsRepository(
private val secretsDataStore: DataStore<Secrets>,
): SecretsRepository {
override val secretsFlow = secretsDataStore.data
override suspend fun updateSecrets(secrets: Secrets) {
secretsDataStore.updateData {
secrets
}
}
override suspend fun getSecrets(): Secrets {
return secretsFlow.single()
}
}
private fun Secrets.getHmacKeys(deviceTypeId: UShort): HmacKeys {
return when(deviceTypeId) {
DeviceType.VitalBraceletBEDeviceType -> this.beHmacKeys
DeviceType.VitalCharactersDeviceType -> this.vbcHmacKeys
DeviceType.VitalSeriesDeviceType -> this.vbdmHmacKeys
else -> throw IllegalArgumentException("Unknown DeviceTypeId")
}
}
fun Secrets.getCryptographicTransformerMap(): Map<UShort, CryptographicTransformer> {
val cipher = this.vbCipherList.toIntArray()
val beCipher = this.beCipherList.toIntArray()
val vbdmHmacKeys = this.getHmacKeys(DeviceType.VitalSeriesDeviceType)
val vbcHmacKeys = this.getHmacKeys(DeviceType.VitalCharactersDeviceType)
val beHmacKeys = this.getHmacKeys(DeviceType.VitalBraceletBEDeviceType)
return mapOf(
Pair(DeviceType.VitalSeriesDeviceType, CryptographicTransformer(vbdmHmacKeys.hmacKey1, vbdmHmacKeys.hmacKey2, this.aesKey, cipher)),
Pair(DeviceType.VitalCharactersDeviceType, CryptographicTransformer(vbcHmacKeys.hmacKey1, vbcHmacKeys.hmacKey2, this.aesKey, cipher)),
Pair(DeviceType.VitalBraceletBEDeviceType, CryptographicTransformer(beHmacKeys.hmacKey1, beHmacKeys.hmacKey2, this.aesKey, beCipher)),
)
}

View File

@ -1,6 +1,9 @@
package com.github.nacabaro.vbhelper.source
import com.github.cfogrady.vbnfc.CryptographicTransformer
import com.github.cfogrady.vbnfc.data.DeviceType
import com.github.nacabaro.vbhelper.source.proto.Secrets
import com.github.nacabaro.vbhelper.source.proto.Secrets.HmacKeys
import java.io.InputStream
import java.nio.ByteBuffer
import java.nio.ByteOrder
@ -28,63 +31,58 @@ class DexFileSecretsImporter: SecretsImporter {
const val VBC_TEST_TAG_PASSWORD = "a71dfb22"
}
override fun importSecrets(inputStream: InputStream): Map<UShort, Secrets> {
override fun importSecrets(inputStream: InputStream): Secrets {
val deviceToSecrets = readSecrets(inputStream)
verifySecretCorrectness(deviceToSecrets)
return deviceToSecrets
}
private fun readSecrets(inputStream: InputStream): Map<UShort, Secrets> {
private fun readSecrets(inputStream: InputStream): 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
return Secrets.newBuilder()
.setAesKey(aesKey)
.addAllVbCipher(vbdmSubstitutionCipher.toList())
.addAllBeCipher(beSubstitutionCipher.toList())
.setVbdmHmacKeys(getHmac(dexFile, VBDM_HMAC_KEY_1_IDX, VBDM_HMAC_KEY_2_IDX))
.setVbcHmacKeys(getHmac(dexFile, VBC_HMAC_KEY_1_IDX, VBC_HMAC_KEY_2_IDX))
.setBeHmacKeys(getHmac(dexFile, BE_HMAC_KEY_1_IDX, BE_HMAC_KEY_2_IDX))
.build()
}
private fun buildSecrets(dexFile: ByteArray, hmacKeyIdx1: Int, hmacKeyIdx2: Int, aesKey: String, substitutionCipher: IntArray): Secrets {
private fun getHmac(dexFile: ByteArray, hmacKeyIdx1: Int, hmacKeyIdx2: Int): HmacKeys {
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)
return HmacKeys.newBuilder()
.setHmacKey1(hmacKey1)
.setHmacKey2(hmacKey2)
.build()
}
@OptIn(ExperimentalStdlibApi::class)
private fun verifySecretCorrectness(deviceToSecrets: Map<UShort, Secrets>) {
for (keyValue in deviceToSecrets) {
private fun verifySecretCorrectness(secrets: Secrets) {
val deviceToCryptographicTransformers = secrets.getCryptographicTransformerMap()
for (keyValue in deviceToCryptographicTransformers) {
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()}")
}
}
DeviceType.VitalBraceletBEDeviceType -> assertBuildsCorrectPassword(keyValue.value, BE_TEST_TAG_PASSWORD)
DeviceType.VitalCharactersDeviceType -> assertBuildsCorrectPassword(keyValue.value, VBC_TEST_TAG_PASSWORD)
DeviceType.VitalSeriesDeviceType -> assertBuildsCorrectPassword(keyValue.value, VBDM_TEST_TAG_PASSWORD)
}
}
}
@OptIn(ExperimentalStdlibApi::class)
private fun assertBuildsCorrectPassword(cryptographicTransformer: CryptographicTransformer, expectedPassword: String) {
val result = cryptographicTransformer.createNfcPassword(
TEST_TAG
)
if( result.toHexString() != expectedPassword) {
throw InvalidKeyException("Secrets were loaded, but were unsuccessful at generating the test password")
}
}
}
fun ByteArray.toIntArray(byteOrder: ByteOrder): IntArray {

View File

@ -1,9 +0,0 @@
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)
}
}

View File

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

View File

@ -0,0 +1,11 @@
package com.github.nacabaro.vbhelper.source
import com.github.nacabaro.vbhelper.source.proto.Secrets
import kotlinx.coroutines.flow.Flow
interface SecretsRepository {
val secretsFlow: Flow<Secrets>
suspend fun getSecrets(): Secrets
suspend fun updateSecrets(secrets: Secrets)
}

View File

@ -0,0 +1,24 @@
package com.github.nacabaro.vbhelper.source
import androidx.datastore.core.CorruptionException
import androidx.datastore.core.Serializer
import com.github.nacabaro.vbhelper.source.proto.Secrets
import com.google.protobuf.InvalidProtocolBufferException
import java.io.InputStream
import java.io.OutputStream
object SecretsSerializer: Serializer<Secrets> {
override val defaultValue = Secrets.getDefaultInstance()
override suspend fun readFrom(input: InputStream): Secrets {
try {
return Secrets.parseFrom(input)
} catch (exception: InvalidProtocolBufferException) {
throw CorruptionException("Cannot read proto.", exception)
}
}
override suspend fun writeTo(t: Secrets, output: OutputStream) {
t.writeTo(output)
}
}

View File

@ -0,0 +1,19 @@
syntax = "proto3";
option java_package = "com.github.nacabaro.vbhelper.source.proto";
option java_multiple_files = true;
message Secrets {
string aes_key = 1;
repeated int32 vb_cipher = 2;
repeated int32 be_cipher = 3;
message HmacKeys {
string hmac_key_1 = 1;
string hmac_key_2 = 2;
}
HmacKeys vbdm_hmac_keys = 4;
HmacKeys vbc_hmac_keys = 5;
HmacKeys be_hmac_keys = 6;
}

View File

@ -1,6 +1,6 @@
package com.github.nacabaro.vbhelper.source
import com.github.cfogrady.vbnfc.data.DeviceType
import com.github.nacabaro.vbhelper.source.proto.Secrets
import org.junit.Assert
import org.junit.Test
import java.io.ByteArrayInputStream
@ -14,15 +14,18 @@ import java.util.zip.ZipOutputStream
class ApkSecretsImporterTest {
@Test
fun testThatRealImportSecretsHasAllDeviceTypes() {
fun testThatRealImportSecretsPasses() {
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])
val secrets = apkFileSecretsImporter.importSecrets(it)
Assert.assertEquals("Cipher size isn't correct", 16, secrets.vbCipherCount)
Assert.assertEquals("BE Cipher size isn't correct", 16, secrets.beCipherCount)
Assert.assertFalse("AES Key is empty", secrets.aesKey.isEmpty())
assertHmacKeysArePopulated("VBDM", secrets.vbdmHmacKeys)
assertHmacKeysArePopulated("VBC", secrets.vbcHmacKeys)
assertHmacKeysArePopulated("BE", secrets.beHmacKeys)
}
}
@ -34,7 +37,7 @@ class ApkSecretsImporterTest {
val inputStreamContents = it.readAllBytes()
Assert.assertTrue("Unexpected file contents received by DexSecretsImporter", inputStreamContents.contentEquals(expectedDexContents))
foundFile = true
emptyMap()
Secrets.newBuilder().build()
}
val apkBytes = constructTestApk(expectedDexContents)
ByteArrayInputStream(apkBytes).use {

View File

@ -1,6 +1,6 @@
package com.github.nacabaro.vbhelper.source
import com.github.cfogrady.vbnfc.data.DeviceType
import com.github.nacabaro.vbhelper.source.proto.Secrets.HmacKeys
import org.junit.Assert
import org.junit.Test
import java.io.ByteArrayInputStream
@ -10,18 +10,26 @@ import java.nio.ByteOrder
import java.security.InvalidKeyException
fun assertHmacKeysArePopulated(msg: String, hmacKeys: HmacKeys) {
Assert.assertFalse("$msg hmacKey1 is empty", hmacKeys.hmacKey1.isEmpty())
Assert.assertFalse("$msg hmacKey2 is empty", hmacKeys.hmacKey2.isEmpty())
}
class DexFileSecretsImporterTest {
@Test
fun testThatImportSecretsHasAllDeviceTypes() {
fun testThatImportSecretsIsPopulated() {
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])
val secrets = dexFileSecretsImporter.importSecrets(it)
Assert.assertEquals("Cipher size isn't correct", 16, secrets.vbCipherCount)
Assert.assertEquals("BE Cipher size isn't correct", 16, secrets.beCipherCount)
Assert.assertFalse("AES Key is empty", secrets.aesKey.isEmpty())
assertHmacKeysArePopulated("VBDM", secrets.vbdmHmacKeys)
assertHmacKeysArePopulated("VBC", secrets.vbcHmacKeys)
assertHmacKeysArePopulated("BE", secrets.beHmacKeys)
}
}

View File

@ -4,3 +4,9 @@ plugins {
alias(libs.plugins.kotlin.android) apply false
alias(libs.plugins.kotlin.compose) apply false
}
buildscript {
dependencies {
classpath(libs.protobuf.gradle.plugin)
}
}

View File

@ -1,5 +1,6 @@
[versions]
agp = "8.7.3"
datastore = "1.1.1"
kotlin = "2.0.0"
coreKtx = "1.15.0"
junit = "4.13.2"
@ -8,12 +9,15 @@ espressoCore = "3.6.1"
lifecycleRuntimeKtx = "2.8.7"
activityCompose = "1.9.3"
composeBom = "2024.04.01"
protobufGradlePlugin = "0.9.4"
protobufJavalite = "4.27.0"
roomRuntime = "2.6.1"
vbNfcReader = "0.1.0"
dimReader = "2.1.0"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
androidx-datastore = { module = "androidx.datastore:datastore", version.ref = "datastore" }
androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "roomRuntime" }
androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "roomRuntime" }
junit = { group = "junit", name = "junit", version.ref = "junit" }
@ -29,6 +33,8 @@ androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-toolin
androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
androidx-material3 = { group = "androidx.compose.material3", name = "material3" }
protobuf-gradle-plugin = { module = "com.google.protobuf:protobuf-gradle-plugin", version.ref = "protobufGradlePlugin" }
protobuf-javalite = { module = "com.google.protobuf:protobuf-javalite", version.ref = "protobufJavalite" }
vb-nfc-reader = { module = "com.github.cfogrady:vb-nfc-reader", version.ref = "vbNfcReader" }
dim-reader = { module = "com.github.cfogrady:vb-dim-reader", version.ref = "dimReader" }