Lewis: May this revision serve well! lu5a@proton.me
+418
Diff
round #3
+227
crates/tranquil-comms/src/email/dkim.rs
+227
crates/tranquil-comms/src/email/dkim.rs
···
1
+
use std::fs;
2
+
3
+
use base64::Engine;
4
+
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
5
+
use ed25519_dalek::pkcs8::DecodePrivateKey as _;
6
+
use lettre::Message;
7
+
use lettre::message::dkim::{
8
+
DkimCanonicalization, DkimCanonicalizationType, DkimConfig as LettreDkimConfig,
9
+
DkimSigningAlgorithm, DkimSigningKey,
10
+
};
11
+
use lettre::message::header::HeaderName;
12
+
use rsa::pkcs1::EncodeRsaPrivateKey;
13
+
use rsa::pkcs8::LineEnding;
14
+
15
+
use super::types::{DkimKeyPath, DkimSelector, EmailDomain};
16
+
use crate::sender::SendError;
17
+
18
+
const SIGNED_HEADERS: &[&str] = &[
19
+
"From",
20
+
"Sender",
21
+
"Reply-To",
22
+
"To",
23
+
"Cc",
24
+
"Subject",
25
+
"Date",
26
+
"In-Reply-To",
27
+
"References",
28
+
"MIME-Version",
29
+
"Content-Type",
30
+
"Content-Transfer-Encoding",
31
+
];
32
+
33
+
pub struct DkimSigner {
34
+
config: LettreDkimConfig,
35
+
}
36
+
37
+
impl DkimSigner {
38
+
pub fn load(
39
+
selector: DkimSelector,
40
+
domain: EmailDomain,
41
+
path: DkimKeyPath,
42
+
) -> Result<Self, SendError> {
43
+
let pem = fs::read_to_string(path.as_path()).map_err(|e| {
44
+
SendError::DkimSign(format!("read DKIM key {}: {e}", path.as_path().display()))
45
+
})?;
46
+
Self::from_pem(selector, domain, &pem)
47
+
}
48
+
49
+
pub fn from_pem(
50
+
selector: DkimSelector,
51
+
domain: EmailDomain,
52
+
pem: &str,
53
+
) -> Result<Self, SendError> {
54
+
let key = parse_key(pem)?;
55
+
let canonicalization = DkimCanonicalization {
56
+
header: DkimCanonicalizationType::Relaxed,
57
+
body: DkimCanonicalizationType::Relaxed,
58
+
};
59
+
let headers = SIGNED_HEADERS
60
+
.iter()
61
+
.copied()
62
+
.map(HeaderName::new_from_ascii_str)
63
+
.collect();
64
+
let config = LettreDkimConfig::new(
65
+
selector.into_inner(),
66
+
domain.into_inner(),
67
+
key,
68
+
headers,
69
+
canonicalization,
70
+
);
71
+
Ok(Self { config })
72
+
}
73
+
74
+
pub fn sign(&self, message: &mut Message) {
75
+
message.sign(&self.config);
76
+
}
77
+
}
78
+
79
+
impl std::fmt::Debug for DkimSigner {
80
+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
81
+
f.write_str("DkimSigner")
82
+
}
83
+
}
84
+
85
+
fn parse_key(input: &str) -> Result<DkimSigningKey, SendError> {
86
+
let trimmed = input.trim_start();
87
+
match trimmed {
88
+
s if s.starts_with("-----BEGIN RSA PRIVATE KEY-----") => {
89
+
DkimSigningKey::new(input, DkimSigningAlgorithm::Rsa)
90
+
.map_err(|e| SendError::DkimSign(format!("RSA PKCS#1 PEM rejected: {e}")))
91
+
}
92
+
s if s.starts_with("-----BEGIN PRIVATE KEY-----") => parse_pkcs8(input),
93
+
s if s.starts_with("-----BEGIN") => Err(SendError::DkimSign(
94
+
"unrecognized PEM type; expected an RSA or Ed25519 private key".to_string(),
95
+
)),
96
+
_ => DkimSigningKey::new(input.trim(), DkimSigningAlgorithm::Ed25519).map_err(|e| {
97
+
SendError::DkimSign(format!(
98
+
"expected base64-encoded 32-byte Ed25519 seed or a PEM-wrapped key: {e}"
99
+
))
100
+
}),
101
+
}
102
+
}
103
+
104
+
fn parse_pkcs8(pem: &str) -> Result<DkimSigningKey, SendError> {
105
+
let ed25519_err = match ed25519_dalek::SigningKey::from_pkcs8_pem(pem) {
106
+
Ok(key) => {
107
+
let seed = BASE64_STANDARD.encode(key.to_bytes());
108
+
return DkimSigningKey::new(&seed, DkimSigningAlgorithm::Ed25519)
109
+
.map_err(|e| SendError::DkimSign(format!("re-import Ed25519 seed: {e}")));
110
+
}
111
+
Err(e) => e,
112
+
};
113
+
114
+
let rsa_err = match rsa::RsaPrivateKey::from_pkcs8_pem(pem) {
115
+
Ok(key) => {
116
+
let pkcs1 = key
117
+
.to_pkcs1_pem(LineEnding::LF)
118
+
.map_err(|e| SendError::DkimSign(format!("re-encode RSA PKCS#8 as PKCS#1: {e}")))?;
119
+
return DkimSigningKey::new(pkcs1.as_str(), DkimSigningAlgorithm::Rsa)
120
+
.map_err(|e| SendError::DkimSign(format!("re-import RSA PKCS#1: {e}")));
121
+
}
122
+
Err(e) => e,
123
+
};
124
+
125
+
Err(SendError::DkimSign(format!(
126
+
"PKCS#8 PEM rejected by both parsers; ed25519: {ed25519_err}; rsa: {rsa_err}"
127
+
)))
128
+
}
129
+
130
+
#[cfg(test)]
131
+
mod tests {
132
+
use super::*;
133
+
use ed25519_dalek::pkcs8::EncodePrivateKey as _;
134
+
use lettre::message::Mailbox;
135
+
use lettre::message::header::ContentType;
136
+
use rsa::pkcs1::DecodeRsaPrivateKey as _;
137
+
138
+
const ED25519_RAW_SEED_B64: &str = "QkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkI=";
139
+
140
+
const RSA_PKCS1_PEM: &str = include_str!("test_fixtures/rsa2048-priv-pkcs1.pem");
141
+
142
+
fn ed25519_pkcs8_pem() -> String {
143
+
let key = ed25519_dalek::SigningKey::from_bytes(&[7u8; 32]);
144
+
key.to_pkcs8_pem(LineEnding::LF).unwrap().to_string()
145
+
}
146
+
147
+
fn rsa_pkcs8_pem() -> String {
148
+
let key = rsa::RsaPrivateKey::from_pkcs1_pem(RSA_PKCS1_PEM).unwrap();
149
+
key.to_pkcs8_pem(LineEnding::LF).unwrap().to_string()
150
+
}
151
+
152
+
fn signer(pem: &str) -> DkimSigner {
153
+
DkimSigner::from_pem(
154
+
DkimSelector::parse("default").unwrap(),
155
+
EmailDomain::parse("nel.pet").unwrap(),
156
+
pem,
157
+
)
158
+
.expect("key should load")
159
+
}
160
+
161
+
fn signed_headers(signer: &DkimSigner) -> String {
162
+
let from: Mailbox = "sender@nel.pet".parse().unwrap();
163
+
let to: Mailbox = "recipient@nel.pet".parse().unwrap();
164
+
let mut message = Message::builder()
165
+
.from(from)
166
+
.to(to)
167
+
.subject("Roundtrip")
168
+
.header(ContentType::TEXT_PLAIN)
169
+
.body("Body".to_string())
170
+
.unwrap();
171
+
signer.sign(&mut message);
172
+
String::from_utf8(message.formatted()).unwrap()
173
+
}
174
+
175
+
#[test]
176
+
fn rejects_garbage() {
177
+
assert!(matches!(
178
+
parse_key("not a key"),
179
+
Err(SendError::DkimSign(_))
180
+
));
181
+
}
182
+
183
+
#[test]
184
+
fn rejects_unknown_pem_type() {
185
+
let pem = "-----BEGIN OPENSSH PRIVATE KEY-----\nx\n-----END OPENSSH PRIVATE KEY-----\n";
186
+
match parse_key(pem) {
187
+
Err(SendError::DkimSign(msg)) => assert!(msg.contains("unrecognized"), "msg: {msg}"),
188
+
other => panic!("expected unrecognized PEM error, got {other:?}"),
189
+
}
190
+
}
191
+
192
+
#[test]
193
+
fn ed25519_raw_seed_signs() {
194
+
let raw = signed_headers(&signer(ED25519_RAW_SEED_B64));
195
+
assert_signed_with(&raw, "a=ed25519-sha256");
196
+
}
197
+
198
+
#[test]
199
+
fn ed25519_pkcs8_pem_signs() {
200
+
let raw = signed_headers(&signer(&ed25519_pkcs8_pem()));
201
+
assert_signed_with(&raw, "a=ed25519-sha256");
202
+
}
203
+
204
+
#[test]
205
+
fn rsa_pkcs1_pem_signs() {
206
+
let raw = signed_headers(&signer(RSA_PKCS1_PEM));
207
+
assert_signed_with(&raw, "a=rsa-sha256");
208
+
}
209
+
210
+
#[test]
211
+
fn rsa_pkcs8_pem_signs() {
212
+
let raw = signed_headers(&signer(&rsa_pkcs8_pem()));
213
+
assert_signed_with(&raw, "a=rsa-sha256");
214
+
}
215
+
216
+
fn assert_signed_with(raw: &str, algorithm: &str) {
217
+
assert!(
218
+
raw.contains("DKIM-Signature:"),
219
+
"no signature header: {raw}"
220
+
);
221
+
assert!(raw.contains(algorithm), "missing {algorithm}: {raw}");
222
+
assert!(
223
+
raw.contains("c=relaxed/relaxed"),
224
+
"expected relaxed/relaxed canonicalization: {raw}"
225
+
);
226
+
}
227
+
}
+27
crates/tranquil-comms/src/email/test_fixtures/rsa2048-priv-pkcs1.pem
+27
crates/tranquil-comms/src/email/test_fixtures/rsa2048-priv-pkcs1.pem
···
1
+
-----BEGIN RSA PRIVATE KEY-----
2
+
MIIEowIBAAKCAQEAtsQsUV8QpqrygsY+2+JCQ6Fw8/omM71IM2N/R8pPbzbgOl0p
3
+
78MZGsgPOQ2HSznjD0FPzsH8oO2B5Uftws04LHb2HJAYlz25+lN5cqfHAfa3fgmC
4
+
38FfwBkn7l582UtPWZ/wcBOnyCgb3yLcvJrXyrt8QxHJgvWO23ITrUVYszImbXQ6
5
+
7YGS0YhMrbixRzmo2tpm3JcIBtnHrEUMsT0NfFdfsZhTT8YbxBvA8FdODgEwx7u/
6
+
vf3J9qbi4+Kv8cvqyJuleIRSjVXPsIMnoejIn04APPKIjpMyQdnWlby7rNyQtE4+
7
+
CV+jcFjqJbE/Xilcvqxt6DirjFCvYeKYl1uHLwIDAQABAoIBAH7Mg2LA7bB0EWQh
8
+
XiL3SrnZG6BpAHAM9jaQ5RFNjua9z7suP5YUaSpnegg/FopeUuWWjmQHudl8bg5A
9
+
ZPgtoLdYoU8XubfUH19I4o1lUXBPVuaeeqn6Yw/HZCjAbSXkVdz8VbesK092ZD/e
10
+
0/4V/3irsn5lrMSq0L322yfvYKaRDFxKCF7UMnWrGcHZl6Msbv/OffLRk19uYB7t
11
+
4WGhK1zCfKIfgdLJnD0eoI6Q4wU6sJvvpyTe8NDDo8HpdAwNn3YSahSewKp9gHgg
12
+
VIQlTZUdsHxM+R+2RUwJZYj9WSTbq+s1nKICUmjQBPnWbrPW963BE5utQPFt3mOe
13
+
EWRzdsECgYEA3MBhJC1Okq+u5yrFE8plufdwNvm9fg5uYUYafvdlQiXsFTx+XDGm
14
+
FXpuWhP/bheOh1jByzPZ1rvjF57xiZjkIuzcvtePTs/b5fT82K7CydDchkc8qb0W
15
+
2dI40h+13e++sUPKYdC9aqjZHzOgl3kOlkDbyRCF3F8mNDujE49rLWcCgYEA0/MU
16
+
dX5A6VSDb5K+JCNq8vDaBKNGU8GAr2fpYAhtk/3mXLI+/Z0JN0di9ZgeNhhJr2jN
17
+
11OU/2pOButpsgnkIo2y36cOQPf5dQpSgXZke3iNDld3osuLIuPNJn/3C087AtOq
18
+
+w4YxZClZLAxiLCqX8SBVrB2IiFCQ70SJ++n8vkCgYEAzmi3rBsNEA1jblVIh1PF
19
+
wJhD/bOQ4nBd92iUV8m9jZdl4wl4YX4u/IBI9MMkIG24YIe2VOl7s9Rk5+4/jNg/
20
+
4QQ2998Y6aljxOZJEdZ+3jQELy4m49OhrTRq2ta5t/Z3CMsJTmLe6f9NXWZpr5iK
21
+
8iVdHOjtMXxqfYaR2jVNEtsCgYAl9uWUQiAoa037v0I1wO5YQ9IZgJGJUSDWynsg
22
+
C4JtPs5zji4ASY+sCipsqWnH8MPKGrC8QClxMr51ONe+30yw78a5jvfbpU9Wqpmq
23
+
vOU0xJwnlH1GeMUcY8eMfOFocjG0yOtYeubvBIDLr0/AFzz9WHp+Z69RX7m53nUR
24
+
GDlyKQKBgDGZVAbUBiB8rerqNbONBAxfipoa4IJ+ntBrFT2DtoIZNbSzaoK+nVbH
25
+
kbWMJycaV5PVOh1lfAiZeWCxQz5RcZh/RS8USnxyMG1j4dP/wLcbdasI8uRaSC6Y
26
+
hFHL5HjhLrIo0HRWySS2b2ztBI2FP1M+MaaGFPHDzm2OyZg85yr3
27
+
-----END RSA PRIVATE KEY-----
+164
crates/tranquil-comms/src/email/transport.rs
+164
crates/tranquil-comms/src/email/transport.rs
···
1
+
use std::sync::Arc;
2
+
use std::time::Duration;
3
+
4
+
use futures::StreamExt;
5
+
use hickory_resolver::TokioAsyncResolver;
6
+
use lettre::transport::smtp::AsyncSmtpTransport;
7
+
use lettre::transport::smtp::Error as SmtpError;
8
+
use lettre::transport::smtp::client::{Tls, TlsParameters};
9
+
use lettre::transport::smtp::extension::ClientId;
10
+
use lettre::{AsyncTransport, Message, Tokio1Executor};
11
+
use tokio::sync::Semaphore;
12
+
13
+
use super::message::recipient_domain;
14
+
use super::mx;
15
+
use super::types::{HeloName, MxRecord};
16
+
use crate::sender::SendError;
17
+
18
+
pub enum SendMode {
19
+
Smarthost {
20
+
transport: Box<AsyncSmtpTransport<Tokio1Executor>>,
21
+
total_timeout: Duration,
22
+
},
23
+
DirectMx {
24
+
resolver: Arc<TokioAsyncResolver>,
25
+
helo: HeloName,
26
+
command_timeout: Duration,
27
+
total_timeout: Duration,
28
+
require_tls: bool,
29
+
inflight: Arc<Semaphore>,
30
+
},
31
+
}
32
+
33
+
impl std::fmt::Debug for SendMode {
34
+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
35
+
match self {
36
+
Self::Smarthost { total_timeout, .. } => {
37
+
write!(f, "SendMode::Smarthost(total_timeout={total_timeout:?})")
38
+
}
39
+
Self::DirectMx {
40
+
helo, require_tls, ..
41
+
} => write!(
42
+
f,
43
+
"SendMode::DirectMx({}, require_tls={require_tls})",
44
+
helo.as_str()
45
+
),
46
+
}
47
+
}
48
+
}
49
+
50
+
pub async fn dispatch(mode: &SendMode, message: Message) -> Result<(), SendError> {
51
+
match mode {
52
+
SendMode::Smarthost {
53
+
transport,
54
+
total_timeout,
55
+
} => with_total_timeout(*total_timeout, run_send(transport, message)).await,
56
+
SendMode::DirectMx {
57
+
resolver,
58
+
helo,
59
+
command_timeout,
60
+
total_timeout,
61
+
require_tls,
62
+
inflight,
63
+
} => {
64
+
with_total_timeout(*total_timeout, async {
65
+
let _permit =
66
+
inflight.clone().acquire_owned().await.map_err(|_| {
67
+
SendError::SmtpTransient("send semaphore closed".to_string())
68
+
})?;
69
+
send_direct(
70
+
resolver.as_ref(),
71
+
helo,
72
+
*command_timeout,
73
+
*require_tls,
74
+
message,
75
+
)
76
+
.await
77
+
})
78
+
.await
79
+
}
80
+
}
81
+
}
82
+
83
+
async fn with_total_timeout<F: std::future::Future<Output = Result<(), SendError>>>(
84
+
total: Duration,
85
+
fut: F,
86
+
) -> Result<(), SendError> {
87
+
tokio::time::timeout(total, fut)
88
+
.await
89
+
.unwrap_or(Err(SendError::Timeout))
90
+
}
91
+
92
+
async fn run_send(
93
+
transport: &AsyncSmtpTransport<Tokio1Executor>,
94
+
message: Message,
95
+
) -> Result<(), SendError> {
96
+
transport
97
+
.send(message)
98
+
.await
99
+
.map(|_| ())
100
+
.map_err(classify_smtp_error)
101
+
}
102
+
103
+
async fn send_direct(
104
+
resolver: &TokioAsyncResolver,
105
+
helo: &HeloName,
106
+
command_timeout: Duration,
107
+
require_tls: bool,
108
+
message: Message,
109
+
) -> Result<(), SendError> {
110
+
let domain = recipient_domain(&message)?;
111
+
let mxs = mx::resolve(resolver, &domain).await?;
112
+
let outcome = futures::stream::iter(mxs)
113
+
.fold(None::<Result<(), SendError>>, |acc, mx_record| {
114
+
let message = message.clone();
115
+
async move {
116
+
match &acc {
117
+
Some(Ok(())) | Some(Err(SendError::SmtpPermanent(_))) => acc,
118
+
_ => Some(
119
+
attempt_one_host(mx_record, helo, command_timeout, require_tls, message)
120
+
.await,
121
+
),
122
+
}
123
+
}
124
+
})
125
+
.await;
126
+
outcome.unwrap_or_else(|| {
127
+
Err(SendError::SmtpTransient(format!(
128
+
"no MX records returned for {}",
129
+
domain.as_str()
130
+
)))
131
+
})
132
+
}
133
+
134
+
async fn attempt_one_host(
135
+
mx_record: MxRecord,
136
+
helo: &HeloName,
137
+
command_timeout: Duration,
138
+
require_tls: bool,
139
+
message: Message,
140
+
) -> Result<(), SendError> {
141
+
let host = mx_record.host.as_str().to_string();
142
+
let tls_params = TlsParameters::new(host.clone())
143
+
.map_err(|e| SendError::SmtpTransient(format!("TLS params for {host}: {e}")))?;
144
+
let tls = match require_tls {
145
+
true => Tls::Required(tls_params),
146
+
false => Tls::Opportunistic(tls_params),
147
+
};
148
+
let transport: AsyncSmtpTransport<Tokio1Executor> =
149
+
AsyncSmtpTransport::<Tokio1Executor>::builder_dangerous(&host)
150
+
.port(25)
151
+
.tls(tls)
152
+
.hello_name(ClientId::Domain(helo.as_str().to_string()))
153
+
.timeout(Some(command_timeout))
154
+
.build();
155
+
run_send(&transport, message).await
156
+
}
157
+
158
+
fn classify_smtp_error(e: SmtpError) -> SendError {
159
+
match () {
160
+
_ if e.is_permanent() => SendError::SmtpPermanent(e.to_string()),
161
+
_ if e.is_timeout() => SendError::Timeout,
162
+
_ => SendError::SmtpTransient(e.to_string()),
163
+
}
164
+
}
History
4 rounds
0 comments
oyster.cafe
submitted
#3
1 commit
expand
collapse
feat(tranquil-comms): smtp and dkim signing
Lewis: May this revision serve well! <lu5a@proton.me>
merge conflicts detected
expand
collapse
expand
collapse
- .config/nextest.toml:68
- Cargo.lock:9
- Cargo.toml:26
- crates/tranquil-comms/Cargo.toml:10
- crates/tranquil-config/src/lib.rs:5
- example.toml:373
expand 0 comments
oyster.cafe
submitted
#2
1 commit
expand
collapse
feat(tranquil-comms): smtp and dkim signing
Lewis: May this revision serve well! <lu5a@proton.me>
expand 0 comments
oyster.cafe
submitted
#1
1 commit
expand
collapse
feat(tranquil-comms): smtp and dkim signing
Lewis: May this revision serve well! <lu5a@proton.me>
expand 0 comments
oyster.cafe
submitted
#0
1 commit
expand
collapse
feat(tranquil-comms): smtp and dkim signing
Lewis: May this revision serve well! <lu5a@proton.me>