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 DSSE-signed manifest.json files. Runtime admission additionally checks the configured trust, payload and host contracts, and loadable dependencies. 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:

Base bundle contents declared by the source contract:

Full-analyzer overlay contents declared by the source contract:

docker-compose.plugins.scanner-full-analyzers.yml declares 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 mounted build-service-publish.sh paths align both Scanner hosts after publishing the host: scanner-web builds the base profile, while scanner-worker builds the complete runtime profiles. Each host output receives the producer’s exact contract DLLs before image construction. Alignment is performed for every selected host; a prior web build must not cause the worker’s contract copy to be skipped. Do not assemble Scanner runtime bundles by copying an old bin/ or ignored analyzer drop tree. When only one host changes, stage its newly verified bundle in an immutable, owner-labelled runtime directory and bind that directory to the normal container bundle path. Replacing the shared live drop folder can invalidate another host’s still-running contract cohort.

The web cohort contains only the base language and OS bundle names declared by ScannerAnalyzerBundleContract. The generic producer also emits secrets for the Worker’s existing reflection loader; that bundle does not implement the signed Scanner manifest contract and must not be placed under the web verifier’s analyzer root. Do not suppress missing-manifest errors to admit it.

Harness overlay:

The Worker options types ScannerAnalyzerHostOptions and ScannerOsAnalyzerHostOptions default AllowUnsigned and the four Require* booleans to false. Thus signature checking is enabled by default, while an unconfigured Worker host can return an empty analyzer set for missing inputs. The committed docker-compose.scanner.yml, full-analyzer overlay and OS overlay already set the applicable RequirePluginDirectory, RequireTrustStoreDirectory, RequireTrustedKeys and RequireLoadedAnalyzers flags to true and name the mounted public trust directory. These configured Worker hosts refuse missing roots/trust or zero activated analyzers; contract-assembly rejections abort activation.

The WebService uses its separate ScannerSignedPluginVerifier: it logs missing roots/trust or rejected manifests and does not itself throw a startup failure for those conditions. The Worker option flags do not define that verifier’s policy. These are source and committed-Compose facts, not a claim that any particular live cohort, trust mount or image has been qualified.

Configured signing for normal builds

Normal Scanner packaging resolves the central scanner-plugin-manifest signature purpose through the canonical compliance profile and existing provider registry, preserving configured provider ordering and explicit pins. Existing installation files support Ed25519 or ES256, with independently admitted matching public trust through the raw Ed25519 or public SPKI index contract. The tool registers bouncycastle.ed25519, default, and the separately enabled fips.ecdsa.soft / eu.eidas.soft providers. Unsupported profile, provider, custody or Scanner wire selections fail closed. The separate RSA runtime-plugin-artifact contract does not sign Scanner DSSE manifests.

The operator supplies all five settings to package-runtime-plugins.ps1 or to the environment inherited by build-service-publish.sh:

Environment settingPowerShell parameterRequired input
STELLAOPS_SCANNER_PLUGIN_SIGNING_KEY_FILEScannerSigningKeyPathExisting protected installation key file produced through the central configured-material codec: base64 Ed25519 material or ES256 EC PEM
STELLAOPS_SCANNER_PLUGIN_SIGNING_KEY_IDScannerSigningKeyIdOperator-approved key identity
STELLAOPS_SCANNER_PLUGIN_SIGNING_PROVIDERScannerSigningProviderExact selected provider identity; bundled file-capable providers are bouncycastle.ed25519, default, and the separately enabled fips.ecdsa.soft / eu.eidas.soft
STELLAOPS_SCANNER_PLUGIN_SIGNING_PROFILEScannerSigningProfileConfigured compliance profile, subject to canonical STELLAOPS_CRYPTO_COMPLIANCE_PROFILE precedence; unknown profiles fail closed
STELLAOPS_SCANNER_PLUGIN_SIGNING_TRUST_STOREScannerSigningTrustStoreIndependently admitted public-only directory: historical raw Ed25519 files or index.json entries with exact opaque key IDs and public SPKI

The key file is authoritative: a missing, unreadable, malformed or conflicting file cannot fall back to a registered key or the tracked test seed. Resolution does not generate, enroll, replace or persist a private key. The normal packager does not export or admit public trust. It signs each staged unsigned manifest, stamps its actual payload and host contracts, and verifies the resulting DSSE signature against the configured public key before writing the manifest. Missing unsigned input is an error; copying an old signed manifest is not a fallback. -SignScannerAnalyzers source restamping is refused during packaging.

The explicit harness profile and protected -LabOnly procedure remain separate test/lab contracts. Their keys are not normal-build defaults or production trust. Signing-tool tests use isolated ephemeral private material; committed analyzer fixtures are checked through their public keys without reading their tracked seed.

Operator input request before candidate qualification

CCP-079 supplies source and isolated tests only. Before a real signing/build window, the operator must approve the Scanner purpose, provider/profile and key identity, provide an existing protected key-file pointer, and independently approve its matching raw public key and trust-directory pointer. Private material must stay outside source, image contexts and public trust directories. Supply pointers through the settings above; never paste key material into a receipt or command-line argument. No sanctioned Scanner identity is implied by a passing test. Enrollment of a new identity and admission of its public trust are separate operator actions, and normal build never performs them.

For an existing approved base64 Ed25519 key file, the supported preparation sequence is: use export-public below into a new, empty review directory; record the returned key ID and public-key SHA-256; independently approve that public identity and its intended Scanner purpose; then admit only the public files to the approved trust directory and configure the five settings. Use the returned key ID for ScannerSigningKeyId. Export does not establish that the identity is approved or admit it to runtime trust. The exporter writes its output files, so an existing trust directory must not be used as the review destination.

If no sanctioned pointer exists, provision-purpose is the explicit source-supported installation-file operation. It requires separate operator authority; normal lookup, signing and packaging never invoke it. The generic CLI key commands and enroll-runtime-key are not substitutes: the latter owns the different RSA runtime-bundle artifact contract. No live identity or trust follows from the provisioning tests.

Regional provisioning and public recovery

The tool composes the existing crypto DI options. Crypto:Compliance:ProfileId in --configuration <operator-json> is overridden by normal environment configuration, then by STELLAOPS_CRYPTO_COMPLIANCE_PROFILE. The effective value is validated before any lenient profile fallback. Algorithm selection uses scanner-plugin-manifest through SignatureProviderSelection; there is no algorithm CLI switch.

Effective profilePurpose algorithmInstallation-file capability
world, international, kcmvpEd25519Existing BouncyCastle provider and base64 configured-material codec
fips, eidasES256Existing EC providers and EC PEM configured-material codec
gost, smRegional GOST / SM selectionRefused before generation: Scanner has no admitted wire token for that selection
Unknown, blank or experimental pqNone admittedRefused before generation; no world/default substitution

Provider choice remains the registry’s separate configured ordering: StellaOps:Crypto:Registry:ActiveProfile, Profiles:<name>:PreferredProviders and PreferredProviders. --provider pins an exact provider. The first selected provider must itself support signing, verification and the existing material-generation capability; the tool does not search for another provider merely because that provider can generate. The bundled default registry can select default for ES256. Selecting a FIPS or eIDAS algorithm does not certify that provider. fips.ecdsa.soft requires FIPS_SOFT_ALLOWED=true; eu.eidas.soft requires EIDAS_SOFT_ALLOWED=true; both remain non-certified software. Production-blocked simulator/software identities are refused. Provider-held/non-exportable creation without an existing admitted installation custody mechanism is refused before generation; this tool neither exports their private material nor introduces a secret store.

Prepare an existing protected parent directory owned by the operator. Windows permits only owner/SYSTEM/Administrators access, refuses reparse ancestors and holds ancestor handles against rename/replacement. Linux requires an owner-only directory, descriptor-bound creation with no symlink following, and current-operator ownership; it requires /proc and statx. Other platforms fail closed. The file is reserved atomically with create-only semantics before key generation. Existing destinations, unsafe permissions and ambiguous paths are refused. The key ID is opaque; generated EC IDs contain sha256: and must never be converted to Windows filenames or renamed to make a filename fit.

After the separate operator grant, the explicit sequence is:

StellaOps.Scanner.Plugin.SigningTool provision-purpose --purpose scanner-plugin-manifest --configuration <operator-config> --provider <selected-provider> --key-file <existing-protected-parent/scanner.key>
StellaOps.Scanner.Plugin.SigningTool export-purpose-public --purpose scanner-plugin-manifest --configuration <same-config> --provider <same-provider> --key-id <exact-returned-id> --key-file <same-private-file> --out <public-review-directory/index.json>

The first command creates only the private file and reports non-secret profile/provider/key identity. The second reopens that same identity through the configured resolver and writes public review material create-only. It does not admit trust. Independently review the public SPKI fingerprint, exact key ID, purpose and provider, then admit only the approved public index into the runtime trust directory. Index entries bind keyId, algorithm and publicKeyBase64; SPKI supports the existing EdDSA/ES256/RS256/PS256 verifier wire. RSA verification support does not create a new RSA provisioning choice. Conflicting raw/index keys and duplicate index identities are refused.

A public-export failure leaves the private file untouched. Retry only export-purpose-public with the same key and a new output destination; it recovers the same public identity. Generation or partial-write failure leaves the reserved file in place and returns failure. Do not overwrite it or silently regenerate: an operator must first reconcile the incomplete reservation. Normal sign-purpose always honors the configured file, rejects conflicts with provider state, signs through the selected provider and verifies against independent public trust before producing a manifest.

After those inputs and build-signing authority exist, qualify both Scanner hosts and their analyzer cohorts together from source containing CCP-075 and CCP-079. Record actual image/cohort IDs and forward recovery before asking for the exact CCP-075 custody transfer. This source integration does not qualify images or authorize deployment.

Verbs

dotnet exec .../StellaOps.Scanner.Plugin.SigningTool.dll <verb> ...
VerbPurpose
sign-purposeResolve the configured Scanner purpose key, sign, and verify against configured public trust before emitting the manifest.
provision-purposeExplicit create-only protected installation enrollment under configured regional policy; separate operator authority required.
export-purpose-publicReopen the established purpose identity and export its public index create-only; retry without generating or admitting trust.
signExplicit isolated author/test/lab signing with supplied Ed25519 material; not used by normal packaging.
verifyRead a signed manifest, look up each signature’s key id in a trust-store directory, exit 0 iff at least one signature verifies.
derive-keyidPrint the conventional key id for an Ed25519 public key file.
export-publicDerive public material from a private seed, or take the raw 32-byte public key with --public-key-file, and write a loader-compatible trust-store entry without exposing the seed.
sign-externalSign with an Ed25519 key held outside the process: an external command (--external-signer, --external-signer-arg) signs the DSSE PAE bytes, and the result must verify against --trust-store before the manifest is written. The release pipeline uses it to keep the plugin release key on the deployment host.

sign-purpose

dotnet exec StellaOps.Scanner.Plugin.SigningTool.dll sign-purpose \
  --purpose scanner-plugin-manifest --profile world \
  --provider bouncycastle.ed25519 --key-id <approved-key-id> \
  --key-file <protected-base64-key-file> --trust-store <approved-public-trust-directory> \
  --manifest <unsigned-manifest> --stamp-assembly-sha256 \
  --assembly-base-dir <staged-bundle> --contract-assembly <required-contract-dlls> \
  --out <staged-bundle>/manifest.json

Every custody option is required. Wrong purpose, unsupported profile/provider, missing key or public trust, and a signature/public-key mismatch return input error 3 without writing a signed manifest. This command never changes public trust.

sign (isolated author/test/lab use)

dotnet exec StellaOps.Scanner.Plugin.SigningTool.dll sign \
  --manifest plugins/scanner/analyzers/elixir/manifest.unsigned.json \
  --key      <isolated-ephemeral-key> \
  --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

verify

dotnet exec StellaOps.Scanner.Plugin.SigningTool.dll verify \
  --manifest    <staged-operator-signed-manifest> \
  --trust-store <approved-public-trust-directory>

Exit codes:

derive-keyid (historical public-fixture diagnostic)

This example checks the known public fixture’s naming convention only. Its output is not the identity of a newly operator-signed bundle and grants no trust.

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 <approved-protected-base64-key-file> \
  --output-directory <reviewed-public-export-directory> \
  --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. Run it only under the separate operator authority for that key and public export. Writing an export does not admit it into a running host’s trust store.

Key id convention

keyId = <prefix> + first16hex(SHA-256(rawPublicKey))

The derivation helpers default to stella-scanner-plugin-dev-; an operator deriving a new approved identity must supply the intended prefix explicitly. Normal sign-purpose uses its configured key ID without generating or renaming it.

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

<approved-public-trust-directory>/
├── index.json
├── <approved-key-id>.public.b64
└── <approved-key-id>.public.ed25519

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);

ScannerAnalyzerBundleContract.ProductionTrustStoreRelativePath supplies the default relative location. The committed overlays explicitly select /app/trust-roots/plugins/scanner; operators must bind the approved public-only directory to the actual configured location for their selected cohort.

Production posture

Determinism

Ed25519 signatures are deterministic by RFC 8032 construction. Re-signing the same manifest with the same key produces byte-identical signature bytes. Normal packaged cohorts are build output under .runtime/plugins/; source fixtures and their historical public signatures remain separate.

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

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 signing tool resolves existing private custody for an authorized build; it does not enroll or own the operator’s key. Operators checking an already-signed cohort use stella scanner plugins. These commands reuse the Loader and trust reader, but a local result does not establish the selected runtime host’s activation, contract compatibility or deployment state.

For the normal examples, set APPROVED_PLUGIN_DIR to the selected staged cohort, APPROVED_SCANNER_TRUST_DIR to its independently approved public-only trust directory, and APPROVED_PLUGIN_MANIFEST to the specific manifest being checked. Use the public identity selected for that cohort, not the repository’s dev trust.

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 "$APPROVED_PLUGIN_DIR" \
  --trust-store "$APPROVED_SCANNER_TRUST_DIR"

Text output reports the observed plug-in IDs, versions, language capabilities, signature status, key IDs and payload digests. Compare them with the approved cohort inventory; the historical fixture’s key ID is not an expected normal result.

Exit codes:

--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    "$APPROVED_PLUGIN_MANIFEST" \
  --trust-store "$APPROVED_SCANNER_TRUST_DIR"

The default posture is --strict (rejects unsigned manifests). Keep it enabled for normal operation and key rotation. --strict=false permits unsigned input and belongs only to explicitly isolated tests.

Exit codes match list.

derive-keyid

Operator-side convenience over the public key-ID derivation helper. Supply an approved public-key file and the explicitly chosen identity prefix; compare the result to the key ID configured for signing before using that trust entry.

stella scanner plugins derive-keyid \
  --public-key "$APPROVED_SCANNER_PUBLIC_KEY" --prefix "$APPROVED_SCANNER_KEY_PREFIX"

APPROVED_SCANNER_PUBLIC_KEY and APPROVED_SCANNER_KEY_PREFIX are explicit operator inputs. Omitting --prefix selects the helper’s dev naming convention; it does not select or approve a production identity.

Smoke recipe

After staging a purpose-signed cohort, check it with the same approved public trust selected for its signatures:

stella scanner plugins list "$APPROVED_PLUGIN_DIR" \
  --trust-store "$APPROVED_SCANNER_TRUST_DIR" --format json

Require exit code 0, success: true, rejectedCount: 0, the exact expected nonzero cohort count, and the approved key IDs on the signed entries. Success is conditional on the actual signatures, payloads and loadable dependencies passing; it is not guaranteed merely because signing ran. This local check does not replace qualification of the deployed web/worker cohort or authorize trust changes.

Source verification

Checked against 17cdc1a44a483a2594515350f32de0f5b4a72d4d on 2026-10-13. Recheck defaults and committed deployment flags with:

rg -n 'AllowUnsigned|RequirePluginDirectory|RequireTrustStoreDirectory|RequireTrustedKeys|RequireLoadedAnalyzers' \
  src/Scanner/StellaOps.Scanner.Worker/Plugins/ScannerAnalyzerHostOptions.cs \
  src/Scanner/StellaOps.Scanner.Worker/Plugins/ScannerOsAnalyzerHostOptions.cs \
  devops/compose/docker-compose.scanner.yml \
  devops/compose/docker-compose.plugins.scanner-full-analyzers.yml \
  devops/compose/docker-compose.plugins.scanner-os.yml

For WebService refusal/logging behavior, inspect src/Scanner/StellaOps.Scanner.WebService/Hosting/ScannerSignedPluginVerifier.cs. For CLI defaults and result fields, inspect CommandFactory.cs (the HandleScannerPluginsVerifyAsync registration) and CommandHandlers.ScannerPlugins.cs under src/Cli/StellaOps.Cli/Commands/. Source and Compose checks do not establish which configuration a running deployment currently uses.