hostquarantine, awsinv, hostfence: quarantine interface, retirement receipt and exact compute observation (claim Plan 8 Task 2)
Co-Authored-By: Codex <noreply@openai.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
6 files changed,
+892
−0
internal/awsinv/exact_instance.go+96 −0internal/awsinv/exact_instance_test.go+103 −0internal/hostfence/challenge.go+237 −0internal/hostfence/challenge_test.go+263 −0internal/hostquarantine/quarantine.go+116 −0internal/hostquarantine/quarantine_test.go+77 −0
diff --git a/internal/awsinv/exact_instance.go b/internal/awsinv/exact_instance.go| new file mode 100644 |
| index 000000000..ff854f0f0 |
| --- /dev/null |
| +++ b/internal/awsinv/exact_instance.go |
| @@ -0,0 +1,96 @@ |
| +package awsinv |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "fmt" |
| + "strings" |
| + |
| + "github.com/aws/aws-sdk-go-v2/aws" |
| + awsmiddleware "github.com/aws/aws-sdk-go-v2/aws/middleware" |
| + awsconfig "github.com/aws/aws-sdk-go-v2/config" |
| + "github.com/aws/aws-sdk-go-v2/service/ec2" |
| +) |
| + |
| +// ErrExactInstance marks every result that fails to prove that the exact |
| +// enrolled EC2 instance is stopped or terminated. Callers must treat it as a |
| +// refusal, including when AWS says that the instance does not exist. |
| +var ErrExactInstance = errors.New("awsinv: exact EC2 instance is not fenced") |
| + |
| +// ExactInstance is the complete observation accepted by the declare-lost |
| +// client. Account comes from Reservation.OwnerId, not from a request field or |
| +// the caller's default profile. |
| +type ExactInstance struct { |
| + Account string `json:"account"` |
| + Region string `json:"region"` |
| + InstanceID string `json:"instance_id"` |
| + State string `json:"state"` |
| + RequestID string `json:"request_id"` |
| +} |
| + |
| +// ExactEC2API is the DescribeInstances slice of EC2API. The production EC2 |
| +// client and every existing awsinv EC2 fake satisfy it; keeping the verifier |
| +// on this one-method view makes a write-capable client unnecessary. |
| +type ExactEC2API interface { |
| + DescribeInstances(context.Context, *ec2.DescribeInstancesInput, ...func(*ec2.Options)) (*ec2.DescribeInstancesOutput, error) |
| +} |
| + |
| +// NewEC2Reader resolves the operator's ordinary AWS credential chain and |
| +// returns the existing read-only EC2 seam in the enrolled region. It grants |
| +// no stop, terminate or restart operation. |
| +func NewEC2Reader(ctx context.Context, region string) (ExactEC2API, error) { |
| + if strings.TrimSpace(region) == "" { |
| + return nil, fmt.Errorf("%w: enrolled region is empty", ErrExactInstance) |
| + } |
| + cfg, err := awsconfig.LoadDefaultConfig(ctx, awsconfig.WithRegion(region)) |
| + if err != nil { |
| + return nil, fmt.Errorf("%w: AWS config: %v", ErrExactInstance, err) |
| + } |
| + probeCtx, cancel := context.WithTimeout(ctx, credentialProbe) |
| + defer cancel() |
| + if _, err := cfg.Credentials.Retrieve(probeCtx); err != nil { |
| + return nil, fmt.Errorf("%w: no usable AWS credential: %v", ErrExactInstance, err) |
| + } |
| + return factoryFor(cfg)(region).EC2, nil |
| +} |
| + |
| +// VerifyExactStoppedInstance makes one DescribeInstances call constrained to |
| +// the exact enrolled ID. Any API error, pagination, missing field, duplicate |
| +// result, owner mismatch or live state refuses; InvalidInstanceID.NotFound is |
| +// therefore never treated as termination evidence. |
| +func VerifyExactStoppedInstance(ctx context.Context, api ExactEC2API, account, region, instanceID string) (ExactInstance, error) { |
| + wantAccount := strings.TrimSpace(account) |
| + wantRegion := strings.TrimSpace(region) |
| + wantID := strings.TrimSpace(instanceID) |
| + if api == nil || wantAccount == "" || wantRegion == "" || wantID == "" { |
| + return ExactInstance{}, fmt.Errorf("%w: incomplete enrolled cloud binding", ErrExactInstance) |
| + } |
| + out, err := api.DescribeInstances(ctx, &ec2.DescribeInstancesInput{InstanceIds: []string{wantID}}, func(o *ec2.Options) { |
| + o.Region = wantRegion |
| + }) |
| + if err != nil { |
| + return ExactInstance{}, fmt.Errorf("%w: DescribeInstances %s in %s/%s: %v", ErrExactInstance, wantID, wantAccount, wantRegion, err) |
| + } |
| + if out == nil || aws.ToString(out.NextToken) != "" || len(out.Reservations) != 1 { |
| + return ExactInstance{}, fmt.Errorf("%w: DescribeInstances returned a partial or ambiguous result", ErrExactInstance) |
| + } |
| + requestID, ok := awsmiddleware.GetRequestIDMetadata(out.ResultMetadata) |
| + if !ok || strings.TrimSpace(requestID) == "" { |
| + return ExactInstance{}, ErrExactInstance |
| + } |
| + r := out.Reservations[0] |
| + owner := aws.ToString(r.OwnerId) |
| + if owner == "" || owner != wantAccount || len(r.Instances) != 1 { |
| + return ExactInstance{}, fmt.Errorf("%w: reservation owner or instance count does not match enrollment", ErrExactInstance) |
| + } |
| + in := r.Instances[0] |
| + gotID := aws.ToString(in.InstanceId) |
| + if gotID == "" || gotID != wantID || in.State == nil { |
| + return ExactInstance{}, fmt.Errorf("%w: returned instance does not exactly match enrollment", ErrExactInstance) |
| + } |
| + state := string(in.State.Name) |
| + if state != "stopped" && state != "terminated" { |
| + return ExactInstance{}, fmt.Errorf("%w: instance %s is %q", ErrExactInstance, wantID, state) |
| + } |
| + return ExactInstance{Account: owner, Region: wantRegion, InstanceID: gotID, State: state, RequestID: requestID}, nil |
| +} |
diff --git a/internal/awsinv/exact_instance_test.go b/internal/awsinv/exact_instance_test.go| new file mode 100644 |
| index 000000000..872bb1ea0 |
| --- /dev/null |
| +++ b/internal/awsinv/exact_instance_test.go |
| @@ -0,0 +1,103 @@ |
| +package awsinv |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "testing" |
| + |
| + "github.com/aws/aws-sdk-go-v2/aws" |
| + awsmiddleware "github.com/aws/aws-sdk-go-v2/aws/middleware" |
| + "github.com/aws/aws-sdk-go-v2/service/ec2" |
| + "github.com/aws/aws-sdk-go-v2/service/ec2/types" |
| + "github.com/aws/smithy-go/middleware" |
| +) |
| + |
| +type exactEC2 struct { |
| + out *ec2.DescribeInstancesOutput |
| + err error |
| + in *ec2.DescribeInstancesInput |
| + region string |
| +} |
| + |
| +func (f *exactEC2) DescribeInstances(_ context.Context, in *ec2.DescribeInstancesInput, options ...func(*ec2.Options)) (*ec2.DescribeInstancesOutput, error) { |
| + f.in = in |
| + var opts ec2.Options |
| + for _, option := range options { |
| + option(&opts) |
| + } |
| + f.region = opts.Region |
| + return f.out, f.err |
| +} |
| + |
| +func exactOutput(owner, id string, state types.InstanceStateName) *ec2.DescribeInstancesOutput { |
| + out := &ec2.DescribeInstancesOutput{Reservations: []types.Reservation{{ |
| + OwnerId: aws.String(owner), |
| + Instances: []types.Instance{{InstanceId: aws.String(id), State: &types.InstanceState{Name: state}}}, |
| + }}} |
| + awsmiddleware.SetRequestIDMetadata(&out.ResultMetadata, "aws-request-123") |
| + return out |
| +} |
| + |
| +func TestVerifyExactStoppedInstanceAcceptsOnlyStoppedOrTerminated(t *testing.T) { |
| + for _, state := range []types.InstanceStateName{types.InstanceStateNameStopped, types.InstanceStateNameTerminated} { |
| + t.Run(string(state), func(t *testing.T) { |
| + api := &exactEC2{out: exactOutput("123456789012", "i-123", state)} |
| + got, err := VerifyExactStoppedInstance(t.Context(), api, "123456789012", "eu-west-1", "i-123") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if got.Account != "123456789012" || got.Region != "eu-west-1" || got.InstanceID != "i-123" || got.State != string(state) || got.RequestID != "aws-request-123" { |
| + t.Fatalf("observation = %+v", got) |
| + } |
| + if len(api.in.InstanceIds) != 1 || api.in.InstanceIds[0] != "i-123" { |
| + t.Fatalf("DescribeInstances input = %+v", api.in) |
| + } |
| + if api.region != "eu-west-1" { |
| + t.Fatalf("DescribeInstances region = %q", api.region) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +func TestVerifyExactStoppedInstanceRefusesEveryIncompleteOrLiveAnswer(t *testing.T) { |
| + notFound := errors.New("InvalidInstanceID.NotFound") |
| + cases := map[string]*exactEC2{ |
| + "missing-request-id": {out: func() *ec2.DescribeInstancesOutput { |
| + o := exactOutput("123456789012", "i-123", types.InstanceStateNameStopped) |
| + o.ResultMetadata = middleware.Metadata{} |
| + return o |
| + }()}, |
| + "empty-request-id": {out: func() *ec2.DescribeInstancesOutput { |
| + o := exactOutput("123456789012", "i-123", types.InstanceStateNameStopped) |
| + awsmiddleware.SetRequestIDMetadata(&o.ResultMetadata, "") |
| + return o |
| + }()}, |
| + "blank-request-id": {out: func() *ec2.DescribeInstancesOutput { |
| + o := exactOutput("123456789012", "i-123", types.InstanceStateNameStopped) |
| + awsmiddleware.SetRequestIDMetadata(&o.ResultMetadata, " ") |
| + return o |
| + }()}, |
| + "not-found": {err: notFound}, |
| + "nil": {}, |
| + "no-reservation": {out: &ec2.DescribeInstancesOutput{}}, |
| + "partial-page": {out: func() *ec2.DescribeInstancesOutput { |
| + o := exactOutput("123456789012", "i-123", types.InstanceStateNameStopped) |
| + o.NextToken = aws.String("more") |
| + return o |
| + }()}, |
| + "wrong-owner": {out: exactOutput("999999999999", "i-123", types.InstanceStateNameStopped)}, |
| + "missing-owner": {out: exactOutput("", "i-123", types.InstanceStateNameStopped)}, |
| + "wrong-instance": {out: exactOutput("123456789012", "i-other", types.InstanceStateNameStopped)}, |
| + "running": {out: exactOutput("123456789012", "i-123", types.InstanceStateNameRunning)}, |
| + "missing-state": {out: &ec2.DescribeInstancesOutput{Reservations: []types.Reservation{{OwnerId: aws.String("123456789012"), Instances: []types.Instance{{InstanceId: aws.String("i-123")}}}}}}, |
| + "two-reservations": {out: &ec2.DescribeInstancesOutput{Reservations: []types.Reservation{{OwnerId: aws.String("123456789012")}, {OwnerId: aws.String("123456789012")}}}}, |
| + "two-instances": {out: &ec2.DescribeInstancesOutput{Reservations: []types.Reservation{{OwnerId: aws.String("123456789012"), Instances: []types.Instance{{InstanceId: aws.String("i-123")}, {InstanceId: aws.String("i-123")}}}}}}, |
| + } |
| + for name, api := range cases { |
| + t.Run(name, func(t *testing.T) { |
| + if _, err := VerifyExactStoppedInstance(t.Context(), api, "123456789012", "eu-west-1", "i-123"); !errors.Is(err, ErrExactInstance) { |
| + t.Fatalf("error = %v, want ErrExactInstance", err) |
| + } |
| + }) |
| + } |
| +} |
diff --git a/internal/hostfence/challenge.go b/internal/hostfence/challenge.go| new file mode 100644 |
| index 000000000..ced20cc67 |
| --- /dev/null |
| +++ b/internal/hostfence/challenge.go |
| @@ -0,0 +1,237 @@ |
| +// Package hostfence defines the operator-bound evidence used by a host loss |
| +// declaration. It does not stop or terminate compute and it does not mutate a |
| +// host claim. |
| +package hostfence |
| + |
| +import ( |
| + "context" |
| + "crypto/ed25519" |
| + "encoding/json" |
| + "errors" |
| + "fmt" |
| + "strings" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/awsinv" |
| + "github.com/carlosframework/platform/internal/hostclaim" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +const ( |
| + ChallengeTTL = 120 * time.Second |
| + ObservationMaxAge = 60 * time.Second |
| + ObservationMaxSkew = 5 * time.Second |
| +) |
| + |
| +var ( |
| + ErrChallenge = errors.New("hostfence: challenge refused") |
| + ErrObservation = errors.New("hostfence: cloud observation refused") |
| +) |
| + |
| +type CloudBinding struct { |
| + Provider string `json:"provider"` |
| + Account string `json:"account"` |
| + Region string `json:"region"` |
| + InstanceID string `json:"instance_id"` |
| +} |
| + |
| +type Operation struct { |
| + ID string `json:"id"` |
| + Kind string `json:"kind"` |
| + Phase string `json:"phase"` |
| +} |
| + |
| +type RecordObservation struct { |
| + Key string `json:"key"` |
| + ETag string `json:"etag"` |
| + CommitState string `json:"commit_state"` |
| +} |
| + |
| +// Consequences is signed by the console so the confirmation cannot be |
| +// replayed for a different transition or a different set of claimed losses. |
| +type Consequences struct { |
| + Transition string `json:"transition"` |
| + DischargeLocal []hostclaim.ObligationRef `json:"discharge_local,omitempty"` |
| + Chores []string `json:"chores,omitempty"` |
| + PossibleWriteLoss bool `json:"possible_write_loss,omitempty"` |
| + PossibleTimerLoss bool `json:"possible_timer_loss,omitempty"` |
| + DestroyedStorageAssertion []string `json:"destroyed_storage_assertion,omitempty"` |
| +} |
| + |
| +// Challenge is the console's exact, short-lived snapshot of the transition |
| +// the operator inspected. Its signed envelope is submitted unchanged. |
| +type Challenge struct { |
| + ID string `json:"id"` |
| + Actor string `json:"actor"` |
| + IssuedAt time.Time `json:"issued_at"` |
| + ExpiresAt time.Time `json:"expires_at"` |
| + Host string `json:"host"` |
| + HolderIncarnation string `json:"holder_incarnation"` |
| + HolderStamp uint64 `json:"holder_stamp"` |
| + HolderSession uint64 `json:"holder_session"` |
| + ClaimGen uint64 `json:"claim_gen"` |
| + ClaimETag string `json:"claim_etag"` |
| + Op Operation `json:"op"` |
| + Record RecordObservation `json:"record"` |
| + Box string `json:"box"` |
| + Enrollment string `json:"enrollment"` |
| + Cloud CloudBinding `json:"cloud"` |
| + Consequences Consequences `json:"consequences"` |
| +} |
| + |
| +type CloudObservation struct { |
| + ObservedAt time.Time `json:"observed_at"` |
| + Account string `json:"account"` |
| + Region string `json:"region"` |
| + InstanceID string `json:"instance_id"` |
| + State string `json:"state"` |
| + RequestID string `json:"request_id"` |
| +} |
| + |
| +type OperatorAttestation struct { |
| + MaintainComputeFence bool `json:"maintain_compute_fence"` |
| + ColdQuarantineStartOnly bool `json:"cold_quarantine_start_only"` |
| + Disks []string `json:"disks,omitempty"` |
| +} |
| + |
| +type Submission struct { |
| + Challenge json.RawMessage `json:"challenge"` |
| + Observation CloudObservation `json:"observation"` |
| + RequestID string `json:"request_id"` |
| + Attestation OperatorAttestation `json:"attestation"` |
| + Confirmation string `json:"confirmation"` |
| +} |
| + |
| +// PrepareChallenge supplies the one permitted lifetime. The caller fills the |
| +// exact claim, record, actor, enrollment and consequence bindings first. |
| +func PrepareChallenge(now time.Time, c Challenge) (Challenge, error) { |
| + c.IssuedAt = now.UTC() |
| + c.ExpiresAt = c.IssuedAt.Add(ChallengeTTL) |
| + if err := validateChallenge(c); err != nil { |
| + return Challenge{}, err |
| + } |
| + return c, nil |
| +} |
| + |
| +func validateChallenge(c Challenge) error { |
| + if c.ID == "" || c.Actor == "" || c.Host == "" || c.HolderIncarnation == "" || |
| + c.ClaimGen == 0 || c.ClaimETag == "" || c.Op.Kind == "" || |
| + c.Record.Key == "" || c.Record.CommitState == "" || c.Box == "" || c.Enrollment == "" || |
| + c.Cloud.Provider != "aws" || c.Cloud.Account == "" || c.Cloud.Region == "" || c.Cloud.InstanceID == "" || |
| + c.Consequences.Transition == "" || c.IssuedAt.IsZero() || c.ExpiresAt.IsZero() || |
| + !c.ExpiresAt.Equal(c.IssuedAt.Add(ChallengeTTL)) { |
| + return fmt.Errorf("%w: incomplete or incorrectly timed challenge", ErrChallenge) |
| + } |
| + if c.Op.Kind == hostclaim.KindServing || c.Consequences.Transition == "destroyed-local-only" { |
| + if c.Op.Kind != hostclaim.KindServing || c.Op.ID != "" || c.Op.Phase != "" || |
| + c.Consequences.Transition != "destroyed-local-only" || c.Consequences.PossibleWriteLoss || |
| + c.Consequences.PossibleTimerLoss || len(c.Consequences.Chores) != 0 || |
| + len(c.Consequences.DischargeLocal) == 0 || |
| + len(c.Consequences.DestroyedStorageAssertion) != len(c.Consequences.DischargeLocal) { |
| + return ErrChallenge |
| + } |
| + want := map[string]bool{} |
| + for _, ref := range c.Consequences.DischargeLocal { |
| + key := ref.Op + "/" + ref.ID + "/" + ref.Retirement |
| + if ref.ID == "" || ref.Op == "" || ref.Retirement == "" || want[key] { |
| + return ErrChallenge |
| + } |
| + want[key] = true |
| + } |
| + for _, assertion := range c.Consequences.DestroyedStorageAssertion { |
| + if !want[assertion] { |
| + return ErrChallenge |
| + } |
| + delete(want, assertion) |
| + } |
| + if len(want) != 0 { |
| + return ErrChallenge |
| + } |
| + } else if c.Op.ID == "" || len(c.Consequences.DischargeLocal) != 0 { |
| + return ErrChallenge |
| + } |
| + if (c.Record.CommitState == "absent") != (c.Record.ETag == "") { |
| + return fmt.Errorf("%w: record absence and ETag are inconsistent", ErrChallenge) |
| + } |
| + return nil |
| +} |
| + |
| +// SignChallenge uses the existing root release signer and its existing |
| +// payload envelope. A scoped release signer is rejected by that primitive. |
| +func SignChallenge(sg release.Signer, c Challenge) ([]byte, error) { |
| + if err := validateChallenge(c); err != nil { |
| + return nil, err |
| + } |
| + wire, err := release.SignRootEnvelope(sg, c) |
| + if err != nil { |
| + return nil, fmt.Errorf("%w: %v", ErrChallenge, err) |
| + } |
| + return wire, nil |
| +} |
| + |
| +// VerifyChallenge authenticates and decodes the envelope before checking its |
| +// lifetime. A challenge at its expiry instant is expired. |
| +func VerifyChallenge(pub ed25519.PublicKey, wire []byte, now time.Time) (Challenge, error) { |
| + var c Challenge |
| + if err := release.VerifyRootEnvelope(pub, wire, &c); err != nil { |
| + return Challenge{}, fmt.Errorf("%w: %v", ErrChallenge, err) |
| + } |
| + if err := validateChallenge(c); err != nil { |
| + return Challenge{}, err |
| + } |
| + now = now.UTC() |
| + if c.IssuedAt.After(now.Add(ObservationMaxSkew)) || !now.Before(c.ExpiresAt) { |
| + return Challenge{}, fmt.Errorf("%w: expired or future challenge", ErrChallenge) |
| + } |
| + return c, nil |
| +} |
| + |
| +// ObserveForChallenge is the CLI-side verifier. It asks AWS for the exact |
| +// enrolled instance after inspection and returns only an accepted stopped or |
| +// terminated observation. It never makes a cloud write call. |
| +func ObserveForChallenge(ctx context.Context, api awsinv.ExactEC2API, c Challenge, now func() time.Time) (CloudObservation, error) { |
| + if err := validateChallenge(c); err != nil { |
| + return CloudObservation{}, err |
| + } |
| + if now == nil { |
| + return CloudObservation{}, fmt.Errorf("%w: no observation clock", ErrObservation) |
| + } |
| + started := now().UTC() |
| + if !started.After(c.IssuedAt) { |
| + return CloudObservation{}, fmt.Errorf("%w: observation did not begin after challenge issuance", ErrObservation) |
| + } |
| + got, err := awsinv.VerifyExactStoppedInstance(ctx, api, c.Cloud.Account, c.Cloud.Region, c.Cloud.InstanceID) |
| + if err != nil { |
| + return CloudObservation{}, fmt.Errorf("%w: %v", ErrObservation, err) |
| + } |
| + consumedAt := now().UTC() |
| + // observed_at is the conservative request start, not the response time: |
| + // a DescribeInstances call blocked for a minute cannot make an old read |
| + // appear fresh merely by returning now. |
| + o := CloudObservation{ObservedAt: started, Account: got.Account, Region: got.Region, InstanceID: got.InstanceID, State: got.State, RequestID: got.RequestID} |
| + if err := ValidateObservation(c, o, consumedAt); err != nil { |
| + return CloudObservation{}, err |
| + } |
| + return o, nil |
| +} |
| + |
| +// ValidateObservation is the console's binding and freshness check. It does |
| +// not claim that AWS signed the observation: it validates the authenticated |
| +// operator assertion carried by the submission. |
| +func ValidateObservation(c Challenge, o CloudObservation, now time.Time) error { |
| + now = now.UTC() |
| + if err := validateChallenge(c); err != nil { |
| + return err |
| + } |
| + if o.Account != c.Cloud.Account || o.Region != c.Cloud.Region || o.InstanceID != c.Cloud.InstanceID || |
| + (o.State != "stopped" && o.State != "terminated") || strings.TrimSpace(o.RequestID) == "" { |
| + return fmt.Errorf("%w: cloud identity or state does not match the challenge", ErrObservation) |
| + } |
| + if !o.ObservedAt.After(c.IssuedAt) || o.ObservedAt.After(now.Add(ObservationMaxSkew)) || now.Sub(o.ObservedAt) > ObservationMaxAge { |
| + return fmt.Errorf("%w: observation is stale, predates inspection, or is too far in the future", ErrObservation) |
| + } |
| + if !now.Before(c.ExpiresAt) { |
| + return fmt.Errorf("%w: challenge expired", ErrObservation) |
| + } |
| + return nil |
| +} |
diff --git a/internal/hostfence/challenge_test.go b/internal/hostfence/challenge_test.go| new file mode 100644 |
| index 000000000..32a739503 |
| --- /dev/null |
| +++ b/internal/hostfence/challenge_test.go |
| @@ -0,0 +1,263 @@ |
| +package hostfence |
| + |
| +import ( |
| + "context" |
| + "crypto/ed25519" |
| + "encoding/base64" |
| + "encoding/json" |
| + "errors" |
| + "github.com/carlosframework/platform/internal/hostclaim" |
| + "reflect" |
| + "testing" |
| + "time" |
| + |
| + "github.com/aws/aws-sdk-go-v2/aws" |
| + awsmiddleware "github.com/aws/aws-sdk-go-v2/aws/middleware" |
| + "github.com/aws/aws-sdk-go-v2/service/ec2" |
| + "github.com/aws/aws-sdk-go-v2/service/ec2/types" |
| + "github.com/aws/smithy-go/middleware" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +type ec2Answer struct { |
| + out *ec2.DescribeInstancesOutput |
| + err error |
| +} |
| + |
| +func (f ec2Answer) DescribeInstances(_ context.Context, _ *ec2.DescribeInstancesInput, _ ...func(*ec2.Options)) (*ec2.DescribeInstancesOutput, error) { |
| + return f.out, f.err |
| +} |
| + |
| +func challengeFixture(t *testing.T, now time.Time) Challenge { |
| + t.Helper() |
| + c, err := PrepareChallenge(now, Challenge{ |
| + ID: "loss-1", Actor: "operator@example.com", Host: "app.example.com", |
| + HolderIncarnation: "inc-1", HolderStamp: 7, HolderSession: 8, |
| + ClaimGen: 9, ClaimETag: "etag-claim", |
| + Op: Operation{ID: "op-1", Kind: "moving", Phase: "copying"}, |
| + Record: RecordObservation{Key: "apps/bab/app/instances/app.example.com.json", ETag: "etag-record", CommitState: "committed"}, |
| + Box: "box-1", Enrollment: "0123456789abcdef0123456789abcdef", |
| + Cloud: CloudBinding{Provider: "aws", Account: "123456789012", Region: "eu-west-1", InstanceID: "i-123"}, |
| + Consequences: Consequences{Transition: "declare-lost", Chores: []string{"park-local/1"}, PossibleWriteLoss: true}, |
| + }) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return c |
| +} |
| + |
| +func TestChallengeRootEnvelopeAndLifetime(t *testing.T) { |
| + pub, priv, err := ed25519.GenerateKey(nil) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + now := time.Date(2026, 10, 5, 12, 0, 0, 0, time.UTC) |
| + c := challengeFixture(t, now) |
| + wire, err := SignChallenge(release.RootSigner(priv), c) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + got, err := VerifyChallenge(pub, wire, now.Add(119*time.Second)) |
| + if err != nil || got.ID != c.ID || got.Cloud != c.Cloud { |
| + t.Fatalf("verify = %+v, %v", got, err) |
| + } |
| + if _, err := VerifyChallenge(pub, wire, c.ExpiresAt); !errors.Is(err, ErrChallenge) { |
| + t.Fatalf("expiry error = %v", err) |
| + } |
| + wire[len(wire)/2] ^= 1 |
| + if _, err := VerifyChallenge(pub, wire, now); !errors.Is(err, ErrChallenge) { |
| + t.Fatalf("tamper error = %v", err) |
| + } |
| +} |
| + |
| +func TestChallengeModelsPresentAndAbsentRecordObservations(t *testing.T) { |
| + now := time.Date(2026, 10, 5, 12, 0, 0, 0, time.UTC) |
| + _, priv, err := ed25519.GenerateKey(nil) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + c := challengeFixture(t, now) |
| + c.HolderStamp, c.HolderSession = 0, 0 |
| + if _, err := SignChallenge(release.RootSigner(priv), c); err != nil { |
| + t.Fatalf("explicit zero holder binding refused: %v", err) |
| + } |
| + c.Record = RecordObservation{Key: c.Record.Key, CommitState: "absent"} |
| + if _, err := SignChallenge(release.RootSigner(priv), c); err != nil { |
| + t.Fatalf("explicit absent record refused: %v", err) |
| + } |
| + for name, breakIt := range map[string]func(*Challenge){ |
| + "present-without-etag": func(c *Challenge) { c.Record.ETag = "" }, |
| + "absent-with-etag": func(c *Challenge) { c.Record.CommitState = "absent" }, |
| + } { |
| + t.Run(name, func(t *testing.T) { |
| + c := challengeFixture(t, now) |
| + breakIt(&c) |
| + if _, err := SignChallenge(release.RootSigner(priv), c); !errors.Is(err, ErrChallenge) { |
| + t.Fatalf("error = %v", err) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +func TestObserveForChallengeBindsExactStoppedInstance(t *testing.T) { |
| + issued := time.Date(2026, 10, 5, 12, 0, 0, 0, time.UTC) |
| + c := challengeFixture(t, issued) |
| + api := ec2Answer{out: &ec2.DescribeInstancesOutput{Reservations: []types.Reservation{{ |
| + OwnerId: aws.String(c.Cloud.Account), Instances: []types.Instance{{InstanceId: aws.String(c.Cloud.InstanceID), State: &types.InstanceState{Name: types.InstanceStateNameStopped}}}, |
| + }}}} |
| + awsmiddleware.SetRequestIDMetadata(&api.out.ResultMetadata, "aws-request-123") |
| + times := []time.Time{issued.Add(time.Second), issued.Add(2 * time.Second)} |
| + i := 0 |
| + o, err := ObserveForChallenge(t.Context(), api, c, func() time.Time { v := times[i]; i++; return v }) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if o.State != "stopped" || o.ObservedAt != times[0] || o.RequestID != "aws-request-123" { |
| + t.Fatalf("observation = %+v", o) |
| + } |
| + |
| +} |
| + |
| +func TestObserveForChallengeRefusesMissingRequestID(t *testing.T) { |
| + issued := time.Date(2026, 10, 5, 12, 0, 0, 0, time.UTC) |
| + c := challengeFixture(t, issued) |
| + for _, id := range []string{"missing", "", " "} { |
| + t.Run(id, func(t *testing.T) { |
| + api := ec2Answer{out: &ec2.DescribeInstancesOutput{Reservations: []types.Reservation{{ |
| + OwnerId: aws.String(c.Cloud.Account), Instances: []types.Instance{{InstanceId: aws.String(c.Cloud.InstanceID), State: &types.InstanceState{Name: types.InstanceStateNameStopped}}}, |
| + }}}} |
| + api.out.ResultMetadata = middleware.Metadata{} |
| + if id != "missing" { |
| + awsmiddleware.SetRequestIDMetadata(&api.out.ResultMetadata, id) |
| + } |
| + if _, err := ObserveForChallenge(t.Context(), api, c, func() time.Time { return issued.Add(time.Second) }); !errors.Is(err, ErrObservation) { |
| + t.Fatalf("missing request ID accepted: %v", err) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +func TestObserveForChallengeRefusesAReadThatBlockedPastFreshness(t *testing.T) { |
| + issued := time.Date(2026, 10, 5, 12, 0, 0, 0, time.UTC) |
| + c := challengeFixture(t, issued) |
| + api := ec2Answer{out: &ec2.DescribeInstancesOutput{Reservations: []types.Reservation{{ |
| + OwnerId: aws.String(c.Cloud.Account), Instances: []types.Instance{{InstanceId: aws.String(c.Cloud.InstanceID), State: &types.InstanceState{Name: types.InstanceStateNameStopped}}}, |
| + }}}} |
| + awsmiddleware.SetRequestIDMetadata(&api.out.ResultMetadata, "aws-request-123") |
| + times := []time.Time{issued.Add(time.Second), issued.Add(62 * time.Second)} |
| + i := 0 |
| + if _, err := ObserveForChallenge(t.Context(), api, c, func() time.Time { v := times[i]; i++; return v }); !errors.Is(err, ErrObservation) { |
| + t.Fatalf("error = %v", err) |
| + } |
| +} |
| + |
| +func TestValidateObservationFreshnessAndBinding(t *testing.T) { |
| + issued := time.Date(2026, 10, 5, 12, 0, 0, 0, time.UTC) |
| + c := challengeFixture(t, issued) |
| + valid := CloudObservation{ObservedAt: issued.Add(10 * time.Second), Account: c.Cloud.Account, Region: c.Cloud.Region, InstanceID: c.Cloud.InstanceID, State: "terminated", RequestID: "aws-request-123"} |
| + if err := ValidateObservation(c, valid, issued.Add(60*time.Second)); err != nil { |
| + t.Fatal(err) |
| + } |
| + checks := []CloudObservation{ |
| + func() CloudObservation { o := valid; o.RequestID = ""; return o }(), |
| + func() CloudObservation { o := valid; o.RequestID = " "; return o }(), |
| + func() CloudObservation { o := valid; o.ObservedAt = issued; return o }(), |
| + func() CloudObservation { o := valid; o.ObservedAt = issued.Add(11 * time.Second); return o }(), |
| + func() CloudObservation { o := valid; o.Account = "999999999999"; return o }(), |
| + func() CloudObservation { o := valid; o.State = "running"; return o }(), |
| + } |
| + for i, o := range checks { |
| + now := issued.Add(70 * time.Second) |
| + if i < 2 { |
| + now = issued.Add(60 * time.Second) |
| + } |
| + if i == 3 { |
| + now = issued.Add(5 * time.Second) |
| + } |
| + if err := ValidateObservation(c, o, now); !errors.Is(err, ErrObservation) { |
| + t.Errorf("case %d error = %v", i, err) |
| + } |
| + } |
| +} |
| + |
| +func TestServingDestroyedLocalOnlyChallengeBindsOriginalChore(t *testing.T) { |
| + now := time.Date(2026, 10, 5, 12, 0, 0, 0, time.UTC) |
| + pub, priv, err := ed25519.GenerateKey(nil) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + c := challengeFixture(t, now) |
| + c.Op = Operation{Kind: hostclaim.KindServing} |
| + c.Consequences = Consequences{ |
| + Transition: "destroyed-local-only", |
| + DischargeLocal: []hostclaim.ObligationRef{{ID: "local", Op: "discovery", Retirement: "retired-installation"}}, |
| + DestroyedStorageAssertion: []string{"discovery/local/retired-installation"}, |
| + } |
| + wire, err := SignChallenge(release.RootSigner(priv), c) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + got, err := VerifyChallenge(pub, wire, now.Add(time.Second)) |
| + if err != nil || !reflect.DeepEqual(got, c) { |
| + t.Fatalf("serving binding round trip = %+v %v", got, err) |
| + } |
| + for name, mutate := range map[string]func(*Challenge){ |
| + "generic": func(c *Challenge) { c.Consequences.Transition = "declare-lost" }, |
| + "non-serving": func(c *Challenge) { c.Op.Kind = "moving" }, |
| + "current-op": func(c *Challenge) { c.Op.ID = "discovery" }, |
| + "phase": func(c *Challenge) { c.Op.Phase = "recover" }, |
| + "claim-gen": func(c *Challenge) { c.ClaimGen = 0 }, |
| + "claim-etag": func(c *Challenge) { c.ClaimETag = "" }, |
| + "write-loss": func(c *Challenge) { c.Consequences.PossibleWriteLoss = true }, |
| + "timer-loss": func(c *Challenge) { c.Consequences.PossibleTimerLoss = true }, |
| + "unbound-chore": func(c *Challenge) { c.Consequences.Chores = []string{"extra"} }, |
| + "empty": func(c *Challenge) { c.Consequences.DischargeLocal = nil }, |
| + "original-op": func(c *Challenge) { c.Consequences.DischargeLocal[0].Op = "" }, |
| + "retirement": func(c *Challenge) { c.Consequences.DischargeLocal[0].Retirement = "" }, |
| + "proof": func(c *Challenge) { |
| + c.Consequences.DestroyedStorageAssertion = []string{"other/local/retired-installation"} |
| + }, |
| + } { |
| + t.Run(name, func(t *testing.T) { |
| + bad := c |
| + bad.Consequences.DischargeLocal = append([]hostclaim.ObligationRef(nil), c.Consequences.DischargeLocal...) |
| + mutate(&bad) |
| + if _, err := SignChallenge(release.RootSigner(priv), bad); !errors.Is(err, ErrChallenge) { |
| + t.Fatalf("widened challenge = %v", err) |
| + } |
| + }) |
| + } |
| + // The signature covers the exact revision and each original chore identity. |
| + for _, field := range []string{"claim_gen", "claim_etag", "op", "retirement"} { |
| + var envelope struct { |
| + Payload string `json:"payload"` |
| + Sig string `json:"sig"` |
| + } |
| + if err := json.Unmarshal(wire, &envelope); err != nil { |
| + t.Fatal(err) |
| + } |
| + var payload map[string]any |
| + raw, err := base64.StdEncoding.DecodeString(envelope.Payload) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := json.Unmarshal(raw, &payload); err != nil { |
| + t.Fatal(err) |
| + } |
| + switch field { |
| + case "claim_gen": |
| + payload[field] = float64(c.ClaimGen + 1) |
| + case "claim_etag": |
| + payload[field] = "other-etag" |
| + default: |
| + refs := payload["consequences"].(map[string]any)["discharge_local"].([]any) |
| + refs[0].(map[string]any)[field] = "other-identity" |
| + } |
| + raw, _ = json.Marshal(payload) |
| + envelope.Payload = base64.StdEncoding.EncodeToString(raw) |
| + tampered, _ := json.Marshal(envelope) |
| + if _, err := VerifyChallenge(pub, tampered, now); !errors.Is(err, ErrChallenge) { |
| + t.Fatalf("changed %s retained signature: %v", field, err) |
| + } |
| + } |
| +} |
diff --git a/internal/hostquarantine/quarantine.go b/internal/hostquarantine/quarantine.go| new file mode 100644 |
| index 000000000..529692779 |
| --- /dev/null |
| +++ b/internal/hostquarantine/quarantine.go |
| @@ -0,0 +1,116 @@ |
| +// Package hostquarantine is the deployment-wide admission gate for enrolled |
| +// boxes. The authoritative directory implementation is deliberately outside |
| +// this package; callers supply an exact-object reader and every Check performs |
| +// a fresh lookup. |
| +package hostquarantine |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "fmt" |
| + "regexp" |
| + "strconv" |
| + "strings" |
| + "time" |
| +) |
| + |
| +const ( |
| + StateActive = "active" |
| + StateQuarantined = "quarantined" |
| + StateRetired = "retired" |
| +) |
| + |
| +var ( |
| + ErrAdmission = errors.New("hostquarantine: admission refused") |
| + ErrRecord = errors.New("hostquarantine: malformed retirement completion") |
| +) |
| + |
| +type Actor struct { |
| + Enrollment string `json:"enrollment"` |
| + State string `json:"state"` |
| + QuarantineRevision uint64 `json:"quarantine_revision"` |
| +} |
| + |
| +// ActorDirectory reads one exact actor from the root-signed deployment |
| +// directory. Implementations must return an error for missing, unreadable or |
| +// unverifiable authority. |
| +type ActorDirectory interface { |
| + Lookup(context.Context, string) (Actor, error) |
| +} |
| + |
| +// Check admits only the exact active enrollment returned by a fresh lookup. |
| +// Missing actors, unreadable authority, identity mismatches, quarantine, |
| +// retirement and unknown states all fail closed. |
| +func Check(ctx context.Context, dir ActorDirectory, box, enrollment string) error { |
| + if dir == nil || box == "" || enrollment == "" { |
| + return fmt.Errorf("%w: actor identity or directory is absent", ErrAdmission) |
| + } |
| + a, err := dir.Lookup(ctx, box) |
| + if err != nil { |
| + return fmt.Errorf("%w: actor directory lookup: %v", ErrAdmission, err) |
| + } |
| + if a.Enrollment == "" || a.Enrollment != enrollment { |
| + return fmt.Errorf("%w: enrollment mismatch", ErrAdmission) |
| + } |
| + if a.State != StateActive { |
| + return fmt.Errorf("%w: actor state %q", ErrAdmission, a.State) |
| + } |
| + return nil |
| +} |
| + |
| +type RetirementEntry struct { |
| + Host string `json:"host"` |
| + Incarnation string `json:"incarnation"` |
| + Stamp uint64 `json:"stamp"` |
| + RetirementID string `json:"retirement_id"` |
| + JournalSHA256 string `json:"journal_sha256"` |
| +} |
| + |
| +// RetirementCompletion is the returning machine's durable retirement |
| +// receipt. Readmission also requires the matching fresh private box report; |
| +// this object alone does not grant admission. |
| +type RetirementCompletion struct { |
| + V int `json:"v"` |
| + Box string `json:"box"` |
| + Enrollment string `json:"enrollment"` |
| + InstanceID string `json:"instance_id"` |
| + QuarantineRevision uint64 `json:"quarantine_revision"` |
| + BootID string `json:"boot_id"` |
| + CompletedAt time.Time `json:"completed_at"` |
| + Complete bool `json:"complete"` |
| + InventorySHA256 string `json:"inventory_sha256"` |
| + Entries []RetirementEntry `json:"entries"` |
| +} |
| + |
| +var sha256RE = regexp.MustCompile(`^[0-9a-f]{64}$`) |
| + |
| +func safeSegment(s string) bool { |
| + return s != "" && s != "." && s != ".." && !strings.ContainsAny(s, "/\\") |
| +} |
| + |
| +func RetirementKey(box, enrollment string, revision uint64) (string, error) { |
| + if !safeSegment(box) || !safeSegment(enrollment) || revision == 0 { |
| + return "", fmt.Errorf("%w: invalid key identity", ErrRecord) |
| + } |
| + return "control/host-retirement/" + box + "/" + enrollment + "/" + strconv.FormatUint(revision, 10) + ".json", nil |
| +} |
| + |
| +func (r RetirementCompletion) Validate() error { |
| + if r.V != 1 || !safeSegment(r.Box) || !safeSegment(r.Enrollment) || r.InstanceID == "" || |
| + r.QuarantineRevision == 0 || r.BootID == "" { |
| + return fmt.Errorf("%w: invalid header", ErrRecord) |
| + } |
| + if r.Complete { |
| + if r.CompletedAt.IsZero() || !sha256RE.MatchString(r.InventorySHA256) || r.Entries == nil { |
| + return fmt.Errorf("%w: completed receipt lacks durable inventory evidence", ErrRecord) |
| + } |
| + } else if !r.CompletedAt.IsZero() { |
| + return fmt.Errorf("%w: incomplete receipt has completed_at", ErrRecord) |
| + } |
| + for i, e := range r.Entries { |
| + if e.Host == "" || e.Incarnation == "" || e.Stamp == 0 || e.RetirementID == "" || !sha256RE.MatchString(e.JournalSHA256) { |
| + return fmt.Errorf("%w: invalid entry %d", ErrRecord, i) |
| + } |
| + } |
| + return nil |
| +} |
diff --git a/internal/hostquarantine/quarantine_test.go b/internal/hostquarantine/quarantine_test.go| new file mode 100644 |
| index 000000000..9f0890fd3 |
| --- /dev/null |
| +++ b/internal/hostquarantine/quarantine_test.go |
| @@ -0,0 +1,77 @@ |
| +package hostquarantine |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "os" |
| + "testing" |
| + "time" |
| +) |
| + |
| +type fakeDirectory struct { |
| + actor Actor |
| + err error |
| + calls int |
| +} |
| + |
| +func (f *fakeDirectory) Lookup(_ context.Context, _ string) (Actor, error) { |
| + f.calls++ |
| + return f.actor, f.err |
| +} |
| + |
| +func TestCheckRequiresFreshExactActiveEnrollment(t *testing.T) { |
| + d := &fakeDirectory{actor: Actor{Enrollment: "enroll-1", State: StateActive, QuarantineRevision: 3}} |
| + if err := Check(t.Context(), d, "box-1", "enroll-1"); err != nil { |
| + t.Fatal(err) |
| + } |
| + d.actor.State = StateQuarantined |
| + if err := Check(t.Context(), d, "box-1", "enroll-1"); !errors.Is(err, ErrAdmission) { |
| + t.Fatalf("quarantine error = %v", err) |
| + } |
| + if d.calls != 2 { |
| + t.Fatalf("directory calls = %d, want a fresh lookup per check", d.calls) |
| + } |
| +} |
| + |
| +func TestCheckFailsClosed(t *testing.T) { |
| + cases := map[string]*fakeDirectory{ |
| + "missing": {err: os.ErrNotExist}, |
| + "unreadable": {err: errors.New("signature refused")}, |
| + "mismatch": {actor: Actor{Enrollment: "other", State: StateActive}}, |
| + "retired": {actor: Actor{Enrollment: "enroll-1", State: StateRetired}}, |
| + "unknown": {actor: Actor{Enrollment: "enroll-1", State: "paused"}}, |
| + } |
| + for name, d := range cases { |
| + t.Run(name, func(t *testing.T) { |
| + if err := Check(t.Context(), d, "box-1", "enroll-1"); !errors.Is(err, ErrAdmission) { |
| + t.Fatalf("error = %v", err) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +func TestRetirementCompletionKeyAndValidation(t *testing.T) { |
| + key, err := RetirementKey("box-1", "enroll-1", 7) |
| + if err != nil || key != "control/host-retirement/box-1/enroll-1/7.json" { |
| + t.Fatalf("key = %q, %v", key, err) |
| + } |
| + hash := "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" |
| + r := RetirementCompletion{ |
| + V: 1, Box: "box-1", Enrollment: "enroll-1", InstanceID: "i-123", QuarantineRevision: 7, |
| + BootID: "boot-1", CompletedAt: time.Date(2026, 10, 5, 12, 0, 0, 0, time.UTC), Complete: true, |
| + InventorySHA256: hash, |
| + Entries: []RetirementEntry{{Host: "app.example.com", Incarnation: "inc-1", Stamp: 8, RetirementID: "retire-1", JournalSHA256: hash}}, |
| + } |
| + if err := r.Validate(); err != nil { |
| + t.Fatal(err) |
| + } |
| + r.Entries = nil |
| + if err := r.Validate(); !errors.Is(err, ErrRecord) { |
| + t.Fatalf("nil entries error = %v", err) |
| + } |
| + r.Entries = []RetirementEntry{{Host: "app.example.com", Incarnation: "inc-1", Stamp: 8, RetirementID: "retire-1", JournalSHA256: hash}} |
| + r.Entries[0].JournalSHA256 = "" |
| + if err := r.Validate(); !errors.Is(err, ErrRecord) { |
| + t.Fatalf("error = %v", err) |
| + } |
| +} |