| 1 | // Package hostfence defines the operator-bound evidence used by a host loss |
| 2 | // declaration. It does not stop or terminate compute and it does not mutate a |
| 3 | // host claim. |
| 4 | package hostfence |
| 5 | |
| 6 | import ( |
| 7 | "context" |
| 8 | "crypto/ed25519" |
| 9 | "encoding/json" |
| 10 | "errors" |
| 11 | "fmt" |
| 12 | "strings" |
| 13 | "time" |
| 14 | |
| 15 | "github.com/carlosframework/platform/internal/awsinv" |
| 16 | "github.com/carlosframework/platform/internal/hostclaim" |
| 17 | "github.com/carlosframework/platform/internal/release" |
| 18 | ) |
| 19 | |
| 20 | const ( |
| 21 | ChallengeTTL = 120 * time.Second |
| 22 | ObservationMaxAge = 60 * time.Second |
| 23 | ObservationMaxSkew = 5 * time.Second |
| 24 | ) |
| 25 | |
| 26 | var ( |
| 27 | ErrChallenge = errors.New("hostfence: challenge refused") |
| 28 | ErrObservation = errors.New("hostfence: cloud observation refused") |
| 29 | ) |
| 30 | |
| 31 | type CloudBinding struct { |
| 32 | Provider string `json:"provider"` |
| 33 | Account string `json:"account"` |
| 34 | Region string `json:"region"` |
| 35 | InstanceID string `json:"instance_id"` |
| 36 | } |
| 37 | |
| 38 | type Operation struct { |
| 39 | ID string `json:"id"` |
| 40 | Kind string `json:"kind"` |
| 41 | Phase string `json:"phase"` |
| 42 | } |
| 43 | |
| 44 | type RecordObservation struct { |
| 45 | Key string `json:"key"` |
| 46 | ETag string `json:"etag"` |
| 47 | CommitState string `json:"commit_state"` |
| 48 | } |
| 49 | |
| 50 | // Consequences is signed by the console so the confirmation cannot be |
| 51 | // replayed for a different transition or a different set of claimed losses. |
| 52 | type Consequences struct { |
| 53 | Transition string `json:"transition"` |
| 54 | DischargeLocal []hostclaim.ObligationRef `json:"discharge_local,omitempty"` |
| 55 | Chores []string `json:"chores,omitempty"` |
| 56 | PossibleWriteLoss bool `json:"possible_write_loss,omitempty"` |
| 57 | PossibleTimerLoss bool `json:"possible_timer_loss,omitempty"` |
| 58 | DestroyedStorageAssertion []string `json:"destroyed_storage_assertion,omitempty"` |
| 59 | } |
| 60 | |
| 61 | // Challenge is the console's exact, short-lived snapshot of the transition |
| 62 | // the operator inspected. Its signed envelope is submitted unchanged. |
| 63 | type Challenge struct { |
| 64 | ID string `json:"id"` |
| 65 | Actor string `json:"actor"` |
| 66 | IssuedAt time.Time `json:"issued_at"` |
| 67 | ExpiresAt time.Time `json:"expires_at"` |
| 68 | Host string `json:"host"` |
| 69 | HolderIncarnation string `json:"holder_incarnation"` |
| 70 | HolderStamp uint64 `json:"holder_stamp"` |
| 71 | HolderSession uint64 `json:"holder_session"` |
| 72 | ClaimGen uint64 `json:"claim_gen"` |
| 73 | ClaimETag string `json:"claim_etag"` |
| 74 | Op Operation `json:"op"` |
| 75 | Record RecordObservation `json:"record"` |
| 76 | Box string `json:"box"` |
| 77 | Enrollment string `json:"enrollment"` |
| 78 | Cloud CloudBinding `json:"cloud"` |
| 79 | Consequences Consequences `json:"consequences"` |
| 80 | } |
| 81 | |
| 82 | type CloudObservation struct { |
| 83 | ObservedAt time.Time `json:"observed_at"` |
| 84 | Account string `json:"account"` |
| 85 | Region string `json:"region"` |
| 86 | InstanceID string `json:"instance_id"` |
| 87 | State string `json:"state"` |
| 88 | RequestID string `json:"request_id"` |
| 89 | } |
| 90 | |
| 91 | type OperatorAttestation struct { |
| 92 | MaintainComputeFence bool `json:"maintain_compute_fence"` |
| 93 | ColdQuarantineStartOnly bool `json:"cold_quarantine_start_only"` |
| 94 | Disks []string `json:"disks,omitempty"` |
| 95 | } |
| 96 | |
| 97 | type Submission struct { |
| 98 | Challenge json.RawMessage `json:"challenge"` |
| 99 | Observation CloudObservation `json:"observation"` |
| 100 | RequestID string `json:"request_id"` |
| 101 | Attestation OperatorAttestation `json:"attestation"` |
| 102 | Confirmation string `json:"confirmation"` |
| 103 | } |
| 104 | |
| 105 | // PrepareChallenge supplies the one permitted lifetime. The caller fills the |
| 106 | // exact claim, record, actor, enrollment and consequence bindings first. |
| 107 | func PrepareChallenge(now time.Time, c Challenge) (Challenge, error) { |
| 108 | c.IssuedAt = now.UTC() |
| 109 | c.ExpiresAt = c.IssuedAt.Add(ChallengeTTL) |
| 110 | if err := validateChallenge(c); err != nil { |
| 111 | return Challenge{}, err |
| 112 | } |
| 113 | return c, nil |
| 114 | } |
| 115 | |
| 116 | func validateChallenge(c Challenge) error { |
| 117 | if c.ID == "" || c.Actor == "" || c.Host == "" || c.HolderIncarnation == "" || |
| 118 | c.ClaimGen == 0 || c.ClaimETag == "" || c.Op.Kind == "" || |
| 119 | c.Record.Key == "" || c.Record.CommitState == "" || c.Box == "" || c.Enrollment == "" || |
| 120 | c.Cloud.Provider != "aws" || c.Cloud.Account == "" || c.Cloud.Region == "" || c.Cloud.InstanceID == "" || |
| 121 | c.Consequences.Transition == "" || c.IssuedAt.IsZero() || c.ExpiresAt.IsZero() || |
| 122 | !c.ExpiresAt.Equal(c.IssuedAt.Add(ChallengeTTL)) { |
| 123 | return fmt.Errorf("%w: incomplete or incorrectly timed challenge", ErrChallenge) |
| 124 | } |
| 125 | if c.Op.Kind == hostclaim.KindServing || c.Consequences.Transition == "destroyed-local-only" { |
| 126 | serving := c.Op.Kind == hostclaim.KindServing |
| 127 | move := c.Op.Kind == hostclaim.KindMoving || c.Op.Kind == hostclaim.KindLanding || c.Op.Kind == hostclaim.KindReverting || c.Op.Kind == hostclaim.KindRestoring |
| 128 | if (!serving && !move) || (serving && (c.Op.ID != "" || c.Op.Phase != "")) || (move && c.Op.ID == "") || |
| 129 | c.Consequences.Transition != "destroyed-local-only" || c.Consequences.PossibleWriteLoss || |
| 130 | c.Consequences.PossibleTimerLoss || len(c.Consequences.Chores) != 0 || |
| 131 | len(c.Consequences.DischargeLocal) == 0 || |
| 132 | len(c.Consequences.DestroyedStorageAssertion) != len(c.Consequences.DischargeLocal) { |
| 133 | return ErrChallenge |
| 134 | } |
| 135 | want := map[string]bool{} |
| 136 | for _, ref := range c.Consequences.DischargeLocal { |
| 137 | key := ref.Op + "/" + ref.ID + "/" + ref.Retirement |
| 138 | if ref.ID == "" || ref.Op == "" || ref.Retirement == "" || want[key] { |
| 139 | return ErrChallenge |
| 140 | } |
| 141 | want[key] = true |
| 142 | } |
| 143 | for _, assertion := range c.Consequences.DestroyedStorageAssertion { |
| 144 | if !want[assertion] { |
| 145 | return ErrChallenge |
| 146 | } |
| 147 | delete(want, assertion) |
| 148 | } |
| 149 | if len(want) != 0 { |
| 150 | return ErrChallenge |
| 151 | } |
| 152 | } else if c.Op.ID == "" || len(c.Consequences.DischargeLocal) != 0 { |
| 153 | return ErrChallenge |
| 154 | } |
| 155 | if (c.Record.CommitState == "absent") != (c.Record.ETag == "") { |
| 156 | return fmt.Errorf("%w: record absence and ETag are inconsistent", ErrChallenge) |
| 157 | } |
| 158 | return nil |
| 159 | } |
| 160 | |
| 161 | // SignChallenge uses the existing root release signer and its existing |
| 162 | // payload envelope. A scoped release signer is rejected by that primitive. |
| 163 | func SignChallenge(sg release.Signer, c Challenge) ([]byte, error) { |
| 164 | if err := validateChallenge(c); err != nil { |
| 165 | return nil, err |
| 166 | } |
| 167 | wire, err := release.SignRootEnvelope(sg, c) |
| 168 | if err != nil { |
| 169 | return nil, fmt.Errorf("%w: %v", ErrChallenge, err) |
| 170 | } |
| 171 | return wire, nil |
| 172 | } |
| 173 | |
| 174 | // VerifyChallenge authenticates and decodes the envelope before checking its |
| 175 | // lifetime. A challenge at its expiry instant is expired. |
| 176 | func VerifyChallenge(pub ed25519.PublicKey, wire []byte, now time.Time) (Challenge, error) { |
| 177 | var c Challenge |
| 178 | if err := release.VerifyRootEnvelope(pub, wire, &c); err != nil { |
| 179 | return Challenge{}, fmt.Errorf("%w: %v", ErrChallenge, err) |
| 180 | } |
| 181 | if err := validateChallenge(c); err != nil { |
| 182 | return Challenge{}, err |
| 183 | } |
| 184 | now = now.UTC() |
| 185 | if c.IssuedAt.After(now.Add(ObservationMaxSkew)) || !now.Before(c.ExpiresAt) { |
| 186 | return Challenge{}, fmt.Errorf("%w: expired or future challenge", ErrChallenge) |
| 187 | } |
| 188 | return c, nil |
| 189 | } |
| 190 | |
| 191 | // ObserveForChallenge is the CLI-side verifier. It asks AWS for the exact |
| 192 | // enrolled instance after inspection and returns only an accepted stopped or |
| 193 | // terminated observation. It never makes a cloud write call. |
| 194 | func ObserveForChallenge(ctx context.Context, api awsinv.ExactEC2API, c Challenge, now func() time.Time) (CloudObservation, error) { |
| 195 | if err := validateChallenge(c); err != nil { |
| 196 | return CloudObservation{}, err |
| 197 | } |
| 198 | if now == nil { |
| 199 | return CloudObservation{}, fmt.Errorf("%w: no observation clock", ErrObservation) |
| 200 | } |
| 201 | started := now().UTC() |
| 202 | if !started.After(c.IssuedAt) { |
| 203 | return CloudObservation{}, fmt.Errorf("%w: observation did not begin after challenge issuance", ErrObservation) |
| 204 | } |
| 205 | got, err := awsinv.VerifyExactStoppedInstance(ctx, api, c.Cloud.Account, c.Cloud.Region, c.Cloud.InstanceID) |
| 206 | if err != nil { |
| 207 | return CloudObservation{}, fmt.Errorf("%w: %v", ErrObservation, err) |
| 208 | } |
| 209 | consumedAt := now().UTC() |
| 210 | // observed_at is the conservative request start, not the response time: |
| 211 | // a DescribeInstances call blocked for a minute cannot make an old read |
| 212 | // appear fresh merely by returning now. |
| 213 | o := CloudObservation{ObservedAt: started, Account: got.Account, Region: got.Region, InstanceID: got.InstanceID, State: got.State, RequestID: got.RequestID} |
| 214 | if err := ValidateObservation(c, o, consumedAt); err != nil { |
| 215 | return CloudObservation{}, err |
| 216 | } |
| 217 | return o, nil |
| 218 | } |
| 219 | |
| 220 | // ValidateObservation is the console's binding and freshness check. It does |
| 221 | // not claim that AWS signed the observation: it validates the authenticated |
| 222 | // operator assertion carried by the submission. |
| 223 | func ValidateObservation(c Challenge, o CloudObservation, now time.Time) error { |
| 224 | now = now.UTC() |
| 225 | if err := validateChallenge(c); err != nil { |
| 226 | return err |
| 227 | } |
| 228 | if o.Account != c.Cloud.Account || o.Region != c.Cloud.Region || o.InstanceID != c.Cloud.InstanceID || |
| 229 | (o.State != "stopped" && o.State != "terminated") || strings.TrimSpace(o.RequestID) == "" { |
| 230 | return fmt.Errorf("%w: cloud identity or state does not match the challenge", ErrObservation) |
| 231 | } |
| 232 | if !o.ObservedAt.After(c.IssuedAt) || o.ObservedAt.After(now.Add(ObservationMaxSkew)) || now.Sub(o.ObservedAt) > ObservationMaxAge { |
| 233 | return fmt.Errorf("%w: observation is stale, predates inspection, or is too far in the future", ErrObservation) |
| 234 | } |
| 235 | if !now.Before(c.ExpiresAt) { |
| 236 | return fmt.Errorf("%w: challenge expired", ErrObservation) |
| 237 | } |
| 238 | return nil |
| 239 | } |
| 240 | |