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],
|
scraperBaseUrl = row[LoginEvents.scraperBaseUrl],
|
||||||
symbol = row[LoginEvents.symbol],
|
symbol = row[LoginEvents.symbol],
|
||||||
loginType = row[LoginEvents.loginType],
|
loginType = row[LoginEvents.loginType],
|
||||||
|
uuid = row[LoginEvents.uuid],
|
||||||
)
|
)
|
||||||
|
|
||||||
suspend fun allLoginEvents(): List<LoginEvent> = dbQuery {
|
suspend fun allLoginEvents(): List<LoginEvent> = dbQuery {
|
||||||
|
@ -28,6 +29,7 @@ class LoginEventDao {
|
||||||
suspend fun addLoginEvent(event: LoginEvent) = withContext(Dispatchers.IO) {
|
suspend fun addLoginEvent(event: LoginEvent) = withContext(Dispatchers.IO) {
|
||||||
transaction {
|
transaction {
|
||||||
LoginEvents.insert {
|
LoginEvents.insert {
|
||||||
|
it[uuid] = event.uuid
|
||||||
it[schoolName] = event.schoolName
|
it[schoolName] = event.schoolName
|
||||||
it[schoolAddress] = event.schoolAddress
|
it[schoolAddress] = event.schoolAddress
|
||||||
it[scraperBaseUrl] = event.scraperBaseUrl
|
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
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class ServerCommand(
|
data class IntegrityRequest<T>(
|
||||||
val commandString: String,
|
val tokenString: String,
|
||||||
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.http.HttpCredentialsAdapter
|
||||||
import com.google.auth.oauth2.GoogleCredentials
|
import com.google.auth.oauth2.GoogleCredentials
|
||||||
import com.google.common.collect.Lists
|
import com.google.common.collect.Lists
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
import java.util.logging.Logger
|
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 decodeTokenRequest = DecodeIntegrityTokenRequest().setIntegrityToken(tokenString)
|
||||||
val returnString = playIntegrity.v1()
|
val returnString = playIntegrity.v1()
|
||||||
.decodeIntegrityToken(APPLICATION_PACKAGE_IDENTIFIER, decodeTokenRequest)
|
.decodeIntegrityToken(APPLICATION_PACKAGE_IDENTIFIER, decodeTokenRequest)
|
||||||
|
@ -19,7 +20,7 @@ fun decryptToken(tokenString: String, playIntegrity: PlayIntegrity = getPlayInte
|
||||||
val log = Logger.getLogger("decryptToken")
|
val log = Logger.getLogger("decryptToken")
|
||||||
log.info("Decrypted token: $returnString")
|
log.info("Decrypted token: $returnString")
|
||||||
|
|
||||||
return returnString
|
return Json.decodeFromString(returnString)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getPlayIntegrity(): PlayIntegrity {
|
fun getPlayIntegrity(): PlayIntegrity {
|
||||||
|
|
|
@ -4,9 +4,6 @@ import java.security.MessageDigest
|
||||||
import java.util.*
|
import java.util.*
|
||||||
import java.util.logging.Logger
|
import java.util.logging.Logger
|
||||||
|
|
||||||
// Five minute timeout (in milliseconds)
|
|
||||||
const val NONCE_TIMEOUT = 1000 * 60 * 5
|
|
||||||
|
|
||||||
// Package name of the client application
|
// Package name of the client application
|
||||||
const val APPLICATION_PACKAGE_IDENTIFIER = "io.github.wulkanowy"
|
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_LICENSED = "LICENSED"
|
||||||
const val VERDICT_VAL_UNLICENSED = "UNLICENSED"
|
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) {
|
if (integrityVerdict.requestDetails.nonce != null) {
|
||||||
var nonceString: String = integrityVerdict.requestDetails.nonce
|
var nonceString: String = integrityVerdict.requestDetails.nonce
|
||||||
// Server might re-pad base64 with unicode '=', trim any that exist to
|
// Server might re-pad base64 with unicode '=', trim any that exist to
|
||||||
// match our web-safe original
|
// match our web-safe original
|
||||||
val utfEqualRegex = "\\u003d$".toRegex()
|
val utfEqualRegex = "\\u003d$".toRegex()
|
||||||
nonceString = utfEqualRegex.replace(nonceString, "")
|
nonceString = utfEqualRegex.replace(nonceString, "")
|
||||||
val nonceBytes = Base64.getUrlDecoder().decode(nonceString)
|
|
||||||
// The nonce string contains two parts, the random number previously generated,
|
// The nonce string contains two parts, the random number previously generated,
|
||||||
// and the SHA256 hash of the command string, we need to separate them
|
// 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
|
// 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("Random nonce segment: $randomString")
|
||||||
log.info("Hash nonce segment: $hashString")
|
log.info("Hash nonce segment: $hashString")
|
||||||
|
|
||||||
// Verify the random part of the nonce was a random number previously generated on
|
return if (validateHash(originalNonce, hashString)) {
|
||||||
// the server, and that it hasn't expired
|
if (validateVerdict(integrityVerdict)) {
|
||||||
val matchingRandom = randomStorage.find { it.random == randomString }
|
ValidateResult.VALIDATE_SUCCESS
|
||||||
if (matchingRandom != null) {
|
} else {
|
||||||
val currentTimestamp = System.currentTimeMillis()
|
ValidateResult.VALIDATE_INTEGRITY_FAIL
|
||||||
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 ValidateResult.VALIDATE_NONCE_EXPIRED
|
} else {
|
||||||
|
ValidateResult.VALIDATE_NONCE_MISMATCH
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return ValidateResult.VALIDATE_NONCE_NOT_FOUND
|
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 messageDigest = MessageDigest.getInstance("SHA-256")
|
||||||
val commandHashBytes = messageDigest.digest(commandString.toByteArray(Charsets.UTF_8))
|
val commandHashBytes = messageDigest.digest(commandString.toByteArray(Charsets.UTF_8))
|
||||||
val commandHashString = commandHashBytes.toHexString()
|
val commandHashString = commandHashBytes.toHexString()
|
||||||
|
@ -80,7 +64,7 @@ fun validateHash(commandString: String, hashString: String) : Boolean {
|
||||||
return hashMatch
|
return hashMatch
|
||||||
}
|
}
|
||||||
|
|
||||||
fun validateVerdict(integrityVerdict: IntegrityVerdict) : Boolean {
|
fun validateVerdict(integrityVerdict: IntegrityVerdict): Boolean {
|
||||||
// Process the integrity verdict and 'validate' the command if the following positive
|
// Process the integrity verdict and 'validate' the command if the following positive
|
||||||
// signals exist:
|
// signals exist:
|
||||||
// 1) Positive device integrity signal
|
// 1) Positive device integrity signal
|
||||||
|
@ -99,12 +83,9 @@ fun validateVerdict(integrityVerdict: IntegrityVerdict) : Boolean {
|
||||||
|
|
||||||
if (metDeviceIntegrity) {
|
if (metDeviceIntegrity) {
|
||||||
val recognitionVerdict = integrityVerdict.appIntegrity.appRecognitionVerdict
|
val recognitionVerdict = integrityVerdict.appIntegrity.appRecognitionVerdict
|
||||||
if (recognitionVerdict == VERDICT_VAL_VERSION_RECOGNIZED ||
|
if (recognitionVerdict == VERDICT_VAL_VERSION_RECOGNIZED || recognitionVerdict == VERDICT_VAL_VERSION_UNRECOGNIZED) {
|
||||||
recognitionVerdict == VERDICT_VAL_VERSION_UNRECOGNIZED) {
|
if (integrityVerdict.accountDetails.appLicensingVerdict == VERDICT_VAL_LICENSED) {
|
||||||
if (integrityVerdict.accountDetails.appLicensingVerdict ==
|
if (integrityVerdict.requestDetails.requestPackageName == APPLICATION_PACKAGE_IDENTIFIER) {
|
||||||
VERDICT_VAL_LICENSED) {
|
|
||||||
if (integrityVerdict.requestDetails.requestPackageName ==
|
|
||||||
APPLICATION_PACKAGE_IDENTIFIER) {
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,7 +5,6 @@ package io.github.wulkanowy.schools.integrity
|
||||||
enum class ValidateResult {
|
enum class ValidateResult {
|
||||||
VALIDATE_SUCCESS,
|
VALIDATE_SUCCESS,
|
||||||
VALIDATE_NONCE_NOT_FOUND,
|
VALIDATE_NONCE_NOT_FOUND,
|
||||||
VALIDATE_NONCE_EXPIRED,
|
|
||||||
VALIDATE_NONCE_MISMATCH,
|
VALIDATE_NONCE_MISMATCH,
|
||||||
VALIDATE_INTEGRITY_FAIL
|
VALIDATE_INTEGRITY_FAIL
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,9 +3,11 @@ package io.github.wulkanowy.schools.model
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
import org.jetbrains.exposed.sql.*
|
import org.jetbrains.exposed.sql.*
|
||||||
import org.jetbrains.exposed.sql.javatime.timestamp
|
import org.jetbrains.exposed.sql.javatime.timestamp
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class LoginEvent(
|
data class LoginEvent(
|
||||||
|
val uuid: String,
|
||||||
val schoolName: String,
|
val schoolName: String,
|
||||||
val schoolAddress: String,
|
val schoolAddress: String,
|
||||||
val scraperBaseUrl: String,
|
val scraperBaseUrl: String,
|
||||||
|
@ -15,6 +17,7 @@ data class LoginEvent(
|
||||||
|
|
||||||
object LoginEvents : Table() {
|
object LoginEvents : Table() {
|
||||||
val id = integer("id").autoIncrement()
|
val id = integer("id").autoIncrement()
|
||||||
|
val uuid = varchar("uuid", 36)
|
||||||
val timestamp = timestamp("timestamp")
|
val timestamp = timestamp("timestamp")
|
||||||
val schoolName = varchar("schoolName", 256)
|
val schoolName = varchar("schoolName", 256)
|
||||||
val schoolAddress = varchar("schoolAddress", 256)
|
val schoolAddress = varchar("schoolAddress", 256)
|
||||||
|
@ -23,4 +26,8 @@ object LoginEvents : Table() {
|
||||||
val loginType = varchar("loginType", 32)
|
val loginType = varchar("loginType", 32)
|
||||||
|
|
||||||
override val primaryKey = PrimaryKey(id)
|
override val primaryKey = PrimaryKey(id)
|
||||||
|
|
||||||
|
init {
|
||||||
|
uniqueIndex("Unique event constraint", uuid)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,87 +1,39 @@
|
||||||
package io.github.wulkanowy.schools.plugins
|
package io.github.wulkanowy.schools.plugins
|
||||||
|
|
||||||
import com.google.gson.Gson
|
|
||||||
import io.github.wulkanowy.schools.dao.LoginEventDao
|
import io.github.wulkanowy.schools.dao.LoginEventDao
|
||||||
import io.github.wulkanowy.schools.integrity.*
|
import io.github.wulkanowy.schools.integrity.*
|
||||||
import io.github.wulkanowy.schools.model.LoginEvent
|
import io.github.wulkanowy.schools.model.LoginEvent
|
||||||
import io.ktor.http.*
|
import io.ktor.http.*
|
||||||
import io.ktor.server.application.*
|
import io.ktor.server.application.*
|
||||||
import io.ktor.server.auth.*
|
|
||||||
import io.ktor.server.request.*
|
import io.ktor.server.request.*
|
||||||
import io.ktor.server.response.*
|
import io.ktor.server.response.*
|
||||||
import io.ktor.server.routing.*
|
import io.ktor.server.routing.*
|
||||||
|
|
||||||
fun Application.configureRouting() {
|
fun Application.configureRouting() {
|
||||||
val loginEventDao = LoginEventDao()
|
val loginEventDao = LoginEventDao()
|
||||||
|
val playIntegrity = getPlayIntegrity()
|
||||||
|
|
||||||
routing {
|
routing {
|
||||||
authenticate("auth") {
|
post("/log/loginEvent") {
|
||||||
post("/log/loginEvent") {
|
val request = call.receive<IntegrityRequest<LoginEvent>>()
|
||||||
val loginEvent = call.receive<LoginEvent>()
|
val integrityVerdictPayload = decryptToken(request.tokenString, playIntegrity)
|
||||||
loginEventDao.addLoginEvent(loginEvent)
|
val integrityVerdict = integrityVerdictPayload.tokenPayloadExternal
|
||||||
call.respond(status = HttpStatusCode.NoContent, "")
|
|
||||||
|
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("/") {
|
get("/") {
|
||||||
call.respond(loginEventDao.allLoginEvents())
|
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