carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1package hostclaim
2
3import (
4 "bytes"
5 "context"
6 "crypto/ed25519"
7 "encoding/json"
8 "errors"
9 "fmt"
10 "os"
11 "reflect"
12 "strings"
13 "time"
14
15 "github.com/carlosframework/platform/internal/release"
16)
17
18const lossRecordVersion = 1
19
20var (
21 ErrLossRecord = errors.New("hostclaim: loss record refused")
22 ErrLossRecordAbsent = errors.New("hostclaim: loss record absent")
23)
24
25type TimerKnowledge string
26
27const (
28 TimersComplete TimerKnowledge = "complete"
29 TimersPartial TimerKnowledge = "partial"
30 TimersUnknown TimerKnowledge = "unknown"
31)
32
33type TimerSource struct {
34 Bucket string `json:"bucket"`
35 Key string `json:"key"`
36 VersionID string `json:"version_id"`
37 SHA256 string `json:"sha256"`
38 CapturedAt time.Time `json:"captured_at"`
39}
40
41type TimerLoss struct {
42 Knowledge TimerKnowledge `json:"knowledge"`
43 Source *TimerSource `json:"source,omitempty"`
44 Entries []json.RawMessage `json:"entries,omitempty"`
45}
46
47type LossCloudObservation struct {
48 ObservedAt time.Time `json:"observed_at"`
49 Account string `json:"account"`
50 Region string `json:"region"`
51 InstanceID string `json:"instance_id"`
52 State string `json:"state"`
53 RequestID string `json:"request_id"`
54}
55
56type LossOperatorAttestation struct {
57 MaintainComputeFence bool `json:"maintain_compute_fence"`
58 ColdQuarantineStartOnly bool `json:"cold_quarantine_start_only"`
59 DestroyedStorage []string `json:"destroyed_storage,omitempty"`
60}
61
62type ObligationRef struct {
63 ID string `json:"id"`
64 Op string `json:"op"`
65 Retirement string `json:"retirement"`
66}
67
68type LossConsequences struct {
69 RevokeExecution bool `json:"revoke_execution"`
70 RemoveParticipation bool `json:"remove_participation"`
71 DischargeLocal []ObligationRef `json:"discharge_local,omitempty"`
72 ReassignRegional []ObligationRef `json:"reassign_regional,omitempty"`
73 DestroyedStorage []string `json:"destroyed_storage,omitempty"`
74 WritesPotentiallyLost bool `json:"writes_potentially_lost"`
75 TimersPotentiallyLost bool `json:"timers_potentially_lost"`
76 RecoveryIntent json.RawMessage `json:"recovery_intent"`
77}
78
79type WriteSource struct {
80 Reason string `json:"reason"` // target | source-cut
81 Box string `json:"box"`
82 Generation string `json:"generation"`
83 Session uint64 `json:"session"`
84 Region string `json:"region"`
85 Baseline string `json:"baseline"`
86}
87
88// ArchivedAdmissionLoss is the exact evidence copied out of a claim before a
89// loss-bearing admission can be pruned or its holder removed.
90type ArchivedAdmissionLoss struct {
91 LossID string `json:"loss_id"`
92 AdmissionClaimGen uint64 `json:"admission_claim_gen"`
93 WritesLost *WritesLost `json:"writes_lost,omitempty"`
94 AdditionalWritesLost []WritesLost `json:"additional_writes_lost,omitempty"`
95}
96
97type LossRecord struct {
98 AlertsPending bool `json:"alerts_pending,omitempty"`
99 Automatic *AutomaticLoss `json:"automatic,omitempty"`
100 V int `json:"v"`
101 ID string `json:"id"`
102 State string `json:"state"` // prepared | committed | conflicted
103 Actor string `json:"actor"`
104 AcceptedAt time.Time `json:"accepted_at"`
105 Host string `json:"host"`
106 HolderIncarnation string `json:"holder_incarnation"`
107 HolderStamp uint64 `json:"holder_stamp"`
108 HolderSession uint64 `json:"holder_session"`
109 ExpectedClaimGen uint64 `json:"expected_claim_gen"`
110 ExpectedClaimETag string `json:"expected_claim_etag"`
111 OpID string `json:"op_id"`
112 OpKind string `json:"op_kind"`
113 OpPhase string `json:"op_phase"`
114 Box string `json:"box"`
115 Enrollment string `json:"enrollment"`
116 QuarantineRevision uint64 `json:"quarantine_revision"`
117 CloudObservation LossCloudObservation `json:"cloud_observation"`
118 OperatorAttestation LossOperatorAttestation `json:"operator_attestation"`
119 FencedAt time.Time `json:"fenced_at"`
120 ConfirmedConsequences LossConsequences `json:"confirmed_consequences"`
121 Timers TimerLoss `json:"timers"`
122 WriteSources []WriteSource `json:"write_sources,omitempty"`
123 ResultClaimGen uint64 `json:"result_claim_gen,omitempty"`
124 ArchivedAdmissionLosses []ArchivedAdmissionLoss `json:"archived_admission_losses,omitempty"`
125}
126
127type ObservedLossRecord struct {
128 Record LossRecord
129 ETag string
130}
131
132func LossRecordKey(id string) (string, error) {
133 if !safeLossID(id) {
134 return "", fmt.Errorf("%w: invalid declaration id", ErrLossRecord)
135 }
136 return "control/host-loss/" + id + ".json", nil
137}
138
139func safeLossID(id string) bool {
140 if id == "" || len(id) > 128 {
141 return false
142 }
143 for _, r := range id {
144 if !((r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' || r == '_') {
145 return false
146 }
147 }
148 return true
149}
150
151func (r LossRecord) Validate() error {
152 if r.Automatic != nil {
153 return r.validateAutomatic()
154 }
155 if r.V != lossRecordVersion || !safeLossID(r.ID) || (r.State != "prepared" && r.State != "committed" && r.State != "conflicted") ||
156 r.Actor == "" || r.AcceptedAt.IsZero() || r.Host == "" || !isHex64(r.HolderIncarnation) || r.HolderStamp == 0 || r.ExpectedClaimGen == 0 ||
157 r.ExpectedClaimETag == "" || !knownKinds[r.OpKind] || r.Box == "" || r.Enrollment == "" ||
158 r.QuarantineRevision == 0 || r.FencedAt.IsZero() {
159 return ErrLossRecord
160 }
161 if r.OpKind == KindServing {
162 if r.OpID != "" || r.OpPhase != "" || !destroyedLocalOnly(r.ConfirmedConsequences) ||
163 len(r.WriteSources) != 0 || len(r.ArchivedAdmissionLosses) != 0 {
164 return ErrLossRecord
165 }
166 } else if r.OpID == "" {
167 return ErrLossRecord
168 }
169 intent, intentErr := canonicalRaw(r.ConfirmedConsequences.RecoveryIntent)
170 if intentErr == nil && intent == `{"kind":"destroyed-local-only"}` && !destroyedLocalOnly(r.ConfirmedConsequences) {
171 return ErrLossRecord
172 }
173 if destroyedLocalOnly(r.ConfirmedConsequences) && (len(r.WriteSources) != 0 || len(r.ArchivedAdmissionLosses) != 0) {
174 return ErrLossRecord
175 }
176 if _, err := Key(r.Host); err != nil {
177 return ErrLossRecord
178 }
179 if r.State == "committed" && r.ResultClaimGen == 0 || r.State == "prepared" && r.ResultClaimGen != 0 {
180 return ErrLossRecord
181 }
182 o := r.CloudObservation
183 if o.ObservedAt.IsZero() || o.Account == "" || o.Region == "" || o.InstanceID == "" || o.RequestID == "" || (o.State != "stopped" && o.State != "terminated") {
184 return ErrLossRecord
185 }
186 if !r.OperatorAttestation.MaintainComputeFence || !r.OperatorAttestation.ColdQuarantineStartOnly || !validConsequences(r.ConfirmedConsequences) ||
187 !reflect.DeepEqual(r.OperatorAttestation.DestroyedStorage, r.ConfirmedConsequences.DestroyedStorage) || !validTimerLoss(r.Timers) {
188 return ErrLossRecord
189 }
190 seenSources := map[string]bool{}
191 for _, s := range r.WriteSources {
192 if (s.Reason != "target" && s.Reason != "source-cut") || s.Box == "" || (s.Session == 0) != (s.Generation == "") || s.Region == "" || s.Baseline == "" {
193 return ErrLossRecord
194 }
195 key := fmt.Sprintf("%s\x00%d", s.Box, s.Session)
196 if seenSources[key] {
197 return ErrLossRecord
198 }
199 seenSources[key] = true
200 }
201 seenArchived := map[string]bool{}
202 for _, a := range r.ArchivedAdmissionLosses {
203 if a.LossID != r.ID || a.AdmissionClaimGen == 0 || (a.WritesLost == nil && len(a.AdditionalWritesLost) == 0) || !validWriteLosses(a.WritesLost, a.AdditionalWritesLost) {
204 return ErrLossRecord
205 }
206 key := fmt.Sprintf("%s\x00%d", a.LossID, a.AdmissionClaimGen)
207 if seenArchived[key] {
208 return ErrLossRecord
209 }
210 seenArchived[key] = true
211 }
212 return nil
213}
214
215func destroyedLocalOnly(c LossConsequences) bool {
216 if !validConsequences(c) || c.RevokeExecution || c.RemoveParticipation || len(c.ReassignRegional) != 0 ||
217 c.WritesPotentiallyLost || c.TimersPotentiallyLost || len(c.DischargeLocal) == 0 ||
218 len(c.DestroyedStorage) != len(c.DischargeLocal) {
219 return false
220 }
221 intent, err := canonicalRaw(c.RecoveryIntent)
222 if err != nil || intent != `{"kind":"destroyed-local-only"}` {
223 return false
224 }
225 want := map[string]bool{}
226 for _, ref := range c.DischargeLocal {
227 want[ref.Op+"/"+ref.ID+"/"+ref.Retirement] = true
228 }
229 for _, assertion := range c.DestroyedStorage {
230 if !want[assertion] {
231 return false
232 }
233 delete(want, assertion)
234 }
235 return len(want) == 0
236}
237
238func validConsequences(c LossConsequences) bool {
239 if len(c.RecoveryIntent) == 0 || !json.Valid(c.RecoveryIntent) {
240 return false
241 }
242 var object map[string]json.RawMessage
243 if json.Unmarshal(c.RecoveryIntent, &object) != nil || object == nil {
244 return false
245 }
246 for _, refs := range [][]ObligationRef{c.DischargeLocal, c.ReassignRegional} {
247 seen := map[string]bool{}
248 for _, ref := range refs {
249 if ref.ID == "" || ref.Op == "" || ref.Retirement == "" || seen[ref.Op+"\x00"+ref.ID] {
250 return false
251 }
252 seen[ref.Op+"\x00"+ref.ID] = true
253 }
254 }
255 return true
256}
257
258func validTimerLoss(t TimerLoss) bool {
259 switch t.Knowledge {
260 case TimersUnknown:
261 return t.Source == nil && len(t.Entries) == 0
262 case TimersComplete, TimersPartial:
263 if t.Source == nil || t.Source.Bucket == "" || t.Source.Key == "" || t.Source.VersionID == "" || !isHex64(t.Source.SHA256) || t.Source.CapturedAt.IsZero() {
264 return false
265 }
266 default:
267 return false
268 }
269 for _, entry := range t.Entries {
270 if len(entry) == 0 || !json.Valid(entry) || strings.TrimSpace(string(entry)) == "null" {
271 return false
272 }
273 }
274 return true
275}
276
277func validWriteLosses(primary *WritesLost, additional []WritesLost) bool {
278 seen := map[string]bool{}
279 if primary != nil {
280 if primary.Box == "" || (primary.Session == 0) != (primary.Generation == "") {
281 return false
282 }
283 seen[fmt.Sprintf("%s\x00%d", primary.Box, primary.Session)] = true
284 }
285 for _, w := range additional {
286 if w.Box == "" || (w.Session == 0) != (w.Generation == "") {
287 return false
288 }
289 key := fmt.Sprintf("%s\x00%d", w.Box, w.Session)
290 if seen[key] {
291 return false
292 }
293 seen[key] = true
294 }
295 return true
296}
297
298func ReadLossRecord(ctx context.Context, s release.Store, pub ed25519.PublicKey, id string) (ObservedLossRecord, error) {
299 key, err := LossRecordKey(id)
300 if err != nil {
301 return ObservedLossRecord{}, err
302 }
303 wire, etag, err := s.Get(ctx, key)
304 if errors.Is(err, os.ErrNotExist) {
305 return ObservedLossRecord{}, ErrLossRecordAbsent
306 }
307 if err != nil {
308 return ObservedLossRecord{}, err
309 }
310 var record LossRecord
311 if err := release.VerifyRootEnvelope(pub, wire, &record); err != nil || record.ID != id || record.Validate() != nil || etag == "" {
312 return ObservedLossRecord{}, ErrLossRecord
313 }
314 return ObservedLossRecord{Record: record, ETag: etag}, nil
315}
316
317func CreateLossRecord(ctx context.Context, s release.Store, sg release.Signer, record LossRecord) (ObservedLossRecord, error) {
318 if record.State != "prepared" || record.Validate() != nil {
319 return ObservedLossRecord{}, ErrLossRecord
320 }
321 key, _ := LossRecordKey(record.ID)
322 wire, err := release.SignRootEnvelope(sg, record)
323 if err != nil {
324 return ObservedLossRecord{}, fmt.Errorf("%w: %v", ErrLossRecord, err)
325 }
326 etag, err := s.Put(ctx, key, wire, "")
327 if errors.Is(err, release.ErrCASConflict) {
328 existing, readErr := ReadLossRecord(ctx, s, sg.RootPublicKey(), record.ID)
329 existingPayload, existingErr := json.Marshal(existing.Record)
330 payload, payloadErr := json.Marshal(record)
331 if readErr == nil && existingErr == nil && payloadErr == nil && bytes.Equal(existingPayload, payload) {
332 return existing, nil
333 }
334 return ObservedLossRecord{}, fmt.Errorf("%w: declaration id already names different evidence", ErrLossRecord)
335 }
336 if err != nil {
337 return ObservedLossRecord{}, err
338 }
339 return ObservedLossRecord{Record: record, ETag: etag}, nil
340}
341
342// ReplaceLossRecord conditionally advances delivery/accounting fields while
343// proving that the accepted evidence and consequences prepared earlier are
344// byte-for-byte unchanged.
345func ReplaceLossRecord(ctx context.Context, s release.Store, sg release.Signer, before ObservedLossRecord, next LossRecord) (ObservedLossRecord, error) {
346 if err := ValidateLossRecordReplacement(before, next); err != nil {
347 return ObservedLossRecord{}, ErrLossRecord
348 }
349 key, _ := LossRecordKey(next.ID)
350 wire, err := release.SignRootEnvelope(sg, next)
351 if err != nil {
352 return ObservedLossRecord{}, fmt.Errorf("%w: %v", ErrLossRecord, err)
353 }
354 etag, err := s.Put(ctx, key, wire, before.ETag)
355 if errors.Is(err, release.ErrCASConflict) {
356 return ObservedLossRecord{}, ErrConflict
357 }
358 if err != nil {
359 return ObservedLossRecord{}, err
360 }
361 return ObservedLossRecord{Record: next, ETag: etag}, nil
362}
363
364func immutableLossFieldsEqual(a, b LossRecord) bool {
365 a.State, b.State = "", ""
366 a.ResultClaimGen, b.ResultClaimGen = 0, 0
367 a.ArchivedAdmissionLosses, b.ArchivedAdmissionLosses = nil, nil
368 return reflect.DeepEqual(a, b)
369}
370
371func validLossProgression(before, next LossRecord) bool {
372 if next.ID != before.ID || len(next.ArchivedAdmissionLosses) < len(before.ArchivedAdmissionLosses) {
373 return false
374 }
375 if len(before.ArchivedAdmissionLosses) > 0 && !reflect.DeepEqual(next.ArchivedAdmissionLosses[:len(before.ArchivedAdmissionLosses)], before.ArchivedAdmissionLosses) {
376 return false
377 }
378 switch before.State {
379 case "prepared":
380 return next.State == "prepared" || next.State == "committed" || next.State == "conflicted"
381 case "committed":
382 return next.State == "committed" && next.ResultClaimGen == before.ResultClaimGen
383 case "conflicted":
384 return next.State == "conflicted" && next.ResultClaimGen == before.ResultClaimGen
385 }
386 return false
387}
388
389// IsDestroyedLocalOnly reports the complete attested consequence shape, not
390// merely its intent marker. Owners and durable records use the same predicate.
391func IsDestroyedLocalOnly(c LossConsequences) bool { return destroyedLocalOnly(c) }
392
393// ValidateLossRecordReplacement also guards scoped transports that receive
394// signed bytes directly, without passing through ReplaceLossRecord.
395func ValidateLossRecordReplacement(before ObservedLossRecord, next LossRecord) error {
396 if before.ETag == "" || before.Record.Validate() != nil || next.Validate() != nil || !immutableLossFieldsEqual(before.Record, next) || !validLossProgression(before.Record, next) {
397 return ErrLossRecord
398 }
399 return nil
400}
401
402// Automatic records retain the exact claim evidence, without fabricating operator authority.
403func (r LossRecord) validateAutomatic() error {
404 l := r.Automatic
405 if l == nil || l.Reported || !r.AlertsPending || r.V != lossRecordVersion || r.Actor != "automatic" || r.ID != l.ID || r.Host == "" || r.HolderIncarnation != l.Incarnation || r.HolderStamp != l.Stamp || r.HolderSession != l.Session || r.OpID != l.Op || r.OpKind != l.Kind || r.OpPhase != l.Phase || r.Box != l.Box || r.Enrollment != l.Enrollment || r.QuarantineRevision != 0 || r.ExpectedClaimGen == 0 || r.ExpectedClaimETag == "" || (r.State != "prepared" && r.State != "committed") || (r.State == "prepared" && r.ResultClaimGen != 0) || (r.State == "committed" && r.ResultClaimGen == 0) {
406 return ErrLossRecord
407 }
408 at, err := time.Parse(time.RFC3339Nano, l.ObservedAt)
409 if err != nil || !r.AcceptedAt.Equal(at) || !r.FencedAt.Equal(at) || r.OperatorAttestation.MaintainComputeFence || r.OperatorAttestation.ColdQuarantineStartOnly || len(r.OperatorAttestation.DestroyedStorage) != 0 || r.CloudObservation != (LossCloudObservation{}) || r.Timers.Knowledge != l.Timers || r.Timers.Source != nil || len(r.Timers.Entries) != 0 || len(r.WriteSources) != 0 {
410 return ErrLossRecord
411 }
412 if _, err = Key(r.Host); err != nil {
413 return ErrLossRecord
414 }
415 if err = validateAutomaticLosses(&Claim{EvidenceRefs: []AutomaticLoss{*l}}, map[string]bool{l.ID: true}); err != nil {
416 return ErrLossRecord
417 }
418 return nil
419}
420