typeduck-codex-web 0.4.0

A standalone browser interface and Codex runtime
Documentation
use std::fs;
use std::fs::OpenOptions;
use std::io::Error;
use std::io::ErrorKind;
use std::io::Write;
use std::path::Path;
use std::time::Duration;
use std::time::SystemTime;
use std::time::UNIX_EPOCH;

use base64::Engine;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use constant_time_eq::constant_time_eq_32;
use hmac::Hmac;
use hmac::Mac;
use rand::RngCore;
use sha2::Sha256;

const TOKEN_FILE_NAME: &str = "token";
const SESSION_COOKIE_NAME: &str = "typeduck_codex_web";
const SESSION_LIFETIME: Duration = Duration::from_secs(30 * 24 * 60 * 60);

type HmacSha256 = Hmac<Sha256>;

pub(crate) struct WebAuth {
    key: [u8; 32],
    bootstrap_token: String,
}

impl WebAuth {
    pub(crate) fn load(reset: bool) -> std::io::Result<Self> {
        let codex_home = codex_utils_home_dir::find_codex_home()?;
        let auth_dir = codex_home.join("typeduck-codex-web");
        fs::create_dir_all(&auth_dir)?;
        let token_path = auth_dir.join(TOKEN_FILE_NAME);
        let token = if reset || !token_path.exists() {
            replace_token(&token_path)?
        } else {
            read_token(&token_path)?
        };
        let key = decode_token(&token)?;
        Ok(Self {
            key,
            bootstrap_token: token,
        })
    }

    pub(crate) fn bootstrap_token(&self) -> &str {
        &self.bootstrap_token
    }

    pub(crate) fn verify_bootstrap(&self, candidate: &str) -> bool {
        decode_token(candidate).is_ok_and(|candidate| constant_time_eq_32(&self.key, &candidate))
    }

    pub(crate) fn session_cookie(&self, path: &str) -> std::io::Result<String> {
        let expires_at = unix_time()?.saturating_add(SESSION_LIFETIME.as_secs());
        let nonce = random_token();
        let payload = format!("{expires_at}.{nonce}");
        let signature = self.sign(&payload)?;
        Ok(format!(
            "{SESSION_COOKIE_NAME}={payload}.{signature}; Path={path}; HttpOnly; SameSite=Lax; Max-Age={}",
            SESSION_LIFETIME.as_secs()
        ))
    }

    pub(crate) fn authorize_cookie_header(&self, cookie_header: Option<&str>) -> bool {
        let Some(cookie) = cookie_header.and_then(session_cookie_value) else {
            return false;
        };
        let Some((payload, signature)) = cookie.rsplit_once('.') else {
            return false;
        };
        let Some((expires_at, _nonce)) = payload.split_once('.') else {
            return false;
        };
        let Ok(expires_at) = expires_at.parse::<u64>() else {
            return false;
        };
        match unix_time() {
            Ok(now) if now <= expires_at => {}
            Ok(_) | Err(_) => return false,
        }
        let Ok(signature) = URL_SAFE_NO_PAD.decode(signature) else {
            return false;
        };
        let Ok(mut mac) = HmacSha256::new_from_slice(&self.key) else {
            return false;
        };
        mac.update(payload.as_bytes());
        mac.verify_slice(&signature).is_ok()
    }

    fn sign(&self, payload: &str) -> std::io::Result<String> {
        let mut mac = HmacSha256::new_from_slice(&self.key).map_err(Error::other)?;
        mac.update(payload.as_bytes());
        Ok(URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes()))
    }
}

fn read_token(path: &Path) -> std::io::Result<String> {
    let metadata = fs::symlink_metadata(path)?;
    if metadata.file_type().is_symlink() || !metadata.is_file() {
        return Err(Error::new(
            ErrorKind::InvalidData,
            format!(
                "browser token path is not a regular file: {}",
                path.display()
            ),
        ));
    }
    harden_permissions(path)?;
    let token = fs::read_to_string(path)?;
    let token = token.trim().to_string();
    decode_token(&token)?;
    Ok(token)
}

fn replace_token(path: &Path) -> std::io::Result<String> {
    let token = random_token();
    let parent = path
        .parent()
        .ok_or_else(|| Error::other("browser token path has no parent"))?;
    let temp_path = parent.join(format!(".token-{}", random_token()));
    let mut options = OpenOptions::new();
    options.write(true).create_new(true);
    #[cfg(unix)]
    {
        use std::os::unix::fs::OpenOptionsExt;
        options.mode(0o600);
    }
    let mut file = options.open(&temp_path)?;
    file.write_all(token.as_bytes())?;
    file.write_all(b"\n")?;
    file.sync_all()?;
    drop(file);
    if path.exists() {
        fs::remove_file(path)?;
    }
    fs::rename(&temp_path, path)?;
    harden_permissions(path)?;
    Ok(token)
}

#[cfg(unix)]
fn harden_permissions(path: &Path) -> std::io::Result<()> {
    use std::os::unix::fs::PermissionsExt;
    fs::set_permissions(path, fs::Permissions::from_mode(0o600))
}

#[cfg(not(unix))]
fn harden_permissions(_path: &Path) -> std::io::Result<()> {
    Ok(())
}

fn decode_token(token: &str) -> std::io::Result<[u8; 32]> {
    URL_SAFE_NO_PAD
        .decode(token)
        .map_err(Error::other)?
        .try_into()
        .map_err(|_| {
            Error::new(
                ErrorKind::InvalidData,
                "browser token must contain 32 bytes",
            )
        })
}

fn random_token() -> String {
    let mut bytes = [0_u8; 32];
    rand::rng().fill_bytes(&mut bytes);
    URL_SAFE_NO_PAD.encode(bytes)
}

fn unix_time() -> std::io::Result<u64> {
    SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map(|duration| duration.as_secs())
        .map_err(Error::other)
}

fn session_cookie_value(header: &str) -> Option<&str> {
    header.split(';').find_map(|part| {
        let (name, value) = part.trim().split_once('=')?;
        (name == SESSION_COOKIE_NAME).then_some(value)
    })
}

#[cfg(test)]
#[path = "auth_tests.rs"]
mod tests;