diff --git a/.github/workflows/README.md b/.github/workflows/README.md index e648cab7db61..c7b21e104652 100644 --- a/.github/workflows/README.md +++ b/.github/workflows/README.md @@ -79,7 +79,7 @@ File: `codeql-analysis.yml` # Continuous Verification -Runs the standard model checking, simulation, trace validation, counterexample, and disaster recovery jobs each week. +Runs the standard model checking, simulation, trace validation, and counterexample jobs each week. File: `ci-verification.yml` 3rd party dependencies: None diff --git a/.github/workflows/ci-verification.yml b/.github/workflows/ci-verification.yml index 1a72ca4feb95..12c03e553f14 100644 --- a/.github/workflows/ci-verification.yml +++ b/.github/workflows/ci-verification.yml @@ -242,25 +242,3 @@ jobs: name: tlc-trace-validation-consensus path: | tla/traces/* - - model-checking-self-healing-open: - name: Model Checking - Self-Healing Open - runs-on: [self-hosted, 1ES.Pool=gha-vmss-d16av6-ci] - container: - image: mcr.microsoft.com/azurelinux/base/core:3.0 - options: --user root --publish-all --cap-add NET_ADMIN --cap-add NET_RAW --cap-add SYS_PTRACE - - steps: - - name: "Checkout dependencies" - shell: bash - run: | - gpg --import /etc/pki/rpm-gpg/MICROSOFT-RPM-GPG-KEY - tdnf -y update - tdnf -y install ca-certificates git - - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - name: Install Stateright dependencies - run: | - tdnf install -y cargo - - - run: cd tla/disaster-recovery && cargo run check diff --git a/CHANGELOG.md b/CHANGELOG.md index fd07fc345f19..735409710e0b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,26 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). +## [7.0.16] + +[7.0.16]: https://github.com/microsoft/CCF/releases/tag/ccf-7.0.16 + +### Changed + +- HTTP/1.x request targets, including query strings, are now bounded before accumulation by a new `max_request_target_size` setting (16 KB by default), independent of `max_header_size`. Oversized targets return HTTP 414 `RequestTargetTooLong`, increment the per-interface `request_target_too_long` error metric, and close the session. HTTP/2 limits are unchanged (#8333). + +### Fixed + +- Temporary native PEM buffers, string copies, private JWK fields and JSON values owned by the `ccf.crypto.generateRsaKeyPair`, `ccf.crypto.generateEcdsaKeyPair`, `ccf.crypto.generateEddsaKeyPair`, `ccf.crypto.pemToJwk` (and its RSA/EdDSA variants), `ccf.crypto.jwkToPem` (and its RSA/EdDSA variants), and `ccf.crypto.sign` bindings are now scrubbed on scope exit. Previously these copies were scrubbed only on success or not at all. JavaScript-owned strings and internal library temporaries are not covered by this change (#8354). +- Fixed a double free when setting a property on a JavaScript object fails, which application script could trigger while the request object was being built. Such failures are now reported as a failed request (#8356). +- Historical states retrieved by JavaScript endpoints, through `ccf.historicalState` or `ccf.historical.getStateRange`, remain available through response conversion and are released when the request completes, rather than being retained for the lifetime of the node (#8355). +- JavaScript `verifySnpAttestation()` and the deprecated C++ `ccf::pal::snp::Attestation` returned swapped `current_minor` and `current_build` values. Both now match the AMD SEV-SNP report layout, with `current_build` at offset `0x1E8` and `current_minor` at `0x1E9` (#8083). + +### Changed + +- SNP attestation reports are now parsed and verified through TAV. Decode a report with `ccf::pal::snp::parse_attestation_report_unverified()`, which returns `ccf::pal::snp::AttestationReport`, an owning smart pointer, and verify it against TAV and CCF's policy with `ccf::pal::verify_snp_attestation_report_and_get()`. Field accessors borrow the report's storage, so destroying or replacing the owner invalidates them. The packed `ccf::pal::snp::Attestation` wire-layout type and its accessors still work, but are deprecated (#8083). +- `ccf::pal::snp::get_attestation()` in `ccf/pal/snp_ioctl.h` is unchanged, but its `get()` accessor is deprecated. Call `get_raw()` instead for the unverified report bytes, then decode them with `parse_attestation_report_unverified()` (#8083). + ## [7.0.15] [7.0.15]: https://github.com/microsoft/CCF/releases/tag/ccf-7.0.15 @@ -23,6 +43,8 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. - Reaching the soft session cap on an unsecured RPC interface no longer terminates the node by attempting a TLS handshake without a certificate. (#8331) - Transactions from an earlier view are now rejected before entering the replication queue even after the node has stepped down. This prevents rolled-back writes from being replicated after a later election and blocking subsequent replication (#8293, #8295). - Nodes now retain a peer's reconnect address even when an incoming node-to-node channel was established before its Raft configuration was applied. Previously, losing that connection could prevent outbound consensus messages from reaching the peer and stall elections (#8336). +- Transactions with pending writes now correctly validate `foreach`, `size`, and `clear` observations of an existing empty KV table made at revision zero. Previously, these observations could be mistaken for no whole-map read dependency (#8320). +- The OpenAPI schema for `GET /node/consensus` and `GET /node/network` now correctly marks `details.primary_id` and `primary_id` as nullable, matching their `null` value while no primary is known (e.g. between elections). Previously the schema required a non-null string, causing spurious response validation failures (#8344). ### Changed @@ -33,11 +55,6 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. - Removed the exported `evercbor` CMake target and installed `libevercbor.a` library. Applications using CCF's public APIs that explicitly depend on this target or link this library directly must remove that dependency. No further build changes are necessary: the replacement CBOR implementation is linked transitively by CCF (#8297). -### Fixed - -- Transactions with pending writes now correctly validate `foreach`, `size`, and `clear` observations of an existing empty KV table made at revision zero. Previously, these observations could be mistaken for no whole-map read dependency (#8320). -- The OpenAPI schema for `GET /node/consensus` and `GET /node/network` now correctly marks `details.primary_id` and `primary_id` as nullable, matching their `null` value while no primary is known (e.g. between elections). Previously the schema required a non-null string, causing spurious response validation failures (#8344). - ## [7.0.14] [7.0.14]: https://github.com/microsoft/CCF/releases/tag/ccf-7.0.14 diff --git a/CMakeLists.txt b/CMakeLists.txt index 5c542bc5dc75..9ada2f7d7a07 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -263,6 +263,10 @@ add_ccf_static_library( SRCS ${CCF_DIR}/src/pal/attestation.cpp LINK_LIBS ccfcrypto ) +target_include_directories( + ccf_pal + PRIVATE ${CCF_DIR}/3rdparty/internal/tee-attestation-verification/ffi/include +) # CCF js lib add_ccf_static_library( @@ -510,6 +514,11 @@ install( PATTERN "*.inc" ) +install( + FILES ${TAV_INCLUDE_DIR}/tav/snp.h ${TAV_INCLUDE_DIR}/tav/utils.h + DESTINATION include/3rdparty/tav +) + # Install all private CCF headers, which may still be needed install( DIRECTORY src/ @@ -598,6 +607,7 @@ if(BUILD_TESTS) snp_ioctl_test ${CMAKE_CURRENT_SOURCE_DIR}/src/pal/test/snp_ioctl_test.cpp ) + target_link_libraries(snp_ioctl_test PRIVATE ccf_pal) set_property(TEST snp_ioctl_test APPEND PROPERTY LABELS snp) set_property(TEST snp_ioctl_test APPEND PROPERTY CONFIGURATIONS snp) diff --git a/cmake/ccf_rs.cmake b/cmake/ccf_rs.cmake index d24e557d8c0e..eecf172092c8 100644 --- a/cmake/ccf_rs.cmake +++ b/cmake/ccf_rs.cmake @@ -75,10 +75,29 @@ add_custom_target( "${CCF_RS_DIR}/rust-toolchain.toml" "${CCF_DIR}/src/cose/cose_rs/Cargo.toml" "${CCF_DIR}/3rdparty/internal/cose-openssl/Cargo.toml" + "${CCF_DIR}/3rdparty/internal/tee-attestation-verification/ffi/Cargo.toml" COMMENT "Building ${CCF_RS_PACKAGE} Rust static library (Cargo profile: ${CCF_RS_CARGO_PROFILE_NAME})" USES_TERMINAL VERBATIM ) +add_library(ccf_rs INTERFACE) +target_link_libraries( + ccf_rs + INTERFACE + $ + $ + ssl + crypto +) +if(CMAKE_SYSTEM_NAME STREQUAL "Linux") + target_link_libraries( + ccf_rs + INTERFACE ${CMAKE_THREAD_LIBS_INIT} ${CMAKE_DL_LIBS} m + ) +endif() +add_dependencies(ccf_rs cargo-build_ccf_rs) + install(FILES "${CCF_RS_LIB_BUILD_PATH}" DESTINATION lib) +install(TARGETS ccf_rs EXPORT ccf) diff --git a/cmake/crypto.cmake b/cmake/crypto.cmake index da483a15ee08..b18a33f19205 100644 --- a/cmake/crypto.cmake +++ b/cmake/crypto.cmake @@ -43,13 +43,7 @@ add_hardening(ccfcrypto) add_tidy(ccfcrypto) target_link_libraries(ccfcrypto PUBLIC crypto ssl ccf_threading) -target_link_libraries( - ccfcrypto - PUBLIC - $ - $ -) -add_dependencies(ccfcrypto cargo-build_ccf_rs) +target_link_libraries(ccfcrypto PUBLIC ccf_rs) set_property(TARGET ccfcrypto PROPERTY POSITION_INDEPENDENT_CODE ON) install(TARGETS ccfcrypto EXPORT ccf DESTINATION lib) diff --git a/doc/audit/builtin_maps.rst b/doc/audit/builtin_maps.rst index b100eae08ea3..5e0f425477f0 100644 --- a/doc/audit/builtin_maps.rst +++ b/doc/audit/builtin_maps.rst @@ -494,11 +494,11 @@ Signatures emitted by the primary node at regular interval, over the root of the ``cose_signatures`` ~~~~~~~~~~~~~~~~~~~ -COSE signatures emitted by the primary node over the root of the Merkle Tree at that sequence number. +COSE signatures over the Merkle root, keyed by service signing identity type. -**Key** Sentinel value 0, represented as a little-endian 64-bit unsigned integer. +**Key** Identity type as a little-endian 64-bit unsigned integer. Only ``CLASSICAL`` (0) is currently populated; ``PQ`` (1) is reserved for future support. -**Value** Raw COSE Sign1 message as byte string (DER-encoded). Implements the following :ccf_repo:`CDDL schema `. +**Value** A CBOR-encoded COSE Sign1 message, stored as a base64-encoded JSON string. Implements the following :ccf_repo:`CDDL schema `. ``recovery_shares`` ~~~~~~~~~~~~~~~~~~~ diff --git a/doc/host_config_schema/host_config.json b/doc/host_config_schema/host_config.json index 781cad582693..78a7b46f0be8 100644 --- a/doc/host_config_schema/host_config.json +++ b/doc/host_config_schema/host_config.json @@ -70,6 +70,11 @@ "default": "16KB", "description": "Maximum size (size string) of a single HTTP request header (key or value). Submitting a request with a header larger than this value will result in the client session being automatically closed" }, + "max_request_target_size": { + "type": "string", + "default": "16KB", + "description": "HTTP/1.x only. Maximum size (size string) of a single HTTP request target, including the query string. Submitting a request with a target larger than this value will result in the client session being automatically closed" + }, "max_headers_count": { "type": "integer", "default": 256, diff --git a/doc/schemas/node_openapi.json b/doc/schemas/node_openapi.json index c7e88b8ae774..edb604e822e9 100644 --- a/doc/schemas/node_openapi.json +++ b/doc/schemas/node_openapi.json @@ -666,6 +666,9 @@ }, "max_headers_count": { "$ref": "#/components/schemas/uint32" + }, + "max_request_target_size": { + "$ref": "#/components/schemas/SizeString" } }, "type": "object" @@ -828,12 +831,16 @@ }, "request_payload_too_large": { "$ref": "#/components/schemas/uint64" + }, + "request_target_too_long": { + "$ref": "#/components/schemas/uint64" } }, "required": [ "parsing", "request_payload_too_large", - "request_header_too_large" + "request_header_too_large", + "request_target_too_long" ], "type": "object" }, @@ -963,7 +970,7 @@ "info": { "description": "This API provides public, uncredentialed access to service and node state.", "title": "CCF Public Node API", - "version": "5.0.7" + "version": "5.0.8" }, "openapi": "3.0.0", "paths": { diff --git a/include/ccf/http_configuration.h b/include/ccf/http_configuration.h index ebe03a465100..5a50fd1d34f3 100644 --- a/include/ccf/http_configuration.h +++ b/include/ccf/http_configuration.h @@ -13,6 +13,7 @@ namespace ccf::http // requests that are too large. static const ccf::ds::SizeString default_max_body_size = {"1MB"}; static const ccf::ds::SizeString default_max_header_size = {"16KB"}; + static const ccf::ds::SizeString default_max_request_target_size = {"16KB"}; static const uint32_t default_max_headers_count = 256; // HTTP/2 only, as per nghttp2 defaults @@ -26,6 +27,9 @@ namespace ccf::http std::optional max_header_size = std::nullopt; std::optional max_headers_count = std::nullopt; + // HTTP/1.x only, including the query string. + std::optional max_request_target_size = std::nullopt; + // HTTP/2 only std::optional max_concurrent_streams_count = std::nullopt; std::optional initial_window_size = std::nullopt; @@ -42,6 +46,7 @@ namespace ccf::http max_body_size, max_header_size, max_headers_count, + max_request_target_size, max_concurrent_streams_count, initial_window_size, max_frame_size); @@ -53,6 +58,7 @@ namespace ccf::http ParserConfiguration config; config.max_body_size = "1GB"; config.max_header_size = "100MB"; + config.max_request_target_size = "100MB"; config.max_headers_count = 1024; config.max_concurrent_streams_count = 1; config.initial_window_size = "64KB"; diff --git a/include/ccf/node/quote.h b/include/ccf/node/quote.h index ac9e983ab0ce..b28442e045ec 100644 --- a/include/ccf/node/quote.h +++ b/include/ccf/node/quote.h @@ -39,8 +39,15 @@ namespace ccf static std::optional get_host_data(const QuoteInfo& quote_info); +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wdeprecated-declarations" + [[deprecated("Use get_snp_attestation_report")]] static std::optional get_snp_attestation( const QuoteInfo& quote_info); +#pragma GCC diagnostic pop + + static std::optional + get_snp_attestation_report(const QuoteInfo& quote_info); static QuoteVerificationResult verify_quote_against_store( ccf::kv::ReadOnlyTx& tx, diff --git a/include/ccf/odata_error.h b/include/ccf/odata_error.h index d3221d05993b..107160998fd2 100644 --- a/include/ccf/odata_error.h +++ b/include/ccf/odata_error.h @@ -83,6 +83,7 @@ namespace ccf ERROR(UnsupportedHttpVerb) ERROR(UnsupportedContentType) ERROR(RequestBodyTooLarge) + ERROR(RequestTargetTooLong) ERROR(RequestHeaderTooLarge) ERROR(PreconditionFailed) diff --git a/include/ccf/pal/attestation.h b/include/ccf/pal/attestation.h index 19fc6ed55ab1..d3f1fbb7e4ee 100644 --- a/include/ccf/pal/attestation.h +++ b/include/ccf/pal/attestation.h @@ -3,6 +3,7 @@ #pragma once #include "ccf/ds/quote_info.h" +#include "ccf/pal/attestation_sev_snp.h" #include "ccf/pal/attestation_sev_snp_endorsements.h" #include "ccf/pal/measurement.h" #include "ccf/pal/report_data.h" @@ -28,6 +29,12 @@ namespace ccf::pal PlatformAttestationMeasurement& measurement, PlatformAttestationReportData& report_data); + /// Verify with TAV, then enforce CCF's SNP attestation policy. + snp::AttestationReport verify_snp_attestation_report_and_get( + const QuoteInfo& quote_info, + PlatformAttestationMeasurement& measurement, + PlatformAttestationReportData& report_data); + void verify_quote( const QuoteInfo& quote_info, PlatformAttestationMeasurement& measurement, diff --git a/include/ccf/pal/attestation_sev_snp.h b/include/ccf/pal/attestation_sev_snp.h index 600ed237a403..fafae2660b17 100644 --- a/include/ccf/pal/attestation_sev_snp.h +++ b/include/ccf/pal/attestation_sev_snp.h @@ -15,9 +15,13 @@ #include #include #include +#include #include +#include #include #include +#include +#include #include namespace ccf::pal::snp @@ -28,6 +32,7 @@ namespace ccf::pal::snp static constexpr auto NO_SECURITY_POLICY = ""; // From https://developer.amd.com/sev/ + [[deprecated("TAV verifies AMD root signing keys internally")]] constexpr auto amd_milan_root_signing_public_key = R"(-----BEGIN PUBLIC KEY----- MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEA0Ld52RJOdeiJlqK2JdsV @@ -44,6 +49,7 @@ pCCoMNit2uLo9M18fHz10lOMT8nWAUvRZFzteXCm+7PHdYPlmQwUw3LvenJ/ILXo QPHfbkH0CyPfhl1jWhJFZasCAwEAAQ== -----END PUBLIC KEY----- )"; + [[deprecated("TAV verifies AMD root signing keys internally")]] constexpr auto amd_genoa_root_signing_public_key = R"(-----BEGIN PUBLIC KEY----- MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEA3Cd95S/uFOuRIskW9vz9 @@ -60,6 +66,7 @@ HP1qYrnvhzaG1S70vw6OkbaaC9EjiH/uHgAJQGxon7u0Q7xgoREWA/e7JcBQwLg8 0Hq/sbRuqesxz7wBWSY254cCAwEAAQ== -----END PUBLIC KEY----- )"; + [[deprecated("TAV verifies AMD root signing keys internally")]] constexpr auto amd_turin_root_signing_public_key = R"(-----BEGIN PUBLIC KEY----- MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAwaAriB7EIuVc4ZB1wD3Y @@ -77,12 +84,14 @@ pRb21iI1NlNCfOGUPIhVpWECAwEAAQ== -----END PUBLIC KEY----- )"; - struct AmdRootSigningKey + struct [[deprecated( + "TAV verifies AMD root signing keys internally")]] AmdRootSigningKey { const char* public_key; const char* issuer; }; + [[deprecated("TAV verifies AMD root signing keys internally")]] inline const std::map amd_root_signing_keys{ {ProductName::Milan, {amd_milan_root_signing_public_key, @@ -222,15 +231,14 @@ pRb21iI1NlNCfOGUPIhVpWECAwEAAQ== TcbVersionRaw() = default; - TcbVersionRaw(const std::vector& data) + TcbVersionRaw(std::span data) { if (data.size() != snp_tcb_version_size) { throw std::logic_error( fmt::format("Invalid TCB version raw data size: {}", data.size())); } - std::memcpy( - static_cast(underlying_data), data.data(), snp_tcb_version_size); + std::memcpy(underlying_data, data.data(), snp_tcb_version_size); } [[nodiscard]] std::vector data() const @@ -392,6 +400,10 @@ pRb21iI1NlNCfOGUPIhVpWECAwEAAQ== sizeof(PlatformInfo) == sizeof(uint64_t), "Cannot cast PlatformInfo to uint64_t"); + static constexpr size_t attestation_report_size = 1184; + + struct [[deprecated("Use ccf::pal::snp::AttestationReport")]] Attestation; + #pragma pack(push, 1) // Table 21 @@ -425,8 +437,8 @@ pRb21iI1NlNCfOGUPIhVpWECAwEAAQ== uint8_t reserved1[21] = {0}; /* 0x18B */ uint8_t chip_id[64] = {0}; /* 0x1A0 */ TcbVersionRaw committed_tcb; /* 0x1E0 */ - uint8_t current_minor = 0; /* 0x1E8 */ - uint8_t current_build = 0; /* 0x1E9 */ + uint8_t current_build = 0; /* 0x1E8 */ + uint8_t current_minor = 0; /* 0x1E9 */ uint8_t current_major = 0; /* 0x1EA */ uint8_t reserved2 = 0; /* 0x1EB */ uint8_t committed_build = 0; /* 0x1EC */ @@ -456,6 +468,49 @@ pRb21iI1NlNCfOGUPIhVpWECAwEAAQ== }; #pragma pack(pop) + // Reports are allocated in Rust and must be freed through TAV, not C++ + // delete. A stateless deleter keeps the smart pointer default-constructible + // without storing a cleanup function pointer. + struct AttestationReportDeleter + { + void operator()(TavSnpAttestationReport* report) const noexcept + { + tav_snp_attestation_report_free(report); + } + }; + + using AttestationReport = + std::unique_ptr; + + inline std::span get_chip_id_for_vcek( + const AttestationReport& report) + { + if (report == nullptr) + { + throw std::logic_error("Cannot access an empty SNP attestation report"); + } + const uint8_t* data = nullptr; + size_t size = 0; + tav_snp_attestation_report_chip_id(report.get(), &data, &size); + const auto chip_id = std::span{data, size}; + const auto product = get_sev_snp_product( + tav_snp_attestation_report_cpuid_fam_id(report.get()), + tav_snp_attestation_report_cpuid_mod_id(report.get())); + if (product == ProductName::Milan || product == ProductName::Genoa) + { + return chip_id; + } + if (product == ProductName::Turin) + { + return chip_id.first(8); + } + throw std::logic_error( + fmt::format("Unsupported SEV-SNP product: {}", product)); + } + + [[nodiscard]] AttestationReport parse_attestation_report_unverified( + std::span report); + static HostPort get_endpoint_loc( const EndorsementsServer& server, const HostPort& default_values) { @@ -475,24 +530,37 @@ pRb21iI1NlNCfOGUPIhVpWECAwEAAQ== static EndorsementEndpointsConfiguration make_endorsement_endpoint_configuration( - const Attestation& quote, + const AttestationReport& quote, const snp::EndorsementsServers& endorsements_servers = {}) { - if (quote.version < minimum_attestation_version) + if (quote == nullptr) + { + throw std::logic_error("Cannot access an empty SNP attestation report"); + } + if ( + tav_snp_attestation_report_version(quote.get()) < + minimum_attestation_version) { throw std::logic_error(fmt::format( "SEV-SNP: attestation version {} is not supported. Minimum " "supported version is {}", - quote.version, + tav_snp_attestation_report_version(quote.get()), minimum_attestation_version)); } EndorsementEndpointsConfiguration config; auto chip_id_hex = - fmt::format("{:02x}", fmt::join(quote.get_chip_id_for_vcek(), "")); + fmt::format("{:02x}", fmt::join(get_chip_id_for_vcek(quote), "")); + const uint8_t* reported_tcb_data = nullptr; + size_t reported_tcb_size = 0; + tav_snp_attestation_report_reported_tcb( + quote.get(), &reported_tcb_data, &reported_tcb_size); + const auto reported_tcb_raw = + std::span{reported_tcb_data, reported_tcb_size}; auto reported_tcb = fmt::format( - "{:0x}", *reinterpret_cast("e.reported_tcb)); + "{:02x}", + fmt::join(reported_tcb_raw.rbegin(), reported_tcb_raw.rend(), "")); constexpr size_t default_max_retries_count = 10; static const ds::SizeString default_max_client_response_size = @@ -533,8 +601,9 @@ pRb21iI1NlNCfOGUPIhVpWECAwEAAQ== } case EndorsementsEndpointType::AMD: { - auto product = - get_sev_snp_product(quote.cpuid_fam_id, quote.cpuid_mod_id); + auto product = get_sev_snp_product( + tav_snp_attestation_report_cpuid_fam_id(quote.get()), + tav_snp_attestation_report_cpuid_mod_id(quote.get())); std::string boot_loader; std::string tee; @@ -546,7 +615,9 @@ pRb21iI1NlNCfOGUPIhVpWECAwEAAQ== case ProductName::Milan: case ProductName::Genoa: { - auto tcb = quote.reported_tcb.to_policy(product).to_milan_genoa(); + auto tcb = TcbVersionRaw(reported_tcb_raw) + .to_policy(product) + .to_milan_genoa(); boot_loader = fmt::format("{}", tcb.boot_loader); tee = fmt::format("{}", tcb.tee); snp = fmt::format("{}", tcb.snp); @@ -555,7 +626,8 @@ pRb21iI1NlNCfOGUPIhVpWECAwEAAQ== } case ProductName::Turin: { - auto tcb = quote.reported_tcb.to_policy(product).to_turin(); + auto tcb = + TcbVersionRaw(reported_tcb_raw).to_policy(product).to_turin(); boot_loader = fmt::format("{}", tcb.boot_loader); tee = fmt::format("{}", tcb.tee); snp = fmt::format("{}", tcb.snp); @@ -608,14 +680,33 @@ pRb21iI1NlNCfOGUPIhVpWECAwEAAQ== return config; } +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wdeprecated-declarations" + [[deprecated("Use the AttestationReport overload")]] + static EndorsementEndpointsConfiguration + make_endorsement_endpoint_configuration( + const Attestation& quote, + const snp::EndorsementsServers& endorsements_servers = {}) + { + const auto* report = reinterpret_cast("e); + return make_endorsement_endpoint_configuration( + parse_attestation_report_unverified({report, attestation_report_size}), + endorsements_servers); + } + class AttestationInterface { public: - [[nodiscard]] virtual const snp::Attestation& get() const = 0; + [[deprecated( + "Use get_raw() and explicitly decode with " + "parse_attestation_report_unverified")]] [[nodiscard]] virtual const snp:: + Attestation& + get() const = 0; virtual std::vector get_raw() = 0; virtual ~AttestationInterface() = default; }; +#pragma GCC diagnostic pop } diff --git a/include/ccf/pal/snp_ioctl.h b/include/ccf/pal/snp_ioctl.h index 445a9b9a210f..7a23eee47c46 100644 --- a/include/ccf/pal/snp_ioctl.h +++ b/include/ccf/pal/snp_ioctl.h @@ -11,6 +11,8 @@ namespace ccf::pal::snp return ioctl6::supports_sev_snp(); } + // Acquire an attestation object. get_raw() returns owned, unverified bytes; + // decode them explicitly with parse_attestation_report_unverified(). static std::unique_ptr get_attestation( const PlatformAttestationReportData& report_data) { diff --git a/include/ccf/pal/snp_ioctl6.h b/include/ccf/pal/snp_ioctl6.h index 344febe028d4..0b6ffdce2f66 100644 --- a/include/ccf/pal/snp_ioctl6.h +++ b/include/ccf/pal/snp_ioctl6.h @@ -24,6 +24,31 @@ namespace ccf::pal::snp::ioctl6 { constexpr auto DEVICE = "/dev/sev-guest"; + namespace detail + { + // Linux snp_guest_msg is 4096 bytes: a 96-byte outer message header and + // 4000-byte payload. The ioctl returns only the decrypted payload, with + // its own 32-byte report response header before the report. + // https://github.com/torvalds/linux/blob/v6.8/drivers/virt/coco/sev-guest/sev-guest.h + // https://github.com/torvalds/linux/blob/v6.8/include/uapi/linux/sev-guest.h + constexpr size_t ATTESTATION_RESPONSE_SIZE = 4000; + struct AttestationResponse + { + uint32_t status = 0; + uint32_t report_size = 0; + std::array reserved = {}; + std::array report_bytes = {}; + std::array< + uint8_t, + detail::ATTESTATION_RESPONSE_SIZE - 0x20 - attestation_report_size> + padding = {}; + }; + static_assert( + sizeof(AttestationResponse) == detail::ATTESTATION_RESPONSE_SIZE); + static_assert(offsetof(AttestationResponse, report_size) == 0x04); + static_assert(offsetof(AttestationResponse, report_bytes) == 0x20); + } + #pragma pack(push, 1) // Helper to add padding to a struct, so that the resulting struct has some // minimum size. As a minor detail, the padding will be initialised to 0. @@ -110,17 +135,24 @@ namespace ccf::pal::snp::ioctl6 #pragma pack(pop) // Table 25 +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wdeprecated-declarations" #pragma pack(push, 1) struct AttestationResp { uint32_t status = 0; uint32_t report_size = 0; uint8_t reserved[0x20 - 0x8] = {0}; - Attestation report; + [[deprecated("Use get_raw() and parse_attestation_report_unverified")]] + snp::Attestation report = {}; uint8_t padding[64] = {0}; // padding to the size of SEV_SNP_REPORT_RSP_BUF_SZ (i.e., 1280 bytes) }; #pragma pack(pop) + static_assert(offsetof(AttestationResp, report_size) == 0x04); + static_assert(offsetof(AttestationResp, report) == 0x20); + static_assert(sizeof(AttestationResp) == 1280); +#pragma GCC diagnostic pop // Table 20 of the SEVSNP ABI constexpr uint8_t GUEST_FIELD_SELECT_GUEST_POLICY = 0b00000001; @@ -193,10 +225,25 @@ namespace ccf::pal::snp::ioctl6 using GuestRequestDerivedKey = GuestRequest; + namespace detail + { + using AttestationRequest = + GuestRequest; + static_assert( + sizeof(AttestationRequest) == sizeof(GuestRequestAttestation)); + static_assert(offsetof(AttestationRequest, req_data) == 8); + static_assert(offsetof(AttestationRequest, resp_wrapper) == 16); + static_assert(offsetof(AttestationRequest, exit_info) == 24); + static_assert(sizeof(AttestationRequest) == 32); + } + // From linux/include/uapi/linux/sev-guest.h constexpr char SEV_GUEST_IOC_TYPE = 'S'; constexpr int SEV_SNP_GUEST_MSG_REPORT = - _IOWR(SEV_GUEST_IOC_TYPE, 0x0, GuestRequestAttestation); + _IOWR(SEV_GUEST_IOC_TYPE, 0x0, detail::AttestationRequest); + static_assert( + _IOWR(SEV_GUEST_IOC_TYPE, 0x0, detail::AttestationRequest) == + _IOWR(SEV_GUEST_IOC_TYPE, 0x0, GuestRequestAttestation)); constexpr int SEV_SNP_GUEST_MSG_DERIVED_KEY = _IOWR(SEV_GUEST_IOC_TYPE, 0x1, GuestRequestDerivedKey); @@ -205,13 +252,11 @@ namespace ccf::pal::snp::ioctl6 return access(DEVICE, W_OK) == 0; } - class Attestation : public AttestationInterface + namespace detail { - IoctlSentinel resp_with_sentinel; - PaddedAttestationResp& padded_resp = resp_with_sentinel.data; - - public: - Attestation(const PlatformAttestationReportData& report_data) + inline void request_attestation( + const PlatformAttestationReportData& report_data, + IoctlSentinel& response) { AttestationReq req = {}; if (report_data.data.size() <= snp_attestation_report_data_size) @@ -235,8 +280,8 @@ namespace ccf::pal::snp::ioctl6 // Documented at // https://www.kernel.org/doc/html/latest/virt/coco/sev-guest.html - GuestRequestAttestation payload = { - .req_data = &req, .resp_wrapper = &padded_resp, .exit_info = {0}}; + AttestationRequest payload = { + .req_data = &req, .resp_wrapper = &response.data, .exit_info = {0}}; int rc = ioctl(fd, SEV_SNP_GUEST_MSG_REPORT, &payload); if (rc < 0) @@ -250,7 +295,7 @@ namespace ccf::pal::snp::ioctl6 throw std::logic_error(msg); } - if (!resp_with_sentinel.sentinels_intact()) + if (!response.sentinels_intact()) { // This occurs if a kernel/firmware upgrade causes the response to // overflow our struct. If that happens, it is better to fail early than @@ -259,17 +304,54 @@ namespace ccf::pal::snp::ioctl6 "SEV_SNP_GUEST_MSG_REPORT IOCTL overwrote safety sentinels."); } } + } + + class Attestation : public AttestationInterface + { + PaddedAttestationResp padded_resp; + + public: + Attestation(const PlatformAttestationReportData& report_data) + { + IoctlSentinel response; + detail::request_attestation(report_data, response); + // Retain legacy storage for the reference returned by get(). + static_assert(sizeof(padded_resp) == sizeof(response.data)); + std::memcpy(&padded_resp, &response.data, sizeof(padded_resp)); + } - [[nodiscard]] const snp::Attestation& get() const override +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wdeprecated-declarations" + [[deprecated( + "Use get_raw() and explicitly decode with " + "parse_attestation_report_unverified")]] [[nodiscard]] const ccf::pal:: + snp::Attestation& + get() const override { + if (padded_resp.report_size != attestation_report_size) + { + throw std::logic_error(fmt::format( + "Unexpected SEV-SNP attestation report size: {} != {}", + padded_resp.report_size, + attestation_report_size)); + } return padded_resp.report; } std::vector get_raw() override { - auto* quote_bytes = reinterpret_cast(&padded_resp.report); - return {quote_bytes, quote_bytes + padded_resp.report_size}; + if (padded_resp.report_size != attestation_report_size) + { + throw std::logic_error(fmt::format( + "Unexpected SEV-SNP attestation report size: {} != {}", + padded_resp.report_size, + attestation_report_size)); + } + const auto* report = + reinterpret_cast(&padded_resp.report); + return {report, report + attestation_report_size}; } +#pragma GCC diagnostic pop }; class DerivedKey diff --git a/python/pyproject.toml b/python/pyproject.toml index 50017d2f73df..900ffe42262c 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "ccf" -version = "7.0.15" +version = "7.0.16" authors = [ { name="CCF Team", email="CCF-Sec@microsoft.com" }, ] diff --git a/src/crypto/pem.cpp b/src/crypto/pem.cpp index 46947396381a..d3f0523c6a56 100644 --- a/src/crypto/pem.cpp +++ b/src/crypto/pem.cpp @@ -2,12 +2,16 @@ // Licensed under the Apache 2.0 License. #include "ccf/crypto/pem.h" +#include + namespace ccf::crypto { void Pem::check_pem_format() { if (!s.contains("-----BEGIN")) { + // Construction has not completed, so a caller's guard cannot run yet. + OPENSSL_cleanse(s.data(), s.size()); throw std::runtime_error("PEM constructed with non-PEM data"); } } diff --git a/src/enclave/http_session.h b/src/enclave/http_session.h index 22db6151cc09..9947df55e1c8 100644 --- a/src/enclave/http_session.h +++ b/src/enclave/http_session.h @@ -71,6 +71,22 @@ namespace http close_session(); } + catch (RequestTargetTooLongException& e) + { + if (error_reporter) + { + error_reporter->report_request_target_too_long_error(interface_id); + } + + LOG_DEBUG_FMT("Request target is too long: {}", e.what()); + + send_odata_error_response(ccf::ErrorDetails{ + HTTP_STATUS_URI_TOO_LONG, + ccf::errors::RequestTargetTooLong, + e.what()}); + + close_session(); + } catch (RequestHeaderTooLargeException& e) { if (error_reporter) diff --git a/src/enclave/rpc_sessions.h b/src/enclave/rpc_sessions.h index 7f851a0c4e1e..8c016072f59d 100644 --- a/src/enclave/rpc_sessions.h +++ b/src/enclave/rpc_sessions.h @@ -187,6 +187,13 @@ namespace ccf get_interface_from_interface_id(id).errors.request_header_too_large++; } + void report_request_target_too_long_error( + const ccf::ListenInterfaceID& id) override + { + std::lock_guard guard(lock); + get_interface_from_interface_id(id).errors.request_target_too_long++; + } + void update_listening_interface_options( const ccf::NodeInfoNetwork& node_info) { diff --git a/src/endpoints/endpoint_registry.cpp b/src/endpoints/endpoint_registry.cpp index 5a06825c847a..0fc8d2a78c90 100644 --- a/src/endpoints/endpoint_registry.cpp +++ b/src/endpoints/endpoint_registry.cpp @@ -269,7 +269,7 @@ namespace ccf::endpoints return std::make_shared( sig, - cached_sig->cose_signature, + cached_sig->cose_signatures, proof.get_root(), proof.get_path(), node, diff --git a/src/http/error_reporter.h b/src/http/error_reporter.h index 33ed441394da..fad7663ab796 100644 --- a/src/http/error_reporter.h +++ b/src/http/error_reporter.h @@ -15,5 +15,7 @@ namespace http const ccf::ListenInterfaceID&) = 0; virtual void report_request_header_too_large_error( const ccf::ListenInterfaceID&) = 0; + virtual void report_request_target_too_long_error( + const ccf::ListenInterfaceID&) = 0; }; } \ No newline at end of file diff --git a/src/http/http_exceptions.h b/src/http/http_exceptions.h index a7e1d0dde1d7..170d1aee4a10 100644 --- a/src/http/http_exceptions.h +++ b/src/http/http_exceptions.h @@ -36,6 +36,14 @@ namespace http {} }; + class RequestTargetTooLongException : public RequestTooLargeException + { + public: + explicit RequestTargetTooLongException(const std::string& msg) : + RequestTooLargeException(msg) + {} + }; + class RequestHeaderTooLargeException : public RequestTooLargeException { public: diff --git a/src/http/http_parser.h b/src/http/http_parser.h index ae182397e6e7..93f46e8e12a7 100644 --- a/src/http/http_parser.h +++ b/src/http/http_parser.h @@ -406,6 +406,7 @@ namespace http RequestProcessor& proc; std::string url; + size_t max_request_target_size; public: ~RequestParser() override = default; @@ -415,13 +416,25 @@ namespace http const ccf::http::ParserConfiguration& config = ccf::http::ParserConfiguration{}) : Parser(HTTP_REQUEST, config), - proc(proc_) + proc(proc_), + max_request_target_size( + config.max_request_target_size + .value_or(ccf::http::default_max_request_target_size) + .count_bytes()) { settings.on_url = on_url; } void append_url(const char* at, size_t length) { + if ( + length > max_request_target_size || + url.size() > max_request_target_size - length) + { + throw RequestTargetTooLongException(fmt::format( + "HTTP request target is too long (max size allowed: {})", + max_request_target_size)); + } url.append(at, length); } diff --git a/src/http/test/http_test.cpp b/src/http/test/http_test.cpp index eb8feec6a3d7..edfa80cd3b78 100644 --- a/src/http/test/http_test.cpp +++ b/src/http/test/http_test.cpp @@ -363,6 +363,124 @@ DOCTEST_TEST_CASE("Body too large") } } +DOCTEST_TEST_CASE("Request target size limit") +{ + ccf::http::ParserConfiguration config; + DOCTEST_SUBCASE("Default limit") {} + DOCTEST_SUBCASE("Smaller configured limit") + { + config.max_request_target_size = "32B"; + } + DOCTEST_SUBCASE("Larger configured limit") + { + config.max_request_target_size = "32KB"; + } + DOCTEST_SUBCASE("Smaller header limit does not lower target default") + { + config.max_header_size = "32B"; + } + DOCTEST_SUBCASE("Larger header limit does not raise target default") + { + config.max_header_size = "32KB"; + } + + const auto limit = config.max_request_target_size + .value_or(ccf::http::default_max_request_target_size) + .count_bytes(); + + for (const auto size : {limit - 1, limit, limit + 1}) + { + for (const size_t chunk_size : {size_t{1}, size_t{7}, size + 4}) + { + DOCTEST_CAPTURE(size); + DOCTEST_CAPTURE(chunk_size); + http::SimpleRequestProcessor sp; + http::RequestParser p(sp, config); + const auto target = "/?q=" + std::string(size - 4, 'a'); + const auto prefix = s_to_v(("GET " + target).c_str()); + size_t offset = 0; + while (offset < prefix.size()) + { + const auto length = std::min(chunk_size, prefix.size() - offset); + if (size > limit && offset + length > limit + 4) + { + DOCTEST_CHECK_THROWS_AS( + p.execute(prefix.data() + offset, length), + http::RequestTargetTooLongException); + break; + } + DOCTEST_CHECK_NOTHROW(p.execute(prefix.data() + offset, length)); + offset += length; + } + DOCTEST_CHECK(sp.received.empty()); + if (size <= limit) + { + const auto suffix = s_to_v(" HTTP/1.1\r\n\r\n"); + p.execute(suffix.data(), suffix.size()); + DOCTEST_REQUIRE(sp.received.size() == 1); + DOCTEST_CHECK(sp.received.front().url == target); + sp.received.pop(); + + // The limit is per request, not per connection. + const auto next = http::Request(target, HTTP_GET).build_request(); + p.execute(next.data(), next.size()); + DOCTEST_REQUIRE(sp.received.size() == 1); + DOCTEST_CHECK(sp.received.front().url == target); + } + } + } +} + +DOCTEST_TEST_CASE("Request target rejected before append") +{ + ccf::http::ParserConfiguration config; + config.max_request_target_size = "4B"; + http::SimpleRequestProcessor sp; + http::RequestParser p(sp, config); + p.new_message(); + p.append_url("/abc", 4); + DOCTEST_CHECK_THROWS_AS( + p.append_url("d", 1), http::RequestTargetTooLongException); + p.end_message(); + DOCTEST_REQUIRE(sp.received.size() == 1); + DOCTEST_CHECK(sp.received.front().url == "/abc"); + + config.max_request_target_size = "0B"; + http::RequestParser zero_limit(sp, config); + DOCTEST_CHECK_THROWS_AS( + zero_limit.append_url("/", 1), http::RequestTargetTooLongException); +} + +DOCTEST_TEST_CASE("Request target configuration") +{ + const auto omitted = + nlohmann::json::object().get(); + DOCTEST_CHECK_FALSE(omitted.max_request_target_size.has_value()); + + const auto config = nlohmann::json{ + {"max_request_target_size", + "32KB"}}.get(); + DOCTEST_REQUIRE(config.max_request_target_size.has_value()); + DOCTEST_CHECK(config.max_request_target_size->count_bytes() == 32 * 1024); + DOCTEST_CHECK_FALSE(config.max_header_size.has_value()); + const nlohmann::json encoded = config; + DOCTEST_CHECK(encoded["max_request_target_size"] == "32KB"); + DOCTEST_CHECK(encoded.get() == config); + + const auto permissive_config = ccf::http::permissive_configuration(); + DOCTEST_REQUIRE(permissive_config.max_request_target_size.has_value()); + DOCTEST_CHECK( + permissive_config.max_request_target_size->count_bytes() == + 100 * 1024 * 1024); + http::SimpleRequestProcessor sp; + http::RequestParser p(sp, permissive_config); + const auto target = "/" + std::string(32 * 1024, 'a'); + const auto request = http::Request(target, HTTP_GET).build_request(); + p.execute(request.data(), request.size()); + DOCTEST_REQUIRE(sp.received.size() == 1); + DOCTEST_CHECK(sp.received.front().url == target); +} + DOCTEST_TEST_CASE("Multiple requests") { http::SimpleRequestProcessor sp; diff --git a/src/js/core/context.cpp b/src/js/core/context.cpp index ee9c9f8698b5..d89709b085de 100644 --- a/src/js/core/context.cpp +++ b/src/js/core/context.cpp @@ -614,44 +614,23 @@ namespace ccf::js::core std::optional Context::to_str(const JSWrappedValue& x) const { - size_t len = 0; - const auto* val = JS_ToCStringLen(ctx, &len, x.val); - if (val == nullptr) - { - // JS_ToCStringLen returns nullptr when a JS exception is already set (eg - // OOM, or an exception during coercion). Preserve that exception for - // callers. - return std::nullopt; - } - // Construct with explicit length rather than relying on the returned - // buffer's NUL terminator, since the JS string may itself contain - // embedded NUL characters which would otherwise silently truncate it. - std::string r(val, len); - JS_FreeCString(ctx, val); - return r; + return to_str(x.val); } std::optional Context::to_str(const JSValue& x) const { size_t len = 0; - const auto* val = JS_ToCStringLen(ctx, &len, x); - if (val == nullptr) - { - // JS_ToCStringLen returns nullptr when a JS exception is already set (eg - // OOM, or an exception during coercion). Preserve that exception for - // callers. - return std::nullopt; - } - // See comment in to_str(const JSWrappedValue&) above. - std::string r(val, len); - JS_FreeCString(ctx, val); - return r; + return to_str(x, len); } std::optional Context::to_str( const JSValue& x, size_t& len) const { - const auto* val = JS_ToCStringLen(ctx, &len, x); + const auto free_cstring = [this](const char* str) { + JS_FreeCString(ctx, str); + }; + const std::unique_ptr val( + JS_ToCStringLen(ctx, &len, x), free_cstring); if (val == nullptr) { // JS_ToCStringLen returns nullptr when a JS exception is already set (eg @@ -659,26 +638,26 @@ namespace ccf::js::core // caller return std::nullopt; } - // See comment in to_str(const JSWrappedValue&) above. - std::string r(val, len); - JS_FreeCString(ctx, val); - return r; + // Preserve embedded NUL bytes. The QuickJS buffer may alias a live JS + // string, so release it even if copying throws, but do not cleanse it. + return std::string(val.get(), len); } std::optional Context::to_str(const JSAtom& atom) const { size_t len = 0; - const auto* val = JS_AtomToCStringLen(ctx, &len, atom); + const auto free_cstring = [this](const char* str) { + JS_FreeCString(ctx, str); + }; + const std::unique_ptr val( + JS_AtomToCStringLen(ctx, &len, atom), free_cstring); if (val == nullptr) { // JS_AtomToCStringLen returns nullptr when a JS exception is already set // (eg OOM). Preserve that exception for callers. return std::nullopt; } - // See comment in to_str(const JSWrappedValue&) above. - std::string r(val, len); - JS_FreeCString(ctx, val); - return r; + return std::string(val.get(), len); } void Context::add_extension(const js::extensions::ExtensionPtr& extension) diff --git a/src/js/core/wrapped_value.cpp b/src/js/core/wrapped_value.cpp index 5fddcf3a652f..45b9f240835a 100644 --- a/src/js/core/wrapped_value.cpp +++ b/src/js/core/wrapped_value.cpp @@ -73,12 +73,10 @@ namespace ccf::js::core int JSWrappedValue::set(const char* prop, JSWrappedValue&& value) const { - int rc = JS_SetPropertyStr(ctx, val, prop, value.val); - if (rc == 1) - { - value.val = ccf::js::core::constants::Null; - } - return rc; + // JS_SetPropertyStr takes ownership of the value on every return path, + // including failure, so we call .take() to always drop our local owning + // reference + return JS_SetPropertyStr(ctx, val, prop, value.take()); } int JSWrappedValue::set_getter( @@ -91,9 +89,8 @@ namespace ccf::js::core return -1; } - // NB: Where other calls check the return code to determine whether they - // are responsible for freeing, this call unconditionally frees the getter - // arg, so we call .take() to always drop our local owning reference + // NB: This call unconditionally frees the getter arg, so we call .take() to + // always drop our local owning reference int rc = JS_DefinePropertyGetSet( ctx, val, @@ -141,13 +138,10 @@ namespace ccf::js::core int JSWrappedValue::set_at_index(uint32_t index, JSWrappedValue&& value) const { - int rc = - JS_DefinePropertyValueUint32(ctx, val, index, value.val, JS_PROP_C_W_E); - if (rc == 1) - { - value.val = ccf::js::core::constants::Null; - } - return rc; + // As with set(), JS_DefinePropertyValueUint32 takes ownership of the value + // on every return path, including failure + return JS_DefinePropertyValueUint32( + ctx, val, index, value.take(), JS_PROP_C_W_E); } bool JSWrappedValue::is_exception() const diff --git a/src/js/extensions/ccf/crypto.cpp b/src/js/extensions/ccf/crypto.cpp index d3051720b960..ae6c3d5fbbd5 100644 --- a/src/js/extensions/ccf/crypto.cpp +++ b/src/js/extensions/ccf/crypto.cpp @@ -16,6 +16,7 @@ #include "ccf/js/core/context.h" #include "ds/internal_logger.h" #include "js/checks.h" +#include "js/extensions/ccf/scoped_cleanse.h" #include "tls/ca.h" #include @@ -107,12 +108,12 @@ namespace ccf::js::extensions try { ccf::crypto::Pem prv = k->private_key_pem(); + ccf::js::ScopedCleanse prv_guard(prv); ccf::crypto::Pem pub = k->public_key_pem(); auto r = jsctx.new_obj(); JS_CHECK_EXC(r); auto private_key = jsctx.new_string(prv.str()); - OPENSSL_cleanse(prv.data(), prv.size()); JS_CHECK_EXC(private_key); JS_CHECK_SET(r.set("privateKey", std::move(private_key))); auto public_key = jsctx.new_string(pub.str()); @@ -170,12 +171,12 @@ namespace ccf::js::extensions auto k = ccf::crypto::make_ec_key_pair(cid); ccf::crypto::Pem prv = k->private_key_pem(); + ccf::js::ScopedCleanse prv_guard(prv); ccf::crypto::Pem pub = k->public_key_pem(); auto r = jsctx.new_obj(); JS_CHECK_EXC(r); auto private_key = jsctx.new_string(prv.str()); - OPENSSL_cleanse(prv.data(), prv.size()); JS_CHECK_EXC(private_key); JS_CHECK_SET(r.set("privateKey", std::move(private_key))); auto public_key = jsctx.new_string(pub.str()); @@ -228,12 +229,12 @@ namespace ccf::js::extensions auto k = ccf::crypto::make_eddsa_key_pair(cid); ccf::crypto::Pem prv = k->private_key_pem(); + ccf::js::ScopedCleanse prv_guard(prv); ccf::crypto::Pem pub = k->public_key_pem(); auto r = jsctx.new_obj(); JS_CHECK_EXC(r); auto private_key = jsctx.new_string(prv.str()); - OPENSSL_cleanse(prv.data(), prv.size()); JS_CHECK_EXC(private_key); JS_CHECK_SET(r.set("privateKey", std::move(private_key))); auto public_key = jsctx.new_string(pub.str()); @@ -478,6 +479,7 @@ namespace ccf::js::extensions { return ccf::js::core::constants::Exception; } + ccf::js::ScopedCleanse pem_str_guard(*pem_str); std::optional kid = std::nullopt; if (argc == 2) @@ -491,38 +493,42 @@ namespace ccf::js::extensions } T jwk; + ccf::js::ScopedCleanse jwk_guard(jwk); try { + ccf::crypto::Pem pem(*pem_str); + ccf::js::ScopedCleanse pem_guard(pem); + if constexpr (std::is_same_v) { - auto pubk = ccf::crypto::make_ec_public_key(*pem_str); + auto pubk = ccf::crypto::make_ec_public_key(pem); jwk = pubk->public_key_jwk(kid); } else if constexpr (std::is_same_v) { - auto kp = ccf::crypto::make_ec_key_pair(*pem_str); + auto kp = ccf::crypto::make_ec_key_pair(pem); jwk = kp->private_key_jwk(kid); } else if constexpr (std::is_same_v) { - auto pubk = ccf::crypto::make_rsa_public_key(*pem_str); + auto pubk = ccf::crypto::make_rsa_public_key(pem); jwk = pubk->public_key_jwk(kid); } else if constexpr (std::is_same_v) { - auto kp = ccf::crypto::make_rsa_key_pair(*pem_str); + auto kp = ccf::crypto::make_rsa_key_pair(pem); jwk = kp->private_key_jwk(kid); } else if constexpr (std:: is_same_v) { - auto pubk = ccf::crypto::make_eddsa_public_key(*pem_str); + auto pubk = ccf::crypto::make_eddsa_public_key(pem); jwk = pubk->public_key_jwk_eddsa(kid); } else if constexpr (std:: is_same_v) { - auto kp = ccf::crypto::make_eddsa_key_pair(*pem_str); + auto kp = ccf::crypto::make_eddsa_key_pair(pem); jwk = kp->private_key_jwk_eddsa(kid); } else @@ -538,7 +544,11 @@ namespace ccf::js::extensions try { - auto jwk_str = nlohmann::json(jwk).dump(); + nlohmann::json jwk_json; + ccf::js::ScopedCleanse jwk_json_guard(jwk_json); + ccf::crypto::to_json(jwk_json, jwk); + auto jwk_str = jwk_json.dump(); + ccf::js::ScopedCleanse jwk_str_guard(jwk_str); return JS_ParseJSON(ctx, jwk_str.c_str(), jwk_str.size(), ""); } catch (const std::exception& ex) @@ -566,12 +576,18 @@ namespace ccf::js::extensions { return ccf::js::core::constants::Exception; } + ccf::js::ScopedCleanse jwk_str_guard(*jwk_str); ccf::crypto::Pem pem; + ccf::js::ScopedCleanse pem_guard(pem); try { - T jwk = ccf::parse_json_safe(jwk_str.value()); + auto jwk_json = ccf::parse_json_safe(*jwk_str); + ccf::js::ScopedCleanse jwk_json_guard(jwk_json); + T jwk; + ccf::js::ScopedCleanse jwk_guard(jwk); + jwk_json.get_to(jwk); if constexpr (std::is_same_v) { @@ -619,25 +635,6 @@ namespace ccf::js::extensions return JS_NewString(ctx, pem.str().c_str()); } - // Cleanses (via OPENSSL_cleanse) the contents of the referenced - // container when the guard goes out of scope. Used to scrub owned copies - // of key material on all exit paths, including exceptions. - template - struct ScopeCleanse - { - T& secret; - explicit ScopeCleanse(T& s) : secret(s) {} - ScopeCleanse(const ScopeCleanse&) = delete; - ScopeCleanse& operator=(const ScopeCleanse&) = delete; - ~ScopeCleanse() - { - if (!secret.empty()) - { - OPENSSL_cleanse(secret.data(), secret.size()); - } - } - }; - // Reads the optional RSA-OAEP "label" parameter. An absent, null or // empty label means "no label", matching the behaviour of the previous // JS_GetArrayBuffer-based code, which silently ignored a null pointer. @@ -696,7 +693,7 @@ namespace ccf::js::extensions // wrapping key: that copy allocates and can fail, and the early return // below must not drop the plaintext without scrubbing it. auto& key = *key_opt; - ScopeCleanse key_cleanse(key); + ccf::js::ScopedCleanse key_cleanse(key); auto wrapping_key_opt = jsctx.copy_array_buffer(argv[1]); if (!wrapping_key_opt.has_value()) @@ -705,7 +702,7 @@ namespace ccf::js::extensions } // wrapping_key is a symmetric secret for AES-KWP. auto& wrapping_key = *wrapping_key_opt; - ScopeCleanse wrapping_key_cleanse(wrapping_key); + ccf::js::ScopedCleanse wrapping_key_cleanse(wrapping_key); auto parameters = argv[2]; auto wrap_algo_name_val = jsctx.get_property(parameters, "name"); @@ -833,7 +830,7 @@ namespace ccf::js::extensions // The guard is kept adjacent to the copy so that no fallible statement // can sit between creating the secret and protecting it. auto& unwrapping_key = *unwrapping_key_opt; - ScopeCleanse unwrapping_key_cleanse(unwrapping_key); + ccf::js::ScopedCleanse unwrapping_key_cleanse(unwrapping_key); auto parameters = argv[2]; auto wrap_algo_name_val = jsctx.get_property(parameters, "name"); @@ -864,13 +861,13 @@ namespace ccf::js::extensions auto pemPrivateUnwrappingKey = ccf::crypto::Pem(unwrapping_key.data(), unwrapping_key.size()); - ScopeCleanse pem_cleanse(pemPrivateUnwrappingKey); + ccf::js::ScopedCleanse pem_cleanse(pemPrivateUnwrappingKey); auto unwrapped_key = ccf::crypto::ckm_rsa_pkcs_oaep_unwrap( pemPrivateUnwrappingKey, key, label_opt); // The unwrapped key is plaintext secret material. This guard runs // after JS_NewArrayBufferCopy has taken its own copy. - ScopeCleanse unwrapped_cleanse(unwrapped_key); + ccf::js::ScopedCleanse unwrapped_cleanse(unwrapped_key); return JS_NewArrayBufferCopy( ctx, unwrapped_key.data(), unwrapped_key.size()); @@ -882,7 +879,7 @@ namespace ccf::js::extensions ccf::crypto::ckm_aes_key_unwrap_pad(unwrapping_key, key); // The unwrapped key is plaintext secret material. This guard runs // after JS_NewArrayBufferCopy has taken its own copy. - ScopeCleanse unwrapped_cleanse(unwrapped_key); + ccf::js::ScopedCleanse unwrapped_cleanse(unwrapped_key); return JS_NewArrayBufferCopy( ctx, unwrapped_key.data(), unwrapped_key.size()); @@ -911,13 +908,13 @@ namespace ccf::js::extensions auto privPemUnwrappingKey = ccf::crypto::Pem(unwrapping_key.data(), unwrapping_key.size()); - ScopeCleanse pem_cleanse(privPemUnwrappingKey); + ccf::js::ScopedCleanse pem_cleanse(privPemUnwrappingKey); auto unwrapped_key = ccf::crypto::ckm_rsa_aes_key_unwrap( privPemUnwrappingKey, key, label_opt); // The unwrapped key is plaintext secret material. This guard runs // after JS_NewArrayBufferCopy has taken its own copy. - ScopeCleanse unwrapped_cleanse(unwrapped_key); + ccf::js::ScopedCleanse unwrapped_cleanse(unwrapped_key); return JS_NewArrayBufferCopy( ctx, unwrapped_key.data(), unwrapped_key.size()); @@ -970,7 +967,8 @@ namespace ccf::js::extensions { return ccf::js::core::constants::Exception; } - auto key = *key_str; + ccf::js::ScopedCleanse key_str_guard(*key_str); + auto& key = *key_str; size_t data_size = 0; uint8_t* data = JS_GetArrayBuffer(ctx, &data_size, argv[2]); @@ -986,6 +984,7 @@ namespace ccf::js::extensions try { ccf::crypto::Pem key_pem(key); + ccf::js::ScopedCleanse key_pem_guard(key_pem); auto key_pair = ccf::crypto::make_eddsa_key_pair(key_pem); auto sig = key_pair->sign(contents); return JS_NewArrayBufferCopy(ctx, sig.data(), sig.size()); @@ -1034,7 +1033,9 @@ namespace ccf::js::extensions if (algo_name == "ECDSA") { - auto key_pair = ccf::crypto::make_ec_key_pair(key); + ccf::crypto::Pem key_pem(key); + ccf::js::ScopedCleanse key_pem_guard(key_pem); + auto key_pair = ccf::crypto::make_ec_key_pair(key_pem); auto sig_der = key_pair->sign(contents, mdtype); auto sig = ccf::crypto::ecdsa_sig_der_to_p1363( sig_der, key_pair->get_curve_id()); @@ -1043,7 +1044,9 @@ namespace ccf::js::extensions if (algo_name == "RSA-PSS") { - auto key_pair = ccf::crypto::make_rsa_key_pair(key); + ccf::crypto::Pem key_pem(key); + ccf::js::ScopedCleanse key_pem_guard(key_pem); + auto key_pair = ccf::crypto::make_rsa_key_pair(key_pem); int64_t salt_length{}; std::ignore = JS_ToInt64( @@ -1060,6 +1063,7 @@ namespace ccf::js::extensions if (algo_name == "HMAC") { std::vector vkey(key.begin(), key.end()); + ccf::js::ScopedCleanse> vkey_guard(vkey); const auto sig = ccf::crypto::hmac(mdtype, vkey, contents); return JS_NewArrayBufferCopy(ctx, sig.data(), sig.size()); } diff --git a/src/js/extensions/ccf/scoped_cleanse.h b/src/js/extensions/ccf/scoped_cleanse.h new file mode 100644 index 000000000000..3f94d6181b44 --- /dev/null +++ b/src/js/extensions/ccf/scoped_cleanse.h @@ -0,0 +1,96 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the Apache 2.0 License. +#pragma once + +#include "ccf/crypto/jwk.h" + +#include + +namespace ccf::js +{ + namespace detail + { + template + requires requires(T& target) { + target.data(); + target.size(); + target.empty(); + } + void cleanse(T& target) + { + if (!target.empty()) + { + OPENSSL_cleanse(target.data(), target.size()); + } + } + + inline void cleanse(ccf::crypto::JsonWebKeyECPublic& jwk) {} + inline void cleanse(ccf::crypto::JsonWebKeyRSAPublic& jwk) {} + inline void cleanse(ccf::crypto::JsonWebKeyEdDSAPublic& jwk) {} + + inline void cleanse(ccf::crypto::JsonWebKeyECPrivate& jwk) + { + cleanse(jwk.d); + } + + inline void cleanse(ccf::crypto::JsonWebKeyRSAPrivate& jwk) + { + cleanse(jwk.d); + cleanse(jwk.p); + cleanse(jwk.q); + cleanse(jwk.dp); + cleanse(jwk.dq); + cleanse(jwk.qi); + } + + inline void cleanse(ccf::crypto::JsonWebKeyEdDSAPrivate& jwk) + { + cleanse(jwk.d); + } + + inline void cleanse(nlohmann::json& value) + { + if (auto* string = value.get_ptr()) + { + cleanse(*string); + } + else if (auto* array = value.get_ptr()) + { + for (auto& child : *array) + { + cleanse(child); + } + } + else if (auto* object = value.get_ptr()) + { + for (auto& [key, child] : *object) + { + cleanse(child); + } + } + } + } + + // Scrubs owned byte containers, private JWK fields and JSON string values + // on scope exit. The target must outlive the guard and must not discard + // secret storage (for example by shrinking or overwriting it) while guarded. + template + class ScopedCleanse + { + public: + explicit ScopedCleanse(T& target) : target(&target) {} + + ScopedCleanse(const ScopedCleanse&) = delete; + ScopedCleanse& operator=(const ScopedCleanse&) = delete; + ScopedCleanse(ScopedCleanse&&) = delete; + ScopedCleanse& operator=(ScopedCleanse&&) = delete; + + ~ScopedCleanse() + { + detail::cleanse(*target); + } + + private: + T* target; + }; +} diff --git a/src/js/extensions/snp_attestation.cpp b/src/js/extensions/snp_attestation.cpp index 245c2268410f..aa24eb15a898 100644 --- a/src/js/extensions/snp_attestation.cpp +++ b/src/js/extensions/snp_attestation.cpp @@ -22,9 +22,9 @@ namespace ccf::js::extensions { JSValue make_js_tcb_version( - js::core::Context& jsctx, pal::snp::TcbVersionRaw tcb) + js::core::Context& jsctx, std::span tcb) { - auto data_hex = jsctx.new_string(tcb.to_hex()); + auto data_hex = jsctx.new_string(pal::snp::TcbVersionRaw(tcb).to_hex()); JS_CHECK_EXC(data_hex); return data_hex.take(); } @@ -94,10 +94,9 @@ namespace ccf::js::extensions pal::PlatformAttestationMeasurement measurement = {}; pal::PlatformAttestationReportData report_data = {}; std::optional parsed_uvm_endorsements; - try { - pal::verify_snp_attestation_report( + const auto attestation = pal::verify_snp_attestation_report_and_get( quote_info, measurement, report_data); if (uvm_endorsements.has_value()) { @@ -107,223 +106,316 @@ namespace ccf::js::extensions measurement, default_uvm_roots_of_trust); } - } - catch (const std::exception& e) - { - return JS_ThrowRangeError(ctx, "%s", e.what()); - } - - auto attestation = *reinterpret_cast( - quote_info.quote.data()); - - auto r = jsctx.new_obj(); - JS_CHECK_EXC(r); - - auto a = jsctx.new_obj(); - JS_CHECK_EXC(a); - - JS_CHECK_SET(a.set_uint32("version", attestation.version)); - JS_CHECK_SET(a.set_uint32("guest_svn", attestation.guest_svn)); - - auto policy = jsctx.new_obj(); - JS_CHECK_EXC(policy); - - JS_CHECK_SET( - policy.set_uint32("abi_minor", attestation.policy.abi_minor)); - JS_CHECK_SET( - policy.set_uint32("abi_major", attestation.policy.abi_major)); - JS_CHECK_SET(policy.set_uint32("smt", attestation.policy.smt)); - JS_CHECK_SET( - policy.set_uint32("migrate_ma", attestation.policy.migrate_ma)); - JS_CHECK_SET(policy.set_uint32("debug", attestation.policy.debug)); - JS_CHECK_SET( - policy.set_uint32("single_socket", attestation.policy.single_socket)); + auto r = jsctx.new_obj(); + JS_CHECK_EXC(r); + + auto a = jsctx.new_obj(); + JS_CHECK_EXC(a); + + JS_CHECK_SET(a.set_uint32( + "version", tav_snp_attestation_report_version(attestation.get()))); + JS_CHECK_SET(a.set_uint32( + "guest_svn", + tav_snp_attestation_report_guest_svn(attestation.get()))); + + auto policy = jsctx.new_obj(); + JS_CHECK_EXC(policy); + + JS_CHECK_SET(policy.set_uint32( + "abi_minor", + tav_snp_attestation_report_policy_abi_minor(attestation.get()))); + JS_CHECK_SET(policy.set_uint32( + "abi_major", + tav_snp_attestation_report_policy_abi_major(attestation.get()))); + JS_CHECK_SET(policy.set_uint32( + "smt", tav_snp_attestation_report_policy_smt(attestation.get()))); + JS_CHECK_SET(policy.set_uint32( + "migrate_ma", + tav_snp_attestation_report_policy_migrate_ma(attestation.get()))); + JS_CHECK_SET(policy.set_uint32( + "debug", tav_snp_attestation_report_policy_debug(attestation.get()))); + JS_CHECK_SET(policy.set_uint32( + "single_socket", + tav_snp_attestation_report_policy_single_socket(attestation.get()))); + + JS_CHECK_SET(a.set("policy", std::move(policy))); - JS_CHECK_SET(a.set("policy", std::move(policy))); - - { - auto family_id = jsctx.new_array_buffer_copy(attestation.family_id); - JS_CHECK_EXC(family_id); - JS_CHECK_SET(a.set("family_id", std::move(family_id))); - } - - { - auto image_id = jsctx.new_array_buffer_copy(attestation.image_id); - JS_CHECK_EXC(image_id); - JS_CHECK_SET(a.set("image_id", std::move(image_id))); - } - - JS_CHECK_SET(a.set_uint32("vmpl", attestation.vmpl)); - JS_CHECK_SET(a.set_uint32( - "signature_algo", static_cast(attestation.signature_algo))); - - { - auto platform_version = - jsctx.wrap(make_js_tcb_version(jsctx, attestation.platform_version)); - JS_CHECK_EXC(platform_version); - JS_CHECK_SET(a.set("platform_version", std::move(platform_version))); - } - - { - auto platform_info = jsctx.new_obj(); - JS_CHECK_EXC(platform_info); - JS_CHECK_SET( - platform_info.set_uint32("smt_en", attestation.platform_info.smt_en)); - JS_CHECK_SET(platform_info.set_uint32( - "tsme_en", attestation.platform_info.tsme_en)); - JS_CHECK_SET(a.set("plaform_info", std::move(platform_info))); - } + { + const uint8_t* data = nullptr; + size_t size = 0; + tav_snp_attestation_report_family_id(attestation.get(), &data, &size); + auto family_id = + jsctx.new_array_buffer_copy(std::span{data, size}); + JS_CHECK_EXC(family_id); + JS_CHECK_SET(a.set("family_id", std::move(family_id))); + } - { - auto flags = jsctx.new_obj(); - JS_CHECK_EXC(flags); - JS_CHECK_SET( - flags.set_uint32("author_key_en", attestation.flags.author_key_en)); - JS_CHECK_SET( - flags.set_uint32("mask_chip_key", attestation.flags.mask_chip_key)); - JS_CHECK_SET( - flags.set_uint32("signing_key", attestation.flags.signing_key)); - JS_CHECK_SET(a.set("flags", std::move(flags))); - } + { + const uint8_t* data = nullptr; + size_t size = 0; + tav_snp_attestation_report_image_id(attestation.get(), &data, &size); + auto image_id = + jsctx.new_array_buffer_copy(std::span{data, size}); + JS_CHECK_EXC(image_id); + JS_CHECK_SET(a.set("image_id", std::move(image_id))); + } - { - auto attestation_report_data = - jsctx.new_array_buffer_copy(attestation.report_data); - JS_CHECK_EXC(attestation_report_data); - JS_CHECK_SET(a.set("report_data", std::move(attestation_report_data))); - } + JS_CHECK_SET(a.set_uint32( + "vmpl", tav_snp_attestation_report_vmpl(attestation.get()))); + JS_CHECK_SET(a.set_uint32( + "signature_algo", + static_cast( + tav_snp_attestation_report_signature_algo(attestation.get())))); - { - auto attestation_measurement = - jsctx.new_array_buffer_copy(attestation.measurement); - JS_CHECK_EXC(attestation_measurement); - JS_CHECK_SET(a.set("measurement", std::move(attestation_measurement))); - } + { + const uint8_t* data = nullptr; + size_t size = 0; + tav_snp_attestation_report_platform_version( + attestation.get(), &data, &size); + auto platform_version = jsctx.wrap( + make_js_tcb_version(jsctx, std::span{data, size})); + JS_CHECK_EXC(platform_version); + JS_CHECK_SET(a.set("platform_version", std::move(platform_version))); + } - { - auto attestation_host_data = - jsctx.new_array_buffer_copy(attestation.host_data); - JS_CHECK_EXC(attestation_host_data); - JS_CHECK_SET(a.set("host_data", std::move(attestation_host_data))); - } + { + auto platform_info = jsctx.new_obj(); + JS_CHECK_EXC(platform_info); + const auto raw_platform_info = + tav_snp_attestation_report_platform_info(attestation.get()); + JS_CHECK_SET( + platform_info.set_uint32("smt_en", raw_platform_info & 1)); + JS_CHECK_SET( + platform_info.set_uint32("tsme_en", (raw_platform_info >> 1) & 1)); + JS_CHECK_SET(a.set("plaform_info", std::move(platform_info))); + } - { - auto attestation_id_key_digest = - jsctx.new_array_buffer_copy(attestation.id_key_digest); - JS_CHECK_EXC(attestation_id_key_digest); - JS_CHECK_SET( - a.set("id_key_digest", std::move(attestation_id_key_digest))); - } + { + auto flags = jsctx.new_obj(); + JS_CHECK_EXC(flags); + JS_CHECK_SET(flags.set_uint32( + "author_key_en", + tav_snp_attestation_report_flags_author_key_en(attestation.get()))); + JS_CHECK_SET(flags.set_uint32( + "mask_chip_key", + tav_snp_attestation_report_flags_mask_chip_key(attestation.get()))); + JS_CHECK_SET(flags.set_uint32( + "signing_key", + tav_snp_attestation_report_flags_signing_key(attestation.get()))); + JS_CHECK_SET(a.set("flags", std::move(flags))); + } - { - auto attestation_author_key_digest = - jsctx.new_array_buffer_copy(attestation.author_key_digest); - JS_CHECK_EXC(attestation_author_key_digest); - JS_CHECK_SET( - a.set("author_key_digest", std::move(attestation_author_key_digest))); - } + { + const uint8_t* data = nullptr; + size_t size = 0; + tav_snp_attestation_report_report_data( + attestation.get(), &data, &size); + auto attestation_report_data = + jsctx.new_array_buffer_copy(std::span{data, size}); + JS_CHECK_EXC(attestation_report_data); + JS_CHECK_SET( + a.set("report_data", std::move(attestation_report_data))); + } - { - auto attestation_report_id = - jsctx.new_array_buffer_copy(attestation.report_id); - JS_CHECK_EXC(attestation_report_id); - JS_CHECK_SET(a.set("report_id", std::move(attestation_report_id))); - } + { + const uint8_t* data = nullptr; + size_t size = 0; + tav_snp_attestation_report_measurement( + attestation.get(), &data, &size); + auto attestation_measurement = + jsctx.new_array_buffer_copy(std::span{data, size}); + JS_CHECK_EXC(attestation_measurement); + JS_CHECK_SET( + a.set("measurement", std::move(attestation_measurement))); + } - { - auto attestation_report_id_ma = - jsctx.new_array_buffer_copy(attestation.report_id_ma); - JS_CHECK_EXC(attestation_report_id_ma); - JS_CHECK_SET( - a.set("report_id_ma", std::move(attestation_report_id_ma))); - } + { + const uint8_t* data = nullptr; + size_t size = 0; + tav_snp_attestation_report_host_data(attestation.get(), &data, &size); + auto attestation_host_data = + jsctx.new_array_buffer_copy(std::span{data, size}); + JS_CHECK_EXC(attestation_host_data); + JS_CHECK_SET(a.set("host_data", std::move(attestation_host_data))); + } - { - auto reported_tcb = - jsctx.wrap(make_js_tcb_version(jsctx, attestation.reported_tcb)); - JS_CHECK_EXC(reported_tcb); - JS_CHECK_SET(a.set("reported_tcb", std::move(reported_tcb))); - } + { + const uint8_t* data = nullptr; + size_t size = 0; + tav_snp_attestation_report_id_key_digest( + attestation.get(), &data, &size); + auto attestation_id_key_digest = + jsctx.new_array_buffer_copy(std::span{data, size}); + JS_CHECK_EXC(attestation_id_key_digest); + JS_CHECK_SET( + a.set("id_key_digest", std::move(attestation_id_key_digest))); + } - JS_CHECK_SET(a.set_uint32("cpuid_fam_id", attestation.cpuid_fam_id)); - JS_CHECK_SET(a.set_uint32("cpuid_mod_id", attestation.cpuid_mod_id)); - JS_CHECK_SET(a.set_uint32("cpuid_step", attestation.cpuid_step)); + { + const uint8_t* data = nullptr; + size_t size = 0; + tav_snp_attestation_report_author_key_digest( + attestation.get(), &data, &size); + auto attestation_author_key_digest = + jsctx.new_array_buffer_copy(std::span{data, size}); + JS_CHECK_EXC(attestation_author_key_digest); + JS_CHECK_SET(a.set( + "author_key_digest", std::move(attestation_author_key_digest))); + } - { - auto attestation_chip_id = - jsctx.new_array_buffer_copy(attestation.chip_id); - JS_CHECK_EXC(attestation_chip_id); - JS_CHECK_SET(a.set("chip_id", std::move(attestation_chip_id))); - } + { + const uint8_t* data = nullptr; + size_t size = 0; + tav_snp_attestation_report_report_id(attestation.get(), &data, &size); + auto attestation_report_id = + jsctx.new_array_buffer_copy(std::span{data, size}); + JS_CHECK_EXC(attestation_report_id); + JS_CHECK_SET(a.set("report_id", std::move(attestation_report_id))); + } - { - auto committed_tcb = - jsctx.wrap(make_js_tcb_version(jsctx, attestation.committed_tcb)); - JS_CHECK_EXC(committed_tcb); - JS_CHECK_SET(a.set("committed_tcb", std::move(committed_tcb))); - } + { + const uint8_t* data = nullptr; + size_t size = 0; + tav_snp_attestation_report_report_id_ma( + attestation.get(), &data, &size); + auto attestation_report_id_ma = + jsctx.new_array_buffer_copy(std::span{data, size}); + JS_CHECK_EXC(attestation_report_id_ma); + JS_CHECK_SET( + a.set("report_id_ma", std::move(attestation_report_id_ma))); + } - JS_CHECK_SET(a.set_uint32("current_minor", attestation.current_minor)); - JS_CHECK_SET(a.set_uint32("current_build", attestation.current_build)); - JS_CHECK_SET(a.set_uint32("current_major", attestation.current_major)); - JS_CHECK_SET( - a.set_uint32("committed_build", attestation.committed_build)); - JS_CHECK_SET( - a.set_uint32("committed_minor", attestation.committed_minor)); - JS_CHECK_SET( - a.set_uint32("committed_major", attestation.committed_major)); + { + const uint8_t* data = nullptr; + size_t size = 0; + tav_snp_attestation_report_reported_tcb( + attestation.get(), &data, &size); + auto reported_tcb = jsctx.wrap( + make_js_tcb_version(jsctx, std::span{data, size})); + JS_CHECK_EXC(reported_tcb); + JS_CHECK_SET(a.set("reported_tcb", std::move(reported_tcb))); + } - { - auto launch_tcb = - jsctx.wrap(make_js_tcb_version(jsctx, attestation.launch_tcb)); - JS_CHECK_EXC(launch_tcb); - JS_CHECK_SET(a.set("launch_tcb", std::move(launch_tcb))); - } + JS_CHECK_SET(a.set_uint32( + "cpuid_fam_id", + tav_snp_attestation_report_cpuid_fam_id(attestation.get()))); + JS_CHECK_SET(a.set_uint32( + "cpuid_mod_id", + tav_snp_attestation_report_cpuid_mod_id(attestation.get()))); + JS_CHECK_SET(a.set_uint32( + "cpuid_step", + tav_snp_attestation_report_cpuid_step(attestation.get()))); - auto signature = jsctx.new_obj(); - JS_CHECK_EXC(signature); + { + const uint8_t* data = nullptr; + size_t size = 0; + tav_snp_attestation_report_chip_id(attestation.get(), &data, &size); + auto attestation_chip_id = + jsctx.new_array_buffer_copy(std::span{data, size}); + JS_CHECK_EXC(attestation_chip_id); + JS_CHECK_SET(a.set("chip_id", std::move(attestation_chip_id))); + } - { - auto signature_r = jsctx.new_array_buffer_copy(attestation.signature.r); - JS_CHECK_EXC(signature_r); - JS_CHECK_SET(signature.set("r", std::move(signature_r))); - } + { + const uint8_t* data = nullptr; + size_t size = 0; + tav_snp_attestation_report_committed_tcb( + attestation.get(), &data, &size); + auto committed_tcb = jsctx.wrap( + make_js_tcb_version(jsctx, std::span{data, size})); + JS_CHECK_EXC(committed_tcb); + JS_CHECK_SET(a.set("committed_tcb", std::move(committed_tcb))); + } - { - auto signature_s = jsctx.new_array_buffer_copy(attestation.signature.s); - JS_CHECK_EXC(signature_s); - JS_CHECK_SET(signature.set("s", std::move(signature_s))); - } + JS_CHECK_SET(a.set_uint32( + "current_minor", + tav_snp_attestation_report_current_minor(attestation.get()))); + JS_CHECK_SET(a.set_uint32( + "current_build", + tav_snp_attestation_report_current_build(attestation.get()))); + JS_CHECK_SET(a.set_uint32( + "current_major", + tav_snp_attestation_report_current_major(attestation.get()))); + JS_CHECK_SET(a.set_uint32( + "committed_build", + tav_snp_attestation_report_committed_build(attestation.get()))); + JS_CHECK_SET(a.set_uint32( + "committed_minor", + tav_snp_attestation_report_committed_minor(attestation.get()))); + JS_CHECK_SET(a.set_uint32( + "committed_major", + tav_snp_attestation_report_committed_major(attestation.get()))); - JS_CHECK_SET(a.set("signature", std::move(signature))); - JS_CHECK_SET(r.set("attestation", std::move(a))); + { + const uint8_t* data = nullptr; + size_t size = 0; + tav_snp_attestation_report_launch_tcb( + attestation.get(), &data, &size); + auto launch_tcb = jsctx.wrap( + make_js_tcb_version(jsctx, std::span{data, size})); + JS_CHECK_EXC(launch_tcb); + JS_CHECK_SET(a.set("launch_tcb", std::move(launch_tcb))); + } - if (parsed_uvm_endorsements.has_value()) - { - auto u = jsctx.new_obj(); - JS_CHECK_EXC(u); + auto signature = jsctx.new_obj(); + JS_CHECK_EXC(signature); { - auto did = jsctx.new_string(parsed_uvm_endorsements.value().did); - JS_CHECK_EXC(did); - JS_CHECK_SET(u.set("did", std::move(did))); + const uint8_t* data = nullptr; + size_t size = 0; + tav_snp_attestation_report_signature_r( + attestation.get(), &data, &size); + auto signature_r = + jsctx.new_array_buffer_copy(std::span{data, size}); + JS_CHECK_EXC(signature_r); + JS_CHECK_SET(signature.set("r", std::move(signature_r))); } { - auto feed = jsctx.new_string(parsed_uvm_endorsements.value().feed); - JS_CHECK_EXC(feed); - JS_CHECK_SET(u.set("feed", std::move(feed))); + const uint8_t* data = nullptr; + size_t size = 0; + tav_snp_attestation_report_signature_s( + attestation.get(), &data, &size); + auto signature_s = + jsctx.new_array_buffer_copy(std::span{data, size}); + JS_CHECK_EXC(signature_s); + JS_CHECK_SET(signature.set("s", std::move(signature_s))); } + JS_CHECK_SET(a.set("signature", std::move(signature))); + JS_CHECK_SET(r.set("attestation", std::move(a))); + + if (parsed_uvm_endorsements.has_value()) { - auto svn = jsctx.new_string(parsed_uvm_endorsements.value().svn); - JS_CHECK_EXC(svn); - JS_CHECK_SET(u.set("svn", std::move(svn))); - JS_CHECK_SET(r.set("uvm_endorsements", std::move(u))); + auto u = jsctx.new_obj(); + JS_CHECK_EXC(u); + + { + auto did = jsctx.new_string(parsed_uvm_endorsements.value().did); + JS_CHECK_EXC(did); + JS_CHECK_SET(u.set("did", std::move(did))); + } + + { + auto feed = jsctx.new_string(parsed_uvm_endorsements.value().feed); + JS_CHECK_EXC(feed); + JS_CHECK_SET(u.set("feed", std::move(feed))); + } + + { + auto svn = jsctx.new_string(parsed_uvm_endorsements.value().svn); + JS_CHECK_EXC(svn); + JS_CHECK_SET(u.set("svn", std::move(svn))); + JS_CHECK_SET(r.set("uvm_endorsements", std::move(u))); + } } - } - return r.take(); + return r.take(); + } + catch (const std::exception& e) + { + return JS_ThrowRangeError(ctx, "%s", e.what()); + } } #pragma clang diagnostic pop diff --git a/src/js/interpreter_cache.h b/src/js/interpreter_cache.h index b7a5907c02cf..be8de0f0d4f9 100644 --- a/src/js/interpreter_cache.h +++ b/src/js/interpreter_cache.h @@ -4,6 +4,7 @@ #include "ccf/ds/locking.h" #include "ccf/js/interpreter_cache_interface.h" +#include "ds/internal_logger.h" #include "ds/lru.h" namespace ccf::js diff --git a/src/js/registry.cpp b/src/js/registry.cpp index b12dcd66d5b1..2c6a3a3d575e 100644 --- a/src/js/registry.cpp +++ b/src/js/registry.cpp @@ -232,6 +232,9 @@ namespace ccf::js } }; + // Historical reads remain valid in response getters and toJSON, but must + // still be released before the interpreter can serve another request. + ExtensionScope historical_extension_scope(ctx); ccf::js::core::JSWrappedValue val; { ExtensionScope extension_scope(ctx); @@ -242,6 +245,20 @@ namespace ccf::js extension_scope.add(extension); } + if (namespace_restriction) + { + // The live KvExtension takes precedence until handler teardown. + // After that, retain only its namespace policy for historical reads, + // including while extracting exceptions and converting the response. + historical_extension_scope.add( + std::make_shared( + nullptr, namespace_restriction)); + } + + historical_extension_scope.add( + std::make_shared( + &context.get_historical_state())); + if (pre_exec_hook.has_value()) { pre_exec_hook.value()(ctx); @@ -686,10 +703,6 @@ namespace ccf::js // add ccf.consensus.* extensions.emplace_back( std::make_shared(this)); - // add ccf.historical.* - extensions.emplace_back( - std::make_shared( - &context.get_historical_state())); interpreter_cache->set_interpreter_factory( [extensions](ccf::js::TxAccess access) { diff --git a/src/js/test/js.cpp b/src/js/test/js.cpp index 696307a320a2..7635139d71bb 100644 --- a/src/js/test/js.cpp +++ b/src/js/test/js.cpp @@ -2,16 +2,23 @@ // Licensed under the Apache 2.0 License. #include "ccf/js/common_context.h" #include "ccf/js/core/wrapped_value.h" +#include "ccf/js/extensions/ccf/consensus.h" #include "ccf/js/extensions/ccf/crypto.h" #include "ccf/js/extensions/ccf/gov.h" #include "ccf/js/extensions/ccf/historical.h" #include "ccf/js/extensions/ccf/kv.h" #include "ccf/js/extensions/snp_attestation.h" +#include "ccf/js/registry.h" +#include "ccf/service/tables/modules.h" +#include "enclave/http_rpc_context.h" +#include "js/extensions/ccf/scoped_cleanse.h" #include "js/global_class_ids.h" +#include "js/interpreter_cache.h" #include "js/permissions_checks.h" #include "kv/store.h" #include "kv/test/null_encryptor.h" #include "kv/untyped_map.h" +#include "node/rpc/test/node_stub.h" #include "node/tx_receipt_impl.h" #define DOCTEST_CONFIG_IMPLEMENT @@ -1329,6 +1336,110 @@ TEST_CASE("JSWrappedValue copy assignment frees old value") JS_FreeRuntime(rt); } +// QuickJS frees the value passed to JS_SetProperty* and JS_DefinePropertyValue* +// on every return path, so the wrapper must relinquish its reference even when +// the set fails. Script can make these fail by placing a setter or read-only +// property on the target's prototype chain, or by making the target +// non-extensible, before the C++ side populates an object. +TEST_CASE("JSWrappedValue setters release the value when the set fails") +{ + ccf::js::core::Context ctx(TxAccess::APP_RW); + JS_UpdateStackTop(ctx.runtime()); + + // Drain any pending exception and return its message + auto pending_exception = [&]() { + REQUIRE(JS_HasException(ctx) != 0); + return ctx.error_message().first; + }; + + SUBCASE("Non-extensible target") + { + auto target = ctx.new_obj(); + REQUIRE(JS_PreventExtensions(ctx, target.val) == 1); + + auto value = ctx.new_obj(); + JSValue raw = JS_DupValue(ctx, value.val); + REQUIRE(get_ref_count(raw) == 2); + + REQUIRE(target.set("x", std::move(value)) == -1); + REQUIRE(get_ref_count(raw) == 1); + CHECK(pending_exception() == "TypeError: object is not extensible"); + + JS_FreeValue(ctx, raw); + } + + SUBCASE("Inherited throwing setter which retains the value") + { + auto handler = ctx.get_exported_function( + "Object.defineProperty(Object.prototype, 'headers', {" + " set(v) { globalThis.leaked = v; throw new Error('boom'); }," + " configurable: true });" + "export function handler() { globalThis.leaked.tag = 'ok'; " + "return JSON.stringify(globalThis.leaked); }", + "handler", + "/test/poisoned_setter"); + + auto target = ctx.new_obj(); + JSValue raw = ctx.undefined().val; + { + auto value = ctx.new_obj(); + raw = JS_DupValue(ctx, value.val); + REQUIRE(get_ref_count(raw) == 2); + + REQUIRE(target.set("headers", std::move(value)) == -1); + CHECK(pending_exception() == "Error: boom"); + } + // Held by raw and by globalThis.leaked + REQUIRE(get_ref_count(raw) == 2); + + // The stashed value must still be a valid object + auto result = ctx.inner_call(handler, {}); + REQUIRE_FALSE(result.is_exception()); + CHECK(ctx.to_str(result).value() == "{\"tag\":\"ok\"}"); + + JS_FreeValue(ctx, raw); + } + + SUBCASE("Inherited read-only data property") + { + ctx.get_exported_function( + "Object.defineProperty(Object.prototype, 'headers', {" + " value: 1, writable: false, configurable: true });" + "export function handler() {}", + "handler", + "/test/poisoned_readonly"); + + auto target = ctx.new_obj(); + auto value = ctx.new_obj(); + JSValue raw = JS_DupValue(ctx, value.val); + REQUIRE(get_ref_count(raw) == 2); + + REQUIRE(target.set("headers", std::move(value)) == -1); + REQUIRE(get_ref_count(raw) == 1); + CHECK(pending_exception() == "TypeError: 'headers' is read-only"); + + JS_FreeValue(ctx, raw); + } + + SUBCASE("set_at_index on a non-extensible array") + { + auto target = ctx.new_array(); + REQUIRE(JS_PreventExtensions(ctx, target.val) == 1); + + auto value = ctx.new_obj(); + JSValue raw = JS_DupValue(ctx, value.val); + REQUIRE(get_ref_count(raw) == 2); + + // Define does not request JS_PROP_THROW, so this is a rejection (0) + // rather than an exception (-1), but the value is consumed either way + REQUIRE(target.set_at_index(0, std::move(value)) == 0); + REQUIRE(get_ref_count(raw) == 1); + CHECK(JS_HasException(ctx) == 0); + + JS_FreeValue(ctx, raw); + } +} + TEST_CASE("QuickJS rejects arena allocations above a lowered heap limit") { ccf::js::core::Context ctx(TxAccess::APP_RW); @@ -1517,40 +1628,49 @@ export function run() { SUBCASE( "unwrapKey (AES-KWP): algorithm.name getter transfers the wrapped key") { - // Same > 512-byte allocation trick as the wrapKey test: a 1024-byte + // Same > 512-byte allocation trick as the wrapKey test: a 1032-byte // wrapped payload routes through the system malloc so ASAN can see // the transfer(0) as a real free and catch the C++ read. + // Use valid ciphertext to avoid OpenSSL's heap corruption on failed + // AES-KWP unwraps (see #8358); this test targets re-entry, not rejection. ccf::js::core::Context ctx(TxAccess::APP_RW); ctx.add_extension(std::make_shared()); const auto script = R"( export function run() { - const wrapped = new Uint8Array(1024); - for (let i = 0; i < 1024; ++i) wrapped[i] = i & 0xff; + const key = new Uint8Array(1024); + for (let i = 0; i < key.length; ++i) key[i] = i & 0xff; const unwrappingKey = new Uint8Array(16); for (let i = 0; i < 16; ++i) unwrappingKey[i] = 0xa0 + i; + const wrapped = ccf.crypto.wrapKey( + key.buffer, unwrappingKey.buffer, { name: "AES-KWP" }); + if (wrapped.byteLength !== 1032) + throw new Error("Unexpected wrapped length"); const params = { get name() { - wrapped.buffer.transfer(0); + wrapped.transfer(0); return "AES-KWP"; } }; - try { - ccf.crypto.unwrapKey(wrapped.buffer, unwrappingKey.buffer, params); - return "no throw"; - } catch (e) { - return "threw"; + const unwrapped = new Uint8Array( + ccf.crypto.unwrapKey(wrapped, unwrappingKey.buffer, params)); + if (wrapped.byteLength !== 0) + throw new Error("Wrapped buffer was not detached"); + if (unwrapped.length !== key.length) + throw new Error("Unexpected unwrapped length"); + for (let i = 0; i < key.length; ++i) { + if (unwrapped[i] !== key[i]) + throw new Error("Unwrapped bytes differ from the original key"); } + return "ok"; } )"; auto func = ctx.get_exported_function(script, "run", "/reentry-unwrap.js"); const auto result = ctx.call_with_rt_options( func, {}, std::nullopt, ccf::js::core::RuntimeLimitsPolicy::NONE); REQUIRE_FALSE(result.is_exception()); - // Random ciphertext + random unwrapping key must fail; success would - // suggest we accidentally ran on freed memory. const auto s = ctx.to_str(result); REQUIRE(s.has_value()); - REQUIRE(*s == "threw"); + REQUIRE(*s == "ok"); } SUBCASE( @@ -1766,7 +1886,7 @@ export function run() { } } -TEST_CASE("Historical state") +namespace { class CountingStore : public ccf::kv::Store { @@ -1780,14 +1900,22 @@ TEST_CASE("Historical state") } }; + ccf::TxReceiptImplPtr make_test_receipt() + { + return std::make_shared( + std::vector{1, 2, 3}, + ccf::CoseSignatureMap{}, + ccf::HistoryTree::Hash{}, + nullptr, + ccf::NodeId("test-node"), + std::nullopt); + } +} + +TEST_CASE("Historical state") +{ auto store = std::make_shared(); - auto receipt = std::make_shared( - std::vector{1, 2, 3}, - std::nullopt, - ccf::HistoryTree::Hash{}, - nullptr, - ccf::NodeId("test-node"), - std::nullopt); + auto receipt = make_test_receipt(); auto state = std::make_shared(store, receipt, ccf::TxID{1, 1}); std::weak_ptr original_state = state; @@ -1837,6 +1965,559 @@ TEST_CASE("Historical state") REQUIRE(original_state.expired()); } +TEST_CASE("Historical handles are scoped to their extension") +{ + ccf::js::core::Context ctx(TxAccess::APP_RO); + auto receipt = make_test_receipt(); + + auto run_request = [&](ccf::SeqNo seqno, const auto& during_request) { + auto store = std::make_shared(); + std::weak_ptr weak_store = store; + auto state = std::make_shared( + store, receipt, ccf::TxID{1, seqno}); + std::weak_ptr weak_state = state; + + auto extension = + std::make_shared(nullptr); + ctx.add_extension(extension); + + auto js_state = extension->create_historical_state_object(ctx, state); + REQUIRE_FALSE(js_state.is_exception()); + auto map = js_state["kv"]["public:records"]; + REQUIRE_FALSE(map.is_exception()); + REQUIRE(ctx.to_str(map["size"]) == "0"); + REQUIRE(store->tx_creations == 1); + + during_request(); + + // End of request: the extension goes away, the interpreter and JS values + // remain + REQUIRE(ctx.remove_extension(extension)); + extension.reset(); + state.reset(); + store.reset(); + REQUIRE(weak_state.expired()); + REQUIRE(weak_store.expired()); + + return map; + }; + + auto expect_unavailable = [&](const ccf::js::core::JSWrappedValue& map) { + auto size = map["size"]; + REQUIRE(size.is_exception()); + auto [reason, trace] = ctx.error_message(); + REQUIRE(reason.find("Unable to access MapHandle") != std::string::npos); + }; + + auto stale_map = run_request(1, [] {}); + + { + INFO("A handle retained after its request completed fails gracefully"); + expect_unavailable(stale_map); + } + + run_request(2, [&] { + INFO("Handles from earlier requests are not visible to later ones"); + expect_unavailable(stale_map); + }); +} + +TEST_CASE("JS registry does not share historical state between interpreters") +{ + ccf::AbstractNodeContext context; + context.install_subsystem( + std::make_shared()); + auto interpreter_cache = std::make_shared(1); + context.install_subsystem( + interpreter_cache); + + [[maybe_unused]] ccf::js::BaseDynamicJSEndpointRegistry registry(context); + + for (const auto access : {TxAccess::APP_RO, TxAccess::APP_RW}) + { + auto interpreter = + interpreter_cache->get_interpreter(access, std::nullopt, 0); + REQUIRE(interpreter != nullptr); + REQUIRE( + interpreter->get_extension() != + nullptr); + REQUIRE( + interpreter->get_extension() == + nullptr); + } +} + +TEST_CASE("Historical response conversion preserves request isolation") +{ + class StateCache : public ccf::StubNodeStateCache + { + public: + std::weak_ptr latest_state; + + std::vector get_state_range( + ccf::historical::RequestHandle, + ccf::SeqNo, + ccf::SeqNo, + ccf::historical::ExpiryDuration) override + { + auto state = std::make_shared( + std::make_shared(), + make_test_receipt(), + ccf::TxID{1, 1}); + latest_state = state; + return {state}; + } + }; + + ccf::AbstractNodeContext context; + auto state_cache = std::make_shared(); + context.install_subsystem(state_cache); + auto interpreter_cache = std::make_shared(1); + context.install_subsystem( + interpreter_cache); + ccf::js::BaseDynamicJSEndpointRegistry registry(context, "public:test"); + registry.set_js_kv_namespace_restriction( + [](const std::string& map_name, std::string& explanation) { + explanation = "Restricted test table"; + return map_name == "public:restricted" ? KVAccessPermissions::ILLEGAL : + KVAccessPermissions::READ_WRITE; + }); + + ccf::kv::Store store; + store.set_encryptor(std::make_shared()); + { + auto tx = store.create_tx(); + tx.rw("public:test.modules")->put("/response.js", R"( +export function run(request) { + const live = ccf.kv["public:records"]; + if (live.size !== 0) { + throw new Error("Unexpected live state"); + } + const state = ccf.historical.getStateRange(1, 1, 1, 1)[0]; + if (request.query === "handler_throw") { + throw new Error("Handler failure"); + } + return { + body: { + toJSON() { + if (request.query === "restricted") { + return state.kv["public:restricted"].size; + } + if (request.query === "live") { + return live.size; + } + if (request.query === "throw") { + throw new Error("Response failure"); + } + return state.kv["public:records"].size; + } + } + }; +} +)"); + REQUIRE(tx.commit() == ccf::kv::CommitResult::SUCCESS); + } + + auto endpoint = std::make_shared(); + endpoint->properties.js_module = "/response.js"; + endpoint->properties.js_function = "run"; + endpoint->properties.mode = ccf::endpoints::Mode::ReadWrite; + endpoint->properties.interpreter_reuse = + ccf::endpoints::InterpreterReusePolicy{.key = "historical"}; + + for (const std::string query : + {"ok", "restricted", "live", "throw", "handler_throw", "ok"}) + { + INFO(query); + auto rpc_ctx = std::make_shared( + std::make_shared( + ccf::InvalidSessionId, std::vector{}), + ccf::HttpVersion::HTTP1, + HTTP_GET, + "/response?" + query, + ccf::http::HeaderMap{}, + std::vector{}); + auto tx = store.create_tx(); + ccf::endpoints::EndpointContext endpoint_ctx(rpc_ctx, tx); + registry.execute_endpoint(endpoint, endpoint_ctx); + REQUIRE( + rpc_ctx->get_response_status() == + (query == "ok" ? HTTP_STATUS_OK : HTTP_STATUS_INTERNAL_SERVER_ERROR)); + if (query == "ok") + { + REQUIRE(nlohmann::json::parse(rpc_ctx->get_response_body()) == 0); + } + REQUIRE(state_cache->latest_state.expired()); + auto interpreter = interpreter_cache->get_interpreter( + TxAccess::APP_RW, endpoint->properties.interpreter_reuse, 0); + REQUIRE( + interpreter->get_extension() == + nullptr); + REQUIRE( + interpreter->get_extension() == + nullptr); + } +} + +TEST_CASE("ScopedCleanse scrubs secret bytes on scope exit") +{ + SUBCASE("std::string") + { + std::string secret(32, 'A'); + { + ccf::js::ScopedCleanse guard(secret); + REQUIRE(secret == std::string(32, 'A')); + } + // The guard destructor has zeroed the string's bytes in place. The + // std::string object itself is still alive here so its buffer can be + // safely inspected. + for (char c : secret) + { + CHECK(c == '\0'); + } + } + + SUBCASE("std::vector") + { + std::vector secret(32, 0xAB); + { + ccf::js::ScopedCleanse> guard(secret); + REQUIRE(secret == std::vector(32, 0xAB)); + } + for (auto b : secret) + { + CHECK(b == 0); + } + } + + SUBCASE("ccf::crypto::Pem") + { + const std::string pem_text = + "-----BEGIN FAKE-----\nabcdefghij\n-----END FAKE-----\n"; + ccf::crypto::Pem pem(pem_text); + REQUIRE(pem.str() == pem_text); + { + ccf::js::ScopedCleanse guard(pem); + } + for (size_t i = 0; i < pem.size(); ++i) + { + CHECK(pem.data()[i] == 0); + } + } + + SUBCASE("Scrubs on exception unwind") + { + std::string secret(16, 'S'); + try + { + ccf::js::ScopedCleanse guard(secret); + throw std::runtime_error("boom"); + } + catch (const std::runtime_error&) + {} + for (char c : secret) + { + CHECK(c == '\0'); + } + } + + SUBCASE("Empty target is a no-op") + { + std::string empty; + ccf::js::ScopedCleanse guard(empty); + CHECK(empty.empty()); + } + + SUBCASE("Private JWK fields") + { + ccf::crypto::JsonWebKeyECPrivate ec; + ccf::crypto::JsonWebKeyEdDSAPrivate eddsa; + ccf::crypto::JsonWebKeyRSAPrivate rsa; + ec.d = eddsa.d = "short secret"; + ec.x = eddsa.x = "public"; + rsa.n = "public"; + for (auto* field : {&rsa.d, &rsa.p, &rsa.q, &rsa.dp, &rsa.dq, &rsa.qi}) + { + *field = std::string(64, 'S'); + } + { + ccf::js::ScopedCleanse ec_guard(ec); + ccf::js::ScopedCleanse eddsa_guard(eddsa); + ccf::js::ScopedCleanse rsa_guard(rsa); + } + CHECK(ec.d == std::string(12, '\0')); + CHECK(eddsa.d == std::string(12, '\0')); + CHECK(ec.x == "public"); + CHECK(eddsa.x == "public"); + CHECK(rsa.n == "public"); + for (const auto* field : + {&rsa.d, &rsa.p, &rsa.q, &rsa.dp, &rsa.dq, &rsa.qi}) + { + CHECK(*field == std::string(64, '\0')); + } + } + + SUBCASE("Partially deserialised JWK on exception unwind") + { + nlohmann::json json = { + {"kty", "RSA"}, + {"n", "public"}, + {"e", "AQAB"}, + {"d", "secret"}, + {"p", "secret"}}; + ccf::crypto::JsonWebKeyRSAPrivate jwk; + const auto parse = [&]() { + ccf::js::ScopedCleanse json_guard(json); + ccf::js::ScopedCleanse jwk_guard(jwk); + json.get_to(jwk); + }; + CHECK_THROWS_AS(parse(), std::exception); + CHECK(jwk.d == std::string(6, '\0')); + CHECK(jwk.p == std::string(6, '\0')); + CHECK(jwk.q.empty()); + CHECK(json["d"] == std::string(6, '\0')); + CHECK(json["p"] == std::string(6, '\0')); + } + + SUBCASE("JSON strings in objects and arrays on early return") + { + nlohmann::json json = { + {"d", "secret"}, + {"nested", nlohmann::json::array({"another", 42, nullptr, true})}}; + const auto leave_scope = [&]() { + ccf::js::ScopedCleanse guard(json); + return; + }; + leave_scope(); + CHECK(json["d"] == std::string(6, '\0')); + CHECK(json["nested"][0] == std::string(7, '\0')); + CHECK(json["nested"][1] == 42); + CHECK(json["nested"][2].is_null()); + CHECK(json["nested"][3] == true); + } + + SUBCASE("JSON roots on exception unwind") + { + const auto check_unwind = + [](nlohmann::json value, const nlohmann::json& expected) { + const auto fail = [&]() { + ccf::js::ScopedCleanse guard(value); + throw std::runtime_error("boom"); + }; + CHECK_THROWS_AS(fail(), std::runtime_error); + CHECK(value.type() == expected.type()); + CHECK(value == expected); + }; + check_unwind("secret", std::string(6, '\0')); + check_unwind( + nlohmann::json::array({"secret", {{"d", "nested"}}}), + nlohmann::json::array( + {std::string(6, '\0'), {{"d", std::string(6, '\0')}}})); + for (const auto& value : + {nlohmann::json(), + nlohmann::json(true), + nlohmann::json(42), + nlohmann::json(42u), + nlohmann::json(3.5), + nlohmann::json(""), + nlohmann::json::array(), + nlohmann::json::object(), + nlohmann::json::binary({1, 2, 3})}) + { + check_unwind(value, value); + } + } +} + +namespace +{ + // Helper: run a JS snippet through a ccf.crypto-equipped context and assert + // it returned without throwing. The snippet must define and return from a + // handler() function. + void run_crypto_handler(const std::string& body) + { + ccf::js::CommonContext ctx(TxAccess::APP_RW); + JS_UpdateStackTop(ctx.runtime()); + const auto module = + fmt::format("export function handler() {{\n{}\n}}", body); + auto handler = + ctx.get_exported_function(module, "handler", "/test/crypto.js"); + const auto result = ctx.call_with_rt_options( + handler, {}, std::nullopt, ccf::js::core::RuntimeLimitsPolicy::NONE); + if (result.is_exception()) + { + const auto [reason, trace] = ctx.error_message(); + FAIL("JS threw: ", reason); + } + } +} + +TEST_CASE("ccf.crypto private-key bindings still succeed after scrubbing") +{ + // These regression tests exercise the binding paths whose C++-side private + // key copies are now guarded by ScopedCleanse. Direct assertion that + // freed memory was scrubbed would be undefined behaviour; the unit tests + // above cover the guard's scrubbing semantics. + + SUBCASE("generateRsaKeyPair") + { + run_crypto_handler(R"JS( + const kp = ccf.crypto.generateRsaKeyPair(2048); + if (typeof kp.privateKey !== "string" || kp.privateKey.length === 0) + throw new Error("bad privateKey"); + if (typeof kp.publicKey !== "string" || kp.publicKey.length === 0) + throw new Error("bad publicKey"); + )JS"); + } + + SUBCASE("generateEcdsaKeyPair") + { + run_crypto_handler(R"JS( + const kp = ccf.crypto.generateEcdsaKeyPair("secp256r1"); + if (!kp.privateKey.includes("PRIVATE KEY")) + throw new Error("bad privateKey"); + if (!kp.publicKey.includes("PUBLIC KEY")) + throw new Error("bad publicKey"); + )JS"); + } + + SUBCASE("generateEddsaKeyPair") + { + run_crypto_handler(R"JS( + const kp = ccf.crypto.generateEddsaKeyPair("curve25519"); + if (!kp.privateKey.includes("PRIVATE KEY")) + throw new Error("bad privateKey"); + if (!kp.publicKey.includes("PUBLIC KEY")) + throw new Error("bad publicKey"); + )JS"); + } + + SUBCASE("Private and public JWK conversions preserve caller-owned values") + { + run_crypto_handler(R"JS( + const cases = [ + [ccf.crypto.generateEcdsaKeyPair("secp256r1"), + ccf.crypto.pemToJwk, ccf.crypto.jwkToPem, + ccf.crypto.pubPemToJwk, ccf.crypto.pubJwkToPem], + [ccf.crypto.generateRsaKeyPair(2048), + ccf.crypto.rsaPemToJwk, ccf.crypto.rsaJwkToPem, + ccf.crypto.pubRsaPemToJwk, ccf.crypto.pubRsaJwkToPem], + [ccf.crypto.generateEddsaKeyPair("curve25519"), + ccf.crypto.eddsaPemToJwk, ccf.crypto.eddsaJwkToPem, + ccf.crypto.pubEddsaPemToJwk, ccf.crypto.pubEddsaJwkToPem], + ]; + for (const [kp, toJwk, toPem, pubToJwk, pubToPem] of cases) { + for (const [pem, encode, decode] of [ + [kp.privateKey, toJwk, toPem], + [kp.publicKey, pubToJwk, pubToPem], + ]) { + const jwk = encode(pem, "test-key"); + const original = JSON.stringify(jwk); + const roundTrip = decode(jwk); + if (JSON.stringify(encode(roundTrip, "test-key")) !== original) + throw new Error("key round trip changed JWK"); + if (JSON.stringify(jwk) !== original || + JSON.stringify(encode(pem, "test-key")) !== original) + throw new Error("caller-owned key was scrubbed"); + } + } + )JS"); + } + + SUBCASE("sign with ECDSA") + { + run_crypto_handler(R"JS( + const kp = ccf.crypto.generateEcdsaKeyPair("secp256r1"); + const data = ccf.strToBuf("hello"); + const sig = ccf.crypto.sign( + {name: "ECDSA", hash: "SHA-256"}, kp.privateKey, data); + if (!(sig instanceof ArrayBuffer) || sig.byteLength === 0) + throw new Error("bad signature"); + if (!ccf.crypto.verifySignature( + {name: "ECDSA", hash: "SHA-256"}, kp.publicKey, sig, data)) + throw new Error("signature did not verify"); + )JS"); + } + + SUBCASE("sign with EdDSA") + { + run_crypto_handler(R"JS( + const kp = ccf.crypto.generateEddsaKeyPair("curve25519"); + const data = ccf.strToBuf("hello"); + const sig = ccf.crypto.sign( + {name: "EdDSA"}, kp.privateKey, data); + if (!(sig instanceof ArrayBuffer) || sig.byteLength === 0) + throw new Error("bad signature"); + if (!ccf.crypto.verifySignature( + {name: "EdDSA"}, kp.publicKey, sig, data)) + throw new Error("signature did not verify"); + )JS"); + } + + SUBCASE("sign with RSA-PSS") + { + run_crypto_handler(R"JS( + const kp = ccf.crypto.generateRsaKeyPair(2048); + const data = ccf.strToBuf("hello"); + const algorithm = {name: "RSA-PSS", hash: "SHA-256", saltLength: 32}; + const sig = ccf.crypto.sign(algorithm, kp.privateKey, data); + if (!ccf.crypto.verifySignature(algorithm, kp.publicKey, sig, data)) + throw new Error("signature did not verify"); + )JS"); + } + + SUBCASE("HMAC accepts a non-PEM key") + { + run_crypto_handler(R"JS( + const key = String.fromCharCode(11).repeat(20); + const sig = new Uint8Array(ccf.crypto.sign( + {name: "HMAC", hash: "SHA-256"}, key, ccf.strToBuf("Hi There"))); + const hex = Array.from(sig, b => b.toString(16).padStart(2, "0")).join(""); + if (hex !== "b0344c61d8db38535ca8afceaf0bf12b881dc200c9833da726e9376c2e32cff7") + throw new Error("bad HMAC"); + if (key !== String.fromCharCode(11).repeat(20)) + throw new Error("caller-owned HMAC key was scrubbed"); + )JS"); + } + + SUBCASE("Malformed PEMs and JWKs still throw") + { + run_crypto_handler(R"JS( + function expectError(fn) { + let threw = false; + try { fn(); } catch (e) { threw = true; } + if (!threw) throw new Error("malformed key was accepted"); + } + for (const pem of ["not a PEM", "-----BEGIN PRIVATE KEY-----\ninvalid"]) { + for (const toJwk of [ + ccf.crypto.pemToJwk, ccf.crypto.rsaPemToJwk, ccf.crypto.eddsaPemToJwk, + ]) { + expectError(() => toJwk(pem)); + } + for (const name of ["ECDSA", "EdDSA", "RSA-PSS"]) { + expectError(() => ccf.crypto.sign( + {name, hash: "SHA-256", saltLength: 32}, pem, ccf.strToBuf("hello"))); + } + } + const jwks = [ + [ccf.crypto.pemToJwk(ccf.crypto.generateEcdsaKeyPair("secp256r1").privateKey), + ccf.crypto.jwkToPem, "d"], + [ccf.crypto.rsaPemToJwk(ccf.crypto.generateRsaKeyPair(2048).privateKey), + ccf.crypto.rsaJwkToPem, "qi"], + [ccf.crypto.eddsaPemToJwk(ccf.crypto.generateEddsaKeyPair("curve25519").privateKey), + ccf.crypto.eddsaJwkToPem, "d"], + ]; + for (const [jwk, toPem, field] of jwks) { + jwk[field] = {}; + const original = JSON.stringify(jwk); + expectError(() => toPem(jwk)); + if (JSON.stringify(jwk) !== original) + throw new Error("caller-owned invalid JWK was scrubbed"); + } + )JS"); + } +} + int main(int argc, char** argv) { ccf::js::register_class_ids(); diff --git a/src/node/historical_queries.h b/src/node/historical_queries.h index 31423e8f4ca1..982041465a93 100644 --- a/src/node/historical_queries.h +++ b/src/node/historical_queries.h @@ -74,12 +74,31 @@ namespace ccf::historical return signatures->get(); } - static std::optional get_cose_signature( + static ccf::CoseSignatureMap get_cose_signatures( const ccf::kv::StorePtr& sig_store) { auto tx = sig_store->create_read_only_tx(); auto* signatures = tx.ro(ccf::Tables::COSE_SIGNATURES); - return signatures->get(); + ccf::CoseSignatureMap cose_signatures; + signatures->foreach( + [&cose_signatures](const auto& identity_type, const auto& signature) { + cose_signatures.emplace(identity_type, signature); + return true; + }); + return cose_signatures; + } + + // This historical API exposes a single COSE signature, so receipts are + // described by the CLASSICAL one. + static std::optional select_described_cose_signature( + const ccf::CoseSignatureMap& cose_signatures) + { + const auto signature = cose_signatures.find(ccf::IdentityType::CLASSICAL); + if (signature == cose_signatures.end()) + { + return std::nullopt; + } + return signature->second; } static std::optional> get_tree( @@ -497,8 +516,10 @@ namespace ccf::historical // Iterate through earlier indices. If this signature covers them // then create a receipt for them const auto sig = get_signature(sig_details->store); - const auto cose_sig = get_cose_signature(sig_details->store); - if (!sig.has_value() && !cose_sig.has_value()) + const auto cose_sigs = get_cose_signatures(sig_details->store); + const auto described_cose_sig = + select_described_cose_signature(cose_sigs); + if (!sig.has_value() && !described_cose_sig.has_value()) { return false; } @@ -533,7 +554,7 @@ namespace ccf::historical details->transaction_id = {sig->view, seqno}; details->receipt = std::make_shared( sig->sig, - cose_sig, + cose_sigs, proof.get_root(), proof.get_path(), sig->node, @@ -544,8 +565,8 @@ namespace ccf::historical } else { - auto cose_receipt = - ccf::cose::decode_ccf_receipt(cose_sig.value(), false); + auto cose_receipt = ccf::cose::decode_ccf_receipt( + described_cose_sig.value(), false); auto parsed_txid = ccf::TxID::from_str(cose_receipt.phdr.ccf.txid); if (!parsed_txid.has_value()) @@ -557,7 +578,7 @@ namespace ccf::historical details->transaction_id = {parsed_txid->view, seqno}; details->receipt = std::make_shared( std::nullopt, - cose_sig, + cose_sigs, proof.get_root(), proof.get_path(), ccf::NodeId{}, @@ -842,17 +863,19 @@ namespace ccf::historical // the receipt _later_ for an already-fetched signature // transaction. const auto sig = get_signature(details->store); - const auto cose_sig = get_cose_signature(details->store); + const auto cose_sigs = get_cose_signatures(details->store); + const auto described_cose_sig = + select_described_cose_signature(cose_sigs); if (sig.has_value()) { details->transaction_id = {sig->view, sig->seqno}; details->receipt = std::make_shared( - sig->sig, cose_sig, sig->root.h, nullptr, sig->node, sig->cert); + sig->sig, cose_sigs, sig->root.h, nullptr, sig->node, sig->cert); } - else if (cose_sig.has_value()) + else if (described_cose_sig.has_value()) { auto as_receipt = - ccf::cose::decode_ccf_receipt(cose_sig.value(), false); + ccf::cose::decode_ccf_receipt(described_cose_sig.value(), false); const auto& txid = as_receipt.phdr.ccf.txid; auto parsed_txid = ccf::TxID::from_str(txid); @@ -864,7 +887,7 @@ namespace ccf::historical details->transaction_id = parsed_txid.value(); details->receipt = std::make_shared( std::nullopt, - cose_sig, + cose_sigs, std::nullopt, nullptr, ccf::NodeId{}, diff --git a/src/node/historical_queries_adapter.cpp b/src/node/historical_queries_adapter.cpp index 9f9d41b29956..6962808ebe9f 100644 --- a/src/node/historical_queries_adapter.cpp +++ b/src/node/historical_queries_adapter.cpp @@ -269,7 +269,14 @@ namespace ccf std::optional describe_cose_signature_v1( const TxReceiptImpl& receipt) { - return receipt.cose_signature; + // This API exposes a single signature, so it returns the CLASSICAL one. + const auto signature = + receipt.cose_signatures.find(IdentityType::CLASSICAL); + if (signature == receipt.cose_signatures.end()) + { + return std::nullopt; + } + return signature->second; } std::optional describe_cose_receipt_v1( diff --git a/src/node/history.h b/src/node/history.h index e20ccef9069e..cc98472602eb 100644 --- a/src/node/history.h +++ b/src/node/history.h @@ -107,7 +107,7 @@ namespace ccf ccf::Tables::SERIALISED_MERKLE_TREE); PrimarySignature sig_value(id, txid.seqno); signatures->put(sig_value); - cose_signatures->put(ccf::CoseSignature{}); + cose_signatures->put(ccf::IdentityType::CLASSICAL, ccf::CoseSignature{}); serialised_tree->put({}); return sig.commit_reserved(); } @@ -424,7 +424,7 @@ namespace ccf } std::vector cose_sign(cose_buf.to_vector()); - cose_signatures->put(cose_sign); + cose_signatures->put(ccf::IdentityType::CLASSICAL, cose_sign); auto* serialised_tree = sig.template wo( ccf::Tables::SERIALISED_MERKLE_TREE); @@ -807,9 +807,10 @@ namespace ccf // verifying. auto* cose_signatures = tx.template ro(ccf::Tables::COSE_SIGNATURES); - auto cose_sig = cose_signatures->get(); + auto cose_sig = cose_signatures->get(ccf::IdentityType::CLASSICAL); const auto cose_sig_version = - cose_signatures->get_version_of_previous_write(); + cose_signatures->get_version_of_previous_write( + ccf::IdentityType::CLASSICAL); if ( cose_sig.has_value() && cose_sig_version.has_value() && cose_sig_version.value() == version) diff --git a/src/node/node_state.h b/src/node/node_state.h index c60aa82d8a79..f146543e30b3 100644 --- a/src/node/node_state.h +++ b/src/node/node_state.h @@ -112,7 +112,8 @@ namespace ccf best_view = ls->view; } - auto lcs = tx.ro(Tables::COSE_SIGNATURES)->get(); + auto lcs = tx.ro(Tables::COSE_SIGNATURES) + ->get(ccf::IdentityType::CLASSICAL); if (lcs.has_value()) { auto receipt = cose::decode_ccf_receipt(lcs.value(), false); @@ -881,10 +882,14 @@ namespace ccf } auto snp_attestation = - AttestationProvider::get_snp_attestation(quote_info); + AttestationProvider::get_snp_attestation_report(quote_info); if (snp_attestation.has_value()) { - snp_tcb_version = snp_attestation.value().reported_tcb; + const uint8_t* data = nullptr; + size_t size = 0; + tav_snp_attestation_report_reported_tcb( + snp_attestation.value().get(), &data, &size); + snp_tcb_version = ccf::pal::snp::TcbVersionRaw({data, size}); } // Verify that the security policy matches the quoted digest of the policy @@ -1032,19 +1037,18 @@ namespace ccf // Check that tcbm in endorsement matches reported TCB in our // retrieved attestation - const auto* quote = - reinterpret_cast( - quote_info.quote.data()); - const auto reported_tcb = quote->reported_tcb; - - // tcbm is a single hex value, like DB18000000000004. To match - // that with a TcbVersion, reverse the bytes. - const auto* tcb_begin = - reinterpret_cast(&reported_tcb); - const std::span tcb_bytes{ - tcb_begin, tcb_begin + sizeof(reported_tcb)}; - auto tcb_as_hex = fmt::format( - "{:02x}", fmt::join(tcb_bytes.rbegin(), tcb_bytes.rend(), "")); + const auto report = + ccf::pal::snp::parse_attestation_report_unverified( + quote_info.quote); + const uint8_t* data = nullptr; + size_t size = 0; + tav_snp_attestation_report_reported_tcb( + report.get(), &data, &size); + const auto reported_tcb = + ccf::pal::snp::TcbVersionRaw({data, size}); + + // tcbm is a single hex value, like DB18000000000004. + auto tcb_as_hex = reported_tcb.to_hex(); ccf::nonstd::to_upper(tcb_as_hex); if (tcb_as_hex == aci_endorsements.tcbm) @@ -2036,7 +2040,8 @@ namespace ccf } ccf::COSESignaturesConfig cs_cfg{}; - auto lcs = tx.ro(network.cose_signatures)->get(); + auto lcs = + tx.ro(network.cose_signatures)->get(ccf::IdentityType::CLASSICAL); if (lcs.has_value()) { CoseSignature cs = lcs.value(); @@ -3630,8 +3635,9 @@ namespace ccf [s = this->snapshotter]( ccf::kv::Version version, const CoseSignatures::Write& w) -> ccf::kv::ConsensusHookPtr { - assert(w.has_value()); - s->record_cose_signature(version, w.value()); + const auto cose_signatures = extract_cose_signatures(w); + assert(!cose_signatures.empty()); + s->record_cose_signatures(version, cose_signatures); return {nullptr}; })); diff --git a/src/node/quote.cpp b/src/node/quote.cpp index 03ef0db0b30e..9606d60571b9 100644 --- a/src/node/quote.cpp +++ b/src/node/quote.cpp @@ -22,6 +22,7 @@ #include "node/js_policy.h" #include "node/uvm_endorsements.h" +#include #include namespace ccf @@ -153,8 +154,8 @@ namespace ccf return measurement; } - std::optional AttestationProvider::get_snp_attestation( - const QuoteInfo& quote_info) + std::optional AttestationProvider:: + get_snp_attestation_report(const QuoteInfo& quote_info) { if (quote_info.format != QuoteFormat::amd_sev_snp_v1) { @@ -164,10 +165,7 @@ namespace ccf { pal::PlatformAttestationMeasurement d = {}; pal::PlatformAttestationReportData r = {}; - pal::verify_quote(quote_info, d, r); - auto attestation = *reinterpret_cast( - quote_info.quote.data()); - return attestation; + return pal::verify_snp_attestation_report_and_get(quote_info, d, r); } catch (const std::exception& e) { @@ -176,6 +174,33 @@ namespace ccf } } +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wdeprecated-declarations" + std::optional AttestationProvider::get_snp_attestation( + const QuoteInfo& quote_info) + { + auto report = get_snp_attestation_report(quote_info); + if (!report.has_value()) + { + return std::nullopt; + } + + if (quote_info.quote.size() != sizeof(pal::snp::Attestation)) + { + LOG_FAIL_FMT( + "Verified SNP report has unexpected size {} (expected {})", + quote_info.quote.size(), + sizeof(pal::snp::Attestation)); + return std::nullopt; + } + + pal::snp::Attestation legacy_report = {}; + std::memcpy( + &legacy_report, quote_info.quote.data(), sizeof(pal::snp::Attestation)); + return legacy_report; + } +#pragma GCC diagnostic pop + std::optional AttestationProvider::get_host_data( const QuoteInfo& quote_info) { @@ -205,13 +230,13 @@ namespace ccf pal::PlatformAttestationReportData r = {}; try { - pal::verify_quote(quote_info, d, r); - auto quote = *reinterpret_cast( - quote_info.quote.data()); - std::copy( - std::begin(quote.host_data), - std::end(quote.host_data), - rep.begin()); + const auto report = + pal::verify_snp_attestation_report_and_get(quote_info, d, r); + const uint8_t* data = nullptr; + size_t size = 0; + tav_snp_attestation_report_host_data(report.get(), &data, &size); + const auto host_data = std::span{data, size}; + std::copy(host_data.begin(), host_data.end(), rep.begin()); } catch (const std::exception& e) { @@ -279,9 +304,8 @@ namespace ccf pal::PlatformAttestationMeasurement d = {}; pal::PlatformAttestationReportData r = {}; - pal::verify_quote(quote_info, d, r); auto attestation = - *reinterpret_cast(quote_info.quote.data()); + pal::verify_snp_attestation_report_and_get(quote_info, d, r); std::optional min_tcb_opt = std::nullopt; auto* h = tx.ro(Tables::SNP_TCB_VERSIONS); @@ -290,9 +314,12 @@ namespace ccf const std::string& cpuid_hex, const pal::snp::TcbVersionPolicy& v) { auto cpuid = pal::snp::cpuid_from_hex(cpuid_hex); if ( - cpuid.get_family_id() == attestation.cpuid_fam_id && - cpuid.get_model_id() == attestation.cpuid_mod_id && - cpuid.stepping == attestation.cpuid_step) + cpuid.get_family_id() == + tav_snp_attestation_report_cpuid_fam_id(attestation.get()) && + cpuid.get_model_id() == + tav_snp_attestation_report_cpuid_mod_id(attestation.get()) && + cpuid.stepping == + tav_snp_attestation_report_cpuid_step(attestation.get())) { min_tcb_opt = v; return false; @@ -307,9 +334,13 @@ namespace ccf // CPUID of the attested cpu must now be equal to the min_tcb_opt's cpuid auto product_family = pal::snp::get_sev_snp_product( - attestation.cpuid_fam_id, attestation.cpuid_mod_id); + tav_snp_attestation_report_cpuid_fam_id(attestation.get()), + tav_snp_attestation_report_cpuid_mod_id(attestation.get())); + const uint8_t* data = nullptr; + size_t size = 0; + tav_snp_attestation_report_reported_tcb(attestation.get(), &data, &size); auto attestation_tcb_policy = - attestation.reported_tcb.to_policy(product_family); + pal::snp::TcbVersionRaw({data, size}).to_policy(product_family); if (pal::snp::TcbVersionPolicy::is_valid( min_tcb_opt.value(), attestation_tcb_policy)) diff --git a/src/node/rpc/node_frontend.h b/src/node/rpc/node_frontend.h index ea1c6e5d3089..0945cd932e7c 100644 --- a/src/node/rpc/node_frontend.h +++ b/src/node/rpc/node_frontend.h @@ -449,7 +449,7 @@ namespace ccf openapi_info.description = "This API provides public, uncredentialed access to service and node " "state."; - openapi_info.document_version = "5.0.7"; + openapi_info.document_version = "5.0.8"; } // NOLINTNEXTLINE(readability-function-cognitive-complexity) @@ -717,7 +717,7 @@ namespace ccf ccf::kv::Version cose_seqno = 0; auto cose_signatures = args.tx.template ro(Tables::COSE_SIGNATURES); - auto cose_sig = cose_signatures->get(); + auto cose_sig = cose_signatures->get(ccf::IdentityType::CLASSICAL); if (cose_sig.has_value() && !cose_sig->empty()) { auto receipt = ccf::cose::decode_ccf_receipt(cose_sig.value(), false); @@ -1662,7 +1662,8 @@ namespace ccf ctx.tx, in.snp_uvm_endorsements, recovering); auto attestation = - AttestationProvider::get_snp_attestation(in.quote_info).value(); + AttestationProvider::get_snp_attestation_report(in.quote_info) + .value(); InternalTablesAccess::trust_node_snp_tcb_version( ctx.tx, attestation); break; diff --git a/src/node/rpc/test/frontend_test.cpp b/src/node/rpc/test/frontend_test.cpp index cc8f78ed9205..5e2c79b18df1 100644 --- a/src/node/rpc/test/frontend_test.cpp +++ b/src/node/rpc/test/frontend_test.cpp @@ -1245,6 +1245,48 @@ TEST_CASE("Decoded Templated paths") } } +TEST_CASE("Forwarded request target limit" * doctest::test_suite("forwarding")) +{ + constexpr size_t forwarding_limit = 100 * 1024 * 1024; + auto target_size = forwarding_limit; + SUBCASE("At the forwarding limit") {} + SUBCASE("Above the forwarding limit") + { + target_size += 1; + } + const std::string prefix = "/app/empty_function?padding="; + const auto target = prefix + std::string(target_size - prefix.size(), 'a'); + const auto packed = ::http::Request(target, HTTP_POST).build_request(); + + ccf::http::ParserConfiguration config; + config.max_request_target_size = "101MB"; + { + ::http::SimpleRequestProcessor processor; + ::http::RequestParser ingress(processor, config); + ingress.execute(packed.data(), packed.size()); + REQUIRE(processor.received.size() == 1); + CHECK(processor.received.front().url == target); + } + + if (target_size > forwarding_limit) + { + CHECK_THROWS_AS( + ccf::make_fwd_rpc_context(user_session, packed, ccf::FrameFormat::http), + ::http::RequestTargetTooLongException); + } + else + { + auto forwarded = + ccf::make_fwd_rpc_context(user_session, packed, ccf::FrameFormat::http); + REQUIRE(forwarded != nullptr); + CHECK(forwarded->get_request_path() == "/app/empty_function"); + CHECK( + forwarded->get_request_query() == + std::string_view(target).substr(target.find('?') + 1)); + CHECK(forwarded->get_serialised_request() == packed); + } +} + TEST_CASE("Forwarding" * doctest::test_suite("forwarding")) { NetworkState network_primary; @@ -1477,8 +1519,18 @@ TEST_CASE("Userfrontend forwarding" * doctest::test_suite("forwarding")) publish_frontend_state(user_frontend_backup, network_backup); auto write_req = create_simple_request(); + write_req.set_query_param( + "padding", + std::string(ccf::http::default_max_request_target_size.count_bytes(), 'a')); auto serialized_call = write_req.build_request(); + ccf::http::ParserConfiguration ingress_config; + ingress_config.max_request_target_size = "32KB"; + ::http::SimpleRequestProcessor ingress_processor; + ::http::RequestParser ingress_parser(ingress_processor, ingress_config); + ingress_parser.execute(serialized_call.data(), serialized_call.size()); + REQUIRE(ingress_processor.received.size() == 1); + auto ctx = ccf::make_rpc_context(user_session, serialized_call); user_frontend_backup.process(ctx); REQUIRE(ctx->response_is_pending); @@ -1490,6 +1542,7 @@ TEST_CASE("Userfrontend forwarding" * doctest::test_suite("forwarding")) ccf::kv::test::FirstBackupNodeId, forwarded_msg.data(), forwarded_msg.size()); + REQUIRE(fwd_ctx != nullptr); user_frontend_primary.process_forwarded(fwd_ctx); auto response = parse_response(fwd_ctx->serialise_response()); diff --git a/src/node/rpc/test/internal_tables_access_test.cpp b/src/node/rpc/test/internal_tables_access_test.cpp index 42b90fed27e5..486e0e22e00c 100644 --- a/src/node/rpc/test/internal_tables_access_test.cpp +++ b/src/node/rpc/test/internal_tables_access_test.cpp @@ -89,6 +89,17 @@ TEST_CASE("direct node deletion updates consensus configuration") REQUIRE(consensus.configuration_changes == 1); } +TEST_CASE("trust_node_snp_tcb_version rejects an empty owner") +{ + ccf::kv::Store kv_store; + auto tx = kv_store.create_tx(); + const pal::snp::AttestationReport report; + CHECK_THROWS_WITH_AS( + InternalTablesAccess::trust_node_snp_tcb_version(tx, report), + "Cannot access an empty SNP attestation report", + std::logic_error); +} + TEST_CASE("trust_node_uvm_endorsements - not recovering, empty map") { ccf::kv::Store kv_store; diff --git a/src/node/session_metrics.h b/src/node/session_metrics.h index 632a37a06f3d..b70a0fc4c637 100644 --- a/src/node/session_metrics.h +++ b/src/node/session_metrics.h @@ -16,6 +16,7 @@ namespace ccf size_t parsing; size_t request_payload_too_large; size_t request_header_too_large; + size_t request_target_too_long; }; struct PerInterface @@ -37,7 +38,8 @@ namespace ccf SessionMetrics::Errors, parsing, request_payload_too_large, - request_header_too_large); + request_header_too_large, + request_target_too_long); DECLARE_JSON_TYPE(SessionMetrics::PerInterface); DECLARE_JSON_REQUIRED_FIELDS( diff --git a/src/node/signature_cache_interface.h b/src/node/signature_cache_interface.h index 5e3cf30f0067..b4b7679b87ba 100644 --- a/src/node/signature_cache_interface.h +++ b/src/node/signature_cache_interface.h @@ -14,7 +14,7 @@ namespace ccf struct CachedSignature { std::optional sig; - std::optional> cose_signature; + CoseSignatureMap cose_signatures; std::vector serialised_tree; ccf::SeqNo sig_seqno; }; diff --git a/src/node/signature_cache_subsystem.h b/src/node/signature_cache_subsystem.h index 370b59486e9f..d8505f2243ac 100644 --- a/src/node/signature_cache_subsystem.h +++ b/src/node/signature_cache_subsystem.h @@ -17,12 +17,12 @@ namespace ccf struct PendingEntry { std::optional sig = std::nullopt; - std::optional> cose_signature = std::nullopt; + CoseSignatureMap cose_signatures; std::optional> serialised_tree = std::nullopt; [[nodiscard]] bool is_complete() const { - return (sig.has_value() || cose_signature.has_value()) && + return (sig.has_value() || !cose_signatures.empty()) && serialised_tree.has_value(); } }; @@ -83,7 +83,7 @@ namespace ccf const auto& [version, entry] = *it; if ( - !(entry.sig.has_value() || entry.cose_signature.has_value()) || + (!entry.sig.has_value() && entry.cose_signatures.empty()) || !entry.serialised_tree.has_value()) { return std::nullopt; @@ -91,7 +91,7 @@ namespace ccf return CachedSignature{ entry.sig, - entry.cose_signature, + entry.cose_signatures, entry.serialised_tree.value(), version}; } @@ -104,12 +104,12 @@ namespace ccf entry.sig = sig; } - void on_cose_signature_committed( - ccf::kv::Version version, const std::vector& cose_sig) + void on_cose_signatures_committed( + ccf::kv::Version version, const CoseSignatureMap& cose_signatures) { ccf::ds::MutexGuard guard(cache_mutex); auto& entry = get_or_create_entry(version); - entry.cose_signature = cose_sig; + entry.cose_signatures = cose_signatures; } void on_tree_committed( @@ -136,9 +136,10 @@ namespace ccf Tables::COSE_SIGNATURES, CoseSignatures::wrap_commit_hook( [this](ccf::kv::Version version, const CoseSignatures::Write& w) { - if (w.has_value()) + const auto cose_signatures = extract_cose_signatures(w); + if (!cose_signatures.empty()) { - on_cose_signature_committed(version, w.value()); + on_cose_signatures_committed(version, cose_signatures); } })); diff --git a/src/node/snapshot_serdes.h b/src/node/snapshot_serdes.h index a06a6556c734..9a032e483c90 100644 --- a/src/node/snapshot_serdes.h +++ b/src/node/snapshot_serdes.h @@ -415,7 +415,7 @@ namespace ccf } static std::vector build_and_serialise_receipt( - const std::vector& cose_sig, + const CoseSignatureMap& cose_sigs, const std::vector& tree, ccf::kv::Version seqno, const ccf::crypto::Sha256Hash& write_set_digest, @@ -429,7 +429,7 @@ namespace ccf cd.set(std::move(claims_digest)); ccf::TxReceiptImpl tx_receipt( {}, - cose_sig, + cose_sigs, proof.get_root(), proof.get_path(), {}, diff --git a/src/node/snapshotter.h b/src/node/snapshotter.h index 55a0a7ba65b0..85957723a0b9 100644 --- a/src/node/snapshotter.h +++ b/src/node/snapshotter.h @@ -71,7 +71,7 @@ namespace ccf std::optional<::consensus::Index> evidence_idx = std::nullopt; - std::optional> cose_sig = std::nullopt; + std::optional cose_sigs = std::nullopt; std::optional> tree = std::nullopt; // Outputs of the serialise action, handed to the persist action. @@ -203,7 +203,7 @@ namespace ccf std::shared_ptr self; ccf::kv::Version version; ::consensus::Index evidence_idx; - std::vector cose_sig; + CoseSignatureMap cose_sigs; std::vector tree; std::shared_ptr serialised; @@ -213,13 +213,13 @@ namespace ccf std::shared_ptr _self, ccf::kv::Version _version, ::consensus::Index _evidence_idx, - std::vector _cose_sig, + CoseSignatureMap _cose_sigs, std::vector _tree, std::shared_ptr _serialised) : self(std::move(_self)), version(_version), evidence_idx(_evidence_idx), - cose_sig(std::move(_cose_sig)), + cose_sigs(std::move(_cose_sigs)), tree(std::move(_tree)), serialised(std::move(_serialised)), name(fmt::format("persist-snapshot@{}", version)) @@ -228,7 +228,7 @@ namespace ccf void do_action() override { self->persist_snapshot_( - version, evidence_idx, cose_sig, tree, serialised); + version, evidence_idx, cose_sigs, tree, serialised); } [[nodiscard]] const std::string& get_name() const override @@ -310,12 +310,12 @@ namespace ccf void persist_snapshot_( ccf::kv::Version version, ::consensus::Index evidence_idx, - const std::vector& cose_sig, + const CoseSignatureMap& cose_sigs, const std::vector& tree, const std::shared_ptr& serialised) { auto serialised_receipt = build_and_serialise_receipt( - cose_sig, + cose_sigs, tree, evidence_idx, serialised->write_set_digest, @@ -365,7 +365,7 @@ namespace ccf if ( snapshot_info.evidence_idx.has_value() && idx > snapshot_info.evidence_idx.value() && - snapshot_info.cose_sig.has_value() && snapshot_info.tree.has_value()) + snapshot_info.cose_sigs.has_value() && snapshot_info.tree.has_value()) { // Commit evidence is durable. Enqueue the persist action on this // generation's ordered task collection. OrderedTasks guarantees it @@ -378,7 +378,7 @@ namespace ccf shared_from_this(), snapshot_info.version, snapshot_info.evidence_idx.value(), - std::move(snapshot_info.cose_sig.value()), + std::move(snapshot_info.cose_sigs.value()), std::move(snapshot_info.tree.value()), snapshot_info.serialised)); @@ -513,8 +513,8 @@ namespace ccf return false; } - void record_cose_signature( - ::consensus::Index idx, const std::vector& cose_sig) + void record_cose_signatures( + ::consensus::Index idx, const CoseSignatureMap& cose_sigs) { std::lock_guard guard(lock); @@ -523,16 +523,17 @@ namespace ccf if ( pending_snapshot.evidence_idx.has_value() && idx > pending_snapshot.evidence_idx.value() && - !pending_snapshot.cose_sig.has_value()) + !pending_snapshot.cose_sigs.has_value()) { LOG_TRACE_FMT( - "Recording COSE signature at {} for snapshot {} with evidence at " - "{}", + "Recording {} COSE signature(s) at {} for snapshot {} with " + "evidence at {}", + cose_sigs.size(), idx, pending_snapshot.version, pending_snapshot.evidence_idx.value()); - pending_snapshot.cose_sig = cose_sig; + pending_snapshot.cose_sigs = cose_sigs; } } } diff --git a/src/node/test/historical_queries.cpp b/src/node/test/historical_queries.cpp index c90dac444242..096f3da89764 100644 --- a/src/node/test/historical_queries.cpp +++ b/src/node/test/historical_queries.cpp @@ -20,6 +20,7 @@ #include "kv/test/stub_consensus.h" #include "node/history.h" #include "node/share_manager.h" +#include "node/signature_cache_subsystem.h" #include #include @@ -2196,6 +2197,93 @@ TEST_CASE("adjust_ranges") } } +TEST_CASE( + "Historical and cached COSE signatures belong to the same transaction") +{ + auto state = create_and_init_state(); + auto& store = *state.kv_store; + ccf::SignatureCacheSubsystem signature_cache; + signature_cache.register_hooks(store); + ccf::historical::StateCache historical_cache( + store, state.ledger_secrets, std::make_shared()); + + const std::vector signature_transactions{ + {{ccf::IdentityType::CLASSICAL, {1, 2}}, {ccf::IdentityType::PQ, {3, 4}}}, + {{ccf::IdentityType::CLASSICAL, {5, 6}}}, + {{ccf::IdentityType::PQ, {7, 8}}}}; + + for (const auto& written_signatures : signature_transactions) + { + auto tx = store.create_tx(); + auto* signatures = tx.wo(ccf::Tables::COSE_SIGNATURES); + for (const auto& [identity_type, signature] : written_signatures) + { + signatures->put(identity_type, signature); + } + tx.wo(ccf::Tables::SERIALISED_MERKLE_TREE) + ->put({}); + REQUIRE(tx.commit() == ccf::kv::CommitResult::SUCCESS); + + const auto txid = store.current_txid(); + INFO("Signature transaction: ", txid.to_str()); + { + auto read_tx = store.create_read_only_tx(); + REQUIRE( + read_tx.ro(ccf::Tables::COSE_SIGNATURES)->size() == + 2); + } + store.compact(txid.seqno); + const auto cached = signature_cache.get_signature_for(txid.seqno - 1); + REQUIRE(cached.has_value()); + REQUIRE(cached->sig_seqno == txid.seqno); + REQUIRE(cached->cose_signatures == written_signatures); + + const auto ledger = construct_host_ledger(store.get_consensus()); + const auto& entry = ledger.at(txid.seqno); + auto result = ccf::kv::ApplyResult::FAIL; + ccf::ClaimsDigest claims_digest; + bool has_commit_evidence = false; + const auto historical_store = historical_cache.deserialise_ledger_entry( + txid.seqno, + entry.data(), + entry.size(), + result, + claims_digest, + has_commit_evidence); + REQUIRE(historical_store != nullptr); + REQUIRE(result == ccf::kv::ApplyResult::PASS_SIGNATURE); + REQUIRE(historical_store->current_txid() == txid); + REQUIRE( + ccf::historical::get_cose_signatures(historical_store) == + cached->cose_signatures); + } +} + +TEST_CASE("Legacy COSE receipt descriptions select CLASSICAL, not PQ") +{ + const ccf::CoseSignature classical_signature{1, 2, 3}; + const ccf::CoseSignature pq_signature{4, 5, 6}; + ccf::TxReceiptImpl receipt( + std::nullopt, + {{ccf::IdentityType::CLASSICAL, classical_signature}, + {ccf::IdentityType::PQ, pq_signature}}, + std::nullopt, + nullptr, + ccf::NodeId{}, + std::nullopt); + + REQUIRE(ccf::describe_cose_signature_v1(receipt) == classical_signature); + REQUIRE( + ccf::historical::select_described_cose_signature(receipt.cose_signatures) == + classical_signature); + + receipt.cose_signatures.erase(ccf::IdentityType::CLASSICAL); + REQUIRE_FALSE(ccf::describe_cose_signature_v1(receipt).has_value()); + REQUIRE_FALSE( + ccf::historical::select_described_cose_signature(receipt.cose_signatures) + .has_value()); +} + int main(int argc, char** argv) { doctest::Context context; diff --git a/src/node/test/history.cpp b/src/node/test/history.cpp index 6e725c61f29f..695d9748f3bf 100644 --- a/src/node/test/history.cpp +++ b/src/node/test/history.cpp @@ -727,3 +727,85 @@ int main(int argc, char** argv) return res; return res; } + +TEST_CASE("COSE signature table holds one entry per identity") +{ + ccf::kv::Store store; + auto encryptor = std::make_shared(); + store.set_encryptor(encryptor); + + const ccf::CoseSignature ec384_sig{1, 2, 3}; + const ccf::CoseSignature mldsa65_sig{4, 5, 6}; + + INFO("A table carrying two identities round-trips both"); + { + auto tx = store.create_tx(); + auto* handle = tx.wo(ccf::Tables::COSE_SIGNATURES); + handle->put(ccf::IdentityType::CLASSICAL, ec384_sig); + handle->put(ccf::IdentityType::PQ, mldsa65_sig); + REQUIRE(tx.commit() == ccf::kv::CommitResult::SUCCESS); + } + + { + auto tx = store.create_read_only_tx(); + auto* handle = tx.ro(ccf::Tables::COSE_SIGNATURES); + + // A reader which only understands ECDSA still finds it, unaffected by + // the presence of identities it does not know about. + REQUIRE(handle->get(ccf::IdentityType::CLASSICAL) == ec384_sig); + REQUIRE(handle->get(ccf::IdentityType::PQ) == mldsa65_sig); + + ccf::CoseSignatureMap read_back; + handle->foreach([&read_back](const auto& identity_type, const auto& sig) { + read_back.emplace(identity_type, sig); + return true; + }); + REQUIRE(read_back.size() == 2); + } +} + +TEST_CASE("CLASSICAL COSE signatures interoperate with the legacy singleton") +{ + using LegacyCoseSignatures = ccf::ServiceValue; + ccf::kv::Store store; + store.set_encryptor(std::make_shared()); + const ccf::CoseSignature legacy_signature{1, 2, 3}; + const ccf::CoseSignature keyed_signature{4, 5, 6}; + + { + auto tx = store.create_tx(); + tx.wo(ccf::Tables::COSE_SIGNATURES) + ->put(legacy_signature); + REQUIRE(tx.commit() == ccf::kv::CommitResult::SUCCESS); + } + + { + auto tx = store.create_tx(); + auto* signatures = tx.rw(ccf::Tables::COSE_SIGNATURES); + REQUIRE(signatures->get(ccf::IdentityType::CLASSICAL) == legacy_signature); + signatures->put(ccf::IdentityType::CLASSICAL, keyed_signature); + REQUIRE(tx.commit() == ccf::kv::CommitResult::SUCCESS); + } + + { + auto tx = store.create_read_only_tx(); + REQUIRE( + tx.ro(ccf::Tables::COSE_SIGNATURES)->get() == + keyed_signature); + } +} + +TEST_CASE("extract_cose_signatures skips removals") +{ + ccf::CoseSignatures::Write writes; + writes[ccf::IdentityType::CLASSICAL] = ccf::CoseSignature{1, 2, 3}; + // A removal is recorded as an unset value, and must not be reported as a + // signature. + writes[ccf::IdentityType::PQ] = std::nullopt; + + const auto extracted = ccf::extract_cose_signatures(writes); + REQUIRE(extracted.size() == 1); + REQUIRE( + extracted.at(ccf::IdentityType::CLASSICAL) == ccf::CoseSignature{1, 2, 3}); + REQUIRE_FALSE(extracted.contains(ccf::IdentityType::PQ)); +} diff --git a/src/node/test/snapshotter.cpp b/src/node/test/snapshotter.cpp index 158d5f8fa045..100f7a458c72 100644 --- a/src/node/test/snapshotter.cpp +++ b/src/node/test/snapshotter.cpp @@ -380,7 +380,8 @@ bool record_signature( "b96881e8c6f9265af8"); bool requires_snapshot = snapshotter->record_committable(idx); - snapshotter->record_cose_signature(idx, dummy_cose_sig); + snapshotter->record_cose_signatures( + idx, {{ccf::IdentityType::CLASSICAL, dummy_cose_sig}}); snapshotter->record_serialised_tree(idx, history->serialise_tree(idx)); return requires_snapshot; diff --git a/src/node/tx_receipt_impl.h b/src/node/tx_receipt_impl.h index 2dd465b925c3..4e8e27c86146 100644 --- a/src/node/tx_receipt_impl.h +++ b/src/node/tx_receipt_impl.h @@ -5,6 +5,7 @@ #include "ccf/network_identity_interface.h" #include "ccf/receipt.h" #include "node/history.h" +#include "service/tables/signatures.h" namespace ccf { @@ -13,7 +14,7 @@ namespace ccf struct TxReceiptImpl { std::optional> signature; - std::optional> cose_signature = std::nullopt; + CoseSignatureMap cose_signatures; std::optional root; std::shared_ptr path; ccf::NodeId node_id; @@ -27,7 +28,7 @@ namespace ccf TxReceiptImpl( const std::optional>& signature_, - const std::optional>& cose_signature, + CoseSignatureMap cose_signatures_, const std::optional& root_, std::shared_ptr path_, NodeId node_id_, @@ -43,7 +44,7 @@ namespace ccf const std::optional& cose_endorsements_ = std::nullopt) : signature(signature_), - cose_signature(cose_signature), + cose_signatures(std::move(cose_signatures_)), root(root_), path(std::move(path_)), node_id(std::move(node_id_)), diff --git a/src/pal/attestation.cpp b/src/pal/attestation.cpp index 8df0a1fbea34..29f1e7167c37 100644 --- a/src/pal/attestation.cpp +++ b/src/pal/attestation.cpp @@ -3,9 +3,7 @@ #include "ccf/pal/attestation.h" -#include "ccf/crypto/ecdsa.h" #include "ccf/crypto/openssl/openssl_wrappers.h" -#include "ccf/crypto/verifier.h" #include "ccf/ds/json.h" #include "ccf/pal/attestation_sev_snp.h" #include "ccf/pal/sev_snp_cpuid.h" @@ -16,37 +14,49 @@ namespace ccf::pal { - using Unique_ASN1_OBJECT = ccf::crypto::OpenSSL:: - Unique_SSL_OBJECT; - using Unique_ASN1_INTEGER = ccf::crypto::OpenSSL:: - Unique_SSL_OBJECT; - namespace { - std::string x509_name_to_rfc2253_string(X509_NAME* name) - { - ccf::crypto::OpenSSL::CHECKNULL(name); + using TavErrorPtr = std::unique_ptr; - ccf::crypto::OpenSSL::Unique_BIO mem; - const auto rc = X509_NAME_print_ex(mem, name, 0, XN_FLAG_RFC2253); - if (rc < 0) + void check_tav_error(std::string_view operation, const TavError* error) + { + if (error != nullptr) { - const auto ec = ERR_get_error(); - throw std::runtime_error(fmt::format( - "OpenSSL error (rc={}, ec={}): {}", - rc, - ec, - ccf::crypto::OpenSSL::error_string(ec))); + throw std::logic_error(fmt::format( + "SEV-SNP: TAV {} failed ({}): {}", + operation, + static_cast(tav_error_code(error)), + tav_error_message(error))); } + } + } - BUF_MEM* bptr = nullptr; - ccf::crypto::OpenSSL::CHECK1(BIO_get_mem_ptr(mem, &bptr)); - ccf::crypto::OpenSSL::CHECKNULL(bptr); - - return {bptr->data, bptr->length}; + namespace snp + { + AttestationReport parse_attestation_report_unverified( + std::span report) + { + TavSnpAttestationReport* raw_report = nullptr; + TavErrorPtr error( + tav_snp_attestation_report_from_unverified_bytes( + report.data(), report.size(), &raw_report), + tav_error_free); + AttestationReport parsed_report(raw_report); + check_tav_error("unverified report parsing", error.get()); + if (parsed_report == nullptr) + { + throw std::logic_error( + "SEV-SNP: TAV parsing succeeded without returning a report"); + } + return parsed_report; } } + using Unique_ASN1_OBJECT = ccf::crypto::OpenSSL:: + Unique_SSL_OBJECT; + using Unique_ASN1_INTEGER = ccf::crypto::OpenSSL:: + Unique_SSL_OBJECT; + void verify_virtual_attestation_report( const QuoteInfo& quote_info, PlatformAttestationMeasurement& measurement, @@ -235,7 +245,7 @@ namespace ccf::pal } // Verifying SNP attestation report is available on all platforms. - void verify_snp_attestation_report( + snp::AttestationReport verify_snp_attestation_report_and_get( const QuoteInfo& quote_info, PlatformAttestationMeasurement& measurement, PlatformAttestationReportData& report_data) @@ -247,33 +257,20 @@ namespace ccf::pal quote_info.format)); } - if (quote_info.quote.size() != sizeof(snp::Attestation)) + const auto& report = quote_info.quote; + const auto& endorsements = quote_info.endorsements; + if (report.size() != snp::attestation_report_size) { throw std::logic_error(fmt::format( "Input SEV-SNP attestation report is not of expected size {}: {}", - sizeof(snp::Attestation), - quote_info.quote.size())); + snp::attestation_report_size, + report.size())); } - auto quote = - *reinterpret_cast(quote_info.quote.data()); - - if (quote.version < snp::minimum_attestation_version) - { - throw std::logic_error(fmt::format( - "SEV-SNP: Attestation version is {} not >= expected minimum {}", - quote.version, - snp::minimum_attestation_version)); - } - - auto product_family = - snp::get_sev_snp_product(quote.cpuid_fam_id, quote.cpuid_mod_id); - // ---- Verify certificate chain ---- auto certificates = ccf::crypto::split_x509_cert_bundle(std::string_view( - reinterpret_cast(quote_info.endorsements.data()), - quote_info.endorsements.size())); + reinterpret_cast(endorsements.data()), endorsements.size())); if (certificates.size() != 3) { throw std::logic_error(fmt::format( @@ -286,97 +283,54 @@ namespace ccf::pal auto ask_cert = certificates[1]; auto ark_cert = certificates[2]; - auto ark_verifier = ccf::crypto::make_verifier(ark_cert); - - auto key = snp::amd_root_signing_keys.find(product_family); - if (key == snp::amd_root_signing_keys.end()) - { - throw std::logic_error(fmt::format( - "SEV-SNP: No known root certificate for {}", product_family)); - } - const auto& expected_ark = key->second; - if (ark_verifier->public_key_pem().str() != expected_ark.public_key) - { - throw std::logic_error(fmt::format( - "SEV-SNP: The root of trust public key for this attestation was not " - "the expected one for v{} {} {}: {} != {}", - quote.version, - quote.cpuid_fam_id, - quote.cpuid_mod_id, - ark_verifier->public_key_pem().str(), - expected_ark.public_key)); - } - - ccf::crypto::OpenSSL::Unique_BIO mem_bio(ark_cert); - ccf::crypto::OpenSSL::Unique_X509 x509( - mem_bio, true, true /* check_null */); - const auto issuer = x509_name_to_rfc2253_string(X509_get_issuer_name(x509)); - if (issuer != expected_ark.issuer) - { - throw std::logic_error(fmt::format( - "SEV-SNP: The root of trust issuer for this attestation was not " - "the expected one for {}: {} != {}", - product_family, - issuer, - expected_ark.issuer)); - } - - if (!ark_verifier->verify_certificate({&ark_cert})) - { - throw std::logic_error( - "SEV-SNP: The root of trust public key for this attestation was not " - "self signed as expected"); - } - - auto vcek_verifier = ccf::crypto::make_verifier(/* leaf */ vcek_cert); - if (!vcek_verifier->verify_certificate( - /* root */ {&ark_cert}, /* chain */ {&ask_cert})) + TavSnpAttestationReport* raw_report = nullptr; + TavErrorPtr error( + tav_verify_snp_attestation( + report.data(), + report.size(), + ark_cert.data(), + ark_cert.size(), + ask_cert.data(), + ask_cert.size(), + vcek_cert.data(), + vcek_cert.size(), + &raw_report), + tav_error_free); + snp::AttestationReport attestation(raw_report); + check_tav_error("verification", error.get()); + if (attestation == nullptr) { throw std::logic_error( - "SEV-SNP: The chain of signatures from the root of trust to this " - "attestation is broken"); + "SEV-SNP: TAV verification succeeded without returning a report"); } - // ---- Verify attestation report signature ---- - - // According to Table 134 (2025-06-12) only ecdsa_p384_sha384 is supported - if (quote.signature_algo != snp::SignatureAlgorithm::ecdsa_p384_sha384) + if ( + tav_snp_attestation_report_version(attestation.get()) < + snp::minimum_attestation_version) { throw std::logic_error(fmt::format( - "SEV-SNP: Unsupported signature algorithm: {} (supported: {})", - quote.signature_algo, - snp::SignatureAlgorithm::ecdsa_p384_sha384)); + "SEV-SNP: Attestation version is {} not >= expected minimum {}", + tav_snp_attestation_report_version(attestation.get()), + snp::minimum_attestation_version)); } - // Make ASN1 DER signature - auto quote_signature = ccf::crypto::ecdsa_sig_from_r_s( - quote.signature.r, - sizeof(quote.signature.r), - quote.signature.s, - sizeof(quote.signature.s), - false /* little endian */ - ); - - std::span quote_without_signature{ - quote_info.quote.data(), - quote_info.quote.size() - sizeof(quote.signature)}; - if (!vcek_verifier->verify(quote_without_signature, quote_signature)) - { - throw std::logic_error( - "SEV-SNP: Chip certificate (VCEK) did not sign this attestation"); - } + const auto product_family = snp::get_sev_snp_product( + tav_snp_attestation_report_cpuid_fam_id(attestation.get()), + tav_snp_attestation_report_cpuid_mod_id(attestation.get())); // ---- Verify attestation report contents ---- - if (quote.flags.signing_key != snp::attestation_flags_signing_key_vcek) + if ( + tav_snp_attestation_report_flags_signing_key(attestation.get()) != + snp::attestation_flags_signing_key_vcek) { throw std::logic_error(fmt::format( "SEV-SNP: Attestation report must be signed by VCEK: {}", - static_cast(quote.flags.signing_key))); + tav_snp_attestation_report_flags_signing_key(attestation.get()))); } // mask_chip_key if set means the operator set the vcek to 0s - if (quote.flags.mask_chip_key != 0) + if (tav_snp_attestation_report_flags_mask_chip_key(attestation.get())) { throw std::logic_error( fmt::format("SEV-SNP: Mask chip key must not be set")); @@ -385,15 +339,15 @@ namespace ccf::pal // All attestation reports generated by guests must have VMPL <= 3 // while host generated reports have VMPL > 3. // We should reject host generated reports. - if (quote.vmpl > 3) + if (tav_snp_attestation_report_vmpl(attestation.get()) > 3) { throw std::logic_error(fmt::format( "SEV-SNP: This report seems to be host generated (VMPL {} > 3)", - quote.vmpl)); + tav_snp_attestation_report_vmpl(attestation.get()))); } // Debug mode would allow decryption of guest pages - if (quote.policy.debug != 0) + if (tav_snp_attestation_report_policy_debug(attestation.get())) { throw std::logic_error( "SEV-SNP: SNP attestation report guest policy debugging must not be " @@ -402,18 +356,25 @@ namespace ccf::pal // Migration of CCF nodes and other services could allow duplicates, and // hence must be disallowed - if (quote.policy.migrate_ma != 0) + if (tav_snp_attestation_report_policy_migrate_ma(attestation.get())) { throw std::logic_error( "SEV-SNP: SNP attestation report guest policy migration must not be " "enabled"); } + const uint8_t* reported_tcb_data = nullptr; + size_t reported_tcb_size = 0; + tav_snp_attestation_report_reported_tcb( + attestation.get(), &reported_tcb_data, &reported_tcb_size); + const auto reported_tcb_raw = + std::span{reported_tcb_data, reported_tcb_size}; auto endorsed_tcb = get_endorsed_tcb_from_cert(product_family, vcek_cert); if (endorsed_tcb.has_value()) { auto endorsed_tcb_policy = endorsed_tcb->to_policy(product_family); - auto reported_tcb = quote.reported_tcb.to_policy(product_family); + auto reported_tcb = + snp::TcbVersionRaw(reported_tcb_raw).to_policy(product_family); if (!snp::TcbVersionPolicy::is_valid(endorsed_tcb_policy, reported_tcb)) { @@ -426,7 +387,7 @@ namespace ccf::pal } auto endorsed_chip_id = get_endorsed_chip_id_from_cert(vcek_cert); - auto reported_chip_id = quote.get_chip_id_for_vcek(); + auto reported_chip_id = snp::get_chip_id_for_vcek(attestation); if ( endorsed_chip_id.has_value() && (endorsed_chip_id->size() != reported_chip_id.size() || @@ -444,13 +405,14 @@ namespace ccf::pal if (quote_info.endorsed_tcb.has_value()) { - const auto& quote_endorsed_tcb = quote_info.endorsed_tcb.value(); - auto raw_endorsed_tcb = snp::TcbVersionRaw::from_hex(quote_endorsed_tcb); + auto raw_endorsed_tcb = + snp::TcbVersionRaw::from_hex(quote_info.endorsed_tcb.value()); - if (raw_endorsed_tcb != quote.reported_tcb) + const auto reported_tcb = snp::TcbVersionRaw(reported_tcb_raw); + if (raw_endorsed_tcb != reported_tcb) { auto endorsed_tcb_hex = raw_endorsed_tcb.to_hex(); - auto report_tcb_hex = quote.reported_tcb.to_hex(); + auto report_tcb_hex = reported_tcb.to_hex(); throw std::logic_error(fmt::format( "SEV-SNP: endorsed TCB {} does not match reported TCB {}", endorsed_tcb_hex, @@ -460,8 +422,21 @@ namespace ccf::pal // ---- Set return values ---- - report_data = SnpAttestationReportData(quote.report_data); - measurement = SnpAttestationMeasurement(quote.measurement); + const uint8_t* data = nullptr; + size_t size = 0; + tav_snp_attestation_report_report_data(attestation.get(), &data, &size); + report_data = SnpAttestationReportData({data, size}); + tav_snp_attestation_report_measurement(attestation.get(), &data, &size); + measurement = SnpAttestationMeasurement({data, size}); + return attestation; + } + + void verify_snp_attestation_report( + const QuoteInfo& quote_info, + PlatformAttestationMeasurement& measurement, + PlatformAttestationReportData& report_data) + { + verify_snp_attestation_report_and_get(quote_info, measurement, report_data); } void verify_quote( diff --git a/src/pal/quote_generation.h b/src/pal/quote_generation.h index 72ec00b8ddea..4c35101ad437 100644 --- a/src/pal/quote_generation.h +++ b/src/pal/quote_generation.h @@ -89,25 +89,27 @@ namespace ccf::pal { QuoteInfo node_quote_info = {}; node_quote_info.format = QuoteFormat::amd_sev_snp_v1; - auto attestation = snp::get_attestation(report_data); + node_quote_info.quote = snp::get_attestation(report_data)->get_raw(); + auto report = + snp::parse_attestation_report_unverified(node_quote_info.quote); - if (attestation->get().version < pal::snp::minimum_attestation_version) + if ( + tav_snp_attestation_report_version(report.get()) < + pal::snp::minimum_attestation_version) { throw std::logic_error(fmt::format( "SEV-SNP: attestation version {} is less than the minimum supported " "version {}", - attestation->get().version, + tav_snp_attestation_report_version(report.get()), pal::snp::minimum_attestation_version)); } - node_quote_info.quote = attestation->get_raw(); - if (endorsement_cb != nullptr) { endorsement_cb( node_quote_info, snp::make_endorsement_endpoint_configuration( - attestation->get(), endorsements_servers)); + report, endorsements_servers)); } } diff --git a/src/pal/test/snp_attestation_validation.cpp b/src/pal/test/snp_attestation_validation.cpp index afc8d026418e..6cd2aa48eee2 100644 --- a/src/pal/test/snp_attestation_validation.cpp +++ b/src/pal/test/snp_attestation_validation.cpp @@ -6,12 +6,14 @@ #include "ccf/ds/hex.h" #include "ccf/ds/logger.h" #include "ccf/ds/quote_info.h" +#include "ccf/node/quote.h" #include "ccf/pal/attestation.h" #include "ccf/pal/attestation_sev_snp.h" #include "ccf/pal/attestation_sev_snp_endorsements.h" #include "ccf/pal/measurement.h" #include "ccf/pal/report_data.h" #include "ccf/pal/sev_snp_cpuid.h" +#include "ccf/pal/snp_ioctl.h" #include "crypto/openssl/hash.h" #include "pal/test/attestation.h" #include "pal/test/attestation_sev_snp_endorsements.h" @@ -20,6 +22,7 @@ #include #include #include +#include #define DOCTEST_CONFIG_IMPLEMENT #include @@ -128,6 +131,270 @@ namespace } } +TEST_CASE("CCF policy is separate from generic TAV verification") +{ + using namespace ccf::pal; + const auto certs = milan_endorsement_certs(); + REQUIRE(certs.size() == 3); + TavSnpAttestationReport* raw_report = nullptr; + const std::unique_ptr error( + tav_verify_snp_attestation( + snp::testing::milan_attestation.data(), + snp::testing::milan_attestation.size(), + certs[2].data(), + certs[2].size(), + certs[1].data(), + certs[1].size(), + certs[0].data(), + certs[0].size(), + &raw_report), + tav_error_free); + const snp::AttestationReport report(raw_report); + REQUIRE(error == nullptr); + REQUIRE(report != nullptr); + CHECK( + tav_snp_attestation_report_version(report.get()) == + snp::minimum_attestation_version); + + PlatformAttestationMeasurement measurement; + PlatformAttestationReportData report_data; + const std::vector endorsements( + snp::testing::milan_endorsements.begin(), + snp::testing::milan_endorsements.end()); + const ccf::QuoteInfo quote_info = { + .format = ccf::QuoteFormat::amd_sev_snp_v1, + .quote = snp::testing::milan_attestation, + .endorsements = endorsements, + .uvm_endorsements = std::nullopt, + .endorsed_tcb = "0000000000000000"}; + CHECK_THROWS_WITH_AS( + verify_snp_attestation_report_and_get(quote_info, measurement, report_data), + doctest::Contains("does not match reported TCB"), + std::logic_error); +} + +TEST_CASE("unverified SNP report rejects invalid sizes") +{ + for (const size_t size : {0U, 100U, 1183U, 1185U}) + { + const auto expected_error = size == 0 ? + "SEV-SNP: TAV unverified report parsing failed (1): attestation report " + "is empty" : + fmt::format( + "SEV-SNP: TAV unverified report parsing failed (1): Invalid " + "attestation report: expected 1184 bytes, got {}", + size); + CHECK_THROWS_WITH_AS( + static_cast(ccf::pal::snp::parse_attestation_report_unverified( + std::vector(size))), + expected_error.c_str(), + std::logic_error); + } +} + +TEST_CASE("SNP chip ID access rejects empty handles") +{ + ccf::pal::snp::AttestationReport report; + CHECK_THROWS_WITH_AS( + ccf::pal::snp::get_chip_id_for_vcek(report), + "Cannot access an empty SNP attestation report", + std::logic_error); +} + +TEST_CASE("SNP endorsement configuration rejects empty owners") +{ + using namespace ccf::pal::snp; + AttestationReport report; + + SUBCASE("default constructed") {} + + SUBCASE("moved from") + { + report = parse_attestation_report_unverified(testing::milan_attestation); + auto owner = std::move(report); + REQUIRE(owner != nullptr); + CHECK_NOTHROW(make_endorsement_endpoint_configuration(owner)); + } + + REQUIRE(report == nullptr); + CHECK_THROWS_WITH_AS( + make_endorsement_endpoint_configuration(report), + "Cannot access an empty SNP attestation report", + std::logic_error); +} + +TEST_CASE("VCEK chip ID uses the product-specific prefix") +{ + using namespace ccf::pal::snp; + struct TestCase + { + const std::vector& report; + size_t chip_id_size; + }; + for (const auto& [raw_report, expected_size] : + {TestCase{testing::milan_attestation, 64}, + TestCase{testing::genoa_attestation, 64}, + TestCase{testing::turin_attestation, 8}}) + { + auto report = parse_attestation_report_unverified(raw_report); + const auto vcek_chip_id = get_chip_id_for_vcek(report); + REQUIRE(vcek_chip_id.size() == expected_size); + CHECK(std::equal( + vcek_chip_id.begin(), vcek_chip_id.end(), raw_report.begin() + 0x1A0)); + } +} + +TEST_CASE("TCB values can be constructed from borrowed bytes") +{ + using ccf::pal::snp::TcbVersionRaw; + std::array bytes = {4, 0, 0, 0, 0, 0, 24, 219}; + const auto tcb = TcbVersionRaw(std::span(bytes)); + CHECK(tcb.to_hex() == "db18000000000004"); + CHECK(tcb == TcbVersionRaw(std::vector(bytes.begin(), bytes.end()))); + bytes.fill(0); + CHECK(tcb.to_hex() == "db18000000000004"); + for (const size_t size : {0, 7, 9}) + { + const std::vector invalid_bytes(size); + const auto expected_error = + fmt::format("Invalid TCB version raw data size: {}", size); + CHECK_THROWS_WITH_AS( + TcbVersionRaw{invalid_bytes}, expected_error.c_str(), std::logic_error); + CHECK_THROWS_WITH_AS( + TcbVersionRaw(std::span(invalid_bytes)), + expected_error.c_str(), + std::logic_error); + } +} + +TEST_CASE("SNP verification preserves invalid size error") +{ + ccf::pal::PlatformAttestationMeasurement measurement; + ccf::pal::PlatformAttestationReportData report_data; + const ccf::QuoteInfo quote_info = { + .format = ccf::QuoteFormat::amd_sev_snp_v1, + .quote = std::vector(100), + .endorsements = {}, + .uvm_endorsements = std::nullopt}; + CHECK_THROWS_WITH_AS( + ccf::pal::verify_snp_attestation_report_and_get( + quote_info, measurement, report_data), + doctest::Contains( + "Input SEV-SNP attestation report is not of expected size 1184: 100"), + std::logic_error); +} + +TEST_CASE("SNP verification rejects other quote formats before parsing") +{ + for (const auto format : + {ccf::QuoteFormat::insecure_virtual, ccf::QuoteFormat::oe_sgx_v1}) + { + const ccf::QuoteInfo quote_info = { + .format = format, + .quote = {}, + .endorsements = {}, + .uvm_endorsements = std::nullopt}; + ccf::pal::PlatformAttestationMeasurement measurement; + ccf::pal::PlatformAttestationReportData report_data; + const auto expected_error = fmt::format( + "Unexpected attestation report to verify for SEV-SNP: {}", format); + CHECK_THROWS_WITH_AS( + ccf::pal::verify_snp_attestation_report_and_get( + quote_info, measurement, report_data), + expected_error.c_str(), + std::logic_error); + CHECK_THROWS_WITH_AS( + ccf::pal::verify_snp_attestation_report( + quote_info, measurement, report_data), + expected_error.c_str(), + std::logic_error); + } +} + +TEST_CASE("SNP request rejects oversized report data before ioctl") +{ + ccf::pal::PlatformAttestationReportData report_data; + report_data.data.resize(ccf::pal::snp_attestation_report_data_size + 1); + CHECK_THROWS_WITH_AS( + ccf::pal::snp::ioctl6::Attestation{report_data}, + "User-defined report data is larger than available space", + std::logic_error); +} + +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Wdeprecated-declarations" +TEST_CASE("legacy SNP report layout matches the AMD specification") +{ + using ccf::pal::snp::Attestation; + + static_assert( + std::is_same_v< + decltype(std::declval().get()), + const Attestation&>); + static_assert( + std::is_same_v< + decltype(std::declval().get_raw()), + std::vector>); + static_assert( + std::is_same_v< + decltype(std::declval().get_raw()), + std::vector>); + static_assert(std::is_same_v< + decltype(ccf::AttestationProvider::get_snp_attestation( + std::declval())), + std::optional>); + static_assert(std::is_same_v< + decltype(ccf::pal::snp::ioctl6::AttestationResp::report), + Attestation>); + + CHECK(ccf::pal::snp::amd_root_signing_keys.size() == 3); + CHECK( + ccf::pal::snp::amd_root_signing_keys.at(ccf::pal::snp::ProductName::Milan) + .public_key == ccf::pal::snp::amd_milan_root_signing_public_key); + + Attestation report = {}; + CHECK(sizeof(report) == ccf::pal::snp::attestation_report_size); + CHECK(alignof(Attestation) == 1); + CHECK(offsetof(Attestation, version) == 0x000); + CHECK(offsetof(Attestation, policy) == 0x008); + CHECK(offsetof(Attestation, report_data) == 0x050); + CHECK(offsetof(Attestation, measurement) == 0x090); + CHECK(offsetof(Attestation, reported_tcb) == 0x180); + CHECK(offsetof(Attestation, chip_id) == 0x1A0); + CHECK(offsetof(Attestation, current_build) == 0x1E8); + CHECK(offsetof(Attestation, current_minor) == 0x1E9); + CHECK(offsetof(Attestation, current_major) == 0x1EA); + CHECK(offsetof(Attestation, signature) == 0x2A0); + + ccf::pal::snp::ioctl6::detail::AttestationResponse response; + response.report_size = ccf::pal::snp::attestation_report_size; + std::copy( + ccf::pal::snp::testing::milan_attestation.begin(), + ccf::pal::snp::testing::milan_attestation.end(), + response.report_bytes.begin()); + ccf::pal::snp::ioctl6::PaddedAttestationResp legacy_response; + static_assert(std::is_trivially_copyable_v); + static_assert(sizeof(legacy_response) == sizeof(response)); + std::memcpy(&legacy_response, &response, sizeof(legacy_response)); + CHECK(legacy_response.report_size == response.report_size); + REQUIRE(response.report_bytes[0x1E8] != response.report_bytes[0x1E9]); + CHECK(legacy_response.report.current_build == response.report_bytes[0x1E8]); + CHECK(legacy_response.report.current_minor == response.report_bytes[0x1E9]); + CHECK( + std::memcmp( + &legacy_response.report, + response.report_bytes.data(), + response.report_bytes.size()) == 0); + + report.version = ccf::pal::snp::minimum_attestation_version; + report.cpuid_fam_id = 0x19; + report.cpuid_mod_id = 0x01; + const auto config = + ccf::pal::snp::make_endorsement_endpoint_configuration(report); + CHECK(config.servers.size() == 1); +} +#pragma clang diagnostic pop + TEST_CASE("milan validation") { using namespace ccf; @@ -144,8 +411,15 @@ TEST_CASE("milan validation") pal::PlatformAttestationMeasurement measurement; pal::PlatformAttestationReportData report_data; + const auto report = pal::verify_snp_attestation_report_and_get( + milan_quote_info, measurement, report_data); + REQUIRE(report != nullptr); + const auto verified_measurement = measurement.data; + const auto verified_report_data = report_data.data; pal::verify_snp_attestation_report( milan_quote_info, measurement, report_data); + CHECK(measurement.data == verified_measurement); + CHECK(report_data.data == verified_report_data); } TEST_CASE("genoa validation") @@ -188,6 +462,31 @@ TEST_CASE("turin validation") turin_quote_info, measurement, report_data); } +TEST_CASE("Invalid attestation signature fails TAV verification") +{ + using namespace ccf; + + auto invalid_attestation = pal::snp::testing::milan_attestation; + static constexpr size_t signature_offset = 0x2a0; + invalid_attestation[signature_offset] ^= 1; + auto quote_info = QuoteInfo{ + .format = QuoteFormat::amd_sev_snp_v1, + .quote = std::move(invalid_attestation), + .endorsements = std::vector( + pal::snp::testing::milan_endorsements.begin(), + pal::snp::testing::milan_endorsements.end()), + .uvm_endorsements = std::nullopt, + }; + + pal::PlatformAttestationMeasurement measurement; + pal::PlatformAttestationReportData report_data; + + CHECK_THROWS_WITH_AS( + pal::verify_snp_attestation_report(quote_info, measurement, report_data), + doctest::Contains("SEV-SNP: TAV verification failed (104):"), + std::logic_error); +} + TEST_CASE("Mismatched attestation and endorsements fail") { using namespace ccf; @@ -207,9 +506,7 @@ TEST_CASE("Mismatched attestation and endorsements fail") CHECK_THROWS_WITH_AS( pal::verify_snp_attestation_report( mismatched_quote, measurement, report_data), - doctest::Contains( - "SEV-SNP: The root of trust public key for this attestation " - "was not the expected one"), + doctest::Contains("SEV-SNP: TAV verification failed (102):"), std::logic_error); } @@ -223,9 +520,7 @@ TEST_CASE("ARK with unexpected issuer fails") CHECK_THROWS_WITH_AS( ccf::pal::verify_snp_attestation_report( quote_info, measurement, report_data), - doctest::Contains( - "SEV-SNP: The root of trust issuer for this attestation was not " - "the expected one"), + doctest::Contains("SEV-SNP: TAV verification failed (102):"), std::logic_error); } @@ -269,11 +564,15 @@ TEST_CASE("Parsing of Tcb versions from strings") TEST_CASE("Parsing tcb versions from attestaion") { - auto milan_attestation = *reinterpret_cast( - ccf::pal::snp::testing::milan_attestation.data()); - auto milan_tcb = - milan_attestation.reported_tcb.to_policy(ccf::pal::snp::ProductName::Milan) - .to_milan_genoa(); + auto milan_attestation = ccf::pal::snp::parse_attestation_report_unverified( + ccf::pal::snp::testing::milan_attestation); + const uint8_t* data = nullptr; + size_t size = 0; + tav_snp_attestation_report_reported_tcb( + milan_attestation.get(), &data, &size); + auto milan_tcb = ccf::pal::snp::TcbVersionRaw({data, size}) + .to_policy(ccf::pal::snp::ProductName::Milan) + .to_milan_genoa(); CHECK_EQ(milan_tcb.microcode, 0xdb); CHECK_EQ(milan_tcb.snp, 0x18); CHECK_EQ(milan_tcb.tee, 0x00); @@ -488,7 +787,7 @@ TEST_CASE("Quote endorsements url generation") for (auto [attestation, servers, expected_url] : test_cases) { auto quote = - *reinterpret_cast(attestation.data()); + ccf::pal::snp::parse_attestation_report_unverified(attestation); auto config = ccf::pal::snp::make_endorsement_endpoint_configuration(quote, servers); @@ -496,15 +795,52 @@ TEST_CASE("Quote endorsements url generation") } } +TEST_CASE("Quote endorsement TCB formatting preserves leading zeroes") +{ + using namespace ccf::pal::snp; + + for (const auto& expected_tcb : + {"0000000000000000", "0001000000000004", "0b18000000000004"}) + { + auto report = testing::milan_attestation; + const auto tcb_bytes = ccf::ds::from_hex(expected_tcb); + std::reverse_copy( + tcb_bytes.begin(), tcb_bytes.end(), report.begin() + 0x180); + auto quote = parse_attestation_report_unverified(report); + + const auto default_config = make_endorsement_endpoint_configuration(quote); + REQUIRE_EQ(default_config.servers.size(), 1); + REQUIRE_EQ(default_config.servers.front().size(), 1); + CHECK(default_config.servers.front().front().uri.ends_with( + std::string("/") + expected_tcb)); + + const auto config = make_endorsement_endpoint_configuration( + quote, + {{EndorsementsEndpointType::Azure}, {EndorsementsEndpointType::THIM}}); + REQUIRE_EQ(config.servers.size(), 2); + REQUIRE_EQ(config.servers.front().size(), 1); + REQUIRE_EQ(config.servers.back().size(), 1); + CHECK(config.servers.front().front().uri.ends_with( + std::string("/") + expected_tcb)); + CHECK_EQ( + config.servers.back().front().params.at("tcbVersion"), expected_tcb); + } +} + TEST_CASE("Quote endorsements generation for v2 attestation version fails") { auto v2_format_milan_attestation = - *reinterpret_cast( - ccf::pal::snp::testing::v2_format_milan_attestation.data()); + ccf::pal::snp::parse_attestation_report_unverified( + ccf::pal::snp::testing::v2_format_milan_attestation); - CHECK_EQ(v2_format_milan_attestation.version, 2); - CHECK_EQ(v2_format_milan_attestation.cpuid_fam_id, 0x0); - CHECK_EQ(v2_format_milan_attestation.cpuid_mod_id, 0x0); + CHECK_EQ( + tav_snp_attestation_report_version(v2_format_milan_attestation.get()), 2); + CHECK_EQ( + tav_snp_attestation_report_cpuid_fam_id(v2_format_milan_attestation.get()), + 0x0); + CHECK_EQ( + tav_snp_attestation_report_cpuid_mod_id(v2_format_milan_attestation.get()), + 0x0); CHECK_THROWS_WITH( ccf::pal::snp::make_endorsement_endpoint_configuration( @@ -530,8 +866,8 @@ TEST_CASE("Extracting metadata from endorsements") .uvm_endorsements = std::nullopt, }; - auto attestation = *reinterpret_cast( - milan_quote_info.quote.data()); + auto attestation = + pal::snp::parse_attestation_report_unverified(milan_quote_info.quote); auto certificates = ccf::crypto::split_x509_cert_bundle(std::string_view( reinterpret_cast(milan_quote_info.endorsements.data()), @@ -542,14 +878,17 @@ TEST_CASE("Extracting metadata from endorsements") auto endorsed_tcb = pal::get_endorsed_tcb_from_cert( pal::snp::ProductName::Milan, chip_certificate); REQUIRE(endorsed_tcb.has_value()); + const uint8_t* data = nullptr; + size_t size = 0; + tav_snp_attestation_report_reported_tcb(attestation.get(), &data, &size); CHECK_EQ( nlohmann::json(endorsed_tcb.value()).dump(), - nlohmann::json(attestation.reported_tcb).dump()); + nlohmann::json(pal::snp::TcbVersionRaw({data, size})).dump()); auto endorsed_chip_id = pal::get_endorsed_chip_id_from_cert(chip_certificate); REQUIRE(endorsed_chip_id.has_value()); - auto printable_reported_chip_id = std::span( - attestation.chip_id, attestation.chip_id + sizeof(attestation.chip_id)); + tav_snp_attestation_report_chip_id(attestation.get(), &data, &size); + const auto printable_reported_chip_id = std::span{data, size}; CHECK_EQ( ds::to_hex(endorsed_chip_id.value()), ds::to_hex(printable_reported_chip_id)); diff --git a/src/pal/test/snp_ioctl_test.cpp b/src/pal/test/snp_ioctl_test.cpp index f873733d173e..2cd0d3779f15 100644 --- a/src/pal/test/snp_ioctl_test.cpp +++ b/src/pal/test/snp_ioctl_test.cpp @@ -23,11 +23,14 @@ TEST_CASE("SNP request attestation") snp_report_data.report_data.begin(), snp_report_data.report_data.end(), 0); PlatformAttestationReportData report_data(snp_report_data); - snp::ioctl6::Attestation ioctl_attestation(report_data); - - const snp::Attestation& attestation = ioctl_attestation.get(); - - SnpAttestationReportData attested_report_data(attestation.report_data); + const auto attestation = snp::get_attestation(report_data)->get_raw(); + REQUIRE(attestation.size() == snp::attestation_report_size); + const auto report = snp::parse_attestation_report_unverified(attestation); + + const uint8_t* data = nullptr; + size_t size = 0; + tav_snp_attestation_report_report_data(report.get(), &data, &size); + SnpAttestationReportData attested_report_data({data, size}); REQUIRE_EQ(snp_report_data.report_data, attested_report_data.report_data); } diff --git a/src/pal/test/verify_attestation.cpp b/src/pal/test/verify_attestation.cpp index 660380739b1b..ef99ab5f730e 100644 --- a/src/pal/test/verify_attestation.cpp +++ b/src/pal/test/verify_attestation.cpp @@ -18,8 +18,8 @@ void fetch_endorsements( const std::vector& attestation_raw, std::vector& output) { - auto attestation = *reinterpret_cast( - attestation_raw.data()); + auto attestation = + ccf::pal::snp::parse_attestation_report_unverified(attestation_raw); auto endorsement_config = ccf::pal::snp::make_endorsement_endpoint_configuration( @@ -64,15 +64,16 @@ int main(int argc, char** argv) .add_option( "-a,--attestation", attestation_hex, "Attestation in hex format") ->check([](const std::string& attestation_hex) { - auto attest = ccf::ds::from_hex(attestation_hex); - if (attest.size() != sizeof(ccf::pal::snp::Attestation)) + try { - return std::string(fmt::format( - "Attestation size is incorrect {} != {}", - attest.size(), - sizeof(ccf::pal::snp::Attestation))); + static_cast(ccf::pal::snp::parse_attestation_report_unverified( + ccf::ds::from_hex(attestation_hex))); + return std::string(); + } + catch (const std::exception& e) + { + return std::string(e.what()); } - return std::string(); }); ccf::LoggerLevel log_level = ccf::LoggerLevel::INFO; diff --git a/src/pal/test/verify_uvm_attestation_and_endorsements.cpp b/src/pal/test/verify_uvm_attestation_and_endorsements.cpp index f4822e7ce583..aa9c981ead73 100644 --- a/src/pal/test/verify_uvm_attestation_and_endorsements.cpp +++ b/src/pal/test/verify_uvm_attestation_and_endorsements.cpp @@ -241,12 +241,15 @@ int main(int argc, char** argv) "Expected SNP quote format"); LOG_INFO_FMT("Verifying endorsements"); - const auto* attestation_unverified = - reinterpret_cast( - quote_info.quote.data()); + const auto attestation_unverified = + ccf::pal::snp::parse_attestation_report_unverified(quote_info.quote); + const uint8_t* data = nullptr; + size_t size = 0; + tav_snp_attestation_report_reported_tcb( + attestation_unverified.get(), &data, &size); validate_endorsements( endorsements, - attestation_unverified->reported_tcb, + ccf::pal::snp::TcbVersionRaw({data, size}), quote_info.endorsements); LOG_INFO_FMT("Verifying quote"); diff --git a/src/service/internal_tables_access.h b/src/service/internal_tables_access.h index f332af2a16b0..7e6e3cae63e1 100644 --- a/src/service/internal_tables_access.h +++ b/src/service/internal_tables_access.h @@ -964,35 +964,49 @@ namespace ccf } static void trust_node_snp_tcb_version( - ccf::kv::Tx& tx, pal::snp::Attestation& attestation) + ccf::kv::Tx& tx, const pal::snp::AttestationReport& attestation) { - if (attestation.version < pal::snp::minimum_attestation_version) + if (attestation == nullptr) + { + throw std::logic_error("Cannot access an empty SNP attestation report"); + } + if ( + tav_snp_attestation_report_version(attestation.get()) < + pal::snp::minimum_attestation_version) { throw std::logic_error(fmt::format( "SEV-SNP: attestation version {} is not supported. Minimum " "supported version is {}", - attestation.version, + tav_snp_attestation_report_version(attestation.get()), pal::snp::minimum_attestation_version)); } // As cpuid -> attestation cpuid is surjective, we must use the local // cpuid and validate it against the attestation's cpuid auto cpuid = pal::snp::get_cpuid_untrusted(); if ( - cpuid.get_family_id() != attestation.cpuid_fam_id || - cpuid.get_model_id() != attestation.cpuid_mod_id || - cpuid.stepping != attestation.cpuid_step) + cpuid.get_family_id() != + tav_snp_attestation_report_cpuid_fam_id(attestation.get()) || + cpuid.get_model_id() != + tav_snp_attestation_report_cpuid_mod_id(attestation.get()) || + cpuid.stepping != + tav_snp_attestation_report_cpuid_step(attestation.get())) { throw std::runtime_error(fmt::format( "CPU-sourced cpuid does not match attestation cpuid ({} != {}, {}, " "{})", cpuid.hex_str(), - attestation.cpuid_fam_id, - attestation.cpuid_mod_id, - attestation.cpuid_step)); + tav_snp_attestation_report_cpuid_fam_id(attestation.get()), + tav_snp_attestation_report_cpuid_mod_id(attestation.get()), + tav_snp_attestation_report_cpuid_step(attestation.get()))); } auto* h = tx.wo(Tables::SNP_TCB_VERSIONS); auto product = pal::snp::get_sev_snp_product(cpuid); - h->put(cpuid.hex_str(), attestation.reported_tcb.to_policy(product)); + const uint8_t* data = nullptr; + size_t size = 0; + tav_snp_attestation_report_reported_tcb(attestation.get(), &data, &size); + h->put( + cpuid.hex_str(), + pal::snp::TcbVersionRaw({data, size}).to_policy(product)); } static void init_configuration( diff --git a/src/service/tables/identity_types.h b/src/service/tables/identity_types.h index 5b215c441387..3efb114e6f0a 100644 --- a/src/service/tables/identity_types.h +++ b/src/service/tables/identity_types.h @@ -12,6 +12,8 @@ namespace ccf { + // Preserve the existing 64-bit C++ representation. + // NOLINTNEXTLINE(performance-enum-size) enum class IdentityType : uint64_t { CLASSICAL = 0, diff --git a/src/service/tables/signatures.h b/src/service/tables/signatures.h index cf9f8a758c50..94e6ff7d3dec 100644 --- a/src/service/tables/signatures.h +++ b/src/service/tables/signatures.h @@ -4,7 +4,9 @@ #include "ccf/service/map.h" #include "node_signature.h" +#include "service/tables/identity_types.h" +#include #include #include @@ -62,9 +64,26 @@ namespace ccf ccf::kv::RawCopySerialisedValue>; using CoseSignature = std::vector; + using CoseSignatureMap = std::map; - // Most recent COSE signature is a single Value in the KV - using CoseSignatures = ServiceValue; + // One COSE signature per service signing identity. CLASSICAL is 0, so its + // key serialises to the same 8 zero bytes as the single-value table which + // preceded multiple signing identities. + using CoseSignatures = ServiceMap; + + inline CoseSignatureMap extract_cose_signatures( + const CoseSignatures::Write& writes) + { + CoseSignatureMap signatures; + for (const auto& [identity_type, signature] : writes) + { + if (signature.has_value()) + { + signatures.emplace(identity_type, signature.value()); + } + } + return signatures; + } namespace Tables { diff --git a/src/tasks/job_board.cpp b/src/tasks/job_board.cpp index daefcc2f2d9c..d25c39155549 100644 --- a/src/tasks/job_board.cpp +++ b/src/tasks/job_board.cpp @@ -47,10 +47,9 @@ namespace ccf::tasks using DelayedTasksByTime = std::map; - std::atomic total_elapsed = - std::chrono::milliseconds(0); - ccf::ds::Mutex tasks_mutex; + std::chrono::milliseconds total_elapsed CCF_GUARDED_BY(tasks_mutex) = + std::chrono::milliseconds(0); DelayedTasksByTime tasks CCF_GUARDED_BY(tasks_mutex); }; @@ -162,16 +161,17 @@ namespace ccf::tasks { ccf::ds::MutexGuard lock(delayed.tasks_mutex); - const auto trigger_time = delayed.total_elapsed.load() + initial_delay; + const auto trigger_time = delayed.total_elapsed + initial_delay; delayed.tasks[trigger_time].emplace_back(task, periodic_delay); } void tick(std::chrono::milliseconds elapsed) { - elapsed += delayed.total_elapsed.load(); - { ccf::ds::MutexGuard lock(delayed.tasks_mutex); + elapsed += delayed.total_elapsed; + delayed.total_elapsed = elapsed; + auto end_it = delayed.tasks.upper_bound(elapsed); Delayed::DelayedTasksByTime repeats; @@ -210,8 +210,6 @@ namespace ccf::tasks repeated_tasks.end()); } } - - delayed.total_elapsed.store(elapsed); } }; diff --git a/tests/e2e_common_endpoints.py b/tests/e2e_common_endpoints.py index f95a5add1039..a7454a98a5d4 100644 --- a/tests/e2e_common_endpoints.py +++ b/tests/e2e_common_endpoints.py @@ -220,14 +220,15 @@ def run_large_message_test( metrics_name, length, *args, + path="/node/commit", **kwargs, ): with primary.client("user0") as client: - before_errors_count = get_main_interface_errors()[metrics_name] + before_errors = get_main_interface_errors() # Note: endpoint does not matter as request parsing is done before dispatch try: r = client.get( - "/node/commit", + path, *args, **kwargs, ) @@ -235,22 +236,17 @@ def run_large_message_test( # In some cases, the client ends up writing to the now-closed socket first # before reading the server error, resulting in a connection error assert length > threshold - assert ( - get_main_interface_errors()[metrics_name] == before_errors_count + 1 - ) else: if length > threshold: assert r.status_code == expected_status.value assert r.body.json()["error"]["code"] == expected_code - assert ( - get_main_interface_errors()[metrics_name] - == before_errors_count + 1 - ) else: assert r.status_code == http.HTTPStatus.OK.value - assert ( - get_main_interface_errors()[metrics_name] == before_errors_count - ) + + expected_errors = before_errors.copy() + if length > threshold: + expected_errors[metrics_name] += 1 + assert get_main_interface_errors() == expected_errors def get_sizes(n, http2): ns = [n // 2, n - 10, n - 1, n, n + 1, n + 10, n * 2] @@ -296,6 +292,31 @@ def get_sizes(n, http2): headers={long_header: "some header value"}, ) + if not args.http2: + for size in ( + args.max_http_request_target_size - 1, + args.max_http_request_target_size, + args.max_http_request_target_size + 1, + ): + prefix = "/node/commit?padding=" + if size < len(prefix): + LOG.warning( + f"Skipping {size} byte request target: the test endpoint " + f"requires at least {len(prefix)} bytes" + ) + continue + target = prefix + "a" * (size - len(prefix)) + assert len(target) == size + LOG.info(f"Verifying cap on request target, sending a {size} byte target") + run_large_message_test( + args.max_http_request_target_size, + http.HTTPStatus.REQUEST_URI_TOO_LONG, + "RequestTargetTooLong", + "request_target_too_long", + len(target), + path=target, + ) + # Note: infra generally inserts extra headers (eg, content type and length, user-agent, accept) extra_headers_count = infra.clients.CCFClient.default_impl_type.extra_headers_count( args.http2 diff --git a/tests/e2e_operations.py b/tests/e2e_operations.py index 67006f397322..3e1a369637f1 100644 --- a/tests/e2e_operations.py +++ b/tests/e2e_operations.py @@ -4314,6 +4314,30 @@ def run_backup_snapshot_cleanup(const_args): test_backup_snapshot_cleanup(network, args) +def copy_ledger_chunk_to_read_only_dir(src, dst, mutate=None): + """ + Copy a ledger chunk into a read-only ledger directory without exposing + a partial or transient copy under its final name. Ledger cleanup deletes + the source as soon as a digest-identical copy is visible in the read-only + directory, so the copy is published atomically once the source is no + longer needed and, if a mutate callback is given, only after the callback + has been applied to the temporary copy. + """ + with tempfile.NamedTemporaryFile( + dir=os.path.dirname(dst), delete=False + ) as tmp_file: + tmp_path = tmp_file.name + + try: + shutil.copyfile(src, tmp_path) + if mutate is not None: + mutate(tmp_path) + os.replace(tmp_path, dst) + finally: + if os.path.exists(tmp_path): + os.unlink(tmp_path) + + def test_max_committed_ledger_chunk_files(network, args, read_only_ledger_dir): """ Verify that the periodic cleanup timer deletes committed ledger chunks @@ -4340,7 +4364,8 @@ def copy_new_committed_to_readonly(): if f.startswith("ledger_") and ccf.ledger.is_ledger_chunk_committed(f): dst = os.path.join(read_only_ledger_dir, f) if not os.path.exists(dst): - shutil.copy2(os.path.join(main_ledger_dir, f), dst) + src = os.path.join(main_ledger_dir, f) + copy_ledger_chunk_to_read_only_dir(src, dst) def wait_for_cleanup(max_count, timeout=15): end_time = time.time() + timeout @@ -4414,9 +4439,8 @@ def run_max_committed_ledger_chunk_files(const_args): main_ledger_dir = primary.get_main_ledger_dir() for f in os.listdir(main_ledger_dir): if f.startswith("ledger_") and ccf.ledger.is_ledger_chunk_committed(f): - shutil.copy2( - os.path.join(main_ledger_dir, f), - os.path.join(tmp_dir, f), + copy_ledger_chunk_to_read_only_dir( + os.path.join(main_ledger_dir, f), os.path.join(tmp_dir, f) ) test_max_committed_ledger_chunk_files(network, args, tmp_dir) @@ -4469,7 +4493,7 @@ def get_committed_chunks(d): for f in committed[:num_to_backup]: src = os.path.join(main_ledger_dir, f) dst = os.path.join(read_only_ledger_dir, f) - shutil.copy2(src, dst) + copy_ledger_chunk_to_read_only_dir(src, dst) backed_up.append(f) LOG.info(f"Backed up {f} to read-only dir") @@ -4556,18 +4580,21 @@ def get_committed_chunks(d): LOG.warning("Not enough committed chunks to test cleanup, skipping") return network - # Copy oldest chunk to read-only dir, but corrupt it + # Copy oldest chunk to read-only dir, but corrupt it. The corruption is + # applied before the copy is published under its final name: a transient + # digest-identical copy would legitimately allow cleanup to delete the + # source, making the assertion below fail spuriously. target_chunk = committed[0] src = os.path.join(main_ledger_dir, target_chunk) dst = os.path.join(read_only_ledger_dir, target_chunk) - shutil.copy2(src, dst) - # Corrupt the read-only copy by flipping a byte - with open(dst, "r+b") as f: - f.seek(0) - original_byte = f.read(1) - f.seek(0) - f.write(bytes([original_byte[0] ^ 0xFF])) + def flip_first_byte(path): + with open(path, "r+b") as f: + original_byte = f.read(1) + f.seek(0) + f.write(bytes([original_byte[0] ^ 0xFF])) + + copy_ledger_chunk_to_read_only_dir(src, dst, mutate=flip_first_byte) LOG.info(f"Corrupted read-only copy of {target_chunk}") @@ -4624,7 +4651,8 @@ def copy_new_committed_to_readonly(): if f.startswith("ledger_") and ccf.ledger.is_ledger_chunk_committed(f): dst = os.path.join(read_only_ledger_dir, f) if not os.path.exists(dst): - shutil.copy2(os.path.join(main_ledger_dir, f), dst) + src = os.path.join(main_ledger_dir, f) + copy_ledger_chunk_to_read_only_dir(src, dst) def get_latest_committed_snapshot_seqno(): best = None @@ -4743,10 +4771,9 @@ def run_post_snapshot_chunk_retention(const_args): main_ledger_dir = primary.get_main_ledger_dir() for f in os.listdir(main_ledger_dir): if f.startswith("ledger_") and ccf.ledger.is_ledger_chunk_committed(f): - shutil.copy2( - os.path.join(main_ledger_dir, f), - os.path.join(tmp_dir, f), - ) + src = os.path.join(main_ledger_dir, f) + dst = os.path.join(tmp_dir, f) + copy_ledger_chunk_to_read_only_dir(src, dst) test_post_snapshot_chunks_retained(network, args, tmp_dir) diff --git a/tests/infra/e2e_args.py b/tests/infra/e2e_args.py index 7c0b2d4e8a84..bfc2b297dea1 100644 --- a/tests/infra/e2e_args.py +++ b/tests/infra/e2e_args.py @@ -77,6 +77,9 @@ "max_http_header_size": ( "network.rpc_interfaces.*.http_configuration.max_header_size" ), + "max_http_request_target_size": ( + "network.rpc_interfaces.*.http_configuration.max_request_target_size" + ), "max_http_headers_count": ( "network.rpc_interfaces.*.http_configuration.max_headers_count" ), @@ -143,6 +146,7 @@ def _convert_curve_id(value): "curve_id": _convert_curve_id, "max_http_body_size": _convert_size_string_to_bytes, "max_http_header_size": _convert_size_string_to_bytes, + "max_http_request_target_size": _convert_size_string_to_bytes, "http2": lambda value: value == "HTTP2", "tick_ms": lambda value: _convert_time_string(value, "ms"), } @@ -642,6 +646,12 @@ def cli_args( help="Maximum allowed size of single header in single HTTP request", default=1024 * 16, # 16KB ) + parser.add_argument( + "--max-http-request-target-size", + help="Maximum allowed size of an HTTP/1.x request target, including the query", + default=infra.interfaces.DEFAULT_MAX_HTTP_REQUEST_TARGET_SIZE, + type=int, + ) parser.add_argument( "--max-http-headers-count", help="Maximum number of headers in single HTTP request", diff --git a/tests/infra/interfaces.py b/tests/infra/interfaces.py index 0c284a5d2f98..aa0477b1193c 100644 --- a/tests/infra/interfaces.py +++ b/tests/infra/interfaces.py @@ -27,6 +27,7 @@ def make_address(host, port=0): DEFAULT_MAX_HTTP_BODY_SIZE = 1024 * 1024 DEFAULT_MAX_HTTP_HEADER_SIZE = 16 * 1024 +DEFAULT_MAX_HTTP_REQUEST_TARGET_SIZE = 16 * 1024 DEFAULT_MAX_HTTP_HEADERS_COUNT = 256 DEFAULT_MAX_CONCURRENT_STREAMS_COUNT = 100 @@ -180,6 +181,9 @@ class RPCInterface(Interface): max_http_header_size: int | None = field( default_factory=lambda: DEFAULT_MAX_HTTP_HEADER_SIZE ) + max_http_request_target_size: int | None = field( + default_factory=lambda: DEFAULT_MAX_HTTP_REQUEST_TARGET_SIZE + ) max_http_headers_count: int | None = field( default_factory=lambda: DEFAULT_MAX_HTTP_HEADERS_COUNT ) @@ -204,6 +208,7 @@ def apply_args(self, args): self.max_open_sessions_hard = args.max_open_sessions_hard self.max_http_body_size = args.max_http_body_size self.max_http_header_size = args.max_http_header_size + self.max_http_request_target_size = args.max_http_request_target_size self.max_http_headers_count = args.max_http_headers_count self.forwarding_timeout_ms = args.forwarding_timeout_ms self.app_protocol = "HTTP2" if args.http2 else "HTTP1" @@ -227,6 +232,7 @@ def to_json(interface): http_config = { "max_body_size": str(interface.max_http_body_size), "max_header_size": str(interface.max_http_header_size), + "max_request_target_size": str(interface.max_http_request_target_size), "max_headers_count": interface.max_http_headers_count, } if interface.app_protocol == "HTTP2": diff --git a/tests/infra/remote.py b/tests/infra/remote.py index 31522f11a2c3..e55018104a68 100644 --- a/tests/infra/remote.py +++ b/tests/infra/remote.py @@ -580,6 +580,11 @@ def __init__( ) # Configuration file + v = ( + ccf._versionifier.to_python_version(version) + if version is not None + else None + ) if config_file: LOG.info( f"Node {self.local_node_id}: Using configuration file {config_file}" @@ -657,6 +662,13 @@ def __init__( # This will also ensure the render produced valid JSON j = json.loads(output) + # Releases before 7.0.16 reject this unknown HTTP configuration field. + if v is not None and v < Version("7.0.16"): + for interface in j["network"]["rpc_interfaces"].values(): + interface["http_configuration"].pop( + "max_request_target_size", None + ) + # Enclave config removed from 7.x onwards. if major_version is not None and major_version < 7: enclave_platform = infra.platform_detection.get_platform() @@ -695,11 +707,6 @@ def __init__( os.path.basename(config_file), ] - v = ( - ccf._versionifier.to_python_version(version) - if version is not None - else None - ) if v is None or v >= Version("7.0.0.dev0"): cmd += [ "--log-level", diff --git a/tests/js-custom-authorization/custom_authorization.py b/tests/js-custom-authorization/custom_authorization.py index 1a528de70629..e82684059722 100644 --- a/tests/js-custom-authorization/custom_authorization.py +++ b/tests/js-custom-authorization/custom_authorization.py @@ -14,6 +14,7 @@ from functools import partial from http import HTTPStatus +import infra.clients import infra.e2e_args import infra.net import infra.network @@ -1428,6 +1429,45 @@ def make_body(): return network +@reqs.description("Historical state remains available through response conversion") +def test_historical_response_conversion(network, args): + primary, _ = network.find_nodes() + with primary.client() as c: + writes = [] + for _ in range(2): + r = c.post("/app/increment") + assert r.status_code == http.HTTPStatus.OK, r + c.wait_for_commit(r) + writes.append(r) + + for write in writes: + expected_value = write.body.json()["value"] + for path in ("/app/historical", f"/app/historical/range/{write.seqno}"): + headers = { + infra.clients.CCF_TX_ID_HEADER: f"{write.view}.{write.seqno}" + } + timeout = time.time() + 10 + while True: + r = c.get(path, headers=headers) + if r.status_code != http.HTTPStatus.ACCEPTED: + break + assert time.time() < timeout, r + time.sleep(0.1) + + assert r.status_code == http.HTTPStatus.OK, r + assert r.body.json() == {"value": expected_value}, r + assert r.headers["x-historical-value"] == str(expected_value), r + + for fail in ("body", "json"): + r = c.get(path, headers={**headers, "x-throw": fail}) + assert r.status_code == http.HTTPStatus.INTERNAL_SERVER_ERROR, r + r = c.get(path, headers=headers) + assert r.status_code == http.HTTPStatus.OK, r + assert r.body.json() == {"value": expected_value}, r + + return network + + def test_caching_of_app_code(network, args): primary, backups = network.find_nodes() LOG.info( @@ -1471,6 +1511,7 @@ def run_interpreter_reuse(args): network = test_reused_interpreter_behaviour(network, args) network = test_caching_of_kv_handles(network, args) + network = test_historical_response_conversion(network, args) network = test_caching_of_app_code(network, args) diff --git a/tests/js-interpreter-reuse/app.json b/tests/js-interpreter-reuse/app.json index 77cacc0f3402..4cd9e1526c72 100644 --- a/tests/js-interpreter-reuse/app.json +++ b/tests/js-interpreter-reuse/app.json @@ -95,6 +95,34 @@ } } }, + "/historical": { + "get": { + "js_module": "global_handle.js", + "js_function": "historical", + "forwarding_required": "never", + "redirection_strategy": "none", + "authn_policies": ["no_auth"], + "mode": "historical", + "openapi": {}, + "interpreter_reuse": { + "key": "historical_response" + } + } + }, + "/historical/range/{seqno}": { + "get": { + "js_module": "global_handle.js", + "js_function": "historical", + "forwarding_required": "never", + "redirection_strategy": "none", + "authn_policies": ["no_auth"], + "mode": "readonly", + "openapi": {}, + "interpreter_reuse": { + "key": "historical_response" + } + } + }, "/func_caching": { "get": { "js_module": "func_caching.js", diff --git a/tests/js-interpreter-reuse/src/global_handle.ts b/tests/js-interpreter-reuse/src/global_handle.ts index e709e6699b6d..23b04aa71af2 100644 --- a/tests/js-interpreter-reuse/src/global_handle.ts +++ b/tests/js-interpreter-reuse/src/global_handle.ts @@ -80,4 +80,52 @@ function increment() { return { body: { value: v } }; } -export { globals, increment }; +function historical(request) { + const seqno = Number(request.params.seqno); + const getState = request.params.seqno + ? () => ccf.historical.getStateRange(seqno, seqno, seqno, 180)?.[0] + : () => ccf.historicalState; + if (!getState()) { + return { statusCode: 202 }; + } + + const read = () => + ccfapp.uint32.decode( + getState().kv["public:cached_handle_table"].get( + ccfapp.string.encode("single_key"), + ), + ); + const fail = request.headers["x-throw"]; + + // These reads happen after the endpoint handler has returned. + return { + get body() { + read(); + if (fail === "body") { + throw new Error("Historical response body failure"); + } + return { + toJSON() { + const value = read(); + if (fail === "json") { + throw new Error("Historical response JSON failure"); + } + return { value }; + }, + }; + }, + get headers() { + return { + get "x-historical-value"() { + return `${read()}`; + }, + }; + }, + get statusCode() { + read(); + return 200; + }, + }; +} + +export { globals, increment, historical }; diff --git a/tests/npm_tests.py b/tests/npm_tests.py index ff7877d09dc5..9311c7c8df86 100644 --- a/tests/npm_tests.py +++ b/tests/npm_tests.py @@ -746,6 +746,12 @@ def test_npm_app(network, args): assert r.status_code == http.HTTPStatus.OK, r.status_code report_json = r.body.json()["attestation"] print(f"{report_json=}") + raw_report = b64decode(reference_quote["raw"]) + expected_current_build = raw_report[0x1E8] + expected_current_minor = raw_report[0x1E9] + assert expected_current_build != expected_current_minor + assert report_json["current_build"] == expected_current_build + assert report_json["current_minor"] == expected_current_minor assert report_json[ "report_data" ] == "7a6a68c0a2b85b8aae00ca04f644831680222f44167e5558a9e072b70c60e958" + ( diff --git a/tla/disaster-recovery/.gitignore b/tla/disaster-recovery/.gitignore deleted file mode 100644 index eb5a316cbd19..000000000000 --- a/tla/disaster-recovery/.gitignore +++ /dev/null @@ -1 +0,0 @@ -target diff --git a/tla/disaster-recovery/Cargo.lock b/tla/disaster-recovery/Cargo.lock deleted file mode 100644 index 9666614b5e3e..000000000000 --- a/tla/disaster-recovery/Cargo.lock +++ /dev/null @@ -1,592 +0,0 @@ -# This file is automatically @generated by Cargo. -# It is not intended for manual editing. -version = 4 - -[[package]] -name = "ahash" -version = "0.8.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" -dependencies = [ - "cfg-if", - "getrandom", - "once_cell", - "version_check", - "zerocopy", -] - -[[package]] -name = "anstream" -version = "0.6.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "301af1932e46185686725e0fad2f8f2aa7da69dd70bf6ecc44d6b703844a3933" -dependencies = [ - "anstyle", - "anstyle-parse", - "anstyle-query", - "anstyle-wincon", - "colorchoice", - "is_terminal_polyfill", - "utf8parse", -] - -[[package]] -name = "anstyle" -version = "1.0.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "862ed96ca487e809f1c8e5a8447f6ee2cf102f846893800b20cebdf541fc6bbd" - -[[package]] -name = "anstyle-parse" -version = "0.2.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" -dependencies = [ - "utf8parse", -] - -[[package]] -name = "anstyle-query" -version = "1.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c8bdeb6047d8983be085bab0ba1472e6dc604e7041dbf6fcd5e71523014fae9" -dependencies = [ - "windows-sys", -] - -[[package]] -name = "anstyle-wincon" -version = "3.0.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "403f75924867bb1033c59fbf0797484329750cfbe3c4325cd33127941fabc882" -dependencies = [ - "anstyle", - "once_cell_polyfill", - "windows-sys", -] - -[[package]] -name = "ascii" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16" - -[[package]] -name = "autocfg" -version = "1.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" - -[[package]] -name = "bitflags" -version = "2.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b8e56985ec62d17e9c1001dc89c88ecd7dc08e47eba5ec7c29c7b5eeecde967" - -[[package]] -name = "ccf-selfhealingopen" -version = "0.0.0" -dependencies = [ - "clap", - "stateright", -] - -[[package]] -name = "cfg-if" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9555578bc9e57714c812a1f84e4fc5b4d21fcb063490c624de019f7464c91268" - -[[package]] -name = "choice" -version = "0.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a3b71fc821deaf602a933ada5c845d088156d0cdf2ebf43ede390afe93466553" - -[[package]] -name = "chunked_transfer" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e4de3bc4ea267985becf712dc6d9eed8b04c953b3fcfb339ebc87acd9804901" - -[[package]] -name = "clap" -version = "4.5.40" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40b6887a1d8685cebccf115538db5c0efe625ccac9696ad45c409d96566e910f" -dependencies = [ - "clap_builder", - "clap_derive", -] - -[[package]] -name = "clap_builder" -version = "4.5.40" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0c66c08ce9f0c698cbce5c0279d0bb6ac936d8674174fe48f736533b964f59e" -dependencies = [ - "anstream", - "anstyle", - "clap_lex", - "strsim", -] - -[[package]] -name = "clap_derive" -version = "4.5.40" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2c7947ae4cc3d851207c1adb5b5e260ff0cca11446b1d6d1423788e442257ce" -dependencies = [ - "heck", - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "clap_lex" -version = "0.7.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b94f61472cee1439c0b966b47e3aca9ae07e45d070759512cd390ea2bebc6675" - -[[package]] -name = "colorchoice" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" - -[[package]] -name = "crossbeam-utils" -version = "0.8.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" - -[[package]] -name = "dashmap" -version = "6.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf" -dependencies = [ - "cfg-if", - "crossbeam-utils", - "hashbrown", - "lock_api", - "once_cell", - "parking_lot_core", -] - -[[package]] -name = "getrandom" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4" -dependencies = [ - "cfg-if", - "libc", - "r-efi", - "wasi", -] - -[[package]] -name = "hashbrown" -version = "0.14.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" - -[[package]] -name = "heck" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" - -[[package]] -name = "httpdate" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" - -[[package]] -name = "id-set" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9633fadf6346456cf8531119ba4838bc6d82ac4ce84d9852126dd2aa34d49264" - -[[package]] -name = "is_terminal_polyfill" -version = "1.70.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" - -[[package]] -name = "itoa" -version = "1.0.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" - -[[package]] -name = "libc" -version = "0.2.173" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8cfeafaffdbc32176b64fb251369d52ea9f0a8fbc6f8759edffef7b525d64bb" - -[[package]] -name = "lock_api" -version = "0.4.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96936507f153605bddfcda068dd804796c84324ed2510809e5b2a624c81da765" -dependencies = [ - "autocfg", - "scopeguard", -] - -[[package]] -name = "log" -version = "0.4.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" - -[[package]] -name = "memchr" -version = "2.7.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0" - -[[package]] -name = "nohash-hasher" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" - -[[package]] -name = "once_cell" -version = "1.21.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" - -[[package]] -name = "once_cell_polyfill" -version = "1.70.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4895175b425cb1f87721b59f0f286c2092bd4af812243672510e1ac53e2e0ad" - -[[package]] -name = "parking_lot" -version = "0.12.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70d58bf43669b5795d1576d0641cfb6fbb2057bf629506267a92807158584a13" -dependencies = [ - "lock_api", - "parking_lot_core", -] - -[[package]] -name = "parking_lot_core" -version = "0.9.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc838d2a56b5b1a6c25f55575dfc605fabb63bb2365f6c2353ef9159aa69e4a5" -dependencies = [ - "cfg-if", - "libc", - "redox_syscall", - "smallvec", - "windows-targets", -] - -[[package]] -name = "ppv-lite86" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" -dependencies = [ - "zerocopy", -] - -[[package]] -name = "proc-macro2" -version = "1.0.95" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02b3e5e68a3a1a02aad3ec490a98007cbc13c37cbe84a3cd7b8e406d76e7f778" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "quote" -version = "1.0.40" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" -dependencies = [ - "proc-macro2", -] - -[[package]] -name = "r-efi" -version = "5.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "74765f6d916ee2faa39bc8e68e4f3ed8949b48cccdac59983d287a7cb71ce9c5" - -[[package]] -name = "rand" -version = "0.9.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" -dependencies = [ - "rand_chacha", - "rand_core", -] - -[[package]] -name = "rand_chacha" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" -dependencies = [ - "ppv-lite86", - "rand_core", -] - -[[package]] -name = "rand_core" -version = "0.9.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" -dependencies = [ - "getrandom", -] - -[[package]] -name = "redox_syscall" -version = "0.5.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d04b7d0ee6b4a0207a0a7adb104d23ecb0b47d6beae7152d0fa34b692b29fd6" -dependencies = [ - "bitflags", -] - -[[package]] -name = "ryu" -version = "1.0.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" - -[[package]] -name = "scopeguard" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" - -[[package]] -name = "serde" -version = "1.0.219" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" -dependencies = [ - "serde_derive", -] - -[[package]] -name = "serde_derive" -version = "1.0.219" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "serde_json" -version = "1.0.140" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373" -dependencies = [ - "itoa", - "memchr", - "ryu", - "serde", -] - -[[package]] -name = "smallvec" -version = "1.15.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" - -[[package]] -name = "stateright" -version = "0.31.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd1157f21b11916f90fe1f2ac9a8d0e09a8813b28701584141060f414eedf6ba" -dependencies = [ - "ahash", - "choice", - "crossbeam-utils", - "dashmap", - "id-set", - "log", - "nohash-hasher", - "parking_lot", - "rand", - "serde", - "serde_json", - "tiny_http", -] - -[[package]] -name = "strsim" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" - -[[package]] -name = "syn" -version = "2.0.103" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e4307e30089d6fd6aff212f2da3a1f9e32f3223b1f010fb09b7c95f90f3ca1e8" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "tiny_http" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "389915df6413a2e74fb181895f933386023c71110878cd0825588928e64cdc82" -dependencies = [ - "ascii", - "chunked_transfer", - "httpdate", - "log", -] - -[[package]] -name = "unicode-ident" -version = "1.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" - -[[package]] -name = "utf8parse" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" - -[[package]] -name = "version_check" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" - -[[package]] -name = "wasi" -version = "0.14.2+wasi-0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9683f9a5a998d873c0d21fcbe3c083009670149a8fab228644b8bd36b2c48cb3" -dependencies = [ - "wit-bindgen-rt", -] - -[[package]] -name = "windows-sys" -version = "0.59.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" -dependencies = [ - "windows-targets", -] - -[[package]] -name = "windows-targets" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" -dependencies = [ - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_gnullvm", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", -] - -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" - -[[package]] -name = "windows_i686_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" - -[[package]] -name = "windows_i686_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" - -[[package]] -name = "windows_i686_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" - -[[package]] -name = "wit-bindgen-rt" -version = "0.39.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1" -dependencies = [ - "bitflags", -] - -[[package]] -name = "zerocopy" -version = "0.8.25" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1702d9583232ddb9174e01bb7c15a2ab8fb1bc6f227aa1233858c351a3ba0cb" -dependencies = [ - "zerocopy-derive", -] - -[[package]] -name = "zerocopy-derive" -version = "0.8.25" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28a6e20d751156648aa063f3800b706ee209a32c0b4d9f24be3d980b01be55ef" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] diff --git a/tla/disaster-recovery/Cargo.toml b/tla/disaster-recovery/Cargo.toml deleted file mode 100644 index 92950edbfb19..000000000000 --- a/tla/disaster-recovery/Cargo.toml +++ /dev/null @@ -1,7 +0,0 @@ -[package] -name = "ccf-selfhealingopen" -version = "0.0.0" - -[dependencies] -clap = { version = "4.5.38", features = ["derive"] } -stateright = "0.31.0" diff --git a/tla/disaster-recovery/Readme.md b/tla/disaster-recovery/Readme.md deleted file mode 100644 index e337787e0027..000000000000 --- a/tla/disaster-recovery/Readme.md +++ /dev/null @@ -1,11 +0,0 @@ -# Self-healing-open specification in [stateright](https://github.com/stateright/stateright) - -The properties are specified in [main.rs](./src/main.rs), while the model is specified in [model.rs](./src/model.rs). - -Due to stateright being executable, there is little syntactic sugar, and so there is quite a bit of boilerplate. -The functional parts of the specification are in `advance_step`, `on_start`, `on_timeout` and `on_msg`. - -The specification can be checked from the command line via `cargo run check`. - -However, a more useful UX is via the web-view which is hosted locally via `cargo run serve`. -This allows you to explore the specification actions interactively, and the checker can be exhaustively run using the `Run to completion` button, which should find several useful examples of states where the network is opened, and where a deadlock is reached. diff --git a/tla/disaster-recovery/src/main.rs b/tla/disaster-recovery/src/main.rs deleted file mode 100644 index d92767d1a0f0..000000000000 --- a/tla/disaster-recovery/src/main.rs +++ /dev/null @@ -1,244 +0,0 @@ -extern crate clap; -extern crate stateright; -use clap::Parser; -mod model; -use model::{ModelCfg, Msg, NextStep, Node, State}; -use stateright::{actor::*, report::WriteReporter, util::HashableHashSet, Checker, Model}; -use std::sync::Arc; - -fn implies(a: bool, b: bool) -> bool { - !a || b -} - -fn reached_open(state: &ActorModelState) -> bool { - state - .actor_states - .iter() - .any(|actor_state: &Arc| matches!(actor_state.next_step, NextStep::Open { .. })) -} - -fn reached_open_timeout(state: &ActorModelState, expected_to_timeout: bool) -> bool { - state.actor_states.iter().any(|actor_state: &Arc| { - matches! ( - actor_state.next_step, - NextStep::Open {timeout} if timeout == expected_to_timeout - ) - }) -} - -fn unanimous_votes(model: &ActorModel, state: &ActorModelState) -> bool { - let peers: HashableHashSet = (0..model.cfg.n_nodes) - .map(|i| Id::from(i as usize)) - .collect(); - state.actor_states.iter().all(|actor_state: &Arc| { - actor_state.submitted_vote.is_some() - && peers.iter().all(|peer| { - actor_state - .submitted_vote - .clone() - .unwrap() - .1 - .recv - .iter() - .any(|g| g.src == *peer) - }) - }) -} - -fn majority_have_same_maximum(state: &ActorModelState) -> bool { - // get the chosen replica of each replica into a vector and sort that vector - // that there is only one value up to the n/2th index - let mut chosen_replicas: Vec = state - .actor_states - .iter() - .filter(|actor_state: &&Arc| actor_state.submitted_vote.is_some()) - .map(|actor_state| { - actor_state - .submitted_vote - .clone() - .unwrap() - .1 - .recv - .iter() - .max_by_key(|g| (g.txid, g.src)) - .unwrap() - .src - }) - .collect(); - chosen_replicas.sort(); - let majority_idx = state.actor_states.len() / 2; - let majority_chosen_replica = chosen_replicas.get(majority_idx); - majority_chosen_replica.is_some() - && chosen_replicas[0..majority_idx] - .iter() - .all(|&r| r == *majority_chosen_replica.unwrap()) -} - -fn liveness_properties(model: ActorModel) -> ActorModel { - let model = model - .property( - stateright::Expectation::Eventually, - "Unanimous votes => no chance of a fork", - |model: &ActorModel, state: &ActorModelState| { - // Define deadlock as a path which does not reach open without - // Hence unanimous votes => reach open - // Hence on every path unanimous votes => <> reached open - // Since votes are not forgotten on a node, we check for a state where unanimous votes => reached open - return implies( - unanimous_votes(model, state), - reached_open_timeout(state, false), - ); - }, - ) - .property( - stateright::Expectation::Eventually, - "Open", - |_, state: &ActorModelState| { - // all runs should eventually open, either via the reliable method, or via the failover timeout - reached_open(state) - }, - ) - .property( - stateright::Expectation::Eventually, - "Majority votes => no fork", - |_, state: &ActorModelState| { - return implies( - majority_have_same_maximum(state), - reached_open_timeout(state, false), - ); - }, - ); - return model; -} - -fn invariant_properties(model: ActorModel) -> ActorModel { - let model = model - .property( - stateright::Expectation::Always, - "No open with timeout, no fork", - |_model: &ActorModel, state: &ActorModelState| { - // Check if there is no fork in the state - let open_node_count = state - .actor_states - .iter() - .filter(|actor_state: &&Arc| { - matches!(actor_state.next_step, NextStep::Open { .. }) - }) - .count(); - implies(!reached_open_timeout(state, true), open_node_count <= 1) - }, - ) - .property( - stateright::Expectation::Always, - "Deadlock", - |_model, state| { - let all_open_join = state - .actor_states - .iter() - .all(|actor_state: &Arc| actor_state.next_step == NextStep::OpenJoin); - let all_votes_delivered = state - .network - .iter_all() - .filter(|msg| matches!(msg.msg, Msg::Vote(_))) - .count() - == 0; - !(all_open_join && all_votes_delivered) - }, - ) - .property( - stateright::Expectation::Always, - "Persist committed txs", - |_model: &ActorModel, state: &ActorModelState| { - let majority_idx = state.actor_states.len() / 2; - let commit_txid = state - .actor_states - .iter() - .map(|actor_state| actor_state.txid) - .collect::>()[majority_idx]; - let cond = state - .actor_states - .iter() - .filter(|actor_state: &&Arc| { - matches!(actor_state.next_step, NextStep::Open { .. }) - }) - .all(|actor_state: &Arc| actor_state.txid >= commit_txid); - implies(!reached_open_timeout(state, true), cond) - }, - ); - return model; -} - -fn reachable_properties(model: ActorModel) -> ActorModel { - let model = model - .property( - stateright::Expectation::Sometimes, - "Open is possible", - |_, state| implies(state.actor_states.len() > 1, reached_open(state)), - ) - .property( - stateright::Expectation::Sometimes, - "Unsafe open with timeout", - |_, state| reached_open_timeout(state, true), - ) - .property( - stateright::Expectation::Sometimes, - "Majority vote still opens without timeout", - |_model, state| majority_have_same_maximum(state) && reached_open_timeout(state, false), - ); - return model; -} - -fn properties(model: ActorModel) -> ActorModel { - let model = liveness_properties(model); - let model = invariant_properties(model); - let model = reachable_properties(model); - return model; -} - -#[derive(Parser, Debug)] -#[command(version, about = "Model for CCF's self-healing-open", long_about = None)] -struct CliArgs { - #[clap(short, long, default_value = "3")] - n_nodes: usize, - - #[command(subcommand)] - command: Commands, -} - -#[derive(Parser, Debug)] -enum Commands { - /// Check the model - Check, - /// Serve the model on localhost:8080 - Serve, -} - -fn check(model: ActorModel) { - let checker = model - .checker() - .spawn_bfs() - .join_and_report(&mut WriteReporter::new(&mut std::io::stderr())); - checker.assert_properties(); -} - -fn serve(model: ActorModel) { - let checker = model.checker(); - println!("Serving model on http://localhost:8080"); - checker.serve("localhost:8080"); -} - -fn main() { - let args = CliArgs::parse(); - - let model = ModelCfg { - n_nodes: args.n_nodes, - } - .into_model(); - - let model = properties(model); - - match args.command { - Commands::Check => check(model), - Commands::Serve => serve(model), - } -} diff --git a/tla/disaster-recovery/src/model.rs b/tla/disaster-recovery/src/model.rs deleted file mode 100644 index 735db3193186..000000000000 --- a/tla/disaster-recovery/src/model.rs +++ /dev/null @@ -1,193 +0,0 @@ -extern crate stateright; -use stateright::{actor::*, util::HashableHashSet}; -use std::borrow::Cow; - -type Txid = u64; - -#[derive(Debug, Clone, Hash, PartialEq, Eq, PartialOrd, Ord)] -pub struct GossipStruct { - pub src: Id, - pub txid: Txid, -} - -#[derive(Debug, Clone, Hash, PartialEq, Eq, PartialOrd, Ord)] -pub struct VoteStruct { - pub src: Id, - pub recv: HashableHashSet, -} - -#[derive(Debug, Clone, Hash, PartialEq, Eq, PartialOrd, Ord)] -pub enum Msg { - Gossip(GossipStruct), - Vote(VoteStruct), - IAmOpen(Id), -} - -#[derive(Debug, Clone, Hash, PartialEq, Eq, PartialOrd, Ord)] -pub enum Timer { - ElectionTimeout, -} - -#[derive(Debug, Clone, Hash, PartialEq, Eq, PartialOrd, Ord)] -pub enum NextStep { - Vote, - OpenJoin, - Open { timeout: bool }, - Join, -} - -#[derive(Debug, Clone, Hash, PartialEq, Eq, PartialOrd, Ord)] -pub struct State { - pub next_step: NextStep, - pub gossips: HashableHashSet, - pub votes: HashableHashSet, - pub submitted_vote: Option<(Id, VoteStruct)>, - pub txid: Txid, -} - -#[derive(Debug, Clone, Hash, PartialEq, Eq)] -pub struct Node { - pub peers: HashableHashSet, -} - -impl Node { - fn vote_for_max<'a>(gossips: &HashableHashSet, id: Id) -> (Id, VoteStruct) { - let dst = gossips - .iter() - .max_by_key(|g| (g.txid, g.src)) - .unwrap() - .src; - let vote = VoteStruct { - src: id, - recv: gossips.clone(), - }; - return (dst, vote); - } - - fn other_peers(&self, id: Id) -> Vec { - self.peers.iter().filter(|&&p| p != id).cloned().collect() - } - - fn advance_step(&self, state: &mut State, o: &mut Out, id: Id, timeout: bool) -> bool { - match state.next_step { - NextStep::Vote if state.gossips.len() == self.peers.len() || timeout => { - let (dst, vote) = Node::vote_for_max(&state.gossips, id); - state.submitted_vote = Some((dst, vote.clone())); - if dst == id { - state.votes.insert(vote); - } else { - o.send(dst, Msg::Vote(vote)); - } - state.next_step = NextStep::OpenJoin; - return true; - } - NextStep::OpenJoin if state.votes.len() >= (self.peers.len() + 1) / 2 || timeout => { - state.next_step = NextStep::Open { timeout }; - o.broadcast(&self.other_peers(id), &Msg::IAmOpen(id)); - return true; - } - _ => false, - } - } - - fn advance_several(&self, state: &mut State, o: &mut Out, id: Id, timeout: bool) { - while self.advance_step(state, o, id, timeout) {} - } -} - -impl Actor for Node { - type Msg = Msg; - type State = State; - type Timer = Timer; - type Storage = (); - type Random = (); - - fn on_start(&self, id: Id, _storage: &Option, o: &mut Out) -> Self::State { - let txid = usize::from(id) as Txid; // Use id as txid for simplicity - let gossip = GossipStruct { src: id, txid }; - let mut gossips = HashableHashSet::new(); - gossips.insert(gossip.clone()); - let mut state = State { - next_step: NextStep::Vote, - gossips, - votes: HashableHashSet::new(), - submitted_vote: None, - txid: usize::from(id) as Txid, - }; - o.broadcast(&self.other_peers(id), &Msg::Gossip(gossip)); - o.set_timer(Timer::ElectionTimeout, model_timeout()); - self.advance_several(&mut state, o, id, false); - return state; - } - - fn on_timeout(&self, id: Id, state: &mut Cow, timer: &Timer, o: &mut Out) { - match timer { - Timer::ElectionTimeout => match state.next_step { - NextStep::Vote if !state.gossips.is_empty() => { - let state = state.to_mut(); - self.advance_several(state, o, id, true); - o.set_timer(Timer::ElectionTimeout, model_timeout()); - } - NextStep::OpenJoin if !state.votes.is_empty() => { - let state = state.to_mut(); - self.advance_several(state, o, id, true); - } - _ => { - o.set_timer(Timer::ElectionTimeout, model_timeout()); - } - }, - } - } - - fn on_msg( - &self, - id: Id, - state: &mut Cow, - _src: Id, - msg: Self::Msg, - o: &mut Out, - ) { - let state = state.to_mut(); - match msg { - Msg::Gossip(gossip) => { - // Freeze gossip collection after voting is submitted - if !state.gossips.contains(&gossip) && state.submitted_vote.is_none() { - state.gossips.insert(gossip.clone()); - } - } - Msg::Vote(vote) => { - if !state.votes.contains(&vote) { - state.votes.insert(vote); - } - } - Msg::IAmOpen(_) => { - if !matches!(state.next_step, NextStep::Open { .. }) { - state.next_step = NextStep::Join; - } - } - }; - self.advance_several(state, o, id, false); - } -} - -#[derive(Debug, Clone, Hash, PartialEq, Eq)] -pub struct ModelCfg { - pub n_nodes: usize, -} - -impl ModelCfg { - pub fn into_model(self) -> ActorModel { - let peers: HashableHashSet = (0..self.n_nodes).map(|i| Id::from(i as usize)).collect(); - ActorModel::new(self.clone(), ()) - .actors( - (0..self.n_nodes) - .map(|_| Node { - peers: peers.clone(), - }) - .collect::>(), - ) - //.init_network(Network::new_ordered([])) - .init_network(Network::new_unordered_nonduplicating([])) - .lossy_network(LossyNetwork::No) - } -}