From 843bb5406e6f299cb2e481e0183b705a6306c666 Mon Sep 17 00:00:00 2001 From: peg Date: Mon, 15 Jun 2026 10:01:20 +0200 Subject: [PATCH 1/4] Accept dstack-mr-gcp measurements format --- README.md | 2 +- multimeasurements/multimeasurements.go | 158 +++++++++++++++++++- multimeasurements/multimeasurements_test.go | 134 +++++++++++++++-- proxy/atls_config.go | 10 +- proxy/atls_config_test.go | 38 +++++ 5 files changed, 316 insertions(+), 26 deletions(-) create mode 100644 proxy/atls_config_test.go 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..67d588d 100644 --- a/multimeasurements/multimeasurements.go +++ b/multimeasurements/multimeasurements.go @@ -7,7 +7,9 @@ package multimeasurements import ( "bytes" + "encoding/hex" "encoding/json" + "fmt" "io" "net/http" "os" @@ -30,6 +32,37 @@ type MeasurementsContainer struct { type LegacyMultiMeasurements map[string]measurements.M +type rawGCPMeasurements struct { + MRTD rawGCPMeasurementValues `json:"mrtd"` + RTMR0 rawGCPMeasurementValues `json:"rtmr0"` + RTMR1 rawGCPMeasurementValues `json:"rtmr1"` + RTMR2 rawGCPMeasurementValues `json:"rtmr2"` + RTMR3 rawGCPMeasurementValues `json:"rtmr3"` +} + +type rawGCPMeasurementValues struct { + values []string + set bool +} + +// UnmarshalJSON accepts scalar or list measurement values from dstack-mr-gcp JSON +func (v *rawGCPMeasurementValues) UnmarshalJSON(data []byte) error { + v.set = true + + var scalar string + if err := json.Unmarshal(data, &scalar); err == nil { + v.values = []string{scalar} + return nil + } + + var values []string + if err := json.Unmarshal(data, &values); err != nil { + return err + } + v.values = values + return nil +} + // New returns a MultiMeasurements instance, with the measurements // loaded from a file or URL. func New(path string) (m *MultiMeasurements, err error) { @@ -53,12 +86,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{} - // 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) + if err := json.Unmarshal(data, &m.Measurements); err == nil { + return m, nil + } + + var legacyData LegacyMultiMeasurements + if err := json.Unmarshal(data, &legacyData); err == nil { for measurementID, measurements := range legacyData { container := MeasurementsContainer{ MeasurementID: measurementID, @@ -67,9 +107,115 @@ 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 +} - return m, err +// 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) + } + + mrtdValues, err := raw.requiredHexValues("mrtd", raw.MRTD) + if err != nil { + return nil, err + } + rtmr0Values, err := raw.requiredHexValues("rtmr0", raw.RTMR0) + if err != nil { + return nil, err + } + rtmr1Values, err := raw.requiredHexValues("rtmr1", raw.RTMR1) + if err != nil { + return nil, err + } + rtmr2Values, err := raw.requiredHexValues("rtmr2", raw.RTMR2) + if err != nil { + return nil, err + } + rtmr3Values, err := raw.optionalHexValues("rtmr3", raw.RTMR3) + if err != nil { + return nil, err + } + if len(rtmr3Values) == 0 { + rtmr3Values = [][]byte{make([]byte, measurements.TDXMeasurementLength)} + } + + containers := make([]MeasurementsContainer, 0, len(mrtdValues)*len(rtmr0Values)*len(rtmr1Values)*len(rtmr2Values)*len(rtmr3Values)) + for mrtdIdx, mrtd := range mrtdValues { + for rtmr0Idx, rtmr0 := range rtmr0Values { + for rtmr1Idx, rtmr1 := range rtmr1Values { + for rtmr2Idx, rtmr2 := range rtmr2Values { + for rtmr3Idx, rtmr3 := range rtmr3Values { + container := MeasurementsContainer{ + MeasurementID: fmt.Sprintf("dstack-mr-gcp-%d-%d-%d-%d-%d", mrtdIdx, rtmr0Idx, rtmr1Idx, rtmr2Idx, rtmr3Idx), + AttestationType: "dcap-tdx", + Measurements: measurements.M{ + 0: {Expected: mrtd, ValidationOpt: measurements.Enforce}, + 1: {Expected: rtmr0, ValidationOpt: measurements.Enforce}, + 2: {Expected: rtmr1, ValidationOpt: measurements.Enforce}, + 3: {Expected: rtmr2, ValidationOpt: measurements.Enforce}, + 4: {Expected: rtmr3, ValidationOpt: measurements.Enforce}, + }, + } + containers = append(containers, container) + } + } + } + } + } + + return containers, nil +} + +// requiredHexValues decodes a required dstack-mr-gcp measurement field +func (rawGCPMeasurements) requiredHexValues(field string, values rawGCPMeasurementValues) ([][]byte, error) { + if !values.set { + return nil, fmt.Errorf("parsing raw GCP measurements: missing %q", field) + } + decoded, err := decodeRawGCPHexValues(field, values.values) + if err != nil { + return nil, err + } + if len(decoded) == 0 { + return nil, fmt.Errorf("parsing raw GCP measurements: %q must not be empty", field) + } + return decoded, nil +} + +// optionalHexValues decodes an optional dstack-mr-gcp measurement field +func (rawGCPMeasurements) optionalHexValues(field string, values rawGCPMeasurementValues) ([][]byte, error) { + if !values.set { + return nil, nil + } + return decodeRawGCPHexValues(field, values.values) +} + +// decodeRawGCPHexValues decodes and validates dstack-mr-gcp TDX measurement hex values +func decodeRawGCPHexValues(field string, values []string) ([][]byte, error) { + decoded := make([][]byte, 0, len(values)) + for idx, value := range values { + if value == "" { + return nil, fmt.Errorf("parsing raw GCP measurements: %q[%d] must not be empty", field, idx) + } + bytes, err := hex.DecodeString(value) + if err != nil { + return nil, fmt.Errorf("parsing raw GCP measurements: decoding %q[%d]: %w", field, idx, err) + } + if len(bytes) != measurements.TDXMeasurementLength { + return nil, fmt.Errorf("parsing raw GCP measurements: %q[%d] has invalid length %d", field, idx, len(bytes)) + } + decoded = append(decoded, bytes) + } + return decoded, 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..d1c7a74 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 scalar-valued 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,57 @@ 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") +} 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..0df983e --- /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) +} From 462c95704421e567decc64b739ab1454e67b9931 Mon Sep 17 00:00:00 2001 From: peg Date: Mon, 15 Jun 2026 10:40:18 +0200 Subject: [PATCH 2/4] Add a cap for maximum amount of entries in dstack-mr measurements --- multimeasurements/multimeasurements.go | 11 ++++++++++- multimeasurements/multimeasurements_test.go | 20 ++++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/multimeasurements/multimeasurements.go b/multimeasurements/multimeasurements.go index 67d588d..f73e2e1 100644 --- a/multimeasurements/multimeasurements.go +++ b/multimeasurements/multimeasurements.go @@ -32,6 +32,10 @@ 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 type rawGCPMeasurements struct { MRTD rawGCPMeasurementValues `json:"mrtd"` RTMR0 rawGCPMeasurementValues `json:"rtmr0"` @@ -149,7 +153,12 @@ func parseRawGCPMeasurements(data []byte) ([]MeasurementsContainer, error) { rtmr3Values = [][]byte{make([]byte, measurements.TDXMeasurementLength)} } - containers := make([]MeasurementsContainer, 0, len(mrtdValues)*len(rtmr0Values)*len(rtmr1Values)*len(rtmr2Values)*len(rtmr3Values)) + total := len(mrtdValues) * len(rtmr0Values) * len(rtmr1Values) * len(rtmr2Values) * len(rtmr3Values) + 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 mrtdValues { for rtmr0Idx, rtmr0 := range rtmr0Values { for rtmr1Idx, rtmr1 := range rtmr1Values { diff --git a/multimeasurements/multimeasurements_test.go b/multimeasurements/multimeasurements_test.go index d1c7a74..67be0c6 100644 --- a/multimeasurements/multimeasurements_test.go +++ b/multimeasurements/multimeasurements_test.go @@ -204,3 +204,23 @@ func TestMultiMeasurementsRawGCPMalformed(t *testing.T) { 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) { + // 23 values per field -> 23^5 = 6,436,343 >> 10,000 limit + manyValues := make([]string, 23) + for i := range manyValues { + manyValues[i] = `"` + strings.Repeat("ab", 48) + `"` + } + valuesJSON := "[" + strings.Join(manyValues, ",") + "]" + + _, err := NewFromBytes([]byte(`{ + "mrtd": ` + valuesJSON + `, + "rtmr0": ` + valuesJSON + `, + "rtmr1": ` + valuesJSON + `, + "rtmr2": ` + valuesJSON + `, + "rtmr3": ` + valuesJSON + ` +}`)) + require.Error(t, err) + require.Contains(t, err.Error(), "exceeds limit") +} From ca1802109974570f72c417e873438261551f71a7 Mon Sep 17 00:00:00 2001 From: Mateusz Morusiewicz <11313015+Ruteri@users.noreply.github.com> Date: Tue, 16 Jun 2026 14:34:44 +0200 Subject: [PATCH 3/4] Adjusts to use standard encoding --- multimeasurements/multimeasurements.go | 150 ++++++-------------- multimeasurements/multimeasurements_test.go | 19 ++- proxy/atls_config_test.go | 4 +- 3 files changed, 54 insertions(+), 119 deletions(-) diff --git a/multimeasurements/multimeasurements.go b/multimeasurements/multimeasurements.go index f73e2e1..5eb1914 100644 --- a/multimeasurements/multimeasurements.go +++ b/multimeasurements/multimeasurements.go @@ -7,7 +7,6 @@ package multimeasurements import ( "bytes" - "encoding/hex" "encoding/json" "fmt" "io" @@ -16,6 +15,7 @@ import ( "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 @@ -35,36 +35,14 @@ 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 +// 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 rawGCPMeasurementValues `json:"mrtd"` - RTMR0 rawGCPMeasurementValues `json:"rtmr0"` - RTMR1 rawGCPMeasurementValues `json:"rtmr1"` - RTMR2 rawGCPMeasurementValues `json:"rtmr2"` - RTMR3 rawGCPMeasurementValues `json:"rtmr3"` -} - -type rawGCPMeasurementValues struct { - values []string - set bool -} - -// UnmarshalJSON accepts scalar or list measurement values from dstack-mr-gcp JSON -func (v *rawGCPMeasurementValues) UnmarshalJSON(data []byte) error { - v.set = true - - var scalar string - if err := json.Unmarshal(data, &scalar); err == nil { - v.values = []string{scalar} - return nil - } - - var values []string - if err := json.Unmarshal(data, &values); err != nil { - return err - } - v.values = values - return nil + 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 @@ -129,102 +107,60 @@ func parseRawGCPMeasurements(data []byte) ([]MeasurementsContainer, error) { return nil, fmt.Errorf("parsing raw GCP measurements: %w", err) } - mrtdValues, err := raw.requiredHexValues("mrtd", raw.MRTD) - if err != nil { - return nil, err - } - rtmr0Values, err := raw.requiredHexValues("rtmr0", raw.RTMR0) - if err != nil { - return nil, err - } - rtmr1Values, err := raw.requiredHexValues("rtmr1", raw.RTMR1) - if err != nil { - return nil, err + if raw.RTMR3 == nil { + raw.RTMR3 = make(encoding.HexBytes, measurements.TDXMeasurementLength) } - rtmr2Values, err := raw.requiredHexValues("rtmr2", raw.RTMR2) - if err != nil { + if err := validateGCPMeasurements(raw); err != nil { return nil, err } - rtmr3Values, err := raw.optionalHexValues("rtmr3", raw.RTMR3) - if err != nil { - return nil, err - } - if len(rtmr3Values) == 0 { - rtmr3Values = [][]byte{make([]byte, measurements.TDXMeasurementLength)} - } - total := len(mrtdValues) * len(rtmr0Values) * len(rtmr1Values) * len(rtmr2Values) * len(rtmr3Values) + 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 mrtdValues { - for rtmr0Idx, rtmr0 := range rtmr0Values { - for rtmr1Idx, rtmr1 := range rtmr1Values { - for rtmr2Idx, rtmr2 := range rtmr2Values { - for rtmr3Idx, rtmr3 := range rtmr3Values { - container := MeasurementsContainer{ - MeasurementID: fmt.Sprintf("dstack-mr-gcp-%d-%d-%d-%d-%d", mrtdIdx, rtmr0Idx, rtmr1Idx, rtmr2Idx, rtmr3Idx), - AttestationType: "dcap-tdx", - Measurements: measurements.M{ - 0: {Expected: mrtd, ValidationOpt: measurements.Enforce}, - 1: {Expected: rtmr0, ValidationOpt: measurements.Enforce}, - 2: {Expected: rtmr1, ValidationOpt: measurements.Enforce}, - 3: {Expected: rtmr2, ValidationOpt: measurements.Enforce}, - 4: {Expected: rtmr3, ValidationOpt: measurements.Enforce}, - }, - } - containers = append(containers, container) - } - } - } + 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 } -// requiredHexValues decodes a required dstack-mr-gcp measurement field -func (rawGCPMeasurements) requiredHexValues(field string, values rawGCPMeasurementValues) ([][]byte, error) { - if !values.set { - return nil, fmt.Errorf("parsing raw GCP measurements: missing %q", field) - } - decoded, err := decodeRawGCPHexValues(field, values.values) - if err != nil { - return nil, err - } - if len(decoded) == 0 { - return nil, fmt.Errorf("parsing raw GCP measurements: %q must not be empty", field) - } - return decoded, nil -} - -// optionalHexValues decodes an optional dstack-mr-gcp measurement field -func (rawGCPMeasurements) optionalHexValues(field string, values rawGCPMeasurementValues) ([][]byte, error) { - if !values.set { - return nil, nil - } - return decodeRawGCPHexValues(field, values.values) -} - -// decodeRawGCPHexValues decodes and validates dstack-mr-gcp TDX measurement hex values -func decodeRawGCPHexValues(field string, values []string) ([][]byte, error) { - decoded := make([][]byte, 0, len(values)) - for idx, value := range values { - if value == "" { - return nil, fmt.Errorf("parsing raw GCP measurements: %q[%d] must not be empty", field, idx) +// 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) } - bytes, err := hex.DecodeString(value) - if err != nil { - return nil, fmt.Errorf("parsing raw GCP measurements: decoding %q[%d]: %w", field, idx, err) + 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)) + } } - if len(bytes) != measurements.TDXMeasurementLength { - return nil, fmt.Errorf("parsing raw GCP measurements: %q[%d] has invalid length %d", field, idx, len(bytes)) + } + + 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)) } - decoded = append(decoded, bytes) } - return decoded, nil + + 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 67be0c6..eda484c 100644 --- a/multimeasurements/multimeasurements_test.go +++ b/multimeasurements/multimeasurements_test.go @@ -64,11 +64,11 @@ const rawGCPChunkedJSON = `{ "tdattributes": "0000001000000000" }` -// rawGCPScalarJSON returns a scalar-valued dstack-mr-gcp fixture +// rawGCPScalarJSON returns a single-value dstack-mr-gcp fixture func rawGCPScalarJSON() []byte { return []byte(`{ - "mrtd": "` + strings.Repeat("11", 48) + `", - "rtmr0": "` + strings.Repeat("22", 48) + `", + "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) + `", @@ -196,8 +196,8 @@ func TestMultiMeasurementsRawGCPChunked(t *testing.T) { // 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)+`", + "mrtd": ["not-hex"], + "rtmr0": ["`+strings.Repeat("22", 48)+`"], "rtmr1": "`+strings.Repeat("33", 48)+`", "rtmr2": "`+strings.Repeat("44", 48)+`" }`))) @@ -207,8 +207,8 @@ func TestMultiMeasurementsRawGCPMalformed(t *testing.T) { // TestMultiMeasurementsRawGCPCartesianLimit tests that oversized cartesian products are rejected func TestMultiMeasurementsRawGCPCartesianLimit(t *testing.T) { - // 23 values per field -> 23^5 = 6,436,343 >> 10,000 limit - manyValues := make([]string, 23) + // 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) + `"` } @@ -217,9 +217,8 @@ func TestMultiMeasurementsRawGCPCartesianLimit(t *testing.T) { _, err := NewFromBytes([]byte(`{ "mrtd": ` + valuesJSON + `, "rtmr0": ` + valuesJSON + `, - "rtmr1": ` + valuesJSON + `, - "rtmr2": ` + valuesJSON + `, - "rtmr3": ` + 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_test.go b/proxy/atls_config_test.go index 0df983e..be84347 100644 --- a/proxy/atls_config_test.go +++ b/proxy/atls_config_test.go @@ -15,8 +15,8 @@ import ( // TestCreateAttestationValidatorsFromRawGCPMeasurementFile tests dstack-mr-gcp validator setup func TestCreateAttestationValidatorsFromRawGCPMeasurementFile(t *testing.T) { measurementsJSON := []byte(`{ - "mrtd": "` + strings.Repeat("11", 48) + `", - "rtmr0": "` + strings.Repeat("22", 48) + `", + "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) + `" From 59917c7ba6ae8688aefb5f866135a0d2d415a637 Mon Sep 17 00:00:00 2001 From: peg Date: Wed, 17 Jun 2026 10:55:38 +0200 Subject: [PATCH 4/4] Remove named return type from MultiMeasurements constructor --- multimeasurements/multimeasurements.go | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/multimeasurements/multimeasurements.go b/multimeasurements/multimeasurements.go index 5eb1914..9d00136 100644 --- a/multimeasurements/multimeasurements.go +++ b/multimeasurements/multimeasurements.go @@ -47,27 +47,23 @@ type rawGCPMeasurements struct { // New returns a MultiMeasurements instance, with the measurements // loaded from a file or URL. -func New(path string) (m *MultiMeasurements, err error) { - var data []byte +func New(path string) (*MultiMeasurements, error) { if strings.HasPrefix(path, "http") { - // load from URL resp, err := http.Get(path) if err != nil { return nil, err } defer resp.Body.Close() - data, err = io.ReadAll(resp.Body) - if err != nil { - return nil, err - } - } else { - // load from file - data, err = os.ReadFile(path) + data, err := io.ReadAll(resp.Body) if err != nil { return nil, err } + return NewFromBytes(data) + } + data, err := os.ReadFile(path) + if err != nil { + return nil, err } - return NewFromBytes(data) }