diff --git a/.github/actions/setup-merobox/action.yml b/.github/actions/setup-merobox/action.yml index 31fd62aecb..e7809add9a 100644 --- a/.github/actions/setup-merobox/action.yml +++ b/.github/actions/setup-merobox/action.yml @@ -45,10 +45,15 @@ runs: # * the `delete_blob` step (admin-API blob delete that cascades # parent+chunks+metadata, merobox#285 — 0.6.39) used by # 37-stranded-resync (the old delete_blob_on_disk rm was a no-op for - # chunked blobs, so it could not stage the strand). + # chunked blobs, so it could not stage the strand), and + # * the `login` step forwarding MERO_AUTH_BOOTSTRAP_SECRET into the + # node container and presenting it as provider_data.bootstrap_secret + # (merobox#292 — 0.6.42), required now that first-root-key bootstrap + # demands the out-of-band secret (core#3221). Without it every + # fresh-node scenario login fails "Invalid credentials". # Pin the floor so an older build fails loudly rather than skipping # steps or collecting empty log artifacts. - MIN_MEROBOX="0.6.39" + MIN_MEROBOX="0.6.42" HAVE_MEROBOX="$(merobox --version | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1)" if [ "$(printf '%s\n%s\n' "$MIN_MEROBOX" "$HAVE_MEROBOX" | sort -V | head -1)" != "$MIN_MEROBOX" ]; then echo "::error::merobox ${HAVE_MEROBOX} is older than the required ${MIN_MEROBOX} (delete_blob step, merobox#285)" diff --git a/.github/workflows/e2e-rust-apps-release.yml b/.github/workflows/e2e-rust-apps-release.yml index 2b70045398..58f7f29fcd 100644 --- a/.github/workflows/e2e-rust-apps-release.yml +++ b/.github/workflows/e2e-rust-apps-release.yml @@ -28,6 +28,11 @@ env: RUST_BACKTRACE: 1 CALIMERO_CONTRACTS_VERSION: "latest" CARGO_TARGET_DIR: ${{ github.workspace }}/target + # Out-of-band secret gating first-root-key bootstrap (fail-closed since the + # TOFU removal). Requires merobox pass-through support (forward into node + # containers + present in the `login` step); inert on older merobox. See + # the matching comment in e2e-rust-apps.yml. + MERO_AUTH_BOOTSTRAP_SECRET: merobox-e2e-bootstrap jobs: build-apps: diff --git a/.github/workflows/e2e-rust-apps.yml b/.github/workflows/e2e-rust-apps.yml index 5375cf5ecc..fed3cdcf58 100644 --- a/.github/workflows/e2e-rust-apps.yml +++ b/.github/workflows/e2e-rust-apps.yml @@ -117,6 +117,16 @@ jobs: uses: ./.github/actions/setup-merobox - name: Run auth-seam e2e (merobox, embedded auth) + env: + # Out-of-band secret gating first-root-key bootstrap (fail-closed + # since the TOFU removal). Binary-mode merobox spawns merod with + # os.environ inherited, and script steps (target: local) inherit + # it too — so this single export both provisions the node's + # expected secret AND lets scripts/e2e-auth-seam.sh present the + # same value in provider_data.bootstrap_secret on the first + # login. CI-only test value for an ephemeral localhost node; not + # baked into any image or shipped config. + MERO_AUTH_BOOTSTRAP_SECRET: auth-seam-ci-bootstrap run: | merobox bootstrap run workflows/auth-seam.yml \ --no-docker --binary-path ./merod --auth-mode embedded @@ -463,6 +473,18 @@ jobs: uses: ./.github/actions/setup-merobox - name: Run ${{ matrix.workflow }} + env: + # Out-of-band secret gating first-root-key bootstrap (fail-closed + # since the TOFU removal). Requires merobox pass-through support: + # merobox must forward this var into node containers AND present + # it as provider_data.bootstrap_secret in its `login` step + # (tracked cross-repo in calimero-network/merobox). Inert on older + # merobox; once the pass-through ships via the APT repo these + # workflows heal without further changes here. Bump MIN_MEROBOX in + # .github/actions/setup-merobox when that lands. CI-only test + # value for ephemeral containers; not baked into any image or + # shipped config. + MERO_AUTH_BOOTSTRAP_SECRET: merobox-e2e-bootstrap run: | # Phase 11.3 (#2237): single attempt, fail loud. The retry loop # + `merobox nuke --force` between attempts existed to mask diff --git a/.github/workflows/sdk-e2e.yml b/.github/workflows/sdk-e2e.yml index 8e6cebe582..d4f829dd55 100644 --- a/.github/workflows/sdk-e2e.yml +++ b/.github/workflows/sdk-e2e.yml @@ -31,6 +31,24 @@ on: env: CARGO_TERM_COLOR: always SERVER_PORT: "2428" + # Out-of-band secret gating first-root-key bootstrap (fail-closed since the + # TOFU removal). The boot step exports it into merod's environment; the + # "Bootstrap root key" step presents the same value to mint the dev/dev root + # key BEFORE the SDK suite runs, so mero-js's plain username/password logins + # authenticate on the existing-user fast path and never need the secret. + # CI-only test value for an ephemeral localhost node. + MERO_AUTH_BOOTSTRAP_SECRET: sdk-e2e-ci-bootstrap + # Root-user credentials, pre-provisioned by the "Bootstrap root key" step + # and consumed by mero-js's e2e harness (tests/e2e/harness.ts resolveCreds() + # reads MERO_E2E_USER / MERO_E2E_PASS, defaulting to dev/dev). Pinning them + # here keeps both sides in lockstep even if the harness defaults change. + # + # The password MUST satisfy the provider's configured minimum length + # (default 8): the "Bootstrap root key" step below creates the root key, and + # creation enforces the minimum (a short password → 401 at bootstrap). This + # workflow-level value is what that step inherits, so it cannot be `dev`. + MERO_E2E_USER: dev + MERO_E2E_PASS: dev-password jobs: sdk-e2e: @@ -99,18 +117,41 @@ jobs: if [ "$i" = "60" ]; then echo "merod did not become healthy"; exit 1; fi done + - name: Bootstrap root key (out-of-band secret) + # First-root-key bootstrap requires MERO_AUTH_BOOTSTRAP_SECRET (the + # node inherited it from the boot step's environment). Mint the root + # key for MERO_E2E_USER here so every login the SDK suite performs is + # a plain existing-user authentication — including the negative test + # that asserts wrong/wrong is rejected (a root key exists, so the + # bootstrap path never re-opens). + run: | + BODY=$(jq -n --arg u "$MERO_E2E_USER" --arg p "$MERO_E2E_PASS" \ + --arg s "$MERO_AUTH_BOOTSTRAP_SECRET" --argjson ts "$(date +%s)" \ + '{auth_method: "user_password", public_key: $u, + client_name: "sdk-e2e-ci", permissions: ["admin"], timestamp: $ts, + provider_data: {username: $u, password: $p, bootstrap_secret: $s}}') + RESP=$(curl -fsS -m 10 -X POST "http://localhost:$SERVER_PORT/auth/token" \ + -H 'Content-Type: application/json' -d "$BODY") || { + echo "::error::root-key bootstrap request failed"; exit 1; } + TOKEN=$(echo "$RESP" | jq -r '.data.access_token // empty') + if [ -z "$TOKEN" ]; then + echo "::error::root-key bootstrap did not return a token: $RESP" + exit 1 + fi + echo "root key bootstrapped for user $MERO_E2E_USER" + - name: Run mero-js e2e against booted node working-directory: _mero-js env: NODE_BASE_URL: "http://localhost:${{ env.SERVER_PORT }}" # The e2e coverage recorder writes the endpoints it hit here. MERO_COVERAGE_OUT: "${{ github.workspace }}/_mero-js/covered-endpoints.json" - # The SDK harness bootstraps the node's first root key, so its password - # must satisfy the provider's minimum length (default 8). The harness - # would otherwise fall back to its own `dev`/`dev` default and be - # rejected at creation. - MERO_E2E_USER: dev - MERO_E2E_PASS: dev-password + # MERO_E2E_USER / MERO_E2E_PASS are inherited from the workflow-level + # env (dev / dev-password) so the suite logs in with the exact + # credentials the "Bootstrap root key" step provisioned. Do not + # re-declare them here — a second, divergent value is what previously + # let the bootstrap step mint one password while the suite logged in + # with another. run: | pnpm install --frozen-lockfile pnpm test:e2e diff --git a/crates/auth/src/config.rs b/crates/auth/src/config.rs index 6dc928bdd4..be691cc556 100644 --- a/crates/auth/src/config.rs +++ b/crates/auth/src/config.rs @@ -91,6 +91,19 @@ pub struct UserPasswordConfig { /// Maximum password length #[serde(default = "default_max_password_length")] pub max_password_length: usize, + + /// Out-of-band secret required to bootstrap the very first root key on a + /// fresh node. + /// + /// When this is `None` (and the `MERO_AUTH_BOOTSTRAP_SECRET` environment + /// variable is unset), first-login bootstrap is **disabled** — the node + /// will not mint a root key for an unauthenticated caller, and a root key + /// must be provisioned out of band. When set, the bootstrapping client + /// must present a matching secret before the first root key is created. + /// An empty string is treated the same as `None` (bootstrap disabled), so + /// a blank value from env interpolation can never open the gate. + #[serde(default)] + pub bootstrap_secret: Option, } impl Default for UserPasswordConfig { @@ -98,6 +111,7 @@ impl Default for UserPasswordConfig { Self { min_password_length: 8, max_password_length: 128, + bootstrap_secret: None, } } } diff --git a/crates/auth/src/providers/impls/user_password.rs b/crates/auth/src/providers/impls/user_password.rs index b80af27b7f..109fa1501f 100644 --- a/crates/auth/src/providers/impls/user_password.rs +++ b/crates/auth/src/providers/impls/user_password.rs @@ -9,6 +9,7 @@ use ring::pbkdf2; use serde::{Deserialize, Serialize}; use serde_json::Value; use sha2::{Digest, Sha256}; +use subtle::ConstantTimeEq; use tracing::{debug, error, info, warn}; use validator::Validate; @@ -115,6 +116,10 @@ pub struct UserPasswordAuthData { pub username: String, /// Password (will be hashed) pub password: String, + /// Out-of-band bootstrap secret, only consulted when creating the very + /// first root key on a fresh node (ignored for existing users). + #[serde(default)] + pub bootstrap_secret: Option, } /// Username/password auth data type for the registry @@ -304,12 +309,48 @@ impl UserPasswordProvider { Ok((key_id, root_key)) } + /// Resolve the effective bootstrap secret. + /// + /// The `MERO_AUTH_BOOTSTRAP_SECRET` environment variable takes precedence + /// (the recommended out-of-band channel); the configured value is used as + /// a fallback. Returns `None` when neither is set, which disables + /// first-login bootstrap entirely. An empty string in either source is + /// treated as unset — otherwise a blank env interpolation or + /// `bootstrap_secret = ""` in config would let `""` match a caller that + /// omitted the field (`unwrap_or_default`), silently re-enabling the + /// unauthenticated TOFU bootstrap this gate exists to close. + fn effective_bootstrap_secret(&self) -> Option { + std::env::var("MERO_AUTH_BOOTSTRAP_SECRET") + .ok() + .filter(|s| !s.is_empty()) + .or_else(|| { + self.config + .bootstrap_secret + .clone() + .filter(|s| !s.is_empty()) + }) + } + + /// Constant-time comparison of a presented bootstrap secret against the + /// expected one. + /// + /// Both values are hashed to fixed-length digests first, so the comparison + /// runs in time independent of the secret's length or content and does not + /// leak the expected length. + fn bootstrap_secret_matches(expected: &str, provided: &str) -> bool { + let expected_digest = Sha256::digest(expected.as_bytes()); + let provided_digest = Sha256::digest(provided.as_bytes()); + expected_digest.ct_eq(&provided_digest).into() + } + /// Core authentication logic for username/password /// /// # Arguments /// /// * `username` - The username /// * `password` - The password + /// * `bootstrap_secret` - The out-of-band secret presented by the caller, + /// only consulted on the first-root-key bootstrap path /// /// # Returns /// @@ -318,6 +359,7 @@ impl UserPasswordProvider { &self, username: &str, password: &str, + bootstrap_secret: Option<&str>, ) -> eyre::Result<(String, Vec)> { // On the authentication path enforce only the MAXIMUM length (a bound on // PBKDF2 work per request). The minimum is a policy for new credentials @@ -345,7 +387,35 @@ impl UserPasswordProvider { .await?; if existing_keys.is_empty() { - // Bootstrap case - create the first root key + // Bootstrap case - create the first root key, but only for a caller + // that proves possession of the out-of-band bootstrap secret. + // Without this gate the first unauthenticated caller to reach a + // fresh node is silently granted a ROOT admin key (trust-on-first- + // use). We deliberately return the same generic error on every + // failure path so a probe cannot distinguish "bootstrap disabled", + // "wrong secret", and "already bootstrapped". + let Some(expected_secret) = self.effective_bootstrap_secret() else { + debug!("Bootstrap rejected: no bootstrap secret configured (bootstrap disabled)"); + return Err(eyre::eyre!("Invalid username or password")); + }; + + // Defense in depth: an empty presented secret can never bootstrap, + // regardless of what the expected secret resolves to. Callers that + // omit the field default to "" (`unwrap_or_default`), so without + // this guard a future regression that lets an empty *expected* + // secret through would make SHA-256("") == SHA-256("") re-enable + // the unauthenticated TOFU bootstrap this gate exists to close. + let provided_secret = bootstrap_secret.unwrap_or_default(); + if provided_secret.is_empty() { + debug!("Bootstrap rejected: empty bootstrap secret presented"); + return Err(eyre::eyre!("Invalid username or password")); + } + + if !Self::bootstrap_secret_matches(&expected_secret, provided_secret) { + debug!("Bootstrap rejected: bootstrap secret missing or mismatched"); + return Err(eyre::eyre!("Invalid username or password")); + } + let (key_id, root_key) = self.create_root_key(username, password).await?; debug!( user = %crate::utils::sanitize_for_log(username), @@ -373,7 +443,11 @@ impl AuthVerifierFn for UserPasswordVerifier { // Authenticate using the core authentication logic let (key_id, permissions) = self .provider - .authenticate_core(&auth_data.username, &auth_data.password) + .authenticate_core( + &auth_data.username, + &auth_data.password, + auth_data.bootstrap_secret.as_deref(), + ) .await?; // Return the authentication response @@ -407,6 +481,11 @@ pub struct UserPasswordRequest { /// Password #[validate(length(min = 1, message = "Password is required"))] pub password: String, + + /// Optional out-of-band bootstrap secret, only used to create the first + /// root key on a fresh node. + #[serde(default)] + pub bootstrap_secret: Option, } #[async_trait] @@ -458,7 +537,8 @@ impl AuthProvider for UserPasswordProvider { // Create username/password specific auth data JSON Ok(serde_json::json!({ "username": user_pass_data.username, - "password": user_pass_data.password + "password": user_pass_data.password, + "bootstrap_secret": user_pass_data.bootstrap_secret })) } @@ -512,8 +592,18 @@ impl AuthProvider for UserPasswordProvider { .map_err(|_| eyre::eyre!("Invalid password"))? .to_string(); + // Optional out-of-band bootstrap secret header. + let bootstrap_secret = headers + .get("x-bootstrap-secret") + .and_then(|h| h.to_str().ok()) + .map(str::to_string); + // Create auth data - let auth_data = UserPasswordAuthData { username, password }; + let auth_data = UserPasswordAuthData { + username, + password, + bootstrap_secret, + }; // Create verifier let provider = Arc::new(self.clone()); @@ -616,20 +706,15 @@ mod tests { use sha2::{Digest, Sha256}; use super::*; - - fn old_unsalted_key_id(username: &str, password: &str) -> String { - let mut hasher = Sha256::new(); - hasher.update(format!("user_password:{username}:{password}").as_bytes()); - hex::encode(hasher.finalize()) - } - - /// A provider backed by in-memory storage, with the default length policy - /// (min 8 / max 128). - fn test_provider() -> UserPasswordProvider { - use crate::config::JwtConfig; - use crate::secrets::SecretManager; - use crate::storage::MemoryStorage; - + use crate::config::JwtConfig; + use crate::secrets::SecretManager; + use crate::storage::models::KeyType; + use crate::storage::MemoryStorage; + + /// A provider backed by in-memory storage. `config` lets a test set the + /// bootstrap secret and length policy; pass `UserPasswordConfig::default()` + /// for the default (min 8 / max 128, bootstrap disabled). + fn test_provider(config: UserPasswordConfig) -> UserPasswordProvider { let storage: Arc = Arc::new(MemoryStorage::new()); let secret_manager = Arc::new(SecretManager::new(Arc::clone(&storage))); let token_manager = TokenManager::new( @@ -641,24 +726,176 @@ mod tests { Arc::clone(&storage), secret_manager, ); - // Constructed directly rather than through `new(ProviderContext, _)`: - // `ProviderContext` carries a full `AuthConfig` the provider never keeps. UserPasswordProvider { storage: Arc::clone(&storage), key_manager: KeyManager::new(storage), token_manager, - config: UserPasswordConfig::default(), + config, } } - // --- legacy key-id migration (upgrade path) -------------------------- + fn config_with_secret(secret: Option<&str>) -> UserPasswordConfig { + UserPasswordConfig { + bootstrap_secret: secret.map(str::to_string), + ..UserPasswordConfig::default() + } + } + + async fn root_key_count(provider: &UserPasswordProvider) -> usize { + provider + .key_manager + .list_keys(KeyType::Root) + .await + .unwrap() + .len() + } + + fn old_unsalted_key_id(username: &str, password: &str) -> String { + let mut hasher = Sha256::new(); + hasher.update(format!("user_password:{username}:{password}").as_bytes()); + hex::encode(hasher.finalize()) + } + + // --- bootstrap secret gate (finding #2) ------------------------------ + + #[tokio::test] + async fn bootstrap_disabled_by_default_rejects_first_login() { + let provider = test_provider(config_with_secret(None)); + let result = provider + .authenticate_core("admin", "correct horse battery staple", None) + .await; + assert!( + result.is_err(), + "bootstrap must fail closed when no bootstrap secret is configured" + ); + assert_eq!( + root_key_count(&provider).await, + 0, + "no root key should be minted without a bootstrap secret" + ); + } + + #[tokio::test] + async fn bootstrap_requires_matching_secret() { + let provider = test_provider(config_with_secret(Some("s3cr3t-bootstrap"))); + + // Missing secret -> rejected, no key created. + assert!(provider + .authenticate_core("admin", "password-1", None) + .await + .is_err()); + assert_eq!(root_key_count(&provider).await, 0); + + // Wrong secret -> rejected, no key created. + assert!(provider + .authenticate_core("admin", "password-1", Some("wrong")) + .await + .is_err()); + assert_eq!(root_key_count(&provider).await, 0); + + // Correct secret -> exactly one admin root key minted. + let (_, perms) = provider + .authenticate_core("admin", "password-1", Some("s3cr3t-bootstrap")) + .await + .expect("correct bootstrap secret should succeed"); + assert!(perms.contains(&"admin".to_string())); + assert_eq!(root_key_count(&provider).await, 1); + } + + #[tokio::test] + async fn existing_user_authenticates_without_bootstrap_secret() { + let provider = test_provider(config_with_secret(Some("s3cr3t-bootstrap"))); + + // Bootstrap the first key with the secret. + provider + .authenticate_core("admin", "password-1", Some("s3cr3t-bootstrap")) + .await + .unwrap(); + assert_eq!(root_key_count(&provider).await, 1); + + // The now-existing user authenticates on the fast path, no secret needed. + let (_, perms) = provider + .authenticate_core("admin", "password-1", None) + .await + .expect("existing user should authenticate without the bootstrap secret"); + assert!(perms.contains(&"admin".to_string())); + assert_eq!(root_key_count(&provider).await, 1, "no duplicate root key"); + + // Once a root key exists, a different identity cannot bootstrap a second + // one even with the correct secret. + assert!(provider + .authenticate_core("intruder", "password-2", Some("s3cr3t-bootstrap")) + .await + .is_err()); + assert_eq!(root_key_count(&provider).await, 1); + } + + #[tokio::test] + async fn empty_config_bootstrap_secret_keeps_bootstrap_disabled() { + // `bootstrap_secret = ""` (e.g. a blank env interpolation in config) + // must behave exactly like no secret at all: bootstrap stays disabled + // and, critically, a caller omitting the field (which the verifier + // defaults to "") must not match SHA-256("") == SHA-256(""). + let provider = test_provider(config_with_secret(Some(""))); + + assert!(provider + .authenticate_core("admin", "password-1", None) + .await + .is_err()); + assert!(provider + .authenticate_core("admin", "password-1", Some("")) + .await + .is_err()); + assert_eq!( + root_key_count(&provider).await, + 0, + "an empty configured secret must never mint a root key" + ); + } + + #[tokio::test] + async fn empty_presented_secret_never_bootstraps() { + // An empty *presented* secret is rejected outright, before any + // comparison, even when a real secret is configured. Together with the + // empty-config filter this guarantees an all-empty pairing can never + // authenticate, even if one of the two guards regresses. + let provider = test_provider(config_with_secret(Some("s3cr3t-bootstrap"))); + + assert!(provider + .authenticate_core("admin", "password-1", Some("")) + .await + .is_err()); + assert!(provider + .authenticate_core("admin", "password-1", None) + .await + .is_err()); + assert_eq!( + root_key_count(&provider).await, + 0, + "an empty presented secret must never mint a root key" + ); + } + + #[test] + fn bootstrap_secret_matches_is_exact() { + assert!(UserPasswordProvider::bootstrap_secret_matches("abc", "abc")); + assert!(!UserPasswordProvider::bootstrap_secret_matches( + "abc", "abcd" + )); + assert!(!UserPasswordProvider::bootstrap_secret_matches("abc", "")); + assert!(!UserPasswordProvider::bootstrap_secret_matches( + "abc", "abC" + )); + } + + // --- legacy key-id migration (upgrade path, finding #4) -------------- #[tokio::test] async fn legacy_key_id_is_migrated_in_place_on_login() { // An upgraded node stores the root key under the OLD unsalted id. // Without migration the salted lookup misses, root keys exist so the // bootstrap branch is skipped, and the operator is locked out forever. - let provider = test_provider(); + let provider = test_provider(UserPasswordConfig::default()); let (user, pass) = ("alice", "correct horse battery staple"); let legacy_id = old_unsalted_key_id(user, pass); @@ -674,9 +911,10 @@ mod tests { .await .unwrap(); - // The existing operator can still log in. + // The existing operator can still log in (no bootstrap secret needed — + // they match on the migration/fast path, not the bootstrap branch). let (key_id, permissions) = provider - .authenticate_core(user, pass) + .authenticate_core(user, pass, None) .await .expect("an existing user must not be locked out by the key-id change"); @@ -704,13 +942,13 @@ mod tests { ); // A second login resolves directly via the new id. - let (again, _) = provider.authenticate_core(user, pass).await.unwrap(); + let (again, _) = provider.authenticate_core(user, pass, None).await.unwrap(); assert_eq!(again, new_id); } #[tokio::test] async fn wrong_password_does_not_migrate_or_authenticate() { - let provider = test_provider(); + let provider = test_provider(UserPasswordConfig::default()); let legacy_id = old_unsalted_key_id("alice", "right-password"); let key = Key::new_root_key_with_permissions( "alice".to_string(), @@ -726,7 +964,7 @@ mod tests { // Wrong password: no legacy key exists for THOSE credentials. assert!(provider - .authenticate_core("alice", "wrong-password") + .authenticate_core("alice", "wrong-password", None) .await .is_err()); // The real key is untouched. @@ -744,7 +982,7 @@ mod tests { async fn short_legacy_password_still_authenticates_after_upgrade() { // The min-length policy must not lock out a user whose password predates // it (e.g. the `dev`/`dev` credentials every e2e harness uses). - let provider = test_provider(); + let provider = test_provider(UserPasswordConfig::default()); let (user, pass) = ("dev", "dev"); // 3 chars, below the min of 8 let legacy_id = old_unsalted_key_id(user, pass); let key = Key::new_root_key_with_permissions( @@ -760,40 +998,39 @@ mod tests { .unwrap(); assert!( - provider.authenticate_core(user, pass).await.is_ok(), + provider.authenticate_core(user, pass, None).await.is_ok(), "an existing short password must still authenticate" ); } #[tokio::test] async fn bootstrap_still_enforces_the_minimum_length() { - // Creating a NEW credential is where the policy bites. - let provider = test_provider(); + // Creating a NEW credential is where the min-length policy bites. The + // bootstrap-secret gate runs first, so a valid secret must be presented + // to REACH the length check (that gate is covered separately above). + let provider = test_provider(config_with_secret(Some("s3cr3t-bootstrap"))); let err = provider - .authenticate_core("dev", "dev") + .authenticate_core("dev", "dev", Some("s3cr3t-bootstrap")) .await .expect_err("bootstrap with a too-short password must be rejected"); assert!( err.to_string().contains("at least"), "expected a min-length error, got: {err}" ); - assert!( - provider - .key_manager - .list_keys(crate::storage::models::KeyType::Root) - .await - .unwrap() - .is_empty(), + assert_eq!( + root_key_count(&provider).await, + 0, "no root key may be created when the password violates the policy" ); } #[tokio::test] async fn overlong_password_is_rejected_on_the_auth_path() { - // The maximum IS enforced at login: it bounds PBKDF2 work per request. - let provider = test_provider(); + // The maximum IS enforced at login: it bounds PBKDF2 work per request, + // so it fires before the bootstrap gate regardless of any secret. + let provider = test_provider(UserPasswordConfig::default()); let err = provider - .authenticate_core("alice", &"x".repeat(129)) + .authenticate_core("alice", &"x".repeat(129), None) .await .expect_err("an over-long password must be rejected before the KDF runs"); assert!( @@ -802,7 +1039,7 @@ mod tests { ); } - // --- #8: salted KDF key-id derivation ------------------------------- + // --- salted KDF key-id derivation (finding #4) ---------------------- #[test] fn test_derive_key_id_is_deterministic() { @@ -838,7 +1075,7 @@ mod tests { ); } - // --- #8: password length enforcement -------------------------------- + // --- password length enforcement (finding #17) ---------------------- #[test] fn test_password_too_short_rejected() { diff --git a/scripts/e2e-auth-seam.sh b/scripts/e2e-auth-seam.sh index 6066b505cd..0e24dab2d9 100755 --- a/scripts/e2e-auth-seam.sh +++ b/scripts/e2e-auth-seam.sh @@ -18,6 +18,13 @@ # NODE_URL defaults to http://localhost:4001. The node must be freshly # initialised with --auth-mode embedded (first login bootstraps the root # user with the credentials below). +# +# First-login bootstrap requires the out-of-band secret: export +# MERO_AUTH_BOOTSTRAP_SECRET before starting the node AND before running +# this script. merobox (binary mode) and this script both inherit the +# caller's environment, so a single export covers both ends — the node +# reads it as its expected secret, and the login below presents it in +# provider_data.bootstrap_secret. # POSIX sh, not bash: merobox's script step hardcodes /bin/sh (dash on # Ubuntu CI), ignoring the shebang. No pipefail — every pipeline's output @@ -27,8 +34,11 @@ set -eu NODE_URL="${1:-http://localhost:4001}" USERNAME="${MERO_E2E_USER:-dev}" # Must satisfy the provider's configured minimum length (default 8) — the -# bootstrap path enforces it for every NEW credential. +# bootstrap path enforces it for every NEW credential (finding #17). PASSWORD="${MERO_E2E_PASS:-dev-password}" +# Out-of-band bootstrap secret for the first root key (finding #2; empty = not +# presented, and the node then fails the bootstrap login closed, by design). +BOOTSTRAP_SECRET="${MERO_AUTH_BOOTSTRAP_SECRET:-}" # The exact permission strings mero-react demands for AppMode.MultiContext # (getPermissionsForMode) and auth-frontend forwards untouched. @@ -74,11 +84,15 @@ status_of() { # status_of [body] echo "== auth-seam e2e against $NODE_URL ==" # 1. Bootstrap root login (first login on a fresh embedded-auth node creates -# the root key with admin). -LOGIN_BODY=$(jq -n --arg u "$USERNAME" --arg p "$PASSWORD" --argjson ts "$(date +%s)" \ +# the root key with admin — gated on the out-of-band bootstrap secret, +# presented in provider_data.bootstrap_secret; omitted when unset so an +# unsecured run fails exactly like a real unprovisioned node would). +LOGIN_BODY=$(jq -n --arg u "$USERNAME" --arg p "$PASSWORD" --arg bs "$BOOTSTRAP_SECRET" \ + --argjson ts "$(date +%s)" \ '{auth_method: "user_password", public_key: $u, client_name: "auth-seam-e2e", permissions: ["admin"], timestamp: $ts, - provider_data: {username: $u, password: $p}}') + provider_data: ({username: $u, password: $p} + + (if $bs == "" then {} else {bootstrap_secret: $bs} end))}') ROOT_RESPONSE=$(curl -s -m 10 -X POST "$NODE_URL/auth/token" \ -H 'Content-Type: application/json' \ -d "$LOGIN_BODY") diff --git a/workflows/auth-seam.yml b/workflows/auth-seam.yml index 11f218cceb..c4116abf13 100644 --- a/workflows/auth-seam.yml +++ b/workflows/auth-seam.yml @@ -32,6 +32,12 @@ steps: # No `login` step needed: the seam script performs the bootstrap login # itself (first login on a fresh embedded-auth node creates the root # key), and node teardown is process-level, not HTTP. + # + # First-login bootstrap is gated on an out-of-band secret: export + # MERO_AUTH_BOOTSTRAP_SECRET before `merobox bootstrap run`. Binary-mode + # merobox starts merod with the inherited environment (the node's + # expected secret) and local script steps inherit it too (the script + # presents it in provider_data.bootstrap_secret) — one export, both ends. - name: "Run the client-token seam assertions" type: script script: scripts/e2e-auth-seam.sh @@ -41,5 +47,6 @@ stop_all_nodes: true nuke_on_end: true # How to run (from the repo root): +# export MERO_AUTH_BOOTSTRAP_SECRET= # merobox bootstrap run workflows/auth-seam.yml --no-docker \ # --binary-path ./target/debug/merod