Add play integrity test config

This commit is contained in:
Mikołaj Pich 2023-09-19 21:24:30 +02:00
parent ec7fb1d9eb
commit 12a01f83a6
16 changed files with 361 additions and 1 deletions

View file

@ -1,2 +1,3 @@
TOKEN=
DB_HOST=db
GOOGLE_APPLICATION_CREDENTIALS=wulkanowy-gac.json

View file

@ -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
View file

@ -35,3 +35,4 @@ out/
### VS Code ###
.vscode/
.env
wulkanowy-*.json

View file

@ -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")

View file

@ -15,6 +15,8 @@ services:
db:
image: postgres:16-alpine
restart: always
ports:
- "5432:5432"
environment:
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=postgres

View file

@ -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",

View file

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

View file

@ -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) }

View file

@ -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)

View file

@ -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
)

View file

@ -0,0 +1,9 @@
package io.github.wulkanowy.schools.integrity
import kotlinx.serialization.Serializable
@Serializable
data class ServerCommand(
val commandString: String,
val tokenString: String
)

View file

@ -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
}

View file

@ -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()
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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)
}
}
}
}
}