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);nowMsis 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 overUTF8("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 aslong.OperatorTaskConfirmation.TemplateVersionisint, so the wiring adapter must refuse a value aboveint.MaxValue(or widen that field) and never cast unchecked:(int)4294967297Lis 1.intentDigest: 64 lowercase hex.prNumber(release): positive safe integer in canonical decimal, decoded aslong. The Conductor's PR numbers areint(MergeRequest.PrNumber,HttpGitHubStateReader), so the release route (plan C9c) refuses a value aboveint.MaxValuethe same way, as 401invalid_body.headSha(release): exactly 40 lowercase hex; uppercase isinvalid_body(vectorrelease-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 #2104OperatorTaskPolicyleaf.
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)¶
- key format →
not_configured nowMsis a safe non-negative integer →invalid_clock- method/path allowlisted and query exactly empty →
unsupported_route - signature format →
invalid_signature - timestamp format →
invalid_timestamp - freshness →
stale_timestamp - context charset and length (1–4096) →
invalid_context - body at most 4096 bytes →
invalid_body - constant-time HMAC compare →
signature_mismatch - context decode and canonical check →
invalid_context - body canonical check and
requestIdagreement →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 pureSignedTaskRequestTarget.Split. The query keeps its?, so a bare?is refused (unsupported_route, vectorroute-empty-query-marker). Nothing is decoded:Request.Pathwould read/api/cockpit/task/%70roposeas the propose route, and the wire path is what gets checked. An empty target is refused, never rebuilt. Kestrel always setsRawTarget; 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.