Skip to content

Operator Task Signature (rig-cockpit-task-v1)

Tracking: rig-conductor#2104. The Worker (rig-cockpit-worker) is the signer; the Conductor is the verifier: ConductorE.Core.UseCases.OperatorTaskSignature (pure, static, BCL only). Its canonical encoders are internal, so the Conductor exposes no signing API. Both sides pin the same frozen vector file, tests/ConductorE.Core.Tests/Fixtures/task-signature-vectors.json: v1.1, 5 signing + 91 verification cases, SHA-256 2ae33ca9…42ec5397. v1.1 only adds the release route (plan C6, lockstep with Worker W3); every v1 vector is byte-for-byte unchanged. The Conductor tests reproduce every signing vector from structured inputs and assert every verification vector's code and envelope.

Status: wired, no key. The C8b propose and status routes, the C9c release route and the C10b confirm route verify every call with it (Operator Task Routes). No key is provisioned, so they answer 503 not_configured. The key is a dedicated COCKPIT_TASK_SIGNING_KEY (exactly 64 lowercase hex → 32 bytes; anything else is not_configured), never OPERATOR_PUSH_SECRET, the chat token or any other credential. The vector key 000102…1f is public, and TaskSigningKeyConfig refuses it (see Reading a request and the key).

Envelope and field rules

route method path body (canonical JSON, exact key order)
propose POST /api/cockpit/task/propose {"requestId","templateId"}
confirm POST /api/cockpit/task/confirm {"requestId","templateVersion","intentDigest","expectedRevision"}
status GET /api/cockpit/task/status empty (0 bytes)
release (v1.1) POST /api/cockpit/task/release {"requestId","prNumber","headSha"}
  • X-Rig-Task-Timestamp: epoch milliseconds, ASCII ^[1-9][0-9]{0,15}$, at most 2^53−1. Fresh if |nowMs − ts| ≤ 30 000 (both boundaries inclusive); nowMs is an explicit input from 0 to 2^53−1.
  • X-Rig-Task-Context: canonical, unpadded base64url (1–4096 chars) of {"requestId":…,"owner":{"tenantId":"dashecorp","operatorId":…,"conversationId":…}}.
  • X-Rig-Task-Signature: exactly 64 lowercase hex (uppercase is malformed) of HMAC-SHA256 over UTF8("rig-cockpit-task-v1\n" + METHOD + "\n" + PATH + "\n" + TIMESTAMP + "\n" + CONTEXT + "\n") ++ RAW_BODY_BYTES. A missing header is a malformed value of that header.
  • Canonical JSON: no whitespace, no extra or duplicate keys, only " and \ escaped, all other characters literal UTF-8. The body is at most 4096 bytes.
  • requestId: lowercase UUID, not all zeros; the body's must equal the context's. templateId: ^[a-z0-9-]{1,64}$. templateVersion, expectedRevision: positive safe integers in canonical decimal, decoded as long. OperatorTaskConfirmation.TemplateVersion is int, so the wiring adapter must refuse a value above int.MaxValue (or widen that field) and never cast unchecked: (int)4294967297L is 1. intentDigest: 64 lowercase hex.
  • prNumber (release): positive safe integer in canonical decimal, decoded as long. The Conductor's PR numbers are int (MergeRequest.PrNumber, HttpGitHubStateReader), so the release route (plan C9c) refuses a value above int.MaxValue the same way, as 401 invalid_body. headSha (release): exactly 40 lowercase hex; uppercase is invalid_body (vector release-bad-sha), as is any other key order (release-key-order). A verified release body is only a request to release that exact head: the hold SHA, the PR state and the task record decide whether anything merges.
  • operatorId, conversationId: 1–256 UTF-16 code units, well-formed, no U+0000–U+001F or U+007F, and neither end is ECMAScript trim whitespace (U+0009–U+000D, U+0020, U+00A0, U+1680, U+2000–U+200A, U+2028, U+2029, U+202F, U+205F, U+3000, U+FEFF). Same rule as the #2104 OperatorTaskPolicy leaf.

owner.tenantId is the hosting-org scope, not the task's owner tenant

The context's owner.tenantId is the literal dashecorp: the GitHub org that hosts every repo v1 supports, and the only value the signer can produce (TASK_TENANT_ID in the Worker's src/utils/task-signature.js is that constant, and the signer refuses anything else). It is not the tenant that owns the stored task. That one is resolved server-side from the repo (ITenantResolver); today every dashecorp/* repo resolves to tenant-0 invotek, and OperatorTaskPolicy accepts any canonical TenantId there. The status route reports the two separately — "hostingScope":"dashecorp","repoTenant":"invotek" — and the store owner is never taken from the signed context.

So a verified signature proves the caller holds the key and named the hosting scope; it says nothing about which tenant owns the task. The accepted value also cannot be widened on one side: the frozen vector file pins 12 contexts, every one "tenantId":"dashecorp", and its SHA-256 is asserted in both repos, so signer and verifier would have to change together.

Verifier check order (first failure wins; Verify never throws)

  1. key format → not_configured
  2. nowMs is a safe non-negative integer → invalid_clock
  3. method/path allowlisted and query exactly empty → unsupported_route
  4. signature format → invalid_signature
  5. timestamp format → invalid_timestamp
  6. freshness → stale_timestamp
  7. context charset and length (1–4096) → invalid_context
  8. body at most 4096 bytes → invalid_body
  9. constant-time HMAC compare → signature_mismatch
  10. context decode and canonical check → invalid_context
  11. body canonical check and requestId agreement → invalid_body

Content (steps 10–11) is read only after the MAC matched; tests pin this by forging the signature on every content vector. Character classes are ASCII-only and avoid .NET Regex $, which also matches before a trailing newline. Template, digest and revision semantics belong to the #2104 OperatorTaskPolicy leaf, not this codec.

Reading a request and the key (plan C8a)

Two pieces sit between HTTP and Verify. The C8b routes call both: the key is a DI singleton read once at startup.

TaskSigningKeyConfig (Core, tests) reads COCKPIT_TASK_SIGNING_KEY once. The value is used only when it is exactly 64 lowercase hex characters and is not the public vector key (compared in constant time). There is no trim, fallback or fail-open. Every other value is not_configured, and the route answers 503. State says why, for logs only: Missing, Malformed (for example a trailing newline, so write the Secret with printf %s) or PublicFixture. ToString never prints the key.

SignedTaskRequestReader (Api, tests) builds a SignedTaskRequest (Core, tests) from what arrived:

  • Body: read in one pass, up to 4097 bytes. One byte past the limit is enough for the verifier to refuse an oversized body (invalid_body), and the rest is never buffered. The calling endpoint must not bind or read the body itself.
  • Headers: each X-Rig-Task-* header counts only with exactly one value. A repeated header reads as missing, which is refused as malformed.
  • Target: path and query come from IHttpRequestFeature.RawTarget, split at the first ? by the pure SignedTaskRequestTarget.Split. The query keeps its ?, so a bare ? is refused (unsupported_route, vector route-empty-query-marker). Nothing is decoded: Request.Path would read /api/cockpit/task/%70ropose as the propose route, and the wire path is what gets checked. An empty target is refused, never rebuilt. Kestrel always sets RawTarget; TestHost never does, so a WebApplicationFactory test has to set it (a loopback Kestrel test covers the real server).
  • Clock: read through TimeProvider, after the body.

SignedTaskRequest.Verify(config) passes only the configured key to OperatorTaskSignature.Verify, so the public vector key can never verify a request in production. SignedTaskRequest.StatusCodeFor(error) is 503 for not_configured and invalid_clock (the Conductor cannot verify), and 401 for everything else.

Deployment. deploy/k8s/deployment.yaml sets COCKPIT_TASK_SIGNING_KEY from secretKeyRef rig-conductor-task-secrets/cockpit-task-signing-key with optional: true, as RUNPOD_API_KEY does. The pod boots before rig-gitops provisions that Secret (plan G2), and until then the task routes answer 503.

What a valid signature does NOT prove

It proves only that the holder of the task key signed these exact bytes. It is not operator consent, not release approval and not replay protection. A replay inside the window verifies again, so durable request and expected-revision state must decide whether any effect happens. The future adapter must take owner and context from verified operator authentication and persisted records, never from model output or client bodies.