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