Skip to content

Commit

Permalink
Add DKIM Ed25519 capability (#276)
Browse files Browse the repository at this point in the history
  • Loading branch information
cw789 authored Sep 25, 2021
1 parent 5bd741d commit 5ce941b
Show file tree
Hide file tree
Showing 7 changed files with 184 additions and 16 deletions.
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
```

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......
```

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),
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-----

0 comments on commit 5ce941b

Please sign in to comment.