ADR-043: Plugin bundle admission without the platform crypto provider
Status: Accepted Date: 2026-11-04 Amends: ADR-030 (region-aware, per-plugin signing and the runtime-bundle.v2 envelope) Related ADRs: ADR-028 (verification-root lifecycle, the source of the root statuses)
Read this before changing how a host without the platform crypto provider admits a plugin bundle, before adding an algorithm to the standard bundle verifier, or before moving a type between the plugin loader and its Stella-side assembly.
Context
ADR-030 made runtime-bundle.v2 the plugin bundle envelope: a manifest with a signatures array of (algorithm, keyId, provider, profile, signaturePath) entries, detached signatures beside the assembly, and verification against a signing-root set in which any active or retiring root admits. Its verifier resolves each entry through the platform crypto provider registry, which carries the regional algorithms (for example SM2) and needs the profile-aware StellaOps.Cryptography library.
The plugin loader StellaOps.Plugin is also used by hosts without the platform crypto provider: a Router gateway host composed on StellaOps.Router.Gateway.Hosting, and other hosts that load transports, stores or backends from a folder. Such a host cannot reference the profile-aware library, the Stella Ops plugin contracts or the platform’s boot-pull signing roots, and still has to admit signed bundles fail-closed.
Decision
1. One envelope: runtime-bundle.v2
The loader admits runtime-bundle.v2 bundles through SignedRuntimePluginAdmission, unchanged from ADR-030: manifest binding (the id equals the bundle directory name; module, contract version and the required capability match what the host asks for), the assembly’s SHA-256 against the manifest, the escape-safe signature path (SignedRuntimePluginAdmission.ResolveBundleFile, public so a verifier in another assembly applies the same guard), then the signature verifier. No second envelope and no second admission chain exist.
2. RS256 and ES256 on .NET cryptography
StandardBundlePluginSignatureVerifier verifies the signature entries on the dependency-free pair StellaOps.Cryptography.Abstractions and StellaOps.Cryptography.DefaultProvider, which wrap the .NET cryptography classes:
RS256: RSA PKCS#1 v1.5 with SHA-256, throughIAsymmetricSignatureVerifier;ES256: ECDSA with SHA-256 on P-256 only, a 64-byte IEEE P1363 signature, throughIEs256Verifier. A key on another curve presented as ES256 is refused, although the signature is a valid ECDSA signature for that key.
The detached signature file is base64 text over the assembly bytes. Any entry that validates admits the bundle. An entry with another algorithm fails in this verifier (“only RS256 and ES256 are”). A runtime-bundle.v1 manifest normalizes to the legacy single entry and verifies only when the host configures a trust-root file for it.
3. A local trust-root directory
The keys come from LocalDirectoryPluginSigningRootSource: a directory laid out <root>/<profile>/<keyId>.pub (.pem accepted), one PEM SubjectPublicKeyInfo public key per file, the file name being the keyId of the signature entry. Every key on disk counts as active; the operator curates the directory and removes a revoked key. An absent directory or an empty set admits nothing. No network call and no platform service is involved, so the source works offline and in an air-gapped installation.
4. Regional entries stay on the Stella side
Region-aware verification (SM2 and the other profile algorithms through the crypto provider registry), the platform boot-pull signing roots, cosign verification, release manifest signing, the configured verifier selector and the host probe report live in StellaOps.Plugin.Regional (src/Plugin/), which only Stella Ops hosts reference. StellaOps.Plugin references StellaOps.DependencyInjection and the dependency-free cryptography pair, and grants its internals to its own tests only; StellaOps.Plugin.Regional uses its public API.
One bundle can carry a standard entry (RS256 or ES256) and a regional entry. A host without the platform crypto provider admits it on the standard entry; a Stella Ops host admits it on either.
5. The package DLL is also the folder plugin
A transport, store or backend assembly carries its own plugin entry, so the DLL that a NuGet package delivers is the same file a host loads from a signed bundle. A bundle signs one assembly; a second library inside the bundle directory would load unverified, so a host refuses it. A component whose parts are two assemblies (for example the client and server halves of a Router transport) ships as two bundles, and a reference between admitted assemblies resolves to the admitted one.
Consequences
- A host without the platform crypto provider admits signed bundles fail-closed with the same envelope and chain as a Stella Ops host, and needs no network access to do so.
- Bundle producers that target such hosts sign with RS256 or ES256 on P-256; a regional signature alone is refused there.
- The trust-root directory has no lifecycle of its own: rotation is adding the new key file, re-signing, then removing the old file. Stella Ops hosts keep the ADR-028 lifecycle through the boot-pull source.
- The public surface of
StellaOps.PluginincludesResolveBundleFile, which the Stella-side assembly needs; no internal member is visible to another assembly.
First host
StellaOps.Router.Gateway.Hosting reads Gateway:TransportPlugins:Directory (one directory per bundle; module router, capability router:transport, contract runtime-bundle.v2), Gateway:TransportPlugins:TrustRootDirectory and the optional Gateway:TransportPlugins:Profile. A refused bundle stops the host. Configuration and the bundle layout: Router gateway host.
Alternatives considered
- A second, simpler envelope for these hosts: rejected; one bundle would need two manifests to serve both kinds of host, and the admission chain would fork.
- Region-aware verification in the loader: rejected; it needs the profile-aware cryptography library, the plugin contracts and the platform signing roots, which these hosts do not have.
- Several assemblies under one bundle signature: rejected; the manifest is not signed, so a digest list in it protects nothing, and per-assembly signatures already exist as separate bundles.
References
- Code:
src/__Libraries/StellaOps.Plugin/Security/{SignedRuntimePluginAdmission,StandardBundlePluginSignatureVerifier,IPluginSigningRootSource,Rs256PluginSignatureVerifier}.cs;src/Plugin/StellaOps.Plugin.Regional/Security/; the Router host side insrc/Router/__Libraries/StellaOps.Router.Gateway.Hosting/Transports/SignedTransportBundles.cs. - Conformance:
PluginLoaderClosureConformanceTests(the loader’s closure) and the internals check of the shared foundations, insrc/__Tests/architecture/StellaOps.Architecture.Contracts.Tests/. - Tests:
StandardBundlePluginSignatureVerifierTests(src/__Libraries/__Tests/StellaOps.Plugin.Tests/),RouterGatewayHostSignedBundleTests(src/Router/__Tests/StellaOps.Router.Gateway.Host.Tests/).
