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        .unwrap_or_else(|| {
26            PathBuf::from(env::var("HOME").unwrap_or_else(|_| ".".into())).join(".config")
27        })
28        .join("open_envault")
29        .join("keys")
30}
31
32/// Atomic write: temp file in the same directory, fsync, rename.
33fn atomic_write(path: &Path, content: &[u8]) -> Result<()> {
34    let parent = path.parent().unwrap_or_else(|| Path::new("."));
35    let file_name = path
36        .file_name()
37        .map(|n| n.to_string_lossy().into_owned())
38        .unwrap_or_else(|| "out".into());
39    let tmp = parent.join(format!(".{file_name}.{}.tmp", std::process::id()));
40    {
41        let mut file = fs::File::create(&tmp)?;
42        std::io::Write::write_all(&mut file, content)?;
43        file.sync_all()?;
44    }
45    fs::rename(&tmp, path)?;
46    Ok(())
47}
48
49/// Encrypt `content` and write it to `path` atomically for `recipients`.
50pub fn encrypt(content: &str, path: &Path, recipients: &[String]) -> Result<()> {
51    let text = store::encrypt(content, &store::recipients_from_strings(recipients)?)?;
52    atomic_write(path, text.as_bytes())
53}
54
55/// Decrypt the file at `path` using identities from the environment and (when
56/// `environment` is given) the default per-user key file for that environment.
57pub fn decrypt_for(path: &Path, environment: Option<&str>) -> Result<String> {
58    let mut identities = keys::identities_from_env();
59    if let Some(name) = environment {
60        let key_path = default_key_dir().join(format!("{name}.txt"));
61        if key_path.is_file()
62            && let Ok(identity) = keys::read_identity_file(&key_path)
63        {
64            identities.push(identity);
65        }
66    }
67    if identities.is_empty() {
68        anyhow::bail!(
69            "no age identity available; set OPENENCRYPT_AGE_KEY/SOPS_AGE_KEY or a key file"
70        );
71    }
72    let text = fs::read_to_string(path)
73        .with_context(|| format!("read encrypted file {}", path.display()))?;
74    store::decrypt(&text, &identities)
75}
76
77/// Decrypt the file at `path` using environment-supplied identities only.
78pub fn decrypt(path: &Path) -> Result<String> {
79    decrypt_for(path, None)
80}
81
82/// List the recipients recorded in the encrypted file at `path`.
83pub fn recipients_of(path: &Path) -> Result<Vec<String>> {
84    let text = fs::read_to_string(path)
85        .with_context(|| format!("read encrypted file {}", path.display()))?;
86    store::list_recipients(&text)
87}
88
89/// Generate a fresh age identity and write it to `path` (mode 0600).
90/// Returns the full key-file text so callers can print the public key.
91pub fn generate_key(path: &Path) -> Result<String> {
92    let (text, _public) = keys::generate_identity()?;
93    if let Some(parent) = path.parent() {
94        fs::create_dir_all(parent)?;
95    }
96    fs::write(path, text.as_bytes())
97        .with_context(|| format!("write key file {}", path.display()))?;
98    #[cfg(unix)]
99    {
100        use std::os::unix::fs::PermissionsExt;
101        fs::set_permissions(path, fs::Permissions::from_mode(0o600))
102            .with_context(|| format!("chmod key file {}", path.display()))?;
103    }
104    Ok(text)
105}