Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add DKIM Ed25519 capability #276

Merged
merged 1 commit into from
Sep 25, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
19 changes: 16 additions & 3 deletions README.markdown
Original file line number Diff line number Diff line change
Expand Up @@ -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
cw789 marked this conversation as resolved.
Show resolved Hide resolved
```

To send DKIM-signed email:
Expand All @@ -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......
```
cw789 marked this conversation as resolved.
Show resolved Hide resolved

See RFC6376 for more details.

## Server Example
Expand Down
163 changes: 152 additions & 11 deletions src/mimemail.erl
Original file line number Diff line number Diff line change
Expand Up @@ -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()} |
Expand Down Expand Up @@ -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 <code>openssl genrsa -out out-file.pem 1024</code>
%% `{pem_encrypted, KeyBinary, Password}' - generated by, eg
%% <code>openssl genrsa -des3 -out out-file.pem 1024</code>
%% RFC8463
%% Ed25519 - Erlang/OTP 24.1+ only!
%% `{pem_plain, KeyBinary}' - generated by <code>openssl genpkey -algorithm ed25519 -out out-file.pem</code>
%% `{pem_encrypted, KeyBinary, Password}' - generated by, eg
%% <code>openssl genpkey -des3 -algorithm ed25519 -out out-file.pem</code>
%% 3rd paramerter is password to decrypt the key.
-spec dkim_sign_email([binary()], binary(), dkim_options()) -> [binary()].
dkim_sign_email(Headers, Body, Opts) ->
Expand All @@ -1089,21 +1097,22 @@ 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),
CanHeaders = dkim_canonicalize_headers(Headers1, HdrsCanT),
[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].

Expand Down Expand Up @@ -1147,15 +1156,37 @@ dkim_hash_data(CanonicHeaders, DkimHeader) ->
JoinedHeaders = << <<Hdr/binary, "\r\n">> || Hdr <- CanonicHeaders>>,
crypto:hash(sha256, <<JoinedHeaders/binary, DkimHeader/binary>>).

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),
cw789 marked this conversation as resolved.
Show resolved Hide resolved
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
Expand All @@ -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=">>;
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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(
Expand All @@ -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.
5 changes: 5 additions & 0 deletions test/fixtures/dkim-ed25519-encrypted-private.pem
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
-----BEGIN ENCRYPTED PRIVATE KEY-----
MIGKME4GCSqGSIb3DQEFDTBBMCkGCSqGSIb3DQEFDDAcBAjWxBqVOoAQmQICCAAw
DAYIKoZIhvcNAgkFADAUBggqhkiG9w0DBwQINxHTI3T4bPEEOFrkHOCl0Y4wOEPa
TEMzq2vB5tqpSVcbbup6BdRGV1f7yDsk+9l9f08m3pZUIbeNgUy1Y9JmUjxU
-----END ENCRYPTED PRIVATE KEY-----
3 changes: 3 additions & 0 deletions test/fixtures/dkim-ed25519-encrypted-public.pem
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
-----BEGIN PUBLIC KEY-----
MCowBQYDK2VwAyEAHWHDpSxS5ABadBDrOKcpyaImlzV4//pJ3A3UgdLuFMk=
-----END PUBLIC KEY-----
3 changes: 3 additions & 0 deletions test/fixtures/dkim-ed25519-private.pem
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
-----BEGIN PRIVATE KEY-----
MC4CAQAwBQYDK2VwBCIEINLp5tYtDtUVSeH4BJb3+ygipAjPHFm4eB0QNWlhcUNZ
-----END PRIVATE KEY-----
3 changes: 3 additions & 0 deletions test/fixtures/dkim-ed25519-public.pem
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
-----BEGIN PUBLIC KEY-----
MCowBQYDK2VwAyEAgxFnePs7aR/rt5KBGSaJU4T+Uh2cIvLtV6cBz5ypIYE=
-----END PUBLIC KEY-----