Skip to main content

doido_generators/commands/
credentials.rs

1//! `doido credentials` — AES-256-GCM encrypted credentials (Rails
2//! `credentials`). Plaintext lives only in `$EDITOR`; on disk it is the
3//! encrypted `config/credentials.yml.enc`, decryptable with the app's master
4//! key (`config/master.key` or the `DOIDO_MASTER_KEY` env var).
5
6use 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
18/// Seed contents shown when editing credentials for the first time.
19const 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 the encrypted credentials in `$EDITOR`
28    Edit,
29    /// Print the decrypted credentials to stdout
30    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
53/// The master key from `DOIDO_MASTER_KEY` or `config/master.key`, if available.
54fn 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
66/// The master key, generating (and gitignoring) `config/master.key` if missing.
67fn 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
83/// The master key, erroring if none is configured (for read-only commands).
84fn 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
94/// Ensure `config/master.key` is gitignored (append the line if absent).
95fn 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
108/// Decrypt the credentials file, or return the seed template when absent.
109fn 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
121/// Encrypt `plaintext` to the credentials file.
122fn 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, &current).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        // A wrong key fails authentication.
194        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}