open_envault/crypto/
keys.rs1use 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
14pub type PublicKey = Recipient;
16
17pub const SECRET_LINE_PREFIX: &str = "AGE-SECRET-KEY-";
18const IDENTITY_VARS: [&str; 3] = ["OPENENVAULT_AGE_KEY", "OPENENCRYPT_AGE_KEY", "SOPS_AGE_KEY"];
19const IDENTITY_FILE_VARS: [&str; 3] = [
20 "OPENENVAULT_AGE_KEY_FILE",
21 "OPENENCRYPT_AGE_KEY_FILE",
22 "SOPS_AGE_KEY_FILE",
23];
24
25pub fn parse_recipient(input: &str) -> Result<Recipient> {
27 let s = input.trim();
28 Recipient::from_str(s).map_err(|_| anyhow::anyhow!("invalid age recipient: {s}"))
29}
30
31pub fn parse_identity(input: &str) -> Result<Identity> {
33 let line = input.trim();
34 if !line.starts_with(SECRET_LINE_PREFIX) {
35 bail!("invalid age identity (expected a line starting with {SECRET_LINE_PREFIX})");
36 }
37 Identity::from_str(line).map_err(|_| anyhow::anyhow!("invalid age identity"))
38}
39
40pub fn identity_from_key_text(text: &str) -> Result<Identity> {
42 for line in text.lines() {
43 let line = line.trim();
44 if line.starts_with(SECRET_LINE_PREFIX) {
45 return parse_identity(line);
46 }
47 }
48 bail!("no age secret key found in key material")
49}
50
51pub fn read_identity_file(path: &Path) -> Result<Identity> {
53 let text =
54 fs::read_to_string(path).with_context(|| format!("read key file {}", path.display()))?;
55 identity_from_key_text(&text).with_context(|| format!("parse key file {}", path.display()))
56}
57
58pub fn identities_from_env() -> Vec<Identity> {
61 let mut identities = Vec::new();
62 for var in IDENTITY_VARS {
63 let Ok(value) = env::var(var) else { continue };
64 if value.trim().is_empty() {
65 continue;
66 }
67 if let Ok(identity) = parse_identity(&value) {
68 identities.push(identity);
69 }
70 }
71 for var in IDENTITY_FILE_VARS {
72 let Ok(path) = env::var(var) else { continue };
73 if path.is_empty() {
74 continue;
75 }
76 if let Ok(identity) = read_identity_file(Path::new(&path)) {
77 identities.push(identity);
78 }
79 }
80 identities
81}
82
83pub fn wrap_data_key(data_key: &[u8; 32], recipient: &Recipient) -> Result<String> {
85 age::encrypt_and_armor(recipient, data_key).context("encrypt data key with age")
86}
87
88fn unwrap_one(enc: &str, identities: &[Identity]) -> Result<Vec<u8>> {
90 let ids: Vec<&dyn age::Identity> = identities.iter().map(|i| i as &dyn age::Identity).collect();
91 if ids.is_empty() {
92 bail!("no age identities available");
93 }
94 let decryptor = age::Decryptor::new_buffered(ArmoredReader::new(enc.as_bytes()))
95 .context("parse age data key payload")?;
96 let mut plain = Vec::new();
97 decryptor
98 .decrypt(ids.into_iter())
99 .context("age identity cannot decrypt data key")?
100 .read_to_end(&mut plain)
101 .context("read decrypted data key")?;
102 Ok(plain)
103}
104
105pub fn unwrap_data_key(enc_values: &[String], identities: &[Identity]) -> Result<[u8; 32]> {
107 for enc in enc_values {
108 let Ok(bytes) = unwrap_one(enc, identities) else {
109 continue;
110 };
111 if bytes.len() != 32 {
112 continue;
113 }
114 let mut key = [0u8; 32];
115 key.copy_from_slice(&bytes);
116 return Ok(key);
117 }
118 bail!("no configured identity can decrypt this file (wrong key?)")
119}
120
121pub fn generate_identity() -> Result<(String, String)> {
123 let identity = Identity::generate();
124 let recipient = identity.to_public();
125 let secret = identity.to_string();
126 let text = format!(
127 "# created: {}\n# public key: {}\n{}\n",
128 super::util::date_utc_today(),
129 recipient,
130 secret.expose_secret()
131 );
132 Ok((text, recipient.to_string()))
133}
134
135#[cfg(test)]
136mod tests {
137 use super::*;
138
139 #[test]
140 fn generate_roundtrip() {
141 let (key_text, public) = generate_identity().unwrap();
142 assert!(public.starts_with("age1"));
143 let identity = identity_from_key_text(&key_text).unwrap();
144 let data_key = [9u8; 32];
145 let wrapped = wrap_data_key(&data_key, &parse_recipient(&public).unwrap()).unwrap();
146 assert!(wrapped.starts_with("-----BEGIN AGE ENCRYPTED FILE-----"));
147 assert_eq!(unwrap_data_key(&[wrapped], &[identity]).unwrap(), data_key);
148 }
149
150 #[test]
151 fn wrong_identity_fails() {
152 let (_, public) = generate_identity().unwrap();
153 let (other_text, _) = generate_identity().unwrap();
154 let other = identity_from_key_text(&other_text).unwrap();
155 let data_key = [1u8; 32];
156 let wrapped = wrap_data_key(&data_key, &parse_recipient(&public).unwrap()).unwrap();
157 assert!(unwrap_data_key(&[wrapped], &[other]).is_err());
158 }
159}