via-cli 0.2.0

Run commands and API requests with 1Password-backed credentials without exposing secrets to your shell
Documentation
use std::collections::BTreeMap;
use std::process::Command;
use std::sync::Mutex;

use crate::config::OnePasswordCacheMode;
use crate::error::ViaError;
use crate::providers::SecretProvider;
use crate::secrets::SecretValue;

pub struct OnePasswordCliProvider {
    account: Option<String>,
    cache: OnePasswordCacheMode,
    cache_ttl_seconds: u64,
    config_hash: String,
    ref_ids: BTreeMap<String, String>,
    registered: Mutex<bool>,
}

impl OnePasswordCliProvider {
    pub fn new(
        account: Option<String>,
        cache: OnePasswordCacheMode,
        cache_ttl_seconds: u64,
        allowed_refs: Vec<String>,
    ) -> Self {
        let config_hash = cache_config_hash(account.as_deref(), &allowed_refs);
        let ref_ids = allowed_refs
            .into_iter()
            .map(|reference| {
                let id = reference_id(account.as_deref(), &reference);
                (reference, id)
            })
            .collect();

        Self {
            account,
            cache,
            cache_ttl_seconds,
            config_hash,
            ref_ids,
            registered: Mutex::new(false),
        }
    }
}

impl SecretProvider for OnePasswordCliProvider {
    fn resolve(&self, reference: &str) -> Result<SecretValue, ViaError> {
        if self.cache == OnePasswordCacheMode::Daemon {
            return self.resolve_via_daemon(reference);
        }

        resolve_direct(self.account.as_deref(), reference)
    }
}

impl OnePasswordCliProvider {
    fn resolve_via_daemon(&self, reference: &str) -> Result<SecretValue, ViaError> {
        self.ensure_daemon_registered()?;
        let ref_id = self.ref_ids.get(reference).ok_or_else(|| {
            ViaError::InvalidConfig(
                "1Password daemon refused to resolve a secret outside the provider allowlist"
                    .to_owned(),
            )
        })?;
        crate::daemon::resolve_onepassword_secret(&self.config_hash, ref_id, self.cache_ttl_seconds)
    }

    fn ensure_daemon_registered(&self) -> Result<(), ViaError> {
        let mut registered = self.registered.lock().map_err(|_| {
            ViaError::InvalidConfig("1Password daemon registration lock was poisoned".to_owned())
        })?;
        if *registered {
            return Ok(());
        }

        let refs = self
            .ref_ids
            .iter()
            .map(|(reference, id)| crate::daemon::AllowedOnePasswordRef {
                id: id.clone(),
                reference: reference.clone(),
            })
            .collect::<Vec<_>>();
        crate::daemon::register_onepassword_refs(&self.config_hash, self.account.as_deref(), refs)?;
        *registered = true;
        Ok(())
    }
}

fn resolve_direct(account: Option<&str>, reference: &str) -> Result<SecretValue, ViaError> {
    let mut command = Command::new("op");
    command.arg("read").arg(reference);
    if let Some(account) = account {
        command.arg("--account").arg(account);
    }

    let span = crate::timing::span("1password op read");
    let output = match command.output() {
        Ok(output) => {
            span.finish(format!("status={:?}", output.status.code()));
            output
        }
        Err(source) => {
            span.finish("failed_to_start");
            return Err(ViaError::MissingProgram {
                program: "op".to_owned(),
                source,
            });
        }
    };

    if !output.status.success() {
        return Err(ViaError::ExternalCommandFailed {
            program: "op".to_owned(),
            status: output.status.code(),
            stderr: String::from_utf8_lossy(&output.stderr).trim().to_owned(),
        });
    }

    Ok(SecretValue::from_utf8_lossy_trimmed(output.stdout))
}

fn cache_config_hash(account: Option<&str>, refs: &[String]) -> String {
    let mut context = ring::digest::Context::new(&ring::digest::SHA256);
    context.update(b"via:1password-cache:v1");
    context.update(b"\0");
    context.update(account.unwrap_or("").as_bytes());
    for reference in refs {
        context.update(b"\0");
        context.update(reference.as_bytes());
    }
    hex_encode(context.finish().as_ref())
}

fn reference_id(account: Option<&str>, reference: &str) -> String {
    let mut context = ring::digest::Context::new(&ring::digest::SHA256);
    context.update(b"via:1password-ref:v1");
    context.update(b"\0");
    context.update(account.unwrap_or("").as_bytes());
    context.update(b"\0");
    context.update(reference.as_bytes());
    hex_encode(context.finish().as_ref())
}

fn hex_encode(bytes: &[u8]) -> String {
    const HEX: &[u8; 16] = b"0123456789abcdef";
    let mut encoded = String::with_capacity(bytes.len() * 2);
    for byte in bytes {
        encoded.push(HEX[(byte >> 4) as usize] as char);
        encoded.push(HEX[(byte & 0x0f) as usize] as char);
    }
    encoded
}