Skip to main content

fission_credentials/
lib.rs

1use anyhow::{bail, Context, Result};
2use base64::{engine::general_purpose::STANDARD_NO_PAD, Engine as _};
3use chacha20poly1305::{
4    aead::{Aead, KeyInit},
5    XChaCha20Poly1305, XNonce,
6};
7use fission_command_core::DistributionProvider;
8use serde::{Deserialize, Serialize};
9use std::env;
10use std::fs;
11use std::path::PathBuf;
12use std::time::{SystemTime, UNIX_EPOCH};
13
14#[derive(Debug, Serialize, Deserialize)]
15struct VaultRecord {
16    schema_version: u32,
17    provider: String,
18    created_at_unix_seconds: u64,
19    nonce: String,
20    ciphertext: String,
21}
22
23pub fn provider_secret(
24    provider: DistributionProvider,
25    env_names: &[&str],
26) -> Result<Option<String>> {
27    if let Some(name) = env_names.iter().find(|name| env::var_os(name).is_some()) {
28        return env::var(name)
29            .map(Some)
30            .with_context(|| format!("environment variable {name} is not valid UTF-8"));
31    }
32    let path = vault_record_path(provider)?;
33    if !path.exists() {
34        return Ok(None);
35    }
36    let bytes = load_provider_secret(provider)?;
37    String::from_utf8(bytes)
38        .map(Some)
39        .context("stored provider credential is not valid UTF-8")
40}
41
42pub fn read_secret_source(source: &str) -> Result<String> {
43    if let Some(name) = source.strip_prefix("env:") {
44        env::var(name).with_context(|| format!("environment variable {name} is not set"))
45    } else if let Some(path) = source.strip_prefix("file:") {
46        fs::read_to_string(path).with_context(|| format!("failed to read credential file {path}"))
47    } else {
48        bail!("credential source must be env:<NAME> or file:<PATH>")
49    }
50}
51
52pub fn store_provider_secret(provider: DistributionProvider, secret: &[u8]) -> Result<()> {
53    let key = vault_key(true)?;
54    let mut nonce = [0u8; 24];
55    getrandom::getrandom(&mut nonce)?;
56    let cipher = XChaCha20Poly1305::new_from_slice(&key)
57        .map_err(|error| anyhow::anyhow!("failed to initialize vault cipher: {error}"))?;
58    let ciphertext = cipher
59        .encrypt(XNonce::from_slice(&nonce), secret)
60        .map_err(|error| anyhow::anyhow!("failed to encrypt credential record: {error}"))?;
61    let record = VaultRecord {
62        schema_version: 1,
63        provider: provider.as_str().to_string(),
64        created_at_unix_seconds: now_unix_seconds(),
65        nonce: STANDARD_NO_PAD.encode(nonce),
66        ciphertext: STANDARD_NO_PAD.encode(ciphertext),
67    };
68    let path = vault_record_path(provider)?;
69    if let Some(parent) = path.parent() {
70        fs::create_dir_all(parent)?;
71    }
72    fs::write(&path, serde_json::to_vec_pretty(&record)?)
73        .with_context(|| format!("failed to write {}", path.display()))?;
74    Ok(())
75}
76
77pub fn load_provider_secret(provider: DistributionProvider) -> Result<Vec<u8>> {
78    let path = vault_record_path(provider)?;
79    let record: VaultRecord = serde_json::from_slice(
80        &fs::read(&path).with_context(|| format!("failed to read {}", path.display()))?,
81    )?;
82    let nonce = STANDARD_NO_PAD
83        .decode(record.nonce)
84        .context("failed to decode vault nonce")?;
85    let ciphertext = STANDARD_NO_PAD
86        .decode(record.ciphertext)
87        .context("failed to decode vault ciphertext")?;
88    let key = vault_key(false)?;
89    let cipher = XChaCha20Poly1305::new_from_slice(&key)
90        .map_err(|error| anyhow::anyhow!("failed to initialize vault cipher: {error}"))?;
91    cipher
92        .decrypt(XNonce::from_slice(&nonce), ciphertext.as_ref())
93        .map_err(|error| anyhow::anyhow!("failed to decrypt credential record: {error}"))
94}
95
96pub fn rotate_provider_secret(provider: DistributionProvider) -> Result<()> {
97    let secret = load_provider_secret(provider)?;
98    store_provider_secret(provider, &secret)
99}
100
101pub fn vault_record_path(provider: DistributionProvider) -> Result<PathBuf> {
102    Ok(vault_dir()?.join(format!("{}.json", provider.as_str())))
103}
104
105fn vault_key(create: bool) -> Result<[u8; 32]> {
106    let entry = keyring::Entry::new("fission", "release-vault")
107        .context("failed to open OS credential store for the Fission release vault")?;
108    match entry.get_password() {
109        Ok(encoded) => decode_vault_key(&encoded),
110        Err(error) if create => {
111            let mut key = [0u8; 32];
112            getrandom::getrandom(&mut key)?;
113            entry
114                .set_password(&STANDARD_NO_PAD.encode(key))
115                .with_context(|| {
116                    format!("failed to store Fission vault key in OS credential store: {error}")
117                })?;
118            Ok(key)
119        }
120        Err(error) => {
121            Err(error).context("Fission vault key does not exist in the OS credential store")
122        }
123    }
124}
125
126fn decode_vault_key(encoded: &str) -> Result<[u8; 32]> {
127    let bytes = STANDARD_NO_PAD
128        .decode(encoded)
129        .context("failed to decode Fission vault key")?;
130    let key: [u8; 32] = bytes
131        .try_into()
132        .map_err(|_| anyhow::anyhow!("Fission vault key has the wrong length"))?;
133    Ok(key)
134}
135
136fn vault_dir() -> Result<PathBuf> {
137    let home = env::var_os("HOME")
138        .or_else(|| env::var_os("USERPROFILE"))
139        .context("HOME/USERPROFILE is not set")?;
140    Ok(PathBuf::from(home).join(".fission/vault"))
141}
142
143fn now_unix_seconds() -> u64 {
144    SystemTime::now()
145        .duration_since(UNIX_EPOCH)
146        .unwrap_or_default()
147        .as_secs()
148}