doido_generators/commands/
credentials.rs1use clap::Subcommand;
7use doido_core::anyhow::anyhow;
8use doido_core::Result;
9use std::fs;
10use std::io::Write;
11use std::path::{Path, PathBuf};
12use std::process::Command;
13
14const CONFIG_DIR: &str = "config";
15const MASTER_KEY_NAME: &str = "master.key";
16const CREDENTIALS_NAME: &str = "credentials.yml.enc";
17
18const DEFAULT_TEMPLATE: &str = "# Encrypted credentials — edit with `doido credentials edit`.\n\
20# secret_key_base: change-me\n\
21# aws:\n\
22# access_key_id: ...\n\
23# secret_access_key: ...\n";
24
25#[derive(Subcommand)]
26pub enum CredentialsCommand {
27 Edit,
29 Show,
31}
32
33pub fn run(cmd: CredentialsCommand) {
34 let config_dir = Path::new(CONFIG_DIR);
35 let result = match cmd {
36 CredentialsCommand::Edit => edit(config_dir),
37 CredentialsCommand::Show => show(config_dir),
38 };
39 if let Err(e) = result {
40 doido_core::tracing::error!("credentials: {e}");
41 std::process::exit(1);
42 }
43}
44
45fn master_key_path(config_dir: &Path) -> PathBuf {
46 config_dir.join(MASTER_KEY_NAME)
47}
48
49fn credentials_path(config_dir: &Path) -> PathBuf {
50 config_dir.join(CREDENTIALS_NAME)
51}
52
53fn read_master_key(config_dir: &Path) -> Option<Vec<u8>> {
55 if let Ok(key) = std::env::var("DOIDO_MASTER_KEY") {
56 let key = key.trim();
57 if !key.is_empty() {
58 return Some(key.as_bytes().to_vec());
59 }
60 }
61 fs::read_to_string(master_key_path(config_dir))
62 .ok()
63 .map(|s| s.trim().as_bytes().to_vec())
64}
65
66fn ensure_master_key(config_dir: &Path) -> Result<Vec<u8>> {
68 if let Some(key) = read_master_key(config_dir) {
69 return Ok(key);
70 }
71 let key = doido_core::crypto::generate_key_hex();
72 fs::create_dir_all(config_dir).map_err(|e| anyhow!("could not create {CONFIG_DIR}: {e}"))?;
73 fs::write(master_key_path(config_dir), &key)
74 .map_err(|e| anyhow!("could not write master key: {e}"))?;
75 gitignore_master_key();
76 doido_core::tracing::info!(
77 "generated {}/{MASTER_KEY_NAME} (keep it secret — it was added to .gitignore)",
78 config_dir.display()
79 );
80 Ok(key.into_bytes())
81}
82
83fn require_master_key(config_dir: &Path) -> Result<Vec<u8>> {
85 read_master_key(config_dir).ok_or_else(|| {
86 anyhow!(
87 "no master key: set DOIDO_MASTER_KEY or create {}/{MASTER_KEY_NAME} \
88 (run `doido credentials edit`)",
89 CONFIG_DIR
90 )
91 })
92}
93
94fn gitignore_master_key() {
96 let path = Path::new(".gitignore");
97 let mut content = fs::read_to_string(path).unwrap_or_default();
98 if content.contains(MASTER_KEY_NAME) {
99 return;
100 }
101 if !content.is_empty() && !content.ends_with('\n') {
102 content.push('\n');
103 }
104 content.push_str("config/master.key\n");
105 let _ = fs::write(path, content);
106}
107
108fn read_credentials(config_dir: &Path, key: &[u8]) -> Result<String> {
110 match fs::read(credentials_path(config_dir)) {
111 Ok(blob) => {
112 let plain = doido_core::crypto::decrypt(key, &blob).ok_or_else(|| {
113 anyhow!("could not decrypt {CREDENTIALS_NAME} (wrong master key?)")
114 })?;
115 String::from_utf8(plain).map_err(|e| anyhow!("credentials are not valid UTF-8: {e}"))
116 }
117 Err(_) => Ok(DEFAULT_TEMPLATE.to_string()),
118 }
119}
120
121fn write_credentials(config_dir: &Path, key: &[u8], plaintext: &str) -> Result<()> {
123 fs::create_dir_all(config_dir).map_err(|e| anyhow!("could not create {CONFIG_DIR}: {e}"))?;
124 let blob = doido_core::crypto::encrypt(key, plaintext.as_bytes());
125 fs::write(credentials_path(config_dir), blob)
126 .map_err(|e| anyhow!("could not write {CREDENTIALS_NAME}: {e}"))
127}
128
129fn edit(config_dir: &Path) -> Result<()> {
130 let key = ensure_master_key(config_dir)?;
131 let current = read_credentials(config_dir, &key)?;
132
133 let tmp = std::env::temp_dir().join(format!("doido-credentials-{}.yml", std::process::id()));
134 fs::write(&tmp, ¤t).map_err(|e| anyhow!("could not write temp file: {e}"))?;
135
136 let editor = std::env::var("EDITOR").unwrap_or_else(|_| "vi".to_string());
137 let status = Command::new(&editor).arg(&tmp).status();
138 let edited = match status {
139 Ok(s) if s.success() => {
140 fs::read_to_string(&tmp).map_err(|e| anyhow!("could not read temp file: {e}"))?
141 }
142 Ok(_) => {
143 let _ = fs::remove_file(&tmp);
144 return Err(anyhow!("editor exited non-zero; credentials unchanged"));
145 }
146 Err(e) => {
147 let _ = fs::remove_file(&tmp);
148 return Err(anyhow!("could not launch editor '{editor}': {e}"));
149 }
150 };
151 let _ = fs::remove_file(&tmp);
152
153 write_credentials(config_dir, &key, &edited)?;
154 doido_core::tracing::info!(
155 "credentials saved to {}",
156 credentials_path(config_dir).display()
157 );
158 Ok(())
159}
160
161fn show(config_dir: &Path) -> Result<()> {
162 let key = require_master_key(config_dir)?;
163 let plain = read_credentials(config_dir, &key)?;
164 print!("{plain}");
165 let _ = std::io::stdout().flush();
166 Ok(())
167}
168
169#[cfg(test)]
170mod tests {
171 use super::*;
172
173 fn temp_dir() -> PathBuf {
174 let dir = std::env::temp_dir().join(format!(
175 "doido-cred-{}",
176 doido_core::crypto::generate_key_hex()
177 ));
178 fs::create_dir_all(&dir).unwrap();
179 dir
180 }
181
182 #[test]
183 fn credentials_round_trip_through_files() {
184 let dir = temp_dir();
185 let key = b"a-test-master-key";
186
187 write_credentials(&dir, key, "secret_key_base: abc123\n").unwrap();
188 assert_eq!(
189 read_credentials(&dir, key).unwrap(),
190 "secret_key_base: abc123\n"
191 );
192
193 assert!(read_credentials(&dir, b"wrong-key").is_err());
195
196 fs::remove_dir_all(&dir).ok();
197 }
198
199 #[test]
200 fn absent_credentials_yield_the_seed_template() {
201 let dir = temp_dir();
202 let plain = read_credentials(&dir, b"k").unwrap();
203 assert!(plain.contains("Encrypted credentials"));
204 fs::remove_dir_all(&dir).ok();
205 }
206}