fission_credentials/
lib.rs1use 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}