Skip to content
This repository has been archived by the owner on Jun 20, 2023. It is now read-only.

Encrypt Event Check-in data before submit (EXPOSUREAPP-8685) #3863

Merged
merged 14 commits into from
Aug 11, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -12,5 +12,7 @@ interface CWAConfig {

val isDeviceTimeCheckEnabled: Boolean

val isUnencryptedCheckInsEnabled: Boolean

interface Mapper : ConfigMapper<CWAConfig>
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ class CWAConfigMapper @Inject constructor() : CWAConfig.Mapper {
latestVersionCode = rawConfig.latestVersionCode,
minVersionCode = rawConfig.minVersionCode,
supportedCountries = rawConfig.getMappedSupportedCountries(),
isDeviceTimeCheckEnabled = !rawConfig.isDeviceTimeCheckDisabled()
isDeviceTimeCheckEnabled = !rawConfig.isDeviceTimeCheckDisabled(),
isUnencryptedCheckInsEnabled = rawConfig.isUnencryptedCheckInsEnabled()
)
}

Expand Down Expand Up @@ -43,11 +44,26 @@ class CWAConfigMapper @Inject constructor() : CWAConfig.Mapper {
}
}

private fun ApplicationConfigurationAndroid.isUnencryptedCheckInsEnabled(): Boolean {
if (!hasAppFeatures()) return false
return try {
(0 until appFeatures.appFeaturesCount)
.map { appFeatures.getAppFeatures(it) }
.firstOrNull { it.label == "unencrypted-checkins-enabled" }
?.let { it.value == 1 }
?: false
} catch (e: Exception) {
Timber.e(e, "Failed to map `unencrypted-checkins-enabled` from %s", this)
false
}
}

data class CWAConfigContainer(
override val latestVersionCode: Long,
override val minVersionCode: Long,
override val supportedCountries: List<String>,
override val isDeviceTimeCheckEnabled: Boolean
override val isDeviceTimeCheckEnabled: Boolean,
override val isUnencryptedCheckInsEnabled: Boolean,
) : CWAConfig

companion object {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,28 @@ import javax.inject.Inject
class AesCryptography @Inject constructor() {

fun decrypt(
decryptionKey: ByteArray,
encryptedData: ByteArray
key: ByteArray,
encryptedData: ByteArray,
iv: IvParameterSpec? = null
): ByteArray = with(Cipher.getInstance(TRANSFORMATION)) {
val keySpec = SecretKeySpec(decryptionKey, ALGORITHM)
val keySpec = SecretKeySpec(key, ALGORITHM)
val ivParameterSpec = iv ?: defaultIvParameterSpec
init(Cipher.DECRYPT_MODE, keySpec, ivParameterSpec)
doFinal(encryptedData)
}

private val ivParameterSpec
fun encrypt(
key: ByteArray,
data: ByteArray,
iv: IvParameterSpec? = null,
): ByteArray = with(Cipher.getInstance(TRANSFORMATION)) {
val keySpec = SecretKeySpec(key, ALGORITHM)
val ivParameterSpec = iv ?: defaultIvParameterSpec
init(Cipher.ENCRYPT_MODE, keySpec, ivParameterSpec)
doFinal(data)
}

private val defaultIvParameterSpec
get() = IvParameterSpec(
ByteArray(16) { 0 }
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ class DccCoseDecoder @Inject constructor(

private fun ByteArray.decrypt(decryptionKey: ByteArray) = try {
aesEncryptor.decrypt(
decryptionKey = decryptionKey,
key = decryptionKey,
encryptedData = this
)
} catch (e: Throwable) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,8 @@ class DefaultPlaybook @Inject constructor(
keyList = data.temporaryExposureKeys,
consentToFederation = data.consentToFederation,
visitedCountries = data.visitedCountries,
checkIns = data.checkIns,
unencryptedCheckIns = data.unencryptedCheckIns,
encryptedCheckIns = data.encryptedCheckIns,
submissionType = data.submissionType
)
submissionServer.submitPayload(serverSubmissionData)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,8 @@ interface Playbook {
val temporaryExposureKeys: List<TemporaryExposureKeyExportOuterClass.TemporaryExposureKey>,
val consentToFederation: Boolean,
val visitedCountries: List<String>,
val checkIns: List<CheckInOuterClass.CheckIn>,
val unencryptedCheckIns: List<CheckInOuterClass.CheckIn>,
val encryptedCheckIns: List<CheckInOuterClass.CheckInProtectedReport>,
val submissionType: SubmissionPayload.SubmissionType
)
}
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
package de.rki.coronawarnapp.presencetracing.checkins

import de.rki.coronawarnapp.appconfig.AppConfigProvider
import de.rki.coronawarnapp.presencetracing.checkins.cryptography.CheckInCryptography
import de.rki.coronawarnapp.presencetracing.checkins.derivetime.deriveTime
import de.rki.coronawarnapp.presencetracing.checkins.split.splitByMidnightUTC
import de.rki.coronawarnapp.server.protocols.internal.pt.CheckInOuterClass
import de.rki.coronawarnapp.server.protocols.internal.v2.RiskCalculationParametersOuterClass.TransmissionRiskValueMapping
import de.rki.coronawarnapp.submission.Symptoms
import de.rki.coronawarnapp.submission.task.TransmissionRiskVector
import de.rki.coronawarnapp.submission.task.TransmissionRiskVectorDeterminator
Expand All @@ -24,6 +24,7 @@ import javax.inject.Singleton
class CheckInsTransformer @Inject constructor(
private val timeStamper: TimeStamper,
private val transmissionDeterminator: TransmissionRiskVectorDeterminator,
private val checkInCryptography: CheckInCryptography,
private val appConfigProvider: AppConfigProvider
) {
/**
Expand All @@ -36,16 +37,17 @@ class CheckInsTransformer @Inject constructor(
* @param checkIns [List] of local database [CheckIn]
* @param symptoms [Symptoms] symptoms to calculate transmission risk level
*/
suspend fun transform(checkIns: List<CheckIn>, symptoms: Symptoms): List<CheckInOuterClass.CheckIn> {
val presenceTracing = appConfigProvider
.getAppConfig()
.presenceTracing

val submissionParams = presenceTracing.submissionParameters
val trvMappings = presenceTracing.riskCalculationParameters.transmissionRiskValueMapping
suspend fun transform(checkIns: List<CheckIn>, symptoms: Symptoms): CheckInsReport {
val appConfig = appConfigProvider.getAppConfig()
val submissionParams = appConfig.presenceTracing.submissionParameters
val trvMappings = appConfig.presenceTracing.riskCalculationParameters.transmissionRiskValueMapping
val transmissionVector = transmissionDeterminator.determine(symptoms)
val now = timeStamper.nowUTC
return checkIns.flatMap { originalCheckIn ->

val unencryptedCheckIns = mutableListOf<CheckInOuterClass.CheckIn>()
val encryptedCheckIns = mutableListOf<CheckInOuterClass.CheckInProtectedReport>()

for (originalCheckIn in checkIns) {
Timber.d("Transforming check-in=$originalCheckIn")
val derivedTimes = submissionParams.deriveTime(
originalCheckIn.checkInStart.seconds,
Expand All @@ -54,46 +56,46 @@ class CheckInsTransformer @Inject constructor(

if (derivedTimes == null) {
Timber.d("CheckIn can't be derived")
emptyList() // Excluded from submission
} else {
Timber.d("Derived times=$derivedTimes")
val derivedCheckIn = originalCheckIn.copy(
checkInStart = derivedTimes.startTimeSeconds.secondsToInstant(),
checkInEnd = derivedTimes.endTimeSeconds.secondsToInstant()
)

derivedCheckIn.splitByMidnightUTC().mapNotNull { checkIn ->
checkIn.toOuterCheckIn(now, transmissionVector, trvMappings)
}
continue // Excluded from submission
}
}
}

private fun CheckIn.toOuterCheckIn(
now: Instant,
transmissionVector: TransmissionRiskVector,
trvMappings: List<TransmissionRiskValueMapping>
): CheckInOuterClass.CheckIn? {
val transmissionRiskLevel = determineRiskTransmission(now, transmissionVector)
Timber.d("Derived times=$derivedTimes")
val derivedCheckIn = originalCheckIn.copy(
checkInStart = derivedTimes.startTimeSeconds.secondsToInstant(),
checkInEnd = derivedTimes.endTimeSeconds.secondsToInstant()
)

derivedCheckIn.splitByMidnightUTC().forEach { checkIn ->
val riskLevel = checkIn.determineRiskTransmission(now, transmissionVector)

// Find transmissionRiskValue for matched transmissionRiskLevel - default 0.0 if no match
val riskValue = trvMappings.find { it.transmissionRiskLevel == riskLevel }?.transmissionRiskValue ?: 0.0

// Find transmissionRiskValue for matched transmissionRiskLevel - default 0.0 if no match
val transmissionRiskValue = trvMappings.find {
it.transmissionRiskLevel == transmissionRiskLevel
}?.transmissionRiskValue ?: 0.0
if (riskValue == 0.0) {
Timber.d("CheckIn has TRL=$riskLevel is excluded from submission (TRV=0)")
return@forEach // Exclude check-in with TRV = 0.0 from submission
}

// Exclude check-in with TRV = 0.0
if (transmissionRiskValue == 0.0) {
Timber.d("CheckIn has TRL=$transmissionRiskLevel is excluded from submission (TRV=0)")
return null // Not mapped
if (appConfig.isUnencryptedCheckInsEnabled) {
checkIn.toUnencryptedCheckIn(riskLevel).also { unencryptedCheckIns.add(it) }
}
checkInCryptography.encrypt(checkIn, riskLevel).also { encryptedCheckIns.add(it) }
}
}
encryptedCheckIns.shuffle() // As per specs
return CheckInsReport(
unencryptedCheckIns = unencryptedCheckIns,
encryptedCheckIns = encryptedCheckIns
)
}

return CheckInOuterClass.CheckIn.newBuilder()
private fun CheckIn.toUnencryptedCheckIn(riskLevel: Int) =
CheckInOuterClass.CheckIn.newBuilder()
.setLocationId(traceLocationId.toProtoByteString())
.setStartIntervalNumber(checkInStart.derive10MinutesInterval().toInt())
.setEndIntervalNumber(checkInEnd.derive10MinutesInterval().toInt())
.setTransmissionRiskLevel(transmissionRiskLevel)
.setTransmissionRiskLevel(riskLevel)
.build()
}
}

/**
Expand All @@ -107,3 +109,8 @@ fun CheckIn.determineRiskTransmission(now: Instant, transmissionVector: Transmis
val ageInDays = Days.daysBetween(startMidnight, nowMidnight).days
return transmissionVector.raw.getOrElse(ageInDays) { 1 } // Default value
}

data class CheckInsReport(
val unencryptedCheckIns: List<CheckInOuterClass.CheckIn>,
val encryptedCheckIns: List<CheckInOuterClass.CheckInProtectedReport>,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
package de.rki.coronawarnapp.presencetracing.checkins.cryptography

import androidx.annotation.VisibleForTesting
import de.rki.coronawarnapp.covidcertificate.common.cryptography.AesCryptography
import de.rki.coronawarnapp.presencetracing.checkins.CheckIn
import de.rki.coronawarnapp.presencetracing.checkins.qrcode.TraceLocationId
import de.rki.coronawarnapp.server.protocols.internal.pt.CheckInOuterClass.CheckInRecord
import de.rki.coronawarnapp.server.protocols.internal.pt.CheckInOuterClass.CheckInProtectedReport
import de.rki.coronawarnapp.server.protocols.internal.pt.TraceWarning
import de.rki.coronawarnapp.util.HashExtensions.toSHA256
import de.rki.coronawarnapp.util.TimeAndDateExtensions.derive10MinutesInterval
import de.rki.coronawarnapp.util.encoding.base64
import de.rki.coronawarnapp.util.security.RandomStrong
import de.rki.coronawarnapp.util.toProtoByteString
import okio.ByteString
import okio.ByteString.Companion.decodeHex
import okio.ByteString.Companion.toByteString
import org.joda.time.Instant
import javax.crypto.Mac
import javax.crypto.spec.IvParameterSpec
import javax.crypto.spec.SecretKeySpec
import javax.inject.Inject
import kotlin.random.Random

class CheckInCryptography @Inject constructor(
@RandomStrong private val secureRandom: Random,
private val aesCryptography: AesCryptography
) {
fun encrypt(
checkIn: CheckIn,
transmissionRiskLevel: Int
): CheckInProtectedReport {

val checkInRecord = createCheckInRecord(checkIn.checkInStart, checkIn.checkInEnd, transmissionRiskLevel)
val encryptionKey = getEncryptionKey(checkIn.traceLocationId)
val iv = getCryptographicSeed()
val encryptedCheckInRecord =
aesCryptography.encrypt(encryptionKey.toByteArray(), checkInRecord.toByteArray(), IvParameterSpec(iv))
val macKey = getMacKey(checkIn.traceLocationId).toByteArray()
val mac = getMac(macKey, iv, encryptedCheckInRecord)

return CheckInProtectedReport.newBuilder()
.setEncryptedCheckInRecord(encryptedCheckInRecord.toByteString().toProtoByteString())
.setIv(iv.toByteString().toProtoByteString())
.setLocationIdHash(checkIn.traceLocationIdHash.toProtoByteString())
.setMac(mac.toByteString().toProtoByteString())
.build()
}

fun decrypt(
checkInProtectedReport: CheckInProtectedReport,
traceLocationId: TraceLocationId
): TraceWarning.TraceTimeIntervalWarning {

val macKey = getMacKey(traceLocationId).toByteArray()
val mac = getMac(
macKey,
checkInProtectedReport.iv.toByteArray(),
checkInProtectedReport.encryptedCheckInRecord.toByteArray()
)

if (!mac.contentEquals(checkInProtectedReport.mac.toByteArray())) throw IllegalArgumentException(
"Message Authentication Codes are not the same ${mac.base64()} != ${
checkInProtectedReport.mac.toByteArray().base64()
}"
)

val encryptionKey = getEncryptionKey(traceLocationId)
val ivParameterSpec = IvParameterSpec(checkInProtectedReport.iv.toByteArray())
val decryptedData = aesCryptography.decrypt(
encryptionKey.toByteArray(),
checkInProtectedReport.encryptedCheckInRecord.toByteArray(),
ivParameterSpec
)

val checkInRecord = CheckInRecord.parseFrom(decryptedData)

return TraceWarning.TraceTimeIntervalWarning.newBuilder()
.setLocationIdHash(checkInProtectedReport.locationIdHash)
.setPeriod(checkInRecord.period)
.setTransmissionRiskLevel(checkInRecord.transmissionRiskLevel)
.setStartIntervalNumber(checkInRecord.startIntervalNumber)
.build()
}

private fun getCryptographicSeed(): ByteArray {
val cryptographicSeed = ByteArray(16)
secureRandom.nextBytes(cryptographicSeed)
return cryptographicSeed
}

private fun createCheckInRecord(checkInStart: Instant, checkInEnd: Instant, riskLevel: Int): CheckInRecord {
val start = checkInStart.derive10MinutesInterval().toInt()
val end = checkInEnd.derive10MinutesInterval().toInt()
return CheckInRecord.newBuilder()
.setStartIntervalNumber(start)
.setPeriod(end - start)
.setTransmissionRiskLevel(riskLevel)
.build()
}

@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
fun getMac(key: ByteArray, iv: ByteArray, encryptedCheckInRecord: ByteArray): ByteArray {
return with(Mac.getInstance(HMAC_SHA256)) {
init(SecretKeySpec(key, HMAC_SHA256))
doFinal(iv.plus(encryptedCheckInRecord))
}
}

@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
fun getEncryptionKey(traceLocationId: TraceLocationId): ByteString {
return CWA_ENCRYPTION_KEY.plus(traceLocationId.toByteArray()).toSHA256().decodeHex()
}

@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
fun getMacKey(traceLocationId: TraceLocationId): ByteString {
return CWA_MAC_KEY.plus(traceLocationId.toByteArray()).toSHA256().decodeHex()
}

companion object {
private const val HMAC_SHA256 = "HmacSHA256"
private val CWA_MAC_KEY = "4357412d4d41432d4b4559".decodeHex().toByteArray()
private val CWA_ENCRYPTION_KEY = "4357412d454e4352595054494f4e2d4b4559".decodeHex().toByteArray()
}
}
Loading