From 62a767a52d6573d3a01518472ac1222dafad7be8 Mon Sep 17 00:00:00 2001 From: Zhengchao An Date: Wed, 26 Aug 2026 21:25:02 +0800 Subject: [PATCH] test(connect): add short credential E2E profile (#6664) * feat(connect): add short credential E2E profile * ci(connect): test short credential boundary --- .github/workflows/ci.yml | 58 ++++++++++++++ rustfs/Cargo.toml | 2 + rustfs/src/connect/client.rs | 3 + rustfs/src/connect/registration.rs | 6 ++ rustfs/tests/connect_registration.rs | 108 +++++++++++++++++++++++---- 5 files changed, 161 insertions(+), 16 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index acb3ad106..d7cf207e0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -495,6 +495,64 @@ jobs: cargo nextest run -p rustfs -p rustfs-ecstore --features rio-v2 cargo test -p rustfs --doc --features rio-v2 + connect-short-credential-boundary: + name: Connect Short Credential Boundary + if: github.event_name != 'pull_request' || github.event.action != 'closed' + needs: [ quick-checks ] + runs-on: sm-standard-4 + timeout-minutes: 60 + env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" + steps: + - name: Checkout repository + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + with: + persist-credentials: false + + - name: Setup Rust environment + uses: ./.github/actions/setup + with: + rust-version: stable + cache-shared-key: ci-dev + cache-save-if: 'false' + install-build-packaging-tools: 'false' + install-test-tools: 'false' + + - name: Run short credential behavior tests + env: + CARGO_BUILD_JOBS: "2" + run: | + cargo test -p rustfs --test connect_registration \ + --features connect-e2e-short-credentials \ + registration_enforces_build_profile_credential_lifetime -- --exact + cargo test -p rustfs --test connect_registration \ + --features connect-e2e-short-credentials \ + rotation_waits_for_threshold_and_stops_on_revocation -- --exact + + - name: Reject short credentials in release builds + env: + CARGO_BUILD_JOBS: "2" + run: | + log="$(mktemp)" + set +e + CARGO_TERM_COLOR=never cargo check -p rustfs --release \ + --features connect-e2e-short-credentials >"$log" 2>&1 + status=$? + set -e + cat "$log" + expected='error: connect-e2e-short-credentials is restricted to debug builds' + summary="error: could not compile \`rustfs\` (lib) due to 1 previous error" + expected_count="$(grep -Fxc "$expected" "$log" || true)" + summary_count="$(grep -Fc "$summary" "$log" || true)" + error_count="$(grep -Ec '^error(:|\[)' "$log" || true)" + if [ "$status" -ne 101 ] || [ "$expected_count" -ne 1 ] \ + || [ "$summary_count" -ne 1 ] || [ "$error_count" -ne 2 ]; then + echo "release feature gate did not fail solely at the expected compile_error" >&2 + rm -f "$log" + exit 1 + fi + rm -f "$log" + test-and-lint-protocols: name: "Test and Lint (${{ matrix.features.name }})" if: github.event_name != 'pull_request' || github.event.action != 'closed' diff --git a/rustfs/Cargo.toml b/rustfs/Cargo.toml index a37ebfef8..670c4b395 100644 --- a/rustfs/Cargo.toml +++ b/rustfs/Cargo.toml @@ -57,6 +57,8 @@ tracing-chunk-debug = [] # Enable per-chunk tracing in data plane (high noise, full = ["metrics-gpu", "ftps", "swift", "webdav", "sftp", "pyroscope"] manual-test-runners = [] e2e-test-hooks = [] +# Shortens Connect credentials only in debug E2E builds. +connect-e2e-short-credentials = [] # Builds the dedicated rustfs-cli-e2e target with a build-time public enrollment root. offline-enrollment-e2e-root = [] rio-v2 = ["rustfs-ecstore/rio-v2"] diff --git a/rustfs/src/connect/client.rs b/rustfs/src/connect/client.rs index 114f1d742..4855adb46 100644 --- a/rustfs/src/connect/client.rs +++ b/rustfs/src/connect/client.rs @@ -37,6 +37,9 @@ use super::registration::{ const MAX_ATTEMPTS: usize = 3; const MAX_RESPONSE_BYTES: usize = 1024 * 1024; +#[cfg(feature = "connect-e2e-short-credentials")] +const ROTATION_THRESHOLD_SECONDS: i64 = 120; +#[cfg(not(feature = "connect-e2e-short-credentials"))] const ROTATION_THRESHOLD_SECONDS: i64 = 8 * 60 * 60; const PENDING_REGISTRATION_STATE_DOMAIN: &[u8] = b"RUSTFS-CONNECT-PENDING-REGISTRATION-V1"; diff --git a/rustfs/src/connect/registration.rs b/rustfs/src/connect/registration.rs index 3be43c4ac..82d1dcae4 100644 --- a/rustfs/src/connect/registration.rs +++ b/rustfs/src/connect/registration.rs @@ -38,6 +38,12 @@ use super::identity::{DeviceIdentity, RegistrationProof}; pub const PROTOCOL_VERSION: &str = "v1"; +#[cfg(all(feature = "connect-e2e-short-credentials", not(debug_assertions)))] +compile_error!("connect-e2e-short-credentials is restricted to debug builds"); + +#[cfg(feature = "connect-e2e-short-credentials")] +const CERTIFICATE_LIFETIME_SECONDS: i64 = 300; +#[cfg(not(feature = "connect-e2e-short-credentials"))] const CERTIFICATE_LIFETIME_SECONDS: i64 = 86_400; const ROTATION_DOMAIN: &[u8] = b"RUSTFS-CONNECT-CREDENTIAL-ROTATION-V1"; const MAX_TOKEN_BYTES: u64 = 16 * 1024; diff --git a/rustfs/tests/connect_registration.rs b/rustfs/tests/connect_registration.rs index ff7937ab5..c6d5e8c61 100644 --- a/rustfs/tests/connect_registration.rs +++ b/rustfs/tests/connect_registration.rs @@ -50,6 +50,14 @@ use tokio::sync::watch; use tokio_rustls::TlsAcceptor; use tokio_util::sync::CancellationToken; +#[cfg(feature = "connect-e2e-short-credentials")] +const EXPECTED_CERTIFICATE_LIFETIME_SECONDS: i64 = 300; +#[cfg(not(feature = "connect-e2e-short-credentials"))] +const EXPECTED_CERTIFICATE_LIFETIME_SECONDS: i64 = 86_400; +#[cfg(feature = "connect-e2e-short-credentials")] +const EXPECTED_ROTATION_THRESHOLD_SECONDS: i64 = 120; +#[cfg(not(feature = "connect-e2e-short-credentials"))] +const EXPECTED_ROTATION_THRESHOLD_SECONDS: i64 = 8 * 60 * 60; const ORGANIZATION_UID: &str = "0198f4b0-1a00-7c10-8d21-2e3f4a5b6c70"; const CLUSTER_UID: &str = "0198f4b0-2b00-7d20-9e31-3f4a5b6c7d81"; const DEVICE_UID: &str = "0198f4b0-3c00-7e30-8f41-4a5b6c7d8e92"; @@ -106,7 +114,13 @@ impl TestPki { fn credential(&self, identity: &rustfs::connect::DeviceIdentity, uri: &str, serial_byte: u8) -> Value { let now = OffsetDateTime::now_utc().replace_nanosecond(0).expect("whole second"); - self.credential_window(identity, uri, serial_byte, now, now + time::Duration::days(1)) + self.credential_window( + identity, + uri, + serial_byte, + now, + now + time::Duration::seconds(EXPECTED_CERTIFICATE_LIFETIME_SECONDS), + ) } fn credential_window( @@ -474,14 +488,19 @@ fn due_runtime_config( pki: &TestPki, endpoint: &str, ) -> (HeartbeatConfig, Value, rustfs::connect::DeviceIdentity) { - runtime_config(temp, pki, endpoint, 17) + runtime_config( + temp, + pki, + endpoint, + EXPECTED_CERTIFICATE_LIFETIME_SECONDS - EXPECTED_ROTATION_THRESHOLD_SECONDS, + ) } fn runtime_config( temp: &tempfile::TempDir, pki: &TestPki, endpoint: &str, - elapsed_hours: i64, + elapsed_seconds: i64, ) -> (HeartbeatConfig, Value, rustfs::connect::DeviceIdentity) { let (identity_store, credential_store) = stores(temp); let identity = identity_store.load_or_create().expect("create current identity"); @@ -490,13 +509,13 @@ fn runtime_config( &identity, &format!("urn:rustfs:connect:device:{DEVICE_UID}"), 0x20, - now - time::Duration::hours(elapsed_hours), - now + time::Duration::hours(24 - elapsed_hours), + now - time::Duration::seconds(elapsed_seconds), + now + time::Duration::seconds(EXPECTED_CERTIFICATE_LIFETIME_SECONDS - elapsed_seconds), ); let not_before = OffsetDateTime::parse(credential["notBefore"].as_str().expect("notBefore"), &Rfc3339).expect("parse notBefore"); let not_after = OffsetDateTime::parse(credential["notAfter"].as_str().expect("notAfter"), &Rfc3339).expect("parse notAfter"); - assert_eq!(not_after - not_before, time::Duration::hours(24)); + assert_eq!(not_after - not_before, time::Duration::seconds(EXPECTED_CERTIFICATE_LIFETIME_SECONDS)); fs::create_dir_all(temp.path().join("credential")).expect("credential directory"); write_stored_credential(&temp.path().join("credential/device.crt.json"), &credential); let next = stage_next_identity(temp); @@ -665,6 +684,49 @@ async fn registration_rejects_untrusted_or_misbound_credentials() { } } +#[tokio::test] +async fn registration_enforces_build_profile_credential_lifetime() { + let pki = TestPki::new(); + let accepted_temp = tempfile::tempdir().expect("temp dir"); + let (accepted_identity_store, accepted_credential_store) = stores(&accepted_temp); + let accepted_identity = accepted_identity_store.load_or_create().expect("create identity"); + let accepted = pki.credential(&accepted_identity, &format!("urn:rustfs:connect:device:{DEVICE_UID}"), 4); + let accepted_server = server(&pki, vec![Reply::Json(StatusCode::CREATED, accepted)]).await; + let credential = client(&accepted_server, &pki, Duration::from_secs(2)) + .register(&accepted_identity_store, &accepted_credential_store, &token()) + .await + .expect("configured lifetime must be accepted"); + assert_eq!( + credential.not_after_unix - credential.not_before_unix, + EXPECTED_CERTIFICATE_LIFETIME_SECONDS + ); + + let rejected_temp = tempfile::tempdir().expect("temp dir"); + let (rejected_identity_store, rejected_credential_store) = stores(&rejected_temp); + let rejected_identity = rejected_identity_store.load_or_create().expect("create identity"); + let now = OffsetDateTime::now_utc().replace_nanosecond(0).expect("whole second"); + let other_lifetime = if cfg!(feature = "connect-e2e-short-credentials") { + 86_400 + } else { + 300 + }; + let rejected = pki.credential_window( + &rejected_identity, + &format!("urn:rustfs:connect:device:{DEVICE_UID}"), + 5, + now, + now + time::Duration::seconds(other_lifetime), + ); + let rejected_server = server(&pki, vec![Reply::Json(StatusCode::CREATED, rejected)]).await; + assert!(matches!( + client(&rejected_server, &pki, Duration::from_secs(2)) + .register(&rejected_identity_store, &rejected_credential_store, &token()) + .await, + Err(ClientError::Credential(_)) + )); + assert!(!rejected_temp.path().join("credential/device.crt.json").exists()); +} + #[tokio::test] async fn stored_credential_is_revalidated_before_reuse() { let temp = tempfile::tempdir().expect("temp dir"); @@ -710,8 +772,8 @@ async fn register_rejects_expired_and_not_yet_valid_stored_credentials() { &identity, &format!("urn:rustfs:connect:device:{DEVICE_UID}"), 10, - now - time::Duration::days(2), - now - time::Duration::days(1), + now - time::Duration::seconds(EXPECTED_CERTIFICATE_LIFETIME_SECONDS + 60), + now - time::Duration::seconds(60), ); write_stored_credential(&path, &expired); assert!(matches!( @@ -724,7 +786,7 @@ async fn register_rejects_expired_and_not_yet_valid_stored_credentials() { &format!("urn:rustfs:connect:device:{DEVICE_UID}"), 11, now + time::Duration::hours(1), - now + time::Duration::hours(25), + now + time::Duration::hours(1) + time::Duration::seconds(EXPECTED_CERTIFICATE_LIFETIME_SECONDS), ); write_stored_credential(&path, &future); assert!(matches!( @@ -765,7 +827,7 @@ async fn concurrent_rotation_retries_converge_and_promote_the_next_key() { ) .await; let retry_client = client(&retries, &pki, Duration::from_millis(80)); - let due = registered.not_after_unix - 8 * 60 * 60; + let due = registered.not_after_unix - EXPECTED_ROTATION_THRESHOLD_SECONDS; let (first, second) = tokio::join!( retry_client.rotate_if_due(&identity_store, &credential_store, due), retry_client.rotate_if_due(&identity_store, &credential_store, due) @@ -1074,7 +1136,7 @@ async fn heartbeat_retries_with_the_new_credential_after_concurrent_rotation() { let due = OffsetDateTime::parse(current["notAfter"].as_str().expect("notAfter"), &Rfc3339) .expect("parse notAfter") .unix_timestamp() - - 8 * 60 * 60; + - EXPECTED_ROTATION_THRESHOLD_SECONDS; client(&server, &pki, Duration::from_millis(250)) .rotate_if_due(&identity_store, &credential_store, due) .await @@ -1159,7 +1221,7 @@ async fn inventory_retries_with_the_new_credential_after_concurrent_rotation() { let due = OffsetDateTime::parse(current["notAfter"].as_str().expect("notAfter"), &Rfc3339) .expect("parse notAfter") .unix_timestamp() - - 8 * 60 * 60; + - EXPECTED_ROTATION_THRESHOLD_SECONDS; client(&server, &pki, Duration::from_millis(250)) .rotate_if_due(&identity_store, &credential_store, due) .await @@ -1391,7 +1453,7 @@ async fn rotation_commit_recovers_after_each_durable_step() { .await .expect("register"); let failed = server(&pki, vec![Reply::Json(StatusCode::SERVICE_UNAVAILABLE, json!({})); 3]).await; - let due = registered.not_after_unix - 8 * 60 * 60; + let due = registered.not_after_unix - EXPECTED_ROTATION_THRESHOLD_SECONDS; assert!(matches!( client(&failed, &pki, Duration::from_secs(2)) .rotate_if_due(&identity_store, &credential_store, due) @@ -1473,7 +1535,11 @@ async fn pending_reenrollment_blocks_rotation_and_resumes_original_exchange() { let rotation = server(&pki, vec![]).await; let error = client(&rotation, &pki, Duration::from_secs(2)) - .rotate_if_due(&identity_store, &credential_store, registered.not_after_unix - 8 * 60 * 60) + .rotate_if_due( + &identity_store, + &credential_store, + registered.not_after_unix - EXPECTED_ROTATION_THRESHOLD_SECONDS, + ) .await .expect_err("pending reenrollment blocks rotation"); assert!(matches!(error, ClientError::PendingRegistration)); @@ -1660,15 +1726,25 @@ async fn rotation_waits_for_threshold_and_stops_on_revocation() { .register(&identity_store, &credential_store, &token()) .await .expect("register"); + assert_eq!(current.not_after_unix - current.not_before_unix, EXPECTED_CERTIFICATE_LIFETIME_SECONDS); assert!( connect - .rotate_if_due(&identity_store, &credential_store, current.not_before_unix) + .rotate_if_due( + &identity_store, + &credential_store, + current.not_after_unix - EXPECTED_ROTATION_THRESHOLD_SECONDS - 1, + ) .await .expect("not due") .is_none() ); + assert_eq!(rotation_server.seen.lock().expect("seen lock").len(), 1); let error = connect - .rotate_if_due(&identity_store, &credential_store, current.not_after_unix - 8 * 60 * 60) + .rotate_if_due( + &identity_store, + &credential_store, + current.not_after_unix - EXPECTED_ROTATION_THRESHOLD_SECONDS, + ) .await .expect_err("revocation must stop rotation"); assert!(matches!(error, ClientError::AccessRevoked { .. }));