// 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" { serving := c.Op.Kind == hostclaim.KindServing move := c.Op.Kind == hostclaim.KindMoving || c.Op.Kind == hostclaim.KindLanding || c.Op.Kind == hostclaim.KindReverting || c.Op.Kind == hostclaim.KindRestoring if (!serving && !move) || (serving && (c.Op.ID != "" || c.Op.Phase != "")) || (move && c.Op.ID == "") || 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 }