diff --git a/.github/workflows/build-deploy-nitro-enclave.yaml b/.github/workflows/build-deploy-nitro-enclave.yaml index cf21554..13a2726 100644 --- a/.github/workflows/build-deploy-nitro-enclave.yaml +++ b/.github/workflows/build-deploy-nitro-enclave.yaml @@ -223,8 +223,29 @@ jobs: id: create-insecure-eif run: | docker load --input /tmp/${{ matrix.app }}-nitro-insecure-image.tar - sudo nitro-cli build-enclave --docker-uri "${{ matrix.nitro_prefix }}-insecure:latest" --output-file go-enclave-outbound-insecure.eif + BUILD_OUTPUT=$(sudo nitro-cli build-enclave --docker-uri "${{ matrix.nitro_prefix }}-insecure:latest" --output-file go-enclave-outbound-insecure.eif) + echo "$BUILD_OUTPUT" ls -la go-enclave-outbound-insecure.eif + + # Extract PCR measurements for the insecure image + MEASUREMENTS_SECTION=$(echo "$BUILD_OUTPUT" | awk '/^{/,/^}$/' | sed 's/^}/}/') + echo "$MEASUREMENTS_SECTION" > pcr_measurements_insecure.json + + if [ -f pcr_measurements_insecure.json ]; then + MEASUREMENTS=$(cat pcr_measurements_insecure.json) + PCR0=$(echo "$MEASUREMENTS" | grep -oP '"PCR0": "\K[^"]+' || echo "N/A") + PCR1=$(echo "$MEASUREMENTS" | grep -oP '"PCR1": "\K[^"]+' || echo "N/A") + PCR2=$(echo "$MEASUREMENTS" | grep -oP '"PCR2": "\K[^"]+' || echo "N/A") + + echo "**Insecure PCR Measurements [FOR TESTING ONLY] (${{ matrix.app }}):**" >> $GITHUB_STEP_SUMMARY + echo '```' >> $GITHUB_STEP_SUMMARY + echo "PCR0: $PCR0" >> $GITHUB_STEP_SUMMARY + echo "PCR1: $PCR1" >> $GITHUB_STEP_SUMMARY + echo "PCR2: $PCR2" >> $GITHUB_STEP_SUMMARY + echo '```' >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + fi + # Overwrite the EIF so the plugin Dockerfile picks it up sudo cp go-enclave-outbound-insecure.eif go-enclave-outbound.eif @@ -278,7 +299,7 @@ jobs: - name: confidential-workflows nitro_prefix: nitro-workflows plugin_prefix: enclave-workflows - runs-on: [self-hosted, Linux, X64] + runs-on: ubuntu-latest permissions: contents: read packages: write diff --git a/.github/workflows/go-tests.yaml b/.github/workflows/go-tests.yaml index 599b941..f2b4292 100644 --- a/.github/workflows/go-tests.yaml +++ b/.github/workflows/go-tests.yaml @@ -22,8 +22,14 @@ on: - cron: '0 7 * * *' env: CHAINLINK_COMMIT_SHA: a1ea8cd880345a3a6c80bf70f079c7dc783d5493 - LEGACY_COMMIT_SHA: 24ffd2435ed7d6bca45525c2189d720b7a4ef6bc # v0.0.14, the commit pointing to whatever is capability gitref is deployed in Chainlink's plugins.private.yaml: https://github.com/smartcontractkit/chainlink/blob/develop/plugins/plugins.private.yaml#L50. + LEGACY_COMMIT_SHA: 7d70fcf65d8b5af102d8267b54d31ea163dea203 # initial commit, the commit pointing to whatever is capability gitref is deployed in Chainlink's plugins.private.yaml: https://github.com/smartcontractkit/chainlink/blob/develop/plugins/plugins.private.yaml#L50. LEGACY_ENCLAVE_RELEASE: v1.3.0 # The release version whose enclaves are currently deployed in production. Update this when deploying new enclaves. + # Optional PCR override: when all three are set, the legacy-enclaves leg uses + # these measurements directly instead of fetching them from the release build + # logs. Leave blank to fetch from LEGACY_ENCLAVE_RELEASE. + LEGACY_ENCLAVE_PCR0: 7cf9af0301ac6aff79c3f0c3d069c72ceb75cd6aebf118754de8368023bed3ad51d97c7e370fee0bf83719772be709f8 + LEGACY_ENCLAVE_PCR1: 4b4d5b3661b3efc12920900c80e126e4ce783c522de6c02a2a5bf7af3a2b9327b86776f188e4be1c1c404a129dbda493 + LEGACY_ENCLAVE_PCR2: e651899c697e2aced754cba2b0cd49c3ae73b0d0fbac104adf93a6e0869bc5237452fcfac5aacbd84dea199121812416 # Pinned refs for the supporting CRE Docker images, used as GHCR cache keys. JD_VERSION: 0.22.1 # job-distributor release (checked out as v${JD_VERSION}) ATLAS_SHA: da84cb72d3a160e02896247d46ab4b9806ebee2f # chip-ingress + chip-config @@ -33,11 +39,11 @@ jobs: # run the real-enclave e2e + integration suite plus the backwards-compat and # legacy-enclaves variants; the nightly schedule runs only the real-enclave # e2e + integration suite. Pull requests exercise fake enclaves for the - # standard and remote-legacy variants. Backwards-compatibility runs a prior-release - # capability plugin, which predates fake enclaves and cannot validate their - # attestation, so for now it runs against real enclaves on the self-hosted - # Nitro runner. TODO(PRIV-506): once a prior release with fake-enclave support - # exists, switch this variant back to fake enclaves on the hosted runners. + # standard and backwards-compat variants, plus the remote legacy-enclaves + # variant. Backwards-compatibility runs a prior-release capability plugin + # against fake enclaves via the confidential-HTTP e2e portion (the prior + # release now supports fake enclaves, so it no longer needs the self-hosted + # Nitro runner). # # Applying the "e2e-real-enclaves" label to a PR additionally runs the e2e + # integration suites against real Nitro enclaves on the self-hosted runner. @@ -67,14 +73,14 @@ jobs: E2E_HTTP='{"name":"E2E confidential HTTP (fake enclaves)","real":false,"legacy":false,"legacy-enclaves":false,"portion":"e2e-http"}' E2E_WORKFLOWS='{"name":"E2E workflow engine (fake enclaves)","real":false,"legacy":false,"legacy-enclaves":false,"portion":"e2e-workflows"}' REAL_E2E='{"name":"e2e + integration (real enclaves)","real":true,"legacy":false,"legacy-enclaves":false}' - BACKWARDS_COMPAT='{"name":"Test backwards compatibility (real enclaves)","real":true,"legacy":true,"legacy-enclaves":false}' + BACKWARDS_COMPAT='{"name":"Test backwards compatibility (fake enclaves)","real":false,"legacy":true,"legacy-enclaves":false,"portion":"e2e-http"}' LEGACY_ENCLAVES='{"name":"Test against legacy enclaves","real":false,"legacy":false,"legacy-enclaves":true}' # ---- Select variants for the triggering event ---- case "${{ github.event_name }}" in pull_request) - # Fake-enclave suites for fast feedback, plus the real-enclave - # backwards-compat and remote-legacy variants. + # Fake-enclave suites for fast feedback (including backwards-compat), + # plus the remote legacy-enclaves variant. VARIANTS=("$UNIT" "$E2E_HTTP" "$E2E_WORKFLOWS" "$BACKWARDS_COMPAT" "$LEGACY_ENCLAVES") # Opt-in: the "e2e-real-enclaves" label adds a real Nitro e2e + # integration run on the self-hosted runner. @@ -83,9 +89,11 @@ jobs: fi ;; push) - # Release-branch pushes: the same real/legacy set a PR runs, minus - # the fake-enclave suites. Pushes to main are gated out by - # run-suite, so this only runs on release/** pushes. + # Release-branch pushes: the real e2e + integration suite plus the + # backwards-compat and remote legacy-enclaves variants (dropping the + # plain fake-enclave unit/e2e suites a PR runs for fast feedback). + # Pushes to main are gated out by run-suite, so this only runs on + # release/** pushes. VARIANTS=("$REAL_E2E" "$BACKWARDS_COMPAT" "$LEGACY_ENCLAVES") ;; *) @@ -480,9 +488,9 @@ jobs: test: name: ${{ matrix.name }} - # Real-enclave variants (the nightly run and the backwards-compatibility - # variant) require the self-hosted Nitro instance; fake-enclave variants run - # on regular GitHub-hosted runners. + # Real-enclave variants (the nightly run and the label-triggered e2e + + # integration suite) require the self-hosted Nitro instance; fake-enclave + # variants run on regular GitHub-hosted runners. runs-on: ${{ matrix.real && fromJSON('["self-hosted", "Linux", "X64"]') || fromJSON(format('["runs-on={0}", "cpu=16+32", "ram=8+64", "family=c7i+c7a+c6i", "volume=200gb"]', github.run_id)) }} if: ${{ needs.setup.outputs.run-suite == 'true' }} needs: [setup, build, prebuild-images] @@ -714,44 +722,51 @@ jobs: env: GH_TOKEN: ${{ github.token }} run: | - echo "Fetching PCR measurements for legacy enclave release ${{ env.LEGACY_ENCLAVE_RELEASE }}..." + if [ -n "${{ env.LEGACY_ENCLAVE_PCR0 }}" ] && [ -n "${{ env.LEGACY_ENCLAVE_PCR1 }}" ] && [ -n "${{ env.LEGACY_ENCLAVE_PCR2 }}" ]; then + echo "Using PCR measurement override (skipping release fetch)..." + PCR0="${{ env.LEGACY_ENCLAVE_PCR0 }}" + PCR1="${{ env.LEGACY_ENCLAVE_PCR1 }}" + PCR2="${{ env.LEGACY_ENCLAVE_PCR2 }}" + else + echo "Fetching PCR measurements for legacy enclave release ${{ env.LEGACY_ENCLAVE_RELEASE }}..." - # Get the release notes to find the workflow run URL - RELEASE_BODY=$(gh release view "${{ env.LEGACY_ENCLAVE_RELEASE }}" --repo "${{ github.repository }}" --json body --jq '.body') - echo "Release body: $RELEASE_BODY" + # Get the release notes to find the workflow run URL + RELEASE_BODY=$(gh release view "${{ env.LEGACY_ENCLAVE_RELEASE }}" --repo "${{ github.repository }}" --json body --jq '.body') + echo "Release body: $RELEASE_BODY" - # Extract the workflow run ID from the release notes URL - RUN_ID=$(echo "$RELEASE_BODY" | grep -oP 'actions/runs/\K[0-9]+' || true) - if [ -z "$RUN_ID" ]; then - echo "::error::Could not find workflow run ID in release notes for ${{ env.LEGACY_ENCLAVE_RELEASE }}" - exit 1 - fi - echo "Found workflow run ID: $RUN_ID" - - # Get the build-enclave-and-plugin job ID. The build is now a matrix - # across enclave apps, so match the confidential-http job (which builds - # the enclave the CI test connects to). Fall back to the pre-matrix - # single-job name for older releases. - JOB_ID=$(gh api "repos/${{ github.repository }}/actions/runs/${RUN_ID}/jobs" \ - --jq '.jobs[] | select(.name == "build-enclave-and-plugin" or (.name | startswith("build-enclave-and-plugin (confidential-http"))) | .id' | head -1) - echo "Found job ID: $JOB_ID" - - # Download job logs and parse PCR measurements - JOB_LOG=$(gh api "repos/${{ github.repository }}/actions/jobs/${JOB_ID}/logs" 2>/dev/null || true) - - # The build logs two measurement blocks: the secure image (printed - # first) and the insecure (ALLOW_RECONFIG) image whose PCRs differ. - # The deployed CI enclaves run the insecure EIF, so parse the block - # logged after the insecure image build, not the secure one. - INSECURE_LOG=$(echo "$JOB_LOG" | sed -n '/nitro-enclave-insecure:latest/,$p') - - PCR0=$(echo "$INSECURE_LOG" | grep -oP '"PCR0"\s*:\s*"\K[0-9a-f]+' | head -1 || true) - PCR1=$(echo "$INSECURE_LOG" | grep -oP '"PCR1"\s*:\s*"\K[0-9a-f]+' | head -1 || true) - PCR2=$(echo "$INSECURE_LOG" | grep -oP '"PCR2"\s*:\s*"\K[0-9a-f]+' | head -1 || true) - - if [ -z "$PCR0" ] || [ -z "$PCR1" ] || [ -z "$PCR2" ]; then - echo "::error::Could not parse PCR measurements from job $JOB_ID logs" - exit 1 + # Extract the workflow run ID from the release notes URL + RUN_ID=$(echo "$RELEASE_BODY" | grep -oP 'actions/runs/\K[0-9]+' || true) + if [ -z "$RUN_ID" ]; then + echo "::error::Could not find workflow run ID in release notes for ${{ env.LEGACY_ENCLAVE_RELEASE }}" + exit 1 + fi + echo "Found workflow run ID: $RUN_ID" + + # Get the build-enclave-and-plugin job ID. The build is now a matrix + # across enclave apps, so match the confidential-http job (which builds + # the enclave the CI test connects to). Fall back to the pre-matrix + # single-job name for older releases. + JOB_ID=$(gh api "repos/${{ github.repository }}/actions/runs/${RUN_ID}/jobs" \ + --jq '.jobs[] | select(.name == "build-enclave-and-plugin" or (.name | startswith("build-enclave-and-plugin (confidential-http"))) | .id' | head -1) + echo "Found job ID: $JOB_ID" + + # Download job logs and parse PCR measurements + JOB_LOG=$(gh api "repos/${{ github.repository }}/actions/jobs/${JOB_ID}/logs" 2>/dev/null || true) + + # The build logs two measurement blocks: the secure image (printed + # first) and the insecure (ALLOW_RECONFIG) image whose PCRs differ. + # The deployed CI enclaves run the insecure EIF, so parse the block + # logged after the insecure image build, not the secure one. + INSECURE_LOG=$(echo "$JOB_LOG" | sed -n '/nitro-enclave-insecure:latest/,$p') + + PCR0=$(echo "$INSECURE_LOG" | grep -oP '"PCR0"\s*:\s*"\K[0-9a-f]+' | head -1 || true) + PCR1=$(echo "$INSECURE_LOG" | grep -oP '"PCR1"\s*:\s*"\K[0-9a-f]+' | head -1 || true) + PCR2=$(echo "$INSECURE_LOG" | grep -oP '"PCR2"\s*:\s*"\K[0-9a-f]+' | head -1 || true) + + if [ -z "$PCR0" ] || [ -z "$PCR1" ] || [ -z "$PCR2" ]; then + echo "::error::Could not parse PCR measurements from job $JOB_ID logs" + exit 1 + fi fi # Write pcr_measurements.json matching the format produced by nitro-cli @@ -912,8 +927,9 @@ jobs: # Clean up JD database volume if it exists docker volume rm job-distributor-db-volume || true - # Scheduled/release runs (and the backwards-compat variant) exercise REAL - # enclaves and only run the ./tests module (integration + tests/e2e). + # Real-enclave runs (the nightly schedule, release pushes, and the + # label-triggered variant) only run the ./tests module (integration + + # tests/e2e). if [ "${{ matrix.real }}" = "true" ]; then echo "Real-enclave run: testing the ./tests module against Nitro enclaves" # Leave ENCLAVE_TYPE unset so the harness provisions real Nitro enclaves. diff --git a/enclave-client/attestation-validator/describe.go b/enclave-client/attestation-validator/describe.go new file mode 100644 index 0000000..466b035 --- /dev/null +++ b/enclave-client/attestation-validator/describe.go @@ -0,0 +1,44 @@ +package attestationvalidator + +import ( + "fmt" + + "github.com/fxamacker/cbor/v2" +) + +// coseSign1 mirrors the COSE_Sign1 array structure wrapping a Nitro attestation. +type coseSign1 struct { + _ struct{} `cbor:",toarray"` //nolint:revive // idiomatic CBOR array encoding + Protected []byte + Unprotected cbor.RawMessage + Payload []byte + Signature []byte +} + +// attestationPayload holds the Nitro attestation payload fields used for diagnostics. +type attestationPayload struct { + ModuleID string `cbor:"module_id"` + PCRs map[uint][]byte `cbor:"pcrs"` +} + +// DescribeMeasurements decodes the PCR measurements a Nitro attestation actually +// reports, for diagnostic logging when validation fails. It does NOT verify the +// signature or certificate chain; it only parses the COSE_Sign1 payload so the +// measurements the enclave reported can be compared against the expected trusted +// values. Returns a short summary, or a note when the document cannot be parsed +// (e.g. a fake attestation). +func DescribeMeasurements(attestation []byte) string { + if len(attestation) == 0 { + return "no attestation received" + } + var sign1 coseSign1 + if err := cbor.Unmarshal(attestation, &sign1); err != nil || len(sign1.Payload) == 0 { + return "unparseable attestation (not a Nitro COSE document)" + } + var payload attestationPayload + if err := cbor.Unmarshal(sign1.Payload, &payload); err != nil { + return "unparseable attestation payload" + } + return fmt.Sprintf("moduleID=%s PCR0=%x PCR1=%x PCR2=%x", + payload.ModuleID, payload.PCRs[0], payload.PCRs[1], payload.PCRs[2]) +} diff --git a/enclave-client/go.mod b/enclave-client/go.mod index ec42eb0..20a1b44 100644 --- a/enclave-client/go.mod +++ b/enclave-client/go.mod @@ -5,6 +5,7 @@ go 1.26.4 replace github.com/smartcontractkit/confidential-compute => ../ require ( + github.com/fxamacker/cbor/v2 v2.9.0 github.com/smartcontractkit/chainlink-common v0.11.2-0.20260714160921-4033d0253977 github.com/smartcontractkit/confidential-compute v0.0.0-00010101000000-000000000000 github.com/stretchr/testify v1.11.1 @@ -19,7 +20,6 @@ require ( github.com/cloudevents/sdk-go/v2 v2.16.1 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/doyensec/safeurl v0.2.2 // indirect - github.com/fxamacker/cbor/v2 v2.9.0 // indirect github.com/gabriel-vasile/mimetype v1.4.8 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect diff --git a/enclave-client/pool.go b/enclave-client/pool.go index 7b4c16e..dcad7eb 100644 --- a/enclave-client/pool.go +++ b/enclave-client/pool.go @@ -945,7 +945,8 @@ func (c *enclavePool) validateAttestationAgainstMultipleMeasurements(enclave typ if combinedErr == nil { combinedErr = errors.New("no trusted measurements configured") } - return false, fmt.Errorf("attestation validation failed for enclave %x: %w", enclave.EnclaveID, combinedErr) + return false, fmt.Errorf("attestation validation failed for enclave %x (received %s): %w", + enclave.EnclaveID, attestationvalidator.DescribeMeasurements(attestation), combinedErr) } } } diff --git a/scripts/check_prod.go b/scripts/check_prod.go index 515208c..429ac79 100644 --- a/scripts/check_prod.go +++ b/scripts/check_prod.go @@ -8,9 +8,41 @@ import ( "os" "time" + "github.com/fxamacker/cbor/v2" + "github.com/smartcontractkit/chainlink-common/pkg/teeattestation/nitro" ) +// coseSign1 mirrors the COSE_Sign1 array structure wrapping a Nitro attestation. +type coseSign1 struct { + _ struct{} `cbor:",toarray"` + Protected []byte + Unprotected cbor.RawMessage + Payload []byte + Signature []byte +} + +// attestationPayload holds the Nitro attestation payload fields used for diagnostics. +type attestationPayload struct { + ModuleID string `cbor:"module_id"` + PCRs map[uint][]byte `cbor:"pcrs"` +} + +// parseAttestationPCRs decodes the PCR measurements a Nitro attestation reports, +// without verifying its signature or certificate chain. It lets us surface the +// measurements the enclave actually reported when no known measurement validates. +func parseAttestationPCRs(attestation []byte) (map[uint][]byte, error) { + var sign1 coseSign1 + if err := cbor.Unmarshal(attestation, &sign1); err != nil || len(sign1.Payload) == 0 { + return nil, fmt.Errorf("not a Nitro COSE document") + } + var payload attestationPayload + if err := cbor.Unmarshal(sign1.Payload, &payload); err != nil { + return nil, fmt.Errorf("unparseable attestation payload: %w", err) + } + return payload.PCRs, nil +} + // pcrSetJSON renders a PCRSet as the trusted-measurement JSON that // nitro.ValidateAndParse consumes (hex-string pcr0/pcr1/pcr2 fields). func pcrSetJSON(m PCRSet) []byte { @@ -64,6 +96,13 @@ func runCheckProd(args []string) { fmt.Printf(" PCR2: %s\n", hex.EncodeToString(doc.PCRs[2])) } else { fmt.Printf(" Running: UNKNOWN - no listed measurement validates the live attestation\n") + if pcrs, err := parseAttestationPCRs(resp.Attestation); err == nil { + fmt.Printf(" Attested PCR0: %s\n", hex.EncodeToString(pcrs[0])) + fmt.Printf(" Attested PCR1: %s\n", hex.EncodeToString(pcrs[1])) + fmt.Printf(" Attested PCR2: %s\n", hex.EncodeToString(pcrs[2])) + } else { + fmt.Printf(" (could not parse attested PCRs: %v)\n", err) + } // Try each measurement anyway to surface why none matched. for i, m := range validMeasurements { _, err := nitro.ValidateAndParse(resp.Attestation, userData[:], pcrSetJSON(m)) diff --git a/scripts/go.mod b/scripts/go.mod index 9256c90..6251808 100644 --- a/scripts/go.mod +++ b/scripts/go.mod @@ -5,6 +5,7 @@ go 1.26.4 replace github.com/smartcontractkit/confidential-compute => ../ require ( + github.com/fxamacker/cbor/v2 v2.9.0 github.com/smartcontractkit/chainlink-common v0.11.2-0.20260714160921-4033d0253977 github.com/smartcontractkit/chainlink-protos/cre/go v0.0.0-20260622152157-c8e129347b8b github.com/smartcontractkit/confidential-compute v0.0.0-20251014091702-53f02d33c6b4 @@ -16,7 +17,6 @@ require ( require ( github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/doyensec/safeurl v0.2.2 // indirect - github.com/fxamacker/cbor/v2 v2.9.0 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/hf/nsm v0.0.0-20220930140112-cd181bd646b9 // indirect github.com/mdlayher/socket v0.4.1 // indirect