Add play integrity test config
This commit is contained in:
parent
ec7fb1d9eb
commit
12a01f83a6
16 changed files with 361 additions and 1 deletions
|
@ -1,2 +1,3 @@
|
|||
TOKEN=
|
||||
DB_HOST=db
|
||||
GOOGLE_APPLICATION_CREDENTIALS=wulkanowy-gac.json
|
||||
|
|
5
.github/workflows/deploy.yml
vendored
5
.github/workflows/deploy.yml
vendored
|
@ -16,6 +16,11 @@ jobs:
|
|||
envkey_DEBUG: true
|
||||
envkey_DB_HOST: "db"
|
||||
envkey_TOKEN: ${{ secrets.TOKEN }}
|
||||
envkey_GOOGLE_APPLICATION_CREDENTIALS: "wulkanowy-gac.json"
|
||||
- name: Create File
|
||||
run: echo $GAC_CONTENT > wulkanowy-gac.json
|
||||
env:
|
||||
GAC_CONTENT: ${{ secrets.$GAC_CONTENT }}
|
||||
- uses: alex-ac/github-action-ssh-docker-compose@master
|
||||
name: Docker-Compose Remote Deployment
|
||||
with:
|
||||
|
|
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -35,3 +35,4 @@ out/
|
|||
### VS Code ###
|
||||
.vscode/
|
||||
.env
|
||||
wulkanowy-*.json
|
||||
|
|
|
@ -41,6 +41,8 @@ dependencies {
|
|||
implementation("io.ktor:ktor-server-netty-jvm:$ktor_version")
|
||||
implementation("ch.qos.logback:logback-classic:$logback_version")
|
||||
implementation("io.ktor:ktor-server-auth:$ktor_version")
|
||||
implementation("com.google.apis:google-api-services-playintegrity:v1-rev20230910-2.0.0")
|
||||
implementation("com.google.auth:google-auth-library-oauth2-http:1.19.0")
|
||||
|
||||
implementation("org.jetbrains.exposed:exposed-core:$exposed_version")
|
||||
implementation("org.jetbrains.exposed:exposed-dao:$exposed_version")
|
||||
|
|
|
@ -15,6 +15,8 @@ services:
|
|||
db:
|
||||
image: postgres:16-alpine
|
||||
restart: always
|
||||
ports:
|
||||
- "5432:5432"
|
||||
environment:
|
||||
- POSTGRES_USER=postgres
|
||||
- POSTGRES_PASSWORD=postgres
|
||||
|
|
|
@ -9,7 +9,7 @@ import org.jetbrains.exposed.sql.transactions.transaction
|
|||
|
||||
object DatabaseFactory {
|
||||
fun init() {
|
||||
val host = System.getenv("DB_HOST") ?: "localhost"
|
||||
val host = System.getenv("DB_HOST")
|
||||
val database = Database.connect(
|
||||
url = "jdbc:pgsql://$host:5432/schools",
|
||||
driver = "com.impossibl.postgres.jdbc.PGDriver",
|
||||
|
|
|
@ -0,0 +1,9 @@
|
|||
package io.github.wulkanowy.schools.integrity
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class CommandResult(
|
||||
val commandSuccess: Boolean,
|
||||
val diagnosticMessage: String,
|
||||
)
|
|
@ -0,0 +1,7 @@
|
|||
package io.github.wulkanowy.schools.integrity
|
||||
|
||||
const val RANDOM_BYTE_COUNT = 16
|
||||
|
||||
fun ByteArray.toHexString(): String = joinToString(separator = "") {
|
||||
currentByte -> "%02x".format(currentByte) }
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
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)
|
|
@ -0,0 +1,41 @@
|
|||
package io.github.wulkanowy.schools.integrity
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class RequestDetails(
|
||||
val requestPackageName: String? = null,
|
||||
val timestampMillis: Long = 0,
|
||||
val nonce: String? = null
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class AppIntegrity(
|
||||
val appRecognitionVerdict: String? = null,
|
||||
val packageName: String? = null,
|
||||
val certificateSha256Digest: List<String>? = null,
|
||||
val versionCode: Int = 0
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class DeviceIntegrity(
|
||||
val deviceRecognitionVerdict: List<String>? = null
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class AccountDetails(
|
||||
val appLicensingVerdict: String? = null
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class IntegrityVerdict(
|
||||
val requestDetails: RequestDetails,
|
||||
val appIntegrity: AppIntegrity,
|
||||
val deviceIntegrity: DeviceIntegrity,
|
||||
val accountDetails: AccountDetails
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class IntegrityVerdictPayload(
|
||||
val tokenPayloadExternal: IntegrityVerdict
|
||||
)
|
|
@ -0,0 +1,9 @@
|
|||
package io.github.wulkanowy.schools.integrity
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class ServerCommand(
|
||||
val commandString: String,
|
||||
val tokenString: String
|
||||
)
|
|
@ -0,0 +1,54 @@
|
|||
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
|
||||
}
|
|
@ -0,0 +1,35 @@
|
|||
package io.github.wulkanowy.schools.integrity
|
||||
|
||||
import com.google.api.client.http.javanet.NetHttpTransport
|
||||
import com.google.api.client.json.gson.GsonFactory
|
||||
import com.google.api.services.playintegrity.v1.PlayIntegrity
|
||||
import com.google.api.services.playintegrity.v1.model.DecodeIntegrityTokenRequest
|
||||
import com.google.auth.http.HttpCredentialsAdapter
|
||||
import com.google.auth.oauth2.GoogleCredentials
|
||||
import com.google.common.collect.Lists
|
||||
import java.util.logging.Logger
|
||||
|
||||
fun decryptToken(tokenString: String, playIntegrity: PlayIntegrity = getPlayIntegrity()): String {
|
||||
val decodeTokenRequest = DecodeIntegrityTokenRequest().setIntegrityToken(tokenString)
|
||||
val returnString = playIntegrity.v1()
|
||||
.decodeIntegrityToken(APPLICATION_PACKAGE_IDENTIFIER, decodeTokenRequest)
|
||||
.execute()
|
||||
.toPrettyString()
|
||||
|
||||
val log = Logger.getLogger("decryptToken")
|
||||
log.info("Decrypted token: $returnString")
|
||||
|
||||
return returnString
|
||||
}
|
||||
|
||||
fun getPlayIntegrity(): PlayIntegrity {
|
||||
val googleCredentials = GoogleCredentials.getApplicationDefault()
|
||||
.createScoped(Lists.newArrayList("https://www.googleapis.com/auth/playintegrity"))
|
||||
return PlayIntegrity.Builder(
|
||||
NetHttpTransport(),
|
||||
GsonFactory.getDefaultInstance(),
|
||||
HttpCredentialsAdapter(googleCredentials)
|
||||
)
|
||||
.setApplicationName("application")
|
||||
.build()
|
||||
}
|
|
@ -0,0 +1,114 @@
|
|||
package io.github.wulkanowy.schools.integrity
|
||||
|
||||
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"
|
||||
|
||||
// Values returned by the verdict that provide integrity signals
|
||||
const val VERDICT_VAL_MEETS_BASIC_INTEGRITY = "MEETS_BASIC_INTEGRITY"
|
||||
const val VERDICT_VAL_MEETS_DEVICE_INTEGRITY = "MEETS_DEVICE_INTEGRITY"
|
||||
const val VERDICT_VAL_MEETS_STRONG_INTEGRITY = "MEETS_STRONG_INTEGRITY"
|
||||
const val VERDICT_VAL_MEETS_VIRTUAL_INTEGRITY = "MEETS_VIRTUAL_INTEGRITY"
|
||||
const val VERDICT_VAL_VERSION_UNRECOGNIZED = "UNRECOGNIZED_VERSION"
|
||||
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 {
|
||||
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
|
||||
// we don't actually base64 decode them.
|
||||
val randomString = nonceString.slice(IntRange(0, (RANDOM_BYTE_COUNT * 2) - 1))
|
||||
val hashString = nonceString.slice(IntRange(RANDOM_BYTE_COUNT * 2, nonceString.lastIndex))
|
||||
|
||||
val log = Logger.getLogger("validateCommand")
|
||||
log.info("Raw nonce: $nonceString")
|
||||
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 ValidateResult.VALIDATE_NONCE_EXPIRED
|
||||
}
|
||||
}
|
||||
return ValidateResult.VALIDATE_NONCE_NOT_FOUND
|
||||
}
|
||||
|
||||
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()
|
||||
val hashMatch = hashString.contentEquals(commandHashString)
|
||||
|
||||
val log = Logger.getLogger("validateHash")
|
||||
log.info("Command string: $commandString")
|
||||
log.info("token hash string: $hashString")
|
||||
log.info("command hash string: $commandHashString")
|
||||
log.info("hashMatch: $hashMatch")
|
||||
|
||||
return hashMatch
|
||||
}
|
||||
|
||||
fun validateVerdict(integrityVerdict: IntegrityVerdict) : Boolean {
|
||||
// Process the integrity verdict and 'validate' the command if the following positive
|
||||
// signals exist:
|
||||
// 1) Positive device integrity signal
|
||||
// 2) Recognized app version signal
|
||||
// 3) Licensed user signal
|
||||
// 4) Application package identifier match
|
||||
var metDeviceIntegrity = false
|
||||
for (deviceField in integrityVerdict.deviceIntegrity.deviceRecognitionVerdict!!) {
|
||||
when (deviceField) {
|
||||
VERDICT_VAL_MEETS_BASIC_INTEGRITY -> metDeviceIntegrity = true
|
||||
VERDICT_VAL_MEETS_DEVICE_INTEGRITY -> metDeviceIntegrity = true
|
||||
VERDICT_VAL_MEETS_STRONG_INTEGRITY -> metDeviceIntegrity = true
|
||||
VERDICT_VAL_MEETS_VIRTUAL_INTEGRITY -> metDeviceIntegrity = true
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
|
@ -0,0 +1,11 @@
|
|||
package io.github.wulkanowy.schools.integrity
|
||||
|
||||
// Return values for the validation result of a command with an associated
|
||||
// Play Integrity token
|
||||
enum class ValidateResult {
|
||||
VALIDATE_SUCCESS,
|
||||
VALIDATE_NONCE_NOT_FOUND,
|
||||
VALIDATE_NONCE_EXPIRED,
|
||||
VALIDATE_NONCE_MISMATCH,
|
||||
VALIDATE_INTEGRITY_FAIL
|
||||
}
|
|
@ -1,6 +1,8 @@
|
|||
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.*
|
||||
|
@ -23,5 +25,63 @@ fun Application.configureRouting() {
|
|||
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