zeroclaw-labs/zeroclaw · error

Invalid OTP code; estop resume denied

Error message

Invalid OTP code; estop resume denied

What it means

Resuming from estop was refused because the supplied OTP code failed validation (validator.validate returned false). This fires only when the estop config sets require_otp_to_resume=true: the emergency-stop state is security-relevant, so unpausing it demands a current, valid one-time code.

Source

Thrown at crates/zeroclaw-runtime/src/security/estop.rs:206

    fn ensure_resume_is_authorized(
        &self,
        otp_code: Option<&str>,
        otp_validator: Option<&OtpValidator>,
    ) -> Result<()> {
        if !self.config.require_otp_to_resume {
            return Ok(());
        }

        let code = otp_code
            .map(str::trim)
            .filter(|value| !value.is_empty())
            .context("OTP code is required to resume estop state")?;
        let validator = otp_validator
            .context("OTP validator is required to resume estop state with OTP enabled")?;
        let valid = validator.validate(code)?;
        if !valid {
            anyhow::bail!("Invalid OTP code; estop resume denied");
        }
        Ok(())
    }

    fn persist_state(&mut self) -> Result<()> {
        if let Some(parent) = self.state_path.parent() {
            fs::create_dir_all(parent).with_context(|| {
                format!(
                    "Failed to create estop state dir {}",
                    parent.display().to_string()
                )
            })?;
        }

        let body =
            serde_json::to_string_pretty(&self.state).context("Failed to serialize estop state")?;

        let temp_path = self

View on GitHub (pinned to 88bb9c8533)

Solutions

  1. Generate a fresh code from the current time window and retry — never reuse a code.
  2. Check clock sync on both the authenticator device and the daemon host (NTP).
  3. Verify the validator and the user's authenticator share the same OTP secret/step.
  4. If the secret is lost, re-provision OTP, or temporarily disable require_otp_to_resume with appropriate authorization.

Example fix

// before
estop.resume(selector, Some("123455"), Some(&validator))?; // typo, denied

// after — sanity-check, then retry with the current code
let code = read_current_code().trim().to_string();
if !(6..=8).contains(&code.len()) || !code.chars().all(|c| c.is_ascii_digit()) {
    anyhow::bail!("otp code malformed before submit");
}
match estop.resume(selector, Some(&code), Some(&validator)) {
    Err(e) if e.to_string().contains("Invalid OTP") => retry_with_next_code(),
    other => other?,
}
Defensive patterns

Strategy: retry

Validate before calling

fn plausible_otp(code: &str) -> bool {
    let c = code.trim();
    (6..=8).contains(&c.len()) && c.chars().all(|d| d.is_ascii_digit())
}

Try / catch

match estop.resume(selector, Some(&code), Some(&validator)) {
    Err(e) if e.to_string().contains("Invalid OTP") => {
        // prompt for the CURRENT code and retry once; do not loop without backoff
    }
    other => other?,
}

Prevention

When it happens

Trigger: Calling resume() with an expired TOTP window, a code already used (replay protection), a typo, or a code generated from a different secret than the validator's; clock drift between the generator and validator beyond the allowed step.

Common situations: Operator's authenticator app out of sync (phone clock skew); validator provisioned with a different base32 secret than the user's; reusing a code that worked for a previous action; NTP broken on the host running the daemon.

Related errors


AI-assisted analysis of zeroclaw-labs/zeroclaw@88bb9c8533 (2026-08-23). Data as JSON: /api/errors/4abc944ce6a14e8d. Report an issue: GitHub.