mirror of
https://github.com/BrenBroZAYT/gameyfin.git
synced 2026-06-16 08:15:48 +00:00
Implement plugin verification via certificates and JAR signing
This commit is contained in:
@@ -50,3 +50,4 @@ logs
|
|||||||
templates
|
templates
|
||||||
/gameyfin/src/main/frontend/**/*.js
|
/gameyfin/src/main/frontend/**/*.js
|
||||||
/gameyfin/src/main/frontend/**/*.js.map
|
/gameyfin/src/main/frontend/**/*.js.map
|
||||||
|
/gameyfin/src/main/bundles/
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ import {
|
|||||||
PlayCircle,
|
PlayCircle,
|
||||||
Power,
|
Power,
|
||||||
QuestionMark,
|
QuestionMark,
|
||||||
|
SealCheck,
|
||||||
|
SealQuestion,
|
||||||
|
SealWarning,
|
||||||
SlidersHorizontal,
|
SlidersHorizontal,
|
||||||
StopCircle,
|
StopCircle,
|
||||||
WarningCircle
|
WarningCircle
|
||||||
@@ -15,6 +18,7 @@ import PluginState from "Frontend/generated/org/pf4j/PluginState";
|
|||||||
import React, {ReactNode, useEffect, useState} from "react";
|
import React, {ReactNode, useEffect, useState} from "react";
|
||||||
import PluginDetailsModal from "Frontend/components/general/modals/PluginDetailsModal";
|
import PluginDetailsModal from "Frontend/components/general/modals/PluginDetailsModal";
|
||||||
import PluginLogo from "Frontend/components/general/PluginLogo";
|
import PluginLogo from "Frontend/components/general/PluginLogo";
|
||||||
|
import PluginTrustLevel from "Frontend/generated/de/grimsi/gameyfin/core/plugins/management/PluginTrustLevel";
|
||||||
|
|
||||||
export function PluginManagementCard({plugin, updatePlugin}: {
|
export function PluginManagementCard({plugin, updatePlugin}: {
|
||||||
plugin: PluginDto,
|
plugin: PluginDto,
|
||||||
@@ -63,6 +67,27 @@ export function PluginManagementCard({plugin, updatePlugin}: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function trustLevelToBadge(trustLevel: PluginTrustLevel | undefined): React.ReactNode {
|
||||||
|
switch (trustLevel) {
|
||||||
|
case PluginTrustLevel.OFFICIAL:
|
||||||
|
return <Tooltip color="foreground" placement="bottom" content="Official plugin">
|
||||||
|
<SealCheck weight="fill" className="fill-success"/>
|
||||||
|
</Tooltip>;
|
||||||
|
case PluginTrustLevel.BUNDLED:
|
||||||
|
return <Tooltip color="foreground" placement="bottom" content="Bundled plugin">
|
||||||
|
<SealCheck weight="fill"/>
|
||||||
|
</Tooltip>;
|
||||||
|
case PluginTrustLevel.THIRD_PARTY:
|
||||||
|
return <Tooltip color="foreground" placement="bottom" content="3rd party plugin">
|
||||||
|
<SealWarning/>
|
||||||
|
</Tooltip>;
|
||||||
|
default:
|
||||||
|
return <Tooltip color="foreground" placement="bottom" content="Unkown verification status">
|
||||||
|
<SealQuestion/>
|
||||||
|
</Tooltip>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function isDisabled(state: PluginState | undefined): boolean {
|
function isDisabled(state: PluginState | undefined): boolean {
|
||||||
return state === PluginState.DISABLED;
|
return state === PluginState.DISABLED;
|
||||||
}
|
}
|
||||||
@@ -102,9 +127,12 @@ export function PluginManagementCard({plugin, updatePlugin}: {
|
|||||||
</Button>
|
</Button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-1 flex-col items-center gap-1">
|
<div className="flex flex-1 flex-col items-center gap-2">
|
||||||
<PluginLogo plugin={plugin}/>
|
<PluginLogo plugin={plugin}/>
|
||||||
<p className="font-semibold">{plugin.name}</p>
|
<p className="flex flex-row gap-1 font-semibold">
|
||||||
|
{plugin.name}
|
||||||
|
{trustLevelToBadge(plugin.trustLevel)}
|
||||||
|
</p>
|
||||||
<div className="flex flex-row gap-2">
|
<div className="flex flex-row gap-2">
|
||||||
<Chip size="sm" radius="sm" className="text-xs">{plugin.version}</Chip>
|
<Chip size="sm" radius="sm" className="text-xs">{plugin.version}</Chip>
|
||||||
<Chip size="sm" radius="sm" className="text-xs" color={stateToColor(plugin.state)}>
|
<Chip size="sm" radius="sm" className="text-xs" color={stateToColor(plugin.state)}>
|
||||||
|
|||||||
+1
-8
@@ -12,15 +12,8 @@ class DatabasePluginStatusProvider(
|
|||||||
override fun isPluginDisabled(pluginId: String): Boolean {
|
override fun isPluginDisabled(pluginId: String): Boolean {
|
||||||
var pluginManagement = pluginManagementRepository.findByIdOrNull(pluginId)
|
var pluginManagement = pluginManagementRepository.findByIdOrNull(pluginId)
|
||||||
|
|
||||||
// If the plugin is unknown, persist it as enabled
|
|
||||||
if (pluginManagement == null) {
|
if (pluginManagement == null) {
|
||||||
|
return true
|
||||||
// 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 pluginManagement.enabled != true
|
return pluginManagement.enabled != true
|
||||||
|
|||||||
+91
-4
@@ -4,9 +4,17 @@ import de.grimsi.gameyfin.core.plugins.config.PluginConfigRepository
|
|||||||
import de.grimsi.gameyfin.pluginapi.core.GameyfinPlugin
|
import de.grimsi.gameyfin.pluginapi.core.GameyfinPlugin
|
||||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||||
import org.pf4j.*
|
import org.pf4j.*
|
||||||
|
import org.springframework.data.repository.findByIdOrNull
|
||||||
import org.springframework.stereotype.Component
|
import org.springframework.stereotype.Component
|
||||||
|
import java.io.InputStream
|
||||||
import java.nio.file.Path
|
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.Path
|
||||||
|
import kotlin.io.path.extension
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @see https://stackoverflow.com/questions/73654174/my-application-cant-find-the-extension-with-pf4j
|
* @see https://stackoverflow.com/questions/73654174/my-application-cant-find-the-extension-with-pf4j
|
||||||
@@ -18,6 +26,10 @@ class GameyfinPluginManager(
|
|||||||
val pluginManagementRepository: PluginManagementRepository
|
val pluginManagementRepository: PluginManagementRepository
|
||||||
) : DefaultPluginManager(Path(System.getProperty("pf4j.pluginsDir", "plugins"))) {
|
) : DefaultPluginManager(Path(System.getProperty("pf4j.pluginsDir", "plugins"))) {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val PUBLIC_KEY_FILE = "certificates/gameyfin-plugins.pem"
|
||||||
|
}
|
||||||
|
|
||||||
private val log = KotlinLogging.logger {}
|
private val log = KotlinLogging.logger {}
|
||||||
|
|
||||||
// This took me way too long to figure out...
|
// This took me way too long to figure out...
|
||||||
@@ -41,12 +53,10 @@ class GameyfinPluginManager(
|
|||||||
val compoundPluginLoader = CompoundPluginLoader()
|
val compoundPluginLoader = CompoundPluginLoader()
|
||||||
val developmentPluginLoader = GameyfinPluginLoader(this, javaClass.classLoader)
|
val developmentPluginLoader = GameyfinPluginLoader(this, javaClass.classLoader)
|
||||||
val jarPluginLoader = JarPluginLoader(this)
|
val jarPluginLoader = JarPluginLoader(this)
|
||||||
val defaultPluginLoader = DefaultPluginLoader(this)
|
|
||||||
|
|
||||||
return compoundPluginLoader
|
return compoundPluginLoader
|
||||||
.add(developmentPluginLoader, this::isDevelopment)
|
.add(developmentPluginLoader, this::isDevelopment)
|
||||||
.add(jarPluginLoader, this::isNotDevelopment)
|
.add(jarPluginLoader, this::isNotDevelopment)
|
||||||
.add(defaultPluginLoader, this::isNotDevelopment)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun createPluginStatusProvider(): PluginStatusProvider {
|
override fun createPluginStatusProvider(): PluginStatusProvider {
|
||||||
@@ -59,6 +69,32 @@ class GameyfinPluginManager(
|
|||||||
if (pluginWrapper != null) {
|
if (pluginWrapper != null) {
|
||||||
// Inject config after loading, before starting
|
// Inject config after loading, before starting
|
||||||
configurePlugin(pluginWrapper)
|
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
|
return pluginWrapper
|
||||||
@@ -69,11 +105,11 @@ class GameyfinPluginManager(
|
|||||||
|
|
||||||
// Validate config before starting the plugin
|
// Validate config before starting the plugin
|
||||||
if (!validatePluginConfig(pluginId)) {
|
if (!validatePluginConfig(pluginId)) {
|
||||||
log.error { "Plugin $pluginId has invalid configuration" }
|
log.warn { "Plugin $pluginId has invalid configuration" }
|
||||||
|
|
||||||
val pluginWrapper = getPlugin(pluginId)
|
val pluginWrapper = getPlugin(pluginId)
|
||||||
pluginWrapper.pluginState = PluginState.FAILED
|
pluginWrapper.pluginState = PluginState.FAILED
|
||||||
this.firePluginStateEvent(PluginStateEvent(this, pluginWrapper, pluginWrapper.pluginState));
|
this.firePluginStateEvent(PluginStateEvent(this, pluginWrapper, pluginWrapper.pluginState))
|
||||||
return pluginWrapper.pluginState
|
return pluginWrapper.pluginState
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,4 +173,55 @@ class GameyfinPluginManager(
|
|||||||
private fun getConfig(pluginId: String): Map<String, String?> {
|
private fun getConfig(pluginId: String): Map<String, String?> {
|
||||||
return pluginConfigRepository.findAllById_PluginId(pluginId).associate { it.id.key to it.value }
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -9,5 +9,6 @@ data class PluginDto(
|
|||||||
val author: String,
|
val author: String,
|
||||||
val hasLogo: Boolean,
|
val hasLogo: Boolean,
|
||||||
val state: PluginState,
|
val state: PluginState,
|
||||||
val priority: Int
|
val priority: Int,
|
||||||
|
val trustLevel: PluginTrustLevel
|
||||||
)
|
)
|
||||||
+4
-2
@@ -8,7 +8,9 @@ data class PluginManagementEntry(
|
|||||||
@Id
|
@Id
|
||||||
val pluginId: String,
|
val pluginId: String,
|
||||||
|
|
||||||
var enabled: Boolean = true,
|
var enabled: Boolean = false,
|
||||||
|
|
||||||
var priority: Int = 0
|
var priority: Int = 0,
|
||||||
|
|
||||||
|
var trustLevel: PluginTrustLevel = PluginTrustLevel.UNKNOWN,
|
||||||
)
|
)
|
||||||
+6
-2
@@ -13,6 +13,7 @@ class PluginManagementService(
|
|||||||
) {
|
) {
|
||||||
fun getPluginDtos(): List<PluginDto> {
|
fun getPluginDtos(): List<PluginDto> {
|
||||||
return pluginManager.plugins.map {
|
return pluginManager.plugins.map {
|
||||||
|
val pluginManagementEntry = getPluginManagementEntry(it.pluginId)
|
||||||
PluginDto(
|
PluginDto(
|
||||||
it.pluginId,
|
it.pluginId,
|
||||||
it.descriptor.pluginDescription,
|
it.descriptor.pluginDescription,
|
||||||
@@ -20,13 +21,15 @@ class PluginManagementService(
|
|||||||
it.descriptor.provider,
|
it.descriptor.provider,
|
||||||
(it.plugin as GameyfinPlugin).hasLogo(),
|
(it.plugin as GameyfinPlugin).hasLogo(),
|
||||||
it.pluginState,
|
it.pluginState,
|
||||||
getPluginManagementEntry(it.pluginId).priority
|
pluginManagementEntry.priority,
|
||||||
|
pluginManagementEntry.trustLevel
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getPluginDto(pluginId: String): PluginDto {
|
fun getPluginDto(pluginId: String): PluginDto {
|
||||||
val plugin = pluginManager.getPlugin(pluginId)
|
val plugin = pluginManager.getPlugin(pluginId)
|
||||||
|
val pluginManagementEntry = getPluginManagementEntry(pluginId)
|
||||||
return PluginDto(
|
return PluginDto(
|
||||||
plugin.pluginId,
|
plugin.pluginId,
|
||||||
plugin.descriptor.pluginDescription,
|
plugin.descriptor.pluginDescription,
|
||||||
@@ -34,7 +37,8 @@ class PluginManagementService(
|
|||||||
plugin.descriptor.provider,
|
plugin.descriptor.provider,
|
||||||
(plugin.plugin as GameyfinPlugin).hasLogo(),
|
(plugin.plugin as GameyfinPlugin).hasLogo(),
|
||||||
plugin.pluginState,
|
plugin.pluginState,
|
||||||
getPluginManagementEntry(pluginId).priority
|
pluginManagementEntry.priority,
|
||||||
|
pluginManagementEntry.trustLevel
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+8
@@ -0,0 +1,8 @@
|
|||||||
|
package de.grimsi.gameyfin.core.plugins.management
|
||||||
|
|
||||||
|
enum class PluginTrustLevel {
|
||||||
|
BUNDLED,
|
||||||
|
OFFICIAL,
|
||||||
|
THIRD_PARTY,
|
||||||
|
UNKNOWN,
|
||||||
|
}
|
||||||
@@ -16,7 +16,7 @@ management:
|
|||||||
pause:
|
pause:
|
||||||
enabled: false
|
enabled: false
|
||||||
restart:
|
restart:
|
||||||
enabled: true
|
access: unrestricted
|
||||||
|
|
||||||
spring:
|
spring:
|
||||||
# Workaround for https://github.com/vaadin/hilla/issues/842
|
# Workaround for https://github.com/vaadin/hilla/issues/842
|
||||||
|
|||||||
@@ -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-----
|
||||||
Binary file not shown.
Reference in New Issue
Block a user