test(connect): add short credential E2E profile (#6664)

* feat(connect): add short credential E2E profile

* ci(connect): test short credential boundary
This commit is contained in:
Zhengchao An
2026-08-26 21:25:02 +08:00
committed by GitHub
parent 7c2361757e
commit 62a767a52d
5 changed files with 161 additions and 16 deletions
+58
View File
@@ -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'
+2
View File
@@ -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"]
+3
View File
@@ -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";
+6
View File
@@ -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;
+92 -16
View File
@@ -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 { .. }));