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:
- 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.
- 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.
- Mandatory enforcement: enforcement is staged by a per-tenant
signing-requiredflag 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. - eIDAS assurance: AdES is the v1 bar. QES remains the P7 follow-on, gated by counsel and the offline LOTL/qualified-provider pack.
- 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.
- Key-to-person binding: one person may have multiple device/holder keys. A current key means the device or holder in use has an
ActiveDecisionSigningkey supported by the tenant’s active provider. - 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:
- 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 JWTsub/nameclaim (or anX-StellaOps-Actorheader). Theapprovals/gate_decisionstables have no signature column. There is no non-repudiation. - No per-account key exists.
UserEntityholds only a password hash + optional TOTP secret — no certificate, no key, no public key. WebAuthn/passkeys are declaredFutureand unimplemented. Every signing key is service-, tenant-, or installation-scoped; a DSSEkeyidis always a service identity, never a human. - The one DSSE-signed human decision uses the wrong key. VEX override (
RealVexOverrideAttestorAdapter) produces a genuinevex-override@v1DSSE envelope but signs it with a service key; the operator identity is unsigned payload data. - The dual-control ceremony is signature-theater.
HttpContextCeremonyApproverValidatoraccepts any non-emptyApprovalSignatureblob without verifying it (the real gate is the bearer token), andCeremonyOrchestrator.ExecuteCeremonyAsyncperforms no signing on quorum. - Fabricated evidence reaches the UI.
ReleaseControlSignalCatalogemits hardcodedDecisionDigest = "sha256:decision-{…}"andPolicyDecisionDsse = "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
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.
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 operatorsubjectId(the enrolling account’ssub), aproviderHint/region, a canonicalalgorithmId(theSignatureAlgorithmsconstants), thedecision-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_keysbecomes a synced verification view that Policy/Attestor read — Authority does not referenceStellaOps.Policy.Persistence(this avoids a dependency-graph inversion and the Guid-vs-stringTenantIdmismatch between the two stores:ITrustedKeyRegistry’s record is Guid-tenant, the liveTrustedKeyRepositoryis string-tenant). New OAuth scopes for enrollment and decision-signing are added to the canonicalStellaOpsScopes.cs. The stable enrolled-key shape, thepolicy.trusted_keysverification-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.Verification routes through
ICryptoProviderRegistry— never bare BCL. A sharedIOperatorDecisionVerifierresolves the provider by the enrolledproviderHintfirst (TryResolve(providerHint)+Supports(Verification, algorithmId)), falling back toResolveOrThrow(CryptoCapability.Verification, algorithmId)for the tenant-compliance-profile default (noteResolveOrThrowitself takes noproviderHintparameter), then verifies the DSSE PAE withprovider.CreateEphemeralVerifier(alg, spki).VerifyAsync(...)(theIAsymmetricSignatureVerifier.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.A new
operator-decision@v1in-toto predicate, wrapped in a DSSE envelope signed by the operator key, is persisted on the decision record (new signature columns onapprovals/gate_decisions/exception tables). The predicate binds: decision type, subject id + digest, verdict, reason, operatorsub+ enrolledkeyId+ key fingerprint, fresh-auth claims (auth_time/acr/amr), and a policy-context digest. The envelopekeyidresolves to the enrolled key. The predicate field table, worked example, canonical-JSON/PAE contract, and regional signing matrix are specified indocs/modules/attestor/operator-decision-predicate.md(the model lives atsrc/Attestor/StellaOps.Attestation/OperatorDecision/OperatorDecisionPredicate.cs; registered in the predicate schema registry). Delivered by WS3 (SPRINT_20260608_013, ATTEST-OPSIGN-001).Enrollment and signing are protected, fresh-auth-gated actions. Replace the stub
FreshAuthService.promptReAuth()with real OIDCmax_age/acrstep-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.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.Fix the adjacent gaps as part of the same change. Make the dual-control ceremony verify
ApprovalSignaturethrough 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 fabricatedDecisionDigest/PolicyDecisionDssestub strings; remove the triage “Sign & Apply” 10-second undo (incompatible with a signed attestation).
Consequences
- New verify code per region (no new deps): (a) a net-new raw-key verification-only construction path — today the verification-only
CryptoSigningKeyctor is EC-only, so Ed25519/SM2/GOST need a verifier built directly from SPKI; (b) Ed25519 via BouncyCastleEd25519Signer; © SM2 — the verify blocker is theSM_SOFT_ALLOWEDenv gate onSupports()(notCryptoProviderProductionGuard, which is sign-side only), so SM2 verify must be resolvable in production without enabling software signing; (d) GOST-2012 via a new managedBouncyCastleGostCryptoProvider(ECGost3410Signerfrom SPKI) registered as the default GOST verify provider — the nativeOpenSslGost/Pkcs11Gost/CryptoPro providers stay opt-in signing backends; (e) ECDSA/RSA already work (EcdsaSigner.CreateVerifierFromPublicKey). - eIDAS QES is the largest piece: the CAdES + EU-trusted-list (offline LOTL) + RFC-3161 machinery exists, but production QES of an operator-presented qualified certificate needs a qualified-cert profile + QSCD attestation + LTV chain policy, and an offline qualified-provider pack (regulator/vendor data, not code) — gated by the existing eIDAS qualified-provider-pack decision and a counsel question on legal binding. Treat eIDAS-QES as a follow-on tier. The sealed in-repo eIDAS fixture is acceptable for deterministic engineering proof only; it must not be used as production pack evidence or legal status.
- New persistence: signature columns on the decision tables; enrollment fields on
policy.trusted_keys(forward-only migrations per ADR-004). Thepolicy.trusted_keysverification-view delta (added columns, EF compiled-model touch point, migration location, the string-tenant reconciliation, and the multi-tenant registry variant) is specified in the enrolled-key contract §5 and owned by WS5 (POLICY-OPSIGN-008). - Behaviour change (phased, default-off): when signed-decision enforcement is enabled, an unsigned approval is rejected. Ship behind a per-decision-type enforcement flag so existing flows keep working until each surface has a signing client.
- Client enforcement metadata: approval DTOs expose per-action signing metadata (
required, issuer id, provider hint) so browser and CLI clients can branch before posting. Exception approvals expose the Policy approvalapprovalRequestIdseparately from the affectedexceptionId; clients must sign and submit against the approval request id. - Determinism (canonicalization authority = server):
CanonJson(Utf8JsonWriter escaping) is not RFC-8785/JS-reproducible, so the server computes and returns the exact to-be-signed PAE bytes and the CLI/browser sign those bytes verbatim (no client-side canonicalization). A frozen cross-language conformance fixture guards byte-identity. This is a WS3 contract consumed by WS6 (CLI) and WS7 (browser) — the real long-pole for client signing. - Reuse:
ITrustedKeyRegistry/policy.trusted_keys,ICryptoProviderRegistry/CreateEphemeralVerifier/IAsymmetricSignatureVerifier,TenantAwareCryptoProviderRegistry(profile/preference routing), the Attestor DSSE envelope/serializer, thevex-override@v1pipeline (as the template), and the CLIscan diff --signing-keylocal-key pattern. - Browser limitation is permanent (WebCrypto has no SM2/GOST/QES): regulated regions sign via the CLI or a smartcard/QSCD; the UI offers signing only where WebCrypto can produce the algorithm, and directs other regions to the CLI. The operator-facing enrollment/signing UX, browser algorithm matrix, non-custodial semantics, and sign-server-bytes rule are maintained in the Web architecture operator decision-signing UX section.
- Scope decision — triage dispositions: Findings triage accept/not-affected decisions (
IDecisionService) are out of scope for operator signing in v1 — the existing hash-chained ledger already provides tamper-evidence; WS7’s job there is purely to stop the UI’s “Sign & Apply” label from claiming a signature it does not produce. Revisit if stakeholders require signed triage. (Resolves the audit’s internal-inconsistency finding.) - License gate: cleared. No new managed dependency; BouncyCastle 2.6.2 (MIT) and BCL already in
NOTICE.md/THIRD-PARTY-DEPENDENCIES.md. One doc-sync: the SM row inTHIRD-PARTY-DEPENDENCIES.md§6.2 (TBD/TBD) is corrected to BouncyCastle/MIT. - Air-gap: public-key verification needs no connectivity; the eIDAS trusted-list uses the offline pack path; all crypto is local. No cloud-managed services introduced.
- Release evidence truthfulness: ReleaseOrchestrator no longer emits fabricated
sha256:decision-*orpolicy-decision-*dssestrings for unsigned approvals. Signed release decisions persist the verified compact-envelope digest andkeyid; unsigned replay and evidence surfaces report absent/not-available rather than a synthetic attestation-shaped value.
