Send signatures and verify signature of patches file before loading it

This commit is contained in:
oSumAtrIX
2024-06-29 03:44:55 +02:00
parent f9cae1ea56
commit 4a685a2b53
17 changed files with 302 additions and 73 deletions

View File

@@ -39,6 +39,7 @@ internal object StartAPICommand : Runnable {
configureSerialization()
configureSecurity()
configureOpenAPI()
configureLogging()
configureRouting()
}.start(wait = true)
}

View File

@@ -4,6 +4,7 @@ import app.revanced.api.configuration.repository.AnnouncementRepository
import app.revanced.api.configuration.repository.BackendRepository
import app.revanced.api.configuration.repository.ConfigurationRepository
import app.revanced.api.configuration.repository.GitHubBackendRepository
import app.revanced.api.configuration.services.*
import app.revanced.api.configuration.services.AnnouncementService
import app.revanced.api.configuration.services.ApiService
import app.revanced.api.configuration.services.AuthService
@@ -130,6 +131,7 @@ fun Application.configureDependencies(
)
}
singleOf(::AnnouncementService)
singleOf(::SignatureService)
singleOf(::PatchesService)
singleOf(::ApiService)
}

View File

@@ -0,0 +1,16 @@
package app.revanced.api.configuration
import io.ktor.server.application.*
import io.ktor.server.plugins.callloging.*
import io.ktor.server.request.*
internal fun Application.configureLogging() {
install(CallLogging) {
format { call ->
val status = call.response.status()
val httpMethod = call.request.httpMethod.value
val uri = call.request.uri
"$status $httpMethod $uri"
}
}
}

View File

@@ -4,7 +4,7 @@ import io.ktor.client.*
import kotlinx.datetime.LocalDateTime
/**
* The backend of the application used to get data for the API.
* The backend of the API used to get data.
*
* @param client The HTTP client to use for requests.
*/
@@ -97,12 +97,18 @@ abstract class BackendRepository internal constructor(
val createdAt: LocalDateTime,
val assets: Set<BackendAsset>,
) {
companion object {
fun Set<BackendAsset>.first(assetRegex: Regex) = first { assetRegex.containsMatchIn(it.name) }
}
/**
* An asset of a release.
*
* @property name The name of the asset.
* @property downloadUrl The URL to download the asset.
*/
class BackendAsset(
val name: String,
val downloadUrl: String,
)
}

View File

@@ -1,18 +1,78 @@
package app.revanced.api.configuration.repository
import app.revanced.api.configuration.services.PatchesService
import kotlinx.serialization.KSerializer
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.descriptors.PrimitiveKind
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import java.io.File
/**
* The repository storing the configuration for the API.
*
* @property organization The API backends organization name where the repositories for the patches and integrations are.
* @property patches The source of the patches.
* @property integrations The source of the integrations.
* @property contributorsRepositoryNames The names of the repositories to get contributors from.
* @property apiVersion The version to use for the API.
* @property host The host of the API to configure CORS.
*/
@Serializable
internal class ConfigurationRepository(
val organization: String,
@SerialName("patches-repository")
val patchesRepository: String,
@SerialName("integrations-repositories")
val integrationsRepositoryNames: Set<String>,
val patches: AssetConfiguration,
val integrations: AssetConfiguration,
@SerialName("contributors-repositories")
val contributorsRepositoryNames: Set<String>,
@SerialName("api-version")
val apiVersion: Int = 1,
val host: String,
)
) {
/**
* An asset configuration.
*
* [PatchesService] uses [BackendRepository] to get assets from its releases.
* A release contains multiple assets.
*
* This configuration is used in [ConfigurationRepository]
* to determine which release assets from repositories to get and to verify them.
*
* @property repository The repository in which releases are made to get an asset.
* @property assetRegex The regex matching the asset name.
* @property signatureAssetRegex The regex matching the signature asset name to verify the asset.
* @property publicKeyFile The public key file to verify the signature of the asset.
*/
@Serializable
internal class AssetConfiguration(
val repository: String,
@Serializable(with = RegexSerializer::class)
@SerialName("asset-regex")
val assetRegex: Regex,
@Serializable(with = RegexSerializer::class)
@SerialName("signature-asset-regex")
val signatureAssetRegex: Regex,
@Serializable(with = FileSerializer::class)
@SerialName("public-key-file")
val publicKeyFile: File,
)
}
private object RegexSerializer : KSerializer<Regex> {
override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("Regex", PrimitiveKind.STRING)
override fun serialize(encoder: Encoder, value: Regex) = encoder.encodeString(value.pattern)
override fun deserialize(decoder: Decoder) = Regex(decoder.decodeString())
}
private object FileSerializer : KSerializer<File> {
override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("File", PrimitiveKind.STRING)
override fun serialize(encoder: Encoder, value: File) = encoder.encodeString(value.path)
override fun deserialize(decoder: Decoder) = File(decoder.decodeString())
}

View File

@@ -35,7 +35,10 @@ class GitHubBackendRepository(client: HttpClient) : BackendRepository(client) {
releaseNote = release.body,
createdAt = release.createdAt.toLocalDateTime(TimeZone.UTC),
assets = release.assets.map {
BackendAsset(downloadUrl = it.browserDownloadUrl)
BackendAsset(
name = it.name,
downloadUrl = it.browserDownloadUrl,
)
}.toSet(),
)
}
@@ -156,6 +159,7 @@ class GitHubOrganization {
) {
@Serializable
class GitHubAsset(
val name: String,
val browserDownloadUrl: String,
)
}

View File

@@ -1,6 +1,8 @@
package app.revanced.api.configuration.routes
import app.revanced.api.configuration.installCache
import app.revanced.api.configuration.installNotarizedRoute
import app.revanced.api.configuration.schema.APIAssetPublicKeys
import app.revanced.api.configuration.schema.APIRelease
import app.revanced.api.configuration.schema.APIReleaseVersion
import app.revanced.api.configuration.services.PatchesService
@@ -10,6 +12,7 @@ import io.ktor.server.application.*
import io.ktor.server.plugins.ratelimit.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import kotlin.time.Duration.Companion.days
import org.koin.ktor.ext.get as koinGet
internal fun Route.patchesRoute() = route("patches") {
@@ -42,6 +45,18 @@ internal fun Route.patchesRoute() = route("patches") {
}
}
}
rateLimit(RateLimitName("strong")) {
route("keys") {
installCache(356.days)
installPatchesPublicKeyRouteDocumentation()
get {
call.respond(patchesService.publicKeys())
}
}
}
}
fun Route.installLatestPatchesRouteDocumentation() = installNotarizedRoute {
@@ -88,3 +103,18 @@ fun Route.installLatestPatchesListRouteDocumentation() = installNotarizedRoute {
}
}
}
fun Route.installPatchesPublicKeyRouteDocumentation() = installNotarizedRoute {
tags = setOf("Patches")
get = GetInfo.builder {
description("Get the public keys for verifying patches and integrations assets")
summary("Get patches and integrations public keys")
response {
description("The public keys")
mediaTypes("application/json")
responseCode(HttpStatusCode.OK)
responseType<APIAssetPublicKeys>()
}
}
}

View File

@@ -1,14 +1,13 @@
package app.revanced.api.configuration.schema
import kotlinx.datetime.LocalDateTime
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
class APIRelease(
val version: String,
val createdAt: LocalDateTime,
val changelog: String,
val description: String,
val assets: Set<APIAsset>,
)
@@ -49,23 +48,15 @@ class APIContributable(
@Serializable
class APIAsset(
val downloadUrl: String,
) {
val type = when {
downloadUrl.endsWith(".jar") -> Type.PATCHES
downloadUrl.endsWith(".apk") -> Type.INTEGRATIONS
else -> Type.UNKNOWN
}
val signatureDownloadUrl: String,
// TODO: Remove this eventually when integrations are merged into patches.
val type: APIAssetType,
)
enum class Type {
@SerialName("patches")
PATCHES,
@SerialName("integrations")
INTEGRATIONS,
@SerialName("unknown")
UNKNOWN,
}
@Serializable
enum class APIAssetType {
PATCHES,
INTEGRATION,
}
@Serializable
@@ -113,3 +104,9 @@ class APIRateLimit(
val remaining: Int,
val reset: LocalDateTime,
)
@Serializable
class APIAssetPublicKeys(
val patchesPublicKey: String,
val integrationsPublicKey: String,
)

View File

@@ -1,87 +1,119 @@
package app.revanced.api.configuration.services
import app.revanced.api.configuration.repository.BackendRepository
import app.revanced.api.configuration.repository.BackendRepository.BackendOrganization.BackendRepository.BackendRelease.Companion.first
import app.revanced.api.configuration.repository.ConfigurationRepository
import app.revanced.api.configuration.schema.APIAsset
import app.revanced.api.configuration.schema.APIRelease
import app.revanced.api.configuration.schema.APIReleaseVersion
import app.revanced.api.configuration.schema.*
import app.revanced.library.PatchUtils
import app.revanced.patcher.PatchBundleLoader
import com.github.benmanes.caffeine.cache.Caffeine
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.withContext
import io.ktor.util.*
import java.io.ByteArrayOutputStream
import java.net.URL
internal class PatchesService(
private val signatureService: SignatureService,
private val backendRepository: BackendRepository,
private val configurationRepository: ConfigurationRepository,
) {
private val patchesListCache = Caffeine
.newBuilder()
.maximumSize(1)
.build<String, ByteArray>()
suspend fun latestRelease(): APIRelease {
val patchesRelease = backendRepository.release(
configurationRepository.organization,
configurationRepository.patchesRepository,
configurationRepository.patches.repository,
)
val integrationsReleases = withContext(Dispatchers.Default) {
configurationRepository.integrationsRepositoryNames.map {
async { backendRepository.release(configurationRepository.organization, it) }
}
}.awaitAll()
val assets = (patchesRelease.assets + integrationsReleases.flatMap { it.assets })
.map { APIAsset(it.downloadUrl) }
.filter { it.type != APIAsset.Type.UNKNOWN }
.toSet()
val integrationsRelease = backendRepository.release(
configurationRepository.organization,
configurationRepository.integrations.repository,
)
fun ConfigurationRepository.AssetConfiguration.asset(
release: BackendRepository.BackendOrganization.BackendRepository.BackendRelease,
assetType: APIAssetType,
) = APIAsset(
release.assets.first(assetRegex).downloadUrl,
release.assets.first(signatureAssetRegex).downloadUrl,
assetType,
)
val patchesAsset = configurationRepository.patches.asset(
patchesRelease,
APIAssetType.PATCHES,
)
val integrationsAsset = configurationRepository.integrations.asset(
integrationsRelease,
APIAssetType.INTEGRATION,
)
return APIRelease(
patchesRelease.tag,
patchesRelease.createdAt,
patchesRelease.releaseNote,
assets,
setOf(patchesAsset, integrationsAsset),
)
}
suspend fun latestVersion(): APIReleaseVersion {
val patchesRelease = backendRepository.release(
configurationRepository.organization,
configurationRepository.patchesRepository,
configurationRepository.patches.repository,
)
return APIReleaseVersion(patchesRelease.tag)
}
private val patchesListCache = Caffeine
.newBuilder()
.maximumSize(1)
.build<String, ByteArray>()
suspend fun list(): ByteArray {
val patchesRelease = backendRepository.release(
configurationRepository.organization,
configurationRepository.patchesRepository,
configurationRepository.patches.repository,
)
return patchesListCache.getIfPresent(patchesRelease.tag) ?: run {
val downloadUrl = patchesRelease.assets
.map { APIAsset(it.downloadUrl) }
.find { it.type == APIAsset.Type.PATCHES }
?.downloadUrl
return patchesListCache.get(patchesRelease.tag) {
val patchesDownloadUrl = patchesRelease.assets
.first(configurationRepository.patches.assetRegex).downloadUrl
val patches = kotlin.io.path.createTempFile().toFile().apply {
outputStream().use { URL(downloadUrl).openStream().copyTo(it) }
}.let { file ->
PatchBundleLoader.Jar(file).also { file.delete() }
val signatureDownloadUrl = patchesRelease.assets
.first(configurationRepository.patches.signatureAssetRegex).downloadUrl
val patchesFile = kotlin.io.path.createTempFile().toFile().apply {
outputStream().use { URL(patchesDownloadUrl).openStream().copyTo(it) }
}
val patches = if (
signatureService.verify(
patchesFile,
signatureDownloadUrl,
configurationRepository.patches.publicKeyFile,
)
) {
PatchBundleLoader.Jar(patchesFile)
} else {
// Use an empty set of patches if the signature is invalid.
emptySet()
}
patchesFile.delete()
ByteArrayOutputStream().use { stream ->
PatchUtils.Json.serialize(patches, outputStream = stream)
stream.toByteArray()
}.also {
patchesListCache.put(patchesRelease.tag, it)
}
}
}
fun publicKeys(): APIAssetPublicKeys {
fun publicKeyBase64(getAssetConfiguration: ConfigurationRepository.() -> ConfigurationRepository.AssetConfiguration) =
configurationRepository.getAssetConfiguration().publicKeyFile.readBytes().encodeBase64()
return APIAssetPublicKeys(
publicKeyBase64 { patches },
publicKeyBase64 { integrations },
)
}
}

View File

@@ -0,0 +1,72 @@
package app.revanced.api.configuration.services
import com.github.benmanes.caffeine.cache.Caffeine
import org.bouncycastle.jce.provider.BouncyCastleProvider
import org.bouncycastle.openpgp.*
import org.bouncycastle.openpgp.operator.bc.BcKeyFingerprintCalculator
import org.bouncycastle.openpgp.operator.bc.BcPGPContentVerifierBuilderProvider
import java.io.File
import java.io.InputStream
import java.net.URL
import java.security.MessageDigest
import java.security.Security
internal class SignatureService {
private val signatureCache = Caffeine
.newBuilder()
.maximumSize(2) // Assuming this is enough for patches and integrations.
.build<ByteArray, Boolean>() // Hash -> Verified.
fun verify(
file: File,
signatureDownloadUrl: String,
publicKeyFile: File,
): Boolean {
val fileBytes = file.readBytes()
return signatureCache.get(MessageDigest.getInstance("SHA-256").digest(fileBytes)) {
verify(
fileBytes = fileBytes,
signatureInputStream = URL(signatureDownloadUrl).openStream(),
publicKeyInputStream = publicKeyFile.inputStream(),
)
}
}
private fun verify(
fileBytes: ByteArray,
signatureInputStream: InputStream,
publicKeyInputStream: InputStream,
) = getSignature(signatureInputStream).apply {
init(BcPGPContentVerifierBuilderProvider(), getPublicKey(publicKeyInputStream))
update(fileBytes)
}.verify()
private fun getPublicKey(publicKeyInputStream: InputStream): PGPPublicKey {
val decoderStream = PGPUtil.getDecoderStream(publicKeyInputStream)
PGPPublicKeyRingCollection(decoderStream, BcKeyFingerprintCalculator()).forEach { keyRing ->
keyRing.publicKeys.forEach { publicKey ->
if (publicKey.isEncryptionKey) {
return publicKey
}
}
}
throw IllegalArgumentException("Can't find encryption key in key ring.")
}
private fun getSignature(inputStream: InputStream): PGPSignature {
val decoderStream = PGPUtil.getDecoderStream(inputStream)
val pgpObjectFactory = PGPObjectFactory(decoderStream, BcKeyFingerprintCalculator())
val signatureList = pgpObjectFactory.nextObject() as PGPSignatureList
return signatureList.first()
}
private companion object {
init {
Security.addProvider(BouncyCastleProvider())
}
}
}

View File

@@ -4,8 +4,7 @@
<pattern>%d{YYYY-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
</encoder>
</appender>
<root level="trace">
<root level="info">
<appender-ref ref="STDOUT"/>
</root>
<logger name="org.eclipse.jetty" level="INFO"/>
</configuration>