carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

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.
4package hostfence
5
6import (
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
20const (
21 ChallengeTTL = 120 * time.Second
22 ObservationMaxAge = 60 * time.Second
23 ObservationMaxSkew = 5 * time.Second
24)
25
26var (
27 ErrChallenge = errors.New("hostfence: challenge refused")
28 ErrObservation = errors.New("hostfence: cloud observation refused")
29)
30
31type 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
38type Operation struct {
39 ID string `json:"id"`
40 Kind string `json:"kind"`
41 Phase string `json:"phase"`
42}
43
44type 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.
52type 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.
63type 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
82type 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
91type 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
97type 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.
107func 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
116func 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.
163func 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.
176func 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.
194func 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.
223func 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