Runbook — Adding a Router Transport Plugin

Sprint reference: SPRINT_20260501_005 (audit finding B5; sprint now archived).

The gateway transport plugin loader requires a signed transports.signed.json manifest and per-DLL Authenticode (or side-car) signatures against the Stella-internal CA bundle. This runbook covers building, signing, and deploying a new disk-resident router transport plugin.

Scope. This runbook only governs disk-resident drop-in transport plugins under plugins/router/transports/. Transport plugins built into the gateway image (TCP, TLS, UDP, RabbitMq, Messaging, InMemory) ride the gateway image signature, load via LoadFromAssembly, and do not appear in the manifest.

Gateway configuration keys

The loader binds from Gateway:TransportPlugins:* (see StellaOps.Gateway.WebService/Program.cs and RouterTransportPolicyOptions):

KeyDefaultPurpose
Gateway:TransportPlugins:Directory<AppContext.BaseDirectory>/plugins/router/transportsDrop directory scanned for the manifest + DLLs.
Gateway:TransportPlugins:SearchPatternStellaOps.Router.Transport.*.dllGlob for the extra-DLL fail-closed check; pinned file names must match it.
Gateway:TransportPlugins:TrustRootDirectoryfalls back to Plugins:TrustRootDirectory, then <BaseDirectory>/trust-roots/pluginsPEM trust-root bundle the manifest signer chain must validate against.
Gateway:TransportPlugins:AllowedSignerThumbprintsempty (any chain-valid signer)Optional uppercase-hex SHA-1 allowlist; when non-empty the verified signer MUST appear here.
Gateway:TransportPlugins:RequireSignedManifesttrueWhen false (honoured only under IHostEnvironment.IsDevelopment()) no disk transports load at all; in any non-dev environment a false is logged as critical and forced back to true.

Prerequisites

1. Build the transport DLL

Implement IRouterTransportPlugin (in StellaOps.Router.Common.Plugins). The plugin exposes TransportName, DisplayName, IsAvailable(IServiceProvider), and a Register(...) method that wires the transport’s ITransportServer / ITransportClient implementations into DI via the supplied RouterTransportRegistrationContext. Build against the same net10.0 target as the gateway:

dotnet build src/Router/__Libraries/StellaOps.Router.Transport.MyTransport \
    --configuration Release

The output should be StellaOps.Router.Transport.MyTransport.dll under bin/Release/net10.0/. The directory loader consumes the loose DLL (not a NuGet package), so a plain dotnet build is sufficient. The file name MUST be a bare DLL name — no path separators, no globs — and MUST match Gateway:TransportPlugins:SearchPattern (default StellaOps.Router.Transport.*.dll).

2. Sign each DLL

Apply Authenticode (Windows) or the side-car signature scheme used by the platform plugin host (PSV-03). The leaf certificate’s SHA-1 thumbprint becomes the signerThumbprint for that DLL in the manifest.

3. Compute SHA-256 digests

sha256sum plugins/router/transports/StellaOps.Router.Transport.MyTransport.dll

The output (lower-case hex, no separators) goes into the manifest’s transports[].sha256.

4. Generate the signed manifest

The manifest schema is stellaops.router.transports/v1. A worked example appears in RouterTransportManifest and the test fixture under src/Router/__Tests/StellaOps.Router.Common.Tests/Plugins/Trust/RouterTransportSignedFixture.cs.

The signing tool (release engineering owns the production tool — see docs/release/runbooks/sign-router-transport.md once published) produces:

{
  "schema": "stellaops.router.transports/v1",
  "transports": [
    {
      "fileName": "StellaOps.Router.Transport.MyTransport.dll",
      "sha256": "<lower-case hex>",
      "signerThumbprint": "<UPPERCASE-HEX-SHA1-NO-SEP>",
      "minPlatformVersion": "1.0.0"
    }
  ],
  "signedAt": "2026-05-01T00:00:00Z",
  "signerCertificate": "<base64 DER of leaf>",
  "certificateChain": ["<base64 DER of intermediate(s), root excluded>"],
  "signatureAlgorithm": "RS256",
  "signature": "<base64 signature over canonical bytes>"
}

Required fields (the strict parser rejects the manifest otherwise): schema, signerCertificate, signatureAlgorithm, and signature at the top level, plus fileName, sha256, and signerThumbprint on every transports[] entry. signatureAlgorithm is a JOSE-style token resolved by SignatureAlgorithmName.FromString(...)RS256, PS256, or ES256 for the default verifier. EdDSA is a recognised token but not verifiable by the default DefaultAsymmetricSignatureVerifier (its VerifyEd25519 returns false), so an EdDSA-signed manifest parses yet fails signature verification (ManifestSignatureInvalid) unless a regional crypto plugin that replaces the verifier is loaded. certificateChain (base64 DER of any intermediates, root excluded) and signedAt are optional, and minPlatformVersion is informational (not yet enforced). signerThumbprint is the uppercase hex SHA-1 thumbprint (no separators) of the leaf, which is what the X.509 validator surfaces (leaf.Thumbprint).

The signature covers the canonical encoding produced by RouterTransportManifestCanonicalBytes.ComputeFor(...):

schema (UTF-8) || 0x0A
fileName₁ 0x1F sha256₁ 0x1F signerThumbprint₁ 0x1F minPlatformVersion₁ 0x0A
fileName₂ 0x1F sha256₂ 0x1F signerThumbprint₂ 0x1F minPlatformVersion₂ 0x0A
...

Entries MUST be sorted ordinal by fileName before encoding so reordering the manifest array does not change the signed bytes.

The manifest signature is verified with the same X.509-pinned validator as the platform plugin pipeline (X509PinnedSignatureValidator from PSV-03, shared from StellaOps.Plugin.Host); the canonical bytes above are wrapped in a synthetic PluginSignatureManifest envelope with manifestSha256 = SHA256(canonicalBytes) and an empty assemblies list. The manifest’s signerCertificate, certificateChain, signatureAlgorithm, signature, and signedAt are copied verbatim into that envelope, and the validator is invoked unchanged (see RouterTransportTrustPipeline.EvaluateAsync).

5. Stage and pre-flight verify

Place the DLL(s) and the new transports.signed.json in plugins/router/transports/:

plugins/router/transports/
├── StellaOps.Router.Transport.MyTransport.dll
└── transports.signed.json

Verify before restarting the gateway:

stella router transports verify \
  --manifest plugins/router/transports/ \
  --trust-root /etc/stellaops/trust-roots/plugins

Expected output:

[OK] Router transports verification
  manifest signer: <thumbprint>

  per-DLL results:
    STATUS FILE                                          SHA-256 (expected)                                                   REASON
    OK     StellaOps.Router.Transport.MyTransport.dll    <pinned-sha256>

Exit code 0 means every fail-closed check passed; 1 means at least one failed and the gateway will refuse to load the directory.

6. Distribute via the offline kit

Add the signed DLL and manifest to the offline kit bundle published for the target distribution. The kit ships:

The same trust-root bundle is also consumed by the platform plugin host (PSV-05). Rotating it requires a kit refresh + a restart of every gateway and platform host.

7. Restart the gateway

docker compose -f devops/compose/docker-compose.stella-ops.yml \
    restart router-gateway

On rejection the gateway fails to start: bootstrap throws Router transport plugins directory '<dir>' rejected: <Reason> - <detail> (the loader also emits a structured LogError of the form Router transport directory '<dir>' rejected: reason=<Reason> detail=<detail>).

Note. The X509PinnedSignatureValidator writes a Plugin signature verified: subject='...' thumbprint='...' info line, but on the gateway boot path it is constructed with a null logger, so that line does not appear in gateway startup logs on success. Use the pre-flight stella router transports verify (step 5) to confirm the signer thumbprint instead — that is the authoritative success check. A clean boot simply does not throw the rejection above.

Recovery — manifest rotation

When rotating the manifest signer:

  1. Issue the new leaf certificate from the Stella-internal CA.
  2. Re-sign every DLL in the directory with the new leaf.
  3. Generate a new transports.signed.json whose signerCertificate is the new leaf and whose transports[].signerThumbprint is the new thumbprint.
  4. Add the new leaf thumbprint to Gateway:TransportPlugins:AllowedSignerThumbprints before the restart.
  5. Pre-flight with stella router transports verify.
  6. Roll out via offline-kit refresh + gateway restart.

The extra-DLL fail-closed behavior means partial rotations cannot leave the directory in a half-signed state: an unsigned DLL alongside a manifest that does not pin it triggers ExtraneousDll and refuses to load anything.

Failure modes

All rejection reasons below are members of the RouterTransportTrustRejection enum (listed in pipeline-detection order).

Boot-path gating. On gateway startup the trust pipeline only runs when TransportPluginDirectoryProbe.HasSignedManifestLoadCandidate(...) finds a manifest or a DLL matching SearchPattern in the directory. If the directory is missing or empty, the probe returns false, the gateway logs an info line (No disk-resident router transport plugins found under '<dir>'. Using AppDomain-resident transport plugins only.) and boots normally with the built-in (AppDomain-resident) transports. Consequently DirectoryMissing can never fail gateway boot — a missing directory simply means “no disk transports” — and is only ever surfaced by the pre-flight CLI stella router transports verify (which calls the pipeline directly). ManifestMissing, by contrast, does fail boot when a SearchPattern-matching DLL is present without a transports.signed.json alongside it (the DLL makes the probe return true). Once any load candidate is present, every reason below fails boot.

SymptomLikely causeAction
DirectoryMissing (CLI verify only — see note)The configured transports directory does not existCreate the directory (or fix Gateway:TransportPlugins:Directory); redeploy via offline kit
ManifestMissingtransports.signed.json not in directoryRestore manifest; redeploy via offline kit
ManifestMalformedManifest fails schema-shape checks (bad schema, missing signerCertificate/signatureAlgorithm/signature, empty/duplicate/path-bearing fileName)Regenerate the manifest with the signing tool; ensure all required fields are present
ManifestSignatureInvalidLeaf chain fails to validate against trust-root bundleRefresh PEM bundle; verify CA expiry
ManifestSignerNotAllowedVerified signer thumbprint not in operator allowlistAdd the new thumbprint to AllowedSignerThumbprints, then restart
ExtraneousDllA DLL is in the directory that the manifest does not pinEither remove the unpinned DLL or re-issue the manifest with it
ListedDllMissingManifest references a DLL that is not on diskRestore the missing DLL or re-issue the manifest without it
ListedDllDigestMismatchDLL on disk does not match manifest pin (corrupted, modified, or wrong build)Replace with the original signed binary; verify SHA-256 matches before rolling out