gloves 0.5.11

seamless secret manager and handoff
Documentation
use std::{
    collections::HashSet,
    fs,
    path::{Path, PathBuf},
};

use chrono::Duration;
use ed25519_dalek::SigningKey;
use rand::RngExt;
use serde_json::Value;

use crate::{
    agent::{age_crypto, backend::AgentBackend, meta::MetadataStore},
    audit::AuditLog,
    error::{GlovesError, Result},
    fs_secure::{create_private_file_if_missing, ensure_private_dir, write_private_file_atomic},
    human::{backend::HumanBackend, pending::PendingRequestStore},
    manager::{SecretsManager, SetSecretOptions},
    paths::SecretsPaths,
    types::{AgentId, Owner, SecretId, SecretValue},
};

const REQUEST_ID_COMMAND_HINT: &str = "To find a valid request id:\n  gloves requests list\nThen run one of:\n  gloves requests approve <request-id>\n  gloves requests deny <request-id>\n  (legacy shortcuts)\n  gloves approve <request-id>\n  gloves deny <request-id>";
const REQUEST_ID_EXAMPLE: &str = "123e4567-e89b-12d3-a456-426614174000";

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum SecretTtl {
    Days(i64),
    Never,
}

impl SecretTtl {
    pub(crate) fn duration(self) -> Option<Duration> {
        match self {
            Self::Days(days) => Some(Duration::days(days)),
            Self::Never => None,
        }
    }

    pub(crate) fn ttl_days(self) -> Option<i64> {
        match self {
            Self::Days(days) => Some(days),
            Self::Never => None,
        }
    }

    pub(crate) fn never_expires(self) -> bool {
        matches!(self, Self::Never)
    }
}

pub(crate) fn init_layout(paths: &SecretsPaths) -> Result<()> {
    ensure_private_dir(paths.root())?;
    ensure_private_dir(&paths.agents_dir())?;
    ensure_private_dir(&paths.store_dir())?;
    ensure_private_dir(&paths.metadata_dir())?;
    ensure_private_dir(&paths.vaults_dir())?;
    ensure_private_dir(&paths.encrypted_dir())?;
    ensure_private_dir(&paths.mounts_dir())?;
    create_private_file_if_missing(&paths.pending_file(), b"[]")?;
    create_private_file_if_missing(&paths.audit_file(), b"")?;
    create_private_file_if_missing(&paths.vault_sessions_file(), b"[]")?;
    Ok(())
}

pub(crate) fn manager_for_paths(paths: &SecretsPaths) -> Result<SecretsManager> {
    init_layout(paths)?;
    let agent_backend = AgentBackend::new(paths.store_dir())?;
    let human_backend = HumanBackend::new();
    let metadata_store = MetadataStore::new(paths.metadata_dir())?;
    let pending_store = PendingRequestStore::new(paths.pending_file())?;
    let audit_log = AuditLog::new(paths.audit_file())?;
    Ok(SecretsManager::new(
        agent_backend,
        human_backend,
        metadata_store,
        pending_store,
        audit_log,
    ))
}

fn load_or_create_identity_file(identity_file: &Path) -> Result<PathBuf> {
    if let Some(parent_dir) = identity_file.parent() {
        ensure_private_dir(parent_dir)?;
    }
    if identity_file.exists() {
        age_crypto::validate_identity_file(identity_file)?;
        return Ok(identity_file.to_path_buf());
    }

    age_crypto::generate_identity_file(identity_file)?;
    Ok(identity_file.to_path_buf())
}

fn load_or_create_signing_key_file(signing_key_file: &Path) -> Result<SigningKey> {
    if let Some(parent_dir) = signing_key_file.parent() {
        ensure_private_dir(parent_dir)?;
    }
    if signing_key_file.exists() {
        let bytes = fs::read(signing_key_file)?;
        let key_bytes: [u8; 32] = bytes
            .as_slice()
            .try_into()
            .map_err(|_| GlovesError::InvalidInput("invalid signing key".to_owned()))?;
        return Ok(SigningKey::from_bytes(&key_bytes));
    }

    let mut key_bytes = [0_u8; 32];
    rand::rng().fill(&mut key_bytes);
    let key = SigningKey::from_bytes(&key_bytes);
    write_private_file_atomic(signing_key_file, &key.to_bytes())?;
    Ok(key)
}

pub(crate) fn load_or_create_identity_for_agent(
    paths: &SecretsPaths,
    agent_id: &AgentId,
) -> Result<PathBuf> {
    let legacy_namespaced_identity = paths.namespaced_identity_file_for_agent(agent_id.as_str());
    if legacy_namespaced_identity.exists() {
        age_crypto::validate_identity_file(&legacy_namespaced_identity)?;
        return Ok(legacy_namespaced_identity);
    }
    let legacy_root_identity = paths.legacy_identity_file_for_agent(agent_id.as_str());
    if legacy_root_identity.exists() {
        age_crypto::validate_identity_file(&legacy_root_identity)?;
        return Ok(legacy_root_identity);
    }
    load_or_create_identity_file(&paths.identity_file_for_agent(agent_id.as_str()))
}

pub(crate) fn recipient_from_identity_file(identity_file: &std::path::Path) -> Result<String> {
    age_crypto::recipient_from_identity_file(identity_file)
}

pub(crate) fn load_or_create_recipient_for_agent(
    paths: &SecretsPaths,
    agent_id: &AgentId,
) -> Result<String> {
    let identity_file = load_or_create_identity_for_agent(paths, agent_id)?;
    recipient_from_identity_file(&identity_file)
}

pub(crate) fn load_or_create_signing_key_for_agent(
    paths: &SecretsPaths,
    agent_id: &AgentId,
) -> Result<SigningKey> {
    let legacy_signing_key = paths.legacy_signing_key_file_for_agent(agent_id.as_str());
    if legacy_signing_key.exists() {
        return load_or_create_signing_key_file(&legacy_signing_key);
    }
    load_or_create_signing_key_file(&paths.signing_key_file_for_agent(agent_id.as_str()))
}

pub(crate) fn validate_ttl_days(ttl_days: i64, field_name: &str) -> Result<i64> {
    if ttl_days <= 0 {
        return Err(GlovesError::InvalidInput(format!(
            "{field_name} must be greater than zero"
        )));
    }
    Ok(ttl_days)
}

pub(crate) fn parse_secret_ttl_argument(
    ttl_literal: Option<&str>,
    default_ttl_days: i64,
    field_name: &str,
) -> Result<SecretTtl> {
    match ttl_literal {
        Some(ttl_literal) => parse_secret_ttl_literal(ttl_literal, field_name),
        None => Ok(SecretTtl::Days(default_ttl_days)),
    }
}

pub(crate) fn parse_secret_ttl_json_value(
    ttl_value: Option<&Value>,
    default_ttl_days: i64,
    field_name: &str,
) -> Result<SecretTtl> {
    match ttl_value {
        None | Some(Value::Null) => Ok(SecretTtl::Days(default_ttl_days)),
        Some(Value::String(ttl_literal)) => parse_secret_ttl_literal(ttl_literal, field_name),
        Some(Value::Number(ttl_days)) => {
            let ttl_days = ttl_days.as_i64().ok_or_else(|| {
                GlovesError::InvalidInput(format!(
                    "{field_name} must be a positive whole-day count or `never`"
                ))
            })?;
            Ok(SecretTtl::Days(validate_ttl_days(ttl_days, field_name)?))
        }
        Some(_) => Err(GlovesError::InvalidInput(format!(
            "{field_name} must be a positive whole-day count or `never`"
        ))),
    }
}

fn parse_secret_ttl_literal(ttl_literal: &str, field_name: &str) -> Result<SecretTtl> {
    let ttl_literal = ttl_literal.trim();
    if ttl_literal.eq_ignore_ascii_case("never") {
        return Ok(SecretTtl::Never);
    }

    let ttl_days = ttl_literal.parse::<i64>().map_err(|_| {
        GlovesError::InvalidInput(format!(
            "{field_name} must be a positive whole-day count or `never`"
        ))
    })?;
    Ok(SecretTtl::Days(validate_ttl_days(ttl_days, field_name)?))
}

pub(crate) fn parse_request_uuid(request_id: &str) -> Result<uuid::Uuid> {
    let request_id = request_id.trim();
    if request_id.is_empty() {
        return Err(GlovesError::InvalidInput(format!(
            "request id is empty\n{REQUEST_ID_COMMAND_HINT}"
        )));
    }
    if request_id.eq_ignore_ascii_case("request") || request_id.eq_ignore_ascii_case("requests") {
        return Err(GlovesError::InvalidInput(format!(
            "`{request_id}` is a label, not a request id\n{REQUEST_ID_COMMAND_HINT}"
        )));
    }
    request_id
        .parse::<uuid::Uuid>()
        .map_err(|error| {
            GlovesError::InvalidInput(format!(
                "invalid request id `{request_id}`; expected a UUID like `{REQUEST_ID_EXAMPLE}`\n{REQUEST_ID_COMMAND_HINT}\nparser detail: {error}"
            ))
        })
}

pub(crate) fn ensure_agent_vault_secret(
    paths: &SecretsPaths,
    secret_name: &str,
    creator: &AgentId,
    ttl_days: i64,
    length_bytes: usize,
) -> Result<()> {
    let manager = manager_for_paths(paths)?;
    let secret_id = SecretId::new(secret_name)?;
    if manager.metadata_store.path_for(&secret_id).exists() {
        return Ok(());
    }

    let recipient = load_or_create_recipient_for_agent(paths, creator)?;
    let mut recipients = HashSet::new();
    recipients.insert(creator.clone());

    let mut secret_bytes = vec![0_u8; length_bytes];
    rand::rng().fill(secret_bytes.as_mut_slice());
    match manager.set(
        secret_id,
        SecretValue::new(secret_bytes),
        SetSecretOptions {
            owner: Owner::Agent,
            ttl: Some(Duration::days(ttl_days)),
            created_by: creator.clone(),
            recipients,
            recipient_keys: vec![recipient],
        },
    ) {
        Ok(_) | Err(GlovesError::AlreadyExists) => Ok(()),
        Err(error) => Err(error),
    }
}