diff --git a/.github/workflows/integration-emu.yml b/.github/workflows/integration-emu.yml index fd854c44d..c9ac9c587 100644 --- a/.github/workflows/integration-emu.yml +++ b/.github/workflows/integration-emu.yml @@ -121,6 +121,15 @@ jobs: test-command: "./migtdemu.sh --operation rebind-prepare --policy-file ./config/AzCVMEmu/policy_v2_corim.json --servtd-signer-anchor-file ./config/AzCVMEmu/servtd_signer_anchor.bin --servtd-corim-file ./config/AzCVMEmu/tcb_mapping_corim.cose --mock-report --features spdm_attestation --both --no-sudo --log-level info" artifact-name: "corim-rebind-test-logs" + # The source CoRIM maps the mock TDINFO hash while the destination + # CoRIM does not, requiring lookup through the authenticated peer. + - test-name: "CoRIM Asymmetric Peer SVN Lookup (Mock Report)" + test-type: "corim-asymmetric-migration" + install-jq: 'true' + timeout-seconds: 900 + test-command: "./migtdemu.sh --policy-v2 --policy-file ./config/AzCVMEmu/policy_v2_corim_asymmetric.json --servtd-signer-anchor-file ./config/AzCVMEmu/servtd_signer_anchor_asymmetric.bin --src-servtd-corim-file ./config/AzCVMEmu/tcb_mapping_corim_asymmetric_src.cose --dst-servtd-corim-file ./config/AzCVMEmu/tcb_mapping_corim_asymmetric_dst.cose --mock-report --features spdm_attestation --both --no-sudo --log-level info" + artifact-name: "corim-asymmetric-migration-test-logs" + - test-name: "SPDM Rebind Prepare (Skip RA)" test-type: "spdm-rebind-skip-ra" install-jq: 'true' @@ -249,6 +258,7 @@ jobs: run: | chmod +x ./migtdemu.sh chmod +x ./sh_script/build_AzCVMEmu_policy_and_test.sh + chmod +x ./sh_script/gen_asymmetric_servtd_corim_fixture.sh - name: Generate policy files if: matrix.install-jq == 'true' @@ -267,6 +277,10 @@ jobs: config/AzCVMEmu/tcb_mapping_corim.cose fi + - name: Generate asymmetric CoRIM fixtures + if: matrix.test-type == 'corim-asymmetric-migration' + run: ./sh_script/gen_asymmetric_servtd_corim_fixture.sh + - name: Run test id: test_run run: | @@ -447,6 +461,35 @@ jobs: done fi + # Confirm the peer mappings differ and the policy check succeeded. + if [[ "${{ matrix.test-type }}" == "corim-asymmetric-migration" ]]; then + echo "" + echo "=== Verifying asymmetric peer CoRIM fixtures ===" + if ! jq -e '[.policyData.policy[] | select(has("servtd"))] | length == 0' \ + config/AzCVMEmu/policy_v2_corim_asymmetric.json >/dev/null; then + echo "❌ policy_v2_corim_asymmetric.json still contains a servtd isvsvn rule (would mask the decisive check)" + exit 1 + fi + if [[ $(wc -c < config/AzCVMEmu/servtd_signer_anchor_asymmetric.bin) -ne 48 ]]; then + echo "❌ Asymmetric ServTD signer anchor is not 48 bytes" + exit 1 + fi + if cmp -s config/AzCVMEmu/tcb_mapping_corim_asymmetric_src.cose \ + config/AzCVMEmu/tcb_mapping_corim_asymmetric_dst.cose; then + echo "❌ Source and destination CoRIM fixtures are byte-identical -- scenario is not asymmetric" + exit 1 + fi + echo "✅ Source/destination CoRIM fixtures differ and share a 48-byte signer anchor" + for ROLE in source destination; do + LOG=$(find . -maxdepth 1 -name "migtd_*_${ROLE}.log" -print -quit) + if [[ -n "$LOG" ]] && grep -aq "UnqualifiedMigTdInfo\|Policy v2 check failed" "$LOG"; then + echo "❌ $ROLE log shows a policy check failure despite the run being reported as passing" + exit 1 + fi + done + echo "✅ No UnqualifiedMigTdInfo/policy-check-failed evidence in either role's log" + fi + # Dump revocation-related log lines for the signer-revocation test. if [[ "${{ matrix.test-type }}" == "policy-v2-revoked" ]]; then echo "" @@ -477,6 +520,10 @@ jobs: config/AzCVMEmu/policy_v2_corim.json config/AzCVMEmu/servtd_signer_anchor.bin config/AzCVMEmu/tcb_mapping_corim.cose + config/AzCVMEmu/policy_v2_corim_asymmetric.json + config/AzCVMEmu/servtd_signer_anchor_asymmetric.bin + config/AzCVMEmu/tcb_mapping_corim_asymmetric_src.cose + config/AzCVMEmu/tcb_mapping_corim_asymmetric_dst.cose target/release/migtd retention-days: 7 if-no-files-found: ignore diff --git a/.gitignore b/.gitignore index 55e43a20e..d475e5d62 100644 --- a/.gitignore +++ b/.gitignore @@ -37,6 +37,11 @@ config/AzCVMEmu/policy_issuer_chain*.pem config/AzCVMEmu/policy_v2_corim.json config/AzCVMEmu/servtd_signer_anchor.bin config/AzCVMEmu/tcb_mapping_corim.cose +# Generated asymmetric CoRIM fixtures +config/AzCVMEmu/policy_v2_corim_asymmetric.json +config/AzCVMEmu/servtd_signer_anchor_asymmetric.bin +config/AzCVMEmu/tcb_mapping_corim_asymmetric_src.cose +config/AzCVMEmu/tcb_mapping_corim_asymmetric_dst.cose config/Azure/policy_v2_signed*.json config/Azure/policy_issuer_chain*.pem # Auto-generated collateral/tcb-mapping (fetched/regenerated by the mock-test builds) diff --git a/sh_script/corim_cli_helpers.sh b/sh_script/corim_cli_helpers.sh new file mode 100644 index 000000000..cf9228e32 --- /dev/null +++ b/sh_script/corim_cli_helpers.sh @@ -0,0 +1,184 @@ +#!/usr/bin/env bash + +CORIM_CLI_REV="8438b89051aac8e170c753c17540d24c2eb27650" + +configure_corim_cli() { + local repo_root="$1" + CORIM_CLI_INSTALL_ROOT="${CORIM_CLI_INSTALL_ROOT:-$repo_root/target/corim-cli-$CORIM_CLI_REV}" + CORIM_CLI_BIN="${CORIM_CLI:-$CORIM_CLI_INSTALL_ROOT/bin/corim-cli}" +} + +install_corim_cli() { + if [ ! -x "$CORIM_CLI_BIN" ]; then + if [ -n "${CORIM_CLI:-}" ]; then + echo "CORIM_CLI is not executable: $CORIM_CLI_BIN" >&2 + return 1 + fi + + echo "Installing official Azure/corim CLI at $CORIM_CLI_REV..." + cargo install \ + --locked \ + --git https://github.com/Azure/corim \ + --rev "$CORIM_CLI_REV" \ + --root "$CORIM_CLI_INSTALL_ROOT" \ + corim-cli + fi + + if ! "$CORIM_CLI_BIN" sign --help >/dev/null 2>&1; then + echo "corim-cli does not support the required sign command: $CORIM_CLI_BIN" >&2 + echo "Use Azure/corim revision $CORIM_CLI_REV or a newer compatible build." >&2 + return 1 + fi +} + +ecdsa_der_to_p1363() { + local input="$1" + local output="$2" + + python3 - "$input" "$output" <<'PY' +import pathlib +import sys + +data = pathlib.Path(sys.argv[1]).read_bytes() + +def read_length(offset): + if offset >= len(data): + raise ValueError("missing DER length") + first = data[offset] + offset += 1 + if first < 0x80: + return first, offset + width = first & 0x7f + if width == 0 or width > 2 or offset + width > len(data): + raise ValueError("invalid DER length") + return int.from_bytes(data[offset:offset + width], "big"), offset + width + +def read_integer(offset): + if offset >= len(data) or data[offset] != 0x02: + raise ValueError("expected DER INTEGER") + length, offset = read_length(offset + 1) + end = offset + length + if length == 0 or end > len(data): + raise ValueError("invalid DER INTEGER length") + value = data[offset:end] + if value[0] & 0x80: + raise ValueError("negative ECDSA integer") + value = value.lstrip(b"\x00") + if len(value) > 48: + raise ValueError("P-384 integer exceeds 48 bytes") + return value.rjust(48, b"\x00"), end + +if not data or data[0] != 0x30: + raise ValueError("expected DER SEQUENCE") +sequence_length, offset = read_length(1) +if offset + sequence_length != len(data): + raise ValueError("invalid DER SEQUENCE length") +r, offset = read_integer(offset) +s, offset = read_integer(offset) +if offset != len(data): + raise ValueError("trailing data after ECDSA signature") + +pathlib.Path(sys.argv[2]).write_bytes(r + s) +PY +} + +compute_signer_anchor() { + local root_cert="$1" + local signer_eku_oid="$2" + local output="$3" + local work_dir="$4" + local root_der="$work_dir/corim-root.der" + local root_hash="$work_dir/corim-root.sha384" + local eku_der="$work_dir/corim-signer-eku.der" + + openssl x509 -in "$root_cert" -outform DER -out "$root_der" + openssl dgst -sha384 -binary "$root_der" > "$root_hash" + openssl asn1parse -genstr "OID:$signer_eku_oid" -out "$eku_der" -noout + + { + printf '%s' 'MIGTD-RTMR1-ANCHOR-V1' + printf '\0' + cat "$root_hash" + printf '\0' + cat "$eku_der" + } | openssl dgst -sha384 -binary > "$output" + + if [ "$(wc -c < "$output")" -ne 48 ]; then + echo "Generated signer anchor is not 48 bytes" >&2 + return 1 + fi +} + +generate_signed_corim() { + local tdinfo_hash="$1" + local svn="$2" + local generation="$3" + local cert_chain="$4" + local private_key="$5" + local output="$6" + local work_dir="$7" + local template="$work_dir/tcb_mapping_corim.json" + local unsigned="$work_dir/tcb_mapping_corim.cbor" + local staging="$work_dir/tcb_mapping_corim.staging.cose" + local tbs="$work_dir/tcb_mapping_corim.tbs" + local signature_der="$work_dir/tcb_mapping_corim.sig.der" + local signature_raw="$work_dir/tcb_mapping_corim.sig" + local hash_base64 + + hash_base64=$(printf '%s' "$tdinfo_hash" | xxd -r -p | openssl base64 -A) + jq -n \ + --arg hash "$hash_base64" \ + --argjson svn "$svn" \ + --argjson generation "$generation" \ + '{ + "corim-id": "Microsoft/TDX/tcb-mapping", + "comids": [{ + "tag-identity": { + "id": "1F2E3D4C-5B6A-4798-8A9B-0C1D2E3F4A5B", + "version": $generation + }, + "triples": { + "reference-triples": [{ + "ref-env": { + "class": {"vendor": "Intel", "model": "TDX"}, + "instance": {"type": "bytes", "value": "bWlncmF0aW9uLXRk"} + }, + "ref-claims": [{ + "value": {"digests": [[7, $hash]]} + }] + }], + "conditional-endorsement-series-triples": [{ + "common-condition": { + "environment": { + "class": {"vendor": "Intel", "model": "TDX"}, + "instance": {"type": "bytes", "value": "bWlncmF0aW9uLXRk"} + }, + "claims-list": [] + }, + "series": [{ + "condition": [{ + "value": {"digests": [[7, $hash]]} + }], + "addition": [{ + "value": {"svn": {"type": "svn", "value": $svn}} + }] + }] + }] + } + }] + }' > "$template" + + "$CORIM_CLI_BIN" generate "$template" -o "$unsigned" + "$CORIM_CLI_BIN" sign prepare "$unsigned" \ + --alg ES384 \ + --signer-name "MigTD TCB Mapping Endorsement" \ + --x5chain "$cert_chain" \ + --out-staging "$staging" \ + --out-tbs "$tbs" + openssl dgst -sha384 -sign "$private_key" -out "$signature_der" "$tbs" + ecdsa_der_to_p1363 "$signature_der" "$signature_raw" + "$CORIM_CLI_BIN" sign finalize "$staging" \ + --signature "$signature_raw" \ + -o "$output" + "$CORIM_CLI_BIN" validate --skip-expiry "$output" +} diff --git a/sh_script/gen_asymmetric_servtd_corim_fixture.sh b/sh_script/gen_asymmetric_servtd_corim_fixture.sh new file mode 100755 index 000000000..931d9b157 --- /dev/null +++ b/sh_script/gen_asymmetric_servtd_corim_fixture.sh @@ -0,0 +1,97 @@ +#!/usr/bin/env bash +# +# Copyright (c) 2026 Microsoft Corporation +# +# SPDX-License-Identifier: BSD-2-Clause-Patent + +set -euo pipefail + +PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +OUTPUT_DIR="${OUTPUT_DIR:-$PROJECT_ROOT/config/AzCVMEmu}" +WORK_DIR="$PROJECT_ROOT/target/servtd-corim-asymmetric-fixture-work" +BASE_POLICY="$OUTPUT_DIR/policy_v2_corim.json" +BASE_GEN_SCRIPT="$PROJECT_ROOT/sh_script/build_AzCVMEmu_policy_and_test.sh" +SIGNER_EKU_OID="${MIGTD_SIGNER_EKU_OID:-1.3.6.1.4.1.311.76.59.1.43}" + +# shellcheck source=corim_cli_helpers.sh +source "$PROJECT_ROOT/sh_script/corim_cli_helpers.sh" +configure_corim_cli "$PROJECT_ROOT" + +if [[ ! -f "$BASE_POLICY" ]]; then + echo "Missing CoRIM-only policy: $BASE_POLICY" >&2 + exit 1 +fi + +MOCK_TDINFO_HASH="$(grep -oP 'CORIM_MOCK_TDINFO_HASH="\K[^"]+' "$BASE_GEN_SCRIPT")" +if [[ -z "$MOCK_TDINFO_HASH" ]]; then + echo "Missing mock TDINFO hash in $BASE_GEN_SCRIPT" >&2 + exit 1 +fi + +rm -rf "$WORK_DIR" +mkdir -p "$WORK_DIR/src" "$WORK_DIR/dst" +trap 'rm -rf "$WORK_DIR"' EXIT + +install_corim_cli + +openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:secp384r1 \ + -out "$WORK_DIR/root.key" +openssl req -new -x509 \ + -key "$WORK_DIR/root.key" \ + -days 3650 \ + -out "$WORK_DIR/root.pem" \ + -subj "/CN=MigTD Asymmetric CoRIM Test Root/O=Microsoft" \ + -sha384 + +openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:secp384r1 \ + -out "$WORK_DIR/leaf.key" +openssl req -new \ + -key "$WORK_DIR/leaf.key" \ + -out "$WORK_DIR/leaf.csr" \ + -subj "/CN=MigTD Asymmetric CoRIM Test Signer/O=Microsoft" +openssl x509 -req \ + -in "$WORK_DIR/leaf.csr" \ + -CA "$WORK_DIR/root.pem" \ + -CAkey "$WORK_DIR/root.key" \ + -CAcreateserial \ + -out "$WORK_DIR/leaf.pem" \ + -days 3650 \ + -sha384 \ + -extensions v3_signer \ + -extfile <(printf '[v3_signer]\nkeyUsage = digitalSignature\nextendedKeyUsage = %s\n' "$SIGNER_EKU_OID") +cat "$WORK_DIR/leaf.pem" "$WORK_DIR/root.pem" > "$WORK_DIR/chain.pem" + +POLICY_SVN="$(jq -er '.policyData.policySvn | select(type == "number")' "$BASE_POLICY")" +compute_signer_anchor \ + "$WORK_DIR/root.pem" \ + "$SIGNER_EKU_OID" \ + "$OUTPUT_DIR/servtd_signer_anchor_asymmetric.bin" \ + "$WORK_DIR" + +# Only the source maps the shared mock hash. +generate_signed_corim \ + "$MOCK_TDINFO_HASH" \ + 2 \ + "$POLICY_SVN" \ + "$WORK_DIR/chain.pem" \ + "$WORK_DIR/leaf.key" \ + "$OUTPUT_DIR/tcb_mapping_corim_asymmetric_src.cose" \ + "$WORK_DIR/src" +generate_signed_corim \ + "$(printf 'DEADBEEF%.0s' {1..12})" \ + 1 \ + "$POLICY_SVN" \ + "$WORK_DIR/chain.pem" \ + "$WORK_DIR/leaf.key" \ + "$OUTPUT_DIR/tcb_mapping_corim_asymmetric_dst.cose" \ + "$WORK_DIR/dst" + +jq '.policyData.policy |= [.[] | select(has("servtd") | not)]' \ + "$BASE_POLICY" > "$OUTPUT_DIR/policy_v2_corim_asymmetric.json" + +if cmp -s \ + "$OUTPUT_DIR/tcb_mapping_corim_asymmetric_src.cose" \ + "$OUTPUT_DIR/tcb_mapping_corim_asymmetric_dst.cose"; then + echo "Source and destination CoRIMs must differ" >&2 + exit 1 +fi diff --git a/src/migtd/src/mig_policy.rs b/src/migtd/src/mig_policy.rs index d0ebe91c2..25e251156 100644 --- a/src/migtd/src/mig_policy.rs +++ b/src/migtd/src/mig_policy.rs @@ -178,17 +178,24 @@ mod v2 { policy_peer.len(), event_log_peer.len() ); - let (policy_peer, peer_issuer_chain) = + let (policy_peer, peer_issuer_chain, peer_servtd_corim) = crate::migration::pre_session_data::decode_peer_data(policy_peer) .ok_or(PolicyError::InvalidParameter)?; log::info!( - "BC> POL-AR-02 decode_peer_data ok policy.len={} issuer_chain.len={}\n", + "BC> POL-AR-02 decode_peer_data ok policy.len={} issuer_chain.len={} servtd_corim.len={}\n", policy_peer.len(), - peer_issuer_chain.len() + peer_issuer_chain.len(), + peer_servtd_corim.map(|c| c.len()).unwrap_or(0) ); if is_src { log::info!("BC> POL-AR-03 -> authenticate_migration_dest\n"); - authenticate_migration_dest(quote_peer, event_log_peer, policy_peer, peer_issuer_chain) + authenticate_migration_dest( + quote_peer, + event_log_peer, + policy_peer, + peer_issuer_chain, + peer_servtd_corim, + ) } else { log::info!("BC> POL-AR-03 -> authenticate_migration_source\n"); authenticate_migration_source( @@ -196,6 +203,7 @@ mod v2 { event_log_peer, policy_peer, peer_issuer_chain, + peer_servtd_corim, ) } } @@ -205,6 +213,7 @@ mod v2 { event_log_dst: &[u8], mig_policy_dst: &[u8], policy_issuer_chain: &[u8], + peer_servtd_corim: Option<&[u8]>, ) -> Result, PolicyError> { log::info!("BC> POL-DST-01 authenticate_remote_common BEGIN\n"); let (evaluation_data_dst, verified_policy_dst, suppl_data) = authenticate_remote_common( @@ -212,6 +221,7 @@ mod v2 { event_log_dst, mig_policy_dst, policy_issuer_chain, + peer_servtd_corim, )?; log::info!( "BC> POL-DST-02 authenticate_remote_common ok suppl_data.len={}\n", @@ -248,6 +258,7 @@ mod v2 { event_log_src: &[u8], mig_policy_src: &[u8], policy_issuer_chain: &[u8], + peer_servtd_corim: Option<&[u8]>, ) -> Result, PolicyError> { log::info!("BC> POL-SRC-01 authenticate_remote_common BEGIN\n"); let (evaluation_data_src, _verified_policy_src, suppl_data) = authenticate_remote_common( @@ -255,6 +266,7 @@ mod v2 { event_log_src, mig_policy_src, policy_issuer_chain, + peer_servtd_corim, )?; log::info!( "BC> POL-SRC-02 authenticate_remote_common ok suppl_data.len={}\n", @@ -286,7 +298,7 @@ mod v2 { event_log_dst: &[u8], mig_policy_dst: &[u8], ) -> Result, PolicyError> { - let (mig_policy_dst, peer_issuer_chain) = + let (mig_policy_dst, peer_issuer_chain, peer_servtd_corim) = crate::migration::pre_session_data::decode_peer_data(mig_policy_dst) .ok_or(PolicyError::InvalidParameter)?; let (evaluation_data_dst, verified_policy_dst, tdx_report) = authenticate_rebinding_common( @@ -294,6 +306,7 @@ mod v2 { event_log_dst, mig_policy_dst, peer_issuer_chain, + peer_servtd_corim, )?; let relative_reference = get_local_tcb_evaluation_info()?; let policy = get_verified_policy().ok_or(PolicyError::InvalidParameter)?; @@ -327,7 +340,7 @@ mod v2 { _init_tdinfo: &[u8], servtd_ext_src: &[u8], ) -> Result, PolicyError> { - let (mig_policy_src, peer_issuer_chain) = + let (mig_policy_src, peer_issuer_chain, peer_servtd_corim) = crate::migration::pre_session_data::decode_peer_data(mig_policy_src) .ok_or(PolicyError::InvalidParameter)?; // Verify quote src / event log src / policy src @@ -336,6 +349,7 @@ mod v2 { event_log_src, mig_policy_src, peer_issuer_chain, + peer_servtd_corim, )?; let policy = get_verified_policy().ok_or(PolicyError::InvalidParameter)?; @@ -363,6 +377,7 @@ mod v2 { event_log: &[u8], mig_policy: &'p [u8], policy_issuer_chain: &[u8], + peer_servtd_corim: Option<&[u8]>, ) -> Result<(PolicyEvaluationInfo, VerifiedPolicy<'p>, Vec), PolicyError> { let policy = get_verified_policy().ok_or(PolicyError::InvalidParameter)?; @@ -383,6 +398,7 @@ mod v2 { event_log, mig_policy, policy_issuer_chain, + peer_servtd_corim, &get_rtmrs_from_suppl_data(&suppl_data)?, )?; log::info!("BC> POL-CMN-03 verify_policy_and_event_log ok\n"); @@ -405,6 +421,7 @@ mod v2 { event_log: &[u8], mig_policy: &'p [u8], policy_issuer_chain: &[u8], + peer_servtd_corim: Option<&[u8]>, ) -> Result<(PolicyEvaluationInfo, VerifiedPolicy<'p>, TdxReport), PolicyError> { // 1. Verify quote & get supplemental data let tdreport_verified = verify_tdreport(tdreport)?; @@ -414,6 +431,7 @@ mod v2 { event_log, mig_policy, policy_issuer_chain, + peer_servtd_corim, &get_rtmrs_from_tdreport(&tdreport_verified)?, )?; @@ -471,6 +489,8 @@ mod v2 { event_log: &[u8], mig_policy: &'p [u8], policy_issuer_chain: &[u8], + #[cfg_attr(not(feature = "servtd_corim"), allow(unused_variables))] + peer_servtd_corim: Option<&[u8]>, rtmrs: &[[u8; SHA384_DIGEST_SIZE]; 4], ) -> Result, PolicyError> { let unverified_policy = RawPolicyData::deserialize_from_json(mig_policy)?; @@ -481,6 +501,7 @@ mod v2 { // 2. Verify the peer policy using the peer's issuer chain let local_policy = get_verified_policy().ok_or(PolicyError::InvalidParameter)?; let local_servtd_crl = local_policy.servtd_crl.as_deref().map(str::as_bytes); + #[cfg_attr(not(feature = "servtd_corim"), allow(unused_mut))] let mut verified_policy = unverified_policy .verify_with_authoritative_servtd_crl(policy_issuer_chain, local_servtd_crl)?; @@ -528,11 +549,13 @@ mod v2 { _ => return Err(PolicyError::PeerCertChainValidation), } - // CoRIM is a local endorsement authority, not peer-supplied policy - // data. Attach our verified CoRIM for peer TCB lookups, then apply our - // measured CRL to every retained peer/local-authority signer chain. + // Peer SVN lookups must use the authenticated peer TCB-mapping CoRIM. #[cfg(feature = "servtd_corim")] - verified_policy.set_servtd_corim_from(local_policy); + if let Some(peer_corim_cose) = peer_servtd_corim { + verified_policy + .attach_verified_peer_servtd_corim(peer_corim_cose) + .log_err("Peer servtd CoRIM verification")?; + } if let Some(servtd_crl) = local_servtd_crl { verified_policy .verify_signer_chains_not_revoked(servtd_crl) @@ -763,7 +786,7 @@ mod v2 { _init_tdinfo: &[u8], servtd_ext_src: &[u8], ) -> Result, PolicyError> { - let (mig_policy_src, peer_issuer_chain) = + let (mig_policy_src, peer_issuer_chain, peer_servtd_corim) = crate::migration::pre_session_data::decode_peer_data(peer_data) .ok_or(PolicyError::InvalidParameter)?; @@ -772,6 +795,7 @@ mod v2 { event_log_src, mig_policy_src, peer_issuer_chain, + peer_servtd_corim, )?; let relative_reference = get_local_tcb_evaluation_info()?; diff --git a/src/migtd/src/migration/pre_session_data.rs b/src/migtd/src/migration/pre_session_data.rs index d7007819c..6cae00785 100644 --- a/src/migtd/src/migration/pre_session_data.rs +++ b/src/migtd/src/migration/pre_session_data.rs @@ -371,26 +371,58 @@ pub(super) async fn exchange_hello_packet( .ok_or(MigrationResult::InvalidParameter) } -/// Encode `(policy, issuer_chain)` into the peer-data blob. +/// Attacker-influenced (VMM-relayed) peer servTD CoRIM size ceiling, enforced +/// at decode time before any CBOR/COSE parsing allocates +/// (`ServtdCorim::decode_signed`). Production TCB-mapping CoRIMs enrolled via +/// the policy-v2 tooling are on the order of a few KB per mapped release; 64 +/// KiB comfortably covers a multi-release cumulative mapping while remaining +/// far below the overall 1 MiB pre-session payload cap +/// (`MAX_PRE_SESSION_PAYLOAD_SIZE`). +pub(crate) const MAX_PEER_SERVTD_CORIM_SIZE: usize = 64 * 1024; + +/// Borrowed `(policy, issuer_chain, servtd_corim)` slices decoded from a +/// peer-data blob. `servtd_corim` is `None` when the peer sent no CoRIM (see +/// [`decode_peer_data`] for exactly when that occurs). +pub(crate) type PeerData<'a> = (&'a [u8], &'a [u8], Option<&'a [u8]>); + +/// Encode `(policy, issuer_chain, servtd_corim)` into the peer-data blob. /// -/// Format: `[u32 LE policy_len][policy][u32 LE chain_len][issuer_chain]`. -/// Returns `None` if either length exceeds `u32::MAX`. -pub(crate) fn encode_peer_data(policy: &[u8], issuer_chain: &[u8]) -> Option> { +/// Format: `[u32 LE policy_len][policy][u32 LE chain_len][issuer_chain] +/// [u32 LE corim_len][servtd_corim]`. The trailing CoRIM field is a +/// wire-compatible *addition*: `servtd_corim` may be empty (no CoRIM +/// enrolled locally), in which case `corim_len` is sent as `0` so the +/// decoder can distinguish "peer build understands the field but has +/// nothing to send" from "peer build predates the field". Returns `None` if +/// any length exceeds `u32::MAX`. +pub(crate) fn encode_peer_data( + policy: &[u8], + issuer_chain: &[u8], + servtd_corim: &[u8], +) -> Option> { let policy_len = u32::try_from(policy.len()).ok()?; let chain_len = u32::try_from(issuer_chain.len()).ok()?; + let corim_len = u32::try_from(servtd_corim.len()).ok()?; - let mut blob = Vec::with_capacity(8 + policy.len() + issuer_chain.len()); + let mut blob = Vec::with_capacity(12 + policy.len() + issuer_chain.len() + servtd_corim.len()); blob.extend_from_slice(&policy_len.to_le_bytes()); blob.extend_from_slice(policy); blob.extend_from_slice(&chain_len.to_le_bytes()); blob.extend_from_slice(issuer_chain); + blob.extend_from_slice(&corim_len.to_le_bytes()); + blob.extend_from_slice(servtd_corim); Some(blob) } /// Decode a peer-data blob produced by [`encode_peer_data`]. /// -/// Returns borrowed `(policy, issuer_chain)` slices. Rejects trailing bytes. -pub(crate) fn decode_peer_data(data: &[u8]) -> Option<(&[u8], &[u8])> { +/// Returns borrowed `(policy, issuer_chain, servtd_corim)` slices. The CoRIM +/// element is `None` both when a peer built before this field existed sent a +/// blob that ends exactly after `issuer_chain` (legacy 2-field framing) and +/// when a peer that understands the field sent an explicit empty CoRIM +/// (`corim_len == 0`, e.g. no CoRIM enrolled locally). A present CoRIM larger +/// than [`MAX_PEER_SERVTD_CORIM_SIZE`] is rejected before the caller can +/// allocate/parse it. Rejects trailing bytes in all cases. +pub(crate) fn decode_peer_data(data: &[u8]) -> Option> { if data.len() < 4 { return None; } @@ -406,16 +438,42 @@ pub(crate) fn decode_peer_data(data: &[u8]) -> Option<(&[u8], &[u8])> { .ok()?, ) as usize; let chain_offset = chain_len_offset + 4; - let end = chain_offset.checked_add(chain_len)?; + let chain_end = chain_offset.checked_add(chain_len)?; + if data.len() < chain_end { + return None; + } + let issuer_chain = &data[chain_offset..chain_end]; + + // Legacy (pre-CoRIM-transport) framing: nothing follows the issuer chain. + if data.len() == chain_end { + return Some((policy, issuer_chain, None)); + } + + // New framing: a `u32` corim_len + corim bytes must follow exactly. + if data.len() < chain_end.checked_add(4)? { + return None; + } + let corim_len = u32::from_le_bytes(data[chain_end..chain_end + 4].try_into().ok()?) as usize; + if corim_len > MAX_PEER_SERVTD_CORIM_SIZE { + return None; + } + let corim_offset = chain_end + 4; + let end = corim_offset.checked_add(corim_len)?; // Require exact length — reject trailing bytes. if data.len() != end { return None; } - let issuer_chain = &data[chain_offset..end]; - Some((policy, issuer_chain)) + let servtd_corim = &data[corim_offset..end]; + let servtd_corim = if servtd_corim.is_empty() { + None + } else { + Some(servtd_corim) + }; + Some((policy, issuer_chain, servtd_corim)) } -/// Build the local peer-data blob from configured policy and issuer chain. +/// Build the local peer-data blob from configured policy, issuer chain, and +/// (when enrolled) signed servTD TCB-mapping CoRIM. #[cfg(feature = "policy_v2")] pub(crate) fn local_peer_data() -> Option> { let policy = crate::config::get_policy()?; @@ -423,7 +481,17 @@ pub(crate) fn local_peer_data() -> Option> { // (CoRIM-only), else the policy issuer chain PEM. The peer resolves either // form via `policy::resolve_signer_anchor`. let issuer_chain = crate::config::get_signer_anchor_source()?; - encode_peer_data(policy, issuer_chain) + // Send our own signed CoRIM (if enrolled) so the peer resolves *our* + // current/init hashes through *our* authenticated mapping instead of + // theirs. Absent the `servtd_corim` feature, or when nothing is + // enrolled, send an empty field — the peer then falls back to whatever + // JSON `servtdCollateral` our policy carries (or fails closed if we ship + // neither). + #[cfg(feature = "servtd_corim")] + let servtd_corim = crate::config::get_servtd_corim().unwrap_or(&[]); + #[cfg(not(feature = "servtd_corim"))] + let servtd_corim: &[u8] = &[]; + encode_peer_data(policy, issuer_chain, servtd_corim) } /// Exchange peer-data blobs (policy + issuer chain) with the remote. @@ -451,10 +519,11 @@ pub(crate) async fn pre_session_data_exchange .await .log_err("pre_session_data_exchange: receive_pre_session_data_packet")?; - let (peer_policy, peer_issuer_chain) = decode_peer_data(&peer_blob).ok_or_else(|| { - log::error!("pre_session_data_exchange: malformed peer_data blob\n"); - MigrationResult::InvalidParameter - })?; + let (peer_policy, peer_issuer_chain, _peer_servtd_corim) = decode_peer_data(&peer_blob) + .ok_or_else(|| { + log::error!("pre_session_data_exchange: malformed peer_data blob\n"); + MigrationResult::InvalidParameter + })?; if peer_policy.is_empty() || peer_issuer_chain.is_empty() { log::error!("pre_session_data_exchange: Received empty policy or issuer chain from peer\n"); return Err(MigrationResult::InvalidParameter); @@ -469,3 +538,74 @@ pub(crate) async fn pre_session_data_exchange Ok(peer_blob) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn round_trip_with_corim() { + let blob = encode_peer_data(b"policy", b"chain", b"corim").unwrap(); + let (policy, chain, corim) = decode_peer_data(&blob).unwrap(); + assert_eq!(policy, b"policy"); + assert_eq!(chain, b"chain"); + assert_eq!(corim, Some(b"corim".as_slice())); + } + + #[test] + fn round_trip_with_empty_corim_is_none() { + // A peer that understands the field but has nothing to send still + // sends the length-prefixed (empty) field explicitly. + let blob = encode_peer_data(b"policy", b"chain", b"").unwrap(); + let (policy, chain, corim) = decode_peer_data(&blob).unwrap(); + assert_eq!(policy, b"policy"); + assert_eq!(chain, b"chain"); + assert_eq!(corim, None); + } + + #[test] + fn legacy_two_field_blob_decodes_with_no_corim() { + // Simulate a pre-#229 peer: no third field at all. + let mut legacy = Vec::new(); + legacy.extend_from_slice(&6u32.to_le_bytes()); + legacy.extend_from_slice(b"policy"); + legacy.extend_from_slice(&5u32.to_le_bytes()); + legacy.extend_from_slice(b"chain"); + + let (policy, chain, corim) = decode_peer_data(&legacy).unwrap(); + assert_eq!(policy, b"policy"); + assert_eq!(chain, b"chain"); + assert_eq!(corim, None); + } + + #[test] + fn oversized_corim_is_rejected() { + let oversized = vec![0u8; MAX_PEER_SERVTD_CORIM_SIZE + 1]; + let blob = encode_peer_data(b"policy", b"chain", &oversized).unwrap(); + assert!(decode_peer_data(&blob).is_none()); + } + + #[test] + fn corim_at_size_limit_is_accepted() { + let at_limit = vec![0x42u8; MAX_PEER_SERVTD_CORIM_SIZE]; + let blob = encode_peer_data(b"policy", b"chain", &at_limit).unwrap(); + let (_, _, corim) = decode_peer_data(&blob).unwrap(); + assert_eq!(corim, Some(at_limit.as_slice())); + } + + #[test] + fn trailing_bytes_after_corim_are_rejected() { + let mut blob = encode_peer_data(b"policy", b"chain", b"corim").unwrap(); + blob.push(0xAA); + assert!(decode_peer_data(&blob).is_none()); + } + + #[test] + fn truncated_corim_length_prefix_is_rejected() { + let mut blob = encode_peer_data(b"policy", b"chain", b"corim").unwrap(); + // Claim a corim_len larger than what actually follows. + let len_offset = blob.len() - 5 - 4; + blob[len_offset..len_offset + 4].copy_from_slice(&100u32.to_le_bytes()); + assert!(decode_peer_data(&blob).is_none()); + } +} diff --git a/src/policy/src/v2/policy.rs b/src/policy/src/v2/policy.rs index dd3bd210f..2b722744e 100644 --- a/src/policy/src/v2/policy.rs +++ b/src/policy/src/v2/policy.rs @@ -231,24 +231,11 @@ pub struct VerifiedPolicy<'a> { /// authority for servtd lookups (fail-closed: a CoRIM miss is a miss, /// with no fallback to the legacy JSON collateral). Only available with /// the `servtd_corim` feature. + /// + /// For a peer policy, this must be the peer's authenticated CoRIM. + /// Using the local mapping breaks migration between independent releases. #[cfg(feature = "servtd_corim")] - servtd_corim: Option>, -} - -#[cfg(feature = "servtd_corim")] -enum ServtdCorimAuthority<'a> { - Owned(ServtdCorim), - Borrowed(&'a ServtdCorim), -} - -#[cfg(feature = "servtd_corim")] -impl ServtdCorimAuthority<'_> { - fn as_ref(&self) -> &ServtdCorim { - match self { - Self::Owned(corim) => corim, - Self::Borrowed(corim) => corim, - } - } + servtd_corim: Option, } impl<'a> VerifiedPolicy<'a> { @@ -263,19 +250,27 @@ impl<'a> VerifiedPolicy<'a> { /// Attach decoded CoRIM servtd collateral. Once set, **all** servtd /// lookups resolve against the CoRIM and the legacy JSON collateral is no /// longer consulted. + /// + /// Callers attaching a CoRIM to a *peer's* `VerifiedPolicy` MUST have + /// already verified `corim`'s COSE signature/x5chain against that same + /// peer's own resolved `signer_anchor` (see `ServtdCorim::decode_signed`). + /// Never attach a locally-sourced CoRIM to a peer's policy. #[cfg(feature = "servtd_corim")] pub fn set_servtd_corim(&mut self, corim: ServtdCorim) { - self.servtd_corim = Some(ServtdCorimAuthority::Owned(corim)); + self.servtd_corim = Some(corim); } - /// Use the local, already signature/anchor-verified CoRIM as the authority - /// for peer TCB lookups. Peers do not supply a CoRIM or CRL for this step. + /// Verify and attach the peer's signed TCB-mapping CoRIM using its signer + /// anchor. Current and initial peer hash lookups then use this mapping. + /// Callers must still apply the local authoritative servTD CRL. #[cfg(feature = "servtd_corim")] - pub fn set_servtd_corim_from(&mut self, local_policy: &'static VerifiedPolicy<'static>) { - self.servtd_corim = local_policy - .servtd_corim - .as_ref() - .map(|corim| ServtdCorimAuthority::Borrowed(corim.as_ref())); + pub fn attach_verified_peer_servtd_corim( + &mut self, + peer_servtd_corim_cose: &[u8], + ) -> Result<(), PolicyError> { + let corim = ServtdCorim::decode_signed(peer_servtd_corim_cose, 0, &self.signer_anchor)?; + self.servtd_corim = Some(corim); + Ok(()) } /// Check every retained servTD signer chain against an authoritative CRL. @@ -303,9 +298,7 @@ impl<'a> VerifiedPolicy<'a> { } #[cfg(feature = "servtd_corim")] if let Some(corim) = self.servtd_corim.as_ref() { - corim - .as_ref() - .verify_signer_chain_not_revoked(authoritative_crl)?; + corim.verify_signer_chain_not_revoked(authoritative_crl)?; } Ok(()) } @@ -323,7 +316,7 @@ impl<'a> VerifiedPolicy<'a> { pub fn servtd_lookup_by_tdinfo_hash(&self, tdinfo_hash: &[u8]) -> Option { #[cfg(feature = "servtd_corim")] if let Some(corim) = &self.servtd_corim { - return corim.as_ref().lookup_by_tdinfo_hash(tdinfo_hash); + return corim.lookup_by_tdinfo_hash(tdinfo_hash); } // JSON path — hash -> SVN via the one-hash TCB mapping, then optional // SVN -> (date, status) via the TD Identity when it is shipped. @@ -1547,6 +1540,186 @@ mod test { assert_eq!(hit.unwrap().isvsvn, 1); } + // Peer SVN must be resolved through the peer TCB-mapping CoRIM. + #[cfg(feature = "servtd_corim")] + const CORIM_KNOWN_HASH_HEX: &str = "347c6170a91341351937962e08a7695703e7b87984b1c69216372c380302ac420d42381e4585007057b20b2579286384"; + + #[cfg(feature = "servtd_corim")] + fn base_verified_policy() -> VerifiedPolicy<'static> { + let policy_data = include_bytes!("../../test/policy_v2/policy_v2.json"); + let issuer_chain = + include_bytes!("../../test/policy_v2/cert_chain/policy_issuer_chain.pem"); + let policy = RawPolicyData::deserialize_from_json(policy_data).unwrap(); + policy.verify(issuer_chain).unwrap() + } + + /// Recover the RTMR1 signer anchor embedded in a signed COSE CoRIM + /// sample's own `x5chain`, running the real ES384 signature + chain + /// verification on the way. Mirrors the private + /// `servtd_corim::test::signer_anchor_from_sample` helper, which is + /// not reachable from this module. + #[cfg(feature = "servtd_corim")] + fn signer_anchor_from_cose_sample(cose: &[u8]) -> [u8; SHA384_DIGEST_SIZE] { + use crate::v2::compute_signer_anchor; + + let env = corim::types::signed::decode_signed_corim(cose).expect("decode COSE"); + let tbs = env.to_be_signed(&[]).expect("tbs"); + let chain = env.protected.x5chain.as_ref().expect("x5chain"); + let certs = chain.certs(); + let (root_der, leaf_eku_oids_der) = + crypto::verify_cose_sign1_es384_x5chain(&certs, &tbs, &env.signature) + .expect("verify signature"); + let leaf_eku_oid_der = leaf_eku_oids_der.first().expect("leaf asserts >= 1 EKU"); + compute_signer_anchor(&root_der, leaf_eku_oid_der).expect("anchor") + } + + /// Scenario 1: an older destination whose OWN local mapping does not + /// know a newer source's `tdinfo_hash` still accepts the source once + /// the source's own authenticated CoRIM (verified against the + /// SOURCE's own resolved signer anchor) is attached to the source's + /// `VerifiedPolicy`. The destination's local ignorance of the hash is + /// irrelevant: only the peer's own mapping is consulted. + #[cfg(feature = "servtd_corim")] + #[test] + fn peer_corim_resolves_hash_missing_from_destination_local_mapping() { + use crate::v2::hex_string_to_bytes; + + let corim_hash = hex_string_to_bytes(CORIM_KNOWN_HASH_HEX).unwrap(); + + // The destination's own local enrolled policy never saw this + // source release, so its own (legacy JSON) mapping does not know + // the hash. + let verified_dest = base_verified_policy(); + assert!(verified_dest + .servtd_lookup_by_tdinfo_hash(&corim_hash) + .is_none()); + + // The source's own `VerifiedPolicy`: attach the source's own + // signed CoRIM after verifying it against the SOURCE's own + // resolved signer anchor (never the destination's). + let tcb = include_bytes!("../../test/policy_v2/corim/tcb_mapping.cose"); + let mut verified_peer = base_verified_policy(); + verified_peer.signer_anchor = signer_anchor_from_cose_sample(tcb); + verified_peer + .attach_verified_peer_servtd_corim(tcb) + .expect("peer corim verifies against the peer's own anchor"); + + let hit = verified_peer.servtd_lookup_by_tdinfo_hash(&corim_hash); + assert_eq!( + hit.expect("peer's own CoRIM resolves its own release") + .isvsvn, + 1 + ); + } + + /// Scenario 2: once a peer's authenticated CoRIM is attached, a + /// `tdinfo_hash` it does not endorse fails closed (`None`), never + /// falling back to the destination's local mapping or any default + /// acceptance. `servtd_lookup_by_tdinfo_hash` is the single lookup + /// primitive shared by BOTH the current-release lookup + /// (`setup_evaluation_data`/`setup_evaluation_data_with_tdreport`, via + /// `servtd_lookup_by_report`) and the initial-release lookup + /// (`mig_policy::verify_init_servtd_svn_order`), so exercising it + /// here covers a missing hash at either call site. + #[cfg(feature = "servtd_corim")] + #[test] + fn peer_corim_missing_hash_fails_closed() { + let tcb = include_bytes!("../../test/policy_v2/corim/tcb_mapping.cose"); + let mut verified_peer = base_verified_policy(); + verified_peer.signer_anchor = signer_anchor_from_cose_sample(tcb); + verified_peer + .attach_verified_peer_servtd_corim(tcb) + .unwrap(); + + // Neither the peer's CoRIM (single endorsed release) nor any + // fallback resolves an unrelated hash. + let unknown_hash = [0xAAu8; SHA384_DIGEST_SIZE]; + assert!(verified_peer + .servtd_lookup_by_tdinfo_hash(&unknown_hash) + .is_none()); + } + + /// Scenario 3a: a peer CoRIM signed under a root/EKU anchor that does + /// not match the peer's own resolved RTMR1 signer anchor is rejected + /// outright and never attached. + #[cfg(feature = "servtd_corim")] + #[test] + fn attach_peer_corim_rejects_wrong_signer_anchor() { + let tcb = include_bytes!("../../test/policy_v2/corim/tcb_mapping.cose"); + let mut wrong_anchor = signer_anchor_from_cose_sample(tcb); + wrong_anchor[0] ^= 0xFF; + + let mut verified_peer = base_verified_policy(); + verified_peer.signer_anchor = wrong_anchor; + + assert!(verified_peer + .attach_verified_peer_servtd_corim(tcb) + .is_err()); + } + + /// Scenario 3b: after attaching, the peer CoRIM's signer chain is + /// still checked against the LOCAL authoritative servTD CRL (never a + /// peer-supplied one), and a revoked signer is rejected. + #[cfg(feature = "servtd_corim")] + #[test] + fn attach_peer_corim_then_local_crl_rejects_revoked_signer() { + let tcb = include_bytes!("../../test/policy_v2/corim/revocation/tcb_mapping.cose"); + let mut verified_peer = base_verified_policy(); + verified_peer.signer_anchor = signer_anchor_from_cose_sample(tcb); + verified_peer + .attach_verified_peer_servtd_corim(tcb) + .unwrap(); + + let local_crl = + include_bytes!("../../test/policy_v2/corim/revocation/crl_leaf_revoked.pem"); + assert!(matches!( + verified_peer.verify_signer_chains_not_revoked(local_crl), + Err(PolicyError::SignerRevoked) + )); + } + + /// Scenario 4 (best-effort proxy — see final report): bidirectional + /// signer-leaf rotation. `signer_a.pem`/`signer_b.pem` are two + /// independently rotated leaf keys under the SAME root + dedicated + /// signer EKU and resolve to the identical RTMR1 signer anchor + /// (`measurement::signer_anchor_from_chain_ignores_leaf_subject_and_key`). + /// This proves the property `attach_verified_peer_servtd_corim`'s + /// accept/reject decision depends on: anchor equality alone, not which + /// specific leaf-key generation produced the peer's transported + /// chain. A genuinely independent two-signer-key end-to-end CoRIM + /// rotation could not be exercised locally: this repository ships + /// only one signed CoRIM COSE sample key, and no tooling to mint a + /// second one was available in this environment. + #[cfg(feature = "servtd_corim")] + #[test] + fn rotated_leaf_keys_resolve_to_the_same_signer_anchor() { + let anchor_old = compute_signer_anchor_from_chain_pem(include_bytes!( + "../../../crypto/test/eku/signer_a.pem" + )) + .unwrap(); + let anchor_new = compute_signer_anchor_from_chain_pem(include_bytes!( + "../../../crypto/test/eku/signer_b.pem" + )) + .unwrap(); + assert_eq!( + anchor_old, anchor_new, + "rotated leaf keys under the same root+EKU must resolve to the \ + same anchor, so attach_verified_peer_servtd_corim's decision \ + never depends on which generation produced the peer's chain" + ); + + // A rotated-generation anchor that happens NOT to match this + // CoRIM sample's real signer (unrelated key material) still fails + // closed: rotation alone never grants acceptance, the anchor must + // genuinely match the CoRIM's own signer. + let tcb = include_bytes!("../../test/policy_v2/corim/tcb_mapping.cose"); + let mut verified_after_rotation = base_verified_policy(); + verified_after_rotation.signer_anchor = anchor_new; + assert!(verified_after_rotation + .attach_verified_peer_servtd_corim(tcb) + .is_err()); + } + #[test] fn test_global_policy() { let global = include_str!("../../test/policy_v2/global.json");