Files
lettre/src/message/dkim.rs
2025-02-22 07:44:56 +00:00

616 lines
22 KiB
Rust

use std::{
borrow::Cow,
error::Error as StdError,
fmt::{self, Display},
time::SystemTime,
};
use ed25519_dalek::Signer;
use rsa::{pkcs1::DecodeRsaPrivateKey, pkcs1v15::Pkcs1v15Sign, RsaPrivateKey};
use sha2::{Digest, Sha256};
use crate::message::{
header::{HeaderName, HeaderValue},
Headers, Message,
};
/// Describe Dkim Canonicalization to apply to either body or headers
#[derive(Copy, Clone, Debug)]
pub enum DkimCanonicalizationType {
Simple,
Relaxed,
}
impl Display for DkimCanonicalizationType {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
DkimCanonicalizationType::Simple => "simple",
DkimCanonicalizationType::Relaxed => "relaxed",
})
}
}
/// Describe Canonicalization to be applied before signing
#[derive(Copy, Clone, Debug)]
pub struct DkimCanonicalization {
pub header: DkimCanonicalizationType,
pub body: DkimCanonicalizationType,
}
impl Default for DkimCanonicalization {
fn default() -> Self {
DkimCanonicalization {
header: DkimCanonicalizationType::Simple,
body: DkimCanonicalizationType::Relaxed,
}
}
}
/// Format canonicalization to be shown in Dkim header
impl Display for DkimCanonicalization {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}/{}", self.header, self.body)
}
}
/// Describe the algorithm used for signing the message
#[derive(Copy, Clone, Debug)]
pub enum DkimSigningAlgorithm {
Rsa,
Ed25519,
}
impl Display for DkimSigningAlgorithm {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
DkimSigningAlgorithm::Rsa => "rsa",
DkimSigningAlgorithm::Ed25519 => "ed25519",
})
}
}
/// Describe [`DkimSigningKey`] key error
#[derive(Debug)]
pub struct DkimSigningKeyError(InnerDkimSigningKeyError);
#[derive(Debug)]
enum InnerDkimSigningKeyError {
Base64(base64::DecodeError),
Rsa(rsa::pkcs1::Error),
Ed25519(ed25519_dalek::ed25519::Error),
}
impl Display for DkimSigningKeyError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match &self.0 {
InnerDkimSigningKeyError::Base64(_err) => "base64 decode error",
InnerDkimSigningKeyError::Rsa(_err) => "rsa decode error",
InnerDkimSigningKeyError::Ed25519(_err) => "ed25519 decode error",
})
}
}
impl StdError for DkimSigningKeyError {
fn source(&self) -> Option<&(dyn StdError + 'static)> {
Some(match &self.0 {
InnerDkimSigningKeyError::Base64(err) => err,
InnerDkimSigningKeyError::Rsa(err) => err,
InnerDkimSigningKeyError::Ed25519(err) => err,
})
}
}
/// Describe a signing key to be carried by [`DkimConfig`] struct
#[derive(Debug)]
pub struct DkimSigningKey(InnerDkimSigningKey);
#[derive(Debug)]
enum InnerDkimSigningKey {
Rsa(RsaPrivateKey),
Ed25519(ed25519_dalek::SigningKey),
}
impl DkimSigningKey {
pub fn new(
private_key: &str,
algorithm: DkimSigningAlgorithm,
) -> Result<DkimSigningKey, DkimSigningKeyError> {
Ok(Self(match algorithm {
DkimSigningAlgorithm::Rsa => InnerDkimSigningKey::Rsa(
RsaPrivateKey::from_pkcs1_pem(private_key)
.map_err(|err| DkimSigningKeyError(InnerDkimSigningKeyError::Rsa(err)))?,
),
DkimSigningAlgorithm::Ed25519 => {
InnerDkimSigningKey::Ed25519(ed25519_dalek::SigningKey::from_bytes(
&crate::base64::decode(private_key)
.map_err(|err| DkimSigningKeyError(InnerDkimSigningKeyError::Base64(err)))?
.try_into()
.map_err(|_| {
DkimSigningKeyError(InnerDkimSigningKeyError::Ed25519(
ed25519_dalek::ed25519::Error::new(),
))
})?,
))
}
}))
}
fn get_signing_algorithm(&self) -> DkimSigningAlgorithm {
match self.0 {
InnerDkimSigningKey::Rsa(_) => DkimSigningAlgorithm::Rsa,
InnerDkimSigningKey::Ed25519(_) => DkimSigningAlgorithm::Ed25519,
}
}
}
/// A struct to describe Dkim configuration applied when signing a message
#[derive(Debug)]
pub struct DkimConfig {
/// The name of the key published in DNS
selector: String,
/// The domain for which we sign the message
domain: String,
/// The private key in PKCS1 string format
private_key: DkimSigningKey,
/// A list of header names to be included in the signature. Signing of more than one
/// header with the same name is not supported
headers: Vec<HeaderName>,
/// The signing algorithm to be used when signing
canonicalization: DkimCanonicalization,
}
impl DkimConfig {
/// Create a default signature configuration with a set of headers and "simple/relaxed"
/// canonicalization
pub fn default_config(
selector: String,
domain: String,
private_key: DkimSigningKey,
) -> DkimConfig {
DkimConfig {
selector,
domain,
private_key,
headers: vec![
HeaderName::new_from_ascii_str("From"),
HeaderName::new_from_ascii_str("Subject"),
HeaderName::new_from_ascii_str("To"),
HeaderName::new_from_ascii_str("Date"),
],
canonicalization: DkimCanonicalization {
header: DkimCanonicalizationType::Simple,
body: DkimCanonicalizationType::Relaxed,
},
}
}
/// Create a [`DkimConfig`]
pub fn new(
selector: String,
domain: String,
private_key: DkimSigningKey,
headers: Vec<HeaderName>,
canonicalization: DkimCanonicalization,
) -> DkimConfig {
DkimConfig {
selector,
domain,
private_key,
headers,
canonicalization,
}
}
}
/// Create a Headers struct with a Dkim-Signature Header created from given parameters
fn dkim_header_format(
config: &DkimConfig,
timestamp: u64,
headers_list: &str,
body_hash: &str,
signature: &str,
) -> Headers {
let mut headers = Headers::new();
let header_name =
dkim_canonicalize_header_tag("DKIM-Signature", config.canonicalization.header);
let header_name = HeaderName::new_from_ascii(header_name.into()).unwrap();
headers.insert_raw(HeaderValue::new(header_name, format!("v=1; a={signing_algorithm}-sha256; d={domain}; s={selector}; c={canon}; q=dns/txt; t={timestamp}; h={headers_list}; bh={body_hash}; b={signature}",domain=config.domain, selector=config.selector,canon=config.canonicalization,timestamp=timestamp,headers_list=headers_list,body_hash=body_hash,signature=signature,signing_algorithm=config.private_key.get_signing_algorithm())));
headers
}
/// Canonicalize the body of an email
fn dkim_canonicalize_body(
mut body: &[u8],
canonicalization: DkimCanonicalizationType,
) -> Cow<'_, [u8]> {
match canonicalization {
DkimCanonicalizationType::Simple => {
// Remove empty lines at end
while body.ends_with(b"\r\n\r\n") {
body = &body[..body.len() - 2];
}
Cow::Borrowed(body)
}
DkimCanonicalizationType::Relaxed => {
let mut out = Vec::with_capacity(body.len());
loop {
match body {
[b' ' | b'\t', b'\r', b'\n', ..] => {}
[b' ' | b'\t', b' ' | b'\t', ..] => {}
[b' ' | b'\t', ..] => out.push(b' '),
[c, ..] => out.push(*c),
[] => break,
}
body = &body[1..];
}
// Remove empty lines at end
while out.ends_with(b"\r\n\r\n") {
out.truncate(out.len() - 2);
}
Cow::Owned(out)
}
}
}
fn dkim_canonicalize_headers_relaxed(headers: &str) -> String {
let mut r = String::with_capacity(headers.len());
fn skip_whitespace(h: &str) -> &str {
match h.as_bytes().first() {
Some(b' ' | b'\t') => skip_whitespace(&h[1..]),
_ => h,
}
}
fn name(h: &str, out: &mut String) {
if let Some(name_end) = h.bytes().position(|c| c == b':') {
let (name, rest) = h.split_at(name_end + 1);
*out += name;
// Space after header colon is stripped.
value(skip_whitespace(rest), out);
} else {
// This should never happen.
*out += h;
}
}
fn value(h: &str, out: &mut String) {
match h.as_bytes() {
// Continuation lines.
[b'\r', b'\n', b' ' | b'\t', ..] => {
out.push(' ');
value(skip_whitespace(&h[2..]), out);
}
// End of header.
[b'\r', b'\n', ..] => {
*out += "\r\n";
name(&h[2..], out);
}
// Sequential whitespace.
[b' ' | b'\t', b' ' | b'\t' | b'\r', ..] => value(&h[1..], out),
// All whitespace becomes spaces.
[b'\t', ..] => {
out.push(' ');
value(&h[1..], out);
}
[_, ..] => {
let mut chars = h.chars();
out.push(chars.next().unwrap());
value(chars.as_str(), out);
}
[] => {}
}
}
name(headers, &mut r);
r
}
/// Canonicalize header tag
fn dkim_canonicalize_header_tag(
name: &str,
canonicalization: DkimCanonicalizationType,
) -> Cow<'_, str> {
match canonicalization {
DkimCanonicalizationType::Simple => Cow::Borrowed(name),
DkimCanonicalizationType::Relaxed => Cow::Owned(name.to_lowercase()),
}
}
/// Canonicalize signed headers passed as `headers_list` among `mail_headers` using canonicalization
fn dkim_canonicalize_headers<'a>(
headers_list: impl IntoIterator<Item = &'a str>,
mail_headers: &Headers,
canonicalization: DkimCanonicalizationType,
) -> String {
let mut covered_headers = Headers::new();
for name in headers_list {
if let Some(h) = mail_headers.find_header(name) {
let name = dkim_canonicalize_header_tag(name, canonicalization);
covered_headers.insert_raw(HeaderValue::dangerous_new_pre_encoded(
HeaderName::new_from_ascii(name.into()).unwrap(),
h.get_raw().into(),
h.get_encoded().into(),
));
}
}
let serialized = covered_headers.to_string();
match canonicalization {
DkimCanonicalizationType::Simple => serialized,
DkimCanonicalizationType::Relaxed => dkim_canonicalize_headers_relaxed(&serialized),
}
}
/// Sign with Dkim a message by adding Dkim-Signature header created with configuration expressed by
/// `dkim_config`
pub fn dkim_sign(message: &mut Message, dkim_config: &DkimConfig) {
dkim_sign_fixed_time(message, dkim_config, crate::time::now());
}
fn dkim_sign_fixed_time(message: &mut Message, dkim_config: &DkimConfig, timestamp: SystemTime) {
let timestamp = timestamp
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap()
.as_secs();
let headers = message.headers();
let body_hash = Sha256::digest(dkim_canonicalize_body(
&message.body_raw(),
dkim_config.canonicalization.body,
));
let bh = crate::base64::encode(body_hash);
let mut signed_headers_list =
dkim_config
.headers
.iter()
.fold(String::new(), |mut list, header| {
if !list.is_empty() {
list.push(':');
}
list.push_str(header);
list
});
if let DkimCanonicalizationType::Relaxed = dkim_config.canonicalization.header {
signed_headers_list.make_ascii_lowercase();
}
let dkim_header = dkim_header_format(dkim_config, timestamp, &signed_headers_list, &bh, "");
let signed_headers = dkim_canonicalize_headers(
dkim_config.headers.iter().map(AsRef::as_ref),
headers,
dkim_config.canonicalization.header,
);
let canonicalized_dkim_header = dkim_canonicalize_headers(
["DKIM-Signature"],
&dkim_header,
dkim_config.canonicalization.header,
);
let mut hashed_headers = Sha256::new();
hashed_headers.update(signed_headers.as_bytes());
hashed_headers.update(canonicalized_dkim_header.trim_end().as_bytes());
let hashed_headers = hashed_headers.finalize();
let signature = match &dkim_config.private_key.0 {
InnerDkimSigningKey::Rsa(private_key) => crate::base64::encode(
private_key
.sign(Pkcs1v15Sign::new::<Sha256>(), &hashed_headers)
.unwrap(),
),
InnerDkimSigningKey::Ed25519(private_key) => {
crate::base64::encode(private_key.sign(&hashed_headers).to_bytes())
}
};
let dkim_header = dkim_header_format(
dkim_config,
timestamp,
&signed_headers_list,
&bh,
&signature,
);
message.headers.insert_raw(HeaderValue::new(
HeaderName::new_from_ascii_str("DKIM-Signature"),
dkim_header.get_raw("DKIM-Signature").unwrap().to_owned(),
));
}
#[cfg(test)]
mod test {
use pretty_assertions::assert_eq;
use super::{
super::{
header::{HeaderName, HeaderValue},
Header, Message,
},
dkim_canonicalize_body, dkim_canonicalize_headers, dkim_sign_fixed_time,
DkimCanonicalization, DkimCanonicalizationType, DkimConfig, DkimSigningAlgorithm,
DkimSigningKey,
};
use crate::StdError;
const KEY_RSA: &str = "-----BEGIN RSA PRIVATE KEY-----
MIIEowIBAAKCAQEAwOsW7UFcWn1ch3UM8Mll5qZH5hVHKJQ8Z0tUlebUECq0vjw6
VcsIucZ/B70VpCN63whyi7oApdCIS1o0zad7f0UaW/BfxXADqdcFL36uMaG0RHer
uSASjQGnsl9Kozt/dXiDZX5ngjr/arLJhNZSNR4/9VSwqbE2OPXaSaQ9BsqneD0P
8dCVSfkkDZCcfC2864z7hvC01lFzWQKF36ZAoGBERHScHtFMAzUOgGuqqPiP5khw
DQB3Ffccf+BsWLU2OOteshUwTGjpoangbPCYj6kckwNm440lQwuqTinpC92yyIE5
Ol8psNMW49DLowAeZb6JrjLhD+wY9bghTaOkcwIDAQABAoIBAHTZ8LkkrdvhsvoZ
XA088AwVC9fBa6iYoT2v0zw45JomQ/Q2Zt8wa8ibAradQU56byJI65jWwS2ucd+y
c+ldWOBt6tllb50XjCCDrRBnmvtVBuux0MIBOztNlVXlgj/8+ecdZ/lB51Bqi+sF
ACsF5iVmfTcMZTVjsYQu5llUseI6Lwgqpx6ktaXD2PVsVo9Gf01ssZ4GCy69wB/3
20CsOz4LEpSYkq1oE98lMMGCfD7py3L9kWHYNNisam78GM+1ynRxRGwEDUbz6pxs
fGPIAwHLaZsOmibPkBB0PJTW742w86qQ8KAqC6ZbRYOF19rSMj3oTfRnPMHn9Uu5
N8eQcoECgYEA97SMUrz2hqII5i8igKylO9kV8pjcIWKI0rdt8MKj4FXTNYjjO9I+
41ONOjhUOpFci/G3YRKi8UiwbKxIRTvIxNMh2xj6Ws3iO9gQHK1j8xTWxJdjEBEz
EuZI59Mi5H7fxSL1W+n8nS8JVsaH93rvQErngqTUAsihAzjxHWdFwm0CgYEAx2Dh
claESJP2cOKgYp+SUNwc26qMaqnl1f37Yn+AflrQOfgQqJe5TRbicEC+nFlm6XUt
3st1Nj29H0uOMmMZDmDCO+cOs5Qv5A9pG6jSC6wM+2KNHQDtrxlakBFygePEPVVy
GXaY9DRa9Q4/4ataxDR2/VvIAWfEEtMTJIBDtl8CgYAIXEuwLziS6r0qJ8UeWrVp
A7a97XLgnZbIpfBMBAXL+JmcYPZqenos6hEGOgh9wZJCFvJ9kEd3pWBvCpGV5KKu
IgIuhvVMQ06zfmNs1F1fQwDMud9aF3qF1Mf5KyMuWynqWXe2lns0QvYpu6GzNK8G
mICf5DhTr7nfhfh9aZLtMQKBgCxKsmqzG5n//MxhHB4sstVxwJtwDNeZPKzISnM8
PfBT/lQSbqj1Y73japRjXbTgC4Ore3A2JKjTGFN+dm1tJGDUT/H8x4BPWEBCyCfT
3i2noA6sewrJbQPsDvlYVubSEYNKmxlbBmmhw98StlBMv9I8kX6BSDI/uggwid0e
/WvjAoGBAKpZ0UOKQyrl9reBiUfrpRCvIMakBMd79kNiH+5y0Soq/wCAnAuABayj
XEIBhFv+HxeLEnT7YV+Zzqp5L9kKw/EU4ik3JX/XsEihdSxEuGX00ZYOw05FEfpW
cJ5Ku0OTwRtSMaseRPX+T4EfG1Caa/eunPPN4rh+CSup2BVVarOT
-----END RSA PRIVATE KEY-----";
#[derive(Clone)]
struct TestHeader(String);
impl Header for TestHeader {
fn name() -> HeaderName {
HeaderName::new_from_ascii_str("Test")
}
fn parse(s: &str) -> Result<Self, Box<dyn StdError + Send + Sync>> {
Ok(Self(s.into()))
}
fn display(&self) -> HeaderValue {
HeaderValue::new(Self::name(), self.0.clone())
}
}
fn test_message() -> Message {
Message::builder()
.from("Test O'Leary <test+ezrz@example.net>".parse().unwrap())
.to("Test2 <test2@example.org>".parse().unwrap())
.date(std::time::UNIX_EPOCH)
.header(TestHeader("test test very very long with spaces and extra spaces \twill be folded to several lines ".to_owned()))
.subject("Test with utf-8 ë")
.body("test\r\n\r\ntest \ttest\r\n\r\n\r\n".to_owned()).unwrap()
}
#[test]
fn test_headers_simple_canonicalize() {
let message = test_message();
dbg!(message.headers.to_string());
assert_eq!(dkim_canonicalize_headers(["From", "Test"], &message.headers, DkimCanonicalizationType::Simple), "From: =?utf-8?b?VGVzdCBPJ0xlYXJ5?= <test+ezrz@example.net>\r\nTest: test test very very long with spaces and extra spaces \twill be\r\n folded to several lines \r\n");
}
#[test]
fn test_headers_relaxed_canonicalize() {
let message = test_message();
dbg!(message.headers.to_string());
assert_eq!(dkim_canonicalize_headers(["From", "Test"], &message.headers, DkimCanonicalizationType::Relaxed),"from:=?utf-8?b?VGVzdCBPJ0xlYXJ5?= <test+ezrz@example.net>\r\ntest:test test very very long with spaces and extra spaces will be folded to several lines\r\n");
}
#[test]
fn test_body_simple_canonicalize() {
let body = b" C \r\nD \t E\r\n\r\n\r\n";
assert_eq!(
dkim_canonicalize_body(body, DkimCanonicalizationType::Simple).into_owned(),
b" C \r\nD \t E\r\n"
);
}
#[test]
fn test_body_relaxed_canonicalize() {
let body = b" C \r\nD \t E\r\n\tF\r\n\t\r\n\r\n\r\n";
assert_eq!(
dkim_canonicalize_body(body, DkimCanonicalizationType::Relaxed).into_owned(),
b" C\r\nD E\r\n F\r\n"
);
}
#[test]
fn test_signature_rsa_simple() {
let mut message = test_message();
let signing_key = DkimSigningKey::new(KEY_RSA, DkimSigningAlgorithm::Rsa).unwrap();
dkim_sign_fixed_time(
&mut message,
&DkimConfig::new(
"dkimtest".to_owned(),
"example.org".to_owned(),
signing_key,
vec![
HeaderName::new_from_ascii_str("Date"),
HeaderName::new_from_ascii_str("From"),
HeaderName::new_from_ascii_str("Subject"),
HeaderName::new_from_ascii_str("To"),
],
DkimCanonicalization {
header: DkimCanonicalizationType::Simple,
body: DkimCanonicalizationType::Simple,
},
),
std::time::UNIX_EPOCH,
);
let signed = message.formatted();
let signed = std::str::from_utf8(&signed).unwrap();
assert_eq!(
signed,
std::concat!(
"From: =?utf-8?b?VGVzdCBPJ0xlYXJ5?= <test+ezrz@example.net>\r\n",
"To: Test2 <test2@example.org>\r\n",
"Date: Thu, 01 Jan 1970 00:00:00 +0000\r\n",
"Test: test test very very long with spaces and extra spaces \twill be\r\n",
" folded to several lines \r\n",
"Subject: Test with utf-8 =?utf-8?b?w6s=?=\r\n",
"Content-Transfer-Encoding: 7bit\r\n",
"DKIM-Signature: v=1; a=rsa-sha256; d=example.org; s=dkimtest;\r\n",
" c=simple/simple; q=dns/txt; t=0; h=Date:From:Subject:To;\r\n",
" bh=f3Zksdcjqa/xRBwdyFzIXWCcgP7XTgxjCgYsXOMKQl4=;\r\n",
" b=NhoIMMAALoSgu5lKAR0+MUQunOWnU7wpF9ORUFtpxq9sGZDo9AX43AMhFemyM5W204jpFwMU6pm7AMR1nOYBdSYye4yUALtvT2nqbJBwSh7JeYu+z22t1RFKp7qQR1il8aSrkbZuNMFHYuSEwW76QtKwcNqP4bQOzS9CzgQp0ABu8qwYPBr/EypykPTfqjtyN+ywrfdqjjGOzTpRGolH0hc3CrAETNjjHbNBgKgucXmXTN7hMRdzqWjeFPxizXwouwNAavFClPG0l33gXVArFWn+CkgA84G/s4zuJiF7QPZR87Pu4pw/vIlSXxH4a42W3tT19v9iBTH7X7ldYegtmQ==\r\n",
"\r\n",
"test\r\n",
"\r\n",
"test \ttest\r\n",
"\r\n",
"\r\n",
)
);
}
#[test]
fn test_signature_rsa_relaxed() {
let mut message = test_message();
let signing_key = DkimSigningKey::new(KEY_RSA, DkimSigningAlgorithm::Rsa).unwrap();
dkim_sign_fixed_time(
&mut message,
&DkimConfig::new(
"dkimtest".to_owned(),
"example.org".to_owned(),
signing_key,
vec![
HeaderName::new_from_ascii_str("Date"),
HeaderName::new_from_ascii_str("From"),
HeaderName::new_from_ascii_str("Subject"),
HeaderName::new_from_ascii_str("To"),
],
DkimCanonicalization {
header: DkimCanonicalizationType::Relaxed,
body: DkimCanonicalizationType::Relaxed,
},
),
std::time::UNIX_EPOCH,
);
let signed = message.formatted();
let signed = std::str::from_utf8(&signed).unwrap();
println!("{signed}");
assert_eq!(
signed,
std::concat!(
"From: =?utf-8?b?VGVzdCBPJ0xlYXJ5?= <test+ezrz@example.net>\r\n",
"To: Test2 <test2@example.org>\r\n",
"Date: Thu, 01 Jan 1970 00:00:00 +0000\r\n",
"Test: test test very very long with spaces and extra spaces \twill be\r\n",
" folded to several lines \r\n","Subject: Test with utf-8 =?utf-8?b?w6s=?=\r\n",
"Content-Transfer-Encoding: 7bit\r\n",
"DKIM-Signature: v=1; a=rsa-sha256; d=example.org; s=dkimtest;\r\n",
" c=relaxed/relaxed; q=dns/txt; t=0; h=date:from:subject:to;\r\n",
" bh=qN8je6qJgWFGSnN2MycC/XKPbN6BOrMJyAX2h4m19Ss=;\r\n",
" b=YaVfmH8dbGEywoLJ4uhbvYqDyQG1UGKFH3PE7zXGgk+YFxUgkwWjoA3aQupDNQtfTjfUsNe0dnrjyZP+ylnESpZBpbCIf5/n3FEh6j3RQthqNbQblcfH/U8mazTuRbVjYBbTZQDaQCMPTz+8D+ZQfXo2oq6dGzTuGvmuYft0CVsq/BIp/EkhZHqiphDeVJSHD4iKW8+L2XwEWThoY92xOYc1G0TtBwz2UJgtiHX2YulH/kRBHeK3dKn9RTNVL3VZ+9ZrnFwIhET9TPGtU2I+q0EMSWF9H9bTrASMgW/U+E0VM2btqJlrTU6rQ7wlQeHdwecLnzXcyhCUInF1+veMNw==\r\n",
"\r\n",
"test\r\n",
"\r\n",
"test \ttest\r\n",
"\r\n",
"\r\n",
)
);
}
}