From ea51337c7fa98d4731b606357e03ac60e4c755ba Mon Sep 17 00:00:00 2001 From: fran domovic Date: Mon, 13 Jul 2026 21:28:49 +0200 Subject: [PATCH 1/6] fix(auth): require out-of-band bootstrap secret for first root key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On a fresh node with no root keys, the first unauthenticated caller to POST /auth/token was silently granted a ROOT admin key (trust-on-first- use). Whoever reached the node first — potentially an attacker on the network rather than the operator — became admin. Gate the bootstrap path behind an out-of-band secret: the caller must present a secret matching MERO_AUTH_BOOTSTRAP_SECRET (or the configured user_password.bootstrap_secret). The comparison is constant-time over SHA-256 digests. When no secret is configured, first-login bootstrap is disabled (fail closed); a root key must then be provisioned out of band. All failure paths return the same generic error so a probe cannot tell "disabled", "wrong secret", and "already bootstrapped" apart. Existing users are unaffected — they authenticate on the fast path before the bootstrap branch and never need the secret. Co-Authored-By: Claude Opus 4.8 --- crates/auth/src/config.rs | 12 + .../auth/src/providers/impls/user_password.rs | 208 +++++++++++++++++- 2 files changed, 216 insertions(+), 4 deletions(-) diff --git a/crates/auth/src/config.rs b/crates/auth/src/config.rs index 6dc928bdd4..a9543751c5 100644 --- a/crates/auth/src/config.rs +++ b/crates/auth/src/config.rs @@ -91,6 +91,17 @@ 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. + #[serde(default)] + pub bootstrap_secret: Option, } impl Default for UserPasswordConfig { @@ -98,6 +109,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 7e41717546..bf44ee7df5 100644 --- a/crates/auth/src/providers/impls/user_password.rs +++ b/crates/auth/src/providers/impls/user_password.rs @@ -7,6 +7,7 @@ use axum::http::Request; use serde::{Deserialize, Serialize}; use serde_json::Value; use sha2::{Digest, Sha256}; +use subtle::ConstantTimeEq; use tracing::{debug, error}; use validator::Validate; @@ -28,6 +29,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 @@ -156,12 +161,39 @@ 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. + 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()) + } + + /// 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 /// @@ -170,6 +202,7 @@ impl UserPasswordProvider { &self, username: &str, password: &str, + bootstrap_secret: Option<&str>, ) -> eyre::Result<(String, Vec)> { // Try to verify existing credentials if let Some((key_id, root_key)) = self.verify_credentials(username, password).await? { @@ -190,7 +223,24 @@ 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")); + }; + + let provided_secret = bootstrap_secret.unwrap_or_default(); + 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), @@ -218,7 +268,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 @@ -252,6 +306,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] @@ -303,7 +362,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 })) } @@ -357,8 +417,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()); @@ -452,3 +522,133 @@ register_auth_provider!(UserPasswordProviderRegistration); // Register the username/password auth data type register_auth_data_type!(UserPasswordAuthDataType); + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::JwtConfig; + use crate::secrets::SecretManager; + use crate::storage::models::KeyType; + use crate::storage::providers::memory::MemoryStorage; + + 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( + JwtConfig { + issuer: "test".to_string(), + access_token_expiry: 3600, + refresh_token_expiry: 30 * 24 * 3600, + }, + Arc::clone(&storage), + secret_manager, + ); + let key_manager = KeyManager::new(Arc::clone(&storage)); + UserPasswordProvider { + storage, + key_manager, + token_manager, + config, + } + } + + 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() + } + + #[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", "pw", None) + .await + .is_err()); + assert_eq!(root_key_count(&provider).await, 0); + + // Wrong secret -> rejected, no key created. + assert!(provider + .authenticate_core("admin", "pw", 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", "pw", 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", "pw", 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", "pw", 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", "pw2", Some("s3cr3t-bootstrap")) + .await + .is_err()); + assert_eq!(root_key_count(&provider).await, 1); + } + + #[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" + )); + } +} From c7f9c2691239ac715a6db88e7677b1c66d7a42e5 Mon Sep 17 00:00:00 2001 From: fran domovic Date: Tue, 14 Jul 2026 11:22:48 +0200 Subject: [PATCH 2/6] fix(auth): treat empty bootstrap_secret config as unset Review follow-up: the env var was filtered for emptiness but the config fallback was not, so bootstrap_secret = "" (blank env interpolation) would match a caller that omitted the field (unwrap_or_default) and silently re-enable unauthenticated TOFU bootstrap. Filter both sources and pin with a unit test. Co-Authored-By: Claude Fable 5 --- crates/auth/src/config.rs | 2 ++ .../auth/src/providers/impls/user_password.rs | 36 +++++++++++++++++-- 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/crates/auth/src/config.rs b/crates/auth/src/config.rs index a9543751c5..be691cc556 100644 --- a/crates/auth/src/config.rs +++ b/crates/auth/src/config.rs @@ -100,6 +100,8 @@ pub struct UserPasswordConfig { /// 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, } diff --git a/crates/auth/src/providers/impls/user_password.rs b/crates/auth/src/providers/impls/user_password.rs index bf44ee7df5..69b35d3af8 100644 --- a/crates/auth/src/providers/impls/user_password.rs +++ b/crates/auth/src/providers/impls/user_password.rs @@ -166,12 +166,21 @@ impl UserPasswordProvider { /// 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. + /// 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()) + .or_else(|| { + self.config + .bootstrap_secret + .clone() + .filter(|s| !s.is_empty()) + }) } /// Constant-time comparison of a presented bootstrap secret against the @@ -640,6 +649,29 @@ mod tests { assert_eq!(root_key_count(&provider).await, 1); } + #[tokio::test] + async fn empty_config_secret_is_treated_as_unset() { + // `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", "pw", None) + .await + .is_err()); + assert!(provider + .authenticate_core("admin", "pw", Some("")) + .await + .is_err()); + assert_eq!( + root_key_count(&provider).await, + 0, + "an empty configured secret must never mint a root key" + ); + } + #[test] fn bootstrap_secret_matches_is_exact() { assert!(UserPasswordProvider::bootstrap_secret_matches("abc", "abc")); From 90110a0dd1c429af6f12199e3efcf56511dd2817 Mon Sep 17 00:00:00 2001 From: fran domovic Date: Tue, 14 Jul 2026 11:49:02 +0200 Subject: [PATCH 3/6] fix(auth): reject an empty presented bootstrap secret outright Defense in depth for the empty-string bypass class: the empty-config filter (previous commit) closes one end, but the caller side defaults an omitted bootstrap_secret to "" (unwrap_or_default), so any future regression that lets an empty *expected* secret through would re-enable SHA-256("") == SHA-256("") TOFU bootstrap. Guard the presented secret before any comparison so an all-empty pairing can never authenticate, even if one of the two filters regresses. Tests: empty_config_bootstrap_secret_keeps_bootstrap_disabled (renamed from empty_config_secret_is_treated_as_unset) and the new empty_presented_secret_never_bootstraps pin both ends against a fresh KeyManager and assert zero root keys are minted. Co-Authored-By: Claude Fable 5 --- .../auth/src/providers/impls/user_password.rs | 36 ++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/crates/auth/src/providers/impls/user_password.rs b/crates/auth/src/providers/impls/user_password.rs index 69b35d3af8..04c542f43c 100644 --- a/crates/auth/src/providers/impls/user_password.rs +++ b/crates/auth/src/providers/impls/user_password.rs @@ -244,7 +244,18 @@ impl UserPasswordProvider { 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")); @@ -650,7 +661,7 @@ mod tests { } #[tokio::test] - async fn empty_config_secret_is_treated_as_unset() { + 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 @@ -672,6 +683,29 @@ mod tests { ); } + #[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", "pw", Some("")) + .await + .is_err()); + assert!(provider + .authenticate_core("admin", "pw", 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")); From ffcba1872932ac730c3a3bfcc1ba626ae363f00a Mon Sep 17 00:00:00 2001 From: fran domovic Date: Tue, 14 Jul 2026 11:52:00 +0200 Subject: [PATCH 4/6] ci(e2e): provision the out-of-band bootstrap secret in the e2e harnesses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bootstrap gate (fail-closed since the TOFU removal) broke every e2e flow that relied on first-login root bootstrap. Re-provision each harness without weakening the server-side gate: * auth-seam: export MERO_AUTH_BOOTSTRAP_SECRET on the CI step. Binary- mode merobox spawns merod with os.environ inherited and local script steps inherit it too, so one export provisions the node's expected secret AND lets scripts/e2e-auth-seam.sh present the same value in provider_data.bootstrap_secret on the bootstrap login (omitted when unset, so an unprovisioned run still fails closed). * sdk-e2e: export the secret into merod's environment at boot and mint the MERO_E2E_USER root key with one bootstrap login BEFORE the mero-js suite runs — its plain username/password logins then authenticate on the existing-user fast path and never need the secret. Credentials are pinned via MERO_E2E_USER/MERO_E2E_PASS (mero-js resolveCreds reads them) so both sides stay in lockstep. * scenario matrix (e2e-rust-apps + release variant): pre-stage the same env var. Inert today — merobox (<=0.6.41) neither forwards env into node containers nor presents a bootstrap secret in its login step — but once merobox ships the pass-through via the APT repo these jobs heal without further changes here (then bump MIN_MEROBOX). Verified locally with merobox 0.6.41 (binary mode): auth-seam passes 7/7 with the secret exported, and fails closed (root login rejected) without it. Co-Authored-By: Claude Fable 5 --- .github/workflows/e2e-rust-apps-release.yml | 5 +++ .github/workflows/e2e-rust-apps.yml | 22 +++++++++++++ .github/workflows/sdk-e2e.yml | 36 +++++++++++++++++++++ scripts/e2e-auth-seam.sh | 20 ++++++++++-- workflows/auth-seam.yml | 7 ++++ 5 files changed, 87 insertions(+), 3 deletions(-) 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 340a70d23d..3691dad259 100644 --- a/.github/workflows/sdk-e2e.yml +++ b/.github/workflows/sdk-e2e.yml @@ -31,6 +31,19 @@ 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. + MERO_E2E_USER: dev + MERO_E2E_PASS: dev jobs: sdk-e2e: @@ -99,6 +112,29 @@ 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: diff --git a/scripts/e2e-auth-seam.sh b/scripts/e2e-auth-seam.sh index 1fa0dab6d3..5d7b5e3d13 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,6 +34,9 @@ set -eu NODE_URL="${1:-http://localhost:4001}" USERNAME="${MERO_E2E_USER:-dev}" PASSWORD="${MERO_E2E_PASS:-dev}" +# Out-of-band bootstrap secret for the first root key (empty = not presented; +# 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. @@ -72,11 +82,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 From 5074e53231793e5f36c65da3689482a75052ddc2 Mon Sep 17 00:00:00 2001 From: fran domovic Date: Wed, 15 Jul 2026 13:27:27 +0200 Subject: [PATCH 5/6] ci: require merobox >= 0.6.42 for the bootstrap-secret login passthrough (merobox#292) --- .github/actions/setup-merobox/action.yml | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) 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)" From 3aa7adb1b38db5814bdac034850bd2e5a3c53e00 Mon Sep 17 00:00:00 2001 From: fran domovic Date: Wed, 15 Jul 2026 13:42:54 +0200 Subject: [PATCH 6/6] ci(sdk-e2e): bootstrap root key with a policy-length password The workflow-level MERO_E2E_PASS was 'dev' (3 chars), which the 'Bootstrap root key' step inherits and presents to create the first root key. Key creation enforces the configured minimum password length (default 8, finding #17), so bootstrap returned 401 and the SDK suite could not run. Set the workflow-level password to 'dev-password' and drop the divergent per-step override so the value the bootstrap step mints and the value the mero-js suite logs in with can never disagree again. --- .github/workflows/sdk-e2e.yml | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/.github/workflows/sdk-e2e.yml b/.github/workflows/sdk-e2e.yml index 81f3200f97..d4f829dd55 100644 --- a/.github/workflows/sdk-e2e.yml +++ b/.github/workflows/sdk-e2e.yml @@ -42,8 +42,13 @@ env: # 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 + MERO_E2E_PASS: dev-password jobs: sdk-e2e: @@ -141,12 +146,12 @@ jobs: 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