Imposed rule: Work of this type or tasks of this type on this component must also be applied everywhere else it should be applied.

Pack Signing & RBAC Controls

This document defines the signing, verification, and authorization requirements for Stella Ops Task Packs across the CLI, Packs Registry, Task Runner, and Offline Kit. It is written for operators who publish and approve packs and for engineers building pack tooling. It aligns with Authority sprint tasks (AUTH-PACKS-41-001, AUTH-PACKS-43-001) and security-guild expectations. For the canonical scope catalogue and role model, see authority-scopes.md and scopes-and-roles.md.

Implementation status (reconciled 2026-05-30). Several controls in this document are the target posture; the shipped implementation is narrower. Where they diverge, an explicit Status note is given. Ground truth: StellaOpsScopes.cs (scope catalog), StellaOps.PacksRegistry.* (registry service), PasswordGrantHandlers.cs / StellaOpsScopeAuthorizationHandler.cs (Authority approval-token enforcement), and StellaOps.Cli (PackClient.cs, CommandFactory.cs).

Key gaps to be aware of before relying on this page:

  • The Packs Registry service signature verifier for uploaded packs is SimpleSignatureVerifier (SHA-256 digest match, accepts unsigned) or RsaSignatureVerifier (RSA-PKCS1 over the digest string). Cosign/Fulcio and DSSE verification of uploaded pack bundles are not implemented — those rows are roadmap.
  • The OFFLINE-SEED EXPORT is a separate path and it IS signed (SPRINT_20260722_012 JOB-13, F-JOB13-1; producer/verifier interop and key precedence fixed by SPRINT_20260914_001 JCR-1). POST /api/v1/export/offline-seed writes catalog-manifest.json.dsse.json beside the manifest when an export signing key is configured (PacksRegistry:Export:Signing:*, ES256, runtime-supplied — see §7.1). The envelope binds the manifest by SHA-256, so a verifier must recompute that digest and check the signature — binding is not authenticity. With no key the export stays complete and unsigned, and the missing envelope is what says so. Verify with tools/scripts/packs/verify_offline_seed_archive.py --public-key <pem>.
  • The Packs Registry WebService authorizes with a single static X-API-Key (PacksRegistry:Auth:ApiKey); when no key is configured, auth is disabled. It does not enforce the packs.* OAuth scopes — scope enforcement lives in the CLI token requests and the shared Authority resource-server policies (StellaOpsResourceServerPolicies), applied when the registry is fronted by the gateway/router.

1 · Threat Model Highlights

ThreatMitigation
Unsigned or tampered pack uploaded to registryDigest is recomputed (SHA-256) and the supplied signature is verified on upload (PackService.UploadAsync). Status: today the verifier is SimpleSignatureVerifier (digest match; unsigned accepted unless Policy.RequireSignature=true) or RsaSignatureVerifier. Mandatory cosign/DSSE verification is roadmap (not implemented).
Unauthorized user publishing or promoting packsAuthority scope packs.write is requested by the CLI and enforced by the shared resource-server policy when the registry is gateway-fronted. Status: the standalone Packs Registry WebService gates on a static X-API-Key, not on the packs.* scopes.
Privilege escalation during approvalsApproval tokens require packs.approve and the pack_run_id / pack_gate_id / pack_plan_hash parameters at the token endpoint, plus fresh-auth (≤ 5 minutes); enforced in PasswordGrantHandlers (issuance) and StellaOpsScopeAuthorizationHandler (resource layer). All three are recorded as sensitive audit properties (pack.run_id, pack.gate_id, pack.plan_hash).
Secret exfiltration via pack stepsSecrets injection with redaction, sealed-mode network guardrails, evidence review. Status: the CLI exposes secret injection (PackClient.InjectSecretAsync); the broader sandbox/redaction guarantees are roadmap.
Replay of old approval tokensApproval tokens bind pack_plan_hash (validated as a 32–128 char hex digest) and require fresh authentication; stale auth_time is rejected at the resource layer.
Malicious pack in Offline KitMirror/offline-seed export carries provenance and digests, and the export’s catalog manifest is DSSE-signed when a key is configured (JOB-13). Status: the PRODUCE side is implemented; DSSE verification on IMPORT remains roadmap, so a consumer must still verify the archive itself before trusting it.

2 · Signing Requirements

2.1 Implemented today

2.2 Roadmap (NOT IMPLEMENTED in the registry service)

The following describe the intended target posture and are not present in StellaOps.PacksRegistry:

PolicyDescriptionStatus
anyOfAccepts any key in configured trust store.Roadmap
keyRefAccepts specific key ID (kid).Roadmap
oidcIssuerAccepts Fulcio certificates from allowed issuers (e.g., https://fulcio.sigstore.dev).Roadmap
thresholdRequires N-of-M signatures.Roadmap

3 · RBAC & Scopes

Authority exposes exactly four pack-related scopes (canonical source: StellaOpsScopes.cs):

ScopeConstDescription
packs.readStellaOpsScopes.PacksReadRead-only access to Task Packs catalogues and manifests.
packs.writeStellaOpsScopes.PacksWritePublish or update Task Packs in the registry.
packs.runStellaOpsScopes.PacksRunExecute Task Packs via CLI or Task Runner.
packs.approveStellaOpsScopes.PacksApproveFulfil Task Pack approval gates.

All four are exposed as resource-server policies in StellaOpsResourceServerPolicies and seeded into the authority.permissions catalog by S002_seed_full_scope_permissions.sql. Tenant assignment is mandatory for any pack scope under the client-credentials grant (ClientCredentialsHandlers rejects with "Pack scopes require a tenant assignment.").

Note. The CLI PackClient additionally requests two scopes — packs.secrets (secret injection) and packs.cache (cache management) — that are not registered in StellaOpsScopes.cs. They are therefore unknown scopes and will not be issued in an OpTok; treat the corresponding CLI surfaces (InjectSecretAsync, ManageCacheAsync) as not yet scope-backed.

3.1 Role Mapping (illustrative)

These role bundles are guidance only — they are not defined as named roles in the codebase. Operators assemble custom roles from the four scopes above via the Console Admin role editor.

Role (suggested)ScopesUse Cases
pack.viewerpacks.readInspect packs, plan runs.
pack.publisherpacks.read, packs.writePublish new versions, manage channels.
pack.operatorpacks.read, packs.runExecute packs, monitor runs.
pack.approverpacks.read, packs.approveFulfil approvals, authorize promotions.
pack.adminall fourFull lifecycle management (rare).

Roles are tenant-scoped; cross-tenant access requires explicit addition.

3.2 CLI Enforcement


4 · Approvals & Fresh Auth


5 · Secret Management

Status: largely roadmap / partially wired. The CLI exposes a secret-injection call (PackClient.InjectSecretAsync → POST /api/v1/packs/runs/{runId}/secrets, requesting the unregistered packs.secrets scope), but the documented secret audit events below (secretRequested / secretDelivered) are not emitted by the Packs Registry service. Treat this section as the intended design.

Sealed mode is intended to require secrets to originate from a sealed vault, with external endpoints blocked.


6 · Audit & Evidence


7 · Offline / Air-Gap Policies

Status: partly roadmap. Offline-seed export is implemented (POST /api/v1/export/offline-seed → offline.seed.exported); the manual offline-signing approval workflow (.approval-request.json, trust-bundle.pem) is a target design and is not wired in the registry/CLI today.

7.1 · Offline-seed export signing operations

Implemented (SPRINT_20260722_012 JOB-13; corrected by SPRINT_20260914_001 JCR-1/JCR-2). jobengine-web signs the export’s catalog-manifest.json in process: the manifest is hashed as it streams into the archive and a small DSSE subject document (entryName, manifestSha256, tenantId, declaredPackCount) is signed with ES256 and written as the 7th root entry, catalog-manifest.json.dsse.json. The envelope uses the DSSE specification’s field names (payloadType, payload, signatures[].keyid, signatures[].sig) and standard padded base64.

Key supply (.memory-independent summary of KEY_MATERIAL.md’s rule: runtime only, the operator’s source wins):

SettingEnvironment aliasRole
PacksRegistry:Export:Signing:Es256PrivateKeyPemFileSTELLAOPS_PACKSREGISTRY_EXPORT_ES256_PRIVATE_KEY_PEM_FILEAuthoritative once set. Path to a mounted EC P-256 private key PEM. A configured path that is missing, unreadable or empty fails the host at startup naming this variable — never a silent fallback.
PacksRegistry:Export:Signing:Es256PrivateKeyPkcs8Base64STELLAOPS_PACKSREGISTRY_EXPORT_ES256_PRIVATE_KEY_PKCS8_B64Inline PKCS#8, consulted only when no file is configured.
PacksRegistry:Export:Signing:KeyIdSTELLAOPS_PACKSREGISTRY_EXPORT_KEY_IDThe keyid written into the envelope. Empty (the compose default) means derived from the key: sha256-<lowercase hex SHA-256 of the public half's DER SubjectPublicKeyInfo>, the delimiter-safe fingerprint form the verification-key rotation runbook uses for introduced roots. Set it only to pin an operator-chosen id.

Neither source configured → the export is complete and visibly unsigned (no .dsse.json entry; one Information log line per export). A configured signer that fails at signing time also ships unsigned, but logs at Error naming the tenant (owner ruling D-JCR-1). The compose estate mounts devops/etc/jobengine/ read-only at /app/etc/jobengine and points the file variable at packsregistry-export-dev.pem, which devops/compose/scripts/ensure-dev-certs.sh generates per lab (gitignored, never committed) together with its public half packsregistry-export-dev.pub.pem. Run that script before docker compose up. A release-bundle install gets the same pair from install.sh / install.ps1, generated once into the bundle’s config/etc/jobengine/keys/ and never overwritten on a re-run.

Why the key id is derived. A fixed label is independent of the key it names, so the producer can keep signing with one key under a new label, or keep one label across a key change. Platform refuses to publish key material that duplicates an existing root under a different id, including a revoked root. That refusal is what makes revocation final, and it means a relabelled key can never be trusted again. A fingerprint id cannot drift from its key: rotating the key changes the id, and the same key always presents the same id. Compute it from the public half alone:

printf 'sha256-%s
' "$(openssl pkey -pubin -in packsregistry-export-dev.pub.pem -outform DER | openssl dgst -sha256 -r | cut -d' ' -f1)"

Rotating the export signing key (ADR-028; first run on the lab estate 2026-09-25, SPRINT_20260924_005). Use this when the current key is compromised, when its root has been revoked, or on a scheduled rotation. It never needs a change to the distinctness rule, and a revoked root stays revoked.

  1. Generate the new key at runtime. The script moves the current pair out of the directory the container mounts (into the git-ignored .local-secrets/retired/jobengine-export/<UTC>/), writes a fresh EC P-256 pair at the same paths, proves the pair with a sign/verify round trip, and prints the new key id. It prints no private material.

    bash devops/compose/scripts/rotate-jobengine-export-key.sh --confirm
    
  2. Publish the public half as a new pack-export-public-key root. This is consequences-gated: the previously active root moves to retiring and stays trusted for the grace window.

    stella crypto verification-key introduce --profile world      --material-type pack-export-public-key      --key-id sha256-<fingerprint>      --public-material-file devops/etc/jobengine/keys/packsregistry-export-dev.pub.pem      --confirm --i-understand-old-material-will-be-invalidated
    
  3. Recreate jobengine-web so the host reloads the key and pulls the updated trust set. An estate that adopts the published trust set must clear any local STELLAOPS_PACKSREGISTRY_EXPORT_VERIFICATION_PUBLIC_KEY_PEM_FILE in the same recreate, because the host refuses to start with both configured (section 7.2).

  4. Verify a fresh export against the published document, then confirm the negative case. An archive signed under a revoked root must still be refused with 422 signature-key-untrusted.

The CLI authenticates as the operator when STELLAOPS_AUTHORITY_USERNAME and STELLAOPS_AUTHORITY_PASSWORD are set, and uses the password grant with the tenant from STELLAOPS_AUTHORITY_TENANT. Without a username it falls back to client credentials. It sends no token at all unless STELLAOPS_AUTHORITY_URL is set, and Platform then answers 403. list needs crypto:read. introduce needs crypto:read and crypto:profile:admin together, because the read policy guards every verification-key route and the admin policy is added on top; the CLI requests both.

Verifying an archive. The public key is the SPKI PEM beside the private one; for any other key derive it with openssl ec -in <private.pem> -pubout. Then:

python3 tools/scripts/packs/verify_offline_seed_archive.py --archive export.zip --public-key packsregistry-export-dev.pub.pem
# optional determinism check against a second export of the unchanged catalog:
python3 tools/scripts/packs/verify_offline_seed_archive.py --archive a.zip --compare b.zip --public-key <pem>

In product (SPRINT_20260914_003 JEF-5). The same checks, codes and verdicts ship as the CLI verb stella verify offline-seed, backed by the jobengine family’s closed consumer SDK StellaOps.PacksRegistry.OfflineSeed.Contracts (the producer takes its payload type, schema pin and base64 rule from the same SDK, so the two sides cannot drift; the committed fixture pins both verifiers to the real producer):

stella verify offline-seed --archive export.zip --public-key packsregistry-export-dev.pub.pem
stella verify offline-seed --archive export.zip --trust-set trust-set.json --compare second.zip --output json

--public-key is the SPKI PEM; --trust-set is a stellaops.packsregistry.verification-trust-set/v1 document whose active and retiring roots are trusted. The CLI’s exit policy is stricter than the script’s (D-JEF-6): ABSENT and BOUND_UNVERIFIED exit 1 unless --integrity-only says integrity was all that was asked. Verdicts are identical.

VerdictMeaningstella verify offline-seed exit
VERIFIEDEnvelope binds this manifest and the signature verifies under the supplied key.0
BOUND_UNVERIFIEDEnvelope binds this manifest; no --public-key given (or cryptography not installed), so authenticity was not checked. Binding is not authenticity.1 (0 with --integrity-only)
INVALID_SIGNATUREBound, but no signature verifies under the supplied key.1
INVALID_BINDINGThe envelope names a different manifest, entry or pack count — the dangerous case, checked first.1
ABSENTNo envelope: the export ran unsigned.1 (0 with --integrity-only)
UNREADABLEThe envelope could not be parsed.1

The verifier accepts both base64 alphabets and both field spellings (DSSE parsers must), and a fixture produced by the real service — tools/scripts/packs/fixtures/offline-seed-signed/ — is verified to VERIFIED by its test module, so producer and verifier cannot drift apart unnoticed again.

7.2 · Importing an offline-seed archive

Implemented (SPRINT_20260914_006 OSI-1). POST /api/v1/import/offline-seed takes the archive as an application/zip body under the same packs.write scope the export uses. It verifies with the same SDK as the CLI verb and then replays the accepted packs through the ordinary upload path, so an import cannot acquire weaker rules than an upload.

Trust supply — a NEW key family, deliberately separate from PacksRegistry:Verification:* (that one is the RSA root for pack signatures; this is the ES256 key that signs the archive):

SettingRole
PacksRegistry:Export:Verification:TrustSetJsonA verification-trust-set/v1 document. Its active and retiring roots are trusted, which is the ADR-028 grace window: during a rotation an archive exported under the outgoing key still imports.
PacksRegistry:Export:Verification:TrustEpochOptional. When present it must equal sha256: of <regionProfile>\n<canonical-json>, so a truncated or re-ordered pull fails closed instead of narrowing trust silently.
PacksRegistry:Export:Verification:PublicKeyPemA single SPKI PEM, for an estate with one export key and no rotation ledger. Consulted only when no trust set is configured.

A trust set must carry exactly one active root and at most one retiring one; archived and revoked roots are ledger history, never live trust. A misconfigured root fails the host at startup rather than degrading to “no trust”. With nothing configured the endpoint answers 503 export_trust_not_configured — it refuses rather than verifying against nothing.

Verdict to response. The vocabulary is the CLI verb’s, so an operator reads the same words from either surface:

VerdictResponseCatalog effect
VERIFIED, no findings200 with importedPacks / alreadyPresentPacks and the signing keyidPacks written; offline.seed.import.completed audit row
INVALID_BINDING422 with the finding codesNothing written; offline.seed.import.refused audit row
INVALID_SIGNATURE422 with the finding codesNothing written; refusal audit row
BOUND_UNVERIFIED422Nothing written; refusal audit row
ABSENT422Nothing written; refusal audit row
UNREADABLE422Nothing written; refusal audit row

ABSENT and BOUND_UNVERIFIED are refusals here with no --integrity-only equivalent. The CLI can offer that escape because it reports to a human; an ingest gate admitting content into a catalog cannot, and integrity is not authenticity.

Tenancy. Packs land in the caller’s validated stellaops:tenant claim. The tenantId inside catalog-manifest.json is read for verification and otherwise ignored, so a cross-estate import is never a cross-tenant write and an archive cannot name its own destination.

7.3 · Anchoring an admitted import in the transparency log

Implemented (SPRINT_20260914_006 OSI-1c, posture D-OSI-4). Every admitted import is anchored in the local transparency log: the archive’s own catalog-manifest.json.dsse.json is submitted to the Attestor’s POST /api/evidence/v1/rekor/entries under the artifact kind offline-seed-import, with the manifest digest as meta.artifact.sha256 and the accepting keyid in the subject.

SettingRole
PacksRegistry:Import:Anchoring:AttestorUrlBase URL of the Attestor. Blank means no transparency log, which is a supported air-gapped deployment, not a degraded one.
PacksRegistry:Import:Anchoring:TimeoutSecondsHow long one submission may take before it counts as unreachable (default 10). The dedicated identity’s token request is bounded by the same value.
PacksRegistry:Import:Anchoring:RetryIntervalSecondsHow often the backlog is drained once a log is configured (default 300).
PacksRegistry:Import:Anchoring:AuthScopeScope requested for the submission token (default attest:create). The route sits behind the attestor:write policy, which is satisfied by this scope - the two spellings differ, and requesting the policy name mints a token the policy rejects.
PacksRegistry:Import:Anchoring:Authority:TokenEndpoint / :ClientId / :ClientSecretThe dedicated anchoring identity. When all three are set the sink mints its own client-credentials token per tenant, sending the tenant as the tenant request parameter so the issued token carries the stellaops:tenant claim the Evidence host reads. Authority answers 400 Tenant selection is required to a client-credentials request that omits it. When they are absent the sink falls back to the host’s shared IStellaOpsTokenClient, if one is registered.

Each setting also resolves from its flat STELLAOPS_PACKSREGISTRY_IMPORT_ANCHORING_* alias (..._AUTH_SCOPE, ..._AUTHORITY_TOKEN_ENDPOINT, ..._AUTHORITY_CLIENT_ID, ..._AUTHORITY_CLIENT_SECRET). The reference registration is stellaops-jobengine-anchor in devops/etc/authority/plugins/standard.yaml, holding only attest:create; its secret reaches the Authority container through JOBENGINE_ANCHOR_AUTHORITY_CLIENT_SECRET, because the seeder resolves clientSecretEnvironmentVariable in Authority’s own environment, not in the jobengine’s.

The Evidence host must know the artifact kind. offline-seed-import was added to the Attestor’s closed artifact-kind whitelist in the same change that introduced anchoring. An estate whose running Evidence image predates it answers every submission 400 artifact_kind_invalid; the import is still admitted and the entry sits at pending with that exact answer on its packs.import_anchors row, which is the tell to look for before suspecting the sink. Measured on the lab estate 2026-09-20 (SPRINT_20260914_006 OSI-1e): twelve one-minute attempts recorded that answer, and the drainer anchored the row seventeen seconds after the rebuilt Evidence host came up.

A verified import that cannot be anchored is admitted and recorded, never refused. This estate is air-gap-first, so making the log an availability dependency of ingest would break the primary deployment shape; recording “unanchored” and moving on would leave an evidence gap nothing ever closes. The offline.seed.import.completed audit row therefore carries an anchor= field:

anchor=MeaningRetried
anchoredThe log accepted the entry; the row also carries logIndex=<n>.Terminal
pendingA log is configured but did not accept the entry - unreachable, refusing, or rejecting the token alike. The import stands and the entry is queued durably in packs.import_anchors with the log’s answer (or the transport failure) on the row.Yes, until it lands
not-configuredNo transparency log on this estate.Never

Why a deferred anchor is not a lost one. The manifest digest, the accepting key id and the DSSE envelope itself are captured at admission time and retained on the backlog row, so anchoring later proves exactly the same fact it would have proven then — deferral costs evidence timeliness, not evidence. Storing only a digest would have made pending unrecoverable, because the uploaded archive is gone by the time a retry runs.

The log de-duplicates on the canonical bundle hash: importing the same archive bytes twice anchors both admissions to the same logIndex, and a backlog row drained after an identical archive was anchored inline settles to that existing index rather than a new one.

Entries are never discarded on an attempt ceiling: a backlog that will not drain is an operator signal, and dropping it is the evidence loss the queue exists to prevent. Settled rows are retained with their log_index rather than deleted, so pending is the absence of an anchor rather than the presence of a row.

Per-pack signatures still apply. The archive’s ES256 signature attests the catalog; each pack’s own signature attests that pack. A pack whose signature this estate does not trust is refused by the upload path with 422 pack_rejected_on_import. An importing estate must trust both, which is the intended property, not an obstacle to work around.


8 · Incident Response


9 · Compliance Checklist


Last updated: 2025-10-27 (Sprint 43). Reconciled against implementation 2026-05-30 — see the “Implementation status” note at the top; sections 2, 5, 6, 7, and 8 distinguish shipped behavior from roadmap.