Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG-v2.3.0.md
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,7 @@ This changes what plugins and filter scripts see for Bedrock traffic. Read the [
- **OCI plugins must be signed** (Enterprise). `AI_STUDIO_OCI_REQUIRE_SIGNATURE` now defaults to `true`, as the Microgateway's `OCI_PLUGINS_REQUIRE_SIGNATURE` already did, and the Helm charts now require signatures too. An unsigned plugin, or one signed with a key that isn't configured, no longer loads. Sign your plugins (`tools/publish-plugin.sh` does) and configure their keys (`OCI_PLUGINS_PUBKEY_*`), or set `AI_STUDIO_OCI_REQUIRE_SIGNATURE=false`. Community Edition can't verify signatures: it warns once at startup and loads OCI plugins as unverified.
- An invalid value (such as `yes`) now stops startup.
- The first start after the upgrade checks every cached plugin against its registry once more. With signatures required, a plugin whose registry is unreachable then fails to load.
- A plugin whose command names no key (`?pubkey=`), such as one installed from the marketplace, is accepted if any configured key verifies it, the Tyk key first. To trust a third-party publisher, add their key (`OCI_PLUGINS_PUBKEY_<NAME>`) on AI Studio and every Microgateway; the install command doesn't change. `?pubkey=` still restricts a plugin to one key. Removing a key stops the plugins it verified from loading.
- **Transparency-log entries are required by default** (Enterprise). For plugins signed without one (`--tlog-upload=false`, for example air-gapped signing), set `AI_STUDIO_OCI_TLOG_POLICY` / `OCI_PLUGINS_TLOG_POLICY` to `disabled`. A private Sigstore deployment sets `*_TRUSTED_ROOT_FILE`.
- **The `cosign` binary is no longer shipped or needed** at runtime. Keep using the cosign CLI to sign plugins. Cosign policy files (`VerifyWithPolicy`) are no longer supported.
- **Plugins no longer inherit the host's environment.** List any variables a plugin needs in `PLUGIN_ENV_PASSTHROUGH` (see [plugin deployment](docs/site/docs/plugins-deployment.md)).
Expand Down
6 changes: 4 additions & 2 deletions docs/site/docs/plugins-deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -362,12 +362,14 @@ Configure public keys for signature verification:

```bash
# Public keys for verifying plugin signatures (the Tyk signing key is always trusted)
OCI_PLUGINS_PUBKEY_1="$(cat /path/to/cosign.pub)" # key content, selected with ?pubkey=1
OCI_PLUGINS_PUBKEY_FILE_CI=/path/to/ci-cosign.pub # key file, selected with ?pubkey=CI
OCI_PLUGINS_PUBKEY_1="$(cat /path/to/cosign.pub)" # key content, pinned with ?pubkey=1
OCI_PLUGINS_PUBKEY_FILE_CI=/path/to/ci-cosign.pub # key file, pinned with ?pubkey=CI
```

Key files must be regular files of at most 64 KiB.

A command without `?pubkey=` (marketplace installs, for example) is checked against every configured key, the Tyk key first, and loads if any of them verifies it. To trust a third-party publisher's plugins, add their public key on AI Studio and on every Microgateway that loads them; the install command stays as it is. Add `?pubkey=<key>` to a command to accept only that key for the plugin. Removing a key from the configuration stops the plugins it verified from loading, cached copies included.

To load unsigned plugins (for example, while developing one), set `AI_STUDIO_OCI_REQUIRE_SIGNATURE=false` on AI Studio and `OCI_PLUGINS_REQUIRE_SIGNATURE=false` on the Microgateway.

**Upgrading from 2.2:** AI Studio now requires signatures by default, and the first start re-verifies every cached OCI plugin against its registry. See the breaking changes in the 2.3.0 release notes (`CHANGELOG-v2.3.0.md`).
Expand Down
2 changes: 1 addition & 1 deletion docs/site/docs/plugins-security.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ For production, OCI is the recommended way to distribute plugins.
- **Internal registries are refused**: a registry that is, or resolves to, an internal address, or does not resolve, is refused before any request (see the internal-hosts check under [Command validation](#command-validation)). This includes plugins already installed and cached. Registry, signature and token-server connections are also checked on the address being dialled, whatever `LLM_UPSTREAM_BLOCK_INTERNAL` is set to. Behind an HTTP(S) proxy (`HTTPS_PROXY` / `HTTP_PROXY`, `NO_PROXY` honoured) the proxy endpoint itself may be internal; the target registry is still checked by name before the request is handed to the proxy, but connect-time rebinding protection does not apply to it, so the proxy is responsible for egress. The registry name must still resolve on the host running AI Studio or the Microgateway. On proxy-only networks, where that host cannot resolve public names, list the registry and every host it redirects blob downloads to in `LLM_UPSTREAM_ALLOWED_INTERNAL_HOSTS` (for `docker.tyk.io`: `LLM_UPSTREAM_ALLOWED_INTERNAL_HOSTS=docker.tyk.io,dl.cloudsmith.io`). If the registry name does not resolve (DNS unavailable), a cached plugin that passes its integrity, signature and key checks still loads, with a warning naming the registry; anything that would contact the registry is refused. To use a registry inside your network, turn on the plugin's **Allow internal host** switch (admin-only; it travels to edges with the plugin), or name the registry in `LLM_UPSTREAM_ALLOWED_INTERNAL_HOSTS` on AI Studio and on every Microgateway that loads its plugins. `ALLOW_INTERNAL_NETWORK_ACCESS=true` also works, but opens much more (see above).
- **Restrict registries.** Set `AI_STUDIO_OCI_ALLOWED_REGISTRIES` (AI Studio) and `OCI_PLUGINS_ALLOWED_REGISTRIES` (Microgateway) to a comma-separated list of registry hosts. An `oci://` command for any other registry is refused. With the variable unset, any registry is allowed.
- **Pin by digest.** `oci://registry.example.com/plugins/my-plugin@sha256:…` always loads the same bytes. A tag (`:v1.2.3`) can be moved to different content by anyone who can push to the repository.
- **Require signatures (Enterprise Edition).** AI Studio and the Microgateway require signatures by default (`AI_STUDIO_OCI_REQUIRE_SIGNATURE`, `OCI_PLUGINS_REQUIRE_SIGNATURE`, both default `true`; AI Studio's default was `false` before 2.3.0). Signatures are verified in-process, without the `cosign` binary, against the digest that was pulled: cosign v2 signatures (the `.sig` tag) and cosign v3 Sigstore bundles (OCI referrers) are both accepted, and an attestation signed with the same key is not. The Tyk signing key is always trusted. Add your own keys as `OCI_PLUGINS_PUBKEY_1`, `OCI_PLUGINS_PUBKEY_2`, … or `OCI_PLUGINS_PUBKEY_<NAME>` (key content), or `OCI_PLUGINS_PUBKEY_FILE_<NAME>` (a file path), and select one per plugin with `?pubkey=` on the command (`?pubkey=2`, `?pubkey=CI`).
- **Require signatures (Enterprise Edition).** AI Studio and the Microgateway require signatures by default (`AI_STUDIO_OCI_REQUIRE_SIGNATURE`, `OCI_PLUGINS_REQUIRE_SIGNATURE`, both default `true`; AI Studio's default was `false` before 2.3.0). Signatures are verified in-process, without the `cosign` binary, against the digest that was pulled: cosign v2 signatures (the `.sig` tag) and cosign v3 Sigstore bundles (OCI referrers) are both accepted, and an attestation signed with the same key is not. The Tyk signing key is always trusted. Add your own keys as `OCI_PLUGINS_PUBKEY_1`, `OCI_PLUGINS_PUBKEY_2`, … or `OCI_PLUGINS_PUBKEY_<NAME>` (key content), or `OCI_PLUGINS_PUBKEY_FILE_<NAME>` (a file path). A plugin whose command names no key, such as one installed from the marketplace, loads if any configured key verifies it (the Tyk key is tried first), so trusting a third-party publisher means adding their key, not editing the install command. To restrict a plugin to one key, name it with `?pubkey=` on the command (`?pubkey=2`, `?pubkey=CI`). Removing a key stops the plugins it verified from loading.
- **Transparency log.** By default a signature also needs a valid Rekor entry for the signing key (`AI_STUDIO_OCI_TLOG_POLICY` / `OCI_PLUGINS_TLOG_POLICY` = `required`), checked offline against an embedded Sigstore trusted root (`*_TRUSTED_ROOT_FILE` replaces it). Keep `required`: `optional` does not protect against someone who can write to the registry and remove the entry. Use `disabled` only for air-gapped signing (`--tlog-upload=false`).
- With signatures required and no verifier, a plugin is refused, never loaded unverified.
- In **Community Edition** signatures are not verified, whatever these variables say: OCI plugins load recorded as unverified, and a warning is logged once at startup.
Expand Down
2 changes: 2 additions & 0 deletions features/PluginMarketplace.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ is set. Without it there is no index to compare against, so no plugin is linked

A marketplace install goes through the ordinary plugin creation wizard (`POST /plugins` →
`validate-and-load` → `approve-scopes` → `PATCH`) with the marketplace entry's OCI reference as the command.
The command names no signing key, so with signatures enforced it loads if any configured key verifies it (the
Tyk key, or a third-party publisher's key the operator added; see `PluginSecurity.md`, "Which key is tried").
Nothing is recorded at install time. Instead the link is **derived from the command**:

`MarketplaceService.ReconcileInstalledVersions` (`services/marketplace_installed_versions.go`) is the single
Expand Down
2 changes: 2 additions & 0 deletions features/PluginSecurity.md
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,8 @@ OCI_PLUGINS_PUBKEY_FILE_COSIGN=/etc/ai-studio/cosign.pub
# 6. The embedded Tyk key: "embedded:default" (always the first default key)
```

**Which key is tried** (`OCIPluginClient.signatureKeys`): a command with `?pubkey=` is checked with that key only. A command without it (every marketplace install) is checked with each configured default key in turn (`DefaultPublicKeys`, from `LoadPublicKeysFromEnv`: the embedded Tyk key first, then `OCI_PLUGINS_PUBKEY_<n>`, named and file keys), for the index and then the platform-manifest fallback; the first key that verifies wins. Operators trust a third-party publisher by adding its key on Studio and every gateway, never by editing the install command; adding a key therefore lets it verify any unpinned plugin. The key that verified is recorded in the cache metadata (`SignatureKey`). A cache hit with `?pubkey=` needs that exact key recorded; one without needs the recorded key to still be configured, so removing a key revokes the plugins it verified (`""`, recorded by releases before this, is the default key and stays valid). The cache records the key ID, not the key's fingerprint, so replacing the PEM behind an existing name is not detected (midsommar-clean-0388).

**Core seams the Enterprise verifier uses** (`pkg/ociplugins`, documented in `doc.go`):
- `NewRemoteRepository(cfg, ref, authConfigName)`: the repository client plugin pulls use (insecure-registry list, registry auth, netguard SSRF guard).
- `WithAuthConfig` / `AuthConfigFrom`: the plugin reference's `?auth=` name, carried on the context to `VerifySignature`.
Expand Down
3 changes: 3 additions & 0 deletions microgateway/docs/oci-plugin-workflow.md
Original file line number Diff line number Diff line change
Expand Up @@ -544,6 +544,9 @@ OCI_PLUGINS_PUBKEY_PROD="-----BEGIN PUBLIC KEY-----..."
# Approach 3: File-based keys
OCI_PLUGINS_PUBKEY_FILE_CI=/etc/microgateway/keys/ci.pub

# A command without ?pubkey= is accepted if any configured key verifies it
# (the Tyk key first); ?pubkey=KEY_NAME accepts only that key.

# Registry Authentication
OCI_PLUGINS_REGISTRY_NEXUS_USERNAME=plugin-reader
OCI_PLUGINS_REGISTRY_NEXUS_PASSWORDENV=NEXUS_PASSWORD
Expand Down
97 changes: 77 additions & 20 deletions pkg/ociplugins/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
"io"
"os"
"runtime"
"slices"
"strings"
"time"

Expand Down Expand Up @@ -197,10 +198,10 @@
}

// Verify signature if required.
var signedDigest string
var signedDigest, signedKey string
if c.config.RequireSignature {
var err error
signedDigest, err = c.verifySignature(ctx, ref, pulled, params)
signedDigest, signedKey, err = c.verifySignature(ctx, ref, pulled, params)
if err != nil {
return nil, err
}
Expand Down Expand Up @@ -265,6 +266,7 @@
}
if enforced {
metadata.SignedDigest = signedDigest
metadata.SignatureKey = signedKey
}

if err := c.storage.StoreMetadata(cacheRef.Digest, params.Architecture, metadata); err != nil {
Expand Down Expand Up @@ -292,8 +294,13 @@
}

// verifySignature checks the signature of the pulled plugin and returns the
// digest whose signature verified. Every failure is an
// ErrSignatureVerificationFailed.
// digest whose signature verified and the public key ID that verified it.
// Every failure is an ErrSignatureVerificationFailed.
//
// A command that names a key (?pubkey=) is checked with that key only.
// Otherwise every configured default key is tried in turn (signatureKeys),
// so a plugin signed with a key the operator added installs, for example
// from the marketplace, without its command being edited.
//
// The signature is checked against the top-level digest the reference
// resolved to: the index for a multi-arch plugin, which publish-plugin.sh
Expand All @@ -305,31 +312,32 @@
// never consulted. Both checks use the digests as pulled, not the tag: a tag
// moved between the pull and the check would otherwise get a signed image
// verified and an unsigned one stored.
func (c *OCIPluginClient) verifySignature(ctx context.Context, ref *OCIReference, pulled *PullResult, params *OCIPluginParams) (string, error) {
func (c *OCIPluginClient) verifySignature(ctx context.Context, ref *OCIReference, pulled *PullResult, params *OCIPluginParams) (string, string, error) {
fail := func(reason string) error {
return &ErrSignatureVerificationFailed{Reference: ref.FullReference(), Reason: reason}
}
if c.securityService == nil {
return "", fail("no signature verifier configured")
return "", "", fail("no signature verifier configured")
}

var resolved string
if pulled.Resolved != nil {
resolved = pulled.Resolved.Digest.String()
}
if !isSHA256Digest(resolved) {
return "", fail(fmt.Sprintf("the reference resolved to no valid sha256 digest (%q)", resolved))
return "", "", fail(fmt.Sprintf("the reference resolved to no valid sha256 digest (%q)", resolved))
}
var platform string
if isIndexMediaType(pulled.Resolved.MediaType) && pulled.Manifest != nil && pulled.Manifest.Digest.String() != resolved {
platform = pulled.Manifest.Digest.String()
if !isSHA256Digest(platform) {
return "", fail(fmt.Sprintf("the platform manifest has no valid sha256 digest (%q)", platform))
return "", "", fail(fmt.Sprintf("the platform manifest has no valid sha256 digest (%q)", platform))
}
}

ctx = WithAuthConfig(ctx, params.AuthConfig)
firstErr := c.verifyDigest(ctx, ref, resolved, params.PublicKey)
keys := c.signatureKeys(params)
key, firstErr := c.verifyDigest(ctx, ref, resolved, keys)
if firstErr == nil {
kind := "manifest"
if platform != "" {
Expand All @@ -339,32 +347,81 @@
Str("reference", ref.FullReference()).
Str("verified_digest", resolved).
Str("digest_kind", kind).
Str("public_key", key).
Msg("OCI plugin signature check passed")
return resolved, nil
return resolved, key, nil
}
if platform == "" {
return "", fail(firstErr.Error())
return "", "", fail(firstErr.Error())
}

secondErr := c.verifyDigest(ctx, ref, platform, params.PublicKey)
key, secondErr := c.verifyDigest(ctx, ref, platform, keys)
if secondErr != nil {
return "", fail(fmt.Sprintf("index %s: %v; platform manifest %s: %v", resolved, firstErr, platform, secondErr))
return "", "", fail(fmt.Sprintf("index %s: %v; platform manifest %s: %v", resolved, firstErr, platform, secondErr))
}
log.Info().
Str("reference", ref.FullReference()).
Str("verified_digest", platform).
Str("digest_kind", "platform").
Str("index_digest", resolved).
Str("public_key", key).
Msg("OCI plugin signature check passed on the platform manifest (the index has no valid signature)")
return platform, nil
return platform, key, nil
}

// signatureKeys returns the public key IDs a signature check tries, in
// order: the key the command names, or else every configured default key
// (the embedded Tyk key first), without duplicates. With no default keys it
// is the empty ID, which the verifier refuses.
func (c *OCIPluginClient) signatureKeys(params *OCIPluginParams) []string {
if params.PublicKey != "" {
return []string{params.PublicKey}
}
var keys []string
seen := make(map[string]bool)
for _, key := range c.config.DefaultPublicKeys {
if key != "" && !seen[key] {
seen[key] = true
keys = append(keys, key)
}
}
if len(keys) == 0 {
return []string{""}
}
return keys
}

// verifyDigest checks the signature of the artifact with the given digest in
// ref's repository.
func (c *OCIPluginClient) verifyDigest(ctx context.Context, ref *OCIReference, digest, publicKey string) error {
// ref's repository with each key in turn, and returns the first key that
// verifies it. When none does, the error gives every key's reason.
func (c *OCIPluginClient) verifyDigest(ctx context.Context, ref *OCIReference, digest string, keys []string) (string, error) {
pinned := *ref
pinned.Digest = digest
return c.securityService.VerifySignature(ctx, &pinned, publicKey)
var reasons []string
for _, key := range keys {
err := c.securityService.VerifySignature(ctx, &pinned, key)
if err == nil {
return key, nil
}
if len(keys) == 1 {
return "", err
}

Check warning on line 408 in pkg/ociplugins/client.go

View check run for this annotation

probelabs / Visor: security

security Issue

The `signatureKeys` function has an implicit fallback to the default Tyk key when no default keys are configured. This happens because the `if len(keys) == 0` block returns `[]string{""}`, and an empty key ID is resolved to the default Tyk key. This behavior is non-obvious and contrary to a comment in the tests which suggests the verifier should refuse to operate. In security-sensitive code, explicit failure is preferable to implicit fallback on misconfiguration.
Raw output
Modify `signatureKeys` to return an empty slice when no default keys are configured. This will cause `verifyDigest` to fail cleanly with a "no configured public key verified it" error, which is more explicit and safer. The `if len(keys) == 0` block can be removed entirely.
reasons = append(reasons, fmt.Sprintf("key %q: %v", key, err))
}
return "", fmt.Errorf("no configured public key verified it (%s)", strings.Join(reasons, "; "))
}

// signatureKeyAccepted reports whether a cache entry whose signature was
// verified with recorded may be served to a load that asks for requested.
// A named key must be the one recorded. A load that names none accepts any
// key still configured, so removing a key from the configuration revokes
// the plugins it verified; "" is an entry cached before the key that
// verified was recorded, which was checked with the default key.
func (c *OCIPluginClient) signatureKeyAccepted(recorded, requested string) bool {
if requested != "" || recorded == "" {
return recorded == requested

Check warning on line 422 in pkg/ociplugins/client.go

View check run for this annotation

probelabs / Visor: performance

performance Issue

The `verifyDigest` function sequentially attempts signature verification with each configured public key. Signature verification is a computationally expensive (CPU-bound) operation. If a large number of public keys are configured, this sequential process can introduce significant latency during plugin loading, especially if the correct key is at the end of the list or if verification fails for all keys.
Raw output
For a small number of keys, the current sequential approach is acceptable. However, to make this more robust against a larger number of keys, consider parallelizing the signature verification. You could launch a goroutine for each key and use a channel to receive the result, returning as soon as the first successful verification completes. This would reduce the worst-case latency from O(N) to roughly O(1) in terms of verification time (plus goroutine overhead).
}
return slices.Contains(c.config.DefaultPublicKeys, recorded)
}

// isSHA256Digest reports whether d is a well-formed "sha256:<64 hex>" digest.
Expand All @@ -374,7 +431,7 @@
}

// isIndexMediaType reports whether mediaType is a multi-platform index.
func isIndexMediaType(mediaType string) bool {

Check warning on line 434 in pkg/ociplugins/client.go

View check run for this annotation

probelabs / Visor: performance

performance Issue

The `signatureKeyAccepted` function uses `slices.Contains` to check if a recorded key exists in the `DefaultPublicKeys` slice. This is an O(N) operation, where N is the number of default public keys. This function is called when loading a plugin from the cache. While the number of keys is currently expected to be small, using a more efficient data structure for lookups is a best practice to prevent potential performance degradation if the number of keys grows.
Raw output
To optimize this lookup, convert the `DefaultPublicKeys` slice into a map (set) when the `OCIPluginClient` is initialized. This would change the lookup from O(N) to O(1). For example, add a `defaultPublicKeysSet map[string]struct{}` to the `OCIPluginClient` struct, populate it once, and then use it for fast lookups in `signatureKeyAccepted`.
return mediaType == ocispec.MediaTypeImageIndex || mediaType == "application/vnd.docker.distribution.manifest.list.v2+json"
}

Expand Down Expand Up @@ -426,8 +483,8 @@
// by fetching it again to record its hash (loading the cached copy
// unverified only when the registry cannot be reached), or as
// ErrCacheCorrupted when signatures are required. So is an entry cached
// while signatures were not required, or verified with a different public
// key than the one now asked for.
// while signatures were not required, or verified with a key the load does
// not accept (signatureKeyAccepted).
func (c *OCIPluginClient) getLocalPlugin(ref *OCIReference, params *OCIPluginParams) (*LocalPlugin, error) {
execPath := c.storage.GetExecutablePath(ref.Digest, params.Architecture)

Expand All @@ -449,7 +506,7 @@
if c.signaturesEnforced() && !metadata.SignatureChecked {
return nil, &ErrCacheCorrupted{Path: execPath, Reason: "cached without signature verification, which is now required"}
}
if c.signaturesEnforced() && metadata.SignatureKey != params.PublicKey {
if c.signaturesEnforced() && !c.signatureKeyAccepted(metadata.SignatureKey, params.PublicKey) {
return nil, &ErrCacheCorrupted{Path: execPath, Reason: "signature was verified with a different public key"}
}
// Serve the entry only if a fresh fetch of ref.Digest would verify the
Expand Down
Loading
Loading