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 = selfView on GitHub (pinned to 88bb9c8533)
Solutions
- Generate a fresh code from the current time window and retry — never reuse a code.
- Check clock sync on both the authenticator device and the daemon host (NTP).
- Verify the validator and the user's authenticator share the same OTP secret/step.
- 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
- Keep authenticator device and daemon host NTP-synced to avoid window drift.
- Provision the OTP validator and the user's authenticator from the same secret.
- Pre-validate format (digits, expected length) before consuming a retry attempt.
- Never cache or reuse a submitted code.
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
- security.estop.require_otp_to_resume=true but security.otp.e
- security.otp.cache_valid_secs must be greater than or equal
- security.otp.gated_actions[{i}] contains invalid characters:
- Emergency stop is disabled. Enable [security.estop].enabled
- Authenticator data is shorter than the required fixed fields
AI-assisted analysis of zeroclaw-labs/zeroclaw@88bb9c8533 (2026-08-23).
Data as JSON: /api/errors/4abc944ce6a14e8d.
Report an issue: GitHub.