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 0000000..20f2a6a Binary files /dev/null and b/gameyfin/src/main/resources/certificates/gameyfin.jks differ