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
View File
@@ -1,4 +1,5 @@
/vendor/ /vendor/
/vendor.lit-bak/ /vendor.lit-bak/
/dist/ /dist/
/.gitea-token
.phpunit.result.cache .phpunit.result.cache
+10 -13
View File
@@ -3,9 +3,9 @@
# #
# bin/release-check.sh [version] default: Plugin::VERSION # bin/release-check.sh [version] default: Plugin::VERSION
# #
# Fails unless releases/latest is that version, ZIP and .sig are public downloads below the repository's release # Fails unless releases/latest is that version, the ZIP is a public download below the repository's release URL (the
# URL (the only place GiteaUpdater accepts a package from), and the downloaded ZIP passes the signature check against # only place GiteaUpdater accepts a package from), and the downloaded ZIP is the plugin in that version with its
# the key built into the plugin. # autoloader.
# #
# Environment: LIT_MCP_GITEA (default https://git.loosen-it.de), only to try the script against another server. # Environment: LIT_MCP_GITEA (default https://git.loosen-it.de), only to try the script against another server.
set -euo pipefail set -euo pipefail
@@ -25,18 +25,15 @@ latest="$(curl -fsS "$gitea/api/v1/repos/$repo/releases/latest")" \
tag="$(jq -r .tag_name <<<"$latest")" tag="$(jq -r .tag_name <<<"$latest")"
[[ "$tag" == "v$version" ]] || fail "releases/latest liefert '$tag', erwartet v$version" [[ "$tag" == "v$version" ]] || fail "releases/latest liefert '$tag', erwartet v$version"
asset_url() { jq -r --arg n "$1" '.assets[] | select(.name == $n) | .browser_download_url' <<<"$latest"; } zip_url="$(jq -r --arg n "$name" '.assets[] | select(.name == $n) | .browser_download_url' <<<"$latest")"
zip_url="$(asset_url "$name")" [[ "$zip_url" == "$gitea/$repo/releases/download/"* ]] || fail "Asset-URL fehlt oder liegt woanders: '$zip_url'"
sig_url="$(asset_url "$name.sig")"
for url in "$zip_url" "$sig_url"; do
[[ "$url" == "$gitea/$repo/releases/download/"* ]] || fail "Asset-URL fehlt oder liegt woanders: '$url'"
done
work="$(mktemp -d)" work="$(mktemp -d)"
trap 'rm -rf "$work"' EXIT trap 'rm -rf "$work"' EXIT
curl -fsSL "$zip_url" -o "$work/$name" || fail "ZIP nicht ohne Anmeldung ladbar: $zip_url" curl -fsSL "$zip_url" -o "$work/$name" || fail "ZIP nicht ohne Anmeldung ladbar: $zip_url"
curl -fsSL "$sig_url" -o "$work/$name.sig" || fail "Signatur nicht ohne Anmeldung ladbar: $sig_url" entries="$(bsdtar -tf "$work/$name")" || fail "Das heruntergeladene ZIP ist kein gültiges ZIP"
php bin/release-sign.php verify "$work/$name" "$work/$name.sig" >/dev/null \ grep -qx 'wp-lit-mcp/vendor/autoload_packages.php' <<<"$entries" || fail "Im ZIP fehlt der Autoloader (vendor/)"
|| fail "Das heruntergeladene Release besteht die Signaturprüfung nicht" [[ "$(bsdtar -xOf "$work/$name" wp-lit-mcp/wp-lit-mcp.php | sed -n 's/^ \* Version: //p')" == "$version" ]] \
|| fail "Das ZIP enthält nicht Version $version"
echo "Release v$version in Ordnung: öffentlich ladbar, Signatur gültig" echo "Release v$version in Ordnung: öffentlich ladbar, Plugin und Autoloader im ZIP"
+69
View File
@@ -0,0 +1,69 @@
#!/usr/bin/env bash
# Publishes a release built by bin/release.sh: draft -> ZIP attached and checked -> published -> checked from outside
# (bin/release-check.sh).
#
# bin/release-publish.sh [version] default: Plugin::VERSION
#
# The release only turns public once the ZIP hangs on it, so no customer site ever sees a release without its
# package. An existing draft for the tag (created by hand or by an earlier run) is reused; a published one is refused.
# An existing tag fixes the commit (a release recreated after deleting it), a new tag points at HEAD.
#
# Needs: curl, jq, bsdtar and a Gitea token with write access to the repository (see bin/token.sh).
# Environment: LIT_MCP_GITEA (default https://git.loosen-it.de), only to try the script against another server.
set -euo pipefail
root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$root"
gitea="${LIT_MCP_GITEA:-https://git.loosen-it.de}"
api="$gitea/api/v1/repos/janniclas.loosen/WP-LIT-MCP"
version="${1:-$(sed -n "s/.*const VERSION = '\(.*\)';/\1/p" src/Plugin.php)}"
name="wp-lit-mcp-$version.zip"
zip="dist/$name"
fail() { echo "ABBRUCH: $*" >&2; exit 1; }
step() { echo "==> $*"; }
source "$root/bin/token.sh"
load_token
[[ -r "$zip" ]] || fail "dist/ hat nicht $name: erst bin/release.sh"
# A stale file in dist/ is the cheapest way to ship the wrong release.
step "ZIP prüfen"
[[ "$(bsdtar -xOf "$zip" wp-lit-mcp/wp-lit-mcp.php | sed -n 's/^ \* Version: //p')" == "$version" ]] \
|| fail "Das ZIP enthält nicht Version $version"
auth=(-H "Authorization: token $GITEA_TOKEN")
json=(-H 'Content-Type: application/json')
api_get() { curl -fsS "${auth[@]}" "$api/$1"; }
step "Entwurf v$version suchen oder anlegen"
release="$(api_get 'releases?per_page=50' | jq -c --arg t "v$version" '[.[] | select(.tag_name == $t)][0] // empty')"
if [[ -z "$release" ]]; then
notes="$(awk -v header="= $version =" '$0 == header { on = 1; next } /^= / { on = 0 } on' readme.txt)"
target="$(curl -sS "${auth[@]}" "$api/tags/v$version" | jq -r '.commit.sha // empty')"
[[ -n "$target" ]] || target="$(git rev-parse HEAD)"
request="$(jq -n --arg tag "v$version" --arg sha "$target" --arg body "$notes" \
'{tag_name: $tag, target_commitish: $sha, name: $tag, body: $body, draft: true}')"
release="$(curl -fsS "${auth[@]}" "${json[@]}" -d "$request" "$api/releases")" || fail "Release ließ sich nicht anlegen"
fi
[[ "$(jq -r .draft <<<"$release")" == "true" ]] || fail "v$version ist schon veröffentlicht"
id="$(jq -r .id <<<"$release")"
step "ZIP anhängen"
old="$(jq -r --arg n "$name" '.assets[]? | select(.name == $n) | .id' <<<"$release")"
[[ -z "$old" ]] || curl -fsS -X DELETE "${auth[@]}" "$api/releases/$id/assets/$old" >/dev/null
curl -fsS "${auth[@]}" -F "attachment=@$zip" "$api/releases/$id/assets?name=$name" >/dev/null \
|| fail "Upload von $name fehlgeschlagen"
# Read it back instead of trusting the upload: only a draft that really carries the ZIP goes public.
step "Anhang nachprüfen"
size="$(api_get "releases/$id" | jq -r --arg n "$name" '.assets[]? | select(.name == $n) | .size')"
[[ "$size" == "$(stat -c %s "$zip")" ]] || fail "$name hängt nicht oder mit falscher Größe am Entwurf (Gitea: '$size')"
step "Veröffentlichen"
curl -fsS -X PATCH "${auth[@]}" "${json[@]}" -d '{"draft": false}' "$api/releases/$id" >/dev/null \
|| fail "Veröffentlichen fehlgeschlagen"
step "Ergebnis von außen prüfen"
exec bash bin/release-check.sh "$version"
-64
View File
@@ -1,64 +0,0 @@
<?php
/**
* Release signing for WP-LIT-MCP (called by bin/release.sh, usable by hand).
*
* php bin/release-sign.php keygen <key-file> new key pair; prints the public key for PackageSignature
* php bin/release-sign.php sign <zip> <key-file> prints the signature (the .sig asset)
* php bin/release-sign.php verify <zip> <sig-file> checks against the key built into the plugin
*/
declare(strict_types=1);
// The class guards against direct web access; this is the command line.
define('ABSPATH', __DIR__ . '/');
require_once __DIR__ . '/../src/Updater/PackageSignature.php';
use LIT\MCP\Updater\PackageSignature;
function fail(string $message): never
{
fwrite(STDERR, $message . "\n");
exit(1);
}
[, $command, $first, $second] = $argv + [null, '', '', ''];
switch ($command) {
case 'keygen':
if ($first === '' || file_exists($first)) {
fail('usage: keygen <key-file> (an existing file is never overwritten)');
}
$pair = sodium_crypto_sign_keypair();
if (! is_dir(dirname($first)) && ! mkdir(dirname($first), 0700, true)) {
fail('cannot create ' . dirname($first));
}
// 0600 before the secret is written, so it is never readable by others.
touch($first);
chmod($first, 0600);
file_put_contents($first, base64_encode(sodium_crypto_sign_secretkey($pair)));
echo base64_encode(sodium_crypto_sign_publickey($pair)), "\n";
break;
case 'sign':
$secret = is_readable($second) ? base64_decode(trim((string) file_get_contents($second)), true) : false;
if (! is_readable($first) || $secret === false || strlen($secret) !== SODIUM_CRYPTO_SIGN_SECRETKEYBYTES) {
fail('usage: sign <zip> <key-file> (readable ZIP, key file from keygen)');
}
$message = PackageSignature::message(basename($first), $first);
echo base64_encode(sodium_crypto_sign_detached($message, $secret)), "\n";
break;
case 'verify':
if (! is_readable($first) || ! is_readable($second)) {
fail('usage: verify <zip> <sig-file>');
}
if (! (new PackageSignature())->verifies(basename($first), $first, (string) file_get_contents($second))) {
fail('signature does not verify against the key built into the plugin');
}
echo "ok\n";
break;
default:
fail('usage: keygen <key-file> | sign <zip> <key-file> | verify <zip> <sig-file>');
}
+9 -20
View File
@@ -1,20 +1,17 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Builds and signs a WP-LIT-MCP release from the committed state. It publishes nothing: tag, Gitea release and the # Builds a WP-LIT-MCP release ZIP from the committed state into dist/. It publishes nothing: bin/ship.sh does the
# upload of the two files in dist/ are done afterwards, then bin/release-check.sh looks at the result. # whole way (this script, push, release, upload, check from outside).
# #
# bin/release.sh # bin/release.sh
# #
# Needs: bsdtar, composer, php (sodium). The signing key (php bin/release-sign.php keygen) lives outside the # Needs: bsdtar, composer.
# repository. # Environment: LIT_MCP_DIVI_CHECK (command that must succeed before a release, default:
# # ddev wp --user=janniclas lit-mcp divi-check).
# Environment: LIT_MCP_RELEASE_KEY (default ~/.config/wp-lit-mcp/release.key), LIT_MCP_DIVI_CHECK (command that must
# succeed before a release, default: ddev wp --user=janniclas lit-mcp divi-check).
set -euo pipefail set -euo pipefail
root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$root" cd "$root"
key="${LIT_MCP_RELEASE_KEY:-$HOME/.config/wp-lit-mcp/release.key}"
divi_check="${LIT_MCP_DIVI_CHECK:-ddev wp --user=janniclas lit-mcp divi-check}" divi_check="${LIT_MCP_DIVI_CHECK:-ddev wp --user=janniclas lit-mcp divi-check}"
fail() { echo "ABBRUCH: $*" >&2; exit 1; } fail() { echo "ABBRUCH: $*" >&2; exit 1; }
@@ -29,7 +26,6 @@ version="$(sed -n "s/.*const VERSION = '\(.*\)';/\1/p" src/Plugin.php)"
grep -q "^= $version =\$" readme.txt || fail "readme.txt hat keinen Changelog-Eintrag für $version" grep -q "^= $version =\$" readme.txt || fail "readme.txt hat keinen Changelog-Eintrag für $version"
[[ -z "$(git status --porcelain)" ]] || fail "Arbeitsbaum nicht sauber: erst committen, das ZIP entsteht aus HEAD" [[ -z "$(git status --porcelain)" ]] || fail "Arbeitsbaum nicht sauber: erst committen, das ZIP entsteht aus HEAD"
! git rev-parse -q --verify "refs/tags/v$version" >/dev/null || fail "Tag v$version existiert schon" ! git rev-parse -q --verify "refs/tags/v$version" >/dev/null || fail "Tag v$version existiert schon"
[[ -r "$key" ]] || fail "Signierschlüssel fehlt: $key (php bin/release-sign.php keygen <Datei>)"
# --- 2. Tests and the Divi check must be green ------------------------------------------------------------------------ # --- 2. Tests and the Divi check must be green ------------------------------------------------------------------------
step "Tests" step "Tests"
@@ -46,8 +42,9 @@ git archive HEAD | tar -x -C "$work/wp-lit-mcp"
(cd "$work/wp-lit-mcp" && composer install --no-dev --optimize-autoloader --no-interaction --quiet) (cd "$work/wp-lit-mcp" && composer install --no-dev --optimize-autoloader --no-interaction --quiet)
name="wp-lit-mcp-$version.zip" name="wp-lit-mcp-$version.zip"
mkdir -p dist mkdir -p dist
# Files of earlier releases would be one wrong click away from the upload.
rm -f dist/wp-lit-mcp-*
zip="$root/dist/$name" zip="$root/dist/$name"
rm -f "$zip" "$zip.sig"
(cd "$work" && bsdtar --format zip -cf "$zip" wp-lit-mcp) (cd "$work" && bsdtar --format zip -cf "$zip" wp-lit-mcp)
# --- 4. Look into the ZIP like WordPress will: one folder, the plugin, its autoloader, the right version -------------- # --- 4. Look into the ZIP like WordPress will: one folder, the plugin, its autoloader, the right version --------------
@@ -57,18 +54,10 @@ if grep -qv '^wp-lit-mcp/' <<<"$entries"; then fail "ZIP enthält Einträge auß
for need in wp-lit-mcp/wp-lit-mcp.php wp-lit-mcp/vendor/autoload_packages.php; do for need in wp-lit-mcp/wp-lit-mcp.php wp-lit-mcp/vendor/autoload_packages.php; do
grep -qx "$need" <<<"$entries" || fail "Im ZIP fehlt $need" grep -qx "$need" <<<"$entries" || fail "Im ZIP fehlt $need"
done done
if grep -qE '^wp-lit-mcp/(tests|bin|docs/superpowers|\.git)(/|$)' <<<"$entries"; then if grep -qE '^wp-lit-mcp/(tests|bin|docs/superpowers|\.git|\.gitea-token)(/|$)' <<<"$entries"; then
fail "Entwicklungsdateien im ZIP" fail "Entwicklungsdateien im ZIP"
fi fi
[[ "$(bsdtar -xOf "$zip" wp-lit-mcp/wp-lit-mcp.php | sed -n 's/^ \* Version: //p')" == "$version" ]] \ [[ "$(bsdtar -xOf "$zip" wp-lit-mcp/wp-lit-mcp.php | sed -n 's/^ \* Version: //p')" == "$version" ]] \
|| fail "Version im ZIP stimmt nicht mit $version überein" || fail "Version im ZIP stimmt nicht mit $version überein"
# --- 5. Sign, and prove the signature against the key built into the plugin ------------------------------------------- echo "In Ordnung: dist/$name"
step "Signieren"
php bin/release-sign.php sign "$zip" "$key" > "$zip.sig"
php bin/release-sign.php verify "$zip" "$zip.sig" >/dev/null \
|| fail "Signatur passt nicht zum Schlüssel im Plugin (PackageSignature::RELEASE_KEY)"
echo "In Ordnung: dist/$name und dist/$name.sig"
echo "Weiter: Tag v$version und Release auf Gitea anlegen, beide Dateien hochladen, veröffentlichen."
echo "Danach: bin/release-check.sh $version"
Executable
+38
View File
@@ -0,0 +1,38 @@
#!/usr/bin/env bash
# One command for a release: build (tests, Divi check, ZIP), push main, publish, check from outside.
#
# bin/ship.sh
#
# Run it when Plugin::VERSION is bumped and committed (release.sh refuses a dirty tree or an existing tag). The token
# comes from $GITEA_TOKEN or a file (see bin/token.sh).
#
# Environment: LIT_MCP_GITEA (default https://git.loosen-it.de), only to try the script against another server.
set -euo pipefail
root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$root"
gitea="${LIT_MCP_GITEA:-https://git.loosen-it.de}"
repo="janniclas.loosen/WP-LIT-MCP"
fail() { echo "ABBRUCH: $*" >&2; exit 1; }
step() { echo "==> $*"; }
source "$root/bin/token.sh"
load_token
export GITEA_TOKEN
[[ "$(git rev-parse --abbrev-ref HEAD)" == "main" ]] || fail "Releases gehen nur von main aus"
bash bin/release.sh
# Push only if Gitea does not have HEAD yet. The token travels in a header of this one call, via the environment: not
# in the URL, not in the git configuration, not in the process list.
remote_head="$(curl -fsS "$gitea/api/v1/repos/$repo/branches/main" | jq -r .commit.id)"
if [[ "$remote_head" != "$(git rev-parse HEAD)" ]]; then
step "main pushen"
basic="$(printf 'janniclas.loosen:%s' "$GITEA_TOKEN" | base64 -w0)"
GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=http.extraHeader GIT_CONFIG_VALUE_0="Authorization: Basic $basic" \
git push "$gitea/$repo.git" HEAD:main || fail "Push fehlgeschlagen"
fi
bash bin/release-publish.sh
+17
View File
@@ -0,0 +1,17 @@
# Loads the Gitea token into GITEA_TOKEN: the environment first, then .gitea-token in the repository (gitignored),
# then ~/.config/wp-lit-mcp/gitea-token. Sourced by bin/ship.sh and bin/release-publish.sh, which define $root and fail().
load_token() {
local file
for file in "$root/.gitea-token" "$HOME/.config/wp-lit-mcp/gitea-token"; do
[[ -z "${GITEA_TOKEN:-}" && -r "$file" ]] || continue
# A token file the repository would commit, or that others can read, is refused before it is used.
if [[ "$file" == "$root/.gitea-token" ]]; then
git -C "$root" check-ignore -q .gitea-token || fail ".gitea-token steht nicht in .gitignore"
fi
[[ "$(stat -c %a "$file")" =~ ^[0-7]00$ ]] || fail "$file ist für andere lesbar: chmod 600"
GITEA_TOKEN="$(<"$file")"
done
[[ -n "${GITEA_TOKEN:-}" ]] \
|| fail "Kein Token: GITEA_TOKEN setzen oder .gitea-token im Repo (alternativ ~/.config/wp-lit-mcp/gitea-token) anlegen"
}
+2 -6
View File
@@ -200,8 +200,7 @@ Bibliothekseintrags.
Neue Versionen erscheinen als Release auf git.loosen-it.de; WordPress zeigt sie unter Plugins und Dashboard → Neue Versionen erscheinen als Release auf git.loosen-it.de; WordPress zeigt sie unter Plugins und Dashboard →
Aktualisierungen an wie jedes andere Plugin. Automatisch einspielen lässt sich ein Update mit dem Schalter Aktualisierungen an wie jedes andere Plugin. Automatisch einspielen lässt sich ein Update mit dem Schalter
„Automatische Updates aktivieren“ in der Plugin-Liste. Ein Update ersetzt den Plugin-Ordner; ein Entwicklungs- „Automatische Updates aktivieren“ in der Plugin-Liste. Ein Update ersetzt den Plugin-Ordner; ein Entwicklungs-
Checkout mit .git-Ordner bekommt deshalb kein Update angeboten. Jedes Paket ist signiert (Ed25519); WordPress Checkout mit .git-Ordner bekommt deshalb kein Update angeboten. Versionen vor 1.0.1 kennen diesen
installiert nur, was zur Signatur im Plugin passt. Versionen vor 1.0.1 kennen diesen
Weg nicht: der Wechsel auf 1.0.1 ist einmalig von Hand (ZIP hochladen), danach läuft es über WordPress. Weg nicht: der Wechsel auf 1.0.1 ist einmalig von Hand (ZIP hochladen), danach läuft es über WordPress.
== Divi-Freigabe == == Divi-Freigabe ==
@@ -211,10 +210,7 @@ LIT MCP schreibt nur mit Divi-Versionen, die für diese Plugin-Version geprüft
== Changelog == == Changelog ==
= 1.0.1 = = 1.0.1 =
* Erstes vollständig ausgeliefertes Release: 1.0.0 wurde ohne Paketdatei veröffentlicht. Inhalt unverändert. * Updates über WordPress: Das neueste Release von git.loosen-it.de erscheint als Plugin-Update (Update URI); automatisches Einspielen über den Schalter in der Plugin-Liste. Der Wechsel auf 1.0.1 ist einmalig von Hand. (Version 1.0.0 wurde nie ausgeliefert.)
= 1.0.0 =
* Updates über WordPress: Das neueste Release von git.loosen-it.de erscheint als Plugin-Update (Update URI); automatisches Einspielen über den Schalter in der Plugin-Liste. Der Wechsel auf 1.0.0 ist einmalig von Hand.
= 0.7.0 = = 0.7.0 =
* Designsystem schreiben (Etappe 4): Presets lesen, anlegen, ändern, löschen, einzelnen Modulen und ganzen Seiten zuweisen und Verweise überall entfernen (Probelauf, Bestätigung, Snapshot, Wiederherstellen). * Designsystem schreiben (Etappe 4): Presets lesen, anlegen, ändern, löschen, einzelnen Modulen und ganzen Seiten zuweisen und Verweise überall entfernen (Probelauf, Bestätigung, Snapshot, Wiederherstellen).
+1 -2
View File
@@ -38,7 +38,6 @@ use LIT\MCP\Server\RequestContext;
use LIT\MCP\Server\ServerRegistrar; use LIT\MCP\Server\ServerRegistrar;
use LIT\MCP\Server\VisibilityFilter; use LIT\MCP\Server\VisibilityFilter;
use LIT\MCP\Updater\GiteaUpdater; use LIT\MCP\Updater\GiteaUpdater;
use LIT\MCP\Updater\PackageSignature;
defined('ABSPATH') || exit; defined('ABSPATH') || exit;
@@ -57,7 +56,7 @@ final class Plugin
self::$pluginFile = $pluginFile; self::$pluginFile = $pluginFile;
// Before the dependency check: a plugin that is inactive for a missing dependency must still be updatable. // 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(); $missing = (new Dependencies())->missing();
if ($missing !== []) { if ($missing !== []) {
+5 -43
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 * 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. * 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 * shortcut: no signature – a package is trusted as far as the repository's release downloads and TLS are, so whoever
* alone cannot ship code. * 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
* shortcut: public release assets only (WordPress downloads without credentials), a private repository would * you do not control.
* need a token in the downloads.
*/ */
final class GiteaUpdater final class GiteaUpdater
{ {
@@ -31,53 +30,16 @@ final class GiteaUpdater
/** /**
* @param string $pluginBasename plugin_basename() of the main file, the only plugin answered for. * @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 string $pluginDir The plugin's folder; an update replaces it as a whole.
* @param PackageSignature $signature Checks the downloaded ZIP before WordPress unpacks it.
*/ */
public function __construct( public function __construct(
private string $pluginBasename, private string $pluginBasename,
private string $pluginDir, private string $pluginDir
private PackageSignature $signature
) { ) {
} }
public function register(): void public function register(): void
{ {
add_filter('update_plugins_' . self::HOST, [$this, 'offer'], 10, 3); 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);
}
}
+5 -104
View File
@@ -8,7 +8,6 @@ use Brain\Monkey\Filters;
use Brain\Monkey\Functions; use Brain\Monkey\Functions;
use LIT\MCP\Tests\Unit\TestCase; use LIT\MCP\Tests\Unit\TestCase;
use LIT\MCP\Updater\GiteaUpdater; use LIT\MCP\Updater\GiteaUpdater;
use LIT\MCP\Updater\PackageSignature;
use Mockery; use Mockery;
use WP_Error; use WP_Error;
@@ -20,40 +19,26 @@ final class GiteaUpdaterTest extends TestCase
private string $logFile = ''; private string $logFile = '';
private string|false $previousLog = ''; private string|false $previousLog = '';
private string $secretKey = '';
private string $publicKey = '';
/** @var list<string> */
private array $tempFiles = [];
protected function setUp(): void protected function setUp(): void
{ {
parent::setUp(); parent::setUp();
Functions\stubTranslationFunctions();
// error_log() is a PHP internal and cannot be stubbed; redirect it to a temp file instead. // error_log() is a PHP internal and cannot be stubbed; redirect it to a temp file instead.
$this->logFile = $this->tempFile(); $this->logFile = (string) tempnam(sys_get_temp_dir(), 'lit-mcp-log');
$this->previousLog = ini_set('error_log', $this->logFile); $this->previousLog = ini_set('error_log', $this->logFile);
$pair = sodium_crypto_sign_keypair();
$this->secretKey = sodium_crypto_sign_secretkey($pair);
$this->publicKey = base64_encode(sodium_crypto_sign_publickey($pair));
} }
protected function tearDown(): void protected function tearDown(): void
{ {
ini_set('error_log', (string) $this->previousLog); ini_set('error_log', (string) $this->previousLog);
foreach ($this->tempFiles as $file) { unlink($this->logFile);
if (file_exists($file)) {
unlink($file);
}
}
parent::tearDown(); parent::tearDown();
} }
public function testRegistersItsFilters(): void public function testRegistersItselfForTheGiteaHost(): void
{ {
$updater = $this->updater(); $updater = $this->updater();
Filters\expectAdded('update_plugins_' . GiteaUpdater::HOST)->once()->with([$updater, 'offer'], 10, 3); Filters\expectAdded('update_plugins_' . GiteaUpdater::HOST)->once()->with([$updater, 'offer'], 10, 3);
Filters\expectAdded('upgrader_pre_download')->once()->with([$updater, 'verifyDownload'], 10, 2);
$updater->register(); $updater->register();
} }
@@ -84,8 +69,7 @@ final class GiteaUpdaterTest extends TestCase
Functions\expect('wp_remote_get')->never(); Functions\expect('wp_remote_get')->never();
try { try {
$updater = new GiteaUpdater(self::BASENAME, $dir, new PackageSignature($this->publicKey)); self::assertFalse((new GiteaUpdater(self::BASENAME, $dir))->offer(false, [], self::BASENAME));
self::assertFalse($updater->offer(false, [], self::BASENAME));
} finally { } finally {
rmdir($dir . '/.git'); rmdir($dir . '/.git');
rmdir($dir); rmdir($dir);
@@ -136,81 +120,10 @@ final class GiteaUpdaterTest extends TestCase
]; ];
} }
public function testDownloadWithAValidSignatureIsHandedToWordPress(): void
{
$zip = $this->tempFile('release bytes');
$this->givenDownload($zip, $this->sign(self::ZIP_NAME, $zip));
self::assertSame($zip, $this->updater()->verifyDownload(false, self::ZIP));
self::assertFileExists($zip);
}
public function testDownloadWithAForeignSignatureIsDeletedAndRefused(): void
{
$zip = $this->tempFile('release bytes');
$other = $this->tempFile('other bytes');
$this->givenDownload($zip, $this->sign(self::ZIP_NAME, $other));
$result = $this->updater()->verifyDownload(false, self::ZIP);
self::assertInstanceOf(WP_Error::class, $result);
self::assertFileDoesNotExist($zip);
self::assertStringContainsString('update failed: signature of ' . self::ZIP_NAME, $this->log());
}
public function testSignatureOfAnotherVersionDoesNotPassAsThisOne(): void
{
$zip = $this->tempFile('old release bytes');
$this->givenDownload($zip, $this->sign('wp-lit-mcp-0.9.0.zip', $zip));
self::assertInstanceOf(WP_Error::class, $this->updater()->verifyDownload(false, self::ZIP));
}
public function testDownloadWithoutSignatureFileIsRefused(): void
{
$zip = $this->tempFile('release bytes');
$this->givenDownload($zip, '');
self::assertInstanceOf(WP_Error::class, $this->updater()->verifyDownload(false, self::ZIP));
self::assertFileDoesNotExist($zip);
}
public function testFailedDownloadIsReturnedAsIs(): void
{
$error = new WP_Error('download_failed', 'cURL error 28');
Functions\expect('download_url')->once()->with(self::ZIP)->andReturn($error);
self::assertSame($error, $this->updater()->verifyDownload(false, self::ZIP));
}
public function testOtherPackagesAndEarlierDecisionsAreNotTouched(): void
{
Functions\expect('download_url')->never();
self::assertFalse($this->updater()->verifyDownload(false, 'https://downloads.wordpress.org/plugin/x.zip'));
self::assertSame('/tmp/earlier.zip', $this->updater()->verifyDownload('/tmp/earlier.zip', self::ZIP));
}
private function updater(): GiteaUpdater private function updater(): GiteaUpdater
{ {
// __DIR__ has no .git folder. // __DIR__ has no .git folder.
return new GiteaUpdater(self::BASENAME, __DIR__, new PackageSignature($this->publicKey)); return new GiteaUpdater(self::BASENAME, __DIR__);
}
private function tempFile(string $contents = ''): string
{
$file = (string) tempnam(sys_get_temp_dir(), 'lit-mcp-test');
file_put_contents($file, $contents);
$this->tempFiles[] = $file;
return $file;
}
private function sign(string $assetName, string $zipPath): string
{
$message = PackageSignature::message($assetName, $zipPath);
return base64_encode(sodium_crypto_sign_detached($message, $this->secretKey));
} }
private function log(): string private function log(): string
@@ -218,18 +131,6 @@ final class GiteaUpdaterTest extends TestCase
return (string) file_get_contents($this->logFile); return (string) file_get_contents($this->logFile);
} }
private function givenDownload(string $zip, string $signatureBody): void
{
Functions\expect('download_url')->once()->with(self::ZIP)->andReturn($zip);
Functions\expect('wp_safe_remote_get')->once()
->with(self::ZIP . '.sig', Mockery::type('array'))
->andReturn(['body' => $signatureBody]);
Functions\when('wp_remote_retrieve_body')->justReturn($signatureBody);
Functions\when('wp_delete_file')->alias(static function (string $file): void {
unlink($file);
});
}
private function givenRelease(int $status, string $body): void private function givenRelease(int $status, string $body): void
{ {
$latestRelease = Mockery::pattern('#^https://git\.loosen-it\.de/api/v1/repos/.+/releases/latest$#'); $latestRelease = Mockery::pattern('#^https://git\.loosen-it\.de/api/v1/repos/.+/releases/latest$#');
@@ -1,91 +0,0 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Updater;
use LIT\MCP\Updater\PackageSignature;
use PHPUnit\Framework\TestCase;
final class PackageSignatureTest extends TestCase
{
private string $zip = '';
private string $secretKey = '';
private PackageSignature $signature;
protected function setUp(): void
{
$pair = sodium_crypto_sign_keypair();
$this->secretKey = sodium_crypto_sign_secretkey($pair);
$this->signature = new PackageSignature(base64_encode(sodium_crypto_sign_publickey($pair)));
$this->zip = (string) tempnam(sys_get_temp_dir(), 'lit-mcp-sig');
file_put_contents($this->zip, 'zip bytes');
}
protected function tearDown(): void
{
unlink($this->zip);
}
public function testASignatureOverNameAndContentVerifies(): void
{
$signature = $this->sign('wp-lit-mcp-1.0.0.zip');
self::assertTrue($this->signature->verifies('wp-lit-mcp-1.0.0.zip', $this->zip, $signature));
}
public function testAnotherAssetNameDoesNotVerify(): void
{
$signature = $this->sign('wp-lit-mcp-1.0.0.zip');
self::assertFalse($this->signature->verifies('wp-lit-mcp-1.0.1.zip', $this->zip, $signature));
}
public function testChangedContentDoesNotVerify(): void
{
$signature = $this->sign('x.zip');
file_put_contents($this->zip, 'changed bytes');
self::assertFalse($this->signature->verifies('x.zip', $this->zip, $signature));
}
public function testAnotherKeyDoesNotVerify(): void
{
$foreign = new PackageSignature(base64_encode(sodium_crypto_sign_publickey(sodium_crypto_sign_keypair())));
self::assertFalse($foreign->verifies('x.zip', $this->zip, $this->sign('x.zip')));
}
/** @dataProvider malformedInput */
public function testMalformedInputFailsClosed(string $signature, string $publicKey, string $zipPath): void
{
$subject = new PackageSignature($publicKey === 'real' ? $this->realKey() : $publicKey);
self::assertFalse($subject->verifies('x.zip', $zipPath === 'zip' ? $this->zip : $zipPath, $signature));
}
/** @return array<string, array{string, string, string}> */
public static function malformedInput(): array
{
return [
'signature is no base64' => ['not base64 !!', 'real', 'zip'],
'signature is empty' => ['', 'real', 'zip'],
'signature has the wrong length' => [base64_encode('short'), 'real', 'zip'],
'no key configured' => ['AAAA', '', 'zip'],
'key has the wrong length' => ['AAAA', base64_encode('short'), 'zip'],
'file is missing' => ['AAAA', 'real', '/nonexistent/x.zip'],
];
}
private function sign(string $assetName): string
{
$message = PackageSignature::message($assetName, $this->zip);
return base64_encode(sodium_crypto_sign_detached($message, $this->secretKey));
}
private function realKey(): string
{
return base64_encode(sodium_crypto_sign_publickey(sodium_crypto_sign_keypair()));
}
}