Publish and trust a plugin

Develop, sign, install and revoke an out-of-process plugin, and the trust model behind it.

Gate One extensions are out-of-process services. They are trusted through a signed manifest and a local trust store; code is never loaded into the gateway. This page is the publisher/operator walkthrough: develop, sign, install and revoke.

Plugin families

Extension families and what is not a plugin are defined once in Plugins. For publishers, the only fully signed extension today is the event consumer; injection bundles are route configuration and auth adapters stay in the IAM under the Gate One licence.

The trust model

Three roles:

  • the plugin author writes the plugin;

  • the publisher holds a signing key and signs the manifest;

  • the operator runs Gate One and provisions the trust store.

The publisher signs the manifest once. The operator never re-signs: they only decide which keys and names they accept. The signature attests provenance and integrity — “this artefact is the one I signed and it is unchanged” — not quality.

Note
Signing is not a quality gate. A valid signature says nothing about whether the plugin behaves. The lever that matters after deployment is revocation (see below): the publisher can withdraw trust once experience shows the plugin is wrong, on their own initiative or at the plugin author’s request (a bug, or a move to another model).

Why it is useful: it enforces trust between parties. The operator trusts one publisher key instead of every developer; the publisher attests to what it signed; a swapped or tampered artefact fails closed (signature + digest); and revokedDigests gives recall.

Develop and test unsigned

You do not need a signature to develop. Register the plugin with just name and url (plus types when there is no manifest):

"usage": {
  "dir": "./usage-wal",
  "subjectSecretEnv": "GATE_ONE_SUBJECT_SECRET",
  "pluginSecretEnv": "GATE_ONE_PLUGIN_SECRET",
  "types": { "error": { "shareBytes": 10485760, "fsync": "window_ms" } },
  "plugins": [{
    "name": "com.company.audit",
    "url": "http://127.0.0.1:9100",
    "types": ["error"]
  }]
}

Gate One loads it and delivers events; nothing is verified. So a developer can iterate without a key pair.

Important

An unsigned plugin is never silent. Gate One logs a CONFIG WARNING usage.plugins[<name>]: unsigned plugin (no manifest) at startup and in gate-one -check. The warning is for the operator only — end users are not shown plugin trust state, because they cannot act on it and it would leak deployment details.

Set usage.requireSigned: true in production: an unsigned plugin then becomes a hard load error instead of a warning.

Publish step by step (event consumer)

  1. Implement the service. It exposes GET /health and POST /events, and verifies the X-Gate-One-Assertion on every call (see the plugin contract).

  2. Build the artefact and compute its sha256:<hex> digest (and optional size).

  3. Write the manifest — name, version, hostAPI, artifact, capabilities: ["events"], and the events subscription.

  4. Sign the manifest with the publisher key.

  5. Distribute the artefact, the signed manifest and the public key.

The manifest

{
  "schemaVersion": 1,
  "kind": "extension",
  "name": "com.company.audit",
  "version": "1.0.0",
  "apiVersion": "v1",
  "hostAPI": ">=1.0 <2.0",
  "keyId": "company-2026",
  "artifact": {
    "type": "binary",
    "digest": "sha256:…",
    "size": 10485760,
    "platform": "linux/amd64"
  },
  "capabilities": ["events"],
  "events": {
    "types": ["auth", "error"],
    "filters": { "statusClasses": ["4xx", "5xx"] },
    "maxLagBytes": 5000000,
    "onMaxLag": "block"
  },
  "signature": { "alg": "ed25519", "keyId": "company-2026", "value": "…" }
}

The manifest is authoritative for the subscription; the fields on the plugin entry (types, filters, maxLagBytes, onMaxLag) are used only when there is no manifest.

Signing the manifest

The signature is Ed25519 over the JCS canonical form (RFC 8785) of the document with the signature member removed, followed by a newline and the artefact digest:

payload = JCS(manifest without the "signature" member) + "\n" + artifact.digest
signature.value = base64url(ed25519_sign(private_key, payload))

JCS is a standard canonicalization (sorted keys, compact, defined number formatting), so any language with a JCS library can sign — the publisher does not have to be in Go.

The CLI does it for you:

# generate a key pair (Ed25519); prints the public key for the trust store
gate-one keygen -out company-2026.json -keyId company-2026

# sign: fills artifact.digest/size and signature, writes the signed manifest
gate-one sign -in manifest.json -key company-2026.json \
  -artifact company-audit -out manifest.signed.json

# also register it in a publisher feed (registry): author + distribution URL
gate-one sign -in manifest.json -key company-2026.json \
  -artifact company-audit -out manifest.signed.json \
  --feed registry.json --publisher "Company Ltd" \
  --distribution https://company.example/plugins/audit

# check a signature (or a whole document) later
gate-one verify -in manifest.signed.json -key company-2026.json -artifact company-audit

The same gate-one sign/verify sign the publisher feed (a feed has no separate artefact, so no -artifact is passed). With --feed, sign also upserts this plugin into the feed and re-signs it, so a registry page can link the author and the distribution URL.

Distribute and advertise

There is no Gate One-operated registry or marketplace today. Publishing is out-of-band, and the reverse-DNS name is the discovery key: an operator allow-lists it in trust-store.allowedNames (for example com.company.*) and registers the plugin in usage.plugins.

The publisher feed doubles as the registry. Each entry carries the plugin’s name, version, digest, publisher (author/company) and distribution URL, so it can be rendered as a page:

"plugins": [
  {
    "name": "com.company.audit",
    "version": "1.0.0",
    "digest": "sha256:…",
    "publisher": "Company Ltd",
    "distribution": "https://company.example/plugins/audit",
    "status": "active"
  }
]

Distribute the artefact + signed manifest + public key (and the SBOM) wherever you publish software — a release page, a package registry, a file share — and point distribution at it. The operator fetches from there, verifies against your key, and adds it to the trust store. The feed is also how you revoke and how operators learn which versions are compatible.

Trust lifecycle

sequenceDiagram
  autonumber
  participant Dev as Plugin author
  participant Pub as Publisher (signer)
  participant Op as Operator
  participant GO as Gate One
  participant Pl as Plugin service
  Dev->>Pub: artefact + manifest draft + tests
  Pub->>Pub: sign manifest (Ed25519)
  Pub-->>Op: artefact + signed manifest + public key
  Op->>Op: trust store (key, allowedNames, allowedDependencies)
  Op->>GO: usage.plugins + usage.requireSigned
  GO->>GO: verify signature, digest, name, host API
  GO->>Pl: POST /events (signed assertion)
  Pl-->>GO: ack offsets
  Pub-->>Op: revoke (digest) if the plugin misbehaves

Revocation

Trust can be withdrawn after deployment. Three local mechanisms, applied by the operator (the trust store is provisioned out-of-band and never fetched from a plugin):

  • Revoke an artefact — add its sha256 digest to trust-store.revokedDigests. It is refused even with a valid signature.

  • Revoke a key — remove the key from trust-store.keys; everything that key signed is refused.

  • Reload — SIGHUP re-reads the trust store and the configuration; the next load of the plugin fails closed.

The publisher feed

A revocation should not have to wait for every operator to edit a file. The publisher feed is a signed document the publisher maintains, listing the components that fit together and their revocation status:

{
  "schemaVersion": 1,
  "kind": "publisher-feed",
  "publisher": "Cladone-IT",
  "generatedAt": "2026-11-07T00:00:00Z",
  "validUntil":  "2026-12-07T00:00:00Z",
  "hosts": [ { "name": "gate-one", "version": "1.5.0", "hostAPI": ">=1.0 <2.0" } ],
  "externalComponents": [ { "name": "redis", "role": "session-store", "constraint": ">=7.0 <8.0" } ],
  "plugins": [ { "name": "com.company.audit", "version": "1.0.0", "digest": "sha256:…", "publisher": "Company Ltd", "distribution": "https://company.example/plugins/audit", "status": "active" } ],
  "revocations": [ { "digest": "sha256:…", "reason": "misbehaviour", "severity": "high" } ],
  "signature": { "alg": "ed25519", "keyId": "cladone-2026", "value": "…" }
}

The operator points at it and pins the publisher key:

"publisherFeed": {
  "file": "publisher-feed.json",
  "keys": { "cladone-2026": "<base64 Ed25519 public key>" },
  "mode": "enforce"
}
  • mode: warn (default) — revocations and incompatibilities are reported by gate-one -check and doctor; nothing is blocked.

  • mode: enforce — the feed’s revocations (by digest and by key) are merged into the plugin trust store, so a revoked plugin refuses to load; a plugin the feed marks revoked is refused when a digest is given. A stale (validUntil passed) feed refuses new loads rather than failing open.

  • The feed only narrows trust and advises (compatibility); the local trust store stays authoritative, and the operator can always override.

This is how the publisher — or the plugin author asking them — pulls a plugin out of production after experience shows it misbehaves, without waiting for a manual edit on every deployment.

Differences by plugin type

  • Event consumer (events) — the implemented path; this page.

  • Injection / UI — today a per-route inject bundle (HTML/JS/CSS and client-side patches), referenced by route.inject and validated against inject-bundle.schema.json. It is a configuration file, not a signed plugin; a signed-bundle path is planned.

  • Auth adapter (kind: auth) — IAM-only and under the Gate One licence; it is not an extension and cannot be signed by a third party.

There is deliberately no “route handler” plugin: a Backend-For-Frontend is a proxied backend: route, and response transforms are the gateway’s native injector chain. Adding an out-of-process transform would buffer the response and sit on the hot path, which Gate One avoids.

Install on the operator side

"usage": {
  "dir": "./usage-wal",
  "subjectSecretEnv": "GATE_ONE_SUBJECT_SECRET",
  "pluginSecretEnv": "GATE_ONE_PLUGIN_SECRET",
  "trustStore": "trust-store.json",
  "requireSigned": true,
  "types": { "auth": { "shareBytes": 10485760, "fsync": "always" } },
  "plugins": [{
    "name": "com.company.audit",
    "url": "http://127.0.0.1:9100",
    "manifest": "company-audit.manifest.json",
    "artifact": "company-audit"
  }]
}
  • a signed manifest requires usage.trustStore;

  • artifact is the file whose digest the manifest signs (Gate One verifies it, it does not launch it — the service must be running at url);

  • relative paths are resolved from the configuration file;

  • instead of inline entries, usage.pluginsDir (conventionally plugins.d) scans every *.json descriptor in a directory; a descriptor has the same shape as one usage.plugins[] entry, and its manifest/artifact paths are relative to the descriptor;

  • SIGHUP reloads the configuration, including plugins.

The complete signed example is the usage-plugin example.