Prepare release engine

This commit is contained in:
Jan-Niclas Loosen
2026-11-04 14:46:54 +01:00
parent 8fe4eb63c8
commit b2759fba73
13 changed files with 159 additions and 390 deletions
+1 -2
View File
@@ -38,7 +38,6 @@ use LIT\MCP\Server\RequestContext;
use LIT\MCP\Server\ServerRegistrar;
use LIT\MCP\Server\VisibilityFilter;
use LIT\MCP\Updater\GiteaUpdater;
use LIT\MCP\Updater\PackageSignature;
defined('ABSPATH') || exit;
@@ -57,7 +56,7 @@ final class Plugin
self::$pluginFile = $pluginFile;
// Before the dependency check: a plugin that is inactive for a missing dependency must still be updatable.
(new GiteaUpdater(plugin_basename($pluginFile), dirname($pluginFile), new PackageSignature()))->register();
(new GiteaUpdater(plugin_basename($pluginFile), dirname($pluginFile)))->register();
$missing = (new Dependencies())->missing();
if ($missing !== []) {
+7 -45
View File
@@ -13,11 +13,10 @@ defined('ABSPATH') || exit;
* Gitea host, so WordPress asks the filter update_plugins_<host> (about every 12 hours, which is also the only
* caching needed). The release carries the ZIP as asset wp-lit-mcp-<version>.zip.
*
* The ZIP is only installed with a valid signature (PackageSignature, asset <zip>.sig); a hijacked Gitea account
* alone cannot ship code.
*
* shortcut: public release assets only (WordPress downloads without credentials), a private repository would
* need a token in the downloads.
* shortcut: no signature – a package is trusted as far as the repository's release downloads and TLS are, so whoever
* can publish a release there ships code to every site that updates. Public release assets only (WordPress downloads
* without credentials). Add a signature checked before unpacking (key outside Gitea) before the plugin runs on sites
* you do not control.
*/
final class GiteaUpdater
{
@@ -29,55 +28,18 @@ final class GiteaUpdater
private const SLUG = 'wp-lit-mcp';
/**
* @param string $pluginBasename plugin_basename() of the main file, the only plugin answered for.
* @param string $pluginDir The plugin's folder; an update replaces it as a whole.
* @param PackageSignature $signature Checks the downloaded ZIP before WordPress unpacks it.
* @param string $pluginBasename plugin_basename() of the main file, the only plugin answered for.
* @param string $pluginDir The plugin's folder; an update replaces it as a whole.
*/
public function __construct(
private string $pluginBasename,
private string $pluginDir,
private PackageSignature $signature
private string $pluginDir
) {
}
public function register(): void
{
add_filter('update_plugins_' . self::HOST, [$this, 'offer'], 10, 3);
add_filter('upgrader_pre_download', [$this, 'verifyDownload'], 10, 2);
}
/**
* Downloads our package itself to check its signature before WordPress unpacks a byte. Fails closed: a missing
* or wrong signature file stops the update.
*
* @param mixed $reply What an earlier filter decided; false = WordPress downloads as usual.
* @return mixed The downloaded file's path, a WP_Error, or $reply for any package that is not ours.
*/
public function verifyDownload(mixed $reply, string $package): mixed
{
if ($reply !== false || ! str_starts_with($package, self::RELEASE_DOWNLOADS)) {
return $reply;
}
$file = download_url($package);
if (is_wp_error($file)) {
return $file;
}
// The signature is 89 bytes; never read more of an answer than that file can be.
$response = wp_safe_remote_get($package . '.sig', ['timeout' => 10, 'limit_response_size' => 1024]);
$signature = wp_remote_retrieve_body($response);
if (! $this->signature->verifies(basename($package), $file, $signature)) {
wp_delete_file($file);
Log::failure('update', 'signature of ' . basename($package) . ' is missing or does not verify');
return new \WP_Error(
'lit_mcp_bad_signature',
__('Die Signatur des Plugin-Pakets stimmt nicht. Das Update wurde nicht installiert.', 'wp-lit-mcp')
);
}
return $file;
}
/**
-45
View File
@@ -1,45 +0,0 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Updater;
defined('ABSPATH') || exit;
/**
* Checks the Ed25519 signature of a release ZIP. The private key never leaves the maintainer's machine, so a
* compromised Gitea account alone cannot ship code to the sites. bin/release-sign.php signs with the same message.
*/
final class PackageSignature
{
/** Base64 of the 32-byte public key; the matching private key signs the releases (bin/release-sign.php keygen). */
public const RELEASE_KEY = '8iPADRjYwxrnKK3bZ0nUnP2iXlIKpC1dJ0nZxCxOG2E=';
public function __construct(private string $publicKey = self::RELEASE_KEY)
{
}
/**
* The signed text: asset name and SHA-256 of the file. The name binds the signature to one version – a validly
* signed older ZIP does not pass as a newer release.
*/
public static function message(string $assetName, string $zipPath): string
{
return $assetName . "\n" . (string) hash_file('sha256', $zipPath);
}
public function verifies(string $assetName, string $zipPath, string $signatureBase64): bool
{
$signature = base64_decode(trim($signatureBase64), true);
$key = base64_decode($this->publicKey, true);
if (
$signature === false || strlen($signature) !== SODIUM_CRYPTO_SIGN_BYTES
|| $key === false || strlen($key) !== SODIUM_CRYPTO_SIGN_PUBLICKEYBYTES
|| ! is_readable($zipPath)
) {
return false;
}
return sodium_crypto_sign_verify_detached($signature, self::message($assetName, $zipPath), $key);
}
}