ADR-025: Operator-signed governance decisions (per-account keys, verified through the crypto abstraction)

Status: Accepted (partial implementation in progress) Date: 2026-06-08 Sprint: SPRINT_20260608_002_DOCS_operator_signed_decisions_program.md (with the per-module execution sprints it schedules) Related ADRs: ADR-019 (VEX signature verification), ADR-021 (keyless signing), ADR-024 (referrer DSSE signing), ADR-012 (per-tenant clients)

Read this before changing how a human approval/waiver/risk-acceptance decision is recorded, before touching the dual-control ceremony, or before adding any per-user key material.

Implementation Status

As of 2026-06-10, the foundational pieces are implemented in their owning sprints: WS1 regional verify-from-public-key and IOperatorDecisionVerifier, WS2 Authority operator key enrollment/fresh-auth, and WS3 operator-decision@v1 predicate/PAE/DSSE plus dual-control ceremony repair. ReleaseOrchestrator WS4 is implemented: fabricated unsigned DecisionDigest/PolicyDecisionDsse placeholders are removed, signed release-decision metadata persists on approvals/gate decisions, the approve/reject/promote/deploy/rollback endpoints enforce verified operator-decision@v1 envelopes behind default-off per-type flags, production key lookup resolves through the Authority IssuerDirectory client when configured, and a release decision prepare endpoint returns server PAE bytes for clients to sign. Policy/Findings WS5 is implemented and verified with targeted runtime tests for exception approve/reject, risk override, gate bypass, VEX override operator-key anchoring, and Docker-backed Postgres persistence. Policy also exposes a server-canonical prepare endpoint for exception-approve and exception-reject. CLI WS6 is partially implemented: the reusable registry-routed signer, attest attach --sign, signed ReleaseOrchestrator decision verbs, and signed stella exception approve/reject --sign have focused proof. Web WS7 has the device-key service and unsigned triage-label fix complete; the next UI pass can use the real Authority list/read endpoints plus release and exception approve/reject prepare surfaces. ReleaseOrchestrator and Policy approval DTOs expose client-visible signing enforcement metadata, and Policy exception approval DTOs expose the Policy approval approvalRequestId that browser/CLI clients must use when preparing and submitting exception operator decisions. eIDAS QES is synthetic-testable, but production-qualified status remains gated on the real qualified-provider pack and counsel decision. Missing production eIDAS provisioning does not block the other signing lanes. The existing sealed eIDAS fixture may be used only for deterministic parser/loader/CAdES/QES-wiring tests; it is not a Gate 4 asset and cannot satisfy production QES.

Lifecycle-program status update (2026-07-18)

The Accepted decisions above remain the architectural baseline. The owner has now frozen the following binding target for the lifecycle-managed rollout in SPRINT_20260718_009_Authority_operator_signing_key_lifecycle.md; these are target contracts, not claims that the later sprint tasks are already implemented:

  1. Provisioning: a forced client-side enrolment ceremony is the default. A custodial server-mint fallback is allowed only for explicitly named low-assurance tenants, must be labelled as a non-repudiation downgrade, is never the default, and is forbidden for regulated tenants. Such a fallback does not satisfy the normal non-custodial operator-signature assurance in Decision 1.
  2. Accountable act: the personal signature is required at the grant (approve, auto-approve, or gate-override). An ordinary request is key-gated, not signature-gated; when auto-approval turns the request into the grant, that request carries the grant signature.
  3. Mandatory enforcement: enforcement is staged by a per-tenant signing-required flag that defaults off until that tenant’s operators have enrolled. There is no global flip, and enabling a tenant follows provisioning plus explicit owner authorization.
  4. eIDAS assurance: AdES is the v1 bar. QES remains the P7 follow-on, gated by counsel and the offline LOTL/qualified-provider pack.
  5. Provider change: a grace window is on. A provider change notifies exception-granting roles and moves affected operators toward re-enrolment while retained keys remain available for historical verification.
  6. Key-to-person binding: one person may have multiple device/holder keys. A current key means the device or holder in use has an Active DecisionSigning key supported by the tenant’s active provider.
  7. Enrolment route: blocked flows link to /administration/profile, pre-filled with the issuer namespace and active provider.

This rollout adds three material deltas to the original default-off implementation: tenant-staged mandatory grants, enrolment auto-binding to the active provider, and explicit provider-change re-enrolment lifecycle handling. It does not rewrite the original Accepted decisions or weaken historical key resolvability.

Acceptance update (2026-07-24). The lifecycle-managed path is now live-proven on the local disposable tenant: a keyless/unsigned grant was rejected while a signed operator-decision@v1 grant persisted; the browser ceremony enrolled public material only; provider changes retired incompatible keys without breaking verification of an earlier envelope; and the affected operator received the re-enrolment notification. Regional enrolment is verification-provider bound: GOST public keys enrol under ru.bouncycastle.gost and SM public keys under cn.sm.soft; the private signing holders remain external CLI/smartcard/HSM providers and are never implied by those verification identities.

Context

Stella Ops has industrial-grade DSSE/in-toto signing, but it signs machine-produced artifacts (SBOMs, scan verdicts, policy decisions, deltas) automatically with service/tenant keys. Human governance decisions are not signed by the human at all. Investigation (2026-06-08) found:

  1. No human approval carries an operator signature. Release promote/approve/deploy (ReleaseEndpoints/ApprovalEndpoints), policy exception/waiver approve (ExceptionApprovalEndpoints, M-of-N), risk-override approve (OverrideEndpoints), and promotion-gate bypass (PolicySimulationEndpoints) all record only the JWT sub/name claim (or an X-StellaOps-Actor header). The approvals/gate_decisions tables have no signature column. There is no non-repudiation.
  2. No per-account key exists. UserEntity holds only a password hash + optional TOTP secret — no certificate, no key, no public key. WebAuthn/passkeys are declared Future and unimplemented. Every signing key is service-, tenant-, or installation-scoped; a DSSE keyid is always a service identity, never a human.
  3. The one DSSE-signed human decision uses the wrong key. VEX override (RealVexOverrideAttestorAdapter) produces a genuine vex-override@v1 DSSE envelope but signs it with a service key; the operator identity is unsigned payload data.
  4. The dual-control ceremony is signature-theater. HttpContextCeremonyApproverValidator accepts any non-empty ApprovalSignature blob without verifying it (the real gate is the bearer token), and CeremonyOrchestrator.ExecuteCeremonyAsync performs no signing on quorum.
  5. Fabricated evidence reaches the UI. ReleaseControlSignalCatalog emits hardcoded DecisionDigest = "sha256:decision-{…}" and PolicyDecisionDsse = "policy-decision-{id}.dsse" placeholder strings that look like real attestations.

The platform is offline / air-gap-first, on-prem, sovereign-first with regional crypto (eIDAS/FIPS/GOST/SM) via plugins, and BUSL-1.1 supply-chain integrity guarantees. Operator-decision signing must honour all of that: no cloud KMS, no server-held operator private keys, regional-compliant verification, and no new non-permissive dependencies.

Decision

  1. Operators hold their own private key; only the public key is enrolled. No Vault, no custodial key. Each operator generates a keypair and enrolls the public key. The server never holds an operator’s private signing key — this is the property that makes the signature non-repudiable (the server cannot forge it). The custodial (server-signs-on-behalf) model is explicitly rejected.

  2. Enroll operator public keys in the Authority-resident StellaOps.IssuerDirectory(it already exists for public-key enrollment: add/rotate/revoke + validator + audit). It records the operator subjectId(the enrolling account’s sub), a providerHint/region, a canonical algorithmId(the SignatureAlgorithms constants), the decision-signingpurpose, and a keyset lifecycle (active/revoked + validity windows) so signatures stay verifiable against the key that was active when a decision was made. policy.trusted_keys becomes a synced verification view that Policy/Attestor read — Authority does not reference StellaOps.Policy.Persistence(this avoids a dependency-graph inversion and the Guid-vs-string TenantId mismatch between the two stores: ITrustedKeyRegistry’s record is Guid-tenant, the live TrustedKeyRepository is string-tenant). New OAuth scopes for enrollment and decision-signing are added to the canonical StellaOpsScopes.cs. The stable enrolled-key shape, the policy.trusted_keys verification-view delta, and the Authority→Policy pull-by-keyId sync mechanism are frozen in the operator decision-signing enrolled-key contract (AUTH-OPSIGN-006) — the single reference WS3/WS4/WS5/WS6/WS7 consume.

  3. Verification routes through ICryptoProviderRegistry — never bare BCL. A shared IOperatorDecisionVerifier resolves the provider by the enrolled providerHint first (TryResolve(providerHint) + Supports(Verification, algorithmId)), falling back to ResolveOrThrow(CryptoCapability.Verification, algorithmId) for the tenant-compliance-profile default (note ResolveOrThrow itself takes no providerHint parameter), then verifies the DSSE PAE with provider.CreateEphemeralVerifier(alg, spki).VerifyAsync(...) (the IAsymmetricSignatureVerifier.Verify(spki,…) path for RSA/ECDSA). This routes a GOST signature to the GOST provider, an SM2 signature to the SM provider, etc. — regional compliance is structural, not bypassed.

  4. A new operator-decision@v1 in-toto predicate, wrapped in a DSSE envelope signed by the operator key, is persisted on the decision record (new signature columns on approvals/gate_decisions/exception tables). The predicate binds: decision type, subject id + digest, verdict, reason, operator sub + enrolled keyId + key fingerprint, fresh-auth claims (auth_time/acr/amr), and a policy-context digest. The envelope keyid resolves to the enrolled key. The predicate field table, worked example, canonical-JSON/PAE contract, and regional signing matrix are specified in docs/modules/attestor/operator-decision-predicate.md(the model lives at src/Attestor/StellaOps.Attestation/OperatorDecision/OperatorDecisionPredicate.cs; registered in the predicate schema registry). Delivered by WS3 (SPRINT_20260608_013, ATTEST-OPSIGN-001).

  5. Enrollment and signing are protected, fresh-auth-gated actions. Replace the stub FreshAuthService.promptReAuth() with real OIDC max_age/acr step-up; gate both key enrollment/rotation and decision signing on a fresh re-auth. Key enrollment/rotation is additionally auditable and may require dual-control — this is the one new attack surface (whoever can swap an enrolled public key can sign as that operator), so it is hardened deliberately.

  6. All regional profiles are supported, with no new managed dependency. Server-side verify-from-public-key for every region is backed by already-cleared BouncyCastle.Cryptography 2.6.2 (MIT) + .NET BCL: ECDSA/RSA (BCL), Ed25519/SM2/GOST-2012/PQ (BouncyCastle), eIDAS CAdES/QES (System.Security.Cryptography.Pkcs + X509Chain + System.Xml). The browser signs only NIST algorithms via native WebCrypto; SM2, GOST, and eIDAS-QES are CLI/smartcard-only on the operator side. Commercial/native backends (CryptoPro CSP, PKCS#11 HSM modules) remain customer-provided, opt-in, non-default plugins.

  7. Fix the adjacent gaps as part of the same change. Make the dual-control ceremony verify ApprovalSignature through the registry and assemble a multi-signed DSSE on quorum; re-point VEX override from the service key to the operator’s enrolled key; delete the fabricated DecisionDigest/PolicyDecisionDsse stub strings; remove the triage “Sign & Apply” 10-second undo (incompatible with a signed attestation).

Consequences