Skip to Content
PluginsSigning

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 --json

Or using the thin shell wrapper (no Laravel knowledge required):

scripts/plugin-keygen --json

Output:

VCTRS_PLUGIN_REGISTRY_PUBKEY=<base64-public-key> Private key (store in your secret manager — NEVER commit this): <base64-private-key>

Storing the keys

KeyWhere to put it
Public keyVCTRS_PLUGIN_REGISTRY_PUBKEY in .env.production
Private keyCI/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:keygen run 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_KEY env var), or
  • A file path containing the base64-encoded key.

Output:

  • Writes my-plugin-1.0.0.zip.sig next to the archive (base64 detached Ed25519 signature).
  • Prints the SHA-256 hex digest to stdout (store alongside the .sig for 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):

  1. Local hard-trusted keysconfig('plugins.trusted_keys'), operator-configured, empty by default.
  2. Back-compat single keyconfig('plugins.registry_pubkey') (from VCTRS_PLUGIN_REGISTRY_PUBKEY), synthesized into a one-entry {publisher: 'VCTRS', keyId: 'registry-pubkey'} keyring entry.
  3. Remote keyringtrusted-keys.json fetched from the marketplace; its revocations[] list suppresses a matching keyId from any source, local ones included.

Admission (PluginInstaller::installFromZip):

  • Executable code — a server-code provider or a uiMode: module ESM bundle — must match a trusted, non-revoked keyring key, or the archive is refused. This holds regardless of require_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):

TierAssigned when
untrustedno signature match (declarative only — executable code with no match is refused)
signed_third_partymatched any trusted keyring key
signed_first_partymatched 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=true

The enforcement flag is VCTRS_PLUGIN_REQUIRE_SIGNATURE, and it defaults to false (config/plugins.phpenv('VCTRS_PLUGIN_REQUIRE_SIGNATURE', false)). Setting VCTRS_PLUGIN_REGISTRY_PUBKEY does not turn enforcement on; the config comment states this verbatim: “Setting registry_pubkey does NOT imply this flag.” installFromZip() consults config('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

  1. Run plugin:keygen to produce a new keypair.
  2. Deploy the new VCTRS_PLUGIN_REGISTRY_PUBKEY to all host environments.
  3. Re-sign existing plugin archives with the new private key.
  4. Revoke the old private key in your secret manager.

Across the multi-publisher keyring, a compromised key is retired by adding its keyId to revocations[] in the remote trusted-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.

Last updated on