envoy-cli 0.2.8

A Git-like CLI for managing encrypted environment files
use anyhow::bail;
use argon2::password_hash::rand_core::RngCore;
use argon2::{Algorithm, Argon2, Params, Version};
use chacha20poly1305::aead::OsRng;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs;
use std::path::PathBuf;
use std::sync::Mutex;
use std::time::{SystemTime, UNIX_EPOCH};

use once_cell::sync::OnceCell;

static SESSION_KEY: OnceCell<[u8; 32]> = OnceCell::new();
static PASSPHRASE_OVERRIDE: OnceCell<Mutex<Option<String>>> = OnceCell::new();

pub fn set_passphrase_override(passphrase: Option<String>) {
    let mutex = PASSPHRASE_OVERRIDE.get_or_init(|| Mutex::new(None));
    let mut guard = mutex.lock().unwrap();
    *guard = passphrase;
}

pub fn take_passphrase_override() -> Option<String> {
    let mutex = PASSPHRASE_OVERRIDE.get_or_init(|| Mutex::new(None));
    let mut guard = mutex.lock().unwrap();
    guard.take()
}

pub fn clear_passphrase_override() {
    set_passphrase_override(None);
}

fn session_key_path() -> PathBuf {
    let mut path = dirs::home_dir().expect("home dir");
    path.push(".envoy");
    path.push(".session_key");
    path
}

fn get_or_init_session_key() -> &'static [u8; 32] {
    SESSION_KEY.get_or_init(|| {
        let path = session_key_path();

        if path.exists()
            && let Ok(data) = fs::read(&path)
            && data.len() == 32
        {
            let mut key = [0u8; 32];
            key.copy_from_slice(&data);
            return key;
        }

        let mut key = [0u8; 32];
        OsRng.fill_bytes(&mut key);

        if let Some(parent) = path.parent()
            && let Err(e) = fs::create_dir_all(parent)
        {
            eprintln!("Warning: Failed to create session directory: {}", e);
        }

        if let Err(e) = fs::write(&path, key) {
            eprintln!("Warning: Failed to persist session key: {}", e);
        }

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

        key
    })
}

const SESSION_TTL_SECS: u64 = 15 * 60;

#[derive(Serialize, Deserialize, Clone)]
pub struct Session {
    pub project_id: String,
    #[serde(alias = "encrypted_manifest_key")]
    pub manifest_key: Vec<u8>,
    pub expires_at: u64,
}

#[derive(Serialize, Deserialize, Default)]
struct SessionStore {
    sessions: HashMap<String, Session>,
}

fn session_path() -> PathBuf {
    let mut path = dirs::home_dir().expect("home dir");
    path.push(".envoy");
    path.push("sessions.json");
    path
}

fn load_store() -> anyhow::Result<SessionStore> {
    let path = session_path();
    if !path.exists() {
        return Ok(SessionStore::default());
    }

    let data = fs::read(&path)?;
    let store: SessionStore = serde_json::from_slice(&data)?;
    Ok(store)
}

fn save_store(store: &SessionStore) -> anyhow::Result<()> {
    let path = session_path();
    fs::create_dir_all(path.parent().unwrap())?;

    fs::write(&path, serde_json::to_vec_pretty(&store)?)?;

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

    Ok(())
}

pub fn clear_session(project_id: &str) -> anyhow::Result<()> {
    let mut store = load_store()?;
    store.sessions.remove(project_id);
    save_store(&store)?;
    Ok(())
}

pub fn load_session(project_id: &str) -> anyhow::Result<Option<Session>> {
    let store = load_store()?;

    if let Some(session) = store.sessions.get(project_id) {
        let now = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs();

        if session.expires_at < now || session.project_id != project_id {
            clear_session(project_id)?;
            return Ok(None);
        }

        let key = decrypt_manifest_key(
            &session.manifest_key,
            &session.project_id,
            session.expires_at,
        )?;

        let final_session = Session {
            project_id: project_id.to_string(),
            manifest_key: key,
            expires_at: session.expires_at,
        };

        Ok(Some(final_session))
    } else {
        Ok(None)
    }
}

pub fn save_session(project_id: &str, manifest_key: &[u8]) -> anyhow::Result<()> {
    let mut store = load_store()?;
    let now = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs();
    let expires_at = now + SESSION_TTL_SECS;

    let encrypted_key = encrypt_manifest_key(manifest_key, project_id, expires_at)?;

    let session = Session {
        project_id: project_id.to_string(),
        manifest_key: encrypted_key,
        expires_at,
    };

    store.sessions.insert(project_id.to_string(), session);
    save_store(&store)?;

    Ok(())
}

use chacha20poly1305::{
    XChaCha20Poly1305, XNonce,
    aead::{Aead, KeyInit, Payload},
};

const SESSION_BLOB_VERSION: u8 = 1;
const SESSION_VERSION_LEN: usize = 1;
const SESSION_NONCE_LEN: usize = 24;
const SESSION_HEADER_LEN: usize = SESSION_VERSION_LEN + SESSION_NONCE_LEN;

fn encrypt_manifest_key(
    manifest_key: &[u8],
    project_id: &str,
    expires_at: u64,
) -> anyhow::Result<Vec<u8>> {
    let cipher = XChaCha20Poly1305::new(get_or_init_session_key().into());

    let mut nonce = [0u8; SESSION_NONCE_LEN];
    OsRng.fill_bytes(&mut nonce);

    let aad = format!("{}:{}", project_id, expires_at);

    let ciphertext = match cipher.encrypt(
        XNonce::from_slice(&nonce),
        Payload {
            msg: manifest_key,
            aad: aad.as_bytes(),
        },
    ) {
        Ok(cipher) => cipher,
        Err(error) => bail!(error),
    };

    let mut out = Vec::with_capacity(SESSION_HEADER_LEN + ciphertext.len());
    out.push(SESSION_BLOB_VERSION);
    out.extend_from_slice(&nonce);
    out.extend_from_slice(&ciphertext);

    Ok(out)
}

fn decrypt_manifest_key(
    encrypted: &[u8],
    project_id: &str,
    expires_at: u64,
) -> anyhow::Result<Vec<u8>> {
    if encrypted.len() < SESSION_HEADER_LEN {
        anyhow::bail!("Invalid session blob: too short");
    }

    let version = encrypted[0];
    if version != SESSION_BLOB_VERSION {
        anyhow::bail!("Unsupported session blob version: {}", version);
    }

    let nonce_start = SESSION_VERSION_LEN;
    let ciphertext_start = nonce_start + SESSION_NONCE_LEN;

    let nonce = &encrypted[nonce_start..ciphertext_start];
    let ciphertext = &encrypted[ciphertext_start..];

    let cipher = XChaCha20Poly1305::new(get_or_init_session_key().into());

    let aad = format!("{}:{}", project_id, expires_at);

    let plaintext = match cipher.decrypt(
        XNonce::from_slice(nonce),
        Payload {
            msg: ciphertext,
            aad: aad.as_bytes(),
        },
    ) {
        Ok(plain) => plain,
        Err(error) => bail!(error),
    };

    Ok(plaintext)
}

pub fn derive_manifest_key_from_passphrase(
    passphrase: &str,
    project_id: &str,
) -> anyhow::Result<Vec<u8>> {
    use sha2::{Digest, Sha256};
    let mut hasher = Sha256::new();
    hasher.update(project_id.as_bytes());
    let hash = hasher.finalize();
    let salt: [u8; 16] = hash[..16].try_into().unwrap();

    let argon2 = Argon2::new(
        Algorithm::Argon2id,
        Version::V0x13,
        Params::new(19456, 2, 1, Some(32))
            .map_err(|e| anyhow::anyhow!("Failed to create Argon2 params: {}", e))?,
    );
    let pass = passphrase.as_bytes();
    let mut key = [0u8; 32];
    argon2
        .hash_password_into(pass, &salt, &mut key)
        .map_err(|e| anyhow::anyhow!("Failed to hash password: {}", e))?;
    Ok(key.to_vec())
}