butterfly-bot 0.8.0

Butterfly Bot is an opinionated personal-ops AI assistant built for people who want results, not setup overhead.
Documentation
use std::fs::File;
use std::io::Write;
use std::path::Path;

use cocoon::Cocoon;

use crate::error::{ButterflyBotError, Result};

const BLOB_MAGIC: &[u8; 4] = b"BBC1";
const BLOB_VERSION: u8 = 1;
const CIPHER_CHA_CHA20_POLY1305: u8 = 1;

fn wrap_envelope(payload: &[u8]) -> Vec<u8> {
    let mut out = Vec::with_capacity(6 + payload.len());
    out.extend_from_slice(BLOB_MAGIC);
    out.push(BLOB_VERSION);
    out.push(CIPHER_CHA_CHA20_POLY1305);
    out.extend_from_slice(payload);
    out
}

fn unwrap_envelope(raw: Vec<u8>, path: &Path) -> Result<Vec<u8>> {
    if raw.len() < 6 {
        return Err(ButterflyBotError::SecurityStorage(format!(
            "encrypted secret {} has invalid envelope length",
            path.to_string_lossy()
        )));
    }

    if &raw[0..4] != BLOB_MAGIC {
        return Err(ButterflyBotError::SecurityStorage(format!(
            "encrypted secret {} has invalid envelope magic",
            path.to_string_lossy()
        )));
    }

    let version = raw[4];
    if version != BLOB_VERSION {
        return Err(ButterflyBotError::SecurityStorage(format!(
            "encrypted secret {} has unsupported envelope version {}",
            path.to_string_lossy(),
            version
        )));
    }

    let cipher = raw[5];
    if cipher != CIPHER_CHA_CHA20_POLY1305 {
        return Err(ButterflyBotError::SecurityStorage(format!(
            "encrypted secret {} has unsupported cipher id {}",
            path.to_string_lossy(),
            cipher
        )));
    }

    Ok(raw[6..].to_vec())
}

pub fn load_secret(path: &Path, passphrase: &str) -> Result<Option<String>> {
    if !path.exists() {
        return Ok(None);
    }

    let mut file = File::open(path).map_err(|e| {
        ButterflyBotError::SecurityStorage(format!(
            "failed to open encrypted secret {}: {e}",
            path.to_string_lossy()
        ))
    })?;

    let cocoon = Cocoon::new(passphrase.as_bytes());
    let decoded = cocoon.parse(&mut file).map_err(|e| {
        ButterflyBotError::SecurityStorage(format!(
            "failed to decrypt encrypted secret {}: {e:?}",
            path.to_string_lossy()
        ))
    })?;

    let payload = unwrap_envelope(decoded, path)?;

    let value = String::from_utf8(payload).map_err(|e| {
        ButterflyBotError::SecurityStorage(format!(
            "invalid utf8 in encrypted secret {}: {e}",
            path.to_string_lossy()
        ))
    })?;

    let trimmed = value.trim();
    if trimmed.is_empty() {
        return Err(ButterflyBotError::SecurityStorage(format!(
            "encrypted secret {} is empty",
            path.to_string_lossy()
        )));
    }

    Ok(Some(trimmed.to_string()))
}

pub fn persist_secret(path: &Path, passphrase: &str, value: &str) -> Result<()> {
    if let Some(parent) = path.parent() {
        std::fs::create_dir_all(parent).map_err(|e| {
            ButterflyBotError::SecurityStorage(format!(
                "failed to create encrypted secret directory {}: {e}",
                parent.to_string_lossy()
            ))
        })?;

        #[cfg(unix)]
        {
            use std::os::unix::fs::PermissionsExt;
            let _ = std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o700));
        }
    }

    let temp_path = {
        let suffix = format!(
            ".tmp-{}-{}",
            std::process::id(),
            std::time::SystemTime::now()
                .duration_since(std::time::UNIX_EPOCH)
                .map(|d| d.as_nanos())
                .unwrap_or_default()
        );
        let file_name = path
            .file_name()
            .and_then(|name| name.to_str())
            .unwrap_or("secret.cocoon");
        let temp_name = format!("{file_name}{suffix}");
        path.parent()
            .unwrap_or_else(|| Path::new("."))
            .join(temp_name)
    };

    let mut file = File::create(&temp_path).map_err(|e| {
        ButterflyBotError::SecurityStorage(format!(
            "failed to create encrypted secret temp {}: {e}",
            temp_path.to_string_lossy()
        ))
    })?;

    let mut cocoon = Cocoon::new(passphrase.as_bytes());
    let payload = wrap_envelope(value.as_bytes());
    cocoon.dump(payload, &mut file).map_err(|e| {
        ButterflyBotError::SecurityStorage(format!(
            "failed to write encrypted secret temp {}: {e:?}",
            temp_path.to_string_lossy()
        ))
    })?;

    file.flush().map_err(|e| {
        ButterflyBotError::SecurityStorage(format!(
            "failed to flush encrypted secret temp {}: {e}",
            temp_path.to_string_lossy()
        ))
    })?;
    file.sync_all().map_err(|e| {
        ButterflyBotError::SecurityStorage(format!(
            "failed to sync encrypted secret temp {}: {e}",
            temp_path.to_string_lossy()
        ))
    })?;

    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        let _ = std::fs::set_permissions(&temp_path, std::fs::Permissions::from_mode(0o600));
    }

    std::fs::rename(&temp_path, path).map_err(|e| {
        let _ = std::fs::remove_file(&temp_path);
        ButterflyBotError::SecurityStorage(format!(
            "failed to move encrypted secret temp {} to {}: {e}",
            temp_path.to_string_lossy(),
            path.to_string_lossy()
        ))
    })?;

    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600));
    }

    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn cocoon_roundtrip_secret() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("secret.cocoon");

        persist_secret(&path, "test-passphrase", "super-secret").unwrap();
        let loaded = load_secret(&path, "test-passphrase").unwrap();

        assert_eq!(loaded.as_deref(), Some("super-secret"));
    }

    #[test]
    fn cocoon_wrong_passphrase_fails() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("secret.cocoon");

        persist_secret(&path, "pass-a", "super-secret").unwrap();
        let err = load_secret(&path, "pass-b").unwrap_err();

        assert!(format!("{err}").contains("failed to decrypt encrypted secret"));
    }

    #[test]
    fn cocoon_invalid_magic_fails_closed() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("secret.cocoon");

        persist_secret(&path, "test-passphrase", "super-secret").unwrap();
        let mut file = File::open(&path).unwrap();
        let cocoon = Cocoon::new("test-passphrase".as_bytes());
        let mut decoded = cocoon.parse(&mut file).unwrap();
        decoded[0] = b'X';

        let mut out = File::create(&path).unwrap();
        let mut cocoon = Cocoon::new("test-passphrase".as_bytes());
        cocoon.dump(decoded, &mut out).unwrap();

        let err = load_secret(&path, "test-passphrase").unwrap_err();
        assert!(format!("{err}").contains("invalid envelope magic"));
    }

    #[test]
    fn cocoon_unsupported_version_fails_closed() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("secret.cocoon");

        persist_secret(&path, "test-passphrase", "super-secret").unwrap();
        let mut file = File::open(&path).unwrap();
        let cocoon = Cocoon::new("test-passphrase".as_bytes());
        let mut decoded = cocoon.parse(&mut file).unwrap();
        decoded[4] = 99;

        let mut out = File::create(&path).unwrap();
        let mut cocoon = Cocoon::new("test-passphrase".as_bytes());
        cocoon.dump(decoded, &mut out).unwrap();

        let err = load_secret(&path, "test-passphrase").unwrap_err();
        assert!(format!("{err}").contains("unsupported envelope version"));
    }
}