From 2f8dce44d3f2be74b5c6ec0a2e7f4ceced715328 Mon Sep 17 00:00:00 2001 From: pennae Date: Wed, 13 Jul 2022 10:33:30 +0200 Subject: initial import --- .gitignore | 7 + Cargo.lock | 3269 +++++++++++++++++++++++++++++++ Cargo.toml | 41 + README.md | 26 + Raven-Silhouette.svg | 140 ++ Rocket.toml | 116 ++ default.nix | 54 + flake.lock | 27 + flake.nix | 32 + migrations/20220626163140_init.down.sql | 1 + migrations/20220626163140_init.up.sql | 291 +++ pytest.ini | 3 + rustfmt.toml | 8 + sqlx-data.json | 1801 +++++++++++++++++ src/api/auth/account.rs | 413 ++++ src/api/auth/device.rs | 455 +++++ src/api/auth/email.rs | 126 ++ src/api/auth/invite.rs | 47 + src/api/auth/mod.rs | 238 +++ src/api/auth/oauth.rs | 433 ++++ src/api/auth/password.rs | 260 +++ src/api/auth/session.rs | 107 + src/api/mod.rs | 32 + src/api/oauth.rs | 163 ++ src/api/profile/mod.rs | 324 +++ src/auth.rs | 241 +++ src/bin/minorskulk.rs | 9 + src/cache.rs | 42 + src/crypto.rs | 408 ++++ src/db/mod.rs | 1026 ++++++++++ src/js.rs | 53 + src/lib.rs | 319 +++ src/mailer.rs | 105 + src/push.rs | 198 ++ src/types.rs | 436 +++++ src/types/oauth.rs | 267 +++ src/utils.rs | 124 ++ tests/_utils.py | 421 ++++ tests/api.py | 252 +++ tests/conftest.py | 115 ++ tests/integration.rs | 73 + tests/smtp.py | 27 + tests/test_auth_account.py | 348 ++++ tests/test_auth_device.py | 434 ++++ tests/test_auth_email.py | 96 + tests/test_auth_oauth.py | 369 ++++ tests/test_auth_password.py | 211 ++ tests/test_auth_session.py | 69 + tests/test_oauth.py | 97 + tests/test_profile.py | 134 ++ tests/test_push.py | 147 ++ web/index.html | 392 ++++ web/js/browser/browser.js | 4 + web/js/browser/lib/client.js | 792 ++++++++ web/js/browser/lib/crypto.js | 163 ++ web/js/browser/lib/hawk.d.ts | 24 + web/js/browser/lib/hawk.js | 145 ++ web/js/browser/lib/recoveryKey.js | 38 + web/js/browser/lib/utils.js | 26 + web/js/crypto.js | 137 ++ web/js/crypto.test.js | 49 + web/js/main.js | 761 +++++++ 62 files changed, 16966 insertions(+) create mode 100644 .gitignore create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 README.md create mode 100644 Raven-Silhouette.svg create mode 100644 Rocket.toml create mode 100644 default.nix create mode 100644 flake.lock create mode 100644 flake.nix create mode 100644 migrations/20220626163140_init.down.sql create mode 100644 migrations/20220626163140_init.up.sql create mode 100644 pytest.ini create mode 100644 rustfmt.toml create mode 100644 sqlx-data.json create mode 100644 src/api/auth/account.rs create mode 100644 src/api/auth/device.rs create mode 100644 src/api/auth/email.rs create mode 100644 src/api/auth/invite.rs create mode 100644 src/api/auth/mod.rs create mode 100644 src/api/auth/oauth.rs create mode 100644 src/api/auth/password.rs create mode 100644 src/api/auth/session.rs create mode 100644 src/api/mod.rs create mode 100644 src/api/oauth.rs create mode 100644 src/api/profile/mod.rs create mode 100644 src/auth.rs create mode 100644 src/bin/minorskulk.rs create mode 100644 src/cache.rs create mode 100644 src/crypto.rs create mode 100644 src/db/mod.rs create mode 100644 src/js.rs create mode 100644 src/lib.rs create mode 100644 src/mailer.rs create mode 100644 src/push.rs create mode 100644 src/types.rs create mode 100644 src/types/oauth.rs create mode 100644 src/utils.rs create mode 100644 tests/_utils.py create mode 100644 tests/api.py create mode 100644 tests/conftest.py create mode 100644 tests/integration.rs create mode 100644 tests/smtp.py create mode 100644 tests/test_auth_account.py create mode 100644 tests/test_auth_device.py create mode 100644 tests/test_auth_email.py create mode 100644 tests/test_auth_oauth.py create mode 100644 tests/test_auth_password.py create mode 100644 tests/test_auth_session.py create mode 100644 tests/test_oauth.py create mode 100644 tests/test_profile.py create mode 100644 tests/test_push.py create mode 100644 web/index.html create mode 100644 web/js/browser/browser.js create mode 100644 web/js/browser/lib/client.js create mode 100644 web/js/browser/lib/crypto.js create mode 100644 web/js/browser/lib/hawk.d.ts create mode 100644 web/js/browser/lib/hawk.js create mode 100644 web/js/browser/lib/recoveryKey.js create mode 100644 web/js/browser/lib/utils.js create mode 100644 web/js/crypto.js create mode 100644 web/js/crypto.test.js create mode 100644 web/js/main.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..72cb6fa --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +/target +**/.*.~undo-tree~ +**/.#* +.env +**/__pycache__ +/test.log +/result diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..7936ce1 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,3269 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "aead" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b613b8e1e3cf911a086f53f03bf286f52fd7a7258e4fa606f0ef220d39d8877" +dependencies = [ + "generic-array", +] + +[[package]] +name = "aes" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e8b47f52ea9bae42228d07ec09eb676433d7c4ed1ebdf0f1d1c29ed446f1ab8" +dependencies = [ + "cfg-if", + "cipher 0.3.0", + "cpufeatures", + "opaque-debug", +] + +[[package]] +name = "aes-gcm" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df5f85a83a7d8b0442b6aa7b504b8212c1733da07b98aae43d4bc21b2cb3cdf6" +dependencies = [ + "aead", + "aes", + "cipher 0.3.0", + "ctr", + "ghash", + "subtle", +] + +[[package]] +name = "ahash" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcb51a0695d8f838b1ee009b3fbf66bda078cd64590202a864a8f3e8c4315c47" +dependencies = [ + "getrandom", + "once_cell", + "version_check", +] + +[[package]] +name = "aho-corasick" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e37cfd5e7657ada45f742d6e99ca5788580b5c529dc78faf11ece6dc702656f" +dependencies = [ + "memchr", +] + +[[package]] +name = "ansi_term" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d52a9bb7ec0cf484c551830a7ce27bd20d67eac647e1befb56b0be4ee39a55d2" +dependencies = [ + "winapi", +] + +[[package]] +name = "anyhow" +version = "1.0.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb07d2053ccdbe10e2af2995a2f116c1330396493dc1269f6a91d0ae82e19704" + +[[package]] +name = "async-channel" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2114d64672151c0c5eaa5e131ec84a74f06e1e559830dabba01ca30605d66319" +dependencies = [ + "concurrent-queue", + "event-listener", + "futures-core", +] + +[[package]] +name = "async-stream" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dad5c83079eae9969be7fadefe640a1c566901f05ff91ab221de4b6f68d9507e" +dependencies = [ + "async-stream-impl", + "futures-core", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10f203db73a71dfa2fb6dd22763990fa26f3d2625a6da2da900d23b87d26be27" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "async-trait" +version = "0.1.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96cf8829f67d2eab0b2dfa42c5d0ef737e0724e4a82b01b3e292456202b19716" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "atoi" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c57d12312ff59c811c0643f4d80830505833c9ffaebd193d819392b265be8e" +dependencies = [ + "num-traits", +] + +[[package]] +name = "atomic" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b88d82667eca772c4aa12f0f1348b3ae643424c8876448f3f7bd5787032e234c" +dependencies = [ + "autocfg 1.1.0", +] + +[[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 = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dde43e75fd43e8a1bf86103336bc699aa8d17ad1be60c76c0bdfd4828e19b78" +dependencies = [ + "autocfg 1.1.0", +] + +[[package]] +name = "autocfg" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa" + +[[package]] +name = "base16ct" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "349a06037c7bf932dd7e7d1f653678b2038b9ad46a74102f1fc7bd7872678cce" + +[[package]] +name = "base64" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "904dfeac50f3cdaba28fc6f57fdcddb75f49ed61346676a78c4ffe55877802fd" + +[[package]] +name = "base64ct" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6b4d9b1225d28d360ec6a231d65af1fd99a2a095154c8040689617290569c5c" + +[[package]] +name = "binascii" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "383d29d513d8764dcdc42ea295d979eb99c3c9f00607b3692cf68a431f7dca72" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "block-buffer" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4152116fd6e9dadb291ae18fc1ec3575ed6d84c29642d97890f4b4a3417297e4" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-buffer" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf7fe51849ea569fd452f37822f606a5cabb684dc918707a0193fd4664ff324" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37ccbd214614c6783386c1af30caf03192f17891059cecc394b4fb119e363de3" + +[[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 = "cache-padded" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1db59621ec70f09c5e9b597b220c7a2b43611f4710dc03ceb8748637775692c" + +[[package]] +name = "castaway" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2698f953def977c68f935bb0dfa959375ad4638570e969e2f1e9f433cbf1af6" + +[[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", + "time 0.1.44", + "winapi", +] + +[[package]] +name = "cipher" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ee52072ec15386f770805afd189a01c8841be8696bed250fa2f13c4c0d6dfb7" +dependencies = [ + "generic-array", +] + +[[package]] +name = "cipher" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1873270f8f7942c191139cb8a40fd228da6c3fd2fc376d7e92d47aa14aeb59e" +dependencies = [ + "crypto-common", + "inout", +] + +[[package]] +name = "coarsetime" +version = "0.1.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "454038500439e141804c655b4cd1bc6a70bcb95cd2bc9463af5661b6956f0e46" +dependencies = [ + "libc", + "once_cell", + "wasi 0.11.0+wasi-snapshot-preview1", + "wasm-bindgen", +] + +[[package]] +name = "concurrent-queue" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30ed07550be01594c6026cff2a1d7fe9c8f683caa798e12b68694ac9e88286a3" +dependencies = [ + "cache-padded", +] + +[[package]] +name = "const-oid" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d6f2aa4d0537bcc1c74df8755072bd31c1ef1a3a1b85a68e8404a8c353b7b8b" + +[[package]] +name = "const-oid" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4c78c047431fee22c1a7bb92e00ad095a02a983affe4d8a72e2a2c62c1b94f3" + +[[package]] +name = "cookie" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94d4706de1b0fa5b132270cddffa8585166037822e260a944fe161acd137ca05" +dependencies = [ + "aes-gcm", + "base64", + "hkdf", + "hmac 0.12.1", + "percent-encoding", + "rand", + "sha2 0.10.2", + "subtle", + "time 0.3.11", + "version_check", +] + +[[package]] +name = "core-foundation" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "194a7a9e6de53fa55116934067c844d9d749312f75c6f6d0980e8c252f8c2146" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5827cebf4670468b8772dd191856768aedcb1b0278a04f989f7766351917b9dc" + +[[package]] +name = "cpufeatures" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59a6001667ab124aebae2a495118e11d30984c3a653e99d86d58971708cf5e4b" +dependencies = [ + "libc", +] + +[[package]] +name = "crc" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53757d12b596c16c78b83458d732a5d1a17ab3f53f2f7412f6fb57cc8a140ab3" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d0165d2900ae6778e36e80bbc4da3b5eefccee9ba939761f9c2882a5d9af3ff" + +[[package]] +name = "crossbeam-queue" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f25d8400f4a7a5778f0e4e52384a48cbd9b5c495d110786187fc750075277a2" +dependencies = [ + "cfg-if", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d82ee10ce34d7bc12c2122495e7593a9c41347ecdd64185af4ecf72cb1a7f83" +dependencies = [ + "cfg-if", + "once_cell", +] + +[[package]] +name = "crypto-bigint" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83bd3bb4314701c568e340cd8cf78c975aa0ca79e03d3f6d1677d5b0c9c0c03" +dependencies = [ + "generic-array", + "rand_core", + "subtle", +] + +[[package]] +name = "crypto-bigint" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c6a1d5fa1de37e071642dfa44ec552ca5b299adb128fab16138e24b548fd21" +dependencies = [ + "generic-array", + "rand_core", + "subtle", + "zeroize", +] + +[[package]] +name = "crypto-common" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ccfd8c0ee4cce11e45b3fd6f9d5e69e0cc62912aa6a0cb1bf4617b0eba5a12f" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "crypto-mac" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1d1a86f49236c215f271d40892d5fc950490551400b02ef360692c29815c714" +dependencies = [ + "generic-array", + "subtle", +] + +[[package]] +name = "ct-codecs" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3b7eb4404b8195a9abb6356f4ac07d8ba267045c8d6d220ac4dc992e6cc75df" + +[[package]] +name = "ctr" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "049bb91fb4aaf0e3c7efa6cd5ef877dbbbd15b39dad06d9948de4ec8a75761ea" +dependencies = [ + "cipher 0.3.0", +] + +[[package]] +name = "curl" +version = "0.4.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37d855aeef205b43f65a5001e0997d81f8efca7badad4fad7d897aa7f0d0651f" +dependencies = [ + "curl-sys", + "libc", + "openssl-probe", + "openssl-sys", + "schannel", + "socket2", + "winapi", +] + +[[package]] +name = "curl-sys" +version = "0.4.55+curl-7.83.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23734ec77368ec583c2e61dd3f0b0e5c98b93abe6d2a004ca06b91dd7e3e2762" +dependencies = [ + "cc", + "libc", + "libnghttp2-sys", + "libz-sys", + "openssl-sys", + "pkg-config", + "vcpkg", + "winapi", +] + +[[package]] +name = "der" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79b71cca7d95d7681a4b3b9cdf63c8dbc3730d0584c2c74e31416d64a90493f4" +dependencies = [ + "const-oid 0.6.2", + "crypto-bigint 0.2.11", + "der_derive", +] + +[[package]] +name = "der" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6919815d73839e7ad218de758883aae3a257ba6759ce7a9992501efbb53d705c" +dependencies = [ + "const-oid 0.7.1", + "pem-rfc7468 0.3.1", +] + +[[package]] +name = "der_derive" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8aed3b3c608dc56cf36c45fe979d04eda51242e6703d8d0bb03426ef7c41db6a" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "devise" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50c7580b072f1c8476148f16e0a0d5dedddab787da98d86c5082c5e9ed8ab595" +dependencies = [ + "devise_codegen", + "devise_core", +] + +[[package]] +name = "devise_codegen" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "123c73e7a6e51b05c75fe1a1b2f4e241399ea5740ed810b0e3e6cacd9db5e7b2" +dependencies = [ + "devise_core", + "quote", +] + +[[package]] +name = "devise_core" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841ef46f4787d9097405cac4e70fb8644fc037b526e8c14054247c0263c400d0" +dependencies = [ + "bitflags", + "proc-macro2", + "proc-macro2-diagnostics", + "quote", + "syn", +] + +[[package]] +name = "digest" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3dd60d1080a57a05ab032377049e0591415d2b31afd7028356dbf3cc6dcb066" +dependencies = [ + "generic-array", +] + +[[package]] +name = "digest" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2fb860ca6fafa5552fb6d0e816a69c8e49f0908bf524e30a90d97c85892d506" +dependencies = [ + "block-buffer 0.10.2", + "crypto-common", + "subtle", +] + +[[package]] +name = "dirs" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca3aa72a6f96ea37bbc5aa912f6788242832f75369bdfdadcb0e38423f100059" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b1d1d91c932ef41c0f2663aa8b0ca0342d444d842c06914aa0a7e352d0bada6" +dependencies = [ + "libc", + "redox_users", + "winapi", +] + +[[package]] +name = "dotenv" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77c90badedccf4105eca100756a0b1289e191f6fcbdadd3cee1d2f614f97da8f" + +[[package]] +name = "ecdsa" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0d69ae62e0ce582d56380743515fefaf1a8c70cec685d9677636d7e30ae9dc9" +dependencies = [ + "der 0.5.1", + "elliptic-curve", + "rfc6979", + "signature", +] + +[[package]] +name = "ece" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8dd5463ffecc0677adcd786c4481f73b215714d4757edf2eb37a573c03d00459" +dependencies = [ + "base64", + "byteorder", + "hex", + "hkdf", + "lazy_static", + "once_cell", + "openssl", + "serde", + "sha2 0.10.2", + "thiserror", +] + +[[package]] +name = "ed25519-compact" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24e1f30f0312ac83726c1197abeacd91c9557f8a623e904a009ae6bc529ae8d8" +dependencies = [ + "ct-codecs", + "getrandom", +] + +[[package]] +name = "either" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f107b87b6afc2a64fd13cac55fe06d6c8859f12d4b14cbcdd2c67d0976781be" +dependencies = [ + "serde", +] + +[[package]] +name = "elliptic-curve" +version = "0.11.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25b477563c2bfed38a3b7a60964c49e058b2510ad3f12ba3483fd8f62c2306d6" +dependencies = [ + "base16ct", + "crypto-bigint 0.3.2", + "der 0.5.1", + "ff", + "generic-array", + "group", + "pem-rfc7468 0.3.1", + "rand_core", + "sec1", + "subtle", + "zeroize", +] + +[[package]] +name = "email-encoding" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34dd14c63662e0206599796cd5e1ad0268ab2b9d19b868d6050d688eba2bbf98" +dependencies = [ + "base64", + "memchr", +] + +[[package]] +name = "email_address" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8684b7c9cb4857dfa1e5b9629ef584ba618c9b93bae60f58cb23f4f271d0468e" + +[[package]] +name = "encoding_rs" +version = "0.8.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9852635589dc9f9ea1b6fe9f05b50ef208c85c834a562f0c6abb1c475736ec2b" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "event-listener" +version = "2.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77f3309417938f28bf8228fcff79a4a37103981e3e186d2ccd19c74b38f4eb71" + +[[package]] +name = "fastrand" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3fcf0cee53519c866c09b5de1f6c56ff9d647101f81c1964fa632e148896cdf" +dependencies = [ + "instant", +] + +[[package]] +name = "ff" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "131655483be284720a17d74ff97592b8e76576dc25563148601df2d7c9080924" +dependencies = [ + "rand_core", + "subtle", +] + +[[package]] +name = "figment" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790b4292c72618abbab50f787a477014fe15634f96291de45672ce46afe122df" +dependencies = [ + "atomic", + "pear", + "serde", + "toml", + "uncased", + "version_check", +] + +[[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 = "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-intrusive" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62007592ac46aa7c2b6416f7deb9a8a8f63a01e0f1d6e1787d5630170db2b63e" +dependencies = [ + "futures-core", + "lock_api", + "parking_lot 0.11.2", +] + +[[package]] +name = "futures-io" +version = "0.3.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc4045962a5a5e935ee2fdedaa4e08284547402885ab326734432bed5d12966b" + +[[package]] +name = "futures-lite" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694489acd39452c77daa48516b894c153f192c3578d5a839b62c58099fcbf48" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "memchr", + "parking", + "pin-project-lite", + "waker-fn", +] + +[[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 = "generator" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1d9279ca822891c1a4dae06d185612cf8fc6acfe5dff37781b41297811b12ee" +dependencies = [ + "cc", + "libc", + "log", + "rustversion", + "winapi", +] + +[[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.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4eb1a864a501629691edf6c15a593b7a51eebaa1e8468e9ddc623de7c9b58ec6" +dependencies = [ + "cfg-if", + "libc", + "wasi 0.11.0+wasi-snapshot-preview1", +] + +[[package]] +name = "ghash" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1583cc1656d7839fd3732b80cf4f38850336cdb9b8ded1cd399ca62958de3c99" +dependencies = [ + "opaque-debug", + "polyval", +] + +[[package]] +name = "glob" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b919933a397b79c37e33b77bb2aa3dc8eb6e165ad809e58ff75bc7db2e34574" + +[[package]] +name = "group" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc5ac374b108929de78460075f3dc439fa66df9d8fc77e8f12caa5165fcf0c89" +dependencies = [ + "ff", + "rand_core", + "subtle", +] + +[[package]] +name = "h2" +version = "0.3.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37a82c6d637fc9515a4694bbf1cb2457b79d81ce52b3108bdeea58b07dd34a57" +dependencies = [ + "bytes", + "fnv", + "futures-core", + "futures-sink", + "futures-util", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util 0.7.3", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "607c8a29735385251a339424dd462993c0fed8fa09d378f259377df08c126022" +dependencies = [ + "ahash", +] + +[[package]] +name = "hashlink" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d452c155cb93fecdfb02a73dd57b5d8e442c2063bd7aac72f1bc5e4263a43086" +dependencies = [ + "hashbrown", +] + +[[package]] +name = "hawk" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f42afdd0e58859aa7b944db9125bdddb5437233161726c20578fbb73c776f440" +dependencies = [ + "anyhow", + "base64", + "log", + "once_cell", + "ring", + "thiserror", + "url", +] + +[[package]] +name = "heck" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2540771e65fc8cb83cd6e8a237f70c319bd5c29f78ed1084ba5d50eeac86f7f9" +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 = "hex-literal" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ebdb29d2ea9ed0083cd8cece49bbd968021bd99b0849edb4a9a7ee0fdf6a4e0" + +[[package]] +name = "hkdf" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "791a029f6b9fc27657f6f188ec6e5e43f6911f6f878e0dc5501396e09809d437" +dependencies = [ + "hmac 0.12.1", +] + +[[package]] +name = "hmac" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a2a2320eb7ec0ebe8da8f744d7812d9fc4cb4d09344ac01898dbcb6a20ae69b" +dependencies = [ + "crypto-mac", + "digest 0.9.0", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest 0.10.3", +] + +[[package]] +name = "hmac-sha1-compact" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d103cfecf6edf3f7d1dc7c5ab64e99488c0f8d11786e43b40873e66e8489d014" + +[[package]] +name = "hmac-sha256" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd29dbba58ee5314f3ec570066d78a3f4772bf45b322efcf2ce2a43af69a4d85" +dependencies = [ + "digest 0.9.0", +] + +[[package]] +name = "hmac-sha512" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a928b002dff1780b7fa21056991d395770ab9359154b8c1724c4d0511dad0a65" +dependencies = [ + "digest 0.9.0", +] + +[[package]] +name = "hostname" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c731c3e10504cc8ed35cfe2f1db4c9274c3d35fa486e3b31df46f068ef3e867" +dependencies = [ + "libc", + "match_cfg", + "winapi", +] + +[[package]] +name = "http" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75f43d41e26995c17e71ee126451dd3941010b0514a81a9d11f3b341debc2399" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[package]] +name = "http-body" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5f38f16d184e36f2408a55281cd658ecbd3ca05cce6d6510a176eca393e26d1" +dependencies = [ + "bytes", + "http", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "496ce29bb5a52785b44e0f7ca2847ae0bb839c9bd28f69acac9b99d461c0c04c" + +[[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 = "humantime-serde" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57a3db5ea5923d99402c94e9feb261dc5ee9b4efa158b0315f788cf549cc200c" +dependencies = [ + "humantime", + "serde", +] + +[[package]] +name = "hyper" +version = "0.14.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02c929dc5c39e335a03c405292728118860721b10190d98c2a0f0efd5baafbac" +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 = "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 = "if_chain" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb56e1aa765b4b4f3aadfab769793b7087bb03a4ea4920644a6d238e2df5b9ed" + +[[package]] +name = "indexmap" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a35a97730320ffe8e2d410b5d3b69279b98d2c14bdb8b70ea89ecf7888d41e" +dependencies = [ + "autocfg 1.1.0", + "hashbrown", + "serde", +] + +[[package]] +name = "inlinable_string" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8fae54786f62fb2918dcfae3d568594e50eb9b5c25bf04371af6fe7516452fb" + +[[package]] +name = "inout" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0c10553d664a4d0bcff9f4215d0aac67a639cc68ef660840afe309b807bc9f5" +dependencies = [ + "generic-array", +] + +[[package]] +name = "instant" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a5bbe824c507c5da5956355e86a746d82e0e1464f65d862cc5e71da70e94b2c" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "isahc" +version = "1.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "334e04b4d781f436dc315cb1e7515bd96826426345d498149e4bde36b67f8ee9" +dependencies = [ + "async-channel", + "castaway", + "crossbeam-utils", + "curl", + "curl-sys", + "encoding_rs", + "event-listener", + "futures-lite", + "http", + "log", + "mime", + "once_cell", + "polling", + "slab", + "sluice", + "tracing", + "tracing-futures", + "url", + "waker-fn", +] + +[[package]] +name = "itertools" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9a9d19fa1e79b6215ff29b9d6880b706147f16e9b1dbb1e4e5947b5b02bc5e3" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112c678d4050afce233f4f2852bb2eb519230b3cf12f33585275537d7e41578d" + +[[package]] +name = "js-sys" +version = "0.3.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3fac17f7123a73ca62df411b1bf727ccc805daa070338fda671c86dac1bdc27" +dependencies = [ + "wasm-bindgen", +] + +[[package]] +name = "jwt-simple" +version = "0.10.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ae17b3b351f55edf6c5d2f0776ad8a467677751362207b92f443bf0a348f67" +dependencies = [ + "anyhow", + "coarsetime", + "ct-codecs", + "ed25519-compact", + "hmac-sha1-compact", + "hmac-sha256", + "hmac-sha512", + "k256", + "p256", + "rand", + "rsa", + "serde", + "serde_json", + "thiserror", + "zeroize", +] + +[[package]] +name = "k256" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19c3a5e0a0b8450278feda242592512e09f61c72e018b8cd5c859482802daf2d" +dependencies = [ + "cfg-if", + "ecdsa", + "elliptic-curve", + "sec1", + "sha2 0.9.9", +] + +[[package]] +name = "lazy_static" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" +dependencies = [ + "spin 0.5.2", +] + +[[package]] +name = "lettre" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5677c78c7c7ede1dd68e8a7078012bc625449fb304e7b509b917eaaedfe6e849" +dependencies = [ + "async-trait", + "base64", + "email-encoding", + "email_address", + "fastrand", + "futures-io", + "futures-util", + "hostname", + "httpdate", + "idna", + "mime", + "native-tls", + "nom", + "once_cell", + "quoted_printable", + "serde", + "socket2", + "tokio", + "tokio-native-tls", +] + +[[package]] +name = "libc" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "349d5a591cd28b49e1d1037471617a32ddcda5731b99419008085f72d5a53836" + +[[package]] +name = "libm" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33a33a362ce288760ec6a508b94caaec573ae7d3bbbd91b87aa0bad4456839db" + +[[package]] +name = "libnghttp2-sys" +version = "0.1.7+1.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57ed28aba195b38d5ff02b9170cbff627e336a20925e43b4945390401c5dc93f" +dependencies = [ + "cc", + "libc", +] + +[[package]] +name = "libz-sys" +version = "1.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9702761c3935f8cc2f101793272e202c72b99da8f4224a19ddcf1279a6450bbf" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "lock_api" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "327fa5b6a6940e4699ec49a9beae1ea4845c6bab9314e4f84ac68742139d8c53" +dependencies = [ + "autocfg 1.1.0", + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abb12e687cfb44aa40f41fc3978ef76448f9b6038cad6aef4259d3c095a2382e" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "loom" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff50ecb28bb86013e935fb6683ab1f6d3a20016f123c76fd4c27470076ac30f5" +dependencies = [ + "cfg-if", + "generator", + "scoped-tls", + "serde", + "serde_json", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "match_cfg" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffbee8634e0d45d258acb448e7eaab3fce7a0a467395d4d9f228e3c1f01fb2e4" + +[[package]] +name = "matchers" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8263075bb86c5a1b1427b5ae862e8889656f126e9f77c484496e8b47cf5c5558" +dependencies = [ + "regex-automata", +] + +[[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.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "658646b21e0b72f7866c7038ab086d3d5e1cd6271f060fd37defb241949d0582" +dependencies = [ + "digest 0.10.3", +] + +[[package]] +name = "memchr" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dffe52ecf27772e601905b7522cb4ef790d2cc203488bbd0e2fe85fcb74566d" + +[[package]] +name = "mime" +version = "0.3.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a60c7ce501c71e03a9c9c0d35b861413ae925bd979cc7a4e30d060069aaac8d" + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "minor-skulk" +version = "0.1.0" +dependencies = [ + "anyhow", + "base64", + "chrono", + "dotenv", + "either", + "futures", + "hawk", + "hex", + "hex-literal", + "hkdf", + "hmac 0.12.1", + "humantime-serde", + "lazy_static", + "lettre", + "password-hash", + "rand", + "rocket", + "scrypt", + "serde", + "serde_json", + "sha2 0.10.2", + "sqlx", + "subtle", + "url", + "validator", + "web-push", + "zeroize", +] + +[[package]] +name = "mio" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57ee1c23c7c63b0c9250c339ffdc69255f110b298b901b9f6c82547b7b87caaf" +dependencies = [ + "libc", + "log", + "wasi 0.11.0+wasi-snapshot-preview1", + "windows-sys", +] + +[[package]] +name = "multer" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f8f35e687561d5c1667590911e6698a8cb714a134a7505718a182e7bc9d3836" +dependencies = [ + "bytes", + "encoding_rs", + "futures-util", + "http", + "httparse", + "log", + "memchr", + "mime", + "spin 0.9.3", + "tokio", + "tokio-util 0.6.10", + "version_check", +] + +[[package]] +name = "native-tls" +version = "0.2.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd7e2f3618557f980e0b17e8856252eee3c97fa12c54dff0ca290fb6266ca4a9" +dependencies = [ + "lazy_static", + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + +[[package]] +name = "nom" +version = "7.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8903e5a29a317527874d0402f867152a3d21c908bb0b933e416c65e301d4c36" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "num-bigint-dig" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4547ee5541c18742396ae2c895d0717d0f886d8823b8399cdaf7b07d63ad0480" +dependencies = [ + "autocfg 0.1.8", + "byteorder", + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand", + "smallvec", + "zeroize", +] + +[[package]] +name = "num-integer" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "225d3389fb3509a24c93f5c29eb6bde2586b98d9f016636dff58d7c6f7569cd9" +dependencies = [ + "autocfg 1.1.0", + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d03e6c028c5dc5cac6e2dec0efda81fc887605bb3d884578bb6d6bf7514e252" +dependencies = [ + "autocfg 1.1.0", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "578ede34cf02f8924ab9447f50c28075b4d3e5b269972345e7e0372b38c6cdcd" +dependencies = [ + "autocfg 1.1.0", + "libm", +] + +[[package]] +name = "num_cpus" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19e64526ebdee182341572e50e9ad03965aa510cd94427a4549448f285e957a1" +dependencies = [ + "hermit-abi", + "libc", +] + +[[package]] +name = "num_threads" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2819ce041d2ee131036f4fc9d6ae7ae125a3a40e97ba64d04fe799ad9dabbb44" +dependencies = [ + "libc", +] + +[[package]] +name = "once_cell" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18a6dbe30758c9f83eb00cbea4ac95966305f5a7772f3f42ebfc7fc7eddbd8e1" + +[[package]] +name = "opaque-debug" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "624a8340c38c1b80fd549087862da4ba43e08858af025b236e509b6649fc13d5" + +[[package]] +name = "openssl" +version = "0.10.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "618febf65336490dfcf20b73f885f5651a0c89c64c2d4a8c3662585a70bf5bd0" +dependencies = [ + "bitflags", + "cfg-if", + "foreign-types", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b501e44f11665960c7e7fcf062c7d96a14ade4aa98116c004b2e37b5be7d736c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "openssl-probe" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff011a302c396a5197692431fc1948019154afc178baf7d8e37367442a4601cf" + +[[package]] +name = "openssl-sys" +version = "0.9.75" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5f9bd0c2710541a3cda73d6f9ac4f1b240de4ae261065d309dbe73d9dceb42f" +dependencies = [ + "autocfg 1.1.0", + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "p256" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19736d80675fbe9fe33426268150b951a3fb8f5cfca2a23a17c85ef3adb24e3b" +dependencies = [ + "ecdsa", + "elliptic-curve", + "sec1", + "sha2 0.9.9", +] + +[[package]] +name = "parking" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "427c3892f9e783d91cc128285287e70a59e206ca452770ece88a76f7a3eddd72" + +[[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.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3742b2c103b9f06bc9fff0a37ff4912935851bee6d36f3c02bcc755bcfec228f" +dependencies = [ + "lock_api", + "parking_lot_core 0.9.3", +] + +[[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.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09a279cbf25cb0757810394fbc1e359949b59e348145c643a939a525692e6929" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-sys", +] + +[[package]] +name = "password-hash" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7676374caaee8a325c9e7a2ae557f216c5563a171d6997b0ef8a65af35147700" +dependencies = [ + "base64ct", + "rand_core", + "subtle", +] + +[[package]] +name = "paste" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c520e05135d6e763148b6426a837e239041653ba7becd2e538c076c738025fc" + +[[package]] +name = "pbkdf2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83a0692ec44e4cf1ef28ca317f14f8f07da2d95ec3fa01f86e4467b725e60917" +dependencies = [ + "digest 0.10.3", +] + +[[package]] +name = "pear" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15e44241c5e4c868e3eaa78b7c1848cadd6344ed4f54d029832d32b415a58702" +dependencies = [ + "inlinable_string", + "pear_codegen", + "yansi", +] + +[[package]] +name = "pear_codegen" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82a5ca643c2303ecb740d506539deba189e16f2754040a42901cd8105d0282d0" +dependencies = [ + "proc-macro2", + "proc-macro2-diagnostics", + "quote", + "syn", +] + +[[package]] +name = "pem" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd56cbd21fea48d0c440b41cd69c589faacade08c992d9a54e471b79d0fd13eb" +dependencies = [ + "base64", + "once_cell", + "regex", +] + +[[package]] +name = "pem-rfc7468" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84e93a3b1cc0510b03020f33f21e62acdde3dcaef432edc95bea377fbd4c2cd4" +dependencies = [ + "base64ct", +] + +[[package]] +name = "pem-rfc7468" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01de5d978f34aa4b2296576379fcc416034702fd94117c56ffd8a1a767cefb30" +dependencies = [ + "base64ct", +] + +[[package]] +name = "percent-encoding" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4fd5641d01c8f18a23da7b6fe29298ff4b55afcccdf78973b24cf3175fee32e" + +[[package]] +name = "pin-project" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78203e83c48cffbe01e4a2d35d566ca4de445d79a85372fc64e378bfc812a260" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "710faf75e1b33345361201d36d04e98ac1ed8909151a017ed384700836104c74" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0a7ae3ac2f1173085d398531c705756c94a4c56843785df85a60c1a0afac116" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "pkcs1" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "116bee8279d783c0cf370efa1a94632f2108e5ef0bb32df31f051647810a4e2c" +dependencies = [ + "der 0.4.5", + "pem-rfc7468 0.2.4", + "zeroize", +] + +[[package]] +name = "pkcs8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee3ef9b64d26bad0536099c816c6734379e45bbd5f14798def6809e5cc350447" +dependencies = [ + "der 0.4.5", + "pem-rfc7468 0.2.4", + "pkcs1", + "spki 0.4.1", + "zeroize", +] + +[[package]] +name = "pkcs8" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cabda3fb821068a9a4fab19a683eac3af12edf0f34b94a8be53c4972b8149d0" +dependencies = [ + "der 0.5.1", + "spki 0.5.4", + "zeroize", +] + +[[package]] +name = "pkg-config" +version = "0.3.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1df8c4ec4b0627e53bdf214615ad287367e482558cf84b109250b37464dc03ae" + +[[package]] +name = "polling" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "685404d509889fade3e86fe3a5803bca2ec09b0c0778d5ada6ec8bf7a8de5259" +dependencies = [ + "cfg-if", + "libc", + "log", + "wepoll-ffi", + "winapi", +] + +[[package]] +name = "polyval" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8419d2b623c7c0896ff2d5d96e2cb4ede590fed28fcc34934f4c33c036e620a1" +dependencies = [ + "cfg-if", + "cpufeatures", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb9f9e6e233e5c4a35559a617bf40a4ec447db2e84c20b55a6f83167b7e57872" + +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + +[[package]] +name = "proc-macro2" +version = "1.0.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd96a1e8ed2596c337f8eae5f24924ec83f5ad5ab21ea8e455d3566c69fbcaf7" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "proc-macro2-diagnostics" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bf29726d67464d49fa6224a1d07936a8c08bb3fba727c7493f6cf1616fdaada" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "version_check", + "yansi", +] + +[[package]] +name = "quote" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3bcdf212e9776fbcb2d23ab029360416bb1706b1aea2d1a5ba002727cbcab804" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "quoted_printable" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fee2dce59f7a43418e3382c766554c614e06a552d53a8f07ef499ea4b332c0f" + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d34f1408f55294453790c48b2f1ebbb1c5b4b7563eb1f418bcfcfdbb06ebb4e7" +dependencies = [ + "getrandom", +] + +[[package]] +name = "redox_syscall" +version = "0.2.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62f25bc4c7e55e0b0b7a1d43fb893f4fa1361d0abe38b9ce4f323c2adfe6ef42" +dependencies = [ + "bitflags", +] + +[[package]] +name = "redox_users" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b033d837a7cf162d7993aded9304e30a83213c648b6e389db233191f891e5c2b" +dependencies = [ + "getrandom", + "redox_syscall", + "thiserror", +] + +[[package]] +name = "ref-cast" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "685d58625b6c2b83e4cc88a27c4bf65adb7b6b16dbdc413e515c9405b47432ab" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a043824e29c94169374ac5183ac0ed43f5724dc4556b19568007486bd840fa1f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "regex" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c4eb3267174b8c6c2f654116623910a0fef09c4753f8dd83db29c48a0df988b" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c230d73fb8d8c1b9c0b3135c5142a8acee3a0558fb8db5cf1cb65f8d7862132" +dependencies = [ + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.6.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3f87b73ce11b1619a3c6332f45341e0047173771e8b8b73f87bfeefb7b56244" + +[[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 = "rfc6979" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96ef608575f6392792f9ecf7890c00086591d29a83910939d430753f7c050525" +dependencies = [ + "crypto-bigint 0.3.2", + "hmac 0.11.0", + "zeroize", +] + +[[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 0.5.2", + "untrusted", + "web-sys", + "winapi", +] + +[[package]] +name = "rocket" +version = "0.5.0-rc.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98ead083fce4a405feb349cf09abdf64471c6077f14e0ce59364aa90d4b99317" +dependencies = [ + "async-stream", + "async-trait", + "atomic", + "atty", + "binascii", + "bytes", + "either", + "figment", + "futures", + "indexmap", + "log", + "memchr", + "multer", + "num_cpus", + "parking_lot 0.12.1", + "pin-project-lite", + "rand", + "ref-cast", + "rocket_codegen", + "rocket_http", + "serde", + "serde_json", + "state", + "tempfile", + "time 0.3.11", + "tokio", + "tokio-stream", + "tokio-util 0.7.3", + "ubyte", + "version_check", + "yansi", +] + +[[package]] +name = "rocket_codegen" +version = "0.5.0-rc.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6aeb6bb9c61e9cd2c00d70ea267bf36f76a4cc615e5908b349c2f9d93999b47" +dependencies = [ + "devise", + "glob", + "indexmap", + "proc-macro2", + "quote", + "rocket_http", + "syn", + "unicode-xid", +] + +[[package]] +name = "rocket_http" +version = "0.5.0-rc.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ded65d127954de3c12471630bf4b81a2792f065984461e65b91d0fdaafc17a2" +dependencies = [ + "cookie", + "either", + "futures", + "http", + "hyper", + "indexmap", + "log", + "memchr", + "pear", + "percent-encoding", + "pin-project-lite", + "ref-cast", + "serde", + "smallvec", + "stable-pattern", + "state", + "time 0.3.11", + "tokio", + "uncased", +] + +[[package]] +name = "rsa" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e05c2603e2823634ab331437001b411b9ed11660fbc4066f3908c84a9439260d" +dependencies = [ + "byteorder", + "digest 0.9.0", + "lazy_static", + "num-bigint-dig", + "num-integer", + "num-iter", + "num-traits", + "pkcs1", + "pkcs8 0.7.6", + "rand", + "subtle", + "zeroize", +] + +[[package]] +name = "rustversion" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0a5f7c728f5d284929a1cccb5bc19884422bfe6ef4d6c409da2c41838983fcf" + +[[package]] +name = "ryu" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3f6f92acf49d1b98f7a81226834412ada05458b7364277387724a237f062695" + +[[package]] +name = "salsa20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97a22f5af31f73a954c10289c93e8a50cc23d971e80ee446f1f6f7137a088213" +dependencies = [ + "cipher 0.4.3", +] + +[[package]] +name = "schannel" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88d6731146462ea25d9244b2ed5fd1d716d25c52e4d54aa4fb0f3c4e9854dbe2" +dependencies = [ + "lazy_static", + "windows-sys", +] + +[[package]] +name = "scoped-tls" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6a9290e3c9cf0f18145ef7ffa62d68ee0bf5fcd651017e586dc7fd5da448c2" + +[[package]] +name = "scopeguard" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d29ab0c6d3fc0ee92fe66e2d99f700eab17a8d57d1c1d3b748380fb20baa78cd" + +[[package]] +name = "scrypt" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f9e24d2b632954ded8ab2ef9fea0a0c769ea56ea98bddbafbad22caeeadf45d" +dependencies = [ + "hmac 0.12.1", + "password-hash", + "pbkdf2", + "salsa20", + "sha2 0.10.2", +] + +[[package]] +name = "sec1" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08da66b8b0965a5555b6bd6639e68ccba85e1e2506f5fbb089e93f8a04e1a2d1" +dependencies = [ + "der 0.5.1", + "generic-array", + "pkcs8 0.8.0", + "subtle", + "zeroize", +] + +[[package]] +name = "sec1_decode" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6326ddc956378a0739200b2c30892dccaf198992dfd7323274690b9e188af23" +dependencies = [ + "der 0.4.5", + "pem", + "thiserror", +] + +[[package]] +name = "security-framework" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dc14f172faf8a0194a3aded622712b0de276821addc574fa54fc0a1167e10dc" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0160a13a177a45bfb43ce71c01580998474f556ad854dcbca936dd2841a5c556" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "serde" +version = "1.0.139" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0171ebb889e45aa68b44aee0859b3eede84c6f5f5c228e6f140c0b2a0a46cad6" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.139" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1d3230c1de7932af58ad8ffbe1d784bd55efd5a9d84ac24f69c72d83543dfb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.82" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82c2c1fdcd807d1098552c5b9a36e425e42e9fbd7c6a37a8425f390f781f7fa7" +dependencies = [ + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha-1" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "028f48d513f9678cda28f6e4064755b3fbb2af6acd672f2c209b62323f7aea0f" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest 0.10.3", +] + +[[package]] +name = "sha2" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d58a1e1bf39749807d89cf2d98ac2dfa0ff1cb3faa38fbb64dd88ac8013d800" +dependencies = [ + "block-buffer 0.9.0", + "cfg-if", + "cpufeatures", + "digest 0.9.0", + "opaque-debug", +] + +[[package]] +name = "sha2" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55deaec60f81eefe3cce0dc50bda92d6d8e88f2a27df7c5033b42afeb1ed2676" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest 0.10.3", +] + +[[package]] +name = "sharded-slab" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "900fba806f70c630b0a382d0d825e17a0f19fcd059a2ade1ff237bcddf446b31" +dependencies = [ + "lazy_static", +] + +[[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 = "signature" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02658e48d89f2bec991f9a78e69cfa4c316f8d6a6c4ec12fae1aeb263d486788" +dependencies = [ + "digest 0.9.0", + "rand_core", +] + +[[package]] +name = "slab" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb703cfe953bccee95685111adeedb76fabe4e97549a58d16f03ea7b9367bb32" + +[[package]] +name = "sluice" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d7400c0eff44aa2fcb5e31a5f24ba9716ed90138769e4977a2ba6014ae63eb5" +dependencies = [ + "async-channel", + "futures-core", + "futures-io", +] + +[[package]] +name = "smallvec" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fd0db749597d91ff862fd1d55ea87f7855a744a8425a64695b6fca237d1dad1" + +[[package]] +name = "socket2" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66d72b759436ae32898a2af0a14218dbf55efde3feeb170eb623637db85ee1e0" +dependencies = [ + "libc", + "winapi", +] + +[[package]] +name = "spin" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e63cff320ae2c57904679ba7cb63280a3dc4613885beafb148ee7bf9aa9042d" + +[[package]] +name = "spin" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c530c2b0d0bf8b69304b39fe2001993e267461948b890cd037d8ad4293fa1a0d" + +[[package]] +name = "spki" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c01a0c15da1b0b0e1494112e7af814a678fec9bd157881b49beac661e9b6f32" +dependencies = [ + "der 0.4.5", +] + +[[package]] +name = "spki" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d01ac02a6ccf3e07db148d2be087da624fea0221a16152ed01f0496a6b0a27" +dependencies = [ + "base64ct", + "der 0.5.1", +] + +[[package]] +name = "sqlformat" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4b7922be017ee70900be125523f38bdd644f4f06a1b16e8fa5a8ee8c34bffd4" +dependencies = [ + "itertools", + "nom", + "unicode_categories", +] + +[[package]] +name = "sqlx" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f82cbe94f41641d6c410ded25bbf5097c240cefdf8e3b06d04198d0a96af6a4" +dependencies = [ + "sqlx-core", + "sqlx-macros", +] + +[[package]] +name = "sqlx-core" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b69bf218860335ddda60d6ce85ee39f6cf6e5630e300e19757d1de15886a093" +dependencies = [ + "ahash", + "atoi", + "base64", + "bitflags", + "byteorder", + "bytes", + "chrono", + "crc", + "crossbeam-queue", + "dirs", + "either", + "event-listener", + "futures-channel", + "futures-core", + "futures-intrusive", + "futures-util", + "hashlink", + "hex", + "hkdf", + "hmac 0.12.1", + "indexmap", + "itoa", + "libc", + "log", + "md-5", + "memchr", + "once_cell", + "paste", + "percent-encoding", + "rand", + "serde", + "serde_json", + "sha-1", + "sha2 0.10.2", + "smallvec", + "sqlformat", + "sqlx-rt", + "stringprep", + "thiserror", + "tokio-stream", + "url", + "whoami", +] + +[[package]] +name = "sqlx-macros" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40c63177cf23d356b159b60acd27c54af7423f1736988502e36bae9a712118f" +dependencies = [ + "dotenv", + "either", + "heck", + "hex", + "once_cell", + "proc-macro2", + "quote", + "serde", + "serde_json", + "sha2 0.10.2", + "sqlx-core", + "sqlx-rt", + "syn", + "url", +] + +[[package]] +name = "sqlx-rt" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "874e93a365a598dc3dadb197565952cb143ae4aa716f7bcc933a8d836f6bf89f" +dependencies = [ + "native-tls", + "once_cell", + "tokio", + "tokio-native-tls", +] + +[[package]] +name = "stable-pattern" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4564168c00635f88eaed410d5efa8131afa8d8699a612c80c455a0ba05c21045" +dependencies = [ + "memchr", +] + +[[package]] +name = "state" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbe866e1e51e8260c9eed836a042a5e7f6726bb2b411dffeaa712e19c388f23b" +dependencies = [ + "loom", +] + +[[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 = "subtle" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bdef32e8150c2a081110b42772ffe7d7c9032b606bc226c8260fd97e0976601" + +[[package]] +name = "syn" +version = "1.0.98" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c50aef8a904de4c23c788f104b7dddc7d6f79c647c7c8ce4cc8f73eb0ca773dd" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.12.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f36bdaa60a83aca3921b5259d5400cbf5e90fc51931376a9bd4a0eb79aa7210f" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "unicode-xid", +] + +[[package]] +name = "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 = "thiserror" +version = "1.0.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd829fe32373d27f76265620b5309d0340cb8550f523c1dda251d6298069069a" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0396bc89e626244658bef819e22d0cc459e795a5ebe878e6ec336d1674a8d79a" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thread_local" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5516c27b78311c50bf42c071425c560ac799b11c30b31f87e3081965fe5e0180" +dependencies = [ + "once_cell", +] + +[[package]] +name = "time" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db9e6914ab8b1ae1c260a4ae7a49b6c5611b40328a735b21862567685e73255" +dependencies = [ + "libc", + "wasi 0.10.0+wasi-snapshot-preview1", + "winapi", +] + +[[package]] +name = "time" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72c91f41dcb2f096c05f0873d667dceec1087ce5bcf984ec8ffb19acddbb3217" +dependencies = [ + "itoa", + "libc", + "num_threads", + "time-macros", +] + +[[package]] +name = "time-macros" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42657b1a6f4d817cda8e7a0ace261fe0cc946cf3a80314390b22cc61ae080792" + +[[package]] +name = "tinyvec" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87cc5ceb3875bb20c2890005a4e226a4651264a5c75edb2421b52861a0a0cb50" +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.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c51a52ed6686dd62c320f9b89299e9dfb46f730c7a48e635c19f21d116cb1439" +dependencies = [ + "bytes", + "libc", + "memchr", + "mio", + "num_cpus", + "once_cell", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "winapi", +] + +[[package]] +name = "tokio-macros" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9724f9a975fb987ef7a3cd9be0350edcbe130698af5b8f7a631e23d42d052484" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-native-tls" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7d995660bd2b7f8c1568414c1126076c13fbb725c40112dc0120b78eb9b717b" +dependencies = [ + "native-tls", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df54d54117d6fdc4e4fea40fe1e4e566b3505700e148a6827e59b34b0d2600d9" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.6.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36943ee01a6d67977dd3f84a5a1d2efeb4ada3a1ae771cadfaa535d9d9fc6507" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "log", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc463cd8deddc3770d20f9852143d50bf6094e640b485cb2e189a2099085ff45" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", + "tracing", +] + +[[package]] +name = "toml" +version = "0.5.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d82e1a7758622a465f8cee077614c73484dac5b836c02ff6a40d5d1010324d7" +dependencies = [ + "serde", +] + +[[package]] +name = "tower-service" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6bc1c9ce2b5135ac7f93c72918fc37feb872bdc6a5533a8b85eb4b86bfdae52" + +[[package]] +name = "tracing" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a400e31aa60b9d44a52a8ee0343b5b18566b03a8321e0d321f695cf56e940160" +dependencies = [ + "cfg-if", + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11c75893af559bc8e10716548bdef5cb2b983f8e637db9d0e15126b61b484ee2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b7358be39f2f274f322d2aaed611acc57f382e8eb1e5b48cb9ae30933495ce7" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-futures" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97d095ae15e245a057c8e8451bab9b3ee1e1f68e9ba2b4fbc18d0ac5237835f2" +dependencies = [ + "pin-project", + "tracing", +] + +[[package]] +name = "tracing-log" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78ddad33d2d10b1ed7eb9d1f518a5674713876e97e5bb9b7345a7984fbb4f922" +dependencies = [ + "lazy_static", + "log", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a713421342a5a666b7577783721d3117f1b69a393df803ee17bb73b1e122a59" +dependencies = [ + "ansi_term", + "matchers", + "once_cell", + "regex", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[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 = "ubyte" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a58e29f263341a29bb79e14ad7fda5f63b1c7e48929bad4c685d7876b1d04e94" +dependencies = [ + "serde", +] + +[[package]] +name = "uncased" +version = "0.9.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09b01702b0fd0b3fadcf98e098780badda8742d4f4a7676615cad90e8ac73622" +dependencies = [ + "serde", + "version_check", +] + +[[package]] +name = "unicode-bidi" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "099b7128301d285f79ddd55b9a83d5e6b9e97c92e0ea0daebee7263e932de992" + +[[package]] +name = "unicode-ident" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5bd2fe26506023ed7b5e1e315add59d6f584c621d037f9368fea9cfb988f368c" + +[[package]] +name = "unicode-normalization" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "854cbdc4f7bc6ae19c820d44abdc3277ac3e1b2b93db20a636825d9322fb60e6" +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.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "957e51f3646910546462e67d5f7599b9e4fb8acdd304b087a6494730f9eebf04" + +[[package]] +name = "unicode_categories" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e" + +[[package]] +name = "universal-hash" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f214e8f697e925001e66ec2c6e37a4ef93f0f78c2eed7814394e10c62025b05" +dependencies = [ + "generic-array", + "subtle", +] + +[[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", +] + +[[package]] +name = "validator" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f07b0a1390e01c0fc35ebb26b28ced33c9a3808f7f9fbe94d3cc01e233bfeed5" +dependencies = [ + "idna", + "lazy_static", + "regex", + "serde", + "serde_derive", + "serde_json", + "url", + "validator_derive", +] + +[[package]] +name = "validator_derive" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea7ed5e8cf2b6bdd64a6c4ce851da25388a89327b17b88424ceced6bd5017923" +dependencies = [ + "if_chain", + "lazy_static", + "proc-macro-error", + "proc-macro2", + "quote", + "regex", + "syn", + "validator_types", +] + +[[package]] +name = "validator_types" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2ddf34293296847abfc1493b15c6e2f5d3cd19f57ad7d22673bf4c6278da329" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "valuable" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830b7e5d4d90034032940e4ace0d9a9a057e7a45cd94e6c007832e39edb82f6d" + +[[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 = "waker-fn" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d5b2c62b4012a3e1eca5a7e077d13b3bf498c4073e33ccd58626607748ceeca" + +[[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.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a143597ca7c7793eff794def352d41792a93c481eb1042423ff7ff72ba2c31f" + +[[package]] +name = "wasi" +version = "0.11.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" + +[[package]] +name = "wasm-bindgen" +version = "0.2.81" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c53b543413a17a202f4be280a7e5c62a1c69345f5de525ee64f8cfdbc954994" +dependencies = [ + "cfg-if", + "wasm-bindgen-macro", +] + +[[package]] +name = "wasm-bindgen-backend" +version = "0.2.81" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5491a68ab4500fa6b4d726bd67408630c3dbe9c4fe7bda16d5c82a1fd8c7340a" +dependencies = [ + "bumpalo", + "lazy_static", + "log", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.81" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c441e177922bc58f1e12c022624b6216378e5febc2f0533e41ba443d505b80aa" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.81" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d94ac45fcf608c1f45ef53e748d35660f168490c10b23704c7779ab8f5c3048" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-backend", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.81" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a89911bd99e5f3659ec4acf9c4d93b0a90fe4a2a11f15328472058edc5261be" + +[[package]] +name = "web-push" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b1d99b17f0cd87b673d856db1f6c041ddc719c0fdeb59f97c770b8499996aba" +dependencies = [ + "base64", + "chrono", + "ece", + "futures-lite", + "http", + "isahc", + "jwt-simple", + "log", + "pem", + "pkcs8 0.7.6", + "sec1_decode", + "serde", + "serde_derive", + "serde_json", +] + +[[package]] +name = "web-sys" +version = "0.3.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fed94beee57daf8dd7d51f2b15dc2bcde92d7a72304cdf662a4371008b71b90" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wepoll-ffi" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d743fdedc5c64377b5fc2bc036b01c7fd642205a0d96356034ae3404d49eb7fb" +dependencies = [ + "cc", +] + +[[package]] +name = "whoami" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "524b58fa5a20a2fb3014dd6358b70e6579692a56ef6fce928834e488f42f65e8" +dependencies = [ + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-sys" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea04155a16a59f9eab786fe12a4a450e75cdb175f9e0d80da1e17db09f55b8d2" +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.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bb8c3fd39ade2d67e9874ac4f3db21f0d710bee00fe7cab16949ec184eeaa47" + +[[package]] +name = "windows_i686_gnu" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180e6ccf01daf4c426b846dfc66db1fc518f074baa793aa7d9b9aaeffad6a3b6" + +[[package]] +name = "windows_i686_msvc" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2e7917148b2812d1eeafaeb22a97e4813dfa60a3f8f78ebe204bcc88f12f024" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4dcd171b8776c41b97521e5da127a2d86ad280114807d0b2ab1e462bc764d9e1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c811ca4a8c853ef420abd8592ba53ddbbac90410fab6903b3e79972a631f7680" + +[[package]] +name = "yansi" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09041cd90cf85f7f8b2df60c646f853b7f535ce68f85244eb6731cf89fa498ec" + +[[package]] +name = "zeroize" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d68d9dcec5f9b43a30d38c49f91dfedfaac384cb8f085faca366c26207dd1619" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f8f187641dad4f680d25c4bfc4225b418165984179f26ca76ec4fb6441d3a17" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..da84734 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,41 @@ +[package] +name = "minor-skulk" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +anyhow = "1.0" +base64 = "0.13" +chrono = "0.4.19" +dotenv = "0.15" +either = "1.7" +futures = "0.3.21" +hawk = "4.0.0" +hex = "0.4" +hkdf = "0.12.3" +hmac = "0.12.1" +humantime-serde = "1.1.1" +lazy_static = "1.4" +lettre = { version = "0.10", features = [ "tokio1", "tokio1-native-tls", "serde" ] } +password-hash = "0.4" +rand = "0.8.5" +rocket = { version = "0.5.0-rc.2", features = [ "json" ] } +scrypt = { version = "0.10", features = [ "simple" ] } +serde = { version = "1.0", features = [ "derive" ] } +serde_json = "1.0" +sha2 = "0.10.2" +sqlx = { version = "0.6.0", features = [ "runtime-tokio-native-tls", "postgres", "chrono", "json", "offline" ] } +subtle = "2.4.1" +url = "2.2.2" +validator = { version = "0.15", features = [ "derive" ] } +web-push = "0.9.2" +zeroize = { version = "1.4.3", features = [ "zeroize_derive" ] } + +[dev-dependencies] +hex-literal = "0.3.4" + +[profile.dev.package.scrypt] +# avoid tests taking a huge amount of time due to unoptimized scrypt +opt-level = 2 diff --git a/README.md b/README.md new file mode 100644 index 0000000..3da6105 --- /dev/null +++ b/README.md @@ -0,0 +1,26 @@ +# installation + + * compile minor-skulk with `cargo build`. + * edit `Rocket.toml`, settings values for all the mandatory parameters + * for test deployments it's sufficient to use the http server provided by + minor-skulk. live deployments **must** use a reverse proxy to add TLS! + * install [syncstorage-rs](https://github.com/mozilla-services/syncstorage-rs) + * configure syncstorage-rs for a single-node setup as exemplified in + [this nixos module](https://github.com/NixOS/nixpkgs/pull/176835). + you will also have to set `tokenserver.fxa_oauth_server_url` as + noted in `Rocket.toml`, otherwise actual sync will not work. + +# configuring firefox desktop + +go to `about:config` and set `identity.fxaccounts.autoconfig.uri` to +the address of your server, e.g. `http://localhost:8000` if you're just +starting minor-skulk locally for testing. if you are not using https you +must also create a `identity.fxaccounts.allowHttp` settings with value `true`, +otherwise firefox will not accept the config. restart firefox for the +changes to take effect, then create an account or log in as usual. + +# configuring firefox android + +this is a sufficiently involved process that the web interface has a +dedicated guide. just open the url of your server in firefox on android +and follow the guide. ("just". we're so sorry.) diff --git a/Raven-Silhouette.svg b/Raven-Silhouette.svg new file mode 100644 index 0000000..78ecff2 --- /dev/null +++ b/Raven-Silhouette.svg @@ -0,0 +1,140 @@ + + + + diff --git a/Rocket.toml b/Rocket.toml new file mode 100644 index 0000000..85c5abf --- /dev/null +++ b/Rocket.toml @@ -0,0 +1,116 @@ +[default] +# identifier used in http response headers +ident = "minor skulk" +# limit to request parameter size. must be at least 32KiB. +limits.string = "32 KiB" +# limit to binary data size. avatars are sent as binary data, +# so this setting also limits the maximum permissible avatar +# size. +limits.bytes = "128 KiB" + + + +# connection string for the database (mandatory) +# +# current only postgresql is supported, with libpq connection strings. +# +#database_url = "postgresql:///minorskulk" + +# base location of the server (mandatory) +# +# this is the base url of the server, e.g. `https://my.domain` if you are running +# at the root of a domain or `https://my.domain/minorskulk` if you are running +# behind a reverse proxy that changes the root path. +# NOTE: if this is set incorrectly, *nothing will work*. the url is used as part +# of a request signing scheme, so setting this incorrectly will break all requests +# that use this signing scheme (which includes the login process). +# +#location = "https://my.domain" + +# base location of the synstorage token server (mandatory) +# +# this is the base url of your syncstorage token server, i.e. a syncstorage-rs +# instance. the token server must also be configured to use this instance to +# verify client tokens by setting `tokenserver.fxa_email_domain` to some local +# value and `tokenserver.fxa_oauth_server_url` to `/oauth/v1`. +# +# this url will only be rendered into the client autoconfig descriptor. setting +# an incorrect url here will not impact the function of minor skulk, but it will +# require setting not just the account server url in firefox but the token server +# url as well. +# +#token_server_location = "https://synstorage.my.domain" + +# vapid key for push notifications (mandatory) +# +# this must be set to the path to a valid EC key, generated for example with +# +# openssl ecparam -genkey -name prime256v1 -out private_key.pem +# +# not setting this key will cause all push notifications to fail, including tab +# sending between devices on one account. +# +#vapid_key = "/etc/secrets/minor-skulk-push-key.pem" + +# vapid subject identifier (mandatory) +# +# must be set to a mailto: address or a web url. mozilla ostensibly uses this +# information to contact you if weird things happen, and will reject pushes +# from server that do not provide this datum. +#vapid_subject = "mailto:minorskulk@my.domain" + +# default push notification TTL (optional) +# +# default lifetime of push notifications sent through the mozilla webpush service. +# notifications that cannot be delivered within this time frame will be dropped. +# +#default_push_ttl = "2 days" + +# expired token prune interval (optional) +# +# pruning interval for: +# - expired key fetch token +# - expired oauth tokens +# - expired oauth authorization code +# - expired device commands +# - expired invite codes +# +#prune_expired_interval = "5 minutes" + +# mail-from address (mandatory) +# +# all emails sent by minor skulk will be sent from this address. +# +#mail_from = "minor skulk " + +# mail host (optional) +# +# mail host to use when sending emails. +# +#mail_host = "localhost" + +# mail port (optional) +# +# port to use when connecting to `mail_host`. +# +#mail_port = 25 + +# invite only mode (optional) +# +# if set this instance will run in invite-only mode, disabling public +# registrations and requiring a single-use invite link to create a new +# account instead. invite links can be generated by the invite admin +# using the special (hidden) `#/generate-invite` fragment identifier +# on their account settings page. +# +#invite_only = false + +# invite admin (optional) +# +# email address of the user capable of creating invite links. if no user +# is registered with the configured email address during startup a new +# invite code will be generated (valid for one hour) and written to the +# log. this check is done on every startup, so not setting this value will +# produce a new invite code every time minor skulk starts. +# +#invite_admin_address = "admin@my.domain" diff --git a/default.nix b/default.nix new file mode 100644 index 0000000..1942086 --- /dev/null +++ b/default.nix @@ -0,0 +1,54 @@ +{ stdenv +, rustPlatform +, rust +, lib +, openssl +, pkg-config +, postgresql +, postgresqlTestHook +, python3 +}: + +rustPlatform.buildRustPackage rec { + pname = "minor-skulk"; + version = "0.1.0"; + + src = lib.cleanSource ./.; + + nativeBuildInputs = [ + pkg-config + ]; + checkInputs = [ + openssl + postgresql + postgresqlTestHook + (python3.withPackages (p: [ p.pytest p.pyfxa p.requests p.http-ece p.aiosmtpd ])) + ]; + buildInputs = [ + openssl + ]; + + postPatch = '' + patchShebangs ./tests/run.sh + ''; + + # tests can't run multithreaded yet + dontUseCargoParallelTests = true; + + # test config for postgres hook and integration tests + PGDATABASE = "testdb"; + PGUSER = "testuser"; + ROCKET_DATABASE_URL = "postgres:///${PGDATABASE}?user=${PGUSER}"; + ROCKET_LOCATION = "http://localhost:8000"; + ROCKET_TOKEN_SERVER_LOCATION = "http://localhost:5000"; + ROCKET_VAPID_KEY = "private_key.pem"; + ROCKET_VAPID_SUBJECT = "undefined"; # not needed for tests + ROCKET_MAIL_FROM = "minor skulk "; + ROCKET_MAIL_PORT = 2525; + + preCheck = '' + openssl ecparam -genkey -name prime256v1 -out private_key.pem + ''; + + cargoLock.lockFile = ./Cargo.lock; +} diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..3b26f34 --- /dev/null +++ b/flake.lock @@ -0,0 +1,27 @@ +{ + "nodes": { + "nixpkgs": { + "locked": { + "lastModified": 1653704018, + "narHash": "sha256-5dzlxE4okyu+M39yeVtHWQXzDZQxFF5rUB1iY9R6Lb4=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "13f08d71ceff5101321e0291854495a1ec153a5e", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable-small", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..8276544 --- /dev/null +++ b/flake.nix @@ -0,0 +1,32 @@ +{ + inputs = { + nixpkgs.url = github:NixOS/nixpkgs/nixos-unstable-small; + }; + + outputs = { self, nixpkgs }: + let + systems = { "x86_64-linux" = {}; }; + combine = fn: with builtins; + let + parts = mapAttrs (s: _: fn (nixpkgs.legacyPackages.${s})) systems; + keys = foldl' (a: b: a // b) {} (attrValues parts); + in + mapAttrs (k: _: mapAttrs (s: _: parts.${s}.${k} or {}) systems) keys; + in + combine (pkgs: rec { + packages = rec { + minor-skulk = pkgs.callPackage ./default.nix {}; + default = minor-skulk; + }; + + devShells.default = pkgs.mkShell { + inputsFrom = [ packages.default ]; + packages = with pkgs; [ + rustfmt + rust-analyzer + clippy + sqlx-cli + ]; + }; + }); +} diff --git a/migrations/20220626163140_init.down.sql b/migrations/20220626163140_init.down.sql new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/migrations/20220626163140_init.down.sql @@ -0,0 +1 @@ + diff --git a/migrations/20220626163140_init.up.sql b/migrations/20220626163140_init.up.sql new file mode 100644 index 0000000..916d31c --- /dev/null +++ b/migrations/20220626163140_init.up.sql @@ -0,0 +1,291 @@ +create domain session_id as bytea check (length(value) = 32); +create domain user_id as bytea check (length(value) = 16); +create domain hawk_key as bytea check (length(value) = 32); +create domain device_id as bytea check (length(value) = 16); +create domain key_fetch_id as bytea check (length(value) = 32); +create domain oauth_token_id as bytea check (length(value) = 32); +create domain oauth_auth_id as bytea check (length(value) = 32); +create domain secret_key as bytea check (length(value) = 32); +create domain verify_hash as bytea check (length(value) = 32); +create domain password_change_id as bytea check (length(value) = 32); +create domain account_reset_id as bytea check (length(value) = 32); +create domain avatar_id as bytea check (length(value) = 16); + +create type oauth_token_kind as enum ('access', 'refresh'); +create type oauth_access_type as enum ('online', 'offline'); +create type device_command as (name text, body text); + +create type device_push_info as ( + callback text, + public_key text, + auth_key text +); + +create table users ( + user_id user_id not null primary key, + auth_salt text not null, + email text not null check (length(email) <= 256), + display_name text check (length(display_name) <= 256), + ka secret_key not null, + wrapwrap_kb secret_key not null, + verify_hash verify_hash not null, + verified bool not null default false, + + created_at timestamp with time zone not null default now() +); + +create unique index user__email__idx on users ((lower(email))); + +create table user_avatars ( + user_id user_id not null primary key references users on delete cascade, + id avatar_id not null, + data bytea not null check (length(data) <= 128 * 1024), + content_type text not null check (length(content_type) < 1024) +); + +create table device ( + device_id device_id not null primary key, + user_id user_id not null references users on delete cascade, + created_at timestamp with time zone not null default now(), + name text not null check (length(name) <= 256), + type text not null check (length(type) <= 16), + push device_push_info, + available_commands device_command[] not null, + push_expired boolean not null, + location jsonb not null check (jsonb_typeof(location) = 'object') +); + +create function insert_or_update_device( + p_device_id device_id, + p_user_id user_id, + p_name text, + p_type text, + p_push device_push_info, + p_available_commands device_command[], + p_location jsonb, + out result device) + returns device + language plpgsql + as $$ + begin + update device + set name = coalesce(p_name, name), + type = coalesce(p_type, type), + push = coalesce(p_push, push), + available_commands = coalesce(p_available_commands, available_commands), + push_expired = push_expired and p_push is null, + location = coalesce(p_location, location) + where device_id = p_device_id + returning * + into result; + + if not found then + insert into device (device_id, user_id, name, type, push, push_expired, + available_commands, location) + values (p_device_id, p_user_id, p_name, p_type, p_push, p_push is null, + p_available_commands, coalesce(p_location, '{}'::jsonb)) + returning * + into result; + end if; + end + $$; + +create table user_session ( + session_id session_id not null primary key, + user_id user_id not null references users on delete cascade, + req_hmac_key hawk_key not null, + device_id device_id references device on delete cascade, + last_active timestamp with time zone default now(), + + created_at timestamp with time zone not null default now(), + verified bool not null, + verify_code text +); + +create function cascade_session_delete() + returns trigger + language plpgsql + as $$ + begin + delete from device where device.device_id = old.device_id; + return null; + end + $$; + +create trigger user_session__cascade_device + after delete on user_session + for each row + when (old.device_id is not null) + execute function cascade_session_delete(); + +create table verify_codes ( + user_id user_id not null primary key references users on delete cascade, + session_id session_id references user_session on delete set null, + code text not null, + expires_at timestamp with time zone default (now() + '5 minutes'::interval) +); + +create table device_commands ( + index bigserial not null, + device_id device_id not null references device on delete cascade, + command text not null check (length(command) <= 2048), + payload json not null check (length(command) <= 16 * 1024), + expires timestamp with time zone not null, + sender varchar(32), -- hex(sender device_id) + primary key (index) +); + +create index device_command__device_id__idx on device_commands (device_id); +create index device_command__expires__idx on device_commands (expires); + +create table key_fetch ( + id key_fetch_id not null primary key, + hmac_key hawk_key not null, + keys bytea not null, + expires_at timestamp with time zone not null default (now() + '5 minutes'::interval) +); + +create index key_fetch__expires_at__idx on key_fetch (expires_at); + +create table oauth_token ( + id oauth_token_id not null primary key, + kind oauth_token_kind not null, + user_id user_id not null references users on delete cascade, + client_id text not null, + scope text not null check (length(scope) < 1024), + created_at timestamp with time zone not null default now(), + + -- refresh tokens can own sessions. refresh tokens are not owned by + -- sessions, otherwise the login procedure would immediately invalidate + -- tokens for fenix. + session_id session_id + references user_session on delete cascade + check (session_id is null or kind = 'refresh'), + + -- access tokens can be owned by refresh tokens or sessions. owned tokens + -- are invalidated with their parent, revoking eg sync tokens for removed + -- devices. + parent_refresh oauth_token_id + references oauth_token on delete cascade + check (parent_refresh is null or kind = 'access'), + parent_session session_id + references user_session on delete cascade + check (parent_session is null or kind = 'access'), + expires_at timestamp with time zone check ((expires_at is not null) = (kind = 'access')) +); + +create index oauth_token__expires_at__idx on oauth_token (expires_at); + +create function cascade_oauth_delete() + returns trigger + language plpgsql + as $$ + begin + delete from user_session where user_session.session_id = old.session_id; + return null; + end + $$; + +create trigger oauth_token__cascade_session + after delete on oauth_token + for each row + when (old.session_id is not null) + execute function cascade_oauth_delete(); + +create function cascade_device_delete() + returns trigger + language plpgsql + as $$ + begin + with sessions as ( + delete from user_session where device_id = old.device_id returning session_id + ) delete from oauth_token where session_id in (select * from sessions); + return null; + end + $$; + +create trigger device__cascade_ + after delete on device + for each row + execute function cascade_device_delete(); + +create table oauth_authorization ( + id oauth_auth_id not null primary key, + client_id text not null, + user_id user_id not null references users on delete cascade, + scope text not null, + access_type oauth_access_type not null, + code_challenge text not null, + keys_jwe text check (length(keys_jwe) < 16 * 1024), + auth_at timestamp with time zone not null, + expires_at timestamp with time zone default (now() + '5 minutes'::interval) +); + +create index oauth_authorization__expires_at__idx on oauth_authorization (expires_at); + +create table password_change_tokens ( + id password_change_id not null primary key, + user_id user_id not null references users on delete cascade, + hmac_key hawk_key not null, + expires_at timestamp with time zone not null default (now() + '5 minutes'::interval), + forgot_code text, + + unique (user_id) +); + +create table account_reset_tokens ( + id account_reset_id not null primary key, + user_id user_id not null references users on delete cascade, + hmac_key hawk_key not null, + expires_at timestamp with time zone not null default (now() + '5 minutes'::interval), + + unique (user_id) +); + +create procedure reset_user_auth( + uid user_id, + salt text, + wwkb secret_key, + verify verify_hash) +language sql +begin atomic + delete from device where user_id = uid; + delete from user_session where user_id = uid; + delete from verify_codes where user_id = uid; + delete from oauth_token where user_id = uid; + delete from oauth_authorization where user_id = uid; + update users set auth_salt = salt, wrapwrap_kb = wwkb, verify_hash = verify where user_id = uid; +end; + +create table invite_codes ( + code text not null primary key, + expires_at timestamp with time zone not null +); + + + +create procedure prune_expired_tokens() +language sql +begin atomic + delete from key_fetch where expires_at <= now(); + delete from oauth_token where expires_at <= now(); + delete from oauth_authorization where expires_at <= now(); + delete from device_commands where expires <= now(); + delete from invite_codes where expires_at <= now(); +end; + +create procedure prune_expired_verify_codes() +language sql +begin atomic + -- verify codes can be removed in two ways: either using them to verify an account, + -- in which case the account can stay, or by timeout, in which case the account must + -- go. triggers can't distinguish those cases. + with old_verify as ( + delete from verify_codes where expires_at <= now() returning user_id + ) + delete from users where user_id in (select user_id from old_verify); + + delete from user_session where not verified and created_at <= now() - '5 minutes'::interval; + delete from password_change_tokens where expires_at <= now(); + delete from account_reset_tokens where expires_at <= now(); +end; diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..82cb340 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +testpaths = tests +markers = invite diff --git a/rustfmt.toml b/rustfmt.toml new file mode 100644 index 0000000..fa0dbda --- /dev/null +++ b/rustfmt.toml @@ -0,0 +1,8 @@ +match_block_trailing_comma = true +use_small_heuristics = "Max" +# all of these are unstable +# blank_lines_upper_bound = 3 +# imports_granularity = "Crate" +# overflow_delimited_expr = true +# group_imports = "StdExternalCrate" +# use_field_init_shorthand = true diff --git a/sqlx-data.json b/sqlx-data.json new file mode 100644 index 0000000..d0d64ea --- /dev/null +++ b/sqlx-data.json @@ -0,0 +1,1801 @@ +{ + "db": "PostgreSQL", + "0775cc960b9cbb1be71c4717f39fc3b1e5ff2f45fa653928bbafec112d5f6809": { + "describe": { + "columns": [ + { + "name": "user_id: UserID", + "ordinal": 0, + "type_info": "Bytea" + }, + { + "name": "session_id: SessionID", + "ordinal": 1, + "type_info": "Bytea" + }, + { + "name": "code", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "email", + "ordinal": 3, + "type_info": "Text" + } + ], + "nullable": [ + false, + true, + false, + false + ], + "parameters": { + "Left": [ + "Bytea" + ] + } + }, + "query": "select user_id as \"user_id: UserID\", session_id as \"session_id: SessionID\", code,\n email\n from verify_codes join users using (user_id)\n where user_id = $1" + }, + "09b7b91867e6b72294de43a604dde5cfaacc1e75eca1b3d15dec6ddf127729e0": { + "describe": { + "columns": [ + { + "name": "?column?", + "ordinal": 0, + "type_info": "Int4" + } + ], + "nullable": [ + null + ], + "parameters": { + "Left": [ + "Bytea" + ] + } + }, + "query": "update user_session\n set verified = true, verify_code = null\n where session_id = $1\n returning 1" + }, + "1a809571b386badb23dde5cec33f167ccdd7f195ab6c154f84dea0e9ca16b116": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Left": [ + { + "Custom": { + "kind": { + "Domain": "Bytea" + }, + "name": "user_id" + } + }, + "Text", + "Text", + { + "Custom": { + "kind": { + "Domain": "Bytea" + }, + "name": "secret_key" + } + }, + { + "Custom": { + "kind": { + "Domain": "Bytea" + }, + "name": "secret_key" + } + }, + { + "Custom": { + "kind": { + "Domain": "Bytea" + }, + "name": "verify_hash" + } + }, + "Text" + ] + } + }, + "query": "insert into users (user_id, auth_salt, email, ka, wrapwrap_kb, verify_hash,\n display_name)\n values ($1, $2, $3, $4, $5, $6, $7)" + }, + "1d661b638cff8f0ab00e7c580db305dcdcc38c2747f4fb6db26ef205749ed59a": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Left": [] + } + }, + "query": "call prune_expired_tokens()" + }, + "1dbd6bcfd8f5b9ba10688a7e6ba6c6b2479c8a6de6dcdd6b77f727de421a9e13": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Left": [] + } + }, + "query": "call prune_expired_verify_codes()" + }, + "20a6d965b2b3e9fe8c403c686695a032fcb1bf8774d92f39fdd867a0964a2e3e": { + "describe": { + "columns": [ + { + "name": "?column?", + "ordinal": 0, + "type_info": "Int4" + } + ], + "nullable": [ + null + ], + "parameters": { + "Left": [ + "Text" + ] + } + }, + "query": "delete from invite_codes where code = $1 and expires_at > now() returning 1" + }, + "250d2e6c4d9184d534cc08c44d97c933e252923a097728a2c9663591cbc9fe34": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Left": [ + { + "Custom": { + "kind": { + "Domain": "Bytea" + }, + "name": "device_id" + } + }, + "Bytea" + ] + } + }, + "query": "update user_session set device_id = $1 where session_id = $2" + }, + "28115effdfc060af711db170ea566e6eaba0a8ed77966443033fe0409c3e4dbd": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Left": [ + { + "Custom": { + "kind": { + "Domain": "Bytea" + }, + "name": "user_id" + } + }, + "Text", + { + "Custom": { + "kind": { + "Domain": "Bytea" + }, + "name": "secret_key" + } + }, + { + "Custom": { + "kind": { + "Domain": "Bytea" + }, + "name": "verify_hash" + } + } + ] + } + }, + "query": "call reset_user_auth($1, $2, $3, $4)" + }, + "2c8a4f47fa20723d24beb958a3bedc0c36b40b211e8c3fea96f6a32d6ddaa684": { + "describe": { + "columns": [ + { + "name": "?column?", + "ordinal": 0, + "type_info": "Int4" + } + ], + "nullable": [ + null + ], + "parameters": { + "Left": [ + "Bytea", + "Bytea" + ] + } + }, + "query": "delete from device where user_id = $1 and device_id = $2 returning 1" + }, + "2ca0ad391bc6433cb9caf4cc7b14ff12ef29f64ca721d50af45d023b693e6919": { + "describe": { + "columns": [ + { + "name": "user_id: UserID", + "ordinal": 0, + "type_info": "Bytea" + }, + { + "name": "session_id: SessionID", + "ordinal": 1, + "type_info": "Bytea" + }, + { + "name": "code", + "ordinal": 2, + "type_info": "Text" + } + ], + "nullable": [ + false, + true, + false + ], + "parameters": { + "Left": [ + "Bytea", + "Text" + ] + } + }, + "query": "delete from verify_codes\n where user_id = $1 and code = $2\n returning user_id as \"user_id: UserID\", session_id as \"session_id: SessionID\",\n code" + }, + "2d99a9a6c5026bf018b999b6813798ea1dba411bae3961248ac552411b6d460a": { + "describe": { + "columns": [ + { + "name": "user_id: UserID", + "ordinal": 0, + "type_info": "Bytea" + }, + { + "name": "client_id", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "scope: ScopeSet", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "parent_refresh: OauthTokenID", + "ordinal": 3, + "type_info": "Bytea" + }, + { + "name": "parent_session: SessionID", + "ordinal": 4, + "type_info": "Bytea" + }, + { + "name": "expires_at!", + "ordinal": 5, + "type_info": "Timestamptz" + } + ], + "nullable": [ + false, + false, + false, + true, + true, + true + ], + "parameters": { + "Left": [ + "Bytea" + ] + } + }, + "query": "select user_id as \"user_id: UserID\", client_id, scope as \"scope: ScopeSet\",\n parent_refresh as \"parent_refresh: OauthTokenID\",\n parent_session as \"parent_session: SessionID\",\n expires_at as \"expires_at!\"\n from oauth_token\n where id = $1 and kind = 'access' and expires_at > now()" + }, + "2fb1e56d01c53f2d2b7c64409da6118018bd76c47108438d6664c5092c9f047e": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Left": [ + "Bytea" + ] + } + }, + "query": "delete from oauth_token where id = $1" + }, + "3346dd1d2775de8fbf9a36a95812aa55904e4d4db9e626cd69e04e896ba0fc5d": { + "describe": { + "columns": [ + { + "name": "index", + "ordinal": 0, + "type_info": "Int8" + }, + { + "name": "command", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "payload", + "ordinal": 2, + "type_info": "Json" + }, + { + "name": "expires", + "ordinal": 3, + "type_info": "Timestamptz" + }, + { + "name": "sender", + "ordinal": 4, + "type_info": "Varchar" + } + ], + "nullable": [ + false, + false, + false, + false, + true + ], + "parameters": { + "Left": [ + "Int8", + "Bytea", + "Bytea", + "Int8" + ] + } + }, + "query": "select index, command, payload, expires, sender\n from device_commands join device using (device_id)\n where index >= $1 and device_id = $2 and user_id = $3\n order by index\n limit $4" + }, + "3a857907a2bf4014a0cd56f4fd7ff50fe41332e9d282a87fa1ee18fccf227cbe": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Left": [ + { + "Custom": { + "kind": { + "Domain": "Bytea" + }, + "name": "oauth_auth_id" + } + }, + { + "Custom": { + "kind": { + "Domain": "Bytea" + }, + "name": "user_id" + } + }, + "Text", + "Text", + { + "Custom": { + "kind": { + "Enum": [ + "online", + "offline" + ] + }, + "name": "oauth_access_type" + } + }, + "Text", + "Text", + "Timestamptz" + ] + } + }, + "query": "insert into oauth_authorization (id, user_id, client_id, scope, access_type,\n code_challenge, keys_jwe, auth_at)\n values ($1, $2, $3, $4, $5, $6, $7, $8)" + }, + "3b1f4bf37ce4c8ccee3c7a39a69053fe36b5c0b4fcbbff9260096a68fa7c68a3": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Left": [ + "Bytea", + "Text", + { + "Custom": { + "kind": { + "Domain": "Bytea" + }, + "name": "secret_key" + } + }, + { + "Custom": { + "kind": { + "Domain": "Bytea" + }, + "name": "verify_hash" + } + } + ] + } + }, + "query": "update users\n set auth_salt = $2, wrapwrap_kb = $3, verify_hash = $4\n where user_id = $1" + }, + "4ae6964de7c75cf6494732a16a7ea5372ebf5c6aefb7acbd4e4e722968967321": { + "describe": { + "columns": [ + { + "name": "user_id: UserID", + "ordinal": 0, + "type_info": "Bytea" + }, + { + "name": "client_id", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "scope: ScopeSet", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "session_id: SessionID", + "ordinal": 3, + "type_info": "Bytea" + } + ], + "nullable": [ + false, + false, + false, + true + ], + "parameters": { + "Left": [ + "Bytea" + ] + } + }, + "query": "select user_id as \"user_id: UserID\", client_id, scope as \"scope: ScopeSet\",\n session_id as \"session_id: SessionID\"\n from oauth_token\n where id = $1 and kind = 'refresh'" + }, + "51b56a1a77411b06b6ebb93f986da2861ad75be55dc20b650eab87ba4fe0a8dd": { + "describe": { + "columns": [ + { + "name": "auth_salt: String", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "email", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "ka: SecretKey", + "ordinal": 2, + "type_info": "Bytea" + }, + { + "name": "wrapwrap_kb: SecretKey", + "ordinal": 3, + "type_info": "Bytea" + }, + { + "name": "verify_hash: VerifyHash", + "ordinal": 4, + "type_info": "Bytea" + }, + { + "name": "display_name", + "ordinal": 5, + "type_info": "Text" + }, + { + "name": "verified", + "ordinal": 6, + "type_info": "Bool" + } + ], + "nullable": [ + false, + false, + false, + false, + false, + true, + false + ], + "parameters": { + "Left": [ + "Bytea" + ] + } + }, + "query": "select auth_salt as \"auth_salt: String\", email,\n ka as \"ka: SecretKey\", wrapwrap_kb as \"wrapwrap_kb: SecretKey\",\n verify_hash as \"verify_hash: VerifyHash\", display_name, verified\n from users\n where user_id = $1" + }, + "6266ac82d35ed2d8f6d17f8c354b0a4c4386254a5078f33eca3b6aa57e18c916": { + "describe": { + "columns": [ + { + "name": "device_id!: DeviceID", + "ordinal": 0, + "type_info": "Bytea" + }, + { + "name": "name!", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "type_!", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "push: DevicePush", + "ordinal": 3, + "type_info": { + "Custom": { + "kind": { + "Composite": [ + [ + "callback", + "Text" + ], + [ + "public_key", + "Text" + ], + [ + "auth_key", + "Text" + ] + ] + }, + "name": "device_push_info" + } + } + }, + { + "name": "available_commands!: DeviceCommands", + "ordinal": 4, + "type_info": { + "Custom": { + "kind": { + "Array": { + "Custom": { + "kind": { + "Composite": [ + [ + "name", + "Text" + ], + [ + "body", + "Text" + ] + ] + }, + "name": "device_command" + } + } + }, + "name": "_device_command" + } + } + }, + { + "name": "push_expired!", + "ordinal": 5, + "type_info": "Bool" + }, + { + "name": "location!", + "ordinal": 6, + "type_info": "Jsonb" + }, + { + "name": "last_active!", + "ordinal": 7, + "type_info": "Timestamptz" + } + ], + "nullable": [ + null, + null, + null, + null, + null, + null, + null, + null + ], + "parameters": { + "Left": [ + { + "Custom": { + "kind": { + "Domain": "Bytea" + }, + "name": "device_id" + } + }, + { + "Custom": { + "kind": { + "Domain": "Bytea" + }, + "name": "user_id" + } + }, + "Text", + "Text", + { + "Custom": { + "kind": { + "Composite": [ + [ + "callback", + "Text" + ], + [ + "public_key", + "Text" + ], + [ + "auth_key", + "Text" + ] + ] + }, + "name": "device_push_info" + } + }, + { + "Custom": { + "kind": { + "Array": { + "Custom": { + "kind": { + "Composite": [ + [ + "name", + "Text" + ], + [ + "body", + "Text" + ] + ] + }, + "name": "device_command" + } + } + }, + "name": "_device_command" + } + }, + "Jsonb" + ] + } + }, + "query": "select device_id as \"device_id!: DeviceID\", name as \"name!\", type as \"type_!\",\n push as \"push: DevicePush\",\n available_commands as \"available_commands!: DeviceCommands\",\n push_expired as \"push_expired!\", location as \"location!\",\n coalesce(last_active, to_timestamp(0)) as \"last_active!\"\n from insert_or_update_device($1, $2, $3, $4, $5, $6, $7) as iud\n left join user_session using (device_id)" + }, + "6490a0bfec6cf91bb4a0c507456be07fbbc7b742c0327cd4f912ef70e9d2394a": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Left": [ + "Text", + "Timestamptz" + ] + } + }, + "query": "insert into invite_codes (code, expires_at) values ($1, $2)" + }, + "67ebf7c646aa2e33dea84cea3c7af4a75b5e89509effd96d671112d4b7473d70": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Left": [ + { + "Custom": { + "kind": { + "Domain": "Bytea" + }, + "name": "user_id" + } + }, + { + "Custom": { + "kind": { + "Domain": "Bytea" + }, + "name": "session_id" + } + }, + "Text" + ] + } + }, + "query": "insert into verify_codes (user_id, session_id, code)\n values ($1, $2, $3)" + }, + "6e51008763091f55a4bc6db5ad719814430d3e910a67e1387ccda35547903a6c": { + "describe": { + "columns": [ + { + "name": "id: AvatarID", + "ordinal": 0, + "type_info": "Bytea" + }, + { + "name": "data", + "ordinal": 1, + "type_info": "Bytea" + }, + { + "name": "content_type", + "ordinal": 2, + "type_info": "Text" + } + ], + "nullable": [ + false, + false, + false + ], + "parameters": { + "Left": [ + "Bytea" + ] + } + }, + "query": "select id as \"id: AvatarID\", data, content_type\n from user_avatars\n where id = $1" + }, + "6e84f90266300a757d50bb602d2b6aa1a9c086246778abf073354942f106aa32": { + "describe": { + "columns": [ + { + "name": "client_id?", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "device_id?: DeviceID", + "ordinal": 1, + "type_info": "Bytea" + }, + { + "name": "session_token_id?: SessionID", + "ordinal": 2, + "type_info": "Bytea" + }, + { + "name": "refresh_token_id?: OauthTokenID", + "ordinal": 3, + "type_info": "Bytea" + }, + { + "name": "device_type?", + "ordinal": 4, + "type_info": "Text" + }, + { + "name": "name?", + "ordinal": 5, + "type_info": "Text" + }, + { + "name": "created_time?", + "ordinal": 6, + "type_info": "Timestamptz" + }, + { + "name": "last_access_time?", + "ordinal": 7, + "type_info": "Timestamptz" + }, + { + "name": "scope?", + "ordinal": 8, + "type_info": "Text" + } + ], + "nullable": [ + false, + false, + false, + false, + false, + false, + null, + true, + false + ], + "parameters": { + "Left": [ + "Bytea" + ] + } + }, + "query": "select\n ot.client_id as \"client_id?\",\n d.device_id as \"device_id?: DeviceID\",\n us.session_id as \"session_token_id?: SessionID\",\n ot.id as \"refresh_token_id?: OauthTokenID\",\n d.type as \"device_type?\",\n d.name as \"name?\",\n coalesce(d.created_at, us.created_at, ot.created_at) as \"created_time?\",\n us.last_active as \"last_access_time?\",\n ot.scope as \"scope?\"\n from device d\n full outer join user_session us on (d.device_id = us.device_id)\n full outer join oauth_token ot on (us.session_id = ot.session_id)\n where\n (ot.kind is null or ot.kind = 'refresh')\n and $1 in (d.user_id, us.user_id, ot.user_id)\n order by d.device_id" + }, + "71274c082aa53ef6d50aedf935ea7ae8fc2b4abb9549083d4e04d0c52907db30": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Left": [ + "Bytea", + "Text" + ] + } + }, + "query": "update users\n set display_name = $2\n where user_id = $1" + }, + "71531a84073d291f64f413f39af5e634eb5c55b1003cd70a81e32990555ce754": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Left": [ + { + "Custom": { + "kind": { + "Domain": "Bytea" + }, + "name": "key_fetch_id" + } + }, + { + "Custom": { + "kind": { + "Domain": "Bytea" + }, + "name": "hawk_key" + } + }, + "Bytea" + ] + } + }, + "query": "insert into key_fetch (id, hmac_key, keys) values ($1, $2, $3)" + }, + "762e8a42405b37ed74696f8f09aab8de82cbb564b21e5126c1ff0faeb7c872a1": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Left": [ + { + "Custom": { + "kind": { + "Domain": "Bytea" + }, + "name": "user_id" + } + }, + { + "Custom": { + "kind": { + "Domain": "Bytea" + }, + "name": "avatar_id" + } + }, + "Bytea", + "Text" + ] + } + }, + "query": "insert into user_avatars (user_id, id, data, content_type)\n values ($1, $2, $3, $4)\n on conflict (user_id) do update set\n id = $2, data = $3, content_type = $4" + }, + "7cb2caaa204ec71a49a4e70426c549e5899f9c2c606055aa217e1349f6774dc5": { + "describe": { + "columns": [ + { + "name": "?column?", + "ordinal": 0, + "type_info": "Int4" + } + ], + "nullable": [ + null + ], + "parameters": { + "Left": [ + "Bytea" + ] + } + }, + "query": "update users set verified = true where user_id = $1 returning 1" + }, + "7d5dde8d94f2e93415c7e6a2e52bb998d7790bd2fb37b9eaf6270331210ebd2b": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Left": [ + "Text" + ] + } + }, + "query": "delete from users where email = lower($1)" + }, + "8234a164578e19107e610122ae2f07c0ac77dda7260b3eba70cc51fd4b17e15e": { + "describe": { + "columns": [ + { + "name": "hmac_key: HawkKey", + "ordinal": 0, + "type_info": "Bytea" + }, + { + "name": "user_id: UserID", + "ordinal": 1, + "type_info": "Bytea" + }, + { + "name": "forgot_code", + "ordinal": 2, + "type_info": "Text" + } + ], + "nullable": [ + false, + false, + true + ], + "parameters": { + "Left": [ + "Bytea", + "Bool" + ] + } + }, + "query": "delete from password_change_tokens\n where id = $1 and expires_at > now() and (forgot_code is not null) = $2\n returning hmac_key as \"hmac_key: HawkKey\", user_id as \"user_id: UserID\",\n forgot_code" + }, + "86eb609c6a307fac53b3bf7d673683b1a69a695eb02a6832cc5efc982abb3fa9": { + "describe": { + "columns": [ + { + "name": "index", + "ordinal": 0, + "type_info": "Int8" + } + ], + "nullable": [ + false + ], + "parameters": { + "Left": [ + { + "Custom": { + "kind": { + "Domain": "Bytea" + }, + "name": "device_id" + } + }, + "Text", + "Json", + "Timestamptz", + "Varchar" + ] + } + }, + "query": "insert into device_commands (device_id, command, payload, expires, sender)\n values ($1, $2, $3, $4, $5)\n returning index" + }, + "8b838aca5cdd674e44fed8f8c4bca06e0534c48d66c1c8a11b0f13ae67c4dc06": { + "describe": { + "columns": [ + { + "name": "device_id: DeviceID", + "ordinal": 0, + "type_info": "Bytea" + }, + { + "name": "name", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "type_", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "push: DevicePush", + "ordinal": 3, + "type_info": { + "Custom": { + "kind": { + "Composite": [ + [ + "callback", + "Text" + ], + [ + "public_key", + "Text" + ], + [ + "auth_key", + "Text" + ] + ] + }, + "name": "device_push_info" + } + } + }, + { + "name": "available_commands: DeviceCommands", + "ordinal": 4, + "type_info": { + "Custom": { + "kind": { + "Array": { + "Custom": { + "kind": { + "Composite": [ + [ + "name", + "Text" + ], + [ + "body", + "Text" + ] + ] + }, + "name": "device_command" + } + } + }, + "name": "_device_command" + } + } + }, + { + "name": "push_expired", + "ordinal": 5, + "type_info": "Bool" + }, + { + "name": "location", + "ordinal": 6, + "type_info": "Jsonb" + }, + { + "name": "last_active!", + "ordinal": 7, + "type_info": "Timestamptz" + } + ], + "nullable": [ + false, + false, + false, + true, + false, + false, + false, + null + ], + "parameters": { + "Left": [ + "Bytea" + ] + } + }, + "query": "select d.device_id as \"device_id: DeviceID\", d.name, d.type as type_,\n d.push as \"push: DevicePush\",\n d.available_commands as \"available_commands: DeviceCommands\",\n d.push_expired, d.location, coalesce(us.last_active, to_timestamp(0)) as \"last_active!\"\n from device d left join user_session us using (device_id)\n where d.user_id = $1" + }, + "8bdcb263a11f3923370a538efcd441b87d6250a8c04f0ff61353dd60e3521111": { + "describe": { + "columns": [ + { + "name": "hmac_key: HawkKey", + "ordinal": 0, + "type_info": "Bytea" + }, + { + "name": "keys", + "ordinal": 1, + "type_info": "Bytea" + } + ], + "nullable": [ + false, + false + ], + "parameters": { + "Left": [ + "Bytea" + ] + } + }, + "query": "delete from key_fetch\n where id = $1 and expires_at > now()\n returning hmac_key as \"hmac_key: HawkKey\", keys" + }, + "920895da4fe20ddbb0ffaaa49bc879c28a7bc5ba4bc65331632c7ba5ea1ba1f6": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Left": [ + "Bytea" + ] + } + }, + "query": "update device\n set push_expired = true\n where device_id = $1" + }, + "926453dabc0466c763e22605c9845b79e1e755a395cb0dbc679cab7fc7b52c02": { + "describe": { + "columns": [ + { + "name": "?column?", + "ordinal": 0, + "type_info": "Int4" + } + ], + "nullable": [ + null + ], + "parameters": { + "Left": [ + "Bytea", + "Bytea" + ] + } + }, + "query": "delete from user_session\n where user_id = $1 and session_id = $2\n returning 1" + }, + "9633dc7be0139f99b6ed0ab472620825ffe327aa2e6a81f3151c664f1f5fcacf": { + "describe": { + "columns": [ + { + "name": "uid: UserID", + "ordinal": 0, + "type_info": "Bytea" + }, + { + "name": "req_hmac_key: HawkKey", + "ordinal": 1, + "type_info": "Bytea" + }, + { + "name": "device_id: DeviceID", + "ordinal": 2, + "type_info": "Bytea" + }, + { + "name": "session_id: SessionID", + "ordinal": 3, + "type_info": "Bytea" + }, + { + "name": "created_at", + "ordinal": 4, + "type_info": "Timestamptz" + }, + { + "name": "verified", + "ordinal": 5, + "type_info": "Bool" + }, + { + "name": "verify_code", + "ordinal": 6, + "type_info": "Text" + } + ], + "nullable": [ + false, + false, + true, + false, + false, + false, + true + ], + "parameters": { + "Left": [ + "Bytea" + ] + } + }, + "query": "update user_session\n set last_active = now()\n where session_id = (\n select session_id from oauth_token where kind = 'refresh' and id = $1\n )\n returning user_id as \"uid: UserID\", req_hmac_key as \"req_hmac_key: HawkKey\",\n device_id as \"device_id: DeviceID\", session_id as \"session_id: SessionID\",\n created_at, verified, verify_code" + }, + "9842eb82258490f84375f203e6bfb34127e6389b8f82c18a7be80eb20ed87cf3": { + "describe": { + "columns": [ + { + "name": "id: AvatarID", + "ordinal": 0, + "type_info": "Bytea" + } + ], + "nullable": [ + false + ], + "parameters": { + "Left": [ + "Bytea" + ] + } + }, + "query": "select id as \"id: AvatarID\" from user_avatars where user_id = $1" + }, + "a68e2238885dba26b94ddc0534c84dd3a992c338f58ab33ef31cac5c8538dfd3": { + "describe": { + "columns": [ + { + "name": "user_id: UserID", + "ordinal": 0, + "type_info": "Bytea" + }, + { + "name": "client_id", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "scope: ScopeSet", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "access_type: OauthAccessType", + "ordinal": 3, + "type_info": { + "Custom": { + "kind": { + "Enum": [ + "online", + "offline" + ] + }, + "name": "oauth_access_type" + } + } + }, + { + "name": "code_challenge", + "ordinal": 4, + "type_info": "Text" + }, + { + "name": "keys_jwe", + "ordinal": 5, + "type_info": "Text" + }, + { + "name": "auth_at", + "ordinal": 6, + "type_info": "Timestamptz" + } + ], + "nullable": [ + false, + false, + false, + false, + false, + true, + false + ], + "parameters": { + "Left": [ + "Bytea" + ] + } + }, + "query": "delete from oauth_authorization\n where id = $1 and expires_at > now()\n returning user_id as \"user_id: UserID\", client_id, scope as \"scope: ScopeSet\",\n access_type as \"access_type: OauthAccessType\",\n code_challenge, keys_jwe, auth_at" + }, + "ad00d5caef2f8078846c751bec410e0e2a2083f9de1ab7ede6505eca754fd145": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Left": [ + "Bytea" + ] + } + }, + "query": "delete from oauth_token where id = $1 and kind = 'refresh'" + }, + "b272e9b3f4f75818993bdb73301eb117422870ec28c36e9174eab7963dcc037b": { + "describe": { + "columns": [ + { + "name": "device_id: DeviceID", + "ordinal": 0, + "type_info": "Bytea" + }, + { + "name": "name", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "type_", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "push: DevicePush", + "ordinal": 3, + "type_info": { + "Custom": { + "kind": { + "Composite": [ + [ + "callback", + "Text" + ], + [ + "public_key", + "Text" + ], + [ + "auth_key", + "Text" + ] + ] + }, + "name": "device_push_info" + } + } + }, + { + "name": "available_commands: DeviceCommands", + "ordinal": 4, + "type_info": { + "Custom": { + "kind": { + "Array": { + "Custom": { + "kind": { + "Composite": [ + [ + "name", + "Text" + ], + [ + "body", + "Text" + ] + ] + }, + "name": "device_command" + } + } + }, + "name": "_device_command" + } + } + }, + { + "name": "push_expired", + "ordinal": 5, + "type_info": "Bool" + }, + { + "name": "location", + "ordinal": 6, + "type_info": "Jsonb" + }, + { + "name": "last_active!", + "ordinal": 7, + "type_info": "Timestamptz" + } + ], + "nullable": [ + false, + false, + false, + true, + false, + false, + false, + null + ], + "parameters": { + "Left": [ + "Bytea", + "Bytea" + ] + } + }, + "query": "select d.device_id as \"device_id: DeviceID\", d.name, d.type as type_,\n d.push as \"push: DevicePush\",\n d.available_commands as \"available_commands: DeviceCommands\",\n d.push_expired, d.location, coalesce(us.last_active, to_timestamp(0)) as \"last_active!\"\n from device d left join user_session us using (device_id)\n where d.user_id = $1 and d.device_id = $2" + }, + "b77775fe6421918088a2ef58b1fef5fd49341b0a614a083c9d0149939a515448": { + "describe": { + "columns": [ + { + "name": "?column?", + "ordinal": 0, + "type_info": "Int4" + } + ], + "nullable": [ + null + ], + "parameters": { + "Left": [ + "Text" + ] + } + }, + "query": "select 1 from users where email = lower($1)" + }, + "bac51521196d267e983f2118939d59a3f992b1180ddeba257eab5fc3cfc4382b": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Left": [ + { + "Custom": { + "kind": { + "Domain": "Bytea" + }, + "name": "password_change_id" + } + }, + { + "Custom": { + "kind": { + "Domain": "Bytea" + }, + "name": "user_id" + } + }, + { + "Custom": { + "kind": { + "Domain": "Bytea" + }, + "name": "hawk_key" + } + }, + "Text" + ] + } + }, + "query": "insert into password_change_tokens (id, user_id, hmac_key, forgot_code)\n values ($1, $2, $3, $4)\n on conflict (user_id) do update set id = $1, hmac_key = $3, forgot_code = $4,\n expires_at = default" + }, + "c032ea41fcf2c4ac42c0fa14f1ef88a8589306888b8853b1c3aa3195b1db2f24": { + "describe": { + "columns": [ + { + "name": "id: UserID", + "ordinal": 0, + "type_info": "Bytea" + }, + { + "name": "auth_salt: String", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "email", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "ka: SecretKey", + "ordinal": 3, + "type_info": "Bytea" + }, + { + "name": "wrapwrap_kb: SecretKey", + "ordinal": 4, + "type_info": "Bytea" + }, + { + "name": "verify_hash: VerifyHash", + "ordinal": 5, + "type_info": "Bytea" + }, + { + "name": "display_name", + "ordinal": 6, + "type_info": "Text" + }, + { + "name": "verified", + "ordinal": 7, + "type_info": "Bool" + } + ], + "nullable": [ + false, + false, + false, + false, + false, + false, + true, + false + ], + "parameters": { + "Left": [ + "Text" + ] + } + }, + "query": "select user_id as \"id: UserID\", auth_salt as \"auth_salt: String\", email,\n ka as \"ka: SecretKey\", wrapwrap_kb as \"wrapwrap_kb: SecretKey\",\n verify_hash as \"verify_hash: VerifyHash\", display_name, verified\n from users\n where email = lower($1)" + }, + "cd0c9015a7736e16fc571db03f333c7e0e57e1ea4c4d5f7eebf8eadb5e2c9b3e": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Left": [ + { + "Custom": { + "kind": { + "Domain": "Bytea" + }, + "name": "oauth_token_id" + } + }, + { + "Custom": { + "kind": { + "Domain": "Bytea" + }, + "name": "user_id" + } + }, + "Text", + "Text", + { + "Custom": { + "kind": { + "Domain": "Bytea" + }, + "name": "oauth_token_id" + } + }, + { + "Custom": { + "kind": { + "Domain": "Bytea" + }, + "name": "session_id" + } + }, + "Timestamptz" + ] + } + }, + "query": "insert into oauth_token (id, kind, user_id, client_id, scope, session_id,\n parent_refresh, parent_session, expires_at)\n values ($1, 'access', $2, $3, $4, null, $5, $6, $7)" + }, + "d159085f88600222a47bb0ba50b07bcbb4076076c460a1880b13ed44fbcbeac1": { + "describe": { + "columns": [ + { + "name": "uid: UserID", + "ordinal": 0, + "type_info": "Bytea" + }, + { + "name": "req_hmac_key: HawkKey", + "ordinal": 1, + "type_info": "Bytea" + }, + { + "name": "device_id: DeviceID", + "ordinal": 2, + "type_info": "Bytea" + }, + { + "name": "created_at", + "ordinal": 3, + "type_info": "Timestamptz" + }, + { + "name": "verified", + "ordinal": 4, + "type_info": "Bool" + }, + { + "name": "verify_code", + "ordinal": 5, + "type_info": "Text" + } + ], + "nullable": [ + false, + false, + true, + false, + false, + true + ], + "parameters": { + "Left": [ + "Bytea" + ] + } + }, + "query": "update user_session\n set last_active = now()\n where session_id = $1\n returning user_id as \"uid: UserID\", req_hmac_key as \"req_hmac_key: HawkKey\",\n device_id as \"device_id: DeviceID\", created_at, verified, verify_code" + }, + "e70f7ba02ef7aa6caf9dc1c9ac6bf0211fc170d4ed2298d6954501360f371743": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Left": [ + { + "Custom": { + "kind": { + "Domain": "Bytea" + }, + "name": "account_reset_id" + } + }, + { + "Custom": { + "kind": { + "Domain": "Bytea" + }, + "name": "user_id" + } + }, + { + "Custom": { + "kind": { + "Domain": "Bytea" + }, + "name": "hawk_key" + } + } + ] + } + }, + "query": "insert into account_reset_tokens (id, user_id, hmac_key)\n values ($1, $2, $3)\n on conflict (user_id) do update set id = $1, hmac_key = $3, expires_at = default" + }, + "eb7efdefa66929158d12c37e4105e9596f922abf477d27ff7d9807e572dff81b": { + "describe": { + "columns": [ + { + "name": "created_at", + "ordinal": 0, + "type_info": "Timestamptz" + } + ], + "nullable": [ + false + ], + "parameters": { + "Left": [ + { + "Custom": { + "kind": { + "Domain": "Bytea" + }, + "name": "session_id" + } + }, + { + "Custom": { + "kind": { + "Domain": "Bytea" + }, + "name": "user_id" + } + }, + { + "Custom": { + "kind": { + "Domain": "Bytea" + }, + "name": "hawk_key" + } + }, + "Bool", + "Text" + ] + } + }, + "query": "insert into user_session (session_id, user_id, req_hmac_key, device_id, verified,\n verify_code)\n values ($1, $2, $3, null, $4, $5)\n returning created_at" + }, + "ed1bbab2b586333c18c012b4fe7b7a75cf949795f8e19ca90b4869486b28f988": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Left": [ + "Bytea", + "Bytea" + ] + } + }, + "query": "delete from user_avatars where user_id = $1 and id = $2" + }, + "f27cbc16ac657f8b8e60df8e9564638a9fef007f7ef01b6d4f6dc1a7fe7a34b3": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Left": [ + { + "Custom": { + "kind": { + "Domain": "Bytea" + }, + "name": "oauth_token_id" + } + }, + { + "Custom": { + "kind": { + "Domain": "Bytea" + }, + "name": "user_id" + } + }, + "Text", + "Text", + { + "Custom": { + "kind": { + "Domain": "Bytea" + }, + "name": "session_id" + } + } + ] + } + }, + "query": "insert into oauth_token (id, kind, user_id, client_id, scope, session_id)\n values ($1, 'refresh', $2, $3, $4, $5)" + }, + "ff68e826c4762e58595b4c90180503ee38f254f848fd50d0eb97221bb50e23a0": { + "describe": { + "columns": [ + { + "name": "hmac_key: HawkKey", + "ordinal": 0, + "type_info": "Bytea" + }, + { + "name": "user_id: UserID", + "ordinal": 1, + "type_info": "Bytea" + } + ], + "nullable": [ + false, + false + ], + "parameters": { + "Left": [ + "Bytea" + ] + } + }, + "query": "delete from account_reset_tokens\n where id = $1 and expires_at > now()\n returning hmac_key as \"hmac_key: HawkKey\", user_id as \"user_id: UserID\"" + } +} \ No newline at end of file diff --git a/src/api/auth/account.rs b/src/api/auth/account.rs new file mode 100644 index 0000000..51dd98e --- /dev/null +++ b/src/api/auth/account.rs @@ -0,0 +1,413 @@ +use std::sync::Arc; + +use anyhow::Result; +use chrono::{DateTime, Utc}; +use password_hash::SaltString; +use rand::{thread_rng, Rng}; +use rocket::request::FromRequest; +use rocket::State; +use rocket::{serde::json::Json, Request}; +use serde::{Deserialize, Serialize}; +use validator::Validate; + +use crate::api::{Empty, EMPTY}; +use crate::db::{Db, DbConn}; +use crate::mailer::Mailer; +use crate::push::PushClient; +use crate::types::AccountResetID; +use crate::utils::DeferAction; +use crate::Config; +use crate::{ + api::{auth, serialize_dt}, + auth::{AuthSource, Authenticated}, + crypto::{AuthPW, KeyBundle, KeyFetchReq, SecretBytes, SessionCredentials}, + types::{HawkKey, KeyFetchID, OauthToken, SecretKey, SessionID, User, UserID, VerifyHash}, +}; + +// TODO better error handling + +// MISSING get /account/profile +// MISSING get /account/status +// MISSING post /account/status +// MISSING post /account/reset + +#[derive(Deserialize, Debug, Validate)] +#[serde(deny_unknown_fields)] +#[allow(non_snake_case)] +pub(crate) struct Create { + #[validate(email, length(min = 3, max = 256))] + email: String, + authPW: AuthPW, + // MISSING service + // MISSING redirectTo + // MISSING resume + // MISSING metricsContext + // NOTE we misuse style to communicate an invite token! + style: Option, + // MISSING verificationMethod +} + +#[derive(Serialize, Debug)] +#[allow(non_snake_case)] +#[serde(deny_unknown_fields)] +pub(crate) struct CreateResp { + uid: UserID, + sessionToken: SecretBytes<32>, + #[serde(skip_serializing_if = "Option::is_none")] + keyFetchToken: Option>, + #[serde(serialize_with = "serialize_dt")] + authAt: DateTime, + // MISSING verificationMethod +} + +// MISSING arg: service +#[post("/account/create?", data = "")] +pub(crate) async fn create( + db: &DbConn, + cfg: &State, + mailer: &State>, + keys: Option, + data: Json, +) -> auth::Result { + let keys = keys.unwrap_or(false); + let data = data.into_inner(); + data.validate().map_err(|_| auth::Error::InvalidParameter)?; + + if db.user_email_exists(&data.email).await? { + return Err(auth::Error::AccountExists); + } + + match (cfg.invite_only, data.style) { + (false, Some(_)) => return Err(auth::Error::InvalidParameter), + (false, None) => (), + (true, None) => return Err(auth::Error::InviteOnly), + (true, Some(code)) => { + db.use_invite_code(&code).await.map_err(|e| match e { + sqlx::Error::RowNotFound => auth::Error::InviteNotFound, + e => auth::Error::Other(anyhow!(e)), + })?; + }, + } + + let ka = SecretBytes::generate(); + let wrapwrap_kb = SecretBytes::generate(); + let auth_salt = SaltString::generate(rand::rngs::OsRng); + let stretched = data.authPW.stretch(auth_salt.as_salt())?; + let verify_hash = stretched.verify_hash(); + let session_token = SecretBytes::generate(); + let session = SessionCredentials::derive(&session_token); + let key_fetch_token = if keys { + let key_fetch_token = SecretBytes::generate(); + let req = KeyFetchReq::from_token(&key_fetch_token); + let wrapped = req.derive_resp().wrap_keys(&KeyBundle { + ka: ka.clone(), + wrap_kb: stretched.decrypt_wwkb(&wrapwrap_kb), + }); + db.add_key_fetch(KeyFetchID(req.token_id.0), &HawkKey(req.req_hmac_key), &wrapped).await?; + Some(key_fetch_token) + } else { + None + }; + let uid = db + .add_user(User { + auth_salt, + email: data.email.to_owned(), + ka: SecretKey(ka), + wrapwrap_kb: SecretKey(wrapwrap_kb), + verify_hash: VerifyHash(verify_hash), + display_name: None, + verified: false, + }) + .await?; + let session_id = SessionID(session.token_id.0); + let auth_at = db + .add_session(session_id.clone(), &uid, HawkKey(session.req_hmac_key), false, None) + .await?; + let verify_code = hex::encode(&SecretBytes::<16>::generate().0); + db.add_verify_code(&uid, &session_id, &verify_code).await?; + // NOTE we send the email in this context rather than a spawn to signal + // send errors to the client. + mailer.send_account_verify(&uid, &data.email, &verify_code).await.map_err(|e| { + error!("failed to send email: {e}"); + auth::Error::EmailFailed + })?; + Ok(Json(CreateResp { + uid, + sessionToken: session_token, + keyFetchToken: key_fetch_token, + authAt: auth_at, + })) +} + +#[derive(Deserialize, Debug, Validate)] +#[serde(deny_unknown_fields)] +#[allow(non_snake_case)] +pub(crate) struct Login { + #[validate(email, length(min = 3, max = 256))] + email: String, + authPW: AuthPW, + // MISSING service + // MISSING redirectTo + // MISSING resume + // MISSING reason + // MISSING unblockCode + // MISSING originalLoginEmail + // MISSING verificationMethod +} + +#[derive(Serialize, Debug)] +#[allow(non_snake_case)] +#[serde(deny_unknown_fields)] +pub(crate) struct LoginResp { + uid: UserID, + sessionToken: SecretBytes<32>, + #[serde(skip_serializing_if = "Option::is_none")] + keyFetchToken: Option>, + // MISSING verificationMethod + // MISSING verificationReason + // NOTE this is the *account* verified status, not the session status. + // the spec doesn't say. + verified: bool, + #[serde(serialize_with = "serialize_dt")] + authAt: DateTime, + // MISSING metricsEnabled +} + +// MISSING arg: service +// MISSING arg: verificationMethod +#[post("/account/login?", data = "")] +pub(crate) async fn login( + db: &DbConn, + mailer: &State>, + keys: Option, + data: Json, +) -> auth::Result { + let keys = keys.unwrap_or(false); + let data = data.into_inner(); + data.validate().map_err(|_| auth::Error::InvalidParameter)?; + + let (uid, user) = db.get_user(&data.email).await.map_err(|_| auth::Error::UnknownAccount)?; + if user.email != data.email { + return Err(auth::Error::IncorrectEmailCase); + } + if !user.verified { + return Err(auth::Error::UnverifiedAccount); + } + + let stretched = data.authPW.stretch(user.auth_salt.as_salt())?; + if stretched.verify_hash() != user.verify_hash.0 { + return Err(auth::Error::IncorrectPassword); + } + + let session_token = SecretBytes::generate(); + let session = SessionCredentials::derive(&session_token); + let key_fetch_token = if keys { + let key_fetch_token = SecretBytes::generate(); + let req = KeyFetchReq::from_token(&key_fetch_token); + let wrapped = req.derive_resp().wrap_keys(&KeyBundle { + ka: user.ka.0.clone(), + wrap_kb: stretched.decrypt_wwkb(&user.wrapwrap_kb.0), + }); + db.add_key_fetch(KeyFetchID(req.token_id.0), &HawkKey(req.req_hmac_key), &wrapped).await?; + Some(key_fetch_token) + } else { + None + }; + + let session_id = SessionID(session.token_id.0); + let verify_code = format!("{:06}", thread_rng().gen_range(0..=999999)); + let auth_at = db + .add_session( + session_id.clone(), + &uid, + HawkKey(session.req_hmac_key), + false, + Some(&verify_code), + ) + .await?; + // NOTE we send the email in this context rather than a spawn to signal + // send errors to the client. + mailer.send_session_verify(&data.email, &verify_code).await.map_err(|e| { + error!("failed to send email: {e}"); + auth::Error::EmailFailed + })?; + Ok(Json(LoginResp { + uid, + sessionToken: session_token, + keyFetchToken: key_fetch_token, + verified: true, + authAt: auth_at, + })) +} + +#[derive(Deserialize, Debug, Validate)] +#[serde(deny_unknown_fields)] +#[allow(non_snake_case)] +pub(crate) struct Destroy { + #[validate(email, length(min = 3, max = 256))] + email: String, + authPW: AuthPW, +} + +// TODO may also be authenticated with a verified session +#[post("/account/destroy", data = "")] +pub(crate) async fn destroy( + db: &DbConn, + db_pool: &Db, + defer: &DeferAction, + pc: &State>, + data: Json, +) -> auth::Result { + let data = data.into_inner(); + data.validate().map_err(|_| auth::Error::InvalidParameter)?; + + let (uid, user) = db.get_user(&data.email).await.map_err(|_| auth::Error::UnknownAccount)?; + if user.email != data.email { + return Err(auth::Error::IncorrectEmailCase); + } + + let stretched = data.authPW.stretch(user.auth_salt.as_salt())?; + if stretched.verify_hash() != user.verify_hash.0 { + return Err(auth::Error::IncorrectPassword); + } + + let devs = db.get_devices(&uid).await; + db.delete_user(&data.email).await?; + match devs { + Ok(devs) => defer.spawn_after_success("api::account/destroy(post)", { + let (pc, db) = (Arc::clone(pc), db_pool.clone()); + async move { + let db = db.begin().await?; + pc.account_destroyed(&devs, &uid).await; + db.commit().await?; + Ok(()) + } + }), + Err(e) => warn!("account_destroyed push failed: {e}"), + } + + Ok(EMPTY) +} + +#[derive(Deserialize, Serialize, Debug)] +#[serde(deny_unknown_fields)] +pub(crate) struct KeysResp { + bundle: String, +} + +// NOTE the key fetch endpoint must delete a key fetch token from the database +// once it has identified it, regardless of whether the request succeeds or +// fails. we'll do this with a single-use auth source that sets the db to always +// commit. the auth source must not be used for anything else. we can get away +// with using a request guard because we'll always commit even if the guard +// fails, but this is only allowable because this is the only handler for the path. + +#[derive(Debug)] +pub(crate) struct WithKeyFetch; + +#[async_trait] +impl AuthSource for WithKeyFetch { + type ID = KeyFetchID; + type Context = Vec; + async fn hawk(r: &Request<'_>, id: &KeyFetchID) -> Result<(SecretBytes<32>, Self::Context)> { + let db = Authenticated::<(), Self>::get_conn(r).await?; + db.always_commit().await?; + Ok(db.finish_key_fetch(id).await.map(|(h, ks)| (h.0, ks))?) + } + async fn bearer_token(_: &Request<'_>, _: &OauthToken) -> Result<(KeyFetchID, Self::Context)> { + // key fetch tokens are only valid in hawk requests + bail!("invalid key fetch authentication") + } +} + +#[get("/account/keys")] +pub(crate) async fn keys(auth: Authenticated<(), WithKeyFetch>) -> Json { + // NOTE contrary to its own api spec fxa does not delete a key fetch if the + // associated session is not verified. we don't duplicate this special case + // because we control the clients, and requesting keys on an unverified session + // can be interpreted as a protocol violation anyway. + Json(KeysResp { bundle: hex::encode(&auth.context) }) +} + +#[derive(Debug)] +pub(crate) struct WithResetToken; + +#[async_trait] +impl AuthSource for WithResetToken { + type ID = AccountResetID; + type Context = UserID; + async fn hawk( + r: &Request<'_>, + id: &AccountResetID, + ) -> Result<(SecretBytes<32>, Self::Context)> { + // unlike key fetch we'll use a separate transaction here since the body of the + // handler can fail. + let pool = <&Db as FromRequest>::from_request(r) + .await + .success_or_else(|| anyhow!("could not open db connection"))?; + let db = pool.begin().await?; + let result = db.finish_account_reset(id).await.map(|(h, ctx)| (h.0, ctx))?; + db.commit().await?; + Ok(result) + } + async fn bearer_token( + _: &Request<'_>, + _: &OauthToken, + ) -> Result<(AccountResetID, Self::Context)> { + bail!("invalid password change authentication") + } +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +#[allow(non_snake_case)] +pub(crate) struct AccountResetReq { + authPW: AuthPW, + // MISSING wrapKb + // MISSING recoveryKeyId + // MISSING sessionToken +} + +// NOTE resetting an account does not clear active sync data on the storage server, +// so an account may be reported as disconnected for a while. this is not an error, +// just an inconvenience we haven't found out how to fix yet. + +// MISSING arg: keys +#[post("/account/reset", data = "")] +pub(crate) async fn reset( + db: &DbConn, + mailer: &State>, + client: &State>, + defer: &DeferAction, + data: Authenticated, +) -> auth::Result { + let user = db.get_user_by_id(&data.context).await?; + + let notify_devs = db.get_devices(&data.context).await?; + + let wrapwrap_kb = SecretBytes::generate(); + let auth_salt = SaltString::generate(rand::rngs::OsRng); + let stretched = data.body.authPW.stretch(auth_salt.as_salt())?; + let verify_hash = stretched.verify_hash(); + + db.reset_user_auth(&data.context, auth_salt, SecretKey(wrapwrap_kb), VerifyHash(verify_hash)) + .await?; + + defer.spawn_after_success("api::auth/account/reset(post)", { + let client = Arc::clone(client); + async move { + client.password_reset(¬ify_devs).await; + Ok(()) + } + }); + + mailer + .send_account_reset(&user.email) + .await + .map_err(|e| { + warn!("account reset email send failed: {e}"); + }) + .ok(); + + Ok(EMPTY) +} diff --git a/src/api/auth/device.rs b/src/api/auth/device.rs new file mode 100644 index 0000000..2b05e12 --- /dev/null +++ b/src/api/auth/device.rs @@ -0,0 +1,455 @@ +use std::time::Duration; +use std::{collections::HashMap, sync::Arc}; + +use chrono::{DateTime, Utc}; +use futures::future::join_all; +use rocket::{serde::json::Json, State}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use crate::api::auth::{WithSession, WithVerifiedFxaLogin, WithVerifiedSession}; +use crate::api::{Empty, EMPTY}; +use crate::db::DbConn; +use crate::push::PushClient; +use crate::utils::DeferAction; +use crate::{ + api::{auth, serialize_dt_opt}, + auth::Authenticated, + db::Db, + types::{ + Device, DeviceCommand, DeviceCommands, DeviceID, DevicePush, DeviceUpdate, OauthTokenID, + SessionID, + }, +}; + +fn map_error(e: sqlx::Error) -> auth::Error { + match &e { + // not-null violations can presumably only be caused by bad parameters + sqlx::Error::Database(de) if de.code().as_deref() == Some("23502") => { + auth::Error::MissingParameter + }, + sqlx::Error::RowNotFound => auth::Error::UnknownDevice, + _ => auth::Error::Other(anyhow!(e)), + } +} + +#[derive(Debug, Serialize, Deserialize, PartialEq)] +#[allow(non_snake_case)] +#[serde(deny_unknown_fields)] +pub(crate) struct Info { + isCurrentDevice: bool, + id: DeviceID, + lastAccessTime: i64, + name: String, + r#type: String, + pushCallback: Option, + pushPublicKey: Option, + pushAuthKey: Option, + pushEndpointExpired: bool, + availableCommands: HashMap, + // NOTE location is optional per the spec, but fenix crashes if it isn't present + location: Value, + // MISSING lastAccessTimeFormatted + // MISSING approximateLastAccessTime + // MISSING approximateLastAccessTimeFormatted +} + +fn device_to_json(current: Option<&DeviceID>, dev: Device) -> Info { + let (pcb, ppk, pak) = match dev.push { + Some(p) => (Some(p.callback), Some(p.public_key), Some(p.auth_key)), + None => (None, None, None), + }; + Info { + isCurrentDevice: Some(&dev.device_id) == current, + id: dev.device_id, + lastAccessTime: dev.last_active.timestamp(), + name: dev.name, + r#type: dev.type_, + pushCallback: pcb, + pushPublicKey: ppk, + pushAuthKey: pak, + pushEndpointExpired: dev.push_expired, + availableCommands: dev.available_commands.into_map(), + location: dev.location, + } +} + +#[derive(Serialize, Deserialize, PartialEq)] +#[serde(transparent)] +pub(crate) struct ListResp(Vec); + +#[get("/account/devices")] +pub(crate) async fn devices( + db: &DbConn, + auth: Authenticated<(), WithVerifiedSession>, +) -> auth::Result { + let devs = db.get_devices(&auth.context.uid).await?; + Ok(Json(ListResp( + devs.into_iter().map(|dev| device_to_json(auth.context.device_id.as_ref(), dev)).collect(), + ))) +} + +#[derive(Debug, Deserialize)] +#[allow(non_snake_case)] +#[serde(deny_unknown_fields)] +pub(crate) struct DeviceReq { + id: Option, + name: Option, + r#type: Option, + pushCallback: Option, + pushPublicKey: Option, + pushAuthKey: Option, + availableCommands: Option>, + // present for legacy reasons, ignored + #[allow(dead_code)] + capabilities: Option>, + location: Option, +} + +#[post("/account/device", data = "")] +pub(crate) async fn device( + db: &DbConn, + db_pool: &Db, + defer: &DeferAction, + client: &State>, + // need to allow registrations to all sessions, otherwise the "now verified" + // notification can't be sent + data: Authenticated, +) -> auth::Result { + let dev = data.body; + if let (None, None, None) = (&dev.name, &dev.r#type, &dev.pushCallback) { + return Err(auth::Error::MissingParameter); + } + + let push = dev.pushCallback.map(|pcb| DevicePush { + callback: pcb, + public_key: dev.pushPublicKey.unwrap_or_default(), + auth_key: dev.pushAuthKey.unwrap_or_default(), + }); + + let (own_id, changed_id, notify) = match (dev.id, data.context.device_id) { + (None, None) => { + let new = DeviceID::random(); + (Some(new.clone()), new, true) + }, + (None, Some(own)) => (Some(own.clone()), own, false), + (Some(other), own) => (own, other, false), + }; + let result = db + .change_device( + &data.context.uid, + &changed_id, + DeviceUpdate { + name: dev.name.as_ref().map(AsRef::as_ref), + type_: dev.r#type.as_ref().map(AsRef::as_ref), + push, + available_commands: dev.availableCommands.map(DeviceCommands), + location: dev.location, + }, + ) + .await + .map_err(map_error)?; + if notify { + db.set_session_device(&data.session, Some(&changed_id)).await?; + match db.get_devices(&data.context.uid).await { + Err(e) => warn!("device_connected push failed: {e}"), + Ok(mut devs) => defer.spawn_after_success("api::auth/account/device(post)", { + devs.retain(|d| d.device_id != changed_id); + let (client, db) = (Arc::clone(client), db_pool.clone()); + let name = result.name.clone(); + async move { + let db = db.begin().await?; + client.device_connected(&db, &devs, &name).await; + db.commit().await?; + Ok(()) + } + }), + }; + } + Ok(Json(device_to_json(own_id.as_ref(), result))) +} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct Command { + target: DeviceID, + command: String, + payload: Value, + ttl: Option, +} + +#[derive(Debug, Deserialize, Serialize)] +#[allow(non_snake_case)] +#[serde(deny_unknown_fields)] +pub(crate) struct InvokeResp { + enqueued: bool, + notified: bool, + notifyError: Option, +} + +// NOTE fenix doesn't register a push callback for some reason, so receiving tabs +// always requires opening the tab share menu or tab list first. +#[post("/account/devices/invoke_command", data = "")] +pub(crate) async fn invoke( + client: &State>, + db: &DbConn, + cmd: Authenticated, +) -> auth::Result { + let sender = cmd.context.device_id; + let dev = db.get_device(&cmd.context.uid, &cmd.body.target).await.map_err(map_error)?; + if dev.available_commands.get(&cmd.body.command).is_none() { + return Err(auth::Error::NoDeviceCommand); + } + let ttl = cmd.body.ttl.unwrap_or(30 * 86400).clamp(60, 30 * 86400); + let idx = db + .enqueue_command(&cmd.body.target, &sender, &cmd.body.command, &cmd.body.payload, ttl) + .await?; + let (notified, error) = client + .command_received(db, &dev, &cmd.body.command, idx, &sender) + .await + .map_or_else(|e| (false, Some(e.to_string())), |_| (true, None)); + Ok(Json(InvokeResp { enqueued: true, notified, notifyError: error })) +} + +#[derive(Debug, Serialize, Deserialize, PartialEq)] +#[serde(deny_unknown_fields)] +pub(crate) struct CommandData { + command: String, + payload: Value, + sender: Option, +} + +#[derive(Debug, Serialize, Deserialize, PartialEq)] +#[serde(deny_unknown_fields)] +pub(crate) struct CommandsEntry { + index: i64, + data: CommandData, +} + +#[derive(Debug, Serialize, Deserialize, PartialEq)] +#[serde(deny_unknown_fields)] +pub(crate) struct CommandsResp { + index: i64, + last: bool, + messages: Vec, +} + +fn map_command(c: DeviceCommand) -> CommandsEntry { + CommandsEntry { + index: c.index, + data: CommandData { command: c.command, payload: c.payload, sender: c.sender }, + } +} + +#[get("/account/device/commands?&")] +pub(crate) async fn commands( + db: &DbConn, + index: i64, + limit: Option, + auth: Authenticated<(), WithVerifiedSession>, +) -> auth::Result { + let dev = auth.context.device_id.as_ref().ok_or(auth::Error::UnknownDevice)?; + let (more, cmds) = + db.get_commands(&auth.context.uid, dev, index, limit.unwrap_or(100).clamp(0, 100)).await?; + Ok(Json(CommandsResp { + index: cmds.iter().map(|c| c.index).max().unwrap_or(0), + last: !more, + messages: cmds.into_iter().map(map_command).collect(), + })) +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct DestroyReq { + id: DeviceID, +} + +#[post("/account/device/destroy", data = "")] +pub(crate) async fn destroy( + db: &DbConn, + db_pool: &Db, + defer: &DeferAction, + client: &State>, + req: crate::auth::Authenticated, +) -> auth::Result { + db.delete_device(&req.context.uid, &req.body.id).await.map_err(map_error)?; + match db.get_devices(&req.context.uid).await { + Err(e) => warn!("device_disconnected push failed: {e}"), + Ok(devs) => defer.spawn_after_success("api::auth/account/device/destroy(post)", { + let (client, db) = (Arc::clone(client), db_pool.clone()); + async move { + let db = db.begin().await?; + client.device_disconnected(&db, &devs, &req.body.id).await; + db.commit().await?; + Ok(()) + } + }), + }; + Ok(EMPTY) +} + +#[derive(Debug, Deserialize)] +pub(crate) enum NotifyTarget { + #[serde(rename = "all")] + All, +} + +#[derive(Debug, Deserialize)] +pub(crate) enum NotifyEPAction { + #[serde(rename = "accountVerify")] + AccountVerify, +} + +#[derive(Debug, Deserialize)] +#[allow(non_snake_case)] +#[serde(untagged, deny_unknown_fields)] +pub(crate) enum NotifyReq { + // deny_unknown_fields and flatten don't work together + All { + #[allow(dead_code)] + to: NotifyTarget, + _endpointAction: Option, + excluded: Option>, + payload: Value, + TTL: Option, + }, + Some { + to: Vec, + _endpointAction: Option, + payload: Value, + TTL: Option, + }, +} + +#[post("/account/devices/notify", data = "")] +pub(crate) async fn notify( + db: &DbConn, + client: &State>, + req: Authenticated, +) -> auth::Result { + let (to, payload, ttl) = match req.body { + NotifyReq::All { excluded, payload, TTL: ttl, .. } => { + let excluded = excluded.unwrap_or_default(); + let mut devs = db.get_devices(&req.context.uid).await?; + devs.retain(|d| !excluded.contains(&d.device_id)); + (devs, payload, ttl) + }, + NotifyReq::Some { to, payload, TTL: ttl, .. } => { + let to = join_all(to.iter().map(|id| db.get_device(&req.context.uid, id))) + .await + .into_iter() + .collect::, _>>()?; + (to, payload, ttl) + }, + }; + client.push_any(db, &to, Duration::from_secs(ttl.unwrap_or(0).into()), payload).await; + Ok(EMPTY) +} + +#[derive(Debug, Serialize)] +#[allow(non_snake_case)] +pub(crate) struct AttachedClient { + clientId: Option, + deviceId: Option, + sessionTokenId: Option, + refreshTokenId: Option, + isCurrentSession: bool, + deviceType: Option, + name: Option, + #[serde(serialize_with = "serialize_dt_opt")] + createdTime: Option>, + // MISSING createdTimeFormatted + #[serde(serialize_with = "serialize_dt_opt")] + lastAccessTime: Option>, + // MISSING lastAccessTimeFormatted + // MISSING approximateLastAccessTime + // MISSING approximateLastAccessTimeFormatted + scope: Option, + // MISSING location + // MISSING userAgent + // MISSING os +} + +// MISSING filterIdleDevicesTimestamp +#[get("/account/attached_clients")] +pub(crate) async fn attached_clients( + db: &DbConn, + auth: Authenticated<(), WithVerifiedFxaLogin>, +) -> auth::Result> { + let clients = db.get_attached_clients(&auth.context.uid).await?; + Ok(Json( + clients + .into_iter() + .map(|dev| AttachedClient { + clientId: dev.client_id, + deviceId: dev.device_id, + refreshTokenId: dev.refresh_token_id, + isCurrentSession: dev.session_token_id.as_ref() == Some(&auth.session), + sessionTokenId: dev.session_token_id, + deviceType: dev.device_type, + name: dev.name, + createdTime: dev.created_time, + lastAccessTime: dev.last_access_time, + scope: dev.scope, + }) + .collect::>(), + )) +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +#[allow(non_snake_case)] +pub(crate) struct DestroyAttachedClientReq { + // NOTE should be used to verify token deletion, but since we allow only a fixed + // number of clients that makes little sense. + #[allow(dead_code)] + clientId: Option, + sessionTokenId: Option, + refreshTokenId: Option, + deviceId: Option, +} + +#[post("/account/attached_client/destroy", data = "")] +pub(crate) async fn destroy_attached_client( + db: &DbConn, + db_pool: &Db, + defer: &DeferAction, + client: &State>, + req: Authenticated, +) -> auth::Result { + // only one id may be given, otherwise deleting things properly is more work. + if (req.body.sessionTokenId.is_some() as u32) + + (req.body.refreshTokenId.is_some() as u32) + + (req.body.deviceId.is_some() as u32) + != 1 + { + return Err(auth::Error::InvalidParameter); + } + + if let Some(dev) = req.body.deviceId { + let devs = db.get_devices(&req.context.uid).await; + db.delete_device(&req.context.uid, &dev).await?; + match devs { + Err(e) => warn!("device_disconnected push failed: {e}"), + Ok(devs) => { + defer.spawn_after_success("api::auth/account/attached_client/destroy(post)", { + let (client, db) = (Arc::clone(client), db_pool.clone()); + async move { + let db = db.begin().await?; + client.device_disconnected(&db, &devs, &dev).await; + db.commit().await?; + Ok(()) + } + }) + }, + }; + } + if let Some(id) = req.body.sessionTokenId { + db.delete_session(&req.context.uid, &id).await?; + } + if let Some(id) = req.body.refreshTokenId { + db.delete_refresh_token(&id).await?; + } + + Ok(EMPTY) +} diff --git a/src/api/auth/email.rs b/src/api/auth/email.rs new file mode 100644 index 0000000..f206759 --- /dev/null +++ b/src/api/auth/email.rs @@ -0,0 +1,126 @@ +use std::sync::Arc; + +use rocket::{serde::json::Json, State}; +use serde::{Deserialize, Serialize}; + +use crate::{ + api::{ + auth::{self, WithFxaLogin}, + Empty, EMPTY, + }, + auth::Authenticated, + db::{Db, DbConn}, + mailer::Mailer, + push::PushClient, + types::UserID, + utils::DeferAction, +}; + +// MISSING get /recovery_emails +// MISSING post /recovery_email +// MISSING post /recovery_email/destroy +// MISSING post /recovery_email/resend_code +// MISSING post /recovery_email/set_primary +// MISSING post /emails/reminders/cad +// MISSING post /recovery_email/secondary/resend_code +// MISSING post /recovery_email/secondary/verify_code + +#[derive(Debug, Serialize)] +#[allow(non_snake_case)] +pub(crate) struct StatusResp { + email: String, + verified: bool, + sessionVerified: bool, + emailVerified: bool, +} + +// MISSING arg: reason +#[get("/recovery_email/status")] +pub(crate) async fn status( + db: &DbConn, + req: Authenticated<(), WithFxaLogin>, +) -> auth::Result { + let user = db.get_user_by_id(&req.context.uid).await?; + Ok(Json(StatusResp { + email: user.email, + verified: user.verified, + sessionVerified: req.context.verified, + emailVerified: user.verified, + })) +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct VerifyReq { + uid: UserID, + code: String, + // MISSING service + // MISSING reminder + // MISSING type + // MISSING style + // MISSING marketingOptIn + // MISSING newsletters +} + +#[post("/recovery_email/verify_code", data = "")] +pub(crate) async fn verify_code( + db: &DbConn, + db_pool: &Db, + defer: &DeferAction, + pc: &State>, + req: Json, +) -> auth::Result { + let code = match db.try_use_verify_code(&req.uid, &req.code).await? { + Some(code) => code, + None => return Err(auth::Error::InvalidVerificationCode), + }; + db.set_user_verified(&req.uid).await?; + if let Some(sid) = code.session_id { + db.set_session_verified(&sid).await?; + } + match db.get_devices(&req.uid).await { + Ok(devs) => defer.spawn_after_success("api::auth/recovery_email/verify_code(post)", { + let (pc, db) = (Arc::clone(pc), db_pool.clone()); + async move { + let db = db.begin().await?; + pc.account_verified(&db, &devs).await; + db.commit().await?; + Ok(()) + } + }), + Err(e) => warn!("account_verified push failed: {e}"), + } + Ok(EMPTY) +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct ResendReq { + // MISSING email + // MISSING service + // MISSING redirectTo + // MISSING resume + // MISSING style + // MISSING type +} + +// MISSING arg: service +// MISSING arg: type +#[post("/recovery_email/resend_code", data = "")] +pub(crate) async fn resend_code( + db: &DbConn, + mailer: &State>, + req: Authenticated, +) -> auth::Result { + let (email, code) = match db.get_verify_code(&req.context.uid).await { + Ok(v) => v, + Err(_) => return Err(auth::Error::InvalidVerificationCode), + }; + // NOTE we send the email in this context rather than a spawn to signal + // send errors to the client. + mailer.send_account_verify(&req.context.uid, &email, &code.code).await.map_err(|e| { + error!("failed to send email: {e}"); + auth::Error::EmailFailed + })?; + Ok(EMPTY) +} diff --git a/src/api/auth/invite.rs b/src/api/auth/invite.rs new file mode 100644 index 0000000..dd81540 --- /dev/null +++ b/src/api/auth/invite.rs @@ -0,0 +1,47 @@ +use base64::URL_SAFE_NO_PAD; +use chrono::{Duration, Utc}; +use rocket::{http::uri::Reference, serde::json::Json, State}; +use serde::{Deserialize, Serialize}; + +use crate::{api::auth, auth::Authenticated, crypto::SecretBytes, db::DbConn, Config}; + +use super::WithVerifiedFxaLogin; + +pub(crate) async fn generate_invite_link( + db: &DbConn, + cfg: &Config, + ttl: Duration, +) -> anyhow::Result> { + let code = base64::encode_config(&SecretBytes::<32>::generate().0, URL_SAFE_NO_PAD); + db.add_invite_code(&code, Utc::now() + ttl).await?; + Ok(Reference::parse_owned(format!("{}/#/register/{}", cfg.location, code)) + .map_err(|e| anyhow!("url building failed at {e}"))?) +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct GenerateReq { + ttl_hours: u32, +} + +#[derive(Debug, Serialize)] +pub(crate) struct GenerateResp { + url: Reference<'static>, +} + +#[post("/generate", data = "")] +pub(crate) async fn generate( + db: &DbConn, + cfg: &State, + req: Authenticated, +) -> auth::Result { + if !req.context.verified { + return Err(auth::Error::UnverifiedSession); + } + let user = db.get_user_by_id(&req.context.uid).await?; + if user.email != cfg.invite_admin_address { + return Err(auth::Error::InvalidAuthToken); + } + let url = generate_invite_link(&db, &cfg, Duration::hours(req.body.ttl_hours as i64)).await?; + Ok(Json(GenerateResp { url })) +} diff --git a/src/api/auth/mod.rs b/src/api/auth/mod.rs new file mode 100644 index 0000000..2c6d34d --- /dev/null +++ b/src/api/auth/mod.rs @@ -0,0 +1,238 @@ +use rocket::{ + http::Status, + response::{self, Responder}, + serde::json::Json, + Request, Response, +}; +use serde_json::json; + +use crate::{ + auth::Authenticated, + crypto::SecretBytes, + types::{OauthToken, SessionID, UserSession}, +}; + +pub(crate) mod account; +pub(crate) mod device; +pub(crate) mod email; +pub(crate) mod invite; +pub(crate) mod oauth; +pub(crate) mod password; +pub(crate) mod session; + +// we don't provide any additional fields. some we can't provide anyway (eg +// invalid parameter `validation`), others are implied by the request body (eg +// account exists `email`), and *our* client doesn't care about them anyway +#[derive(Debug)] +pub(crate) enum Error { + AccountExists, + UnknownAccount, + IncorrectPassword, + UnverifiedAccount, + InvalidVerificationCode, + InvalidBody, + InvalidParameter, + MissingParameter, + InvalidSignature, + InvalidAuthToken, + RequestTooLarge, + IncorrectEmailCase, + UnknownDevice, + UnverifiedSession, + EmailFailed, + NoDeviceCommand, + UnknownClientID, + ScopesNotAllowed, + + InviteOnly, + InviteNotFound, + + Other(anyhow::Error), + UnexpectedStatus(Status), +} + +#[rustfmt::skip] +impl<'r> Responder<'r, 'static> for Error { + fn respond_to(self, request: &'r Request<'_>) -> response::Result<'static> { + let (code, errno, msg) = match self { + Error::AccountExists => (Status::BadRequest, 101, "account already exists"), + Error::UnknownAccount => (Status::BadRequest, 102, "unknown account"), + Error::IncorrectPassword => (Status::BadRequest, 103, "incorrect password"), + Error::UnverifiedAccount => (Status::BadRequest, 104, "unverified account"), + Error::InvalidVerificationCode => (Status::BadRequest, 105, "invalid verification code"), + Error::InvalidBody => (Status::BadRequest, 106, "invalid json in request body"), + Error::InvalidParameter => (Status::BadRequest, 107, "invalid parameter in request body"), + Error::MissingParameter => (Status::BadRequest, 108, "missing parameter in request body"), + Error::InvalidSignature => (Status::Unauthorized, 109, "invalid request signature"), + Error::InvalidAuthToken => (Status::Unauthorized, 110, "invalid authentication token"), + Error::RequestTooLarge => (Status::PayloadTooLarge, 113, "request too large"), + Error::IncorrectEmailCase => (Status::BadRequest, 120, "incorrect email case"), + Error::UnknownDevice => (Status::BadRequest, 123, "unknown device"), + Error::UnverifiedSession => (Status::BadRequest, 138, "unverified session"), + Error::EmailFailed => (Status::UnprocessableEntity, 151, "failed to send email"), + Error::NoDeviceCommand => (Status::BadRequest, 157, "unavailable device command"), + Error::UnknownClientID => (Status::BadRequest, 162, "unknown client_id"), + Error::ScopesNotAllowed => (Status::BadRequest, 169, "requested scopes not allowed"), + Error::InviteOnly => (Status::BadRequest, -1, "invite code required"), + Error::InviteNotFound => (Status::BadRequest, -2, "invite code not found"), + Error::Other(e) => { + error!("non-api error during request: {:#?}", e); + (Status::InternalServerError, 999, "internal error") + }, + Error::UnexpectedStatus(s) => (s, 999, ""), + }; + let body = json!({ + "code": code.code, + "errno": errno, + "error": code.reason_lossy(), + "message": msg + }); + Response::build_from(Json(body).respond_to(request)?).status(code).ok() + } +} + +impl From for Error { + fn from(e: sqlx::Error) -> Self { + Error::Other(anyhow!(e)) + } +} + +impl From for Error { + fn from(e: anyhow::Error) -> Self { + Error::Other(e) + } +} + +pub(crate) type Result = std::result::Result, Error>; + +// hack marker type to convey that auth failed due to an unverified session. +// without this the catcher could convert the Unauthorized error we get from +// auth failures into just one thing, even though we have multiple causes. +#[derive(Clone, Copy, Debug)] +struct UsedUnverifiedSession; + +#[catch(default)] +pub(crate) fn catch_all(status: Status, req: &Request<'_>) -> Error { + match req.local_cache(|| None) { + Some(UsedUnverifiedSession) => Error::UnverifiedSession, + _ => { + match status.code { + 401 => Error::InvalidSignature, + // these three are caused by Json errors + 400 => Error::InvalidBody, + 413 => Error::RequestTooLarge, + 422 => Error::InvalidParameter, + // generic unauthorized instead of 404 for eg wrong method or nonexistant endpoints + 404 => Error::InvalidSignature, + _ => { + error!("caught unexpected error {status}"); + Error::UnexpectedStatus(status) + }, + } + }, + } +} + +#[derive(Debug)] +pub(crate) struct WithFxaLogin; + +#[async_trait] +impl crate::auth::AuthSource for WithFxaLogin { + type ID = SessionID; + type Context = UserSession; + async fn hawk( + r: &Request<'_>, + id: &SessionID, + ) -> anyhow::Result<(SecretBytes<32>, Self::Context)> { + let db = Authenticated::<(), Self>::get_conn(r).await?; + let k = db.use_session(id).await?; + Ok((k.req_hmac_key.0.clone(), k)) + } + async fn bearer_token( + _: &Request<'_>, + _: &OauthToken, + ) -> anyhow::Result<(SessionID, Self::Context)> { + bail!("refresh tokens not allowed here"); + } +} + +#[derive(Debug)] +pub(crate) struct WithVerifiedFxaLogin; + +#[async_trait] +impl crate::auth::AuthSource for WithVerifiedFxaLogin { + type ID = SessionID; + type Context = UserSession; + async fn hawk( + r: &Request<'_>, + id: &SessionID, + ) -> anyhow::Result<(SecretBytes<32>, Self::Context)> { + let res = WithFxaLogin::hawk(r, id).await?; + match res.1.verified { + true => Ok(res), + false => { + r.local_cache(|| Some(UsedUnverifiedSession)); + bail!("session not verified"); + }, + } + } + async fn bearer_token( + _: &Request<'_>, + _: &OauthToken, + ) -> anyhow::Result<(SessionID, Self::Context)> { + bail!("refresh tokens not allowed here"); + } +} + +#[derive(Debug)] +pub(crate) struct WithSession; + +#[rocket::async_trait] +impl crate::auth::AuthSource for WithSession { + type ID = SessionID; + type Context = UserSession; + async fn hawk( + r: &Request<'_>, + id: &SessionID, + ) -> anyhow::Result<(SecretBytes<32>, Self::Context)> { + WithFxaLogin::hawk(r, id).await + } + async fn bearer_token( + r: &Request<'_>, + token: &OauthToken, + ) -> anyhow::Result<(SessionID, Self::Context)> { + let db = Authenticated::<(), Self>::get_conn(r).await?; + Ok(db.use_session_from_refresh(&token.hash()).await?) + } +} + +#[derive(Debug)] +pub(crate) struct WithVerifiedSession; + +#[rocket::async_trait] +impl crate::auth::AuthSource for WithVerifiedSession { + type ID = SessionID; + type Context = UserSession; + async fn hawk( + r: &Request<'_>, + id: &SessionID, + ) -> anyhow::Result<(SecretBytes<32>, Self::Context)> { + WithVerifiedFxaLogin::hawk(r, id).await + } + async fn bearer_token( + r: &Request<'_>, + token: &OauthToken, + ) -> anyhow::Result<(SessionID, Self::Context)> { + let db = Authenticated::<(), Self>::get_conn(r).await?; + let res = db.use_session_from_refresh(&token.hash()).await?; + match res.1.verified { + true => Ok(res), + false => { + // technically unreachable because generating a refresh token requires a + // valid fxa session + r.local_cache(|| Some(UsedUnverifiedSession)); + bail!("session not verified"); + }, + } + } +} diff --git a/src/api/auth/oauth.rs b/src/api/auth/oauth.rs new file mode 100644 index 0000000..b0ed8ee --- /dev/null +++ b/src/api/auth/oauth.rs @@ -0,0 +1,433 @@ +use std::collections::HashMap; + +use chrono::{DateTime, Duration, Local, Utc}; +use rocket::serde::json::Json; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use sha2::Digest; +use subtle::ConstantTimeEq; + +use crate::api::auth::WithVerifiedFxaLogin; +use crate::db::DbConn; +use crate::types::oauth::{Scope, ScopeSet}; +use crate::{ + api::{auth, serialize_dt}, + auth::Authenticated, + crypto::{SecretBytes, SessionCredentials}, + types::{ + HawkKey, OauthAccessToken, OauthAccessType, OauthAuthorization, OauthAuthorizationID, + OauthRefreshToken, OauthToken, OauthTokenID, SessionID, UserID, + }, +}; + +// MISSING get /oauth/client/{client_id} + +pub(crate) struct OauthClient { + pub(crate) id: &'static str, + // NOTE not read so far, but good to have + #[allow(dead_code)] + pub(crate) name: &'static str, + pub(crate) scopes: &'static [Scope<'static>], +} + +const SESSION_SCOPE: Scope = Scope::borrowed("https://identity.mozilla.com/tokens/session"); + +// NOTE the telemetry scopes don't seem to be needed. since we'd have to give +// out keys for them (fxa does) we'll exclude them entirely. +// see fxa-auth-server/config/dev.json for lists of predefined clients and permissions. +pub(crate) const OAUTH_CLIENTS: [OauthClient; 2] = [ + OauthClient { + id: "5882386c6d801776", + name: "Firefox", + scopes: &[ + Scope::borrowed("profile:write"), + Scope::borrowed("https://identity.mozilla.com/apps/oldsync"), + Scope::borrowed("https://identity.mozilla.com/tokens/session"), + // "https://identity.mozilla.com/ids/ecosystem_telemetry", + ], + }, + OauthClient { + id: "a2270f727f45f648", + name: "Fenix", + scopes: &[ + Scope::borrowed("profile"), + Scope::borrowed("https://identity.mozilla.com/apps/oldsync"), + Scope::borrowed("https://identity.mozilla.com/tokens/session"), + // "https://identity.mozilla.com/ids/ecosystem_telemetry", + ], + }, +]; + +// NOTE fxa dev config allows scoped keys only for: +// - https://identity.mozilla.com/apps/notes +// - https://identity.mozilla.com/apps/oldsync +// - https://identity.mozilla.com/ids/ecosystem_telemetry +// - https://identity.mozilla.com/apps/send +// we only implement sync because notes and send are dead and +// telemetry is of no use to us +const SCOPES_WITH_KEYS: [Scope; 1] = [Scope::borrowed("https://identity.mozilla.com/apps/oldsync")]; + +fn check_client_and_scopes( + client_id: &str, + scope: &ScopeSet, +) -> Result<&'static OauthClient, auth::Error> { + let desc = match OAUTH_CLIENTS.iter().find(|&s| s.id == client_id) { + Some(d) => d, + None => return Err(auth::Error::UnknownClientID), + }; + if !scope.is_allowed_by(desc.scopes) { + return Err(auth::Error::ScopesNotAllowed); + } + Ok(desc) +} + +#[derive(Debug, Deserialize)] +pub(crate) enum PkceChallengeType { + S256, +} + +#[derive(Debug, Deserialize)] +pub(crate) enum AuthResponseType { + #[serde(rename = "code")] + Code, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct OauthAuthReq { + client_id: String, + state: String, + keys_jwe: Option, + scope: ScopeSet, + access_type: OauthAccessType, + // NOTE we don't support confidential clients, so PKCE is mandatory + code_challenge: String, + + // MISSING redirect_uri + // MISSING acr_value + + // for validation during deserialization only + #[allow(dead_code)] + code_challenge_method: PkceChallengeType, + #[allow(dead_code)] + response_type: AuthResponseType, +} + +#[derive(Debug, Serialize)] +pub(crate) struct OauthAuthResp { + code: OauthAuthorizationID, + state: String, + // MISSING redirect +} + +#[post("/oauth/authorization", data = "")] +pub(crate) async fn authorization( + db: &DbConn, + req: Authenticated, +) -> auth::Result { + check_client_and_scopes(&req.body.client_id, &req.body.scope)?; + let id = OauthAuthorizationID::random(); + db.add_oauth_authorization( + &id, + OauthAuthorization { + user_id: req.context.uid, + client_id: req.body.client_id, + scope: req.body.scope, + access_type: req.body.access_type, + code_challenge: req.body.code_challenge, + keys_jwe: req.body.keys_jwe, + auth_at: req.context.created_at, + }, + ) + .await?; + Ok(Json(OauthAuthResp { code: id, state: req.body.state })) +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct ScopedKeysReq { + client_id: String, + scope: ScopeSet, +} + +#[derive(Debug, Serialize)] +#[allow(non_snake_case)] +pub(crate) struct ScopedKey { + identifier: String, + keyRotationSecret: &'static str, + keyRotationTimestamp: u64, +} + +#[post("/account/scoped-key-data", data = "")] +pub(crate) async fn scoped_key_data( + data: Authenticated, +) -> auth::Result> { + check_client_and_scopes(&data.body.client_id, &data.body.scope)?; + // like fxa we'll stub out key rotation handling entirely and return the same constants. + Ok(Json( + data.body + .scope + .split() + .filter(|s| SCOPES_WITH_KEYS.contains(s)) + .map(|scope| { + ( + scope.to_string(), + ScopedKey { + identifier: scope.to_string(), + keyRotationSecret: + "0000000000000000000000000000000000000000000000000000000000000000", + keyRotationTimestamp: 0, + }, + ) + }) + .collect(), + )) +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct OauthDestroy { + client_id: String, + token: OauthToken, +} + +#[post("/oauth/destroy", data = "")] +pub(crate) async fn destroy(db: &DbConn, data: Json) -> auth::Result<()> { + // MISSING api spec allows an optional basic auth header, but what for? + // TODO fxa also checks the authorization header if present, but firefox doesn't send it + let client_id = if let Ok(t) = db.get_refresh_token(&data.token.hash()).await { + t.client_id + } else if let Ok(t) = db.get_access_token(&data.token.hash()).await { + t.client_id + } else { + return Err(auth::Error::InvalidParameter); + }; + // fxa does constant-time checks for client_id, do that here too. + if client_id.as_bytes().ct_eq(data.client_id.as_bytes()).into() { + db.delete_oauth_token(&data.token.hash()).await?; + Ok(Json(())) + } else { + Err(auth::Error::InvalidParameter) + } +} + +#[derive(Debug, Deserialize)] +#[serde(tag = "grant_type")] +enum TokenReqDetails { + // we can't use deny_unknown_fields when flatten is involved, and multiple + // flattens in the same struct cause problems if one of them is greedy (like map). + // flatten an extra map into every variant instead and check each of them. + #[serde(rename = "authorization_code")] + AuthCode { + code: OauthAuthorizationID, + code_verifier: String, + // NOTE only useful with redirect flows, which we kinda don't support at all + #[allow(dead_code)] + redirect_uri: Option, + #[serde(flatten)] + extra: HashMap, + }, + #[serde(rename = "refresh_token")] + RefreshToken { + refresh_token: OauthToken, + scope: ScopeSet, + #[serde(flatten)] + extra: HashMap, + }, + #[serde(rename = "fxa-credentials")] + FxaCreds { + scope: ScopeSet, + access_type: Option, + #[serde(flatten)] + extra: HashMap, + }, +} + +impl TokenReqDetails { + fn extra_is_empty(&self) -> bool { + match self { + TokenReqDetails::AuthCode { extra, .. } => extra.is_empty(), + TokenReqDetails::RefreshToken { extra, .. } => extra.is_empty(), + TokenReqDetails::FxaCreds { extra, .. } => extra.is_empty(), + } + } +} + +// TODO log errors in all the places + +#[derive(Debug, Deserialize)] +pub(crate) struct TokenReq { + client_id: String, + ttl: Option, + #[serde(flatten)] + details: TokenReqDetails, + // MISSING client_secret + // MISSING redirect_uri + // MISSING ttl + // MISSING ppid_seed + // MISSING resource +} + +#[derive(Debug, Serialize)] +pub(crate) enum TokenType { + #[serde(rename = "bearer")] + Bearer, +} + +#[derive(Debug, Serialize)] +pub(crate) struct TokenResp { + access_token: OauthToken, + #[serde(skip_serializing_if = "Option::is_none")] + refresh_token: Option, + // MISSING id_token + #[serde(skip_serializing_if = "Option::is_none")] + session_token: Option, + scope: ScopeSet, + token_type: TokenType, + expires_in: u32, + #[serde(serialize_with = "serialize_dt")] + auth_at: DateTime, + #[serde(skip_serializing_if = "Option::is_none")] + keys_jwe: Option, +} + +#[post("/oauth/token", data = "", rank = 1)] +pub(crate) async fn token_authenticated( + db: &DbConn, + req: Authenticated, +) -> auth::Result { + match &req.body.details { + TokenReqDetails::FxaCreds { .. } => (), + _ => return Err(auth::Error::InvalidParameter), + } + token_impl( + db, + Some(req.context.uid), + Some(req.context.created_at), + req.body, + None, + Some(req.session.clone()), + ) + .await +} + +#[post("/oauth/token", data = "", rank = 2)] +pub(crate) async fn token_unauthenticated( + db: &DbConn, + req: Json, +) -> auth::Result { + let (parent_refresh, auth_at) = match &req.details { + TokenReqDetails::RefreshToken { refresh_token, .. } => { + let session = db.use_session_from_refresh(&refresh_token.hash()).await?; + (Some(refresh_token.hash()), Some(session.1.created_at)) + }, + TokenReqDetails::AuthCode { .. } => (None, None), + _ => return Err(auth::Error::InvalidParameter), + }; + token_impl(db, None, auth_at, req.into_inner(), parent_refresh, None).await +} + +async fn token_impl( + db: &DbConn, + user_id: Option, + auth_at: Option>, + req: TokenReq, + parent_refresh: Option, + parent_session: Option, +) -> auth::Result { + if !req.details.extra_is_empty() { + return Err(auth::Error::InvalidParameter); + } + let ttl = req.ttl.unwrap_or(3600).clamp(0, 7 * 86400); + + let (auth_at, scope, keys_jwe, user_id, access_type) = match req.details { + TokenReqDetails::AuthCode { code, code_verifier, .. } => { + let auth = match db.take_oauth_authorization(&code).await { + Ok(a) => a, + Err(_) => return Err(auth::Error::InvalidAuthToken), + }; + if !bool::from(auth.client_id.as_bytes().ct_eq(req.client_id.as_bytes())) { + return Err(auth::Error::UnknownClientID); + } + let mut sha = sha2::Sha256::new(); + sha.update(code_verifier.as_bytes()); + let challenge = base64::encode_config(&sha.finalize(), base64::URL_SAFE_NO_PAD); + if !bool::from(challenge.as_bytes().ct_eq(auth.code_challenge.as_bytes())) { + return Err(auth::Error::InvalidParameter); + } + (auth.auth_at, auth.scope, auth.keys_jwe, auth.user_id, Some(auth.access_type)) + }, + TokenReqDetails::RefreshToken { refresh_token, scope, .. } => { + let auth_at = + auth_at.expect("oauth token requests with refresh token must set auth_at"); + let base = db.get_refresh_token(&refresh_token.hash()).await?; + if !bool::from(base.client_id.as_bytes().ct_eq(req.client_id.as_bytes())) { + return Err(auth::Error::UnknownClientID); + } + check_client_and_scopes(&req.client_id, &scope)?; + if !base.scope.implies_all(&scope) { + return Err(auth::Error::ScopesNotAllowed); + } + (auth_at, scope, None, base.user_id, None) + }, + TokenReqDetails::FxaCreds { scope, access_type, .. } => { + let user_id = user_id.expect("oauth token requests with fxa must set user_id"); + let auth_at = auth_at.expect("oauth token requests with fxa must set auth_at"); + check_client_and_scopes(&req.client_id, &scope)?; + (auth_at, scope, None, user_id, access_type) + }, + }; + + let access_token = OauthToken::random(); + db.add_access_token( + &access_token.hash(), + OauthAccessToken { + user_id: user_id.clone(), + client_id: req.client_id.clone(), + scope: scope.clone(), + parent_refresh, + parent_session, + expires_at: (Local::now() + Duration::seconds(ttl.into())).into(), + }, + ) + .await?; + + let (refresh_token, session_token) = if access_type == Some(OauthAccessType::Offline) { + let (session_token, session_id) = if scope.implies(&SESSION_SCOPE) { + let session_token = SecretBytes::generate(); + let session = SessionCredentials::derive(&session_token); + let session_id = SessionID(session.token_id.0); + db.add_session(session_id.clone(), &user_id, HawkKey(session.req_hmac_key), true, None) + .await?; + (Some(session_token.0), Some(SessionID(session.token_id.0))) + } else { + (None, None) + }; + + let refresh_token = OauthToken::random(); + db.add_refresh_token( + &refresh_token.hash(), + OauthRefreshToken { + user_id, + client_id: req.client_id, + scope: scope.remove(&SESSION_SCOPE), + session_id, + }, + ) + .await?; + (Some(refresh_token), session_token) + } else { + (None, None) + }; + + Ok(Json(TokenResp { + access_token, + refresh_token, + session_token: session_token.map(hex::encode), + scope: scope.remove(&SESSION_SCOPE), + token_type: TokenType::Bearer, + expires_in: ttl, + auth_at, + keys_jwe, + })) +} diff --git a/src/api/auth/password.rs b/src/api/auth/password.rs new file mode 100644 index 0000000..0eeab4f --- /dev/null +++ b/src/api/auth/password.rs @@ -0,0 +1,260 @@ +use std::sync::Arc; + +use anyhow::Result; +use password_hash::SaltString; +use rocket::{request::FromRequest, serde::json::Json, Request, State}; +use serde::{Deserialize, Serialize}; +use validator::Validate; + +use crate::{ + api::auth, + auth::{AuthSource, Authenticated}, + crypto::{AccountResetReq, AuthPW, KeyBundle, KeyFetchReq, PasswordChangeReq, SecretBytes}, + db::{Db, DbConn}, + mailer::Mailer, + types::{ + AccountResetID, HawkKey, KeyFetchID, OauthToken, PasswordChangeID, SecretKey, UserID, + VerifyHash, + }, +}; + +// MISSING get /password/forgot/status +// MISSING post /password/create +// MISSING post /password/forgot/resend_code + +#[derive(Debug, Deserialize, Validate)] +#[serde(deny_unknown_fields)] +#[allow(non_snake_case)] +pub(crate) struct ChangeStartReq { + #[validate(email, length(min = 3, max = 256))] + email: String, + oldAuthPW: AuthPW, +} + +#[derive(Debug, Serialize)] +#[allow(non_snake_case)] +pub(crate) struct ChangeStartResp { + keyFetchToken: SecretBytes<32>, + passwordChangeToken: SecretBytes<32>, +} + +#[post("/password/change/start", data = "")] +pub(crate) async fn change_start( + db: &DbConn, + data: Json, +) -> auth::Result { + let data = data.into_inner(); + data.validate().map_err(|_| auth::Error::InvalidParameter)?; + + let (uid, user) = db.get_user(&data.email).await.map_err(|_| auth::Error::UnknownAccount)?; + if user.email != data.email { + return Err(auth::Error::IncorrectEmailCase); + } + if !user.verified { + return Err(auth::Error::UnverifiedAccount); + } + + let stretched = data.oldAuthPW.stretch(user.auth_salt.as_salt())?; + if stretched.verify_hash() != user.verify_hash.0 { + return Err(auth::Error::IncorrectPassword); + } + + let change_token = SecretBytes::generate(); + let change_req = PasswordChangeReq::from_change_token(&change_token); + let key_fetch_token = SecretBytes::generate(); + let key_req = KeyFetchReq::from_token(&key_fetch_token); + let wrapped = key_req.derive_resp().wrap_keys(&KeyBundle { + ka: user.ka.0.clone(), + wrap_kb: stretched.decrypt_wwkb(&user.wrapwrap_kb.0), + }); + db.add_key_fetch(KeyFetchID(key_req.token_id.0), &HawkKey(key_req.req_hmac_key), &wrapped) + .await?; + db.add_password_change( + &uid, + &PasswordChangeID(change_req.token_id.0), + &HawkKey(change_req.req_hmac_key), + None, + ) + .await?; + + Ok(Json(ChangeStartResp { keyFetchToken: key_fetch_token, passwordChangeToken: change_token })) +} + +// NOTE we use a plain bool here and in the db instead of an enum because +// enums aren't usable in const generics in stable. +#[derive(Debug)] +pub(crate) struct WithChangeToken; + +#[async_trait] +impl AuthSource for WithChangeToken { + type ID = PasswordChangeID; + type Context = (UserID, Option); + async fn hawk( + r: &Request<'_>, + id: &PasswordChangeID, + ) -> Result<(SecretBytes<32>, Self::Context)> { + // unlike key fetch we'll use a separate transaction here since the body of the + // handler can fail. + let pool = <&Db as FromRequest>::from_request(r) + .await + .success_or_else(|| anyhow!("could not open db connection"))?; + let db = pool.begin().await?; + let result = db.finish_password_change(id, IS_FORGOT).await.map(|(h, ctx)| (h.0, ctx))?; + db.commit().await?; + Ok(result) + } + async fn bearer_token( + _: &Request<'_>, + _: &OauthToken, + ) -> Result<(PasswordChangeID, Self::Context)> { + bail!("invalid password change authentication") + } +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +#[allow(non_snake_case)] +pub(crate) struct ChangeFinishReq { + authPW: AuthPW, + wrapKb: SecretBytes<32>, + // MISSING sessionToken +} + +#[derive(Debug, Serialize)] +#[allow(non_snake_case)] +pub(crate) struct ChangeFinishResp { + // NOTE we intentionally deviate from mozilla here. mozilla creates a new + // session if sessionToken is set in the request, but we use the "legacy" + // password change mechanism that leaves the requesting session and its + // device and keys intact. as such this struct is intentionally empty. + // + // MISSING uid + // MISSING sessionToken + // MISSING verified + // MISSING authAt + // MISSING keyFetchToken +} + +#[post("/password/change/finish", data = "")] +pub(crate) async fn change_finish( + db: &DbConn, + mailer: &State>, + data: Authenticated>, +) -> auth::Result { + let user = db.get_user_by_id(&data.context.0).await?; + + let auth_salt = SaltString::generate(rand::rngs::OsRng); + let stretched = data.body.authPW.stretch(auth_salt.as_salt())?; + let verify_hash = stretched.verify_hash(); + let wrapwrap_kb = stretched.rewrap_wkb(&data.body.wrapKb); + + db.change_user_auth( + &data.context.0, + auth_salt, + SecretKey(wrapwrap_kb), + VerifyHash(verify_hash), + ) + .await?; + + // NOTE password_changed/password_reset pushes seem to have no effect, so skip them. + + mailer + .send_password_changed(&user.email) + .await + .map_err(|e| { + warn!("password change email send failed: {e}"); + }) + .ok(); + + Ok(Json(ChangeFinishResp {})) +} + +#[derive(Debug, Deserialize, Validate)] +#[serde(deny_unknown_fields)] +#[allow(non_snake_case)] +pub(crate) struct ForgotStartReq { + #[validate(email, length(min = 3, max = 256))] + email: String, +} + +#[derive(Debug, Serialize)] +#[allow(non_snake_case)] +pub(crate) struct ForgotStartResp { + passwordForgotToken: SecretBytes<32>, + ttl: u32, + codeLength: u32, + tries: u32, +} + +#[post("/password/forgot/send_code", data = "")] +pub(crate) async fn forgot_start( + db: &DbConn, + mailer: &State>, + data: Json, +) -> auth::Result { + let data = data.into_inner(); + data.validate().map_err(|_| auth::Error::InvalidParameter)?; + + let (uid, user) = db.get_user(&data.email).await.map_err(|_| auth::Error::UnknownAccount)?; + if user.email != data.email { + return Err(auth::Error::IncorrectEmailCase); + } + if !user.verified { + return Err(auth::Error::UnverifiedAccount); + } + + let forgot_code = hex::encode(SecretBytes::<16>::generate().0); + let forgot_token = SecretBytes::generate(); + let forgot_req = PasswordChangeReq::from_forgot_token(&forgot_token); + db.add_password_change( + &uid, + &PasswordChangeID(forgot_req.token_id.0), + &HawkKey(forgot_req.req_hmac_key), + Some(&forgot_code), + ) + .await?; + + mailer.send_password_forgot(&user.email, &forgot_code).await?; + + Ok(Json(ForgotStartResp { + passwordForgotToken: forgot_token, + ttl: 300, + codeLength: 16, + tries: 1, + })) +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +#[allow(non_snake_case)] +pub(crate) struct ForgotFinishReq { + code: String, + // MISSING accountResetWithRecoveryKey +} + +#[derive(Debug, Serialize)] +#[allow(non_snake_case)] +pub(crate) struct ForgotFinishResp { + accountResetToken: SecretBytes<32>, +} + +#[post("/password/forgot/verify_code", data = "")] +pub(crate) async fn forgot_finish( + db: &DbConn, + data: Authenticated>, +) -> auth::Result { + if Some(data.body.code) != data.context.1 { + return Err(auth::Error::InvalidVerificationCode); + } + + let reset_token = SecretBytes::generate(); + let reset_req = AccountResetReq::from_token(&reset_token); + db.add_account_reset( + &data.context.0, + &AccountResetID(reset_req.token_id.0), + &HawkKey(reset_req.req_hmac_key), + ) + .await?; + + Ok(Json(ForgotFinishResp { accountResetToken: reset_token })) +} diff --git a/src/api/auth/session.rs b/src/api/auth/session.rs new file mode 100644 index 0000000..5911b92 --- /dev/null +++ b/src/api/auth/session.rs @@ -0,0 +1,107 @@ +use std::sync::Arc; + +use rocket::serde::json::Json; +use rocket::State; +use serde::{Deserialize, Serialize}; + +use crate::api::auth::WithFxaLogin; +use crate::api::{auth, Empty, EMPTY}; +use crate::auth::Authenticated; +use crate::db::Db; +use crate::db::DbConn; +use crate::mailer::Mailer; +use crate::push::PushClient; +use crate::types::{SessionID, UserID}; +use crate::utils::DeferAction; + +// MISSING post /session/duplicate +// MISSING post /session/reauth +// MISSING post /session/verify/send_push + +#[derive(Debug, Serialize)] +pub(crate) struct StatusResp { + state: &'static str, // what does this *do*? + uid: UserID, +} + +#[get("/session/status")] +pub(crate) async fn status(req: Authenticated<(), WithFxaLogin>) -> auth::Result { + Ok(Json(StatusResp { state: "", uid: req.context.uid })) +} + +#[post("/session/resend_code", data = "")] +pub(crate) async fn resend_code( + db: &DbConn, + mailer: &State>, + req: Authenticated, +) -> auth::Result { + let code = match req.context.verify_code { + Some(code) => code, + _ => return Err(auth::Error::InvalidVerificationCode), + }; + + let user = db.get_user_by_id(&req.context.uid).await?; + mailer.send_session_verify(&user.email, &code).await.map_err(|e| { + error!("failed to send email: {e}"); + auth::Error::EmailFailed + })?; + Ok(EMPTY) +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct VerifyReq { + code: String, + // MISSING service + // MISSING scopes + // MISSING marketingOptIn + // MISSING newsletters +} + +#[post("/session/verify_code", data = "")] +pub(crate) async fn verify_code( + db: &DbConn, + req: Authenticated, +) -> auth::Result { + if req.context.verify_code.as_ref() != Some(&req.body.code) { + return Err(auth::Error::InvalidVerificationCode); + } + db.set_session_verified(&req.session).await?; + Ok(EMPTY) +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct DestroyReq { + custom_session_id: Option, +} + +#[post("/session/destroy", data = "")] +pub(crate) async fn destroy( + db: &DbConn, + db_pool: &Db, + defer: &DeferAction, + client: &State>, + data: Authenticated, +) -> auth::Result { + if data.body.custom_session_id.is_some() && !data.context.verified { + return Err(auth::Error::UnverifiedSession); + } + let id = data.body.custom_session_id.as_ref().unwrap_or(&data.session); + db.delete_session(&data.context.uid, id).await.map_err(|_| auth::Error::UnknownDevice)?; + if let Some(id) = data.context.device_id { + match db.get_devices(&data.context.uid).await { + Err(e) => warn!("device_disconnected push failed: {e}"), + Ok(devs) => defer.spawn_after_success("api::auth/session/destroy(post)", { + let (client, db) = (Arc::clone(client), db_pool.clone()); + async move { + let db = db.begin().await?; + client.device_disconnected(&db, &devs, &id).await; + db.commit().await?; + Ok(()) + } + }), + }; + } + Ok(EMPTY) +} diff --git a/src/api/mod.rs b/src/api/mod.rs new file mode 100644 index 0000000..1831659 --- /dev/null +++ b/src/api/mod.rs @@ -0,0 +1,32 @@ +use chrono::{DateTime, TimeZone}; +use rocket::serde::json::Json; +use serde::{Deserialize, Serialize, Serializer}; + +pub(crate) mod auth; +pub(crate) mod oauth; +pub(crate) mod profile; + +pub fn serialize_dt(dt: &DateTime, ser: S) -> Result +where + S: Serializer, + TZ: TimeZone, +{ + ser.serialize_i64(dt.timestamp()) +} + +pub fn serialize_dt_opt(dt: &Option>, ser: S) -> Result +where + S: Serializer, + TZ: TimeZone, +{ + match dt { + Some(dt) => serialize_dt(dt, ser), + None => ser.serialize_unit(), + } +} + +#[derive(Clone, Copy, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Empty {} + +pub const EMPTY: Json = Json(Empty {}); diff --git a/src/api/oauth.rs b/src/api/oauth.rs new file mode 100644 index 0000000..0519125 --- /dev/null +++ b/src/api/oauth.rs @@ -0,0 +1,163 @@ +use rocket::{ + http::Status, + response::{self, Responder}, + serde::json::Json, + Request, Response, +}; +use serde::{Deserialize, Serialize}; +use serde_json::json; + +use crate::{ + api::Empty, + types::{OauthToken, UserID}, +}; +use crate::{db::DbConn, types::oauth::Scope}; + +use super::EMPTY; + +// we don't provide any additional fields. some we can't provide anyway (eg +// invalid parameter `validation`), others are implied by the request body (eg +// account exists `email`), and *our* client doesn't care about them anyway +#[derive(Debug)] +pub(crate) enum Error { + InvalidParameter, + Unauthorized, + PayloadTooLarge, + + Other(anyhow::Error), + UnexpectedStatus(Status), +} + +#[rustfmt::skip] +impl<'r> Responder<'r, 'static> for Error { + fn respond_to(self, request: &'r Request<'_>) -> response::Result<'static> { + let (code, errno, msg) = match self { + Error::InvalidParameter => (Status::BadRequest, 109, "invalid request parameter"), + Error::Unauthorized => (Status::Forbidden, 111, "unauthorized"), + Error::PayloadTooLarge => (Status::PayloadTooLarge, 999, "payload too large"), + Error::Other(e) => { + error!("non-api error during request: {:?}", e); + (Status::InternalServerError, 999, "internal error") + }, + Error::UnexpectedStatus(s) => (s, 999, ""), + }; + let body = json!({ + "code": code.code, + "errno": errno, + "error": code.reason_lossy(), + "message": msg + }); + Response::build_from(Json(body).respond_to(request)?).status(code).ok() + } +} + +impl From for Error { + fn from(e: sqlx::Error) -> Self { + Error::Other(anyhow!(e)) + } +} + +impl From for Error { + fn from(e: anyhow::Error) -> Self { + Error::Other(e) + } +} + +pub(crate) type Result = std::result::Result, Error>; + +#[catch(default)] +pub(crate) fn catch_all(status: Status, _r: &Request<'_>) -> Error { + match status.code { + 401 => Error::Unauthorized, + // these three are caused by Json errors + 400 => Error::InvalidParameter, + 413 => Error::PayloadTooLarge, + 422 => Error::InvalidParameter, + // generic unauthorized instead of 404 for eg wrong method or nonexistant endpoints + 404 => Error::Unauthorized, + _ => { + error!("caught unexpected error {status}"); + Error::UnexpectedStatus(status) + }, + } +} + +fn map_error(e: sqlx::Error) -> Error { + match &e { + sqlx::Error::RowNotFound => Error::InvalidParameter, + _ => Error::Other(anyhow!(e)), + } +} + +// MISSING GET /v1/authorization +// MISSING POST /v1/authorization +// MISSING POST /v1/authorized-clients +// MISSING POST /v1/authorized-clients/destroy +// MISSING GET /v1/client/:id +// MISSING POST /v1/introspect +// MISSING GET /v1/jwks +// MISSING POST /v1/key-data +// MISSING POST /v1/token +// MISSING POST /v1/verify + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct DestroyReq { + access_token: Option, + refresh_token: Option, + // NOTE this field does not exist in the spec, but fenix sends it + token: Option, + // MISSING client_id + // MISSING client_secret + // MISSING refresh_token_id +} + +#[post("/destroy", data = "")] +pub(crate) async fn destroy( + db: &DbConn, + req: Json, +) -> std::result::Result, Error> { + // MISSING spec says basic auth is allowed, but nothing seems to use it + if let Some(t) = req.0.access_token { + db.delete_oauth_token(&t.hash()).await?; + } + if let Some(t) = req.0.refresh_token { + db.delete_oauth_token(&t.hash()).await?; + } + if let Some(t) = req.0.token { + db.delete_oauth_token(&t.hash()).await?; + } + Ok(EMPTY) +} + +#[get("/jwks")] +pub(crate) async fn jwks() -> Json { + // HACK we need to return *something* for /jwks, otherwise PyFxA fails. + // since syncstorage-rs uses PyFxA to check oauth tokens this is bad. + EMPTY +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct VerifyReq { + token: OauthToken, +} + +#[derive(Debug, Serialize)] +pub(crate) struct VerifyResp { + user: UserID, + client_id: String, + scope: Vec>, + // MISSING generation + // MISSING profile_changed_at +} + +#[post("/verify", data = "")] +pub(crate) async fn verify(db: &DbConn, req: Json) -> Result { + let token = db.get_access_token(&req.token.hash()).await.map_err(map_error)?; + Ok(Json(VerifyResp { + user: token.user_id, + client_id: token.client_id, + scope: token.scope.split().map(|s| s.into_owned()).collect::>(), + })) +} diff --git a/src/api/profile/mod.rs b/src/api/profile/mod.rs new file mode 100644 index 0000000..28d1e03 --- /dev/null +++ b/src/api/profile/mod.rs @@ -0,0 +1,324 @@ +use std::sync::Arc; + +use either::Either; +use rocket::{ + data::ToByteUnit, + http::{uri::Absolute, ContentType, Status}, + response::{self, Responder}, + serde::json::Json, + Request, Response, State, +}; +use serde::{Deserialize, Serialize}; +use serde_json::json; +use sha2::{Digest, Sha256}; +use Either::{Left, Right}; + +use crate::{ + api::Empty, + auth::{Authenticated, WithBearer, AuthenticatedRequest}, + cache::Immutable, + db::Db, + types::{oauth::Scope, UserID}, + utils::DeferAction, +}; +use crate::{db::DbConn, types::AvatarID, Config}; +use crate::{push::PushClient, types::Avatar}; + +use super::EMPTY; + +// we don't provide any additional fields. some we can't provide anyway (eg +// invalid parameter `validation`), others are implied by the request body (eg +// account exists `email`), and *our* client doesn't care about them anyway +#[derive(Debug)] +pub(crate) enum Error { + Unauthorized, + InvalidParameter, + PayloadTooLarge, + NotFound, + + // this is actually a response from the auth api (not the profile api), + // but firefox needs the *exact response* of this auth error to refresh + // profile fetch oauth tokens for its ui. :( + InvalidAuthToken, + + Other(anyhow::Error), + UnexpectedStatus(Status), +} + +#[rustfmt::skip] +impl<'r> Responder<'r, 'static> for Error { + fn respond_to(self, request: &'r Request<'_>) -> response::Result<'static> { + let (code, errno, msg) = match self { + Error::Unauthorized => (Status::Forbidden, 100, "unauthorized"), + Error::InvalidParameter => (Status::BadRequest, 101, "invalid parameter in request body"), + Error::PayloadTooLarge => (Status::PayloadTooLarge, 999, "payload too large"), + Error::NotFound => (Status::NotFound, 999, "not found"), + + Error::InvalidAuthToken => (Status::Unauthorized, 110, "invalid authentication token"), + + Error::Other(e) => { + error!("non-api error during request: {:?}", e); + (Status::InternalServerError, 999, "internal error") + }, + Error::UnexpectedStatus(s) => (s, 999, ""), + }; + let body = json!({ + "code": code.code, + "errno": errno, + "error": code.reason_lossy(), + "message": msg + }); + Response::build_from(Json(body).respond_to(request)?).status(code).ok() + } +} + +impl From for Error { + fn from(e: sqlx::Error) -> Self { + Error::Other(anyhow!(e)) + } +} + +impl From for Error { + fn from(e: anyhow::Error) -> Self { + Error::Other(e) + } +} + +pub(crate) type Result = std::result::Result, Error>; + +#[catch(default)] +pub(crate) fn catch_all(status: Status, r: &Request<'_>) -> Error { + match status.code { + // these three are caused by Json errors + 400 | 422 => Error::InvalidParameter, + 413 => Error::PayloadTooLarge, + // translate forbidden-because-token to the auth api error for firefox + 401 if r.invalid_token_used() => Error::InvalidAuthToken, + // generic unauthorized instead of 404 for eg wrong method or nonexistant endpoints + 401 | 404 => Error::Unauthorized, + _ => { + error!("caught unexpected error {status}"); + Error::UnexpectedStatus(status) + }, + } +} + +// MISSING GET /v1/email +// MISSING GET /v1/subscriptions +// MISSING GET /v1/uid +// MISSING GET /v1/display_name +// MISSING DELETE /v1/cache/:uid +// MISSING send profile:change webchannel event an avatar/name changes + +#[derive(Debug, Serialize)] +#[allow(non_snake_case)] +pub(crate) struct ProfileResp { + uid: Option, + email: Option, + locale: Option, + amrValues: Option>, + twoFactorAuthentication: bool, + displayName: Option, + // NOTE spec does not exist, fxa-profile-server schema says this field is optional, + // but fenix exceptions if it's null. + // NOTE it also *must* be a valid url, or fenix crashes entirely. + avatar: Absolute<'static>, + avatarDefault: bool, + subscriptions: Option>, +} + +#[get("/profile")] +pub(crate) async fn profile( + db: &DbConn, + cfg: &State, + auth: Authenticated<(), WithBearer>, +) -> Result { + let has_scope = |s| auth.context.implies(&Scope::borrowed(s)); + + let user = db.get_user_by_id(&auth.session).await?; + let (avatar, avatar_default) = if has_scope("profile:avatar") { + match db.get_user_avatar_id(&auth.session).await? { + Some(id) => (uri!(cfg.avatars_prefix(), avatar_get_img(id = id.to_string())), false), + None => ( + uri!(cfg.avatars_prefix(), avatar_get_img("00000000000000000000000000000000")), + true, + ), + } + } else { + (uri!(cfg.avatars_prefix(), avatar_get_img("00000000000000000000000000000000")), true) + }; + Ok(Json(ProfileResp { + uid: if has_scope("profile:uid") { Some(auth.session) } else { None }, + email: if has_scope("profile:email") { Some(user.email) } else { None }, + locale: None, + amrValues: None, + twoFactorAuthentication: false, + displayName: if has_scope("profile:display_name") { user.display_name } else { None }, + avatar, + avatarDefault: avatar_default, + subscriptions: None, + })) +} + +#[derive(Debug, Deserialize)] +#[allow(non_snake_case)] +pub(crate) struct DisplayNameReq { + displayName: String, +} + +#[post("/display_name", data = "")] +pub(crate) async fn display_name_post( + db: &DbConn, + db_pool: &Db, + pc: &State>, + defer: &DeferAction, + req: Authenticated, +) -> Result { + if !req.context.implies(&Scope::borrowed("profile:display_name:write")) { + return Err(Error::Unauthorized); + } + + db.set_user_name(&req.session, &req.body.displayName).await?; + match db.get_devices(&req.session).await { + Ok(devs) => defer.spawn_after_success("api::profile/display_name(post)", { + let (pc, db) = (Arc::clone(pc), db_pool.clone()); + async move { + let db = db.begin().await?; + pc.profile_updated(&db, &devs).await; + db.commit().await?; + Ok(()) + } + }), + Err(e) => warn!("profile_updated push failed: {e}"), + } + Ok(EMPTY) +} + +#[derive(Serialize)] +#[allow(non_snake_case)] +pub(crate) struct AvatarResp { + id: AvatarID, + avatarDefault: bool, + avatar: Absolute<'static>, +} + +#[get("/avatar")] +pub(crate) async fn avatar_get( + db: &DbConn, + cfg: &State, + req: Authenticated<(), WithBearer>, +) -> Result { + if !req.context.implies(&Scope::borrowed("profile:avatar")) { + return Err(Error::Unauthorized); + } + + let resp = match db.get_user_avatar_id(&req.session).await? { + Some(id) => { + let url = uri!(cfg.avatars_prefix(), avatar_get_img(id = id.to_string())); + AvatarResp { id, avatarDefault: false, avatar: url } + }, + None => { + let url = + uri!(cfg.avatars_prefix(), avatar_get_img("00000000000000000000000000000000")); + AvatarResp { id: AvatarID([0; 16]), avatarDefault: true, avatar: url } + }, + }; + Ok(Json(resp)) +} + +#[get("/")] +pub(crate) async fn avatar_get_img( + db: &DbConn, + id: &str, +) -> std::result::Result<(ContentType, Immutable, &'static [u8]>>), Error> { + let id = id.parse().map_err(|_| Error::NotFound)?; + + if id == AvatarID([0; 16]) { + return Ok(( + ContentType::SVG, + Immutable(Right(include_bytes!("../../../Raven-Silhouette.svg"))), + )); + } + + match db.get_user_avatar(&id).await? { + Some(avatar) => { + let ct = avatar.content_type.parse().expect("invalid content type in db"); + Ok((ct, Immutable(Left(avatar.data)))) + }, + None => Err(Error::NotFound), + } +} + +#[derive(Serialize)] +#[allow(non_snake_case)] +pub(crate) struct AvatarUploadResp { + url: Absolute<'static>, +} + +#[post("/avatar/upload", data = "")] +pub(crate) async fn avatar_upload( + db: &DbConn, + db_pool: &Db, + pc: &State>, + defer: &DeferAction, + cfg: &State, + ct: &ContentType, + req: Authenticated<(), WithBearer>, + data: Vec, +) -> Result { + if !req.context.implies(&Scope::borrowed("profile:avatar:write")) { + return Err(Error::Unauthorized); + } + if data.len() >= 128.kibibytes() { + return Err(Error::PayloadTooLarge); + } + + if !ct.is_png() + && !ct.is_gif() + && !ct.is_bmp() + && !ct.is_jpeg() + && !ct.is_webp() + && !ct.is_avif() + && !ct.is_svg() + { + return Err(Error::InvalidParameter); + } + + let mut sha = Sha256::new(); + sha.update(&req.session.0); + sha.update(&data); + let id = AvatarID(sha.finalize()[0..16].try_into().unwrap()); + + db.set_user_avatar(&req.session, Avatar { id: id.clone(), data, content_type: ct.to_string() }) + .await?; + match db.get_devices(&req.session).await { + Ok(devs) => defer.spawn_after_success("api::profile/avatar/upload(post)", { + let (pc, db) = (Arc::clone(pc), db_pool.clone()); + async move { + let db = db.begin().await?; + pc.profile_updated(&db, &devs).await; + db.commit().await?; + Ok(()) + } + }), + Err(e) => warn!("profile_updated push failed: {e}"), + } + + let url = uri!(cfg.avatars_prefix(), avatar_get_img(id = id.to_string())); + Ok(Json(AvatarUploadResp { url })) +} + +#[delete("/avatar/")] +pub(crate) async fn avatar_delete( + db: &DbConn, + id: &str, + req: Authenticated<(), WithBearer>, +) -> Result { + if !req.context.implies(&Scope::borrowed("profile:avatar:write")) { + return Err(Error::Unauthorized); + } + let id = id.parse().map_err(|_| Error::NotFound)?; + + db.delete_user_avatar(&req.session, &id).await?; + Ok(EMPTY) +} diff --git a/src/auth.rs b/src/auth.rs new file mode 100644 index 0000000..f56c5e2 --- /dev/null +++ b/src/auth.rs @@ -0,0 +1,241 @@ +use std::str::FromStr; +use std::time::Duration; + +use anyhow::Result; +use hawk::{DigestAlgorithm, Header, Key, PayloadHasher, RequestBuilder}; +use rocket::data::{self, FromData, ToByteUnit}; +use rocket::http::Status; +use rocket::outcome::{try_outcome, Outcome}; +use rocket::request::{local_cache, FromRequest, Request}; +use rocket::{request, Data, Ignite, Phase, Rocket, Sentinel}; +use serde::Deserialize; +use serde_json::error::Category; + +use crate::crypto::SecretBytes; +use crate::db::DbConn; +use crate::types::oauth::ScopeSet; +use crate::types::{OauthToken, UserID}; +use crate::Config; + +#[rocket::async_trait] +pub(crate) trait AuthSource { + type ID: FromStr + Send + Sync + Clone; + type Context: Send + Sync; + async fn hawk(r: &Request<'_>, id: &Self::ID) -> Result<(SecretBytes<32>, Self::Context)>; + async fn bearer_token(r: &Request<'_>, id: &OauthToken) -> Result<(Self::ID, Self::Context)>; +} + +// marker trait and type to communicate that authentication has failed with invalid +// tokens used. this is needed to properly translate these error for the profile api. +pub(crate) trait AuthenticatedRequest { + fn invalid_token_used(&self) -> bool; +} + +struct InvalidTokenUsed; + +impl<'r> AuthenticatedRequest for Request<'r> { + fn invalid_token_used(&self) -> bool { + self.local_cache(|| None as Option).is_some() + } +} + +#[derive(Debug)] +pub(crate) struct Authenticated { + pub body: T, + pub session: Src::ID, + pub context: Src::Context, +} + +enum AuthKind<'a> { + Hawk { header: Header }, + Token { token: &'a str }, +} + +fn drop_auth_prefix<'a>(s: &'a str, prefix: &str) -> Option<&'a str> { + if prefix.len() <= s.len() && s[..prefix.len()].eq_ignore_ascii_case(prefix) { + Some(&s[prefix.len()..]) + } else { + None + } +} + +impl Sentinel for Authenticated { + fn abort(rocket: &Rocket) -> bool { + // NOTE data sentinels are broken in rocket 0.5-rc2 + Self::try_get_state(rocket).is_none() || <&DbConn as Sentinel>::abort(rocket) + } +} + +impl Authenticated { + fn try_get_state(r: &Rocket) -> Option<&Config> { + r.state::() + } + + fn state(r: &Rocket) -> &Config { + Self::try_get_state(r).unwrap() + } + + async fn parse_auth<'a>( + request: &'a Request<'_>, + ) -> Outcome, (Status, anyhow::Error), ()> { + let auth = match request.headers().get("authorization").take(2).enumerate().last() { + Some((0, h)) => h, + Some((_, _)) => { + return Outcome::Failure(( + Status::BadRequest, + anyhow!("multiple authorization headers present"), + )) + }, + None => return Outcome::Forward(()), + }; + if let Some(hawk) = drop_auth_prefix(auth, "hawk ") { + match Header::from_str(hawk) { + Ok(header) => Outcome::Success(AuthKind::Hawk { header }), + Err(e) => Outcome::Failure(( + Status::Unauthorized, + anyhow!(e).context("malformed hawk header"), + )), + } + } else if let Some(token) = drop_auth_prefix(auth, "bearer ") { + Outcome::Success(AuthKind::Token { token }) + } else { + Outcome::Forward(()) + } + } + + pub async fn get_conn<'r>(req: &'r Request<'_>) -> Result<&'r DbConn> { + match <&'r DbConn as FromRequest<'r>>::from_request(req).await { + Outcome::Success(db) => Ok(db), + Outcome::Failure((_, e)) => Err(e.context("get db connection")), + _ => Err(anyhow!("could not get db connection")), + } + } + + async fn verify_hawk( + request: &Request<'_>, + hawk: Header, + data: Option<&str>, + ) -> Result<(Src::ID, Src::Context)> { + let cfg = Self::state(request.rocket()); + let url = format!("{}{}", cfg.location, request.uri()); + let url = url::Url::parse(&url).unwrap(); + let hash = data + .map(|d| PayloadHasher::hash("application/json", DigestAlgorithm::Sha256, d)) + .transpose()?; + let hawk_req = RequestBuilder::from_url(request.method().as_str(), &url)?; + let hawk_req = match hash.as_ref() { + Some(h) => hawk_req.hash(Some(h.as_ref())).request(), + _ => hawk_req.request(), + }; + let id: Src::ID = + match hawk.id.clone().ok_or_else(|| anyhow!("missing hawk key id"))?.parse() { + Ok(id) => id, + Err(_) => bail!("malformed hawk key id"), + }; + let (key, context) = Src::hawk(request, &id).await?; + let key = Key::new(&key.0, DigestAlgorithm::Sha256)?; + // large skew was taken from fxa-auth-server, large clock skews seem to happen + if !hawk_req.validate_header(&hawk, &key, Duration::from_secs(20 * 365 * 86400)) { + bail!("bad hawk signature"); + } + Ok((id, context)) + } + + async fn verify_bearer_token( + request: &Request<'_>, + token: &str, + ) -> Result<(Src::ID, Src::Context)> { + let token = match token.parse() { + Ok(token) => token, + Err(_) => bail!("malformed oauth token"), + }; + Src::bearer_token(request, &token).await + } +} + +#[rocket::async_trait] +impl<'r, Src: AuthSource> FromRequest<'r> for Authenticated<(), Src> { + type Error = anyhow::Error; + + async fn from_request(request: &'r Request<'_>) -> request::Outcome { + let auth = try_outcome!(Self::parse_auth(request).await); + let result = match auth { + AuthKind::Hawk { header } => Self::verify_hawk(request, header, None).await, + AuthKind::Token { token } => Self::verify_bearer_token(request, token).await, + }; + match result { + Ok((session, context)) => { + Outcome::Success(Authenticated { body: (), session, context }) + }, + Err(e) => { + request.local_cache(|| Some(InvalidTokenUsed)); + Outcome::Failure((Status::Unauthorized, anyhow!(e))) + }, + } + } +} + +#[rocket::async_trait] +impl<'r, T: Deserialize<'r>, Src: AuthSource> FromData<'r> for Authenticated { + type Error = anyhow::Error; + + async fn from_data(request: &'r Request<'_>, data: Data<'r>) -> data::Outcome<'r, Self> { + let auth = try_outcome_data!(data, Self::parse_auth(request).await); + let limit = + request.rocket().config().limits.get("json").unwrap_or_else(|| 1u32.mebibytes()); + let raw_json = match data.open(limit).into_string().await { + Ok(r) if r.is_complete() => local_cache!(request, r.into_inner()), + Ok(_) => { + return data::Outcome::Failure(( + Status::PayloadTooLarge, + anyhow!("request too large"), + )) + }, + Err(e) => return data::Outcome::Failure((Status::InternalServerError, e.into())), + }; + let verify_result = match auth { + AuthKind::Hawk { header } => Self::verify_hawk(request, header, Some(raw_json)).await, + AuthKind::Token { token } => Self::verify_bearer_token(request, token).await, + }; + let result = match verify_result { + Ok((session, context)) => { + serde_json::from_str(raw_json).map(|body| Authenticated { body, session, context }) + }, + Err(e) => { + request.local_cache(|| Some(InvalidTokenUsed)); + return Outcome::Failure((Status::Unauthorized, anyhow!(e))); + }, + }; + match result { + Ok(r) => Outcome::Success(r), + Err(e) => { + // match Json here to keep catchers generic + let status = match e.classify() { + Category::Data => Status::UnprocessableEntity, + _ => Status::BadRequest, + }; + Outcome::Failure((status, anyhow!(e))) + }, + } + } +} + +#[derive(Debug)] +pub(crate) struct WithBearer; + +#[rocket::async_trait] +impl crate::auth::AuthSource for WithBearer { + type ID = UserID; + type Context = ScopeSet; + async fn hawk(_r: &Request<'_>, _id: &Self::ID) -> Result<(SecretBytes<32>, Self::Context)> { + bail!("hawk signatures not allowed here") + } + async fn bearer_token( + r: &Request<'_>, + token: &OauthToken, + ) -> Result<(Self::ID, Self::Context)> { + let db = Authenticated::<(), Self>::get_conn(r).await?; + let t = db.get_access_token(&token.hash()).await?; + Ok((t.user_id, t.scope)) + } +} diff --git a/src/bin/minorskulk.rs b/src/bin/minorskulk.rs new file mode 100644 index 0000000..1609edb --- /dev/null +++ b/src/bin/minorskulk.rs @@ -0,0 +1,9 @@ +use minor_skulk::build; + +#[rocket::main] +async fn main() -> anyhow::Result<()> { + dotenv::dotenv().ok(); + + let _ = build().await?.launch().await?; + Ok(()) +} diff --git a/src/cache.rs b/src/cache.rs new file mode 100644 index 0000000..680d9da --- /dev/null +++ b/src/cache.rs @@ -0,0 +1,42 @@ +use std::borrow::Cow; + +use rocket::{ + http::Header, + request::{self, FromRequest}, + response::{self, Responder}, + Request, +}; + +pub(crate) struct Etagged<'r, T>(pub T, pub Cow<'r, str>); + +impl<'r, 'o: 'r, T: Responder<'r, 'o>> Responder<'r, 'o> for Etagged<'o, T> { + fn respond_to(self, r: &'r Request<'_>) -> response::Result<'o> { + let mut resp = self.0.respond_to(r)?; + resp.set_header(Header::new("etag", self.1)); + Ok(resp) + } +} + +pub(crate) struct Immutable(pub T); + +impl<'r, 'o: 'r, T: Responder<'r, 'o>> Responder<'r, 'o> for Immutable { + fn respond_to(self, r: &'r Request<'_>) -> response::Result<'o> { + let mut resp = self.0.respond_to(r)?; + resp.set_header(Header::new("cache-control", "public, max-age=604800, immutable")); + Ok(resp) + } +} + +pub(crate) struct IfNoneMatch<'r>(pub &'r str); + +#[async_trait] +impl<'r> FromRequest<'r> for IfNoneMatch<'r> { + type Error = (); + + async fn from_request(req: &'r Request<'_>) -> request::Outcome { + match req.headers().get_one("if-none-match") { + Some(h) => request::Outcome::Success(Self(h)), + None => request::Outcome::Forward(()), + } + } +} diff --git a/src/crypto.rs b/src/crypto.rs new file mode 100644 index 0000000..cf1044e --- /dev/null +++ b/src/crypto.rs @@ -0,0 +1,408 @@ +#![deny(clippy::pedantic)] +#![deny(clippy::restriction)] +#![allow(clippy::blanket_clippy_restriction_lints)] +#![allow(clippy::implicit_return)] +#![allow(clippy::missing_docs_in_private_items)] +#![allow(clippy::shadow_reuse)] + +use std::fmt::Debug; + +use hmac::{Hmac, Mac}; +use password_hash::{Output, Salt}; +use rand::RngCore; +use scrypt::scrypt; +use serde::{Deserialize, Serialize}; +use sha2::Sha256; + +const NAMESPACE: &[u8] = b"identity.mozilla.com/picl/v1/"; + +#[derive(Clone, PartialEq, Eq, Zeroize, Serialize, Deserialize)] +#[serde(try_from = "String", into = "String")] +pub struct SecretBytes(pub [u8; N]); + +impl Drop for SecretBytes { + fn drop(&mut self) { + self.zeroize(); + } +} + +#[derive(Clone, PartialEq, Eq)] +pub struct TokenID(pub [u8; 32]); + +impl Debug for SecretBytes { + fn fmt(&self, fmt: &mut std::fmt::Formatter<'_>) -> Result<(), std::fmt::Error> { + fmt.write_fmt(format_args!("SecretBytes {{ raw: {} }}", hex::encode(&self.0))) + } +} + +impl SecretBytes { + fn xor(&self, other: &Self) -> Self { + let mut result = self.clone(); + for (a, b) in result.0.iter_mut().zip(other.0.iter()) { + *a ^= b; + } + result + } +} + +impl From> for String { + fn from(sb: SecretBytes) -> Self { + hex::encode(&sb.0) + } +} + +impl TryFrom for SecretBytes { + type Error = hex::FromHexError; + + fn try_from(value: String) -> Result { + let mut result = Self([0; N]); + hex::decode_to_slice(value, &mut result.0)?; + Ok(result) + } +} + +impl From> for Output { + fn from(s: SecretBytes<32>) -> Output { + #[allow(clippy::unwrap_used)] + Output::new(&s.0).unwrap() + } +} + +mod from_hkdf { + use hkdf::Hkdf; + use sha2::Sha256; + + // sealing lets us guarantee that SIZE is always correct, + // which means that from_hkdf always receives correctly sized slices + // and copies never fail + mod private { + pub trait Seal {} + impl Seal for super::super::SecretBytes {} + impl Seal for super::super::TokenID {} + impl Seal for (L, R) {} + } + + pub trait FromHkdf: private::Seal { + const SIZE: usize; + fn from_hkdf(bytes: &[u8]) -> Self; + } + + impl FromHkdf for super::SecretBytes { + const SIZE: usize = N; + fn from_hkdf(bytes: &[u8]) -> Self { + #[allow(clippy::unwrap_used)] + Self(bytes.try_into().unwrap()) + } + } + + impl FromHkdf for super::TokenID { + const SIZE: usize = 32; + fn from_hkdf(bytes: &[u8]) -> Self { + #[allow(clippy::expect_used)] + Self(bytes.try_into().expect("hkdf failed")) + } + } + + impl FromHkdf for (L, R) { + const SIZE: usize = L::SIZE + R::SIZE; + #[allow(clippy::indexing_slicing)] + fn from_hkdf(bytes: &[u8]) -> Self { + (L::from_hkdf(&bytes[0..L::SIZE]), R::from_hkdf(&bytes[L::SIZE..])) + } + } + + pub fn from_hkdf(key: &[u8], info: &[&[u8]]) -> O { + let hk = Hkdf::::new(None, key); + let mut buf = vec![0; O::SIZE]; + #[allow(clippy::expect_used)] + // worth keeping an eye out for very large results (>255*32 bytes) + hk.expand_multi_info(info, buf.as_mut_slice()).expect("hkdf failed"); + O::from_hkdf(&buf) + } +} + +use from_hkdf::from_hkdf; +use zeroize::Zeroize; + +impl SecretBytes { + pub fn generate() -> Self { + let mut result = Self([0; N]); + rand::rngs::OsRng.fill_bytes(&mut result.0); + result + } +} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(transparent)] +pub struct AuthPW { + pub pw: SecretBytes<32>, +} + +pub struct StretchedPW { + pub pw: Output, +} + +impl AuthPW { + pub fn stretch(&self, salt: Salt) -> anyhow::Result { + let mut result = [0; 32]; + let mut buf = [0; Salt::MAX_LENGTH]; + let params = scrypt::Params::new(16, 8, 1)?; + let salt = salt.b64_decode(&mut buf)?; + scrypt(&self.pw.0, salt, ¶ms, &mut result)?; + Ok(StretchedPW { pw: Output::new(&result)? }) + } +} + +impl StretchedPW { + pub fn verify_hash(&self) -> Output { + let raw: SecretBytes<32> = from_hkdf(self.pw.as_bytes(), &[NAMESPACE, b"verifyHash"]); + raw.into() + } + + fn wrap_wrap_key(&self) -> SecretBytes<32> { + from_hkdf(self.pw.as_bytes(), &[NAMESPACE, b"wrapwrapKey"]) + } + + pub fn decrypt_wwkb(&self, wwkb: &SecretBytes<32>) -> SecretBytes<32> { + wwkb.xor(&self.wrap_wrap_key()) + } + + pub fn rewrap_wkb(&self, wkb: &SecretBytes<32>) -> SecretBytes<32> { + wkb.xor(&self.wrap_wrap_key()) + } +} + +pub struct SessionCredentials { + pub token_id: TokenID, + pub req_hmac_key: SecretBytes<32>, +} + +impl SessionCredentials { + pub fn derive(seed: &SecretBytes<32>) -> Self { + let (token_id, req_hmac_key) = from_hkdf(&seed.0, &[NAMESPACE, b"sessionToken"]); + Self { token_id, req_hmac_key } + } +} + +pub struct KeyFetchReq { + pub token_id: TokenID, + pub req_hmac_key: SecretBytes<32>, + pub key_request_key: SecretBytes<32>, +} + +impl KeyFetchReq { + pub fn from_token(key_fetch_token: &SecretBytes<32>) -> Self { + let (token_id, (req_hmac_key, key_request_key)) = + from_hkdf(&key_fetch_token.0, &[NAMESPACE, b"keyFetchToken"]); + Self { token_id, req_hmac_key, key_request_key } + } + + pub fn derive_resp(&self) -> KeyFetchResp { + let (resp_hmac_key, resp_xor_key) = + from_hkdf(&self.key_request_key.0, &[NAMESPACE, b"account/keys"]); + KeyFetchResp { resp_hmac_key, resp_xor_key } + } +} + +pub struct KeyFetchResp { + pub resp_hmac_key: SecretBytes<32>, + pub resp_xor_key: SecretBytes<64>, +} + +impl KeyFetchResp { + pub fn wrap_keys(&self, keys: &KeyBundle) -> WrappedKeyBundle { + let ciphertext = self.resp_xor_key.xor(&keys.to_bytes()); + #[allow(clippy::unwrap_used)] + let mut hmac = Hmac::::new_from_slice(&self.resp_hmac_key.0).unwrap(); + hmac.update(&ciphertext.0); + let hmac = *hmac.finalize().into_bytes().as_ref(); + WrappedKeyBundle { ciphertext, hmac } + } +} + +pub struct KeyBundle { + pub ka: SecretBytes<32>, + pub wrap_kb: SecretBytes<32>, +} + +impl KeyBundle { + pub fn to_bytes(&self) -> SecretBytes<64> { + let mut result = SecretBytes([0; 64]); + result.0[0..32].copy_from_slice(&self.ka.0); + result.0[32..].copy_from_slice(&self.wrap_kb.0); + result + } +} + +#[derive(Debug)] +pub struct WrappedKeyBundle { + pub ciphertext: SecretBytes<64>, + pub hmac: [u8; 32], +} + +impl WrappedKeyBundle { + pub fn to_bytes(&self) -> [u8; 96] { + let mut result = [0; 96]; + result[0..64].copy_from_slice(&self.ciphertext.0); + result[64..].copy_from_slice(&self.hmac); + result + } +} + +pub struct PasswordChangeReq { + pub token_id: TokenID, + pub req_hmac_key: SecretBytes<32>, +} + +impl PasswordChangeReq { + pub fn from_change_token(token: &SecretBytes<32>) -> Self { + let (token_id, req_hmac_key) = from_hkdf(&token.0, &[NAMESPACE, b"passwordChangeToken"]); + Self { token_id, req_hmac_key } + } + + pub fn from_forgot_token(token: &SecretBytes<32>) -> Self { + let (token_id, req_hmac_key) = from_hkdf(&token.0, &[NAMESPACE, b"passwordForgotToken"]); + Self { token_id, req_hmac_key } + } +} + +pub struct AccountResetReq { + pub token_id: TokenID, + pub req_hmac_key: SecretBytes<32>, +} + +impl AccountResetReq { + pub fn from_token(token: &SecretBytes<32>) -> Self { + let (token_id, req_hmac_key) = from_hkdf(&token.0, &[NAMESPACE, b"accountResetToken"]); + Self { token_id, req_hmac_key } + } +} + +#[cfg(test)] +mod test { + use hex_literal::hex; + use password_hash::{Output, SaltString}; + + use crate::crypto::{KeyBundle, KeyFetchReq, SessionCredentials}; + + use super::{AuthPW, SecretBytes}; + + macro_rules! shex { + ( $s: literal ) => { + SecretBytes(hex!($s)) + }; + } + + #[test] + fn test_derive_session() { + let creds = SessionCredentials::derive(&SecretBytes(hex!( + "a0a1a2a3a4a5a6a7 a8a9aaabacadaeaf b0b1b2b3b4b5b6b7 b8b9babbbcbdbebf" + ))); + assert_eq!( + creds.token_id.0, + hex!("c0a29dcf46174973da1378696e4c82ae10f723cf4f4d9f75e39f4ae3851595ab") + ); + assert_eq!( + creds.req_hmac_key.0, + hex!("9d8f22998ee7f579 8b887042466b72d5 3e56ab0c094388bf 65831f702d2febc0") + ); + } + + #[test] + fn test_key_fetch() { + let key_fetch = KeyFetchReq::from_token(&shex!( + "8081828384858687 88898a8b8c8d8e8f 9091929394959697 98999a9b9c9d9e9f" + )); + assert_eq!( + key_fetch.token_id.0, + hex!("3d0a7c02a15a62a2882f76e39b6494b500c022a8816e048625a495718998ba60") + ); + assert_eq!( + key_fetch.req_hmac_key.0, + hex!("87b8937f61d38d0e 29cd2d5600b3f4da 0aa48ac41de36a0e fe84bb4a9872ceb7") + ); + assert_eq!( + key_fetch.key_request_key.0, + hex!("14f338a9e8c6324d 9e102d4e6ee83b20 9796d5c74bb734a4 10e729e014a4a546") + ); + + let resp = key_fetch.derive_resp(); + assert_eq!( + resp.resp_hmac_key.0, + hex!("f824d2953aab9faf 51a1cb65ba9e7f9e 5bf91c8d8fd1ac1c 8c2d31853a8a1210") + ); + assert_eq!( + resp.resp_xor_key.0, + hex!( + "ce7d7aa77859b235 9932970bbe2101f2 e80d01faf9191bd5 ee52181d2f0b7809 + 8281ba8cff392543 3a89f7c3095e0c89 900a469d60790c83 3281c4df1a11c763" + ) + ); + + let bundle = KeyBundle { + ka: shex!("2021222324252627 28292a2b2c2d2e2f 3031323334353637 38393a3b3c3d3e3f"), + wrap_kb: shex!("7effe354abecbcb2 34a8dfc2d7644b4a d339b525589738f2 d27341bb8622ecd8"), + }; + assert_eq!( + bundle.to_bytes().0, + hex!( + "2021222324252627 28292a2b2c2d2e2f 3031323334353637 38393a3b3c3d3e3f + 7effe354abecbcb2 34a8dfc2d7644b4a d339b525589738f2 d27341bb8622ecd8" + ) + ); + + let wrapped = resp.wrap_keys(&bundle); + assert_eq!( + wrapped.ciphertext.0, + hex!( + "ee5c58845c7c9412 b11bbd20920c2fdd d83c33c9cd2c2de2 d66b222613364636 + fc7e59d854d599f1 0e212801de3a47c3 4333f3b838ee3471 e0f285649c332bbb" + ) + ); + assert_eq!( + wrapped.hmac, + hex!("4c17f42a0b319bbb a327d2b326ad23e9 37219b4de32e3ec7 b3e3f740522ad6ef") + ); + assert_eq!( + wrapped.to_bytes(), + hex!( + "ee5c58845c7c9412 b11bbd20920c2fdd d83c33c9cd2c2de2 d66b222613364636 + fc7e59d854d599f1 0e212801de3a47c3 4333f3b838ee3471 e0f285649c332bbb + 4c17f42a0b319bbb a327d2b326ad23e9 37219b4de32e3ec7 b3e3f740522ad6ef" + ) + ); + } + + #[test] + fn test_stretch() -> anyhow::Result<()> { + let auth_pw = AuthPW { + pw: SecretBytes(hex!( + "247b675ffb4c4631 0bc87e26d712153a be5e1c90ef00a478 4594f97ef54f2375" + )), + }; + + let stretched = auth_pw.stretch( + SaltString::b64_encode(&hex!( + "00f0000000000000 0000000000000000 0000000000000000 0000000000000000" + ))? + .as_salt(), + )?; + assert_eq!( + stretched.pw, + Output::new(&hex!( + "441509e25c92ee10 3d5a1a874e6f155d f25a44d06e61c894 616c9e85181dba97" + ))? + ); + + assert_eq!( + stretched.verify_hash().as_bytes(), + hex!("a4765bf103dc057f 4cf4bc2c131ddb67 16e8a4333cc55e1d 3c449f31f0eec4f1") + ); + + assert_eq!( + stretched.wrap_wrap_key().0, + hex!("3ebea117efa9faf5 7ce195899b290505 8368e7760cc26ea5 8a2a1be0da7fb287") + ); + Ok(()) + } +} diff --git a/src/db/mod.rs b/src/db/mod.rs new file mode 100644 index 0000000..040507f --- /dev/null +++ b/src/db/mod.rs @@ -0,0 +1,1026 @@ +use std::{error::Error, mem::replace, sync::Arc}; + +use anyhow::Result; +use chrono::{DateTime, Duration, Utc}; +use password_hash::SaltString; +use rocket::{ + fairing::{self, Fairing}, + futures::lock::{MappedMutexGuard, Mutex, MutexGuard}, + http::Status, + request::{FromRequest, Outcome}, + Request, Response, Sentinel, +}; +use serde_json::Value; +use sqlx::{query, query_as, query_scalar, PgPool, Postgres, Transaction}; + +use crate::{ + crypto::WrappedKeyBundle, + types::{ + oauth::ScopeSet, AccountResetID, AttachedClient, Avatar, AvatarID, Device, DeviceCommand, + DeviceCommands, DeviceID, DevicePush, DeviceUpdate, HawkKey, KeyFetchID, OauthAccessToken, + OauthAccessType, OauthAuthorization, OauthAuthorizationID, OauthRefreshToken, OauthTokenID, + PasswordChangeID, SecretKey, SessionID, User, UserID, UserSession, VerifyCode, VerifyHash, + }, +}; + +// we implement a completely custom db type set instead of using rocket_db_pools for two reasons: +// 1. rocket_db_pools doesn't support sqlx 0.6 +// 2. we want one transaction per *request*, including all guards + +#[derive(Clone)] +pub struct Db { + db: Arc, +} + +impl Db { + pub async fn connect(db: &str) -> Result { + Ok(Self { db: Arc::new(PgPool::connect(db).await?) }) + } + + pub async fn begin(&self) -> Result { + Ok(DbConn(Mutex::new(ConnState::Capable(self.clone())))) + } + + pub async fn migrate(&self) -> Result<()> { + sqlx::migrate!().run(&*self.db).await?; + Ok(()) + } +} + +struct ActiveConn { + tx: Transaction<'static, Postgres>, + always_commit: bool, +} + +#[allow(clippy::large_enum_variant)] +enum ConnState { + None, + Capable(Db), + Active(ActiveConn), + Done, +} + +pub struct DbConn(Mutex); + +struct DbWrap(Db); +struct DbConnWrap(DbConn); +type DbLock<'c> = MappedMutexGuard<'c, ConnState, ActiveConn>; + +#[rocket::async_trait] +impl Fairing for Db { + fn info(&self) -> fairing::Info { + fairing::Info { + name: "db access", + kind: fairing::Kind::Ignite | fairing::Kind::Response | fairing::Kind::Singleton, + } + } + + async fn on_ignite(&self, rocket: rocket::Rocket) -> fairing::Result { + Ok(rocket.manage(DbWrap(self.clone())).manage(self.clone())) + } + + async fn on_response<'r>(&self, req: &'r Request<'_>, resp: &mut Response<'r>) { + let conn = req.local_cache(|| DbConnWrap(DbConn(Mutex::new(ConnState::None)))); + let s = replace(&mut *conn.0 .0.lock().await, ConnState::Done); + if let ConnState::Active(ActiveConn { tx, always_commit }) = s { + // don't commit if the request failed, unless explicitly asked to. + // this is used by key fetch to invalidate tokens even if the header + // signature of the request is incorrect. + if !always_commit && resp.status() != Status::Ok { + return; + } + if let Err(e) = tx.commit().await { + resp.set_status(Status::InternalServerError); + error!("commit failed: {}", e); + } + } + } +} + +impl DbConn { + pub async fn commit(self) -> Result<(), sqlx::Error> { + match self.0.into_inner() { + ConnState::None => Ok(()), + ConnState::Capable(_) => Ok(()), + ConnState::Active(ac) => ac.tx.commit().await, + ConnState::Done => Ok(()), + } + } + + fn register<'r>(req: &'r Request<'_>) -> &'r DbConn { + &req.local_cache(|| { + let db = req.rocket().state::().unwrap().0.clone(); + DbConnWrap(DbConn(Mutex::new(ConnState::Capable(db)))) + }) + .0 + } + + // defer opening a transaction and thus locking the mutex holding it. + // without deferral the order of arguments in route signatures is important, + // which may be surprising: placing a DbConn before a guard that also uses + // the database causes a concurrent transaction error. + // HACK maybe we should return errors instead of panicking, but there's no + // way an error here is not a severe bug + async fn get(&self) -> sqlx::Result> { + let mut m = match self.0.try_lock() { + Some(m) => m, + None => panic!("attempted to open concurrent transactions"), + }; + match &*m { + ConnState::Capable(db) => { + *m = ConnState::Active(ActiveConn { + tx: db.db.begin().await?, + always_commit: false, + }); + }, + ConnState::None | ConnState::Done => panic!("db connection requested after teardown"), + _ => (), + } + Ok(MutexGuard::map(m, |g| match g { + ConnState::Active(ref mut tx) => tx, + _ => unreachable!(), + })) + } +} + +impl<'r> Sentinel for &'r Db { + fn abort(rocket: &rocket::Rocket) -> bool { + rocket.state::().is_none() + } +} + +#[rocket::async_trait] +impl<'r> FromRequest<'r> for &'r Db { + type Error = anyhow::Error; + + async fn from_request(req: &'r Request<'_>) -> Outcome { + Outcome::Success(&req.rocket().state::().unwrap().0) + } +} + +impl<'r> Sentinel for &'r DbConn { + fn abort(rocket: &rocket::Rocket) -> bool { + rocket.state::().is_none() + } +} + +#[rocket::async_trait] +impl<'r> FromRequest<'r> for &'r DbConn { + type Error = anyhow::Error; + + async fn from_request(req: &'r Request<'_>) -> Outcome { + Outcome::Success(DbConn::register(req)) + } +} + +// +// +// + +// +// +// + +impl DbConn { + pub(crate) async fn always_commit(&self) -> Result<()> { + self.get().await?.always_commit = true; + Ok(()) + } + + // + // + // + + pub(crate) async fn add_session( + &self, + id: SessionID, + user_id: &UserID, + key: HawkKey, + verified: bool, + verify_code: Option<&str>, + ) -> sqlx::Result> { + query_scalar!( + r#"insert into user_session (session_id, user_id, req_hmac_key, device_id, verified, + verify_code) + values ($1, $2, $3, null, $4, $5) + returning created_at"#, + id as _, + user_id as _, + key as _, + verified, + verify_code, + ) + .fetch_one(&mut self.get().await?.tx) + .await + } + + pub(crate) async fn use_session(&self, id: &SessionID) -> sqlx::Result { + query_as!( + UserSession, + r#"update user_session + set last_active = now() + where session_id = $1 + returning user_id as "uid: UserID", req_hmac_key as "req_hmac_key: HawkKey", + device_id as "device_id: DeviceID", created_at, verified, verify_code"#, + id as _ + ) + .fetch_one(&mut self.get().await?.tx) + .await + } + + pub(crate) async fn use_session_from_refresh( + &self, + id: &OauthTokenID, + ) -> sqlx::Result<(SessionID, UserSession)> { + query!( + r#"update user_session + set last_active = now() + where session_id = ( + select session_id from oauth_token where kind = 'refresh' and id = $1 + ) + returning user_id as "uid: UserID", req_hmac_key as "req_hmac_key: HawkKey", + device_id as "device_id: DeviceID", session_id as "session_id: SessionID", + created_at, verified, verify_code"#, + id as _ + ) + .map(|r| { + ( + r.session_id.clone(), + UserSession { + uid: r.uid, + req_hmac_key: r.req_hmac_key, + device_id: r.device_id, + created_at: r.created_at, + verified: r.verified, + verify_code: r.verify_code, + }, + ) + }) + .fetch_one(&mut self.get().await?.tx) + .await + } + + pub(crate) async fn delete_session(&self, user: &UserID, id: &SessionID) -> sqlx::Result<()> { + query_scalar!( + r#"delete from user_session + where user_id = $1 and session_id = $2 + returning 1"#, + user as _, + id as _ + ) + .fetch_one(&mut self.get().await?.tx) + .await?; + Ok(()) + } + + pub(crate) async fn set_session_device<'d>( + &self, + id: &SessionID, + dev: Option<&'d DeviceID>, + ) -> sqlx::Result<()> { + query!( + r#"update user_session set device_id = $1 where session_id = $2"#, + dev as _, + id as _ + ) + .execute(&mut self.get().await?.tx) + .await?; + Ok(()) + } + + pub(crate) async fn set_session_verified(&self, id: &SessionID) -> sqlx::Result<()> { + query_scalar!( + r#"update user_session + set verified = true, verify_code = null + where session_id = $1 + returning 1"#, + id as _ + ) + .fetch_one(&mut self.get().await?.tx) + .await?; + Ok(()) + } + + // + // + // + + pub(crate) async fn enqueue_command( + &self, + target: &DeviceID, + sender: &Option, + command: &str, + payload: &Value, + ttl: u32, + ) -> sqlx::Result { + let expires = Utc::now() + Duration::seconds(ttl as i64); + query!( + r#"insert into device_commands (device_id, command, payload, expires, sender) + values ($1, $2, $3, $4, $5) + returning index"#, + target as _, + command, + payload, + expires, + sender.as_ref().map(ToString::to_string) + ) + .map(|x| x.index) + .fetch_one(&mut self.get().await?.tx) + .await + } + + pub(crate) async fn get_commands( + &self, + user: &UserID, + dev: &DeviceID, + min_index: i64, + limit: i64, + ) -> sqlx::Result<(bool, Vec)> { + // NOTE while fxa api docs state that command queries return only commands enqueued + // *after* index, what pushbox actually does is return commands *starting at* index! + let mut results = query_as!( + DeviceCommand, + r#"select index, command, payload, expires, sender + from device_commands join device using (device_id) + where index >= $1 and device_id = $2 and user_id = $3 + order by index + limit $4"#, + min_index, + dev as _, + user as _, + limit + 1 + ) + .fetch_all(&mut self.get().await?.tx) + .await?; + let more = results.len() > limit as usize; + results.truncate(limit as usize); + Ok((more, results)) + } + + // + // + // + + pub(crate) async fn get_devices(&self, user: &UserID) -> sqlx::Result> { + query_as!( + Device, + r#"select d.device_id as "device_id: DeviceID", d.name, d.type as type_, + d.push as "push: DevicePush", + d.available_commands as "available_commands: DeviceCommands", + d.push_expired, d.location, coalesce(us.last_active, to_timestamp(0)) as "last_active!" + from device d left join user_session us using (device_id) + where d.user_id = $1"#, + user as _ + ) + .fetch_all(&mut self.get().await?.tx) + .await + } + + pub(crate) async fn get_device(&self, user: &UserID, dev: &DeviceID) -> sqlx::Result { + query_as!( + Device, + r#"select d.device_id as "device_id: DeviceID", d.name, d.type as type_, + d.push as "push: DevicePush", + d.available_commands as "available_commands: DeviceCommands", + d.push_expired, d.location, coalesce(us.last_active, to_timestamp(0)) as "last_active!" + from device d left join user_session us using (device_id) + where d.user_id = $1 and d.device_id = $2"#, + user as _, + dev as _ + ) + .fetch_one(&mut self.get().await?.tx) + .await + } + + pub(crate) async fn change_device<'d>( + &self, + user: &UserID, + id: &DeviceID, + dev: DeviceUpdate<'d>, + ) -> sqlx::Result { + query_as!( + Device, + r#"select device_id as "device_id!: DeviceID", name as "name!", type as "type_!", + push as "push: DevicePush", + available_commands as "available_commands!: DeviceCommands", + push_expired as "push_expired!", location as "location!", + coalesce(last_active, to_timestamp(0)) as "last_active!" + from insert_or_update_device($1, $2, $3, $4, $5, $6, $7) as iud + left join user_session using (device_id)"#, + id as _, + user as _, + // these two are not optional but are Option anyway. the db will + // refuse insertions that don't have them set. + dev.name, + dev.type_, + dev.push as _, + dev.available_commands as _, + dev.location + ) + .fetch_one(&mut self.get().await?.tx) + .await + } + + pub(crate) async fn set_push_expired(&self, dev: &DeviceID) -> sqlx::Result<()> { + query!( + r#"update device + set push_expired = true + where device_id = $1"#, + dev as _ + ) + .execute(&mut self.get().await?.tx) + .await?; + Ok(()) + } + + pub(crate) async fn delete_device(&self, user: &UserID, dev: &DeviceID) -> sqlx::Result<()> { + query_scalar!( + r#"delete from device where user_id = $1 and device_id = $2 returning 1"#, + user as _, + dev as _ + ) + .fetch_one(&mut self.get().await?.tx) + .await?; + Ok(()) + } + + // + // + // + + pub(crate) async fn add_key_fetch( + &self, + id: KeyFetchID, + hmac_key: &HawkKey, + keys: &WrappedKeyBundle, + ) -> sqlx::Result<()> { + query!( + r#"insert into key_fetch (id, hmac_key, keys) values ($1, $2, $3)"#, + id as _, + hmac_key as _, + &keys.to_bytes()[..] + ) + .execute(&mut self.get().await?.tx) + .await?; + Ok(()) + } + + pub(crate) async fn finish_key_fetch( + &self, + id: &KeyFetchID, + ) -> sqlx::Result<(HawkKey, Vec)> { + query!( + r#"delete from key_fetch + where id = $1 and expires_at > now() + returning hmac_key as "hmac_key: HawkKey", keys"#, + id as _ + ) + .map(|r| (r.hmac_key, r.keys)) + .fetch_one(&mut self.get().await?.tx) + .await + } + + // + // + // + + pub(crate) async fn get_refresh_token( + &self, + id: &OauthTokenID, + ) -> sqlx::Result { + query_as!( + OauthRefreshToken, + r#"select user_id as "user_id: UserID", client_id, scope as "scope: ScopeSet", + session_id as "session_id: SessionID" + from oauth_token + where id = $1 and kind = 'refresh'"#, + id as _ + ) + .fetch_one(&mut self.get().await?.tx) + .await + } + + pub(crate) async fn get_access_token( + &self, + id: &OauthTokenID, + ) -> sqlx::Result { + query_as!( + OauthAccessToken, + r#"select user_id as "user_id: UserID", client_id, scope as "scope: ScopeSet", + parent_refresh as "parent_refresh: OauthTokenID", + parent_session as "parent_session: SessionID", + expires_at as "expires_at!" + from oauth_token + where id = $1 and kind = 'access' and expires_at > now()"#, + id as _ + ) + .fetch_one(&mut self.get().await?.tx) + .await + } + + pub(crate) async fn add_refresh_token( + &self, + id: &OauthTokenID, + token: OauthRefreshToken, + ) -> sqlx::Result<()> { + query!( + r#"insert into oauth_token (id, kind, user_id, client_id, scope, session_id) + values ($1, 'refresh', $2, $3, $4, $5)"#, + id as _, + token.user_id as _, + token.client_id, + token.scope as _, + token.session_id as _ + ) + .execute(&mut self.get().await?.tx) + .await?; + Ok(()) + } + + pub(crate) async fn add_access_token( + &self, + id: &OauthTokenID, + token: OauthAccessToken, + ) -> sqlx::Result<()> { + query!( + r#"insert into oauth_token (id, kind, user_id, client_id, scope, session_id, + parent_refresh, parent_session, expires_at) + values ($1, 'access', $2, $3, $4, null, $5, $6, $7)"#, + id as _, + token.user_id as _, + token.client_id, + token.scope as _, + token.parent_refresh as _, + token.parent_session as _, + token.expires_at, + ) + .execute(&mut self.get().await?.tx) + .await?; + Ok(()) + } + + pub(crate) async fn delete_oauth_token(&self, id: &OauthTokenID) -> sqlx::Result<()> { + query!(r#"delete from oauth_token where id = $1"#, id as _) + .execute(&mut self.get().await?.tx) + .await?; + Ok(()) + } + + pub(crate) async fn delete_refresh_token(&self, id: &OauthTokenID) -> sqlx::Result<()> { + query!(r#"delete from oauth_token where id = $1 and kind = 'refresh'"#, id as _) + .execute(&mut self.get().await?.tx) + .await?; + Ok(()) + } + + // + // + // + + pub(crate) async fn add_oauth_authorization( + &self, + id: &OauthAuthorizationID, + auth: OauthAuthorization, + ) -> sqlx::Result<()> { + query!( + r#"insert into oauth_authorization (id, user_id, client_id, scope, access_type, + code_challenge, keys_jwe, auth_at) + values ($1, $2, $3, $4, $5, $6, $7, $8)"#, + id as _, + auth.user_id as _, + auth.client_id, + auth.scope as _, + auth.access_type as _, + auth.code_challenge, + auth.keys_jwe, + auth.auth_at, + ) + .execute(&mut self.get().await?.tx) + .await?; + Ok(()) + } + + pub(crate) async fn take_oauth_authorization( + &self, + id: &OauthAuthorizationID, + ) -> sqlx::Result { + query_as!( + OauthAuthorization, + r#"delete from oauth_authorization + where id = $1 and expires_at > now() + returning user_id as "user_id: UserID", client_id, scope as "scope: ScopeSet", + access_type as "access_type: OauthAccessType", + code_challenge, keys_jwe, auth_at"#, + id as _ + ) + .fetch_one(&mut self.get().await?.tx) + .await + } + + // + // + // + + pub(crate) async fn user_email_exists(&self, email: &str) -> sqlx::Result { + Ok(query_scalar!(r#"select 1 from users where email = lower($1)"#, email) + .fetch_optional(&mut self.get().await?.tx) + .await? + .is_some()) + } + + pub(crate) async fn add_user(&self, user: User) -> sqlx::Result { + let id = UserID::random(); + query_scalar!( + r#"insert into users (user_id, auth_salt, email, ka, wrapwrap_kb, verify_hash, + display_name) + values ($1, $2, $3, $4, $5, $6, $7)"#, + id as _, + user.auth_salt.as_str(), + user.email, + user.ka as _, + user.wrapwrap_kb as _, + user.verify_hash as _, + user.display_name, + ) + .execute(&mut self.get().await?.tx) + .await?; + Ok(id) + } + + pub(crate) async fn get_user(&self, email: &str) -> sqlx::Result<(UserID, User)> { + query!( + r#"select user_id as "id: UserID", auth_salt as "auth_salt: String", email, + ka as "ka: SecretKey", wrapwrap_kb as "wrapwrap_kb: SecretKey", + verify_hash as "verify_hash: VerifyHash", display_name, verified + from users + where email = lower($1)"#, + email + ) + .try_map(|r| { + Ok(( + r.id, + User { + auth_salt: SaltString::new(&r.auth_salt).map_err(decode_err("auth_salt"))?, + email: r.email, + ka: r.ka, + wrapwrap_kb: r.wrapwrap_kb, + verify_hash: r.verify_hash, + display_name: r.display_name, + verified: r.verified, + }, + )) + }) + .fetch_one(&mut self.get().await?.tx) + .await + } + + pub(crate) async fn get_user_by_id(&self, id: &UserID) -> sqlx::Result { + query!( + r#"select auth_salt as "auth_salt: String", email, + ka as "ka: SecretKey", wrapwrap_kb as "wrapwrap_kb: SecretKey", + verify_hash as "verify_hash: VerifyHash", display_name, verified + from users + where user_id = $1"#, + id as _ + ) + .try_map(|r| { + Ok(User { + auth_salt: SaltString::new(&r.auth_salt).map_err(decode_err("auth_salt"))?, + email: r.email, + ka: r.ka, + wrapwrap_kb: r.wrapwrap_kb, + verify_hash: r.verify_hash, + display_name: r.display_name, + verified: r.verified, + }) + }) + .fetch_one(&mut self.get().await?.tx) + .await + } + + pub(crate) async fn set_user_name(&self, id: &UserID, name: &str) -> sqlx::Result<()> { + query!( + "update users + set display_name = $2 + where user_id = $1", + id as _, + name, + ) + .execute(&mut self.get().await?.tx) + .await?; + Ok(()) + } + + pub(crate) async fn set_user_verified(&self, id: &UserID) -> sqlx::Result<()> { + query_scalar!("update users set verified = true where user_id = $1 returning 1", id as _) + .fetch_one(&mut self.get().await?.tx) + .await?; + Ok(()) + } + + pub(crate) async fn delete_user(&self, email: &str) -> sqlx::Result<()> { + query_scalar!(r#"delete from users where email = lower($1)"#, email) + .execute(&mut self.get().await?.tx) + .await?; + Ok(()) + } + + pub(crate) async fn change_user_auth( + &self, + uid: &UserID, + salt: SaltString, + wwkb: SecretKey, + verify_hash: VerifyHash, + ) -> sqlx::Result<()> { + query!( + r#"update users + set auth_salt = $2, wrapwrap_kb = $3, verify_hash = $4 + where user_id = $1"#, + uid as _, + salt.to_string(), + wwkb as _, + verify_hash as _, + ) + .execute(&mut self.get().await?.tx) + .await?; + Ok(()) + } + + pub(crate) async fn reset_user_auth( + &self, + uid: &UserID, + salt: SaltString, + wwkb: SecretKey, + verify_hash: VerifyHash, + ) -> sqlx::Result<()> { + query!( + r#"call reset_user_auth($1, $2, $3, $4)"#, + uid as _, + salt.to_string(), + wwkb as _, + verify_hash as _, + ) + .execute(&mut self.get().await?.tx) + .await?; + Ok(()) + } + + // + // + // + + pub(crate) async fn get_attached_clients( + &self, + id: &UserID, + ) -> sqlx::Result> { + query_as!( + AttachedClient, + r#"select + ot.client_id as "client_id?", + d.device_id as "device_id?: DeviceID", + us.session_id as "session_token_id?: SessionID", + ot.id as "refresh_token_id?: OauthTokenID", + d.type as "device_type?", + d.name as "name?", + coalesce(d.created_at, us.created_at, ot.created_at) as "created_time?", + us.last_active as "last_access_time?", + ot.scope as "scope?" + from device d + full outer join user_session us on (d.device_id = us.device_id) + full outer join oauth_token ot on (us.session_id = ot.session_id) + where + (ot.kind is null or ot.kind = 'refresh') + and $1 in (d.user_id, us.user_id, ot.user_id) + order by d.device_id"#, + id as _, + ) + .fetch_all(&mut self.get().await?.tx) + .await + } + + // + // + // + + pub(crate) async fn get_user_avatar_id(&self, id: &UserID) -> sqlx::Result> { + query!(r#"select id as "id: AvatarID" from user_avatars where user_id = $1"#, id as _,) + .map(|r| r.id) + .fetch_optional(&mut *self.get().await?.tx) + .await + } + + pub(crate) async fn get_user_avatar(&self, id: &AvatarID) -> sqlx::Result> { + query_as!( + Avatar, + r#"select id as "id: AvatarID", data, content_type + from user_avatars + where id = $1"#, + id as _, + ) + .fetch_optional(&mut *self.get().await?.tx) + .await + } + + pub(crate) async fn set_user_avatar(&self, id: &UserID, avatar: Avatar) -> sqlx::Result<()> { + query!( + r#"insert into user_avatars (user_id, id, data, content_type) + values ($1, $2, $3, $4) + on conflict (user_id) do update set + id = $2, data = $3, content_type = $4"#, + id as _, + avatar.id as _, + avatar.data, + avatar.content_type, + ) + .execute(&mut *self.get().await?.tx) + .await?; + Ok(()) + } + + pub(crate) async fn delete_user_avatar( + &self, + user: &UserID, + id: &AvatarID, + ) -> sqlx::Result<()> { + query!(r#"delete from user_avatars where user_id = $1 and id = $2"#, user as _, id as _,) + .execute(&mut *self.get().await?.tx) + .await?; + Ok(()) + } + + // + // + // + + pub(crate) async fn add_verify_code( + &self, + user: &UserID, + session: &SessionID, + code: &str, + ) -> sqlx::Result<()> { + query!( + r#"insert into verify_codes (user_id, session_id, code) + values ($1, $2, $3)"#, + user as _, + session as _, + code, + ) + .execute(&mut *self.get().await?.tx) + .await?; + Ok(()) + } + + pub(crate) async fn get_verify_code( + &self, + user: &UserID, + ) -> sqlx::Result<(String, VerifyCode)> { + query!( + r#"select user_id as "user_id: UserID", session_id as "session_id: SessionID", code, + email + from verify_codes join users using (user_id) + where user_id = $1"#, + user as _, + ) + .map(|r| { + (r.email, VerifyCode { user_id: r.user_id, session_id: r.session_id, code: r.code }) + }) + .fetch_one(&mut *self.get().await?.tx) + .await + } + + pub(crate) async fn try_use_verify_code( + &self, + user: &UserID, + code: &str, + ) -> sqlx::Result> { + query_as!( + VerifyCode, + r#"delete from verify_codes + where user_id = $1 and code = $2 + returning user_id as "user_id: UserID", session_id as "session_id: SessionID", + code"#, + user as _, + code, + ) + .fetch_optional(&mut *self.get().await?.tx) + .await + } + + // + // + // + + pub(crate) async fn add_password_change( + &self, + user: &UserID, + id: &PasswordChangeID, + key: &HawkKey, + forgot_code: Option<&str>, + ) -> sqlx::Result<()> { + query!( + r#"insert into password_change_tokens (id, user_id, hmac_key, forgot_code) + values ($1, $2, $3, $4) + on conflict (user_id) do update set id = $1, hmac_key = $3, forgot_code = $4, + expires_at = default"#, + id as _, + user as _, + key as _, + forgot_code, + ) + .execute(&mut *self.get().await?.tx) + .await?; + Ok(()) + } + + pub(crate) async fn finish_password_change( + &self, + id: &PasswordChangeID, + is_forgot: bool, + ) -> sqlx::Result<(HawkKey, (UserID, Option))> { + query!( + r#"delete from password_change_tokens + where id = $1 and expires_at > now() and (forgot_code is not null) = $2 + returning hmac_key as "hmac_key: HawkKey", user_id as "user_id: UserID", + forgot_code"#, + id as _, + is_forgot, + ) + .map(|r| (r.hmac_key, (r.user_id, r.forgot_code))) + .fetch_one(&mut self.get().await?.tx) + .await + } + + pub(crate) async fn add_account_reset( + &self, + user: &UserID, + id: &AccountResetID, + key: &HawkKey, + ) -> sqlx::Result<()> { + query!( + r#"insert into account_reset_tokens (id, user_id, hmac_key) + values ($1, $2, $3) + on conflict (user_id) do update set id = $1, hmac_key = $3, expires_at = default"#, + id as _, + user as _, + key as _, + ) + .execute(&mut *self.get().await?.tx) + .await?; + Ok(()) + } + + pub(crate) async fn finish_account_reset( + &self, + id: &AccountResetID, + ) -> sqlx::Result<(HawkKey, UserID)> { + query!( + r#"delete from account_reset_tokens + where id = $1 and expires_at > now() + returning hmac_key as "hmac_key: HawkKey", user_id as "user_id: UserID""#, + id as _, + ) + .map(|r| (r.hmac_key, r.user_id)) + .fetch_one(&mut self.get().await?.tx) + .await + } + + // + // + // + + pub async fn add_invite_code(&self, code: &str, expires: DateTime) -> sqlx::Result<()> { + query!(r#"insert into invite_codes (code, expires_at) values ($1, $2)"#, code, expires,) + .execute(&mut self.get().await?.tx) + .await?; + Ok(()) + } + + pub(crate) async fn use_invite_code(&self, code: &str) -> sqlx::Result<()> { + query_scalar!( + r#"delete from invite_codes where code = $1 and expires_at > now() returning 1"#, + code, + ) + .fetch_one(&mut self.get().await?.tx) + .await?; + Ok(()) + } + + // + // + // + + pub(crate) async fn prune_expired_tokens(&self) -> sqlx::Result<()> { + query!("call prune_expired_tokens()").execute(&mut self.get().await?.tx).await?; + Ok(()) + } + + pub(crate) async fn prune_expired_verify_codes(&self) -> sqlx::Result<()> { + query!("call prune_expired_verify_codes()").execute(&mut self.get().await?.tx).await?; + Ok(()) + } +} + +fn decode_err(c: &str) -> impl FnOnce(E) -> sqlx::Error { + let index = c.to_string(); + move |e| sqlx::Error::ColumnDecode { index, source: Box::new(e) } +} diff --git a/src/js.rs b/src/js.rs new file mode 100644 index 0000000..f3d662c --- /dev/null +++ b/src/js.rs @@ -0,0 +1,53 @@ +use std::{collections::HashMap, path::PathBuf}; + +use rocket::http::{ContentType, Status}; +use sha2::{Digest, Sha256}; + +use crate::cache::{Etagged, IfNoneMatch}; + +struct Entry { + data: &'static str, + hash: String, +} + +fn enter(data: &'static str) -> Entry { + let mut sha = Sha256::new(); + sha.update(data.as_bytes()); + let hash = base64::encode(sha.finalize()); + Entry { data, hash } +} + +lazy_static! { + static ref JS: HashMap<&'static str, Entry> = { + let mut m = HashMap::new(); + m.insert("main", enter(include_str!("../web/js//main.js"))); + m.insert("crypto", enter(include_str!("../web/js//crypto.js"))); + m.insert("auth-client/browser", enter(include_str!("../web/js//browser/browser.js"))); + m.insert("auth-client/lib/client", enter(include_str!("../web/js//browser/lib/client.js"))); + m.insert("auth-client/lib/crypto", enter(include_str!("../web/js//browser/lib/crypto.js"))); + m.insert("auth-client/lib/hawk", enter(include_str!("../web/js//browser/lib/hawk.js"))); + m.insert( + "auth-client/lib/recoveryKey", + enter(include_str!("../web/js//browser/lib/recoveryKey.js")), + ); + m.insert("auth-client/lib/utils", enter(include_str!("../web/js//browser/lib/utils.js"))); + m + }; +} + +#[get("/")] +pub(crate) async fn static_js( + name: PathBuf, + inm: Option>, +) -> (Status, Result<(ContentType, Etagged<'_, &'static str>), ()>) { + let entry = JS.get(name.to_string_lossy().as_ref()); + match entry { + Some(e) => match inm { + Some(h) if h.0 == e.hash => (Status::NotModified, Err(())), + _ => { + (Status::Ok, Ok((ContentType::JavaScript, Etagged(e.data, e.hash.as_str().into())))) + }, + }, + _ => (Status::NotFound, Err(())), + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..1e6fa31 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,319 @@ +use std::{ + path::PathBuf, + sync::Arc, + time::{Duration as StdDuration, SystemTime, UNIX_EPOCH}, +}; + +use anyhow::Context; +use chrono::Duration; +use db::Db; +use futures::Future; +use lettre::message::Mailbox; +use mailer::Mailer; +use push::PushClient; +use rocket::{ + fairing::AdHoc, + http::{uri::Absolute, ContentType, Header}, + request::{self, FromRequest}, + response::Redirect, + tokio::{ + spawn, + time::{interval_at, Instant, MissedTickBehavior}, + }, + Request, State, +}; +use serde_json::{json, Value}; +use utils::DeferredActions; + +use crate::api::auth::invite::generate_invite_link; + +#[macro_use] +extern crate rocket; +#[macro_use] +extern crate anyhow; +#[macro_use] +extern crate lazy_static; + +#[macro_use] +pub(crate) mod utils; +pub(crate) mod api; +mod auth; +mod cache; +mod crypto; +pub mod db; +mod js; +mod mailer; +mod push; +mod types; + +fn default_push_ttl() -> std::time::Duration { + std::time::Duration::from_secs(2 * 86400) +} + +fn default_task_interval() -> std::time::Duration { + std::time::Duration::from_secs(5 * 60) +} + +#[derive(serde::Deserialize)] +struct Config { + database_url: String, + location: Absolute<'static>, + token_server_location: Absolute<'static>, + vapid_key: PathBuf, + vapid_subject: String, + #[serde(default = "default_push_ttl", with = "humantime_serde")] + default_push_ttl: std::time::Duration, + #[serde(default = "default_task_interval", with = "humantime_serde")] + prune_expired_interval: std::time::Duration, + + mail_from: Mailbox, + mail_host: Option, + mail_port: Option, + + #[serde(default)] + invite_only: bool, + #[serde(default)] + invite_admin_address: String, +} + +impl Config { + pub fn avatars_prefix(&self) -> Absolute<'static> { + Absolute::parse_owned(format!("{}/avatars", self.location)).unwrap() + } +} + +#[get("/")] +async fn root() -> (ContentType, &'static str) { + (ContentType::HTML, include_str!("../web/index.html")) +} + +#[get("/settings/<_..>")] +async fn settings() -> Redirect { + Redirect::to(uri!("/#/settings")) +} + +#[get("/auth/v1/authorization")] +async fn auth_auth() -> (ContentType, &'static str) { + root().await +} + +#[get("/force_auth")] +async fn force_auth() -> Redirect { + Redirect::to(uri!("/#/force_auth")) +} + +#[derive(Debug)] +struct IsFenix(bool); + +#[rocket::async_trait] +impl<'r> FromRequest<'r> for IsFenix { + type Error = std::convert::Infallible; + + async fn from_request(request: &'r Request<'_>) -> request::Outcome { + let ua = request.headers().get_one("user-agent"); + request::Outcome::Success(IsFenix( + ua.map(|ua| ua.contains("Firefox") && ua.contains("Android")).unwrap_or(false), + )) + } +} + +#[get("/.well-known/fxa-client-configuration")] +async fn fxa_client_configuration(cfg: &State, is_fenix: IsFenix) -> Value { + let base = &cfg.location; + json!({ + "auth_server_base_url": format!("{base}/auth"), + "oauth_server_base_url": format!("{base}/oauth"), + "pairing_server_base_uri": format!("{base}/pairing"), + "profile_server_base_url": format!("{base}/profile"), + // NOTE trailing slash is *essential*, otherwise fenix will refuse to sync. + // likewise firefox desktop seems to misbehave if there *is* a trailing slash. + "sync_tokenserver_base_url": format!("{}{}", cfg.token_server_location, if is_fenix.0 { "/" } else { "" }) + }) +} + +// NOTE it looks like firefox does not implement refresh token rotation. +// since it also looks like it doesn't implement MTLS we can't secure +// refresh tokens against being stolen as advised by +// https://datatracker.ietf.org/doc/html/draft-ietf-oauth-security-topics +// section 2.2.2 + +// NOTE firefox "oldsync" scope is the current version? +// https://github.com/mozilla/fxa/blob/main/packages/fxa-auth-server/docs/oauth/scopes.md +// https://mozilla.github.io/ecosystem-platform/explanation/onepw-protocol +// https://mozilla.github.io/ecosystem-platform/api +// https://github.com/mozilla/fxa/blob/main/packages/fxa-auth-server/docs/device_registration.md +// -> push for everything +// https://mozilla.github.io/ecosystem-platform/explanation/scoped-keys + +#[get("/.well-known/openid-configuration")] +fn oid(cfg: &State) -> Value { + let base = &cfg.location; + json!({ + "authorization_endpoint": format!("{base}/auth/v1/authorization"), + "introspection_endpoint": format!("{base}/oauth/v1/introspect"), + "issuer": base.to_string(), + "jwks_uri": format!("{base}/oauth/v1/jwks"), + "revocation_endpoint": format!("{base}/oauth/v1/destroy"), + "token_endpoint": format!("{base}/auth/v1/oauth/token"), + "userinfo_endpoint": format!("{base}/profile/v1/profile"), + "claims_supported": ["aud","exp","iat","iss","sub"], + "id_token_signing_alg_values_supported": ["RS256"], + "response_types_supported": ["code","token"], + "scopes_supported": ["openid","profile","email"], + "subject_types_supported": ["public"], + "token_endpoint_auth_methods_supported": ["client_secret_post"], + }) +} + +fn spawn_periodic(context: &'static str, t: StdDuration, p: P, f: A) +where + A: Fn(P) -> F + Send + Sync + Sized + 'static, + P: Clone + Send + Sync + 'static, + F: Future> + Send + Sized, +{ + let mut interval = interval_at(Instant::now() + t, t); + interval.set_missed_tick_behavior(MissedTickBehavior::Skip); + + spawn(async move { + loop { + interval.tick().await; + info!("starting periodic {context}"); + if let Err(e) = f(p.clone()).await { + error!("periodic {context} failed: {e}"); + } + } + }); +} + +async fn ensure_invite_admin(db: &Db, cfg: &Config) -> anyhow::Result<()> { + if !cfg.invite_only { + return Ok(()); + } + + let tx = db.begin().await?; + match tx.get_user(&cfg.invite_admin_address).await { + Err(sqlx::Error::RowNotFound) => { + let url = generate_invite_link(&tx, cfg, Duration::hours(1)).await?; + tx.commit().await?; + warn!("admin user {} does not exist, register at {url}", cfg.invite_admin_address); + Ok(()) + }, + Err(e) => Err(anyhow!(e)), + Ok(_) => Ok(()), + } +} + +pub async fn build() -> anyhow::Result> { + let rocket = rocket::build(); + let config = rocket.figment().extract::().context("reading config")?; + let db = Arc::new(Db::connect(&config.database_url).await.unwrap()); + + db.migrate().await.context("running db migrations")?; + + ensure_invite_admin(&db, &config).await?; + let push = Arc::new( + PushClient::new( + &config.vapid_key, + &config.vapid_subject, + config.location.clone(), + config.default_push_ttl, + ) + .context("setting up push notifications")?, + ); + let mailer = Arc::new( + Mailer::new( + config.mail_from.clone(), + config.mail_host.as_deref().unwrap_or("localhost"), + config.mail_port.unwrap_or(25), + config.location.clone(), + ) + .context("setting up mail notifications")?, + ); + spawn_periodic("verify code prune", StdDuration::from_secs(5 * 60), Arc::clone(&db), { + |db| async move { + let tx = db.begin().await?; + tx.prune_expired_verify_codes().await?; + tx.commit().await?; + Ok(()) + } + }); + spawn_periodic("expired token prune", config.prune_expired_interval, Arc::clone(&db), { + |db| async move { + let tx = db.begin().await?; + tx.prune_expired_tokens().await?; + tx.commit().await?; + Ok(()) + } + }); + let rocket = rocket + .manage(config) + .manage(push) + .manage(mailer) + .attach(db) + .attach(DeferredActions) + .mount("/", routes![root, settings, oid, auth_auth, force_auth, fxa_client_configuration,]) + .register("/auth/v1", catchers![api::auth::catch_all,]) + .mount( + "/auth/v1", + routes![ + api::auth::account::create, + api::auth::account::login, + api::auth::account::destroy, + api::auth::account::keys, + api::auth::account::reset, + api::auth::oauth::token_authenticated, + api::auth::oauth::token_unauthenticated, + api::auth::oauth::destroy, + api::auth::oauth::scoped_key_data, + api::auth::device::devices, + api::auth::device::device, + api::auth::device::invoke, + api::auth::device::commands, + api::auth::session::status, + api::auth::session::resend_code, + api::auth::session::verify_code, + api::auth::session::destroy, + api::auth::oauth::authorization, + api::auth::device::destroy, + api::auth::device::notify, + api::auth::device::attached_clients, + api::auth::device::destroy_attached_client, + api::auth::email::status, + api::auth::email::verify_code, + api::auth::email::resend_code, + api::auth::password::change_start, + api::auth::password::change_finish, + api::auth::password::forgot_start, + api::auth::password::forgot_finish, + ], + ) + // slight hack to allow the js auth client to "just work" + .register("/_invite/v1", catchers![api::auth::catch_all,]) + .mount("/_invite/v1", routes![api::auth::invite::generate,]) + .attach(AdHoc::on_response("/auth Timestamp", |req, resp| { + Box::pin(async move { + if req.uri().path().as_str().starts_with("/auth/v1/") { + if let Ok(ts) = SystemTime::now().duration_since(UNIX_EPOCH) { + resp.set_header(Header::new("timestamp", ts.as_secs().to_string())); + } + } + }) + })) + .register("/profile", catchers![api::profile::catch_all,]) + .mount( + "/profile/v1", + routes![ + api::profile::profile, + api::profile::display_name_post, + api::profile::avatar_get, + api::profile::avatar_upload, + api::profile::avatar_delete, + ], + ) + .register("/avatars", catchers![api::profile::catch_all,]) + .mount("/avatars", routes![api::profile::avatar_get_img]) + .register("/oauth/v1", catchers![api::oauth::catch_all,]) + .mount("/oauth/v1", routes![api::oauth::destroy, api::oauth::jwks, api::oauth::verify,]) + .mount("/js", routes![js::static_js]); + Ok(rocket) +} diff --git a/src/mailer.rs b/src/mailer.rs new file mode 100644 index 0000000..1ea1a8b --- /dev/null +++ b/src/mailer.rs @@ -0,0 +1,105 @@ +use std::time::Duration; + +use lettre::{ + message::Mailbox, + transport::smtp::client::{Tls, TlsParameters}, + AsyncSmtpTransport, Message, Tokio1Executor, +}; +use rocket::http::uri::Absolute; +use serde_json::json; + +use crate::types::UserID; + +pub struct Mailer { + from: Mailbox, + verify_base: Absolute<'static>, + transport: AsyncSmtpTransport, +} + +impl Mailer { + pub fn new( + from: Mailbox, + host: &str, + port: u16, + verify_base: Absolute<'static>, + ) -> anyhow::Result { + Ok(Mailer { + from, + verify_base, + transport: AsyncSmtpTransport::::builder_dangerous(host) + .port(port) + .tls(Tls::Opportunistic(TlsParameters::new(host.to_string())?)) + .timeout(Some(Duration::from_secs(5))) + .build(), + }) + } + + pub(crate) async fn send_account_verify( + &self, + uid: &UserID, + to: &str, + code: &str, + ) -> anyhow::Result<()> { + let fragment = base64::encode_config( + serde_json::to_string(&json!({ + "uid": uid, + "email": to, + "code": code, + }))?, + base64::URL_SAFE, + ); + let email = Message::builder() + .from(self.from.clone()) + .to(to.parse()?) + .subject("account verify code") + .body(format!("{}/#/verify/{fragment}", self.verify_base))?; + lettre::AsyncTransport::send(&self.transport, email).await?; + Ok(()) + } + + pub(crate) async fn send_session_verify(&self, to: &str, code: &str) -> anyhow::Result<()> { + let email = Message::builder() + .from(self.from.clone()) + .to(to.parse()?) + .subject("session verify code") + .body(format!("{code}"))?; + lettre::AsyncTransport::send(&self.transport, email).await?; + Ok(()) + } + + pub(crate) async fn send_password_changed(&self, to: &str) -> anyhow::Result<()> { + let email = Message::builder() + .from(self.from.clone()) + .to(to.parse()?) + .subject("account password has been changed") + .body(String::from( + "your account password has been changed. if you haven't done this, \ + you're probably in trouble now.", + ))?; + lettre::AsyncTransport::send(&self.transport, email).await?; + Ok(()) + } + + pub(crate) async fn send_password_forgot(&self, to: &str, code: &str) -> anyhow::Result<()> { + let email = Message::builder() + .from(self.from.clone()) + .to(to.parse()?) + .subject("account reset code") + .body(code.to_string())?; + lettre::AsyncTransport::send(&self.transport, email).await?; + Ok(()) + } + + pub(crate) async fn send_account_reset(&self, to: &str) -> anyhow::Result<()> { + let email = Message::builder() + .from(self.from.clone()) + .to(to.parse()?) + .subject("account has been reset") + .body(String::from( + "your account has been reset. if you haven't done this, \ + you're probably in trouble now.", + ))?; + lettre::AsyncTransport::send(&self.transport, email).await?; + Ok(()) + } +} diff --git a/src/push.rs b/src/push.rs new file mode 100644 index 0000000..6ee1afb --- /dev/null +++ b/src/push.rs @@ -0,0 +1,198 @@ +use anyhow::Result; +use rocket::http::uri::Absolute; +use serde_json::{json, Value}; +use std::time::Duration; +use std::{fs::File, io::Read, path::Path}; + +use serde::Serialize; +use web_push::{ + ContentEncoding, SubscriptionInfo, VapidSignatureBuilder, WebPushClient, WebPushMessageBuilder, +}; + +use crate::db::DbConn; +use crate::types::{Device, DeviceID, DevicePush, UserID}; + +pub(crate) struct PushClient { + key: Box<[u8]>, + client: WebPushClient, + subject: String, + base_uri: Absolute<'static>, + default_ttl: Duration, +} + +impl PushClient { + pub(crate) fn new>( + key: P, + subject: &str, + base_uri: Absolute<'static>, + default_ttl: Duration, + ) -> Result { + let mut key_bytes = vec![]; + File::open(key).and_then(|mut f| f.read_to_end(&mut key_bytes))?; + Ok(PushClient { + key: key_bytes.into_boxed_slice(), + client: WebPushClient::new()?, + subject: subject.to_string(), + base_uri, + default_ttl, + }) + } + + async fn push_raw(&self, to: &DevicePush, ttl: Duration, data: Option<&[u8]>) -> Result<()> { + let sub = SubscriptionInfo::new(&to.callback, &to.public_key, &to.auth_key); + let mut sig = VapidSignatureBuilder::from_pem(&*self.key, &sub)?; + // mozilla requires {aud,exp,sub} or message will get a 401 unauthorized. + // {aud,exp} are added automatically + sig.add_claim("sub", self.subject.as_str()); + let mut builder = WebPushMessageBuilder::new(&sub)?; + if let Some(data) = data { + builder.set_payload(ContentEncoding::Aes128Gcm, data); + } + builder.set_vapid_signature(sig.build()?); + builder.set_ttl(ttl.as_secs().min(u32::MAX as u64) as u32); + Ok(self.client.send(builder.build()?).await?) + } + + async fn push_one( + &self, + context: &str, + db: Option<&DbConn>, + to: &Device, + ttl: Duration, + data: Option<&[u8]>, + ) -> Result<()> { + match (to.push_expired, to.push.as_ref()) { + (false, Some(ep)) => match self.push_raw(ep, ttl, data).await { + Ok(()) => Ok(()), + Err(e) => { + warn!("{} push to {} failed: {}", context, &to.device_id, e); + if let Some(db) = db { + if let Err(e) = db.set_push_expired(&to.device_id).await { + warn!("failed to set {} push_endpoint_expired: {}", &to.device_id, e); + } + } + Err(e) + }, + }, + (_, None) => Err(anyhow!("no push callback")), + (true, _) => Err(anyhow!("push endpoint expired")), + } + } + + async fn push_all( + &self, + context: &str, + db: Option<&DbConn>, + to: &[Device], + ttl: Duration, + msg: impl Serialize, + ) { + let msg = serde_json::to_vec(&msg).expect("push message serialization failed"); + for dev in to { + // ignore errors here, except by logging them. we can't notify the client + // about anything and failing isn't an option either. + let _ = self.push_one(context, db, dev, ttl, Some(&msg)).await; + } + } + + pub(crate) async fn command_received( + &self, + db: &DbConn, + to: &Device, + command: &str, + index: i64, + sender: &Option, + ) -> Result<()> { + let url = + format!("{}/auth/v1/account/device/commands?index={}&limit=1", self.base_uri, index); + let msg = json!({ + "version": 1, + "command": "fxaccounts:command_received", + "data": { + "command": command, + "index": index, + "sender": sender, + "url": url, + }, + }); + let msg = serde_json::to_vec(&msg)?; + self.push_one("command_received", Some(db), to, self.default_ttl, Some(&msg)).await + } + + pub(crate) async fn device_connected(&self, db: &DbConn, to: &[Device], name: &str) { + let msg = json!({ + "version": 1, + "command": "fxaccounts:device_connected", + "data": { + "deviceName": name, + }, + }); + self.push_all("device_connected", Some(db), to, self.default_ttl, &msg).await; + } + + pub(crate) async fn device_disconnected(&self, db: &DbConn, to: &[Device], id: &DeviceID) { + let msg = json!({ + "version": 1, + "command": "fxaccounts:device_disconnected", + "data": { + "id": id, + }, + }); + self.push_all("device_disconnected", Some(db), to, self.default_ttl, &msg).await; + } + + pub(crate) async fn profile_updated(&self, db: &DbConn, to: &[Device]) { + let msg = json!({ + "version": 1, + "command": "fxaccounts:profile_updated", + }); + self.push_all("profile_updated", Some(db), to, self.default_ttl, &msg).await; + } + + pub(crate) async fn account_verified(&self, db: &DbConn, to: &[Device]) { + for dev in to { + // ignore errors here, except by logging them. we can't notify the client + // about anything and failing isn't an option either. + let _ = self.push_one("account_verified", Some(db), dev, Duration::ZERO, None).await; + } + } + + pub(crate) async fn account_destroyed(&self, to: &[Device], uid: &UserID) { + let msg = json!({ + "version": 1, + "command": "fxaccounts:account_destroyed", + "data": { + "uid": uid, + }, + }); + self.push_all("account_destroyed", None, to, self.default_ttl, &msg).await; + } + + pub(crate) async fn password_reset(&self, to: &[Device]) { + let msg = serde_json::to_vec(&json!({ + "version": 1u32, + "command": "fxaccounts:password_reset", + })) + .expect("serde failed"); + for dev in to { + // ignore errors here, except by logging them. we can't notify the client + // about anything and failing isn't an option either. + let _ = self.push_one("password_reset", None, dev, self.default_ttl, Some(&msg)).await; + // NOTE password_reset alone doesn't seem to do much, se we also disconnect + // each device explicitly. + let msg = serde_json::to_vec(&json!({ + "version": 1, + "command": "fxaccounts:device_disconnected", + "data": { + "id": dev.device_id, + }, + })) + .expect("serde failed"); + let _ = self.push_one("password_reset", None, dev, self.default_ttl, Some(&msg)).await; + } + } + + pub(crate) async fn push_any(&self, db: &DbConn, to: &[Device], ttl: Duration, payload: Value) { + self.push_all("push_any", Some(db), to, ttl, &payload).await; + } +} diff --git a/src/types.rs b/src/types.rs new file mode 100644 index 0000000..c0c5dfe --- /dev/null +++ b/src/types.rs @@ -0,0 +1,436 @@ +use crate::crypto::SecretBytes; +use chrono::{DateTime, Utc}; +use password_hash::{rand_core::OsRng, Output, SaltString}; +use rand::RngCore; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use sha2::{Digest, Sha256}; +use sqlx::{ + postgres::{PgArgumentBuffer, PgTypeInfo, PgValueRef}, + Decode, Encode, Postgres, Type, +}; +use std::{ + collections::HashMap, + fmt::{Debug, Display}, + ops::Deref, + str::FromStr, +}; + +use self::oauth::ScopeSet; + +pub(crate) mod oauth; + +macro_rules! array_type { + ( + $( #[ $attr:meta ] )* + $name:ident($inner:ty) as $sql_name:ident { + $( $body:tt )* + } + ) => { + $( #[ $attr ] )* + pub(crate) struct $name(pub(crate) $inner); + + impl $name { + $( $body )* + } + + impl Type for $name { + fn type_info() -> PgTypeInfo { + PgTypeInfo::with_name(stringify!($sql_name)) + } + } + + impl Encode<'_, Postgres> for $name { + fn encode_by_ref(&self, buf: &mut PgArgumentBuffer) -> sqlx::encode::IsNull { + let raw = self.0.iter().map(Self::encode_elem).collect::>(); + Encode::<'_, Postgres>::encode_by_ref(&raw, buf) + } + } + + impl Decode<'_, Postgres> for $name { + fn decode(value: PgValueRef) -> Result { + Ok(Self::decode_elems(Decode::<'_, Postgres>::decode(value)?)?) + } + } + } +} + +macro_rules! bytea_types { + () => {}; + ( + #[simple_array] + struct $name:ident($inner:ty) as $sql_name:ident; + + $( $rest:tt )* + ) => { + bytea_types!{ + #[derive(Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] + #[serde(try_from = "String", into = "String")] + struct $name($inner) as $sql_name { + fn decode(v) -> _ { &v.0[..] } + fn encode(v) -> _ { v } + } + + impl FromStr for $name {} + impl ToString for $name {} + impl Debug for $name {} + + $( $rest )* + } + }; + ( + $( #[ $attr:meta ] )* + struct $name:ident($inner:ty) as $sql_name:ident { + $( fn arbitrary($a:ident) -> _ { $ae:expr } )? + fn decode($d:ident) -> _ { $de:expr } + fn encode($e:ident) -> _ { $ee:expr } + + $( $impls:tt )* + } + + $( $rest:tt )* + ) => { + $( #[ $attr ] )* + pub(crate) struct $name(pub(crate) $inner); + + impl $name { + $( $impls )* + } + + impl Type for $name { + fn type_info() -> PgTypeInfo { + PgTypeInfo::with_name(stringify!($sql_name)) + } + } + + impl Encode<'_, Postgres> for $name { + fn encode_by_ref(&self, buf: &mut PgArgumentBuffer) -> sqlx::encode::IsNull { + let $d = self; + <&[u8] as Encode<'_, Postgres>>::encode_by_ref(&$de, buf) + } + } + + impl Decode<'_, Postgres> for $name { + fn decode(value: PgValueRef) -> Result { + let $e = <&[u8] as Decode<'_, Postgres>>::decode(value)?.try_into()?; + Ok($name($ee)) + } + } + + bytea_types!{ $( $rest )* } + }; + ( impl ToString for $name:ident {} $( $rest:tt )* ) => { + impl Display for $name { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> Result<(), std::fmt::Error> { + f.write_str(&hex::encode(&self.0)) + } + } + impl From<$name> for String { + fn from(s: $name) -> String { + format!("{}", s) + } + } + bytea_types!{ $( $rest )* } + }; + ( impl Debug for $name:ident {} $( $rest:tt )* ) => { + impl Debug for $name { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_tuple(stringify!($name)).field(&self.to_string()).finish() + } + } + bytea_types!{ $( $rest )* } + }; + ( impl FromStr for $name:ident {} $( $rest:tt )* ) => { + impl FromStr for $name { + type Err = anyhow::Error; + + fn from_str(s: &str) -> Result { + Ok(Self(hex::decode(s)?.as_slice().try_into()?)) + } + } + impl TryFrom for $name { + type Error = anyhow::Error; + + fn try_from(s: String) -> Result { + s.parse() + } + } + bytea_types!{ $( $rest )* } + } +} + +// +// +// + +bytea_types! { + #[derive(Clone, Debug, PartialEq, Eq)] + struct HawkKey(SecretBytes<32>) as hawk_key { + fn arbitrary(a) -> _ { SecretBytes(a) } + fn decode(v) -> _ { v.0.0.as_ref() } + fn encode(v) -> _ { SecretBytes(v) } + } + + #[simple_array] + struct SessionID([u8; 32]) as session_id; + + #[simple_array] + struct DeviceID([u8; 16]) as device_id; + + #[simple_array] + struct UserID([u8; 16]) as user_id; + + #[simple_array] + struct KeyFetchID([u8; 32]) as key_fetch_id; + + #[simple_array] + struct OauthTokenID([u8; 32]) as oauth_token_id; + + #[simple_array] + struct OauthAuthorizationID([u8; 32]) as oauth_auth_id; + + #[simple_array] + struct PasswordChangeID([u8; 32]) as password_change_id; + + #[simple_array] + struct AccountResetID([u8; 32]) as account_reset_id; + + #[simple_array] + struct AvatarID([u8; 16]) as avatar_id; + + #[derive(Clone, Debug, PartialEq, Eq)] + struct SecretKey(SecretBytes<32>) as secret_key { + fn arbitrary(a) -> _ { SecretBytes(a) } + fn decode(v) -> _ { v.0.0.as_ref() } + fn encode(v) -> _ { SecretBytes(v) } + } + + #[derive(Clone, Debug, PartialEq, Eq)] + struct VerifyHash(Output) as verify_hash { + fn arbitrary(a) -> _ { Output::new(<[u8; 32]>::as_ref(&a)).unwrap() } + fn decode(v) -> _ { v.0.as_ref() } + fn encode(v) -> _ { v } + } +} + +impl DeviceID { + pub fn random() -> Self { + let mut result = Self([0; 16]); + OsRng.fill_bytes(&mut result.0); + result + } +} + +impl UserID { + pub fn random() -> Self { + let mut result = Self([0; 16]); + OsRng.fill_bytes(&mut result.0); + result + } +} + +impl OauthAuthorizationID { + pub fn random() -> Self { + let mut result = Self([0; 32]); + OsRng.fill_bytes(&mut result.0); + result + } +} + +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(try_from = "String", into = "String")] +pub(crate) struct OauthToken([u8; 32]); + +impl OauthToken { + pub fn random() -> Self { + let mut result = Self([0; 32]); + OsRng.fill_bytes(&mut result.0); + result + } + + pub fn hash(&self) -> OauthTokenID { + let mut sha = Sha256::new(); + sha.update(&self.0); + OauthTokenID(*sha.finalize().as_ref()) + } +} + +bytea_types! { + impl Debug for OauthToken {} + impl FromStr for OauthToken {} + impl ToString for OauthToken {} +} + +#[derive(Debug, Deserialize, PartialEq, Eq, Type)] +#[sqlx(type_name = "oauth_access_type", rename_all = "lowercase")] +#[serde(rename_all = "lowercase")] +pub enum OauthAccessType { + Online, + Offline, +} + +#[derive(Debug)] +pub(crate) struct UserSession { + pub(crate) uid: UserID, + pub(crate) req_hmac_key: HawkKey, + pub(crate) device_id: Option, + pub(crate) created_at: DateTime, + pub(crate) verified: bool, + pub(crate) verify_code: Option, +} + +#[derive(Clone, Debug)] +pub(crate) struct DeviceCommand { + pub(crate) index: i64, + pub(crate) command: String, + pub(crate) payload: Value, + #[allow(dead_code)] + pub(crate) expires: DateTime, + // NOTE this is a device ID, but we don't link it to the actual sender device + // because removing a device would also remove its queued commands. this mirrors + // what fxa does. + pub(crate) sender: Option, +} + +#[derive(Clone, Debug, PartialEq, sqlx::Type)] +#[sqlx(type_name = "device_push_info")] +pub(crate) struct DevicePush { + pub(crate) callback: String, + pub(crate) public_key: String, + pub(crate) auth_key: String, +} + +#[derive(Clone, Debug, PartialEq, sqlx::Type)] +#[sqlx(type_name = "device_command")] +struct DeviceCommandsEntry { + name: String, + body: String, +} + +array_type! { + #[derive(Clone, Debug, PartialEq)] + DeviceCommands(HashMap) as _device_command { + fn encode_elem(e: (&String, &String)) -> DeviceCommandsEntry { + DeviceCommandsEntry { name: e.0.clone(), body: e.1.clone() } + } + fn decode_elems(e: Vec) -> anyhow::Result { + Ok(Self(e.into_iter().map(|e| (e.name, e.body)).collect())) + } + + pub(crate) fn into_map(self) -> HashMap { + self.0 + } + } +} + +impl Deref for DeviceCommands { + type Target = HashMap; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +#[derive(Clone, Debug)] +pub(crate) struct Device { + pub(crate) device_id: DeviceID, + // taken from session, otherwise UNIX_EPOCH + pub(crate) last_active: DateTime, + pub(crate) name: String, + pub(crate) type_: String, + pub(crate) push: Option, + pub(crate) available_commands: DeviceCommands, + pub(crate) push_expired: bool, + // actually a str->str map, but we treat it as opaque for simplicity. + // writing a HashMap to the db through sqlx is an immense pain, + // and we don't care about the value anyway—it only has to exist for fenix. + pub(crate) location: Value, +} + +#[derive(Clone, Debug)] +pub(crate) struct DeviceUpdate<'a> { + pub(crate) name: Option<&'a str>, + pub(crate) type_: Option<&'a str>, + pub(crate) push: Option, + pub(crate) available_commands: Option, + pub(crate) location: Option, +} + +#[derive(Debug, sqlx::Type)] +#[sqlx(type_name = "oauth_token_kind", rename_all = "lowercase")] +pub(crate) enum OauthTokenKind { + Access, + Refresh, +} + +#[derive(Debug)] +pub(crate) struct OauthAccessToken { + pub(crate) user_id: UserID, + pub(crate) client_id: String, + pub(crate) scope: ScopeSet, + pub(crate) parent_refresh: Option, + pub(crate) parent_session: Option, + pub(crate) expires_at: DateTime, +} + +#[derive(Debug)] +pub(crate) struct OauthRefreshToken { + pub(crate) user_id: UserID, + pub(crate) client_id: String, + pub(crate) scope: ScopeSet, + pub(crate) session_id: Option, +} + +#[derive(Debug)] +pub(crate) struct OauthAuthorization { + pub(crate) user_id: UserID, + pub(crate) client_id: String, + pub(crate) scope: ScopeSet, + pub(crate) access_type: OauthAccessType, + pub(crate) code_challenge: String, + pub(crate) keys_jwe: Option, + pub(crate) auth_at: DateTime, +} + +#[derive(Debug)] +#[cfg_attr(test, derive(Clone))] +pub(crate) struct User { + pub(crate) auth_salt: SaltString, + pub(crate) email: String, + pub(crate) display_name: Option, + pub(crate) ka: SecretKey, + pub(crate) wrapwrap_kb: SecretKey, + pub(crate) verify_hash: VerifyHash, + pub(crate) verified: bool, +} + +// MISSING user secondary email addresses + +#[derive(Debug)] +pub(crate) struct Avatar { + pub(crate) id: AvatarID, + pub(crate) data: Vec, + pub(crate) content_type: String, +} + +#[derive(Debug)] +pub(crate) struct AttachedClient { + pub(crate) client_id: Option, + pub(crate) device_id: Option, + pub(crate) session_token_id: Option, + pub(crate) refresh_token_id: Option, + pub(crate) device_type: Option, + pub(crate) name: Option, + pub(crate) created_time: Option>, + pub(crate) last_access_time: Option>, + pub(crate) scope: Option, +} + +#[derive(Debug)] +pub(crate) struct VerifyCode { + #[allow(dead_code)] + pub(crate) user_id: UserID, + pub(crate) session_id: Option, + #[allow(dead_code)] + pub(crate) code: String, +} diff --git a/src/types/oauth.rs b/src/types/oauth.rs new file mode 100644 index 0000000..222c567 --- /dev/null +++ b/src/types/oauth.rs @@ -0,0 +1,267 @@ +use std::{borrow::Cow, fmt::Display}; + +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(transparent)] +pub(crate) struct Scope<'a>(pub Cow<'a, str>); + +impl<'a> Scope<'a> { + pub const fn borrowed(s: &'a str) -> Self { + Self(Cow::Borrowed(s)) + } + + pub fn into_owned(self) -> Scope<'static> { + Scope(Cow::Owned(self.0.into_owned())) + } + + pub fn implies(&self, other: &Scope) -> bool { + let (a, b) = (&*self.0, &*other.0); + match (a.strip_prefix("https://"), b.strip_prefix("https://")) { + (Some(a), Some(b)) => { + let (a_origin, a_path) = a.split_once('/').unwrap_or((a, "")); + let (b_origin, b_path) = b.split_once('/').unwrap_or((b, "")); + if a_origin != b_origin { + false + } else { + let (a_path, a_frag) = match a_path.split_once('#') { + Some((p, f)) => (p, Some(f)), + None => (a_path, None), + }; + let (b_path, b_frag) = match b_path.split_once('#') { + Some((p, f)) => (p, Some(f)), + None => (b_path, None), + }; + if b_path + .strip_prefix(a_path) + .map_or(false, |br| br.is_empty() || br.starts_with('/')) + { + match (a_frag, b_frag) { + (Some(af), Some(bf)) => af == bf, + (Some(_), None) => false, + _ => true, + } + } else { + false + } + } + }, + (None, None) => { + let (a, a_write) = + a.strip_suffix(":write").map(|s| (s, true)).unwrap_or((a, false)); + let (b, b_write) = + b.strip_suffix(":write").map(|s| (s, true)).unwrap_or((b, false)); + if b_write && !a_write { + false + } else { + b.strip_prefix(a).map_or(false, |br| br.is_empty() || br.starts_with(':')) + } + }, + _ => false, + } + } +} + +impl<'a> Display for Scope<'a> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.0.fmt(f) + } +} + +#[derive(Clone, Debug, Serialize, Deserialize, sqlx::Type)] +#[serde(transparent)] +#[sqlx(transparent)] +pub(crate) struct ScopeSet(String); + +impl ScopeSet { + pub fn split(&self) -> impl Iterator { + // not using split_whitespace because the oauth spec explicitly says to split on SP + self.0.split(' ').filter(|s| !s.is_empty()).map(Scope::borrowed) + } + + pub fn implies(&self, scope: &Scope) -> bool { + self.split().any(|a| a.implies(scope)) + } + + pub fn implies_all(&self, scopes: &ScopeSet) -> bool { + scopes.split().all(|b| self.implies(&b)) + } + + pub fn is_allowed_by(&self, allowed: &[Scope]) -> bool { + self.split().all(|scope| allowed.iter().any(|perm| perm.implies(&scope))) + } + + pub fn remove(&self, remove: &Scope) -> ScopeSet { + let remaining = self.split().filter(|s| !remove.implies(s)); + ScopeSet(remaining.map(|s| s.0).collect::>().join(" ")) + } +} + +impl PartialEq for ScopeSet { + fn eq(&self, other: &Self) -> bool { + let (mut a, mut b) = (self.split().collect::>(), other.split().collect::>()); + a.sort_by(|a, b| a.0.cmp(&b.0)); + b.sort_by(|a, b| a.0.cmp(&b.0)); + a.eq(&b) + } +} + +impl Eq for ScopeSet {} + +impl Display for ScopeSet { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.0.fmt(f) + } +} + +#[cfg(test)] +mod test { + use super::{Scope, ScopeSet}; + + #[test] + fn test_scope_implies() { + assert!(ScopeSet("profile:write".to_string()).implies(&Scope::borrowed("profile"))); + assert!(ScopeSet("profile".to_string()).implies(&Scope::borrowed("profile:email"))); + assert!(ScopeSet("profile:write".to_string()).implies(&Scope::borrowed("profile:email"))); + assert!( + ScopeSet("profile:write".to_string()).implies(&Scope::borrowed("profile:email:write")) + ); + assert!( + ScopeSet("profile:email:write".to_string()).implies(&Scope::borrowed("profile:email")) + ); + assert!(ScopeSet("profile profile:email:write".to_string()) + .implies(&Scope::borrowed("profile:email"))); + assert!(ScopeSet("profile profile:email:write".to_string()) + .implies(&Scope::borrowed("profile:display_name"))); + assert!(ScopeSet("profile https://identity.mozilla.com/apps/oldsync".to_string()) + .implies(&Scope::borrowed("profile"))); + assert!(ScopeSet("profile https://identity.mozilla.com/apps/oldsync".to_string()) + .implies(&Scope::borrowed("https://identity.mozilla.com/apps/oldsync"))); + assert!(ScopeSet("https://identity.mozilla.com/apps/oldsync".to_string()) + .implies(&Scope::borrowed("https://identity.mozilla.com/apps/oldsync#read"))); + assert!(ScopeSet("https://identity.mozilla.com/apps/oldsync".to_string()) + .implies(&Scope::borrowed("https://identity.mozilla.com/apps/oldsync/bookmarks"))); + assert!(ScopeSet("https://identity.mozilla.com/apps/oldsync".to_string()) + .implies(&Scope::borrowed("https://identity.mozilla.com/apps/oldsync/bookmarks#read"))); + assert!(ScopeSet("https://identity.mozilla.com/apps/oldsync#read".to_string()) + .implies(&Scope::borrowed("https://identity.mozilla.com/apps/oldsync/bookmarks#read"))); + assert!(ScopeSet("https://identity.mozilla.com/apps/oldsync#read profile".to_string()) + .implies(&Scope::borrowed("https://identity.mozilla.com/apps/oldsync/bookmarks#read"))); + + assert!(!ScopeSet("profile:email:write".to_string()).implies(&Scope::borrowed("profile"))); + assert!( + !ScopeSet("profile:email:write".to_string()).implies(&Scope::borrowed("profile:write")) + ); + assert!(!ScopeSet("profile:email".to_string()) + .implies(&Scope::borrowed("profile:display_name"))); + assert!(!ScopeSet("profilebogey".to_string()).implies(&Scope::borrowed("profile"))); + assert!(!ScopeSet("profile:write".to_string()) + .implies(&Scope::borrowed("https://identity.mozilla.com/apps/oldsync"))); + assert!(!ScopeSet("profile profile:email:write".to_string()) + .implies(&Scope::borrowed("profile:write"))); + assert!(!ScopeSet("https".to_string()) + .implies(&Scope::borrowed("https://identity.mozilla.com/apps/oldsync"))); + assert!(!ScopeSet("https://identity.mozilla.com/apps/oldsync".to_string()) + .implies(&Scope::borrowed("profile"))); + assert!(!ScopeSet("https://identity.mozilla.com/apps/oldsync#read".to_string()) + .implies(&Scope::borrowed("https://identity.mozilla.com/apps/oldsync/bookmarks"))); + assert!(!ScopeSet("https://identity.mozilla.com/apps/oldsync#write".to_string()) + .implies(&Scope::borrowed("https://identity.mozilla.com/apps/oldsync/bookmarks#read"))); + assert!(!ScopeSet("https://identity.mozilla.com/apps/oldsync/bookmarks".to_string()) + .implies(&Scope::borrowed("https://identity.mozilla.com/apps/oldsync"))); + assert!(!ScopeSet("https://identity.mozilla.com/apps/oldsync/bookmarks".to_string()) + .implies(&Scope::borrowed("https://identity.mozilla.com/apps/oldsync/passwords"))); + assert!(!ScopeSet("https://identity.mozilla.com/apps/oldsyncer".to_string()) + .implies(&Scope::borrowed("https://identity.mozilla.com/apps/oldsync"))); + assert!(!ScopeSet("https://identity.mozilla.com/apps/oldsync".to_string()) + .implies(&Scope::borrowed("https://identity.mozilla.com/apps/oldsyncer"))); + assert!(!ScopeSet("https://identity.mozilla.org/apps/oldsync".to_string()) + .implies(&Scope::borrowed("https://identity.mozilla.com/apps/oldsync"))); + } + + #[test] + fn test_scopes_allowed_by() { + const ALLOWED: [Scope; 2] = [ + Scope::borrowed("profile:write"), + Scope::borrowed("https://identity.mozilla.com/apps/oldsync"), + ]; + + assert!(ScopeSet("profile".to_string()).is_allowed_by(&ALLOWED)); + assert!(ScopeSet("profile:write".to_string()).is_allowed_by(&ALLOWED)); + assert!(ScopeSet("profile:email".to_string()).is_allowed_by(&ALLOWED)); + assert!(ScopeSet("profile:email:write".to_string()).is_allowed_by(&ALLOWED)); + assert!(ScopeSet("https://identity.mozilla.com/apps/oldsync".to_string()) + .is_allowed_by(&ALLOWED)); + assert!(ScopeSet("https://identity.mozilla.com/apps/oldsync#read".to_string()) + .is_allowed_by(&ALLOWED)); + assert!(ScopeSet("https://identity.mozilla.com/apps/oldsync/bookmarks".to_string()) + .is_allowed_by(&ALLOWED)); + assert!(ScopeSet("https://identity.mozilla.com/apps/oldsync/bookmarks#read".to_string()) + .is_allowed_by(&ALLOWED)); + assert!(ScopeSet("profile https://identity.mozilla.com/apps/oldsync".to_string()) + .is_allowed_by(&ALLOWED)); + + assert!(!ScopeSet("storage".to_string()).is_allowed_by(&ALLOWED)); + assert!(!ScopeSet("storage:write".to_string()).is_allowed_by(&ALLOWED)); + assert!(!ScopeSet("storage:email".to_string()).is_allowed_by(&ALLOWED)); + assert!(!ScopeSet("storage:email:write".to_string()).is_allowed_by(&ALLOWED)); + assert!(!ScopeSet("https://identity.mozilla.com/apps/newsync".to_string()) + .is_allowed_by(&ALLOWED)); + assert!(!ScopeSet("https://identity.mozilla.com/apps/newsync#read".to_string()) + .is_allowed_by(&ALLOWED)); + assert!(!ScopeSet("https://identity.mozilla.com/apps/newsync/bookmarks".to_string()) + .is_allowed_by(&ALLOWED)); + assert!(!ScopeSet("https://identity.mozilla.com/apps/newsync/bookmarks#read".to_string()) + .is_allowed_by(&ALLOWED)); + assert!(!ScopeSet("storage https://identity.mozilla.com/apps/newsync".to_string()) + .is_allowed_by(&ALLOWED)); + } + + #[test] + fn test_scopes_remove() { + assert_eq!( + ScopeSet("profile foo".to_string()).remove(&Scope::borrowed("profile")), + ScopeSet("foo".to_string()) + ); + assert_ne!( + ScopeSet("profile:write foo".to_string()).remove(&Scope::borrowed("profile")), + ScopeSet("foo".to_string()) + ); + assert_eq!( + ScopeSet("profile:write foo".to_string()).remove(&Scope::borrowed("profile:write")), + ScopeSet("foo".to_string()) + ); + assert_eq!( + ScopeSet("profile:x foo".to_string()).remove(&Scope::borrowed("profile")), + ScopeSet("foo".to_string()) + ); + assert_ne!( + ScopeSet("profile:x:write foo".to_string()).remove(&Scope::borrowed("profile")), + ScopeSet("foo".to_string()) + ); + assert_eq!( + ScopeSet("profile:x:write foo".to_string()).remove(&Scope::borrowed("profile:write")), + ScopeSet("foo".to_string()) + ); + + assert_eq!( + ScopeSet("https://foo/bar foo".to_string()).remove(&Scope::borrowed("https://foo/bar")), + ScopeSet("foo".to_string()) + ); + assert_eq!( + ScopeSet("https://foo/bar/baz foo".to_string()) + .remove(&Scope::borrowed("https://foo/bar")), + ScopeSet("foo".to_string()) + ); + assert_eq!( + ScopeSet("https://foo/bar#read foo".to_string()) + .remove(&Scope::borrowed("https://foo/bar")), + ScopeSet("foo".to_string()) + ); + assert_eq!( + ScopeSet("https://foo/bar/baz#read foo".to_string()) + .remove(&Scope::borrowed("https://foo/bar")), + ScopeSet("foo".to_string()) + ); + } +} diff --git a/src/utils.rs b/src/utils.rs new file mode 100644 index 0000000..5a2407a --- /dev/null +++ b/src/utils.rs @@ -0,0 +1,124 @@ +use std::convert::Infallible; + +use futures::Future; +use rocket::{ + fairing::{self, Fairing, Info, Kind}, + http::Status, + request::{FromRequest, Outcome}, + tokio::{ + self, + sync::broadcast::{channel, Sender}, + task::JoinHandle, + }, + Request, Response, Sentinel, +}; + +// does the same as try_outcome!(...).map_forward(|_| data), +// but without moving data in non-failure cases. +macro_rules! try_outcome_data { + ($data:expr, $e:expr) => { + match $e { + ::rocket::outcome::Outcome::Success(val) => val, + ::rocket::outcome::Outcome::Failure(e) => { + return ::rocket::outcome::Outcome::Failure(::std::convert::From::from(e)) + }, + ::rocket::outcome::Outcome::Forward(()) => { + return ::rocket::outcome::Outcome::Forward($data) + }, + } + }; +} + +pub trait CanBeLogged: Send + 'static { + fn into_message(self) -> Option; + fn not_run() -> Self; +} + +impl CanBeLogged for Result<(), anyhow::Error> { + fn into_message(self) -> Option { + self.err().map(|e| e.to_string()) + } + fn not_run() -> Self { + Ok(()) + } +} + +pub fn spawn_logged(context: &'static str, future: T) -> JoinHandle<()> +where + T: Future + Send + 'static, + T::Output: CanBeLogged + Send + 'static, +{ + tokio::spawn(async move { + if let Some(msg) = future.await.into_message() { + warn!("{context}: {msg}"); + } + }) +} + +pub struct DeferredActions; +struct HasDeferredActions; + +pub struct DeferAction(Sender<()>); + +#[async_trait] +impl Fairing for DeferredActions { + fn info(&self) -> Info { + Info { name: "deferred actions", kind: Kind::Ignite | Kind::Response } + } + + async fn on_ignite(&self, rocket: rocket::Rocket) -> fairing::Result { + Ok(rocket.manage(HasDeferredActions)) + } + + async fn on_response<'r>(&self, req: &'r Request<'_>, res: &mut Response<'r>) { + if res.status() == Status::Ok { + if let Some(DeferAction(tx)) = req.local_cache(|| None) { + // could have no receivers, that's not an error here + tx.send(()).ok(); + } + } + } +} + +impl<'r> Sentinel for &'r DeferAction { + fn abort(rocket: &rocket::Rocket) -> bool { + rocket.state::().is_none() + } +} + +#[async_trait] +impl<'r> FromRequest<'r> for &'r DeferAction { + type Error = Infallible; + + async fn from_request(req: &'r Request<'_>) -> Outcome { + Outcome::Success( + req.local_cache(|| { + let (tx, _) = channel(1); + Some(DeferAction(tx)) + }) + .as_ref() + .unwrap(), + ) + } +} + +impl DeferAction { + pub fn spawn_after_success(&self, context: &'static str, future: T) + where + T: Future + Send + 'static, + T::Output: CanBeLogged + Send + 'static, + { + let mut r = self.0.subscribe(); + spawn_logged(context, async move { + match r.recv().await { + Ok(_) => { + // the request finished with success, now wait for it to be dropped + // to ensure that all other fairings have run to completion. + r.recv().await.ok(); + future.await + }, + Err(_) => CanBeLogged::not_run(), + } + }); + } +} diff --git a/tests/_utils.py b/tests/_utils.py new file mode 100644 index 0000000..6ccd99c --- /dev/null +++ b/tests/_utils.py @@ -0,0 +1,421 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this file, +# You can obtain one at http://mozilla.org/MPL/2.0/. +""" + +fxa._utils: miscellaneous low-level utilities for PyFxA + +This private-api stuff that will most likely change, move, refactor +etc as we go. So don't import any of it outside of this package. + +""" +from __future__ import absolute_import +import os +import time +import hashlib +import hmac +import six +from binascii import hexlify, unhexlify +from base64 import b64encode +try: + import cPickle as pickle +except ImportError: # pragma: no cover + import pickle + +from six import PY3 +from six.moves.urllib.parse import urlparse, urljoin + +import requests +import requests.auth +import requests.utils +import hawkauthlib + +import fxa +import fxa.errors +import fxa.crypto + + +# Send a custom user-agent header +# so we're easy to identify in server logs etc. + +USER_AGENT_HEADER = ' '.join(( + 'Mozilla/5.0 (Mobile; Firefox Accounts; rv:1.0)', + 'PyFxA/%s' % (fxa.__version__), + requests.utils.default_user_agent(), +)) + + +if not PY3: + hexstr = hexlify +else: # pragma: no cover + def hexstr(data): + """Like binascii.hexlify, but always returns a str instance.""" + return hexlify(data).decode("ascii") + + +def uniq(size=10): + """Generate a short random hex string.""" + return hexstr(os.urandom(size // 2 + 1))[:size] + + +def get_hmac(data, secret, algorithm=hashlib.sha256): + """Generate an hexdigest hmac for given data, secret and algorithm.""" + return hmac.new(secret.encode('utf-8'), + data.encode('utf-8'), + algorithm).hexdigest() + + +def scope_matches(provided, required): + """Check that required scopes match the ones provided. This is used during + token verification to raise errors if expected scopes are not met. + + :note: + + The rules for parsing and matching scopes in FxA are documented at + https://github.com/mozilla/fxa-oauth-server/blob/master/docs/scopes.md + + :param provided: list of scopes provided for the current token. + :param required: the scope required (e.g. by the application). + :returns: ``True`` if all required scopes are provided, ``False`` if not. + """ + if isinstance(provided, six.string_types): + raise ValueError("Provided scopes must be a list, not a single string") + + if not isinstance(required, (list, tuple)): + required = [required] + + for req in required: + if not any(_match_single_scope(prov, req) for prov in provided): + return False + + return True + + +def _match_single_scope(provided, required): + if provided.startswith('https:'): + return _match_url_scope(provided, required) + else: + return _match_shortname_scope(provided, required) + + +def _match_shortname_scope(provided, required): + if required.startswith('https:'): + return False + prov_names = provided.split(':') + req_names = required.split(':') + # If we require :write, it must be provided. + if req_names[-1] == 'write': + if prov_names[-1] != 'write': + return False + req_names.pop() + prov_names.pop() + elif prov_names[-1] == 'write': + prov_names.pop() + # Provided names must be a prefix of required names. + if len(prov_names) > len(req_names): + return False + for (p, r) in zip(prov_names, req_names): + if p != r: + return False + # It matches! + return True + + +def _match_url_scope(provided, required): + if not required.startswith('https:'): + return False + # Pop the hash fragments + (prov_url, prov_hash) = (provided.rsplit('#', 1) + [None])[:2] + (req_url, req_hash) = (required.rsplit('#', 1) + [None])[:2] + # Provided URL must be a prefix of required. + if req_url != prov_url: + if not (req_url.startswith(prov_url + '/')): + return False + # If hash is provided, it must match that required. + if prov_hash: + if not req_hash or req_hash != prov_hash: + return False + # It matches! + return True + + +class APIClient(object): + """A requests.Session wrapper specialized for FxA API access. + + An instance of this class should be used for making requests to an FxA + web API endpoint. It wraps a requests.Session instance and provides + a broadly similar interface, with some additional functionality that's + specific to Firefox Accounts: + + * default base server URL + * backoff protocol support + * sensible request timeouts + * timestamp skew tracking with automatic retry on clockskew error + + """ + + def __init__(self, server_url, session=None): + if session is None: + session = requests.Session() + # Properties that can be customized to change behaviour. + self.server_url = server_url + self.timeout = 30 + self.max_retry_after = None + # Internal state. + self._session = session + self._backoff_until = 0 + self._backoff_response = None + self._clockskew = None + + # Reflect useful properties of the wrapped Session object. + + @property + def headers(self): + return self._session.headers + + @headers.setter + def headers(self, value): + self._session.headers = value + + @property + def auth(self): + return self._session.auth + + @auth.setter + def auth(self, value): + if getattr(value, "apiclient", None) is None: + value.apiclient = self + self._session.auth = value + + @property + def hooks(self): + return self._session.hooks + + @hooks.setter + def hooks(self, value): + self._session.hooks = value + + @property + def verify(self): + return self._session.verify + + @verify.setter + def verify(self, value): + self._session.verify = value + + # Add some handy utility methods of our own. + + def client_curtime(self): + """Get the current timestamp, as seen by the client. + + This is a helper function that returns the current local time. + It's mostly here for symmetry with server_curtime() and to assist + in testability of this class. + """ + return time.time() + + def server_curtime(self): + """Get the current timestamp, as seen by the server. + + This is a helper function that automatically applies any detected + clock-skew, to report what the current timestamp is on the server + instead of on the client. + """ + return self.client_curtime() + (self._clockskew or 0) + + # The actual request-making stuff. + + def request(self, method, url, json=None, retry_auth_errors=True, **kwds): + """Make a request to the API and process the response. + + This method implements the low-level details of interacting with an + FxA Web API, stripping away most of the details of HTTP. It will + return the parsed JSON of a successful responses, or raise an exception + for an error response. It's also responsible for backoff handling + and clock-skew tracking. + """ + # Don't make requests if we're in backoff. + # Instead just synthesize a backoff response. + if self._backoff_response is not None: + if self._backoff_until >= self.client_curtime(): + resp = pickle.loads(self._backoff_response) + resp.request = None + resp.headers["Timestamp"] = str(int(self.server_curtime())) + return resp + else: + self._backoff_until = 0 + self._backoff_response = None + + # Apply defaults and perform the request. + while url.startswith("/"): + url = url[1:] + if self.server_url.endswith("/"): + url = urljoin(self.server_url, url) + else: + url = urljoin(self.server_url + "/", url) + if self.timeout is not None: + kwds.setdefault("timeout", self.timeout) + + # Configure the user agent + headers = kwds.get('headers', {}) + headers.setdefault('User-Agent', USER_AGENT_HEADER) + kwds['headers'] = headers + + resp = self._session.request(method, url, json=json, **kwds) + + # Everything should return a valid JSON response. Even errors. + content_type = resp.headers.get("content-type", "") + if not content_type.startswith("application/json"): + msg = "API responded with non-json content-type: {0}" + raise fxa.errors.OutOfProtocolError(msg.format(content_type)) + try: + body = resp.json() + except ValueError as e: + msg = "API responded with invalid json: {0}" + raise fxa.errors.OutOfProtocolError(msg.format(e)) + + # Check for backoff indicator from the server. + # If found, backoff up to the client-specified max time. + if resp.status_code in (429, 500, 503): + try: + retry_after = int(resp.headers["retry-after"]) + except (KeyError, ValueError): + pass + else: + if self.max_retry_after is not None: + retry_after = max(retry_after, self.max_retry_after) + self._backoff_until = self.client_curtime() + retry_after + self._backoff_response = pickle.dumps(resp) + + # If we get a 401 with "serverTime" field in the body, then we're + # probably out of sync with the server's clock. Check our skew, + # adjust if necessary and try again. + if retry_auth_errors: + if resp.status_code == 401 and "serverTime" in body: + try: + server_timestamp = int(body["serverTime"]) + except ValueError: + msg = "API responded with non-integer serverTime: {0}" + msg = msg.format(body["serverTime"]) + raise fxa.errors.OutOfProtocolError(msg) + # If our guestimate is more than 30 seconds out, try again. + # This assumes the auth hook will use the updated clockskew. + if abs(server_timestamp - self.server_curtime()) > 30: + self._clockskew = server_timestamp - self.client_curtime() + return self.request(method, url, json, False, **kwds) + + # See if we need to adjust for clock skew between client and server. + # We do this automatically once per session in the hopes of avoiding + # having to retry subsequent auth failures. We do it *after* the retry + # checking above, because it wrecks the "were we out of sync?" check. + if self._clockskew is None and "timestamp" in resp.headers: + try: + server_timestamp = int(resp.headers["timestamp"]) + except ValueError: + msg = "API responded with non-integer timestamp: {0}" + msg = msg.format(resp.headers["timestamp"]) + raise fxa.errors.OutOfProtocolError(msg) + else: + self._clockskew = server_timestamp - self.client_curtime() + + # Raise exceptions for any error responses. + # XXX TODO: hooks for raising error subclass based on errno. + if 400 <= resp.status_code < 500: + raise fxa.errors.ClientError(body) + if 500 <= resp.status_code < 600: + raise fxa.errors.ServerError(body) + if resp.status_code < 200 or resp.status_code >= 300: + msg = "API responded with unexpected status code: {0}" + raise fxa.errors.OutOfProtocolError(msg.format(resp.status_code)) + + # Return the parsed JSON body for successful responses. + return body + + def get(self, url, **kwds): + return self.request("GET", url, **kwds) + + def post(self, url, json=None, **kwds): + return self.request("POST", url, json, **kwds) + + def put(self, url, json=None, **kwds): + return self.request("PUT", url, json, **kwds) + + def delete(self, url, **kwds): + return self.request("DELETE", url, **kwds) + + +class HawkTokenAuth(requests.auth.AuthBase): + """A requests auth hook implementing token-based hawk auth. + + This auth hook implements the hkdf-derived-hawk-token auth scheme + as used by the Firefox Accounts auth server. It uses HKDF to derive + an id and secret key from a random 32-byte token, then signs the request + with those credentials using the Hawk request-signing scheme. + """ + + def __init__(self, token, tokentype, apiclient=None): + tokendata = unhexlify(token) + key_material = fxa.crypto.derive_key(tokendata, tokentype, 3*32) + self.id = hexstr(key_material[:32]) + self.auth_key = key_material[32:64] + self.bundle_key = key_material[64:] + self.apiclient = apiclient + + def __call__(self, req): + # Requests doesn't include the port in the Host header by default. + # Ensure a fully-correct value so that signatures work properly. + req.headers["Host"] = urlparse(req.url).netloc + params = {} + if req.body: + body = _encoded(req.body, 'utf-8') + hasher = hashlib.sha256() + hasher.update(b"hawk.1.payload\napplication/json\n") + hasher.update(body) + hasher.update(b"\n") + hash = b64encode(hasher.digest()) + if PY3: + hash = hash.decode("ascii") + params["hash"] = hash + if self.apiclient is not None: + params["ts"] = str(int(self.apiclient.server_curtime())) + hawkauthlib.sign_request(req, self.id, self.auth_key, params=params) + return req + + def bundle(self, namespace, payload): + """Bundle encrypted response data.""" + return fxa.crypto.bundle(self.bundle_key, namespace, payload) + + def unbundle(self, namespace, payload): + """Unbundle encrypted response data.""" + return fxa.crypto.unbundle(self.bundle_key, namespace, payload) + + +class BearerTokenAuth(requests.auth.AuthBase): + """A requests auth hook implementing OAuth bearer-token-based auth. + + This auth hook implements the simple "bearer token" auth scheme. + The provided token is passed directly in the Authorization header. + """ + + def __init__(self, token, apiclient=None): + self.token = token + + def __call__(self, req): + req.headers["Authorization"] = "Bearer {0}".format(self.token) + return req + + +def _decoded(value, encoding='utf-8'): + """Make sure the value is of type ``unicode`` in both PY2 and PY3.""" + value_type = type(value) + if value_type != six.text_type: + value = value.decode(encoding) + return value + + +def _encoded(value, encoding='utf-8'): + """Make sure the value is of type ``bytes`` in both PY2 and PY3.""" + value_type = type(value) + if value_type != six.binary_type: + return value.encode(encoding) + return value diff --git a/tests/api.py b/tests/api.py new file mode 100644 index 0000000..9d2f70d --- /dev/null +++ b/tests/api.py @@ -0,0 +1,252 @@ +import asyncio +import base64 +import binascii +import http.server +import http_ece +import json +import os +import queue +import quopri +import threading +from _utils import HawkTokenAuth, APIClient, hexstr +from aiosmtpd.controller import Controller +from cryptography.hazmat.backends import default_backend +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import ec +from fxa.crypto import quick_stretch_password, derive_key, xor + +AUTH_URL = "http://localhost:8000/auth" +PROFILE_URL = "http://localhost:8000/profile" +OAUTH_URL = "http://localhost:8000/oauth" +INVITE_URL = "http://localhost:8000/_invite" +PUSH_PORT = 10264 +SMTP_PORT = 2525 + +def auth_pw(email, pw): + return derive_key(quick_stretch_password(email, pw), "authPW").hex() + +class AuthClient: + def __init__(self, /, email=None, session=None, bearer=None, props=None): + self.password = "" + self.client = APIClient(f"{AUTH_URL}/v1") + self.email = email + assert int(session is not None) + int(bearer is not None) < 2 + self.session = session + self.auth = HawkTokenAuth(session, "sessionToken", self.client) if session else None + self.bearer = bearer + self.headers = { 'authorization': f'bearer {bearer}' } if bearer else {} + self.props = props + + def post(self, url, json=None, **kwds): + return self.client.post(url, json, **kwds) + def post_a(self, url, json=None, **kwds): + kwds.setdefault('headers', {}) + kwds['headers'] |= self.headers + return self.client.post(url, json, auth=self.auth, **kwds) + + def get(self, url, **kwds): + return self.client.get(url, **kwds) + def get_a(self, url, **kwds): + kwds.setdefault('headers', {}) + kwds['headers'] |= self.headers + return self.client.get(url, auth=self.auth, **kwds) + + def delete(self, url, **kwds): + return self.client.delete(url, **kwds) + def delete_a(self, url, **kwds): + kwds.setdefault('headers', {}) + kwds['headers'] |= self.headers + return self.client.delete(url, auth=self.auth, **kwds) + + def create_account(self, email, pw, keys=None, invite=None, **kwds): + body = { + "email": email, + "authPW": hexstr(derive_key(quick_stretch_password(email, pw), "authPW")), + "style": invite, + } + params = { 'keys': str(keys).lower() } if keys is not None else {} + resp = self.client.post("/account/create", body, params=params, **kwds) + return AuthClient(email=email, session=resp['sessionToken'], props=resp) + def destroy_account(self, email, pw, **kwds): + body = { "email": email, "authPW": hexstr(derive_key(quick_stretch_password(email, pw), "authPW")) } + return self.client.post("/account/destroy", body) + + def fetch_keys(self, key_fetch_token, pw): + pw = quick_stretch_password(self.email, pw) + auth = HawkTokenAuth(key_fetch_token, "keyFetchToken", self.client) + resp = self.client.get("/account/keys", auth=auth) + bundle = binascii.unhexlify(resp["bundle"]) + keys = auth.unbundle("account/keys", bundle) + unwrap_key = derive_key(pw, "unwrapBkey") + return (keys[:32], xor(keys[32:], unwrap_key)) + + def login(self, email, pw, keys=None, **kwds): + body = { "email": email, "authPW": hexstr(derive_key(quick_stretch_password(email, pw), "authPW")) } + params = { "keys": str(keys).lower() } if keys is not None else {} + resp = self.client.post("/account/login", body, params=params) + return AuthClient(email=email, session=resp['sessionToken'], props=resp) + def destroy_session(self, **kwds): + return self.post_a("/session/destroy", kwds) + + def profile(self): + token = self.post_a("/oauth/token", { + "client_id": "5882386c6d801776", + "ttl": 60, + "grant_type": "fxa-credentials", + "access_type": "online", + "scope": "profile:write", + }) + return Profile(token['access_token']) + +class Invite: + def __init__(self, token): + self.client = APIClient(f"{INVITE_URL}/v1") + self.auth = HawkTokenAuth(token, "sessionToken", self.client) + + def post(self, url, json=None, **kwds): + return self.client.post(url, json, **kwds) + def post_a(self, url, json=None, **kwds): + return self.client.post(url, json, auth=self.auth, **kwds) + +class PasswordChange: + def __init__(self, client, token, hkdf='passwordChangeToken'): + self.client = client + self.auth = HawkTokenAuth(token, hkdf, self.client) + + def post(self, url, json=None, **kwds): + return self.client.post(url, json, **kwds) + def post_a(self, url, json=None, **kwds): + return self.client.post(url, json, auth=self.auth, **kwds) + +class AccountReset: + def __init__(self, client, token): + self.client = client + self.auth = HawkTokenAuth(token, 'accountResetToken', self.client) + + def post(self, url, json=None, **kwds): + return self.client.post(url, json, **kwds) + def post_a(self, url, json=None, **kwds): + return self.client.post(url, json, auth=self.auth, **kwds) + +class Profile: + def __init__(self, token): + self.client = APIClient(f"{PROFILE_URL}/v1") + self.token = token + + def get(self, url, **kwds): + return self.client.get(url, **kwds) + def get_a(self, url, **kwds): + kwds.setdefault('headers', {}) + kwds['headers']['authorization'] = f'bearer {self.token}' + return self.client.get(url, **kwds) + + def post(self, url, json=None, **kwds): + return self.client.post(url, json, **kwds) + def post_a(self, url, json=None, **kwds): + kwds.setdefault('headers', {}) + kwds['headers']['authorization'] = f'bearer {self.token}' + return self.client.post(url, json, **kwds) + + def delete(self, url, **kwds): + return self.client.delete(url, **kwds) + def delete_a(self, url, **kwds): + kwds.setdefault('headers', {}) + kwds['headers']['authorization'] = f'bearer {self.token}' + return self.client.delete(url, **kwds) + +class Oauth: + def __init__(self): + self.client = APIClient(f"{OAUTH_URL}/v1") + + def post(self, url, json=None, **kwds): + return self.client.post(url, json, **kwds) + +class Device: + def __init__(self, auth, name, type="desktop", commands={}, pcb=None): + self.auth = auth + dev = auth.post_a("/account/device", { + "name": name, + "type": type, + "availableCommands": commands, + } | self._mk_push(pcb)) + self.id = dev['id'] + self.props = dev + + def _mk_push(self, pcb): + if not pcb: + return {} + + self.priv = ec.generate_private_key(ec.SECP256R1, default_backend()) + self.public = self.priv.public_key().public_bytes( + encoding=serialization.Encoding.X962, + format=serialization.PublicFormat.UncompressedPoint) + self.authkey = os.urandom(16) + return { + "pushCallback": pcb, + "pushPublicKey": base64.urlsafe_b64encode(self.public).decode('utf8'), + "pushAuthKey": base64.urlsafe_b64encode(self.authkey).decode('utf8'), + } + + def update_pcb(self, pcb): + self.props = self.auth.post_a("/account/device", { "id": self.id } | self._mk_push(pcb)) + + def decrypt(self, data): + raw = http_ece.decrypt(data, private_key=self.priv, auth_secret=self.authkey) + return json.loads(raw.decode('utf8')) + +class PushServer: + def __init__(self): + q = self.q = queue.Queue() + + class Handler(http.server.BaseHTTPRequestHandler): + def do_POST(self): + if self.path.startswith("/err/"): + self.send_response(410) + self.end_headers() + else: + self.send_response(200) + self.end_headers() + + q.put((self.path, self.headers, self.rfile.read(int(self.headers['content-length'])))) + + server = self.server = http.server.ThreadingHTTPServer(("localhost", PUSH_PORT), Handler) + threading.Thread(target=server.serve_forever).start() + + def wait(self, timeout=2): + return self.q.get(timeout=timeout) + def done(self, timeout=2): + try: + self.q.get(timeout=timeout) + return False + except queue.Empty: + return True + + def good(self, id): + return f"http://localhost:{PUSH_PORT}/{id}" + def bad(self, id): + return f"http://localhost:{PUSH_PORT}/err/{id}" + +class MailServer: + def __init__(self): + q = self.q = queue.Queue() + + class Handler: + async def handle_RCPT(self, server, session, envelope, address, rcpt_options): + envelope.rcpt_tos.append(address) + return '250 OK' + + async def handle_DATA(self, server, session, envelope): + headers, body = envelope.content.decode('utf8').split("\r\n\r\n", maxsplit=1) + if "Content-Transfer-Encoding: quoted-printable" in headers: + body = quopri.decodestring(body).decode('utf8') + q.put((envelope.rcpt_tos, body)) + return '250 Message accepted for delivery' + + self.controller = Controller(Handler(), hostname="localhost", port=SMTP_PORT) + self.controller.start() + + def stop(self): + self.controller.stop() + + def wait(self, timeout=2): + return self.q.get(timeout=timeout) diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..15149cb --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,115 @@ +import base64 +import json +import pytest +from fxa.errors import ClientError + +from api import * + +@pytest.fixture +def push_server(): + s = PushServer() + yield s + s.server.shutdown() + s.server.server_close() + +@pytest.fixture +def mail_server(): + s = MailServer() + yield s + s.stop() + +@pytest.fixture +def client(): + return AuthClient() + +def _login(client, email, mail_server): + # unverified accounts and unverified session behave the same, so we don't bother + # with dedicated unverified-session tests and just always verify. + c = client.login(email, "") + (to, body) = mail_server.wait() + assert to == [email] + c.post_a('/session/verify_code', { 'code': body.strip() }) + return c + +def _account(client, primary, email, mail_server): + s = client.create_account(email, "") + try: + (to, body) = mail_server.wait() + assert to == [email] + data = json.loads(base64.urlsafe_b64decode(body.split("#/verify/", maxsplit=1)[1]).decode('utf8')) + s.post_a('/recovery_email/verify_code', { 'uid': data['uid'], 'code': data['code'] }) + if primary: + yield s + else: + c = _login(client, email, mail_server) + yield c + s.password = c.password + finally: + try: + s.destroy_account(email, s.password) + except ClientError as e: + # don't fail if the account was already deleted + if e.details['errno'] != 102: + raise + +@pytest.fixture(params=[True, False], ids=["primary", "secondary"]) +def account(client, request, mail_server): + for a in _account(client, request.param, "test.account@test-auth", mail_server): + yield a +@pytest.fixture(params=[True, False], ids=["primary", "secondary"]) +def account2(client, request, mail_server): + for a in _account(client, request.param, "test.account2@test-auth", mail_server): + yield a + +@pytest.fixture +def unverified_account(client, mail_server): + s = client.create_account("test.account@test-auth", "") + yield s + s.destroy_account("test.account@test-auth", "") + +@pytest.fixture +def login(client, mail_server): + return _login(client, "test.account@test-auth", mail_server) +@pytest.fixture +def login2(client, mail_server): + return _login(client, "test.account2@test-auth", mail_server) + +def _refresh_token(account, scope): + body = { + "client_id": "5882386c6d801776", + "ttl": 60, + "grant_type": "fxa-credentials", + "access_type": "offline", + "scope": scope, + } + yield account.post_a("/oauth/token", body) + +@pytest.fixture +def refresh_token(account): + for r in _refresh_token(account, "profile https://identity.mozilla.com/apps/oldsync https://identity.mozilla.com/tokens/session"): + yield AuthClient(email=account.email, bearer=r['refresh_token'], props=r) +@pytest.fixture +def narrow_refresh_token(account): + for r in _refresh_token(account, "profile https://identity.mozilla.com/tokens/session"): + yield AuthClient(email=account.email, bearer=r['refresh_token'], props=r) + +def _account_or_rt(account, request, scope): + if request.param: + yield account + else: + for r in _refresh_token(account, scope): + yield AuthClient(email=account.email, bearer=r['refresh_token'], props=r) + +@pytest.fixture(params=[True, False], ids=["session", "refresh_token"]) +def account_or_rt(account, request): + for r in _account_or_rt(account, request, "profile https://identity.mozilla.com/apps/oldsync https://identity.mozilla.com/tokens/session"): + yield r + +@pytest.fixture +def forgot_token(account): + resp = account.post_a("/password/forgot/send_code", { 'email': account.email }) + assert 'passwordForgotToken' in resp + assert resp['ttl'] == 300 + assert resp['codeLength'] == 16 + assert resp['tries'] == 1 + return PasswordChange(account.client, resp['passwordForgotToken'], 'passwordForgotToken') diff --git a/tests/integration.rs b/tests/integration.rs new file mode 100644 index 0000000..afded52 --- /dev/null +++ b/tests/integration.rs @@ -0,0 +1,73 @@ +use std::env; + +use anyhow::Context; +use base64::URL_SAFE_NO_PAD; +use chrono::{Duration, Utc}; +use futures::channel::oneshot::channel; +use minor_skulk::{build, db::Db}; +use password_hash::rand_core::OsRng; +use rand::RngCore; +use rocket::{ + fairing::AdHoc, + tokio::{process::Command, spawn}, +}; + +#[macro_use] +extern crate rocket; +extern crate anyhow; + +async fn run_pytest(markers: &'static str, invite: bool) -> anyhow::Result<()> { + dotenv::dotenv().ok(); + // at this point this is only a test runner to be used by the nix build. + env::set_var("ROCKET_LOG_LEVEL", "off"); + + let (tx, rx) = channel(); + let rocket = build().await?.attach(AdHoc::on_liftoff("notify startup", move |rocket| { + Box::pin(async move { + // add an invite code as-if generated during startup and move it to an + // env var, emulating the user looking at the logs and copying the code. + // invite_only needs this to function. + if invite { + let db = rocket.state::().unwrap(); + let tx = db.begin().await.unwrap(); + let mut code = [0; 32]; + OsRng.fill_bytes(&mut code); + let code = base64::encode_config(code, URL_SAFE_NO_PAD); + tx.add_invite_code(&code, Utc::now() + Duration::minutes(5)).await.unwrap(); + tx.commit().await.unwrap(); + env::set_var("INVITE_CODE", code); + } + + tx.send(()).unwrap(); + }) + })); + + spawn(async move { rocket.launch().await }); + let test = spawn(async move { + rx.await.expect("test trigger failed"); + let mut child = Command::new("pytest") + .arg("-vvv") + .arg("-m") + .arg(markers) + .spawn() + .expect("failed to spawn"); + child.wait().await + }); + + assert!(test.await.context("starting pytest")?.context("running pytest")?.success()); + + Ok(()) +} + +#[async_test] +async fn open() -> anyhow::Result<()> { + env::set_var("ROCKET_INVITE_ONLY", "false"); + run_pytest("not invite", false).await +} + +#[async_test] +async fn invite_only() -> anyhow::Result<()> { + env::set_var("ROCKET_INVITE_ONLY", "true"); + env::set_var("ROCKET_INVITE_ADMIN_ADDRESS", "test.account@test-auth"); + run_pytest("invite", true).await +} diff --git a/tests/smtp.py b/tests/smtp.py new file mode 100644 index 0000000..fa6e16b --- /dev/null +++ b/tests/smtp.py @@ -0,0 +1,27 @@ +import asyncio +import quopri +from aiosmtpd.controller import Controller + +class PrintHandler: + async def handle_RCPT(self, server, session, envelope, address, rcpt_options): + envelope.rcpt_tos.append(address) + return '250 OK' + + async def handle_DATA(self, server, session, envelope): + print('Message from %s' % envelope.mail_from) + print('Message for %s' % envelope.rcpt_tos) + print('Message data:\n') + headers, body = envelope.content.decode('utf8').split("\r\n\r\n", maxsplit=1) + if "Content-Transfer-Encoding: quoted-printable" in headers: + body = quopri.decodestring(body).decode('utf8') + print(headers) + print() + print(body) + print() + print('End of message') + return '250 Message accepted for delivery' + +if __name__ == '__main__': + controller = Controller(PrintHandler(), hostname="localhost", port=2525) + controller.start() + input() diff --git a/tests/test_auth_account.py b/tests/test_auth_account.py new file mode 100644 index 0000000..68a407b --- /dev/null +++ b/tests/test_auth_account.py @@ -0,0 +1,348 @@ +import os +import pytest +from fxa.errors import ClientError + +from api import * + +def test_create_no_args(client): + with pytest.raises(ClientError) as e: + client.post("/account/create") + assert e.value.details == { + 'code': 400, + 'errno': 106, + 'error': 'Bad Request', + 'message': 'invalid json in request body' + } + +@pytest.mark.parametrize("args", [ + {"email": "", "authPW": "", "extra": ""}, + {"email": "", "authPW": "00"}, + {"email": "a" * 257, "authPW": "0" * 64}, + {"email": "a@test", "authPW": "00"}, + {"email": "a@test", "authPW": "00" * 32, "style": "foo"}, +]) +def test_create_invalid_args(client, args): + with pytest.raises(ClientError) as e: + client.post("/account/create", args) + assert e.value.details == { + 'code': 400, + 'errno': 107, + 'error': 'Bad Request', + 'message': 'invalid parameter in request body' + } + +def test_create_nomail(client): + with pytest.raises(ClientError) as e: + client.create_account("test.account@test-auth", "") + assert e.value.details == { + 'code': 422, + 'errno': 151, + 'error': 'Unprocessable Entity', + 'message': 'failed to send email' + } + +def test_create_exists(account): + with pytest.raises(ClientError) as e: + account.create_account(account.email, "") + assert e.value.details == { + 'code': 400, + 'errno': 101, + 'error': 'Bad Request', + 'message': 'account already exists' + } + +@pytest.mark.parametrize("keys", [None, False, True]) +@pytest.mark.parametrize("verify", [False, True]) +def test_create_destroy(client, keys, verify, mail_server): + s = client.create_account("test.create.destroy@test-auth", "", keys=keys) + try: + if verify: + (to, body) = mail_server.wait() + assert to == [s.email] + data = json.loads(base64.urlsafe_b64decode(body.split("#/verify/", maxsplit=1)[1]).decode('utf8')) + s.post_a('/recovery_email/verify_code', { 'uid': data['uid'], 'code': data['code'] }) + if keys: + k = s.fetch_keys(s.props['keyFetchToken'], "") + with pytest.raises(ClientError) as e: + s.fetch_keys(s.props['keyFetchToken'], "") + assert e.value.details == { + 'code': 401, + 'errno': 109, + 'error': 'Unauthorized', + 'message': 'invalid request signature' + } + finally: + client.destroy_account("test.create.destroy@test-auth", "") + +def test_login_no_args(client): + with pytest.raises(ClientError) as e: + client.post("/account/login") + assert e.value.details == { + 'code': 400, + 'errno': 106, + 'error': 'Bad Request', + 'message': 'invalid json in request body' + } + +@pytest.mark.parametrize("args", [ + {"email": "", "authPW": "", "extra": ""}, + {"email": "", "authPW": "00"}, + {"email": "a" * 257, "authPW": "0" * 64}, + {"email": "a@test", "authPW": "00"}, +]) +def test_login_invalid_args(client, args): + with pytest.raises(ClientError) as e: + client.post("/account/login", args) + assert e.value.details == { + 'code': 400, + 'errno': 107, + 'error': 'Bad Request', + 'message': 'invalid parameter in request body' + } + +def test_login_noaccount(client): + with pytest.raises(ClientError) as e: + client.login("test@test", "") + assert e.value.details == { + 'code': 400, + 'errno': 102, + 'error': 'Bad Request', + 'message': 'unknown account' + } + +def test_login_unverified(client, unverified_account): + with pytest.raises(ClientError) as e: + client.login(unverified_account.email, "") + assert e.value.details == { + 'code': 400, + 'errno': 104, + 'error': 'Bad Request', + 'message': 'unverified account' + } + +def test_login_badcase(account): + with pytest.raises(ClientError) as e: + account.login(account.email.upper(), "") + assert e.value.details == { + 'code': 400, + 'errno': 120, + 'error': 'Bad Request', + 'message': 'incorrect email case' + } + +def test_login_badpasswd(account): + with pytest.raises(ClientError) as e: + account.login(account.email, "ca0cb780-f114-405a-a5c2-1a4deb933a51") + assert e.value.details == { + 'code': 400, + 'errno': 103, + 'error': 'Bad Request', + 'message': 'incorrect password' + } + +@pytest.mark.parametrize("keys", [None, False, True]) +def test_login(client, account, keys): + s = client.login(account.email, "", keys=keys) + try: + if keys: + s.fetch_keys(s.props['keyFetchToken'], "") + with pytest.raises(ClientError) as e: + s.fetch_keys(s.props['keyFetchToken'], "") + assert e.value.details == { + 'code': 401, + 'errno': 109, + 'error': 'Unauthorized', + 'message': 'invalid request signature' + } + finally: + s.destroy_session() + +@pytest.mark.parametrize("args", [ + {"email": "", "authPW": "", "extra": ""}, + {"email": "", "authPW": "00"}, + {"email": "a" * 257, "authPW": "0" * 64}, + {"email": "a@test", "authPW": "00"}, +]) +def test_destroy_invalid_args(client, args): + with pytest.raises(ClientError) as e: + client.post("/account/destroy", args) + assert e.value.details == { + 'code': 400, + 'errno': 107, + 'error': 'Bad Request', + 'message': 'invalid parameter in request body' + } + +def test_destroy_noaccount(client): + with pytest.raises(ClientError) as e: + client.destroy_account("test@test", "") + assert e.value.details == { + 'code': 400, + 'errno': 102, + 'error': 'Bad Request', + 'message': 'unknown account' + } + +def test_destroy_badcase(account): + with pytest.raises(ClientError) as e: + account.destroy_account(account.email.upper(), "") + assert e.value.details == { + 'code': 400, + 'errno': 120, + 'error': 'Bad Request', + 'message': 'incorrect email case' + } + +def test_destroy_badpasswd(account): + with pytest.raises(ClientError) as e: + account.destroy_account(account.email, "ca0cb780-f114-405a-a5c2-1a4deb933a51") + assert e.value.details == { + 'code': 400, + 'errno': 103, + 'error': 'Bad Request', + 'message': 'incorrect password' + } + +@pytest.mark.parametrize("auth", [ + {}, + {"authorization": "bearer invalid"}, + {"authorization": "hawk id=invalid"}, +]) +def test_keys_invalid(client, auth): + with pytest.raises(ClientError) as e: + client.get("/account/keys", headers=auth) + assert e.value.details == { + 'code': 401, + 'errno': 109, + 'error': 'Unauthorized', + 'message': 'invalid request signature' + } + +# create and login do the remaining keyfetch tests. +# we don't check whether the bundle is actually *valid* because we'd have to look +# into the db to do a full test, so we'll trust it is correct if sync works. + +@pytest.fixture +def reset_token(account, forgot_token, mail_server): + (to, body) = mail_server.wait() + assert account.email in to + resp = forgot_token.post_a("/password/forgot/verify_code", { 'code': body.strip() }) + return AccountReset(account.client, resp['accountResetToken']) + +@pytest.mark.parametrize("args", [ + { 'authPW': '00', }, + { 'authPW': '00' * 32, 'extra': 0, }, +]) +def test_reset_invalid(reset_token, args): + with pytest.raises(ClientError) as e: + reset_token.post_a("/account/reset", args) + assert e.value.details == { + 'code': 400, + 'errno': 107, + 'error': 'Bad Request', + 'message': 'invalid parameter in request body' + } + +def test_reset(account, reset_token, mail_server, push_server): + dev = Device(account, "dev", pcb=push_server.good("d4105515-f4f0-4d26-85c1-f48c5c348edb")) + resp = reset_token.post_a("/account/reset", { 'authPW': auth_pw(account.email, "") }) + assert resp == {} + (to, body) = mail_server.wait() + assert account.email in to + assert 'account has been reset' in body + p = push_server.wait() + assert p[0] == "/d4105515-f4f0-4d26-85c1-f48c5c348edb" + assert dev.decrypt(p[2]) == {'command': 'fxaccounts:password_reset', 'version': 1} + p = push_server.wait() + assert p[0] == "/d4105515-f4f0-4d26-85c1-f48c5c348edb" + assert dev.decrypt(p[2]) == { + 'command': 'fxaccounts:device_disconnected', + 'data': {'id': dev.id}, + 'version': 1 + } + assert push_server.done() + +def test_reset(account, reset_token, mail_server, push_server): + reset_token.post_a("/account/reset", { 'authPW': auth_pw(account.email, "") }) + with pytest.raises(ClientError) as e: + reset_token.post_a("/account/reset", { 'authPW': auth_pw(account.email, "") }) + assert e.value.details == { + 'code': 401, + 'errno': 109, + 'error': 'Unauthorized', + 'message': 'invalid request signature' + } + +@pytest.mark.invite +def test_create_noinvite(client): + with pytest.raises(ClientError) as e: + client.create_account("test.create.destroy@test-auth", "") + assert e.value.details == { + 'code': 400, + 'errno': -1, + 'error': 'Bad Request', + 'message': 'invite code required' + } + +@pytest.mark.invite +def test_create_badinvite(client): + with pytest.raises(ClientError) as e: + client.create_account("test.create.destroy@test-auth", "", { 'style': '' }) + assert e.value.details == { + 'code': 400, + 'errno': -1, + 'error': 'Bad Request', + 'message': 'invite code required' + } + +@pytest.mark.invite +def test_create_invite(client, mail_server): + # all in one test because restarting the server from python would be a pain + c = client.create_account("test.account@test-auth", "", invite=os.environ['INVITE_CODE']) + c2 = None + try: + invite = Invite(c.props['sessionToken']) + # unverified sessions fail + with pytest.raises(ClientError) as e: + code = invite.post_a('/generate', { 'ttl_hours': 1 }) + assert e.value.details == { + 'code': 400, + 'errno': 138, + 'error': 'Bad Request', + 'message': 'unverified session' + } + # verified session works + (to, body) = mail_server.wait() + data = json.loads(base64.urlsafe_b64decode(body.split("#/verify/", maxsplit=1)[1]).decode('utf8')) + c.post_a('/recovery_email/verify_code', { 'uid': data['uid'], 'code': data['code'] }) + code = invite.post_a('/generate', { 'ttl_hours': 1 }) + assert 'url' in code + code = code['url'].split('/')[-1] + # code allows registration + c2 = client.create_account("test.account2@test-auth", "", invite=code) + (to, body) = mail_server.wait() + data = json.loads(base64.urlsafe_b64decode(body.split("#/verify/", maxsplit=1)[1]).decode('utf8')) + c2.post_a('/recovery_email/verify_code', { 'uid': data['uid'], 'code': data['code'] }) + # token is consumed + with pytest.raises(ClientError) as e: + client.create_account("test.account3@test-auth", "", invite=code) + assert e.value.details == { + 'code': 400, + 'errno': -2, + 'error': 'Bad Request', + 'message': 'invite code not found' + } + # non-admin accounts can't generate tokens + with pytest.raises(ClientError) as e: + invite2 = Invite(c2.props['sessionToken']) + invite2.post_a('/generate', { 'ttl_hours': 1 }) + assert e.value.details == { + 'code': 401, + 'errno': 110, + 'error': 'Unauthorized', + 'message': 'invalid authentication token' + } + finally: + c.destroy_account(c.email, "") + if c2 is not None: + c2.destroy_account(c2.email, "") diff --git a/tests/test_auth_device.py b/tests/test_auth_device.py new file mode 100644 index 0000000..8504ba7 --- /dev/null +++ b/tests/test_auth_device.py @@ -0,0 +1,434 @@ +import copy +import pytest +import random +import time +from fxa.errors import ClientError + +from api import * + +device_data = [ + { "name": "testdev1", "type": "desktop", "availableCommands": { "a": "b" } }, + { "name": "testdev2", "type": "desktop", "availableCommands": { "a": "b" } }, +] + +@pytest.fixture +def populate_devices(account_or_rt, login): + devs = [] + for (account, dev) in [(account_or_rt, device_data[0]), (login, device_data[1])]: + dev = account.post_a("/account/device", dev) + devs.append(dev) + return devs + +def test_create_noauth(client): + with pytest.raises(ClientError) as e: + client.post_a("/account/device", device_data[0]) + assert e.value.details == { + 'code': 401, + 'errno': 109, + 'error': 'Unauthorized', + 'message': 'invalid request signature' + } + +@pytest.mark.parametrize("args,code,errno,error,message", [ + ({ 'name': 'dev', 'availableCommands': {'a':1} }, + 400, 107, 'Bad Request', 'invalid parameter in request body'), + ({ 'name': 'dev', 'availableCommands': {'a':1}, 'extra': 0 }, + 400, 107, 'Bad Request', 'invalid parameter in request body'), + ({}, + 400, 108, 'Bad Request', 'missing parameter in request body'), + ({ 'id': '00' * 16, 'name': 'dev' }, + 400, 108, 'Bad Request', 'missing parameter in request body'), +]) +def test_create_invalid(account_or_rt, args, code, errno, error, message): + with pytest.raises(ClientError) as e: + account_or_rt.post_a("/account/device", args) + assert e.value.details == {'code': code, 'errno': errno, 'error': error, 'message': message} + +def test_destroy_noauth(client, populate_devices): + with pytest.raises(ClientError) as e: + client.post_a("/account/device/destroy") + assert e.value.details == { + 'code': 401, + 'errno': 109, + 'error': 'Unauthorized', + 'message': 'invalid request signature' + } + with pytest.raises(ClientError) as e: + client.post_a("/account/device/destroy", {'id': populate_devices[0]['id']}) + assert e.value.details == { + 'code': 401, + 'errno': 109, + 'error': 'Unauthorized', + 'message': 'invalid request signature' + } + +@pytest.mark.parametrize("args,code,errno,error,message", [ + ({ 'id': '00' }, + 400, 107, 'Bad Request', 'invalid parameter in request body'), + ({ 'id': '00' * 16, 'extra': 0 }, + 400, 107, 'Bad Request', 'invalid parameter in request body'), + ({ 'id': '00' * 16 }, + 400, 123, 'Bad Request', 'unknown device'), +]) +def test_destroy_invalid(account_or_rt, args, code, errno, error, message): + with pytest.raises(ClientError) as e: + account_or_rt.post_a("/account/device/destroy", args) + assert e.value.details == {'code': code, 'errno': errno, 'error': error, 'message': message} + +def test_create_destroy(account_or_rt): + dev = account_or_rt.post_a("/account/device", device_data[0]) + account_or_rt.post_a("/account/device/destroy", {'id': dev['id']}) + +def test_create_unverified(unverified_account): + unverified_account.post_a("/account/device", device_data[0]) + +def test_list_noauth(client): + with pytest.raises(ClientError) as e: + client.get("/account/devices") + assert e.value.details == { + 'code': 401, + 'errno': 109, + 'error': 'Unauthorized', + 'message': 'invalid request signature' + } + +def test_list_unverified(unverified_account): + with pytest.raises(ClientError) as e: + unverified_account.get_a("/account/devices") + assert e.value.details == { + 'code': 400, + 'errno': 138, + 'error': 'Bad Request', + 'message': 'unverified session' + } + +def test_list_none(account_or_rt): + devs = account_or_rt.get_a("/account/devices") + assert devs == [] + +@pytest.mark.usefixtures("populate_devices") +def test_list(account_or_rt): + devs = account_or_rt.get_a("/account/devices") + assert len(devs) == 2 + (first, second) = (0, 1) if devs[0]['name'] == 'testdev1' else (1, 0) + assert devs[first]['isCurrentDevice'] + assert not devs[second]['isCurrentDevice'] + for (k, v) in device_data[first].items(): + assert devs[0][k] == v + for (k, v) in device_data[second].items(): + assert devs[1][k] == v + +def test_change(account_or_rt, populate_devices): + devs1 = populate_devices + devs = copy.deepcopy(devs1) + (devs[0]['name'], devs[1]['name']) = (devs[1]['name'], devs[0]['name']) + (devs[0]['pushCallback'], devs[1]['pushCallback']) = (devs[1]['pushCallback'] or "", devs[0]['pushCallback'] or "") + (devs[0]['pushPublicKey'], devs[1]['pushPublicKey']) = (devs[1]['pushPublicKey'] or "", devs[0]['pushPublicKey'] or "") + (devs[0]['pushAuthKey'], devs[1]['pushAuthKey']) = (devs[1]['pushAuthKey'] or "", devs[0]['pushAuthKey'] or "") + for dev in devs: + del dev['isCurrentDevice'] + del dev['lastAccessTime'] + del dev['pushEndpointExpired'] + account_or_rt.post_a("/account/device", dev) + devs2 = account_or_rt.get_a("/account/devices") + mdevs1 = { + devs1[0]['id']: devs1[0], + devs1[1]['id']: devs1[1], + } + mdevs2 = { + devs2[0]['id']: devs2[0], + devs2[1]['id']: devs2[1], + } + (id1, id2) = mdevs1.keys() + for (i1, i2) in [(id1, id2), (id2, id1)]: + assert mdevs1[i1]['name'] == mdevs2[i2]['name'] + assert mdevs1[i1]['pushCallback'] or '' == mdevs2[i2]['pushCallback'] or '' + assert mdevs1[i1]['pushPublicKey'] or '' == mdevs2[i2]['pushPublicKey'] or '' + assert mdevs1[i1]['pushAuthKey'] or '' == mdevs2[i2]['pushAuthKey'] or '' + +def test_invoke_noauth(client): + body = {"target": "0" * 32, "command": "foo", "payload": {}, "ttl": 10} + with pytest.raises(ClientError) as e: + client.post_a("/account/devices/invoke_command", body) + assert e.value.details == { + 'code': 401, + 'errno': 109, + 'error': 'Unauthorized', + 'message': 'invalid request signature' + } + +def test_invoke_unverified(unverified_account): + body = {"target": "0" * 32, "command": "foo", "payload": {}, "ttl": 10} + with pytest.raises(ClientError) as e: + unverified_account.post_a("/account/devices/invoke_command", body) + assert e.value.details == { + 'code': 400, + 'errno': 138, + 'error': 'Bad Request', + 'message': 'unverified session' + } + +@pytest.mark.parametrize("args,code,errno,error,message", [ + ({"target": "00", "command": "foo", "payload": {}, "ttl": 10}, + 400, 107, 'Bad Request', 'invalid parameter in request body'), + ({"target": "00" * 16, "command": "foo", "payload": {}, "ttl": 10, 'extra': 0}, + 400, 107, 'Bad Request', 'invalid parameter in request body'), + ({"target": "0" * 32, "command": "foo", "payload": {}, "ttl": 10}, + 400, 123, 'Bad Request', 'unknown device'), +]) +def test_invoke_invalid(account_or_rt, args, code, errno, error, message): + with pytest.raises(ClientError) as e: + account_or_rt.post_a("/account/devices/invoke_command", args) + assert e.value.details == {'code': code, 'errno': errno, 'error': error, 'message': message} + +def test_invoke_nocmd(account_or_rt, populate_devices): + body = {"target": populate_devices[0]['id'], "command": "foo", "payload": {}, "ttl": 10} + with pytest.raises(ClientError) as e: + account_or_rt.post_a("/account/devices/invoke_command", body) + assert e.value.details == { + 'code': 400, + 'errno': 157, + 'error': 'Bad Request', + 'message': 'unavailable device command' + } + +def test_invoke_other_account(account_or_rt, account2): + dev = account2.post_a("/account/device", device_data[0]) + body = {"target": dev['id'], "command": "foo", "payload": {}, "ttl": 10} + with pytest.raises(ClientError) as e: + account_or_rt.post_a("/account/devices/invoke_command", body) + assert e.value.details == { + 'code': 400, + 'errno': 123, + 'error': 'Bad Request', + 'message': 'unknown device' + } + +@pytest.mark.parametrize("ttl", [None, 1, 60, 999999999]) +@pytest.mark.parametrize("has_sender", [False, True]) +def test_invoke(account_or_rt, login, ttl, has_sender): + sender = account_or_rt.post_a("/account/device", device_data[1])['id'] if has_sender else None + dev = login.post_a("/account/device", device_data[0]) + + body = { + "target": dev['id'], + "command": "a", + "payload": { "data": str(random.random()), }, + "ttl": ttl, + } + resp = account_or_rt.post_a("/account/devices/invoke_command", body) + assert resp['enqueued'] + assert not resp['notified'] + assert resp['notifyError'] == "no push callback" + + cmd = login.get_a("/account/device/commands?index=0&limit=11") + assert cmd['last'] + assert len(cmd['messages']) == 1 + assert cmd['messages'][0]['data']['command'] == 'a' + assert cmd['messages'][0]['data']['payload'] == body['payload'] + assert cmd['messages'][0]['data']['sender'] == sender + +def test_commands_noauth(client): + with pytest.raises(ClientError) as e: + client.get_a("/account/device/commands?index=1") + assert e.value.details == { + 'code': 401, + 'errno': 109, + 'error': 'Unauthorized', + 'message': 'invalid request signature' + } + +def test_commands_unverified(unverified_account): + with pytest.raises(ClientError) as e: + unverified_account.get_a("/account/device/commands?index=1") + assert e.value.details == { + 'code': 400, + 'errno': 138, + 'error': 'Bad Request', + 'message': 'unverified session' + } + +def test_commands_nodev(account_or_rt): + with pytest.raises(ClientError) as e: + account_or_rt.get_a("/account/device/commands?index=1") + assert e.value.details == { + 'code': 400, + 'errno': 123, + 'error': 'Bad Request', + 'message': 'unknown device' + } + +@pytest.mark.parametrize('n_cmds,offset,limit', [ + (1, 0, 0), + (1, 0, 1), + (1, 0, 100), + (1, 1, 0), + (1, 1, 1), + (1, 1, 100), + (2, 0, 0), + (2, 0, 1), + (2, 0, 100), + (2, 1, 0), + (2, 1, 1), + (2, 1, 100), + (101, 0, 100), + (101, 10, 100), +]) +def test_device_commands_list(account_or_rt, login, n_cmds, offset, limit): + account_or_rt.post_a("/account/device", device_data[1])['id'] + dev = login.post_a("/account/device", device_data[0]) + + bodies = [ { + "target": dev['id'], + "command": "a", + "payload": { "data": str(i), }, + } for i in range(0, n_cmds) ] + + for b in bodies: + resp = account_or_rt.post_a("/account/devices/invoke_command", b) + assert resp['enqueued'] + assert not resp['notified'] + assert resp['notifyError'] == "no push callback" + + cmd = login.get_a("/account/device/commands", params={ "index": 0, "limit": 1 }) + assert cmd == login.get_a("/account/device/commands", params={ "index": 0, "limit": 1 }) + + first_index = cmd['index'] + cmds = login.get_a("/account/device/commands", params={ "limit": limit, "index": first_index + offset }) + assert cmds['last'] == (offset + limit >= n_cmds) + assert len(cmds['messages']) == min(max(n_cmds - offset, 0), limit) + +def test_attached_clients_unauth(client): + with pytest.raises(ClientError) as e: + client.get_a("/account/attached_clients") + assert e.value.details == { + 'code': 401, + 'errno': 109, + 'error': 'Unauthorized', + 'message': 'invalid request signature' + } + +def test_attached_clients_unverified(unverified_account): + with pytest.raises(ClientError) as e: + unverified_account.get_a("/account/attached_clients") + assert e.value.details == { + 'code': 400, + 'errno': 138, + 'error': 'Bad Request', + 'message': 'unverified session' + } + +def test_attached_clients_badauth(refresh_token): + with pytest.raises(ClientError) as e: + refresh_token.get_a("/account/attached_clients") + assert e.value.details == { + 'code': 401, + 'errno': 109, + 'error': 'Unauthorized', + 'message': 'invalid request signature' + } + +def test_attached_clients(account, refresh_token, push_server): + dev1 = Device(account, "dev1") + dev2 = Device(refresh_token, "dev2", "mobile") + # filter potential login-only sessions + devs = [ d for d in account.get_a("/account/attached_clients") if d['name'] != None ] + assert len(devs) == 2 + devs = { + devs[0]['name']: devs[0], + devs[1]['name']: devs[1], + } + assert devs['dev1']['deviceId'] == dev1.id + assert devs['dev1']['sessionTokenId'] != None + assert devs['dev1']['refreshTokenId'] == None + assert devs['dev1']['isCurrentSession'] + assert devs['dev1']['deviceType'] == "desktop" + assert (time.time() - devs['dev1']['createdTime']) < 10 + assert (time.time() - devs['dev1']['lastAccessTime']) < 10 + assert devs['dev1']['scope'] == None + # + assert devs['dev2']['deviceId'] == dev2.id + assert devs['dev2']['sessionTokenId'] != None + assert devs['dev2']['refreshTokenId'] != None + assert not devs['dev2']['isCurrentSession'] + assert devs['dev2']['deviceType'] == "mobile" + assert (time.time() - devs['dev2']['createdTime']) < 10 + assert (time.time() - devs['dev2']['lastAccessTime']) < 10 + assert devs['dev2']['scope'] == "profile https://identity.mozilla.com/apps/oldsync" + +def test_attached_client_destroy_unauth(client): + with pytest.raises(ClientError) as e: + client.post_a("/account/attached_client/destroy") + assert e.value.details == { + 'code': 401, + 'errno': 109, + 'error': 'Unauthorized', + 'message': 'invalid request signature' + } + +def test_attached_client_destroy_unverified(unverified_account): + with pytest.raises(ClientError) as e: + unverified_account.post_a("/account/attached_client/destroy") + assert e.value.details == { + 'code': 400, + 'errno': 138, + 'error': 'Bad Request', + 'message': 'unverified session' + } + +def test_attached_client_destroy_badauth(refresh_token): + with pytest.raises(ClientError) as e: + refresh_token.post_a("/account/attached_client/destroy") + assert e.value.details == { + 'code': 401, + 'errno': 109, + 'error': 'Unauthorized', + 'message': 'invalid request signature' + } + +@pytest.mark.parametrize("args,code,errno,error,message", [ + ({"sessionTokenId": "0"}, + 400, 107, 'Bad Request', 'invalid parameter in request body'), + ({"refreshTokenId": "0"}, + 400, 107, 'Bad Request', 'invalid parameter in request body'), + ({"deviceId": "0"}, + 400, 107, 'Bad Request', 'invalid parameter in request body'), + ({"sessionTokenId": "00" * 16, 'extra': 0}, + 400, 107, 'Bad Request', 'invalid parameter in request body'), + ({"sessionTokenId": "00" * 16, 'refreshTokenId': "00" * 16}, + 400, 107, 'Bad Request', 'invalid parameter in request body'), + ({"sessionTokenId": "00" * 16, 'refreshTokenId': "00" * 16, 'deviceId': "00" * 16}, + 400, 107, 'Bad Request', 'invalid parameter in request body'), + ({'refreshTokenId': "00" * 16, 'deviceId': "00" * 16}, + 400, 107, 'Bad Request', 'invalid parameter in request body'), +]) +def test_attached_client_destroy_invalid(account, args, code, errno, error, message): + with pytest.raises(ClientError) as e: + account.post_a("/account/attached_client/destroy", args) + assert e.value.details == {'code': code, 'errno': errno, 'error': error, 'message': message} + +@pytest.mark.parametrize("fn", [ + (lambda d: {'sessionTokenId': d['sessionTokenId']}), + (lambda d: {'refreshTokenId': d['refreshTokenId']}), + (lambda d: {'deviceId': d['deviceId']}), +], ids=["session", "refresh", "device"]) +def test_attached_client_destroy(account, refresh_token, fn): + Device(refresh_token, "dev2") + devs = account.get_a("/account/attached_clients") + account.post_a("/account/attached_client/destroy", fn(devs[0])) + devs2 = account.get_a("/account/attached_clients") + assert len(devs2) == len(devs) - 1 + +def test_attached_client_destroy_push(account, refresh_token, push_server): + dev = Device(account, "dev") + dev2 = Device(refresh_token, "dev2") + dev.update_pcb(push_server.good("4ed8d1d3-e756-4866-9169-aafe612eb1e9")) + account.post_a("/account/attached_client/destroy", { 'deviceId': dev2.id }) + p = push_server.wait() + assert p[0] == "/4ed8d1d3-e756-4866-9169-aafe612eb1e9" + assert dev.decrypt(p[2]) == { + 'command': 'fxaccounts:device_disconnected', + 'data': {'id': dev2.id}, + 'version': 1 + } + assert push_server.done() diff --git a/tests/test_auth_email.py b/tests/test_auth_email.py new file mode 100644 index 0000000..319f2d4 --- /dev/null +++ b/tests/test_auth_email.py @@ -0,0 +1,96 @@ +import pytest +from fxa.errors import ClientError + +from api import * + +def test_status_noauth(client, refresh_token): + with pytest.raises(ClientError) as e: + client.post_a("/recovery_email/status") + assert e.value.details == { + 'code': 401, + 'errno': 109, + 'error': 'Unauthorized', + 'message': 'invalid request signature' + } + with pytest.raises(ClientError) as e: + refresh_token.post_a("/recovery_email/status") + assert e.value.details == { + 'code': 401, + 'errno': 109, + 'error': 'Unauthorized', + 'message': 'invalid request signature' + } + +def test_status_unverified(unverified_account): + resp = unverified_account.get_a("/recovery_email/status") + assert resp == { + 'email': unverified_account.email, + 'emailVerified': False, + 'sessionVerified': False, + 'verified': False + } + +def test_status_verified(account): + resp = account.get_a("/recovery_email/status") + assert resp == { + 'email': account.email, + 'emailVerified': True, + 'sessionVerified': True, + 'verified': True + } + +@pytest.mark.parametrize("args,code,errno,error,message", [ + ({ 'uid': '00', 'code': "" }, + 400, 107, 'Bad Request', 'invalid parameter in request body'), + ({ 'id': '00' * 16, 'code': 0 }, + 400, 107, 'Bad Request', 'invalid parameter in request body'), + ({ 'id': '00' * 16, 'code': "", 'extra': 0 }, + 400, 107, 'Bad Request', 'invalid parameter in request body'), +]) +def test_verify_code_invalid(unverified_account, args, code, errno, error, message): + with pytest.raises(ClientError) as e: + unverified_account.post_a("/recovery_email/verify_code", args) + assert e.value.details == {'code': code, 'errno': errno, 'error': error, 'message': message} + +def test_verify_code(account): + # fixture does all the work + pass + +def test_verify_code_reuse(client, mail_server): + s = client.create_account("test@test", "") + (to, body) = mail_server.wait() + assert to == ["test@test"] + data = json.loads(base64.urlsafe_b64decode(body.split("#/verify/", maxsplit=1)[1]).decode('utf8')) + s.post_a('/recovery_email/verify_code', { 'uid': data['uid'], 'code': data['code'] }) + with pytest.raises(ClientError) as e: + s.post_a('/recovery_email/verify_code', { 'uid': data['uid'], 'code': data['code'] }) + s.destroy_account("test@test", "") + assert e.value.details == { + 'code': 400, + 'errno': 105, + 'error': 'Bad Request', + 'message': 'invalid verification code' + } + +def test_resend_code(client, mail_server): + s = client.create_account("test@test", "") + (to, body) = mail_server.wait() + assert to == ["test@test"] + data = json.loads(base64.urlsafe_b64decode(body.split("#/verify/", maxsplit=1)[1]).decode('utf8')) + s.post_a('/recovery_email/resend_code', {}) + (to2, body2) = mail_server.wait() + assert to == to2 + assert body == body2 + s.post_a('/recovery_email/verify_code', { 'uid': data['uid'], 'code': data['code'] }) + with pytest.raises(ClientError) as e: + s.post_a('/recovery_email/resend_code', {}) + (to, body) = mail_server.wait() + assert to == ["test@test"] + data = json.loads(base64.urlsafe_b64decode(body.split("#/verify/", maxsplit=1)[1]).decode('utf8')) + s.destroy_account("test@test", "") + assert e.value.details == { + 'code': 400, + 'errno': 105, + 'error': 'Bad Request', + 'message': 'invalid verification code' + } diff --git a/tests/test_auth_oauth.py b/tests/test_auth_oauth.py new file mode 100644 index 0000000..f8ad201 --- /dev/null +++ b/tests/test_auth_oauth.py @@ -0,0 +1,369 @@ +import copy +import pytest +import time +from fxa.errors import ClientError + +@pytest.mark.parametrize("client_id,scopes", [ + # firefox + ("5882386c6d801776", "profile:write https://identity.mozilla.com/apps/oldsync https://identity.mozilla.com/tokens/session"), + # fenix + ("a2270f727f45f648", "profile https://identity.mozilla.com/apps/oldsync https://identity.mozilla.com/tokens/session"), +]) +def test_oauth_client_scopes(account, client_id, scopes): + body = { + "client_id": client_id, + "ttl": 60, + "grant_type": "fxa-credentials", + "access_type": "online", + "scope": scopes, + } + s = account.post_a("/oauth/token", body)['access_token'] + account.post_a("/oauth/destroy", { "client_id": client_id, "token": s }) + +def test_oauth_authorization_noauth(account): + body = { + "client_id": "5882386c6d801776", + "ttl": 60, + "grant_type": "fxa-credentials", + "access_type": "online", + "scope": "profile", + } + with pytest.raises(ClientError) as e: + account.post("/oauth/authorization", body) + assert e.value.details == { + 'code': 401, + 'errno': 109, + 'error': 'Unauthorized', + 'message': 'invalid request signature' + } + +def test_oauth_authorization_unverified(unverified_account): + body = { + "client_id": "5882386c6d801776", + "ttl": 60, + "grant_type": "fxa-credentials", + "access_type": "online", + "scope": "profile", + } + with pytest.raises(ClientError) as e: + unverified_account.post_a("/oauth/authorization", body) + assert e.value.details == { + 'code': 400, + 'errno': 138, + 'error': 'Bad Request', + 'message': 'unverified session' + } + +@pytest.mark.parametrize("args", [ + { "client_id": "5882386c6d801776", "state": "", "scope": "profile", "access_type": "invalid", + "code_challenge": "", "code_challenge_method": "S256", "response_type": "code" }, + { "client_id": "5882386c6d801776", "state": "", "scope": "profile", "access_type": "online", + "code_challenge": "", "code_challenge_method": "invalid", "response_type": "code" }, + { "client_id": "5882386c6d801776", "state": "", "scope": "profile", "access_type": "online", + "code_challenge": "", "code_challenge_method": "S256", "response_type": "invalid" }, +]) +def test_oauth_authorization_invalid(account, args): + with pytest.raises(ClientError) as e: + account.post_a("/oauth/authorization", args) + assert e.value.details == { + 'code': 400, + 'errno': 107, + 'error': 'Bad Request', + 'message': 'invalid parameter in request body' + } + +def test_oauth_authorization_badclientid(account): + args = { "client_id": "invalid", "state": "", "scope": "profile", "access_type": "online", + "code_challenge": "", "code_challenge_method": "S256", "response_type": "code" } + with pytest.raises(ClientError) as e: + account.post_a("/oauth/authorization", args) + assert e.value.details == { + 'code': 400, + 'errno': 162, + 'error': 'Bad Request', + 'message': 'unknown client_id' + } + +def test_oauth_authorization_badscope(account): + args = { "client_id": "5882386c6d801776", "state": "", "scope": "invalid", "access_type": "online", + "code_challenge": "", "code_challenge_method": "S256", "response_type": "code" } + with pytest.raises(ClientError) as e: + account.post_a("/oauth/authorization", args) + assert e.value.details == { + 'code': 400, + 'errno': 169, + 'error': 'Bad Request', + 'message': 'requested scopes not allowed' + } + +# see below for combined /authorization + unauthed /token + +def test_oauth_destroy_notoken(account): + args = { "client_id": "5882386c6d801776", "token": "00" * 32 } + with pytest.raises(ClientError) as e: + account.post("/oauth/destroy", args) + assert e.value.details == { + 'code': 400, + 'errno': 107, + 'error': 'Bad Request', + 'message': 'invalid parameter in request body' + } + +def test_oauth_destroy_badclient(account, refresh_token): + args = { "client_id": "other", "token": refresh_token.bearer } + with pytest.raises(ClientError) as e: + account.post("/oauth/destroy", args) + assert e.value.details == { + 'code': 400, + 'errno': 107, + 'error': 'Bad Request', + 'message': 'invalid parameter in request body' + } + +def test_oauth_scoped_keys_badclient(account): + with pytest.raises(ClientError) as e: + account.post_a("/account/scoped-key-data", { + "client_id": "invalid", + "scope": "https://identity.mozilla.com/apps/oldsync" + }) + assert e.value.details == { + 'code': 400, + 'errno': 162, + 'error': 'Bad Request', + 'message': 'unknown client_id' + } + +def test_oauth_scoped_keys_badscope(account): + with pytest.raises(ClientError) as e: + account.post_a("/account/scoped-key-data", { + "client_id": "5882386c6d801776", + "scope": "scope" + }) + assert e.value.details == { + 'code': 400, + 'errno': 169, + 'error': 'Bad Request', + 'message': 'requested scopes not allowed' + } + +def test_oauth_scoped_unverified(unverified_account): + with pytest.raises(ClientError) as e: + unverified_account.post_a("/account/scoped-key-data", { + "client_id": "5882386c6d801776", + "scope": "https://identity.mozilla.com/apps/oldsync" + }) + assert e.value.details == { + 'code': 400, + 'errno': 138, + 'error': 'Bad Request', + 'message': 'unverified session' + } + +def test_oauth_scoped_keys(account): + resp = account.post_a("/account/scoped-key-data", { + "client_id": "5882386c6d801776", + "scope": "https://identity.mozilla.com/apps/oldsync" + }) + assert resp == { + "https://identity.mozilla.com/apps/oldsync": { + "identifier": "https://identity.mozilla.com/apps/oldsync", + "keyRotationSecret": "00" * 32, + "keyRotationTimestamp": 0, + }, + } + +@pytest.mark.parametrize("access_type", ["online", "offline"]) +def test_oauth_token_fxa_badclient(account, access_type): + body = { "client_id": "invalid", "ttl": 60, "grant_type": "fxa-credentials", + "access_type": access_type, "scope": "profile" } + with pytest.raises(ClientError) as e: + account.post_a("/oauth/token", body) + assert e.value.details == { + 'code': 400, + 'errno': 162, + 'error': 'Bad Request', + 'message': 'unknown client_id' + } + +@pytest.mark.parametrize("access_type", ["online", "offline"]) +def test_oauth_token_fxa_badscope(account, access_type): + body = { "client_id": "5882386c6d801776", "ttl": 60, "grant_type": "fxa-credentials", + "access_type": access_type, "scope": "scope" } + with pytest.raises(ClientError) as e: + account.post_a("/oauth/token", body) + assert e.value.details == { + 'code': 400, + 'errno': 169, + 'error': 'Bad Request', + 'message': 'requested scopes not allowed' + } + +@pytest.mark.parametrize("grant_type,access_type", [ + ("authorization_code", "online"), + ("refresh_token", "online"), + ("fxa-credentials", "foo"), +]) +def test_oauth_token_fxa_invalid(account, grant_type, access_type): + body = { "client_id": "5882386c6d801776", "ttl": 60, "grant_type": grant_type, + "access_type": access_type, "scope": "scope" } + with pytest.raises(ClientError) as e: + account.post_a("/oauth/token", body) + assert e.value.details == { + 'code': 400, + 'errno': 107, + 'error': 'Bad Request', + 'message': 'invalid parameter in request body' + } + +def test_oauth_token_unverified(unverified_account): + body = { "client_id": "5882386c6d801776", "ttl": 60, "grant_type": "fxa-credentials", + "access_type": "online", "scope": "profile" } + with pytest.raises(ClientError) as e: + unverified_account.post_a("/oauth/token", body) + assert e.value.details == { + 'code': 400, + 'errno': 138, + 'error': 'Bad Request', + 'message': 'unverified session' + } + +@pytest.fixture +def auth_code(account): + body = { + "client_id": "5882386c6d801776", + "state": "test", + "scope": "profile", + "access_type": "online", + "code_challenge": "n4bQgYhMfWWaL-qgxVrQFaO_TxsrC4Is0V1sFbDwCgg", # "test" + "code_challenge_method": "S256", + "response_type": "code", + } + return account.post_a("/oauth/authorization", body)['code'] + +@pytest.mark.parametrize("args,code,error,errno,message", [ + ({ "client_id": "5882386c6d801776", "ttl": 60, "grant_type": "authorization_code", + "code": "invalid", "code_verifier": "test" }, + 400, 'Bad Request', 107, 'invalid parameter in request body'), + ({ "client_id": "5882386c6d801776", "ttl": 60, "grant_type": "authorization_code", + "code_verifier": "test", "extra": 0 }, + 400, 'Bad Request', 107, 'invalid parameter in request body'), + ({ "client_id": "5882386c6d801776", "ttl": 60, "grant_type": "authorization_code", + "code": "00" * 32, "code_verifier": "test" }, + 401, 'Unauthorized', 110, 'invalid authentication token'), + ({ "client_id": "invalid", "ttl": 60, "grant_type": "authorization_code", + "code_verifier": "test" }, + 400, 'Bad Request', 162, 'unknown client_id'), + ({ "client_id": "5882386c6d801776", "ttl": 60, "grant_type": "authorization_code", + "code_verifier": "invalid" }, + 400, 'Bad Request', 107, 'invalid parameter in request body'), +]) +def test_oauth_token_other_invalidcode(account, args, code, error, errno, message, auth_code): + args = copy.deepcopy(args) + if 'code' not in args: args['code'] = auth_code + with pytest.raises(ClientError) as e: + account.post("/oauth/token", args) + assert e.value.details == { 'code': code, 'errno': errno, 'error': error, 'message': message } + +@pytest.mark.parametrize("args,code,error,errno,message", [ + ({ "client_id": "5882386c6d801776", "ttl": 60, "grant_type": "fxa-credentials", + "scope": "profile", "access_type": "online" }, + 400, 'Bad Request', 107, 'invalid parameter in request body'), + ({ "client_id": "5882386c6d801776", "ttl": 60, "grant_type": "refresh_token", + "refresh_token": "invalid", "code_verifier": "test" }, + 400, 'Bad Request', 107, 'invalid parameter in request body'), + ({ "client_id": "5882386c6d801776", "ttl": 60, "grant_type": "refresh_token", + "scope": "profile", "extra": 0 }, + 400, 'Bad Request', 107, 'invalid parameter in request body'), + ({ "client_id": "invalid", "ttl": 60, "grant_type": "refresh_token", + "scope": "profile" }, + 400, 'Bad Request', 162, 'unknown client_id'), + ({ "client_id": "5882386c6d801776", "ttl": 60, "grant_type": "refresh_token", + "scope": "foo" }, + 400, 'Bad Request', 169, 'requested scopes not allowed'), + ({ "client_id": "5882386c6d801776", "ttl": 60, "grant_type": "refresh_token", + "scope": "profile:write" }, + 400, 'Bad Request', 169, 'requested scopes not allowed'), +]) +def test_oauth_token_other_invalidrefresh(account, args, code, error, errno, message, refresh_token): + args = copy.deepcopy(args) + if 'refresh_token' not in args: args['refresh_token'] = refresh_token.bearer + with pytest.raises(ClientError) as e: + account.post("/oauth/token", args) + assert e.value.details == { 'code': code, 'errno': errno, 'error': error, 'message': message } + +@pytest.mark.parametrize("refresh", [False, True]) +def test_oauth_fxa(account, refresh): + body = { + "client_id": "5882386c6d801776", + "ttl": 60, + "grant_type": "fxa-credentials", + "access_type": "offline" if refresh else "online", + "scope": "profile", + } + resp = account.post_a("/oauth/token", body) + + assert 'access_token' in resp + assert ('refresh_token' in resp) == refresh + assert 'session_token' not in resp + assert resp['scope'] == 'profile' + assert resp['token_type'] == 'bearer' + assert resp['expires_in'] <= 60 + assert (resp['auth_at'] - time.time()) < 10 + assert 'keys_jwe' not in resp + +@pytest.mark.parametrize("keys_jwe", [None, "keyskeyskeys"]) +@pytest.mark.parametrize("refresh,session", [ + (False, False), + (True, False), + (True, True), +]) +def test_oauth_auth_code(account, keys_jwe, refresh, session): + scope = "profile" + (" https://identity.mozilla.com/tokens/session" if session else "") + body = { + "client_id": "5882386c6d801776", + "state": "test", + "keys_jwe": keys_jwe, + "scope": scope, + "access_type": "offline" if refresh else "online", + "code_challenge": "n4bQgYhMfWWaL-qgxVrQFaO_TxsrC4Is0V1sFbDwCgg", # "test" + "code_challenge_method": "S256", + "response_type": "code", + } + resp = account.post_a("/oauth/authorization", body) + assert resp['state'] == "test" + + body = { + "client_id": "5882386c6d801776", + "ttl": 60, + "grant_type": "authorization_code", + "code": resp['code'], + "code_verifier": "test", + } + resp = account.post("/oauth/token", body) + assert 'access_token' in resp + assert ('refresh_token' in resp) == refresh + assert ('session_token' in resp) == session + assert resp['scope'] == 'profile' + assert resp['token_type'] == 'bearer' + assert resp['expires_in'] <= 60 + assert (resp['auth_at'] - time.time()) < 10 + assert keys_jwe is None or (resp['keys_jwe'] == keys_jwe) + +def test_oauth_refresh(account, refresh_token): + body = { + "client_id": "5882386c6d801776", + "ttl": 60, + "grant_type": "refresh_token", + "refresh_token": refresh_token.bearer, + "scope": "profile", + } + resp = account.post("/oauth/token", body) + + assert 'access_token' in resp + assert 'refresh_token' not in resp + assert 'session_token' not in resp + assert resp['scope'] == 'profile' + assert resp['token_type'] == 'bearer' + assert resp['expires_in'] <= 60 + assert (resp['auth_at'] - time.time()) < 10 + assert 'keys_jwe' not in resp diff --git a/tests/test_auth_password.py b/tests/test_auth_password.py new file mode 100644 index 0000000..7c2064a --- /dev/null +++ b/tests/test_auth_password.py @@ -0,0 +1,211 @@ +import pytest +from fxa.crypto import derive_key, quick_stretch_password +from fxa.errors import ClientError + +from api import * + +@pytest.mark.parametrize("args", [ + { 'email': "", 'oldAuthPW': '00' * 32 }, + { 'email': "test0@test", 'oldAuthPW': '00' }, + { 'email': "test0@test", 'oldAuthPW': '00' * 32, 'extra': 0 }, +]) +def test_change_start_invalid(account, args): + with pytest.raises(ClientError) as e: + account.post_a("/password/change/start", args) + assert e.value.details == { + 'code': 400, + 'errno': 107, + 'error': 'Bad Request', + 'message': 'invalid parameter in request body' + } + +def test_change_start_badaccount(account): + with pytest.raises(ClientError) as e: + account.post_a("/password/change/start", { 'email': "test0@test", 'oldAuthPW': '00' * 32 }) + assert e.value.details == { + 'code': 400, + 'errno': 102, + 'error': 'Bad Request', + 'message': 'unknown account' + } + with pytest.raises(ClientError) as e: + account.post_a("/password/change/start", { 'email': account.email.upper(), 'oldAuthPW': '00' * 32 }) + assert e.value.details == { + 'code': 400, + 'errno': 120, + 'error': 'Bad Request', + 'message': 'incorrect email case' + } + +def test_change_start_unverified(unverified_account): + with pytest.raises(ClientError) as e: + unverified_account.post_a("/password/change/start", { + 'email': unverified_account.email, + 'oldAuthPW': '00' * 32 + }) + assert e.value.details == { + 'code': 400, + 'errno': 104, + 'error': 'Bad Request', + 'message': 'unverified account' + } + +def test_change_start_badpw(account): + with pytest.raises(ClientError) as e: + account.post_a("/password/change/start", { 'email': account.email, 'oldAuthPW': '00' * 32 }) + assert e.value.details == { + 'code': 400, + 'errno': 103, + 'error': 'Bad Request', + 'message': 'incorrect password' + } + +@pytest.fixture +def change_token(account): + pw = auth_pw(account.email, "") + resp = account.post_a("/password/change/start", { 'email': account.email, 'oldAuthPW': pw }) + assert 'keyFetchToken' in resp + return PasswordChange(account.client, resp['passwordChangeToken']) + +@pytest.mark.parametrize("args", [ + { 'authPW': '00', 'wrapKb': '00' * 32, 'sessionToken': '00' * 32, }, + { 'authPW': '00' * 32, 'wrapKb': '00', 'sessionToken': '00' * 32, }, + { 'authPW': '00' * 32, 'wrapKb': '00' * 32, 'sessionToken': '00', }, +]) +def test_change_finish_invalid(change_token, args): + with pytest.raises(ClientError) as e: + change_token.post_a("/password/change/finish", args) + assert e.value.details == { + 'code': 400, + 'errno': 107, + 'error': 'Bad Request', + 'message': 'invalid parameter in request body' + } + +def test_change_finish(account, change_token, mail_server): + pw = auth_pw(account.email, "new") + change_token.post_a("/password/change/finish", { + 'authPW': pw, + 'wrapKb': '00' * 32, + }) + account.password = "new" # for fixture teardown + (to, body) = mail_server.wait() + assert account.email in to + assert 'password has been changed' in body + + # just do a login test to see that the password was really changed + account.login(account.email, "new") + with pytest.raises(ClientError) as e: + account.login(account.email, "") + assert e.value.details == { + 'code': 400, + 'errno': 103, + 'error': 'Bad Request', + 'message': 'incorrect password' + } + +def test_change_finish_twice(account, change_token, mail_server): + pw = auth_pw(account.email, "new") + change_token.post_a("/password/change/finish", { + 'authPW': pw, + 'wrapKb': '00' * 32, + }) + account.password = "new" # for fixture teardown + + with pytest.raises(ClientError) as e: + change_token.post_a("/password/change/finish", { + 'authPW': pw, + 'wrapKb': '00' * 32, + }) + assert e.value.details == { + 'code': 401, + 'errno': 109, + 'error': 'Unauthorized', + 'message': 'invalid request signature' + } + +@pytest.mark.parametrize("args", [ + { 'email': "" }, + { 'email': "test0@test", 'extra': 0 }, +]) +def test_forgot_start_invalid(account, args): + with pytest.raises(ClientError) as e: + account.post_a("/password/forgot/send_code", args) + assert e.value.details == { + 'code': 400, + 'errno': 107, + 'error': 'Bad Request', + 'message': 'invalid parameter in request body' + } + +def test_change_forgot_badaccount(account): + with pytest.raises(ClientError) as e: + account.post_a("/password/forgot/send_code", { 'email': "test0@test" }) + assert e.value.details == { + 'code': 400, + 'errno': 102, + 'error': 'Bad Request', + 'message': 'unknown account' + } + with pytest.raises(ClientError) as e: + account.post_a("/password/forgot/send_code", { 'email': account.email.upper() }) + assert e.value.details == { + 'code': 400, + 'errno': 120, + 'error': 'Bad Request', + 'message': 'incorrect email case' + } + +def test_change_forgot_unverified(unverified_account): + with pytest.raises(ClientError) as e: + unverified_account.post_a("/password/forgot/send_code", { 'email': unverified_account.email }) + assert e.value.details == { + 'code': 400, + 'errno': 104, + 'error': 'Bad Request', + 'message': 'unverified account' + } + +@pytest.mark.parametrize("args", [ + { 'code': '', 'extra': 0, }, +]) +def test_forgot_finish_invalid(change_token, args): + with pytest.raises(ClientError) as e: + change_token.post_a("/password/forgot/send_code", args) + assert e.value.details == { + 'code': 400, + 'errno': 107, + 'error': 'Bad Request', + 'message': 'invalid parameter in request body' + } + +def test_forgot_finish_badcode(account, forgot_token, mail_server): + (to, body) = mail_server.wait() + assert account.email in to + with pytest.raises(ClientError) as e: + resp = forgot_token.post_a("/password/forgot/verify_code", { 'code': '' }) + assert e.value.details == { + 'code': 400, + 'errno': 105, + 'error': 'Bad Request', + 'message': 'invalid verification code' + } + +def test_forgot_finish(account, forgot_token, mail_server): + (to, body) = mail_server.wait() + assert account.email in to + resp = forgot_token.post_a("/password/forgot/verify_code", { 'code': body.strip() }) + assert 'accountResetToken' in resp + +def test_forgot_finish_twice(account, forgot_token, mail_server): + (to, body) = mail_server.wait() + forgot_token.post_a("/password/forgot/verify_code", { 'code': body.strip() }) + + with pytest.raises(ClientError) as e: + forgot_token.post_a("/password/forgot/verify_code", { 'code': body.strip() }) + assert e.value.details == { + 'code': 401, + 'errno': 109, + 'error': 'Unauthorized', + 'message': 'invalid request signature' + } diff --git a/tests/test_auth_session.py b/tests/test_auth_session.py new file mode 100644 index 0000000..3a6e7c4 --- /dev/null +++ b/tests/test_auth_session.py @@ -0,0 +1,69 @@ +import pytest +from fxa.errors import ClientError + +from api import * + +def test_session_loggedout(client): + with pytest.raises(ClientError) as e: + client.post("/session/destroy") + assert e.value.details == { + 'code': 401, + 'errno': 109, + 'error': 'Unauthorized', + 'message': 'invalid request signature' + } + +def test_status(account): + resp = account.get_a("/session/status") + assert resp == { 'state': '', 'uid': account.props['uid'] } + +def test_resend(account, mail_server): + c = account.login(account.email, "") + (to, body) = mail_server.wait() + assert to == [account.email] + c.post_a("/session/resend_code", {}) + (to2, body2) = mail_server.wait() + assert to == to2 + assert body == body2 + +@pytest.mark.parametrize("args", [ + { 'custom_session_id': '00' }, + { 'extra': '00' }, +]) +def test_session_invalid(account, args): + with pytest.raises(ClientError) as e: + account.post_a("/session/destroy", args) + assert e.value.details == { + 'code': 400, + 'errno': 107, + 'error': 'Bad Request', + 'message': 'invalid parameter in request body' + } + +def test_session_noid(account): + with pytest.raises(ClientError) as e: + account.post_a("/session/destroy", { 'custom_session_id': '0' * 64 }) + assert e.value.details == { + 'code': 400, + 'errno': 123, + 'error': 'Bad Request', + 'message': 'unknown device' + } + +def test_session_destroy_other(account, account2): + with pytest.raises(ClientError) as e: + account.post_a("/session/destroy", { 'custom_session_id': account2.auth.id }) + assert e.value.details == { + 'code': 400, + 'errno': 123, + 'error': 'Bad Request', + 'message': 'unknown device' + } + +def test_session_destroy_unverified(unverified_account): + unverified_account.destroy_session() + unverified_account.destroy_session = lambda *args: None + +def test_session_destroy(account): + s = account.login(account.email, "") + s.destroy_session() diff --git a/tests/test_oauth.py b/tests/test_oauth.py new file mode 100644 index 0000000..3eb32ac --- /dev/null +++ b/tests/test_oauth.py @@ -0,0 +1,97 @@ +import pytest +from fxa.errors import ClientError + +from api import * + +@pytest.fixture +def oauth(): + return Oauth() + +@pytest.fixture +def access_token(account): + body = { + "client_id": "5882386c6d801776", + "ttl": 60, + "grant_type": "fxa-credentials", + "access_type": "online", + "scope": "profile", + } + resp = account.post_a("/oauth/token", body) + return resp['access_token'] + +@pytest.mark.parametrize("args,code,errno,error,message", [ + ({"access_token": "0"}, + 400, 109, 'Bad Request', 'invalid request parameter'), + ({"refresh_token": "0"}, + 400, 109, 'Bad Request', 'invalid request parameter'), + ({"token": "0"}, + 400, 109, 'Bad Request', 'invalid request parameter'), +]) +def test_destroy_invalid(oauth, args, code, errno, error, message): + with pytest.raises(ClientError) as e: + oauth.post("/destroy", args) + assert e.value.details == {'code': code, 'errno': errno, 'error': error, 'message': message} + +def test_destroy_access(oauth, access_token): + oauth.post("/verify", {'token': access_token}) + oauth.post("/destroy", {'access_token': access_token}) + with pytest.raises(ClientError) as e: + oauth.post("/verify", {'token': access_token}) + assert e.value.details == { + 'code': 400, + 'errno': 109, + 'error': 'Bad Request', + 'message': 'invalid request parameter' + } + +def test_destroy_refresh(oauth, refresh_token): + refresh_token.get_a("/account/devices") + oauth.post("/destroy", {'refresh_token': refresh_token.bearer}) + with pytest.raises(ClientError) as e: + refresh_token.get_a("/account/devices") + assert e.value.details == { + 'code': 401, + 'errno': 109, + 'error': 'Unauthorized', + 'message': 'invalid request signature' + } + +def test_destroy_any(oauth, access_token, refresh_token): + oauth.post("/verify", {'token': access_token}) + oauth.post("/destroy", {'token': access_token}) + with pytest.raises(ClientError) as e: + oauth.post("/verify", {'token': access_token}) + assert e.value.details == { + 'code': 400, + 'errno': 109, + 'error': 'Bad Request', + 'message': 'invalid request parameter' + } + + refresh_token.get_a("/account/devices") + oauth.post("/destroy", {'token': refresh_token.bearer}) + with pytest.raises(ClientError) as e: + refresh_token.get_a("/account/devices") + assert e.value.details == { + 'code': 401, + 'errno': 109, + 'error': 'Unauthorized', + 'message': 'invalid request signature' + } + +def test_oauth_verify(account, oauth, access_token): + assert oauth.post("/verify", {'token': access_token}) == { + 'user': account.props['uid'], + 'client_id': "5882386c6d801776", + 'scope': ['profile'], + } + +def test_oauth_verify_refresh(oauth, refresh_token): + with pytest.raises(ClientError) as e: + oauth.post("/verify", {'token': refresh_token.bearer}) + assert e.value.details == { + 'code': 400, + 'errno': 109, + 'error': 'Bad Request', + 'message': 'invalid request parameter' + } diff --git a/tests/test_profile.py b/tests/test_profile.py new file mode 100644 index 0000000..5e7308a --- /dev/null +++ b/tests/test_profile.py @@ -0,0 +1,134 @@ +import pytest +from fxa.errors import ClientError + +from api import * + +@pytest.fixture +def profile(account): + return account.profile() + +def test_profile_noauth(profile): + with pytest.raises(ClientError) as e: + profile.get("/profile") + assert e.value.details == { + 'code': 403, + 'errno': 100, + 'error': 'Forbidden', + 'message': 'unauthorized' + } + +def test_display_name_noauth(profile): + with pytest.raises(ClientError) as e: + profile.post("/display_name", {'displayName': 'foo'}) + assert e.value.details == { + 'code': 403, + 'errno': 100, + 'error': 'Forbidden', + 'message': 'unauthorized' + } + +def test_avatar_upload_noauth(profile): + with pytest.raises(ClientError) as e: + profile.post("/avatar/upload", "foo", headers={'content-type': 'image/png'}) + assert e.value.details == { + 'code': 403, + 'errno': 100, + 'error': 'Forbidden', + 'message': 'unauthorized' + } + +def test_avatar_delete_noauth(profile): + with pytest.raises(ClientError) as e: + profile.delete("/avatar/00000000000000000000000000000000") + assert e.value.details == { + 'code': 403, + 'errno': 100, + 'error': 'Forbidden', + 'message': 'unauthorized' + } + +def test_profile(account, profile): + resp = profile.get_a("/profile") + assert resp == { + 'amrValues': None, + 'avatar': 'http://localhost:8000/avatars/00000000000000000000000000000000', + 'avatarDefault': True, + 'displayName': None, + 'email': account.email, + 'locale': None, + 'subscriptions': None, + 'twoFactorAuthentication': False, + 'uid': account.props['uid'] + } + +def test_display_name(account, profile): + resp = profile.get_a("/profile") + assert resp == { + 'amrValues': None, + 'avatar': 'http://localhost:8000/avatars/00000000000000000000000000000000', + 'avatarDefault': True, + 'displayName': None, + 'email': account.email, + 'locale': None, + 'subscriptions': None, + 'twoFactorAuthentication': False, + 'uid': account.props['uid'] + } + profile.post_a("/display_name", {'displayName': 'foo'}) + resp = profile.get_a("/profile") + assert resp == { + 'amrValues': None, + 'avatar': 'http://localhost:8000/avatars/00000000000000000000000000000000', + 'avatarDefault': True, + 'displayName': 'foo', + 'email': account.email, + 'locale': None, + 'subscriptions': None, + 'twoFactorAuthentication': False, + 'uid': account.props['uid'] + } + +def test_avatar(account, profile): + resp = profile.get_a("/avatar") + assert resp == { + 'avatar': 'http://localhost:8000/avatars/00000000000000000000000000000000', + 'avatarDefault': True, + 'id': '00000000000000000000000000000000' + } + +def test_avatar_upload(account, profile): + # server does not parse the bytes + profile.post_a("/avatar/upload", "foo", headers={'content-type': 'image/png'}) + resp = profile.get_a("/avatar") + new_id = resp['id'] + assert resp['avatar'] != 'http://localhost:8000/avatars/00000000000000000000000000000000' + assert not resp['avatarDefault'] + assert resp['id'] != '00000000000000000000000000000000' + resp = profile.get_a("/profile") + assert resp['avatar'] != 'http://localhost:8000/avatars/00000000000000000000000000000000' + assert not resp['avatarDefault'] + +def test_avatar_delete(account, profile): + # server does not parse the bytes + profile.post_a("/avatar/upload", "foo", headers={'content-type': 'image/png'}) + resp = profile.get_a("/avatar") + new_id = resp['id'] + profile.delete_a(f"/avatar/{new_id}") + resp = profile.get_a("/avatar") + assert resp == { + 'avatar': 'http://localhost:8000/avatars/00000000000000000000000000000000', + 'avatarDefault': True, + 'id': '00000000000000000000000000000000' + } + resp = profile.get_a("/profile") + assert resp == { + 'amrValues': None, + 'avatar': 'http://localhost:8000/avatars/00000000000000000000000000000000', + 'avatarDefault': True, + 'displayName': None, + 'email': account.email, + 'locale': None, + 'subscriptions': None, + 'twoFactorAuthentication': False, + 'uid': account.props['uid'] + } diff --git a/tests/test_push.py b/tests/test_push.py new file mode 100644 index 0000000..e6d732a --- /dev/null +++ b/tests/test_push.py @@ -0,0 +1,147 @@ +import pytest + +from api import * + +def test_account_destroy(account, push_server): + dev = Device(account, "dev", pcb=push_server.good("09d0114e-3b23-4ba3-8474-efac7433e3ba")) + account.destroy_account(account.email, "") + p = push_server.wait() + assert p[0] == "/09d0114e-3b23-4ba3-8474-efac7433e3ba" + assert dev.decrypt(p[2]) == { + 'command': 'fxaccounts:account_destroyed', + 'data': {'uid': account.props['uid']}, + 'version': 1, + } + assert push_server.done() + +def test_account_verify(client, push_server, mail_server): + account = client.create_account("test@test", "") + try: + (to, body) = mail_server.wait() + assert to == ["test@test"] + data = json.loads(base64.urlsafe_b64decode(body.split("#/verify/", maxsplit=1)[1]).decode('utf8')) + dev = Device(account, "dev", pcb=push_server.good("8accbe08-4040-44c2-8fd9-cf2669b56cb1")) + account.post_a('/recovery_email/verify_code', { 'uid': data['uid'], 'code': data['code'] }) + p = push_server.wait() + assert p[0] == "/8accbe08-4040-44c2-8fd9-cf2669b56cb1" + assert p[2] == b'' + assert push_server.done() + finally: + account.destroy_account(account.email, "") + +def test_session_destroy(client, account, push_server): + dev1 = Device(account, "dev1") + session = client.login(account.email, "") + dev2 = Device(session, "dev2") + dev1.update_pcb(push_server.good("e6d21a00-9e5e-4d21-92bc-90860cba836e")) + session.destroy_session() + p = push_server.wait() + assert p[0] == "/e6d21a00-9e5e-4d21-92bc-90860cba836e" + assert dev1.decrypt(p[2]) == { + 'command': 'fxaccounts:device_disconnected', + 'data': {'id': dev2.id}, + 'version': 1, + } + assert push_server.done() + +def test_device_connected(client, account, push_server): + dev1 = Device(account, "dev1", pcb=push_server.good("236b8205-daee-4879-b911-64b1f4fd8fd7")) + session = client.login(account.email, "") + dev2 = Device(session, "dev2") + p = push_server.wait() + assert p[0] == "/236b8205-daee-4879-b911-64b1f4fd8fd7" + assert dev1.decrypt(p[2]) == { + 'command': 'fxaccounts:device_connected', + 'data': {'deviceName': 'dev2'}, + 'version': 1, + } + assert push_server.done() + +def test_device_invoke(account, login, push_server): + dev = Device(account, "dev1", commands={'a':'a'}, pcb=push_server.good("3610b071-e2ef-4daa-a4e3-eaa74e50f2a0")) + account.post_a("/account/devices/invoke_command", { + "target": dev.id, + "command": "a", + "payload": {"data": "foo"}, + "ttl": 10, + }) + p = push_server.wait() + assert p[0] == "/3610b071-e2ef-4daa-a4e3-eaa74e50f2a0" + msg = dev.decrypt(p[2]) + # NOTE needed because index is unpredictable due to pg sequence use + del msg['data']['index'] + del msg['data']['url'] + assert msg == { + 'command': 'fxaccounts:command_received', + 'data': {'command': 'a', 'sender': dev.id}, + 'version': 1, + } + assert push_server.done() + +def test_expiry(account, login, push_server): + dev = Device(account, "dev1", commands={'a':'a'}, pcb=push_server.bad("59ba8fcc-f3b0-4b1f-ac27-36d66e022d1e")) + account.post_a("/account/devices/invoke_command", { + "target": dev.id, + "command": "a", + "payload": {"data": "foo"}, + "ttl": 10, + }) + p = push_server.wait() + assert p[0] == "/err/59ba8fcc-f3b0-4b1f-ac27-36d66e022d1e" + account.post_a("/account/devices/invoke_command", { + "target": dev.id, + "command": "a", + "payload": {"data": "foo"}, + "ttl": 10, + }) + with pytest.raises(queue.Empty): + push_server.wait() + dev.update_pcb(push_server.good("59ba8fcc-f3b0-4b1f-ac27-36d66e022d1e")) + account.post_a("/account/devices/invoke_command", { + "target": dev.id, + "command": "a", + "payload": {"data": "foo"}, + "ttl": 10, + }) + p = push_server.wait() + assert p[0] == "/59ba8fcc-f3b0-4b1f-ac27-36d66e022d1e" + assert push_server.done() + +def test_device_notify(account, login, push_server): + dev1 = Device(account, "dev1") + dev2 = Device(login, "dev2") + dev1.update_pcb(push_server.good("738ac7e3-96ef-461c-880c-0af20e311354")) + dev2.update_pcb(push_server.good("85a98191-9486-46e2-877a-1152e3d4af4e")) + account.post_a("/account/devices/notify", { + "to": "all", + "_endpointAction": "accountVerify", + "excluded": [dev2.id], + "payload": {'a':1}, + "TTL": 0, + }) + p = push_server.wait() + assert p[0] == "/738ac7e3-96ef-461c-880c-0af20e311354" + assert dev1.decrypt(p[2]) == {'a':1} + account.post_a("/account/devices/notify", { + "to": [dev2.id], + "_endpointAction": "accountVerify", + "payload": {'a':2}, + "TTL": 0, + }) + p = push_server.wait() + assert p[0] == "/85a98191-9486-46e2-877a-1152e3d4af4e" + assert dev2.decrypt(p[2]) == {'a':2} + assert push_server.done() + +def test_profile(account, push_server): + dev = Device(account, "dev", pcb=push_server.good("12608154-8942-4f1c-9de2-a56465d27d6e")) + profile = account.profile() + profile.post_a("/display_name", {"displayName": "foo"}) + p = push_server.wait() + assert p[0] == "/12608154-8942-4f1c-9de2-a56465d27d6e" + assert dev.decrypt(p[2]) == {'command': 'fxaccounts:profile_updated', 'version': 1} + profile.post_a("/avatar/upload", "doesn't parse the image", headers={'content-type': 'image/png'}) + p = push_server.wait() + assert p[0] == "/12608154-8942-4f1c-9de2-a56465d27d6e" + assert dev.decrypt(p[2]) == {'command': 'fxaccounts:profile_updated', 'version': 1} + assert push_server.done() diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..f36654f --- /dev/null +++ b/web/index.html @@ -0,0 +1,392 @@ + + + + + + + + + + + +
+

+ +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/web/js/browser/browser.js b/web/js/browser/browser.js new file mode 100644 index 0000000..aa8b444 --- /dev/null +++ b/web/js/browser/browser.js @@ -0,0 +1,4 @@ +import AuthClient from './lib/client'; +export * from './lib/client'; +export * from './lib/recoveryKey'; +export default AuthClient; diff --git a/web/js/browser/lib/client.js b/web/js/browser/lib/client.js new file mode 100644 index 0000000..fadbdd9 --- /dev/null +++ b/web/js/browser/lib/client.js @@ -0,0 +1,792 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { + function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } + return new (P || (P = Promise))(function (resolve, reject) { + function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } + function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } + function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } + step((generator = generator.apply(thisArg, _arguments || [])).next()); + }); +}; +var __rest = (this && this.__rest) || function (s, e) { + var t = {}; + for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p) && e.indexOf(p) < 0) + t[p] = s[p]; + if (s != null && typeof Object.getOwnPropertySymbols === "function") + for (var i = 0, p = Object.getOwnPropertySymbols(s); i < p.length; i++) { + if (e.indexOf(p[i]) < 0 && Object.prototype.propertyIsEnumerable.call(s, p[i])) + t[p[i]] = s[p[i]]; + } + return t; +}; +import * as crypto from './crypto'; +import * as hawk from './hawk'; +var ERRORS; +(function (ERRORS) { + ERRORS[ERRORS["INVALID_TIMESTAMP"] = 111] = "INVALID_TIMESTAMP"; + ERRORS[ERRORS["INCORRECT_EMAIL_CASE"] = 120] = "INCORRECT_EMAIL_CASE"; +})(ERRORS || (ERRORS = {})); +var tokenType; +(function (tokenType) { + tokenType["sessionToken"] = "sessionToken"; + tokenType["passwordForgotToken"] = "passwordForgotToken"; + tokenType["keyFetchToken"] = "keyFetchToken"; + tokenType["accountResetToken"] = "accountResetToken"; + tokenType["passwordChangeToken"] = "passwordChangeToken"; +})(tokenType || (tokenType = {})); +var AUTH_PROVIDER; +(function (AUTH_PROVIDER) { + AUTH_PROVIDER["GOOGLE"] = "google"; + AUTH_PROVIDER["APPLE"] = "apple"; +})(AUTH_PROVIDER || (AUTH_PROVIDER = {})); +function langHeader(lang) { + return new Headers(lang + ? { + 'Accept-Language': lang, + } + : {}); +} +function pathWithKeys(path, keys) { + return `${path}${keys ? '?keys=true' : ''}`; +} +function fetchOrTimeout(input, init = {}, timeout) { + return __awaiter(this, void 0, void 0, function* () { + let id = 0; + if (typeof AbortController !== 'undefined') { + const aborter = new AbortController(); + init.signal = aborter.signal; + id = setTimeout((() => aborter.abort()), timeout); + } + try { + return yield fetch(input, init); + } + finally { + if (id) { + clearTimeout(id); + } + } + }); +} +function cleanStringify(value) { + // remove keys with null values + return JSON.stringify(value, (_, v) => (v == null ? undefined : v)); +} +export default class AuthClient { + constructor(authServerUri, options = {}) { + if (new RegExp(`/${AuthClient.VERSION}$`).test(authServerUri)) { + this.uri = authServerUri; + } + else { + this.uri = `${authServerUri}/${AuthClient.VERSION}`; + } + this.localtimeOffsetMsec = 0; + this.timeout = options.timeout || 30000; + } + static create(authServerUri) { + return __awaiter(this, void 0, void 0, function* () { + if (typeof TextEncoder === 'undefined') { + yield import( + // @ts-ignore + /* webpackChunkName: "fast-text-encoding" */ 'fast-text-encoding'); + } + yield crypto.checkWebCrypto(); + return new AuthClient(authServerUri); + }); + } + url(path) { + return `${this.uri}${path}`; + } + request(method, path, payload, headers = new Headers()) { + return __awaiter(this, void 0, void 0, function* () { + headers.set('Content-Type', 'application/json'); + const response = yield fetchOrTimeout(this.url(path), { + method, + headers, + body: cleanStringify(payload), + }, this.timeout); + let result = yield response.text(); + try { + result = JSON.parse(result); + } + catch (e) { } + if (result.errno) { + throw result; + } + if (!response.ok) { + throw { + error: 'Unknown error', + message: result, + errno: 999, + code: response.status, + }; + } + return result; + }); + } + hawkRequest(method, path, token, kind, payload, extraHeaders = new Headers()) { + return __awaiter(this, void 0, void 0, function* () { + const makeHeaders = () => __awaiter(this, void 0, void 0, function* () { + const headers = yield hawk.header(method, this.url(path), token, kind, { + payload: cleanStringify(payload), + contentType: 'application/json', + localtimeOffsetMsec: this.localtimeOffsetMsec, + }); + for (const [name, value] of extraHeaders) { + headers.set(name, value); + } + return headers; + }); + try { + return yield this.request(method, path, payload, yield makeHeaders()); + } + catch (e) { + if (e.errno === ERRORS.INVALID_TIMESTAMP) { + const serverTime = e.serverTime * 1000 || Date.now(); + this.localtimeOffsetMsec = serverTime - Date.now(); + return this.request(method, path, payload, yield makeHeaders()); + } + throw e; + } + }); + } + sessionGet(path, sessionToken) { + return __awaiter(this, void 0, void 0, function* () { + return this.hawkRequest('GET', path, sessionToken, tokenType.sessionToken); + }); + } + sessionPost(path, sessionToken, payload, headers) { + return __awaiter(this, void 0, void 0, function* () { + return this.hawkRequest('POST', path, sessionToken, tokenType.sessionToken, payload, headers); + }); + } + sessionPut(path, sessionToken, payload, headers) { + return __awaiter(this, void 0, void 0, function* () { + return this.hawkRequest('PUT', path, sessionToken, tokenType.sessionToken, payload, headers); + }); + } + signUp(email, password, options = {}) { + return __awaiter(this, void 0, void 0, function* () { + const credentials = yield crypto.getCredentials(email, password); + const payloadOptions = (_a) => { + var { keys, lang } = _a, rest = __rest(_a, ["keys", "lang"]); + return rest; + }; + const payload = Object.assign({ email, authPW: credentials.authPW }, payloadOptions(options)); + const accountData = yield this.request('POST', pathWithKeys('/account/create', options.keys), payload, langHeader(options.lang)); + if (options.keys) { + accountData.unwrapBKey = credentials.unwrapBKey; + } + return accountData; + }); + } + signIn(email, password, options = {}) { + return __awaiter(this, void 0, void 0, function* () { + const credentials = yield crypto.getCredentials(email, password); + const payloadOptions = (_a) => { + var { keys } = _a, rest = __rest(_a, ["keys"]); + return rest; + }; + const payload = Object.assign({ email, authPW: credentials.authPW }, payloadOptions(options)); + try { + const accountData = yield this.request('POST', pathWithKeys('/account/login', options.keys), payload); + if (options.keys) { + accountData.unwrapBKey = credentials.unwrapBKey; + } + return accountData; + } + catch (error) { + if (error && + error.email && + error.errno === ERRORS.INCORRECT_EMAIL_CASE && + !options.skipCaseError) { + options.skipCaseError = true; + options.originalLoginEmail = email; + return this.signIn(error.email, password, options); + } + else { + throw error; + } + } + }); + } + verifyCode(uid, code, options = {}) { + return __awaiter(this, void 0, void 0, function* () { + return this.request('POST', '/recovery_email/verify_code', Object.assign({ uid, + code }, options)); + }); + } + recoveryEmailStatus(sessionToken) { + return __awaiter(this, void 0, void 0, function* () { + return this.sessionGet('/recovery_email/status', sessionToken); + }); + } + recoveryEmailResendCode(sessionToken, options = {}) { + return __awaiter(this, void 0, void 0, function* () { + const payloadOptions = (_a) => { + var { lang } = _a, rest = __rest(_a, ["lang"]); + return rest; + }; + return this.sessionPost('/recovery_email/resend_code', sessionToken, payloadOptions(options), langHeader(options.lang)); + }); + } + passwordForgotSendCode(email, options = {}) { + return __awaiter(this, void 0, void 0, function* () { + const payloadOptions = (_a) => { + var { lang } = _a, rest = __rest(_a, ["lang"]); + return rest; + }; + const payload = Object.assign({ email }, payloadOptions(options)); + return this.request('POST', '/password/forgot/send_code', payload, langHeader(options.lang)); + }); + } + passwordForgotResendCode(email, passwordForgotToken, options = {}) { + return __awaiter(this, void 0, void 0, function* () { + const payloadOptions = (_a) => { + var { lang } = _a, rest = __rest(_a, ["lang"]); + return rest; + }; + const payload = Object.assign({ email }, payloadOptions(options)); + return this.hawkRequest('POST', '/password/forgot/resend_code', passwordForgotToken, tokenType.passwordForgotToken, payload, langHeader(options.lang)); + }); + } + passwordForgotVerifyCode(code, passwordForgotToken, options = {}) { + return __awaiter(this, void 0, void 0, function* () { + const payload = Object.assign({ code }, options); + return this.hawkRequest('POST', '/password/forgot/verify_code', passwordForgotToken, tokenType.passwordForgotToken, payload); + }); + } + passwordForgotStatus(passwordForgotToken) { + return __awaiter(this, void 0, void 0, function* () { + return this.hawkRequest('GET', '/password/forgot/status', passwordForgotToken, tokenType.passwordForgotToken); + }); + } + accountReset(email, newPassword, accountResetToken, options = {}) { + return __awaiter(this, void 0, void 0, function* () { + const credentials = yield crypto.getCredentials(email, newPassword); + const payloadOptions = (_a) => { + var { keys } = _a, rest = __rest(_a, ["keys"]); + return rest; + }; + const payload = Object.assign({ authPW: credentials.authPW }, payloadOptions(options)); + const accountData = yield this.hawkRequest('POST', pathWithKeys('/account/reset', options.keys), accountResetToken, tokenType.accountResetToken, payload); + if (options.keys && accountData.keyFetchToken) { + accountData.unwrapBKey = credentials.unwrapBKey; + } + return accountData; + }); + } + finishSetup(token, email, newPassword) { + return __awaiter(this, void 0, void 0, function* () { + const credentials = yield crypto.getCredentials(email, newPassword); + const payload = { + token, + authPW: credentials.authPW, + }; + return yield this.request('POST', '/account/finish_setup', payload); + }); + } + verifyAccountThirdParty(code, provider = AUTH_PROVIDER.GOOGLE, metricsContext = {}) { + return __awaiter(this, void 0, void 0, function* () { + const payload = { + code, + provider, + metricsContext, + }; + return yield this.request('POST', '/linked_account/login', payload); + }); + } + unlinkThirdParty(sessionToken, providerId) { + return __awaiter(this, void 0, void 0, function* () { + let provider; + switch (providerId) { + case 2: { + provider = AUTH_PROVIDER.APPLE; + break; + } + default: { + provider = AUTH_PROVIDER.GOOGLE; + } + } + return yield this.sessionPost('/linked_account/unlink', sessionToken, { + provider, + }); + }); + } + accountKeys(keyFetchToken, unwrapBKey) { + return __awaiter(this, void 0, void 0, function* () { + const credentials = yield hawk.deriveHawkCredentials(keyFetchToken, 'keyFetchToken'); + const keyData = yield this.hawkRequest('GET', '/account/keys', keyFetchToken, tokenType.keyFetchToken); + const keys = yield crypto.unbundleKeyFetchResponse(credentials.bundleKey, keyData.bundle); + return { + kA: keys.kA, + kB: crypto.unwrapKB(keys.wrapKB, unwrapBKey), + }; + }); + } + accountDestroy(email, password, options = {}, sessionToken) { + return __awaiter(this, void 0, void 0, function* () { + const credentials = yield crypto.getCredentials(email, password); + const payload = { + email, + authPW: credentials.authPW, + }; + try { + if (sessionToken) { + return yield this.sessionPost('/account/destroy', sessionToken, payload); + } + else { + return yield this.request('POST', '/account/destroy', payload); + } + } + catch (error) { + if (error && + error.email && + error.errno === ERRORS.INCORRECT_EMAIL_CASE && + !options.skipCaseError) { + options.skipCaseError = true; + return this.accountDestroy(error.email, password, options, sessionToken); + } + else { + throw error; + } + } + }); + } + accountStatus(uid) { + return __awaiter(this, void 0, void 0, function* () { + return this.request('GET', `/account/status?uid=${uid}`); + }); + } + accountStatusByEmail(email) { + return __awaiter(this, void 0, void 0, function* () { + return this.request('POST', '/account/status', { email }); + }); + } + accountProfile(sessionToken) { + return __awaiter(this, void 0, void 0, function* () { + return this.sessionGet('/account/profile', sessionToken); + }); + } + account(sessionToken) { + return __awaiter(this, void 0, void 0, function* () { + return this.sessionGet('/account', sessionToken); + }); + } + sessionDestroy(sessionToken, options = {}) { + return __awaiter(this, void 0, void 0, function* () { + return this.sessionPost('/session/destroy', sessionToken, options); + }); + } + sessionStatus(sessionToken) { + return __awaiter(this, void 0, void 0, function* () { + return this.sessionGet('/session/status', sessionToken); + }); + } + sessionVerifyCode(sessionToken, code, options = {}) { + return __awaiter(this, void 0, void 0, function* () { + return this.sessionPost('/session/verify_code', sessionToken, Object.assign({ code }, options)); + }); + } + sessionResendVerifyCode(sessionToken) { + return __awaiter(this, void 0, void 0, function* () { + return this.sessionPost('/session/resend_code', sessionToken, {}); + }); + } + sessionReauth(sessionToken, email, password, options = {}) { + return __awaiter(this, void 0, void 0, function* () { + const credentials = yield crypto.getCredentials(email, password); + const payloadOptions = (_a) => { + var { keys } = _a, rest = __rest(_a, ["keys"]); + return rest; + }; + const payload = Object.assign({ email, authPW: credentials.authPW }, payloadOptions(options)); + try { + const accountData = yield this.sessionPost(pathWithKeys('/session/reauth', options.keys), sessionToken, payload); + if (options.keys) { + accountData.unwrapBKey = credentials.unwrapBKey; + } + return accountData; + } + catch (error) { + if (error && + error.email && + error.errno === ERRORS.INCORRECT_EMAIL_CASE && + !options.skipCaseError) { + options.skipCaseError = true; + options.originalLoginEmail = email; + return this.sessionReauth(sessionToken, error.email, password, options); + } + else { + throw error; + } + } + }); + } + certificateSign(sessionToken, publicKey, duration, options = {}) { + return __awaiter(this, void 0, void 0, function* () { + const payload = { + publicKey, + duration, + }; + return this.sessionPost(`/certificate/sign${options.service + ? `?service=${encodeURIComponent(options.service)}` + : ''}`, sessionToken, payload); + }); + } + passwordChange(email, oldPassword, newPassword, options = {}) { + return __awaiter(this, void 0, void 0, function* () { + const oldCredentials = yield this.passwordChangeStart(email, oldPassword); + const keys = yield this.accountKeys(oldCredentials.keyFetchToken, oldCredentials.unwrapBKey); + const newCredentials = yield crypto.getCredentials(oldCredentials.email, newPassword); + const wrapKb = crypto.unwrapKB(keys.kB, newCredentials.unwrapBKey); + const sessionToken = options.sessionToken + ? (yield hawk.deriveHawkCredentials(options.sessionToken, 'sessionToken')) + .id + : undefined; + const payload = { + wrapKb, + authPW: newCredentials.authPW, + sessionToken, + }; + const accountData = yield this.hawkRequest('POST', pathWithKeys('/password/change/finish', options.keys), oldCredentials.passwordChangeToken, tokenType.passwordChangeToken, payload); + if (options.keys && accountData.keyFetchToken) { + accountData.unwrapBKey = newCredentials.unwrapBKey; + } + return accountData; + }); + } + passwordChangeStart(email, oldPassword, options = {}) { + return __awaiter(this, void 0, void 0, function* () { + const oldCredentials = yield crypto.getCredentials(email, oldPassword); + try { + const passwordData = yield this.request('POST', '/password/change/start', { + email, + oldAuthPW: oldCredentials.authPW, + }); + return { + authPW: oldCredentials.authPW, + unwrapBKey: oldCredentials.unwrapBKey, + email: email, + keyFetchToken: passwordData.keyFetchToken, + passwordChangeToken: passwordData.passwordChangeToken, + }; + } + catch (error) { + if (error && + error.email && + error.errno === ERRORS.INCORRECT_EMAIL_CASE && + !options.skipCaseError) { + options.skipCaseError = true; + return this.passwordChangeStart(error.email, oldPassword, options); + } + else { + throw error; + } + } + }); + } + createPassword(sessionToken, email, newPassword) { + return __awaiter(this, void 0, void 0, function* () { + const newCredentials = yield crypto.getCredentials(email, newPassword); + const payload = { + authPW: newCredentials.authPW, + }; + return this.sessionPost('/password/create', sessionToken, payload); + }); + } + getRandomBytes() { + return __awaiter(this, void 0, void 0, function* () { + return this.request('POST', '/get_random_bytes'); + }); + } + deviceRegister(sessionToken, name, type, options = {}) { + return __awaiter(this, void 0, void 0, function* () { + const payload = Object.assign({ name, + type }, options); + return this.sessionPost('/account/device', sessionToken, payload); + }); + } + deviceUpdate(sessionToken, id, name, options = {}) { + return __awaiter(this, void 0, void 0, function* () { + const payload = Object.assign({ id, + name }, options); + return this.sessionPost('/account/device', sessionToken, payload); + }); + } + deviceDestroy(sessionToken, id) { + return __awaiter(this, void 0, void 0, function* () { + return this.sessionPost('/account/device/destroy', sessionToken, { id }); + }); + } + deviceList(sessionToken) { + return __awaiter(this, void 0, void 0, function* () { + return this.sessionGet('/account/devices', sessionToken); + }); + } + sessions(sessionToken) { + return __awaiter(this, void 0, void 0, function* () { + return this.sessionGet('/account/sessions', sessionToken); + }); + } + securityEvents(sessionToken) { + return __awaiter(this, void 0, void 0, function* () { + return this.sessionGet('/securityEvents', sessionToken); + }); + } + deleteSecurityEvents(sessionToken) { + return __awaiter(this, void 0, void 0, function* () { + return this.hawkRequest('DELETE', '/securityEvents', sessionToken, tokenType.sessionToken, {}); + }); + } + attachedClients(sessionToken) { + return __awaiter(this, void 0, void 0, function* () { + return this.sessionGet('/account/attached_clients', sessionToken); + }); + } + attachedClientDestroy(sessionToken, clientInfo) { + return __awaiter(this, void 0, void 0, function* () { + return this.sessionPost('/account/attached_client/destroy', sessionToken, { + clientId: clientInfo.clientId, + deviceId: clientInfo.deviceId, + refreshTokenId: clientInfo.refreshTokenId, + sessionTokenId: clientInfo.sessionTokenId, + }); + }); + } + sendUnblockCode(email, options = {}) { + return __awaiter(this, void 0, void 0, function* () { + return this.request('POST', '/account/login/send_unblock_code', Object.assign({ email }, options)); + }); + } + rejectUnblockCode(uid, unblockCode) { + return __awaiter(this, void 0, void 0, function* () { + return this.request('POST', '/account/login/reject_unblock_code', { + uid, + unblockCode, + }); + }); + } + consumeSigninCode(code, flowId, flowBeginTime, deviceId) { + return __awaiter(this, void 0, void 0, function* () { + return this.request('POST', '/signinCodes/consume', { + code, + metricsContext: { + deviceId, + flowId, + flowBeginTime, + }, + }); + }); + } + createSigninCode(sessionToken) { + return __awaiter(this, void 0, void 0, function* () { + return this.sessionPost('/signinCodes', sessionToken, {}); + }); + } + createCadReminder(sessionToken) { + return __awaiter(this, void 0, void 0, function* () { + return this.sessionPost('/emails/reminders/cad', sessionToken, {}); + }); + } + recoveryEmails(sessionToken) { + return __awaiter(this, void 0, void 0, function* () { + return this.sessionGet('/recovery_emails', sessionToken); + }); + } + recoveryEmailCreate(sessionToken, email, options = {}) { + return __awaiter(this, void 0, void 0, function* () { + return this.sessionPost('/recovery_email', sessionToken, Object.assign({ email }, options)); + }); + } + recoveryEmailDestroy(sessionToken, email) { + return __awaiter(this, void 0, void 0, function* () { + return this.sessionPost('/recovery_email/destroy', sessionToken, { email }); + }); + } + recoveryEmailSetPrimaryEmail(sessionToken, email) { + return __awaiter(this, void 0, void 0, function* () { + return this.sessionPost('/recovery_email/set_primary', sessionToken, { + email, + }); + }); + } + recoveryEmailSecondaryVerifyCode(sessionToken, email, code) { + return __awaiter(this, void 0, void 0, function* () { + return this.sessionPost('/recovery_email/secondary/verify_code', sessionToken, { email, code }); + }); + } + recoveryEmailSecondaryResendCode(sessionToken, email) { + return __awaiter(this, void 0, void 0, function* () { + return this.sessionPost('/recovery_email/secondary/resend_code', sessionToken, { email }); + }); + } + createTotpToken(sessionToken, options = {}) { + return __awaiter(this, void 0, void 0, function* () { + return this.sessionPost('/totp/create', sessionToken, options); + }); + } + deleteTotpToken(sessionToken) { + return __awaiter(this, void 0, void 0, function* () { + return this.sessionPost('/totp/destroy', sessionToken, {}); + }); + } + checkTotpTokenExists(sessionToken) { + return __awaiter(this, void 0, void 0, function* () { + return this.sessionGet('/totp/exists', sessionToken); + }); + } + verifyTotpCode(sessionToken, code, options = {}) { + return __awaiter(this, void 0, void 0, function* () { + return this.sessionPost('/session/verify/totp', sessionToken, Object.assign({ code }, options)); + }); + } + replaceRecoveryCodes(sessionToken) { + return __awaiter(this, void 0, void 0, function* () { + return this.sessionGet('/recoveryCodes', sessionToken); + }); + } + updateRecoveryCodes(sessionToken, recoveryCodes) { + return __awaiter(this, void 0, void 0, function* () { + return this.sessionPut('/recoveryCodes', sessionToken, { recoveryCodes }); + }); + } + consumeRecoveryCode(sessionToken, code) { + return __awaiter(this, void 0, void 0, function* () { + return this.sessionPost('/session/verify/recoveryCode', sessionToken, { + code, + }); + }); + } + createRecoveryKey(sessionToken, recoveryKeyId, recoveryData, enabled) { + return __awaiter(this, void 0, void 0, function* () { + return this.sessionPost('/recoveryKey', sessionToken, { + recoveryKeyId, + recoveryData, + enabled, + }); + }); + } + getRecoveryKey(accountResetToken, recoveryKeyId) { + return __awaiter(this, void 0, void 0, function* () { + return this.hawkRequest('GET', `/recoveryKey/${recoveryKeyId}`, accountResetToken, tokenType.accountResetToken); + }); + } + resetPasswordWithRecoveryKey(accountResetToken, email, newPassword, recoveryKeyId, keys, options = {}) { + return __awaiter(this, void 0, void 0, function* () { + const credentials = yield crypto.getCredentials(email, newPassword); + const newWrapKb = crypto.unwrapKB(keys.kB, credentials.unwrapBKey); + const payload = { + wrapKb: newWrapKb, + authPW: credentials.authPW, + sessionToken: options.sessionToken, + recoveryKeyId, + }; + const accountData = yield this.hawkRequest('POST', pathWithKeys('/account/reset', options.keys), accountResetToken, tokenType.accountResetToken, payload); + if (options.keys && accountData.keyFetchToken) { + accountData.unwrapBKey = credentials.unwrapBKey; + } + return accountData; + }); + } + deleteRecoveryKey(sessionToken) { + return __awaiter(this, void 0, void 0, function* () { + return this.hawkRequest('DELETE', '/recoveryKey', sessionToken, tokenType.sessionToken, {}); + }); + } + recoveryKeyExists(sessionToken, email) { + return __awaiter(this, void 0, void 0, function* () { + if (sessionToken) { + return this.sessionPost('/recoveryKey/exists', sessionToken, { email }); + } + return this.request('POST', '/recoveryKey/exists', { email }); + }); + } + verifyRecoveryKey(sessionToken, recoveryKeyId) { + return __awaiter(this, void 0, void 0, function* () { + return this.sessionPost('/recoveryKey/verify', sessionToken, { + recoveryKeyId, + }); + }); + } + createOAuthCode(sessionToken, clientId, state, options = {}) { + return __awaiter(this, void 0, void 0, function* () { + return this.sessionPost('/oauth/authorization', sessionToken, { + access_type: options.access_type, + acr_values: options.acr_values, + client_id: clientId, + code_challenge: options.code_challenge, + code_challenge_method: options.code_challenge_method, + keys_jwe: options.keys_jwe, + redirect_uri: options.redirect_uri, + response_type: options.response_type, + scope: options.scope, + state, + }); + }); + } + createOAuthToken(sessionToken, clientId, options = {}) { + return __awaiter(this, void 0, void 0, function* () { + return this.sessionPost('/oauth/token', sessionToken, { + grant_type: 'fxa-credentials', + access_type: options.access_type, + client_id: clientId, + scope: options.scope, + ttl: options.ttl, + }); + }); + } + getOAuthScopedKeyData(sessionToken, clientId, scope) { + return __awaiter(this, void 0, void 0, function* () { + return this.sessionPost('/account/scoped-key-data', sessionToken, { + client_id: clientId, + scope, + }); + }); + } + getSubscriptionPlans() { + return __awaiter(this, void 0, void 0, function* () { + return this.request('GET', '/oauth/subscriptions/plans'); + }); + } + getActiveSubscriptions(accessToken) { + return __awaiter(this, void 0, void 0, function* () { + return this.request('GET', '/oauth/subscriptions/active', null, new Headers({ + authorization: `Bearer ${accessToken}`, + })); + }); + } + createSupportTicket(accessToken, supportTicket) { + return __awaiter(this, void 0, void 0, function* () { + return this.request('POST', '/support/ticket', supportTicket, new Headers({ + authorization: `Bearer ${accessToken}`, + })); + }); + } + updateNewsletters(sessionToken, newsletters) { + return __awaiter(this, void 0, void 0, function* () { + return this.sessionPost('/newsletters', sessionToken, { + newsletters, + }); + }); + } + verifyIdToken(idToken, clientId, expiryGracePeriod) { + return __awaiter(this, void 0, void 0, function* () { + const payload = { + id_token: idToken, + client_id: clientId, + }; + if (expiryGracePeriod) { + payload.expiry_grace_period = expiryGracePeriod; + } + return this.request('POST', '/oauth/id-token-verify', payload); + }); + } + sendPushLoginRequest(sessionToken) { + return __awaiter(this, void 0, void 0, function* () { + return this.sessionPost('/session/verify/send_push', sessionToken, {}); + }); + } +} +AuthClient.VERSION = 'v1'; diff --git a/web/js/browser/lib/crypto.js b/web/js/browser/lib/crypto.js new file mode 100644 index 0000000..6fa6107 --- /dev/null +++ b/web/js/browser/lib/crypto.js @@ -0,0 +1,163 @@ +var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { + function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } + return new (P || (P = Promise))(function (resolve, reject) { + function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } + function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } + function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } + step((generator = generator.apply(thisArg, _arguments || [])).next()); + }); +}; +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +import { hexToUint8, uint8ToBase64Url, uint8ToHex, xor } from './utils'; +const encoder = () => new TextEncoder(); +const NAMESPACE = 'identity.mozilla.com/picl/v1/'; +// These functions implement the onepw protocol +// https://github.com/mozilla/fxa-auth-server/wiki/onepw-protocol +export function getCredentials(email, password) { + return __awaiter(this, void 0, void 0, function* () { + const passkey = yield crypto.subtle.importKey('raw', encoder().encode(password), 'PBKDF2', false, ['deriveBits']); + const quickStretchedRaw = yield crypto.subtle.deriveBits({ + name: 'PBKDF2', + salt: encoder().encode(`${NAMESPACE}quickStretch:${email}`), + iterations: 1000, + hash: 'SHA-256', + }, passkey, 256); + const quickStretchedKey = yield crypto.subtle.importKey('raw', quickStretchedRaw, 'HKDF', false, ['deriveBits']); + const authPW = yield crypto.subtle.deriveBits({ + name: 'HKDF', + salt: new Uint8Array(0), + // The builtin ts type definition for HKDF was wrong + // at the time this was written, hence the ignore + // @ts-ignore + info: encoder().encode(`${NAMESPACE}authPW`), + hash: 'SHA-256', + }, quickStretchedKey, 256); + const unwrapBKey = yield crypto.subtle.deriveBits({ + name: 'HKDF', + salt: new Uint8Array(0), + // @ts-ignore + info: encoder().encode(`${NAMESPACE}unwrapBkey`), + hash: 'SHA-256', + }, quickStretchedKey, 256); + return { + authPW: uint8ToHex(new Uint8Array(authPW)), + unwrapBKey: uint8ToHex(new Uint8Array(unwrapBKey)), + }; + }); +} +export function deriveBundleKeys(key, keyInfo, payloadBytes = 64) { + return __awaiter(this, void 0, void 0, function* () { + const baseKey = yield crypto.subtle.importKey('raw', hexToUint8(key), 'HKDF', false, ['deriveBits']); + const keyMaterial = yield crypto.subtle.deriveBits({ + name: 'HKDF', + salt: new Uint8Array(0), + // @ts-ignore + info: encoder().encode(`${NAMESPACE}${keyInfo}`), + hash: 'SHA-256', + }, baseKey, (32 + payloadBytes) * 8); + const hmacKey = yield crypto.subtle.importKey('raw', new Uint8Array(keyMaterial.slice(0, 32)), { + name: 'HMAC', + hash: 'SHA-256', + length: 256, + }, true, ['verify']); + const xorKey = new Uint8Array(keyMaterial.slice(32)); + return { + hmacKey, + xorKey, + }; + }); +} +export function unbundleKeyFetchResponse(key, bundle) { + return __awaiter(this, void 0, void 0, function* () { + const b = hexToUint8(bundle); + const keys = yield deriveBundleKeys(key, 'account/keys'); + const ciphertext = b.subarray(0, 64); + const expectedHmac = b.subarray(b.byteLength - 32); + const valid = yield crypto.subtle.verify('HMAC', keys.hmacKey, expectedHmac, ciphertext); + if (!valid) { + throw new Error('Bad HMac'); + } + const keyAWrapB = xor(ciphertext, keys.xorKey); + return { + kA: uint8ToHex(keyAWrapB.subarray(0, 32)), + wrapKB: uint8ToHex(keyAWrapB.subarray(32)), + }; + }); +} +export function unwrapKB(wrapKB, unwrapBKey) { + return uint8ToHex(xor(hexToUint8(wrapKB), hexToUint8(unwrapBKey))); +} +export function hkdf(keyMaterial, salt, info, bytes) { + return __awaiter(this, void 0, void 0, function* () { + const key = yield crypto.subtle.importKey('raw', keyMaterial, 'HKDF', false, [ + 'deriveBits', + ]); + const result = yield crypto.subtle.deriveBits({ + name: 'HKDF', + salt, + // @ts-ignore + info, + hash: 'SHA-256', + }, key, bytes * 8); + return new Uint8Array(result); + }); +} +export function jweEncrypt(keyMaterial, kid, data, forTestingOnly) { + return __awaiter(this, void 0, void 0, function* () { + const key = yield crypto.subtle.importKey('raw', keyMaterial, { + name: 'AES-GCM', + }, false, ['encrypt']); + const jweHeader = uint8ToBase64Url(encoder().encode(JSON.stringify({ + enc: 'A256GCM', + alg: 'dir', + kid, + }))); + const iv = (forTestingOnly === null || forTestingOnly === void 0 ? void 0 : forTestingOnly.testIV) || crypto.getRandomValues(new Uint8Array(12)); + const encrypted = yield crypto.subtle.encrypt({ + name: 'AES-GCM', + iv, + additionalData: encoder().encode(jweHeader), + tagLength: 128, + }, key, data); + const ciphertext = new Uint8Array(encrypted.slice(0, encrypted.byteLength - 16)); + const authenticationTag = new Uint8Array(encrypted.slice(encrypted.byteLength - 16)); + // prettier-ignore + const compactJWE = `${jweHeader}..${uint8ToBase64Url(iv)}.${uint8ToBase64Url(ciphertext)}.${uint8ToBase64Url(authenticationTag)}`; + return compactJWE; + }); +} +export function checkWebCrypto() { + return __awaiter(this, void 0, void 0, function* () { + try { + yield crypto.subtle.importKey('raw', crypto.getRandomValues(new Uint8Array(16)), 'PBKDF2', false, ['deriveKey']); + yield crypto.subtle.importKey('raw', crypto.getRandomValues(new Uint8Array(32)), 'HKDF', false, ['deriveKey']); + yield crypto.subtle.importKey('raw', crypto.getRandomValues(new Uint8Array(32)), { + name: 'HMAC', + hash: 'SHA-256', + length: 256, + }, false, ['sign']); + yield crypto.subtle.importKey('raw', crypto.getRandomValues(new Uint8Array(32)), { + name: 'AES-GCM', + }, false, ['encrypt']); + yield crypto.subtle.digest('SHA-256', crypto.getRandomValues(new Uint8Array(16))); + return true; + } + catch (err) { + try { + console.warn('loading webcrypto shim', err); + // prettier-ignore + // @ts-ignore + window.asmCrypto = yield import(/* webpackChunkName: "asmcrypto.js" */ 'asmcrypto.js'); + // prettier-ignore + // @ts-ignore + yield import(/* webpackChunkName: "webcrypto-liner" */ 'webcrypto-liner/build/shim'); + return true; + } + catch (e) { + return false; + } + } + }); +} diff --git a/web/js/browser/lib/hawk.d.ts b/web/js/browser/lib/hawk.d.ts new file mode 100644 index 0000000..d4e5263 --- /dev/null +++ b/web/js/browser/lib/hawk.d.ts @@ -0,0 +1,24 @@ +/// +export declare function deriveHawkCredentials(token: hexstring, context: string): Promise<{ + id: string; + key: Uint8Array; + bundleKey: string; +}>; +export declare function hawkHeader(method: string, uri: string, options: { + credentials: { + id: string; + key: Uint8Array; + }; + payload?: string; + timestamp?: number; + nonce?: string; + contentType?: string; + localtimeOffsetMsec?: number; +}): Promise; +export declare function header(method: string, uri: string, token: string, kind: string, options: { + payload?: string; + timestamp?: number; + nonce?: string; + contentType?: string; + localtimeOffsetMsec?: number; +}): Promise; diff --git a/web/js/browser/lib/hawk.js b/web/js/browser/lib/hawk.js new file mode 100644 index 0000000..69c7153 --- /dev/null +++ b/web/js/browser/lib/hawk.js @@ -0,0 +1,145 @@ +var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { + function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } + return new (P || (P = Promise))(function (resolve, reject) { + function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } + function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } + function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } + step((generator = generator.apply(thisArg, _arguments || [])).next()); + }); +}; +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +import { hexToUint8, uint8ToBase64, uint8ToHex } from './utils'; +const encoder = () => new TextEncoder(); +const NAMESPACE = 'identity.mozilla.com/picl/v1/'; +export function deriveHawkCredentials(token, context) { + return __awaiter(this, void 0, void 0, function* () { + const baseKey = yield crypto.subtle.importKey('raw', hexToUint8(token), 'HKDF', false, ['deriveBits']); + const keyMaterial = yield crypto.subtle.deriveBits({ + name: 'HKDF', + salt: new Uint8Array(0), + // @ts-ignore + info: encoder().encode(`${NAMESPACE}${context}`), + hash: 'SHA-256', + }, baseKey, 32 * 3 * 8); + const id = new Uint8Array(keyMaterial.slice(0, 32)); + const authKey = new Uint8Array(keyMaterial.slice(32, 64)); + const bundleKey = new Uint8Array(keyMaterial.slice(64)); + return { + id: uint8ToHex(id), + key: authKey, + bundleKey: uint8ToHex(bundleKey), + }; + }); +} +// The following is adapted from https://github.com/hapijs/hawk/blob/master/lib/browser.js +/* + HTTP Hawk Authentication Scheme + Copyright (c) 2012-2013, Eran Hammer + MIT Licensed + */ +function parseUri(input) { + const parts = input.match(/^([^:]+)\:\/\/(?:[^@/]*@)?([^\/:]+)(?:\:(\d+))?([^#]*)(?:#.*)?$/); + if (!parts) { + return { host: '', port: '', resource: '' }; + } + const scheme = parts[1].toLowerCase(); + const uri = { + host: parts[2], + port: parts[3] || (scheme === 'http' ? '80' : scheme === 'https' ? '443' : ''), + resource: parts[4], + }; + return uri; +} +function randomString(size) { + const randomSource = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; + const len = randomSource.length; + const result = []; + for (let i = 0; i < size; ++i) { + result[i] = randomSource[Math.floor(Math.random() * len)]; + } + return result.join(''); +} +function generateNormalizedString(type, options) { + let normalized = 'hawk.1.' + + type + + '\n' + + options.ts + + '\n' + + options.nonce + + '\n' + + (options.method || '').toUpperCase() + + '\n' + + (options.resource || '') + + '\n' + + options.host.toLowerCase() + + '\n' + + options.port + + '\n' + + (options.hash || '') + + '\n'; + if (options.ext) { + normalized += options.ext.replace(/\\/g, '\\\\').replace(/\n/g, '\\n'); + } + normalized += '\n'; + if (options.app) { + normalized += options.app + '\n' + (options.dlg || '') + '\n'; + } + return normalized; +} +function calculatePayloadHash(payload = '', contentType = '') { + return __awaiter(this, void 0, void 0, function* () { + const data = encoder().encode(`hawk.1.payload\n${contentType}\n${payload}\n`); + const hash = yield crypto.subtle.digest('SHA-256', data); + return uint8ToBase64(new Uint8Array(hash)); + }); +} +function calculateMac(type, credentials, options) { + return __awaiter(this, void 0, void 0, function* () { + const normalized = generateNormalizedString(type, options); + const key = yield crypto.subtle.importKey('raw', credentials.key, { + name: 'HMAC', + hash: 'SHA-256', + length: 256, + }, true, ['sign']); + const hmac = yield crypto.subtle.sign('HMAC', key, encoder().encode(normalized)); + return uint8ToBase64(new Uint8Array(hmac)); + }); +} +export function hawkHeader(method, uri, options) { + return __awaiter(this, void 0, void 0, function* () { + const timestamp = options.timestamp || + Math.floor((Date.now() + (options.localtimeOffsetMsec || 0)) / 1000); + const parsedUri = parseUri(uri); + const hash = yield calculatePayloadHash(options.payload, options.contentType); + const artifacts = { + ts: timestamp, + nonce: options.nonce || randomString(6), + method, + resource: parsedUri.resource, + host: parsedUri.host, + port: parsedUri.port, + hash, + }; + const mac = yield calculateMac('header', options.credentials, artifacts); + const header = 'Hawk id="' + + options.credentials.id + + '", ts="' + + artifacts.ts + + '", nonce="' + + artifacts.nonce + + (artifacts.hash ? '", hash="' + artifacts.hash : '') + + '", mac="' + + mac + + '"'; + return header; + }); +} +export function header(method, uri, token, kind, options) { + return __awaiter(this, void 0, void 0, function* () { + const credentials = yield deriveHawkCredentials(token, kind); + const authorization = yield hawkHeader(method, uri, Object.assign({ credentials }, options)); + return new Headers({ authorization }); + }); +} diff --git a/web/js/browser/lib/recoveryKey.js b/web/js/browser/lib/recoveryKey.js new file mode 100644 index 0000000..cc8604d --- /dev/null +++ b/web/js/browser/lib/recoveryKey.js @@ -0,0 +1,38 @@ +var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { + function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } + return new (P || (P = Promise))(function (resolve, reject) { + function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } + function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } + function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } + step((generator = generator.apply(thisArg, _arguments || [])).next()); + }); +}; +import { jweEncrypt, hkdf } from './crypto'; +import { hexToUint8, uint8ToHex } from './utils'; +function randomKey() { + return __awaiter(this, void 0, void 0, function* () { + // The key is displayed in base32 'Crockford' so the length should be + // divisible by (5 bits per character) and (8 bits per byte). + // 20 bytes == 160 bits == 32 base32 characters + const recoveryKey = yield crypto.getRandomValues(new Uint8Array(20)); + // Flip bits to set first char to base32 'A' as a version identifier. + // Why 'A'? https://github.com/mozilla/fxa-content-server/pull/6323#discussion_r201211711 + recoveryKey[0] = 0x50 | (0x07 & recoveryKey[0]); + return recoveryKey; + }); +} +export function generateRecoveryKey(uid, keys, forTestingOnly) { + return __awaiter(this, void 0, void 0, function* () { + const recoveryKey = (forTestingOnly === null || forTestingOnly === void 0 ? void 0 : forTestingOnly.testRecoveryKey) || (yield randomKey()); + const encoder = new TextEncoder(); + const salt = hexToUint8(uid); + const encryptionKey = yield hkdf(recoveryKey, salt, encoder.encode('fxa recovery encrypt key'), 32); + const recoveryKeyId = uint8ToHex(yield hkdf(recoveryKey, salt, encoder.encode('fxa recovery fingerprint'), 16)); + const recoveryData = yield jweEncrypt(encryptionKey, recoveryKeyId, encoder.encode(JSON.stringify(keys)), forTestingOnly ? { testIV: forTestingOnly.testIV } : undefined); + return { + recoveryKey, + recoveryKeyId, + recoveryData, + }; + }); +} diff --git a/web/js/browser/lib/utils.js b/web/js/browser/lib/utils.js new file mode 100644 index 0000000..5bf0383 --- /dev/null +++ b/web/js/browser/lib/utils.js @@ -0,0 +1,26 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +const HEX_STRING = /^(?:[a-fA-F0-9]{2})+$/; +export function hexToUint8(str) { + if (!HEX_STRING.test(str)) { + throw new Error(`invalid hex string: ${str}`); + } + const bytes = str.match(/[a-fA-F0-9]{2}/g); + return new Uint8Array(bytes.map((byte) => parseInt(byte, 16))); +} +export function uint8ToHex(array) { + return array.reduce((str, byte) => str + ('00' + byte.toString(16)).slice(-2), ''); +} +export function uint8ToBase64(array) { + return btoa(String.fromCharCode(...array)); +} +export function uint8ToBase64Url(array) { + return uint8ToBase64(array) + .replace(/=/g, '') + .replace(/\+/g, '-') + .replace(/\//g, '_'); +} +export function xor(array1, array2) { + return new Uint8Array(array1.map((byte, i) => byte ^ array2[i])); +} diff --git a/web/js/crypto.js b/web/js/crypto.js new file mode 100644 index 0000000..5c12a2c --- /dev/null +++ b/web/js/crypto.js @@ -0,0 +1,137 @@ +'use strict'; + +// taken from fxa +function hexToUint8(str) { + const HEX_STRING = /^(?:[a-fA-F0-9]{2})+$/; + if (!HEX_STRING.test(str)) { + throw new Error(`invalid hex string: ${str}`); + } + const bytes = str.match(/[a-fA-F0-9]{2}/g); + return new Uint8Array(bytes.map((byte) => parseInt(byte, 16))); +} +function uint8ToHex(array) { + return array.reduce((str, byte) => str + ('00' + byte.toString(16)).slice(-2), ''); +} +function uint8ToBase64(array) { + return btoa(String.fromCharCode(...array)); +} +function uint8ToBase64Url(array) { + return uint8ToBase64(array).replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_'); +} + +export function urlatob(s) { + return atob(s.replace(/-/g, '+').replace(/_/g, "/")); +} + +export async function deriveScopedKey(kB, uid, identifier, key_rotation_secret, key_rotation_timestamp) { + async function importKey(k) { + return await crypto.subtle.importKey('raw', hexToUint8(k), 'HKDF', false, ['deriveBits']); + } + + async function derive(salt, k, info, len) { + let params = { + name: 'HKDF', + salt, + info: new TextEncoder().encode(info), + hash: 'SHA-256', + }; + return new Uint8Array(await crypto.subtle.deriveBits(params, await importKey(k), len * 8)); + } + + if (identifier == "https://identity.mozilla.com/apps/oldsync") { + const bits = await derive( + new Uint8Array(), + kB, + "identity.mozilla.com/picl/v1/oldsync", + 64); + const kHash = await crypto.subtle.digest("SHA-256", hexToUint8(kB)); + const kid = key_rotation_timestamp.toString() + "-" + + uint8ToBase64Url(new Uint8Array(kHash.slice(0, 16))); + return { + k: uint8ToBase64Url(bits), + kty: "oct", + kid, + scope: identifier, + }; + } else { + const bits = await derive( + hexToUint8(uid), + kB + key_rotation_secret, + "identity.mozilla.com/picl/v1/scoped_key\n" + identifier, + 16 + 32); + const fp = new Uint8Array(bits.slice(0, 16)); + const key = new Uint8Array(bits.slice(16)); + const kid = key_rotation_timestamp.toString() + "-" + uint8ToBase64Url(fp); + return { + k: uint8ToBase64Url(key), + kty: "oct", + kid, + scope: identifier, + }; + } +} + +export async function encryptScopedKeys(bundle, to, local_key, iv) { + let encode = s => new TextEncoder().encode(s); + + let peer_key = await crypto.subtle.importKey( + "jwk", + JSON.parse(urlatob(to)), + { name: "ECDH", namedCurve: "P-256" }, + false, + ['deriveKey']); + local_key = local_key || await crypto.subtle.generateKey( + { name: "ECDH", namedCurve: "P-256" }, + true, + ['deriveKey']); + iv = iv || new Uint8Array(await crypto.subtle.exportKey( + "raw", + await crypto.subtle.generateKey({ name: "AES-CBC", length: 128 }, true, ['encrypt']) + )).slice(0, 12); + + let key = await crypto.subtle.deriveKey( + { name: "ECDH", public: peer_key }, + local_key.privateKey, + { name: "AES-GCM", length: 256 }, + true, + ['encrypt']); + key = new Uint8Array(await crypto.subtle.exportKey("raw", key)); + key = await crypto.subtle.digest( + "SHA-256", + new Uint8Array([ + 0, 0, 0, 1, // rounds + ...key, + 0, 0, 0, 7, ...encode("A256GCM"), + 0, 0, 0, 0, // apu + 0, 0, 0, 0, // apv + 0, 0, 1, 0, // key size + ])); + key = await crypto.subtle.importKey("raw", key, { name: "AES-GCM" }, false, ['encrypt']); + + let exported_key = await crypto.subtle.exportKey("jwk", local_key.publicKey); + let header = { + "alg": "ECDH-ES", + "enc": "A256GCM", + "epk": { + crv: "P-256", + kty: "EC", + x: exported_key.x, + y: exported_key.y + } + }; + + let ciphered = await crypto.subtle.encrypt( + { name: "AES-GCM", iv, additionalData: encode(uint8ToBase64Url(encode(JSON.stringify(header)))) }, + key, + encode(JSON.stringify(bundle))); + let tag = ciphered.slice(-16); + ciphered = ciphered.slice(0, -16); + + return (uint8ToBase64Url(encode(JSON.stringify(header))) + + ".." + + uint8ToBase64Url(new Uint8Array(iv)) + + "." + + uint8ToBase64Url(new Uint8Array(ciphered)) + + "." + + uint8ToBase64Url(new Uint8Array(tag))); +} diff --git a/web/js/crypto.test.js b/web/js/crypto.test.js new file mode 100644 index 0000000..8e3247c --- /dev/null +++ b/web/js/crypto.test.js @@ -0,0 +1,49 @@ +async function testCrypto() { + let keys = await deriveScopedKey( + "8b2e1303e21eee06a945683b8d495b9bf079ca30baa37eb8392d9ffa4767be45", + "aeaa1725c7a24ff983c6295725d5fc9b", + "app_key:https%3A//example.com", + "517d478cb4f994aa69930416648a416fdaa1762c5abf401a2acf11a0f185e98d", + 1510726317); + if (keys.k != "Kkbk1_Q0oCcTmggeDH6880bQrxin2RLu5D00NcJazdQ") throw "assert"; + if (keys.kid != "1510726317-Voc-Eb9IpoTINuo9ll7bjA") throw "assert"; + if (keys.kty != "oct") throw "assert"; + + let keys_jwk = "eyJjcnYiOiJQLTI1NiIsImt0eSI6IkVDIiwieCI6IlNpQm42dWViamlnbVF" + + "xdzRUcE56czNBVXlDYWUxX3NHMmI5RnpocTNGeW8iLCJ5IjoicTk5WHExUld" + + "OVEZwazk5cGRRT1NqVXZ3RUxzczUxUGttQUdDWGhMZk1WNCJ9"; + let bundle = { + app_key: { + k: "Kkbk1_Q0oCcTmggeDH6880bQrxin2RLu5D00NcJazdQ", + kid: "1510726317-Voc-Eb9IpoTINuo9ll7bjA", + kty: "oct" + } + }; + let local_key = { + "crv": "P-256", + "kty": "EC", + "d": "X9tJG0Ue55tuepC-6msMg04Qv5gJtL95AIJ0X0gDj8Q", + "x": "N4zPRazB87vpeBgHzFvkvd_48owFYYxEVXRMrOU6LDo", + "y": "4ncUxN6x_xT1T1kzy_S_V2fYZ7uUJT_HVRNZBLJRsxU" + }; + let iv = new Uint8Array([0xff, 0x4b, 0x18, 0x7f, 0xb1, 0xdd, 0x5a, 0xe4, 0x6f, 0xd9, 0xc3, 0x34]); + let k = await crypto.subtle.importKey( + "jwk", + local_key, + { name: "ECDH", namedCurve: "P-256" }, + true, + ['deriveKey']); + console.log(await encryptScopedKeys( + bundle, + keys_jwk, + { publicKey: k, privateKey: k }, + iv)); + + console.log(await deriveScopedKey( + "eaf9570b7219a4187d3d6bf3cec2770c2e0719b7cc0dfbb38243d6f1881675e9", + "aeaa1725c7a24ff983c6295725d5fc9b", + "https://identity.mozilla.com/apps/oldsync", + "0000000000000000000000000000000000000000000000000000000000000000", + 1510726317123 + )); +} diff --git a/web/js/main.js b/web/js/main.js new file mode 100644 index 0000000..bffcdfb --- /dev/null +++ b/web/js/main.js @@ -0,0 +1,761 @@ +// https://mozilla.github.io/ecosystem-platform/reference/webchannels-in-firefox-desktop-fennec + +'use strict'; + +import AuthClient from './auth-client/browser'; +import { deriveScopedKey, encryptScopedKeys, urlatob } from './crypto'; + +class Channel { + constructor() { + this.waiting = {}; + this.idseq = 0; + + window.addEventListener( + 'WebChannelMessageToContent', + ev => { + if (ev.detail.message.error) { + for (const wait in this.waiting) { + this.waiting[wait].reject(new Error(ev.detail.message.error)); + } + this.waiting = {}; + } else { + let message = this.waiting[ev.detail.message.messageId]; + delete this.waiting[ev.detail.message.messageId]; + if (message) { + message.resolve(ev.detail.message.data); + } + } + }, + true + ); + } + + _send(id, command, data) { + const messageId = this.idseq++; + window.dispatchEvent( + new window.CustomEvent('WebChannelMessageToChrome', { + detail: JSON.stringify({ + id, + message: { command, data, messageId }, + }), + }) + ); + return messageId; + } + + _send_wait(id, command, data) { + return new Promise((resolve, reject) => { + let messageId = this._send(id, command, data); + this.waiting[messageId] = { resolve, reject }; + }); + } + + async getStatus(isPairing, service) { + return await channel._send_wait( + 'account_updates', + 'fxaccounts:fxa_status', + { isPairing, service }); + } + + loadCredentials(email, creds, engines) { + let services = undefined; + if (engines) { + services = { + sync: { + declinedEngines: engines.declined, + offeredEngines: engines.offered, + } + }; + } + this._send('account_updates', 'fxaccounts:login', { + customizeSync: !!engines, + services, + email, + keyFetchToken: creds.keyFetchToken, + sessionToken: creds.sessionToken, + uid: creds.uid, + unwrapBKey: creds.unwrapBKey, + verified: creds.verified || false, + verifiedCanLinkAccount: false + }); + } + + passwordChanged(email, uid) { + this._send('account_updates', 'fxaccounts:change_password', { + email, + uid, + verified: true, + }); + } + + accountDestroyed(email, uid) { + this._send('account_updates', 'fxaccounts:delete', { + email, + uid, + }); + } +} + +class ProfileClient { + constructor(authClient, session) { + this._authClient = authClient; + this._session = session; + } + + async _acquireToken(scope) { + // NOTE the api has destroy commands for tokens, the client doesn't + let token = await this._authClient.createOAuthToken( + this._session.signedInUser.sessionToken, + this._session.clientId, + { scope, ttl: 60 }); + return token.access_token; + } + + async _request(endpoint, token, options) { + options = options || {}; + options.mode = "same-origin"; + options.headers = new Headers({ + authorization: `bearer ${token}`, + ...(options.headers || {}), + }); + let req = new Request(`${client_config.profile_server_base_url}${endpoint}`, options); + let resp = await fetch(req); + if (!resp.ok) throw new Error(resp.statusText); + return await resp.json(); + } + + async getProfile() { + let token = await this._acquireToken("profile"); + return await this._request("/v1/profile", token); + } + + async setDisplayName(name) { + let token = await this._acquireToken("profile:display_name:write"); + return await this._request("/v1/display_name", token, { + method: "POST", + body: JSON.stringify({ displayName: name }), + }); + } + + async setAvatar(avatar) { + let token = await this._acquireToken("profile:avatar:write"); + return await this._request("/v1/avatar/upload", token, { + method: "POST", + body: avatar.slice(), + headers: { + "Content-Type": avatar.type, + }, + }); + } +} + +function $(id) { + return document.getElementById(id); +} + +function showMessage(message, className) { + let m = $("message-modal"); + m.className = className || ""; + $("message").innerText = message; + $("message-modal-close").hidden = true; + m.showModal(); +} + +function showError(prefix, e) { + console.log(e); + if (e instanceof Object && "errno" in e) + e = e.message; + showMessage(prefix + String(e), "error"); +} + +function showRecoverableError(prefix, e) { + if (e === undefined) { + e = prefix; + prefix = ""; + } + + let close = $("message-modal-close"); + close.onclick = ev => { + ev.preventDefault(); + hideMessage(); + }; + showError(prefix, e, "error"); + close.hidden = false; +} + +function hideMessage() { + $("message-modal").close(); +} + +function wrapHandler(fn) { + function failed(e) { + showRecoverableError("failed: ", e); + console.log(e); + } + + return function() { + try { + let val = fn.apply(this, arguments); + if (val instanceof Promise) val = val.catch(failed); + return val; + } catch (e) { + failed(e); + throw e; + } + }; +} + +function switchTo(id, tabID) { + for (const screen of document.getElementsByClassName("container")) { + screen.hidden = true; + } + $(id).hidden = false; + + if (tabID) { + for (const tab of $(id).getElementsByClassName("tab")) { + tab.hidden = true; + } + $(tabID).hidden = false; + } +} + +function dateDiffText(when) { + let diff = Math.round(((+new Date()) - (+when)) / 1000); + let finalize = diff < 0 ? (s => `in ${s}`) : (s => `${s} ago`); + diff = Math.abs(diff); + let s; + if (diff < 5) + return "now"; + else if (diff < 60) + s = `${diff} second`; + else if (diff < 60 * 60) + s = `${Math.round(diff / 60)} minute`; + else if (diff < 60 * 60 * 24) + s = `${Math.round(diff / 60 / 60)} hour`; + else if (diff < 60 * 60 * 24 * 31) + s = `${Math.round(diff / 60 / 60 / 24)} day`; + else + s = `${Math.round(diff / 60 / 60 / 24 / 31)} month`; + if (!/1 /.test(s)) + s += "s"; + return finalize(s); +} + +////////////////////////////////////////// +// initialization +////////////////////////////////////////// + +let client_config; +var channel = new Channel(); +const isAndroid = /Android/.test(navigator.userAgent); + +document.body.onload = () => { + showMessage("Loading ...", "animate"); + + if (isAndroid) { + document.head.appendChild($("tpl-fenix-style").content); + } + + fetch("/.well-known/fxa-client-configuration") + .then(resp => { + if (!resp.ok) throw new Error(resp.statusText); + return resp.json(); + }) + .then(data => { + client_config = data; + hideMessage(); + return initUI(); + }) + .catch(e => { + showError("initialization failed: ", e); + }); +}; + +async function initUI() { + return isAndroid + ? await initUIAndroid() + : await initUIDesktop(); +} + +async function initUIDesktop() { + let present = wrapHandler(async () => { + if (window.location.hash.startsWith("#/verify/")) { + verify_init(JSON.parse(urlatob(window.location.hash.substr(9)))); + } else if (window.location.hash.startsWith("#/register/")) { + signup_init(window.location.hash.substr(11)); + } else { + let data = await channel.getStatus(); + if (!data.signedInUser) { + signin_init(); + } else if (window.location.hash == "#/settings") { + settings_init(data); + } else if (window.location.hash == "#/settings/change-password") { + settings_chpw_init(data); + } else if (window.location.hash == "#/settings/destroy") { + settings_destroy_init(data); + } else if (window.location.hash == "#/force_auth") { + signin_init(); + } else if (window.location.hash == "#/generate-invite") { + generate_invite_init(data); + } else { + switchTo("desktop-signedin"); + } + } + hideMessage(); + }); + + window.onpopstate = () => window.setTimeout(present, 0); + + for (let a of $("desktop-settings").querySelectorAll("nav a")) { + a.onclick = async ev => { + ev.preventDefault(); + window.location = ev.target.href; + await present(); + }; + } + await present(); +} + +async function initUIAndroid() { + fenix_signin_init(); +} + +////////////////////////////////////////// +// signin form +////////////////////////////////////////// + +function signin_init() { + switchTo("desktop-signin"); + let frm = $("frm-signin"); + frm.onsubmit = wrapHandler(signin_run); + frm.getElementsByClassName("_signup")[0].onclick = wrapHandler(signin_signup_instead); + frm.getElementsByClassName("_reset")[0].onclick = wrapHandler(ev => { + ev.preventDefault(); + password_reset_init(); + }); +} + +function signin_signup_instead(ev) { + ev.preventDefault(); + signup_init(); +} + +// NOTE it looks like firefox discards its session token before asking for reauth +// (eg if oauth verification of a sync token fails). we can't use the reauth +// endpoint for that, so we'll just always log in. +async function signin_run(ev) { + ev.preventDefault(); + let frm = ev.target; + + if (frm[0].value == "" || !frm[0].validity.valid) { + return showRecoverableError("email is not valid"); + } + if (frm[1].value == "" || !frm[1].validity.valid) { + return showRecoverableError("password is not valid"); + } + + let c = new AuthClient(client_config.auth_server_base_url); + try { + let session = await c.signIn(frm["email"].value, frm["password"].value, { + keys: true, + }); + + signin_confirm_init(frm["email"].value, session); + } catch (e) { + if (e.errno == 104) { + showRecoverableError(` + account is not verified. please verify or wait a few minutes and register again. + `.trim()); + } else { + throw e; + } + } +} + +////////////////////////////////////////// +// sign-in confirmation +////////////////////////////////////////// + +function signin_confirm_init(email, session) { + let frm = $("frm-signin-confirm"); + frm.getElementsByClassName("_resend")[0].onclick = wrapHandler(async ev => { + ev.preventDefault(); + + let c = new AuthClient(client_config.auth_server_base_url); + await c.sessionResendVerifyCode(session.sessionToken); + alert("code resent!"); + }); + frm.onsubmit = wrapHandler(async ev => { + ev.preventDefault(); + + let c = new AuthClient(client_config.auth_server_base_url); + c.sessionVerifyCode(session.sessionToken, frm["code"].value) + .then(resp => { + channel.loadCredentials(email, session, { offered: [], declined: [] }); + switchTo("desktop-signedin"); + }) + .catch(e => { + showError("verification failed: ", e); + }); + }); + switchTo("desktop-signin-confirm"); +} + +////////////////////////////////////////// +// password reset +////////////////////////////////////////// + +function password_reset_init() { + let frm = $("frm-resetpw"); + frm.onsubmit = wrapHandler(async ev => { + ev.preventDefault(); + + let c = new AuthClient(client_config.auth_server_base_url); + let token = await c.passwordForgotSendCode(frm["email"].value); + let code = prompt(` + please enter your password reset code from the email you've just received + `.trim()); + if (code === null) { + return showRecoverableError("password reset aborted"); + } + + let reset_token = await c.passwordForgotVerifyCode(code, token.passwordForgotToken); + password_reset_newpw(frm['email'].value, reset_token.accountResetToken); + }); + switchTo("desktop-resetpw"); +} + +function password_reset_newpw(email, reset_token) { + let frm = $("frm-resetpw-newpw"); + frm.onsubmit = wrapHandler(async ev => { + ev.preventDefault(); + + let c = new AuthClient(client_config.auth_server_base_url); + if (frm['new'].value != frm['new-confirm'].value) { + return showRecoverableError("passwords don't match!"); + } + + await c.accountReset(email, frm['new'].value, reset_token); + switchTo("desktop-signin"); + }); + switchTo("desktop-resetpw-newpw"); +} + +////////////////////////////////////////// +// signup form +////////////////////////////////////////// + +function signup_init(code) { + let frm = $("frm-signup"); + switchTo("desktop-signup"); + frm.onsubmit = wrapHandler(async ev => signup_run(ev, code)); +} + +async function signup_run(ev, code) { + ev.preventDefault(); + let frm = ev.target; + + if (frm[0].value == "" || !frm[0].validity.valid) { + return showRecoverableError("email is not valid"); + } + if (frm[1].value == "" || !frm[1].validity.valid) { + return showRecoverableError("password is not valid"); + } + + let c = new AuthClient(client_config.auth_server_base_url); + let session = await c.signUp(frm["email"].value, frm["password"].value, { + keys: true, + style: code, + }); + + cwts_continue(frm["email"].value, session); +} + +////////////////////////////////////////// +// choose-what-to-sync form +////////////////////////////////////////// + +function cwts_continue(email, session) { + // TODO we don't query browser capabilities, but we probably should + let frm = $("frm-cwts"); + frm.onsubmit = wrapHandler((ev) => { + ev.preventDefault(); + + let offered = [], declined = []; + for (const engine of frm.querySelectorAll("input[type=checkbox]")) { + if (!engine.checked) declined.push(engine.name); + else offered.push(engine.name); + } + channel.loadCredentials(email, session, { offered, declined }); + + switchTo("desktop-signup-unverified"); + }); + switchTo("desktop-cwts"); +} + +////////////////////////////////////////// +// verification +////////////////////////////////////////// + +function verify_init(context) { + let c = new AuthClient(client_config.auth_server_base_url); + c.verifyCode(context.uid, context.code) + .then(resp => { + switchTo("desktop-signedin"); + }) + .catch(e => { + showError("verification failed: ", e); + }); +} + +////////////////////////////////////////// +// settings root +////////////////////////////////////////// + +function settings_init(session) { + switchTo("desktop-settings", "desktop-settings-main"); + + let inner = async () => { + showMessage("Loading ...", "animate"); + + let ac = new AuthClient(client_config.auth_server_base_url) + let pc = new ProfileClient(ac, session); + + let initProfile = async () => { + let profile = await pc.getProfile(); + $("settings-name").value = profile.displayName || ""; + $("frm-settings-name").onsubmit = wrapHandler(async ev => { + showMessage("Applying ...") + ev.preventDefault(); + await pc.setDisplayName($("settings-name").value); + hideMessage(); + }); + $("settings-avatar-img").src = profile.avatar; + $("frm-settings-avatar").onsubmit = wrapHandler(async ev => { + showMessage("Saving ...") + ev.preventDefault(); + await pc.setAvatar($("settings-avatar").files[0]); + settings_init(session); + }); + }; + + await Promise.all([ + initProfile(), + settings_populateClients(ac, session), + ]); + + hideMessage(); + }; + + inner().catch(e => { + showError("initialization failed: ", e); + }); +} + +async function settings_populateClients(authClient, session) { + let clients = await authClient.attachedClients(session.signedInUser.sessionToken); + + let body = $("settings-clients").getElementsByTagName("tbody")[0]; + body.innerHTML = ""; + for (const c of clients) { + let row = document.createElement("tr"); + let add = (val, tip) => { + let cell = document.createElement("td"); + cell.innerText = val || ""; + if (tip) cell.title = tip; + row.appendChild(cell); + }; + let addDateDiff = val => { + let text = dateDiffText(new Date(val)); + add(text, new Date(val)); + }; + add(c.name); + add(c.deviceType); + addDateDiff(c.createdTime * 1000); + addDateDiff(c.lastAccessTime * 1000); + add(c.scope ? "yes" : "", (c.scope ? c.scope : "").replace(/ +/g, "\n")); + if (c.isCurrentSession) { + let cell = document.createElement("td"); + cell.innerHTML = `current session`; + row.appendChild(cell); + } else if (c.deviceId || c.sessionTokenId || c.refreshTokenId) { + let remove = document.createElement("button"); + remove.innerText = 'remove'; + remove.onclick = wrapHandler(async ev => { + ev.preventDefault(); + let info = { clientId: c.clientId }; + if (c.deviceId) + info.deviceId = c.deviceId; + else if (c.sessionTokenId) + info.sessionTokenId = c.sessionTokenId; + else if (c.refreshTokenId) + info.refreshTokenId = c.refreshTokenId; + showMessage("Processing ...", "animate"); + await authClient.attachedClientDestroy(session.signedInUser.sessionToken, info); + await settings_populateClients(authClient, session); + hideMessage(); + }); + row.appendChild(remove); + } + body.appendChild(row); + } +} + +////////////////////////////////////////// +// settings change password +////////////////////////////////////////// + +function settings_chpw_init(session) { + switchTo("desktop-settings", "desktop-settings-chpw"); + let frm = $("frm-settings-chpw"); + frm.onsubmit = wrapHandler(async ev => { + ev.preventDefault(); + let frm = ev.target; + + if (frm["new"].value != frm["new-confirm"].value) { + showRecoverableError("passwords don't match"); + return; + } + + let c = new AuthClient(client_config.auth_server_base_url); + let resp = await c.passwordChange(session.signedInUser.email, frm['old'].value, frm['new'].value, {}); + + channel.passwordChanged(session.signedInUser.email, session.signedInUser.uid); + alert("password changed"); + }); +} + +////////////////////////////////////////// +// settings destroy +////////////////////////////////////////// + +function settings_destroy_init(session) { + switchTo("desktop-settings", "desktop-settings-destroy"); + let frm = $("frm-settings-destroy"); + frm.onsubmit = wrapHandler(async ev => { + ev.preventDefault(); + let frm = ev.target; + + if (frm[0].value == "" || !frm[0].validity.valid) { + return showRecoverableError("email is not valid"); + } + if (frm[1].value == "" || !frm[1].validity.valid) { + return showRecoverableError("password is not valid"); + } + + let c = new AuthClient(client_config.auth_server_base_url); + await c.accountDestroy(frm["email"].value, frm["password"].value); + + channel.accountDestroyed(frm["email"], session.signedInUser.uid); + switchTo("desktop-deleted"); + }); +} + +////////////////////////////////////////// +// generate invite +////////////////////////////////////////// + +function generate_invite_init(session) { + let frm = $("frm-generate-invite"); + $("desktop-generate-invite-result").hidden = true; + frm.onsubmit = wrapHandler(async ev => { + ev.preventDefault(); + + let server_url = new URL(client_config.auth_server_base_url); + server_url.pathname = server_url.pathname.split("/").slice(0, -1).join("/") + "/_invite"; + let c = new AuthClient(server_url.toString()); + let resp = await c.sessionPost( + "/generate", + session.signedInUser.sessionToken, + { 'ttl_hours': parseInt(frm["ttl"].value) }); + + $("desktop-generate-invite-result-link").href = resp.url; + $("desktop-generate-invite-result-link").innerText = resp.url; + $("desktop-generate-invite-result").hidden = false; + + }); + switchTo("desktop-generate-invite"); +} + +////////////////////////////////////////// +// fenix signin +////////////////////////////////////////// + +function fenix_signin_init() { + switchTo("fenix-signin-warning"); + $("fenix-signin-dialog-show").onclick = wrapHandler(async ev => { + ev.preventDefault(); + switchTo("fenix-signin"); + $("frm-fenix-signin").onsubmit = wrapHandler(fenix_signin_step2); + }); +} + +async function fenix_signin_step2(ev) { + ev.preventDefault(); + + let url = new URL(window.location); + let params = new URLSearchParams(url.search); + let param = (p) => { + let val = params.get(p); + if (val === undefined) throw `missing parameter ${p}`; + return val; + }; + + let frm = ev.target; + let c = new AuthClient(client_config.auth_server_base_url); + let session = await c.signIn(frm["email"].value, frm["password"].value, { + keys: true, + }); + let verifyCode = prompt("enter verify code"); + await c.sessionVerifyCode(session.sessionToken, verifyCode); + try { + let keys = await c.accountKeys(session.keyFetchToken, session.unwrapBKey); + let scoped_keys = await c.getOAuthScopedKeyData( + session.sessionToken, + param("client_id"), + param("scope")); + for (var scope in scoped_keys) { + scoped_keys[scope] = await deriveScopedKey( + keys.kB, + session.uid, + scope, + scoped_keys[scope].keyRotationSecret, + scoped_keys[scope].keyRotationTimestamp); + } + + let keys_jwe = await encryptScopedKeys(scoped_keys, param("keys_jwk")); + + let code = await c.createOAuthCode( + session.sessionToken, + param("client_id"), + param("state"), + { + access_type: param("access_type"), + keys_jwe, + response_type: param("response_type"), + scope: param("scope"), + code_challenge_method: param("code_challenge_method"), + code_challenge: param("code_challenge"), + }); + + console.log(`browser.tabs.executeScript({code: \` + port = browser.runtime.connectNative("mozacWebchannel"); + port.postMessage({ + id: "account_updates", + message: { + command: "fxaccounts:oauth_login", + data: { + "code": "${code.code}", + "state": "${code.state}", + "redirect": "urn:ietf:wg:oauth:2.0:oob:oauth-redirect-webchannel", + "action": "signin" + }, + messageId: 1, + }}); + \`});`); + + switchTo("fenix-signin-warning"); + } finally { + c.sessionDestroy(session.sessionToken); + } +} -- cgit v1.2.3