Use integrity check for log/loginEvent endpoint
This commit is contained in:
parent
4ae2fc89d1
commit
b4760755fd
10 changed files with 45 additions and 175 deletions
|
@ -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
|
||||
|
|
|
@ -1,9 +0,0 @@
|
|||
package io.github.wulkanowy.schools.integrity
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class CommandResult(
|
||||
val commandSuccess: Boolean,
|
||||
val diagnosticMessage: String,
|
||||
)
|
|
@ -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)
|
|
@ -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,
|
||||
)
|
|
@ -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
|
||||
}
|
|
@ -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 {
|
||||
|
|
|
@ -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,16 +36,7 @@ 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)) {
|
||||
return if (validateHash(originalNonce, hashString)) {
|
||||
if (validateVerdict(integrityVerdict)) {
|
||||
ValidateResult.VALIDATE_SUCCESS
|
||||
} else {
|
||||
|
@ -59,9 +46,6 @@ fun validateCommand(commandString: String, integrityVerdict: IntegrityVerdict):
|
|||
ValidateResult.VALIDATE_NONCE_MISMATCH
|
||||
}
|
||||
}
|
||||
return ValidateResult.VALIDATE_NONCE_EXPIRED
|
||||
}
|
||||
}
|
||||
return ValidateResult.VALIDATE_NONCE_NOT_FOUND
|
||||
}
|
||||
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue