From 4ab7dbddece79b0a2c56ab75d7348e02d1973002 Mon Sep 17 00:00:00 2001
From: grimsi <9295182+grimsi@users.noreply.github.com>
Date: Wed, 2 Apr 2025 10:35:15 +0200
Subject: [PATCH] Implement plugin verification via certificates and JAR
signing
---
.gitignore | 1 +
.../general/cards/PluginManagementCard.tsx | 32 +++++-
.../DatabasePluginStatusProvider.kt | 9 +-
.../management/GameyfinPluginManager.kt | 95 +++++++++++++++++-
.../core/plugins/management/PluginDto.kt | 3 +-
.../management/PluginManagementEntry.kt | 6 +-
.../management/PluginManagementService.kt | 8 +-
.../plugins/management/PluginTrustLevel.kt | 8 ++
gameyfin/src/main/resources/application.yml | 2 +-
.../certificates/gameyfin-plugins.pem | 23 +++++
.../main/resources/certificates/gameyfin.jks | Bin 0 -> 3468 bytes
11 files changed, 167 insertions(+), 20 deletions(-)
create mode 100644 gameyfin/src/main/kotlin/de/grimsi/gameyfin/core/plugins/management/PluginTrustLevel.kt
create mode 100644 gameyfin/src/main/resources/certificates/gameyfin-plugins.pem
create mode 100644 gameyfin/src/main/resources/certificates/gameyfin.jks
diff --git a/.gitignore b/.gitignore
index b2503e1..502ff23 100644
--- a/.gitignore
+++ b/.gitignore
@@ -50,3 +50,4 @@ logs
templates
/gameyfin/src/main/frontend/**/*.js
/gameyfin/src/main/frontend/**/*.js.map
+/gameyfin/src/main/bundles/
diff --git a/gameyfin/src/main/frontend/components/general/cards/PluginManagementCard.tsx b/gameyfin/src/main/frontend/components/general/cards/PluginManagementCard.tsx
index 87bcb49..1de63b5 100644
--- a/gameyfin/src/main/frontend/components/general/cards/PluginManagementCard.tsx
+++ b/gameyfin/src/main/frontend/components/general/cards/PluginManagementCard.tsx
@@ -5,6 +5,9 @@ import {
PlayCircle,
Power,
QuestionMark,
+ SealCheck,
+ SealQuestion,
+ SealWarning,
SlidersHorizontal,
StopCircle,
WarningCircle
@@ -15,6 +18,7 @@ import PluginState from "Frontend/generated/org/pf4j/PluginState";
import React, {ReactNode, useEffect, useState} from "react";
import PluginDetailsModal from "Frontend/components/general/modals/PluginDetailsModal";
import PluginLogo from "Frontend/components/general/PluginLogo";
+import PluginTrustLevel from "Frontend/generated/de/grimsi/gameyfin/core/plugins/management/PluginTrustLevel";
export function PluginManagementCard({plugin, updatePlugin}: {
plugin: PluginDto,
@@ -63,6 +67,27 @@ export function PluginManagementCard({plugin, updatePlugin}: {
}
}
+ function trustLevelToBadge(trustLevel: PluginTrustLevel | undefined): React.ReactNode {
+ switch (trustLevel) {
+ case PluginTrustLevel.OFFICIAL:
+ return
+
+ ;
+ case PluginTrustLevel.BUNDLED:
+ return
+
+ ;
+ case PluginTrustLevel.THIRD_PARTY:
+ return
+
+ ;
+ default:
+ return
+
+ ;
+ }
+ }
+
function isDisabled(state: PluginState | undefined): boolean {
return state === PluginState.DISABLED;
}
@@ -102,9 +127,12 @@ export function PluginManagementCard({plugin, updatePlugin}: {
-
+
- {plugin.name}
+
+ {plugin.name}
+ {trustLevelToBadge(plugin.trustLevel)}
+
{plugin.version}
diff --git a/gameyfin/src/main/kotlin/de/grimsi/gameyfin/core/plugins/management/DatabasePluginStatusProvider.kt b/gameyfin/src/main/kotlin/de/grimsi/gameyfin/core/plugins/management/DatabasePluginStatusProvider.kt
index 29cc161..2832dcc 100644
--- a/gameyfin/src/main/kotlin/de/grimsi/gameyfin/core/plugins/management/DatabasePluginStatusProvider.kt
+++ b/gameyfin/src/main/kotlin/de/grimsi/gameyfin/core/plugins/management/DatabasePluginStatusProvider.kt
@@ -12,15 +12,8 @@ class DatabasePluginStatusProvider(
override fun isPluginDisabled(pluginId: String): Boolean {
var pluginManagement = pluginManagementRepository.findByIdOrNull(pluginId)
- // If the plugin is unknown, persist it as enabled
if (pluginManagement == null) {
-
- // Set priority to the max value of the current plugins + 1 (which is the lowest priority) or 1 if there are no entries
- val currentMaxPriority = pluginManagementRepository.findMaxPriority() ?: 0
-
- pluginManagement = pluginManagementRepository.save(
- PluginManagementEntry(pluginId = pluginId, enabled = true, priority = currentMaxPriority + 1)
- )
+ return true
}
return pluginManagement.enabled != true
diff --git a/gameyfin/src/main/kotlin/de/grimsi/gameyfin/core/plugins/management/GameyfinPluginManager.kt b/gameyfin/src/main/kotlin/de/grimsi/gameyfin/core/plugins/management/GameyfinPluginManager.kt
index 93b5d4e..bae0198 100644
--- a/gameyfin/src/main/kotlin/de/grimsi/gameyfin/core/plugins/management/GameyfinPluginManager.kt
+++ b/gameyfin/src/main/kotlin/de/grimsi/gameyfin/core/plugins/management/GameyfinPluginManager.kt
@@ -4,9 +4,17 @@ import de.grimsi.gameyfin.core.plugins.config.PluginConfigRepository
import de.grimsi.gameyfin.pluginapi.core.GameyfinPlugin
import io.github.oshai.kotlinlogging.KotlinLogging
import org.pf4j.*
+import org.springframework.data.repository.findByIdOrNull
import org.springframework.stereotype.Component
+import java.io.InputStream
import java.nio.file.Path
+import java.security.PublicKey
+import java.security.cert.CertificateFactory
+import java.security.cert.X509Certificate
+import java.util.jar.JarFile
import kotlin.io.path.Path
+import kotlin.io.path.extension
+
/**
* @see https://stackoverflow.com/questions/73654174/my-application-cant-find-the-extension-with-pf4j
@@ -18,6 +26,10 @@ class GameyfinPluginManager(
val pluginManagementRepository: PluginManagementRepository
) : DefaultPluginManager(Path(System.getProperty("pf4j.pluginsDir", "plugins"))) {
+ companion object {
+ private const val PUBLIC_KEY_FILE = "certificates/gameyfin-plugins.pem"
+ }
+
private val log = KotlinLogging.logger {}
// This took me way too long to figure out...
@@ -41,12 +53,10 @@ class GameyfinPluginManager(
val compoundPluginLoader = CompoundPluginLoader()
val developmentPluginLoader = GameyfinPluginLoader(this, javaClass.classLoader)
val jarPluginLoader = JarPluginLoader(this)
- val defaultPluginLoader = DefaultPluginLoader(this)
return compoundPluginLoader
.add(developmentPluginLoader, this::isDevelopment)
.add(jarPluginLoader, this::isNotDevelopment)
- .add(defaultPluginLoader, this::isNotDevelopment)
}
override fun createPluginStatusProvider(): PluginStatusProvider {
@@ -59,6 +69,32 @@ class GameyfinPluginManager(
if (pluginWrapper != null) {
// Inject config after loading, before starting
configurePlugin(pluginWrapper)
+
+ // Update or create the PluginManagementEntry
+ if (pluginPath != null) {
+
+ // Set priority to the max value of the current plugins + 1 (which is the lowest priority) or 1 if there are no entries
+ val currentMaxPriority = pluginManagementRepository.findMaxPriority() ?: 0
+
+ val pluginManagementEntry = pluginManagementRepository.findByIdOrNull(pluginWrapper.pluginId)
+ ?: PluginManagementEntry(pluginId = pluginWrapper.pluginId, priority = currentMaxPriority + 1)
+
+ if (pluginPath.extension == "jar") {
+ log.debug { "Verifying plugin signature for ${pluginWrapper.pluginId}" }
+ pluginManagementEntry.trustLevel = verifyPluginSignature(pluginPath)
+ log.debug { "Plugin ${pluginWrapper.pluginId} verification status: ${pluginManagementEntry.trustLevel}" }
+ } else {
+ pluginManagementEntry.trustLevel = PluginTrustLevel.BUNDLED
+ }
+
+ if (pluginManagementEntry.trustLevel in listOf(PluginTrustLevel.OFFICIAL, PluginTrustLevel.BUNDLED)) {
+ pluginManagementEntry.enabled = true
+ log.info { "Plugin ${pluginWrapper.pluginId} verified, starting" }
+ startPlugin(pluginWrapper.pluginId)
+ }
+
+ pluginManagementRepository.save(pluginManagementEntry)
+ }
}
return pluginWrapper
@@ -69,11 +105,11 @@ class GameyfinPluginManager(
// Validate config before starting the plugin
if (!validatePluginConfig(pluginId)) {
- log.error { "Plugin $pluginId has invalid configuration" }
+ log.warn { "Plugin $pluginId has invalid configuration" }
val pluginWrapper = getPlugin(pluginId)
pluginWrapper.pluginState = PluginState.FAILED
- this.firePluginStateEvent(PluginStateEvent(this, pluginWrapper, pluginWrapper.pluginState));
+ this.firePluginStateEvent(PluginStateEvent(this, pluginWrapper, pluginWrapper.pluginState))
return pluginWrapper.pluginState
}
@@ -137,4 +173,55 @@ class GameyfinPluginManager(
private fun getConfig(pluginId: String): Map {
return pluginConfigRepository.findAllById_PluginId(pluginId).associate { it.id.key to it.value }
}
+
+ fun verifyPluginSignature(pluginPath: Path): PluginTrustLevel {
+ val certFactory: CertificateFactory = CertificateFactory.getInstance("X.509")
+ val certFileInputStream = javaClass.classLoader.getResourceAsStream(PUBLIC_KEY_FILE)
+ val cert: X509Certificate = certFactory.generateCertificate(certFileInputStream) as X509Certificate
+ val publicKey: PublicKey = cert.publicKey
+ certFileInputStream?.close()
+
+ val jarFile = JarFile(pluginPath.toFile(), true)
+ val entries = jarFile.entries()
+
+ while (entries.hasMoreElements()) {
+ val entry = entries.nextElement()
+ if (entry.isDirectory || entry.name.startsWith("META-INF/")) continue
+
+ try {
+ val buffer = ByteArray(8192)
+ val entryInputStream: InputStream = jarFile.getInputStream(entry)
+ while ((entryInputStream.read(buffer, 0, buffer.size)) != -1) {
+ // We just read
+ // This will throw a SecurityException if a signature/digest check fails
+ }
+ } catch (_: SecurityException) {
+ // Signature verification failed
+ return PluginTrustLevel.THIRD_PARTY
+ }
+
+ val codeSigners = entry.codeSigners
+
+ if (codeSigners == null || codeSigners.isEmpty()) {
+ // No code signers, so we can't verify the signature
+ return PluginTrustLevel.THIRD_PARTY
+ }
+
+ for (codeSigner in codeSigners) {
+ val certs = codeSigner.signerCertPath.certificates
+
+ for (cert in certs) {
+ if (cert is X509Certificate) {
+ try {
+ cert.verify(publicKey)
+ } catch (_: Exception) {
+ // Signature verification failed
+ return PluginTrustLevel.THIRD_PARTY
+ }
+ }
+ }
+ }
+ }
+ return PluginTrustLevel.OFFICIAL
+ }
}
\ No newline at end of file
diff --git a/gameyfin/src/main/kotlin/de/grimsi/gameyfin/core/plugins/management/PluginDto.kt b/gameyfin/src/main/kotlin/de/grimsi/gameyfin/core/plugins/management/PluginDto.kt
index 603e6b5..da754c8 100644
--- a/gameyfin/src/main/kotlin/de/grimsi/gameyfin/core/plugins/management/PluginDto.kt
+++ b/gameyfin/src/main/kotlin/de/grimsi/gameyfin/core/plugins/management/PluginDto.kt
@@ -9,5 +9,6 @@ data class PluginDto(
val author: String,
val hasLogo: Boolean,
val state: PluginState,
- val priority: Int
+ val priority: Int,
+ val trustLevel: PluginTrustLevel
)
\ No newline at end of file
diff --git a/gameyfin/src/main/kotlin/de/grimsi/gameyfin/core/plugins/management/PluginManagementEntry.kt b/gameyfin/src/main/kotlin/de/grimsi/gameyfin/core/plugins/management/PluginManagementEntry.kt
index 0be243e..a194a21 100644
--- a/gameyfin/src/main/kotlin/de/grimsi/gameyfin/core/plugins/management/PluginManagementEntry.kt
+++ b/gameyfin/src/main/kotlin/de/grimsi/gameyfin/core/plugins/management/PluginManagementEntry.kt
@@ -8,7 +8,9 @@ data class PluginManagementEntry(
@Id
val pluginId: String,
- var enabled: Boolean = true,
+ var enabled: Boolean = false,
- var priority: Int = 0
+ var priority: Int = 0,
+
+ var trustLevel: PluginTrustLevel = PluginTrustLevel.UNKNOWN,
)
\ No newline at end of file
diff --git a/gameyfin/src/main/kotlin/de/grimsi/gameyfin/core/plugins/management/PluginManagementService.kt b/gameyfin/src/main/kotlin/de/grimsi/gameyfin/core/plugins/management/PluginManagementService.kt
index 2878109..b060f2d 100644
--- a/gameyfin/src/main/kotlin/de/grimsi/gameyfin/core/plugins/management/PluginManagementService.kt
+++ b/gameyfin/src/main/kotlin/de/grimsi/gameyfin/core/plugins/management/PluginManagementService.kt
@@ -13,6 +13,7 @@ class PluginManagementService(
) {
fun getPluginDtos(): List {
return pluginManager.plugins.map {
+ val pluginManagementEntry = getPluginManagementEntry(it.pluginId)
PluginDto(
it.pluginId,
it.descriptor.pluginDescription,
@@ -20,13 +21,15 @@ class PluginManagementService(
it.descriptor.provider,
(it.plugin as GameyfinPlugin).hasLogo(),
it.pluginState,
- getPluginManagementEntry(it.pluginId).priority
+ pluginManagementEntry.priority,
+ pluginManagementEntry.trustLevel
)
}
}
fun getPluginDto(pluginId: String): PluginDto {
val plugin = pluginManager.getPlugin(pluginId)
+ val pluginManagementEntry = getPluginManagementEntry(pluginId)
return PluginDto(
plugin.pluginId,
plugin.descriptor.pluginDescription,
@@ -34,7 +37,8 @@ class PluginManagementService(
plugin.descriptor.provider,
(plugin.plugin as GameyfinPlugin).hasLogo(),
plugin.pluginState,
- getPluginManagementEntry(pluginId).priority
+ pluginManagementEntry.priority,
+ pluginManagementEntry.trustLevel
)
}
diff --git a/gameyfin/src/main/kotlin/de/grimsi/gameyfin/core/plugins/management/PluginTrustLevel.kt b/gameyfin/src/main/kotlin/de/grimsi/gameyfin/core/plugins/management/PluginTrustLevel.kt
new file mode 100644
index 0000000..6c100e7
--- /dev/null
+++ b/gameyfin/src/main/kotlin/de/grimsi/gameyfin/core/plugins/management/PluginTrustLevel.kt
@@ -0,0 +1,8 @@
+package de.grimsi.gameyfin.core.plugins.management
+
+enum class PluginTrustLevel {
+ BUNDLED,
+ OFFICIAL,
+ THIRD_PARTY,
+ UNKNOWN,
+}
\ No newline at end of file
diff --git a/gameyfin/src/main/resources/application.yml b/gameyfin/src/main/resources/application.yml
index 295db73..13d5cb9 100644
--- a/gameyfin/src/main/resources/application.yml
+++ b/gameyfin/src/main/resources/application.yml
@@ -16,7 +16,7 @@ management:
pause:
enabled: false
restart:
- enabled: true
+ access: unrestricted
spring:
# Workaround for https://github.com/vaadin/hilla/issues/842
diff --git a/gameyfin/src/main/resources/certificates/gameyfin-plugins.pem b/gameyfin/src/main/resources/certificates/gameyfin-plugins.pem
new file mode 100644
index 0000000..cc7018b
--- /dev/null
+++ b/gameyfin/src/main/resources/certificates/gameyfin-plugins.pem
@@ -0,0 +1,23 @@
+-----BEGIN CERTIFICATE-----
+MIID6zCCAlOgAwIBAgIIInXOudKRPwswDQYJKoZIhvcNAQEMBQAwJDERMA8GA1UE
+ChMIR2FtZXlmaW4xDzANBgNVBAMTBmdyaW1zaTAeFw0yNTA0MDEyMzI0MjdaFw0y
+NTA2MzAyMzI0MjdaMCQxETAPBgNVBAoTCEdhbWV5ZmluMQ8wDQYDVQQDEwZncmlt
+c2kwggGiMA0GCSqGSIb3DQEBAQUAA4IBjwAwggGKAoIBgQCe3Nfyz++gEGyiPXR2
+Gi1BtJrbQyAhqkO94XrCa0MmBPlb5AxakuMh3G9A8VmKgfaGithC5uO6rNKQX9rr
+TnxUBBNjVh0bu/gFD3YfgiE+UmPrf0BM2bFFm3fc3Ag4RwkyMnORTjUiKIJIK9LG
+kXIIeDd0SO5Hs+LBvyymOLNX6nsVIFa+tywVWJwPDLwxnf66PIH8E16aGBlsfwFl
+IjiVFp+54VaaOLv5vm+PcbPg4Q2bg3kD25V5U+KNd2FqElryKflrRQekCo1T52FL
+l7HQvyYdI8+E6wKNhO3io86/i9o7rNIl7QtO4OrwuRpoNho33HzqFMMkcz2yCWjR
+ilFzw7bGvAhKeCjrNHMHHpYlbXJ28opMJAfZMr0jLl8z2UuHe2kRCtUJL8sPpqcB
+RFT83/+Zc+3b846OIHf+WXGCtXBJ3XR2m+aZh41I4L4PSDDMebjboTVDvq0pfnvY
+ZIw1FAJTP09Wxe1ZB5xKBNG+MYQm4q0zoP0Os9BhAGAurQUCAwEAAaMhMB8wHQYD
+VR0OBBYEFOfG8eAgUuk0TK2ds09pV1BjFyqZMA0GCSqGSIb3DQEBDAUAA4IBgQCV
+YpKo50L1AjkjVpNnkVGX+mzTEHWkjE5Xhjmc/xsZAMeP2Dg0wSWqU6Vc1gya3Yvc
+Lnbjj1pVh5JLNNrTmCfttqYAuPYNlxkUfbzv5+61gyI4FsCUbddLwql9WSHToyeW
+xW+SmwIkKdRLFOiO927DuHc1G2UVXRPn2YFHTUTeHxZUSvBXvRoeQ+ofgqf54jGq
+oTNTe65/NfXzhQyTycwk3Zz3bRB8La2r20PBpNM8oTGwxbyGrbNYdZb+OwGIjEkB
+KeES8mjwS2PsPy6NdtLxN68No38ilXRzCzQL06lmLYA530n/SigPWbuRRE2JlDmN
+KcU+UQgzGf5gJ8Kut/Kg0K+qI4gq761N4EPnWXE8I59OhGDRzAA6FyJf8PJ9luQc
+pz7Yoc8QxRrDm36eQBBLVmGsnXVUD6FTvuofTjFPMrTOZFCcHNmH9JDdSCQHO1sI
+UCu7bWYGkAF93hFlAfl1h60tmZAyR2gMGwfgyDbdL2XYXA7dDRQftEQvtBmp5zI=
+-----END CERTIFICATE-----
diff --git a/gameyfin/src/main/resources/certificates/gameyfin.jks b/gameyfin/src/main/resources/certificates/gameyfin.jks
new file mode 100644
index 0000000000000000000000000000000000000000..20f2a6abbe63db69c87b78c7d7acaa7c95544ab7
GIT binary patch
literal 3468
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