package hostfence import ( "context" "crypto/ed25519" "encoding/base64" "encoding/json" "errors" "github.com/carlosframework/platform/internal/hostclaim" "reflect" "testing" "time" "github.com/aws/aws-sdk-go-v2/aws" awsmiddleware "github.com/aws/aws-sdk-go-v2/aws/middleware" "github.com/aws/aws-sdk-go-v2/service/ec2" "github.com/aws/aws-sdk-go-v2/service/ec2/types" "github.com/aws/smithy-go/middleware" "github.com/carlosframework/platform/internal/release" ) type ec2Answer struct { out *ec2.DescribeInstancesOutput err error } func (f ec2Answer) DescribeInstances(_ context.Context, _ *ec2.DescribeInstancesInput, _ ...func(*ec2.Options)) (*ec2.DescribeInstancesOutput, error) { return f.out, f.err } func challengeFixture(t *testing.T, now time.Time) Challenge { t.Helper() c, err := PrepareChallenge(now, Challenge{ ID: "loss-1", Actor: "operator@example.com", Host: "app.example.com", HolderIncarnation: "inc-1", HolderStamp: 7, HolderSession: 8, ClaimGen: 9, ClaimETag: "etag-claim", Op: Operation{ID: "op-1", Kind: "moving", Phase: "copying"}, Record: RecordObservation{Key: "apps/bab/app/instances/app.example.com.json", ETag: "etag-record", CommitState: "committed"}, Box: "box-1", Enrollment: "0123456789abcdef0123456789abcdef", Cloud: CloudBinding{Provider: "aws", Account: "123456789012", Region: "eu-west-1", InstanceID: "i-123"}, Consequences: Consequences{Transition: "declare-lost", Chores: []string{"park-local/1"}, PossibleWriteLoss: true}, }) if err != nil { t.Fatal(err) } return c } func TestChallengeRootEnvelopeAndLifetime(t *testing.T) { pub, priv, err := ed25519.GenerateKey(nil) if err != nil { t.Fatal(err) } now := time.Date(2026, 10, 5, 12, 0, 0, 0, time.UTC) c := challengeFixture(t, now) wire, err := SignChallenge(release.RootSigner(priv), c) if err != nil { t.Fatal(err) } got, err := VerifyChallenge(pub, wire, now.Add(119*time.Second)) if err != nil || got.ID != c.ID || got.Cloud != c.Cloud { t.Fatalf("verify = %+v, %v", got, err) } if _, err := VerifyChallenge(pub, wire, c.ExpiresAt); !errors.Is(err, ErrChallenge) { t.Fatalf("expiry error = %v", err) } wire[len(wire)/2] ^= 1 if _, err := VerifyChallenge(pub, wire, now); !errors.Is(err, ErrChallenge) { t.Fatalf("tamper error = %v", err) } } func TestChallengeModelsPresentAndAbsentRecordObservations(t *testing.T) { now := time.Date(2026, 10, 5, 12, 0, 0, 0, time.UTC) _, priv, err := ed25519.GenerateKey(nil) if err != nil { t.Fatal(err) } c := challengeFixture(t, now) c.HolderStamp, c.HolderSession = 0, 0 if _, err := SignChallenge(release.RootSigner(priv), c); err != nil { t.Fatalf("explicit zero holder binding refused: %v", err) } c.Record = RecordObservation{Key: c.Record.Key, CommitState: "absent"} if _, err := SignChallenge(release.RootSigner(priv), c); err != nil { t.Fatalf("explicit absent record refused: %v", err) } for name, breakIt := range map[string]func(*Challenge){ "present-without-etag": func(c *Challenge) { c.Record.ETag = "" }, "absent-with-etag": func(c *Challenge) { c.Record.CommitState = "absent" }, } { t.Run(name, func(t *testing.T) { c := challengeFixture(t, now) breakIt(&c) if _, err := SignChallenge(release.RootSigner(priv), c); !errors.Is(err, ErrChallenge) { t.Fatalf("error = %v", err) } }) } } func TestObserveForChallengeBindsExactStoppedInstance(t *testing.T) { issued := time.Date(2026, 10, 5, 12, 0, 0, 0, time.UTC) c := challengeFixture(t, issued) api := ec2Answer{out: &ec2.DescribeInstancesOutput{Reservations: []types.Reservation{{ OwnerId: aws.String(c.Cloud.Account), Instances: []types.Instance{{InstanceId: aws.String(c.Cloud.InstanceID), State: &types.InstanceState{Name: types.InstanceStateNameStopped}}}, }}}} awsmiddleware.SetRequestIDMetadata(&api.out.ResultMetadata, "aws-request-123") times := []time.Time{issued.Add(time.Second), issued.Add(2 * time.Second)} i := 0 o, err := ObserveForChallenge(t.Context(), api, c, func() time.Time { v := times[i]; i++; return v }) if err != nil { t.Fatal(err) } if o.State != "stopped" || o.ObservedAt != times[0] || o.RequestID != "aws-request-123" { t.Fatalf("observation = %+v", o) } } func TestObserveForChallengeRefusesMissingRequestID(t *testing.T) { issued := time.Date(2026, 10, 5, 12, 0, 0, 0, time.UTC) c := challengeFixture(t, issued) for _, id := range []string{"missing", "", " "} { t.Run(id, func(t *testing.T) { api := ec2Answer{out: &ec2.DescribeInstancesOutput{Reservations: []types.Reservation{{ OwnerId: aws.String(c.Cloud.Account), Instances: []types.Instance{{InstanceId: aws.String(c.Cloud.InstanceID), State: &types.InstanceState{Name: types.InstanceStateNameStopped}}}, }}}} api.out.ResultMetadata = middleware.Metadata{} if id != "missing" { awsmiddleware.SetRequestIDMetadata(&api.out.ResultMetadata, id) } if _, err := ObserveForChallenge(t.Context(), api, c, func() time.Time { return issued.Add(time.Second) }); !errors.Is(err, ErrObservation) { t.Fatalf("missing request ID accepted: %v", err) } }) } } func TestObserveForChallengeRefusesAReadThatBlockedPastFreshness(t *testing.T) { issued := time.Date(2026, 10, 5, 12, 0, 0, 0, time.UTC) c := challengeFixture(t, issued) api := ec2Answer{out: &ec2.DescribeInstancesOutput{Reservations: []types.Reservation{{ OwnerId: aws.String(c.Cloud.Account), Instances: []types.Instance{{InstanceId: aws.String(c.Cloud.InstanceID), State: &types.InstanceState{Name: types.InstanceStateNameStopped}}}, }}}} awsmiddleware.SetRequestIDMetadata(&api.out.ResultMetadata, "aws-request-123") times := []time.Time{issued.Add(time.Second), issued.Add(62 * time.Second)} i := 0 if _, err := ObserveForChallenge(t.Context(), api, c, func() time.Time { v := times[i]; i++; return v }); !errors.Is(err, ErrObservation) { t.Fatalf("error = %v", err) } } func TestValidateObservationFreshnessAndBinding(t *testing.T) { issued := time.Date(2026, 10, 5, 12, 0, 0, 0, time.UTC) c := challengeFixture(t, issued) valid := CloudObservation{ObservedAt: issued.Add(10 * time.Second), Account: c.Cloud.Account, Region: c.Cloud.Region, InstanceID: c.Cloud.InstanceID, State: "terminated", RequestID: "aws-request-123"} if err := ValidateObservation(c, valid, issued.Add(60*time.Second)); err != nil { t.Fatal(err) } checks := []CloudObservation{ func() CloudObservation { o := valid; o.RequestID = ""; return o }(), func() CloudObservation { o := valid; o.RequestID = " "; return o }(), func() CloudObservation { o := valid; o.ObservedAt = issued; return o }(), func() CloudObservation { o := valid; o.ObservedAt = issued.Add(11 * time.Second); return o }(), func() CloudObservation { o := valid; o.Account = "999999999999"; return o }(), func() CloudObservation { o := valid; o.State = "running"; return o }(), } for i, o := range checks { now := issued.Add(70 * time.Second) if i < 2 { now = issued.Add(60 * time.Second) } if i == 3 { now = issued.Add(5 * time.Second) } if err := ValidateObservation(c, o, now); !errors.Is(err, ErrObservation) { t.Errorf("case %d error = %v", i, err) } } } func TestServingDestroyedLocalOnlyChallengeBindsOriginalChore(t *testing.T) { now := time.Date(2026, 10, 5, 12, 0, 0, 0, time.UTC) pub, priv, err := ed25519.GenerateKey(nil) if err != nil { t.Fatal(err) } c := challengeFixture(t, now) c.Op = Operation{Kind: hostclaim.KindServing} c.Consequences = Consequences{ Transition: "destroyed-local-only", DischargeLocal: []hostclaim.ObligationRef{{ID: "local", Op: "discovery", Retirement: "retired-installation"}}, DestroyedStorageAssertion: []string{"discovery/local/retired-installation"}, } wire, err := SignChallenge(release.RootSigner(priv), c) if err != nil { t.Fatal(err) } got, err := VerifyChallenge(pub, wire, now.Add(time.Second)) if err != nil || !reflect.DeepEqual(got, c) { t.Fatalf("serving binding round trip = %+v %v", got, err) } for name, mutate := range map[string]func(*Challenge){ "generic": func(c *Challenge) { c.Consequences.Transition = "declare-lost" }, "non-serving": func(c *Challenge) { c.Op.Kind = "moving" }, "current-op": func(c *Challenge) { c.Op.ID = "discovery" }, "phase": func(c *Challenge) { c.Op.Phase = "recover" }, "claim-gen": func(c *Challenge) { c.ClaimGen = 0 }, "claim-etag": func(c *Challenge) { c.ClaimETag = "" }, "write-loss": func(c *Challenge) { c.Consequences.PossibleWriteLoss = true }, "timer-loss": func(c *Challenge) { c.Consequences.PossibleTimerLoss = true }, "unbound-chore": func(c *Challenge) { c.Consequences.Chores = []string{"extra"} }, "empty": func(c *Challenge) { c.Consequences.DischargeLocal = nil }, "original-op": func(c *Challenge) { c.Consequences.DischargeLocal[0].Op = "" }, "retirement": func(c *Challenge) { c.Consequences.DischargeLocal[0].Retirement = "" }, "proof": func(c *Challenge) { c.Consequences.DestroyedStorageAssertion = []string{"other/local/retired-installation"} }, } { t.Run(name, func(t *testing.T) { bad := c bad.Consequences.DischargeLocal = append([]hostclaim.ObligationRef(nil), c.Consequences.DischargeLocal...) mutate(&bad) if _, err := SignChallenge(release.RootSigner(priv), bad); !errors.Is(err, ErrChallenge) { t.Fatalf("widened challenge = %v", err) } }) } // The signature covers the exact revision and each original chore identity. for _, field := range []string{"claim_gen", "claim_etag", "op", "retirement"} { var envelope struct { Payload string `json:"payload"` Sig string `json:"sig"` } if err := json.Unmarshal(wire, &envelope); err != nil { t.Fatal(err) } var payload map[string]any raw, err := base64.StdEncoding.DecodeString(envelope.Payload) if err != nil { t.Fatal(err) } if err := json.Unmarshal(raw, &payload); err != nil { t.Fatal(err) } switch field { case "claim_gen": payload[field] = float64(c.ClaimGen + 1) case "claim_etag": payload[field] = "other-etag" default: refs := payload["consequences"].(map[string]any)["discharge_local"].([]any) refs[0].(map[string]any)[field] = "other-identity" } raw, _ = json.Marshal(payload) envelope.Payload = base64.StdEncoding.EncodeToString(raw) tampered, _ := json.Marshal(envelope) if _, err := VerifyChallenge(pub, tampered, now); !errors.Is(err, ErrChallenge) { t.Fatalf("changed %s retained signature: %v", field, err) } } }