From efd5dd990250f9c60dd523d9ede646a26f9c6416 Mon Sep 17 00:00:00 2001 From: Erik Hollensbe Date: Wed, 16 Feb 2022 17:02:02 -0800 Subject: [PATCH] Initial commit Signed-off-by: Erik Hollensbe --- .gitignore | 5 + Cargo.lock | 2107 ++++++++++++++++++++++++++++++++ Cargo.toml | 51 + LICENSE.txt | 26 + Makefile | 10 + README.md | 113 ++ dockerfiles/Dockerfile.zlint | 12 + dockerfiles/Makefile | 6 + hack/pg_hba.conf | 3 + migrations/.gitkeep | 0 migrations/V1__init.sql | 86 ++ src/acme/ca.rs | 333 +++++ src/acme/challenge.rs | 324 +++++ src/acme/dns.rs | 73 ++ src/acme/handlers/account.rs | 316 +++++ src/acme/handlers/directory.rs | 146 +++ src/acme/handlers/mod.rs | 246 ++++ src/acme/handlers/nonce.rs | 173 +++ src/acme/handlers/order.rs | 708 +++++++++++ src/acme/jose/crypto/mod.rs | 8 + src/acme/jose/mod.rs | 602 +++++++++ src/acme/mod.rs | 165 +++ src/acmed.rs | 146 +++ src/errors/acme.rs | 80 ++ src/errors/db.rs | 132 ++ src/errors/mod.rs | 495 ++++++++ src/lib.rs | 6 + src/models/account.rs | 635 ++++++++++ src/models/mod.rs | 135 ++ src/models/nonce.rs | 117 ++ src/models/order.rs | 1070 ++++++++++++++++ src/test/mod.rs | 473 +++++++ src/util/mod.rs | 24 + 33 files changed, 8826 insertions(+) create mode 100644 .gitignore create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 LICENSE.txt create mode 100644 Makefile create mode 100644 README.md create mode 100644 dockerfiles/Dockerfile.zlint create mode 100644 dockerfiles/Makefile create mode 100644 hack/pg_hba.conf create mode 100644 migrations/.gitkeep create mode 100644 migrations/V1__init.sql create mode 100644 src/acme/ca.rs create mode 100644 src/acme/challenge.rs create mode 100644 src/acme/dns.rs create mode 100644 src/acme/handlers/account.rs create mode 100644 src/acme/handlers/directory.rs create mode 100644 src/acme/handlers/mod.rs create mode 100644 src/acme/handlers/nonce.rs create mode 100644 src/acme/handlers/order.rs create mode 100644 src/acme/jose/crypto/mod.rs create mode 100644 src/acme/jose/mod.rs create mode 100644 src/acme/mod.rs create mode 100644 src/acmed.rs create mode 100644 src/errors/acme.rs create mode 100644 src/errors/db.rs create mode 100644 src/errors/mod.rs create mode 100644 src/lib.rs create mode 100644 src/models/account.rs create mode 100644 src/models/mod.rs create mode 100644 src/models/nonce.rs create mode 100644 src/models/order.rs create mode 100644 src/test/mod.rs create mode 100644 src/util/mod.rs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..20de8e3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +/target +coyote.db +error.log +ca.{pem,key} +caddy diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..cbaa1bc --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,2107 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "aho-corasick" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e37cfd5e7657ada45f742d6e99ca5788580b5c529dc78faf11ece6dc702656f" +dependencies = [ + "memchr", +] + +[[package]] +name = "async-recursion" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cda8f4bcc10624c4e85bc66b3f452cca98cfa5ca002dc83a16aad2367641bea" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[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 = "atty" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8" +dependencies = [ + "hermit-abi", + "libc", + "winapi", +] + +[[package]] +name = "autocfg" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa" + +[[package]] +name = "base64" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "904dfeac50f3cdaba28fc6f57fdcddb75f49ed61346676a78c4ffe55877802fd" + +[[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.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf7fe51849ea569fd452f37822f606a5cabb684dc918707a0193fd4664ff324" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bollard" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92fed694fd5a7468c971538351c61b9c115f1ae6ed411cd2800f0f299403a4b" +dependencies = [ + "base64", + "bollard-stubs", + "bytes", + "chrono", + "dirs-next", + "futures-core", + "futures-util", + "hex", + "http", + "hyper", + "hyperlocal", + "log", + "pin-project", + "serde", + "serde_derive", + "serde_json", + "serde_urlencoded", + "thiserror", + "tokio", + "tokio-util", + "url", + "winapi", +] + +[[package]] +name = "bollard-stubs" +version = "1.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2f2e73fffe9455141e170fb9c1feb0ac521ec7e7dcd47a7cab72a658490fb8" +dependencies = [ + "chrono", + "serde", + "serde_with", +] + +[[package]] +name = "bumpalo" +version = "3.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4a45a46ab1f2412e53d3a0ade76ffad2025804294569aae387231a0cd6e0899" + +[[package]] +name = "byteorder" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14c189c53d098945499cdfa7ecc63567cf3886b3332b312a5b4585d8d3a6a610" + +[[package]] +name = "bytes" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4872d67bab6358e59559027aa3b9157c53d9358c51423c17554809a8858e0f8" + +[[package]] +name = "cc" +version = "1.0.73" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fff2a6927b3bb87f9595d67196a70493f627687a71d87a0d692242c33f58c11" + +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + +[[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", + "serde", + "time 0.1.43", + "winapi", +] + +[[package]] +name = "coyote" +version = "0.1.0" +dependencies = [ + "async-trait", + "base64", + "bollard", + "chrono", + "deadpool-postgres", + "eggshell", + "env_logger", + "futures", + "futures-core", + "http", + "hyper", + "lazy_static", + "log", + "openssl", + "rand 0.8.5", + "ratpack", + "refinery", + "rustls", + "rustls-pemfile", + "serde", + "serde_json", + "spectral", + "tempfile", + "thiserror", + "tokio", + "tokio-postgres", + "trust-dns-client", + "url", + "webpki-roots", + "x509-parser", +] + +[[package]] +name = "cpufeatures" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95059428f66df56b63431fdb4e1947ed2190586af5c5a8a8b71122bdf5a7f469" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57952ca27b5e3606ff4dd79b0020231aaf9d6aa76dc05fd30137538c50bd3ce8" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "darling" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0d720b8683f8dd83c65155f0530560cba68cd2bf395f6513a483caee57ff7f4" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a340f241d2ceed1deb47ae36c4144b2707ec7dd0b649f894cb39bb595986324" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72c41b3b7352feb3211a0d743dc5700a4e3b60f51bd2b368892d1e0f9a95f44b" +dependencies = [ + "darling_core", + "quote", + "syn", +] + +[[package]] +name = "data-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ee2393c4a91429dffb4bedf19f4d6abf27d8a732c8ce4980305d782e5426d57" + +[[package]] +name = "deadpool" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3bf0c5365c0925c80a838a6810a1bf38d3304ca6b4eb25829e29e33da12de786" +dependencies = [ + "async-trait", + "deadpool-runtime", + "num_cpus", + "serde", + "tokio", +] + +[[package]] +name = "deadpool-postgres" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46ff1451a33b8b31b15eedcf5401dbbb28606caed4fa94d20487eb3fac2ebd04" +dependencies = [ + "deadpool", + "log", + "serde", + "tokio", + "tokio-postgres", +] + +[[package]] +name = "deadpool-runtime" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaa37046cc0f6c3cc6090fbdbf73ef0b8ef4cfcc37f6befc0020f63e8cf121e1" +dependencies = [ + "tokio", +] + +[[package]] +name = "der-oid-macro" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c73af209b6a5dc8ca7cbaba720732304792cddc933cfea3d74509c2b1ef2f436" +dependencies = [ + "num-bigint 0.4.3", + "num-traits", + "syn", +] + +[[package]] +name = "der-parser" +version = "6.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cddf120f700b411b2b02ebeb7f04dc0b7c8835909a6c2f52bf72ed0dd3433b2" +dependencies = [ + "der-oid-macro", + "nom", + "num-bigint 0.4.3", + "num-traits", + "rusticata-macros", +] + +[[package]] +name = "digest" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2fb860ca6fafa5552fb6d0e816a69c8e49f0908bf524e30a90d97c85892d506" +dependencies = [ + "block-buffer", + "crypto-common", + "subtle", +] + +[[package]] +name = "dirs-next" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b98cf8ebf19c3d1b223e151f99a4f9f0690dca41414773390fc824184ac833e1" +dependencies = [ + "cfg-if", + "dirs-sys-next", +] + +[[package]] +name = "dirs-sys-next" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ebda144c4fe02d1f7ea1a7d9641b6fc6b580adcfa024ae48797ecdeb6825b4d" +dependencies = [ + "libc", + "redox_users", + "winapi", +] + +[[package]] +name = "eggshell" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e0d7fdcd264498c248c95c915cd7c2d3b4f0bea65aa112d874d1a92fa278be1" +dependencies = [ + "async-trait", + "bollard", + "lazy_static", + "thiserror", + "tokio", +] + +[[package]] +name = "endian-type" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c34f04666d835ff5d62e058c3995147c06f42fe86ff053337632bca83e42702d" + +[[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 = "env_logger" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b2cf0344971ee6c64c31be0d530793fba457d322dfec2810c453d0ef228f9c3" +dependencies = [ + "atty", + "humantime", + "log", + "regex", + "termcolor", +] + +[[package]] +name = "fallible-iterator" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4443176a9f2c162692bd3d352d745ef9413eec5782a80d8fd6f8a1ac692a07f7" + +[[package]] +name = "fastrand" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3fcf0cee53519c866c09b5de1f6c56ff9d647101f81c1964fa632e148896cdf" +dependencies = [ + "instant", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[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 = "fuchsia-cprng" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a06f77d526c1a601b7c4cdd98f54b5eaabffc14d5f2f0296febdc7f357c6d3ba" + +[[package]] +name = "futures" +version = "0.3.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f73fe65f54d1e12b726f517d3e2135ca3125a437b6d998caf1962961f7172d9e" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3083ce4b914124575708913bca19bfe887522d6e2e6d0952943f5eac4a74010" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c09fd04b7e4073ac7156a9539b57a484a8ea920f79c7c675d05d289ab6110d3" + +[[package]] +name = "futures-executor" +version = "0.3.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9420b90cfa29e327d0429f19be13e7ddb68fa1cccb09d65e5706b8c7a749b8a6" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc4045962a5a5e935ee2fdedaa4e08284547402885ab326734432bed5d12966b" + +[[package]] +name = "futures-macro" +version = "0.3.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33c1e13800337f4d4d7a316bf45a567dbcb6ffe087f16424852d97e97a91f512" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21163e139fa306126e6eedaf49ecdb4588f939600f0b1e770f4205ee4b7fa868" + +[[package]] +name = "futures-task" +version = "0.3.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c66a976bf5909d801bbef33416c41372779507e7a6b3a5e25e4749c58f776a" + +[[package]] +name = "futures-util" +version = "0.3.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b7abd5d659d9b90c8cba917f6ec750a74e2dc23902ef9cd4cc8c8b22e6036a" +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 = "h2" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9f1f717ddc7b2ba36df7e871fd88db79326551d3d6f1fc406fbfd28b582ff8e" +dependencies = [ + "bytes", + "fnv", + "futures-core", + "futures-sink", + "futures-util", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[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 = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hmac" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddca131f3e7f2ce2df364b57949a9d47915cfbd35e46cfee355ccebbf794d6a2" +dependencies = [ + "digest", +] + +[[package]] +name = "http" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31f4c6746584866f0feabcc69893c5b51beef3831656a968ed7ae254cdc4fd03" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[package]] +name = "http-body" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ff4f84919677303da5f147645dbea6b1881f368d03ac84e1dc09031ebd7b2c6" +dependencies = [ + "bytes", + "http", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9100414882e15fb7feccb4897e5f0ff0ff1ca7d1a86a23208ada4d7a18e6c6c4" + +[[package]] +name = "httpdate" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4a1e36c821dbe04574f602848a19f742f4fb3c98d40449f11bcad18d6b17421" + +[[package]] +name = "humantime" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a3a5bfb195931eeb336b2a7b4d761daec841b97f947d34394601737a7bba5e4" + +[[package]] +name = "hyper" +version = "0.14.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "043f0e083e9901b6cc658a77d1eb86f4fc650bbb977a4337dd63192826aa85dd" +dependencies = [ + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", + "want", +] + +[[package]] +name = "hyperlocal" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fafdf7b2b2de7c9784f76e02c0935e65a8117ec3b768644379983ab333ac98c" +dependencies = [ + "futures-util", + "hex", + "hyper", + "pin-project", + "tokio", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[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", + "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 = "ipnet" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68f2d64f2edebec4ce84ad108148e67e1064789bee435edc5b60ad398714a3a9" + +[[package]] +name = "itoa" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aab8fc367588b89dcee83ab0fd66b72b50b72fa1904d7095045ace2b0c81c35" + +[[package]] +name = "js-sys" +version = "0.3.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a38fc24e30fd564ce974c02bf1d337caddff65be6cc4735a1f7eab22a7440f04" +dependencies = [ + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" + +[[package]] +name = "libc" +version = "0.2.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06e509672465a0504304aa87f9f176f2b2b716ed8fb105ebe5c02dc6dce96a94" + +[[package]] +name = "lock_api" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88943dd7ef4a2e5a4bfa2753aaab3013e34ce2533d1996fb18ef591e315e2b3b" +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 = "matches" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3e378b66a060d48947b590737b30a1be76706c8dd7b8ba0f2fe3989c68a853f" + +[[package]] +name = "md-5" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6a38fc55c8bbc10058782919516f88826e70320db6d206aebc49611d24216ae" +dependencies = [ + "digest", +] + +[[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.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba272f85fa0b41fc91872be579b3bbe0f56b792aa361a380eb669469f68dafb2" +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 = "nibble_vec" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77a5d83df9f36fe23f0c3648c6bbb8b0298bb5f1939c8f2704431371f4b84d43" +dependencies = [ + "smallvec", +] + +[[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.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c28774a7fd2fbb4f0babd8237ce554b73af68021b5f695a3cebd6c59bac0980f" +dependencies = [ + "winapi", +] + +[[package]] +name = "num" +version = "0.1.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4703ad64153382334aa8db57c637364c322d3372e097840c72000dabdcf6156e" +dependencies = [ + "num-bigint 0.1.44", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + +[[package]] +name = "num-bigint" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e63899ad0da84ce718c14936262a41cee2c79c981fc0a0e7c7beb47d5a07e8c1" +dependencies = [ + "num-integer", + "num-traits", + "rand 0.4.6", + "rustc-serialize", +] + +[[package]] +name = "num-bigint" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f93ab6289c7b344a8a9f60f88d80aa20032336fe78da341afc91c8a2341fc75f" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-complex" +version = "0.1.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b288631d7878aaf59442cffd36910ea604ecd7745c36054328595114001c9656" +dependencies = [ + "num-traits", + "rustc-serialize", +] + +[[package]] +name = "num-integer" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2cc698a63b549a70bc047073d2949cce27cd1c7b0a4a862d08a8031bc2801db" +dependencies = [ + "autocfg", + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2021c8337a54d21aca0d59a92577a029af9431cb59b909b03252b9c164fad59" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.1.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee314c74bd753fc86b4780aa9475da469155f3848473a261d2d18e35245a784e" +dependencies = [ + "num-bigint 0.1.44", + "num-integer", + "num-traits", + "rustc-serialize", +] + +[[package]] +name = "num-traits" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a64b1ec5cda2586e284722486d802acf1f7dbdc623e2bfc57e65ca1cd099290" +dependencies = [ + "autocfg", +] + +[[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 = "num_threads" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97ba99ba6393e2c3734791401b66902d981cb03bf190af674ca69949b6d5fb15" +dependencies = [ + "libc", +] + +[[package]] +name = "oid-registry" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe554cb2393bc784fd678c82c84cc0599c31ceadc7f03a594911f822cb8d1815" +dependencies = [ + "der-parser", +] + +[[package]] +name = "once_cell" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da32515d9f6e6e489d7bc9d84c71b060db7247dc035bbe44eac88cf87486d8d5" + +[[package]] +name = "openssl" +version = "0.10.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c7ae222234c30df141154f159066c5093ff73b63204dcda7121eb082fc56a95" +dependencies = [ + "bitflags", + "cfg-if", + "foreign-types", + "libc", + "once_cell", + "openssl-sys", +] + +[[package]] +name = "openssl-sys" +version = "0.9.72" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e46109c383602735fa0a2e48dd2b7c892b048e1bf69e5c3b1d804b7d9c203cb" +dependencies = [ + "autocfg", + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[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 0.8.5", +] + +[[package]] +name = "parking_lot" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87f5ec2493a61ac0506c0f4199f99070cbe83857b0337006a30f3e6719b8ef58" +dependencies = [ + "lock_api", + "parking_lot_core 0.9.1", +] + +[[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 = "parking_lot_core" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28141e0cc4143da2443301914478dc976a61ffdb3f043058310c70df2fed8954" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-sys", +] + +[[package]] +name = "percent-encoding" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4fd5641d01c8f18a23da7b6fe29298ff4b55afcccdf78973b24cf3175fee32e" + +[[package]] +name = "phf" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fabbf1ead8a5bcbc20f5f8b939ee3f5b0f6f281b6ad3468b84656b658b455259" +dependencies = [ + "phf_shared", +] + +[[package]] +name = "phf_shared" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6796ad771acdc0123d2a88dc428b5e38ef24456743ddb1744ed628f9815c096" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "58ad3879ad3baf4e44784bc6a718a8698867bb991f8ce24d1bcbe2cfb4c3a75e" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "744b6f092ba29c3650faf274db506afd39944f48420f6c86b17cfe0ee1cb36bb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[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 = "pkg-config" +version = "0.3.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "58893f751c9b0412871a09abd62ecd2a00298c6c83befa223ef98c52aef40cbe" + +[[package]] +name = "postgres-protocol" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79ec03bce71f18b4a27c4c64c6ba2ddf74686d69b91d8714fb32ead3adaed713" +dependencies = [ + "base64", + "byteorder", + "bytes", + "fallible-iterator", + "hmac", + "md-5", + "memchr", + "rand 0.8.5", + "sha2", + "stringprep", +] + +[[package]] +name = "postgres-types" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04619f94ba0cc80999f4fc7073607cb825bc739a883cb6d20900fc5e009d6b0d" +dependencies = [ + "bytes", + "chrono", + "fallible-iterator", + "postgres-protocol", + "serde", + "serde_json", +] + +[[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 = "quote" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "864d3e96a899863136fc6e99f3d7cae289dafe43bf2c5ac19b70df7210c0a145" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "radix_trie" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c069c179fcdc6a2fe24d8d18305cf085fdbd4f922c041943e203685d6a1c58fd" +dependencies = [ + "endian-type", + "nibble_vec", +] + +[[package]] +name = "rand" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "552840b97013b1a26992c11eac34bdd778e464601a4c2054b5f0bff7c6761293" +dependencies = [ + "fuchsia-cprng", + "libc", + "rand_core 0.3.1", + "rdrand", + "winapi", +] + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha", + "rand_core 0.6.3", +] + +[[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 0.6.3", +] + +[[package]] +name = "rand_core" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a6fdeb83b075e8266dcc8762c22776f6877a63111121f5f8c7411e5be7eed4b" +dependencies = [ + "rand_core 0.4.2", +] + +[[package]] +name = "rand_core" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c33a3c44ca05fa6f1807d8e6743f3824e8509beca625669633be0acbdf509dc" + +[[package]] +name = "rand_core" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d34f1408f55294453790c48b2f1ebbb1c5b4b7563eb1f418bcfcfdbb06ebb4e7" +dependencies = [ + "getrandom", +] + +[[package]] +name = "ratpack" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5a13f3016fde9fbad50dd0f7a2cef95b421224908dbe68125a4f5b447bdecb7" +dependencies = [ + "async-recursion", + "http", + "hyper", + "tokio", + "tokio-rustls", + "webpki", +] + +[[package]] +name = "rdrand" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "678054eb77286b51581ba43620cc911abf02758c91f93f479767aed0f90458b2" +dependencies = [ + "rand_core 0.3.1", +] + +[[package]] +name = "redox_syscall" +version = "0.2.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8383f39639269cde97d255a32bdb68c047337295414940c68bdd30c2e13203ff" +dependencies = [ + "bitflags", +] + +[[package]] +name = "redox_users" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "528532f3d801c87aec9def2add9ca802fe569e44a544afe633765267840abe64" +dependencies = [ + "getrandom", + "redox_syscall", +] + +[[package]] +name = "refinery" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f02bce2299450431623ef714bef438f1ae9badfb123ef23b114f0ef4300237f" +dependencies = [ + "refinery-core", + "refinery-macros", +] + +[[package]] +name = "refinery-core" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9e21e4c78f030876a62ac983e96baa45403878ec58bc6a3722123736a82a0a9" +dependencies = [ + "async-trait", + "cfg-if", + "lazy_static", + "log", + "regex", + "serde", + "siphasher", + "thiserror", + "time 0.3.7", + "tokio", + "tokio-postgres", + "toml", + "url", + "walkdir", +] + +[[package]] +name = "refinery-macros" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e53d3fed32d2b561d99f1f2d976559a8ce1f3999acb1afb95f528777cac0cbe5" +dependencies = [ + "proc-macro2", + "quote", + "refinery-core", + "regex", + "syn", +] + +[[package]] +name = "regex" +version = "1.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d07a8629359eb56f1e2fb1652bb04212c072a87ba68546a04065d525673ac461" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.6.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f497285884f3fcff424ffc933e56d7cbca511def0c9831a7f9b5f6153e3cc89b" + +[[package]] +name = "remove_dir_all" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acd125665422973a33ac9d3dd2df85edad0f4ae9b00dafb1a05e43a9f5ef8e7" +dependencies = [ + "winapi", +] + +[[package]] +name = "ring" +version = "0.16.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3053cf52e236a3ed746dfc745aa9cacf1b791d846bdaf412f60a8d7d6e17c8fc" +dependencies = [ + "cc", + "libc", + "once_cell", + "spin", + "untrusted", + "web-sys", + "winapi", +] + +[[package]] +name = "rustc-serialize" +version = "0.3.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dcf128d1287d2ea9d80910b5f1120d0b8eede3fbf1abe91c40d39ea7d51e6fda" + +[[package]] +name = "rusticata-macros" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" +dependencies = [ + "nom", +] + +[[package]] +name = "rustls" +version = "0.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b323592e3164322f5b193dc4302e4e36cd8d37158a712d664efae1a5c2791700" +dependencies = [ + "log", + "ring", + "sct", + "webpki", +] + +[[package]] +name = "rustls-pemfile" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ee86d63972a7c661d1536fefe8c3c8407321c3df668891286de28abcd087360" +dependencies = [ + "base64", +] + +[[package]] +name = "rustversion" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2cc38e8fa666e2de3c4aba7edeb5ffc5246c1c2ed0e3d17e560aeeba736b23f" + +[[package]] +name = "ryu" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73b4b750c782965c211b42f022f59af1fbceabdd026623714f104152f1ec149f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "scopeguard" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d29ab0c6d3fc0ee92fe66e2d99f700eab17a8d57d1c1d3b748380fb20baa78cd" + +[[package]] +name = "sct" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d53dcdb7c9f8158937a7981b48accfd39a43af418591a5d008c7b22b5e1b7ca4" +dependencies = [ + "ring", + "untrusted", +] + +[[package]] +name = "serde" +version = "1.0.136" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce31e24b01e1e524df96f1c2fdd054405f8d7376249a5110886fb4b658484789" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.136" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08597e7152fcd306f41838ed3e37be9eaeed2b61c42e2117266a554fab4662f9" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.79" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e8d9fa5c3b304765ce1fd9c4c8a3de2c8db365a5b91be52f186efc675681d95" +dependencies = [ + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_with" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec1e6ec4d8950e5b1e894eac0d360742f3b1407a6078a604a731c4b3f49cefbc" +dependencies = [ + "rustversion", + "serde", + "serde_with_macros", +] + +[[package]] +name = "serde_with_macros" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12e47be9471c72889ebafb5e14d5ff930d89ae7a67bbdb5f8abb564f845a927e" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "sha2" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99c3bd8169c58782adad9290a9af5939994036b76187f7b4f0e6de91dbbfc0ec" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e51e73328dc4ac0c7ccbda3a494dfa03df1de2f46018127f60c693f2648455b0" +dependencies = [ + "libc", +] + +[[package]] +name = "siphasher" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a86232ab60fa71287d7f2ddae4a7073f6b7aac33631c3015abb556f08c6d0a3e" + +[[package]] +name = "slab" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9def91fd1e018fe007022791f865d0ccc9b3a0d5001e01aabb8b40e46000afb5" + +[[package]] +name = "smallvec" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2dd574626839106c320a323308629dcb1acfc96e32a8cba364ddc61ac23ee83" + +[[package]] +name = "socket2" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66d72b759436ae32898a2af0a14218dbf55efde3feeb170eb623637db85ee1e0" +dependencies = [ + "libc", + "winapi", +] + +[[package]] +name = "spectral" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae3c15181f4b14e52eeaac3efaeec4d2764716ce9c86da0c934c3e318649c5ba" +dependencies = [ + "num", +] + +[[package]] +name = "spin" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e63cff320ae2c57904679ba7cb63280a3dc4613885beafb148ee7bf9aa9042d" + +[[package]] +name = "stringprep" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee348cb74b87454fff4b551cbf727025810a004f88aeacae7f85b87f4e9a1c1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", +] + +[[package]] +name = "strsim" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" + +[[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 = "tempfile" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cdb1ef4eaeeaddc8fbd371e5017057064af0911902ef36b39801f67cc6d79e4" +dependencies = [ + "cfg-if", + "fastrand", + "libc", + "redox_syscall", + "remove_dir_all", + "winapi", +] + +[[package]] +name = "termcolor" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dfed899f0eb03f32ee8c6a0aabdb8a7949659e3466561fc0adf54e26d88c5f4" +dependencies = [ + "winapi-util", +] + +[[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.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca8a50ef2360fbd1eeb0ecd46795a87a19024eb4b53c5dc916ca1fd95fe62438" +dependencies = [ + "libc", + "winapi", +] + +[[package]] +name = "time" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "004cbc98f30fa233c61a38bc77e96a9106e65c88f2d3bef182ae952027e5753d" +dependencies = [ + "itoa", + "libc", + "num_threads", +] + +[[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.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af73ac49756f3f7c01172e34a23e5d0216f6c32333757c2c61feb2bbff5a5ee" +dependencies = [ + "bytes", + "libc", + "memchr", + "mio", + "num_cpus", + "once_cell", + "parking_lot 0.12.0", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "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 = "tokio-postgres" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b6c8b33df661b548dcd8f9bf87debb8c56c05657ed291122e1188698c2ece95" +dependencies = [ + "async-trait", + "byteorder", + "bytes", + "fallible-iterator", + "futures", + "log", + "parking_lot 0.11.2", + "percent-encoding", + "phf", + "pin-project-lite", + "postgres-protocol", + "postgres-types", + "socket2", + "tokio", + "tokio-util", +] + +[[package]] +name = "tokio-rustls" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a27d5f2b839802bd8267fa19b0530f5a08b9c08cd417976be2a65d130fe1c11b" +dependencies = [ + "rustls", + "tokio", + "webpki", +] + +[[package]] +name = "tokio-util" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e99e1983e5d376cd8eb4b66604d2e99e79f5bd988c3055891dcd8c9e2604cc0" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "log", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31142970826733df8241ef35dc040ef98c679ab14d7c3e54d827099b3acecaa" +dependencies = [ + "serde", +] + +[[package]] +name = "tower-service" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "360dfd1d6d30e05fda32ace2c8c70e9c0a9da713275777f5a4dbb8a1893930c6" + +[[package]] +name = "tracing" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d8d93354fe2a8e50d5953f5ae2e47a3fc2ef03292e7ea46e3cc38f549525fb9" +dependencies = [ + "cfg-if", + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03cfcb51380632a72d3111cb8d3447a8d908e577d31beeac006f836383d29a23" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "trust-dns-client" +version = "0.20.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b4ef9b9bde0559b78a4abb00339143750085f05e5a453efb7b8bef1061f09dc" +dependencies = [ + "cfg-if", + "data-encoding", + "futures-channel", + "futures-util", + "lazy_static", + "log", + "radix_trie", + "rand 0.8.5", + "thiserror", + "time 0.3.7", + "tokio", + "trust-dns-proto", +] + +[[package]] +name = "trust-dns-proto" +version = "0.20.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca94d4e9feb6a181c690c4040d7a24ef34018d8313ac5044a61d21222ae24e31" +dependencies = [ + "async-trait", + "cfg-if", + "data-encoding", + "enum-as-inner", + "futures-channel", + "futures-io", + "futures-util", + "idna", + "ipnet", + "lazy_static", + "log", + "rand 0.8.5", + "smallvec", + "thiserror", + "tinyvec", + "tokio", + "url", +] + +[[package]] +name = "try-lock" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59547bce71d9c38b83d9c0e92b6066c4253371f15005def0c30d9657f50c7642" + +[[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.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e8820f5d777f6224dc4be3632222971ac30164d4a258d595640799554ebfd99" + +[[package]] +name = "unicode-xid" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ccb82d61f80a663efe1f787a51b16b5a51e3314d6ac365b08639f52387b33f3" + +[[package]] +name = "untrusted" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" + +[[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", + "serde", +] + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f" + +[[package]] +name = "walkdir" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "808cf2735cd4b6866113f648b791c6adc5714537bc222d9347bb203386ffda56" +dependencies = [ + "same-file", + "winapi", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ce8a968cb1cd110d136ff8b819a556d6fb6d919363c61534f6860c7eb172ba0" +dependencies = [ + "log", + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.10.2+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd6fbd9a79829dd1ad0cc20627bf1ed606756a7f77edff7b66b7064f9cb327c6" + +[[package]] +name = "wasm-bindgen" +version = "0.2.79" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25f1af7423d8588a3d840681122e72e6a24ddbcb3f0ec385cac0d12d24256c06" +dependencies = [ + "cfg-if", + "wasm-bindgen-macro", +] + +[[package]] +name = "wasm-bindgen-backend" +version = "0.2.79" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b21c0df030f5a177f3cba22e9bc4322695ec43e7257d865302900290bcdedca" +dependencies = [ + "bumpalo", + "lazy_static", + "log", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.79" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4203d69e40a52ee523b2529a773d5ffc1dc0071801c87b3d270b471b80ed01" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.79" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa8a30d46208db204854cadbb5d4baf5fcf8071ba5bf48190c3e59937962ebc" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-backend", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.79" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d958d035c4438e28c70e4321a2911302f10135ce78a9c7834c0cab4123d06a2" + +[[package]] +name = "web-sys" +version = "0.3.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c060b319f29dd25724f09a2ba1418f142f539b2be99fbf4d2d5a8f7330afb8eb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f095d78192e208183081cc07bc5515ef55216397af48b873e5edcd72637fa1bd" +dependencies = [ + "ring", + "untrusted", +] + +[[package]] +name = "webpki-roots" +version = "0.22.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "552ceb903e957524388c4d3475725ff2c8b7960922063af6ce53c9a43da07449" +dependencies = [ + "webpki", +] + +[[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-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70ec6ce85bb158151cae5e5c87f95a8e97d2c0c4b001223f33a334e3ce5de178" +dependencies = [ + "winapi", +] + +[[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 = "windows-sys" +version = "0.32.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3df6e476185f92a12c072be4a189a0210dcdcf512a1891d6dff9edb874deadc6" +dependencies = [ + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_msvc" +version = "0.32.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8e92753b1c443191654ec532f14c199742964a061be25d77d7a96f09db20bf5" + +[[package]] +name = "windows_i686_gnu" +version = "0.32.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a711c68811799e017b6038e0922cb27a5e2f43a2ddb609fe0b6f3eeda9de615" + +[[package]] +name = "windows_i686_msvc" +version = "0.32.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "146c11bb1a02615db74680b32a68e2d61f553cc24c4eb5b4ca10311740e44172" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.32.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c912b12f7454c6620635bbff3450962753834be2a594819bd5e945af18ec64bc" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.32.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "504a2476202769977a040c6364301a3f65d0cc9e3fb08600b2bda150a0488316" + +[[package]] +name = "x509-parser" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffc90836a84cb72e6934137b1504d0cae304ef5d83904beb0c8d773bbfe256ed" +dependencies = [ + "base64", + "chrono", + "data-encoding", + "der-parser", + "lazy_static", + "nom", + "oid-registry", + "ring", + "rusticata-macros", + "thiserror", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..f8f560e --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,51 @@ +[package] +name = "coyote" +version = "0.1.0" +authors = ["Erik Hollensbe ", "Adam Ierymenko "] +edition = "2021" +license = "BSD-3-Clause" +readme = "README.md" +description = "Embeddable ACME server with programmable challenges and storage" +repository = "https://github.com/zerotier/coyote" +homepage = "https://github.com/zerotier/coyote" +keywords = ["ACME", "letsencrypt", "zerotier"] + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +thiserror = "^1.0" +rand = "^0.8" +base64 = "^0.13" +spectral = "^0.6" +serde_json = "^1.0" +serde = "^1.0" +tokio = { version = "^1.16", features = ["full"] } +ratpack = "^0.1" # { git = "https://github.com/zerotier/ratpack", branch = "main" } +hyper = "^0.14" +http = "^0.2" +url = { version = "^2.2", features = [ "serde" ] } +deadpool-postgres = { version = "^0.10", features = ["serde"] } +log = "^0.4" +env_logger = "^0.9" +trust-dns-client = "^0.20" +tempfile = "^3.3" +openssl = "^0.10" +lazy_static = "^1.4" +refinery = { version = "^0.8", features = ["tokio-postgres"] } +tokio-postgres = { version = "^0.7", features = ["with-serde_json-1", "with-chrono-0_4"] } +async-trait = "^0.1" +eggshell = "^0.1" # { path = "../eggshell" } +bollard = "^0.11" +futures = "^0.3" +futures-core = "^0.3" +chrono = { version = "^0.4", features = [ "serde" ] } +x509-parser = { version = "^0.12", features = [ "ring", "verify", "validate" ] } +rustls = "^0.20" +rustls-pemfile = "^0.3" +webpki-roots = "^0.22" + +[lib] + +[[bin]] +name = "acmed" +path = "src/acmed.rs" diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..f272a61 --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,26 @@ +Copyright 2022 ZeroTier, Inc. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this +list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, +this list of conditions and the following disclaimer in the documentation +and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its contributors +may be used to endorse or promote products derived from this software without +specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..77c5dec --- /dev/null +++ b/Makefile @@ -0,0 +1,10 @@ +postgres: stop-postgres + docker run -e POSTGRES_DB=coyote -e POSTGRES_HOST_AUTH_METHOD=trust -itd -p 127.0.0.1:5432:5432 --name acmed-postgres postgres:latest + +stop-postgres: + docker rm -f acmed-postgres || : + +run-with-backtrace: + RUST_BACKTRACE=1 rustup run nightly cargo run + +.PHONY: postgres stop-postgres diff --git a/README.md b/README.md new file mode 100644 index 0000000..c914912 --- /dev/null +++ b/README.md @@ -0,0 +1,113 @@ +## Coyote: an ACME toolkit + +Coyote lets you make ACME servers, which are not guaranteed to not explode in +your face. You have to code that out yourself. + +coyote aims to solve a few problems (not all of these are solved yet; see "Task List" below): + +- Provide an alternative to boulder and pebble +- Provide ACME with backing storage you prefer to use, by way of Rust's traits for storage implementation. +- Provide ACME in non-conforming scenarios (e.g., behind corporate firewalls) +- Provide ACME services with hooks into the validation system, so you can implement validations however you feel like. +- It's a library; make it as big or as small as you like. No need for multiple implementations. +- A FOSS alternative to the letsencrypt canonical implementation that is _also_ tested against LE's test suite. + +`acmed` comes with coyote; it is a complete example canonical implementation against PostgreSQL for backing storage and OpenSSL for crypto. It (deliberately) allows all challenges through and is not meant for production usage. + +coyote is intended to let you build an `acmed` without using `acmed` itself, but that will not come until the project is complete. Until then it is a single implementation with enough traits / generic parameters to make alternatives possible later. For example, work to implement a redis-based nonce validation system would just be a trait implementation at the time of this writing, even though it is not available today. + +## Running `acmed` + +[acmed](src/acmed.rs) is a very small, example implementation of coyote, intended to demonstrate usage of it. It is not meant or designed to be used in a production environment. It does not perform challenges properly, allowing all of them that come in. + +You'll need `docker` to launch the postgres instance. Provide `HOSTNAME` to set a host name for TLS service; otherwise `localhost` is assumed. A CA at `ca.pem` and `ca.key` will be generated at the directory you run the `cargo` commands from, which you will need to pass to clients to your certificates. Also, a TLS in-memory cert will be generated to serve the `acmed` instance. + +To launch: + +``` +$ make postgres +$ HOSTNAME=tls-hostname.local cargo run --bin acmed +``` + +It will start a service on `https://${HOSTNAME}:8000` which you can then pass as +the `--server` flag to `certbot`, e.g.: + +``` +certbot --server 'https://${HOSTNAME}:8000' certonly --standalone -d 'foo.com' -m 'erik+github@hollensbe.org' --agree-tos +``` + +Otherwise, the use is the same. + +To access the postgres instance that `acmed` is running against (provided by `make postgres`): + +``` +psql -U postgres -h localhost coyote +``` + +## Tests + +`docker` is required to run the tests. The tests take around 70 seconds to run on a 5900X and use all 24 threads most of the test runtime. Be mindful of the time they take, especially when running them on a slower system. + +``` +cargo test +``` + +## Task List + +### JOSE/ACME Protocols: + +- [x] JWS decoding; serde codec (handled in middleware) +- [x] JWK conversion to openssl types; signing and validation +- [x] Full validation and production of nonce +- [x] Full validation of ACME protected header (in middleware) +- [x] RFC7807 "problem details" HTTP error return values +- [x] Various validating codecs for ACME structs +- [ ] MAYBE: rate limiting (see 6.6 of RFC8555), but probably later +- [ ] Integration of well-used third party ACME client in testing + +### Handlers: + +- [x] Nonce Handlers +- [x] Nonce Middleware +- [x] Accounts (RFC8555 7.3) + - [x] Handlers: + - [x] New Account + - [x] Lookup Account + - [x] De-registration +- [x] Orders (RFC8555 7.4) + - [x] Challenge Traits + - [ ] HTTP basic impl: needed for certbot tests + - [ ] MAYBE: DNS basic impl; see "Other concerns" below + - [ ] Handlers: + - [x] Authorization Request + - [x] Fetch challenges + - [x] Initiate Challenge + - [x] Deactivate Challenge + - [x] Challenge status + - [x] Finalization + - [x] Fetch Certificate + - [ ] Revocation of Certificate +- Other concerns: + - [ ] Key Changes (`/key-change` endpoint, see RFC8555 7.3.5) + +### Storage: + +- DB Layer + - [x] PostgreSQL implementation + - [x] Nonce storage + - [x] Account storage + - [x] Order information / state machine storage + - [x] Cert storage + - [ ] Encrypted at rest + +## Things coyote doesn't currently handle + +These are things that are not covered by our initial goals, and we do not feel they are higher priority items. We will happily accept pull requests for this functionality. + +- Accounts: + - Terms of Service changes + - External Account Bindings + +### LICENSE + +This software is covered by the BSD-3-Clause License. See [LICENSE.txt](LICENSE.txt) for more details. diff --git a/dockerfiles/Dockerfile.zlint b/dockerfiles/Dockerfile.zlint new file mode 100644 index 0000000..dd12a61 --- /dev/null +++ b/dockerfiles/Dockerfile.zlint @@ -0,0 +1,12 @@ +# vim: ft=dockerfile + +FROM alpine:latest as builder + +ARG ZLINT + +RUN apk add curl +RUN curl -sSL "https://github.com/zmap/zlint/releases/download/v${ZLINT}/zlint_${ZLINT}_Linux_x86_64.tar.gz" | tar -vxz -C /tmp + +FROM alpine:latest + +COPY --from=builder /tmp/zlint*/zlint /usr/bin diff --git a/dockerfiles/Makefile b/dockerfiles/Makefile new file mode 100644 index 0000000..2fdffe3 --- /dev/null +++ b/dockerfiles/Makefile @@ -0,0 +1,6 @@ +all: zlint + +ZLINT=3.3.0 + +zlint: + docker build --build-arg ZLINT=${ZLINT} -t zerotier/zlint:latest -f Dockerfile.zlint . diff --git a/hack/pg_hba.conf b/hack/pg_hba.conf new file mode 100644 index 0000000..c400c43 --- /dev/null +++ b/hack/pg_hba.conf @@ -0,0 +1,3 @@ +host all all all trust +local all all ident map=postgres +local all all trust diff --git a/migrations/.gitkeep b/migrations/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/migrations/V1__init.sql b/migrations/V1__init.sql new file mode 100644 index 0000000..5084265 --- /dev/null +++ b/migrations/V1__init.sql @@ -0,0 +1,86 @@ +create table nonces ( + nonce varchar primary key +); +-- +create table jwks ( + id serial primary key, + nonce_key varchar not null unique, + alg varchar not null, + -- base64'd public key components + n varchar, + e varchar, + x varchar, + y varchar, + + created_at timestamptz default CURRENT_TIMESTAMP not null, + deleted_at timestamptz, + + CHECK((n is not null and e is not null) or (x is not null and y is not null)) +); +-- +create table accounts ( + id serial primary key, + jwk_id integer not null, + orders_nonce varchar not null unique, + -- TODO: external accounts + + created_at timestamptz default CURRENT_TIMESTAMP not null, + deleted_at timestamptz +); +-- +create table contacts ( + id serial primary key, + account_id integer not null, + contact varchar not null, + + created_at timestamptz default CURRENT_TIMESTAMP not null, + deleted_at timestamptz, + + UNIQUE (account_id, contact) +); +-- +create table orders_challenges ( + id serial primary key, + order_id varchar not null, + authorization_id varchar not null, + challenge_type varchar not null, + reference varchar not null unique, + identifier varchar not null, + token varchar not null, + status varchar not null, + validated timestamptz, + created_at timestamptz default CURRENT_TIMESTAMP not null, + deleted_at timestamptz +); +-- +create table orders_authorizations ( + id serial primary key, + order_id varchar not null, + identifier varchar not null, + reference varchar not null unique, + expires timestamptz not null, + created_at timestamptz default CURRENT_TIMESTAMP not null, + deleted_at timestamptz +); +-- +create table orders_certificate ( + id serial primary key, + order_id varchar not null unique, + reference varchar not null unique, + certificate bytea not null, + created_at timestamptz default CURRENT_TIMESTAMP not null, + deleted_at timestamptz +); +-- +create table orders ( + id serial primary key, + order_id varchar not null unique, -- this is the *public* order id. it is not the primary key used in the db. + expires timestamptz, + not_before timestamptz default CURRENT_TIMESTAMP, + not_after timestamptz, + error text, + finalized bool not null, + + created_at timestamptz default CURRENT_TIMESTAMP not null, + deleted_at timestamptz +); diff --git a/src/acme/ca.rs b/src/acme/ca.rs new file mode 100644 index 0000000..955cae9 --- /dev/null +++ b/src/acme/ca.rs @@ -0,0 +1,333 @@ +use std::{ + convert::TryInto, + sync::Arc, + time::{Duration, SystemTime}, +}; + +use log::warn; +use openssl::{ + asn1::Asn1Time, + bn::BigNum, + error::ErrorStack, + hash::MessageDigest, + pkey::{PKey, Private}, + rsa::Rsa, + x509::{X509Extension, X509Name, X509Req, X509}, +}; +use tokio::sync::RwLock; + +pub(crate) fn st_to_asn1(time: SystemTime) -> Result { + Asn1Time::from_unix( + time.duration_since(SystemTime::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() + .try_into() + .unwrap_or_default(), + ) +} + +#[derive(Clone, Debug)] +pub struct CA { + certificate: X509, + private_key: PKey, +} + +impl CA { + pub fn new(certificate: X509, private_key: PKey) -> Self { + Self { + certificate, + private_key, + } + } + + pub fn certificate(self) -> X509 { + self.certificate + } + + pub fn private_key(self) -> PKey { + self.private_key + } + + pub fn generate_and_sign_cert( + &self, + req: X509Req, + not_before: SystemTime, + not_after: SystemTime, + ) -> Result { + let mut builder = X509::builder()?; + builder.set_pubkey(req.public_key()?.as_ref())?; + builder.set_issuer_name(self.certificate.issuer_name())?; + builder.set_serial_number( + BigNum::from_u32(rand::random::())? + .as_ref() + .to_asn1_integer()? + .as_ref(), + )?; + + let exts = req.extensions(); + if let Ok(exts) = exts { + for ext in exts { + builder.append_extension(ext)?; + } + } + + builder.append_extension(X509Extension::new( + None, + Some(&builder.x509v3_context(None, None)), + "keyUsage", + "critical,keyEncipherment,digitalSignature", + )?)?; + + builder.append_extension(X509Extension::new( + None, + Some(&builder.x509v3_context(None, None)), + "extendedKeyUsage", + "critical,serverAuth", + )?)?; + + builder.append_extension(X509Extension::new( + None, + Some(&builder.x509v3_context(None, None)), + "authorityKeyIdentifier", + "issuer", + )?)?; + + builder.append_extension(X509Extension::new( + None, + Some(&builder.x509v3_context(None, None)), + "subjectKeyIdentifier", + "hash", + )?)?; + + builder.append_extension(X509Extension::new( + None, + Some(&builder.x509v3_context(None, None)), + "issuerAltName", + "issuer:copy", + )?)?; + + builder.set_subject_name(req.subject_name())?; + builder.set_version(2)?; + builder.set_not_before(st_to_asn1(not_before)?.as_ref())?; + builder.set_not_after(st_to_asn1(not_after)?.as_ref())?; + + builder.sign(&self.private_key, MessageDigest::sha512())?; + Ok(builder.build()) + } + + pub fn new_test_ca() -> Result { + let mut builder = X509::builder()?; + + let mut namebuilder = X509Name::builder()?; + namebuilder.append_entry_by_text("C", "US")?; + namebuilder.append_entry_by_text("O", "ZeroTier")?; + namebuilder.append_entry_by_text("CN", "CA Signing Certificate")?; + namebuilder.append_entry_by_text("ST", "California")?; + namebuilder.append_entry_by_text("L", "Irvine")?; + namebuilder.append_entry_by_text("OU", "A Test Suite")?; + builder.set_subject_name(&namebuilder.build())?; + + let mut namebuilder = X509Name::builder()?; + namebuilder.append_entry_by_text("C", "US")?; + namebuilder.append_entry_by_text("O", "ZeroTier")?; + namebuilder.append_entry_by_text("CN", "CA Signing Certificate")?; + namebuilder.append_entry_by_text("ST", "California")?; + namebuilder.append_entry_by_text("L", "Irvine")?; + namebuilder.append_entry_by_text("OU", "A Test Suite")?; + builder.set_issuer_name(&namebuilder.build())?; + + builder.set_serial_number( + BigNum::from_u32(rand::random::())? + .as_ref() + .to_asn1_integer()? + .as_ref(), + )?; + + let key = Rsa::generate(4096)?; + // FIXME there has to be a much better way of doing this! + let pubkey = PKey::public_key_from_pem(&key.public_key_to_pem().unwrap()).unwrap(); + + builder.set_pubkey(&pubkey)?; + builder.set_version(2)?; + builder.set_not_before(Asn1Time::days_from_now(0)?.as_ref())?; + builder.set_not_after(Asn1Time::days_from_now(365)?.as_ref())?; + + builder.append_extension(X509Extension::new( + None, + Some(&builder.x509v3_context(None, None)), + "basicConstraints", + "critical,CA:true,pathlen:0", + )?)?; + + builder.append_extension(X509Extension::new( + None, + Some(&builder.x509v3_context(None, None)), + "keyUsage", + "critical,keyCertSign", + )?)?; + + builder.append_extension(X509Extension::new( + None, + Some(&builder.x509v3_context(None, None)), + "subjectKeyIdentifier", + "hash", + )?)?; + + builder.append_extension(X509Extension::new( + None, + Some(&builder.x509v3_context(None, None)), + "issuerAltName", + "issuer:copy", + )?)?; + + let privkey = PKey::from_rsa(key)?; + builder.sign(privkey.as_ref(), MessageDigest::sha512())?; + Ok(Self::new(builder.build(), privkey)) + } +} + +#[derive(Clone, Debug)] +pub struct CACollector { + poll_interval: Duration, + ca: SharedCA, +} + +type SharedCA = Arc>>; + +impl CACollector { + pub fn new(poll_interval: Duration) -> Self { + Self { + poll_interval, + ca: Arc::new(RwLock::new(None)), + } + } + + pub fn ca(self) -> SharedCA { + self.ca.clone() + } + + pub async fn spawn_collector(&mut self, f: F) + where + F: Fn() -> Result, + { + loop { + let res = f(); + + match res { + Ok(ca) => { self.ca.write().await.replace(ca); }, + Err(e) => warn!("Failed to retrieve CA, signing will will continue to use the old CA, if any. Error: {}", e.to_string()) + } + tokio::time::sleep(self.poll_interval).await; + } + } + + pub async fn sign( + self, + req: X509Req, + not_before: SystemTime, + not_after: SystemTime, + ) -> Result { + Ok(self + .ca() + .read() + .await + .clone() + .unwrap() + .generate_and_sign_cert(req, not_before, not_after)?) + } +} + +mod tests { + use openssl::{error::ErrorStack, x509::X509Req}; + + fn generate_csr() -> Result { + use openssl::{pkey::PKey, rsa::Rsa, x509::X509Name}; + + let mut namebuilder = X509Name::builder().unwrap(); + namebuilder + .append_entry_by_text("CN", "example.org") + .unwrap(); + let mut req = X509Req::builder().unwrap(); + req.set_subject_name(&namebuilder.build()).unwrap(); + + let key = Rsa::generate(4096).unwrap(); + // FIXME there has to be a much better way of doing this! + let pubkey = PKey::public_key_from_pem(&key.public_key_to_pem().unwrap()).unwrap(); + + req.set_pubkey(&pubkey).unwrap(); + Ok(req.build()) + } + + #[test] + fn test_basic_ca_sign() { + use spectral::prelude::*; + + use super::{st_to_asn1, CA}; + use openssl::{pkey::PKey, rsa::Rsa}; + use std::time::SystemTime; + + let now = SystemTime::now(); + + let ca = CA::new_test_ca().unwrap(); + let signed = ca + .generate_and_sign_cert(generate_csr().unwrap(), SystemTime::UNIX_EPOCH, now) + .unwrap(); + + let result = signed.verify(&ca.private_key()); + assert_that!(result).is_ok(); + assert_that!(result.unwrap()).is_true(); + + let badkey = Rsa::generate(4096).unwrap(); + let result = signed.verify(PKey::from_rsa(badkey).unwrap().as_ref()); + assert_that!(result).is_ok(); + assert_that!(result.unwrap()).is_false(); + + assert_that!(signed.not_before()) + .is_equal_to(&*st_to_asn1(SystemTime::UNIX_EPOCH).unwrap()); + assert_that!(signed.not_after()).is_equal_to(&*st_to_asn1(now).unwrap()); + } + + #[tokio::test(flavor = "multi_thread")] + async fn test_ca_collector() { + use super::{st_to_asn1, CACollector, CA}; + use openssl::{pkey::PKey, rsa::Rsa}; + use spectral::prelude::*; + use std::time::Duration; + use std::time::SystemTime; + + let collector = CACollector::new(Duration::new(0, 500)); + + let mut inner = collector.clone(); + let handle = tokio::spawn(async move { + // we only want one of these, instead of polling for new ones, in this test. + let ca = CA::new_test_ca().unwrap(); + inner + .spawn_collector(|| -> Result { Ok(ca.clone()) }) + .await + }); + + tokio::time::sleep(Duration::new(1, 0)).await; + + let now = SystemTime::now(); + let signed = collector + .clone() + .sign(generate_csr().unwrap(), SystemTime::UNIX_EPOCH, now) + .await + .unwrap(); + + let result = signed.verify(&collector.ca().read().await.clone().unwrap().private_key()); + assert_that!(result).is_ok(); + assert_that!(result.unwrap()).is_true(); + + let badkey = Rsa::generate(4096).unwrap(); + let result = signed.verify(PKey::from_rsa(badkey).unwrap().as_ref()); + assert_that!(result).is_ok(); + assert_that!(result.unwrap()).is_false(); + + assert_that!(signed.not_before()) + .is_equal_to(&*st_to_asn1(SystemTime::UNIX_EPOCH).unwrap()); + assert_that!(signed.not_after()).is_equal_to(&*st_to_asn1(now).unwrap()); + + handle.abort(); + } +} diff --git a/src/acme/challenge.rs b/src/acme/challenge.rs new file mode 100644 index 0000000..3df5882 --- /dev/null +++ b/src/acme/challenge.rs @@ -0,0 +1,324 @@ +use serde::{Deserialize, Serialize}; +use std::{collections::HashMap, convert::TryFrom, ops::Add, sync::Arc}; +use tokio::sync::Mutex; + +use crate::{ + errors::db::{LoadError, SaveError}, + models::{order::Challenge, Postgres}, +}; + +use super::handlers::order::OrderStatus; + +// most of this is RFC8555 section 8 +// read RFC8555 7.1.6 on state transitions between different parts of the challenge + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(into = "String")] +pub enum ChallengeType { + DNS01, // dns-01 challenge type + HTTP01, // http-01 challenge type +} + +impl TryFrom<&str> for ChallengeType { + type Error = LoadError; + fn try_from(value: &str) -> Result { + match value { + "dns-01" => Ok(ChallengeType::DNS01), + "http-01" => Ok(ChallengeType::HTTP01), + _ => Err(LoadError::InvalidEnum), + } + } +} + +impl Into for ChallengeType { + fn into(self) -> String { + match self { + ChallengeType::DNS01 => "dns-01", + ChallengeType::HTTP01 => "http-01", + } + .to_string() + } +} + +impl ChallengeType { + pub(crate) fn to_string(self) -> String { + self.into() + } +} + +#[derive(Clone)] +pub struct Challenger { + list: Arc>>, + expiration: Option, +} + +impl Challenger { + pub fn new(expiration: Option) -> Self { + Self { + list: Arc::new(Mutex::new(HashMap::new())), + expiration, + } + } + + pub(crate) async fn schedule(&self, c: Challenge) { + self.list.lock().await.insert(c.reference.clone(), c); + } + + pub async fn tick(&self, ticker: T) + where + T: Fn(Challenge) -> Option<()>, + { + let mut lock = self.list.lock().await; + let mut ch = HashMap::new(); + let mut sv = Vec::new(); + let mut iv = Vec::new(); + + for (s, c) in lock.iter_mut() { + match c.status { + OrderStatus::Processing => { + ch.insert(s.clone(), c.clone()); + } + _ => {} + } + } + + drop(lock); + + let expires = self.expiration.is_some(); + let now = chrono::DateTime::::from(std::time::SystemTime::now()); + + for (s, c) in ch { + if expires && c.created_at.add(self.expiration.unwrap()) < now { + iv.push(s.clone()); + continue; + } + + match ticker(c.clone()) { + Some(_) => { + sv.push(s.clone()); + } + None => {} + } + } + + let mut lock = self.list.lock().await; + + for s in sv { + match lock.get_mut(&s) { + Some(i) => i.status = OrderStatus::Valid, + None => {} + } + } + + for s in iv { + match lock.get_mut(&s) { + Some(i) => i.status = OrderStatus::Invalid, + None => {} + } + } + } + + pub async fn reconcile(&self, db: Postgres) -> Result<(), SaveError> { + let mut lock = self.list.lock().await; + let mut db_lock = db.client().await?; + let tx = db_lock.transaction().await?; + let mut sv = Vec::new(); + + // FIXME needs to manage challenge statuses, or that needs to move up a level + for (s, c) in lock.iter_mut() { + match c.status { + OrderStatus::Pending | OrderStatus::Processing => {} + _ => { + let mut c: crate::models::order::Challenge = c.clone().into(); + c.persist_status(&tx).await?; + sv.push(s.clone()); + } + } + } + + for s in sv { + lock.remove(&s); + } + + tx.commit().await?; + + Ok(()) + } +} + +mod tests { + + #[tokio::test(flavor = "multi_thread")] + async fn test_challenge_scheduler_basic_with_expiration() { + use super::{ChallengeType, Challenger}; + use crate::acme::handlers::order::OrderStatus; + use crate::models::order::{Authorization, Challenge, Order}; + use crate::models::Record; + use crate::test::PGTest; + use crate::util::make_nonce; + use spectral::prelude::*; + use std::time::Duration; + + let pg = PGTest::new("test_challenge_scheduler_basic_with_expiration") + .await + .unwrap(); + let c = Challenger::new(Some(chrono::Duration::seconds(1))); + + let mut order = Order::default(); + order.create(pg.db()).await.unwrap(); + + let mut authz = Authorization::default(); + authz.order_id = order.order_id.clone(); + authz.identifier = Some("example.com".to_string()); + authz.create(pg.db().clone()).await.unwrap(); + + // FIXME some of this shit needs to be in default() + let mut challenge = Challenge { + id: None, + order_id: order.order_id.clone(), + authorization_id: authz.reference.clone(), + identifier: "example.com".to_string(), + challenge_type: ChallengeType::DNS01, + reference: make_nonce(None), + token: make_nonce(None), + status: OrderStatus::Processing, + created_at: chrono::DateTime::::from(std::time::SystemTime::now()), + deleted_at: None, + validated: None, + }; + + challenge.create(pg.db()).await.unwrap(); + + c.schedule(challenge.clone()).await; + c.tick(|_c| Some(())).await; + c.reconcile(pg.db()).await.unwrap(); + + let challenges = order + .challenges(&pg.db().client().await.unwrap().transaction().await.unwrap()) + .await + .unwrap(); + + assert_that!(challenges.len()).is_equal_to(1); + assert_that!(challenges[0].id).is_equal_to(challenge.id); + assert_that!(challenges[0].status).is_equal_to(OrderStatus::Valid); + assert_that!(challenges[0].validated).is_some(); + + let mut challenge = Challenge { + id: None, + order_id: order.order_id.clone(), + authorization_id: authz.reference.clone(), + identifier: "example.com".to_string(), + challenge_type: ChallengeType::DNS01, + reference: make_nonce(None), + token: make_nonce(None), + status: OrderStatus::Processing, + created_at: chrono::DateTime::::from(std::time::SystemTime::now()), + deleted_at: None, + validated: None, + }; + + challenge.create(pg.db()).await.unwrap(); + + // wait for the challenge to expire + tokio::time::sleep(Duration::new(2, 0)).await; + + c.schedule(challenge.clone()).await; + c.tick(|_c| None).await; + c.reconcile(pg.db()).await.unwrap(); + + let challenges = order + .challenges(&pg.db().client().await.unwrap().transaction().await.unwrap()) + .await + .unwrap(); + + assert_that!(challenges.len()).is_equal_to(2); + assert_that!(challenges[1].id).is_equal_to(challenge.id); + assert_that!(challenges[1].status).is_equal_to(OrderStatus::Invalid); + } + + #[tokio::test(flavor = "multi_thread")] + async fn test_challenge_scheduler_async() { + use super::{ChallengeType, Challenger}; + use crate::acme::handlers::order::OrderStatus; + use crate::models::order::{Authorization, Challenge, Order}; + use crate::models::Record; + use crate::test::PGTest; + use crate::util::make_nonce; + use spectral::prelude::*; + use std::time::Duration; + use tokio::sync::mpsc; + + let pg = PGTest::new("test_challenge_scheduler_async").await.unwrap(); + let c = Challenger::new(Some(chrono::Duration::seconds(1))); + let db = pg.db(); + + let (s, mut r) = mpsc::unbounded_channel(); + let mut handles = Vec::new(); + + let c2 = c.clone(); + let db2 = db.clone(); + let supervisor = tokio::spawn(async move { + loop { + c2.tick(|_c| Some(())).await; + c2.reconcile(db2.clone()).await.unwrap(); + tokio::time::sleep(Duration::new(1, 0)).await; + } + }); + + for _ in 0..10 { + let c = c.clone(); + let mut order = Order::default(); + order.create(db.clone()).await.unwrap(); + let mut authz = Authorization::default(); + authz.identifier = Some("example.com".to_string()); + authz.order_id = order.order_id.clone(); + authz.create(db.clone()).await.unwrap(); + let s = s.clone(); + let db2 = db.clone(); + + handles.push(tokio::spawn(async move { + for _ in 0..100 { + let mut challenge = Challenge { + id: None, + order_id: order.order_id.clone(), + authorization_id: authz.reference.clone(), + identifier: "example.com".to_string(), + token: make_nonce(None), + reference: make_nonce(None), + challenge_type: ChallengeType::DNS01, + status: OrderStatus::Pending, + created_at: chrono::DateTime::::from( + std::time::SystemTime::now(), + ), + deleted_at: None, + validated: None, + }; + + challenge.create(db2.clone()).await.unwrap(); + c.schedule(challenge.clone()).await; + s.send((order.clone(), challenge.id.unwrap())).unwrap(); + } + })); + } + + drop(s); + tokio::time::sleep(Duration::new(2, 0)).await; // give the supervisor an opp to wake up + + loop { + if let Some((order, challenge_id)) = r.recv().await { + let mut lockeddb = db.clone().client().await.unwrap(); + let tx = lockeddb.transaction().await.unwrap(); + + let ch = order.challenges(&tx).await.unwrap(); + assert_that!(ch + .iter() + .find(|x| x.id.is_some() && x.id.unwrap() == challenge_id)) + .is_some(); + } else { + break; + } + } + + supervisor.abort(); + } +} diff --git a/src/acme/dns.rs b/src/acme/dns.rs new file mode 100644 index 0000000..71e56b3 --- /dev/null +++ b/src/acme/dns.rs @@ -0,0 +1,73 @@ +use serde::{de::Visitor, Deserialize, Deserializer, Serialize}; +use std::str::FromStr; +use trust_dns_client::rr::Name; + +#[derive(Debug, Clone, PartialEq)] +pub struct DNSName(pub(crate) Name); + +impl DNSName { + pub(crate) fn from_str(name: &str) -> Result { + Ok(Self(Name::from_str(&name)?)) + } + + pub(crate) fn to_string(&self) -> String { + self.0.to_string() + } +} + +pub struct DNSNameVisitor; + +impl<'de> Visitor<'de> for DNSNameVisitor { + type Value = DNSName; + + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + formatter.write_str("A DNS Name") + } + + fn visit_borrowed_str(self, v: &'de str) -> Result + where + E: serde::de::Error, + { + match Self::Value::from_str(v) { + Ok(name) => Ok(name), + Err(e) => Err(serde::de::Error::custom(e)), + } + } +} + +impl Serialize for DNSName { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(&self.0.to_string().trim_end_matches(".")) + } +} + +impl<'de> Deserialize<'de> for DNSName { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + Ok(deserializer.deserialize_string(DNSNameVisitor)?) + } +} + +mod tests { + #[test] + fn test_dns_serde() { + use super::DNSName; + use crate::acme::ACMEIdentifier; + use spectral::prelude::*; + + let json = + serde_json::to_string(&ACMEIdentifier::DNS(DNSName::from_str("foo.com").unwrap())); + assert_that!(json).is_ok(); + let json = json.unwrap(); + + let id = serde_json::from_str::(&json); + assert_that!(id).is_ok(); + let id = id.unwrap(); + assert_that!(id.to_string()).is_equal_to("foo.com".to_string()); + } +} diff --git a/src/acme/handlers/account.rs b/src/acme/handlers/account.rs new file mode 100644 index 0000000..651941a --- /dev/null +++ b/src/acme/handlers/account.rs @@ -0,0 +1,316 @@ +use std::convert::{TryFrom, TryInto}; + +use serde::{Deserialize, Serialize}; +use thiserror::Error; +use url::Url; + +use ratpack::prelude::*; + +use super::{uri_to_url, HandlerState, ServiceState}; +use crate::{ + errors::{acme::JWSError, ACMEValidationError}, + models::{ + account::{new_accounts, JWK}, + Record, + }, +}; + +/// RFC8555 7.1.2 +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Account { + status: AccountStatus, + contact: Option>, + terms_of_service_agreed: Option, + external_account_binding: Option, + orders: Option, +} + +impl Default for Account { + fn default() -> Self { + Self { + status: AccountStatus::Revoked, + contact: None, + terms_of_service_agreed: None, + external_account_binding: None, + orders: None, + } + } +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum AccountStatus { + Valid, + Deactivated, + Revoked, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct AccountUrl(Url); + +#[derive(Debug, Clone, Error)] +pub enum AccountUrlError { + #[error("invalid url scheme for account")] + InvalidScheme, + #[error("unknown error: {0}")] + Other(String), +} + +impl TryFrom<&str> for AccountUrl { + type Error = AccountUrlError; + fn try_from(s: &str) -> Result { + match Url::parse(s) { + Ok(url) => url.try_into(), + Err(e) => Err(AccountUrlError::Other(e.to_string())), + } + } +} + +impl TryFrom for AccountUrl { + type Error = AccountUrlError; + + fn try_from(url: Url) -> Result { + // RFC8555 7.3 + if url.scheme() != "mailto" { + return Err(AccountUrlError::InvalidScheme); + } + + Ok(Self(url)) + } +} + +impl Into for AccountUrl { + fn into(self) -> String { + self.0.to_string() + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ExternalBinding {} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct NewAccount { + pub contact: Option>, + pub terms_of_service_agreed: Option, + pub only_return_existing: Option, + pub external_account_binding: Option, +} + +impl NewAccount { + pub fn contacts(&self) -> Option> { + self.contact.clone() + } + + pub fn is_deleted(&self) -> bool { + self.contact.is_none() || self.contact.as_ref().unwrap().is_empty() + } + + pub fn to_account(&self) -> Account { + Account { + status: AccountStatus::Valid, + contact: self.contact.clone(), + terms_of_service_agreed: self.terms_of_service_agreed, + external_account_binding: None, + orders: None, // FIXME needs to be populated with a slug for user orders + } + } +} + +impl Default for NewAccount { + fn default() -> Self { + Self { + contact: None, + terms_of_service_agreed: None, + only_return_existing: None, + external_account_binding: None, + } + } +} + +pub(crate) async fn new_account( + req: Request, + _resp: Option>, + _params: Params, + app: App, + state: HandlerState, +) -> HTTPResult { + let appstate_opt = app.state().await.clone().unwrap(); + let appstate = appstate_opt.lock().await; + + match state.clone().jws { + Some(mut jws) => { + let newacct = jws.clone().payload::()?; + let uri = req.uri().clone(); + let url = uri_to_url(appstate.clone().baseurl, uri).await?; + + let protected = jws.protected()?; + + if protected.kid().is_some() && newacct.only_return_existing.unwrap_or_default() { + let rec = + match JWK::find_by_kid(protected.kid().unwrap(), appstate.db.clone()).await { + Ok(rec) => rec, + Err(_) => return Err(ACMEValidationError::AccountDoesNotExist.to_status()), + }; + + let resp = state + .decorate_response(url.clone(), Response::builder())? + .status(StatusCode::OK) + .header( + "Location", + url.clone() + .join(&format!("./account/{}", &rec.clone().nonce_key()))? + .to_string(), + ) + .body(Body::from(serde_json::to_string(&rec)?)) + .unwrap(); + return Ok((req, Some(resp), state)); + } else { + let mut jwk = jws.into_db_jwk()?; + + jwk.create(appstate.db.clone()).await?; + + let mut acct = new_accounts(newacct.clone(), jwk.clone(), appstate.db.clone())?; + acct.create(appstate.db.clone()).await?; + + let resp = state + .decorate_response(url.clone(), Response::builder())? + .status(StatusCode::CREATED) + .header( + "Location", + url.join(&format!("./account/{}", &jwk.nonce_key()))? + .to_string(), + ) + .body(Body::from(serde_json::to_string(&newacct.to_account())?)) + .unwrap(); + return Ok((req, Some(resp), state)); + } + } + None => { + return Err(ratpack::Error::StatusCode( + StatusCode::NOT_FOUND, + String::default(), + )) + } + } +} + +pub(crate) async fn post_account( + req: Request, + _resp: Option>, + _params: Params, + app: App, + state: HandlerState, +) -> HTTPResult { + let appstate_opt = app.state().await.clone().unwrap(); + let appstate = appstate_opt.lock().await; + + // FIXME this still needs code to update contact lists; see 7.3.2. + match state.clone().jws { + Some(mut jws) => { + let acct: Account = jws.payload()?; + + match acct.status { + AccountStatus::Deactivated => { + let aph = jws.protected()?; + let kid = aph.kid(); + + if kid.is_none() { + return Err(JWSError::InvalidPublicKey.to_status()); + } + + let kid = kid.unwrap(); + let target = JWK::find_by_kid(kid, appstate.db.clone()).await?; + let target_jwk: crate::acme::jose::JWK = target.clone().try_into()?; + + match target_jwk.try_into() { + Ok(key) => match jws.verify(key) { + Ok(b) => { + if !b { + return Err(ACMEValidationError::InvalidSignature.to_status()); + } + } + Err(e) => return Err(e.into()), + }, + Err(e) => return Err(e.into()), + } + + target.delete(appstate.db.clone()).await?; + let url = uri_to_url(appstate.clone().baseurl, req.uri().clone()).await?; + + return Ok(( + req, + Some( + state + .decorate_response(url.clone(), Response::builder())? + .status(StatusCode::OK) + .body(Body::from(serde_json::to_string(&target)?)) + .unwrap(), + ), + state, + )); + } + _ => {} + } + } + None => { + return Err(ratpack::Error::StatusCode( + StatusCode::NOT_FOUND, + String::default(), + )) + } + } + + return Err(ACMEValidationError::InvalidRequest.to_status()); +} + +mod tests { + #[tokio::test(flavor = "multi_thread")] + async fn new_account_failures() { + use crate::test::TestService; + use http::StatusCode; + use hyper::Body; + use spectral::prelude::*; + + let srv = TestService::new("new_account_failures").await; + let res = srv.clone().app.get("/account").await; + assert_that!(res.status()).is_equal_to(StatusCode::METHOD_NOT_ALLOWED); + + let res = srv.clone().app.post("/account", Body::default()).await; + assert_that!(res.status()).is_equal_to(StatusCode::FORBIDDEN); + + let res = srv.clone().app.post("/account/herp", Body::default()).await; + assert_that!(res.status()).is_equal_to(StatusCode::FORBIDDEN); + } + + #[tokio::test(flavor = "multi_thread")] + async fn account_register_with_certbot() { + use crate::test::TestService; + use spectral::prelude::*; + + let srv = TestService::new("account_register_with_certbot").await; + + for _ in 0..10 { + let res = srv + .clone() + .certbot( + None, + "register -m 'erik@hollensbe.org' --agree-tos".to_string(), + ) + .await; + assert_that!(res).is_ok(); + + let dir = res.unwrap(); + + let res = srv + .clone() + .certbot( + Some(dir.clone()), + "unregister -m 'erik@hollensbe.org'".to_string(), + ) + .await; + assert_that!(res).is_ok(); + } + } +} diff --git a/src/acme/handlers/directory.rs b/src/acme/handlers/directory.rs new file mode 100644 index 0000000..11af5dd --- /dev/null +++ b/src/acme/handlers/directory.rs @@ -0,0 +1,146 @@ +use super::{uri_to_url, HandlerState, ServiceState}; +use ratpack::prelude::*; +use serde::{Deserialize, Serialize}; + +/// See 7.1.1 of RFC8555 +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct DirectoryMeta { + #[serde(skip_serializing_if = "Option::is_none")] + terms_of_service: Option, + #[serde(skip_serializing_if = "Option::is_none")] + website: Option, + #[serde(skip_serializing_if = "Option::is_none")] + caa_identities: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + external_account_required: Option, +} + +impl Default for DirectoryMeta { + fn default() -> Self { + Self { + terms_of_service: None, + website: None, + caa_identities: None, + external_account_required: None, + } + } +} + +/// See 7.1.1 of RFC8555 +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct Directory { + new_nonce: url::Url, + new_account: url::Url, + new_order: url::Url, + new_authz: url::Url, + revoke_cert: url::Url, + key_change: url::Url, + #[serde(skip_serializing_if = "Option::is_none")] + meta: Option, +} + +pub(crate) async fn directory( + req: Request, + _resp: Option>, + _params: Params, + app: App, + state: HandlerState, +) -> HTTPResult { + let uri = req.uri().clone(); + let url = uri_to_url(app.state().await.unwrap().lock().await.baseurl.clone(), uri).await?; + + let dir = Directory { + new_nonce: url.join("./nonce")?, + new_account: url.join("./account")?, + new_order: url.join("./order")?, + new_authz: url.join("./authz")?, + revoke_cert: url.join("./revoke")?, + key_change: url.join("./key")?, + meta: Some(DirectoryMeta::default()), + }; + + Ok(( + req, + Some( + state + .decorate_response(url, Response::builder())? + .status(StatusCode::OK) + .body(Body::from(serde_json::to_string(&dir)?)) + .unwrap(), + ), + state, + )) +} + +mod tests { + #[tokio::test(flavor = "multi_thread")] + async fn test_basic_directory() { + use super::{super::*, Directory, DirectoryMeta}; + use crate::test::PGTest; + use ratpack::app::TestApp; + use spectral::prelude::*; + use std::time::Duration; + + let pg = PGTest::new("test_basic_directory").await.unwrap(); + let c = Challenger::new(Some(chrono::Duration::seconds(1))); + let mut app = App::with_state( + ServiceState::new( + "http://example.com".to_string(), + pg.db(), + c.clone(), + CACollector::new(Duration::MAX), + PostgresNonceValidator::new(pg.db()), + ) + .unwrap(), + ); + configure_routes(&mut app, None); + + let app = TestApp::new(app); + + let mut res = app.get("/").await; + + let res = hyper::body::to_bytes(res.body_mut()).await.unwrap(); + let res = serde_json::from_slice::(&res).unwrap(); + + assert_that!(res).is_equal_to(Directory { + new_nonce: "http://example.com/nonce".parse().unwrap(), + new_account: "http://example.com/account".parse().unwrap(), + new_order: "http://example.com/order".parse().unwrap(), + new_authz: "http://example.com/authz".parse().unwrap(), + revoke_cert: "http://example.com/revoke".parse().unwrap(), + key_change: "http://example.com/key".parse().unwrap(), + meta: Some(DirectoryMeta::default()), + }); + + let mut app = App::with_state( + ServiceState::new( + "http://example.com/acme".to_string(), + pg.db(), + c, + CACollector::new(Duration::MAX), + PostgresNonceValidator::new(pg.db()), + ) + .unwrap(), + ); + + configure_routes(&mut app, Some("/acme")); + + let app = TestApp::new(app); + let mut res = app.get("/acme/").await; + + let res = hyper::body::to_bytes(res.body_mut()).await.unwrap(); + let res = serde_json::from_slice::(&res).unwrap(); + + assert_that!(res).is_equal_to(Directory { + new_nonce: "http://example.com/acme/nonce".parse().unwrap(), + new_account: "http://example.com/acme/account".parse().unwrap(), + new_order: "http://example.com/acme/order".parse().unwrap(), + new_authz: "http://example.com/acme/authz".parse().unwrap(), + revoke_cert: "http://example.com/acme/revoke".parse().unwrap(), + key_change: "http://example.com/acme/key".parse().unwrap(), + meta: Some(DirectoryMeta::default()), + }); + } +} diff --git a/src/acme/handlers/mod.rs b/src/acme/handlers/mod.rs new file mode 100644 index 0000000..a8cdf4c --- /dev/null +++ b/src/acme/handlers/mod.rs @@ -0,0 +1,246 @@ +use std::convert::TryInto; + +use crate::{ + acme::{ + ca::CACollector, + challenge::Challenger, + handlers::{ + account::{new_account, post_account}, + directory::directory, + nonce::{new_nonce_get, new_nonce_head}, + order::{ + existing_order, finalize_order, get_certificate, new_order, post_authz, + post_challenge, + }, + }, + jose::{ACMEKey, JWK}, + NonceValidator, PostgresNonceValidator, + }, + errors::{acme::JWSError, ACMEValidationError, Error, HandlerError}, + models::Postgres, +}; +use http::response::Builder; +use ratpack::prelude::*; + +pub(crate) mod account; +pub(crate) mod directory; +pub(crate) mod nonce; +pub(crate) mod order; + +pub(crate) const REPLAY_NONCE_HEADER: &str = "Replay-Nonce"; + +#[derive(Clone)] +pub struct ServiceState { + baseurl: url::Url, + db: Postgres, + c: Challenger, + ca: CACollector, + pnv: PostgresNonceValidator, +} + +impl ServiceState { + pub fn new( + baseurl: String, + db: Postgres, + c: Challenger, + ca: CACollector, + pnv: PostgresNonceValidator, + ) -> Result { + Ok(Self { + baseurl: baseurl.parse()?, + db, + c, + ca, + pnv, + }) + } +} + +#[derive(Clone)] +pub struct HandlerState { + jws: Option, + nonce: Option, +} + +impl HandlerState { + pub(crate) fn decorate_response( + &self, + url: url::Url, + builder: Builder, + ) -> Result { + if self.nonce.is_none() { + return Err(ACMEValidationError::NonceNotFound.into()); + } + + Ok(builder + .header("content-type", "application/json") + .header(REPLAY_NONCE_HEADER, self.clone().nonce.unwrap()) + .header( + "Link", + format!(r#"<{}>;rel="index""#, url.join("/")?.to_string()), + )) + } +} + +impl TransientState for HandlerState { + fn initial() -> Self { + Self { + jws: None, + nonce: None, + } + } +} + +pub(crate) async fn uri_to_url( + baseurl: url::Url, + uri: http::Uri, +) -> Result { + baseurl.join(&uri.to_string()) +} + +async fn handle_log_request( + req: Request, + resp: Option>, + _params: Params, + _app: App, + state: HandlerState, +) -> HTTPResult { + log::info!("{} request to {}", req.method(), req.uri().path()); + Ok((req, resp, state)) +} + +async fn handle_nonce( + req: Request, + _resp: Option>, + _params: Params, + app: App, + mut state: HandlerState, +) -> HTTPResult { + state.nonce = Some(app.state().await.unwrap().lock().await.pnv.make().await?); + Ok((req, None, state)) +} + +async fn handle_jws( + mut req: Request, + _resp: Option>, + _params: Params, + app: App, + mut state: HandlerState, +) -> HTTPResult { + let jws: Result = + serde_json::from_slice(&hyper::body::to_bytes(req.body_mut()).await?); + + // what a mess. + if let Ok(jws) = jws { + let uri = req.uri().clone(); + let appstate_opt = app.state().await.clone().unwrap(); + let appstate = appstate_opt.lock().await; + + match jws.clone().protected() { + Ok(mut protected) => { + if let Err(e) = protected + .validate( + uri_to_url(appstate.baseurl.clone(), uri).await?, + appstate.pnv.clone(), + ) + .await + { + return Err(e.to_status()); + } else { + let key: Result, Error> = if let Some(jwk) = protected.jwk() { + Ok(Some(jwk.try_into()?)) + } else if let Some(kid) = protected.kid() { + let jwk = + crate::models::account::JWK::find_by_kid(kid, appstate.db.clone()) + .await?; + + let localjwk: Result = jwk.try_into(); + match localjwk { + Ok(mut localjwk) => match (&mut localjwk).try_into() { + Ok(x) => Ok(Some(x)), + Err(e) => Err(e.into()), + }, + Err(e) => Err(e.into()), + } + } else { + Ok(None) + }; + + match key { + Err(e) => return Err(e.to_status()), + Ok(Some(key)) => match jws.verify(key) { + Ok(result) => { + if result { + state.jws = Some(jws); + return Ok((req, None, state)); + } + + return Err(JWSError::ValidationFailed.to_status()); + } + Err(e) => return Err(e.to_status()), + }, + Ok(None) => return Err(JWSError::Missing.to_status()), + } + } + } + Err(e) => return Err(e.to_status()), + } + } + + Err(ratpack::Error::StatusCode( + StatusCode::FORBIDDEN, + String::default(), + )) +} + +macro_rules! jws_handler { + ($($x:path)*) => { + compose_handler!(handle_log_request, handle_nonce, handle_jws, $($x)*) + }; +} + +pub fn configure_routes(app: &mut App, rootpath: Option<&str>) { + let rootpath = rootpath.unwrap_or("/").to_string(); + + app.get( + &(rootpath.clone()), + compose_handler!(handle_log_request, handle_nonce, directory), + ); + + app.head( + &(rootpath.clone() + "nonce"), + compose_handler!(handle_log_request, handle_nonce, new_nonce_head), + ); + app.get( + &(rootpath.clone() + "nonce"), + compose_handler!(handle_log_request, handle_nonce, new_nonce_get), + ); + + app.post(&(rootpath.clone() + "account"), jws_handler!(new_account)); + app.post( + &(rootpath.clone() + "account/:key_id"), + jws_handler!(post_account), + ); + + app.post(&(rootpath.clone() + "order"), jws_handler!(new_order)); + app.post( + &(rootpath.clone() + "order/:order_id"), + jws_handler!(existing_order), + ); + app.post( + &(rootpath.clone() + "order/:order_id/finalize"), + jws_handler!(finalize_order), + ); + app.post( + &(rootpath.clone() + "order/:order_id/certificate"), + jws_handler!(get_certificate), + ); + app.post( + &(rootpath.clone() + "authz/:auth_id"), + jws_handler!(post_authz), + ); + app.post( + &(rootpath.clone() + "chall/:challenge_id"), + jws_handler!(post_challenge), + ); +} diff --git a/src/acme/handlers/nonce.rs b/src/acme/handlers/nonce.rs new file mode 100644 index 0000000..47cbcb6 --- /dev/null +++ b/src/acme/handlers/nonce.rs @@ -0,0 +1,173 @@ +// nonces are covered in RFC8555 section 7.2 mostly. They're also a critical part of the JOSE usage +// in this library. + +use super::{uri_to_url, HandlerState, ServiceState}; +use ratpack::prelude::*; + +pub(crate) async fn new_nonce_head( + req: Request, + _resp: Option>, + _params: Params, + app: App, + state: HandlerState, +) -> HTTPResult { + let uri = req.uri().clone(); + + Ok(( + req, + Some( + state + .decorate_response( + uri_to_url(app.state().await.unwrap().lock().await.baseurl.clone(), uri) + .await?, + Response::builder(), + )? + .status(StatusCode::OK) + .header("Cache-Control", "no-store") // last para of 7.2 + .body(Body::default()) + .unwrap(), + ), + state, + )) +} + +pub(crate) async fn new_nonce_get( + req: Request, + _resp: Option>, + _params: Params, + app: App, + state: HandlerState, +) -> HTTPResult { + let uri = req.uri().clone(); + + Ok(( + req, + Some( + state + .decorate_response( + uri_to_url(app.state().await.unwrap().lock().await.baseurl.clone(), uri) + .await?, + Response::builder(), + )? + .status(StatusCode::CREATED) + .header("Cache-Control", "no-store") // last para of 7.2 + .body(Body::default()) + .unwrap(), + ), + state, + )) +} + +mod tests { + #[tokio::test(flavor = "multi_thread")] + async fn test_basic_head() { + use super::super::*; + use crate::test::PGTest; + use ratpack::app::TestApp; + use spectral::prelude::*; + use std::time::Duration; + + let pg = PGTest::new("test_basic_head").await.unwrap(); + let c = Challenger::new(Some(chrono::Duration::seconds(1))); + let mut app = App::with_state( + ServiceState::new( + "http://127.0.0.1:8000".to_string(), + pg.db(), + c, + CACollector::new(Duration::MAX), + PostgresNonceValidator::new(pg.db()), + ) + .unwrap(), + ); + + configure_routes(&mut app, None); + + let app: TestApp = TestApp::new(app); + + let res = app.head("/nonce").await; + let headers = res.headers(); + let nonce = headers.get(REPLAY_NONCE_HEADER).unwrap().clone(); + assert_that!(nonce.is_empty()).is_false(); + drop(headers); + + let mut handles = Vec::new(); + for _ in 0..10000 { + let nonce = nonce.clone(); + let app = app.clone(); + let handle = tokio::spawn(async move { + let res = app.head("/nonce").await; + assert_that!(res.status()).is_equal_to(StatusCode::OK); + + let nonce2 = res + .headers() + .get(REPLAY_NONCE_HEADER) + .unwrap() + .to_str() + .unwrap(); + + assert!(!nonce2.is_empty()); + + assert_ne!(nonce, nonce2); + }); + + handles.push(handle); + } + for handle in handles { + handle.await.unwrap() + } + } + + #[tokio::test(flavor = "multi_thread")] + async fn test_basic_get() { + use super::super::*; + use crate::test::PGTest; + use ratpack::app::TestApp; + use spectral::prelude::*; + use std::time::Duration; + + let pg = PGTest::new("test_basic_get").await.unwrap(); + let c = Challenger::new(Some(chrono::Duration::seconds(1))); + let mut app = App::with_state( + ServiceState::new( + "http://127.0.0.1:8000".to_string(), + pg.db(), + c, + CACollector::new(Duration::MAX), + PostgresNonceValidator::new(pg.db()), + ) + .unwrap(), + ); + + configure_routes(&mut app, None); + + let app: TestApp = TestApp::new(app); + + let res = app.get("/nonce").await; + let headers = res.headers(); + let nonce = headers.get(REPLAY_NONCE_HEADER).unwrap().clone(); + assert_that!(nonce.is_empty()).is_false(); + drop(headers); + + let mut handles = Vec::new(); + for _ in 0..10000 { + let nonce = nonce.clone(); + let app = app.clone(); + let handle = tokio::spawn(async move { + let res = app.get("/nonce").await; + let headers = res.headers(); + let nonce2 = headers.get(REPLAY_NONCE_HEADER).unwrap().clone(); + assert_that!(nonce.is_empty()).is_false(); + drop(headers); + + assert!(!nonce2.is_empty()); + + assert_ne!(nonce, nonce2); + }); + + handles.push(handle); + } + for handle in handles { + handle.await.unwrap() + } + } +} diff --git a/src/acme/handlers/order.rs b/src/acme/handlers/order.rs new file mode 100644 index 0000000..8022db4 --- /dev/null +++ b/src/acme/handlers/order.rs @@ -0,0 +1,708 @@ +use http::HeaderValue; +use serde::{Deserialize, Serialize}; +use std::{ + collections::HashSet, + convert::{TryFrom, TryInto}, +}; +use tokio_postgres::Transaction; +use url::Url; +use x509_parser::prelude::*; + +use ratpack::prelude::*; + +use crate::{ + acme::{challenge::ChallengeType, ACMEIdentifier}, + errors::{db::LoadError, ACMEValidationError}, + models::{order::Challenge, Record}, +}; + +use super::{uri_to_url, HandlerState, ServiceState}; + +pub struct OrdersList { + orders: Vec, +} + +/// RFC8555 7.1.3. Detailed read. +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Order { + #[serde(skip_serializing_if = "Option::is_none")] + pub status: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub expires: Option>, // required for pending and valid states + pub identifiers: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub not_before: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub not_after: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, + // read 7.1.3's missive on this + section 7.5 + #[serde(skip_serializing_if = "Option::is_none")] + pub authorizations: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub finalize: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub certificate: Option, +} + +/// RFC8555 7.1.3 & 7.1.6 +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub enum OrderStatus { + Pending, + Ready, + Processing, + Valid, + Invalid, +} + +impl ToString for OrderStatus { + fn to_string(&self) -> String { + match self { + Self::Pending => "pending", + Self::Ready => "ready", + Self::Processing => "processing", + Self::Valid => "valid", + Self::Invalid => "invalid", + } + .to_string() + } +} + +impl TryFrom for OrderStatus { + type Error = crate::errors::db::LoadError; + + fn try_from(s: String) -> Result { + Self::try_from(s.as_str()) + } +} + +impl TryFrom<&str> for OrderStatus { + type Error = crate::errors::db::LoadError; + + fn try_from(s: &str) -> Result { + Ok(match s { + "pending" => Self::Pending, + "ready" => Self::Ready, + "processing" => Self::Processing, + "valid" => Self::Valid, + "invalid" => Self::Invalid, + _ => return Err(LoadError::InvalidEnum), + }) + } +} + +pub(crate) async fn new_order( + req: Request, + _resp: Option>, + _params: Params, + app: App, + state: HandlerState, +) -> HTTPResult { + let appstate_opt = app.state().await.clone().unwrap(); + let appstate = appstate_opt.lock().await; + + match state.clone().jws { + Some(jws) => { + let order: Order = jws.payload()?; + + let mut o = crate::models::order::Order::new( + order.not_before.map_or(None, |f| Some(f.into())), + order.not_after.map_or(None, |f| Some(f.into())), + ); + o.create(appstate.db.clone()).await?; + + for id in order.identifiers { + let mut authz = crate::models::order::Authorization::default(); + authz.identifier = Some(id.clone().to_string()); + authz.order_id = o.order_id.clone(); + authz.create(appstate.db.clone()).await?; + + // for now at least, schedule one http-01 and dns-01 per name + + for chall in vec![ChallengeType::DNS01, ChallengeType::HTTP01] { + let mut c = Challenge::new( + o.order_id.clone(), + authz.reference.clone(), + chall, + id.clone().to_string(), + OrderStatus::Pending, + ); + + c.create(appstate.db.clone()).await?; + } + } + + let url = uri_to_url(appstate.clone().baseurl, req.uri().clone()).await?; + + let order: Order = + crate::models::order::Order::find(o.id()?.unwrap(), appstate.db.clone()) + .await? + .into_handler_order(url.clone())?; + + return Ok(( + req, + Some( + state + .decorate_response(url.clone(), Response::builder())? + .status(StatusCode::CREATED) + .header( + "Location", + url.join(&format!("./order/{}", o.order_id))?.to_string(), + ) + .body(Body::from(serde_json::to_string(&order)?)) + .unwrap(), + ), + state, + )); + } + None => {} + } + + return Err(ACMEValidationError::InvalidRequest.into()); +} + +pub(crate) async fn existing_order( + req: Request, + _resp: Option>, + params: Params, + app: App, + state: HandlerState, +) -> HTTPResult { + let appstate_opt = app.state().await.clone().unwrap(); + let appstate = appstate_opt.lock().await; + + match state.clone().jws { + Some(_jws) => { + let order_id = params.get("order_id").unwrap(); + + let o = crate::models::order::Order::find_by_reference( + order_id.to_string(), + appstate.db.clone(), + ) + .await?; + + let url = uri_to_url(appstate.clone().baseurl, req.uri().clone()).await?; + let h_order = serde_json::to_string(&o.clone().into_handler_order(url.clone())?)?; + + return Ok(( + req, + Some( + state + .decorate_response(url.clone(), Response::builder())? + .status(StatusCode::OK) + .header( + "Location", + url.join(&format!("./order/{}", o.order_id))?.to_string(), + ) + .body(Body::from(h_order)) + .unwrap(), + ), + state, + )); + } + None => {} + } + + return Err(ACMEValidationError::InvalidRequest.into()); +} + +/// RFC8555 7.4. +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct FinalizeOrderRequest { + csr: String, +} + +pub(crate) async fn finalize_order( + req: Request, + _resp: Option>, + params: Params, + app: App, + state: HandlerState, +) -> HTTPResult { + let appstate_opt = app.state().await.clone().unwrap(); + let appstate = appstate_opt.lock().await; + + match state.clone().jws { + Some(jws) => { + let finalize_order: FinalizeOrderRequest = jws.payload()?; + + let order_id = params.get("order_id").unwrap(); + + let order = crate::models::order::Order::find_by_reference( + order_id.to_string(), + appstate.db.clone(), + ) + .await?; + + if order.authorizations.is_none() { + return Err(ACMEValidationError::InvalidRequest.into()); + } + + // this code yields to the x509-parser crate to reap and check the subjectAltName + // extensions. This is necessary because rust-openssl does not support this + // functionality. + // + // relevant topic on github: + // https://github.com/sfackler/rust-openssl/pull/1095#issuecomment-636279332 + // + // commonName is not checked as it should not be validated in a typical situation + // involving TLS and subjectAltName components, but this may be something to revisit + // later. + // + // Later, the csr is handed back to rust-openssl to complete the CA signing process. + + let decoded = + &base64::decode_config(finalize_order.csr.clone(), base64::URL_SAFE_NO_PAD)?; + + let (_, csr) = X509CertificationRequest::from_der(decoded)?; + csr.verify_signature()?; + + let mut mapping = HashSet::new(); + + for id in order.authorizations.clone().unwrap() { + for id in id.identifier { + mapping.insert(id); + } + } + + if let Some(extensions) = csr.requested_extensions() { + for extension in extensions { + match extension { + ParsedExtension::SubjectAlternativeName(name) => { + for val in name.general_names.iter() { + match val { + GeneralName::DNSName(val) => { + if mapping.contains(&val.to_string()) { + mapping.remove(&val.to_string()); + } else { + return Err(ACMEValidationError::Other( + "CSR contains invalid names".to_string(), + ) + .into()); + } + } + _ => {} + } + } + } + _ => {} + } + } + } + + if !mapping.is_empty() { + return Err( + ACMEValidationError::Other("CSR contains invalid names".to_string()).into(), + ); + } + + if order.not_before.is_none() { + return Err(ACMEValidationError::Other( + "not_before missing in order cadence".to_string(), + ) + .into()); + } + + if order.not_after.is_none() { + return Err(ACMEValidationError::Other( + "not_after missing in order cadence".to_string(), + ) + .into()); + } + + let csr = openssl::x509::X509Req::from_der(decoded)?; + + let res = appstate + .ca + .clone() + .sign( + csr, + order.clone().not_before.unwrap().into(), + order.clone().not_after.unwrap().into(), + ) + .await; + + match res { + Ok(cert) => order.record_certificate(cert, appstate.db.clone()).await?, + Err(e) => return Err(ACMEValidationError::Other(e.to_string()).into()), + }; + + let url = uri_to_url(appstate.clone().baseurl, req.uri().clone()).await?; + let h_order = serde_json::to_string(&order.clone().into_handler_order(url.clone())?)?; + + return Ok(( + req, + Some( + state + .decorate_response(url.clone(), Response::builder())? + .status(StatusCode::OK) + .header( + "Location", + url.join(&format!("./order/{}", order.order_id))? + .to_string(), + ) + .body(Body::from(h_order)) + .unwrap(), + ), + state, + )); + } + None => {} + } + + return Err(ACMEValidationError::InvalidRequest.into()); +} + +pub(crate) async fn get_certificate( + req: Request, + _resp: Option>, + params: Params, + app: App, + state: HandlerState, +) -> HTTPResult { + let appstate_opt = app.state().await.clone().unwrap(); + let appstate = appstate_opt.lock().await; + + match state.clone().jws { + Some(_jws) => { + let order_id = params.get("order_id").unwrap(); + + let order = crate::models::order::Order::find_by_reference( + order_id.to_string(), + appstate.db.clone(), + ) + .await?; + + let cert = order.certificate(appstate.db.clone()).await?; + let mut cacert = appstate + .ca + .clone() + .ca() + .read() + .await + .clone() + .unwrap() + .certificate() + .to_pem()?; + + let mut chain = cert.certificate; + chain.append(&mut cacert); + + let url = uri_to_url(appstate.baseurl.clone(), req.uri().clone()).await?; + + return Ok(( + req, + Some( + state + .decorate_response(url, Response::builder())? + .status(StatusCode::OK) + .body(Body::from(chain)) + .unwrap(), + ), + state, + )); + } + None => {} + } + + return Err(ACMEValidationError::InvalidRequest.into()); +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Authorization { + identifier: ACMEIdentifier, + status: AuthStatus, + expires: chrono::DateTime, + challenges: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + wildcard: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum AuthStatus { + Pending, + Valid, + Deactivated, + Revoked, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ChallengeAuthorization { + #[serde(rename = "type")] + typ: ChallengeType, + url: Url, + token: String, + status: OrderStatus, + #[serde(skip_serializing_if = "Option::is_none")] + validated: Option>, +} + +impl ChallengeAuthorization { + fn from_challenge(c: &Challenge, url: Url) -> Result { + Ok(Self { + typ: c.challenge_type.clone(), + url, + token: c.token.clone(), + status: c.status.clone(), + validated: c.validated.map(|t| t.into()), + }) + } +} + +impl Authorization { + async fn from_authorization_id( + auth_id: &str, + url: Url, + tx: &Transaction<'_>, + ) -> Result { + let auth = crate::models::order::Authorization::find_by_reference(auth_id, &tx).await?; + let challenges = auth.challenges(&tx).await?; + + let chs = challenges + .iter() + .map(|c| ChallengeAuthorization::from_challenge(c, c.into_url(url.clone()))) + .collect::>>(); + + if let Some(Err(error)) = chs.iter().find(|p| p.is_err()) { + return Err(LoadError::Generic(error.to_string())); + } + + let chs = chs + .iter() + .map(|c| c.as_ref().unwrap().clone()) + .collect::>(); + + Ok(Self { + expires: auth.expires.into(), + status: if auth.deleted_at.is_some() { + AuthStatus::Deactivated + } else { + if chs.iter().any(|ca| ca.status == OrderStatus::Valid) { + AuthStatus::Valid + } else if chs + .iter() + .all(|ca| ca.status != OrderStatus::Valid && ca.status != OrderStatus::Invalid) + { + AuthStatus::Pending + } else { + AuthStatus::Revoked + } + }, + identifier: auth.identifier.unwrap().try_into()?, + challenges: chs, + wildcard: None, // FIXME wtf? re-check spec + }) + } +} + +pub(crate) async fn post_authz( + req: Request, + _resp: Option>, + params: Params, + app: App, + state: HandlerState, +) -> HTTPResult { + let appstate_opt = app.state().await.clone().unwrap(); + let appstate = appstate_opt.lock().await; + + match state.clone().jws { + Some(_jws) => { + let auth_id = params.get("auth_id").unwrap(); + + let db = appstate.db.clone(); + let mut lockeddb = db.client().await?; + let tx = lockeddb.transaction().await?; + + let mut statuscode = StatusCode::CREATED; + + let authz = Authorization::from_authorization_id( + auth_id, + uri_to_url(appstate.clone().baseurl, req.uri().clone()).await?, + &tx, + ) + .await?; + for chall in authz.clone().challenges { + if chall.status == OrderStatus::Valid { + statuscode = StatusCode::OK; + break; + } + } + + let url = uri_to_url(appstate.clone().baseurl, req.uri().clone()).await?; + let builder = state + .decorate_response(url.clone(), Response::builder())? + .header( + "Link", + HeaderValue::from_str(&format!(r#"<{}>;rel="up""#, url.clone()))?, + ); + + let out = serde_json::to_string(&authz)?; + return Ok(( + req, + Some(builder.status(statuscode).body(Body::from(out)).unwrap()), + state, + )); + } + None => {} + } + + return Err(ACMEValidationError::InvalidRequest.into()); +} + +pub(crate) async fn post_challenge( + req: Request, + _resp: Option>, + params: Params, + app: App, + state: HandlerState, +) -> HTTPResult { + let appstate_opt = app.state().await.clone().unwrap(); + let appstate = appstate_opt.lock().await; + + match state.clone().jws { + Some(_jws) => { + let challenge_id = params.get("challenge_id").unwrap(); + + let db = appstate.db.clone(); + let mut lockeddb = db.client().await?; + let tx = lockeddb.transaction().await?; + + let mut ch = Challenge::find_by_reference(challenge_id.to_string(), &tx).await?; + if ch.status == OrderStatus::Pending { + ch.status = OrderStatus::Processing; + ch.persist_status(&tx).await?; + appstate.c.schedule(ch.clone()).await; + } + + let authz = ch.authorization(&tx).await?; + tx.commit().await?; + + let url = uri_to_url(appstate.clone().baseurl, req.uri().clone()).await?; + + // FIXME 7.5.1 indicates a Retry-After header can be sent to feed the client hints on how + // often to retry here... we can use the polling value fed to the challenger for this + // value. + let builder = state + .decorate_response(url.clone(), Response::builder())? + .status(StatusCode::OK) + .header( + "Link", + HeaderValue::from_str(&format!( + r#"<{}>;rel="up""#, + authz.into_url(url.clone()) + ))?, + ); + + return Ok(( + req, + Some( + builder + .body(Body::from(serde_json::to_string( + &ChallengeAuthorization::from_challenge(&ch, ch.into_url(url))?, + )?)) + .unwrap(), + ), + state, + )); + } + None => {} + } + + return Err(ACMEValidationError::InvalidRequest.into()); +} + +mod tests { + #[tokio::test(flavor = "multi_thread")] + async fn test_order_flow_single_domain() { + use crate::test::TestService; + use spectral::prelude::*; + use std::sync::Arc; + use tempfile::TempDir; + + let srv = TestService::new("test_order_flow_single_domain").await; + + let dir = Arc::new(TempDir::new().unwrap()); + + for _ in 0..10 { + let res = srv.clone().certbot( + Some(dir.clone()), + format!("certonly --http-01-port {} --standalone -d 'foo.com' -m 'erik@hollensbe.org' --agree-tos", + rand::random::() % 10000 + 1024) + .to_string(), + ) + .await; + + assert_that!(res).is_ok(); + + let res = srv + .clone() + .certbot(Some(dir.clone()), "update_symlinks".to_string()) + .await; + + assert_that!(res).is_ok(); + + let mut root = dir.path().to_path_buf(); + root.push("live/foo.com"); + + for filename in vec!["fullchain", "cert", "chain", "privkey"] { + let mut path = root.clone(); + path.push(filename.to_string() + ".pem"); + let res = path.metadata(); + assert_that!(res).is_ok(); + } + + assert_that!(srv.zlint(dir.clone()).await).is_ok(); + } + } + + #[tokio::test(flavor = "multi_thread")] + async fn test_order_flow_multi_domain() { + use crate::test::TestService; + use spectral::prelude::*; + use std::sync::Arc; + use tempfile::TempDir; + + let srv = TestService::new("test_order_flow_multi_domain").await; + + let dir = Arc::new(TempDir::new().unwrap()); + + for domain in vec!["foo.com", "bar.com", "example.org", "example.com"] { + let res = srv.clone().certbot( + Some(dir.clone()), + format!( + "certonly --http-01-port {} --standalone -d '{}' -m 'erik@hollensbe.org' --agree-tos", + rand::random::() % 10000 + 1024, + domain + ), + ) + .await; + + assert_that!(res).is_ok(); + + let res = srv + .clone() + .certbot(Some(dir.clone()), "update_symlinks".to_string()) + .await; + + assert_that!(res).is_ok(); + + let mut root = dir.path().to_path_buf(); + root.push(format!("live/{}", domain)); + + for filename in vec!["fullchain", "cert", "chain", "privkey"] { + let mut path = root.clone(); + path.push(filename.to_string() + ".pem"); + let res = path.metadata(); + assert_that!(res).is_ok(); + } + + assert_that!(srv.zlint(dir.clone()).await).is_ok(); + } + } +} diff --git a/src/acme/jose/crypto/mod.rs b/src/acme/jose/crypto/mod.rs new file mode 100644 index 0000000..73d1194 --- /dev/null +++ b/src/acme/jose/crypto/mod.rs @@ -0,0 +1,8 @@ +use lazy_static::lazy_static; +use openssl::{ec::EcGroup, nid::Nid}; + +const NID_ES256: Nid = Nid::X9_62_PRIME256V1; + +lazy_static! { + pub static ref EC_GROUP: EcGroup = EcGroup::from_curve_name(NID_ES256).unwrap(); +} diff --git a/src/acme/jose/mod.rs b/src/acme/jose/mod.rs new file mode 100644 index 0000000..cbfd657 --- /dev/null +++ b/src/acme/jose/mod.rs @@ -0,0 +1,602 @@ +pub mod crypto; + +use std::{ + convert::{TryFrom, TryInto}, + time::SystemTime, +}; + +use openssl::{ + bn::BigNum, + ec::{EcKey, EcPointRef}, + ecdsa::EcdsaSig, + hash::MessageDigest, + pkey::{PKey, Private, Public}, + rsa::Rsa, + sha::sha256, + sign::{Signer, Verifier}, +}; +use serde::{Deserialize, Serialize}; +use url::Url; + +use crate::{ + acme::{NonceValidator, ACME_EXPECTED_ALGS}, + errors::{acme::*, ACMEValidationError}, + util::{make_nonce, to_base64}, +}; + +use super::PostgresNonceValidator; + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct ACMEProtectedHeader { + alg: String, + #[serde(skip_serializing_if = "Option::is_none")] + jwk: Option, + #[serde(skip_serializing_if = "Option::is_none")] + kid: Option, + nonce: String, + url: Url, +} + +impl ACMEProtectedHeader { + pub fn new_jwk(jwk: JWK, url: Url, nonce: String) -> Self { + Self { + url, + alg: String::from(ACME_EXPECTED_ALGS[0].clone()), + jwk: Some(jwk), + kid: None, + nonce, + } + } + + pub fn new_kid(kid: Url, url: Url, nonce: String) -> Self { + Self { + url, + alg: String::from(ACME_EXPECTED_ALGS[0].clone()), + jwk: None, + kid: Some(kid), + nonce, + } + } + + pub fn nonce(&self) -> String { + self.nonce.clone() + } + + pub fn kid(&self) -> Option { + self.kid.clone() + } + + pub fn jwk(&mut self) -> Option<&mut JWK> { + self.jwk.as_mut() + } + + pub async fn validate( + &self, + request_url: Url, + validator: PostgresNonceValidator, + ) -> Result<(), ACMEValidationError> { + if self.nonce.is_empty() { + return Err(ACMEValidationError::NonceNotFound); + } + + if base64::decode_config(self.nonce.clone(), base64::URL_SAFE_NO_PAD).is_err() { + return Err(ACMEValidationError::NonceDecodeError); + } + + if self.jwk.is_none() && self.kid.is_none() { + return Err(ACMEValidationError::NoKeyProvided); + } + + if !self.url.eq(&request_url) { + return Err(ACMEValidationError::URLNotEqual( + request_url.to_string(), + self.url.to_string(), + )); + } + + if !ACME_EXPECTED_ALGS.contains(&self.alg) { + return Err(ACMEValidationError::AlgNotEqual( + ACME_EXPECTED_ALGS.join(", "), + self.alg.to_string(), + )); + } + + Ok(validator.validate(&self.nonce).await?) + } +} + +#[derive(Debug, Clone)] +pub enum ACMEKey { + ECDSA(EcKey), + RSA(Rsa), +} + +#[derive(Debug, Clone)] +pub enum ACMEPrivateKey { + ECDSA(EcKey), + RSA(Rsa), +} + +impl TryFrom<&EcPointRef> for ACMEKey { + type Error = JWSError; + + fn try_from(ec: &EcPointRef) -> Result { + let mut ctx = openssl::bn::BigNumContext::new()?; + let mut x = openssl::bn::BigNum::new()?; + let mut y = openssl::bn::BigNum::new()?; + ec.affine_coordinates_gfp(&crypto::EC_GROUP, &mut x, &mut y, &mut ctx)?; + Ok((&mut JWK { + x: Some(base64::encode_config(&x.to_vec(), base64::URL_SAFE_NO_PAD)), + y: Some(base64::encode_config(&y.to_vec(), base64::URL_SAFE_NO_PAD)), + alg: Some("ES256".into()), + crv: Some("P-256".into()), + _use: Some("sig".into()), + kty: "EC".into(), + n: None, + e: None, + }) + .try_into()?) + } +} + +impl TryFrom> for ACMEKey { + type Error = JWSError; + + fn try_from(value: Rsa) -> Result { + Ok((&mut JWK { + e: Some(base64::encode_config( + &value.e().to_vec(), + base64::URL_SAFE_NO_PAD, + )), + n: Some(base64::encode_config( + &value.n().to_vec(), + base64::URL_SAFE_NO_PAD, + )), + alg: Some("RS256".into()), + kty: "RSA".into(), + _use: Some("sig".into()), + x: None, + y: None, + crv: None, + }) + .try_into()?) + } +} + +impl TryFrom<&mut JWK> for ACMEKey { + type Error = JWSError; + + fn try_from(jwk: &mut JWK) -> Result { + match jwk.kty.as_str() { + "RSA" => Ok(ACMEKey::RSA(jwk.into_rsa()?)), + "EC" => Ok(ACMEKey::ECDSA(jwk.into_ec()?)), + _ => Err(JWSError::InvalidPublicKey), + } + } +} + +impl TryInto for JWK { + type Error = JWSError; + fn try_into(self) -> Result { + match self.kty.as_str() { + "RSA" => Ok(ACMEKey::RSA(self.into_rsa()?)), + "EC" => Ok(ACMEKey::ECDSA(self.into_ec()?)), + _ => Err(JWSError::InvalidPublicKey), + } + } +} + +impl TryFrom for ACMEKey { + type Error = JWSError; + + fn try_from(mut jws: JWS) -> Result { + if let Some(jwk) = jws.protected()?.jwk() { + return jwk.try_into(); + } + + return Err(JWSError::InvalidPublicKey); + } +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct JWK { + #[serde(skip_serializing_if = "Option::is_none")] + pub alg: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub crv: Option, + pub kty: String, + #[serde(skip_serializing_if = "Option::is_none", rename = "use")] + pub _use: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub x: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub y: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub n: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub e: Option, +} + +impl JWK { + fn into_rsa(&self) -> Result, JWSError> { + if self.n.is_none() || self.e.is_none() { + return Err(JWSError::Encode( + "e/n parameters missing in RSA JWK translation".to_string(), + )); + } + + let n = base64::decode_config(self.n.clone().unwrap(), base64::URL_SAFE_NO_PAD)?; + let e = base64::decode_config(self.e.clone().unwrap(), base64::URL_SAFE_NO_PAD)?; + + Ok(Rsa::from_public_components( + BigNum::from_slice(&n)?, + BigNum::from_slice(&e)?, + )?) + } + + fn into_ec(&self) -> Result, JWSError> { + if self.x.is_none() || self.y.is_none() { + return Err(JWSError::Encode( + "x/y parameters missing in EC JWK translation".to_string(), + )); + } + + let x = base64::decode_config(self.x.clone().unwrap(), base64::URL_SAFE_NO_PAD)?; + let y = base64::decode_config(self.y.clone().unwrap(), base64::URL_SAFE_NO_PAD)?; + + let key = EcKey::from_public_key_affine_coordinates( + &crypto::EC_GROUP, + BigNum::from_slice(&x)?.as_ref(), + BigNum::from_slice(&y)?.as_ref(), + )?; + + Ok(key) + } + + fn from_jws(jws: &mut JWS) -> Result { + let mut aph = jws.protected()?; + let alg = aph.alg.clone(); + + if aph.jwk().is_some() { + let mut jwk = aph.jwk.unwrap(); + jwk.alg = Some(alg); + return Ok(jwk); + } + + return Err(JWSError::InvalidPublicKey); + } + + // just a simple way to unroll private params without making them easy to dink with + pub(crate) fn params( + &self, + ) -> ( + Option, + Option, + Option, + Option, + Option, + ) { + ( + self.alg.clone(), + self.n.clone(), + self.e.clone(), + self.x.clone(), + self.y.clone(), + ) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct JWS { + protected: String, + payload: String, + signature: String, +} + +impl JWS { + pub fn new(protected: &ACMEProtectedHeader, payload: &T) -> Self + where + T: serde::Serialize + ?Sized, + { + JWS { + protected: to_base64(protected).expect("could not encode protected header"), + payload: to_base64(payload).expect("could not encode payload"), + signature: Default::default(), + } + } + + pub fn protected(&mut self) -> Result { + let res = serde_json::from_slice::(&base64::decode_config( + self.protected.clone(), + base64::URL_SAFE_NO_PAD, + )?)?; + + Ok(res) + } + + pub fn payload(&self) -> Result + where + T: for<'de> serde::Deserialize<'de>, + { + Ok(serde_json::from_slice(&base64::decode_config( + self.payload.clone(), + base64::URL_SAFE_NO_PAD, + )?)?) + } + + pub fn verify(&self, key: ACMEKey) -> Result { + let to_verify = format!("{}.{}", self.protected, self.payload); + let digest = sha256(to_verify.as_bytes()); + + let decoded = base64::decode_config(self.signature.clone(), base64::URL_SAFE_NO_PAD)?; + + match key { + ACMEKey::ECDSA(key) => { + if decoded.len() != 64 { + return Err(JWSValidationError::SignatureDecode); + } + + let r = BigNum::from_slice(&decoded[0..32])?; + let s = BigNum::from_slice(&decoded[32..64])?; + + let signature = + EcdsaSig::from_private_components(r, s).expect("could not program components"); + + Ok(signature.verify(&digest, &key)?) + } + ACMEKey::RSA(key) => { + let pkey = PKey::from_rsa(key)?; + let mut verifier = Verifier::new(MessageDigest::sha256(), pkey.as_ref())?; + verifier.update(to_verify.as_bytes())?; + Ok(verifier.verify(&decoded)?) + } + } + } + + pub fn verify_with_signature( + &mut self, + key: ACMEKey, + signature: String, + ) -> Result { + self.signature = signature; + self.verify(key) + } + + pub fn sign(&mut self, key: ACMEPrivateKey) -> Result { + let to_sign = format!("{}.{}", self.protected, self.payload); + + match key { + ACMEPrivateKey::ECDSA(key) => { + let digest = sha256(to_sign.as_bytes()); + let signature = EcdsaSig::sign(&digest, &key)?; + + let r = signature.r().to_vec(); + let s = signature.s().to_vec(); + + let mut v = Vec::with_capacity(r.len() + s.len()); + let pad = &[0; 32]; + v.extend_from_slice( + &pad.iter() + .take(32 - r.len()) + .map(|c| *c) + .collect::>(), + ); + v.extend_from_slice(&r); + v.extend_from_slice( + &pad.iter() + .take(32 - s.len()) + .map(|c| *c) + .collect::>(), + ); + v.extend_from_slice(&s); + + self.signature = base64::encode_config(v, base64::URL_SAFE_NO_PAD); + + Ok(self.clone()) + } + ACMEPrivateKey::RSA(key) => { + let pkey = PKey::from_rsa(key)?; + let mut signer = Signer::new(MessageDigest::sha256(), pkey.as_ref())?; + signer.update(to_sign.as_bytes())?; + + self.signature = + base64::encode_config(signer.sign_to_vec().unwrap(), base64::URL_SAFE_NO_PAD); + + Ok(self.clone()) + } + } + } + + pub(crate) fn into_db_jwk(&self) -> Result { + let aph = self.clone().protected()?; + if aph.jwk.is_none() { + return Err(JWSError::InvalidPublicKey); + } + + let jwk = aph.jwk.unwrap(); + + Ok(crate::models::account::JWK { + nonce_key: make_nonce(crate::models::NONCE_KEY_SIZE), + n: jwk.n.clone(), + e: jwk.e.clone(), + x: jwk.x.clone(), + y: jwk.y.clone(), + alg: aph.alg.clone(), + id: None, + created_at: chrono::DateTime::::from(SystemTime::now()), + deleted_at: None, + }) + } +} + +mod tests { + extern crate spectral; + + #[tokio::test(flavor = "multi_thread")] + async fn aph_test_validate() { + use super::*; + use crate::test::TestService; + use spectral::prelude::*; + + let svc = TestService::new("aph_test_validate").await; + + let good_url = Url::parse("https://one/two").unwrap(); + let bad_url = Url::parse("https://not/one/two").unwrap(); + + let validator = crate::acme::PostgresNonceValidator::new(svc.pg.db()); + + let kid = Url::parse("http://127.0.0.1:8000/accounts/this_is_a_kid").unwrap(); + + let aph = ACMEProtectedHeader::new_kid(kid.clone(), good_url.clone(), "😀".to_string()); + assert_that!(aph.validate(good_url.clone(), validator.clone()).await) + .is_err_containing(ACMEValidationError::NonceDecodeError); + + let aph = ACMEProtectedHeader::new_kid(kid.clone(), good_url.clone(), "".to_string()); + + assert_that!(aph.validate(good_url.clone(), validator.clone()).await) + .is_err_containing(ACMEValidationError::NonceNotFound); + + let aph = ACMEProtectedHeader::new_kid( + kid.clone(), + good_url.clone(), + validator.make().await.expect("could not insert a nonce"), + ); + assert_that!(aph.validate(good_url.clone(), validator.clone()).await).is_ok(); + + assert_that!( + ACMEProtectedHeader::new_kid( + kid.clone(), + good_url.clone(), + validator.make().await.expect("could not insert a nonce") + ) + .validate(bad_url.clone(), validator.clone()) + .await + ) + .is_err_containing(ACMEValidationError::URLNotEqual( + bad_url.to_string(), + good_url.to_string(), + )); + } + + #[tokio::test(flavor = "multi_thread")] + async fn jws_validate() { + use super::{ACMEProtectedHeader, JWS}; + use openssl::{ec::EcKey, rsa::Rsa}; + use serde::Serialize; + use spectral::prelude::*; + use std::convert::TryInto; + use url::Url; + + #[derive(Serialize, Clone)] + struct JWSTest { + artist: String, + song: String, + } + + let payload = JWSTest { + artist: "Tone Loc".to_string(), + song: "Funky Cold Medina".to_string(), + }; + + let kid = Url::parse("http://127.0.0.1:8000/accounts/this_is_a_kid").unwrap(); + + // we won't be validating the protected headers in this pass so garbage for the nonce is OK + let protected = ACMEProtectedHeader::new_kid( + kid, + Url::parse("http://good.url").unwrap(), + "1234".to_string(), + ); + + // 1000 iterations of this crap because we were seeing some stuff + // in parallel becausa I hate waiting + let mut handles = Vec::new(); + + for _ in 0..10 { + let payload = payload.clone(); + let protected = protected.clone(); + let handle = tokio::spawn(async move { + for _ in 0..100 { + let mut jws = JWS::new(&protected, &payload); + let eckey = EcKey::generate(super::crypto::EC_GROUP.as_ref()).unwrap(); + + let signed = jws.sign(super::ACMEPrivateKey::ECDSA(eckey.clone())); + assert_that!(signed).is_ok(); + + let mut jws = signed.unwrap(); + let acmekey: &super::ACMEKey = &eckey.public_key().try_into().unwrap(); + let res = jws.verify(acmekey.clone()); + assert_that!(res).is_ok(); + assert_that!(res.unwrap()).is_true(); + + let rsa = Rsa::generate(4096).unwrap(); + let pubkey = Rsa::from_public_components( + rsa.n().to_owned().unwrap(), + rsa.e().to_owned().unwrap(), + ) + .unwrap(); + + let signed = jws.sign(super::ACMEPrivateKey::RSA(rsa.clone())); + assert_that!(signed).is_ok(); + + let jws = signed.unwrap(); + let res = jws.verify(pubkey.try_into().unwrap()); + assert_that!(res).is_ok(); + assert_that!(res.unwrap()).is_true(); + } + }); + + handles.push(handle); + } + + for handle in handles { + handle.await.unwrap(); + } + } + + #[test] + fn jwk_into() { + use openssl::ec::EcKey; + use openssl::rsa::Rsa; + use spectral::prelude::*; + use std::convert::TryInto; + + let eckey = EcKey::generate(&super::crypto::EC_GROUP).unwrap(); + let jwk: Result = eckey.public_key().try_into(); + assert_that!(jwk).is_ok(); + + let eckey2 = match jwk.unwrap() { + super::ACMEKey::ECDSA(key) => Some(key), + _ => None, + }; + + assert_that!(eckey2).is_some(); + let mut ctx = openssl::bn::BigNumContext::new().unwrap(); + + let res = eckey.public_key().eq( + &super::crypto::EC_GROUP, + eckey2.unwrap().public_key(), + &mut ctx, + ); + assert_that!(res).is_ok(); + assert_that!(res.unwrap()).is_true(); + + let rsa = Rsa::generate(4096).unwrap(); + let pubkey = + Rsa::from_public_components(rsa.n().to_owned().unwrap(), rsa.e().to_owned().unwrap()) + .unwrap(); + + let jwk: Result = pubkey.clone().try_into(); + + assert_that!(jwk).is_ok(); + + let rsa2 = match jwk.unwrap() { + super::ACMEKey::RSA(key) => Some(key), + _ => None, + }; + + assert_that!(rsa2).is_some(); + + assert_that!(pubkey.public_key_to_der().unwrap()) + .is_equal_to(rsa2.unwrap().public_key_to_der().unwrap()); + } +} diff --git a/src/acme/mod.rs b/src/acme/mod.rs new file mode 100644 index 0000000..45102b5 --- /dev/null +++ b/src/acme/mod.rs @@ -0,0 +1,165 @@ +pub mod ca; +pub mod challenge; +pub mod dns; +pub mod handlers; +pub mod jose; + +use std::{collections::HashSet, convert::TryFrom, sync::Arc}; + +use hyper::Body; +use tokio::sync::Mutex; + +use async_trait::async_trait; +use lazy_static::lazy_static; +use serde::{Deserialize, Serialize}; + +use crate::{ + errors::{ + db::{LoadError, SaveError}, + ACMEValidationError, + }, + models::{nonce::Nonce, Postgres, Record}, + util::make_nonce, +}; + +use self::dns::DNSName; + +const DEFAULT_NONCE_SIZE: usize = 16; +const ACME_CONTENT_TYPE: &str = "application/jose+json"; + +lazy_static! { + /// List of supported algorithms, with the ACME preferred one first. This is relied on in this + /// code. + pub static ref ACME_EXPECTED_ALGS: [String; 2] = ["ES256".to_string(), "RS256".to_string()]; +} + +/// A Result<> that calls can return to trampoline through ratpack handlers swiftly by triggering HTTP +/// "problem documents" (RFC7807) to be returned immediately from ratpack's routing framework. +#[must_use] +pub enum ACMEResult { + Ok(hyper::Response), + Err(crate::errors::Error), +} + +impl Into, serde_json::Error>> for ACMEResult { + fn into(self) -> Result, serde_json::Error> { + match self { + ACMEResult::Ok(res) => Ok(res), + ACMEResult::Err(e) => { + return Ok(hyper::Response::builder() + .status(500) + .header("content-type", "application/json") + .body(Body::from(serde_json::to_string(&e)?)) + .unwrap()) + } + } + } +} + +impl From for ACMEResult { + fn from(e: crate::errors::Error) -> Self { + return ACMEResult::Err(e); + } +} + +/// Defines the notion of an "identifier" from the ACME specification. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] // NOTE: other identifier types as they are added may break this +#[serde(tag = "type", content = "value")] +pub enum ACMEIdentifier { + DNS(dns::DNSName), // NOTE: DNS names cannot be wildcards. +} + +impl TryFrom for ACMEIdentifier { + type Error = LoadError; + + fn try_from(value: String) -> Result { + match DNSName::from_str(&value) { + Ok(x) => Ok(ACMEIdentifier::DNS(x)), + Err(e) => Err(LoadError::Generic(e.to_string())), + } + } +} + +impl ACMEIdentifier { + pub fn to_string(self) -> String { + match self { + ACMEIdentifier::DNS(name) => name.to_string(), + } + } +} + +#[async_trait] +pub trait NonceValidator { + /// This function must mutate the underlying storage to prune the nonce it's validating after a + /// successful fetch. One may use ACMEValidationError::NonceFetchError to specify errors with + /// fetching the Nonce. Likewise, ACMEValidationError::NonceNotFound is expected to be returned + /// when the nonce cannot be located (validation error). + async fn validate(&self, nonce: &str) -> Result<(), ACMEValidationError>; + + /// This function is expected to always make & store a new nonce; if it fails to add because it already + /// exists, it should return error. + async fn make(&self) -> Result; +} + +/// Defines a basic (very basic) Nonce validation system +#[derive(Debug, Clone)] +pub struct SetValidator(Arc>>); + +impl Default for SetValidator { + fn default() -> Self { + SetValidator(Arc::new(Mutex::new(HashSet::new()))) + } +} + +#[async_trait] +impl NonceValidator for SetValidator { + async fn validate(&self, nonce: &str) -> Result<(), ACMEValidationError> { + if self.0.lock().await.remove(nonce) { + Ok(()) + } else { + Err(ACMEValidationError::NonceNotFound) + } + } + + async fn make(&self) -> Result { + let nonce = make_nonce(None); + + if !self.0.lock().await.insert(nonce.clone()) { + return Err(SaveError::Generic("could not persist nonce".to_string())); + } + + Ok(nonce) + } +} + +#[derive(Clone)] +pub struct PostgresNonceValidator(crate::models::Postgres); + +impl PostgresNonceValidator { + pub fn new(pg: Postgres) -> Self { + Self(pg) + } +} + +#[async_trait] +impl NonceValidator for PostgresNonceValidator { + async fn validate(&self, nonce: &str) -> Result<(), ACMEValidationError> { + let nonce = match Nonce::find(nonce.to_string(), self.0.clone()).await { + Ok(nonce) => nonce, + Err(_) => return Err(ACMEValidationError::NonceNotFound), + }; + + if let Err(_) = nonce.delete(self.0.clone()).await { + return Err(ACMEValidationError::NonceNotFound); + } + + Ok(()) + } + + async fn make(&self) -> Result { + let mut nonce = Nonce::new(); + nonce.create(self.0.clone()).await?; + Ok(nonce.id().unwrap().unwrap()) + } +} diff --git a/src/acmed.rs b/src/acmed.rs new file mode 100644 index 0000000..5fb30d3 --- /dev/null +++ b/src/acmed.rs @@ -0,0 +1,146 @@ +use std::{ + io::Write, + ops::Add, + time::{Duration, SystemTime}, +}; + +use openssl::{ + error::ErrorStack, + pkey::{PKey, Private}, + rsa::Rsa, + x509::{X509Extension, X509Name, X509Req}, +}; + +use coyote::{ + acme::{ + ca::{CACollector, CA}, + challenge::Challenger, + handlers::{configure_routes, ServiceState}, + PostgresNonceValidator, + }, + models::Postgres, +}; + +use ratpack::prelude::*; + +const CHALLENGE_EXPIRATION: i64 = 600; + +#[tokio::main] +async fn main() -> Result<(), ServerError> { + // set HOSTNAME in your environment to something your webserver or certbot can hit; otherwise + // it will be 'localhost'. a cert will be generated with this name to serve the service with. + // This is really important. + let dnsname = &std::env::var("HOSTNAME").unwrap_or("localhost".to_string()); + + env_logger::builder() + .filter_level(log::LevelFilter::Info) + .init(); + // + // to start a database to work with me: + // + // make postgres + // + let pg = Postgres::new("host=localhost dbname=coyote user=postgres", 10) + .await + .unwrap(); + pg.migrate().await.unwrap(); + + let c = Challenger::new(Some(chrono::Duration::seconds(CHALLENGE_EXPIRATION))); + let ca = CACollector::new(Duration::MAX); + + let pg2 = pg.clone(); + let c2 = c.clone(); + + // FIXME probably need something magical with signals here to manage shutdown that I don't want to think about yet + tokio::spawn(async move { + loop { + // FIXME whitelist all challenge requests. This is not how ACME is supposed to work. You have to write this. + c2.tick(|_c| Some(())).await; + // NOTE this will explode violently if it unwraps to error, e.g. if the db goes down. + c2.reconcile(pg2.clone()).await.unwrap(); + + tokio::time::sleep(Duration::new(1, 0)).await; + } + }); + + let mut ca2 = ca.clone(); + let (csr, key) = generate_csr(dnsname)?; + + let test_ca = CA::new_test_ca().unwrap(); + let cert = test_ca.generate_and_sign_cert( + csr, + SystemTime::now(), + SystemTime::now().add(Duration::from_secs(365 * 24 * 60 * 60)), + )?; + + let test_ca2 = test_ca.clone(); + + tokio::spawn(async move { + // after CA generation, write out the key and certificate + let mut buf = std::fs::File::create("ca.key").unwrap(); + let private = test_ca + .clone() + .private_key() + .private_key_to_pem_pkcs8() + .unwrap(); + buf.write(&private).unwrap(); + + let mut buf = std::fs::File::create("ca.pem").unwrap(); + let cert = test_ca.clone().certificate().to_pem().unwrap(); + buf.write(&cert).unwrap(); + + ca2.spawn_collector(|| -> Result { Ok(test_ca.clone()) }) + .await + }); + + let validator = PostgresNonceValidator::new(pg.clone()); + let ss = ServiceState::new( + format!("https://{}:8000", dnsname), + pg.clone(), + c, + ca, + validator, + )?; + let mut app = App::with_state(ss); + + configure_routes(&mut app, None); + + let key = key.private_key_to_der()?; + + let config = rustls::ServerConfig::builder() + .with_safe_defaults() + .with_no_client_auth() + .with_single_cert( + vec![ + rustls::Certificate(cert.to_der()?), + rustls::Certificate(test_ca2.certificate().to_der()?), + ], + rustls::PrivateKey(key), + )?; + + Ok(app.serve_tls("0.0.0.0:8000", config).await?) +} + +fn generate_csr(dnsname: &str) -> Result<(X509Req, Rsa), ErrorStack> { + log::info!("hostname: {}", dnsname); + let mut namebuilder = X509Name::builder().unwrap(); + namebuilder.append_entry_by_text("CN", dnsname).unwrap(); + let mut req = X509Req::builder().unwrap(); + req.set_subject_name(&namebuilder.build()).unwrap(); + let mut extensions = openssl::stack::Stack::new()?; + extensions.push(X509Extension::new( + None, + Some(&req.x509v3_context(None)), + "subjectAltName", + &format!("DNS:{}", dnsname), + )?)?; + req.add_extensions(&extensions)?; + req.set_version(2)?; + + let key = Rsa::generate(4096).unwrap(); + // FIXME there has to be a much better way of doing this! + let pubkey = PKey::public_key_from_pem(&key.public_key_to_pem().unwrap()).unwrap(); + + req.set_pubkey(&pubkey).unwrap(); + Ok((req.build(), key)) +} diff --git a/src/errors/acme.rs b/src/errors/acme.rs new file mode 100644 index 0000000..5ba324e --- /dev/null +++ b/src/errors/acme.rs @@ -0,0 +1,80 @@ +use super::ACMEValidationError; +use openssl::error::ErrorStack; +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +#[derive(Clone, Error, Debug, PartialEq, Serialize, Deserialize)] +pub enum JWSValidationError { + #[error("general JWS handshake error: {0:?}")] + General(JWSError), + #[error("base64 error decoding signature")] + SignatureDecode, + #[error("openssl internal error managing signature: {0}")] + OpenSSL(String), + #[error("error validating ACME payload: {0}")] + ACMEValidationError(ACMEValidationError), +} + +impl From for JWSValidationError { + fn from(e: JWSError) -> Self { + Self::General(e) + } +} + +impl From for JWSValidationError { + fn from(_: base64::DecodeError) -> Self { + Self::SignatureDecode + } +} + +impl From for JWSValidationError { + fn from(es: ErrorStack) -> Self { + let errors = es + .errors() + .into_iter() + .map(|x| x.to_string()) + .collect::>(); + Self::OpenSSL(errors.join("\n")) + } +} + +#[derive(Clone, Error, Debug, PartialEq, Serialize, Deserialize)] +pub enum JWSError { + #[error("openssl error: {0}")] + OpenSSL(String), + #[error("error encoding JWS component: {0}")] + Encode(String), + #[error("serde error decoding JSON: {0}")] + JSONDecode(String), + #[error("base64 error decoding payload")] + PayloadDecode, + #[error("invalid public key")] + InvalidPublicKey, + #[error("missing JWS")] + Missing, + #[error("validation failed")] + ValidationFailed, +} + +impl From for JWSError { + fn from(es: ErrorStack) -> Self { + let errors = es + .errors() + .into_iter() + .map(|x| x.to_string()) + .collect::>(); + Self::OpenSSL(errors.join("\n")) + } +} + +impl From for JWSError { + fn from(_: base64::DecodeError) -> Self { + Self::PayloadDecode + } +} + +impl From for JWSError { + fn from(e: serde_json::Error) -> Self { + Self::JSONDecode(e.to_string()) + } +} diff --git a/src/errors/db.rs b/src/errors/db.rs new file mode 100644 index 0000000..42defb8 --- /dev/null +++ b/src/errors/db.rs @@ -0,0 +1,132 @@ +use deadpool_postgres::PoolError; +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum ConnectionError { + #[error("Unspecified connection error: {0}")] + Generic(String), + #[error("Database error: {0}")] + DB(tokio_postgres::Error), + #[error("Connection pool error: {0}")] + Pool(PoolError), + #[error("Migration run error: {0}")] + Migrations(MigrationError), +} + +impl From for ConnectionError { + fn from(tp: tokio_postgres::Error) -> Self { + Self::DB(tp) + } +} + +impl From for ConnectionError { + fn from(me: MigrationError) -> Self { + Self::Migrations(me) + } +} + +impl From for ConnectionError { + fn from(pe: PoolError) -> Self { + Self::Pool(pe) + } +} + +#[derive(Debug, Error)] +pub enum SaveError { + #[error("error while saving: {0}")] + Generic(String), + #[error("database error while saving: {0}")] + DBError(tokio_postgres::Error), + #[error("error while encoding json: {0}")] + JSONCodecError(String), + #[error("error while refreshing results after write: {0}")] + ReloadError(LoadError), + #[error("db connection error: {0}")] + ConnectionError(ConnectionError), +} + +impl From for SaveError { + fn from(e: ConnectionError) -> Self { + Self::ConnectionError(e) + } +} + +impl From for SaveError { + fn from(e: LoadError) -> Self { + return Self::JSONCodecError(e.to_string()); + } +} + +impl From for SaveError { + fn from(e: serde_json::Error) -> Self { + return Self::JSONCodecError(e.to_string()); + } +} + +impl From for SaveError { + fn from(tp: tokio_postgres::Error) -> Self { + Self::DBError(tp) + } +} + +#[derive(Debug, Error)] +pub enum LoadError { + #[error("error while loading: {0}")] + Generic(String), + #[error("database error while loading: {0}")] + DBError(tokio_postgres::Error), + #[error("error while decoding json: {0}")] + JSONCodecError(String), + #[error("error while connecting to database: {0}")] + ConnectionError(ConnectionError), + #[error("invalid token in enum translation")] + InvalidEnum, + #[error("key not found")] + NotFound, +} + +impl From for LoadError { + fn from(ce: ConnectionError) -> Self { + Self::ConnectionError(ce) + } +} + +impl From for LoadError { + fn from(e: serde_json::Error) -> Self { + return Self::JSONCodecError(e.to_string()); + } +} + +impl From for LoadError { + fn from(e: tokio_postgres::Error) -> Self { + Self::DBError(e) + } +} + +#[derive(Debug, Error)] +pub enum MigrationError { + #[error("Unspecified migration error: {0}")] + Generic(String), + #[error("Database error: {0}")] + DBError(tokio_postgres::Error), + #[error("migration error: {0}")] + Error(refinery::Error), +} + +impl From for MigrationError { + fn from(e: tokio_postgres::Error) -> Self { + Self::DBError(e) + } +} + +impl From for MigrationError { + fn from(e: refinery::Error) -> Self { + Self::Error(e) + } +} + +impl From for MigrationError { + fn from(e: ConnectionError) -> Self { + Self::Generic(e.to_string()) + } +} diff --git a/src/errors/mod.rs b/src/errors/mod.rs new file mode 100644 index 0000000..586f1d0 --- /dev/null +++ b/src/errors/mod.rs @@ -0,0 +1,495 @@ +use crate::acme::ACMEIdentifier; +use http::StatusCode; +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +use self::db::LoadError; + +pub mod acme; +pub mod db; + +#[derive(Clone, Debug, Error)] +pub enum HandlerError { + #[error("generic handler error: {0}")] + Generic(String), + #[error("ACME validation error: {0}")] + ACMEValidationError(ACMEValidationError), +} + +impl From for HandlerError { + fn from(ave: ACMEValidationError) -> Self { + HandlerError::ACMEValidationError(ave) + } +} + +impl From for HandlerError { + fn from(upe: url::ParseError) -> Self { + HandlerError::Generic(upe.to_string()) + } +} + +impl From for Error { + fn from(he: HandlerError) -> Self { + match he { + HandlerError::Generic(he) => Error::new(RFCError::Malformed, &he), + HandlerError::ACMEValidationError(ave) => Error::from(ave), + } + } +} + +#[derive(Error, Clone, Debug, PartialEq, Serialize, Deserialize)] +pub enum ACMEValidationError { + #[error("No key provided")] + NoKeyProvided, + + #[error("url {0} not equal to protected header value: {1}")] + URLNotEqual(String, String), + + #[error("alg must be {0}, not {1}")] + AlgNotEqual(String, String), + + #[error("nonce decode error")] + NonceDecodeError, + + #[error("could not validate nonce")] + NonceNotFound, + + #[error("Nonce fetching error: {0}")] + NonceFetchError(String), + + #[error("Other error: {0}")] + Other(String), + + #[error("invalid signature")] + InvalidSignature, + + #[error("invalid request")] + InvalidRequest, + + #[error("account does not exist")] + AccountDoesNotExist, +} + +impl ratpack::ToStatus for Error { + fn to_status(&self) -> ratpack::Error { + match self.error_type { + RFCError::BadNonce | RFCError::BadPublicKey | RFCError::BadSignatureAlgorithm => { + ratpack::Error::StatusCode(StatusCode::BAD_REQUEST, self.detail.clone()) + } + _ => ratpack::Error::StatusCode(StatusCode::FORBIDDEN, self.detail.clone()), + } + } +} + +impl ratpack::ToStatus for ACMEValidationError { + fn to_status(&self) -> ratpack::Error { + let e: Error = self.clone().into(); + e.to_status() + } +} + +impl From for Error { + fn from(ave: ACMEValidationError) -> Self { + match ave.clone() { + ACMEValidationError::NoKeyProvided + | ACMEValidationError::NonceDecodeError + | ACMEValidationError::InvalidRequest => { + Self::new(RFCError::Malformed, &ave.to_string()) + } + ACMEValidationError::Other(_) + | ACMEValidationError::NonceNotFound + | ACMEValidationError::NonceFetchError(_) + | ACMEValidationError::URLNotEqual(_, _) + | ACMEValidationError::InvalidSignature => { + Self::new(RFCError::Unauthorized, &ave.to_string()) + } + ACMEValidationError::AlgNotEqual(_, _) => { + Self::new(RFCError::BadSignatureAlgorithm, &ave.to_string()) + } + ACMEValidationError::AccountDoesNotExist => { + Self::new(RFCError::AccountDoesNotExist, &ave.to_string()) + } + } + } +} + +impl From for LoadError { + fn from(u: url::ParseError) -> Self { + LoadError::Generic(u.to_string()) + } +} + +impl ratpack::ToStatus for acme::JWSError { + fn to_status(&self) -> ratpack::Error { + let e: Error = self.clone().into(); + e.to_status() + } +} + +impl From for Error { + fn from(jws: acme::JWSError) -> Self { + match jws { + acme::JWSError::InvalidPublicKey => Self::new(RFCError::BadPublicKey, &jws.to_string()), + acme::JWSError::Missing => Self::new(RFCError::Malformed, &jws.to_string()), + _ => Self::new( + RFCError::Malformed, + "malformed content during generation or parsing of JWS envelope", + ), + } + } +} + +impl ratpack::ToStatus for acme::JWSValidationError { + fn to_status(&self) -> ratpack::Error { + let e: Error = self.clone().into(); + e.to_status() + } +} + +impl From for Error { + fn from(jve: acme::JWSValidationError) -> Self { + match jve { + acme::JWSValidationError::ACMEValidationError(e) => e.into(), + acme::JWSValidationError::General(e) => e.into(), + _ => Self::new( + RFCError::Malformed, + "malformed content during generation or parsing of JWS envelope", + ), + } + } +} + +/// All error return values inherit from the URN below. +const ACME_URN_NAMESPACE: &str = "urn:ietf:params:acme:error:"; + +/// RFCError is for reporting errors conformant to the ACME RFC. +#[derive(Debug, PartialEq, Clone, Serialize, Deserialize)] +pub enum RFCError { + AccountDoesNotExist, + AlreadyRevoked, + BadCSR, + BadNonce, + BadPublicKey, + BadRevocationReason, + BadSignatureAlgorithm, + CAA, + Compound, + Connection, + DNS, + ExternalAccountRequired, + IncorrectResponse, + InvalidContact, + Malformed, + OrderNotReady, + RateLimited, + RejectedIdentifier, + ServerInterval, + TLS, + Unauthorized, + UnsupportedContact, + UnsupportedIdentifier, + UserActionRequired, +} + +impl RFCError { + /// to_string converts an enum error into the string you should return to the client. + fn to_string(self) -> String { + ACME_URN_NAMESPACE.to_string() + + match self { + RFCError::AccountDoesNotExist => "accountDoesNotExist", + RFCError::AlreadyRevoked => "alreadyRevoked", + RFCError::BadCSR => "badCSR", + RFCError::BadNonce => "badNonce", + RFCError::BadPublicKey => "badPublicKey", + RFCError::BadRevocationReason => "badRevocationReason", + RFCError::BadSignatureAlgorithm => "badSignatureAlgorithm", + RFCError::CAA => "caa", + RFCError::Compound => "compound", + RFCError::Connection => "connection", + RFCError::DNS => "dns", + RFCError::ExternalAccountRequired => "externalAccountRequired", + RFCError::IncorrectResponse => "incorrectResponse", + RFCError::InvalidContact => "invalidContact", + RFCError::Malformed => "malformed", + RFCError::OrderNotReady => "orderNotReady", + RFCError::RateLimited => "rateLimited", + RFCError::RejectedIdentifier => "rejectedIdentifier", + RFCError::ServerInterval => "serverInterval", + RFCError::TLS => "tls", + RFCError::Unauthorized => "unauthorized", + RFCError::UnsupportedContact => "unsupportedContact", + RFCError::UnsupportedIdentifier => "unsupportedIdentifier", + RFCError::UserActionRequired => "userActionRequired", + } + } +} + +/// ValidationError are for errors in validation of the .. error. See Error::validate for more +/// information. +#[derive(Debug, PartialEq)] +pub enum ValidationError { + /// MissingContext is returned when details about a specific error are required, and missing + MissingContext(&'static str), + /// This error is returned when critical missing detail about the request's subject is missing + MissingTarget, + /// This error is returned when the identifier's subject is parsed invalid. + InvalidIdentifier, +} + +/// Error is the error returned to the client. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Error { + #[serde(rename = "type")] + error_type: RFCError, + #[serde(skip_serializing_if = "Option::is_none")] + subproblems: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + identifier: Option, + detail: String, + #[serde(skip_serializing_if = "Option::is_none")] + external_account_binding: Option, + #[serde(skip_serializing_if = "Option::is_none")] + user_action_instance: Option, +} + +impl Error { + /// new constructs a new error struct. Use methods like subproblems() and identifier() to build + /// a fully validate()able struct from parts. + pub fn new(error_type: RFCError, detail: &str) -> Self { + Self { + error_type, + detail: detail.to_string(), + subproblems: None, + identifier: None, + user_action_instance: None, + external_account_binding: None, + } + } + + /// validate the error. For an error to be valid it must: + /// - have an identifier, or subproblems, and all its subproblems must validate. + /// - For RFCError::ExternalAccountRequired, external_account_binding() must be called to set data. + /// - For RFCError::UserActionRequired, user_action_instance() must be called to set data. + /// - For RFCError::Compound, subproblems must exist. + pub fn validate(self) -> Result<(), ValidationError> { + match self.error_type { + RFCError::ExternalAccountRequired => { + if self.external_account_binding.is_none() { + return Err(ValidationError::MissingContext( + "missing external_account_binding", + )); + } + } + RFCError::UserActionRequired => { + if self.user_action_instance.is_none() { + return Err(ValidationError::MissingContext( + "missing user_action_instance", + )); + } + } + RFCError::Compound => { + if self.subproblems.is_none() { + return Err(ValidationError::MissingContext( + "missing subproblems in compound error", + )); + } + } + _ => {} + } + + let sp = self.subproblems; + + if self.identifier.is_none() && sp.is_none() + || (sp.is_some() && sp.clone().unwrap().is_empty()) + { + return Err(ValidationError::MissingTarget); + } + + if sp.is_some() { + for prob in sp.unwrap() { + if let Err(r) = prob.validate() { + return Err(r); + } + } + } + + if self.identifier.is_some() { + match self.identifier.unwrap() { + ACMEIdentifier::DNS(name) => { + if name.0.is_empty() + || name.0.is_root() + || name.0.is_localhost() + || name.0.is_wildcard() + // no TLDs + || name.0.num_labels() < 2 + { + return Err(ValidationError::InvalidIdentifier); + } + } + } + } + + Ok(()) + } + + pub fn subproblems(mut self, problems: Vec) -> Self { + self.subproblems = Some(problems); + self + } + + pub fn identifier(mut self, identifier: ACMEIdentifier) -> Self { + self.identifier = Some(identifier); + self + } + + pub fn user_action_instance(mut self, user_action_instance: String) -> Self { + self.user_action_instance = Some(user_action_instance); + self + } + + pub fn external_account_binding(mut self, external_account_binding: String) -> Self { + self.external_account_binding = Some(external_account_binding); + self + } +} + +impl std::fmt::Display for Error { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.detail) + } +} + +mod tests { + #[test] + fn test_validate() { + use super::{Error, RFCError, ValidationError}; + use crate::acme::{dns::DNSName, ACMEIdentifier}; + use spectral::prelude::*; + + assert_that!(Error::new(RFCError::AccountDoesNotExist, "these are the details").validate()) + .is_err_containing(ValidationError::MissingTarget); + + assert_that!( + Error::new(RFCError::AccountDoesNotExist, "these are the details") + .identifier(ACMEIdentifier::DNS(DNSName::from_str("foo.com").unwrap())) + .validate() + ) + .is_ok(); + + assert_that!( + Error::new(RFCError::AccountDoesNotExist, "these are the details") + .subproblems(vec![]) + .validate() + ) + .is_err_containing(ValidationError::MissingTarget); + + assert_that!( + Error::new(RFCError::AccountDoesNotExist, "these are the details") + .subproblems(vec![Error::new( + RFCError::AccountDoesNotExist, + "these are the details" + ) + .identifier(ACMEIdentifier::DNS(DNSName::from_str("foo.com").unwrap()))]) + .validate() + ) + .is_ok(); + + let bad_names = vec!["*.foo.com", ".", "yodawg", "localhost"]; + + for name in bad_names { + assert_that!( + Error::new(RFCError::AccountDoesNotExist, "these are the details") + .subproblems(vec![Error::new( + RFCError::AccountDoesNotExist, + "these are the details" + ) + .identifier(ACMEIdentifier::DNS(DNSName::from_str(name).unwrap()))]) + .validate() + ) + .named(name) + .is_err_containing(ValidationError::InvalidIdentifier); + } + + assert_that!( + Error::new(RFCError::AccountDoesNotExist, "these are the details") + .subproblems(vec![Error::new( + RFCError::AccountDoesNotExist, + "these are the details" + )]) + .validate() + ) + .is_err_containing(ValidationError::MissingTarget); + + assert_that!( + Error::new(RFCError::ExternalAccountRequired, "these are the details").validate() + ) + .is_err_containing(ValidationError::MissingContext( + "missing external_account_binding", + )); + + assert_that!(Error::new(RFCError::UserActionRequired, "these are the details").validate()) + .is_err_containing(ValidationError::MissingContext( + "missing user_action_instance", + )); + + assert_that!(Error::new(RFCError::Compound, "these are the details").validate()) + .is_err_containing(ValidationError::MissingContext( + "missing subproblems in compound error", + )); + } + + #[test] + fn test_to_string() { + use super::RFCError; + use spectral::prelude::*; + + assert_that!(RFCError::AccountDoesNotExist.to_string()) + .is_equal_to("urn:ietf:params:acme:error:accountDoesNotExist".to_string()); + assert_that!(RFCError::AlreadyRevoked.to_string()) + .is_equal_to("urn:ietf:params:acme:error:alreadyRevoked".to_string()); + assert_that!(RFCError::BadCSR.to_string()) + .is_equal_to("urn:ietf:params:acme:error:badCSR".to_string()); + assert_that!(RFCError::BadNonce.to_string()) + .is_equal_to("urn:ietf:params:acme:error:badNonce".to_string()); + assert_that!(RFCError::BadPublicKey.to_string()) + .is_equal_to("urn:ietf:params:acme:error:badPublicKey".to_string()); + assert_that!(RFCError::BadRevocationReason.to_string()) + .is_equal_to("urn:ietf:params:acme:error:badRevocationReason".to_string()); + assert_that!(RFCError::BadSignatureAlgorithm.to_string()) + .is_equal_to("urn:ietf:params:acme:error:badSignatureAlgorithm".to_string()); + assert_that!(RFCError::CAA.to_string()) + .is_equal_to("urn:ietf:params:acme:error:caa".to_string()); + assert_that!(RFCError::Compound.to_string()) + .is_equal_to("urn:ietf:params:acme:error:compound".to_string()); + assert_that!(RFCError::Connection.to_string()) + .is_equal_to("urn:ietf:params:acme:error:connection".to_string()); + assert_that!(RFCError::DNS.to_string()) + .is_equal_to("urn:ietf:params:acme:error:dns".to_string()); + assert_that!(RFCError::ExternalAccountRequired.to_string()) + .is_equal_to("urn:ietf:params:acme:error:externalAccountRequired".to_string()); + assert_that!(RFCError::IncorrectResponse.to_string()) + .is_equal_to("urn:ietf:params:acme:error:incorrectResponse".to_string()); + assert_that!(RFCError::InvalidContact.to_string()) + .is_equal_to("urn:ietf:params:acme:error:invalidContact".to_string()); + assert_that!(RFCError::Malformed.to_string()) + .is_equal_to("urn:ietf:params:acme:error:malformed".to_string()); + assert_that!(RFCError::OrderNotReady.to_string()) + .is_equal_to("urn:ietf:params:acme:error:orderNotReady".to_string()); + assert_that!(RFCError::RateLimited.to_string()) + .is_equal_to("urn:ietf:params:acme:error:rateLimited".to_string()); + assert_that!(RFCError::RejectedIdentifier.to_string()) + .is_equal_to("urn:ietf:params:acme:error:rejectedIdentifier".to_string()); + assert_that!(RFCError::ServerInterval.to_string()) + .is_equal_to("urn:ietf:params:acme:error:serverInterval".to_string()); + assert_that!(RFCError::TLS.to_string()) + .is_equal_to("urn:ietf:params:acme:error:tls".to_string()); + assert_that!(RFCError::Unauthorized.to_string()) + .is_equal_to("urn:ietf:params:acme:error:unauthorized".to_string()); + assert_that!(RFCError::UnsupportedContact.to_string()) + .is_equal_to("urn:ietf:params:acme:error:unsupportedContact".to_string()); + assert_that!(RFCError::UnsupportedIdentifier.to_string()) + .is_equal_to("urn:ietf:params:acme:error:unsupportedIdentifier".to_string()); + assert_that!(RFCError::UserActionRequired.to_string()) + .is_equal_to("urn:ietf:params:acme:error:userActionRequired".to_string()); + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..7884a54 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,6 @@ +#![allow(dead_code)] +pub mod acme; +pub mod errors; +pub mod models; +pub mod test; +pub(crate) mod util; diff --git a/src/models/account.rs b/src/models/account.rs new file mode 100644 index 0000000..040d116 --- /dev/null +++ b/src/models/account.rs @@ -0,0 +1,635 @@ +use std::convert::{TryFrom, TryInto}; + +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; +use tokio_postgres::{Row, Transaction}; +use url::Url; + +use crate::{ + acme::{handlers::account::NewAccount, jose}, + errors::acme::JWSError, + util::make_nonce, +}; + +use super::{LoadError, Postgres, Record, SaveError}; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct Account { + pub id: Option, + jwk_id: i32, + orders_nonce: String, + contacts: Vec, + created_at: chrono::DateTime, + deleted_at: Option>, +} + +pub(crate) fn new_accounts( + account: NewAccount, + jwk: JWK, + _db: Postgres, +) -> Result { + let jwk_id = jwk.id()?; + + if jwk_id.is_none() { + return Err(LoadError::Generic( + "missing JWK in account save".to_string(), + )); + } + + let contacts = account.contacts(); + let contacts = contacts.unwrap(); + let jwk_id = jwk_id.unwrap(); + + Ok(Account::new( + jwk_id, + contacts + .iter() + .map(|c| c.to_owned().into()) + .collect::>(), + )) +} + +pub async fn get_contacts_for_account( + id: i32, + tx: &Transaction<'_>, +) -> Result, LoadError> { + let mut contacts = Vec::new(); + + let rows = tx + .query("select contact from contacts where account_id=$1", &[&id]) + .await?; + for row in rows { + contacts.push(row.get("contact")); + } + + Ok(contacts) +} + +impl Account { + pub fn new(jwk_id: i32, contacts: Vec) -> Self { + Self { + jwk_id, + contacts, + orders_nonce: make_nonce(super::NONCE_KEY_SIZE), + id: None, + created_at: chrono::DateTime::::from(std::time::SystemTime::now()), + deleted_at: None, + } + } + + pub async fn find_by_kid(jwk_id: i32, db: Postgres) -> Result { + let mut lockeddb = db.client().await?; + let tx = lockeddb.transaction().await?; + + let res = tx + .query_one("select * from accounts where jwk_id=$1", &[&jwk_id]) + .await?; + + Self::new_from_row(&res, &tx).await + } + + pub async fn find_deleted(id: i32, db: Postgres) -> Result { + let mut lockeddb = db.client().await?; + let tx = lockeddb.transaction().await?; + + let res = tx + .query_one("select * from accounts where id=$1", &[&id]) + .await?; + + Self::new_from_row(&res, &tx).await + } +} + +#[async_trait] +impl Record for Account { + async fn new_from_row(row: &Row, tx: &Transaction<'_>) -> Result { + Ok(Self { + id: Some(row.get("id")), + jwk_id: row.get("jwk_id"), + orders_nonce: row.get("orders_nonce"), + contacts: get_contacts_for_account(row.get("id"), tx).await?, + created_at: row.get("created_at"), + deleted_at: row.get("deleted_at"), + }) + } + + async fn find(id: i32, db: Postgres) -> Result { + let mut lockeddb = db.client().await?; + let tx = lockeddb.transaction().await?; + + let res = tx + .query_one( + "select * from accounts where id=$1 and deleted_at is null", + &[&id], + ) + .await?; + + Self::new_from_row(&res, &tx).await + } + + fn id(&self) -> Result, LoadError> { + Ok(self.id) + } + + async fn create(&mut self, db: Postgres) -> Result { + let mut db = db.client().await?; + let tx = db.transaction().await?; + + let res = tx + .query_one( + " + insert into accounts (jwk_id, orders_nonce) values ($1, $2) + returning id, created_at + ", + &[&self.jwk_id, &self.orders_nonce], + ) + .await?; + + let id = res.get("id"); + let created_at = res.get("created_at"); + + self.id = Some(id); + self.created_at = created_at; + + for contact in &self.contacts { + tx.query_one( + " + insert into contacts (account_id, contact) values ($1, $2) + returning id, created_at + ", + &[&id, &contact], + ) + .await?; + } + + tx.commit().await?; + + return Ok(id); + } + + async fn delete(&self, db: Postgres) -> Result<(), SaveError> { + if self.id.is_none() { + return Err(SaveError::Generic( + "this JWK record was never saved".to_string(), + )); + } + + let mut db = db.client().await?; + let tx = db.transaction().await?; + let res = tx + .execute( + "update accounts set deleted_at=CURRENT_TIMESTAMP where id=$1 and deleted_at is null", + &[&self.id.unwrap()], + ) + .await?; + + if res == 0 { + // FIXME this should probably be a log warning later + return Err(SaveError::Generic( + "db did not delete primary key; was already removed".to_string(), + )); + } + + Ok(tx.commit().await?) + } + + async fn update(&self, _db: Postgres) -> Result<(), SaveError> { + Err(SaveError::Generic( + "accounts may not be updated".to_string(), + )) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct JWK { + pub id: Option, + pub nonce_key: String, + pub alg: String, + pub n: Option, + pub e: Option, + pub x: Option, + pub y: Option, + pub created_at: chrono::DateTime, + pub deleted_at: Option>, +} + +impl JWK { + pub fn new_rs256(n: String, e: String) -> Self { + Self { + id: None, + nonce_key: make_nonce(super::NONCE_KEY_SIZE), + n: Some(n), + e: Some(e), + alg: "RS256".into(), + x: None, + y: None, + created_at: chrono::DateTime::::from(std::time::SystemTime::now()), + deleted_at: None, + } + } + + pub fn new_es256(x: String, y: String) -> Self { + Self { + id: None, + nonce_key: make_nonce(super::NONCE_KEY_SIZE), + x: Some(x), + y: Some(y), + alg: "ES256".into(), + e: None, + n: None, + created_at: chrono::DateTime::::from(std::time::SystemTime::now()), + deleted_at: None, + } + } + + pub async fn find_deleted(id: i32, db: Postgres) -> Result { + let mut db = db.client().await?; + let tx = db.transaction().await?; + + let res = tx + .query_one("select * from jwks where id=$1", &[&id]) + .await?; + + Self::new_from_row(&res, &tx).await + } + + pub async fn find_by_nonce(nonce_key: String, db: Postgres) -> Result { + let res = db + .clone() + .client() + .await? + .query_one( + "select id from jwks where nonce_key=$1 and deleted_at is null", + &[&nonce_key], + ) + .await; + + match res { + Ok(row) => { + let id: i32 = row.get(0); + Self::find(id, db).await + } + + Err(_) => Err(LoadError::NotFound), + } + } + + pub async fn find_by_kid(url: Url, db: Postgres) -> Result { + if let None = url.path_segments() { + return Err(LoadError::NotFound); + } + + if let None = url.path_segments().unwrap().last() { + return Err(LoadError::NotFound); + } + + Self::find_by_nonce(url.path_segments().unwrap().last().unwrap().to_string(), db).await + } + + pub fn nonce_key(&self) -> String { + self.nonce_key.clone() + } +} + +impl TryFrom<&mut jose::JWK> for JWK { + type Error = JWSError; + + fn try_from(jwk: &mut jose::JWK) -> Result { + let (alg, n, e, x, y) = jwk.params(); + + let alg = match alg { + Some(alg) => alg, + None => return Err(JWSError::InvalidPublicKey), + }; + + Ok(JWK { + nonce_key: make_nonce(super::NONCE_KEY_SIZE), + n, + e, + x, + y, + alg, + id: None, + created_at: chrono::DateTime::::from(std::time::SystemTime::now()), + deleted_at: None, + }) + } +} + +impl TryInto for JWK { + type Error = JWSError; + + fn try_into(self) -> Result { + let mut crv = None; + + if self.x.is_some() && self.y.is_some() { + // NOTE once more algos are supported this will need to change + crv = Some("P-256".to_string()) + } + + Ok(jose::JWK { + _use: None, + kty: match self.alg.as_str() { + "ES256" => "ECDSA", + "RS256" => "RSA", + _ => "you should really be validating this field", + } + .to_string(), + crv, + n: self.n, + e: self.e, + x: self.x, + y: self.y, + alg: Some(self.alg), + }) + } +} + +#[async_trait] +impl Record for JWK { + async fn new_from_row(row: &Row, _tx: &Transaction<'_>) -> Result { + Ok(Self { + id: Some(row.get("id")), + nonce_key: row.get("nonce_key"), + n: row.get("n"), + e: row.get("e"), + alg: row.get("alg"), + x: row.get("x"), + y: row.get("y"), + created_at: row.get("created_at"), + deleted_at: row.get("deleted_at"), + }) + } + + async fn find(id: i32, db: Postgres) -> Result { + let mut db = db.client().await?; + let tx = db.transaction().await?; + + let res = tx + .query_one( + "select * from jwks where id=$1 and deleted_at is null", + &[&id], + ) + .await?; + + Self::new_from_row(&res, &tx).await + } + + fn id(&self) -> Result, LoadError> { + Ok(self.id) + } + + async fn create(&mut self, db: Postgres) -> Result { + let mut db = db.client().await?; + let tx = db.transaction().await?; + + let res = tx + .query_one( + " + insert into jwks (nonce_key, n, e, alg, x, y) values ($1, $2, $3, $4, $5, $6) + returning id, created_at + ", + &[ + &self.nonce_key, + &self.n, + &self.e, + &self.alg, + &self.x, + &self.y, + ], + ) + .await?; + + tx.commit().await?; + + let id = res.get("id"); + let created_at = res.get("created_at"); + self.id = Some(id); + self.created_at = created_at; + + return Ok(id); + } + + async fn delete(&self, db: Postgres) -> Result<(), SaveError> { + if self.id.is_none() { + return Err(SaveError::Generic( + "this JWK record was never saved".to_string(), + )); + } + + let mut db = db.client().await?; + let tx = db.transaction().await?; + let res = tx + .execute( + "update jwks set deleted_at=CURRENT_TIMESTAMP where id=$1 and deleted_at is null", + &[&self.id.unwrap()], + ) + .await?; + + if res == 0 { + // FIXME this should probably be a log warning later + return Err(SaveError::Generic( + "db did not delete primary key; was already removed".to_string(), + )); + } + + Ok(tx.commit().await?) + } + + async fn update(&self, _db: Postgres) -> Result<(), SaveError> { + Err(SaveError::Generic("JWKs may not be updated".to_string())) + } +} + +mod tests { + #[tokio::test(flavor = "multi_thread")] + async fn account_crud_single_contact() { + use spectral::prelude::*; + + use super::{Account, JWK}; + use crate::acme::handlers::account::NewAccount; + use crate::models::Record; + use crate::test::PGTest; + use std::convert::TryInto; + + let pg = PGTest::new("account_crud_single_contact").await.unwrap(); + + let mut acct = NewAccount::default(); + acct.contact = Some(vec!["mailto:erik@hollensbe.org".try_into().unwrap()]); + + let mut jwk = JWK::new_es256("x".to_string(), "y".to_string()); + jwk.create(pg.db()).await.unwrap(); + let acct = super::new_accounts(acct, jwk, pg.db()); + assert_that!(acct).is_ok(); + let mut acct = acct.unwrap(); + assert_that!(acct.create(pg.db()).await).is_ok(); + let id = acct.id(); + assert_that!(id).is_ok(); + let id = id.unwrap(); + assert_that!(id).is_some(); + assert_that!(id.unwrap()).is_not_equal_to(0); + let id = id.unwrap(); + + let newacct = Account::find(id, pg.db()).await.unwrap(); + assert_that!(acct).is_equal_to(newacct); + + assert_that!(acct.delete(pg.db()).await).is_ok(); + assert_that!(acct.delete(pg.db()).await).is_err(); + + let oldacct = Account::find_deleted(id, pg.db()).await.unwrap(); + + acct.deleted_at = oldacct.deleted_at; + + assert_that!(acct).is_equal_to(oldacct); + } + + #[tokio::test(flavor = "multi_thread")] + async fn jwk_check_constraint() { + use spectral::prelude::*; + + // this test ensures that n & e and x & y are sticky to each other. other validation is performed outside + // the db, but this is good for keeping the db hygenic. + use crate::test::PGTest; + use tokio_postgres::types::ToSql; + + let pg = PGTest::new("jwk_check_constraint").await.unwrap(); + + let bad: &[(&str, &[&(dyn ToSql + Sync)])] = &[ + ( + "insert into jwks (nonce_key, n, x, alg) values ($1, $2, $3, $4)", + &[ + &"firstbad".to_string(), + &"aaaa".to_string(), + &"bbbb".to_string(), + &"alg".to_string(), + ], + ), + ( + "insert into jwks (nonce_key, e, y, alg) values ($1, $2, $3, $4)", + &[ + &"secondbad".to_string(), + &"aaaa".to_string(), + &"bbbb".to_string(), + &"alg".to_string(), + ], + ), + ]; + + for args in bad.iter() { + let res = pg + .db() + .client() + .await + .unwrap() + .execute(args.0, args.1) + .await; + assert_that!(res).is_err(); + } + + let good: &[(&str, &[&(dyn ToSql + Sync)])] = &[ + ( + "insert into jwks (nonce_key, n, e, alg) values ($1, $2, $3, $4)", + &[ + &"firstgood".to_string(), + &"aaaa".to_string(), + &"bbbb".to_string(), + &"alg".to_string(), + ], + ), + ( + "insert into jwks (nonce_key, x, y, alg) values ($1, $2, $3, $4)", + &[ + &"secondgood".to_string(), + &"aaaa".to_string(), + &"bbbb".to_string(), + &"alg".to_string(), + ], + ), + ]; + + for args in good.iter() { + let res = pg + .db() + .client() + .await + .unwrap() + .execute(args.0, args.1) + .await; + assert_that!(res).is_ok(); + } + } + + #[tokio::test(flavor = "multi_thread")] + async fn jwk_find_nonexistent_nonce() { + use spectral::prelude::*; + + use super::JWK; + use crate::errors::db::LoadError; + use crate::test::PGTest; + + let pg = PGTest::new("jwk_find_nonexistent_nonce").await.unwrap(); + + let res = JWK::find_by_nonce("abcdef".to_string(), pg.db()).await; + assert_that!(res).is_err(); + assert_that!(res.unwrap_err()).matches(|e| match e { + // ugh! + &LoadError::NotFound => true, + _ => false, + }); + } + + #[tokio::test(flavor = "multi_thread")] + async fn jwk_create_delete_find() { + use spectral::prelude::*; + + use super::JWK; + use crate::models::Record; + use crate::test::PGTest; + + let pg = PGTest::new("jwk_create_delete_find").await.unwrap(); + + let jwks = &mut [ + JWK::new_rs256("aaaaaa".to_string(), "bbbb".to_string()), + JWK::new_es256("aaaaaa".to_string(), "bbbb".to_string()), + ]; + + for origjwk in jwks.iter_mut() { + let res = origjwk.create(pg.db()).await; + assert_that!(res).is_ok(); + + let res = JWK::find(origjwk.id().unwrap().unwrap(), pg.db()).await; + assert_that!(res).is_ok(); + + let jwk = res.unwrap(); + + assert_that!(jwk).is_equal_to(origjwk.clone()); + + let res = JWK::find_by_nonce(origjwk.nonce_key(), pg.db()).await; + assert_that!(res).is_ok(); + + let jwk = res.unwrap(); + + assert_that!(jwk).is_equal_to(origjwk.clone()); + + let res = origjwk.delete(pg.db()).await; + assert_that!(res).is_ok(); + + let res = origjwk.delete(pg.db()).await; + assert_that!(res).is_err(); + + let res = JWK::find(origjwk.id().unwrap().unwrap(), pg.db()).await; + assert_that!(res).is_err(); + + let res = JWK::find_by_nonce(origjwk.nonce_key(), pg.db()).await; + assert_that!(res).is_err(); + + let res = JWK::find_deleted(origjwk.id().unwrap().unwrap(), pg.db()).await; + assert_that!(res).is_ok(); + + let jwk = res.unwrap(); + origjwk.deleted_at = jwk.deleted_at; + + assert_that!(jwk).is_equal_to(origjwk.clone()); + } + } +} diff --git a/src/models/mod.rs b/src/models/mod.rs new file mode 100644 index 0000000..58ec61a --- /dev/null +++ b/src/models/mod.rs @@ -0,0 +1,135 @@ +use std::str::FromStr; + +use crate::errors::db::*; +use async_trait::async_trait; +use deadpool_postgres::{Manager, ManagerConfig, Object, Pool}; +use refinery::Report; +use tokio_postgres::{Config, NoTls, Row, Transaction}; + +pub mod migrations { + use refinery::embed_migrations; + embed_migrations!("migrations"); +} + +pub mod account; +pub mod nonce; +pub mod order; + +pub(crate) const NONCE_KEY_SIZE: Option = Some(32); + +#[derive(Clone)] +pub struct Postgres { + pool: Pool, + config: String, +} + +impl Postgres { + pub async fn connect_one(config: &str) -> Result { + let (client, conn) = tokio_postgres::connect(config, NoTls).await?; + + tokio::spawn(async move { + if let Err(e) = conn.await { + log::error!("postgresql connection error: {}", e) + } + }); + + Ok(client) + } + + pub async fn new(config: &str, pool_size: usize) -> Result { + let pg_config = Config::from_str(config)?; + let mgr_config = ManagerConfig::default(); + let mgr = Manager::from_config(pg_config, NoTls, mgr_config); + // FIXME deadpool's error here is in a private package, so we can't apply Try + // operations + let pool = Pool::builder(mgr).max_size(pool_size).build().unwrap(); + + Ok(Self { + pool, + config: config.to_string(), + }) + } + + pub async fn client(self) -> Result { + Ok(self.pool.get().await?) + } + + /// migrate the database. The migration implementation is refinery and the migrations live in + /// `migrations/` off the root of the repository, but are otherwise compiled into the library. + pub async fn migrate(&self) -> Result { + let mut c = Self::connect_one(&self.config).await?; + let report = migrations::migrations::runner().run_async(&mut c).await?; + Ok(report) + } + + /// resets the database, destroying all data in the public schema. + /// useful for tests. + pub(crate) async fn reset(&self) -> Result<(), SaveError> { + let c = Self::connect_one(&self.config).await?; + c.execute("drop schema public cascade", &[]).await?; + c.execute("create schema public", &[]).await?; + Ok(()) + } +} + +/// This trait encapsulates a record with a typed primary key (PK). Each record is capable of a +/// number of operations on itself provided by the trait members, but a the database handle must be +/// passed, and it needs to be kept under lock inside many of the functions. +#[async_trait] +pub trait Record +where + Self: Sized + Sync + Clone + Send + 'static, +{ + /// new_from_row converts a row in the database to the appropriate struct. This method is async + /// so it can make other database calls, etc. + async fn new_from_row(row: &Row, db: &Transaction<'_>) -> Result; + + /// find a record by the PK, requires a database handle. + async fn find(id: PK, db: Postgres) -> Result; + + /// Get the ID (if available) of the primary key. + fn id(&self) -> Result, LoadError>; + + /// Create the record; mutates the record, returns the PK. + async fn create(&mut self, db: Postgres) -> Result; + /// Update the record. + async fn update(&self, db: Postgres) -> Result<(), SaveError>; + /// Delete the record. + async fn delete(&self, db: Postgres) -> Result<(), SaveError>; +} + +/// This trait encapuslates a list of Records. Some set operations are supplied that work on a +/// transaction handle. FK is a typed foreign key against the join table. +#[async_trait] +pub trait RecordList +where + Self: Sized + Sync + Clone + Send + 'static, +{ + /// Fetch the collection by its related foreign key + async fn collect(id: FK, tx: &Transaction<'_>) -> Result, LoadError>; + /// Get the latest record for a given collection + async fn latest(id: FK, tx: &Transaction<'_>) -> Result; + /// append a record to this collection. Returns the new collection. + async fn append(&self, id: FK, tx: &Transaction<'_>) -> Result, SaveError>; + /// remove a record from this collection. You must re-call collect to get an updated list. + async fn remove(&self, id: FK, tx: &Transaction<'_>) -> Result<(), SaveError>; + /// Determine if a record exists in this collection. + async fn exists(&self, id: FK, tx: &Transaction<'_>) -> Result; +} + +mod tests { + #[tokio::test(flavor = "multi_thread")] + async fn test_migrate() { + use crate::test::PGTest; + use spectral::prelude::*; + + let pg = PGTest::new("test_migrate").await.unwrap(); + let db = pg.db(); + db.reset().await.unwrap(); + let report = db.migrate().await.unwrap(); + assert_that!(report.applied_migrations().len()).is_greater_than(0); + + let report = db.migrate().await.unwrap(); + assert_that!(report.applied_migrations().len()).is_equal_to(0); + } +} diff --git a/src/models/nonce.rs b/src/models/nonce.rs new file mode 100644 index 0000000..9cc96ed --- /dev/null +++ b/src/models/nonce.rs @@ -0,0 +1,117 @@ +use super::{LoadError, Record, Postgres, SaveError}; +use crate::util::make_nonce; +use async_trait::async_trait; +use tokio_postgres::{Row, Transaction}; + +#[derive(Clone)] +pub struct Nonce { + nonce: String, +} + +impl std::fmt::Debug for Nonce { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.nonce) + } +} + +impl PartialEq for Nonce { + fn eq(&self, other: &Self) -> bool { + self.nonce.eq(&other.nonce) + } +} + +impl Nonce { + pub fn new() -> Self { + Self { + nonce: make_nonce(None), + } + } +} + +#[async_trait] +impl Record for Nonce { + async fn new_from_row(row: &Row, _tx: &Transaction<'_>) -> Result { + if row.len() > 0 { + Ok(Self { + nonce: row.get("nonce"), + }) + } else { + Err(LoadError::NotFound) + } + } + + fn id(&self) -> Result, LoadError> { + return Ok(Some(self.nonce.clone())); + } + + async fn find(id: String, db: Postgres) -> Result { + let mut db = db.client().await?; + let row = db + .query_one("select nonce from nonces where nonce = $1", &[&id]) + .await?; + + let tx = db.transaction().await?; + + Self::new_from_row(&row, &tx).await + } + + async fn create(&mut self, db: Postgres) -> Result { + let mut db = db.client().await?; + let tx = db.transaction().await?; + tx.execute("insert into nonces (nonce) values ($1)", &[&self.nonce]) + .await?; + tx.commit().await?; + + Ok(self.nonce.clone()) + } + + async fn delete(&self, db: Postgres) -> Result<(), SaveError> { + let mut db = db.client().await?; + let tx = db.transaction().await?; + let res = tx + .execute("delete from nonces where nonce = $1", &[&self.nonce]) + .await?; + tx.commit().await?; + + if res == 0 { + return Err(SaveError::Generic("nonce was already removed".to_string())); + } + + Ok(()) + } + + async fn update(&self, _: Postgres) -> Result<(), SaveError> { + Err(SaveError::Generic("cannot update a nonce".to_string())) + } +} + +mod tests { + #[tokio::test(flavor = "multi_thread")] + async fn nonce_crud_test() { + use spectral::prelude::*; + + use super::Nonce; + use crate::models::Record; + use crate::test::PGTest; + + let pg = PGTest::new("nonce_crud_test").await.unwrap(); + let db = pg.db(); + + let mut nonce = Nonce::new(); + nonce.create(db.clone()).await.unwrap(); + + let found = Nonce::find(nonce.id().unwrap().unwrap(), db.clone()) + .await + .unwrap(); + + assert_that!(nonce).is_equal_to(found.clone()); + + let res = found.delete(db.clone()).await; + assert_that!(res).is_ok(); + let res = found.delete(db.clone()).await; + assert_that!(res).is_err(); + + let res = Nonce::find(found.id().unwrap().unwrap(), db.clone()).await; + assert_that!(res).is_err(); + } +} diff --git a/src/models/order.rs b/src/models/order.rs new file mode 100644 index 0000000..69144bf --- /dev/null +++ b/src/models/order.rs @@ -0,0 +1,1070 @@ +use std::convert::{TryFrom, TryInto}; +use std::ops::Add; + +use async_trait::async_trait; +use openssl::x509::X509; +use tokio_postgres::{Row, Transaction}; +use url::Url; + +use super::{Postgres, Record, RecordList}; +use crate::acme::challenge::ChallengeType; +use crate::acme::ACMEIdentifier; +use crate::{ + acme::{dns::DNSName, handlers::order::OrderStatus}, + errors::db::{LoadError, SaveError}, + util::make_nonce, +}; + +#[derive(Debug, Clone, PartialEq)] +pub struct Order { + id: Option, + pub order_id: String, + pub error: Option, + pub status: OrderStatus, + pub created_at: chrono::DateTime, + pub authorizations: Option>, + pub not_before: Option>, + pub not_after: Option>, + expires: Option>, + finalized: bool, + deleted_at: Option>, +} + +impl Default for Order { + fn default() -> Self { + Self { + id: None, + order_id: make_nonce(super::NONCE_KEY_SIZE), + finalized: false, + expires: None, + not_before: None, + not_after: None, + error: None, + status: OrderStatus::Pending, + authorizations: None, + created_at: chrono::DateTime::::from(std::time::SystemTime::now()), + deleted_at: None, + } + } +} + +impl Order { + pub(crate) fn new( + not_before: Option>, + not_after: Option>, + ) -> Order { + Order { + not_before, + not_after, + ..Default::default() + } + } + + pub(crate) async fn find_by_reference( + order_id: String, + db: Postgres, + ) -> Result { + let mut client = db.clone().client().await?; + let tx = client.transaction().await?; + let res = tx + .query_one("select id from orders where order_id = $1", &[&order_id]) + .await; + + match res { + Ok(row) => { + let id = row.get(0); + drop(tx); + Self::find(id, db).await + } + Err(_) => Err(LoadError::NotFound), + } + } + + pub(crate) fn into_handler_order( + self, + url: Url, + ) -> Result { + let auths = self.authorizations; + + let mut dt_expires: Option> = None; + let mut dt_notbefore: Option> = None; + let mut dt_notafter: Option> = None; + + if let Some(expires) = self.expires { + dt_expires = Some(expires.into()) + } + + if let Some(notbefore) = self.not_before { + dt_notbefore = Some(notbefore.into()) + } + + if let Some(notafter) = self.not_after { + dt_notafter = Some(notafter.into()) + } + + let o = crate::acme::handlers::order::Order { + status: Some(self.status.clone()), + expires: dt_expires, + identifiers: if auths.clone().is_some() { + auths + .clone() + .unwrap() + .iter() + // FIXME remove these unwraps + .map(|a| { + ACMEIdentifier::DNS( + DNSName::from_str(&a.identifier.clone().unwrap()).unwrap(), + ) + }) + .collect::>() + } else { + Vec::new() + }, + not_after: dt_notafter, + not_before: dt_notbefore, + error: self.error, + authorizations: if auths.clone().is_some() { + Some( + auths + .clone() + .unwrap() + .iter() + .map(|x: &Authorization| x.into_url(url.clone())) + .collect::>(), + ) + } else { + None + }, + finalize: Some( + url.join(&format!("/order/{}/finalize", self.order_id)) + .unwrap(), + ), + // FIXME this needs to be at a unique location, not related to the order id + certificate: Some( + url.join(&format!("/order/{}/certificate", self.order_id)) + .unwrap(), + ), + }; + + Ok(o) + } + + pub(crate) async fn challenges( + &self, + tx: &Transaction<'_>, + ) -> Result, LoadError> { + Challenge::collect(self.order_id.clone(), tx).await + } + + pub(crate) async fn record_certificate( + &self, + certificate: X509, + db: Postgres, + ) -> Result { + let mut cert = Certificate::default(); + cert.order_id = self.order_id.clone(); + let pem = match certificate.to_pem() { + Ok(pem) => pem, + Err(e) => return Err(SaveError::Generic(e.to_string())), + }; + + cert.certificate = pem; + cert.create(db).await + } + + pub(crate) async fn certificate(&self, db: Postgres) -> Result { + Certificate::find_by_order_id(self.order_id.clone(), db).await + } +} + +#[async_trait] +impl Record for Order { + async fn new_from_row(_row: &Row, _tx: &Transaction<'_>) -> Result { + Err(LoadError::Generic("unimplemented".to_string())) + } + + async fn find(id: i32, db: super::Postgres) -> Result { + let mut client = db.client().await?; + let tx = client.transaction().await?; + + let order_row = tx + .query_one( + "select * from orders where id=$1 and deleted_at is null", + &[&id], + ) + .await?; + + let order_id: String = order_row.get("order_id"); + let mut status = OrderStatus::Pending; + let authorizations = Authorization::collect(order_id.clone(), &tx).await?; + + // ensure all at least one challenge has passed for each identifier (carried in the + // authorization) + // + // FIXME test the shit out of this later + let mut valid = false; + for authz in &authorizations { + if authz.identifier.is_none() { + // FIXME this should never happen and we should do something here + break; + } + + valid = false; + + // any invalids breaks it. + for chall in authz.challenges(&tx).await? { + if chall.status == OrderStatus::Invalid { + status = OrderStatus::Invalid; + break; + } else if chall.status == OrderStatus::Valid { + valid = true + } + } + + // escape hatch for invalid status + if status == OrderStatus::Invalid || !valid { + break; + } + } + + if valid { + status = OrderStatus::Valid; + } + + let error: Option = order_row.get("error"); + + Ok(Order { + id: order_row.get("id"), + order_id: order_row.get("order_id"), + expires: order_row.get("expires"), + not_before: order_row.get("not_before"), + not_after: order_row.get("not_after"), + error: if error.is_some() { + serde_json::from_str(&error.unwrap())? + } else { + None + }, + finalized: order_row.get("finalized"), + deleted_at: order_row.get("deleted_at"), + created_at: order_row.get("created_at"), + status: status.into(), + authorizations: Some(authorizations), + }) + } + + fn id(&self) -> Result, LoadError> { + Ok(self.id) + } + + async fn create(&mut self, db: super::Postgres) -> Result { + let mut client = db.client().await?; + let tx = client.transaction().await?; + + let mut error = None; + + if self.error.is_some() { + error = Some(serde_json::to_string(&self.error)?) + } + + let res = tx + .query_one( + " + insert into orders + (order_id, expires, not_before, not_after, error, finalized) + values + ($1, $2, $3, $4, $5, $6) + returning + id, created_at + ", + &[ + &self.order_id, + &self.expires, + &self + .not_before + .unwrap_or(chrono::DateTime::::from( + std::time::SystemTime::now(), + )), + &self.clone().not_after.unwrap_or( + chrono::DateTime::::from(std::time::SystemTime::now()) + .add(chrono::Duration::days(365)), + ), + &error, + &self.finalized, + ], + ) + .await?; + + let id = res.get("id"); + self.id = Some(id); + self.created_at = res.get("created_at"); + + tx.commit().await?; + + return Ok(id); + } + + async fn update(&self, db: super::Postgres) -> Result<(), SaveError> { + if self.id.is_none() { + return Err(SaveError::Generic( + "record was not saved and updates were requested".to_string(), + )); + } + + let mut client = db.client().await?; + let tx = client.transaction().await?; + + let mut error = None; + + if self.error.is_some() { + error = Some(serde_json::to_string(&self.error)?) + } + + let res = tx + .execute( + "update orders set deleted_at=$1, expires=$2, error=$3 where id=$4 and deleted_at is null", + &[&self.deleted_at, &self.expires, &error, &self.id.unwrap()], + ) + .await?; + + if res != 1 { + return Err(SaveError::Generic("row could not be deleted".to_string())); + } + + // FIXME update authz and certs + Ok(tx.commit().await?) + } + + async fn delete(&self, db: super::Postgres) -> Result<(), SaveError> { + if self.id.is_none() { + return Err(SaveError::Generic( + "record was not saved and deletion was requested".to_string(), + )); + } + + let mut client = db.client().await?; + let tx = client.transaction().await?; + + let res = tx + .execute( + "update orders set deleted_at=CURRENT_TIMESTAMP where id=$1 and deleted_at is null", + &[&self.id.unwrap()], + ) + .await?; + + if res != 1 { + return Err(SaveError::Generic("row could not be deleted".to_string())); + } + + Ok(tx.commit().await?) + } +} + +#[derive(Debug, Clone, PartialEq)] +pub struct Challenge { + pub id: Option, + pub order_id: String, + pub challenge_type: ChallengeType, + pub identifier: String, + pub token: String, + pub reference: String, + pub status: OrderStatus, + pub validated: Option>, + pub created_at: chrono::DateTime, + pub deleted_at: Option>, + pub authorization_id: String, +} + +impl Challenge { + pub fn new( + order_id: String, + authorization_id: String, + challenge_type: ChallengeType, + identifier: String, + status: OrderStatus, + ) -> Self { + Self { + id: None, + order_id, + authorization_id, + challenge_type, + identifier, + token: make_nonce(None), + reference: make_nonce(None), + status, + validated: None, + created_at: chrono::DateTime::::from(std::time::SystemTime::now()), + deleted_at: None, + } + } + + pub(crate) async fn find_by_reference( + challenge_id: String, + tx: &Transaction<'_>, + ) -> Result { + let row = tx + .query_one( + "select * from orders_challenges where reference=$1", + &[&challenge_id], + ) + .await?; + + Self::new_from_row(&row) + } + + pub(crate) async fn find_by_authorization( + authorization: String, + tx: &Transaction<'_>, + ) -> Result, LoadError> { + let rows = tx + .query( + "select * from orders_challenges where authorization_id = $1 order by created_at DESC", + &[&authorization], + ) + .await?; + + let mut ret = Vec::new(); + + for row in rows { + ret.push(Self::new_from_row(&row)?) + } + + Ok(ret) + } + + pub(crate) async fn authorization( + &self, + tx: &Transaction<'_>, + ) -> Result { + Authorization::find_by_reference(&self.authorization_id, tx).await + } + + pub(crate) fn into_url(&self, url: url::Url) -> url::Url { + url.join(&format!("/chall/{}", self.reference)).unwrap() + } + + fn new_from_row(result: &Row) -> Result { + let ct: ChallengeType = result.get::<_, &str>("challenge_type").try_into()?; + let id = result.get::<_, &str>("identifier"); + + Ok(Self { + id: result.get("id"), + order_id: result.get("order_id"), + authorization_id: result.get("authorization_id"), + challenge_type: ct.clone(), + identifier: id.to_string(), + validated: result.get("validated"), + reference: result.get("reference"), + token: result.get("token"), + status: OrderStatus::try_from(result.get::<_, String>("status"))?, + created_at: result.get("created_at"), + deleted_at: result.get("deleted_at"), + }) + } + + pub async fn create(&mut self, db: Postgres) -> Result { + let mut client = db.client().await?; + let tx = client.transaction().await?; + let res = tx.query_one( + "insert into orders_challenges (order_id, authorization_id, challenge_type, identifier, token, reference, status, created_at, deleted_at) values ($1, $2, $3, $4, $5, $6, $7, $8, $9) returning id", + &[&self.order_id.clone(), &self.authorization_id.clone(), &self.challenge_type.clone().to_string(), &self.identifier.clone().to_string(), &self.token.clone(), &self.reference.clone(), &self.status.clone().to_string(), &self.created_at, &self.deleted_at], + ).await?; + + let id = res.get("id"); + tx.commit().await?; + + self.id = Some(id); + + Ok(id) + } + + pub async fn persist_status(&mut self, tx: &Transaction<'_>) -> Result<(), SaveError> { + match self.id { + None => return Err(SaveError::Generic("save this record first".to_string())), + _ => {} + } + + if self.status == OrderStatus::Valid { + self.validated = Some(chrono::DateTime::::from( + std::time::SystemTime::now(), + )) + } + + tx.execute( + "update orders_challenges set status=$1, validated=$2 where authorization_id=$3 and id=$4", + &[ + &self.status.clone().to_string(), + &self.validated, + &self.authorization_id.clone(), + &self.id.unwrap(), + ], + ) + .await?; + Ok(()) + } +} + +#[async_trait] +impl RecordList for Challenge { + async fn collect(order_id: String, tx: &Transaction<'_>) -> Result, LoadError> { + let mut ret = Vec::new(); + + let results = tx + .query( + "select * from orders_challenges where order_id = $1 order by created_at ASC", + &[&order_id], + ) + .await?; + + for result in results.iter() { + ret.push(Self::new_from_row(result)?); + } + + Ok(ret) + } + + async fn latest(_order_id: String, _tx: &Transaction<'_>) -> Result { + return Err(LoadError::Generic("unimplemented".to_string())); + } + + async fn append(&self, order_id: String, tx: &Transaction<'_>) -> Result, SaveError> { + tx.execute( + "insert into orders_challenges (order_id, authorization_id, challenge_type, token, reference, status, created_at, deleted_at) values ($1, $2, $3, $4, $5, $6, $7, $8) returning id", + &[&order_id, &self.authorization_id.clone(), &self.challenge_type.clone().to_string(), &self.token.clone(), &self.reference.clone(), &self.status.clone().to_string(), &self.created_at, &self.deleted_at], + ).await?; + Ok(Self::collect(order_id, tx).await?) + } + + async fn remove(&self, _: String, _tx: &Transaction<'_>) -> Result<(), SaveError> { + return Err(SaveError::Generic("unimplemented".to_string())); + } + + async fn exists(&self, _order_id: String, _tx: &Transaction<'_>) -> Result { + return Err(LoadError::Generic("unimplemented".to_string())); + } +} + +// XXX this and authorizations are awful similar and it drives me nuts +#[derive(Debug, Clone, PartialEq)] +pub struct Certificate { + id: Option, + order_id: String, + reference: String, + pub certificate: Vec, + created_at: chrono::DateTime, + deleted_at: Option>, +} + +impl Default for Certificate { + fn default() -> Self { + Self { + id: None, + order_id: "".to_string(), + reference: make_nonce(None), + certificate: Vec::new(), + created_at: chrono::DateTime::::from(std::time::SystemTime::now()), + deleted_at: None, + } + } +} + +impl Into for Certificate { + fn into(self) -> String { + self.reference + } +} + +impl Certificate { + pub(crate) async fn find_by_order_id( + order_id: String, + db: Postgres, + ) -> Result { + let mut client = db.client().await?; + let tx = client.transaction().await?; + + let result = tx + .query_one( + "select * from orders_certificate where order_id = $1 and deleted_at is null limit 1", + &[&order_id], + ) + .await?; + + Self::new_from_row(&result, &tx).await + } +} + +#[async_trait] +impl Record for Certificate { + async fn new_from_row(row: &Row, _tx: &Transaction<'_>) -> Result { + Ok(Self { + id: row.get("id"), + order_id: row.get("order_id"), + reference: row.get("reference"), + certificate: row.get("certificate"), + created_at: row.get("created_at"), + deleted_at: row.get("deleted_at"), + }) + } + + async fn find(id: i32, db: super::Postgres) -> Result { + let mut client = db.client().await?; + let tx = client.transaction().await?; + + let result = tx + .query_one( + "select * from orders_certificate where id = $1 and deleted_at is null", + &[&id], + ) + .await?; + + Self::new_from_row(&result, &tx).await + } + + fn id(&self) -> Result, LoadError> { + Ok(self.id) + } + + async fn create(&mut self, db: super::Postgres) -> Result { + let mut client = db.client().await?; + let tx = client.transaction().await?; + + let ret = tx.query_one( + "insert into orders_certificate (order_id, reference, certificate) values ($1, $2, $3) returning id, created_at", + &[&self.order_id, &self.reference, &self.certificate] + ).await?; + + self.id = Some(ret.get("id")); + self.created_at = ret.get("created_at"); + + tx.commit().await?; + + Ok(self.id.unwrap()) + } + + async fn delete(&self, db: super::Postgres) -> Result<(), SaveError> { + if self.id.is_none() { + return Err(SaveError::Generic( + "record was not saved and deletion was requested".to_string(), + )); + } + + let mut client = db.client().await?; + let tx = client.transaction().await?; + + let res = tx.execute( + "update orders_certificate set deleted_at=CURRENT_TIMESTAMP where id=$1 and deleted_at is null", + &[&self.id.unwrap()], + ) + .await?; + + if res != 1 { + return Err(SaveError::Generic("row could not be deleted".to_string())); + } + + Ok(tx.commit().await?) + } + + async fn update(&self, _db: super::Postgres) -> Result<(), SaveError> { + Err(SaveError::Generic( + "update is not implemented for order certificates".to_string(), + )) + } +} + +#[derive(Debug, Clone, PartialEq)] +pub struct Authorization { + id: Option, + // FIXME make this Option<> to guard against writing an empty string + pub order_id: String, + pub reference: String, + pub expires: chrono::DateTime, + pub identifier: Option, + created_at: chrono::DateTime, + pub deleted_at: Option>, +} + +impl Default for Authorization { + fn default() -> Self { + Self { + id: None, + order_id: "".to_string(), + identifier: None, + expires: chrono::DateTime::::from(std::time::SystemTime::now()), + reference: make_nonce(None), + created_at: chrono::DateTime::::from(std::time::SystemTime::now()), + deleted_at: None, + } + } +} + +impl ToString for Authorization { + fn to_string(&self) -> String { + self.reference.clone() + } +} + +impl Authorization { + pub(crate) async fn find_by_reference( + reference: &str, + tx: &Transaction<'_>, + ) -> Result { + let res = tx + .query_one( + "select * from orders_authorizations where reference = $1", + &[&reference], + ) + .await?; + + Ok(Self::new_from_row(&res, tx).await?) + } + + pub(crate) async fn challenges( + &self, + tx: &Transaction<'_>, + ) -> Result, LoadError> { + Challenge::find_by_authorization(self.reference.clone(), tx).await + } + + pub fn into_url(&self, baseurl: Url) -> Url { + baseurl.join(&format!("/authz/{}", self.reference)).unwrap() + } +} + +#[async_trait] +impl RecordList for Authorization { + async fn collect(order_id: String, tx: &Transaction<'_>) -> Result, LoadError> { + let mut ret = Vec::new(); + + let results = tx + .query( + "select * from orders_authorizations where order_id = $1 order by created_at ASC", + &[&order_id], + ) + .await?; + + for result in results.iter() { + ret.push(Self::new_from_row(result, tx).await?); + } + + Ok(ret) + } + + async fn latest(order_id: String, tx: &Transaction<'_>) -> Result { + let row = tx + .query_one( + "select * from orders_authorizations where order_id = $1 order by created_at ASC limit 1", + &[&order_id], + ) + .await?; + + Ok(Self::new_from_row(&row, &tx).await?) + } + + async fn append(&self, order_id: String, tx: &Transaction<'_>) -> Result, SaveError> { + if self.identifier.is_none() { + return Err(SaveError::Generic( + "cannot insert an authorization without an identifier".to_string(), + )); + } + + tx.execute("insert into orders_authorizations (order_id, expires, identifier, reference, created_at, deleted_at) values ($1, $2, $3, $4, $5, $6)", &[&order_id, &self.expires, &self.identifier.clone().unwrap(),&self.reference, &self.created_at, &self.deleted_at]).await?; + Ok(Self::collect(order_id, tx).await?) + } + + async fn remove(&self, order_id: String, tx: &Transaction<'_>) -> Result<(), SaveError> { + tx.execute( + "delete from orders_authorizations where id=$1 and order_id=$2", + &[&self.id()?, &order_id], + ) + .await?; + Ok(()) + } + + async fn exists(&self, order_id: String, tx: &Transaction<'_>) -> Result { + let res = tx + .query_one( + "select count(*)::integer as count from orders_authorizations where id=$1 and order_id=$2", + &[&self.id()?, &order_id], + ) + .await?; + + Ok(res.get::<_, i32>(0) == 1) + } +} + +#[async_trait] +impl Record for Authorization { + async fn new_from_row(row: &Row, _tx: &Transaction<'_>) -> Result { + Ok(Self { + id: row.get("id"), + order_id: row.get("order_id"), + identifier: Some(row.get::<_, String>("identifier")), + reference: row.get("reference"), + expires: row.get("expires"), + created_at: row.get("created_at"), + deleted_at: row.get("deleted_at"), + }) + } + + async fn find(id: i32, db: super::Postgres) -> Result { + let mut client = db.client().await?; + let tx = client.transaction().await?; + + let result = tx + .query_one( + "select * from orders_authorizations where id = $1 and deleted_at is null", + &[&id], + ) + .await?; + + Self::new_from_row(&result, &tx).await + } + + fn id(&self) -> Result, LoadError> { + Ok(self.id) + } + + async fn create(&mut self, db: super::Postgres) -> Result { + if self.identifier.is_none() { + return Err(SaveError::Generic( + "cannot insert an authorization without an identifier".to_string(), + )); + } + + let mut client = db.client().await?; + let tx = client.transaction().await?; + + let ret = tx.query_one("insert into orders_authorizations (order_id, expires, reference, identifier) values ($1, $2, $3, $4) returning id, created_at", &[&self.order_id, &self.expires, &self.reference, &self.identifier]).await?; + + self.id = Some(ret.get("id")); + self.created_at = ret.get("created_at"); + + tx.commit().await?; + + Ok(self.id.unwrap()) + } + + async fn delete(&self, db: super::Postgres) -> Result<(), SaveError> { + if self.id.is_none() { + return Err(SaveError::Generic( + "record was not saved and deletion was requested".to_string(), + )); + } + + let mut client = db.client().await?; + let tx = client.transaction().await?; + + let res = tx.execute( + "update orders_authorizations set deleted_at=CURRENT_TIMESTAMP where id=$1 and deleted_at is null", + &[&self.id.unwrap()], + ) + .await?; + + if res != 1 { + return Err(SaveError::Generic("row could not be deleted".to_string())); + } + + Ok(tx.commit().await?) + } + + async fn update(&self, _db: super::Postgres) -> Result<(), SaveError> { + Err(SaveError::Generic( + "update is not implemented for order authorizations".to_string(), + )) + } +} + +mod tests { + #[tokio::test(flavor = "multi_thread")] + async fn test_order_certificate() { + use super::Certificate; + use crate::models::Record; + use crate::test::PGTest; + use crate::util::make_nonce; + use spectral::prelude::*; + + let pg = PGTest::new("test_order_certificate").await.unwrap(); + + let good = vec![Certificate { + order_id: make_nonce(None), + ..Default::default() + }]; + + for mut item in good { + assert_that!(item.create(pg.db()).await).is_ok(); + assert_that!(item.id()).is_ok(); + assert_that!(item.id().unwrap()).is_some(); + assert_that!(item.id().unwrap().unwrap()).is_not_equal_to(0); + + assert_that!(item.order_id).is_not_equal_to("".to_string()); + assert_that!(item.reference).is_not_equal_to("".to_string()); + + let s: String = item.clone().into(); + assert_that!(&s).is_equal_to(&item.reference); + + let new = Certificate::find(item.id().unwrap().unwrap(), pg.db()).await; + assert_that!(new).is_ok(); + + let new = new.unwrap(); + + assert_that!(&new).is_equal_to(&item); + + assert_that!(item.update(pg.db()).await).is_err(); + + assert_that!(item.delete(pg.db()).await).is_ok(); + + let new = Certificate::find(item.id().unwrap().unwrap(), pg.db()).await; + assert_that!(new).is_err(); + } + } + + #[tokio::test(flavor = "multi_thread")] + async fn test_order_authorization() { + use super::Authorization; + use crate::models::{Record, RecordList}; + use crate::test::PGTest; + use crate::util::make_nonce; + + use spectral::prelude::*; + + let pg = PGTest::new("test_order_authorization").await.unwrap(); + + let mut bad = Authorization::default(); + + assert_that!(bad.create(pg.db()).await).is_err(); + bad.order_id = make_nonce(None); + assert_that!(bad.create(pg.db()).await).is_err(); + bad.identifier = Some("example.com".to_string()); + assert_that!(bad.create(pg.db()).await).is_ok(); + + let good = vec![Authorization { + order_id: make_nonce(None), + identifier: Some("example.com".to_string()), + ..Default::default() + }]; + + for mut item in good { + assert_that!(item.create(pg.db()).await).is_ok(); + assert_that!(item.id()).is_ok(); + assert_that!(item.id().unwrap()).is_some(); + assert_that!(item.id().unwrap().unwrap()).is_not_equal_to(0); + + assert_that!(item.order_id).is_not_equal_to("".to_string()); + assert_that!(item.reference).is_not_equal_to("".to_string()); + + let s: String = item.to_string(); + assert_that!(&s).is_equal_to(&item.reference); + + let new = Authorization::find(item.id().unwrap().unwrap(), pg.db()).await; + assert_that!(new).is_ok(); + + let mut new = new.unwrap(); + new.expires = chrono::DateTime::::from(std::time::SystemTime::now()); + item.expires = new.expires; + + assert_that!(&new).is_equal_to(&item); + + assert_that!(item.update(pg.db()).await).is_err(); + + assert_that!(item.delete(pg.db()).await).is_ok(); + + let new = Authorization::find(item.id().unwrap().unwrap(), pg.db()).await; + assert_that!(new).is_err(); + } + + for _ in 0..10 { + let mut obj = Authorization { + order_id: "special".to_string(), + identifier: Some("example.com".to_string()), + ..Default::default() + }; + + assert_that!(obj.create(pg.db()).await).is_ok(); + } + + let auths = Authorization::collect( + "special".to_string(), + &pg.db().client().await.unwrap().transaction().await.unwrap(), + ) + .await; + assert_that!(auths).is_ok(); + let auths = auths.unwrap(); + + assert_that!(auths.len()).is_equal_to(10); + + let mut bad2 = Authorization::default(); + + let good2 = Authorization { + order_id: "special".to_string(), + identifier: Some("example.com".to_string()), + ..Default::default() + }; + + let db = pg.db(); + let mut lockeddb = db.client().await.unwrap(); + let tx = lockeddb.transaction().await.unwrap(); + assert_that!(bad2.append("special".to_string(), &tx).await).is_err(); + + // these drops are important because the errors will abort the tx + drop(tx); + bad2.order_id = make_nonce(None); + + let tx = lockeddb.transaction().await.unwrap(); + assert_that!(bad2.append("special".to_string(), &tx).await).is_err(); + + drop(tx); + bad2.identifier = Some("example.com".to_string()); + + let tx = lockeddb.transaction().await.unwrap(); + assert_that!(bad2.append("special".to_string(), &tx).await).is_ok(); + + let auths = good2.append("special".to_string(), &tx).await.unwrap(); + tx.commit().await.unwrap(); + + assert_that!(auths.len()).is_equal_to(12); + + for auth in &auths { + assert_that!(auth + .exists( + "special".to_string(), + &lockeddb.transaction().await.unwrap(), + ) + .await + .unwrap()) + .is_true(); + } + + assert_that!(auths[11] + .exists( + "special".to_string(), + &lockeddb.transaction().await.unwrap(), + ) + .await + .unwrap()) + .is_true(); + + let tx = lockeddb.transaction().await.unwrap(); + + assert_that!(auths[11].remove("special".to_string(), &tx).await).is_ok(); + + tx.commit().await.unwrap(); + + let auths_new = Authorization::collect( + "special".to_string(), + &lockeddb.transaction().await.unwrap(), + ) + .await; + assert_that!(auths_new).is_ok(); + let auths_new = auths_new.unwrap(); + + assert_that!(auths[11] + .exists( + "special".to_string(), + &lockeddb.transaction().await.unwrap(), + ) + .await + .unwrap()) + .is_false(); + + assert_that!(auths_new.len()).is_equal_to(11); + } +} diff --git a/src/test/mod.rs b/src/test/mod.rs new file mode 100644 index 0000000..ea0d660 --- /dev/null +++ b/src/test/mod.rs @@ -0,0 +1,473 @@ +#![cfg(test)] + +use std::process::Stdio; +use std::sync::Once; +use std::{sync::Arc, time::Duration}; + +use crate::acme::ca::{CACollector, CA}; +use crate::acme::challenge::Challenger; +use crate::acme::handlers::{configure_routes, HandlerState, ServiceState}; +use crate::acme::PostgresNonceValidator; +use crate::errors::db::MigrationError; +use crate::models::Postgres; +use crate::util::make_nonce; + +use bollard::container::{LogsOptions, StartContainerOptions}; +use openssl::error::ErrorStack; +use ratpack::app::TestApp; +use ratpack::prelude::*; + +use bollard::{ + container::{Config, WaitContainerOptions}, + models::HostConfig, + Docker, +}; +use eggshell::EggShell; +use futures::TryStreamExt; +use lazy_static::lazy_static; +use openssl::sha::sha256; +use tempfile::{tempdir, TempDir}; +use thiserror::Error; +use tokio::net::TcpListener; +use tokio::sync::Mutex; +use url::Url; + +const DEBUG_VAR: &str = "DEBUG"; + +fn is_debug() -> bool { + !std::env::var(DEBUG_VAR).unwrap_or_default().is_empty() +} + +pub(crate) const DEFAULT_CONTACT: &str = "erik@hollensbe.org"; + +impl From for eggshell::Error { + fn from(me: MigrationError) -> Self { + Self::Generic(me.to_string()) + } +} + +#[derive(Clone)] +pub struct PGTest { + gs: Arc>, + postgres: Postgres, + docker: Arc>, + // NOTE: the only reason we keep this is to ensure it lives the same lifetime as the PGTest + // struct; otherwise the temporary directory is removed prematurely. + temp: Arc>, +} + +fn pull_images(images: Vec<&str>) -> () { + // bollard doesn't let you pull images. sadly, this is what I came up with until I can patch + // it. + + for image in images { + let mut cmd = &mut std::process::Command::new("docker"); + if !is_debug() { + cmd = cmd.stdout(Stdio::null()).stderr(Stdio::null()); + } + + let stat = cmd.args(vec!["pull", image]).status().unwrap(); + if !stat.success() { + panic!("could not pull images"); + } + } +} + +async fn wait_for_images(images: Vec<&str>) -> () { + let docker = Docker::connect_with_local_defaults().unwrap(); + + for image in images { + loop { + match docker.inspect_image(image).await { + Ok(_) => break, + Err(_) => { + tokio::time::sleep(Duration::new(0, 200)).await; + } + } + } + } +} + +static INIT: Once = Once::new(); +lazy_static! { + static ref IMAGES: Vec<&'static str> = vec![ + "certbot/certbot:latest", + "postgres:latest", + "zerotier/zlint:latest", + ]; +} + +const HBA_CONFIG_PATH: &str = "hack/pg_hba.conf"; + +impl PGTest { + pub async fn new(name: &str) -> Result { + INIT.call_once(|| { + let mut builder = &mut env_logger::builder(); + if is_debug() { + builder = builder.filter_level(log::LevelFilter::Info) + } + builder.init(); + pull_images(IMAGES.to_vec()); + }); + + wait_for_images(IMAGES.to_vec()).await; + + let pwd = std::env::current_dir().unwrap(); + let hbapath = pwd.join(HBA_CONFIG_PATH); + + let temp = tempdir().unwrap(); + + let docker = Arc::new(Mutex::new(Docker::connect_with_local_defaults().unwrap())); + let mut gs = EggShell::new(docker.clone()).await?; + + if is_debug() { + gs.set_debug(true) + } + + log::info!("launching postgres instance: {}", name); + + gs.launch( + name, + bollard::container::Config { + image: Some("postgres:latest".to_string()), + env: Some( + vec!["POSTGRES_PASSWORD=dummy", "POSTGRES_DB=coyote"] + .iter() + .map(|x| x.to_string()) + .collect(), + ), + host_config: Some(HostConfig { + binds: Some(vec![ + format!( + "{}:{}", + hbapath.to_string_lossy().to_string(), + "/etc/postgresql/pg_hba.conf" + ), + format!("{}:{}", temp.path().display(), "/var/run/postgresql"), + ]), + ..Default::default() + }), + cmd: Some( + vec![ + "-c", + "shared_buffers=512MB", + "-c", + "max_connections=200", + "-c", + "unix_socket_permissions=0777", + ] + .iter() + .map(|x| x.to_string()) + .collect(), + ), + ..Default::default() + }, + None, + ) + .await?; + + log::info!("waiting for postgres instance: {}", name); + + let mut postgres: Option = None; + let config = format!("host={} dbname=coyote user=postgres", temp.path().display()); + + while postgres.is_none() { + let pg = Postgres::connect_one(&config).await; + + match pg { + Ok(_) => postgres = Some(Postgres::new(&config, 200).await.unwrap()), + Err(_) => tokio::time::sleep(Duration::new(1, 0)).await, + } + } + + log::info!("connected to postgres instance: {}", name); + + let postgres = postgres.unwrap(); + postgres.migrate().await?; + + Ok(Self { + docker, + gs: Arc::new(Mutex::new(gs)), + postgres, + temp: Arc::new(Mutex::new(temp)), + }) + } + + pub fn db(&self) -> Postgres { + self.postgres.clone() + } + + pub fn eggshell(self) -> Arc> { + self.gs + } + + pub fn docker(&self) -> Arc> { + self.docker.clone() + } +} +#[derive(Debug, Clone, Error)] +pub(crate) enum ContainerError { + #[error("Unknown error encountered: {0}")] + Generic(String), + + #[error("container failed with exit status: {0}: {1}")] + Failed(i64, String), +} + +fn short_hash(s: String) -> String { + String::from( + &sha256(s.as_bytes()) + .iter() + .map(|c| format!("{:x}", c)) + .take(10) + .collect::>() + .join("")[0..10], + ) +} + +#[derive(Clone)] +pub(crate) struct TestService { + pub pg: Box, + pub nonce: PostgresNonceValidator, + pub app: ratpack::app::TestApp, + pub url: String, +} + +impl TestService { + pub(crate) async fn new(name: &str) -> Self { + let pg = PGTest::new(name).await.unwrap(); + let c = Challenger::new(Some(chrono::Duration::seconds(60))); + let validator = PostgresNonceValidator::new(pg.db().clone()); + + let c2 = c.clone(); + let pg2 = pg.db().clone(); + + tokio::spawn(async move { + loop { + c2.tick(|_c| Some(())).await; + c2.reconcile(pg2.clone()).await.unwrap(); + + tokio::time::sleep(Duration::new(0, 250)).await; + } + }); + + let ca = CACollector::new(Duration::new(0, 250)); + let mut ca2 = ca.clone(); + + tokio::spawn(async move { + let ca = CA::new_test_ca().unwrap(); + ca2.spawn_collector(|| -> Result { Ok(ca.clone()) }) + .await + }); + + let lis = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = lis.local_addr().unwrap(); + let url = format!("http://{}", addr); + drop(lis); + + let mut app = App::with_state( + ServiceState::new(url.clone(), pg.db(), c, ca, validator.clone()).unwrap(), + ); + + configure_routes(&mut app, None); + + let a = app.clone(); + + tokio::spawn(async move { + a.serve(&addr.clone().to_string()).await.unwrap(); + }); + + Self { + pg: Box::new(pg), + nonce: validator, + app: TestApp::new(app), + url, + } + } + + pub(crate) async fn zlint(&self, certs: Arc) -> Result<(), ContainerError> { + log::info!("letsencrypt dir: {}", certs.path().display()); + let name = &format!("zlint-{}", short_hash(make_nonce(None))); + + let res = self + .launch( + name, + Config { + attach_stdout: Some(is_debug()), + attach_stderr: Some(is_debug()), + image: Some("zerotier/zlint:latest".to_string()), + entrypoint: Some( + vec!["/bin/sh", "-c"] + .iter() + .map(|c| c.to_string()) + .collect::>(), + ), + cmd: Some(vec![ + "set -e; for file in /etc/letsencrypt/live/*/fullchain.pem; do zlint $file; done" + .to_string(), + ]), + host_config: Some(HostConfig { + binds: Some(vec![format!( + "{}:{}", + certs.path().to_string_lossy(), + "/etc/letsencrypt" + )]), + ..Default::default() + }), + ..Default::default() + }, + None, + ) + .await; + + if let Err(e) = res { + return Err(ContainerError::Generic(e.to_string())); + } + + self.wait(name).await?; + return Ok(()); + } + + pub(crate) async fn certbot( + &self, + certs: Option>, + command: String, + ) -> Result, ContainerError> { + let server_url = Url::parse(&self.url).unwrap(); + let server_url_hash = short_hash(server_url.to_string()); + let certs: Arc = match certs { + Some(certs) => certs, + None => Arc::new(tempdir().unwrap()), + }; + + log::info!("letsencrypt dir: {}", certs.path().display()); + + let name = &format!( + "certbot-{}-{}", + server_url_hash, + short_hash(make_nonce(None)) + ); + + let res = self + .launch( + name, + Config { + image: Some("certbot/certbot:latest".to_string()), + entrypoint: Some( + vec!["/bin/sh", "-c"] + .iter() + .map(|c| c.to_string()) + .collect::>(), + ), + cmd: Some(vec![format!( + // this 755 set is a hack around containers running as root and the + // test launching them running as a user. + "certbot --non-interactive --logs-dir '/etc/letsencrypt/logs' --server '{}' {} && chmod -R 755 /etc/letsencrypt", + server_url, command + )]), + host_config: Some(HostConfig { + network_mode: Some("host".to_string()), + binds: Some(vec![format!( + "{}:{}", + certs.path().to_string_lossy(), + "/etc/letsencrypt" + )]), + ..Default::default() + }), + ..Default::default() + }, + None, + ) + .await; + + if let Err(e) = res { + return Err(ContainerError::Generic(e.to_string())); + } + + self.wait(name).await?; + return Ok(certs); + } + + async fn launch( + &self, + name: &str, + config: Config, + start_opts: Option>, + ) -> Result<(), eggshell::Error> { + self.pg + .clone() + .eggshell() + .lock() + .await + .set_debug(is_debug()); + + self.pg + .clone() + .eggshell() + .lock() + .await + .launch(name, config, start_opts) + .await + } + + async fn wait(&self, name: &str) -> Result<(), ContainerError> { + loop { + tokio::time::sleep(Duration::new(1, 0)).await; + + let locked = self.pg.docker.lock().await; + let waitres = locked + .wait_container::( + name, + Some(WaitContainerOptions { + condition: "not-running".to_string(), + }), + ) + .try_next() + .await; + + if let Ok(Some(res)) = waitres { + if res.status_code != 0 || res.error.is_some() { + let mut error = res.error.unwrap_or_default().message; + + let logs = locked + .logs::( + name, + Some(LogsOptions:: { + stderr: is_debug(), + stdout: is_debug(), + ..Default::default() + }), + ) + .try_next() + .await; + if let Ok(Some(logs)) = logs { + error = Some(format!("{}", logs)); + let logs = logs.into_bytes(); + if logs.len() > 50 && is_debug() { + std::fs::write("error.log", logs).unwrap(); + error = Some("error too long: error written to error.log".to_string()) + } + } + + return Err(ContainerError::Failed( + res.status_code, + error.unwrap_or_default(), + )); + } else { + return Ok(()); + } + } + } + } +} + +mod tests { + #[tokio::test(flavor = "multi_thread")] + async fn pgtest_basic() { + use super::PGTest; + use spectral::prelude::*; + + let res = PGTest::new("pgtest_basic").await; + assert_that!(res.is_ok()).is_true(); + } +} diff --git a/src/util/mod.rs b/src/util/mod.rs new file mode 100644 index 0000000..674288e --- /dev/null +++ b/src/util/mod.rs @@ -0,0 +1,24 @@ +use rand::Fill; + +const DEFAULT_NONCE_SIZE: usize = 64; + +// generate some random bytes +pub(crate) fn make_nonce(len: Option) -> String { + let mut r = Vec::new(); + r.resize(len.unwrap_or(DEFAULT_NONCE_SIZE), 0); + + r.try_fill(&mut rand::thread_rng()) + .expect("Couldn't do a random"); + + base64::encode_config(r, base64::URL_SAFE_NO_PAD) +} + +pub(crate) fn to_base64(payload: &T) -> Result +where + T: serde::Serialize + ?Sized, +{ + Ok(base64::encode_config( + serde_json::to_string(payload)?, + base64::URL_SAFE_NO_PAD, + )) +}