~cytrogen/vbhelper

da5e6c662868bf9012e2aae1ff9176fd0b796d2b — nacabaro 1 year, 3 months ago 4c0e2fa + c456d45
Merge pull request #11 from cfogrady/SecretsRepo

Add Secrets Repo Using a Proto DataStore
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" }