Skip to the content.

Signing plugins

← Back to index

Every plugin the bridge installs or updates has to carry an Ed25519 signature made by its author, and it has to be the same key the plugin was installed under. An unsigned repository, a signature that does not match the files, or a signature by a different key stops the install before anything is written to disk.

This is the one control that survives a repository changing hands. A URL is not an identity: an account takeover rewrites every branch at once, and the clone still looks exactly like the plugin you installed last month. A key the attacker does not hold does not.

What the bridge checks

  1. plugin.sig exists at the repository root and is version 1, ed25519.
  2. Its id matches plugin.json’s, so a valid signature cannot be lifted onto another plugin.
  3. Its digest matches the files actually in the clone — everything except .git/ and plugin.sig itself, hashed by path and content in a fixed order.
  4. The signature verifies against the public key the file carries.
  5. The key is one you have accepted. A key this machine has not seen is a second desktop confirmation showing the fingerprint. An update signed by a key other than the one the plugin is pinned to is a third, separate confirmation, every time — being a trusted author of one plugin never grants another.

Steps 1–4 are cryptography. Step 5 is you: the fingerprint means nothing until you compare it with what the author publishes. Settings → Plugins → Signing keys lists every key you have accepted, what it has signed, and lets you forget one (which uninstalls nothing; it only means the next thing that key signs is confirmed again).

Making a key

Once, on a machine you control, in the plugin repository:

node scripts/sign-plugin.mjs keygen

That writes the private key to vrcnext-signing-key.txt (mode 0600; --out FILE picks another path) and prints the public key and its fingerprint. There is no recovery: lose the file and your users confirm a key change on their next update.

Keep the private key out of the repository. Publish the fingerprint in your README, so users have something to compare against. node scripts/sign-plugin.mjs fingerprint [--key FILE] prints it again at any time:

Signed by  4c82-4d21-20fc-0d00-1154-ef7a-156c-faea

Signing a release

The signature covers the tracked tree, so commit first, then sign:

node scripts/sign-plugin.mjs sign --key ~/keys/vrcnext-signing-key.txt
node scripts/sign-plugin.mjs verify
git commit -am "Sign"

The signer refuses a dirty working tree on purpose — the bytes on your disk would not be the bytes anyone else receives. It reads each file from git’s objects rather than from disk, so a Windows checkout with core.autocrlf signs the same LF bytes the bridge’s clone receives.

Sign locally. A signature is worth something because it proves the key holder made the tree, not merely someone who can push to the repository. That is the case it exists for: an account or token takeover rewrites every branch, and the pinned key is what notices.

From GitHub Actions, if you must

A job that signs whatever lands on main gives up exactly that: anyone who can push gets their code signed, and anyone who can add a workflow can read the secret. If you sign in Actions anyway, copy scripts/plugin-sign-workflow.yml from the plugin system into .github/workflows/sign.yml; it is shaped to limit the damage:

The secret never leaves the runner; only the signature is pushed.

The format

plugin.sig, at the repository root:

{
  "version": 1,
  "algorithm": "ed25519",
  "id": "club-security",
  "publicKey": "<64 hex>",
  "digest": "<64 hex>",
  "signature": "<128 hex>",
  "signedAt": 1759000000
}

digest is sha256 over "vrcnext-plugin-tree-v1\n" followed by, for every file in path order, its /-separated relative path, a NUL, its length as eight little-endian bytes, and its contents. A submodule contributes nothing (the clone does not fetch it). A symlink anywhere refuses the tree — the bridge will not install it, and the signer will not sign it. The signature covers "vrcnext-plugin-signature-v1\n" + id + "\n" + digest + "\n", which is what binds it to one plugin.

signedAt is chosen by the signer, and the bridge uses it for one thing: an update whose signature is older than the installed tree’s is refused, as is one whose version is lower. A signature proves who made a tree, not that it is their latest, and without this someone who controls the repository but not the key could put an old signed commit back. Going back on purpose is an uninstall and a fresh install.

The fingerprint is the first 16 bytes of the public key hex string’s sha256, in groups of four. It exists to be compared by eye, which is why it is grouped rather than run together.

What signing does not give you