nostr-rs-relay

My dev fork of nostr-rs-relay
git clone git://jb55.com/nostr-rs-relay
Log | Files | Refs | README | LICENSE

commit 234a8ba0ac67d72d5024479be91fe0e05dfa8910
parent f679fa08936a3ce7e9c9be06c54ab59b356077d3
Author: Greg Heartsfield <scsibug@imap.cc>
Date:   Sat, 12 Feb 2022 09:29:25 -0600

feat: limit event publishing to NIP-05 verified users

This adds a new configurable feature to restrict event publishing to
only users with NIP-05 verified metadata.  Domains can be whitelisted
or blacklisted.  Verification expiration and schedules are
configurable.

This upgrades the database to add a table for tracking verification
records.

Diffstat:
MCargo.lock | 317+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------
MCargo.toml | 6+++++-
MDockerfile | 6+++---
Mconfig.toml | 35+++++++++++++++++++++++++++++++----
Adocs/user-verification-nip05.md | 248+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/config.rs | 78+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Msrc/db.rs | 225++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------
Msrc/error.rs | 33+++++++++++++++++++++++++++++++++
Msrc/event.rs | 32++++++++++++++++++++++++++++----
Msrc/info.rs | 2+-
Msrc/lib.rs | 1+
Msrc/main.rs | 104++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------
Asrc/nip05.rs | 812+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/protostream.rs | 6++++--
14 files changed, 1787 insertions(+), 118 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -143,6 +143,22 @@ dependencies = [ ] [[package]] +name = "core-foundation" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6888e10551bb93e424d8df1d07f1a8b4fceb0001a3a4b048bfc47554946f47b3" +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.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -153,9 +169,9 @@ dependencies = [ [[package]] name = "crossbeam-utils" -version = "0.8.6" +version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfcae03edb34f947e64acdb1c33ec169824e20657e9ecb61cef6c8c74dcb8120" +checksum = "b5e5bed1f1c269533fa816a0a5492b3545209a205ca1a54842be180eb63a16a6" dependencies = [ "cfg-if", "lazy_static", @@ -206,12 +222,36 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" [[package]] +name = "fastrand" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3fcf0cee53519c866c09b5de1f6c56ff9d647101f81c1964fa632e148896cdf" +dependencies = [ + "instant", +] + +[[package]] name = "fnv" version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" [[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] name = "form_urlencoded" version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -229,9 +269,9 @@ checksum = "a06f77d526c1a601b7c4cdd98f54b5eaabffc14d5f2f0296febdc7f357c6d3ba" [[package]] name = "futures" -version = "0.3.19" +version = "0.3.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28560757fe2bb34e79f907794bb6b22ae8b0e5c669b638a1132f2592b19035b4" +checksum = "f73fe65f54d1e12b726f517d3e2135ca3125a437b6d998caf1962961f7172d9e" dependencies = [ "futures-channel", "futures-core", @@ -244,9 +284,9 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.19" +version = "0.3.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba3dda0b6588335f360afc675d0564c17a77a2bda81ca178a4b6081bd86c7f0b" +checksum = "c3083ce4b914124575708913bca19bfe887522d6e2e6d0952943f5eac4a74010" dependencies = [ "futures-core", "futures-sink", @@ -254,15 +294,15 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.19" +version = "0.3.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0c8ff0461b82559810cdccfde3215c3f373807f5e5232b71479bff7bb2583d7" +checksum = "0c09fd04b7e4073ac7156a9539b57a484a8ea920f79c7c675d05d289ab6110d3" [[package]] name = "futures-executor" -version = "0.3.19" +version = "0.3.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29d6d2ff5bb10fb95c85b8ce46538a2e5f5e7fdc755623a7d4529ab8a4ed9d2a" +checksum = "9420b90cfa29e327d0429f19be13e7ddb68fa1cccb09d65e5706b8c7a749b8a6" dependencies = [ "futures-core", "futures-task", @@ -271,15 +311,15 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.19" +version = "0.3.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1f9d34af5a1aac6fb380f735fe510746c38067c5bf16c7fd250280503c971b2" +checksum = "fc4045962a5a5e935ee2fdedaa4e08284547402885ab326734432bed5d12966b" [[package]] name = "futures-macro" -version = "0.3.19" +version = "0.3.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6dbd947adfffb0efc70599b3ddcf7b5597bb5fa9e245eb99f62b3a5f7bb8bd3c" +checksum = "33c1e13800337f4d4d7a316bf45a567dbcb6ffe087f16424852d97e97a91f512" dependencies = [ "proc-macro2", "quote", @@ -288,15 +328,15 @@ dependencies = [ [[package]] name = "futures-sink" -version = "0.3.19" +version = "0.3.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3055baccb68d74ff6480350f8d6eb8fcfa3aa11bdc1a1ae3afdd0514617d508" +checksum = "21163e139fa306126e6eedaf49ecdb4588f939600f0b1e770f4205ee4b7fa868" [[package]] name = "futures-task" -version = "0.3.19" +version = "0.3.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ee7c6485c30167ce4dfb83ac568a849fe53274c831081476ee13e0dce1aad72" +checksum = "57c66a976bf5909d801bbef33416c41372779507e7a6b3a5e25e4749c58f776a" [[package]] name = "futures-timer" @@ -306,9 +346,9 @@ checksum = "e64b03909df88034c26dc1547e8970b91f98bdb65165d6a4e9110d94263dbb2c" [[package]] name = "futures-util" -version = "0.3.19" +version = "0.3.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9b5cf40b47a271f77a8b1bec03ca09044d99d2372c0de244e66430761127164" +checksum = "d8b7abd5d659d9b90c8cba917f6ec750a74e2dc23902ef9cd4cc8c8b22e6036a" dependencies = [ "futures-channel", "futures-core", @@ -477,6 +517,19 @@ dependencies = [ ] [[package]] +name = "hyper-tls" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6183ddfa99b85da61a140bea0efc93fdf56ceaa041b37d553518030827f9905" +dependencies = [ + "bytes", + "hyper", + "native-tls", + "tokio", + "tokio-native-tls", +] + +[[package]] name = "idna" version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -548,9 +601,9 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.114" +version = "0.2.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0005d08a8f7b65fb8073cb697aa0b12b631ed251ce73d862ce50eeb52ce3b50" +checksum = "e74d72e0f9b65b5b4ca49a346af3976df0f9c61d550727f349ecd559f251a26c" [[package]] name = "libsqlite3-sys" @@ -570,9 +623,9 @@ checksum = "7fb9b38af92608140b86b693604b9ffcc5824240a484d1ecd4795bacb2fe88f3" [[package]] name = "lock_api" -version = "0.4.5" +version = "0.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "712a4d093c9976e24e7dbca41db895dabcbac38eb5f4045393d17a95bdfb1109" +checksum = "88943dd7ef4a2e5a4bfa2753aaab3013e34ce2533d1996fb18ef591e315e2b3b" dependencies = [ "scopeguard", ] @@ -630,6 +683,24 @@ dependencies = [ ] [[package]] +name = "native-tls" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48ba9f7719b5a0f42f338907614285fb5fd70e53858141f69898a1fb7203b24d" +dependencies = [ + "lazy_static", + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + +[[package]] name = "no-std-compat" version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -663,12 +734,16 @@ dependencies = [ "futures-util", "governor", "hex", + "http", "hyper", + "hyper-tls", "lazy_static", "log", "nonzero_ext", + "parse_duration", "r2d2", "r2d2_sqlite", + "rand 0.8.4", "rusqlite", "secp256k1", "serde 1.0.136", @@ -690,6 +765,74 @@ dependencies = [ ] [[package]] +name = "num" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8536030f9fea7127f841b45bb6243b27255787fb4eb83958aa1ef9d2fdc0c36" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits 0.2.14", +] + +[[package]] +name = "num-bigint" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "090c7f9998ee0ff65aa5b723e4009f7b217707f1fb5ea551329cc4d6231fb304" +dependencies = [ + "autocfg 1.0.1", + "num-integer", + "num-traits 0.2.14", +] + +[[package]] +name = "num-complex" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6b19411a9719e753aff12e5187b74d60d3dc449ec3f4dc21e3989c3f554bc95" +dependencies = [ + "autocfg 1.0.1", + "num-traits 0.2.14", +] + +[[package]] +name = "num-integer" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2cc698a63b549a70bc047073d2949cce27cd1c7b0a4a862d08a8031bc2801db" +dependencies = [ + "autocfg 1.0.1", + "num-traits 0.2.14", +] + +[[package]] +name = "num-iter" +version = "0.1.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2021c8337a54d21aca0d59a92577a029af9431cb59b909b03252b9c164fad59" +dependencies = [ + "autocfg 1.0.1", + "num-integer", + "num-traits 0.2.14", +] + +[[package]] +name = "num-rational" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c000134b5dbf44adc5cb772486d335293351644b801551abe8f75c84cfa4aef" +dependencies = [ + "autocfg 1.0.1", + "num-bigint", + "num-integer", + "num-traits 0.2.14", +] + +[[package]] name = "num-traits" version = "0.1.43" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -730,6 +873,39 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "624a8340c38c1b80fd549087862da4ba43e08858af025b236e509b6649fc13d5" [[package]] +name = "openssl" +version = "0.10.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c7ae222234c30df141154f159066c5093ff73b63204dcda7121eb082fc56a95" +dependencies = [ + "bitflags", + "cfg-if", + "foreign-types", + "libc", + "once_cell", + "openssl-sys", +] + +[[package]] +name = "openssl-probe" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff011a302c396a5197692431fc1948019154afc178baf7d8e37367442a4601cf" + +[[package]] +name = "openssl-sys" +version = "0.9.72" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e46109c383602735fa0a2e48dd2b7c892b048e1bf69e5c3b1d804b7d9c203cb" +dependencies = [ + "autocfg 1.0.1", + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] name = "parking_lot" version = "0.11.2" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -755,6 +931,17 @@ dependencies = [ ] [[package]] +name = "parse_duration" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7037e5e93e0172a5a96874380bf73bc6ecef022e26fa25f2be26864d6b3ba95d" +dependencies = [ + "lazy_static", + "num", + "regex", +] + +[[package]] name = "percent-encoding" version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1030,6 +1217,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f497285884f3fcff424ffc933e56d7cbca511def0c9831a7f9b5f6153e3cc89b" [[package]] +name = "remove_dir_all" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acd125665422973a33ac9d3dd2df85edad0f4ae9b00dafb1a05e43a9f5ef8e7" +dependencies = [ + "winapi", +] + +[[package]] name = "rusqlite" version = "0.26.3" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1057,6 +1253,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "73b4b750c782965c211b42f022f59af1fbceabdd026623714f104152f1ec149f" [[package]] +name = "schannel" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f05ba609c234e60bee0d547fe94a4c7e9da733d1c962cf6e59efa4cd9c8bc75" +dependencies = [ + "lazy_static", + "winapi", +] + +[[package]] name = "scheduled-thread-pool" version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1091,6 +1297,29 @@ dependencies = [ ] [[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 = "0.8.23" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1176,9 +1405,9 @@ checksum = "f2dd574626839106c320a323308629dcb1acfc96e32a8cba364ddc61ac23ee83" [[package]] name = "socket2" -version = "0.4.3" +version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f82496b90c36d70af5fcd482edaa2e0bd16fade569de1330405fecbbdac736b" +checksum = "66d72b759436ae32898a2af0a14218dbf55efde3feeb170eb623637db85ee1e0" dependencies = [ "libc", "winapi", @@ -1202,6 +1431,20 @@ dependencies = [ ] [[package]] +name = "tempfile" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cdb1ef4eaeeaddc8fbd371e5017057064af0911902ef36b39801f67cc6d79e4" +dependencies = [ + "cfg-if", + "fastrand", + "libc", + "redox_syscall", + "remove_dir_all", + "winapi", +] + +[[package]] name = "termcolor" version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1247,9 +1490,9 @@ checksum = "cda74da7e1a664f795bb1f8a87ec406fb89a02522cf6e50620d016add6dbbf5c" [[package]] name = "tokio" -version = "1.15.0" +version = "1.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fbbf1c778ec206785635ce8ad57fe52b3009ae9e0c9f574a728f3049d3e55838" +checksum = "0c27a64b625de6d309e8c57716ba93021dccf1b3b5c97edd6d3dd2d2135afc0a" dependencies = [ "bytes", "libc", @@ -1276,6 +1519,16 @@ dependencies = [ ] [[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-tungstenite" version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1318,9 +1571,9 @@ checksum = "360dfd1d6d30e05fda32ace2c8c70e9c0a9da713275777f5a4dbb8a1893930c6" [[package]] name = "tracing" -version = "0.1.29" +version = "0.1.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "375a639232caf30edfc78e8d89b2d4c375515393e7af7e16f01cd96917fb2105" +checksum = "2d8d93354fe2a8e50d5953f5ae2e47a3fc2ef03292e7ea46e3cc38f549525fb9" dependencies = [ "cfg-if", "pin-project-lite", @@ -1329,9 +1582,9 @@ dependencies = [ [[package]] name = "tracing-core" -version = "0.1.21" +version = "0.1.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f4ed65637b8390770814083d20756f87bfa2c21bf2f110babdc5438351746e4" +checksum = "03cfcb51380632a72d3111cb8d3447a8d908e577d31beeac006f836383d29a23" dependencies = [ "lazy_static", ] diff --git a/Cargo.toml b/Cargo.toml @@ -25,4 +25,8 @@ r2d2_sqlite = "^0.19" lazy_static = "^1.4" governor = "^0.4" nonzero_ext = "^0.3" -hyper={ version="0.14", features=["server","http1","http2","tcp"] } +hyper = { version="0.14", features=["client", "server","http1","http2","tcp"] } +hyper-tls = "^0.5" +http = { version = "^0.2" } +parse_duration = "^2" +rand = "^0.8" diff --git a/Dockerfile b/Dockerfile @@ -1,4 +1,4 @@ -FROM rust:1.57 as builder +FROM rust:1.58.1 as builder RUN USER=root cargo new --bin nostr-rs-relay WORKDIR ./nostr-rs-relay @@ -12,11 +12,11 @@ COPY ./src ./src RUN rm ./target/release/deps/nostr*relay* RUN cargo build --release -FROM debian:buster-slim +FROM debian:bullseye-20220125-slim ARG APP=/usr/src/app ARG APP_DATA=/usr/src/app/db RUN apt-get update \ - && apt-get install -y ca-certificates tzdata sqlite3 \ + && apt-get install -y ca-certificates tzdata sqlite3 libc6 \ && rm -rf /var/lib/apt/lists/* EXPOSE 8080 diff --git a/config.toml b/config.toml @@ -22,7 +22,7 @@ description = "A newly created nostr-rs-relay.\n\nCustomize this with your own i # line option. data_directory = "." -# Database connection pool settings: +# Database connection pool settings for subscribers: # Minimum number of SQLite reader connections #min_conn = 4 @@ -59,8 +59,8 @@ reject_future_seconds = 1800 #max_ws_frame_bytes = 131072 # Broadcast buffer size, in number of events. This prevents slow -# readers from consuming memory. Defaults to 4096. -#broadcast_buffer = 4096 +# readers from consuming memory. +#broadcast_buffer = 16384 # Event persistence buffer size, in number of events. This provides # backpressure to senders if writes are slow. Defaults to 16. @@ -68,8 +68,35 @@ reject_future_seconds = 1800 [authorization] # Pubkey addresses in this array are whitelisted for event publishing. -# Only valid events by these authors will be accepted. +# Only valid events by these authors will be accepted, if the variable +# is set. #pubkey_whitelist = [ # "35d26e4690cbe1a898af61cc3515661eb5fa763b57bd0b42e45099c8b32fd50f", # "887645fef0ce0c3c1218d2f5d8e6132a19304cdc57cd20281d082f38cfea0072", #] + +[verified_users] +# NIP-05 verification of users. Can be "enabled" to require NIP-05 +# metadata for event authors, "passive" to perform validation but +# never block publishing, or "disabled" to do nothing. +#mode = "disabled" + +# Domain names that will be prevented from publishing events. +#domain_blacklist = ["wellorder.net"] + +# Domain names that are allowed to publish events. If defined, only +# events NIP-05 verified authors at these domains are persisted. +#domain_whitelist = ["example.com"] + +# Consider an pubkey "verified" if we have a successful validation +# from the NIP-05 domain within this amount of time. Note, if the +# domain provides a successful response that omits the account, +# verification is immediately revoked. +#verify_expiration = "1 week" + +# How long to wait between verification attempts for a specific author. +#verify_update_frequency = "24 hours" + +# How many consecutive failed checks before we give up on verifying +# this author. +#max_consecutive_failures = 20 diff --git a/docs/user-verification-nip05.md b/docs/user-verification-nip05.md @@ -0,0 +1,248 @@ +# Author Verification Design Document + +The relay will use NIP-05 DNS-based author verification to limit which +authors can publish events to a relay. This document describes how +this feature will operate. + +## Considerations + +DNS-based author verification is designed to be deployed in relays that +want to prevent spam, so there should be strong protections to prevent +unauthorized authors from persisting data. This includes data needed to +verify new authors. + +There should be protections in place to ensure the relay cannot be +used to spam or flood other webservers. Additionally, there should be +protections against server-side request forgery (SSRF). + +## Design Overview + +### Concepts + +All authors are initially "unverified". Unverified authors that submit +appropriate `NIP-05` metadata events become "candidates" for +verification. A candidate author becomes verified when the relay +inspects a kind `0` metadata event for the author with a `nip05` field, +and follows the procedure in `NIP-05` to successfully associate the +author with an internet identifier. + +The `NIP-05` procedure verifies an author for a fixed period of time, +configurable by the relay operator. If this "verification expiration +time" (`verify_expiration`) is exceeded without being refreshed, they +are once again unverified. + +Verified authors have their status regularly and automatically updated +through scheduled polling to their verified domain, this process is +"re-verification". It is performed based on the configuration setting +`verify_update_frequency`, which defines how long the relay waits +between verification attempts (whether the result was success or +failure). + +Authors may change their verification data (the internet identifier from +`NIP-05`) with a new metadata event, which then requires +re-verification. Their old verification remains valid until +expiration. + +Performing candidate author verification is a best-effort activity and +may be significantly rate-limited to prevent relays being used to +attack other hosts. Candidate verification (untrusted authors) should +never impact re-verification (trusted authors). + +## Operating Modes + +The relay may operate in one of three modes. "Disabled" performs no +validation activities, and will never permit or deny events based on +an author's NIP-05 metadata. "Passive" performs NIP-05 validation, +but does not permit or deny events based on the validity or presence +of NIP-05 metadata. "Enabled" will require current and valid NIP-05 +metadata for any events to be persisted. "Enabled" mode will +additionally consider domain whitelist/blacklist configuration data to +restrict which author's events are persisted. + +## Design Details + +### Data Storage + +Verification is stored in a dedicated table. This tracks: + +* `nip05` identifier +* most recent verification timestamp +* most recent verification failure timestamp +* reference to the metadata event (used for tracking `created_at` and + `pubkey`) + +### Event Handling + +All events are first validated to ensure the signature is valid. + +Incoming events of kind _other_ than metadata (kind `0`) submitted by +clients will be evaluated as follows. + +* If the event's author has a current verification, the event is + persisted as normal. +* If the event's author has either no verification, or the + verification is expired, the event is rejected. + +If the event is a metadata event, we handle it differently. + +We first determine the verification status of the event's pubkey. + +* If the event author is unverified, AND the event contains a `nip05` + key, we consider this a verification candidate. +* If the event author is unverified, AND the event does not contain a + `nip05` key, this is not a candidate, and the event is dropped. + +* If the event author is verified, AND the event contains a `nip05` + key that is identical to the currently stored value, no special + action is needed. +* If the event author is verified, AND the event contains a different + `nip05` than was previously verified, with a more recent timestamp, + we need to re-verify. +* If the event author is verified, AND the event is missing a `nip05` + key, and the event timestamp is more recent than what was verified, + we do nothing. The current verification will be allowed to expire. + +### Candidate Verification + +When a candidate verification is requested, a rate limit will be +utilized. If the rate limit is exceeded, new candidate verification +requests will be dropped. In practice, this is implemented by a +size-limited channel that drops events that exceed a threshold. + +Candidates are never persisted in the database. + +### Re-Verification + +Re-verification is straightforward when there has been no change to +the `nip05` key. A new request to the `nip05` domain is performed, +and if successful, the verification timestamp is updated to the +current time. If the request fails due to a timeout or server error, +the failure timestamp is updated instead. + +When the the `nip05` key has changed and this event is more recent, we +will create a new verification record, and delete all other records +for the same name. + +Regarding creating new records vs. updating: We never update the event +reference or `nip05` identifier in a verification record. Every update +either reset the last failure or last success timestamp. + +### Determining Verification Status + +In determining if an event is from a verified author, the following +procedure should be used: + +Join the verification table with the event table, to provide +verification data alongside the event `created_at` and `pubkey` +metadata. Find the most recent verification record for the author, +based on the `created_at` time. + +Reject the record if the success timestamp is not within our +configured expiration time. + +Reject records with disallowed domains, based on any whitelists or +blacklists in effect. + +If a result remains, the author is treated as verified. + +This does give a time window for authors transitioning their verified +status between domains. There may be a period of time in which there +are multiple valid rows in the verification table for a given author. + +### Cleaning Up Inactive Verifications + +After a author verification has expired, we will continue to check for +it to become valid again. After a configurable number of attempts, we +should simply forget it, and reclaim the space. + +### Addition of Domain Whitelist/Blacklist + +A set of whitelisted or blacklisted domains may be provided. If both +are provided, only the whitelist is used. In this context, domains +are either "allowed" (present on a whitelist and NOT present on a +blacklist), or "denied" (NOT present on a whitelist and present on a +blacklist). + +The processes outlined so far are modified in the presence of these +options: + +* Only authors with allowed domains can become candidates for + verification. +* Verification status queries additionally filter out any denied + domains. +* Re-verification processes only proceed with allowed domains. + +### Integration + +We have an existing database writer thread, which receives events and +attempts to persist them to disk. Once validated and persisted, these +events are broadcast to all subscribers. + +When verification is enabled, the writer must check to ensure a valid, +unexpired verification record exists for the auther. All metadata +events (regardless of verification status) are forwarded to a verifier +module. If the verifier determines a new verification record is +needed, it is also responsible for persisting and broadcasting the +event, just as the database writer would have done. + +## Threat Scenarios + +Some of these mitigations are fully implemented, others are documented +simply to demonstrate a mitigation is possible. + +### Domain Spamming + +*Threat*: A author with a high-volume of events creates a metadata event +with a bogus domain, causing the relay to generate significant +unwanted traffic to a target. + +*Mitigation*: Rate limiting for all candidate verification will limit +external requests to a reasonable amount. Currently, this is a simple +delay that slows down the HTTP task. + +### Denial of Service for Legitimate Authors + +*Threat*: A author with a high-volume of events creates a metadata event +with a domain that is invalid for them, _but which is used by other +legitimate authors_. This triggers rate-limiting against the legitimate +domain, and blocks authors from updating their own metadata. + +*Mitigation*: Rate limiting should only apply to candidates, so any +existing verified authors have priority for re-verification. New +authors will be affected, as we can not distinguish between the threat +and a legitimate author. _(Unimplemented)_ + +### Denial of Service by Consuming Storage + +*Threat*: A author creates a high volume of random metadata events with +unique domains, in order to cause us to store large amounts of data +for to-be-verified authors. + +*Mitigation*: No data is stored for candidate authors. This makes it +harder for new authors to become verified, but is effective at +preventing this attack. + +### Metadata Replay for Verified Author + +*Threat*: Attacker replays out-of-date metadata event for a author, to +cause a verification to fail. + +*Mitigation*: New metadata events have their signed timestamp compared +against the signed timestamp of the event that has most recently +verified them. If the metadata event is older, it is discarded. + +### Server-Side Request Forgery via Metadata + +*Threat*: Attacker includes malicious data in the `nip05` event, which +is used to generate HTTP requests against potentially internal +resources. Either leaking data, or invoking webservices beyond their +own privileges. + +*Mitigation*: Consider detecting and dropping when the `nip05` field +is an IP address. Allow the relay operator to utilize the `blacklist` +or `whitelist` to constrain hosts that will be contacted. Most +importantly, the verification process is hardcoded to only make +requests to a known url path +(`.well-known/nostr.json?name=<LOCAL_NAME>`). The `<LOCAL_NAME>` +component is restricted to a basic ASCII subset (preventing additional +URL components). diff --git a/src/config.rs b/src/config.rs @@ -2,6 +2,7 @@ use lazy_static::lazy_static; use log::*; use serde::{Deserialize, Serialize}; use std::sync::RwLock; +use std::time::Duration; // initialize a singleton default configuration lazy_static! { @@ -67,6 +68,60 @@ pub struct Authorization { pub pubkey_whitelist: Option<Vec<String>>, // If present, only allow these pubkeys to publish events } +#[derive(Serialize, Deserialize, PartialEq, Debug, Clone)] +#[serde(rename_all = "lowercase")] +pub enum VerifiedUsersMode { + Enabled, + Passive, + Disabled, +} + +#[derive(Debug, Serialize, Deserialize)] +#[allow(unused)] +pub struct VerifiedUsers { + pub mode: VerifiedUsersMode, // Mode of operation: "enabled" (enforce) or "passive" (check only). If none, this is simply disabled. + pub domain_whitelist: Option<Vec<String>>, // If present, only allow verified users from these domains can publish events + pub domain_blacklist: Option<Vec<String>>, // If present, allow all verified users from any domain except these + pub verify_expiration: Option<String>, // how long a verification is cached for before no longer being used + pub verify_update_frequency: Option<String>, // how often to attempt to update verification + pub verify_expiration_duration: Option<Duration>, // internal result of parsing verify_expiration + pub verify_update_frequency_duration: Option<Duration>, // internal result of parsing verify_update_frequency + pub max_consecutive_failures: usize, // maximum number of verification failures in a row, before ceasing future checks +} + +impl VerifiedUsers { + pub fn init(&mut self) { + self.verify_expiration_duration = self.verify_expiration_duration(); + self.verify_update_frequency_duration = self.verify_update_duration(); + } + + pub fn is_enabled(&self) -> bool { + self.mode == VerifiedUsersMode::Enabled + } + + pub fn is_active(&self) -> bool { + self.mode == VerifiedUsersMode::Enabled || self.mode == VerifiedUsersMode::Passive + } + + pub fn is_passive(&self) -> bool { + self.mode == VerifiedUsersMode::Passive + } + + pub fn verify_expiration_duration(&self) -> Option<Duration> { + self.verify_expiration + .as_ref() + .and_then(|x| parse_duration::parse(x).ok()) + } + pub fn verify_update_duration(&self) -> Option<Duration> { + self.verify_update_frequency + .as_ref() + .and_then(|x| parse_duration::parse(x).ok()) + } + pub fn is_valid(&self) -> bool { + self.verify_expiration_duration().is_some() && self.verify_update_duration().is_some() + } +} + #[derive(Debug, Serialize, Deserialize)] #[allow(unused)] pub struct Settings { @@ -75,6 +130,7 @@ pub struct Settings { pub network: Network, pub limits: Limits, pub authorization: Authorization, + pub verified_users: VerifiedUsers, pub retention: Retention, pub options: Options, } @@ -96,7 +152,7 @@ impl Settings { fn new_from_default(default: &Settings) -> Result<Self, config::ConfigError> { let config: config::Config = config::Config::new(); - let settings: Settings = config + let mut settings: Settings = config // use defaults .with_merged(config::Config::try_from(default).unwrap())? // override with file contents @@ -109,6 +165,12 @@ impl Settings { settings.database.min_conn, settings.database.max_conn ); } + // ensure durations parse + if !settings.verified_users.is_valid() { + panic!("VerifiedUsers time settings could not be parsed"); + } + // initialize durations for verified users + settings.verified_users.init(); Ok(settings) } } @@ -137,12 +199,22 @@ impl Default for Settings { max_event_bytes: Some(2 << 17), // 128K max_ws_message_bytes: Some(2 << 17), // 128K max_ws_frame_bytes: Some(2 << 17), // 128K - broadcast_buffer: 4096, - event_persist_buffer: 16, + broadcast_buffer: 16384, + event_persist_buffer: 4096, }, authorization: Authorization { pubkey_whitelist: None, // Allow any address to publish }, + verified_users: VerifiedUsers { + mode: VerifiedUsersMode::Disabled, + domain_whitelist: None, + domain_blacklist: None, + verify_expiration: Some("1 week".to_owned()), + verify_update_frequency: Some("1 day".to_owned()), + verify_expiration_duration: None, + verify_update_frequency_duration: None, + max_consecutive_failures: 20, + }, retention: Retention { max_events: None, // max events max_bytes: None, // max size diff --git a/src/db.rs b/src/db.rs @@ -1,21 +1,21 @@ //! Event persistence and querying -use crate::config; +use crate::config::SETTINGS; +use crate::error::Error; use crate::error::Result; use crate::event::Event; +use crate::nip05; use crate::subscription::Subscription; use governor::clock::Clock; use governor::{Quota, RateLimiter}; use hex; use log::*; -use rusqlite::params; -use rusqlite::Connection; -use rusqlite::OpenFlags; -//use std::num::NonZeroU32; -use crate::config::SETTINGS; use r2d2; use r2d2_sqlite::SqliteConnectionManager; use rusqlite::limits::Limit; +use rusqlite::params; use rusqlite::types::ToSql; +use rusqlite::Connection; +use rusqlite::OpenFlags; use std::path::Path; use std::thread; use std::time::Duration; @@ -23,9 +23,9 @@ use std::time::Instant; use tokio::task; pub type SqlitePool = r2d2::Pool<r2d2_sqlite::SqliteConnectionManager>; - +pub type PooledConnection = r2d2::PooledConnection<r2d2_sqlite::SqliteConnectionManager>; /// Database file -const DB_FILE: &str = "nostr.db"; +pub const DB_FILE: &str = "nostr.db"; /// Startup DB Pragmas const STARTUP_SQL: &str = r##" @@ -42,7 +42,7 @@ PRAGMA journal_mode=WAL; PRAGMA main.synchronous=NORMAL; PRAGMA foreign_keys = ON; PRAGMA application_id = 1654008667; -PRAGMA user_version = 3; +PRAGMA user_version = 4; -- Event Table CREATE TABLE IF NOT EXISTS event ( @@ -98,37 +98,79 @@ FOREIGN KEY(event_id) REFERENCES event(id) ON UPDATE RESTRICT ON DELETE CASCADE -- Pubkey References Index CREATE INDEX IF NOT EXISTS pubkey_ref_index ON pubkey_ref(referenced_pubkey); + +-- NIP-05 User Validation. +-- This represents the validation of a user. +-- cases; +-- we query, and find a valid result. update verified_at, and proceed. +-- we query, and get a 404/503/host down. update failed_at, and we are done. +-- we query, and get a 200, but the local part is not present with the given address. wipe out verified_at, update failed_at. +-- we need to know how often to query failing validations. +-- two cases, either we get a NIP-05 metadata event regularly that we can use to restart validation. +-- or, we simply get lots of non-metadata events, but the user fixed their NIP-05 host. +-- what should trigger a new attempt? what should trigger cleaning? +-- we will never write anything to the table if it is not valid at least once. +-- we will keep trying at frequency X to re-validate the already-valid nip05s. + +-- incoming metadata events with nip05 +CREATE TABLE IF NOT EXISTS user_verification ( +id INTEGER PRIMARY KEY, +metadata_event INTEGER NOT NULL, -- the metadata event used for this validation. +name TEXT NOT NULL, -- the nip05 field value (user@domain). +verified_at INTEGER, -- timestamp this author/nip05 was most recently verified. +failed_at INTEGER, -- timestamp a verification attempt failed (host down). +failure_count INTEGER DEFAULT 0, -- number of consecutive failures. +FOREIGN KEY(metadata_event) REFERENCES event(id) ON UPDATE CASCADE ON DELETE CASCADE +); "##; -pub fn build_read_pool() -> SqlitePool { - let config = config::SETTINGS.read().unwrap(); - let db_dir = &config.database.data_directory; +// TODO: drop the pubkey_ref and event_ref tables + +pub fn build_pool( + name: &str, + flags: OpenFlags, + min_size: u32, + max_size: u32, + wait_for_db: bool, +) -> SqlitePool { + let settings = SETTINGS.read().unwrap(); + + let db_dir = &settings.database.data_directory; let full_path = Path::new(db_dir).join(DB_FILE); // small hack; if the database doesn't exist yet, that means the // writer thread hasn't finished. Give it a chance to work. This // is only an issue with the first time we run. - while !full_path.exists() { + while !full_path.exists() && wait_for_db { debug!("Database reader pool is waiting on the database to be created..."); thread::sleep(Duration::from_millis(500)); } let manager = SqliteConnectionManager::file(&full_path) - .with_flags(OpenFlags::SQLITE_OPEN_READ_ONLY) + .with_flags(flags) .with_init(|c| c.execute_batch(STARTUP_SQL)); let pool: SqlitePool = r2d2::Pool::builder() .test_on_check_out(true) // no noticeable performance hit - .min_idle(Some(config.database.min_conn)) - .max_size(config.database.max_conn) + .min_idle(Some(min_size)) + .max_size(max_size) .build(manager) .unwrap(); info!( - "Built a connection pool (min={}, max={})", - config.database.min_conn, config.database.max_conn + "Built a connection pool {:?} (min={}, max={})", + name, min_size, max_size ); - return pool; + pool +} + +/// Build a single database connection, with provided flags +pub fn build_conn(flags: OpenFlags) -> Result<Connection> { + let settings = SETTINGS.read().unwrap(); + let db_dir = &settings.database.data_directory; + let full_path = Path::new(db_dir).join(DB_FILE); + // create a connection + Ok(Connection::open_with_flags(&full_path, flags)?) } /// Upgrade DB to latest version, and execute pragma settings -pub fn upgrade_db(conn: &mut Connection) -> Result<()> { +pub fn upgrade_db(conn: &mut PooledConnection) -> Result<()> { // check the version. let mut curr_version = db_version(conn)?; info!("DB version = {:?}", curr_version); @@ -150,8 +192,7 @@ pub fn upgrade_db(conn: &mut Connection) -> Result<()> { if curr_version == 0 { match conn.execute_batch(INIT_SQL) { Ok(()) => { - info!("database pragma/schema initialized to v3, and ready"); - //curr_version = 3; + info!("database pragma/schema initialized to v4, and ready"); } Err(err) => { error!("update failed: {}", err); @@ -189,15 +230,13 @@ value TEXT, -- the tag value, if not hex. value_hex BLOB, -- the tag value, if it can be interpreted as a hex string. FOREIGN KEY(event_id) REFERENCES event(id) ON UPDATE CASCADE ON DELETE CASCADE ); -CREATE INDEX IF NOT EXISTS tag_val_index ON tag(value); -CREATE INDEX IF NOT EXISTS tag_val_hex_index ON tag(value_hex); PRAGMA user_version = 3; "##; // TODO: load existing refs into tag table match conn.execute_batch(upgrade_sql) { Ok(()) => { info!("database schema upgraded v2 -> v3"); - //curr_version = 3; + curr_version = 3; } Err(err) => { error!("update failed: {}", err); @@ -226,14 +265,43 @@ PRAGMA user_version = 3; } tx.commit()?; info!("Upgrade complete"); - } else if curr_version == 3 { + } + if curr_version == 3 { + debug!("database schema needs update from 3->4"); + let upgrade_sql = r##" +-- incoming metadata events with nip05 +CREATE TABLE IF NOT EXISTS user_verification ( +id INTEGER PRIMARY KEY, +metadata_event INTEGER NOT NULL, -- the metadata event used for this validation. +name TEXT NOT NULL, -- the nip05 field value (user@domain). +verified_at INTEGER, -- timestamp this author/nip05 was most recently verified. +failed_at INTEGER, -- timestamp a verification attempt failed (host down). +failure_count INTEGER DEFAULT 0, -- number of consecutive failures. +FOREIGN KEY(metadata_event) REFERENCES event(id) ON UPDATE CASCADE ON DELETE CASCADE +); +CREATE INDEX IF NOT EXISTS user_verification_author_index ON user_verification(author); +CREATE INDEX IF NOT EXISTS user_verification_author_index ON user_verification(author); +PRAGMA user_version = 4; +"##; + // TODO: load existing refs into tag table + match conn.execute_batch(upgrade_sql) { + Ok(()) => { + info!("database schema upgraded v3 -> v4"); + //curr_version = 4; + } + Err(err) => { + error!("update failed: {}", err); + panic!("database could not be upgraded"); + } + } + } else if curr_version == 4 { debug!("Database version was already current"); } else if curr_version > 3 { panic!("Database version is newer than supported by this executable"); } // Setup PRAGMA conn.execute_batch(STARTUP_SQL)?; - info!("Finished pragma"); + debug!("SQLite PRAGMA startup completed"); Ok(()) } @@ -241,26 +309,37 @@ PRAGMA user_version = 3; pub async fn db_writer( mut event_rx: tokio::sync::mpsc::Receiver<Event>, bcast_tx: tokio::sync::broadcast::Sender<Event>, + metadata_tx: tokio::sync::broadcast::Sender<Event>, mut shutdown: tokio::sync::broadcast::Receiver<()>, ) -> tokio::task::JoinHandle<Result<()>> { + let settings = SETTINGS.read().unwrap(); + + // are we performing NIP-05 checking? + let nip05_active = settings.verified_users.is_active(); + // are we requriing NIP-05 user verification? + let nip05_enabled = settings.verified_users.is_enabled(); + task::spawn_blocking(move || { // get database configuration settings - let config = SETTINGS.read().unwrap(); - let db_dir = &config.database.data_directory; + let settings = SETTINGS.read().unwrap(); + let db_dir = &settings.database.data_directory; let full_path = Path::new(db_dir).join(DB_FILE); - // create a connection - let mut conn = Connection::open_with_flags( - &full_path, + // create a connection pool + let pool = build_pool( + "event writer", OpenFlags::SQLITE_OPEN_READ_WRITE | OpenFlags::SQLITE_OPEN_CREATE, - )?; + 1, + 4, + false, + ); info!("opened database {:?} for writing", full_path); - upgrade_db(&mut conn)?; + upgrade_db(&mut pool.get()?)?; // Make a copy of the whitelist - let whitelist = &config.authorization.pubkey_whitelist.clone(); + let whitelist = &settings.authorization.pubkey_whitelist.clone(); // get rate limit settings - let rps_setting = config.limits.messages_per_sec; + let rps_setting = settings.limits.messages_per_sec; let mut most_recent_rate_limit = Instant::now(); let mut lim_opt = None; let clock = governor::clock::QuantaClock::default(); @@ -287,7 +366,7 @@ pub async fn db_writer( // check if this event is authorized. if let Some(allowed_addrs) = whitelist { - debug!("Checking against whitelist"); + debug!("Checking against pubkey whitelist"); // if the event address is not in allowed_addrs. if !allowed_addrs.contains(&event.pubkey) { info!( @@ -299,15 +378,57 @@ pub async fn db_writer( } } + // send any metadata events to the NIP-05 verifier + if nip05_active && event.is_kind_metadata() { + // we are sending this prior to even deciding if we + // persist it. this allows the nip05 module to + // inspect it, update if necessary, or persist a new + // event and broadcast it itself. + metadata_tx.send(event.clone()).ok(); + } + + // check for NIP-05 verification + if nip05_enabled { + match nip05::query_latest_user_verification(pool.get()?, event.pubkey.to_owned()) { + Ok(uv) => { + if uv.is_valid() { + info!( + "new event from verified author ({:?},{:?})", + uv.name.to_string(), + event.get_author_prefix() + ); + } else { + info!("rejecting event, author ({:?} / {:?}) verification invalid (expired/wrong domain)", + uv.name.to_string(), + event.get_author_prefix() + ); + continue; + } + } + Err(Error::SqlError(rusqlite::Error::QueryReturnedNoRows)) => { + debug!( + "no verification records found for pubkey: {:?}", + event.get_author_prefix() + ); + continue; + } + Err(e) => { + warn!("checking nip05 verification status failed: {:?}", e); + continue; + } + } + } + // TODO: cache recent list of authors to remove a DB call. let start = Instant::now(); - match write_event(&mut conn, &event) { + match write_event(&mut pool.get()?, &event) { Ok(updated) => { if updated == 0 { - debug!("ignoring duplicate event"); + trace!("ignoring duplicate event"); } else { info!( - "persisted event: {} in {:?}", + "persisted event {:?} from {:?} in {:?}", event.get_event_id_prefix(), + event.get_author_prefix(), start.elapsed() ); event_write = true; @@ -316,9 +437,10 @@ pub async fn db_writer( } } Err(err) => { - warn!("event insert failed: {}", err); + warn!("event insert failed: {:?}", err); } } + // use rate limit, if defined, and if an event was actually written. if event_write { if let Some(ref lim) = lim_opt { @@ -327,9 +449,9 @@ pub async fn db_writer( // check if we have recently logged rate // limits, but print out a message only once // per second. - if most_recent_rate_limit.elapsed().as_secs() > 1 { + if most_recent_rate_limit.elapsed().as_secs() > 10 { warn!( - "rate limit reached for event creation (sleep for {:?})", + "rate limit reached for event creation (sleep for {:?}) (suppressing future messages for 10 seconds)", wait_for ); // reset last rate limit message @@ -342,7 +464,6 @@ pub async fn db_writer( } } } - conn.close().ok(); info!("database connection closed"); Ok(()) }) @@ -355,7 +476,7 @@ pub fn db_version(conn: &mut Connection) -> Result<usize> { } /// Persist an event to the database. -pub fn write_event(conn: &mut Connection, e: &Event) -> Result<usize> { +pub fn write_event(conn: &mut PooledConnection, e: &Event) -> Result<usize> { // start transaction let tx = conn.transaction()?; // get relevant fields from event and convert to blobs. @@ -402,7 +523,11 @@ pub fn write_event(conn: &mut Connection, e: &Event) -> Result<usize> { params![ev_id, hex::decode(&e.pubkey).ok(), e.created_at], )?; if update_count > 0 { - info!("hid {} older metadata events", update_count); + info!( + "hid {} older metadata events for author {:?}", + update_count, + e.get_author_prefix() + ); } } // if this event is for a contact update, hide every other kind=3 @@ -413,7 +538,11 @@ pub fn write_event(conn: &mut Connection, e: &Event) -> Result<usize> { params![ev_id, hex::decode(&e.pubkey).ok(), e.created_at], )?; if update_count > 0 { - info!("hid {} older contact events", update_count); + info!( + "hid {} older contact events for author {:?}", + update_count, + e.get_author_prefix() + ); } } tx.commit()?; @@ -666,7 +795,7 @@ fn query_from_sub(sub: &Subscription) -> (String, Vec<Box<dyn ToSql>>) { /// query is immediately aborted. pub async fn db_query( sub: Subscription, - conn: r2d2::PooledConnection<r2d2_sqlite::SqliteConnectionManager>, + conn: PooledConnection, query_tx: tokio::sync::mpsc::Sender<QueryResult>, mut abandon_query_rx: tokio::sync::oneshot::Receiver<()>, ) { diff --git a/src/error.rs b/src/error.rs @@ -40,8 +40,41 @@ pub enum Error { ConfigError(config::ConfigError), #[error("Data directory does not exist")] DatabaseDirError, + #[error("Database Connection Pool Error")] + DatabasePoolError(r2d2::Error), #[error("Custom Error : {0}")] CustomError(String), + #[error("Task join error")] + JoinError, + #[error("Hyper Client error")] + HyperError(hyper::Error), + #[error("Unknown/Undocumented")] + UnknownError, +} + +//impl From<Box<dyn std::error::Error>> for Error { +// fn from(e: Box<dyn std::error::Error>) -> Self { +// Error::CustomError("error".to_owned()) +// } +//} + +impl From<hyper::Error> for Error { + fn from(h: hyper::Error) -> Self { + Error::HyperError(h) + } +} + +impl From<r2d2::Error> for Error { + fn from(d: r2d2::Error) -> Self { + Error::DatabasePoolError(d) + } +} + +impl From<tokio::task::JoinError> for Error { + /// Wrap SQL error + fn from(_j: tokio::task::JoinError) -> Self { + Error::JoinError + } } impl From<rusqlite::Error> for Error { diff --git a/src/event.rs b/src/event.rs @@ -2,6 +2,7 @@ use crate::config; use crate::error::Error::*; use crate::error::Result; +use crate::nip05; use bitcoin_hashes::{sha256, Hash}; use lazy_static::lazy_static; use log::*; @@ -71,7 +72,7 @@ impl From<EventCmd> for Result<Event> { } /// Seconds since 1970 -fn unix_time() -> u64 { +pub fn unix_time() -> u64 { SystemTime::now() .duration_since(SystemTime::UNIX_EPOCH) .map(|x| x.as_secs()) @@ -79,6 +80,25 @@ fn unix_time() -> u64 { } impl Event { + pub fn is_kind_metadata(&self) -> bool { + self.kind == 0 + } + + /// Pull a NIP-05 Name out of the event, if one exists + pub fn get_nip05_addr(&self) -> Option<nip05::Nip05Name> { + if self.is_kind_metadata() { + // very quick check if we should attempt to parse this json + if self.content.contains("\"nip05\"") { + // Parse into JSON + let md_parsed: Value = serde_json::from_str(&self.content).ok()?; + let md_map = md_parsed.as_object()?; + let nip05_str = md_map.get("nip05")?.as_str()?; + return nip05::Nip05Name::try_from(nip05_str).ok(); + } + } + None + } + /// Build an event tag index fn build_index(&mut self) { // if there are no tags; just leave the index as None @@ -107,6 +127,9 @@ impl Event { pub fn get_event_id_prefix(&self) -> String { self.id.chars().take(8).collect() } + pub fn get_author_prefix(&self) -> String { + self.pubkey.chars().take(8).collect() + } /// Check if this event has a valid signature. fn is_valid(&self) -> bool { @@ -133,7 +156,7 @@ impl Event { // * serialize with no spaces/newlines let c_opt = self.to_canonical(); if c_opt.is_none() { - info!("event could not be canonicalized"); + debug!("event could not be canonicalized"); return false; } let c = c_opt.unwrap(); @@ -142,6 +165,7 @@ impl Event { let hex_digest = format!("{:x}", digest); // * ensure the id matches the computed sha256sum. if self.id != hex_digest { + debug!("event id does not match digest"); return false; } // * validate the message digest (sig) using the pubkey & computed sha256 message hash. @@ -152,11 +176,11 @@ impl Event { let verify = SECP.verify_schnorr(&sig, &msg, &pubkey); matches!(verify, Ok(())) } else { - info!("Client sent malformed pubkey"); + debug!("Client sent malformed pubkey"); false } } else { - warn!("Error converting digest to secp256k1 message"); + info!("Error converting digest to secp256k1 message"); false } } diff --git a/src/info.rs b/src/info.rs @@ -2,7 +2,7 @@ use crate::config; /// Relay Info use serde::{Deserialize, Serialize}; -const CARGO_PKG_VERSION: Option<&'static str> = option_env!("CARGO_PKG_VERSION"); +pub const CARGO_PKG_VERSION: Option<&'static str> = option_env!("CARGO_PKG_VERSION"); #[derive(Debug, Serialize, Deserialize)] #[allow(unused)] diff --git a/src/lib.rs b/src/lib.rs @@ -5,6 +5,7 @@ pub mod db; pub mod error; pub mod event; pub mod info; +pub mod nip05; pub mod protostream; pub mod subscription; pub mod tags; diff --git a/src/main.rs b/src/main.rs @@ -15,6 +15,7 @@ use nostr_rs_relay::db; use nostr_rs_relay::error::{Error, Result}; use nostr_rs_relay::event::Event; use nostr_rs_relay::info::RelayInfo; +use nostr_rs_relay::nip05; use nostr_rs_relay::protostream; use nostr_rs_relay::protostream::NostrMessage::*; use nostr_rs_relay::protostream::NostrResponse::*; @@ -24,8 +25,7 @@ use std::env; use std::net::SocketAddr; use std::path::Path; use tokio::runtime::Builder; -use tokio::sync::broadcast; -use tokio::sync::broadcast::{Receiver, Sender}; +use tokio::sync::broadcast::{self, Receiver, Sender}; use tokio::sync::mpsc; use tokio::sync::oneshot; use tokio_tungstenite::WebSocketStream; @@ -171,21 +171,45 @@ fn main() -> Result<(), Error> { *settings = c; } - let config = config::SETTINGS.read().unwrap(); + let settings = config::SETTINGS.read().unwrap(); + trace!("Config: {:?}", settings); // do some config validation. - if !Path::new(&config.database.data_directory).is_dir() { + if !Path::new(&settings.database.data_directory).is_dir() { error!("Database directory does not exist"); return Err(Error::DatabaseDirError); } - trace!("config: {:?}", config); - let addr = format!("{}:{}", config.network.address.trim(), config.network.port); + let addr = format!( + "{}:{}", + settings.network.address.trim(), + settings.network.port + ); let socket_addr = addr.parse().expect("listening address not valid"); - if let Some(addr_whitelist) = &config.authorization.pubkey_whitelist { + // address whitelisting settings + if let Some(addr_whitelist) = &settings.authorization.pubkey_whitelist { info!( "Event publishing restricted to {} pubkey(s)", addr_whitelist.len() ); } + // check if NIP-05 enforced user verification is on + if settings.verified_users.is_active() { + info!( + "NIP-05 user verification mode:{:?}", + settings.verified_users.mode + ); + if let Some(d) = settings.verified_users.verify_update_duration() { + info!("NIP-05 check user verification every: {:?}", d); + } + if let Some(d) = settings.verified_users.verify_expiration_duration() { + info!("NIP-05 user verification expires after: {:?}", d); + } + if let Some(wl) = &settings.verified_users.domain_whitelist { + info!("NIP-05 domain whitelist: {:?}", wl); + } + if let Some(bl) = &settings.verified_users.domain_blacklist { + info!("NIP-05 domain blacklist: {:?}", bl); + } + } // configure tokio runtime let rt = Builder::new_multi_thread() .enable_all() @@ -206,12 +230,38 @@ fn main() -> Result<(), Error> { let (event_tx, event_rx) = mpsc::channel::<Event>(settings.limits.event_persist_buffer); // establish a channel for letting all threads now about a // requested server shutdown. - let (invoke_shutdown, _) = broadcast::channel::<()>(1); + let (invoke_shutdown, shutdown_listen) = broadcast::channel::<()>(1); + // create a channel for sending any new metadata event. These + // will get processed relatively slowly (a potentially + // multi-second blocking HTTP call) on a single thread, so we + // buffer requests on the channel. No harm in dropping events + // here, since we are protecting against DoS. This can make + // it difficult to setup initial metadata in bulk, since + // overwhelming this will drop events and won't register + // metadata events. + let (metadata_tx, metadata_rx) = broadcast::channel::<Event>(4096); // start the database writer thread. Give it a channel for // writing events, and for publishing events that have been // written (to all connected clients). - db::db_writer(event_rx, bcast_tx.clone(), invoke_shutdown.subscribe()).await; + db::db_writer( + event_rx, + bcast_tx.clone(), + metadata_tx.clone(), + shutdown_listen, + ) + .await; info!("db writer created"); + + // create a nip-05 verifier thread + let verifier_opt = nip05::Verifier::new(metadata_rx, bcast_tx.clone()); + if let Ok(mut v) = verifier_opt { + if settings.verified_users.is_active() { + tokio::task::spawn(async move { + info!("starting up NIP-05 verifier..."); + v.run().await; + }); + } + } // // listen for ctrl-c interruupts let ctrl_c_shutdown = invoke_shutdown.clone(); tokio::spawn(async move { @@ -220,7 +270,14 @@ fn main() -> Result<(), Error> { ctrl_c_shutdown.send(()).ok(); }); // build a connection pool for sqlite connections - let pool = db::build_read_pool(); + let pool = db::build_pool( + "client query", + rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY + | rusqlite::OpenFlags::SQLITE_OPEN_SHARED_CACHE, + settings.database.min_conn, + settings.database.max_conn, + true, + ); // A `Service` is needed for every connection, so this // creates one from our `handle_request` function. let make_svc = make_service_fn(|conn: &AddrStream| { @@ -285,7 +342,7 @@ async fn nostr_server( // and how many it received from queries. let mut client_published_event_count: usize = 0; let mut client_received_event_count: usize = 0; - info!("new connection for client: {}", cid); + info!("new connection for client: {:?}", cid); loop { tokio::select! { _ = shutdown.recv() => { @@ -298,6 +355,7 @@ async fn nostr_server( client_received_event_count += 1; nostr_stream.send(res).await.ok(); }, + // TODO: consider logging the LaggedRecv error Ok(global_event) = bcast_rx.recv() => { // an event has been broadcast to all clients // first check if there is a subscription for this event. @@ -306,14 +364,14 @@ async fn nostr_server( // TODO: serialize at broadcast time, instead of // once for each consumer. if let Ok(event_str) = serde_json::to_string(&global_event) { - debug!("sub match: client: {}, sub: {}, event: {}", + debug!("sub match: client: {:?}, sub: {:?}, event: {:?}", cid, s, global_event.get_event_id_prefix()); // create an event response and send it let res = EventRes(s.to_owned(),event_str); nostr_stream.send(res).await.ok(); } else { - warn!("could not convert event to string"); + warn!("could not serialize event {:?}", global_event.get_event_id_prefix()); } } }, @@ -327,13 +385,19 @@ async fn nostr_server( match parsed { Ok(e) => { let id_prefix:String = e.id.chars().take(8).collect(); - debug!("successfully parsed/validated event: {} from client: {}", id_prefix, cid); + debug!("successfully parsed/validated event: {:?} from client: {:?}", id_prefix, cid); + // TODO: consider moving some/all + // authorization checks here, instead + // of the DB module, so we can send a + // proper NOTICE back to the client if + // they are unable to write. + // Write this to the database event_tx.send(e.clone()).await.ok(); client_published_event_count += 1; }, Err(_) => { - info!("client {} sent an invalid event", cid); + info!("client {:?} sent an invalid event", cid); nostr_stream.send(NoticeRes("event was invalid".to_owned())).await.ok(); } } @@ -385,19 +449,19 @@ async fn nostr_server( } }, None => { - debug!("normal websocket close from client: {}",cid); + debug!("normal websocket close from client: {:?}",cid); break; }, Some(Err(Error::ConnError)) => { - debug!("got connection close/error, disconnecting client: {}",cid); + debug!("got connection close/error, disconnecting client: {:?}",cid); break; } Some(Err(Error::EventMaxLengthError(s))) => { - info!("client {} sent event larger ({} bytes) than max size", cid, s); + info!("client {:?} sent event larger ({} bytes) than max size", cid, s); nostr_stream.send(NoticeRes("event exceeded max size".to_owned())).await.ok(); }, Some(Err(e)) => { - info!("got non-fatal error from client: {}, error: {:?}", cid, e); + info!("got non-fatal error from client: {:?}, error: {:?}", cid, e); }, } }, @@ -408,7 +472,7 @@ async fn nostr_server( stop_tx.send(()).ok(); } info!( - "stopping connection for client: {} (client sent {} event(s), received {})", + "stopping connection for client: {:?} (client sent {} event(s), received {})", cid, client_published_event_count, client_received_event_count ); } diff --git a/src/nip05.rs b/src/nip05.rs @@ -0,0 +1,812 @@ +//! User Verification Through NIP-05 +use crate::config::SETTINGS; +use crate::db; +use crate::error::{Error, Result}; +use crate::event::{unix_time, Event}; +use hyper::body::HttpBody; +use hyper::client::connect::HttpConnector; +use hyper::Client; +use hyper_tls::HttpsConnector; +use log::*; +use rand::Rng; +use rusqlite::params; +use std::time::Duration; +use std::time::Instant; +use std::time::SystemTime; +use tokio::time::Interval; + +/// NIP-05 verifier +pub struct Verifier { + /// Metadata events for us to inspect + metadata_rx: tokio::sync::broadcast::Receiver<Event>, + /// Newly validated events get written and then broadcast on this channel to subscribers + event_tx: tokio::sync::broadcast::Sender<Event>, + /// SQLite read query pool + read_pool: db::SqlitePool, + /// SQLite write query pool + write_pool: db::SqlitePool, + /// HTTP client + client: hyper::Client<HttpsConnector<HttpConnector>, hyper::Body>, + /// After all accounts are updated, wait this long before checking again. + wait_after_finish: Duration, + /// Minimum amount of time between HTTP queries + http_wait_duration: Duration, + /// Interval for updating verification records + reverify_interval: Interval, +} + +/// A NIP-05 identifier is a local part and domain. +#[derive(PartialEq, Debug, Clone)] +pub struct Nip05Name { + local: String, + domain: String, +} + +impl Nip05Name { + /// Does this name represent the entire domain? + pub fn is_domain_only(&self) -> bool { + self.local == "_" + } + + /// Determine the URL to query for verification + fn to_url(&self) -> Option<http::Uri> { + format!( + "https://{}/.well-known/nostr.json?name={}", + self.domain, self.local + ) + .parse::<http::Uri>() + .ok() + } +} + +// Parsing Nip05Names from strings +impl std::convert::TryFrom<&str> for Nip05Name { + type Error = Error; + fn try_from(inet: &str) -> Result<Self, Self::Error> { + // break full name at the @ boundary. + let components: Vec<&str> = inet.split('@').collect(); + if components.len() != 2 { + Err(Error::CustomError("too many/few components".to_owned())) + } else { + // check if local name is valid + let local = components[0]; + let domain = components[1]; + if local + .chars() + .all(|x| x.is_alphanumeric() || x == '_' || x == '-' || x == '.') + { + if domain + .chars() + .all(|x| x.is_alphanumeric() || x == '-' || x == '.') + { + Ok(Nip05Name { + local: local.to_owned(), + domain: domain.to_owned(), + }) + } else { + Err(Error::CustomError( + "invalid character in domain part".to_owned(), + )) + } + } else { + Err(Error::CustomError( + "invalid character in local part".to_owned(), + )) + } + } + } +} + +impl std::fmt::Display for Nip05Name { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}@{}", self.local, self.domain) + } +} + +// Current time, with a slight foward jitter in seconds +fn now_jitter(sec: u64) -> u64 { + // random time between now, and 10min in future. + let mut rng = rand::thread_rng(); + let jitter_amount = rng.gen_range(0..sec); + let now = unix_time(); + now.saturating_add(jitter_amount) +} + +/// Check if the specified username and address are present and match in this response body +fn body_contains_user(username: &str, address: &str, bytes: hyper::body::Bytes) -> Result<bool> { + // convert the body into json + let body: serde_json::Value = serde_json::from_slice(&bytes)?; + // ensure we have a names object. + let names_map = body + .as_object() + .and_then(|x| x.get("names")) + .and_then(|x| x.as_object()) + .ok_or_else(|| Error::CustomError("not a map".to_owned()))?; + // get the pubkey for the requested user + let check_name = names_map.get(username).and_then(|x| x.as_str()); + // ensure the address is a match + Ok(check_name.map(|x| x == address).unwrap_or(false)) +} + +impl Verifier { + pub fn new( + metadata_rx: tokio::sync::broadcast::Receiver<Event>, + event_tx: tokio::sync::broadcast::Sender<Event>, + ) -> Result<Self> { + info!("creating NIP-05 verifier"); + // build a database connection for reading and writing. + let write_pool = db::build_pool( + "nip05 writer", + rusqlite::OpenFlags::SQLITE_OPEN_READ_WRITE, + 1, // min conns + 4, // max conns + true, // wait for DB + ); + let read_pool = db::build_pool( + "nip05 reader", + rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY, + 1, // min conns + 8, // max conns + true, // wait for DB + ); + // setup hyper client + let https = HttpsConnector::new(); + let client = Client::builder().build::<_, hyper::Body>(https); + + // After all accounts have been re-verified, don't check again + // for this long. + let wait_after_finish = Duration::from_secs(60 * 10); + // when we have an active queue of accounts to validate, we + // will wait this duration between HTTP requests. + let http_wait_duration = Duration::from_secs(1); + // setup initial interval for re-verification. If we find + // there is no work to be done, it will be reset to a longer + // duration. + let reverify_interval = tokio::time::interval(http_wait_duration); + Ok(Verifier { + metadata_rx, + event_tx, + read_pool, + write_pool, + client, + wait_after_finish, + http_wait_duration, + reverify_interval, + }) + } + + /// Perform web verification against a NIP-05 name and address. + pub async fn get_web_verification( + &mut self, + nip: &Nip05Name, + pubkey: &str, + ) -> UserWebVerificationStatus { + self.get_web_verification_res(nip, pubkey) + .await + .unwrap_or(UserWebVerificationStatus::Unknown) + } + + /// Perform web verification against an `Event` (must be metadata). + pub async fn get_web_verification_from_event( + &mut self, + e: &Event, + ) -> UserWebVerificationStatus { + let nip_parse = e.get_nip05_addr(); + if let Some(nip) = nip_parse { + self.get_web_verification_res(&nip, &e.pubkey) + .await + .unwrap_or(UserWebVerificationStatus::Unknown) + } else { + UserWebVerificationStatus::Unknown + } + } + + /// Perform web verification, with a `Result` return. + async fn get_web_verification_res( + &mut self, + nip: &Nip05Name, + pubkey: &str, + ) -> Result<UserWebVerificationStatus> { + // determine if this domain should be checked + if !is_domain_allowed(&nip.domain) { + return Ok(UserWebVerificationStatus::DomainNotAllowed); + } + let url = nip + .to_url() + .ok_or_else(|| Error::CustomError("invalid NIP-05 URL".to_owned()))?; + let req = hyper::Request::builder() + .method(hyper::Method::GET) + .uri(url) + .header("Accept", "application/json") + .header( + "User-Agent", + format!( + "nostr-rs-relay/{} NIP-05 Verifier", + crate::info::CARGO_PKG_VERSION.unwrap() + ), + ) + .body(hyper::Body::empty()) + .expect("request builder"); + + let response_fut = self.client.request(req); + + // HTTP request with timeout + match tokio::time::timeout(Duration::from_secs(5), response_fut).await { + Ok(response_res) => { + let response = response_res?; + // limit size of verification document to 1MB. + const MAX_ALLOWED_RESPONSE_SIZE: u64 = 1024 * 1024; + // determine content length from response + let response_content_length = match response.body().size_hint().upper() { + Some(v) => v, + None => MAX_ALLOWED_RESPONSE_SIZE + 1, // reject missing content length + }; + // TODO: test how hyper handles the client providing an inaccurate content-length. + if response_content_length <= MAX_ALLOWED_RESPONSE_SIZE { + let (parts, body) = response.into_parts(); + // TODO: consider redirects + if parts.status == http::StatusCode::OK { + // parse body, determine if the username / key / address is present + let body_bytes = hyper::body::to_bytes(body).await?; + let body_matches = body_contains_user(&nip.local, pubkey, body_bytes)?; + if body_matches { + return Ok(UserWebVerificationStatus::Verified); + } else { + // successful response, parsed as a nip-05 + // document, but this name/pubkey was not + // present. + return Ok(UserWebVerificationStatus::Unverified); + } + } + } else { + info!( + "content length missing or exceeded limits for account: {:?}", + nip.to_string() + ); + } + } + Err(_) => { + info!("timeout verifying account {:?}", nip); + return Ok(UserWebVerificationStatus::Unknown); + } + } + Ok(UserWebVerificationStatus::Unknown) + } + + /// Perform NIP-05 verifier tasks. + pub async fn run(&mut self) { + // use this to schedule periodic re-validation tasks + // run a loop, restarting on failure + loop { + let res = self.run_internal().await; + if let Err(e) = res { + info!("error in verifier: {:?}", e); + } + } + } + + /// Internal select loop for performing verification + async fn run_internal(&mut self) -> Result<()> { + tokio::select! { + m = self.metadata_rx.recv() => { + match m { + Ok(e) => { + if let Some(naddr) = e.get_nip05_addr() { + info!("got metadata event for ({:?},{:?})", naddr.to_string() ,e.get_author_prefix()); + // Process a new author, checking if they are verified: + let check_verified = get_latest_user_verification(self.read_pool.get().expect("could not get connection"), &e.pubkey).await; + // ensure the event we got is more recent than the one we have, otherwise we can ignore it. + if let Ok(last_check) = check_verified { + if e.created_at <= last_check.event_created { + // this metadata is from the same author as an existing verification. + // it is older than what we have, so we can ignore it. + debug!("received older metadata event for author {:?}", e.get_author_prefix()); + return Ok(()); + } + } + // old, or no existing record for this user. In either case, we just create a new one. + let start = Instant::now(); + let v = self.get_web_verification_from_event(&e).await; + info!( + "checked name {:?}, result: {:?}, in: {:?}", + naddr.to_string(), + v, + start.elapsed() + ); + // sleep to limit how frequently we make HTTP requests for new metadata events. This should limit us to 4 req/sec. + tokio::time::sleep(Duration::from_millis(250)).await; + // if this user was verified, we need to write the + // record, persist the event, and broadcast. + if let UserWebVerificationStatus::Verified = v { + self.create_new_verified_user(&naddr.to_string(), &e).await?; + } + } + }, + Err(tokio::sync::broadcast::error::RecvError::Lagged(c)) => { + warn!("incoming metadata events overwhelmed buffer, {} events dropped",c); + } + Err(tokio::sync::broadcast::error::RecvError::Closed) => { + info!("metadata broadcast channel closed"); + } + } + }, + _ = self.reverify_interval.tick() => { + // check and see if there is an old account that needs + // to be reverified + self.do_reverify().await?; + }, + } + Ok(()) + } + + /// Reverify the oldest user verification record. + async fn do_reverify(&mut self) -> Result<()> { + let reverify_setting; + let max_failures; + { + // this block prevents a read handle to settings being + // captured by the async DB call (guard is not Send) + let settings = SETTINGS.read().unwrap(); + reverify_setting = settings.verified_users.verify_update_frequency_duration; + max_failures = settings.verified_users.max_consecutive_failures; + } + // get from settings, but default to 6hrs between re-checking an account + let reverify_dur = reverify_setting.unwrap_or_else(|| Duration::from_secs(60 * 60 * 6)); + // find all verification records that have success or failure OLDER than the reverify_dur. + let now = SystemTime::now(); + let earliest = now - reverify_dur; + let earliest_epoch = earliest + .duration_since(SystemTime::UNIX_EPOCH) + .map(|x| x.as_secs()) + .unwrap_or(0); + let vr = get_oldest_user_verification(self.read_pool.get()?, earliest_epoch).await; + match vr { + Ok(ref v) => { + let new_status = self.get_web_verification(&v.name, &v.address).await; + match new_status { + UserWebVerificationStatus::Verified => { + // freshly verified account, update the + // timestamp. + self.update_verification_record(self.write_pool.get()?, v) + .await?; + } + UserWebVerificationStatus::DomainNotAllowed + | UserWebVerificationStatus::Unknown => { + // server may be offline, or temporarily + // blocked by the config file. Note the + // failure so we can process something + // else. + + // have we had enough failures to give up? + if v.failure_count >= max_failures as u64 { + info!( + "giving up on verifying {:?} after {} failures", + v.name, v.failure_count + ); + self.delete_verification_record(self.write_pool.get()?, v) + .await?; + } else { + // record normal failure, incrementing failure count + self.fail_verification_record(self.write_pool.get()?, v) + .await?; + } + } + UserWebVerificationStatus::Unverified => { + // domain has removed the verification, drop + // the record on our side. + self.delete_verification_record(self.write_pool.get()?, v) + .await?; + } + } + } + Err(Error::SqlError(rusqlite::Error::QueryReturnedNoRows)) => { + // No users need verification. Reset the interval to + // the next verification attempt. + let start = tokio::time::Instant::now() + self.wait_after_finish; + self.reverify_interval = tokio::time::interval_at(start, self.http_wait_duration); + } + Err(ref e) => { + warn!( + "Error when checking for NIP-05 verification records: {:?}", + e + ); + } + } + Ok(()) + } + + /// Reset the verification timestamp on a VerificationRecord + pub async fn update_verification_record( + &mut self, + mut conn: db::PooledConnection, + vr: &VerificationRecord, + ) -> Result<()> { + let vr_id = vr.rowid; + let vr_str = vr.to_string(); + tokio::task::spawn_blocking(move || { + // add some jitter to the verification to prevent everything from stacking up together. + let verif_time = now_jitter(600); + let tx = conn.transaction()?; + { + // update verification time and reset any failure count + let query = + "UPDATE user_verification SET verified_at=?, failure_count=0 WHERE id=?"; + let mut stmt = tx.prepare(query)?; + stmt.execute(params![verif_time, vr_id])?; + } + tx.commit()?; + info!("verification updated for {}", vr_str); + let ok: Result<()> = Ok(()); + ok + }) + .await? + } + /// Reset the failure timestamp on a VerificationRecord + pub async fn fail_verification_record( + &mut self, + mut conn: db::PooledConnection, + vr: &VerificationRecord, + ) -> Result<()> { + let vr_id = vr.rowid; + let vr_str = vr.to_string(); + let fail_count = vr.failure_count.saturating_add(1); + tokio::task::spawn_blocking(move || { + // add some jitter to the verification to prevent everything from stacking up together. + let fail_time = now_jitter(600); + let tx = conn.transaction()?; + { + let query = "UPDATE user_verification SET failed_at=?, failure_count=? WHERE id=?"; + let mut stmt = tx.prepare(query)?; + stmt.execute(params![fail_time, fail_count, vr_id])?; + } + tx.commit()?; + info!("verification failed for {}", vr_str); + let ok: Result<()> = Ok(()); + ok + }) + .await? + } + /// Delete a VerificationRecord that is no longer valid + pub async fn delete_verification_record( + &mut self, + mut conn: db::PooledConnection, + vr: &VerificationRecord, + ) -> Result<()> { + let vr_id = vr.rowid; + let vr_str = vr.to_string(); + tokio::task::spawn_blocking(move || { + let tx = conn.transaction()?; + { + let query = "DELETE FROM user_verification WHERE id=?;"; + let mut stmt = tx.prepare(query)?; + stmt.execute(params![vr_id])?; + } + tx.commit()?; + info!("verification rescinded for {}", vr_str); + let ok: Result<()> = Ok(()); + ok + }) + .await? + } + + /// Persist an event, create a verification record, and broadcast. + // TODO: have more event-writing logic handled in the db module. + // Right now, these events avoid the rate limit. That is + // acceptable since as soon as the user is registered, this path + // is no longer used. + // TODO: refactor these into spawn_blocking + // calls to get them off the async executors. + async fn create_new_verified_user(&mut self, name: &str, event: &Event) -> Result<()> { + let start = Instant::now(); + // we should only do this if we are enabled. if we are + // disabled/passive, the event has already been persisted. + let should_write_event; + { + let settings = SETTINGS.read().unwrap(); + should_write_event = settings.verified_users.is_enabled() + } + if should_write_event { + match db::write_event(&mut self.write_pool.get()?, event) { + Ok(updated) => { + if updated != 0 { + info!( + "persisted event: {:?} in {:?}", + event.get_event_id_prefix(), + start.elapsed() + ); + self.event_tx.send(event.clone()).ok(); + } + } + Err(err) => { + warn!("event insert failed: {:?}", err); + if let Error::SqlError(r) = err { + warn!("because: : {:?}", r); + } + } + } + } + // write the verification record + save_verification_record(self.write_pool.get()?, event, name).await?; + Ok(()) + } +} + +/// Result of checking user's verification status against DNS/HTTP. +#[derive(PartialEq, Debug, Clone)] +pub enum UserWebVerificationStatus { + Verified, // user is verified, as of now. + DomainNotAllowed, // domain blacklist or whitelist denied us from attempting a verification + Unknown, // user's status could not be determined (timeout, server error) + Unverified, // user's status is not verified (successful check, name / addr do not match) +} + +/// A NIP-05 verification record. +#[derive(PartialEq, Debug, Clone)] +// Basic information for a verification event. Gives us all we need to assert a NIP-05 address is good. +pub struct VerificationRecord { + pub rowid: u64, // database row for this verification event + pub name: Nip05Name, // address being verified + pub address: String, // pubkey + pub event: String, // event ID hash providing the verification + pub event_created: u64, // when the metadata event was published + pub last_success: Option<u64>, // the most recent time a verification was provided. None if verification under this name has never succeeded. + pub last_failure: Option<u64>, // the most recent time verification was attempted, but could not be completed. + pub failure_count: u64, // how many consecutive failures have been observed. +} + +/// Check with settings to determine if a given domain is allowed to +/// publish. +pub fn is_domain_allowed(domain: &str) -> bool { + let settings = SETTINGS.read().unwrap(); + // if there is a whitelist, domain must be present in it. + if let Some(wl) = &settings.verified_users.domain_whitelist { + // workaround for Vec contains not accepting &str + return wl.iter().any(|x| x == domain); + } + // otherwise, check that user is not in the blacklist + if let Some(bl) = &settings.verified_users.domain_blacklist { + return !bl.iter().any(|x| x == domain); + } + true +} + +impl VerificationRecord { + /// Check if the record is recent enough to be considered valid, + /// and the domain is allowed. + pub fn is_valid(&self) -> bool { + let settings = SETTINGS.read().unwrap(); + // how long a verification record is good for + let nip05_expiration = &settings.verified_users.verify_expiration_duration; + if let Some(e) = nip05_expiration { + if !self.is_current(e) { + return false; + } + } + // check domains + is_domain_allowed(&self.name.domain) + } + + /// Check if this record has been validated since the given + /// duration. + fn is_current(&self, d: &Duration) -> bool { + match self.last_success { + Some(s) => { + // current time - duration + let now = SystemTime::now(); + let cutoff = now - *d; + let cutoff_epoch = cutoff + .duration_since(SystemTime::UNIX_EPOCH) + .map(|x| x.as_secs()) + .unwrap_or(0); + s > cutoff_epoch + } + None => false, + } + } +} + +impl std::fmt::Display for VerificationRecord { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "({:?},{:?})", + self.name.to_string(), + self.address.chars().take(8).collect::<String>() + ) + } +} + +/// Create a new verification record based on an event +pub async fn save_verification_record( + mut conn: db::PooledConnection, + event: &Event, + name: &str, +) -> Result<()> { + let e = hex::decode(&event.id).ok(); + let n = name.to_owned(); + let a_prefix = event.get_author_prefix(); + tokio::task::spawn_blocking(move || { + let tx = conn.transaction()?; + { + // if we create a /new/ one, we should get rid of any old ones. or group the new ones by name and only consider the latest. + let query = "INSERT INTO user_verification (metadata_event, name, verified_at) VALUES ((SELECT id from event WHERE event_hash=?), ?, strftime('%s','now'));"; + let mut stmt = tx.prepare(query)?; + stmt.execute(params![e, n])?; + // get the row ID + let v_id = tx.last_insert_rowid(); + // delete everything else by this name + let del_query = "DELETE FROM user_verification WHERE name = ? AND id != ?;"; + let mut del_stmt = tx.prepare(del_query)?; + let count = del_stmt.execute(params![n,v_id])?; + if count > 0 { + info!("removed {} old verification records for ({:?},{:?})", count, n, a_prefix); + } + } + tx.commit()?; + info!("saved new verification record for ({:?},{:?})", n, a_prefix); + let ok: Result<()> = Ok(()); + ok + }).await? +} + +/// Retrieve the most recent verification record for a given pubkey +// Important, this is the most recent verification /of the most recent metadata event/. +pub async fn get_latest_user_verification( + conn: db::PooledConnection, + pubkey: &str, +) -> Result<VerificationRecord> { + let p = pubkey.to_owned(); + tokio::task::spawn_blocking(move || query_latest_user_verification(conn, p)).await? +} + +pub fn query_latest_user_verification( + mut conn: db::PooledConnection, + pubkey: String, +) -> Result<VerificationRecord> { + let tx = conn.transaction()?; + let query = "SELECT v.id, v.name, e.event_hash, e.created_at, v.verified_at, v.failed_at, v.failure_count FROM user_verification v LEFT JOIN event e ON e.id=v.metadata_event WHERE e.author=? ORDER BY e.created_at DESC, v.verified_at DESC, v.failed_at DESC LIMIT 1;"; + let mut stmt = tx.prepare_cached(query)?; + let fields = stmt.query_row(params![hex::decode(&pubkey).ok()], |r| { + let rowid: u64 = r.get(0)?; + let rowname: String = r.get(1)?; + let eventid: Vec<u8> = r.get(2)?; + let created_at: u64 = r.get(3)?; + // create a tuple since we can't throw non-rusqlite errors in this closure + Ok(( + rowid, + rowname, + eventid, + created_at, + r.get(4).ok(), + r.get(5).ok(), + r.get(6)?, + )) + })?; + Ok(VerificationRecord { + rowid: fields.0, + name: Nip05Name::try_from(&fields.1[..])?, + address: pubkey, + event: hex::encode(fields.2), + event_created: fields.3, + last_success: fields.4, + last_failure: fields.5, + failure_count: fields.6, + }) +} + +/// Retrieve the oldest user verification (async) +pub async fn get_oldest_user_verification( + conn: db::PooledConnection, + earliest: u64, +) -> Result<VerificationRecord> { + let res = + tokio::task::spawn_blocking(move || query_oldest_user_verification(conn, earliest)).await?; + res +} + +pub fn query_oldest_user_verification( + mut conn: db::PooledConnection, + earliest: u64, +) -> Result<VerificationRecord> { + let tx = conn.transaction()?; + let query = "SELECT v.id, v.name, e.event_hash, e.author, e.created_at, v.verified_at, v.failed_at, v.failure_count FROM user_verification v LEFT JOIN event e ON e.id=v.metadata_event WHERE (v.verified_at < ? OR v.verified_at IS NULL) AND (v.failed_at < ? OR v.failed_at IS NULL) ORDER BY v.verified_at ASC, v.failed_at ASC LIMIT 1;"; + let mut stmt = tx.prepare_cached(query)?; + let fields = stmt.query_row(params![earliest, earliest], |r| { + let rowid: u64 = r.get(0)?; + let rowname: String = r.get(1)?; + let eventid: Vec<u8> = r.get(2)?; + let pubkey: Vec<u8> = r.get(3)?; + let created_at: u64 = r.get(4)?; + // create a tuple since we can't throw non-rusqlite errors in this closure + Ok(( + rowid, + rowname, + eventid, + pubkey, + created_at, + r.get(5).ok(), + r.get(6).ok(), + r.get(7)?, + )) + })?; + let vr = VerificationRecord { + rowid: fields.0, + name: Nip05Name::try_from(&fields.1[..])?, + address: hex::encode(fields.3), + event: hex::encode(fields.2), + event_created: fields.4, + last_success: fields.5, + last_failure: fields.6, + failure_count: fields.7, + }; + Ok(vr) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn local_from_inet() { + let addr = "bob@example.com"; + let parsed = Nip05Name::try_from(addr); + assert!(!parsed.is_err()); + let v = parsed.unwrap(); + assert_eq!(v.local, "bob"); + assert_eq!(v.domain, "example.com"); + } + + #[test] + fn not_enough_sep() { + let addr = "bob_example.com"; + let parsed = Nip05Name::try_from(addr); + assert!(parsed.is_err()); + } + + #[test] + fn too_many_sep() { + let addr = "foo@bob@example.com"; + let parsed = Nip05Name::try_from(addr); + assert!(parsed.is_err()); + } + + #[test] + fn invalid_local_name() { + // non-permitted ascii chars + assert!(Nip05Name::try_from("foo!@example.com").is_err()); + assert!(Nip05Name::try_from("foo @example.com").is_err()); + assert!(Nip05Name::try_from(" foo@example.com").is_err()); + assert!(Nip05Name::try_from("f oo@example.com").is_err()); + assert!(Nip05Name::try_from("foo<@example.com").is_err()); + // unicode dash + assert!(Nip05Name::try_from("foo‐bar@example.com").is_err()); + // emoji + assert!(Nip05Name::try_from("foo😭bar@example.com").is_err()); + } + #[test] + fn invalid_domain_name() { + // non-permitted ascii chars + assert!(Nip05Name::try_from("foo@examp!e.com").is_err()); + assert!(Nip05Name::try_from("foo@ example.com").is_err()); + assert!(Nip05Name::try_from("foo@exa mple.com").is_err()); + assert!(Nip05Name::try_from("foo@example .com").is_err()); + assert!(Nip05Name::try_from("foo@exa<mple.com").is_err()); + // unicode dash + assert!(Nip05Name::try_from("foobar@exa‐mple.com").is_err()); + // emoji + assert!(Nip05Name::try_from("foobar@ex😭ample.com").is_err()); + } + + #[test] + fn to_url() { + let nip = Nip05Name::try_from("foobar@example.com").unwrap(); + assert_eq!( + nip.to_url(), + Some( + "https://example.com/.well-known/nostr.json?name=foobar" + .parse() + .unwrap() + ) + ); + } +} diff --git a/src/protostream.rs b/src/protostream.rs @@ -81,9 +81,11 @@ impl Stream for NostrStream { Poll::Ready(None) => Poll::Ready(None), Poll::Ready(Some(v)) => match v { Ok(Message::Text(vs)) => Poll::Ready(Some(convert(vs))), - Ok(Message::Ping(_x)) => { - debug!("client ping"); + Ok(Message::Ping(x)) => { + debug!("client ping ({:?})", x); //Pin::new(&mut self.ws_stream).start_send(Message::Pong(x)); + // TODO: restructure this so that Pongs work + //Pin::new(&mut self.ws_stream).write_pending(); //info!("sent pong"); Poll::Pending }