GravityKit code signing: technical overview
This is the architecture-level companion to GravityKit products now give you a stronger reason to trust what you install. That post explains what changed and why a customer should care; this one is for readers who want the mechanics: the signing pipeline end-to-end, where verification hooks into WordPress’s update flow, how we manage and rotate keys, how revocation works, and what the design deliberately does not try to solve.
In plain terms: GravityKit products distributed through gravitykit.com are cryptographically signed at build time, and the signature is verified on the customer site before WordPress unpacks the ZIP. The verifier lives in Foundation, the shared library bundled inside every GravityKit product; GravityView 2.57 is the first to carry it. Because the newest copy of Foundation on a site becomes the active one, a single product update lifts protection across all other GravityKit products already on the same install.
The pipeline #
Signing happens once, inside a controlled build environment. Verification happens on every install attempt. Between those two ends sit three independent components, each running in its own security context:
- Build pipeline. Builds the plugin ZIP, then signs it with the active package key. The output is a signature triplet โ
sha256,signature,signing_key_idโ written alongside the ZIP filename. The signing tool re-verifies what it just produced before continuing; any failure aborts the run and nothing is published. - Distribution backend. Stores the triplet against the build record and serves it through our Store API. This layer is not trusted to alter ZIP bytes or signing metadata inconsistently: a mismatched filename, hash, signature, or key ID fails verification on the customer site. The distribution backend still participates in release selection, so it is separately hardened and the Store API fails closed on incomplete integrity records.
- Foundation on the customer site. Hooks to
upgrader_pre_downloadearly inWP_Upgrader‘s download phase. Foundation downloads the ZIP itself, or verifies the temp file if an earlier WordPress filter has already provided one. It hashes the bytes that will be unpacked, pulls the expected triplet from the Store API, and runs the signature check. Any mismatch โ missing signature, hash mismatch, invalid signature, or revoked key โ stops the install before unpacking starts.
GravityKit-managed downloads are accepted only from HTTPS GravityKit-controlled hosts. If an install or update is identified as a GravityKit product but points at an untrusted host, Foundation blocks it instead of passing it through to WordPress unsigned.
Two cryptographic primitives do the actual work: Ed25519 for the signature, SHA-256 for the file hash. Ed25519 is the digital-signature algorithm used by SSH, TLS 1.3, age, minisign, and many contemporary code-signing systems โ fast to verify, 64-byte signatures, 32-byte public keys, and none of the per-call randomness pitfalls that have produced real-world breaks in older signature schemes.
The signature is not computed over the ZIP directly. The SHA-256 of the ZIP is embedded inside a structured envelope, and the envelope is what gets signed, so the signature transitively binds to the file’s bytes through the hash:
gk.sig.v1.pkg:ed25519:{key_id}:{"filename":"...","sha256":"..."}
The body is canonical JSON (sorted keys and no whitespace). That makes the bytes a verifier reconstructs match the bytes the signer produced exactly, and prevents a filename containing a colon from tricking a parser into reading part of it as a different field. The pkg purpose tag exists so a package signature cannot be replayed as a revocation manifest signature even with the same key. The key_id is regex-validated (/\A[a-z0-9-]{1,64}\z/) at both build and verify time so a hostile key ID cannot smuggle in extra colons and shift the meaning of the header.
Three keys, three roles #
The trust anchor is a small, fixed set of keys with strictly separated jobs.
| Role | What it does |
|---|---|
| Active package key | Signs every release shipping today. |
| Pre-staged successor | The next package key. Public half is already shipped inside Foundation; the private half stays offline until rotation. |
| Revocation root | Signs the revocation manifest only. Held separately; never used to sign packages. |
Package key trust is local. Public keys ship inside Foundation, so there is no runtime key fetch on the package path. That is also what makes the pre-staged successor work: when the time comes to rotate, we promote it from pre-staged to active and retire the previous active key. Customers do not need a Foundation update to trust the new key โ that trust was distributed in advance.
Key lookups enforce three things: purpose match, algorithm match, and status filter. A package key can never resolve as the revocation root. Retired keys never resolve, so retirement immediately invalidates every package signed with that key identity.
Revocation #
A separately signed manifest, hosted on a verify-only endpoint and produced with the revocation root key. Customer sites fetch it at frequent intervals. The body is small JSON (a list of revoked key IDs) capped at 256 KB so a runaway upstream cannot exhaust memory on the verifier.
Failure handling is deliberate:
- First successful verify result is copied to a durable, network-scoped store โ the last-known-good manifest body.
- Every verified revoked key ID is also copied into a durable, network-scoped set. This set is checked before the network path, so a key once observed revoked by that site stays blocked even if an older signed manifest is later replayed.
- Fetch or signature-verify failure with a stored last-known-good: serve it back and re-prime the cache for 30 minutes. A hostile CDN serving a forged clean manifest cannot overwrite the trusted body: we reject the forged copy, and the stored one persists.
- Either failure on cold start with nothing ever stored: cache an empty list briefly so a transient first-fetch failure does not permanently block updates. Source-baked revoked keys still apply in this state.
The asymmetric property is precise: on Foundation versions with durable revocation memory, an attacker with sustained MITM cannot un-revoke a key that the site has already observed in a verified manifest. They can still delay delivery of a new revocation to a cold site that has never received it. For emergency cases, Foundation can also ship with a source-baked list of revoked keys so the block applies even before the remote manifest is reachable.
What this does and does not protect #
This protects customers from modified ZIP bytes, missing or mismatched signing metadata, invalid signatures, revoked package keys, untrusted GravityKit download hosts, and WordPress download-path short-circuits that would otherwise bypass the verifier.
It does not claim that the Store API is a cryptographic release ledger. The package signature binds the ZIP filename and SHA-256 hash; the distribution system still decides which signed release is offered to a site. That release-selection path is operationally hardened, but it is not the same thing as signing product/channel/version metadata as a separate artifact.
WordPress’s own signing โ and why it is not this #
WordPress added a code path for cryptographic signature verification in 5.2 (May 2019). The function verify_file_signature() in wp-admin/includes/file.php is wired into core’s download path and uses Ed25519 over SHA-384. In practice, it has been non-operational as a blocking plugin-package integrity system.
The history makes the reason clear. Signature verification arrived in changeset 44954 on March 21, 2019, six weeks before 5.2 shipped. The commit message labels the feature “experimental package signing” with “soft verification” โ and the wp_signature_softfail filter that arrived in the same changeset defaults to soft-fail behavior. Verification failures are demoted to a telemetry report and a non-blocking warning; WP_Upgrader::run() carries a “Pretend this error didn’t happen” path before continuing the install.
Plugins were taken out of the picture inside the same release cycle. Changeset [45262] disabled plugin signature verification entirely after math errors on specific PHP/opcache combinations. Tellyworth’s commit note: “At the 5.2 release the API servers will only provide signatures for core update packages.” That note is from 2019. The state hasn’t changed.
None of this gave us a foundation to build on, so we built our own. We would happily reti re it the day a shared signing model (core’s,ย FAIR’s, or whatever comes next) emerges that fits plugin distribution outside the .org directory.
This is one layer. Other parts of how our products are built and shipped get continuous hardening too โ most of it invisible from a customer’s vantage point, all of it ongoing.
If you have any questions, please reach out to support.