Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions .github/workflows/checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ jobs:
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: builder_hub_test
options: >-
--health-cmd pg_isready
--health-interval 10s
Expand All @@ -36,7 +37,7 @@ jobs:
uses: actions/checkout@v4

- name: Run migrations
run: for file in schema/*.sql; do psql "postgres://postgres:postgres@localhost:5432/postgres?sslmode=disable" -f $file; done
run: for file in schema/*.sql; do psql "postgres://postgres:postgres@localhost:5432/builder_hub_test?sslmode=disable" -f $file; done

- name: Run unit tests
run: make test-with-db
Expand Down Expand Up @@ -92,4 +93,4 @@ jobs:
run: |
curl --location --remote-name https://github.com/Orange-OpenSource/hurl/releases/download/6.1.1/hurl_6.1.1_amd64.deb
sudo dpkg -i hurl_6.1.1_amd64.deb
./scripts/ci/integration-test.sh
./scripts/ci/integration-test.sh
13 changes: 11 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,15 @@ dev-postgres-up: ## Start the PostgreSQL database for development
dev-postgres-down: ## Stop the PostgreSQL database for development
docker rm -f postgres-test

.PHONY: test-postgres-up
test-postgres-up: ## Start the dedicated disposable PostgreSQL test database
docker run -d --name builder-hub-postgres-test -p 5432:5432 -e POSTGRES_USER=postgres -e POSTGRES_PASSWORD=postgres -e POSTGRES_DB=builder_hub_test postgres
for file in schema/*.sql; do psql "postgres://postgres:postgres@localhost:5432/builder_hub_test?sslmode=disable" -f $file; done

.PHONY: test-postgres-down
test-postgres-down: ## Stop the dedicated disposable PostgreSQL test database
docker rm -f builder-hub-postgres-test

.PHONY: dev-docker-compose-up
dev-docker-compose-up: ## Start Docker compose
docker compose -f docker/docker-compose.yaml build
Expand Down Expand Up @@ -75,7 +84,7 @@ test: ## Run tests

.PHONY: test-with-db
test-with-db: ## Run tests including live database tests
RUN_DB_TESTS=1 go test -race ./...
RUN_DB_TESTS=1 TEST_POSTGRES_DSN="postgres://postgres:postgres@localhost:5432/builder_hub_test?sslmode=disable" go test -race ./...

.PHONY: lint
lint: ## Run linters
Expand Down Expand Up @@ -118,7 +127,7 @@ db-dump: ## Dump the database contents to file 'database.dump'
.PHONY: dev-db-setup
dev-db-setup: ## Create the basic database entries for testing and development
@printf "$(BLUE)Create the allow-all measurements $(NC)\n"
$(CURL) $(CURL_AUTH) --request POST --url http://localhost:8081/api/admin/v1/measurements --data '{"measurement_id": "test1","attestation_type": "test","measurements": {}}'
$(CURL) $(CURL_AUTH) --request POST --url http://localhost:8081/api/admin/v1/measurements --data '{"measurement_id": "test1","attestation_type": "dcap-tdx","measurements": {}}'

@printf "$(BLUE)Enable the measurements $(NC)\n"
$(CURL) $(CURL_AUTH) --request POST --url http://localhost:8081/api/admin/v1/measurements/activation/test1 --data '{"enabled": true}'
Expand Down
29 changes: 29 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,19 @@ Response: Array with currently allowed measurement JSONs

[testdata/get-measurements.json](https://github.com/flashbots/builder-config-hub/blob/main/testdata/get-measurements.json)

Authenticated instance requests receive two headers from the attestation proxy:

- `X-Flashbots-Attestation-Type`, containing `dcap-tdx`, `gcp-tdx`, `azure-tdx`, or `none`.
- `X-Flashbots-Measurement`, containing the expected policy selected by the proxy.

For example, a DCAP policy is represented as compact JSON with an array of accepted values per register:

```json
{"type":"dcap","measurements":{"0":["<MRTD SHA-384>"],"3":["<RTMR2 SHA-384>"]}}
```

Portable image policies use `"type": "image"`. If no attestation was provided, it will have `"type": "no_attestation"` and no `measurements` field. Legacy headers containing a plain register-to-string object are accepted for backwards compatibility.

---

## Admin Endpoints
Expand Down Expand Up @@ -253,6 +266,22 @@ To allow _any_ measurement, use an empty measurements field:
}
```

Portable DCAP policies use image-component hashes instead of register values. They are supported for both `dcap-tdx` and `gcp-tdx`; `dcap_image_hashes` and `measurements` cannot be used together.

```json
{
"measurement_id": "portable-image-v1",
"attestation_type": "dcap-tdx",
"dcap_image_hashes": {
"uki_authenticode": "111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111",
"kernel_authenticode": "222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222",
"cmdline_hash": "333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333",
"initrd_hash": "444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444",
"gpt_disk_guid_hash": "555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555"
}
}
```

### Enable/disable measurements

`POST /api/admin/v1/measurements/activation/{measurement_id}`
Expand Down
33 changes: 24 additions & 9 deletions adapters/database/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ func (s *Service) Close() error {

func (s *Service) GetActiveMeasurementsByType(ctx context.Context, attestationType string) ([]domain.Measurement, error) {
var measurements []Measurement
err := s.DB.SelectContext(ctx, &measurements, `SELECT * FROM measurements_whitelist WHERE is_active=true AND attestation_type=$1`, attestationType)
err := s.DB.SelectContext(ctx, &measurements, `SELECT * FROM measurements_whitelist WHERE is_active=true AND attestation_type=$1 ORDER BY id`, attestationType)
var domainMeasurements []domain.Measurement
for _, m := range measurements {
domainM, err := convertMeasurementToDomain(m)
Expand Down Expand Up @@ -76,7 +76,7 @@ func (s *Service) GetBuilderByIP(ip net.IP) (*domain.Builder, error) {
// GetActiveMeasurements retrieves all measurements
func (s *Service) GetActiveMeasurements(ctx context.Context) ([]domain.Measurement, error) {
var measurements []Measurement
err := s.DB.SelectContext(ctx, &measurements, `SELECT * FROM measurements_whitelist WHERE is_active=true`)
err := s.DB.SelectContext(ctx, &measurements, `SELECT * FROM measurements_whitelist WHERE is_active=true ORDER BY id`)
var domainMeasurements []domain.Measurement
for _, m := range measurements {
domainM, err := convertMeasurementToDomain(m)
Expand Down Expand Up @@ -245,14 +245,29 @@ func (s *Service) LogEvent(ctx context.Context, eventName, builderName, name str
}

func (s *Service) AddMeasurement(ctx context.Context, measurement domain.Measurement, enabled bool) error {
bts, err := json.Marshal(measurement.Measurement)
if err != nil {
return err
var measurementJSON any
var imageHashesJSON any
if measurement.DcapImageHashes != nil {
bts, err := json.Marshal(measurement.DcapImageHashes)
if err != nil {
return err
}
imageHashesJSON = bts
} else {
measurements := measurement.Measurement
if measurements == nil {
measurements = make(map[string]domain.SingleMeasurement)
}
bts, err := json.Marshal(measurements)
if err != nil {
return err
}
measurementJSON = bts
}
_, err = s.DB.ExecContext(ctx, `
INSERT INTO measurements_whitelist (name, attestation_type, measurement, is_active)
VALUES ($1, $2, $3, $4)
`, measurement.Name, measurement.AttestationType, bts, enabled)
_, err := s.DB.ExecContext(ctx, `
INSERT INTO measurements_whitelist (name, attestation_type, measurement, dcap_image_hashes, is_active)
VALUES ($1, $2, $3, $4, $5)
`, measurement.Name, measurement.AttestationType, measurementJSON, imageHashesJSON, enabled)
return err
}

Expand Down
75 changes: 60 additions & 15 deletions adapters/database/service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,21 +4,18 @@ import (
"context"
"encoding/json"
"net"
"os"
"strings"
"testing"

"github.com/flashbots/builder-hub/domain"
"github.com/flashbots/builder-hub/internal/testutil"
"github.com/stretchr/testify/require"
)

func TestGetBuilder(t *testing.T) {
if os.Getenv("RUN_DB_TESTS") != "1" {
t.Skip("skipping test; RUN_DB_TESTS is not set to 1")
}
serv, err := NewDatabaseService("postgres://postgres:postgres@localhost:5432/postgres?sslmode=disable")
if err != nil {
t.Errorf("NewDatabaseService() = %v; want nil", err)
}
serv, err := NewDatabaseService(testutil.PostgresDSN(t))
require.NoError(t, err)
testutil.RequireTestDatabase(t, serv.DB)
_, err = serv.DB.Exec("TRUNCATE TABLE public.builders CASCADE")
require.NoError(t, err)
_, err = serv.DB.Exec("TRUNCATE TABLE public.measurements_whitelist CASCADE")
Expand Down Expand Up @@ -48,14 +45,62 @@ func TestGetBuilder(t *testing.T) {
})
}

func TestAdminFlow(t *testing.T) {
if os.Getenv("RUN_DB_TESTS") != "1" {
t.Skip("skipping test; RUN_DB_TESTS is not set to 1")
}
dbService, err := NewDatabaseService("postgres://postgres:postgres@localhost:5432/postgres?sslmode=disable")
if err != nil {
t.Errorf("NewDatabaseService() = %v; want nil", err)
func TestPortableMeasurement(t *testing.T) {
dbService, err := NewDatabaseService(testutil.PostgresDSN(t))
require.NoError(t, err)
testutil.RequireTestDatabase(t, dbService.DB)
_, err = dbService.DB.Exec("TRUNCATE TABLE public.measurements_whitelist CASCADE")
require.NoError(t, err)

hashes := &domain.DcapImageHashes{
UKIAuthenticode: strings.Repeat("1", 96),
KernelAuthenticode: strings.Repeat("2", 96),
CmdlineHash: strings.Repeat("3", 96),
InitrdHash: strings.Repeat("4", 96),
GPTDiskGUIDHash: strings.Repeat("5", 96),
}
err = dbService.AddMeasurement(context.Background(), domain.Measurement{
Name: "portable-image",
AttestationType: "dcap-tdx",
DcapImageHashes: hashes,
}, true)
require.NoError(t, err)

measurements, err := dbService.GetActiveMeasurementsByType(context.Background(), "dcap-tdx")
require.NoError(t, err)
require.Len(t, measurements, 1)
require.Nil(t, measurements[0].Measurement)
require.Equal(t, hashes, measurements[0].DcapImageHashes)

t.Run("rejects missing policy representation", func(t *testing.T) {
_, err := dbService.DB.Exec(`
INSERT INTO measurements_whitelist (name, attestation_type, measurement, dcap_image_hashes)
VALUES ('missing-policy', 'dcap-tdx', NULL, NULL)
`)
require.Error(t, err)
})

t.Run("rejects JSON null policy representation", func(t *testing.T) {
_, err := dbService.DB.Exec(`
INSERT INTO measurements_whitelist (name, attestation_type, measurement, dcap_image_hashes)
VALUES ('json-null-policy', 'dcap-tdx', 'null'::jsonb, NULL)
`)
require.Error(t, err)
})

t.Run("rejects non-object policy representation", func(t *testing.T) {
_, err := dbService.DB.Exec(`
INSERT INTO measurements_whitelist (name, attestation_type, measurement, dcap_image_hashes)
VALUES ('array-policy', 'dcap-tdx', '[]'::jsonb, NULL)
`)
require.Error(t, err)
})
}

func TestAdminFlow(t *testing.T) {
dbService, err := NewDatabaseService(testutil.PostgresDSN(t))
require.NoError(t, err)
testutil.RequireTestDatabase(t, dbService.DB)
_, err = dbService.DB.Exec("TRUNCATE TABLE public.builders CASCADE")
require.NoError(t, err)
_, err = dbService.DB.Exec("TRUNCATE TABLE public.measurements_whitelist CASCADE")
Expand Down
32 changes: 20 additions & 12 deletions adapters/database/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,23 +10,31 @@ import (
)

type Measurement struct {
ID int `db:"id"`
Name string `db:"name"`
AttestationType string `db:"attestation_type"`
Measurement json.RawMessage `db:"measurement"`
IsActive bool `db:"is_active"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
DeprecatedAt *time.Time `db:"deprecated_at"`
ID int `db:"id"`
Name string `db:"name"`
AttestationType string `db:"attestation_type"`
Measurement sql.NullString `db:"measurement"`
DcapImageHashes sql.NullString `db:"dcap_image_hashes"`
IsActive bool `db:"is_active"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
DeprecatedAt *time.Time `db:"deprecated_at"`
}

func convertMeasurementToDomain(measurement Measurement) (*domain.Measurement, error) {
var m domain.Measurement
m.AttestationType = measurement.AttestationType
m.Measurement = make(map[string]domain.SingleMeasurement)
err := json.Unmarshal(measurement.Measurement, &m.Measurement)
if err != nil {
return nil, err
if measurement.Measurement.Valid && measurement.Measurement.String != "null" {
m.Measurement = make(map[string]domain.SingleMeasurement)
if err := json.Unmarshal([]byte(measurement.Measurement.String), &m.Measurement); err != nil {
return nil, err
}
}
if measurement.DcapImageHashes.Valid && measurement.DcapImageHashes.String != "null" {
m.DcapImageHashes = new(domain.DcapImageHashes)
if err := json.Unmarshal([]byte(measurement.DcapImageHashes.String), m.DcapImageHashes); err != nil {
return nil, err
}
}
m.Name = measurement.Name
return &m, nil
Expand Down
Loading
Loading