fission-credentials 0.6.1

Credential vault helpers for fission command provider integrations
Documentation
use anyhow::{bail, Context, Result};
use base64::{engine::general_purpose::STANDARD_NO_PAD, Engine as _};
use chacha20poly1305::{
    aead::{Aead, KeyInit},
    XChaCha20Poly1305, XNonce,
};
use fission_command_core::DistributionProvider;
use serde::{Deserialize, Serialize};
use std::env;
use std::fs;
use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};

#[derive(Debug, Serialize, Deserialize)]
struct VaultRecord {
    schema_version: u32,
    provider: String,
    created_at_unix_seconds: u64,
    nonce: String,
    ciphertext: String,
}

pub fn provider_secret(
    provider: DistributionProvider,
    env_names: &[&str],
) -> Result<Option<String>> {
    if let Some(name) = env_names.iter().find(|name| env::var_os(name).is_some()) {
        return env::var(name)
            .map(Some)
            .with_context(|| format!("environment variable {name} is not valid UTF-8"));
    }
    let path = vault_record_path(provider)?;
    if !path.exists() {
        return Ok(None);
    }
    let bytes = load_provider_secret(provider)?;
    String::from_utf8(bytes)
        .map(Some)
        .context("stored provider credential is not valid UTF-8")
}

pub fn read_secret_source(source: &str) -> Result<String> {
    if let Some(name) = source.strip_prefix("env:") {
        env::var(name).with_context(|| format!("environment variable {name} is not set"))
    } else if let Some(path) = source.strip_prefix("file:") {
        fs::read_to_string(path).with_context(|| format!("failed to read credential file {path}"))
    } else {
        bail!("credential source must be env:<NAME> or file:<PATH>")
    }
}

pub fn store_provider_secret(provider: DistributionProvider, secret: &[u8]) -> Result<()> {
    let key = vault_key(true)?;
    let mut nonce = [0u8; 24];
    getrandom::getrandom(&mut nonce)?;
    let cipher = XChaCha20Poly1305::new_from_slice(&key)
        .map_err(|error| anyhow::anyhow!("failed to initialize vault cipher: {error}"))?;
    let ciphertext = cipher
        .encrypt(XNonce::from_slice(&nonce), secret)
        .map_err(|error| anyhow::anyhow!("failed to encrypt credential record: {error}"))?;
    let record = VaultRecord {
        schema_version: 1,
        provider: provider.as_str().to_string(),
        created_at_unix_seconds: now_unix_seconds(),
        nonce: STANDARD_NO_PAD.encode(nonce),
        ciphertext: STANDARD_NO_PAD.encode(ciphertext),
    };
    let path = vault_record_path(provider)?;
    if let Some(parent) = path.parent() {
        fs::create_dir_all(parent)?;
    }
    fs::write(&path, serde_json::to_vec_pretty(&record)?)
        .with_context(|| format!("failed to write {}", path.display()))?;
    Ok(())
}

pub fn load_provider_secret(provider: DistributionProvider) -> Result<Vec<u8>> {
    let path = vault_record_path(provider)?;
    let record: VaultRecord = serde_json::from_slice(
        &fs::read(&path).with_context(|| format!("failed to read {}", path.display()))?,
    )?;
    let nonce = STANDARD_NO_PAD
        .decode(record.nonce)
        .context("failed to decode vault nonce")?;
    let ciphertext = STANDARD_NO_PAD
        .decode(record.ciphertext)
        .context("failed to decode vault ciphertext")?;
    let key = vault_key(false)?;
    let cipher = XChaCha20Poly1305::new_from_slice(&key)
        .map_err(|error| anyhow::anyhow!("failed to initialize vault cipher: {error}"))?;
    cipher
        .decrypt(XNonce::from_slice(&nonce), ciphertext.as_ref())
        .map_err(|error| anyhow::anyhow!("failed to decrypt credential record: {error}"))
}

pub fn rotate_provider_secret(provider: DistributionProvider) -> Result<()> {
    let secret = load_provider_secret(provider)?;
    store_provider_secret(provider, &secret)
}

pub fn vault_record_path(provider: DistributionProvider) -> Result<PathBuf> {
    Ok(vault_dir()?.join(format!("{}.json", provider.as_str())))
}

fn vault_key(create: bool) -> Result<[u8; 32]> {
    let entry = keyring::Entry::new("fission", "release-vault")
        .context("failed to open OS credential store for the Fission release vault")?;
    match entry.get_password() {
        Ok(encoded) => decode_vault_key(&encoded),
        Err(error) if create => {
            let mut key = [0u8; 32];
            getrandom::getrandom(&mut key)?;
            entry
                .set_password(&STANDARD_NO_PAD.encode(key))
                .with_context(|| {
                    format!("failed to store Fission vault key in OS credential store: {error}")
                })?;
            Ok(key)
        }
        Err(error) => {
            Err(error).context("Fission vault key does not exist in the OS credential store")
        }
    }
}

fn decode_vault_key(encoded: &str) -> Result<[u8; 32]> {
    let bytes = STANDARD_NO_PAD
        .decode(encoded)
        .context("failed to decode Fission vault key")?;
    let key: [u8; 32] = bytes
        .try_into()
        .map_err(|_| anyhow::anyhow!("Fission vault key has the wrong length"))?;
    Ok(key)
}

fn vault_dir() -> Result<PathBuf> {
    let home = env::var_os("HOME")
        .or_else(|| env::var_os("USERPROFILE"))
        .context("HOME/USERPROFILE is not set")?;
    Ok(PathBuf::from(home).join(".fission/vault"))
}

fn now_unix_seconds() -> u64 {
    SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .unwrap_or_default()
        .as_secs()
}