| 1 | package hostclaim |
| 2 | |
| 3 | import ( |
| 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 | |
| 18 | const lossRecordVersion = 1 |
| 19 | |
| 20 | var ( |
| 21 | ErrLossRecord = errors.New("hostclaim: loss record refused") |
| 22 | ErrLossRecordAbsent = errors.New("hostclaim: loss record absent") |
| 23 | ) |
| 24 | |
| 25 | type TimerKnowledge string |
| 26 | |
| 27 | const ( |
| 28 | TimersComplete TimerKnowledge = "complete" |
| 29 | TimersPartial TimerKnowledge = "partial" |
| 30 | TimersUnknown TimerKnowledge = "unknown" |
| 31 | ) |
| 32 | |
| 33 | type 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 | |
| 41 | type TimerLoss struct { |
| 42 | Knowledge TimerKnowledge `json:"knowledge"` |
| 43 | Source *TimerSource `json:"source,omitempty"` |
| 44 | Entries []json.RawMessage `json:"entries,omitempty"` |
| 45 | } |
| 46 | |
| 47 | type 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 | |
| 56 | type 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 | |
| 62 | type ObligationRef struct { |
| 63 | ID string `json:"id"` |
| 64 | Op string `json:"op"` |
| 65 | Retirement string `json:"retirement"` |
| 66 | } |
| 67 | |
| 68 | type 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 | |
| 79 | type 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. |
| 90 | type 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 | |
| 97 | type 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 | |
| 127 | type ObservedLossRecord struct { |
| 128 | Record LossRecord |
| 129 | ETag string |
| 130 | } |
| 131 | |
| 132 | func 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 | |
| 139 | func 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 | |
| 151 | func (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 | |
| 215 | func 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 | |
| 238 | func 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 | |
| 258 | func 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 | |
| 277 | func 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 | |
| 298 | func 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 | |
| 317 | func 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. |
| 345 | func 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 | |
| 364 | func 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 | |
| 371 | func 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. |
| 391 | func IsDestroyedLocalOnly(c LossConsequences) bool { return destroyedLocalOnly(c) } |
| 392 | |
| 393 | // ValidateLossRecordReplacement also guards scoped transports that receive |
| 394 | // signed bytes directly, without passing through ReplaceLossRecord. |
| 395 | func 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. |
| 403 | func (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 | |