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:
- Plugins must be signed with the active regional cryptography (world/FIPS/GOST/SM/eIDAS/KCMVP), routed through the same
ICryptoProviderRegistry.ResolveSignerpath every other signing surface uses — sample keys for dev/world, HSM/PKCS#11/remote managers in production. - Signing must be per-plugin (release plugin-by-plugin, incrementally), with a safe rotation story.
- 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:
- A bundle is admitted when any entry validates against an active or retiring root (dual-trust grace).
- An archived root is verify-only (immutable/historical material); it does not admit fresh mounted material on its own.
- A revoked root is never served by the source; an entry that matches only revoked/archived roots fails closed.
- The synthetic legacy-cosign entry verifies against the configured file trust root (
cosign.pub) via the existingOfflineDevRsaSha256PluginVerifier— exactly the legacy path — so v1 bundles keep admitting.
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:
PlatformBootPullPluginSigningRootSource— pulls the ADR-028GET /platform/verification-keys/{profile}/signing-root/jwks-setendpoint (shared internal verification token), the same boot-pull channel EvidenceLocker/Attestor use.LocalDirectoryPluginSigningRootSource— reads a local trust-root dir (<root>/<profile>/<keyId>.pub) for the CLI / air-gap / offline path.
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
- Plugins are signed under the active region’s crypto and released/deployed independently and digest-pinned; rotation is safe (dual-trust grace) and consequence-gated.
- Existing v1 cosign / offline-dev-RSA bundles keep admitting (v1→v2 normalization + the legacy-cosign file-root path); the regression is covered by tests.
- Verification uses pure managed crypto (no cosign binary) and the internal Zot registry by digest (no Rekor/Fulcio) — fully air-gap-safe.
- Cost: a per-plugin release flow, a signing-root set boot-pull per host that opts into
region-aware, and the dual-trust verification path. Hosts not opting in keep the existing offline-dev-RSA / cosign verifiers unchanged.
Alternatives considered
- DSSE-everywhere for plugin bundles: rejected — forks the shared admission contract and discards the detached-sig-beside-assembly convention + path guard for no benefit over a typed multi-sig array.
- Bake each signed plugin into a service image: rejected — forces a service-image rebuild per plugin, has no per-plugin signature gate at the trust boundary, and is hostile to air-gap incremental release.
- A second, plugin-specific key lifecycle: rejected — ADR-028’s signing-root lifecycle already models introduce/re-envelope/invalidate with dual-trust and a JWKS-set boot-pull; duplicating it would drift.
References
- Code (consumer side):
src/__Libraries/StellaOps.Plugin/Security/{SignedRuntimePluginAdmission,RegionAwarePluginSignatureVerifier,IPluginSigningRootSource,PlatformBootPullPluginSigningRootSource,PluginSignatureVerifierSelector,PluginManifestSigner}.cs; contracts insrc/__Libraries/StellaOps.Cryptography/PluginSigningCapabilities.cs. - Reused (no new code):
src/Platform/StellaOps.Platform.WebService/Services/VerificationRootRegistry.cs,.../Endpoints/VerificationKeyLifecycleEndpoints.cs(ADR-028);src/Scanner/__Libraries/StellaOps.Scanner.Storage.Oci/OciArtifactPusher.cs(ADR-024). - Sprint:
docs/implplan/SPRINT_20260610_004_Platform_region_aware_plugin_signing_release.md.
