From 5ce941b38c22debb32b3b81ade21eb61bb1a4d96 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Chris=20W=C3=B6gi?= <789@c-w.li>
Date: Sat, 25 Sep 2021 20:28:16 +0200
Subject: [PATCH] Add DKIM Ed25519 capability (#276)
---
.github/workflows/ci.yml | 4 +-
README.markdown | 19 +-
src/mimemail.erl | 163 ++++++++++++++++--
.../dkim-ed25519-encrypted-private.pem | 5 +
.../dkim-ed25519-encrypted-public.pem | 3 +
test/fixtures/dkim-ed25519-private.pem | 3 +
test/fixtures/dkim-ed25519-public.pem | 3 +
7 files changed, 184 insertions(+), 16 deletions(-)
create mode 100644 test/fixtures/dkim-ed25519-encrypted-private.pem
create mode 100644 test/fixtures/dkim-ed25519-encrypted-public.pem
create mode 100644 test/fixtures/dkim-ed25519-private.pem
create mode 100644 test/fixtures/dkim-ed25519-public.pem
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 4083866..34fd757 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -15,11 +15,11 @@ jobs:
strategy:
matrix:
- otp_vsn: [21.3, 22.3, 23.3, 24.0]
+ otp_vsn: [21.3, 22.3, 23.3, 24.1]
os: [ubuntu-latest]
profile: [test, ranch18, ranch20]
exclude:
- - otp_vsn: 24.0 # ranch 1.7 does not work on OTP24+
+ - otp_vsn: 24.1 # ranch 1.7 does not work on OTP24+
profile: test
container:
diff --git a/README.markdown b/README.markdown
index 847bca1..3423689 100644
--- a/README.markdown
+++ b/README.markdown
@@ -90,13 +90,18 @@ The `send` method variants `send/2, send/3, send_blocking/2` take an `Options` a
### DKIM signing of outgoing emails
-You may wish to configure DKIM signing [RFC6376](http://tools.ietf.org/html/rfc6376) of outgoing emails
-for better security. To do that you need public and private RSA keys, which can be generated by
+You may wish to configure DKIM signing [RFC6376](https://datatracker.ietf.org/doc/html/rfc5672) or [RFC8463](https://datatracker.ietf.org/doc/html/rfc8463) (Ed25519) of outgoing emails
+for better security. To do that you need public and private keys, which can be generated by
following commands:
```bash
+# RSA
openssl genrsa -out private-key.pem 1024
openssl rsa -in private-key.pem -out public-key.pem -pubout
+
+# Ed25519 - Erlang/OTP 24.1+ only!
+openssl genpkey -algorithm ed25519 -out private-key.pem
+openssl pkey -in private-key.pem -out public-key.pem -pubout
```
To send DKIM-signed email:
@@ -120,12 +125,20 @@ SignedMailBody = \
gen_smtp_client:send({"whatever@example.com", ["andrew@hijacked.us"], SignedMailBody}, []).
```
-Don't forget to put your public key to `foo.bar._domainkey.example.com` TXT DNS record as smth like
+For using Ed25519 you need to set the option `{a, 'ed25519-sha256'}`.
+
+Don't forget to put your public key to `foo.bar._domainkey.example.com` TXT DNS record as something like
+RSA:
```
v=DKIM1; g=*; k=rsa; p=MIGfMA0GCSqGSIb3DQEBA......
```
+Ed25519:
+```
+v=DKIM1; g=*; k=ed25519; p=MIGfMA0GCSqGSIb3DQEBA......
+```
+
See RFC6376 for more details.
## Server Example
diff --git a/src/mimemail.erl b/src/mimemail.erl
index 0b6a4de..d5cd51e 100644
--- a/src/mimemail.erl
+++ b/src/mimemail.erl
@@ -105,6 +105,7 @@
{t, now | calendar:datetime()} |
{x, calendar:datetime()} |
{c, {simple | relaxed, simple | relaxed}} |
+ {a, 'rsa-sha256' | 'ed25519-sha256'} |
{private_key, dkim_priv_key()}].
-type options() :: [{encoding, binary()} |
{decode_attachment, boolean()} |
@@ -1070,11 +1071,18 @@ encode_extra_utf_bytes(Bytes, [C|T], AccOut) when C >= 128 andalso C =< 191 ->
%% be located in "foo.bar._domainkey.example.com" (see RFC-6376 #3.6.2.1).
%% `t' - signature timestamp: 'now' or UTC {Date, Time}
%% `x' - signatue expiration time: UTC {Date, Time}
+%% `a` - signing algorithm (default: `rsa-sha256`):
%% `private_key' - private key, to sign emails. May be of 2 types: encrypted and
%% plain in PEM format:
+%% RSA
%% `{pem_plain, KeyBinary}' - generated by openssl genrsa -out out-file.pem 1024
%% `{pem_encrypted, KeyBinary, Password}' - generated by, eg
%% openssl genrsa -des3 -out out-file.pem 1024
+%% RFC8463
+%% Ed25519 - Erlang/OTP 24.1+ only!
+%% `{pem_plain, KeyBinary}' - generated by openssl genpkey -algorithm ed25519 -out out-file.pem
+%% `{pem_encrypted, KeyBinary, Password}' - generated by, eg
+%% openssl genpkey -des3 -algorithm ed25519 -out out-file.pem
%% 3rd paramerter is password to decrypt the key.
-spec dkim_sign_email([binary()], binary(), dkim_options()) -> [binary()].
dkim_sign_email(Headers, Body, Opts) ->
@@ -1089,13 +1097,14 @@ dkim_sign_email(Headers, Body, Opts) ->
end
end, [], [t, x]),
{HdrsCanT, BodyCanT} = Can = proplists:get_value(c, Opts, {relaxed, simple}),
+ Algorithm = proplists:get_value(a, Opts, 'rsa-sha256'),
PrivateKey = proplists:get_value(private_key, Opts),
%% hash body
CanBody = dkim_canonicalize_body(Body, BodyCanT),
BodyHash = dkim_hash_body(CanBody),
Tags = [%% {b, <<>>},
- {v, 1}, {a, <<"rsa-sha256">>}, {bh, BodyHash}, {c, Can},
+ {v, 1}, {a, Algorithm}, {bh, BodyHash}, {c, Can},
{d, SDID}, {h, HeadersToSign}, {s, Selector} | OptionalTags],
%% hash headers
Headers1 = dkim_filter_headers(Headers, HeadersToSign),
@@ -1103,7 +1112,7 @@ dkim_sign_email(Headers, Body, Opts) ->
[DkimHeaderNoB] = dkim_canonicalize_headers([dkim_make_header([{b, undefined} | Tags])], HdrsCanT),
DataHash = dkim_hash_data(CanHeaders, DkimHeaderNoB),
%% sign
- Signature = dkim_sign(DataHash, PrivateKey),
+ Signature = dkim_sign(DataHash, Algorithm, PrivateKey),
DkimHeader = dkim_make_header([{b, Signature} | Tags]),
[DkimHeader | Headers].
@@ -1147,15 +1156,37 @@ dkim_hash_data(CanonicHeaders, DkimHeader) ->
JoinedHeaders = << <> || Hdr <- CanonicHeaders>>,
crypto:hash(sha256, <>).
-dkim_sign(DataHash, {pem_plain, PrivBin}) ->
+%% TODO: Remove once we require Erlang/OTP 24.1+
+%% Related Erlang/OTP bug: https://github.com/erlang/otp/pull/5157
+ed25519_supported() ->
+ {ok, PublicKeyAppVersionString} = application:get_key(public_key, vsn),
+ PublicKeyAppVersionList =
+ lists:map(fun erlang:list_to_integer/1, string:tokens(PublicKeyAppVersionString, ".")),
+ PublicKeyAppVersionList >= [1, 11, 2].
+
+dkim_get_algorithm_digest(Algorithm) ->
+ case Algorithm of
+ 'rsa-sha256' ->
+ sha256;
+ 'ed25519-sha256' ->
+ case ed25519_supported() of
+ true ->
+ none;
+ false ->
+ throw("DKIM with Ed25519 requires Erlang/OTP 24.1+")
+ end
+ end.
+
+dkim_sign(DataHash, Algorithm, {pem_plain, PrivBin}) ->
[PrivEntry] = public_key:pem_decode(PrivBin),
+ Digest = dkim_get_algorithm_digest(Algorithm),
Key = public_key:pem_entry_decode(PrivEntry),
- public_key:sign({digest, DataHash}, sha256, Key);
-dkim_sign(DataHash, {pem_encrypted, EncPrivBin, Passwd}) ->
+ public_key:sign({digest, DataHash}, Digest, Key);
+dkim_sign(DataHash, Algorithm, {pem_encrypted, EncPrivBin, Passwd}) ->
[EncPrivEntry] = public_key:pem_decode(EncPrivBin),
+ Digest = dkim_get_algorithm_digest(Algorithm),
Key = public_key:pem_entry_decode(EncPrivEntry, Passwd),
- public_key:sign({digest, DataHash}, sha256, Key).
-
+ public_key:sign({digest, DataHash}, Digest, Key).
dkim_make_header(Tags) ->
RevTags = lists:reverse(Tags), %so {b, ...} became last tag
@@ -1166,9 +1197,9 @@ dkim_make_header(Tags) ->
dkim_encode_tag(v, 1) ->
%% version
<<"v=1">>;
-dkim_encode_tag(a, <<"rsa-sha256">>) ->
+dkim_encode_tag(a, Algorithm) ->
%% algorithm
- <<"a=rsa-sha256">>;
+ <<"a=", (atom_to_binary(Algorithm, utf8))/binary>>;
dkim_encode_tag(b, undefined) ->
%% signature (when hashing with no digest)
<<"b=">>;
@@ -2377,7 +2408,7 @@ dkim_canonicalization_test_() ->
dkim_canonicalize_headers(Hdrs, relaxed))
end}].
-dkim_sign_test_() ->
+dkim_sign_rsa_test_() ->
%% * sign using test/fixtures/dkim*.pem
{ok, PrivKey} = file:read_file("test/fixtures/dkim-rsa-private.pem"),
[{"Sign simple",
@@ -2430,7 +2461,6 @@ dkim_sign_test_() ->
{private_key, {pem_plain, PrivKey}}]}],
Enc = encode(Email, Options),
- file:write_file("/home/seriy/relaxed-signed.eml", Enc),
{_, _, [{DkimHdrName, DkimHdrVal} | _], _, _} = decode(Enc),
?assertEqual(<<"DKIM-Signature">>, DkimHdrName),
?assertEqual(
@@ -2441,4 +2471,115 @@ dkim_sign_test_() ->
"T97QadH42PT6XmO2v01q5nhMgNE4yQyf9DBJs=">>, DkimHdrVal)
end}].
+dkim_sign_ed25519_test_() ->
+ case ed25519_supported() of
+ true ->
+ %% * sign using test/fixtures/dkim*.pem
+ {ok, PrivKey} = file:read_file("test/fixtures/dkim-ed25519-private.pem"),
+ [{"Sign simple",
+ fun() ->
+ Email = {<<"text">>, <<"plain">>,
+ [{<<"From">>, <<"me@example.com">>},
+ {<<"Subject">>, <<"Hello world!">>},
+ {<<"Date">>, <<"Thu, 28 Nov 2013 04:15:44 +0400">>},
+ {<<"Message-ID">>, <<"the-id">>},
+ {<<"Content-Type">>, <<"text/plain; charset=utf-8">>}],
+ #{},
+ <<"123">>},
+ Options = [{dkim, [{s, <<"foo.bar">>},
+ {d, <<"example.com">>},
+ {c, {simple, simple}},
+ {a, 'ed25519-sha256'},
+ {t, {{2014, 2, 4}, {23, 15, 00}}},
+ {x, {{2114, 2, 4}, {23, 15, 00}}},
+ {private_key, {pem_plain, PrivKey}}]}],
+
+ Enc = encode(Email, Options),
+ %% This `Enc' value can be verified, for example, by Python script
+ %% https://launchpad.net/dkimpy like:
+ %% >>> pubkey = ''.join(open("test/fixtures/dkim-ed25519-public.pem").read().splitlines()[1:-1])
+ %% >>> dns_mock = lambda *args: 'v=DKIM1; g=*; k=ed25519; p=' + pubkey
+ %% >>> import dkim
+ %% >>> d = dkim.DKIM(mime_message) % pass `Enc' value as 1'st argument
+ %% >>> d.verify(dnsfunc=dns_mock)
+ %% True
+ {_, _, [{DkimHdrName, DkimHdrVal} | _], _, _} = decode(Enc),
+ ?assertEqual(<<"DKIM-Signature">>, DkimHdrName),
+ ?assertEqual(<<"t=1391555700; x=4547229300; s=foo.bar; h=from:to:subject:date; d=example.com; c=simple/simple; "
+ "bh=Afm/S7SaxS19en1h955RwsupTF914DQUPqYU8Nh7kpw=; a=ed25519-sha256; v=1; "
+ "b=bFPndkFlgpFbfVKBF9HiVkQQF/3ojOQT7ycrZYp0yYe4oyItUQexlvd+Q7BviiHv/seLVBESpBjLbthbfb5HDA==">>,
+ DkimHdrVal)
+ end},
+ {"Sign relaxed headers, simple body",
+ fun() ->
+ Email = {<<"text">>, <<"plain">>,
+ [{<<"From">>, <<"me@example.com">>},
+ {<<"Subject">>, <<"Hello world!">>},
+ {<<"Date">>, <<"Thu, 28 Nov 2013 04:15:44 +0400">>},
+ {<<"Message-ID">>, <<"the-id-relaxed">>},
+ {<<"Content-Type">>, <<"text/plain; charset=utf-8">>}],
+ #{},
+ <<"123">>},
+ Options = [{dkim, [{s, <<"foo.bar">>},
+ {d, <<"example.com">>},
+ {c, {relaxed, simple}},
+ {a, 'ed25519-sha256'},
+ {private_key, {pem_plain, PrivKey}}]}],
+
+ Enc = encode(Email, Options),
+ {_, _, [{DkimHdrName, DkimHdrVal} | _], _, _} = decode(Enc),
+ ?assertEqual(<<"DKIM-Signature">>, DkimHdrName),
+ ?assertEqual(<<"s=foo.bar; h=from:to:subject:date; d=example.com; c=relaxed/simple; "
+ "bh=Afm/S7SaxS19en1h955RwsupTF914DQUPqYU8Nh7kpw=; a=ed25519-sha256; v=1; "
+ "b=f7wORU/qmPr4q891m5zmZMadPm9n9e596mBJHBD6tE51PAl4pHdpw9xRC1kwLGmxPTEK5SiQluPVTbDHVhVZBQ==">>,
+ DkimHdrVal)
+ end}];
+ false ->
+ []
+ end.
+
+dkim_sign_ed25519_encrypted_key_test_() ->
+ case ed25519_supported() of
+ true ->
+ %% * sign using test/fixtures/dkim*.pem
+ {ok, EncryptedPrivKey} =
+ file:read_file("test/fixtures/dkim-ed25519-encrypted-private.pem"),
+ [{"Sign encrypted",
+ fun() ->
+ Email = {<<"text">>, <<"plain">>,
+ [{<<"From">>, <<"me@example.com">>},
+ {<<"Subject">>, <<"Hello world!">>},
+ {<<"Date">>, <<"Thu, 28 Nov 2013 04:15:44 +0400">>},
+ {<<"Message-ID">>, <<"the-id">>},
+ {<<"Content-Type">>, <<"text/plain; charset=utf-8">>}],
+ #{},
+ <<"123">>},
+ Options = [{dkim, [{s, <<"foo.bar">>},
+ {d, <<"example.com">>},
+ {c, {simple, simple}},
+ {a, 'ed25519-sha256'},
+ {t, {{2014, 2, 4}, {23, 15, 00}}},
+ {x, {{2114, 2, 4}, {23, 15, 00}}},
+ {private_key, {pem_encrypted, EncryptedPrivKey, "password"}}]}],
+
+ Enc = encode(Email, Options),
+ %% This `Enc' value can be verified, for example, by Python script
+ %% https://launchpad.net/dkimpy like:
+ %% >>> pubkey = ''.join(open("test/fixtures/dkim-ed25519-public.pem").read().splitlines()[1:-1])
+ %% >>> dns_mock = lambda *args: 'v=DKIM1; g=*; k=ed25519; p=' + pubkey
+ %% >>> import dkim
+ %% >>> d = dkim.DKIM(mime_message) % pass `Enc' value as 1'st argument
+ %% >>> d.verify(dnsfunc=dns_mock)
+ %% True
+ {_, _, [{DkimHdrName, DkimHdrVal} | _], _, _} = decode(Enc),
+ ?assertEqual(<<"DKIM-Signature">>, DkimHdrName),
+ ?assertEqual(<<"t=1391555700; x=4547229300; s=foo.bar; h=from:to:subject:date; d=example.com; c=simple/simple; "
+ "bh=Afm/S7SaxS19en1h955RwsupTF914DQUPqYU8Nh7kpw=; a=ed25519-sha256; v=1; "
+ "b=JgsuW5OmKPk188YRmxs1cLA8mrAf9FNC+s/PYK7Vat7HF4l7FglcoWWHqm0/Cg7o/V+8bP1RNwes1xDKS8/wDQ==">>,
+ DkimHdrVal)
+ end}];
+ false ->
+ []
+ end.
+
-endif.
diff --git a/test/fixtures/dkim-ed25519-encrypted-private.pem b/test/fixtures/dkim-ed25519-encrypted-private.pem
new file mode 100644
index 0000000..284c3a8
--- /dev/null
+++ b/test/fixtures/dkim-ed25519-encrypted-private.pem
@@ -0,0 +1,5 @@
+-----BEGIN ENCRYPTED PRIVATE KEY-----
+MIGKME4GCSqGSIb3DQEFDTBBMCkGCSqGSIb3DQEFDDAcBAjWxBqVOoAQmQICCAAw
+DAYIKoZIhvcNAgkFADAUBggqhkiG9w0DBwQINxHTI3T4bPEEOFrkHOCl0Y4wOEPa
+TEMzq2vB5tqpSVcbbup6BdRGV1f7yDsk+9l9f08m3pZUIbeNgUy1Y9JmUjxU
+-----END ENCRYPTED PRIVATE KEY-----
diff --git a/test/fixtures/dkim-ed25519-encrypted-public.pem b/test/fixtures/dkim-ed25519-encrypted-public.pem
new file mode 100644
index 0000000..520b19a
--- /dev/null
+++ b/test/fixtures/dkim-ed25519-encrypted-public.pem
@@ -0,0 +1,3 @@
+-----BEGIN PUBLIC KEY-----
+MCowBQYDK2VwAyEAHWHDpSxS5ABadBDrOKcpyaImlzV4//pJ3A3UgdLuFMk=
+-----END PUBLIC KEY-----
diff --git a/test/fixtures/dkim-ed25519-private.pem b/test/fixtures/dkim-ed25519-private.pem
new file mode 100644
index 0000000..2e2819e
--- /dev/null
+++ b/test/fixtures/dkim-ed25519-private.pem
@@ -0,0 +1,3 @@
+-----BEGIN PRIVATE KEY-----
+MC4CAQAwBQYDK2VwBCIEINLp5tYtDtUVSeH4BJb3+ygipAjPHFm4eB0QNWlhcUNZ
+-----END PRIVATE KEY-----
diff --git a/test/fixtures/dkim-ed25519-public.pem b/test/fixtures/dkim-ed25519-public.pem
new file mode 100644
index 0000000..20c0516
--- /dev/null
+++ b/test/fixtures/dkim-ed25519-public.pem
@@ -0,0 +1,3 @@
+-----BEGIN PUBLIC KEY-----
+MCowBQYDK2VwAyEAgxFnePs7aR/rt5KBGSaJU4T+Uh2cIvLtV6cBz5ypIYE=
+-----END PUBLIC KEY-----