Skip to main content

open_envault/crypto/
mod.rs

1//! Native SOPS-over-age encryption for open_envault.
2//!
3//! This crate implements the SOPS file format for dotenv documents with age
4//! recipients, so `open_envault` needs no external `sops`/`rage` binaries and
5//! files remain interchangeable with official SOPS. See [`store`] for the
6//! format and [`keys`] for age key handling.
7
8pub mod keys;
9pub mod store;
10pub mod util;
11pub mod value;
12
13use anyhow::{Context, Result};
14use std::{
15    env, fs,
16    path::{Path, PathBuf},
17};
18
19pub use keys::{Identity, Recipient};
20
21/// Default per-user key directory (relative to the config home).
22pub fn default_key_dir() -> PathBuf {
23    env::var_os("XDG_CONFIG_HOME")
24        .map(PathBuf::from)
25        .or_else(|| env::var_os("APPDATA").map(PathBuf::from))
26        .unwrap_or_else(|| {
27            PathBuf::from(env::var("HOME").unwrap_or_else(|_| ".".into())).join(".config")
28        })
29        .join("open-envault")
30        .join("keys")
31}
32
33fn legacy_key_dir() -> PathBuf {
34    default_key_dir()
35        .parent()
36        .unwrap_or_else(|| Path::new("."))
37        .join("open_envault")
38        .join("keys")
39}
40
41/// Atomic write: temp file in the same directory, fsync, rename.
42fn atomic_write(path: &Path, content: &[u8]) -> Result<()> {
43    let parent = path.parent().unwrap_or_else(|| Path::new("."));
44    fs::create_dir_all(parent)?;
45    let file_name = path
46        .file_name()
47        .map(|n| n.to_string_lossy().into_owned())
48        .unwrap_or_else(|| "out".into());
49    let tmp = parent.join(format!(
50        ".{file_name}.{}.{}.tmp",
51        std::process::id(),
52        std::time::SystemTime::now()
53            .duration_since(std::time::UNIX_EPOCH)
54            .unwrap_or_default()
55            .as_nanos()
56    ));
57    let mut file = fs::OpenOptions::new()
58        .write(true)
59        .create_new(true)
60        .open(&tmp)?;
61    std::io::Write::write_all(&mut file, content)?;
62    file.sync_all()?;
63    drop(file);
64    replace_file(&tmp, path).inspect_err(|_error| {
65        let _ = fs::remove_file(&tmp);
66    })?;
67    #[cfg(unix)]
68    fs::File::open(parent)?.sync_all()?;
69    Ok(())
70}
71
72#[cfg(not(windows))]
73fn replace_file(source: &Path, destination: &Path) -> std::io::Result<()> {
74    fs::rename(source, destination)
75}
76
77#[cfg(windows)]
78fn replace_file(source: &Path, destination: &Path) -> std::io::Result<()> {
79    use std::os::windows::ffi::OsStrExt;
80    use windows_sys::Win32::Storage::FileSystem::{MOVEFILE_REPLACE_EXISTING, MoveFileExW};
81    let source: Vec<u16> = source.as_os_str().encode_wide().chain(Some(0)).collect();
82    let destination: Vec<u16> = destination
83        .as_os_str()
84        .encode_wide()
85        .chain(Some(0))
86        .collect();
87    if unsafe {
88        MoveFileExW(
89            source.as_ptr(),
90            destination.as_ptr(),
91            MOVEFILE_REPLACE_EXISTING,
92        )
93    } == 0
94    {
95        Err(std::io::Error::last_os_error())
96    } else {
97        Ok(())
98    }
99}
100
101/// Encrypt `content` and write it to `path` atomically for `recipients`.
102pub fn encrypt(content: &str, path: &Path, recipients: &[String]) -> Result<()> {
103    let text = store::encrypt(content, &store::recipients_from_strings(recipients)?)?;
104    atomic_write(path, text.as_bytes())
105}
106
107/// Decrypt the file at `path` using identities from the environment and (when
108/// `environment` is given) the default per-user key file for that environment.
109pub fn decrypt_for(path: &Path, environment: Option<&str>) -> Result<String> {
110    decrypt_for_with_key_file(path, environment, None)
111}
112
113/// Decrypt using environment identities plus an explicitly configured key file.
114pub fn decrypt_for_with_key_file(
115    path: &Path,
116    environment: Option<&str>,
117    configured_key_file: Option<&Path>,
118) -> Result<String> {
119    let mut identities = keys::identities_from_env();
120    if let Some(key_path) = configured_key_file
121        && key_path.is_file()
122    {
123        identities.push(keys::read_identity_file(key_path)?);
124    }
125    if let Some(name) = environment {
126        for key_dir in [default_key_dir(), legacy_key_dir()] {
127            let key_path = key_dir.join(format!("{name}.txt"));
128            if key_path.is_file()
129                && let Ok(identity) = keys::read_identity_file(&key_path)
130            {
131                identities.push(identity);
132                break;
133            }
134        }
135    }
136    if identities.is_empty() {
137        anyhow::bail!(
138            "no age identity available; set OPENENVAULT_AGE_KEY or SOPS_AGE_KEY, configure a key file, or run `oenv setup`"
139        );
140    }
141    let text = fs::read_to_string(path)
142        .with_context(|| format!("read encrypted file {}", path.display()))?;
143    store::decrypt(&text, &identities)
144}
145
146/// Decrypt the file at `path` using environment-supplied identities only.
147pub fn decrypt(path: &Path) -> Result<String> {
148    decrypt_for(path, None)
149}
150
151/// List the recipients recorded in the encrypted file at `path`.
152pub fn recipients_of(path: &Path) -> Result<Vec<String>> {
153    let text = fs::read_to_string(path)
154        .with_context(|| format!("read encrypted file {}", path.display()))?;
155    store::list_recipients(&text)
156}
157
158/// Generate a fresh age identity and write it to `path` (mode 0600).
159/// Returns the full key-file text so callers can print the public key.
160pub fn generate_key(path: &Path) -> Result<String> {
161    let (text, _public) = keys::generate_identity()?;
162    if let Some(parent) = path.parent() {
163        fs::create_dir_all(parent)?;
164    }
165    let mut file = fs::OpenOptions::new()
166        .write(true)
167        .create_new(true)
168        .open(path)
169        .with_context(|| {
170            if path.exists() {
171                format!(
172                    "key file already exists; refusing to overwrite {}",
173                    path.display()
174                )
175            } else {
176                format!("write key file {}", path.display())
177            }
178        })?;
179    std::io::Write::write_all(&mut file, text.as_bytes())?;
180    file.sync_all()?;
181    #[cfg(unix)]
182    {
183        use std::os::unix::fs::PermissionsExt;
184        fs::set_permissions(path, fs::Permissions::from_mode(0o600))
185            .with_context(|| format!("chmod key file {}", path.display()))?;
186    }
187    Ok(text)
188}
189
190/// Reuse an existing identity or create a new one without overwriting files.
191pub fn ensure_key(path: &Path) -> Result<(String, String, bool)> {
192    if path.is_file() {
193        let identity = keys::read_identity_file(path)
194            .with_context(|| format!("validate existing age key {}", path.display()))?;
195        return Ok((
196            identity.to_public().to_string(),
197            path.display().to_string(),
198            false,
199        ));
200    }
201    let text = generate_key(path)?;
202    let identity = keys::identity_from_key_text(&text)?;
203    Ok((
204        identity.to_public().to_string(),
205        path.display().to_string(),
206        true,
207    ))
208}