zeroclaw/src/security/otp.rs

368 lines
11 KiB
Rust

use crate::config::OtpConfig;
use crate::security::secrets::SecretStore;
use anyhow::{Context, Result};
use parking_lot::Mutex;
use ring::hmac;
use std::collections::HashMap;
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
const OTP_SECRET_FILE: &str = "otp-secret";
const OTP_DIGITS: u32 = 6;
const OTP_ISSUER: &str = "ZeroClaw";
#[derive(Debug)]
pub struct OtpValidator {
config: OtpConfig,
secret: Vec<u8>,
cached_codes: Mutex<HashMap<String, u64>>,
}
impl OtpValidator {
pub fn from_config(
config: &OtpConfig,
zeroclaw_dir: &Path,
store: &SecretStore,
) -> Result<(Self, Option<String>)> {
let secret_path = secret_file_path(zeroclaw_dir);
let (secret, generated) = if secret_path.exists() {
let encoded = fs::read_to_string(&secret_path).with_context(|| {
format!("Failed to read OTP secret file {}", secret_path.display())
})?;
let decrypted = store
.decrypt(encoded.trim())
.context("Failed to decrypt OTP secret file")?;
(decode_base32_secret(&decrypted)?, false)
} else {
let raw: [u8; 20] = rand::random();
let encoded_secret = encode_base32_secret(&raw);
let encrypted = store
.encrypt(&encoded_secret)
.context("Failed to encrypt OTP secret")?;
write_secret_file(&secret_path, &encrypted)?;
(raw.to_vec(), true)
};
let validator = Self {
config: config.clone(),
secret,
cached_codes: Mutex::new(HashMap::new()),
};
let uri = if generated {
Some(validator.otpauth_uri())
} else {
None
};
Ok((validator, uri))
}
pub fn validate(&self, code: &str) -> Result<bool> {
self.validate_at(code, unix_timestamp_now())
}
fn validate_at(&self, code: &str, now_secs: u64) -> Result<bool> {
let normalized = code.trim();
if normalized.len() != OTP_DIGITS as usize
|| !normalized.chars().all(|ch| ch.is_ascii_digit())
{
return Ok(false);
}
{
let mut cache = self.cached_codes.lock();
cache.retain(|_, expiry| *expiry >= now_secs);
if cache
.get(normalized)
.is_some_and(|expiry| *expiry >= now_secs)
{
return Ok(false);
}
}
let step = self.config.token_ttl_secs.max(1);
let counter = now_secs / step;
let counters = [
counter.saturating_sub(1),
counter,
counter.saturating_add(1),
];
let is_valid = counters
.iter()
.map(|c| compute_totp_code(&self.secret, *c))
.any(|candidate| candidate == normalized);
if is_valid {
let mut cache = self.cached_codes.lock();
cache.insert(
normalized.to_string(),
now_secs.saturating_add(self.config.cache_valid_secs),
);
}
Ok(is_valid)
}
pub fn otpauth_uri(&self) -> String {
let secret = encode_base32_secret(&self.secret);
let account = "zeroclaw";
format!(
"otpauth://totp/{issuer}:{account}?secret={secret}&issuer={issuer}&period={period}",
issuer = OTP_ISSUER,
period = self.config.token_ttl_secs.max(1)
)
}
#[cfg(test)]
pub(crate) fn code_for_timestamp(&self, timestamp: u64) -> String {
let counter = timestamp / self.config.token_ttl_secs.max(1);
compute_totp_code(&self.secret, counter)
}
}
pub fn secret_file_path(zeroclaw_dir: &Path) -> PathBuf {
zeroclaw_dir.join(OTP_SECRET_FILE)
}
fn write_secret_file(path: &Path, value: &str) -> Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("Failed to create directory {}", parent.display()))?;
}
let temp_path = path.with_extension(format!("tmp-{}", uuid::Uuid::new_v4()));
let mut temp_file = fs::OpenOptions::new()
.create_new(true)
.write(true)
.open(&temp_path)
.with_context(|| {
format!(
"Failed to create temporary OTP secret {}",
temp_path.display()
)
})?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
temp_file
.set_permissions(fs::Permissions::from_mode(0o600))
.with_context(|| {
format!(
"Failed to set permissions on temporary OTP secret {}",
temp_path.display()
)
})?;
}
temp_file.write_all(value.as_bytes()).with_context(|| {
format!(
"Failed to write temporary OTP secret {}",
temp_path.display()
)
})?;
temp_file.sync_all().with_context(|| {
format!(
"Failed to fsync temporary OTP secret {}",
temp_path.display()
)
})?;
drop(temp_file);
fs::rename(&temp_path, path).with_context(|| {
format!(
"Failed to atomically replace OTP secret file {}",
path.display()
)
})?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(path, fs::Permissions::from_mode(0o600)).with_context(|| {
format!(
"Failed to enforce permissions on OTP secret file {}",
path.display()
)
})?;
}
Ok(())
}
fn unix_timestamp_now() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_secs())
.unwrap_or(0)
}
fn compute_totp_code(secret: &[u8], counter: u64) -> String {
let key = hmac::Key::new(hmac::HMAC_SHA1_FOR_LEGACY_USE_ONLY, secret);
let counter_bytes = counter.to_be_bytes();
let digest = hmac::sign(&key, &counter_bytes);
let hash = digest.as_ref();
let offset = (hash[19] & 0x0f) as usize;
let binary = ((u32::from(hash[offset]) & 0x7f) << 24)
| (u32::from(hash[offset + 1]) << 16)
| (u32::from(hash[offset + 2]) << 8)
| u32::from(hash[offset + 3]);
let code = binary % 10_u32.pow(OTP_DIGITS);
format!("{code:0>6}")
}
fn encode_base32_secret(input: &[u8]) -> String {
const ALPHABET: &[u8; 32] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
if input.is_empty() {
return String::new();
}
let mut result = String::new();
let mut buffer = 0u16;
let mut bits_left = 0u8;
for byte in input {
buffer = (buffer << 8) | u16::from(*byte);
bits_left += 8;
while bits_left >= 5 {
let index = ((buffer >> (bits_left - 5)) & 0x1f) as usize;
result.push(ALPHABET[index] as char);
bits_left -= 5;
}
}
if bits_left > 0 {
let index = ((buffer << (5 - bits_left)) & 0x1f) as usize;
result.push(ALPHABET[index] as char);
}
result
}
fn decode_base32_secret(raw: &str) -> Result<Vec<u8>> {
fn decode_char(ch: char) -> Option<u8> {
match ch {
'A'..='Z' => Some((ch as u8) - b'A'),
'2'..='7' => Some((ch as u8) - b'2' + 26),
_ => None,
}
}
let mut cleaned = raw
.chars()
.filter(|ch| !matches!(ch, ' ' | '\t' | '\n' | '\r' | '-'))
.collect::<String>()
.to_ascii_uppercase();
while cleaned.ends_with('=') {
cleaned.pop();
}
if cleaned.is_empty() {
anyhow::bail!("OTP secret is empty");
}
let mut output = Vec::new();
let mut buffer = 0u32;
let mut bits_left = 0u8;
for ch in cleaned.chars() {
let value = decode_char(ch)
.with_context(|| format!("OTP secret contains invalid base32 character '{ch}'"))?;
buffer = (buffer << 5) | u32::from(value);
bits_left += 5;
if bits_left >= 8 {
let byte = ((buffer >> (bits_left - 8)) & 0xff) as u8;
output.push(byte);
bits_left -= 8;
}
}
if output.is_empty() {
anyhow::bail!("OTP secret did not decode to any bytes");
}
Ok(output)
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
fn test_config() -> OtpConfig {
OtpConfig {
enabled: true,
token_ttl_secs: 30,
cache_valid_secs: 120,
..OtpConfig::default()
}
}
#[test]
fn valid_totp_code_is_accepted() {
let dir = tempdir().unwrap();
let store = SecretStore::new(dir.path(), true);
let (validator, _) = OtpValidator::from_config(&test_config(), dir.path(), &store).unwrap();
let now = 1_700_000_000u64;
let code = validator.code_for_timestamp(now);
assert!(validator.validate_at(&code, now).unwrap());
}
#[test]
fn replayed_totp_code_is_rejected() {
let dir = tempdir().unwrap();
let store = SecretStore::new(dir.path(), true);
let (validator, _) = OtpValidator::from_config(&test_config(), dir.path(), &store).unwrap();
let now = 1_700_000_000u64;
let code = validator.code_for_timestamp(now);
assert!(validator.validate_at(&code, now).unwrap());
assert!(!validator.validate_at(&code, now).unwrap());
}
#[test]
fn expired_totp_code_is_rejected() {
let dir = tempdir().unwrap();
let store = SecretStore::new(dir.path(), true);
let (validator, _) = OtpValidator::from_config(&test_config(), dir.path(), &store).unwrap();
let stale = 1_700_000_000u64;
let now = stale + 300;
let code = validator.code_for_timestamp(stale);
assert!(!validator.validate_at(&code, now).unwrap());
}
#[test]
fn wrong_totp_code_is_rejected() {
let dir = tempdir().unwrap();
let store = SecretStore::new(dir.path(), true);
let (validator, _) = OtpValidator::from_config(&test_config(), dir.path(), &store).unwrap();
assert!(!validator.validate_at("123456", 1_700_000_000).unwrap());
}
#[test]
fn secret_is_generated_and_reused() {
let dir = tempdir().unwrap();
let store = SecretStore::new(dir.path(), true);
let (first, first_uri) =
OtpValidator::from_config(&test_config(), dir.path(), &store).unwrap();
assert!(first_uri.is_some());
let secret_path = secret_file_path(dir.path());
let stored = fs::read_to_string(&secret_path).unwrap();
assert!(SecretStore::is_encrypted(stored.trim()));
let (second, second_uri) =
OtpValidator::from_config(&test_config(), dir.path(), &store).unwrap();
assert!(second_uri.is_none());
let ts = 1_700_000_000u64;
assert_eq!(first.code_for_timestamp(ts), second.code_for_timestamp(ts));
}
}