Scanner plug-in signing
This page describes how authors produce a signed Scanner plug-in manifest and how operators curate the trust store that the Scanner runtime checks against. It mirrors the schema and behaviour shipped in StellaOps.Scanner.Plugin.Contracts and StellaOps.Scanner.Plugin.Loader.
Why signing
The Scanner host loads language-analyzer plug-ins out of a drop folder (typically plugins/scanner/analyzers/<lang>/). A plug-in is a manifest + an assembly DLL. By default the loader (ScannerPluginLoader) refuses to load any manifest without a valid DSSE signature whose key id resolves to a trust anchor in ScannerPluginLoaderOptions.TrustedKeys. This is the fail-secure posture from CoC §3 DoD.
The signing tool (StellaOps.Scanner.Plugin.SigningTool) is the build-time authoring half of that pipeline: it produces signed manifest.json files that the runtime loader will accept. Runtime verification support (TrustStore and Ed25519AsymmetricSignatureVerifier) lives in StellaOps.Scanner.Plugin.Loader, so Worker/WebService hosts do not depend on the console/tooling assembly.
Runtime analyzer bundle contract
Scanner language analyzer bundles use a restart-time directory contract shared by Worker and WebService path resolution:
plugins/scanner/analyzers/
|-- dotnet/
| |-- manifest.json
| |-- StellaOps.Scanner.Analyzers.Lang.DotNet.dll
| |-- *.deps.json / runtimeconfig / transitive DLLs as required
|-- node/
| |-- manifest.json
| `-- ...
`-- <analyzer>/
|-- manifest.json
`-- payload files
Rules:
- The bundle root is
plugins/scanner/analyzersrelative to the application base directory unlessScannerAnalyzerHost__PluginDirectoryoverrides it. - Each analyzer is an immediate child directory of the root. The runtime does not recursively search nested bundle sets.
- Each child directory contains exactly one signed
manifest.json. The manifestassembly.pathis relative to that child directory and must not be absolute or contain... - Published bundles must include the analyzer DLL, transitive DLLs, deps and runtime files needed for
Assembly.LoadFrom, plus any analyzer-owned static data. The manifestassembly.sha256must bind the actual DLL bytes shipped in that assembled bundle. - Published manifests must also bind the host-owned contract assemblies that the analyzer was compiled against. The signing tool writes these signed metadata entries:
org.stellaops.contract-assembly.<assemblyName>=version=<assemblyVersion>;sha256=<digest>. Language analyzers require exact stamps forStellaOps.Scanner.Analyzers.Lang,StellaOps.Scanner.Analyzers.LangAdapter, andStellaOps.Scanner.Plugin.Contracts; OS analyzers requireStellaOps.Scanner.Analyzers.OS. - Worker compares those signed version/digest stamps with its own loaded contract assemblies after signature and payload-SHA verification, but before loading any plug-in assembly. A missing, malformed, unavailable, or mismatched contract rejects the bundle with a stable
contract_assembly.*reason code. Either analyzer host aborts activation if any bundle has such a rejection, so cross-load-context API skew cannot silently degrade a scan to zero components. - Production trust roots live under
etc/scanner/trust-store/scanner-pluginsrelative to the application base directory. Operators mount public keys there read-only. Private seeds never belong in production images or mounted trust stores. - The committed dev trust store at
src/__Tests/__Datasets/trust-store/scanner-plugins/devremains only a compatibility fixture for current image-staged manifests untilSCN-PLUG-002publishes mounted production bundles.
Approved base bundle:
- OS analyzers:
apk,dpkg,rpm, loaded throughScannerOsAnalyzerHostfrom the signed base bundle root. - Language analyzers:
dotnet,golang,java,node,python.
Approved full-analyzer overlay:
- the complete language set: base
dotnet,golang,java,node,pythonplus optionalbun,ccpp,dart,deno,elixir,php,ruby,rust, andswift.
Compose mounts the full language set read-only at /app/plugins/scanner/analyzers and points ScannerAnalyzerHost__PluginDirectory at that canonical runtime root. The same overlay mounts the signed base bundle at /app/plugins/scanner/base for ScannerOsAnalyzerHost, so required OS analyzers stay signed and admitted without relying on raw build-output directories.
package-runtime-plugins.ps1 -Module scanner -Profile scanner-runtime publishes every language analyzer from the current source tree, rebuilds the base OS analyzers with their complete dotnet publish dependency closure, stamps the resulting host contracts, signs the manifests, and emits both the base and full-analyzer profiles. The RPM producer additionally fails packaging unless its managed SQLite provider and Linux native assets are present. The build-service-publish.sh scanner-worker path runs that producer after the Worker publish, overlays the producer’s exact contract DLLs into the image context, and only then builds the image. Do not assemble Scanner runtime bundles by copying an old bin/ or ignored analyzer drop tree.
Harness overlay:
hello(stellaops.scanner.plugin.hello), built fromsrc/Scanner/samples/plugins/hello-langand signed with the samemanifest.json/assembly.sha256contract. It is the deterministic dummy analyzer for compose probe tests, not a production analyzer.
Fail-closed behavior is opt-in during the image-staged compatibility window. ScannerAnalyzerHostOptions exposes RequirePluginDirectory, RequireTrustStoreDirectory, RequireTrustedKeys, and RequireLoadedAnalyzers; production compose should enable these once the mounted base bundle and production trust store are wired. Until then, the Worker preserves the existing degraded empty-analyzer behavior when the bundle or trust root is absent, with explicit warning/error logs.
Verbs
dotnet exec .../StellaOps.Scanner.Plugin.SigningTool.dll <verb> ...
| Verb | Purpose |
|---|---|
sign | Read an unsigned manifest, sign with an Ed25519 key, emit the signed manifest. |
verify | Read a signed manifest, look up each signature’s key id in a trust-store directory, exit 0 iff at least one signature verifies. |
derive-keyid | Print the conventional key id for an Ed25519 public key file. |
export-public | Derive public material from a private seed and write a loader-compatible trust-store entry without exposing the seed. |
sign
dotnet exec StellaOps.Scanner.Plugin.SigningTool.dll sign \
--manifest plugins/scanner/analyzers/elixir/manifest.unsigned.json \
--key src/__Tests/__Datasets/trust-store/scanner-plugins/dev/seed.hex \
--stamp-assembly-sha256 \
--assembly-base-dir plugins/scanner/analyzers/elixir \
--contract-assembly path/to/StellaOps.Scanner.Analyzers.Lang.dll \
path/to/StellaOps.Scanner.Analyzers.LangAdapter.dll \
path/to/StellaOps.Scanner.Plugin.Contracts.dll \
--out plugins/scanner/analyzers/elixir/manifest.json
--manifest— path to the unsigned manifest JSON. Must match theScannerPluginManifestschema exposed byPlugin.Contracts(the signing tool validates the same invariants the loader does — relativeassembly.path, no..traversal, required fields, etc. — and refuses to sign garbage with a clear diagnostic).--key— path to the Ed25519 private key file. Accepts 32-byte raw seeds, 64-byte libsodium expanded secrets, hex of either, or base-64 of either.--key-id— optional. When omitted the tool derives the conventional key id from the public key.--key-id-prefix— optional. Override the defaultstella-scanner-plugin-dev-prefix when curating production keys.--stamp-assembly-sha256and--assembly-base-dir— computeassembly.sha256from the staged payload rather than trusting an old manifest value.--contract-assembly— one or more host-owned contract DLLs. The supplied assembly names must exactly match the required language or OS set; missing, duplicate, or extra contract assemblies fail signing.--out— destination path for the signed manifest.
verify
dotnet exec StellaOps.Scanner.Plugin.SigningTool.dll verify \
--manifest plugins/scanner/analyzers/elixir/manifest.json \
--trust-store src/__Tests/__Datasets/trust-store/scanner-plugins/dev
Exit codes:
0— manifest verified against a trusted key.4— verification failed (tampered payload, unknown key, bad algorithm token, etc.). The stable reason code is printed on stderr (signature.payload_mismatch,signature.verify.failed, …).3— input error (file not found, manifest invalid JSON, …).
derive-keyid
dotnet exec StellaOps.Scanner.Plugin.SigningTool.dll derive-keyid \
--key src/__Tests/__Datasets/trust-store/scanner-plugins/dev/stella-scanner-plugin-dev-ef2eaaad7b347fe5.public.ed25519
# prints: stella-scanner-plugin-dev-ef2eaaad7b347fe5
export-public
dotnet exec StellaOps.Scanner.Plugin.SigningTool.dll export-public \
--key <offline-release-seed> \
--output-directory <release-trust-store> \
--key-id-prefix stella-scanner-plugin-release-
The command writes the raw <keyId>.public.ed25519 file consumed by TrustStore, a base-64 mirror, and index.json. It never writes or prints the private seed.
Key id convention
keyId = <prefix> + first16hex(SHA-256(rawPublicKey))
Default prefix is stella-scanner-plugin-dev-; production hosts should choose a stable, host-specific prefix.
This convention is deterministic: an operator can rebuild the keyId -> publicKey mapping from public keys alone, which means key rotation can be audited without trusting the file names on disk.
Trust-store layout
src/__Tests/__Datasets/trust-store/scanner-plugins/dev/
├── README.md
├── index.json
├── seed.hex (dev only)
├── stella-scanner-plugin-dev-ef2eaaad7b347fe5.public.b64
└── stella-scanner-plugin-dev-ef2eaaad7b347fe5.public.ed25519
<keyid>.public.ed25519— raw 32-byte Ed25519 public key (binary). The runtime trust-store reader inStellaOps.Scanner.Plugin.Loader(TrustStore.LoadFromDirectory) keys off this file’s name prefix.<keyid>.public.b64— mirror in base-64. Optional but convenient for copy-paste / log redaction.index.json— canonical JSON metadata (key id, algorithm, hex/base64 public key, SHA-256 of the public key for cross-checks).seed.hex— dev only. Production hosts MUST NOT ship the seed; only the public key files belong in a production trust store.
A Scanner host wires the Loader trust store into the loader like this:
var trusted = TrustStore.LoadFromDirectory(trustStoreDirectory);
var options = new ScannerPluginLoaderOptions
{
PluginDirectory = pluginDirectory,
SearchPattern = "manifest.json", // or the default "*.manifest.json"
TrustedKeys = trusted,
AllowUnsigned = false, // production posture; CoC §3 DoD
};
var loader = new ScannerPluginLoader(options, new Ed25519AsymmetricSignatureVerifier());
var result = await loader.LoadAsync(cancellationToken);
Runtime hosts should use ScannerAnalyzerBundleContract.ProductionTrustStoreRelativePath for production mounts. Examples below that reference the dev trust store are authoring/test smoke checks only.
Production posture
- Use a hardware-backed signer (HSM / Cryptography.Plugin.Hsm) rather than a committed seed file.
- Run the signing tool inside the same supply-chain perimeter that produces the plug-in DLL; ideally as the same MSBuild target so the signed manifest is part of the publish output.
- Rebuild and re-sign the bundle whenever a host contract changes. For the in-repo Worker use
devops/docker/build-service-publish.sh scanner-worker; acontract_assembly.mismatchrejection is rebuild guidance, not a switch to bypass. - Publish only the public-key half of each rotated key to operators; the private half stays in the signer.
- Never enable
AllowUnsigned=trueoutside dev/CI. The loader emits a WARNING log when that path is taken (loader.manifest.allow_unsigned) so operators can audit for accidental drift.
Determinism
Ed25519 signatures are deterministic by RFC 8032 construction. Re-signing the same manifest with the same key produces byte-identical signature bytes, which is what makes the signed manifest.json artefact a reproducible build output suitable for committing into plugins/scanner/.
The canonical-payload computation uses StellaOps.Canonical.Json’s RFC 8785 canonicalization (the same library the verifier consumes), so the canonical bytes the signing tool covers are byte-identical to the canonical bytes the loader reconstructs from the in-record manifest core.
Code-of-conduct notes
- All Ed25519 operations go through
StellaOps.Cryptography.Profiles.EdDsa, an exempt profile assembly under CoC §7.4. No BCL crypto is called from feature code. - Runtime hosts use
StellaOps.Scanner.Plugin.Loaderfor trust-store reading and Ed25519 verification. The signing tool is build-time authoring support and must not be a Worker/WebService runtime dependency. - All non-EdDSA signature checks delegate to the canonical
DefaultAsymmetricSignatureVerifierso regional crypto plug-ins (FIPS, GOST, SM, eIDAS) can substitute the implementation when loaded. - The signing tool injects
TimeProvider; noDateTime.UtcNowis used.
Author quick-start
Third-party analyzer authors should start from the docs/dev/templates/scanner-analyzer/ template (Hello-Lang). The template ships with a working signed manifest and a worked-example integration test under StellaOps.Scanner.Plugin.Loader.Tests/HelloLangTemplateIntegrationTests.cs.
Operator workflow (stella scanner plugins)
The StellaOps.Scanner.Plugin.SigningTool console app is intentionally scoped to authors: it owns the private signing key. Operators and CI pipelines that need to enumerate or verify already-signed manifests use the in-CLI verbs under stella scanner plugins instead. The CLI reuses the same Loader ScannerPluginLoader and TrustStore the runtime does, so a result from the CLI is byte-equivalent to what the Scanner host will see at load time.
stella scanner plugins list <plugin-dir> [--trust-store <dir>] [--pattern <glob>] [--allow-unsigned] [--format text|json]
stella scanner plugins verify --manifest <path> [--trust-store <dir>] [--strict] [--format text|json]
stella scanner plugins derive-keyid --public-key <path> [--prefix <prefix>] [--format text|json]
list
Enumerate plug-ins under a directory. One signed manifest.json is expected per immediate subdirectory (the drop-folder convention used by plugins/scanner/analyzers/<lang>/).
stella scanner plugins list plugins/scanner/analyzers/ \
--trust-store src/__Tests/__Datasets/trust-store/scanner-plugins/dev
Output (text mode):
ID | Version | Languages | Signed | Key ID | SHA-256 (short)
stellaops.scanner.plugin.elixir | 0.1.0 | elixir,erlang,hex,otp | ok | stella-scanner-plugin-dev-ef2eaaad7b347fe5 | 96093f1b42b9
Exit codes:
0— at least one plug-in loaded and zero rejections.1— one or more plug-ins were rejected (fail-secure; CoC §3 DoD), or the directory contained no manifests at all.2— input error (directory missing, trust store malformed, etc.).
--format json produces a deterministic JSON document with success, count, rejectedCount, plugins[], and rejected[] arrays; useful for CI gates.
verify
Verify a single signed manifest against a trust-store directory. Mirrors the SigningTool’s verify verb but lives in the operator CLI so it doesn’t drag the signing-tool binary onto production hosts.
stella scanner plugins verify \
--manifest plugins/scanner/analyzers/elixir/manifest.json \
--trust-store src/__Tests/__Datasets/trust-store/scanner-plugins/dev
The default posture is --strict (rejects unsigned manifests). Pass --strict=false for offline-dev rotations.
Exit codes match list.
derive-keyid
Operator-side convenience over KeyMaterial.DeriveKeyId. Useful when curating production trust stores from rotated public keys.
stella scanner plugins derive-keyid \
--public-key src/__Tests/__Datasets/trust-store/scanner-plugins/dev/stella-scanner-plugin-dev-ef2eaaad7b347fe5.public.ed25519
# prints: stella-scanner-plugin-dev-ef2eaaad7b347fe5
--prefix is supported for production keys; the default is stella-scanner-plugin-dev-.
Smoke recipe
After landing a freshly-signed plug-in into plugins/scanner/analyzers/, operators can run the dev trust store against it as a self-check:
stella scanner plugins list plugins/scanner/analyzers/ \
--trust-store src/__Tests/__Datasets/trust-store/scanner-plugins/dev --format json
The result should report success: true, count >= 1, and the new plug-in’s signed: true with the expected key id.
