Diff archives (/diff/...?zip=true) are signed with Ed25519. The point is
that an updater can check two things before it installs anything: the archive
was built by your API instance, and it was built for exactly the update the
updater asked for.
Only if all of these hold:
- The repository is listed in
ALLOWED_REPOSwith its id (owner/repo#id, the id is logged on first use). Never through*. If someone registers the name after a rename, it's a different id and nothing gets signed. fromandtoare existing tags. The diff is computed between the commits they point to.tois a descendant offrom(GitHub statusahead), so applying the diff turnsfrominto exactlyto.tois the latest version that/versionreports. Downgrades are never signed.- Every path is safe (printable ASCII, no
.., no absolute paths, no.git), no file turns into a directory or back, and every file is a regular file (no symlinks or submodules) that matches its git blob hash.
If any of this fails, there's no archive at all. Without a signing key,
archives are off unless you set ALLOW_UNSIGNED_ZIPS=true.
| Field | Signature over | |
|---|---|---|
manifest + manifest_signature |
the exact bytes of manifest |
This is what updaters should check. It binds the archive to repository, versions, commits, hash and deletions. |
signature |
the raw 32-byte SHA-256 of the archive (checksum is the hex form) |
Only with LEGACY_SIGNATURES=true. Not bound to anything, see below. |
The manifest is plain text. One key=value per line, every line ends with
\n, followed by one delete= line for each file the update removes (deleted
and renamed files, sorted):
format=version-api-update-manifest/1
repository=Ym0T/pterodactyl-nginx-egg
from=v3.2.3
to=v3.2.5
from_commit=da308f62891fa3855cd2851194cac7bcbe7782d6
to_commit=5d5b3f9b522f65f6845a69e558090827b2c88a4e
sha256=920fc17ac13aef4cfaa567af209fd3107c7d01d5b8b408cf6dd7eb5cc4ee01d8
size=25637
created_at=1792165817
delete=nginx/sites-available/default
repository is spelled as in ALLOWED_REPOS (GitHub names are
case-insensitive). created_at is a Unix timestamp. When parsing, split lines
only at \n (not at other Unicode line breaks) and each line at the first
=. Values never contain \n, keys never contain =.
The archive contains exactly the changed files of to, nothing else.
Executable files keep their mode from the git tree.
version-api generate-keypairThis prints SIGNING_PRIVATE_KEY=... and PUBLIC_KEY_BASE64=....
Put the base64 private key into a file that only the service user can read and
point SIGNING_PRIVATE_KEY_FILE to it. That keeps the key out of the process
environment.
umask 077
printf '%s\n' 'BASE64_PRIVATE_KEY' > /path/outside/backups/signing.key
export SIGNING_PRIVATE_KEY_FILE=/path/outside/backups/signing.keySIGNING_PRIVATE_KEY works too, but not both at once. An invalid key stops
the service at startup, and the public key is logged when it starts.
API_BASE_URL is required as soon as archives are enabled.
On Pterodactyl, everything under /home/container shows up in the file
manager for subusers with file access and ends up in backups. If that matters
to you, pass the key as a startup variable that isn't user-viewable instead.
The diff cache in DATA_DIR/diff_cache.json decides what gets signed, so it
is authenticated with a key derived from the signing key. A modified file is
simply ignored. Still, keep DATA_DIR writable only by the service: someone
who can change the archives in there can't forge a signature, but they can
make updates fail.
Embed PUBLIC_KEY_BASE64 in your updater. Never verify against the
public_key field of an API response. Whoever can tamper with the response
can replace that field as well.
Accept an archive only if all of these hold, otherwise refuse the update. Don't fall back to installing unsigned or unverified archives.
- The manifest signature verifies with your embedded public key.
formatisversion-api-update-manifest/1,repositoryis your repository (compare case-insensitively), andfrom/toare the installed and the requested version.- The SHA-256 of the downloaded file equals
sha256in the manifest. created_atis recent. Archives live forZIP_RETENTION_HOURS(24 h by default), so something like 48 h is a good limit. Without this check, someone who controls the network could keep replaying an old but genuinely signed manifest and hold a client back on an old version.
Then apply the update:
- Extract into an empty staging directory and check every path again
(relative, no
.., no.git). - Delete exactly the
delete=paths from the manifest, before copying, so renames that only change the case work on case-insensitive file systems. Check each path again, delete only regular files (rm -f -- "$root/$path", never-r) and skip paths below a symlinked directory. Never take deletions from the unsignedfiles_changedlist. - Copy the staged files into the installation, ideally after a backup so a failed copy can be rolled back, and record the new version.
Here's a reference implementation for a POSIX shell with OpenSSL 3 and jq:
#!/bin/sh
# Usage: verify-update.sh RESPONSE_JSON ARCHIVE REPOSITORY FROM TO
# Prints the paths to delete on success; prints nothing and exits non-zero
# otherwise.
set -eu
PUBLIC_KEY_BASE64="your_base64_public_key_here"
response=$1 archive=$2 repository=$3 from=$4 to=$5
tmp=$(mktemp -d)
trap 'rm -rf "$tmp"' EXIT
# Pinned public key -> PEM (DER prefix for a raw Ed25519 public key)
{ printf '\060\052\060\005\006\003\053\145\160\003\041\000'; printf '%s' "$PUBLIC_KEY_BASE64" | base64 -d; } > "$tmp/pub.der"
openssl pkey -pubin -inform DER -in "$tmp/pub.der" -out "$tmp/pub.pem"
jq -j '.manifest' "$response" > "$tmp/manifest"
jq -r '.manifest_signature' "$response" | base64 -d > "$tmp/manifest.sig"
openssl pkeyutl -verify -pubin -inkey "$tmp/pub.pem" -rawin \
-in "$tmp/manifest" -sigfile "$tmp/manifest.sig" >/dev/null
field() { sed -n "s/^$1=//p" "$tmp/manifest"; }
lower() { printf '%s' "$1" | tr '[:upper:]' '[:lower:]'; }
[ "$(field format)" = "version-api-update-manifest/1" ]
[ "$(lower "$(field repository)")" = "$(lower "$repository")" ]
[ "$(field from)" = "$from" ]
[ "$(field to)" = "$to" ]
[ "$(sha256sum "$archive" | cut -d' ' -f1)" = "$(field sha256)" ]
created=$(field created_at)
case "$created" in "" | *[!0-9]*) exit 1 ;; esac
[ $(( $(date +%s) - created )) -lt 172800 ] # at most 48 hours old
# Signed deletions: check all of them first, print only if all are safe.
field delete > "$tmp/delete"
while IFS= read -r path; do
case "/$path/" in
*"/../"* | *"/./"* | *"//"* | *"/.git/"*) echo "unsafe path: $path" >&2; exit 1 ;;
esac
done < "$tmp/delete"
cat "$tmp/delete"jq -j writes the manifest without adding a newline, so the bytes are exactly
the ones that were signed.
Some updaters only check signature against the SHA-256 of the archive. That
signature says "this key signed an archive with this hash" and nothing more.
It doesn't name a repository or a version, so such an updater accepts any
archive that was ever signed with the key, including old ones.
LEGACY_SIGNATURES=true adds this signature so those updaters keep working
while you ship one that checks the manifest. The same key can be used for
both: a manifest is always longer than 32 bytes, so a hash signature can never
pass as a manifest signature or the other way round. That also means a
switch to manifests doesn't require a new key.
How to switch:
- Enable
LEGACY_SIGNATURESand release an updater that verifies the manifest as described above and ignoressignaturecompletely. - Old installations pick up the new updater through a normal update.
- Once (nearly) all installations have it, turn
LEGACY_SIGNATURESoff. Installations that still run the old updater then can't update anymore, but they don't become less safe either.
- Create a new key pair.
- Release an updater that trusts the new public key. During the transition it can trust both.
- Switch the API to the new private key. The stored diff cache belongs to the old key and is discarded, archives are built again when they're requested.
You only have to do this if the private key may have leaked.
| Error | Meaning |
|---|---|
signing_unavailable (503) |
No key configured and ALLOW_UNSIGNED_ZIPS not set. |
zip_not_allowed (403) |
The repository is only served through *. Add it to ALLOWED_REPOS. |
repository_not_pinned (503) |
Add the repository id: ALLOWED_REPOS=owner/repo#id. |
version_not_found (404) |
from or to is not a tag of the repository. |
versions_not_sequential (422) |
to is not a descendant of from (downgrade or diverged branches). |
zip_target_not_latest (422) |
to is not the latest version. |
unsupported_file_type (422) |
The update contains a symlink or submodule. |
unsupported_change (422) |
A file turns into a directory or the other way round. |
unsafe_path (422) |
A path isn't printable ASCII or not a plain relative path. |
zip_build_limit_reached (503), zip_client_limit_reached (429) |
Build limits reached. Existing archives are still handed out. |
diff_too_large (422) |
300 or more changed files, do a full update instead. |
If the signature doesn't verify, the public key in the updater usually doesn't
match the API's key (compare it with the one logged at startup), or the
manifest wasn't written byte for byte (use jq -j, not jq -r).