Use integrity check for log/loginEvent endpoint

This commit is contained in:
Mikołaj Pich 2023-09-24 19:17:13 +02:00
parent 4ae2fc89d1
commit b4760755fd
10 changed files with 45 additions and 175 deletions

View file

@ -19,6 +19,7 @@ class LoginEventDao {
scraperBaseUrl = row[LoginEvents.scraperBaseUrl],
symbol = row[LoginEvents.symbol],
loginType = row[LoginEvents.loginType],
uuid = row[LoginEvents.uuid],
)
suspend fun allLoginEvents(): List<LoginEvent> = dbQuery {
@ -28,6 +29,7 @@ class LoginEventDao {
suspend fun addLoginEvent(event: LoginEvent) = withContext(Dispatchers.IO) {
transaction {
LoginEvents.insert {
it[uuid] = event.uuid
it[schoolName] = event.schoolName
it[schoolAddress] = event.schoolAddress
it[scraperBaseUrl] = event.scraperBaseUrl

View file

@ -1,9 +0,0 @@
package io.github.wulkanowy.schools.integrity
import kotlinx.serialization.Serializable
@Serializable
data class CommandResult(
val commandSuccess: Boolean,
val diagnosticMessage: String,
)

View file

@ -1,9 +0,0 @@
package io.github.wulkanowy.schools.integrity
import kotlinx.serialization.Serializable
import java.util.logging.Logger
val randomStorage = mutableListOf<IntegrityRandom>()
@Serializable
data class IntegrityRandom(val random: String, val timestamp: Long)

View file

@ -3,7 +3,7 @@ package io.github.wulkanowy.schools.integrity
import kotlinx.serialization.Serializable
@Serializable
data class ServerCommand(
val commandString: String,
val tokenString: String
data class IntegrityRequest<T>(
val tokenString: String,
val data: T,
)

View file

@ -1,54 +0,0 @@
package io.github.wulkanowy.schools.integrity
// Build a summary string of integrity verdict information. This is used for
// informational purposes, you would not normally pass this information back
// to the client.
fun summarizeVerdict(integrityVerdict: IntegrityVerdict): String {
var verdictString = "Device integrity: "
var foundDeviceIntegritySignal = false
for (deviceField in integrityVerdict.deviceIntegrity.deviceRecognitionVerdict!!) {
when (deviceField) {
VERDICT_VAL_MEETS_BASIC_INTEGRITY -> {
foundDeviceIntegritySignal = true
verdictString += "Basic "
}
VERDICT_VAL_MEETS_DEVICE_INTEGRITY -> {
foundDeviceIntegritySignal = true
verdictString += "Device "
}
VERDICT_VAL_MEETS_STRONG_INTEGRITY -> {
foundDeviceIntegritySignal = true
verdictString += "Strong "
}
VERDICT_VAL_MEETS_VIRTUAL_INTEGRITY -> {
foundDeviceIntegritySignal = true
verdictString += "Virtual "
}
}
}
if (!foundDeviceIntegritySignal) {
verdictString = "Not found"
}
verdictString += when (integrityVerdict.appIntegrity.appRecognitionVerdict) {
VERDICT_VAL_VERSION_RECOGNIZED -> "\nApp version recognized"
VERDICT_VAL_VERSION_UNRECOGNIZED -> "\nApp version unrecognized"
else -> "\nApp version unevaluated"
}
verdictString += when (integrityVerdict.accountDetails.appLicensingVerdict) {
VERDICT_VAL_LICENSED -> "\nApp licensed"
VERDICT_VAL_UNLICENSED -> "\nApp unlicensed"
else -> "\nApp license unevaluated"
}
verdictString += when (integrityVerdict.requestDetails.requestPackageName) {
APPLICATION_PACKAGE_IDENTIFIER -> "\nPackage name match"
else -> "\nPackage name mismatch"
}
return verdictString
}

View file

@ -7,9 +7,10 @@ import com.google.api.services.playintegrity.v1.model.DecodeIntegrityTokenReques
import com.google.auth.http.HttpCredentialsAdapter
import com.google.auth.oauth2.GoogleCredentials
import com.google.common.collect.Lists
import kotlinx.serialization.json.Json
import java.util.logging.Logger
fun decryptToken(tokenString: String, playIntegrity: PlayIntegrity = getPlayIntegrity()): String {
fun decryptToken(tokenString: String, playIntegrity: PlayIntegrity = getPlayIntegrity()): IntegrityVerdictPayload {
val decodeTokenRequest = DecodeIntegrityTokenRequest().setIntegrityToken(tokenString)
val returnString = playIntegrity.v1()
.decodeIntegrityToken(APPLICATION_PACKAGE_IDENTIFIER, decodeTokenRequest)
@ -19,7 +20,7 @@ fun decryptToken(tokenString: String, playIntegrity: PlayIntegrity = getPlayInte
val log = Logger.getLogger("decryptToken")
log.info("Decrypted token: $returnString")
return returnString
return Json.decodeFromString(returnString)
}
fun getPlayIntegrity(): PlayIntegrity {

View file

@ -4,9 +4,6 @@ import java.security.MessageDigest
import java.util.*
import java.util.logging.Logger
// Five minute timeout (in milliseconds)
const val NONCE_TIMEOUT = 1000 * 60 * 5
// Package name of the client application
const val APPLICATION_PACKAGE_IDENTIFIER = "io.github.wulkanowy"
@ -20,14 +17,13 @@ const val VERDICT_VAL_VERSION_RECOGNIZED = "PLAY_RECOGNIZED"
const val VERDICT_VAL_LICENSED = "LICENSED"
const val VERDICT_VAL_UNLICENSED = "UNLICENSED"
fun validateCommand(commandString: String, integrityVerdict: IntegrityVerdict): ValidateResult {
fun validateCommand(originalNonce: String, integrityVerdict: IntegrityVerdict): ValidateResult {
if (integrityVerdict.requestDetails.nonce != null) {
var nonceString: String = integrityVerdict.requestDetails.nonce
// Server might re-pad base64 with unicode '=', trim any that exist to
// match our web-safe original
val utfEqualRegex = "\\u003d$".toRegex()
nonceString = utfEqualRegex.replace(nonceString, "")
val nonceBytes = Base64.getUrlDecoder().decode(nonceString)
// The nonce string contains two parts, the random number previously generated,
// and the SHA256 hash of the command string, we need to separate them
// The values were written out as hex values, so they are base64 compatible, but
@ -40,32 +36,20 @@ fun validateCommand(commandString: String, integrityVerdict: IntegrityVerdict):
log.info("Random nonce segment: $randomString")
log.info("Hash nonce segment: $hashString")
// Verify the random part of the nonce was a random number previously generated on
// the server, and that it hasn't expired
val matchingRandom = randomStorage.find { it.random == randomString }
if (matchingRandom != null) {
val currentTimestamp = System.currentTimeMillis()
val timeDelta = currentTimestamp - matchingRandom.timestamp
// Can only use once, remove from the server's random list after matching
randomStorage.remove(matchingRandom)
if (timeDelta < NONCE_TIMEOUT) {
return if (validateHash(commandString, hashString)) {
if (validateVerdict(integrityVerdict)) {
ValidateResult.VALIDATE_SUCCESS
} else {
ValidateResult.VALIDATE_INTEGRITY_FAIL
}
} else {
ValidateResult.VALIDATE_NONCE_MISMATCH
}
return if (validateHash(originalNonce, hashString)) {
if (validateVerdict(integrityVerdict)) {
ValidateResult.VALIDATE_SUCCESS
} else {
ValidateResult.VALIDATE_INTEGRITY_FAIL
}
return ValidateResult.VALIDATE_NONCE_EXPIRED
} else {
ValidateResult.VALIDATE_NONCE_MISMATCH
}
}
return ValidateResult.VALIDATE_NONCE_NOT_FOUND
}
fun validateHash(commandString: String, hashString: String) : Boolean {
fun validateHash(commandString: String, hashString: String): Boolean {
val messageDigest = MessageDigest.getInstance("SHA-256")
val commandHashBytes = messageDigest.digest(commandString.toByteArray(Charsets.UTF_8))
val commandHashString = commandHashBytes.toHexString()
@ -80,7 +64,7 @@ fun validateHash(commandString: String, hashString: String) : Boolean {
return hashMatch
}
fun validateVerdict(integrityVerdict: IntegrityVerdict) : Boolean {
fun validateVerdict(integrityVerdict: IntegrityVerdict): Boolean {
// Process the integrity verdict and 'validate' the command if the following positive
// signals exist:
// 1) Positive device integrity signal
@ -99,12 +83,9 @@ fun validateVerdict(integrityVerdict: IntegrityVerdict) : Boolean {
if (metDeviceIntegrity) {
val recognitionVerdict = integrityVerdict.appIntegrity.appRecognitionVerdict
if (recognitionVerdict == VERDICT_VAL_VERSION_RECOGNIZED ||
recognitionVerdict == VERDICT_VAL_VERSION_UNRECOGNIZED) {
if (integrityVerdict.accountDetails.appLicensingVerdict ==
VERDICT_VAL_LICENSED) {
if (integrityVerdict.requestDetails.requestPackageName ==
APPLICATION_PACKAGE_IDENTIFIER) {
if (recognitionVerdict == VERDICT_VAL_VERSION_RECOGNIZED || recognitionVerdict == VERDICT_VAL_VERSION_UNRECOGNIZED) {
if (integrityVerdict.accountDetails.appLicensingVerdict == VERDICT_VAL_LICENSED) {
if (integrityVerdict.requestDetails.requestPackageName == APPLICATION_PACKAGE_IDENTIFIER) {
return true
}
}

View file

@ -5,7 +5,6 @@ package io.github.wulkanowy.schools.integrity
enum class ValidateResult {
VALIDATE_SUCCESS,
VALIDATE_NONCE_NOT_FOUND,
VALIDATE_NONCE_EXPIRED,
VALIDATE_NONCE_MISMATCH,
VALIDATE_INTEGRITY_FAIL
}

View file

@ -3,9 +3,11 @@ package io.github.wulkanowy.schools.model
import kotlinx.serialization.Serializable
import org.jetbrains.exposed.sql.*
import org.jetbrains.exposed.sql.javatime.timestamp
import java.util.UUID
@Serializable
data class LoginEvent(
val uuid: String,
val schoolName: String,
val schoolAddress: String,
val scraperBaseUrl: String,
@ -15,6 +17,7 @@ data class LoginEvent(
object LoginEvents : Table() {
val id = integer("id").autoIncrement()
val uuid = varchar("uuid", 36)
val timestamp = timestamp("timestamp")
val schoolName = varchar("schoolName", 256)
val schoolAddress = varchar("schoolAddress", 256)
@ -23,4 +26,8 @@ object LoginEvents : Table() {
val loginType = varchar("loginType", 32)
override val primaryKey = PrimaryKey(id)
init {
uniqueIndex("Unique event constraint", uuid)
}
}

View file

@ -1,87 +1,39 @@
package io.github.wulkanowy.schools.plugins
import com.google.gson.Gson
import io.github.wulkanowy.schools.dao.LoginEventDao
import io.github.wulkanowy.schools.integrity.*
import io.github.wulkanowy.schools.model.LoginEvent
import io.ktor.http.*
import io.ktor.server.application.*
import io.ktor.server.auth.*
import io.ktor.server.request.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
fun Application.configureRouting() {
val loginEventDao = LoginEventDao()
val playIntegrity = getPlayIntegrity()
routing {
authenticate("auth") {
post("/log/loginEvent") {
val loginEvent = call.receive<LoginEvent>()
loginEventDao.addLoginEvent(loginEvent)
call.respond(status = HttpStatusCode.NoContent, "")
post("/log/loginEvent") {
val request = call.receive<IntegrityRequest<LoginEvent>>()
val integrityVerdictPayload = decryptToken(request.tokenString, playIntegrity)
val integrityVerdict = integrityVerdictPayload.tokenPayloadExternal
when (val result = validateCommand(request.data.uuid, integrityVerdict)) {
ValidateResult.VALIDATE_SUCCESS -> {
loginEventDao.addLoginEvent(request.data)
call.respond(status = HttpStatusCode.NoContent, "")
}
ValidateResult.VALIDATE_NONCE_NOT_FOUND,
ValidateResult.VALIDATE_NONCE_MISMATCH,
ValidateResult.VALIDATE_INTEGRITY_FAIL -> {
call.respond(status = HttpStatusCode.BadRequest, message = result.name)
}
}
}
get("/") {
call.respond(loginEventDao.allLoginEvents())
}
route("/performCommand") {
post {
val incomingCommand = call.receive<ServerCommand>()
val decodedTokenString = decryptToken(incomingCommand.tokenString)
val integrityVerdictPayload = Gson()
.fromJson(decodedTokenString, IntegrityVerdictPayload::class.java)
if (integrityVerdictPayload != null) {
val integrityVerdict = integrityVerdictPayload.tokenPayloadExternal
// Integrity signals didn't pass our 'success' criteria,
// pass the verdict summary string
// back in the diagnostic field
when (validateCommand(incomingCommand.commandString, integrityVerdict)) {
ValidateResult.VALIDATE_SUCCESS -> call.respond(
CommandResult(
commandSuccess = true,
diagnosticMessage = summarizeVerdict(integrityVerdict),
)
)
ValidateResult.VALIDATE_NONCE_NOT_FOUND -> call.respond(
CommandResult(
commandSuccess = false,
diagnosticMessage = "Failed to find matching nonce",
)
)
ValidateResult.VALIDATE_NONCE_EXPIRED -> call.respond(
CommandResult(
commandSuccess = false,
diagnosticMessage = "Token nonce expired",
)
)
ValidateResult.VALIDATE_NONCE_MISMATCH -> call.respond(
CommandResult(
commandSuccess = false,
diagnosticMessage = "Token nonce didn't match command hash",
)
)
ValidateResult.VALIDATE_INTEGRITY_FAIL -> call.respond(
CommandResult(
commandSuccess = false,
diagnosticMessage = summarizeVerdict(integrityVerdict),
)
)
}
} else {
val invalidToken = CommandResult(
commandSuccess = false,
diagnosticMessage = "Token invalid",
)
call.respond(invalidToken)
}
}
}
}
}