use std::path::Path;
use gunnar_repair::order::MailRelay;
pub const KEPT_REL: &str = "pairing/mail-relay.json";
pub fn keep(root: &Path, relay: &MailRelay) -> Result<String, String> {
let smtp = crate::postman::Smtp::from_url(relay.smtp_url.trim(), relay.smtp_from.trim())
.map_err(|why| {
format!("the mail_relay this box was fed cannot mail ({why}): nothing was kept")
})?;
let smtp = smtp.with_ca(&relay.smtp_ca).map_err(|why| {
format!("the mail_relay's secret.smtp_ca is no root ({why}): nothing was kept")
})?;
let (host, port, _) = smtp.endpoint();
let at = format!("{host}:{port}");
let json = serde_json::to_vec(relay)
.map_err(|e| format!("the mail_relay could not be written: {e}"))?;
seal_to(root, &json)
.map_err(|e| format!("the mail_relay could not be kept at {KEPT_REL}: {e:#}"))?;
tracing::warn!(target: "gunnar::audit", event = "mail_relay_kept", relay = %at, from = %relay.smtp_from.trim(), "the ONE box fed this box its mail relay again: kept sealed, in use from the next mail");
Ok(format!(
"the mail relay {at} is kept: this box mails as {} now",
relay.smtp_from.trim()
))
}
pub fn kept(root: &Path) -> Option<MailRelay> {
let text = match open_from(root) {
Ok(t) => t?,
Err(e) => {
tracing::error!(file = KEPT_REL, error = %format!("{e:#}"), "the kept mail relay does not open: the relay fed at lay is used");
return None;
}
};
serde_json::from_str::<MailRelay>(&text)
.ok()
.filter(|r| !r.smtp_url.trim().is_empty() && !r.smtp_from.trim().is_empty())
}
pub fn over(fed: Option<crate::feed::Fed>, root: &Path) -> Option<crate::feed::Fed> {
let Some(k) = kept(root) else { return fed };
let mut f = fed.unwrap_or_default();
f.smtp_url = Some(crate::feed::Withheld(k.smtp_url.trim().to_string()));
f.smtp_from = Some(k.smtp_from.trim().to_string());
f.smtp_ca = Some(k.smtp_ca.trim().to_string()).filter(|c| !c.is_empty());
Some(f)
}
fn kek_dir(root: &Path) -> std::path::PathBuf {
root.join("pairing")
}
fn seal_to(root: &Path, bytes: &[u8]) -> anyhow::Result<()> {
let dir = kek_dir(root);
std::fs::create_dir_all(&dir)?;
let kek = gunnar_core::Kek::for_control_dir(&dir)
.map_err(|e| anyhow::anyhow!("the pairing KEK under {}: {e}", dir.display()))?;
let sealed = gunnar_core::seal::seal(&kek, KEPT_REL, bytes)
.map_err(|e| anyhow::anyhow!("sealing {KEPT_REL}: {e}"))?;
crate::durable::write_atomic_unframed(root, KEPT_REL, &sealed)?;
use std::os::unix::fs::PermissionsExt as _;
std::fs::set_permissions(root.join(KEPT_REL), std::fs::Permissions::from_mode(0o600))?;
Ok(())
}
fn open_from(root: &Path) -> anyhow::Result<Option<String>> {
let path = root.join(KEPT_REL);
let raw = match std::fs::read(&path) {
Ok(r) => r,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(e) => anyhow::bail!("{}: {e}", path.display()),
};
if !gunnar_core::seal::is_sealed(&raw) {
anyhow::bail!("{} is not sealed", path.display());
}
let kek_file = kek_dir(root).join(gunnar_core::seal::KEK_FILE);
let kek = match gunnar_core::Kek::from_env().map_err(|e| anyhow::anyhow!("{e}"))? {
Some(k) => k,
None if kek_file.exists() => {
gunnar_core::Kek::from_file(&kek_file).map_err(|e| anyhow::anyhow!("{e}"))?
}
None => anyhow::bail!(
"{} is sealed and this box holds no key for it",
path.display()
),
};
let plain = gunnar_core::seal::unseal(&kek, KEPT_REL, &raw).map_err(|e| {
anyhow::anyhow!("{} does not open under this box's key: {e}", path.display())
})?;
String::from_utf8(plain)
.map(Some)
.map_err(|e| anyhow::anyhow!("{}: {e}", path.display()))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::postman::{FedRelay, Postman as _};
const TOKEN: &str = "t0kenVALUEnever-shown";
#[test]
fn a_main_fed_its_relay_again_keeps_it_sealed_and_its_postman_mails_through_it() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path().to_path_buf();
let r = root.clone();
let postman = FedRelay {
fed: move || over(None, &r),
};
assert!(
postman.ready().unwrap_err().contains("secret.smtp_url"),
"fed no relay: mail is off"
);
let bad = MailRelay {
smtp_url: format!("http://u:{TOKEN}@relay.mock.invalid"),
smtp_from: "g@mock.invalid".into(),
smtp_ca: String::new(),
};
let e = keep(&root, &bad).unwrap_err();
assert!(e.contains("mail_relay") && !e.contains(TOKEN), "{e}");
assert!(!root.join(KEPT_REL).exists() && kept(&root).is_none());
let relay = MailRelay {
smtp_url: format!("smtp://gunnar%40mock.invalid:{TOKEN}@relay.mock.invalid:587"),
smtp_from: "Gunnar <gunnar@mock.invalid>".into(),
smtp_ca: String::new(),
};
let said = keep(&root, &relay).expect("kept");
assert!(
said.contains("relay.mock.invalid:587")
&& said.contains("gunnar@mock.invalid")
&& !said.contains(TOKEN),
"{said}"
);
let raw = std::fs::read(root.join(KEPT_REL)).unwrap();
assert!(
gunnar_core::seal::is_sealed(&raw) && !String::from_utf8_lossy(&raw).contains(TOKEN),
"sealed at rest"
);
let mode = std::os::unix::fs::PermissionsExt::mode(
&std::fs::metadata(root.join(KEPT_REL))
.unwrap()
.permissions(),
) & 0o777;
assert_eq!(mode, 0o600);
assert_eq!(kept(&root), Some(relay.clone()));
postman.ready().expect("the kept relay is in use at once");
let lay = crate::feed::Fed {
smtp_url: Some(crate::feed::Withheld(
"smtp://old:old@old.mock.invalid:587".into(),
)),
smtp_from: Some("old@mock.invalid".into()),
fleet: Some("f".into()),
..Default::default()
};
let f = over(Some(lay), &root).unwrap();
assert_eq!(
(
f.smtp_url.map(|w| w.0),
f.smtp_from.as_deref(),
f.fleet.as_deref()
),
(
Some(relay.smtp_url.clone()),
Some("Gunnar <gunnar@mock.invalid>"),
Some("f")
)
);
}
fn private_ca() -> (String, Vec<u8>, Vec<u8>) {
let ca_key = rcgen::KeyPair::generate().unwrap();
let mut ca = rcgen::CertificateParams::new(Vec::<String>::new()).unwrap();
ca.is_ca = rcgen::IsCa::Ca(rcgen::BasicConstraints::Unconstrained);
ca.key_usages = vec![
rcgen::KeyUsagePurpose::KeyCertSign,
rcgen::KeyUsagePurpose::CrlSign,
];
ca.distinguished_name
.push(rcgen::DnType::CommonName, "the relay operator's own CA");
let ca_cert = ca.self_signed(&ca_key).unwrap();
let issuer = rcgen::Issuer::new(ca, ca_key);
let leaf_key = rcgen::KeyPair::generate().unwrap();
let leaf = rcgen::CertificateParams::new(vec!["localhost".to_owned()])
.unwrap()
.signed_by(&leaf_key, &issuer)
.unwrap();
(ca_cert.pem(), leaf.der().to_vec(), leaf_key.serialize_der())
}
fn fake_relay(
leaf: Vec<u8>,
key: Vec<u8>,
) -> (u16, std::sync::Arc<std::sync::atomic::AtomicUsize>) {
use std::io::{BufRead as _, Write as _};
let provider = std::sync::Arc::new(rustls::crypto::ring::default_provider());
let config = rustls::ServerConfig::builder_with_provider(provider)
.with_safe_default_protocol_versions()
.unwrap()
.with_no_client_auth()
.with_single_cert(
vec![leaf.into()],
rustls::pki_types::PrivateKeyDer::Pkcs8(key.into()),
)
.unwrap();
let config = std::sync::Arc::new(config);
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
let took = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
let count = std::sync::Arc::clone(&took);
std::thread::spawn(move || {
for stream in listener.incoming().flatten() {
let (config, count) = (
std::sync::Arc::clone(&config),
std::sync::Arc::clone(&count),
);
std::thread::spawn(move || {
let Ok(conn) = rustls::ServerConnection::new(config) else {
return;
};
let mut tls = rustls::StreamOwned::new(conn, stream);
if tls
.write_all(b"220 localhost ESMTP fake\r\n")
.and_then(|()| tls.flush())
.is_err()
{
return;
}
let mut reader = std::io::BufReader::new(tls);
let mut data = false;
loop {
let mut line = String::new();
if reader.read_line(&mut line).map_or(true, |n| n == 0) {
return;
}
let upper = line.to_ascii_uppercase();
let answer: &[u8] = if data {
if line == ".\r\n" {
data = false;
count.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
b"250 queued\r\n"
} else {
continue;
}
} else if upper.starts_with("EHLO") || upper.starts_with("HELO") {
b"250-localhost\r\n250 AUTH PLAIN LOGIN\r\n"
} else if upper.starts_with("AUTH") {
b"235 ok\r\n"
} else if upper.starts_with("DATA") {
data = true;
b"354 go on\r\n"
} else if upper.starts_with("QUIT") {
let _ = reader.get_mut().write_all(b"221 bye\r\n");
return;
} else {
b"250 ok\r\n"
};
let tls = reader.get_mut();
if tls.write_all(answer).and_then(|()| tls.flush()).is_err() {
return;
}
}
});
}
});
(port, took)
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn a_relay_behind_a_private_ca_is_trusted_under_the_fed_smtp_ca() {
let (ca, leaf, key) = private_ca();
let (other_ca, _, _) = private_ca();
let (port, took) = fake_relay(leaf, key);
let dir = tempfile::tempdir().unwrap();
let root = dir.path().to_path_buf();
let r = root.clone();
let postman = FedRelay {
fed: move || over(None, &r),
};
let mail = gunnar_repair::sign_in::SignInMail {
to: "buyer@mock.invalid".into(),
subject: "sign in".into(),
body: "a link".into(),
};
let relay = |ca: &str| MailRelay {
smtp_url: format!("smtps://gunnar%40mock.invalid:{TOKEN}@localhost:{port}"),
smtp_from: "Gunnar <gunnar@mock.invalid>".into(),
smtp_ca: ca.to_owned(),
};
keep(&root, &relay("")).expect("kept without a CA");
let refused = postman
.post(&mail)
.await
.expect_err("the public roots do not know the relay's CA");
assert!(!refused.contains(TOKEN), "{refused}");
keep(&root, &relay(&other_ca)).expect("kept with another CA");
postman
.post(&mail)
.await
.expect_err("another CA is not the relay's");
assert_eq!(
took.load(std::sync::atomic::Ordering::SeqCst),
0,
"no mail went to an untrusted relay"
);
let said = keep(&root, &relay(&ca)).expect("kept with the relay's CA");
assert!(
said.contains("localhost") && !said.contains(TOKEN),
"{said}"
);
assert_eq!(
kept(&root).map(|k| k.smtp_ca),
Some(ca.clone()),
"the CA is kept, sealed with the relay"
);
postman
.post(&mail)
.await
.expect("mailed through the relay, under its CA");
assert_eq!(took.load(std::sync::atomic::Ordering::SeqCst), 1);
let e = keep(
&root,
&relay("-----BEGIN CERTIFICATE-----\nnot base64\n-----END CERTIFICATE-----\n"),
)
.expect_err("a CA that does not parse");
assert!(
e.contains("smtp_ca") && e.contains("nothing was kept"),
"{e}"
);
assert_eq!(
kept(&root).map(|k| k.smtp_ca),
Some(ca),
"the CA kept before stays"
);
}
}