boxid: a box record verifies only under AWS's signature, for its own instance
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
9 files changed,
+516
−0
internal/boxid/boxid_test.go+213 −0internal/boxid/certs.go+43 −0internal/boxid/certs/README.md+7 −0internal/boxid/certs/ap-southeast-2.pem+19 −0internal/boxid/certs/eu-west-1.pem+19 −0internal/boxid/certs/eu-west-2.pem+19 −0internal/boxid/certs/us-east-1.pem+19 −0internal/boxid/record.go+93 −0internal/boxid/verify.go+84 −0
diff --git a/internal/boxid/boxid_test.go b/internal/boxid/boxid_test.go| new file mode 100644 |
| index 000000000..bf3c0c6e9 |
| --- /dev/null |
| +++ b/internal/boxid/boxid_test.go |
| @@ -0,0 +1,213 @@ |
| +package boxid |
| + |
| +import ( |
| + "context" |
| + "crypto" |
| + "crypto/rand" |
| + "crypto/rsa" |
| + "crypto/sha256" |
| + "crypto/x509" |
| + "crypto/x509/pkix" |
| + "encoding/base64" |
| + "encoding/hex" |
| + "encoding/json" |
| + "errors" |
| + "math/big" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// signer stands in for AWS: one key and certificate per test. |
| +type signer struct { |
| + key *rsa.PrivateKey |
| + cert *x509.Certificate |
| +} |
| + |
| +func newSigner(t *testing.T) signer { |
| + t.Helper() |
| + key, err := rsa.GenerateKey(rand.Reader, 2048) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + tmpl := &x509.Certificate{SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: "test"}, |
| + NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(time.Hour)} |
| + der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + cert, err := x509.ParseCertificate(der) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return signer{key, cert} |
| +} |
| + |
| +func (s signer) record(t *testing.T, box, account, region, instance string) (Record, []byte) { |
| + t.Helper() |
| + doc, _ := json.Marshal(map[string]string{"accountId": account, "region": region, "instanceId": instance}) |
| + sum := sha256.Sum256(doc) |
| + sig, err := rsa.SignPKCS1v15(rand.Reader, s.key, crypto.SHA256, sum[:]) |
| + if err != nil { |
| + 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"} |
| + raw, _ := json.Marshal(rec) |
| + return rec, raw |
| +} |
| + |
| +func (s signer) verifier() Verifier { |
| + return Verifier{Account: "111122223333", Certs: map[string]*x509.Certificate{"eu-west-1": s.cert}} |
| +} |
| + |
| +func TestAutoScalingBoxVerifiesWhenIDIsItsInstance(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) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if id.InstanceID != "i-0abc" || id.Region != "eu-west-1" || id.AccountID != "111122223333" { |
| + t.Fatalf("identity = %+v", id) |
| + } |
| +} |
| + |
| +// 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) |
| + } |
| +} |
| + |
| +func TestRefusals(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") |
| + 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" |
| + 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 { |
| + 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) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +func TestLegacyLabelNeedsABindNamingThisExactRecord(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) |
| + } |
| + 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) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +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") |
| + 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.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 { |
| + 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) |
| + } |
| +} |
| + |
| +// 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) { |
| + 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) |
| + } |
| + if _, _, _, err := Load(ctx, st, "i-0bbb"); !errors.Is(err, ErrUnverified) { |
| + t.Fatalf("err = %v, want ErrUnverified", err) |
| + } |
| +} |
| + |
| +func TestEmbeddedCertsCoverTheFlagshipRegions(t *testing.T) { |
| + certs, err := EmbeddedCerts() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, region := range []string{"eu-west-1", "eu-west-2", "us-east-1", "ap-southeast-2"} { |
| + c := certs[region] |
| + if c == nil { |
| + t.Errorf("no certificate for %s", region) |
| + continue |
| + } |
| + if c.PublicKeyAlgorithm != x509.RSA { |
| + t.Errorf("%s: %v key; the base64-signature certificate is RSA (the PKCS7 one is DSA: wrong tab)", region, c.PublicKeyAlgorithm) |
| + } |
| + } |
| +} |
diff --git a/internal/boxid/certs.go b/internal/boxid/certs.go| new file mode 100644 |
| index 000000000..292d7876f |
| --- /dev/null |
| +++ b/internal/boxid/certs.go |
| @@ -0,0 +1,43 @@ |
| +package boxid |
| + |
| +import ( |
| + "crypto/x509" |
| + "embed" |
| + "encoding/pem" |
| + "fmt" |
| + "strings" |
| +) |
| + |
| +//go:embed certs/*.pem |
| +var certFS embed.FS |
| + |
| +// EmbeddedCerts returns AWS's instance-identity signing certificates by |
| +// region, from certs/<aws-region>.pem. A region with no file has no |
| +// certificate, so its boxes never verify: that is the safe default. |
| +func EmbeddedCerts() (map[string]*x509.Certificate, error) { |
| + entries, err := certFS.ReadDir("certs") |
| + if err != nil { |
| + return nil, err |
| + } |
| + out := map[string]*x509.Certificate{} |
| + for _, e := range entries { |
| + region, ok := strings.CutSuffix(e.Name(), ".pem") |
| + if !ok { |
| + continue |
| + } |
| + raw, err := certFS.ReadFile("certs/" + e.Name()) |
| + if err != nil { |
| + return nil, err |
| + } |
| + block, _ := pem.Decode(raw) |
| + if block == nil { |
| + return nil, fmt.Errorf("boxid: certs/%s is not PEM", e.Name()) |
| + } |
| + cert, err := x509.ParseCertificate(block.Bytes) |
| + if err != nil { |
| + return nil, fmt.Errorf("boxid: certs/%s: %w", e.Name(), err) |
| + } |
| + out[region] = cert |
| + } |
| + return out, nil |
| +} |
diff --git a/internal/boxid/certs/README.md b/internal/boxid/certs/README.md| new file mode 100644 |
| index 000000000..2993de1a6 |
| --- /dev/null |
| +++ b/internal/boxid/certs/README.md |
| @@ -0,0 +1,7 @@ |
| +AWS's public certificates for the base64-encoded instance identity |
| +signature (IMDS `/latest/dynamic/instance-identity/signature`), one per |
| +region, from |
| +https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/regions-certs.html |
| +(the RSA certificate (the one for the base64-encoded signature; not DSA, not RSA-2048)). A region with no file here has no |
| +certificate, and a box there never verifies. Add a region's file before |
| +running a second box in it. |
diff --git a/internal/boxid/certs/ap-southeast-2.pem b/internal/boxid/certs/ap-southeast-2.pem| new file mode 100644 |
| index 000000000..1411c269a |
| --- /dev/null |
| +++ b/internal/boxid/certs/ap-southeast-2.pem |
| @@ -0,0 +1,19 @@ |
| +-----BEGIN CERTIFICATE----- |
| +MIIDITCCAoqgAwIBAgIUFxWyAdk4oiXIOC9PxcgjYYh71mwwDQYJKoZIhvcNAQEL |
| +BQAwXDELMAkGA1UEBhMCVVMxGTAXBgNVBAgTEFdhc2hpbmd0b24gU3RhdGUxEDAO |
| +BgNVBAcTB1NlYXR0bGUxIDAeBgNVBAoTF0FtYXpvbiBXZWIgU2VydmljZXMgTExD |
| +MB4XDTI0MDQyOTE1MjE0M1oXDTI5MDQyODE1MjE0M1owXDELMAkGA1UEBhMCVVMx |
| +GTAXBgNVBAgTEFdhc2hpbmd0b24gU3RhdGUxEDAOBgNVBAcTB1NlYXR0bGUxIDAe |
| +BgNVBAoTF0FtYXpvbiBXZWIgU2VydmljZXMgTExDMIGfMA0GCSqGSIb3DQEBAQUA |
| +A4GNADCBiQKBgQCHvRjf/0kStpJ248khtIaN8qkDN3tkw4VjvA9nvPl2anJO+eIB |
| +UqPfQG09kZlwpWpmyO8bGB2RWqWxCwuB/dcnIob6w420k9WY5C0IIGtDRNauN3ku |
| +vGXkw3HEnF0EjYr0pcyWUvByWY4KswZV42X7Y7XSS13hOIcL6NLA+H94/QIDAQAB |
| +o4HfMIHcMAsGA1UdDwQEAwIHgDAdBgNVHQ4EFgQUJdbMCBXKtvCcWdwUUizvtUF2 |
| +UTgwgZkGA1UdIwSBkTCBjoAUJdbMCBXKtvCcWdwUUizvtUF2UTihYKReMFwxCzAJ |
| +BgNVBAYTAlVTMRkwFwYDVQQIExBXYXNoaW5ndG9uIFN0YXRlMRAwDgYDVQQHEwdT |
| +ZWF0dGxlMSAwHgYDVQQKExdBbWF6b24gV2ViIFNlcnZpY2VzIExMQ4IUFxWyAdk4 |
| +oiXIOC9PxcgjYYh71mwwEgYDVR0TAQH/BAgwBgEB/wIBADANBgkqhkiG9w0BAQsF |
| +AAOBgQByjeQe6lr7fiIhoGdjBXYzDfkX0lGGvMIhRh57G1bbceQfaYdZd7Ptc0jl |
| +bpycKGaTvhUdkpMOiV2Hi9dOOYawkdhyJDstmDNKu6P9+b6Kak8He5z3NU1tUR2Y |
| +uTwcz7Ye8Nldx//ws3raErfTI7D6s9m63OX8cAJ/f8bNgikwpw== |
| +-----END CERTIFICATE----- |
diff --git a/internal/boxid/certs/eu-west-1.pem b/internal/boxid/certs/eu-west-1.pem| new file mode 100644 |
| index 000000000..6aec21c96 |
| --- /dev/null |
| +++ b/internal/boxid/certs/eu-west-1.pem |
| @@ -0,0 +1,19 @@ |
| +-----BEGIN CERTIFICATE----- |
| +MIIDITCCAoqgAwIBAgIUakDaQ1Zqy87Hy9ESXA1pFC116HkwDQYJKoZIhvcNAQEL |
| +BQAwXDELMAkGA1UEBhMCVVMxGTAXBgNVBAgTEFdhc2hpbmd0b24gU3RhdGUxEDAO |
| +BgNVBAcTB1NlYXR0bGUxIDAeBgNVBAoTF0FtYXpvbiBXZWIgU2VydmljZXMgTExD |
| +MB4XDTI0MDQyOTE2MTgxMFoXDTI5MDQyODE2MTgxMFowXDELMAkGA1UEBhMCVVMx |
| +GTAXBgNVBAgTEFdhc2hpbmd0b24gU3RhdGUxEDAOBgNVBAcTB1NlYXR0bGUxIDAe |
| +BgNVBAoTF0FtYXpvbiBXZWIgU2VydmljZXMgTExDMIGfMA0GCSqGSIb3DQEBAQUA |
| +A4GNADCBiQKBgQCHvRjf/0kStpJ248khtIaN8qkDN3tkw4VjvA9nvPl2anJO+eIB |
| +UqPfQG09kZlwpWpmyO8bGB2RWqWxCwuB/dcnIob6w420k9WY5C0IIGtDRNauN3ku |
| +vGXkw3HEnF0EjYr0pcyWUvByWY4KswZV42X7Y7XSS13hOIcL6NLA+H94/QIDAQAB |
| +o4HfMIHcMAsGA1UdDwQEAwIHgDAdBgNVHQ4EFgQUJdbMCBXKtvCcWdwUUizvtUF2 |
| +UTgwgZkGA1UdIwSBkTCBjoAUJdbMCBXKtvCcWdwUUizvtUF2UTihYKReMFwxCzAJ |
| +BgNVBAYTAlVTMRkwFwYDVQQIExBXYXNoaW5ndG9uIFN0YXRlMRAwDgYDVQQHEwdT |
| +ZWF0dGxlMSAwHgYDVQQKExdBbWF6b24gV2ViIFNlcnZpY2VzIExMQ4IUakDaQ1Zq |
| +y87Hy9ESXA1pFC116HkwEgYDVR0TAQH/BAgwBgEB/wIBADANBgkqhkiG9w0BAQsF |
| +AAOBgQADIkn/MqaLGPuK5+prZZ5Ox4bBZLPtreO2C7r0pqU2kPM2lVPyYYydkvP0 |
| +lgSmmsErGu/oL9JNztDe2oCA+kNy17ehcsf8cw0uP861czNFKCeU8b7FgBbL+sIm |
| +qi33rAq6owWGi/5uEcfCR+JP7W+oSYVir5r/yDmWzx+BVH5S/g== |
| +-----END CERTIFICATE----- |
diff --git a/internal/boxid/certs/eu-west-2.pem b/internal/boxid/certs/eu-west-2.pem| new file mode 100644 |
| index 000000000..7ef8e220c |
| --- /dev/null |
| +++ b/internal/boxid/certs/eu-west-2.pem |
| @@ -0,0 +1,19 @@ |
| +-----BEGIN CERTIFICATE----- |
| +MIIDITCCAoqgAwIBAgIUCgCV/DPxYNND/swDgEKGiC5I+EwwDQYJKoZIhvcNAQEL |
| +BQAwXDELMAkGA1UEBhMCVVMxGTAXBgNVBAgTEFdhc2hpbmd0b24gU3RhdGUxEDAO |
| +BgNVBAcTB1NlYXR0bGUxIDAeBgNVBAoTF0FtYXpvbiBXZWIgU2VydmljZXMgTExD |
| +MB4XDTI0MDQyOTE2MjkxNFoXDTI5MDQyODE2MjkxNFowXDELMAkGA1UEBhMCVVMx |
| +GTAXBgNVBAgTEFdhc2hpbmd0b24gU3RhdGUxEDAOBgNVBAcTB1NlYXR0bGUxIDAe |
| +BgNVBAoTF0FtYXpvbiBXZWIgU2VydmljZXMgTExDMIGfMA0GCSqGSIb3DQEBAQUA |
| +A4GNADCBiQKBgQCHvRjf/0kStpJ248khtIaN8qkDN3tkw4VjvA9nvPl2anJO+eIB |
| +UqPfQG09kZlwpWpmyO8bGB2RWqWxCwuB/dcnIob6w420k9WY5C0IIGtDRNauN3ku |
| +vGXkw3HEnF0EjYr0pcyWUvByWY4KswZV42X7Y7XSS13hOIcL6NLA+H94/QIDAQAB |
| +o4HfMIHcMAsGA1UdDwQEAwIHgDAdBgNVHQ4EFgQUJdbMCBXKtvCcWdwUUizvtUF2 |
| +UTgwgZkGA1UdIwSBkTCBjoAUJdbMCBXKtvCcWdwUUizvtUF2UTihYKReMFwxCzAJ |
| +BgNVBAYTAlVTMRkwFwYDVQQIExBXYXNoaW5ndG9uIFN0YXRlMRAwDgYDVQQHEwdT |
| +ZWF0dGxlMSAwHgYDVQQKExdBbWF6b24gV2ViIFNlcnZpY2VzIExMQ4IUCgCV/DPx |
| +YNND/swDgEKGiC5I+EwwEgYDVR0TAQH/BAgwBgEB/wIBADANBgkqhkiG9w0BAQsF |
| +AAOBgQATPu/sOE2esNa4+XPEGKlEJSgqzyBSQLQc+VWo6FAJhGG9fp7D97jhHeLC |
| +5vwfmtTAfnGBxadfAOT3ASkxnOZhXtnRna460LtnNHm7ArCVgXKJo7uBn6ViXtFh |
| +uEEw4y6p9YaLQna+VC8Xtgw6WKq2JXuKzuhuNKSFaGGw9vRcHg== |
| +-----END CERTIFICATE----- |
diff --git a/internal/boxid/certs/us-east-1.pem b/internal/boxid/certs/us-east-1.pem| new file mode 100644 |
| index 000000000..be318505b |
| --- /dev/null |
| +++ b/internal/boxid/certs/us-east-1.pem |
| @@ -0,0 +1,19 @@ |
| +-----BEGIN CERTIFICATE----- |
| +MIIDITCCAoqgAwIBAgIUE1y2NIKCU+Rg4uu4u32koG9QEYIwDQYJKoZIhvcNAQEL |
| +BQAwXDELMAkGA1UEBhMCVVMxGTAXBgNVBAgTEFdhc2hpbmd0b24gU3RhdGUxEDAO |
| +BgNVBAcTB1NlYXR0bGUxIDAeBgNVBAoTF0FtYXpvbiBXZWIgU2VydmljZXMgTExD |
| +MB4XDTI0MDQyOTE3MzQwMVoXDTI5MDQyODE3MzQwMVowXDELMAkGA1UEBhMCVVMx |
| +GTAXBgNVBAgTEFdhc2hpbmd0b24gU3RhdGUxEDAOBgNVBAcTB1NlYXR0bGUxIDAe |
| +BgNVBAoTF0FtYXpvbiBXZWIgU2VydmljZXMgTExDMIGfMA0GCSqGSIb3DQEBAQUA |
| +A4GNADCBiQKBgQCHvRjf/0kStpJ248khtIaN8qkDN3tkw4VjvA9nvPl2anJO+eIB |
| +UqPfQG09kZlwpWpmyO8bGB2RWqWxCwuB/dcnIob6w420k9WY5C0IIGtDRNauN3ku |
| +vGXkw3HEnF0EjYr0pcyWUvByWY4KswZV42X7Y7XSS13hOIcL6NLA+H94/QIDAQAB |
| +o4HfMIHcMAsGA1UdDwQEAwIHgDAdBgNVHQ4EFgQUJdbMCBXKtvCcWdwUUizvtUF2 |
| +UTgwgZkGA1UdIwSBkTCBjoAUJdbMCBXKtvCcWdwUUizvtUF2UTihYKReMFwxCzAJ |
| +BgNVBAYTAlVTMRkwFwYDVQQIExBXYXNoaW5ndG9uIFN0YXRlMRAwDgYDVQQHEwdT |
| +ZWF0dGxlMSAwHgYDVQQKExdBbWF6b24gV2ViIFNlcnZpY2VzIExMQ4IUE1y2NIKC |
| +U+Rg4uu4u32koG9QEYIwEgYDVR0TAQH/BAgwBgEB/wIBADANBgkqhkiG9w0BAQsF |
| +AAOBgQAlxSmwcWnhT4uAeSinJuz+1BTcKhVSWb5jT8pYjQb8ZoZkXXRGb09mvYeU |
| +NeqOBr27rvRAnaQ/9LUQf72+SahDFuS4CMI8nwowytqbmwquqFr4dxA/SDADyRiF |
| +ea1UoMuNHTY49J/1vPomqsVn7mugTp+TbjqCfOJTpu0temHcFA== |
| +-----END CERTIFICATE----- |
diff --git a/internal/boxid/record.go b/internal/boxid/record.go| new file mode 100644 |
| index 000000000..a743ca0ea |
| --- /dev/null |
| +++ b/internal/boxid/record.go |
| @@ -0,0 +1,93 @@ |
| +// 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 |
| + |
| +import ( |
| + "bytes" |
| + "context" |
| + "encoding/json" |
| + "errors" |
| + "fmt" |
| + "os" |
| + |
| + "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" } |
| + |
| +type Record struct { |
| + V int `json:"v"` |
| + Box string `json:"box"` |
| + IdentityDocument string `json:"identity_document"` |
| + IdentitySignature string `json:"identity_signature"` |
| + 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 { |
| + body, err := json.Marshal(rec) |
| + if err != nil { |
| + return fmt.Errorf("boxid: marshal: %w", err) |
| + } |
| + _, err = s.Put(ctx, Key(rec.Box), body, "") |
| + if !errors.Is(err, release.ErrCASConflict) { |
| + return err |
| + } |
| + cur, _, gerr := s.Get(ctx, Key(rec.Box)) |
| + if gerr != nil { |
| + return fmt.Errorf("boxid: %s exists and cannot be read: %w", rec.Box, gerr) |
| + } |
| + if bytes.Equal(cur, body) { |
| + return nil |
| + } |
| + 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) |
| + } |
| + 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 |
| +} |
diff --git a/internal/boxid/verify.go b/internal/boxid/verify.go| new file mode 100644 |
| index 000000000..52f922971 |
| --- /dev/null |
| +++ b/internal/boxid/verify.go |
| @@ -0,0 +1,84 @@ |
| +package boxid |
| + |
| +import ( |
| + "crypto/sha256" |
| + "crypto/x509" |
| + "encoding/base64" |
| + "encoding/hex" |
| + "encoding/json" |
| + "errors" |
| + "fmt" |
| +) |
| + |
| +// ErrUnverified wraps every reason a record is not evidence of identity. |
| +var ErrUnverified = errors.New("boxid: record does not verify") |
| + |
| +type Identity struct{ AccountID, Region, InstanceID string } |
| + |
| +type Verifier struct { |
| + Account string |
| + Certs map[string]*x509.Certificate |
| +} |
| + |
| +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") |
| + } |
| + doc, err := base64.StdEncoding.DecodeString(rec.IdentityDocument) |
| + if err != nil { |
| + return Identity{}, refuse("document is not base64") |
| + } |
| + sig, err := base64.StdEncoding.DecodeString(rec.IdentitySignature) |
| + if err != nil { |
| + return Identity{}, refuse("signature is not base64") |
| + } |
| + var d struct { |
| + AccountID string `json:"accountId"` |
| + Region string `json:"region"` |
| + InstanceID string `json:"instanceId"` |
| + } |
| + if err := json.Unmarshal(doc, &d); err != nil { |
| + return Identity{}, refuse("document is not JSON") |
| + } |
| + cert := v.Certs[d.Region] |
| + if cert == nil { |
| + return Identity{}, 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) |
| + } |
| + id := Identity{AccountID: d.AccountID, Region: d.Region, InstanceID: d.InstanceID} |
| + if rec.Box == d.InstanceID { |
| + return id, nil |
| + } |
| + if bind == nil { |
| + return Identity{}, refuse("%q is a legacy label with no operator bind", rec.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 |
| +} |