carlos / platform Public

Add typed signed maintenance worker control records

Paul Campbell pushed by paul@keymail.dev 1ad717b2c305ef673bc9e9bb161f0a9c60c6dbff parent 124f146
4 files changed, +435 −15
  • implementation/maintenance-worker-identity/proposal.json +20 −12
  • internal/release/systempointer.go +1 −3
  • internal/release/workercontrol.go +173 −0
  • internal/release/workercontrol_test.go +241 −0

CI passed — run details

diff --git a/implementation/maintenance-worker-identity/proposal.json b/implementation/maintenance-worker-identity/proposal.json
index 80b3ad5bc..17709da75 100644
--- a/implementation/maintenance-worker-identity/proposal.json
+++ b/implementation/maintenance-worker-identity/proposal.json
@@ -1,13 +1,13 @@
{
- "status": "Revision2 proposed; supersedes rejected38326fa7. No runtime worker or live credential.",
+ "status": "Revision3 proposed; incorporates Fable revision2 REQUEST_CHANGES. Codec implementation in progress; no runtime worker, live credential, or deployed IAM boundary.",
"parents": "Maintenance protocol5f18cdd / platform117; Fable investigations worker-identity-design and worker-identity-design-r2 are proposals, not approvals",
"scope": "Initial shared/root platform boxes only, exactly one eligible enrolled box for the target region/pool. Customer fleet targets and ambiguous multi-box placement refuse. Do not distribute deployment decryption keys to customer machines.",
- "isolation_prerequisite": "Do not create/persist/activate worker credential or accept private maintenance input on a box with unisolated tenant processes. Existing isolated-exec design revision13 (platform126) owns tenant mount/PrivatePIDs/IMDS/cgroup isolation and must supply observed runtime/layout checks. File0600 owned by carlos cannot isolate secrets from current flat same-UID children. No competing isolation implementation or unchecked feature boolean.",
+ "isolation_prerequisite": "Require observed isolated-exec probe ready, box layout_mode nested, and every activator-managed route nested. Foreign/external routes refuse until their supervisor identity and isolation are proven. Apply this gate to both worker and console hosts: flat same-UID tenants can also read console signing/decryption material. Re-evaluate before each bearer use or private-input decryption; an unavailable observation or layout downgrade holds execution. Platform126 owns these runtime/layout checks; no competing implementation or unchecked feature boolean.",
"identity": "Dedicated maintenance workers, not customer fleets. Reuse canonical per-box token-hash/label conventions only; never put worker credentials in fleet_boxes or human/service auth. Therefore existing fleet tunnel/store/placement doors cannot confer worker authority and need no platform-kind extension. Customer fleets remain unsupported.",
- "authoritative_storage": "Deployment store only for worker admission/revocation and publication state; SQLite never supplies authority or monotonic generation. Existing trusted control-store boundary applies: root signatures authenticate writers but do not by themselves prevent replay of old signed objects by a privileged store administrator. Do not claim anti-rollback against that stronger attacker. A stolen old bearer plus privileged ability to roll back all control objects is NOT covered; this requires an independent control-store write boundary. Review must distinguish external token revocation from compromise of platform-wide IAM.",
+ "authoritative_storage": "Deployment store only; SQLite never supplies authority or monotonic generation. Admission remains disabled until an actual IAM boundary prevents worker credentials from overwriting/deleting authoritative control records. Non-console host roles require explicit Deny PutObject/DeleteObject for control/maintenance/workers/* and control/maintenance/assignments/*; candidate writes use separate control/maintenance/candidates/*. Console writer identity must be distinct from every admitted worker instance profile; do not admit a worker on the console host while they share write credentials. Root signatures authenticate records but cannot prevent rollback/ETag ABA by an authorized store writer. Verify old signed-record replay fails using the deployed worker principal. Broader guard-write permissions under control/maintenance/hosts require review before live activation; no claim of revocation safety from signatures alone.",
"head": {
"key": "control/maintenance/workers/<logical-label>/head.json",
- "wire": "Closed bounded root-signed control record using existing release signed-wrapper/signature mechanics, disjoint required kind/schema. Contains deployment origin, ops account, logical label, physical box identity digest, generation, token hash, status(active/revoking/detached), placement, candidate identity/digest, audit metadata, and at most8 registered immutable assignment references.",
+ "wire": "Closed bounded root-signed control record using existing release signed-wrapper/signature mechanics, disjoint required kind/schema. Contains deployment origin, ops account, logical label, physical box identity digest, generation, revision, token hash, status(active/revoking/detached), placement, candidate identity/digest, and at most8 registered immutable assignment references. New internal v1 encoding requires exact typed canonical field order/escaping and explicit required fields; insignificant whitespace is accepted. No change to existing release readers.",
"updates": "Create-only first admission. Every update uses exact head ETag CAS. Generation advances only on explicit owner re-admission and refuses overflow. Assignment registration CAS requires active head and matching generation. Revoking/detached head cannot register assignments or authenticate new requests.",
"label": "OwnerBox is stable logical box label, preserving guard rules. Inventory-friendly labels collide in status.Label; candidate also binds physical box identity digest. Initial collision refuses; physical replacement requires explicit owner re-admit selecting current head and expected old identity, new candidate and generation. Never silently reinterpret a label.",
"owner": "Every admit/detach/read administrative door requires current human ops owner through actual apiSystemOwner. Human/service CLI tokens cannot authenticate worker publication. Authenticated worker endpoint derives deployment/box/generation from verified head and bearer, never body strings."
@@ -16,19 +16,19 @@
"key": "control/maintenance/assignments/<assignment-id>/state.json",
"purpose": "One authoritative typed server-internal CAS record per immutable assignment, covering preflight, compatibility intermediate, execution outcome and resume publication. Their accepted references live in this record so each publication and revocation contend on THE SAME object, independent of console count.",
"wire": "Bounded closed root-signed server-internal control record with immutable assignment digest/scope/enrollment generation, monotonic revision, disabled flag, and fixed optional accepted preflight/compatibility/execution/resume references. Require console root signature before accepting any state/ref. Type validators enforce approved protocol per-kind field/phase rules. No generic object key/write/signing API and no offline worker receipt format; CLI continues using authenticated server responses and opaque references.",
- "create": "Create immutable assignment and initial state, then CAS active worker head to register its exact reference. Until registered, state grants no execution/publication authority. Failed registration leaves inert orphan. Immutable assignment binds record/account/app/host/request/attempt/placement/worker generation; owner approval later binds exact preflight and assignment.",
- "publish": "Authenticate current head and registered assignment; read enabled assignment-state snapshot. Recompute/verify approved request/record/artifact/environment/input/recipient/worker-evidence bindings, persist encrypted private payload and immutable receipt create-only. CAS accepted reference into the SAME enabled assignment state using its ETag. This CAS is the acceptance point. CAS conflict requires fresh validation; never retry against a disabled snapshot. Identical publication-ID retry is idempotent; changed bytes conflict. Accepted result does not grant serving resume.",
- "projections": "Guard advances and public status/result projections happen only from accepted authoritative assignment-state refs. A projection alone is never accepted provenance. Missing/delayed projection is unavailable or pending until deterministic reconciliation. Current request/attempt checks prevent old accepted refs from entering a newer operation. Compatibility intermediate and final resume refs remain distinct as approved protocol requires; neither can be replaced with a terminal guard CAS alone.",
- "close": "Removing a reference from head.open requires assignment state already disabled/closed, preserving accepted refs. A stale in-flight publisher therefore cannot publish after removal. Maximum8 open assignments bounds revocation work."
+ "create": "Create immutable assignment and initial state, then CAS active worker head to register its exact reference. Until registered, state grants no execution/publication authority. Failed registration leaves inert orphan. Immutable assignment binds record/account/app/host/request/attempt/placement/worker generation; owner approval later binds exact preflight and assignment. Assignment IDs must derive deterministically from the immutable request/attempt or be durably retained before registration, so retries cannot consume new open slots. Immediately before owner approval lineage CAS/guard creation, re-read enabled assignment state and active matching registered head. A subsequent revocation race keeps the guard denied and is reconciled; approval never bypasses the disabled state.",
+ "publish": "Authenticate current head and registered assignment; read enabled assignment-state snapshot. Recompute/verify approved request/record/artifact/environment/input/recipient/worker-evidence bindings, persist encrypted private payload and immutable receipt create-only. CAS accepted reference into the SAME enabled assignment state using its ETag. This CAS is the acceptance point. CAS conflict requires fresh validation; never retry against a disabled snapshot. Identical publication-ID retry is idempotent; changed bytes conflict. Accepted result does not grant serving resume. Every change increments revision with checked overflow. An identical accepted reference is an idempotent read only; it must not write the same bytes or perform a no-op CAS. No accepted slot may be replaced.",
+ "projections": "Guard advances and public status/result projections happen only from accepted authoritative assignment-state refs. A projection alone is never accepted provenance. Missing/delayed projection is unavailable or pending until deterministic reconciliation. Current request/attempt checks prevent old accepted refs from entering a newer operation. Compatibility intermediate and final resume refs remain distinct as approved protocol requires; neither can be replaced with a terminal guard CAS alone. Status and result doors read the verified signed assignment state directly; guard projections cannot invent or erase accepted outcomes.",
+ "close": "An explicit idempotent reconciliation loop closes terminal/refused assignments, expired five-minute preflight assignments, and assignments whose 24-hour private payload has expired. First CAS state to disabled preserving all accepted references, reconcile any accepted terminal outcome, then CAS remove its exact reference from head.open. Never remove first. Re-read on every conflict, retaining other concurrent registrations. Reclaim all8 slots without losing crash recovery. Accepted resume requires the full existing resume evidence/lease conditions, not just a stored reference."
},
"revocation": [
"CAS head active->revoking, retaining exact open references and generation; this closes new registration/authentication. This starts revocation but does NOT claim all in-flight publication is yet cancelled.",
"For each registered assignment, CAS its authoritative state to disabled while preserving accepted refs. Publication and disable CAS share the same object: whichever wins is ordered first. A publisher authenticated before head revoking can still win until that assignment disable CAS; this is an in-flight accepted historical result, never a claim that head change immediately stopped it.",
- "Keep matching instance guard denied. Where matching guard is Requested/Quiescing/Fenced/Executing/ResumeRequested, advance to Failed/Ambiguous through actual allowed edges, preserving identities/pins. Do not pretend guard change proves process stop or releases lease. Terminal accepted results remain historical. Stale/absent guard is not authorization to construct or alter another attempt; assignment disabled remains authoritative denial and reconciliation closes matching later-created guard.",
+ "After disabling an assignment, re-read its signed authoritative state. If an execution acceptance won before disable, reconcile the matching guard to that actual accepted terminal outcome and evidence, even if the guard projection was delayed. Do not label that accepted outcome Ambiguous merely because revocation reached the guard first. If no accepted terminal result exists, preserve denial and use only the approved failure/ambiguous edges. Accepted resume must meet full existing resume conditions. Stale/absent guards cannot authorize changing another attempt; reconciliation handles a matching guard created after disable. No guard transition proves process stop or releases a lease.",
"Worker coordinator continuously checks assignment/enrollment validity and cancels active supervisor on revocation, uses checked tree termination and retains fence on uncertainty. Fresh owner recovery/resume still requires proven stop, retained lease and checkpoint/checker path; no automatic un-fence or mutation retry.",
"Only after all registered assignment states are disabled and matching deny projections reconciled, CAS head revoking->detached and clear open refs. Before then status remains revoking. Any console can idempotently resume a crashed revocation from retained head/open refs, without a process-local lock or second-console assumption."
],
- "bootstrap": "After observed isolation gate, worker durably creates once a random32-byte token in privatefile.CreateExclusiveDurable storage with protected parent, checked fsync and no replacement on restart. Candidate contains only hash, random candidate ID, physical identity/label and placement; publish through supported agent control-store path. Signed console-origin discovery uses reviewed existing root release signing wrapper, explicit kind, canonical HTTPS origin and persisted monotonic discovery watermark. Ops-owner workers admit selects exact candidate via supported CLI/API; no operator SSH/SSM step. Conflicting candidate/label refuses. Explicit detach/re-admit controls rotation; no self-reactivation.",
+ "bootstrap": "After observed isolation gate, worker durably creates once a random32-byte token in privatefile.CreateExclusiveDurable storage with protected parent, checked fsync and no replacement on restart. Candidate contains only hash, random candidate ID, physical identity/label and placement; publish through supported agent control-store path. Signed console-origin discovery uses reviewed existing root release signing wrapper, explicit kind, canonical HTTPS origin and persisted monotonic discovery watermark. Ops-owner workers admit selects exact candidate via supported CLI/API; no operator SSH/SSM step. Conflicting candidate/label refuses. Explicit detach/re-admit controls rotation; no self-reactivation. Candidate publication uses only the isolated candidate prefix, never worker head/assignment write credentials. Root-keyless consoles refuse all mutation doors.",
"assignment_and_execution": {
"owner_box": "Use stable logical box label (existing valid 1-32-character name), not fleet/label or fleet name. Existing guard continuity keeps OwnerBox fixed; replacing physical hardware under the same logical label requires explicit owner re-enrollment and a new credential generation. Changing logical label for an existing guard is unsupported until a reviewed guard migration.",
"immutable": "Server creates assignment bound to deployment origin, dedicated worker kind/ops account, logical box label, enrollment generation and token-hash identity, app/account/host, request/attempt, record identity/revision and exact placement observation. Request body copies do not select principal or assignment.",
@@ -46,7 +46,15 @@
"wrong generation/box/attempt/recipient publication denied",
"signed control type confusion rejected",
"flat-layout/host-PID-visible tenant denies worker credential activation",
- "candidate physical identity collision and explicit replacement rules"
+ "candidate physical identity collision and explicit replacement rules",
+ "accepted terminal outcome survives revocation during projection lag",
+ "terminal/refused/expired reconciliation reclaims all8 open slots across crashes",
+ "registration retry retains one deterministic assignment identity",
+ "deployed worker IAM rejects replay of old signed head and enabled assignment",
+ "foreign/external route and console flat layout deny admission and decryption",
+ "layout revert refused with active/revoking head or retained worker token",
+ "revision overflow and identical-reference retry perform no state rewrite"
],
- "review_request": "Assess distributed revocation/acceptance invariant and exact fit to approved opaque-reference protocol. In particular publication accept-before-disable stays historical even if projection finishes later; no claim that signed head alone prevents privileged-store rollback. Recommend concrete corrections before implementation."
+ "review_request": "Review revision3 corrections and the typed signed-record codec. Runtime admission remains gated on deployed IAM and platform126 isolation. No merge/deploy claim until full Fable approval and exact gates.",
+ "revert_interlock": "Platform126 integration must refuse layout revert for a box with active/revoking worker head. The box-side revert pass also refuses while a durable worker token exists. Console layout downgrade is refused while console worker-control signing/private-input capability is enabled. These are required admission/rollout interlocks, not optional diagnostics; old binaries unable to enforce them are ineligible."
}
diff --git a/internal/release/systempointer.go b/internal/release/systempointer.go
index fe3eb19dc..95d44314e 100644
--- a/internal/release/systempointer.go
+++ b/internal/release/systempointer.go
@@ -169,9 +169,7 @@ func signSystemPointer(sg Signer, p *SystemPointer) ([]byte, error) {
if err != nil {
return nil, err
}
- b64 := base64.StdEncoding.EncodeToString(payload)
- sig := ed25519.Sign(sg.key, []byte(b64))
- return json.MarshalIndent(signedWrapper{Payload: b64, Sig: base64.StdEncoding.EncodeToString(sig)}, "", " ")
+ return signRootPayload(sg, payload)
}
// verifySystemPointer is the single parse site for system pointers, and so
diff --git a/internal/release/workercontrol.go b/internal/release/workercontrol.go
new file mode 100644
index 000000000..f0e6e76d8
--- /dev/null
+++ b/internal/release/workercontrol.go
@@ -0,0 +1,173 @@
+package release
+
+import (
+ "bytes"
+ "crypto/ed25519"
+ "encoding/base64"
+ "encoding/json"
+ "errors"
+ "net/url"
+
+ "github.com/carlosframework/platform/internal/controlname"
+ "github.com/carlosframework/platform/internal/maintenanceguard"
+)
+
+const MaxWorkerControlBytes = 32 << 10
+
+var ErrWorkerControl = errors.New("worker control record refused")
+
+type WorkerHead struct {
+ Schema int `json:"schema"`
+ Kind string `json:"kind"`
+ Deployment string `json:"deployment"`
+ Account string `json:"account"`
+ Box string `json:"box"`
+ PhysicalIdentity string `json:"physical_identity"`
+ Generation int64 `json:"generation"`
+ Revision int64 `json:"revision"`
+ TokenHash string `json:"token_hash"`
+ Status string `json:"status"`
+ Placement string `json:"placement"`
+ Candidate maintenanceguard.Reference `json:"candidate"`
+ Open []maintenanceguard.Reference `json:"open"`
+}
+
+type WorkerAssignmentIdentity struct {
+ Assignment maintenanceguard.Reference `json:"assignment"`
+ Deployment string `json:"deployment"`
+ Box string `json:"box"`
+ Generation int64 `json:"generation"`
+ Account string `json:"account"`
+ App string `json:"app"`
+ Host string `json:"host"`
+ Operation string `json:"operation"`
+ Request string `json:"request"`
+ Attempt string `json:"attempt"`
+}
+
+type WorkerAssignmentState struct {
+ Schema int `json:"schema"`
+ Kind string `json:"kind"`
+ Identity WorkerAssignmentIdentity `json:"identity"`
+ Revision int64 `json:"revision"`
+ Disabled bool `json:"disabled"`
+ Expires int64 `json:"expires_at"`
+ Preflight *maintenanceguard.Reference `json:"preflight,omitempty"`
+ Compatibility *maintenanceguard.Reference `json:"compatibility,omitempty"`
+ Execution *maintenanceguard.Reference `json:"execution,omitempty"`
+ Resume *maintenanceguard.Reference `json:"resume,omitempty"`
+}
+
+func workerOrigin(s string) bool {
+ u, e := url.Parse(s)
+ return e == nil && u.Scheme == "https" && u.User == nil && controlname.ValidHostShape(u.Host) && s == "https://"+u.Host
+}
+func workerRef(r maintenanceguard.Reference) bool {
+ return maintenanceguard.ValidID(r.ID) && maintenanceguard.ValidDigest(r.Digest)
+}
+func (h WorkerHead) Valid() bool {
+ if h.Schema != 1 || h.Kind != "maintenance-worker" || !workerOrigin(h.Deployment) || !controlname.ValidAccount(h.Account) || !controlname.ValidApp(h.Box) || !maintenanceguard.ValidDigest(h.PhysicalIdentity) || h.Generation <= 0 || h.Revision <= 0 || !maintenanceguard.ValidDigest(h.TokenHash) || (h.Status != "active" && h.Status != "revoking" && h.Status != "detached") || (h.Placement != "" && !controlname.ValidApp(h.Placement)) || !workerRef(h.Candidate) || h.Open == nil || len(h.Open) > 8 || (h.Status == "detached" && len(h.Open) != 0) {
+ return false
+ }
+ seen := map[string]bool{}
+ for _, ref := range h.Open {
+ if !workerRef(ref) || seen[ref.ID] {
+ return false
+ }
+ seen[ref.ID] = true
+ }
+ return true
+}
+func (i WorkerAssignmentIdentity) Valid() bool {
+ return workerRef(i.Assignment) && workerOrigin(i.Deployment) && controlname.ValidApp(i.Box) && i.Generation > 0 && controlname.ValidAccount(i.Account) && controlname.ValidApp(i.App) && controlname.ValidHostShape(i.Host) && maintenanceguard.ValidID(i.Operation) && maintenanceguard.ValidID(i.Request) && maintenanceguard.ValidID(i.Attempt)
+}
+func (s WorkerAssignmentState) Valid() bool {
+ if s.Schema != 1 || s.Kind != "maintenance-assignment" || !s.Identity.Valid() || s.Revision <= 0 || s.Expires <= 0 {
+ return false
+ }
+ for _, r := range []*maintenanceguard.Reference{s.Preflight, s.Compatibility, s.Execution, s.Resume} {
+ if r != nil && !workerRef(*r) {
+ return false
+ }
+ }
+ return true
+}
+
+// Canonical encoding is part of this new internal wire version. Comparing typed
+// encoding rejects aliases, duplicate/unknown fields and missing false fields.
+func decodeWorkerCanonical(raw []byte, dst any) error {
+ if len(raw) == 0 || len(raw) > MaxWorkerControlBytes {
+ return ErrWorkerControl
+ }
+ if json.Unmarshal(raw, dst) != nil {
+ return ErrWorkerControl
+ }
+ canonical, err := json.Marshal(dst)
+ if err != nil {
+ return ErrWorkerControl
+ }
+ var compact bytes.Buffer
+ if json.Compact(&compact, raw) != nil || !bytes.Equal(compact.Bytes(), canonical) {
+ return ErrWorkerControl
+ }
+ return nil
+}
+func signRootPayload(sg Signer, payload []byte) ([]byte, error) {
+ if sg.usable() != nil || sg.scope != nil || sg.grant != nil {
+ return nil, ErrWorkerControl
+ }
+ b64 := base64.StdEncoding.EncodeToString(payload)
+ signature := ed25519.Sign(sg.key, []byte(b64))
+ return json.MarshalIndent(signedWrapper{Payload: b64, Sig: base64.StdEncoding.EncodeToString(signature)}, "", " ")
+}
+func verifyWorkerPayload(pub ed25519.PublicKey, wire []byte) ([]byte, error) {
+ var wrapper signedWrapper
+ if decodeWorkerCanonical(wire, &wrapper) != nil || wrapper.Grant != nil || len(pub) != ed25519.PublicKeySize {
+ return nil, ErrWorkerControl
+ }
+ signature, err := base64.StdEncoding.Strict().DecodeString(wrapper.Sig)
+ if err != nil || base64.StdEncoding.EncodeToString(signature) != wrapper.Sig || len(signature) != ed25519.SignatureSize || !ed25519.Verify(pub, []byte(wrapper.Payload), signature) {
+ return nil, ErrWorkerControl
+ }
+ raw, err := base64.StdEncoding.Strict().DecodeString(wrapper.Payload)
+ if err != nil || len(raw) > MaxWorkerControlBytes/2 || base64.StdEncoding.EncodeToString(raw) != wrapper.Payload {
+ return nil, ErrWorkerControl
+ }
+ return raw, nil
+}
+func SignWorkerHead(sg Signer, h WorkerHead) ([]byte, error) {
+ if !h.Valid() {
+ return nil, ErrWorkerControl
+ }
+ raw, err := json.Marshal(h)
+ if err != nil {
+ return nil, ErrWorkerControl
+ }
+ return signRootPayload(sg, raw)
+}
+func VerifyWorkerHead(pub ed25519.PublicKey, wire []byte) (WorkerHead, error) {
+ var h WorkerHead
+ raw, err := verifyWorkerPayload(pub, wire)
+ if err != nil || decodeWorkerCanonical(raw, &h) != nil || !h.Valid() {
+ return WorkerHead{}, ErrWorkerControl
+ }
+ return h, nil
+}
+func SignWorkerAssignment(sg Signer, s WorkerAssignmentState) ([]byte, error) {
+ if !s.Valid() {
+ return nil, ErrWorkerControl
+ }
+ raw, err := json.Marshal(s)
+ if err != nil {
+ return nil, ErrWorkerControl
+ }
+ return signRootPayload(sg, raw)
+}
+func VerifyWorkerAssignment(pub ed25519.PublicKey, wire []byte) (WorkerAssignmentState, error) {
+ var s WorkerAssignmentState
+ raw, err := verifyWorkerPayload(pub, wire)
+ if err != nil || decodeWorkerCanonical(raw, &s) != nil || !s.Valid() {
+ return WorkerAssignmentState{}, ErrWorkerControl
+ }
+ return s, nil
+}
diff --git a/internal/release/workercontrol_test.go b/internal/release/workercontrol_test.go
new file mode 100644
index 000000000..bbf962e29
--- /dev/null
+++ b/internal/release/workercontrol_test.go
@@ -0,0 +1,241 @@
+package release
+
+import (
+ "bytes"
+ "encoding/base64"
+ "encoding/json"
+ "errors"
+ "math"
+ "reflect"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/maintenanceguard"
+)
+
+func workerTestRef(b byte) maintenanceguard.Reference {
+ return maintenanceguard.Reference{ID: base64.RawURLEncoding.EncodeToString(bytes.Repeat([]byte{b}, 32)), Digest: strings.Repeat("ab", 32)}
+}
+func workerTestHead() WorkerHead {
+ return WorkerHead{Schema: 1, Kind: "maintenance-worker", Deployment: "https://console.example.com", Account: "bds", Box: "annie-welding", PhysicalIdentity: strings.Repeat("cd", 32), Generation: 1, Revision: 1, TokenHash: strings.Repeat("ef", 32), Status: "active", Placement: "eu", Candidate: workerTestRef(1), Open: []maintenanceguard.Reference{workerTestRef(2)}}
+}
+func workerTestAssignment() WorkerAssignmentState {
+ return WorkerAssignmentState{Schema: 1, Kind: "maintenance-assignment", Identity: WorkerAssignmentIdentity{Assignment: workerTestRef(2), Deployment: "https://console.example.com", Box: "annie-welding", Generation: 1, Account: "bds", App: "home", Host: "home.example.com", Operation: workerTestRef(3).ID, Request: workerTestRef(4).ID, Attempt: workerTestRef(5).ID}, Revision: 1, Expires: 2000000000}
+}
+func TestWorkerControlRoundTripAndTypeSeparation(t *testing.T) {
+ priv, pub := testKeys(t)
+ head := workerTestHead()
+ head.Generation = math.MaxInt64
+ head.Revision = math.MaxInt64
+ wire, err := SignWorkerHead(RootSigner(priv), head)
+ if err != nil {
+ t.Fatal(err)
+ }
+ got, err := VerifyWorkerHead(pub, wire)
+ if err != nil || !reflect.DeepEqual(got, head) {
+ t.Fatalf("head round trip: %v", err)
+ }
+ if _, err = VerifyWorkerAssignment(pub, wire); !errors.Is(err, ErrWorkerControl) {
+ t.Fatal("head accepted as assignment")
+ }
+ state := workerTestAssignment()
+ refs := []*maintenanceguard.Reference{&head.Candidate, &head.Open[0]}
+ state.Preflight = refs[0]
+ state.Execution = refs[1]
+ state.Disabled = true
+ wire, err = SignWorkerAssignment(RootSigner(priv), state)
+ if err != nil {
+ t.Fatal(err)
+ }
+ gotState, err := VerifyWorkerAssignment(pub, wire)
+ if err != nil || !reflect.DeepEqual(gotState, state) {
+ t.Fatalf("assignment round trip: %v", err)
+ }
+ if _, err = VerifyWorkerHead(pub, wire); !errors.Is(err, ErrWorkerControl) {
+ t.Fatal("assignment accepted as head")
+ }
+ _, otherPub := testKeys(t)
+ if _, err = VerifyWorkerAssignment(otherPub, wire); !errors.Is(err, ErrWorkerControl) {
+ t.Fatal("wrong root accepted")
+ }
+ if _, err = VerifyWorkerAssignment(nil, wire); !errors.Is(err, ErrWorkerControl) {
+ t.Fatal("missing root accepted")
+ }
+ wire[len(wire)/2] ^= 1
+ if _, err = VerifyWorkerAssignment(pub, wire); !errors.Is(err, ErrWorkerControl) {
+ t.Fatal("tampered wire accepted")
+ }
+}
+func TestWorkerControlRefusesNonRootSigner(t *testing.T) {
+ _, _, scoped := scopedFixture(t, []string{"canary"}, time.Hour)
+ for _, sg := range []Signer{{}, scoped} {
+ if _, err := SignWorkerHead(sg, workerTestHead()); !errors.Is(err, ErrWorkerControl) {
+ t.Fatal("head signed without root")
+ }
+ if _, err := SignWorkerAssignment(sg, workerTestAssignment()); !errors.Is(err, ErrWorkerControl) {
+ t.Fatal("assignment signed without root")
+ }
+ }
+}
+func TestWorkerControlRefusesSignedMalformedPayload(t *testing.T) {
+ priv, pub := testKeys(t)
+ headRaw, _ := json.Marshal(workerTestHead())
+ stateRaw, _ := json.Marshal(workerTestAssignment())
+ for name, mutate := range map[string]func(string) string{
+ "duplicate": func(s string) string { return strings.Replace(s, `"schema":1`, `"schema":1,"schema":1`, 1) },
+ "alias": func(s string) string { return strings.Replace(s, `"schema":1`, `"Schema":1`, 1) },
+ "unknown": func(s string) string { return strings.Replace(s, `"schema":1`, `"schema":1,"extension":true`, 1) },
+ "float": func(s string) string { return strings.Replace(s, `"schema":1`, `"schema":1.0`, 1) },
+ "overflow": func(s string) string { return strings.Replace(s, `"revision":1`, `"revision":9223372036854775808`, 1) },
+ "trailing": func(s string) string { return s + ` {}` },
+ "escaped-alias": func(s string) string { return strings.Replace(s, `"schema":1`, `"\u0073chema":1`, 1) },
+ "invalid-utf8": func(s string) string { return strings.Replace(s, "console", string([]byte{255}), 1) },
+ "surrogate": func(s string) string { return strings.Replace(s, "console", `\ud800`, 1) },
+ } {
+ t.Run(name, func(t *testing.T) {
+ for _, raw := range [][]byte{headRaw, stateRaw} {
+ wire, err := signRootPayload(RootSigner(priv), []byte(mutate(string(raw))))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err = VerifyWorkerHead(pub, wire); !errors.Is(err, ErrWorkerControl) {
+ t.Fatal("malformed head accepted")
+ }
+ if _, err = VerifyWorkerAssignment(pub, wire); !errors.Is(err, ErrWorkerControl) {
+ t.Fatal("malformed assignment accepted")
+ }
+ }
+ })
+ }
+ for _, raw := range []string{
+ strings.Replace(string(stateRaw), `"disabled":false,`, "", 1),
+ strings.TrimSuffix(string(stateRaw), "}") + `,"execution":null}`,
+ strings.Replace(string(headRaw), `"open":[`+mustWorkerJSON(t, workerTestRef(2))+`]`, `"open":null`, 1),
+ } {
+ wire, err := signRootPayload(RootSigner(priv), []byte(raw))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err = VerifyWorkerHead(pub, wire); !errors.Is(err, ErrWorkerControl) {
+ t.Fatal("missing/null field accepted as head")
+ }
+ if _, err = VerifyWorkerAssignment(pub, wire); !errors.Is(err, ErrWorkerControl) {
+ t.Fatal("missing/null field accepted as assignment")
+ }
+ }
+}
+func mustWorkerJSON(t *testing.T, v any) string {
+ t.Helper()
+ raw, err := json.Marshal(v)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return string(raw)
+}
+func TestWorkerControlRefusesInvalidHeadScope(t *testing.T) {
+ priv, pub := testKeys(t)
+ for name, change := range map[string]func(*WorkerHead){
+ "http": func(h *WorkerHead) { h.Deployment = "http://console.example.com" },
+ "path": func(h *WorkerHead) { h.Deployment += "/" },
+ "user": func(h *WorkerHead) { h.Deployment = "https://owner@console.example.com" },
+ "box-path": func(h *WorkerHead) { h.Box = "../other" },
+ "zero-generation": func(h *WorkerHead) { h.Generation = 0 },
+ "zero-revision": func(h *WorkerHead) { h.Revision = 0 },
+ "token": func(h *WorkerHead) { h.TokenHash = "bad" },
+ "physical-identity": func(h *WorkerHead) { h.PhysicalIdentity = "bad" },
+ "unknown-state": func(h *WorkerHead) { h.Status = "enabled" },
+ "duplicate-assignment": func(h *WorkerHead) { h.Open = append(h.Open, h.Open[0]) },
+ "overflow-open": func(h *WorkerHead) {
+ for i := byte(3); i < 11; i++ {
+ h.Open = append(h.Open, workerTestRef(i))
+ }
+ },
+ "detached-open": func(h *WorkerHead) { h.Status = "detached" },
+ "null-open": func(h *WorkerHead) { h.Open = nil },
+ } {
+ t.Run(name, func(t *testing.T) {
+ h := workerTestHead()
+ change(&h)
+ if _, err := SignWorkerHead(RootSigner(priv), h); !errors.Is(err, ErrWorkerControl) {
+ t.Fatal("invalid head signed")
+ }
+ wire, err := signRootPayload(RootSigner(priv), []byte(mustWorkerJSON(t, h)))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err = VerifyWorkerHead(pub, wire); !errors.Is(err, ErrWorkerControl) {
+ t.Fatal("invalid signed head accepted")
+ }
+ })
+ }
+}
+func TestWorkerControlRejectsWrapperAmbiguityAndOversize(t *testing.T) {
+ priv, pub := testKeys(t)
+ wire, err := SignWorkerHead(RootSigner(priv), workerTestHead())
+ if err != nil {
+ t.Fatal(err)
+ }
+ var compact bytes.Buffer
+ if err = json.Compact(&compact, wire); err != nil {
+ t.Fatal(err)
+ }
+ s := compact.String()
+ for _, raw := range []string{
+ strings.Replace(s, `"payload":`, `"Payload":`, 1),
+ strings.TrimSuffix(s, "}") + `,"grant":null}`,
+ strings.TrimSuffix(s, "}") + `,"extra":0}`,
+ s + strings.Repeat(" ", MaxWorkerControlBytes),
+ `null`,
+ } {
+ if _, err := VerifyWorkerHead(pub, []byte(raw)); !errors.Is(err, ErrWorkerControl) {
+ t.Fatal("malformed wrapper accepted")
+ }
+ }
+ big, err := signRootPayload(RootSigner(priv), bytes.Repeat([]byte(" "), MaxWorkerControlBytes))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err = VerifyWorkerHead(pub, big); !errors.Is(err, ErrWorkerControl) {
+ t.Fatal("oversize payload accepted")
+ }
+}
+
+func TestWorkerControlRefusesInvalidAssignmentScope(t *testing.T) {
+ priv, pub := testKeys(t)
+ for name, change := range map[string]func(*WorkerAssignmentState){
+ "kind": func(s *WorkerAssignmentState) { s.Kind = "maintenance-worker" },
+ "schema": func(s *WorkerAssignmentState) { s.Schema = 2 },
+ "assignment-digest": func(s *WorkerAssignmentState) { s.Identity.Assignment.Digest = "bad" },
+ "deployment": func(s *WorkerAssignmentState) { s.Identity.Deployment = "https://console.example.com/#fragment" },
+ "box": func(s *WorkerAssignmentState) { s.Identity.Box = "../other" },
+ "generation": func(s *WorkerAssignmentState) { s.Identity.Generation = 0 },
+ "account": func(s *WorkerAssignmentState) { s.Identity.Account = "../bds" },
+ "app": func(s *WorkerAssignmentState) { s.Identity.App = "../home" },
+ "host": func(s *WorkerAssignmentState) { s.Identity.Host = "https://home.example.com" },
+ "operation": func(s *WorkerAssignmentState) { s.Identity.Operation = "bad" },
+ "request": func(s *WorkerAssignmentState) { s.Identity.Request = "bad" },
+ "attempt": func(s *WorkerAssignmentState) { s.Identity.Attempt = "bad" },
+ "revision": func(s *WorkerAssignmentState) { s.Revision = 0 },
+ "expiry": func(s *WorkerAssignmentState) { s.Expires = 0 },
+ "preflight": func(s *WorkerAssignmentState) { s.Preflight = &maintenanceguard.Reference{} },
+ "compatibility": func(s *WorkerAssignmentState) { s.Compatibility = &maintenanceguard.Reference{} },
+ "execution": func(s *WorkerAssignmentState) { s.Execution = &maintenanceguard.Reference{} },
+ "resume": func(s *WorkerAssignmentState) { s.Resume = &maintenanceguard.Reference{} },
+ } {
+ t.Run(name, func(t *testing.T) {
+ state := workerTestAssignment()
+ change(&state)
+ if _, err := SignWorkerAssignment(RootSigner(priv), state); !errors.Is(err, ErrWorkerControl) {
+ t.Fatal("invalid assignment signed")
+ }
+ wire, err := signRootPayload(RootSigner(priv), []byte(mustWorkerJSON(t, state)))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err = VerifyWorkerAssignment(pub, wire); !errors.Is(err, ErrWorkerControl) {
+ t.Fatal("invalid signed assignment accepted")
+ }
+ })
+ }
+}