Plugin Archive Signing
VCTRbase uses Ed25519 signatures to verify the integrity and provenance of plugin
archives before installation. The host verifies every archive against a
multi-publisher trust keyring (App\Plugins\Marketplace\Keyring) — not a single
registry key — assembled from operator-configured local keys, a back-compat single
registry key, and a remote trusted-keys.json fetched from the marketplace. Each
publisher signs with its own private key at release time; a matching, non-revoked
keyring entry yields that archive’s (publisher, keyId).
Canonical reference:
docs/PLUGINS.md(“Tiers & signing”) is the source of truth for the keyring, the three trust tiers, and the install-time admission rule. This document covers key generation, signing, the trust model, and host configuration.
Overview
Publisher VCTRbase host
───────────────────────────────── ─────────────────────────────────────
plugin:keygen ─► PUBKEY → .env VCTRS_PLUGIN_REGISTRY_PUBKEY=<pubkey>
PRIVKEY → secrets
│
plugin:sign zip ◄──┘
└─► zip.sig ──────────────► installFromZip(zip, sig, digest)
Keyring::verify(zip, sig) → (publisher, keyId) ✓Generating a Keypair
Run this once per registry environment (staging, production):
# Human-readable output
php artisan plugin:keygen
# JSON (for scripting / CI)
php artisan plugin:keygen --jsonOr using the thin shell wrapper (no Laravel knowledge required):
scripts/plugin-keygen --jsonOutput:
VCTRS_PLUGIN_REGISTRY_PUBKEY=<base64-public-key>
Private key (store in your secret manager — NEVER commit this):
<base64-private-key>Storing the keys
| Key | Where to put it |
|---|---|
| Public key | VCTRS_PLUGIN_REGISTRY_PUBKEY in .env.production |
| Private key | CI/CD secret store (GitHub Actions secret, Vault, etc.) — never committed to version control |
Security warning: The private key is printed to stdout as a one-time developer/registry setup step. Immediately copy it into your secret manager and close or clear your terminal history. If the private key is ever compromised, rotate with a new
plugin:keygenrun and redeploy the public key.
Signing a Plugin Archive
Before publishing a plugin, sign its ZIP archive:
# Using the artisan command
php artisan plugin:sign my-plugin-1.0.0.zip --key="$VCTRS_PLUGIN_SIGN_KEY"
# Using the shell wrapper
scripts/sign-plugin my-plugin-1.0.0.zip --key="$VCTRS_PLUGIN_SIGN_KEY"The --key option accepts either:
- A base64-encoded private key string (from
VCTRS_PLUGIN_SIGN_KEYenv var), or - A file path containing the base64-encoded key.
Output:
- Writes
my-plugin-1.0.0.zip.signext to the archive (base64 detached Ed25519 signature). - Prints the SHA-256 hex digest to stdout (store alongside the
.sigfor integrity verification).
CI/CD Integration (GitHub Actions example)
- name: Sign plugin archive
env:
VCTRS_PLUGIN_SIGN_KEY: ${{ secrets.VCTRS_PLUGIN_SIGN_KEY }}
run: |
DIGEST=$(scripts/sign-plugin dist/my-plugin-${{ env.VERSION }}.zip --key="$VCTRS_PLUGIN_SIGN_KEY")
echo "PLUGIN_DIGEST=$DIGEST" >> $GITHUB_ENV
- name: Upload to registry
run: |
# Upload both the ZIP and the .sig file to your plugin registry API
curl -X POST https://registry.vctrs.io/plugins/publish \
-F "archive=@dist/my-plugin-${{ env.VERSION }}.zip" \
-F "signature=@dist/my-plugin-${{ env.VERSION }}.zip.sig" \
-F "digest=$PLUGIN_DIGEST"Trust model
Verification runs against the multi-publisher keyring, and admission depends on
what the archive ships, not merely on whether it is signed. docs/PLUGINS.md
(“Tiers & signing”) is canonical; the essentials:
Keyring sources (Keyring::verify — first non-revoked match wins):
- Local hard-trusted keys —
config('plugins.trusted_keys'), operator-configured, empty by default. - Back-compat single key —
config('plugins.registry_pubkey')(fromVCTRS_PLUGIN_REGISTRY_PUBKEY), synthesized into a one-entry{publisher: 'VCTRS', keyId: 'registry-pubkey'}keyring entry. - Remote keyring —
trusted-keys.jsonfetched from the marketplace; itsrevocations[]list suppresses a matchingkeyIdfrom any source, local ones included.
Admission (PluginInstaller::installFromZip):
- Executable code — a server-code
provideror auiMode: moduleESM bundle — must match a trusted, non-revoked keyring key, or the archive is refused. This holds regardless ofrequire_signature. - Declarative archives with no keyring match install at the untrusted community tier —
unless
require_signature=true, which refuses every unsigned upload outright.
Trust tiers stamped at install (FirstPartyKeys::trustLevelFor, persisted to the DB
plugins.trust column):
| Tier | Assigned when |
|---|---|
untrusted | no signature match (declarative only — executable code with no match is refused) |
signed_third_party | matched any trusted keyring key |
signed_first_party | matched a key whose keyId is on the locally-committed plugins.first_party_key_ids allowlist (VCTRS_PLUGIN_FIRST_PARTY_KEY_IDS, default vctrs-ed25519-2026) |
Only signed_first_party grants platform-level PHP execution. A keyring match alone
is signed_third_party, never first-party — first-partiness is decided by the
locally-committed key-id allowlist, never by keyring membership, so a compromised remote
trusted-keys.json cannot mint platform trust. The back-compat registry-pubkey keyId
is not on that allowlist, so an archive signed only with VCTRS_PLUGIN_REGISTRY_PUBKEY
installs as signed_third_party.
Host Configuration
Set these environment variables on the VCTRbase host:
# A trusted public key. This is the back-compat single-key keyring source; see
# "Trust model" above for the full multi-publisher keyring.
VCTRS_PLUGIN_REGISTRY_PUBKEY=<base64-public-key>
# Hard-require a valid trusted-key signature on EVERY upload (declarative included).
# Defaults to false. This is the ONLY switch that forces signing on every upload.
VCTRS_PLUGIN_REQUIRE_SIGNATURE=trueThe enforcement flag is
VCTRS_PLUGIN_REQUIRE_SIGNATURE, and it defaults tofalse(config/plugins.php—env('VCTRS_PLUGIN_REQUIRE_SIGNATURE', false)). SettingVCTRS_PLUGIN_REGISTRY_PUBKEYdoes not turn enforcement on; the config comment states this verbatim: “Setting registry_pubkey does NOT imply this flag.”installFromZip()consultsconfig('plugins.require_signature')once (PluginInstaller.php), and that flag is the sole gate for requiring a signature on every upload. (Note: executable code is always refused unless signed — see the admission rule above — so leaving this off still rejects unsigned providers and module bundles; it only re-admits unsigned declarative uploads.)
Rotating Keys
- Run
plugin:keygento produce a new keypair. - Deploy the new
VCTRS_PLUGIN_REGISTRY_PUBKEYto all host environments. - Re-sign existing plugin archives with the new private key.
- Revoke the old private key in your secret manager.
Across the multi-publisher keyring, a compromised key is retired by adding its
keyIdtorevocations[]in the remotetrusted-keys.json— that suppresses it from every keyring source, including local hard-trusted entries, without a host redeploy. Only the back-compat single key above is rotated by swapping the env var. A first-party key id (plugins.first_party_key_ids) is retired by editing that locally-committed allowlist and redeploying, since it is deliberately not remote-controllable.