diff --git a/README.md b/README.md index 6184843..ce9de58 100644 --- a/README.md +++ b/README.md @@ -122,7 +122,7 @@ This repository contains a sample [measurements.json](./measurements.json) file ## Measurements Attestation verification requires the expected measurements which you pass through the `--{client, server}-measurements` flag. -The measurements are expected to be a JSON map, and multiple valid measurements can be provided. The verifier will attempt to verify with each of the provided measurements, and if any succeeds, the attestation is assumed valid. +Measurements can be provided in the legacy JSON map format, the current measurement container format, or as raw GCP DCAP measurement JSON output by [flashbots/dstack-mr-gcp](https://github.com/flashbots/dstack-mr-gcp). Multiple valid measurements can be provided. The verifier will attempt to verify with each of the provided measurements, and if any succeeds, the attestation is assumed valid. The (single) validated measurement is json-marshalled and forwarded (returned in the case of client) as "X-Flashbots-Measurement" header, and the type of attestation as "X-Flashbots-Attestation-Type" header. For mapping attestation types to OIDs and issuers, see [internal/attestation/variant/variant.go](./internal/attestation/variant/variant.go). To only validate and forward the measurement (as opposed to also authorizing the measurement against an expected one), simply provide an empty expected measurements object. diff --git a/multimeasurements/multimeasurements.go b/multimeasurements/multimeasurements.go index 21d4b2b..5eb1914 100644 --- a/multimeasurements/multimeasurements.go +++ b/multimeasurements/multimeasurements.go @@ -8,12 +8,14 @@ package multimeasurements import ( "bytes" "encoding/json" + "fmt" "io" "net/http" "os" "strings" "github.com/flashbots/cvm-reverse-proxy/internal/attestation/measurements" + "github.com/flashbots/cvm-reverse-proxy/internal/encoding" ) // MultiMeasurements holds several known measurements, and can check if @@ -30,6 +32,19 @@ type MeasurementsContainer struct { type LegacyMultiMeasurements map[string]measurements.M +// Caps expansion of dstack-mr-gcp measurements +const maxGCPMeasurementContainers = 10_000 + +// Structure used by the dstack-mr-gcp output. mrtd and rtmr0 hold one entry per +// possible value; rtmr1-3 are single values. +type rawGCPMeasurements struct { + MRTD []encoding.HexBytes `json:"mrtd"` + RTMR0 []encoding.HexBytes `json:"rtmr0"` + RTMR1 encoding.HexBytes `json:"rtmr1"` + RTMR2 encoding.HexBytes `json:"rtmr2"` + RTMR3 encoding.HexBytes `json:"rtmr3"` +} + // New returns a MultiMeasurements instance, with the measurements // loaded from a file or URL. func New(path string) (m *MultiMeasurements, err error) { @@ -53,12 +68,19 @@ func New(path string) (m *MultiMeasurements, err error) { } } - m = &MultiMeasurements{} + return NewFromBytes(data) +} + +// NewFromBytes returns a MultiMeasurements instance loaded from JSON bytes +func NewFromBytes(data []byte) (*MultiMeasurements, error) { + m := &MultiMeasurements{} + + if err := json.Unmarshal(data, &m.Measurements); err == nil { + return m, nil + } - // Try to load the v2 data schema, if that fails fall back to legacy v1 schema - if err = json.Unmarshal(data, &m.Measurements); err != nil { - var legacyData LegacyMultiMeasurements - err = json.Unmarshal(data, &legacyData) + var legacyData LegacyMultiMeasurements + if err := json.Unmarshal(data, &legacyData); err == nil { for measurementID, measurements := range legacyData { container := MeasurementsContainer{ MeasurementID: measurementID, @@ -67,9 +89,78 @@ func New(path string) (m *MultiMeasurements, err error) { } m.Measurements = append(m.Measurements, container) } + return m, nil + } + + rawGCPContainers, err := parseRawGCPMeasurements(data) + if err != nil { + return nil, err + } + m.Measurements = rawGCPContainers + return m, nil +} + +// parseRawGCPMeasurements expands dstack-mr-gcp JSON into DCAP TDX measurement containers +func parseRawGCPMeasurements(data []byte) ([]MeasurementsContainer, error) { + var raw rawGCPMeasurements + if err := json.Unmarshal(data, &raw); err != nil { + return nil, fmt.Errorf("parsing raw GCP measurements: %w", err) + } + + if raw.RTMR3 == nil { + raw.RTMR3 = make(encoding.HexBytes, measurements.TDXMeasurementLength) + } + if err := validateGCPMeasurements(raw); err != nil { + return nil, err + } + + total := len(raw.MRTD) * len(raw.RTMR0) + if total > maxGCPMeasurementContainers { + return nil, fmt.Errorf("parsing raw GCP measurements: cartesian product of %d containers exceeds limit of %d", total, maxGCPMeasurementContainers) + } + + containers := make([]MeasurementsContainer, 0, total) + for mrtdIdx, mrtd := range raw.MRTD { + for rtmr0Idx, rtmr0 := range raw.RTMR0 { + containers = append(containers, MeasurementsContainer{ + MeasurementID: fmt.Sprintf("dstack-mr-gcp-%d-%d", mrtdIdx, rtmr0Idx), + AttestationType: "dcap-tdx", + Measurements: measurements.M{ + 0: {Expected: mrtd, ValidationOpt: measurements.Enforce}, + 1: {Expected: rtmr0, ValidationOpt: measurements.Enforce}, + 2: {Expected: raw.RTMR1, ValidationOpt: measurements.Enforce}, + 3: {Expected: raw.RTMR2, ValidationOpt: measurements.Enforce}, + 4: {Expected: raw.RTMR3, ValidationOpt: measurements.Enforce}, + }, + }) + } + } + + return containers, nil +} + +// validateGCPMeasurements checks that all dstack-mr-gcp fields are present and have valid TDX measurement lengths +func validateGCPMeasurements(raw rawGCPMeasurements) error { + lists := map[string][]encoding.HexBytes{"mrtd": raw.MRTD, "rtmr0": raw.RTMR0} + for field, values := range lists { + if len(values) == 0 { + return fmt.Errorf("parsing raw GCP measurements: %q must not be empty", field) + } + for idx, value := range values { + if len(value) != measurements.TDXMeasurementLength { + return fmt.Errorf("parsing raw GCP measurements: %q[%d] has invalid length %d", field, idx, len(value)) + } + } + } + + scalars := map[string]encoding.HexBytes{"rtmr1": raw.RTMR1, "rtmr2": raw.RTMR2, "rtmr3": raw.RTMR3} + for field, value := range scalars { + if len(value) != measurements.TDXMeasurementLength { + return fmt.Errorf("parsing raw GCP measurements: %q has invalid length %d", field, len(value)) + } } - return m, err + return nil } // Contains checks if the provided measurements match one of the known measurements. Any keys in the provided diff --git a/multimeasurements/multimeasurements_test.go b/multimeasurements/multimeasurements_test.go index 8cc6a50..eda484c 100644 --- a/multimeasurements/multimeasurements_test.go +++ b/multimeasurements/multimeasurements_test.go @@ -4,6 +4,7 @@ import ( "encoding/hex" "os" "path/filepath" + "strings" "testing" "github.com/stretchr/testify/require" @@ -23,6 +24,67 @@ func mustBytesFromHex(hexValue string) []byte { // Measurements V1 (legacy) JSON (from https://github.com/flashbots/cvm-reverse-proxy/blob/837588b9f87ee49d1bb6dca4712a1c2844eb1ecc/measurements.json) var measurementsV1JSON = []byte(`{"azure-tdx-example":{"11":{"expected":"efa43e0beff151b0f251c4abf48152382b1452b4414dbd737b4127de05ca31f7"},"12":{"expected":"0000000000000000000000000000000000000000000000000000000000000000"},"13":{"expected":"0000000000000000000000000000000000000000000000000000000000000000"},"15":{"expected":"0000000000000000000000000000000000000000000000000000000000000000"},"4":{"expected":"ea92ff762767eae6316794f1641c485d4846bc2b9df2eab6ba7f630ce6f4d66f"},"8":{"expected":"0000000000000000000000000000000000000000000000000000000000000000"},"9":{"expected":"c9f429296634072d1063a03fb287bed0b2d177b0a504755ad9194cffd90b2489"}},"dcap-tdx-example":{"0":{"expected":"5d56080eb9ef8ce0bbaf6bdcdadeeb06e7c5b0a4d1ec16be868a85a953babe0c5e54d01c8e050a54fe1ca078372530d2"},"1":{"expected":"4216e925f796f4e282cfa6e72d4c77a80560987afa29155a61fdc33adb80eab0d4112abd52387e5e25a60deefb8a5287"},"2":{"expected":"4274fefb79092c164000b571b64ecb432fa2357adb421fd1c77a867168d7d7f7fe82796d1eba092c7bab35cf43f5ec55"},"3":{"expected":"000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"},"4":{"expected":"000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"}}}`) +const rawGCPChunkedJSON = `{ + "rtmr1": "cdf855b56d27967473b885164b3910ab4d81f3db0bd50e114593bd5fd91cf55760de7776c93f4724cefeaf5ac0843e62", + "rtmr2": "438337a98c597535940941a3d9913e04a76d84e4ebf69dbb89e1addc8bae7183579685f0ef3144875dba7d933d9dcabf", + "rtmr3": "000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", + "rtmr0": [ + "c07820997dc2a5e1cc67b05e89852c1a72289e0ec82034bee5b3605cd759328853a758a346522651956afe9222914235", + "c6975b4a5d66fd88bce4d449ed161e0800ce0f5bcd6a3246f2c83407e230474ff56e6e8d5bc9e9d95cf692d458257954", + "8256ae5bb15489ebb0181f9935fe00625751e879d4e3b0a111aaba48da31e5cdd82f379cf4b97411618b02ce1deba1fd", + "46af3396ed9969f670f69142f7b74514598b0f25bb66fc3094402c2de37f4f7493d83cc0860416d3e50f7dda1c34f658", + "c0f93df02880d6c1dc1a5104e04ef691bc41cdc9da49f8834cc1cdc10acdb557758371d19d2466b560be7a66643953fd", + "6927bd0230ca2dba4ad21ee68c1f7e018660d5ad6a99e185eac8adc1f05dd6eeb10178d2a744d22cd14b8945b454712b", + "b90ca4eb0badc04128035ad62f1d1e792f1ca40c99ce25e3cce13f8167eb6265e890c65097518e9f8f5af91519d60d73", + "c3940a1a1f6709fed6d90c34698cb91767fa6261f4469fdcf6da5e36c6f2493fdfd34bc2d1a9f726a17ed88a79b33561", + "8530903ec5cb9aad1737c2bf0e9df958ec0a3ede63fe556415990192b4def86a50d8f6869b6283141b13dd4848b0cea2", + "9efb7193464610d63fbd948901998eda998b3e47e9a0abb72857ba948dcbacd3a17ee75e5081455dccaae208b8294bd9", + "5c942d2f4a08acd594b7d8914362835dcbb12994aedc2128abecf5585807cb8886faf7b9b32611cd4e63eac269632560", + "284f209fece0d49331f2e411c46f6debc3be698bd8587264c90ab0dbdf651046aef3badaae9a7983ea0855a7dabdfa00", + "8d5f7f704ccba0a63157f1ec1214c7f043005b045adae261e0581951965a96350d6196f38751b5dd0e72fca181817efa", + "123ad184172b44083b191b12557f3c923416d8e654ffb390736db331ff2a5bce6c89d14d62cf70e113b98d8f13e78519", + "8ae3d7af48afa0f30fd700a58ca84cd5e0054fbe011d9ed228e30a17db456987e63c6dfd71437aac33ffb9d796088d70", + "3fdfcb2bbf25c9e535f7e4724b1cee79666824cab1565f985d2e1e0218818d538cf6f3bfa5c623c13d6226ae51ea8cd6", + "640b92712990cc8aab4f3786611a8acc3180525abd42a31c06eb7611b8c54a72247dfe8a7a93d3c922f771797a7932de", + "7c8fc1dd62391d416ac64174b833f821b59738d816d96168483300127608e0cf3345840b5bd9325c125dd6b2f595f1b0", + "a191d8250215e05e31fc42fa00f4b7a8729e1fb83b3dacb3def3989b9eaa3f8d199b96759477ce20bbd47c909b6b984c", + "633eeb1778affe65d1b3633527395763602c06e9d7aea52a2a6d5073c33ee1fe78f3a83aeb58edd036de681eee3d1f0f", + "b372a4eac4561e3a8d92028a38e8860a63d7e69c7fcab250aa49d1c951c94b49d0abbe87c353fcd14651f64ac5dde055", + "90d7dbdb795d66669ff44aff1f8ea0de13f5362f1dec68f17fca60364fcc019de18b246c9e173c09102360442dba3261", + "8292abcf17f665c5f63e158a5fd7f2e160ac5b5ae4811532d93c3b5f38a53adebebddaf531aca4ef91d9fb68fb4312a8", + "e1d0235496f93f9475bf0b26d33da5c15831cfc94104d6bea7ab82db027c5f1e917d47dda6953eefae7dcb20ab6f75c4" + ], + "mrtd": [ + "a5844e88897b70c318bef929ef4dfd6c7304c52c4bc9c3f39132f0fdccecf3eb5bab70110ee42a12509a31c037288694", + "8370d8f6d02f2d13e211e91c93fde923049522b241425a29a7bf0071ef49b250af4ef49d852fa3e10065d1b51dfce8fb", + "feb7486608382c1ff0e15b4648ddc0acea6ca974eb53e3529f4c4bd5ffbaa20bf335cb75965cea65fe473aed9647c162" + ], + "mrconfigid": "000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", + "xfam": "e700060000000000", + "tdattributes": "0000001000000000" +}` + +// rawGCPScalarJSON returns a single-value dstack-mr-gcp fixture +func rawGCPScalarJSON() []byte { + return []byte(`{ + "mrtd": ["` + strings.Repeat("11", 48) + `"], + "rtmr0": ["` + strings.Repeat("22", 48) + `"], + "rtmr1": "` + strings.Repeat("33", 48) + `", + "rtmr2": "` + strings.Repeat("44", 48) + `", + "mr_aggregated": "` + strings.Repeat("aa", 32) + `", + "mr_image": "` + strings.Repeat("bb", 32) + `" +}`) +} + +// writeMeasurementsFile writes a temporary measurements fixture +func writeMeasurementsFile(t *testing.T, data []byte) string { + t.Helper() + path := filepath.Join(t.TempDir(), "measurements.json") + err := os.WriteFile(path, data, 0644) + require.NoError(t, err) + return path +} + // TestMultiMeasurementsV2 tests the v2 data schema func TestMultiMeasurementsV2(t *testing.T) { // Load expected measurements from JSON file (in V2 format) @@ -58,26 +120,22 @@ func TestMultiMeasurementsV2(t *testing.T) { exists, _ = m.Contains(testMeasurements) require.False(t, exists) - // Check for another set of known measurements (dcap-tdx-example) + // Check for another set of known measurements (dcap-tdx-dummy) testMeasurements = TestMeasurements{ - 0: mustBytesFromHex("5d56080eb9ef8ce0bbaf6bdcdadeeb06e7c5b0a4d1ec16be868a85a953babe0c5e54d01c8e050a54fe1ca078372530d2"), - 1: mustBytesFromHex("4216e925f796f4e282cfa6e72d4c77a80560987afa29155a61fdc33adb80eab0d4112abd52387e5e25a60deefb8a5287"), - 2: mustBytesFromHex("4274fefb79092c164000b571b64ecb432fa2357adb421fd1c77a867168d7d7f7fe82796d1eba092c7bab35cf43f5ec55"), - 3: mustBytesFromHex("000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"), + 0: mustBytesFromHex("47a1cc074b914df8596bad0ed13d50d561ad1effc7f7cc530ab86da7ea49ffc03e57e7da829f8cba9c629c3970505323"), + 1: mustBytesFromHex("da6e07866635cb34a9ffcdc26ec6622f289e625c42c39b320f29cdf1dc84390b4f89dd0b073be52ac38ca7b0a0f375bb"), + 2: mustBytesFromHex("a7157e7c5f932e9babac9209d4527ec9ed837b8e335a931517677fa746db51ee56062e3324e266e3f39ec26a516f4f71"), + 3: mustBytesFromHex("e63560e50830e22fbc9b06cdce8afe784bf111e4251256cf104050f1347cd4ad9f30da408475066575145da0b098a124"), 4: mustBytesFromHex("000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"), } exists, foundMeasurement = m.Contains(testMeasurements) require.True(t, exists) - require.Equal(t, "dcap-tdx-example-02", foundMeasurement.MeasurementID) + require.Equal(t, "dcap-tdx-dummy", foundMeasurement.MeasurementID) } func TestMultiMeasurementsV1(t *testing.T) { - tempDir := t.TempDir() - err := os.WriteFile(filepath.Join(tempDir, "measurements.json"), measurementsV1JSON, 0644) - require.NoError(t, err) - // Load expected measurements from JSON file - m, err := New(filepath.Join(tempDir, "measurements.json")) + m, err := New(writeMeasurementsFile(t, measurementsV1JSON)) require.NoError(t, err) require.Len(t, m.Measurements, 2) @@ -92,3 +150,76 @@ func TestMultiMeasurementsV1(t *testing.T) { require.True(t, exists) require.Equal(t, "dcap-tdx-example", foundMeasurement.MeasurementID) } + +// TestMultiMeasurementsRawGCPScalar tests scalar-valued dstack-mr-gcp measurements +func TestMultiMeasurementsRawGCPScalar(t *testing.T) { + m, err := New(writeMeasurementsFile(t, rawGCPScalarJSON())) + require.NoError(t, err) + require.Len(t, m.Measurements, 1) + + container := m.Measurements[0] + require.Equal(t, "dcap-tdx", container.AttestationType) + require.Equal(t, mustBytesFromHex(strings.Repeat("11", 48)), container.Measurements[0].Expected) + require.Equal(t, mustBytesFromHex(strings.Repeat("22", 48)), container.Measurements[1].Expected) + require.Equal(t, mustBytesFromHex(strings.Repeat("33", 48)), container.Measurements[2].Expected) + require.Equal(t, mustBytesFromHex(strings.Repeat("44", 48)), container.Measurements[3].Expected) + require.Equal(t, mustBytesFromHex(strings.Repeat("00", 48)), container.Measurements[4].Expected) + + exists, foundMeasurement := m.Contains(TestMeasurements{ + 0: mustBytesFromHex(strings.Repeat("11", 48)), + 1: mustBytesFromHex(strings.Repeat("22", 48)), + 2: mustBytesFromHex(strings.Repeat("33", 48)), + 3: mustBytesFromHex(strings.Repeat("44", 48)), + 4: mustBytesFromHex(strings.Repeat("00", 48)), + }) + require.True(t, exists) + require.Equal(t, container.MeasurementID, foundMeasurement.MeasurementID) +} + +// TestMultiMeasurementsRawGCPChunked tests list-valued dstack-mr-gcp measurements +func TestMultiMeasurementsRawGCPChunked(t *testing.T) { + m, err := New(writeMeasurementsFile(t, []byte(rawGCPChunkedJSON))) + require.NoError(t, err) + require.Len(t, m.Measurements, 72) + + exists, foundMeasurement := m.Contains(TestMeasurements{ + 0: mustBytesFromHex("feb7486608382c1ff0e15b4648ddc0acea6ca974eb53e3529f4c4bd5ffbaa20bf335cb75965cea65fe473aed9647c162"), + 1: mustBytesFromHex("c6975b4a5d66fd88bce4d449ed161e0800ce0f5bcd6a3246f2c83407e230474ff56e6e8d5bc9e9d95cf692d458257954"), + 2: mustBytesFromHex("cdf855b56d27967473b885164b3910ab4d81f3db0bd50e114593bd5fd91cf55760de7776c93f4724cefeaf5ac0843e62"), + 3: mustBytesFromHex("438337a98c597535940941a3d9913e04a76d84e4ebf69dbb89e1addc8bae7183579685f0ef3144875dba7d933d9dcabf"), + 4: mustBytesFromHex(strings.Repeat("00", 48)), + }) + require.True(t, exists) + require.Equal(t, "dcap-tdx", foundMeasurement.AttestationType) +} + +// TestMultiMeasurementsRawGCPMalformed tests malformed dstack-mr-gcp measurements +func TestMultiMeasurementsRawGCPMalformed(t *testing.T) { + _, err := New(writeMeasurementsFile(t, []byte(`{ + "mrtd": ["not-hex"], + "rtmr0": ["`+strings.Repeat("22", 48)+`"], + "rtmr1": "`+strings.Repeat("33", 48)+`", + "rtmr2": "`+strings.Repeat("44", 48)+`" +}`))) + require.Error(t, err) + require.Contains(t, err.Error(), "parsing raw GCP measurements") +} + +// TestMultiMeasurementsRawGCPCartesianLimit tests that oversized cartesian products are rejected +func TestMultiMeasurementsRawGCPCartesianLimit(t *testing.T) { + // 101 values for both mrtd and rtmr0 -> 101*101 = 10,201 > 10,000 limit + manyValues := make([]string, 101) + for i := range manyValues { + manyValues[i] = `"` + strings.Repeat("ab", 48) + `"` + } + valuesJSON := "[" + strings.Join(manyValues, ",") + "]" + + _, err := NewFromBytes([]byte(`{ + "mrtd": ` + valuesJSON + `, + "rtmr0": ` + valuesJSON + `, + "rtmr1": "` + strings.Repeat("33", 48) + `", + "rtmr2": "` + strings.Repeat("44", 48) + `" +}`)) + require.Error(t, err) + require.Contains(t, err.Error(), "exceeds limit") +} diff --git a/proxy/atls_config.go b/proxy/atls_config.go index 7f46c40..f485242 100644 --- a/proxy/atls_config.go +++ b/proxy/atls_config.go @@ -4,7 +4,6 @@ package proxy import ( "context" "crypto/x509/pkix" - "encoding/json" "errors" "fmt" "log/slog" @@ -89,16 +88,11 @@ func CreateAttestationValidatorsFromFile(log *slog.Logger, jsonMeasurementsPath return nil, nil } - jsonMeasurements, err := os.ReadFile(jsonMeasurementsPath) - if err != nil { - return nil, err - } - - var parsedMeasurements []multimeasurements.MeasurementsContainer - err = json.Unmarshal(jsonMeasurements, &parsedMeasurements) + multiMeasurements, err := multimeasurements.New(jsonMeasurementsPath) if err != nil { return nil, err } + parsedMeasurements := multiMeasurements.Measurements // Group validators by attestation type validatorsByType := make(map[AttestationType][]atls.Validator) diff --git a/proxy/atls_config_test.go b/proxy/atls_config_test.go new file mode 100644 index 0000000..be84347 --- /dev/null +++ b/proxy/atls_config_test.go @@ -0,0 +1,38 @@ +package proxy + +import ( + "io" + "log/slog" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/flashbots/cvm-reverse-proxy/internal/atls" + "github.com/stretchr/testify/require" +) + +// TestCreateAttestationValidatorsFromRawGCPMeasurementFile tests dstack-mr-gcp validator setup +func TestCreateAttestationValidatorsFromRawGCPMeasurementFile(t *testing.T) { + measurementsJSON := []byte(`{ + "mrtd": ["` + strings.Repeat("11", 48) + `"], + "rtmr0": ["` + strings.Repeat("22", 48) + `"], + "rtmr1": "` + strings.Repeat("33", 48) + `", + "rtmr2": "` + strings.Repeat("44", 48) + `", + "rtmr3": "` + strings.Repeat("00", 48) + `" +}`) + path := filepath.Join(t.TempDir(), "measurements.json") + err := os.WriteFile(path, measurementsJSON, 0644) + require.NoError(t, err) + + log := slog.New(slog.NewTextHandler(io.Discard, nil)) + validators, err := CreateAttestationValidatorsFromFile(log, path) + require.NoError(t, err) + require.Len(t, validators, 1) + + multiValidator, ok := validators[0].(interface { + Validators() []atls.Validator + }) + require.True(t, ok) + require.Len(t, multiValidator.Validators(), 1) +}