diff --git a/src/main/kotlin/io/github/wulkanowy/schools/dao/LoginEventDao.kt b/src/main/kotlin/io/github/wulkanowy/schools/dao/LoginEventDao.kt index d4cd323..f93d949 100644 --- a/src/main/kotlin/io/github/wulkanowy/schools/dao/LoginEventDao.kt +++ b/src/main/kotlin/io/github/wulkanowy/schools/dao/LoginEventDao.kt @@ -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 = 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 diff --git a/src/main/kotlin/io/github/wulkanowy/schools/integrity/CommandResult.kt b/src/main/kotlin/io/github/wulkanowy/schools/integrity/CommandResult.kt deleted file mode 100644 index 0477c23..0000000 --- a/src/main/kotlin/io/github/wulkanowy/schools/integrity/CommandResult.kt +++ /dev/null @@ -1,9 +0,0 @@ -package io.github.wulkanowy.schools.integrity - -import kotlinx.serialization.Serializable - -@Serializable -data class CommandResult( - val commandSuccess: Boolean, - val diagnosticMessage: String, -) diff --git a/src/main/kotlin/io/github/wulkanowy/schools/integrity/IntegrityRandom.kt b/src/main/kotlin/io/github/wulkanowy/schools/integrity/IntegrityRandom.kt deleted file mode 100644 index 1c18952..0000000 --- a/src/main/kotlin/io/github/wulkanowy/schools/integrity/IntegrityRandom.kt +++ /dev/null @@ -1,9 +0,0 @@ -package io.github.wulkanowy.schools.integrity - -import kotlinx.serialization.Serializable -import java.util.logging.Logger - -val randomStorage = mutableListOf() - -@Serializable -data class IntegrityRandom(val random: String, val timestamp: Long) diff --git a/src/main/kotlin/io/github/wulkanowy/schools/integrity/ServerCommand.kt b/src/main/kotlin/io/github/wulkanowy/schools/integrity/IntegrityRequest.kt similarity index 55% rename from src/main/kotlin/io/github/wulkanowy/schools/integrity/ServerCommand.kt rename to src/main/kotlin/io/github/wulkanowy/schools/integrity/IntegrityRequest.kt index 9a05859..44ad0e6 100644 --- a/src/main/kotlin/io/github/wulkanowy/schools/integrity/ServerCommand.kt +++ b/src/main/kotlin/io/github/wulkanowy/schools/integrity/IntegrityRequest.kt @@ -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( + val tokenString: String, + val data: T, ) diff --git a/src/main/kotlin/io/github/wulkanowy/schools/integrity/SummarizeVerdict.kt b/src/main/kotlin/io/github/wulkanowy/schools/integrity/SummarizeVerdict.kt deleted file mode 100644 index 847ca2f..0000000 --- a/src/main/kotlin/io/github/wulkanowy/schools/integrity/SummarizeVerdict.kt +++ /dev/null @@ -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 -} diff --git a/src/main/kotlin/io/github/wulkanowy/schools/integrity/TokenDecrypt.kt b/src/main/kotlin/io/github/wulkanowy/schools/integrity/TokenDecrypt.kt index 1824a3e..2639d39 100644 --- a/src/main/kotlin/io/github/wulkanowy/schools/integrity/TokenDecrypt.kt +++ b/src/main/kotlin/io/github/wulkanowy/schools/integrity/TokenDecrypt.kt @@ -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 { diff --git a/src/main/kotlin/io/github/wulkanowy/schools/integrity/ValidateCommand.kt b/src/main/kotlin/io/github/wulkanowy/schools/integrity/ValidateCommand.kt index be2332a..4bb9e23 100644 --- a/src/main/kotlin/io/github/wulkanowy/schools/integrity/ValidateCommand.kt +++ b/src/main/kotlin/io/github/wulkanowy/schools/integrity/ValidateCommand.kt @@ -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 } } diff --git a/src/main/kotlin/io/github/wulkanowy/schools/integrity/ValidateResult.kt b/src/main/kotlin/io/github/wulkanowy/schools/integrity/ValidateResult.kt index 73b71d9..a0bd0e7 100644 --- a/src/main/kotlin/io/github/wulkanowy/schools/integrity/ValidateResult.kt +++ b/src/main/kotlin/io/github/wulkanowy/schools/integrity/ValidateResult.kt @@ -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 } diff --git a/src/main/kotlin/io/github/wulkanowy/schools/model/LoginEvent.kt b/src/main/kotlin/io/github/wulkanowy/schools/model/LoginEvent.kt index 3fff58d..f395ca4 100644 --- a/src/main/kotlin/io/github/wulkanowy/schools/model/LoginEvent.kt +++ b/src/main/kotlin/io/github/wulkanowy/schools/model/LoginEvent.kt @@ -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) + } } diff --git a/src/main/kotlin/io/github/wulkanowy/schools/plugins/Routing.kt b/src/main/kotlin/io/github/wulkanowy/schools/plugins/Routing.kt index 21c01bc..feb36c2 100644 --- a/src/main/kotlin/io/github/wulkanowy/schools/plugins/Routing.kt +++ b/src/main/kotlin/io/github/wulkanowy/schools/plugins/Routing.kt @@ -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() - loginEventDao.addLoginEvent(loginEvent) - call.respond(status = HttpStatusCode.NoContent, "") + post("/log/loginEvent") { + val request = call.receive>() + 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() - 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) - } - } - } } }