Skip to main content

open_envault/crypto/
keys.rs

1//! Age key handling: recipients, identities, data-key wrapping, and key
2//! generation.
3//!
4//! The data key is wrapped for every recipient individually and stored as an
5//! ASCII-armored age file in the SOPS metadata (`enc` field), mirroring
6//! `github.com/getsops/sops/v3/age.MasterKey.Encrypt`.
7
8use age::{armor::ArmoredReader, secrecy::ExposeSecret};
9use anyhow::{Context, Result, bail};
10use std::{env, fs, io::Read, path::Path, str::FromStr};
11
12pub use age::x25519::{Identity, Recipient};
13
14/// Public recipient type (age1...).
15pub type PublicKey = Recipient;
16
17pub const SECRET_LINE_PREFIX: &str = "AGE-SECRET-KEY-";
18const IDENTITY_VARS: [&str; 2] = ["OPENENCRYPT_AGE_KEY", "SOPS_AGE_KEY"];
19const IDENTITY_FILE_VARS: [&str; 2] = ["OPENENCRYPT_AGE_KEY_FILE", "SOPS_AGE_KEY_FILE"];
20
21/// Parse a single public recipient string (`age1...`).
22pub fn parse_recipient(input: &str) -> Result<Recipient> {
23    let s = input.trim();
24    Recipient::from_str(s).map_err(|_| anyhow::anyhow!("invalid age recipient: {s}"))
25}
26
27/// Parse a single secret identity line (`AGE-SECRET-KEY-1...`).
28pub fn parse_identity(input: &str) -> Result<Identity> {
29    let line = input.trim();
30    if !line.starts_with(SECRET_LINE_PREFIX) {
31        bail!("invalid age identity (expected a line starting with {SECRET_LINE_PREFIX})");
32    }
33    Identity::from_str(line).map_err(|_| anyhow::anyhow!("invalid age identity"))
34}
35
36/// Extract the first secret identity from key-file text (ignores `#` headers).
37pub fn identity_from_key_text(text: &str) -> Result<Identity> {
38    for line in text.lines() {
39        let line = line.trim();
40        if line.starts_with(SECRET_LINE_PREFIX) {
41            return parse_identity(line);
42        }
43    }
44    bail!("no age secret key found in key material")
45}
46
47/// Read and parse a private key file.
48pub fn read_identity_file(path: &Path) -> Result<Identity> {
49    let text =
50        fs::read_to_string(path).with_context(|| format!("read key file {}", path.display()))?;
51    identity_from_key_text(&text).with_context(|| format!("parse key file {}", path.display()))
52}
53
54/// Collect identities from `OPENENCRYPT_AGE_KEY` / `SOPS_AGE_KEY` and the
55/// corresponding `*_KEY_FILE` variables. Missing variables are skipped.
56pub fn identities_from_env() -> Vec<Identity> {
57    let mut identities = Vec::new();
58    for var in IDENTITY_VARS {
59        let Ok(value) = env::var(var) else { continue };
60        if value.trim().is_empty() {
61            continue;
62        }
63        if let Ok(identity) = parse_identity(&value) {
64            identities.push(identity);
65        }
66    }
67    for var in IDENTITY_FILE_VARS {
68        let Ok(path) = env::var(var) else { continue };
69        if path.is_empty() {
70            continue;
71        }
72        if let Ok(identity) = read_identity_file(Path::new(&path)) {
73            identities.push(identity);
74        }
75    }
76    identities
77}
78
79/// Wrap the data key for a single recipient, returning the armored payload.
80pub fn wrap_data_key(data_key: &[u8; 32], recipient: &Recipient) -> Result<String> {
81    age::encrypt_and_armor(recipient, data_key).context("encrypt data key with age")
82}
83
84/// Try to unwrap the data key from one armored payload with any of `identities`.
85fn unwrap_one(enc: &str, identities: &[Identity]) -> Result<Vec<u8>> {
86    let ids: Vec<&dyn age::Identity> = identities.iter().map(|i| i as &dyn age::Identity).collect();
87    if ids.is_empty() {
88        bail!("no age identities available");
89    }
90    let decryptor = age::Decryptor::new_buffered(ArmoredReader::new(enc.as_bytes()))
91        .context("parse age data key payload")?;
92    let mut plain = Vec::new();
93    decryptor
94        .decrypt(ids.into_iter())
95        .context("age identity cannot decrypt data key")?
96        .read_to_end(&mut plain)
97        .context("read decrypted data key")?;
98    Ok(plain)
99}
100
101/// Unwrap the data key from any of the armored `enc` payloads.
102pub fn unwrap_data_key(enc_values: &[String], identities: &[Identity]) -> Result<[u8; 32]> {
103    for enc in enc_values {
104        let Ok(bytes) = unwrap_one(enc, identities) else {
105            continue;
106        };
107        if bytes.len() != 32 {
108            continue;
109        }
110        let mut key = [0u8; 32];
111        key.copy_from_slice(&bytes);
112        return Ok(key);
113    }
114    bail!("no configured identity can decrypt this file (wrong key?)")
115}
116
117/// Generate a fresh identity. Returns `(key file text, public recipient)`.
118pub fn generate_identity() -> Result<(String, String)> {
119    let identity = Identity::generate();
120    let recipient = identity.to_public();
121    let secret = identity.to_string();
122    let text = format!(
123        "# created: {}\n# public key: {}\n{}\n",
124        super::util::date_utc_today(),
125        recipient,
126        secret.expose_secret()
127    );
128    Ok((text, recipient.to_string()))
129}
130
131#[cfg(test)]
132mod tests {
133    use super::*;
134
135    #[test]
136    fn generate_roundtrip() {
137        let (key_text, public) = generate_identity().unwrap();
138        assert!(public.starts_with("age1"));
139        let identity = identity_from_key_text(&key_text).unwrap();
140        let data_key = [9u8; 32];
141        let wrapped = wrap_data_key(&data_key, &parse_recipient(&public).unwrap()).unwrap();
142        assert!(wrapped.starts_with("-----BEGIN AGE ENCRYPTED FILE-----"));
143        assert_eq!(unwrap_data_key(&[wrapped], &[identity]).unwrap(), data_key);
144    }
145
146    #[test]
147    fn wrong_identity_fails() {
148        let (_, public) = generate_identity().unwrap();
149        let (other_text, _) = generate_identity().unwrap();
150        let other = identity_from_key_text(&other_text).unwrap();
151        let data_key = [1u8; 32];
152        let wrapped = wrap_data_key(&data_key, &parse_recipient(&public).unwrap()).unwrap();
153        assert!(unwrap_data_key(&[wrapped], &[other]).is_err());
154    }
155}