From bf3a778cdccdc1ba5d8bfed20d4e53a293bcc071 Mon Sep 17 00:00:00 2001 From: Sven Sauleau Date: Thu, 20 Jan 2022 20:45:23 +0000 Subject: [PATCH] first commit --- .gitignore | 1 + Cargo.lock | 1111 +++++++++++++++++++++++++++++++++++++++ Cargo.toml | 29 + LICENSE | 21 + README.md | 32 ++ src/bytes.rs | 62 +++ src/canonicalization.rs | 152 ++++++ src/dns.rs | 45 ++ src/errors.rs | 91 ++++ src/hash.rs | 379 +++++++++++++ src/header.rs | 28 + src/lib.rs | 309 +++++++++++ src/parser.rs | 240 +++++++++ src/public_key.rs | 148 ++++++ src/result.rs | 59 +++ 15 files changed, 2707 insertions(+) create mode 100644 .gitignore create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 LICENSE create mode 100644 README.md create mode 100644 src/bytes.rs create mode 100644 src/canonicalization.rs create mode 100644 src/dns.rs create mode 100644 src/errors.rs create mode 100644 src/hash.rs create mode 100644 src/header.rs create mode 100644 src/lib.rs create mode 100644 src/parser.rs create mode 100644 src/public_key.rs create mode 100644 src/result.rs diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..2f7896d1 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +target/ diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 00000000..f87e97b6 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,1111 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "async-trait" +version = "0.1.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "061a7acccaa286c011ddc30970520b98fa40e00c9d644633fb26b5fc63a265e3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "autocfg" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d49d90015b3c36167a20fe2810c5cd875ad504b39cff3d4eae7977e6b7c1cb2" + +[[package]] +name = "autocfg" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb031dd78e28731d87d56cc8ffef4a8f36ca26c38fe2de700543e627f8a464a" + +[[package]] +name = "base64" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "904dfeac50f3cdaba28fc6f57fdcddb75f49ed61346676a78c4ffe55877802fd" + +[[package]] +name = "base64ct" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6b4d9b1225d28d360ec6a231d65af1fd99a2a095154c8040689617290569c5c" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "block-buffer" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4152116fd6e9dadb291ae18fc1ec3575ed6d84c29642d97890f4b4a3417297e4" +dependencies = [ + "generic-array", +] + +[[package]] +name = "byteorder" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14c189c53d098945499cdfa7ecc63567cf3886b3332b312a5b4585d8d3a6a610" + +[[package]] +name = "cfdkim" +version = "0.1.0" +dependencies = [ + "base64", + "chrono", + "futures", + "indexmap", + "mailparse", + "nom", + "quick-error 2.0.1", + "rsa", + "sha-1", + "sha2", + "slog", + "tokio", + "trust-dns-resolver", +] + +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + +[[package]] +name = "charset" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18e9079d1a12a2cc2bffb5db039c43661836ead4082120d5844f02555aca2d46" +dependencies = [ + "base64", + "encoding_rs", +] + +[[package]] +name = "chrono" +version = "0.4.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "670ad68c9088c2a963aaa298cb369688cf3f9465ce5e2d4ca10e6e0098a1ce73" +dependencies = [ + "libc", + "num-integer", + "num-traits", + "time", + "winapi", +] + +[[package]] +name = "const-oid" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d6f2aa4d0537bcc1c74df8755072bd31c1ef1a3a1b85a68e8404a8c353b7b8b" + +[[package]] +name = "cpufeatures" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95059428f66df56b63431fdb4e1947ed2190586af5c5a8a8b71122bdf5a7f469" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-bigint" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83bd3bb4314701c568e340cd8cf78c975aa0ca79e03d3f6d1677d5b0c9c0c03" +dependencies = [ + "generic-array", + "rand_core", + "subtle", +] + +[[package]] +name = "data-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ee2393c4a91429dffb4bedf19f4d6abf27d8a732c8ce4980305d782e5426d57" + +[[package]] +name = "der" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79b71cca7d95d7681a4b3b9cdf63c8dbc3730d0584c2c74e31416d64a90493f4" +dependencies = [ + "const-oid", + "crypto-bigint", +] + +[[package]] +name = "digest" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3dd60d1080a57a05ab032377049e0591415d2b31afd7028356dbf3cc6dcb066" +dependencies = [ + "generic-array", +] + +[[package]] +name = "encoding_rs" +version = "0.8.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7896dc8abb250ffdda33912550faa54c88ec8b998dec0b2c55ab224921ce11df" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "enum-as-inner" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c5f0096a91d210159eceb2ff5e1c4da18388a170e1e3ce948aac9c8fdbbf595" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "form_urlencoded" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fc25a87fa4fd2094bffb06925852034d90a17f0d1e05197d4956d3555752191" +dependencies = [ + "matches", + "percent-encoding", +] + +[[package]] +name = "futures" +version = "0.3.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28560757fe2bb34e79f907794bb6b22ae8b0e5c669b638a1132f2592b19035b4" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba3dda0b6588335f360afc675d0564c17a77a2bda81ca178a4b6081bd86c7f0b" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0c8ff0461b82559810cdccfde3215c3f373807f5e5232b71479bff7bb2583d7" + +[[package]] +name = "futures-executor" +version = "0.3.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29d6d2ff5bb10fb95c85b8ce46538a2e5f5e7fdc755623a7d4529ab8a4ed9d2a" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9d34af5a1aac6fb380f735fe510746c38067c5bf16c7fd250280503c971b2" + +[[package]] +name = "futures-macro" +version = "0.3.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbd947adfffb0efc70599b3ddcf7b5597bb5fa9e245eb99f62b3a5f7bb8bd3c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3055baccb68d74ff6480350f8d6eb8fcfa3aa11bdc1a1ae3afdd0514617d508" + +[[package]] +name = "futures-task" +version = "0.3.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ee7c6485c30167ce4dfb83ac568a849fe53274c831081476ee13e0dce1aad72" + +[[package]] +name = "futures-util" +version = "0.3.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9b5cf40b47a271f77a8b1bec03ca09044d99d2372c0de244e66430761127164" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "pin-utils", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd48d33ec7f05fbfa152300fdad764757cbded343c1aa1cff2fbaf4134851803" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "418d37c8b1d42553c93648be529cb70f920d3baf8ef469b74b9638df426e0b4c" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "hashbrown" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab5ef0d4909ef3724cc8cce6ccc8572c5c817592e9285f5464f8e86f8bd3726e" + +[[package]] +name = "heck" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d621efb26863f0e9924c6ac577e8275e5e6b77455db64ffa6c65c904e9e132c" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "hermit-abi" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62b467343b94ba476dcb2500d242dadbb39557df889310ac77c5d99100aaac33" +dependencies = [ + "libc", +] + +[[package]] +name = "hostname" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c731c3e10504cc8ed35cfe2f1db4c9274c3d35fa486e3b31df46f068ef3e867" +dependencies = [ + "libc", + "match_cfg", + "winapi", +] + +[[package]] +name = "idna" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "418a0a6fab821475f634efe3ccc45c013f742efe03d853e8d3355d5cb850ecf8" +dependencies = [ + "matches", + "unicode-bidi", + "unicode-normalization", +] + +[[package]] +name = "indexmap" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282a6247722caba404c065016bbfa522806e51714c34f5dfc3e4a3a46fcb4223" +dependencies = [ + "autocfg 1.0.1", + "hashbrown", +] + +[[package]] +name = "instant" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a5bbe824c507c5da5956355e86a746d82e0e1464f65d862cc5e71da70e94b2c" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "ipconfig" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7e2f18aece9709094573a9f24f483c4f65caa4298e2f7ae1b71cc65d853fad7" +dependencies = [ + "socket2", + "widestring", + "winapi", + "winreg", +] + +[[package]] +name = "ipnet" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68f2d64f2edebec4ce84ad108148e67e1064789bee435edc5b60ad398714a3a9" + +[[package]] +name = "lazy_static" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" +dependencies = [ + "spin", +] + +[[package]] +name = "libc" +version = "0.2.113" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eef78b64d87775463c549fbd80e19249ef436ea3bf1de2a1eb7e717ec7fab1e9" + +[[package]] +name = "libm" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7d73b3f436185384286bd8098d17ec07c9a7d2388a6599f824d8502b529702a" + +[[package]] +name = "linked-hash-map" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fb9b38af92608140b86b693604b9ffcc5824240a484d1ecd4795bacb2fe88f3" + +[[package]] +name = "lock_api" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712a4d093c9976e24e7dbca41db895dabcbac38eb5f4045393d17a95bdfb1109" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51b9bbe6c47d51fc3e1a9b945965946b4c44142ab8792c50835a980d362c2710" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "lru-cache" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31e24f1ad8321ca0e8a1e0ac13f23cb668e6f5466c2c57319f6a5cf1cc8e3b1c" +dependencies = [ + "linked-hash-map", +] + +[[package]] +name = "mailparse" +version = "0.13.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d70ae0840b192a2f7d1dc46e75f38720a7e3c52dfdc968ba3202fa270668dc67" +dependencies = [ + "base64", + "charset", + "quoted_printable", +] + +[[package]] +name = "match_cfg" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffbee8634e0d45d258acb448e7eaab3fce7a0a467395d4d9f228e3c1f01fb2e4" + +[[package]] +name = "matches" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3e378b66a060d48947b590737b30a1be76706c8dd7b8ba0f2fe3989c68a853f" + +[[package]] +name = "memchr" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "308cc39be01b73d0d18f82a0e7b2a3df85245f84af96fdddc5d202d27e47b86a" + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "mio" +version = "0.7.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8067b404fe97c70829f082dec8bcf4f71225d7eaea1d8645349cb76fa06205cc" +dependencies = [ + "libc", + "log", + "miow", + "ntapi", + "winapi", +] + +[[package]] +name = "miow" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9f1c5b025cda876f66ef43a113f91ebc9f4ccef34843000e0adf6ebbab84e21" +dependencies = [ + "winapi", +] + +[[package]] +name = "nom" +version = "7.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b1d11e1ef389c76fe5b81bcaf2ea32cf88b62bc494e19f493d0b30e7a930109" +dependencies = [ + "memchr", + "minimal-lexical", + "version_check", +] + +[[package]] +name = "ntapi" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6bb902e437b6d86e03cce10a7e2af662292c5dfef23b65899ea3ac9354ad44" +dependencies = [ + "winapi", +] + +[[package]] +name = "num-bigint-dig" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4547ee5541c18742396ae2c895d0717d0f886d8823b8399cdaf7b07d63ad0480" +dependencies = [ + "autocfg 0.1.7", + "byteorder", + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand", + "smallvec", + "zeroize", +] + +[[package]] +name = "num-integer" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2cc698a63b549a70bc047073d2949cce27cd1c7b0a4a862d08a8031bc2801db" +dependencies = [ + "autocfg 1.0.1", + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2021c8337a54d21aca0d59a92577a029af9431cb59b909b03252b9c164fad59" +dependencies = [ + "autocfg 1.0.1", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a64b1ec5cda2586e284722486d802acf1f7dbdc623e2bfc57e65ca1cd099290" +dependencies = [ + "autocfg 1.0.1", + "libm", +] + +[[package]] +name = "num_cpus" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19e64526ebdee182341572e50e9ad03965aa510cd94427a4549448f285e957a1" +dependencies = [ + "hermit-abi", + "libc", +] + +[[package]] +name = "opaque-debug" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "624a8340c38c1b80fd549087862da4ba43e08858af025b236e509b6649fc13d5" + +[[package]] +name = "parking_lot" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d17b78036a60663b797adeaee46f5c9dfebb86948d1255007a1d6be0271ff99" +dependencies = [ + "instant", + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d76e8e1493bcac0d2766c42737f34458f1c8c50c0d23bcb24ea953affb273216" +dependencies = [ + "cfg-if", + "instant", + "libc", + "redox_syscall", + "smallvec", + "winapi", +] + +[[package]] +name = "pem-rfc7468" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84e93a3b1cc0510b03020f33f21e62acdde3dcaef432edc95bea377fbd4c2cd4" +dependencies = [ + "base64ct", +] + +[[package]] +name = "percent-encoding" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4fd5641d01c8f18a23da7b6fe29298ff4b55afcccdf78973b24cf3175fee32e" + +[[package]] +name = "pin-project-lite" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e280fbe77cc62c91527259e9442153f4688736748d24660126286329742b4c6c" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "pkcs1" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "116bee8279d783c0cf370efa1a94632f2108e5ef0bb32df31f051647810a4e2c" +dependencies = [ + "der", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "pkcs8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee3ef9b64d26bad0536099c816c6734379e45bbd5f14798def6809e5cc350447" +dependencies = [ + "der", + "pem-rfc7468", + "pkcs1", + "spki", + "zeroize", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb9f9e6e233e5c4a35559a617bf40a4ec447db2e84c20b55a6f83167b7e57872" + +[[package]] +name = "proc-macro2" +version = "1.0.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7342d5883fbccae1cc37a2353b09c87c9b0f3afd73f5fb9bba687a1f733b029" +dependencies = [ + "unicode-xid", +] + +[[package]] +name = "quick-error" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" + +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + +[[package]] +name = "quote" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47aa80447ce4daf1717500037052af176af5d38cc3e571d9ec1c7353fc10c87d" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "quoted_printable" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fee2dce59f7a43418e3382c766554c614e06a552d53a8f07ef499ea4b332c0f" + +[[package]] +name = "rand" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e7573632e6454cf6b99d7aac4ccca54be06da05aca2ef7423d22d27d4d4bcd8" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", + "rand_hc", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d34f1408f55294453790c48b2f1ebbb1c5b4b7563eb1f418bcfcfdbb06ebb4e7" +dependencies = [ + "getrandom", +] + +[[package]] +name = "rand_hc" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d51e9f596de227fda2ea6c84607f5558e196eeaf43c986b724ba4fb8fdf497e7" +dependencies = [ + "rand_core", +] + +[[package]] +name = "redox_syscall" +version = "0.2.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8383f39639269cde97d255a32bdb68c047337295414940c68bdd30c2e13203ff" +dependencies = [ + "bitflags", +] + +[[package]] +name = "resolv-conf" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52e44394d2086d010551b14b53b1f24e31647570cd1deb0379e2c21b329aba00" +dependencies = [ + "hostname", + "quick-error 1.2.3", +] + +[[package]] +name = "rsa" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e05c2603e2823634ab331437001b411b9ed11660fbc4066f3908c84a9439260d" +dependencies = [ + "byteorder", + "digest", + "lazy_static", + "num-bigint-dig", + "num-integer", + "num-iter", + "num-traits", + "pkcs1", + "pkcs8", + "rand", + "subtle", + "zeroize", +] + +[[package]] +name = "scopeguard" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d29ab0c6d3fc0ee92fe66e2d99f700eab17a8d57d1c1d3b748380fb20baa78cd" + +[[package]] +name = "sha-1" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99cd6713db3cf16b6c84e06321e049a9b9f699826e16096d23bbcc44d15d51a6" +dependencies = [ + "block-buffer", + "cfg-if", + "cpufeatures", + "digest", + "opaque-debug", +] + +[[package]] +name = "sha2" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d58a1e1bf39749807d89cf2d98ac2dfa0ff1cb3faa38fbb64dd88ac8013d800" +dependencies = [ + "block-buffer", + "cfg-if", + "cpufeatures", + "digest", + "opaque-debug", +] + +[[package]] +name = "slab" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9def91fd1e018fe007022791f865d0ccc9b3a0d5001e01aabb8b40e46000afb5" + +[[package]] +name = "slog" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8347046d4ebd943127157b94d63abb990fcf729dc4e9978927fdf4ac3c998d06" + +[[package]] +name = "smallvec" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2dd574626839106c320a323308629dcb1acfc96e32a8cba364ddc61ac23ee83" + +[[package]] +name = "socket2" +version = "0.3.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "122e570113d28d773067fab24266b66753f6ea915758651696b6e35e49f88d6e" +dependencies = [ + "cfg-if", + "libc", + "winapi", +] + +[[package]] +name = "spin" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e63cff320ae2c57904679ba7cb63280a3dc4613885beafb148ee7bf9aa9042d" + +[[package]] +name = "spki" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c01a0c15da1b0b0e1494112e7af814a678fec9bd157881b49beac661e9b6f32" +dependencies = [ + "der", +] + +[[package]] +name = "subtle" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bdef32e8150c2a081110b42772ffe7d7c9032b606bc226c8260fd97e0976601" + +[[package]] +name = "syn" +version = "1.0.86" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a65b3f4ffa0092e9887669db0eae07941f023991ab58ea44da8fe8e2d511c6b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-xid", +] + +[[package]] +name = "synstructure" +version = "0.12.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f36bdaa60a83aca3921b5259d5400cbf5e90fc51931376a9bd4a0eb79aa7210f" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "unicode-xid", +] + +[[package]] +name = "thiserror" +version = "1.0.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "854babe52e4df1653706b98fcfc05843010039b406875930a70e4d9644e5c417" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa32fd3f627f367fe16f893e2597ae3c05020f8bba2666a4e6ea73d377e5714b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "time" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db9e6914ab8b1ae1c260a4ae7a49b6c5611b40328a735b21862567685e73255" +dependencies = [ + "libc", + "wasi", + "winapi", +] + +[[package]] +name = "tinyvec" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c1c1d5a42b6245520c249549ec267180beaffcc0615401ac8e31853d4b6d8d2" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cda74da7e1a664f795bb1f8a87ec406fb89a02522cf6e50620d016add6dbbf5c" + +[[package]] +name = "tokio" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fbbf1c778ec206785635ce8ad57fe52b3009ae9e0c9f574a728f3049d3e55838" +dependencies = [ + "libc", + "mio", + "num_cpus", + "pin-project-lite", + "tokio-macros", + "winapi", +] + +[[package]] +name = "tokio-macros" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b557f72f448c511a979e2564e55d74e6c4432fc96ff4f6241bc6bded342643b7" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "trust-dns-proto" +version = "0.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad0d7f5db438199a6e2609debe3f69f808d074e0a2888ee0bccb45fe234d03f4" +dependencies = [ + "async-trait", + "cfg-if", + "data-encoding", + "enum-as-inner", + "futures-channel", + "futures-io", + "futures-util", + "idna", + "ipnet", + "lazy_static", + "log", + "rand", + "smallvec", + "thiserror", + "tinyvec", + "tokio", + "url", +] + +[[package]] +name = "trust-dns-resolver" +version = "0.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ad17b608a64bd0735e67bde16b0636f8aa8591f831a25d18443ed00a699770" +dependencies = [ + "cfg-if", + "futures-util", + "ipconfig", + "lazy_static", + "log", + "lru-cache", + "parking_lot", + "resolv-conf", + "smallvec", + "thiserror", + "tokio", + "trust-dns-proto", +] + +[[package]] +name = "typenum" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dcf81ac59edc17cc8697ff311e8f5ef2d99fcbd9817b34cec66f90b6c3dfd987" + +[[package]] +name = "unicode-bidi" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a01404663e3db436ed2746d9fefef640d868edae3cceb81c3b8d5732fda678f" + +[[package]] +name = "unicode-normalization" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d54590932941a9e9266f0832deed84ebe1bf2e4c9e4a3554d393d18f5e854bf9" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-segmentation" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8895849a949e7845e06bd6dc1aa51731a103c42707010a5b591c0038fb73385b" + +[[package]] +name = "unicode-xid" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ccb82d61f80a663efe1f787a51b16b5a51e3314d6ac365b08639f52387b33f3" + +[[package]] +name = "url" +version = "2.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a507c383b2d33b5fc35d1861e77e6b383d158b2da5e14fe51b83dfedf6fd578c" +dependencies = [ + "form_urlencoded", + "idna", + "matches", + "percent-encoding", +] + +[[package]] +name = "version_check" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f" + +[[package]] +name = "wasi" +version = "0.10.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a143597ca7c7793eff794def352d41792a93c481eb1042423ff7ff72ba2c31f" + +[[package]] +name = "widestring" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c168940144dd21fd8046987c16a46a33d5fc84eec29ef9dcddc2ac9e31526b7c" + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "winreg" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2986deb581c4fe11b621998a5e53361efe6b48a151178d0cd9eeffa4dc6acc9" +dependencies = [ + "winapi", +] + +[[package]] +name = "zeroize" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d68d9dcec5f9b43a30d38c49f91dfedfaac384cb8f085faca366c26207dd1619" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81e8f13fef10b63c06356d65d416b070798ddabcadc10d3ece0c5be9b3c7eddb" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 00000000..9517f7cb --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "cfdkim" +version = "0.1.0" +authors = ["Sven Sauleau "] +edition = "2021" +description = "DKIM (RFC6376) implementation" +repository = "https://github.com/cloudflare/dkim" +documentation = "https://docs.rs/cfdkim" +categories = ["email"] +keywords = ["email", "dkim", "authentification"] +readme = "README.md" +license = "MIT" + +[dependencies] +mailparse = "0.13.7" +quick-error = "2.0.1" +nom = "7.1.0" +chrono = "0.4.19" +trust-dns-resolver = "0.20.3" +futures = "0.3.18" +sha-1 = "0.9" +sha2 = "0.9" +base64 = "0.13.0" +rsa = "0.5.0" +slog = "2.7.0" +indexmap = "1.8.0" + +[dev-dependencies] +tokio = { version = "1.14.0", features = ["macros"] } diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..50709db8 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2022 Cloudflare + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 00000000..e6e3423f --- /dev/null +++ b/README.md @@ -0,0 +1,32 @@ +# cfdkim + +> DKIM ([RFC6376]) implementation + +## Features + +### Verifying email signatures + +Example: +```rust +let res: DKIMResult = cfdkim::verify_email(&logger, &from_domain, &parsed_email).await?; + +if let Some(err) = &res.error() { + error!(logger, "dkim verify fail: {}", err); +} + +println!("dkim={}", res.with_detail()); +``` + +The `verify_email` arguments are the following: +- `logger`: [slog]::Logger +- `from_domain`: &str ([RFC5322].From's domain) +- `parsed_email`: [mailparse]::ParsedMail + +### Signing an email + +Work in progress. + +[RFC5322]: https://datatracker.ietf.org/doc/html/rfc5322 +[RFC6376]: https://datatracker.ietf.org/doc/html/rfc6376 +[slog]: https://crates.io/crates/slog +[mailparse]: https://crates.io/crates/mailparse diff --git a/src/bytes.rs b/src/bytes.rs new file mode 100644 index 00000000..3c412997 --- /dev/null +++ b/src/bytes.rs @@ -0,0 +1,62 @@ +///! Various utility functions to operate on bytes + +pub(crate) fn get_all_after<'a>(bytes: &'a [u8], end: &[u8]) -> &'a [u8] { + if let Some(mut end_index) = find(bytes, end) { + end_index += end.len(); + return &bytes[end_index..]; + } else { + return &[]; + } +} + +/// Find the offset of specific bytes in bytes +pub(crate) fn find(bytes: &[u8], search: &[u8]) -> Option { + bytes + .windows(search.len()) + .position(|window| window == search) +} + +pub(crate) fn replace(bytes: &mut [u8], from: char, to: char) { + for byte in bytes.iter_mut() { + if *byte == from as u8 { + *byte = to as u8; + } + } +} + +pub(crate) fn replace_slice(source: &[u8], from: &[u8], to: &[u8]) -> Vec { + let mut result = source.to_vec(); + let from_len = from.len(); + let to_len = to.len(); + + let mut i = 0; + while i + from_len <= result.len() { + if result[i..].starts_with(from) { + result.splice(i..i + from_len, to.iter().cloned()); + i += to_len; + } else { + i += 1; + } + } + + result +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn it_find() { + assert_eq!(find(&[97, 98, 99], &[1]), None); + assert_eq!(find(&[97, 98, 99], &[97]), Some(0)); + assert_eq!(find(&[97, 98, 99], &[97, 98]), Some(0)); + } + + #[test] + fn it_replace_slice() { + let source = "aba".as_bytes(); + assert_eq!(replace_slice(&source, &[97], &[99]), "cbc".as_bytes()); + assert_eq!(replace_slice(&source, &[97, 98], &[]), "a".as_bytes()); + } +} diff --git a/src/canonicalization.rs b/src/canonicalization.rs new file mode 100644 index 00000000..1cbd8bf8 --- /dev/null +++ b/src/canonicalization.rs @@ -0,0 +1,152 @@ +// Inspired from https://docs.rs/dkim/latest/src/dkim/canonicalization.rs.html +use crate::bytes; + +#[derive(PartialEq, Clone, Debug)] +pub enum Type { + Simple, + Relaxed, +} + +/// Canonicalize body using the simple canonicalization algorithm. +/// +/// The first argument **must** be the body of the mail. +pub(crate) fn canonicalize_body_simple(mut body: &[u8]) -> Vec { + if body.is_empty() { + return b"\r\n".to_vec(); + } + + while body.ends_with(b"\r\n\r\n") { + body = &body[..body.len() - 2]; + } + + body.to_vec() +} + +/// https://datatracker.ietf.org/doc/html/rfc6376#section-3.4.3 +/// Canonicalize body using the relaxed canonicalization algorithm. +/// +/// The first argument **must** be the body of the mail. +pub(crate) fn canonicalize_body_relaxed(body: &[u8]) -> Vec { + let mut body = body.to_vec(); + // See https://tools.ietf.org/html/rfc6376#section-3.4.4 for implementation details + + // Reduce all sequences of WSP within a line to a single SP character. + bytes::replace(&mut body, '\t', ' '); + let mut previous = false; + body.retain(|c| { + if *c == b' ' { + if previous { + false + } else { + previous = true; + true + } + } else { + previous = false; + true + } + }); + + // Ignore all whitespace at the end of lines. Implementations MUST NOT remove the CRLF at the end of the line. + while let Some(idx) = bytes::find(&body, b" \r\n") { + body.remove(idx); + } + + // Ignore all empty lines at the end of the message body. "Empty line" is defined in Section 3.4.3. + while body.ends_with(b"\r\n\r\n") { + body.remove(body.len() - 1); + body.remove(body.len() - 1); + } + + // If the body is non-empty but does not end with a CRLF, a CRLF is added. (For email, this is only possible when using extensions to SMTP or non-SMTP transport mechanisms.) + if !body.is_empty() && !body.ends_with(b"\r\n") { + body.push(b'\r'); + body.push(b'\n'); + } + + body +} + +// https://datatracker.ietf.org/doc/html/rfc6376#section-3.4.1 +pub(crate) fn canonicalize_header_simple(key: &str, value: &[u8]) -> Vec { + // TODO: according to the spec whitespace MUST NOT be changed? pydkim does + // change it too. + let mut out = Vec::new(); + out.extend_from_slice(&key.as_bytes()); + out.extend_from_slice(b":"); + out.extend_from_slice(value); + out.extend_from_slice(b"\r\n"); + + out +} + +// https://datatracker.ietf.org/doc/html/rfc6376#section-3.4.2 +pub(crate) fn canonicalize_header_relaxed(key: &str, value: &[u8]) -> Vec { + let key = key.to_lowercase(); + let key = key.trim_end(); + let value = canonicalize_header_value_relaxed(value); + + let mut out = Vec::new(); + out.extend_from_slice(&key.as_bytes()); + out.extend_from_slice(b":"); + out.extend_from_slice(&value); + out.extend_from_slice(b"\r\n"); + + out +} + +fn canonicalize_header_value_relaxed(value: &[u8]) -> Vec { + let mut value = value.to_vec(); + bytes::replace(&mut value, '\t', ' '); + value = bytes::replace_slice(&value, b"\r\n", b""); + + while value.ends_with(b" ") { + value.remove(value.len() - 1); + } + while value.starts_with(b" ") { + value.remove(0); + } + let mut previous = false; + value.retain(|c| { + if *c == b' ' { + if previous { + false + } else { + previous = true; + true + } + } else { + previous = false; + true + } + }); + + value +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_canonicalize_header_relaxed() { + assert_eq!( + canonicalize_header_relaxed("SUBJect", b" AbC\r\n"), + b"subject:AbC\r\n" + ); + assert_eq!( + canonicalize_header_relaxed("Subject \t", b"\t Your Name\t \r\n"), + b"subject:Your Name\r\n" + ); + assert_eq!( + canonicalize_header_relaxed("Subject \t", b"\t Kimi \t \r\n No \t\r\n Na Wa\r\n"), + b"subject:Kimi No Na Wa\r\n" + ); + } + + #[test] + fn test_canonicalize_body_relaxed() { + assert_eq!(canonicalize_body_relaxed(b"\r\n"), b"\r\n"); + assert_eq!(canonicalize_body_relaxed(b"hey \r\n"), b"hey\r\n"); + } +} diff --git a/src/dns.rs b/src/dns.rs new file mode 100644 index 00000000..3a23c88e --- /dev/null +++ b/src/dns.rs @@ -0,0 +1,45 @@ +use crate::DKIMError; +use futures::future::BoxFuture; +use std::sync::Arc; +use trust_dns_resolver::error::{ResolveError, ResolveErrorKind}; +use trust_dns_resolver::TokioAsyncResolver; + +/// A trait for entities that perform DNS resolution. +pub trait Lookup: Sync + Send { + fn lookup_txt<'a>(&'a self, name: &'a str) -> BoxFuture<'a, Result, DKIMError>>; +} + +fn to_lookup_error(err: ResolveError) -> DKIMError { + match err.kind() { + ResolveErrorKind::NoRecordsFound { .. } => DKIMError::NoKeyForSignature, + _ => DKIMError::KeyUnavailable(format!("failed to query DNS: {}", err)), + } +} + +// Technically we should be able to implemement Lookup for TokioAsyncResolver +// directly but it's failing for some reason. +struct TokioAsyncResolverWrapper { + inner: TokioAsyncResolver, +} +impl Lookup for TokioAsyncResolverWrapper { + fn lookup_txt<'a>(&'a self, name: &'a str) -> BoxFuture<'a, Result, DKIMError>> { + Box::pin(async move { + self.inner + .txt_lookup(name) + .await + .map_err(to_lookup_error)? + .into_iter() + .map(|txt| { + Ok(txt + .iter() + .map(|data| String::from_utf8_lossy(data)) + .collect()) + }) + .collect() + }) + } +} + +pub(crate) fn from_tokio_resolver(resolver: TokioAsyncResolver) -> Arc { + Arc::new(TokioAsyncResolverWrapper { inner: resolver }) +} diff --git a/src/errors.rs b/src/errors.rs new file mode 100644 index 00000000..717d424d --- /dev/null +++ b/src/errors.rs @@ -0,0 +1,91 @@ +/// DKIM error status +pub enum Status { + Permfail, + Tempfail, +} + +quick_error! { + #[derive(Debug, PartialEq, Clone)] + /// DKIM errors + pub enum DKIMError { + UnsupportedHashAlgorithm(value: String) { + display("unsupported hash algorithm: {}", value) + } + UnsupportedCanonicalizationType(value: String) { + display("unsupported canonicalization: {}", value) + } + SignatureSyntaxError(err: String) { + display("signature syntax error: {}", err) + } + SignatureMissingRequiredTag(name: &'static str) { + display("signature missing required tag ({})", name) + } + IncompatibleVersion { + display("incompatible version") + } + DomainMismatch { + display("domain mismatch") + } + FromFieldNotSigned { + display("From field not signed") + } + SignatureExpired { + display("signature expired") + } + UnacceptableSignatureHeader { + display("unacceptable signature header") + } + UnsupportedQueryMethod { + display("unsupported query method") + } + KeyUnavailable(err: String) { + display("key unavailable: {}", err) + } + UnknownInternalError(err: String) { + display("internal error: {}", err) + } + NoKeyForSignature { + display("no key for signature") + } + KeySyntaxError { + display("key syntax error") + } + KeyIncompatibleVersion { + display("key incompatible version") + } + InappropriateKeyAlgorithm { + display("inappropriate key algorithm") + } + SignatureDidNotVerify { + display("signature did not verify") + } + BodyHashDidNotVerify { + display("body hash did not verify") + } + } +} + +impl DKIMError { + pub fn status(self) -> Status { + use DKIMError::*; + match self { + SignatureSyntaxError(_) + | SignatureMissingRequiredTag(_) + | IncompatibleVersion + | DomainMismatch + | FromFieldNotSigned + | SignatureExpired + | UnacceptableSignatureHeader + | UnsupportedQueryMethod + | NoKeyForSignature + | KeySyntaxError + | KeyIncompatibleVersion + | InappropriateKeyAlgorithm + | SignatureDidNotVerify + | BodyHashDidNotVerify + | UnsupportedCanonicalizationType(_) + | UnsupportedHashAlgorithm(_) => Status::Permfail, + KeyUnavailable(_) | UnknownInternalError(_) => Status::Tempfail, + } + } +} diff --git a/src/hash.rs b/src/hash.rs new file mode 100644 index 00000000..599cb789 --- /dev/null +++ b/src/hash.rs @@ -0,0 +1,379 @@ +use indexmap::set::IndexSet; +use mailparse::MailHeaderMap; +use slog::debug; + +use crate::canonicalization::{ + self, canonicalize_body_relaxed, canonicalize_body_simple, canonicalize_header_relaxed, + canonicalize_header_simple, +}; +use crate::{bytes, DKIMError, DKIMHeader}; + +#[derive(Debug, Clone)] +pub enum HashAlgo { + RsaSha1, + RsaSha256, +} + +/// Get the body part of an email +fn get_body<'a>(email: &'a mailparse::ParsedMail<'a>) -> &'a [u8] { + let body = email.raw_bytes; + bytes::get_all_after(body, b"\r\n\r\n") +} + +fn hash_sha1>(data: T) -> Vec { + use sha1::{Digest, Sha1}; + + let mut hasher = Sha1::new(); + hasher.update(data); + hasher.finalize().to_vec() +} + +fn hash_sha256>(data: T) -> Vec { + use sha2::{Digest, Sha256}; + + let mut hasher = Sha256::new(); + hasher.update(data); + hasher.finalize().to_vec() +} + +/// Returns the hash of message's body +/// https://datatracker.ietf.org/doc/html/rfc6376#section-3.7 +pub(crate) fn compute_body_hash<'a>( + canonicalization_type: canonicalization::Type, + length: Option, + hash_algo: HashAlgo, + email: &'a mailparse::ParsedMail<'a>, +) -> Result { + let body = get_body(email); + + let mut canonicalized_body = if canonicalization_type == canonicalization::Type::Simple { + canonicalize_body_simple(body) + } else { + canonicalize_body_relaxed(body) + }; + if let Some(length) = length { + let length = length + .parse::() + .map_err(|err| DKIMError::SignatureSyntaxError(format!("invalid length: {}", err)))?; + canonicalized_body.truncate(length); + }; + + let hash = match hash_algo { + HashAlgo::RsaSha1 => hash_sha1(&canonicalized_body), + HashAlgo::RsaSha256 => hash_sha256(&canonicalized_body), + }; + Ok(base64::encode(&hash)) +} + +fn select_headers<'a, 'b>( + headers: &'b str, + email: &'a mailparse::ParsedMail<'a>, +) -> Result, DKIMError> { + let mut signed_headers = vec![]; + + // Transform the header list into a ordered set to deduplicate the headers + // while precerving the order + let headers: IndexSet<&str> = IndexSet::from_iter(headers.split(":")); + + for name in headers { + let name = name.trim(); + if let Some(header) = email.headers.get_first_header(name) { + signed_headers.push((name.to_owned(), header.get_value_raw())); + } + } + + Ok(signed_headers) +} + +pub(crate) fn compute_headers_hash<'a, 'b>( + logger: &slog::Logger, + canonicalization_type: canonicalization::Type, + headers: &'b str, + hash_algo: HashAlgo, + dkim_header: &'b DKIMHeader<'b>, + email: &'a mailparse::ParsedMail<'a>, +) -> Result, DKIMError> { + let mut input = Vec::new(); + + // Add the headers defined in `h=` in the hash + for (key, value) in select_headers(headers, email)? { + let canonicalized_value = if canonicalization_type == canonicalization::Type::Simple { + canonicalize_header_simple(&key, &value) + } else { + canonicalize_header_relaxed(&key, &value) + }; + input.extend_from_slice(&canonicalized_value); + } + + // Add the DKIM-Signature header in the hash. Remove the value of the + // signature (b) first. + { + let sign = dkim_header.get_raw_tag("b").unwrap(); + let value = dkim_header.raw_bytes.replace(&sign, ""); + input.extend_from_slice(&"dkim-signature:".as_bytes()); + input.extend_from_slice(&value.as_bytes()); + } + debug!(logger, "headers to hash: {:?}", input); + + let hash = match hash_algo { + HashAlgo::RsaSha1 => hash_sha1(&input), + HashAlgo::RsaSha256 => hash_sha256(&input), + }; + Ok(hash) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn dkim_header() -> DKIMHeader<'static> { + crate::validate_header("v=1; a=rsa-sha256; q=dns/txt; c=relaxed/relaxed; s=smtp; d=test.com; t=1641506955; h=content-type:to: subject:date:from:mime-version:sender; bh=PU2XIErWsXvhvt1W96ntPWZ2VImjVZ3vBY2T/A+wA3A=; b=PIO0A014nyntOGKdTdtvCJor9ZxvP1M3hoLeEh8HqZ+RvAyEKdAc7VOg+/g/OTaZgsmw6U sZCoN0YNVp+2o9nkaeUslsVz3M4I55HcZnarxl+fhplIMcJ/3s0nIhXL51MfGPRqPbB7/M Gjg9/07/2vFoid6Kitg6Z+CfoD2wlSRa8xDfmeyA2cHpeVuGQhGxu7BXuU8kGbeM4+weit Ql3t9zalhikEPI5Pr7dzYFrgWNOEO6w6rQfG7niKON1BimjdbJlGanC7cO4UL361hhXT4X iXLnC9TG39xKFPT/+4nkHy8pp6YvWkD3wKlBjwkYNm0JvKGwTskCMDeTwxXhAg==").unwrap() + } + + #[test] + fn test_compute_body_hash_simple() { + let email = mailparse::parse_mail( + r#"To: test@sauleau.com +Subject: subject +From: Sven Sauleau + +Hello Alice + "# + .as_bytes(), + ) + .unwrap(); + + let canonicalization_type = canonicalization::Type::Simple; + let length = None; + let hash_algo = HashAlgo::RsaSha1; + assert_eq!( + compute_body_hash( + canonicalization_type.clone(), + length.clone(), + hash_algo, + &email + ) + .unwrap(), + "uoq1oCgLlTqpdDX/iUbLy7J1Wic=" + ); + let hash_algo = HashAlgo::RsaSha256; + assert_eq!( + compute_body_hash(canonicalization_type, length, hash_algo, &email).unwrap(), + "frcCV1k9oG9oKj3dpUqdJg1PxRT2RSN/XKdLCPjaYaY=" + ) + } + + #[test] + fn test_compute_body_hash_relaxed() { + let email = mailparse::parse_mail( + r#"To: test@sauleau.com +Subject: subject +From: Sven Sauleau + +Hello Alice + "# + .as_bytes(), + ) + .unwrap(); + + let canonicalization_type = canonicalization::Type::Relaxed; + let length = None; + let hash_algo = HashAlgo::RsaSha1; + assert_eq!( + compute_body_hash( + canonicalization_type.clone(), + length.clone(), + hash_algo, + &email + ) + .unwrap(), + "2jmj7l5rSw0yVb/vlWAYkK/YBwk=" + ); + let hash_algo = HashAlgo::RsaSha256; + assert_eq!( + compute_body_hash(canonicalization_type, length, hash_algo, &email).unwrap(), + "47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=" + ) + } + + #[test] + fn test_compute_body_hash_length() { + let email = mailparse::parse_mail( + r#"To: test@sauleau.com +Subject: subject +From: Sven Sauleau + +Hello Alice + "# + .as_bytes(), + ) + .unwrap(); + + let canonicalization_type = canonicalization::Type::Relaxed; + let length = Some("3".to_owned()); + let hash_algo = HashAlgo::RsaSha1; + assert_eq!( + compute_body_hash( + canonicalization_type.clone(), + length.clone(), + hash_algo, + &email + ) + .unwrap(), + "2jmj7l5rSw0yVb/vlWAYkK/YBwk=" + ); + let hash_algo = HashAlgo::RsaSha256; + assert_eq!( + compute_body_hash(canonicalization_type, length.clone(), hash_algo, &email).unwrap(), + "47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=" + ) + } + + #[test] + fn test_compute_body_hash_empty_simple() { + let email = mailparse::parse_mail(&[]).unwrap(); + + let canonicalization_type = canonicalization::Type::Simple; + let length = None; + let hash_algo = HashAlgo::RsaSha1; + assert_eq!( + compute_body_hash( + canonicalization_type.clone(), + length.clone(), + hash_algo, + &email + ) + .unwrap(), + "uoq1oCgLlTqpdDX/iUbLy7J1Wic=" + ); + let hash_algo = HashAlgo::RsaSha256; + assert_eq!( + compute_body_hash(canonicalization_type, length.clone(), hash_algo, &email).unwrap(), + "frcCV1k9oG9oKj3dpUqdJg1PxRT2RSN/XKdLCPjaYaY=" + ) + } + + #[test] + fn test_compute_body_hash_empty_relaxed() { + let email = mailparse::parse_mail(&[]).unwrap(); + + let canonicalization_type = canonicalization::Type::Relaxed; + let length = None; + let hash_algo = HashAlgo::RsaSha1; + assert_eq!( + compute_body_hash( + canonicalization_type.clone(), + length.clone(), + hash_algo, + &email + ) + .unwrap(), + "2jmj7l5rSw0yVb/vlWAYkK/YBwk=" + ); + let hash_algo = HashAlgo::RsaSha256; + assert_eq!( + compute_body_hash(canonicalization_type, length.clone(), hash_algo, &email).unwrap(), + "47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=" + ) + } + + #[test] + fn test_compute_headers_hash_simple() { + let email = mailparse::parse_mail( + r#"To: test@sauleau.com +Subject: subject +From: Sven Sauleau + +Hello Alice + "# + .as_bytes(), + ) + .unwrap(); + + let canonicalization_type = canonicalization::Type::Simple; + let hash_algo = HashAlgo::RsaSha1; + let headers = "To: Subject".to_owned(); + let logger = slog::Logger::root(slog::Discard, slog::o!()); + assert_eq!( + compute_headers_hash( + &logger, + canonicalization_type.clone(), + &headers, + hash_algo, + &dkim_header(), + &email + ) + .unwrap(), + &[ + 139, 181, 80, 152, 144, 190, 55, 167, 172, 184, 152, 202, 222, 81, 169, 121, 20, 5, + 213, 151 + ], + ); + let hash_algo = HashAlgo::RsaSha256; + assert_eq!( + compute_headers_hash( + &logger, + canonicalization_type.clone(), + &headers, + hash_algo, + &dkim_header(), + &email + ) + .unwrap(), + &[ + 34, 222, 85, 83, 216, 70, 124, 226, 60, 174, 156, 184, 140, 247, 178, 88, 76, 99, + 182, 251, 149, 224, 243, 172, 54, 202, 138, 72, 45, 45, 88, 9 + ] + ) + } + + #[test] + fn test_compute_headers_hash_relaxed() { + let email = mailparse::parse_mail( + r#"To: test@sauleau.com +Subject: subject +From: Sven Sauleau + +Hello Alice + "# + .as_bytes(), + ) + .unwrap(); + + let canonicalization_type = canonicalization::Type::Relaxed; + let hash_algo = HashAlgo::RsaSha1; + let headers = "To: Subject".to_owned(); + let logger = slog::Logger::root(slog::Discard, slog::o!()); + assert_eq!( + compute_headers_hash( + &logger, + canonicalization_type.clone(), + &headers, + hash_algo, + &dkim_header(), + &email + ) + .unwrap(), + &[ + 14, 171, 230, 1, 77, 117, 47, 207, 243, 167, 179, 5, 150, 82, 154, 25, 125, 124, + 44, 164 + ] + ); + let hash_algo = HashAlgo::RsaSha256; + assert_eq!( + compute_headers_hash( + &logger, + canonicalization_type.clone(), + &headers, + hash_algo, + &dkim_header(), + &email + ) + .unwrap(), + &[ + 45, 186, 211, 81, 49, 111, 18, 147, 180, 245, 207, 39, 9, 9, 118, 137, 248, 204, + 70, 214, 16, 98, 216, 111, 230, 130, 196, 3, 60, 201, 166, 224 + ] + ) + } +} diff --git a/src/header.rs b/src/header.rs new file mode 100644 index 00000000..fb708888 --- /dev/null +++ b/src/header.rs @@ -0,0 +1,28 @@ +use crate::parser; +use std::collections::HashMap; + +pub(crate) const HEADER: &str = "DKIM-Signature"; +pub(crate) const REQUIRED_TAGS: &[&str] = &["v", "a", "b", "bh", "d", "h", "s"]; + +#[derive(Debug)] +pub struct DKIMHeader<'a> { + pub(crate) tags: HashMap, + pub(crate) raw_bytes: &'a str, +} + +impl<'a> DKIMHeader<'a> { + pub(crate) fn get_tag(&self, name: &str) -> Option { + self.tags.get(name).map(|v| v.value.clone()) + } + + pub(crate) fn get_raw_tag(&self, name: &str) -> Option { + self.tags.get(name).map(|v| v.raw_value.clone()) + } + + pub(crate) fn get_required_tag(&self, name: &str) -> String { + // Required tags are guaranteed by the parser to be present so it's safe + // to assert and unwrap. + debug_assert!(REQUIRED_TAGS.contains(&name)); + self.tags.get(name).unwrap().value.clone() + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 00000000..e81b9fae --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,309 @@ +// Implementation of DKIM: https://datatracker.ietf.org/doc/html/rfc6376 + +use rsa::PublicKey; +use slog::debug; +use std::collections::{HashMap, HashSet}; +use std::sync::Arc; +use trust_dns_resolver::TokioAsyncResolver; + +use mailparse::MailHeaderMap; + +#[macro_use] +extern crate quick_error; + +mod bytes; +mod canonicalization; +mod dns; +mod errors; +mod hash; +mod header; +mod parser; +mod public_key; +mod result; + +pub use errors::DKIMError; +use header::*; +pub use parser::tag_list as parse_tag_list; +pub use parser::Tag; +pub use result::DKIMResult; + +const SIGN_EXPIRATION_DRIFT_MINS: i64 = 15; +const DNS_NAMESPACE: &str = "_domainkey"; + +// https://datatracker.ietf.org/doc/html/rfc6376#section-6.1.1 +fn validate_header<'a>(value: &'a str) -> Result, DKIMError> { + let (_, tags) = + parser::tag_list(value).map_err(|err| DKIMError::SignatureSyntaxError(err.to_string()))?; + + // Check presence of required tags + { + let mut tag_names: HashSet = HashSet::new(); + for tag in &tags { + tag_names.insert(tag.name.clone()); + } + for required in REQUIRED_TAGS { + if tag_names.get(*required).is_none() { + return Err(DKIMError::SignatureMissingRequiredTag(required)); + } + } + } + + let mut tags_map = HashMap::new(); + for tag in &tags { + tags_map.insert(tag.name.clone(), tag.clone()); + } + let header = DKIMHeader { + tags: tags_map, + raw_bytes: value, + }; + // FIXME: we could get the keys instead of generating tag_names ourselves + + // Check version + { + let version = header.get_required_tag("v"); + if version != "1" { + return Err(DKIMError::IncompatibleVersion); + } + } + + // Check that "d=" tag is the same as or a parent domain of the domain part + // of the "i=" tag + if let Some(user) = header.get_tag("i") { + let signing_domain = header.get_required_tag("d"); + // TODO: naive check, should switch to parsing the domains/email + if !user.ends_with(&signing_domain) { + return Err(DKIMError::DomainMismatch); + } + } + + // Check that "h=" tag includes the From header + { + let value = header.get_required_tag("h"); + let headers = value.split(":"); + let headers: Vec = headers.map(|h| h.to_lowercase()).collect(); + if !headers.contains(&"from".to_string()) { + return Err(DKIMError::FromFieldNotSigned); + } + } + + if let Some(query_method) = header.get_tag("q") { + if query_method != "dns/txt" { + return Err(DKIMError::UnsupportedQueryMethod); + } + } + + // Check that "x=" tag isn't expired + if let Some(expiration) = header.get_tag("x") { + let mut expiration = + chrono::NaiveDateTime::from_timestamp(expiration.parse::().unwrap_or_default(), 0); + expiration += chrono::Duration::minutes(SIGN_EXPIRATION_DRIFT_MINS); + let now = chrono::Utc::now().naive_utc(); + if now > expiration { + return Err(DKIMError::SignatureExpired); + } + } + + Ok(header) +} + +// https://datatracker.ietf.org/doc/html/rfc6376#section-6.1.3 Step 4 +// TODO: implement verification with ed25519 keys +fn verify_signature( + hash_algo: hash::HashAlgo, + header_hash: Vec, + signature: Vec, + public_key: impl PublicKey, +) -> Result { + Ok(public_key + .verify( + rsa::PaddingScheme::PKCS1v15Sign { + hash: Some(match hash_algo { + hash::HashAlgo::RsaSha1 => rsa::hash::Hash::SHA1, + hash::HashAlgo::RsaSha256 => rsa::hash::Hash::SHA2_256, + }), + }, + &header_hash, + &signature, + ) + .is_ok()) +} + +async fn verify_email_header<'a>( + logger: &'a slog::Logger, + resolver: Arc, + dkim_header: &'a DKIMHeader<'a>, + email: &'a mailparse::ParsedMail<'a>, +) -> Result<(), DKIMError> { + let public_key = public_key::retrieve_public_key( + logger, + Arc::clone(&resolver), + dkim_header.get_required_tag("d"), + dkim_header.get_required_tag("s"), + dkim_header.get_tag("k"), + ) + .await?; + + let (header_canonicalization_type, body_canonicalization_type) = + parser::parse_canonicalization(dkim_header.get_tag("c"))?; + let hash_algo = parser::parse_hash_algo(&dkim_header.get_required_tag("a"))?; + let computed_body_hash = hash::compute_body_hash( + body_canonicalization_type, + dkim_header.get_tag("l"), + hash_algo.clone(), + email, + )?; + let computed_headers_hash = hash::compute_headers_hash( + logger, + header_canonicalization_type, + &dkim_header.get_required_tag("h"), + hash_algo.clone(), + &dkim_header, + email, + )?; + debug!(logger, "body_hash {:?}", computed_body_hash); + + let header_body_hash = dkim_header.get_required_tag("bh").clone(); + if header_body_hash != computed_body_hash { + return Err(DKIMError::BodyHashDidNotVerify); + } + + let signature = base64::decode(dkim_header.get_required_tag("b")).map_err(|err| { + DKIMError::SignatureSyntaxError(format!("failed to decode signature: {}", err)) + })?; + if !verify_signature(hash_algo, computed_headers_hash, signature, public_key)? { + return Err(DKIMError::SignatureDidNotVerify); + } + + Ok(()) +} + +/// Run the DKIM verification on the email +pub async fn verify_email<'a>( + logger: &slog::Logger, + from_domain: &str, + email: &'a mailparse::ParsedMail<'a>, +) -> Result { + let resolver = TokioAsyncResolver::tokio_from_system_conf().map_err(|err| { + DKIMError::UnknownInternalError(format!("failed to create DNS resolver: {}", err)) + })?; + let resolver = dns::from_tokio_resolver(resolver); + + let mut last_error = None; + + for h in email.headers.get_all_headers(HEADER) { + let value = h.get_value(); + debug!(logger, "checking signature {:?}", value); + + let dkim_header = match validate_header(&value) { + Ok(v) => v, + Err(err) => { + debug!(logger, "failed to verify: {}", err); + last_error = Some(err); + continue; + } + }; + + // Select the signature corresponding to the email sender + let signing_domain = dkim_header.get_required_tag("d"); + if signing_domain != from_domain { + continue; + } + + match verify_email_header(logger, Arc::clone(&resolver), &dkim_header, email).await { + Ok(()) => return Ok(DKIMResult::pass(signing_domain)), + Err(err) => { + debug!(logger, "failed to verify: {}", err); + last_error = Some(err); + continue; + } + } + } + + if let Some(err) = last_error { + Ok(DKIMResult::fail(err, from_domain.to_owned())) + } else { + Ok(DKIMResult::neutral(from_domain.to_owned())) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_validate_header() { + let header = r#"v=1; a=rsa-sha256; d=example.net; s=brisbane; +c=relaxed/simple; q=dns/txt; i=foo@eng.example.net; +t=1117574938; x=9118006938; l=200; +h=from:to:subject:date:keywords:keywords; +z=From:foo@eng.example.net|To:joe@example.com| +Subject:demo=20run|Date:July=205,=202005=203:44:08=20PM=20-0700; +bh=MTIzNDU2Nzg5MDEyMzQ1Njc4OTAxMjM0NTY3ODkwMTI=; +b=dzdVyOfAKCdLXdJOc9G2q8LoXSlEniSbav+yuU4zGeeruD00lszZ + VoG4ZHRNiYzR + "#; + validate_header(header).unwrap(); + } + + #[test] + fn test_validate_header_missing_tag() { + let header = "v=1; a=rsa-sha256; bh=a; b=b"; + assert_eq!( + validate_header(header).unwrap_err(), + DKIMError::SignatureMissingRequiredTag("d") + ); + } + + #[test] + fn test_validate_header_domain_mismatch() { + let header = r#"v=1; a=rsa-sha256; d=example.net; s=brisbane; i=foo@hein.com; h=headers; bh=hash; b=hash + "#; + assert_eq!( + validate_header(header).unwrap_err(), + DKIMError::DomainMismatch + ); + } + + #[test] + fn test_validate_header_incompatible_version() { + let header = r#"v=3; a=rsa-sha256; d=example.net; s=brisbane; i=foo@example.net; h=headers; bh=hash; b=hash + "#; + assert_eq!( + validate_header(header).unwrap_err(), + DKIMError::IncompatibleVersion + ); + } + + #[test] + fn test_validate_header_missing_from_in_headers_signature() { + let header = r#"v=1; a=rsa-sha256; d=example.net; s=brisbane; i=foo@example.net; h=Subject:A:B; bh=hash; b=hash + "#; + assert_eq!( + validate_header(header).unwrap_err(), + DKIMError::FromFieldNotSigned + ); + } + + #[test] + fn test_validate_header_expired_in_drift() { + let mut now = chrono::Utc::now().naive_utc(); + now -= chrono::Duration::seconds(1); + + let header = format!("v=1; a=rsa-sha256; d=example.net; s=brisbane; i=foo@example.net; h=From:B; bh=hash; b=hash; x={}", now.timestamp()); + + assert!(validate_header(&header).is_ok()); + } + + #[test] + fn test_validate_header_expired() { + let mut now = chrono::Utc::now().naive_utc(); + now -= chrono::Duration::hours(3); + + let header = format!("v=1; a=rsa-sha256; d=example.net; s=brisbane; i=foo@example.net; h=From:B; bh=hash; b=hash; x={}", now.timestamp()); + + assert_eq!( + validate_header(&header).unwrap_err(), + DKIMError::SignatureExpired + ); + } +} diff --git a/src/parser.rs b/src/parser.rs new file mode 100644 index 00000000..d698342a --- /dev/null +++ b/src/parser.rs @@ -0,0 +1,240 @@ +use crate::{canonicalization, hash, DKIMError}; +use nom::bytes::complete::tag; +use nom::bytes::complete::take_while1; +use nom::character::complete::alpha1; +use nom::combinator::opt; +use nom::multi::fold_many0; +use nom::sequence::delimited; +use nom::sequence::pair; +use nom::sequence::preceded; +use nom::sequence::terminated; +use nom::IResult; + +#[derive(Clone, Debug, PartialEq)] +/// DKIM signature tag +pub struct Tag { + /// Name of the tag (v, i, a, h, ...) + pub name: String, + /// Value of the tag with spaces removed + pub value: String, + /// Value of the tag as seen in the text + pub raw_value: String, +} + +/// Main entrypoint of the parser. Parses the DKIM signature tag list +/// as specified https://datatracker.ietf.org/doc/html/rfc6376#section-3.6.1. +/// tag-list = tag-spec *( ";" tag-spec ) [ ";" ] +pub fn tag_list(input: &str) -> IResult<&str, Vec> { + let (input, start) = tag_spec(input)?; + + terminated( + fold_many0( + preceded(tag(";"), tag_spec), + move || vec![start.clone()], + |mut acc: Vec, item| { + acc.push(item); + acc + }, + ), + opt(tag(";")), + )(input) +} + +/// tag-spec = [FWS] tag-name [FWS] "=" [FWS] tag-value [FWS] +fn tag_spec(input: &str) -> IResult<&str, Tag> { + let (input, name) = delimited(opt(fws), tag_name, opt(fws))(input)?; + let (input, _) = tag("=")(input)?; + + // Parse the twice to keep the original text + let value_input = input; + let (_, raw_value) = delimited(opt(fws), raw_tag_value, opt(fws))(value_input)?; + let (input, value) = delimited(opt(fws), tag_value, opt(fws))(value_input)?; + + Ok(( + input, + Tag { + name: name.to_owned(), + value, + raw_value, + }, + )) +} + +/// tag-name = ALPHA *ALNUMPUNC +/// ALNUMPUNC = ALPHA / DIGIT / "_" +fn tag_name(input: &str) -> IResult<&str, &str> { + alpha1(input) +} + +/// tag-value = [ tval *( 1*(WSP / FWS) tval ) ] +/// tval = 1*VALCHAR +/// VALCHAR = %x21-3A / %x3C-7E +fn tag_value(input: &str) -> IResult<&str, String> { + let is_valchar = |c| (c >= '!' && c <= ':') || (c >= '<' && c <= '~'); + match opt(take_while1(is_valchar))(input)? { + (input, Some(start)) => fold_many0( + preceded(fws, take_while1(is_valchar)), + || start.to_owned(), + |mut acc: String, item| { + acc += item; + acc + }, + )(input), + (input, None) => Ok((input, "".to_string())), + } +} + +fn raw_tag_value(input: &str) -> IResult<&str, String> { + let is_valchar = |c| (c >= '!' && c <= ':') || (c >= '<' && c <= '~'); + match opt(take_while1(is_valchar))(input)? { + (input, Some(start)) => fold_many0( + pair(fws, take_while1(is_valchar)), + || start.to_owned(), + |mut acc: String, item| { + acc += &(item.0.to_owned() + item.1); + acc + }, + )(input), + (input, None) => Ok((input, "".to_string())), + } +} + +/// FWS is folding whitespace. It allows multiple lines separated by CRLF followed by at least one whitespace, to be joined. +fn fws(input: &str) -> IResult<&str, &str> { + take_while1(|c| c == ' ' || c == '\t' || c == '\r' || c == '\n')(input) +} + +pub(crate) fn parse_hash_algo(value: &String) -> Result { + use hash::HashAlgo; + match value.as_str() { + "rsa-sha1" => Ok(HashAlgo::RsaSha1), + "rsa-sha256" => Ok(HashAlgo::RsaSha256), + e => Err(DKIMError::UnsupportedHashAlgorithm(e.to_string())), + } +} + +/// Parses the canonicalization value (passed in c=) and returns canonicalization +/// for (Header, Body) +pub(crate) fn parse_canonicalization( + value: Option, +) -> Result<(canonicalization::Type, canonicalization::Type), DKIMError> { + use canonicalization::Type::{Relaxed, Simple}; + if value.is_none() { + return Ok((Simple, Simple)); + } + match value.unwrap().as_str() { + "simple/simple" => Ok((Simple, Simple)), + "relaxed/simple" => Ok((Relaxed, Simple)), + "simple/relaxed" => Ok((Simple, Relaxed)), + "relaxed/relaxed" => Ok((Relaxed, Relaxed)), + "relaxed" => Ok((Relaxed, Simple)), + "simple" => Ok((Simple, Simple)), + v => Err(DKIMError::UnsupportedCanonicalizationType(v.to_owned())), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_canonicalization_empty() { + use canonicalization::Type::Simple; + assert_eq!(parse_canonicalization(None).unwrap(), (Simple, Simple)); + } + + #[test] + fn test_canonicalization_one_algo() { + use canonicalization::Type::{Relaxed, Simple}; + + assert_eq!( + parse_canonicalization(Some("simple".to_string())).unwrap(), + (Simple, Simple) + ); + assert_eq!( + parse_canonicalization(Some("relaxed".to_string())).unwrap(), + (Relaxed, Simple) + ); + } + + #[test] + fn test_tag_list() { + assert_eq!( + tag_list("a = a/1@.-:= ").unwrap(), + ( + "", + vec![Tag { + name: "a".to_string(), + value: "a/1@.-:=".to_string(), + raw_value: "a/1@.-:=".to_string() + }] + ) + ); + assert_eq!( + tag_list("a= a ; b = a\n bc").unwrap(), + ( + "", + vec![ + Tag { + name: "a".to_string(), + value: "a".to_string(), + raw_value: "a".to_string() + }, + Tag { + name: "b".to_string(), + value: "abc".to_string(), + raw_value: "a\n bc".to_string() + } + ] + ) + ); + } + + #[test] + fn test_tag_spec() { + assert_eq!( + tag_spec("a=b").unwrap(), + ( + "", + Tag { + name: "a".to_string(), + value: "b".to_string(), + raw_value: "b".to_string() + } + ) + ); + assert_eq!( + tag_spec("a=b c d e f").unwrap(), + ( + "", + Tag { + name: "a".to_string(), + value: "bcdef".to_string(), + raw_value: "b c d e f".to_string() + } + ) + ); + } + + #[test] + fn test_tag_list_dns() { + assert_eq!( + tag_list("k=rsa; p=kEy+/").unwrap(), + ( + "", + vec![ + Tag { + name: "k".to_string(), + value: "rsa".to_string(), + raw_value: "rsa".to_string() + }, + Tag { + name: "p".to_string(), + value: "kEy+/".to_string(), + raw_value: "kEy+/".to_string() + } + ] + ) + ); + } +} diff --git a/src/public_key.rs b/src/public_key.rs new file mode 100644 index 00000000..721dd323 --- /dev/null +++ b/src/public_key.rs @@ -0,0 +1,148 @@ +use rsa::{pkcs8, RsaPublicKey}; +use slog::{debug, warn}; +use std::collections::HashMap; +use std::sync::Arc; + +use crate::{dns, parser, DKIMError, DNS_NAMESPACE}; + +// https://datatracker.ietf.org/doc/html/rfc6376#section-6.1.2 +pub(crate) async fn retrieve_public_key( + logger: &slog::Logger, + resolver: Arc, + domain: String, + subdomain: String, + key_type: Option, +) -> Result { + let dns_name = format!("{}.{}.{}", subdomain, DNS_NAMESPACE, domain); + let res = resolver.lookup_txt(&dns_name).await?; + // TODO: Return multiple keys for when verifiying the signatures. During key + // rotation they are often multiple keys to consider. + let txt = res.first().ok_or(DKIMError::NoKeyForSignature)?; + debug!(logger, "DKIM TXT: {:?}", txt); + + // Parse the tags inside the DKIM TXT DNS record + let (_, tags) = parser::tag_list(txt).map_err(|err| { + warn!(logger, "key syntax error: {}", err); + DKIMError::KeySyntaxError + })?; + + let mut tags_map = HashMap::new(); + for tag in &tags { + tags_map.insert(tag.name.clone(), tag.clone()); + } + + // Check version + if let Some(version) = tags_map.get("v") { + if version.value != "DKIM1" { + return Err(DKIMError::KeyIncompatibleVersion); + } + } + + // Check key has right type + if let Some(v) = tags_map.get("k") { + let key_type = key_type.unwrap_or_else(|| "rsa".to_string()); + if v.value != key_type { + return Err(DKIMError::InappropriateKeyAlgorithm); + } + } + + let tag = tags_map.get("p").ok_or(DKIMError::NoKeyForSignature)?; + let bytes = base64::decode(&tag.value).map_err(|err| { + DKIMError::KeyUnavailable(format!("failed to decode public key: {}", err)) + })?; + let key = pkcs8::FromPublicKey::from_public_key_der(&bytes) + .map_err(|err| DKIMError::KeyUnavailable(format!("failed to parse public key: {}", err)))?; + Ok(key) +} + +#[cfg(test)] +mod tests { + use super::*; + use futures::future::BoxFuture; + + #[tokio::test] + async fn test_retrieve_public_key() { + struct TestResolver {} + impl dns::Lookup for TestResolver { + fn lookup_txt<'a>( + &'a self, + name: &'a str, + ) -> BoxFuture<'a, Result, DKIMError>> { + Box::pin(async move { + assert_eq!(name, "dkim._domainkey.cloudflare.com"); + Ok(vec!["v=DKIM1; k=rsa; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA6gmVDBSBJ0l1/33uAF0gwIsrjQV6nnYjL9DMX6+ez4NNJ2um0InYy128Rd+OlIhmdSld6g3tj3O6R+BwsYsQgU8RWE8VJaRybvPw2P3Asgms4uPrFWHSFiWMPH0P9i/oPwnUO9jZKHiz4+MzFC3bG8BacX7YIxCuWnDU8XNmNsRaLmrv9CHX4/3GHyoHSmDA1ETtyz9JHRCOC8ho8C7b4f2Auwedlau9Lid9LGBhozhgRFhrFwFMe93y34MO1clPbY6HwxpudKWBkMQCTlmXVRnkKxHlJ+fYCyC2jjpCIbGWj2oLxBtFOASWMESR4biW0ph2bsZXslcUSPMTVTkFxQIDAQAB".to_string()]) + }) + } + } + let resolver = Arc::new(TestResolver {}); + let logger = slog::Logger::root(slog::Discard, slog::o!()); + + retrieve_public_key( + &logger, + resolver, + "cloudflare.com".to_string(), + "dkim".to_string(), + None, + ) + .await + .unwrap(); + } + + #[tokio::test] + async fn test_retrieve_public_key_incompatible_version() { + struct TestResolver {} + impl dns::Lookup for TestResolver { + fn lookup_txt<'a>( + &'a self, + name: &'a str, + ) -> BoxFuture<'a, Result, DKIMError>> { + Box::pin(async move { + assert_eq!(name, "dkim._domainkey.cloudflare.com"); + Ok(vec!["v=DKIM6; p=key".to_string()]) + }) + } + } + let resolver = Arc::new(TestResolver {}); + let logger = slog::Logger::root(slog::Discard, slog::o!()); + + let key = retrieve_public_key( + &logger, + resolver, + "cloudflare.com".to_string(), + "dkim".to_string(), + None, + ) + .await + .unwrap_err(); + assert_eq!(key, DKIMError::KeyIncompatibleVersion); + } + + #[tokio::test] + async fn test_retrieve_public_key_inappropriate_key_algorithm() { + struct TestResolver {} + impl dns::Lookup for TestResolver { + fn lookup_txt<'a>( + &'a self, + name: &'a str, + ) -> BoxFuture<'a, Result, DKIMError>> { + Box::pin(async move { + assert_eq!(name, "dkim._domainkey.cloudflare.com"); + Ok(vec!["v=DKIM1; p=key; k=foo".to_string()]) + }) + } + } + let resolver = Arc::new(TestResolver {}); + let logger = slog::Logger::root(slog::Discard, slog::o!()); + + let key = retrieve_public_key( + &logger, + resolver, + "cloudflare.com".to_string(), + "dkim".to_string(), + None, + ) + .await + .unwrap_err(); + assert_eq!(key, DKIMError::InappropriateKeyAlgorithm); + } +} diff --git a/src/result.rs b/src/result.rs new file mode 100644 index 00000000..3f0aa25f --- /dev/null +++ b/src/result.rs @@ -0,0 +1,59 @@ +use crate::DKIMError; + +#[derive(Clone)] +/// Result of the DKIM verification +pub struct DKIMResult { + value: &'static str, + error: Option, + domain_used: String, +} +impl DKIMResult { + /// Constructs a `pass` result + pub fn pass(domain_used: String) -> Self { + DKIMResult { + value: "pass", + error: None, + domain_used, + } + } + /// Constructs a `neutral` result + pub fn neutral(domain_used: String) -> Self { + DKIMResult { + value: "neutral", + error: None, + domain_used, + } + } + /// Constructs a `fail` result with a reason + pub fn fail(reason: DKIMError, domain_used: String) -> Self { + DKIMResult { + value: "fail", + error: Some(reason), + domain_used, + } + } + + pub fn error(&self) -> Option { + self.error.clone() + } + + /// Returns the domain used to pass the DKIM verification + pub fn domain_used(&self) -> String { + self.domain_used.to_lowercase() + } + + /// Returns the verification result as a summary: fail, neutral or pass. + pub fn summary(&self) -> &'static str { + self.value + } + + /// Similar to `summary` but with detail on fail. Typically used for the + /// `Authentication-Results` header. + pub fn with_detail(&self) -> String { + if let Some(err) = self.error() { + format!("{} ({})", self.value, err) + } else { + self.value.to_owned() + } + } +}