~cytrogen/vbhelper

c456d455ef9ad93960d32cbd7f3a43d2aa280a48 — Christopher O'Grady 1 year, 3 months ago 9871f04
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.
M app/build.gradle.kts => app/build.gradle.kts +26 -0
@@ 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")
}
\ No newline at end of file

M app/src/main/java/com/github/nacabaro/vbhelper/di/AppContainer.kt => app/src/main/java/com/github/nacabaro/vbhelper/di/AppContainer.kt +2 -0
@@ 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
}
\ No newline at end of file

M app/src/main/java/com/github/nacabaro/vbhelper/di/DefaultAppContainer.kt => app/src/main/java/com/github/nacabaro/vbhelper/di/DefaultAppContainer.kt +18 -1
@@ 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()
    }
}
\ No newline at end of file

    override val dataStoreSecretsRepository = DataStoreSecretsRepository(context.secretsStore)

}


M app/src/main/java/com/github/nacabaro/vbhelper/source/ApkSecretsImporter.kt => app/src/main/java/com/github/nacabaro/vbhelper/source/ApkSecretsImporter.kt +2 -1
@@ 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) {

A app/src/main/java/com/github/nacabaro/vbhelper/source/DataStoreSecretsRepository.kt => app/src/main/java/com/github/nacabaro/vbhelper/source/DataStoreSecretsRepository.kt +46 -0
@@ 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)),
    )
}
\ No newline at end of file

M app/src/main/java/com/github/nacabaro/vbhelper/source/DexFileSecretsImporter.kt => app/src/main/java/com/github/nacabaro/vbhelper/source/DexFileSecretsImporter.kt +34 -36
@@ 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 {

D app/src/main/java/com/github/nacabaro/vbhelper/source/Secrets.kt => app/src/main/java/com/github/nacabaro/vbhelper/source/Secrets.kt +0 -9
@@ 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)
    }
}

M app/src/main/java/com/github/nacabaro/vbhelper/source/SecretsImporter.kt => app/src/main/java/com/github/nacabaro/vbhelper/source/SecretsImporter.kt +2 -1
@@ 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
}
\ No newline at end of file

A app/src/main/java/com/github/nacabaro/vbhelper/source/SecretsRepository.kt => app/src/main/java/com/github/nacabaro/vbhelper/source/SecretsRepository.kt +11 -0
@@ 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)
}
\ No newline at end of file

A app/src/main/java/com/github/nacabaro/vbhelper/source/SecretsSerializer.kt => app/src/main/java/com/github/nacabaro/vbhelper/source/SecretsSerializer.kt +24 -0
@@ 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)
    }
}
\ No newline at end of file

A app/src/main/proto/com/github/nacabaro/vbhelper/source/Secrets.proto => app/src/main/proto/com/github/nacabaro/vbhelper/source/Secrets.proto +19 -0
@@ 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;

}
\ 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 +11 -8
@@ 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 {


@@ 70,4 73,4 @@ class ApkSecretsImporterTest {
        }
        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 +14 -6
@@ 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)
        }
    }


M build.gradle.kts => build.gradle.kts +6 -0
@@ 3,4 3,10 @@ plugins {
    alias(libs.plugins.android.application) apply false
    alias(libs.plugins.kotlin.android) apply false
    alias(libs.plugins.kotlin.compose) apply false
}

buildscript {
    dependencies {
        classpath(libs.protobuf.gradle.plugin)
    }
}
\ No newline at end of file

M gradle/libs.versions.toml => gradle/libs.versions.toml +6 -0
@@ 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" }