ADR-030: Region-aware, per-plugin signing and OCI artifact release

Status: Accepted Date: 2026-06-11 Sprint: SPRINT_20260610_004_Platform_region_aware_plugin_signing_release.md Related ADRs: ADR-024 (internal Zot OCI registry / attachment state), ADR-028 (verification-root rotation & re-enveloping — reused verbatim for the signing-root lifecycle), ADR-025 (operator-signed governance decisions)

Read this before changing how a mounted plugin bundle is signed or verified, before adding a regional crypto bundle, before adding a new mounted-plugin host/loader, or before wiring a per-plugin release/deploy path.

Context

Plugin signing was hardwired: runtime bundles (SignedRuntimePluginManifest, runtime-bundle.v1) carry a single detached <assembly>.dll.sig produced by devops/build/package-runtime-plugins.ps1 (cosign or offline RSA-PKCS1-SHA256), and SignedRuntimePluginAdmission.Admit() verified it against one hardcoded RSA cosign.pub. There was no algorithm/keyId in the manifest, no region awareness, and no way to release a plugin independently — every plugin shipped baked into a service image.

Three operator requirements drove this change:

  1. Plugins must be signed with the active regional cryptography (world/FIPS/GOST/SM/eIDAS/KCMVP), routed through the same ICryptoProviderRegistry.ResolveSigner path every other signing surface uses — sample keys for dev/world, HSM/PKCS#11/remote managers in production.
  2. Signing must be per-plugin (release plugin-by-plugin, incrementally), with a safe rotation story.
  3. Release artifacts must live on the internal OCI registry (Zot), digest-pinned, not baked into service images.

The platform already had every primitive needed: region-aware signing/verification (ICryptoProviderRegistry, CryptoComplianceService.ActiveProfile, the regional crypto plugins), an internal OCI registry with OciArtifactPusher (ADR-024), and a fully-shipped verification-root rotation lifecycle (ADR-028: VerificationRootRegistry + VerificationKeyLifecycleEndpoints, including a signing-root material type with a JWKS-set boot-pull). The only missing pieces were the consumer side (a region-aware verifier and the v2 envelope) and the per-plugin sign/release/deploy flow.

Decision

1. Signature envelope: runtime-bundle.v2 with a multi-signature array

Extend SignedRuntimePluginManifest with a signatures: [{ algorithm, keyId, provider, profile, signaturePath }] array (runtime-bundle.v2). This is not DSSE-everywhere — it keeps the detached-sig-beside-assembly convention and the path-traversal guard, does not fork the shared admission contract, and the array enables ADR-028 dual-trust grace (verify if any active/retiring root validates).

A legacy v1 bundle (no signatures array, single detached <assembly>.sig) normalizes to a synthetic single-entry v2 (RS256 / legacy-cosign provider / legacy-cosign keyId) so existing cosign / offline-dev-RSA bundles keep verifying unchanged. SignedRuntimePluginAdmission.NormalizeSignatures() is the single normalization chokepoint; the escape-safe signature-path guard runs against every declared entry (it must resolve to the conventional <assembly>.sig).

The canonical per-profile signing algorithm token (PluginSigningProfileAlgorithms) is: world → RS256, fips → RS256, sm → SM2, gost → GOST3410-2012-256, kcmvp → ES256, eidas → ES256. Bundles advertise a crypto:signing:<algo> capability (PluginSigningCapabilities) alongside their existing capabilities.

2. Region-aware, fail-closed verification keyed by (algorithm, keyId)

RegionAwarePluginSignatureVerifier resolves, per signature entry, a verifier + public key by (algorithm, keyId) against the active profile’s signing-root set (active + retiring + archived) supplied by an IPluginSigningRootSource, and verifies the detached signature through the crypto registry’s ephemeral verifier for that algorithm (managed crypto, no native cosign binary). Acceptance rule:

Fail-closed is preserved end-to-end: an empty/unreachable signing-root set, no-matching-keyId, an unsupported algorithm, or a signature mismatch all reject. AllowUnsigned is never enabled in production.

The algorithm token in the v2 manifest is the plugin-signing token (PluginSigningProfileAlgorithms); the verifier maps it to the crypto-registry verification token before resolving the provider (the only divergence is GOST: manifest GOST3410-2012-256 → registry GOST12-256).

IPluginSigningRootSource has two implementations:

3. Centralized verifier selection (no drift)

Every host that builds a mounted-plugin verifier from configuration (Authority, Excititor WebService/Worker, Concelier, Scheduler, Notify) routes through one shared factory — PluginSignatureVerifierSelector.CreateVerifier(...) — which owns the none | cosign | offline-dev-rsa-sha256 | region-aware branch. Selecting region-aware requires the host to supply a RegionAwarePluginVerifierContext (crypto registry + signing-root source + active profile); an absent context for that provider is a fail-fast configuration error, never a silent downgrade. This guarantees the supported-provider set (including the new region-aware id) has exactly one definition across the ~6 selector call sites.

4. Reuse the ADR-028 signing-root lifecycle verbatim

Rotation = introduce (dual-trust) → per-plugin re-sign under the new keyId → invalidate (consequences-gated). No new lifecycle code: VerificationRootRegistry + VerificationKeyLifecycleEndpoints are reused unchanged; only the consumer-side verifier and source are new.

5. Release artifacts as per-plugin digest-pinned OCI artifacts (not image-baked)

Each signed bundle becomes one digest-pinned OCI artifact (media type application/vnd.stellaops.plugin.bundle.v1) in Zot via OciArtifactPusher; deploy pulls by digest, verifies the region signature before extraction (fail-closed, reusing RegionAwarePluginSignatureVerifier), then extracts to the plugin volume. This gives per-plugin independence (no service-image rebuild per plugin), a real per-plugin signature gate at the trust boundary, and air-gap friendliness. (Phases 3–5 — OCI media type, push, CLI, deploy plugin — are tracked in the sprint; this ADR fixes the contract.)

Consequences

Alternatives considered

References