package hostclaim import ( "bytes" "context" "crypto/ed25519" "encoding/json" "errors" "fmt" "os" "reflect" "strings" "time" "github.com/carlosframework/platform/internal/release" ) const lossRecordVersion = 1 var ( ErrLossRecord = errors.New("hostclaim: loss record refused") ErrLossRecordAbsent = errors.New("hostclaim: loss record absent") ) type TimerKnowledge string const ( TimersComplete TimerKnowledge = "complete" TimersPartial TimerKnowledge = "partial" TimersUnknown TimerKnowledge = "unknown" ) type TimerSource struct { Bucket string `json:"bucket"` Key string `json:"key"` VersionID string `json:"version_id"` SHA256 string `json:"sha256"` CapturedAt time.Time `json:"captured_at"` } type TimerLoss struct { Knowledge TimerKnowledge `json:"knowledge"` Source *TimerSource `json:"source,omitempty"` Entries []json.RawMessage `json:"entries,omitempty"` } type LossCloudObservation 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 LossOperatorAttestation struct { MaintainComputeFence bool `json:"maintain_compute_fence"` ColdQuarantineStartOnly bool `json:"cold_quarantine_start_only"` DestroyedStorage []string `json:"destroyed_storage,omitempty"` } type ObligationRef struct { ID string `json:"id"` Op string `json:"op"` Retirement string `json:"retirement"` } type LossConsequences struct { RevokeExecution bool `json:"revoke_execution"` RemoveParticipation bool `json:"remove_participation"` DischargeLocal []ObligationRef `json:"discharge_local,omitempty"` ReassignRegional []ObligationRef `json:"reassign_regional,omitempty"` DestroyedStorage []string `json:"destroyed_storage,omitempty"` WritesPotentiallyLost bool `json:"writes_potentially_lost"` TimersPotentiallyLost bool `json:"timers_potentially_lost"` RecoveryIntent json.RawMessage `json:"recovery_intent"` } type WriteSource struct { Reason string `json:"reason"` // target | source-cut Box string `json:"box"` Generation string `json:"generation"` Session uint64 `json:"session"` Region string `json:"region"` Baseline string `json:"baseline"` } // ArchivedAdmissionLoss is the exact evidence copied out of a claim before a // loss-bearing admission can be pruned or its holder removed. type ArchivedAdmissionLoss struct { LossID string `json:"loss_id"` AdmissionClaimGen uint64 `json:"admission_claim_gen"` WritesLost *WritesLost `json:"writes_lost,omitempty"` AdditionalWritesLost []WritesLost `json:"additional_writes_lost,omitempty"` } type LossRecord struct { AlertsPending bool `json:"alerts_pending,omitempty"` Automatic *AutomaticLoss `json:"automatic,omitempty"` V int `json:"v"` ID string `json:"id"` State string `json:"state"` // prepared | committed | conflicted Actor string `json:"actor"` AcceptedAt time.Time `json:"accepted_at"` Host string `json:"host"` HolderIncarnation string `json:"holder_incarnation"` HolderStamp uint64 `json:"holder_stamp"` HolderSession uint64 `json:"holder_session"` ExpectedClaimGen uint64 `json:"expected_claim_gen"` ExpectedClaimETag string `json:"expected_claim_etag"` OpID string `json:"op_id"` OpKind string `json:"op_kind"` OpPhase string `json:"op_phase"` Box string `json:"box"` Enrollment string `json:"enrollment"` QuarantineRevision uint64 `json:"quarantine_revision"` CloudObservation LossCloudObservation `json:"cloud_observation"` OperatorAttestation LossOperatorAttestation `json:"operator_attestation"` FencedAt time.Time `json:"fenced_at"` ConfirmedConsequences LossConsequences `json:"confirmed_consequences"` Timers TimerLoss `json:"timers"` WriteSources []WriteSource `json:"write_sources,omitempty"` ResultClaimGen uint64 `json:"result_claim_gen,omitempty"` ArchivedAdmissionLosses []ArchivedAdmissionLoss `json:"archived_admission_losses,omitempty"` } type ObservedLossRecord struct { Record LossRecord ETag string } func LossRecordKey(id string) (string, error) { if !safeLossID(id) { return "", fmt.Errorf("%w: invalid declaration id", ErrLossRecord) } return "control/host-loss/" + id + ".json", nil } func safeLossID(id string) bool { if id == "" || len(id) > 128 { return false } for _, r := range id { if !((r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' || r == '_') { return false } } return true } func (r LossRecord) Validate() error { if r.Automatic != nil { return r.validateAutomatic() } if r.V != lossRecordVersion || !safeLossID(r.ID) || (r.State != "prepared" && r.State != "committed" && r.State != "conflicted") || r.Actor == "" || r.AcceptedAt.IsZero() || r.Host == "" || !isHex64(r.HolderIncarnation) || r.HolderStamp == 0 || r.ExpectedClaimGen == 0 || r.ExpectedClaimETag == "" || !knownKinds[r.OpKind] || r.Box == "" || r.Enrollment == "" || r.QuarantineRevision == 0 || r.FencedAt.IsZero() { return ErrLossRecord } if r.OpKind == KindServing { if r.OpID != "" || r.OpPhase != "" || !destroyedLocalOnly(r.ConfirmedConsequences) || len(r.WriteSources) != 0 || len(r.ArchivedAdmissionLosses) != 0 { return ErrLossRecord } } else if r.OpID == "" { return ErrLossRecord } intent, intentErr := canonicalRaw(r.ConfirmedConsequences.RecoveryIntent) if intentErr == nil && intent == `{"kind":"destroyed-local-only"}` && !destroyedLocalOnly(r.ConfirmedConsequences) { return ErrLossRecord } if destroyedLocalOnly(r.ConfirmedConsequences) && (len(r.WriteSources) != 0 || len(r.ArchivedAdmissionLosses) != 0) { return ErrLossRecord } if _, err := Key(r.Host); err != nil { return ErrLossRecord } if r.State == "committed" && r.ResultClaimGen == 0 || r.State == "prepared" && r.ResultClaimGen != 0 { return ErrLossRecord } o := r.CloudObservation if o.ObservedAt.IsZero() || o.Account == "" || o.Region == "" || o.InstanceID == "" || o.RequestID == "" || (o.State != "stopped" && o.State != "terminated") { return ErrLossRecord } if !r.OperatorAttestation.MaintainComputeFence || !r.OperatorAttestation.ColdQuarantineStartOnly || !validConsequences(r.ConfirmedConsequences) || !reflect.DeepEqual(r.OperatorAttestation.DestroyedStorage, r.ConfirmedConsequences.DestroyedStorage) || !validTimerLoss(r.Timers) { return ErrLossRecord } seenSources := map[string]bool{} for _, s := range r.WriteSources { if (s.Reason != "target" && s.Reason != "source-cut") || s.Box == "" || (s.Session == 0) != (s.Generation == "") || s.Region == "" || s.Baseline == "" { return ErrLossRecord } key := fmt.Sprintf("%s\x00%d", s.Box, s.Session) if seenSources[key] { return ErrLossRecord } seenSources[key] = true } seenArchived := map[string]bool{} for _, a := range r.ArchivedAdmissionLosses { if a.LossID != r.ID || a.AdmissionClaimGen == 0 || (a.WritesLost == nil && len(a.AdditionalWritesLost) == 0) || !validWriteLosses(a.WritesLost, a.AdditionalWritesLost) { return ErrLossRecord } key := fmt.Sprintf("%s\x00%d", a.LossID, a.AdmissionClaimGen) if seenArchived[key] { return ErrLossRecord } seenArchived[key] = true } return nil } func destroyedLocalOnly(c LossConsequences) bool { if !validConsequences(c) || c.RevokeExecution || c.RemoveParticipation || len(c.ReassignRegional) != 0 || c.WritesPotentiallyLost || c.TimersPotentiallyLost || len(c.DischargeLocal) == 0 || len(c.DestroyedStorage) != len(c.DischargeLocal) { return false } intent, err := canonicalRaw(c.RecoveryIntent) if err != nil || intent != `{"kind":"destroyed-local-only"}` { return false } want := map[string]bool{} for _, ref := range c.DischargeLocal { want[ref.Op+"/"+ref.ID+"/"+ref.Retirement] = true } for _, assertion := range c.DestroyedStorage { if !want[assertion] { return false } delete(want, assertion) } return len(want) == 0 } func validConsequences(c LossConsequences) bool { if len(c.RecoveryIntent) == 0 || !json.Valid(c.RecoveryIntent) { return false } var object map[string]json.RawMessage if json.Unmarshal(c.RecoveryIntent, &object) != nil || object == nil { return false } for _, refs := range [][]ObligationRef{c.DischargeLocal, c.ReassignRegional} { seen := map[string]bool{} for _, ref := range refs { if ref.ID == "" || ref.Op == "" || ref.Retirement == "" || seen[ref.Op+"\x00"+ref.ID] { return false } seen[ref.Op+"\x00"+ref.ID] = true } } return true } func validTimerLoss(t TimerLoss) bool { switch t.Knowledge { case TimersUnknown: return t.Source == nil && len(t.Entries) == 0 case TimersComplete, TimersPartial: if t.Source == nil || t.Source.Bucket == "" || t.Source.Key == "" || t.Source.VersionID == "" || !isHex64(t.Source.SHA256) || t.Source.CapturedAt.IsZero() { return false } default: return false } for _, entry := range t.Entries { if len(entry) == 0 || !json.Valid(entry) || strings.TrimSpace(string(entry)) == "null" { return false } } return true } func validWriteLosses(primary *WritesLost, additional []WritesLost) bool { seen := map[string]bool{} if primary != nil { if primary.Box == "" || (primary.Session == 0) != (primary.Generation == "") { return false } seen[fmt.Sprintf("%s\x00%d", primary.Box, primary.Session)] = true } for _, w := range additional { if w.Box == "" || (w.Session == 0) != (w.Generation == "") { return false } key := fmt.Sprintf("%s\x00%d", w.Box, w.Session) if seen[key] { return false } seen[key] = true } return true } func ReadLossRecord(ctx context.Context, s release.Store, pub ed25519.PublicKey, id string) (ObservedLossRecord, error) { key, err := LossRecordKey(id) if err != nil { return ObservedLossRecord{}, err } wire, etag, err := s.Get(ctx, key) if errors.Is(err, os.ErrNotExist) { return ObservedLossRecord{}, ErrLossRecordAbsent } if err != nil { return ObservedLossRecord{}, err } var record LossRecord if err := release.VerifyRootEnvelope(pub, wire, &record); err != nil || record.ID != id || record.Validate() != nil || etag == "" { return ObservedLossRecord{}, ErrLossRecord } return ObservedLossRecord{Record: record, ETag: etag}, nil } func CreateLossRecord(ctx context.Context, s release.Store, sg release.Signer, record LossRecord) (ObservedLossRecord, error) { if record.State != "prepared" || record.Validate() != nil { return ObservedLossRecord{}, ErrLossRecord } key, _ := LossRecordKey(record.ID) wire, err := release.SignRootEnvelope(sg, record) if err != nil { return ObservedLossRecord{}, fmt.Errorf("%w: %v", ErrLossRecord, err) } etag, err := s.Put(ctx, key, wire, "") if errors.Is(err, release.ErrCASConflict) { existing, readErr := ReadLossRecord(ctx, s, sg.RootPublicKey(), record.ID) existingPayload, existingErr := json.Marshal(existing.Record) payload, payloadErr := json.Marshal(record) if readErr == nil && existingErr == nil && payloadErr == nil && bytes.Equal(existingPayload, payload) { return existing, nil } return ObservedLossRecord{}, fmt.Errorf("%w: declaration id already names different evidence", ErrLossRecord) } if err != nil { return ObservedLossRecord{}, err } return ObservedLossRecord{Record: record, ETag: etag}, nil } // ReplaceLossRecord conditionally advances delivery/accounting fields while // proving that the accepted evidence and consequences prepared earlier are // byte-for-byte unchanged. func ReplaceLossRecord(ctx context.Context, s release.Store, sg release.Signer, before ObservedLossRecord, next LossRecord) (ObservedLossRecord, error) { if err := ValidateLossRecordReplacement(before, next); err != nil { return ObservedLossRecord{}, ErrLossRecord } key, _ := LossRecordKey(next.ID) wire, err := release.SignRootEnvelope(sg, next) if err != nil { return ObservedLossRecord{}, fmt.Errorf("%w: %v", ErrLossRecord, err) } etag, err := s.Put(ctx, key, wire, before.ETag) if errors.Is(err, release.ErrCASConflict) { return ObservedLossRecord{}, ErrConflict } if err != nil { return ObservedLossRecord{}, err } return ObservedLossRecord{Record: next, ETag: etag}, nil } func immutableLossFieldsEqual(a, b LossRecord) bool { a.State, b.State = "", "" a.ResultClaimGen, b.ResultClaimGen = 0, 0 a.ArchivedAdmissionLosses, b.ArchivedAdmissionLosses = nil, nil return reflect.DeepEqual(a, b) } func validLossProgression(before, next LossRecord) bool { if next.ID != before.ID || len(next.ArchivedAdmissionLosses) < len(before.ArchivedAdmissionLosses) { return false } if len(before.ArchivedAdmissionLosses) > 0 && !reflect.DeepEqual(next.ArchivedAdmissionLosses[:len(before.ArchivedAdmissionLosses)], before.ArchivedAdmissionLosses) { return false } switch before.State { case "prepared": return next.State == "prepared" || next.State == "committed" || next.State == "conflicted" case "committed": return next.State == "committed" && next.ResultClaimGen == before.ResultClaimGen case "conflicted": return next.State == "conflicted" && next.ResultClaimGen == before.ResultClaimGen } return false } // IsDestroyedLocalOnly reports the complete attested consequence shape, not // merely its intent marker. Owners and durable records use the same predicate. func IsDestroyedLocalOnly(c LossConsequences) bool { return destroyedLocalOnly(c) } // ValidateLossRecordReplacement also guards scoped transports that receive // signed bytes directly, without passing through ReplaceLossRecord. func ValidateLossRecordReplacement(before ObservedLossRecord, next LossRecord) error { if before.ETag == "" || before.Record.Validate() != nil || next.Validate() != nil || !immutableLossFieldsEqual(before.Record, next) || !validLossProgression(before.Record, next) { return ErrLossRecord } return nil } // Automatic records retain the exact claim evidence, without fabricating operator authority. func (r LossRecord) validateAutomatic() error { l := r.Automatic 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) { return ErrLossRecord } at, err := time.Parse(time.RFC3339Nano, l.ObservedAt) 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 { return ErrLossRecord } if _, err = Key(r.Host); err != nil { return ErrLossRecord } if err = validateAutomaticLosses(&Claim{EvidenceRefs: []AutomaticLoss{*l}}, map[string]bool{l.ID: true}); err != nil { return ErrLossRecord } return nil }