From 6ef7bac071507b6684a47d929d035a6a99e1a16e Mon Sep 17 00:00:00 2001 From: Zhengchao An Date: Wed, 26 Aug 2026 18:50:58 +0800 Subject: [PATCH] test(connect): isolate offline enrollment e2e root (#6659) * test(connect): isolate offline enrollment e2e root * test(connect): own e2e issuer key id --- .config/make/lint-fmt.mak | 5 ++ .config/make/pre-commit.mak | 2 +- .github/workflows/ci.yml | 3 + rustfs/Cargo.toml | 7 ++ rustfs/src/bin/rustfs-cli.rs | 51 +++++++++++ rustfs/src/connect/offline/enrollment.rs | 47 ++++++++-- rustfs/tests/connect_offline_enrollment.rs | 87 +++++++++++++++++++ .../offline-enrollment-e2e/challenge.json | 8 ++ .../fixtures/offline-enrollment-e2e/root.json | 4 + .../offline-enrollment-e2e/trust-chain.json | 1 + scripts/check_offline_enrollment_e2e.sh | 78 +++++++++++++++++ 11 files changed, 284 insertions(+), 9 deletions(-) create mode 100644 rustfs/tests/fixtures/offline-enrollment-e2e/challenge.json create mode 100644 rustfs/tests/fixtures/offline-enrollment-e2e/root.json create mode 100644 rustfs/tests/fixtures/offline-enrollment-e2e/trust-chain.json create mode 100755 scripts/check_offline_enrollment_e2e.sh diff --git a/.config/make/lint-fmt.mak b/.config/make/lint-fmt.mak index 673010369..391a06226 100644 --- a/.config/make/lint-fmt.mak +++ b/.config/make/lint-fmt.mak @@ -80,6 +80,11 @@ embedded-secrets-check: ## Check no private key material or credential literal i @echo "๐Ÿ”‘ Checking embedded secret material guard..." ./scripts/check_embedded_secrets.sh +.PHONY: offline-enrollment-e2e-check +offline-enrollment-e2e-check: core-deps ## Build and exercise the dedicated offline enrollment E2E root + @echo "๐Ÿ” Checking the offline enrollment E2E root boundary..." + ./scripts/check_offline_enrollment_e2e.sh + .PHONY: test-wiring-check test-wiring-check: ## Check tests stay registered and selected by their intended runners @echo "๐Ÿงช Checking test wiring..." diff --git a/.config/make/pre-commit.mak b/.config/make/pre-commit.mak index cbf7a1c83..716b182f4 100644 --- a/.config/make/pre-commit.mak +++ b/.config/make/pre-commit.mak @@ -23,7 +23,7 @@ pre-commit: fmt-check unsafe-code-check architecture-migration-check logging-gua @echo "โœ… All pre-commit checks passed!" .PHONY: pre-pr -pre-pr: fmt-check unsafe-code-check architecture-migration-check logging-guardrails-check error-other-ratchet-check tokio-io-uring-check extension-schema-check body-cache-whitelist-check s3s-footprint-check fips-wording-check embedded-secrets-check test-wiring-check doc-paths-check planning-docs-check log-analyzer-rules-check clippy-check test ## Run full pre-PR checks with clippy and tests +pre-pr: fmt-check unsafe-code-check architecture-migration-check logging-guardrails-check error-other-ratchet-check tokio-io-uring-check extension-schema-check body-cache-whitelist-check s3s-footprint-check fips-wording-check embedded-secrets-check test-wiring-check doc-paths-check planning-docs-check log-analyzer-rules-check offline-enrollment-e2e-check clippy-check test ## Run full pre-PR checks with clippy and tests @echo "โœ… All pre-PR checks passed!" .PHONY: dev-check diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d1ae8e44a..acb3ad106 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -308,6 +308,9 @@ jobs: } > artifacts/test-and-lint/doctest-diagnostics.txt exit "${status}" + - name: Check offline enrollment E2E root boundary + run: ./scripts/check_offline_enrollment_e2e.sh + - name: Upload test reports and diagnostics if: always() uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 diff --git a/rustfs/Cargo.toml b/rustfs/Cargo.toml index 30fb499ba..c392d5629 100644 --- a/rustfs/Cargo.toml +++ b/rustfs/Cargo.toml @@ -39,6 +39,11 @@ path = "src/main.rs" name = "rustfs-cli" path = "src/bin/rustfs-cli.rs" +[[bin]] +name = "rustfs-cli-e2e" +path = "src/bin/rustfs-cli.rs" +required-features = ["offline-enrollment-e2e-root"] + [features] default = ["ftps", "webdav"] metrics-gpu = ["rustfs-obs/gpu"] @@ -52,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 = [] +# Builds the dedicated rustfs-cli-e2e target with a build-time public enrollment root. +offline-enrollment-e2e-root = [] rio-v2 = ["rustfs-ecstore/rio-v2"] pyroscope = ["rustfs-obs/pyroscope"] # Tokio runtime telemetry. Requires `--cfg tokio_unstable`; use `make build-profiling`. diff --git a/rustfs/src/bin/rustfs-cli.rs b/rustfs/src/bin/rustfs-cli.rs index ce92d6c19..f87c93bcd 100644 --- a/rustfs/src/bin/rustfs-cli.rs +++ b/rustfs/src/bin/rustfs-cli.rs @@ -294,7 +294,12 @@ fn enroll(arguments: &[String]) -> Result<(), String> { .duration_since(UNIX_EPOCH) .map_err(|_| "the system clock is before the Unix epoch".to_string())? .as_secs() as i64; + #[cfg(feature = "offline-enrollment-e2e-root")] + let now = enrollment_evaluation_time(now)?; + #[cfg(feature = "offline-enrollment-e2e-root")] + let verified = verify_enrollment_challenge(&challenge, now).map_err(|error| error.to_string())?; + #[cfg(not(feature = "offline-enrollment-e2e-root"))] let verified = OfflineEnrollment::verify_challenge(&challenge, now).map_err(|error| error.to_string())?; // First use mints the key; a retry answers with the one already enrolled, @@ -313,6 +318,32 @@ fn enroll(arguments: &[String]) -> Result<(), String> { Ok(()) } +#[cfg(feature = "offline-enrollment-e2e-root")] +fn enrollment_evaluation_time(system_now: i64) -> Result { + // The public fixture gate needs a compile-time clock; real E2E builds omit it. + if env!("CARGO_BIN_NAME") == "rustfs-cli-e2e" + && let Some(value) = option_env!("RUSTFS_E2E_OFFLINE_ENROLLMENT_FIXTURE_TIME") + { + return value + .parse() + .map_err(|_| "the compiled E2E enrollment fixture time is invalid".to_owned()); + } + + Ok(system_now) +} + +#[cfg(feature = "offline-enrollment-e2e-root")] +fn verify_enrollment_challenge( + challenge: &[u8], + now: i64, +) -> Result { + if env!("CARGO_BIN_NAME") == "rustfs-cli-e2e" { + return OfflineEnrollment::verify_e2e_challenge(challenge, now); + } + + OfflineEnrollment::verify_challenge(challenge, now) +} + /// Reads the challenge from a file, or from stdin when the path is `-`. /// /// A challenge is not a secret โ€” it is signed, public, and carried in by hand โ€” @@ -420,6 +451,26 @@ mod tests { use super::*; + #[cfg(feature = "offline-enrollment-e2e-root")] + #[test] + fn only_the_dedicated_e2e_target_selects_the_test_root() { + let challenge = + fs::read(PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/offline-enrollment-e2e/challenge.json")) + .expect("read E2E enrollment challenge"); + let result = verify_enrollment_challenge(&challenge, 4_070_908_800); + + if env!("CARGO_BIN_NAME") == "rustfs-cli-e2e" { + result.expect("the dedicated E2E binary selects the test root"); + } else { + assert_eq!( + result + .expect_err("every production binary must retain the hosted root") + .reason(), + "ENROLLMENT_ROOT_UNKNOWN" + ); + } + } + #[test] fn connect_offline_bundle_cli_shutdown_is_bounded_with_a_stuck_blocking_collector() { let runtime = tokio::runtime::Builder::new_current_thread().build().expect("test runtime"); diff --git a/rustfs/src/connect/offline/enrollment.rs b/rustfs/src/connect/offline/enrollment.rs index ebf568070..a6c7c69d1 100644 --- a/rustfs/src/connect/offline/enrollment.rs +++ b/rustfs/src/connect/offline/enrollment.rs @@ -46,8 +46,22 @@ use crate::connect::identity::DeviceIdentity; /// The hosted enrolment root, compiled in. Both halves are pinned: the /// fingerprint identifies the root, and the point is what actually verifies the /// first link, so a build cannot be pointed at a different key by supplying one. -const PINNED_ROOT_KEY_ID: &str = "df22e2806112debbe953672aafa186d699af0e97dd3fd2b09fa8359005fe348f"; -const PINNED_ROOT_PUBLIC_KEY: &str = "BFfx-K-FfEA5nK_Rz3IHacvRCkJyQ7JOd1geLyU6HKRZDgNezmVuKhvJ22VhemyjV__Gshk8JGGqOBzYPMD0p6s"; +const HOSTED_ROOT: EnrollmentRoot = EnrollmentRoot { + key_id: "df22e2806112debbe953672aafa186d699af0e97dd3fd2b09fa8359005fe348f", + public_key: "BFfx-K-FfEA5nK_Rz3IHacvRCkJyQ7JOd1geLyU6HKRZDgNezmVuKhvJ22VhemyjV__Gshk8JGGqOBzYPMD0p6s", +}; + +#[cfg(feature = "offline-enrollment-e2e-root")] +const E2E_ROOT: EnrollmentRoot = EnrollmentRoot { + key_id: env!("RUSTFS_E2E_OFFLINE_ENROLLMENT_ROOT_KEY_ID"), + public_key: env!("RUSTFS_E2E_OFFLINE_ENROLLMENT_ROOT_PUBLIC_KEY"), +}; + +#[derive(Clone, Copy)] +struct EnrollmentRoot { + key_id: &'static str, + public_key: &'static str, +} /// Domain separation tags. A document that verifies under one of these must not /// be accepted for another artifact type, so the tag is part of the signature @@ -327,6 +341,22 @@ impl OfflineEnrollment { /// `now_unix` is the device's reading of the current time, which the clock /// skew tolerance treats as advisory. pub fn verify_challenge(document: &[u8], now_unix: i64) -> Result { + Self::verify_challenge_with_root(document, now_unix, HOSTED_ROOT) + } + + /// Verify a challenge against the fixed root carried only by the dedicated + /// E2E CLI build. No caller can supply or replace this root at runtime. + #[cfg(feature = "offline-enrollment-e2e-root")] + #[doc(hidden)] + pub fn verify_e2e_challenge(document: &[u8], now_unix: i64) -> Result { + Self::verify_challenge_with_root(document, now_unix, E2E_ROOT) + } + + fn verify_challenge_with_root( + document: &[u8], + now_unix: i64, + root: EnrollmentRoot, + ) -> Result { let envelope: SignedDocument = serde_json::from_slice(document).map_err(|_| EnrollmentError::MalformedDocument)?; // Step 1: the encoding is checked before anything is decoded from it, so @@ -345,7 +375,7 @@ impl OfflineEnrollment { let issued_at = parse_timestamp(&routing.issued_at)?; // Steps 3 to 5. - let connect_key = verify_trust_chain(&routing.trust_chain, &routing.connect_key_id, issued_at)?; + let connect_key = verify_trust_chain(&routing.trust_chain, &routing.connect_key_id, issued_at, root)?; // Step 6. The verification key comes from the chain, so `signature.keyId` // is a label rather than an input: a value naming some other key simply @@ -439,24 +469,25 @@ fn verify_trust_chain( chain: &[SignedDocument], connect_key_id: &str, challenge_issued_at: i64, + root: EnrollmentRoot, ) -> Result { // The pinned root gate runs before the chain's shape is examined, so a // chain that is internally consistent under a foreign root โ€” exactly what // trust on first use would have accepted โ€” is refused for its root rather // than for its length. let first = chain.first().ok_or(EnrollmentError::EnrollmentRootUnknown)?; - let root = decode_trust_link(first)?; - if root.0.issuer_key_id != PINNED_ROOT_KEY_ID { + let first_link = decode_trust_link(first)?; + if first_link.0.issuer_key_id != root.key_id { return Err(EnrollmentError::EnrollmentRootUnknown); } let [_, second] = chain else { return Err(EnrollmentError::TrustChainInvalid); }; - let links = [root, decode_trust_link(second)?]; + let links = [first_link, decode_trust_link(second)?]; - let mut issuer_key_id = PINNED_ROOT_KEY_ID.to_owned(); - let (mut issuer_key, _) = decode_public_key(PINNED_ROOT_PUBLIC_KEY).ok_or(EnrollmentError::EnrollmentRootUnknown)?; + let mut issuer_key_id = root.key_id.to_owned(); + let (mut issuer_key, _) = decode_public_key(root.public_key).ok_or(EnrollmentError::EnrollmentRootUnknown)?; for (index, ((link, link_bytes), entry)) in links.iter().zip(chain).enumerate() { if link.format_version != FORMAT_TRUST_LINK diff --git a/rustfs/tests/connect_offline_enrollment.rs b/rustfs/tests/connect_offline_enrollment.rs index d1b60a9eb..f07030128 100644 --- a/rustfs/tests/connect_offline_enrollment.rs +++ b/rustfs/tests/connect_offline_enrollment.rs @@ -54,6 +54,14 @@ fn fixture_dir() -> PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../protocol/agent/v1/fixtures/offline-enrollment") } +#[cfg(feature = "offline-enrollment-e2e-root")] +fn e2e_fixture(name: &str) -> Vec { + let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("tests/fixtures/offline-enrollment-e2e") + .join(name); + fs::read(&path).unwrap_or_else(|error| panic!("read {}: {error}", path.display())) +} + fn sha256_hex(bytes: &[u8]) -> String { Sha256::digest(bytes).iter().map(|byte| format!("{byte:02x}")).collect() } @@ -268,6 +276,85 @@ fn every_challenge_accept_vector_verifies_and_exposes_the_signed_fields() { ); } +#[cfg(feature = "offline-enrollment-e2e-root")] +#[test] +fn e2e_root_is_fixed_and_disjoint_from_the_hosted_root() { + let challenge = e2e_fixture("challenge.json"); + let now = unix("2099-01-01T00:00:00Z"); + + let verified = + OfflineEnrollment::verify_e2e_challenge(&challenge, now).expect("the dedicated E2E root verifies its challenge"); + assert_eq!(verified.challenge_id, "018f7e6d-9d6a-7d93-8f64-8b20b3384712"); + assert_eq!( + OfflineEnrollment::verify_challenge(&challenge, now) + .expect_err("the production verifier must not trust the E2E root") + .reason(), + "ENROLLMENT_ROOT_UNKNOWN" + ); + + let hosted = accept_vector_named("challenge signed by a chained signing key under the pinned root"); + let hosted_now = unix(field(&hosted, "evaluationTime")); + assert_eq!( + OfflineEnrollment::verify_e2e_challenge(&envelope(&hosted["document"]), hosted_now) + .expect_err("the E2E verifier must not silently retain the hosted root") + .reason(), + "ENROLLMENT_ROOT_UNKNOWN" + ); +} + +#[cfg(feature = "offline-enrollment-e2e-root")] +#[test] +fn e2e_public_chain_matches_the_challenge_and_every_signature_verifies() { + use p256::ecdsa::signature::Verifier as _; + + let root: Value = serde_json::from_slice(&e2e_fixture("root.json")).expect("E2E root parses"); + let chain_bytes = e2e_fixture("trust-chain.json"); + let chain: Value = serde_json::from_slice(&chain_bytes).expect("E2E chain parses"); + let challenge_envelope: Value = serde_json::from_slice(&e2e_fixture("challenge.json")).expect("E2E challenge parses"); + let challenge_bytes = signed_octets(&challenge_envelope); + let challenge: Value = serde_json::from_slice(&challenge_bytes).expect("signed E2E challenge parses"); + + assert_eq!(challenge["trustChain"], chain, "the independent and embedded chain JSON must match"); + let compact_chain = chain_bytes + .strip_suffix(b"\n") + .expect("the chain fixture has one final newline"); + assert!( + challenge_bytes + .windows(compact_chain.len()) + .any(|window| window == compact_chain), + "the signed challenge must embed the independent chain byte for byte" + ); + + let mut issuer = verifying_key(field(&root, "publicKey")); + let mut issuer_id = field(&root, "keyId").to_owned(); + for link in chain.as_array().expect("E2E chain is a list") { + assert_eq!(field(&link["signature"], "keyId"), issuer_id.as_str()); + let signature = BASE64_URL_NO_PAD + .decode(field(&link["signature"], "value")) + .expect("trust-link signature is base64url"); + issuer + .verify( + &signing_input("rustfs-offline-trust-link-v1", &signed_octets(link)), + &p256::ecdsa::Signature::from_slice(&signature).expect("trust-link signature parses"), + ) + .expect("trust-link signature verifies"); + let document = signed_document(link); + issuer_id = field(&document, "subjectKeyId").to_owned(); + issuer = verifying_key(field(&document, "subjectPublicKey")); + } + + assert_eq!(field(&challenge, "connectKeyId"), issuer_id.as_str()); + let signature = BASE64_URL_NO_PAD + .decode(field(&challenge_envelope["signature"], "value")) + .expect("challenge signature is base64url"); + issuer + .verify( + &signing_input("rustfs-offline-enrollment-challenge-v1", &challenge_bytes), + &p256::ecdsa::Signature::from_slice(&signature).expect("challenge signature parses"), + ) + .expect("challenge signature verifies"); +} + /// Connect's own producer wrote the response accept vectors. Rebuilding them /// from the challenge they answer, with the device nonce and production time /// they used, must reproduce every field that does not depend on which device diff --git a/rustfs/tests/fixtures/offline-enrollment-e2e/challenge.json b/rustfs/tests/fixtures/offline-enrollment-e2e/challenge.json new file mode 100644 index 000000000..1f7033f73 --- /dev/null +++ b/rustfs/tests/fixtures/offline-enrollment-e2e/challenge.json @@ -0,0 +1,8 @@ +{ + "bytes": "eyJmb3JtYXRWZXJzaW9uIjoicnVzdGZzLmNvbm5lY3Qub2ZmbGluZS5lbnJvbGxtZW50Q2hhbGxlbmdlLzEiLCJwcm90b2NvbFZlcnNpb24iOiJ2MSIsImNoYWxsZW5nZUlkIjoiMDE4ZjdlNmQtOWQ2YS03ZDkzLThmNjQtOGIyMGIzMzg0NzEyIiwib3JnYW5pemF0aW9uTmFtZSI6Im9yZ2FuaXphdGlvbnMvMDE4ZjdlNmQtOWQ2YS03ZDkzLThmNjQtOGIyMGIzMzg0NzEzIiwiY2x1c3Rlck5hbWUiOiJvcmdhbml6YXRpb25zLzAxOGY3ZTZkLTlkNmEtN2Q5My04ZjY0LThiMjBiMzM4NDcxMy9jbHVzdGVycy8wMThmN2U2ZC05ZDZhLTdkOTMtOGY2NC04YjIwYjMzODQ3MTQiLCJub25jZSI6InBLU2twS1NrcEtTa3BLU2twS1NrcEtTa3BLU2twS1NrcEtTa3BLU2twS1EiLCJpc3N1ZWRBdCI6IjIwOTktMDEtMDFUMDA6MDA6MDBaIiwiZXhwaXJlc0F0IjoiMjA5OS0wMS0wOFQwMDowMDowMFoiLCJjb25uZWN0S2V5SWQiOiIwNjA3NWY0ODRkY2U1YzA3NDVjYTc2NWE4ODk5MzA5YmQ3MTc2MTI5NDdhMzc3MGEwZDI3YjhjY2I4YmY5MDUyIiwidHJ1c3RDaGFpbiI6W3siYnl0ZXMiOiJleUptYjNKdFlYUldaWEp6YVc5dUlqb2ljblZ6ZEdaekxtTnZibTVsWTNRdWIyWm1iR2x1WlM1MGNuVnpkRXhwYm1zdk1TSXNJbkJ5YjNSdlkyOXNWbVZ5YzJsdmJpSTZJbll4SWl3aWMyVnlhV0ZzSWpvaVpUQTBZVEF3TURBd01EQXdNREF3TURBd01EQXdNREF3TURBd01EQXdNREVpTENKeWIyeGxJam9pYVc1MFpYSnRaV1JwWVhSbElpd2lhWE56ZFdWeVMyVjVTV1FpT2lJNVlUVmpNemcxWVdKa09UZGpOMlppT0RSak5UUXdZalZtTldVME1tUTNPVGt4T1RNd1lUUTBOMk01TURJMk56azVaVGhtWkRSak5ESXpNR0kxWXpOaklpd2ljM1ZpYW1WamRFdGxlVWxrSWpvaVlUazFOVGxpWkRSbE1EazBPV0ZqWXpFeU1tRm1abU0xTldVMlpHSTRaVGt4Wmpoa01EazNaVFUxWldNNE9UbGtZVFl4T1RGbE5EWTVZelE0T0RKaE1pSXNJbk4xWW1wbFkzUlFkV0pzYVdOTFpYa2lPaUpDVG13elZFeDFTazV0Tld4NFRGUmFNRGxUWVdwWVRHMUZSM0ZqZHpGaVQyZzROVWhEYmpWdVVUazJWVlZUYUZVNFZHeEhVVWsxYmxaSldWUnJRWFpLYjFWRVMwWmhVVlp0WVZGVk9WRkhaemsyVDJ0RU1EUWlMQ0p1YjNSQ1pXWnZjbVVpT2lJeU1ESXdMVEF4TFRBeFZEQXdPakF3T2pBd1dpSXNJbTV2ZEVGbWRHVnlJam9pTWpBNU9TMHdNUzB3TVZRd01Eb3dNRG93TUZvaWZRPT0iLCJzaWduYXR1cmUiOnsiYWxnb3JpdGhtIjoiRVMyNTYiLCJrZXlJZCI6IjlhNWMzODVhYmQ5N2M3ZmI4NGM1NDBiNWY1ZTQyZDc5OTE5MzBhNDQ3YzkwMjY3OTllOGZkNGM0MjMwYjVjM2MiLCJ2YWx1ZSI6IklUSThKVGFURjRoRHJ0bF9tSk0zYmZ0eUlhMlJaSjFNNVpPVk1xaEE2cVJSSzFNcEcxQlF0emRRVzB6bHo4eG1RZUJNWl84clViTUlFcnFBel8tSWZRIn19LHsiYnl0ZXMiOiJleUptYjNKdFlYUldaWEp6YVc5dUlqb2ljblZ6ZEdaekxtTnZibTVsWTNRdWIyWm1iR2x1WlM1MGNuVnpkRXhwYm1zdk1TSXNJbkJ5YjNSdlkyOXNWbVZ5YzJsdmJpSTZJbll4SWl3aWMyVnlhV0ZzSWpvaVpUQTBZVEF3TURBd01EQXdNREF3TURBd01EQXdNREF3TURBd01EQXdNRElpTENKeWIyeGxJam9pYzJsbmJtbHVaeUlzSW1semMzVmxja3RsZVVsa0lqb2lZVGsxTlRsaVpEUmxNRGswT1dGall6RXlNbUZtWm1NMU5XVTJaR0k0WlRreFpqaGtNRGszWlRVMVpXTTRPVGxrWVRZeE9URmxORFk1WXpRNE9ESmhNaUlzSW5OMVltcGxZM1JMWlhsSlpDSTZJakEyTURjMVpqUTROR1JqWlRWak1EYzBOV05oTnpZMVlUZzRPVGt6TURsaVpEY3hOell4TWprME4yRXpOemN3WVRCa01qZGlPR05qWWpoaVpqa3dOVElpTENKemRXSnFaV04wVUhWaWJHbGpTMlY1SWpvaVFrRk9lWFpITFdkR1YwdFRaM0pMWTNkUVpGUmhNRWN3TTBKdlVtRkNSbWhvZG10cWRFUjZlVk5tYm5weFYxcHROa2xLV0U1RWRVZEdlVXBFZVZWSmNYZzJSa1p5TlZSV2NsOXVhbUZCYzFWb2FEbE1VMlJ2SWl3aWJtOTBRbVZtYjNKbElqb2lNakF5TUMwd01TMHdNVlF3TURvd01Eb3dNRm9pTENKdWIzUkJablJsY2lJNklqSXdPVGt0TURFdE1ERlVNREE2TURBNk1EQmFJbjA9Iiwic2lnbmF0dXJlIjp7ImFsZ29yaXRobSI6IkVTMjU2Iiwia2V5SWQiOiJhOTU1OWJkNGUwOTQ5YWNjMTIyYWZmYzU1ZTZkYjhlOTFmOGQwOTdlNTVlYzg5OWRhNjE5MWU0NjljNDg4MmEyIiwidmFsdWUiOiI4MTVrVGNMY1hkZXZORFRia1dBck1KSnpORTVmaEdxbTNUb3hTM3Exdl9ZQkRVcmFieldmTFhjMEc5TjZiVXZFeEd3WEJUMVFpVzJvUDBCM3JKUS0ydyJ9fV19", + "signature": { + "algorithm": "ES256", + "keyId": "06075f484dce5c0745ca765a8899309bd717612947a3770a0d27b8ccb8bf9052", + "value": "8i2nvYhNsZbL1UPF82Otr4pcecYfKhdRV1Q7m3Geqcx-TdvYk486Q_cZHSQ7OaqnIhTZwPgMYFYKUuEhwAaYEQ" + } +} diff --git a/rustfs/tests/fixtures/offline-enrollment-e2e/root.json b/rustfs/tests/fixtures/offline-enrollment-e2e/root.json new file mode 100644 index 000000000..8d3803a81 --- /dev/null +++ b/rustfs/tests/fixtures/offline-enrollment-e2e/root.json @@ -0,0 +1,4 @@ +{ + "keyId": "9a5c385abd97c7fb84c540b5f5e42d7991930a447c9026799e8fd4c4230b5c3c", + "publicKey": "BIPyiA1W2NDuy3ftHLtlY2tO2WNjkjGINZ_9QQvyyN9syzbMb91QYG2SN0AqZPylFsTL-loF4M1tVySZtXJpKhM" +} diff --git a/rustfs/tests/fixtures/offline-enrollment-e2e/trust-chain.json b/rustfs/tests/fixtures/offline-enrollment-e2e/trust-chain.json new file mode 100644 index 000000000..8ecc1b90b --- /dev/null +++ b/rustfs/tests/fixtures/offline-enrollment-e2e/trust-chain.json @@ -0,0 +1 @@ +[{"bytes":"eyJmb3JtYXRWZXJzaW9uIjoicnVzdGZzLmNvbm5lY3Qub2ZmbGluZS50cnVzdExpbmsvMSIsInByb3RvY29sVmVyc2lvbiI6InYxIiwic2VyaWFsIjoiZTA0YTAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDEiLCJyb2xlIjoiaW50ZXJtZWRpYXRlIiwiaXNzdWVyS2V5SWQiOiI5YTVjMzg1YWJkOTdjN2ZiODRjNTQwYjVmNWU0MmQ3OTkxOTMwYTQ0N2M5MDI2Nzk5ZThmZDRjNDIzMGI1YzNjIiwic3ViamVjdEtleUlkIjoiYTk1NTliZDRlMDk0OWFjYzEyMmFmZmM1NWU2ZGI4ZTkxZjhkMDk3ZTU1ZWM4OTlkYTYxOTFlNDY5YzQ4ODJhMiIsInN1YmplY3RQdWJsaWNLZXkiOiJCTmwzVEx1Sk5tNWx4TFRaMDlTYWpYTG1FR3FjdzFiT2g4NUhDbjVuUTk2VVVTaFU4VGxHUUk1blZJWVRrQXZKb1VES0ZhUVZtYVFVOVFHZzk2T2tEMDQiLCJub3RCZWZvcmUiOiIyMDIwLTAxLTAxVDAwOjAwOjAwWiIsIm5vdEFmdGVyIjoiMjA5OS0wMS0wMVQwMDowMDowMFoifQ==","signature":{"algorithm":"ES256","keyId":"9a5c385abd97c7fb84c540b5f5e42d7991930a447c9026799e8fd4c4230b5c3c","value":"ITI8JTaTF4hDrtl_mJM3bftyIa2RZJ1M5ZOVMqhA6qRRK1MpG1BQtzdQW0zlz8xmQeBMZ_8rUbMIErqAz_-IfQ"}},{"bytes":"eyJmb3JtYXRWZXJzaW9uIjoicnVzdGZzLmNvbm5lY3Qub2ZmbGluZS50cnVzdExpbmsvMSIsInByb3RvY29sVmVyc2lvbiI6InYxIiwic2VyaWFsIjoiZTA0YTAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDIiLCJyb2xlIjoic2lnbmluZyIsImlzc3VlcktleUlkIjoiYTk1NTliZDRlMDk0OWFjYzEyMmFmZmM1NWU2ZGI4ZTkxZjhkMDk3ZTU1ZWM4OTlkYTYxOTFlNDY5YzQ4ODJhMiIsInN1YmplY3RLZXlJZCI6IjA2MDc1ZjQ4NGRjZTVjMDc0NWNhNzY1YTg4OTkzMDliZDcxNzYxMjk0N2EzNzcwYTBkMjdiOGNjYjhiZjkwNTIiLCJzdWJqZWN0UHVibGljS2V5IjoiQkFOeXZHLWdGV0tTZ3JLY3dQZFRhMEcwM0JvUmFCRmhodmtqdER6eVNmbnpxV1ptNklKWE5EdUdGeUpEeVVJcXg2RkZyNVRWcl9uamFBc1VoaDlMU2RvIiwibm90QmVmb3JlIjoiMjAyMC0wMS0wMVQwMDowMDowMFoiLCJub3RBZnRlciI6IjIwOTktMDEtMDFUMDA6MDA6MDBaIn0=","signature":{"algorithm":"ES256","keyId":"a9559bd4e0949acc122affc55e6db8e91f8d097e55ec899da6191e469c4882a2","value":"815kTcLcXdevNDTbkWArMJJzNE5fhGqm3ToxS3q1v_YBDUrabzWfLXc0G9N6bUvExGwXBT1QiW2oP0B3rJQ-2w"}}] diff --git a/scripts/check_offline_enrollment_e2e.sh b/scripts/check_offline_enrollment_e2e.sh new file mode 100755 index 000000000..05d38aa5f --- /dev/null +++ b/scripts/check_offline_enrollment_e2e.sh @@ -0,0 +1,78 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +fixture_root="${repo_root}/rustfs/tests/fixtures/offline-enrollment-e2e" +root_fixture="${fixture_root}/root.json" +challenge_fixture="${fixture_root}/challenge.json" + +for command in cargo python3; do + command -v "${command}" >/dev/null 2>&1 || { + echo "offline enrollment E2E gate: ${command} is required" >&2 + exit 1 + } +done + +read_fixture_field() +{ + python3 -c 'import json, sys; value=json.load(open(sys.argv[1], encoding="utf-8"))[sys.argv[2]]; assert isinstance(value, str) and value; print(value)' "$1" "$2" +} + +RUSTFS_E2E_OFFLINE_ENROLLMENT_ROOT_KEY_ID="$(read_fixture_field "${root_fixture}" keyId)" +RUSTFS_E2E_OFFLINE_ENROLLMENT_ROOT_PUBLIC_KEY="$(read_fixture_field "${root_fixture}" publicKey)" +RUSTFS_E2E_OFFLINE_ENROLLMENT_FIXTURE_TIME="$( + python3 -c 'import base64, datetime, json, sys; envelope=json.load(open(sys.argv[1], encoding="utf-8")); document=json.loads(base64.b64decode(envelope["bytes"], validate=True)); print(int(datetime.datetime.fromisoformat(document["issuedAt"].replace("Z", "+00:00")).timestamp()))' "${challenge_fixture}" +)" +export RUSTFS_E2E_OFFLINE_ENROLLMENT_ROOT_KEY_ID +export RUSTFS_E2E_OFFLINE_ENROLLMENT_ROOT_PUBLIC_KEY +export RUSTFS_E2E_OFFLINE_ENROLLMENT_FIXTURE_TIME + +[[ "${RUSTFS_E2E_OFFLINE_ENROLLMENT_ROOT_KEY_ID}" =~ ^[0-9a-f]{64}$ ]] \ + || { echo 'offline enrollment E2E gate: fixture root key id is invalid' >&2; exit 1; } +[[ "${RUSTFS_E2E_OFFLINE_ENROLLMENT_ROOT_PUBLIC_KEY}" =~ ^[A-Za-z0-9_-]{87}$ ]] \ + || { echo 'offline enrollment E2E gate: fixture root public key is invalid' >&2; exit 1; } +[[ "${RUSTFS_E2E_OFFLINE_ENROLLMENT_FIXTURE_TIME}" =~ ^[0-9]+$ ]] \ + || { echo 'offline enrollment E2E gate: fixture evaluation time is invalid' >&2; exit 1; } + +task_root="$(mktemp -d "${TMPDIR:-/tmp}/rustfs-offline-enrollment-e2e.XXXXXX")" +case "${task_root}" in + "${TMPDIR:-/tmp}"/rustfs-offline-enrollment-e2e.*) ;; + *) echo 'offline enrollment E2E gate: unsafe temporary path' >&2; exit 1 ;; +esac +cleanup() +{ + rm -rf -- "${task_root}" +} +trap cleanup EXIT +trap 'exit 130' HUP INT TERM + +export CARGO_TARGET_DIR="${task_root}/target" +feature=offline-enrollment-e2e-root +cargo build --locked -p rustfs --bin rustfs-cli-e2e --features "${feature}" +cargo build --locked -p rustfs --bin rustfs-cli --features "${feature}" +cargo test --locked -p rustfs --test connect_offline_enrollment --features "${feature}" e2e_ + +"${CARGO_TARGET_DIR}/debug/rustfs-cli-e2e" connect offline enroll \ + --challenge "${challenge_fixture}" \ + --output "${task_root}/e2e-response.json" \ + --key-dir "${task_root}/e2e-key" \ + >"${task_root}/e2e.stdout" 2>"${task_root}/e2e.stderr" +test -s "${task_root}/e2e-response.json" \ + || { echo 'offline enrollment E2E gate: dedicated CLI produced no response' >&2; exit 1; } + +if "${CARGO_TARGET_DIR}/debug/rustfs-cli" connect offline enroll \ + --challenge "${challenge_fixture}" \ + --output "${task_root}/production-response.json" \ + --key-dir "${task_root}/production-key" \ + >"${task_root}/production.stdout" 2>"${task_root}/production.stderr" +then + echo 'offline enrollment E2E gate: production CLI accepted the E2E root' >&2 + exit 1 +fi +grep -Fq 'the offline enrollment trust chain is not issued by a root pinned in this build' \ + "${task_root}/production.stderr" \ + || { echo 'offline enrollment E2E gate: production CLI failed for the wrong reason' >&2; exit 1; } +test ! -e "${task_root}/production-response.json" \ + || { echo 'offline enrollment E2E gate: rejected production CLI wrote a response' >&2; exit 1; } + +echo 'Offline enrollment E2E root gate passed.'