boxid: a box record is enrollment evidence, and one instance never holds two references
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
3 files changed,
+237
−174
internal/boxid/boxid_test.go+98 −90internal/boxid/record.go+24 −46internal/boxid/verify.go+115 −38
diff --git a/internal/boxid/boxid_test.go b/internal/boxid/boxid_test.go| index bf3c0c6e9..f153f4c49 100644 |
| --- a/internal/boxid/boxid_test.go |
| +++ b/internal/boxid/boxid_test.go |
| @@ -13,6 +13,7 @@ import ( |
| "encoding/json" |
| "errors" |
| "math/big" |
| + "os" |
| "testing" |
| "time" |
| @@ -53,146 +54,153 @@ func (s signer) record(t *testing.T, box, account, region, instance string) (Rec |
| t.Fatal(err) |
| } |
| rec := Record{V: 1, Box: box, IdentityDocument: base64.StdEncoding.EncodeToString(doc), |
| - IdentitySignature: base64.StdEncoding.EncodeToString(sig), WrittenAt: "2026-10-05T00:00:00Z"} |
| + IdentitySignature: base64.StdEncoding.EncodeToString(sig), WrittenAt: "2026-10-06T00:00:00Z"} |
| raw, _ := json.Marshal(rec) |
| return rec, raw |
| } |
| +// verifier certifies eu-west-1 and eu-west-2 with the same key, so a test |
| +// can show that a supported but wrong region is still refused. |
| func (s signer) verifier() Verifier { |
| - return Verifier{Account: "111122223333", Certs: map[string]*x509.Certificate{"eu-west-1": s.cert}} |
| + return Verifier{Account: "111122223333", Certs: map[string]*x509.Certificate{"eu-west-1": s.cert, "eu-west-2": s.cert}} |
| } |
| -func TestAutoScalingBoxVerifiesWhenIDIsItsInstance(t *testing.T) { |
| +func expect(box string) Expect { return Expect{Box: box, AWSRegion: "eu-west-1"} } |
| + |
| +func TestEnrollProvesTheCloudOfALabel(t *testing.T) { |
| s := newSigner(t) |
| - rec, raw := s.record(t, "i-0abc", "111122223333", "eu-west-1", "i-0abc") |
| - id, err := s.verifier().Verify(rec, raw, nil) |
| + _, raw := s.record(t, "annie-welding", "111122223333", "eu-west-1", "i-0abc") |
| + ev, err := s.verifier().Enroll(raw, expect("annie-welding")) |
| if err != nil { |
| t.Fatal(err) |
| } |
| - if id.InstanceID != "i-0abc" || id.Region != "eu-west-1" || id.AccountID != "111122223333" { |
| - t.Fatalf("identity = %+v", id) |
| + sum := sha256.Sum256(raw) |
| + want := Evidence{Box: "annie-welding", RecordSHA256: hex.EncodeToString(sum[:]), |
| + Cloud: Cloud{Provider: "aws", Account: "111122223333", Region: "eu-west-1", InstanceID: "i-0abc"}} |
| + if ev != want { |
| + t.Fatalf("evidence = %+v\nwant %+v", ev, want) |
| } |
| -} |
| - |
| -// Review Focus 5: a valid document for instance A under box id B. |
| -func TestRecordUnderAnotherInstancesIDIsRefused(t *testing.T) { |
| - s := newSigner(t) |
| - rec, raw := s.record(t, "i-0other", "111122223333", "eu-west-1", "i-0abc") |
| - if _, err := s.verifier().Verify(rec, raw, nil); !errors.Is(err, ErrUnverified) { |
| - t.Fatalf("err = %v, want ErrUnverified", err) |
| + if !ev.Cloud.Complete() { |
| + t.Fatal("a proven cloud is not complete") |
| } |
| } |
| -func TestRefusals(t *testing.T) { |
| +func TestEnrollRefusals(t *testing.T) { |
| s, other := newSigner(t), newSigner(t) |
| - cases := map[string]func() (Record, []byte, Verifier){ |
| - "wrong account": func() (Record, []byte, Verifier) { |
| - r, raw := s.record(t, "i-0abc", "999999999999", "eu-west-1", "i-0abc") |
| - return r, raw, s.verifier() |
| - }, |
| - "region with no certificate": func() (Record, []byte, Verifier) { |
| - r, raw := s.record(t, "i-0abc", "111122223333", "ap-south-1", "i-0abc") |
| - return r, raw, s.verifier() |
| - }, |
| - "signed by someone else": func() (Record, []byte, Verifier) { |
| - r, raw := other.record(t, "i-0abc", "111122223333", "eu-west-1", "i-0abc") |
| - return r, raw, s.verifier() |
| - }, |
| - "document altered after signing": func() (Record, []byte, Verifier) { |
| - r, _ := s.record(t, "i-0abc", "111122223333", "eu-west-1", "i-0abc") |
| + good := func() []byte { |
| + _, raw := s.record(t, "annie-welding", "111122223333", "eu-west-1", "i-0abc") |
| + return raw |
| + } |
| + cases := map[string]struct { |
| + raw []byte |
| + e Expect |
| + }{ |
| + "wrong account": {func() []byte { |
| + _, raw := s.record(t, "annie-welding", "999999999999", "eu-west-1", "i-0abc") |
| + return raw |
| + }(), expect("annie-welding")}, |
| + "region with no certificate": {func() []byte { |
| + _, raw := s.record(t, "annie-welding", "111122223333", "ap-south-1", "i-0abc") |
| + return raw |
| + }(), Expect{Box: "annie-welding", AWSRegion: "ap-south-1"}}, |
| + "a supported region that is not the placement's": {func() []byte { |
| + _, raw := s.record(t, "annie-welding", "111122223333", "eu-west-2", "i-0abc") |
| + return raw |
| + }(), expect("annie-welding")}, |
| + "signed by someone else": {func() []byte { |
| + _, raw := other.record(t, "annie-welding", "111122223333", "eu-west-1", "i-0abc") |
| + return raw |
| + }(), expect("annie-welding")}, |
| + "document altered after signing": {func() []byte { |
| + r, _ := s.record(t, "annie-welding", "111122223333", "eu-west-1", "i-0abc") |
| doc, _ := json.Marshal(map[string]string{"accountId": "111122223333", "region": "eu-west-1", "instanceId": "i-0zzz"}) |
| - r.IdentityDocument, r.Box = base64.StdEncoding.EncodeToString(doc), "i-0zzz" |
| + r.IdentityDocument = base64.StdEncoding.EncodeToString(doc) |
| raw, _ := json.Marshal(r) |
| - return r, raw, s.verifier() |
| - }, |
| - "empty box": func() (Record, []byte, Verifier) { |
| - r, raw := s.record(t, "", "111122223333", "eu-west-1", "i-0abc") |
| - return r, raw, s.verifier() |
| - }, |
| - } |
| - for name, mk := range cases { |
| + return raw |
| + }(), expect("annie-welding")}, |
| + "record names another box": {good(), expect("bert-sawing")}, |
| + "reference is not a label": {good(), expect("annie.welding")}, |
| + "no expected region": {good(), Expect{Box: "annie-welding"}}, |
| + "tofu names another instance": {good(), Expect{Box: "annie-welding", AWSRegion: "eu-west-1", TofuInstanceID: "i-0zzz"}}, |
| + "unknown field in the record": {[]byte(`{"v":1,"box":"annie-welding","extra":1}`), expect("annie-welding")}, |
| + "trailing data": {append(good(), []byte(`{}`)...), expect("annie-welding")}, |
| + "version 2": {func() []byte { |
| + r, _ := s.record(t, "annie-welding", "111122223333", "eu-west-1", "i-0abc") |
| + r.V = 2 |
| + raw, _ := json.Marshal(r) |
| + return raw |
| + }(), expect("annie-welding")}, |
| + "reference enrolled elsewhere": {good(), Expect{Box: "annie-welding", AWSRegion: "eu-west-1", Roster: []Enrolled{{Box: "annie-welding", Cloud: Cloud{InstanceID: "i-0old"}}}}}, |
| + } |
| + for name, tc := range cases { |
| t.Run(name, func(t *testing.T) { |
| - r, raw, v := mk() |
| - if _, err := v.Verify(r, raw, nil); !errors.Is(err, ErrUnverified) { |
| - t.Fatalf("err = %v, want ErrUnverified", err) |
| + if ev, err := s.verifier().Enroll(tc.raw, tc.e); !errors.Is(err, ErrUnverified) { |
| + t.Fatalf("evidence = %+v, err = %v; want ErrUnverified", ev, err) |
| } |
| }) |
| } |
| } |
| -func TestLegacyLabelNeedsABindNamingThisExactRecord(t *testing.T) { |
| +// Review Focus 1: box A's record, intact and validly signed, copied under |
| +// box B's key with its box field rewritten. The signature still verifies, |
| +// because the box field is not signed. Only the roster check refuses it. |
| +func TestEnrollRefusesAnInstanceAlreadyEnrolledUnderAnotherReference(t *testing.T) { |
| s := newSigner(t) |
| - rec, raw := s.record(t, "annie-welding", "111122223333", "eu-west-1", "i-0abc") |
| - if _, err := s.verifier().Verify(rec, raw, nil); !errors.Is(err, ErrUnverified) { |
| - t.Fatalf("no bind: err = %v, want ErrUnverified", err) |
| + _, raw := s.record(t, "bert-sawing", "111122223333", "eu-west-1", "i-0abc") |
| + roster := []Enrolled{{Box: "annie-welding", Cloud: Cloud{Provider: "aws", Account: "111122223333", Region: "eu-west-1", InstanceID: "i-0abc"}}} |
| + if _, err := s.verifier().Enroll(raw, Expect{Box: "bert-sawing", AWSRegion: "eu-west-1", Roster: roster}); !errors.Is(err, ErrUnverified) { |
| + t.Fatalf("err = %v, want ErrUnverified", err) |
| } |
| - sum := sha256.Sum256(raw) |
| - good := &Bind{RecordSHA256: hex.EncodeToString(sum[:]), InstanceID: "i-0abc", TofuInstanceID: "i-0abc", Operator: "paul", At: "2026-10-05T00:00:00Z"} |
| - if _, err := s.verifier().Verify(rec, raw, good); err != nil { |
| - t.Fatalf("good bind: %v", err) |
| - } |
| - for name, b := range map[string]Bind{ |
| - "another record's hash": {RecordSHA256: "00", InstanceID: "i-0abc", TofuInstanceID: "i-0abc", Operator: "paul"}, |
| - "tofu names another box": {RecordSHA256: good.RecordSHA256, InstanceID: "i-0abc", TofuInstanceID: "i-0zzz", Operator: "paul"}, |
| - "bind names another box": {RecordSHA256: good.RecordSHA256, InstanceID: "i-0zzz", TofuInstanceID: "i-0zzz", Operator: "paul"}, |
| - "no operator": {RecordSHA256: good.RecordSHA256, InstanceID: "i-0abc", TofuInstanceID: "i-0abc"}, |
| - } { |
| - b := b |
| - t.Run(name, func(t *testing.T) { |
| - if _, err := s.verifier().Verify(rec, raw, &b); !errors.Is(err, ErrUnverified) { |
| - t.Fatalf("err = %v, want ErrUnverified", err) |
| - } |
| - }) |
| + // The same box re-enrolled as the same instance is not a conflict. |
| + roster[0].Box = "bert-sawing" |
| + if _, err := s.verifier().Enroll(raw, Expect{Box: "bert-sawing", AWSRegion: "eu-west-1", Roster: roster}); err != nil { |
| + t.Fatalf("re-enrolling the same instance: %v", err) |
| + } |
| +} |
| + |
| +func TestEnrollAcceptsTheTofuInstanceForALegacyReference(t *testing.T) { |
| + s := newSigner(t) |
| + _, raw := s.record(t, "annie-welding", "111122223333", "eu-west-1", "i-0abc") |
| + if _, err := s.verifier().Enroll(raw, Expect{Box: "annie-welding", AWSRegion: "eu-west-1", TofuInstanceID: "i-0abc"}); err != nil { |
| + t.Fatal(err) |
| } |
| } |
| func TestPublishIsCreateOnlyAndIdempotent(t *testing.T) { |
| ctx, st, s := context.Background(), release.NewDirStore(t.TempDir()), newSigner(t) |
| - rec, _ := s.record(t, "i-0abc", "111122223333", "eu-west-1", "i-0abc") |
| + rec, _ := s.record(t, "annie-welding", "111122223333", "eu-west-1", "i-0abc") |
| if err := Publish(ctx, st, rec); err != nil { |
| t.Fatal(err) |
| } |
| if err := Publish(ctx, st, rec); err != nil { |
| t.Fatalf("republishing the same record: %v", err) |
| } |
| - other, _ := s.record(t, "i-0abc", "111122223333", "eu-west-1", "i-0abc") |
| + other := rec |
| other.WrittenAt = "2027-01-01T00:00:00Z" |
| if err := Publish(ctx, st, other); !errors.Is(err, release.ErrCASConflict) { |
| t.Fatalf("a different record for the same box: err = %v, want ErrCASConflict", err) |
| } |
| - got, raw, bind, err := Load(ctx, st, "i-0abc") |
| - if err != nil || got.WrittenAt != rec.WrittenAt || len(raw) == 0 || bind != nil { |
| - t.Fatalf("Load = %+v, %d bytes, bind %v, err %v", got, len(raw), bind, err) |
| - } |
| -} |
| - |
| -func TestLoadReadsABindWhenThereIsOne(t *testing.T) { |
| - ctx, st, s := context.Background(), release.NewDirStore(t.TempDir()), newSigner(t) |
| - rec, _ := s.record(t, "annie-welding", "111122223333", "eu-west-1", "i-0abc") |
| - if err := Publish(ctx, st, rec); err != nil { |
| - t.Fatal(err) |
| - } |
| - body, _ := json.Marshal(Bind{RecordSHA256: "aa", InstanceID: "i-0abc", TofuInstanceID: "i-0abc", Operator: "paul"}) |
| - if _, err := st.Put(ctx, BindKey("annie-welding"), body, ""); err != nil { |
| + raw, err := Load(ctx, st, "annie-welding") |
| + if err != nil { |
| t.Fatal(err) |
| } |
| - _, _, bind, err := Load(ctx, st, "annie-welding") |
| - if err != nil || bind == nil || bind.Operator != "paul" { |
| - t.Fatalf("bind = %+v, err %v", bind, err) |
| + if _, err := s.verifier().Enroll(raw, expect("annie-welding")); err != nil { |
| + t.Fatalf("the published record does not enroll: %v", err) |
| } |
| } |
| -// Review Focus 5, the storage half: box A's own record, unchanged and |
| -// validly signed, stored under box B's key must not load as B. |
| -func TestLoadRefusesAnotherBoxesRecordUnderThisKey(t *testing.T) { |
| +func TestPublishAndLoadRefuseAReferenceThatIsNotALabel(t *testing.T) { |
| ctx, st, s := context.Background(), release.NewDirStore(t.TempDir()), newSigner(t) |
| - _, raw := s.record(t, "i-0aaa", "111122223333", "eu-west-1", "i-0aaa") |
| - if _, err := st.Put(ctx, Key("i-0bbb"), raw, ""); err != nil { |
| - t.Fatal(err) |
| + rec, _ := s.record(t, "x.bind", "111122223333", "eu-west-1", "i-0abc") |
| + if err := Publish(ctx, st, rec); err == nil { |
| + t.Fatal("published under a reference with a dot") |
| } |
| - if _, _, _, err := Load(ctx, st, "i-0bbb"); !errors.Is(err, ErrUnverified) { |
| + if _, err := Load(ctx, st, "../edges/x"); !errors.Is(err, ErrUnverified) { |
| t.Fatalf("err = %v, want ErrUnverified", err) |
| } |
| + if _, err := Load(ctx, st, "nobody-here"); !errors.Is(err, os.ErrNotExist) { |
| + t.Fatalf("err = %v, want os.ErrNotExist", err) |
| + } |
| } |
| func TestEmbeddedCertsCoverTheFlagshipRegions(t *testing.T) { |
diff --git a/internal/boxid/record.go b/internal/boxid/record.go| index a743ca0ea..5bbcac646 100644 |
| --- a/internal/boxid/record.go |
| +++ b/internal/boxid/record.go |
| @@ -1,7 +1,10 @@ |
| -// Package boxid binds the name a claim uses for a box to one AWS instance |
| -// (region HA spec 1 §5.1). The binding is the box's own AWS-signed identity |
| -// document, written once. Nothing else is evidence of a box's identity: not |
| -// an edges file, not a status snapshot. |
| +// Package boxid is the evidence a platform box presents to be enrolled in |
| +// the roster (region HA spec 1 §5.1, ruling H11): the box's own AWS-signed |
| +// instance identity document, published once under its box reference. The |
| +// record grants nothing by itself. Enrollment verifies it (Verifier.Enroll) |
| +// and copies the proven account, region and instance id into the roster |
| +// entry's cloud field; from then on the roster, never this record, is what |
| +// binds a reference to an instance. |
| package boxid |
| import ( |
| @@ -10,15 +13,19 @@ import ( |
| "encoding/json" |
| "errors" |
| "fmt" |
| - "os" |
| + "github.com/carlosframework/platform/internal/controlname" |
| "github.com/carlosframework/platform/internal/release" |
| ) |
| const Prefix = "control/boxes/" |
| -func Key(box string) string { return Prefix + box + ".json" } |
| -func BindKey(box string) string { return Prefix + box + ".bind.json" } |
| +// ValidBox reports whether ref can be a box reference: one DNS label, so |
| +// it can never contain "." or "/" and no two references share a key. |
| +func ValidBox(ref string) bool { return controlname.ValidLabel(ref) } |
| + |
| +// Key is where a box's record lives. Callers check ValidBox first. |
| +func Key(box string) string { return Prefix + box + ".json" } |
| type Record struct { |
| V int `json:"v"` |
| @@ -28,20 +35,13 @@ type Record struct { |
| WrittenAt string `json:"written_at"` |
| } |
| -// Bind is an operator's statement that a legacy label belongs to one |
| -// instance. It names the exact record bytes it vouches for. |
| -type Bind struct { |
| - RecordSHA256 string `json:"record_sha256"` |
| - InstanceID string `json:"instance_id"` |
| - TofuInstanceID string `json:"tofu_instance_id"` |
| - Operator string `json:"operator"` |
| - At string `json:"at"` |
| -} |
| - |
| // Publish writes a box's record create-only. Writing the same bytes again |
| // succeeds; a different record for the same box is ErrCASConflict, because |
| // a record is never replaced. |
| func Publish(ctx context.Context, s release.Store, rec Record) error { |
| + if !ValidBox(rec.Box) { |
| + return fmt.Errorf("boxid: %q is not a box reference", rec.Box) |
| + } |
| body, err := json.Marshal(rec) |
| if err != nil { |
| return fmt.Errorf("boxid: marshal: %w", err) |
| @@ -60,34 +60,12 @@ func Publish(ctx context.Context, s release.Store, rec Record) error { |
| return fmt.Errorf("boxid: %s already has a different record: %w", rec.Box, release.ErrCASConflict) |
| } |
| -// Load returns a box's record, its exact bytes (which a Bind's hash is over) |
| -// and its bind when one exists. A missing record is os.ErrNotExist-wrapped. |
| -// A record that names a different box than the key it is stored under is |
| -// ErrUnverified: the key is part of the binding. |
| -func Load(ctx context.Context, s release.Store, box string) (Record, []byte, *Bind, error) { |
| - raw, _, err := s.Get(ctx, Key(box)) |
| - if err != nil { |
| - return Record{}, nil, nil, err |
| - } |
| - var rec Record |
| - if err := json.Unmarshal(raw, &rec); err != nil { |
| - return Record{}, nil, nil, fmt.Errorf("boxid: %s: %w", box, err) |
| +// Load returns the exact bytes of a box's record, which is what enrollment |
| +// verifies and hashes. A missing record is os.ErrNotExist-wrapped. |
| +func Load(ctx context.Context, s release.Store, box string) ([]byte, error) { |
| + if !ValidBox(box) { |
| + return nil, fmt.Errorf("%w: %q is not a box reference", ErrUnverified, box) |
| } |
| - if rec.Box != box { |
| - // Another box's record, intact and validly signed, copied under |
| - // this name. Verify would accept it as that other box. |
| - return Record{}, nil, nil, fmt.Errorf("%w: the record stored for %q names %q", ErrUnverified, box, rec.Box) |
| - } |
| - braw, _, err := s.Get(ctx, BindKey(box)) |
| - if errors.Is(err, os.ErrNotExist) { |
| - return rec, raw, nil, nil |
| - } |
| - if err != nil { |
| - return Record{}, nil, nil, err |
| - } |
| - var b Bind |
| - if err := json.Unmarshal(braw, &b); err != nil { |
| - return Record{}, nil, nil, fmt.Errorf("boxid: %s bind: %w", box, err) |
| - } |
| - return rec, raw, &b, nil |
| + raw, _, err := s.Get(ctx, Key(box)) |
| + return raw, err |
| } |
diff --git a/internal/boxid/verify.go b/internal/boxid/verify.go| index 52f922971..033404a8d 100644 |
| --- a/internal/boxid/verify.go |
| +++ b/internal/boxid/verify.go |
| @@ -1,6 +1,7 @@ |
| package boxid |
| import ( |
| + "bytes" |
| "crypto/sha256" |
| "crypto/x509" |
| "encoding/base64" |
| @@ -8,38 +9,115 @@ import ( |
| "encoding/json" |
| "errors" |
| "fmt" |
| + "io" |
| ) |
| -// ErrUnverified wraps every reason a record is not evidence of identity. |
| +// ErrUnverified wraps every reason a record is not evidence for enrollment. |
| var ErrUnverified = errors.New("boxid: record does not verify") |
| +// Identity is what an identity document proves. termfence binds to it |
| +// until it moves to Cloud; nothing else uses it. |
| type Identity struct{ AccountID, Region, InstanceID string } |
| +// Cloud is the roster entry's cloud field (spec §2, §5.1): the instance a |
| +// box reference is enrolled as. Enrollment fills it from a verified record; |
| +// every later check (the termination fence, volume records) compares |
| +// against it and never against anything a box says about itself. |
| +type Cloud struct { |
| + Provider string `json:"provider"` |
| + Account string `json:"account"` |
| + Region string `json:"region"` |
| + InstanceID string `json:"instance_id"` |
| +} |
| + |
| +// Complete reports whether every field is set and the provider is AWS. |
| +func (c Cloud) Complete() bool { |
| + return c.Provider == "aws" && c.Account != "" && c.Region != "" && c.InstanceID != "" |
| +} |
| + |
| type Verifier struct { |
| - Account string |
| - Certs map[string]*x509.Certificate |
| + Account string // the deployment's AWS account id |
| + Certs map[string]*x509.Certificate // by AWS region |
| +} |
| + |
| +// Enrolled is one roster entry as enrollment needs to see it: its |
| +// reference and the instance it was enrolled as. Entries in every state |
| +// count, retired ones included: a retired reference never names a working |
| +// box again (spec §5.9), and a terminated instance never comes back. |
| +type Enrolled struct { |
| + Box string |
| + Cloud Cloud |
| +} |
| + |
| +// Expect is what the enrolling side knows before it reads the record. |
| +type Expect struct { |
| + Box string // the reference being enrolled |
| + AWSRegion string // the AWS region of the placement it is enrolled into |
| + // TofuInstanceID is the region's tofu region-host instance_id output. |
| + // The caller sets it when Box already appears in claims or edges files, |
| + // as a box predating region HA does, and leaves it "" otherwise. |
| + TofuInstanceID string |
| + Roster []Enrolled |
| +} |
| + |
| +// Evidence is what enrollment records: the roster entry's cloud, and the |
| +// SHA-256 of the exact record bytes it was proven from. |
| +type Evidence struct { |
| + Box string |
| + Cloud Cloud |
| + RecordSHA256 string |
| } |
| func refuse(format string, a ...any) error { |
| return fmt.Errorf("%w: %s", ErrUnverified, fmt.Sprintf(format, a...)) |
| } |
| -// Verify checks, in order: the signature under the certificate for the |
| -// document's own region, the deployment account, and the binding of the |
| -// record's box id to the signed instance id. A box whose id is its instance |
| -// id needs nothing more. Any other id is a legacy label and needs a bind |
| -// that names these exact record bytes and agrees on the instance. |
| -func (v Verifier) Verify(rec Record, raw []byte, bind *Bind) (Identity, error) { |
| - if rec.Box == "" { |
| - return Identity{}, refuse("no box id") |
| +func decodeRecord(raw []byte) (Record, error) { |
| + dec := json.NewDecoder(bytes.NewReader(raw)) |
| + dec.DisallowUnknownFields() |
| + var rec Record |
| + if err := dec.Decode(&rec); err != nil { |
| + return Record{}, refuse("record is not JSON: %v", err) |
| + } |
| + if _, err := dec.Token(); err != io.EOF { |
| + return Record{}, refuse("trailing data after the record") |
| + } |
| + return rec, nil |
| +} |
| + |
| +// Enroll checks raw, the exact bytes stored at Key(e.Box), as enrollment |
| +// evidence (spec §5.1, H11). In order: the reference, the record's own box |
| +// field, AWS's signature under the certificate for the document's region, |
| +// the deployment account, the AWS region expected for the placement, the |
| +// tofu instance id for a reference predating region HA, and the roster: |
| +// the reference enrolled as another instance, or the instance enrolled |
| +// under another reference, is refused. A record copied from one box to |
| +// another's key carries the first box's signed instance id, so the last |
| +// check is what keeps one machine from holding two references. |
| +func (v Verifier) Enroll(raw []byte, e Expect) (Evidence, error) { |
| + if !ValidBox(e.Box) { |
| + return Evidence{}, refuse("%q is not a box reference", e.Box) |
| + } |
| + if e.AWSRegion == "" { |
| + return Evidence{}, refuse("no expected AWS region for %s", e.Box) |
| + } |
| + rec, err := decodeRecord(raw) |
| + if err != nil { |
| + return Evidence{}, err |
| + } |
| + if rec.V != 1 { |
| + return Evidence{}, refuse("record version %d", rec.V) |
| + } |
| + if rec.Box != e.Box { |
| + return Evidence{}, refuse("the record stored for %q names %q", e.Box, rec.Box) |
| } |
| doc, err := base64.StdEncoding.DecodeString(rec.IdentityDocument) |
| if err != nil { |
| - return Identity{}, refuse("document is not base64") |
| + return Evidence{}, refuse("document is not base64") |
| } |
| sig, err := base64.StdEncoding.DecodeString(rec.IdentitySignature) |
| if err != nil { |
| - return Identity{}, refuse("signature is not base64") |
| + return Evidence{}, refuse("signature is not base64") |
| } |
| var d struct { |
| AccountID string `json:"accountId"` |
| @@ -47,38 +125,37 @@ func (v Verifier) Verify(rec Record, raw []byte, bind *Bind) (Identity, error) { |
| InstanceID string `json:"instanceId"` |
| } |
| if err := json.Unmarshal(doc, &d); err != nil { |
| - return Identity{}, refuse("document is not JSON") |
| + return Evidence{}, refuse("document is not JSON") |
| } |
| cert := v.Certs[d.Region] |
| if cert == nil { |
| - return Identity{}, refuse("no signing certificate for region %q", d.Region) |
| + return Evidence{}, refuse("no signing certificate for region %q", d.Region) |
| } |
| if err := cert.CheckSignature(x509.SHA256WithRSA, doc, sig); err != nil { |
| - return Identity{}, refuse("signature: %v", err) |
| - } |
| - if d.InstanceID == "" { |
| - return Identity{}, refuse("document names no instance") |
| - } |
| - if v.Account == "" || d.AccountID != v.Account { |
| - return Identity{}, refuse("account %q is not the deployment's", d.AccountID) |
| + return Evidence{}, refuse("signature: %v", err) |
| } |
| - id := Identity{AccountID: d.AccountID, Region: d.Region, InstanceID: d.InstanceID} |
| - if rec.Box == d.InstanceID { |
| - return id, nil |
| + switch { |
| + case d.InstanceID == "": |
| + return Evidence{}, refuse("document names no instance") |
| + case v.Account == "" || d.AccountID != v.Account: |
| + return Evidence{}, refuse("account %q is not the deployment's", d.AccountID) |
| + case d.Region != e.AWSRegion: |
| + return Evidence{}, refuse("the box is in %q, the placement is in %q", d.Region, e.AWSRegion) |
| + case e.TofuInstanceID != "" && e.TofuInstanceID != d.InstanceID: |
| + return Evidence{}, refuse("tofu records instance %q for this region, the record %q", e.TofuInstanceID, d.InstanceID) |
| } |
| - if bind == nil { |
| - return Identity{}, refuse("%q is a legacy label with no operator bind", rec.Box) |
| + for _, en := range e.Roster { |
| + if en.Box == e.Box && en.Cloud.InstanceID != d.InstanceID { |
| + return Evidence{}, refuse("%s is enrolled as instance %q", e.Box, en.Cloud.InstanceID) |
| + } |
| + if en.Box != e.Box && en.Cloud.InstanceID == d.InstanceID { |
| + return Evidence{}, refuse("instance %s is enrolled as %s", d.InstanceID, en.Box) |
| + } |
| } |
| sum := sha256.Sum256(raw) |
| - switch { |
| - case bind.RecordSHA256 != hex.EncodeToString(sum[:]): |
| - return Identity{}, refuse("bind is for a different record") |
| - case bind.InstanceID != d.InstanceID: |
| - return Identity{}, refuse("bind names instance %q, the record %q", bind.InstanceID, d.InstanceID) |
| - case bind.TofuInstanceID != d.InstanceID: |
| - return Identity{}, refuse("tofu records instance %q, the record %q", bind.TofuInstanceID, d.InstanceID) |
| - case bind.Operator == "": |
| - return Identity{}, refuse("bind names no operator") |
| - } |
| - return id, nil |
| + return Evidence{ |
| + Box: e.Box, |
| + Cloud: Cloud{Provider: "aws", Account: d.AccountID, Region: d.Region, InstanceID: d.InstanceID}, |
| + RecordSHA256: hex.EncodeToString(sum[:]), |
| + }, nil |
| } |