| 1 | package hostfence |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "crypto/ed25519" |
| 6 | "encoding/base64" |
| 7 | "encoding/json" |
| 8 | "errors" |
| 9 | "github.com/carlosframework/platform/internal/hostclaim" |
| 10 | "reflect" |
| 11 | "testing" |
| 12 | "time" |
| 13 | |
| 14 | "github.com/aws/aws-sdk-go-v2/aws" |
| 15 | awsmiddleware "github.com/aws/aws-sdk-go-v2/aws/middleware" |
| 16 | "github.com/aws/aws-sdk-go-v2/service/ec2" |
| 17 | "github.com/aws/aws-sdk-go-v2/service/ec2/types" |
| 18 | "github.com/aws/smithy-go/middleware" |
| 19 | "github.com/carlosframework/platform/internal/release" |
| 20 | ) |
| 21 | |
| 22 | type ec2Answer struct { |
| 23 | out *ec2.DescribeInstancesOutput |
| 24 | err error |
| 25 | } |
| 26 | |
| 27 | func (f ec2Answer) DescribeInstances(_ context.Context, _ *ec2.DescribeInstancesInput, _ ...func(*ec2.Options)) (*ec2.DescribeInstancesOutput, error) { |
| 28 | return f.out, f.err |
| 29 | } |
| 30 | |
| 31 | func challengeFixture(t *testing.T, now time.Time) Challenge { |
| 32 | t.Helper() |
| 33 | c, err := PrepareChallenge(now, Challenge{ |
| 34 | ID: "loss-1", Actor: "operator@example.com", Host: "app.example.com", |
| 35 | HolderIncarnation: "inc-1", HolderStamp: 7, HolderSession: 8, |
| 36 | ClaimGen: 9, ClaimETag: "etag-claim", |
| 37 | Op: Operation{ID: "op-1", Kind: "moving", Phase: "copying"}, |
| 38 | Record: RecordObservation{Key: "apps/bab/app/instances/app.example.com.json", ETag: "etag-record", CommitState: "committed"}, |
| 39 | Box: "box-1", Enrollment: "0123456789abcdef0123456789abcdef", |
| 40 | Cloud: CloudBinding{Provider: "aws", Account: "123456789012", Region: "eu-west-1", InstanceID: "i-123"}, |
| 41 | Consequences: Consequences{Transition: "declare-lost", Chores: []string{"park-local/1"}, PossibleWriteLoss: true}, |
| 42 | }) |
| 43 | if err != nil { |
| 44 | t.Fatal(err) |
| 45 | } |
| 46 | return c |
| 47 | } |
| 48 | |
| 49 | func TestChallengeRootEnvelopeAndLifetime(t *testing.T) { |
| 50 | pub, priv, err := ed25519.GenerateKey(nil) |
| 51 | if err != nil { |
| 52 | t.Fatal(err) |
| 53 | } |
| 54 | now := time.Date(2026, 10, 5, 12, 0, 0, 0, time.UTC) |
| 55 | c := challengeFixture(t, now) |
| 56 | wire, err := SignChallenge(release.RootSigner(priv), c) |
| 57 | if err != nil { |
| 58 | t.Fatal(err) |
| 59 | } |
| 60 | got, err := VerifyChallenge(pub, wire, now.Add(119*time.Second)) |
| 61 | if err != nil || got.ID != c.ID || got.Cloud != c.Cloud { |
| 62 | t.Fatalf("verify = %+v, %v", got, err) |
| 63 | } |
| 64 | if _, err := VerifyChallenge(pub, wire, c.ExpiresAt); !errors.Is(err, ErrChallenge) { |
| 65 | t.Fatalf("expiry error = %v", err) |
| 66 | } |
| 67 | wire[len(wire)/2] ^= 1 |
| 68 | if _, err := VerifyChallenge(pub, wire, now); !errors.Is(err, ErrChallenge) { |
| 69 | t.Fatalf("tamper error = %v", err) |
| 70 | } |
| 71 | } |
| 72 | |
| 73 | func TestChallengeModelsPresentAndAbsentRecordObservations(t *testing.T) { |
| 74 | now := time.Date(2026, 10, 5, 12, 0, 0, 0, time.UTC) |
| 75 | _, priv, err := ed25519.GenerateKey(nil) |
| 76 | if err != nil { |
| 77 | t.Fatal(err) |
| 78 | } |
| 79 | c := challengeFixture(t, now) |
| 80 | c.HolderStamp, c.HolderSession = 0, 0 |
| 81 | if _, err := SignChallenge(release.RootSigner(priv), c); err != nil { |
| 82 | t.Fatalf("explicit zero holder binding refused: %v", err) |
| 83 | } |
| 84 | c.Record = RecordObservation{Key: c.Record.Key, CommitState: "absent"} |
| 85 | if _, err := SignChallenge(release.RootSigner(priv), c); err != nil { |
| 86 | t.Fatalf("explicit absent record refused: %v", err) |
| 87 | } |
| 88 | for name, breakIt := range map[string]func(*Challenge){ |
| 89 | "present-without-etag": func(c *Challenge) { c.Record.ETag = "" }, |
| 90 | "absent-with-etag": func(c *Challenge) { c.Record.CommitState = "absent" }, |
| 91 | } { |
| 92 | t.Run(name, func(t *testing.T) { |
| 93 | c := challengeFixture(t, now) |
| 94 | breakIt(&c) |
| 95 | if _, err := SignChallenge(release.RootSigner(priv), c); !errors.Is(err, ErrChallenge) { |
| 96 | t.Fatalf("error = %v", err) |
| 97 | } |
| 98 | }) |
| 99 | } |
| 100 | } |
| 101 | |
| 102 | func TestObserveForChallengeBindsExactStoppedInstance(t *testing.T) { |
| 103 | issued := time.Date(2026, 10, 5, 12, 0, 0, 0, time.UTC) |
| 104 | c := challengeFixture(t, issued) |
| 105 | api := ec2Answer{out: &ec2.DescribeInstancesOutput{Reservations: []types.Reservation{{ |
| 106 | OwnerId: aws.String(c.Cloud.Account), Instances: []types.Instance{{InstanceId: aws.String(c.Cloud.InstanceID), State: &types.InstanceState{Name: types.InstanceStateNameStopped}}}, |
| 107 | }}}} |
| 108 | awsmiddleware.SetRequestIDMetadata(&api.out.ResultMetadata, "aws-request-123") |
| 109 | times := []time.Time{issued.Add(time.Second), issued.Add(2 * time.Second)} |
| 110 | i := 0 |
| 111 | o, err := ObserveForChallenge(t.Context(), api, c, func() time.Time { v := times[i]; i++; return v }) |
| 112 | if err != nil { |
| 113 | t.Fatal(err) |
| 114 | } |
| 115 | if o.State != "stopped" || o.ObservedAt != times[0] || o.RequestID != "aws-request-123" { |
| 116 | t.Fatalf("observation = %+v", o) |
| 117 | } |
| 118 | |
| 119 | } |
| 120 | |
| 121 | func TestObserveForChallengeRefusesMissingRequestID(t *testing.T) { |
| 122 | issued := time.Date(2026, 10, 5, 12, 0, 0, 0, time.UTC) |
| 123 | c := challengeFixture(t, issued) |
| 124 | for _, id := range []string{"missing", "", " "} { |
| 125 | t.Run(id, func(t *testing.T) { |
| 126 | api := ec2Answer{out: &ec2.DescribeInstancesOutput{Reservations: []types.Reservation{{ |
| 127 | OwnerId: aws.String(c.Cloud.Account), Instances: []types.Instance{{InstanceId: aws.String(c.Cloud.InstanceID), State: &types.InstanceState{Name: types.InstanceStateNameStopped}}}, |
| 128 | }}}} |
| 129 | api.out.ResultMetadata = middleware.Metadata{} |
| 130 | if id != "missing" { |
| 131 | awsmiddleware.SetRequestIDMetadata(&api.out.ResultMetadata, id) |
| 132 | } |
| 133 | if _, err := ObserveForChallenge(t.Context(), api, c, func() time.Time { return issued.Add(time.Second) }); !errors.Is(err, ErrObservation) { |
| 134 | t.Fatalf("missing request ID accepted: %v", err) |
| 135 | } |
| 136 | }) |
| 137 | } |
| 138 | } |
| 139 | |
| 140 | func TestObserveForChallengeRefusesAReadThatBlockedPastFreshness(t *testing.T) { |
| 141 | issued := time.Date(2026, 10, 5, 12, 0, 0, 0, time.UTC) |
| 142 | c := challengeFixture(t, issued) |
| 143 | api := ec2Answer{out: &ec2.DescribeInstancesOutput{Reservations: []types.Reservation{{ |
| 144 | OwnerId: aws.String(c.Cloud.Account), Instances: []types.Instance{{InstanceId: aws.String(c.Cloud.InstanceID), State: &types.InstanceState{Name: types.InstanceStateNameStopped}}}, |
| 145 | }}}} |
| 146 | awsmiddleware.SetRequestIDMetadata(&api.out.ResultMetadata, "aws-request-123") |
| 147 | times := []time.Time{issued.Add(time.Second), issued.Add(62 * time.Second)} |
| 148 | i := 0 |
| 149 | if _, err := ObserveForChallenge(t.Context(), api, c, func() time.Time { v := times[i]; i++; return v }); !errors.Is(err, ErrObservation) { |
| 150 | t.Fatalf("error = %v", err) |
| 151 | } |
| 152 | } |
| 153 | |
| 154 | func TestValidateObservationFreshnessAndBinding(t *testing.T) { |
| 155 | issued := time.Date(2026, 10, 5, 12, 0, 0, 0, time.UTC) |
| 156 | c := challengeFixture(t, issued) |
| 157 | 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"} |
| 158 | if err := ValidateObservation(c, valid, issued.Add(60*time.Second)); err != nil { |
| 159 | t.Fatal(err) |
| 160 | } |
| 161 | checks := []CloudObservation{ |
| 162 | func() CloudObservation { o := valid; o.RequestID = ""; return o }(), |
| 163 | func() CloudObservation { o := valid; o.RequestID = " "; return o }(), |
| 164 | func() CloudObservation { o := valid; o.ObservedAt = issued; return o }(), |
| 165 | func() CloudObservation { o := valid; o.ObservedAt = issued.Add(11 * time.Second); return o }(), |
| 166 | func() CloudObservation { o := valid; o.Account = "999999999999"; return o }(), |
| 167 | func() CloudObservation { o := valid; o.State = "running"; return o }(), |
| 168 | } |
| 169 | for i, o := range checks { |
| 170 | now := issued.Add(70 * time.Second) |
| 171 | if i < 2 { |
| 172 | now = issued.Add(60 * time.Second) |
| 173 | } |
| 174 | if i == 3 { |
| 175 | now = issued.Add(5 * time.Second) |
| 176 | } |
| 177 | if err := ValidateObservation(c, o, now); !errors.Is(err, ErrObservation) { |
| 178 | t.Errorf("case %d error = %v", i, err) |
| 179 | } |
| 180 | } |
| 181 | } |
| 182 | |
| 183 | func TestServingDestroyedLocalOnlyChallengeBindsOriginalChore(t *testing.T) { |
| 184 | now := time.Date(2026, 10, 5, 12, 0, 0, 0, time.UTC) |
| 185 | pub, priv, err := ed25519.GenerateKey(nil) |
| 186 | if err != nil { |
| 187 | t.Fatal(err) |
| 188 | } |
| 189 | c := challengeFixture(t, now) |
| 190 | c.Op = Operation{Kind: hostclaim.KindServing} |
| 191 | c.Consequences = Consequences{ |
| 192 | Transition: "destroyed-local-only", |
| 193 | DischargeLocal: []hostclaim.ObligationRef{{ID: "local", Op: "discovery", Retirement: "retired-installation"}}, |
| 194 | DestroyedStorageAssertion: []string{"discovery/local/retired-installation"}, |
| 195 | } |
| 196 | wire, err := SignChallenge(release.RootSigner(priv), c) |
| 197 | if err != nil { |
| 198 | t.Fatal(err) |
| 199 | } |
| 200 | got, err := VerifyChallenge(pub, wire, now.Add(time.Second)) |
| 201 | if err != nil || !reflect.DeepEqual(got, c) { |
| 202 | t.Fatalf("serving binding round trip = %+v %v", got, err) |
| 203 | } |
| 204 | for name, mutate := range map[string]func(*Challenge){ |
| 205 | "generic": func(c *Challenge) { c.Consequences.Transition = "declare-lost" }, |
| 206 | "non-serving": func(c *Challenge) { c.Op.Kind = "moving" }, |
| 207 | "current-op": func(c *Challenge) { c.Op.ID = "discovery" }, |
| 208 | "phase": func(c *Challenge) { c.Op.Phase = "recover" }, |
| 209 | "claim-gen": func(c *Challenge) { c.ClaimGen = 0 }, |
| 210 | "claim-etag": func(c *Challenge) { c.ClaimETag = "" }, |
| 211 | "write-loss": func(c *Challenge) { c.Consequences.PossibleWriteLoss = true }, |
| 212 | "timer-loss": func(c *Challenge) { c.Consequences.PossibleTimerLoss = true }, |
| 213 | "unbound-chore": func(c *Challenge) { c.Consequences.Chores = []string{"extra"} }, |
| 214 | "empty": func(c *Challenge) { c.Consequences.DischargeLocal = nil }, |
| 215 | "original-op": func(c *Challenge) { c.Consequences.DischargeLocal[0].Op = "" }, |
| 216 | "retirement": func(c *Challenge) { c.Consequences.DischargeLocal[0].Retirement = "" }, |
| 217 | "proof": func(c *Challenge) { |
| 218 | c.Consequences.DestroyedStorageAssertion = []string{"other/local/retired-installation"} |
| 219 | }, |
| 220 | } { |
| 221 | t.Run(name, func(t *testing.T) { |
| 222 | bad := c |
| 223 | bad.Consequences.DischargeLocal = append([]hostclaim.ObligationRef(nil), c.Consequences.DischargeLocal...) |
| 224 | mutate(&bad) |
| 225 | if _, err := SignChallenge(release.RootSigner(priv), bad); !errors.Is(err, ErrChallenge) { |
| 226 | t.Fatalf("widened challenge = %v", err) |
| 227 | } |
| 228 | }) |
| 229 | } |
| 230 | // The signature covers the exact revision and each original chore identity. |
| 231 | for _, field := range []string{"claim_gen", "claim_etag", "op", "retirement"} { |
| 232 | var envelope struct { |
| 233 | Payload string `json:"payload"` |
| 234 | Sig string `json:"sig"` |
| 235 | } |
| 236 | if err := json.Unmarshal(wire, &envelope); err != nil { |
| 237 | t.Fatal(err) |
| 238 | } |
| 239 | var payload map[string]any |
| 240 | raw, err := base64.StdEncoding.DecodeString(envelope.Payload) |
| 241 | if err != nil { |
| 242 | t.Fatal(err) |
| 243 | } |
| 244 | if err := json.Unmarshal(raw, &payload); err != nil { |
| 245 | t.Fatal(err) |
| 246 | } |
| 247 | switch field { |
| 248 | case "claim_gen": |
| 249 | payload[field] = float64(c.ClaimGen + 1) |
| 250 | case "claim_etag": |
| 251 | payload[field] = "other-etag" |
| 252 | default: |
| 253 | refs := payload["consequences"].(map[string]any)["discharge_local"].([]any) |
| 254 | refs[0].(map[string]any)[field] = "other-identity" |
| 255 | } |
| 256 | raw, _ = json.Marshal(payload) |
| 257 | envelope.Payload = base64.StdEncoding.EncodeToString(raw) |
| 258 | tampered, _ := json.Marshal(envelope) |
| 259 | if _, err := VerifyChallenge(pub, tampered, now); !errors.Is(err, ErrChallenge) { |
| 260 | t.Fatalf("changed %s retained signature: %v", field, err) |
| 261 | } |
| 262 | } |
| 263 | } |
| 264 | |